@bridge4dev/runner 0.44.2 → 0.45.1

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.
@@ -1983,8 +1983,9 @@ class ClaudeSession {
1983
1983
  // exit: the words reach the agent immediately and the turn continues.
1984
1984
  const openAsk = this.oldestOpenAsk();
1985
1985
  if (openAsk) {
1986
- this.answerQuestion({ askId: openAsk, action: 'discuss', text });
1987
- return;
1986
+ // `answerQuestion` answers whether the ask took it; either way the words
1987
+ // are now the card's business and not the queue's.
1988
+ return this.answerQuestion({ askId: openAsk, action: 'discuss', text });
1988
1989
  }
1989
1990
  this.resumingTurn();
1990
1991
  const accepted = this.input.push({
@@ -1993,10 +1994,15 @@ class ClaudeSession {
1993
1994
  parent_tool_use_id: null,
1994
1995
  });
1995
1996
  if (!accepted) {
1996
- log.warn('claude: send after session ended — message dropped', {
1997
+ // #231 F8: said out loud now instead of only here. The supervisor puts
1998
+ // the message back on the queue — until this release the refusal reached
1999
+ // nobody, and the browser was left showing a bubble for words the agent
2000
+ // had already refused.
2001
+ log.warn('claude: send after session ended — message refused', {
1997
2002
  sessionId: this.spec.sessionId,
1998
2003
  });
1999
2004
  }
2005
+ return accepted;
2000
2006
  }
2001
2007
  async interrupt() {
2002
2008
  this.aborting = true;
@@ -2296,6 +2302,13 @@ class ClaudeSession {
2296
2302
  // reads it to keep the streak counter honest — a turn that was
2297
2303
  // refused is not a turn that ran.
2298
2304
  ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
2305
+ // #300: the same arithmetic as the failing branch below, on the
2306
+ // branch where it decides something else. A successful turn that
2307
+ // put nothing on the wire is usually a warm-up the person never
2308
+ // asked for — a resumed process closing a turn of its own — and
2309
+ // the supervisor holds it rather than announcing «your turn»
2310
+ // over an agent that has not spoken yet.
2311
+ ...(this.turnProduced ? { produced: true } : {}),
2299
2312
  });
2300
2313
  // #279: a turn just moved the plan, so the percentages are worth
2301
2314
  // re-reading — throttled inside, and free either way.
@@ -552,10 +552,13 @@ class CodexSession {
552
552
  }
553
553
  send(text) {
554
554
  if (this.stopped) {
555
- log.warn('codex: send after session ended — message dropped', {
555
+ // #231 F8: answered rather than swallowed. The supervisor puts the
556
+ // message back on the queue instead of leaving a bubble on screen for
557
+ // words this session already refused.
558
+ log.warn('codex: send after session ended — message refused', {
556
559
  sessionId: this.spec.sessionId,
557
560
  });
558
- return;
561
+ return false;
559
562
  }
560
563
  // A parked elicitation takes priority: the agent is blocked on it, and
561
564
  // starting a turn would deadlock behind the open request. Typing is a
@@ -572,7 +575,7 @@ class CodexSession {
572
575
  custom: text,
573
576
  })),
574
577
  });
575
- return;
578
+ return true;
576
579
  }
577
580
  // Answering a plan card by typing instead of clicking is normal ("do it
578
581
  // differently"). Without this the held plan stayed forever and
@@ -592,13 +595,14 @@ class CodexSession {
592
595
  // Approving the plan is the moment the agent may actually work.
593
596
  if (!this.ready) {
594
597
  this.queuedInput.push(text);
595
- return;
598
+ return true;
596
599
  }
597
600
  if (this.activeTurnId) {
598
601
  void this.steer(text);
599
- return;
602
+ return true;
600
603
  }
601
604
  this.startTurn(text);
605
+ return true;
602
606
  }
603
607
  async steer(text) {
604
608
  if (!this.threadId || !this.activeTurnId)
@@ -1560,6 +1564,10 @@ class CodexSession {
1560
1564
  type: 'turn_end',
1561
1565
  ok: true,
1562
1566
  ...(status === 'interrupted' ? { aborted: true } : {}),
1567
+ // #300, the twin of the failing branch above: a turn that ended well
1568
+ // having produced nothing is usually no turn at all, and the supervisor
1569
+ // holds it rather than saying «your turn» over a working agent.
1570
+ ...(this.turnProduced ? { produced: true } : {}),
1563
1571
  });
1564
1572
  this.flushQueued();
1565
1573
  }
