@adecore/agents 0.23.1 → 0.24.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.
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,9 +1,10 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { accessSync, constants } from 'node:fs';
2
- import { attachmentImageMime, type ChatSkill } from '@adecore/agent-contracts';
3
+ import { isRecord, attachmentImageMime, type ChatGoal, type ChatGoalAction, type ChatSkill } from '@adecore/agent-contracts';
3
4
  import { errorText } from '../error-text.ts';
4
5
  import { codexServiceTier, codexThreadOptions } from '../providers/codex.ts';
5
6
  import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
6
- import { CodexProtocol } from './codex-protocol.ts';
7
+ import { codexGoal, CodexProtocol } from './codex-protocol.ts';
7
8
  import { CodexTransport, DEFAULT_CODEX_CLIENT, type CodexClientInfo, type CodexFrame } from './codex-transport.ts';
8
9
  import { attachmentNote } from './input.ts';
9
10
 
@@ -11,10 +12,6 @@ function textInput(text: string) {
11
12
  return [{ type: 'text', text, text_elements: [] }];
12
13
  }
13
14
 
14
- function isRecord(value: unknown): value is Record<string, unknown> {
15
- return typeof value === 'object' && value !== null && !Array.isArray(value);
16
- }
17
-
18
15
  /* The enabled skills of a `skills/list` answer; Codex reports one entry per folder it was asked about. */
