Merge remote-tracking branch 'public-gh/master'
* public-gh/master: Fix review feedback: duplicate wizard entry, command resolution, @types/node Fix server: remove DEFAULT_OPENCODE_LOCAL_MODEL from agents route Fix TS errors: remove DEFAULT_OPENCODE_LOCAL_MODEL references Regenerate pnpm-lock.yaml after PR #62 merge fix(onboard): preserve env-derived secrets defaults and report ignored exposure env in local_trusted mode fix: parseBooleanFromEnv silently treats common truthy values as false `onboard` now derives defaults from env vars before writing config Use precomputed runtime env in OpenCode execute Fix remaining OpenCode review comments Address PR feedback for OpenCode integration Add OpenCode provider integration and strict model selection
This commit is contained in:
@@ -17,7 +17,7 @@ const codexLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCodexStreamEvent,
|
||||
};
|
||||
|
||||
const opencodeLocalCLIAdapter: CLIAdapterModule = {
|
||||
const openCodeLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "opencode_local",
|
||||
formatStdoutEvent: printOpenCodeStreamEvent,
|
||||
};
|
||||
@@ -33,7 +33,7 @@ const openclawCLIAdapter: CLIAdapterModule = {
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, opencodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
AUTH_BASE_URL_MODES,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
DEPLOYMENT_MODES,
|
||||
SECRET_PROVIDERS,
|
||||
STORAGE_PROVIDERS,
|
||||
type AuthBaseUrlMode,
|
||||
type DeploymentExposure,
|
||||
type DeploymentMode,
|
||||
type SecretProvider,
|
||||
type StorageProvider,
|
||||
} from "@paperclipai/shared";
|
||||
import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
|
||||
@@ -12,6 +25,7 @@ import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir,
|
||||
@@ -29,18 +43,116 @@ type OnboardOptions = {
|
||||
invokedByRun?: boolean;
|
||||
};
|
||||
|
||||
function quickstartDefaults(): Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets"> {
|
||||
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
||||
|
||||
const ONBOARD_ENV_KEYS = [
|
||||
"DATABASE_URL",
|
||||
"PAPERCLIP_DB_BACKUP_ENABLED",
|
||||
"PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES",
|
||||
"PAPERCLIP_DB_BACKUP_RETENTION_DAYS",
|
||||
"PAPERCLIP_DB_BACKUP_DIR",
|
||||
"PAPERCLIP_DEPLOYMENT_MODE",
|
||||
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||
"HOST",
|
||||
"PORT",
|
||||
"SERVE_UI",
|
||||
"PAPERCLIP_ALLOWED_HOSTNAMES",
|
||||
"PAPERCLIP_AUTH_BASE_URL_MODE",
|
||||
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
|
||||
"BETTER_AUTH_URL",
|
||||
"PAPERCLIP_STORAGE_PROVIDER",
|
||||
"PAPERCLIP_STORAGE_LOCAL_DIR",
|
||||
"PAPERCLIP_STORAGE_S3_BUCKET",
|
||||
"PAPERCLIP_STORAGE_S3_REGION",
|
||||
"PAPERCLIP_STORAGE_S3_ENDPOINT",
|
||||
"PAPERCLIP_STORAGE_S3_PREFIX",
|
||||
"PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE",
|
||||
"PAPERCLIP_SECRETS_PROVIDER",
|
||||
"PAPERCLIP_SECRETS_STRICT_MODE",
|
||||
"PAPERCLIP_SECRETS_MASTER_KEY_FILE",
|
||||
] as const;
|
||||
|
||||
function parseBooleanFromEnv(rawValue: string | undefined): boolean | null {
|
||||
if (rawValue === undefined) return null;
|
||||
const lower = rawValue.trim().toLowerCase();
|
||||
if (lower === "true" || lower === "1" || lower === "yes") return true;
|
||||
if (lower === "false" || lower === "0" || lower === "no") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNumberFromEnv(rawValue: string | undefined): number | null {
|
||||
if (!rawValue) return null;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseEnumFromEnv<T extends string>(rawValue: string | undefined, allowedValues: readonly T[]): T | null {
|
||||
if (!rawValue) return null;
|
||||
return allowedValues.includes(rawValue as T) ? (rawValue as T) : null;
|
||||
}
|
||||
|
||||
function resolvePathFromEnv(rawValue: string | undefined): string | null {
|
||||
if (!rawValue || rawValue.trim().length === 0) return null;
|
||||
return path.resolve(expandHomePrefix(rawValue.trim()));
|
||||
}
|
||||
|
||||
function quickstartDefaultsFromEnv(): {
|
||||
defaults: OnboardDefaults;
|
||||
usedEnvKeys: string[];
|
||||
ignoredEnvKeys: Array<{ key: string; reason: string }>;
|
||||
} {
|
||||
const instanceId = resolvePaperclipInstanceId();
|
||||
return {
|
||||
const defaultStorage = defaultStorageConfig();
|
||||
const defaultSecrets = defaultSecretsConfig();
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
|
||||
const deploymentMode =
|
||||
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
|
||||
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
|
||||
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
);
|
||||
const deploymentExposure =
|
||||
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
|
||||
const authPublicBaseUrl =
|
||||
(process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? process.env.BETTER_AUTH_URL)?.trim() || undefined;
|
||||
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
|
||||
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
|
||||
AUTH_BASE_URL_MODES,
|
||||
);
|
||||
const authBaseUrlMode = authBaseUrlModeFromEnv ?? (authPublicBaseUrl ? "explicit" : "auto");
|
||||
const allowedHostnamesFromEnv = process.env.PAPERCLIP_ALLOWED_HOSTNAMES
|
||||
? process.env.PAPERCLIP_ALLOWED_HOSTNAMES
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => value.length > 0)
|
||||
: [];
|
||||
const storageProvider =
|
||||
parseEnumFromEnv<StorageProvider>(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ??
|
||||
defaultStorage.provider;
|
||||
const secretsProvider =
|
||||
parseEnumFromEnv<SecretProvider>(process.env.PAPERCLIP_SECRETS_PROVIDER, SECRET_PROVIDERS) ??
|
||||
defaultSecrets.provider;
|
||||
const databaseBackupEnabled = parseBooleanFromEnv(process.env.PAPERCLIP_DB_BACKUP_ENABLED) ?? true;
|
||||
const databaseBackupIntervalMinutes = Math.max(
|
||||
1,
|
||||
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES) ?? 60,
|
||||
);
|
||||
const databaseBackupRetentionDays = Math.max(
|
||||
1,
|
||||
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ?? 30,
|
||||
);
|
||||
const defaults: OnboardDefaults = {
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
mode: databaseUrl ? "postgres" : "embedded-postgres",
|
||||
...(databaseUrl ? { connectionString: databaseUrl } : {}),
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: resolveDefaultBackupDir(instanceId),
|
||||
enabled: databaseBackupEnabled,
|
||||
intervalMinutes: databaseBackupIntervalMinutes,
|
||||
retentionDays: databaseBackupRetentionDays,
|
||||
dir: resolvePathFromEnv(process.env.PAPERCLIP_DB_BACKUP_DIR) ?? resolveDefaultBackupDir(instanceId),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
@@ -48,19 +160,56 @@ function quickstartDefaults(): Pick<PaperclipConfig, "database" | "logging" | "s
|
||||
logDir: resolveDefaultLogsDir(instanceId),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
deploymentMode,
|
||||
exposure: deploymentExposure,
|
||||
host: process.env.HOST ?? "127.0.0.1",
|
||||
port: Number(process.env.PORT) || 3100,
|
||||
allowedHostnames: Array.from(new Set(allowedHostnamesFromEnv)),
|
||||
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
baseUrlMode: authBaseUrlMode,
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
},
|
||||
storage: {
|
||||
provider: storageProvider,
|
||||
localDisk: {
|
||||
baseDir:
|
||||
resolvePathFromEnv(process.env.PAPERCLIP_STORAGE_LOCAL_DIR) ?? defaultStorage.localDisk.baseDir,
|
||||
},
|
||||
s3: {
|
||||
bucket: process.env.PAPERCLIP_STORAGE_S3_BUCKET ?? defaultStorage.s3.bucket,
|
||||
region: process.env.PAPERCLIP_STORAGE_S3_REGION ?? defaultStorage.s3.region,
|
||||
endpoint: process.env.PAPERCLIP_STORAGE_S3_ENDPOINT ?? defaultStorage.s3.endpoint,
|
||||
prefix: process.env.PAPERCLIP_STORAGE_S3_PREFIX ?? defaultStorage.s3.prefix,
|
||||
forcePathStyle:
|
||||
parseBooleanFromEnv(process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE) ??
|
||||
defaultStorage.s3.forcePathStyle,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: secretsProvider,
|
||||
strictMode: parseBooleanFromEnv(process.env.PAPERCLIP_SECRETS_STRICT_MODE) ?? defaultSecrets.strictMode,
|
||||
localEncrypted: {
|
||||
keyFilePath:
|
||||
resolvePathFromEnv(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
|
||||
defaultSecrets.localEncrypted.keyFilePath,
|
||||
},
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
};
|
||||
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
|
||||
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
|
||||
ignoredEnvKeys.push({
|
||||
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||
reason: "Ignored because deployment mode local_trusted always forces private exposure",
|
||||
});
|
||||
}
|
||||
|
||||
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
|
||||
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
|
||||
(key) => process.env[key] !== undefined && !ignoredKeySet.has(key),
|
||||
);
|
||||
return { defaults, usedEnvKeys, ignoredEnvKeys };
|
||||
}
|
||||
|
||||
export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
@@ -116,6 +265,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
}
|
||||
|
||||
let llm: PaperclipConfig["llm"] | undefined;
|
||||
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
|
||||
let {
|
||||
database,
|
||||
logging,
|
||||
@@ -123,7 +273,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
auth,
|
||||
storage,
|
||||
secrets,
|
||||
} = quickstartDefaults();
|
||||
} = derivedDefaults;
|
||||
|
||||
if (setupMode === "advanced") {
|
||||
p.log.step(pc.bold("Database"));
|
||||
@@ -191,13 +341,20 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
logging = await promptLogging();
|
||||
|
||||
p.log.step(pc.bold("Server"));
|
||||
({ server, auth } = await promptServer());
|
||||
({ server, auth } = await promptServer({ currentServer: server, currentAuth: auth }));
|
||||
|
||||
p.log.step(pc.bold("Storage"));
|
||||
storage = await promptStorage(defaultStorageConfig());
|
||||
storage = await promptStorage(storage);
|
||||
|
||||
p.log.step(pc.bold("Secrets"));
|
||||
secrets = defaultSecretsConfig();
|
||||
const secretsDefaults = defaultSecretsConfig();
|
||||
secrets = {
|
||||
provider: secrets.provider ?? secretsDefaults.provider,
|
||||
strictMode: secrets.strictMode ?? secretsDefaults.strictMode,
|
||||
localEncrypted: {
|
||||
keyFilePath: secrets.localEncrypted?.keyFilePath ?? secretsDefaults.localEncrypted.keyFilePath,
|
||||
},
|
||||
};
|
||||
p.log.message(
|
||||
pc.dim(
|
||||
`Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`,
|
||||
@@ -205,9 +362,17 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
);
|
||||
} else {
|
||||
p.log.step(pc.bold("Quickstart"));
|
||||
p.log.message(
|
||||
pc.dim("Using local defaults: embedded database, no LLM provider, file storage, and local encrypted secrets."),
|
||||
);
|
||||
p.log.message(pc.dim("Using quickstart defaults."));
|
||||
if (usedEnvKeys.length > 0) {
|
||||
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
|
||||
} else {
|
||||
p.log.message(
|
||||
pc.dim("No environment overrides detected: embedded database, file storage, local encrypted secrets."),
|
||||
);
|
||||
}
|
||||
for (const ignored of ignoredEnvKeys) {
|
||||
p.log.message(pc.dim(`Ignored ${ignored.key}: ${ignored.reason}`));
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = ensureAgentJwtSecret(configPath);
|
||||
|
||||
@@ -149,7 +149,14 @@ export async function promptServer(opts?: {
|
||||
}
|
||||
|
||||
return {
|
||||
server: { deploymentMode, exposure, host: hostStr.trim(), port, allowedHostnames, serveUi: true },
|
||||
server: {
|
||||
deploymentMode,
|
||||
exposure,
|
||||
host: hostStr.trim(),
|
||||
port,
|
||||
allowedHostnames,
|
||||
serveUi: currentServer?.serveUi ?? true,
|
||||
},
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ When a heartbeat fires, Paperclip:
|
||||
|---------|----------|-------------|
|
||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||
|
||||
@@ -52,7 +54,7 @@ Three registries consume these modules:
|
||||
|
||||
## Choosing an Adapter
|
||||
|
||||
- **Need a coding agent?** Use `claude_local` or `codex_local`
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local`
|
||||
- **Need to run a script or command?** Use `process`
|
||||
- **Need to call an external service?** Use `http`
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||
|
||||
@@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org
|
||||
|
||||
Returns the full organizational tree for the company.
|
||||
|
||||
## List Adapter Models
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/adapters/{adapterType}/models
|
||||
```
|
||||
|
||||
Returns selectable models for an adapter type.
|
||||
|
||||
- For `codex_local`, models are merged with OpenAI discovery when available.
|
||||
- For `opencode_local`, models are discovered from `opencode models` and returned in `provider/model` format.
|
||||
- `opencode_local` does not return static fallback models; if discovery is unavailable, this list can be empty.
|
||||
|
||||
## Config Revisions
|
||||
|
||||
```
|
||||
|
||||
@@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires:
|
||||
- **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.)
|
||||
- **Capabilities** — short description of what this agent does
|
||||
|
||||
Common adapter choices:
|
||||
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
|
||||
- `openclaw` / `http` for webhook-based external agents
|
||||
- `process` for generic local command execution
|
||||
|
||||
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
|
||||
Paperclip validates the selected model against live `opencode models` output.
|
||||
|
||||
## Agent Hiring via Governance
|
||||
|
||||
Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject.
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Added initial `opencode_local` adapter package for local OpenCode execution
|
||||
- Add local OpenCode adapter package with server/UI/CLI modules.
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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>;
|
||||
@@ -13,42 +21,21 @@ function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function printToolEvent(part: Record<string, unknown>): void {
|
||||
const tool = asString(part.tool, "tool");
|
||||
const callId = asString(part.callID, asString(part.id, ""));
|
||||
const state = asRecord(part.state);
|
||||
const status = asString(state?.status);
|
||||
const input = state?.input;
|
||||
const output = asString(state?.output).replace(/\s+$/, "");
|
||||
const metadata = asRecord(state?.metadata);
|
||||
const exit = asNumber(metadata?.exit, NaN);
|
||||
const isError =
|
||||
status === "failed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled" ||
|
||||
(Number.isFinite(exit) && exit !== 0);
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`));
|
||||
if (input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(input)));
|
||||
}
|
||||
}
|
||||
|
||||
if (status || output) {
|
||||
const summary = [
|
||||
"tool_result",
|
||||
status ? `status=${status}` : "",
|
||||
Number.isFinite(exit) ? `exit=${exit}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
console.log((isError ? pc.red : pc.cyan)(summary));
|
||||
if (output) {
|
||||
console.log((isError ? pc.red : pc.gray)(output));
|
||||
}
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +43,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
@@ -74,18 +59,36 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text);
|
||||
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);
|
||||
if (part) {
|
||||
printToolEvent(part);
|
||||
} else {
|
||||
console.log(pc.yellow("tool_use"));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -93,20 +96,18 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason, "step_finish");
|
||||
const input = asNumber(tokens?.input);
|
||||
const output = asNumber(tokens?.output);
|
||||
const cached = asNumber(cache?.read);
|
||||
const cost = asNumber(part?.cost);
|
||||
console.log(pc.blue(`step finished: reason=${reason}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
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 part = asRecord(parsed.part);
|
||||
const message = asString(parsed.message) || asString(part?.message) || line;
|
||||
console.log(pc.red(`error: ${message}`));
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
export const type = "opencode_local";
|
||||
export const label = "OpenCode (local)";
|
||||
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
|
||||
|
||||
export const models = [
|
||||
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
|
||||
{ id: "openai/gpt-5.2", label: "openai/gpt-5.2" },
|
||||
{ id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" },
|
||||
{ id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" },
|
||||
];
|
||||
export const models: Array<{ id: string; label: string }> = [];
|
||||
|
||||
export const agentConfigurationDoc = `# opencode_local agent configuration
|
||||
|
||||
@@ -26,8 +20,8 @@ Don't use when:
|
||||
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, optional): OpenCode model id in provider/model format (for example openai/gpt-5.2-codex)
|
||||
- variant (string, optional): provider-specific reasoning/profile variant passed as --variant
|
||||
- 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
|
||||
@@ -38,7 +32,9 @@ Operational fields:
|
||||
- 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.
|
||||
- Runs are executed with: opencode run --format json ...
|
||||
- Prompts are passed as the final positional message argument.
|
||||
- Sessions are resumed with --session when stored session cwd matches current cwd.
|
||||
`;
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
||||
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
@@ -34,81 +34,11 @@ function firstNonEmptyLine(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
|
||||
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
|
||||
const raw = envOverrides[key];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
const raw = process.env[key];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
function hasEffectiveEnvValue(envOverrides: Record<string, string>, key: string): boolean {
|
||||
return getEffectiveEnvValue(envOverrides, key).trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveOpenCodeBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasEffectiveEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
function resolveProviderFromModel(model: string): string | null {
|
||||
function parseModelProvider(model: string | null): string | null {
|
||||
if (!model) return null;
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed) return null;
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
return trimmed.slice(0, slash).toLowerCase();
|
||||
}
|
||||
|
||||
function isProviderModelNotFoundFailure(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`;
|
||||
return /ProviderModelNotFoundError|provider model not found/i.test(haystack);
|
||||
}
|
||||
|
||||
type ProviderModelNotFoundDetails = {
|
||||
providerId: string | null;
|
||||
modelId: string | null;
|
||||
suggestions: string[];
|
||||
};
|
||||
|
||||
function parseProviderModelNotFoundDetails(
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
): ProviderModelNotFoundDetails | null {
|
||||
if (!isProviderModelNotFoundFailure(stdout, stderr)) return null;
|
||||
const haystack = `${stdout}\n${stderr}`;
|
||||
|
||||
const providerMatch = haystack.match(/providerID:\s*"([^"]+)"/i);
|
||||
const modelMatch = haystack.match(/modelID:\s*"([^"]+)"/i);
|
||||
const suggestionsMatch = haystack.match(/suggestions:\s*\[([^\]]*)\]/i);
|
||||
const suggestions = suggestionsMatch
|
||||
? Array.from(
|
||||
suggestionsMatch[1].matchAll(/"([^"]+)"/g),
|
||||
(match) => match[1].trim(),
|
||||
).filter((value) => value.length > 0)
|
||||
: [];
|
||||
|
||||
return {
|
||||
providerId: providerMatch?.[1]?.trim().toLowerCase() || null,
|
||||
modelId: modelMatch?.[1]?.trim() || null,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
function formatModelNotFoundError(
|
||||
model: string,
|
||||
providerFromModel: string | null,
|
||||
details: ProviderModelNotFoundDetails | null,
|
||||
): string {
|
||||
const provider = details?.providerId || providerFromModel || "unknown";
|
||||
const missingModel = details?.modelId || model;
|
||||
const suggestions = details?.suggestions ?? [];
|
||||
const suggestionText =
|
||||
suggestions.length > 0 ? ` Suggested models: ${suggestions.map((value) => `\`${value}\``).join(", ")}.` : "";
|
||||
return (
|
||||
`OpenCode model \`${missingModel}\` is unavailable for provider \`${provider}\`.` +
|
||||
` Run \`opencode models ${provider}\` and set adapterConfig.model to a supported value.` +
|
||||
suggestionText
|
||||
);
|
||||
if (!trimmed.includes("/")) return null;
|
||||
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
@@ -160,8 +90,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "opencode");
|
||||
const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL);
|
||||
const variant = asString(config.variant, asString(config.effort, ""));
|
||||
const model = asString(config.model, "").trim();
|
||||
const variant = asString(config.variant, "").trim();
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
@@ -209,52 +139,39 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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 [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
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 billingType = resolveOpenCodeBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model,
|
||||
command,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
@@ -278,37 +195,41 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
const resolvedInstructionsFilePath = instructionsFilePath
|
||||
? path.resolve(cwd, instructionsFilePath)
|
||||
: "";
|
||||
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (instructionsFilePath) {
|
||||
if (resolvedInstructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\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`,
|
||||
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const commandNotes = (() => {
|
||||
if (!instructionsFilePath) return [] as string[];
|
||||
if (!resolvedInstructionsFilePath) return [] as string[];
|
||||
if (instructionsPrefix.length > 0) {
|
||||
return [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
];
|
||||
})();
|
||||
|
||||
@@ -329,7 +250,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (model) args.push("--model", model);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push(prompt);
|
||||
return args;
|
||||
};
|
||||
|
||||
@@ -341,10 +261,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, idx) => {
|
||||
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
||||
return value;
|
||||
}),
|
||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
@@ -353,29 +270,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
env: runtimeEnv,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
|
||||
return {
|
||||
proc,
|
||||
rawStderr: proc.stderr,
|
||||
parsed: parseOpenCodeJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const providerFromModel = resolveProviderFromModel(model);
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
||||
rawStderr: string;
|
||||
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
@@ -390,7 +301,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||
const resolvedSessionId =
|
||||
attempt.parsed.sessionId ??
|
||||
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
@@ -400,50 +313,54 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
...(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 modelNotFound = parseProviderModelNotFoundDetails(attempt.proc.stdout, attempt.proc.stderr);
|
||||
const fallbackErrorMessage = modelNotFound
|
||||
? formatModelNotFoundError(model, providerFromModel, modelNotFound)
|
||||
: parsedError ||
|
||||
stderrLine ||
|
||||
`OpenCode exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
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: attempt.proc.exitCode,
|
||||
exitCode: synthesizedExitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
usage: attempt.parsed.usage,
|
||||
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: providerFromModel,
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
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),
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
const initialFailed =
|
||||
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
initialFailed &&
|
||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] OpenCode resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true);
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
@@ -62,3 +59,13 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
195
packages/adapters/opencode-local/src/server/models.ts
Normal file
195
packages/adapters/opencode-local/src/server/models.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
|
||||
function resolveOpenCodeCommand(input: unknown): string {
|
||||
const envOverride =
|
||||
typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" &&
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0
|
||||
? process.env.PAPERCLIP_OPENCODE_COMMAND.trim()
|
||||
: "opencode";
|
||||
return asString(input, envOverride);
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
|
||||
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
|
||||
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]);
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<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 isVolatileEnvKey(key: string): boolean {
|
||||
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
|
||||
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
|
||||
const envKey = Object.entries(env)
|
||||
.filter(([key]) => !isVolatileEnvKey(key))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${hashValue(value)}`)
|
||||
.join("\n");
|
||||
return `${command}\n${cwd}\n${envKey}`;
|
||||
}
|
||||
|
||||
function pruneExpiredDiscoveryCache(now: number) {
|
||||
for (const [key, value] of discoveryCache.entries()) {
|
||||
if (value.expiresAt <= now) discoveryCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverOpenCodeModels(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = resolveOpenCodeCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
|
||||
const result = await runChildProcess(
|
||||
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
["models"],
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
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 = resolveOpenCodeCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const key = discoveryCacheKey(command, cwd, env);
|
||||
const now = Date.now();
|
||||
pruneExpiredDiscoveryCache(now);
|
||||
const cached = discoveryCache.get(key);
|
||||
if (cached && cached.expiresAt > now) return cached.models;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message = asString(rec.message, "") || asString(rec.error, "") || asString(rec.code, "");
|
||||
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;
|
||||
const code = asString(rec.code, "").trim();
|
||||
if (code) return code;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
@@ -15,12 +22,12 @@ function asErrorText(value: unknown): string {
|
||||
export function parseOpenCodeJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
let totalCostUsd = 0;
|
||||
const errors: string[] = [];
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
costUsd: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
@@ -30,8 +37,8 @@ export function parseOpenCodeJsonl(stdout: string) {
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const foundSession = asString(event.sessionID, "").trim();
|
||||
if (foundSession) sessionId = foundSession;
|
||||
const currentSessionId = asString(event.sessionID, "").trim();
|
||||
if (currentSessionId) sessionId = currentSessionId;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
|
||||
@@ -48,15 +55,25 @@ export function parseOpenCodeJsonl(stdout: string) {
|
||||
const cache = parseObject(tokens.cache);
|
||||
usage.inputTokens += asNumber(tokens.input, 0);
|
||||
usage.cachedInputTokens += asNumber(cache.read, 0);
|
||||
usage.outputTokens += asNumber(tokens.output, 0);
|
||||
totalCostUsd += asNumber(part.cost, 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 part = parseObject(event.part);
|
||||
const msg = asErrorText(event.message ?? part.message ?? event.error ?? part.error).trim();
|
||||
if (msg) errorMessage = msg;
|
||||
const text = errorText(event.error ?? event.message).trim();
|
||||
if (text) errors.push(text);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,8 +81,7 @@ export function parseOpenCodeJsonl(stdout: string) {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
|
||||
errorMessage,
|
||||
errorMessage: errors.length > 0 ? errors.join("\n") : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,7 +92,7 @@ export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): b
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror/i.test(
|
||||
return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
||||
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
import { parseOpenCodeJsonl } from "./parse.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
@@ -22,19 +21,6 @@ function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentT
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
|
||||
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
|
||||
const raw = envOverrides[key];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
const raw = process.env[key];
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
@@ -44,22 +30,25 @@ function firstNonEmptyLine(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
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;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const OPENCODE_AUTH_REQUIRED_RE =
|
||||
/(?:not\s+authenticated|authentication\s+required|unauthorized|forbidden|api(?:[_\s-]?key)?(?:\s+is)?\s+required|missing\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|provider\s+credentials|login\s+required)/i;
|
||||
const OPENCODE_MODEL_NOT_FOUND_RE = /ProviderModelNotFoundError|provider\s+model\s+not\s+found/i;
|
||||
/(?: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,
|
||||
@@ -70,7 +59,7 @@ export async function testEnvironment(
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
|
||||
checks.push({
|
||||
code: "opencode_cwd_valid",
|
||||
level: "info",
|
||||
@@ -90,100 +79,138 @@ export async function testEnvironment(
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
|
||||
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
||||
if (cwdInvalid) {
|
||||
checks.push({
|
||||
code: "opencode_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
code: "opencode_command_skipped",
|
||||
level: "warn",
|
||||
message: "Skipped command check because working directory validation failed.",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configDefinesOpenAiKey = Object.prototype.hasOwnProperty.call(env, "OPENAI_API_KEY");
|
||||
const effectiveOpenAiKey = getEffectiveEnvValue(env, "OPENAI_API_KEY");
|
||||
if (isNonEmpty(effectiveOpenAiKey)) {
|
||||
const source = configDefinesOpenAiKey ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
code: "opencode_openai_api_key_present",
|
||||
level: "info",
|
||||
message: "OPENAI_API_KEY is set for OpenCode authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_openai_api_key_missing",
|
||||
level: "warn",
|
||||
message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.",
|
||||
hint: configDefinesOpenAiKey
|
||||
? "adapterConfig.env defines OPENAI_API_KEY but it is empty. Set a non-empty value or remove the override."
|
||||
: "Set OPENAI_API_KEY in adapter env/shell, or authenticate with `opencode auth login`.",
|
||||
});
|
||||
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 modelValidationPassed = false;
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "opencode")) {
|
||||
try {
|
||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
checks.push({
|
||||
code: "opencode_models_discovered",
|
||||
level: "info",
|
||||
message: `Discovered ${discovered.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_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `opencode`.",
|
||||
detail: command,
|
||||
hint: "Use the `opencode` CLI command to run the automatic installation and auth probe.",
|
||||
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.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL).trim();
|
||||
const variant = asString(config.variant, asString(config.effort, "")).trim();
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
const args = ["run", "--format", "json"];
|
||||
if (model) args.push("--model", model);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("Respond with hello.");
|
||||
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: runtimeEnv,
|
||||
});
|
||||
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);
|
||||
|
||||
try {
|
||||
const probe = await runChildProcess(
|
||||
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: 60,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = parseOpenCodeJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
const modelNotFound = OPENCODE_MODEL_NOT_FOUND_RE.test(authEvidence);
|
||||
const modelProvider = (() => {
|
||||
const slash = model.indexOf("/");
|
||||
if (slash <= 0) return "openai";
|
||||
return model.slice(0, slash).toLowerCase();
|
||||
})();
|
||||
|
||||
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, verify `opencode run --format json \"Respond with hello\"` manually.",
|
||||
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
@@ -196,24 +223,16 @@ export async function testEnvironment(
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try `opencode run --format json \"Respond with hello\"` manually to inspect full output.",
|
||||
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
|
||||
}),
|
||||
});
|
||||
} else if (modelNotFound) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_model_unavailable",
|
||||
level: "warn",
|
||||
message: `OpenCode could not run model \`${model}\`.`,
|
||||
...(detail ? { detail } : {}),
|
||||
hint: `Run \`opencode models ${modelProvider}\` and set adapterConfig.model to one of the available models.`,
|
||||
});
|
||||
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "OpenCode CLI is installed, but authentication is not ready.",
|
||||
message: "OpenCode is installed, but provider authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Configure OPENAI_API_KEY in adapter env/shell, then retry the probe.",
|
||||
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
@@ -221,9 +240,17 @@ export async function testEnvironment(
|
||||
level: "error",
|
||||
message: "OpenCode hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `opencode run --format json \"Respond with hello\"` manually in this working directory to debug.",
|
||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "OpenCode hello probe failed.",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
@@ -56,10 +55,12 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
ac.model = v.model || DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
ac.graceSec = 20;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
|
||||
@@ -21,26 +21,57 @@ function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
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(value, null, 2);
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return String(value);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isJsonLike(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false;
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
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[] {
|
||||
@@ -51,6 +82,24 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt
|
||||
|
||||
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") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
return [
|
||||
@@ -62,93 +111,31 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
|
||||
const toolName = asString(part?.tool, "tool");
|
||||
const state = asRecord(part?.state);
|
||||
const input = state?.input ?? {};
|
||||
const output = asString(state?.output).trim();
|
||||
const status = asString(state?.status).trim();
|
||||
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
|
||||
const isError =
|
||||
status === "failed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled" ||
|
||||
(Number.isFinite(exitCode) && exitCode !== 0);
|
||||
|
||||
const entries: TranscriptEntry[] = [
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input,
|
||||
},
|
||||
];
|
||||
|
||||
if (status || output) {
|
||||
const lines: string[] = [];
|
||||
if (status) lines.push(`status: ${status}`);
|
||||
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
|
||||
if (output) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
if (isJsonLike(output)) {
|
||||
try {
|
||||
lines.push(JSON.stringify(JSON.parse(output), null, 2));
|
||||
} catch {
|
||||
lines.push(output);
|
||||
}
|
||||
} else {
|
||||
lines.push(output);
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content: lines.join("\n").trim() || "tool completed",
|
||||
isError,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason);
|
||||
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),
|
||||
outputTokens: asNumber(tokens?.output),
|
||||
cachedTokens: asNumber(cache?.read),
|
||||
costUsd: asNumber(part?.cost),
|
||||
subtype: reason || "step_finish",
|
||||
isError: reason === "error" || reason === "failed",
|
||||
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 message =
|
||||
asString(parsed.message) ||
|
||||
asString(asRecord(parsed.part)?.message) ||
|
||||
stringifyUnknown(parsed.error ?? asRecord(parsed.part)?.error) ||
|
||||
line;
|
||||
return [{ kind: "stderr", ts, text: message }];
|
||||
const text = errorText(parsed.error ?? parsed.message);
|
||||
return [{ kind: "stderr", ts, text: text || line }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -21,7 +21,15 @@ export const AGENT_STATUSES = [
|
||||
] as const;
|
||||
export type AgentStatus = (typeof AGENT_STATUSES)[number];
|
||||
|
||||
export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "opencode_local", "cursor", "openclaw"] as const;
|
||||
export const AGENT_ADAPTER_TYPES = [
|
||||
"process",
|
||||
"http",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"opencode_local",
|
||||
"cursor",
|
||||
"openclaw",
|
||||
] as const;
|
||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||
|
||||
export const AGENT_ROLES = [
|
||||
|
||||
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
@@ -80,7 +80,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -96,7 +96,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -112,7 +112,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -141,7 +141,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -155,6 +155,9 @@ importers:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.12.0
|
||||
version: 22.19.11
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
@@ -173,7 +176,7 @@ importers:
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
drizzle-kit:
|
||||
specifier: ^0.31.9
|
||||
version: 0.31.9
|
||||
@@ -185,7 +188,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
packages/shared:
|
||||
dependencies:
|
||||
@@ -228,7 +231,7 @@ importers:
|
||||
version: link:../packages/shared
|
||||
better-auth:
|
||||
specifier: 1.4.18
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
detect-port:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
@@ -277,7 +280,7 @@ importers:
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: ^24.6.0
|
||||
version: 24.11.0
|
||||
version: 24.12.0
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
@@ -295,10 +298,10 @@ importers:
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^6.1.0
|
||||
version: 6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vitest:
|
||||
specifier: ^3.0.5
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
ui:
|
||||
dependencies:
|
||||
@@ -2837,8 +2840,8 @@ packages:
|
||||
'@types/node@22.19.11':
|
||||
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
||||
|
||||
'@types/node@24.11.0':
|
||||
resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==}
|
||||
'@types/node@24.12.0':
|
||||
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
|
||||
|
||||
'@types/node@25.2.3':
|
||||
resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
|
||||
@@ -8159,7 +8162,7 @@ snapshots:
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
@@ -8168,7 +8171,7 @@ snapshots:
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/cookiejar@2.1.5': {}
|
||||
|
||||
@@ -8186,7 +8189,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@@ -8221,7 +8224,7 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.11.0':
|
||||
'@types/node@24.12.0':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
@@ -8243,18 +8246,18 @@ snapshots:
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
dependencies:
|
||||
'@types/cookiejar': 2.1.5
|
||||
'@types/methods': 1.1.4
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
form-data: 4.0.5
|
||||
|
||||
'@types/supertest@6.0.3':
|
||||
@@ -8268,7 +8271,7 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.3
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
@@ -8292,13 +8295,13 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
@@ -8383,7 +8386,7 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.9.19: {}
|
||||
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)):
|
||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||
@@ -8403,7 +8406,7 @@ snapshots:
|
||||
pg: 8.18.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
|
||||
better-call@1.1.8(zod@4.3.6):
|
||||
dependencies:
|
||||
@@ -10644,13 +10647,13 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite-node@3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
vite-node@3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
@@ -10686,7 +10689,7 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@6.4.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -10695,7 +10698,7 @@ snapshots:
|
||||
rollup: 4.57.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 24.11.0
|
||||
'@types/node': 24.12.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
@@ -10716,7 +10719,7 @@ snapshots:
|
||||
lightningcss: 1.30.2
|
||||
tsx: 4.21.0
|
||||
|
||||
vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -10725,7 +10728,7 @@ snapshots:
|
||||
rollup: 4.57.1
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 24.11.0
|
||||
'@types/node': 24.12.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
@@ -10746,11 +10749,11 @@ snapshots:
|
||||
lightningcss: 1.30.2
|
||||
tsx: 4.21.0
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
@@ -10768,12 +10771,12 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite-node: 3.2.4(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
vite-node: 3.2.4(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 24.11.0
|
||||
'@types/node': 24.12.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
|
||||
@@ -32,6 +32,7 @@ const workspacePaths = [
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/opencode-local",
|
||||
"packages/adapters/openclaw",
|
||||
];
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ const { resolve } = require('path');
|
||||
const root = '$REPO_ROOT';
|
||||
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw',
|
||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw',
|
||||
'server', 'cli'];
|
||||
const names = [];
|
||||
for (const d of dirs) {
|
||||
@@ -245,6 +245,7 @@ pnpm --filter @paperclipai/adapter-utils build
|
||||
pnpm --filter @paperclipai/db build
|
||||
pnpm --filter @paperclipai/adapter-claude-local build
|
||||
pnpm --filter @paperclipai/adapter-codex-local build
|
||||
pnpm --filter @paperclipai/adapter-opencode-local build
|
||||
pnpm --filter @paperclipai/adapter-openclaw build
|
||||
pnpm --filter @paperclipai/server build
|
||||
|
||||
@@ -280,7 +281,7 @@ if [ "$dry_run" = true ]; then
|
||||
echo ""
|
||||
echo " Preview what would be published:"
|
||||
for dir in packages/shared packages/adapter-utils packages/db \
|
||||
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/openclaw \
|
||||
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \
|
||||
server cli; do
|
||||
echo " --- $dir ---"
|
||||
cd "$REPO_ROOT/$dir"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
||||
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local";
|
||||
import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server";
|
||||
import { listAdapterModels } from "../adapters/index.js";
|
||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
||||
@@ -8,9 +9,11 @@ import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from ".
|
||||
describe("adapter model listing", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
|
||||
resetCodexModelsCacheForTests();
|
||||
resetCursorModelsCacheForTests();
|
||||
setCursorModelsRunnerForTests(null);
|
||||
resetOpenCodeModelsCacheForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -60,6 +63,7 @@ describe("adapter model listing", () => {
|
||||
expect(models).toEqual(codexFallbackModels);
|
||||
});
|
||||
|
||||
|
||||
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
|
||||
setCursorModelsRunnerForTests(() => ({
|
||||
status: null,
|
||||
@@ -90,4 +94,11 @@ describe("adapter model listing", () => {
|
||||
expect(first.some((model) => model.id === "gpt-5.3-codex-high")).toBe(true);
|
||||
expect(first.some((model) => model.id === "composer-1")).toBe(true);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,14 @@ import {
|
||||
} from "@paperclipai/adapter-cursor-local/server";
|
||||
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||
import {
|
||||
execute as opencodeExecute,
|
||||
testEnvironment as opencodeTestEnvironment,
|
||||
sessionCodec as opencodeSessionCodec,
|
||||
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 {
|
||||
agentConfigurationDoc as openCodeAgentConfigurationDoc,
|
||||
} from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
execute as openclawExecute,
|
||||
testEnvironment as openclawTestEnvironment,
|
||||
@@ -58,16 +61,6 @@ const codexLocalAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: codexAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const opencodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: opencodeExecute,
|
||||
testEnvironment: opencodeTestEnvironment,
|
||||
sessionCodec: opencodeSessionCodec,
|
||||
models: opencodeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: opencodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const cursorLocalAdapter: ServerAdapterModule = {
|
||||
type: "cursor",
|
||||
execute: cursorExecute,
|
||||
@@ -89,8 +82,19 @@ const openclawAdapter: ServerAdapterModule = {
|
||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const openCodeLocalAdapter: ServerAdapterModule = {
|
||||
type: "opencode_local",
|
||||
execute: openCodeExecute,
|
||||
testEnvironment: openCodeTestEnvironment,
|
||||
sessionCodec: openCodeSessionCodec,
|
||||
models: [],
|
||||
listModels: listOpenCodeModels,
|
||||
supportsLocalAgentJwt: true,
|
||||
agentConfigurationDoc: openCodeAgentConfigurationDoc,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||
[claudeLocalAdapter, codexLocalAdapter, opencodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||
@@ -198,15 +198,34 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
return next;
|
||||
}
|
||||
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
}
|
||||
// OpenCode requires explicit model selection — no default
|
||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
}
|
||||
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>) {
|
||||
const trimmed = candidatePath.trim();
|
||||
if (path.isAbsolute(trimmed)) return trimmed;
|
||||
@@ -338,7 +357,9 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/adapters/:type/models", async (req, res) => {
|
||||
router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = req.params.type as string;
|
||||
const models = await listAdapterModels(type);
|
||||
res.json(models);
|
||||
@@ -592,6 +613,11 @@ export function agentRoutes(db: Db) {
|
||||
requestedAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
companyId,
|
||||
hireInput.adapterType,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
const normalizedHireInput = {
|
||||
...hireInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
@@ -727,6 +753,11 @@ export function agentRoutes(db: Db) {
|
||||
requestedAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
companyId,
|
||||
req.body.adapterType,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
|
||||
const agent = await svc.create(companyId, {
|
||||
...req.body,
|
||||
@@ -906,6 +937,27 @@ 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 rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
? (asRecord(patchData.adapterConfig) ?? {})
|
||||
: (asRecord(existing.adapterConfig) ?? {});
|
||||
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
rawEffectiveAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
existing.companyId,
|
||||
requestedAdapterType,
|
||||
effectiveAdapterConfig,
|
||||
);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const agent = await svc.update(id, patchData, {
|
||||
recordRevision: {
|
||||
|
||||
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 |
@@ -8,7 +8,7 @@ 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 prompt at runtime.";
|
||||
"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,
|
||||
|
||||
@@ -117,7 +117,10 @@ export const agentsApi = {
|
||||
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
||||
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
||||
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/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||
),
|
||||
testEnvironment: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractModelName, extractProviderId } from "../lib/model-utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import {
|
||||
@@ -42,6 +42,7 @@ import { getUIAdapter } from "../adapters";
|
||||
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
|
||||
/* ---- Create mode values ---- */
|
||||
|
||||
@@ -132,7 +133,7 @@ const codexThinkingEffortOptions = [
|
||||
{ id: "high", label: "High" },
|
||||
] as const;
|
||||
|
||||
const opencodeVariantOptions = [
|
||||
const openCodeThinkingEffortOptions = [
|
||||
{ id: "", label: "Auto" },
|
||||
{ id: "minimal", label: "Minimal" },
|
||||
{ id: "low", label: "Low" },
|
||||
@@ -279,9 +280,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||
|
||||
// Fetch adapter models for the effective adapter type
|
||||
const { data: fetchedModels } = useQuery({
|
||||
queryKey: ["adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
||||
const {
|
||||
data: fetchedModels,
|
||||
error: fetchedModelsError,
|
||||
} = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType)
|
||||
: ["agents", "none", "adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const models = fetchedModels ?? externalModels ?? [];
|
||||
|
||||
@@ -339,17 +346,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? "modelReasoningEffort"
|
||||
: adapterType === "cursor"
|
||||
? "mode"
|
||||
: adapterType === "opencode_local"
|
||||
? "variant"
|
||||
: "effort";
|
||||
: adapterType === "opencode_local"
|
||||
? "variant"
|
||||
: "effort";
|
||||
const thinkingEffortOptions =
|
||||
adapterType === "codex_local"
|
||||
? codexThinkingEffortOptions
|
||||
: adapterType === "cursor"
|
||||
? cursorModeOptions
|
||||
: adapterType === "opencode_local"
|
||||
? opencodeVariantOptions
|
||||
: claudeThinkingEffortOptions;
|
||||
: adapterType === "opencode_local"
|
||||
? openCodeThinkingEffortOptions
|
||||
: claudeThinkingEffortOptions;
|
||||
const currentThinkingEffort = isCreate
|
||||
? val!.thinkingEffort
|
||||
: adapterType === "codex_local"
|
||||
@@ -360,8 +367,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
)
|
||||
: adapterType === "cursor"
|
||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: adapterType === "opencode_local"
|
||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||
const codexSearchEnabled = adapterType === "codex_local"
|
||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||
@@ -483,7 +490,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
} else if (t === "cursor") {
|
||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
} else if (t === "opencode_local") {
|
||||
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
||||
nextValues.model = "";
|
||||
}
|
||||
set!(nextValues);
|
||||
} else {
|
||||
@@ -498,9 +505,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? DEFAULT_CODEX_LOCAL_MODEL
|
||||
: t === "cursor"
|
||||
? DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: t === "opencode_local"
|
||||
? DEFAULT_OPENCODE_LOCAL_MODEL
|
||||
: "",
|
||||
: "",
|
||||
effort: "",
|
||||
modelReasoningEffort: "",
|
||||
variant: "",
|
||||
@@ -605,9 +610,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? "codex"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude"
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude"
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
@@ -622,7 +627,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}
|
||||
open={modelOpen}
|
||||
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
|
||||
value={currentThinkingEffort}
|
||||
@@ -898,7 +913,10 @@ function AdapterTypeDropdown({
|
||||
<Popover>
|
||||
<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">
|
||||
<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" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -918,7 +936,10 @@ function AdapterTypeDropdown({
|
||||
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 && (
|
||||
<span className="text-[10px] text-muted-foreground">Coming soon</span>
|
||||
)}
|
||||
@@ -1184,20 +1205,56 @@ function ModelDropdown({
|
||||
onChange,
|
||||
open,
|
||||
onOpenChange,
|
||||
allowDefault,
|
||||
required,
|
||||
groupByProvider,
|
||||
}: {
|
||||
models: AdapterModel[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
allowDefault: boolean;
|
||||
required: boolean;
|
||||
groupByProvider: boolean;
|
||||
}) {
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const selected = models.find((m) => m.id === value);
|
||||
const filteredModels = models.filter((m) => {
|
||||
if (!modelSearch.trim()) return true;
|
||||
const q = modelSearch.toLowerCase();
|
||||
return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q);
|
||||
});
|
||||
const filteredModels = useMemo(() => {
|
||||
return models.filter((m) => {
|
||||
if (!modelSearch.trim()) return true;
|
||||
const q = modelSearch.toLowerCase();
|
||||
const provider = extractProviderId(m.id) ?? "";
|
||||
return (
|
||||
m.id.toLowerCase().includes(q) ||
|
||||
m.label.toLowerCase().includes(q) ||
|
||||
provider.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [models, modelSearch]);
|
||||
const groupedModels = useMemo(() => {
|
||||
if (!groupByProvider) {
|
||||
return [
|
||||
{
|
||||
provider: "models",
|
||||
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
},
|
||||
];
|
||||
}
|
||||
const map = new Map<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 (
|
||||
<Field label="Model" hint={help.model}>
|
||||
@@ -1211,7 +1268,9 @@ function ModelDropdown({
|
||||
<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">
|
||||
<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>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -1225,33 +1284,45 @@ function ModelDropdown({
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
<button
|
||||
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) => (
|
||||
{allowDefault && (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
m.id === value && "bg-accent",
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!value && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(m.id);
|
||||
onChange("");
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<span>{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
||||
Default
|
||||
</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 && (
|
||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||
|
||||
@@ -55,14 +55,23 @@ export function NewAgentDialog() {
|
||||
enabled: !!selectedCompanyId && newAgentOpen,
|
||||
});
|
||||
|
||||
const { data: adapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", configValues.adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(configValues.adapterType),
|
||||
enabled: newAgentOpen,
|
||||
const {
|
||||
data: adapterModels,
|
||||
error: adapterModelsError,
|
||||
isLoading: adapterModelsLoading,
|
||||
isFetching: adapterModelsFetching,
|
||||
} = useQuery({
|
||||
queryKey:
|
||||
selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
|
||||
: ["agents", "none", "adapter-models", configValues.adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
|
||||
enabled: Boolean(selectedCompanyId) && newAgentOpen,
|
||||
});
|
||||
|
||||
const isFirstAgent = !agents || agents.length === 0;
|
||||
const effectiveRole = isFirstAgent ? "ceo" : role;
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Auto-fill for CEO
|
||||
useEffect(() => {
|
||||
@@ -82,6 +91,9 @@ export function NewAgentDialog() {
|
||||
closeNewAgent();
|
||||
navigate(agentUrl(result.agent));
|
||||
},
|
||||
onError: (error) => {
|
||||
setFormError(error instanceof Error ? error.message : "Failed to create agent");
|
||||
},
|
||||
});
|
||||
|
||||
function reset() {
|
||||
@@ -91,6 +103,7 @@ export function NewAgentDialog() {
|
||||
setReportsTo("");
|
||||
setConfigValues(defaultCreateValues);
|
||||
setExpanded(true);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function buildAdapterConfig() {
|
||||
@@ -100,6 +113,35 @@ export function NewAgentDialog() {
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedCompanyId || !name.trim()) return;
|
||||
setFormError(null);
|
||||
if (configValues.adapterType === "opencode_local") {
|
||||
const selectedModel = configValues.model.trim();
|
||||
if (!selectedModel) {
|
||||
setFormError("OpenCode requires an explicit model in provider/model format.");
|
||||
return;
|
||||
}
|
||||
if (adapterModelsError) {
|
||||
setFormError(
|
||||
adapterModelsError instanceof Error
|
||||
? adapterModelsError.message
|
||||
: "Failed to load OpenCode models.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (adapterModelsLoading || adapterModelsFetching) {
|
||||
setFormError("OpenCode models are still loading. Please wait and try again.");
|
||||
return;
|
||||
}
|
||||
const discovered = adapterModels ?? [];
|
||||
if (!discovered.some((entry) => entry.id === selectedModel)) {
|
||||
setFormError(
|
||||
discovered.length === 0
|
||||
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
||||
: `Configured OpenCode model is unavailable: ${selectedModel}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
createAgent.mutate({
|
||||
name: name.trim(),
|
||||
role: effectiveRole,
|
||||
@@ -281,6 +323,11 @@ export function NewAgentDialog() {
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isFirstAgent ? "This will be the CEO" : ""}
|
||||
</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
|
||||
size="sm"
|
||||
disabled={!name.trim() || createAgent.isPending}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
@@ -115,6 +116,8 @@ function buildAssigneeAdapterOverrides(input: {
|
||||
adapterConfig.variant = input.thinkingEffortOverride;
|
||||
} else if (adapterType === "claude_local") {
|
||||
adapterConfig.effort = input.thinkingEffortOverride;
|
||||
} else if (adapterType === "opencode_local") {
|
||||
adapterConfig.variant = input.thinkingEffortOverride;
|
||||
}
|
||||
}
|
||||
if (adapterType === "claude_local" && input.chrome) {
|
||||
@@ -248,9 +251,12 @@ export function NewIssueDialog() {
|
||||
}, [agents, orderedProjects]);
|
||||
|
||||
const { data: assigneeAdapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", assigneeAdapterType],
|
||||
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
|
||||
enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
||||
queryKey:
|
||||
effectiveCompanyId && assigneeAdapterType
|
||||
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType)
|
||||
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
|
||||
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!),
|
||||
enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides,
|
||||
});
|
||||
|
||||
const createIssue = useMutation({
|
||||
@@ -364,7 +370,7 @@ export function NewIssueDialog() {
|
||||
? 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;
|
||||
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
|
||||
setAssigneeThinkingEffort("");
|
||||
}
|
||||
@@ -496,12 +502,21 @@ export function NewIssueDialog() {
|
||||
[orderedProjects],
|
||||
);
|
||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||
() =>
|
||||
(assigneeAdapterModels ?? []).map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
searchText: model.id,
|
||||
})),
|
||||
() => {
|
||||
return [...(assigneeAdapterModels ?? [])]
|
||||
.sort((a, b) => {
|
||||
const providerA = extractProviderIdWithFallback(a.id);
|
||||
const providerB = extractProviderIdWithFallback(b.id);
|
||||
const byProvider = providerA.localeCompare(providerB);
|
||||
if (byProvider !== 0) return byProvider;
|
||||
return a.id.localeCompare(b.id);
|
||||
})
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
searchText: `${model.id} ${extractProviderIdWithFallback(model.id)}`,
|
||||
}));
|
||||
},
|
||||
[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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "../lib/utils";
|
||||
import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { defaultCreateValues } from "./agent-config-defaults";
|
||||
import {
|
||||
@@ -24,10 +25,10 @@ import {
|
||||
DEFAULT_CODEX_LOCAL_MODEL
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { HintIcon } from "./agent-config-primitives";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import {
|
||||
Building2,
|
||||
Bot,
|
||||
@@ -76,6 +77,7 @@ export function OnboardingWizard() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
|
||||
// Step 1
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
@@ -149,10 +151,18 @@ export function OnboardingWizard() {
|
||||
if (step === 3) autoResizeTextarea();
|
||||
}, [step, taskDescription, autoResizeTextarea]);
|
||||
|
||||
const { data: adapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
||||
enabled: onboardingOpen && step === 2
|
||||
const {
|
||||
data: adapterModels,
|
||||
error: adapterModelsError,
|
||||
isLoading: adapterModelsLoading,
|
||||
isFetching: adapterModelsFetching,
|
||||
} = 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 =
|
||||
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || adapterType === "cursor";
|
||||
@@ -162,9 +172,9 @@ export function OnboardingWizard() {
|
||||
? "codex"
|
||||
: adapterType === "cursor"
|
||||
? "agent"
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude");
|
||||
: adapterType === "opencode_local"
|
||||
? "opencode"
|
||||
: "claude");
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 2) return;
|
||||
@@ -182,6 +192,41 @@ export function OnboardingWizard() {
|
||||
adapterType === "claude_local" &&
|
||||
adapterEnvResult?.status === "fail" &&
|
||||
hasAnthropicApiKeyOverrideCheck;
|
||||
const filteredModels = useMemo(() => {
|
||||
const query = modelSearch.trim().toLowerCase();
|
||||
return (adapterModels ?? []).filter((entry) => {
|
||||
if (!query) return true;
|
||||
const provider = extractProviderIdWithFallback(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 = extractProviderIdWithFallback(entry.id);
|
||||
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() {
|
||||
setStep(1);
|
||||
@@ -225,8 +270,6 @@ export function OnboardingWizard() {
|
||||
? model || DEFAULT_CODEX_LOCAL_MODEL
|
||||
: adapterType === "cursor"
|
||||
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
||||
: adapterType === "opencode_local"
|
||||
? model || DEFAULT_OPENCODE_LOCAL_MODEL
|
||||
: model,
|
||||
command,
|
||||
args,
|
||||
@@ -315,6 +358,35 @@ export function OnboardingWizard() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
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;
|
||||
}
|
||||
if (adapterModelsLoading || adapterModelsFetching) {
|
||||
setError("OpenCode models are still loading. Please wait and try again.");
|
||||
return;
|
||||
}
|
||||
const discoveredModels = adapterModels ?? [];
|
||||
if (!discoveredModels.some((entry) => entry.id === selectedModelId)) {
|
||||
setError(
|
||||
discoveredModels.length === 0
|
||||
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
||||
: `Configured OpenCode model is unavailable: ${selectedModelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocalAdapter) {
|
||||
const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
|
||||
if (!result) return;
|
||||
@@ -590,8 +662,8 @@ export function OnboardingWizard() {
|
||||
{
|
||||
value: "opencode_local" as const,
|
||||
label: "OpenCode",
|
||||
icon: Code,
|
||||
desc: "Local OpenCode agent"
|
||||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw" as const,
|
||||
@@ -640,9 +712,14 @@ export function OnboardingWizard() {
|
||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||
} else if (nextType === "cursor" && !model) {
|
||||
setModel(DEFAULT_CURSOR_LOCAL_MODEL);
|
||||
} else if (nextType === "opencode_local" && !model) {
|
||||
setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
|
||||
}
|
||||
if (nextType === "opencode_local") {
|
||||
if (!model.includes("/")) {
|
||||
setModel("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setModel("");
|
||||
}}
|
||||
>
|
||||
{opt.recommended && (
|
||||
@@ -688,7 +765,13 @@ export function OnboardingWizard() {
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
Model
|
||||
</label>
|
||||
<Popover open={modelOpen} onOpenChange={setModelOpen}>
|
||||
<Popover
|
||||
open={modelOpen}
|
||||
onOpenChange={(next) => {
|
||||
setModelOpen(next);
|
||||
if (!next) setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
@@ -698,7 +781,10 @@ export function OnboardingWizard() {
|
||||
>
|
||||
{selectedModel
|
||||
? selectedModel.label
|
||||
: model || "Default"}
|
||||
: model ||
|
||||
(adapterType === "opencode_local"
|
||||
? "Select model (required)"
|
||||
: "Default")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -707,36 +793,60 @@ export function OnboardingWizard() {
|
||||
className="w-[var(--radix-popover-trigger-width)] p-1"
|
||||
align="start"
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!model && "bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
setModel("");
|
||||
setModelOpen(false);
|
||||
}}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{(adapterModels ?? []).map((m) => (
|
||||
<input
|
||||
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"
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{adapterType !== "opencode_local" && (
|
||||
<button
|
||||
key={m.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
m.id === model && "bg-accent"
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||
!model && "bg-accent"
|
||||
)}
|
||||
onClick={() => {
|
||||
setModel(m.id);
|
||||
setModel("");
|
||||
setModelOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{m.id}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
Default
|
||||
</button>
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{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>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -802,7 +912,7 @@ export function OnboardingWizard() {
|
||||
: adapterType === "codex_local"
|
||||
? `${effectiveAdapterCommand} exec --json -`
|
||||
: adapterType === "opencode_local"
|
||||
? `${effectiveAdapterCommand} run --format json \"Respond with hello.\"`
|
||||
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
|
||||
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
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.",
|
||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex), OpenClaw webhook, spawned process, or generic HTTP webhook.",
|
||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.",
|
||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||
model: "Override the default model used by the adapter.",
|
||||
@@ -34,7 +34,7 @@ export const help: Record<string, string> = {
|
||||
search: "Enable Codex web search capability during runs.",
|
||||
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
||||
command: "The command to execute (e.g. node, python).",
|
||||
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).",
|
||||
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
|
||||
args: "Command-line arguments, comma-separated.",
|
||||
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||
|
||||
16
ui/src/lib/model-utils.ts
Normal file
16
ui/src/lib/model-utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function extractProviderId(modelId: string): string | null {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed.includes("/")) return null;
|
||||
const provider = trimmed.slice(0, trimmed.indexOf("/")).trim();
|
||||
return provider || null;
|
||||
}
|
||||
|
||||
export function extractProviderIdWithFallback(modelId: string, fallback = "other"): string {
|
||||
return extractProviderId(modelId) ?? fallback;
|
||||
}
|
||||
|
||||
export function extractModelName(modelId: string): string {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed.includes("/")) return trimmed;
|
||||
return trimmed.slice(trimmed.indexOf("/") + 1).trim();
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export const queryKeys = {
|
||||
taskSessions: (id: string) => ["agents", "task-sessions", id] as const,
|
||||
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||
adapterModels: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "adapter-models", adapterType] as const,
|
||||
},
|
||||
issues: {
|
||||
list: (companyId: string) => ["issues", companyId] as const,
|
||||
|
||||
@@ -1155,8 +1155,12 @@ function ConfigurationTab({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: adapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", agent.adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(agent.adapterType),
|
||||
queryKey:
|
||||
companyId
|
||||
? queryKeys.agents.adapterModels(companyId, agent.adapterType)
|
||||
: ["agents", "none", "adapter-models", agent.adapterType],
|
||||
queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType),
|
||||
enabled: Boolean(companyId),
|
||||
});
|
||||
|
||||
const updateAgent = useMutation({
|
||||
|
||||
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: ["packages/db", "server", "ui", "cli"],
|
||||
projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user