@namzu/cli 18.1.0 → 19.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +5898 -0
  2. package/README.md +23 -24
  3. package/dist/bin.js +12 -1
  4. package/dist/bin.js.map +1 -1
  5. package/dist/checkpoints/store.d.ts +75 -0
  6. package/dist/checkpoints/store.d.ts.map +1 -0
  7. package/dist/checkpoints/store.js +177 -0
  8. package/dist/checkpoints/store.js.map +1 -0
  9. package/dist/checkpoints/wrap.d.ts +15 -0
  10. package/dist/checkpoints/wrap.d.ts.map +1 -0
  11. package/dist/checkpoints/wrap.js +37 -0
  12. package/dist/checkpoints/wrap.js.map +1 -0
  13. package/dist/cli.d.ts +6 -0
  14. package/dist/cli.d.ts.map +1 -1
  15. package/dist/cli.js +12 -1
  16. package/dist/cli.js.map +1 -1
  17. package/dist/commands/acp.d.ts.map +1 -1
  18. package/dist/commands/acp.js +2 -0
  19. package/dist/commands/acp.js.map +1 -1
  20. package/dist/commands/drain.d.ts.map +1 -1
  21. package/dist/commands/drain.js +15 -2
  22. package/dist/commands/drain.js.map +1 -1
  23. package/dist/commands/provider-wait.d.ts +44 -0
  24. package/dist/commands/provider-wait.d.ts.map +1 -0
  25. package/dist/commands/provider-wait.js +65 -0
  26. package/dist/commands/provider-wait.js.map +1 -0
  27. package/dist/commands/run-flags.d.ts +13 -1
  28. package/dist/commands/run-flags.d.ts.map +1 -1
  29. package/dist/commands/run-flags.js +41 -0
  30. package/dist/commands/run-flags.js.map +1 -1
  31. package/dist/commands/run-stream.d.ts.map +1 -1
  32. package/dist/commands/run-stream.js +33 -13
  33. package/dist/commands/run-stream.js.map +1 -1
  34. package/dist/commands/run.d.ts.map +1 -1
  35. package/dist/commands/run.js +132 -78
  36. package/dist/commands/run.js.map +1 -1
  37. package/dist/commands/stdin.d.ts +17 -0
  38. package/dist/commands/stdin.d.ts.map +1 -0
  39. package/dist/commands/stdin.js +69 -0
  40. package/dist/commands/stdin.js.map +1 -0
  41. package/dist/config/load.d.ts.map +1 -1
  42. package/dist/config/load.js +107 -2
  43. package/dist/config/load.js.map +1 -1
  44. package/dist/config/schema.d.ts +80 -5
  45. package/dist/config/schema.d.ts.map +1 -1
  46. package/dist/config/schema.js.map +1 -1
  47. package/dist/context/doctrine.d.ts +16 -38
  48. package/dist/context/doctrine.d.ts.map +1 -1
  49. package/dist/context/doctrine.js +16 -75
  50. package/dist/context/doctrine.js.map +1 -1
  51. package/dist/context/environment.d.ts +2 -0
  52. package/dist/context/environment.d.ts.map +1 -1
  53. package/dist/context/environment.js +3 -0
  54. package/dist/context/environment.js.map +1 -1
  55. package/dist/integrations/mcp/servers.d.ts +19 -0
  56. package/dist/integrations/mcp/servers.d.ts.map +1 -1
  57. package/dist/integrations/mcp/servers.js +23 -2
  58. package/dist/integrations/mcp/servers.js.map +1 -1
  59. package/dist/integrations/plugins/runtime.d.ts +8 -2
  60. package/dist/integrations/plugins/runtime.d.ts.map +1 -1
  61. package/dist/integrations/plugins/runtime.js +24 -12
  62. package/dist/integrations/plugins/runtime.js.map +1 -1
  63. package/dist/integrations/providers/chain-capabilities.js +1 -1
  64. package/dist/integrations/providers/chain-capabilities.js.map +1 -1
  65. package/dist/integrations/providers/credential-store.d.ts +1 -1
  66. package/dist/integrations/providers/credential-store.js +1 -1
  67. package/dist/integrations/providers/harness-credentials.d.ts.map +1 -1
  68. package/dist/integrations/providers/harness-credentials.js +7 -0
  69. package/dist/integrations/providers/harness-credentials.js.map +1 -1
  70. package/dist/integrations/providers/registry.d.ts +2 -2
  71. package/dist/integrations/providers/registry.js +2 -2
  72. package/dist/integrations/sessions/store.d.ts +14 -22
  73. package/dist/integrations/sessions/store.d.ts.map +1 -1
  74. package/dist/integrations/sessions/store.js +78 -175
  75. package/dist/integrations/sessions/store.js.map +1 -1
  76. package/dist/integrations/sessions/transcript-export.d.ts.map +1 -1
  77. package/dist/integrations/sessions/transcript-export.js +6 -2
  78. package/dist/integrations/sessions/transcript-export.js.map +1 -1
  79. package/dist/integrations/state/identity.d.ts +21 -0
  80. package/dist/integrations/state/identity.d.ts.map +1 -0
  81. package/dist/integrations/state/identity.js +49 -0
  82. package/dist/integrations/state/identity.js.map +1 -0
  83. package/dist/integrations/state/immutable-json.d.ts +10 -0
  84. package/dist/integrations/state/immutable-json.d.ts.map +1 -0
  85. package/dist/integrations/state/immutable-json.js +38 -0
  86. package/dist/integrations/state/immutable-json.js.map +1 -0
  87. package/dist/integrations/state/project.d.ts +10 -0
  88. package/dist/integrations/state/project.d.ts.map +1 -0
  89. package/dist/integrations/state/project.js +15 -0
  90. package/dist/integrations/state/project.js.map +1 -0
  91. package/dist/integrations/state/report.d.ts.map +1 -1
  92. package/dist/integrations/state/report.js +42 -12
  93. package/dist/integrations/state/report.js.map +1 -1
  94. package/dist/integrations/subagents/definitions.d.ts +19 -0
  95. package/dist/integrations/subagents/definitions.d.ts.map +1 -0
  96. package/dist/integrations/subagents/definitions.js +26 -0
  97. package/dist/integrations/subagents/definitions.js.map +1 -0
  98. package/dist/integrations/subagents/parent.d.ts +13 -0
  99. package/dist/integrations/subagents/parent.d.ts.map +1 -0
  100. package/dist/integrations/subagents/parent.js +57 -0
  101. package/dist/integrations/subagents/parent.js.map +1 -0
  102. package/dist/integrations/subagents/runtime.d.ts +24 -13
  103. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  104. package/dist/integrations/subagents/runtime.js +282 -122
  105. package/dist/integrations/subagents/runtime.js.map +1 -1
  106. package/dist/memory/store.d.ts +35 -15
  107. package/dist/memory/store.d.ts.map +1 -1
  108. package/dist/memory/store.js +79 -25
  109. package/dist/memory/store.js.map +1 -1
  110. package/dist/permissions/mode.d.ts +12 -45
  111. package/dist/permissions/mode.d.ts.map +1 -1
  112. package/dist/permissions/mode.js +18 -13
  113. package/dist/permissions/mode.js.map +1 -1
  114. package/dist/release-notes.d.ts +20 -0
  115. package/dist/release-notes.d.ts.map +1 -0
  116. package/dist/release-notes.js +64 -0
  117. package/dist/release-notes.js.map +1 -0
  118. package/dist/tui/AgentExplorer.d.ts.map +1 -1
  119. package/dist/tui/AgentExplorer.js +14 -18
  120. package/dist/tui/AgentExplorer.js.map +1 -1
  121. package/dist/tui/App.d.ts.map +1 -1
  122. package/dist/tui/App.js +606 -207
  123. package/dist/tui/App.js.map +1 -1
  124. package/dist/tui/BrandHeader.d.ts +12 -0
  125. package/dist/tui/BrandHeader.d.ts.map +1 -0
  126. package/dist/tui/BrandHeader.js +17 -0
  127. package/dist/tui/BrandHeader.js.map +1 -0
  128. package/dist/tui/ChoicePicker.d.ts +11 -1
  129. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  130. package/dist/tui/ChoicePicker.js +46 -13
  131. package/dist/tui/ChoicePicker.js.map +1 -1
  132. package/dist/tui/Composer.d.ts +4 -1
  133. package/dist/tui/Composer.d.ts.map +1 -1
  134. package/dist/tui/Composer.js +4 -4
  135. package/dist/tui/Composer.js.map +1 -1
  136. package/dist/tui/ComposerFrame.d.ts +10 -0
  137. package/dist/tui/ComposerFrame.d.ts.map +1 -0
  138. package/dist/tui/ComposerFrame.js +93 -0
  139. package/dist/tui/ComposerFrame.js.map +1 -0
  140. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  141. package/dist/tui/EditPromptPicker.js +1 -1
  142. package/dist/tui/EditPromptPicker.js.map +1 -1
  143. package/dist/tui/LiveActivity.d.ts +12 -5
  144. package/dist/tui/LiveActivity.d.ts.map +1 -1
  145. package/dist/tui/LiveActivity.js +17 -32
  146. package/dist/tui/LiveActivity.js.map +1 -1
  147. package/dist/tui/PermissionOverlay.d.ts +26 -2
  148. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  149. package/dist/tui/PermissionOverlay.js +108 -16
  150. package/dist/tui/PermissionOverlay.js.map +1 -1
  151. package/dist/tui/Picker.d.ts +2 -2
  152. package/dist/tui/Picker.d.ts.map +1 -1
  153. package/dist/tui/Picker.js +79 -52
  154. package/dist/tui/Picker.js.map +1 -1
  155. package/dist/tui/StatusBar.d.ts.map +1 -1
  156. package/dist/tui/StatusBar.js +5 -2
  157. package/dist/tui/StatusBar.js.map +1 -1
  158. package/dist/tui/TaskList.d.ts +3 -1
  159. package/dist/tui/TaskList.d.ts.map +1 -1
  160. package/dist/tui/TaskList.js +13 -4
  161. package/dist/tui/TaskList.js.map +1 -1
  162. package/dist/tui/Transcript.d.ts +3 -3
  163. package/dist/tui/Transcript.d.ts.map +1 -1
  164. package/dist/tui/Transcript.js +16 -34
  165. package/dist/tui/Transcript.js.map +1 -1
  166. package/dist/tui/agent.d.ts +113 -50
  167. package/dist/tui/agent.d.ts.map +1 -1
  168. package/dist/tui/agent.js +512 -293
  169. package/dist/tui/agent.js.map +1 -1
  170. package/dist/tui/choice-selection.d.ts +17 -0
  171. package/dist/tui/choice-selection.d.ts.map +1 -0
  172. package/dist/tui/choice-selection.js +38 -0
  173. package/dist/tui/choice-selection.js.map +1 -0
  174. package/dist/tui/exit-summary.d.ts +7 -1
  175. package/dist/tui/exit-summary.d.ts.map +1 -1
  176. package/dist/tui/exit-summary.js +12 -3
  177. package/dist/tui/exit-summary.js.map +1 -1
  178. package/dist/tui/index.d.ts +3 -1
  179. package/dist/tui/index.d.ts.map +1 -1
  180. package/dist/tui/index.js +5 -3
  181. package/dist/tui/index.js.map +1 -1
  182. package/dist/tui/logo.d.ts +7 -22
  183. package/dist/tui/logo.d.ts.map +1 -1
  184. package/dist/tui/logo.js +8 -34
  185. package/dist/tui/logo.js.map +1 -1
  186. package/dist/tui/model-choices.d.ts.map +1 -1
  187. package/dist/tui/model-choices.js +21 -6
  188. package/dist/tui/model-choices.js.map +1 -1
  189. package/dist/tui/permission-review.d.ts +15 -4
  190. package/dist/tui/permission-review.d.ts.map +1 -1
  191. package/dist/tui/permission-review.js +48 -9
  192. package/dist/tui/permission-review.js.map +1 -1
  193. package/dist/tui/provider-selection.d.ts +4 -0
  194. package/dist/tui/provider-selection.d.ts.map +1 -0
  195. package/dist/tui/provider-selection.js +16 -0
  196. package/dist/tui/provider-selection.js.map +1 -0
  197. package/dist/tui/run-interruption.d.ts +3 -2
  198. package/dist/tui/run-interruption.d.ts.map +1 -1
  199. package/dist/tui/run-interruption.js +2 -1
  200. package/dist/tui/run-interruption.js.map +1 -1
  201. package/dist/tui/shell-escape.d.ts +42 -0
  202. package/dist/tui/shell-escape.d.ts.map +1 -0
  203. package/dist/tui/shell-escape.js +110 -0
  204. package/dist/tui/shell-escape.js.map +1 -0
  205. package/dist/tui/slashCommands.d.ts +77 -31
  206. package/dist/tui/slashCommands.d.ts.map +1 -1
  207. package/dist/tui/slashCommands.js +343 -303
  208. package/dist/tui/slashCommands.js.map +1 -1
  209. package/dist/tui/terminal-choice-text.d.ts +8 -0
  210. package/dist/tui/terminal-choice-text.d.ts.map +1 -0
  211. package/dist/tui/terminal-choice-text.js +37 -0
  212. package/dist/tui/terminal-choice-text.js.map +1 -0
  213. package/dist/tui/theme.d.ts +3 -4
  214. package/dist/tui/theme.d.ts.map +1 -1
  215. package/dist/tui/theme.js +16 -17
  216. package/dist/tui/theme.js.map +1 -1
  217. package/dist/tui/types.d.ts +6 -1
  218. package/dist/tui/types.d.ts.map +1 -1
  219. package/package.json +6 -4
