@adecore/agents 0.27.1 → 0.27.3
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/dist/chat/backend.d.ts +1 -0
- package/dist/chat/chat-core.d.ts +1 -0
- package/dist/chat/chat-core.js +1 -0
- package/dist/chat/chat-session.d.ts +2 -0
- package/dist/chat/chat-session.js +33 -2
- package/dist/chat/claude-backend.d.ts +1 -0
- package/dist/chat/claude-backend.js +32 -0
- package/dist/chat/codex-backend.d.ts +1 -0
- package/dist/chat/codex-backend.js +24 -3
- package/dist/chat/projector.d.ts +1 -0
- package/dist/chat/projector.js +8 -1
- package/dist/tasks/wiring.js +8 -1
- package/dist/testing/fake-codex.d.ts +1 -0
- package/dist/testing/fake-codex.js +16 -0
- package/package.json +3 -3
- package/src/chat/backend.ts +3 -0
- package/src/chat/chat-core.ts +3 -0
- package/src/chat/chat-session.ts +40 -2
- package/src/chat/claude-backend.ts +33 -0
- package/src/chat/codex-backend.ts +24 -3
- package/src/chat/projector.ts +8 -1
- package/src/tasks/wiring.ts +7 -1
- package/src/testing/fake-codex.ts +18 -0
package/dist/chat/backend.d.ts
CHANGED
package/dist/chat/chat-core.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface ChatCoreHooks {
|
|
|
29
29
|
runtimeModeFor?(chatId: string, mode: RuntimeMode): RuntimeMode;
|
|
30
30
|
instructionsFor?(chatId: string): string | null;
|
|
31
31
|
resumeNoteFor?(chatId: string): string | null;
|
|
32
|
+
resumeInstructionsFor?(chatId: string): string | null;
|
|
32
33
|
foldersFor?(chatId: string): readonly string[];
|
|
33
34
|
projectConfigFor?(chatId: string): boolean;
|
|
34
35
|
hiddenFor?(chatId: string): boolean;
|
package/dist/chat/chat-core.js
CHANGED
|
@@ -306,6 +306,7 @@ export class ChatCore {
|
|
|
306
306
|
saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
|
|
307
307
|
instructions: () => (this.hooks.instructionsFor ? this.hooks.instructionsFor(payload.chatId) : this.instructions),
|
|
308
308
|
resumeNote: () => this.hooks.resumeNoteFor?.(payload.chatId) ?? null,
|
|
309
|
+
resumeInstructions: () => this.hooks.resumeInstructionsFor?.(payload.chatId) ?? null,
|
|
309
310
|
folders: () => this.hooks.foldersFor?.(payload.chatId) ?? [],
|
|
310
311
|
projectConfig: () => this.hooks.projectConfigFor?.(payload.chatId) ?? true,
|
|
311
312
|
...(promptNotes ? { promptNotes } : {}),
|
|
@@ -41,6 +41,7 @@ export interface ChatSessionOptions {
|
|
|
41
41
|
saveGeneratedImage?: BackendLaunch['saveGeneratedImage'];
|
|
42
42
|
instructions?(): string | null;
|
|
43
43
|
resumeNote?(): string | null;
|
|
44
|
+
resumeInstructions?(): string | null;
|
|
44
45
|
folders?(): readonly string[];
|
|
45
46
|
projectConfig?(): boolean;
|
|
46
47
|
promptNotes?: PromptNotes;
|
|
@@ -154,6 +155,7 @@ export declare class ChatSession {
|
|
|
154
155
|
turnId: string;
|
|
155
156
|
}>;
|
|
156
157
|
private submitInput;
|
|
158
|
+
private steer;
|
|
157
159
|
unqueue(messageId: string): ChatQueuedMessage | null;
|
|
158
160
|
sendNow(messageId: string): boolean;
|
|
159
161
|
private get queue();
|
|
@@ -12,6 +12,8 @@ import { errorText } from '../error-text.js';
|
|
|
12
12
|
import { systemClock } from '../outbox/outbox-worker.js';
|
|
13
13
|
// Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
|
|
14
14
|
const TITLE_RECHECK_MS = 10_000;
|
|
15
|
+
// Well inside the 60 seconds a client gives a request; the steer itself goes on after it.
|
|
16
|
+
const STEER_ANSWER_MS = 15_000;
|
|
15
17
|
/* One per limited turn, so a second fork of the same turn writes no second note. */
|
|
16
18
|
function continuedNoteId(turnId) {
|
|
17
19
|
return `continued-${turnId}`;
|
|
@@ -383,9 +385,30 @@ export class ChatSession {
|
|
|
383
385
|
return queued;
|
|
384
386
|
}
|
|
385
387
|
this.steering.add(message.id);
|
|
388
|
+
const steer = this.steer(message, text, extras, backend.steerTurn.bind(backend), turnId, queued);
|
|
389
|
+
// Claude takes a steer only between tool calls, so a long command would outlast the client's request.
|
|
390
|
+
const clock = this.options.clock ?? systemClock;
|
|
391
|
+
let timer = null;
|
|
392
|
+
const waited = new Promise((resolve) => {
|
|
393
|
+
timer = clock.setTimeout(() => resolve(null), STEER_ANSWER_MS);
|
|
394
|
+
});
|
|
395
|
+
try {
|
|
396
|
+
const answer = await Promise.race([steer, waited]);
|
|
397
|
+
if (answer !== null) {
|
|
398
|
+
return answer;
|
|
399
|
+
}
|
|
400
|
+
// Still in the queue until it is taken; a later failure pauses the queue as it does now.
|
|
401
|
+
steer.catch(() => undefined);
|
|
402
|
+
return queued;
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
clock.clearTimeout(timer);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async steer(message, text, extras, steerTurn, turnId, queued) {
|
|
386
409
|
try {
|
|
387
410
|
const references = this.options.references?.(extras.chats) ?? { ids: extras.chats ?? [], note: null };
|
|
388
|
-
const accepted = await
|
|
411
|
+
const accepted = await steerTurn({
|
|
389
412
|
promptId: message.id,
|
|
390
413
|
text,
|
|
391
414
|
preamble: references.note,
|
|
@@ -1475,7 +1498,14 @@ export class ChatSession {
|
|
|
1475
1498
|
return;
|
|
1476
1499
|
}
|
|
1477
1500
|
const pending = this.watchStart(turnId);
|
|
1478
|
-
|
|
1501
|
+
let backend;
|
|
1502
|
+
try {
|
|
1503
|
+
backend = this.ensureBackend();
|
|
1504
|
+
}
|
|
1505
|
+
catch (error) {
|
|
1506
|
+
// What the host prepares for a launch (its folders, its instructions) can throw too; the turn already opened, so it fails.
|
|
1507
|
+
backend = Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
1508
|
+
}
|
|
1479
1509
|
const generation = this.generation;
|
|
1480
1510
|
void Promise.all([backend, this.turnReady, this.options.save?.()])
|
|
1481
1511
|
.then(([started]) => {
|
|
@@ -1599,6 +1629,7 @@ export class ChatSession {
|
|
|
1599
1629
|
saveGeneratedImage: this.options.saveGeneratedImage,
|
|
1600
1630
|
instructions: this.options.instructions?.() ?? null,
|
|
1601
1631
|
resumeNote: this.options.resumeNote?.() ?? null,
|
|
1632
|
+
resumeInstructions: this.options.resumeInstructions?.() ?? null,
|
|
1602
1633
|
...(info.goal === undefined ? {} : { goal: info.goal }),
|
|
1603
1634
|
...(folders.length > 0 ? { folders } : {}),
|
|
1604
1635
|
...(projectConfig ? {} : { projectConfig }),
|
|
@@ -7,6 +7,8 @@ import { buildUserMessage } from './input.js';
|
|
|
7
7
|
// Claude may echo a steer only once a slow tool returns, so a running turn sets no deadline; after the turn ended, a message
|
|
8
8
|
// it still holds opens the next turn at once or never arrives.
|
|
9
9
|
const STEER_GRACE_MS = 30_000;
|
|
10
|
+
// The request id of a `cancel_async_message` that withdraws a steer, followed by its uuid.
|
|
11
|
+
const CANCEL_STEER = 'cancel-steer-';
|
|
10
12
|
/*
|
|
11
13
|
* One `claude -p` process on the stream-json protocol: flags from the selection and the modes,
|
|
12
14
|
* frames in over stdout, frames out over stdin. The process stays alive between turns; the session
|
|
@@ -101,6 +103,14 @@ export class ClaudeBackend {
|
|
|
101
103
|
this.interrupted = true;
|
|
102
104
|
this.protocol.noteInterrupt();
|
|
103
105
|
this.write({ type: 'control_request', request_id: `interrupt-${Date.now()}`, request: { subtype: 'interrupt' } });
|
|
106
|
+
// A steer the CLI has not taken in survives a plain interrupt and would run after the Stop; it goes back to the queue instead.
|
|
107
|
+
for (const promptId of this.steers.keys()) {
|
|
108
|
+
this.write({
|
|
109
|
+
type: 'control_request',
|
|
110
|
+
request_id: `${CANCEL_STEER}${promptId}`,
|
|
111
|
+
request: { subtype: 'cancel_async_message', message_uuid: promptId }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
104
114
|
}
|
|
105
115
|
stopTask(taskId) {
|
|
106
116
|
this.write(this.protocol.stopTaskRequest(taskId));
|
|
@@ -167,6 +177,9 @@ export class ClaudeBackend {
|
|
|
167
177
|
return;
|
|
168
178
|
}
|
|
169
179
|
const echoed = typeof frame === 'object' && frame !== null ? frame : null;
|
|
180
|
+
if (echoed?.type === 'control_response' && this.cancelledSteer(echoed.response)) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
170
183
|
if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
|
|
171
184
|
this.steers.get(echoed.uuid)?.resolve(true);
|
|
172
185
|
this.steers.delete(echoed.uuid);
|
|
@@ -195,6 +208,25 @@ export class ClaudeBackend {
|
|
|
195
208
|
this.failed(error);
|
|
196
209
|
}
|
|
197
210
|
}
|
|
211
|
+
/* Whether this answers a withdrawn steer; one the CLI no longer held (already taken in) still waits for its echo. */
|
|
212
|
+
cancelledSteer(response) {
|
|
213
|
+
if (typeof response !== 'object' || response === null) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
const { request_id: requestId, response: body } = response;
|
|
217
|
+
if (typeof requestId !== 'string' || !requestId.startsWith(CANCEL_STEER)) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
const promptId = requestId.slice(CANCEL_STEER.length);
|
|
221
|
+
if (typeof body === 'object' && body !== null && body.cancelled === true) {
|
|
222
|
+
this.steers.get(promptId)?.resolve(false);
|
|
223
|
+
this.steers.delete(promptId);
|
|
224
|
+
if (this.steers.size === 0) {
|
|
225
|
+
this.clearSteerTimer();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
198
230
|
awaitSteers() {
|
|
199
231
|
if (this.steers.size === 0 || this.steerTimer !== null) {
|
|
200
232
|
return;
|
|
@@ -68,7 +68,7 @@ export class CodexBackend {
|
|
|
68
68
|
imageWrites = null;
|
|
69
69
|
transport = null;
|
|
70
70
|
threadId = '';
|
|
71
|
-
// Codex
|
|
71
|
+
// Codex ignores developer instructions on `thread/resume` (measured on 0.154 and 0.163), so the first prompt of a resumed process carries them.
|
|
72
72
|
contextPending = null;
|
|
73
73
|
imageInputSupported = null;
|
|
74
74
|
/* A turn asked for that Codex has not named yet; `turn/interrupt` needs that name. */
|
|
@@ -131,7 +131,7 @@ export class CodexBackend {
|
|
|
131
131
|
if (this.launch.resume) {
|
|
132
132
|
try {
|
|
133
133
|
result = await transport.request('thread/resume', { threadId: this.launch.resume, excludeTurns: true, ...params });
|
|
134
|
-
this.contextPending = this.launch.
|
|
134
|
+
this.contextPending = await this.resumeContext(transport, this.launch.resume);
|
|
135
135
|
}
|
|
136
136
|
catch (error) {
|
|
137
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.
|
|
@@ -156,6 +156,26 @@ export class CodexBackend {
|
|
|
156
156
|
// An app-server without goals, or with the feature turned off, has none to report.
|
|
157
157
|
});
|
|
158
158
|
}
|
|
159
|
+
/*
|
|
160
|
+
* A developer message outranks the thread's first instructions, where a note in front of the prompt loses to
|
|
161
|
+
* them; an app-server without `thread/inject_items` gets the instructions in front of the prompt after all.
|
|
162
|
+
*/
|
|
163
|
+
async resumeContext(transport, threadId) {
|
|
164
|
+
const instructions = this.launch.resumeInstructions ?? null;
|
|
165
|
+
if (instructions !== null) {
|
|
166
|
+
try {
|
|
167
|
+
const items = [{ type: 'message', role: 'developer', content: [{ type: 'input_text', text: instructions }] }];
|
|
168
|
+
await transport.request('thread/inject_items', { threadId, items });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (!(error instanceof Error) || error.name !== 'CodexRpcError') {
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
return [instructions, this.launch.resumeNote].filter((part) => part !== null).join('\n\n');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return this.launch.resumeNote;
|
|
178
|
+
}
|
|
159
179
|
async readGoal(transport) {
|
|
160
180
|
const result = await transport.request('thread/goal/get', { threadId: this.threadId });
|
|
161
181
|
const goal = codexGoal(isRecord(result) ? result.goal : null);
|
|
@@ -318,7 +338,8 @@ export class CodexBackend {
|
|
|
318
338
|
}
|
|
319
339
|
catch (error) {
|
|
320
340
|
const rejected = error instanceof Error && error.name === 'CodexRpcError';
|
|
321
|
-
|
|
341
|
+
// A compact or review turn takes no steer (codex 0.162: "cannot steer a compact turn"); the message waits for the next turn.
|
|
342
|
+
if (rejected && /no active turn|not.*active|turn.*mismatch|expected.*turn|method not found|cannot steer|not steerable/i.test(error.message)) {
|
|
322
343
|
return false;
|
|
323
344
|
}
|
|
324
345
|
throw error;
|
package/dist/chat/projector.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export declare class ThreadProjector {
|
|
|
21
21
|
private readonly pendingMetadata;
|
|
22
22
|
private readonly backgroundCalls;
|
|
23
23
|
private readonly unplacedCalls;
|
|
24
|
+
private readonly wokenRows;
|
|
24
25
|
private thinking;
|
|
25
26
|
constructor(thread: ChatThread, options: ProjectorOptions);
|
|
26
27
|
reset(): void;
|
package/dist/chat/projector.js
CHANGED
|
@@ -100,6 +100,8 @@ export class ThreadProjector {
|
|
|
100
100
|
backgroundCalls = new Set();
|
|
101
101
|
// Agents a subagent opened whose call never reached the stream (a grandchild's own); only drill-in shows them.
|
|
102
102
|
unplacedCalls = new Set();
|
|
103
|
+
// The row of an earlier process an agent woke under, by the ref of its own call in this one: its frames still name that call.
|
|
104
|
+
wokenRows = new Map();
|
|
103
105
|
// The stretch of thinking still open: its item, and which refs already streamed into it.
|
|
104
106
|
thinking = null;
|
|
105
107
|
constructor(thread, options) {
|
|
@@ -549,7 +551,8 @@ export class ThreadProjector {
|
|
|
549
551
|
events.push(this.thread.upsert({ ...tool, progress }));
|
|
550
552
|
}
|
|
551
553
|
subagent(generation, ref) {
|
|
552
|
-
const
|
|
554
|
+
const id = this.itemId(generation, ref);
|
|
555
|
+
const item = this.thread.get(id) ?? this.thread.get(this.wokenRows.get(id) ?? '');
|
|
553
556
|
return item?.kind === 'subagent' ? item : null;
|
|
554
557
|
}
|
|
555
558
|
/*
|
|
@@ -652,6 +655,10 @@ export class ThreadProjector {
|
|
|
652
655
|
}
|
|
653
656
|
// A settled agent that starts again under another call was woken by a message, and works on in the background.
|
|
654
657
|
const resumed = event.resumed === true || (item.status !== 'running' && item.toolUseId !== event.ref);
|
|
658
|
+
const ownCall = this.itemId(generation, item.toolUseId);
|
|
659
|
+
if (resumed && item.id !== ownCall) {
|
|
660
|
+
this.wokenRows.set(ownCall, item.id);
|
|
661
|
+
}
|
|
655
662
|
events.push(this.thread.upsert({
|
|
656
663
|
...item,
|
|
657
664
|
description: item.description || event.description || '',
|
package/dist/tasks/wiring.js
CHANGED
|
@@ -104,9 +104,16 @@ export function wireTasks(deps) {
|
|
|
104
104
|
coordinator,
|
|
105
105
|
recover: async () => {
|
|
106
106
|
for (const task of deps.tasks.all()) {
|
|
107
|
-
if (task.status
|
|
107
|
+
if (task.status !== 'open') {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// One child whose record this version cannot read must not keep every later task from recovering.
|
|
111
|
+
try {
|
|
108
112
|
await recoverOpen(task);
|
|
109
113
|
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
console.error(`Recovering task ${task.id} failed:`, errorText(e));
|
|
116
|
+
}
|
|
110
117
|
}
|
|
111
118
|
await coordinator.settled();
|
|
112
119
|
for (const task of deps.tasks.all()) {
|
|
@@ -4,6 +4,7 @@ export declare const FAKE_CHILD_STEPS = 250;
|
|
|
4
4
|
export declare const fakeCodexForks: Frame[];
|
|
5
5
|
export interface FakeCodexOptions {
|
|
6
6
|
resumeLead?: string;
|
|
7
|
+
injectItems?: boolean;
|
|
7
8
|
}
|
|
8
9
|
export declare function fakeCodexWith(options: FakeCodexOptions): FakeCli;
|
|
9
10
|
export declare const fakeCodex: FakeCli;
|
|
@@ -451,6 +451,22 @@ export function fakeCodexWith(options) {
|
|
|
451
451
|
notify('thread/started', { thread });
|
|
452
452
|
return;
|
|
453
453
|
}
|
|
454
|
+
case 'thread/inject_items': {
|
|
455
|
+
if (options.injectItems === false) {
|
|
456
|
+
out({ id, error: { code: -32601, message: 'unknown method thread/inject_items' } });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
// The newest developer message is the one the model follows, so `note?` answers with it.
|
|
460
|
+
const items = Array.isArray(params.items) ? params.items : [];
|
|
461
|
+
for (const injected of items) {
|
|
462
|
+
const content = Array.isArray(injected.content) ? injected.content[0] : undefined;
|
|
463
|
+
if (injected.role === 'developer' && typeof content?.text === 'string') {
|
|
464
|
+
threadInstructions.set(String(params.threadId), content.text);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
out({ id, result: {} });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
454
470
|
case 'thread/fork': {
|
|
455
471
|
const source = String(params.threadId);
|
|
456
472
|
const turns = threadTurns.get(source) ?? [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.3",
|
|
4
4
|
"description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"test": "bun --conditions=source test"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@adecore/agent-contracts": "0.27.
|
|
85
|
-
"@adecore/intelligent-ui": "0.27.
|
|
84
|
+
"@adecore/agent-contracts": "0.27.3",
|
|
85
|
+
"@adecore/intelligent-ui": "0.27.3",
|
|
86
86
|
"zod": "^4.6.5"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
package/src/chat/backend.ts
CHANGED
|
@@ -40,6 +40,9 @@ export interface BackendLaunch {
|
|
|
40
40
|
// The part of it a resumed thread has to hear again in front of its next prompt, for a CLI that ignores new
|
|
41
41
|
// instructions on a resume; null when there is nothing to repeat.
|
|
42
42
|
resumeNote: string | null;
|
|
43
|
+
// Instructions a resumed thread takes in as a developer message, for a CLI that keeps the ones its thread started
|
|
44
|
+
// with; absent or null when the thread holds them already.
|
|
45
|
+
resumeInstructions?: string | null;
|
|
43
46
|
// The goal the chat last knew of, which a CLI that takes it up again from its own transcript on a resume still works toward.
|
|
44
47
|
goal?: ChatGoal;
|
|
45
48
|
// Folders outside `cwd` the agent may reach without asking. Claude Code takes them as `--add-dir`; Codex's
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -119,6 +119,8 @@ export interface ChatCoreHooks {
|
|
|
119
119
|
instructionsFor?(chatId: string): string | null;
|
|
120
120
|
/* What of the instructions a resumed thread hears again in front of its next prompt, for a CLI that ignores them on a resume. */
|
|
121
121
|
resumeNoteFor?(chatId: string): string | null;
|
|
122
|
+
/* Instructions a resumed thread takes in again as a developer message, for a CLI that keeps those its thread started with; null when it holds them. */
|
|
123
|
+
resumeInstructionsFor?(chatId: string): string | null;
|
|
122
124
|
/*
|
|
123
125
|
* Folders outside its working directory a chat's agent may reach without asking, absolute. Asked
|
|
124
126
|
* before every turn, so a host can hand one over for as long as it is needed: a change starts the
|
|
@@ -495,6 +497,7 @@ export class ChatCore {
|
|
|
495
497
|
saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
|
|
496
498
|
instructions: () => (this.hooks.instructionsFor ? this.hooks.instructionsFor(payload.chatId) : this.instructions),
|
|
497
499
|
resumeNote: () => this.hooks.resumeNoteFor?.(payload.chatId) ?? null,
|
|
500
|
+
resumeInstructions: () => this.hooks.resumeInstructionsFor?.(payload.chatId) ?? null,
|
|
498
501
|
folders: () => this.hooks.foldersFor?.(payload.chatId) ?? [],
|
|
499
502
|
projectConfig: () => this.hooks.projectConfigFor?.(payload.chatId) ?? true,
|
|
500
503
|
...(promptNotes ? { promptNotes } : {}),
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -76,6 +76,7 @@ export interface ChatSessionOptions {
|
|
|
76
76
|
// What the agent is told at the start of every process, and what of it a resumed thread hears again; see `BackendLaunch`.
|
|
77
77
|
instructions?(): string | null;
|
|
78
78
|
resumeNote?(): string | null;
|
|
79
|
+
resumeInstructions?(): string | null;
|
|
79
80
|
// The folders beside the working directory, asked before every turn; a change starts the next turn in a new process.
|
|
80
81
|
folders?(): readonly string[];
|
|
81
82
|
// False runs the CLI without the configuration of the working folder; asked before every turn like the folders.
|
|
@@ -116,6 +117,8 @@ export interface LimitResumeHooks {
|
|
|
116
117
|
|
|
117
118
|
// Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
|
|
118
119
|
const TITLE_RECHECK_MS = 10_000;
|
|
120
|
+
// Well inside the 60 seconds a client gives a request; the steer itself goes on after it.
|
|
121
|
+
const STEER_ANSWER_MS = 15_000;
|
|
119
122
|
|
|
120
123
|
export interface ChatSendExtras {
|
|
121
124
|
uiChoice?: ChatUiChoiceOrigin;
|
|
@@ -544,9 +547,37 @@ export class ChatSession {
|
|
|
544
547
|
return queued;
|
|
545
548
|
}
|
|
546
549
|
this.steering.add(message.id);
|
|
550
|
+
const steer = this.steer(message, text, extras, backend.steerTurn.bind(backend), turnId, queued);
|
|
551
|
+
// Claude takes a steer only between tool calls, so a long command would outlast the client's request.
|
|
552
|
+
const clock = this.options.clock ?? systemClock;
|
|
553
|
+
let timer: unknown = null;
|
|
554
|
+
const waited = new Promise<null>((resolve) => {
|
|
555
|
+
timer = clock.setTimeout(() => resolve(null), STEER_ANSWER_MS);
|
|
556
|
+
});
|
|
557
|
+
try {
|
|
558
|
+
const answer = await Promise.race([steer, waited]);
|
|
559
|
+
if (answer !== null) {
|
|
560
|
+
return answer;
|
|
561
|
+
}
|
|
562
|
+
// Still in the queue until it is taken; a later failure pauses the queue as it does now.
|
|
563
|
+
steer.catch(() => undefined);
|
|
564
|
+
return queued;
|
|
565
|
+
} finally {
|
|
566
|
+
clock.clearTimeout(timer);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private async steer(
|
|
571
|
+
message: ChatQueuedMessage,
|
|
572
|
+
text: string,
|
|
573
|
+
extras: ChatSendExtras,
|
|
574
|
+
steerTurn: NonNullable<ChatBackend['steerTurn']>,
|
|
575
|
+
turnId: string,
|
|
576
|
+
queued: { queued: boolean; turnId: string }
|
|
577
|
+
): Promise<{ queued: boolean; turnId: string }> {
|
|
547
578
|
try {
|
|
548
579
|
const references = this.options.references?.(extras.chats) ?? { ids: extras.chats ?? [], note: null };
|
|
549
|
-
const accepted = await
|
|
580
|
+
const accepted = await steerTurn({
|
|
550
581
|
promptId: message.id,
|
|
551
582
|
text,
|
|
552
583
|
preamble: references.note,
|
|
@@ -1705,7 +1736,13 @@ export class ChatSession {
|
|
|
1705
1736
|
return;
|
|
1706
1737
|
}
|
|
1707
1738
|
const pending = this.watchStart(turnId);
|
|
1708
|
-
|
|
1739
|
+
let backend: Promise<ChatBackend>;
|
|
1740
|
+
try {
|
|
1741
|
+
backend = this.ensureBackend();
|
|
1742
|
+
} catch (error) {
|
|
1743
|
+
// What the host prepares for a launch (its folders, its instructions) can throw too; the turn already opened, so it fails.
|
|
1744
|
+
backend = Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
1745
|
+
}
|
|
1709
1746
|
const generation = this.generation;
|
|
1710
1747
|
void Promise.all([backend, this.turnReady, this.options.save?.()])
|
|
1711
1748
|
.then(([started]) => {
|
|
@@ -1840,6 +1877,7 @@ export class ChatSession {
|
|
|
1840
1877
|
saveGeneratedImage: this.options.saveGeneratedImage,
|
|
1841
1878
|
instructions: this.options.instructions?.() ?? null,
|
|
1842
1879
|
resumeNote: this.options.resumeNote?.() ?? null,
|
|
1880
|
+
resumeInstructions: this.options.resumeInstructions?.() ?? null,
|
|
1843
1881
|
...(info.goal === undefined ? {} : { goal: info.goal }),
|
|
1844
1882
|
...(folders.length > 0 ? { folders } : {}),
|
|
1845
1883
|
...(projectConfig ? {} : { projectConfig }),
|
|
@@ -9,6 +9,8 @@ import { buildUserMessage } from './input.ts';
|
|
|
9
9
|
// Claude may echo a steer only once a slow tool returns, so a running turn sets no deadline; after the turn ended, a message
|
|
10
10
|
// it still holds opens the next turn at once or never arrives.
|
|
11
11
|
const STEER_GRACE_MS = 30_000;
|
|
12
|
+
// The request id of a `cancel_async_message` that withdraws a steer, followed by its uuid.
|
|
13
|
+
const CANCEL_STEER = 'cancel-steer-';
|
|
12
14
|
|
|
13
15
|
export interface ClaudeBackendOptions {
|
|
14
16
|
// Tools the CLI runs without asking, in every mode, as `--allowedTools` patterns (`Bash(git status)`).
|
|
@@ -116,6 +118,14 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
116
118
|
this.interrupted = true;
|
|
117
119
|
this.protocol.noteInterrupt();
|
|
118
120
|
this.write({ type: 'control_request', request_id: `interrupt-${Date.now()}`, request: { subtype: 'interrupt' } });
|
|
121
|
+
// A steer the CLI has not taken in survives a plain interrupt and would run after the Stop; it goes back to the queue instead.
|
|
122
|
+
for (const promptId of this.steers.keys()) {
|
|
123
|
+
this.write({
|
|
124
|
+
type: 'control_request',
|
|
125
|
+
request_id: `${CANCEL_STEER}${promptId}`,
|
|
126
|
+
request: { subtype: 'cancel_async_message', message_uuid: promptId }
|
|
127
|
+
});
|
|
128
|
+
}
|
|
119
129
|
}
|
|
120
130
|
|
|
121
131
|
stopTask(taskId: string): void {
|
|
@@ -193,6 +203,9 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
193
203
|
return;
|
|
194
204
|
}
|
|
195
205
|
const echoed = typeof frame === 'object' && frame !== null ? (frame as Record<string, unknown>) : null;
|
|
206
|
+
if (echoed?.type === 'control_response' && this.cancelledSteer(echoed.response)) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
196
209
|
if (echoed?.type === 'user' && typeof echoed.uuid === 'string' && echoed.parent_tool_use_id == null) {
|
|
197
210
|
this.steers.get(echoed.uuid)?.resolve(true);
|
|
198
211
|
this.steers.delete(echoed.uuid);
|
|
@@ -222,6 +235,26 @@ export class ClaudeBackend implements ChatBackend {
|
|
|
222
235
|
}
|
|
223
236
|
}
|
|
224
237
|
|
|
238
|
+
/* Whether this answers a withdrawn steer; one the CLI no longer held (already taken in) still waits for its echo. */
|
|
239
|
+
private cancelledSteer(response: unknown): boolean {
|
|
240
|
+
if (typeof response !== 'object' || response === null) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const { request_id: requestId, response: body } = response as { request_id?: unknown; response?: unknown };
|
|
244
|
+
if (typeof requestId !== 'string' || !requestId.startsWith(CANCEL_STEER)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const promptId = requestId.slice(CANCEL_STEER.length);
|
|
248
|
+
if (typeof body === 'object' && body !== null && (body as { cancelled?: unknown }).cancelled === true) {
|
|
249
|
+
this.steers.get(promptId)?.resolve(false);
|
|
250
|
+
this.steers.delete(promptId);
|
|
251
|
+
if (this.steers.size === 0) {
|
|
252
|
+
this.clearSteerTimer();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
225
258
|
private awaitSteers(): void {
|
|
226
259
|
if (this.steers.size === 0 || this.steerTimer !== null) {
|
|
227
260
|
return;
|
|
@@ -81,7 +81,7 @@ export class CodexBackend implements ChatBackend {
|
|
|
81
81
|
private imageWrites: Promise<void> | null = null;
|
|
82
82
|
private transport: CodexTransport | null = null;
|
|
83
83
|
private threadId = '';
|
|
84
|
-
// Codex
|
|
84
|
+
// Codex ignores developer instructions on `thread/resume` (measured on 0.154 and 0.163), so the first prompt of a resumed process carries them.
|
|
85
85
|
private contextPending: string | null = null;
|
|
86
86
|
private imageInputSupported: boolean | null = null;
|
|
87
87
|
/* A turn asked for that Codex has not named yet; `turn/interrupt` needs that name. */
|
|
@@ -148,7 +148,7 @@ export class CodexBackend implements ChatBackend {
|
|
|
148
148
|
if (this.launch.resume) {
|
|
149
149
|
try {
|
|
150
150
|
result = await transport.request('thread/resume', { threadId: this.launch.resume, excludeTurns: true, ...params });
|
|
151
|
-
this.contextPending = this.launch.
|
|
151
|
+
this.contextPending = await this.resumeContext(transport, this.launch.resume);
|
|
152
152
|
} catch (error) {
|
|
153
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
154
|
if (!(error instanceof Error) || error.name !== 'CodexRpcError') {
|
|
@@ -172,6 +172,26 @@ export class CodexBackend implements ChatBackend {
|
|
|
172
172
|
});
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/*
|
|
176
|
+
* A developer message outranks the thread's first instructions, where a note in front of the prompt loses to
|
|
177
|
+
* them; an app-server without `thread/inject_items` gets the instructions in front of the prompt after all.
|
|
178
|
+
*/
|
|
179
|
+
private async resumeContext(transport: CodexTransport, threadId: string): Promise<string | null> {
|
|
180
|
+
const instructions = this.launch.resumeInstructions ?? null;
|
|
181
|
+
if (instructions !== null) {
|
|
182
|
+
try {
|
|
183
|
+
const items = [{ type: 'message', role: 'developer', content: [{ type: 'input_text', text: instructions }] }];
|
|
184
|
+
await transport.request('thread/inject_items', { threadId, items });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (!(error instanceof Error) || error.name !== 'CodexRpcError') {
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
return [instructions, this.launch.resumeNote].filter((part) => part !== null).join('\n\n');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return this.launch.resumeNote;
|
|
193
|
+
}
|
|
194
|
+
|
|
175
195
|
private async readGoal(transport: CodexTransport): Promise<ChatGoal | null> {
|
|
176
196
|
const result = await transport.request('thread/goal/get', { threadId: this.threadId });
|
|
177
197
|
const goal = codexGoal(isRecord(result) ? result.goal : null);
|
|
@@ -338,7 +358,8 @@ export class CodexBackend implements ChatBackend {
|
|
|
338
358
|
return true;
|
|
339
359
|
} catch (error) {
|
|
340
360
|
const rejected = error instanceof Error && error.name === 'CodexRpcError';
|
|
341
|
-
|
|
361
|
+
// A compact or review turn takes no steer (codex 0.162: "cannot steer a compact turn"); the message waits for the next turn.
|
|
362
|
+
if (rejected && /no active turn|not.*active|turn.*mismatch|expected.*turn|method not found|cannot steer|not steerable/i.test(error.message)) {
|
|
342
363
|
return false;
|
|
343
364
|
}
|
|
344
365
|
throw error;
|
package/src/chat/projector.ts
CHANGED
|
@@ -131,6 +131,8 @@ export class ThreadProjector {
|
|
|
131
131
|
private readonly backgroundCalls = new Set<string>();
|
|
132
132
|
// Agents a subagent opened whose call never reached the stream (a grandchild's own); only drill-in shows them.
|
|
133
133
|
private readonly unplacedCalls = new Set<string>();
|
|
134
|
+
// The row of an earlier process an agent woke under, by the ref of its own call in this one: its frames still name that call.
|
|
135
|
+
private readonly wokenRows = new Map<string, string>();
|
|
134
136
|
// The stretch of thinking still open: its item, and which refs already streamed into it.
|
|
135
137
|
private thinking: { id: string; refs: Set<string>; last: string | null } | null = null;
|
|
136
138
|
|
|
@@ -610,7 +612,8 @@ export class ThreadProjector {
|
|
|
610
612
|
}
|
|
611
613
|
|
|
612
614
|
private subagent(generation: number, ref: string): ChatSubagentItem | null {
|
|
613
|
-
const
|
|
615
|
+
const id = this.itemId(generation, ref);
|
|
616
|
+
const item = this.thread.get(id) ?? this.thread.get(this.wokenRows.get(id) ?? '');
|
|
614
617
|
return item?.kind === 'subagent' ? item : null;
|
|
615
618
|
}
|
|
616
619
|
|
|
@@ -719,6 +722,10 @@ export class ThreadProjector {
|
|
|
719
722
|
}
|
|
720
723
|
// A settled agent that starts again under another call was woken by a message, and works on in the background.
|
|
721
724
|
const resumed = event.resumed === true || (item.status !== 'running' && item.toolUseId !== event.ref);
|
|
725
|
+
const ownCall = this.itemId(generation, item.toolUseId);
|
|
726
|
+
if (resumed && item.id !== ownCall) {
|
|
727
|
+
this.wokenRows.set(ownCall, item.id);
|
|
728
|
+
}
|
|
722
729
|
events.push(
|
|
723
730
|
this.thread.upsert({
|
|
724
731
|
...item,
|
package/src/tasks/wiring.ts
CHANGED
|
@@ -181,8 +181,14 @@ export function wireTasks(deps: TaskWiringDeps): TaskWiring {
|
|
|
181
181
|
coordinator,
|
|
182
182
|
recover: async () => {
|
|
183
183
|
for (const task of deps.tasks.all()) {
|
|
184
|
-
if (task.status
|
|
184
|
+
if (task.status !== 'open') {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
// One child whose record this version cannot read must not keep every later task from recovering.
|
|
188
|
+
try {
|
|
185
189
|
await recoverOpen(task);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.error(`Recovering task ${task.id} failed:`, errorText(e));
|
|
186
192
|
}
|
|
187
193
|
}
|
|
188
194
|
await coordinator.settled();
|
|
@@ -26,6 +26,8 @@ export const fakeCodexForks: Frame[] = [];
|
|
|
26
26
|
export interface FakeCodexOptions {
|
|
27
27
|
// How the note a host repeats in front of a resumed thread's first prompt starts; `pasted?` answers it back.
|
|
28
28
|
resumeLead?: string;
|
|
29
|
+
// False answers `thread/inject_items` the way an app-server without it does.
|
|
30
|
+
injectItems?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export function fakeCodexWith(options: FakeCodexOptions): FakeCli {
|
|
@@ -490,6 +492,22 @@ export function fakeCodexWith(options: FakeCodexOptions): FakeCli {
|
|
|
490
492
|
notify('thread/started', { thread });
|
|
491
493
|
return;
|
|
492
494
|
}
|
|
495
|
+
case 'thread/inject_items': {
|
|
496
|
+
if (options.injectItems === false) {
|
|
497
|
+
out({ id, error: { code: -32601, message: 'unknown method thread/inject_items' } });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// The newest developer message is the one the model follows, so `note?` answers with it.
|
|
501
|
+
const items = Array.isArray(params.items) ? (params.items as Frame[]) : [];
|
|
502
|
+
for (const injected of items) {
|
|
503
|
+
const content = Array.isArray(injected.content) ? (injected.content[0] as { text?: unknown } | undefined) : undefined;
|
|
504
|
+
if (injected.role === 'developer' && typeof content?.text === 'string') {
|
|
505
|
+
threadInstructions.set(String(params.threadId), content.text);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
out({ id, result: {} });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
493
511
|
case 'thread/fork': {
|
|
494
512
|
const source = String(params.threadId);
|
|
495
513
|
const turns = threadTurns.get(source) ?? [];
|