@bridge4dev/runner 0.27.0 → 0.30.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.
@@ -16,6 +16,11 @@ import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailu
16
16
  import { selfUpdate } from './self-update.js';
17
17
  import { rememberWorkspacePath } from './environment.js';
18
18
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
19
+ import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
20
+ import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
21
+ /** Refusals shared by every checkpoint command (ticket #126). */
22
+ const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
23
+ const AGENT_BUSY = 'The agent is still working — stop the turn first';
19
24
  export class Supervisor {
20
25
  ws;
21
26
  opts;
@@ -37,6 +42,17 @@ export class Supervisor {
37
42
  authRelay = new AuthRelay();
38
43
  /** Serialises repo-mutating git commands per workspace repo (QA-99 MAJOR-1). */
39
44
  repoLocks = new Map();
45
+ /**
46
+ * Repo keys with work in flight right now (ticket #126).
47
+ *
48
+ * A checkpoint deliberately does NOT take the repo lock — it only reads the
49
+ * worktree, and queueing a user's message behind a 150-second push would be
50
+ * a plain regression. What it does instead is decline to run while a
51
+ * mutating command holds the repo: a snapshot taken halfway through an apply
52
+ * would be a state that never existed, and offering to restore it is worse
53
+ * than having no restore point for that one message.
54
+ */
55
+ repoLockDepth = new Map();
40
56
  /** An update is installing right now — a second one would fight it. */
41
57
  selfUpdateInFlight = false;
42
58
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
@@ -268,6 +284,13 @@ export class Supervisor {
268
284
  // asked for it) resumes on the next message instead of replaying its
269
285
  // original prompt.
270
286
  if (descriptor.status === 'STARTING') {
287
+ // Ticket #126: the point before the agent has touched anything. It is
288
+ // anchored to seq 0 — the synthetic opening bubble the dashboard puts in
289
+ // front of every feed — so "put it all back" is reachable from the very
290
+ // first thing on the page.
291
+ await this.captureCheckpoint(running, 'TURN', 0);
292
+ if (this.isStale(running))
293
+ return;
271
294
  this.launchAgent(running, composeInitialPrompt(descriptor), null);
272
295
  }
273
296
  else {
@@ -360,6 +383,14 @@ export class Supervisor {
360
383
  // Facts about this session only. The project's own documentation is read by
361
384
  // each agent itself — see `composeWorkspaceContext`.
362
385
  const workspaceContext = composeWorkspaceContext(descriptor);
386
+ const rewind = running.rewindAnchor;
387
+ delete running.rewindAnchor;
388
+ // A rewind resumes the conversation the POINT names, which is not always
389
+ // the one this session is on now: rewinding twice, or rewinding the first
390
+ // message after a rewind, both reach back into the thread the fork came
391
+ // from. Its transcript is still on disk — that is what makes the point
392
+ // usable at all.
393
+ const resumeTarget = rewind ? rewind.agentSession : resumeId;
363
394
  running.session = adapter.startSession({
364
395
  sessionId: descriptor.id,
365
396
  cwd: running.worktreePath,
@@ -372,7 +403,12 @@ export class Supervisor {
372
403
  mode: running.mode,
373
404
  ...(running.model ? { model: running.model } : {}),
374
405
  ...(running.effort ? { effort: running.effort } : {}),
375
- ...(resumeId ? { resumeProviderSessionId: resumeId } : {}),
406
+ ...(resumeTarget ? { resumeProviderSessionId: resumeTarget } : {}),
407
+ // Ticket #126: a conversation rewind takes effect exactly here, on the
408
+ // next process this session starts. Consumed rather than kept — a rewind
409
+ // is one event, not a standing setting, and re-applying it on a later
410
+ // relaunch would silently throw away everything said since.
411
+ ...(rewind ? { resumeAtAnchor: rewind.anchor } : {}),
376
412
  // Descriptor MCP (auto-issued per-workspace key) wins over config.toml.
377
413
  ...((descriptor.mcp ?? this.opts.mcp) ? { mcp: descriptor.mcp ?? this.opts.mcp } : {}),
378
414
  // The SDK budget is per-process; hand the RESIDUAL session budget down.
@@ -571,6 +607,16 @@ export class Supervisor {
571
607
  this.launchAgent(running, prompt, null);
572
608
  return;
573
609
  }
610
+ // The way back when the CLI refuses the rewind anchor: one relaunch WITHOUT
611
+ // it, keeping the conversation. Nothing was rewound, so nothing is cut.
612
+ if (running.rewindRetry && !running.stopRequested) {
613
+ const { prompt } = running.rewindRetry;
614
+ delete running.rewindRetry;
615
+ running.rewindRetryDone = true;
616
+ running.costBaseUsd = running.costUsd;
617
+ this.launchAgent(running, prompt, running.descriptor.providerSessionId);
618
+ return;
619
+ }
574
620
  // Auth recovery: one relaunch that KEEPS the provider session, so a
575
621
  // credential hiccup does not cost the agent its whole conversation.
576
622
  if (running.authRetry && !running.stopRequested) {
@@ -581,6 +627,54 @@ export class Supervisor {
581
627
  this.launchAgent(running, prompt, running.descriptor.providerSessionId);
582
628
  return;
583
629
  }
630
+ // The mode crossed the `full` boundary: a NEW process, resuming the same
631
+ // conversation (ticket #156). Placed with the other one-shot relaunches and
632
+ // after the cleanup above, which is the whole point of routing it here.
633
+ if (running.modeRelaunch && !running.stopRequested) {
634
+ const { priorStatus } = running.modeRelaunch;
635
+ delete running.modeRelaunch;
636
+ running.parkRequested = false;
637
+ running.session = null;
638
+ running.costBaseUsd = running.costUsd; // the next process starts from here
639
+ const mode = running.mode;
640
+ // «Your conversation is kept» is only true when there IS one to keep
641
+ // (QA-128): a session switched before its first turn has no provider
642
+ // session id, so the new process starts the conversation over — and
643
+ // saying otherwise is a promise the feed can be checked against.
644
+ const kept = Boolean(running.descriptor.providerSessionId);
645
+ this.sendEvent(running, 'notice', {
646
+ level: 'info',
647
+ text: (mode === 'full'
648
+ ? 'Switched to «Unrestricted». The agent was restarted — it no longer asks about anything. '
649
+ : 'Left «Unrestricted». The agent was restarted — permission checks are back on. ') +
650
+ (kept
651
+ ? 'Your conversation is kept; send a message to carry on.'
652
+ : 'This session had not started a conversation yet, so nothing was lost — send a message to begin.'),
653
+ });
654
+ this.sendEvent(running, 'settings', { mode });
655
+ // Empty prompt: the agent boots, reports its capabilities and waits, the
656
+ // same as a free CHAT session. It must NOT start a turn of its own here.
657
+ if (this.launchAgent(running, '', running.descriptor.providerSessionId)) {
658
+ // Anything typed during the park window is waiting on disk (see
659
+ // `deliverMessage`), and the new process is the one that can take it.
660
+ this.flushPendingMessages(running);
661
+ if (priorStatus === 'REVIEW') {
662
+ this.reportStatus(descriptor.id, 'REVIEW', {
663
+ costUsd: running.costUsd,
664
+ activeMs: running.activeMs,
665
+ });
666
+ }
667
+ return;
668
+ }
669
+ // The agent did not start — an exhausted budget is the only way here. The
670
+ // session stays parked and resumable rather than silently disappearing.
671
+ this.reportStatus(descriptor.id, statusForReport(running), {
672
+ costUsd: running.costUsd,
673
+ activeMs: running.activeMs,
674
+ });
675
+ this.drainSessionsWaitingForCapacity();
676
+ return;
677
+ }
584
678
  if (running.stopRequested) {
585
679
  // Report before removing from the map — reportStatus records
586
680
  // lastReported on the live entry, and the journal cleanup below
@@ -795,29 +889,48 @@ export class Supervisor {
795
889
  running.openQuestions.size === 0 &&
796
890
  Boolean(running.descriptor.providerSessionId));
797
891
  }
798
- park(running) {
892
+ /**
893
+ * Stop the agent process but keep the session resumable.
894
+ *
895
+ * `quiet` is for the callers that say it better themselves: a conversation
896
+ * rewind parks the session too, and announcing «the runner switched to
897
+ * another session» there is simply untrue — nothing switched, the user
898
+ * rewound (ticket #126).
899
+ */
900
+ park(running, options = {}) {
799
901
  if (!running.session)
800
902
  return;
903
+ // Ask the process where the conversation stands while it is still there to
904
+ // ask (ticket #126) — a restore point taken after this would otherwise
905
+ // have no way to rewind the agent's memory.
906
+ this.currentAnchor(running);
801
907
  running.parkRequested = true;
802
908
  // `code` is what the dashboard reads to tell «parked» from «your turn»
803
909
  // (#124). The prose stays for runners older than 0.23.0, which the
804
910
  // dashboard still matches on; delete that fallback once the fleet has
805
911
  // moved, not before.
806
- this.sendEvent(running, 'system_note', {
807
- code: 'session_parked',
808
- text: 'Session parked — the runner switched to another session. Send a message to resume.',
809
- });
912
+ if (!options.quiet) {
913
+ this.sendEvent(running, 'system_note', {
914
+ code: 'session_parked',
915
+ text: 'Session parked — the runner switched to another session. Send a message to resume.',
916
+ });
917
+ }
810
918
  running.session.stop('session_parked');
811
919
  }
812
920
  forwardEvent(running, event) {
813
921
  const { descriptor } = running;
814
922
  switch (event.type) {
815
- case 'provider_session':
923
+ case 'provider_session': {
816
924
  running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
817
925
  this.reportStatus(descriptor.id, statusForReport(running), {
818
926
  providerSessionId: event.providerSessionId,
819
927
  });
928
+ // The CLI resumed at the anchor and forked, so the cut announced when
929
+ // the rewind was asked for stands. Nothing to send — it is already in
930
+ // the feed; this only retires the promise to take it back.
931
+ delete running.pendingRewind;
820
932
  return;
933
+ }
821
934
  case 'cost':
822
935
  // event.costUsd is the live process's running total; the session
823
936
  // total also includes what earlier processes spent (park/restart).
@@ -829,7 +942,22 @@ export class Supervisor {
829
942
  });
830
943
  return;
831
944
  case 'turn_end': {
832
- this.sendEvent(running, 'turn_end', { ok: event.ok, errorMessage: event.errorMessage });
945
+ this.sendEvent(running, 'turn_end', {
946
+ ok: event.ok,
947
+ errorMessage: event.errorMessage,
948
+ ...(event.aborted ? { aborted: true } : {}),
949
+ });
950
+ // The session is already on its way out with a status that MEANS
951
+ // something — a spent budget, a Stop, a teardown. A turn ending inside
952
+ // that window is a consequence of it, and letting the line below
953
+ // overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
954
+ // WAITING_INPUT) replaces a true, actionable ending with a wrong one.
955
+ if (running.stopRequested || running.budgetSpent)
956
+ return;
957
+ // A finished turn is the last instant this conversation is BOTH
958
+ // complete and readable: the process can be parked or lose its slot at
959
+ // any point after it, and `conversationAnchor()` needs a live one.
960
+ this.currentAnchor(running);
833
961
  // Turn end is the natural checkpoint for the budget clock: the slice
834
962
  // just closed, so this is the moment the API can persist it. Without a
835
963
  // report here `agentActiveMs` stayed 0 and every restart or resume
@@ -854,6 +982,31 @@ export class Supervisor {
854
982
  // A provider session the CLI no longer knows (state wiped, expired
855
983
  // history): don't fail the session — relaunch it fresh once with the
856
984
  // same prompt, the git worktree still holds all the work.
985
+ // The CLI does not know the point we asked it to resume at. The
986
+ // conversation is intact — only the rewind did not happen — so the
987
+ // session must survive, the feed must NOT be cut (nothing was rewound),
988
+ // and the person must be told plainly rather than left believing the
989
+ // agent has forgotten something it still remembers.
990
+ if (event.code === 'rewind_failed') {
991
+ // Put the conversation back on the page: the agent still has every
992
+ // word of it, and a transcript that shows less than the agent knows
993
+ // is the one outcome this feature must never produce.
994
+ const cutToUndo = running.pendingRewind;
995
+ delete running.pendingRewind;
996
+ if (cutToUndo) {
997
+ this.sendEvent(running, 'session_rewind_undone', {
998
+ rewoundSeq: cutToUndo.rewoundSeq,
999
+ });
1000
+ }
1001
+ if (!event.recovered && !running.rewindRetryDone) {
1002
+ running.rewindRetry = { prompt: running.lastPrompt };
1003
+ this.sendEvent(running, 'notice', {
1004
+ level: 'warn',
1005
+ text: 'The conversation could not be rewound to that point — the agent still remembers everything after it. Its files were not affected.',
1006
+ });
1007
+ }
1008
+ return;
1009
+ }
857
1010
  if (event.code === 'resume_failed' && !running.freshRetryDone) {
858
1011
  running.freshRetry = { prompt: running.lastPrompt };
859
1012
  this.sendEvent(running, 'notice', {
@@ -1126,16 +1279,19 @@ export class Supervisor {
1126
1279
  }
1127
1280
  // The feed shows what the USER wrote plus the files they picked — not the
1128
1281
  // composed prompt with workspace paths, which is an implementation detail.
1129
- this.sendEvent(running, 'message', {
1282
+ // Its seq is the name this message answers to for the rest of its life
1283
+ // (ticket #125): recall, checkpoint anchor and rewind all quote it.
1284
+ const echoed = this.sendEvent(running, 'message', {
1130
1285
  role: 'user',
1131
1286
  text,
1132
1287
  ...(attachments?.length ? { attachments } : {}),
1133
1288
  });
1289
+ const originSeq = echoed.seq;
1134
1290
  if (!running.worktreePath) {
1135
1291
  // Session is still being prepared — deliver after launch (QA-96 F3).
1136
1292
  // Attachments travel as metadata and are downloaded at delivery time,
1137
1293
  // which is the first moment the worktree is guaranteed to exist.
1138
- running.pendingMessages.push(running.journal.appendPending(text, attachments));
1294
+ this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
1139
1295
  return;
1140
1296
  }
1141
1297
  this.enqueueDelivery(running, async () => {
@@ -1143,15 +1299,40 @@ export class Supervisor {
1143
1299
  if (this.isStale(running)) {
1144
1300
  // The session ended while the files downloaded. Park the message on
1145
1301
  // disk rather than dropping it — «Продолжить» carries it to the agent.
1146
- running.journal.appendPending(text, attachments);
1302
+ // Announced like every other queueing decision: a bubble that is
1303
+ // waiting must say so, whichever door it came through.
1304
+ const parked = running.journal.appendPending(text, attachments, originSeq);
1305
+ if (parked.seq !== undefined) {
1306
+ this.sendEvent(running, 'message_queued', { targetSeq: parked.seq });
1307
+ }
1147
1308
  log.warn('supervisor: session ended before the message could be delivered', {
1148
1309
  sessionId: running.descriptor.id,
1149
1310
  });
1150
1311
  return;
1151
1312
  }
1152
- this.deliverMessage(running, composed);
1313
+ // Ticket #126: the restore point belongs in FRONT of this message, and
1314
+ // this is the last moment the tree is still as the user left it.
1315
+ await this.captureCheckpoint(running, 'TURN', originSeq);
1316
+ if (this.isStale(running))
1317
+ return;
1318
+ this.deliverMessage(running, composed, [], originSeq);
1153
1319
  });
1154
1320
  }
1321
+ /**
1322
+ * Queue a message that no agent can take yet, and say so in the feed
1323
+ * (ticket #125).
1324
+ *
1325
+ * Every queueing decision goes through here so the browser's idea of "this
1326
+ * one can still be taken back" cannot drift from the runner's. Records with
1327
+ * no seq — orphans, and anything written by a runner older than 0.28.0 —
1328
+ * stay silent: they have no name the browser could quote back.
1329
+ */
1330
+ queueMessage(running, record) {
1331
+ running.pendingMessages.push(record);
1332
+ if (record.seq !== undefined) {
1333
+ this.sendEvent(running, 'message_queued', { targetSeq: record.seq });
1334
+ }
1335
+ }
1155
1336
  /**
1156
1337
  * Run delivery work for one session, strictly after whatever is already
1157
1338
  * queued for it. Order is the whole point: two messages typed seconds apart
@@ -1211,7 +1392,7 @@ export class Supervisor {
1211
1392
  * resolves them and a refusal puts exactly those records back in the queue —
1212
1393
  * the message is retired from disk only once an agent has it.
1213
1394
  */
1214
- deliverMessage(running, text, held = []) {
1395
+ deliverMessage(running, text, held = [], originSeq) {
1215
1396
  // Older instructions are already waiting: send them together, in order.
1216
1397
  // Without this a message that CAN go now jumps the queue — and the ones it
1217
1398
  // jumped are stranded, because the drain only runs when a process exits
@@ -1223,20 +1404,40 @@ export class Supervisor {
1223
1404
  // would deliver the words while silently dropping the screenshot
1224
1405
  // (QA-104 MAJOR-4). `text` is already composed by the caller, so it
1225
1406
  // needs no attachments of its own.
1226
- running.pendingMessages.push(running.journal.appendPending(text));
1407
+ this.queueMessage(running, running.journal.appendPending(text, undefined, originSeq));
1227
1408
  this.flushPendingMessages(running);
1228
1409
  return;
1229
1410
  }
1230
1411
  const settle = () => {
1231
- for (const record of held)
1412
+ const delivered = [];
1413
+ for (const record of held) {
1232
1414
  running.journal.resolvePending(record.id);
1415
+ if (record.seq !== undefined)
1416
+ delivered.push(record.seq);
1417
+ }
1418
+ // Ticket #125: the queue is now empty of these, so the browser must stop
1419
+ // offering to take them back. Only for messages that were ever announced
1420
+ // as queued — the ordinary path never queues and needs no frame.
1421
+ if (delivered.length > 0) {
1422
+ this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
1423
+ }
1233
1424
  };
1234
- if (running.session) {
1425
+ if (running.session && !running.parkRequested) {
1235
1426
  running.session.send(text);
1236
1427
  settle();
1237
1428
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
1238
1429
  return;
1239
1430
  }
1431
+ // The process is on its way out and `running.session` has not been cleared
1432
+ // yet — parking only ASKS it to stop (QA-128). Handing the text to a dying
1433
+ // process and then reporting it delivered is the one outcome worse than
1434
+ // making the user wait: the words are gone and the interface says they
1435
+ // arrived. This window is short but it is exactly when somebody types,
1436
+ // because they have just changed the mode.
1437
+ if (running.parkRequested) {
1438
+ this.requeue(running, held, text, originSeq);
1439
+ return;
1440
+ }
1240
1441
  // Parked session: the follow-up message becomes the resume prompt.
1241
1442
  if (!this.ensureCapacity(running.descriptor.id)) {
1242
1443
  this.sendEvent(running, 'system_note', {
@@ -1250,7 +1451,7 @@ export class Supervisor {
1250
1451
  // carries it once a slot frees up, instead of the user's instruction
1251
1452
  // vanishing into a system note. On disk, too — the wait can outlive the
1252
1453
  // daemon (session 9).
1253
- running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
1454
+ this.requeue(running, held, text, originSeq);
1254
1455
  return;
1255
1456
  }
1256
1457
  // A person typing into the session is the clearest signal that the work is
@@ -1263,7 +1464,105 @@ export class Supervisor {
1263
1464
  // The agent did not start (an exhausted budget is the only way here). The
1264
1465
  // instruction stays on disk, so «Продолжить» — which is what raises the
1265
1466
  // budget — carries it to the agent instead of dropping it.
1266
- running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
1467
+ this.requeue(running, held, text, originSeq);
1468
+ }
1469
+ /**
1470
+ * Nothing could take the message: put it back where it came from.
1471
+ *
1472
+ * Records that were already held keep their identity (and their queued
1473
+ * announcement); a first-time refusal mints one and announces it.
1474
+ */
1475
+ requeue(running, held, text, originSeq) {
1476
+ if (held.length > 0) {
1477
+ // Already announced as queued when they first went in — re-announcing
1478
+ // would be a second frame saying what the feed already knows.
1479
+ running.pendingMessages.push(...held);
1480
+ return;
1481
+ }
1482
+ this.queueMessage(running, running.journal.appendPending(text, undefined, originSeq));
1483
+ }
1484
+ /**
1485
+ * Where this session's conversation stands, live process or not (ticket #126).
1486
+ *
1487
+ * Reads the running agent when there is one and remembers what it said;
1488
+ * falls back to that memory when there is not. The provider session id is
1489
+ * checked on the way out rather than on the way in: an anchor minted before
1490
+ * a fork is not wrong, it simply belongs to a conversation this session no
1491
+ * longer has.
1492
+ */
1493
+ currentAnchor(running) {
1494
+ const providerSessionId = running.descriptor.providerSessionId;
1495
+ const live = providerSessionId ? (running.session?.conversationAnchor() ?? null) : null;
1496
+ if (live && providerSessionId) {
1497
+ running.journal.recordAnchor(live, providerSessionId);
1498
+ return { anchor: live, agentSession: providerSessionId };
1499
+ }
1500
+ // No live process to ask — and that is the COMMON case at the moment a
1501
+ // restore point is taken: a parked session, a runner that just
1502
+ // reconnected, the message right after a rewind. The journal keeps the
1503
+ // tip, together with the conversation it belongs to.
1504
+ const remembered = running.journal.lastAnchor;
1505
+ return remembered
1506
+ ? { anchor: remembered.anchor, agentSession: remembered.providerSessionId }
1507
+ : null;
1508
+ }
1509
+ /**
1510
+ * Take a restore point in front of the work that is about to start
1511
+ * (ticket #126).
1512
+ *
1513
+ * Called from the delivery chain — which is already asynchronous because of
1514
+ * attachments — rather than from `deliverMessage`, which is synchronous and
1515
+ * has five exits. Never throws and never blocks delivery: a message must
1516
+ * reach the agent whether or not a restore point could be taken.
1517
+ *
1518
+ * Three reasons it declines, and each of them is a state in which a snapshot
1519
+ * would be a lie rather than a restore point:
1520
+ * - the machine's owner switched checkpoints off;
1521
+ * - the agent is mid-turn, so the tree is being written to as we read it;
1522
+ * - a repo-mutating command holds the repository.
1523
+ */
1524
+ async captureCheckpoint(running, kind, messageSeq) {
1525
+ const worktreePath = running.worktreePath;
1526
+ if (!worktreePath || this.opts.checkpointsEnabled === false)
1527
+ return;
1528
+ if (kind === 'TURN' && this.isWorktreeBusy(worktreePath))
1529
+ return;
1530
+ if (await this.isRepoLocked(worktreePath))
1531
+ return;
1532
+ // The anchor and the conversation it names travel TOGETHER: a point
1533
+ // recorded against an older provider session stays usable, because the
1534
+ // rewind resumes the session the point names rather than whichever one the
1535
+ // session happens to be on now.
1536
+ const anchor = this.currentAnchor(running);
1537
+ const result = await createCheckpoint({
1538
+ worktreePath,
1539
+ sessionId: running.descriptor.id,
1540
+ kind,
1541
+ ...(messageSeq === undefined ? {} : { messageSeq }),
1542
+ ...(anchor ? { agentAnchor: anchor.anchor, agentSession: anchor.agentSession } : {}),
1543
+ });
1544
+ if (this.isStale(running))
1545
+ return;
1546
+ if (!result.created) {
1547
+ if (result.reason === 'too-large') {
1548
+ // Loud, because the alternative is a session that quietly has no way
1549
+ // back and a button that quietly is not there.
1550
+ this.sendEvent(running, 'notice', {
1551
+ level: 'warn',
1552
+ text: 'This working tree is too large to take a restore point — rewind is unavailable for this step. Check that build output is in .gitignore.',
1553
+ });
1554
+ }
1555
+ return;
1556
+ }
1557
+ this.sendEvent(running, 'checkpoint', {
1558
+ ordinal: result.record.ordinal,
1559
+ kind: result.record.kind,
1560
+ createdAt: result.record.createdAt,
1561
+ fileCount: result.record.fileCount,
1562
+ canRewindContext: Boolean(result.record.agentAnchor),
1563
+ ...(messageSeq === undefined ? {} : { messageSeq }),
1564
+ ...(result.skippedFiles.length ? { skippedFiles: result.skippedFiles.slice(0, 20) } : {}),
1565
+ });
1267
1566
  }
1268
1567
  /**
1269
1568
  * Deliver messages that raced session start (already journaled).
@@ -1299,6 +1598,13 @@ export class Supervisor {
1299
1598
  });
1300
1599
  return;
1301
1600
  }
1601
+ // Anchored to the FIRST of the joined messages: that bubble is the one a
1602
+ // person means by "put it back to before I said this".
1603
+ await this.captureCheckpoint(running, 'TURN', pending[0]?.seq);
1604
+ if (this.isStale(running)) {
1605
+ running.pendingMessages.unshift(...pending);
1606
+ return;
1607
+ }
1302
1608
  this.deliverMessage(running, parts.join('\n\n'), pending);
1303
1609
  });
1304
1610
  }
@@ -1326,6 +1632,22 @@ export class Supervisor {
1326
1632
  const running = this.sessions.get(sessionId);
1327
1633
  if (!running)
1328
1634
  return;
1635
+ // Validated HERE, before anything is remembered (QA-128). `running.mode` is
1636
+ // what a parked session launches with and what the API is told the session
1637
+ // is in, so storing a mode this workspace forbids made a parked session
1638
+ // report itself as «Unrestricted» and made the adapter refuse it again on
1639
+ // every single relaunch — a red line in the feed with no user action behind
1640
+ // it. The adapters keep their own guard; this one stops the value from ever
1641
+ // being written down.
1642
+ if (mode && !availableModes(running.descriptor.workspace.trustMode).includes(mode)) {
1643
+ this.sendEvent(running, 'notice', { level: 'warn', text: MODE_REFUSED_TEXT });
1644
+ this.sendEvent(running, 'settings', { mode: running.mode });
1645
+ mode = undefined;
1646
+ if (model === undefined && effort === undefined)
1647
+ return;
1648
+ }
1649
+ // Kept so a refused switch can put the picker back where it was.
1650
+ const previousMode = running.mode;
1329
1651
  // Remember first: a parked session applies them on its next launch.
1330
1652
  if (model)
1331
1653
  running.model = model;
@@ -1339,6 +1661,67 @@ export class Supervisor {
1339
1661
  this.sendEvent(running, 'settings', { model, mode, effort });
1340
1662
  return;
1341
1663
  }
1664
+ // «Unrestricted» is the one mode the agent cannot be talked into on a
1665
+ // process that was not launched for it (ticket #156). The CLI refuses the
1666
+ // control request outright — `Cannot set permission mode to
1667
+ // bypassPermissions because the session was not launched with
1668
+ // --dangerously-skip-permissions` — and until this branch existed that
1669
+ // refusal became a `notice` in the catch below, leaving the picker reading
1670
+ // «Unrestricted» over a session that went on asking.
1671
+ const needsRelaunch = Boolean(mode && running.session.modeSwitchNeedsRelaunch(mode));
1672
+ if (needsRelaunch && this.isMidTurn(running)) {
1673
+ // A new process would take the turn — and the open card, and the parked
1674
+ // question — down with it. The dashboard locks the picker while the agent
1675
+ // RUNS, but a session sitting on a permission card is WAITING_PERMISSION
1676
+ // and the picker is live there by design (that is the very moment somebody
1677
+ // reaches for it), so this guard is reachable through the interface and
1678
+ // not only through a direct API call (QA-128).
1679
+ this.sendEvent(running, 'notice', {
1680
+ level: 'warn',
1681
+ // Both directions: getting OUT of «Unrestricted» needs a new process
1682
+ // just as much as getting in, and a sentence written for one of them
1683
+ // reads as nonsense during the other.
1684
+ text: (mode === 'full'
1685
+ ? 'Switching to «Unrestricted» starts a new agent process, so it can only be done between turns. '
1686
+ : 'Leaving «Unrestricted» starts a new agent process, so it can only be done between turns. ') +
1687
+ (running.openQuestions.size > 0 || running.lastReported === 'WAITING_PERMISSION'
1688
+ ? 'Answer what the agent is asking first, or press Stop.'
1689
+ : 'Stop the turn, or wait for it to finish.'),
1690
+ });
1691
+ running.mode = previousMode;
1692
+ this.sendEvent(running, 'settings', { mode: previousMode });
1693
+ // The rest of the request still stands — refusing the mode is no reason
1694
+ // to drop a model or effort change that travelled with it (QA-128).
1695
+ await this.applyLiveSettings(running, model, undefined, effort);
1696
+ return;
1697
+ }
1698
+ if (needsRelaunch && mode) {
1699
+ await this.applyLiveSettings(running, model, undefined, effort);
1700
+ // Handed to `pumpEvents` rather than done here, exactly like the auth and
1701
+ // stale-resume recoveries above it (QA-128). `park()` only ASKS the
1702
+ // process to stop; the cleanup that follows — settling the active clock,
1703
+ // clearing the budget timers, moving the cost baseline so the next
1704
+ // process does not re-count what this one spent — happens when the event
1705
+ // stream ends. Relaunching inline wins the race against all of it: the
1706
+ // new session lands in `running.session` first, and the old pump then
1707
+ // sees `running.session !== session` and returns without cleaning up.
1708
+ running.modeRelaunch = { priorStatus: running.lastReported };
1709
+ this.park(running, { quiet: true });
1710
+ return;
1711
+ }
1712
+ await this.applyLiveSettings(running, model, mode, effort);
1713
+ }
1714
+ /** Is a turn (or a question the agent is parked on) in flight right now? */
1715
+ isMidTurn(running) {
1716
+ return (running.lastReported === 'RUNNING' ||
1717
+ running.lastReported === 'STARTING' ||
1718
+ running.lastReported === 'WAITING_PERMISSION' ||
1719
+ running.openQuestions.size > 0);
1720
+ }
1721
+ /** The three live setters, in the order that lets an explicit pick win. */
1722
+ async applyLiveSettings(running, model, mode, effort) {
1723
+ if (!running.session)
1724
+ return;
1342
1725
  try {
1343
1726
  // Model first: switching models can invalidate the picked effort, and
1344
1727
  // the adapter drops it in that case — applying effort after lets an
@@ -1381,6 +1764,19 @@ export class Supervisor {
1381
1764
  // dashboard while this runner was offline) must not keep an agent process
1382
1765
  // running and holding the single runner slot (QA-99 MAJOR-2).
1383
1766
  const known = new Set(descriptors.map((d) => d.id));
1767
+ // Ticket #126: this list is the only moment the runner learns which
1768
+ // sessions still exist. Deleting a session while its dev server is
1769
+ // switched off leaves restore points — a full copy of a working tree —
1770
+ // with no row anywhere pointing at them, and nothing else on this machine
1771
+ // would ever collect them.
1772
+ void pruneCheckpoints({ liveSessionIds: known }).then((result) => {
1773
+ if (result.droppedRefs > 0) {
1774
+ log.info('supervisor: collected orphaned restore points', {
1775
+ sessions: result.droppedSessions.length,
1776
+ refs: result.droppedRefs,
1777
+ });
1778
+ }
1779
+ }, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }));
1384
1780
  for (const [sessionId, running] of [...this.sessions]) {
1385
1781
  if (known.has(sessionId))
1386
1782
  continue;
@@ -1657,6 +2053,11 @@ export class Supervisor {
1657
2053
  });
1658
2054
  this.journals.closeAndDelete(sessionId);
1659
2055
  this.orphanMessages.delete(sessionId);
2056
+ // Ticket #126: the restore points are copies of this session's
2057
+ // working tree. The session is gone from the API, so they are the
2058
+ // last thing on this machine that still holds its content.
2059
+ if (workspacePath)
2060
+ await dropCheckpoints(workspacePath, sessionId);
1660
2061
  return void reply({ ok: true, result: { removed: true, ...result } });
1661
2062
  }
1662
2063
  case 'reset_workspace':
@@ -1664,6 +2065,245 @@ export class Supervisor {
1664
2065
  ok: false,
1665
2066
  error: 'reset_workspace is not supported by this runner version',
1666
2067
  });
2068
+ case 'recall_message': {
2069
+ // Ticket #125: take a queued message back before any agent sees it.
2070
+ const sessionId = frame.sessionId;
2071
+ if (!sessionId)
2072
+ return void reply({ ok: false, error: 'sessionId is required' });
2073
+ const running = this.sessions.get(sessionId);
2074
+ if (!running)
2075
+ return void reply({ ok: false, error: 'Unknown session' });
2076
+ const targetSeq = num(frame.args?.['targetSeq']);
2077
+ if (targetSeq === null)
2078
+ return void reply({ ok: false, error: 'targetSeq is required' });
2079
+ // NOT A SINGLE `await` FROM HERE TO THE REPLY. Frames are handled
2080
+ // concurrently (`void this.onFrame(frame)`, gotcha #68), so a yield
2081
+ // in this window would let the delivery chain hand the agent the
2082
+ // very message we are removing — and both sides would report success.
2083
+ const record = running.journal.findPendingBySeq(targetSeq);
2084
+ const index = record
2085
+ ? running.pendingMessages.findIndex((held) => held.id === record.id)
2086
+ : -1;
2087
+ if (!record || index < 0) {
2088
+ // Never queued, or a flush already took it out of the queue and is
2089
+ // downloading its files. Both read the same from where the user
2090
+ // stands: the agent has it. Answering `not_found` here would be a
2091
+ // lie about a session we know, and answering success would be a
2092
+ // promise we cannot keep — this is the only safe direction.
2093
+ return void reply({
2094
+ ok: true,
2095
+ result: { recalled: false, reason: 'already_delivered' },
2096
+ });
2097
+ }
2098
+ running.pendingMessages.splice(index, 1);
2099
+ running.journal.cancelPending(record.id);
2100
+ this.sendEvent(running, 'message_recalled', { targetSeq });
2101
+ return void reply({ ok: true, result: { recalled: true } });
2102
+ }
2103
+ // ─── Ticket #126: restore points ────────────────────────────────
2104
+ case 'session_checkpoint': {
2105
+ const running = this.requireSession(frame.sessionId);
2106
+ if (typeof running === 'string')
2107
+ return void reply({ ok: false, error: running });
2108
+ if (this.opts.checkpointsEnabled === false) {
2109
+ return void reply({ ok: false, error: CHECKPOINTS_OFF });
2110
+ }
2111
+ if (!running.worktreePath) {
2112
+ return void reply({ ok: false, error: 'The session has no working folder yet' });
2113
+ }
2114
+ if (this.isWorktreeBusy(running.worktreePath)) {
2115
+ return void reply({ ok: false, error: AGENT_BUSY });
2116
+ }
2117
+ const before = (await listCheckpoints(running.worktreePath, running.descriptor.id))
2118
+ .length;
2119
+ await this.captureCheckpoint(running, 'MANUAL', num(frame.args?.['messageSeq']) ?? undefined);
2120
+ const after = await listCheckpoints(running.worktreePath, running.descriptor.id);
2121
+ if (after.length === before) {
2122
+ return void reply({ ok: false, error: 'A restore point could not be taken right now' });
2123
+ }
2124
+ return void reply({ ok: true, result: { ordinal: after.at(-1)?.ordinal } });
2125
+ }
2126
+ case 'session_checkpoints': {
2127
+ const running = this.requireSession(frame.sessionId);
2128
+ if (typeof running === 'string')
2129
+ return void reply({ ok: false, error: running });
2130
+ if (!running.worktreePath)
2131
+ return void reply({ ok: true, result: { items: [] } });
2132
+ const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
2133
+ return void reply({
2134
+ ok: true,
2135
+ result: {
2136
+ items: items.map((item) => ({
2137
+ ordinal: item.ordinal,
2138
+ kind: item.kind,
2139
+ createdAt: item.createdAt,
2140
+ fileCount: item.fileCount,
2141
+ // An anchor is enough: it names its own conversation, and that
2142
+ // conversation is still on disk.
2143
+ canRewindContext: Boolean(item.agentAnchor),
2144
+ ...(item.messageSeq === undefined ? {} : { messageSeq: item.messageSeq }),
2145
+ })),
2146
+ },
2147
+ });
2148
+ }
2149
+ case 'session_rewind_preview': {
2150
+ const running = this.requireSession(frame.sessionId);
2151
+ if (typeof running === 'string')
2152
+ return void reply({ ok: false, error: running });
2153
+ const ordinal = num(frame.args?.['ordinal']);
2154
+ if (ordinal === null || !running.worktreePath) {
2155
+ return void reply({ ok: false, error: 'ordinal is required' });
2156
+ }
2157
+ const preview = await previewRewind({
2158
+ worktreePath: running.worktreePath,
2159
+ sessionId: running.descriptor.id,
2160
+ ordinal,
2161
+ });
2162
+ return void reply({ ok: true, result: preview });
2163
+ }
2164
+ case 'session_rewind_files': {
2165
+ const running = this.requireSession(frame.sessionId);
2166
+ if (typeof running === 'string')
2167
+ return void reply({ ok: false, error: running });
2168
+ if (this.opts.checkpointsEnabled === false) {
2169
+ return void reply({ ok: false, error: CHECKPOINTS_OFF });
2170
+ }
2171
+ const ordinal = num(frame.args?.['ordinal']);
2172
+ if (ordinal === null || !running.worktreePath) {
2173
+ return void reply({ ok: false, error: 'ordinal is required' });
2174
+ }
2175
+ if (this.isWorktreeBusy(running.worktreePath)) {
2176
+ return void reply({ ok: false, error: AGENT_BUSY });
2177
+ }
2178
+ const confirmRaw = frame.args?.['confirmDeletes'];
2179
+ const confirmDeletes = Array.isArray(confirmRaw)
2180
+ ? confirmRaw.filter((p) => typeof p === 'string')
2181
+ : [];
2182
+ const expectedTreeOid = frame.args?.['expectedTreeOid'];
2183
+ // The repo lock, unlike the read-only checkpoint, IS taken here: this
2184
+ // writes the working tree, exactly like commit, apply and revert.
2185
+ const result = await this.withRepoLockFor(running.worktreePath, () => applyRewind({
2186
+ worktreePath: running.worktreePath,
2187
+ sessionId: running.descriptor.id,
2188
+ ordinal,
2189
+ confirmDeletes,
2190
+ ...(typeof expectedTreeOid === 'string' && expectedTreeOid
2191
+ ? { expectedTreeOid }
2192
+ : {}),
2193
+ }));
2194
+ this.sendEvent(running, 'checkpoint', {
2195
+ ordinal: result.safety.ordinal,
2196
+ kind: result.safety.kind,
2197
+ createdAt: result.safety.createdAt,
2198
+ fileCount: result.safety.fileCount,
2199
+ canRewindContext: Boolean(result.safety.agentAnchor),
2200
+ // This frame IS the browser's offer of «Undo rewind» — the point it
2201
+ // names belongs to no message, so there is nowhere else the offer
2202
+ // could come from. Withheld when the rewind was itself an undo:
2203
+ // going back again is a redo, and a button that quietly changes
2204
+ // meaning after one press is worse than no button.
2205
+ undoable: result.rewoundToKind !== 'SAFETY',
2206
+ });
2207
+ this.sendEvent(running, 'system_note', {
2208
+ code: 'files_rewound',
2209
+ text: `Working tree put back to an earlier point: ${result.restored} file(s) restored, ${result.recreated} brought back, ${result.deleted} removed. This is undoable — the state you just left was saved first.`,
2210
+ });
2211
+ return void reply({ ok: true, result });
2212
+ }
2213
+ case 'context_rewind': {
2214
+ const running = this.requireSession(frame.sessionId);
2215
+ if (typeof running === 'string')
2216
+ return void reply({ ok: false, error: running });
2217
+ const ordinal = num(frame.args?.['ordinal']);
2218
+ if (ordinal === null || !running.worktreePath) {
2219
+ return void reply({ ok: false, error: 'ordinal is required' });
2220
+ }
2221
+ if (this.isWorktreeBusy(running.worktreePath)) {
2222
+ return void reply({ ok: false, error: AGENT_BUSY });
2223
+ }
2224
+ const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
2225
+ const point = items.find((item) => item.ordinal === ordinal);
2226
+ // No equality check against the CURRENT provider session any more.
2227
+ // The point names the conversation its anchor lives in, and that
2228
+ // conversation is still on disk — which is what makes rewinding
2229
+ // twice, or rewinding the first message after a rewind, work at all.
2230
+ // Requiring the ids to match refused both, on the grounds that the
2231
+ // point «predates a conversation rewind» — true, and beside the
2232
+ // point.
2233
+ const agentSession = point?.agentSession ?? running.descriptor.providerSessionId;
2234
+ if (!point?.agentAnchor || !agentSession) {
2235
+ return void reply({
2236
+ ok: false,
2237
+ error: 'The agent had not answered yet at this point, so there is no conversation to rewind to. Its files can still be restored.',
2238
+ });
2239
+ }
2240
+ // What an agent remembers is fixed when its process starts, so the
2241
+ // rewind is applied by replacing that process. Park the live one and
2242
+ // bring it straight back — the CLI's own rewind happens when you
2243
+ // press it, and so does this one. Waiting for the user's next message
2244
+ // would leave them staring at a page where nothing had changed.
2245
+ running.rewindAnchor = { anchor: point.agentAnchor, agentSession };
2246
+ // Parked, not relaunched. A launch with no prompt does not resume
2247
+ // the conversation at all — the CLI reads the anchor only when the
2248
+ // first input arrives — so spawning a process here would idle in a
2249
+ // runner slot and change nothing. The anchor is consumed by the
2250
+ // launch the user's next message triggers, which is the same moment
2251
+ // the CLI validates it either way.
2252
+ if (running.session)
2253
+ this.park(running, { quiet: true });
2254
+ // The conversation tip is now THIS point — that is what a rewind
2255
+ // means — so the next restore point must record it, not the message
2256
+ // the user just rewound away from (which `park()` above would
2257
+ // otherwise have left behind, QA-120 M3). Writing it here is also
2258
+ // what lets the very next message be rewound in turn: it inherits a
2259
+ // tip that names a conversation still on disk.
2260
+ running.journal.recordAnchor(point.agentAnchor, agentSession);
2261
+ // The cut is announced NOW, and taken back if the CLI turns out not
2262
+ // to know the anchor.
2263
+ //
2264
+ // Waiting for confirmation was tried and is not possible: a launch
2265
+ // with no prompt does not resume at all until the first input, so
2266
+ // `system:init` — and with it any proof the fork happened — never
2267
+ // arrives on its own (measured: 20s, no init). Holding the cut until
2268
+ // then meant the page did not change when the button was pressed,
2269
+ // and the late cut then swallowed the message the user had sent in
2270
+ // the meantime.
2271
+ //
2272
+ // So the feed is cut optimistically and CORRECTED if the resume is
2273
+ // refused — an amendment, exactly like every other removal here.
2274
+ if (point?.messageSeq !== undefined) {
2275
+ const cut = this.sendEvent(running, 'session_rewound', {
2276
+ targetSeq: point.messageSeq,
2277
+ });
2278
+ running.pendingRewind = { rewoundSeq: cut.seq };
2279
+ }
2280
+ this.sendEvent(running, 'system_note', {
2281
+ code: 'context_rewound',
2282
+ text: (point?.messageSeq ?? 0) > 0
2283
+ ? 'Rewound to an earlier point — the agent no longer remembers anything after it, and your message is back in the composer.'
2284
+ : 'Rewound to an earlier point — the agent no longer remembers anything after it.',
2285
+ });
2286
+ return void reply({ ok: true, result: { rewound: true } });
2287
+ }
2288
+ case 'compact_context': {
2289
+ const running = this.requireSession(frame.sessionId);
2290
+ if (typeof running === 'string')
2291
+ return void reply({ ok: false, error: running });
2292
+ if (!running.session) {
2293
+ return void reply({
2294
+ ok: false,
2295
+ error: 'The agent is not running — send a message first, then compact',
2296
+ });
2297
+ }
2298
+ const done = await running.session.compact();
2299
+ if (!done) {
2300
+ return void reply({
2301
+ ok: false,
2302
+ error: 'The agent could not compact right now — wait for the current turn to finish',
2303
+ });
2304
+ }
2305
+ return void reply({ ok: true, result: { started: true } });
2306
+ }
1667
2307
  case 'git_status': {
1668
2308
  const paths = gitCommandPaths(frame.args);
1669
2309
  if (!paths)
@@ -2265,15 +2905,30 @@ export class Supervisor {
2265
2905
  */
2266
2906
  withRepoLock(repoKey, fn) {
2267
2907
  const previous = this.repoLocks.get(repoKey) ?? Promise.resolve();
2908
+ this.repoLockDepth.set(repoKey, (this.repoLockDepth.get(repoKey) ?? 0) + 1);
2268
2909
  const run = previous.catch(() => undefined).then(fn);
2269
2910
  const tail = run.catch(() => undefined);
2270
2911
  this.repoLocks.set(repoKey, tail);
2271
2912
  void tail.then(() => {
2913
+ const depth = (this.repoLockDepth.get(repoKey) ?? 1) - 1;
2914
+ if (depth <= 0)
2915
+ this.repoLockDepth.delete(repoKey);
2916
+ else
2917
+ this.repoLockDepth.set(repoKey, depth);
2272
2918
  if (this.repoLocks.get(repoKey) === tail)
2273
2919
  this.repoLocks.delete(repoKey);
2274
2920
  });
2275
2921
  return run;
2276
2922
  }
2923
+ /** Is a repo-mutating command queued or running for this path right now? */
2924
+ async isRepoLocked(pathInsideRepo) {
2925
+ try {
2926
+ return (this.repoLockDepth.get(await repoKeyFor(pathInsideRepo)) ?? 0) > 0;
2927
+ }
2928
+ catch {
2929
+ return false;
2930
+ }
2931
+ }
2277
2932
  /**
2278
2933
  * Same lock, keyed by the shared repository rather than by whichever path the
2279
2934
  * caller happened to have. A worktree commit and a workspace squash-merge
@@ -2282,6 +2937,17 @@ export class Supervisor {
2282
2937
  async withRepoLockFor(pathInsideRepo, fn) {
2283
2938
  return this.withRepoLock(await repoKeyFor(pathInsideRepo), fn);
2284
2939
  }
2940
+ /**
2941
+ * The live session this command names, or the sentence to refuse it with.
2942
+ *
2943
+ * A string return is deliberately unmistakable for a session: every caller
2944
+ * has to branch on the type, so "I did not check" cannot compile.
2945
+ */
2946
+ requireSession(sessionId) {
2947
+ if (!sessionId)
2948
+ return 'sessionId is required';
2949
+ return this.sessions.get(sessionId) ?? 'Unknown session';
2950
+ }
2285
2951
  /** A session actively mid-turn in this worktree — git writes must wait. */
2286
2952
  isWorktreeBusy(worktreePath) {
2287
2953
  for (const running of this.sessions.values()) {
@@ -2299,6 +2965,13 @@ export class Supervisor {
2299
2965
  // Hard cap far below the API's 128KB Zod limit — an oversized payload would
2300
2966
  // be rejected forever and wedge the journal (QA-96 F2).
2301
2967
  static EVENT_PAYLOAD_CAP = 100_000;
2968
+ /**
2969
+ * Journal an event, put it on the wire, and return it.
2970
+ *
2971
+ * The return value matters since ticket #125: the seq assigned here is the
2972
+ * only name the browser and the runner share for one message, so the caller
2973
+ * that echoes a user bubble has to be able to read it back.
2974
+ */
2302
2975
  sendEvent(running, eventType, payload) {
2303
2976
  let compact = Object.fromEntries(Object.entries(maskSecrets(payload)).filter(([, v]) => v !== undefined));
2304
2977
  if (JSON.stringify(compact).length > Supervisor.EVENT_PAYLOAD_CAP) {
@@ -2323,6 +2996,7 @@ export class Supervisor {
2323
2996
  running.lastReported = 'WAITING_PERMISSION';
2324
2997
  this.syncBudgetClock(running);
2325
2998
  }
2999
+ return event;
2326
3000
  }
2327
3001
  reportStatus(sessionId, status, extra) {
2328
3002
  const running = this.sessions.get(sessionId);