@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.
@@ -5,7 +5,7 @@ import { AsyncQueue } from '../async-queue.js';
5
5
  import { log } from '../log.js';
6
6
  import { mcpConfigPath } from '../paths.js';
7
7
  import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
8
- import { AGENT_MODES, } from './types.js';
8
+ import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
9
9
  import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
10
10
  // Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
11
11
  // 1. Bare tool names in `allowedTools` auto-approve BEFORE canUseTool — we
@@ -65,14 +65,77 @@ export function scrubbedEnv() {
65
65
  }
66
66
  return env;
67
67
  }
68
+ /**
69
+ * The one env var the allowlist above refuses, added back for exactly one mode
70
+ * (ticket #156).
71
+ *
72
+ * The CLI's own guard, read out of the binary verbatim:
73
+ *
74
+ * ```js
75
+ * if (t === "bypassPermissions" || r) {
76
+ * if (typeof process.getuid === "function" && process.getuid() === 0
77
+ * && process.env.IS_SANDBOX !== "1" && !Z.CLAUDE_CODE_BUBBLEWRAP)
78
+ * console.error("--dangerously-skip-permissions cannot be used with root/sudo privileges …"),
79
+ * process.exit(1)
80
+ * }
81
+ * ```
82
+ *
83
+ * The runner's default install is root (`install.sh`), so on an ordinary
84
+ * machine «Unrestricted» did not degrade — it killed the session at launch with
85
+ * exit code 1. On the machine this was found on it happened to start, because
86
+ * `~/.claude/settings.json` carries `env: { IS_SANDBOX: "1" }` and this adapter
87
+ * loads user settings; that is a hole in the scrub covering a bug, not a fix.
88
+ *
89
+ * The condition here is the CLI's own, no wider: `full` AND uid 0. It is safe
90
+ * precisely where it is applied — `bypassPermissions` is the mode in which the
91
+ * SDK never calls `canUseTool` (it says so itself), so layer 1 is already inert
92
+ * and there is nothing left for `IS_SANDBOX` to weaken. In every other mode the
93
+ * scrub stands, which is what the comment on `ENV_ALLOWLIST` has always meant.
94
+ */
95
+ function agentEnv(mode) {
96
+ const env = scrubbedEnv();
97
+ if (mode === 'full' && process.getuid?.() === 0)
98
+ env['IS_SANDBOX'] = '1';
99
+ return env;
100
+ }
68
101
  // Normalized mode → Claude permission mode (session-5 plan §2). `full` is the
69
102
  // owner's explicit call (2026-07-24): "same as Claude works now, we don't
70
103
  // restrict anything" — in that mode the SDK stops calling canUseTool at all,
71
104
  // so the layer-1 policy cannot gate tools either; the dashboard says so.
105
+ //
106
+ // `auto` maps to the CLI's `default` rung, and the reason is the whole of
107
+ // QA-128's one surviving BLOCKER — it was `'auto'` for most of this ticket and
108
+ // had to come back.
109
+ //
110
+ // The CLI's own `auto` rung runs a MODEL CLASSIFIER over each tool call, and
111
+ // what the classifier approves never becomes a `can_use_tool` request. This
112
+ // adapter's only route into layer 1 is `canUseTool`. So in that rung
113
+ // `evaluateToolUse` is not consulted, and the rules the product calls
114
+ // unconditional — `sudo`, `git push`, docker control, secret paths, writes
115
+ // outside the worktree, anything inside `.git` — are simply not applied.
116
+ //
117
+ // Measured, not reasoned. Same adapter, same options, only the model differs:
118
+ //
119
+ // sonnet (has the auto rung) : `sudo -n id` → ran, policy consulted 0 times
120
+ // haiku-4.5 (has no auto rung) : `sudo -n id` → DENIED by policy
121
+ //
122
+ // Haiku is why this took a second pass to see: it refuses the rung outright
123
+ // (`auto mode unavailable for this model`) and the CLI quietly falls back to
124
+ // `default`, so a probe run on it shows the layer working perfectly.
125
+ //
126
+ // Nothing is lost by mapping to `default`. The CLI's rung was never what made
127
+ // «Auto» mean «only the hard limits» — `effectiveTrust(trust, 'auto') → AUTO`
128
+ // in `policy.ts` is, and it needs `canUseTool` to be reached to say so. What
129
+ // `default` gives up is the classifier's convenience; what it keeps is the only
130
+ // place the hard limits are enforced at all.
131
+ //
132
+ // `full` is the deliberate exception: `bypassPermissions` also stops calling
133
+ // `canUseTool`, and there that IS the meaning of the mode — the dashboard says
134
+ // so, and STRICT workspaces are not offered it.
72
135
  const MODE_TO_PERMISSION = {
73
136
  ask: 'default',
74
137
  plan: 'plan',
75
- auto: 'acceptEdits',
138
+ auto: 'default',
76
139
  full: 'bypassPermissions',
77
140
  };
