@adecore/agents 0.23.1 → 0.24.0

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.
Files changed (153) hide show
  1. package/README.md +3 -3
  2. package/dist/chat/attachment-store.js +4 -5
  3. package/dist/chat/backend.d.ts +6 -1
  4. package/dist/chat/background-work.d.ts +1 -1
  5. package/dist/chat/background-work.js +2 -1
  6. package/dist/chat/chat-core.d.ts +53 -161
  7. package/dist/chat/chat-core.js +146 -112
  8. package/dist/chat/chat-log.js +3 -3
  9. package/dist/chat/chat-process.d.ts +21 -0
  10. package/dist/chat/chat-process.js +109 -9
  11. package/dist/chat/chat-session.d.ts +10 -17
  12. package/dist/chat/chat-session.js +141 -182
  13. package/dist/chat/chat-store.d.ts +1 -0
  14. package/dist/chat/chat-store.js +105 -1
  15. package/dist/chat/claude-backend.d.ts +4 -0
  16. package/dist/chat/claude-backend.js +51 -27
  17. package/dist/chat/claude-protocol.d.ts +12 -0
  18. package/dist/chat/claude-protocol.js +137 -7
  19. package/dist/chat/claude-title.d.ts +1 -3
  20. package/dist/chat/claude-title.js +11 -39
  21. package/dist/chat/claude-transcript.d.ts +1 -0
  22. package/dist/chat/claude-transcript.js +36 -17
  23. package/dist/chat/codex-backend.d.ts +21 -1
  24. package/dist/chat/codex-backend.js +168 -10
  25. package/dist/chat/codex-protocol.d.ts +2 -0
  26. package/dist/chat/codex-protocol.js +37 -3
  27. package/dist/chat/codex-thread.js +1 -3
  28. package/dist/chat/codex-transport.d.ts +3 -0
  29. package/dist/chat/codex-transport.js +38 -35
  30. package/dist/chat/context-breakdown.js +2 -2
  31. package/dist/chat/errors.d.ts +1 -1
  32. package/dist/chat/external-sessions.js +2 -2
  33. package/dist/chat/fixtures/claude-2.1.296-goal.json +136 -0
  34. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  35. package/dist/chat/index.d.ts +29 -0
  36. package/dist/chat/index.js +23 -0
  37. package/dist/chat/projector.d.ts +2 -1
  38. package/dist/chat/projector.js +12 -3
  39. package/dist/chat/request-summary.js +1 -4
  40. package/dist/chat/subagent-settlement.js +1 -3
  41. package/dist/chat/thread.d.ts +3 -0
  42. package/dist/chat/thread.js +20 -3
  43. package/dist/chat/ui-queries.d.ts +2 -0
  44. package/dist/chat/ui-queries.js +40 -5
  45. package/dist/chat/ui-replies.d.ts +33 -0
  46. package/dist/chat/ui-replies.js +152 -0
  47. package/dist/context/index.d.ts +2 -0
  48. package/dist/context/index.js +3 -0
  49. package/dist/coordination/index.d.ts +15 -0
  50. package/dist/coordination/index.js +16 -0
  51. package/dist/host/handlers.js +8 -0
  52. package/dist/host/index.d.ts +7 -0
  53. package/dist/host/index.js +7 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.js +10 -0
  56. package/dist/providers/accounts/status.js +1 -3
  57. package/dist/providers/claude.js +3 -1
  58. package/dist/providers/codex.js +3 -1
  59. package/dist/providers/index.d.ts +14 -0
  60. package/dist/providers/index.js +12 -0
  61. package/dist/run-process.js +54 -14
  62. package/dist/skills.d.ts +1 -0
  63. package/dist/skills.js +4 -0
  64. package/dist/tasks/task-coordinator.d.ts +4 -0
  65. package/dist/tasks/task-coordinator.js +16 -2
  66. package/dist/tasks/wiring.js +7 -1
  67. package/dist/{providers/accounts/test-accounts.d.ts → testing/accounts.d.ts} +2 -2
  68. package/dist/{providers/accounts/test-accounts.js → testing/accounts.js} +3 -3
  69. package/dist/testing/bin/fake-claude.d.ts +1 -0
  70. package/dist/testing/bin/fake-claude.js +6 -0
  71. package/dist/testing/bin/fake-codex.d.ts +1 -0
  72. package/dist/testing/bin/fake-codex.js +6 -0
  73. package/dist/{chat → testing}/fake-claude.d.ts +1 -1
  74. package/dist/{chat → testing}/fake-claude.js +0 -4
  75. package/dist/{chat → testing}/fake-cli.d.ts +1 -1
  76. package/dist/{chat → testing}/fake-codex.d.ts +1 -1
  77. package/dist/{chat → testing}/fake-codex.js +70 -8
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.js +7 -0
  80. package/dist/{outbox → testing}/manual-clock.d.ts +1 -1
  81. package/dist/{watch-test-helpers.d.ts → testing/watch.d.ts} +1 -1
  82. package/dist/usage/exchange.d.ts +5 -3
  83. package/dist/usage/exchange.js +25 -16
  84. package/dist/usage/index-file.d.ts +1 -0
  85. package/dist/usage/index-file.js +20 -2
  86. package/dist/usage/index.d.ts +9 -0
  87. package/dist/usage/index.js +6 -0
  88. package/dist/usage/limits/normalize.js +1 -3
  89. package/dist/usage/pricing.d.ts +5 -3
  90. package/dist/usage/pricing.js +28 -19
  91. package/dist/usage/scanner.js +39 -11
  92. package/dist/usage/usage-service.js +8 -3
  93. package/package.json +72 -11
  94. package/src/chat/attachment-store.ts +4 -6
  95. package/src/chat/backend.ts +8 -0
  96. package/src/chat/background-work.ts +2 -2
  97. package/src/chat/chat-core.ts +221 -143
  98. package/src/chat/chat-log.ts +3 -3
  99. package/src/chat/chat-process.ts +128 -8
  100. package/src/chat/chat-session.ts +153 -204
  101. package/src/chat/chat-store.ts +104 -1
  102. package/src/chat/claude-backend.ts +58 -26
  103. package/src/chat/claude-protocol.ts +147 -8
  104. package/src/chat/claude-title.ts +11 -39
  105. package/src/chat/claude-transcript.ts +38 -18
  106. package/src/chat/codex-backend.ts +182 -20
  107. package/src/chat/codex-protocol.ts +40 -5
  108. package/src/chat/codex-thread.ts +1 -4
  109. package/src/chat/codex-transport.ts +45 -35
  110. package/src/chat/context-breakdown.ts +2 -2
  111. package/src/chat/errors.ts +1 -0
  112. package/src/chat/external-sessions.ts +2 -2
  113. package/src/chat/fixtures/claude-2.1.296-goal.json +136 -0
  114. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  115. package/src/chat/index.ts +49 -0
  116. package/src/chat/projector.ts +13 -4
  117. package/src/chat/request-summary.ts +8 -5
  118. package/src/chat/subagent-settlement.ts +1 -4
  119. package/src/chat/thread.ts +22 -3
  120. package/src/chat/ui-queries.ts +46 -5
  121. package/src/chat/ui-replies.ts +187 -0
  122. package/src/context/index.ts +26 -0
  123. package/src/coordination/index.ts +67 -0
  124. package/src/host/handlers.ts +8 -0
  125. package/src/host/index.ts +8 -0
  126. package/src/index.ts +20 -0
  127. package/src/providers/accounts/status.ts +1 -4
  128. package/src/providers/claude.ts +3 -1
  129. package/src/providers/codex.ts +3 -1
  130. package/src/providers/index.ts +15 -0
  131. package/src/run-process.ts +56 -16
  132. package/src/skills.ts +5 -0
  133. package/src/tasks/task-coordinator.ts +22 -2
  134. package/src/tasks/wiring.ts +7 -1
  135. package/src/{providers/accounts/test-accounts.ts → testing/accounts.ts} +4 -4
  136. package/src/testing/bin/fake-claude.ts +7 -0
  137. package/src/testing/bin/fake-codex.ts +7 -0
  138. package/src/{chat → testing}/fake-claude.ts +1 -5
  139. package/src/{chat → testing}/fake-cli.ts +1 -1
  140. package/src/{chat → testing}/fake-codex.ts +75 -6
  141. package/src/testing/index.ts +7 -0
  142. package/src/{outbox → testing}/manual-clock.ts +1 -1
  143. package/src/{watch-test-helpers.ts → testing/watch.ts} +1 -1
  144. package/src/usage/exchange.ts +31 -20
  145. package/src/usage/index-file.ts +23 -2
  146. package/src/usage/index.ts +10 -0
  147. package/src/usage/limits/normalize.ts +1 -4
  148. package/src/usage/pricing.ts +34 -23
  149. package/src/usage/scanner.ts +48 -11
  150. package/src/usage/usage-service.ts +8 -3
  151. /package/dist/{chat → testing}/fake-cli.js +0 -0
  152. /package/dist/{outbox → testing}/manual-clock.js +0 -0
  153. /package/dist/{watch-test-helpers.js → testing/watch.js} +0 -0
