@bridge4dev/runner 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompt.d.ts +53 -2
- package/dist/agent-prompt.js +44 -8
- package/dist/index.js +19 -0
- package/dist/protocol.d.ts +144 -2
- package/dist/protocol.js +62 -12
- package/dist/supervisor.d.ts +34 -1
- package/dist/supervisor.js +271 -12
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent-prompt.d.ts
CHANGED
|
@@ -23,7 +23,26 @@
|
|
|
23
23
|
* runner is the process that opens the file, and it is the only side that can
|
|
24
24
|
* see what the path actually resolves to on this disk.
|
|
25
25
|
*/
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Big enough for a real process document; small enough to stay a prompt.
|
|
28
|
+
*
|
|
29
|
+
* Raised from 32 KB to 64 KB in 0.36.0 (ticket #192). 32 KB was picked when the
|
|
30
|
+
* only prompts in existence were 20–25 KB, and the number was never visible
|
|
31
|
+
* anywhere: a project whose file grew past it simply stopped having a system
|
|
32
|
+
* prompt, and said so once per session in a line nobody was watching. DevBridge's
|
|
33
|
+
* own `dev-prompt.md` had been over the line for two releases.
|
|
34
|
+
*
|
|
35
|
+
* It stays a real ceiling rather than becoming a setting, and the reason is the
|
|
36
|
+
* price. This text is not read once — it is in `system[]` on EVERY request the
|
|
37
|
+
* agent makes, for the whole life of the process, so a prompt is paid for by the
|
|
38
|
+
* turn. 64 KB is roughly 16k tokens, which is already a lot to carry for
|
|
39
|
+
* twenty turns; anything beyond it is a document the agent should be asked to
|
|
40
|
+
* read, not a rule it must never forget.
|
|
41
|
+
*
|
|
42
|
+
* The number is on the wire (`agent_prompt_state` announces it), so the settings
|
|
43
|
+
* card shows the ceiling of the runner that would actually read the file —
|
|
44
|
+
* never a copy of this constant that could be a version behind.
|
|
45
|
+
*/
|
|
27
46
|
export declare const AGENT_PROMPT_MAX_BYTES: number;
|
|
28
47
|
export type AgentPromptResult = {
|
|
29
48
|
ok: true;
|
|
@@ -33,10 +52,18 @@ export type AgentPromptResult = {
|
|
|
33
52
|
bytes: number;
|
|
34
53
|
sha: string;
|
|
35
54
|
}
|
|
36
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Already phrased for a human and safe to show — no raw paths beyond the one
|
|
57
|
+
* they typed.
|
|
58
|
+
*
|
|
59
|
+
* `bytes` is carried only by the one refusal where the size IS the answer: a
|
|
60
|
+
* file over the ceiling. «Too big» without the number leaves the person to
|
|
61
|
+
* guess how much has to go, which is how #192 sat unnoticed for two releases.
|
|
62
|
+
*/
|
|
37
63
|
| {
|
|
38
64
|
ok: false;
|
|
39
65
|
reason: string;
|
|
66
|
+
bytes?: number;
|
|
40
67
|
};
|
|
41
68
|
/**
|
|
42
69
|
* Read the project's prompt file, or explain why it cannot be read.
|
|
@@ -62,6 +89,30 @@ export type AgentPromptResult = {
|
|
|
62
89
|
export declare function readAgentPrompt(projectRoot: string, relPath: string): AgentPromptResult;
|
|
63
90
|
/** How the prompt is announced in the session feed and in the journal. */
|
|
64
91
|
export declare function agentPromptSizeLabel(bytes: number): string;
|
|
92
|
+
/**
|
|
93
|
+
* What the settings card asks before anybody starts a session: would this file
|
|
94
|
+
* reach the agent, and how big is it right now (ticket #192).
|
|
95
|
+
*
|
|
96
|
+
* Deliberately the SAME function the launch uses rather than a second, lighter
|
|
97
|
+
* check. A settings screen that says «fine» over a file the launch would refuse
|
|
98
|
+
* is worse than a screen that says nothing — and the two would drift the first
|
|
99
|
+
* time a rule was added to only one of them.
|
|
100
|
+
*
|
|
101
|
+
* The text itself never leaves the machine: this answers about the file, and a
|
|
102
|
+
* project's standing rules are not something the dashboard needs a copy of.
|
|
103
|
+
*/
|
|
104
|
+
export interface AgentPromptState {
|
|
105
|
+
/** Would this file reach the agent's system prompt, as things stand now? */
|
|
106
|
+
loaded: boolean;
|
|
107
|
+
/** The ceiling THIS runner enforces — so the far side never assumes its own. */
|
|
108
|
+
limit: number;
|
|
109
|
+
/** Present whenever it is known: on success always, on refusal when size is the reason. */
|
|
110
|
+
bytes?: number;
|
|
111
|
+
sha?: string;
|
|
112
|
+
/** Already phrased for a human, when `loaded` is false. */
|
|
113
|
+
reason?: string;
|
|
114
|
+
}
|
|
115
|
+
export declare function inspectAgentPrompt(projectRoot: string, relPath: string): AgentPromptState;
|
|
65
116
|
/**
|
|
66
117
|
* A configured path, safe to put in a line a person reads.
|
|
67
118
|
*
|
package/dist/agent-prompt.js
CHANGED
|
@@ -27,10 +27,29 @@ import { isGitInternalPath, isSecretPath } from './policy.js';
|
|
|
27
27
|
* runner is the process that opens the file, and it is the only side that can
|
|
28
28
|
* see what the path actually resolves to on this disk.
|
|
29
29
|
*/
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Big enough for a real process document; small enough to stay a prompt.
|
|
32
|
+
*
|
|
33
|
+
* Raised from 32 KB to 64 KB in 0.36.0 (ticket #192). 32 KB was picked when the
|
|
34
|
+
* only prompts in existence were 20–25 KB, and the number was never visible
|
|
35
|
+
* anywhere: a project whose file grew past it simply stopped having a system
|
|
36
|
+
* prompt, and said so once per session in a line nobody was watching. DevBridge's
|
|
37
|
+
* own `dev-prompt.md` had been over the line for two releases.
|
|
38
|
+
*
|
|
39
|
+
* It stays a real ceiling rather than becoming a setting, and the reason is the
|
|
40
|
+
* price. This text is not read once — it is in `system[]` on EVERY request the
|
|
41
|
+
* agent makes, for the whole life of the process, so a prompt is paid for by the
|
|
42
|
+
* turn. 64 KB is roughly 16k tokens, which is already a lot to carry for
|
|
43
|
+
* twenty turns; anything beyond it is a document the agent should be asked to
|
|
44
|
+
* read, not a rule it must never forget.
|
|
45
|
+
*
|
|
46
|
+
* The number is on the wire (`agent_prompt_state` announces it), so the settings
|
|
47
|
+
* card shows the ceiling of the runner that would actually read the file —
|
|
48
|
+
* never a copy of this constant that could be a version behind.
|
|
49
|
+
*/
|
|
50
|
+
export const AGENT_PROMPT_MAX_BYTES = 64 * 1024;
|
|
51
|
+
function deny(reason, bytes) {
|
|
52
|
+
return { ok: false, reason, ...(bytes === undefined ? {} : { bytes }) };
|
|
34
53
|
}
|
|
35
54
|
/**
|
|
36
55
|
* Read the project's prompt file, or explain why it cannot be read.
|
|
@@ -140,17 +159,20 @@ export function readAgentPrompt(projectRoot, relPath) {
|
|
|
140
159
|
// this, `ln /etc/hostname docs/prompt.md` walks past both «inside the
|
|
141
160
|
// project folder» and «not on the protected list», because both were
|
|
142
161
|
// decided about the NAME.
|
|
143
|
-
let
|
|
162
|
+
let read;
|
|
144
163
|
try {
|
|
145
|
-
|
|
164
|
+
read = readCapped(realPath, AGENT_PROMPT_MAX_BYTES + 1, link);
|
|
146
165
|
}
|
|
147
166
|
catch (error) {
|
|
148
167
|
if (error instanceof PromptFileRefused)
|
|
149
168
|
return deny(error.reason);
|
|
150
169
|
return deny(describeFsError(error));
|
|
151
170
|
}
|
|
171
|
+
const buffer = read.buffer;
|
|
152
172
|
if (buffer.length > AGENT_PROMPT_MAX_BYTES) {
|
|
153
|
-
|
|
173
|
+
// The size comes off the descriptor that was just read, not off a second
|
|
174
|
+
// `stat` of the name — it is the size of the file this refusal is about.
|
|
175
|
+
return deny(`the file is larger than ${Math.floor(AGENT_PROMPT_MAX_BYTES / 1024)} KB`, read.size);
|
|
154
176
|
}
|
|
155
177
|
if (buffer.includes(0))
|
|
156
178
|
return deny('the file is not text');
|
|
@@ -180,6 +202,9 @@ class PromptFileRefused extends Error {
|
|
|
180
202
|
*
|
|
181
203
|
* @param expected The `lstat` taken before the path checks — the identity every
|
|
182
204
|
* rule above was decided about.
|
|
205
|
+
* @returns The bytes actually read, capped at `limit`, and the file's own size
|
|
206
|
+
* as the descriptor reports it. The two differ exactly when the file is over
|
|
207
|
+
* the ceiling, which is the case that needs the real number to explain itself.
|
|
183
208
|
*/
|
|
184
209
|
function readCapped(file, limit, expected) {
|
|
185
210
|
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
@@ -203,7 +228,7 @@ function readCapped(file, limit, expected) {
|
|
|
203
228
|
break;
|
|
204
229
|
read += n;
|
|
205
230
|
}
|
|
206
|
-
return buffer.subarray(0, read);
|
|
231
|
+
return { buffer: buffer.subarray(0, read), size: opened.size };
|
|
207
232
|
}
|
|
208
233
|
finally {
|
|
209
234
|
fs.closeSync(fd);
|
|
@@ -234,6 +259,17 @@ function describeFsError(error) {
|
|
|
234
259
|
export function agentPromptSizeLabel(bytes) {
|
|
235
260
|
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
236
261
|
}
|
|
262
|
+
export function inspectAgentPrompt(projectRoot, relPath) {
|
|
263
|
+
const result = readAgentPrompt(projectRoot, relPath);
|
|
264
|
+
return result.ok
|
|
265
|
+
? { loaded: true, limit: AGENT_PROMPT_MAX_BYTES, bytes: result.bytes, sha: result.sha }
|
|
266
|
+
: {
|
|
267
|
+
loaded: false,
|
|
268
|
+
limit: AGENT_PROMPT_MAX_BYTES,
|
|
269
|
+
reason: result.reason,
|
|
270
|
+
...(result.bytes === undefined ? {} : { bytes: result.bytes }),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
237
273
|
/**
|
|
238
274
|
* A configured path, safe to put in a line a person reads.
|
|
239
275
|
*
|
package/dist/index.js
CHANGED
|
@@ -261,6 +261,16 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
261
261
|
*/
|
|
262
262
|
contextRewind: true,
|
|
263
263
|
contextCompaction: true,
|
|
264
|
+
/**
|
|
265
|
+
* Ticket #196: understands `session_pause`, and therefore that a pause is a
|
|
266
|
+
* STATE rather than a single interrupt.
|
|
267
|
+
*
|
|
268
|
+
* Announced because the API has to know which of the two it is talking to:
|
|
269
|
+
* a runner without this drops the frame unread, and the pause has to fall
|
|
270
|
+
* back to the old best-effort interrupt rather than silently holding
|
|
271
|
+
* nothing at all.
|
|
272
|
+
*/
|
|
273
|
+
sessionPause: true,
|
|
264
274
|
/**
|
|
265
275
|
* Reads the project's own prompt file and hands it to the agent as
|
|
266
276
|
* SYSTEM-prompt text, for both Claude and Codex.
|
|
@@ -373,8 +383,17 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
373
383
|
'git_pull',
|
|
374
384
|
'git_merge_abort',
|
|
375
385
|
'recipe_state',
|
|
386
|
+
// Ticket #192: the project settings card asks whether the prompt file
|
|
387
|
+
// would load and how big it is. Announced as a command rather than as a
|
|
388
|
+
// flag, because that is the list `runCommand` actually dispatches on.
|
|
389
|
+
'agent_prompt_state',
|
|
376
390
|
'propose_commit_message',
|
|
377
391
|
'recall_message',
|
|
392
|
+
// The answer to a parked question, as a command that ANSWERS rather than
|
|
393
|
+
// a frame written into a socket and hoped for. Announced here because
|
|
394
|
+
// this list is what `runCommand` actually dispatches on, and the API
|
|
395
|
+
// falls back to the old frame for runners that do not name it.
|
|
396
|
+
'answer_question',
|
|
378
397
|
'compact_context',
|
|
379
398
|
...(checkpointsEnabled
|
|
380
399
|
? [
|
package/dist/protocol.d.ts
CHANGED
|
@@ -14,6 +14,22 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
14
14
|
epoch: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
15
15
|
activeMsBase: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
16
16
|
extraBudgetMinutes: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
|
17
|
+
/**
|
|
18
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
19
|
+
*
|
|
20
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
21
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
22
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
23
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
24
|
+
* else about the session.
|
|
25
|
+
*
|
|
26
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
27
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
28
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
29
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
30
|
+
* behaviour every runner had before this release.
|
|
31
|
+
*/
|
|
32
|
+
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
17
33
|
workspace: z.ZodObject<{
|
|
18
34
|
id: z.ZodString;
|
|
19
35
|
path: z.ZodString;
|
|
@@ -201,6 +217,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
201
217
|
url: string;
|
|
202
218
|
token: string;
|
|
203
219
|
} | undefined;
|
|
220
|
+
pausedUntil?: string | null | undefined;
|
|
204
221
|
skipAgentPrompt?: boolean | undefined;
|
|
205
222
|
branchHint?: string | undefined;
|
|
206
223
|
branchPlan?: {
|
|
@@ -248,12 +265,85 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
|
|
|
248
265
|
costUsd?: number | undefined;
|
|
249
266
|
activeMsBase?: number | undefined;
|
|
250
267
|
extraBudgetMinutes?: number | null | undefined;
|
|
268
|
+
pausedUntil?: unknown;
|
|
251
269
|
skipAgentPrompt?: unknown;
|
|
252
270
|
branchHint?: unknown;
|
|
253
271
|
branchPlan?: unknown;
|
|
254
272
|
workMode?: unknown;
|
|
255
273
|
}>;
|
|
256
274
|
export type SessionDescriptor = z.infer<typeof SessionDescriptorSchema>;
|
|
275
|
+
/**
|
|
276
|
+
* One human answer to a parked agent question, minus the session it belongs to.
|
|
277
|
+
*
|
|
278
|
+
* Written once and used twice on purpose: as the body of the `question_answer`
|
|
279
|
+
* frame every runner since 0.14 understands, and as the arguments of the
|
|
280
|
+
* `answer_question` COMMAND that replaced it (session 18). The two must not
|
|
281
|
+
* drift — an answer that the frame accepts and the command rejects would fail
|
|
282
|
+
* exactly on the machines whose connection is bad enough to need the command.
|
|
283
|
+
*/
|
|
284
|
+
export declare const QuestionAnswerShape: {
|
|
285
|
+
readonly askId: z.ZodString;
|
|
286
|
+
readonly action: z.ZodEnum<["answer", "discuss"]>;
|
|
287
|
+
readonly answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
288
|
+
questionId: z.ZodString;
|
|
289
|
+
values: z.ZodArray<z.ZodString, "many">;
|
|
290
|
+
custom: z.ZodOptional<z.ZodString>;
|
|
291
|
+
notes: z.ZodOptional<z.ZodString>;
|
|
292
|
+
}, "strip", z.ZodTypeAny, {
|
|
293
|
+
values: string[];
|
|
294
|
+
questionId: string;
|
|
295
|
+
custom?: string | undefined;
|
|
296
|
+
notes?: string | undefined;
|
|
297
|
+
}, {
|
|
298
|
+
values: string[];
|
|
299
|
+
questionId: string;
|
|
300
|
+
custom?: string | undefined;
|
|
301
|
+
notes?: string | undefined;
|
|
302
|
+
}>, "many">>;
|
|
303
|
+
readonly text: z.ZodOptional<z.ZodString>;
|
|
304
|
+
};
|
|
305
|
+
/** The `answer_question` command's arguments — the shape above on its own. */
|
|
306
|
+
export declare const QuestionAnswerArgsSchema: z.ZodObject<{
|
|
307
|
+
readonly askId: z.ZodString;
|
|
308
|
+
readonly action: z.ZodEnum<["answer", "discuss"]>;
|
|
309
|
+
readonly answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
310
|
+
questionId: z.ZodString;
|
|
311
|
+
values: z.ZodArray<z.ZodString, "many">;
|
|
312
|
+
custom: z.ZodOptional<z.ZodString>;
|
|
313
|
+
notes: z.ZodOptional<z.ZodString>;
|
|
314
|
+
}, "strip", z.ZodTypeAny, {
|
|
315
|
+
values: string[];
|
|
316
|
+
questionId: string;
|
|
317
|
+
custom?: string | undefined;
|
|
318
|
+
notes?: string | undefined;
|
|
319
|
+
}, {
|
|
320
|
+
values: string[];
|
|
321
|
+
questionId: string;
|
|
322
|
+
custom?: string | undefined;
|
|
323
|
+
notes?: string | undefined;
|
|
324
|
+
}>, "many">>;
|
|
325
|
+
readonly text: z.ZodOptional<z.ZodString>;
|
|
326
|
+
}, "strip", z.ZodTypeAny, {
|
|
327
|
+
askId: string;
|
|
328
|
+
action: "answer" | "discuss";
|
|
329
|
+
text?: string | undefined;
|
|
330
|
+
answers?: {
|
|
331
|
+
values: string[];
|
|
332
|
+
questionId: string;
|
|
333
|
+
custom?: string | undefined;
|
|
334
|
+
notes?: string | undefined;
|
|
335
|
+
}[] | undefined;
|
|
336
|
+
}, {
|
|
337
|
+
askId: string;
|
|
338
|
+
action: "answer" | "discuss";
|
|
339
|
+
text?: string | undefined;
|
|
340
|
+
answers?: {
|
|
341
|
+
values: string[];
|
|
342
|
+
questionId: string;
|
|
343
|
+
custom?: string | undefined;
|
|
344
|
+
notes?: string | undefined;
|
|
345
|
+
}[] | undefined;
|
|
346
|
+
}>;
|
|
257
347
|
export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
258
348
|
type: z.ZodLiteral<"hello_ack">;
|
|
259
349
|
serverId: z.ZodString;
|
|
@@ -274,6 +364,22 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
274
364
|
epoch: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
275
365
|
activeMsBase: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
276
366
|
extraBudgetMinutes: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
|
367
|
+
/**
|
|
368
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
369
|
+
*
|
|
370
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
371
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
372
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
373
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
374
|
+
* else about the session.
|
|
375
|
+
*
|
|
376
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
377
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
378
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
379
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
380
|
+
* behaviour every runner had before this release.
|
|
381
|
+
*/
|
|
382
|
+
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
277
383
|
workspace: z.ZodObject<{
|
|
278
384
|
id: z.ZodString;
|
|
279
385
|
path: z.ZodString;
|
|
@@ -461,6 +567,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
461
567
|
url: string;
|
|
462
568
|
token: string;
|
|
463
569
|
} | undefined;
|
|
570
|
+
pausedUntil?: string | null | undefined;
|
|
464
571
|
skipAgentPrompt?: boolean | undefined;
|
|
465
572
|
branchHint?: string | undefined;
|
|
466
573
|
branchPlan?: {
|
|
@@ -508,6 +615,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
508
615
|
costUsd?: number | undefined;
|
|
509
616
|
activeMsBase?: number | undefined;
|
|
510
617
|
extraBudgetMinutes?: number | null | undefined;
|
|
618
|
+
pausedUntil?: unknown;
|
|
511
619
|
skipAgentPrompt?: unknown;
|
|
512
620
|
branchHint?: unknown;
|
|
513
621
|
branchPlan?: unknown;
|
|
@@ -555,6 +663,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
555
663
|
url: string;
|
|
556
664
|
token: string;
|
|
557
665
|
} | undefined;
|
|
666
|
+
pausedUntil?: string | null | undefined;
|
|
558
667
|
skipAgentPrompt?: boolean | undefined;
|
|
559
668
|
branchHint?: string | undefined;
|
|
560
669
|
branchPlan?: {
|
|
@@ -608,6 +717,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
608
717
|
costUsd?: number | undefined;
|
|
609
718
|
activeMsBase?: number | undefined;
|
|
610
719
|
extraBudgetMinutes?: number | null | undefined;
|
|
720
|
+
pausedUntil?: unknown;
|
|
611
721
|
skipAgentPrompt?: unknown;
|
|
612
722
|
branchHint?: unknown;
|
|
613
723
|
branchPlan?: unknown;
|
|
@@ -658,6 +768,22 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
658
768
|
epoch: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
659
769
|
activeMsBase: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
660
770
|
extraBudgetMinutes: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
|
771
|
+
/**
|
|
772
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
773
|
+
*
|
|
774
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
775
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
776
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
777
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
778
|
+
* else about the session.
|
|
779
|
+
*
|
|
780
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
781
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
782
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
783
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
784
|
+
* behaviour every runner had before this release.
|
|
785
|
+
*/
|
|
786
|
+
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
661
787
|
workspace: z.ZodObject<{
|
|
662
788
|
id: z.ZodString;
|
|
663
789
|
path: z.ZodString;
|
|
@@ -845,6 +971,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
845
971
|
url: string;
|
|
846
972
|
token: string;
|
|
847
973
|
} | undefined;
|
|
974
|
+
pausedUntil?: string | null | undefined;
|
|
848
975
|
skipAgentPrompt?: boolean | undefined;
|
|
849
976
|
branchHint?: string | undefined;
|
|
850
977
|
branchPlan?: {
|
|
@@ -892,6 +1019,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
892
1019
|
costUsd?: number | undefined;
|
|
893
1020
|
activeMsBase?: number | undefined;
|
|
894
1021
|
extraBudgetMinutes?: number | null | undefined;
|
|
1022
|
+
pausedUntil?: unknown;
|
|
895
1023
|
skipAgentPrompt?: unknown;
|
|
896
1024
|
branchHint?: unknown;
|
|
897
1025
|
branchPlan?: unknown;
|
|
@@ -937,6 +1065,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
937
1065
|
url: string;
|
|
938
1066
|
token: string;
|
|
939
1067
|
} | undefined;
|
|
1068
|
+
pausedUntil?: string | null | undefined;
|
|
940
1069
|
skipAgentPrompt?: boolean | undefined;
|
|
941
1070
|
branchHint?: string | undefined;
|
|
942
1071
|
branchPlan?: {
|
|
@@ -987,6 +1116,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
987
1116
|
costUsd?: number | undefined;
|
|
988
1117
|
activeMsBase?: number | undefined;
|
|
989
1118
|
extraBudgetMinutes?: number | null | undefined;
|
|
1119
|
+
pausedUntil?: unknown;
|
|
990
1120
|
skipAgentPrompt?: unknown;
|
|
991
1121
|
branchHint?: unknown;
|
|
992
1122
|
branchPlan?: unknown;
|
|
@@ -1046,8 +1176,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1046
1176
|
requestId: string;
|
|
1047
1177
|
note?: string | undefined;
|
|
1048
1178
|
}>, z.ZodObject<{
|
|
1049
|
-
type: z.ZodLiteral<"question_answer">;
|
|
1050
|
-
sessionId: z.ZodString;
|
|
1051
1179
|
askId: z.ZodString;
|
|
1052
1180
|
action: z.ZodEnum<["answer", "discuss"]>;
|
|
1053
1181
|
answers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
@@ -1067,6 +1195,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1067
1195
|
notes?: string | undefined;
|
|
1068
1196
|
}>, "many">>;
|
|
1069
1197
|
text: z.ZodOptional<z.ZodString>;
|
|
1198
|
+
type: z.ZodLiteral<"question_answer">;
|
|
1199
|
+
sessionId: z.ZodString;
|
|
1070
1200
|
}, "strip", z.ZodTypeAny, {
|
|
1071
1201
|
sessionId: string;
|
|
1072
1202
|
type: "question_answer";
|
|
@@ -1109,6 +1239,18 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1109
1239
|
}, {
|
|
1110
1240
|
sessionId: string;
|
|
1111
1241
|
type: "session_interrupt";
|
|
1242
|
+
}>, z.ZodObject<{
|
|
1243
|
+
type: z.ZodLiteral<"session_pause">;
|
|
1244
|
+
sessionId: z.ZodString;
|
|
1245
|
+
pausedUntil: z.ZodNullable<z.ZodString>;
|
|
1246
|
+
}, "strip", z.ZodTypeAny, {
|
|
1247
|
+
sessionId: string;
|
|
1248
|
+
type: "session_pause";
|
|
1249
|
+
pausedUntil: string | null;
|
|
1250
|
+
}, {
|
|
1251
|
+
sessionId: string;
|
|
1252
|
+
type: "session_pause";
|
|
1253
|
+
pausedUntil: string | null;
|
|
1112
1254
|
}>, z.ZodObject<{
|
|
1113
1255
|
type: z.ZodLiteral<"server_settings">;
|
|
1114
1256
|
maxSessions: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
package/dist/protocol.js
CHANGED
|
@@ -40,6 +40,22 @@ export const SessionDescriptorSchema = z.object({
|
|
|
40
40
|
activeMsBase: z.number().int().min(0).optional().default(0),
|
|
41
41
|
// Extra minutes granted by «Продолжить», on top of the workspace budget.
|
|
42
42
|
extraBudgetMinutes: z.number().int().min(0).nullable().optional().default(null),
|
|
43
|
+
/**
|
|
44
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
45
|
+
*
|
|
46
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
47
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
48
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
49
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
50
|
+
* else about the session.
|
|
51
|
+
*
|
|
52
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
53
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
54
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
55
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
56
|
+
* behaviour every runner had before this release.
|
|
57
|
+
*/
|
|
58
|
+
pausedUntil: z.string().max(40).nullable().optional().catch(undefined),
|
|
43
59
|
workspace: z.object({
|
|
44
60
|
id: z.string().uuid(),
|
|
45
61
|
path: z.string(),
|
|
@@ -167,6 +183,31 @@ export const SessionDescriptorSchema = z.object({
|
|
|
167
183
|
// project). Preferred over the [mcp] section of config.toml when present.
|
|
168
184
|
mcp: z.object({ url: z.string().url(), token: z.string().min(1) }).optional(),
|
|
169
185
|
});
|
|
186
|
+
/**
|
|
187
|
+
* One human answer to a parked agent question, minus the session it belongs to.
|
|
188
|
+
*
|
|
189
|
+
* Written once and used twice on purpose: as the body of the `question_answer`
|
|
190
|
+
* frame every runner since 0.14 understands, and as the arguments of the
|
|
191
|
+
* `answer_question` COMMAND that replaced it (session 18). The two must not
|
|
192
|
+
* drift — an answer that the frame accepts and the command rejects would fail
|
|
193
|
+
* exactly on the machines whose connection is bad enough to need the command.
|
|
194
|
+
*/
|
|
195
|
+
export const QuestionAnswerShape = {
|
|
196
|
+
askId: z.string().min(1).max(64),
|
|
197
|
+
action: z.enum(['answer', 'discuss']),
|
|
198
|
+
answers: z
|
|
199
|
+
.array(z.object({
|
|
200
|
+
questionId: z.string().min(1).max(120),
|
|
201
|
+
values: z.array(z.string().max(2_000)).max(16),
|
|
202
|
+
custom: z.string().max(10_000).optional(),
|
|
203
|
+
notes: z.string().max(2_000).optional(),
|
|
204
|
+
}))
|
|
205
|
+
.max(4)
|
|
206
|
+
.optional(),
|
|
207
|
+
text: z.string().max(20_000).optional(),
|
|
208
|
+
};
|
|
209
|
+
/** The `answer_question` command's arguments — the shape above on its own. */
|
|
210
|
+
export const QuestionAnswerArgsSchema = z.object(QuestionAnswerShape);
|
|
170
211
|
export const GatewayFrameSchema = z.discriminatedUnion('type', [
|
|
171
212
|
z.object({
|
|
172
213
|
type: z.literal('hello_ack'),
|
|
@@ -222,21 +263,30 @@ export const GatewayFrameSchema = z.discriminatedUnion('type', [
|
|
|
222
263
|
z.object({
|
|
223
264
|
type: z.literal('question_answer'),
|
|
224
265
|
sessionId: z.string().uuid(),
|
|
225
|
-
|
|
226
|
-
action: z.enum(['answer', 'discuss']),
|
|
227
|
-
answers: z
|
|
228
|
-
.array(z.object({
|
|
229
|
-
questionId: z.string().min(1).max(120),
|
|
230
|
-
values: z.array(z.string().max(2_000)).max(16),
|
|
231
|
-
custom: z.string().max(10_000).optional(),
|
|
232
|
-
notes: z.string().max(2_000).optional(),
|
|
233
|
-
}))
|
|
234
|
-
.max(4)
|
|
235
|
-
.optional(),
|
|
236
|
-
text: z.string().max(20_000).optional(),
|
|
266
|
+
...QuestionAnswerShape,
|
|
237
267
|
}),
|
|
238
268
|
z.object({ type: z.literal('session_stop'), sessionId: z.string().uuid() }),
|
|
239
269
|
z.object({ type: z.literal('session_interrupt'), sessionId: z.string().uuid() }),
|
|
270
|
+
/**
|
|
271
|
+
* The session is held under a clock, or the clock is off — ticket #196.
|
|
272
|
+
*
|
|
273
|
+
* ONE frame carrying the whole state, not a pause/resume pair: two frames are
|
|
274
|
+
* two ways for a runner to end up believing something the row does not say,
|
|
275
|
+
* and `pausedUntil: null` already IS «resumed». The API's own SSE frame is
|
|
276
|
+
* built the same way and for the same reason.
|
|
277
|
+
*
|
|
278
|
+
* Until this existed a pause was a single `session_interrupt` — and an
|
|
279
|
+
* interrupt is an event, not a state. It could arrive before the turn it was
|
|
280
|
+
* meant to stop (both agents drop an interrupt with no active turn), and it
|
|
281
|
+
* said nothing about the minutes that followed: queued messages flushed,
|
|
282
|
+
* auto-resume relaunched, and a background subagent's report woke a whole new
|
|
283
|
+
* turn, all while every screen said «На паузе».
|
|
284
|
+
*/
|
|
285
|
+
z.object({
|
|
286
|
+
type: z.literal('session_pause'),
|
|
287
|
+
sessionId: z.string().uuid(),
|
|
288
|
+
pausedUntil: z.string().max(40).nullable(),
|
|
289
|
+
}),
|
|
240
290
|
// Server-wide settings changed mid-connection (session 8) — today just the
|
|
241
291
|
// parallel-session ceiling.
|
|
242
292
|
z.object({
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -371,8 +371,41 @@ export declare class Supervisor {
|
|
|
371
371
|
* fire and forget — a failure here is logged, and the records stay on disk.
|
|
372
372
|
*/
|
|
373
373
|
private flushPendingMessages;
|
|
374
|
-
/**
|
|
374
|
+
/**
|
|
375
|
+
* Stop the current turn without ending the session (VS-Code-style Stop).
|
|
376
|
+
*
|
|
377
|
+
* @param reason `'pause'` when a clock did it rather than a person. Only the
|
|
378
|
+
* words in the feed differ — «Turn interrupted by the user» over a turn the
|
|
379
|
+
* user did not touch is the kind of small lie that costs an hour of
|
|
380
|
+
* debugging later.
|
|
381
|
+
* @param announce Say it in the feed. The pause watchdog passes `false`: the
|
|
382
|
+
* line was already written when the clock landed, and one per background
|
|
383
|
+
* turn it stops would bury the feed under a fact nobody asked about.
|
|
384
|
+
*/
|
|
375
385
|
private interruptSession;
|
|
386
|
+
/** Is this session held under a clock right now (ticket #196)? */
|
|
387
|
+
private static isPaused;
|
|
388
|
+
/**
|
|
389
|
+
* Set or clear the clock this session is held under (ticket #196).
|
|
390
|
+
*
|
|
391
|
+
* `null` means the pause is over. Releasing does NOT start anything by
|
|
392
|
+
* itself: the API sends the messages that were waiting through the ordinary
|
|
393
|
+
* message path, and anything this runner was holding is flushed here.
|
|
394
|
+
*/
|
|
395
|
+
private applyPause;
|
|
396
|
+
/**
|
|
397
|
+
* The turn started anyway — stop it (ticket #196).
|
|
398
|
+
*
|
|
399
|
+
* The last line of defence, and the only one that can catch a turn nobody
|
|
400
|
+
* outside the agent process asked for: a background subagent's report wakes a
|
|
401
|
+
* new turn inside the CLI (gotcha #244), and an interrupted Codex turn starts
|
|
402
|
+
* the next queued one by itself. Neither passes through any frame this runner
|
|
403
|
+
* could refuse, so the only place to notice them is the events they produce.
|
|
404
|
+
*
|
|
405
|
+
* Deliberately quiet: the notice was already written when the pause landed,
|
|
406
|
+
* and one per interrupted background turn would bury the feed.
|
|
407
|
+
*/
|
|
408
|
+
private stopWorkUnderPause;
|
|
376
409
|
/** Live model / interaction-mode switch (persisted for the next relaunch). */
|
|
377
410
|
private applySettings;
|
|
378
411
|
/** Is a turn (or a question the agent is parked on) in flight right now? */
|
package/dist/supervisor.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { log } from './log.js';
|
|
4
4
|
import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
|
|
5
5
|
import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
|
|
6
|
-
import { agentPromptSizeLabel, quotePath, readAgentPrompt } from './agent-prompt.js';
|
|
6
|
+
import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
|
|
7
7
|
import { JournalStore } from './journal.js';
|
|
8
8
|
import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
|
|
9
9
|
import { readRecipeProposal } from './recipe.js';
|
|
@@ -18,6 +18,7 @@ import { selfUpdate } from './self-update.js';
|
|
|
18
18
|
import { rememberWorkspacePath } from './environment.js';
|
|
19
19
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
20
20
|
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
21
|
+
import { QuestionAnswerArgsSchema } from './protocol.js';
|
|
21
22
|
import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
|
|
22
23
|
/** Refusals shared by every checkpoint command (ticket #126). */
|
|
23
24
|
const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
|
|
@@ -220,6 +221,9 @@ export class Supervisor {
|
|
|
220
221
|
case 'session_interrupt':
|
|
221
222
|
await this.interruptSession(frame.sessionId);
|
|
222
223
|
break;
|
|
224
|
+
case 'session_pause':
|
|
225
|
+
await this.applyPause(frame.sessionId, frame.pausedUntil);
|
|
226
|
+
break;
|
|
223
227
|
case 'session_settings':
|
|
224
228
|
await this.applySettings(frame.sessionId, frame.model, frame.mode, frame.effort);
|
|
225
229
|
break;
|
|
@@ -263,6 +267,11 @@ export class Supervisor {
|
|
|
263
267
|
existing.stopRequested = true;
|
|
264
268
|
existing.session?.stop('session_stopped');
|
|
265
269
|
}
|
|
270
|
+
// The descriptor is newer than what this session was built from, so its
|
|
271
|
+
// clock is too (QA-149 MAJOR-1). Applied on the way out of every early
|
|
272
|
+
// return, not just the reconnect one — a descriptor re-sent for any
|
|
273
|
+
// reason is the freshest thing this runner will see about the pause.
|
|
274
|
+
await this.applyPause(descriptor.id, descriptor.pausedUntil ?? null);
|
|
266
275
|
return;
|
|
267
276
|
}
|
|
268
277
|
// Up to `maxSessions` agents at once (session 8). Sessions idling after a
|
|
@@ -301,6 +310,12 @@ export class Supervisor {
|
|
|
301
310
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
302
311
|
epoch: descriptor.epoch,
|
|
303
312
|
openQuestions: new Set(),
|
|
313
|
+
answeredAsks: new Set(),
|
|
314
|
+
// Ticket #196: a pause is part of what a session IS, so it is read off
|
|
315
|
+
// the descriptor rather than waiting for a frame. Without this a runner
|
|
316
|
+
// that restarted mid-pause would come back knowing nothing and pick the
|
|
317
|
+
// work straight back up.
|
|
318
|
+
...pausedUntilOf(descriptor),
|
|
304
319
|
mode: descriptor.mode,
|
|
305
320
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
306
321
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -512,6 +527,19 @@ export class Supervisor {
|
|
|
512
527
|
branch: running.branch,
|
|
513
528
|
worktreePath: running.worktreePath,
|
|
514
529
|
});
|
|
530
|
+
// Ticket #196: a Stop or a pause that arrived while this process was coming
|
|
531
|
+
// up was dropped on the floor — `interruptSession` returns early when there
|
|
532
|
+
// is no adapter yet, and the window covers preparing the worktree and
|
|
533
|
+
// hashing the whole tree for a checkpoint. The turn it was aimed at is
|
|
534
|
+
// exactly the one starting now, so it is answered here, at the first moment
|
|
535
|
+
// there is something to answer it with.
|
|
536
|
+
if (running.interruptWhenReady || Supervisor.isPaused(running)) {
|
|
537
|
+
delete running.interruptWhenReady;
|
|
538
|
+
void this.interruptSession(descriptor.id, Supervisor.isPaused(running) ? 'pause' : 'user').catch((error) => log.warn('supervisor: could not honour an interrupt requested during startup', {
|
|
539
|
+
sessionId: descriptor.id,
|
|
540
|
+
error: String(error),
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
515
543
|
void this.pumpEvents(running);
|
|
516
544
|
return true;
|
|
517
545
|
}
|
|
@@ -959,6 +987,9 @@ export class Supervisor {
|
|
|
959
987
|
for (const waiting of this.sessions.values()) {
|
|
960
988
|
if (waiting.session || waiting.stopRequested || waiting.budgetSpent)
|
|
961
989
|
continue;
|
|
990
|
+
// A freed slot is not a reason to start work somebody put on hold (#196).
|
|
991
|
+
if (Supervisor.isPaused(waiting))
|
|
992
|
+
continue;
|
|
962
993
|
if (waiting.pendingMessages.length === 0 || !waiting.worktreePath)
|
|
963
994
|
continue;
|
|
964
995
|
if (this.liveSessionCount(waiting.descriptor.id) >= this.maxSessions)
|
|
@@ -1125,8 +1156,19 @@ export class Supervisor {
|
|
|
1125
1156
|
// Anything below that is the agent talking means the agent is working. Read
|
|
1126
1157
|
// before the switch so every such case gets it, including the ones added
|
|
1127
1158
|
// after this line was written.
|
|
1128
|
-
if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type))
|
|
1129
|
-
|
|
1159
|
+
if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type)) {
|
|
1160
|
+
// Ticket #196. The same signal, read twice for opposite reasons: when the
|
|
1161
|
+
// session is free it proves the agent is working (#185), and when the
|
|
1162
|
+
// session is held under a clock it proves something started that should
|
|
1163
|
+
// not have. The second is the ONLY way to catch a turn nobody outside the
|
|
1164
|
+
// agent process asked for — a background subagent's report (gotcha #244)
|
|
1165
|
+
// and Codex's own «interrupted, so start the next queued turn» both come
|
|
1166
|
+
// through no frame this runner could refuse.
|
|
1167
|
+
if (Supervisor.isPaused(running))
|
|
1168
|
+
this.stopWorkUnderPause(running);
|
|
1169
|
+
else
|
|
1170
|
+
this.noteAgentIsWorking(running);
|
|
1171
|
+
}
|
|
1130
1172
|
switch (event.type) {
|
|
1131
1173
|
case 'provider_session': {
|
|
1132
1174
|
running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
|
|
@@ -1431,8 +1473,13 @@ export class Supervisor {
|
|
|
1431
1473
|
if (!running) {
|
|
1432
1474
|
log.warn('supervisor: question answer for unknown session', { sessionId: frame.sessionId });
|
|
1433
1475
|
this.ws.send({ type: 'session_unknown', sessionId: frame.sessionId });
|
|
1434
|
-
return;
|
|
1476
|
+
return 'unknown_session';
|
|
1435
1477
|
}
|
|
1478
|
+
// A redelivery of an answer this runner already took. Silence here is the
|
|
1479
|
+
// point: the first copy did everything, and the second must not add a note,
|
|
1480
|
+
// a message or a status report — see `answeredAsks`.
|
|
1481
|
+
if (running.answeredAsks.has(frame.askId))
|
|
1482
|
+
return 'duplicate';
|
|
1436
1483
|
const typed = frame.text?.trim();
|
|
1437
1484
|
// Asked first, echoed second: the adapter resolves synchronously but its
|
|
1438
1485
|
// own events reach the feed a microtask later, so the user's words still
|
|
@@ -1445,12 +1492,17 @@ export class Supervisor {
|
|
|
1445
1492
|
...(frame.text !== undefined ? { text: frame.text } : {}),
|
|
1446
1493
|
});
|
|
1447
1494
|
if (accepted) {
|
|
1495
|
+
running.answeredAsks.add(frame.askId);
|
|
1448
1496
|
// What the user typed belongs in the conversation, whichever exit they took.
|
|
1449
1497
|
if (typed)
|
|
1450
1498
|
this.sendEvent(running, 'message', { role: 'user', text: typed });
|
|
1451
|
-
return;
|
|
1499
|
+
return 'answered';
|
|
1452
1500
|
}
|
|
1453
1501
|
running.openQuestions.delete(frame.askId);
|
|
1502
|
+
// Remembered even though it was a miss: whatever happened to the ask, the
|
|
1503
|
+
// words below have now been published once, and a redelivery must not
|
|
1504
|
+
// publish them again.
|
|
1505
|
+
running.answeredAsks.add(frame.askId);
|
|
1454
1506
|
this.sendEvent(running, 'system_note', {
|
|
1455
1507
|
text: typed
|
|
1456
1508
|
? 'That question is no longer open — sending your reply as an ordinary message instead.'
|
|
@@ -1465,11 +1517,12 @@ export class Supervisor {
|
|
|
1465
1517
|
sessionId: frame.sessionId,
|
|
1466
1518
|
error: String(error),
|
|
1467
1519
|
}));
|
|
1468
|
-
return;
|
|
1520
|
+
return 'not_open';
|
|
1469
1521
|
}
|
|
1470
1522
|
// Nothing to deliver — but the API optimistically flipped the session to
|
|
1471
1523
|
// RUNNING when it relayed the answer, so put the real status back.
|
|
1472
1524
|
this.reportStatus(frame.sessionId, statusForReport(running), {});
|
|
1525
|
+
return 'not_open';
|
|
1473
1526
|
}
|
|
1474
1527
|
async onUserMessage(sessionId, text, attachments) {
|
|
1475
1528
|
const running = this.sessions.get(sessionId);
|
|
@@ -1495,10 +1548,16 @@ export class Supervisor {
|
|
|
1495
1548
|
...(attachments?.length ? { attachments } : {}),
|
|
1496
1549
|
});
|
|
1497
1550
|
const originSeq = echoed.seq;
|
|
1498
|
-
if (!running.worktreePath) {
|
|
1551
|
+
if (!running.worktreePath || Supervisor.isPaused(running)) {
|
|
1499
1552
|
// Session is still being prepared — deliver after launch (QA-96 F3).
|
|
1500
1553
|
// Attachments travel as metadata and are downloaded at delivery time,
|
|
1501
1554
|
// which is the first moment the worktree is guaranteed to exist.
|
|
1555
|
+
//
|
|
1556
|
+
// Or the session is held under a clock (#196). The API refuses live
|
|
1557
|
+
// messages for a paused session, but not every path goes through that
|
|
1558
|
+
// check — the outbox flushes on reconnect, and the git service posts its
|
|
1559
|
+
// own conflict tasks. Held here rather than delivered, so «на паузе»
|
|
1560
|
+
// means the same thing whichever door the words came through.
|
|
1502
1561
|
this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
|
|
1503
1562
|
return;
|
|
1504
1563
|
}
|
|
@@ -1780,6 +1839,11 @@ export class Supervisor {
|
|
|
1780
1839
|
* fire and forget — a failure here is logged, and the records stay on disk.
|
|
1781
1840
|
*/
|
|
1782
1841
|
flushPendingMessages(running) {
|
|
1842
|
+
// Ticket #196: six places call this — a launch, a mode relaunch, a freed
|
|
1843
|
+
// slot, a delivery, and twice on reconnect — and none of them knew about a
|
|
1844
|
+
// pause. Held work stays held until the clock is off.
|
|
1845
|
+
if (Supervisor.isPaused(running))
|
|
1846
|
+
return;
|
|
1783
1847
|
const pending = running.pendingMessages.splice(0);
|
|
1784
1848
|
if (pending.length === 0)
|
|
1785
1849
|
return;
|
|
@@ -1816,11 +1880,29 @@ export class Supervisor {
|
|
|
1816
1880
|
this.deliverMessage(running, parts.join('\n\n'), pending);
|
|
1817
1881
|
});
|
|
1818
1882
|
}
|
|
1819
|
-
/**
|
|
1820
|
-
|
|
1883
|
+
/**
|
|
1884
|
+
* Stop the current turn without ending the session (VS-Code-style Stop).
|
|
1885
|
+
*
|
|
1886
|
+
* @param reason `'pause'` when a clock did it rather than a person. Only the
|
|
1887
|
+
* words in the feed differ — «Turn interrupted by the user» over a turn the
|
|
1888
|
+
* user did not touch is the kind of small lie that costs an hour of
|
|
1889
|
+
* debugging later.
|
|
1890
|
+
* @param announce Say it in the feed. The pause watchdog passes `false`: the
|
|
1891
|
+
* line was already written when the clock landed, and one per background
|
|
1892
|
+
* turn it stops would bury the feed under a fact nobody asked about.
|
|
1893
|
+
*/
|
|
1894
|
+
async interruptSession(sessionId, reason = 'user', announce = true) {
|
|
1821
1895
|
const running = this.sessions.get(sessionId);
|
|
1822
|
-
if (!running
|
|
1896
|
+
if (!running)
|
|
1897
|
+
return;
|
|
1898
|
+
if (!running.session) {
|
|
1899
|
+
// The process is still coming up. Remembered rather than dropped: the
|
|
1900
|
+
// launch honours this the moment the adapter exists, so a Stop or a pause
|
|
1901
|
+
// pressed during startup stops the turn it was aimed at instead of
|
|
1902
|
+
// vanishing (#196).
|
|
1903
|
+
running.interruptWhenReady = true;
|
|
1823
1904
|
return;
|
|
1905
|
+
}
|
|
1824
1906
|
// The turn being interrupted is the turn the question belongs to — leaving
|
|
1825
1907
|
// the ask parked would make the user's next message be swallowed as its
|
|
1826
1908
|
// answer (QA-106 m7). The adapter reports each withdrawal itself; the local
|
|
@@ -1828,13 +1910,98 @@ export class Supervisor {
|
|
|
1828
1910
|
running.session.cancelQuestions('turn_aborted');
|
|
1829
1911
|
running.openQuestions.clear();
|
|
1830
1912
|
await running.session.interrupt();
|
|
1831
|
-
|
|
1913
|
+
if (announce) {
|
|
1914
|
+
this.sendEvent(running, 'notice', {
|
|
1915
|
+
level: 'info',
|
|
1916
|
+
text: reason === 'pause'
|
|
1917
|
+
? 'Paused — the turn was stopped. Nothing else will start until the clock runs out.'
|
|
1918
|
+
: 'Turn interrupted by the user',
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1832
1921
|
const next = running.descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
|
|
1833
1922
|
this.reportStatus(sessionId, next, {
|
|
1834
1923
|
costUsd: running.costUsd,
|
|
1835
1924
|
activeMs: Supervisor.spentMs(running),
|
|
1836
1925
|
});
|
|
1837
1926
|
}
|
|
1927
|
+
/** Is this session held under a clock right now (ticket #196)? */
|
|
1928
|
+
static isPaused(running) {
|
|
1929
|
+
return running.pausedUntil !== undefined && running.pausedUntil > Date.now();
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Set or clear the clock this session is held under (ticket #196).
|
|
1933
|
+
*
|
|
1934
|
+
* `null` means the pause is over. Releasing does NOT start anything by
|
|
1935
|
+
* itself: the API sends the messages that were waiting through the ordinary
|
|
1936
|
+
* message path, and anything this runner was holding is flushed here.
|
|
1937
|
+
*/
|
|
1938
|
+
async applyPause(sessionId, pausedUntil) {
|
|
1939
|
+
const running = this.sessions.get(sessionId);
|
|
1940
|
+
if (!running)
|
|
1941
|
+
return;
|
|
1942
|
+
const until = pausedUntil ? Date.parse(pausedUntil) : Number.NaN;
|
|
1943
|
+
if (Number.isFinite(until) && until > Date.now()) {
|
|
1944
|
+
const first = !Supervisor.isPaused(running);
|
|
1945
|
+
running.pausedUntil = until;
|
|
1946
|
+
// Interrupting on every pause frame, not only the first: the frame is
|
|
1947
|
+
// also how a MOVED pause arrives, and a turn that slipped through in
|
|
1948
|
+
// between has to be stopped too. `interruptSession` is idempotent — an
|
|
1949
|
+
// adapter with nothing to interrupt does nothing.
|
|
1950
|
+
if (first || running.lastReported === 'RUNNING' || running.lastReported === 'STARTING') {
|
|
1951
|
+
await this.interruptSession(sessionId, 'pause');
|
|
1952
|
+
}
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (running.pausedUntil === undefined)
|
|
1956
|
+
return; // already free
|
|
1957
|
+
delete running.pausedUntil;
|
|
1958
|
+
// Both of these belong to the clock and must go with it (QA-149 MINOR-1).
|
|
1959
|
+
// A pause set while the process was still coming up and cancelled a second
|
|
1960
|
+
// later used to leave `interruptWhenReady` standing — and the launch then
|
|
1961
|
+
// killed the session's first turn and signed it «Turn interrupted by the
|
|
1962
|
+
// user», over a turn the user never touched. Exactly the lie #196 removed.
|
|
1963
|
+
delete running.interruptWhenReady;
|
|
1964
|
+
delete running.pauseInterruptAt;
|
|
1965
|
+
// Whatever was held while the clock ran goes now, in the order it arrived.
|
|
1966
|
+
this.flushPendingMessages(running);
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* The turn started anyway — stop it (ticket #196).
|
|
1970
|
+
*
|
|
1971
|
+
* The last line of defence, and the only one that can catch a turn nobody
|
|
1972
|
+
* outside the agent process asked for: a background subagent's report wakes a
|
|
1973
|
+
* new turn inside the CLI (gotcha #244), and an interrupted Codex turn starts
|
|
1974
|
+
* the next queued one by itself. Neither passes through any frame this runner
|
|
1975
|
+
* could refuse, so the only place to notice them is the events they produce.
|
|
1976
|
+
*
|
|
1977
|
+
* Deliberately quiet: the notice was already written when the pause landed,
|
|
1978
|
+
* and one per interrupted background turn would bury the feed.
|
|
1979
|
+
*/
|
|
1980
|
+
stopWorkUnderPause(running) {
|
|
1981
|
+
if (!Supervisor.isPaused(running) || !running.session)
|
|
1982
|
+
return;
|
|
1983
|
+
// One interrupt per burst, not per event. An abort is not instant: a turn
|
|
1984
|
+
// being stopped still emits whatever was already in flight, and without
|
|
1985
|
+
// this every one of those lines would fire another `interrupt()` — a
|
|
1986
|
+
// hundred round trips into the CLI to stop something that is already
|
|
1987
|
+
// stopping. Two seconds is far shorter than the gap before a genuinely NEW
|
|
1988
|
+
// turn (a background subagent finishing), which still gets its own.
|
|
1989
|
+
const now = Date.now();
|
|
1990
|
+
if (running.pauseInterruptAt !== undefined && now - running.pauseInterruptAt < 2_000)
|
|
1991
|
+
return;
|
|
1992
|
+
running.pauseInterruptAt = now;
|
|
1993
|
+
// Through the ordinary path rather than straight at the adapter (QA-149
|
|
1994
|
+
// MAJOR-2): it also withdraws the cards this turn had opened. A turn killed
|
|
1995
|
+
// with a question still parked leaves the person's next message to be
|
|
1996
|
+
// swallowed as an answer to it — the QA-106 m7 trap, which the main
|
|
1997
|
+
// interrupt path has guarded against since it was found.
|
|
1998
|
+
void this.interruptSession(running.descriptor.id, 'pause', false).catch((error) => {
|
|
1999
|
+
log.warn('supervisor: could not stop a turn that started under a pause', {
|
|
2000
|
+
sessionId: running.descriptor.id,
|
|
2001
|
+
error: String(error),
|
|
2002
|
+
});
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
1838
2005
|
/** Live model / interaction-mode switch (persisted for the next relaunch). */
|
|
1839
2006
|
async applySettings(sessionId, model, mode, effort) {
|
|
1840
2007
|
const running = this.sessions.get(sessionId);
|
|
@@ -2026,6 +2193,19 @@ export class Supervisor {
|
|
|
2026
2193
|
tracked.epoch = descriptor.epoch;
|
|
2027
2194
|
tracked.descriptor = { ...tracked.descriptor, epoch: descriptor.epoch };
|
|
2028
2195
|
}
|
|
2196
|
+
// Ticket #196, QA-149 MAJOR-1. The pause is re-established HERE, and
|
|
2197
|
+
// this is the case that matters most: a dropped socket leaves the agent
|
|
2198
|
+
// process running, so «reconnect» is precisely when a session is
|
|
2199
|
+
// `tracked`. The first cut of #196 read `pausedUntil` only in the two
|
|
2200
|
+
// constructors of a NEW `RunningSession`, which meant it survived a
|
|
2201
|
+
// runner RESTART and not a reconnect — and a pause set while the socket
|
|
2202
|
+
// was down never arrived at all, because `session_pause` is
|
|
2203
|
+
// fire-and-forget with no outbox behind it.
|
|
2204
|
+
//
|
|
2205
|
+
// Both directions matter: the row may have gained a clock (hold now) or
|
|
2206
|
+
// lost one (release and send what was held). `applyPause` does both, and
|
|
2207
|
+
// it runs BEFORE `flushSessionOutbox` arrives from the API side.
|
|
2208
|
+
await this.applyPause(descriptor.id, descriptor.pausedUntil ?? null);
|
|
2029
2209
|
this.reportStatus(descriptor.id, statusForReport(tracked), {
|
|
2030
2210
|
costUsd: tracked.costUsd,
|
|
2031
2211
|
...(tracked.branch ? { branch: tracked.branch } : {}),
|
|
@@ -2091,6 +2271,8 @@ export class Supervisor {
|
|
|
2091
2271
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
2092
2272
|
epoch: descriptor.epoch,
|
|
2093
2273
|
openQuestions: new Set(),
|
|
2274
|
+
answeredAsks: new Set(),
|
|
2275
|
+
...pausedUntilOf(descriptor),
|
|
2094
2276
|
mode: descriptor.mode,
|
|
2095
2277
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
2096
2278
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -2135,7 +2317,15 @@ export class Supervisor {
|
|
|
2135
2317
|
// middle of" — would be addressed to an agent that remembers none of
|
|
2136
2318
|
// it. A process killed before it reported its session id (the SIGABRT
|
|
2137
2319
|
// this ticket came from) leaves the row in exactly that state.
|
|
2138
|
-
|
|
2320
|
+
// Ticket #196: a paused session is never continued automatically. The
|
|
2321
|
+
// row still says RUNNING — a pause interrupts the turn but is not a
|
|
2322
|
+
// status — so without this the reconnect would read «mid-turn» and
|
|
2323
|
+
// relaunch the agent with «continue from where you stopped», which is
|
|
2324
|
+
// the exact opposite of what the clock was set for.
|
|
2325
|
+
const willContinue = wasMidTurn &&
|
|
2326
|
+
!Supervisor.isPaused(running) &&
|
|
2327
|
+
Boolean(resumeId) &&
|
|
2328
|
+
claimAutoResume(descriptor.id);
|
|
2139
2329
|
// The note stays either way (owner's call): an interruption is a fact
|
|
2140
2330
|
// about the session and must not disappear just because we recovered
|
|
2141
2331
|
// from it. Only the instruction at the end changes — telling someone to
|
|
@@ -2281,6 +2471,39 @@ export class Supervisor {
|
|
|
2281
2471
|
ok: false,
|
|
2282
2472
|
error: 'reset_workspace is not supported by this runner version',
|
|
2283
2473
|
});
|
|
2474
|
+
case 'answer_question': {
|
|
2475
|
+
// The same work as the `question_answer` frame, with one difference
|
|
2476
|
+
// that is the whole point: this one ANSWERS. The frame was
|
|
2477
|
+
// fire-and-forget, so the API called a write into a socket a delivery
|
|
2478
|
+
// — and a socket reports OPEN for up to three missed pongs after the
|
|
2479
|
+
// machine behind it has gone. An answer lost in that window took the
|
|
2480
|
+
// card with it for a day (2026-08-11).
|
|
2481
|
+
//
|
|
2482
|
+
// No `await` before the reply, for the same reason as `recall_message`
|
|
2483
|
+
// below: `onQuestionAnswer` is synchronous, and a yield here would let
|
|
2484
|
+
// a concurrent frame close the ask between the check and the answer.
|
|
2485
|
+
const sessionId = frame.sessionId;
|
|
2486
|
+
if (!sessionId)
|
|
2487
|
+
return void reply({ ok: false, error: 'sessionId is required' });
|
|
2488
|
+
const parsed = QuestionAnswerArgsSchema.safeParse(frame.args ?? {});
|
|
2489
|
+
if (!parsed.success) {
|
|
2490
|
+
return void reply({
|
|
2491
|
+
ok: false,
|
|
2492
|
+
error: parsed.error.issues[0]?.message ?? 'malformed answer',
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
const outcome = this.onQuestionAnswer({
|
|
2496
|
+
type: 'question_answer',
|
|
2497
|
+
sessionId,
|
|
2498
|
+
...parsed.data,
|
|
2499
|
+
});
|
|
2500
|
+
// `ok` is about the RELAY, not about the agent's luck: an answer for a
|
|
2501
|
+
// session this runner no longer holds is the one case the API can fix
|
|
2502
|
+
// by trying again later, so it is the one case reported as a failure.
|
|
2503
|
+
return void reply(outcome === 'unknown_session'
|
|
2504
|
+
? { ok: false, error: 'Unknown session', result: { outcome } }
|
|
2505
|
+
: { ok: true, result: { outcome } });
|
|
2506
|
+
}
|
|
2284
2507
|
case 'recall_message': {
|
|
2285
2508
|
// Ticket #125: take a queued message back before any agent sees it.
|
|
2286
2509
|
const sessionId = frame.sessionId;
|
|
@@ -2918,6 +3141,28 @@ export class Supervisor {
|
|
|
2918
3141
|
this.opts.onRestartRequested?.(outcome);
|
|
2919
3142
|
return;
|
|
2920
3143
|
}
|
|
3144
|
+
/**
|
|
3145
|
+
* «Would this file reach the agent, and how big is it» (ticket #192).
|
|
3146
|
+
*
|
|
3147
|
+
* The settings card asks before a session exists, so the answer has to
|
|
3148
|
+
* come from the machine that would do the reading: the file is on this
|
|
3149
|
+
* disk, and the ceiling belongs to THIS build of the runner. A dashboard
|
|
3150
|
+
* carrying its own copy of either would be a screen that can be a
|
|
3151
|
+
* version wrong about a rule it is stating.
|
|
3152
|
+
*
|
|
3153
|
+
* Answers `ok: true` even when the file would be refused — «too large»
|
|
3154
|
+
* is an answer about the file, not a failure of the command, and the
|
|
3155
|
+
* card has to be able to draw it.
|
|
3156
|
+
*/
|
|
3157
|
+
case 'agent_prompt_state': {
|
|
3158
|
+
const root = str(frame.args?.['root']) ?? str(frame.args?.['workspacePath']);
|
|
3159
|
+
if (!root)
|
|
3160
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
3161
|
+
const path = str(frame.args?.['path']);
|
|
3162
|
+
if (!path)
|
|
3163
|
+
return void reply({ ok: false, error: 'path argument is required' });
|
|
3164
|
+
return void reply({ ok: true, result: inspectAgentPrompt(root, path) });
|
|
3165
|
+
}
|
|
2921
3166
|
// ─── Session 14: the project recipe ──────────────────────────
|
|
2922
3167
|
//
|
|
2923
3168
|
// Read-only, always available even when verification is switched off:
|
|
@@ -3297,6 +3542,20 @@ export class Supervisor {
|
|
|
3297
3542
|
this.verify.shutdown();
|
|
3298
3543
|
}
|
|
3299
3544
|
}
|
|
3545
|
+
/**
|
|
3546
|
+
* The pause carried by a descriptor, as a field that can be spread (#196).
|
|
3547
|
+
*
|
|
3548
|
+
* A clock already in the past is read as no clock at all: the API clears it on
|
|
3549
|
+
* release, but a runner that was down when the release happened would otherwise
|
|
3550
|
+
* hold the session for ever on a number nobody is going to update.
|
|
3551
|
+
*/
|
|
3552
|
+
function pausedUntilOf(descriptor) {
|
|
3553
|
+
const raw = descriptor.pausedUntil;
|
|
3554
|
+
if (!raw)
|
|
3555
|
+
return {};
|
|
3556
|
+
const until = Date.parse(raw);
|
|
3557
|
+
return Number.isFinite(until) && until > Date.now() ? { pausedUntil: until } : {};
|
|
3558
|
+
}
|
|
3300
3559
|
function str(value) {
|
|
3301
3560
|
return typeof value === 'string' && value ? value : null;
|
|
3302
3561
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.37.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED