Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@commonlyai/cli",
"version": "0.1.168",
"license": "Apache-2.0",
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
"description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
"type": "module",
"main": "./src/index.js",
"bin": {
Expand Down
161 changes: 161 additions & 0 deletions packages/seats/src/acp/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* Newline-delimited JSON-RPC 2.0 over a child's stdio, as ACP's stdio
* transport defines it: one message per line, no embedded newlines.
*
* The client owns framing and id bookkeeping only. What a notification or an
* agent→client request MEANS is the adapter's business, so both arrive through
* handlers. A request with no handler answer is -32601, which is how this
* client declines fs/* and terminal/*: it advertised neither in initialize, so
* an agent that asks anyway gets the standard "method not found".
*/
import { StringDecoder } from 'node:string_decoder';
import { runFailure } from '../failures.js';

/** ACP lines carry tool output and diffs, so the cap is generous, not tight. */
export const DEFAULT_MAX_LINE_CHARS = 8 * 1024 * 1024;

export const METHOD_NOT_FOUND = -32601;
const INTERNAL_ERROR = -32603;

/** A JSON-RPC error response, kept whole so the adapter can classify it. */
export class AcpRpcError extends Error {
constructor(method, error) {
super(`${method} failed (${error?.code}): ${error?.message || 'no message'}`);
this.name = 'AcpRpcError';
this.method = method;
this.code = error?.code;
this.rpcMessage = typeof error?.message === 'string' ? error.message : '';
this.data = error?.data;
}
}

/** Throw this from an onRequest handler to answer with a specific error. */
export const rpcError = (code, message) => Object.assign(new Error(message), { rpcCode: code });

const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const hasId = (message) => Object.prototype.hasOwnProperty.call(message, 'id') && message.id !== null;

/**
* @param {object} opts
* @param {import('node:stream').Readable} opts.input the child's stdout
* @param {import('node:stream').Writable} opts.output the child's stdin
* @param {(method: string, params: any) => void} [opts.onNotification]
* @param {(method: string, params: any) => Promise<any> | any} [opts.onRequest]
* resolve with the result; throw `rpcError(code, message)` to answer an error;
* resolve `undefined` for "not mine" and the client answers -32601.
* @param {(failure: import('../failures.js').RunFailure) => void} [opts.onProtocolError]
* @param {number} [opts.maxLineChars]
*/
export const createAcpClient = ({
input, output, onNotification = () => {}, onRequest = () => undefined,
onProtocolError = () => {}, maxLineChars = DEFAULT_MAX_LINE_CHARS,
}) => {
const pending = new Map();
const decoder = new StringDecoder('utf8');
let buffer = '';
let nextId = 1;
let broken = null;

const write = (message) => {
if (broken || output.destroyed || output.writableEnded) return false;
output.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
return true;
};

// Every in-flight request learns why the connection ended, so no caller
// waits on a response that cannot come.
const fail = (failure) => {
if (broken) return;
broken = failure;
for (const { reject } of pending.values()) reject(failure);
pending.clear();
};

const protocolError = (detail) => {
const failure = runFailure('protocol', detail);
fail(failure);
onProtocolError(failure);
};

const answerRequest = async (message) => {
const { id, method, params } = message;
try {
const result = await onRequest(method, params);
if (result === undefined) {
write({ id, error: { code: METHOD_NOT_FOUND, message: `method not supported by this client: ${method}` } });
} else {
write({ id, result });
}
} catch (error) {
write({ id, error: { code: error?.rpcCode ?? INTERNAL_ERROR, message: error?.message || 'client error' } });
}
};

const consume = (line) => {
let message;
try { message = JSON.parse(line); } catch {
protocolError(`agent wrote a line that is not JSON (${line.length} chars)`);
return;
}
if (!isObject(message) || message.jsonrpc !== '2.0') {
protocolError('agent wrote a message that is not JSON-RPC 2.0');
return;
}
if (typeof message.method === 'string') {
if (hasId(message)) answerRequest(message);
// Unknown notifications are ignored by contract: ACP adds update kinds
// faster than any client learns them.
else onNotification(message.method, message.params);
return;
}
if (!hasId(message) || !pending.has(message.id)) {
protocolError(`agent answered a request id it was never sent: ${JSON.stringify(message.id ?? null)}`);
return;
}
const { resolve, reject, method } = pending.get(message.id);
// JSON-RPC 2.0: an error is an object whose code is an integer. Anything
// else is a broken answer, not an error the adapter can classify, and it
// fails every pending request with it.
if (message.error != null && (!isObject(message.error) || !Number.isInteger(message.error.code))) {
protocolError(`agent answered ${method} with an error whose code is not an integer: ${JSON.stringify(message.error?.code ?? null).slice(0, 40)}`);
return;
}
pending.delete(message.id);
if (message.error != null) reject(new AcpRpcError(method, message.error));
else resolve(message.result);
};

input.on('data', (chunk) => {
if (broken) return;
buffer += decoder.write(chunk);
let newline = buffer.indexOf('\n');
while (newline !== -1) {
if (newline > maxLineChars) {
protocolError(`agent line exceeded ${maxLineChars} chars`);
return;
}
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (line) consume(line);
if (broken) return;
newline = buffer.indexOf('\n');
}
if (buffer.length > maxLineChars) protocolError(`agent line exceeded ${maxLineChars} chars`);
});

return {
request(method, params) {
if (broken) return Promise.reject(broken);
const id = nextId;
nextId += 1;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject, method });
write({ id, method, params });
});
},
notify(method, params) { return write({ method, params }); },
/** End the connection; pending requests reject with `failure`. */
close(failure) { fail(failure); },
get broken() { return broken; },
};
};
215 changes: 215 additions & 0 deletions packages/seats/src/adapters/acp-launch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* Launch functions for ACP agents. Each one turns a run ctx into a command, an
* isolated env and a cleanup; the protocol itself is adapters/acp.js.
*
* The provider is an OpenAI-compatible route from `ctx.provider = { baseURL,
* apiKey, model }`. The key travels in the child env (goose) or a 0600 file
* the agent's config points at (opencode), never in argv, which any same-user
* process can read.
*
* HOME comes from ctx.env.HOME when the caller gives one, because that is
* where an agent keeps the sessions a later resume needs. Without one, the
* spawn gets a throwaway HOME, so nothing of the operator's leaks in and
* nothing of the run survives it.
*/
import { spawnSync } from 'node:child_process';
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runFailure } from '../failures.js';
import { createAcpAdapter, readsAsRefusal } from './acp.js';