@@ -1,16 +1,14 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { accessSync, constants } from 'node:fs';
2
- import { attachmentImageMime } from '@adecore/agent-contracts';
3
+ import { isRecord, attachmentImageMime } from '@adecore/agent-contracts';
3
4
  import { errorText } from '../error-text.js';
4
5
  import { codexServiceTier, codexThreadOptions } from '../providers/codex.js';
5
- import { CodexProtocol } from './codex-protocol.js';
6
+ import { codexGoal, CodexProtocol } from './codex-protocol.js';
6
7
  import { CodexTransport, DEFAULT_CODEX_CLIENT } from './codex-transport.js';
7
8
  import { attachmentNote } from './input.js';
8
9
  function textInput(text) {
9
10
  return [{ type: 'text', text, text_elements: [] }];
10
11
  }
11
- function isRecord(value) {
12
- return typeof value === 'object' && value !== null && !Array.isArray(value);
13
- }
14
12
  /* The enabled skills of a `skills/list` answer; Codex reports one entry per folder it was asked about. */
15
13
  export function parseSkillsList(result) {
16
14
  const data = isRecord(result) && Array.isArray(result.data) ? result.data : [];
@@ -29,6 +27,30 @@ export function parseSkillsList(result) {
29
27
  }
30
28
  return [...skills.values()].sort((a, b) => a.name.localeCompare(b.name));
31
29
  }
30
+ // The words Claude Code 2.1.296 takes for clearing a goal; Codex reads the same, so `/goal` means one thing in every chat.
31
+ const CLEAR_WORDS = new Set(['clear', 'stop', 'off', 'reset', 'none', 'cancel']);
32
+ /* A `/goal` message the way the CLIs read one: a bare `/goal` shows the goal, a control word acts on it, and anything else is a new objective. Null for any other message. */
33
+ export function parseGoalCommand(text) {
34
+ const match = /^\/goal(?:\s+([\s\S]*))?$/.exec(text.trim());
35
+ if (!match) {
36
+ return null;
37
+ }
38
+ const argument = (match[1] ?? '').trim();
39
+ const word = argument.toLowerCase();
40
+ if (word === '') {
41
+ return { kind: 'show' };
42
+ }
43
+ if (CLEAR_WORDS.has(word)) {
44
+ return { kind: 'clear' };
45
+ }
46
+ if (word === 'pause' || word === 'resume') {
47
+ return { kind: word };
48
+ }
49
+ return { kind: 'set', objective: argument };
50
+ }
51
+ function goalLine(goal) {
52
+ return `Goal ${goal.status.replace('-', ' ')}: ${goal.objective}`;
53
+ }
32
54
  /*
33
55
  * One `codex app-server` process on JSON-RPC over stdio. `start` handshakes (initialize, then
34
56
  * thread/start or thread/resume) before the first turn goes out, so every later request knows the
@@ -53,6 +75,8 @@ export class CodexBackend {
53
75
  turnUnnamed = false;
54
76
  /* A stop that came in while the turn was unnamed, sent once `turn/started` names it. */
55
77
  interruptOwed = false;
78
+ // The thread's goal as Codex last reported it, which a stop pauses first so Codex opens no turn after it.
79
+ goalNow = null;
56
80
  constructor(launch, host, options = {}) {
57
81
  this.launch = launch;
58
82
  this.host = host;
@@ -80,6 +104,19 @@ export class CodexBackend {
80
104
  onExit: (exitCode, stderr) => this.handleExit(transport, exitCode, stderr)
81
105
  });
82
106
  this.transport = transport;
