gaunt-sloth 2.0.0-beta.8 → 2.0.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.
Files changed (106) hide show
  1. package/.gsloth.review-discovery.md +19 -0
  2. package/cli.js +49 -0
  3. package/dist/cli.js +37 -3
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/adkEvalRunner.d.ts +7 -6
  6. package/dist/commands/adkEvalRunner.js +3 -3
  7. package/dist/commands/adkEvalRunner.js.map +1 -1
  8. package/dist/commands/agUiEvalRunner.d.ts +4 -4
  9. package/dist/commands/agUiEvalRunner.js +8 -7
  10. package/dist/commands/agUiEvalRunner.js.map +1 -1
  11. package/dist/commands/askCommand.js +9 -1
  12. package/dist/commands/askCommand.js.map +1 -1
  13. package/dist/commands/batchCommand.d.ts +2 -2
  14. package/dist/commands/batchCommand.js +10 -5
  15. package/dist/commands/batchCommand.js.map +1 -1
  16. package/dist/commands/commandIntrospection.d.ts +1 -1
  17. package/dist/commands/commandIntrospection.js +6 -3
  18. package/dist/commands/commandIntrospection.js.map +1 -1
  19. package/dist/commands/configCommand.d.ts +1 -1
  20. package/dist/commands/configCommand.js +1 -1
  21. package/dist/commands/configSetup.d.ts +1 -1
  22. package/dist/commands/discoveryConfigSchemaAgreement.d.ts +45 -0
  23. package/dist/commands/discoveryConfigSchemaAgreement.js +2 -0
  24. package/dist/commands/discoveryConfigSchemaAgreement.js.map +1 -0
  25. package/dist/commands/evalCommand.d.ts +27 -1
  26. package/dist/commands/evalCommand.js +155 -13
  27. package/dist/commands/evalCommand.js.map +1 -1
  28. package/dist/commands/execCommand.d.ts +1 -1
  29. package/dist/commands/execCommand.js +9 -2
  30. package/dist/commands/execCommand.js.map +1 -1
  31. package/dist/commands/firstRunDialog.d.ts +3 -3
  32. package/dist/commands/firstRunDialog.js +2 -2
  33. package/dist/commands/getCommand.js +10 -1
  34. package/dist/commands/getCommand.js.map +1 -1
  35. package/dist/commands/historyCommand.d.ts +1 -1
  36. package/dist/commands/historyCommand.js +16 -4
  37. package/dist/commands/historyCommand.js.map +1 -1
  38. package/dist/commands/prCommand.js +45 -16
  39. package/dist/commands/prCommand.js.map +1 -1
  40. package/dist/commands/prDiscovery.d.ts +24 -25
  41. package/dist/commands/prDiscovery.js +55 -132
  42. package/dist/commands/prDiscovery.js.map +1 -1
  43. package/dist/commands/requirementsDiscovery.d.ts +82 -0
  44. package/dist/commands/requirementsDiscovery.js +127 -0
  45. package/dist/commands/requirementsDiscovery.js.map +1 -0
  46. package/dist/commands/reviewCommand.js +75 -8
  47. package/dist/commands/reviewCommand.js.map +1 -1
  48. package/dist/commands/reviewDiscovery.d.ts +96 -0
  49. package/dist/commands/reviewDiscovery.js +180 -0
  50. package/dist/commands/reviewDiscovery.js.map +1 -0
  51. package/dist/tui/approvalBridges.d.ts +86 -0
  52. package/dist/tui/approvalBridges.js +147 -0
  53. package/dist/tui/approvalBridges.js.map +1 -0
  54. package/dist/tui/components/App.js +272 -27
  55. package/dist/tui/components/App.js.map +1 -1
  56. package/dist/tui/components/DebugPanel.d.ts +6 -3
  57. package/dist/tui/components/DebugPanel.js +32 -10
  58. package/dist/tui/components/DebugPanel.js.map +1 -1
  59. package/dist/tui/components/FetchProgress.d.ts +1 -1
  60. package/dist/tui/components/LaunchBanner.d.ts +4 -3
  61. package/dist/tui/components/LaunchBanner.js +8 -6
  62. package/dist/tui/components/LaunchBanner.js.map +1 -1
  63. package/dist/tui/components/LiveTurn.d.ts +15 -9
  64. package/dist/tui/components/LiveTurn.js +15 -14
  65. package/dist/tui/components/LiveTurn.js.map +1 -1
  66. package/dist/tui/components/PromptInput.d.ts +2 -2
  67. package/dist/tui/components/PromptInput.js +11 -6
  68. package/dist/tui/components/PromptInput.js.map +1 -1
  69. package/dist/tui/components/ResumePicker.d.ts +69 -0
  70. package/dist/tui/components/ResumePicker.js +93 -0
  71. package/dist/tui/components/ResumePicker.js.map +1 -0
  72. package/dist/tui/components/SelectList.d.ts +1 -1
  73. package/dist/tui/components/SelectList.js +1 -1
  74. package/dist/tui/components/SlashCommandMenu.d.ts +1 -1
  75. package/dist/tui/components/SlashCommandMenu.js +1 -1
  76. package/dist/tui/components/StatusBar.d.ts +27 -1
  77. package/dist/tui/components/StatusBar.js +13 -2
  78. package/dist/tui/components/StatusBar.js.map +1 -1
  79. package/dist/tui/debugRender.d.ts +26 -3
  80. package/dist/tui/debugRender.js +232 -2
  81. package/dist/tui/debugRender.js.map +1 -1
  82. package/dist/tui/fixtureAgent.d.ts +2 -2
  83. package/dist/tui/fixtureAgent.js +2 -2
  84. package/dist/tui/keyBindings.js +7 -1
  85. package/dist/tui/keyBindings.js.map +1 -1
  86. package/dist/tui/keyGuards.d.ts +1 -1
  87. package/dist/tui/keyGuards.js +1 -1
  88. package/dist/tui/ruleWidth.d.ts +1 -1
  89. package/dist/tui/ruleWidth.js +1 -1
  90. package/dist/tui/selectCancelled.d.ts +1 -1
  91. package/dist/tui/selectCancelled.js +1 -1
  92. package/dist/tui/transcriptWindow.js +7 -1
  93. package/dist/tui/transcriptWindow.js.map +1 -1
  94. package/dist/tui/tuiSessionModule.js +89 -190
  95. package/dist/tui/tuiSessionModule.js.map +1 -1
  96. package/dist/tui/types.d.ts +77 -18
  97. package/dist/tui/useMouse.d.ts +1 -1
  98. package/dist/tui/useMouse.js +1 -1
  99. package/dist/tui/useSlothAnimation.d.ts +1 -1
  100. package/dist/tui/viewModel.d.ts +64 -12
  101. package/dist/tui/viewModel.js +43 -11
  102. package/dist/tui/viewModel.js.map +1 -1
  103. package/dist/utils/configErrorGuard.d.ts +11 -3
  104. package/dist/utils/configErrorGuard.js +11 -3
  105. package/dist/utils/configErrorGuard.js.map +1 -1
  106. package/package.json +18 -18
