@namzu/cli 27.0.0 → 28.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 (113) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +24 -18
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +122 -12
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/drain.js +2 -2
  8. package/dist/commands/drain.js.map +1 -1
  9. package/dist/commands/{run-flags.d.ts → exec-flags.d.ts} +29 -21
  10. package/dist/commands/exec-flags.d.ts.map +1 -0
  11. package/dist/commands/{run-flags.js → exec-flags.js} +31 -23
  12. package/dist/commands/exec-flags.js.map +1 -0
  13. package/dist/commands/{run-stream.d.ts → exec-json.d.ts} +20 -14
  14. package/dist/commands/exec-json.d.ts.map +1 -0
  15. package/dist/commands/exec-json.js +473 -0
  16. package/dist/commands/exec-json.js.map +1 -0
  17. package/dist/commands/{run.d.ts → exec.d.ts} +14 -13
  18. package/dist/commands/exec.d.ts.map +1 -0
  19. package/dist/commands/exec.js +683 -0
  20. package/dist/commands/exec.js.map +1 -0
  21. package/dist/commands/host-queries.d.ts +12 -0
  22. package/dist/commands/host-queries.d.ts.map +1 -0
  23. package/dist/commands/host-queries.js +149 -0
  24. package/dist/commands/host-queries.js.map +1 -0
  25. package/dist/commands/prior-messages.js +1 -1
  26. package/dist/commands/prior-messages.js.map +1 -1
  27. package/dist/commands/registry.d.ts.map +1 -1
  28. package/dist/commands/registry.js +2 -0
  29. package/dist/commands/registry.js.map +1 -1
  30. package/dist/commands/resident-flags.d.ts +2 -2
  31. package/dist/commands/resident-flags.d.ts.map +1 -1
  32. package/dist/commands/resident-flags.js +4 -2
  33. package/dist/commands/resident-flags.js.map +1 -1
  34. package/dist/commands/resident-learning.js +1 -1
  35. package/dist/commands/resident-learning.js.map +1 -1
  36. package/dist/commands/resident.js +1 -1
  37. package/dist/commands/resident.js.map +1 -1
  38. package/dist/commands/skills.js +1 -1
  39. package/dist/commands/skills.js.map +1 -1
  40. package/dist/commands/stdin.d.ts +5 -5
  41. package/dist/commands/stdin.js +5 -5
  42. package/dist/commands/types.d.ts +2 -0
  43. package/dist/commands/types.d.ts.map +1 -1
  44. package/dist/config/load.d.ts.map +1 -1
  45. package/dist/config/load.js +19 -0
  46. package/dist/config/load.js.map +1 -1
  47. package/dist/config/schema.d.ts +18 -4
  48. package/dist/config/schema.d.ts.map +1 -1
  49. package/dist/config/schema.js.map +1 -1
  50. package/dist/config/{run-limits.d.ts → turn-guards.d.ts} +1 -1
  51. package/dist/config/turn-guards.d.ts.map +1 -0
  52. package/dist/config/{run-limits.js → turn-guards.js} +1 -1
  53. package/dist/config/turn-guards.js.map +1 -0
  54. package/dist/context/project.d.ts +1 -1
  55. package/dist/context/project.js +1 -1
  56. package/dist/doctor/checks/chain.js +2 -2
  57. package/dist/doctor/checks/chain.js.map +1 -1
  58. package/dist/integrations/providers/access.d.ts +4 -1
  59. package/dist/integrations/providers/access.d.ts.map +1 -1
  60. package/dist/integrations/providers/access.js +6 -3
  61. package/dist/integrations/providers/access.js.map +1 -1
  62. package/dist/integrations/providers/zen-catalogue.d.ts +100 -0
  63. package/dist/integrations/providers/zen-catalogue.d.ts.map +1 -0
  64. package/dist/integrations/providers/zen-catalogue.js +196 -0
  65. package/dist/integrations/providers/zen-catalogue.js.map +1 -0
  66. package/dist/integrations/resident/runner-launch.d.ts +3 -3
  67. package/dist/integrations/resident/runner-launch.d.ts.map +1 -1
  68. package/dist/integrations/resident/runner-worker.js +22 -0
  69. package/dist/integrations/resident/runner-worker.js.map +1 -1
  70. package/dist/integrations/resident/session-step.d.ts +2 -2
  71. package/dist/integrations/resident/session-step.d.ts.map +1 -1
  72. package/dist/integrations/resident/session-step.js +1 -1
  73. package/dist/integrations/resident/session-step.js.map +1 -1
  74. package/dist/integrations/subagents/runtime.d.ts.map +1 -1
  75. package/dist/integrations/subagents/runtime.js +1 -1
  76. package/dist/integrations/subagents/runtime.js.map +1 -1
  77. package/dist/integrations/web/search.d.ts +1 -1
  78. package/dist/logging.d.ts +5 -5
  79. package/dist/logging.js +6 -6
  80. package/dist/permissions/headless-trust.d.ts +2 -2
  81. package/dist/permissions/headless-trust.js +3 -3
  82. package/dist/permissions/headless-trust.js.map +1 -1
  83. package/dist/termination.d.ts +1 -1
  84. package/dist/termination.js +1 -1
  85. package/dist/tui/App.js +5 -5
  86. package/dist/tui/App.js.map +1 -1
  87. package/dist/tui/agent.d.ts +4 -4
  88. package/dist/tui/agent.d.ts.map +1 -1
  89. package/dist/tui/agent.js +13 -4
  90. package/dist/tui/agent.js.map +1 -1
  91. package/dist/tui/slashCommands.d.ts +1 -1
  92. package/dist/tui/slashCommands.js +5 -5
  93. package/dist/tui/slashCommands.js.map +1 -1
  94. package/dist/tui/status-panel-layout.js +1 -1
  95. package/dist/tui/status-panel-layout.js.map +1 -1
  96. package/dist/tui/turn-limits-settings.d.ts +1 -1
  97. package/dist/tui/turn-limits-settings.d.ts.map +1 -1
  98. package/dist/tui/turn-limits-settings.js +1 -1
  99. package/dist/tui/turn-limits-settings.js.map +1 -1
  100. package/dist/user-commands/store.d.ts +3 -3
  101. package/dist/user-commands/store.js +4 -4
  102. package/dist/user-commands/store.js.map +1 -1
  103. package/package.json +3 -3
  104. package/dist/commands/run-flags.d.ts.map +0 -1
  105. package/dist/commands/run-flags.js.map +0 -1
  106. package/dist/commands/run-stream.d.ts.map +0 -1
  107. package/dist/commands/run-stream.js +0 -647
  108. package/dist/commands/run-stream.js.map +0 -1
  109. package/dist/commands/run.d.ts.map +0 -1
  110. package/dist/commands/run.js +0 -617
  111. package/dist/commands/run.js.map +0 -1
  112. package/dist/config/run-limits.d.ts.map +0 -1
  113. package/dist/config/run-limits.js.map +0 -1
