@coda-console/codex 1.8.2 → 1.8.4

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
@@ -23,6 +23,7 @@ import { servedCodexModels } from './providers.mjs';
23
23
  import { input, listen, relayedCodexStreams } from './relay.mjs';
24
24
  import { CodexWaits } from './waits.mjs';
25
25
  import { explainCodexError, reportedCodexFailures } from './errors.mjs';
26
+ import { reportedCodexSleeps } from './sleep.mjs';
26
27
 
27
28
  const env = process.env;
28
29
  // A session starts read-only, unless its machine's configuration says otherwise.
@@ -62,7 +63,7 @@ const adapter = new URL(import.meta.resolve('@agentclientprotocol/codex-acp'));
62
63
  registerHooks({ load(url, context, nextLoad) {
63
64
  const loaded = nextLoad(url, context);
64
65
  if (url !== adapter.href) return loaded;
65
- return { ...loaded, source: reportedCodexFailures(codexOptionsAsShown(relayedCodexStreams(orderedCodexTitles(followCodexProcessExit(continuedCodexSubagents(
66
- promptRequiredCodexSteering(instructedCodexThreads(servedCodexModels(networkedCodexDevelopment(Buffer.from(loaded.source).toString('utf8'))))))))))) };
66
+ return { ...loaded, source: reportedCodexSleeps(reportedCodexFailures(codexOptionsAsShown(relayedCodexStreams(orderedCodexTitles(followCodexProcessExit(continuedCodexSubagents(
67
+ promptRequiredCodexSteering(instructedCodexThreads(servedCodexModels(networkedCodexDevelopment(Buffer.from(loaded.source).toString('utf8')))))))))))) };
67
68
  } });
68
69
  await import(adapter.href);
@@ -0,0 +1,22 @@
1
+ // Codex's interruptible clock.sleep is a native display item, not a shell command or an AIR async task.
2
+ // Keep it in the adapter's normal session routing, including child sessions. waits.mjs turns this private metadata
3
+ // into a Coda background wait; the public ACP update stays valid while crossing codex-acp's schema.
4
+ export function codexSleepUpdate(item, state) {
5
+ return { sessionUpdate: 'session_info_update', _meta: { coda: { sleep: { id: item.id, durationMs: item.durationMs, state } } } };
6
+ }
7
+
8
+ export function reportedCodexSleeps(source) {
9
+ for (const [activity, state] of [['Started', 'waiting'], ['Completed', 'done']]) {
10
+ const before = ` return this.subagents.legacyActivity${activity}(event.item);\n case "sleep":`;
11
+ if (source.split(before).length !== 2) throw new Error('codex-acp sleep patch no longer matches the locked adapter');
12
+ source = source.replace(before, `${before}\n return codexSleepUpdate(event.item, "${state}");`);
13
+ }
14
+ return `import { codexSleepUpdate } from ${JSON.stringify(import.meta.url)};\n${source.replace(/^#![^\n]*\n/, '')}`;
15
+ }
16
+
17
+ export function sleepTitle(durationMs) {
18
+ for (const [unit, label] of [[3600000, '小时'], [60000, '分钟'], [1000, '秒']]) {
19
+ if (durationMs >= unit && durationMs % unit === 0) return `等待 ${durationMs / unit} ${label}`;
20
+ }
21
+ return `等待 ${durationMs / 1000} 秒`;
22
+ }
package/agent/waits.mjs CHANGED
@@ -4,6 +4,7 @@
4
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
5
  // here and handed to the patched codex-acp in the request's `_meta` (instructions.mjs).
6
6
  import { INSTRUCTIONS, sessionInstructions } from './instructions.mjs';
7
+ import { sleepTitle } from './sleep.mjs';
7
8
 
8
9
  const record = value => value && typeof value === 'object' && !Array.isArray(value);
9
10
  const string = value => typeof value === 'string' && value.length > 0;
