@gaunt-sloth/agent 2.0.0-alpha.8 → 2.0.0-beta.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 (126) hide show
  1. package/README.md +73 -23
  2. package/cli-acp.js +21 -24
  3. package/dist/builtInToolsConfig.d.ts +17 -3
  4. package/dist/builtInToolsConfig.js +43 -41
  5. package/dist/builtInToolsConfig.js.map +1 -1
  6. package/dist/core/debugCapture.d.ts +7 -49
  7. package/dist/core/debugCapture.js +1 -1
  8. package/dist/core/debugCapture.js.map +1 -1
  9. package/dist/core/resolveAgentFactory.d.ts +10 -9
  10. package/dist/core/resolveAgentFactory.js +11 -12
  11. package/dist/core/resolveAgentFactory.js.map +1 -1
  12. package/dist/index.d.ts +4 -4
  13. package/dist/index.js +7 -5
  14. package/dist/index.js.map +1 -1
  15. package/dist/mcp/OAuthClientProviderImpl.js.map +1 -1
  16. package/dist/mcp/tlsTrust.d.ts +61 -0
  17. package/dist/mcp/tlsTrust.js +109 -0
  18. package/dist/mcp/tlsTrust.js.map +1 -0
  19. package/dist/middleware/binaryContentInjectionMiddleware.d.ts +8 -1
  20. package/dist/middleware/binaryContentInjectionMiddleware.js +11 -2
  21. package/dist/middleware/binaryContentInjectionMiddleware.js.map +1 -1
  22. package/dist/middleware/frontendImageInjectionMiddleware.d.ts +107 -0
  23. package/dist/middleware/frontendImageInjectionMiddleware.js +167 -0
  24. package/dist/middleware/frontendImageInjectionMiddleware.js.map +1 -0
  25. package/dist/middleware/registry.js +49 -1
  26. package/dist/middleware/registry.js.map +1 -1
  27. package/dist/middleware/types.d.ts +16 -2
  28. package/dist/modules/a2a/A2AClientWrapper.d.ts +56 -1
  29. package/dist/modules/a2a/A2AClientWrapper.js +131 -31
  30. package/dist/modules/a2a/A2AClientWrapper.js.map +1 -1
  31. package/dist/modules/acp/acpAgentApp.d.ts +57 -0
  32. package/dist/modules/acp/acpAgentApp.js +378 -0
  33. package/dist/modules/acp/acpAgentApp.js.map +1 -0
  34. package/dist/modules/acp/acpAgentAppV1.d.ts +52 -0
  35. package/dist/modules/acp/acpAgentAppV1.js +307 -0
  36. package/dist/modules/acp/acpAgentAppV1.js.map +1 -0
  37. package/dist/modules/acp/acpCommon.d.ts +167 -0
  38. package/dist/modules/acp/acpCommon.js +282 -0
  39. package/dist/modules/acp/acpCommon.js.map +1 -0
  40. package/dist/modules/acp/acpPermissions.d.ts +65 -0
  41. package/dist/modules/acp/acpPermissions.js +162 -0
  42. package/dist/modules/acp/acpPermissions.js.map +1 -0
  43. package/dist/modules/acp/acpPermissionsV1.d.ts +45 -0
  44. package/dist/modules/acp/acpPermissionsV1.js +110 -0
  45. package/dist/modules/acp/acpPermissionsV1.js.map +1 -0
  46. package/dist/modules/acp/acpRouter.d.ts +41 -0
  47. package/dist/modules/acp/acpRouter.js +48 -0
  48. package/dist/modules/acp/acpRouter.js.map +1 -0
  49. package/dist/modules/acp/acpStdio.d.ts +47 -0
  50. package/dist/modules/acp/acpStdio.js +71 -0
  51. package/dist/modules/acp/acpStdio.js.map +1 -0
  52. package/dist/modules/acp/acpToolCalls.d.ts +93 -0
  53. package/dist/modules/acp/acpToolCalls.js +193 -0
  54. package/dist/modules/acp/acpToolCalls.js.map +1 -0
  55. package/dist/modules/acp/acpUpdates.d.ts +48 -0
  56. package/dist/modules/acp/acpUpdates.js +142 -0
  57. package/dist/modules/acp/acpUpdates.js.map +1 -0
  58. package/dist/modules/acp/acpUpdatesV1.d.ts +61 -0
  59. package/dist/modules/acp/acpUpdatesV1.js +162 -0
  60. package/dist/modules/acp/acpUpdatesV1.js.map +1 -0
  61. package/dist/modules/apiAgUiModule.d.ts +87 -0
  62. package/dist/modules/apiAgUiModule.js +193 -28
  63. package/dist/modules/apiAgUiModule.js.map +1 -1
  64. package/dist/modules/interactiveSessionModule.js +497 -51
  65. package/dist/modules/interactiveSessionModule.js.map +1 -1
  66. package/dist/modules/slashCommands.d.ts +582 -0
  67. package/dist/modules/slashCommands.js +1001 -0
  68. package/dist/modules/slashCommands.js.map +1 -0
  69. package/dist/resolvers.js +119 -9
  70. package/dist/resolvers.js.map +1 -1
  71. package/dist/tools/GthCustomToolkit.js +95 -11
  72. package/dist/tools/GthCustomToolkit.js.map +1 -1
  73. package/dist/tools/GthDevToolkit.d.ts +8 -5
  74. package/dist/tools/GthDevToolkit.js +89 -43
  75. package/dist/tools/GthDevToolkit.js.map +1 -1
  76. package/dist/tools/GthFileSystemToolkit.d.ts +63 -0
  77. package/dist/tools/GthFileSystemToolkit.js +506 -105
  78. package/dist/tools/GthFileSystemToolkit.js.map +1 -1
  79. package/dist/tools/McpResourceTool.d.ts +31 -0
  80. package/dist/tools/McpResourceTool.js +106 -0
  81. package/dist/tools/McpResourceTool.js.map +1 -0
  82. package/dist/tools/gthChecklistTool.d.ts +30 -0
  83. package/dist/tools/gthChecklistTool.js +80 -0
  84. package/dist/tools/gthChecklistTool.js.map +1 -0
  85. package/dist/tools/gthGrepTool.d.ts +54 -0
  86. package/dist/tools/gthGrepTool.js +482 -0
  87. package/dist/tools/gthGrepTool.js.map +1 -0
  88. package/dist/tools/shell/env.js +1 -1
  89. package/dist/tools/shell/env.js.map +1 -1
  90. package/dist/tools/shell/outputBuffer.js.map +1 -1
  91. package/dist/tools/shell/workDir.d.ts +1 -10
  92. package/dist/tools/shell/workDir.js +10 -36
  93. package/dist/tools/shell/workDir.js.map +1 -1
  94. package/dist/utils/mcpAuthError.d.ts +36 -0
  95. package/dist/utils/mcpAuthError.js +89 -0
  96. package/dist/utils/mcpAuthError.js.map +1 -0
  97. package/dist/utils/mcpUtils.js +16 -0
  98. package/dist/utils/mcpUtils.js.map +1 -1
  99. package/package.json +13 -13
  100. package/dist/core/GthDeepAgent.d.ts +0 -181
  101. package/dist/core/GthDeepAgent.js +0 -682
  102. package/dist/core/GthDeepAgent.js.map +0 -1
  103. package/dist/core/deepAgentPermissions.d.ts +0 -115
  104. package/dist/core/deepAgentPermissions.js +0 -282
  105. package/dist/core/deepAgentPermissions.js.map +0 -1
  106. package/dist/core/gthAcpServer.d.ts +0 -27
  107. package/dist/core/gthAcpServer.js +0 -82
  108. package/dist/core/gthAcpServer.js.map +0 -1
  109. package/dist/core/gthDeepAgentFactory.d.ts +0 -8
  110. package/dist/core/gthDeepAgentFactory.js +0 -9
  111. package/dist/core/gthDeepAgentFactory.js.map +0 -1
  112. package/dist/modules/acpModule.d.ts +0 -40
  113. package/dist/modules/acpModule.js +0 -78
  114. package/dist/modules/acpModule.js.map +0 -1
  115. package/dist/tools/shell/allowlist.d.ts +0 -11
  116. package/dist/tools/shell/allowlist.js +0 -12
  117. package/dist/tools/shell/allowlist.js.map +0 -1
  118. package/dist/tools/shell/arity.d.ts +0 -11
  119. package/dist/tools/shell/arity.js +0 -12
  120. package/dist/tools/shell/arity.js.map +0 -1
  121. package/dist/tools/shell/hardline.d.ts +0 -15
  122. package/dist/tools/shell/hardline.js +0 -88
  123. package/dist/tools/shell/hardline.js.map +0 -1
  124. package/dist/tools/shell/normalize.d.ts +0 -10
  125. package/dist/tools/shell/normalize.js +0 -11
  126. package/dist/tools/shell/normalize.js.map +0 -1
