@bridge4dev/runner 0.34.0 → 0.36.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/adapters/codex.js +28 -8
- package/dist/agent-prompt.d.ts +53 -2
- package/dist/agent-prompt.js +44 -8
- package/dist/index.js +27 -1
- package/dist/protocol.d.ts +70 -0
- package/dist/protocol.js +36 -0
- package/dist/supervisor.d.ts +48 -1
- package/dist/supervisor.js +268 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/codex.js
CHANGED
|
@@ -483,21 +483,41 @@ class CodexSession {
|
|
|
483
483
|
// settings/update in this protocol version).
|
|
484
484
|
...(this.effort ? { effort: this.effort } : {}),
|
|
485
485
|
};
|
|
486
|
-
// Collaboration mode is turn-scoped but sticky,
|
|
487
|
-
//
|
|
486
|
+
// Collaboration mode is turn-scoped but sticky, so it is only sent on a real
|
|
487
|
+
// change. What it must NOT carry is our own instructions (ticket #179).
|
|
488
488
|
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
489
|
+
// The line that used to sit here said `settings` REPLACE the thread's
|
|
490
|
+
// developer instructions, and that passing `null` meant «no developer
|
|
491
|
+
// instructions at all». Both halves are false, and the first one cost the
|
|
492
|
+
// product a duplicated system prompt in every Codex session. Measured on the
|
|
493
|
+
// wire — a fake Responses API behind `codex app-server`, reading the developer
|
|
494
|
+
// message verbatim — on codex-cli 0.135.0 AND 0.147.0, which behave alike:
|
|
495
|
+
//
|
|
496
|
+
// thread developerInstructions + collaborationMode carrying the same text
|
|
497
|
+
// → the text appears TWICE in one developer message: once at the top (on
|
|
498
|
+
// 0.135.0, right after `<permissions instructions>`) and again inside
|
|
499
|
+
// `<collaboration_mode>…</collaboration_mode>`. That is exactly what the
|
|
500
|
+
// ticket's screenshots show, and with a real project prompt it is ~27 KB
|
|
501
|
+
// sent twice on every single request.
|
|
502
|
+
// thread developerInstructions + `developer_instructions: null`
|
|
503
|
+
// → the text appears ONCE, and `<collaboration_mode>` carries Codex's own
|
|
504
|
+
// preset for the mode («# Plan Mode (Conversational)…»).
|
|
505
|
+
//
|
|
506
|
+
// So `null` is not a loss, it is the fix — and it repairs a second, quieter
|
|
507
|
+
// defect at the same time: our text was OVERWRITING Codex's built-in plan
|
|
508
|
+
// instructions, i.e. the one mode a user picks to make the agent think before
|
|
509
|
+
// acting was the one mode that never received the instructions for thinking
|
|
510
|
+
// before acting. The thread-level channel (`threadParams`) stays the single
|
|
511
|
+
// source of DevBridge's rules, exactly like `systemPrompt.append` on Claude.
|
|
494
512
|
if (this.lastCollabMode !== wantCollab) {
|
|
495
513
|
params['collaborationMode'] = {
|
|
496
514
|
mode: wantCollab,
|
|
497
515
|
settings: {
|
|
498
516
|
model: this.model ?? this.threadModel ?? 'gpt-5.5',
|
|
499
517
|
reasoning_effort: null,
|
|
500
|
-
|
|
518
|
+
// «Use the built-in instructions for the selected mode» — the app-server
|
|
519
|
+
// schema's own words. Ours already arrived with the thread.
|
|
520
|
+
developer_instructions: null,
|
|
501
521
|
},
|
|
502
522
|
};
|
|
503
523
|
this.lastCollabMode = wantCollab;
|
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
|
@@ -35,6 +35,18 @@ const LAST_EXIT = takeLastExit();
|
|
|
35
35
|
function print(line) {
|
|
36
36
|
process.stdout.write(line + '\n');
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Дата для оператора, который читает вывод у себя в консоли.
|
|
40
|
+
*
|
|
41
|
+
* Раньше здесь стояло `iso.slice(0, 10)` — это UTC-день, а не день того, кто
|
|
42
|
+
* смотрит: к востоку от Гринвича поздним вечером он показывает вчерашнее число.
|
|
43
|
+
* Часы машины оператора — единственный разумный ответ: у раннера нет ни
|
|
44
|
+
* человека с настройкой, ни организации.
|
|
45
|
+
*/
|
|
46
|
+
function formatLocalDay(iso) {
|
|
47
|
+
const date = new Date(iso);
|
|
48
|
+
return Number.isNaN(date.getTime()) ? iso.slice(0, 10) : date.toLocaleDateString();
|
|
49
|
+
}
|
|
38
50
|
function fail(message) {
|
|
39
51
|
process.stderr.write(`error: ${message}\n`);
|
|
40
52
|
process.exit(1);
|
|
@@ -249,6 +261,16 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
249
261
|
*/
|
|
250
262
|
contextRewind: true,
|
|
251
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,
|
|
252
274
|
/**
|
|
253
275
|
* Reads the project's own prompt file and hands it to the agent as
|
|
254
276
|
* SYSTEM-prompt text, for both Claude and Codex.
|
|
@@ -361,6 +383,10 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
361
383
|
'git_pull',
|
|
362
384
|
'git_merge_abort',
|
|
363
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',
|
|
364
390
|
'propose_commit_message',
|
|
365
391
|
'recall_message',
|
|
366
392
|
'compact_context',
|
|
@@ -908,7 +934,7 @@ async function agentChecks() {
|
|
|
908
934
|
ok: signedIn,
|
|
909
935
|
name: `${agent} login`,
|
|
910
936
|
detail: (info.detail ?? info.status) +
|
|
911
|
-
(info.expiresAt ? ` · until ${info.expiresAt
|
|
937
|
+
(info.expiresAt ? ` · until ${formatLocalDay(info.expiresAt)}` : ''),
|
|
912
938
|
...(signedIn
|
|
913
939
|
? {}
|
|
914
940
|
: {
|
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,6 +265,7 @@ 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;
|
|
@@ -274,6 +292,22 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
274
292
|
epoch: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
275
293
|
activeMsBase: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
276
294
|
extraBudgetMinutes: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
|
295
|
+
/**
|
|
296
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
297
|
+
*
|
|
298
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
299
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
300
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
301
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
302
|
+
* else about the session.
|
|
303
|
+
*
|
|
304
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
305
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
306
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
307
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
308
|
+
* behaviour every runner had before this release.
|
|
309
|
+
*/
|
|
310
|
+
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
277
311
|
workspace: z.ZodObject<{
|
|
278
312
|
id: z.ZodString;
|
|
279
313
|
path: z.ZodString;
|
|
@@ -461,6 +495,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
461
495
|
url: string;
|
|
462
496
|
token: string;
|
|
463
497
|
} | undefined;
|
|
498
|
+
pausedUntil?: string | null | undefined;
|
|
464
499
|
skipAgentPrompt?: boolean | undefined;
|
|
465
500
|
branchHint?: string | undefined;
|
|
466
501
|
branchPlan?: {
|
|
@@ -508,6 +543,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
508
543
|
costUsd?: number | undefined;
|
|
509
544
|
activeMsBase?: number | undefined;
|
|
510
545
|
extraBudgetMinutes?: number | null | undefined;
|
|
546
|
+
pausedUntil?: unknown;
|
|
511
547
|
skipAgentPrompt?: unknown;
|
|
512
548
|
branchHint?: unknown;
|
|
513
549
|
branchPlan?: unknown;
|
|
@@ -555,6 +591,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
555
591
|
url: string;
|
|
556
592
|
token: string;
|
|
557
593
|
} | undefined;
|
|
594
|
+
pausedUntil?: string | null | undefined;
|
|
558
595
|
skipAgentPrompt?: boolean | undefined;
|
|
559
596
|
branchHint?: string | undefined;
|
|
560
597
|
branchPlan?: {
|
|
@@ -608,6 +645,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
608
645
|
costUsd?: number | undefined;
|
|
609
646
|
activeMsBase?: number | undefined;
|
|
610
647
|
extraBudgetMinutes?: number | null | undefined;
|
|
648
|
+
pausedUntil?: unknown;
|
|
611
649
|
skipAgentPrompt?: unknown;
|
|
612
650
|
branchHint?: unknown;
|
|
613
651
|
branchPlan?: unknown;
|
|
@@ -658,6 +696,22 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
658
696
|
epoch: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
659
697
|
activeMsBase: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
660
698
|
extraBudgetMinutes: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
|
699
|
+
/**
|
|
700
|
+
* The clock the session is held under, ISO — ticket #196.
|
|
701
|
+
*
|
|
702
|
+
* Carried in the descriptor and not only in the `session_pause` frame,
|
|
703
|
+
* because a one-off frame is a fact that expires: a runner restarted or
|
|
704
|
+
* reconnected mid-pause would come back knowing nothing and resume the work
|
|
705
|
+
* the pause exists to hold. `hello_ack` re-establishes it with everything
|
|
706
|
+
* else about the session.
|
|
707
|
+
*
|
|
708
|
+
* `.catch(undefined)` like its neighbours: this field rides inside
|
|
709
|
+
* `hello_ack`, which carries EVERY session of the server, so one malformed
|
|
710
|
+
* value must cost its own session at most (QA-100 MAJOR-1). Reading a broken
|
|
711
|
+
* pause as «not paused» is also the safer of the two failures — it is the
|
|
712
|
+
* behaviour every runner had before this release.
|
|
713
|
+
*/
|
|
714
|
+
pausedUntil: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
661
715
|
workspace: z.ZodObject<{
|
|
662
716
|
id: z.ZodString;
|
|
663
717
|
path: z.ZodString;
|
|
@@ -845,6 +899,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
845
899
|
url: string;
|
|
846
900
|
token: string;
|
|
847
901
|
} | undefined;
|
|
902
|
+
pausedUntil?: string | null | undefined;
|
|
848
903
|
skipAgentPrompt?: boolean | undefined;
|
|
849
904
|
branchHint?: string | undefined;
|
|
850
905
|
branchPlan?: {
|
|
@@ -892,6 +947,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
892
947
|
costUsd?: number | undefined;
|
|
893
948
|
activeMsBase?: number | undefined;
|
|
894
949
|
extraBudgetMinutes?: number | null | undefined;
|
|
950
|
+
pausedUntil?: unknown;
|
|
895
951
|
skipAgentPrompt?: unknown;
|
|
896
952
|
branchHint?: unknown;
|
|
897
953
|
branchPlan?: unknown;
|
|
@@ -937,6 +993,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
937
993
|
url: string;
|
|
938
994
|
token: string;
|
|
939
995
|
} | undefined;
|
|
996
|
+
pausedUntil?: string | null | undefined;
|
|
940
997
|
skipAgentPrompt?: boolean | undefined;
|
|
941
998
|
branchHint?: string | undefined;
|
|
942
999
|
branchPlan?: {
|
|
@@ -987,6 +1044,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
987
1044
|
costUsd?: number | undefined;
|
|
988
1045
|
activeMsBase?: number | undefined;
|
|
989
1046
|
extraBudgetMinutes?: number | null | undefined;
|
|
1047
|
+
pausedUntil?: unknown;
|
|
990
1048
|
skipAgentPrompt?: unknown;
|
|
991
1049
|
branchHint?: unknown;
|
|
992
1050
|
branchPlan?: unknown;
|
|
@@ -1109,6 +1167,18 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1109
1167
|
}, {
|
|
1110
1168
|
sessionId: string;
|
|
1111
1169
|
type: "session_interrupt";
|
|
1170
|
+
}>, z.ZodObject<{
|
|
1171
|
+
type: z.ZodLiteral<"session_pause">;
|
|
1172
|
+
sessionId: z.ZodString;
|
|
1173
|
+
pausedUntil: z.ZodNullable<z.ZodString>;
|
|
1174
|
+
}, "strip", z.ZodTypeAny, {
|
|
1175
|
+
sessionId: string;
|
|
1176
|
+
type: "session_pause";
|
|
1177
|
+
pausedUntil: string | null;
|
|
1178
|
+
}, {
|
|
1179
|
+
sessionId: string;
|
|
1180
|
+
type: "session_pause";
|
|
1181
|
+
pausedUntil: string | null;
|
|
1112
1182
|
}>, z.ZodObject<{
|
|
1113
1183
|
type: z.ZodLiteral<"server_settings">;
|
|
1114
1184
|
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(),
|
|
@@ -237,6 +253,26 @@ export const GatewayFrameSchema = z.discriminatedUnion('type', [
|
|
|
237
253
|
}),
|
|
238
254
|
z.object({ type: z.literal('session_stop'), sessionId: z.string().uuid() }),
|
|
239
255
|
z.object({ type: z.literal('session_interrupt'), sessionId: z.string().uuid() }),
|
|
256
|
+
/**
|
|
257
|
+
* The session is held under a clock, or the clock is off — ticket #196.
|
|
258
|
+
*
|
|
259
|
+
* ONE frame carrying the whole state, not a pause/resume pair: two frames are
|
|
260
|
+
* two ways for a runner to end up believing something the row does not say,
|
|
261
|
+
* and `pausedUntil: null` already IS «resumed». The API's own SSE frame is
|
|
262
|
+
* built the same way and for the same reason.
|
|
263
|
+
*
|
|
264
|
+
* Until this existed a pause was a single `session_interrupt` — and an
|
|
265
|
+
* interrupt is an event, not a state. It could arrive before the turn it was
|
|
266
|
+
* meant to stop (both agents drop an interrupt with no active turn), and it
|
|
267
|
+
* said nothing about the minutes that followed: queued messages flushed,
|
|
268
|
+
* auto-resume relaunched, and a background subagent's report woke a whole new
|
|
269
|
+
* turn, all while every screen said «На паузе».
|
|
270
|
+
*/
|
|
271
|
+
z.object({
|
|
272
|
+
type: z.literal('session_pause'),
|
|
273
|
+
sessionId: z.string().uuid(),
|
|
274
|
+
pausedUntil: z.string().max(40).nullable(),
|
|
275
|
+
}),
|
|
240
276
|
// Server-wide settings changed mid-connection (session 8) — today just the
|
|
241
277
|
// parallel-session ceiling.
|
|
242
278
|
z.object({
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -136,6 +136,20 @@ export declare class Supervisor {
|
|
|
136
136
|
* refuse writes to it: in `workMode: DIRECT` the project folder is the
|
|
137
137
|
* agent's own working directory, so without that rule a session could rewrite
|
|
138
138
|
* the prompt it will itself be started with next time (QA-130 MAJOR-3).
|
|
139
|
+
*
|
|
140
|
+
* @param resuming This launch continues an existing agent conversation. It
|
|
141
|
+
* changes only the WORDS, never the behaviour — and the words matter,
|
|
142
|
+
* because a person who has just watched their session crash reads a second
|
|
143
|
+
* «Project prompt loaded» as «my context was thrown away» (ticket #177).
|
|
144
|
+
* Measured, so the line can say it plainly: a system prompt lives in the
|
|
145
|
+
* agent PROCESS, not in the saved conversation. Resuming Claude Code
|
|
146
|
+
* without re-supplying `--append-system-prompt` restores every message and
|
|
147
|
+
* ZERO bytes of the appended prompt; re-supplying it restores the same
|
|
148
|
+
* messages and exactly ONE copy of the prompt, never two. Codex is the same
|
|
149
|
+
* fact by a different route — it rebuilds the developer message from the
|
|
150
|
+
* current parameters on every request. So re-reading the file here is not
|
|
151
|
+
* waste, and skipping it would silently strip the project's rules (and
|
|
152
|
+
* DevBridge's own) from every session that ever resumed.
|
|
139
153
|
*/
|
|
140
154
|
private resolveAgentPrompt;
|
|
141
155
|
/** Warn the user when this share of the budget is gone. */
|
|
@@ -357,8 +371,41 @@ export declare class Supervisor {
|
|
|
357
371
|
* fire and forget — a failure here is logged, and the records stay on disk.
|
|
358
372
|
*/
|
|
359
373
|
private flushPendingMessages;
|
|
360
|
-
/**
|
|
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
|
+
*/
|
|
361
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;
|
|
362
409
|
/** Live model / interaction-mode switch (persisted for the next relaunch). */
|
|
363
410
|
private applySettings;
|
|
364
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';
|
|
@@ -220,6 +220,9 @@ export class Supervisor {
|
|
|
220
220
|
case 'session_interrupt':
|
|
221
221
|
await this.interruptSession(frame.sessionId);
|
|
222
222
|
break;
|
|
223
|
+
case 'session_pause':
|
|
224
|
+
await this.applyPause(frame.sessionId, frame.pausedUntil);
|
|
225
|
+
break;
|
|
223
226
|
case 'session_settings':
|
|
224
227
|
await this.applySettings(frame.sessionId, frame.model, frame.mode, frame.effort);
|
|
225
228
|
break;
|
|
@@ -263,6 +266,11 @@ export class Supervisor {
|
|
|
263
266
|
existing.stopRequested = true;
|
|
264
267
|
existing.session?.stop('session_stopped');
|
|
265
268
|
}
|
|
269
|
+
// The descriptor is newer than what this session was built from, so its
|
|
270
|
+
// clock is too (QA-149 MAJOR-1). Applied on the way out of every early
|
|
271
|
+
// return, not just the reconnect one — a descriptor re-sent for any
|
|
272
|
+
// reason is the freshest thing this runner will see about the pause.
|
|
273
|
+
await this.applyPause(descriptor.id, descriptor.pausedUntil ?? null);
|
|
266
274
|
return;
|
|
267
275
|
}
|
|
268
276
|
// Up to `maxSessions` agents at once (session 8). Sessions idling after a
|
|
@@ -301,6 +309,11 @@ export class Supervisor {
|
|
|
301
309
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
302
310
|
epoch: descriptor.epoch,
|
|
303
311
|
openQuestions: new Set(),
|
|
312
|
+
// Ticket #196: a pause is part of what a session IS, so it is read off
|
|
313
|
+
// the descriptor rather than waiting for a frame. Without this a runner
|
|
314
|
+
// that restarted mid-pause would come back knowing nothing and pick the
|
|
315
|
+
// work straight back up.
|
|
316
|
+
...pausedUntilOf(descriptor),
|
|
304
317
|
mode: descriptor.mode,
|
|
305
318
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
306
319
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -358,8 +371,19 @@ export class Supervisor {
|
|
|
358
371
|
if (descriptor.epoch > 0) {
|
|
359
372
|
// The API owns the resume transition; the feed marker has to come from
|
|
360
373
|
// here because the runner is the only writer of the event seq.
|
|
374
|
+
//
|
|
375
|
+
// Ticket #177: which of the two sentences is true depends on whether
|
|
376
|
+
// there is a conversation to go back to. `providerSessionId` is the
|
|
377
|
+
// agent's own name for it, and it is what the next launch hands to
|
|
378
|
+
// `--resume` / `thread/resume`. Without it the next process starts the
|
|
379
|
+
// conversation over — which is a real loss, and promising «continue
|
|
380
|
+
// where the agent left off» there is the one thing the feed must not do.
|
|
381
|
+
// It happens for real: a process that dies before it reports its session
|
|
382
|
+
// id (the SIGABRT this ticket came from) leaves the row with none.
|
|
361
383
|
this.sendEvent(running, 'system_note', {
|
|
362
|
-
text:
|
|
384
|
+
text: descriptor.providerSessionId
|
|
385
|
+
? 'Session resumed — the agent still has this conversation. Send a message to continue where it left off.'
|
|
386
|
+
: 'Session resumed, but the agent never got as far as naming its conversation, so it starts this one over. Your files, your branch and everything above are untouched.',
|
|
363
387
|
});
|
|
364
388
|
}
|
|
365
389
|
running.lastReported = descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT';
|
|
@@ -444,7 +468,14 @@ export class Supervisor {
|
|
|
444
468
|
// Facts about this session only, plus the one file the project named. The
|
|
445
469
|
// rest of the project's documentation is read by each agent itself — see
|
|
446
470
|
// `composeWorkspaceContext`.
|
|
447
|
-
|
|
471
|
+
// Does this launch CONTINUE the agent's conversation or begin a new one?
|
|
472
|
+
// Read before `rewindAnchor` is consumed below, and without consuming it —
|
|
473
|
+
// the prompt notice says different things about the two cases (#177).
|
|
474
|
+
// A rewind resumes a conversation too, but a CUT one — so it gets neither
|
|
475
|
+
// «loaded» nor «unchanged».
|
|
476
|
+
const rewinding = Boolean(running.rewindAnchor?.agentSession);
|
|
477
|
+
const resuming = rewinding || Boolean(resumeId);
|
|
478
|
+
const agentPrompt = this.resolveAgentPrompt(running, resuming && !rewinding);
|
|
448
479
|
const workspaceContext = composeWorkspaceContext(descriptor, agentPrompt?.text);
|
|
449
480
|
const rewind = running.rewindAnchor;
|
|
450
481
|
delete running.rewindAnchor;
|
|
@@ -494,6 +525,19 @@ export class Supervisor {
|
|
|
494
525
|
branch: running.branch,
|
|
495
526
|
worktreePath: running.worktreePath,
|
|
496
527
|
});
|
|
528
|
+
// Ticket #196: a Stop or a pause that arrived while this process was coming
|
|
529
|
+
// up was dropped on the floor — `interruptSession` returns early when there
|
|
530
|
+
// is no adapter yet, and the window covers preparing the worktree and
|
|
531
|
+
// hashing the whole tree for a checkpoint. The turn it was aimed at is
|
|
532
|
+
// exactly the one starting now, so it is answered here, at the first moment
|
|
533
|
+
// there is something to answer it with.
|
|
534
|
+
if (running.interruptWhenReady || Supervisor.isPaused(running)) {
|
|
535
|
+
delete running.interruptWhenReady;
|
|
536
|
+
void this.interruptSession(descriptor.id, Supervisor.isPaused(running) ? 'pause' : 'user').catch((error) => log.warn('supervisor: could not honour an interrupt requested during startup', {
|
|
537
|
+
sessionId: descriptor.id,
|
|
538
|
+
error: String(error),
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
497
541
|
void this.pumpEvents(running);
|
|
498
542
|
return true;
|
|
499
543
|
}
|
|
@@ -515,8 +559,22 @@ export class Supervisor {
|
|
|
515
559
|
* refuse writes to it: in `workMode: DIRECT` the project folder is the
|
|
516
560
|
* agent's own working directory, so without that rule a session could rewrite
|
|
517
561
|
* the prompt it will itself be started with next time (QA-130 MAJOR-3).
|
|
562
|
+
*
|
|
563
|
+
* @param resuming This launch continues an existing agent conversation. It
|
|
564
|
+
* changes only the WORDS, never the behaviour — and the words matter,
|
|
565
|
+
* because a person who has just watched their session crash reads a second
|
|
566
|
+
* «Project prompt loaded» as «my context was thrown away» (ticket #177).
|
|
567
|
+
* Measured, so the line can say it plainly: a system prompt lives in the
|
|
568
|
+
* agent PROCESS, not in the saved conversation. Resuming Claude Code
|
|
569
|
+
* without re-supplying `--append-system-prompt` restores every message and
|
|
570
|
+
* ZERO bytes of the appended prompt; re-supplying it restores the same
|
|
571
|
+
* messages and exactly ONE copy of the prompt, never two. Codex is the same
|
|
572
|
+
* fact by a different route — it rebuilds the developer message from the
|
|
573
|
+
* current parameters on every request. So re-reading the file here is not
|
|
574
|
+
* waste, and skipping it would silently strip the project's rules (and
|
|
575
|
+
* DevBridge's own) from every session that ever resumed.
|
|
518
576
|
*/
|
|
519
|
-
resolveAgentPrompt(running) {
|
|
577
|
+
resolveAgentPrompt(running, resuming = false) {
|
|
520
578
|
const { descriptor } = running;
|
|
521
579
|
const configured = descriptor.workspace.agentPromptPath?.trim();
|
|
522
580
|
if (!configured)
|
|
@@ -557,7 +615,9 @@ export class Supervisor {
|
|
|
557
615
|
// mode.
|
|
558
616
|
this.sendEvent(running, 'notice', {
|
|
559
617
|
level: 'info',
|
|
560
|
-
text:
|
|
618
|
+
text: resuming
|
|
619
|
+
? `Project prompt re-applied from ${quotePath(result.relPath)} — ${agentPromptSizeLabel(result.bytes)}, sha ${result.sha}. A new agent process needs it again; the conversation is unchanged.`
|
|
620
|
+
: `Project prompt loaded from ${quotePath(result.relPath)} — ${agentPromptSizeLabel(result.bytes)}, sha ${result.sha}.`,
|
|
561
621
|
});
|
|
562
622
|
return { text: result.text, absPath: result.absPath };
|
|
563
623
|
}
|
|
@@ -925,6 +985,9 @@ export class Supervisor {
|
|
|
925
985
|
for (const waiting of this.sessions.values()) {
|
|
926
986
|
if (waiting.session || waiting.stopRequested || waiting.budgetSpent)
|
|
927
987
|
continue;
|
|
988
|
+
// A freed slot is not a reason to start work somebody put on hold (#196).
|
|
989
|
+
if (Supervisor.isPaused(waiting))
|
|
990
|
+
continue;
|
|
928
991
|
if (waiting.pendingMessages.length === 0 || !waiting.worktreePath)
|
|
929
992
|
continue;
|
|
930
993
|
if (this.liveSessionCount(waiting.descriptor.id) >= this.maxSessions)
|
|
@@ -1091,8 +1154,19 @@ export class Supervisor {
|
|
|
1091
1154
|
// Anything below that is the agent talking means the agent is working. Read
|
|
1092
1155
|
// before the switch so every such case gets it, including the ones added
|
|
1093
1156
|
// after this line was written.
|
|
1094
|
-
if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type))
|
|
1095
|
-
|
|
1157
|
+
if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type)) {
|
|
1158
|
+
// Ticket #196. The same signal, read twice for opposite reasons: when the
|
|
1159
|
+
// session is free it proves the agent is working (#185), and when the
|
|
1160
|
+
// session is held under a clock it proves something started that should
|
|
1161
|
+
// not have. The second is the ONLY way to catch a turn nobody outside the
|
|
1162
|
+
// agent process asked for — a background subagent's report (gotcha #244)
|
|
1163
|
+
// and Codex's own «interrupted, so start the next queued turn» both come
|
|
1164
|
+
// through no frame this runner could refuse.
|
|
1165
|
+
if (Supervisor.isPaused(running))
|
|
1166
|
+
this.stopWorkUnderPause(running);
|
|
1167
|
+
else
|
|
1168
|
+
this.noteAgentIsWorking(running);
|
|
1169
|
+
}
|
|
1096
1170
|
switch (event.type) {
|
|
1097
1171
|
case 'provider_session': {
|
|
1098
1172
|
running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
|
|
@@ -1461,10 +1535,16 @@ export class Supervisor {
|
|
|
1461
1535
|
...(attachments?.length ? { attachments } : {}),
|
|
1462
1536
|
});
|
|
1463
1537
|
const originSeq = echoed.seq;
|
|
1464
|
-
if (!running.worktreePath) {
|
|
1538
|
+
if (!running.worktreePath || Supervisor.isPaused(running)) {
|
|
1465
1539
|
// Session is still being prepared — deliver after launch (QA-96 F3).
|
|
1466
1540
|
// Attachments travel as metadata and are downloaded at delivery time,
|
|
1467
1541
|
// which is the first moment the worktree is guaranteed to exist.
|
|
1542
|
+
//
|
|
1543
|
+
// Or the session is held under a clock (#196). The API refuses live
|
|
1544
|
+
// messages for a paused session, but not every path goes through that
|
|
1545
|
+
// check — the outbox flushes on reconnect, and the git service posts its
|
|
1546
|
+
// own conflict tasks. Held here rather than delivered, so «на паузе»
|
|
1547
|
+
// means the same thing whichever door the words came through.
|
|
1468
1548
|
this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
|
|
1469
1549
|
return;
|
|
1470
1550
|
}
|
|
@@ -1746,6 +1826,11 @@ export class Supervisor {
|
|
|
1746
1826
|
* fire and forget — a failure here is logged, and the records stay on disk.
|
|
1747
1827
|
*/
|
|
1748
1828
|
flushPendingMessages(running) {
|
|
1829
|
+
// Ticket #196: six places call this — a launch, a mode relaunch, a freed
|
|
1830
|
+
// slot, a delivery, and twice on reconnect — and none of them knew about a
|
|
1831
|
+
// pause. Held work stays held until the clock is off.
|
|
1832
|
+
if (Supervisor.isPaused(running))
|
|
1833
|
+
return;
|
|
1749
1834
|
const pending = running.pendingMessages.splice(0);
|
|
1750
1835
|
if (pending.length === 0)
|
|
1751
1836
|
return;
|
|
@@ -1782,11 +1867,29 @@ export class Supervisor {
|
|
|
1782
1867
|
this.deliverMessage(running, parts.join('\n\n'), pending);
|
|
1783
1868
|
});
|
|
1784
1869
|
}
|
|
1785
|
-
/**
|
|
1786
|
-
|
|
1870
|
+
/**
|
|
1871
|
+
* Stop the current turn without ending the session (VS-Code-style Stop).
|
|
1872
|
+
*
|
|
1873
|
+
* @param reason `'pause'` when a clock did it rather than a person. Only the
|
|
1874
|
+
* words in the feed differ — «Turn interrupted by the user» over a turn the
|
|
1875
|
+
* user did not touch is the kind of small lie that costs an hour of
|
|
1876
|
+
* debugging later.
|
|
1877
|
+
* @param announce Say it in the feed. The pause watchdog passes `false`: the
|
|
1878
|
+
* line was already written when the clock landed, and one per background
|
|
1879
|
+
* turn it stops would bury the feed under a fact nobody asked about.
|
|
1880
|
+
*/
|
|
1881
|
+
async interruptSession(sessionId, reason = 'user', announce = true) {
|
|
1787
1882
|
const running = this.sessions.get(sessionId);
|
|
1788
|
-
if (!running
|
|
1883
|
+
if (!running)
|
|
1789
1884
|
return;
|
|
1885
|
+
if (!running.session) {
|
|
1886
|
+
// The process is still coming up. Remembered rather than dropped: the
|
|
1887
|
+
// launch honours this the moment the adapter exists, so a Stop or a pause
|
|
1888
|
+
// pressed during startup stops the turn it was aimed at instead of
|
|
1889
|
+
// vanishing (#196).
|
|
1890
|
+
running.interruptWhenReady = true;
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1790
1893
|
// The turn being interrupted is the turn the question belongs to — leaving
|
|
1791
1894
|
// the ask parked would make the user's next message be swallowed as its
|
|
1792
1895
|
// answer (QA-106 m7). The adapter reports each withdrawal itself; the local
|
|
@@ -1794,13 +1897,98 @@ export class Supervisor {
|
|
|
1794
1897
|
running.session.cancelQuestions('turn_aborted');
|
|
1795
1898
|
running.openQuestions.clear();
|
|
1796
1899
|
await running.session.interrupt();
|
|
1797
|
-
|
|
1900
|
+
if (announce) {
|
|
1901
|
+
this.sendEvent(running, 'notice', {
|
|
1902
|
+
level: 'info',
|
|
1903
|
+
text: reason === 'pause'
|
|
1904
|
+
? 'Paused — the turn was stopped. Nothing else will start until the clock runs out.'
|
|
1905
|
+
: 'Turn interrupted by the user',
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1798
1908
|
const next = running.descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
|
|
1799
1909
|
this.reportStatus(sessionId, next, {
|
|
1800
1910
|
costUsd: running.costUsd,
|
|
1801
1911
|
activeMs: Supervisor.spentMs(running),
|
|
1802
1912
|
});
|
|
1803
1913
|
}
|
|
1914
|
+
/** Is this session held under a clock right now (ticket #196)? */
|
|
1915
|
+
static isPaused(running) {
|
|
1916
|
+
return running.pausedUntil !== undefined && running.pausedUntil > Date.now();
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Set or clear the clock this session is held under (ticket #196).
|
|
1920
|
+
*
|
|
1921
|
+
* `null` means the pause is over. Releasing does NOT start anything by
|
|
1922
|
+
* itself: the API sends the messages that were waiting through the ordinary
|
|
1923
|
+
* message path, and anything this runner was holding is flushed here.
|
|
1924
|
+
*/
|
|
1925
|
+
async applyPause(sessionId, pausedUntil) {
|
|
1926
|
+
const running = this.sessions.get(sessionId);
|
|
1927
|
+
if (!running)
|
|
1928
|
+
return;
|
|
1929
|
+
const until = pausedUntil ? Date.parse(pausedUntil) : Number.NaN;
|
|
1930
|
+
if (Number.isFinite(until) && until > Date.now()) {
|
|
1931
|
+
const first = !Supervisor.isPaused(running);
|
|
1932
|
+
running.pausedUntil = until;
|
|
1933
|
+
// Interrupting on every pause frame, not only the first: the frame is
|
|
1934
|
+
// also how a MOVED pause arrives, and a turn that slipped through in
|
|
1935
|
+
// between has to be stopped too. `interruptSession` is idempotent — an
|
|
1936
|
+
// adapter with nothing to interrupt does nothing.
|
|
1937
|
+
if (first || running.lastReported === 'RUNNING' || running.lastReported === 'STARTING') {
|
|
1938
|
+
await this.interruptSession(sessionId, 'pause');
|
|
1939
|
+
}
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
if (running.pausedUntil === undefined)
|
|
1943
|
+
return; // already free
|
|
1944
|
+
delete running.pausedUntil;
|
|
1945
|
+
// Both of these belong to the clock and must go with it (QA-149 MINOR-1).
|
|
1946
|
+
// A pause set while the process was still coming up and cancelled a second
|
|
1947
|
+
// later used to leave `interruptWhenReady` standing — and the launch then
|
|
1948
|
+
// killed the session's first turn and signed it «Turn interrupted by the
|
|
1949
|
+
// user», over a turn the user never touched. Exactly the lie #196 removed.
|
|
1950
|
+
delete running.interruptWhenReady;
|
|
1951
|
+
delete running.pauseInterruptAt;
|
|
1952
|
+
// Whatever was held while the clock ran goes now, in the order it arrived.
|
|
1953
|
+
this.flushPendingMessages(running);
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* The turn started anyway — stop it (ticket #196).
|
|
1957
|
+
*
|
|
1958
|
+
* The last line of defence, and the only one that can catch a turn nobody
|
|
1959
|
+
* outside the agent process asked for: a background subagent's report wakes a
|
|
1960
|
+
* new turn inside the CLI (gotcha #244), and an interrupted Codex turn starts
|
|
1961
|
+
* the next queued one by itself. Neither passes through any frame this runner
|
|
1962
|
+
* could refuse, so the only place to notice them is the events they produce.
|
|
1963
|
+
*
|
|
1964
|
+
* Deliberately quiet: the notice was already written when the pause landed,
|
|
1965
|
+
* and one per interrupted background turn would bury the feed.
|
|
1966
|
+
*/
|
|
1967
|
+
stopWorkUnderPause(running) {
|
|
1968
|
+
if (!Supervisor.isPaused(running) || !running.session)
|
|
1969
|
+
return;
|
|
1970
|
+
// One interrupt per burst, not per event. An abort is not instant: a turn
|
|
1971
|
+
// being stopped still emits whatever was already in flight, and without
|
|
1972
|
+
// this every one of those lines would fire another `interrupt()` — a
|
|
1973
|
+
// hundred round trips into the CLI to stop something that is already
|
|
1974
|
+
// stopping. Two seconds is far shorter than the gap before a genuinely NEW
|
|
1975
|
+
// turn (a background subagent finishing), which still gets its own.
|
|
1976
|
+
const now = Date.now();
|
|
1977
|
+
if (running.pauseInterruptAt !== undefined && now - running.pauseInterruptAt < 2_000)
|
|
1978
|
+
return;
|
|
1979
|
+
running.pauseInterruptAt = now;
|
|
1980
|
+
// Through the ordinary path rather than straight at the adapter (QA-149
|
|
1981
|
+
// MAJOR-2): it also withdraws the cards this turn had opened. A turn killed
|
|
1982
|
+
// with a question still parked leaves the person's next message to be
|
|
1983
|
+
// swallowed as an answer to it — the QA-106 m7 trap, which the main
|
|
1984
|
+
// interrupt path has guarded against since it was found.
|
|
1985
|
+
void this.interruptSession(running.descriptor.id, 'pause', false).catch((error) => {
|
|
1986
|
+
log.warn('supervisor: could not stop a turn that started under a pause', {
|
|
1987
|
+
sessionId: running.descriptor.id,
|
|
1988
|
+
error: String(error),
|
|
1989
|
+
});
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1804
1992
|
/** Live model / interaction-mode switch (persisted for the next relaunch). */
|
|
1805
1993
|
async applySettings(sessionId, model, mode, effort) {
|
|
1806
1994
|
const running = this.sessions.get(sessionId);
|
|
@@ -1992,6 +2180,19 @@ export class Supervisor {
|
|
|
1992
2180
|
tracked.epoch = descriptor.epoch;
|
|
1993
2181
|
tracked.descriptor = { ...tracked.descriptor, epoch: descriptor.epoch };
|
|
1994
2182
|
}
|
|
2183
|
+
// Ticket #196, QA-149 MAJOR-1. The pause is re-established HERE, and
|
|
2184
|
+
// this is the case that matters most: a dropped socket leaves the agent
|
|
2185
|
+
// process running, so «reconnect» is precisely when a session is
|
|
2186
|
+
// `tracked`. The first cut of #196 read `pausedUntil` only in the two
|
|
2187
|
+
// constructors of a NEW `RunningSession`, which meant it survived a
|
|
2188
|
+
// runner RESTART and not a reconnect — and a pause set while the socket
|
|
2189
|
+
// was down never arrived at all, because `session_pause` is
|
|
2190
|
+
// fire-and-forget with no outbox behind it.
|
|
2191
|
+
//
|
|
2192
|
+
// Both directions matter: the row may have gained a clock (hold now) or
|
|
2193
|
+
// lost one (release and send what was held). `applyPause` does both, and
|
|
2194
|
+
// it runs BEFORE `flushSessionOutbox` arrives from the API side.
|
|
2195
|
+
await this.applyPause(descriptor.id, descriptor.pausedUntil ?? null);
|
|
1995
2196
|
this.reportStatus(descriptor.id, statusForReport(tracked), {
|
|
1996
2197
|
costUsd: tracked.costUsd,
|
|
1997
2198
|
...(tracked.branch ? { branch: tracked.branch } : {}),
|
|
@@ -2057,6 +2258,7 @@ export class Supervisor {
|
|
|
2057
2258
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
2058
2259
|
epoch: descriptor.epoch,
|
|
2059
2260
|
openQuestions: new Set(),
|
|
2261
|
+
...pausedUntilOf(descriptor),
|
|
2060
2262
|
mode: descriptor.mode,
|
|
2061
2263
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
2062
2264
|
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
@@ -2095,7 +2297,21 @@ export class Supervisor {
|
|
|
2095
2297
|
*/
|
|
2096
2298
|
const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
|
|
2097
2299
|
const resumeId = descriptor.providerSessionId;
|
|
2098
|
-
|
|
2300
|
+
// Ticket #177: `resumeId` is required, not merely nice to have. Without
|
|
2301
|
+
// it the relaunch starts a FRESH conversation, and `AUTO_RESUME_PROMPT`
|
|
2302
|
+
// — "continue from where you stopped, re-check what you were in the
|
|
2303
|
+
// middle of" — would be addressed to an agent that remembers none of
|
|
2304
|
+
// it. A process killed before it reported its session id (the SIGABRT
|
|
2305
|
+
// this ticket came from) leaves the row in exactly that state.
|
|
2306
|
+
// Ticket #196: a paused session is never continued automatically. The
|
|
2307
|
+
// row still says RUNNING — a pause interrupts the turn but is not a
|
|
2308
|
+
// status — so without this the reconnect would read «mid-turn» and
|
|
2309
|
+
// relaunch the agent with «continue from where you stopped», which is
|
|
2310
|
+
// the exact opposite of what the clock was set for.
|
|
2311
|
+
const willContinue = wasMidTurn &&
|
|
2312
|
+
!Supervisor.isPaused(running) &&
|
|
2313
|
+
Boolean(resumeId) &&
|
|
2314
|
+
claimAutoResume(descriptor.id);
|
|
2099
2315
|
// The note stays either way (owner's call): an interruption is a fact
|
|
2100
2316
|
// about the session and must not disappear just because we recovered
|
|
2101
2317
|
// from it. Only the instruction at the end changes — telling someone to
|
|
@@ -2103,7 +2319,9 @@ export class Supervisor {
|
|
|
2103
2319
|
this.sendEvent(running, 'system_note', {
|
|
2104
2320
|
text: willContinue
|
|
2105
2321
|
? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
|
|
2106
|
-
:
|
|
2322
|
+
: resumeId
|
|
2323
|
+
? 'Runner reconnected. The session was resumed — send a message to continue.'
|
|
2324
|
+
: 'Runner reconnected, but the agent never got as far as naming its conversation, so it starts this one over. Your files, your branch and everything above are untouched.',
|
|
2107
2325
|
});
|
|
2108
2326
|
if (willContinue) {
|
|
2109
2327
|
// Resumed through the PROVIDER session, so the agent keeps its whole
|
|
@@ -2876,6 +3094,28 @@ export class Supervisor {
|
|
|
2876
3094
|
this.opts.onRestartRequested?.(outcome);
|
|
2877
3095
|
return;
|
|
2878
3096
|
}
|
|
3097
|
+
/**
|
|
3098
|
+
* «Would this file reach the agent, and how big is it» (ticket #192).
|
|
3099
|
+
*
|
|
3100
|
+
* The settings card asks before a session exists, so the answer has to
|
|
3101
|
+
* come from the machine that would do the reading: the file is on this
|
|
3102
|
+
* disk, and the ceiling belongs to THIS build of the runner. A dashboard
|
|
3103
|
+
* carrying its own copy of either would be a screen that can be a
|
|
3104
|
+
* version wrong about a rule it is stating.
|
|
3105
|
+
*
|
|
3106
|
+
* Answers `ok: true` even when the file would be refused — «too large»
|
|
3107
|
+
* is an answer about the file, not a failure of the command, and the
|
|
3108
|
+
* card has to be able to draw it.
|
|
3109
|
+
*/
|
|
3110
|
+
case 'agent_prompt_state': {
|
|
3111
|
+
const root = str(frame.args?.['root']) ?? str(frame.args?.['workspacePath']);
|
|
3112
|
+
if (!root)
|
|
3113
|
+
return void reply({ ok: false, error: 'workspacePath is required' });
|
|
3114
|
+
const path = str(frame.args?.['path']);
|
|
3115
|
+
if (!path)
|
|
3116
|
+
return void reply({ ok: false, error: 'path argument is required' });
|
|
3117
|
+
return void reply({ ok: true, result: inspectAgentPrompt(root, path) });
|
|
3118
|
+
}
|
|
2879
3119
|
// ─── Session 14: the project recipe ──────────────────────────
|
|
2880
3120
|
//
|
|
2881
3121
|
// Read-only, always available even when verification is switched off:
|
|
@@ -3255,6 +3495,20 @@ export class Supervisor {
|
|
|
3255
3495
|
this.verify.shutdown();
|
|
3256
3496
|
}
|
|
3257
3497
|
}
|
|
3498
|
+
/**
|
|
3499
|
+
* The pause carried by a descriptor, as a field that can be spread (#196).
|
|
3500
|
+
*
|
|
3501
|
+
* A clock already in the past is read as no clock at all: the API clears it on
|
|
3502
|
+
* release, but a runner that was down when the release happened would otherwise
|
|
3503
|
+
* hold the session for ever on a number nobody is going to update.
|
|
3504
|
+
*/
|
|
3505
|
+
function pausedUntilOf(descriptor) {
|
|
3506
|
+
const raw = descriptor.pausedUntil;
|
|
3507
|
+
if (!raw)
|
|
3508
|
+
return {};
|
|
3509
|
+
const until = Date.parse(raw);
|
|
3510
|
+
return Number.isFinite(until) && until > Date.now() ? { pausedUntil: until } : {};
|
|
3511
|
+
}
|
|
3258
3512
|
function str(value) {
|
|
3259
3513
|
return typeof value === 'string' && value ? value : null;
|
|
3260
3514
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.36.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED