@coda-console/codex 1.6.1 → 1.7.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/agent/entry.mjs CHANGED
@@ -1,9 +1,9 @@
1
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
2
  // patches, which fail the start once the adapter changes. Between it and Coda the entry speaks Coda's protocol for it
3
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).
4
+ // and a steer that finds no turn going is answered `promptRequired` (steering.mjs); the models of the model service chosen in
5
+ // Coda join Codex's own (providers.mjs). What Coda wants of Codex beyond ACP is set here: the mode a session starts in, and
6
+ // which Codex program runs, through the account bridge when another plugin chooses the account (accounts.mjs).
7
7
  import { createRequire, registerHooks } from 'node:module';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { createHash } from 'node:crypto';
@@ -19,6 +19,7 @@ import { networkedCodexDevelopment } from './network.mjs';
19
19
  import { codexOptionsAsShown } from './options.mjs';
20
20
  import { instructedCodexThreads } from './instructions.mjs';
21
21
  import { promptRequiredCodexSteering } from './steering.mjs';
22
+ import { servedCodexModels } from './providers.mjs';
22
23
  import { input, listen, relayedCodexStreams } from './relay.mjs';
23
24
  import { CodexWaits } from './waits.mjs';
24
25
  import { explainCodexError, reportedCodexFailures } from './errors.mjs';
@@ -62,6 +63,6 @@ registerHooks({ load(url, context, nextLoad) {
62
63
  const loaded = nextLoad(url, context);
63
64
  if (url !== adapter.href) return loaded;
64
65
  return { ...loaded, source: reportedCodexFailures(codexOptionsAsShown(relayedCodexStreams(orderedCodexTitles(followCodexProcessExit(continuedCodexSubagents(
65
- promptRequiredCodexSteering(instructedCodexThreads(networkedCodexDevelopment(Buffer.from(loaded.source).toString('utf8')))))))))) };
66
+ promptRequiredCodexSteering(instructedCodexThreads(servedCodexModels(networkedCodexDevelopment(Buffer.from(loaded.source).toString('utf8'))))))))))) };
66
67
  } });
67
68
  await import(adapter.href);
@@ -16,9 +16,17 @@ export function sessionInstructions(systemPrompt) {
16
16
  return content.map(block => block.text).join('\n\n');
17
17
  }
18
18
 
19
+ // Coda's instructions for the session this process runs (Coda runs one a process), as it last opened it; codex-acp's own resume
20
+ // of its thread as the session moves to another model provider (providers.mjs, `carriedInstructions`) carries them on.
21
+ let opened, carrying = false;
22
+ export async function carriedInstructions(work) {
23
+ carrying = true;
24
+ try { return await work(); } finally { carrying = false; }
25
+ }
19
26
  // The thread's developer instructions for a session request: its own configuration's, then Coda's.
