@coda-console/codex 0.0.0-stage → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 coda-console
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,71 @@
1
+ // The account source another plugin plugs into Codex (the `account-source` extension point, docs/plugins.md「内置插件:Codex」):
2
+ // its command, started once and asked over its standard input and output, one JSON object a line.
3
+ // → { "id": 1, "method": "selected" } ← { "id": 1, "result": { "account": { "id", "name" } | null } }
4
+ // → { "id": 2, "method": "tokens", "params": { "account", "refresh"? } } ← { "id": 2, "result": { "accessToken", "chatgptAccountId", "chatgptPlanType" } }
5
+ // `refresh: true` asks the owner to renew a rejected access token, even before its expiry.
6
+ // Either may answer `{ "id", "error": { "message" } }`. A source taking full ownership reports an error until an account
7
+ // is chosen; a legacy source's `null` leaves the underlying login as it is. A source that ends is started again when asked.
8
+ // An installed source owns the account panel and handles `--login` for Codex's existing login entry.
9
+ import { spawn } from 'node:child_process';
10
+ import { dirname } from 'node:path';
11
+ import { readLines } from './lines.mjs';
12
+
13
+ // What Coda hands this plugin's processes of the plugins plugged into its points (CODA_PLUGIN_EXTENSIONS): the first source.
14
+ export function accountSource(env = process.env) {
15
+ let points; try { points = JSON.parse(env.CODA_PLUGIN_EXTENSIONS || '{}'); } catch { return null; }
16
+ return points?.['account-source']?.[0] ?? null;
17
+ }
18
+
19
+ export function sourceEnv(source, env = process.env) {
20
+ return { ...env, CODA_PLUGIN_PATH: source.path, ...(source.state ? { CODA_PLUGIN_STATE: source.state } : {}),
21
+ CODA_PLUGIN_DEPENDENCIES: JSON.stringify({ codex: env.CODA_PLUGIN_PATH }),
22
+ PATH: [dirname(process.execPath), env.PATH].filter(Boolean).join(':') };
23
+ }
24
+
25
+ export class AccountSource {
26
+ constructor(source, { env = process.env, timeoutMs = 10_000 } = {}) {
27
+ this.source = source; this.env = env; this.timeoutMs = timeoutMs; this.next = 0; this.pending = new Map(); this.child = null; this.closed = false;
28
+ }
29
+ start() {
30
+ const { command } = this.source;
31
+ // As a panel's command is run (docs/plugins.md「面板」): its plugin's directory and state, and this Node first on the PATH.
32
+ const child = spawn('/bin/sh', ['-c', command], { env: sourceEnv(this.source, this.env),
33
+ stdio: ['pipe', 'pipe', 'ignore'] });
34
+ child.stdin.on('error', () => {});
35
+ readLines(child.stdout).on('line', line => {
36
+ let message; try { message = JSON.parse(line); } catch { return; }
37
+ const waiter = this.pending.get(message?.id);
38
+ if (!waiter) return;
39
+ this.pending.delete(message.id); clearTimeout(waiter.timer);
40
+ if (message.error) waiter.reject(new Error(typeof message.error.message === 'string' && message.error.message.trim() ? message.error.message.trim().slice(0, 200) : '账号来源拒绝了请求'));
41
+ else waiter.resolve(message.result ?? {});
42
+ });
43
+ const ended = () => {
44
+ if (this.child === child) this.child = null;
45
+ for (const [id, waiter] of this.pending) if (waiter.child === child) { clearTimeout(waiter.timer); this.pending.delete(id); waiter.reject(new Error('账号来源提前退出了')); }
46
+ };
47
+ child.once('error', ended); child.once('exit', ended);
48
+ return child;
49
+ }
50
+ request(method, params) {
51
+ if (this.closed) return Promise.reject(new Error('账号来源已关闭'));
52
+ const child = this.child ??= this.start(), id = ++this.next;
53
+ return new Promise((resolve, reject) => {
54
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('账号来源没有答复')); }, this.timeoutMs);
55
+ this.pending.set(id, { resolve, reject, timer, child });
56
+ child.stdin.write(`${JSON.stringify({ id, method, ...(params ? { params } : {}) })}\n`);
57
+ });
58
+ }
59
+ async selected() {
60
+ const { account } = await this.request('selected');
61
+ if (account == null) return null;
62
+ if (typeof account?.id !== 'string' || !account.id) throw new Error('账号来源给的账号无效');
63
+ return { id: account.id, name: typeof account.name === 'string' && account.name ? account.name.slice(0, 80) : account.id };
64
+ }
65
+ async tokens(account, { refresh = false } = {}) {
66
+ const tokens = await this.request('tokens', { account, ...(refresh ? { refresh: true } : {}) });
67
+ if (typeof tokens.accessToken !== 'string' || typeof tokens.chatgptAccountId !== 'string') throw new Error('账号来源给的凭据不完整');
68
+ return { accessToken: tokens.accessToken, chatgptAccountId: tokens.chatgptAccountId, ...(typeof tokens.chatgptPlanType === 'string' ? { chatgptPlanType: tokens.chatgptPlanType } : {}) };
69
+ }
70
+ close() { this.closed = true; this.child?.kill(); }
71
+ }
@@ -0,0 +1,119 @@
1
+ // The account bridge: what codex-acp runs as its Codex (`CODEX_PATH`, set by entry.mjs) when another plugin is plugged
2
+ // into Codex's `account-source` point (account-source.mjs). Between codex-acp and the real app-server, it signs the
3
+ // app-server in to the account the source has chosen, by handing it the source's tokens; they stay on this private pipe,
4
+ // below ACP and the session's journal. Nothing chosen, Codex keeps this machine's own login.
5
+ // The choice takes effect where nothing is under way: as a session starts and between turns (the native thread and turn
6
+ // entry points ask the source each time). A turn whose account cannot be taken fails and says why, rather than going out
7
+ // on the one before; one under way finishes on the account it began with.
8
+ import { resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { AppServer } from '../scripts/app-server.mjs';
11
+ import { AccountSource, accountSource } from './account-source.mjs';
12
+ import { readLines } from './lines.mjs';
13
+
14
+ class AccountError extends Error {}
15
+ const failed = (account, error) => new AccountError(`没能换到所选的 Codex 账号${account ? ` ${account.name}` : ''}:${error?.message || '凭据不可用'}。去「资源」重新选择或登录`);
16
+
17
+ export function startAccountBridge({ source = new AccountSource(accountSource()), makeServer = options => new AppServer(options), input = process.stdin,
18
+ output = value => process.stdout.write(`${JSON.stringify(value)}\n`) } = {}) {
19
+ let initialized = false, initializeId, applied = null, accountId = null, problem = null, queue = Promise.resolve(), closing = false;
20
+ const active = new Map(), starting = new Map();
21
+ const schedule = work => { const next = queue.then(work); queue = next.catch(() => {}); return next; };
22
+ async function login(tokens) {
23
+ await server.request('account/login/start', { type: 'chatgptAuthTokens', ...tokens });
24
+ const { account, workspaceRouting } = await server.request('account/read', { refreshToken: false });
25
+ if (account?.type !== 'chatgpt' || workspaceRouting?.chatgptAccountId !== tokens.chatgptAccountId) throw new Error('Codex 登录后的账号与所选的不符');
26
+ }
27
+ // Signs in to the chosen account when nothing is under way; with a turn under way, the next boundary does.
28
+ async function synchronize() {
29
+ if (!initialized || closing) return null;
30
+ let selected;
31
+ try { selected = await source.selected(); } catch (error) { problem = failed(null, error); throw problem; }
32
+ if (!selected || selected.id === applied?.id) { problem = null; return selected; }
33
+ if (active.size || starting.size) return selected;
34
+ try {
35
+ const tokens = await source.tokens(selected.id);
36
+ // Closed while they were read, the server is gone: a request made of it now would wait out its timeout for nobody.
37
+ if (closing) return selected;
38
+ // Once login begins a failed one means the actual account is unknown.
39
+ applied = null; accountId = null;
40
+ await login(tokens);
41
+ applied = selected; accountId = tokens.chatgptAccountId; problem = null;
42
+ return selected;
43
+ } catch (error) { problem = failed(selected, error); throw problem; }
44
+ }
45
+ const server = makeServer({ onMessage: message => {
46
+ // codex-acp 1.12 does not send an initialized notification. The successful
47
+ // initialize response is the boundary, before its first account/read.
48
+ if (!message.method && message.id === initializeId && !message.error) {
49
+ initialized = true; server.send({ method: 'initialized' });
50
+ void schedule(synchronize).catch(() => {});
51
+ }
52
+ if (message.method === 'account/chatgptAuthTokens/refresh' && message.id != null) {
53
+ // The source may have renewed the tokens since login. Never answer a request of account A with account B's.
54
+ const account = applied;
55
+ void (async () => {
56
+ try {
57
+ if (!account || (message.params?.previousAccountId && message.params.previousAccountId !== accountId)) throw new Error('当前没有 Coda 交给 Codex 的账号');
58
+ const tokens = await source.tokens(account.id, { refresh: true });
59
+ if (closing) return;
60
+ if (tokens.chatgptAccountId !== accountId) throw new Error('账号来源给的凭据属于另一个账号');
61
+ server.send({ id: message.id, result: tokens });
62
+ } catch (error) { if (!closing) server.send({ id: message.id, error: { code: -32000, message: failed(account, error).message } }); }
63
+ })(); return;
64
+ }
65
+ if (starting.has(message.id) && !message.method) {
66
+ const start = starting.get(message.id), turnId = message.result?.turn?.id;
67
+ starting.delete(message.id);
68
+ // Completion may overtake the request's response. Remember it only for
69
+ // outstanding starts, so late responses neither revive work nor grow a history.
70
+ if (!message.error && typeof turnId === 'string' && !start.completed.has(turnId)) {
71
+ if (!active.has(start.threadId)) active.set(start.threadId, turnId);
72
+ } else void schedule(synchronize).catch(() => {});
73
+ }
74
+ if (message.method === 'turn/started') active.set(message.params.threadId, message.params.turn.id);
75
+ if (message.method === 'turn/completed') {
76
+ const { threadId, turn } = message.params;
77
+ for (const start of starting.values()) if (start.threadId === threadId) start.completed.add(turn.id);
78
+ if (active.get(threadId) === turn.id) active.delete(threadId);
79
+ void schedule(synchronize).catch(() => {});
80
+ }
81
+ output(message);
82
+ } });
83
+ const reader = readLines(input);
84
+ reader.on('line', line => {
85
+ let message; try { message = JSON.parse(line); } catch { return; }
86
+ void schedule(async () => {
87
+ // A request that finds the bridge closed, or that it closes on, is neither sent on nor answered: its client is gone.
88
+ if (closing) return;
89
+ if (message.method === 'initialize') {
90
+ initializeId = message.id;
91
+ message.params.capabilities = { ...message.params.capabilities, experimentalApi: true };
92
+ }
93
+ if (message.method === 'initialized') return;
94
+ // Account reads during ACP startup and all native thread and turn entry points take the chosen account;
95
+ // one that could not be taken never sends on the one before.
96
+ if (['account/read', 'thread/start', 'thread/resume', 'thread/fork', 'turn/start'].includes(message.method)) {
97
+ const selected = await synchronize();
98
+ if (closing) return;
99
+ if (message.method === 'turn/start') {
100
+ if (selected && (problem || !applied || (applied.id !== selected.id && !active.size && !starting.size))) throw problem ?? failed(selected);
101
+ starting.set(message.id, { threadId: message.params.threadId, completed: new Set() });
102
+ }
103
+ }
104
+ // ACP's own login and logout would overwrite what the source gave, or this machine's login under it.
105
+ if (applied && ['account/login/start', 'account/logout'].includes(message.method)) throw new AccountError('Coda 正在为 Codex 选用账号,在「资源」里切换');
106
+ server.send(message);
107
+ }).catch(error => {
108
+ if (message.id != null) output({ id: message.id, error: { code: -32000, message: error instanceof AccountError ? error.message : failed(null, error).message } });
109
+ });
110
+ });
111
+ async function close() { if (closing) return; closing = true; reader.close(); source.close(); await server.close(); }
112
+ reader.on('close', () => void close());
113
+ void server.exited.then(() => reader.close());
114
+ return { close, server, settled: () => queue };
115
+ }
116
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
117
+ const bridge = startAccountBridge();
118
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => void bridge.close());
119
+ }
@@ -0,0 +1,67 @@
1
+ // The Codex agent as Coda starts it (the plugin's `contributes.agents`): the locked codex-acp, run in this process with Coda's
2
+ // patches, which fail the start once the adapter changes. Between it and Coda the entry speaks Coda's protocol for it
3
+ // (waits.mjs, docs/agent-protocol.md): its session instructions become the thread's developer instructions (instructions.mjs),
4
+ // and a steer that finds no turn going is answered `promptRequired` (steering.mjs). What Coda wants of Codex beyond ACP is set
5
+ // here: the mode a session starts in, and which Codex program runs, through the account bridge when another plugin chooses
6
+ // the account (accounts.mjs).
7
+ import { createRequire, registerHooks } from 'node:module';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { createHash } from 'node:crypto';
10
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { accountSource } from './account-source.mjs';
14
+ import { readLines } from './lines.mjs';
15
+ import { orderedCodexTitles } from './titles.mjs';
16
+ import { continuedCodexSubagents } from './subagents.mjs';
17
+ import { followCodexProcessExit } from './process-exit.mjs';
18
+ import { networkedCodexDevelopment } from './network.mjs';
19
+ import { codexOptionsAsShown } from './options.mjs';
20
+ import { instructedCodexThreads } from './instructions.mjs';
21
+ import { promptRequiredCodexSteering } from './steering.mjs';
22
+ import { input, listen, relayedCodexStreams } from './relay.mjs';
23
+ import { CodexWaits } from './waits.mjs';
24
+ import { explainCodexError, reportedCodexFailures } from './errors.mjs';
25
+
26
+ const env = process.env;
27
+ // A session starts read-only, unless its machine's configuration says otherwise.
28
+ env.INITIAL_AGENT_MODE ??= 'read-only';
29
+ // The Codex this session runs, as the handshake's `agentInfo` names it (Coda's protocol「Agent 版本」): the connection's own
30
+ // program when it sets one, else the plugin's locked @openai/codex.
31
+ const own = env.CODEX_PATH;
32
+ function codexVersion() {
33
+ if (own) return /\d+\.\d+\.\d+[\w.+-]*/.exec(spawnSync(own, ['--version'], { encoding: 'utf8', timeout: 3000 }).stdout ?? '')?.[0] ?? null;
34
+ try { return JSON.parse(readFileSync(createRequire(import.meta.url).resolve('@openai/codex/package.json'), 'utf8')).version; } catch { return null; }
35
+ }
36
+ // Another plugin plugged into Codex's `account-source` point chooses the account (accounts.mjs): codex-acp then runs the
37
+ // account bridge as its Codex, through a small program written into this plugin's state, named for what it runs. A Codex
38
+ // program of the connection's own (`CODEX_PATH`) keeps its own login.
39
+ if (!env.CODEX_PATH && accountSource(env) && env.CODA_PLUGIN_STATE) {
40
+ const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
41
+ const program = `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(fileURLToPath(new URL('./accounts.mjs', import.meta.url)))} "$@"\n`;
42
+ const shim = join(env.CODA_PLUGIN_STATE, `account-bridge-${createHash('sha256').update(program).digest('hex').slice(0, 12)}`);
43
+ if (!existsSync(shim)) { writeFileSync(`${shim}.${process.pid}`, program, { mode: 0o700 }); renameSync(`${shim}.${process.pid}`, shim); }
44
+ env.CODEX_PATH = shim;
45
+ }
46
+
47
+ const version = codexVersion();
48
+ const waits = new CodexWaits({ agentInfo: version ? { name: 'codex', version } : null }), parse = line => { try { return JSON.parse(line); } catch { return null; } };
49
+ const say = message => process.stdout.write(`${JSON.stringify(message)}\n`);
50
+ listen(line => { for (const message of waits.fromAgent(parse(line))) say(explainCodexError(message)); });
51
+ readLines(process.stdin).on('line', line => {
52
+ const message = parse(line);
53
+ if (!message || typeof message !== 'object') return;
54
+ const { agent, coda } = waits.fromCoda(message);
55
+ for (const each of agent) input.write(`${JSON.stringify(each)}\n`);
56
+ for (const each of coda) say(each);
57
+ }).on('close', () => input.end());
58
+
59
+ // codex-acp as Node finds it from here (its package names dist/index.js as its main): the plugin's own dependency.
60
+ const adapter = new URL(import.meta.resolve('@agentclientprotocol/codex-acp'));
61
+ registerHooks({ load(url, context, nextLoad) {
62
+ const loaded = nextLoad(url, context);
63
+ if (url !== adapter.href) return loaded;
64
+ return { ...loaded, source: reportedCodexFailures(codexOptionsAsShown(relayedCodexStreams(orderedCodexTitles(followCodexProcessExit(continuedCodexSubagents(
65
+ promptRequiredCodexSteering(instructedCodexThreads(networkedCodexDevelopment(Buffer.from(loaded.source).toString('utf8')))))))))) };
66
+ } });
67
+ await import(adapter.href);
@@ -0,0 +1,67 @@
1
+ // codex-acp puts model-service errors in data.message, SDK-wrapped exceptions in data.details, and some configuration
2
+ // errors in a string data payload. Explain them in ACP's standard message so clients need not know those private shapes.
3
+ export function explainCodexError(message) {
4
+ if (!message || typeof message !== 'object' || !('id' in message) || 'method' in message) return message;
5
+ const error = message.error;
6
+ if (!error || typeof error.message !== 'string') return message;
7
+ const parts = [error.message];
8
+ for (const value of [error.data?.message, error.data?.details, error.data]) {
9
+ const detail = typeof value === 'string' ? value.trim() : '';
10
+ if (detail && !parts.some(part => part.includes(detail))) parts.push(detail);
11
+ }
12
+ return parts.length === 1 ? message : { ...message, error: { ...error, message: parts.join('\n\n') } };
13
+ }
14
+
15
+ // Native service facts, not a retry policy: Coda decides when/how often to try again. Explicit permanent kinds win over
16
+ // generic messages (in particular, quota failures also arrive as JSON-RPC Internal error).
17
+ export function codexFailureData(error, input) {
18
+ const data = error.data && typeof error.data === 'object' && !Array.isArray(error.data) ? error.data : {};
19
+ const info = data.codexErrorInfo;
20
+ const message = typeof data.message === 'string' ? data.message : typeof data.details === 'string' ? data.details : typeof error.data === 'string' ? error.data : error.message;
21
+ let provider; try { provider = JSON.parse(message); } catch {}
22
+ const status = provider?.status ?? (info && typeof info === 'object' ? Object.values(info)[0]?.httpStatusCode : null);
23
+ const permanent = ['usageLimitExceeded', 'unauthorized', 'contextWindowExceeded', 'sessionBudgetExceeded',
24
+ 'cyberPolicy', 'misalignmentPolicyViolation', 'sandboxError', 'threadRollbackFailed'].includes(info) || [401, 403].includes(status);
25
+ const unavailableModel = provider?.type === 'error' && status === 400 && provider.error?.type === 'invalid_request_error' &&
26
+ /^model ['"][^'"\r\n]+['"] is not enabled in rustponsesapi\.?$/.test(provider.error.message ?? '');
27
+ const transport = info && typeof info === 'object' && ['httpConnectionFailed', 'responseStreamConnectionFailed',
28
+ 'responseStreamDisconnected', 'responseTooManyFailedAttempts'].some(kind => kind in info);
29
+ const temporary = !permanent && (unavailableModel || (status >= 500 && status <= 599) ||
30
+ ['internalServerError', 'serverOverloaded'].includes(info) ||
31
+ (transport && (status == null || status >= 500)) ||
32
+ (info === 'other' && message === 'Selected model is at capacity. Please try a different model.') ||
33
+ (info == null && error.code === -32603 && message?.trim() === 'Internal error'));
34
+ if (!temporary) return data;
35
+ return { ...data, ...(data.message || data.details ? {} : { details: error.message }),
36
+ _meta: { ...data._meta, coda: { ...data._meta?.coda, failure: { kind: 'service_unavailable', input: data._meta?.coda?.failure?.input === 'accepted' ? 'accepted' : input } } } };
37
+ }
38
+
39
+ // codex-acp 1.12 only throws quota/auth failures for plain ACP clients; other failed native turns look successful.
40
+ // Keep native failure data at the ACP boundary, even when the terminal notification has no preceding error event.
41
+ export function reportedCodexFailures(source) {
42
+ const replacements = [
43
+ [' async handleFailedTurn(turn) {\n const activeFailure', ` async handleFailedTurn(turn) {
44
+ if (!this.supportsTypedSessionFailures && turn.status === "failed" && this.failure === null) {
45
+ this.failure = RequestError.internalError(this.createTurnErrorData(turn.error ?? {
46
+ message: "Turn failed", codexErrorInfo: null, additionalDetails: null
47
+ }));
48
+ return;
49
+ }
50
+ const activeFailure`],
51
+ [' if (error51 === "usageLimitExceeded") {\n this.failure = RequestError.internalError(',
52
+ ' if (!this.isAuthenticationRequiredError(error51)) {\n this.failure = RequestError.internalError('],
53
+ [' return createAgentTextMessageChunk(`${params.error.message}\n\n`);', ' return null;'],
54
+ [' return data;\n }\n handleTokenUsageUpdated(params) {',
55
+ ' return codexFailureData({ code: -32603, message: error51.message, data }, "accepted");\n }\n handleTokenUsageUpdated(params) {'],
56
+ [' throw err;\n } finally {\n promptNotificationsActive = false;', ` const codaData = codexFailureData(err, sessionState.currentTurnId === null ? "not_accepted" : "accepted");
57
+ if (codaData._meta?.coda?.failure) throw new RequestError(err.code ?? -32603, err.message, codaData);
58
+ throw err;
59
+ } finally {
60
+ promptNotificationsActive = false;`],
61
+ ];
62
+ for (const [before, after] of replacements) {
63
+ if (source.split(before).length !== 2) throw new Error('codex-acp error reporting patch no longer matches the locked adapter');
64
+ source = source.replace(before, after);
65
+ }
66
+ return `import { codexFailureData } from ${JSON.stringify(import.meta.url)};\n${source.replace(/^#![^\n]*\n/, '')}`;
67
+ }
@@ -0,0 +1,45 @@
1
+ // Coda's session instructions (docs/agent-protocol.md「会话指令」) reach Codex as the thread's developer instructions, given on
2
+ // `thread/start` and `thread/resume`. codex-acp reads `session/new` and `session/load` through ACP's schema, which drops
3
+ // `systemPrompt`: the entry checks it and hands its text over in the request's `_meta` under INSTRUCTIONS (waits.mjs), which
4
+ // the schema keeps, and the patched adapter passes that on. They are appended to the developer instructions Codex's own
5
+ // configuration gives as seen from the session's directory (`config/read`), which the thread's would otherwise replace;
6
+ // without them Codex is told nothing. A collaboration mode codex-acp sets carries instructions of its own mode
7
+ // (`developer_instructions: null` is the mode's built-in ones), beside the thread's, not in place of them.
8
+ export const INSTRUCTIONS = 'coda/sessionInstructions';
9
+
10
+ // ACP's `systemPrompt`: its text, '' when there is none; throws on anything but text appended to the agent's own.
11
+ export function sessionInstructions(systemPrompt) {
12
+ if (systemPrompt === undefined || systemPrompt === null) return '';
13
+ if (systemPrompt.mode !== 'append') throw new Error('会话指令只支持 append 模式');
14
+ const { content } = systemPrompt;
15
+ if (!Array.isArray(content) || content.some(block => block?.type !== 'text' || typeof block.text !== 'string')) throw new Error('会话指令只接受文本块');
16
+ return content.map(block => block.text).join('\n\n');
17
+ }
18
+
19
+ // The thread's developer instructions for a session request: its own configuration's, then Coda's.
20
+ export async function codaDeveloperInstructions(codexClient, request) {
21
+ const ours = request._meta?.[INSTRUCTIONS];
22
+ if (!ours) return void 0;
23
+ const own = (await codexClient.configRead({ cwd: request.cwd, includeLayers: false }))?.config?.developer_instructions;
24
+ return own ? `${own}\n\n${ours}` : ours;
25
+ }
26
+
27
+ const given = ',\n developerInstructions: await codaDeveloperInstructions(this.codexClient, request)';
28
+ const patches = [
29
+ // newSession
30
+ [` modelProvider: this.getModelProvider(),
31
+ cwd: request.cwd
32
+ });`, before => before.replace('\n });', `${given}\n });`), 1],
33
+ // loadSession and resumeSession
34
+ [` modelProvider: await this.getResumeModelProvider(),
35
+ threadId: request.sessionId
36
+ });`, before => before.replace('\n });', `${given}\n });`), 2],
37
+ ];
38
+ // Fails loudly once the locked adapter changes.
39
+ export function instructedCodexThreads(source) {
40
+ for (const [before, after, count] of patches) {
41
+ if (source.split(before).length !== count + 1) throw new Error('codex-acp session instructions patch no longer matches the locked adapter');
42
+ source = source.replaceAll(before, after(before));
43
+ }
44
+ return `import { codaDeveloperInstructions } from ${JSON.stringify(import.meta.url)};\n${source.replace(/^#![^\n]*\n/, '')}`;
45
+ }
@@ -0,0 +1,42 @@
1
+ // A copy of Coda's backend/src/adapters/json-lines.mjs: an agent plugin's entry imports nothing of Coda's (docs/plugins.md「Agent」).
2
+ // Reads newline-delimited JSON from a stream. Only \n ends a message: readline also
3
+ // ends lines at a lone \r and at U+2028/U+2029, which JSON leaves unescaped inside
4
+ // strings, so a tool output carrying them would split one message into pieces.
5
+ import { EventEmitter } from 'node:events';
6
+ import { StringDecoder } from 'node:string_decoder';
7
+
8
+ // Emits 'line' for each message (a trailing \r is dropped) and 'close' once, when the
9
+ // input ends or closes or close() is called. Only a clean end delivers an unterminated
10
+ // last line; a destroyed input drops it. Input errors stay with the input's own handlers.
11
+ export function readLines(input) {
12
+ const reader = new EventEmitter(), decoder = new StringDecoder('utf8');
13
+ // The unfinished message stays in pieces until its \n arrives: appending each chunk to one
14
+ // string and searching it would copy a large message over and over.
15
+ let pending = [], closed = false;
16
+ const emit = line => reader.emit('line', line.endsWith('\r') ? line.slice(0, -1) : line);
17
+ const take = text => {
18
+ let start = 0, end;
19
+ while (!closed && (end = text.indexOf('\n', start)) >= 0) {
20
+ pending.push(text.slice(start, end)); start = end + 1;
21
+ const line = pending.join(''); pending = [];
22
+ emit(line);
23
+ }
24
+ if (!closed && start < text.length) pending.push(text.slice(start));
25
+ };
26
+ const data = chunk => take(typeof chunk === 'string' ? chunk : decoder.write(chunk));
27
+ const finish = () => {
28
+ if (closed) return;
29
+ take(decoder.end());
30
+ if (pending.length && !closed) { const rest = pending.join(''); pending = []; emit(rest); }
31
+ reader.close();
32
+ };
33
+ reader.close = () => {
34
+ if (closed) return;
35
+ closed = true;
36
+ input.off('data', data); input.off('end', finish); input.off('close', reader.close);
37
+ input.pause?.();
38
+ reader.emit('close');
39
+ };
40
+ input.on('data', data); input.on('end', finish); input.on('close', reader.close);
41
+ return reader;
42
+ }
@@ -0,0 +1,25 @@
1
+ // codex-acp 1.12.0 supplies its own sandbox policy on every turn, overriding CODEX_CONFIG's
2
+ // sandbox_workspace_write.network_access. Coda's automatic development mode permits networking
3
+ // inside workspace-write; filesystem escapes still use on-request and the native automatic reviewer.
4
+ // The explicit ask-for-approval mode keeps its network boundary. Fail loudly on an adapter upgrade.
5
+ const before = ` static Agent = new _AgentMode(
6
+ "agent",
7
+ "Approve for me",
8
+ "Only ask for actions detected as potentially unsafe",
9
+ "auto_review",
10
+ "on-request",
11
+ "auto_review",
12
+ {
13
+ type: "workspaceWrite",
14
+ writableRoots: [],
15
+ networkAccess: false,
16
+ excludeTmpdirEnvVar: false,
17
+ excludeSlashTmp: false
18
+ },
19
+ "workspace-write"
20
+ );`;
21
+
22
+ export function networkedCodexDevelopment(source) {
23
+ if (source.split(before).length !== 2) throw new Error('codex-acp network policy patch no longer matches the locked adapter');
24
+ return source.replace(before, before.replace('networkAccess: false', 'networkAccess: true'));
25
+ }
@@ -0,0 +1,67 @@
1
+ // Codex's speed is one ordinary ACP select option. The native model catalog decides which paid tiers are offered;
2
+ // their IDs travel unchanged to turn/start. `fast` is Codex's config alias for the catalog/request ID `priority`.
3
+ export const nativeServiceTier = tier => tier === 'fast' ? 'priority' : tier ?? 'default';
4
+ export function codexSpeedOption(model, tier) {
5
+ const tiers = model?.serviceTiers ?? [];
6
+ if (!tiers.length) return null;
7
+ return { id: 'service-tier', name: '速度', description: '加速生成会增加用量', category: 'model_config', type: 'select',
8
+ currentValue: nativeServiceTier(tier), options: [
9
+ { value: 'default', name: '标准', description: '标准速度与用量' },
10
+ ...tiers.filter(t => t.id !== 'default').map(t => ({ value: t.id,
11
+ name: t.id === 'priority' ? '加速' : t.id === 'ultrafast' ? '超高速' : t.name, description: t.description })),
12
+ ] };
13
+ }
14
+
15
+ // Patch the locked codex-acp's session state, config options and outgoing turn together. No boolean conversion may
16
+ // discard an Ultrafast (or future) tier when opening or restoring a session. A different model starts at its own default.
17
+ // The collaboration mode (plan before changing) is not offered; its `/plan` command remains (#1069).
18
+ export function codexOptionsAsShown(source) {
19
+ const patches = [
20
+ [' createCollaborationModeConfigOption(sessionState.collaborationMode),\n', '', 1],
21
+ [' const currentModelSupportsFast = modelSupportsFast(currentModel);\n', '', 2],
22
+ [' fastModeEnabled: sessionMetadata.currentServiceTier === "fast",\n currentModelSupportsFast,',
23
+ ' serviceTier: nativeServiceTier(sessionMetadata.currentServiceTier),', 2],
24
+ [` case FAST_MODE_CONFIG_ID:
25
+ this.applyFastModeChange(sessionState, params);
26
+ break;`, ` case "service-tier": {
27
+ const model = this.findCurrentModel(sessionState.availableModels, sessionState.currentModelId);
28
+ const option = codexSpeedOption(model, sessionState.serviceTier);
29
+ if (!option?.options.some(choice => choice.value === params.value)) throw RequestError.invalidParams();
30
+ sessionState.serviceTier = params.value;
31
+ break;
32
+ }`, 1],
33
+ [` applyFastModeChange(sessionState, params) {
34
+ const value = params.value;
35
+ if (typeof value === "boolean") {
36
+ sessionState.fastModeEnabled = value;
37
+ return;
38
+ }
39
+ if (value !== FAST_MODE_ON && value !== FAST_MODE_OFF) {
40
+ throw RequestError.invalidParams();
41
+ }
42
+ sessionState.fastModeEnabled = value === FAST_MODE_ON;
43
+ }
44
+ `, '', 1],
45
+ [' applyModelAndEffort(sessionState, model, effort) {', ` applyModelAndEffort(sessionState, model, effort) {
46
+ if (ModelId.fromString(sessionState.currentModelId).model !== model.model) {
47
+ sessionState.serviceTier = nativeServiceTier(model.defaultServiceTier);
48
+ }`, 1],
49
+ [' sessionState.currentModelSupportsFast = modelSupportsFast(model);\n', '', 1],
50
+ [` if (sessionState.currentModelSupportsFast) {
51
+ configOptions.push(createFastModeConfigOption(
52
+ sessionState.fastModeEnabled,
53
+ this.booleanConfigOptionsSupported
54
+ ));
55
+ }`, ` const speed = codexSpeedOption(currentModel, sessionState.serviceTier);
56
+ if (speed) configOptions.push(speed);`, 1],
57
+ [` const serviceTier = resolveFastServiceTier(
58
+ sessionState.fastModeEnabled,
59
+ sessionState.currentModelSupportsFast
60
+ );`, ' const serviceTier = sessionState.serviceTier === "default" ? null : sessionState.serviceTier;', 1],
61
+ ];
62
+ for (const [before, after, count] of patches) {
63
+ if (source.split(before).length !== count + 1) throw new Error('codex-acp config option patch no longer matches the locked adapter');
64
+ source = source.replaceAll(before, after);
65
+ }
66
+ return `import { codexSpeedOption, nativeServiceTier } from ${JSON.stringify(import.meta.url)};\n${source.replace(/^#![^\n]*\n/, '')}`;
67
+ }
@@ -0,0 +1,17 @@
1
+ // codex-acp 1.12.0 outlives its Codex app-server. When that child exits on its own, the adapter only
2
+ // fails background tasks: a started turn keeps waiting for a completion that never comes, and every
3
+ // later request fails against the dead child. Follow the child out instead, so the session host reports
4
+ // an agent exit and the backend settles the turn through its connection-lost path. A deliberate provider
5
+ // restart moves the generation first, and stays unaffected. Fails loudly once the adapter changes.
6
+ const before = ` if (generation !== this.codexProcessGeneration) return;
7
+ void this.finishAllAsyncTasks("failed", "after the Codex process exited");`;
8
+ // Exit only after stdout has flushed the failed background tasks.
9
+ const after = ` if (generation !== this.codexProcessGeneration) return;
10
+ void this.finishAllAsyncTasks("failed", "after the Codex process exited").finally(() => {
11
+ logger.log("Codex process exited; ending the adapter with it");
12
+ process.stdout.write("", () => process.exit(1));
13
+ });`;
14
+ export function followCodexProcessExit(source) {
15
+ if (source.split(before).length !== 2) throw new Error('codex-acp process exit patch no longer matches the locked adapter');
16
+ return source.replace(before, after);
17
+ }
@@ -0,0 +1,23 @@
1
+ // codex-acp speaks ACP over these streams instead of standard input and output (`relayedCodexStreams`), so that the entry
2
+ // stands between it and Coda without another process: what Coda says reaches it through `input`; what it says leaves
3
+ // through `output`, a line at a time, to whoever `listen`s, in the order said.
4
+ import { PassThrough, Writable } from 'node:stream';
5
+ import { StringDecoder } from 'node:string_decoder';
6
+
7
+ export const input = new PassThrough();
8
+ let heard = () => {}, pending = '';
9
+ const decoder = new StringDecoder('utf8');
10
+ const take = text => {
11
+ pending += text;
12
+ for (let end; (end = pending.indexOf('\n')) >= 0;) { const line = pending.slice(0, end); pending = pending.slice(end + 1); heard(line); }
13
+ };
14
+ // Each line is passed on before the write is done: what codex-acp waits on having said has reached the listener.
15
+ export const output = new Writable({ write(chunk, _, done) { take(decoder.write(chunk)); done(); }, final(done) { take(decoder.end()); done(); } });
16
+ export const listen = listener => { heard = listener; };
17
+
18
+ const before = ' const acpJsonStream = createJsonStream(process.stdin, process.stdout);';
19
+ // Fails loudly once the locked adapter changes.
20
+ export function relayedCodexStreams(source) {
21
+ if (source.split(before).length !== 2) throw new Error('codex-acp stream patch no longer matches the locked adapter');
22
+ return `import * as codaRelay from ${JSON.stringify(import.meta.url)};\n${source.replace(before, ' const acpJsonStream = createJsonStream(codaRelay.input, codaRelay.output);')}`;
23
+ }
@@ -0,0 +1,32 @@
1
+ // Coda's steering (docs/agent-protocol.md「插话」): codex-acp 1.12.0 declares `_session/steering` and, when no turn is going to
2
+ // take the message in, starts a turn of its own with it. Coda asks with `_meta.steering.idleBehavior: "promptRequired"` to be
3
+ // told so instead, and sends the message as the next prompt once the turn it meant to join has ended: then nothing is started
4
+ // and the answer is `promptRequired`. Without that behaviour codex-acp's own way is kept. Fails loudly once the adapter changes.
5
+ const patches = [
6
+ // codex-acp reads only the session and the prompt of the request: the behaviour asked for goes along.
7
+ [` return {
8
+ sessionId,
9
+ prompt
10
+ };
11
+ }
12
+ createSessionConfigOptions(sessionState) {`, ` return {
13
+ sessionId,
14
+ prompt,
15
+ idleBehavior: params["_meta"]?.steering?.idleBehavior
16
+ };
17
+ }
18
+ createSessionConfigOptions(sessionState) {`],
19
+ // No live turn, or one that ended before the message was injected.
20
+ [' return await this.startNewTurnFromSteering(params);\n', ` if (params.idleBehavior === "promptRequired") {
21
+ logger.log("Steering session found no running turn", { sessionId: params.sessionId });
22
+ return { outcome: "promptRequired", reason: "noRunningTurn" };
23
+ }
24
+ return await this.startNewTurnFromSteering(params);\n`],
25
+ ];
26
+ export function promptRequiredCodexSteering(source) {
27
+ for (const [before, after] of patches) {
28
+ if (source.split(before).length !== 2) throw new Error('codex-acp steering patch no longer matches the locked adapter');
29
+ source = source.replace(before, after);
30
+ }
31
+ return source;
32
+ }
@@ -0,0 +1,16 @@
1
+ // Patches to the locked codex-acp for native subagent sessions. Each fails loudly once the adapter changes.
2
+ const patches = [
3
+ // Codex continues a finished subagent with `followup_task`, which starts a new turn in its thread. The adapter
4
+ // reopens a finished subagent only for `sendInput` and `resumeAgent`, so it drops everything that run does as
5
+ // updates for a terminal subagent. Reopen it when its thread actually starts a turn: a queued `send_message`
6
+ // reports the same "interacted" activity but runs nothing, and must not open a run nobody ends.
7
+ [' if (notification.method === "turn/started") {\n', before => `${before} const codaThreadId = notification.params.threadId;
8
+ if (codaThreadId !== this.rootSessionId && (this.children.get(codaThreadId)?.terminalState !== void 0 || this.terminalPendingSpawns.has(codaThreadId))) await this.reopen(codaThreadId);\n`],
9
+ ];
10
+ export function continuedCodexSubagents(source) {
11
+ for (const [before, after] of patches) {
12
+ if (source.split(before).length !== 2) throw new Error('codex-acp subagent patch no longer matches the locked adapter');
13
+ source = source.replace(before, after(before));
14
+ }
15
+ return source;
16
+ }
@@ -0,0 +1,49 @@
1
+ // Title generation is advisory. Wait for its publication before completion, but
2
+ // never let a stalled generator or a cancelled prompt hold the session open.
3
+ export async function settleTitle(work, { signal, timeout = 15000 } = {}) {
4
+ if (signal?.aborted) return;
5
+ let timer, abort;
6
+ try {
7
+ await Promise.race([
8
+ Promise.resolve().then(work).catch(() => {}),
9
+ new Promise(resolve => {
10
+ timer = setTimeout(resolve, timeout);
11
+ abort = resolve;
12
+ signal?.addEventListener('abort', abort, { once: true });
13
+ }),
14
+ ]);
15
+ } finally {
16
+ clearTimeout(timer);
17
+ signal?.removeEventListener('abort', abort);
18
+ }
19
+ }
20
+
21
+ // codex-acp 1.12.0 starts title generation without returning its promise. Keep
22
+ // this compatibility patch scoped to the locked bundle; fail visibly if its
23
+ // implementation changes, rather than silently restoring completion races.
24
+ export function orderedCodexTitles(source) {
25
+ // Once execution has finished, stopping the advisory title wait must not
26
+ // turn the completed prompt or its file-change report into a cancellation.
27
+ const replacements = [
28
+ [' let promptWasCancelled = false;',
29
+ ' let promptWasCancelled = false;\n let codaExecutionCompleted = false;'],
30
+ [' if (sessionState.titleGen) {\n const promptText',
31
+ ' codaExecutionCompleted = true;\n if (sessionState.titleGen) {\n const promptText'],
32
+ [' if (promptWasCancelled || activePrompt.signal.aborted || this.sessionIsClosing(params.sessionId)) {',
33
+ ' if (promptWasCancelled || (!codaExecutionCompleted && (activePrompt.signal.aborted || this.sessionIsClosing(params.sessionId)))) {'],
34
+ [' this.generateAndPersist(userPromptText).catch(() => {',
35
+ ' return this.generateAndPersist(userPromptText).catch(() => {'],
36
+ [' sessionState.titleGen.onTurnCompleted(promptText);',
37
+ ` await settleTitle(async () => {
38
+ await sessionState.titleGen.onTurnCompleted(promptText);
39
+ await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
40
+ }, { signal: activePrompt.signal });`],
41
+ [' sessionState.sessionTitle = title;\n sessionState.sessionTitleSource = "fallback";',
42
+ ' title = Array.from(title.replace(/\\s+/g, " ").trim()).slice(0, 80).join("");\n sessionState.sessionTitle = title;\n sessionState.sessionTitleSource = "fallback";'],
43
+ ];
44
+ for (const [before, after] of replacements) {
45
+ if (source.split(before).length !== 2) throw new Error('codex-acp title ordering patch no longer matches the locked adapter');
46
+ source = source.replace(before, after);
47
+ }
48
+ return `import { settleTitle } from ${JSON.stringify(import.meta.url)};\n${source.replace(/^#![^\n]*\n/, '')}`;
49
+ }
@@ -0,0 +1,105 @@
1
+ // What codex-acp says of the work it has going beside a turn, in JetBrains AIR (its subagents and the commands it moved to
2
+ // the background), said to Coda as waits of its own (docs/agent-protocol.md「_coda/wait」); and a stop Coda asks of one, asked
3
+ // of codex-acp the AIR way. A continued subagent is another session to codex-acp (`X:generation:N`) and the same one to
4
+ // Coda: every session it names to Coda is `X`, and each run of it a wait of its own. Coda's session instructions are checked
5
+ // here and handed to the patched codex-acp in the request's `_meta` (instructions.mjs).
6
+ import { INSTRUCTIONS, sessionInstructions } from './instructions.mjs';
7
+
8
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
9
+ const string = value => typeof value === 'string' && value.length > 0;
10
+ const base = id => id.replace(/:generation:\d+$/, '');
11
+ const SUBAGENT_END = { completed: 'done', failed: 'failed', cancelled: 'cancelled', disconnected: 'failed' };
12
+ const TASK_END = { completed: 'done', failed: 'failed', stopped: 'cancelled' };
13
+ const AIR = { version: 1, capabilities: ['nativeSubagentSessions', 'asyncTasks'] };
14
+
15
+ export class CodexWaits {
16
+ // `agentInfo`: the Codex it runs, said in place of codex-acp's own name and version.
17
+ constructor({ agentInfo = null } = {}) {
18
+ // The root session; Coda's requests whose answers are read here, by id; the waits said, by id.
19
+ this.root = null; this.asked = new Map(); this.waits = new Map(); this.agentInfo = agentInfo;
20
+ }
21
+ // What Coda says: `agent`, what codex-acp is told; `coda`, what Coda is answered here.
22
+ fromCoda(message) {
23
+ if (message.method === 'initialize' && record(message.params)) {
24
+ this.asked.set(message.id, 'initialize');
25
+ // Without native subagent sessions codex-acp drops a delegated thread's own events and only reports start and end;
26
+ // without async tasks it does not say which commands it moved to the background.
27
+ const capabilities = record(message.params.clientCapabilities) ? message.params.clientCapabilities : {};
28
+ const meta = record(capabilities._meta) ? capabilities._meta : {};
29
+ message = { ...message, params: { ...message.params, clientCapabilities: { ...capabilities, _meta: { ...meta, jetbrains: { air: AIR } } } } };
30
+ } else if (message.method === '_coda/wait/stop' && 'id' in message) return this.stop(message);
31
+ if (['session/new', 'session/load'].includes(message.method) && record(message.params)) {
32
+ let instructions;
33
+ try { instructions = sessionInstructions(message.params.systemPrompt); }
34
+ catch (error) { return { agent: [], coda: [{ jsonrpc: '2.0', id: message.id, error: { code: -32602, message: error.message } }] }; }
35
+ const { systemPrompt, ...params } = message.params, meta = record(params._meta) ? params._meta : {};
36
+ message = { ...message, params: instructions ? { ...params, _meta: { ...meta, [INSTRUCTIONS]: instructions } } : params };
37
+ }
38
+ if (message.method === 'session/new') this.asked.set(message.id, 'session');
39
+ else if (message.method === 'session/load' && string(message.params?.sessionId)) this.root = message.params.sessionId;
40
+ return { agent: [message], coda: [] };
41
+ }
42
+ // A command is stopped through the session that keeps it, the root; a subagent by cancelling its session.
43
+ stop({ id, params }) {
44
+ const wait = this.waits.get(params?.waitId);
45
+ if (wait?.stoppable && wait.asyncTaskId) {
46
+ this.asked.set(id, 'stop');
47
+ return { agent: [{ jsonrpc: '2.0', id, method: '_session/async_task/stop', params: { sessionId: this.root, asyncTaskId: wait.asyncTaskId } }], coda: [] };
48
+ }
49
+ if (wait?.stoppable) return { agent: [{ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId: wait.subagentSessionId } }], coda: [{ jsonrpc: '2.0', id, result: {} }] };
50
+ return { agent: [], coda: [{ jsonrpc: '2.0', id, error: { code: -32602, message: '没有可以停止的等待项' } }] };
51
+ }
52
+ // What codex-acp says, as said to Coda.
53
+ fromAgent(message) {
54
+ if (!record(message)) return [];
55
+ if ('id' in message && !('method' in message)) return [this.answer(message)];
56
+ if (record(message.params) && string(message.params.sessionId)) message = { ...message, params: { ...message.params, sessionId: base(message.params.sessionId) } };
57
+ if (message.method !== 'session/update' || !record(message.params?.update)) return [message];
58
+ const { sessionId, update: u } = message.params;
59
+ const said = params => [{ jsonrpc: '2.0', method: '_coda/wait', params: { sessionId, ...params } }];
60
+ switch (u.sessionUpdate) {
61
+ case 'subagent_spawned': {
62
+ if (!string(u.subagentSessionId)) return [];
63
+ const stoppable = u.capabilities?.cancel === true;
64
+ this.waits.set(u.subagentSessionId, { subagentSessionId: u.subagentSessionId, stoppable });
65
+ return said({ waitId: u.subagentSessionId, kind: 'subagent', state: 'waiting', title: cut(u.name, 500), detail: cut(u.task, 4000),
66
+ childSessionId: base(u.subagentSessionId), stoppable });
67
+ }
68
+ case 'subagent_state_update':
69
+ return string(u.subagentSessionId) && SUBAGENT_END[u.state] ? said({ waitId: u.subagentSessionId, state: SUBAGENT_END[u.state] }) : [];
70
+ case 'async_task_spawned': {
71
+ if (!string(u.asyncTaskId)) return [];
72
+ const stoppable = u.canStop === true;
73
+ this.waits.set(u.asyncTaskId, { asyncTaskId: u.asyncTaskId, stoppable });
74
+ return said({ waitId: u.asyncTaskId, kind: 'task', state: 'waiting', title: cut(u.name, 500), stoppable,
75
+ ...(string(u.toolCallId) ? { toolCallId: u.toolCallId } : {}) });
76
+ }
77
+ case 'async_task_state_update':
78
+ return string(u.asyncTaskId) && TASK_END[u.state] ? said({ waitId: u.asyncTaskId, state: TASK_END[u.state] }) : [];
79
+ case 'async_task_progress': return [];
80
+ case 'tool_call_update': {
81
+ // That a call's command went to the background is told by its wait.
82
+ if (!record(u._meta?.jetbrains?.air?.asyncTasks)) return [message];
83
+ const { asyncTasks, ...air } = u._meta.jetbrains.air;
84
+ return [{ ...message, params: { ...message.params, update: { ...u, _meta: { ...u._meta, jetbrains: { ...u._meta.jetbrains, air } } } } }];
85
+ }
86
+ }
87
+ return [message];
88
+ }
89
+ answer(message) {
90
+ const asked = this.asked.get(message.id);
91
+ if (!asked) return message;
92
+ this.asked.delete(message.id);
93
+ if (asked === 'stop') return { jsonrpc: '2.0', id: message.id, ...('error' in message ? { error: message.error } : { result: {} }) };
94
+ if (!record(message.result)) return message;
95
+ if (asked === 'session' && string(message.result.sessionId)) this.root = message.result.sessionId;
96
+ if (asked !== 'initialize') return message;
97
+ // It reports its waits and takes Coda's session instructions; codex-acp itself declares steering (`_meta.steering`).
98
+ const capabilities = record(message.result.agentCapabilities) ? message.result.agentCapabilities : {};
99
+ const meta = record(capabilities._meta) ? capabilities._meta : {};
100
+ const sessions = record(capabilities.sessionCapabilities) ? capabilities.sessionCapabilities : {};
101
+ return { ...message, result: { ...message.result, ...(this.agentInfo ? { agentInfo: this.agentInfo } : {}),
102
+ agentCapabilities: { ...capabilities, sessionCapabilities: { ...sessions, systemPrompt: {} }, _meta: { ...meta, coda: { waits: {} } } } } };
103
+ }
104
+ }
105
+ const cut = (value, limit) => typeof value === 'string' ? value.slice(0, limit) : undefined;
@@ -0,0 +1,15 @@
1
+ # Codex 品牌图形
2
+
3
+ 仅用于标识会话所用的服务,不作为 Coda 自身标志。商标归各自权利人所有。
4
+
5
+ - `openai.svg`:取自 [OpenAI 品牌指南](https://openai.com/brand/) 展示的 [Blossom_Light.svg](https://images.ctfassets.net/kftzwdyauwt9/3hUGLn3ypllZ0oa01qOYVq/28e8188e6f11b84c3e876569d492734f/Blossom_Light.svg) 中的完整黑色 Blossom 路径。未包含指南的网格、尺寸辅助线和重复示例;保留路径几何。按产品约定用于 Codex。
6
+
7
+ 提取日期:2026-09-19。SVG 是可审阅的源文件,PDF 是由 CairoSVG 转换的等比矢量资源,清单的 `icon` 指向 PDF。重新生成 PDF(开发工具,不是构建或运行依赖):
8
+
9
+ ```sh
10
+ uv run --with cairosvg python - <<'PY'
11
+ import cairosvg
12
+ for name in ['openai']:
13
+ cairosvg.svg2pdf(url=f'{name}.svg', write_to=f'{name}.pdf')
14
+ PY
15
+ ```
Binary file
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="267.198" height="264.812" viewBox="146.694 227.042 267.198 264.812">
2
+ <path fill="black" d="M249.176 323.434V298.276C249.176 296.158 249.971 294.569 251.825 293.509L302.406 264.381C309.29 260.409 317.5 258.555 325.973 258.555C357.75 258.555 377.877 283.185 377.877 309.399C377.877 311.253 377.877 313.371 377.611 315.49L325.178 284.771C322.001 282.919 318.822 282.919 315.645 284.771L249.176 323.434ZM367.283 421.415V361.301C367.283 357.592 365.694 354.945 362.516 353.092L296.048 314.43L317.763 301.982C319.617 300.925 321.206 300.925 323.058 301.982L373.639 331.112C388.205 339.586 398.003 357.592 398.003 375.069C398.003 395.195 386.087 413.733 367.283 421.412V421.415ZM233.553 368.452L211.838 355.742C209.986 354.684 209.19 353.095 209.19 350.975V292.718C209.19 264.383 230.905 242.932 260.301 242.932C271.423 242.932 281.748 246.641 290.49 253.26L238.321 283.449C235.146 285.303 233.555 287.951 233.555 291.659V368.455L233.553 368.452ZM280.292 395.462L249.176 377.985V340.913L280.292 323.436L311.407 340.913V377.985L280.292 395.462ZM300.286 475.968C289.163 475.968 278.837 472.259 270.097 465.64L322.264 435.449C325.441 433.597 327.03 430.949 327.03 427.239V350.445L349.011 363.155C350.865 364.213 351.66 365.802 351.66 367.922V426.179C351.66 454.514 329.679 475.965 300.286 475.965V475.968ZM237.525 416.915L186.944 387.785C172.378 379.31 162.582 361.305 162.582 343.827C162.582 323.436 174.763 305.164 193.563 297.485V357.861C193.563 361.571 195.154 364.217 198.33 366.071L264.535 404.467L242.82 416.915C240.967 417.972 239.377 417.972 237.525 416.915ZM234.614 460.343C204.689 460.343 182.71 437.833 182.71 410.028C182.71 407.91 182.976 405.792 183.238 403.672L235.405 433.863C238.582 435.715 241.763 435.715 244.938 433.863L311.407 395.466V420.622C311.407 422.742 310.612 424.331 308.758 425.389L258.179 454.519C251.293 458.491 243.083 460.343 234.611 460.343H234.614ZM300.286 491.854C332.329 491.854 359.073 469.082 365.167 438.892C394.825 431.211 413.892 403.406 413.892 375.073C413.892 356.535 405.948 338.529 391.648 325.552C392.972 319.991 393.766 314.43 393.766 308.87C393.766 271.003 363.048 242.666 327.562 242.666C320.413 242.666 313.528 243.723 306.644 246.109C294.725 234.457 278.307 227.042 260.301 227.042C228.258 227.042 201.513 249.815 195.42 280.004C165.761 287.685 146.694 315.49 146.694 343.824C146.694 362.362 154.638 380.368 168.938 393.344C167.613 398.906 166.819 404.467 166.819 410.027C166.819 447.894 197.538 476.231 233.024 476.231C240.172 476.231 247.058 475.173 253.943 472.788C265.859 484.441 282.278 491.854 300.286 491.854Z"/>
3
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,78 @@
1
1
  {
2
2
  "name": "@coda-console/codex",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "id": "codex",
4
+ "displayName": "Codex",
5
+ "description": "Codex Agent:Coda 自带的入口运行 codex-acp,报告子 Agent 与后台命令。额度与登录由账号来源插件接管;未接入账号来源时使用 Codex 自己的登录面板。",
6
+ "version": "1.6.1",
7
+ "dependencies": {
8
+ "@agentclientprotocol/codex-acp": "1.12.0",
9
+ "@openai/codex": "0.162.1"
10
+ },
11
+ "overrides": {
12
+ "@agentclientprotocol/codex-acp": {
13
+ "@openai/codex": "$@openai/codex"
14
+ }
15
+ },
16
+ "contributes": {
17
+ "agents": [
18
+ {
19
+ "id": "codex",
20
+ "displayName": "Codex",
21
+ "icon": {
22
+ "light": "icons/openai.pdf",
23
+ "template": true
24
+ },
25
+ "command": "exec \"$CODA_NODE\" \"$CODA_PLUGIN_PATH/agent/entry.mjs\"",
26
+ "modes": {
27
+ "title": "审批模式",
28
+ "unset": "人工审批",
29
+ "auto": "agent",
30
+ "full": "agent-full-access"
31
+ },
32
+ "skillsDir": ".agents/skills",
33
+ "configFiles": [
34
+ "${CODEX_HOME:-~/.codex}/config.toml",
35
+ ".codex/config.toml"
36
+ ],
37
+ "setupHint": "请在运行机为后端所属账号完成 Codex 认证。"
38
+ }
39
+ ],
40
+ "panels": [
41
+ {
42
+ "id": "login",
43
+ "title": "Codex",
44
+ "command": "node \"$CODA_PLUGIN_PATH/scripts/panel.mjs\"",
45
+ "maxAge": "PT0S",
46
+ "actions": {
47
+ "reset": {
48
+ "command": "node \"$CODA_PLUGIN_PATH/scripts/reset.mjs\"",
49
+ "confirm": "使用一次完全重置?\n用最早到期的那一次。成功后消耗 1 次,无法撤销。"
50
+ }
51
+ },
52
+ "signIn": {
53
+ "command": "NO_COLOR=1 exec node \"$CODA_PLUGIN_PATH/scripts/cli.mjs\" login --device-auth",
54
+ "note": "接入账号插件后,登录会添加或更新插件管理的账号。"
55
+ }
56
+ }
57
+ ],
58
+ "extensionPoints": {
59
+ "account-source": {
60
+ "description": "Codex 的账号与登录入口:来源给出当前账号和令牌,响应 --login,并提供账号与额度面板;接入后 Codex 自带的额度面板隐藏。"
61
+ }
62
+ }
63
+ },
64
+ "license": "MIT",
65
+ "repository": {
66
+ "type": "git",
67
+ "url": "git+https://github.com/coda-console/coda-plugins.git",
68
+ "directory": "plugins/codex"
69
+ },
70
+ "publishConfig": {
71
+ "access": "public"
72
+ },
73
+ "files": [
74
+ "agent",
75
+ "scripts",
76
+ "icons"
77
+ ]
78
+ }
@@ -0,0 +1,61 @@
1
+ // A private connection to the Codex CLI's app-server, as this machine's Codex sessions are signed in: the plugin's own CLI
2
+ // (its `@openai/codex` dependency, the one codex-acp runs) in its own home (`CODEX_HOME`, or ~/.codex).
3
+ // Codex reads and renews that login itself; nothing of it passes through here, unless a login is handed to it (the account
4
+ // bridge, agent/accounts.mjs, which also takes what Codex says and asks of its client: `onMessage`).
5
+ import { spawn } from 'node:child_process';
6
+ import { readLines } from '../agent/lines.mjs';
7
+
8
+ export class AppServer {
9
+ constructor({ env = process.env, command = [process.execPath, new URL(import.meta.resolve('@openai/codex/bin/codex.js')).pathname, 'app-server'],
10
+ timeoutMs = 15_000, onMessage = null } = {}) {
11
+ this.pending = new Map(); this.next = 0; this.timeoutMs = timeoutMs; this.onMessage = onMessage;
12
+ this.child = spawn(command[0], command.slice(1), { env, stdio: ['pipe', 'pipe', 'ignore'] });
13
+ this.child.stdin.on('error', () => {});
14
+ readLines(this.child.stdout).on('line', line => {
15
+ let message; try { message = JSON.parse(line); } catch { return; }
16
+ const waiter = message.method ? null : this.pending.get(message.id);
17
+ if (!waiter) {
18
+ if (this.onMessage) return this.onMessage(message);
19
+ // Codex asking for something of its client (new tokens for a login it was handed): this one has none to give.
20
+ if (message.id !== undefined && message.method) this.send({ id: message.id, error: { code: -32601, message: 'not supported' } });
21
+ return;
22
+ }
23
+ this.pending.delete(message.id); clearTimeout(waiter.timer);
24
+ if (message.error) waiter.reject(new Error(oneLine(message.error.message) || 'Codex 拒绝了请求'));
25
+ else waiter.resolve(message.result);
26
+ });
27
+ this.exited = new Promise(resolve => {
28
+ const done = () => {
29
+ for (const waiter of this.pending.values()) { clearTimeout(waiter.timer); waiter.reject(new Error('Codex 提前退出了')); }
30
+ this.pending.clear(); resolve();
31
+ };
32
+ this.child.once('error', done); this.child.once('exit', done);
33
+ });
34
+ }
35
+ send(message) { this.child.stdin.write(`${JSON.stringify(message)}\n`); }
36
+ request(method, params = {}) {
37
+ const id = `coda-${process.pid}-${++this.next}`;
38
+ return new Promise((resolve, reject) => {
39
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('Codex 没有答复')); }, this.timeoutMs);
40
+ this.pending.set(id, { resolve, reject, timer });
41
+ this.send({ id, method, params });
42
+ });
43
+ }
44
+ async initialize() {
45
+ await this.request('initialize', { clientInfo: { name: 'coda_panel', version: '1' }, capabilities: { experimentalApi: true } });
46
+ this.send({ method: 'initialized' });
47
+ }
48
+ async close() {
49
+ this.child.kill('SIGTERM');
50
+ const timer = setTimeout(() => this.child.kill('SIGKILL'), 2000);
51
+ await this.exited; clearTimeout(timer);
52
+ }
53
+ }
54
+ const oneLine = value => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, 200) : '';
55
+
56
+ // Runs `work` against a started app-server, which is always ended after.
57
+ export async function withAppServer(work, options) {
58
+ const server = new AppServer(options);
59
+ try { await server.initialize(); return await work(server); }
60
+ finally { await server.close(); }
61
+ }
@@ -0,0 +1,11 @@
1
+ // Once an account source owns the accounts, even the pre-existing Codex login entry adds to its managed store.
2
+ import { spawn } from 'node:child_process';
3
+ import { accountSource, sourceEnv } from '../agent/account-source.mjs';
4
+
5
+ const source = accountSource();
6
+ if (source && process.argv[2] === 'login') {
7
+ const child = spawn('/bin/sh', ['-c', `${source.command} --login`], { env: sourceEnv(source), stdio: 'inherit' });
8
+ process.on('SIGTERM', () => child.kill('SIGTERM')); process.on('SIGINT', () => child.kill('SIGINT'));
9
+ child.on('error', () => { console.error('无法启动账号插件的登录'); process.exitCode = 1; });
10
+ child.on('close', code => { process.exitCode = code ?? 1; });
11
+ } else await import('@openai/codex/bin/codex.js');
@@ -0,0 +1,7 @@
1
+ // An account source owns its quota panel. Without one, show Codex's own login as before.
2
+ import { withAppServer } from './app-server.mjs';
3
+ import { panel } from './quota.mjs';
4
+ import { accountSource } from '../agent/account-source.mjs';
5
+
6
+ try { console.log(JSON.stringify(accountSource() ? { items: [] } : await withAppServer(panel))); }
7
+ catch (error) { console.error(error.message); process.exit(1); }
@@ -0,0 +1,85 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // What the Codex panel shows of a login (docs/plugins.md「内置插件:Codex」), from what Codex's app-server says of it:
4
+ // `account/read` and `account/rateLimits/read`. Kept apart from the commands so the tests can hand it answers.
5
+
6
+ const CARD_ITEMS = 20;
7
+ const line = (value, max = 80) => typeof value === 'string' ? [...value.replace(/\s+/g, ' ').trim()].slice(0, max).join('') : '';
8
+ // The account a card shows, compared across machines by Coda's group view (its panel cards' `identity`): who signed in
9
+ // and in which ChatGPT workspace, as the quota is one person's in one workspace (one email may sign in to several, and a
10
+ // workspace has many members). A hash of the email and of the workspace's account ID hashed as codex-account keeps it;
11
+ // none where Codex does not say both.
12
+ const hash = text => createHash('sha256').update(text).digest('hex');
13
+ const identity = (account, workspace) => {
14
+ const email = line(account.email, 320).toLowerCase();
15
+ return email && typeof workspace === 'string' && workspace ? { identity: `codex:${hash(`${email}\n${hash(workspace)}`)}` } : {};
16
+ };
17
+ const unixTime = value => typeof value === 'number' && Number.isFinite(new Date(value * 1000).getTime()) ? new Date(value * 1000).toISOString() : null;
18
+ // A credit's expiry as the clients write times (UTC+8), here because a figure's value is text.
19
+ export function utc8(seconds) {
20
+ const d = new Date(seconds * 1000 + 8 * 3600_000);
21
+ return `${d.getUTCFullYear()}/${String(d.getUTCMonth() + 1).padStart(2, '0')}/${String(d.getUTCDate()).padStart(2, '0')} ${d.getUTCHours()}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
22
+ }
23
+
24
+ // A bar per window of each limit, of what is left. A limit's name is said only when there are several.
25
+ export function windows(result) {
26
+ const byID = result?.rateLimitsByLimitId ? Object.values(result.rateLimitsByLimitId) : [];
27
+ const limits = (byID.length ? byID : [result?.rateLimits]).filter(Boolean);
28
+ return limits.flatMap(limit => ['primary', 'secondary'].flatMap(key => {
29
+ const w = limit[key]; if (!w) return [];
30
+ const minutes = w.windowDurationMins;
31
+ const duration = minutes === 300 ? '5 小时' : minutes === 10080 ? '7 天' : Number.isSafeInteger(minutes) && minutes > 0 ? `${minutes} 分钟` : '额度';
32
+ const used = typeof w.usedPercent === 'number' && w.usedPercent >= 0 && w.usedPercent <= 100 ? w.usedPercent : null;
33
+ const left = used === null ? null : Math.round(100 - used), resets = unixTime(w.resetsAt);
34
+ return [{ type: 'meter', label: `${limits.length > 1 ? `${line(limit.limitName ?? limit.limitId, 40) || '额度'} · ` : ''}${duration}`,
35
+ ...(left === null ? {} : { value: `剩余 ${left}%`, fraction: left / 100 }),
36
+ ...(resets ? { time: resets, timeLabel: '重置' } : {}) }];
37
+ }));
38
+ }
39
+
40
+ // The full resets left: the count is Codex's word (its list of them may be capped or missing). Those listed are the
41
+ // available ones, earliest to expire first; one without an expiry never expires.
42
+ export function credits(result) {
43
+ const summary = result?.rateLimitResetCredits;
44
+ if (!Number.isSafeInteger(summary?.availableCount)) return null;
45
+ const listed = (summary.credits ?? []).filter(c => c?.status === 'available' && c.resetType === 'codexRateLimits' && typeof c.id === 'string' && c.id)
46
+ .sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
47
+ return { count: summary.availableCount, listed };
48
+ }
49
+
50
+ // The login's card: its button for a full reset (the one to expire first, named in its question), its windows, then its
51
+ // resets one line each.
52
+ export function card(account, result, workspace) {
53
+ const name = line(account.email) || '本机登录';
54
+ const items = windows(result), resets = credits(result);
55
+ if (resets) {
56
+ const first = resets.listed[0];
57
+ const lines = resets.listed.map((c, i) => ({ type: 'stat', label: `第 ${i + 1} 次`, value: c.expiresAt == null ? '不会过期' : `${utc8(c.expiresAt)} 到期` }));
58
+ const room = CARD_ITEMS - items.length - (first ? 2 : 1);
59
+ items.push({ type: 'stat', label: '完全重置', value: `剩余 ${resets.count} 次` }, ...lines.slice(0, Math.max(0, room)));
60
+ if (first && resets.count > 0) items.unshift({ type: 'button', label: '使用完全重置', action: 'reset', args: { creditId: first.id },
61
+ confirm: `使用 ${line(name, 60)} 的完全重置?\n用最早到期的那一次(${first.expiresAt == null ? '不会过期' : `${utc8(first.expiresAt)} 到期,UTC+8`})。成功后消耗 1 次,无法撤销。` });
62
+ }
63
+ if (!items.length) items.push({ type: 'error', text: '暂时无法获取额度:Codex 没有给出额度窗口' });
64
+ return { type: 'card', title: name, ...(line(account.planType) ? { subtitle: line(account.planType) } : {}), ...identity(account, workspace), items: items.slice(0, CARD_ITEMS) };
65
+ }
66
+
67
+ // The panel: nothing without a login (it is then offered under「添加资源」), a card for one. Quota Codex cannot read stays
68
+ // on the card, which keeps「重新登录」 at hand; one not signed in with ChatGPT has no windows to read.
69
+ export async function panel(server) {
70
+ const { account, workspaceRouting } = await server.request('account/read', { refreshToken: false });
71
+ const workspace = workspaceRouting?.chatgptAccountId;
72
+ if (!account) return { items: [] };
73
+ if (account.type !== 'chatgpt') return { items: [{ type: 'card', title: '本机登录', subtitle: line(account.type),
74
+ items: [{ type: 'markdown', text: '不是用 ChatGPT 账号登录的,没有额度可显示' }] }] };
75
+ let result;
76
+ try { result = await server.request('account/rateLimits/read'); }
77
+ catch (error) {
78
+ return { items: [{ type: 'card', title: line(account.email) || '本机登录', ...(line(account.planType) ? { subtitle: line(account.planType) } : {}), ...identity(account, workspace),
79
+ items: [{ type: 'error', text: `暂时无法获取额度:${error.message}` }] }] };
80
+ }
81
+ return { items: [card(account, result, workspace)] };
82
+ }
83
+
84
+ // What a full reset came to, as one line for the panel when it did not reset anything.
85
+ export const outcomes = { nothingToReset: '当前额度无需重置', alreadyRedeemed: '这次重置已被使用', noCredit: '这次重置已不可用' };
@@ -0,0 +1,15 @@
1
+ // The panel's「使用完全重置」: redeems exactly the credit its button named (without one Codex would pick for itself), once.
2
+ // Anything but a reset is said in one line and fails the action, so the panel shows it.
3
+ import { randomUUID } from 'node:crypto';
4
+ import { withAppServer } from './app-server.mjs';
5
+ import { outcomes } from './quota.mjs';
6
+ import { accountSource } from '../agent/account-source.mjs';
7
+
8
+ if (accountSource()) { console.error('额度已由账号插件接管,请在账号面板使用完全重置'); process.exit(1); }
9
+
10
+ const { creditId } = JSON.parse(process.env.CODA_PANEL_ARGS || '{}');
11
+ if (typeof creditId !== 'string' || !creditId) { console.error('没有指明用哪一次完全重置'); process.exit(1); }
12
+ let outcome;
13
+ try { ({ outcome } = await withAppServer(server => server.request('account/rateLimitResetCredit/consume', { creditId, idempotencyKey: randomUUID() }))); }
14
+ catch (error) { console.error(`使用结果未确认,请核对剩余次数:${error.message}`); process.exit(1); }
15
+ if (outcome !== 'reset') { console.error(outcomes[outcome] ?? '使用结果未确认,请核对剩余次数'); process.exit(1); }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.