@@ -5,6 +5,9 @@ import { foldEvents, foldSubagentEvents, initialSubagentTree, initialTurnViewMod
5
5
  import { buildRejectionMessage } from '@gaunt-sloth/core/core/shell/rejection.js';
6
6
  import { ApprovalStopError } from '@gaunt-sloth/core/core/shell/approvalStop.js';
7
7
  import { shouldAnnounceTermination } from '@gaunt-sloth/core/core/terminationNotice.js';
8
+ import { runEndReport } from '@gaunt-sloth/core/core/runRecap.js';
9
+ import { providerErrorNotice } from '@gaunt-sloth/core/core/providerErrorNotice.js';
10
+ import { readWaitNarration } from '@gaunt-sloth/core/core/waitNarration.js';
8
11
  import { attackBannerCopy, grantsRunAnyway, } from '@gaunt-sloth/core/core/shell/escalationSeverity.js';
9
12
  import { TranscriptViewport } from '#src/tui/components/TranscriptViewport.js';
10
13
  import { ApprovalPrompt } from '#src/tui/components/ApprovalPrompt.js';
@@ -22,15 +25,23 @@ import { ClearBanner } from '#src/tui/components/ClearBanner.js';
22
25
  import { LaunchBanner } from '#src/tui/components/LaunchBanner.js';
23
26
  import { MouseProvider } from '#src/tui/useMouse.js';
24
27
  import { DebugPanel, debugPanelLines, debugPanelRows, DEBUG_TABS, } from '#src/tui/components/DebugPanel.js';
28
+ import { renderApprovalDetails } from '#src/tui/debugRender.js';
25
29
  import { approvalPostureChoices, approvalsRungNotice, approvalsRefusalsNotice, approvalsStatusNotice, approvalsTrustNotice, approvalsUndenyNotice, COMPACTING_LINE, compactionFailedNotice, autocompactNotice, autocompactUnavailableNotice, compactionNotice, compactionUnavailableNotice, createCommandRegistry, dispatchSlashCommand, parseSlashCommand, toolsToggleNotice, } from '@gaunt-sloth/agent/modules/slashCommands.js';
26
- import { resumedConversationNotice, resumeFailedNotice, resumePickerNotice, resumeRefusalNotice, resumeSameConversationNotice, resumeUnavailableNotice, } from '@gaunt-sloth/agent/modules/sessionResume.js';
30
+ import { resumableConversationsNotice, resumedConversationNotice, resumeFailedNotice, resumeRefusalNotice, resumeSameConversationNotice, resumeUnavailableNotice, } from '@gaunt-sloth/agent/modules/sessionResume.js';
27
31
  import { ApprovalsPicker } from '#src/tui/components/ApprovalsPicker.js';
32
+ import { ResumePicker, resumeCancelledNotice } from '#src/tui/components/ResumePicker.js';
28
33
  import { TerminalSizeProvider, useTerminalSize } from '#src/tui/useTerminalSize.js';
29
34
  import { findMatches, scrollOffsetForLine, stepMatch } from '#src/tui/debugSearch.js';
30
35
  import { useTranscriptScroll } from '#src/tui/useTranscriptScroll.js';
31
36
  import { isComposingKeystroke, WHEEL_ROWS_PER_NOTCH } from '#src/tui/transcriptScroll.js';
32
37
  import { TUI_HINT_SUFFIX, TUI_KEY_BINDINGS } from '#src/tui/keyBindings.js';
33
38
  import { isTypedText, typedText } from '#src/tui/keyGuards.js';
39
+ /**
40
+ * The empty line list a debug tab that is not on screen hands the panel. A module-level constant so
41
+ * its identity is stable across renders: a fresh `[]` each time would make the memo that clamps the
42
+ * scroll offset recompute on every render of every other tab.
43
+ */
44
+ const NO_DEBUG_LINES = [];
34
45
  /** Rows of clipping viewport in the docked debug panel (default / restored size). */
35
46
  const DEBUG_VIEWPORT_HEIGHT = 8;
36
47
  /** Rows of chrome around the debug viewport we leave for when it is maximised (panel
@@ -128,6 +139,15 @@ export function App(props) {
128
139
  const [transcript, setTranscript] = useState(seeded);
129
140
  const [live, setLive] = useState(null);
130
141
  const [running, setRunning] = useState(false);
142
+ /**
143
+ * [[EXT-92]] scope (a) — what the run is waiting on, when that is not the model answering.
144
+ *
145
+ * Held as the whole rendered sentence rather than an activity code, because the words are
146
+ * `core/waitNarration.ts`'s to choose: it is the module that reaches every surface, and a second
147
+ * spelling of the same wait on this one is the drift the exported prefix exists to prevent. The
148
+ * status bar renders it in place of its own default label, so this costs no row.
149
+ */
150
+ const [waitActivity, setWaitActivity] = useState(undefined);
131
151
  const [turnCount, setTurnCount] = useState(props.resumed?.turns.length ?? 0);
132
152
  // Subagent tree, folded from `task` tool calls on the live event stream. No backend dispatches
133
153
  // subagents in this release, so nothing fills it today; kept for GS2-25's lean primitive.
@@ -169,6 +189,11 @@ export function App(props) {
169
189
  // chrome: unlike the status notice it is never committed to the transcript, and it owns the
170
190
  // keyboard while mounted (the prompt is suspended below, as it is for a pending approval).
171
191
  const [approvalsPicker, setApprovalsPicker] = useState(null);
192
+ // GS2-112 — the open bare-`/resume` picker's candidates, or null when none is showing. Transient
193
+ // chrome on the same terms as the approvals picker above: never committed to the transcript, and
194
+ // it owns the keyboard while mounted. The list is the snapshot taken when the picker opened; a
195
+ // landed resume replaces the screen, so the picker is closed before the choice is applied.
196
+ const [resumePicker, setResumePicker] = useState(null);
172
197
  // Whether to show the post-`/clear` "history cleared" banner. Hidden again the moment the next
173
198
  // user turn starts so it doesn't linger above a fresh conversation.
174
199
  const [clearedBanner, setClearedBanner] = useState(false);
@@ -221,6 +246,14 @@ export function App(props) {
221
246
  // GS2-20 — starts past the seeded items so a later push never reuses a seeded key.
222
247
  const idRef = useRef(seeded.length);
223
248
  const runningRef = useRef(false);
249
+ /**
250
+ * [[EXT-92]] scope (e) — whether the turn now ending was ended by a throw.
251
+ *
252
+ * A ref rather than state because the only reader is the `finally` of the same async function
253
+ * the catch runs in: a `setState` would not be visible there, and the fact has to be true at the
254
+ * instant the turn is committed, not on the next render.
255
+ */
256
+ const turnErroredRef = useRef(false);
224
257
  /**
225
258
  * GS2-23 — whether a `/compact` is awaiting its summary. Read where `runningRef` is read at
226
259
  * submit, so a plain message cannot start a turn on a thread that is about to be rewritten, and
@@ -249,6 +282,22 @@ export function App(props) {
249
282
  */
250
283
  const conversationIdRef = useRef(props.conversationId);
251
284
  const turnCountRef = useRef(props.resumed?.turns.length ?? 0);
285
+ /**
286
+ * [[EXT-178]] — which conversation the outstanding recap request still belongs to.
287
+ *
288
+ * The recap is a model call launched detached at the end of a turn, so up to
289
+ * `RUN_RECAP_TIMEOUT_MS` can pass between asking and answering, and the session does not stand
290
+ * still for it: `/clear` empties the transcript the recap is describing, and a new turn can
291
+ * start and finish inside the window. Appending then puts a summary of a wiped conversation onto
292
+ * a blank screen, or files the previous turn's recap under this one.
293
+ *
294
+ * So the request carries the epoch it was made in and drops itself if the epoch has moved. It is
295
+ * bumped by exactly the three events that make a pending recap wrong: a new turn starting,
296
+ * `/clear`, and this component unmounting. It is deliberately NOT `turnCountRef`, which counts
297
+ * turns for the status bar, is incremented after the recap is launched, and is reset to 0 by
298
+ * `/clear` — a counter that goes backwards cannot be compared for staleness.
299
+ */
300
+ const recapEpochRef = useRef(0);
252
301
  // Per-turn args buffers for the subagent fold (mirrors foldSubagentTree's internal map).
253
302
  const subagentBuffersRef = useRef(new Map());
254
303
  const debugFocusedRef = useRef(false);
@@ -260,10 +309,11 @@ export function App(props) {
260
309
  // inside <PromptInput>, where it lives.
261
310
  const promptHandleRef = useRef(null);
262
311
  // TUI-C51 — the one thing about the prompt that has to outlive it: the message a command was
263
- // dispatched beside. The prompt is unmounted in four states below, and one of them (`/approvals`
264
- // opening its picker) is entered by a command dispatched FROM the prompt — so the draft is put
265
- // here, where nothing unmounts, and the prompt takes it back when it returns. It stays a slot
266
- // rather than state: nothing here renders it, and the prompt empties it as soon as it has it.
312
+ // dispatched beside. The prompt is unmounted in five states below, and two of them (`/approvals`
313
+ // and a bare `/resume` opening their pickers) are entered by a command dispatched FROM the
314
+ // prompt — so the draft is put here, where nothing unmounts, and the prompt takes it back when
315
+ // it returns. It stays a slot rather than state: nothing here renders it, and the prompt empties
316
+ // it as soon as it has it.
267
317
  const promptDraftCarryRef = useRef(null);
268
318
  // TUI-C79 — set the moment a teardown starts, so a second `quit()` can tell "leaving" from
269
319
  // "already leaving" and skip straight to the unmount. See `quit()`.
@@ -283,6 +333,18 @@ export function App(props) {
283
333
  const debugViewport = debugViewportHeight(debugMaximized, terminalRows);
284
334
  // PageUp/PageDown step tracks the live viewport so maximise pages by (almost) a full screen.
285
335
  const debugPageStep = Math.max(1, debugViewport - 1);
336
+ // [[TUI-C27]] — the Auto-mode tab's lines: the approvals gate's own record of every gated tool
337
+ // call, plus the rater config in force (already held here, since `/approvals` moves it).
338
+ //
339
+ // **Read fresh on every render while that tab is on screen, deliberately, and memoised on
340
+ // nothing.** The decision this tab exists for emits no event: a call the rater approves on its
341
+ // first rating relays nothing to any surface — the tool simply runs — so anything keyed on the
342
+ // approval, negotiation or turn signals the App already has would look live and miss exactly it.
343
+ // A pull costs one render of a capped (50-record) buffer, and only while a user is looking at
344
+ // the tab; every other tab keeps the stable empty array and pays nothing.
345
+ const debugAuto = debugVisible && debugTab === 'auto'
346
+ ? renderApprovalDetails(props.readApprovalCaptures?.() ?? [], approvals).split('\n')
347
+ : NO_DEBUG_LINES;
286
348
  // The active section's line count drives the real maximum scroll offset, so neither the page
287
349
  // step nor the arrow step can push the offset past the end (the over-scroll bug that left
288
350
  // PgUp/↑ burning through phantom offset before anything moved — TUI-C11). Single-sourced with
@@ -294,8 +356,9 @@ export function App(props) {
294
356
  toolsLines: debugTools,
295
357
  mcpLines: debugMcp,
296
358
  responseLines: debugResponse,
359
+ autoLines: debugAuto,
297
360
  activeTab: debugTab,
298
- }), [subagents, debugHistory, debugSystem, debugTools, debugMcp, debugResponse, debugTab]);
361
+ }), [subagents, debugHistory, debugSystem, debugTools, debugMcp, debugResponse, debugAuto, debugTab]);
299
362
  const debugLineCount = debugLines.length;