107
+ try {
108
+ await this.handshake(transport);
109
+ }
110
+ catch (error) {
111
+ // A chat that failed to start drops this backend, so nothing else would end an app-server stuck in its handshake.
112
+ if (this.transport === transport) {
113
+ this.transport = null;
114
+ void transport.dispose();
115
+ }
116
+ throw error;
117
+ }
118
+ }
119
+ async handshake(transport) {
83
120
  await transport.request('initialize', { clientInfo: this.client, capabilities: { experimentalApi: true, requestAttestation: false } });
84
121
  transport.notify('initialized', {});
85
122
  const tier = codexServiceTier(this.launch.selection);
@@ -97,7 +134,10 @@ export class CodexBackend {
97
134
  this.contextPending = this.launch.resumeNote;
98
135
  }
99
136
  catch (error) {
100
- // The thread is gone from Codex's store; a fresh one keeps the chat usable.
137
+ // A refusal means the thread is gone from Codex's store and a fresh one keeps the chat usable; a silence or an exit says nothing about the thread.
138
+ if (!(error instanceof Error) || error.name !== 'CodexRpcError') {
139
+ throw error;
140
+ }
101
141
  this.emit({ type: 'note', level: 'warning', text: `Codex could not resume its thread (${errorText(error)}). Started a new one.` });
102
142
  result = await transport.request('thread/start', { ...params, ...instructions });
103
143
  }
@@ -112,6 +152,17 @@ export class CodexBackend {
112
152
  }
113
153
  this.emit(event);
114
154
  }
155
+ await this.readGoal(transport).catch(() => {
156
+ // An app-server without goals, or with the feature turned off, has none to report.
157
+ });
158
+ }
159
+ async readGoal(transport) {
160
+ const result = await transport.request('thread/goal/get', { threadId: this.threadId });
161
+ const goal = codexGoal(isRecord(result) ? result.goal : null);
162
+ if (this.transport === transport) {
163
+ this.emit({ type: 'goal', goal });
164
+ }
165
+ return goal;
115
166
  }