@@ -1,28 +1,101 @@
1
1
  import { initConfig } from '@gaunt-sloth/core/config.js';
2
- import { defaultStatusCallback, display, displayInfo, displayWarning, flushSessionLog, formatInputPrompt, initSessionLogging, stopSessionLogging, } from '@gaunt-sloth/core/utils/consoleUtils.js';
2
+ import { defaultStatusCallback, display, displayDialogLine, displayInfo, displayLaunchBanner, displayWarning, flushSessionLog, formatInputPrompt, initSessionLogging, stopSessionLogging, } from '@gaunt-sloth/core/utils/consoleUtils.js';
3
3
  import { GthAgentRunner } from '@gaunt-sloth/core/core/GthAgentRunner.js';
4
+ import { GthAbstractAgent } from '@gaunt-sloth/core/core/GthAbstractAgent.js';
5
+ import { launchBannerFields, launchBannerText } from '@gaunt-sloth/core/core/launchBanner.js';
6
+ import { buildRejectionMessage } from '@gaunt-sloth/core/core/shell/rejection.js';
7
+ import { renderNegotiationRows } from '@gaunt-sloth/core/core/shell/negotiation.js';
8
+ import { attackBannerCopy, describeRaterOutcome, grantsRunAnyway, RATER_REASON_LABEL, } from '@gaunt-sloth/core/core/shell/escalationSeverity.js';
9
+ import { frameUntrustedCommand, frameUntrustedText, frameWidthFor, narrowTerminalNotice, STICKY_PREVIEW_MAX_ROWS, } from '@gaunt-sloth/core/core/shell/framing.js';
10
+ import { approvalPromptHeader } from '@gaunt-sloth/core/core/approvals/promptHeader.js';
11
+ import { ApprovalStopError, approvalStopRows } from '@gaunt-sloth/core/core/shell/approvalStop.js';
12
+ import { writeDebugDump } from '@gaunt-sloth/core/utils/debugDump.js';
4
13
  import { appendToFile, getCommandOutputFilePath } from '@gaunt-sloth/core/utils/fileUtils.js';
5
- import { recordSessionSafe } from '@gaunt-sloth/core/history/recordSession.js';
6
- import { createInterface, error, exit, getProjectDir, refStdin, setRawMode, stdin as input, stdout as output, } from '@gaunt-sloth/core/utils/systemUtils.js';
14
+ import { openConversationSafe, recordSessionSafe, } from '@gaunt-sloth/core/history/recordSession.js';
15
+ import { createInterface, error, exit, getProjectDir, getUseColour, refStdin, setRawMode, stdin as input, stdout as output, } from '@gaunt-sloth/core/utils/systemUtils.js';
7
16
  import { HumanMessage } from '@langchain/core/messages';
8
17
  import { MemorySaver } from '@langchain/langgraph';
9
18
  import { createResolvers } from '#src/resolvers.js';
10
19
  import { resolveAgentFactory } from '#src/core/resolveAgentFactory.js';