300
363
  const debugMaxOffset = Math.max(0, debugLineCount - debugViewport);
301
364
  // Match line-indices for the current query over the active tab's lines — one search, every tab
@@ -394,6 +457,8 @@ export function App(props) {
394
457
  // A new exchange supersedes the post-/clear banner; drop it so it doesn't sit above
395
458
  // the fresh conversation.
396
459
  setClearedBanner(false);
460
+ // [[EXT-178]] — and it supersedes the previous turn's recap, which may still be in flight.
461
+ recapEpochRef.current += 1;
397
462
  push({ kind: 'user', text: userInput });
398
463
  // [[TUI-C69]] §5.4 — a new user turn is a person being reached, which ENDS any negotiation
399
464
  // still standing (the runner clears its own transcript on the same event). Rounds from the
@@ -403,11 +468,21 @@ export function App(props) {
403
468
  abortRef.current = ac;
404
469
  runningRef.current = true;
405
470
  setRunning(true);
471
+ // [[EXT-92]] — both per-turn facts start clean. `turnErroredRef` (scope e) is set by the
472
+ // catch and read by the `finally`; `waitActivity` (scope a) is a label the status bar may
473
+ // still be holding from the previous turn's last wait.
474
+ turnErroredRef.current = false;
475
+ setWaitActivity(undefined);
406
476
  setLiveTurn(initialTurnViewModel());
407
477
  // Fresh args-buffer map per turn so partial `task` JSON from a prior turn never bleeds in.
408
478
  subagentBuffersRef.current = new Map();
409
479
  try {
410
480
  for await (const event of agent.runTurn(userInput, ac.signal)) {
481
+ // [[EXT-92]] scope (a) — an event arriving IS the wait ending. Clearing here rather than
482
+ // waiting for a paired "done" narration is what keeps the core signal one-way and the
483
+ // plain surface to exactly one line per wait: there is no end message to emit, so there
484
+ // is none to print. The status bar falls back to its own default label.
485
+ setWaitActivity(undefined);
411
486
  // [[TUI-C99]] — folded from the REF, not from a local: an approval answered mid-turn
412
487
  // writes the outcome onto its tool call from a keystroke, and a local carried across the
413
488
  // loop would overwrite it on the very next event the resumed run produces.
@@ -427,16 +502,43 @@ export function App(props) {
427
502
  push({ kind: 'stop', parts: err.parts });
428
503
  }
429
504
  else {
505
+ // [[EXT-92]] scope (b) — a provider error is rendered as prose here, and the raw payload
506
+ // rides along in a field this item's renderer never prints.
507
+ //
508
+ // The whole defect was on this line. `err.message` for an OpenRouter refusal is
509
+ // `Provider returned error | metadata: {…}` — upstream flattens its own structured
510
+ // metadata into the message — so the user read a JSON blob while the provider's own
511
+ // sentence, its remedy hint, and the fact that it was a SHARED pool rather than their
512
+ // quota all sat unread inside it. Nothing had been lost: the same fields are still
513
+ // objects on `err`, which is why this needed a renderer rather than a parser.
514
+ // `providerErrorNotice` returns null for the ordinary runtime errors it has nothing to
515
+ // add to, and those keep exactly the rendering they had.
516
+ const notice = providerErrorNotice(err);
430
517
  push({
431
518
  kind: 'system',
432
519
  level: 'error',
433
- text: err instanceof Error ? err.message : String(err),
520
+ text: notice?.text ?? (err instanceof Error ? err.message : String(err)),
521
+ ...(notice ? { raw: notice.raw } : {}),
434
522
  });
435
523
  }
524
+ // [[EXT-92]] scope (e) — remember that this turn was ended by the failure above, so the
525
+ // `finally` can commit it as a turn that STOPPED rather than one that finished. An
526
+ // approvals stop is deliberately excluded: it already commits its own `stop` item saying
527
+ // so in full, and marking the turn as well would state the same ending twice.
528
+ if (!(err instanceof ApprovalStopError))
529
+ turnErroredRef.current = true;
436
530
  }
437
531
  finally {
532
+ // [[EXT-92]] scope (a) — the turn is over, so nothing is being waited on. In the `finally`
533
+ // because the endings that most need it are the ones that reach neither the loop's normal
534
+ // exit nor the catch: a turn cancelled mid-rating would otherwise leave the bar naming a
535
+ // wait that no longer exists.
536
+ setWaitActivity(undefined);
438
537
  const vm = liveVmRef.current ?? initialTurnViewModel();
439
- push({ kind: 'assistant', turn: vm });
538
+ push({
539
+ kind: 'assistant',
540
+ turn: turnErroredRef.current ? { ...vm, endedInError: true } : vm,
541
+ });
440
542
  // [[EXT-159]] — and, immediately under the answer, why the turn ended when that is not
441
543
  // simply "the model finished".
442
544
  //
@@ -454,6 +556,50 @@ export function App(props) {
454
556
  const reason = agent.getTerminationReason?.() ?? null;
455
557
  if (reason && shouldAnnounceTermination(reason))
456
558
  push({ kind: 'termination', reason });
559
+ // [[EXT-158]] — and the other silence: a turn that ended cleanly with its own checklist
560
+ // still carrying work. `shouldAnnounceTermination` declines `completed`, correctly — an
561
+ // ordinary answer needs no epitaph — which is exactly why this case had nothing at all.
562
+ //
563
+ // Committed as an ordinary `notice` item rather than a transcript kind of its own. The
564
+ // termination item earns its kind by carrying the REASON value so the transcript holds
565
+ // the classification rather than a rendered line; this fact has no such closed taxonomy
566
+ // to preserve, and a new kind would buy a second row-count case and a second renderer for
567
+ // a title and three lines the generic notice already draws.
568
+ //
569
+ // [[EXT-178]] — and its successor when the user has switched the recap on. The two speak
570
+ // for the same population, so exactly one of them is drawn: `runEndReport` decides, with
571
+ // the recap already resolved, and a recap that never arrived leaves the notice standing.
572
+ //
573
+ // **Detached from the turn's teardown, and that is the whole reason it is down here.**
574
+ // A recap is a model call made after the answer is already on screen; awaiting it in this
575
+ // `finally` would keep the spinner running and the prompt away for as long as a provider
576
+ // takes to reply, which is a worse thing to do to a finished turn than a late paragraph.
577
+ // The transcript is append-only and this lands at the bottom, where the reader is. With
578
+ // the default `off` rung nothing is contacted and the notice lands as it always did.
579
+ const work = agent.getOutstandingWork?.() ?? null;
580
+ const ending = reason;
581
+ // Detached means the session can move on underneath it, so the request carries the epoch
582
+ // it was made in and says nothing if `recapEpochRef` has moved on — see that ref.
583
+ const epoch = recapEpochRef.current;
584
+ void (async () => {
585
+ try {
586
+ const recap = (await agent.requestRunRecap?.(ending)) ?? null;
587
+ if (recapEpochRef.current !== epoch)
588
+ return;
589
+ const report = runEndReport(recap, work, ending);
590
+ if (report.kind !== 'silent') {
591
+ push({
592
+ kind: 'notice',
593
+ title: report.notice.title,
594
+ lines: report.notice.lines,
595
+ tone: report.kind === 'recap' ? 'info' : 'warn',
596
+ });
597
+ }
598
+ }
599
+ catch {
600
+ /* fail-soft: explaining a turn must never be what breaks the session */
601
+ }
602
+ })();
457
603
  }
458
604
  catch {
459
605
  /* fail-soft: explaining a turn must never be what breaks the session */
@@ -715,7 +861,31 @@ export function App(props) {
715
861
  // replaces the screen: the transcript on it belonged to the conversation being left, and the
716
862
  // model is now continuing another one, so the banner and the restored turns take its place —
717
863
  // the same wipe `/clear` does, without the cleared banner, because nothing was discarded (the
718
- // conversation left keeps everything recorded under it). Bare, it lists what could be resumed.
864
+ // conversation left keeps everything recorded under it). Bare, this surface offers the picker
865
+ // below; the readline session prints the list.
866
+ //
867
+ // GS2-112 — **why the interactive picker is one branch here and not a new protocol field.**
868
+ //
869
+ // `/resume` returns `resume?: { id?: number }`, and the missing id is not an omission to be
870
+ // filled in later: the command is pure and cannot reach the store or the runner, so the no-id
871
+ // case has always meant *the surface fulfils this*, which is what every other surface already
872
+ // does with it. The Ink TUI can show a list and read arrow keys; the readline session can print
873
+ // one; a server surface has no user at a keyboard at all. Choosing is therefore the surface's
874
+ // answer to the same request, and nothing in the request needs to change for this surface to
875
+ // give a better one.
876
+ //
877
+ // The alternative was weighed and rejected rather than overlooked. A cross-package chooser — a
878
+ // new result field carrying candidates, or a `chooseConversation` callback threaded from the
879
+ // agent down into the component — would put a widget's contract in the module both surfaces
880
+ // read from, to serve one surface, and would still leave this branch deciding when to open it.
881
+ // It buys nothing: the candidates are already here (`props.listResumeCandidates`, wired in
882
+ // `tuiSessionModule`), and the id the user picks re-enters the with-id path below, which is the
883
+ // same code `--resume <id>` runs at boot. One branch, one new component, no new protocol.
884
+ //
885
+ // **The readline surface keeps the printed list deliberately.** It is not a fallback that has
886
+ // been left behind: with no Ink tree there is nothing to arrow through, and the printed ids
887
+ // remain the only way in on that surface — so `resumableConversationsNotice` stays its list
888
+ // builder, and no picker wording is allowed to leak into it (pinned by a spec).
719
889
  const applyResume = useCallback(async (id) => {
720
890
  const commit = (notice) => push({
721
891
  kind: 'notice',
@@ -724,11 +894,23 @@ export function App(props) {
724
894
  tone: notice.tone ?? 'info',
725
895
  });
726
896
  if (id === undefined) {
897
+ // A session with no store behind it cannot list anything, let alone offer it: this prop is
898
+ // wired only where history is on and a store opened, so its absence is the same "resume is
899
+ // unavailable here" the with-id path reports below.
727
900
  if (!props.listResumeCandidates) {
728
901
  commit(resumeUnavailableNotice());
729
902
  return;
730
903
  }
731
- commit(resumePickerNotice(props.listResumeCandidates()));
904
+ const candidates = props.listResumeCandidates();
905
+ // With nothing to offer, say so and open nothing. An empty <SelectList> renders a "no
906
+ // matches" line and its Enter is inert, so a picker here would be a modal the user can
907
+ // only escape from — strictly worse than the sentence, which also explains what makes a
908
+ // conversation resumable.
909
+ if (candidates.length === 0) {
910
+ commit(resumableConversationsNotice(candidates));
911
+ return;
912
+ }
913
+ setResumePicker(candidates);
732
914
  return;
733
915
  }
734
916
  if (!agent.resumeConversation) {
@@ -1049,6 +1231,10 @@ export function App(props) {
1049
1231
  conversationId: conversationIdRef.current,
1050
1232
  toolsExpanded: toolsExpandedRef.current,
1051
1233
  debugVisible: debugVisibleRef.current,
1234
+ // TUI-C96 — this surface has the slash-command menu (<PromptInput> renders it
1235
+ // unconditionally), so the unknown-command notice may explain a shortened name by
1236
+ // pointing at it. The readline surface says false and gets the other sentence.
1237
+ hasSlashMenu: true,
1052
1238
  // TUI-C37 — undefined (not false) when this surface has no mouse layer, so `/mouse`
1053
1239
  // says it is unavailable rather than silently reporting "off".
1054
1240
  mouseEnabled: props.mouseEnabled === undefined ? undefined : mouseEnabledRef.current,
@@ -1059,6 +1245,10 @@ export function App(props) {
1059
1245
  // TUI-C19 — the actual validation warnings so `/config` renders the details the
1060
1246
  // standing advisory line points at.
1061
1247
  configWarnings: props.advisories,
1248
+ // GS2-88 — the three summaries AND the reason they may be empty, so a command with
1249
+ // nothing to show names the cause the session actually established rather than the
1250
+ // config. Both surfaces pass all four, from the one core builder.
1251
+ historyAvailability: props.historyAvailability,
1062
1252
  historySummary: props.historySummary,
1063
1253
  insightsSummary: props.insightsSummary,
1064
1254
  historySearch: props.historySearch,
@@ -1097,11 +1287,19 @@ export function App(props) {
1097
1287
  // thread intact (the LangGraph checkpointer replays it on the next turn), so the
1098
1288
  // model would still "remember" everything. Reset the agent's thread too so the
1099
1289
  // model context truly matches the now-empty transcript (TUI-C8).
1100
- agent.resetThread?.();
1290
+ //
1291
+ // [[EXT-109]] — and the agent's own record of the conversation goes with it: the
1292
+ // approvals gate's capture log is what the Auto-mode tab above and a later
1293
+ // `/debug-dump` read, and every other pane cleared here would otherwise sit beside one
1294
+ // that still quotes the user's pre-`/clear` messages.
1295
+ agent.clearConversation?.();
1101
1296
  // The status-bar turn counter is part of the conversation state we just wiped, so
1102
1297
  // reset it too — a cleared session starts back at "turns: 0".
1103
1298
  turnCountRef.current = 0;
1104
1299
  setTurnCount(0);
1300
+ // [[EXT-178]] — a recap still in flight describes the conversation just wiped, so drop
1301
+ // it rather than append a summary of nothing onto a cleared screen.
1302
+ recapEpochRef.current += 1;
1105
1303
  }
1106
1304
  if (result.toggleTools) {
1107
1305
  // Flip the fold mode and commit the notice via the shared helper (single-sourced with
@@ -1206,7 +1404,7 @@ export function App(props) {
1206
1404
  // path never registers gsloth's readline Esc handler, so Ink owns raw mode cleanly).
1207
1405
  // 3. Tab while the panel is visible and idle → focus/cycle the panel.
1208
1406
  // 4. While the panel is focused → Tab/Shift+Tab cycle sections, ↑/↓ scroll one line,
1209
- // PageUp/PageDown page-step, m maximises, Esc unfocuses.
1407
+ // PageUp/PageDown page-step, m maximises, Esc leaves the pane (restoring a maximised one).
1210
1408
  useInput((input, key) => {
1211
1409
  // [[TUI-C79]] — **Ctrl+C is this app's, not Ink's**, and it is answered here before anything
1212
1410
  // else can claim it. `render()` takes `exitOnCtrlC: false` (`tuiSessionModule.tsx`), which is
@@ -1227,13 +1425,19 @@ export function App(props) {
1227
1425
  // bare `exit` keyword all take it too).
1228
1426
  //
1229
1427
  // **The modal states are answered first, and they exit.** While an attack banner, an approval
1230
- // prompt or the approvals picker owns the keyboard, a turn IS in flight, so rung 2 would read
1231
- // "stop the turn" where the banner's own controls line says `Ctrl+C exits gth` and the user is
1232
- // looking for the way out of an irreversible action. So it exits, as the screen promises — and
1233
- // it goes through `quit()`, which resolves both bridges fail-closed on the way out rather than
1234
- // leaving the halt unanswered.
1428
+ // prompt or one of the pickers (`/approvals`, bare `/resume`) owns the keyboard, the prompt is
1429
+ // unmounted, so rung 1 has no buffer to scrap; and where that modal state was reached during a
1430
+ // turn, rung 2 would read "stop the turn" while the banner's own controls line says `Ctrl+C
1431
+ // exits gth` and the user is looking for the way out of an irreversible action. So it exits, as
1432
+ // the screen promises — and it goes through `quit()`, which resolves both bridges fail-closed
1433
+ // on the way out rather than leaving the halt unanswered.
1434
+ //
1435
+ // GS2-112 — the resume picker is in this set for coherence rather than for a case it changes
1436
+ // today: `/resume` is idle-only and the prompt is unmounted while its picker is up, so no turn
1437
+ // can be started under it and the ladder would fall to rung 3 and exit anyway. Naming it here
1438
+ // is what keeps that answer from depending on which rung happens to catch it.
1235
1439
  if (key.ctrl && input === 'c') {
1236
- if (pendingAttack || pendingApproval || approvalsPicker) {
1440
+ if (pendingAttack || pendingApproval || approvalsPicker || resumePicker) {
1237
1441
  quit();
1238
1442
  return;
1239
1443
  }
@@ -1369,11 +1573,11 @@ export function App(props) {
1369
1573
  resolveApproval('reject');
1370
1574
  return;
1371
1575
  }
1372
- // CFG-39 — while the `/approvals` picker is open it OWNS the keyboard, exactly as a pending
1373
- // approval does. <SelectList> is a useInput subscriber too and Ink runs every subscriber, so
1374
- // without this the arrow keys would also scroll the transcript and Esc would both cancel the
1375
- // picker and jump to the newest output. Returning here leaves the picker the only claimant.
1376
- if (approvalsPicker)
1576
+ // CFG-39, GS2-112 — while a picker is open it OWNS the keyboard, exactly as a pending approval
1577
+ // does. <SelectList> is a useInput subscriber too and Ink runs every subscriber, so without
1578
+ // this the arrow keys would also scroll the transcript and Esc would both cancel the picker and
1579
+ // jump to the newest output. Returning here leaves the picker the only claimant.
1580
+ if (approvalsPicker || resumePicker)
1377
1581
  return;
1378
1582
  if (key.escape && runningRef.current) {
1379
1583
  abortRef.current?.abort();
@@ -1437,7 +1641,17 @@ export function App(props) {
1437
1641
  }
1438
1642
  return;
1439
1643
  }
1440
- // Esc layering: clear an active search first (less-style), else unfocus the pane.
1644
+ // Esc layering: clear an active search first (less-style), else LEAVE the pane — and leaving
1645
+ // it drops the maximised view as well as focus, in the one press.
1646
+ //
1647
+ // TUI-C107 — focus and maximisation are one state as far as an exit is concerned, so an exit
1648
+ // that dropped only one of them left the user with nothing to press. The prompt is unmounted
1649
+ // while the pane holds focus (the render's `!debugFocused` guard), and a maximised pane takes
1650
+ // the terminal height minus DEBUG_MAX_CHROME_ROWS, so the prompt has no rows to draw in even
1651
+ // once focus is gone: dropping focus alone left the session with no visible prompt, and `m` —
1652
+ // the key that would undo the maximisation — is handled inside this focused-only branch, so
1653
+ // it no longer answered either. Same pairing, and the same reason, as the /debug toggle's
1654
+ // "focus never lingers on a hidden panel" above.
1441
1655
  if (key.escape) {
1442
1656
  if (debugSearchQueryRef.current) {
1443
1657
  clearDebugSearch();
@@ -1445,6 +1659,7 @@ export function App(props) {
1445
1659
  }
1446
1660
  setDebugFocused(false);
1447
1661
  debugFocusedRef.current = false;
1662
+ setDebugMaximized(false);
1448
1663
  return;
1449
1664
  }
1450
1665
  // `/` opens the search input (scoped to pane focus — the seam).
@@ -1588,6 +1803,12 @@ export function App(props) {
1588
1803
  };
1589
1804
  // eslint-disable-next-line react-hooks/exhaustive-deps
1590
1805
  }, []);
1806
+ // [[EXT-178]] — the third thing that invalidates a recap still in flight: this component going
1807
+ // away. A quit unmounts while a provider may still be holding the request open, and the reply
1808
+ // would then land on a session the user has left.
1809
+ useEffect(() => () => {
1810
+ recapEpochRef.current += 1;
1811
+ }, []);
1591
1812
  // Route agent status updates (warnings/errors) into the transcript instead of stdout,
1592
1813
  // which would otherwise corrupt Ink's frame. INFO/DEBUG system lines are suppressed in the
1593
1814
  // TUI: the agent's per-turn chatter (`Requested tools`, `Loaded tools`, `Workdir`, `Model`,
@@ -1598,6 +1819,17 @@ export function App(props) {
1598
1819
  if (!props.subscribeStatus)
1599
1820
  return;
1600
1821
  return props.subscribeStatus((level, message) => {
1822
+ // [[EXT-92]] scope (a) — a wait narration is INFO, and INFO is exactly what this handler
1823
+ // drops, so it is picked out BEFORE the filter and routed to the status bar instead of the
1824
+ // transcript. That is the right destination on both counts: it is a transient fact about the
1825
+ // present moment, which the transcript is the wrong shape for, and the bar is already the
1826
+ // TUI's wait renderer. Recognised by the prefix `core/waitNarration.ts` exports for this,
1827
+ // rather than by a copy of the wording here.
1828
+ const narration = readWaitNarration(message);
1829
+ if (narration !== null) {
1830
+ setWaitActivity(narration);
1831
+ return;
1832
+ }
1601
1833
  if (level === 'INFO' || level === 'DEBUG')
1602
1834
  return;
1603
1835
  if (message.trim())
@@ -1679,10 +1911,19 @@ export function App(props) {
1679
1911
  : 0) -
1680
1912
  (activeChecklist ? checklistPanelRows(activeChecklist) : 0) -
1681
1913
  negotiationPanelRows(negotiationRounds, terminalColumns);
1682
- return (_jsx(TerminalSizeProvider, { size: terminalSize, children: _jsx(MouseProvider, { subscribe: props.subscribeMouse, enabled: mouseEnabled, children: _jsxs(Box, { flexDirection: "column", height: terminalRows, children: [_jsxs(TranscriptViewport, { items: transcript, budgetRows: terminalRows, columns: terminalColumns, toolsExpanded: toolsExpanded, scroll: transcriptScroll, regionRows: transcriptRegionRows, geometry: transcriptGeometry, children: [clearedBanner ? _jsx(ClearBanner, {}) : null, showIntro && props.showLaunchBanner ? (_jsx(LaunchBanner, { model: modelDisplayName, provider: props.modelProviderType })) : null, showIntro ? _jsx(Text, { dimColor: true, children: readyMessage.trim() }) : null, live && transcript.length > 0 ? _jsx(BlankRow, {}) : null, live ? (_jsx(LiveTurn, { turn: live, toolsExpanded: toolsExpanded, streaming: true, columns: terminalColumns })) : null, pendingApproval ? (_jsxs(_Fragment, { children: [live || transcript.length > 0 ? _jsx(BlankRow, {}) : null, _jsx(ApprovalRequestPanel, { pending: pendingApproval.pending, columns: terminalColumns })] })) : null] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [debugVisible ? (_jsx(DebugPanel, { subagents: subagents, historyLines: debugHistory, systemLines: debugSystem, toolsLines: debugTools, mcpLines: debugMcp, responseLines: debugResponse, activeTab: debugTab, scrollOffset: debugScroll, focused: debugFocused, viewportHeight: debugViewport, maximized: debugMaximized, searchQuery: debugSearchQuery, searchActive: debugSearchInput, matchCount: debugMatches.length, currentMatchIndex: debugSearchCurrent, currentMatchLine: currentMatchLine })) : null, activeChecklist ? _jsx(ChecklistPanel, { items: activeChecklist }) : null, _jsx(NegotiationPanel, { rounds: negotiationRounds }), pendingAttack ? _jsx(AttackBanner, { halt: pendingAttack.halt, typed: attackTyped }) : null, pendingApproval && !pendingAttack ? (_jsx(ApprovalPrompt, { pending: pendingApproval.pending })) : null, approvalsPicker && !pendingApproval && !pendingAttack ? (_jsx(ApprovalsPicker, { choices: approvalsPicker, onSelect: (rung) => {
1914
+ return (_jsx(TerminalSizeProvider, { size: terminalSize, children: _jsx(MouseProvider, { subscribe: props.subscribeMouse, enabled: mouseEnabled, children: _jsxs(Box, { flexDirection: "column", height: terminalRows, children: [_jsxs(TranscriptViewport, { items: transcript, budgetRows: terminalRows, columns: terminalColumns, toolsExpanded: toolsExpanded, scroll: transcriptScroll, regionRows: transcriptRegionRows, geometry: transcriptGeometry, children: [clearedBanner ? _jsx(ClearBanner, {}) : null, showIntro && props.showLaunchBanner ? (_jsx(LaunchBanner, { model: modelDisplayName, provider: props.modelProviderType })) : null, showIntro ? _jsx(Text, { dimColor: true, children: readyMessage.trim() }) : null, live && transcript.length > 0 ? _jsx(BlankRow, {}) : null, live ? (_jsx(LiveTurn, { turn: live, toolsExpanded: toolsExpanded, streaming: true, columns: terminalColumns })) : null, pendingApproval ? (_jsxs(_Fragment, { children: [live || transcript.length > 0 ? _jsx(BlankRow, {}) : null, _jsx(ApprovalRequestPanel, { pending: pendingApproval.pending, columns: terminalColumns })] })) : null] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [debugVisible ? (_jsx(DebugPanel, { subagents: subagents, historyLines: debugHistory, systemLines: debugSystem, toolsLines: debugTools, mcpLines: debugMcp, responseLines: debugResponse, autoLines: debugAuto, activeTab: debugTab, scrollOffset: debugScroll, focused: debugFocused, viewportHeight: debugViewport, maximized: debugMaximized, searchQuery: debugSearchQuery, searchActive: debugSearchInput, matchCount: debugMatches.length, currentMatchIndex: debugSearchCurrent, currentMatchLine: currentMatchLine })) : null, activeChecklist ? _jsx(ChecklistPanel, { items: activeChecklist }) : null, _jsx(NegotiationPanel, { rounds: negotiationRounds }), pendingAttack ? _jsx(AttackBanner, { halt: pendingAttack.halt, typed: attackTyped }) : null, pendingApproval && !pendingAttack ? (_jsx(ApprovalPrompt, { pending: pendingApproval.pending })) : null, approvalsPicker && !pendingApproval && !pendingAttack ? (_jsx(ApprovalsPicker, { choices: approvalsPicker, onSelect: (rung) => {
1683
1915
  setApprovalsPicker(null);
1684
1916
  applyApprovalRung(rung);
1685
- }, onCancel: () => setApprovalsPicker(null) })) : null, _jsx(BlankRow, {}), _jsx(Rule, {}), _jsx(NoticeBar, { advisories: props.advisories }), _jsx(McpFailureBar, { failures: props.mcpFailures }), _jsx(StatusBar, { running: running, mode: mode, modelDisplayName: modelDisplayName,
1917
+ }, onCancel: () => setApprovalsPicker(null) })) : null, resumePicker && !approvalsPicker && !pendingApproval && !pendingAttack ? (_jsx(ResumePicker, { candidates: resumePicker, onSelect: (id) => {
1918
+ // Close BEFORE applying: a landed resume replaces the transcript and re-keys its
1919
+ // ids, and this list is a snapshot of the conversations as they were before it.
1920
+ setResumePicker(null);
1921
+ void applyResume(id);
1922
+ }, onCancel: () => {
1923
+ setResumePicker(null);
1924
+ const notice = resumeCancelledNotice();
1925
+ push({ kind: 'notice', title: notice.title, lines: notice.lines, tone: 'info' });
1926
+ } })) : null, _jsx(BlankRow, {}), _jsx(Rule, {}), _jsx(NoticeBar, { advisories: props.advisories }), _jsx(McpFailureBar, { failures: props.mcpFailures }), _jsx(StatusBar, { running: running, activity: waitActivity, mode: mode, modelDisplayName: modelDisplayName,
1686
1927
  // CFG-38 — the provider beside the model, and the live width it has to fit in: the
1687
1928
  // bar drops the provider rather than overflowing a narrow terminal.
1688
1929
  modelProviderType: props.modelProviderType, columns: terminalColumns, turnCount: turnCount, debugHint: debugVisible && !debugFocused, approvals: approvals
@@ -1690,7 +1931,11 @@ export function App(props) {
1690
1931
  rung: approvals.rung,
1691
1932
  raterProfile: approvals.rater,
1692
1933
  }
1693
- : undefined }), !debugFocused && !pendingApproval && !pendingAttack && !approvalsPicker ? (_jsxs(_Fragment, { children: [_jsx(BlankRow, {}), _jsx(PromptInput, { onSubmit: handleSubmit, commands: registry, onMenuStateChange: (active) => {
1934
+ : undefined }), !debugFocused &&
1935
+ !pendingApproval &&
1936
+ !pendingAttack &&
1937
+ !approvalsPicker &&
1938
+ !resumePicker ? (_jsxs(_Fragment, { children: [_jsx(BlankRow, {}), _jsx(PromptInput, { onSubmit: handleSubmit, commands: registry, onMenuStateChange: (active) => {
1694
1939
  slashMenuActiveRef.current = active;
1695
1940
  }, handleRef: promptHandleRef, draftCarryRef: promptDraftCarryRef, promptBlockRows: promptBlockRows }), _jsx(BlankRow, {})] })) : null, _jsx(Text, { dimColor: true, wrap: "truncate-end", children: `${exitMessage.trim()}${TUI_HINT_SUFFIX}` }), _jsx(Rule, {})] })] }) }) }));
1696
1941
  }