@bridge4dev/runner 0.35.0 → 0.37.0

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