@@ -16,7 +17,7 @@ export class CodexWaits {
16
17
  // `agentInfo`: the Codex it runs, said in place of codex-acp's own name and version.
17
18
  constructor({ agentInfo = null } = {}) {
18
19
  // 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
+ this.root = null; this.asked = new Map(); this.waits = new Map(); this.sleeps = new Map(); this.agentInfo = agentInfo;
20
21
  }
21
22
  // What Coda says: `agent`, what codex-acp is told; `coda`, what Coda is answered here.
22
23
  fromCoda(message) {
@@ -37,6 +38,7 @@ export class CodexWaits {
37
38
  }
38
39
  if (message.method === 'session/new') this.asked.set(message.id, 'session');
39
40
  else if (message.method === 'session/load' && string(message.params?.sessionId)) this.root = message.params.sessionId;
41
+ else if (message.method === 'session/prompt') this.asked.set(message.id, { prompt: message.params.sessionId });
40
42
  return { agent: [message], coda: [] };
41
43
  }
42
44
  // A command is stopped through the session that keeps it, the root; a subagent by cancelling its session.
@@ -52,12 +54,31 @@ export class CodexWaits {
52
54
  // What codex-acp says, as said to Coda.
53
55
  fromAgent(message) {
54
56
  if (!record(message)) return [];
55
- if ('id' in message && !('method' in message)) return [this.answer(message)];
57
+ if ('id' in message && !('method' in message)) {
58
+ const sessionId = this.asked.get(message.id)?.prompt;
59
+ // Cancelling or failing a turn can skip the sleep's completed item. End its waits before ending the prompt.
60
+ const finished = sessionId ? this.finishSleeps(base(sessionId), message.error ? 'failed' : message.result?.stopReason === 'cancelled' ? 'cancelled' : 'done') : [];
61
+ return [...finished, this.answer(message)];
62
+ }
56
63
  if (record(message.params) && string(message.params.sessionId)) message = { ...message, params: { ...message.params, sessionId: base(message.params.sessionId) } };
57
64
  if (message.method !== 'session/update' || !record(message.params?.update)) return [message];
58
65
  const { sessionId, update: u } = message.params;
59
66
  const said = params => [{ jsonrpc: '2.0', method: '_coda/wait', params: { sessionId, ...params } }];
60
67
  switch (u.sessionUpdate) {
68
+ case 'session_info_update': {
69
+ const sleep = u._meta?.coda?.sleep;
70
+ if (!sleep) return [message];
71
+ const waitId = `sleep:${sleep.id}`;
72
+ if (sleep.state === 'waiting') {
73
+ if (!this.sleeps.has(sessionId)) this.sleeps.set(sessionId, new Set());
74
+ this.sleeps.get(sessionId).add(waitId);
75
+ return said({ waitId, kind: 'task', state: 'waiting', title: sleepTitle(sleep.durationMs),
76
+ detail: '等待结束后继续;发送消息并选择「立即插话」可提前继续。', stoppable: false });
77
+ }
78
+ if (!this.sleeps.get(sessionId)?.delete(waitId)) return [];
79
+ if (!this.sleeps.get(sessionId).size) this.sleeps.delete(sessionId);
80
+ return said({ waitId, state: 'done' });
81
+ }
61
82
  case 'subagent_spawned': {
62
83
  if (!string(u.subagentSessionId)) return [];
63
84
  const stoppable = u.capabilities?.cancel === true;
@@ -66,7 +87,10 @@ export class CodexWaits {
66
87
  childSessionId: base(u.subagentSessionId), stoppable });
67
88
  }
68
89
  case 'subagent_state_update':
69
- return string(u.subagentSessionId) && SUBAGENT_END[u.state] ? said({ waitId: u.subagentSessionId, state: SUBAGENT_END[u.state] }) : [];
90
+ return string(u.subagentSessionId) && SUBAGENT_END[u.state] ? [
91
+ ...this.finishSleeps(base(u.subagentSessionId), SUBAGENT_END[u.state]),
92
+ ...said({ waitId: u.subagentSessionId, state: SUBAGENT_END[u.state] }),
93
+ ] : [];
70
94
  case 'async_task_spawned': {
71
95
  if (!string(u.asyncTaskId)) return [];
72
96
  const stoppable = u.canStop === true;
@@ -86,6 +110,11 @@ export class CodexWaits {
86
110
  }
87
111
  return [message];
88
112
  }
113
+ finishSleeps(sessionId, state) {
114
+ const pending = this.sleeps.get(sessionId) ?? [];
115
+ this.sleeps.delete(sessionId);
116
+ return [...pending].map(waitId => ({ jsonrpc: '2.0', method: '_coda/wait', params: { sessionId, waitId, state } }));
117
+ }
89
118
  answer(message) {
90
119
  const asked = this.asked.get(message.id);
91
120
  if (!asked) return message;
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.8.2",
6
+ "version": "1.8.4",
7
7
  "categories": ["Agent"],
8
8
  "dependencies": {
9
9
  "@agentclientprotocol/codex-acp": "1.12.0",
package/scripts/quota.mjs CHANGED
@@ -15,11 +15,9 @@ const identity = (account, workspace) => {
15
15
  return email && typeof workspace === 'string' && workspace ? { identity: `codex:${hash(`${email}\n${hash(workspace)}`)}` } : {};
16
16
  };
17
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
- }
18
+ // A credit's expiry in a figure's text: a time mark, which each client writes in its own time zone (Coda's
19
+ // docs/agent-protocol.md「文字里的时刻」).
20
+ const mark = seconds => `<t:${unixTime(seconds)}>`;
23
21
 
24
22
  // A bar per window of each limit, of what is left. A limit's name is said only when there are several.
25
23
  export function windows(result) {
@@ -54,11 +52,11 @@ export function card(account, result, workspace) {
54
52
  const items = windows(result), resets = credits(result);
55
53
  if (resets) {
56
54
  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)} 到期` }));
55
+ const lines = resets.listed.map((c, i) => ({ type: 'stat', label: `第 ${i + 1} 次`, value: c.expiresAt == null ? '不会过期' : `${mark(c.expiresAt)} 到期` }));
58
56
  const room = CARD_ITEMS - items.length - (first ? 2 : 1);
59
57
  items.push({ type: 'stat', label: '完全重置', value: `剩余 ${resets.count} 次` }, ...lines.slice(0, Math.max(0, room)));
60
58
  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 次,无法撤销。` });
59
+ confirm: `使用 ${line(name, 60)} 的完全重置?\n用最早到期的那一次(${first.expiresAt == null ? '不会过期' : `${mark(first.expiresAt)} 到期`})。成功后消耗 1 次,无法撤销。` });
62
60
  }
63
61
  if (!items.length) items.push({ type: 'error', text: '暂时无法获取额度:Codex 没有给出额度窗口' });
64
62
  return { type: 'card', title: name, ...(line(account.planType) ? { subtitle: line(account.planType) } : {}), ...identity(account, workspace), items: items.slice(0, CARD_ITEMS) };