@@ -15,16 +15,17 @@
15
15
  * already carries. A command that had to compute its own answer would be a
16
16
  * second source for a fact the kernel already owns, and the two would drift.
17
17
  *
18
- * `/expand` is the same discipline with the split drawn one step earlier. The
18
+ * Ctrl+O's reprint is the same discipline with the split drawn one step earlier. The
19
19
  * lines it prints were captured when the tool ran and are held on the transcript
20
- * row, which this module cannot see and must not: it validates the argument and
21
- * hands App a `which`, and App — the only thing that knows what rows exist —
22
- * resolves it. Giving this module the transcript to search would put the answer
20
+ * row, which this module cannot see and must not: App — the only thing that knows
21
+ * what rows exist — resolves it. Giving this module the transcript to search
22
+ * would put the answer
23
23
  * to "does block 4 exist" in two places at once.
24
24
  */
25
25
  import { kernelHostCommands, } from '@namzu/sdk';
26
26
  import { renderConfigDebug } from '../config/debug.js';
27
- import { isPermissionMode } from '../permissions/mode.js';
27
+ import { PERMISSION_MODES, isPermissionMode, permissionModeDescription, } from '../permissions/mode.js';
28
+ import { readChangelog, renderReleaseNotes } from '../release-notes.js';
28
29
  import { expandCommand } from '../user-commands/store.js';