19
16
  export function parseSkillsList(result: unknown): ChatSkill[] {
20
17
  const data = isRecord(result) && Array.isArray(result.data) ? result.data : [];
@@ -34,6 +31,35 @@ export function parseSkillsList(result: unknown): ChatSkill[] {
34
31
  return [...skills.values()].sort((a, b) => a.name.localeCompare(b.name));
35
32
  }
36
33
 
34
+ export type GoalCommand = { kind: 'show' } | { kind: 'clear' } | { kind: 'pause' } | { kind: 'resume' } | { kind: 'set'; objective: string };
35
+
36
+ // The words Claude Code 2.1.296 takes for clearing a goal; Codex reads the same, so `/goal` means one thing in every chat.
37
+ const CLEAR_WORDS = new Set(['clear', 'stop', 'off', 'reset', 'none', 'cancel']);
38
+
39
+ /* 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. */
40
+ export function parseGoalCommand(text: string): GoalCommand | null {
41
+ const match = /^\/goal(?:\s+([\s\S]*))?$/.exec(text.trim());
42
+ if (!match) {
43
+ return null;
44
+ }
45
+ const argument = (match[1] ?? '').trim();
46
+ const word = argument.toLowerCase();
47
+ if (word === '') {
48
+ return { kind: 'show' };
49
+ }
50
+ if (CLEAR_WORDS.has(word)) {
51
+ return { kind: 'clear' };
52
+ }
53
+ if (word === 'pause' || word === 'resume') {
54
+ return { kind: word };
55
+ }
56
+ return { kind: 'set', objective: argument };
57
+ }
58
+
59
+ function goalLine(goal: ChatGoal): string {
60
+ return `Goal ${goal.status.replace('-', ' ')}: ${goal.objective}`;
61
+ }
62
+
37
63
  export interface CodexBackendOptions {
38
64
  client?: CodexClientInfo;
39
65
  }
@@ -62,6 +88,8 @@ export class CodexBackend implements ChatBackend {
62
88
  private turnUnnamed = false;
63
89
  /* A stop that came in while the turn was unnamed, sent once `turn/started` names it. */
64
90
  private interruptOwed = false;
91
+ // The thread's goal as Codex last reported it, which a stop pauses first so Codex opens no turn after it.
92
+ private goalNow: ChatGoal | null = null;
65
93
 
66
94
  constructor(launch: BackendLaunch, host: BackendHost, options: CodexBackendOptions = {}) {
67
95
  this.launch = launch;
@@ -93,6 +121,19 @@ export class CodexBackend implements ChatBackend {
93
121
  onExit: (exitCode, stderr) => this.handleExit(transport, exitCode, stderr)
94
122
  });
95
123
  this.transport = transport;
124
+ try {
125
+ await this.handshake(transport);
126
+ } catch (error) {
127
+ // A chat that failed to start drops this backend, so nothing else would end an app-server stuck in its handshake.
128
+ if (this.transport === transport) {
129
+ this.transport = null;
130
+ void transport.dispose();
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ private async handshake(transport: CodexTransport): Promise<void> {
96
137
  await transport.request('initialize', { clientInfo: this.client, capabilities: { experimentalApi: true, requestAttestation: false } });
97
138
  transport.notify('initialized', {});
98
139
  const tier = codexServiceTier(this.launch.selection);
@@ -109,7 +150,10 @@ export class CodexBackend implements ChatBackend {
109
150
  result = await transport.request('thread/resume', { threadId: this.launch.resume, excludeTurns: true, ...params });
110
151
  this.contextPending = this.launch.resumeNote;
111
152
  } catch (error) {
112
- // The thread is gone from Codex's store; a fresh one keeps the chat usable.
153
+ // 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.
154
+ if (!(error instanceof Error) || error.name !== 'CodexRpcError') {
155
+ throw error;
156
+ }
113
157
  this.emit({ type: 'note', level: 'warning', text: `Codex could not resume its thread (${errorText(error)}). Started a new one.` });
114
158
  result = await transport.request('thread/start', { ...params, ...instructions });
115
159
  }
@@ -123,6 +167,18 @@ export class CodexBackend implements ChatBackend {
123
167
  }
124
168
  this.emit(event);
125
169
  }
170
+ await this.readGoal(transport).catch(() => {
171
+ // An app-server without goals, or with the feature turned off, has none to report.
172
+ });
173
+ }
174
+
175
+ private async readGoal(transport: CodexTransport): Promise<ChatGoal | null> {
176
+ const result = await transport.request('thread/goal/get', { threadId: this.threadId });
177
+ const goal = codexGoal(isRecord(result) ? result.goal : null);
178
+ if (this.transport === transport) {
179
+ this.emit({ type: 'goal', goal });
180
+ }
181
+ return goal;
126
182
  }
127
183
 
128
184
  private turnInput(input: TurnInput): unknown[] {
@@ -154,22 +210,121 @@ export class CodexBackend implements ChatBackend {
154
210
  }
155
211
 
156
212
  sendTurn(input: TurnInput): void {
213
+ const command = input.attachments.length === 0 ? parseGoalCommand(input.text) : null;
214
+ if (command !== null) {
215
+ void this.runGoalCommand(command, input);
216
+ return;
217
+ }
157
218
  const nativeInput = this.turnInput(input);
158
- const effort = this.launch.selection.options.effort;
159
- const tier = codexServiceTier(this.launch.selection);
160
219
  this.turnUnnamed = true;
161
220
  this.promptId = input.promptId ?? null;
162
- this.request(
163
- 'turn/start',
164
- {
165
- threadId: this.threadId,
166
- input: nativeInput,
167
- model: this.launch.selection.model,
168
- ...(typeof effort === 'string' ? { effort } : {}),
169
- ...(tier === null ? {} : { serviceTier: tier })
170
- },
171
- () => this.acceptPrompt(input.promptId)
172
- );
221
+ this.request('turn/start', this.turnStartParams(nativeInput), () => this.acceptPrompt(input.promptId));
222
+ }
223
+
224
+ private turnStartParams(nativeInput: unknown[]): Record<string, unknown> {
225
+ const effort = this.launch.selection.options.effort;
226
+ const tier = codexServiceTier(this.launch.selection);
227
+ return {
228
+ threadId: this.threadId,
229
+ input: nativeInput,
230
+ model: this.launch.selection.model,
231
+ ...(typeof effort === 'string' ? { effort } : {}),
232
+ ...(tier === null ? {} : { serviceTier: tier })
233
+ };
234
+ }
235
+
236
+ /*
237
+ * `/goal` through the app-server's goal calls, as Codex's own TUI does. A new objective or a resume
238
+ * starts the first goal turn itself, with this chat's settings, and only then makes the goal active:
239
+ * Codex opens every later turn on its own, and one it opened before this turn would take the old
240
+ * settings. Every other form settles at once with a reply, no turn of the model's.
241
+ */
242
+ private async runGoalCommand(command: GoalCommand, input: TurnInput): Promise<void> {
243
+ const transport = this.transport;
244
+ if (!transport) {
245
+ this.emit({ type: 'failed', message: 'thread/goal failed: Codex is not running' });
246
+ return;
247
+ }
248
+ const threadId = this.threadId;
249
+ const setGoal = async (params: Record<string, unknown>): Promise<void> => {
250
+ const result = await transport.request('thread/goal/set', { threadId, origin: 'user', ...params });
251
+ if (this.transport === transport) {
252
+ this.emit({ type: 'goal', goal: codexGoal(isRecord(result) ? result.goal : null) });
253
+ }
254
+ };
255
+ try {
256
+ const current = await this.readGoal(transport);
257
+ if (command.kind === 'show') {
258
+ this.reply(input.promptId, current === null ? 'No goal is set.' : goalLine(current));
259
+ return;
260
+ }
261
+ if (command.kind === 'clear') {
262
+ const result = await transport.request('thread/goal/clear', { threadId, origin: 'user' });
263
+ this.emit({ type: 'goal', goal: null });
264
+ this.reply(input.promptId, isRecord(result) && result.cleared === true ? 'Goal cleared.' : 'No goal to clear.');
265
+ return;
266
+ }
267
+ if (current === null && command.kind !== 'set') {
268
+ this.reply(input.promptId, 'No goal is set. Set one with /goal and the objective.');
269
+ return;
270
+ }
271
+ if (command.kind === 'pause') {
272
+ await setGoal({ status: 'paused' });
273
+ this.reply(input.promptId, 'Goal paused. /goal resume takes it up again.');
274
+ return;
275
+ }
276
+ if (command.kind === 'set') {
277
+ // A new objective starts over, its tokens and time included.
278
+ if (current !== null) {
279
+ await transport.request('thread/goal/clear', { threadId, origin: 'user' });
280
+ }
281
+ await setGoal({ objective: command.objective, status: 'paused' });
282
+ }
283
+ const nativeInput = command.kind === 'set' ? this.turnInput({ ...input, text: command.objective }) : [];
284
+ this.turnUnnamed = true;
285
+ this.promptId = input.promptId ?? null;
286
+ await transport.request('turn/start', this.turnStartParams(nativeInput));
287
+ if (this.transport !== transport) {
288
+ return;
289
+ }
290
+ this.acceptPrompt(input.promptId);
291
+ await setGoal({ status: 'active' });
292
+ } catch (error) {
293
+ if (this.transport === transport) {
294
+ this.emit({ type: 'failed', message: errorText(error) });
295
+ }
296
+ }
297
+ }
298
+
299
+ /* A turn that ends with what the host said, for a command no model answers. */
300
+ private reply(promptId: string | undefined, text: string): void {
301
+ if (promptId !== undefined) {
302
+ this.emit({ type: 'turn.accepted', promptId });
303
+ }
304
+ this.emit({ type: 'text.done', ref: `goal-${randomUUID()}`, text });
305
+ this.emit({ type: 'turn.done', ...(promptId === undefined ? {} : { promptId }), state: 'done', costUsd: 0 });
306
+ }
307
+
308
+ async goal(action: ChatGoalAction): Promise<void> {
309
+ const transport = this.transport;
310
+ if (!transport) {
311
+ throw new Error('thread/goal failed: Codex is not running');
312
+ }
313
+ if (action === 'clear') {
314
+ await transport.request('thread/goal/clear', { threadId: this.threadId, origin: 'user' });
315
+ if (this.transport === transport) {
316
+ this.emit({ type: 'goal', goal: null });
317
+ }
318
+ return;
319
+ }
320
+ const result = await transport.request('thread/goal/set', {
321
+ threadId: this.threadId,
322
+ status: action === 'pause' ? 'paused' : 'active',
323
+ origin: 'user'
324
+ });
325
+ if (this.transport === transport) {
326
+ this.emit({ type: 'goal', goal: codexGoal(isRecord(result) ? result.goal : null) });
327
+ }
173
328
  }
174
329
 
175
330
  async steerTurn(input: TurnInput): Promise<boolean> {
@@ -277,6 +432,10 @@ export class CodexBackend implements ChatBackend {
277
432
  if (!this.transport) {
278
433
  return;
279
434
  }
435
+ // As Codex's own TUI does: paused first, so no goal turn opens after the one this stops.
436
+ if (this.goalNow?.status === 'active') {
437
+ void this.transport.request('thread/goal/set', { threadId: this.threadId, status: 'paused', origin: 'user' }).catch(() => undefined);
438
+ }
280
439
  if (!turnId) {
281
440
  this.interruptOwed = this.turnUnnamed;
282
441
  // A child request can open a host turn after the root's native turn already ended.
@@ -418,6 +577,9 @@ export class CodexBackend implements ChatBackend {
418
577
  }
419
578
 
420
579
  private emit(event: BackendEvent): void {
580
+ if (event.type === 'goal') {
581
+ this.goalNow = event.goal;
582
+ }
421
583
  // An older autonomous turn can finish while the next prompt still waits for its own turn/started.
422
584
  if ((event.type === 'turn.done' && (!this.turnUnnamed || event.promptId !== undefined)) || event.type === 'failed' || event.type === 'exit') {
423
585
  this.turnUnnamed = false;
@@ -1,5 +1,6 @@
1
1
  import { generatedImage } from './generated-image.ts';
2
- import type { ChatFileChange, ChatQuestion, ChatTurnLimit, UsageWindow } from '@adecore/agent-contracts';
2
+ import type { ChatFileChange, ChatGoal, ChatGoalStatus, ChatQuestion, ChatTurnLimit, UsageWindow } from '@adecore/agent-contracts';
3
+ import { isRecord } from '@adecore/agent-contracts';
3
4
  import { readCodexLimits } from '../usage/limits/normalize.ts';
4
5
  import { codexRuntimeMode } from '../providers/codex.ts';
5
6
  import type { ApprovalDecision, BackendEvent } from './backend.ts';
@@ -13,10 +14,6 @@ type CodexAnswer = { kind: 'respond'; rpcId: CodexRpcId; result: unknown } | { k
13
14
 
14
15
  type Frame = Record<string, unknown>;
15
16
 
16
- function isRecord(value: unknown): value is Frame {
17
- return typeof value === 'object' && value !== null && !Array.isArray(value);
18
- }
19
-
20
17
  function str(value: unknown): string | null {
21
18
  return typeof value === 'string' ? value : null;
22
19
  }
@@ -25,6 +22,38 @@ function num(value: unknown): number {
25
22
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
26
23
  }
27
24
 
25
+ const GOAL_STATUSES: Record<string, ChatGoalStatus> = {
26
+ active: 'active',
27
+ paused: 'paused',
28
+ blocked: 'blocked',
29
+ usageLimited: 'usage-limited',
30
+ budgetLimited: 'budget-limited',
31
+ complete: 'complete'
32
+ };
33
+
34
+ /* A `ThreadGoal` of the app-server (Codex 0.162.1) as a chat says it; null for a goal it cannot read, or none. */
35
+ export function codexGoal(raw: unknown): ChatGoal | null {
36
+ if (!isRecord(raw)) {
37
+ return null;
38
+ }
39
+ const objective = str(raw.objective)?.trim() ?? '';
40
+ const status = GOAL_STATUSES[str(raw.status) ?? ''];
41
+ if (objective === '' || status === undefined) {
42
+ return null;
43
+ }
44
+ const count = (value: unknown): number | undefined => (typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined);
45
+ const tokensUsed = count(raw.tokensUsed);
46
+ const tokenBudget = count(raw.tokenBudget);
47
+ const secondsUsed = count(raw.timeUsedSeconds);
48
+ return {
49
+ objective,
50
+ status,
51
+ ...(tokensUsed === undefined ? {} : { tokensUsed }),
52
+ ...(tokenBudget === undefined || tokenBudget === 0 ? {} : { tokenBudget }),
53
+ ...(secondsUsed === undefined ? {} : { secondsUsed })
54
+ };
55
+ }
56
+
28
57
  // Codex wraps every command in a login shell; the person wants to read the command, not the wrapper.
29
58
  const SHELL_WRAPPER = /^(?:\/bin\/|\/usr\/bin\/)?(?:zsh|bash|sh) -lc (.*)$/s;
30
59
 
@@ -280,6 +309,12 @@ export class CodexProtocol {
280
309
  case 'turn/completed':
281
310
  this.handleTurnCompleted(params, events);
282
311
  break;
312
+ case 'thread/goal/updated':
313
+ events.push({ type: 'goal', goal: codexGoal(params.goal) });
314
+ break;
315
+ case 'thread/goal/cleared':
316
+ events.push({ type: 'goal', goal: null });
317
+ break;
283
318
  case 'thread/closed':
284
319
  this.withdrawOwner(threadId ?? this.ownThreadId, null, events);
285
320
  break;
@@ -1,4 +1,5 @@
1
1
  import type { ChatItem } from '@adecore/agent-contracts';
2
+ import { isRecord } from '@adecore/agent-contracts';
2
3
  import { errorText } from '../error-text.ts';
3
4
  import type { SpawnChatProcess } from './chat-process.ts';
4
5
  import { CodexProtocol } from './codex-protocol.ts';
@@ -6,10 +7,6 @@ import { CodexTransport, DEFAULT_CODEX_CLIENT, type CodexClientInfo } from './co
6
7
  import { ChatError } from './errors.ts';
7
8
  import { readingThread, settledReading } from './subagent-projection.ts';
8
9
 
9
- function isRecord(value: unknown): value is Record<string, unknown> {
10
- return typeof value === 'object' && value !== null && !Array.isArray(value);
11
- }
12
-
13
10
  export interface ThreadItemsParams {
14
11
  threadId: string;
15
12
  cursor?: string;
@@ -1,5 +1,5 @@
1
1
  import { errorText } from '../error-text.ts';
2
- import { ChatChild, type SpawnChatProcess } from './chat-process.ts';
2
+ import { ChatChild, MAX_LINE_LENGTH, readStreamLines, type SpawnChatProcess } from './chat-process.ts';
3
3
 
4
4
  export type CodexFrame = Record<string, unknown>;
5
5
 
@@ -35,7 +35,10 @@ class CodexRpcError extends Error {
35
35
  }
36
36
  }
37
37
 
38
- type Settle = { resolve(value: unknown): void; reject(reason: Error): void; method: string };
38
+ type Settle = { resolve(value: unknown): void; reject(reason: Error): void; method: string; timer: ReturnType<typeof setTimeout> };
39
+
40
+ // The app-server answers every request it takes at once and does the work in notifications, so a silent one is stuck.
41
+ const REQUEST_TIMEOUT_MS = 60_000;
39
42
 
40
43
  /*
41
44
  * One `codex app-server` process and the JSON-RPC framing on its stdio: newline-delimited JSON,
@@ -48,6 +51,8 @@ export class CodexTransport {
48
51
  private readonly pending = new Map<number, Settle>();
49
52
  private nextId = 1;
50
53
  private closed = false;
54
+ // Set once stdin ended or broke; a write after an EPIPE only raises the next one.
55
+ private inputClosed = false;
51
56
 
52
57
  constructor(options: CodexTransportOptions) {
53
58
  this.options = options;
@@ -56,7 +61,7 @@ export class CodexTransport {
56
61
  cwd: options.cwd,
57
62
  env: options.env,
58
63
  ...(options.spawn ? { spawn: options.spawn } : {}),
59
- onError: (error) => this.failed(error),
64
+ onError: (error) => this.broken(error),
60
65
  onExit: (exitCode, stderr) => this.handleExit(exitCode, stderr)
61
66
  });
62
67
  void this.readLines();
@@ -77,10 +82,16 @@ export class CodexTransport {
77
82
  return;
78
83
  }
79
84
  const id = this.nextId++;
80
- this.pending.set(id, { resolve, reject, method });
85
+ const timer = setTimeout(() => {
86
+ this.pending.delete(id);
87
+ reject(new Error(`${method} failed: Codex did not answer in ${REQUEST_TIMEOUT_MS / 1000} seconds`));
88
+ }, REQUEST_TIMEOUT_MS);
89
+ timer.unref?.();
90
+ this.pending.set(id, { resolve, reject, method, timer });
81
91
  try {
82
92
  this.write({ id, method, params });
83
93
  } catch (error) {
94
+ clearTimeout(timer);
84
95
  this.pending.delete(id);
85
96
  reject(error);
86
97
  }
@@ -101,6 +112,7 @@ export class CodexTransport {
101
112
 
102
113
  /* Closes stdin; the app-server ends on its own once it has nothing more to say. */
103
114
  end(): void {
115
+ this.inputClosed = true;
104
116
  try {
105
117
  this.child.process.stdin.end();
106
118
  } catch {
@@ -122,19 +134,27 @@ export class CodexTransport {
122
134
  }
123
135
 
124
136
  private write(frame: CodexFrame): void {
125
- if (this.closed) {
137
+ if (this.closed || this.inputClosed) {
126
138
  throw new Error('Codex stdin is closed');
127
139
  }
128
- this.child.process.stdin.write(`${JSON.stringify(frame)}\n`);
129
- this.child.process.stdin.flush();
140
+ try {
141
+ this.child.process.stdin.write(`${JSON.stringify(frame)}\n`);
142
+ this.child.process.stdin.flush();
143
+ } catch (error) {
144
+ this.inputClosed = true;
145
+ throw error;
146
+ }
147
+ }
148
+
149
+ /* A pipe that failed takes no more writes; a frame a consumer choked on leaves it as it was. */
150
+ private broken(error: unknown): void {
151
+ this.inputClosed = true;
152
+ this.failed(error);
130
153
  }
131
154
 
132
155
  private failed(error: unknown): void {
133
156
  const failure = new Error(`Codex transport failed: ${errorText(error)}`);
134
- for (const settle of this.pending.values()) {
135
- settle.reject(failure);
136
- }
137
- this.pending.clear();
157
+ this.rejectPending(() => failure);
138
158
  try {
139
159
  this.options.onError?.(failure);
140
160
  } catch (reportError) {
@@ -151,32 +171,16 @@ export class CodexTransport {
151
171
  }
152
172
 
153
173
  private async readLines(): Promise<void> {
154
- const reader = this.child.process.stdout.getReader();
155
- const decoder = new TextDecoder();
156
- let buffered = '';
157
174
  try {
158
- while (true) {
159
- const { value, done } = await reader.read();
160
- if (done) {
161
- break;
162
- }
163
- buffered += decoder.decode(value, { stream: true });
164
- let newline = buffered.indexOf('\n');
165
- while (newline >= 0) {
166
- this.readLine(buffered.slice(0, newline));
167
- buffered = buffered.slice(newline + 1);
168
- newline = buffered.indexOf('\n');
169
- }
170
- }
171
- if (buffered.trim() !== '') {
172
- this.readLine(buffered);
173
- }
175
+ await readStreamLines(
176
+ this.child.process.stdout,
177
+ (line) => this.readLine(line),
178
+ () => this.failed(new Error(`Codex wrote a frame of more than ${MAX_LINE_LENGTH / 1_000_000} million characters, which was dropped`))
179
+ );
174
180
  } catch (error) {
175
181
  if (!this.closed) {
176
- this.failed(error);
182
+ this.broken(error);
177
183
  }
178
- } finally {
179
- reader.releaseLock();
180
184
  }
181
185
  }
182
186
 
@@ -203,6 +207,7 @@ export class CodexTransport {
203
207
  return;
204
208
  }
205
209
  this.pending.delete(message.id as number);
210
+ clearTimeout(settle.timer);
206
211
  if (message.error !== undefined) {
207
212
  settle.reject(new CodexRpcError(settle.method, message.error));
208
213
  } else {
@@ -215,10 +220,15 @@ export class CodexTransport {
215
220
  return;
216
221
  }
217
222
  this.closed = true;
223
+ this.rejectPending((method) => new Error(`${method} failed: Codex exited`));
224
+ this.options.onExit(exitCode, stderr);
225
+ }
226
+
227
+ private rejectPending(reason: (method: string) => Error): void {
218
228
  for (const settle of this.pending.values()) {
219
- settle.reject(new Error(`${settle.method} failed: Codex exited`));
229
+ clearTimeout(settle.timer);
230
+ settle.reject(reason(settle.method));
220
231
  }
221
232
  this.pending.clear();
222
- this.options.onExit(exitCode, stderr);
223
233
  }
224
234
  }
@@ -1,4 +1,4 @@
1
- import { attachmentImageMime, type ChatContextBreakdown, type ChatItem } from '@adecore/agent-contracts';
1
+ import { attachmentImageMime, HOST_TASK_ORIGIN, type ChatContextBreakdown, type ChatItem } from '@adecore/agent-contracts';
2
2
 
3
3
  // A rough rate for prose and code alike; scaling to the CLI's own total absorbs the error.
4
4
  const CHARS_PER_TOKEN = 4;
@@ -60,7 +60,7 @@ export function estimateContextBreakdown(items: readonly ChatItem[], contextToke
60
60
  }
61
61
  case 'subagent':
62
62
  // 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.
63
- if (item.origin !== 'ruimte') {
63
+ if (item.origin !== HOST_TASK_ORIGIN) {
64
64
  toolOutput += textTokens(item.prompt ?? '') + textTokens(item.result ?? '');
65
65
  }
66
66
  break;
@@ -15,6 +15,7 @@ export type ChatErrorCode =
15
15
  // The sub-agent was started by a CLI process that has ended, so no process here can stop it.
16
16
  | 'subagent-stale'
17
17
  | 'task-not-found'
18
+ | 'goal-not-found'
18
19
  // What reading or forking a CLI's own conversation is refused with.
19
20
  | 'transcript-missing'
20
21
  | 'transcript-format'
@@ -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.ts';
4
4
  import { claudeProjectSlug, spokenText } from './claude-transcript.ts';
5
5
 
@@ -40,7 +40,7 @@ function records(head: string): Array<Record<string, unknown>> {
40
40
  }
41
41
  try {
42
42
  const parsed: unknown = JSON.parse(line);
43
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? [parsed as Record<string, unknown>] : [];
43
+ return isRecord(parsed) ? [parsed] : [];
44
44
  } catch {
45
45
  return [];
46
46
  }