From f99f174e2dd186373e0b981ba5172a0dc75e3f5d Mon Sep 17 00:00:00 2001 From: Chris Schneider Date: Fri, 6 Mar 2026 17:16:39 +0000 Subject: [PATCH 1/8] Show issue creator in properties sidebar --- ui/src/components/IssueProperties.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index ff7229dc..87846ecd 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -525,6 +525,23 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
+ {(issue.createdByAgentId || issue.createdByUserId) && ( + + {issue.createdByAgentId ? ( + + + + ) : ( + <> + + {creatorUserLabel ?? "User"} + + )} + + )} {issue.startedAt && ( {formatDate(issue.startedAt)} From 5e18ccace7d670defb9f94d5bb6b304b78c12987 Mon Sep 17 00:00:00 2001 From: Dominic O'Carroll <99632940+domocarroll@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:45:09 +1000 Subject: [PATCH 2/8] fix: route heartbeat cost recording through costService Heartbeat runs recorded costs via direct SQL inserts into costEvents and agents.spentMonthlyCents, bypassing costService.createEvent(). This skipped: - companies.spentMonthlyCents update (company budget never incremented) - Agent auto-pause when budget exceeded (enforcement gap) Now calls costService(db).createEvent() which handles all three: insert cost event, update agent spend, update company spend, and auto-pause agent when budgetMonthlyCents is exceeded. --- server/src/services/heartbeat.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index dbba40b2..81d02137 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -9,7 +9,6 @@ import { agentWakeupRequests, heartbeatRunEvents, heartbeatRuns, - costEvents, issues, projectWorkspaces, } from "@paperclipai/db"; @@ -21,6 +20,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"; @@ -977,8 +977,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", @@ -988,16 +988,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) { From 1a75e6d15cffd84c97987386bd7b7bb490f11e31 Mon Sep 17 00:00:00 2001 From: Daniil Okhlopkov <5613295+ohld@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:56:40 +0900 Subject: [PATCH 3/8] fix: default dangerouslySkipPermissions to true for unattended agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents run unattended and cannot respond to interactive permission prompts from Claude Code. When dangerouslySkipPermissions is false (the previous default), Claude Code blocks file operations with "Claude requested permissions to write to /path, but you haven't granted it yet" — making agents unable to edit files. The OnboardingWizard already sets this to true for claude_local agents (OnboardingWizard.tsx:277), but agents created or edited outside the wizard inherit the default of false, breaking them. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/agent-config-defaults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/agent-config-defaults.ts b/ui/src/components/agent-config-defaults.ts index 4072de01..14ade2fd 100644 --- a/ui/src/components/agent-config-defaults.ts +++ b/ui/src/components/agent-config-defaults.ts @@ -8,7 +8,7 @@ export const defaultCreateValues: CreateConfigValues = { model: "", thinkingEffort: "", chrome: false, - dangerouslySkipPermissions: false, + dangerouslySkipPermissions: true, search: false, dangerouslyBypassSandbox: false, command: "", From 8a7b7a2383cf3d8494f600c67c9a6b3ee538630a Mon Sep 17 00:00:00 2001 From: adamrobbie Date: Mon, 9 Mar 2026 16:58:57 -0400 Subject: [PATCH 4/8] docs: remove obsolete TODO for CONTRIBUTING.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index c3d9fc8e..70ddee5f 100644 --- a/README.md +++ b/README.md @@ -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. - -
## Community From 9d2800e691232164a8cd2019decee3cd1b609b23 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:43:40 -0700 Subject: [PATCH 5/8] fix(cli): add restart hint after allowed-hostname change The server builds its hostname allow-set once at startup. When users add a new hostname via the CLI, the config file is updated but the running server doesn't reload it. This adds a clear message telling users to restart the server for the change to take effect. Fixes #538 Co-Authored-By: Claude Opus 4.6 --- cli/src/commands/allowed-hostname.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/src/commands/allowed-hostname.ts b/cli/src/commands/allowed-hostname.ts index 942c464b..d47a3bba 100644 --- a/cli/src/commands/allowed-hostname.ts +++ b/cli/src/commands/allowed-hostname.ts @@ -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")) { From 426b16987a3d9eb4a727dbec263bab70f98c40c1 Mon Sep 17 00:00:00 2001 From: Ken Shimizu Date: Wed, 11 Mar 2026 11:57:46 +0900 Subject: [PATCH 6/8] fix(ui): prevent IME composition Enter from moving focus in new issue title --- ui/src/components/NewIssueDialog.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 9a9076bc..79adf888 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -681,7 +681,12 @@ export function NewIssueDialog() { e.target.style.height = `${e.target.scrollHeight}px`; }} 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(); } From bb6e7215672ac1dabfd69501149fc3ce6ff25da0 Mon Sep 17 00:00:00 2001 From: User Date: Wed, 11 Mar 2026 15:32:39 -0400 Subject: [PATCH 7/8] fix: remove Cmd+1..9 company-switch shortcut This shortcut interfered with browser tab-switching (Cmd+1..9) and produced a black screen when used. Removes the handler, the Layout callback, and the design-guide documentation entry. Closes RUS-56 --- ui/src/components/Layout.tsx | 11 ----------- ui/src/hooks/useKeyboardShortcuts.ts | 12 ++---------- ui/src/pages/DesignGuide.tsx | 2 +- 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 9af35ada..e12e6717 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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(() => { diff --git a/ui/src/hooks/useKeyboardShortcuts.ts b/ui/src/hooks/useKeyboardShortcuts.ts index f12c9f3e..6120da80 100644 --- a/ui/src/hooks/useKeyboardShortcuts.ts +++ b/ui/src/hooks/useKeyboardShortcuts.ts @@ -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]); } diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index e7ee898d..9d4ab867 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -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]) => (
From 6bfe0b84228926b4909cd71aa7f62de29d776914 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 12 Mar 2026 01:34:00 +0000 Subject: [PATCH 8/8] Default Gemini adapter to yolo mode and add API access prompt note Gemini CLI only registers run_shell_command in --approval-mode yolo. Non-yolo modes don't expose it at all, making Paperclip API calls impossible. Always pass --approval-mode yolo and remove the now-unused policy engine code, approval mode config, and UI toggles. Add a "Paperclip API access note" to the prompt with curl examples via run_shell_command, since the universal SKILL.md is tool-agnostic. Also extract structured question events from Gemini assistant messages to support interactive approval flows. Co-Authored-By: Claude Opus 4.6 --- packages/adapter-utils/src/types.ts | 8 ++++ packages/adapters/gemini-local/src/index.ts | 1 - .../gemini-local/src/server/execute.ts | 23 ++++++++-- .../adapters/gemini-local/src/server/parse.ts | 21 +++++++++ .../gemini-local/src/ui/build-config.ts | 2 +- .../__tests__/gemini-local-adapter.test.ts | 31 +++++++++++++ .../__tests__/gemini-local-execute.test.ts | 46 ++++++++++++++++++- .../adapters/gemini-local/config-fields.tsx | 15 ------ 8 files changed, 125 insertions(+), 22 deletions(-) diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 3ffbaec1..6503e5a1 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -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 { diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index 3ceef32e..64b7b99f 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -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 diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 37a94232..4ffb51e3 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -59,6 +59,20 @@ function renderPaperclipEnvNote(env: Record): string { ].join("\n"); } +function renderApiAccessNote(env: Record): 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 { 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 { 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 { 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 | 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, }; } diff --git a/packages/adapters/gemini-local/src/ui/build-config.ts b/packages/adapters/gemini-local/src/ui/build-config.ts index 1fd7ac65..a1ec6ddc 100644 --- a/packages/adapters/gemini-local/src/ui/build-config.ts +++ b/packages/adapters/gemini-local/src/ui/build-config.ts @@ -67,8 +67,8 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record 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; diff --git a/server/src/__tests__/gemini-local-adapter.test.ts b/server/src/__tests__/gemini-local-adapter.test.ts index 41da0530..5c36f6c3 100644 --- a/server/src/__tests__/gemini-local-adapter.test.ts +++ b/server/src/__tests__/gemini-local-adapter.test.ts @@ -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", () => { diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 92f8779a..92badecf 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -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; diff --git a/ui/src/adapters/gemini-local/config-fields.tsx b/ui/src/adapters/gemini-local/config-fields.tsx index a7302bfc..050c8d95 100644 --- a/ui/src/adapters/gemini-local/config-fields.tsx +++ b/ui/src/adapters/gemini-local/config-fields.tsx @@ -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({
- - isCreate - ? set!({ dangerouslyBypassSandbox: v }) - : mark("adapterConfig", "yolo", v) - } - /> ); }