@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.
@@ -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, and its `settings` REPLACE
487
- // the thread's developer instructions — so only send it on a real change.
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
- // Session 13: they are sent for plan mode too. Passing `null` there did not
490
- // mean «keep what the thread had», it meant «no developer instructions at
491
- // all» — so the one mode a user picks when they want the agent to think
492
- // before acting was the one mode where DevBridge's rules, the branch it is
493
- // on and the project's own CLAUDE.md never reached it.
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
- developer_instructions: composeSystemAppend(this.spec),
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;
@@ -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
- /** Big enough for a real process document; small enough to stay a prompt. */
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
- /** Already phrased for a human and safe to show — no raw paths beyond the one they typed. */
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
  *
@@ -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
- /** Big enough for a real process document; small enough to stay a prompt. */
31
- export const AGENT_PROMPT_MAX_BYTES = 32 * 1024;
32
- function deny(reason) {
33
- return { ok: false, reason };
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 buffer;
162
+ let read;
144
163
  try {
145
- buffer = readCapped(realPath, AGENT_PROMPT_MAX_BYTES + 1, link);
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
- return deny(`the file is larger than ${Math.floor(AGENT_PROMPT_MAX_BYTES / 1024)} KB`);
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.slice(0, 10)}` : ''),
937
+ (info.expiresAt ? ` · until ${formatLocalDay(info.expiresAt)}` : ''),
912
938
  ...(signedIn
913
939
  ? {}
914
940
  : {
@@ -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({
@@ -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
- /** Stop the current turn without ending the session (VS-Code-style Stop). */
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? */
@@ -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: 'Session resumed — send a message to continue where the agent left off.',
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
- const agentPrompt = this.resolveAgentPrompt(running);
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: `Project prompt loaded from ${quotePath(result.relPath)} — ${agentPromptSizeLabel(result.bytes)}, sha ${result.sha}.`,
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
- this.noteAgentIsWorking(running);
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
- /** Stop the current turn without ending the session (VS-Code-style Stop). */
1786
- async interruptSession(sessionId) {
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?.session)
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
- this.sendEvent(running, 'notice', { level: 'info', text: 'Turn interrupted by the user' });
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
- const willContinue = wasMidTurn && claimAutoResume(descriptor.id);
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
- : 'Runner reconnected. The session was resumed — send a message to continue.',
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.34.0";
1
+ export declare const RUNNER_VERSION = "0.36.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.34.0';
2
+ export const RUNNER_VERSION = '0.36.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",