@ze-norm/cli 0.12.0 → 0.14.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"work.d.ts","sourceRoot":"","sources":["../../src/commands/work.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,KAAK,EAAqB,cAAc,EAAgB,MAAM,iBAAiB,CAAC;AAgBvF;;;;GAIG;AACH,QAAA,MAAM,gBAAgB,8BAA+B,CAAC;AACtD,KAAK,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AA0BxD;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,GAAG,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAeD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,CAmB/D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,cAAc,EACrB,IAAI,EAAE,cAAc,GACnB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAsCrC;AAQD;;;GAGG;AACH,MAAM,MAAM,OAAO,GAAG,CACpB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,YAAY,KAClB,YAAY,CAAC;AAElB;;;;;;;;;GASG;AACH,qBAAa,WAAY,YAAW,UAAU;IAI1C,OAAO,CAAC,QAAQ,CAAC,KAAK;IAEtB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAL1B,SAAgB,SAAS,EAAE,cAAc,CAAC;gBAGvB,KAAK,EAAE,cAAc,EAErB,OAAO,GAAE,OAAe;IAK3C,GAAG,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CA0EzC;AAED;;;;GAIG;AACH,qBAAa,YAAa,YAAW,UAAU;IAGjC,OAAO,CAAC,QAAQ,CAAC,KAAK;IAFlC,SAAgB,SAAS,EAAE,cAAc,CAAC;gBAEb,KAAK,EAAE,cAAc;IAIlD,GAAG,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CAOzC;AAUD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAmD3E;AA4BD,UAAU,UAAU;IAClB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AA+GD;;;GAGG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE;IACtC,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,OAAO,CAAC;CACf,GAAG,OAAO,CAAC,MAAM,CAAC,CA2HlB;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,CAAC,EAAE,UAAU,GAChB,OAAO,CAAC,IAAI,CAAC,CA0Bf"}
1
+ {"version":3,"file":"work.d.ts","sourceRoot":"","sources":["../../src/commands/work.ts"],"names":[],"mappings":"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"}
@@ -3,7 +3,9 @@ import { execFileSync, spawn } from "node:child_process";
3
3
  import { ZenormClient } from "../api/client.js";
4
4
  import { CliError } from "../util/errors.js";
5
5
  import { log } from "../util/logger.js";
6
- import { LineSplitter, claudeRenderer, codexRenderer, daemonBanner, failureBanner, sessionSummary, statusLine, taskHeader, taskOutcome, } from "./work-render.js";
6
+ import { loadConfig } from "../config/loader.js";
7
+ import { InteractiveAgent, assertUsableTty, processTtyProbe, } from "./interactive-agent.js";
8
+ import { LineSplitter, claudeRenderer, codexRenderer, daemonBanner, failureBanner, postAgentSummary, sessionSummary, statusLine, taskHeader, taskOutcome, } from "./work-render.js";
7
9
  /**
8
10
  * Agents whose subprocess runner slice 3 will wire up. Validated here so an
9
11
  * unknown/missing `--agent` fails loud rather than silently defaulting (project
@@ -19,7 +21,7 @@ const MAX_INTERVAL_SECONDS = 10;
19
21
  const DEFAULT_INTERVAL_SECONDS = 5;
20
22
  const HEARTBEAT_INTERVAL_MS = 30_000;
21
23
  const USAGE = `Usage:
22
- zenorm work --agent <${SUPPORTED_AGENTS.join("|")}> [--interval <seconds>] [--once] [--dry-run]
24
+ zenorm work --agent <${SUPPORTED_AGENTS.join("|")}> [--model <model>] [--interval <seconds>] [--once] [--dry-run] [--interactive]
23
25
 
24
26
  Runs a daemon that claims and processes ready tasks for the current git
25
27
  repository (resolved from \`git remote get-url origin\`), oldest-first, one at a
@@ -28,10 +30,23 @@ bundled ZeNorm \`execute-task\` skill and posts the task outcome itself.
28
30
 
29
31
  Options:
30
32
  --agent <agent> Agent to run each task with. Required. One of: ${SUPPORTED_AGENTS.join(", ")}
33
+ --model <model> Model the agent runs each task with (alias e.g. opus,
34
+ sonnet, or a full model id). Optional; when omitted the
35
+ agent uses its own configured default. For claude this
36
+ also retargets the server-side advisor tool to the same
37
+ model, so a gated \`advisorModel\` no longer 400s the run.
31
38
  --interval <seconds> Poll interval when idle. Default ${DEFAULT_INTERVAL_SECONDS}, max ${MAX_INTERVAL_SECONDS}.
32
39
  --once Process at most one claim (or one idle poll) then exit.
33
40
  --dry-run Print the agent command + handoff prompt each task would
34
- run instead of spawning the agent.`;
41
+ run instead of spawning the agent.
42
+ --interactive Opt into coding-agent mode: instead of running each task
43
+ headlessly, launch the active coding agent (the one named
44
+ by --agent) as a LIVE interactive session seeded with the
45
+ spec handoff. Work with the agent, then exit it to release
46
+ the worker and continue to the next task. Local terminal
47
+ only — requires a usable TTY and fails fast without one.
48
+ Can also be enabled per project via \`interactive: true\`
49
+ in .zenorm.json.`;
35
50
  /**
36
51
  * Headless install hints, surfaced when an agent binary is missing so the
37
52
  * failure is actionable rather than a bare ENOENT.
@@ -60,6 +75,26 @@ const AGENT_INSTALL_HINT = {
60
75
  */
