@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.
- package/README.md +3 -3
- package/dist/chat/attachment-store.js +4 -5
- package/dist/chat/backend.d.ts +6 -1
- package/dist/chat/background-work.d.ts +1 -1
- package/dist/chat/background-work.js +2 -1
- package/dist/chat/chat-core.d.ts +53 -161
- package/dist/chat/chat-core.js +146 -112
- package/dist/chat/chat-log.js +3 -3
- package/dist/chat/chat-process.d.ts +21 -0
- package/dist/chat/chat-process.js +109 -9
- package/dist/chat/chat-session.d.ts +10 -17
- package/dist/chat/chat-session.js +141 -182
- package/dist/chat/chat-store.d.ts +1 -0
- package/dist/chat/chat-store.js +105 -1
- package/dist/chat/claude-backend.d.ts +4 -0
- package/dist/chat/claude-backend.js +51 -27
- package/dist/chat/claude-protocol.d.ts +12 -0
- package/dist/chat/claude-protocol.js +137 -7
- package/dist/chat/claude-title.d.ts +1 -3
- package/dist/chat/claude-title.js +11 -39
- package/dist/chat/claude-transcript.d.ts +1 -0
- package/dist/chat/claude-transcript.js +36 -17
- package/dist/chat/codex-backend.d.ts +21 -1
- package/dist/chat/codex-backend.js +168 -10
- package/dist/chat/codex-protocol.d.ts +2 -0
- package/dist/chat/codex-protocol.js +37 -3
- package/dist/chat/codex-thread.js +1 -3
- package/dist/chat/codex-transport.d.ts +3 -0
- package/dist/chat/codex-transport.js +38 -35
- package/dist/chat/context-breakdown.js +2 -2
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/external-sessions.js +2 -2
- package/dist/chat/fixtures/claude-2.1.296-goal.json +136 -0
- package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
- package/dist/chat/index.d.ts +29 -0
- package/dist/chat/index.js +23 -0
- package/dist/chat/projector.d.ts +2 -1
- package/dist/chat/projector.js +12 -3
- package/dist/chat/request-summary.js +1 -4
- package/dist/chat/subagent-settlement.js +1 -3
- package/dist/chat/thread.d.ts +3 -0
- package/dist/chat/thread.js +20 -3
- package/dist/chat/ui-queries.d.ts +2 -0
- package/dist/chat/ui-queries.js +40 -5
- package/dist/chat/ui-replies.d.ts +33 -0
- package/dist/chat/ui-replies.js +152 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +3 -0
- package/dist/coordination/index.d.ts +15 -0
- package/dist/coordination/index.js +16 -0
- package/dist/host/handlers.js +8 -0
- package/dist/host/index.d.ts +7 -0
- package/dist/host/index.js +7 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +10 -0
- package/dist/providers/accounts/status.js +1 -3
- package/dist/providers/claude.js +3 -1
- package/dist/providers/codex.js +3 -1
- package/dist/providers/index.d.ts +14 -0
- package/dist/providers/index.js +12 -0
- package/dist/run-process.js +54 -14
- package/dist/skills.d.ts +1 -0
- package/dist/skills.js +4 -0
- package/dist/tasks/task-coordinator.d.ts +4 -0
- package/dist/tasks/task-coordinator.js +16 -2
- package/dist/tasks/wiring.js +7 -1
- package/dist/{providers/accounts/test-accounts.d.ts → testing/accounts.d.ts} +2 -2
- package/dist/{providers/accounts/test-accounts.js → testing/accounts.js} +3 -3
- package/dist/testing/bin/fake-claude.d.ts +1 -0
- package/dist/testing/bin/fake-claude.js +6 -0
- package/dist/testing/bin/fake-codex.d.ts +1 -0
- package/dist/testing/bin/fake-codex.js +6 -0
- package/dist/{chat → testing}/fake-claude.d.ts +1 -1
- package/dist/{chat → testing}/fake-claude.js +0 -4
- package/dist/{chat → testing}/fake-cli.d.ts +1 -1
- package/dist/{chat → testing}/fake-codex.d.ts +1 -1
- package/dist/{chat → testing}/fake-codex.js +70 -8
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +7 -0
- package/dist/{outbox → testing}/manual-clock.d.ts +1 -1
- package/dist/{watch-test-helpers.d.ts → testing/watch.d.ts} +1 -1
- package/dist/usage/exchange.d.ts +5 -3
- package/dist/usage/exchange.js +25 -16
- package/dist/usage/index-file.d.ts +1 -0
- package/dist/usage/index-file.js +20 -2
- package/dist/usage/index.d.ts +9 -0
- package/dist/usage/index.js +6 -0
- package/dist/usage/limits/normalize.js +1 -3
- package/dist/usage/pricing.d.ts +5 -3
- package/dist/usage/pricing.js +28 -19
- package/dist/usage/scanner.js +39 -11
- package/dist/usage/usage-service.js +8 -3
- package/package.json +72 -11
- package/src/chat/attachment-store.ts +4 -6
- package/src/chat/backend.ts +8 -0
- package/src/chat/background-work.ts +2 -2
- package/src/chat/chat-core.ts +221 -143
- package/src/chat/chat-log.ts +3 -3
- package/src/chat/chat-process.ts +128 -8
- package/src/chat/chat-session.ts +153 -204
- package/src/chat/chat-store.ts +104 -1
- package/src/chat/claude-backend.ts +58 -26
- package/src/chat/claude-protocol.ts +147 -8
- package/src/chat/claude-title.ts +11 -39
- package/src/chat/claude-transcript.ts +38 -18
- package/src/chat/codex-backend.ts +182 -20
- package/src/chat/codex-protocol.ts +40 -5
- package/src/chat/codex-thread.ts +1 -4
- package/src/chat/codex-transport.ts +45 -35
- package/src/chat/context-breakdown.ts +2 -2
- package/src/chat/errors.ts +1 -0
- package/src/chat/external-sessions.ts +2 -2
- package/src/chat/fixtures/claude-2.1.296-goal.json +136 -0
- package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
- package/src/chat/index.ts +49 -0
- package/src/chat/projector.ts +13 -4
- package/src/chat/request-summary.ts +8 -5
- package/src/chat/subagent-settlement.ts +1 -4
- package/src/chat/thread.ts +22 -3
- package/src/chat/ui-queries.ts +46 -5
- package/src/chat/ui-replies.ts +187 -0
- package/src/context/index.ts +26 -0
- package/src/coordination/index.ts +67 -0
- package/src/host/handlers.ts +8 -0
- package/src/host/index.ts +8 -0
- package/src/index.ts +20 -0
- package/src/providers/accounts/status.ts +1 -4
- package/src/providers/claude.ts +3 -1
- package/src/providers/codex.ts +3 -1
- package/src/providers/index.ts +15 -0
- package/src/run-process.ts +56 -16
- package/src/skills.ts +5 -0
- package/src/tasks/task-coordinator.ts +22 -2
- package/src/tasks/wiring.ts +7 -1
- package/src/{providers/accounts/test-accounts.ts → testing/accounts.ts} +4 -4
- package/src/testing/bin/fake-claude.ts +7 -0
- package/src/testing/bin/fake-codex.ts +7 -0
- package/src/{chat → testing}/fake-claude.ts +1 -5
- package/src/{chat → testing}/fake-cli.ts +1 -1
- package/src/{chat → testing}/fake-codex.ts +75 -6
- package/src/testing/index.ts +7 -0
- package/src/{outbox → testing}/manual-clock.ts +1 -1
- package/src/{watch-test-helpers.ts → testing/watch.ts} +1 -1
- package/src/usage/exchange.ts +31 -20
- package/src/usage/index-file.ts +23 -2
- package/src/usage/index.ts +10 -0
- package/src/usage/limits/normalize.ts +1 -4
- package/src/usage/pricing.ts +34 -23
- package/src/usage/scanner.ts +48 -11
- package/src/usage/usage-service.ts +8 -3
- /package/dist/{chat → testing}/fake-cli.js +0 -0
- /package/dist/{outbox → testing}/manual-clock.js +0 -0
- /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
|
-
//
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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;
|
package/src/chat/codex-thread.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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
|
-
|
|
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 !==
|
|
63
|
+
if (item.origin !== HOST_TASK_ORIGIN) {
|
|
64
64
|
toolOutput += textTokens(item.prompt ?? '') + textTokens(item.result ?? '');
|
|
65
65
|
}
|
|
66
66
|
break;
|
package/src/chat/errors.ts
CHANGED
|
@@ -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
|
|
43
|
+
return isRecord(parsed) ? [parsed] : [];
|
|
44
44
|
} catch {
|
|
45
45
|
return [];
|
|
46
46
|
}
|