@gaunt-sloth/agent 2.0.0-alpha.3 → 2.0.0-alpha.31

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 (96) hide show
  1. package/README.md +73 -23
  2. package/dist/builtInToolsConfig.d.ts +17 -3
  3. package/dist/builtInToolsConfig.js +40 -41
  4. package/dist/builtInToolsConfig.js.map +1 -1
  5. package/dist/core/GthDeepAgent.d.ts +52 -32
  6. package/dist/core/GthDeepAgent.js +438 -198
  7. package/dist/core/GthDeepAgent.js.map +1 -1
  8. package/dist/core/debugCapture.d.ts +8 -49
  9. package/dist/core/debugCapture.js +1 -1
  10. package/dist/core/debugCapture.js.map +1 -1
  11. package/dist/core/deepAgentPermissions.d.ts +67 -2
  12. package/dist/core/deepAgentPermissions.js +151 -3
  13. package/dist/core/deepAgentPermissions.js.map +1 -1
  14. package/dist/core/gthAcpServer.js +11 -0
  15. package/dist/core/gthAcpServer.js.map +1 -1
  16. package/dist/core/gthDeepAgentFactory.d.ts +3 -0
  17. package/dist/core/gthDeepAgentFactory.js +9 -1
  18. package/dist/core/gthDeepAgentFactory.js.map +1 -1
  19. package/dist/core/resolveAgentFactory.d.ts +14 -0
  20. package/dist/core/resolveAgentFactory.js +18 -0
  21. package/dist/core/resolveAgentFactory.js.map +1 -0
  22. package/dist/core/subagentProfiles.d.ts +50 -0
  23. package/dist/core/subagentProfiles.js +76 -0
  24. package/dist/core/subagentProfiles.js.map +1 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/mcp/OAuthClientProviderImpl.js.map +1 -1
  29. package/dist/mcp/tlsTrust.d.ts +61 -0
  30. package/dist/mcp/tlsTrust.js +109 -0
  31. package/dist/mcp/tlsTrust.js.map +1 -0
  32. package/dist/middleware/binaryContentInjectionMiddleware.d.ts +8 -1
  33. package/dist/middleware/binaryContentInjectionMiddleware.js +11 -2
  34. package/dist/middleware/binaryContentInjectionMiddleware.js.map +1 -1
  35. package/dist/middleware/frontendImageInjectionMiddleware.d.ts +106 -0
  36. package/dist/middleware/frontendImageInjectionMiddleware.js +166 -0
  37. package/dist/middleware/frontendImageInjectionMiddleware.js.map +1 -0
  38. package/dist/middleware/registry.js +36 -1
  39. package/dist/middleware/registry.js.map +1 -1
  40. package/dist/middleware/types.d.ts +16 -2
  41. package/dist/modules/a2a/A2AClientWrapper.d.ts +56 -1
  42. package/dist/modules/a2a/A2AClientWrapper.js +131 -31
  43. package/dist/modules/a2a/A2AClientWrapper.js.map +1 -1
  44. package/dist/modules/acpModule.js +17 -1
  45. package/dist/modules/acpModule.js.map +1 -1
  46. package/dist/modules/apiAgUiModule.d.ts +87 -0
  47. package/dist/modules/apiAgUiModule.js +229 -28
  48. package/dist/modules/apiAgUiModule.js.map +1 -1
  49. package/dist/modules/interactiveSessionModule.js +501 -41
  50. package/dist/modules/interactiveSessionModule.js.map +1 -1
  51. package/dist/modules/slashCommands.d.ts +576 -0
  52. package/dist/modules/slashCommands.js +995 -0
  53. package/dist/modules/slashCommands.js.map +1 -0
  54. package/dist/resolvers.js +119 -9
  55. package/dist/resolvers.js.map +1 -1
  56. package/dist/tools/GthCustomToolkit.js +95 -11
  57. package/dist/tools/GthCustomToolkit.js.map +1 -1
  58. package/dist/tools/GthDevToolkit.d.ts +11 -3
  59. package/dist/tools/GthDevToolkit.js +120 -34
  60. package/dist/tools/GthDevToolkit.js.map +1 -1
  61. package/dist/tools/GthFileSystemToolkit.d.ts +63 -0
  62. package/dist/tools/GthFileSystemToolkit.js +506 -105
  63. package/dist/tools/GthFileSystemToolkit.js.map +1 -1
  64. package/dist/tools/McpResourceTool.d.ts +31 -0
  65. package/dist/tools/McpResourceTool.js +106 -0
  66. package/dist/tools/McpResourceTool.js.map +1 -0
  67. package/dist/tools/gthChecklistTool.d.ts +30 -0
  68. package/dist/tools/gthChecklistTool.js +81 -0
  69. package/dist/tools/gthChecklistTool.js.map +1 -0
  70. package/dist/tools/gthGrepTool.d.ts +54 -0
  71. package/dist/tools/gthGrepTool.js +482 -0
  72. package/dist/tools/gthGrepTool.js.map +1 -0
  73. package/dist/tools/shell/env.js +1 -1
  74. package/dist/tools/shell/env.js.map +1 -1
  75. package/dist/tools/shell/outputBuffer.js.map +1 -1
  76. package/dist/tools/shell/workDir.d.ts +11 -0
  77. package/dist/tools/shell/workDir.js +45 -0
  78. package/dist/tools/shell/workDir.js.map +1 -0
  79. package/dist/utils/mcpAuthError.d.ts +36 -0
  80. package/dist/utils/mcpAuthError.js +89 -0
  81. package/dist/utils/mcpAuthError.js.map +1 -0
  82. package/dist/utils/mcpUtils.js +16 -0
  83. package/dist/utils/mcpUtils.js.map +1 -1
  84. package/package.json +12 -11
  85. package/dist/tools/shell/allowlist.d.ts +0 -11
  86. package/dist/tools/shell/allowlist.js +0 -12
  87. package/dist/tools/shell/allowlist.js.map +0 -1
  88. package/dist/tools/shell/arity.d.ts +0 -11
  89. package/dist/tools/shell/arity.js +0 -12
  90. package/dist/tools/shell/arity.js.map +0 -1
  91. package/dist/tools/shell/hardline.d.ts +0 -15
  92. package/dist/tools/shell/hardline.js +0 -88
  93. package/dist/tools/shell/hardline.js.map +0 -1
  94. package/dist/tools/shell/normalize.d.ts +0 -10
  95. package/dist/tools/shell/normalize.js +0 -11
  96. package/dist/tools/shell/normalize.js.map +0 -1
@@ -0,0 +1,995 @@
1
+ /**
2
+ * Pure, React-free slash-command layer shared by BOTH interactive surfaces (GS2-8): the Ink
3
+ * TUI (`packages/app/src/tui/`, which re-exports this module) and the readline `--no-tui`
4
+ * session (`interactiveSessionModule.ts` in this package). One registry, one source of truth —
5
+ * a command added here appears in `/help` on both surfaces automatically.
6
+ *
7
+ * Mirrors how the TUI's `viewModel.ts` keeps its fold logic out of the components: the registry
8
+ * and the parse/dispatch helpers here are unit-testable in isolation, and each surface is the
9
+ * only place that turns the resulting {@link SlashCommandResult} into its own state / side
10
+ * effects (the TUI's `<App>` clears the transcript / pushes notices / quits; the readline loop
11
+ * prints notices and degrades TUI-only effects with a clear "needs the TUI" message).
12
+ *
13
+ * The registry is a plain array so later layers (e.g. extension-registered commands, EXT-5)
14
+ * can append more entries via {@link createCommandRegistry} without this module changing.
15
+ */
16
+ import { APPROVAL_POSTURES, APPROVAL_PROTECTION_DOCS_LINES, APPROVAL_RUNG_DESCRIPTIONS, APPROVAL_RUNG_LABELS, APPROVAL_RUNGS, APPROVAL_WRITE_MODIFIER_HINT, isRatedRung, TOOL_ANNOTATION_HINTS, } from '@gaunt-sloth/core/config.js';
17
+ import { describeApprovalEntry } from '@gaunt-sloth/core/core/approvals/matcher.js';
18
+ import { MOUSE_SELECTION_HINT } from '@gaunt-sloth/core/config/mouse.js';
19
+ /**
20
+ * Build the compact, read-only `/config` summary (GS2-1): a handful of the most orienting
21
+ * resolved-config fields, one per line, with a pointer to `gth config print` for the full view.
22
+ * Pure and secret-free — it only reads non-sensitive scalar fields (never API keys / the live
23
+ * llm instance). Used by the App to fill {@link SlashCommandContext.configSummary}.
24
+ *
25
+ * CFG-25 — `filesystem` is a precedence-picked per-command field (GS2-60), so the panel prints the
26
+ * EFFECTIVE value for the running `command`, read from `config.commands[command].filesystem` —
27
+ * where the GS2-60 resolution already baked the correct 4-layer precedence — falling back to the
28
+ * top-level value, exactly the read `getEffectiveConfig` performs (no precedence re-derived here).
29
+ * When the effective and top-level values differ, both are shown
30
+ * (`Filesystem: all (code; top-level: none)`) so the top-level default can never understate what
31
+ * the session can actually do. Of the other precedence-picked fields
32
+ * (`builtInTools`/`allowedTools`/`binaryFormats`), none are printed by this panel, so none can
33
+ * misreport the same way.
34
+ */
35
+ export function formatConfigSummary(config, command) {
36
+ const fmt = (v) => typeof v === 'string' ? v : Array.isArray(v) ? JSON.stringify(v) : String(v);
37
+ const lines = [];
38
+ lines.push(`Model: ${config.modelDisplayName || 'unknown'}`);
39
+ lines.push(`Agent backend: ${config.agent?.backend ?? 'lean'}`);
40
+ const commandFilesystem = command
41
+ ? config.commands?.[command]?.filesystem
42
+ : undefined;
43
+ const effectiveFilesystem = commandFilesystem !== undefined ? commandFilesystem : config.filesystem;
44
+ if (effectiveFilesystem !== undefined) {
45
+ const differs = commandFilesystem !== undefined &&
46
+ config.filesystem !== undefined &&
47
+ fmt(commandFilesystem) !== fmt(config.filesystem);
48
+ lines.push(differs
49
+ ? `Filesystem: ${fmt(commandFilesystem)} (${command}; top-level: ${fmt(config.filesystem)})`
50
+ : `Filesystem: ${fmt(effectiveFilesystem)}`);
51
+ }
52
+ if (config.streamOutput !== undefined)
53
+ lines.push(`Stream output: ${config.streamOutput}`);
54
+ if (config.useColour !== undefined)
55
+ lines.push(`Colour: ${config.useColour}`);
56
+ const commandNames = config.commands ? Object.keys(config.commands) : [];
57
+ if (commandNames.length > 0)
58
+ lines.push(`Commands configured: ${commandNames.join(', ')}`);
59
+ lines.push('Run `gth config print` for the full resolved config (secrets redacted).');
60
+ return lines;
61
+ }
62
+ /**
63
+ * The `/config` notice, from the pre-rendered summary lines (or an unavailable fallback).
64
+ *
65
+ * TUI-C19 — when config-validation `warnings` are present (unknown keys / deprecated names), they
66
+ * are rendered FIRST, as the details the standing "config has problems" advisory line points at,
67
+ * then a blank spacer, then the resolved summary. A clean config (no warnings) reads exactly as
68
+ * before. Tone flips to `warn` (yellow) while there are warnings so the block reads as caution.
69
+ */
70
+ export function configNotice(summary, warnings) {
71
+ const summaryLines = summary && summary.length > 0
72
+ ? summary
73
+ : ['Configuration details are not available in this session.'];
74
+ const hasWarnings = !!warnings && warnings.length > 0;
75
+ const lines = hasWarnings
76
+ ? [
77
+ `${warnings.length === 1 ? 'Config warning' : `Config warnings (${warnings.length})`}:`,
78
+ ...warnings.map((w) => ` • ${w}`),
79
+ '',
80
+ ...summaryLines,
81
+ ]
82
+ : summaryLines;
83
+ return {
84
+ title: 'Resolved configuration',
85
+ lines,
86
+ ...(hasWarnings ? { tone: 'warn' } : {}),
87
+ };
88
+ }
89
+ /** Shared "history is unavailable" body (history off / DB missing), reused by all three commands. */
90
+ const HISTORY_UNAVAILABLE_LINES = [
91
+ 'No local session history is available in this session.',
92
+ 'Enable it with `history.enabled: true` in your gsloth config (local only, opt-in).',
93
+ ];
94
+ /** The `/history` notice (GS2-7): recent recorded sessions, or an "unavailable" fallback. */
95
+ export function historyNotice(summary) {
96
+ return {
97
+ title: 'Recent sessions',
98
+ lines: summary && summary.length > 0 ? summary : HISTORY_UNAVAILABLE_LINES,
99
+ };
100
+ }
101
+ /** The `/insights` notice (GS2-7): local analytics summary, or an "unavailable" fallback. */
102
+ export function insightsNotice(summary) {
103
+ return {
104
+ title: 'Session insights (local only)',
105
+ lines: summary && summary.length > 0 ? summary : HISTORY_UNAVAILABLE_LINES,
106
+ };
107
+ }
108
+ /**
109
+ * The `/search` notice (GS2-7). With no query it prints usage; otherwise it runs the injected
110
+ * fail-soft {@link SlashCommandContext.historySearch} provider and renders its result lines. When
111
+ * no provider is bound (no store), it reports history as unavailable.
112
+ */
113
+ export function searchNotice(args, search) {
114
+ const query = args.join(' ').trim();
115
+ if (!query) {
116
+ return {
117
+ title: 'Search session history',
118
+ lines: ['Usage: /search <terms> — full-text search across your recorded sessions.'],
119
+ };
120
+ }
121
+ if (!search) {
122
+ return { title: `Search: "${query}"`, lines: HISTORY_UNAVAILABLE_LINES };
123
+ }
124
+ return { title: `Search: "${query}"`, lines: search(query) };
125
+ }
126
+ /**
127
+ * Parse a raw input line into a slash command, or `null` if it is not one. A line is a slash
128
+ * command iff its first non-whitespace character is `/` AND no further `/` appears after the
129
+ * leading one (GS2-8, Mari's dogfood addendum): a pasted filesystem path like
130
+ * `/usr/home/bob/test.md` contains later slashes, so it falls through as ordinary prompt text
131
+ * instead of being swallowed as an unknown command. The name is lower-cased; remaining
132
+ * whitespace-separated tokens are the args.
133
+ */
134
+ export function parseSlashCommand(input) {
135
+ const trimmed = input.trim();
136
+ if (!trimmed.startsWith('/'))
137
+ return null;
138
+ // The `/`-vs-path heuristic: a real command has NO further `/` after the leading one.
139
+ if (trimmed.indexOf('/', 1) !== -1)
140
+ return null;
141
+ const tokens = trimmed.slice(1).split(/\s+/).filter(Boolean);
142
+ if (tokens.length === 0)
143
+ return null; // a bare "/" is not a command
144
+ const [name, ...args] = tokens;
145
+ return { name: name.toLowerCase(), args };
146
+ }
147
+ /**
148
+ * TUI-C10 — the discovery-menu trigger test. The Ink `<PromptInput>` shows the slash-command menu
149
+ * while the user is typing a bare command: the raw input is a menu query iff its first character is
150
+ * `/` and it contains no whitespace yet (once a space is typed the user is entering args, so the
151
+ * menu closes and normal dispatch takes over). Returns the lower-cased query AFTER the slash (so a
152
+ * bare `/` yields `''` = "show everything"), or `null` when the input is not a menu trigger.
153
+ *
154
+ * Kept pure and next to the registry (like {@link parseSlashCommand}) so the menu's show/hide and
155
+ * filter logic is unit-testable without React.
156
+ *
157
+ * GS2-8 — mirrors {@link parseSlashCommand}'s `/`-vs-path heuristic: input with a later `/`
158
+ * (a pasted path like `/usr/bin`) is not a command, so it never triggers the menu either.
159
+ */
160
+ export function slashMenuQuery(input) {
161
+ if (!/^\/\S*$/.test(input))
162
+ return null;
163
+ if (input.indexOf('/', 1) !== -1)
164
+ return null; // later `/` ⇒ a path, not a command query
165
+ return input.slice(1).toLowerCase();
166
+ }
167
+ /**
168
+ * TUI-C10 — filter the registry down to the commands that match a menu query, most-relevant first.
169
+ * Prefix matches (the name starts with the query) rank above looser substring matches; within each
170
+ * bucket the registry's own order is preserved (so extension-registered commands — appended to the
171
+ * array — naturally sort after the built-ins). An empty query returns the whole registry, so a bare
172
+ * `/` lists every command including any the extensions added (never a hardcoded list).
173
+ *
174
+ * Pure: takes the registry the caller already built via {@link createCommandRegistry}, so the menu
175
+ * automatically reflects extension commands without this layer knowing they exist.
176
+ */
177
+ export function filterSlashCommands(registry, query) {
178
+ const q = query.toLowerCase();
179
+ if (!q)
180
+ return [...registry];
181
+ const prefix = registry.filter((c) => c.name.startsWith(q));
182
+ const substring = registry.filter((c) => !c.name.startsWith(q) && c.name.includes(q));
183
+ return [...prefix, ...substring];
184
+ }
185
+ /**
186
+ * The notice for the tool-detail toggle, given the RESULTING (post-toggle) state. Shared by the
187
+ * `/verbose` command (GS2-8 rename of `/tools`) and the Ctrl+T key handler so the copy
188
+ * is single-sourced (TUI-C14).
189
+ */
190
+ export function toolsToggleNotice(expanded) {
191
+ return expanded
192
+ ? {
193
+ title: 'Tool details: on',
194
+ lines: [
195
+ 'Tool calls now show their full inputs and results in the chat history.',
196
+ 'Applies to the whole conversation on screen — run /verbose again to collapse them.',
197
+ ],
198
+ }
199
+ : {
200
+ title: 'Tool details: off',
201
+ lines: [
202
+ 'Tool calls now show as a single summary line in the chat history.',
203
+ 'Applies to the whole conversation on screen — run /verbose again to show the detail.',
204
+ ],
205
+ };
206
+ }
207
+ /**
208
+ * The notice for the debug-panel toggle, given the RESULTING (post-toggle) state. Shared so the
209
+ * command reports exactly the state the component will apply.
210
+ */
211
+ export function debugToggleNotice(visible) {
212
+ return visible
213
+ ? {
214
+ title: 'Debug panel: shown',
215
+ lines: [
216
+ 'Docked panel with the subagent tree and sent-to-model / raw-response views.',
217
+ 'Run /debug again to hide it; Tab cycles its views.',
218
+ ],
219
+ }
220
+ : {
221
+ title: 'Debug panel: hidden',
222
+ lines: [
223
+ 'The docked subagent + debug views are now closed.',
224
+ 'Run /debug again to bring them back.',
225
+ ],
226
+ };
227
+ }
228
+ /**
229
+ * CFG-27 — the notice for a landed approvals RUNG, given the RESULTING (post-apply) posture.
230
+ * Shared so every surface reports exactly the state that was applied, and so the copy can never
231
+ * drift from what the gate actually does.
232
+ *
233
+ * The body of each notice is §10's description, **verbatim** — the one place the ladder is
234
+ * explained to the user, so it must not be paraphrased per surface. §10 rule 4 also fixes the
235
+ * label: the display spelling with spaces, never the kebab-case identifier.
236
+ *
237
+ * `bypass` is the only warn-toned one: it is the single rung with no gate at all. Note what is
238
+ * NOT here — the hardline floor. §8.1 forbids advertising it: descriptions name only protections
239
+ * the user can inspect and extend, which is the deny list.
240
+ */
241
+ /**
242
+ * TUI-C37 — feedback for `/mouse`, describing the state the session has LANDED on.
243
+ *
244
+ * The selection hint is repeated on every "on" notice rather than shown once at launch, because the
245
+ * moment a user reaches for `/mouse` is exactly the moment they are trying to copy something and
246
+ * finding that dragging no longer selects. Telling them there and then is the difference between a
247
+ * fixed problem and a filed bug.
248
+ */
249
+ export function mouseToggleNotice(enabled) {
250
+ return {
251
+ title: enabled ? 'Mouse on' : 'Mouse off',
252
+ lines: enabled
253
+ ? [
254
+ 'Clickable parts of the interface respond to the mouse, and the wheel scrolls the conversation.',
255
+ // Qualified rather than promised: the binding is correct, but a terminal that never sets
256
+ // the Shift bit on a wheel report (Konsole is one) delivers a plain notch, so the page
257
+ // never happens there. Naming a key that silently does nothing is the defect TUI-C11 had
258
+ // to correct once already.
259
+ 'Shift and the wheel together move a page, in terminals that forward Shift with the wheel.',
260
+ MOUSE_SELECTION_HINT,
261
+ 'Turn it off for this session with /mouse off, or always with useMouse false in your config.',
262
+ ]
263
+ : [
264
+ 'Mouse reporting is off; text selection and copying work exactly as your terminal normally does.',
265
+ 'Turn it back on with /mouse on.',
266
+ ],
267
+ };
268
+ }
269
+ /** TUI-C37 — `/mouse` on a surface with no mouse layer (the plain readline session, the fixture). */
270
+ export function mouseUnavailableNotice() {
271
+ return {
272
+ title: 'Mouse unavailable',
273
+ lines: [
274
+ 'This session has no mouse layer, so there is nothing to turn on or off.',
275
+ 'Mouse input needs an interactive terminal running the TUI.',
276
+ ],
277
+ tone: 'warn',
278
+ };
279
+ }
280
+ /**
281
+ * TUI-C37 — parse `/mouse [on|off]`. No argument means "toggle", which is what a bare command name
282
+ * usually means; `null` marks an argument that is neither, so the caller can say so rather than
283
+ * guess. `resolve` needs the current state only for the toggle case.
284
+ */
285
+ export function parseMouseArg(args, current) {
286
+ if (args.length === 0)
287
+ return !current;
288
+ const arg = args[0].toLowerCase();
289
+ if (arg === 'on')
290
+ return true;
291
+ if (arg === 'off')
292
+ return false;
293
+ return null;
294
+ }
295
+ export function approvalsRungNotice(approvals) {
296
+ const lines = [APPROVAL_RUNG_DESCRIPTIONS[approvals.rung]];
297
+ if (isRatedRung(approvals.rung) && approvals.rater) {
298
+ lines.push(`The auto-rater runs under the "${approvals.rater}" identity profile.`);
299
+ }
300
+ lines.push('Session-scoped only (not saved); run /approvals to see or change it.');
301
+ // The moment a user changes posture is the moment the reasoning behind the postures is worth
302
+ // reading, and two sentences cannot hold it.
303
+ lines.push(...APPROVAL_PROTECTION_DOCS_LINES);
304
+ return {
305
+ title: `Approvals: ${APPROVAL_RUNG_LABELS[approvals.rung]}`,
306
+ lines,
307
+ tone: approvals.rung === 'bypass' ? 'warn' : 'info',
308
+ };
309
+ }
310
+ /**
311
+ * The first sentence of a mode's description, for the one-line forms (the picker rows and the usage
312
+ * hint). Cut at the sentence boundary rather than truncated, so a row never ends mid-clause.
313
+ *
314
+ * **A sentence ends at `.`, `?` or `!`.** The copy this shortens is ordinary prose, and a helper
315
+ * that knows only the period renders a question as `Really?.` and hands back BOTH sentences of
316
+ * `Ready? Then go.` — the whole description in a row sized for one line.
317
+ *
318
+ * A terminator only ends the sentence when a space follows it and no dot precedes it, so an
319
+ * ellipsis reads as the pause it is instead of three sentence boundaries.
320
+ *
321
+ * **A description that is already one sentence keeps its own terminator rather than gaining a
322
+ * second.** Nothing was consumed, so it arrives already terminated and appending unconditionally
323
+ * would render `No gate..`. Every surface showing one line of a mode's copy goes through here, so
324
+ * this is the only place that has to know any of it.
325
+ */
326
+ export function firstSentence(description) {
327
+ const boundary = /(?<!\.)[.?!](?=\s)/.exec(description);
328
+ const head = boundary ? description.slice(0, boundary.index + 1) : description;
329
+ return /[.?!]$/.test(head) ? head : `${head}.`;
330
+ }
331
+ /**
332
+ * CFG-39 — **the picker's rows**: the four postures, in ladder order, each carrying its own copy.
333
+ *
334
+ * **The strings come from {@link APPROVAL_RUNG_DESCRIPTIONS}, never from the menu.** Six surfaces
335
+ * describe these modes; a picker that authored its own text would become the seventh and the one
336
+ * that contradicts the rest. Whoever owns that constant owns this copy too, with no second edit.
337
+ *
338
+ * `write` has no row — it is a modifier of Manual rather than a fifth posture — but it stays
339
+ * settable via `/approvals write`. When the session IS on `write`, no row is marked `current`,
340
+ * which is correct: the caller reports the live mode from its own title rather than letting a
341
+ * picker row claim the session is on Manual when it is not.
342
+ */
343
+ export function approvalPostureChoices(current) {
344
+ return APPROVAL_POSTURES.map((rung) => ({
345
+ rung,
346
+ label: APPROVAL_RUNG_LABELS[rung],
347
+ description: APPROVAL_RUNG_DESCRIPTIONS[rung],
348
+ current: rung === current,
349
+ }));
350
+ }
351
+ /**
352
+ * CFG-39 — the picker as TEXT: the selectable list every non-TTY surface (ACP, AG-UI, the readline
353
+ * `--no-tui` session, a piped stdout) prints in place of the interactive rows.
354
+ *
355
+ * The same {@link approvalPostureChoices} the TTY picker renders, so the two cannot list different
356
+ * modes or describe them differently — the text fallback is a rendering of the picker, not a
357
+ * second implementation of it.
358
+ */
359
+ export function approvalPostureLines(current) {
360
+ return approvalPostureChoices(current).map((choice) => `${choice.current ? '●' : '○'} ${choice.label} — ${firstSentence(choice.description)}`);
361
+ }
362
+ /**
363
+ * CFG-27 — the `/approvals` DISPLAY: the mode and its description, the rater profile in the two
364
+ * rated modes, and the allow/deny list sizes. Pure: the surface reads the live posture from the
365
+ * runner and hands it in.
366
+ *
367
+ * CFG-39 — it also carries the selectable list, so `/approvals` with no argument answers "which
368
+ * mode am I on?" and "what else could I be on?" in one place on EVERY surface. On a TTY the TUI
369
+ * renders {@link approvalPostureChoices} as an interactive picker instead; this is what everything
370
+ * else shows.
371
+ *
372
+ * `always: undefined` means the persisted store has not been loaded, and is rendered `—` rather
373
+ * than a misleading `0` — a display must not create the store in order to count it.
374
+ */
375
+ export function approvalsStatusNotice(approvals, allowlist, deny = [], grants = [], trust, options = {}) {
376
+ const rater = isRatedRung(approvals.rung)
377
+ ? (approvals.rater ?? 'main model')
378
+ : 'not used in this mode';
379
+ return {
380
+ title: `Approvals: ${APPROVAL_RUNG_LABELS[approvals.rung]}`,
381
+ lines: [
382
+ APPROVAL_RUNG_DESCRIPTIONS[approvals.rung],
383
+ `Auto-rater: ${rater}`,
384
+ `Allowed: ${allowlist.session} this session · ${allowlist.always ?? '—'} remembered · Denied: ${deny.length}`,
385
+ ...describeGrants(grants),
386
+ ...(trust ? [describeMcpTrust(trust)] : []),
387
+ // The docs pointer rides with the mode list, and is absent for the same reason the list is
388
+ // when a picker is coming: **it is not free to print a URL.** It cannot be shortened without
389
+ // breaking it, so it wraps, and here it would push the head of an already-long notice off a
390
+ // short pane — which costs the reader the mode they asked about. A surface that renders the
391
+ // picker prints this pointer a keystroke later instead, from `approvalsRungNotice`, where the
392
+ // user has actually chosen something.
393
+ ...(options.interactive
394
+ ? []
395
+ : [
396
+ 'Choose a mode with /approvals <name>:',
397
+ ...approvalPostureLines(approvals.rung),
398
+ APPROVAL_WRITE_MODIFIER_HINT,
399
+ ...APPROVAL_PROTECTION_DOCS_LINES,
400
+ ]),
401
+ TRUST_USAGE_LINE,
402
+ ],
403
+ tone: approvals.rung === 'bypass' ? 'warn' : 'info',
404
+ };
405
+ }
406
+ /** How many grants `/approvals` lists before it summarizes the rest. */
407
+ const GRANT_DISPLAY_LIMIT = 10;
408
+ /**
409
+ * §3/§4.7.4 — the granted lines of the `/approvals` display: **what** was granted, **when**, and
410
+ * **under which effective annotations**.
411
+ *
412
+ * The annotations are the half that only a tool grant has, and the half §4.7.4 exists for: a grant
413
+ * is an approval of a tool *as annotated*, so a user asked to believe that has to be able to see
414
+ * what they believed. Each entry is rendered by `describeApprovalEntry` — the same one-liner the
415
+ * escalation menu names the grant with and the withdrawal notice names it with.
416
+ *
417
+ * **The heading claims no scope**, because the list spans both: `getGrants` reports the persisted
418
+ * store's `always` grants alongside this session's, and an `always` grant was made in whatever
419
+ * session the user made it in — often not this one. Each line carries its own scope and the instant
420
+ * it was granted, which is where that question is answered.
421
+ */
422
+ function describeGrants(grants) {
423
+ if (grants.length === 0)
424
+ return [];
425
+ const shown = grants.slice(0, GRANT_DISPLAY_LIMIT).map((grant) => {
426
+ const annotations = grant.annotations
427
+ ? `, approved as ${TOOL_ANNOTATION_HINTS.map((hint) => `${hint}=${grant.annotations?.[hint]}`).join(' ')}`
428
+ : '';
429
+ return ` ${describeApprovalEntry(grant.entry)} — ${grant.scope}, granted ${grant.grantedAt}${annotations}`;
430
+ });
431
+ const rest = grants.length - shown.length;
432
+ return [`Granted approvals:`, ...shown, ...(rest > 0 ? [` …and ${rest} more.`] : [])];
433
+ }
434
+ /**
435
+ * §4.7.1 — the one line saying which of each server's annotation hints this session believes.
436
+ *
437
+ * A server is listed even when it believes nothing, because "believes nothing" is the default and
438
+ * the user needs to be able to tell it apart from "this server is not here at all" — the two look
439
+ * identical when only trusted servers are listed, and the second is what a typo produces.
440
+ */
441
+ function describeMcpTrust(trust) {
442
+ const parts = [
443
+ `defaults — ${trust.defaults.length > 0 ? trust.defaults.join(' ') : 'nothing'}`,
444
+ ...trust.servers.map((entry) => `${entry.server} — ${entry.trusted.length > 0 ? entry.trusted.join(' ') : 'nothing'}`),
445
+ ];
446
+ return `MCP annotations believed: ${parts.join(' · ')}`;
447
+ }
448
+ /** The `/approvals trust` half of the usage copy, shown wherever the rung half is. */
449
+ const TRUST_USAGE_LINE = 'Believe an MCP server’s annotation hints with /approvals trust <server> <hint…>, ' +
450
+ 'and stop believing them with /approvals untrust <server> <hint…>.';
451
+ /**
452
+ * CFG-27/EXT-70 — parse the `/approvals` argument: no arg SHOWS the current posture; any of the
453
+ * five mode names switches to it; `trust` / `untrust` move which of a server's annotation hints are
454
+ * believed (§4.7.1). Returns `null` for an unrecognized first argument so the command renders a
455
+ * usage hint instead of guessing.
456
+ *
457
+ * **All five names are accepted, not the four the picker offers.** `write` is a modifier of
458
+ * `manual` rather than a posture of its own, so it leaves quick access — but it stays fully
459
+ * settable here and in config, which is the whole of what "demoted" means.
460
+ *
461
+ * The retired `read-only` / `auto-safe` / `full-auto` / `ask` spellings are NOT accepted as
462
+ * aliases — this is still alpha, and a silent alias would leave the user believing in a vocabulary
463
+ * the gate no longer has. They are named, with their replacements, by the config-layer error in
464
+ * `RETIRED_APPROVAL_MODES`.
465
+ *
466
+ * **Only the subcommand token is lower-cased.** A server key is the user's own `mcpServers` key
467
+ * (§4.7.5) and is case-sensitive, so folding it would name a different server — one that believes
468
+ * nothing, silently, while the notice reported success. A hint is matched case-insensitively
469
+ * against the fixed vocabulary and echoed back in its **canonical** spelling, so `readonlyhint`
470
+ * resolves rather than vanishing, and what lands in the policy is the name the derivation reads.
471
+ */
472
+ export function parseApprovalsArg(args) {
473
+ if (args.length === 0)
474
+ return { show: true };
475
+ const verb = args[0].toLowerCase();
476
+ const rung = APPROVAL_RUNGS.find((r) => r === verb);
477
+ if (rung)
478
+ return { rung };
479
+ if (verb !== 'trust' && verb !== 'untrust')
480
+ return null;
481
+ const believe = verb === 'trust';
482
+ const server = args[1];
483
+ if (!server)
484
+ return { usage: { kind: 'trust-missing-server', believe } };
485
+ const tokens = args.slice(2);
486
+ if (tokens.length === 0)
487
+ return { usage: { kind: 'trust-missing-hints', believe, server } };
488
+ const hints = [];
489
+ for (const token of tokens) {
490
+ const hint = TOOL_ANNOTATION_HINTS.find((h) => h.toLowerCase() === token.toLowerCase());
491
+ if (!hint)
492
+ return { usage: { kind: 'unknown-hint', believe, token } };
493
+ if (!hints.includes(hint))
494
+ hints.push(hint);
495
+ }
496
+ return { trust: { server, hints, believe } };
497
+ }
498
+ /** The verb a notice names an invocation by, so copy never has to branch twice on the same flag. */
499
+ const trustVerb = (believe) => (believe ? 'trust' : 'untrust');
500
+ /**
501
+ * EXT-70 §4.7.1 — usage copy for a `/approvals trust` invocation that named no server, no hint, or
502
+ * a hint that is not one of the four. Each says what is missing and shows the vocabulary, because
503
+ * the hint names are camelCase MCP identifiers nobody guesses.
504
+ */
505
+ export function approvalsTrustUsageNotice(problem) {
506
+ const usage = `Usage: /approvals ${trustVerb(problem.believe)} <server> <hint…> — hints are ${TOOL_ANNOTATION_HINTS.join(', ')}.`;
507
+ const perHint = 'Trust is per hint and per server: you may believe a server’s readOnlyHint while disbelieving ' +
508
+ 'its openWorldHint.';
509
+ if (problem.kind === 'trust-missing-server') {
510
+ return {
511
+ title: `Which server should this ${trustVerb(problem.believe)}?`,
512
+ lines: [
513
+ 'Name the server by the key you gave it under mcpServers in your config — that is the only ' +
514
+ 'identity a server has here.',
515
+ usage,
516
+ perHint,
517
+ ],
518
+ tone: 'warn',
519
+ };
520
+ }
521
+ if (problem.kind === 'trust-missing-hints') {
522
+ return {
523
+ title: `Which hints should this ${trustVerb(problem.believe)} for ${problem.server}?`,
524
+ lines: [`Name at least one hint. Nothing was changed for ${problem.server}.`, usage, perHint],
525
+ tone: 'warn',
526
+ };
527
+ }
528
+ return {
529
+ title: `Not an annotation hint: ${problem.token}`,
530
+ lines: [`Nothing was changed.`, usage, perHint],
531
+ tone: 'warn',
532
+ };
533
+ }
534
+ /**
535
+ * EXT-70 §4.7.1/§4.7.4 — the notice for a landed `/approvals trust` or `/approvals untrust`, built
536
+ * from what the runner RETURNS rather than from what was asked for, so the copy can only describe
537
+ * the trust actually in force.
538
+ *
539
+ * **The withdrawal half states the consequence where the withdrawal happens.** Ceasing to believe a
540
+ * hint pushes it back to the MCP fail-closed default, and for `readOnlyHint`, `openWorldHint` and
541
+ * `destructiveHint` that is a *weakening* — so §4.7.4 will withdraw that server's saved approvals at
542
+ * the next call, with its own notice. That is the correct direction and is not suppressed; what
543
+ * would be wrong is for the user to meet it as a surprise three turns later. `idempotentHint` is the
544
+ * one hint no weakening move names, so withdrawing it invalidates nothing and the line is absent —
545
+ * which is why the line is driven by the runner's `weakening` list rather than by "was anything
546
+ * withdrawn".
547
+ */
548
+ export function approvalsTrustNotice(change) {
549
+ const believed = change.trusted.length > 0
550
+ ? `Believed from ${change.server}: ${change.trusted.join(', ')}.`
551
+ : `Nothing is believed from ${change.server}; every hint takes its fail-closed default.`;
552
+ const lines = [];
553
+ if (change.added.length > 0) {
554
+ lines.push(`Now believing from ${change.server}: ${change.added.join(', ')}.`);
555
+ }
556
+ if (change.removed.length > 0) {
557
+ lines.push(`No longer believing from ${change.server}: ${change.removed.join(', ')}.`);
558
+ }
559
+ if (change.added.length === 0 && change.removed.length === 0) {
560
+ lines.push(`Nothing changed — ${change.server} was already believed on exactly those hints.`);
561
+ }
562
+ lines.push(believed);
563
+ if (change.weakening.length > 0) {
564
+ const withdrawn = change.weakening.join(', ');
565
+ // Precise where it can be, a rule where it cannot. Naming the grants is only honest for the
566
+ // ones this session can actually see weakened right now; for everything else the rule is
567
+ // stated, because promising a specific withdrawal that then does not happen teaches the user
568
+ // to disbelieve the notice.
569
+ lines.push(change.invalidates.length > 0
570
+ ? `Because ${withdrawn} is no longer believed, ${change.server}'s tools describe themselves ` +
571
+ 'as more dangerous than when you approved them. These saved approvals will be ' +
572
+ `withdrawn the next time that tool is called, and you will be asked again: ` +
573
+ `${change.invalidates.join('; ')}.`
574
+ : `Withdrawing ${withdrawn} can make ${change.server}'s tools describe themselves as more ` +
575
+ `dangerous than when you approved them, so any saved approval for ${change.server} ` +
576
+ 'made while it was believed is withdrawn the next time that tool is called, and you ' +
577
+ 'are asked again.');
578
+ }
579
+ if (!change.configured) {
580
+ lines.push(`Note: no server is configured under the key "${change.server}". A server is identified by ` +
581
+ 'your own mcpServers key, so check the spelling — policy written for a key nothing uses ' +
582
+ 'has no effect.');
583
+ }
584
+ lines.push('A believed hint never grants a server more than the same hint grants a built-in tool.', 'Session-scoped only (not saved); run /approvals to see it.');
585
+ return {
586
+ title: `MCP annotations believed: ${change.server}`,
587
+ lines,
588
+ tone: change.weakening.length > 0 ? 'warn' : 'info',
589
+ };
590
+ }
591
+ /**
592
+ * TUI-C18 — resolve a `/reasoning` invocation against the committed turns' reasoning (in transcript
593
+ * order, index 0 = turn 1). Pure, so the whole selection + friendly-notice logic is unit-testable
594
+ * without React:
595
+ *
596
+ * - **no arg** → the most recent turn that actually recorded thinking; if none exists, a friendly
597
+ * info notice (nothing to show).
598
+ * - **`<n>`** → turn `n` (1-based). A non-positive / non-integer / out-of-range `n` → a warn notice;
599
+ * a valid turn that recorded no thinking → an info notice. Otherwise a `reprintReasoning` request.
600
+ *
601
+ * The App renders a `reprintReasoning` result as a fresh reasoning block (reusing the TUI-C15
602
+ * styling) and a `notice` result via the shared `CommandNotice`.
603
+ */
604
+ export function resolveReasoning(reasonings, args) {
605
+ const count = reasonings.length;
606
+ const has = (i) => (reasonings[i] ?? '').trim().length > 0;
607
+ if (args.length > 0) {
608
+ // `Number(...)` (not parseInt) so "2x"/"1.5"/"" don't silently coerce to a valid index.
609
+ const raw = args[0];
610
+ const n = Number(raw);
611
+ if (!Number.isInteger(n) || n < 1 || n > count) {
612
+ return {
613
+ notice: {
614
+ title: `No turn ${raw}`,
615
+ lines: count === 0
616
+ ? [
617
+ 'This session has no committed turns yet.',
618
+ 'Ask something first, then run /reasoning.',
619
+ ]
620
+ : [
621
+ `Pick a turn between 1 and ${count} (this session has ${count} so far).`,
622
+ 'Run /reasoning with no number for the most recent turn that recorded thinking.',
623
+ ],
624
+ tone: 'warn',
625
+ },
626
+ };
627
+ }
628
+ const idx = n - 1;
629
+ if (!has(idx)) {
630
+ return {
631
+ notice: {
632
+ title: `Turn ${n} has no thinking`,
633
+ lines: [
634
+ `Turn ${n} didn't record a thinking layer (only some models stream one).`,
635
+ 'Run /reasoning (no number) to jump to the most recent turn that did.',
636
+ ],
637
+ },
638
+ };
639
+ }
640
+ return { reprintReasoning: { reasoning: reasonings[idx], turnNumber: n } };
641
+ }
642
+ // No arg: walk back to the most recent turn that recorded thinking.
643
+ for (let i = count - 1; i >= 0; i--) {
644
+ if (has(i))
645
+ return { reprintReasoning: { reasoning: reasonings[i], turnNumber: i + 1 } };
646
+ }
647
+ return {
648
+ notice: {
649
+ title: 'No thinking to show',
650
+ lines: [
651
+ 'No turn in this session has recorded a thinking layer yet.',
652
+ 'Reasoning appears for models that stream a thinking / chain-of-thought layer.',
653
+ ],
654
+ },
655
+ };
656
+ }
657
+ /**
658
+ * `/debug-dump` when no `dumpDebugSession` writer is injected — the fixture agent, or the readline
659
+ * (`--no-tui`) session, which shares this registry (GS2-8) but has no session archive writer.
660
+ */
661
+ const DEBUG_DUMP_UNAVAILABLE_LINES = [
662
+ 'No debug-dump writer is available in this session.',
663
+ 'This is only available in a real TUI session (not the fixture agent or the --no-tui fallback).',
664
+ ];
665
+ /**
666
+ * GS2-47 — resolve whether the `/debug-dump` archive should be redacted. ON by default; opt out via
667
+ * the config (`debugDump.redact: false`) OR the `--unsafe-no-redact` command flag. Any uncertainty
668
+ * (no/non-object config) defaults to redacting — fail safe. `resolvedConfig` is opaque here, so this
669
+ * reads the flag structurally without depending on the `GthConfig` type.
670
+ */
671
+ export function resolveDebugDumpRedact(resolvedConfig, args) {
672
+ if (args.some((a) => a === '--unsafe-no-redact' || a === '--no-redact'))
673
+ return false;
674
+ const debugDump = resolvedConfig?.debugDump;
675
+ if (debugDump &&
676
+ typeof debugDump === 'object' &&
677
+ debugDump.redact === false) {
678
+ return false;
679
+ }
680
+ return true;
681
+ }
682
+ /**
683
+ * The `/debug-dump` success notice (a standard 3-line CommandNotice — DL-1: no command reads as
684
+ * "does nothing"). GS2-47 flips the default to REDACTED: when redaction ran (the default) the note
685
+ * is softened ("secrets redacted; review before sharing") and points at the opt-out. When the user
686
+ * opted OUT (raw archive) it is the loud, impossible-to-miss UNSANITIZED warning. Colour follows
687
+ * DL-8 / the tone rule in maintenance/ux-guidelines.md: the safe, redacted default is normal
688
+ * feedback (no `tone` ⇒ info), while the raw opt-out is caution and so `tone: 'warn'` (yellow) —
689
+ * mirroring how `approvalsModeNotice` reserves yellow for the dangerous (gate-off) state. Redaction is
690
+ * best-effort pattern-based, so even the softened note still says review-before-sharing.
691
+ */
692
+ export function debugDumpNotice(archiveDir, redacted) {
693
+ if (redacted) {
694
+ return {
695
+ title: 'Debug dump written — secrets redacted',
696
+ lines: [
697
+ `Archive: ${archiveDir}`,
698
+ '',
699
+ 'Secrets were redacted (API keys, tokens and auth headers replaced with <redacted>).',
700
+ 'Redaction is best-effort and pattern-based — review before sharing.',
701
+ '',
702
+ 'To write a raw, unredacted archive: set `debugDump.redact: false` in your gsloth config,',
703
+ 'or run `/debug-dump --unsafe-no-redact`.',
704
+ ],
705
+ };
706
+ }
707
+ return {
708
+ title: '⚠️ Debug dump written — UNSANITIZED, review before sharing',
709
+ lines: [
710
+ `Archive: ${archiveDir}`,
711
+ '',
712
+ 'This archive contains the full transcript, resolved config, env info, debug log and git',
713
+ 'state AS-IS — it may include secrets: API keys, tokens, file contents, env vars.',
714
+ 'Review it carefully before sending it anywhere.',
715
+ ],
716
+ tone: 'warn',
717
+ };
718
+ }
719
+ /**
720
+ * Build the default command registry. Returns a fresh array each call so callers may push
721
+ * extension commands onto it (EXT-5) without sharing mutable module state.
722
+ */
723
+ export function createCommandRegistry() {
724
+ return [
725
+ {
726
+ name: 'help',
727
+ description: 'List available slash commands',
728
+ // The help body needs the whole registry, so dispatch special-cases `/help` and
729
+ // calls formatHelp; this stub keeps `/help` listed and self-described.
730
+ run: () => ({ message: 'Available commands (see /help).' }),
731
+ },
732
+ {
733
+ name: 'clear',
734
+ description: 'Clear the transcript',
735
+ // The visible feedback is the <ClearBanner>, which survives the transcript wipe because it
736
+ // is not a transcript item, so no committed notice here.
737
+ run: () => ({ clearTranscript: true }),
738
+ },
739
+ {
740
+ name: 'debug',
741
+ description: 'Toggle the docked subagents + debug panel',
742
+ availableDuringRun: true,
743
+ // State-aware: report the notice for the state the toggle will land on (the inverse of now).
744
+ run: (ctx) => ({ toggleDebug: true, notice: debugToggleNotice(!ctx.debugVisible) }),
745
+ },
746
+ {
747
+ name: 'verbose',
748
+ description: 'Toggle tool-call detail (collapsed summary ⇄ expanded args/result)',
749
+ availableDuringRun: true,
750
+ // State-aware: report the notice for the state the toggle will land on (the inverse of now).
751
+ run: (ctx) => ({ toggleTools: true, notice: toolsToggleNotice(!ctx.toolsExpanded) }),
752
+ },
753
+ {
754
+ name: 'mouse',
755
+ // The `/help` line carries the selection hint too, not just the `/mouse` feedback: someone
756
+ // scanning `/help` because dragging stopped selecting needs the answer where they are looking.
757
+ description: 'Turn terminal mouse reporting on or off (/mouse on|off; no arg toggles). ' +
758
+ 'While on, hold Shift (Option in some macOS terminals) to select text',
759
+ availableDuringRun: true,
760
+ // Available mid-turn deliberately: the reason to reach for this is usually wanting to copy
761
+ // something off the screen, and that urge does not wait for the run to finish.
762
+ run: (ctx, args) => {
763
+ if (ctx.mouseEnabled === undefined)
764
+ return { notice: mouseUnavailableNotice() };
765
+ const target = parseMouseArg(args, ctx.mouseEnabled);
766
+ if (target === null) {
767
+ return {
768
+ notice: {
769
+ title: `Unknown option: ${args[0]}`,
770
+ lines: ['Usage: /mouse [on|off] — with no argument it toggles.'],
771
+ tone: 'warn',
772
+ },
773
+ };
774
+ }
775
+ return { setMouse: target, notice: mouseToggleNotice(target) };
776
+ },
777
+ },
778
+ {
779
+ name: 'approvals',
780
+ description: 'Show or switch the approvals mode ' +
781
+ `(/approvals ${APPROVAL_RUNGS.join('|')}; no arg shows it and offers a picker), ` +
782
+ 'or believe an MCP server’s annotation hints (/approvals trust|untrust <server> <hint…>)',
783
+ // Available mid-turn so the user can change how the run's REMAINING tool calls are handled
784
+ // (EXT-12's reason, generalized to the rung). The surface owns the runner posture, so it
785
+ // applies the change and commits the notice for the landed state.
786
+ //
787
+ // CFG-27 retired `/auto-approve` and `/bypass-approve` with the three-mode vocabulary they
788
+ // named. Neither maps onto the ladder honestly — "auto-approve off" had to mean one of two
789
+ // different rungs — and `/approvals <rung>` says exactly what it will do.
790
+ availableDuringRun: true,
791
+ run: (_ctx, args) => {
792
+ const action = parseApprovalsArg(args);
793
+ if (action === null) {
794
+ return {
795
+ notice: {
796
+ title: `Unknown option: ${args[0]}`,
797
+ lines: [
798
+ `Usage: /approvals [${APPROVAL_RUNGS.join('|')}] — with no argument it shows the current mode.`,
799
+ ...APPROVAL_RUNGS.map((rung) => `${rung} — ${firstSentence(APPROVAL_RUNG_DESCRIPTIONS[rung])}`),
800
+ TRUST_USAGE_LINE,
801
+ ],
802
+ tone: 'warn',
803
+ },
804
+ };
805
+ }
806
+ // A malformed `trust`/`untrust` is explained rather than applied: nothing is changed, so
807
+ // the surface has nothing to do and the command answers on its own.
808
+ if ('usage' in action)
809
+ return { notice: approvalsTrustUsageNotice(action.usage) };
810
+ return { approvals: action };
811
+ },
812
+ },
813
+ {
814
+ name: 'exit',
815
+ description: 'Quit the session',
816
+ run: () => ({ exit: true }),
817
+ },
818
+ {
819
+ name: 'quit',
820
+ description: 'Quit the session (alias of /exit)',
821
+ // GS2-8 — an equal-citizen alias, no deprecation: both names quit.
822
+ run: () => ({ exit: true }),
823
+ },
824
+ {
825
+ name: 'status',
826
+ description: 'Show session status (mode, model, turns)',
827
+ availableDuringRun: true,
828
+ // GS2-8 — absorbs the old `/mode` command: the mode line (and how to change it) now reads
829
+ // as part of one status block alongside the model and turn count already in context.
830
+ run: (ctx) => ({
831
+ notice: {
832
+ title: 'Session status',
833
+ lines: [
834
+ `Mode: ${ctx.mode} — how the agent handles your messages this session.`,
835
+ `Model: ${ctx.modelDisplayName || 'unknown'}`,
836
+ `Turns so far: ${ctx.turnCount}`,
837
+ 'Restart with a different subcommand to change the mode (e.g. `gth chat`).',
838
+ ],
839
+ },
840
+ }),
841
+ },
842
+ {
843
+ name: 'config',
844
+ description: 'Show the resolved configuration (read-only)',
845
+ availableDuringRun: true,
846
+ // Read-only discovery: surface the pre-rendered, secret-free summary the App computed from
847
+ // the resolved config, prefixed with any load-time validation warnings (TUI-C19 — the
848
+ // details the standing advisory line points at). Editing lives in `gth init` / the config
849
+ // file, not here (GS2-1).
850
+ run: (ctx) => ({ notice: configNotice(ctx.configSummary, ctx.configWarnings) }),
851
+ },
852
+ {
853
+ name: 'history',
854
+ description: 'Show recent recorded sessions (local, opt-in history)',
855
+ availableDuringRun: true,
856
+ // Read-only discovery, mirroring /config: render the App's fail-soft, pre-built summary.
857
+ run: (ctx) => ({ notice: historyNotice(ctx.historySummary) }),
858
+ },
859
+ {
860
+ name: 'search',
861
+ description: 'Search recorded session history (/search <terms>)',
862
+ availableDuringRun: true,
863
+ // Dynamic query, so it calls the App-injected fail-soft search provider (stubbable in tests).
864
+ run: (ctx, args) => ({ notice: searchNotice(args, ctx.historySearch) }),
865
+ },
866
+ {
867
+ name: 'insights',
868
+ description: 'Show local analytics over recorded sessions (tokens, cost, top tools)',
869
+ availableDuringRun: true,
870
+ run: (ctx) => ({ notice: insightsNotice(ctx.insightsSummary) }),
871
+ },
872
+ {
873
+ name: 'model',
874
+ description: 'Show the current model / provider',
875
+ availableDuringRun: true,
876
+ run: (ctx) => ({
877
+ notice: {
878
+ title: `Model: ${ctx.modelDisplayName || 'unknown'}`,
879
+ lines: [
880
+ 'This is the model answering your messages this session.',
881
+ 'Change the default via `gth init` or your gsloth config.',
882
+ ],
883
+ },
884
+ }),
885
+ },
886
+ {
887
+ name: 'reasoning',
888
+ description: "Reprint a turn's thinking (/reasoning [n]; no number = latest with thinking)",
889
+ // Read-only recall of a past turn's thinking — safe to run mid-turn, like /history and /config.
890
+ availableDuringRun: true,
891
+ // Pure: resolve the target from the App-provided committed reasonings; the App renders the
892
+ // reprint (reusing TUI-C15 styling) or the friendly notice.
893
+ run: (ctx, args) => resolveReasoning(ctx.turnReasonings ?? [], args),
894
+ },
895
+ {
896
+ name: 'debug-dump',
897
+ description: 'Dump transcript + config + env + debug log to ~/.gsloth/debug-dumps (secrets redacted; --unsafe-no-redact keeps raw)',
898
+ // Read-only from the transcript/thread's perspective (it only writes a diagnostic archive,
899
+ // never mutates session state), so it's useful precisely when something is going wrong
900
+ // mid-turn — mirrors /history, /config, /debug being availableDuringRun.
901
+ availableDuringRun: true,
902
+ run: (ctx, args) => {
903
+ if (!ctx.dumpDebugSession) {
904
+ return {
905
+ notice: {
906
+ title: 'Debug dump unavailable',
907
+ lines: DEBUG_DUMP_UNAVAILABLE_LINES,
908
+ tone: 'warn',
909
+ },
910
+ };
911
+ }
912
+ // GS2-47 — redact by default; opt out via config `debugDump.redact: false` or the
913
+ // `--unsafe-no-redact` flag. The resolved flag is threaded into the writer AND picks the
914
+ // notice (softened when redacted, loud "unsanitized" warning when raw).
915
+ const redact = resolveDebugDumpRedact(ctx.resolvedConfig, args);
916
+ const { archiveDir } = ctx.dumpDebugSession({
917
+ transcript: ctx.transcript ?? [],
918
+ config: ctx.resolvedConfig,
919
+ modelDisplayName: ctx.modelDisplayName,
920
+ redact,
921
+ });
922
+ return { notice: debugDumpNotice(archiveDir, redact) };
923
+ },
924
+ },
925
+ ];
926
+ }
927
+ /**
928
+ * Build the `/help` notice from a registry: one body line per command (`/name — description`),
929
+ * followed by the calling surface's own key bindings when it supplies any (TUI-C63).
930
+ *
931
+ * The bindings are a PARAMETER, never a constant in this module: `/help` is the reference for the
932
+ * surface the reader is looking at, and a key that surface does not have is worse than no entry at
933
+ * all. A surface that passes nothing gets exactly the command list, byte for byte.
934
+ */
935
+ export function formatHelp(registry, keyBindings = []) {
936
+ const lines = registry.map((c) => `/${c.name} — ${c.description}`);
937
+ // One separator row where the commands end and the keys begin. A SPACE, not the empty string: a
938
+ // sibling <Text> holding '' collapses to nothing in Ink's column, so an empty line here would be
939
+ // code that claims a gap the screen never draws. Groups below need none — an unindented title
940
+ // above indented bindings already reads as a group, and this block is long enough already.
941
+ if (keyBindings.length > 0)
942
+ lines.push(' ');
943
+ for (const group of keyBindings) {
944
+ lines.push(group.title);
945
+ for (const binding of group.bindings)
946
+ lines.push(` ${binding.keys} — ${binding.description}`);
947
+ }
948
+ return {
949
+ // The title states what the block actually contains, so it cannot promise keys to a surface
950
+ // that has none.
951
+ title: keyBindings.length > 0 ? 'Slash commands and keys' : 'Slash commands',
952
+ lines,
953
+ };
954
+ }
955
+ /**
956
+ * Dispatch a parsed command against a registry. Unknown commands return a friendly hint
957
+ * rather than throwing, so the component can render it as a system line and never forward
958
+ * the text to the model.
959
+ *
960
+ * EXT-12 — when `options.duringRun` is set (a turn is streaming), commands that are not marked
961
+ * {@link SlashCommand.availableDuringRun} are refused with a friendly notice rather than run,
962
+ * so mid-turn input can only reach the safe, non-mutating commands (`/approvals`, `/verbose`,
963
+ * `/debug`, …). `/help` is always allowed.
964
+ */
965
+ export function dispatchSlashCommand(parsed, registry, ctx, options = {}) {
966
+ if (parsed.name === 'help') {
967
+ // TUI-C63 — the bindings section comes from the context, so each surface advertises its own
968
+ // keyboard and only its own.
969
+ return { notice: formatHelp(registry, ctx.keyBindings) };
970
+ }
971
+ const command = registry.find((c) => c.name === parsed.name);
972
+ if (!command) {
973
+ return {
974
+ notice: {
975
+ title: `Unknown command: /${parsed.name}`,
976
+ lines: ["That isn't a recognized slash command.", 'Run /help to see everything available.'],
977
+ tone: 'warn',
978
+ },
979
+ };
980
+ }
981
+ if (options.duringRun && !command.availableDuringRun) {
982
+ return {
983
+ notice: {
984
+ title: `/${command.name} is not available while the agent is working`,
985
+ lines: [
986
+ 'Wait for the current turn to finish, then run it again.',
987
+ 'Commands like /approvals, /verbose and /debug do work mid-turn.',
988
+ ],
989
+ tone: 'warn',
990
+ },
991
+ };
992
+ }
993
+ return command.run(ctx, parsed.args);
994
+ }
995
+ //# sourceMappingURL=slashCommands.js.map