116
167
  turnInput(input) {
117
168
  const images = input.attachments.filter((attachment) => attachmentImageMime(attachment) !== null);
@@ -142,18 +193,118 @@ export class CodexBackend {
142
193
  return [...textInput(parts.join('')), ...images.map((attachment) => ({ type: 'localImage', path: attachment.path }))];
143
194
  }
144
195
  sendTurn(input) {
196
+ const command = input.attachments.length === 0 ? parseGoalCommand(input.text) : null;
197
+ if (command !== null) {
198
+ void this.runGoalCommand(command, input);
199
+ return;
200
+ }
145
201
  const nativeInput = this.turnInput(input);
146
- const effort = this.launch.selection.options.effort;
147
- const tier = codexServiceTier(this.launch.selection);
148
202
  this.turnUnnamed = true;
149
203
  this.promptId = input.promptId ?? null;
150
- this.request('turn/start', {
204
+ this.request('turn/start', this.turnStartParams(nativeInput), () => this.acceptPrompt(input.promptId));
205
+ }
206
+ turnStartParams(nativeInput) {
207
+ const effort = this.launch.selection.options.effort;
208
+ const tier = codexServiceTier(this.launch.selection);
209
+ return {
151
210
  threadId: this.threadId,
152
211
  input: nativeInput,
153
212
  model: this.launch.selection.model,
154
213
  ...(typeof effort === 'string' ? { effort } : {}),
155
214
  ...(tier === null ? {} : { serviceTier: tier })
156
- }, () => this.acceptPrompt(input.promptId));
215
+ };
216
+ }
217
+ /*
218
+ * `/goal` through the app-server's goal calls, as Codex's own TUI does. A new objective or a resume
219
+ * starts the first goal turn itself, with this chat's settings, and only then makes the goal active:
220
+ * Codex opens every later turn on its own, and one it opened before this turn would take the old
221
+ * settings. Every other form settles at once with a reply, no turn of the model's.
222
+ */
223
+ async runGoalCommand(command, input) {
224
+ const transport = this.transport;
225
+ if (!transport) {
226
+ this.emit({ type: 'failed', message: 'thread/goal failed: Codex is not running' });
227
+ return;
228
+ }
229
+ const threadId = this.threadId;
230
+ const setGoal = async (params) => {
231
+ const result = await transport.request('thread/goal/set', { threadId, origin: 'user', ...params });
232
+ if (this.transport === transport) {
233
+ this.emit({ type: 'goal', goal: codexGoal(isRecord(result) ? result.goal : null) });
234
+ }
235
+ };
236
+ try {
237
+ const current = await this.readGoal(transport);
238
+ if (command.kind === 'show') {
239
+ this.reply(input.promptId, current === null ? 'No goal is set.' : goalLine(current));
240
+ return;
241
+ }
242
+ if (command.kind === 'clear') {
243
+ const result = await transport.request('thread/goal/clear', { threadId, origin: 'user' });
244
+ this.emit({ type: 'goal', goal: null });
245
+ this.reply(input.promptId, isRecord(result) && result.cleared === true ? 'Goal cleared.' : 'No goal to clear.');
246
+ return;
247
+ }
248
+ if (current === null && command.kind !== 'set') {
249
+ this.reply(input.promptId, 'No goal is set. Set one with /goal and the objective.');
250
+ return;
251
+ }
252
+ if (command.kind === 'pause') {
253
+ await setGoal({ status: 'paused' });
254
+ this.reply(input.promptId, 'Goal paused. /goal resume takes it up again.');
255
+ return;
256
+ }
257
+ if (command.kind === 'set') {
258
+ // A new objective starts over, its tokens and time included.
259
+ if (current !== null) {
260
+ await transport.request('thread/goal/clear', { threadId, origin: 'user' });
261
+ }
262
+ await setGoal({ objective: command.objective, status: 'paused' });
263
+ }
264
+ const nativeInput = command.kind === 'set' ? this.turnInput({ ...input, text: command.objective }) : [];
265
+ this.turnUnnamed = true;
266
+ this.promptId = input.promptId ?? null;
267
+ await transport.request('turn/start', this.turnStartParams(nativeInput));
268
+ if (this.transport !== transport) {
269
+ return;
270
+ }
271
+ this.acceptPrompt(input.promptId);
272
+ await setGoal({ status: 'active' });
273
+ }
274
+ catch (error) {
275
+ if (this.transport === transport) {
276
+ this.emit({ type: 'failed', message: errorText(error) });
277
+ }
278
+ }
279
+ }
280
+ /* A turn that ends with what the host said, for a command no model answers. */
281
+ reply(promptId, text) {
282
+ if (promptId !== undefined) {
283
+ this.emit({ type: 'turn.accepted', promptId });
284
+ }
285
+ this.emit({ type: 'text.done', ref: `goal-${randomUUID()}`, text });
286
+ this.emit({ type: 'turn.done', ...(promptId === undefined ? {} : { promptId }), state: 'done', costUsd: 0 });
287
+ }
288
+ async goal(action) {
289
+ const transport = this.transport;
290
+ if (!transport) {
291
+ throw new Error('thread/goal failed: Codex is not running');
292
+ }
293
+ if (action === 'clear') {
294
+ await transport.request('thread/goal/clear', { threadId: this.threadId, origin: 'user' });
295
+ if (this.transport === transport) {
296
+ this.emit({ type: 'goal', goal: null });
297
+ }
298
+ return;
299
+ }
300
+ const result = await transport.request('thread/goal/set', {
301
+ threadId: this.threadId,
302
+ status: action === 'pause' ? 'paused' : 'active',
303
+ origin: 'user'
304
+ });
305
+ if (this.transport === transport) {
306
+ this.emit({ type: 'goal', goal: codexGoal(isRecord(result) ? result.goal : null) });
307
+ }
157
308
  }
158
309
  async steerTurn(input) {
159
310
  const transport = this.transport;
@@ -254,6 +405,10 @@ export class CodexBackend {
254
405
  if (!this.transport) {
255
406
  return;
256
407
  }
408
+ // As Codex's own TUI does: paused first, so no goal turn opens after the one this stops.
409
+ if (this.goalNow?.status === 'active') {
410
+ void this.transport.request('thread/goal/set', { threadId: this.threadId, status: 'paused', origin: 'user' }).catch(() => undefined);
411
+ }
257
412
  if (!turnId) {
258
413
  this.interruptOwed = this.turnUnnamed;
259
414
  // A child request can open a host turn after the root's native turn already ended.
@@ -385,6 +540,9 @@ export class CodexBackend {
385
540
  this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
386
541
  }
387
542
  emit(event) {
543
+ if (event.type === 'goal') {
544
+ this.goalNow = event.goal;
545
+ }
388
546
  // An older autonomous turn can finish while the next prompt still waits for its own turn/started.
389
547
  if ((event.type === 'turn.done' && (!this.turnUnnamed || event.promptId !== undefined)) || event.type === 'failed' || event.type === 'exit') {
390
548
  this.turnUnnamed = false;
@@ -1,3 +1,4 @@
1
+ import type { ChatGoal } from '@adecore/agent-contracts';
1
2
  import type { ApprovalDecision, BackendEvent } from './backend.ts';
2
3
  import type { CodexFrame } from './codex-transport.ts';
3
4
  type CodexRpcId = number | string;
@@ -9,6 +10,7 @@ type CodexAnswer = {
9
10
  kind: 'steer';
10
11
  text: string;
11
12
  };
13
+ export declare function codexGoal(raw: unknown): ChatGoal | null;
12
14
  export declare function unwrapCommand(command: string): string;
13
15
  export declare class CodexProtocol {
14
16
  private readonly generation;
@@ -1,16 +1,44 @@
1
1
  import { generatedImage } from './generated-image.js';
2
+ import { isRecord } from '@adecore/agent-contracts';
2
3
  import { readCodexLimits } from '../usage/limits/normalize.js';
3
4
  import { codexRuntimeMode } from '../providers/codex.js';
4
5
  import { cleanTitle } from '../title-file.js';
5
- function isRecord(value) {
6
- return typeof value === 'object' && value !== null && !Array.isArray(value);
7
- }
8
6
  function str(value) {
9
7
  return typeof value === 'string' ? value : null;
10
8
  }
11
9
  function num(value) {
12
10
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
13
11
  }
12
+ const GOAL_STATUSES = {
13
+ active: 'active',
14
+ paused: 'paused',
15
+ blocked: 'blocked',
16
+ usageLimited: 'usage-limited',
17
+ budgetLimited: 'budget-limited',
18
+ complete: 'complete'
19
+ };
20
+ /* A `ThreadGoal` of the app-server (Codex 0.162.1) as a chat says it; null for a goal it cannot read, or none. */
21
+ export function codexGoal(raw) {
22
+ if (!isRecord(raw)) {
23
+ return null;
24
+ }
25
+ const objective = str(raw.objective)?.trim() ?? '';
26
+ const status = GOAL_STATUSES[str(raw.status) ?? ''];
27
+ if (objective === '' || status === undefined) {
28
+ return null;
29
+ }
30
+ const count = (value) => (typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined);
31
+ const tokensUsed = count(raw.tokensUsed);
32
+ const tokenBudget = count(raw.tokenBudget);
33
+ const secondsUsed = count(raw.timeUsedSeconds);
34
+ return {
35
+ objective,
36
+ status,
37
+ ...(tokensUsed === undefined ? {} : { tokensUsed }),
38
+ ...(tokenBudget === undefined || tokenBudget === 0 ? {} : { tokenBudget }),
39
+ ...(secondsUsed === undefined ? {} : { secondsUsed })
40
+ };
41
+ }
14
42
  // Codex wraps every command in a login shell; the person wants to read the command, not the wrapper.
15
43
  const SHELL_WRAPPER = /^(?:\/bin\/|\/usr\/bin\/)?(?:zsh|bash|sh) -lc (.*)$/s;
16
44
  export function unwrapCommand(command) {
@@ -234,6 +262,12 @@ export class CodexProtocol {
234
262
  case 'turn/completed':
235
263
  this.handleTurnCompleted(params, events);
236
264
  break;
265
+ case 'thread/goal/updated':
266
+ events.push({ type: 'goal', goal: codexGoal(params.goal) });
267
+ break;
268
+ case 'thread/goal/cleared':
269
+ events.push({ type: 'goal', goal: null });
270
+ break;
237
271
  case 'thread/closed':
238
272
  this.withdrawOwner(threadId ?? this.ownThreadId, null, events);
239
273
  break;
@@ -1,11 +1,9 @@
1
+ import { isRecord } from '@adecore/agent-contracts';
1
2
  import { errorText } from '../error-text.js';
2
3
  import { CodexProtocol } from './codex-protocol.js';
3
4
  import { CodexTransport, DEFAULT_CODEX_CLIENT } from './codex-transport.js';
4
5
  import { ChatError } from './errors.js';
5
6
  import { readingThread, settledReading } from './subagent-projection.js';
6
- function isRecord(value) {
7
- return typeof value === 'object' && value !== null && !Array.isArray(value);
8
- }
9
7
  /* One `thread/items/list` answer as far as a reader needs it: the entries newest first, and the cursor to older ones. */
10
8
  export function parseThreadItemsPage(result) {
11
9
  const data = isRecord(result) && Array.isArray(result.data) ? result.data.filter(isRecord) : [];
@@ -21,6 +21,7 @@ export declare class CodexTransport {
21
21
  private readonly pending;
22
22
  private nextId;
23
23
  private closed;
24
+ private inputClosed;
24
25
  constructor(options: CodexTransportOptions);
25
26
  get pid(): number;
26
27
  get alive(): boolean;
@@ -33,10 +34,12 @@ export declare class CodexTransport {
33
34
  stop(): void;
34
35
  dispose(): Promise<void>;
35
36
  private write;
37
+ private broken;
36
38
  private failed;
37
39
  private readLine;
38
40
  private readLines;
39
41
  private handleLine;
40
42
  private handleExit;
43
+ private rejectPending;
41
44
  }
42
45
  export {};
@@ -1,5 +1,5 @@
1
1
  import { errorText } from '../error-text.js';
2
- import { ChatChild } from './chat-process.js';
2
+ import { ChatChild, MAX_LINE_LENGTH, readStreamLines } from './chat-process.js';
3
3
  export const DEFAULT_CODEX_CLIENT = { name: 'agents', title: 'Agents', version: '0.1.0' };
4
4
  class CodexRpcError extends Error {
5
5
  code;
@@ -10,6 +10,8 @@ class CodexRpcError extends Error {
10
10
  this.code = typeof detail.code === 'number' ? detail.code : null;
11
11
  }
12
12
  }
13
+ // The app-server answers every request it takes at once and does the work in notifications, so a silent one is stuck.
14
+ const REQUEST_TIMEOUT_MS = 60_000;
13
15
  /*
14
16
  * One `codex app-server` process and the JSON-RPC framing on its stdio: newline-delimited JSON,
15
17
  * a request carries an id and gets a result or an error back under the same id, a notification
@@ -21,6 +23,8 @@ export class CodexTransport {
21
23
  pending = new Map();
22
24
  nextId = 1;
23
25
  closed = false;
26
+ // Set once stdin ended or broke; a write after an EPIPE only raises the next one.
27
+ inputClosed = false;
24
28
  constructor(options) {
25
29
  this.options = options;
26
30
  this.child = new ChatChild({
@@ -28,7 +32,7 @@ export class CodexTransport {
28
32
  cwd: options.cwd,
29
33
  env: options.env,
30
34
  ...(options.spawn ? { spawn: options.spawn } : {}),
31
- onError: (error) => this.failed(error),
35
+ onError: (error) => this.broken(error),
32
36
  onExit: (exitCode, stderr) => this.handleExit(exitCode, stderr)
33
37
  });
34
38
  void this.readLines();
@@ -46,11 +50,17 @@ export class CodexTransport {
46
50
  return;
47
51
  }
48
52
  const id = this.nextId++;
49
- this.pending.set(id, { resolve, reject, method });
53
+ const timer = setTimeout(() => {
54
+ this.pending.delete(id);
55
+ reject(new Error(`${method} failed: Codex did not answer in ${REQUEST_TIMEOUT_MS / 1000} seconds`));
56
+ }, REQUEST_TIMEOUT_MS);
57
+ timer.unref?.();
58
+ this.pending.set(id, { resolve, reject, method, timer });
50
59
  try {
51
60
  this.write({ id, method, params });
52
61
  }
53
62
  catch (error) {
63
+ clearTimeout(timer);
54
64
  this.pending.delete(id);
55
65
  reject(error);
56
66
  }
@@ -67,6 +77,7 @@ export class CodexTransport {
67
77
  }
68
78
  /* Closes stdin; the app-server ends on its own once it has nothing more to say. */
69
79
  end() {
80
+ this.inputClosed = true;
70
81
  try {
71
82
  this.child.process.stdin.end();
72
83
  }
@@ -85,18 +96,26 @@ export class CodexTransport {
85
96
  return this.child.end();
86
97
  }
87
98
  write(frame) {
88
- if (this.closed) {
99
+ if (this.closed || this.inputClosed) {
89
100
  throw new Error('Codex stdin is closed');
90
101
  }
91
- this.child.process.stdin.write(`${JSON.stringify(frame)}\n`);
92
- this.child.process.stdin.flush();
102
+ try {
103
+ this.child.process.stdin.write(`${JSON.stringify(frame)}\n`);
104
+ this.child.process.stdin.flush();
105
+ }
106
+ catch (error) {
107
+ this.inputClosed = true;
108
+ throw error;
109
+ }
110
+ }
111
+ /* A pipe that failed takes no more writes; a frame a consumer choked on leaves it as it was. */
112
+ broken(error) {
113
+ this.inputClosed = true;
114
+ this.failed(error);
93
115
  }
94
116
  failed(error) {
95
117
  const failure = new Error(`Codex transport failed: ${errorText(error)}`);
96
- for (const settle of this.pending.values()) {
97
- settle.reject(failure);
98
- }
99
- this.pending.clear();
118
+ this.rejectPending(() => failure);
100
119
  try {
101
120
  this.options.onError?.(failure);
102
121
  }
@@ -113,35 +132,14 @@ export class CodexTransport {
113
132
  }
114
133
  }
115
134
  async readLines() {
116
- const reader = this.child.process.stdout.getReader();
117
- const decoder = new TextDecoder();
118
- let buffered = '';
119
135
  try {
120
- while (true) {
121
- const { value, done } = await reader.read();
122
- if (done) {
123
- break;
124
- }
125
- buffered += decoder.decode(value, { stream: true });
126
- let newline = buffered.indexOf('\n');
127
- while (newline >= 0) {
128
- this.readLine(buffered.slice(0, newline));
129
- buffered = buffered.slice(newline + 1);
130
- newline = buffered.indexOf('\n');
131
- }
132
- }
133
- if (buffered.trim() !== '') {
134
- this.readLine(buffered);
135
- }
136
+ await readStreamLines(this.child.process.stdout, (line) => this.readLine(line), () => this.failed(new Error(`Codex wrote a frame of more than ${MAX_LINE_LENGTH / 1_000_000} million characters, which was dropped`)));
136
137
  }
137
138
  catch (error) {
138
139
  if (!this.closed) {
139
- this.failed(error);
140
+ this.broken(error);
140
141
  }
141
142
  }
142
- finally {
143
- reader.releaseLock();
144
- }
145
143
  }
146
144
  handleLine(line) {
147
145
  if (this.closed || line.trim() === '') {
@@ -167,6 +165,7 @@ export class CodexTransport {
167
165
  return;
168
166
  }
169
167
  this.pending.delete(message.id);
168
+ clearTimeout(settle.timer);
170
169
  if (message.error !== undefined) {
171
170
  settle.reject(new CodexRpcError(settle.method, message.error));
172
171
  }
@@ -179,10 +178,14 @@ export class CodexTransport {
179
178
  return;
180
179
  }
181
180
  this.closed = true;
181
+ this.rejectPending((method) => new Error(`${method} failed: Codex exited`));
182
+ this.options.onExit(exitCode, stderr);
183
+ }
184
+ rejectPending(reason) {
182
185
  for (const settle of this.pending.values()) {
183
- settle.reject(new Error(`${settle.method} failed: Codex exited`));
186
+ clearTimeout(settle.timer);
187
+ settle.reject(reason(settle.method));
184
188
  }
185
189
  this.pending.clear();
186
- this.options.onExit(exitCode, stderr);
187
190
  }
188
191
  }
@@ -1,4 +1,4 @@
1
- import { attachmentImageMime } from '@adecore/agent-contracts';
1
+ import { attachmentImageMime, HOST_TASK_ORIGIN } from '@adecore/agent-contracts';
2
2
  // A rough rate for prose and code alike; scaling to the CLI's own total absorbs the error.
3
3
  const CHARS_PER_TOKEN = 4;
4
4
  // What a model is charged for a picture at the size a CLI sends it, whatever its bytes on disk.
@@ -54,7 +54,7 @@ export function estimateContextBreakdown(items, contextTokens) {
54
54
  }
55
55
  case 'subagent':
56
56
  // A row the host drew for work it delegated stands for that work; the call that made it is a tool row of its own.
57
- if (item.origin !== 'ruimte') {
57
+ if (item.origin !== HOST_TASK_ORIGIN) {
58
58
  toolOutput += textTokens(item.prompt ?? '') + textTokens(item.result ?? '');
59
59
  }
60
60
  break;
@@ -1,4 +1,4 @@
1
1
  import { CodedError } from '../coded-error.ts';
2
- export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'subagent-stale' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
2
+ export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'subagent-stale' | 'task-not-found' | 'goal-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
3
3
  export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
4
4
  }
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { clipText } from '@adecore/agent-contracts';
2
+ import { clipText, isRecord } from '@adecore/agent-contracts';
3
3
  import { isForkOrSubagent } from '../usage/readers/codex.js';
4
4
  import { claudeProjectSlug, spokenText } from './claude-transcript.js';
5
5
  const TITLE_LENGTH = 120;
@@ -18,7 +18,7 @@ function records(head) {
18
18
  }
19
19
  try {
20
20
  const parsed = JSON.parse(line);
21
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? [parsed] : [];
21
+ return isRecord(parsed) ? [parsed] : [];
22
22
  }
23
23
  catch {
24
24
  return [];