@namzu/cli 18.1.0 → 20.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 (224) hide show
  1. package/CHANGELOG.md +5951 -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 +33 -16
  103. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  104. package/dist/integrations/subagents/runtime.js +450 -150
  105. package/dist/integrations/subagents/runtime.js.map +1 -1
  106. package/dist/memory/presentation.d.ts +9 -0
  107. package/dist/memory/presentation.d.ts.map +1 -0
  108. package/dist/memory/presentation.js +37 -0
  109. package/dist/memory/presentation.js.map +1 -0
  110. package/dist/memory/store.d.ts +35 -15
  111. package/dist/memory/store.d.ts.map +1 -1
  112. package/dist/memory/store.js +79 -25
  113. package/dist/memory/store.js.map +1 -1
  114. package/dist/permissions/mode.d.ts +15 -45
  115. package/dist/permissions/mode.d.ts.map +1 -1
  116. package/dist/permissions/mode.js +36 -13
  117. package/dist/permissions/mode.js.map +1 -1
  118. package/dist/release-notes.d.ts +20 -0
  119. package/dist/release-notes.d.ts.map +1 -0
  120. package/dist/release-notes.js +64 -0
  121. package/dist/release-notes.js.map +1 -0
  122. package/dist/tui/AgentExplorer.d.ts +1 -1
  123. package/dist/tui/AgentExplorer.d.ts.map +1 -1
  124. package/dist/tui/AgentExplorer.js +25 -21
  125. package/dist/tui/AgentExplorer.js.map +1 -1
  126. package/dist/tui/App.d.ts.map +1 -1
  127. package/dist/tui/App.js +701 -245
  128. package/dist/tui/App.js.map +1 -1
  129. package/dist/tui/BrandHeader.d.ts +12 -0
  130. package/dist/tui/BrandHeader.d.ts.map +1 -0
  131. package/dist/tui/BrandHeader.js +18 -0
  132. package/dist/tui/BrandHeader.js.map +1 -0
  133. package/dist/tui/ChoicePicker.d.ts +11 -1
  134. package/dist/tui/ChoicePicker.d.ts.map +1 -1
  135. package/dist/tui/ChoicePicker.js +46 -13
  136. package/dist/tui/ChoicePicker.js.map +1 -1
  137. package/dist/tui/Composer.d.ts +5 -2
  138. package/dist/tui/Composer.d.ts.map +1 -1
  139. package/dist/tui/Composer.js +6 -11
  140. package/dist/tui/Composer.js.map +1 -1
  141. package/dist/tui/ComposerFrame.d.ts +10 -0
  142. package/dist/tui/ComposerFrame.d.ts.map +1 -0
  143. package/dist/tui/ComposerFrame.js +93 -0
  144. package/dist/tui/ComposerFrame.js.map +1 -0
  145. package/dist/tui/EditPromptPicker.d.ts.map +1 -1
  146. package/dist/tui/EditPromptPicker.js +1 -1
  147. package/dist/tui/EditPromptPicker.js.map +1 -1
  148. package/dist/tui/LiveActivity.d.ts +12 -5
  149. package/dist/tui/LiveActivity.d.ts.map +1 -1
  150. package/dist/tui/LiveActivity.js +17 -32
  151. package/dist/tui/LiveActivity.js.map +1 -1
  152. package/dist/tui/PermissionOverlay.d.ts +26 -2
  153. package/dist/tui/PermissionOverlay.d.ts.map +1 -1
  154. package/dist/tui/PermissionOverlay.js +109 -16
  155. package/dist/tui/PermissionOverlay.js.map +1 -1
  156. package/dist/tui/Picker.d.ts +2 -2
  157. package/dist/tui/Picker.d.ts.map +1 -1
  158. package/dist/tui/Picker.js +82 -52
  159. package/dist/tui/Picker.js.map +1 -1
  160. package/dist/tui/StatusBar.d.ts.map +1 -1
  161. package/dist/tui/StatusBar.js +5 -2
  162. package/dist/tui/StatusBar.js.map +1 -1
  163. package/dist/tui/TaskList.d.ts +3 -1
  164. package/dist/tui/TaskList.d.ts.map +1 -1
  165. package/dist/tui/TaskList.js +13 -4
  166. package/dist/tui/TaskList.js.map +1 -1
  167. package/dist/tui/Transcript.d.ts +4 -12
  168. package/dist/tui/Transcript.d.ts.map +1 -1
  169. package/dist/tui/Transcript.js +45 -75
  170. package/dist/tui/Transcript.js.map +1 -1
  171. package/dist/tui/agent.d.ts +116 -51
  172. package/dist/tui/agent.d.ts.map +1 -1
  173. package/dist/tui/agent.js +539 -307
  174. package/dist/tui/agent.js.map +1 -1
  175. package/dist/tui/choice-selection.d.ts +17 -0
  176. package/dist/tui/choice-selection.d.ts.map +1 -0
  177. package/dist/tui/choice-selection.js +38 -0
  178. package/dist/tui/choice-selection.js.map +1 -0
  179. package/dist/tui/exit-summary.d.ts +7 -1
  180. package/dist/tui/exit-summary.d.ts.map +1 -1
  181. package/dist/tui/exit-summary.js +12 -3
  182. package/dist/tui/exit-summary.js.map +1 -1
  183. package/dist/tui/index.d.ts +3 -1
  184. package/dist/tui/index.d.ts.map +1 -1
  185. package/dist/tui/index.js +5 -3
  186. package/dist/tui/index.js.map +1 -1
  187. package/dist/tui/logo.d.ts +7 -22
  188. package/dist/tui/logo.d.ts.map +1 -1
  189. package/dist/tui/logo.js +8 -34
  190. package/dist/tui/logo.js.map +1 -1
  191. package/dist/tui/model-choices.d.ts.map +1 -1
  192. package/dist/tui/model-choices.js +21 -6
  193. package/dist/tui/model-choices.js.map +1 -1
  194. package/dist/tui/permission-review.d.ts +15 -4
  195. package/dist/tui/permission-review.d.ts.map +1 -1
  196. package/dist/tui/permission-review.js +48 -9
  197. package/dist/tui/permission-review.js.map +1 -1
  198. package/dist/tui/provider-selection.d.ts +4 -0
  199. package/dist/tui/provider-selection.d.ts.map +1 -0
  200. package/dist/tui/provider-selection.js +16 -0
  201. package/dist/tui/provider-selection.js.map +1 -0
  202. package/dist/tui/run-interruption.d.ts +3 -2
  203. package/dist/tui/run-interruption.d.ts.map +1 -1
  204. package/dist/tui/run-interruption.js +2 -1
  205. package/dist/tui/run-interruption.js.map +1 -1
  206. package/dist/tui/shell-escape.d.ts +42 -0
  207. package/dist/tui/shell-escape.d.ts.map +1 -0
  208. package/dist/tui/shell-escape.js +110 -0
  209. package/dist/tui/shell-escape.js.map +1 -0
  210. package/dist/tui/slashCommands.d.ts +82 -31
  211. package/dist/tui/slashCommands.d.ts.map +1 -1
  212. package/dist/tui/slashCommands.js +518 -306
  213. package/dist/tui/slashCommands.js.map +1 -1
  214. package/dist/tui/terminal-choice-text.d.ts +8 -0
  215. package/dist/tui/terminal-choice-text.d.ts.map +1 -0
  216. package/dist/tui/terminal-choice-text.js +37 -0
  217. package/dist/tui/terminal-choice-text.js.map +1 -0
  218. package/dist/tui/theme.d.ts +3 -4
  219. package/dist/tui/theme.d.ts.map +1 -1
  220. package/dist/tui/theme.js +16 -17
  221. package/dist/tui/theme.js.map +1 -1
  222. package/dist/tui/types.d.ts +9 -13
  223. package/dist/tui/types.d.ts.map +1 -1
  224. package/package.json +7 -5