29
30
  import { isCompletionArgument } from './login-prompt.js';
30
31
  function renameConversationAction(_ctx, args) {
@@ -107,7 +108,7 @@ export class CommandNameCollisionError extends Error {
107
108
  * {@link CommandNameCollisionError} exists to prevent; naming each one here
108
109
  * keeps the refusal for every collision nobody decided about.
109
110
  */
110
- export const HOST_OWNED_COMMAND_NAMES = ['skills'];
111
+ export const HOST_OWNED_COMMAND_NAMES = ['skills', 'agents', 'goal'];
111
112
  export function mergeHostCommands(descriptors, locals = CLI_LOCAL_COMMANDS) {
112
113
  const localNames = new Set(locals.map((c) => c.name));
113
114
  const owned = new Set(HOST_OWNED_COMMAND_NAMES);
@@ -196,6 +197,35 @@ export function parseSlash(line) {
196
197
  * being restated in this file.
197
198
  */
198
199
  export const CLI_LOCAL_COMMANDS = [
200
+ {
201
+ name: 'settings',
202
+ description: 'View current settings and change model, reasoning or permissions.',
203
+ action: () => ({ kind: 'settings-picker' }),
204
+ },
205
+ {
206
+ name: 'agents',
207
+ description: 'Inspect delegated agents; /agents available lists configured agents.',
208
+ action: (_ctx, args) => args.length === 0 || (args.length === 1 && args[0] === 'running')
209
+ ? { kind: 'agent-cockpit' }
210
+ : args.length === 1 && args[0] === 'available'
211
+ ? { kind: 'host-command', name: 'agents', args: [] }
212
+ : { kind: 'message', role: 'system', content: 'Usage: /agents [running|available]' },
213
+ },
214
+ {
215
+ name: 'goal',
216
+ description: 'Manage this conversation’s goal and automatic continuation.',
217
+ action: (_ctx, args) => {
218
+ if (args.length === 0)
219
+ return { kind: 'goal-picker' };
220
+ if (args.length === 1 && args[0] === 'status') {
221
+ return { kind: 'host-command', name: 'goal', args: [] };
222
+ }
223
+ if (args.length === 1 && (args[0] === 'set' || args[0] === 'edit')) {
224
+ return { kind: 'goal-editor', edit: args[0] === 'edit' };
225
+ }
226
+ return { kind: 'host-command', name: 'goal', args };
227
+ },
228
+ },
199
229
  {
200
230
  name: 'help',
201
231
  description: 'Choose and run an available slash command.',
@@ -212,6 +242,7 @@ export const CLI_LOCAL_COMMANDS = [
212
242
  .map((command) => ({
213
243
  name: command.name,
214
244
  description: command.description,
245
+ ...(command.unavailable?.(ctx) ? { problem: command.unavailable(ctx) } : {}),
215
246
  })),
216
247
  ...ctx.userCommands.map((command) => ({
217
248
  name: command.name,
@@ -222,14 +253,11 @@ export const CLI_LOCAL_COMMANDS = [
222
253
  };
223
254
  },
224
255
  },
225
- {
226
- name: 'agent',
227
- description: 'Compatibility alias for the automatic delegated-work panel.',
228
- discoverable: false,
229
- action: () => ({ kind: 'agent-cockpit' }),
230
- },
231
256
  {
232
257
  name: 'feedback',
258
+ unavailable: (ctx) => ctx.lastAssistantMessageId()
259
+ ? undefined
260
+ : 'Nothing to rate yet. Wait for an assistant answer.',
233
261
  description: 'Rate the last answer; choose good/bad or add an optional note.',
234
262
  action: (ctx, args) => {
235
263
  if (args.length === 0) {
@@ -282,70 +310,66 @@ export const CLI_LOCAL_COMMANDS = [
282
310
  action: () => ({ kind: 'archive-picker' }),
283
311
  },
284
312
  {
285
- name: 'clear-screen',
286
- description: 'Clear only the terminal transcript; keep this conversation context.',
287
- action: () => ({ kind: 'clear-screen' }),
288
- },
289
- {
290
- name: 'quit',
313
+ name: 'exit',
291
314
  description: 'Exit namzu.',
292
315
  action: () => ({ kind: 'exit' }),
293
316
  },
294
317
  {
295
- name: 'exit',
296
- description: 'Alias of /quit.',
297
- action: () => ({ kind: 'exit' }),
318
+ name: 'rename',
319
+ description: 'Rename this conversation; opens an editor when no name is supplied. /rename clear removes the saved name.',
320
+ action: renameConversationAction,
298
321
  },
299
322
  {
300
- name: 'tools',
301
- description: 'List tools the agent can call.',
302
- action: (ctx) => {
303
- // Asked now, not when the session was built. Some of these register
304
- // during the first turn, so a list captured earlier is a list of what
305
- // was callable then — and this command is asked in the present tense.
306
- const tools = ctx.availableTools();
307
- return {
308
- kind: 'message',
309
- role: 'system',
310
- content: tools.length === 0
311
- ? 'No tools registered yet — the agent session may still be connecting.'
312
- : `Registered tools (${tools.length}):\n ${tools.join('\n ')}`,
313
- };
314
- },
323
+ name: 'fork',
324
+ description: 'Continue in a copy of this conversation, leaving the original where it is.',
325
+ action: () => ({ kind: 'fork' }),
315
326
  },
316
327
  {
317
- name: 'remember',
318
- description: 'Save a fact to durable memory: /remember <text>.',
319
- action: (_ctx, args) => {
320
- const text = args.join(' ').trim();
321
- return text.length === 0
322
- ? {
328
+ name: 'add-dir',
329
+ description: 'Let the file tools reach another directory this session: /add-dir <path>. Alone, list them.',
330
+ action: (ctx, args) => {
331
+ const path = args.join(' ').trim();
332
+ if (path.length === 0) {
333
+ const dirs = ctx.directories?.() ?? [];
334
+ return {
323
335
  kind: 'message',
324
336
  role: 'system',
325
- content: 'Usage: /remember <something to remember>',
326
- }
327
- : { kind: 'remember', text };
337
+ content: dirs.length === 0
338
+ ? 'No added directories. The file tools reach the working directory only. /add-dir <path> adds one for this session; `additionalDirectories` in the config file adds one for every session.'
339
+ : `Added directories (reachable by absolute path, bound into the sandbox):\n${dirs.map((d) => ` ${d}`).join('\n')}`,
340
+ };
341
+ }
342
+ return { kind: 'add-dir', path };
328
343
  },
329
344
  },
330
345
  {
331
- name: 'rename',
332
- description: 'Rename this conversation; opens an editor when no name is supplied.',
333
- action: renameConversationAction,
334
- },
335
- {
336
- name: 'title',
337
- description: 'Alias for /rename; /title clear removes the saved name.',
338
- action: renameConversationAction,
339
- },
340
- {
341
- name: 'fork',
342
- description: 'Continue in a copy of this conversation, leaving the original where it is.',
343
- action: () => ({ kind: 'fork' }),
346
+ name: 'restore',
347
+ description: 'Put files back to before a turn: /restore lists the checkpoints, /restore N restores.',
348
+ action: (_ctx, args) => {
349
+ const raw = args[0]?.trim();
350
+ if (!raw)
351
+ return { kind: 'restore' };
352
+ const turn = Number.parseInt(raw, 10);
353
+ if (!Number.isInteger(turn) || turn < 1 || String(turn) !== raw) {
354
+ return {
355
+ kind: 'message',
356
+ role: 'system',
357
+ content: `/restore takes a turn number from the list: /restore 3. Not: ${raw}`,
358
+ };
359
+ }
360
+ return { kind: 'restore', turn };
361
+ },
344
362
  },
345
363
  {
346
364
  name: 'memory',
347
- description: 'Show what namzu remembers (USER.md + MEMORY.md).',
348
- action: () => ({ kind: 'show-memory' }),
365
+ description: 'Show what namzu remembers, or save a fact about this project: /memory [text]. /memory --user [text] saves it for every project.',
366
+ action: (_ctx, args) => {
367
+ const user = args[0] === '--user';
368
+ const text = (user ? args.slice(1) : args).join(' ').trim();
369
+ return text.length === 0
370
+ ? { kind: 'show-memory' }
371
+ : { kind: 'remember', text, scope: user ? 'user' : 'project' };
372
+ },
349
373
  },
350
374
  {
351
375
  name: 'skills',
@@ -364,66 +388,9 @@ export const CLI_LOCAL_COMMANDS = [
364
388
  description: 'Resume a past conversation in this folder.',
365
389
  action: () => ({ kind: 'resume' }),
366
390
  },
367
- {
368
- name: 'mention',
369
- description: 'Insert @ so a project file can be mentioned in the next prompt.',
370
- action: () => ({ kind: 'composer-draft', text: '@' }),
371
- },
372
- {
373
- name: 'expand',
374
- description: 'Print a collapsed tool output in full: /expand [n].',
375
- action: (_ctx, args) => {
376
- const arg = args.join(' ').trim();
377
- // Bare `/expand` means the most recent one. That is what a person types
378
- // the moment output truncates in front of them, and making them read a
379
- // number back off the screen first would be a toll on the common case.
380
- if (arg.length === 0)
381
- return { kind: 'expand', which: 'last' };
382
- // Matched as the literal decimal a hint can print, and nothing else.
383
- //
384
- // `parseInt` would read `2nd` as 2 and expand a block the operator did
385
- // not name. `Number` plus `Number.isInteger` fixes that one and still
386
- // admits `0x10`, `1e2`, `+3` and `3.0` — four spellings no hint has
387
- // ever shown, each of which turns a typo into a valid reference to
388
- // some OTHER block. What this accepts is exactly what the screen can
389
- // produce, which is the only set with no silently-wrong answer in it.
390
- if (!/^[1-9][0-9]*$/.test(arg)) {
391
- return {
392
- kind: 'message',
393
- role: 'system',
394
- content: 'Usage: /expand [n], where n is the number in the "… +N lines · /expand n" hint under a collapsed tool output. /expand on its own takes the most recent one.',
395
- };
396
- }
397
- return { kind: 'expand', which: Number(arg) };
398
- },
399
- },
400
- {
401
- name: 'skill',
402
- description: 'Alias for /skills; choose a skill or type its name.',
403
- action: (_ctx, args) => {
404
- const name = args.join(' ').trim();
405
- return name.length === 0 ? { kind: 'skill-picker' } : { kind: 'load-skill', name };
406
- },
407
- },
408
- {
409
- name: 'provider',
410
- description: 'Show the current provider + model.',
411
- action: (ctx) => ({
412
- kind: 'message',
413
- role: 'system',
414
- content: ctx.providerSummary === null
415
- ? 'No provider configured. Run /model to pick one, or set an LLM env var (ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY) and restart namzu.'
416
- : `Provider: ${ctx.providerSummary}${ctx.modelSummary ? `\nModel: ${ctx.modelSummary}` : ''}`,
417
- }),
418
- },
419
- {
420
- name: 'pwd',
421
- description: 'Show the active working directory.',
422
- action: (ctx) => ({ kind: 'message', role: 'system', content: ctx.cwd }),
423
- },
424
391
  {
425
392
  name: 'model',
426
- description: 'Re-open the provider picker to switch the primary provider.',
393
+ description: 'Choose a model for the current provider, or change providers.',
427
394
  action: () => ({ kind: 'repick' }),
428
395
  },
429
396
  {
@@ -456,13 +423,41 @@ export const CLI_LOCAL_COMMANDS = [
456
423
  },
457
424
  {
458
425
  name: 'cost',
459
- description: 'Show tokens and spend for this run.',
426
+ description: 'Show usage and cost for the current or latest run.',
427
+ action: (ctx, args) => reportCommand('cost', args, (details) => renderCost(ctx.usage, ctx.compaction, details)),
428
+ },
429
+ {
430
+ name: 'jobs',
431
+ description: 'List background jobs started this session, running and ended.',
432
+ action: (ctx) => ({
433
+ kind: 'message',
434
+ role: 'system',
435
+ content: renderJobs(ctx.jobs()),
436
+ }),
437
+ },
438
+ {
439
+ name: 'release-notes',
440
+ description: 'Show what changed in the version that is running: /release-notes [version].',
441
+ action: (_ctx, args) => ({
442
+ kind: 'message',
443
+ role: 'system',
444
+ content: renderReleaseNotes(readChangelog(), args[0]?.trim() || undefined),
445
+ }),
446
+ },
447
+ {
448
+ name: 'hooks',
449
+ description: 'List the shell hooks this session runs, by event.',
460
450
  action: (ctx) => ({
461
451
  kind: 'message',
462
452
  role: 'system',
463
- content: renderCost(ctx.usage),
453
+ content: renderHooks(ctx.hooks?.()),
464
454
  }),
465
455
  },
456
+ {
457
+ name: 'context',
458
+ description: 'Show context usage and automatic cleanup.',
459
+ action: (ctx, args) => reportCommand('context', args, (details) => renderContext(ctx.usage, ctx.compaction, details)),
460
+ },
466
461
  {
467
462
  name: 'review',
468
463
  description: 'Choose a review target, or provide custom instructions: /review [instructions].',
@@ -473,12 +468,8 @@ export const CLI_LOCAL_COMMANDS = [
473
468
  },
474
469
  {
475
470
  name: 'mcp',
476
- description: 'Show current tool-server connections, tools, and failures.',
477
- action: (ctx) => ({
478
- kind: 'message',
479
- role: 'system',
480
- content: renderMcp(ctx.mcp()),
481
- }),
471
+ description: 'Show connected tool servers and connection problems.',
472
+ action: (ctx, args) => reportCommand('mcp', args, (details) => renderMcp(ctx.mcp(), details), 'tools'),
482
473
  },
483
474
  {
484
475
  name: 'diff',
@@ -523,35 +514,56 @@ export const CLI_LOCAL_COMMANDS = [
523
514
  },
524
515
  {
525
516
  name: 'status',
526
- description: 'Show what this session is, where it may write, and when it stops to ask.',
527
- action: (ctx) => ({
528
- kind: 'message',
529
- role: 'system',
530
- content: renderStatus(ctx),
531
- }),
532
- },
533
- {
534
- name: 'debug-config',
535
- description: 'Show the winning source for every resolved config key.',
536
- action: (ctx) => ({
537
- kind: 'message',
538
- role: 'system',
539
- content: renderConfigDebug(ctx.configDebug),
540
- }),
517
+ description: 'Show the model, permissions, workspace and latest cost.',
518
+ action: (ctx, args) => {
519
+ const which = args.join(' ').trim().toLowerCase();
520
+ if (which === 'config') {
521
+ return {
522
+ kind: 'message',
523
+ role: 'system',
524
+ content: renderConfigDebug(ctx.configDebug),
525
+ };
526
+ }
527
+ if (which === 'tools') {
528
+ const tools = ctx.availableTools();
529
+ return {
530
+ kind: 'message',
531
+ role: 'system',
532
+ content: tools.length === 0
533
+ ? 'No tools registered yet — the agent session may still be connecting.'
534
+ : `Registered tools (${tools.length}):\n ${tools.join('\n ')}`,
535
+ };
536
+ }
537
+ if (which.length > 0 && which !== 'details') {
538
+ return {
539
+ kind: 'message',
540
+ role: 'system',
541
+ content: 'Usage: /status [details|config|tools]',
542
+ };
543
+ }
544
+ return { kind: 'message', role: 'system', content: renderStatus(ctx, which === 'details') };
545
+ },
541
546
  },
542
547
  {
543
548
  name: 'permissions',
544
- description: 'Choose how undecided tool calls are handled: /permissions [mode].',
545
- action: (_ctx, args) => {
549
+ description: 'Choose which actions need your approval.',
550
+ action: (ctx, args) => {
546
551
  if (args.length === 0)
547
552
  return { kind: 'permission-mode-picker' };
548
553
  const mode = args.length === 1 ? args[0]?.toLowerCase() : undefined;
554
+ if (mode === 'details') {
555
+ return {
556
+ kind: 'message',
557
+ role: 'system',
558
+ content: renderPermissions(ctx.permissions, true),
559
+ };
560
+ }
549
561
  if (isPermissionMode(mode))
550
562
  return { kind: 'permission-mode', mode };
551
563
  return {
552
564
  kind: 'message',
553
565
  role: 'system',
554
- content: 'Usage: /permissions [prompt|auto|strict]',
566
+ content: `Usage: /permissions [details|${PERMISSION_MODES.join('|')}]`,
555
567
  };
556
568
  },
557
569
  },
@@ -667,53 +679,131 @@ export function initPrompt(instructionFiles) {
667
679
  'future run, so a sentence that says nothing is not free.',
668
680
  ].join('\n');
669
681
  }
670
- /**
671
- * Spend, stated as spend.
672
- *
673
- * `totalTokens` is cumulative and monotone; it is NOT how full the context is,
674
- * and the two were conflated once already — the gauge divided cumulative spend
675
- * by a guessed window, so it climbed with turn count and read FULL on a
676
- * conversation with room to spare. This command prints the spend and says which
677
- * quantity it is, so that nobody reads it as the other one.
678
- */
679
- export function renderCost(usage) {
680
- if (usage === null) {
681
- return 'No usage reported yet. The kernel emits it as a turn runs, so this fills in after the first exchange.';
682
+ /** The session's shell hooks, by event, in the order the config file gave them. */
683
+ export function renderHooks(hooks) {
684
+ const events = Object.entries(hooks ?? {}).filter(([, entries]) => (entries?.length ?? 0) > 0);
685
+ if (events.length === 0) {
686
+ return 'No hooks. Add a `hooks` key to namzu.config.json or ~/.namzu/config.yaml: event → list of { command, matcher?, timeoutMs? }.';
682
687
  }
683
- // Three states, and the third used to read as the first.
684
- //
685
- // This printed `'$0.0000 (this provider reported no price)'` for any total
686
- // that was not above zero — which was every run, because nothing fed the
687
- // kernel's cost calculation at all. Two things were wrong with it beyond
688
- // the number. A run on a local model costs nothing and is NOT the same
689
- // event as a run nobody can price; and the parenthetical asserted a fact
690
- // about the provider that no code had checked. What was actually known is
691
- // that this side has no rate for the model — a statement about namzu, not
692
- // about the vendor, and the difference decides who the operator goes to.
693
- //
694
- // The kernel exports `describeCost` for exactly this distinction and it is
695
- // deliberately NOT used here: it renders through `formatCost`, which rounds
696
- // to two decimals above a cent, and this command exists to print exact
697
- // figures (see `SlashContext.usage`). Rounding `$0.0731` to `$0.07` to
698
- // reuse a helper would trade the property someone asked for against tidy
699
- // code. The status bar, which must fit, is where the short form belongs.
700
- const amount = `$${usage.cost.totalCost.toFixed(4)}`;
701
- const unpriced = usage.cost.unpricedTokens > 0;
702
- const lines = [
703
- `Tokens: ${usage.totalTokens.toLocaleString('en-US')}`,
704
- `Cost: ${unpriced && usage.cost.totalCost === 0 ? 'not known' : amount}${unpriced && usage.cost.totalCost > 0 ? ' and counting — see below' : ''}`,
688
+ const lines = [];
689
+ for (const [event, entries] of events) {
690
+ lines.push(`${event}`);
691
+ for (const entry of entries ?? []) {
692
+ const when = entry.matcher ? ` · matches ${entry.matcher}` : '';
693
+ const deadline = entry.timeoutMs !== undefined ? ` · ${Math.round(entry.timeoutMs / 1000)}s` : '';
694
+ lines.push(` ${entry.command}${when}${deadline}`);
695
+ }
696
+ }
697
+ return lines.join('\n');
698
+ }
699
+ /** The session's background jobs, as an operator would ask about them. */
700
+ export function renderJobs(jobs) {
701
+ if (jobs.length === 0) {
702
+ return 'No background jobs this session. The agent starts one with `run_in_background` on bash; under a sandbox none can be started.';
703
+ }
704
+ const lines = jobs.map((job) => {
705
+ const state = job.status === 'running'
706
+ ? `running for ${Math.max(1, Math.round((Date.now() - job.startedAt) / 1000))}s`
707
+ : job.status === 'killed'
708
+ ? 'stopped'
709
+ : `exited ${job.exitCode ?? '?'}`;
710
+ return `${job.id} ${state.padEnd(16)} ${job.command}`;
711
+ });
712
+ return [
713
+ `${jobs.length} background job${jobs.length === 1 ? '' : 's'} this session:`,
714
+ ...lines,
705
715
  '',
716
+ 'The agent reads one with the job tool; ask it to stop one, or /exit stops them all.',
717
+ ].join('\n');
718
+ }
719
+ /** Reports validate their subcommand instead of silently ignoring mistyped arguments. */
720
+ function reportCommand(name, args, render, detailName = 'details') {
721
+ const option = args.join(' ').trim().toLowerCase();
722
+ const details = option === detailName || option === 'details';
723
+ return {
724
+ kind: 'message',
725
+ role: 'system',
726
+ content: option.length === 0 || details ? render(details) : `Usage: /${name} [${detailName}]`,
727
+ };
728
+ }
729
+ function contextMeasurement(usage) {
730
+ const context = usage?.context;
731
+ if (!context || context.windowTokens <= 0)
732
+ return ['No context measurement yet.'];
733
+ const percent = Math.min(999, Math.round((context.tokens / context.windowTokens) * 100));
734
+ const approx = context.measured && !context.windowAssumed ? '' : '~';
735
+ return [
736
+ `Context: ${context.tokens.toLocaleString('en-US')} / ${context.windowTokens.toLocaleString('en-US')} tokens (${approx}${percent}%)`,
737
+ `${context.measured ? 'Counted by the provider' : 'Estimated by Namzu'}; window ${context.windowAssumed ? 'assumed from a table or default' : 'declared by the provider or config'}.`,
738
+ ];
739
+ }
740
+ export function renderContext(usage, compaction, details = false) {
741
+ const lines = contextMeasurement(usage);
742
+ if (usage?.context)
743
+ lines.push('Latest reported context size; not cumulative token usage.');
744
+ if (!compaction) {
745
+ lines.push('Automatic cleanup: unavailable before a session starts.');
746
+ }
747
+ else {
748
+ lines.push(`Cleanup this session: ${compaction.passes} passes, ~${compaction.reclaimedTokens.toLocaleString('en-US')} tokens freed.`);
749
+ if (details) {
750
+ const pct = (fraction) => `${Math.round(fraction * 100)}%`;
751
+ lines.push(`Strategy: ${compaction.strategy}`);
752
+ if (compaction.strategy === 'salience') {
753
+ lines.push(`Older, less useful content is shortened from ${pct(compaction.softTarget)} of the window; older history is summarised at ${pct(compaction.triggerThreshold)}.`);
754
+ }
755
+ else {
756
+ lines.push(`Older tool results are cleared at ${pct(compaction.triggerThreshold)} of the window; history is summarised if needed.`);
757
+ }
758
+ lines.push(`Tool results cleared: ${compaction.clearedResults.toLocaleString('en-US')}`, `Messages shortened: ${compaction.stubbedNarrations.toLocaleString('en-US')}`, `Summaries written: ${compaction.summaries.toLocaleString('en-US')}`);
759
+ }
760
+ }
761
+ if (!details)
762
+ lines.push('/context details for cleanup history.');
763
+ return lines.join('\n');
764
+ }
765
+ /** Keep unknown prices distinct from measured zero, in status and cost alike. */
766
+ function costAmount(cost) {
767
+ if (cost.unpricedTokens > 0) {
768
+ return cost.totalCost > 0 ? `at least $${cost.totalCost.toFixed(4)}` : 'not known';
769
+ }
770
+ return `$${cost.totalCost.toFixed(4)}${cost.totalCost === 0 ? ' (measured zero)' : ''}`;
771
+ }
772
+ export function renderCost(usage, compaction = null, details = false) {
773
+ if (usage === null)
774
+ return 'No usage reported yet. Send a message to begin.';
775
+ const lines = [
776
+ 'Current or latest run',
777
+ `Tokens: ${usage.totalTokens.toLocaleString('en-US')} (own model calls)`,
778
+ `Cost: ${costAmount(usage.cost)}`,
706
779
  ];
707
- if (unpriced) {
708
- lines.push(`${usage.cost.unpricedTokens.toLocaleString('en-US')} tokens ran on a model namzu has no rate`, 'for, so what they cost is not in this figure and cannot be. This is not', 'a claim that they were free. Declare the rate to price them.');
780
+ if (usage.cost.unpricedTokens > 0) {
781
+ lines.push(`${usage.cost.unpricedTokens.toLocaleString('en-US')} tokens have no known price and are excluded from the cost.`);
782
+ }
783
+ if (usage.budget) {
784
+ lines.push(`Including delegated agents: ${usage.budget.treeTokens.toLocaleString('en-US')} tokens; limit ${usage.budget.limit === 0 ? 'unlimited' : usage.budget.limit.toLocaleString('en-US')}.`);
785
+ if (usage.budget.poisoned)
786
+ lines.push('Further spending is blocked until unresolved request usage is reconciled.');
709
787
  }
710
- else if (usage.cost.totalCost === 0) {
711
- lines.push('A measured zero, not a missing figure: the model that served this run', 'bills nothing per token.');
788
+ if (details) {
789
+ lines.push('Cost covers this run’s own model calls, excluding delegated calls and earlier runs. These are not conversation totals.');
790
+ if (usage.cost.unpricedTokens > 0) {
791
+ lines.push('Missing prices do not mean those tokens were free.');
792
+ }
793
+ else {
794
+ lines.push('All reported tokens have a known rate.');
795
+ }
796
+ if (usage.cost.cacheDiscount > 0)
797
+ lines.push(`Cache discount: $${usage.cost.cacheDiscount.toFixed(4)}`);
798
+ if (usage.context && usage.context.windowTokens > 0) {
799
+ lines.push('', ...contextMeasurement(usage), '/context details for automatic cleanup.');
800
+ }
801
+ if (compaction)
802
+ lines.push(`Cleanup passes this session: ${compaction.passes}.`);
712
803
  }
713
804
  else {
714
- lines.push('Every token in this run was charged at a known rate.');
805
+ lines.push('/cost details for scope and pricing; /context for context size.');
715
806
  }
716
- lines.push('', 'Cumulative for this run, across every turn. Not a measure of how full', 'the context is — that is a different quantity and it goes down when the', 'conversation is compacted, while this only ever grows.');
717
807
  return lines.join('\n');
718
808
  }
719
809
  /** What decides a tool call, in the order it actually decides it. */
@@ -797,146 +887,92 @@ function reviewTargetPrompt(target) {
797
887
  'Say plainly what you did not examine.',
798
888
  ].join('\n');
799
889
  }
800
- export function renderMcp(mcp) {
890
+ export function renderMcp(mcp, details = false) {
801
891
  if (mcp === null)
802
- return 'No session yet — no tool servers have been contacted.';
803
- const lines = [];
892
+ return 'No session yet. Tool servers have not been checked.';
804
893
  if (mcp.connected.length === 0 && mcp.failed.length === 0) {
805
- return 'No tool servers configured. Add an `mcpServers` block to namzu.config.json.';
894
+ return 'No tool servers configured. Add mcpServers to namzu.config.json.';
806
895
  }
896
+ const lines = [
897
+ `Tool servers: ${mcp.connected.length} connected, ${mcp.failed.length} unavailable.`,
898
+ ];
807
899
  for (const server of mcp.connected) {
808
- lines.push(`${server.name} — connected, ${server.tools.length} tool(s)`);
809
- // Named, not counted. A count answers "did it work"; the operator's
810
- // actual question is whether the tool they wanted is among them.
811
- for (const tool of server.tools)
812
- lines.push(` ${tool}`);
813
- }
814
- if (mcp.failed.length > 0) {
815
- if (lines.length > 0)
816
- lines.push('');
817
- for (const server of mcp.failed) {
818
- lines.push(`${server.name} — NOT available: ${server.reason}`);
819
- }
900
+ lines.push(`${server.name}: connected, ${server.tools.length} tools`);
901
+ if (details)
902
+ for (const tool of server.tools)
903
+ lines.push(` ${tool}`);
820
904
  }
905
+ for (const server of mcp.failed)
906
+ lines.push(`${server.name}: unavailable — ${server.reason}`);
907
+ if (!details && mcp.connected.length > 0)
908
+ lines.push('/mcp tools to list available tools.');
821
909
  return lines.join('\n');
822
910
  }
823
- export function renderStatus(ctx) {
824
- const lines = [];
825
- lines.push(`Provider: ${ctx.providerSummary ?? 'none — run /model to pick one'}`);
826
- lines.push(`Model: ${ctx.modelSummary ?? '—'}`);
827
- lines.push('');
828
- lines.push('Where it may write');
911
+ export function renderStatus(ctx, details = false) {
912
+ const lines = [
913
+ `Provider: ${ctx.providerSummary ?? 'none — run /model to choose one'}`,
914
+ `Model: ${ctx.modelSummary ?? 'not selected'}`,
915
+ `Working directory: ${ctx.cwd}`,
916
+ ...renderPermissions(ctx.permissions, details).split('\n'),
917
+ ];
829
918
  const sandbox = ctx.sandbox;
830
919
  if (!sandbox) {
831
- lines.push(' Not resolved yet — no session has started.');
920
+ lines.push('Sandbox: not resolved yet.');
832
921
  }
833
922
  else if (sandbox.unconfined) {
834
- // The loudest line on the page, and deliberately not softened by the
835
- // environment name: a tier that enforces nothing is not a weaker
836
- // sandbox, it is the absence of one.
837
- lines.push(' Anywhere this shell can. Commands are NOT confined.');
838
- if (sandbox.environment) {
839
- lines.push(` The sandbox is attached (${sandbox.environment}) and enforces nothing here.`);
840
- }
841
- lines.push(' Name what you need under `sandbox.requireIsolation` to be refused instead.');
923
+ lines.push('Sandbox: commands are not confined; they have this shell’s access.');
842
924
  }
843
925
  else {
844
- lines.push(` Confined to this session's sandbox (${sandbox.environment ?? 'unknown'}).`);
845
- lines.push(` Enforced here: ${sandbox.enforced.join(', ')}.`);
846
- }
847
- if (sandbox && sandbox.required.length > 0) {
848
- // Worth its own line even when it matches what is enforced: a demand
849
- // travels to the next machine and a coincidence does not.
850
- lines.push(` Required by config: ${sandbox.required.join(', ')} — a host without them refuses to run.`);
851
- }
852
- else if (sandbox) {
853
- lines.push(' Required by config: nothing — this host decides what you get.');
854
- }
855
- if (sandbox?.workspace === 'working-directory') {
856
- lines.push(' Workspace: real project files — changes persist across turns.');
926
+ lines.push(`Sandbox: ${sandbox.environment ?? 'active'}; enforces ${sandbox.enforced.join(', ') || 'no reported restrictions'}.`);
857
927
  }
858
- else if (sandbox?.workspace === 'ephemeral') {
859
- lines.push(' Workspace: disposable per run — changes are removed at teardown.');
928
+ if (sandbox?.workspace === 'ephemeral') {
929
+ lines.push('Workspace: temporary files; removed when the run ends.');
860
930
  }
861
- else if (sandbox?.workspace === 'host') {
862
- lines.push(' Workspace: real project files on the host.');
931
+ else if (sandbox?.workspace === 'working-directory' || sandbox?.workspace === 'host') {
932
+ lines.push('Workspace: real project files; edits persist.');
863
933
  }
864
- lines.push('');
865
- lines.push('When it stops to ask');
866
- for (const line of renderPermissions(ctx.permissions).split('\n')) {
867
- lines.push(line.length > 0 ? ` ${line}` : '');
868
- }
869
- if (ctx.usage) {
870
- lines.push('');
871
- lines.push(`Spend: ${renderCost(ctx.usage).split('\n')[0] ?? ''}`);
934
+ if (details) {
935
+ for (const dir of ctx.directories?.() ?? [])
936
+ lines.push(`Additional directory: ${dir}`);
937
+ if (sandbox) {
938
+ lines.push(`Required isolation: ${sandbox.required.length > 0 ? sandbox.required.join(', ') : 'none'}.`);
939
+ if (sandbox.required.length > 0)
940
+ lines.push('A host missing required isolation cannot start the session.');
941
+ }
942
+ lines.push('/status config for setting sources; /status tools for available tools.');
872
943
  }
873
- return lines.join('\n').trimEnd();
944
+ if (ctx.usage)
945
+ lines.push(`Spend (current or latest run, own calls): ${costAmount(ctx.usage.cost)}`);
946
+ if (!details)
947
+ lines.push('/status details for rules and workspace details.');
948
+ return lines.join('\n');
874
949
  }
875
- export function renderPermissions(permissions) {
876
- const lines = [];
950
+ export function renderPermissions(permissions, details = false) {
877
951
  const current = permissions.currentMode();
878
- lines.push(`Current mode: ${current.mode}.`);
879
- lines.push('');
880
- // Four effective states, most permissive first, and the approve-all one is the reason this
881
- // function was rewritten: it is reachable from a single keystroke at a
882
- // prompt, it silently outranks the default, and it used to be invisible
883
- // here — so the page answering "how do tool calls get approved" gave the
884
- // safe answer to an operator who had already turned the safety off.
885
- if (current.mode === 'auto') {
886
- lines.push(current.source === 'launch-bypass'
887
- ? 'Unreviewed calls: approved automatically (--dangerously-skip-permissions).'
888
- : 'Unreviewed calls: approved automatically for future turns (/permissions auto).');
889
- }
890
- else if (current.mode === 'strict') {
891
- lines.push('Unreviewed calls: rejected automatically for future turns (/permissions strict).');
892
- lines.push('Only calls an explicit allow rule covers may run; no approval prompt can widen that.');
893
- }
894
- else if (permissions.approvalLatched()) {
895
- lines.push('Unreviewed calls: approved automatically — "approve all" was chosen at');
896
- lines.push('an earlier prompt. Run /permissions prompt to revoke that latch and ask again.');
897
- }
898
- else {
899
- lines.push('Unreviewed calls: you are asked before they run.');
900
- }
901
- // Stated in every mode, because it is true in every mode and an operator
902
- // cannot discover it by using namzu: these tools simply never appear at a
903
- // prompt, so their absence reads as "the agent did not use any".
904
- const neverPrompted = permissions.neverPrompted();
905
- if (neverPrompted.length > 0) {
906
- lines.push('');
907
- lines.push(`Never prompted for (${neverPrompted.length}):`);
908
- lines.push(` ${neverPrompted.join(', ')}`);
909
- lines.push('Each of these declares itself read-only, or is a named exception');
910
- lines.push("for the agent's own task list. A rule can still deny one, and any");
911
- lines.push('call the kernel flags destructive is prompted for regardless.');
952
+ const approvedAll = (current.mode === 'prompt' || current.mode === 'accept-edits') && permissions.approvalLatched();
953
+ const lines = [
954
+ `Permissions: ${current.mode}. ${approvedAll ? 'Tools are approved automatically because “approve all” is active.' : permissionModeDescription(current.mode)}`,
955
+ ];
956
+ if (approvedAll)
957
+ lines.push('Use /permissions prompt to ask again.');
958
+ if (!details)
959
+ return lines.join('\n');
960
+ if (current.source === 'launch-bypass')
961
+ lines.push('Selected at launch with --dangerously-skip-permissions.');
962
+ lines.push('Explicit deny rules and the built-in safety gate apply in every mode.');
963
+ if (current.mode === 'plan')
964
+ lines.push('Plan mode also blocks writes that an allow rule would otherwise permit.');
965
+ const exempt = permissions.neverPrompted();
966
+ if (exempt.length > 0) {
967
+ lines.push(`Tools exempt from ordinary prompts: ${exempt.join(', ')}.`);
968
+ lines.push('Destructive calls still require review; the selected mode decides whether to allow, ask or refuse. Plan mode still requires read-only tools.');
912
969
  }
913
- if (permissions.rules.length === 0) {
914
- lines.push('');
915
- lines.push('No rules configured. Add a "permissions" object to namzu.config.json');
916
- lines.push('to allow or deny tools by name without being asked each time.');
970
+ if (permissions.rules.length > 0) {
971
+ lines.push(`Rules (${permissions.rules.length}):`, ...permissions.rules.map((rule) => ` ${describeRule(rule)}`));
917
972
  }
918
973
  else {
919
- lines.push('');
920
- lines.push(`Rules (${permissions.rules.length}), from your config:`);
921
- for (const rule of permissions.rules)
922
- lines.push(` ${describeRule(rule)}`);
974
+ lines.push('No custom rules. Configure permissions in namzu.config.json.');
923
975
  }
924
- lines.push('');
925
- // The order this function claims to describe starts here, and the page used
926
- // to begin one step in. Omitting the gate is not a false statement, but it
927
- // makes "a rule decides first" read as the whole story when something
928
- // outranks the rules too — and a true-but-incomplete order is a wrong order
929
- // for anyone reasoning about what can still get through.
930
- lines.push('Before any of the below: a built-in safety gate hard-denies a narrow set');
931
- lines.push('of catastrophic shell patterns (rm -rf /, mkfs, fork bombs, curl|sh, …).');
932
- lines.push('It applies in every mode, including --dangerously-skip-permissions, and');
933
- lines.push('nothing here can switch it off.');
934
- lines.push('');
935
- // Stated because the precedence is the part people get wrong, and getting it
936
- // wrong in this direction is the dangerous one: assuming the flag lifts a
937
- // `deny` they wrote.
938
- lines.push('Then a rule decides. The approval setting above only reaches calls');
939
- lines.push('no rule covered, so it can never reopen what a `deny` closed.');
940
976
  return lines.join('\n');
941
977
  }
942
978
  /** Render the exact session control without inventing a global effort menu. */
@@ -1044,8 +1080,12 @@ export function runSlash(line, ctx, builtins = CLI_LOCAL_COMMANDS) {
1044
1080
  // a `problem` so their author is told, rather than leaving them to wonder
1045
1081
  // why the file never ran.
1046
1082
  const cmd = builtins.find((c) => c.name === parsed.name);
1047
- if (cmd)
1048
- return cmd.action(ctx, parsed.args);
1083
+ if (cmd) {
1084
+ const reason = cmd.unavailable?.(ctx);
1085
+ return reason
1086
+ ? { kind: 'message', role: 'system', content: reason }
1087
+ : cmd.action(ctx, parsed.args);
1088
+ }
1049
1089
  const user = ctx.userCommands.find((c) => c.name === parsed.name);
1050
1090
  if (user) {
1051
1091
  const expanded = expandCommand(user, parsed.args.join(' '));