From 6a101e0da11868b4771cb73e2d6dc181407a9338 Mon Sep 17 00:00:00 2001 From: Konan69 Date: Thu, 5 Mar 2026 15:24:20 +0100 Subject: [PATCH 01/93] Add OpenCode provider integration and strict model selection --- cli/package.json | 1 + cli/src/adapters/registry.ts | 8 +- docs/adapters/overview.md | 4 +- docs/api/agents.md | 12 + docs/guides/board-operator/managing-agents.md | 8 + packages/adapter-utils/package.json | 1 + packages/adapters/claude-local/package.json | 1 + packages/adapters/codex-local/package.json | 1 + packages/adapters/openclaw/package.json | 1 + packages/adapters/opencode-local/CHANGELOG.md | 7 + packages/adapters/opencode-local/package.json | 50 +++ .../opencode-local/src/cli/format-event.ts | 109 ++++++ .../adapters/opencode-local/src/cli/index.ts | 1 + packages/adapters/opencode-local/src/index.ts | 28 ++ .../opencode-local/src/server/execute.ts | 311 ++++++++++++++++++ .../opencode-local/src/server/index.ts | 61 ++++ .../opencode-local/src/server/models.test.ts | 33 ++ .../opencode-local/src/server/models.ts | 176 ++++++++++ .../opencode-local/src/server/parse.test.ts | 50 +++ .../opencode-local/src/server/parse.ts | 93 ++++++ .../opencode-local/src/server/test.ts | 236 +++++++++++++ .../opencode-local/src/ui/build-config.ts | 73 ++++ .../adapters/opencode-local/src/ui/index.ts | 2 + .../opencode-local/src/ui/parse-stdout.ts | 135 ++++++++ .../adapters/opencode-local/tsconfig.json | 8 + .../adapters/opencode-local/vitest.config.ts | 7 + packages/db/package.json | 1 + packages/shared/src/constants.ts | 9 +- pnpm-lock.yaml | 166 +++++++++- scripts/generate-npm-package-json.mjs | 1 + scripts/release.sh | 5 +- server/package.json | 3 + server/src/__tests__/adapter-models.test.ts | 10 + server/src/adapters/registry.ts | 25 +- server/src/routes/agents.ts | 53 ++- ui/package.json | 1 + .../brands/opencode-logo-dark-square.svg | 18 + .../brands/opencode-logo-light-square.svg | 18 + .../adapters/opencode-local/config-fields.tsx | 47 +++ ui/src/adapters/opencode-local/index.ts | 12 + ui/src/adapters/registry.ts | 3 +- ui/src/api/agents.ts | 3 +- ui/src/components/AgentConfigForm.tsx | 183 +++++++++-- ui/src/components/AgentProperties.tsx | 1 + ui/src/components/NewAgentDialog.tsx | 49 ++- ui/src/components/NewIssueDialog.tsx | 56 +++- ui/src/components/OnboardingWizard.tsx | 204 ++++++++++-- ui/src/components/OpenCodeLogoIcon.tsx | 22 ++ ui/src/components/agent-config-primitives.tsx | 5 +- ui/src/lib/queryKeys.ts | 2 + ui/src/pages/AgentDetail.tsx | 8 +- ui/src/pages/Agents.tsx | 1 + ui/src/pages/InviteLanding.tsx | 3 +- ui/src/pages/OrgChart.tsx | 1 + vitest.config.ts | 2 +- 55 files changed, 2225 insertions(+), 104 deletions(-) create mode 100644 packages/adapters/opencode-local/CHANGELOG.md create mode 100644 packages/adapters/opencode-local/package.json create mode 100644 packages/adapters/opencode-local/src/cli/format-event.ts create mode 100644 packages/adapters/opencode-local/src/cli/index.ts create mode 100644 packages/adapters/opencode-local/src/index.ts create mode 100644 packages/adapters/opencode-local/src/server/execute.ts create mode 100644 packages/adapters/opencode-local/src/server/index.ts create mode 100644 packages/adapters/opencode-local/src/server/models.test.ts create mode 100644 packages/adapters/opencode-local/src/server/models.ts create mode 100644 packages/adapters/opencode-local/src/server/parse.test.ts create mode 100644 packages/adapters/opencode-local/src/server/parse.ts create mode 100644 packages/adapters/opencode-local/src/server/test.ts create mode 100644 packages/adapters/opencode-local/src/ui/build-config.ts create mode 100644 packages/adapters/opencode-local/src/ui/index.ts create mode 100644 packages/adapters/opencode-local/src/ui/parse-stdout.ts create mode 100644 packages/adapters/opencode-local/tsconfig.json create mode 100644 packages/adapters/opencode-local/vitest.config.ts create mode 100644 ui/public/brands/opencode-logo-dark-square.svg create mode 100644 ui/public/brands/opencode-logo-light-square.svg create mode 100644 ui/src/adapters/opencode-local/config-fields.tsx create mode 100644 ui/src/adapters/opencode-local/index.ts create mode 100644 ui/src/components/OpenCodeLogoIcon.tsx diff --git a/cli/package.json b/cli/package.json index 3493ed17..f42751b0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,6 +36,7 @@ "@clack/prompts": "^0.10.0", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index b97dd5df..a0cd553d 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -1,6 +1,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils"; import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; +import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli"; import { processCLIAdapter } from "./process/index.js"; import { httpCLIAdapter } from "./http/index.js"; @@ -15,13 +16,18 @@ const codexLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCodexStreamEvent, }; +const openCodeLocalCLIAdapter: CLIAdapterModule = { + type: "opencode_local", + formatStdoutEvent: printOpenCodeStreamEvent, +}; + const openclawCLIAdapter: CLIAdapterModule = { type: "openclaw", formatStdoutEvent: printOpenClawStreamEvent, }; const adaptersByType = new Map( - [claudeLocalCLIAdapter, codexLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), + [claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), ); export function getCLIAdapter(type: string): CLIAdapterModule { diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 0d3ccabf..4237f87f 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -20,6 +20,8 @@ When a heartbeat fires, Paperclip: |---------|----------|-------------| | [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | +| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | | [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | @@ -52,7 +54,7 @@ Three registries consume these modules: ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local` or `codex_local` +- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local` - **Need to run a script or command?** Use `process` - **Need to call an external service?** Use `http` - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) diff --git a/docs/api/agents.md b/docs/api/agents.md index 371d2563..143cbc5f 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org Returns the full organizational tree for the company. +## List Adapter Models + +``` +GET /api/companies/{companyId}/adapters/{adapterType}/models +``` + +Returns selectable models for an adapter type. + +- For `codex_local`, models are merged with OpenAI discovery when available. +- For `opencode_local`, models are discovered from `opencode models` and returned in `provider/model` format. +- `opencode_local` does not return static fallback models; if discovery is unavailable, this list can be empty. + ## Config Revisions ``` diff --git a/docs/guides/board-operator/managing-agents.md b/docs/guides/board-operator/managing-agents.md index 4154ee3a..453b967f 100644 --- a/docs/guides/board-operator/managing-agents.md +++ b/docs/guides/board-operator/managing-agents.md @@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires: - **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.) - **Capabilities** — short description of what this agent does +Common adapter choices: +- `claude_local` / `codex_local` / `opencode_local` for local coding agents +- `openclaw` / `http` for webhook-based external agents +- `process` for generic local command execution + +For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`). +Paperclip validates the selected model against live `opencode models` output. + ## Agent Hiring via Governance Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject. diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 20c71067..80ab2874 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -30,6 +30,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@types/node": "^22.12.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index e7531acf..f511e76f 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^22.12.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index dfad0336..194e346f 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^22.12.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/openclaw/package.json b/packages/adapters/openclaw/package.json index 27f0f1b6..84e5ae9c 100644 --- a/packages/adapters/openclaw/package.json +++ b/packages/adapters/openclaw/package.json @@ -44,6 +44,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^22.12.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/opencode-local/CHANGELOG.md b/packages/adapters/opencode-local/CHANGELOG.md new file mode 100644 index 00000000..7661493b --- /dev/null +++ b/packages/adapters/opencode-local/CHANGELOG.md @@ -0,0 +1,7 @@ +# @paperclipai/adapter-opencode-local + +## 0.2.5 + +### Patch Changes + +- Add local OpenCode adapter package with server/UI/CLI modules. diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json new file mode 100644 index 00000000..398acf4b --- /dev/null +++ b/packages/adapters/opencode-local/package.json @@ -0,0 +1,50 @@ +{ + "name": "@paperclipai/adapter-opencode-local", + "version": "0.2.5", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/opencode-local/src/cli/format-event.ts b/packages/adapters/opencode-local/src/cli/format-event.ts new file mode 100644 index 00000000..1af47367 --- /dev/null +++ b/packages/adapters/opencode-local/src/cli/format-event.ts @@ -0,0 +1,109 @@ +import pc from "picocolors"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const data = asRecord(rec.data); + const message = + asString(rec.message) || + asString(data?.message) || + asString(rec.name) || + ""; + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + console.log(line); + return; + } + + const type = asString(parsed.type); + + if (type === "text") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (text) console.log(pc.green(`assistant: ${text}`)); + return; + } + + if (type === "reasoning") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + + if (type === "tool_use") { + const part = asRecord(parsed.part); + const tool = asString(part?.tool, "tool"); + const state = asRecord(part?.state); + const status = asString(state?.status); + const summary = `tool_${status || "event"}: ${tool}`; + const isError = status === "error"; + console.log((isError ? pc.red : pc.yellow)(summary)); + const input = state?.input; + if (input !== undefined) { + try { + console.log(pc.gray(JSON.stringify(input, null, 2))); + } catch { + console.log(pc.gray(String(input))); + } + } + const output = asString(state?.output) || asString(state?.error); + if (output) console.log((isError ? pc.red : pc.gray)(output)); + return; + } + + if (type === "step_finish") { + const part = asRecord(parsed.part); + const tokens = asRecord(part?.tokens); + const cache = asRecord(tokens?.cache); + const input = asNumber(tokens?.input, 0); + const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0); + const cached = asNumber(cache?.read, 0); + const cost = asNumber(part?.cost, 0); + const reason = asString(part?.reason, "step"); + console.log(pc.blue(`step finished (${reason}) tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); + return; + } + + if (type === "error") { + const message = errorText(parsed.error ?? parsed.message); + if (message) console.log(pc.red(`error: ${message}`)); + return; + } + + console.log(line); +} diff --git a/packages/adapters/opencode-local/src/cli/index.ts b/packages/adapters/opencode-local/src/cli/index.ts new file mode 100644 index 00000000..93c29b69 --- /dev/null +++ b/packages/adapters/opencode-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printOpenCodeStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts new file mode 100644 index 00000000..7a21a455 --- /dev/null +++ b/packages/adapters/opencode-local/src/index.ts @@ -0,0 +1,28 @@ +export const type = "opencode_local"; +export const label = "OpenCode (local)"; + +export const models: Array<{ id: string; label: string }> = []; + +export const agentConfigurationDoc = `# opencode_local agent configuration + +Adapter: opencode_local + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt +- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5) +- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max) +- promptTemplate (string, optional): run prompt template +- command (string, optional): defaults to "opencode" +- extraArgs (string[], optional): additional CLI args +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- OpenCode supports multiple providers and models. Use \ + \`opencode models\` to list available options in provider/model format. +- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents. +`; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts new file mode 100644 index 00000000..0d454bfc --- /dev/null +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -0,0 +1,311 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + asString, + asNumber, + asStringArray, + parseObject, + buildPaperclipEnv, + redactEnvForLogs, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + renderTemplate, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; +import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function parseModelProvider(model: string | null): string | null { + if (!model) return null; + const trimmed = model.trim(); + if (!trimmed.includes("/")) return null; + return trimmed.slice(0, trimmed.indexOf("/")).trim() || null; +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const command = asString(config.command, "opencode"); + const model = asString(config.model, "").trim(); + const variant = asString(config.variant, "").trim(); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; + if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; + if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; + if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; + if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; + if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + await ensureCommandResolvable(command, cwd, runtimeEnv); + + await ensureOpenCodeModelConfiguredAndAvailable({ + model, + command, + cwd, + env, + }); + + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stderr", + `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } + + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (instructionsFilePath) { + try { + const instructionsContents = await fs.readFile(instructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + await onLog( + "stderr", + `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`, + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + } + } + + const commandNotes = (() => { + if (!instructionsFilePath) return [] as string[]; + if (instructionsPrefix.length > 0) { + return [ + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, + ]; + } + return [ + `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ]; + })(); + + const renderedPrompt = renderTemplate(promptTemplate, { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }); + const prompt = `${instructionsPrefix}${renderedPrompt}`; + + const buildArgs = (resumeSessionId: string | null) => { + const args = ["run", "--format", "json"]; + if (resumeSessionId) args.push("--session", resumeSessionId); + if (model) args.push("--model", model); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "opencode_local", + command, + cwd, + commandNotes, + commandArgs: [...args, ``], + env: redactEnvForLogs(env), + prompt, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env, + stdin: prompt, + timeoutSec, + graceSec, + onLog, + }); + return { + proc, + rawStderr: proc.stderr, + parsed: parseOpenCodeJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; + rawStderr: string; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null; + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) + : null; + + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const rawExitCode = attempt.proc.exitCode; + const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; + const fallbackErrorMessage = + parsedError || + stderrLine || + `OpenCode exited with code ${synthesizedExitCode ?? -1}`; + const modelId = model || null; + + return { + exitCode: synthesizedExitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, + usage: { + inputTokens: attempt.parsed.usage.inputTokens, + outputTokens: attempt.parsed.usage.outputTokens, + cachedInputTokens: attempt.parsed.usage.cachedInputTokens, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: parseModelProvider(modelId), + model: modelId, + billingType: "unknown", + costUsd: attempt.parsed.usage.costUsd, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); + if ( + sessionId && + initialFailed && + isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stderr", + `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true); + } + + return toResult(initial); +} diff --git a/packages/adapters/opencode-local/src/server/index.ts b/packages/adapters/opencode-local/src/server/index.ts new file mode 100644 index 00000000..3fc48655 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/index.ts @@ -0,0 +1,61 @@ +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + }, +}; + +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { + listOpenCodeModels, + discoverOpenCodeModels, + ensureOpenCodeModelConfiguredAndAvailable, + resetOpenCodeModelsCacheForTests, +} from "./models.js"; +export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/opencode-local/src/server/models.test.ts b/packages/adapters/opencode-local/src/server/models.test.ts new file mode 100644 index 00000000..cd49e4a2 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/models.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + ensureOpenCodeModelConfiguredAndAvailable, + listOpenCodeModels, + resetOpenCodeModelsCacheForTests, +} from "./models.js"; + +describe("openCode models", () => { + afterEach(() => { + delete process.env.PAPERCLIP_OPENCODE_COMMAND; + resetOpenCodeModelsCacheForTests(); + }); + + it("returns an empty list when discovery command is unavailable", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + await expect(listOpenCodeModels()).resolves.toEqual([]); + }); + + it("rejects when model is missing", async () => { + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ model: "" }), + ).rejects.toThrow("OpenCode requires `adapterConfig.model`"); + }); + + it("rejects when discovery cannot run for configured model", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + await expect( + ensureOpenCodeModelConfiguredAndAvailable({ + model: "openai/gpt-5", + }), + ).rejects.toThrow("Failed to start command"); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts new file mode 100644 index 00000000..297527c4 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -0,0 +1,176 @@ +import type { AdapterModel } from "@paperclipai/adapter-utils"; +import { + asString, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; + +const MODELS_CACHE_TTL_MS = 60_000; + +const discoveryCache = new Map(); + +function dedupeModels(models: AdapterModel[]): AdapterModel[] { + const seen = new Set(); + const deduped: AdapterModel[] = []; + for (const model of models) { + const id = model.id.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + deduped.push({ id, label: model.label.trim() || id }); + } + return deduped; +} + +function sortModels(models: AdapterModel[]): AdapterModel[] { + return [...models].sort((a, b) => + a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }), + ); +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function parseModelsOutput(stdout: string): AdapterModel[] { + const parsed: AdapterModel[] = []; + for (const raw of stdout.split(/\r?\n/)) { + const line = raw.trim(); + if (!line) continue; + const firstToken = line.split(/\s+/)[0]?.trim() ?? ""; + if (!firstToken.includes("/")) continue; + const provider = firstToken.slice(0, firstToken.indexOf("/")).trim(); + const model = firstToken.slice(firstToken.indexOf("/") + 1).trim(); + if (!provider || !model) continue; + parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` }); + } + return dedupeModels(parsed); +} + +function normalizeEnv(input: unknown): Record { + const envInput = typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Record) + : {}; + const env: Record = {}; + for (const [key, value] of Object.entries(envInput)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + +function discoveryCacheKey(command: string, cwd: string, env: Record) { + const envKey = Object.entries(env) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + return `${command}\n${cwd}\n${envKey}`; +} + +export async function discoverOpenCodeModels(input: { + command?: unknown; + cwd?: unknown; + env?: unknown; +} = {}): Promise { + const command = asString( + input.command, + (typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" && + process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0 + ? process.env.PAPERCLIP_OPENCODE_COMMAND.trim() + : "opencode"), + ); + const cwd = asString(input.cwd, process.cwd()); + const env = normalizeEnv(input.env); + + const result = await runChildProcess( + `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + ["models"], + { + cwd, + env, + timeoutSec: 20, + graceSec: 3, + onLog: async () => {}, + }, + ); + + if (result.timedOut) { + throw new Error("`opencode models` timed out."); + } + if ((result.exitCode ?? 1) !== 0) { + const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); + throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed."); + } + + return sortModels(parseModelsOutput(result.stdout)); +} + +export async function discoverOpenCodeModelsCached(input: { + command?: unknown; + cwd?: unknown; + env?: unknown; +} = {}): Promise { + const command = asString( + input.command, + (typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" && + process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0 + ? process.env.PAPERCLIP_OPENCODE_COMMAND.trim() + : "opencode"), + ); + const cwd = asString(input.cwd, process.cwd()); + const env = normalizeEnv(input.env); + const key = discoveryCacheKey(command, cwd, env); + const now = Date.now(); + const cached = discoveryCache.get(key); + if (cached && cached.expiresAt > now) return cached.models; + + const models = await discoverOpenCodeModels({ command, cwd, env }); + discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models }); + return models; +} + +export async function ensureOpenCodeModelConfiguredAndAvailable(input: { + model?: unknown; + command?: unknown; + cwd?: unknown; + env?: unknown; +}): Promise { + const model = asString(input.model, "").trim(); + if (!model) { + throw new Error("OpenCode requires `adapterConfig.model` in provider/model format."); + } + + const models = await discoverOpenCodeModelsCached({ + command: input.command, + cwd: input.cwd, + env: input.env, + }); + + if (models.length === 0) { + throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth."); + } + + if (!models.some((entry) => entry.id === model)) { + const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); + throw new Error( + `Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, + ); + } + + return models; +} + +export async function listOpenCodeModels(): Promise { + try { + return await discoverOpenCodeModelsCached(); + } catch { + return []; + } +} + +export function resetOpenCodeModelsCacheForTests() { + discoveryCache.clear(); +} diff --git a/packages/adapters/opencode-local/src/server/parse.test.ts b/packages/adapters/opencode-local/src/server/parse.test.ts new file mode 100644 index 00000000..af0e264d --- /dev/null +++ b/packages/adapters/opencode-local/src/server/parse.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; + +describe("parseOpenCodeJsonl", () => { + it("parses assistant text, usage, cost, and errors", () => { + const stdout = [ + JSON.stringify({ + type: "text", + sessionID: "session_123", + part: { text: "Hello from OpenCode" }, + }), + JSON.stringify({ + type: "step_finish", + sessionID: "session_123", + part: { + reason: "done", + cost: 0.0025, + tokens: { + input: 120, + output: 40, + reasoning: 10, + cache: { read: 20, write: 0 }, + }, + }, + }), + JSON.stringify({ + type: "error", + sessionID: "session_123", + error: { message: "model unavailable" }, + }), + ].join("\n"); + + const parsed = parseOpenCodeJsonl(stdout); + expect(parsed.sessionId).toBe("session_123"); + expect(parsed.summary).toBe("Hello from OpenCode"); + expect(parsed.usage).toEqual({ + inputTokens: 120, + cachedInputTokens: 20, + outputTokens: 50, + costUsd: 0.0025, + }); + expect(parsed.errorMessage).toContain("model unavailable"); + }); + + it("detects unknown session errors", () => { + expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true); + expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true); + expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts new file mode 100644 index 00000000..09cc05ff --- /dev/null +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -0,0 +1,93 @@ +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = asString(rec.message, "").trim(); + if (message) return message; + const data = parseObject(rec.data); + const nestedMessage = asString(data.message, "").trim(); + if (nestedMessage) return nestedMessage; + const name = asString(rec.name, "").trim(); + if (name) return name; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +export function parseOpenCodeJsonl(stdout: string) { + let sessionId: string | null = null; + const messages: string[] = []; + const errors: string[] = []; + const usage = { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + costUsd: 0, + }; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const currentSessionId = asString(event.sessionID, "").trim(); + if (currentSessionId) sessionId = currentSessionId; + + const type = asString(event.type, ""); + + if (type === "text") { + const part = parseObject(event.part); + const text = asString(part.text, "").trim(); + if (text) messages.push(text); + continue; + } + + if (type === "step_finish") { + const part = parseObject(event.part); + const tokens = parseObject(part.tokens); + const cache = parseObject(tokens.cache); + usage.inputTokens += asNumber(tokens.input, 0); + usage.cachedInputTokens += asNumber(cache.read, 0); + usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0); + usage.costUsd += asNumber(part.cost, 0); + continue; + } + + if (type === "tool_use") { + const part = parseObject(event.part); + const state = parseObject(part.state); + if (asString(state.status, "") === "error") { + const text = asString(state.error, "").trim(); + if (text) errors.push(text); + } + continue; + } + + if (type === "error") { + const text = errorText(event.error ?? event.message).trim(); + if (text) errors.push(text); + continue; + } + } + + return { + sessionId, + summary: messages.join("\n\n").trim(), + usage, + errorMessage: errors.length > 0 ? errors.join("\n") : null, + }; +} + +export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}`.toLowerCase(); + return ( + haystack.includes("session not found") || + haystack.includes("unknown session") || + haystack.includes("no session") + ); +} diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts new file mode 100644 index 00000000..f45dd6b1 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -0,0 +1,236 @@ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asString, + asStringArray, + parseObject, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; +import { parseOpenCodeJsonl } from "./parse.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean; +} + +const OPENCODE_AUTH_REQUIRED_RE = + /(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i; + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "opencode"); + const cwd = asString(config.cwd, process.cwd()); + + try { + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + checks.push({ + code: "opencode_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "opencode_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const envConfig = parseObject(config.env); + const env: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); + checks.push({ + code: "opencode_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "opencode_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + + const canRunProbe = + checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); + + let discoveredModels: string[] = []; + let modelValidationPassed = false; + if (canRunProbe) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env }); + discoveredModels = discovered.map((item) => item.id); + if (discoveredModels.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discoveredModels.length} model(s) from OpenCode providers.`, + }); + } else { + checks.push({ + code: "opencode_models_empty", + level: "error", + message: "OpenCode returned no models.", + hint: "Run `opencode models` and verify provider authentication.", + }); + } + } catch (err) { + checks.push({ + code: "opencode_models_discovery_failed", + level: "error", + message: err instanceof Error ? err.message : "OpenCode model discovery failed.", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } + } + + const configuredModel = asString(config.model, "").trim(); + if (!configuredModel) { + checks.push({ + code: "opencode_model_required", + level: "error", + message: "OpenCode requires a configured model in provider/model format.", + hint: "Set adapterConfig.model using an ID from `opencode models`.", + }); + } else if (canRunProbe) { + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: configuredModel, + command, + cwd, + env, + }); + checks.push({ + code: "opencode_model_configured", + level: "info", + message: `Configured model: ${configuredModel}`, + }); + modelValidationPassed = true; + } catch (err) { + checks.push({ + code: "opencode_model_invalid", + level: "error", + message: err instanceof Error ? err.message : "Configured model is unavailable.", + hint: "Run `opencode models` and choose a currently available provider/model ID.", + }); + } + } + + if (canRunProbe && modelValidationPassed) { + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + const variant = asString(config.variant, "").trim(); + const probeModel = configuredModel; + + const args = ["run", "--format", "json"]; + args.push("--model", probeModel); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + + const probe = await runChildProcess( + `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env, + timeoutSec: 60, + graceSec: 5, + stdin: "Respond with hello.", + onLog: async () => {}, + }, + ); + + const parsed = parseOpenCodeJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + + if (probe.timedOut) { + checks.push({ + code: "opencode_hello_probe_timed_out", + level: "warn", + message: "OpenCode hello probe timed out.", + hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", + }); + } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "OpenCode hello probe succeeded." + : "OpenCode probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", + }), + }); + } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_auth_required", + level: "warn", + message: "OpenCode is installed, but provider authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", + }); + } else { + checks.push({ + code: "opencode_hello_probe_failed", + level: "error", + message: "OpenCode hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `opencode run --format json` manually in this working directory to debug.", + }); + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts new file mode 100644 index 00000000..f656585e --- /dev/null +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -0,0 +1,73 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.model) ac.model = v.model; + if (v.thinkingEffort) ac.variant = v.thinkingEffort; + ac.timeoutSec = 0; + ac.graceSec = 20; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/opencode-local/src/ui/index.ts b/packages/adapters/opencode-local/src/ui/index.ts new file mode 100644 index 00000000..a06f826d --- /dev/null +++ b/packages/adapters/opencode-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseOpenCodeStdoutLine } from "./parse-stdout.js"; +export { buildOpenCodeLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/opencode-local/src/ui/parse-stdout.ts b/packages/adapters/opencode-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..d8d2f03f --- /dev/null +++ b/packages/adapters/opencode-local/src/ui/parse-stdout.ts @@ -0,0 +1,135 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const data = asRecord(rec.data); + const msg = + asString(rec.message) || + asString(data?.message) || + asString(rec.name) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function parseToolUse(parsed: Record, ts: string): TranscriptEntry[] { + const part = asRecord(parsed.part); + if (!part) return [{ kind: "system", ts, text: "tool event" }]; + + const toolName = asString(part.tool, "tool"); + const state = asRecord(part.state); + const input = state?.input ?? {}; + const callEntry: TranscriptEntry = { + kind: "tool_call", + ts, + name: toolName, + input, + }; + + const status = asString(state?.status); + if (status !== "completed" && status !== "error") return [callEntry]; + + const output = + asString(state?.output) || + asString(state?.error) || + asString(part.title) || + `${toolName} ${status}`; + + return [ + callEntry, + { + kind: "tool_result", + ts, + toolUseId: asString(part.id, toolName), + content: output, + isError: status === "error", + }, + ]; +} + +export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type); + + if (type === "text") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (!text) return []; + return [{ kind: "assistant", ts, text }]; + } + + if (type === "reasoning") { + const part = asRecord(parsed.part); + const text = asString(part?.text).trim(); + if (!text) return []; + return [{ kind: "thinking", ts, text }]; + } + + if (type === "tool_use") { + return parseToolUse(parsed, ts); + } + + if (type === "step_start") { + return [{ kind: "system", ts, text: "step started" }]; + } + + if (type === "step_finish") { + const part = asRecord(parsed.part); + const tokens = asRecord(part?.tokens); + const cache = asRecord(tokens?.cache); + const reason = asString(part?.reason, "step"); + const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0); + return [ + { + kind: "result", + ts, + text: reason, + inputTokens: asNumber(tokens?.input, 0), + outputTokens: output, + cachedTokens: asNumber(cache?.read, 0), + costUsd: asNumber(part?.cost, 0), + subtype: reason, + isError: false, + errors: [], + }, + ]; + } + + if (type === "error") { + const text = errorText(parsed.error ?? parsed.message); + return [{ kind: "stderr", ts, text: text || line }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/opencode-local/tsconfig.json b/packages/adapters/opencode-local/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/opencode-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/opencode-local/vitest.config.ts b/packages/adapters/opencode-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/opencode-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/db/package.json b/packages/db/package.json index 6cf2dc30..1d38bc05 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -38,6 +38,7 @@ "postgres": "^3.4.5" }, "devDependencies": { + "@types/node": "^22.12.0", "drizzle-kit": "^0.31.9", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0b3490ab..7a6abd12 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -21,7 +21,14 @@ export const AGENT_STATUSES = [ ] as const; export type AgentStatus = (typeof AGENT_STATUSES)[number]; -export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "openclaw"] as const; +export const AGENT_ADAPTER_TYPES = [ + "process", + "http", + "claude_local", + "codex_local", + "opencode_local", + "openclaw", +] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number]; export const AGENT_ROLES = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 304bb691..ae7e8285 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-opencode-local': + specifier: workspace:* + version: link:../packages/adapters/opencode-local '@paperclipai/adapter-utils': specifier: workspace:* version: link:../packages/adapter-utils @@ -72,6 +75,9 @@ importers: packages/adapter-utils: devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -85,6 +91,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -98,6 +107,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -111,6 +123,25 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/adapters/opencode-local: + dependencies: + '@paperclipai/adapter-utils': + specifier: workspace:* + version: link:../../adapter-utils + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -127,6 +158,9 @@ importers: specifier: ^3.4.5 version: 3.4.8 devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 drizzle-kit: specifier: ^0.31.9 version: 0.31.9 @@ -138,7 +172,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) packages/shared: dependencies: @@ -164,6 +198,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-opencode-local': + specifier: workspace:* + version: link:../packages/adapters/opencode-local '@paperclipai/adapter-utils': specifier: workspace:* version: link:../packages/adapter-utils @@ -175,7 +212,7 @@ importers: version: link:../packages/shared better-auth: specifier: ^1.3.8 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) detect-port: specifier: ^2.1.0 version: 2.1.0 @@ -223,9 +260,15 @@ importers: '@types/multer': specifier: ^2.0.0 version: 2.0.0 + '@types/node': + specifier: ^22.12.0 + version: 22.19.11 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + '@types/ws': + specifier: ^8.5.14 + version: 8.18.1 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -237,10 +280,10 @@ importers: version: 5.9.3 vite: specifier: ^6.1.0 - version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) ui: dependencies: @@ -265,6 +308,9 @@ importers: '@paperclipai/adapter-openclaw': specifier: workspace:* version: link:../packages/adapters/openclaw + '@paperclipai/adapter-opencode-local': + specifier: workspace:* + version: link:../packages/adapters/opencode-local '@paperclipai/adapter-utils': specifier: workspace:* version: link:../packages/adapter-utils @@ -2808,6 +2854,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -8192,6 +8241,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.2.3 + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': @@ -8214,6 +8267,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 @@ -8298,7 +8359,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -8318,7 +8379,7 @@ snapshots: pg: 8.18.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) better-call@1.1.8(zod@4.3.6): dependencies: @@ -10571,6 +10632,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: cac: 6.7.14 @@ -10592,6 +10674,21 @@ snapshots: - tsx - yaml + vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.11 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 + vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.25.12 @@ -10607,6 +10704,21 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 + vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.11 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 + vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.27.3 @@ -10622,6 +10734,48 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.19.11 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs index 705c58d1..635a3e15 100644 --- a/scripts/generate-npm-package-json.mjs +++ b/scripts/generate-npm-package-json.mjs @@ -32,6 +32,7 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", + "packages/adapters/opencode-local", "packages/adapters/openclaw", ]; diff --git a/scripts/release.sh b/scripts/release.sh index ab4d8fe2..1da537e7 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -74,7 +74,7 @@ const { resolve } = require('path'); const root = '$REPO_ROOT'; const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw', + 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw', 'server', 'cli']; const names = []; for (const d of dirs) { @@ -131,6 +131,7 @@ pnpm --filter @paperclipai/adapter-utils build pnpm --filter @paperclipai/db build pnpm --filter @paperclipai/adapter-claude-local build pnpm --filter @paperclipai/adapter-codex-local build +pnpm --filter @paperclipai/adapter-opencode-local build pnpm --filter @paperclipai/adapter-openclaw build pnpm --filter @paperclipai/server build @@ -162,7 +163,7 @@ if [ "$dry_run" = true ]; then echo "" echo " Preview what would be published:" for dir in packages/shared packages/adapter-utils packages/db \ - packages/adapters/claude-local packages/adapters/codex-local packages/adapters/openclaw \ + packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \ server cli; do echo " --- $dir ---" cd "$REPO_ROOT/$dir" diff --git a/server/package.json b/server/package.json index df87aca2..31c55dc4 100644 --- a/server/package.json +++ b/server/package.json @@ -33,6 +33,7 @@ "@aws-sdk/client-s3": "^3.888.0", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", @@ -57,7 +58,9 @@ "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.0.0", "@types/multer": "^2.0.0", + "@types/node": "^22.12.0", "@types/supertest": "^6.0.2", + "@types/ws": "^8.5.14", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index f97fc48d..56500a59 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -1,12 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"; +import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server"; import { listAdapterModels } from "../adapters/index.js"; import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js"; describe("adapter model listing", () => { beforeEach(() => { delete process.env.OPENAI_API_KEY; + delete process.env.PAPERCLIP_OPENCODE_COMMAND; resetCodexModelsCacheForTests(); + resetOpenCodeModelsCacheForTests(); vi.restoreAllMocks(); }); @@ -55,4 +58,11 @@ describe("adapter model listing", () => { const models = await listAdapterModels("codex_local"); expect(models).toEqual(codexFallbackModels); }); + + it("returns no opencode models when opencode command is unavailable", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + + const models = await listAdapterModels("opencode_local"); + expect(models).toEqual([]); + }); }); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 33359b14..b722000c 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -19,6 +19,16 @@ import { agentConfigurationDoc as openclawAgentConfigurationDoc, models as openclawModels, } from "@paperclipai/adapter-openclaw"; +import { + execute as openCodeExecute, + testEnvironment as openCodeTestEnvironment, + sessionCodec as openCodeSessionCodec, + listOpenCodeModels, +} from "@paperclipai/adapter-opencode-local/server"; +import { + agentConfigurationDoc as openCodeAgentConfigurationDoc, + models as openCodeModels, +} from "@paperclipai/adapter-opencode-local"; import { listCodexModels } from "./codex-models.js"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -53,8 +63,21 @@ const openclawAdapter: ServerAdapterModule = { agentConfigurationDoc: openclawAgentConfigurationDoc, }; +const openCodeLocalAdapter: ServerAdapterModule = { + type: "opencode_local", + execute: openCodeExecute, + testEnvironment: openCodeTestEnvironment, + sessionCodec: openCodeSessionCodec, + models: openCodeModels, + listModels: listOpenCodeModels, + supportsLocalAgentJwt: true, + agentConfigurationDoc: openCodeAgentConfigurationDoc, +}; + const adaptersByType = new Map( - [claudeLocalAdapter, codexLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]), + [claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map( + (a) => [a.type, a], + ), ); export function getServerAdapter(type: string): ServerAdapterModule { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 9f4fdade..73fb7be2 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -36,11 +36,13 @@ import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, } from "@paperclipai/adapter-codex-local"; +import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", + opencode_local: "instructionsFilePath", }; const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); @@ -193,6 +195,27 @@ export function agentRoutes(db: Db) { return next; } + async function assertAdapterConfigConstraints( + companyId: string, + adapterType: string | null | undefined, + adapterConfig: Record, + ) { + if (adapterType !== "opencode_local") return; + const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig); + const runtimeEnv = asRecord(runtimeConfig.env) ?? {}; + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: runtimeConfig.model, + command: runtimeConfig.command, + cwd: runtimeConfig.cwd, + env: runtimeEnv, + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`); + } + } + function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record) { const trimmed = candidatePath.trim(); if (path.isAbsolute(trimmed)) return trimmed; @@ -324,7 +347,9 @@ export function agentRoutes(db: Db) { } }); - router.get("/adapters/:type/models", async (req, res) => { + router.get("/companies/:companyId/adapters/:type/models", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const type = req.params.type as string; const models = await listAdapterModels(type); res.json(models); @@ -578,6 +603,11 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, { strictMode: strictSecretsMode }, ); + await assertAdapterConfigConstraints( + companyId, + hireInput.adapterType, + normalizedAdapterConfig, + ); const normalizedHireInput = { ...hireInput, adapterConfig: normalizedAdapterConfig, @@ -713,6 +743,11 @@ export function agentRoutes(db: Db) { requestedAdapterConfig, { strictMode: strictSecretsMode }, ); + await assertAdapterConfigConstraints( + companyId, + req.body.adapterType, + normalizedAdapterConfig, + ); const agent = await svc.create(companyId, { ...req.body, @@ -892,6 +927,22 @@ export function agentRoutes(db: Db) { ); } + const requestedAdapterType = + typeof patchData.adapterType === "string" ? patchData.adapterType : existing.adapterType; + const touchesAdapterConfiguration = + Object.prototype.hasOwnProperty.call(patchData, "adapterType") || + Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); + if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { + const effectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") + ? (asRecord(patchData.adapterConfig) ?? {}) + : (asRecord(existing.adapterConfig) ?? {}); + await assertAdapterConfigConstraints( + existing.companyId, + requestedAdapterType, + effectiveAdapterConfig, + ); + } + const actor = getActorInfo(req); const agent = await svc.update(id, patchData, { recordRevision: { diff --git a/ui/package.json b/ui/package.json index 586d37cc..1f5302ac 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,6 +16,7 @@ "@mdxeditor/editor": "^3.52.4", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-openclaw": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", diff --git a/ui/public/brands/opencode-logo-dark-square.svg b/ui/public/brands/opencode-logo-dark-square.svg new file mode 100644 index 00000000..6a67f627 --- /dev/null +++ b/ui/public/brands/opencode-logo-dark-square.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/public/brands/opencode-logo-light-square.svg b/ui/public/brands/opencode-logo-light-square.svg new file mode 100644 index 00000000..a738ad87 --- /dev/null +++ b/ui/public/brands/opencode-logo-light-square.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/ui/src/adapters/opencode-local/config-fields.tsx b/ui/src/adapters/opencode-local/config-fields.tsx new file mode 100644 index 00000000..043e91c1 --- /dev/null +++ b/ui/src/adapters/opencode-local/config-fields.tsx @@ -0,0 +1,47 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + +export function OpenCodeLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, +}: AdapterConfigFieldsProps) { + return ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ ); +} diff --git a/ui/src/adapters/opencode-local/index.ts b/ui/src/adapters/opencode-local/index.ts new file mode 100644 index 00000000..b68f3ace --- /dev/null +++ b/ui/src/adapters/opencode-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseOpenCodeStdoutLine } from "@paperclipai/adapter-opencode-local/ui"; +import { OpenCodeLocalConfigFields } from "./config-fields"; +import { buildOpenCodeLocalConfig } from "@paperclipai/adapter-opencode-local/ui"; + +export const openCodeLocalUIAdapter: UIAdapterModule = { + type: "opencode_local", + label: "OpenCode (local)", + parseStdoutLine: parseOpenCodeStdoutLine, + ConfigFields: OpenCodeLocalConfigFields, + buildAdapterConfig: buildOpenCodeLocalConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 8dbe3637..6477cb72 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -1,12 +1,13 @@ import type { UIAdapterModule } from "./types"; import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; +import { openCodeLocalUIAdapter } from "./opencode-local"; import { openClawUIAdapter } from "./openclaw"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; const adaptersByType = new Map( - [claudeLocalUIAdapter, codexLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]), + [claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]), ); export function getUIAdapter(type: string): UIAdapterModule { diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 0b91f694..4bcfc305 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -117,7 +117,8 @@ export const agentsApi = { api.get(agentPath(id, companyId, "/task-sessions")), resetSession: (id: string, taskKey?: string | null, companyId?: string) => api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), - adapterModels: (type: string) => api.get(`/adapters/${type}/models`), + adapterModels: (companyId: string, type: string) => + api.get(`/companies/${companyId}/adapters/${type}/models`), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 2ddc539b..3d36febc 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -40,6 +40,7 @@ import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; import { MarkdownEditor } from "./MarkdownEditor"; import { ChoosePathButton } from "./PathInstructionsModal"; +import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; /* ---- Create mode values ---- */ @@ -122,6 +123,19 @@ function formatArgList(value: unknown): string { return typeof value === "string" ? value : ""; } +function extractProviderId(modelId: string): string | null { + const trimmed = modelId.trim(); + if (!trimmed.includes("/")) return null; + const provider = trimmed.slice(0, trimmed.indexOf("/")).trim(); + return provider || null; +} + +function extractModelName(modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed.includes("/")) return trimmed; + return trimmed.slice(trimmed.indexOf("/") + 1); +} + const codexThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, @@ -130,6 +144,15 @@ const codexThinkingEffortOptions = [ { id: "high", label: "High" }, ] as const; +const openCodeThinkingEffortOptions = [ + { id: "", label: "Auto" }, + { id: "minimal", label: "Minimal" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "max", label: "Max" }, +] as const; + const claudeThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "low", label: "Low" }, @@ -254,13 +277,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const isLocal = adapterType === "claude_local" || adapterType === "codex_local"; + const isLocal = + adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local"; const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); // Fetch adapter models for the effective adapter type - const { data: fetchedModels } = useQuery({ - queryKey: ["adapter-models", adapterType], - queryFn: () => agentsApi.adapterModels(adapterType), + const { + data: fetchedModels, + error: fetchedModelsError, + } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType) + : ["agents", "none", "adapter-models", adapterType], + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType), + enabled: Boolean(selectedCompanyId), }); const models = fetchedModels ?? externalModels ?? []; @@ -313,9 +343,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? val!.model : eff("adapterConfig", "model", String(config.model ?? "")); - const thinkingEffortKey = adapterType === "codex_local" ? "modelReasoningEffort" : "effort"; + const thinkingEffortKey = + adapterType === "codex_local" + ? "modelReasoningEffort" + : adapterType === "opencode_local" + ? "variant" + : "effort"; const thinkingEffortOptions = - adapterType === "codex_local" ? codexThinkingEffortOptions : claudeThinkingEffortOptions; + adapterType === "codex_local" + ? codexThinkingEffortOptions + : adapterType === "opencode_local" + ? openCodeThinkingEffortOptions + : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" @@ -324,6 +363,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { "modelReasoningEffort", String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""), ) + : adapterType === "opencode_local" + ? eff("adapterConfig", "variant", String(config.variant ?? "")) : eff("adapterConfig", "effort", String(config.effort ?? "")); const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) @@ -549,7 +590,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } immediate className={inputClass} - placeholder={adapterType === "codex_local" ? "codex" : "claude"} + placeholder={ + adapterType === "codex_local" + ? "codex" + : adapterType === "opencode_local" + ? "opencode" + : "claude" + } /> @@ -563,7 +610,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } open={modelOpen} onOpenChange={setModelOpen} + allowDefault={adapterType !== "opencode_local"} + required={adapterType === "opencode_local"} + groupByProvider={adapterType === "opencode_local"} /> + {fetchedModelsError && ( +

+ {fetchedModelsError instanceof Error + ? fetchedModelsError.message + : "Failed to load adapter models."} +

+ )} @@ -860,7 +920,10 @@ function AdapterTypeDropdown({ if (!item.comingSoon) onChange(item.value); }} > - {item.label} + + {item.value === "opencode_local" ? : null} + {item.label} + {item.comingSoon && ( Coming soon )} @@ -1126,20 +1189,56 @@ function ModelDropdown({ onChange, open, onOpenChange, + allowDefault, + required, + groupByProvider, }: { models: AdapterModel[]; value: string; onChange: (id: string) => void; open: boolean; onOpenChange: (open: boolean) => void; + allowDefault: boolean; + required: boolean; + groupByProvider: boolean; }) { const [modelSearch, setModelSearch] = useState(""); const selected = models.find((m) => m.id === value); - const filteredModels = models.filter((m) => { - if (!modelSearch.trim()) return true; - const q = modelSearch.toLowerCase(); - return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q); - }); + const filteredModels = useMemo(() => { + return models.filter((m) => { + if (!modelSearch.trim()) return true; + const q = modelSearch.toLowerCase(); + const provider = extractProviderId(m.id) ?? ""; + return ( + m.id.toLowerCase().includes(q) || + m.label.toLowerCase().includes(q) || + provider.toLowerCase().includes(q) + ); + }); + }, [models, modelSearch]); + const groupedModels = useMemo(() => { + if (!groupByProvider) { + return [ + { + provider: "models", + entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)), + }, + ]; + } + const map = new Map(); + for (const model of filteredModels) { + const provider = extractProviderId(model.id) ?? "other"; + const group = map.get(provider) ?? []; + group.push(model); + map.set(provider, group); + } + return Array.from(map.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([provider, entries]) => ({ + provider, + entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)), + })); + }, [filteredModels, groupByProvider]); return ( @@ -1153,7 +1252,9 @@ function ModelDropdown({ @@ -1167,33 +1268,45 @@ function ModelDropdown({ autoFocus />
- - {filteredModels.map((m) => ( + {allowDefault && ( + )} + {groupedModels.map((group) => ( +
+ {groupByProvider && ( +
+ {group.provider} ({group.entries.length}) +
+ )} + {group.entries.map((m) => ( + + ))} +
))} {filteredModels.length === 0 && (

No models found.

diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index e4f7f01b..2c6f1a37 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -17,6 +17,7 @@ interface AgentPropertiesProps { const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", + opencode_local: "OpenCode (local)", openclaw: "OpenClaw", cursor: "Cursor", process: "Process", diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 14a902cb..236eb1ec 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -55,14 +55,21 @@ export function NewAgentDialog() { enabled: !!selectedCompanyId && newAgentOpen, }); - const { data: adapterModels } = useQuery({ - queryKey: ["adapter-models", configValues.adapterType], - queryFn: () => agentsApi.adapterModels(configValues.adapterType), - enabled: newAgentOpen, + const { + data: adapterModels, + error: adapterModelsError, + } = useQuery({ + queryKey: + selectedCompanyId + ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) + : ["agents", "none", "adapter-models", configValues.adapterType], + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), + enabled: Boolean(selectedCompanyId) && newAgentOpen, }); const isFirstAgent = !agents || agents.length === 0; const effectiveRole = isFirstAgent ? "ceo" : role; + const [formError, setFormError] = useState(null); // Auto-fill for CEO useEffect(() => { @@ -82,6 +89,9 @@ export function NewAgentDialog() { closeNewAgent(); navigate(agentUrl(result.agent)); }, + onError: (error) => { + setFormError(error instanceof Error ? error.message : "Failed to create agent"); + }, }); function reset() { @@ -91,6 +101,7 @@ export function NewAgentDialog() { setReportsTo(""); setConfigValues(defaultCreateValues); setExpanded(true); + setFormError(null); } function buildAdapterConfig() { @@ -100,6 +111,31 @@ export function NewAgentDialog() { function handleSubmit() { if (!selectedCompanyId || !name.trim()) return; + setFormError(null); + if (configValues.adapterType === "opencode_local") { + const selectedModel = configValues.model.trim(); + if (!selectedModel) { + setFormError("OpenCode requires an explicit model in provider/model format."); + return; + } + if (adapterModelsError) { + setFormError( + adapterModelsError instanceof Error + ? adapterModelsError.message + : "Failed to load OpenCode models.", + ); + return; + } + const discovered = adapterModels ?? []; + if (!discovered.some((entry) => entry.id === selectedModel)) { + setFormError( + discovered.length === 0 + ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." + : `Configured OpenCode model is unavailable: ${selectedModel}`, + ); + return; + } + } createAgent.mutate({ name: name.trim(), role: effectiveRole, @@ -281,6 +317,11 @@ export function NewAgentDialog() { {isFirstAgent ? "This will be the CEO" : ""} +
+ {formError && ( +
{formError}
+ )} +
@@ -604,36 +710,60 @@ export function OnboardingWizard() { className="w-[var(--radix-popover-trigger-width)] p-1" align="start" > - - {(adapterModels ?? []).map((m) => ( + setModelSearch(e.target.value)} + autoFocus + /> + {adapterType !== "opencode_local" && ( - ))} + Default + + )} +
+ {groupedModels.map((group) => ( +
+ {adapterType === "opencode_local" && ( +
+ {group.provider} ({group.entries.length}) +
+ )} + {group.entries.map((m) => ( + + ))} +
+ ))} +
+ {filteredModels.length === 0 && ( +

+ No models discovered. +

+ )}
@@ -678,6 +808,8 @@ export function OnboardingWizard() {

{adapterType === "codex_local" ? `${effectiveAdapterCommand} exec --json -` + : adapterType === "opencode_local" + ? `${effectiveAdapterCommand} run --format json` : `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}

@@ -691,6 +823,12 @@ export function OnboardingWizard() { env or run{" "} codex login.

+ ) : adapterType === "opencode_local" ? ( +

+ If providers are unavailable, run{" "} + opencode models and{" "} + opencode auth login. +

) : (

If login is required, run{" "} diff --git a/ui/src/components/OpenCodeLogoIcon.tsx b/ui/src/components/OpenCodeLogoIcon.tsx new file mode 100644 index 00000000..fb3c1d78 --- /dev/null +++ b/ui/src/components/OpenCodeLogoIcon.tsx @@ -0,0 +1,22 @@ +import { cn } from "../lib/utils"; + +interface OpenCodeLogoIconProps { + className?: string; +} + +export function OpenCodeLogoIcon({ className }: OpenCodeLogoIconProps) { + return ( + <> + OpenCode + OpenCode + + ); +} diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index c56ebf7a..74d46e58 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -23,7 +23,7 @@ export const help: Record = { role: "Organizational role. Determines position and capabilities.", reportsTo: "The agent this one reports to in the org hierarchy.", capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.", - adapterType: "How this agent runs: local CLI (Claude/Codex), OpenClaw webhook, spawned process, or generic HTTP webhook.", + adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.", cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.", promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.", model: "Override the default model used by the adapter.", @@ -34,7 +34,7 @@ export const help: Record = { search: "Enable Codex web search capability during runs.", maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.", command: "The command to execute (e.g. node, python).", - localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).", + localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).", args: "Command-line arguments, comma-separated.", extraArgs: "Extra CLI arguments for local adapters, comma-separated.", envVars: "Environment variables injected into the adapter process. Use plain values or secret references.", @@ -52,6 +52,7 @@ export const help: Record = { export const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", + opencode_local: "OpenCode (local)", openclaw: "OpenClaw", cursor: "Cursor", process: "Process", diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 1a7722d3..83504a12 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -11,6 +11,8 @@ export const queryKeys = { taskSessions: (id: string) => ["agents", "task-sessions", id] as const, keys: (agentId: string) => ["agents", "keys", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, + adapterModels: (companyId: string, adapterType: string) => + ["agents", companyId, "adapter-models", adapterType] as const, }, issues: { list: (companyId: string) => ["issues", companyId] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 9243bc2b..e14243e1 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1154,8 +1154,12 @@ function ConfigurationTab({ const queryClient = useQueryClient(); const { data: adapterModels } = useQuery({ - queryKey: ["adapter-models", agent.adapterType], - queryFn: () => agentsApi.adapterModels(agent.adapterType), + queryKey: + companyId + ? queryKeys.agents.adapterModels(companyId, agent.adapterType) + : ["agents", "none", "adapter-models", agent.adapterType], + queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType), + enabled: Boolean(companyId), }); const updateAgent = useMutation({ diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 801d4837..d9b98d77 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -23,6 +23,7 @@ import type { Agent } from "@paperclipai/shared"; const adapterLabels: Record = { claude_local: "Claude", codex_local: "Codex", + opencode_local: "OpenCode", openclaw: "OpenClaw", process: "Process", http: "HTTP", diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 07df8fe5..2ae2950a 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -18,13 +18,14 @@ const joinAdapterOptions: AgentAdapterType[] = [ const adapterLabels: Record = { claude_local: "Claude (local)", codex_local: "Codex (local)", + opencode_local: "OpenCode (local)", openclaw: "OpenClaw", cursor: "Cursor", process: "Process", http: "HTTP", }; -const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local"]); +const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "opencode_local"]); function dateTime(value: string) { return new Date(value).toLocaleString(); diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index bc9edb65..77f2aa08 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -118,6 +118,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L const adapterLabels: Record = { claude_local: "Claude", codex_local: "Codex", + opencode_local: "OpenCode", openclaw: "OpenClaw", process: "Process", http: "HTTP", diff --git a/vitest.config.ts b/vitest.config.ts index 9bcf27c4..9bf83928 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - projects: ["packages/db", "server", "ui", "cli"], + projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"], }, }); From 69c453b27470147bf6c6c0a6d189a15de5211d40 Mon Sep 17 00:00:00 2001 From: Konan69 Date: Thu, 5 Mar 2026 15:52:59 +0100 Subject: [PATCH 02/93] Address PR feedback for OpenCode integration --- .../opencode-local/src/server/execute.ts | 8 +++--- .../opencode-local/src/server/models.ts | 26 +++++++++++++++++-- .../opencode-local/src/server/test.ts | 25 +++++++++++------- .../opencode-local/src/ui/build-config.ts | 2 ++ server/src/adapters/registry.ts | 3 +-- server/src/routes/agents.ts | 7 ++++- ui/src/api/agents.ts | 4 ++- ui/src/components/AgentConfigForm.tsx | 14 +--------- ui/src/components/NewAgentDialog.tsx | 6 +++++ ui/src/components/NewIssueDialog.tsx | 13 +++------- ui/src/components/OnboardingWizard.tsx | 18 +++---------- ui/src/lib/model-utils.ts | 16 ++++++++++++ 12 files changed, 87 insertions(+), 55 deletions(-) create mode 100644 ui/src/lib/model-utils.ts diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 0d454bfc..2b50c23f 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -115,7 +115,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise(); +const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const; +const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]); function dedupeModels(models: AdapterModel[]): AdapterModel[] { const seen = new Set(); @@ -61,14 +65,30 @@ function normalizeEnv(input: unknown): Record { return env; } +function isVolatileEnvKey(key: string): boolean { + if (VOLATILE_ENV_KEY_EXACT.has(key)) return true; + return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)); +} + +function hashValue(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + function discoveryCacheKey(command: string, cwd: string, env: Record) { const envKey = Object.entries(env) + .filter(([key]) => !isVolatileEnvKey(key)) .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}=${value}`) + .map(([key, value]) => `${key}=${hashValue(value)}`) .join("\n"); return `${command}\n${cwd}\n${envKey}`; } +function pruneExpiredDiscoveryCache(now: number) { + for (const [key, value] of discoveryCache.entries()) { + if (value.expiresAt <= now) discoveryCache.delete(key); + } +} + export async function discoverOpenCodeModels(input: { command?: unknown; cwd?: unknown; @@ -83,6 +103,7 @@ export async function discoverOpenCodeModels(input: { ); const cwd = asString(input.cwd, process.cwd()); const env = normalizeEnv(input.env); + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); const result = await runChildProcess( `opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`, @@ -90,7 +111,7 @@ export async function discoverOpenCodeModels(input: { ["models"], { cwd, - env, + env: runtimeEnv, timeoutSec: 20, graceSec: 3, onLog: async () => {}, @@ -124,6 +145,7 @@ export async function discoverOpenCodeModelsCached(input: { const env = normalizeEnv(input.env); const key = discoveryCacheKey(command, cwd, env); const now = Date.now(); + pruneExpiredDiscoveryCache(now); const cached = discoveryCache.get(key); if (cached && cached.expiresAt > now) return cached.models; diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index f45dd6b1..6e1ac0a3 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -38,6 +38,15 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean; } +function normalizeEnv(input: unknown): Record { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const env: Record = {}; + for (const [key, value] of Object.entries(input as Record)) { + if (typeof value === "string") env[key] = value; + } + return env; +} + const OPENCODE_AUTH_REQUIRED_RE = /(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i; @@ -50,7 +59,7 @@ export async function testEnvironment( const cwd = asString(config.cwd, process.cwd()); try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); checks.push({ code: "opencode_cwd_valid", level: "info", @@ -70,7 +79,7 @@ export async function testEnvironment( for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } - const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); try { await ensureCommandResolvable(command, cwd, runtimeEnv); @@ -91,17 +100,15 @@ export async function testEnvironment( const canRunProbe = checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); - let discoveredModels: string[] = []; let modelValidationPassed = false; if (canRunProbe) { try { - const discovered = await discoverOpenCodeModels({ command, cwd, env }); - discoveredModels = discovered.map((item) => item.id); - if (discoveredModels.length > 0) { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { checks.push({ code: "opencode_models_discovered", level: "info", - message: `Discovered ${discoveredModels.length} model(s) from OpenCode providers.`, + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, }); } else { checks.push({ @@ -135,7 +142,7 @@ export async function testEnvironment( model: configuredModel, command, cwd, - env, + env: runtimeEnv, }); checks.push({ code: "opencode_model_configured", @@ -173,7 +180,7 @@ export async function testEnvironment( args, { cwd, - env, + env: runtimeEnv, timeoutSec: 60, graceSec: 5, stdin: "Respond with hello.", diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index f656585e..3abfd6cd 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -57,6 +57,8 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), adapterModels: (companyId: string, type: string) => - api.get(`/companies/${companyId}/adapters/${type}/models`), + api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, + ), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 3d36febc..f9b08d20 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -23,6 +23,7 @@ import { import { Button } from "@/components/ui/button"; import { FolderOpen, Heart, ChevronDown, X } from "lucide-react"; import { cn } from "../lib/utils"; +import { extractModelName, extractProviderId } from "../lib/model-utils"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; import { @@ -123,19 +124,6 @@ function formatArgList(value: unknown): string { return typeof value === "string" ? value : ""; } -function extractProviderId(modelId: string): string | null { - const trimmed = modelId.trim(); - if (!trimmed.includes("/")) return null; - const provider = trimmed.slice(0, trimmed.indexOf("/")).trim(); - return provider || null; -} - -function extractModelName(modelId: string): string { - const trimmed = modelId.trim(); - if (!trimmed.includes("/")) return trimmed; - return trimmed.slice(trimmed.indexOf("/") + 1); -} - const codexThinkingEffortOptions = [ { id: "", label: "Auto" }, { id: "minimal", label: "Minimal" }, diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 236eb1ec..a5392716 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -58,6 +58,8 @@ export function NewAgentDialog() { const { data: adapterModels, error: adapterModelsError, + isLoading: adapterModelsLoading, + isFetching: adapterModelsFetching, } = useQuery({ queryKey: selectedCompanyId @@ -126,6 +128,10 @@ export function NewAgentDialog() { ); return; } + if (adapterModelsLoading || adapterModelsFetching) { + setFormError("OpenCode models are still loading. Please wait and try again."); + return; + } const discovered = adapterModels ?? []; if (!discovered.some((entry) => entry.id === selectedModel)) { setFormError( diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 811bf156..8653cb1b 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -36,6 +36,7 @@ import { Paperclip, } from "lucide-react"; import { cn } from "../lib/utils"; +import { extractProviderIdWithFallback } from "../lib/model-utils"; import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { AgentIcon } from "./AgentIconPicker"; @@ -54,12 +55,6 @@ function getContrastTextColor(hexColor: string): string { return luminance > 0.5 ? "#000000" : "#ffffff"; } -function extractProviderId(modelId: string): string { - const trimmed = modelId.trim(); - if (!trimmed.includes("/")) return "other"; - return trimmed.slice(0, trimmed.indexOf("/")).trim() || "other"; -} - interface IssueDraft { title: string; description: string; @@ -505,8 +500,8 @@ export function NewIssueDialog() { () => { return [...(assigneeAdapterModels ?? [])] .sort((a, b) => { - const providerA = extractProviderId(a.id); - const providerB = extractProviderId(b.id); + const providerA = extractProviderIdWithFallback(a.id); + const providerB = extractProviderIdWithFallback(b.id); const byProvider = providerA.localeCompare(providerB); if (byProvider !== 0) return byProvider; return a.id.localeCompare(b.id); @@ -514,7 +509,7 @@ export function NewIssueDialog() { .map((model) => ({ id: model.id, label: model.label, - searchText: `${model.id} ${extractProviderId(model.id)}`, + searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`, })); }, [assigneeAdapterModels], diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 1df783a2..50b7a6a3 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -17,6 +17,7 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { cn } from "../lib/utils"; +import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils"; import { getUIAdapter } from "../adapters"; import { defaultCreateValues } from "./agent-config-defaults"; import { @@ -61,19 +62,6 @@ Ensure you have a folder agents/ceo and then download this AGENTS.md as well as And after you've finished that, hire yourself a Founding Engineer agent`; -function extractProviderId(modelId: string): string | null { - const trimmed = modelId.trim(); - if (!trimmed.includes("/")) return null; - const provider = trimmed.slice(0, trimmed.indexOf("/")).trim(); - return provider || null; -} - -function extractModelName(modelId: string): string { - const trimmed = modelId.trim(); - if (!trimmed.includes("/")) return trimmed; - return trimmed.slice(trimmed.indexOf("/") + 1); -} - export function OnboardingWizard() { const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany(); @@ -185,7 +173,7 @@ export function OnboardingWizard() { const query = modelSearch.trim().toLowerCase(); return (adapterModels ?? []).filter((entry) => { if (!query) return true; - const provider = extractProviderId(entry.id) ?? ""; + const provider = extractProviderIdWithFallback(entry.id, ""); return ( entry.id.toLowerCase().includes(query) || entry.label.toLowerCase().includes(query) || @@ -204,7 +192,7 @@ export function OnboardingWizard() { } const groups = new Map>(); for (const entry of filteredModels) { - const provider = extractProviderId(entry.id) ?? "other"; + const provider = extractProviderIdWithFallback(entry.id); const bucket = groups.get(provider) ?? []; bucket.push(entry); groups.set(provider, bucket); diff --git a/ui/src/lib/model-utils.ts b/ui/src/lib/model-utils.ts new file mode 100644 index 00000000..b3753e61 --- /dev/null +++ b/ui/src/lib/model-utils.ts @@ -0,0 +1,16 @@ +export function extractProviderId(modelId: string): string | null { + const trimmed = modelId.trim(); + if (!trimmed.includes("/")) return null; + const provider = trimmed.slice(0, trimmed.indexOf("/")).trim(); + return provider || null; +} + +export function extractProviderIdWithFallback(modelId: string, fallback = "other"): string { + return extractProviderId(modelId) ?? fallback; +} + +export function extractModelName(modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed.includes("/")) return trimmed; + return trimmed.slice(trimmed.indexOf("/") + 1); +} From f4f9d6fd3f3c4d84b2550b4f979c97883f3faaa1 Mon Sep 17 00:00:00 2001 From: Konan69 Date: Thu, 5 Mar 2026 16:07:12 +0100 Subject: [PATCH 03/93] Fix remaining OpenCode review comments --- .../opencode-local/src/server/execute.ts | 21 +-- .../opencode-local/src/server/test.ts | 140 ++++++++++-------- ui/src/components/OnboardingWizard.tsx | 6 + ui/src/lib/model-utils.ts | 2 +- 4 files changed, 99 insertions(+), 70 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 2b50c23f..c3d5d591 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -141,38 +141,41 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - if (!instructionsFilePath) return [] as string[]; + if (!resolvedInstructionsFilePath) return [] as string[]; if (instructionsPrefix.length > 0) { return [ - `Loaded agent instructions from ${instructionsFilePath}`, + `Loaded agent instructions from ${resolvedInstructionsFilePath}`, `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, ]; } return [ - `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, ]; })(); diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 6e1ac0a3..569f0d75 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -81,20 +81,30 @@ export async function testEnvironment( } const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); - try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); + if (cwdInvalid) { checks.push({ - code: "opencode_command_resolvable", - level: "info", - message: `Command is executable: ${command}`, - }); - } catch (err) { - checks.push({ - code: "opencode_command_unresolvable", - level: "error", - message: err instanceof Error ? err.message : "Command is not executable", + code: "opencode_command_skipped", + level: "warn", + message: "Skipped command check because working directory validation failed.", detail: command, }); + } else { + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); + checks.push({ + code: "opencode_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "opencode_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } } const canRunProbe = @@ -174,61 +184,71 @@ export async function testEnvironment( if (variant) args.push("--variant", variant); if (extraArgs.length > 0) args.push(...extraArgs); - const probe = await runChildProcess( - `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, - command, - args, - { - cwd, - env: runtimeEnv, - timeoutSec: 60, - graceSec: 5, - stdin: "Respond with hello.", - onLog: async () => {}, - }, - ); + try { + const probe = await runChildProcess( + `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env: runtimeEnv, + timeoutSec: 60, + graceSec: 5, + stdin: "Respond with hello.", + onLog: async () => {}, + }, + ); - const parsed = parseOpenCodeJsonl(probe.stdout); - const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); - const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + const parsed = parseOpenCodeJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); - if (probe.timedOut) { - checks.push({ - code: "opencode_hello_probe_timed_out", - level: "warn", - message: "OpenCode hello probe timed out.", - hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", - }); - } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { - const summary = parsed.summary.trim(); - const hasHello = /\bhello\b/i.test(summary); - checks.push({ - code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", - level: hasHello ? "info" : "warn", - message: hasHello - ? "OpenCode hello probe succeeded." - : "OpenCode probe ran but did not return `hello` as expected.", - ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), - ...(hasHello - ? {} - : { - hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", - }), - }); - } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { - checks.push({ - code: "opencode_hello_probe_auth_required", - level: "warn", - message: "OpenCode is installed, but provider authentication is not ready.", - ...(detail ? { detail } : {}), - hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", - }); - } else { + if (probe.timedOut) { + checks.push({ + code: "opencode_hello_probe_timed_out", + level: "warn", + message: "OpenCode hello probe timed out.", + hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", + }); + } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "OpenCode hello probe succeeded." + : "OpenCode probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", + }), + }); + } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_auth_required", + level: "warn", + message: "OpenCode is installed, but provider authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", + }); + } else { + checks.push({ + code: "opencode_hello_probe_failed", + level: "error", + message: "OpenCode hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `opencode run --format json` manually in this working directory to debug.", + }); + } + } catch (err) { checks.push({ code: "opencode_hello_probe_failed", level: "error", message: "OpenCode hello probe failed.", - ...(detail ? { detail } : {}), + detail: err instanceof Error ? err.message : String(err), hint: "Run `opencode run --format json` manually in this working directory to debug.", }); } diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 50b7a6a3..dd426e53 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -149,6 +149,8 @@ export function OnboardingWizard() { const { data: adapterModels, error: adapterModelsError, + isLoading: adapterModelsLoading, + isFetching: adapterModelsFetching, } = useQuery({ queryKey: createdCompanyId @@ -332,6 +334,10 @@ export function OnboardingWizard() { ); return; } + if (adapterModelsLoading || adapterModelsFetching) { + setError("OpenCode models are still loading. Please wait and try again."); + return; + } const discoveredModels = adapterModels ?? []; if (!discoveredModels.some((entry) => entry.id === selectedModelId)) { setError( diff --git a/ui/src/lib/model-utils.ts b/ui/src/lib/model-utils.ts index b3753e61..baa721f5 100644 --- a/ui/src/lib/model-utils.ts +++ b/ui/src/lib/model-utils.ts @@ -12,5 +12,5 @@ export function extractProviderIdWithFallback(modelId: string, fallback = "other export function extractModelName(modelId: string): string { const trimmed = modelId.trim(); if (!trimmed.includes("/")) return trimmed; - return trimmed.slice(trimmed.indexOf("/") + 1); + return trimmed.slice(trimmed.indexOf("/") + 1).trim(); } From 0078fa66a34a5de541c432bda9f3791c5d07c879 Mon Sep 17 00:00:00 2001 From: Konan69 Date: Thu, 5 Mar 2026 16:11:11 +0100 Subject: [PATCH 04/93] Use precomputed runtime env in OpenCode execute --- packages/adapters/opencode-local/src/server/execute.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index c3d5d591..06f6478e 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -108,7 +108,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", + ), + ); await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureOpenCodeModelConfiguredAndAvailable({ @@ -216,7 +220,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise Date: Thu, 5 Mar 2026 11:14:30 -0600 Subject: [PATCH 05/93] Clarify zero-flag OpenClaw Docker UI smoke defaults --- doc/DEVELOPING.md | 2 +- docs/guides/openclaw-docker-setup.md | 2 ++ scripts/smoke/openclaw-docker-ui.sh | 9 +++++---- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index d7fd0490..c2f39a56 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -287,5 +287,5 @@ This script lives at `scripts/smoke/openclaw-docker-ui.sh` and automates clone/b Pairing behavior for this smoke script: -- default `OPENCLAW_DISABLE_DEVICE_AUTH=1` (no Control UI pairing prompt for local smoke) +- default `OPENCLAW_DISABLE_DEVICE_AUTH=1` (no Control UI pairing prompt for local smoke; no extra pairing env vars required) - set `OPENCLAW_DISABLE_DEVICE_AUTH=0` to require standard device pairing diff --git a/docs/guides/openclaw-docker-setup.md b/docs/guides/openclaw-docker-setup.md index 9a852f32..8aa2ad82 100644 --- a/docs/guides/openclaw-docker-setup.md +++ b/docs/guides/openclaw-docker-setup.md @@ -33,6 +33,8 @@ To spin up OpenClaw in Docker and print a host-browser dashboard URL in one comm pnpm smoke:openclaw-docker-ui ``` +Default behavior is zero-flag: you can run the command as-is with no pairing-related env vars. + What this command does: - clones/updates `openclaw/openclaw` in `/tmp/openclaw-docker` diff --git a/scripts/smoke/openclaw-docker-ui.sh b/scripts/smoke/openclaw-docker-ui.sh index d29c835d..8b2c3b95 100755 --- a/scripts/smoke/openclaw-docker-ui.sh +++ b/scripts/smoke/openclaw-docker-ui.sh @@ -34,6 +34,7 @@ OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}" OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-45}" OPENCLAW_OPEN_BROWSER="${OPENCLAW_OPEN_BROWSER:-0}" OPENCLAW_SECRETS_FILE="${OPENCLAW_SECRETS_FILE:-$HOME/.secrets}" +# Keep default one-command UX: local smoke run should not require manual pairing. OPENCLAW_DISABLE_DEVICE_AUTH="${OPENCLAW_DISABLE_DEVICE_AUTH:-1}" case "$OPENCLAW_DISABLE_DEVICE_AUTH" in @@ -166,14 +167,13 @@ OpenClaw gateway is running. Dashboard URL: $dashboard_url - -Pairing mode: - OPENCLAW_DISABLE_DEVICE_AUTH=$OPENCLAW_DISABLE_DEVICE_AUTH EOF if [[ "$OPENCLAW_DISABLE_DEVICE_AUTH_JSON" == "true" ]]; then cat < Date: Thu, 5 Mar 2026 11:19:56 -0600 Subject: [PATCH 06/93] Sort assignee picker: recent selections first, then alphabetical Tracks most recently selected assignees in localStorage and sorts both the issue properties and new issue dialog assignee lists accordingly. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssueProperties.tsx | 14 ++++++++--- ui/src/components/NewIssueDialog.tsx | 21 +++++++++------- ui/src/lib/recent-assignees.ts | 36 +++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 ui/src/lib/recent-assignees.ts diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index d95c5f8d..ff7229dc 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Link } from "@/lib/router"; import type { Issue } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -9,6 +9,7 @@ import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; +import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { Identity } from "./Identity"; @@ -181,6 +182,12 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp return project ? projectUrl(project) : `/projects/${id}`; }; + const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]); + const sortedAgents = useMemo( + () => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds), + [agents, recentAssigneeIds], + ); + const assignee = issue.assigneeAgentId ? agents?.find((a) => a.id === issue.assigneeAgentId) : null; @@ -342,8 +349,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp {creatorUserLabel ? `Assign to ${creatorUserLabel === "Me" ? "me" : creatorUserLabel}` : "Assign to requester"} )} - {(agents ?? []) - .filter((a) => a.status !== "terminated") + {sortedAgents .filter((a) => { if (!assigneeSearch.trim()) return true; const q = assigneeSearch.toLowerCase(); @@ -356,7 +362,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50", a.id === issue.assigneeAgentId && "bg-accent" )} - onClick={() => { onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }} + onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }} > {a.name} diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 1b07385f..6b872c28 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -10,6 +10,7 @@ import { authApi } from "../api/auth"; import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; import { useProjectOrder } from "../hooks/useProjectOrder"; +import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { Dialog, DialogContent, @@ -472,16 +473,18 @@ export function NewIssueDialog() { : assigneeAdapterType === "opencode_local" ? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local : ISSUE_THINKING_EFFORT_OPTIONS.claude_local; + const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [newIssueOpen]); const assigneeOptions = useMemo( () => - (agents ?? []) - .filter((agent) => agent.status !== "terminated") - .map((agent) => ({ - id: agent.id, - label: agent.name, - searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, - })), - [agents], + sortAgentsByRecency( + (agents ?? []).filter((agent) => agent.status !== "terminated"), + recentAssigneeIds, + ).map((agent) => ({ + id: agent.id, + label: agent.name, + searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, + })), + [agents, recentAssigneeIds], ); const projectOptions = useMemo( () => @@ -637,7 +640,7 @@ export function NewIssueDialog() { noneLabel="No assignee" searchPlaceholder="Search assignees..." emptyMessage="No assignees found." - onChange={setAssigneeId} + onChange={(id) => { if (id) trackRecentAssignee(id); setAssigneeId(id); }} onConfirm={() => { projectSelectorRef.current?.focus(); }} diff --git a/ui/src/lib/recent-assignees.ts b/ui/src/lib/recent-assignees.ts new file mode 100644 index 00000000..7c3e9c91 --- /dev/null +++ b/ui/src/lib/recent-assignees.ts @@ -0,0 +1,36 @@ +const STORAGE_KEY = "paperclip:recent-assignees"; +const MAX_RECENT = 10; + +export function getRecentAssigneeIds(): string[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function trackRecentAssignee(agentId: string): void { + if (!agentId) return; + const recent = getRecentAssigneeIds().filter((id) => id !== agentId); + recent.unshift(agentId); + if (recent.length > MAX_RECENT) recent.length = MAX_RECENT; + localStorage.setItem(STORAGE_KEY, JSON.stringify(recent)); +} + +export function sortAgentsByRecency( + agents: T[], + recentIds: string[], +): T[] { + const recentIndex = new Map(recentIds.map((id, i) => [id, i])); + return [...agents].sort((a, b) => { + const aRecent = recentIndex.get(a.id); + const bRecent = recentIndex.get(b.id); + if (aRecent !== undefined && bRecent !== undefined) return aRecent - bRecent; + if (aRecent !== undefined) return -1; + if (bRecent !== undefined) return 1; + return a.name.localeCompare(b.name); + }); +} From cdf63d00244aa4e99aee08743e112ba634d39d93 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 11:21:44 -0600 Subject: [PATCH 07/93] Add release-changelog skill and releases/ directory Introduces the release-changelog skill (skills/release-changelog/SKILL.md) that teaches agents to generate user-facing changelogs from git history, changesets, and merged PRs. Includes breaking change detection, categorization heuristics, and structured markdown output template. Also creates the releases/ directory convention for storing versioned release notes. Co-Authored-By: Claude Opus 4.6 --- releases/.gitkeep | 0 skills/release-changelog/SKILL.md | 331 ++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 releases/.gitkeep create mode 100644 skills/release-changelog/SKILL.md diff --git a/releases/.gitkeep b/releases/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/skills/release-changelog/SKILL.md b/skills/release-changelog/SKILL.md new file mode 100644 index 00000000..b5a57bba --- /dev/null +++ b/skills/release-changelog/SKILL.md @@ -0,0 +1,331 @@ +--- +name: release-changelog +description: > + Generate user-facing release changelogs for Paperclip. Reads git history, + merged PRs, and changeset files since the last release tag. Detects breaking + changes, categorizes changes, and outputs structured markdown to + releases/v{version}.md. Use when preparing a release or when asked to + generate a changelog. +--- + +# Release Changelog Skill + +Generate a user-facing changelog for a new Paperclip release. This skill reads +the commit history, changeset files, and merged PRs since the last release tag, +detects breaking changes, categorizes everything, and writes a structured +release notes file. + +**Output:** `releases/v{version}.md` in the repo root. +**Review required:** Always present the draft for human sign-off before +finalizing. Never auto-publish. + +--- + +## Step 1 — Determine the Release Range + +Find the last release tag and the planned version: + +```bash +# Last release tag (most recent semver tag) +git tag --sort=-version:refname | head -1 +# e.g. v0.2.7 + +# All commits since that tag +git log v0.2.7..HEAD --oneline --no-merges +``` + +If no tag exists yet, use the initial commit as the base. + +The new version number comes from one of: +- An explicit argument (e.g. "generate changelog for v0.3.0") +- The bump type (patch/minor/major) applied to the last tag +- The version already set in `cli/package.json` if `scripts/release.sh` has been run + +--- + +## Step 2 — Gather Raw Change Data + +Collect changes from three sources, in priority order: + +### 2a. Git Commits + +```bash +git log v{last}..HEAD --oneline --no-merges +git log v{last}..HEAD --format="%H %s" --no-merges # full SHAs for file diffs +``` + +### 2b. Changeset Files + +Look for unconsumed changesets in `.changeset/`: + +```bash +ls .changeset/*.md | grep -v README.md +``` + +Each changeset file has YAML frontmatter with package names and bump types +(`patch`, `minor`, `major`), followed by a description. Parse these — the bump +type is a strong categorization signal, and the description may contain +user-facing summaries. + +### 2c. Merged PRs (when available) + +If GitHub access is available via `gh`: + +```bash +gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels +``` + +PR titles and bodies are often the best source of user-facing descriptions. +Prefer PR descriptions over raw commit messages when both are available. + +--- + +## Step 3 — Detect Breaking Changes + +Scan for breaking changes using these signals. **Any match flags the release as +containing breaking changes**, which affects version bump requirements and +changelog structure. + +### 3a. Migration Files + +Check for new migration files since the last tag: + +```bash +git diff --name-only v{last}..HEAD -- packages/db/src/migrations/ +``` + +- **New migration files exist** = DB migration required in upgrade. +- Inspect migration content: look for `DROP`, `ALTER ... DROP`, `RENAME` to + distinguish destructive vs. additive migrations. +- Additive-only migrations (new tables, new nullable columns, new indexes) are + safe but should still be mentioned. +- Destructive migrations (column drops, type changes, table drops) = breaking. + +### 3b. Schema Changes + +```bash +git diff v{last}..HEAD -- packages/db/src/schema/ +``` + +Look for: +- Removed or renamed columns/tables +- Changed column types +- Removed default values or nullable constraints +- These indicate breaking DB changes even if no explicit migration file exists + +### 3c. API Route Changes + +```bash +git diff v{last}..HEAD -- server/src/routes/ server/src/api/ +``` + +Look for: +- Removed endpoints +- Changed request/response shapes (removed fields, type changes) +- Changed authentication requirements + +### 3d. Config Changes + +```bash +git diff v{last}..HEAD -- cli/src/config/ packages/*/src/*config* +``` + +Look for renamed, removed, or restructured configuration keys. + +### 3e. Changeset Severity + +Any `.changeset/*.md` file with a `major` bump = explicitly flagged breaking. + +### 3f. Commit Conventions + +Scan commit messages for: +- `BREAKING:` or `BREAKING CHANGE:` prefix +- `!` after the type in conventional commits (e.g. `feat!:`, `fix!:`) + +### Version Bump Rules + +| Condition | Minimum Bump | +|---|---| +| Destructive migration (DROP, RENAME) | `major` | +| Removed API endpoints or fields | `major` | +| Any `major` changeset or `BREAKING:` commit | `major` | +| New (additive) migration | `minor` | +| New features (`feat:` commits, `minor` changesets) | `minor` | +| Bug fixes only | `patch` | + +If the planned bump is lower than the minimum required, **warn the reviewer** +and recommend the correct bump level. + +--- + +## Step 4 — Categorize Changes + +Assign every meaningful change to one of these categories: + +| Category | What Goes Here | Shows in User Notes? | +|---|---|---| +| **Breaking Changes** | Anything requiring user action to upgrade | Yes (top, with warning) | +| **Highlights** | New user-visible features, major behavioral changes | Yes (with 1-2 sentence descriptions) | +| **Improvements** | Enhancements to existing features | Yes (bullet list) | +| **Fixes** | Bug fixes | Yes (bullet list) | +| **Internal** | Refactoring, deps, CI, tests, docs | No (dev changelog only) | + +### Categorization Heuristics + +Use these signals to auto-categorize. When signals conflict, prefer the +higher-visibility category and flag for human review. + +| Signal | Category | +|---|---| +| Commit touches migration files, schema changes | Breaking Change (if destructive) | +| Changeset marked `major` | Breaking Change | +| Commit message has `BREAKING:` or `!:` | Breaking Change | +| New UI components, new routes, new API endpoints | Highlight | +| Commit message starts with `feat:` or `add:` | Highlight or Improvement | +| Changeset marked `minor` | Highlight | +| Commit message starts with `fix:` or `bug:` | Fix | +| Changeset marked `patch` | Fix or Improvement | +| Commit message starts with `chore:`, `refactor:`, `ci:`, `test:`, `docs:` | Internal | +| PR has detailed body with user-facing description | Use PR body as the description | + +### Writing Good Descriptions + +- **Highlights** get 1-2 sentence descriptions explaining the user benefit. + Write from the user's perspective ("You can now..." not "Added a component that..."). +- **Improvements and Fixes** are concise bullet points. +- **Breaking Changes** get detailed descriptions including what changed, + why, and what the user needs to do. +- Group related commits into a single changelog entry. Five commits implementing + one feature = one Highlight entry, not five bullets. +- Omit purely internal changes from user-facing notes entirely. + +--- + +## Step 5 — Write the Changelog + +Output the changelog to `releases/v{version}.md` using this template: + +```markdown +# v{version} + +> Released: {YYYY-MM-DD} + +{If breaking changes detected, include this section:} + +## Breaking Changes + +> **Action required before upgrading.** Read the Upgrade Guide below. + +- **{Breaking change title}** — {What changed and why. What the user needs to do.} + +## Highlights + +- **{Feature name}** — {1-2 sentence description of what it does and why it matters.} + +## Improvements + +- {Concise description of improvement} + +## Fixes + +- {Concise description of fix} + +--- + +{If breaking changes detected, include this section:} + +## Upgrade Guide + +### Before You Update + +1. **Back up your database.** + - SQLite: `cp paperclip.db paperclip.db.backup` + - Postgres: `pg_dump -Fc paperclip > paperclip-pre-{version}.dump` +2. **Note your current version:** `paperclip --version` + +### After Updating + +{Specific steps: run migrations, update configs, etc.} + +### Rolling Back + +If something goes wrong: +1. Restore your database backup +2. `npm install @paperclipai/server@{previous-version}` +``` + +### Template Rules + +- Omit any empty section entirely (don't show "## Fixes" with no bullets). +- The Breaking Changes section always comes first when present. +- The Upgrade Guide always comes last when present. +- Use `**bold**` for feature/change names, regular text for descriptions. +- Keep the entire changelog scannable — a busy user should get the gist from + headings and bold text alone. + +--- + +## Step 6 — Present for Review + +After generating the draft: + +1. **Show the full changelog** to the reviewer (CTO or whoever triggered the release). +2. **Flag ambiguous items** — commits you weren't sure how to categorize, or + items that might be breaking but aren't clearly signaled. +3. **Flag version bump mismatches** — if the planned bump is lower than what + the changes warrant. +4. **Wait for approval** before considering the changelog final. + +If the reviewer requests edits, update `releases/v{version}.md` accordingly. + +Do not proceed to publishing, website updates, or social announcements. Those +are handled by the `release` coordination skill (separate from this one). + +--- + +## Directory Convention + +Release changelogs live in `releases/` at the repo root: + +``` +releases/ + v0.2.7.md + v0.3.0.md + ... +``` + +Each file is named `v{version}.md` matching the git tag. This directory is +committed to the repo and serves as the source of truth for release history. + +The `releases/` directory should be created with a `.gitkeep` if it doesn't +exist yet. + +--- + +## Quick Reference + +```bash +# Full workflow summary: + +# 1. Find last tag +LAST_TAG=$(git tag --sort=-version:refname | head -1) + +# 2. Commits since last tag +git log $LAST_TAG..HEAD --oneline --no-merges + +# 3. Files changed (for breaking change detection) +git diff --name-only $LAST_TAG..HEAD + +# 4. Migration changes specifically +git diff --name-only $LAST_TAG..HEAD -- packages/db/src/migrations/ + +# 5. Schema changes +git diff $LAST_TAG..HEAD -- packages/db/src/schema/ + +# 6. Unconsumed changesets +ls .changeset/*.md | grep -v README.md + +# 7. Merged PRs (if gh available) +gh pr list --state merged --search "merged:>=$(git log -1 --format=%aI $LAST_TAG)" \ + --json number,title,body,labels +``` From fd73d6fcab52295796cd6d2a3ad719b42cd0233c Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 11:23:58 -0600 Subject: [PATCH 08/93] docs(skills): add release coordination workflow --- skills/release/SKILL.md | 215 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 skills/release/SKILL.md diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md new file mode 100644 index 00000000..7fe3d97c --- /dev/null +++ b/skills/release/SKILL.md @@ -0,0 +1,215 @@ +--- +name: release +description: > + Coordinate a full Paperclip release across engineering, website publishing, + and social announcement. Use when CTO/CEO requests "do a release" or + "release vX.Y.Z". Runs pre-flight checks, generates changelog via + release-changelog, executes npm release, creates cross-project follow-up + tasks, and posts a release wrap-up. +--- + +# Release Coordination Skill + +Run the full Paperclip release process as an organizational workflow, not just +an npm publish. + +This skill coordinates: +- App release execution (`scripts/release.sh`) +- User-facing changelog generation (`release-changelog` skill) +- Website publishing task creation +- CMO announcement task creation +- Final release summary with links + +--- + +## Trigger + +Use this skill when leadership asks for: +- "do a release" +- "release {patch|minor|major}" +- "release vX.Y.Z" + +--- + +## Preconditions + +Before proceeding, verify all of the following: + +1. `skills/release-changelog/SKILL.md` exists and is usable. +2. The `release-changelog` dependency work is complete/reviewed before running this flow. +3. App repo working tree is clean. +4. There are commits since the last release tag. +5. You have release permissions (`npm whoami` succeeds for real publish). +6. If running via Paperclip, you have issue context for posting status updates. + +If any precondition fails, stop and report the blocker. + +--- + +## Inputs + +Collect these inputs up front: + +- Release request source issue (if in Paperclip) +- Requested bump (`patch|minor|major`) or explicit version (`vX.Y.Z`) +- Whether this run is dry-run or live publish +- Company/project context for follow-up issue creation + +--- + +## Step 1 - Pre-flight and Version Decision + +Run pre-flight in the App repo root: + +```bash +LAST_TAG=$(git tag --sort=-version:refname | head -1) +git diff --quiet && git diff --cached --quiet +git log "${LAST_TAG}..HEAD" --oneline --no-merges | head -50 +``` + +Then detect minimum required bump: + +```bash +# migrations +git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ + +# schema deltas +git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ + +# breaking commit conventions +git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +``` + +Bump policy: +- Destructive migration/API removal/major changeset/breaking commit -> `major` +- Additive migrations or clear new features -> at least `minor` +- Fixes-only -> `patch` + +If requested bump is lower than required minimum, escalate bump and explain why. + +--- + +## Step 2 - Generate Changelog Draft + +Invoke the `release-changelog` skill and produce: +- `releases/v{version}.md` +- Sections ordered as: Breaking Changes (if any), Highlights, Improvements, Fixes, Upgrade Guide (if any) + +Required behavior: +- Present the draft for human review. +- Flag ambiguous categorization items. +- Flag bump mismatches before publish. +- Do not publish until reviewer confirms. + +--- + +## Step 3 - Run App Release + +After changelog approval, execute: + +```bash +# dry run +./scripts/release.sh {patch|minor|major} --dry-run + +# live release +./scripts/release.sh {patch|minor|major} +``` + +Then capture final release metadata: + +```bash +NEW_TAG=$(git tag --sort=-version:refname | head -1) # e.g. v0.4.0 +NEW_VERSION=${NEW_TAG#v} +NPM_URL="https://www.npmjs.com/package/@paperclipai/cli/v/${NEW_VERSION}" +``` + +If publish fails, stop immediately, keep issue in progress/blocked, and include +failure logs in the update. + +--- + +## Step 4 - Create Cross-Project Follow-up Tasks + +Create at least two tasks in Paperclip: + +1. Website task: publish changelog for `v{version}` +2. CMO task: draft announcement tweet for `v{version}` + +When creating tasks: +- Set `parentId` to the release issue id. +- Carry over `goalId` from the parent issue when present. +- Include `billingCode` for cross-team work when required by company policy. +- Mark website task `high` priority if release has breaking changes. + +Suggested payloads: + +```json +POST /api/companies/{companyId}/issues +{ + "projectId": "{websiteProjectId}", + "parentId": "{releaseIssueId}", + "goalId": "{goalId-or-null}", + "billingCode": "{billingCode-or-null}", + "title": "Publish release notes for v{version}", + "priority": "medium", + "status": "todo", + "description": "Publish /changelog entry for v{version}. Include full markdown from releases/v{version}.md and prominent upgrade guide if breaking changes exist." +} +``` + +```json +POST /api/companies/{companyId}/issues +{ + "projectId": "{workspaceProjectId}", + "parentId": "{releaseIssueId}", + "goalId": "{goalId-or-null}", + "billingCode": "{billingCode-or-null}", + "title": "Draft release announcement tweet for v{version}", + "priority": "medium", + "status": "todo", + "description": "Draft launch tweet with top 1-2 highlights, version number, and changelog URL. If breaking changes exist, include an explicit upgrade-guide callout." +} +``` + +--- + +## Step 5 - Wrap Up the Release Issue + +Post a concise markdown update linking: +- Release issue +- Changelog file (`releases/v{version}.md`) +- npm package URL +- Website task +- CMO task +- Final changelog URL (once website publishes) +- Tweet URL (once published) + +Completion rules: +- Keep issue `in_progress` until website + social tasks are done. +- Mark `done` only when all required artifacts are published and linked. +- If waiting on another team, keep open with clear owner and next action. + +--- + +## Paperclip API Notes (When Running in Agent Context) + +Use: +- `GET /api/companies/{companyId}/projects` to resolve website/workspace project IDs. +- `POST /api/companies/{companyId}/issues` to create follow-up tasks. +- `PATCH /api/issues/{issueId}` with comments for release progress. + +For issue-modifying calls, include: +- `Authorization: Bearer $PAPERCLIP_API_KEY` +- `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` + +--- + +## Failure Handling + +If blocked, update the release issue explicitly with: +- what failed +- exact blocker +- who must act next +- whether any release artifacts were partially published + +Never silently fail mid-release. From 529d53acc0f9e1cc6fccfead0d69313d10b085d8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 11:29:29 -0600 Subject: [PATCH 09/93] Pin OpenClaw Docker UI smoke defaults to OpenAI models --- doc/DEVELOPING.md | 4 ++++ docs/guides/openclaw-docker-setup.md | 3 +++ scripts/smoke/openclaw-docker-ui.sh | 12 ++++++++++++ 3 files changed, 19 insertions(+) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c2f39a56..98a8a1bd 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -289,3 +289,7 @@ Pairing behavior for this smoke script: - default `OPENCLAW_DISABLE_DEVICE_AUTH=1` (no Control UI pairing prompt for local smoke; no extra pairing env vars required) - set `OPENCLAW_DISABLE_DEVICE_AUTH=0` to require standard device pairing + +Model behavior for this smoke script: + +- defaults to OpenAI models (`openai/gpt-5.2` + OpenAI fallback) so it does not require Anthropic auth by default diff --git a/docs/guides/openclaw-docker-setup.md b/docs/guides/openclaw-docker-setup.md index 8aa2ad82..3f44af38 100644 --- a/docs/guides/openclaw-docker-setup.md +++ b/docs/guides/openclaw-docker-setup.md @@ -40,6 +40,7 @@ What this command does: - clones/updates `openclaw/openclaw` in `/tmp/openclaw-docker` - builds `openclaw:local` (unless `OPENCLAW_BUILD=0`) - writes `~/.openclaw/openclaw.json` and Docker `.env` +- pins agent model defaults to OpenAI (`openai/gpt-5.2` with OpenAI fallback) - starts `openclaw-gateway` via Compose (with required `/tmp` tmpfs override) - waits for health and prints: - `http://127.0.0.1:18789/#token=...` @@ -55,6 +56,8 @@ Environment knobs: - `OPENCLAW_OPEN_BROWSER=1` to auto-open the URL on macOS - `OPENCLAW_DISABLE_DEVICE_AUTH=1` (default) disables Control UI device pairing for local smoke - `OPENCLAW_DISABLE_DEVICE_AUTH=0` keeps pairing enabled (then approve browser with `devices` CLI commands) +- `OPENCLAW_MODEL_PRIMARY` (default `openai/gpt-5.2`) +- `OPENCLAW_MODEL_FALLBACK` (default `openai/gpt-5.2-chat-latest`) ### Authenticated mode diff --git a/scripts/smoke/openclaw-docker-ui.sh b/scripts/smoke/openclaw-docker-ui.sh index 8b2c3b95..84f1b803 100755 --- a/scripts/smoke/openclaw-docker-ui.sh +++ b/scripts/smoke/openclaw-docker-ui.sh @@ -36,6 +36,8 @@ OPENCLAW_OPEN_BROWSER="${OPENCLAW_OPEN_BROWSER:-0}" OPENCLAW_SECRETS_FILE="${OPENCLAW_SECRETS_FILE:-$HOME/.secrets}" # Keep default one-command UX: local smoke run should not require manual pairing. OPENCLAW_DISABLE_DEVICE_AUTH="${OPENCLAW_DISABLE_DEVICE_AUTH:-1}" +OPENCLAW_MODEL_PRIMARY="${OPENCLAW_MODEL_PRIMARY:-openai/gpt-5.2}" +OPENCLAW_MODEL_FALLBACK="${OPENCLAW_MODEL_FALLBACK:-openai/gpt-5.2-chat-latest}" case "$OPENCLAW_DISABLE_DEVICE_AUTH" in 1|true|TRUE|True|yes|YES|Yes) @@ -101,6 +103,12 @@ cat > "$OPENCLAW_CONFIG_DIR/openclaw.json" < Date: Thu, 5 Mar 2026 15:07:54 +0100 Subject: [PATCH 10/93] move docker into `authenticated` deployment mode --- Dockerfile | 2 +- docker-compose.quickstart.yml | 3 +++ docker-compose.yml | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2339d2ff..dd4c8a84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ ENV NODE_ENV=production \ PAPERCLIP_HOME=/paperclip \ PAPERCLIP_INSTANCE_ID=default \ PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \ - PAPERCLIP_DEPLOYMENT_MODE=local_trusted \ + PAPERCLIP_DEPLOYMENT_MODE=authenticated \ PAPERCLIP_DEPLOYMENT_EXPOSURE=private VOLUME ["/paperclip"] diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index 373c5d48..f41c7388 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -10,5 +10,8 @@ services: PAPERCLIP_HOME: "/paperclip" OPENAI_API_KEY: "${OPENAI_API_KEY:-}" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + PAPERCLIP_DEPLOYMENT_MODE: "authenticated" + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" + BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-dev-insecure-change-me}" volumes: - "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip" diff --git a/docker-compose.yml b/docker-compose.yml index d3cdc6ad..e0cea4f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,9 @@ services: DATABASE_URL: postgres://paperclip:paperclip@db:5432/paperclip PORT: "3100" SERVE_UI: "true" + PAPERCLIP_DEPLOYMENT_MODE: "authenticated" + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" + BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-dev-insecure-change-me}" depends_on: - db From e08362b66703371c3f1f84651d0db5deae65158f Mon Sep 17 00:00:00 2001 From: Victor Duarte Date: Thu, 5 Mar 2026 15:08:36 +0100 Subject: [PATCH 11/93] update docker base image --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index dd4c8a84..fcfd290d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-bookworm-slim AS base +FROM node:lts-trixie-slim AS base RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl git \ && rm -rf /var/lib/apt/lists/* From d2dd8d0cc5aff45964c0728431c24bb4ea2ab3e2 Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 11:38:38 -0300 Subject: [PATCH 12/93] fix incorrect pkg scope --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index fcfd290d..453b3aa6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,8 +21,8 @@ FROM base AS build WORKDIR /app COPY --from=deps /app /app COPY . . -RUN pnpm --filter @paperclip/ui build -RUN pnpm --filter @paperclip/server build +RUN pnpm --filter @paperclipai/ui build +RUN pnpm --filter @paperclipai/server build FROM base AS production WORKDIR /app From 1f261d90f3ef49c7e2af6e854ba8e9047205c47d Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 11:39:13 -0300 Subject: [PATCH 13/93] add missing `openclaw` adapter from deps stage --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 453b3aa6..fea8ddb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ COPY packages/db/package.json packages/db/ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ +COPY packages/adapters/openclaw/package.json packages/adapters/openclaw/ RUN pnpm install --frozen-lockfile FROM base AS build From e5049a448ebe9735a3e388718cc9835f3ca4f507 Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 12:39:37 -0300 Subject: [PATCH 14/93] update typing to node v24 from v20 --- packages/adapter-utils/package.json | 1 + packages/adapter-utils/src/server-utils.ts | 18 ++++++--- packages/adapters/claude-local/package.json | 1 + packages/adapters/codex-local/package.json | 1 + packages/adapters/openclaw/package.json | 1 + packages/db/package.json | 1 + server/package.json | 1 + server/src/index.ts | 2 +- server/src/realtime/live-events-ws.ts | 42 ++++++++++++++++++--- 9 files changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 8a9411af..118eb895 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -30,6 +30,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 1c8b76bd..76efba86 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -15,6 +15,14 @@ interface RunningProcess { graceSec: number; } +type ChildProcessWithEvents = ChildProcess & { + on(event: "error", listener: (err: Error) => void): ChildProcess; + on( + event: "close", + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): ChildProcess; +}; + export const runningProcesses = new Map(); export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; @@ -217,7 +225,7 @@ export async function runChildProcess( env: mergedEnv, shell: false, stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], - }); + }) as ChildProcessWithEvents; if (opts.stdin != null && child.stdin) { child.stdin.write(opts.stdin); @@ -244,7 +252,7 @@ export async function runChildProcess( }, opts.timeoutSec * 1000) : null; - child.stdout?.on("data", (chunk) => { + child.stdout?.on("data", (chunk: unknown) => { const text = String(chunk); stdout = appendWithCap(stdout, text); logChain = logChain @@ -252,7 +260,7 @@ export async function runChildProcess( .catch((err) => onLogError(err, runId, "failed to append stdout log chunk")); }); - child.stderr?.on("data", (chunk) => { + child.stderr?.on("data", (chunk: unknown) => { const text = String(chunk); stderr = appendWithCap(stderr, text); logChain = logChain @@ -260,7 +268,7 @@ export async function runChildProcess( .catch((err) => onLogError(err, runId, "failed to append stderr log chunk")); }); - child.on("error", (err) => { + child.on("error", (err: Error) => { if (timeout) clearTimeout(timeout); runningProcesses.delete(runId); const errno = (err as NodeJS.ErrnoException).code; @@ -272,7 +280,7 @@ export async function runChildProcess( reject(new Error(msg)); }); - child.on("close", (code, signal) => { + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { if (timeout) clearTimeout(timeout); runningProcesses.delete(runId); void logChain.finally(() => { diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index faa16b64..c999013d 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index 9fc9b581..e6853aa7 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -45,6 +45,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/adapters/openclaw/package.json b/packages/adapters/openclaw/package.json index 22acb5e3..c8bd561d 100644 --- a/packages/adapters/openclaw/package.json +++ b/packages/adapters/openclaw/package.json @@ -44,6 +44,7 @@ "picocolors": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.6.0", "typescript": "^5.7.3" } } diff --git a/packages/db/package.json b/packages/db/package.json index 845d5487..0a0b4521 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -38,6 +38,7 @@ "postgres": "^3.4.5" }, "devDependencies": { + "@types/node": "^24.6.0", "drizzle-kit": "^0.31.9", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/server/package.json b/server/package.json index 781f452a..e479eafe 100644 --- a/server/package.json +++ b/server/package.json @@ -58,6 +58,7 @@ "@types/express-serve-static-core": "^5.0.0", "@types/multer": "^2.0.0", "@types/supertest": "^6.0.2", + "@types/ws": "^8.18.1", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/server/src/index.ts b/server/src/index.ts index ada5743f..125d3021 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -444,7 +444,7 @@ const app = await createApp(db as any, { betterAuthHandler, resolveSession, }); -const server = createServer(app); +const server = createServer(app as unknown as Parameters[0]); const listenPort = await detectPort(config.port); if (listenPort !== config.port) { diff --git a/server/src/realtime/live-events-ws.ts b/server/src/realtime/live-events-ws.ts index b082ecb6..d18e2930 100644 --- a/server/src/realtime/live-events-ws.ts +++ b/server/src/realtime/live-events-ws.ts @@ -1,15 +1,45 @@ import { createHash } from "node:crypto"; import type { IncomingMessage, Server as HttpServer } from "node:http"; +import { createRequire } from "node:module"; import type { Duplex } from "node:stream"; import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentApiKeys, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import type { DeploymentMode } from "@paperclipai/shared"; -import { WebSocket, WebSocketServer } from "ws"; import type { BetterAuthSessionResult } from "../auth/better-auth.js"; import { logger } from "../middleware/logger.js"; import { subscribeCompanyLiveEvents } from "../services/live-events.js"; +interface WsSocket { + readyState: number; + ping(): void; + send(data: string): void; + terminate(): void; + close(code?: number, reason?: string): void; + on(event: "pong", listener: () => void): void; + on(event: "close", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; +} + +interface WsServer { + clients: Set; + on(event: "connection", listener: (socket: WsSocket, req: IncomingMessage) => void): void; + on(event: "close", listener: () => void): void; + handleUpgrade( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + callback: (ws: WsSocket) => void, + ): void; + emit(event: "connection", ws: WsSocket, req: IncomingMessage): boolean; +} + +const require = createRequire(import.meta.url); +const { WebSocket, WebSocketServer } = require("ws") as { + WebSocket: { OPEN: number }; + WebSocketServer: new (opts: { noServer: boolean }) => WsServer; +}; + interface UpgradeContext { companyId: string; actorType: "board" | "agent"; @@ -154,8 +184,8 @@ export function setupLiveEventsWebSocketServer( }, ) { const wss = new WebSocketServer({ noServer: true }); - const cleanupByClient = new Map void>(); - const aliveByClient = new Map(); + const cleanupByClient = new Map void>(); + const aliveByClient = new Map(); const pingInterval = setInterval(() => { for (const socket of wss.clients) { @@ -168,7 +198,7 @@ export function setupLiveEventsWebSocketServer( } }, 30000); - wss.on("connection", (socket, req) => { + wss.on("connection", (socket: WsSocket, req: IncomingMessage) => { const context = (req as IncomingMessageWithContext).paperclipUpgradeContext; if (!context) { socket.close(1008, "missing context"); @@ -194,7 +224,7 @@ export function setupLiveEventsWebSocketServer( aliveByClient.delete(socket); }); - socket.on("error", (err) => { + socket.on("error", (err: Error) => { logger.warn({ err, companyId: context.companyId }, "live websocket client error"); }); }); @@ -229,7 +259,7 @@ export function setupLiveEventsWebSocketServer( const reqWithContext = req as IncomingMessageWithContext; reqWithContext.paperclipUpgradeContext = context; - wss.handleUpgrade(req, socket, head, (ws) => { + wss.handleUpgrade(req, socket, head, (ws: WsSocket) => { wss.emit("connection", ws, reqWithContext); }); }) From c610951a71ce7566969d52a49850c60399a69036 Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 12:39:50 -0300 Subject: [PATCH 15/93] update lock file --- pnpm-lock.yaml | 135 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85f9d4b2..c96ed162 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: packages/adapter-utils: devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.11.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -91,6 +94,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.11.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -104,6 +110,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.11.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -130,6 +139,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.11.0 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -159,6 +171,9 @@ importers: specifier: ^3.4.5 version: 3.4.8 devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.11.0 drizzle-kit: specifier: ^0.31.9 version: 0.31.9 @@ -170,7 +185,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) packages/shared: dependencies: @@ -263,6 +278,9 @@ importers: '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 supertest: specifier: ^7.0.0 version: 7.2.2 @@ -2816,6 +2834,9 @@ packages: '@types/node@22.19.11': resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} + '@types/node@24.11.0': + resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} + '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -2851,6 +2872,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -8194,6 +8218,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.11.0': + dependencies: + undici-types: 7.16.0 + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -8235,6 +8263,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.2.3 + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': @@ -8257,6 +8289,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@vitest/spy': 3.2.4 @@ -10601,6 +10641,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: cac: 6.7.14 @@ -10622,6 +10683,21 @@ snapshots: - tsx - yaml + vite@6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.11.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 + vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.25.12 @@ -10637,6 +10713,21 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 + vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.11.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + tsx: 4.21.0 + vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.27.3 @@ -10652,6 +10743,48 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 24.11.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 From 57db28e9e6765f5cb4a7556763630b5bbf5da8c5 Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 12:55:10 -0300 Subject: [PATCH 16/93] wait for a health db --- docker-compose.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index e0cea4f0..3de17a57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,11 @@ services: POSTGRES_USER: paperclip POSTGRES_PASSWORD: paperclip POSTGRES_DB: paperclip + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paperclip -d paperclip"] + interval: 2s + timeout: 5s + retries: 30 ports: - "5432:5432" volumes: @@ -22,7 +27,8 @@ services: PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-dev-insecure-change-me}" depends_on: - - db + db: + condition: service_healthy volumes: pgdata: From 1e5e09f0fa00d2021c9e5f8a503e916debaacaeb Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 14:19:28 -0300 Subject: [PATCH 17/93] expose `PAPERCLIP_ALLOWED_HOSTNAMES` in compose files --- docker-compose.quickstart.yml | 1 + docker-compose.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index f41c7388..f44549b4 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -12,6 +12,7 @@ services: ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" PAPERCLIP_DEPLOYMENT_MODE: "authenticated" PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" + PAPERCLIP_ALLOWED_HOSTNAMES: "${PAPERCLIP_ALLOWED_HOSTNAMES:-localhost}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-dev-insecure-change-me}" volumes: - "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip" diff --git a/docker-compose.yml b/docker-compose.yml index 3de17a57..969d620b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: SERVE_UI: "true" PAPERCLIP_DEPLOYMENT_MODE: "authenticated" PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" + PAPERCLIP_ALLOWED_HOSTNAMES: "${PAPERCLIP_ALLOWED_HOSTNAMES:-localhost}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-dev-insecure-change-me}" depends_on: db: From 4b8e880a961d073bfef540b7c1f368397edd270e Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 14:20:35 -0300 Subject: [PATCH 18/93] remove an insecure default auth secret --- docker-compose.quickstart.yml | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index f44549b4..4193c2f9 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -13,6 +13,6 @@ services: PAPERCLIP_DEPLOYMENT_MODE: "authenticated" PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" PAPERCLIP_ALLOWED_HOSTNAMES: "${PAPERCLIP_ALLOWED_HOSTNAMES:-localhost}" - BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-dev-insecure-change-me}" + BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}" volumes: - "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip" diff --git a/docker-compose.yml b/docker-compose.yml index 969d620b..64817b55 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: PAPERCLIP_DEPLOYMENT_MODE: "authenticated" PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" PAPERCLIP_ALLOWED_HOSTNAMES: "${PAPERCLIP_ALLOWED_HOSTNAMES:-localhost}" - BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-dev-insecure-change-me}" + BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}" depends_on: db: condition: service_healthy From 0d36cf00f892793aba4cca70601d843855ba90c1 Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 14:22:45 -0300 Subject: [PATCH 19/93] Add artifact-check to fail fast on broken builds --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index fea8ddb1..1bfccd1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ COPY --from=deps /app /app COPY . . RUN pnpm --filter @paperclipai/ui build RUN pnpm --filter @paperclipai/server build +RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1) FROM base AS production WORKDIR /app From f75a4d95896dcd365de6e750778d2a074e262b59 Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 14:24:00 -0300 Subject: [PATCH 20/93] force `@types/node@24` in the server --- pnpm-lock.yaml | 13 ++++++++----- server/package.json | 1 + 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c96ed162..fcfb7e78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,7 +228,7 @@ importers: version: link:../packages/shared better-auth: specifier: 1.4.18 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) detect-port: specifier: ^2.1.0 version: 2.1.0 @@ -275,6 +275,9 @@ importers: '@types/multer': specifier: ^2.0.0 version: 2.0.0 + '@types/node': + specifier: ^24.6.0 + version: 24.11.0 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 @@ -292,10 +295,10 @@ importers: version: 5.9.3 vite: specifier: ^6.1.0 - version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) ui: dependencies: @@ -8380,7 +8383,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -8400,7 +8403,7 @@ snapshots: pg: 8.18.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) better-call@1.1.8(zod@4.3.6): dependencies: diff --git a/server/package.json b/server/package.json index e479eafe..2e470111 100644 --- a/server/package.json +++ b/server/package.json @@ -57,6 +57,7 @@ "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.0.0", "@types/multer": "^2.0.0", + "@types/node": "^24.6.0", "@types/supertest": "^6.0.2", "@types/ws": "^8.18.1", "supertest": "^7.0.0", From a85511dad2be40ce06050b245f53d12f056e1fc4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 11:38:09 -0600 Subject: [PATCH 21/93] Add idempotency guards to release skills - release-changelog: Step 0 checks for existing changelog file before generating. Asks reviewer to keep/regenerate/update. Never overwrites silently. Clarifies this skill never triggers version bumps. - release: Step 0 idempotency table covering all steps. Tag check before npm publish prevents double-release. Task search before creation prevents duplicate follow-up tasks. Supports iterating on changelogs pre-publish. Co-Authored-By: Claude Opus 4.6 --- skills/release-changelog/SKILL.md | 32 ++++++++++++++++++ skills/release/SKILL.md | 54 +++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/skills/release-changelog/SKILL.md b/skills/release-changelog/SKILL.md index b5a57bba..d28fa931 100644 --- a/skills/release-changelog/SKILL.md +++ b/skills/release-changelog/SKILL.md @@ -21,6 +21,38 @@ finalizing. Never auto-publish. --- +## Step 0 — Idempotency Check + +Before generating anything, check if a changelog already exists for this version: + +```bash +ls releases/v{version}.md 2>/dev/null +``` + +**If the file already exists:** + +1. Read the existing changelog and present it to the reviewer. +2. Ask: "A changelog for v{version} already exists. Do you want to (a) keep it + as-is, (b) regenerate from scratch, or (c) update specific sections?" +3. If the reviewer says keep it → **stop here**. Do not overwrite. This skill is + done. +4. If the reviewer says regenerate → back up the existing file to + `releases/v{version}.md.prev`, then proceed from Step 1. +5. If the reviewer says update → read the existing file, proceed through Steps + 1-4 to gather fresh data, then merge changes into the existing file rather + than replacing it wholesale. Preserve any manual edits the reviewer previously + made. + +**If the file does not exist:** Proceed normally from Step 1. + +**Critical rule:** This skill NEVER triggers a version bump. It only reads git +history and writes a markdown file. The `release.sh` script is the only thing +that bumps versions, and it is called separately by the `release` coordination +skill. Running this skill multiple times is always safe — worst case it +overwrites a draft changelog (with reviewer permission). + +--- + ## Step 1 — Determine the Release Range Find the last release tag and the planned version: diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 7fe3d97c..aff6d06c 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -57,6 +57,29 @@ Collect these inputs up front: --- +## Step 0 — Idempotency Guards + +Each step in this skill is designed to be safely re-runnable. Before executing +any step, check whether it has already been completed: + +| Step | How to Check | If Already Done | +|---|---|---| +| Changelog | `releases/v{version}.md` exists | Read it, ask reviewer to confirm or update. Do NOT regenerate without asking. | +| npm publish | `git tag v{version}` exists | Skip `release.sh` entirely. A tag means the version is already published. **Never re-run release.sh for an existing tag** — it will fail or create a duplicate. | +| Website task | Search Paperclip issues for "Publish release notes for v{version}" | Skip creation. Link the existing task. | +| CMO task | Search Paperclip issues for "release announcement tweet for v{version}" | Skip creation. Link the existing task. | + +**The golden rule:** If a git tag `v{version}` already exists, the npm release +has already happened. Only post-publish tasks (website, CMO, wrap-up) should +proceed. Never attempt to re-publish. + +**Iterating on changelogs:** You can re-run this skill with an existing changelog +to refine it _before_ the npm publish step. The `release-changelog` skill has +its own idempotency check and will ask the reviewer what to do with an existing +file. This is the expected workflow for iterating on release notes. + +--- + ## Step 1 - Pre-flight and Version Decision Run pre-flight in the App repo root: @@ -91,6 +114,10 @@ If requested bump is lower than required minimum, escalate bump and explain why. ## Step 2 - Generate Changelog Draft +First, check if `releases/v{version}.md` already exists. If it does, the +`release-changelog` skill will detect this and ask the reviewer whether to keep, +regenerate, or update it. **Do not silently overwrite an existing changelog.** + Invoke the `release-changelog` skill and produce: - `releases/v{version}.md` - Sections ordered as: Breaking Changes (if any), Highlights, Improvements, Fixes, Upgrade Guide (if any) @@ -105,13 +132,24 @@ Required behavior: ## Step 3 - Run App Release -After changelog approval, execute: +**Idempotency check:** Before running `release.sh`, verify the tag doesn't +already exist: + +```bash +git tag -l "v{version}" +``` + +If the tag exists, this version has already been published. **Do not re-run +`release.sh`.** Skip to Step 4 (follow-up tasks). Log that the publish was +already completed and capture the existing tag metadata. + +If the tag does NOT exist, proceed with the release: ```bash # dry run ./scripts/release.sh {patch|minor|major} --dry-run -# live release +# live release (only after dry-run review) ./scripts/release.sh {patch|minor|major} ``` @@ -130,7 +168,17 @@ failure logs in the update. ## Step 4 - Create Cross-Project Follow-up Tasks -Create at least two tasks in Paperclip: +**Idempotency check:** Before creating tasks, search for existing ones: + +``` +GET /api/companies/{companyId}/issues?q=release+notes+v{version} +GET /api/companies/{companyId}/issues?q=announcement+tweet+v{version} +``` + +If matching tasks already exist (check title contains the version), skip +creation and link the existing tasks instead. Do not create duplicates. + +Create at least two tasks in Paperclip (only if they don't already exist): 1. Website task: publish changelog for `v{version}` 2. CMO task: draft announcement tweet for `v{version}` From 1b98c2b27920221e44e8150f9d70dc3c48651943 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 11:42:50 -0600 Subject: [PATCH 22/93] Isolate OpenClaw smoke state to avoid stale auth drift --- doc/DEVELOPING.md | 5 +++++ docs/guides/openclaw-docker-setup.md | 4 +++- scripts/smoke/openclaw-docker-ui.sh | 11 ++++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 98a8a1bd..c73d22e5 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -293,3 +293,8 @@ Pairing behavior for this smoke script: Model behavior for this smoke script: - defaults to OpenAI models (`openai/gpt-5.2` + OpenAI fallback) so it does not require Anthropic auth by default + +State behavior for this smoke script: + +- defaults to isolated config dir `~/.openclaw-paperclip-smoke` +- resets smoke agent state each run by default (`OPENCLAW_RESET_STATE=1`) to avoid stale provider/auth drift diff --git a/docs/guides/openclaw-docker-setup.md b/docs/guides/openclaw-docker-setup.md index 3f44af38..50de462b 100644 --- a/docs/guides/openclaw-docker-setup.md +++ b/docs/guides/openclaw-docker-setup.md @@ -39,7 +39,7 @@ What this command does: - clones/updates `openclaw/openclaw` in `/tmp/openclaw-docker` - builds `openclaw:local` (unless `OPENCLAW_BUILD=0`) -- writes `~/.openclaw/openclaw.json` and Docker `.env` +- writes isolated smoke config under `~/.openclaw-paperclip-smoke/openclaw.json` and Docker `.env` - pins agent model defaults to OpenAI (`openai/gpt-5.2` with OpenAI fallback) - starts `openclaw-gateway` via Compose (with required `/tmp` tmpfs override) - waits for health and prints: @@ -58,6 +58,8 @@ Environment knobs: - `OPENCLAW_DISABLE_DEVICE_AUTH=0` keeps pairing enabled (then approve browser with `devices` CLI commands) - `OPENCLAW_MODEL_PRIMARY` (default `openai/gpt-5.2`) - `OPENCLAW_MODEL_FALLBACK` (default `openai/gpt-5.2-chat-latest`) +- `OPENCLAW_CONFIG_DIR` (default `~/.openclaw-paperclip-smoke`) +- `OPENCLAW_RESET_STATE=1` (default) resets smoke agent state on each run to avoid stale auth/session drift ### Authenticated mode diff --git a/scripts/smoke/openclaw-docker-ui.sh b/scripts/smoke/openclaw-docker-ui.sh index 84f1b803..c86348c8 100755 --- a/scripts/smoke/openclaw-docker-ui.sh +++ b/scripts/smoke/openclaw-docker-ui.sh @@ -24,7 +24,7 @@ require_cmd grep OPENCLAW_REPO_URL="${OPENCLAW_REPO_URL:-https://github.com/openclaw/openclaw.git}" OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}" OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}" -OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" +OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw-paperclip-smoke}" OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$OPENCLAW_CONFIG_DIR/workspace}" OPENCLAW_GATEWAY_PORT="${OPENCLAW_GATEWAY_PORT:-18789}" OPENCLAW_BRIDGE_PORT="${OPENCLAW_BRIDGE_PORT:-18790}" @@ -38,6 +38,7 @@ OPENCLAW_SECRETS_FILE="${OPENCLAW_SECRETS_FILE:-$HOME/.secrets}" OPENCLAW_DISABLE_DEVICE_AUTH="${OPENCLAW_DISABLE_DEVICE_AUTH:-1}" OPENCLAW_MODEL_PRIMARY="${OPENCLAW_MODEL_PRIMARY:-openai/gpt-5.2}" OPENCLAW_MODEL_FALLBACK="${OPENCLAW_MODEL_FALLBACK:-openai/gpt-5.2-chat-latest}" +OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}" case "$OPENCLAW_DISABLE_DEVICE_AUTH" in 1|true|TRUE|True|yes|YES|Yes) @@ -76,6 +77,10 @@ if [[ "$OPENCLAW_BUILD" == "1" ]]; then fi log "writing OpenClaw config under $OPENCLAW_CONFIG_DIR" +if [[ "$OPENCLAW_RESET_STATE" == "1" ]]; then + # Ensure deterministic smoke behavior across reruns by removing stale agent/auth state. + rm -rf "$OPENCLAW_CONFIG_DIR/agents" +fi mkdir -p "$OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_CONFIG_DIR/identity" "$OPENCLAW_CONFIG_DIR/credentials" chmod 700 "$OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR/credentials" @@ -185,6 +190,8 @@ Pairing: (Security tradeoff: enable pairing with OPENCLAW_DISABLE_DEVICE_AUTH=0.) Model: ${OPENCLAW_MODEL_PRIMARY} (fallback: ${OPENCLAW_MODEL_FALLBACK}) +State: + OPENCLAW_RESET_STATE=$OPENCLAW_RESET_STATE Useful commands: docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" -f "$COMPOSE_OVERRIDE" logs -f openclaw-gateway @@ -199,6 +206,8 @@ Pairing: docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" -f "$COMPOSE_OVERRIDE" run --rm openclaw-cli devices approve --latest Model: ${OPENCLAW_MODEL_PRIMARY} (fallback: ${OPENCLAW_MODEL_FALLBACK}) +State: + OPENCLAW_RESET_STATE=$OPENCLAW_RESET_STATE Useful commands: docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" -f "$COMPOSE_OVERRIDE" logs -f openclaw-gateway From 9da1803f29caabfd79dc3e735fcb3a0c343bce1c Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 11:44:38 -0600 Subject: [PATCH 23/93] docs: add user-facing changelog for v0.2.7 Generated via release-changelog skill. Covers onboarding resilience improvements, Docker flow fixes, markdown rendering fix, and embedded postgres dependency fix. Co-Authored-By: Claude Opus 4.6 --- releases/v0.2.7.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 releases/v0.2.7.md diff --git a/releases/v0.2.7.md b/releases/v0.2.7.md new file mode 100644 index 00000000..b086d78e --- /dev/null +++ b/releases/v0.2.7.md @@ -0,0 +1,15 @@ +# v0.2.7 + +> Released: 2026-03-04 + +## Improvements + +- **Onboarding resilience** — The setup wizard now continues after a failed environment test instead of getting stuck. If your Anthropic API key doesn't work, you can retry or clear it and proceed with a different configuration. +- **Docker onboarding flow** — Cleaner defaults for the Docker smoke test and improved console guidance during `npx` onboarding runs. +- **Issue search in skills** — The Paperclip skill reference now documents the `q=` search parameter for finding issues by keyword. + +## Fixes + +- **Markdown list rendering** — Fixed list markers (`-`, `*`) not rendering correctly in the editor and comment views. +- **Archived companies hidden** — The new issue dialog no longer shows archived companies in the company selector. +- **Embedded Postgres requirement** — The server now correctly requires the `embedded-postgres` dependency when running in embedded DB mode, preventing startup failures. From 201d91b4f56f0bc9bf2f8c179dfa89dd18a00693 Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 14:53:42 -0300 Subject: [PATCH 24/93] add support to `cursor` and `opencode` in containerized instances --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 1bfccd1d..0fcc3216 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,9 @@ COPY packages/db/package.json packages/db/ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ +COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/openclaw/package.json packages/adapters/openclaw/ +COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ RUN pnpm install --frozen-lockfile FROM base AS build From d8fb93edcf9c83edc29af8f4aba0d00ef757f7ab Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 12:00:38 -0600 Subject: [PATCH 25/93] Auto-copy invite link on creation and replace Copy button with inline icon - Invite link is now automatically copied to clipboard when created - "Copied" badge appears next to the share link header for 2s - Standalone "Copy link" button replaced with a small copy icon next to the link - Icon toggles to a green checkmark while "copied" feedback is shown Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/CompanySettings.tsx | 57 +++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index c510c35f..003be3e8 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -6,7 +6,7 @@ import { companiesApi } from "../api/companies"; import { accessApi } from "../api/access"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; -import { Settings } from "lucide-react"; +import { Settings, Check, Copy } from "lucide-react"; import { CompanyPatternIcon } from "../components/CompanyPatternIcon"; import { Field, ToggleField, HintIcon } from "../components/agent-config-primitives"; @@ -30,6 +30,7 @@ export function CompanySettings() { const [inviteLink, setInviteLink] = useState(null); const [inviteError, setInviteError] = useState(null); + const [copied, setCopied] = useState(false); const generalDirty = !!selectedCompany && @@ -61,13 +62,18 @@ export function CompanySettings() { allowedJoinTypes: "both", expiresInHours: 72, }), - onSuccess: (invite) => { + onSuccess: async (invite) => { setInviteError(null); const base = window.location.origin.replace(/\/+$/, ""); const absoluteUrl = invite.inviteUrl.startsWith("http") ? invite.inviteUrl : `${base}${invite.inviteUrl}`; setInviteLink(absoluteUrl); + try { + await navigator.clipboard.writeText(absoluteUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { /* clipboard may not be available */ } queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) }); }, onError: (err) => { @@ -247,27 +253,38 @@ export function CompanySettings() { Generate a link to invite humans or agents to this company. -

- - {inviteLink && ( - - )} -
+ {inviteError &&

{inviteError}

} {inviteLink && (
-
Share link
-
{inviteLink}
+
+
Share link
+ {copied && ( + + + Copied + + )} +
+
+
{inviteLink}
+ +
)} From 089a2d08bfea1b5c0dadb102c5bc2445c1d9f0bc Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 12:10:01 -0600 Subject: [PATCH 26/93] Add agent invite message flow and txt onboarding link UX --- packages/shared/src/validators/access.ts | 1 + .../__tests__/invite-onboarding-text.test.ts | 30 ++++++++ server/src/routes/access.ts | 49 +++++++++++- ui/src/api/access.ts | 5 ++ ui/src/pages/CompanySettings.tsx | 77 ++++++++++++++++--- 5 files changed, 148 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 494c6842..12593d60 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -11,6 +11,7 @@ export const createCompanyInviteSchema = z.object({ allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"), expiresInHours: z.number().int().min(1).max(24 * 30).optional().default(72), defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), + agentMessage: z.string().max(4000).optional().nullable(), }); export type CreateCompanyInvite = z.infer; diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 10ed81e7..f72bb570 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -70,4 +70,34 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("Connectivity diagnostics"); expect(text).toContain("loopback hostname"); }); + + it("includes inviter message in the onboarding text when provided", () => { + const req = buildReq("localhost:3100"); + const invite = { + id: "invite-3", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + tokenHash: "hash", + defaultsPayload: { + agentMessage: "Please join as our QA lead and prioritize flaky test triage first.", + }, + expiresAt: new Date("2026-03-05T00:00:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-04T00:00:00.000Z"), + updatedAt: new Date("2026-03-04T00:00:00.000Z"), + } as const; + + const text = buildInviteOnboardingTextDocument(req, "token-789", invite as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(text).toContain("Message from inviter"); + expect(text).toContain("prioritize flaky test triage first"); + }); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 27e659e4..340c7d70 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -294,6 +294,7 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv const baseUrl = requestBaseUrl(req); const onboardingPath = `/api/invites/${token}/onboarding`; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; + const inviteMessage = extractInviteMessage(invite); return { id: invite.id, companyId: invite.companyId, @@ -306,6 +307,7 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath, skillIndexPath: "/api/skills/index", skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index", + inviteMessage, }; } @@ -406,6 +408,7 @@ function buildInviteOnboardingManifest( onboarding: { instructions: "Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops.", + inviteMessage: extractInviteMessage(invite), recommendedAdapterType: "openclaw", requiredFields: { requestType: "agent", @@ -466,6 +469,7 @@ export function buildInviteOnboardingTextDocument( ) { const manifest = buildInviteOnboardingManifest(req, token, invite, opts); const onboarding = manifest.onboarding as { + inviteMessage?: string | null; registrationEndpoint: { method: string; path: string; url: string }; claimEndpointTemplate: { method: string; path: string }; textInstructions: { path: string; url: string }; @@ -486,6 +490,13 @@ export function buildInviteOnboardingTextDocument( `- allowedJoinTypes: ${invite.allowedJoinTypes}`, `- expiresAt: ${invite.expiresAt.toISOString()}`, "", + ]; + + if (onboarding.inviteMessage) { + lines.push("## Message from inviter", onboarding.inviteMessage, ""); + } + + lines.push( "## Step 1: Submit agent join request", `${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`, "", @@ -533,7 +544,7 @@ export function buildInviteOnboardingTextDocument( "", "## Connectivity guidance", onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.", - ]; + ); if (diagnostics.length > 0) { lines.push("", "## Connectivity diagnostics"); @@ -555,6 +566,32 @@ export function buildInviteOnboardingTextDocument( return `${lines.join("\n")}\n`; } +function extractInviteMessage(invite: typeof invites.$inferSelect): string | null { + const rawDefaults = invite.defaultsPayload; + if (!rawDefaults || typeof rawDefaults !== "object" || Array.isArray(rawDefaults)) { + return null; + } + const rawMessage = (rawDefaults as Record).agentMessage; + if (typeof rawMessage !== "string") { + return null; + } + const trimmed = rawMessage.trim(); + return trimmed.length ? trimmed : null; +} + +function mergeInviteDefaults( + defaultsPayload: Record | null | undefined, + agentMessage: string | null, +): Record | null { + const merged = defaultsPayload && typeof defaultsPayload === "object" + ? { ...defaultsPayload } + : {}; + if (agentMessage) { + merged.agentMessage = agentMessage; + } + return Object.keys(merged).length ? merged : null; +} + function requestIp(req: Request) { const forwarded = req.header("x-forwarded-for"); if (forwarded) { @@ -704,6 +741,9 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); + const normalizedAgentMessage = typeof req.body.agentMessage === "string" + ? req.body.agentMessage.trim() || null + : null; const token = createInviteToken(); const created = await db @@ -713,7 +753,7 @@ export function accessRoutes( inviteType: "company_join", tokenHash: hashToken(token), allowedJoinTypes: req.body.allowedJoinTypes, - defaultsPayload: req.body.defaultsPayload ?? null, + defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage), expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000), invitedByUserId: req.actor.userId ?? null, }) @@ -731,13 +771,18 @@ export function accessRoutes( inviteType: created.inviteType, allowedJoinTypes: created.allowedJoinTypes, expiresAt: created.expiresAt.toISOString(), + hasAgentMessage: Boolean(normalizedAgentMessage), }, }); + const inviteSummary = toInviteSummaryResponse(req, token, created); res.status(201).json({ ...created, token, inviteUrl: `/invite/${token}`, + onboardingTextPath: inviteSummary.onboardingTextPath, + onboardingTextUrl: inviteSummary.onboardingTextUrl, + inviteMessage: inviteSummary.inviteMessage, }); }, ); diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 6b66a0f1..f43d2713 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -13,6 +13,7 @@ type InviteSummary = { onboardingTextUrl?: string; skillIndexPath?: string; skillIndexUrl?: string; + inviteMessage?: string | null; }; type AcceptInviteInput = @@ -56,6 +57,7 @@ export const accessApi = { allowedJoinTypes?: "human" | "agent" | "both"; expiresInHours?: number; defaultsPayload?: Record | null; + agentMessage?: string | null; } = {}, ) => api.post<{ @@ -64,6 +66,9 @@ export const accessApi = { inviteUrl: string; expiresAt: string; allowedJoinTypes: "human" | "agent" | "both"; + onboardingTextPath?: string; + onboardingTextUrl?: string; + inviteMessage?: string | null; }>(`/companies/${companyId}/invites`, input), getInvite: (token: string) => api.get(`/invites/${token}`), diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 003be3e8..ebab1bbf 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -30,7 +30,10 @@ export function CompanySettings() { const [inviteLink, setInviteLink] = useState(null); const [inviteError, setInviteError] = useState(null); + const [inviteMessage, setInviteMessage] = useState(""); + const [frozenInviteMessage, setFrozenInviteMessage] = useState(null); const [copied, setCopied] = useState(false); + const [copyDelightId, setCopyDelightId] = useState(0); const generalDirty = !!selectedCompany && @@ -59,19 +62,27 @@ export function CompanySettings() { const inviteMutation = useMutation({ mutationFn: () => accessApi.createCompanyInvite(selectedCompanyId!, { - allowedJoinTypes: "both", + allowedJoinTypes: "agent", expiresInHours: 72, + agentMessage: inviteMessage.trim() || null, }), onSuccess: async (invite) => { setInviteError(null); const base = window.location.origin.replace(/\/+$/, ""); - const absoluteUrl = invite.inviteUrl.startsWith("http") - ? invite.inviteUrl - : `${base}${invite.inviteUrl}`; + const onboardingTextLink = invite.onboardingTextUrl + ?? invite.onboardingTextPath + ?? `/api/invites/${invite.token}/onboarding.txt`; + const absoluteUrl = onboardingTextLink.startsWith("http") + ? onboardingTextLink + : `${base}${onboardingTextLink}`; setInviteLink(absoluteUrl); + const submittedMessage = inviteMessage.trim() || null; + setInviteMessage(submittedMessage ?? ""); + setFrozenInviteMessage(invite.inviteMessage ?? submittedMessage); try { await navigator.clipboard.writeText(absoluteUrl); setCopied(true); + setCopyDelightId((prev) => prev + 1); setTimeout(() => setCopied(false), 2000); } catch { /* clipboard may not be available */ } queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) }); @@ -80,6 +91,15 @@ export function CompanySettings() { setInviteError(err instanceof Error ? err.message : "Failed to create invite"); }, }); + + useEffect(() => { + setInviteLink(null); + setInviteError(null); + setInviteMessage(""); + setFrozenInviteMessage(null); + setCopied(false); + setCopyDelightId(0); + }, [selectedCompanyId]); const archiveMutation = useMutation({ mutationFn: ({ companyId, @@ -250,19 +270,51 @@ export function CompanySettings() {
- Generate a link to invite humans or agents to this company. - + + Generate an agent onboarding link (`.txt`) for OpenClaw-style join flows. + +
- + +