78
141
  const SYSTEM_APPEND = [
@@ -186,6 +249,23 @@ class ClaudeSession {
186
249
  /** Guards against overlapping capability probes. */
187
250
  capabilitiesInFlight = false;
188
251
  mode;
252
+ /**
253
+ * A launch-time mode this workspace does not allow, remembered so the feed
254
+ * can say so once the event queue is live (`full` on a STRICT workspace).
255
+ */
256
+ modeRefusedAtLaunch = null;
257
+ /**
258
+ * This process was launched with the bypass capability, so `setPermissionMode`
259
+ * may reach `bypassPermissions` on it.
260
+ *
261
+ * The CLI ties the capability to the LAUNCH, not to the current mode: a
262
+ * session started with the flag can be tightened to `default` and raised back
263
+ * to bypass freely (both verified live), while one started without it is
264
+ * refused outright. So this is the honest predicate for
265
+ * `modeSwitchNeedsRelaunch`, and it is not the same question as
266
+ * «is the mode `full` right now».
267
+ */
268
+ launchedWithBypass;
189
269
  model;
190
270
  /**
191
271
  * Reasoning effort pinned for this session (ticket #111).
@@ -244,10 +324,56 @@ class ClaudeSession {
244
324
  * about the task — the live set typically precedes it (QA-111 m4).
245
325
  */
246
326
  skipTaskIds = new Set();
327
+ /**
328
+ * Uuid of the last message the CLI put in its transcript (ticket #126).
329
+ *
330
+ * This is the anchor a conversation rewind is measured from: handing it back
331
+ * as `resumeSessionAt` resumes the session with everything after it dropped.
332
+ * Every message type carries one — user echoes included, which is what makes
333
+ * "rewind to just before MY message" expressible at all.
334
+ */
335
+ lastMessageUuid = null;
336
+ /**
337
+ * A Stop the USER asked for is in flight.
338
+ *
339
+ * Aborting a turn mid-tool-use makes the SDK end it with
340
+ * `result{subtype:'error_during_execution'}`, which is indistinguishable
341
+ * from a crash by its shape alone — and `turn_end{ok:false}` puts the whole
342
+ * session in FAILED. So the one thing that CAN tell them apart is whether we
343
+ * asked: this flag is set by `interrupt()` and read by the result that
344
+ * follows it.
345
+ *
346
+ * Codex has always drawn the same distinction (`status: 'interrupted'` is
347
+ * reported `ok: true`); this is Claude catching up, not a new rule.
348
+ */
349
+ aborting = false;
350
+ /**
351
+ * The conversation is moving again — whatever a Stop armed is spent.
352
+ *
353
+ * Called from EVERY path that hands the agent something to act on, not just
354
+ * from `send()`: a Stop with nothing to interrupt produces no result at all,
355
+ * so a flag cleared only on a result stays armed and reports the NEXT turn's
356
+ * genuine failure as a clean stop (QA-120 M2). That is the original defect
357
+ * with the sign flipped, and the sign that loses data is this one — a run
358
+ * that failed would be reported as finished, with the reason dropped.
359
+ */
360
+ resumingTurn() {
361
+ this.aborting = false;
362
+ }
247
363
  events = this.output;
248
364
  constructor(spec, queryFn) {
249
365
  this.spec = spec;
250
- this.mode = spec.mode ?? 'ask';
366
+ // Refused rather than honoured, and BEFORE anything is built from it: on a
367
+ // STRICT workspace `full` would launch the CLI in `bypassPermissions`, and
368
+ // there layer 1 is never consulted — the manager's shield would be spent by
369
+ // whoever opened the session (ticket #156). The notice goes out in the
370
+ // constructor's own emit block below, once `output` can carry it.
371
+ const requested = spec.mode ?? 'ask';
372
+ this.mode = availableModes(spec.trustMode).includes(requested) ? requested : 'ask';
373
+ if (this.mode !== requested) {
374
+ this.modeRefusedAtLaunch = requested;
375
+ }
376
+ this.launchedWithBypass = this.mode === 'full';
251
377
  if (spec.model)
252
378
  this.model = spec.model;
253
379
  // Gated, not copied: a pin stored against a model that no longer offers it
@@ -277,7 +403,7 @@ class ClaudeSession {
277
403
  this.mcpConfigFile = mcpConfigFile;
278
404
  const options = {
279
405
  cwd: spec.cwd,
280
- env: scrubbedEnv(),
406
+ env: agentEnv(this.mode),
281
407
  /**
282
408
  * Everything the machine's own Claude has (owner's call, 2026-07-30).
283
409
  *
@@ -299,6 +425,14 @@ class ClaudeSession {
299
425
  */
300
426
  settingSources: ['user', 'project', 'local'],
301
427
  permissionMode: MODE_TO_PERMISSION[this.mode],
428
+ // The SDK's own words: "Must be set to `true` when using
429
+ // `permissionMode: 'bypassPermissions'`." It was never set, so
430
+ // «Unrestricted» asked the CLI for a mode it had not been given
431
+ // permission to offer (ticket #156). Passed only for `full`: as root the
432
+ // CLI refuses to START at all when this flag is present without
433
+ // `IS_SANDBOX=1`, so setting it unconditionally would take every mode
434
+ // down with it — verified live, exit code 1 on a plain `ask` session.
435
+ ...(this.mode === 'full' ? { allowDangerouslySkipPermissions: true } : {}),
302
436
  systemPrompt: {
303
437
  type: 'preset',
304
438
  preset: 'claude_code',
@@ -321,6 +455,15 @@ class ClaudeSession {
321
455
  // `applyFlagSettings` call `setEffort` makes.
322
456
  ...(spec.effort === ULTRACODE ? { settings: { ultracode: true } } : {}),
323
457
  ...(spec.resumeProviderSessionId ? { resume: spec.resumeProviderSessionId } : {}),
458
+ // Ticket #126, conversation rewind. `resumeSessionAt` replays the
459
+ // transcript only up to (and including) this uuid; `forkSession` makes
460
+ // the result a NEW session id instead of truncating the old file, so the
461
+ // conversation the user rewound away from is still on disk afterwards.
462
+ // The SDK's own note applies and is repeated in the UI: a forked session
463
+ // starts without undo history.
464
+ ...(spec.resumeProviderSessionId && spec.resumeAtAnchor
465
+ ? { resumeSessionAt: spec.resumeAtAnchor, forkSession: true }
466
+ : {}),
324
467
  ...(spec.maxBudgetUsd !== undefined ? { maxBudgetUsd: spec.maxBudgetUsd } : {}),
325
468
  // Ticket #119. NOT `mcpServers` — the SDK JSON-stringifies that option
326
469
  // straight into argv (`sdk.mjs`: `H.push("--mcp-config", Re({mcpServers:ke}))`),
@@ -357,6 +500,10 @@ class ClaudeSession {
357
500
  if (this.mcpFallbackNotice) {
358
501
  this.emit({ type: 'notice', level: 'warn', text: this.mcpFallbackNotice });
359
502
  }
503
+ if (this.modeRefusedAtLaunch) {
504
+ this.emit({ type: 'notice', level: 'warn', text: MODE_REFUSED_TEXT });
505
+ this.emit({ type: 'settings', mode: this.mode });
506
+ }
360
507
  this.lastTaskFingerprint = JSON.stringify({ done: 0, total: 0, tasks: [] });
361
508
  this.emit({ type: 'agent_tasks', tasks: [], done: 0, total: 0 });
362
509
  // Report what the agent can do right away. `system:init` only arrives with
@@ -560,7 +707,11 @@ class ClaudeSession {
560
707
  this.adoptLiveModel(this.liveWireModel ?? this.model);
561
708
  const capabilities = {
562
709
  models: this.knownModels,
563
- modes: [...AGENT_MODES],
710
+ // Read from `spec` on every publication, not captured once: the workspace
711
+ // trust level changes under a running session (`setWorkspacePolicy`), and
712
+ // a manager tightening to STRICT has to see «Unrestricted» leave the
713
+ // picker rather than stay there as an offer that will be refused.
714
+ modes: availableModes(this.spec.trustMode),
564
715
  // Cap the list: it lands in an event payload with a hard size limit.
565
716
  commands: commands.slice(0, 150).map((c) => ({
566
717
  name: c.name,
@@ -638,10 +789,103 @@ class ClaudeSession {
638
789
  // worst case. Fire-and-forget, same as the end-of-turn call.
639
790
  this.refreshContextUsage();
640
791
  }
792
+ /**
793
+ * Can this live process reach `mode`, or does the supervisor have to bring a
794
+ * new one up? (ticket #156)
795
+ *
796
+ * Only the `full` boundary matters, and only in the direction the CLI
797
+ * refuses. Crossing it OUTWARD works live — a bypass-launched session accepts
798
+ * `setPermissionMode('default')` and starts consulting `canUseTool` again,
799
+ * verified live — but it is answered `true` here as well, deliberately: that
800
+ * process still carries `--allow-dangerously-skip-permissions` and, as root,
801
+ * `IS_SANDBOX=1`. Leaving a session that is once more gated running with the
802
+ * ungated process's environment is the kind of residue that is correct today
803
+ * and quietly wrong after the next CLI release. One rule, both directions,
804
+ * nothing left over.
805
+ */
806
+ modeSwitchNeedsRelaunch(mode) {
807
+ if (!availableModes(this.spec.trustMode).includes(mode))
808
+ return false;
809
+ return (mode === 'full') !== this.launchedWithBypass;
810
+ }
641
811
  async setMode(mode) {
642
- await this.q.setPermissionMode(MODE_TO_PERMISSION[mode]);
812
+ if (!availableModes(this.spec.trustMode).includes(mode)) {
813
+ this.emit({ type: 'notice', level: 'warn', text: MODE_REFUSED_TEXT });
814
+ // The picker has already moved; say what the mode actually is so it moves
815
+ // back, instead of showing a setting the session is not in.
816
+ this.emit({ type: 'settings', mode: this.mode });
817
+ return;
818
+ }
819
+ // The mode is recorded whatever the CLI says (QA-128). Its permission rung
820
+ // is an optimisation; the layer that produces the cards a user sees is
821
+ // ours, and it reads `this.mode`. Leaving the field behind because the CLI
822
+ // does not offer `auto` on this model would be #157 all over again.
823
+ await this.applyPermissionMode(mode);
643
824
  this.mode = mode;
644
825
  this.emit({ type: 'settings', mode });
826
+ // Ticket #157. The cards already on screen are the reason a person reaches
827
+ // for this control in the first place — the agent has stopped and is
828
+ // waiting on one. Judging only the NEXT tool call left the visible one
829
+ // exactly where it was, which reads as «the switch did nothing».
830
+ this.releasePermissionsAllowedNow();
831
+ }
832
+ /**
833
+ * Move the live CLI to this mode's permission rung, degrading rather than
834
+ * throwing when it refuses one (QA-128).
835
+ *
836
+ * Never rethrows: the caller has already decided what the session's mode is,
837
+ * and the CLI's opinion about its own rung must not undo that.
838
+ */
839
+ async applyPermissionMode(mode) {
840
+ try {
841
+ await this.q.setPermissionMode(MODE_TO_PERMISSION[mode]);
842
+ }
843
+ catch (error) {
844
+ // Swallowed on purpose. The CLI refuses rungs per MODEL, not only per
845
+ // request — `Cannot set permission mode to auto: auto mode unavailable
846
+ // for this model`, measured on `claude-haiku-4-5`, which our own picker
847
+ // offers. Letting that throw is what the caller must never allow: the
848
+ // exception would land before `this.mode` is written, the dashboard would
849
+ // show the new mode, and the layer that raises the cards would still be
850
+ // on the old one. That is ticket #157, reproduced by its own fix.
851
+ log.warn('claude: setPermissionMode refused — the session mode still stands', {
852
+ wanted: MODE_TO_PERMISSION[mode],
853
+ error: String(error),
854
+ });
855
+ }
856
+ }
857
+ /**
858
+ * Re-judge every open permission card under the rules in force NOW, and let
859
+ * through the ones that no longer need a human (ticket #157).
860
+ *
861
+ * Only in the permissive direction: a card that becomes `deny` or stays `ask`
862
+ * is left alone. Denying something the user is looking at — and had every
863
+ * right to approve — would be taking a decision away from them, which is the
864
+ * mistake session 12 spent its whole length undoing.
865
+ */
866
+ releasePermissionsAllowedNow() {
867
+ for (const [requestId, pending] of [...this.pending]) {
868
+ if (!pending.fromPolicy)
869
+ continue;
870
+ const verdict = evaluateToolUse(pending.toolName, pending.input, {
871
+ trustMode: this.spec.trustMode,
872
+ mode: this.mode,
873
+ ...(this.spec.agentAutoCommit === undefined
874
+ ? {}
875
+ : { agentAutoCommit: this.spec.agentAutoCommit }),
876
+ worktreePath: this.spec.cwd,
877
+ });
878
+ if (verdict.decision !== 'allow')
879
+ continue;
880
+ this.emit({
881
+ type: 'permission_resolved',
882
+ requestId,
883
+ allow: true,
884
+ source: 'policy',
885
+ reason: `the session mode changed — ${verdict.reason}`,
886
+ });
887
+ pending.resolve({ behavior: 'allow', updatedInput: pending.input });
888
+ }
645
889
  }
646
890
  /**
647
891
  * Session 15: the project's trust level and auto-commit switch, changed
@@ -649,10 +893,50 @@ class ClaudeSession {
649
893
  * every tool call, so the next one already sees it.
650
894
  */
651
895
  setWorkspacePolicy(policy) {
896
+ const trustChanged = policy.trustMode !== undefined && policy.trustMode !== this.spec.trustMode;
652
897
  if (policy.trustMode !== undefined)
653
898
  this.spec.trustMode = policy.trustMode;
654
899
  if (policy.agentAutoCommit !== undefined)
655
900
  this.spec.agentAutoCommit = policy.agentAutoCommit;
901
+ if (!trustChanged)
902
+ return;
903
+ // The manager tightened to STRICT while this session was running with
904
+ // nothing gated. Waiting for a relaunch would leave the shield down for the
905
+ // rest of the turn, so this one is applied live and immediately: the CLI
906
+ // accepts a TIGHTENING from bypass on the same process (verified live), and
907
+ // from the next tool call `canUseTool` — and layer 1 with it — is consulted
908
+ // again. The process keeps the launch flags it can no longer use; being
909
+ // gated a moment sooner is worth more than that tidiness.
910
+ if (this.mode === 'full' && !availableModes(this.spec.trustMode).includes('full')) {
911
+ this.mode = 'ask';
912
+ // The WITHDRAWN sentence, not the refusal one (QA-128): nobody in this
913
+ // session asked for anything — a manager changed the project.
914
+ this.emit({ type: 'notice', level: 'warn', text: MODE_WITHDRAWN_TEXT });
915
+ this.emit({ type: 'settings', mode: this.mode });
916
+ void this.q.setPermissionMode(MODE_TO_PERMISSION[this.mode]).catch((error) => {
917
+ // The control request is the ONLY thing standing between «the manager
918
+ // set this project to Strict» and an agent that still gates nothing
919
+ // (QA-128). A warning in journald is not a response to that: if the
920
+ // process will not be tightened, it does not get to keep running.
921
+ log.warn('claude: could not tighten a full session after a STRICT switch', {
922
+ error: String(error),
923
+ });
924
+ this.emit({
925
+ type: 'notice',
926
+ level: 'warn',
927
+ text: 'This project was set to Strict trust and the running agent refused to switch its permission checks back on, so the session was stopped. Start it again to continue.',
928
+ });
929
+ this.stop('session_stopped');
930
+ });
931
+ }
932
+ // A tightening to STRICT has to take `full` out of the picker, and a
933
+ // loosening has to put it back — the list is computed from `spec` and only
934
+ // reaches the dashboard through a capabilities frame (ticket #156).
935
+ this.refreshCapabilities();
936
+ // Loosening the workspace releases what it no longer needs a human for, the
937
+ // same way a mode switch does. Tightening changes nothing here on purpose:
938
+ // this only ever lets cards through, never withdraws one.
939
+ this.releasePermissionsAllowedNow();
656
940
  }
657
941
  /**
658
942
  * Change the reasoning-effort level (tickets #111, #112).
@@ -1010,6 +1294,9 @@ class ClaudeSession {
1010
1294
  }
1011
1295
  const verdict = evaluateToolUse(toolName, input, {
1012
1296
  trustMode: this.spec.trustMode,
1297
+ // Ticket #156: the missing argument. Everything else in this object was
1298
+ // already here; the session's own mode was not, so «Auto» decided nothing.
1299
+ mode: this.mode,
1013
1300
  ...(this.spec.agentAutoCommit === undefined
1014
1301
  ? {}
1015
1302
  : { agentAutoCommit: this.spec.agentAutoCommit }),
@@ -1037,10 +1324,13 @@ class ClaudeSession {
1037
1324
  ...(opts.description ? { description: opts.description } : {}),
1038
1325
  input: truncateInput(input),
1039
1326
  });
1040
- return this.waitForAnswer(opts, toolName);
1327
+ return this.waitForAnswer(opts, toolName, input);
1041
1328
  }
1042
1329
  /** Park the tool call until the dashboard answers (or the request aborts). */
1043
- waitForAnswer(opts, toolName) {
1330
+ waitForAnswer(opts, toolName,
1331
+ // The RAW input, not the truncated copy that went on the card: this is what
1332
+ // the tool is released with, and what a later re-judgement is judged on.
1333
+ input) {
1044
1334
  return new Promise((resolve) => {
1045
1335
  const settle = (result) => {
1046
1336
  if (this.pending.delete(opts.requestId))
@@ -1048,6 +1338,8 @@ class ClaudeSession {
1048
1338
  };
1049
1339
  this.pending.set(opts.requestId, {
1050
1340
  toolName,
1341
+ input: input ?? {},
1342
+ fromPolicy: input !== undefined,
1051
1343
  resolve: settle,
1052
1344
  });
1053
1345
  opts.signal.addEventListener('abort', () => {
@@ -1110,6 +1402,7 @@ class ClaudeSession {
1110
1402
  log.warn('claude: answer for a question that is no longer open', { askId: reply.askId });
1111
1403
  return false;
1112
1404
  }
1405
+ this.resumingTurn();
1113
1406
  // Validated BEFORE the ask is consumed: an answer with nothing in it would
1114
1407
  // otherwise release the tool call with `answers: {}` — the very shape of
1115
1408
  // the auto-answer this session deleted — and leave the card unanswerable.
@@ -1219,6 +1512,7 @@ class ClaudeSession {
1219
1512
  log.warn('claude: permission answer for unknown request', { requestId });
1220
1513
  return;
1221
1514
  }
1515
+ this.resumingTurn();
1222
1516
  this.emit({
1223
1517
  type: 'permission_resolved',
1224
1518
  requestId,
@@ -1246,6 +1540,7 @@ class ClaudeSession {
1246
1540
  this.answerQuestion({ askId: openAsk, action: 'discuss', text });
1247
1541
  return;
1248
1542
  }
1543
+ this.resumingTurn();
1249
1544
  const accepted = this.input.push({
1250
1545
  type: 'user',
1251
1546
  message: { role: 'user', content: text },
@@ -1258,13 +1553,37 @@ class ClaudeSession {
1258
1553
  }
1259
1554
  }
1260
1555
  async interrupt() {
1556
+ this.aborting = true;
1261
1557
  try {
1262
1558
  await this.q.interrupt();
1263
1559
  }
1264
1560
  catch (error) {
1561
+ // The abort never reached the SDK, so any failure that arrives now is the
1562
+ // agent's own and must be reported as one.
1563
+ this.aborting = false;
1265
1564
  log.warn('claude: interrupt failed', { error: String(error) });
1266
1565
  }
1267
1566
  }
1567
+ conversationAnchor() {
1568
+ return this.lastMessageUuid;
1569
+ }
1570
+ /**
1571
+ * `/compact` — the CLI's own command, delivered as ordinary user input.
1572
+ *
1573
+ * Deliberately not routed through `send()`: that one diverts text into an
1574
+ * open question card, and a question is exactly the state in which the user
1575
+ * is most likely to look at a full context meter and press the button.
1576
+ */
1577
+ async compact() {
1578
+ if (this.stopped)
1579
+ return false;
1580
+ this.resumingTurn();
1581
+ return this.input.push({
1582
+ type: 'user',
1583
+ message: { role: 'user', content: '/compact' },
1584
+ parent_tool_use_id: null,
1585
+ });
1586
+ }
1268
1587
  stop(reason = 'session_stopped') {
1269
1588
  if (this.stopped)
1270
1589
  return;
@@ -1305,6 +1624,26 @@ class ClaudeSession {
1305
1624
  async consume() {
1306
1625
  try {
1307
1626
  for await (const msg of this.q) {
1627
+ // Ticket #126: where we are in the transcript, remembered BEFORE the
1628
+ // message is interpreted.
1629
+ //
1630
+ // ONLY a finished assistant message of the MAIN conversation. The SDK
1631
+ // stamps a uuid on roughly thirty message kinds — `system:init`, hook
1632
+ // notifications, rate-limit events, tool progress, the closing
1633
+ // `result` — and none of them is something the CLI can resume at. Its
1634
+ // own contract is explicit: «The message ID should be from
1635
+ // `SDKAssistantMessage.uuid`».
1636
+ //
1637
+ // Taking whichever came last handed the CLI, on a fresh session, the
1638
+ // uuid of `system:init` — and the next launch died with «No message
1639
+ // found with message.uuid of …», which is a FAILED session and a
1640
+ // conversation that was never actually rewound.
1641
+ //
1642
+ // `parent_tool_use_id` excludes a subagent's own messages: they live
1643
+ // inside a Task tool call, not in the conversation being resumed.
1644
+ if (msg.type === 'assistant' && msg.parent_tool_use_id === null && msg.uuid) {
1645
+ this.lastMessageUuid = msg.uuid;
1646
+ }
1308
1647
  switch (msg.type) {
1309
1648
  case 'system': {
1310
1649
  if (msg.subtype === 'init') {
@@ -1393,16 +1732,32 @@ class ClaudeSession {
1393
1732
  // otherwise, and it is the last thing anybody sees.
1394
1733
  this.endTaskTurn();
1395
1734
  this.refreshContextUsage();
1396
- if (msg.subtype === 'success') {
1397
- this.emit({ type: 'turn_end', ok: true });
1398
- }
1399
- else {
1735
+ // Consumed here whichever way the turn ended: one Stop arms this
1736
+ // once, for exactly one result.
1737
+ const aborted = this.aborting;
1738
+ this.aborting = false;
1739
+ const failure = msg.subtype === 'success' ? '' : classifyError(msg.subtype, msg.errors);
1740
+ if (failure && isRewindError(failure)) {
1741
+ // Not a failed turn — a refused resume. The CLI answers a bad
1742
+ // `resumeSessionAt` with exactly this and nothing else (no
1743
+ // `system:init`, no assistant message), so reporting it as a
1744
+ // turn failure moved the session to FAILED for a rewind that had
1745
+ // simply not been possible.
1400
1746
  this.emit({
1401
- type: 'turn_end',
1402
- ok: false,
1403
- errorMessage: classifyError(msg.subtype, msg.errors),
1747
+ type: 'error',
1748
+ message: classifyRunError(failure),
1749
+ code: 'rewind_failed',
1404
1750
  });
1405
1751
  }
1752
+ else if (msg.subtype === 'success' || aborted) {
1753
+ // A turn the user stopped is not a failed turn. Reporting it as
1754
+ // one moved the session to FAILED, which is terminal — pressing
1755
+ // Stop cost people the session they meant to keep.
1756
+ this.emit({ type: 'turn_end', ok: true, ...(aborted ? { aborted: true } : {}) });
1757
+ }
1758
+ else {
1759
+ this.emit({ type: 'turn_end', ok: false, errorMessage: failure });
1760
+ }
1406
1761
  break;
1407
1762
  }
1408
1763
  default:
@@ -1526,7 +1881,11 @@ function stringifyContent(content) {
1526
1881
  return content === undefined ? '' : JSON.stringify(content);
1527
1882
  }
1528
1883
  function classifyError(subtype, errors) {
1529
- const detail = errors.length ? `: ${maskString(errors.join('; ').slice(0, 500))}` : '';
1884
+ // Optional on purpose despite the SDK's type: a `result` without `errors`
1885
+ // threw from inside the event pump, which the loop's catch turned into a
1886
+ // plain `error` event — so the turn that failed never ENDED, and the session
1887
+ // sat at RUNNING with no way to tell.
1888
+ const detail = errors?.length ? `: ${maskString(errors.join('; ').slice(0, 500))}` : '';
1530
1889
  switch (subtype) {
1531
1890
  case 'error_max_budget_usd':
1532
1891
  return 'Session budget (USD) exceeded — the run was stopped';
@@ -1556,14 +1915,30 @@ function isAuthError(message) {
1556
1915
  function isResumeError(message) {
1557
1916
  return /no conversation found|session .{0,40}not found|could not resume|invalid session id/i.test(message);
1558
1917
  }
1918
+ /**
1919
+ * The CLI refused the point we asked it to resume at.
1920
+ *
1921
+ * Its exact wording, captured from the CLI itself: «No message found with
1922
+ * message.uuid of: <uuid>». It arrives as a `result` with no `system:init`
1923
+ * before it, and the query then throws with the same sentence — so this is
1924
+ * matched on both paths.
1925
+ */
1926
+ function isRewindError(message) {
1927
+ return /no message found with message\.uuid/i.test(message);
1928
+ }
1559
1929
  function errorCode(message) {
1560
1930
  if (isAuthError(message))
1561
1931
  return 'auth_expired';
1932
+ if (isRewindError(message))
1933
+ return 'rewind_failed';
1562
1934
  if (isResumeError(message))
1563
1935
  return 'resume_failed';
1564
1936
  return undefined;
1565
1937
  }
1566
1938
  export function classifyRunError(message) {
1939
+ if (isRewindError(message)) {
1940
+ return 'The conversation could not be rewound to that point — the agent still remembers everything after it';
1941
+ }
1567
1942
  if (isAuthError(message)) {
1568
1943
  return 'Claude authentication expired on this server — re-login is required (claude setup-token)';
1569
1944
  }