@@ -502,6 +502,13 @@ export type AgentEvent = {
502
502
  * The discriminator #257 turns on. `false` means the turn is safe to send
503
503
  * again; `true` means files may already be written and commands already
504
504
  * run, so it may only be RESUMED.
505
+ *
506
+ * Since ticket #300 it is reported on a SUCCESSFUL turn too, where it
507
+ * answers a different question with the same arithmetic: a turn that ends
508
+ * `ok` having produced nothing is, as often as not, no turn at all — a
509
+ * resumed process closing a warm-up the person never asked for. The
510
+ * supervisor holds such an ending briefly rather than announcing «your
511
+ * turn» over an agent that has not started speaking yet.
505
512
  */
506
513
  produced?: boolean;
507
514
  /**
@@ -553,8 +560,16 @@ export interface AgentSession {
553
560
  * make the NEXT message be consumed as the answer to a turn that is gone.
554
561
  */
555
562
  cancelQuestions(reason: QuestionInvalidationReason): void;
556
- /** Follow-up user input into the live session. */
557
- send(text: string): void;
563
+ /**
564
+ * Follow-up user input into the live session.
565
+ *
566
+ * Answers whether the session TOOK it (#231 F8). It used to return nothing,
567
+ * and a message pushed into a queue that had already ended was written to the
568
+ * log and forgotten — while the browser was showing that very sentence as a
569
+ * bubble with the session marked «working». The supervisor now puts a refusal
570
+ * back on the queue and says so in the feed.
571
+ */
572
+ send(text: string): boolean;
558
573
  /** Switch model mid-session (VS-Code-extension parity). */
559
574
  setModel(model: string): Promise<void>;
560
575
  /** Switch reasoning effort mid-session; null = the model's own default. */
package/dist/index.js CHANGED
@@ -404,6 +404,11 @@ function runnerCapabilities(apiUrlOverride) {
404
404
  // this list is what `runCommand` actually dispatches on, and the API
405
405
  // falls back to the old frame for runners that do not name it.
406
406
  'answer_question',
407
+ // Ticket #299: and an ordinary message the same way. Same reasoning as
408
+ // the line above, on the path people use every minute — until this
409
+ // release the API called a socket write a delivery, and a message written
410
+ // into a socket that still read OPEN could simply cease to exist.
411
+ 'deliver_message',
407
412
  'compact_context',
408
413
  ...(checkpointsEnabled
409
414
  ? [
@@ -344,6 +344,47 @@ export declare const QuestionAnswerArgsSchema: z.ZodObject<{
344
344
  notes?: string | undefined;
345
345
  }[] | undefined;
346
346
  }>;
347
+ /**
348
+ * The body of a `deliver_message` command — ticket #299.
349
+ *
350
+ * The same three fields the frame carries, with `messageId` required rather
351
+ * than optional: an API old enough to omit it is also too old to send this
352
+ * command at all, and the id is the entire reason a redelivery can be
353
+ * recognised instead of duplicated.
354
+ */
355
+ export declare const DeliverMessageArgsSchema: z.ZodObject<{
356
+ messageId: z.ZodString;
357
+ text: z.ZodString;
358
+ attachments: z.ZodCatch<z.ZodOptional<z.ZodArray<z.ZodObject<{
359
+ id: z.ZodString;
360
+ fileName: z.ZodString;
361
+ mimeType: z.ZodString;
362
+ fileSize: z.ZodNumber;
363
+ }, "strip", z.ZodTypeAny, {
364
+ id: string;
365
+ fileName: string;
366
+ mimeType: string;
367
+ fileSize: number;
368
+ }, {
369
+ id: string;
370
+ fileName: string;
371
+ mimeType: string;
372
+ fileSize: number;
373
+ }>, "many">>>;
374
+ }, "strip", z.ZodTypeAny, {
375
+ text: string;
376
+ messageId: string;
377
+ attachments?: {
378
+ id: string;
379
+ fileName: string;
380
+ mimeType: string;
381
+ fileSize: number;
382
+ }[] | undefined;
383
+ }, {
384
+ text: string;
385
+ messageId: string;
386
+ attachments?: unknown;
387
+ }>;
347
388
  export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
348
389
  type: z.ZodLiteral<"hello_ack">;
349
390
  serverId: z.ZodString;
@@ -1126,6 +1167,12 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1126
1167
  type: z.ZodLiteral<"session_message">;
1127
1168
  sessionId: z.ZodString;
1128
1169
  text: z.ZodString;
1170
+ /**
1171
+ * The API's name for this message (#299). Optional: an API deployed before
1172
+ * that ticket sends none, and a message without a name is still a message —
1173
+ * it just cannot be recognised if it is sent a second time.
1174
+ */
1175
+ messageId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
1129
1176
  attachments: z.ZodCatch<z.ZodOptional<z.ZodArray<z.ZodObject<{
1130
1177
  id: z.ZodString;
1131
1178
  fileName: z.ZodString;
@@ -1152,11 +1199,13 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1152
1199
  mimeType: string;
1153
1200
  fileSize: number;
1154
1201
  }[] | undefined;
1202
+ messageId?: string | undefined;
1155
1203
  }, {
1156
1204
  text: string;
1157
1205
  sessionId: string;
1158
1206
  type: "session_message";
1159
1207
  attachments?: unknown;
1208
+ messageId?: unknown;
1160
1209
  }>, z.ZodObject<{
1161
1210
  type: z.ZodLiteral<"permission_answer">;
1162
1211
  sessionId: z.ZodString;
@@ -1401,6 +1450,20 @@ export type RunnerFrame = {
1401
1450
  * land on the session that has already been picked back up.
1402
1451
  */
1403
1452
  epoch?: number;
1453
+ /**
1454
+ * Subagents still running as this status is reported (ticket #236).
1455
+ *
1456
+ * `WAITING_INPUT` with live background work is not «your turn»: the agent
1457
+ * comes back by itself when the subagent reports. The API stores this and
1458
+ * two readers use it — the notification gate, so nobody is called to a
1459
+ * conversation with nothing to answer, and the status strip, which had no
1460
+ * way to know at all on a page opened after the last `agent_tasks` frame.
1461
+ *
1462
+ * Reported on every status, including the one whose only news is that the
1463
+ * number reached zero — that report IS the moment the turn became the
1464
+ * human's.
1465
+ */
1466
+ backgroundTasks?: number;
1404
1467
  } | {
1405
1468
  type: 'session_unknown';
1406
1469
  sessionId: string;
package/dist/protocol.js CHANGED
@@ -208,6 +208,36 @@ export const QuestionAnswerShape = {
208
208
  };
209
209
  /** The `answer_question` command's arguments — the shape above on its own. */
210
210
  export const QuestionAnswerArgsSchema = z.object(QuestionAnswerShape);
211
+ /**
212
+ * Attachment metadata, named once because two shapes now carry it: the legacy
213
+ * `session_message` frame and the `deliver_message` command that replaces it.
214
+ *
215
+ * `.catch(undefined)` is load-bearing and pre-dates both: one malformed entry
216
+ * must cost the attachments, never the sentence the person typed.
217
+ */
218
+ const MessageAttachmentsSchema = z
219
+ .array(z.object({
220
+ id: z.string().uuid(),
221
+ fileName: z.string().max(500),
222
+ mimeType: z.string().max(200),
223
+ fileSize: z.number().int().min(0),
224
+ }))
225
+ .max(10)
226
+ .optional()
227
+ .catch(undefined);
228
+ /**
229
+ * The body of a `deliver_message` command — ticket #299.
230
+ *
231
+ * The same three fields the frame carries, with `messageId` required rather
232
+ * than optional: an API old enough to omit it is also too old to send this
233
+ * command at all, and the id is the entire reason a redelivery can be
234
+ * recognised instead of duplicated.
235
+ */
236
+ export const DeliverMessageArgsSchema = z.object({
237
+ messageId: z.string().min(1).max(120),
238
+ text: z.string(),
239
+ attachments: MessageAttachmentsSchema,
240
+ });
211
241
  export const GatewayFrameSchema = z.discriminatedUnion('type', [
212
242
  z.object({
213
243
  type: z.literal('hello_ack'),
@@ -236,19 +266,16 @@ export const GatewayFrameSchema = z.discriminatedUnion('type', [
236
266
  type: z.literal('session_message'),
237
267
  sessionId: z.string().uuid(),
238
268
  text: z.string(),
269
+ /**
270
+ * The API's name for this message (#299). Optional: an API deployed before
271
+ * that ticket sends none, and a message without a name is still a message —
272
+ * it just cannot be recognised if it is sent a second time.
273
+ */
274
+ messageId: z.string().max(120).optional().catch(undefined),
239
275
  // Files the user attached (session 10) — metadata only; the bytes are
240
276
  // fetched with the runner's own token. `.catch` so a malformed entry costs
241
277
  // the attachments, never the message the user typed.
242
- attachments: z
243
- .array(z.object({
244
- id: z.string().uuid(),
245
- fileName: z.string().max(500),
246
- mimeType: z.string().max(200),
247
- fileSize: z.number().int().min(0),
248
- }))
249
- .max(10)
250
- .optional()
251
- .catch(undefined),
278
+ attachments: MessageAttachmentsSchema,
252
279
  }),
253
280
  z.object({
254
281
  type: z.literal('permission_answer'),
@@ -53,11 +53,42 @@ export interface SupervisorOptions {
53
53
  * announced and no rewind can be started.
54
54
  */
55
55
  checkpointsEnabled?: boolean;
56
+ /**
57
+ * How long a turn that produced nothing is held before it counts (#300).
58
+ *
59
+ * A test seam, like `proposeCommitMessage` above: the real window is 25
60
+ * seconds and no suite can wait that out, so the behaviour would go
61
+ * unverified — which is exactly how the independent review found the hold
62
+ * firing over a working agent (QA-2026-08-16 M-4). Never set in production.
63
+ */
64
+ emptyTurnSettleMs?: number;
56
65
  }
57
66
  export declare class Supervisor {
58
67
  private readonly ws;
59
68
  private readonly opts;
60
69
  private static readonly ORPHAN_MESSAGE_CAP;
70
+ /**
71
+ * How many delivered message ids one session remembers (#299).
72
+ *
73
+ * Only ever consulted for a REDELIVERY, and the API redelivers on a `hello`
74
+ * — within a reconnect of the original, not a day later. A hundred is far
75
+ * more than that window can hold and still nothing next to a session's own
76
+ * journal.
77
+ */
78
+ private static readonly DELIVERED_MESSAGE_CAP;
79
+ /**
80
+ * How long a turn that produced nothing is held before it counts (#300).
81
+ *
82
+ * Long enough to cover the gap the owner watched — a resumed process closed
83
+ * a phantom turn and the real answer landed twenty-one seconds later — and
84
+ * short enough that a genuinely silent turn is not left looking busy. The
85
+ * cost of being wrong is asymmetric on purpose: a late «your turn» is a
86
+ * cosmetic delay, an early one is a lie that invites someone to interrupt an
87
+ * agent mid-thought.
88
+ */
89
+ private static readonly EMPTY_TURN_SETTLE_MS;
90
+ /** The window actually used — the constant, or a test's own shorter one. */
91
+ private readonly emptyTurnSettleMs;
61
92
  /** A finished session's journal is kept this long for a late reconnect. */
62
93
  private static readonly JOURNAL_TTL_MS;
63
94
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -298,6 +329,54 @@ export declare class Supervisor {
298
329
  * card the user was about to answer — the moment another session wanted a
299
330
  * slot.
300
331
  */
332
+ /**
333
+ * Do what the end of a turn does — send it, and move the status.
334
+ *
335
+ * Extracted from `case 'turn_end'` because ticket #300 gave the same work a
336
+ * second caller: a turn held back as a possible phantom finishes here when
337
+ * its timer runs out, and it must end in exactly the way it would have
338
+ * ended immediately. A copy would be two behaviours one edit apart.
339
+ */
340
+ private completeTurn;
341
+ /**
342
+ * Move the session to the status a finished turn leaves it in.
343
+ *
344
+ * Split out of `completeTurn` because ticket #300 gave it a second caller: a
345
+ * turn held as a possible phantom settles here when its timer runs out, and
346
+ * it has to land in exactly the status it would have landed in immediately.
347
+ */
348
+ private settleTurnStatus;
349
+ /**
350
+ * Hold a turn that produced nothing, in case it was never a turn (#300).
351
+ *
352
+ * Returns true when the turn has been parked and the caller must stop. False
353
+ * means «treat it as a real ending»: a session already on its way out, or one
354
+ * that is not running an agent, has nothing to wait for.
355
+ */
356
+ private holdEmptyTurn;
357
+ /**
358
+ * Drop a held turn: the agent spoke, so the turn it «ended» was a phantom.
359
+ *
360
+ * Called from `noteAgentIsWorking` — the one place that already knows the
361
+ * agent has produced something — and from the real turn ending, so a held
362
+ * phantom can never fire after the turn it belonged to has closed properly.
363
+ */
364
+ private clearEmptyTurn;
365
+ /**
366
+ * Record how many subagents are alive, and say so when it matters (#236).
367
+ *
368
+ * One place, because there are now two callers with opposite news — a frame
369
+ * from the adapter, and the process going away — and «is it the human's turn»
370
+ * must be answered the same way by both.
371
+ *
372
+ * The report on reaching zero is the whole point: the agent is already
373
+ * sitting in a resting status, so nothing else will ever tell the API that
374
+ * the session finally became the person's. `REVIEW` counts as well as
375
+ * `WAITING_INPUT` — a TICKET session waiting on its own subagent is in the
376
+ * same position, and leaving it out was how one of the two statuses kept its
377
+ * notification suppressed for good (QA-2026-08-16 M-5).
378
+ */
379
+ private setBackgroundTasks;
301
380
  private isParkable;
302
381
  /**
303
382
  * Stop the agent process but keep the session resumable.
@@ -342,7 +421,22 @@ export declare class Supervisor {
342
421
  * somebody says so.
343
422
  */
344
423
  private onQuestionAnswer;
345
- private onUserMessage;
424
+ /**
425
+ * Take a message, and say what happened to it — synchronously (#299).
426
+ *
427
+ * NOT A SINGLE `await` FROM HERE TO THE RETURN. Frames are handled
428
+ * concurrently (gotcha #68), so a yield between «is this a duplicate» and
429
+ * «record it» would let the retry of a message overtake the original and be
430
+ * delivered twice. The same rule the question answer next door obeys, and for
431
+ * the same reason: this value becomes a `command_result` the API believes.
432
+ *
433
+ * Everything here was already synchronous — the delivery itself rides on
434
+ * `enqueueDelivery`, which registers work on a chain rather than awaiting it.
435
+ * The only change is that the outcome is now spoken out loud instead of being
436
+ * thrown away, because «I wrote it into a socket» was never an answer to
437
+ * «did the runner get it».
438
+ */
439
+ private acceptUserMessage;
346
440
  /**
347
441
  * Queue a message that no agent can take yet, and say so in the feed
348
442
  * (ticket #125).
@@ -19,7 +19,7 @@ import { selfUpdate } from './self-update.js';
19
19
  import { rememberWorkspacePath } from './environment.js';
20
20
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
21
21
  import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
22
- import { QuestionAnswerArgsSchema } from './protocol.js';
22
+ import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
23
23
  import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
24
24
  /** Refusals shared by every checkpoint command (ticket #126). */
25
25
  const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
@@ -57,6 +57,28 @@ export class Supervisor {
57
57
  ws;
58
58
  opts;
59
59
  static ORPHAN_MESSAGE_CAP = 10;
60
+ /**
61
+ * How many delivered message ids one session remembers (#299).
62
+ *
63
+ * Only ever consulted for a REDELIVERY, and the API redelivers on a `hello`
64
+ * — within a reconnect of the original, not a day later. A hundred is far
65
+ * more than that window can hold and still nothing next to a session's own
66
+ * journal.
67
+ */
68
+ static DELIVERED_MESSAGE_CAP = 100;
69
+ /**
70
+ * How long a turn that produced nothing is held before it counts (#300).
71
+ *
72
+ * Long enough to cover the gap the owner watched — a resumed process closed
73
+ * a phantom turn and the real answer landed twenty-one seconds later — and
74
+ * short enough that a genuinely silent turn is not left looking busy. The
75
+ * cost of being wrong is asymmetric on purpose: a late «your turn» is a
76
+ * cosmetic delay, an early one is a lie that invites someone to interrupt an
77
+ * agent mid-thought.
78
+ */
79
+ static EMPTY_TURN_SETTLE_MS = 25_000;
80
+ /** The window actually used — the constant, or a test's own shorter one. */
81
+ emptyTurnSettleMs;
60
82
  /** A finished session's journal is kept this long for a late reconnect. */
61
83
  static JOURNAL_TTL_MS = 72 * 3_600_000;
62
84
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -94,6 +116,7 @@ export class Supervisor {
94
116
  this.ws = ws;
95
117
  this.opts = opts;
96
118
  this.journals = opts.journals ?? new JournalStore();
119
+ this.emptyTurnSettleMs = opts.emptyTurnSettleMs ?? Supervisor.EMPTY_TURN_SETTLE_MS;
97
120
  this.verify = new VerifyRunner({
98
121
  enabled: opts.verifyEnabled !== false,
99
122
  onReport: (report) => {
@@ -207,7 +230,11 @@ export class Supervisor {
207
230
  await this.startSession(frame.session);
208
231
  break;
209
232
  case 'session_message':
210
- await this.onUserMessage(frame.sessionId, frame.text, frame.attachments);
233
+ // The legacy door, still open for an API that has not learned the
234
+ // command yet. Its outcome goes nowhere because nobody is listening —
235
+ // which is the whole defect ticket #299 is about, and why the command
236
+ // below exists.
237
+ this.acceptUserMessage(frame.sessionId, frame.text, frame.attachments, frame.messageId ?? undefined);
211
238
  break;
212
239
  case 'permission_answer': {
213
240
  const running = this.sessions.get(frame.sessionId);
@@ -313,6 +340,8 @@ export class Supervisor {
313
340
  epoch: descriptor.epoch,
314
341
  openQuestions: new Set(),
315
342
  answeredAsks: new Set(),
343
+ deliveredMessageIds: new Set(),
344
+ backgroundTasks: 0,
316
345
  // Ticket #196: a pause is part of what a session IS, so it is read off
317
346
  // the descriptor rather than waiting for a frame. Without this a runner
318
347
  // that restarted mid-pause would come back knowing nothing and pick the
@@ -332,12 +361,25 @@ export class Supervisor {
332
361
  // exist (session 9).
333
362
  running.pendingMessages.push(...running.journal.pending());
334
363
  this.sessions.set(descriptor.id, running);
335
- // Messages that arrived for a session this runner did not know yet.
364
+ /**
365
+ * Messages that arrived for a session this runner did not know yet.
366
+ *
367
+ * They go in through the ORDINARY door now (#231 F4). They used to be
368
+ * pushed straight onto `pendingMessages` with `appendPending` and no
369
+ * `originSeq` — which meant no echo and no `message_queued`, so the words
370
+ * reached the agent and never appeared in the conversation at all. The
371
+ * person saw an empty composer, an empty feed, and then an answer to
372
+ * something they could not see themselves having asked.
373
+ *
374
+ * `queueMessage` is skipped in favour of the full `acceptUserMessage` so
375
+ * that a session which is ready by now delivers immediately rather than
376
+ * sitting in a queue nothing flushes.
377
+ */
336
378
  const orphaned = this.orphanMessages.get(descriptor.id);
337
379
  if (orphaned) {
338
380
  this.orphanMessages.delete(descriptor.id);
339
381
  for (const message of orphaned) {
340
- running.pendingMessages.push(running.journal.appendPending(message.text, message.attachments));
382
+ this.acceptUserMessage(descriptor.id, message.text, message.attachments, message.messageId);
341
383
  }
342
384
  }
343
385
  try {
@@ -1024,6 +1066,20 @@ export class Supervisor {
1024
1066
  // Idle process ended (parked or died between turns) — stay resumable.
1025
1067
  running.session = null;
1026
1068
  running.parkRequested = false;
1069
+ /**
1070
+ * The subagents died with it (QA-2026-08-16 M-5).
1071
+ *
1072
+ * Here rather than in `park()`, because parking only ASKS the process to
1073
+ * stop and this is where it actually went. The adapter stops publishing
1074
+ * the moment it is told to stop, so without this the last frame's count
1075
+ * stands for ever: the strip says «Working in background» over a parked
1076
+ * session and — far worse — the API goes on suppressing that session's
1077
+ * «your turn» notification for the rest of its life, because the number
1078
+ * it stored never reaches zero again.
1079
+ *
1080
+ * Before the `reportStatus` just below, so the zero rides out on it.
1081
+ */
1082
+ running.backgroundTasks = 0;
1027
1083
  running.costBaseUsd = running.costUsd; // next process starts from here
1028
1084
  // Persist the clock: a parked session can sit for hours and the runner
1029
1085
  // may be restarted before it ever runs again.
@@ -1217,6 +1273,176 @@ export class Supervisor {
1217
1273
  * card the user was about to answer — the moment another session wanted a
1218
1274
  * slot.
1219
1275
  */
1276
+ /**
1277
+ * Do what the end of a turn does — send it, and move the status.
1278
+ *
1279
+ * Extracted from `case 'turn_end'` because ticket #300 gave the same work a
1280
+ * second caller: a turn held back as a possible phantom finishes here when
1281
+ * its timer runs out, and it must end in exactly the way it would have
1282
+ * ended immediately. A copy would be two behaviours one edit apart.
1283
+ */
1284
+ completeTurn(running, descriptor, event) {
1285
+ this.sendEvent(running, 'turn_end', {
1286
+ ok: event.ok,
1287
+ errorMessage: event.errorMessage,
1288
+ ...(event.aborted ? { aborted: true } : {}),
1289
+ });
1290
+ // The session is already on its way out with a status that MEANS
1291
+ // something — a spent budget, a Stop, a teardown. A turn ending inside
1292
+ // that window is a consequence of it, and letting the line below
1293
+ // overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
1294
+ // WAITING_INPUT) replaces a true, actionable ending with a wrong one.
1295
+ if (running.stopRequested || running.budgetSpent)
1296
+ return;
1297
+ // A finished turn is the last instant this conversation is BOTH
1298
+ // complete and readable: the process can be parked or lose its slot at
1299
+ // any point after it, and `conversationAnchor()` needs a live one.
1300
+ this.currentAnchor(running);
1301
+ /**
1302
+ * A turn that produced NOTHING does not get to say «your turn» yet (#300).
1303
+ *
1304
+ * Only the STATUS waits. The `turn_end` event above has already gone out,
1305
+ * because the feed's own readers depend on it — the task tray reads it to
1306
+ * retire a finished turn's counters — and because holding it would make two
1307
+ * facts disagree about one moment.
1308
+ *
1309
+ * The status is the whole symptom: `WAITING_INPUT` is what the dashboard
1310
+ * prints as «Your turn», and a resumed agent process closes a turn it never
1311
+ * ran — no text, no thinking, no tool call — twenty-one seconds before the
1312
+ * real answer arrives. The product spent those seconds claiming to wait for
1313
+ * a person who was, in fact, waiting for it.
1314
+ *
1315
+ * `produced` is the adapter's own arithmetic from 0.44.1 (#252/#257),
1316
+ * reused rather than reinvented.
1317
+ */
1318
+ if (event.ok && event.produced !== true && this.holdEmptyTurn(running, descriptor, event)) {
1319
+ return;
1320
+ }
1321
+ // Not held — so any phantom still waiting from an earlier turn is stale and
1322
+ // must not outlive this ending. Cleared HERE and not before the decision
1323
+ // above, or `holdEmptyTurn`'s «two in a row is a quiet agent, not two
1324
+ // phantoms» rule could never see the first one (QA-2026-08-16 M-6).
1325
+ this.clearEmptyTurn(running);
1326
+ this.settleTurnStatus(running, descriptor, event);
1327
+ }
1328
+ /**
1329
+ * Move the session to the status a finished turn leaves it in.
1330
+ *
1331
+ * Split out of `completeTurn` because ticket #300 gave it a second caller: a
1332
+ * turn held as a possible phantom settles here when its timer runs out, and
1333
+ * it has to land in exactly the status it would have landed in immediately.
1334
+ */
1335
+ settleTurnStatus(running, descriptor, event) {
1336
+ // Turn end is the natural checkpoint for the budget clock: the slice
1337
+ // just closed, so this is the moment the API can persist it. Without a
1338
+ // report here `agentActiveMs` stayed 0 and every restart or resume
1339
+ // silently handed the session a full fresh budget.
1340
+ if (event.ok) {
1341
+ const next = descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
1342
+ this.reportStatus(descriptor.id, next, {
1343
+ costUsd: running.costUsd,
1344
+ activeMs: Supervisor.spentMs(running),
1345
+ });
1346
+ }
1347
+ else if (event.limitBlocked) {
1348
+ // #258. The plan refused this turn — it never ran, so the session has
1349
+ // not failed at anything: it is waiting for a window to open, and the
1350
+ // API has just armed a clock to wake it. FAILED is terminal, and a
1351
+ // terminal session cannot be woken — the pause would ring into a dead
1352
+ // row and cancel the words the person queued behind it.
1353
+ this.reportStatus(descriptor.id, descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW', {
1354
+ costUsd: running.costUsd,
1355
+ activeMs: Supervisor.spentMs(running),
1356
+ });
1357
+ }
1358
+ else {
1359
+ this.reportStatus(descriptor.id, 'FAILED', {
1360
+ costUsd: running.costUsd,
1361
+ activeMs: Supervisor.spentMs(running),
1362
+ errorMessage: event.errorMessage ?? 'Agent turn failed',
1363
+ });
1364
+ }
1365
+ }
1366
+ /**
1367
+ * Hold a turn that produced nothing, in case it was never a turn (#300).
1368
+ *
1369
+ * Returns true when the turn has been parked and the caller must stop. False
1370
+ * means «treat it as a real ending»: a session already on its way out, or one
1371
+ * that is not running an agent, has nothing to wait for.
1372
+ */
1373
+ holdEmptyTurn(running, descriptor, event) {
1374
+ // An aborted turn produced nothing BY DEFINITION — the human pressed Stop,
1375
+ // and making them wait 25 seconds to be told the turn is over would be a
1376
+ // new bug in place of the old one.
1377
+ if (event.aborted)
1378
+ return false;
1379
+ // The session is winding down: a status is already on its way that means
1380
+ // something, and holding this would only delay a truthful ending.
1381
+ if (running.stopRequested || running.budgetSpent)
1382
+ return false;
1383
+ // Two of these in a row is not two phantoms — it is a quiet agent. The
1384
+ // second one ends the turn for real.
1385
+ if (running.emptyTurnTimer)
1386
+ return false;
1387
+ log.info('supervisor: holding a turn that produced nothing', {
1388
+ sessionId: descriptor.id,
1389
+ settleMs: this.emptyTurnSettleMs,
1390
+ });
1391
+ const timer = setTimeout(() => {
1392
+ running.emptyTurnTimer = undefined;
1393
+ // The session may have ended, been resumed or been replaced while we
1394
+ // waited — `isStale` is the same guard every other delayed path uses.
1395
+ if (this.isStale(running))
1396
+ return;
1397
+ log.info('supervisor: the empty turn was real after all', { sessionId: descriptor.id });
1398
+ this.settleTurnStatus(running, descriptor, event);
1399
+ }, this.emptyTurnSettleMs);
1400
+ timer.unref();
1401
+ running.emptyTurnTimer = timer;
1402
+ return true;
1403
+ }
1404
+ /**
1405
+ * Drop a held turn: the agent spoke, so the turn it «ended» was a phantom.
1406
+ *
1407
+ * Called from `noteAgentIsWorking` — the one place that already knows the
1408
+ * agent has produced something — and from the real turn ending, so a held
1409
+ * phantom can never fire after the turn it belonged to has closed properly.
1410
+ */
1411
+ clearEmptyTurn(running) {
1412
+ if (!running.emptyTurnTimer)
1413
+ return;
1414
+ clearTimeout(running.emptyTurnTimer);
1415
+ running.emptyTurnTimer = undefined;
1416
+ }
1417
+ /**
1418
+ * Record how many subagents are alive, and say so when it matters (#236).
1419
+ *
1420
+ * One place, because there are now two callers with opposite news — a frame
1421
+ * from the adapter, and the process going away — and «is it the human's turn»
1422
+ * must be answered the same way by both.
1423
+ *
1424
+ * The report on reaching zero is the whole point: the agent is already
1425
+ * sitting in a resting status, so nothing else will ever tell the API that
1426
+ * the session finally became the person's. `REVIEW` counts as well as
1427
+ * `WAITING_INPUT` — a TICKET session waiting on its own subagent is in the
1428
+ * same position, and leaving it out was how one of the two statuses kept its
1429
+ * notification suppressed for good (QA-2026-08-16 M-5).
1430
+ */
1431
+ setBackgroundTasks(running, live) {
1432
+ if (live === running.backgroundTasks)
1433
+ return;
1434
+ const finished = running.backgroundTasks > 0 && live === 0;
1435
+ running.backgroundTasks = live;
1436
+ const resting = running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW'
1437
+ ? running.lastReported
1438
+ : null;
1439
+ if (finished && resting) {
1440
+ this.reportStatus(running.descriptor.id, resting, {
1441
+ costUsd: running.costUsd,
1442
+ activeMs: Supervisor.spentMs(running),
1443
+ });
1444
+ }
1445
+ }
1220
1446
  isParkable(running) {
1221
1447
  return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
1222
1448
  running.openQuestions.size === 0 &&
@@ -1274,6 +1500,12 @@ export class Supervisor {
1274
1500
  * own question must not take the card off the screen.
1275
1501
  */
1276
1502
  noteAgentIsWorking(running) {
1503
+ // Ticket #300: the agent is producing, so a turn held back as a possible
1504
+ // phantom was one. Dropped BEFORE the guards below, because those are about
1505
+ // whether the STATUS may move — and a phantom must be cancelled even when
1506
+ // the status is already right, or it fires later and ends a turn that is
1507
+ // still going.
1508
+ this.clearEmptyTurn(running);
1277
1509
  if (running.openQuestions.size > 0)
1278
1510
  return;
1279
1511
  if (running.stopRequested || running.parkRequested || running.budgetSpent)
@@ -1341,51 +1573,7 @@ export class Supervisor {
1341
1573
  // when it finally succeeds or finally gives up.
1342
1574
  if (!event.ok && this.armApiRetry(running, descriptor, event))
1343
1575
  return;
1344
- this.sendEvent(running, 'turn_end', {
1345
- ok: event.ok,
1346
- errorMessage: event.errorMessage,
1347
- ...(event.aborted ? { aborted: true } : {}),
1348
- });
1349
- // The session is already on its way out with a status that MEANS
1350
- // something — a spent budget, a Stop, a teardown. A turn ending inside
1351
- // that window is a consequence of it, and letting the line below
1352
- // overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
1353
- // WAITING_INPUT) replaces a true, actionable ending with a wrong one.
1354
- if (running.stopRequested || running.budgetSpent)
1355
- return;
1356
- // A finished turn is the last instant this conversation is BOTH
1357
- // complete and readable: the process can be parked or lose its slot at
1358
- // any point after it, and `conversationAnchor()` needs a live one.
1359
- this.currentAnchor(running);
1360
- // Turn end is the natural checkpoint for the budget clock: the slice
1361
- // just closed, so this is the moment the API can persist it. Without a
1362
- // report here `agentActiveMs` stayed 0 and every restart or resume
1363
- // silently handed the session a full fresh budget.
1364
- if (event.ok) {
1365
- const next = descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
1366
- this.reportStatus(descriptor.id, next, {
1367
- costUsd: running.costUsd,
1368
- activeMs: Supervisor.spentMs(running),
1369
- });
1370
- }
1371
- else if (event.limitBlocked) {
1372
- // #258. The plan refused this turn — it never ran, so the session has
1373
- // not failed at anything: it is waiting for a window to open, and the
1374
- // API has just armed a clock to wake it. FAILED is terminal, and a
1375
- // terminal session cannot be woken — the pause would ring into a dead
1376
- // row and cancel the words the person queued behind it.
1377
- this.reportStatus(descriptor.id, descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW', {
1378
- costUsd: running.costUsd,
1379
- activeMs: Supervisor.spentMs(running),
1380
- });
1381
- }
1382
- else {
1383
- this.reportStatus(descriptor.id, 'FAILED', {
1384
- costUsd: running.costUsd,
1385
- activeMs: Supervisor.spentMs(running),
1386
- errorMessage: event.errorMessage ?? 'Agent turn failed',
1387
- });
1388
- }
1576
+ this.completeTurn(running, descriptor, event);
1389
1577
  return;
1390
1578
  }
1391
1579
  case 'error':
@@ -1592,7 +1780,7 @@ export class Supervisor {
1592
1780
  case 'rate_limits':
1593
1781
  this.sendEvent(running, 'rate_limits', { ...event.limits });
1594
1782
  return;
1595
- case 'agent_tasks':
1783
+ case 'agent_tasks': {
1596
1784
  // Ticket #113. A LEVEL signal: every frame carries the whole live set,
1597
1785
  // so the dashboard replaces rather than reconciles and a dropped frame
1598
1786
  // cannot leave a finished subagent spinning in the tray forever.
@@ -1601,7 +1789,23 @@ export class Supervisor {
1601
1789
  done: event.done,
1602
1790
  total: event.total,
1603
1791
  });
1792
+ /**
1793
+ * ...and keep the count where a DECISION can read it (ticket #236).
1794
+ *
1795
+ * Until now this frame only ever flew past on its way to the browser,
1796
+ * and the one place that had to know — «is the turn the human's now» —
1797
+ * was taken without it. Two symptoms, one cause: a status strip reading
1798
+ * «Your turn» a centimetre above a live subagent, and a notification
1799
+ * calling someone to a conversation with nothing to answer.
1800
+ *
1801
+ * Counted from `tasks` rather than from `total`/`done`: those are the
1802
+ * turn's arithmetic and a background task deliberately outlives its own
1803
+ * turn (`endTaskTurn` keeps the live set).
1804
+ */
1805
+ const live = event.tasks.filter((task) => task.status === 'running').length;
1806
+ this.setBackgroundTasks(running, live);
1604
1807
  return;
1808
+ }
1605
1809
  case 'notice': {
1606
1810
  // Only adapter notices are de-duplicated here. The supervisor's own
1607
1811
  // notices (turn interrupted, session parked, budget warnings) go
@@ -1678,12 +1882,9 @@ export class Supervisor {
1678
1882
  // The words must not be eaten. This is the ordinary case after a runner
1679
1883
  // restart: the card in the browser outlived the process that asked, and
1680
1884
  // showing the user's own message in the feed while nothing receives it is
1681
- // the exact failure `onUserMessage` was hardened against (QA-106 M2).
1885
+ // the exact failure `acceptUserMessage` was hardened against (QA-106 M2).
1682
1886
  if (typed) {
1683
- void this.onUserMessage(frame.sessionId, typed).catch((error) => log.error('supervisor: could not deliver a reply to a closed question', {
1684
- sessionId: frame.sessionId,
1685
- error: String(error),
1686
- }));
1887
+ this.acceptUserMessage(frame.sessionId, typed);
1687
1888
  return 'not_open';
1688
1889
  }
1689
1890
  // Nothing to deliver — but the API optimistically flipped the session to
@@ -1691,7 +1892,22 @@ export class Supervisor {
1691
1892
  this.reportStatus(frame.sessionId, statusForReport(running), {});
1692
1893
  return 'not_open';
1693
1894
  }
1694
- async onUserMessage(sessionId, text, attachments) {
1895
+ /**
1896
+ * Take a message, and say what happened to it — synchronously (#299).
1897
+ *
1898
+ * NOT A SINGLE `await` FROM HERE TO THE RETURN. Frames are handled
1899
+ * concurrently (gotcha #68), so a yield between «is this a duplicate» and
1900
+ * «record it» would let the retry of a message overtake the original and be
1901
+ * delivered twice. The same rule the question answer next door obeys, and for
1902
+ * the same reason: this value becomes a `command_result` the API believes.
1903
+ *
1904
+ * Everything here was already synchronous — the delivery itself rides on
1905
+ * `enqueueDelivery`, which registers work on a chain rather than awaiting it.
1906
+ * The only change is that the outcome is now spoken out loud instead of being
1907
+ * thrown away, because «I wrote it into a socket» was never an answer to
1908
+ * «did the runner get it».
1909
+ */
1910
+ acceptUserMessage(sessionId, text, attachments, messageId) {
1695
1911
  const running = this.sessions.get(sessionId);
1696
1912
  if (!running) {
1697
1913
  // The API believes this session lives here but the runner lost track of
@@ -1700,10 +1916,47 @@ export class Supervisor {
1700
1916
  // instead of dropping it silently.
1701
1917
  log.warn('supervisor: message for unknown session — requesting descriptor', { sessionId });
1702
1918
  const queued = this.orphanMessages.get(sessionId) ?? [];
1703
- queued.push({ text, ...(attachments?.length ? { attachments } : {}) });
1919
+ /**
1920
+ * The name travels with the words (QA-2026-08-16 M-2).
1921
+ *
1922
+ * Without it the replay below took the message a SECOND time under no
1923
+ * name, and the API's redelivery of the row it queued for the same
1924
+ * `unknown_session` then looked brand new to `deliveredMessageIds` — the
1925
+ * agent got one instruction twice, in the one scenario that reliably
1926
+ * produces both copies.
1927
+ */
1928
+ queued.push({
1929
+ text,
1930
+ ...(attachments?.length ? { attachments } : {}),
1931
+ ...(messageId ? { messageId } : {}),
1932
+ });
1704
1933
  this.orphanMessages.set(sessionId, queued.slice(-Supervisor.ORPHAN_MESSAGE_CAP));
1705
1934
  this.ws.send({ type: 'session_unknown', sessionId });
1706
- return;
1935
+ return 'unknown_session';
1936
+ }
1937
+ /**
1938
+ * Already taken (#299).
1939
+ *
1940
+ * The API queues a message it could not get confirmed and re-sends it on
1941
+ * the next `hello`. «Not confirmed» is not «not delivered»: the socket can
1942
+ * die between this runner accepting the words and the answer reaching the
1943
+ * API. Without this set the retry would hand the agent the same sentence a
1944
+ * second time — the very failure the queue was built to avoid.
1945
+ *
1946
+ * In memory, so a runner that RESTARTED between the two attempts will take
1947
+ * it twice. That is the deliberate side of the trade: after a restart the
1948
+ * first copy almost certainly never reached the agent, and a duplicate is a
1949
+ * far smaller injury than a message that silently does not exist.
1950
+ */
1951
+ if (messageId) {
1952
+ if (running.deliveredMessageIds.has(messageId))
1953
+ return 'duplicate';
1954
+ running.deliveredMessageIds.add(messageId);
1955
+ if (running.deliveredMessageIds.size > Supervisor.DELIVERED_MESSAGE_CAP) {
1956
+ const oldest = running.deliveredMessageIds.values().next().value;
1957
+ if (oldest !== undefined)
1958
+ running.deliveredMessageIds.delete(oldest);
1959
+ }
1707
1960
  }
1708
1961
  // The feed shows what the USER wrote plus the files they picked — not the
1709
1962
  // composed prompt with workspace paths, which is an implementation detail.
@@ -1712,6 +1965,12 @@ export class Supervisor {
1712
1965
  const echoed = this.sendEvent(running, 'message', {
1713
1966
  role: 'user',
1714
1967
  text,
1968
+ // The API's name for this message, echoed back so the browser can match
1969
+ // the bubble it drew itself the moment Send was pressed (#299). Matching
1970
+ // on text alone works right up until somebody sends the same sentence
1971
+ // twice, which is exactly what a person does when they think the first
1972
+ // one was lost.
1973
+ ...(messageId ? { messageId } : {}),
1715
1974
  ...(attachments?.length ? { attachments } : {}),
1716
1975
  });
1717
1976
  const originSeq = echoed.seq;
@@ -1726,7 +1985,7 @@ export class Supervisor {
1726
1985
  // own conflict tasks. Held here rather than delivered, so «на паузе»
1727
1986
  // means the same thing whichever door the words came through.
1728
1987
  this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
1729
- return;
1988
+ return 'accepted';
1730
1989
  }
1731
1990
  this.enqueueDelivery(running, async () => {
1732
1991
  const composed = await this.materializeAttachments(running, text, attachments);
@@ -1751,6 +2010,7 @@ export class Supervisor {
1751
2010
  return;
1752
2011
  this.deliverMessage(running, composed, [], originSeq);
1753
2012
  });
2013
+ return 'accepted';
1754
2014
  }
1755
2015
  /**
1756
2016
  * Queue a message that no agent can take yet, and say so in the feed
@@ -1987,13 +2247,41 @@ export class Supervisor {
1987
2247
  this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
1988
2248
  }
1989
2249
  };
2250
+ // M-4 (QA-2026-08-16): a new turn is starting, so a phantom held from the
2251
+ // previous one must not fire 25 seconds from now and report WAITING_INPUT
2252
+ // over an agent that is working — the very lie #300 is about, and this time
2253
+ // with an irreversible notification behind it.
2254
+ this.clearEmptyTurn(running);
1990
2255
  if (running.session && !running.parkRequested) {
1991
2256
  // #252: the words this turn is actually running. `lastPrompt` deliberately
1992
2257
  // stays put — three older latches relaunch a PROCESS with it, and handing
1993
2258
  // them a follow-up line instead of the task would start a fresh
1994
2259
  // conversation with «продолжай».
1995
2260
  running.lastTurnPrompt = text;
1996
- running.session.send(text);
2261
+ /**
2262
+ * The process can refuse — and used to refuse in silence (#231 F8).
2263
+ *
2264
+ * `AsyncQueue.push` answers false once the queue has ended, which happens
2265
+ * when the agent's own iterator finished between the check above and this
2266
+ * line. The adapter wrote a line to its log and returned; from here the
2267
+ * message looked delivered, so `settle()` retired it from the queue and
2268
+ * the status went to RUNNING. On screen: the person's bubble, the word
2269
+ * «working», and an agent that had already stopped and would never answer.
2270
+ *
2271
+ * Now it goes back in the queue, exactly like a parked process below, and
2272
+ * the feed says why.
2273
+ */
2274
+ if (!running.session.send(text)) {
2275
+ log.warn('supervisor: the agent process refused the message — requeued', {
2276
+ sessionId: running.descriptor.id,
2277
+ });
2278
+ this.sendEvent(running, 'system_note', {
2279
+ code: 'agent_gone',
2280
+ text: 'The agent process ended before it could take this message. It is queued and will be delivered when the session continues.',
2281
+ });
2282
+ this.requeue(running, held, text, originSeq);
2283
+ return;
2284
+ }
1997
2285
  settle();
1998
2286
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
1999
2287
  return;
@@ -2599,6 +2887,8 @@ export class Supervisor {
2599
2887
  epoch: descriptor.epoch,
2600
2888
  openQuestions: new Set(),
2601
2889
  answeredAsks: new Set(),
2890
+ deliveredMessageIds: new Set(),
2891
+ backgroundTasks: 0,
2602
2892
  ...pausedUntilOf(descriptor),
2603
2893
  mode: descriptor.mode,
2604
2894
  ...(descriptor.model ? { model: descriptor.model } : {}),
@@ -2864,6 +3154,35 @@ export class Supervisor {
2864
3154
  ? { ok: false, error: 'Unknown session', result: { outcome } }
2865
3155
  : { ok: true, result: { outcome } });
2866
3156
  }
3157
+ case 'deliver_message': {
3158
+ // Ticket #299 — the same treatment as `answer_question` above, for
3159
+ // the door people use every minute. The `session_message` frame it
3160
+ // replaces is fire-and-forget, so the API counted a write into a
3161
+ // socket as a delivery; on 16.08.2026 a message written into a socket
3162
+ // that still read OPEN simply ceased to exist, with the queue empty
3163
+ // and the composer cleared.
3164
+ //
3165
+ // No `await` before the reply. `acceptUserMessage` is synchronous
3166
+ // precisely so this stays true: a yield between «is this a duplicate»
3167
+ // and «write it down» would let a redelivery overtake the original.
3168
+ const sessionId = frame.sessionId;
3169
+ if (!sessionId)
3170
+ return void reply({ ok: false, error: 'sessionId is required' });
3171
+ const parsed = DeliverMessageArgsSchema.safeParse(frame.args ?? {});
3172
+ if (!parsed.success) {
3173
+ return void reply({
3174
+ ok: false,
3175
+ error: parsed.error.issues[0]?.message ?? 'malformed message',
3176
+ });
3177
+ }
3178
+ const outcome = this.acceptUserMessage(sessionId, parsed.data.text, parsed.data.attachments, parsed.data.messageId);
3179
+ // `ok` is about the RELAY, like the answer above: `unknown_session` is
3180
+ // the one case the API can fix by asking again after it has re-sent
3181
+ // the descriptor, so it is the one case reported as a failure.
3182
+ return void reply(outcome === 'unknown_session'
3183
+ ? { ok: false, error: 'Unknown session', result: { outcome } }
3184
+ : { ok: true, result: { outcome } });
3185
+ }
2867
3186
  case 'recall_message': {
2868
3187
  // Ticket #125: take a queued message back before any agent sees it.
2869
3188
  const sessionId = frame.sessionId;
@@ -3868,6 +4187,11 @@ export class Supervisor {
3868
4187
  status,
3869
4188
  ...compact,
3870
4189
  ...(running ? { epoch: running.epoch } : {}),
4190
+ // Ticket #236: on EVERY status, not only the interesting ones. The API
4191
+ // stores the number, and a status frame that carries none leaves the last
4192
+ // one standing — which for a session whose subagents have just finished
4193
+ // would mean it goes on claiming background work forever.
4194
+ ...(running ? { backgroundTasks: running.backgroundTasks } : {}),
3871
4195
  });
3872
4196
  }
3873
4197
  /** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.44.2";
1
+ export declare const RUNNER_VERSION = "0.45.1";
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.44.2';
2
+ export const RUNNER_VERSION = '0.45.1';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.44.2",
3
+ "version": "0.45.1",
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",