20
+ import { approvalsRungNotice, approvalsStatusNotice, approvalsTrustNotice, createCommandRegistry, dispatchSlashCommand, formatConfigSummary, parseSlashCommand, } from '#src/modules/slashCommands.js';
21
+ /**
22
+ * [[TUI-C26]] §6 / [[EXT-105]] — **every line of an escalation dialog goes through
23
+ * {@link displayDialogLine}, and no line of one goes anywhere else.**
24
+ *
25
+ * That writer puts the line on **stderr** whatever its severity, and takes the severity as a
26
+ * separate argument. Both halves matter here. The ordinary `display*` helpers bind colour to
27
+ * stream — `displayWarning` is yellow AND stderr, `displayError` is red AND stdout — so a dialog
28
+ * that used them to colour its lines was written across both streams, and only writes to the SAME
29
+ * stream are delivered in the order they were made. On a terminal the two arrive in call order and
30
+ * the dialog reads top to bottom; piped or redirected they need not, and a reader can be shown a
31
+ * rater's answer above the command it answers. A gate whose line order holds only on a terminal is
32
+ * not a gate.
33
+ *
34
+ * So the rule for this file is mechanical: **inside the approval dialog and the attack banner, use
35
+ * `displayDialogLine` and pass the tone.** A `display`/`displayInfo`/`displayWarning`/`displayError`
36
+ * call added among them is the defect coming back, and the stream test covering these two callbacks
37
+ * is what catches it. Everything OUTSIDE them — notices, banners, the session's own chatter — keeps
38
+ * the ordinary helpers.
39
+ *
40
+ * The tone is the observable an assertion can bite on, too: a change that made `catastrophic` look
41
+ * like `destructive` would have to pass both the same tone, which a test can see.
42
+ *
43
+ * This helper is the multi-line form — a framed block, a list of notices, the banner's controls —
44
+ * since every such block is one tone throughout. A single line calls the writer directly.
45
+ */
46
+ const dialogLines = (lines, tone = 'plain') => {
47
+ for (const line of lines)
48
+ displayDialogLine(line, tone);
49
+ };
11
50
  export async function createInteractiveSession(sessionConfig, commandLineConfigOverrides, message) {
12
51
  const config = { ...(await initConfig(commandLineConfigOverrides)) };
13
52
  const checkpointSaver = new MemorySaver();
53
+ // GS2-19: open ONE conversation for this interactive session up-front; every turn below is stamped
54
+ // with its id so a multi-turn chat groups under one conversation (not N unrelated rows). Opt-in /
55
+ // fail-soft: a no-op returning undefined unless `history.enabled`, in which case turns fall back to
56
+ // per-turn 1-turn conversations. Never affects a default run.
57
+ const conversationId = openConversationSafe(config, {
58
+ command: sessionConfig.mode,
59
+ project: getProjectDir(),
60
+ model: config.modelDisplayName,
61
+ }) ?? undefined;
14
62
  // Initialize Runner
15
63
  const logFileName = getCommandOutputFilePath(config, sessionConfig.mode);
16
64
  if (logFileName) {
17
65
  initSessionLogging(logFileName, config.streamSessionInferenceLog);
18
66
  }
19
- // B5: interactive code/chat default to the deep backend; an explicit config.agent.backend
20
- // overrides it. createResolvers() is unchanged, so a lean session keeps the full toolset.
21
- const runner = new GthAgentRunner(defaultStatusCallback, createResolvers(), resolveAgentFactory(config, 'deep'));
67
+ // B5: interactive code/chat default to the LEAN backend; an explicit config.agent.backend
68
+ // overrides it (deep is now opt-in / experimental). createResolvers() is unchanged, so a lean
69
+ // session keeps the full toolset (filesystem + hardened dev/shell).
70
+ const runner = new GthAgentRunner(defaultStatusCallback, createResolvers(), resolveAgentFactory(config, 'lean'));
22
71
  try {
23
72
  await runner.init(sessionConfig.mode, config, checkpointSaver);
24
73
  const rl = createInterface({ input, output });
25
74
  let shouldExit = false;
75
+ // GS2-8 — the readline surface shares the SAME command registry as the Ink TUI (one source
76
+ // of truth): every registered command parses, appears in /help, and dispatches here too.
77
+ const registry = createCommandRegistry();
78
+ // Committed-turn counter for the /status command (mirrors the TUI's status-bar counter).
79
+ let turnCount = 0;
80
+ // GS2-56 — wire `/debug-dump` on the readline (`--no-tui`) surface too. Previously this surface
81
+ // injected no writer, so `/debug-dump` reported itself "unavailable" here; it now forwards to the
82
+ // same core writer the TUI uses AND threads the agent's always-on last-model-request snapshot
83
+ // (read at CALL time from the live agent), so the archive carries the full model input even
84
+ // though this surface keeps no on-screen transcript. Fail-soft: no agent handle ⇒ the snapshot is
85
+ // simply omitted (the other artifacts still write). `redact` is resolved by the shared command.
86
+ const dumpDebugSession = (dumpInput) => {
87
+ const agent = runner.getAgent();
88
+ return writeDebugDump({
89
+ transcript: dumpInput.transcript,
90
+ config: dumpInput.config,
91
+ modelDisplayName: dumpInput.modelDisplayName,
92
+ redact: dumpInput.redact,
93
+ modelRequest: agent instanceof GthAbstractAgent ? agent.lastModelRequest : undefined,
94
+ // [[TUI-C27]] — the approvals gate's record of every gated decision, read from the live
95
+ // runner at CALL time for the same reason the model request is.
96
+ approvals: runner.getApprovalCaptures(),
97
+ });
98
+ };
26
99
  // EXT-18: ref stdin before every rl.question() that can run AFTER an agent turn/stream end.
27
100
  // When a run suspends (tool-approval interrupt) or throws, the stream's finally calls
28
101
  // stopWaitingForEscape(), which unref's stdin so one-shot commands can exit. A prompt that
@@ -33,14 +106,17 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
33
106
  refStdin();
34
107
  return rl.question(prompt);
35
108
  };
36
- // Tool-approval (human-in-the-loop) prompt for gated tools — currently the opt-in
37
- // `run_shell_command`. When a run suspends on such a tool call, the runner calls this with
38
- // the pending command. EXT-9 Tier-2: instead of a bare y/N, offer a scoped choice so the
109
+ // Tool-approval (human-in-the-loop) prompt for gated tools — the shell tool, and at `manual`
110
+ // and `write` the built-in write tools, MCP tools and custom tools (EXT-80). When a run
111
+ // suspends on such a tool call, the runner calls this with the pending call. The opening
112
+ // sentence therefore has to say which kind it is, which is [[TUI-C67]] and core's to render.
113
+ // EXT-9 Tier-2: instead of a bare y/N, offer a scoped choice so the
39
114
  // human can stop re-prompting for an operation they trust:
40
- // [o]nce — approve this single invocation only (persists nothing),
41
- // [s]ession — auto-approve this command's classified prefix for the rest of the session,
42
- // [a]lways — additionally persist it to the project allow-list,
43
- // anything else reject (fail-closed).
115
+ // [o]nce — approve this single invocation only (persists nothing),
116
+ // [s]ession — auto-approve this exact command for the rest of the session,
117
+ // [a]lways — additionally persist it to the project allow-list,
118
+ // [d]eny always refuse it AND record a deny entry for the rest of the session,
119
+ // anything else → reject this one call (fail-closed, and it stays the fallthrough).
44
120
  // The runner consults the allow-list BEFORE calling this, so trusted commands never reach
45
121
  // this prompt at all. (The Ink TUI surfaces the same scoped prompt via an approval bridge —
46
122
  // see tuiSessionModule's createApprovalBridge + the <ApprovalPrompt> component.)
@@ -48,12 +124,136 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
48
124
  const commandText = typeof pending.args.command === 'string'
49
125
  ? pending.args.command
50
126
  : JSON.stringify(pending.args);
51
- displayWarning(`\nThe agent wants to run a shell command via ${pending.name}:`);
52
- display(`\n ${commandText}\n`);
53
- // EXT-10: if the LLM-as-judge gate escalated (rather than auto-approving) this command, show
54
- // its flag + reason before the human decides.
127
+ // [[TUI-C26]] §6 — the command is model-authored text going to a terminal, where it is not
128
+ // inert: a carriage return reaches column 0, an escape sequence clears the screen, and a
129
+ // newline alone lays down a line that looks exactly like this prompt's own chrome. It is
130
+ // painted through core's framing renderer neutralised, inside a line-number gutter, with
131
+ // its command-substitution and composition sites listed above it — the SAME renderer the Ink
132
+ // prompt uses, so the two surfaces cannot differ about how much of a command a human saw.
133
+ // Nothing is clamped to one line: the command that motivated this hid its payload fifteen
134
+ // lines into a commit message, and a clamp discards exactly what the human must rule on.
135
+ const frameWidth = frameWidthFor(output.columns);
136
+ const framedCommand = frameUntrustedCommand(commandText, { width: frameWidth });
137
+ // [[TUI-C67]] — the opening sentence is core's, branched on the `ApprovalSubject` kind the
138
+ // gate itself decided on, so a file write, an MCP call or a custom tool is announced as what
139
+ // it is rather than as a shell command. The Ink prompt renders the identical call; this
140
+ // surface owns only the leading blank line and the trailing colon, which is what keeps the
141
+ // two from describing one call two ways.
142
+ displayDialogLine(`\n${approvalPromptHeader(pending)}:`, 'warn');
143
+ // Below core's floor the frame is wider than the terminal, which wraps it and puts untrusted
144
+ // text at the left edge. The frame is still shown — hiding what the human must rule on would
145
+ // be worse — but the guarantee has lapsed, and it says so instead of lapsing silently.
146
+ const tooNarrow = narrowTerminalNotice(output.columns);
147
+ if (tooNarrow)
148
+ displayDialogLine(tooNarrow, 'warn');
149
+ dialogLines(framedCommand.notices, 'warn');
150
+ displayDialogLine('');
151
+ dialogLines(framedCommand.lines);
152
+ displayDialogLine('');
153
+ // CFG-27: when the auto-rater escalated this command (rather than approving it), show its
154
+ // outcome + reason before the human decides. §6 makes that explanation mandatory whenever a
155
+ // rating exists; at the unrated rungs there is none and the prompt shows the command alone.
156
+ // The outcome is a schema enum; the reason is model-authored prose and is framed exactly like
157
+ // the command, because a dialog forgeable through the string that explains it is not a gate.
158
+ // [[TUI-C26]] §6 — the severity is legible in three independent ways: a glyph, a sentence of
159
+ // the gate's own naming what the outcome MEANS, and the tone it is painted in. The tone is
160
+ // this surface's colour: `catastrophic` is painted `danger` (red) where `destructive` is
161
+ // `warn` (yellow), so the two cannot look alike — and the sentence carries it anyway for a
162
+ // terminal with no colour at all, which is the one that must not be left out.
55
163
  if (pending.safetyVerdict) {
56
- displayWarning(`⚠ safety judge (${pending.safetyVerdict.risk}): ${pending.safetyVerdict.reason}`);
164
+ const severity = describeRaterOutcome(pending.safetyVerdict.outcome);
165
+ displayDialogLine(severity.heading, severity.tone);
166
+ // The reason is the RATER's, and now that the line above it is the gate's own sentence the
167
+ // attribution has to be said rather than implied.
168
+ displayDialogLine(RATER_REASON_LABEL, 'notice');
169
+ dialogLines(frameUntrustedText(pending.safetyVerdict.reason, { width: frameWidth }).lines, severity.tone);
170
+ }
171
+ // EXT-71 §3.2 — when a declared `approvals.escalate` entry is what brought this call here,
172
+ // the prompt shows THE ENTRY THAT FIRED. Without it the user is asked about a command their
173
+ // rung would have approved, with nothing on screen tying the question to the line they wrote
174
+ // — which reads as the gate malfunctioning rather than as their own rule working.
175
+ // [[TUI-C26]] — framed rather than interpolated. The entry is usually something the user
176
+ // wrote, but an MCP entry can carry server-supplied names, and this line sits one string away
177
+ // from the prompt's own chrome. The label stays this surface's own line.
178
+ if (pending.escalatedBy) {
179
+ displayDialogLine('⚠ Your approvals.escalate list matched this call:', 'warn');
180
+ dialogLines(frameUntrustedText(pending.escalatedBy, { width: frameWidth }).lines, 'warn');
181
+ }
182
+ // [[EXT-29]] §6 — when a §5 negotiation preceded this escalation, the human is shown ALL of
183
+ // it. The user is not asked to rule on the final command in isolation: that the agent
184
+ // proposed the same command three times unchanged, against two rejections that each told it
185
+ // what to fix, is the most important thing on the screen and is invisible if only the last
186
+ // attempt is shown. Rendered through core's shared renderer, so the surfaces cannot describe
187
+ // one exchange two ways.
188
+ //
189
+ // [[TUI-C26]] §5.4 — rendered as ROWS, so the two voices are told apart: the rater's turns are
190
+ // painted `warn` (yellow) and the agent's plain, which is what the spec asks for and what one
191
+ // joined string could not express — the whole exchange used to arrive in a single colour. Each
192
+ // row also NAMES its speaker (`rater answered:` / `agent justified:`), which is the half that
193
+ // survives a monochrome terminal. The rows are bound to the terminal width for the same reason
194
+ // the command is: a long justification left to the terminal's own wrap continues at column 0.
195
+ //
196
+ // [[TUI-C75]] — the count comes from `negotiationAttempts`, not from the array: §5.3 clears
197
+ // the transcript on an approved call, so the rounds that survive to here are the attempts
198
+ // since the last approval and not the attempts the human is being asked to weigh.
199
+ for (const row of renderNegotiationRows(pending.negotiationRounds ?? [], {
200
+ width: frameWidth,
201
+ ...(pending.negotiationAttempts !== undefined
202
+ ? { attempts: pending.negotiationAttempts }
203
+ : {}),
204
+ })) {
205
+ if (row.voice === 'rater')
206
+ displayDialogLine(row.text, 'warn');
207
+ else if (row.voice === 'agent')
208
+ displayDialogLine(row.text);
209
+ else
210
+ displayDialogLine(row.text, 'notice');
211
+ }
212
+ // EXT-71/EXT-70 §6 — the menu MUST show what a sticky choice will store, at the moment of
213
+ // the choice, and it names it in the words the control is written in: the command itself for
214
+ // a shell call, the tool (with its server and host bound) for a tool call, since for a tool
215
+ // "the stored thing is the tool, not the arguments" (§4.7.4). The exact entry follows it, so
216
+ // the user sees the thing they are agreeing to rather than a generalization of it.
217
+ const sticky = pending.grantPreview !== undefined;
218
+ if (sticky) {
219
+ // [[TUI-C26]] — these two lines carry the command as typed (§3.1 stores it exactly, never a
220
+ // widened pattern), so they inherit the command's problem in less space: an in-line
221
+ // `approved by rater` fits on one line untouched. Framed like everything else, with the
222
+ // label kept as this surface's OWN line so the untrusted half can never be read as chrome.
223
+ // Bounded to a few rows: for a shell call these carry the command as typed, which is
224
+ // already printed in full above — so an unbounded copy of a long command here (twice over,
225
+ // since the entry repeats it) scrolls the MENU off the screen, and a control the human
226
+ // cannot see is not one they were offered.
227
+ displayDialogLine('[s]/[a] will remember:', 'notice');
228
+ dialogLines(frameUntrustedText(pending.grantSummary ?? pending.grantPreview, {
229
+ width: frameWidth,
230
+ maxRows: STICKY_PREVIEW_MAX_ROWS,
231
+ }).lines, 'notice');
232
+ displayDialogLine(' stored as:', 'notice');
233
+ dialogLines(frameUntrustedText(pending.grantPreview, {
234
+ width: frameWidth,
235
+ maxRows: STICKY_PREVIEW_MAX_ROWS,
236
+ }).lines, 'notice');
237
+ }
238
+ // [[TUI-C26]] §6 — the same requirement for the OTHER sticky choice, and its availability is
239
+ // a different question: the runner offers a deny entry in cases where no grant exists at all
240
+ // (a command that does not statically resolve, every `catastrophic` verdict), because a
241
+ // refusal that cannot be decided still refuses. `recorded as:` rather than a second
242
+ // `stored as:` — one dialog, two labels that read alike, is how a reader loses track of which
243
+ // block they are looking at. The label states the lifetime because there is no persisted deny
244
+ // store and the control must not imply one.
245
+ const stickyDeny = pending.denyPreview !== undefined;
246
+ if (stickyDeny) {
247
+ displayDialogLine('[d] will refuse, for the rest of this session:', 'notice');
248
+ dialogLines(frameUntrustedText(pending.denySummary ?? pending.denyPreview, {
249
+ width: frameWidth,
250
+ maxRows: STICKY_PREVIEW_MAX_ROWS,
251
+ }).lines, 'notice');
252
+ displayDialogLine(' recorded as:', 'notice');
253
+ dialogLines(frameUntrustedText(pending.denyPreview, {
254
+ width: frameWidth,
255
+ maxRows: STICKY_PREVIEW_MAX_ROWS,
256
+ }).lines, 'notice');
57
257
  }
58
258
  setRawMode(false); // ensure typed input is echoed for this confirm
59
259
  // EXT-18: wrap the prompt in try/finally so the raw-mode/ref state is not left wedged if
@@ -62,9 +262,30 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
62
262
  // suspended on the tool interrupt, whose stream-end unref'd stdin).
63
263
  let answer;
64
264
  try {
65
- answer = (await askLine(formatInputPrompt('Approve? [o]nce / [s]ession / [a]lways / [N]o: ')))
66
- .trim()
67
- .toLowerCase();
265
+ // §6 a sticky control is SHOWN only where the gate would actually store something. Where
266
+ // nothing would be remembered — a `catastrophic` outcome (§4.2), a command that does not
267
+ // statically resolve, a tool call nothing can attribute — the menu simply does not offer
268
+ // the choice, because "a control that is offered and then refused reads as a bug rather
269
+ // than as a policy". Hiding it, never disabling it: a disabled control invites the user to
270
+ // hunt for why. The two sticky controls are judged SEPARATELY: the deny entry exists in
271
+ // cases where no grant does, which is the whole reason it is a second condition.
272
+ //
273
+ // Assembled from parts rather than by enumerating the four combinations, so a menu spelling
274
+ // nobody wrote down cannot reach a terminal.
275
+ const controls = [
276
+ '[o]nce',
277
+ ...(sticky ? ['[s]ession', '[a]lways'] : []),
278
+ '[N]o',
279
+ ...(stickyDeny ? ['[d]eny always'] : []),
280
+ ];
281
+ // [[EXT-105]] — the menu is a LINE OF THE DIALOG, so it is written like the rest of it and
282
+ // readline is handed an empty prompt. Left to `rl.question(menu)` it would go to readline's
283
+ // own output — stdout — which is the one line of the dialog it would be worst to lose from a
284
+ // capture and worst to have arrive out of order: the question, below the answer. The cost is
285
+ // one row: readline redraws the line it is editing, so a menu written beside it would be
286
+ // erased on a terminal, and the answer is therefore typed on the row below the menu.
287
+ displayDialogLine(`Approve? ${controls.join(' / ')}:`, 'prompt');
288
+ answer = (await askLine('')).trim().toLowerCase();
68
289
  }
69
290
  finally {
70
291
  refStdin();
@@ -72,16 +293,125 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
72
293
  if (answer === 'o' || answer === 'once') {
73
294
  return { type: 'approve', scope: 'once' };
74
295
  }
75
- if (answer === 's' || answer === 'session') {
76
- displayInfo('Approved for this session, future variants will not re-prompt.');
296
+ // CFG-28 (§4.2) / [[TUI-C26]] §1.1 **a sticky approve is answerable only where the control
297
+ // was OFFERED**, on the same condition that decides whether it was shown. `catastrophic` is
298
+ // one case of it: the runner clamps the allow-list write for that outcome, so a grant is
299
+ // never stored and no grant is on offer — and the others are a command that does not
300
+ // statically resolve and a call nothing can attribute. Typed on a menu that does not carry
301
+ // them, `s` and `a` are unbound answers like any other and fall through to the one-shot
302
+ // refusal below.
303
+ //
304
+ // **The gate has to be on the key, not only on the confirmation.** Bound anywhere else, `a`
305
+ // at a `catastrophic` prompt approves and RUNS the command — nothing between this callback
306
+ // and execution re-reads the verdict — off a menu that has already withdrawn the choice,
307
+ // which is §6's "a control that is offered and then refused" with the withdrawal made
308
+ // cosmetic. The scope is still returned exactly as typed: the clamp on what gets STORED is
309
+ // core's, the single chokepoint for every surface, and this must not start deciding
310
+ // persistence for itself.
311
+ if (sticky && (answer === 's' || answer === 'session')) {
312
+ displayDialogLine('Approved — this exact command will not ask again this session.', 'notice');
77
313
  return { type: 'approve', scope: 'session' };
78
314
  }
79
- if (answer === 'a' || answer === 'always') {
80
- displayInfo('Approved and remembered, saved to the project allow-list.');
315
+ if (sticky && (answer === 'a' || answer === 'always')) {
316
+ displayDialogLine('Approved and remembered — this exact command is saved to the project allow-list.', 'notice');
81
317
  return { type: 'approve', scope: 'always' };
82
318
  }
83
- displayInfo('Command rejected.');
84
- return { type: 'reject', message: 'User rejected the shell command.' };
319
+ // [[TUI-C26]] §6 — *always reject*: a refusal that is also recorded, so the next identical
320
+ // call is refused by rule without reaching a person. Answered only where the control was
321
+ // OFFERED; typed anywhere else it is an unbound answer like any other and falls through to
322
+ // the one-shot refusal below, which is what keeps the safe action the fallthrough.
323
+ //
324
+ // **One spelling for the control this work adds** — `d`, the one the menu prints. A second
325
+ // that the menu never advertises is how this surface starts drifting from the Ink one, which
326
+ // has no aliases at all.
327
+ //
328
+ // That rule governs what is ADDED here, and the long forms `once`, `session` and `always`
329
+ // accepted above are not exceptions to it: they predate this work and they stay. Each is
330
+ // gated by exactly the condition that gates its own letter — `session`/`always` inside the
331
+ // same `sticky` test as `s`/`a`, `once` on a control that is always offered — so none of them
332
+ // widens what is answerable at any prompt. Read this as the reason not to add a fourth alias,
333
+ // never as licence to delete the three that are here.
334
+ const stickyRejected = stickyDeny && answer === 'd';
335
+ // The confirmation says what actually happened and stops there. There is no persisted deny
336
+ // store, so a line implying one would be the same failure §6 names when it calls a control
337
+ // offered and then refused a bug — with the evidence hidden, which is worse.
338
+ displayDialogLine(stickyRejected
339
+ ? 'Refused — this call will not run for the rest of this session, and will not ask ' +
340
+ 'again. Nothing was saved to the project, so a new session will ask about it again.'
341
+ : 'Command rejected.', 'notice');
342
+ // EXT-58 (§7): the model is told the moves it has — re-call with a justification, call a
343
+ // different command, or ask the user — and, when the rater named an already-granted
344
+ // alternative (§4.4), that tool plus the clause saying it needs no approval. A bare "user
345
+ // rejected" leaves the model to guess, which it does by repeating itself or giving up.
346
+ return {
347
+ type: 'reject',
348
+ ...(stickyRejected ? { scope: 'session' } : {}),
349
+ message: buildRejectionMessage({
350
+ source: 'user',
351
+ toolName: pending.name,
352
+ verdict: pending.safetyVerdict,
353
+ }),
354
+ };
355
+ });
356
+ // [[TUI-C68]] §6.1 — the ATTACK BANNER on the plain surface. An `attack` verdict says the
357
+ // command's own structure evidenced compromise, and it ends the run; without this the only
358
+ // recovery is a restart, which §12 forbids. Wiring the callback is what opts this session into
359
+ // being asked — every surface that does not wire it keeps the halt (§6.2), so forgetting fails
360
+ // safe rather than opening a hole.
361
+ //
362
+ // **It is not the approval prompt and must not read like one.** No menu, no scope, no key: one
363
+ // typed phrase runs one command, and everything else stops the run — including a bare Enter and
364
+ // any near miss. There is no second attempt on purpose: a re-prompt turns a typo into another
365
+ // chance at an irreversible action.
366
+ //
367
+ // On this surface `rl.question` reads a whole LINE in cooked mode, so `q` and `Esc` are not
368
+ // keystrokes it can intercept — they are simply text that is not the phrase, and stop the run
369
+ // like any other. That is why the shared copy carries no keyboard line: the Ink TUI adds its own
370
+ // keys beside these, and a line here naming keys this surface cannot honour would be false.
371
+ runner.setAttackHaltCallback(async (halt) => {
372
+ const copy = attackBannerCopy();
373
+ const frameWidth = frameWidthFor(output.columns);
374
+ displayDialogLine(`\n${copy.title}`, 'danger');
375
+ const tooNarrow = narrowTerminalNotice(output.columns);
376
+ if (tooNarrow)
377
+ displayDialogLine(tooNarrow, 'warn');
378
+ // The command and the rater's reason are model-authored text on the last screen between a
379
+ // human and the action. They go through the SAME framing renderer as the approval dialog —
380
+ // neutralised, gutter-numbered, substitution and composition sites listed above — because a
381
+ // banner whose own chrome can be forged by the string it is warning about is worse than none.
382
+ const framedCommand = frameUntrustedCommand(halt.command, { width: frameWidth });
383
+ dialogLines(framedCommand.notices, 'warn');
384
+ displayDialogLine('');
385
+ dialogLines(framedCommand.lines);
386
+ displayDialogLine('');
387
+ displayDialogLine(copy.heading, 'danger');
388
+ displayDialogLine(RATER_REASON_LABEL, 'notice');
389
+ dialogLines(frameUntrustedText(halt.reason, { width: frameWidth }).lines, 'danger');
390
+ // UNCONDITIONAL, on every attack banner whatever the rating said. The banner is rare by
391
+ // construction, so the line cannot become noise, and a static string cannot fail the way a
392
+ // model's explanation can.
393
+ displayDialogLine(copy.irreversible, 'danger');
394
+ dialogLines(copy.controls, 'notice');
395
+ setRawMode(false); // ensure the typed phrase is echoed
396
+ let answer;
397
+ try {
398
+ // [[EXT-105]] — the label goes out like every other line of the banner, and readline is
399
+ // handed an empty prompt; see the approval menu above for why the prompt cannot stay on
400
+ // readline's own stream.
401
+ displayDialogLine(copy.prompt, 'prompt');
402
+ answer = await askLine('');
403
+ }
404
+ finally {
405
+ refStdin();
406
+ }
407
+ // One shot. The matcher is core's, so what the banner SAYS is answerable and what this
408
+ // surface ACCEPTS cannot drift, and every value of the line that is not the phrase is a
409
+ // refusal — which is what keeps the safe answer the fallthrough on a control that otherwise
410
+ // accumulates keystrokes instead of rejecting them.
411
+ if (!grantsRunAnyway(answer))
412
+ return 'stop';
413
+ displayDialogLine(copy.granted, 'warn');
414
+ return 'run-anyway';
85
415
  });
86
416
  if (logFileName) {
87
417
  displayInfo(`${sessionConfig.mode} session will be logged to ${logFileName}\n`);
@@ -92,10 +422,9 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
92
422
  appendToFile(logFileName, logEntry);
93
423
  }
94
424
  flushSessionLog(); // Ensure user input is immediately written to file
95
- // The system prompt (backstory + guidelines + mode prompt + identity) now lives in the
96
- // deep-agent graph via createDeepAgent({ systemPrompt }) see GthDeepAgent so it is no
97
- // longer injected here as a per-turn SystemMessage (which yielded a second, non-first system
98
- // message that Anthropic rejects).
425
+ // The system prompt (backstory + guidelines + mode prompt + identity) is composed by the
426
+ // agent and handed to its graph, so it is not injected here as a per-turn SystemMessage
427
+ // (which yielded a second, non-first system message that Anthropic rejects).
99
428
  const messages = [new HumanMessage(userInput)];
100
429
  // GS2-18: wire the readline (`--no-tui`) interactive path into the opt-in history recorder
101
430
  // at its turn boundary, matching the single-shot and Ink-TUI paths. Fail-soft and
@@ -113,6 +442,7 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
113
442
  /* fail-soft: analytics must never affect the session */
114
443
  }
115
444
  recordSessionSafe(config, {
445
+ conversationId, // GS2-19: group every turn under this session's conversation
116
446
  command: sessionConfig.mode,
117
447
  project: getProjectDir(),
118
448
  model: config.modelDisplayName,
@@ -123,6 +453,27 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
123
453
  tools: runStats.tools.length > 0 ? runStats.tools : undefined,
124
454
  durationMs: Date.now() - startedAt,
125
455
  });
456
+ turnCount += 1; // GS2-8 — feeds the /status turn counter
457
+ };
458
+ // GS2-8 — render a structured command notice on the plain-text surface: tone-matched title
459
+ // (warn ⇒ yellow), then the body lines indented under it.
460
+ const printNotice = (notice) => {
461
+ if (notice.tone === 'warn') {
462
+ displayWarning(notice.title);
463
+ }
464
+ else {
465
+ displayInfo(notice.title);
466
+ }
467
+ for (const line of notice.lines) {
468
+ display(` ${line}`);
469
+ }
470
+ };
471
+ const endSession = async () => {
472
+ display('Exiting...');
473
+ shouldExit = true;
474
+ await runner.cleanup();
475
+ stopSessionLogging();
476
+ rl.close();
126
477
  };