61
76
  export function buildHandoffPrompt(task) {
62
77
  const zenormCli = process.env["ZENORM_CLI"]?.trim() || "zenorm";
78
+ return [
79
+ ...handoffPromptBody(task, zenormCli),
80
+ // Headless runs have no operator to wait on — tell the agent to drive
81
+ // straight through without pausing for input. The interactive variant
82
+ // (buildInteractiveHandoffPrompt) deliberately omits this line.
83
+ "Work non-interactively; do not wait for further input.",
84
+ ].join("\n");
85
+ }
86
+ /**
87
+ * The interactive (coding-agent mode) handoff prompt. Same spec-mode handoff as
88
+ * the headless prompt, MINUS the "work non-interactively" instruction: in this
89
+ * mode the agent runs as a live session the operator drives, so it SHOULD pause
90
+ * for input. Exported for unit testing.
91
+ */
92
+ export function buildInteractiveHandoffPrompt(task) {
93
+ const zenormCli = process.env["ZENORM_CLI"]?.trim() || "zenorm";
94
+ return handoffPromptBody(task, zenormCli).join("\n");
95
+ }
96
+ /** Shared spec-mode handoff lines for both the headless and interactive prompts. */
97
+ function handoffPromptBody(task, zenormCli) {
63
98
  return [
64
99
  `/zenorm ${task.specId}`,
65
100
  "",
@@ -74,9 +109,8 @@ export function buildHandoffPrompt(task) {
74
109
  "ready tasks and implement it alongside the rest. Implement everything",
75
110
  "end-to-end in the current repository working directory using the existing",
76
111
  "ZeNorm execute workflow, completing each task via the normal",
77
- `\`${zenormCli} task complete <task-id>\` flow as you finish it. Work`,
78
- "non-interactively; do not wait for further input.",
79
- ].join("\n");
112
+ `\`${zenormCli} task complete <task-id>\` flow as you finish it.`,
113
+ ];
80
114
  }
81
115
  /**
82
116
  * The headless subprocess invocation for a given agent + task. Pure (no
@@ -90,7 +124,7 @@ export function buildHandoffPrompt(task) {
90
124
  *
91
125
  * Exported for unit testing.
92
126
  */