@@ -1,647 +0,0 @@
1
- /**
2
- * `namzu run-stream [--session <key>] "<prompt>"` — headless STREAMING
3
- * one-shot. Same engine as `run`, but instead of buffering the final text it
4
- * emits one compact NDJSON line per `AgentEvent` to stdout
5
- * (`{"kind":"delta","text":…}`, `{"kind":"tool-start",…}`,
6
- * `{"kind":"error","message":…}`, `{"kind":"done"}`). A host process — a
7
- * desktop app embedding namzu, say — line-scans stdout and renders the turn
8
- * live: the equivalent of the TUI, driven from another runtime.
9
- *
10
- * History: with `--session <key>` the turn is bound to a persisted
11
- * conversation in the project the canonical cwd stands for (keyed by the
12
- * embedder's own session id, recorded on the conversation's
13
- * `session_started.origin`), so prior turns are folded from its session log as
14
- * context and the kernel appends this turn to that log as it runs — opaque
15
- * reasoning and complete tool turns included. That's what lets a reopened
16
- * session show and replay its past messages (`namzu history --session
17
- * <key>`). Without `--session`, prior history may be supplied on stdin as a
18
- * JSON `Message[]` and the turn runs in an in-memory log: nothing is persisted
19
- * (stateless one-shot).
20
- *
21
- * Status lines go to stderr as NDJSON (LOG-05), never stdout, so every
22
- * stdout line is a valid JSON event, and EVERY failure is reported in band,
23
- * including the ones that also carry an exit code.
24
- *
25
- * ## What the exit code means
26
- *
27
- * It used to be explained as "a turn that STARTED and failed exits 0; a refusal
28
- * to start exits non-zero", and that rule did not sort the cases it was applied
29
- * to. An unknown option, a missing prompt, a `--cwd` that is not there and a
30
- * tool server that will not connect are all refusals to start, and all four
31
- * exited 0 while an untrusted folder exited 77. Neither does the retry argument
32
- * the old comment appealed to: retrying an unknown option is exactly as
33
- * pointless as retrying an untrusted folder.
34
- *
35
- * The axis that does sort them:
36
- *
37
- * CAN THE CALLER REACH THE TURN IT ASKED FOR BY CHANGING WHAT IT SENDS?
38
- *
39
- * - **Yes → `0`.** The host reads the `error` event and fixes its own
40
- * invocation. An unknown option, no prompt, a `--cwd` that does not exist, a
41
- * `--permission-mode` that is not a mode, an interactive command named
42
- * headlessly, a provider id that is not a provider.
43
- * - **A turn that started and failed → `0`.** Unchanged: that is an outcome to
44
- * render, and possibly to retry.
45
- * - **Not now → `75`.** The conversation already has an active turn — a
46
- * paused one waiting on a decision or a drain, or one another process is
47
- * running — so this turn was not begun. The event is
48
- * `{"kind":"error","code":"turn_in_progress",...}` naming the turn.
49
- * - **No → non-zero, because a person has to go and do something.** `77` when
50
- * the folder is untrusted — kept to that one condition, because being
51
- * unambiguous is its entire justification. `1` for everything else in this
52
- * group: a conversation that cannot be opened, no provider available, a
53
- * credential or driver the session needs, a declared tool server that is not
54
- * there, a command file that will not parse.
55
- *
56
- * `1` rather than a new code because `namzu run` — the same one-shot, differing
57
- * only in how it prints — already exits `1` for these conditions and `77` for
58
- * trust. A host that shells to one for a script and the other for a UI must not
59
- * be handed two tables for one fact.
60
- *
61
- * Dropping `--session` is NOT "the caller fixing it": it abandons what was
62
- * asked for rather than achieving it.
63
- */
64
- import { TurnCancelled, asSessionId, generateSessionId, isEntityId, jsonLinesSink, } from '@namzu/sdk';
65
- /** EX_TEMPFAIL: the conversation already has an active turn; try again later. */
66
- const EXIT_TURN_IN_PROGRESS = 75;
67
- import { resolveTrustedProjectContext } from '../config/trusted-project-context.js';
68
- import { EXIT_FAIL, EXIT_OK, EXIT_UNTRUSTED } from '../exit-codes.js';
69
- import { closeSessions, findMappedConversation, listRecent, loadConversation, openSessions, resolveConversation, } from '../integrations/sessions/store.js';
70
- import { contextLogging, installCliLogging } from '../logging.js';
71
- import { decideHeadlessTrust } from '../permissions/headless-trust.js';
72
- import { resolvePermissionMode } from '../permissions/mode.js';
73
- import { compilePermissions } from '../permissions/rules.js';
74
- import { withTerminationHandling } from '../termination.js';
75
- import { hostCommandNames } from '../tui/slashCommands.js';
76
- import { expandHeadlessCommand } from '../user-commands/store.js';
77
- import { parsePriorMessages } from './prior-messages.js';
78
- import { applyProviderFlags, buildGate, loadSkillsContext, parseRunFlags, resolveWorkingDirectory, unknownOptionMessage, } from './run-flags.js';
79
- import { readStdin } from './stdin.js';
80
- function defaultPrefs(detected) {
81
- const first = detected[0];
82
- return first
83
- ? {
84
- version: 3,
85
- providers: [{ id: first.entry.id }],
86
- subagents: { active: [] },
87
- }
88
- : null;
89
- }
90
- export const runStreamCommand = {
91
- name: 'run-stream',
92
- description: 'Run a single prompt and stream AgentEvents as NDJSON (for host UIs)',
93
- passThrough: true,
94
- help: [
95
- 'Usage: namzu run-stream <prompt...> [--session <id>] [--cwd <path>]',
96
- '',
97
- 'Run a single prompt and stream one JSON event per line as the turn',
98
- 'unfolds — text deltas, tool starts and ends, usage, then a terminal',
99
- 'event. Built for a host UI that renders progress rather than waiting',
100
- 'for a final string.',
101
- 'The terminal done.text is the settled answer. Earlier deltas may include',
102
- 'progress or answers later rejected by verification.',
103
- '',
104
- 'Takes the same options as `namzu run`, including --effort, --permission-mode and',
105
- 'the [permissions] table from the config file.',
106
- '',
107
- 'History is bound with --session <id>. --continue and --resume are `run`',
108
- 'options and are refused here rather than ignored.',
109
- 'Without --session, optional stdin must be one complete JSON Message[];',
110
- 'invalid or provider-incomplete tool history is refused before a turn.',
111
- '',
112
- 'The folder has to be trusted: run `namzu` here once and accept the',
113
- 'prompt, or pass --trust for one turn. An untrusted folder emits an error',
114
- 'event and exits 77 without running anything.',
115
- '',
116
- 'Needs a provider. Set a credential in the environment, or run namzu',
117
- 'once to pick one interactively.',
118
- '',
119
- 'Every failure is an event on stdout. The exit code says whether YOU can',
120
- 'do anything about it: 0 when changing what you send would reach the turn',
121
- '(a wrong option, no prompt, a bad --cwd) and when a turn started and',
122
- 'failed; 1 when it would not (no provider, a tool server that is not',
123
- 'there, a conversation that cannot be opened); 75 when the --session',
124
- 'conversation already has an active turn (a paused one: finish it with',
125
- '`namzu drain`), and the error event carries code "turn_in_progress"; 77',
126
- 'when the folder has not been trusted, which only a person can change.',
127
- ].join('\n'),
128
- handler: withTerminationHandling(async ({ ctx: bootstrapCtx, rawArgs }, termination) => {
129
- let ctx = bootstrapCtx;
130
- const write = (o) => {
131
- process.stdout.write(`${JSON.stringify(o)}\n`);
132
- };
133
- /**
134
- * Report and stop.
135
- *
136
- * Always in band, always terminated with `done`, and the code says only
137
- * whether the caller can reach the turn by sending something else. The
138
- * argument for each case is in this file's header; the two spellings exist
139
- * so that every call site below has to state which side it is on rather
140
- * than inheriting a default nobody re-reads.
141
- */
142
- const fail = (message) => {
143
- write({ kind: 'error', message });
144
- write({ kind: 'done' });
145
- return EXIT_OK;
146
- };
147
- const refuse = (message) => {
148
- write({ kind: 'error', message });
149
- write({ kind: 'done' });
150
- return EXIT_FAIL;
151
- };
152
- const flags = parseRunFlags(rawArgs);
153
- // The turn's leash: the config file's limits, with a flag overriding each.
154
- const limitsFromFlags = {
155
- ...(flags.maxIterations !== null ? { maxIterations: flags.maxIterations } : {}),
156
- ...(flags.tokenBudget !== null ? { tokenBudget: flags.tokenBudget } : {}),
157
- };
158
- if (flags.unknown.length > 0)
159
- return fail(unknownOptionMessage(flags.unknown));
160
- // `--continue` and `--resume` parse here because the two commands share one
161
- // parser, and this command reads neither. Accepting a flag and doing
162
- // nothing with it is the failure mode the shared parser was introduced to
163
- // end: a host that asked to reopen a conversation was given a stateless
164
- // run, reported as an ordinary success, and the next turn had no history
165
- // with nothing anywhere connecting the two. Refused instead, and named,
166
- // because a host CAN fix this — `--session` is the flag it wanted.
167
- if (flags.continueLast || flags.resume !== null) {
168
- return fail('run-stream does not take --continue or --resume; they are `namzu run` options. Bind history with --session <id>, which keys a persisted conversation in this folder.');
169
- }
170
- // Same reasoning for the wait budget: this command streams a pause to its
171
- // host as an event and the host decides when to resume; a flag it accepted
172
- // and ignored would promise a wait that never happens.
173
- if (flags.waitForProviderMs !== null) {
174
- return fail('run-stream does not take --wait-for-provider; it is a `namzu run` option. A host reads the paused event and resumes when it chooses.');
175
- }
176
- const sessionKey = flags.session;
177
- const prompt = flags.rest.join(' ').trim();
178
- if (!prompt)
179
- return fail('no prompt — pass it as an argument');
180
- const resolved = resolveWorkingDirectory(flags.cwd);
181
- if ('error' in resolved)
182
- return fail(resolved.error);
183
- const requestedCwd = resolved.cwd;
184
- // Before the session store is opened, before anything is read or run in
185
- // that directory.
186
- //
187
- // Reported BOTH ways, which is the one place this command departs from
188
- // its "every failure is an in-band event and the exit code is 0" rule.
189
- // That rule is about a turn that STARTED and failed, which a host should
190
- // render and may sensibly retry. This is a refusal to start at all, and
191
- // a host that cannot tell the two apart will retry the one that must not
192
- // be retried — so the event carries the explanation and the exit code
193
- // carries the fact that nothing ran.
194
- const trust = decideHeadlessTrust({
195
- cwd: requestedCwd,
196
- trustFlag: flags.trust,
197
- });
198
- if (!trust.allowed) {
199
- write({ kind: 'error', message: trust.message ?? 'folder not trusted' });
200
- write({ kind: 'done' });
201
- return EXIT_UNTRUSTED;
202
- }
203
- const cwd = trust.cwd;
204
- ctx = resolveTrustedProjectContext(bootstrapCtx, cwd);
205
- // Project command discovery belongs behind the target folder's trust
206
- // gate, alongside project config and instructions.
207
- const expansion = expandHeadlessCommand(prompt, {
208
- cwd,
209
- builtins: hostCommandNames(),
210
- });
211
- if (expansion.kind === 'refused') {
212
- return expansion.fixable ? fail(expansion.reason) : refuse(expansion.reason);
213
- }
214
- const finalPrompt = expansion.kind === 'expanded' ? expansion.prompt : prompt;
215
- // Resolve the workspace's central Project for every turn. A session key
216
- // additionally binds a durable conversation; without one, stdin history
217
- // remains stateless and no Session record is created.
218
- let cli;
219
- let conversationId = null;
220
- let prior = [];
221
- if (!sessionKey) {
222
- const parsed = parsePriorMessages(await readStdin({ deadline: true }));
223
- if (!parsed.ok)
224
- return fail(`invalid stdin history: ${parsed.error}`);
225
- prior = [...parsed.messages];
226
- }
227
- try {
228
- cli = await openSessions(cwd);
229
- if (sessionKey) {
230
- conversationId = await resolveConversation(cli, sessionKey);
231
- prior = await loadConversation(cli, conversationId);
232
- }
233
- }
234
- catch (err) {
235
- if (sessionKey) {
236
- // Refused, not run stateless.
237
- //
238
- // This used to set `cli = null` and fall through to the branch
239
- // below, which reads prior turns from STDIN — so a caller who named
240
- // a conversation got a turn answered against a different history,
241
- // or none, reported as an ordinary success. `run.ts` already
242
- // refuses the equivalent, in those words: someone who asked for a
243
- // specific conversation and got a new one that looks the same finds
244
- // out several turns later, having already acted on it.
245
- //
246
- // It cannot be softened into a warning, because we cannot say what
247
- // was lost. `resolveConversation` creates the key on first use, so
248
- // a fresh key legitimately has no prior turns — and the failure is
249
- // exactly what stopped us finding out which case this is. "Could
250
- // not look" is not "there was nothing there."
251
- //
252
- // In band, like every other failure here, because that is what the
253
- // host reads. No session has been built at this point in the flow,
254
- // so there is nothing to close on the way out.
255
- //
256
- // Non-zero, which is the correction #321 is about. `resolveConversation`
257
- // CREATES the key on first use, so this cannot be a key the host got
258
- // wrong — the only way here is the store itself: an unwritable
259
- // `.namzu`, a corrupt map file. Nothing the host sends changes that,
260
- // so a host treating 0 as "render the error and move on" would loop
261
- // on an environment fault it could have raised to a person. Dropping
262
- // `--session` is not a fix; it abandons what was asked for.
263
- return refuse(`could not open conversation "${sessionKey}": ${err instanceof Error ? err.message : String(err)}. Nothing ran, because continuing the wrong history is worse than not continuing. Drop --session to run this turn stateless.`);
264
- }
265
- return refuse(`could not open workspace state: ${err instanceof Error ? err.message : String(err)}. Nothing ran, because generated state could not be bound to this working directory.`);
266
- }
267
- // Always NDJSON on stderr, regardless of --log-format/NAMZU_LOG_FORMAT
268
- // — this is the machine-read channel §6.6 of the logging design
269
- // describes, and stdout's own protocol is unaffected by anything the
270
- // operator passes, so stderr here stays that way too. `{ replace:
271
- // true }`: see `run.ts`'s identical comment.
272
- const logging = contextLogging(ctx);
273
- installCliLogging(jsonLinesSink(process.stderr), logging.level);
274
- const { probeAgentSession, createAgentSession } = await import('../tui/agent.js');
275
- const probe = await probeAgentSession();
276
- let prefs = probe.preferences ?? defaultPrefs(probe.detected);
277
- if (!prefs) {
278
- // Nothing detected and nothing configured. `--provider` cannot conjure a
279
- // credential, so no argument the host changes gets past this line.
280
- return refuse('no LLM provider available — set a credential (e.g. ANTHROPIC_API_KEY) or run `namzu` to pick one');
281
- }
282
- // --provider/--model override the persona's configured provider+model for
283
- // this turn, so the Namzu tab's picks win over ~/.namzu/preferences.json.
284
- prefs = applyProviderFlags(prefs, flags);
285
- // The operator's rules and mode reach this command too. They did not:
286
- // `[permissions]` was compiled for `run` and never for `run-stream`, so a
287
- // host UI ran with an empty rule list whatever the config said — the same
288
- // shape as a flag that parses and does nothing, one level larger, and
289
- // silent in exactly the same way.
290
- const modeResult = resolvePermissionMode({
291
- flag: flags.permissionMode,
292
- skipPermissions: flags.skipPermissions,
293
- interactive: false,
294
- });
295
- if ('error' in modeResult)
296
- return fail(modeResult.error);
297
- const permissions = compilePermissions(ctx.config.permissions, ctx.config.permissionChecks);
298
- for (const d of permissions.diagnostics) {
299
- const where = d.pattern ? `permissions.${d.tool}."${d.pattern}"` : `permissions.${d.tool}`;
300
- // In band, because a host line-scanning stdout has no other channel —
301
- // and a permission the operator believes is in force must never be
302
- // dropped without saying so.
303
- write({ kind: 'error', message: `${where}: ${d.message}` });
304
- }
305
- // The resolved `--cwd` is what the agent's tools resolve against, not just
306
- // where the session store lives — a turn told to work in another checkout
307
- // has to glob, read and edit files there.
308
- const gate = buildGate(flags, cwd);
309
- const session = await createAgentSession(prefs, probe.detected, {
310
- cwd,
311
- scope: {
312
- sessionId: conversationId ?? generateSessionId(),
313
- topicId: cli.topicId,
314
- projectId: cli.projectId,
315
- tenantId: cli.tenantId,
316
- },
317
- stateRoot: cli.root,
318
- // A keyed conversation appends to its log; a stateless one runs in an
319
- // in-memory log, so nothing is written for it.
320
- ...(conversationId ? { conversationSessions: cli } : { ephemeral: true }),
321
- rules: permissions.rules,
322
- // The operator's --gate commands, as a standing condition on the
323
- // answer. Spread rather than passed as undefined so a turn without
324
- // gates is byte-identical to the one that shipped before them.
325
- ...(gate ?? {}),
326
- permissionMode: modeResult.mode,
327
- ...(ctx.config.mcpServers ? { mcpServers: ctx.config.mcpServers } : {}),
328
- ...(ctx.config.plugins ? { plugins: ctx.config.plugins } : {}),
329
- ...(ctx.config.web ? { web: ctx.config.web } : {}),
330
- ...(ctx.config.hooks ? { hooks: ctx.config.hooks } : {}),
331
- ...(ctx.config.compaction ? { compaction: ctx.config.compaction } : {}),
332
- ...(ctx.config.memory ? { memory: ctx.config.memory } : {}),
333
- ...(Object.keys({ ...ctx.config.limits, ...limitsFromFlags }).length > 0
334
- ? { limits: { ...ctx.config.limits, ...limitsFromFlags } }
335
- : {}),
336
- ...(ctx.config.sandbox ? { sandbox: ctx.config.sandbox } : {}),
337
- // `!== undefined`, not truthiness: an empty list is the operator
338
- // turning the default screen OFF, and a falsy check would drop it.
339
- ...(ctx.config.toolResultScreens !== undefined
340
- ? { toolResultScreens: ctx.config.toolResultScreens }
341
- : {}),
342
- });
343
- if (!session.hasProvider) {
344
- await session.close();
345
- // The one branch here that is genuinely BOTH. `createAgentSession`
346
- // refuses an id that is not a provider — which `--provider` put there,
347
- // so the host fixes it — and, with the same result object, a missing
348
- // credential, a driver package that would not load, a chain that
349
- // contradicts itself, a client that would not construct. None of those
350
- // four move for any argument.
351
- //
352
- // The session says which, in a field. Deciding it here by reading its
353
- // `errorHint` would be the message-matching that `exit-codes.ts` exists
354
- // to prevent, and would make that sentence unrewordable.
355
- const message = session.errorHint ?? 'agent is not ready';
356
- return session.errorKind === 'invocation' ? fail(message) : refuse(message);
357
- }
358
- // A configured tool server that is not here means the turn cannot do what
359
- // the operator set it up to do, and this command answers a host, not a
360
- // person who might notice. Refused rather than run short — in band, like
361
- // every other failure here, because that is what the host reads.
362
- if (session.mcpFailed.length > 0) {
363
- const said = session.mcpFailed
364
- .map((f) => `tool server "${f.name}" is not available: ${f.reason}`)
365
- .join('; ');
366
- await session.close();
367
- // The servers come from `namzu.config.json`, not from the invocation, so
368
- // there is no argument the host can change to bring one up. `run`
369
- // already exits 1 here.
370
- return refuse(said);
371
- }
372
- // "Printed on every launch" has to reach a host UI too, or the one caller
373
- // with no human watching is the one that never hears it. Its own event
374
- // kind rather than an `error`: the turn is proceeding, and a host that
375
- // treats this as a failure would be wrong.
376
- for (const notice of session.configNotices) {
377
- write({ kind: 'notice', message: notice });
378
- }
379
- // --skills <a,b,c>: load the named skills' bodies and inject them as the
380
- // turn's extra system context (the same channel the TUI's /skill uses).
381
- const extraSystem = await loadSkillsContext(cwd, flags.skills);
382
- const userMessage = {
383
- role: 'user',
384
- content: finalPrompt,
385
- timestamp: Date.now(),
386
- };
387
- const messages = [...prior, userMessage];
388
- let terminalEvent;
389
- let budget;
390
- let busy;
391
- // Stopped from outside (SIGTERM, SIGHUP, SIGINT): the conversation's
392
- // lease is already given back when this runs (`termination.ts`). The host
393
- // is told in band and last — one `error` of code "terminated", then the
394
- // stream's one `done` — then the turn is stopped (its tools' processes go
395
- // with it) and the session closed. Nothing after those two lines is
396
- // written. What the error says depends on where the signal found the
397
- // turn: mid-flight it is left interrupted; once the turn has settled (the
398
- // session still closing, a slow `session_end` hook) it is recorded, and
399
- // the `done` is the turn's own.
400
- const turnAbort = new AbortController();
401
- let stopped = false;
402
- /** Once the turn has settled: the stream's last line, and what a signal then says. */
403
- let settled;
404
- /** The last line has been written; a signal after this writes nothing. */
405
- let ended = false;
406
- const inConversation = conversationId ? ` in conversation ${conversationId}` : '';
407
- termination.onTerminate(async (signal) => {
408
- stopped = true;
409
- if (!ended) {
410
- ended = true;
411
- try {
412
- write({
413
- kind: 'error',
414
- code: 'terminated',
415
- message: settled
416
- ? `stopped by ${signal} ${settled.state}`
417
- : `stopped by ${signal}; the turn was left interrupted${inConversation}. Close it with /abandon in the TUI, or continue it with /resume or \`namzu drain\`.`,
418
- });
419
- write(settled?.last ?? {
420
- kind: 'done',
421
- ...(conversationId ? { sessionId: conversationId } : {}),
422
- });
423
- }
424
- catch {
425
- // The reader is gone (SIGHUP, a closed pipe); there is nobody to tell.
426
- }
427
- }
428
- turnAbort.abort(new TurnCancelled('user'));
429
- await session.close();
430
- closeSessions(cli);
431
- });
432
- let paused;
433
- try {
434
- for await (const event of session.send(messages, {
435
- signal: turnAbort.signal,
436
- ...(extraSystem ? { extraSystem } : {}),
437
- ...(flags.effort !== null ? { effort: flags.effort } : {}),
438
- })) {
439
- if (stopped)
440
- continue;
441
- if ('budget' in event && event.budget)
442
- budget = event.budget;
443
- if (event.kind === 'paused')
444
- paused = event;
445
- if (event.kind === 'error' && event.turnInProgress) {
446
- busy = event.turnInProgress;
447
- write({ ...event, code: 'turn_in_progress' });
448
- }
449
- else if (event.kind === 'done')
450
- terminalEvent = event;
451
- else
452
- write(event);
453
- }
454
- }
455
- catch (err) {
456
- // The signal handler closes the session and ends the process.
457
- if (stopped)
458
- return EXIT_OK;
459
- const message = err instanceof Error ? err.message : String(err);
460
- settled = {
461
- last: { kind: 'done' },
462
- state: `after the turn failed (${message})${inConversation}.`,
463
- };
464
- await session.close();
465
- closeSessions(cli);
466
- if (stopped)
467
- return EXIT_OK;
468
- ended = true;
469
- return fail(message);
470
- }
471
- if (stopped)
472
- return EXIT_OK;
473
- settled = busy
474
- ? {
475
- // Nothing was begun, so nothing was recorded: the conversation's
476
- // active turn is still the one it was. Not now, rather than not ever.
477
- last: { kind: 'done', sessionId: busy.sessionId },
478
- state: `; no turn was started in conversation ${busy.sessionId}.`,
479
- }
480
- : {
481
- // The kernel appended this turn to the conversation's log as it ran,
482
- // so a later `history --session <key>` and the next turn's context
483
- // see it.
484
- last: {
485
- ...(terminalEvent ?? {
486
- kind: 'done',
487
- ...(conversationId ? { sessionId: conversationId } : {}),
488
- }),
489
- ...(budget ? { budget } : {}),
490
- },
491
- state: paused
492
- ? `while the turn was paused: it keeps checkpoint ${paused.checkpointId}${inConversation}. Continue it with /resume or \`namzu drain\`, or close it with /abandon in the TUI.`
493
- : `after the turn ended; it is recorded${inConversation}, and nothing is left to close.`,
494
- };
495
- // A stdio tool server is a child process; a command that returns without
496
- // closing leaves it running.
497
- await session.close();
498
- closeSessions(cli);
499
- // A signal during the close has written the last line already.
500
- if (stopped)
501
- return EXIT_OK;
502
- ended = true;
503
- write(settled.last);
504
- return busy ? EXIT_TURN_IN_PROGRESS : 0;
505
- }),
506
- };
507
- export const historyCommand = {
508
- name: 'history',
509
- description: "Print a session's persisted messages as JSON (for host UIs)",
510
- passThrough: true,
511
- help: [
512
- 'Usage: namzu history [--session <id>] [--cwd <path>]',
513
- '',
514
- "Print a session's persisted messages as JSON. With no session id the",
515
- 'most recent session for the working directory is used.',
516
- '',
517
- 'An empty array means the session exists and has no messages yet — it',
518
- 'is not an error.',
519
- ].join('\n'),
520
- handler: async ({ rawArgs }) => {
521
- const flags = parseRunFlags(rawArgs);
522
- const key = flags.session;
523
- try {
524
- // `--cwd` is in this command's help too, and picks the central Project
525
- // the session is read from. Reading the process's own directory
526
- // instead means a host asking about a session in another checkout is
527
- // told `[]` — indistinguishable from a session with no messages.
528
- const resolved = resolveWorkingDirectory(flags.cwd);
529
- if ('error' in resolved) {
530
- process.stdout.write('[]\n');
531
- return 0;
532
- }
533
- const cli = await openSessions(resolved.cwd);
534
- // Host keys and CLI conversation UUIDs are distinct entry points. Both
535
- // must pass the same workspace membership check before reading content.
536
- const existing = key
537
- ? ((await findMappedConversation(cli, key)) ??
538
- (isEntityId(key, 'session') ? asSessionId(key) : null))
539
- : (await listRecent(cli, 1))[0]?.id;
540
- if (!existing) {
541
- process.stdout.write('[]\n');
542
- return 0;
543
- }
544
- // The fold of the conversation's log: an answer the runtime replaced
545
- // (a guardrail rewrite) reads as replaced, never as the raw text.
546
- const messages = await loadConversation(cli, existing).finally(() => closeSessions(cli));
547
- const out = messages
548
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content)
549
- .map((m) => ({ role: m.role, content: m.content }));
550
- process.stdout.write(`${JSON.stringify(out)}\n`);
551
- return 0;
552
- }
553
- catch {
554
- process.stdout.write('[]\n');
555
- return 0;
556
- }
557
- },
558
- };
559
- // skills-json — read-only skill discovery for a host UI (the Namzu tab's skill
560
- // chips). Prints the cwd-resolved skills as a JSON array of {name, description,
561
- // source}. Distinct from the milestone-owned `skills` management command; this
562
- // is the thin enumeration the desktop polls. Empty array on any failure.
563
- export const skillsJSONCommand = {
564
- name: 'skills-json',
565
- description: 'Print discovered skills as JSON (for host UIs)',
566
- passThrough: true,
567
- help: [
568
- 'Usage: namzu skills-json [--cwd <path>]',
569
- '',
570
- 'Print the skills discovered for a working directory as JSON. Project',
571
- 'skills come from that directory; user skills come from the home',
572
- 'directory either way.',
573
- '',
574
- 'An empty array means no skills were found — it is not an error.',
575
- ].join('\n'),
576
- handler: async ({ rawArgs }) => {
577
- // Project skills live under the working directory, so a host listing the
578
- // skills for one checkout while the process sits in another was shown
579
- // the wrong project's chips — and then `run-stream --cwd <that
580
- // checkout> --skills <name>` could not find the skill it had just
581
- // offered. Same flag, same directory, both ends.
582
- const resolved = resolveWorkingDirectory(parseRunFlags(rawArgs).cwd);
583
- if ('error' in resolved) {
584
- process.stdout.write('[]\n');
585
- return 0;
586
- }
587
- try {
588
- const { discoverSkills } = await import('../skills/store.js');
589
- const skills = discoverSkills({ cwd: resolved.cwd }).map((s) => ({
590
- name: s.name,
591
- description: s.description,
592
- source: s.source,
593
- }));
594
- process.stdout.write(`${JSON.stringify(skills)}\n`);
595
- }
596
- catch {
597
- process.stdout.write('[]\n');
598
- }
599
- return 0;
600
- },
601
- };
602
- // providers-json — read-only provider+model discovery for a host UI (the Namzu
603
- // tab's provider/model pickers). Emits every PROVIDER_REGISTRY entry with
604
- // detection state + a best-effort live model list. Distinct from the `providers`
605
- // profile-management command. Empty models[] → the host falls back to a
606
- // free-text model field seeded with `default`. Never throws.
607
- export const providersJSONCommand = {
608
- name: 'providers-json',
609
- description: 'Print providers + per-provider models as JSON (for host UIs)',
610
- passThrough: true,
611
- handler: async ({ ctx }) => {
612
- try {
613
- // Always NDJSON on stderr — same reasoning as run-stream's own sink
614
- // above; this command answers a host UI polling for a picker list,
615
- // not a person reading a terminal. installProcessSink/jsonLinesSink
616
- // are already statically imported at the top of this file, so the
617
- // dynamic import('@namzu/sdk') this replaced bought nothing — it
618
- // re-fetched a module already loaded for `Message`.
619
- const logging = contextLogging(ctx);
620
- installCliLogging(jsonLinesSink(process.stderr), logging.level);
621
- const { PROVIDER_REGISTRY, ALL_PROVIDER_IDS, findDetected } = await import('../integrations/providers/index.js');
622
- const { probeAgentSession, listProviderModels } = await import('../tui/agent.js');
623
- const probe = await probeAgentSession();
624
- const out = [];
625
- for (const id of ALL_PROVIDER_IDS) {
626
- const entry = PROVIDER_REGISTRY[id];
627
- const det = findDetected(probe.detected, id) ?? null;
628
- const models = det ? await listProviderModels(id, det).catch(() => []) : [];
629
- out.push({
630
- provider: id,
631
- label: entry.label,
632
- detected: Boolean(det),
633
- default: entry.defaultModel,
634
- models,
635
- });
636
- }
637
- // Detected providers first, so the picker defaults to a usable one.
638
- out.sort((a, b) => Number(b.detected) - Number(a.detected));
639
- process.stdout.write(`${JSON.stringify(out)}\n`);
640
- }
641
- catch {
642
- process.stdout.write('[]\n');
643
- }
644
- return 0;
645
- },
646
- };
647
- //# sourceMappingURL=run-stream.js.map