127
478
  const askQuestion = async () => {
128
479
  while (!shouldExit) {
@@ -131,28 +482,82 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
131
482
  if (!userInput.trim()) {
132
483
  continue; // Skip inference if no input
133
484
  }
134
- const lowerInput = userInput.toLowerCase().trim();
135
- if (lowerInput === 'exit' || lowerInput === '/exit') {
136
- display('Exiting...');
137
- shouldExit = true;
138
- await runner.cleanup();
139
- stopSessionLogging();
140
- rl.close();
485
+ // Legacy bare `exit` keyword still quits (parity with the TUI's plain-exit handling).
486
+ if (userInput.toLowerCase().trim() === 'exit') {
487
+ await endSession();
141
488
  break;
142
489
  }
143
- // EXT-12 — `/yolo` toggles session-wide shell auto-approval at the approval-decision
144
- // layer (the runner flag), distinct from the static `shellYolo` config. Session-scoped,
145
- // reversible, never persisted; the hardline floor still blocks catastrophic commands.
146
- if (lowerInput === '/yolo') {
147
- const enabled = runner.toggleSessionYolo();
148
- if (enabled) {
149
- displayWarning('yolo ON — shell commands auto-approved this session (no per-command prompt). ' +
150
- 'The hardline safety floor still blocks catastrophic commands. Run /yolo again to require approvals.');
490
+ // GS2-8every `/command` dispatches through the SAME registry as the Ink TUI (single
491
+ // source of truth). parseSlashCommand's `/`-vs-path heuristic means a pasted filesystem
492
+ // path (`/usr/home/bob/test.md`) is NOT a command and falls through to the model below.
493
+ const parsed = parseSlashCommand(userInput);
494
+ if (parsed) {
495
+ const result = dispatchSlashCommand(parsed, registry, {
496
+ mode: sessionConfig.mode,
497
+ modelDisplayName: config.modelDisplayName ?? '',
498
+ turnCount,
499
+ // No tool-detail panels or debug pane exist on this surface; their commands degrade
500
+ // below rather than vanishing from the catalog.
501
+ toolsExpanded: false,
502
+ debugVisible: false,
503
+ // CFG-25 — pass the session command so the panel prints the EFFECTIVE per-command
504
+ // filesystem value (e.g. `all` for `code`), not the top-level default.
505
+ configSummary: formatConfigSummary(config, sessionConfig.mode),
506
+ // GS2-56 — `/debug-dump` is now available here. This surface keeps no on-screen
507
+ // transcript array, so `transcript` is empty; the real as-sent history lands in the
508
+ // archive's model-messages.json from the always-on snapshot (that is the point).
509
+ transcript: [],
510
+ resolvedConfig: config,
511
+ dumpDebugSession,
512
+ });
513
+ if (result.exit) {
514
+ await endSession();
515
+ break;
516
+ }
517
+ if (result.approvals) {
518
+ // CFG-27 — `/approvals <rung>` sets the session rung at the approval-decision layer
519
+ // (the runner posture). Session-scoped, reversible, never persisted. With no argument
520
+ // the command DISPLAYS the posture instead of changing it.
521
+ if ('show' in result.approvals) {
522
+ printNotice(approvalsStatusNotice(runner.getSessionApprovals(), runner.getAllowlistCounts(), runner.getDenylist(), runner.getGrants(), runner.getMcpAnnotationTrust()));
523
+ }
524
+ else if ('trust' in result.approvals) {
525
+ // EXT-70 §4.7.1 — believe (or stop believing) specific hints from one server, for
526
+ // this session. The notice is built from what the runner RETURNS, so it can only
527
+ // describe the trust actually in force — including §4.7.4's consequence when the
528
+ // withdrawal is a weakening.
529
+ const { server, hints, believe } = result.approvals.trust;
530
+ printNotice(approvalsTrustNotice(runner.setMcpAnnotationTrust(server, hints, believe)));
531
+ }
532
+ else {
533
+ runner.setSessionApprovalRung(result.approvals.rung);
534
+ // Report the posture the runner actually LANDED on, not the one requested.
535
+ printNotice(approvalsRungNotice(runner.getSessionApprovals()));
536
+ }
537
+ }
538
+ else if (result.clearTranscript ||
539
+ result.toggleDebug ||
540
+ result.toggleTools ||
541
+ result.reprintReasoning) {
542
+ // TUI-only effects (transcript clear, debug pane, tool-detail fold, reasoning
543
+ // reprint) have no equivalent on the plain readline surface — degrade with a clear
544
+ // pointer instead of silently doing nothing (GS2-8).
545
+ displayInfo(`/${parsed.name} is not available without the TUI — start the session on an ` +
546
+ `interactive terminal without --no-tui/GTH_NO_TUI to use it.`);
547
+ }
548
+ else if (result.notice) {
549
+ printNotice(result.notice);
151
550
  }
152
- else {
153
- displayInfo('yolo OFF approvals required before each shell command.');
551
+ if (result.message) {
552
+ // Incidental system line (e.g. the /tools→/verbose deprecation pointer).
553
+ if (result.level === 'warning') {
554
+ displayWarning(result.message);
555
+ }
556
+ else {
557
+ displayInfo(result.message);
558
+ }
154
559
  }
155
- continue; // do not send the command to the model
560
+ continue; // never send a slash command to the model
156
561
  }
157
562
  let shouldRetry = false;
158
563
  do {
@@ -161,7 +566,21 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
161
566
  shouldRetry = false;
162
567
  }
163
568
  catch (err) {
164
- display(`\n❌ Error processing message: ${err instanceof Error ? err.message : String(err)}\n`);
569
+ // [[TUI-C71]] a run-ending approvals stop carries the command the rater called an
570
+ // attack, and the rater's own words about it. Both are model-authored text on a
571
+ // terminal, so they are painted through the SAME framing renderer as this surface's
572
+ // approval dialog and §6.1 banner — one row per line, each inside the gutter — rather
573
+ // than interpolated into a line the terminal is free to wrap back to column 0.
574
+ if (err instanceof ApprovalStopError) {
575
+ display('\n❌ Error processing message:');
576
+ for (const row of approvalStopRows(err.parts, { columns: output.columns })) {
577
+ display(row);
578
+ }
579
+ display('');
580
+ }
581
+ else {
582
+ display(`\n❌ Error processing message: ${err instanceof Error ? err.message : String(err)}\n`);
583
+ }
165
584
  // EXT-18: askLine() refs stdin first. This prompt runs in the catch after
166
585
  // processMessage threw, by which point the stream's finally has already unref'd
167
586
  // stdin (same exit as the approval prompt) - re-ref so it waits for input.
@@ -183,6 +602,21 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
183
602
  await processMessage(message);
184
603
  }
185
604
  else {
605
+ // TUI-C33 — the ASCII-art launch banner, ABOVE the untouched ready message. Only on this
606
+ // branch: an `-m` run goes straight to work, which is the readline twin of the TUI hiding
607
+ // its intro when it mounts with an initialMessage.
608
+ //
609
+ // Gated on `stdout.isTTY` (as the TUI's viewport bump is) so piped, redirected and non-TTY
610
+ // runs stay clean, and on `getUseColour()` for the escapes, so a monochrome session degrades
611
+ // the banner to plain text instead of dropping it. `stdout.columns` is what every field is
612
+ // truncated against — see launchBanner.ts on why a wrapped line would shatter the art.
613
+ if (output.isTTY) {
614
+ displayLaunchBanner(launchBannerText({
615
+ ...launchBannerFields(config.modelDisplayName, config.modelProviderType),
616
+ columns: output.columns,
617
+ colour: getUseColour(),
618
+ }));
619
+ }
186
620
  display(sessionConfig.readyMessage);
187
621
  displayInfo(sessionConfig.exitMessage);
188
622
  }
@@ -197,7 +631,19 @@ export async function createInteractiveSession(sessionConfig, commandLineConfigO
197
631
  catch (err) {
198
632
  await runner.cleanup();
199
633
  stopSessionLogging();
200
- error(`Error in ${sessionConfig.mode} command: ${err}`);
634
+ // [[TUI-C71]] — **the `-m` path lands here, not on the loop's handler.** `processMessage` is
635
+ // called once directly for `gth chat -m …` / `gth code -m …`, outside the interactive loop's
636
+ // try/catch, so a run-ending approvals stop on that invocation reaches this outermost catch
637
+ // with nothing between it and the terminal. It is the same untrusted text and it gets the same
638
+ // framed renderer; only the channel differs, since this one writes to stderr.
639
+ if (err instanceof ApprovalStopError) {
640
+ error(`Error in ${sessionConfig.mode} command:`);
641
+ for (const row of approvalStopRows(err.parts, { columns: output.columns }))
642
+ error(row);
643
+ }
644
+ else {
645
+ error(`Error in ${sessionConfig.mode} command: ${err}`);
646
+ }
201
647
  exit(1);
202
648
  }
203
649
  }