93
- export function buildAgentCommand(agent, task) {
127
+ export function buildAgentCommand(agent, task, model) {
94
128
  const prompt = buildHandoffPrompt(task);
95
129
  switch (agent) {
96
130
  case "claude":
@@ -116,11 +150,29 @@ export function buildAgentCommand(agent, task) {
116
150
  "--verbose",
117
151
  "--permission-mode",
118
152
  "auto",
153
+ // --model sets the main session model; it does NOT retarget the
154
+ // server-side advisor tool, which carries its OWN model from the
155
+ // `advisorModel` setting. If that setting points at a model the
156
+ // account can't access (e.g. a gated `claude-fable-5`), EVERY turn
157
+ // 400s with `tools.N.model: ... is not available`. So when the user
158
+ // pins a model we also override `advisorModel` for THIS session via
159
+ // --settings, keeping the advisor on a model we know is reachable.
160
+ ...(model
161
+ ? [
162
+ "--model",
163
+ model,
164
+ "--settings",
165
+ JSON.stringify({ advisorModel: model }),
166
+ ]
167
+ : []),
119
168
  prompt,
120
169
  ],
121
170
  };
122
171
  case "codex":
123
- return { command: "codex", args: ["exec", "--json", prompt] };
172
+ return {
173
+ command: "codex",
174
+ args: ["exec", "--json", ...(model ? ["-m", model] : []), prompt],
175
+ };
124
176
  default: {
125
177
  // Exhaustiveness guard — fail loud if SUPPORTED_AGENTS grows without a
126
178
  // matching headless invocation here (no error-masking default).
@@ -129,11 +181,112 @@ export function buildAgentCommand(agent, task) {
129
181
  }
130
182
  }
131
183
  }
184
+ /**
185
+ * The INTERACTIVE invocation for a given agent + task: launch the agent as a
186
+ * live session (NOT headless) seeded with the spec handoff, so the operator
187
+ * works with it directly in its own TUI. Pure (no side effects) so it can be
188
+ * unit-tested without spawning anything.
189
+ *
190
+ * Unlike `buildAgentCommand`, there is no `-p`/`exec`/`--json` and no
191
+ * stream-json: both agents start an interactive session by default, and the
192
+ * agent's own TUI renders straight to the inherited terminal (so there is no
193
+ * raw JSON event stream for us to re-render, and nothing to dump as raw text).
194
+ * - claude: `claude --permission-mode auto [--model <m>] "<prompt>"`
195
+ * (interactive by default; `-p` would make it headless, so it is deliberately
196
+ * absent). `--permission-mode auto` runs the background classifier that
197
+ * auto-approves safe actions and only blocks destructive ones, so the
198
+ * operator is not interrupted by a permission prompt on every gated tool call
199
+ * — same guardrail the headless path uses, just keeping the session live.
200
+ * - codex: `codex --ask-for-approval never --sandbox workspace-write [-m <m>]
201
+ * "<prompt>"` (interactive by default; the `exec` subcommand is what makes it
202
+ * non-interactive, so it is deliberately absent). `--ask-for-approval never`
203
+ * is codex's full-auto: the operator is never interrupted by an approval
204
+ * prompt, while `--sandbox workspace-write` still confines writes to the
205
+ * workspace (NOT `--dangerously-bypass-approvals-and-sandbox`, which would
206
+ * drop the sandbox entirely).
207
+ *
208
+ * Exported for unit testing.
209
+ */
210
+ export function buildInteractiveAgentCommand(agent, task, model) {
211
+ const prompt = buildInteractiveHandoffPrompt(task);
212
+ switch (agent) {
213
+ case "claude":
214
+ return {
215
+ command: "claude",
216
+ args: ["--permission-mode", "auto", ...(model ? ["--model", model] : []), prompt],
217
+ };
218
+ case "codex":
219
+ return {
220
+ command: "codex",
221
+ args: [
222
+ "--ask-for-approval",
223
+ "never",
224
+ "--sandbox",
225
+ "workspace-write",
226
+ ...(model ? ["-m", model] : []),
227
+ prompt,
228
+ ],
229
+ };
230
+ default: {
231
+ // Exhaustiveness guard — fail loud if SUPPORTED_AGENTS grows without a
232
+ // matching interactive invocation here (no error-masking default).
233
+ const never = agent;
234
+ throw new CliError(`No interactive invocation defined for agent "${String(never)}".`);
235
+ }
236
+ }
237
+ }
132
238
  /** The transcript renderer for each agent's JSON event stream. */
133
239
  const AGENT_RENDERER = {
134
240
  claude: claudeRenderer,
135
241
  codex: codexRenderer,
136
242
  };
243
+ /** The real working-tree snapshot: `git status --porcelain` in the current repo. */
244
+ function realGitStatus() {
245
+ return execFileSync("git", ["status", "--porcelain"], {
246
+ cwd: process.cwd(),
247
+ encoding: "utf-8",
248
+ });
249
+ }
250
+ /**
251
+ * Parse one `git status --porcelain` line into its path. The format is a 2-char
252
+ * status code + a space, then the path; a rename/copy renders as `old -> new`,
253
+ * for which we take the NEW path (`new`). Returns null for a blank line.
254
+ */
255
+ function porcelainPath(line) {
256
+ if (line.trim().length === 0)
257
+ return null;
258
+ // Drop the 3-char status prefix ("XY ").
259
+ const rest = line.slice(3);
260
+ const arrow = rest.indexOf(" -> ");
261
+ return arrow >= 0 ? rest.slice(arrow + 4) : rest;
262
+ }
263
+ /**
264
+ * Diff two `git status --porcelain` snapshots into the set of files the agent
265
+ * touched during the session. We compare the porcelain LINE per path across
266
+ * before/after: a path is "touched" when its line differs (added, removed, or
267
+ * its status code changed). This deliberately IGNORES pre-existing dirt whose
268
+ * status did not change during the session (so unrelated stray files already in
269
+ * the tree don't pollute the summary). Returns a sorted, deduped path list.
270
+ */
271
+ export function diffTouchedFiles(before, after) {
272
+ const index = (porcelain) => {
273
+ const map = new Map();
274
+ for (const line of porcelain.split("\n")) {
275
+ const path = porcelainPath(line);
276
+ if (path !== null)
277
+ map.set(path, line);
278
+ }
279
+ return map;
280
+ };
281
+ const beforeLines = index(before);
282
+ const afterLines = index(after);
283
+ const touched = new Set();
284
+ for (const path of new Set([...beforeLines.keys(), ...afterLines.keys()])) {
285
+ if (beforeLines.get(path) !== afterLines.get(path))
286
+ touched.add(path);
287
+ }
288
+ return [...touched].sort();
289
+ }
137
290
  /**
138
291
  * The real runner: spawns the chosen coding agent headlessly against the
139
292
  * claimed task and awaits its exit.
@@ -147,16 +300,50 @@ const AGENT_RENDERER = {
147
300
  export class AgentRunner {
148
301
  agent;
149
302
  spawnFn;
303
+ model;
304
+ interactive;
305
+ gitStatus;
306
+ fetchTaskStatus;
150
307
  agentName;
151
308
  constructor(agent,
152
309
  // Injectable for tests; defaults to node:child_process spawn.
153
- spawnFn = spawn) {
310
+ spawnFn = spawn,
311
+ // Optional model pin, threaded into the agent invocation.
312
+ model,
313
+ // When true, coding-agent (interactive) mode is on: instead of running the
314
+ // agent headlessly and re-rendering its JSON event stream, the agent is
315
+ // launched as a LIVE session that owns the terminal (inherited stdio). The
316
+ // operator works with it until they close it; its exit releases the worker.
317
+ // The caller is responsible for having already passed the fail-fast TTY
318
+ // check (assertUsableTty).
319
+ interactive = false,
320
+ // Working-tree snapshot for the interactive post-agent summary. Injectable
321
+ // for tests; defaults to a real `git status --porcelain`. Only the
322
+ // interactive path reads it (before open() + after a clean exit).
323
+ gitStatus = realGitStatus,
324
+ // Reads the claimed task's current server-side status for the summary.
325
+ // Optional: when absent (e.g. the existing interactive unit tests that
326
+ // construct the runner with no client) the summary falls back to status
327
+ // `unknown` without warning — only a real fetch FAILURE warns. Wired to the
328
+ // real client in `workCommand`.
329
+ fetchTaskStatus) {
154
330
  this.agent = agent;
155
331
  this.spawnFn = spawnFn;
332
+ this.model = model;
333
+ this.interactive = interactive;
334
+ this.gitStatus = gitStatus;
335
+ this.fetchTaskStatus = fetchTaskStatus;
156
336
  this.agentName = agent;
157
337
  }
158
338
  run(task) {
159
- const { command, args } = buildAgentCommand(this.agent, task);
339
+ return this.interactive ? this.runInteractive(task) : this.runHeadless(task);
340
+ }
341
+ /**
342
+ * Default (non-interactive) path — UNCHANGED behavior: spawn the agent
343
+ * headlessly, pipe + re-render its JSON event stream, await its exit.
344
+ */
345
+ runHeadless(task) {
346
+ const { command, args } = buildAgentCommand(this.agent, task, this.model);
160
347
  const renderer = AGENT_RENDERER[this.agent];
161
348
  return new Promise((resolve, reject) => {
162
349
  // Pipe the agent's stdout so we can re-render its JSON event stream into a
@@ -167,16 +354,13 @@ export class AgentRunner {
167
354
  cwd: process.cwd(),
168
355
  stdio: ["ignore", "pipe", "inherit"],
169
356
  });
170
- // Render stdout line-by-line. Transcript lines go to the user's stdout so
171
- // the work session reads like the agent's own interactive session.
357
+ // Render stdout line-by-line to the user's stdout so the work session
358
+ // reads like the agent's own interactive session.
172
359
  const splitter = new LineSplitter();
173
- const emit = (line) => {
174
- process.stdout.write(`${line}\n`);
175
- };
176
360
  const renderLines = (lines) => {
177
361
  for (const raw of lines) {
178
362
  for (const out of renderer(raw))
179
- emit(out);
363
+ process.stdout.write(`${out}\n`);
180
364
  }
181
365
  };
182
366
  child.stdout?.setEncoding("utf-8");
@@ -186,30 +370,125 @@ export class AgentRunner {
186
370
  child.stdout?.on("end", () => {
187
371
  renderLines(splitter.flush());
188
372
  });
189
- let settled = false;
190
- child.on("error", (err) => {
191
- if (settled)
192
- return;
193
- settled = true;
194
- if (err.code === "ENOENT") {
195
- reject(new CliError(`Could not launch agent "${this.agent}": \`${command}\` was not found on PATH.\n\n` +
196
- AGENT_INSTALL_HINT[this.agent]));
197
- return;
373
+ this.wireChild(child, command, task, resolve, reject);
374
+ });
375
+ }
376
+ /**
377
+ * Coding-agent (interactive) path. Launch the agent as a LIVE session via
378
+ * `InteractiveAgent`: inherited stdio so the agent's own TUI owns the terminal
379
+ * (no piping, no re-render and therefore no raw text dumping), seeded with
380
+ * the spec handoff. The operator works with it; when they close the agent it
381
+ * exits, which resolves this run and RELEASES THE WORKER so the loop claims
382
+ * the next task. A clean operator exit (code 0) is success; a crash/non-zero
383
+ * exit is still a loud failure.
384
+ */
385
+ runInteractive(task) {
386
+ const { command, args } = buildInteractiveAgentCommand(this.agent, task, this.model);
387
+ // A fresh session per task (the daemon reuses one runner across tasks, so
388
+ // the single-operator InteractiveAgent instance must not be shared).
389
+ const session = new InteractiveAgent(command, args, this.spawnFn);
390
+ // Snapshot the working tree BEFORE the agent runs so we can diff it against
391
+ // the post-session state and report which files the agent touched.
392
+ const before = this.gitStatus();
393
+ return new Promise((resolve, reject) => {
394
+ let child;
395
+ try {
396
+ // open() enforces the single-operator invariant and local-only spawn
397
+ // options, then spawns the agent. A throw here (e.g. local-option guard)
398
+ // means nothing was spawned, so there is no child to orphan.
399
+ child = session.open();
400
+ }
401
+ catch (err) {
402
+ reject(err instanceof Error ? err : new CliError(String(err)));
403
+ return;
404
+ }
405
+ // On a CLEAN exit, print the one-line post-agent summary BEFORE releasing
406
+ // the worker (the real `resolve` fires only inside summarizeThenRelease).
407
+ // `wireChild`'s exit handler stays synchronous (shared with the headless
408
+ // path); we hand it a wrapper that defers the resolve until the summary is
409
+ // written. A non-zero exit still rejects through `wireChild` unchanged.
410
+ const releaseAfterSummary = () => {
411
+ void this.summarizeThenRelease(task, before, resolve);
412
+ };
413
+ this.wireChild(child, command, task, releaseAfterSummary, reject);
414
+ });
415
+ }
416
+ /**
417
+ * Print the ONE-LINE post-agent change summary, then RESOLVE (releasing the
418
+ * worker) — in that order, per the spec constraint "Summary before worker
419
+ * release". Computes the touched-file set from the before/after porcelain
420
+ * snapshots and reads the claimed task's current server-side status.
421
+ *
422
+ * Robustness: this runs after a CLEAN agent exit, so it must NEVER turn that
423
+ * success into a failure. The whole body is wrapped so `resolve()` always
424
+ * fires even if the summary work throws. The status read is the one sanctioned
425
+ * soft-fallback — a fetch failure warns and reports `unknown` rather than
426
+ * blocking the release; an absent status fetcher (no client wired, e.g. unit
427
+ * tests) is silently `unknown`.
428
+ */
429
+ async summarizeThenRelease(task, before, resolve) {
430
+ try {
431
+ const after = this.gitStatus();
432
+ const filesTouched = diffTouchedFiles(before, after);
433
+ let status = "unknown";
434
+ if (this.fetchTaskStatus) {
435
+ try {
436
+ status = await this.fetchTaskStatus(task);
198
437
  }
199
- reject(new CliError(`Failed to launch agent "${this.agent}" (\`${command}\`): ${err.message}`));
200
- });
201
- child.on("exit", (code, signal) => {
202
- if (settled)
203
- return;
204
- settled = true;
205
- if (code === 0) {
206
- resolve();
207
- return;
438
+ catch (err) {
439
+ // The status read must not block worker release — warn and fall back
440
+ // to `unknown` (the one allowed soft-fallback) instead of throwing.
441
+ log.warn(`Could not read task ${shortId(task.id)} status for summary`, {
442
+ error: err instanceof Error ? err.message : String(err),
443
+ });
208
444
  }
209
- const detail = code !== null ? `exited with code ${code}` : `was terminated by signal ${signal}`;
210
- // Fail loud — a non-zero/aborted agent run is a FAILURE, never success.
211
- reject(new CliError(`Agent "${this.agent}" (\`${command}\`) ${detail} while running task ${shortId(task.id)}.`));
445
+ }
446
+ process.stdout.write(`${postAgentSummary({
447
+ filesTouched,
448
+ status,
449
+ shortTaskId: shortId(task.id),
450
+ })}\n`);
451
+ }
452
+ catch (err) {
453
+ // A summary failure must never convert a clean agent exit into a failure;
454
+ // warn and still release the worker below.
455
+ log.warn(`Could not print post-agent summary for task ${shortId(task.id)}`, {
456
+ error: err instanceof Error ? err.message : String(err),
212
457
  });
458
+ }
459
+ finally {
460
+ resolve();
461
+ }
462
+ }
463
+ /**
464
+ * Shared exit/error handling for both paths: ENOENT → install hint, non-zero
465
+ * exit → loud failure, code 0 → success (releases the worker). `settled`
466
+ * guards against a double-settle if error and exit both fire.
467
+ */
468
+ wireChild(child, command, task, resolve, reject) {
469
+ let settled = false;
470
+ child.on("error", (err) => {
471
+ if (settled)
472
+ return;
473
+ settled = true;
474
+ if (err.code === "ENOENT") {
475
+ reject(new CliError(`Could not launch agent "${this.agent}": \`${command}\` was not found on PATH.\n\n` +
476
+ AGENT_INSTALL_HINT[this.agent]));
477
+ return;
478
+ }
479
+ reject(new CliError(`Failed to launch agent "${this.agent}" (\`${command}\`): ${err.message}`));
480
+ });
481
+ child.on("exit", (code, signal) => {
482
+ if (settled)
483
+ return;
484
+ settled = true;
485
+ if (code === 0) {
486
+ resolve();
487
+ return;
488
+ }
489
+ const detail = code !== null ? `exited with code ${code}` : `was terminated by signal ${signal}`;
490
+ // Fail loud — a non-zero/aborted agent run is a FAILURE, never success.
491
+ reject(new CliError(`Agent "${this.agent}" (\`${command}\`) ${detail} while running task ${shortId(task.id)}.`));
213
492
  });
214
493
  }
215
494
  }
@@ -220,13 +499,15 @@ export class AgentRunner {
220
499
  */
221
500
  export class DryRunRunner {
222
501
  agent;
502
+ model;
223
503
  agentName;
224
- constructor(agent) {
504
+ constructor(agent, model) {
225
505
  this.agent = agent;
506
+ this.model = model;
226
507
  this.agentName = agent;
227
508
  }
228
509
  run(task) {
229
- const { command, args } = buildAgentCommand(this.agent, task);
510
+ const { command, args } = buildAgentCommand(this.agent, task, this.model);
230
511
  log.info(`[dry-run] would spawn agent "${this.agent}" for task ${shortId(task.id)}`);
231
512
  log.plain(` cwd: ${process.cwd()}`);
232
513
  log.plain(` command: ${command} ${formatDryRunArgs(args)}`);
@@ -318,9 +599,11 @@ function parseWorkArgs(argv) {
318
599
  args: argv,
319
600
  options: {
320
601
  agent: { type: "string" },
602
+ model: { type: "string" },
321
603
  interval: { type: "string" },
322
604
  once: { type: "boolean" },
323
605
  "dry-run": { type: "boolean" },
606
+ interactive: { type: "boolean" },
324
607
  },
325
608
  strict: true,
326
609
  }));
@@ -336,6 +619,17 @@ function parseWorkArgs(argv) {
336
619
  if (!SUPPORTED_AGENTS.includes(agent)) {
337
620
  throw new CliError(`Unsupported agent "${agent}".\n\nSupported agents: ${SUPPORTED_AGENTS.join(", ")}`);
338
621
  }
622
+ // --model is optional: when omitted the agent uses its own configured
623
+ // default. But an explicit empty `--model ""` is a mistake we fail loud on
624
+ // rather than silently passing a blank model through to the agent.
625
+ const rawModel = values["model"];
626
+ let model;
627
+ if (rawModel !== undefined) {
628
+ if (rawModel.trim() === "") {
629
+ throw new CliError(`Invalid --model "${rawModel}". Provide a model alias (e.g. opus, sonnet) or full model id.`);
630
+ }
631
+ model = rawModel;
632
+ }
339
633
  let intervalSeconds = DEFAULT_INTERVAL_SECONDS;
340
634
  const rawInterval = values["interval"];
341
635
  if (rawInterval !== undefined) {
@@ -353,11 +647,25 @@ function parseWorkArgs(argv) {
353
647
  // Validated above against SUPPORTED_AGENTS; the readonly-tuple `includes`
354
648
  // check above does not narrow `string`, so assert the proven type here.
355
649
  agent: agent,
650
+ model,
356
651
  intervalMs: intervalSeconds * 1000,
357
652
  once: values["once"] === true,
358
653
  dryRun: values["dry-run"] === true,
654
+ interactive: values["interactive"] === true,
359
655
  };
360
656
  }
657
+ /**
658
+ * Resolve whether interactive mode is on. Opt-in is explicit by design (the
659
+ * spec keeps existing runs non-interactive unless enabled): the `--interactive`
660
+ * flag, or `interactive: true` in `.zenorm.json`. The flag wins; config only
661
+ * supplies the default when the flag is absent. A bad config value already
662
+ * threw in `validateConfig`, so this never silently coerces.
663
+ */
664
+ export function resolveInteractive(flagPassed, config) {
665
+ if (flagPassed)
666
+ return true;
667
+ return config?.interactive === true;
668
+ }
361
669
  /** Write daemon-only meta output (e.g. the dry-run banner) to stderr. */
362
670
  function writeDaemonLine(line) {
363
671
  process.stderr.write(`${line}\n`);
@@ -372,6 +680,22 @@ async function releaseTaskToTodo(client, task) {
372
680
  });
373
681
  }
374
682
  }
683
+ /**
684
+ * Read the claimed task's CURRENT server-side status for the interactive
685
+ * post-agent summary. `GET /v1/specs/:specId/tasks` returns the spec's tasks;
686
+ * we find this one by id and return its status. Throws if the task is missing
687
+ * from the response (fail loud rather than masking a wrong/empty list) — the
688
+ * caller (`summarizeThenRelease`) catches and degrades to `unknown` so a status
689
+ * read never blocks worker release.
690
+ */
691
+ async function fetchTaskStatus(client, task) {
692
+ const { tasks } = await client.get(`/v1/specs/${task.specId}/tasks`);
693
+ const found = tasks.find((t) => t.id === task.id);
694
+ if (!found) {
695
+ throw new CliError(`Task ${shortId(task.id)} not found in spec ${task.specId} tasks list.`);
696
+ }
697
+ return found.status;
698
+ }
375
699
  async function heartbeatTask(client, task) {
376
700
  try {
377
701
  await client.post(`/v1/tasks/${task.id}/heartbeat`, {});
@@ -517,16 +841,42 @@ export async function workCommand(argv, deps) {
517
841
  // Fatal (CliError) failures — bad args, not a git repo — abort BEFORE the
518
842
  // loop starts so they surface immediately rather than being swallowed as a
519
843
  // transient blip.
520
- const { agent, intervalMs, once, dryRun } = parseWorkArgs(argv);
844
+ const { agent, model, intervalMs, once, dryRun, interactive: interactiveFlag } = parseWorkArgs(argv);
521
845
  const repo = resolveCurrentRepo();
846
+ // Resolve the interactive opt-in (flag OR project config). `loadConfig` walks
847
+ // up from cwd for `.zenorm.json`; a malformed config throws (no masked errors).
848
+ const interactive = resolveInteractive(interactiveFlag, loadConfig());
849
+ if (interactive && dryRun) {
850
+ // --dry-run never spawns an agent, so there is no live agent session to drop
851
+ // into. Refuse the combination loudly rather than silently ignoring one.
852
+ throw new CliError("`--interactive` cannot be combined with `--dry-run`: dry-run never " +
853
+ "launches an agent, so there is no interactive coding-agent session to open.");
854
+ }
855
+ // Fail-fast gate (spec: "Fail-fast without usable TTY"). Done BEFORE the loop
856
+ // and BEFORE any claim so a misconfigured interactive run errors immediately
857
+ // instead of claiming a task and then hanging. Non-interactive runs skip this
858
+ // entirely — they never require a TTY and never prompt.
859
+ if (interactive) {
860
+ assertUsableTty(deps?.ttyProbe ?? processTtyProbe());
861
+ }
522
862
  const client = deps?.client ?? new ZenormClient();
523
- const runner = deps?.runner ?? (dryRun ? new DryRunRunner(agent) : new AgentRunner(agent));
863
+ const runner = deps?.runner ??
864
+ (dryRun
865
+ ? new DryRunRunner(agent, model)
866
+ : new AgentRunner(agent, spawn, model, interactive,
867
+ // Real working-tree snapshot for the interactive post-agent summary.
868
+ realGitStatus,
869
+ // Real status read: list the spec's tasks and pick out this task's
870
+ // current status (it is likely `done` once the agent inside the
871
+ // session ran `zenorm task complete`).
872
+ (task) => fetchTaskStatus(client, task)));
524
873
  // Startup banner — shown on EVERY run (not just --dry-run) so a real session
525
874
  // announces what it's claiming for instead of sitting silent until the first
526
875
  // task lands. Written to stderr so it never interleaves with the agent
527
876
  // transcript when stdout is piped to a file.
528
877
  writeDaemonLine(daemonBanner({
529
878
  agent,
879
+ model,
530
880
  repo: `${repo.owner}/${repo.name}`,
531
881
  intervalSeconds: intervalMs / 1000,
532
882
  once,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A named CLI environment: the full bundle of endpoints the CLI talks to.
3
+ * Selecting one via the `ZENORM_CLI` env var switches API + Clerk together, so
4
+ * you never have to keep `ZENORM_API_URL`, `ZENORM_CLERK_ISSUER`, and
5
+ * `ZENORM_CLERK_CLIENT_ID` in sync by hand.
6
+ *
7
+ * IMPORTANT: only the **prod** preset is baked into this (published) module.
8
+ * The non-prod stacks point at an internal Clerk instance whose identifiers
9
+ * must NOT ship in the public npm tarball (the publish pipeline greps the
10
+ * package for them and blocks the release). Those stacks are launched by the
11
+ * non-shipped source entries (`local.ts` / `dev.ts`, run via their repo shims),
12
+ * which inject `ZENORM_API_URL` + `ZENORM_CLERK_*` before importing the CLI.
13
+ * When `ZENORM_CLI` selects a non-prod stack, this resolver reads those
14
+ * injected vars instead of baking the endpoints in.
15
+ */
16
+ export interface CliEnvironment {
17
+ readonly name: "prod" | "dev" | "local";
18
+ readonly apiUrl: string;
19
+ readonly clerkIssuer: string;
20
+ readonly clerkClientId: string;
21
+ }
22
+ export declare const ZENORM_CLI_ENV_VAR = "ZENORM_CLI";
23
+ /**
24
+ * Resolve the active CLI environment from `ZENORM_CLI`.
25
+ *
26
+ * Unset → production (the public default). A set-but-unrecognized value is a
27
+ * loud error rather than a silent fallback: a typo like `ZENORM_CLI=prd` must
28
+ * not quietly send `zenorm` commands at the wrong stack.
29
+ */
30
+ export declare function resolveEnvironment(): CliEnvironment;
31
+ //# sourceMappingURL=environments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"environments.d.ts","sourceRoot":"","sources":["../../src/config/environments.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,CAAC;IACxC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAChC;AASD,eAAO,MAAM,kBAAkB,eAAe,CAAC;AAyC/C;;;;;;GAMG;AACH,wBAAgB,kBAAkB,IAAI,cAAc,CAiBnD"}