@bridge4dev/runner 0.27.0 → 0.29.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,10 @@ 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
+ /** Refusals shared by every checkpoint command (ticket #126). */
21
+ const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
22
+ const AGENT_BUSY = 'The agent is still working — stop the turn first';
19
23
  export class Supervisor {
20
24
  ws;
21
25
  opts;
@@ -37,6 +41,17 @@ export class Supervisor {
37
41
  authRelay = new AuthRelay();
38
42
  /** Serialises repo-mutating git commands per workspace repo (QA-99 MAJOR-1). */
39
43
  repoLocks = new Map();
44
+ /**
45
+ * Repo keys with work in flight right now (ticket #126).
46
+ *
47
+ * A checkpoint deliberately does NOT take the repo lock — it only reads the
48
+ * worktree, and queueing a user's message behind a 150-second push would be
49
+ * a plain regression. What it does instead is decline to run while a
50
+ * mutating command holds the repo: a snapshot taken halfway through an apply
51
+ * would be a state that never existed, and offering to restore it is worse
52
+ * than having no restore point for that one message.
53
+ */
54
+ repoLockDepth = new Map();
40
55
  /** An update is installing right now — a second one would fight it. */
41
56
  selfUpdateInFlight = false;
42
57
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
@@ -268,6 +283,13 @@ export class Supervisor {
268
283
  // asked for it) resumes on the next message instead of replaying its
269
284
  // original prompt.
270
285
  if (descriptor.status === 'STARTING') {
286
+ // Ticket #126: the point before the agent has touched anything. It is
287
+ // anchored to seq 0 — the synthetic opening bubble the dashboard puts in
288
+ // front of every feed — so "put it all back" is reachable from the very
289
+ // first thing on the page.
290
+ await this.captureCheckpoint(running, 'TURN', 0);
291
+ if (this.isStale(running))
292
+ return;
271
293
  this.launchAgent(running, composeInitialPrompt(descriptor), null);
272
294
  }
273
295
  else {
@@ -360,6 +382,14 @@ export class Supervisor {
360
382
  // Facts about this session only. The project's own documentation is read by
361
383
  // each agent itself — see `composeWorkspaceContext`.
362
384
  const workspaceContext = composeWorkspaceContext(descriptor);
385
+ const rewind = running.rewindAnchor;
386
+ delete running.rewindAnchor;
387
+ // A rewind resumes the conversation the POINT names, which is not always
388
+ // the one this session is on now: rewinding twice, or rewinding the first
389
+ // message after a rewind, both reach back into the thread the fork came
390
+ // from. Its transcript is still on disk — that is what makes the point
391
+ // usable at all.
392
+ const resumeTarget = rewind ? rewind.agentSession : resumeId;
363
393
  running.session = adapter.startSession({
364
394
  sessionId: descriptor.id,
365
395
  cwd: running.worktreePath,
@@ -372,7 +402,12 @@ export class Supervisor {
372
402
  mode: running.mode,
373
403
  ...(running.model ? { model: running.model } : {}),
374
404
  ...(running.effort ? { effort: running.effort } : {}),
375
- ...(resumeId ? { resumeProviderSessionId: resumeId } : {}),
405
+ ...(resumeTarget ? { resumeProviderSessionId: resumeTarget } : {}),
406
+ // Ticket #126: a conversation rewind takes effect exactly here, on the
407
+ // next process this session starts. Consumed rather than kept — a rewind
408
+ // is one event, not a standing setting, and re-applying it on a later
409
+ // relaunch would silently throw away everything said since.
410
+ ...(rewind ? { resumeAtAnchor: rewind.anchor } : {}),
376
411
  // Descriptor MCP (auto-issued per-workspace key) wins over config.toml.
377
412
  ...((descriptor.mcp ?? this.opts.mcp) ? { mcp: descriptor.mcp ?? this.opts.mcp } : {}),
378
413
  // The SDK budget is per-process; hand the RESIDUAL session budget down.
@@ -571,6 +606,16 @@ export class Supervisor {
571
606
  this.launchAgent(running, prompt, null);
572
607
  return;
573
608
  }
609
+ // The way back when the CLI refuses the rewind anchor: one relaunch WITHOUT
610
+ // it, keeping the conversation. Nothing was rewound, so nothing is cut.
611
+ if (running.rewindRetry && !running.stopRequested) {
612
+ const { prompt } = running.rewindRetry;
613
+ delete running.rewindRetry;
614
+ running.rewindRetryDone = true;
615
+ running.costBaseUsd = running.costUsd;
616
+ this.launchAgent(running, prompt, running.descriptor.providerSessionId);
617
+ return;
618
+ }
574
619
  // Auth recovery: one relaunch that KEEPS the provider session, so a
575
620
  // credential hiccup does not cost the agent its whole conversation.
576
621
  if (running.authRetry && !running.stopRequested) {
@@ -795,29 +840,48 @@ export class Supervisor {
795
840
  running.openQuestions.size === 0 &&
796
841
  Boolean(running.descriptor.providerSessionId));
797
842
  }
798
- park(running) {
843
+ /**
844
+ * Stop the agent process but keep the session resumable.
845
+ *
846
+ * `quiet` is for the callers that say it better themselves: a conversation
847
+ * rewind parks the session too, and announcing «the runner switched to
848
+ * another session» there is simply untrue — nothing switched, the user
849
+ * rewound (ticket #126).
850
+ */
851
+ park(running, options = {}) {
799
852
  if (!running.session)
800
853
  return;
854
+ // Ask the process where the conversation stands while it is still there to
855
+ // ask (ticket #126) — a restore point taken after this would otherwise
856
+ // have no way to rewind the agent's memory.
857
+ this.currentAnchor(running);
801
858
  running.parkRequested = true;
802
859
  // `code` is what the dashboard reads to tell «parked» from «your turn»
803
860
  // (#124). The prose stays for runners older than 0.23.0, which the
804
861
  // dashboard still matches on; delete that fallback once the fleet has
805
862
  // 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
- });
863
+ if (!options.quiet) {
864
+ this.sendEvent(running, 'system_note', {
865
+ code: 'session_parked',
866
+ text: 'Session parked — the runner switched to another session. Send a message to resume.',
867
+ });
868
+ }
810
869
  running.session.stop('session_parked');
811
870
  }
812
871
  forwardEvent(running, event) {
813
872
  const { descriptor } = running;
814
873
  switch (event.type) {
815
- case 'provider_session':
874
+ case 'provider_session': {
816
875
  running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
817
876
  this.reportStatus(descriptor.id, statusForReport(running), {
818
877
  providerSessionId: event.providerSessionId,
819
878
  });
879
+ // The CLI resumed at the anchor and forked, so the cut announced when
880
+ // the rewind was asked for stands. Nothing to send — it is already in
881
+ // the feed; this only retires the promise to take it back.
882
+ delete running.pendingRewind;
820
883
  return;
884
+ }
821
885
  case 'cost':
822
886
  // event.costUsd is the live process's running total; the session
823
887
  // total also includes what earlier processes spent (park/restart).
@@ -829,7 +893,22 @@ export class Supervisor {
829
893
  });
830
894
  return;
831
895
  case 'turn_end': {
832
- this.sendEvent(running, 'turn_end', { ok: event.ok, errorMessage: event.errorMessage });
896
+ this.sendEvent(running, 'turn_end', {
897
+ ok: event.ok,
898
+ errorMessage: event.errorMessage,
899
+ ...(event.aborted ? { aborted: true } : {}),
900
+ });
901
+ // The session is already on its way out with a status that MEANS
902
+ // something — a spent budget, a Stop, a teardown. A turn ending inside
903
+ // that window is a consequence of it, and letting the line below
904
+ // overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
905
+ // WAITING_INPUT) replaces a true, actionable ending with a wrong one.
906
+ if (running.stopRequested || running.budgetSpent)
907
+ return;
908
+ // A finished turn is the last instant this conversation is BOTH
909
+ // complete and readable: the process can be parked or lose its slot at
910
+ // any point after it, and `conversationAnchor()` needs a live one.
911
+ this.currentAnchor(running);
833
912
  // Turn end is the natural checkpoint for the budget clock: the slice
834
913
  // just closed, so this is the moment the API can persist it. Without a
835
914
  // report here `agentActiveMs` stayed 0 and every restart or resume
@@ -854,6 +933,31 @@ export class Supervisor {
854
933
  // A provider session the CLI no longer knows (state wiped, expired
855
934
  // history): don't fail the session — relaunch it fresh once with the
856
935
  // same prompt, the git worktree still holds all the work.
936
+ // The CLI does not know the point we asked it to resume at. The
937
+ // conversation is intact — only the rewind did not happen — so the
938
+ // session must survive, the feed must NOT be cut (nothing was rewound),
939
+ // and the person must be told plainly rather than left believing the
940
+ // agent has forgotten something it still remembers.
941
+ if (event.code === 'rewind_failed') {
942
+ // Put the conversation back on the page: the agent still has every
943
+ // word of it, and a transcript that shows less than the agent knows
944
+ // is the one outcome this feature must never produce.
945
+ const cutToUndo = running.pendingRewind;
946
+ delete running.pendingRewind;
947
+ if (cutToUndo) {
948
+ this.sendEvent(running, 'session_rewind_undone', {
949
+ rewoundSeq: cutToUndo.rewoundSeq,
950
+ });
951
+ }
952
+ if (!event.recovered && !running.rewindRetryDone) {
953
+ running.rewindRetry = { prompt: running.lastPrompt };
954
+ this.sendEvent(running, 'notice', {
955
+ level: 'warn',
956
+ text: 'The conversation could not be rewound to that point — the agent still remembers everything after it. Its files were not affected.',
957
+ });
958
+ }
959
+ return;
960
+ }
857
961
  if (event.code === 'resume_failed' && !running.freshRetryDone) {
858
962
  running.freshRetry = { prompt: running.lastPrompt };
859
963
  this.sendEvent(running, 'notice', {
@@ -1126,16 +1230,19 @@ export class Supervisor {
1126
1230
  }
1127
1231
  // The feed shows what the USER wrote plus the files they picked — not the
1128
1232
  // composed prompt with workspace paths, which is an implementation detail.
1129
- this.sendEvent(running, 'message', {
1233
+ // Its seq is the name this message answers to for the rest of its life
1234
+ // (ticket #125): recall, checkpoint anchor and rewind all quote it.
1235
+ const echoed = this.sendEvent(running, 'message', {
1130
1236
  role: 'user',
1131
1237
  text,
1132
1238
  ...(attachments?.length ? { attachments } : {}),
1133
1239
  });
1240
+ const originSeq = echoed.seq;
1134
1241
  if (!running.worktreePath) {
1135
1242
  // Session is still being prepared — deliver after launch (QA-96 F3).
1136
1243
  // Attachments travel as metadata and are downloaded at delivery time,
1137
1244
  // which is the first moment the worktree is guaranteed to exist.
1138
- running.pendingMessages.push(running.journal.appendPending(text, attachments));
1245
+ this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
1139
1246
  return;
1140
1247
  }
1141
1248
  this.enqueueDelivery(running, async () => {
@@ -1143,15 +1250,40 @@ export class Supervisor {
1143
1250
  if (this.isStale(running)) {
1144
1251
  // The session ended while the files downloaded. Park the message on
1145
1252
  // disk rather than dropping it — «Продолжить» carries it to the agent.
1146
- running.journal.appendPending(text, attachments);
1253
+ // Announced like every other queueing decision: a bubble that is
1254
+ // waiting must say so, whichever door it came through.
1255
+ const parked = running.journal.appendPending(text, attachments, originSeq);
1256
+ if (parked.seq !== undefined) {
1257
+ this.sendEvent(running, 'message_queued', { targetSeq: parked.seq });
1258
+ }
1147
1259
  log.warn('supervisor: session ended before the message could be delivered', {
1148
1260
  sessionId: running.descriptor.id,
1149
1261
  });
1150
1262
  return;
1151
1263
  }
1152
- this.deliverMessage(running, composed);
1264
+ // Ticket #126: the restore point belongs in FRONT of this message, and
1265
+ // this is the last moment the tree is still as the user left it.
1266
+ await this.captureCheckpoint(running, 'TURN', originSeq);
1267
+ if (this.isStale(running))
1268
+ return;
1269
+ this.deliverMessage(running, composed, [], originSeq);
1153
1270
  });
1154
1271
  }
1272
+ /**
1273
+ * Queue a message that no agent can take yet, and say so in the feed
1274
+ * (ticket #125).
1275
+ *
1276
+ * Every queueing decision goes through here so the browser's idea of "this
1277
+ * one can still be taken back" cannot drift from the runner's. Records with
1278
+ * no seq — orphans, and anything written by a runner older than 0.28.0 —
1279
+ * stay silent: they have no name the browser could quote back.
1280
+ */
1281
+ queueMessage(running, record) {
1282
+ running.pendingMessages.push(record);
1283
+ if (record.seq !== undefined) {
1284
+ this.sendEvent(running, 'message_queued', { targetSeq: record.seq });
1285
+ }
1286
+ }
1155
1287
  /**
1156
1288
  * Run delivery work for one session, strictly after whatever is already
1157
1289
  * queued for it. Order is the whole point: two messages typed seconds apart
@@ -1211,7 +1343,7 @@ export class Supervisor {
1211
1343
  * resolves them and a refusal puts exactly those records back in the queue —
1212
1344
  * the message is retired from disk only once an agent has it.
1213
1345
  */
1214
- deliverMessage(running, text, held = []) {
1346
+ deliverMessage(running, text, held = [], originSeq) {
1215
1347
  // Older instructions are already waiting: send them together, in order.
1216
1348
  // Without this a message that CAN go now jumps the queue — and the ones it
1217
1349
  // jumped are stranded, because the drain only runs when a process exits
@@ -1223,13 +1355,23 @@ export class Supervisor {
1223
1355
  // would deliver the words while silently dropping the screenshot
1224
1356
  // (QA-104 MAJOR-4). `text` is already composed by the caller, so it
1225
1357
  // needs no attachments of its own.
1226
- running.pendingMessages.push(running.journal.appendPending(text));
1358
+ this.queueMessage(running, running.journal.appendPending(text, undefined, originSeq));
1227
1359
  this.flushPendingMessages(running);
1228
1360
  return;
1229
1361
  }
1230
1362
  const settle = () => {
1231
- for (const record of held)
1363
+ const delivered = [];
1364
+ for (const record of held) {
1232
1365
  running.journal.resolvePending(record.id);
1366
+ if (record.seq !== undefined)
1367
+ delivered.push(record.seq);
1368
+ }
1369
+ // Ticket #125: the queue is now empty of these, so the browser must stop
1370
+ // offering to take them back. Only for messages that were ever announced
1371
+ // as queued — the ordinary path never queues and needs no frame.
1372
+ if (delivered.length > 0) {
1373
+ this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
1374
+ }
1233
1375
  };
1234
1376
  if (running.session) {
1235
1377
  running.session.send(text);
@@ -1250,7 +1392,7 @@ export class Supervisor {
1250
1392
  // carries it once a slot frees up, instead of the user's instruction
1251
1393
  // vanishing into a system note. On disk, too — the wait can outlive the
1252
1394
  // daemon (session 9).
1253
- running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
1395
+ this.requeue(running, held, text, originSeq);
1254
1396
  return;
1255
1397
  }
1256
1398
  // A person typing into the session is the clearest signal that the work is
@@ -1263,7 +1405,105 @@ export class Supervisor {
1263
1405
  // The agent did not start (an exhausted budget is the only way here). The
1264
1406
  // instruction stays on disk, so «Продолжить» — which is what raises the
1265
1407
  // budget — carries it to the agent instead of dropping it.
1266
- running.pendingMessages.push(...(held.length > 0 ? held : [running.journal.appendPending(text)]));
1408
+ this.requeue(running, held, text, originSeq);
1409
+ }
1410
+ /**
1411
+ * Nothing could take the message: put it back where it came from.
1412
+ *
1413
+ * Records that were already held keep their identity (and their queued
1414
+ * announcement); a first-time refusal mints one and announces it.
1415
+ */
1416
+ requeue(running, held, text, originSeq) {
1417
+ if (held.length > 0) {
1418
+ // Already announced as queued when they first went in — re-announcing
1419
+ // would be a second frame saying what the feed already knows.
1420
+ running.pendingMessages.push(...held);
1421
+ return;
1422
+ }
1423
+ this.queueMessage(running, running.journal.appendPending(text, undefined, originSeq));
1424
+ }
1425
+ /**
1426
+ * Where this session's conversation stands, live process or not (ticket #126).
1427
+ *
1428
+ * Reads the running agent when there is one and remembers what it said;
1429
+ * falls back to that memory when there is not. The provider session id is
1430
+ * checked on the way out rather than on the way in: an anchor minted before
1431
+ * a fork is not wrong, it simply belongs to a conversation this session no
1432
+ * longer has.
1433
+ */
1434
+ currentAnchor(running) {
1435
+ const providerSessionId = running.descriptor.providerSessionId;
1436
+ const live = providerSessionId ? (running.session?.conversationAnchor() ?? null) : null;
1437
+ if (live && providerSessionId) {
1438
+ running.journal.recordAnchor(live, providerSessionId);
1439
+ return { anchor: live, agentSession: providerSessionId };
1440
+ }
1441
+ // No live process to ask — and that is the COMMON case at the moment a
1442
+ // restore point is taken: a parked session, a runner that just
1443
+ // reconnected, the message right after a rewind. The journal keeps the
1444
+ // tip, together with the conversation it belongs to.
1445
+ const remembered = running.journal.lastAnchor;
1446
+ return remembered
1447
+ ? { anchor: remembered.anchor, agentSession: remembered.providerSessionId }
1448
+ : null;
1449
+ }
1450
+ /**
1451
+ * Take a restore point in front of the work that is about to start
1452
+ * (ticket #126).
1453
+ *
1454
+ * Called from the delivery chain — which is already asynchronous because of
1455
+ * attachments — rather than from `deliverMessage`, which is synchronous and
1456
+ * has five exits. Never throws and never blocks delivery: a message must
1457
+ * reach the agent whether or not a restore point could be taken.
1458
+ *
1459
+ * Three reasons it declines, and each of them is a state in which a snapshot
1460
+ * would be a lie rather than a restore point:
1461
+ * - the machine's owner switched checkpoints off;
1462
+ * - the agent is mid-turn, so the tree is being written to as we read it;
1463
+ * - a repo-mutating command holds the repository.
1464
+ */
1465
+ async captureCheckpoint(running, kind, messageSeq) {
1466
+ const worktreePath = running.worktreePath;
1467
+ if (!worktreePath || this.opts.checkpointsEnabled === false)
1468
+ return;
1469
+ if (kind === 'TURN' && this.isWorktreeBusy(worktreePath))
1470
+ return;
1471
+ if (await this.isRepoLocked(worktreePath))
1472
+ return;
1473
+ // The anchor and the conversation it names travel TOGETHER: a point
1474
+ // recorded against an older provider session stays usable, because the
1475
+ // rewind resumes the session the point names rather than whichever one the
1476
+ // session happens to be on now.
1477
+ const anchor = this.currentAnchor(running);
1478
+ const result = await createCheckpoint({
1479
+ worktreePath,
1480
+ sessionId: running.descriptor.id,
1481
+ kind,
1482
+ ...(messageSeq === undefined ? {} : { messageSeq }),
1483
+ ...(anchor ? { agentAnchor: anchor.anchor, agentSession: anchor.agentSession } : {}),
1484
+ });
1485
+ if (this.isStale(running))
1486
+ return;
1487
+ if (!result.created) {
1488
+ if (result.reason === 'too-large') {
1489
+ // Loud, because the alternative is a session that quietly has no way
1490
+ // back and a button that quietly is not there.
1491
+ this.sendEvent(running, 'notice', {
1492
+ level: 'warn',
1493
+ 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.',
1494
+ });
1495
+ }
1496
+ return;
1497
+ }
1498
+ this.sendEvent(running, 'checkpoint', {
1499
+ ordinal: result.record.ordinal,
1500
+ kind: result.record.kind,
1501
+ createdAt: result.record.createdAt,
1502
+ fileCount: result.record.fileCount,
1503
+ canRewindContext: Boolean(result.record.agentAnchor),
1504
+ ...(messageSeq === undefined ? {} : { messageSeq }),
1505
+ ...(result.skippedFiles.length ? { skippedFiles: result.skippedFiles.slice(0, 20) } : {}),
1506
+ });
1267
1507
  }
1268
1508
  /**
1269
1509
  * Deliver messages that raced session start (already journaled).
@@ -1299,6 +1539,13 @@ export class Supervisor {
1299
1539
  });
1300
1540
  return;
1301
1541
  }
1542
+ // Anchored to the FIRST of the joined messages: that bubble is the one a
1543
+ // person means by "put it back to before I said this".
1544
+ await this.captureCheckpoint(running, 'TURN', pending[0]?.seq);
1545
+ if (this.isStale(running)) {
1546
+ running.pendingMessages.unshift(...pending);
1547
+ return;
1548
+ }
1302
1549
  this.deliverMessage(running, parts.join('\n\n'), pending);
1303
1550
  });
1304
1551
  }
@@ -1381,6 +1628,19 @@ export class Supervisor {
1381
1628
  // dashboard while this runner was offline) must not keep an agent process
1382
1629
  // running and holding the single runner slot (QA-99 MAJOR-2).
1383
1630
  const known = new Set(descriptors.map((d) => d.id));
1631
+ // Ticket #126: this list is the only moment the runner learns which
1632
+ // sessions still exist. Deleting a session while its dev server is
1633
+ // switched off leaves restore points — a full copy of a working tree —
1634
+ // with no row anywhere pointing at them, and nothing else on this machine
1635
+ // would ever collect them.
1636
+ void pruneCheckpoints({ liveSessionIds: known }).then((result) => {
1637
+ if (result.droppedRefs > 0) {
1638
+ log.info('supervisor: collected orphaned restore points', {
1639
+ sessions: result.droppedSessions.length,
1640
+ refs: result.droppedRefs,
1641
+ });
1642
+ }
1643
+ }, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }));
1384
1644
  for (const [sessionId, running] of [...this.sessions]) {
1385
1645
  if (known.has(sessionId))
1386
1646
  continue;
@@ -1657,6 +1917,11 @@ export class Supervisor {
1657
1917
  });
1658
1918
  this.journals.closeAndDelete(sessionId);
1659
1919
  this.orphanMessages.delete(sessionId);
1920
+ // Ticket #126: the restore points are copies of this session's
1921
+ // working tree. The session is gone from the API, so they are the
1922
+ // last thing on this machine that still holds its content.
1923
+ if (workspacePath)
1924
+ await dropCheckpoints(workspacePath, sessionId);
1660
1925
  return void reply({ ok: true, result: { removed: true, ...result } });
1661
1926
  }
1662
1927
  case 'reset_workspace':
@@ -1664,6 +1929,245 @@ export class Supervisor {
1664
1929
  ok: false,
1665
1930
  error: 'reset_workspace is not supported by this runner version',
1666
1931
  });
1932
+ case 'recall_message': {
1933
+ // Ticket #125: take a queued message back before any agent sees it.
1934
+ const sessionId = frame.sessionId;
1935
+ if (!sessionId)
1936
+ return void reply({ ok: false, error: 'sessionId is required' });
1937
+ const running = this.sessions.get(sessionId);
1938
+ if (!running)
1939
+ return void reply({ ok: false, error: 'Unknown session' });
1940
+ const targetSeq = num(frame.args?.['targetSeq']);
1941
+ if (targetSeq === null)
1942
+ return void reply({ ok: false, error: 'targetSeq is required' });
1943
+ // NOT A SINGLE `await` FROM HERE TO THE REPLY. Frames are handled
1944
+ // concurrently (`void this.onFrame(frame)`, gotcha #68), so a yield
1945
+ // in this window would let the delivery chain hand the agent the
1946
+ // very message we are removing — and both sides would report success.
1947
+ const record = running.journal.findPendingBySeq(targetSeq);
1948
+ const index = record
1949
+ ? running.pendingMessages.findIndex((held) => held.id === record.id)
1950
+ : -1;
1951
+ if (!record || index < 0) {
1952
+ // Never queued, or a flush already took it out of the queue and is
1953
+ // downloading its files. Both read the same from where the user
1954
+ // stands: the agent has it. Answering `not_found` here would be a
1955
+ // lie about a session we know, and answering success would be a
1956
+ // promise we cannot keep — this is the only safe direction.
1957
+ return void reply({
1958
+ ok: true,
1959
+ result: { recalled: false, reason: 'already_delivered' },
1960
+ });
1961
+ }
1962
+ running.pendingMessages.splice(index, 1);
1963
+ running.journal.cancelPending(record.id);
1964
+ this.sendEvent(running, 'message_recalled', { targetSeq });
1965
+ return void reply({ ok: true, result: { recalled: true } });
1966
+ }
1967
+ // ─── Ticket #126: restore points ────────────────────────────────
1968
+ case 'session_checkpoint': {
1969
+ const running = this.requireSession(frame.sessionId);
1970
+ if (typeof running === 'string')
1971
+ return void reply({ ok: false, error: running });
1972
+ if (this.opts.checkpointsEnabled === false) {
1973
+ return void reply({ ok: false, error: CHECKPOINTS_OFF });
1974
+ }
1975
+ if (!running.worktreePath) {
1976
+ return void reply({ ok: false, error: 'The session has no working folder yet' });
1977
+ }
1978
+ if (this.isWorktreeBusy(running.worktreePath)) {
1979
+ return void reply({ ok: false, error: AGENT_BUSY });
1980
+ }
1981
+ const before = (await listCheckpoints(running.worktreePath, running.descriptor.id))
1982
+ .length;
1983
+ await this.captureCheckpoint(running, 'MANUAL', num(frame.args?.['messageSeq']) ?? undefined);
1984
+ const after = await listCheckpoints(running.worktreePath, running.descriptor.id);
1985
+ if (after.length === before) {
1986
+ return void reply({ ok: false, error: 'A restore point could not be taken right now' });
1987
+ }
1988
+ return void reply({ ok: true, result: { ordinal: after.at(-1)?.ordinal } });
1989
+ }
1990
+ case 'session_checkpoints': {
1991
+ const running = this.requireSession(frame.sessionId);
1992
+ if (typeof running === 'string')
1993
+ return void reply({ ok: false, error: running });
1994
+ if (!running.worktreePath)
1995
+ return void reply({ ok: true, result: { items: [] } });
1996
+ const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
1997
+ return void reply({
1998
+ ok: true,
1999
+ result: {
2000
+ items: items.map((item) => ({
2001
+ ordinal: item.ordinal,
2002
+ kind: item.kind,
2003
+ createdAt: item.createdAt,
2004
+ fileCount: item.fileCount,
2005
+ // An anchor is enough: it names its own conversation, and that
2006
+ // conversation is still on disk.
2007
+ canRewindContext: Boolean(item.agentAnchor),
2008
+ ...(item.messageSeq === undefined ? {} : { messageSeq: item.messageSeq }),
2009
+ })),
2010
+ },
2011
+ });
2012
+ }
2013
+ case 'session_rewind_preview': {
2014
+ const running = this.requireSession(frame.sessionId);
2015
+ if (typeof running === 'string')
2016
+ return void reply({ ok: false, error: running });
2017
+ const ordinal = num(frame.args?.['ordinal']);
2018
+ if (ordinal === null || !running.worktreePath) {
2019
+ return void reply({ ok: false, error: 'ordinal is required' });
2020
+ }
2021
+ const preview = await previewRewind({
2022
+ worktreePath: running.worktreePath,
2023
+ sessionId: running.descriptor.id,
2024
+ ordinal,
2025
+ });
2026
+ return void reply({ ok: true, result: preview });
2027
+ }
2028
+ case 'session_rewind_files': {
2029
+ const running = this.requireSession(frame.sessionId);
2030
+ if (typeof running === 'string')
2031
+ return void reply({ ok: false, error: running });
2032
+ if (this.opts.checkpointsEnabled === false) {
2033
+ return void reply({ ok: false, error: CHECKPOINTS_OFF });
2034
+ }
2035
+ const ordinal = num(frame.args?.['ordinal']);
2036
+ if (ordinal === null || !running.worktreePath) {
2037
+ return void reply({ ok: false, error: 'ordinal is required' });
2038
+ }
2039
+ if (this.isWorktreeBusy(running.worktreePath)) {
2040
+ return void reply({ ok: false, error: AGENT_BUSY });
2041
+ }
2042
+ const confirmRaw = frame.args?.['confirmDeletes'];
2043
+ const confirmDeletes = Array.isArray(confirmRaw)
2044
+ ? confirmRaw.filter((p) => typeof p === 'string')
2045
+ : [];
2046
+ const expectedTreeOid = frame.args?.['expectedTreeOid'];
2047
+ // The repo lock, unlike the read-only checkpoint, IS taken here: this
2048
+ // writes the working tree, exactly like commit, apply and revert.
2049
+ const result = await this.withRepoLockFor(running.worktreePath, () => applyRewind({
2050
+ worktreePath: running.worktreePath,
2051
+ sessionId: running.descriptor.id,
2052
+ ordinal,
2053
+ confirmDeletes,
2054
+ ...(typeof expectedTreeOid === 'string' && expectedTreeOid
2055
+ ? { expectedTreeOid }
2056
+ : {}),
2057
+ }));
2058
+ this.sendEvent(running, 'checkpoint', {
2059
+ ordinal: result.safety.ordinal,
2060
+ kind: result.safety.kind,
2061
+ createdAt: result.safety.createdAt,
2062
+ fileCount: result.safety.fileCount,
2063
+ canRewindContext: Boolean(result.safety.agentAnchor),
2064
+ // This frame IS the browser's offer of «Undo rewind» — the point it
2065
+ // names belongs to no message, so there is nowhere else the offer
2066
+ // could come from. Withheld when the rewind was itself an undo:
2067
+ // going back again is a redo, and a button that quietly changes
2068
+ // meaning after one press is worse than no button.
2069
+ undoable: result.rewoundToKind !== 'SAFETY',
2070
+ });
2071
+ this.sendEvent(running, 'system_note', {
2072
+ code: 'files_rewound',
2073
+ 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.`,
2074
+ });
2075
+ return void reply({ ok: true, result });
2076
+ }
2077
+ case 'context_rewind': {
2078
+ const running = this.requireSession(frame.sessionId);
2079
+ if (typeof running === 'string')
2080
+ return void reply({ ok: false, error: running });
2081
+ const ordinal = num(frame.args?.['ordinal']);
2082
+ if (ordinal === null || !running.worktreePath) {
2083
+ return void reply({ ok: false, error: 'ordinal is required' });
2084
+ }
2085
+ if (this.isWorktreeBusy(running.worktreePath)) {
2086
+ return void reply({ ok: false, error: AGENT_BUSY });
2087
+ }
2088
+ const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
2089
+ const point = items.find((item) => item.ordinal === ordinal);
2090
+ // No equality check against the CURRENT provider session any more.
2091
+ // The point names the conversation its anchor lives in, and that
2092
+ // conversation is still on disk — which is what makes rewinding
2093
+ // twice, or rewinding the first message after a rewind, work at all.
2094
+ // Requiring the ids to match refused both, on the grounds that the
2095
+ // point «predates a conversation rewind» — true, and beside the
2096
+ // point.
2097
+ const agentSession = point?.agentSession ?? running.descriptor.providerSessionId;
2098
+ if (!point?.agentAnchor || !agentSession) {
2099
+ return void reply({
2100
+ ok: false,
2101
+ error: 'The agent had not answered yet at this point, so there is no conversation to rewind to. Its files can still be restored.',
2102
+ });
2103
+ }
2104
+ // What an agent remembers is fixed when its process starts, so the
2105
+ // rewind is applied by replacing that process. Park the live one and
2106
+ // bring it straight back — the CLI's own rewind happens when you
2107
+ // press it, and so does this one. Waiting for the user's next message
2108
+ // would leave them staring at a page where nothing had changed.
2109
+ running.rewindAnchor = { anchor: point.agentAnchor, agentSession };
2110
+ // Parked, not relaunched. A launch with no prompt does not resume
2111
+ // the conversation at all — the CLI reads the anchor only when the
2112
+ // first input arrives — so spawning a process here would idle in a
2113
+ // runner slot and change nothing. The anchor is consumed by the
2114
+ // launch the user's next message triggers, which is the same moment
2115
+ // the CLI validates it either way.
2116
+ if (running.session)
2117
+ this.park(running, { quiet: true });
2118
+ // The conversation tip is now THIS point — that is what a rewind
2119
+ // means — so the next restore point must record it, not the message
2120
+ // the user just rewound away from (which `park()` above would
2121
+ // otherwise have left behind, QA-120 M3). Writing it here is also
2122
+ // what lets the very next message be rewound in turn: it inherits a
2123
+ // tip that names a conversation still on disk.
2124
+ running.journal.recordAnchor(point.agentAnchor, agentSession);
2125
+ // The cut is announced NOW, and taken back if the CLI turns out not
2126
+ // to know the anchor.
2127
+ //
2128
+ // Waiting for confirmation was tried and is not possible: a launch
2129
+ // with no prompt does not resume at all until the first input, so
2130
+ // `system:init` — and with it any proof the fork happened — never
2131
+ // arrives on its own (measured: 20s, no init). Holding the cut until
2132
+ // then meant the page did not change when the button was pressed,
2133
+ // and the late cut then swallowed the message the user had sent in
2134
+ // the meantime.
2135
+ //
2136
+ // So the feed is cut optimistically and CORRECTED if the resume is
2137
+ // refused — an amendment, exactly like every other removal here.
2138
+ if (point?.messageSeq !== undefined) {
2139
+ const cut = this.sendEvent(running, 'session_rewound', {
2140
+ targetSeq: point.messageSeq,
2141
+ });
2142
+ running.pendingRewind = { rewoundSeq: cut.seq };
2143
+ }
2144
+ this.sendEvent(running, 'system_note', {
2145
+ code: 'context_rewound',
2146
+ text: (point?.messageSeq ?? 0) > 0
2147
+ ? 'Rewound to an earlier point — the agent no longer remembers anything after it, and your message is back in the composer.'
2148
+ : 'Rewound to an earlier point — the agent no longer remembers anything after it.',
2149
+ });
2150
+ return void reply({ ok: true, result: { rewound: true } });
2151
+ }
2152
+ case 'compact_context': {
2153
+ const running = this.requireSession(frame.sessionId);
2154
+ if (typeof running === 'string')
2155
+ return void reply({ ok: false, error: running });
2156
+ if (!running.session) {
2157
+ return void reply({
2158
+ ok: false,
2159
+ error: 'The agent is not running — send a message first, then compact',
2160
+ });
2161
+ }
2162
+ const done = await running.session.compact();
2163
+ if (!done) {
2164
+ return void reply({
2165
+ ok: false,
2166
+ error: 'The agent could not compact right now — wait for the current turn to finish',
2167
+ });
2168
+ }
2169
+ return void reply({ ok: true, result: { started: true } });
2170
+ }
1667
2171
  case 'git_status': {
1668
2172
  const paths = gitCommandPaths(frame.args);
1669
2173
  if (!paths)
@@ -2265,15 +2769,30 @@ export class Supervisor {
2265
2769
  */
2266
2770
  withRepoLock(repoKey, fn) {
2267
2771
  const previous = this.repoLocks.get(repoKey) ?? Promise.resolve();
2772
+ this.repoLockDepth.set(repoKey, (this.repoLockDepth.get(repoKey) ?? 0) + 1);
2268
2773
  const run = previous.catch(() => undefined).then(fn);
2269
2774
  const tail = run.catch(() => undefined);
2270
2775
  this.repoLocks.set(repoKey, tail);
2271
2776
  void tail.then(() => {
2777
+ const depth = (this.repoLockDepth.get(repoKey) ?? 1) - 1;
2778
+ if (depth <= 0)
2779
+ this.repoLockDepth.delete(repoKey);
2780
+ else
2781
+ this.repoLockDepth.set(repoKey, depth);
2272
2782
  if (this.repoLocks.get(repoKey) === tail)
2273
2783
  this.repoLocks.delete(repoKey);
2274
2784
  });
2275
2785
  return run;
2276
2786
  }
2787
+ /** Is a repo-mutating command queued or running for this path right now? */
2788
+ async isRepoLocked(pathInsideRepo) {
2789
+ try {
2790
+ return (this.repoLockDepth.get(await repoKeyFor(pathInsideRepo)) ?? 0) > 0;
2791
+ }
2792
+ catch {
2793
+ return false;
2794
+ }
2795
+ }
2277
2796
  /**
2278
2797
  * Same lock, keyed by the shared repository rather than by whichever path the
2279
2798
  * caller happened to have. A worktree commit and a workspace squash-merge
@@ -2282,6 +2801,17 @@ export class Supervisor {
2282
2801
  async withRepoLockFor(pathInsideRepo, fn) {
2283
2802
  return this.withRepoLock(await repoKeyFor(pathInsideRepo), fn);
2284
2803
  }
2804
+ /**
2805
+ * The live session this command names, or the sentence to refuse it with.
2806
+ *
2807
+ * A string return is deliberately unmistakable for a session: every caller
2808
+ * has to branch on the type, so "I did not check" cannot compile.
2809
+ */
2810
+ requireSession(sessionId) {
2811
+ if (!sessionId)
2812
+ return 'sessionId is required';
2813
+ return this.sessions.get(sessionId) ?? 'Unknown session';
2814
+ }
2285
2815
  /** A session actively mid-turn in this worktree — git writes must wait. */
2286
2816
  isWorktreeBusy(worktreePath) {
2287
2817
  for (const running of this.sessions.values()) {
@@ -2299,6 +2829,13 @@ export class Supervisor {
2299
2829
  // Hard cap far below the API's 128KB Zod limit — an oversized payload would
2300
2830
  // be rejected forever and wedge the journal (QA-96 F2).
2301
2831
  static EVENT_PAYLOAD_CAP = 100_000;
2832
+ /**
2833
+ * Journal an event, put it on the wire, and return it.
2834
+ *
2835
+ * The return value matters since ticket #125: the seq assigned here is the
2836
+ * only name the browser and the runner share for one message, so the caller
2837
+ * that echoes a user bubble has to be able to read it back.
2838
+ */
2302
2839
  sendEvent(running, eventType, payload) {
2303
2840
  let compact = Object.fromEntries(Object.entries(maskSecrets(payload)).filter(([, v]) => v !== undefined));
2304
2841
  if (JSON.stringify(compact).length > Supervisor.EVENT_PAYLOAD_CAP) {
@@ -2323,6 +2860,7 @@ export class Supervisor {
2323
2860
  running.lastReported = 'WAITING_PERMISSION';
2324
2861
  this.syncBudgetClock(running);
2325
2862
  }
2863
+ return event;
2326
2864
  }
2327
2865
  reportStatus(sessionId, status, extra) {
2328
2866
  const running = this.sessions.get(sessionId);