20
27
  export async function codaDeveloperInstructions(codexClient, request) {
21
- const ours = request._meta?.[INSTRUCTIONS];
28
+ if (!carrying) opened = request._meta?.[INSTRUCTIONS];
29
+ const ours = opened;
22
30
  if (!ours) return void 0;
23
31
  const own = (await codexClient.configRead({ cwd: request.cwd, includeLayers: false }))?.config?.developer_instructions;
24
32
  return own ? `${own}\n\n${ours}` : ours;
@@ -0,0 +1,111 @@
1
+ // The model service chosen in Coda (`codex.modelProvider`, Coda's docs/plugins.md「模型服务」): another plugin's service that
2
+ // speaks the OpenAI Responses API (Codex no longer speaks Chat Completions), from CODA_MODEL_PROVIDERS. The models its address
3
+ // lists (`<地址>/models`) join Codex's own, each named by the service and the model. Codex binds a thread to one model
4
+ // provider, so a turn on another provider's model first moves the session there the way codex-acp moves it for ACP's
5
+ // `providers/set` and `providers/disable`: its app-server starts again with the service as the provider of its OpenAI slot
6
+ // (or without it), and the session's thread is resumed on it. The machine's Codex configuration is not touched.
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { carriedInstructions } from './instructions.mjs';
9
+
10
+ export const SETTING = 'codex.modelProvider';
11
+ const log = text => process.stderr.write(`[codex-agent] ${text}\n`);
12
+ const parse = text => { try { return JSON.parse(text || '{}'); } catch { return {}; } };
13
+ const LIST_TIMEOUT = 8000;
14
+
15
+ // The chosen service: its address, title and key (read from the file Coda keeps it in). Null when none is chosen, or the
16
+ // one chosen is not on this machine or speaks another API.
17
+ export function chosenService(env = process.env) {
18
+ const ref = parse(env.CODA_PLUGIN_SETTINGS)[SETTING];
19
+ if (!ref) return null;
20
+ const provided = parse(env.CODA_MODEL_PROVIDERS)[ref];
21
+ if (!provided?.url) { log(`模型服务 ${ref} 在这台机器上没有`); return null; }
22
+ if (provided.api !== 'openai-responses') { log(`模型服务 ${ref} 讲 ${provided.api},Codex 只接 openai-responses 的`); return null; }
23
+ let key;
24
+ try { key = provided.keyFile ? readFileSync(provided.keyFile, 'utf8').trim() || undefined : undefined; }
25
+ catch (e) { log(`读不到模型服务 ${ref} 的 Key:${e.message}`); }
26
+ return { ref, title: provided.title || ref, url: provided.url, key };
27
+ }
28
+
29
+ // The models the service lists, as Codex's model list has them (app-server `model/list`): `<服务>/<模型>`, named by the
30
+ // model and the service, with no effort or speed to choose.
31
+ export async function serviceModels({ ref, title, url, key }, fetcher = fetch) {
32
+ const at = `${url.replace(/\/+$/, '')}/models`;
33
+ const response = await fetcher(at, { headers: key ? { Authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(LIST_TIMEOUT) });
34
+ if (!response.ok) throw new Error(`${at} 回答 ${response.status}`);
35
+ const body = await response.json();
36
+ return (Array.isArray(body?.data) ? body.data : []).filter(model => typeof model?.id === 'string' && model.id).map(model => ({
37
+ id: `${ref}/${model.id}`, model: model.id, displayName: `${model.id} (${title})`, description: '', hidden: false, isDefault: false,
38
+ supportedReasoningEfforts: [], defaultReasoningEffort: 'medium', inputModalities: ['text', 'image'], serviceTiers: [] }));
39
+ }
40
+
41
+ // One service a process (Coda runs one session a process), listed once, as its session opens.
42
+ export class ServiceModels {
43
+ // `on`: the service's address the session runs on, null on Codex's own (where it opens), undefined when a move to one or
44
+ // the other failed partway: codex-acp sets its gateway before the thread is resumed on it, so that is no sign of either,
45
+ // and keeps its old client, closed, when the app-server it started again does not start.
46
+ constructor(service = chosenService(), fetcher = fetch) { Object.assign(this, { service, fetcher }); this.models = []; this.on = null; }
47
+ list() {
48
+ return this.listed ??= (!this.service ? Promise.resolve([]) : serviceModels(this.service, this.fetcher)
49
+ .catch(e => { log(`模型服务 ${this.service.ref} 列不出模型:${e.message}`); return []; })).then(models => (this.models = models));
50
+ }
51
+ served(model) { return this.models.find(m => m.id === model) ?? null; }
52
+ // What a turn names to Codex: the service's own name for one of its models.
53
+ wire(model) { return this.served(model)?.model ?? model; }
54
+ // A stop that comes while a turn waits on the move has no turn of codex-acp's to interrupt yet: it is the waiting turn's,
55
+ // which ends cancelled with its input not given (`route` says so).
56
+ cancel(sessionId) { if (this.moving === sessionId) this.stopped = true; }
57
+ async route(server, params) {
58
+ this.moving = params.sessionId; this.stopped = false;
59
+ try { await this.move(server, params); } finally { this.moving = null; }
60
+ return this.stopped;
61
+ }
62
+ // Before a turn of codex-acp's `server`: the session on the provider of its model, its thread resumed with Coda's
63
+ // instructions as it was opened. A thread nothing was said in yet is not written down, and would not be resumed after the
64
+ // restart: naming it, by what it is first told, writes it. A move that failed is made again from the restart, on which
65
+ // codex-acp has a client again; the thread was written down before it.
66
+ async move(server, { sessionId, prompt }) {
67
+ const state = server.sessions.get(sessionId);
68
+ if (!state) return;
69
+ const served = this.served(state.currentModelId.replace(/\[[^\]]*\]$/, '')), wanted = served ? this.service.url : null;
70
+ if (this.on === wanted) return;
71
+ if (this.on !== undefined) {
72
+ const client = server.codexAcpClient.appServerClient;
73
+ const { thread } = await client.threadRead({ threadId: sessionId });
74
+ if (thread.path && !existsSync(thread.path)) await client.threadSetName({ threadId: sessionId, name: firstWords(prompt) });
75
+ }
76
+ this.on = undefined;
77
+ await carriedInstructions(() => served ? server.setProvider({ providerId: 'openai', apiType: 'openai', baseUrl: this.service.url,
78
+ headers: this.service.key ? { Authorization: `Bearer ${this.service.key}` } : {} }) : server.disableProvider({ providerId: 'openai' }));
79
+ this.on = wanted;
80
+ }
81
+ // Codex knows nothing of the service's models: that it runs one on fallback metadata, or resumes a session last on one
82
+ // with another model (Coda sets the session's model before each turn), is no news to say in the session.
83
+ quiet({ message } = {}) {
84
+ const named = /^Model metadata for `(.+?)` not found|^This session was recorded with model `(.+?)` but is resuming with `(.+?)`/.exec(message ?? '');
85
+ return !!named && named.slice(1).some(model => model && this.models.some(m => m.model === model));
86
+ }
87
+ }
88
+ const firstWords = prompt => Array.from((prompt ?? []).filter(b => b.type === 'text').map(b => b.text).join(' ').replace(/\s+/g, ' ').trim()).slice(0, 80).join('') || 'Coda';
89
+
90
+ export const services = new ServiceModels();
91
+ const patches = [
92
+ // Codex's models, then the service's.
93
+ [' } while (cursor);\n return models;\n }', ' } while (cursor);\n return [...models, ...await codaServices.list()];\n }', 1],
94
+ // A turn first moves the session to the provider of its model, and names the model as that provider does.
95
+ [' async prompt(params, signal, onTurnStarted) {\n if (this.providerUpdate !== null) {',
96
+ ' async prompt(params, signal, onTurnStarted) {\n if (await codaServices.route(this, params)) return { stopReason: "cancelled" };\n if (this.providerUpdate !== null) {', 1],
97
+ // A stop while the session moves is the moving turn's.
98
+ [' async cancel(params) {\n const sessionState = this.sessions.get(params.sessionId);',
99
+ ' async cancel(params) {\n codaServices.cancel(params.sessionId);\n const sessionState = this.sessions.get(params.sessionId);', 1],
100
+ [' model: modelId.model,\n serviceTier\n }, onTurnStarted);', ' model: codaServices.wire(modelId.model),\n serviceTier\n }, onTurnStarted);', 1],
101
+ [' case "warning":\n return this.createWarningEvent(notification.params);',
102
+ ' case "warning":\n if (codaServices.quiet(notification.params)) return null;\n return this.createWarningEvent(notification.params);', 1],
103
+ ];
104
+ // Fails loudly once the locked adapter changes.
105
+ export function servedCodexModels(source) {
106
+ for (const [before, after, count] of patches) {
107
+ if (source.split(before).length !== count + 1) throw new Error('codex-acp model service patch no longer matches the locked adapter');
108
+ source = source.replaceAll(before, after);
109
+ }
110
+ return `import { services as codaServices } from ${JSON.stringify(import.meta.url)};\n${source.replace(/^#![^\n]*\n/, '')}`;
111
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "id": "codex",
4
4
  "displayName": "Codex",
5
5
  "description": "Codex Agent:Coda 自带的入口运行 codex-acp,报告子 Agent 与后台命令。额度与登录由账号来源插件接管;未接入账号来源时使用 Codex 自己的登录面板。",
6
- "version": "1.6.1",
6
+ "version": "1.7.1",
7
7
  "dependencies": {
8
8
  "@agentclientprotocol/codex-acp": "1.12.0",
9
9
  "@openai/codex": "0.162.1"
@@ -37,6 +37,18 @@
37
37
  "setupHint": "请在运行机为后端所属账号完成 Codex 认证。"
38
38
  }
39
39
  ],
40
+ "configuration": {
41
+ "title": "Codex",
42
+ "properties": {
43
+ "codex.modelProvider": {
44
+ "type": "string",
45
+ "format": "coda-model-provider",
46
+ "title": "模型服务",
47
+ "order": 1,
48
+ "description": "选一个插件提供的模型服务(如 Magpie),它列出的模型和 Codex 自己的模型一起出现在会话的模型里;选中它的模型,那个会话就走这个服务,Codex 自己的模型照旧。只接 OpenAI Responses 接口(openai-responses)的服务:Codex 不再支持 Chat Completions。"
49
+ }
50
+ }
51
+ },
40
52
  "panels": [
41
53
  {
42
54
  "id": "login",