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,
|
formatStdoutEvent: printCodexStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const opencodeLocalCLIAdapter: CLIAdapterModule = {
|
const openCodeLocalCLIAdapter: CLIAdapterModule = {
|
||||||
type: "opencode_local",
|
type: "opencode_local",
|
||||||
formatStdoutEvent: printOpenCodeStreamEvent,
|
formatStdoutEvent: printOpenCodeStreamEvent,
|
||||||
};
|
};
|
||||||
@@ -33,7 +33,7 @@ const openclawCLIAdapter: CLIAdapterModule = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, 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 {
|
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
|
import path from "node:path";
|
||||||
import pc from "picocolors";
|
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 { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.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 { promptServer } from "../prompts/server.js";
|
||||||
import {
|
import {
|
||||||
describeLocalInstancePaths,
|
describeLocalInstancePaths,
|
||||||
|
expandHomePrefix,
|
||||||
resolveDefaultBackupDir,
|
resolveDefaultBackupDir,
|
||||||
resolveDefaultEmbeddedPostgresDir,
|
resolveDefaultEmbeddedPostgresDir,
|
||||||
resolveDefaultLogsDir,
|
resolveDefaultLogsDir,
|
||||||
@@ -29,18 +43,116 @@ type OnboardOptions = {
|
|||||||
invokedByRun?: boolean;
|
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();
|
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: {
|
database: {
|
||||||
mode: "embedded-postgres",
|
mode: databaseUrl ? "postgres" : "embedded-postgres",
|
||||||
|
...(databaseUrl ? { connectionString: databaseUrl } : {}),
|
||||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
|
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
|
||||||
embeddedPostgresPort: 54329,
|
embeddedPostgresPort: 54329,
|
||||||
backup: {
|
backup: {
|
||||||
enabled: true,
|
enabled: databaseBackupEnabled,
|
||||||
intervalMinutes: 60,
|
intervalMinutes: databaseBackupIntervalMinutes,
|
||||||
retentionDays: 30,
|
retentionDays: databaseBackupRetentionDays,
|
||||||
dir: resolveDefaultBackupDir(instanceId),
|
dir: resolvePathFromEnv(process.env.PAPERCLIP_DB_BACKUP_DIR) ?? resolveDefaultBackupDir(instanceId),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
logging: {
|
logging: {
|
||||||
@@ -48,19 +160,56 @@ function quickstartDefaults(): Pick<PaperclipConfig, "database" | "logging" | "s
|
|||||||
logDir: resolveDefaultLogsDir(instanceId),
|
logDir: resolveDefaultLogsDir(instanceId),
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
deploymentMode: "local_trusted",
|
deploymentMode,
|
||||||
exposure: "private",
|
exposure: deploymentExposure,
|
||||||
host: "127.0.0.1",
|
host: process.env.HOST ?? "127.0.0.1",
|
||||||
port: 3100,
|
port: Number(process.env.PORT) || 3100,
|
||||||
allowedHostnames: [],
|
allowedHostnames: Array.from(new Set(allowedHostnamesFromEnv)),
|
||||||
serveUi: true,
|
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
|
||||||
},
|
},
|
||||||
auth: {
|
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> {
|
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;
|
let llm: PaperclipConfig["llm"] | undefined;
|
||||||
|
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
|
||||||
let {
|
let {
|
||||||
database,
|
database,
|
||||||
logging,
|
logging,
|
||||||
@@ -123,7 +273,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
auth,
|
auth,
|
||||||
storage,
|
storage,
|
||||||
secrets,
|
secrets,
|
||||||
} = quickstartDefaults();
|
} = derivedDefaults;
|
||||||
|
|
||||||
if (setupMode === "advanced") {
|
if (setupMode === "advanced") {
|
||||||
p.log.step(pc.bold("Database"));
|
p.log.step(pc.bold("Database"));
|
||||||
@@ -191,13 +341,20 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
logging = await promptLogging();
|
logging = await promptLogging();
|
||||||
|
|
||||||
p.log.step(pc.bold("Server"));
|
p.log.step(pc.bold("Server"));
|
||||||
({ server, auth } = await promptServer());
|
({ server, auth } = await promptServer({ currentServer: server, currentAuth: auth }));
|
||||||
|
|
||||||
p.log.step(pc.bold("Storage"));
|
p.log.step(pc.bold("Storage"));
|
||||||
storage = await promptStorage(defaultStorageConfig());
|
storage = await promptStorage(storage);
|
||||||
|
|
||||||
p.log.step(pc.bold("Secrets"));
|
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(
|
p.log.message(
|
||||||
pc.dim(
|
pc.dim(
|
||||||
`Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`,
|
`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 {
|
} else {
|
||||||
p.log.step(pc.bold("Quickstart"));
|
p.log.step(pc.bold("Quickstart"));
|
||||||
p.log.message(
|
p.log.message(pc.dim("Using quickstart defaults."));
|
||||||
pc.dim("Using local defaults: embedded database, no LLM provider, file storage, and local encrypted secrets."),
|
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);
|
const jwtSecret = ensureAgentJwtSecret(configPath);
|
||||||
|
|||||||
@@ -149,7 +149,14 @@ export async function promptServer(opts?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server: { deploymentMode, exposure, host: hostStr.trim(), port, allowedHostnames, serveUi: true },
|
server: {
|
||||||
|
deploymentMode,
|
||||||
|
exposure,
|
||||||
|
host: hostStr.trim(),
|
||||||
|
port,
|
||||||
|
allowedHostnames,
|
||||||
|
serveUi: currentServer?.serveUi ?? true,
|
||||||
|
},
|
||||||
auth,
|
auth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ When a heartbeat fires, Paperclip:
|
|||||||
|---------|----------|-------------|
|
|---------|----------|-------------|
|
||||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||||
|
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||||
|
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
||||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ Three registries consume these modules:
|
|||||||
|
|
||||||
## Choosing an Adapter
|
## Choosing an Adapter
|
||||||
|
|
||||||
- **Need a coding agent?** Use `claude_local` or `codex_local`
|
- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local`
|
||||||
- **Need to run a script or command?** Use `process`
|
- **Need to run a script or command?** Use `process`
|
||||||
- **Need to call an external service?** Use `http`
|
- **Need to call an external service?** Use `http`
|
||||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org
|
|||||||
|
|
||||||
Returns the full organizational tree for the company.
|
Returns the full organizational tree for the company.
|
||||||
|
|
||||||
|
## List Adapter Models
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/companies/{companyId}/adapters/{adapterType}/models
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns selectable models for an adapter type.
|
||||||
|
|
||||||
|
- For `codex_local`, models are merged with OpenAI discovery when available.
|
||||||
|
- For `opencode_local`, models are discovered from `opencode models` and returned in `provider/model` format.
|
||||||
|
- `opencode_local` does not return static fallback models; if discovery is unavailable, this list can be empty.
|
||||||
|
|
||||||
## Config Revisions
|
## Config Revisions
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires:
|
|||||||
- **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.)
|
- **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.)
|
||||||
- **Capabilities** — short description of what this agent does
|
- **Capabilities** — short description of what this agent does
|
||||||
|
|
||||||
|
Common adapter choices:
|
||||||
|
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
|
||||||
|
- `openclaw` / `http` for webhook-based external agents
|
||||||
|
- `process` for generic local command execution
|
||||||
|
|
||||||
|
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
|
||||||
|
Paperclip validates the selected model against live `opencode models` output.
|
||||||
|
|
||||||
## Agent Hiring via Governance
|
## Agent Hiring via Governance
|
||||||
|
|
||||||
Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject.
|
Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject.
|
||||||
|
|||||||
@@ -4,4 +4,4 @@
|
|||||||
|
|
||||||
### Patch Changes
|
### 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"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import pc from "picocolors";
|
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 {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
return value as Record<string, unknown>;
|
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;
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function printToolEvent(part: Record<string, unknown>): void {
|
function errorText(value: unknown): string {
|
||||||
const tool = asString(part.tool, "tool");
|
if (typeof value === "string") return value;
|
||||||
const callId = asString(part.callID, asString(part.id, ""));
|
const rec = asRecord(value);
|
||||||
const state = asRecord(part.state);
|
if (!rec) return "";
|
||||||
const status = asString(state?.status);
|
const data = asRecord(rec.data);
|
||||||
const input = state?.input;
|
const message =
|
||||||
const output = asString(state?.output).replace(/\s+$/, "");
|
asString(rec.message) ||
|
||||||
const metadata = asRecord(state?.metadata);
|
asString(data?.message) ||
|
||||||
const exit = asNumber(metadata?.exit, NaN);
|
asString(rec.name) ||
|
||||||
const isError =
|
"";
|
||||||
status === "failed" ||
|
if (message) return message;
|
||||||
status === "error" ||
|
try {
|
||||||
status === "cancelled" ||
|
return JSON.stringify(rec);
|
||||||
(Number.isFinite(exit) && exit !== 0);
|
} catch {
|
||||||
|
return "";
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +43,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
|||||||
const line = raw.trim();
|
const line = raw.trim();
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
|
|
||||||
let parsed: Record<string, unknown> | null = null;
|
const parsed = asRecord(safeJsonParse(line));
|
||||||
try {
|
if (!parsed) {
|
||||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
console.log(line);
|
console.log(line);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,18 +59,36 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
|||||||
|
|
||||||
if (type === "text") {
|
if (type === "text") {
|
||||||
const part = asRecord(parsed.part);
|
const part = asRecord(parsed.part);
|
||||||
const text = asString(part?.text);
|
const text = asString(part?.text).trim();
|
||||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||||
return;
|
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") {
|
if (type === "tool_use") {
|
||||||
const part = asRecord(parsed.part);
|
const part = asRecord(parsed.part);
|
||||||
if (part) {
|
const tool = asString(part?.tool, "tool");
|
||||||
printToolEvent(part);
|
const state = asRecord(part?.state);
|
||||||
} else {
|
const status = asString(state?.status);
|
||||||
console.log(pc.yellow("tool_use"));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,20 +96,18 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
|||||||
const part = asRecord(parsed.part);
|
const part = asRecord(parsed.part);
|
||||||
const tokens = asRecord(part?.tokens);
|
const tokens = asRecord(part?.tokens);
|
||||||
const cache = asRecord(tokens?.cache);
|
const cache = asRecord(tokens?.cache);
|
||||||
const reason = asString(part?.reason, "step_finish");
|
const input = asNumber(tokens?.input, 0);
|
||||||
const input = asNumber(tokens?.input);
|
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
|
||||||
const output = asNumber(tokens?.output);
|
const cached = asNumber(cache?.read, 0);
|
||||||
const cached = asNumber(cache?.read);
|
const cost = asNumber(part?.cost, 0);
|
||||||
const cost = asNumber(part?.cost);
|
const reason = asString(part?.reason, "step");
|
||||||
console.log(pc.blue(`step finished: reason=${reason}`));
|
console.log(pc.blue(`step finished (${reason}) tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "error") {
|
if (type === "error") {
|
||||||
const part = asRecord(parsed.part);
|
const message = errorText(parsed.error ?? parsed.message);
|
||||||
const message = asString(parsed.message) || asString(part?.message) || line;
|
if (message) console.log(pc.red(`error: ${message}`));
|
||||||
console.log(pc.red(`error: ${message}`));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
export const type = "opencode_local";
|
export const type = "opencode_local";
|
||||||
export const label = "OpenCode (local)";
|
export const label = "OpenCode (local)";
|
||||||
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
|
|
||||||
|
|
||||||
export const models = [
|
export const models: Array<{ id: string; label: string }> = [];
|
||||||
{ 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 agentConfigurationDoc = `# opencode_local agent configuration
|
export const agentConfigurationDoc = `# opencode_local agent configuration
|
||||||
|
|
||||||
@@ -26,8 +20,8 @@ Don't use when:
|
|||||||
Core fields:
|
Core fields:
|
||||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
- 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
|
- 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)
|
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
|
||||||
- variant (string, optional): provider-specific reasoning/profile variant passed as --variant
|
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
|
||||||
- promptTemplate (string, optional): run prompt template
|
- promptTemplate (string, optional): run prompt template
|
||||||
- command (string, optional): defaults to "opencode"
|
- command (string, optional): defaults to "opencode"
|
||||||
- extraArgs (string[], optional): additional CLI args
|
- extraArgs (string[], optional): additional CLI args
|
||||||
@@ -38,7 +32,9 @@ Operational fields:
|
|||||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||||
|
|
||||||
Notes:
|
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 ...
|
- 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.
|
- Sessions are resumed with --session when stored session cwd matches current cwd.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
renderTemplate,
|
renderTemplate,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||||
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||||
@@ -34,81 +34,11 @@ function firstNonEmptyLine(text: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
|
function parseModelProvider(model: string | null): string | null {
|
||||||
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
|
if (!model) return null;
|
||||||
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 {
|
|
||||||
const trimmed = model.trim();
|
const trimmed = model.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed.includes("/")) return null;
|
||||||
const slash = trimmed.indexOf("/");
|
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function claudeSkillsHome(): string {
|
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.",
|
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||||
);
|
);
|
||||||
const command = asString(config.command, "opencode");
|
const command = asString(config.command, "opencode");
|
||||||
const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL);
|
const model = asString(config.model, "").trim();
|
||||||
const variant = asString(config.variant, asString(config.effort, ""));
|
const variant = asString(config.variant, "").trim();
|
||||||
|
|
||||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||||
@@ -209,52 +139,39 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
if (wakeTaskId) {
|
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||||
}
|
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||||
if (wakeReason) {
|
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||||
}
|
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||||
if (wakeCommentId) {
|
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||||
}
|
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||||
if (approvalId) {
|
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||||
}
|
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||||
if (approvalStatus) {
|
|
||||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
}
|
if (typeof value === "string") env[key] = value;
|
||||||
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 (!hasExplicitApiKey && authToken) {
|
if (!hasExplicitApiKey && authToken) {
|
||||||
env.PAPERCLIP_API_KEY = authToken;
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
}
|
}
|
||||||
const billingType = resolveOpenCodeBillingType(env);
|
const runtimeEnv = Object.fromEntries(
|
||||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||||
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||||
|
),
|
||||||
|
);
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
|
||||||
|
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||||
|
model,
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
const extraArgs = (() => {
|
const extraArgs = (() => {
|
||||||
@@ -278,37 +195,41 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
}
|
}
|
||||||
|
|
||||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
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 = "";
|
let instructionsPrefix = "";
|
||||||
if (instructionsFilePath) {
|
if (resolvedInstructionsFilePath) {
|
||||||
try {
|
try {
|
||||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
||||||
instructionsPrefix =
|
instructionsPrefix =
|
||||||
`${instructionsContents}\n\n` +
|
`${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`;
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"stderr",
|
||||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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 = (() => {
|
const commandNotes = (() => {
|
||||||
if (!instructionsFilePath) return [] as string[];
|
if (!resolvedInstructionsFilePath) return [] as string[];
|
||||||
if (instructionsPrefix.length > 0) {
|
if (instructionsPrefix.length > 0) {
|
||||||
return [
|
return [
|
||||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
||||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
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 (model) args.push("--model", model);
|
||||||
if (variant) args.push("--variant", variant);
|
if (variant) args.push("--variant", variant);
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
args.push(prompt);
|
|
||||||
return args;
|
return args;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,10 +261,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
commandNotes,
|
commandNotes,
|
||||||
commandArgs: args.map((value, idx) => {
|
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||||
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
|
||||||
return value;
|
|
||||||
}),
|
|
||||||
env: redactEnvForLogs(env),
|
env: redactEnvForLogs(env),
|
||||||
prompt,
|
prompt,
|
||||||
context,
|
context,
|
||||||
@@ -353,29 +270,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env: runtimeEnv,
|
||||||
|
stdin: prompt,
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
graceSec,
|
graceSec,
|
||||||
onLog,
|
onLog,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
proc,
|
proc,
|
||||||
|
rawStderr: proc.stderr,
|
||||||
parsed: parseOpenCodeJsonl(proc.stdout),
|
parsed: parseOpenCodeJsonl(proc.stdout),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerFromModel = resolveProviderFromModel(model);
|
|
||||||
|
|
||||||
const toResult = (
|
const toResult = (
|
||||||
attempt: {
|
attempt: {
|
||||||
proc: {
|
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
||||||
exitCode: number | null;
|
rawStderr: string;
|
||||||
signal: string | null;
|
|
||||||
timedOut: boolean;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
};
|
|
||||||
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
||||||
},
|
},
|
||||||
clearSessionOnMissingSession = false,
|
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
|
const resolvedSessionParams = resolvedSessionId
|
||||||
? ({
|
? ({
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
@@ -400,50 +313,54 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
} as Record<string, unknown>)
|
} as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||||
const modelNotFound = parseProviderModelNotFoundDetails(attempt.proc.stdout, attempt.proc.stderr);
|
const rawExitCode = attempt.proc.exitCode;
|
||||||
const fallbackErrorMessage = modelNotFound
|
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
|
||||||
? formatModelNotFoundError(model, providerFromModel, modelNotFound)
|
const fallbackErrorMessage =
|
||||||
: parsedError ||
|
parsedError ||
|
||||||
stderrLine ||
|
stderrLine ||
|
||||||
`OpenCode exited with code ${attempt.proc.exitCode ?? -1}`;
|
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
|
||||||
|
const modelId = model || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: attempt.proc.exitCode,
|
exitCode: synthesizedExitCode,
|
||||||
signal: attempt.proc.signal,
|
signal: attempt.proc.signal,
|
||||||
timedOut: false,
|
timedOut: false,
|
||||||
errorMessage:
|
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||||
(attempt.proc.exitCode ?? 0) === 0
|
usage: {
|
||||||
? null
|
inputTokens: attempt.parsed.usage.inputTokens,
|
||||||
: fallbackErrorMessage,
|
outputTokens: attempt.parsed.usage.outputTokens,
|
||||||
usage: attempt.parsed.usage,
|
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
||||||
|
},
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
sessionParams: resolvedSessionParams,
|
sessionParams: resolvedSessionParams,
|
||||||
sessionDisplayId: resolvedSessionId,
|
sessionDisplayId: resolvedSessionId,
|
||||||
provider: providerFromModel,
|
provider: parseModelProvider(modelId),
|
||||||
model,
|
model: modelId,
|
||||||
billingType,
|
billingType: "unknown",
|
||||||
costUsd: attempt.parsed.costUsd,
|
costUsd: attempt.parsed.usage.costUsd,
|
||||||
resultJson: {
|
resultJson: {
|
||||||
stdout: attempt.proc.stdout,
|
stdout: attempt.proc.stdout,
|
||||||
stderr: attempt.proc.stderr,
|
stderr: attempt.proc.stderr,
|
||||||
},
|
},
|
||||||
summary: attempt.parsed.summary,
|
summary: attempt.parsed.summary,
|
||||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId);
|
const initial = await runAttempt(sessionId);
|
||||||
|
const initialFailed =
|
||||||
|
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
||||||
if (
|
if (
|
||||||
sessionId &&
|
sessionId &&
|
||||||
!initial.proc.timedOut &&
|
initialFailed &&
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
|
||||||
) {
|
) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"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);
|
const retry = await runAttempt(null);
|
||||||
return toResult(retry, true);
|
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";
|
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
function readNonEmptyString(value: unknown): string | null {
|
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;
|
if (typeof value === "string") return value;
|
||||||
const rec = parseObject(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;
|
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 {
|
try {
|
||||||
return JSON.stringify(rec);
|
return JSON.stringify(rec);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -15,12 +22,12 @@ function asErrorText(value: unknown): string {
|
|||||||
export function parseOpenCodeJsonl(stdout: string) {
|
export function parseOpenCodeJsonl(stdout: string) {
|
||||||
let sessionId: string | null = null;
|
let sessionId: string | null = null;
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
let errorMessage: string | null = null;
|
const errors: string[] = [];
|
||||||
let totalCostUsd = 0;
|
|
||||||
const usage = {
|
const usage = {
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
cachedInputTokens: 0,
|
cachedInputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
|
costUsd: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||||
@@ -30,8 +37,8 @@ export function parseOpenCodeJsonl(stdout: string) {
|
|||||||
const event = parseJson(line);
|
const event = parseJson(line);
|
||||||
if (!event) continue;
|
if (!event) continue;
|
||||||
|
|
||||||
const foundSession = asString(event.sessionID, "").trim();
|
const currentSessionId = asString(event.sessionID, "").trim();
|
||||||
if (foundSession) sessionId = foundSession;
|
if (currentSessionId) sessionId = currentSessionId;
|
||||||
|
|
||||||
const type = asString(event.type, "");
|
const type = asString(event.type, "");
|
||||||
|
|
||||||
@@ -48,15 +55,25 @@ export function parseOpenCodeJsonl(stdout: string) {
|
|||||||
const cache = parseObject(tokens.cache);
|
const cache = parseObject(tokens.cache);
|
||||||
usage.inputTokens += asNumber(tokens.input, 0);
|
usage.inputTokens += asNumber(tokens.input, 0);
|
||||||
usage.cachedInputTokens += asNumber(cache.read, 0);
|
usage.cachedInputTokens += asNumber(cache.read, 0);
|
||||||
usage.outputTokens += asNumber(tokens.output, 0);
|
usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
|
||||||
totalCostUsd += asNumber(part.cost, 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "error") {
|
if (type === "error") {
|
||||||
const part = parseObject(event.part);
|
const text = errorText(event.error ?? event.message).trim();
|
||||||
const msg = asErrorText(event.message ?? part.message ?? event.error ?? part.error).trim();
|
if (text) errors.push(text);
|
||||||
if (msg) errorMessage = msg;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,8 +81,7 @@ export function parseOpenCodeJsonl(stdout: string) {
|
|||||||
sessionId,
|
sessionId,
|
||||||
summary: messages.join("\n\n").trim(),
|
summary: messages.join("\n\n").trim(),
|
||||||
usage,
|
usage,
|
||||||
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
|
errorMessage: errors.length > 0 ? errors.join("\n") : null,
|
||||||
errorMessage,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +92,7 @@ export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): b
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.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,
|
haystack,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import {
|
|||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import path from "node:path";
|
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
|
||||||
import { parseOpenCodeJsonl } from "./parse.js";
|
import { parseOpenCodeJsonl } from "./parse.js";
|
||||||
|
|
||||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||||
@@ -22,19 +21,6 @@ function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentT
|
|||||||
return "pass";
|
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 {
|
function firstNonEmptyLine(text: string): string {
|
||||||
return (
|
return (
|
||||||
text
|
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 {
|
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const clean = raw.replace(/\s+/g, " ").trim();
|
const clean = raw.replace(/\s+/g, " ").trim();
|
||||||
const max = 240;
|
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 =
|
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;
|
/(?: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;
|
||||||
const OPENCODE_MODEL_NOT_FOUND_RE = /ProviderModelNotFoundError|provider\s+model\s+not\s+found/i;
|
|
||||||
|
|
||||||
export async function testEnvironment(
|
export async function testEnvironment(
|
||||||
ctx: AdapterEnvironmentTestContext,
|
ctx: AdapterEnvironmentTestContext,
|
||||||
@@ -70,7 +59,7 @@ export async function testEnvironment(
|
|||||||
const cwd = asString(config.cwd, process.cwd());
|
const cwd = asString(config.cwd, process.cwd());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_cwd_valid",
|
code: "opencode_cwd_valid",
|
||||||
level: "info",
|
level: "info",
|
||||||
@@ -90,100 +79,138 @@ export async function testEnvironment(
|
|||||||
for (const [key, value] of Object.entries(envConfig)) {
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
if (typeof value === "string") env[key] = value;
|
if (typeof value === "string") env[key] = value;
|
||||||
}
|
}
|
||||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||||
try {
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
||||||
|
if (cwdInvalid) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_command_resolvable",
|
code: "opencode_command_skipped",
|
||||||
level: "info",
|
level: "warn",
|
||||||
message: `Command is executable: ${command}`,
|
message: "Skipped command check because working directory validation failed.",
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({
|
|
||||||
code: "opencode_command_unresolvable",
|
|
||||||
level: "error",
|
|
||||||
message: err instanceof Error ? err.message : "Command is not executable",
|
|
||||||
detail: command,
|
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 {
|
} else {
|
||||||
checks.push({
|
try {
|
||||||
code: "opencode_openai_api_key_missing",
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
level: "warn",
|
checks.push({
|
||||||
message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.",
|
code: "opencode_command_resolvable",
|
||||||
hint: configDefinesOpenAiKey
|
level: "info",
|
||||||
? "adapterConfig.env defines OPENAI_API_KEY but it is empty. Set a non-empty value or remove the override."
|
message: `Command is executable: ${command}`,
|
||||||
: "Set OPENAI_API_KEY in adapter env/shell, or authenticate with `opencode auth login`.",
|
});
|
||||||
});
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "opencode_command_unresolvable",
|
||||||
|
level: "error",
|
||||||
|
message: err instanceof Error ? err.message : "Command is not executable",
|
||||||
|
detail: command,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canRunProbe =
|
const canRunProbe =
|
||||||
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
||||||
|
|
||||||
|
let modelValidationPassed = false;
|
||||||
if (canRunProbe) {
|
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({
|
checks.push({
|
||||||
code: "opencode_hello_probe_skipped_custom_command",
|
code: "opencode_models_discovery_failed",
|
||||||
level: "info",
|
level: "error",
|
||||||
message: "Skipped hello probe because command is not `opencode`.",
|
message: err instanceof Error ? err.message : "OpenCode model discovery failed.",
|
||||||
detail: command,
|
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||||
hint: "Use the `opencode` CLI command to run the automatic installation and auth probe.",
|
|
||||||
});
|
});
|
||||||
} 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"];
|
const configuredModel = asString(config.model, "").trim();
|
||||||
if (model) args.push("--model", model);
|
if (!configuredModel) {
|
||||||
if (variant) args.push("--variant", variant);
|
checks.push({
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
code: "opencode_model_required",
|
||||||
args.push("Respond with hello.");
|
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(
|
const probe = await runChildProcess(
|
||||||
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
{
|
{
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env: runtimeEnv,
|
||||||
timeoutSec: 45,
|
timeoutSec: 60,
|
||||||
graceSec: 5,
|
graceSec: 5,
|
||||||
|
stdin: "Respond with hello.",
|
||||||
onLog: async () => {},
|
onLog: async () => {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsed = parseOpenCodeJsonl(probe.stdout);
|
const parsed = parseOpenCodeJsonl(probe.stdout);
|
||||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
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) {
|
if (probe.timedOut) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_hello_probe_timed_out",
|
code: "opencode_hello_probe_timed_out",
|
||||||
level: "warn",
|
level: "warn",
|
||||||
message: "OpenCode hello probe timed out.",
|
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 summary = parsed.summary.trim();
|
||||||
const hasHello = /\bhello\b/i.test(summary);
|
const hasHello = /\bhello\b/i.test(summary);
|
||||||
checks.push({
|
checks.push({
|
||||||
@@ -196,24 +223,16 @@ export async function testEnvironment(
|
|||||||
...(hasHello
|
...(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)) {
|
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_hello_probe_auth_required",
|
code: "opencode_hello_probe_auth_required",
|
||||||
level: "warn",
|
level: "warn",
|
||||||
message: "OpenCode CLI is installed, but authentication is not ready.",
|
message: "OpenCode is installed, but provider authentication is not ready.",
|
||||||
...(detail ? { detail } : {}),
|
...(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 {
|
} else {
|
||||||
checks.push({
|
checks.push({
|
||||||
@@ -221,9 +240,17 @@ export async function testEnvironment(
|
|||||||
level: "error",
|
level: "error",
|
||||||
message: "OpenCode hello probe failed.",
|
message: "OpenCode hello probe failed.",
|
||||||
...(detail ? { detail } : {}),
|
...(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 type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
|
||||||
|
|
||||||
function parseCommaArgs(value: string): string[] {
|
function parseCommaArgs(value: string): string[] {
|
||||||
return value
|
return value
|
||||||
@@ -56,10 +55,12 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
|
|||||||
if (v.cwd) ac.cwd = v.cwd;
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
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;
|
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.timeoutSec = 0;
|
||||||
ac.graceSec = 15;
|
ac.graceSec = 20;
|
||||||
const env = parseEnvBindings(v.envBindings);
|
const env = parseEnvBindings(v.envBindings);
|
||||||
const legacy = parseEnvVars(v.envVars);
|
const legacy = parseEnvVars(v.envVars);
|
||||||
for (const [key, value] of Object.entries(legacy)) {
|
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;
|
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 (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 {
|
try {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(rec);
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isJsonLike(text: string): boolean {
|
function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||||
const trimmed = text.trim();
|
const part = asRecord(parsed.part);
|
||||||
if (!trimmed) return false;
|
if (!part) return [{ kind: "system", ts, text: "tool event" }];
|
||||||
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false;
|
|
||||||
try {
|
const toolName = asString(part.tool, "tool");
|
||||||
JSON.parse(trimmed);
|
const state = asRecord(part.state);
|
||||||
return true;
|
const input = state?.input ?? {};
|
||||||
} catch {
|
const callEntry: TranscriptEntry = {
|
||||||
return false;
|
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[] {
|
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);
|
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") {
|
if (type === "step_start") {
|
||||||
const sessionId = asString(parsed.sessionID);
|
const sessionId = asString(parsed.sessionID);
|
||||||
return [
|
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") {
|
if (type === "step_finish") {
|
||||||
const part = asRecord(parsed.part);
|
const part = asRecord(parsed.part);
|
||||||
const tokens = asRecord(part?.tokens);
|
const tokens = asRecord(part?.tokens);
|
||||||
const cache = asRecord(tokens?.cache);
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
kind: "result",
|
kind: "result",
|
||||||
ts,
|
ts,
|
||||||
text: reason,
|
text: reason,
|
||||||
inputTokens: asNumber(tokens?.input),
|
inputTokens: asNumber(tokens?.input, 0),
|
||||||
outputTokens: asNumber(tokens?.output),
|
outputTokens: output,
|
||||||
cachedTokens: asNumber(cache?.read),
|
cachedTokens: asNumber(cache?.read, 0),
|
||||||
costUsd: asNumber(part?.cost),
|
costUsd: asNumber(part?.cost, 0),
|
||||||
subtype: reason || "step_finish",
|
subtype: reason,
|
||||||
isError: reason === "error" || reason === "failed",
|
isError: false,
|
||||||
errors: [],
|
errors: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "error") {
|
if (type === "error") {
|
||||||
const message =
|
const text = errorText(parsed.error ?? parsed.message);
|
||||||
asString(parsed.message) ||
|
return [{ kind: "stderr", ts, text: text || line }];
|
||||||
asString(asRecord(parsed.part)?.message) ||
|
|
||||||
stringifyUnknown(parsed.error ?? asRecord(parsed.part)?.error) ||
|
|
||||||
line;
|
|
||||||
return [{ kind: "stderr", ts, text: message }];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ kind: "stdout", ts, 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;
|
] as const;
|
||||||
export type AgentStatus = (typeof AGENT_STATUSES)[number];
|
export type AgentStatus = (typeof AGENT_STATUSES)[number];
|
||||||
|
|
||||||
export const AGENT_ADAPTER_TYPES = ["process", "http", "claude_local", "codex_local", "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 type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||||
|
|
||||||
export const AGENT_ROLES = [
|
export const AGENT_ROLES = [
|
||||||
|
|||||||
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
@@ -80,7 +80,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.6.0
|
specifier: ^24.6.0
|
||||||
version: 24.11.0
|
version: 24.12.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -96,7 +96,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.6.0
|
specifier: ^24.6.0
|
||||||
version: 24.11.0
|
version: 24.12.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -112,7 +112,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.6.0
|
specifier: ^24.6.0
|
||||||
version: 24.11.0
|
version: 24.12.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -141,7 +141,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.6.0
|
specifier: ^24.6.0
|
||||||
version: 24.11.0
|
version: 24.12.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -155,6 +155,9 @@ importers:
|
|||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.12.0
|
||||||
|
version: 22.19.11
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -173,7 +176,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.6.0
|
specifier: ^24.6.0
|
||||||
version: 24.11.0
|
version: 24.12.0
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.31.9
|
specifier: ^0.31.9
|
||||||
version: 0.31.9
|
version: 0.31.9
|
||||||
@@ -185,7 +188,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@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:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -228,7 +231,7 @@ importers:
|
|||||||
version: link:../packages/shared
|
version: link:../packages/shared
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: 1.4.18
|
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:
|
detect-port:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
@@ -277,7 +280,7 @@ importers:
|
|||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.6.0
|
specifier: ^24.6.0
|
||||||
version: 24.11.0
|
version: 24.12.0
|
||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
@@ -295,10 +298,10 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.4.1(@types/node@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:
|
vitest:
|
||||||
specifier: ^3.0.5
|
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:
|
ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2837,8 +2840,8 @@ packages:
|
|||||||
'@types/node@22.19.11':
|
'@types/node@22.19.11':
|
||||||
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
|
||||||
|
|
||||||
'@types/node@24.11.0':
|
'@types/node@24.12.0':
|
||||||
resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==}
|
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
|
||||||
|
|
||||||
'@types/node@25.2.3':
|
'@types/node@25.2.3':
|
||||||
resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
|
resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
|
||||||
@@ -8159,7 +8162,7 @@ snapshots:
|
|||||||
'@types/body-parser@1.19.6':
|
'@types/body-parser@1.19.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/connect': 3.4.38
|
'@types/connect': 3.4.38
|
||||||
'@types/node': 25.2.3
|
'@types/node': 24.12.0
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8168,7 +8171,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.2.3
|
'@types/node': 24.12.0
|
||||||
|
|
||||||
'@types/cookiejar@2.1.5': {}
|
'@types/cookiejar@2.1.5': {}
|
||||||
|
|
||||||
@@ -8186,7 +8189,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/express-serve-static-core@5.1.1':
|
'@types/express-serve-static-core@5.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.2.3
|
'@types/node': 24.12.0
|
||||||
'@types/qs': 6.14.0
|
'@types/qs': 6.14.0
|
||||||
'@types/range-parser': 1.2.7
|
'@types/range-parser': 1.2.7
|
||||||
'@types/send': 1.2.1
|
'@types/send': 1.2.1
|
||||||
@@ -8221,7 +8224,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/node@24.11.0':
|
'@types/node@24.12.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
|
|
||||||
@@ -8243,18 +8246,18 @@ snapshots:
|
|||||||
|
|
||||||
'@types/send@1.2.1':
|
'@types/send@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.2.3
|
'@types/node': 24.12.0
|
||||||
|
|
||||||
'@types/serve-static@2.2.0':
|
'@types/serve-static@2.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/http-errors': 2.0.5
|
'@types/http-errors': 2.0.5
|
||||||
'@types/node': 25.2.3
|
'@types/node': 24.12.0
|
||||||
|
|
||||||
'@types/superagent@8.1.9':
|
'@types/superagent@8.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/cookiejar': 2.1.5
|
'@types/cookiejar': 2.1.5
|
||||||
'@types/methods': 1.1.4
|
'@types/methods': 1.1.4
|
||||||
'@types/node': 25.2.3
|
'@types/node': 24.12.0
|
||||||
form-data: 4.0.5
|
form-data: 4.0.5
|
||||||
|
|
||||||
'@types/supertest@6.0.3':
|
'@types/supertest@6.0.3':
|
||||||
@@ -8268,7 +8271,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 25.2.3
|
'@types/node': 24.12.0
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@ungap/structured-clone@1.3.0': {}
|
||||||
|
|
||||||
@@ -8292,13 +8295,13 @@ snapshots:
|
|||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@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:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.4
|
'@vitest/spy': 3.2.4
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
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))':
|
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8383,7 +8386,7 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.9.19: {}
|
baseline-browser-mapping@2.9.19: {}
|
||||||
|
|
||||||
better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@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:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
|
||||||
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
'@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
|
||||||
@@ -8403,7 +8406,7 @@ snapshots:
|
|||||||
pg: 8.18.0
|
pg: 8.18.0
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@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):
|
better-call@1.1.8(zod@4.3.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -10644,13 +10647,13 @@ snapshots:
|
|||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
vfile-message: 4.0.3
|
vfile-message: 4.0.3
|
||||||
|
|
||||||
vite-node@3.2.4(@types/node@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:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 2.0.3
|
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:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- jiti
|
- jiti
|
||||||
@@ -10686,7 +10689,7 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- 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:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
@@ -10695,7 +10698,7 @@ snapshots:
|
|||||||
rollup: 4.57.1
|
rollup: 4.57.1
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.11.0
|
'@types/node': 24.12.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
@@ -10716,7 +10719,7 @@ snapshots:
|
|||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
tsx: 4.21.0
|
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:
|
dependencies:
|
||||||
esbuild: 0.27.3
|
esbuild: 0.27.3
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
@@ -10725,7 +10728,7 @@ snapshots:
|
|||||||
rollup: 4.57.1
|
rollup: 4.57.1
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.11.0
|
'@types/node': 24.12.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
@@ -10746,11 +10749,11 @@ snapshots:
|
|||||||
lightningcss: 1.30.2
|
lightningcss: 1.30.2
|
||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
|
|
||||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@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:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.4
|
'@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/pretty-format': 3.2.4
|
||||||
'@vitest/runner': 3.2.4
|
'@vitest/runner': 3.2.4
|
||||||
'@vitest/snapshot': 3.2.4
|
'@vitest/snapshot': 3.2.4
|
||||||
@@ -10768,12 +10771,12 @@ snapshots:
|
|||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 2.0.0
|
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: 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.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)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/debug': 4.1.12
|
'@types/debug': 4.1.12
|
||||||
'@types/node': 24.11.0
|
'@types/node': 24.12.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
- less
|
- less
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const workspacePaths = [
|
|||||||
"packages/adapter-utils",
|
"packages/adapter-utils",
|
||||||
"packages/adapters/claude-local",
|
"packages/adapters/claude-local",
|
||||||
"packages/adapters/codex-local",
|
"packages/adapters/codex-local",
|
||||||
|
"packages/adapters/opencode-local",
|
||||||
"packages/adapters/openclaw",
|
"packages/adapters/openclaw",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ const { resolve } = require('path');
|
|||||||
const root = '$REPO_ROOT';
|
const root = '$REPO_ROOT';
|
||||||
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8');
|
||||||
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db',
|
||||||
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/openclaw',
|
'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw',
|
||||||
'server', 'cli'];
|
'server', 'cli'];
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const d of dirs) {
|
for (const d of dirs) {
|
||||||
@@ -245,6 +245,7 @@ pnpm --filter @paperclipai/adapter-utils build
|
|||||||
pnpm --filter @paperclipai/db build
|
pnpm --filter @paperclipai/db build
|
||||||
pnpm --filter @paperclipai/adapter-claude-local build
|
pnpm --filter @paperclipai/adapter-claude-local build
|
||||||
pnpm --filter @paperclipai/adapter-codex-local build
|
pnpm --filter @paperclipai/adapter-codex-local build
|
||||||
|
pnpm --filter @paperclipai/adapter-opencode-local build
|
||||||
pnpm --filter @paperclipai/adapter-openclaw build
|
pnpm --filter @paperclipai/adapter-openclaw build
|
||||||
pnpm --filter @paperclipai/server build
|
pnpm --filter @paperclipai/server build
|
||||||
|
|
||||||
@@ -280,7 +281,7 @@ if [ "$dry_run" = true ]; then
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Preview what would be published:"
|
echo " Preview what would be published:"
|
||||||
for dir in packages/shared packages/adapter-utils packages/db \
|
for dir in packages/shared packages/adapter-utils packages/db \
|
||||||
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/openclaw \
|
packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw \
|
||||||
server cli; do
|
server cli; do
|
||||||
echo " --- $dir ---"
|
echo " --- $dir ---"
|
||||||
cd "$REPO_ROOT/$dir"
|
cd "$REPO_ROOT/$dir"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local";
|
||||||
import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-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 { listAdapterModels } from "../adapters/index.js";
|
||||||
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js";
|
||||||
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js";
|
||||||
@@ -8,9 +9,11 @@ import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from ".
|
|||||||
describe("adapter model listing", () => {
|
describe("adapter model listing", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete process.env.OPENAI_API_KEY;
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
|
||||||
resetCodexModelsCacheForTests();
|
resetCodexModelsCacheForTests();
|
||||||
resetCursorModelsCacheForTests();
|
resetCursorModelsCacheForTests();
|
||||||
setCursorModelsRunnerForTests(null);
|
setCursorModelsRunnerForTests(null);
|
||||||
|
resetOpenCodeModelsCacheForTests();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ describe("adapter model listing", () => {
|
|||||||
expect(models).toEqual(codexFallbackModels);
|
expect(models).toEqual(codexFallbackModels);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
|
it("returns cursor fallback models when CLI discovery is unavailable", async () => {
|
||||||
setCursorModelsRunnerForTests(() => ({
|
setCursorModelsRunnerForTests(() => ({
|
||||||
status: null,
|
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 === "gpt-5.3-codex-high")).toBe(true);
|
||||||
expect(first.some((model) => model.id === "composer-1")).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";
|
} from "@paperclipai/adapter-cursor-local/server";
|
||||||
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
import { agentConfigurationDoc as cursorAgentConfigurationDoc, models as cursorModels } from "@paperclipai/adapter-cursor-local";
|
||||||
import {
|
import {
|
||||||
execute as opencodeExecute,
|
execute as openCodeExecute,
|
||||||
testEnvironment as opencodeTestEnvironment,
|
testEnvironment as openCodeTestEnvironment,
|
||||||
sessionCodec as opencodeSessionCodec,
|
sessionCodec as openCodeSessionCodec,
|
||||||
|
listOpenCodeModels,
|
||||||
} from "@paperclipai/adapter-opencode-local/server";
|
} 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 {
|
import {
|
||||||
execute as openclawExecute,
|
execute as openclawExecute,
|
||||||
testEnvironment as openclawTestEnvironment,
|
testEnvironment as openclawTestEnvironment,
|
||||||
@@ -58,16 +61,6 @@ const codexLocalAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: codexAgentConfigurationDoc,
|
agentConfigurationDoc: codexAgentConfigurationDoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
const opencodeLocalAdapter: ServerAdapterModule = {
|
|
||||||
type: "opencode_local",
|
|
||||||
execute: opencodeExecute,
|
|
||||||
testEnvironment: opencodeTestEnvironment,
|
|
||||||
sessionCodec: opencodeSessionCodec,
|
|
||||||
models: opencodeModels,
|
|
||||||
supportsLocalAgentJwt: true,
|
|
||||||
agentConfigurationDoc: opencodeAgentConfigurationDoc,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cursorLocalAdapter: ServerAdapterModule = {
|
const cursorLocalAdapter: ServerAdapterModule = {
|
||||||
type: "cursor",
|
type: "cursor",
|
||||||
execute: cursorExecute,
|
execute: cursorExecute,
|
||||||
@@ -89,8 +82,19 @@ const openclawAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
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>(
|
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 {
|
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-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) {
|
export function agentRoutes(db: Db) {
|
||||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||||
@@ -198,15 +198,34 @@ export function agentRoutes(db: Db) {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
|
// OpenCode requires explicit model selection — no default
|
||||||
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
|
||||||
}
|
|
||||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertAdapterConfigConstraints(
|
||||||
|
companyId: string,
|
||||||
|
adapterType: string | null | undefined,
|
||||||
|
adapterConfig: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (adapterType !== "opencode_local") return;
|
||||||
|
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
|
||||||
|
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
|
||||||
|
try {
|
||||||
|
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||||
|
model: runtimeConfig.model,
|
||||||
|
command: runtimeConfig.command,
|
||||||
|
cwd: runtimeConfig.cwd,
|
||||||
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const reason = err instanceof Error ? err.message : String(err);
|
||||||
|
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record<string, unknown>) {
|
function resolveInstructionsFilePath(candidatePath: string, adapterConfig: Record<string, unknown>) {
|
||||||
const trimmed = candidatePath.trim();
|
const trimmed = candidatePath.trim();
|
||||||
if (path.isAbsolute(trimmed)) return trimmed;
|
if (path.isAbsolute(trimmed)) return trimmed;
|
||||||
@@ -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 type = req.params.type as string;
|
||||||
const models = await listAdapterModels(type);
|
const models = await listAdapterModels(type);
|
||||||
res.json(models);
|
res.json(models);
|
||||||
@@ -592,6 +613,11 @@ export function agentRoutes(db: Db) {
|
|||||||
requestedAdapterConfig,
|
requestedAdapterConfig,
|
||||||
{ strictMode: strictSecretsMode },
|
{ strictMode: strictSecretsMode },
|
||||||
);
|
);
|
||||||
|
await assertAdapterConfigConstraints(
|
||||||
|
companyId,
|
||||||
|
hireInput.adapterType,
|
||||||
|
normalizedAdapterConfig,
|
||||||
|
);
|
||||||
const normalizedHireInput = {
|
const normalizedHireInput = {
|
||||||
...hireInput,
|
...hireInput,
|
||||||
adapterConfig: normalizedAdapterConfig,
|
adapterConfig: normalizedAdapterConfig,
|
||||||
@@ -727,6 +753,11 @@ export function agentRoutes(db: Db) {
|
|||||||
requestedAdapterConfig,
|
requestedAdapterConfig,
|
||||||
{ strictMode: strictSecretsMode },
|
{ strictMode: strictSecretsMode },
|
||||||
);
|
);
|
||||||
|
await assertAdapterConfigConstraints(
|
||||||
|
companyId,
|
||||||
|
req.body.adapterType,
|
||||||
|
normalizedAdapterConfig,
|
||||||
|
);
|
||||||
|
|
||||||
const agent = await svc.create(companyId, {
|
const agent = await svc.create(companyId, {
|
||||||
...req.body,
|
...req.body,
|
||||||
@@ -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 actor = getActorInfo(req);
|
||||||
const agent = await svc.update(id, patchData, {
|
const agent = await svc.update(id, patchData, {
|
||||||
recordRevision: {
|
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 =
|
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";
|
"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 =
|
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({
|
export function OpenCodeLocalConfigFields({
|
||||||
isCreate,
|
isCreate,
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ export const agentsApi = {
|
|||||||
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
||||||
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
||||||
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
||||||
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
|
adapterModels: (companyId: string, type: string) =>
|
||||||
|
api.get<AdapterModel[]>(
|
||||||
|
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
|
||||||
|
),
|
||||||
testEnvironment: (
|
testEnvironment: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
type: string,
|
type: string,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -25,6 +24,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
|
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { extractModelName, extractProviderId } from "../lib/model-utils";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +42,7 @@ import { getUIAdapter } from "../adapters";
|
|||||||
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
|
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
|
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||||
|
|
||||||
/* ---- Create mode values ---- */
|
/* ---- Create mode values ---- */
|
||||||
|
|
||||||
@@ -132,7 +133,7 @@ const codexThinkingEffortOptions = [
|
|||||||
{ id: "high", label: "High" },
|
{ id: "high", label: "High" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const opencodeVariantOptions = [
|
const openCodeThinkingEffortOptions = [
|
||||||
{ id: "", label: "Auto" },
|
{ id: "", label: "Auto" },
|
||||||
{ id: "minimal", label: "Minimal" },
|
{ id: "minimal", label: "Minimal" },
|
||||||
{ id: "low", label: "Low" },
|
{ id: "low", label: "Low" },
|
||||||
@@ -279,9 +280,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||||
|
|
||||||
// Fetch adapter models for the effective adapter type
|
// Fetch adapter models for the effective adapter type
|
||||||
const { data: fetchedModels } = useQuery({
|
const {
|
||||||
queryKey: ["adapter-models", adapterType],
|
data: fetchedModels,
|
||||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
error: fetchedModelsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: selectedCompanyId
|
||||||
|
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType)
|
||||||
|
: ["agents", "none", "adapter-models", adapterType],
|
||||||
|
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType),
|
||||||
|
enabled: Boolean(selectedCompanyId),
|
||||||
});
|
});
|
||||||
const models = fetchedModels ?? externalModels ?? [];
|
const models = fetchedModels ?? externalModels ?? [];
|
||||||
|
|
||||||
@@ -339,17 +346,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
? "modelReasoningEffort"
|
? "modelReasoningEffort"
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? "mode"
|
? "mode"
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? "variant"
|
? "variant"
|
||||||
: "effort";
|
: "effort";
|
||||||
const thinkingEffortOptions =
|
const thinkingEffortOptions =
|
||||||
adapterType === "codex_local"
|
adapterType === "codex_local"
|
||||||
? codexThinkingEffortOptions
|
? codexThinkingEffortOptions
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? cursorModeOptions
|
? cursorModeOptions
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? opencodeVariantOptions
|
? openCodeThinkingEffortOptions
|
||||||
: claudeThinkingEffortOptions;
|
: claudeThinkingEffortOptions;
|
||||||
const currentThinkingEffort = isCreate
|
const currentThinkingEffort = isCreate
|
||||||
? val!.thinkingEffort
|
? val!.thinkingEffort
|
||||||
: adapterType === "codex_local"
|
: adapterType === "codex_local"
|
||||||
@@ -360,8 +367,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
)
|
)
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
||||||
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
: eff("adapterConfig", "effort", String(config.effort ?? ""));
|
||||||
const codexSearchEnabled = adapterType === "codex_local"
|
const codexSearchEnabled = adapterType === "codex_local"
|
||||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||||
@@ -483,7 +490,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
} else if (t === "cursor") {
|
} else if (t === "cursor") {
|
||||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||||
} else if (t === "opencode_local") {
|
} else if (t === "opencode_local") {
|
||||||
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
|
nextValues.model = "";
|
||||||
}
|
}
|
||||||
set!(nextValues);
|
set!(nextValues);
|
||||||
} else {
|
} else {
|
||||||
@@ -498,9 +505,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
? DEFAULT_CODEX_LOCAL_MODEL
|
? DEFAULT_CODEX_LOCAL_MODEL
|
||||||
: t === "cursor"
|
: t === "cursor"
|
||||||
? DEFAULT_CURSOR_LOCAL_MODEL
|
? DEFAULT_CURSOR_LOCAL_MODEL
|
||||||
: t === "opencode_local"
|
: "",
|
||||||
? DEFAULT_OPENCODE_LOCAL_MODEL
|
|
||||||
: "",
|
|
||||||
effort: "",
|
effort: "",
|
||||||
modelReasoningEffort: "",
|
modelReasoningEffort: "",
|
||||||
variant: "",
|
variant: "",
|
||||||
@@ -605,9 +610,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
? "codex"
|
? "codex"
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? "agent"
|
? "agent"
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? "opencode"
|
? "opencode"
|
||||||
: "claude"
|
: "claude"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -622,7 +627,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
}
|
}
|
||||||
open={modelOpen}
|
open={modelOpen}
|
||||||
onOpenChange={setModelOpen}
|
onOpenChange={setModelOpen}
|
||||||
|
allowDefault={adapterType !== "opencode_local"}
|
||||||
|
required={adapterType === "opencode_local"}
|
||||||
|
groupByProvider={adapterType === "opencode_local"}
|
||||||
/>
|
/>
|
||||||
|
{fetchedModelsError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{fetchedModelsError instanceof Error
|
||||||
|
? fetchedModelsError.message
|
||||||
|
: "Failed to load adapter models."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<ThinkingEffortDropdown
|
<ThinkingEffortDropdown
|
||||||
value={currentThinkingEffort}
|
value={currentThinkingEffort}
|
||||||
@@ -898,7 +913,10 @@ function AdapterTypeDropdown({
|
|||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||||
<span>{adapterLabels[value] ?? value}</span>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||||
|
<span>{adapterLabels[value] ?? value}</span>
|
||||||
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -918,7 +936,10 @@ function AdapterTypeDropdown({
|
|||||||
if (!item.comingSoon) onChange(item.value);
|
if (!item.comingSoon) onChange(item.value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{item.value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</span>
|
||||||
{item.comingSoon && (
|
{item.comingSoon && (
|
||||||
<span className="text-[10px] text-muted-foreground">Coming soon</span>
|
<span className="text-[10px] text-muted-foreground">Coming soon</span>
|
||||||
)}
|
)}
|
||||||
@@ -1184,20 +1205,56 @@ function ModelDropdown({
|
|||||||
onChange,
|
onChange,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
allowDefault,
|
||||||
|
required,
|
||||||
|
groupByProvider,
|
||||||
}: {
|
}: {
|
||||||
models: AdapterModel[];
|
models: AdapterModel[];
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
allowDefault: boolean;
|
||||||
|
required: boolean;
|
||||||
|
groupByProvider: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [modelSearch, setModelSearch] = useState("");
|
const [modelSearch, setModelSearch] = useState("");
|
||||||
const selected = models.find((m) => m.id === value);
|
const selected = models.find((m) => m.id === value);
|
||||||
const filteredModels = models.filter((m) => {
|
const filteredModels = useMemo(() => {
|
||||||
if (!modelSearch.trim()) return true;
|
return models.filter((m) => {
|
||||||
const q = modelSearch.toLowerCase();
|
if (!modelSearch.trim()) return true;
|
||||||
return m.id.toLowerCase().includes(q) || m.label.toLowerCase().includes(q);
|
const q = modelSearch.toLowerCase();
|
||||||
});
|
const provider = extractProviderId(m.id) ?? "";
|
||||||
|
return (
|
||||||
|
m.id.toLowerCase().includes(q) ||
|
||||||
|
m.label.toLowerCase().includes(q) ||
|
||||||
|
provider.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [models, modelSearch]);
|
||||||
|
const groupedModels = useMemo(() => {
|
||||||
|
if (!groupByProvider) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
provider: "models",
|
||||||
|
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const map = new Map<string, AdapterModel[]>();
|
||||||
|
for (const model of filteredModels) {
|
||||||
|
const provider = extractProviderId(model.id) ?? "other";
|
||||||
|
const group = map.get(provider) ?? [];
|
||||||
|
group.push(model);
|
||||||
|
map.set(provider, group);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([provider, entries]) => ({
|
||||||
|
provider,
|
||||||
|
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
}));
|
||||||
|
}, [filteredModels, groupByProvider]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Field label="Model" hint={help.model}>
|
<Field label="Model" hint={help.model}>
|
||||||
@@ -1211,7 +1268,9 @@ function ModelDropdown({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||||
<span className={cn(!value && "text-muted-foreground")}>
|
<span className={cn(!value && "text-muted-foreground")}>
|
||||||
{selected ? selected.label : value || "Default"}
|
{selected
|
||||||
|
? selected.label
|
||||||
|
: value || (allowDefault ? "Default" : required ? "Select model (required)" : "Select model")}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1225,33 +1284,45 @@ function ModelDropdown({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="max-h-[240px] overflow-y-auto">
|
<div className="max-h-[240px] overflow-y-auto">
|
||||||
<button
|
{allowDefault && (
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
|
||||||
!value && "bg-accent",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
onChange("");
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Default
|
|
||||||
</button>
|
|
||||||
{filteredModels.map((m) => (
|
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
m.id === value && "bg-accent",
|
!value && "bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(m.id);
|
onChange("");
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{m.label}</span>
|
Default
|
||||||
<span className="text-xs text-muted-foreground font-mono">{m.id}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{groupedModels.map((group) => (
|
||||||
|
<div key={group.provider} className="mb-1 last:mb-0">
|
||||||
|
{groupByProvider && (
|
||||||
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.provider} ({group.entries.length})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{group.entries.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
|
m.id === value && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(m.id);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="block w-full text-left truncate" title={m.id}>
|
||||||
|
{groupByProvider ? extractModelName(m.id) : m.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredModels.length === 0 && (
|
{filteredModels.length === 0 && (
|
||||||
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
|
||||||
|
|||||||
@@ -55,14 +55,23 @@ export function NewAgentDialog() {
|
|||||||
enabled: !!selectedCompanyId && newAgentOpen,
|
enabled: !!selectedCompanyId && newAgentOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const {
|
||||||
queryKey: ["adapter-models", configValues.adapterType],
|
data: adapterModels,
|
||||||
queryFn: () => agentsApi.adapterModels(configValues.adapterType),
|
error: adapterModelsError,
|
||||||
enabled: newAgentOpen,
|
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 isFirstAgent = !agents || agents.length === 0;
|
||||||
const effectiveRole = isFirstAgent ? "ceo" : role;
|
const effectiveRole = isFirstAgent ? "ceo" : role;
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Auto-fill for CEO
|
// Auto-fill for CEO
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,6 +91,9 @@ export function NewAgentDialog() {
|
|||||||
closeNewAgent();
|
closeNewAgent();
|
||||||
navigate(agentUrl(result.agent));
|
navigate(agentUrl(result.agent));
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFormError(error instanceof Error ? error.message : "Failed to create agent");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -91,6 +103,7 @@ export function NewAgentDialog() {
|
|||||||
setReportsTo("");
|
setReportsTo("");
|
||||||
setConfigValues(defaultCreateValues);
|
setConfigValues(defaultCreateValues);
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
|
setFormError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAdapterConfig() {
|
function buildAdapterConfig() {
|
||||||
@@ -100,6 +113,35 @@ export function NewAgentDialog() {
|
|||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!selectedCompanyId || !name.trim()) return;
|
if (!selectedCompanyId || !name.trim()) return;
|
||||||
|
setFormError(null);
|
||||||
|
if (configValues.adapterType === "opencode_local") {
|
||||||
|
const selectedModel = configValues.model.trim();
|
||||||
|
if (!selectedModel) {
|
||||||
|
setFormError("OpenCode requires an explicit model in provider/model format.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (adapterModelsError) {
|
||||||
|
setFormError(
|
||||||
|
adapterModelsError instanceof Error
|
||||||
|
? adapterModelsError.message
|
||||||
|
: "Failed to load OpenCode models.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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({
|
createAgent.mutate({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
role: effectiveRole,
|
role: effectiveRole,
|
||||||
@@ -281,6 +323,11 @@ export function NewAgentDialog() {
|
|||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{isFirstAgent ? "This will be the CEO" : ""}
|
{isFirstAgent ? "This will be the CEO" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
{formError && (
|
||||||
|
<div className="px-4 pb-2 text-xs text-destructive">{formError}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end px-4 pb-3">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!name.trim() || createAgent.isPending}
|
disabled={!name.trim() || createAgent.isPending}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||||
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
@@ -115,6 +116,8 @@ function buildAssigneeAdapterOverrides(input: {
|
|||||||
adapterConfig.variant = input.thinkingEffortOverride;
|
adapterConfig.variant = input.thinkingEffortOverride;
|
||||||
} else if (adapterType === "claude_local") {
|
} else if (adapterType === "claude_local") {
|
||||||
adapterConfig.effort = input.thinkingEffortOverride;
|
adapterConfig.effort = input.thinkingEffortOverride;
|
||||||
|
} else if (adapterType === "opencode_local") {
|
||||||
|
adapterConfig.variant = input.thinkingEffortOverride;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (adapterType === "claude_local" && input.chrome) {
|
if (adapterType === "claude_local" && input.chrome) {
|
||||||
@@ -248,9 +251,12 @@ export function NewIssueDialog() {
|
|||||||
}, [agents, orderedProjects]);
|
}, [agents, orderedProjects]);
|
||||||
|
|
||||||
const { data: assigneeAdapterModels } = useQuery({
|
const { data: assigneeAdapterModels } = useQuery({
|
||||||
queryKey: ["adapter-models", assigneeAdapterType],
|
queryKey:
|
||||||
queryFn: () => agentsApi.adapterModels(assigneeAdapterType!),
|
effectiveCompanyId && assigneeAdapterType
|
||||||
enabled: !!effectiveCompanyId && newIssueOpen && supportsAssigneeOverrides,
|
? queryKeys.agents.adapterModels(effectiveCompanyId, assigneeAdapterType)
|
||||||
|
: ["agents", "none", "adapter-models", assigneeAdapterType ?? "none"],
|
||||||
|
queryFn: () => agentsApi.adapterModels(effectiveCompanyId!, assigneeAdapterType!),
|
||||||
|
enabled: Boolean(effectiveCompanyId) && newIssueOpen && supportsAssigneeOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const createIssue = useMutation({
|
const createIssue = useMutation({
|
||||||
@@ -364,7 +370,7 @@ export function NewIssueDialog() {
|
|||||||
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
? ISSUE_THINKING_EFFORT_OPTIONS.codex_local
|
||||||
: assigneeAdapterType === "opencode_local"
|
: assigneeAdapterType === "opencode_local"
|
||||||
? ISSUE_THINKING_EFFORT_OPTIONS.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)) {
|
if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) {
|
||||||
setAssigneeThinkingEffort("");
|
setAssigneeThinkingEffort("");
|
||||||
}
|
}
|
||||||
@@ -496,12 +502,21 @@ export function NewIssueDialog() {
|
|||||||
[orderedProjects],
|
[orderedProjects],
|
||||||
);
|
);
|
||||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() => {
|
||||||
(assigneeAdapterModels ?? []).map((model) => ({
|
return [...(assigneeAdapterModels ?? [])]
|
||||||
id: model.id,
|
.sort((a, b) => {
|
||||||
label: model.label,
|
const providerA = extractProviderIdWithFallback(a.id);
|
||||||
searchText: model.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],
|
[assigneeAdapterModels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { extractModelName, extractProviderIdWithFallback } from "../lib/model-utils";
|
||||||
import { getUIAdapter } from "../adapters";
|
import { getUIAdapter } from "../adapters";
|
||||||
import { defaultCreateValues } from "./agent-config-defaults";
|
import { defaultCreateValues } from "./agent-config-defaults";
|
||||||
import {
|
import {
|
||||||
@@ -24,10 +25,10 @@ import {
|
|||||||
DEFAULT_CODEX_LOCAL_MODEL
|
DEFAULT_CODEX_LOCAL_MODEL
|
||||||
} from "@paperclipai/adapter-codex-local";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-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 { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
import { HintIcon } from "./agent-config-primitives";
|
import { HintIcon } from "./agent-config-primitives";
|
||||||
|
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -76,6 +77,7 @@ export function OnboardingWizard() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [modelOpen, setModelOpen] = useState(false);
|
const [modelOpen, setModelOpen] = useState(false);
|
||||||
|
const [modelSearch, setModelSearch] = useState("");
|
||||||
|
|
||||||
// Step 1
|
// Step 1
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
@@ -149,10 +151,18 @@ export function OnboardingWizard() {
|
|||||||
if (step === 3) autoResizeTextarea();
|
if (step === 3) autoResizeTextarea();
|
||||||
}, [step, taskDescription, autoResizeTextarea]);
|
}, [step, taskDescription, autoResizeTextarea]);
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const {
|
||||||
queryKey: ["adapter-models", adapterType],
|
data: adapterModels,
|
||||||
queryFn: () => agentsApi.adapterModels(adapterType),
|
error: adapterModelsError,
|
||||||
enabled: onboardingOpen && step === 2
|
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 =
|
const isLocalAdapter =
|
||||||
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || adapterType === "cursor";
|
adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "opencode_local" || adapterType === "cursor";
|
||||||
@@ -162,9 +172,9 @@ export function OnboardingWizard() {
|
|||||||
? "codex"
|
? "codex"
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? "agent"
|
? "agent"
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? "opencode"
|
? "opencode"
|
||||||
: "claude");
|
: "claude");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== 2) return;
|
if (step !== 2) return;
|
||||||
@@ -182,6 +192,41 @@ export function OnboardingWizard() {
|
|||||||
adapterType === "claude_local" &&
|
adapterType === "claude_local" &&
|
||||||
adapterEnvResult?.status === "fail" &&
|
adapterEnvResult?.status === "fail" &&
|
||||||
hasAnthropicApiKeyOverrideCheck;
|
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() {
|
function reset() {
|
||||||
setStep(1);
|
setStep(1);
|
||||||
@@ -225,8 +270,6 @@ export function OnboardingWizard() {
|
|||||||
? model || DEFAULT_CODEX_LOCAL_MODEL
|
? model || DEFAULT_CODEX_LOCAL_MODEL
|
||||||
: adapterType === "cursor"
|
: adapterType === "cursor"
|
||||||
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
? model || DEFAULT_CURSOR_LOCAL_MODEL
|
||||||
: adapterType === "opencode_local"
|
|
||||||
? model || DEFAULT_OPENCODE_LOCAL_MODEL
|
|
||||||
: model,
|
: model,
|
||||||
command,
|
command,
|
||||||
args,
|
args,
|
||||||
@@ -315,6 +358,35 @@ export function OnboardingWizard() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
if (adapterType === "opencode_local") {
|
||||||
|
const selectedModelId = model.trim();
|
||||||
|
if (!selectedModelId) {
|
||||||
|
setError("OpenCode requires an explicit model in provider/model format.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (adapterModelsError) {
|
||||||
|
setError(
|
||||||
|
adapterModelsError instanceof Error
|
||||||
|
? adapterModelsError.message
|
||||||
|
: "Failed to load OpenCode models.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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) {
|
if (isLocalAdapter) {
|
||||||
const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
|
const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
@@ -590,8 +662,8 @@ export function OnboardingWizard() {
|
|||||||
{
|
{
|
||||||
value: "opencode_local" as const,
|
value: "opencode_local" as const,
|
||||||
label: "OpenCode",
|
label: "OpenCode",
|
||||||
icon: Code,
|
icon: OpenCodeLogoIcon,
|
||||||
desc: "Local OpenCode agent"
|
desc: "Local multi-provider agent"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "openclaw" as const,
|
value: "openclaw" as const,
|
||||||
@@ -640,9 +712,14 @@ export function OnboardingWizard() {
|
|||||||
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
setModel(DEFAULT_CODEX_LOCAL_MODEL);
|
||||||
} else if (nextType === "cursor" && !model) {
|
} else if (nextType === "cursor" && !model) {
|
||||||
setModel(DEFAULT_CURSOR_LOCAL_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 && (
|
{opt.recommended && (
|
||||||
@@ -688,7 +765,13 @@ export function OnboardingWizard() {
|
|||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
<label className="text-xs text-muted-foreground mb-1 block">
|
||||||
Model
|
Model
|
||||||
</label>
|
</label>
|
||||||
<Popover open={modelOpen} onOpenChange={setModelOpen}>
|
<Popover
|
||||||
|
open={modelOpen}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setModelOpen(next);
|
||||||
|
if (!next) setModelSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
||||||
<span
|
<span
|
||||||
@@ -698,7 +781,10 @@ export function OnboardingWizard() {
|
|||||||
>
|
>
|
||||||
{selectedModel
|
{selectedModel
|
||||||
? selectedModel.label
|
? selectedModel.label
|
||||||
: model || "Default"}
|
: model ||
|
||||||
|
(adapterType === "opencode_local"
|
||||||
|
? "Select model (required)"
|
||||||
|
: "Default")}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -707,36 +793,60 @@ export function OnboardingWizard() {
|
|||||||
className="w-[var(--radix-popover-trigger-width)] p-1"
|
className="w-[var(--radix-popover-trigger-width)] p-1"
|
||||||
align="start"
|
align="start"
|
||||||
>
|
>
|
||||||
<button
|
<input
|
||||||
className={cn(
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
placeholder="Search models..."
|
||||||
!model && "bg-accent"
|
value={modelSearch}
|
||||||
)}
|
onChange={(e) => setModelSearch(e.target.value)}
|
||||||
onClick={() => {
|
autoFocus
|
||||||
setModel("");
|
/>
|
||||||
setModelOpen(false);
|
{adapterType !== "opencode_local" && (
|
||||||
}}
|
|
||||||
>
|
|
||||||
Default
|
|
||||||
</button>
|
|
||||||
{(adapterModels ?? []).map((m) => (
|
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
m.id === model && "bg-accent"
|
!model && "bg-accent"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModel(m.id);
|
setModel("");
|
||||||
setModelOpen(false);
|
setModelOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{m.label}</span>
|
Default
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
</button>
|
||||||
{m.id}
|
)}
|
||||||
</span>
|
<div className="max-h-[240px] overflow-y-auto">
|
||||||
</button>
|
{groupedModels.map((group) => (
|
||||||
))}
|
<div key={group.provider} className="mb-1 last:mb-0">
|
||||||
|
{adapterType === "opencode_local" && (
|
||||||
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.provider} ({group.entries.length})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{group.entries.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
||||||
|
m.id === model && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setModel(m.id);
|
||||||
|
setModelOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="block w-full text-left truncate" title={m.id}>
|
||||||
|
{adapterType === "opencode_local" ? extractModelName(m.id) : m.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filteredModels.length === 0 && (
|
||||||
|
<p className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
No models discovered.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
@@ -802,7 +912,7 @@ export function OnboardingWizard() {
|
|||||||
: adapterType === "codex_local"
|
: adapterType === "codex_local"
|
||||||
? `${effectiveAdapterCommand} exec --json -`
|
? `${effectiveAdapterCommand} exec --json -`
|
||||||
: adapterType === "opencode_local"
|
: adapterType === "opencode_local"
|
||||||
? `${effectiveAdapterCommand} run --format json \"Respond with hello.\"`
|
? `${effectiveAdapterCommand} run --format json "Respond with hello."`
|
||||||
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
: `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
|
|||||||
22
ui/src/components/OpenCodeLogoIcon.tsx
Normal file
22
ui/src/components/OpenCodeLogoIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface OpenCodeLogoIconProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenCodeLogoIcon({ className }: OpenCodeLogoIconProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src="/brands/opencode-logo-light-square.svg"
|
||||||
|
alt="OpenCode"
|
||||||
|
className={cn("dark:hidden", className)}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="/brands/opencode-logo-dark-square.svg"
|
||||||
|
alt="OpenCode"
|
||||||
|
className={cn("hidden dark:block", className)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
|
|||||||
role: "Organizational role. Determines position and capabilities.",
|
role: "Organizational role. Determines position and capabilities.",
|
||||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||||
adapterType: "How this agent runs: local CLI (Claude/Codex), OpenClaw webhook, spawned process, or generic HTTP webhook.",
|
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.",
|
||||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||||
model: "Override the default model used by the adapter.",
|
model: "Override the default model used by the adapter.",
|
||||||
@@ -34,7 +34,7 @@ export const help: Record<string, string> = {
|
|||||||
search: "Enable Codex web search capability during runs.",
|
search: "Enable Codex web search capability during runs.",
|
||||||
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
||||||
command: "The command to execute (e.g. node, python).",
|
command: "The command to execute (e.g. node, python).",
|
||||||
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex).",
|
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
|
||||||
args: "Command-line arguments, comma-separated.",
|
args: "Command-line arguments, comma-separated.",
|
||||||
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
||||||
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
||||||
|
|||||||
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,
|
taskSessions: (id: string) => ["agents", "task-sessions", id] as const,
|
||||||
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||||
|
adapterModels: (companyId: string, adapterType: string) =>
|
||||||
|
["agents", companyId, "adapter-models", adapterType] as const,
|
||||||
},
|
},
|
||||||
issues: {
|
issues: {
|
||||||
list: (companyId: string) => ["issues", companyId] as const,
|
list: (companyId: string) => ["issues", companyId] as const,
|
||||||
|
|||||||
@@ -1155,8 +1155,12 @@ function ConfigurationTab({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: adapterModels } = useQuery({
|
const { data: adapterModels } = useQuery({
|
||||||
queryKey: ["adapter-models", agent.adapterType],
|
queryKey:
|
||||||
queryFn: () => agentsApi.adapterModels(agent.adapterType),
|
companyId
|
||||||
|
? queryKeys.agents.adapterModels(companyId, agent.adapterType)
|
||||||
|
: ["agents", "none", "adapter-models", agent.adapterType],
|
||||||
|
queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType),
|
||||||
|
enabled: Boolean(companyId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateAgent = useMutation({
|
const updateAgent = useMutation({
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ import { defineConfig } from "vitest/config";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
projects: ["packages/db", "server", "ui", "cli"],
|
projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user