// The locale names are the POSIX and glibc categories by name, never an LC_
// prefix: a prefix match admits any variable that starts with it
// (LC_SECRET_TOKEN), and the child env is where the provider key lives.
const LOCALE_ENV = 'LC_(?:ALL|CTYPE|COLLATE|MESSAGES|MONETARY|NUMERIC|TIME|ADDRESS|IDENTIFICATION|MEASUREMENT|NAME|PAPER|TELEPHONE)';
const SAFE_ENV = new RegExp(`^(?:PATH|LANG|${LOCALE_ENV}|TERM|USER|LOGNAME|NO_COLOR|CI|TMPDIR|SSL_CERT_FILE|SSL_CERT_DIR)$`);
const VERSION_TIMEOUT_MS = 5000;

/** The provider route, checked: a plain HTTP(S) base URL, a key and a model. */
export const providerOf = (ctx, label) => {
const { baseURL, apiKey, model } = ctx?.provider || {};
let url;
try { url = new URL(String(baseURL)); } catch {
throw runFailure('unsupported', `${label}: ctx.provider.baseURL must be an HTTP(S) URL`);
}
if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash) {
throw runFailure('unsupported', `${label}: ctx.provider.baseURL must be a plain HTTP(S) URL with no credentials, query or fragment`);
}
if (typeof apiKey !== 'string' || !apiKey) throw runFailure('unsupported', `${label}: ctx.provider.apiKey is missing`);
if (typeof model !== 'string' || !model) throw runFailure('unsupported', `${label}: ctx.provider.model is missing`);
return { url, apiKey, model };
};

