Add OpenCode provider integration and strict model selection
This commit is contained in:
@@ -36,6 +36,7 @@
|
|||||||
"@clack/prompts": "^0.10.0",
|
"@clack/prompts": "^0.10.0",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-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 { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
||||||
import { processCLIAdapter } from "./process/index.js";
|
import { processCLIAdapter } from "./process/index.js";
|
||||||
import { httpCLIAdapter } from "./http/index.js";
|
import { httpCLIAdapter } from "./http/index.js";
|
||||||
@@ -15,13 +16,18 @@ const codexLocalCLIAdapter: CLIAdapterModule = {
|
|||||||
formatStdoutEvent: printCodexStreamEvent,
|
formatStdoutEvent: printCodexStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCodeLocalCLIAdapter: CLIAdapterModule = {
|
||||||
|
type: "opencode_local",
|
||||||
|
formatStdoutEvent: printOpenCodeStreamEvent,
|
||||||
|
};
|
||||||
|
|
||||||
const openclawCLIAdapter: CLIAdapterModule = {
|
const openclawCLIAdapter: CLIAdapterModule = {
|
||||||
type: "openclaw",
|
type: "openclaw",
|
||||||
formatStdoutEvent: printOpenClawStreamEvent,
|
formatStdoutEvent: printOpenClawStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||||
[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 {
|
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ When a heartbeat fires, Paperclip:
|
|||||||
|---------|----------|-------------|
|
|---------|----------|-------------|
|
||||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex 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 |
|
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ Three registries consume these modules:
|
|||||||
|
|
||||||
## Choosing an Adapter
|
## 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 run a script or command?** Use `process`
|
||||||
- **Need to call an external service?** Use `http`
|
- **Need to call an external service?** Use `http`
|
||||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org
|
|||||||
|
|
||||||
Returns the full organizational tree for the company.
|
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
|
## Config Revisions
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires:
|
|||||||
- **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.)
|
- **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.)
|
||||||
- **Capabilities** — short description of what this agent does
|
- **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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
packages/adapters/opencode-local/CHANGELOG.md
Normal file
7
packages/adapters/opencode-local/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# @paperclipai/adapter-opencode-local
|
||||||
|
|
||||||
|
## 0.2.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Add local OpenCode adapter package with server/UI/CLI modules.
|
||||||
50
packages/adapters/opencode-local/package.json
Normal file
50
packages/adapters/opencode-local/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
109
packages/adapters/opencode-local/src/cli/format-event.ts
Normal file
109
packages/adapters/opencode-local/src/cli/format-event.ts
Normal file
@@ -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<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
1
packages/adapters/opencode-local/src/cli/index.ts
Normal file
1
packages/adapters/opencode-local/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { printOpenCodeStreamEvent } from "./format-event.js";
|
||||||
28
packages/adapters/opencode-local/src/index.ts
Normal file
28
packages/adapters/opencode-local/src/index.ts
Normal file
@@ -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.
|
||||||
|
`;
|
||||||
311
packages/adapters/opencode-local/src/server/execute.ts
Normal file
311
packages/adapters/opencode-local/src/server/execute.ts
Normal file
@@ -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<AdapterExecutionResult> {
|
||||||
|
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<string, unknown> => 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<string, string> = { ...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, `<stdin prompt ${prompt.length} chars>`],
|
||||||
|
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<typeof parseOpenCodeJsonl>;
|
||||||
|
},
|
||||||
|
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<string, unknown>)
|
||||||
|
: 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);
|
||||||
|
}
|
||||||
61
packages/adapters/opencode-local/src/server/index.ts
Normal file
61
packages/adapters/opencode-local/src/server/index.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown> | 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<string, unknown> | 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";
|
||||||
33
packages/adapters/opencode-local/src/server/models.test.ts
Normal file
33
packages/adapters/opencode-local/src/server/models.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
176
packages/adapters/opencode-local/src/server/models.ts
Normal file
176
packages/adapters/opencode-local/src/server/models.ts
Normal file
@@ -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<string, { expiresAt: number; models: AdapterModel[] }>();
|
||||||
|
|
||||||
|
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<string, string> {
|
||||||
|
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
|
||||||
|
? (input as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
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<string, string>) {
|
||||||
|
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<AdapterModel[]> {
|
||||||
|
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<AdapterModel[]> {
|
||||||
|
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<AdapterModel[]> {
|
||||||
|
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<AdapterModel[]> {
|
||||||
|
try {
|
||||||
|
return await discoverOpenCodeModelsCached();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetOpenCodeModelsCacheForTests() {
|
||||||
|
discoveryCache.clear();
|
||||||
|
}
|
||||||
50
packages/adapters/opencode-local/src/server/parse.test.ts
Normal file
50
packages/adapters/opencode-local/src/server/parse.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
packages/adapters/opencode-local/src/server/parse.ts
Normal file
93
packages/adapters/opencode-local/src/server/parse.ts
Normal file
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
236
packages/adapters/opencode-local/src/server/test.ts
Normal file
236
packages/adapters/opencode-local/src/server/test.ts
Normal file
@@ -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<AdapterEnvironmentTestResult> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
73
packages/adapters/opencode-local/src/ui/build-config.ts
Normal file
73
packages/adapters/opencode-local/src/ui/build-config.ts
Normal file
@@ -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<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
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<string, unknown> {
|
||||||
|
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||||
|
const env: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown> {
|
||||||
|
const ac: Record<string, unknown> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
2
packages/adapters/opencode-local/src/ui/index.ts
Normal file
2
packages/adapters/opencode-local/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { parseOpenCodeStdoutLine } from "./parse-stdout.js";
|
||||||
|
export { buildOpenCodeLocalConfig } from "./build-config.js";
|
||||||
135
packages/adapters/opencode-local/src/ui/parse-stdout.ts
Normal file
135
packages/adapters/opencode-local/src/ui/parse-stdout.ts
Normal file
@@ -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<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>, 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 }];
|
||||||
|
}
|
||||||
8
packages/adapters/opencode-local/tsconfig.json
Normal file
8
packages/adapters/opencode-local/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
packages/adapters/opencode-local/vitest.config.ts
Normal file
7
packages/adapters/opencode-local/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"postgres": "^3.4.5"
|
"postgres": "^3.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -21,7 +21,14 @@ export const AGENT_STATUSES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type AgentStatus = (typeof AGENT_STATUSES)[number];
|
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 type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||||
|
|
||||||
export const AGENT_ROLES = [
|
export const AGENT_ROLES = [
|
||||||
|
|||||||
166
pnpm-lock.yaml
generated
166
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
|||||||
'@paperclipai/adapter-openclaw':
|
'@paperclipai/adapter-openclaw':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/openclaw
|
version: link:../packages/adapters/openclaw
|
||||||
|
'@paperclipai/adapter-opencode-local':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/adapters/opencode-local
|
||||||
'@paperclipai/adapter-utils':
|
'@paperclipai/adapter-utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapter-utils
|
version: link:../packages/adapter-utils
|
||||||
@@ -72,6 +75,9 @@ importers:
|
|||||||
|
|
||||||
packages/adapter-utils:
|
packages/adapter-utils:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.12.0
|
||||||
|
version: 22.19.11
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -85,6 +91,9 @@ importers:
|
|||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.12.0
|
||||||
|
version: 22.19.11
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -98,6 +107,9 @@ importers:
|
|||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.12.0
|
||||||
|
version: 22.19.11
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -111,6 +123,25 @@ importers:
|
|||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
devDependencies:
|
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:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -127,6 +158,9 @@ importers:
|
|||||||
specifier: ^3.4.5
|
specifier: ^3.4.5
|
||||||
version: 3.4.8
|
version: 3.4.8
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.12.0
|
||||||
|
version: 22.19.11
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.31.9
|
specifier: ^0.31.9
|
||||||
version: 0.31.9
|
version: 0.31.9
|
||||||
@@ -138,7 +172,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.5
|
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:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -164,6 +198,9 @@ importers:
|
|||||||
'@paperclipai/adapter-openclaw':
|
'@paperclipai/adapter-openclaw':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/openclaw
|
version: link:../packages/adapters/openclaw
|
||||||
|
'@paperclipai/adapter-opencode-local':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/adapters/opencode-local
|
||||||
'@paperclipai/adapter-utils':
|
'@paperclipai/adapter-utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapter-utils
|
version: link:../packages/adapter-utils
|
||||||
@@ -175,7 +212,7 @@ importers:
|
|||||||
version: link:../packages/shared
|
version: link:../packages/shared
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.3.8
|
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:
|
detect-port:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
@@ -223,9 +260,15 @@ importers:
|
|||||||
'@types/multer':
|
'@types/multer':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.12.0
|
||||||
|
version: 22.19.11
|
||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
'@types/ws':
|
||||||
|
specifier: ^8.5.14
|
||||||
|
version: 8.18.1
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
@@ -237,10 +280,10 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^6.1.0
|
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:
|
vitest:
|
||||||
specifier: ^3.0.5
|
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:
|
ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -265,6 +308,9 @@ importers:
|
|||||||
'@paperclipai/adapter-openclaw':
|
'@paperclipai/adapter-openclaw':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapters/openclaw
|
version: link:../packages/adapters/openclaw
|
||||||
|
'@paperclipai/adapter-opencode-local':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../packages/adapters/opencode-local
|
||||||
'@paperclipai/adapter-utils':
|
'@paperclipai/adapter-utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/adapter-utils
|
version: link:../packages/adapter-utils
|
||||||
@@ -2808,6 +2854,9 @@ packages:
|
|||||||
'@types/unist@3.0.3':
|
'@types/unist@3.0.3':
|
||||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
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':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
|
||||||
@@ -8192,6 +8241,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.2.3
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@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))':
|
'@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
|
chai: 5.3.3
|
||||||
tinyrainbow: 2.0.0
|
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))':
|
'@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:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.4
|
'@vitest/spy': 3.2.4
|
||||||
@@ -8298,7 +8359,7 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.9.19: {}
|
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:
|
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/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))
|
'@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
|
pg: 8.18.0
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(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):
|
better-call@1.1.8(zod@4.3.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10571,6 +10632,27 @@ snapshots:
|
|||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
vfile-message: 4.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):
|
vite-node@3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
@@ -10592,6 +10674,21 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- 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):
|
vite@6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
@@ -10607,6 +10704,21 @@ snapshots:
|
|||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
tsx: 4.21.0
|
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):
|
vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.27.3
|
esbuild: 0.27.3
|
||||||
@@ -10622,6 +10734,48 @@ snapshots:
|
|||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
tsx: 4.21.0
|
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):
|
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:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const workspacePaths = [
|
|||||||
"packages/adapter-utils",
|
"packages/adapter-utils",
|
||||||
"packages/adapters/claude-local",
|
"packages/adapters/claude-local",
|
||||||
"packages/adapters/codex-local",
|
"packages/adapters/codex-local",
|
||||||
|
"packages/adapters/opencode-local",
|
||||||
"packages/adapters/openclaw",
|
"packages/adapters/openclaw",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const { resolve } = require('path');
|
|||||||
const root = '$REPO_ROOT';
|
const root = '$REPO_ROOT';
|
||||||
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
||||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
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'];
|
'server', 'cli'];
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const d of dirs) {
|
for (const d of dirs) {
|
||||||
@@ -131,6 +131,7 @@ pnpm --filter @paperclipai/adapter-utils build
|
|||||||
pnpm --filter @paperclipai/db build
|
pnpm --filter @paperclipai/db build
|
||||||
pnpm --filter @paperclipai/adapter-claude-local build
|
pnpm --filter @paperclipai/adapter-claude-local build
|
||||||
pnpm --filter @paperclipai/adapter-codex-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/adapter-openclaw build
|
||||||
pnpm --filter @paperclipai/server build
|
pnpm --filter @paperclipai/server build
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ if [ "$dry_run" = true ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Preview what would be published:"
|
echo " Preview what would be published:"
|
||||||
for dir in packages/shared packages/adapter-utils packages/db \
|
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
|
server cli; do
|
||||||
echo " --- $dir ---"
|
echo " --- $dir ---"
|
||||||
cd "$REPO_ROOT/$dir"
|
cd "$REPO_ROOT/$dir"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@aws-sdk/client-s3": "^3.888.0",
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
@@ -57,7 +58,9 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/node": "^22.12.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/ws": "^8.5.14",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
||||||
|
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
|
||||||
import { listAdapterModels } from "../adapters/index.js";
|
import { listAdapterModels } from "../adapters/index.js";
|
||||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||||
|
|
||||||
describe("adapter model listing", () => {
|
describe("adapter model listing", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete process.env.OPENAI_API_KEY;
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
|
||||||
resetCodexModelsCacheForTests();
|
resetCodexModelsCacheForTests();
|
||||||
|
resetOpenCodeModelsCacheForTests();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,4 +58,11 @@ describe("adapter model listing", () => {
|
|||||||
const models = await listAdapterModels("codex_local");
|
const models = await listAdapterModels("codex_local");
|
||||||
expect(models).toEqual(codexFallbackModels);
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ import {
|
|||||||
agentConfigurationDoc as openclawAgentConfigurationDoc,
|
agentConfigurationDoc as openclawAgentConfigurationDoc,
|
||||||
models as openclawModels,
|
models as openclawModels,
|
||||||
} from "@paperclipai/adapter-openclaw";
|
} 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 { listCodexModels } from "./codex-models.js";
|
||||||
import { processAdapter } from "./process/index.js";
|
import { processAdapter } from "./process/index.js";
|
||||||
import { httpAdapter } from "./http/index.js";
|
import { httpAdapter } from "./http/index.js";
|
||||||
@@ -53,8 +63,21 @@ const openclawAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
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<string, ServerAdapterModule>(
|
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||||
[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 {
|
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
|
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||||
|
|
||||||
export function agentRoutes(db: Db) {
|
export function agentRoutes(db: Db) {
|
||||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||||
claude_local: "instructionsFilePath",
|
claude_local: "instructionsFilePath",
|
||||||
codex_local: "instructionsFilePath",
|
codex_local: "instructionsFilePath",
|
||||||
|
opencode_local: "instructionsFilePath",
|
||||||
};
|
};
|
||||||
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
|
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
|
||||||
|
|
||||||
@@ -193,6 +195,27 @@ export function agentRoutes(db: Db) {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertAdapterConfigConstraints(
|
||||||
|
companyId: string,
|
||||||
|
adapterType: string | null | undefined,
|
||||||
|
adapterConfig: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
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<string, unknown>) {
|
function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record<string, unknown>) {
|
||||||
const trimmed = candidatePath.trim();
|
const trimmed = candidatePath.trim();
|
||||||
if (path.isAbsolute(trimmed)) return trimmed;
|
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 type = req.params.type as string;
|
||||||
const models = await listAdapterModels(type);
|
const models = await listAdapterModels(type);
|
||||||
res.json(models);
|
res.json(models);
|
||||||
@@ -578,6 +603,11 @@ export function agentRoutes(db: Db) {
|
|||||||
requestedAdapterConfig,
|
requestedAdapterConfig,
|
||||||
{ strictMode: strictSecretsMode },
|
{ strictMode: strictSecretsMode },
|
||||||
);
|
);
|
||||||
|
await assertAdapterConfigConstraints(
|
||||||
|
companyId,
|
||||||
|
hireInput.adapterType,
|
||||||
|
normalizedAdapterConfig,
|
||||||
|
);
|
||||||
const normalizedHireInput = {
|
const normalizedHireInput = {
|
||||||
...hireInput,
|
...hireInput,
|
||||||
adapterConfig: normalizedAdapterConfig,
|
adapterConfig: normalizedAdapterConfig,
|
||||||
@@ -713,6 +743,11 @@ export function agentRoutes(db: Db) {
|
|||||||
requestedAdapterConfig,
|
requestedAdapterConfig,
|
||||||
{ strictMode: strictSecretsMode },
|
{ strictMode: strictSecretsMode },
|
||||||
);
|
);
|
||||||
|
await assertAdapterConfigConstraints(
|
||||||
|
companyId,
|
||||||
|
req.body.adapterType,
|
||||||
|
normalizedAdapterConfig,
|
||||||
|
);
|
||||||
|
|
||||||
const agent = await svc.create(companyId, {
|
const agent = await svc.create(companyId, {
|
||||||
...req.body,
|
...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 actor = getActorInfo(req);
|
||||||
const agent = await svc.update(id, patchData, {
|
const agent = await svc.update(id, patchData, {
|
||||||
recordRevision: {
|
recordRevision: {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@mdxeditor/editor": "^3.52.4",
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
|
|||||||
18
ui/public/brands/opencode-logo-dark-square.svg
Normal file
18
ui/public/brands/opencode-logo-dark-square.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(30, 0)">
|
||||||
|
<g clip-path="url(#clip0_1401_86283)">
|
||||||
|
<mask id="mask0_1401_86283" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||||
|
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_1401_86283)">
|
||||||
|
<path d="M180 240H60V120H180V240Z" fill="#4B4646"/>
|
||||||
|
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1401_86283">
|
||||||
|
<rect width="240" height="300" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 631 B |
18
ui/public/brands/opencode-logo-light-square.svg
Normal file
18
ui/public/brands/opencode-logo-light-square.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(30, 0)">
|
||||||
|
<g clip-path="url(#clip0_1401_86274)">
|
||||||
|
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
|
||||||
|
<path d="M240 0H0V300H240V0Z" fill="white"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0_1401_86274)">
|
||||||
|
<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
|
||||||
|
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1401_86274">
|
||||||
|
<rect width="240" height="300" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 631 B |
47
ui/src/adapters/opencode-local/config-fields.tsx
Normal file
47
ui/src/adapters/opencode-local/config-fields.tsx
Normal file
@@ -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 (
|
||||||
|
<Field label="Agent instructions file" hint={instructionsFileHint}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? values!.instructionsFilePath ?? ""
|
||||||
|
: eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"instructionsFilePath",
|
||||||
|
String(config.instructionsFilePath ?? ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ instructionsFilePath: v })
|
||||||
|
: mark("adapterConfig", "instructionsFilePath", v || undefined)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="/absolute/path/to/AGENTS.md"
|
||||||
|
/>
|
||||||
|
<ChoosePathButton />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/opencode-local/index.ts
Normal file
12
ui/src/adapters/opencode-local/index.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { UIAdapterModule } from "./types";
|
import type { UIAdapterModule } from "./types";
|
||||||
import { claudeLocalUIAdapter } from "./claude-local";
|
import { claudeLocalUIAdapter } from "./claude-local";
|
||||||
import { codexLocalUIAdapter } from "./codex-local";
|
import { codexLocalUIAdapter } from "./codex-local";
|
||||||
|
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||||
import { openClawUIAdapter } from "./openclaw";
|
import { openClawUIAdapter } from "./openclaw";
|
||||||
import { processUIAdapter } from "./process";
|
import { processUIAdapter } from "./process";
|
||||||
import { httpUIAdapter } from "./http";
|
import { httpUIAdapter } from "./http";
|
||||||
|
|
||||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||||
[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 {
|
export function getUIAdapter(type: string): UIAdapterModule {
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ export const agentsApi = {
|
|||||||
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
||||||
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
||||||
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
||||||
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
|
adapterModels: (companyId: string, type: string) =>
|
||||||
|
api.get<AdapterModel[]>(`/companies/${companyId}/adapters/${type}/models`),
|
||||||
testEnvironment: (
|
testEnvironment: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
type: string,
|
type: string,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { getUIAdapter } from "../adapters";
|
|||||||
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
|
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
|
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||||
|
|
||||||
/* ---- Create mode values ---- */
|
/* ---- Create mode values ---- */
|
||||||
|
|
||||||
@@ -122,6 +123,19 @@ function formatArgList(value: unknown): string {
|
|||||||
return typeof value === "string" ? value : "";
|
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 = [
|
const codexThinkingEffortOptions = [
|
||||||
{ id: "", label: "Auto" },
|
{ id: "", label: "Auto" },
|
||||||
{ id: "minimal", label: "Minimal" },
|
{ id: "minimal", label: "Minimal" },
|
||||||
@@ -130,6 +144,15 @@ const codexThinkingEffortOptions = [
|
|||||||
{ id: "high", label: "High" },
|
{ id: "high", label: "High" },
|
||||||
] as const;
|
] 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 = [
|
const claudeThinkingEffortOptions = [
|
||||||
{ id: "", label: "Auto" },
|
{ id: "", label: "Auto" },
|
||||||
{ id: "low", label: "Low" },
|
{ id: "low", label: "Low" },
|
||||||
@@ -254,13 +277,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
const adapterType = isCreate
|
const adapterType = isCreate
|
||||||
? props.values.adapterType
|
? props.values.adapterType
|
||||||
: overlay.adapterType ?? props.agent.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]);
|
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||||
|
|
||||||
// Fetch adapter models for the effective adapter type
|
// Fetch adapter models for the effective adapter type
|
||||||
const { data: fetchedModels } = useQuery({
|
const {
|
||||||
queryKey: ["adapter-models", adapterType],
|
data: fetchedModels,
|
||||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
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 ?? [];
|
const models = fetchedModels ?? externalModels ?? [];
|
||||||
|
|
||||||
@@ -313,9 +343,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
? val!.model
|
? val!.model
|
||||||
: eff("adapterConfig", "model", String(config.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 =
|
const thinkingEffortOptions =
|
||||||
adapterType === "codex_local" ? codexThinkingEffortOptions : claudeThinkingEffortOptions;
|
adapterType === "codex_local"
|
||||||
|
? codexThinkingEffortOptions
|
||||||
|
: adapterType === "opencode_local"
|
||||||
|
? openCodeThinkingEffortOptions
|
||||||
|
: claudeThinkingEffortOptions;
|
||||||
const currentThinkingEffort = isCreate
|
const currentThinkingEffort = isCreate
|
||||||
? val!.thinkingEffort
|
? val!.thinkingEffort
|
||||||
: adapterType === "codex_local"
|
: adapterType === "codex_local"
|
||||||
@@ -324,6 +363,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
"modelReasoningEffort",
|
"modelReasoningEffort",
|
||||||
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
|
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
|
||||||
)
|
)
|
||||||
|
: adapterType === "opencode_local"
|
||||||
|
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||||
const codexSearchEnabled = adapterType === "codex_local"
|
const codexSearchEnabled = adapterType === "codex_local"
|
||||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||||
@@ -549,7 +590,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
}
|
}
|
||||||
immediate
|
immediate
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder={adapterType === "codex_local" ? "codex" : "claude"}
|
placeholder={
|
||||||
|
adapterType === "codex_local"
|
||||||
|
? "codex"
|
||||||
|
: adapterType === "opencode_local"
|
||||||
|
? "opencode"
|
||||||
|
: "claude"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -563,7 +610,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
}
|
}
|
||||||
open={modelOpen}
|
open={modelOpen}
|
||||||
onOpenChange={setModelOpen}
|
onOpenChange={setModelOpen}
|
||||||
|
allowDefault={adapterType !== "opencode_local"}
|
||||||
|
required={adapterType === "opencode_local"}
|
||||||
|
groupByProvider={adapterType === "opencode_local"}
|
||||||
/>
|
/>
|
||||||
|
{fetchedModelsError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{fetchedModelsError instanceof Error
|
||||||
|
? fetchedModelsError.message
|
||||||
|
: "Failed to load adapter models."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<ThinkingEffortDropdown
|
<ThinkingEffortDropdown
|
||||||
value={currentThinkingEffort}
|
value={currentThinkingEffort}
|
||||||
@@ -817,7 +874,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||||||
|
|
||||||
/* ---- Internal sub-components ---- */
|
/* ---- Internal sub-components ---- */
|
||||||
|
|
||||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]);
|
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||||
|
|
||||||
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
|
||||||
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
|
||||||
@@ -840,7 +897,10 @@ function AdapterTypeDropdown({
|
|||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||||
<span>{adapterLabels[value] ?? value}</span>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||||
|
<span>{adapterLabels[value] ?? value}</span>
|
||||||
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -860,7 +920,10 @@ function AdapterTypeDropdown({
|
|||||||
if (!item.comingSoon) onChange(item.value);
|
if (!item.comingSoon) onChange(item.value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{item.value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</span>
|
||||||
{item.comingSoon && (
|
{item.comingSoon && (
|
||||||
<span className="text-[10px] text-muted-foreground">Coming soon</span>
|
<span className="text-[10px] text-muted-foreground">Coming soon</span>
|
||||||
)}
|
)}
|
||||||
@@ -1126,20 +1189,56 @@ function ModelDropdown({
|
|||||||
onChange,
|
onChange,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
allowDefault,
|
||||||
|
required,
|
||||||
|
groupByProvider,
|
||||||
}: {
|
}: {
|
||||||
models: AdapterModel[];
|
models: AdapterModel[];
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
allowDefault: boolean;
|
||||||
|
required: boolean;
|
||||||
|
groupByProvider: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [modelSearch, setModelSearch] = useState("");
|
const [modelSearch, setModelSearch] = useState("");
|
||||||
const selected = models.find((m) => m.id === value);
|
const selected = models.find((m) => m.id === value);
|
||||||
const filteredModels = models.filter((m) => {
|
const filteredModels = useMemo(() => {
|
||||||
if (!modelSearch.trim()) return true;
|
return models.filter((m) => {
|
||||||
const q = modelSearch.toLowerCase();
|
if (!modelSearch.trim()) return true;
|
||||||
return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q);
|
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<string, AdapterModel[]>();
|
||||||
|
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 (
|
return (
|
||||||
<Field label="Model" hint={help.model}>
|
<Field label="Model" hint={help.model}>
|
||||||
@@ -1153,7 +1252,9 @@ function ModelDropdown({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||||
<span className={cn(!value && "text-muted-foreground")}>
|
<span className={cn(!value && "text-muted-foreground")}>
|
||||||
{selected ? selected.label : value || "Default"}
|
{selected
|
||||||
|
? selected.label
|
||||||
|
: value || (allowDefault ? "Default" : required ? "Select model (required)" : "Select model")}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1167,33 +1268,45 @@ function ModelDropdown({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="max-h-[240px] overflow-y-auto">
|
<div className="max-h-[240px] overflow-y-auto">
|
||||||
<button
|
{allowDefault && (
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
|
||||||
!value && "bg-accent",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
onChange("");
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Default
|
|
||||||
</button>
|
|
||||||
{filteredModels.map((m) => (
|
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
m.id === value && "bg-accent",
|
!value && "bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(m.id);
|
onChange("");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{m.label}</span>
|
Default
|
||||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{groupedModels.map((group) => (
|
||||||
|
<div key={group.provider} className="mb-1 last:mb-0">
|
||||||
|
{groupByProvider && (
|
||||||
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.provider} ({group.entries.length})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{group.entries.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
|
m.id === value && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(m.id);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="block w-full text-left truncate" title={m.id}>
|
||||||
|
{groupByProvider ? extractModelName(m.id) : m.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredModels.length === 0 && (
|
{filteredModels.length === 0 && (
|
||||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface AgentPropertiesProps {
|
|||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
|
|||||||
@@ -55,14 +55,21 @@ export function NewAgentDialog() {
|
|||||||
enabled: !!selectedCompanyId && newAgentOpen,
|
enabled: !!selectedCompanyId && newAgentOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const {
|
||||||
queryKey: ["adapter-models", configValues.adapterType],
|
data: adapterModels,
|
||||||
queryFn: () => agentsApi.adapterModels(configValues.adapterType),
|
error: adapterModelsError,
|
||||||
enabled: newAgentOpen,
|
} = 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 isFirstAgent = !agents || agents.length === 0;
|
||||||
const effectiveRole = isFirstAgent ? "ceo" : role;
|
const effectiveRole = isFirstAgent ? "ceo" : role;
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Auto-fill for CEO
|
// Auto-fill for CEO
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,6 +89,9 @@ export function NewAgentDialog() {
|
|||||||
closeNewAgent();
|
closeNewAgent();
|
||||||
navigate(agentUrl(result.agent));
|
navigate(agentUrl(result.agent));
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFormError(error instanceof Error ? error.message : "Failed to create agent");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -91,6 +101,7 @@ export function NewAgentDialog() {
|
|||||||
setReportsTo("");
|
setReportsTo("");
|
||||||
setConfigValues(defaultCreateValues);
|
setConfigValues(defaultCreateValues);
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
|
setFormError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAdapterConfig() {
|
function buildAdapterConfig() {
|
||||||
@@ -100,6 +111,31 @@ export function NewAgentDialog() {
|
|||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!selectedCompanyId || !name.trim()) return;
|
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({
|
createAgent.mutate({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
role: effectiveRole,
|
role: effectiveRole,
|
||||||
@@ -281,6 +317,11 @@ export function NewAgentDialog() {
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{isFirstAgent ? "This will be the CEO" : ""}
|
{isFirstAgent ? "This will be the CEO" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
{formError && (
|
||||||
|
<div className="px-4 pb-2 text-xs text-destructive">{formError}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end px-4 pb-3">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!name.trim() || createAgent.isPending}
|
disabled={!name.trim() || createAgent.isPending}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ function getContrastTextColor(hexColor: string): string {
|
|||||||
return luminance > 0.5 ? "#000000" : "#ffffff";
|
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 {
|
interface IssueDraft {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -67,7 +73,7 @@ interface IssueDraft {
|
|||||||
assigneeUseProjectWorkspace: boolean;
|
assigneeUseProjectWorkspace: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local"]);
|
const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]);
|
||||||
|
|
||||||
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
const ISSUE_THINKING_EFFORT_OPTIONS = {
|
||||||
claude_local: [
|
claude_local: [
|
||||||
@@ -83,6 +89,14 @@ const ISSUE_THINKING_EFFORT_OPTIONS = {
|
|||||||
{ value: "medium", label: "Medium" },
|
{ value: "medium", label: "Medium" },
|
||||||
{ value: "high", label: "High" },
|
{ value: "high", label: "High" },
|
||||||
],
|
],
|
||||||
|
opencode_local: [
|
||||||
|
{ value: "", label: "Default" },
|
||||||
|
{ value: "minimal", label: "Minimal" },
|
||||||
|
{ value: "low", label: "Low" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
|
{ value: "high", label: "High" },
|
||||||
|
{ value: "max", label: "Max" },
|
||||||
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function buildAssigneeAdapterOverrides(input: {
|
function buildAssigneeAdapterOverrides(input: {
|
||||||
@@ -104,6 +118,8 @@ function buildAssigneeAdapterOverrides(input: {
|
|||||||
adapterConfig.modelReasoningEffort = input.thinkingEffortOverride;
|
adapterConfig.modelReasoningEffort = input.thinkingEffortOverride;
|
||||||
} else if (adapterType === "claude_local") {
|
} else if (adapterType === "claude_local") {
|
||||||
adapterConfig.effort = input.thinkingEffortOverride;
|
adapterConfig.effort = input.thinkingEffortOverride;
|
||||||
|
} else if (adapterType === "opencode_local") {
|
||||||
|
adapterConfig.variant = input.thinkingEffortOverride;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (adapterType === "claude_local" && input.chrome) {
|
if (adapterType === "claude_local" && input.chrome) {
|
||||||
@@ -237,9 +253,12 @@ export function NewIssueDialog() {
|
|||||||
}, [agents, orderedProjects]);
|
}, [agents, orderedProjects]);
|
||||||
|
|
||||||
const { data: assigneeAdapterModels } = useQuery({
|
const { data: assigneeAdapterModels } = useQuery({
|
||||||
queryKey: ["adapter-models", assigneeAdapterType],
|
queryKey:
|
||||||
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
|
effectiveCompanyId && assigneeAdapterType
|
||||||
enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType)
|
||||||
|
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
|
||||||
|
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!),
|
||||||
|
enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createIssue = useMutation({
|
const createIssue = useMutation({
|
||||||
@@ -351,7 +370,9 @@ export function NewIssueDialog() {
|
|||||||
const validThinkingValues =
|
const validThinkingValues =
|
||||||
assigneeAdapterType === "codex_local"
|
assigneeAdapterType === "codex_local"
|
||||||
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
||||||
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
: assigneeAdapterType === "opencode_local"
|
||||||
|
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
|
||||||
|
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
||||||
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
|
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
}
|
}
|
||||||
@@ -451,10 +472,14 @@ export function NewIssueDialog() {
|
|||||||
? "Claude options"
|
? "Claude options"
|
||||||
: assigneeAdapterType === "codex_local"
|
: assigneeAdapterType === "codex_local"
|
||||||
? "Codex options"
|
? "Codex options"
|
||||||
|
: assigneeAdapterType === "opencode_local"
|
||||||
|
? "OpenCode options"
|
||||||
: "Agent options";
|
: "Agent options";
|
||||||
const thinkingEffortOptions =
|
const thinkingEffortOptions =
|
||||||
assigneeAdapterType === "codex_local"
|
assigneeAdapterType === "codex_local"
|
||||||
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
||||||
|
: assigneeAdapterType === "opencode_local"
|
||||||
|
? ISSUE_THINKING_EFFORT_OPTIONS.opencode_local
|
||||||
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
: ISSUE_THINKING_EFFORT_OPTIONS.claude_local;
|
||||||
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
const assigneeOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -477,12 +502,21 @@ export function NewIssueDialog() {
|
|||||||
[orderedProjects],
|
[orderedProjects],
|
||||||
);
|
);
|
||||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() => {
|
||||||
(assigneeAdapterModels ?? []).map((model) => ({
|
return [...(assigneeAdapterModels ?? [])]
|
||||||
id: model.id,
|
.sort((a, b) => {
|
||||||
label: model.label,
|
const providerA = extractProviderId(a.id);
|
||||||
searchText: model.id,
|
const providerB = extractProviderId(b.id);
|
||||||
})),
|
const byProvider = providerA.localeCompare(providerB);
|
||||||
|
if (byProvider !== 0) return byProvider;
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
})
|
||||||
|
.map((model) => ({
|
||||||
|
id: model.id,
|
||||||
|
label: model.label,
|
||||||
|
searchText: `${model.id} ${extractProviderId(model.id)}`,
|
||||||
|
}));
|
||||||
|
},
|
||||||
[assigneeAdapterModels],
|
[assigneeAdapterModels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
import { HintIcon } from "./agent-config-primitives";
|
import { HintIcon } from "./agent-config-primitives";
|
||||||
|
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -49,6 +50,7 @@ type Step = 1 | 2 | 3 | 4;
|
|||||||
type AdapterType =
|
type AdapterType =
|
||||||
| "claude_local"
|
| "claude_local"
|
||||||
| "codex_local"
|
| "codex_local"
|
||||||
|
| "opencode_local"
|
||||||
| "process"
|
| "process"
|
||||||
| "http"
|
| "http"
|
||||||
| "openclaw";
|
| "openclaw";
|
||||||
@@ -59,6 +61,19 @@ 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`;
|
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() {
|
export function OnboardingWizard() {
|
||||||
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
|
const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog();
|
||||||
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
|
const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany();
|
||||||
@@ -72,6 +87,7 @@ export function OnboardingWizard() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
const [modelOpen, setModelOpen] = useState(false);
|
||||||
|
const [modelSearch, setModelSearch] = useState("");
|
||||||
|
|
||||||
// Step 1
|
// Step 1
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
@@ -142,15 +158,21 @@ export function OnboardingWizard() {
|
|||||||
if (step === 3) autoResizeTextarea();
|
if (step === 3) autoResizeTextarea();
|
||||||
}, [step, taskDescription, autoResizeTextarea]);
|
}, [step, taskDescription, autoResizeTextarea]);
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const {
|
||||||
queryKey: ["adapter-models", adapterType],
|
data: adapterModels,
|
||||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
error: adapterModelsError,
|
||||||
enabled: onboardingOpen && step === 2
|
} = useQuery({
|
||||||
|
queryKey:
|
||||||
|
createdCompanyId
|
||||||
|
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
|
||||||
|
: ["agents", "none", "adapter-models", adapterType],
|
||||||
|
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
|
||||||
|
enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2
|
||||||
});
|
});
|
||||||
const isLocalAdapter =
|
const isLocalAdapter =
|
||||||
adapterType === "claude_local" || adapterType === "codex_local";
|
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local";
|
||||||
const effectiveAdapterCommand =
|
const effectiveAdapterCommand =
|
||||||
command.trim() || (adapterType === "codex_local" ? "codex" : "claude");
|
command.trim() || (adapterType === "codex_local" ? "codex" : adapterType === "opencode_local" ? "opencode" : "claude");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 2) return;
|
if (step !== 2) return;
|
||||||
@@ -159,6 +181,41 @@ export function OnboardingWizard() {
|
|||||||
}, [step, adapterType, cwd, model, command, args, url]);
|
}, [step, adapterType, cwd, model, command, args, url]);
|
||||||
|
|
||||||
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
|
const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
const query = modelSearch.trim().toLowerCase();
|
||||||
|
return (adapterModels ?? []).filter((entry) => {
|
||||||
|
if (!query) return true;
|
||||||
|
const provider = extractProviderId(entry.id) ?? "";
|
||||||
|
return (
|
||||||
|
entry.id.toLowerCase().includes(query) ||
|
||||||
|
entry.label.toLowerCase().includes(query) ||
|
||||||
|
provider.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [adapterModels, modelSearch]);
|
||||||
|
const groupedModels = useMemo(() => {
|
||||||
|
if (adapterType !== "opencode_local") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
provider: "models",
|
||||||
|
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const groups = new Map<string, Array<{ id: string; label: string }>>();
|
||||||
|
for (const entry of filteredModels) {
|
||||||
|
const provider = extractProviderId(entry.id) ?? "other";
|
||||||
|
const bucket = groups.get(provider) ?? [];
|
||||||
|
bucket.push(entry);
|
||||||
|
groups.set(provider, bucket);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([provider, entries]) => ({
|
||||||
|
provider,
|
||||||
|
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
}));
|
||||||
|
}, [filteredModels, adapterType]);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setStep(1);
|
setStep(1);
|
||||||
@@ -273,6 +330,31 @@ export function OnboardingWizard() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
if (adapterType === "opencode_local") {
|
||||||
|
const selectedModelId = model.trim();
|
||||||
|
if (!selectedModelId) {
|
||||||
|
setError("OpenCode requires an explicit model in provider/model format.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (adapterModelsError) {
|
||||||
|
setError(
|
||||||
|
adapterModelsError instanceof Error
|
||||||
|
? adapterModelsError.message
|
||||||
|
: "Failed to load OpenCode models.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const discoveredModels = adapterModels ?? [];
|
||||||
|
if (!discoveredModels.some((entry) => entry.id === selectedModelId)) {
|
||||||
|
setError(
|
||||||
|
discoveredModels.length === 0
|
||||||
|
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
||||||
|
: `Configured OpenCode model is unavailable: ${selectedModelId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLocalAdapter) {
|
if (isLocalAdapter) {
|
||||||
const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
|
const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
@@ -500,6 +582,12 @@ export function OnboardingWizard() {
|
|||||||
icon: Code,
|
icon: Code,
|
||||||
desc: "Local Codex agent"
|
desc: "Local Codex agent"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "opencode_local" as const,
|
||||||
|
label: "OpenCode",
|
||||||
|
icon: OpenCodeLogoIcon,
|
||||||
|
desc: "Local multi-provider agent"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "openclaw" as const,
|
value: "openclaw" as const,
|
||||||
label: "OpenClaw",
|
label: "OpenClaw",
|
||||||
@@ -546,7 +634,15 @@ export function OnboardingWizard() {
|
|||||||
setAdapterType(nextType);
|
setAdapterType(nextType);
|
||||||
if (nextType === "codex_local" && !model) {
|
if (nextType === "codex_local" && !model) {
|
||||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (nextType === "opencode_local") {
|
||||||
|
if (!model.includes("/")) {
|
||||||
|
setModel("");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setModel("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<opt.icon className="h-4 w-4" />
|
<opt.icon className="h-4 w-4" />
|
||||||
@@ -561,7 +657,8 @@ export function OnboardingWizard() {
|
|||||||
|
|
||||||
{/* Conditional adapter fields */}
|
{/* Conditional adapter fields */}
|
||||||
{(adapterType === "claude_local" ||
|
{(adapterType === "claude_local" ||
|
||||||
adapterType === "codex_local") && (
|
adapterType === "codex_local" ||
|
||||||
|
adapterType === "opencode_local") && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
@@ -585,7 +682,13 @@ export function OnboardingWizard() {
|
|||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
<label className="text-xs text-muted-foreground mb-1 block">
|
||||||
Model
|
Model
|
||||||
</label>
|
</label>
|
||||||
<Popover open={modelOpen} onOpenChange={setModelOpen}>
|
<Popover
|
||||||
|
open={modelOpen}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setModelOpen(next);
|
||||||
|
if (!next) setModelSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||||
<span
|
<span
|
||||||
@@ -595,7 +698,10 @@ export function OnboardingWizard() {
|
|||||||
>
|
>
|
||||||
{selectedModel
|
{selectedModel
|
||||||
? selectedModel.label
|
? selectedModel.label
|
||||||
: model || "Default"}
|
: model ||
|
||||||
|
(adapterType === "opencode_local"
|
||||||
|
? "Select model (required)"
|
||||||
|
: "Default")}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -604,36 +710,60 @@ export function OnboardingWizard() {
|
|||||||
className="w-[var(--radix-popover-trigger-width)] p-1"
|
className="w-[var(--radix-popover-trigger-width)] p-1"
|
||||||
align="start"
|
align="start"
|
||||||
>
|
>
|
||||||
<button
|
<input
|
||||||
className={cn(
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
placeholder="Search models..."
|
||||||
!model && "bg-accent"
|
value={modelSearch}
|
||||||
)}
|
onChange={(e) => setModelSearch(e.target.value)}
|
||||||
onClick={() => {
|
autoFocus
|
||||||
setModel("");
|
/>
|
||||||
setModelOpen(false);
|
{adapterType !== "opencode_local" && (
|
||||||
}}
|
|
||||||
>
|
|
||||||
Default
|
|
||||||
</button>
|
|
||||||
{(adapterModels ?? []).map((m) => (
|
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
m.id === model && "bg-accent"
|
!model && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModel(m.id);
|
setModel("");
|
||||||
setModelOpen(false);
|
setModelOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{m.label}</span>
|
Default
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
</button>
|
||||||
{m.id}
|
)}
|
||||||
</span>
|
<div className="max-h-[240px] overflow-y-auto">
|
||||||
</button>
|
{groupedModels.map((group) => (
|
||||||
))}
|
<div key={group.provider} className="mb-1 last:mb-0">
|
||||||
|
{adapterType === "opencode_local" && (
|
||||||
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.provider} ({group.entries.length})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{group.entries.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
|
m.id === model && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setModel(m.id);
|
||||||
|
setModelOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="block w-full text-left truncate" title={m.id}>
|
||||||
|
{adapterType === "opencode_local" ? extractModelName(m.id) : m.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filteredModels.length === 0 && (
|
||||||
|
<p className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
No models discovered.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
@@ -678,6 +808,8 @@ export function OnboardingWizard() {
|
|||||||
<p className="text-muted-foreground font-mono break-all">
|
<p className="text-muted-foreground font-mono break-all">
|
||||||
{adapterType === "codex_local"
|
{adapterType === "codex_local"
|
||||||
? `${effectiveAdapterCommand} exec --json -`
|
? `${effectiveAdapterCommand} exec --json -`
|
||||||
|
: adapterType === "opencode_local"
|
||||||
|
? `${effectiveAdapterCommand} run --format json`
|
||||||
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
@@ -691,6 +823,12 @@ export function OnboardingWizard() {
|
|||||||
env or run{" "}
|
env or run{" "}
|
||||||
<span className="font-mono">codex login</span>.
|
<span className="font-mono">codex login</span>.
|
||||||
</p>
|
</p>
|
||||||
|
) : adapterType === "opencode_local" ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
If providers are unavailable, run{" "}
|
||||||
|
<span className="font-mono">opencode models</span> and{" "}
|
||||||
|
<span className="font-mono">opencode auth login</span>.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
If login is required, run{" "}
|
If login is required, run{" "}
|
||||||
|
|||||||
22
ui/src/components/OpenCodeLogoIcon.tsx
Normal file
22
ui/src/components/OpenCodeLogoIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface OpenCodeLogoIconProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenCodeLogoIcon({ className }: OpenCodeLogoIconProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src="/brands/opencode-logo-light-square.svg"
|
||||||
|
alt="OpenCode"
|
||||||
|
className={cn("dark:hidden", className)}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="/brands/opencode-logo-dark-square.svg"
|
||||||
|
alt="OpenCode"
|
||||||
|
className={cn("hidden dark:block", className)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
|
|||||||
role: "Organizational role. Determines position and capabilities.",
|
role: "Organizational role. Determines position and capabilities.",
|
||||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
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.",
|
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.",
|
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.",
|
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.",
|
model: "Override the default model used by the adapter.",
|
||||||
@@ -34,7 +34,7 @@ export const help: Record<string, string> = {
|
|||||||
search: "Enable Codex web search capability during runs.",
|
search: "Enable Codex web search capability during runs.",
|
||||||
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
||||||
command: "The command to execute (e.g. node, python).",
|
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.",
|
args: "Command-line arguments, comma-separated.",
|
||||||
extraArgs: "Extra CLI arguments for local adapters, 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.",
|
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||||
@@ -52,6 +52,7 @@ export const help: Record<string, string> = {
|
|||||||
export const adapterLabels: Record<string, string> = {
|
export const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const queryKeys = {
|
|||||||
taskSessions: (id: string) => ["agents", "task-sessions", id] as const,
|
taskSessions: (id: string) => ["agents", "task-sessions", id] as const,
|
||||||
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||||
configRevisions: (agentId: string) => ["agents", "config-revisions", 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: {
|
issues: {
|
||||||
list: (companyId: string) => ["issues", companyId] as const,
|
list: (companyId: string) => ["issues", companyId] as const,
|
||||||
|
|||||||
@@ -1154,8 +1154,12 @@ function ConfigurationTab({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const { data: adapterModels } = useQuery({
|
||||||
queryKey: ["adapter-models", agent.adapterType],
|
queryKey:
|
||||||
queryFn: () => agentsApi.adapterModels(agent.adapterType),
|
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({
|
const updateAgent = useMutation({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { Agent } from "@paperclipai/shared";
|
|||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude",
|
claude_local: "Claude",
|
||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
|
opencode_local: "OpenCode",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ const joinAdapterOptions: AgentAdapterType[] = [
|
|||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude (local)",
|
claude_local: "Claude (local)",
|
||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
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) {
|
function dateTime(value: string) {
|
||||||
return new Date(value).toLocaleString();
|
return new Date(value).toLocaleString();
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
|
|||||||
const adapterLabels: Record<string, string> = {
|
const adapterLabels: Record<string, string> = {
|
||||||
claude_local: "Claude",
|
claude_local: "Claude",
|
||||||
codex_local: "Codex",
|
codex_local: "Codex",
|
||||||
|
opencode_local: "OpenCode",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
projects: ["packages/db", "server", "ui", "cli"],
|
projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user