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 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")) { 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/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 24580adb..e782bc25 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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) { 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) - } - /> ); } diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index bf55a938..ca8e1bd4 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -587,6 +587,23 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
+ {(issue.createdByAgentId || issue.createdByUserId) && ( + + {issue.createdByAgentId ? ( + + + + ) : ( + <> + + {creatorUserLabel ?? "User"} + + )} + + )} {issue.startedAt && ( {formatDate(issue.startedAt)} 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/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 32d6e34f..01f210ed 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -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(); } diff --git a/ui/src/components/agent-config-defaults.ts b/ui/src/components/agent-config-defaults.ts index bfbb09fb..4cf1df2f 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: "", 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]) => (