/**
* Safe launch variables from the caller's env, plus HOME and XDG roots. Only
* the safe names are read from process.env, and only when ctx.env is absent.
*/
export const isolatedEnv = async (ctx, label) => {
const source = ctx.env || process.env;
const env = Object.fromEntries(Object.entries(source).filter(([name]) => SAFE_ENV.test(name)));
const callerHome = ctx.env?.HOME;
const home = callerHome || await mkdtemp(join(tmpdir(), `seats-${label}-home-`));
env.HOME = home;
env.XDG_CONFIG_HOME = ctx.env?.XDG_CONFIG_HOME || join(home, '.config');
env.XDG_DATA_HOME = ctx.env?.XDG_DATA_HOME || join(home, '.local', 'share');
env.XDG_STATE_HOME = ctx.env?.XDG_STATE_HOME || join(home, '.local', 'state');
env.XDG_CACHE_HOME = ctx.env?.XDG_CACHE_HOME || join(home, '.cache');
const removeHome = callerHome ? async () => {} : () => rm(home, { recursive: true, force: true });
return { env, removeHome };
};

const detectVersion = (binary, pattern) => async () => {
const probe = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: VERSION_TIMEOUT_MS });
if (probe.error || probe.status !== 0) return null;
const version = pattern.exec(String(probe.stdout || ''))?.[1] || 'unknown';
return { path: binary, version };
};

/**
* goose 1.54.0 ends a turn whose provider call failed with stopReason
* end_turn and the error as its reply: "Ran into this error: …". Without this
* reader that error would be posted as the agent's answer. Measured against
* the fake provider: a 400 content-policy body, a 500 and a 429 all take this
* shape, after goose's own retries.
*/
export const readGooseReplyFailure = (text, { credentials = [] } = {}) => {
const match = /^\s*Ran into this error:\s*([\s\S]*)$/.exec(String(text ?? ''));
if (!match) return null;
const detail = match[1].split('\n\nPlease retry')[0].trim();
return runFailure(readsAsRefusal(detail) ? 'refusal' : 'protocol',
`goose ended the turn on a provider error: ${detail}`, { credentials, limit: 500 });
};