@@ -15,17 +15,18 @@
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';
28
- import { expandCommand } from '../user-commands/store.js';
27
+ import { effectivePermissionMode, isPermissionMode, permissionModeDescription, permissionModeLabel, } from '../permissions/mode.js';
28
+ import { readChangelog, renderReleaseNotes } from '../release-notes.js';
29
+ import { ARGUMENTS_TOKEN, expandCommand } from '../user-commands/store.js';
29
30
  import { isCompletionArgument } from './login-prompt.js';
30
31
  function renameConversationAction(_ctx, args) {
31
32
  const text = args.join(' ').trim();
@@ -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);
@@ -121,6 +122,11 @@ export function mergeHostCommands(descriptors, locals = CLI_LOCAL_COMMANDS) {
121
122
  ...fromKernel.map((descriptor) => ({
122
123
  name: descriptor.name,
123
124
  description: descriptor.description,
125
+ help: {
126
+ // An arbitrary JSON Schema does not declare positional CLI syntax.
127
+ usage: [`/${descriptor.name}${descriptor.args ? ' [arguments]' : ''}`],
128
+ ...(descriptor.hint ? { details: [descriptor.hint] } : {}),
129
+ },
124
130
  action: (_ctx, args) => ({
125
131
  kind: 'host-command',
126
132
  name: descriptor.name,
@@ -187,6 +193,62 @@ export function parseSlash(line) {
187
193
  return null;
188
194
  return { name, args };
189
195
  }
196
+ /** Help reads metadata only: even a mutating or unavailable command is safe to inspect. */
197
+ function commandHelp(ctx, args) {
198
+ const name = args[0]?.replace(/^\//, '');
199
+ if (args.length !== 1 || !name) {
200
+ return {
201
+ kind: 'message',
202
+ role: 'system',
203
+ content: 'Usage: /help [command]. For example: /help permissions.',
204
+ };
205
+ }
206
+ const command = (ctx.builtins ?? CLI_LOCAL_COMMANDS).find((entry) => entry.name === name);
207
+ if (command) {
208
+ const reason = command.unavailable?.(ctx);
209
+ return {
210
+ kind: 'message',
211
+ role: 'system',
212
+ content: [
213
+ `/${command.name}`,
214
+ command.description,
215
+ '',
216
+ 'Usage:',
217
+ ...(command.help?.usage ?? [`/${command.name}`]).map((usage) => ` ${usage}`),
218
+ ...(command.help?.details?.length ? ['', ...command.help.details] : []),
219
+ ...(reason ? ['', `Unavailable: ${reason}`] : []),
220
+ ].join('\n'),
221
+ };
222
+ }
223
+ const user = ctx.userCommands.find((entry) => entry.name === name);
224
+ if (user) {
225
+ const acceptsArguments = user.template.includes(ARGUMENTS_TOKEN);
226
+ return {
227
+ kind: 'message',
228
+ role: 'system',
229
+ content: [
230
+ `/${user.name}`,
231
+ user.description,
232
+ '',
233
+ ...(user.problem
234
+ ? []
235
+ : [
236
+ 'Usage:',
237
+ ` /${user.name}${acceptsArguments ? ' [arguments]' : ''}`,
238
+ '',
239
+ 'Running this command sends its saved prompt to the model.',
240
+ ]),
241
+ `${user.source === 'project' ? 'Project command' : 'User command (all projects)'}: ${user.path}`,
242
+ ...(user.problem ? [`Unavailable: ${user.problem}`] : []),
243
+ ].join('\n'),
244
+ };
245
+ }
246
+ return {
247
+ kind: 'message',
248
+ role: 'system',
249
+ content: `Unknown command: /${name}. Use /help to browse available commands.`,
250
+ };
251
+ }
190
252
  /**
191
253
  * The commands that are genuinely this host's.
192
254
  *
@@ -196,10 +258,61 @@ export function parseSlash(line) {
196
258
  * being restated in this file.
197
259
  */
198
260
  export const CLI_LOCAL_COMMANDS = [
261
+ {
262
+ name: 'settings',
263
+ description: 'View current settings and change model, reasoning or permissions.',
264
+ action: () => ({ kind: 'settings-picker' }),
265
+ },
266
+ {
267
+ name: 'agents',
268
+ description: 'Inspect delegated agents; /agents available lists configured agents.',
269
+ help: { usage: ['/agents [running|available]'] },
270
+ action: (_ctx, args) => args.length === 0 || (args.length === 1 && args[0] === 'running')
271
+ ? { kind: 'agent-cockpit' }
272
+ : args.length === 1 && args[0] === 'available'
273
+ ? { kind: 'host-command', name: 'agents', args: [] }
274
+ : { kind: 'message', role: 'system', content: 'Usage: /agents [running|available]' },
275
+ },
276
+ {
277
+ name: 'goal',
278
+ description: 'Manage this conversation’s goal and automatic continuation.',
279
+ help: {
280
+ usage: [
281
+ '/goal',
282
+ '/goal status',
283
+ '/goal set [objective]',
284
+ '/goal edit [objective]',
285
+ '/goal pause|resume|clear',
286
+ ],
287
+ details: [
288
+ 'Set and edit open an editor when no objective is supplied. Direct /goal <objective> also creates a goal.',
289
+ 'Creating or resuming a goal enables automatic work. Pausing or clearing stops future automatic turns.',
290
+ ],
291
+ },
292
+ action: (_ctx, args) => {
293
+ if (args.length === 0)
294
+ return { kind: 'goal-picker' };
295
+ if (args.length === 1 && args[0] === 'status') {
296
+ return { kind: 'host-command', name: 'goal', args: [] };
297
+ }
298
+ if (args.length === 1 && (args[0] === 'set' || args[0] === 'edit')) {
299
+ return { kind: 'goal-editor', edit: args[0] === 'edit' };
300
+ }
301
+ return { kind: 'host-command', name: 'goal', args };
302
+ },
303
+ },
199
304
  {
200
305
  name: 'help',
201
- description: 'Choose and run an available slash command.',
202
- action: (ctx) => {
306
+ description: 'Browse commands, or show usage with /help <command>.',
307
+ help: {
308
+ usage: ['/help', '/help <command>'],
309
+ details: [
310
+ 'Without a name, opens the command menu. With a name, shows help without running the command.',
311
+ ],
312
+ },
313
+ action: (ctx, args) => {
314
+ if (args.length > 0)
315
+ return commandHelp(ctx, args);
203
316
  // Reads what this session OFFERS, not a module constant. A command the
204
317
  // kernel registered and `/help` did not list is a command nobody
205
318
  // discovers. Refused command files remain rows with their reason; App
@@ -212,6 +325,7 @@ export const CLI_LOCAL_COMMANDS = [
212
325
  .map((command) => ({
213
326
  name: command.name,
214
327
  description: command.description,
328
+ ...(command.unavailable?.(ctx) ? { problem: command.unavailable(ctx) } : {}),
215
329
  })),
216
330
  ...ctx.userCommands.map((command) => ({
217
331
  name: command.name,
@@ -222,14 +336,12 @@ export const CLI_LOCAL_COMMANDS = [
222
336
  };
223
337
  },
224
338
  },
225
- {
226
- name: 'agent',
227
- description: 'Compatibility alias for the automatic delegated-work panel.',
228
- discoverable: false,
229
- action: () => ({ kind: 'agent-cockpit' }),
230
- },
231
339
  {
232
340
  name: 'feedback',
341
+ help: { usage: ['/feedback', '/feedback good|bad [note]'] },
342
+ unavailable: (ctx) => ctx.lastAssistantMessageId()
343
+ ? undefined
344
+ : 'Nothing to rate yet. Wait for an assistant answer.',
233
345
  description: 'Rate the last answer; choose good/bad or add an optional note.',
234
346
  action: (ctx, args) => {
235
347
  if (args.length === 0) {
@@ -282,73 +394,100 @@ export const CLI_LOCAL_COMMANDS = [
282
394
  action: () => ({ kind: 'archive-picker' }),
283
395
  },
284
396
  {
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',
397
+ name: 'exit',
291
398
  description: 'Exit namzu.',
292
399
  action: () => ({ kind: 'exit' }),
293
400
  },
294
401
  {
295
- name: 'exit',
296
- description: 'Alias of /quit.',
297
- action: () => ({ kind: 'exit' }),
402
+ name: 'rename',
403
+ help: { usage: ['/rename [name]', '/rename clear'] },
404
+ description: 'Rename this conversation; opens an editor when no name is supplied. /rename clear removes the saved name.',
405
+ action: renameConversationAction,
298
406
  },
299
407
  {
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
- },
408
+ name: 'fork',
409
+ description: 'Continue in a copy of this conversation, leaving the original where it is.',
410
+ action: () => ({ kind: 'fork' }),
315
411
  },
316
412
  {
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
- ? {
413
+ name: 'add-dir',
414
+ help: {
415
+ usage: ['/add-dir [path]'],
416
+ details: ['Without a path, lists added directories. Paths may contain spaces.'],
417
+ },
418
+ description: 'Let the file tools reach another directory this session: /add-dir <path>. Alone, list them.',
419
+ action: (ctx, args) => {
420
+ const path = args.join(' ').trim();
421
+ if (path.length === 0) {
422
+ const dirs = ctx.directories?.() ?? [];
423
+ return {
323
424
  kind: 'message',
324
425
  role: 'system',
325
- content: 'Usage: /remember <something to remember>',
326
- }
327
- : { kind: 'remember', text };
426
+ content: dirs.length === 0
427
+ ? '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.'
428
+ : `Added directories (reachable by absolute path, bound into the sandbox):\n${dirs.map((d) => ` ${d}`).join('\n')}`,
429
+ };
430
+ }
431
+ return { kind: 'add-dir', path };
328
432
  },
329
433
  },
330
434
  {
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' }),
435
+ name: 'restore',
436
+ help: {
437
+ usage: ['/restore [turn]'],
438
+ details: ['Run /restore to list checkpoints before choosing a turn to restore.'],
439
+ },
440
+ description: 'Put files back to before a turn: /restore lists the checkpoints, /restore N restores.',
441
+ action: (_ctx, args) => {
442
+ const raw = args[0]?.trim();
443
+ if (!raw)
444
+ return { kind: 'restore' };
445
+ const turn = Number.parseInt(raw, 10);
446
+ if (!Number.isInteger(turn) || turn < 1 || String(turn) !== raw) {
447
+ return {
448
+ kind: 'message',
449
+ role: 'system',
450
+ content: `/restore takes a turn number from the list: /restore 3. Not: ${raw}`,
451
+ };
452
+ }
453
+ return { kind: 'restore', turn };
454
+ },
344
455
  },
345
456
  {
346
457
  name: 'memory',
347
- description: 'Show what namzu remembers (USER.md + MEMORY.md).',
348
- action: () => ({ kind: 'show-memory' }),
458
+ help: {
459
+ usage: ['/memory [show|list]', '/memory add <text>', '/memory --user add <text>'],
460
+ details: [
461
+ 'Show and list read saved memory. Add saves a project note; --user saves a note for all projects.',
462
+ 'Direct /memory <text> also saves a note. To save a reserved word as a note, use /memory add show.',
463
+ ],
464
+ },
465
+ description: 'Show curated memory, or save a fact with /memory add <text>. Use /memory --user add <text> for every project.',
466
+ action: (_ctx, args) => {
467
+ const user = args[0] === '--user';
468
+ const memoryArgs = user ? args.slice(1) : args;
469
+ const text = memoryArgs.join(' ').trim();
470
+ // Reserve standalone inspection words while retaining multiword facts.
471
+ if (text.length === 0 || ['show', 'list'].includes(text.toLowerCase())) {
472
+ return { kind: 'show-memory' };
473
+ }
474
+ if (memoryArgs[0]?.toLowerCase() === 'add') {
475
+ const fact = memoryArgs.slice(1).join(' ').trim();
476
+ if (fact.length === 0) {
477
+ return {
478
+ kind: 'message',
479
+ role: 'system',
480
+ content: 'Usage: /memory add <text> or /memory --user add <text>. /memory show displays saved memory.',
481
+ };
482
+ }
483
+ return { kind: 'remember', text: fact, scope: user ? 'user' : 'project' };
484
+ }
485
+ return { kind: 'remember', text, scope: user ? 'user' : 'project' };
486
+ },
349
487
  },
350
488
  {
351
489
  name: 'skills',
490
+ help: { usage: ['/skills', '/skills list', '/skills <name>'] },
352
491
  description: 'Choose an available skill; use /skills list for the full roster.',
353
492
  action: (_ctx, args) => {
354
493
  const choice = args.join(' ').trim();
@@ -364,77 +503,33 @@ export const CLI_LOCAL_COMMANDS = [
364
503
  description: 'Resume a past conversation in this folder.',
365
504
  action: () => ({ kind: 'resume' }),
366
505
  },
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
506
  {
425
507
  name: 'model',
426
- description: 'Re-open the provider picker to switch the primary provider.',
508
+ description: 'Choose a model for the current provider, or change providers.',
509
+ help: {
510
+ usage: ['/model'],
511
+ details: [
512
+ 'Press p in the model picker to change providers. Normal selections are saved for future launches; temporary credentials keep the selection in this session.',
513
+ ],
514
+ },
427
515
  action: () => ({ kind: 'repick' }),
428
516
  },
429
517
  {
430
518
  name: 'login',
431
519
  description: 'Sign in with a Claude or Codex subscription.',
520
+ help: {
521
+ usage: ['/login', '/login <callback-address-or-code>'],
522
+ details: [
523
+ 'Start sign-in with /login, then paste the callback address or code when requested.',
524
+ ],
525
+ },
432
526
  action: (_ctx, args) => isCompletionArgument(args)
433
527
  ? { kind: 'login', pasted: args.join(' ').trim() }
434
528
  : { kind: 'login' },
435
529
  },
436
530
  {
437
531
  name: 'logout',
532
+ help: { usage: ['/logout [claude|codex|all]'] },
438
533
  description: 'Remove a Namzu-owned subscription credential: /logout [claude|codex|all].',
439
534
  action: (_ctx, args) => {
440
535
  const target = args.join(' ').trim().toLowerCase();
@@ -456,15 +551,52 @@ export const CLI_LOCAL_COMMANDS = [
456
551
  },
457
552
  {
458
553
  name: 'cost',
459
- description: 'Show tokens and spend for this run.',
554
+ help: { usage: ['/cost [details]'] },
555
+ description: 'Show usage and cost for the current or latest run.',
556
+ action: (ctx, args) => reportCommand('cost', args, (details) => renderCost(ctx.usage, ctx.compaction, details)),
557
+ },
558
+ {
559
+ name: 'jobs',
560
+ description: 'List background jobs started this session, running and ended.',
561
+ action: (ctx) => ({
562
+ kind: 'message',
563
+ role: 'system',
564
+ content: renderJobs(ctx.jobs()),
565
+ }),
566
+ },
567
+ {
568
+ name: 'release-notes',
569
+ help: { usage: ['/release-notes [version]'] },
570
+ description: 'Show what changed in the version that is running: /release-notes [version].',
571
+ action: (_ctx, args) => ({
572
+ kind: 'message',
573
+ role: 'system',
574
+ content: renderReleaseNotes(readChangelog(), args[0]?.trim() || undefined),
575
+ }),
576
+ },
577
+ {
578
+ name: 'hooks',
579
+ description: 'List the shell hooks this session runs, by event.',
460
580
  action: (ctx) => ({
461
581
  kind: 'message',
462
582
  role: 'system',
463
- content: renderCost(ctx.usage),
583
+ content: renderHooks(ctx.hooks?.()),
464
584
  }),
465
585
  },
586
+ {
587
+ name: 'context',
588
+ help: { usage: ['/context [details]'] },
589
+ description: 'Show context usage and automatic cleanup.',
590
+ action: (ctx, args) => reportCommand('context', args, (details) => renderContext(ctx.usage, ctx.compaction, details)),
591
+ },
466
592
  {
467
593
  name: 'review',
594
+ help: {
595
+ usage: ['/review [instructions]'],
596
+ details: [
597
+ 'Without instructions, opens the review target menu. Starting a review uses the selected model.',
598
+ ],
599
+ },
468
600
  description: 'Choose a review target, or provide custom instructions: /review [instructions].',
469
601
  action: (_ctx, args) => {
470
602
  const instructions = args.join(' ').trim();
@@ -473,12 +605,9 @@ export const CLI_LOCAL_COMMANDS = [
473
605
  },
474
606
  {
475
607
  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
- }),
608
+ help: { usage: ['/mcp [tools|details]'] },
609
+ description: 'Show connected tool servers and connection problems.',
610
+ action: (ctx, args) => reportCommand('mcp', args, (details) => renderMcp(ctx.mcp(), details), 'tools'),
482
611
  },
483
612
  {
484
613
  name: 'diff',
@@ -488,6 +617,10 @@ export const CLI_LOCAL_COMMANDS = [
488
617
  {
489
618
  name: 'compact',
490
619
  description: 'Summarise the older half of this conversation to free up context.',
620
+ help: {
621
+ usage: ['/compact'],
622
+ details: ['Compaction uses a model call to summarise older context.'],
623
+ },
491
624
  action: () => ({ kind: 'compact' }),
492
625
  },
493
626
  {
@@ -497,6 +630,7 @@ export const CLI_LOCAL_COMMANDS = [
497
630
  },
498
631
  {
499
632
  name: 'raw',
633
+ help: { usage: ['/raw [on|off]'] },
500
634
  description: 'Toggle copy-friendly plain transcript rendering: /raw [on|off].',
501
635
  action: (_ctx, args) => {
502
636
  const choice = args.join(' ').trim().toLowerCase();
@@ -515,6 +649,12 @@ export const CLI_LOCAL_COMMANDS = [
515
649
  },
516
650
  {
517
651
  name: 'export',
652
+ help: {
653
+ usage: ['/export [path]'],
654
+ details: [
655
+ 'Without a path, choose the clipboard or a Markdown file. Existing files are not overwritten. Paths may contain spaces.',
656
+ ],
657
+ },
518
658
  description: 'Export this verified conversation to the clipboard or a Markdown file.',
519
659
  action: (_ctx, args) => {
520
660
  const path = args.join(' ').trim();
@@ -523,40 +663,76 @@ export const CLI_LOCAL_COMMANDS = [
523
663
  },
524
664
  {
525
665
  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
- }),
666
+ help: { usage: ['/status [details|config|tools]'] },
667
+ description: 'Show the model, permissions, workspace and latest cost.',
668
+ action: (ctx, args) => {
669
+ const which = args.join(' ').trim().toLowerCase();
670
+ if (which === 'config') {
671
+ return {
672
+ kind: 'message',
673
+ role: 'system',
674
+ content: renderConfigDebug(ctx.configDebug),
675
+ };
676
+ }
677
+ if (which === 'tools') {
678
+ const tools = ctx.availableTools();
679
+ return {
680
+ kind: 'message',
681
+ role: 'system',
682
+ content: tools.length === 0
683
+ ? 'No tools registered yet — the agent session may still be connecting.'
684
+ : `Registered tools (${tools.length}):\n ${tools.join('\n ')}`,
685
+ };
686
+ }
687
+ if (which.length > 0 && which !== 'details') {
688
+ return {
689
+ kind: 'message',
690
+ role: 'system',
691
+ content: 'Usage: /status [details|config|tools]',
692
+ };
693
+ }
694
+ return { kind: 'message', role: 'system', content: renderStatus(ctx, which === 'details') };
695
+ },
541
696
  },
542
697
  {
543
698
  name: 'permissions',
544
- description: 'Choose how undecided tool calls are handled: /permissions [mode].',
545
- action: (_ctx, args) => {
699
+ description: 'Choose which actions need your approval.',
700
+ help: {
701
+ usage: ['/permissions', '/permissions details', '/permissions <mode>'],
702
+ details: [
703
+ 'Choose a preset in the menu, or use a typed mode:',
704
+ ...['prompt', 'accept-edits', 'plan', 'auto', 'strict'].map((mode) => ` ${mode}: ${permissionModeLabel(mode)}`),
705
+ 'Changes apply to this session. Explicit deny rules and sandbox restrictions still apply.',
706
+ ],
707
+ },
708
+ action: (ctx, args) => {
546
709
  if (args.length === 0)
547
710
  return { kind: 'permission-mode-picker' };
548
711
  const mode = args.length === 1 ? args[0]?.toLowerCase() : undefined;
712
+ if (mode === 'details') {
713
+ return {
714
+ kind: 'message',
715
+ role: 'system',
716
+ content: renderPermissions(ctx.permissions, true),
717
+ };
718
+ }
549
719
  if (isPermissionMode(mode))
550
720
  return { kind: 'permission-mode', mode };
551
721
  return {
552
722
  kind: 'message',
553
723
  role: 'system',
554
- content: 'Usage: /permissions [prompt|auto|strict]',
724
+ content: 'Usage: /permissions opens the permission menu. Choose a preset there, or use /permissions details to view rules.',
555
725
  };
556
726
  },
557
727
  },
558
728
  {
559
729
  name: 'effort',
730
+ help: {
731
+ usage: ['/effort [level|default]'],
732
+ details: [
733
+ 'Run /effort to see the levels supported by the selected model and usable fallbacks. Changes affect future main-query turns in this session.',
734
+ ],
735
+ },
560
736
  description: 'Choose reasoning effort for future turns: /effort [level|default].',
561
737
  action: (ctx, args) => {
562
738
  if (args.length === 0) {
@@ -608,6 +784,12 @@ export const CLI_LOCAL_COMMANDS = [
608
784
  {
609
785
  name: 'init',
610
786
  description: 'Write an AGENTS.md describing this project to future agents.',
787
+ help: {
788
+ usage: ['/init'],
789
+ details: [
790
+ 'Asks the selected model to read the project and write instructions. Existing instructions are read before changes are proposed.',
791
+ ],
792
+ },
611
793
  action: (ctx) => {
612
794
  if (!ctx.providerSummary) {
613
795
  return {
@@ -667,53 +849,131 @@ export function initPrompt(instructionFiles) {
667
849
  'future run, so a sentence that says nothing is not free.',
668
850
  ].join('\n');
669
851
  }
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.';
852
+ /** The session's shell hooks, by event, in the order the config file gave them. */
853
+ export function renderHooks(hooks) {
854
+ const events = Object.entries(hooks ?? {}).filter(([, entries]) => (entries?.length ?? 0) > 0);
855
+ if (events.length === 0) {
856
+ return 'No hooks. Add a `hooks` key to namzu.config.json or ~/.namzu/config.yaml: event → list of { command, matcher?, timeoutMs? }.';
682
857
  }
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' : ''}`,
858
+ const lines = [];
859
+ for (const [event, entries] of events) {
860
+ lines.push(`${event}`);
861
+ for (const entry of entries ?? []) {
862
+ const when = entry.matcher ? ` · matches ${entry.matcher}` : '';
863
+ const deadline = entry.timeoutMs !== undefined ? ` · ${Math.round(entry.timeoutMs / 1000)}s` : '';
864
+ lines.push(` ${entry.command}${when}${deadline}`);
865
+ }
866
+ }
867
+ return lines.join('\n');
868
+ }
869
+ /** The session's background jobs, as an operator would ask about them. */
870
+ export function renderJobs(jobs) {
871
+ if (jobs.length === 0) {
872
+ return 'No background jobs this session. The agent starts one with `run_in_background` on bash; under a sandbox none can be started.';
873
+ }
874
+ const lines = jobs.map((job) => {
875
+ const state = job.status === 'running'
876
+ ? `running for ${Math.max(1, Math.round((Date.now() - job.startedAt) / 1000))}s`
877
+ : job.status === 'killed'
878
+ ? 'stopped'
879
+ : `exited ${job.exitCode ?? '?'}`;
880
+ return `${job.id} ${state.padEnd(16)} ${job.command}`;
881
+ });
882
+ return [
883
+ `${jobs.length} background job${jobs.length === 1 ? '' : 's'} this session:`,
884
+ ...lines,
705
885
  '',
886
+ 'The agent reads one with the job tool; ask it to stop one, or /exit stops them all.',
887
+ ].join('\n');
888
+ }
889
+ /** Reports validate their subcommand instead of silently ignoring mistyped arguments. */
890
+ function reportCommand(name, args, render, detailName = 'details') {
891
+ const option = args.join(' ').trim().toLowerCase();
892
+ const details = option === detailName || option === 'details';
893
+ return {
894
+ kind: 'message',
895
+ role: 'system',
896
+ content: option.length === 0 || details ? render(details) : `Usage: /${name} [${detailName}]`,
897
+ };
898
+ }
899
+ function contextMeasurement(usage) {
900
+ const context = usage?.context;
901
+ if (!context || context.windowTokens <= 0)
902
+ return ['No context measurement yet.'];
903
+ const percent = Math.min(999, Math.round((context.tokens / context.windowTokens) * 100));
904
+ const approx = context.measured && !context.windowAssumed ? '' : '~';
905
+ return [
906
+ `Context: ${context.tokens.toLocaleString('en-US')} / ${context.windowTokens.toLocaleString('en-US')} tokens (${approx}${percent}%)`,
907
+ `${context.measured ? 'Counted by the provider' : 'Estimated by Namzu'}; window ${context.windowAssumed ? 'assumed from a table or default' : 'declared by the provider or config'}.`,
908
+ ];
909
+ }
910
+ export function renderContext(usage, compaction, details = false) {
911
+ const lines = contextMeasurement(usage);
912
+ if (usage?.context)
913
+ lines.push('Latest reported context size; not cumulative token usage.');
914
+ if (!compaction) {
915
+ lines.push('Automatic cleanup: unavailable before a session starts.');
916
+ }
917
+ else {
918
+ lines.push(`Cleanup this session: ${compaction.passes} passes, ~${compaction.reclaimedTokens.toLocaleString('en-US')} tokens freed.`);
919
+ if (details) {
920
+ const pct = (fraction) => `${Math.round(fraction * 100)}%`;
921
+ lines.push(`Strategy: ${compaction.strategy}`);
922
+ if (compaction.strategy === 'salience') {
923
+ lines.push(`Older, less useful content is shortened from ${pct(compaction.softTarget)} of the window; older history is summarised at ${pct(compaction.triggerThreshold)}.`);
924
+ }
925
+ else {
926
+ lines.push(`Older tool results are cleared at ${pct(compaction.triggerThreshold)} of the window; history is summarised if needed.`);
927
+ }
928
+ lines.push(`Tool results cleared: ${compaction.clearedResults.toLocaleString('en-US')}`, `Messages shortened: ${compaction.stubbedNarrations.toLocaleString('en-US')}`, `Summaries written: ${compaction.summaries.toLocaleString('en-US')}`);
929
+ }
930
+ }
931
+ if (!details)
932
+ lines.push('/context details for cleanup history.');
933
+ return lines.join('\n');
934
+ }
935
+ /** Keep unknown prices distinct from measured zero, in status and cost alike. */
936
+ function costAmount(cost) {
937
+ if (cost.unpricedTokens > 0) {
938
+ return cost.totalCost > 0 ? `at least $${cost.totalCost.toFixed(4)}` : 'not known';
939
+ }
940
+ return `$${cost.totalCost.toFixed(4)}${cost.totalCost === 0 ? ' (measured zero)' : ''}`;
941
+ }
942
+ export function renderCost(usage, compaction = null, details = false) {
943
+ if (usage === null)
944
+ return 'No usage reported yet. Send a message to begin.';
945
+ const lines = [
946
+ 'Current or latest run',
947
+ `Tokens: ${usage.totalTokens.toLocaleString('en-US')} (own model calls)`,
948
+ `Cost: ${costAmount(usage.cost)}`,
706
949
  ];
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.');
950
+ if (usage.cost.unpricedTokens > 0) {
951
+ lines.push(`${usage.cost.unpricedTokens.toLocaleString('en-US')} tokens have no known price and are excluded from the cost.`);
709
952
  }
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.');
953
+ if (usage.budget) {
954
+ lines.push(`Including delegated agents: ${usage.budget.treeTokens.toLocaleString('en-US')} tokens; limit ${usage.budget.limit === 0 ? 'unlimited' : usage.budget.limit.toLocaleString('en-US')}.`);
955
+ if (usage.budget.poisoned)
956
+ lines.push('Further spending is blocked until unresolved request usage is reconciled.');
957
+ }
958
+ if (details) {
959
+ lines.push('Cost covers this run’s own model calls, excluding delegated calls and earlier runs. These are not conversation totals.');
960
+ if (usage.cost.unpricedTokens > 0) {
961
+ lines.push('Missing prices do not mean those tokens were free.');
962
+ }
963
+ else {
964
+ lines.push('All reported tokens have a known rate.');
965
+ }
966
+ if (usage.cost.cacheDiscount > 0)
967
+ lines.push(`Cache discount: $${usage.cost.cacheDiscount.toFixed(4)}`);
968
+ if (usage.context && usage.context.windowTokens > 0) {
969
+ lines.push('', ...contextMeasurement(usage), '/context details for automatic cleanup.');
970
+ }
971
+ if (compaction)
972
+ lines.push(`Cleanup passes this session: ${compaction.passes}.`);
712
973
  }
713
974
  else {
714
- lines.push('Every token in this run was charged at a known rate.');
975
+ lines.push('/cost details for scope and pricing; /context for context size.');
715
976
  }
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
977
  return lines.join('\n');
718
978
  }
719
979
  /** What decides a tool call, in the order it actually decides it. */
@@ -797,146 +1057,93 @@ function reviewTargetPrompt(target) {
797
1057
  'Say plainly what you did not examine.',
798
1058
  ].join('\n');
799
1059
  }
800
- export function renderMcp(mcp) {
1060
+ export function renderMcp(mcp, details = false) {
801
1061
  if (mcp === null)
802
- return 'No session yet — no tool servers have been contacted.';
803
- const lines = [];
1062
+ return 'No session yet. Tool servers have not been checked.';
804
1063
  if (mcp.connected.length === 0 && mcp.failed.length === 0) {
805
- return 'No tool servers configured. Add an `mcpServers` block to namzu.config.json.';
1064
+ return 'No tool servers configured. Add mcpServers to namzu.config.json.';
806
1065
  }
1066
+ const lines = [
1067
+ `Tool servers: ${mcp.connected.length} connected, ${mcp.failed.length} unavailable.`,
1068
+ ];
807
1069
  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
- }
1070
+ lines.push(`${server.name}: connected, ${server.tools.length} tools`);
1071
+ if (details)
1072
+ for (const tool of server.tools)
1073
+ lines.push(` ${tool}`);
820
1074
  }
1075
+ for (const server of mcp.failed)
1076
+ lines.push(`${server.name}: unavailable — ${server.reason}`);
1077
+ if (!details && mcp.connected.length > 0)
1078
+ lines.push('/mcp tools to list available tools.');
821
1079
  return lines.join('\n');
822
1080
  }
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');
1081
+ export function renderStatus(ctx, details = false) {
1082
+ const lines = [
1083
+ `Provider: ${ctx.providerSummary ?? 'none — run /model to choose one'}`,
1084
+ `Model: ${ctx.modelSummary ?? 'not selected'}`,
1085
+ `Working directory: ${ctx.cwd}`,
1086
+ ...renderPermissions(ctx.permissions, details).split('\n'),
1087
+ ];
829
1088
  const sandbox = ctx.sandbox;
830
1089
  if (!sandbox) {
831
- lines.push(' Not resolved yet — no session has started.');
1090
+ lines.push('Sandbox: not resolved yet.');
832
1091
  }
833
1092
  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.');
1093
+ lines.push('Sandbox: commands are not confined; they have this shell’s access.');
842
1094
  }
843
1095
  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.`);
1096
+ lines.push(`Sandbox: ${sandbox.environment ?? 'active'}; enforces ${sandbox.enforced.join(', ') || 'no reported restrictions'}.`);
851
1097
  }
852
- else if (sandbox) {
853
- lines.push(' Required by config: nothing — this host decides what you get.');
1098
+ if (sandbox?.workspace === 'ephemeral') {
1099
+ lines.push('Workspace: temporary files; removed when the run ends.');
854
1100
  }
855
- if (sandbox?.workspace === 'working-directory') {
856
- lines.push(' Workspace: real project files — changes persist across turns.');
1101
+ else if (sandbox?.workspace === 'working-directory' || sandbox?.workspace === 'host') {
1102
+ lines.push('Workspace: real project files; edits persist.');
857
1103
  }
858
- else if (sandbox?.workspace === 'ephemeral') {
859
- lines.push(' Workspace: disposable per run — changes are removed at teardown.');
860
- }
861
- else if (sandbox?.workspace === 'host') {
862
- lines.push(' Workspace: real project files on the host.');
863
- }
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] ?? ''}`);
1104
+ if (details) {
1105
+ for (const dir of ctx.directories?.() ?? [])
1106
+ lines.push(`Additional directory: ${dir}`);
1107
+ if (sandbox) {
1108
+ lines.push(`Required isolation: ${sandbox.required.length > 0 ? sandbox.required.join(', ') : 'none'}.`);
1109
+ if (sandbox.required.length > 0)
1110
+ lines.push('A host missing required isolation cannot start the session.');
1111
+ }
1112
+ lines.push('/status config for setting sources; /status tools for available tools.');
872
1113
  }
873
- return lines.join('\n').trimEnd();
1114
+ if (ctx.usage)
1115
+ lines.push(`Spend (current or latest run, own calls): ${costAmount(ctx.usage.cost)}`);
1116
+ if (!details)
1117
+ lines.push('/status details for rules and workspace details.');
1118
+ return lines.join('\n');
874
1119
  }
875
- export function renderPermissions(permissions) {
876
- const lines = [];
1120
+ export function renderPermissions(permissions, details = false) {
877
1121
  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.');
1122
+ const effective = effectivePermissionMode(current.mode, permissions.approvalLatched());
1123
+ const approvedAll = effective !== current.mode;
1124
+ const lines = [
1125
+ `Permissions: ${permissionModeLabel(effective)}. ${approvedAll ? 'Tools are approved automatically for this session.' : permissionModeDescription(effective)}`,
1126
+ ];
1127
+ if (approvedAll)
1128
+ lines.push('Choose a preset in /permissions to change this.');
1129
+ if (!details)
1130
+ return lines.join('\n');
1131
+ if (current.source === 'launch-bypass')
1132
+ lines.push('Selected at launch with --dangerously-skip-permissions.');
1133
+ lines.push('Explicit deny rules and the built-in safety gate apply in every mode.');
1134
+ if (current.mode === 'plan')
1135
+ lines.push('Plan mode also blocks writes that an allow rule would otherwise permit.');
1136
+ const exempt = permissions.neverPrompted();
1137
+ if (exempt.length > 0) {
1138
+ lines.push(`Tools exempt from ordinary prompts: ${exempt.join(', ')}.`);
1139
+ 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
1140
  }
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.');
1141
+ if (permissions.rules.length > 0) {
1142
+ lines.push(`Rules (${permissions.rules.length}):`, ...permissions.rules.map((rule) => ` ${describeRule(rule)}`));
917
1143
  }
918
1144
  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)}`);
1145
+ lines.push('No custom rules. Configure permissions in namzu.config.json.');
923
1146
  }
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
1147
  return lines.join('\n');
941
1148
  }
942
1149
  /** Render the exact session control without inventing a global effort menu. */
@@ -1044,8 +1251,13 @@ export function runSlash(line, ctx, builtins = CLI_LOCAL_COMMANDS) {
1044
1251
  // a `problem` so their author is told, rather than leaving them to wonder
1045
1252
  // why the file never ran.
1046
1253
  const cmd = builtins.find((c) => c.name === parsed.name);
1047
- if (cmd)
1048
- return cmd.action(ctx, parsed.args);
1254
+ if (cmd) {
1255
+ const commandContext = { ...ctx, builtins };
1256
+ const reason = cmd.unavailable?.(commandContext);
1257
+ return reason
1258
+ ? { kind: 'message', role: 'system', content: reason }
1259
+ : cmd.action(commandContext, parsed.args);
1260
+ }
1049
1261
  const user = ctx.userCommands.find((c) => c.name === parsed.name);
1050
1262
  if (user) {
1051
1263
  const expanded = expandCommand(user, parsed.args.join(' '));