Merge remote-tracking branch 'public-gh/master'

* public-gh/master:
  Default Gemini adapter to yolo mode and add API access prompt note
  fix: remove Cmd+1..9 company-switch shortcut
  fix(ui): prevent IME composition Enter from moving focus in new issue title
  fix(cli): add restart hint after allowed-hostname change
  docs: remove obsolete TODO for CONTRIBUTING.md
  fix: default dangerouslySkipPermissions to true for unattended agents
  fix: route heartbeat cost recording through costService
  Show issue creator in properties sidebar
This commit is contained in:
Dotta
2026-03-12 08:09:06 -05:00
17 changed files with 158 additions and 61 deletions

View File

@@ -248,8 +248,6 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
<!-- TODO: add CONTRIBUTING.md -->
<br/>
## Community

View File

@@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
} else {
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
p.log.message(
pc.dim("Restart the Paperclip server for this change to take effect."),
);
}
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {

View File

@@ -75,6 +75,14 @@ export interface AdapterExecutionResult {
runtimeServices?: AdapterRuntimeServiceReport[];
summary?: string | null;
clearSession?: boolean;
question?: {
prompt: string;
choices: Array<{
key: string;
label: string;
description?: string;
}>;
} | null;
}
export interface AdapterSessionCodec {

View File

@@ -30,7 +30,6 @@ Core fields:
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- promptTemplate (string, optional): run prompt template
- model (string, optional): Gemini model id. Defaults to auto.
- approvalMode (string, optional): "default", "auto_edit", or "yolo" (default: "default")
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
- command (string, optional): defaults to "gemini"
- extraArgs (string[], optional): additional CLI args

View File

@@ -59,6 +59,20 @@ function renderPaperclipEnvNote(env: Record<string, string>): string {
].join("\n");
}
function renderApiAccessNote(env: Record<string, string>): string {
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
return [
"Paperclip API access note:",
"Use run_shell_command with curl to make Paperclip API requests.",
"GET example:",
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`,
"POST/PATCH example:",
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`,
"",
"",
].join("\n");
}
async function resolvePaperclipSkillsDir(): Promise<string | null> {
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
@@ -132,7 +146,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
const command = asString(config.command, "gemini");
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
const sandbox = asBoolean(config.sandbox, false);
const workspaceContext = parseObject(context.paperclipWorkspace);
@@ -250,7 +263,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
const commandNotes = (() => {
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
if (approvalMode !== "default") notes.push(`Added --approval-mode ${approvalMode} for unattended execution.`);
notes.push("Added --approval-mode yolo for unattended execution.");
if (!instructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(
@@ -275,13 +288,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
context,
});
const paperclipEnvNote = renderPaperclipEnvNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
const apiAccessNote = renderApiAccessNote(env);
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
const buildArgs = (resumeSessionId: string | null) => {
const args = ["--output-format", "stream-json"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
args.push("--approval-mode", "yolo");
if (sandbox) {
args.push("--sandbox");
} else {
@@ -398,6 +412,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
question: attempt.parsed.question,
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
};
};

View File

@@ -78,6 +78,7 @@ export function parseGeminiJsonl(stdout: string) {
let errorMessage: string | null = null;
let costUsd: number | null = null;
let resultEvent: Record<string, unknown> | null = null;
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
@@ -98,6 +99,25 @@ export function parseGeminiJsonl(stdout: string) {
if (type === "assistant") {
messages.push(...collectMessageText(event.message));
const messageObj = parseObject(event.message);
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
for (const partRaw of content) {
const part = parseObject(partRaw);
if (asString(part.type, "").trim() === "question") {
question = {
prompt: asString(part.prompt, "").trim(),
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
const choice = parseObject(choiceRaw);
return {
key: asString(choice.key, "").trim(),
label: asString(choice.label, "").trim(),
description: asString(choice.description, "").trim() || undefined,
};
}),
};
break; // only one question per message
}
}
continue;
}
@@ -154,6 +174,7 @@ export function parseGeminiJsonl(stdout: string) {
costUsd,
errorMessage,
resultEvent,
question,
};
}

View File

@@ -67,8 +67,8 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
}
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.dangerouslyBypassSandbox) ac.approvalMode = "yolo";
ac.sandbox = !v.dangerouslyBypassSandbox;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;

View File

@@ -39,6 +39,37 @@ describe("gemini_local parser", () => {
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
expect(parsed.errorMessage).toBe("model access denied");
});
it("extracts structured questions", () => {
const stdout = [
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "output_text", text: "I have a question." },
{
type: "question",
prompt: "Which model?",
choices: [
{ key: "pro", label: "Gemini Pro", description: "Better" },
{ key: "flash", label: "Gemini Flash" },
],
},
],
},
}),
].join("\n");
const parsed = parseGeminiJsonl(stdout);
expect(parsed.summary).toBe("I have a question.");
expect(parsed.question).toEqual({
prompt: "Which model?",
choices: [
{ key: "pro", label: "Gemini Pro", description: "Better" },
{ key: "flash", label: "Gemini Flash", description: undefined },
],
});
});
});
describe("gemini_local stale session detection", () => {

View File

@@ -77,7 +77,6 @@ describe("gemini execute", () => {
command: commandPath,
cwd: workspace,
model: "gemini-2.5-pro",
yolo: true,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
@@ -112,6 +111,51 @@ describe("gemini execute", () => {
);
expect(invocationPrompt).toContain("Paperclip runtime note:");
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
expect(invocationPrompt).toContain("Paperclip API access note:");
expect(invocationPrompt).toContain("run_shell_command");
expect(result.question).toBeNull();
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("always passes --approval-mode yolo", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "gemini");
const capturePath = path.join(root, "capture.json");
await fs.mkdir(workspace, { recursive: true });
await writeFakeGeminiCommand(commandPath);
const previousHome = process.env.HOME;
process.env.HOME = root;
try {
await execute({
runId: "run-yolo",
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: {
command: commandPath,
cwd: workspace,
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
},
context: {},
authToken: "t",
onLog: async () => {},
});
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--approval-mode");
expect(capture.argv).toContain("yolo");
expect(capture.argv).not.toContain("--policy");
expect(capture.argv).not.toContain("--allow-all");
expect(capture.argv).not.toContain("--allow-read");
} finally {
if (previousHome === undefined) {
delete process.env.HOME;

View File

@@ -9,7 +9,6 @@ import {
agentWakeupRequests,
heartbeatRunEvents,
heartbeatRuns,
costEvents,
issues,
projects,
projectWorkspaces,
@@ -22,6 +21,7 @@ import { getServerAdapter, runningProcesses } from "../adapters/index.js";
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
@@ -1029,8 +1029,8 @@ export function heartbeatService(db: Db) {
.where(eq(agentRuntimeState.agentId, agent.id));
if (additionalCostCents > 0 || hasTokenUsage) {
await db.insert(costEvents).values({
companyId: agent.companyId,
const costs = costService(db);
await costs.createEvent(agent.companyId, {
agentId: agent.id,
provider: result.provider ?? "unknown",
model: result.model ?? "unknown",
@@ -1040,16 +1040,6 @@ export function heartbeatService(db: Db) {
occurredAt: new Date(),
});
}
if (additionalCostCents > 0) {
await db
.update(agents)
.set({
spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${additionalCostCents}`,
updatedAt: new Date(),
})
.where(eq(agents.id, agent.id));
}
}
async function startNextQueuedRunForAgent(agentId: string) {

View File

@@ -2,7 +2,6 @@ import type { AdapterConfigFieldsProps } from "../types";
import {
DraftInput,
Field,
ToggleField,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
@@ -45,20 +44,6 @@ export function GeminiLocalConfigFields({
<ChoosePathButton />
</div>
</Field>
<ToggleField
label="Yolo mode"
hint="Run Gemini with --approval-mode yolo for unattended operation."
checked={
isCreate
? values!.dangerouslyBypassSandbox
: eff("adapterConfig", "yolo", config.yolo === true)
}
onChange={(v) =>
isCreate
? set!({ dangerouslyBypassSandbox: v })
: mark("adapterConfig", "yolo", v)
}
/>
</>
);
}

View File

@@ -587,6 +587,23 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
<Separator />
<div className="space-y-1">
{(issue.createdByAgentId || issue.createdByUserId) && (
<PropertyRow label="Created by">
{issue.createdByAgentId ? (
<Link
to={`/agents/${issue.createdByAgentId}`}
className="hover:underline"
>
<Identity name={agentName(issue.createdByAgentId) ?? issue.createdByAgentId.slice(0, 8)} size="sm" />
</Link>
) : (
<>
<User className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm">{creatorUserLabel ?? "User"}</span>
</>
)}
</PropertyRow>
)}
{issue.startedAt && (
<PropertyRow label="Started">
<span className="text-sm">{formatDate(issue.startedAt)}</span>

View File

@@ -104,23 +104,12 @@ export function Layout() {
const togglePanel = togglePanelVisible;
// Cmd+1..9 to switch companies
const switchCompany = useCallback(
(index: number) => {
if (index < companies.length) {
setSelectedCompanyId(companies[index]!.id);
}
},
[companies, setSelectedCompanyId],
);
useCompanyPageMemory();
useKeyboardShortcuts({
onNewIssue: () => openNewIssue(),
onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel,
onSwitchCompany: switchCompany,
});
useEffect(() => {

View File

@@ -699,7 +699,12 @@ export function NewIssueDialog() {
}}
readOnly={createIssue.isPending}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
if (
e.key === "Enter" &&
!e.metaKey &&
!e.ctrlKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
descriptionEditorRef.current?.focus();
}

View File

@@ -8,7 +8,7 @@ export const defaultCreateValues: CreateConfigValues = {
model: "",
thinkingEffort: "",
chrome: false,
dangerouslySkipPermissions: false,
dangerouslySkipPermissions: true,
search: false,
dangerouslyBypassSandbox: false,
command: "",

View File

@@ -4,10 +4,9 @@ interface ShortcutHandlers {
onNewIssue?: () => void;
onToggleSidebar?: () => void;
onTogglePanel?: () => void;
onSwitchCompany?: (index: number) => void;
}
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany }: ShortcutHandlers) {
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
// Don't fire shortcuts when typing in inputs
@@ -16,13 +15,6 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
return;
}
// Cmd+1..9 → Switch company
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
e.preventDefault();
onSwitchCompany?.(parseInt(e.key, 10) - 1);
return;
}
// C → New Issue
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
@@ -44,5 +36,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany]);
}, [onNewIssue, onToggleSidebar, onTogglePanel]);
}

View File

@@ -1313,7 +1313,7 @@ export function DesignGuide() {
["C", "New Issue (outside inputs)"],
["[", "Toggle Sidebar"],
["]", "Toggle Properties Panel"],
["Cmd+1..9 / Ctrl+1..9", "Switch Company (by rail order)"],
["Cmd+Enter / Ctrl+Enter", "Submit markdown comment"],
].map(([key, desc]) => (
<div key={key} className="flex items-center justify-between px-4 py-2">