/** `goose acp` with goose's OpenAI provider pointed at ctx.provider. */
export const launchGoose = async (ctx = {}) => {
const { url, apiKey, model } = providerOf(ctx, 'goose');
const { env, removeHome } = await isolatedEnv(ctx, 'goose');
Object.assign(env, {
GOOSE_PROVIDER: 'openai',
GOOSE_MODEL: model,
// approve: every tool call asks over session/request_permission, so the
// caller's decidePermission (or the reject default) is the policy.
GOOSE_MODE: 'approve',
GOOSE_TELEMETRY_ENABLED: 'false',
// No keychain prompts or keychain reads: the key arrives in env only.
GOOSE_DISABLE_KEYRING: '1',
OPENAI_HOST: url.origin,
OPENAI_BASE_PATH: `${url.pathname.replace(/\/$/, '')}/chat/completions`.replace(/^\//, ''),
OPENAI_API_KEY: apiKey,
});
return {
command: ctx._binaryPath || 'goose', args: ['acp'], env, cwd: ctx.cwd, cleanup: removeHome,
};
};

const OPENCODE_PROVIDER_ID = 'seats';
const OPENCODE_DISABLED_ENV = [
'OPENCODE_DISABLE_CLAUDE_CODE',
'OPENCODE_DISABLE_CLAUDE_CODE_PROMPT',
'OPENCODE_DISABLE_CLAUDE_CODE_SKILLS',
'OPENCODE_DISABLE_EXTERNAL_SKILLS',
'OPENCODE_DISABLE_DEFAULT_PLUGINS',
'OPENCODE_DISABLE_AUTOUPDATE',
];

/**
* The opencode config for one spawn: a single enabled openai-compatible
* provider and it as the default model. Same shape as the cli opencode
* adapter's buildProviderConfig; the default model matters more here, because
* `opencode acp` has no -m flag and its built-in default is a hosted service.
*/
export const opencodeConfig = ({ baseURL, model, keyFile }) => ({
share: 'disabled',
autoupdate: false,
enabled_providers: [OPENCODE_PROVIDER_ID],
model: `${OPENCODE_PROVIDER_ID}/${model}`,
provider: {
[OPENCODE_PROVIDER_ID]: {
npm: '@ai-sdk/openai-compatible',
options: { baseURL, apiKey: `{file:${keyFile}}` },
models: { [model]: { name: model } },
},
},
});

/**
* opencode 2.x reports each provider retry as a session_info_update whose
* `_meta["opencode/retry"]` is {attempt, nextRetryAt, error: {type, message,
* status, response: {body}}}, and sends the key as null when the waited-for
* attempt goes out. Only attempt, nextRetryAt, status and the error's type
* are read: message and body are provider text.
*/
export const readOpencodeRetry = (update) => {
const retry = update?._meta?.['opencode/retry'];
if (retry === null) return { state: 'started' };
if (typeof retry !== 'object') return null;
return {
state: 'scheduled',
attempt: retry.attempt,
nextRetryAt: retry.nextRetryAt,
status: retry.error?.status,
errorType: retry.error?.type,
};
};

/** `opencode acp` with a generated config; project and global config are off. */
export const launchOpencode = async (ctx = {}) => {
const { url, apiKey, model } = providerOf(ctx, 'opencode');
const { env, removeHome } = await isolatedEnv(ctx, 'opencode');
const dir = await mkdtemp(join(tmpdir(), 'seats-opencode-'));
try {
await chmod(dir, 0o700);
const keyFile = join(dir, 'provider-key');
const configPath = join(dir, 'opencode.json');
const configDir = join(dir, 'config-dir');
const xdgConfig = join(dir, 'xdg-config');
await Promise.all([mkdir(configDir), mkdir(join(xdgConfig, 'opencode'), { recursive: true })]);
await writeFile(keyFile, apiKey, { mode: 0o600, flag: 'wx' });
await writeFile(configPath, `${JSON.stringify(opencodeConfig({ baseURL: url.href.replace(/\/$/, ''), model, keyFile }), null, 2)}\n`, { mode: 0o600, flag: 'wx' });
Object.assign(env, {
OPENCODE_CONFIG: configPath,
OPENCODE_CONFIG_DIR: configDir,
OPENCODE_DISABLE_PROJECT_CONFIG: '1',
OPENCODE_AUTO_SHARE: 'false',
// opencode merges global config from XDG_CONFIG_HOME; point it at an
// empty dir so only the generated file counts. Data (sessions) stays
// under the caller's XDG_DATA_HOME, which is what a resume reads.
XDG_CONFIG_HOME: xdgConfig,
...Object.fromEntries(OPENCODE_DISABLED_ENV.map((name) => [name, '1'])),
});
} catch (error) {
await rm(dir, { recursive: true, force: true });
await removeHome();
throw error;
}
return {
command: ctx._binaryPath || 'opencode',
args: ['acp'],
env,
cwd: ctx.cwd,
cleanup: async () => {
await rm(dir, { recursive: true, force: true });
await removeHome();
},
};
};

export const createAcpGooseAdapter = (host = {}) => createAcpAdapter({
...host,
name: 'acp-goose',
launch: launchGoose,
detect: detectVersion('goose', /(\d+\.\d+\.\d+)/),
readReplyFailure: readGooseReplyFailure,
});

export const createAcpOpencodeAdapter = (host = {}) => createAcpAdapter({
...host,
name: 'acp-opencode',
launch: launchOpencode,
readRetryStatus: readOpencodeRetry,
detect: detectVersion('opencode', /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/),
});

export const acpGoose = createAcpGooseAdapter();
export const acpOpencode = createAcpOpencodeAdapter();
Loading
Loading