@bridge4dev/runner 0.44.2 → 0.45.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.
@@ -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'),
@@ -58,6 +58,26 @@ export declare class Supervisor {
58
58
  private readonly ws;
59
59
  private readonly opts;
60
60
  private static readonly ORPHAN_MESSAGE_CAP;
61
+ /**
62
+ * How many delivered message ids one session remembers (#299).
63
+ *
64
+ * Only ever consulted for a REDELIVERY, and the API redelivers on a `hello`
65
+ * — within a reconnect of the original, not a day later. A hundred is far
66
+ * more than that window can hold and still nothing next to a session's own
67
+ * journal.
68
+ */
69
+ private static readonly DELIVERED_MESSAGE_CAP;
70
+ /**
71
+ * How long a turn that produced nothing is held before it counts (#300).
72
+ *
73
+ * Long enough to cover the gap the owner watched — a resumed process closed
74
+ * a phantom turn and the real answer landed twenty-one seconds later — and
75
+ * short enough that a genuinely silent turn is not left looking busy. The
76
+ * cost of being wrong is asymmetric on purpose: a late «your turn» is a
77
+ * cosmetic delay, an early one is a lie that invites someone to interrupt an
78
+ * agent mid-thought.
79
+ */
80
+ private static readonly EMPTY_TURN_SETTLE_MS;
61
81
  /** A finished session's journal is kept this long for a late reconnect. */
62
82
  private static readonly JOURNAL_TTL_MS;
63
83
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -298,6 +318,39 @@ export declare class Supervisor {
298
318
  * card the user was about to answer — the moment another session wanted a
299
319
  * slot.
300
320
  */
321
+ /**
322
+ * Do what the end of a turn does — send it, and move the status.
323
+ *
324
+ * Extracted from `case 'turn_end'` because ticket #300 gave the same work a
325
+ * second caller: a turn held back as a possible phantom finishes here when
326
+ * its timer runs out, and it must end in exactly the way it would have
327
+ * ended immediately. A copy would be two behaviours one edit apart.
328
+ */
329
+ private completeTurn;
330
+ /**
331
+ * Move the session to the status a finished turn leaves it in.
332
+ *
333
+ * Split out of `completeTurn` because ticket #300 gave it a second caller: a
334
+ * turn held as a possible phantom settles here when its timer runs out, and
335
+ * it has to land in exactly the status it would have landed in immediately.
336
+ */
337
+ private settleTurnStatus;
338
+ /**
339
+ * Hold a turn that produced nothing, in case it was never a turn (#300).
340
+ *
341
+ * Returns true when the turn has been parked and the caller must stop. False
342
+ * means «treat it as a real ending»: a session already on its way out, or one
343
+ * that is not running an agent, has nothing to wait for.
344
+ */
345
+ private holdEmptyTurn;
346
+ /**
347
+ * Drop a held turn: the agent spoke, so the turn it «ended» was a phantom.
348
+ *
349
+ * Called from `noteAgentIsWorking` — the one place that already knows the
350
+ * agent has produced something — and from the real turn ending, so a held
351
+ * phantom can never fire after the turn it belonged to has closed properly.
352
+ */
353
+ private clearEmptyTurn;
301
354
  private isParkable;
302
355
  /**
303
356
  * Stop the agent process but keep the session resumable.
@@ -342,7 +395,22 @@ export declare class Supervisor {
342
395
  * somebody says so.
343
396
  */
344
397
  private onQuestionAnswer;
345
- private onUserMessage;
398
+ /**
399
+ * Take a message, and say what happened to it — synchronously (#299).
400
+ *
401
+ * NOT A SINGLE `await` FROM HERE TO THE RETURN. Frames are handled
402
+ * concurrently (gotcha #68), so a yield between «is this a duplicate» and
403
+ * «record it» would let the retry of a message overtake the original and be
404
+ * delivered twice. The same rule the question answer next door obeys, and for
405
+ * the same reason: this value becomes a `command_result` the API believes.
406
+ *
407
+ * Everything here was already synchronous — the delivery itself rides on
408
+ * `enqueueDelivery`, which registers work on a chain rather than awaiting it.
409
+ * The only change is that the outcome is now spoken out loud instead of being
410
+ * thrown away, because «I wrote it into a socket» was never an answer to
411
+ * «did the runner get it».
412
+ */
413
+ private acceptUserMessage;
346
414
  /**
347
415
  * Queue a message that no agent can take yet, and say so in the feed
348
416
  * (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,26 @@ 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;
60
80
  /** A finished session's journal is kept this long for a late reconnect. */
61
81
  static JOURNAL_TTL_MS = 72 * 3_600_000;
62
82
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -207,7 +227,11 @@ export class Supervisor {
207
227
  await this.startSession(frame.session);
208
228
  break;
209
229
  case 'session_message':
210
- await this.onUserMessage(frame.sessionId, frame.text, frame.attachments);
230
+ // The legacy door, still open for an API that has not learned the
231
+ // command yet. Its outcome goes nowhere because nobody is listening —
232
+ // which is the whole defect ticket #299 is about, and why the command
233
+ // below exists.
234
+ this.acceptUserMessage(frame.sessionId, frame.text, frame.attachments, frame.messageId ?? undefined);
211
235
  break;
212
236
  case 'permission_answer': {
213
237
  const running = this.sessions.get(frame.sessionId);
@@ -313,6 +337,8 @@ export class Supervisor {
313
337
  epoch: descriptor.epoch,
314
338
  openQuestions: new Set(),
315
339
  answeredAsks: new Set(),
340
+ deliveredMessageIds: new Set(),
341
+ backgroundTasks: 0,
316
342
  // Ticket #196: a pause is part of what a session IS, so it is read off
317
343
  // the descriptor rather than waiting for a frame. Without this a runner
318
344
  // that restarted mid-pause would come back knowing nothing and pick the
@@ -332,12 +358,25 @@ export class Supervisor {
332
358
  // exist (session 9).
333
359
  running.pendingMessages.push(...running.journal.pending());
334
360
  this.sessions.set(descriptor.id, running);
335
- // Messages that arrived for a session this runner did not know yet.
361
+ /**
362
+ * Messages that arrived for a session this runner did not know yet.
363
+ *
364
+ * They go in through the ORDINARY door now (#231 F4). They used to be
365
+ * pushed straight onto `pendingMessages` with `appendPending` and no
366
+ * `originSeq` — which meant no echo and no `message_queued`, so the words
367
+ * reached the agent and never appeared in the conversation at all. The
368
+ * person saw an empty composer, an empty feed, and then an answer to
369
+ * something they could not see themselves having asked.
370
+ *
371
+ * `queueMessage` is skipped in favour of the full `acceptUserMessage` so
372
+ * that a session which is ready by now delivers immediately rather than
373
+ * sitting in a queue nothing flushes.
374
+ */
336
375
  const orphaned = this.orphanMessages.get(descriptor.id);
337
376
  if (orphaned) {
338
377
  this.orphanMessages.delete(descriptor.id);
339
378
  for (const message of orphaned) {
340
- running.pendingMessages.push(running.journal.appendPending(message.text, message.attachments));
379
+ this.acceptUserMessage(descriptor.id, message.text, message.attachments);
341
380
  }
342
381
  }
343
382
  try {
@@ -1217,6 +1256,142 @@ export class Supervisor {
1217
1256
  * card the user was about to answer — the moment another session wanted a
1218
1257
  * slot.
1219
1258
  */
1259
+ /**
1260
+ * Do what the end of a turn does — send it, and move the status.
1261
+ *
1262
+ * Extracted from `case 'turn_end'` because ticket #300 gave the same work a
1263
+ * second caller: a turn held back as a possible phantom finishes here when
1264
+ * its timer runs out, and it must end in exactly the way it would have
1265
+ * ended immediately. A copy would be two behaviours one edit apart.
1266
+ */
1267
+ completeTurn(running, descriptor, event) {
1268
+ this.sendEvent(running, 'turn_end', {
1269
+ ok: event.ok,
1270
+ errorMessage: event.errorMessage,
1271
+ ...(event.aborted ? { aborted: true } : {}),
1272
+ });
1273
+ // The session is already on its way out with a status that MEANS
1274
+ // something — a spent budget, a Stop, a teardown. A turn ending inside
1275
+ // that window is a consequence of it, and letting the line below
1276
+ // overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
1277
+ // WAITING_INPUT) replaces a true, actionable ending with a wrong one.
1278
+ if (running.stopRequested || running.budgetSpent)
1279
+ return;
1280
+ // A finished turn is the last instant this conversation is BOTH
1281
+ // complete and readable: the process can be parked or lose its slot at
1282
+ // any point after it, and `conversationAnchor()` needs a live one.
1283
+ this.currentAnchor(running);
1284
+ /**
1285
+ * A turn that produced NOTHING does not get to say «your turn» yet (#300).
1286
+ *
1287
+ * Only the STATUS waits. The `turn_end` event above has already gone out,
1288
+ * because the feed's own readers depend on it — the task tray reads it to
1289
+ * retire a finished turn's counters — and because holding it would make two
1290
+ * facts disagree about one moment.
1291
+ *
1292
+ * The status is the whole symptom: `WAITING_INPUT` is what the dashboard
1293
+ * prints as «Your turn», and a resumed agent process closes a turn it never
1294
+ * ran — no text, no thinking, no tool call — twenty-one seconds before the
1295
+ * real answer arrives. The product spent those seconds claiming to wait for
1296
+ * a person who was, in fact, waiting for it.
1297
+ *
1298
+ * `produced` is the adapter's own arithmetic from 0.44.1 (#252/#257),
1299
+ * reused rather than reinvented.
1300
+ */
1301
+ if (event.ok && event.produced !== true && this.holdEmptyTurn(running, descriptor, event)) {
1302
+ return;
1303
+ }
1304
+ this.settleTurnStatus(running, descriptor, event);
1305
+ }
1306
+ /**
1307
+ * Move the session to the status a finished turn leaves it in.
1308
+ *
1309
+ * Split out of `completeTurn` because ticket #300 gave it a second caller: a
1310
+ * turn held as a possible phantom settles here when its timer runs out, and
1311
+ * it has to land in exactly the status it would have landed in immediately.
1312
+ */
1313
+ settleTurnStatus(running, descriptor, event) {
1314
+ // Turn end is the natural checkpoint for the budget clock: the slice
1315
+ // just closed, so this is the moment the API can persist it. Without a
1316
+ // report here `agentActiveMs` stayed 0 and every restart or resume
1317
+ // silently handed the session a full fresh budget.
1318
+ if (event.ok) {
1319
+ const next = descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
1320
+ this.reportStatus(descriptor.id, next, {
1321
+ costUsd: running.costUsd,
1322
+ activeMs: Supervisor.spentMs(running),
1323
+ });
1324
+ }
1325
+ else if (event.limitBlocked) {
1326
+ // #258. The plan refused this turn — it never ran, so the session has
1327
+ // not failed at anything: it is waiting for a window to open, and the
1328
+ // API has just armed a clock to wake it. FAILED is terminal, and a
1329
+ // terminal session cannot be woken — the pause would ring into a dead
1330
+ // row and cancel the words the person queued behind it.
1331
+ this.reportStatus(descriptor.id, descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW', {
1332
+ costUsd: running.costUsd,
1333
+ activeMs: Supervisor.spentMs(running),
1334
+ });
1335
+ }
1336
+ else {
1337
+ this.reportStatus(descriptor.id, 'FAILED', {
1338
+ costUsd: running.costUsd,
1339
+ activeMs: Supervisor.spentMs(running),
1340
+ errorMessage: event.errorMessage ?? 'Agent turn failed',
1341
+ });
1342
+ }
1343
+ }
1344
+ /**
1345
+ * Hold a turn that produced nothing, in case it was never a turn (#300).
1346
+ *
1347
+ * Returns true when the turn has been parked and the caller must stop. False
1348
+ * means «treat it as a real ending»: a session already on its way out, or one
1349
+ * that is not running an agent, has nothing to wait for.
1350
+ */
1351
+ holdEmptyTurn(running, descriptor, event) {
1352
+ // An aborted turn produced nothing BY DEFINITION — the human pressed Stop,
1353
+ // and making them wait 25 seconds to be told the turn is over would be a
1354
+ // new bug in place of the old one.
1355
+ if (event.aborted)
1356
+ return false;
1357
+ // The session is winding down: a status is already on its way that means
1358
+ // something, and holding this would only delay a truthful ending.
1359
+ if (running.stopRequested || running.budgetSpent)
1360
+ return false;
1361
+ // Two of these in a row is not two phantoms — it is a quiet agent. The
1362
+ // second one ends the turn for real.
1363
+ if (running.emptyTurnTimer)
1364
+ return false;
1365
+ log.info('supervisor: holding a turn that produced nothing', {
1366
+ sessionId: descriptor.id,
1367
+ settleMs: Supervisor.EMPTY_TURN_SETTLE_MS,
1368
+ });
1369
+ const timer = setTimeout(() => {
1370
+ running.emptyTurnTimer = undefined;
1371
+ // The session may have ended, been resumed or been replaced while we
1372
+ // waited — `isStale` is the same guard every other delayed path uses.
1373
+ if (this.isStale(running))
1374
+ return;
1375
+ log.info('supervisor: the empty turn was real after all', { sessionId: descriptor.id });
1376
+ this.settleTurnStatus(running, descriptor, event);
1377
+ }, Supervisor.EMPTY_TURN_SETTLE_MS);
1378
+ timer.unref();
1379
+ running.emptyTurnTimer = timer;
1380
+ return true;
1381
+ }
1382
+ /**
1383
+ * Drop a held turn: the agent spoke, so the turn it «ended» was a phantom.
1384
+ *
1385
+ * Called from `noteAgentIsWorking` — the one place that already knows the
1386
+ * agent has produced something — and from the real turn ending, so a held
1387
+ * phantom can never fire after the turn it belonged to has closed properly.
1388
+ */
1389
+ clearEmptyTurn(running) {
1390
+ if (!running.emptyTurnTimer)
1391
+ return;
1392
+ clearTimeout(running.emptyTurnTimer);
1393
+ running.emptyTurnTimer = undefined;
1394
+ }
1220
1395
  isParkable(running) {
1221
1396
  return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
1222
1397
  running.openQuestions.size === 0 &&
@@ -1274,6 +1449,12 @@ export class Supervisor {
1274
1449
  * own question must not take the card off the screen.
1275
1450
  */
1276
1451
  noteAgentIsWorking(running) {
1452
+ // Ticket #300: the agent is producing, so a turn held back as a possible
1453
+ // phantom was one. Dropped BEFORE the guards below, because those are about
1454
+ // whether the STATUS may move — and a phantom must be cancelled even when
1455
+ // the status is already right, or it fires later and ends a turn that is
1456
+ // still going.
1457
+ this.clearEmptyTurn(running);
1277
1458
  if (running.openQuestions.size > 0)
1278
1459
  return;
1279
1460
  if (running.stopRequested || running.parkRequested || running.budgetSpent)
@@ -1341,51 +1522,12 @@ export class Supervisor {
1341
1522
  // when it finally succeeds or finally gives up.
1342
1523
  if (!event.ok && this.armApiRetry(running, descriptor, event))
1343
1524
  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
- }
1525
+ // A previous turn's phantom, if any, is over: this turn has ended for
1526
+ // real, and a timer still holding its predecessor would report a status
1527
+ // for a turn that is two turns old (#300 — the hold itself lives in
1528
+ // `completeTurn`, where the decision belongs).
1529
+ this.clearEmptyTurn(running);
1530
+ this.completeTurn(running, descriptor, event);
1389
1531
  return;
1390
1532
  }
1391
1533
  case 'error':
@@ -1592,7 +1734,7 @@ export class Supervisor {
1592
1734
  case 'rate_limits':
1593
1735
  this.sendEvent(running, 'rate_limits', { ...event.limits });
1594
1736
  return;
1595
- case 'agent_tasks':
1737
+ case 'agent_tasks': {
1596
1738
  // Ticket #113. A LEVEL signal: every frame carries the whole live set,
1597
1739
  // so the dashboard replaces rather than reconciles and a dropped frame
1598
1740
  // cannot leave a finished subagent spinning in the tray forever.
@@ -1601,7 +1743,37 @@ export class Supervisor {
1601
1743
  done: event.done,
1602
1744
  total: event.total,
1603
1745
  });
1746
+ /**
1747
+ * ...and keep the count where a DECISION can read it (ticket #236).
1748
+ *
1749
+ * Until now this frame only ever flew past on its way to the browser,
1750
+ * and the one place that had to know — «is the turn the human's now» —
1751
+ * was taken without it. Two symptoms, one cause: a status strip reading
1752
+ * «Your turn» a centimetre above a live subagent, and a notification
1753
+ * calling someone to a conversation with nothing to answer.
1754
+ *
1755
+ * Counted from `tasks` rather than from `total`/`done`: those are the
1756
+ * turn's arithmetic and a background task deliberately outlives its own
1757
+ * turn (`endTaskTurn` keeps the live set).
1758
+ */
1759
+ const live = event.tasks.filter((task) => task.status === 'running').length;
1760
+ if (live !== running.backgroundTasks) {
1761
+ const wasWaiting = running.backgroundTasks > 0 && live === 0;
1762
+ running.backgroundTasks = live;
1763
+ // The moment the last subagent finishes is the moment the session
1764
+ // really does become the human's — and the agent is sitting in
1765
+ // WAITING_INPUT, so nothing else will ever say so. `reportStatus`
1766
+ // carries the new number and the API turns it into the notification
1767
+ // it withheld earlier.
1768
+ if (wasWaiting && running.lastReported === 'WAITING_INPUT') {
1769
+ this.reportStatus(running.descriptor.id, 'WAITING_INPUT', {
1770
+ costUsd: running.costUsd,
1771
+ activeMs: Supervisor.spentMs(running),
1772
+ });
1773
+ }
1774
+ }
1604
1775
  return;
1776
+ }
1605
1777
  case 'notice': {
1606
1778
  // Only adapter notices are de-duplicated here. The supervisor's own
1607
1779
  // notices (turn interrupted, session parked, budget warnings) go
@@ -1678,12 +1850,9 @@ export class Supervisor {
1678
1850
  // The words must not be eaten. This is the ordinary case after a runner
1679
1851
  // restart: the card in the browser outlived the process that asked, and
1680
1852
  // 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).
1853
+ // the exact failure `acceptUserMessage` was hardened against (QA-106 M2).
1682
1854
  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
- }));
1855
+ this.acceptUserMessage(frame.sessionId, typed);
1687
1856
  return 'not_open';
1688
1857
  }
1689
1858
  // Nothing to deliver — but the API optimistically flipped the session to
@@ -1691,7 +1860,22 @@ export class Supervisor {
1691
1860
  this.reportStatus(frame.sessionId, statusForReport(running), {});
1692
1861
  return 'not_open';
1693
1862
  }
1694
- async onUserMessage(sessionId, text, attachments) {
1863
+ /**
1864
+ * Take a message, and say what happened to it — synchronously (#299).
1865
+ *
1866
+ * NOT A SINGLE `await` FROM HERE TO THE RETURN. Frames are handled
1867
+ * concurrently (gotcha #68), so a yield between «is this a duplicate» and
1868
+ * «record it» would let the retry of a message overtake the original and be
1869
+ * delivered twice. The same rule the question answer next door obeys, and for
1870
+ * the same reason: this value becomes a `command_result` the API believes.
1871
+ *
1872
+ * Everything here was already synchronous — the delivery itself rides on
1873
+ * `enqueueDelivery`, which registers work on a chain rather than awaiting it.
1874
+ * The only change is that the outcome is now spoken out loud instead of being
1875
+ * thrown away, because «I wrote it into a socket» was never an answer to
1876
+ * «did the runner get it».
1877
+ */
1878
+ acceptUserMessage(sessionId, text, attachments, messageId) {
1695
1879
  const running = this.sessions.get(sessionId);
1696
1880
  if (!running) {
1697
1881
  // The API believes this session lives here but the runner lost track of
@@ -1703,7 +1887,31 @@ export class Supervisor {
1703
1887
  queued.push({ text, ...(attachments?.length ? { attachments } : {}) });
1704
1888
  this.orphanMessages.set(sessionId, queued.slice(-Supervisor.ORPHAN_MESSAGE_CAP));
1705
1889
  this.ws.send({ type: 'session_unknown', sessionId });
1706
- return;
1890
+ return 'unknown_session';
1891
+ }
1892
+ /**
1893
+ * Already taken (#299).
1894
+ *
1895
+ * The API queues a message it could not get confirmed and re-sends it on
1896
+ * the next `hello`. «Not confirmed» is not «not delivered»: the socket can
1897
+ * die between this runner accepting the words and the answer reaching the
1898
+ * API. Without this set the retry would hand the agent the same sentence a
1899
+ * second time — the very failure the queue was built to avoid.
1900
+ *
1901
+ * In memory, so a runner that RESTARTED between the two attempts will take
1902
+ * it twice. That is the deliberate side of the trade: after a restart the
1903
+ * first copy almost certainly never reached the agent, and a duplicate is a
1904
+ * far smaller injury than a message that silently does not exist.
1905
+ */
1906
+ if (messageId) {
1907
+ if (running.deliveredMessageIds.has(messageId))
1908
+ return 'duplicate';
1909
+ running.deliveredMessageIds.add(messageId);
1910
+ if (running.deliveredMessageIds.size > Supervisor.DELIVERED_MESSAGE_CAP) {
1911
+ const oldest = running.deliveredMessageIds.values().next().value;
1912
+ if (oldest !== undefined)
1913
+ running.deliveredMessageIds.delete(oldest);
1914
+ }
1707
1915
  }
1708
1916
  // The feed shows what the USER wrote plus the files they picked — not the
1709
1917
  // composed prompt with workspace paths, which is an implementation detail.
@@ -1712,6 +1920,12 @@ export class Supervisor {
1712
1920
  const echoed = this.sendEvent(running, 'message', {
1713
1921
  role: 'user',
1714
1922
  text,
1923
+ // The API's name for this message, echoed back so the browser can match
1924
+ // the bubble it drew itself the moment Send was pressed (#299). Matching
1925
+ // on text alone works right up until somebody sends the same sentence
1926
+ // twice, which is exactly what a person does when they think the first
1927
+ // one was lost.
1928
+ ...(messageId ? { messageId } : {}),
1715
1929
  ...(attachments?.length ? { attachments } : {}),
1716
1930
  });
1717
1931
  const originSeq = echoed.seq;
@@ -1726,7 +1940,7 @@ export class Supervisor {
1726
1940
  // own conflict tasks. Held here rather than delivered, so «на паузе»
1727
1941
  // means the same thing whichever door the words came through.
1728
1942
  this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
1729
- return;
1943
+ return 'accepted';
1730
1944
  }
1731
1945
  this.enqueueDelivery(running, async () => {
1732
1946
  const composed = await this.materializeAttachments(running, text, attachments);
@@ -1751,6 +1965,7 @@ export class Supervisor {
1751
1965
  return;
1752
1966
  this.deliverMessage(running, composed, [], originSeq);
1753
1967
  });
1968
+ return 'accepted';
1754
1969
  }
1755
1970
  /**
1756
1971
  * Queue a message that no agent can take yet, and say so in the feed
@@ -1993,7 +2208,30 @@ export class Supervisor {
1993
2208
  // them a follow-up line instead of the task would start a fresh
1994
2209
  // conversation with «продолжай».
1995
2210
  running.lastTurnPrompt = text;
1996
- running.session.send(text);
2211
+ /**
2212
+ * The process can refuse — and used to refuse in silence (#231 F8).
2213
+ *
2214
+ * `AsyncQueue.push` answers false once the queue has ended, which happens
2215
+ * when the agent's own iterator finished between the check above and this
2216
+ * line. The adapter wrote a line to its log and returned; from here the
2217
+ * message looked delivered, so `settle()` retired it from the queue and
2218
+ * the status went to RUNNING. On screen: the person's bubble, the word
2219
+ * «working», and an agent that had already stopped and would never answer.
2220
+ *
2221
+ * Now it goes back in the queue, exactly like a parked process below, and
2222
+ * the feed says why.
2223
+ */
2224
+ if (!running.session.send(text)) {
2225
+ log.warn('supervisor: the agent process refused the message — requeued', {
2226
+ sessionId: running.descriptor.id,
2227
+ });
2228
+ this.sendEvent(running, 'system_note', {
2229
+ code: 'agent_gone',
2230
+ text: 'The agent process ended before it could take this message. It is queued and will be delivered when the session continues.',
2231
+ });
2232
+ this.requeue(running, held, text, originSeq);
2233
+ return;
2234
+ }
1997
2235
  settle();
1998
2236
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
1999
2237
  return;
@@ -2599,6 +2837,8 @@ export class Supervisor {
2599
2837
  epoch: descriptor.epoch,
2600
2838
  openQuestions: new Set(),
2601
2839
  answeredAsks: new Set(),
2840
+ deliveredMessageIds: new Set(),
2841
+ backgroundTasks: 0,
2602
2842
  ...pausedUntilOf(descriptor),
2603
2843
  mode: descriptor.mode,
2604
2844
  ...(descriptor.model ? { model: descriptor.model } : {}),
@@ -2864,6 +3104,35 @@ export class Supervisor {
2864
3104
  ? { ok: false, error: 'Unknown session', result: { outcome } }
2865
3105
  : { ok: true, result: { outcome } });
2866
3106
  }
3107
+ case 'deliver_message': {
3108
+ // Ticket #299 — the same treatment as `answer_question` above, for
3109
+ // the door people use every minute. The `session_message` frame it
3110
+ // replaces is fire-and-forget, so the API counted a write into a
3111
+ // socket as a delivery; on 16.08.2026 a message written into a socket
3112
+ // that still read OPEN simply ceased to exist, with the queue empty
3113
+ // and the composer cleared.
3114
+ //
3115
+ // No `await` before the reply. `acceptUserMessage` is synchronous
3116
+ // precisely so this stays true: a yield between «is this a duplicate»
3117
+ // and «write it down» would let a redelivery overtake the original.
3118
+ const sessionId = frame.sessionId;
3119
+ if (!sessionId)
3120
+ return void reply({ ok: false, error: 'sessionId is required' });
3121
+ const parsed = DeliverMessageArgsSchema.safeParse(frame.args ?? {});
3122
+ if (!parsed.success) {
3123
+ return void reply({
3124
+ ok: false,
3125
+ error: parsed.error.issues[0]?.message ?? 'malformed message',
3126
+ });
3127
+ }
3128
+ const outcome = this.acceptUserMessage(sessionId, parsed.data.text, parsed.data.attachments, parsed.data.messageId);
3129
+ // `ok` is about the RELAY, like the answer above: `unknown_session` is
3130
+ // the one case the API can fix by asking again after it has re-sent
3131
+ // the descriptor, so it is the one case reported as a failure.
3132
+ return void reply(outcome === 'unknown_session'
3133
+ ? { ok: false, error: 'Unknown session', result: { outcome } }
3134
+ : { ok: true, result: { outcome } });
3135
+ }
2867
3136
  case 'recall_message': {
2868
3137
  // Ticket #125: take a queued message back before any agent sees it.
2869
3138
  const sessionId = frame.sessionId;
@@ -3868,6 +4137,11 @@ export class Supervisor {
3868
4137
  status,
3869
4138
  ...compact,
3870
4139
  ...(running ? { epoch: running.epoch } : {}),
4140
+ // Ticket #236: on EVERY status, not only the interesting ones. The API
4141
+ // stores the number, and a status frame that carries none leaves the last
4142
+ // one standing — which for a session whose subagents have just finished
4143
+ // would mean it goes on claiming background work forever.
4144
+ ...(running ? { backgroundTasks: running.backgroundTasks } : {}),
3871
4145
  });
3872
4146
  }
3873
4147
  /** 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.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.44.2';
2
+ export const RUNNER_VERSION = '0.45.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.44.2",
3
+ "version": "0.45.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",