@0xgeorgemathew/zcode 0.5.1 → 0.6.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.
package/README.md CHANGED
@@ -51,7 +51,7 @@ The Claude Code skill goes to `~/.claude/skills/zcode` and the Codex skill to `$
51
51
 
52
52
  ## Models and effort
53
53
 
54
- Every command runs on GLM-5.3-Flash at effort `max` unless you pass a flag.
54
+ Every command runs on GLM-5.3-Flash (it sees images) at effort `max` unless you pass a flag. `--model glm-5.3` is an explicit override: GLM-5.3 cannot see images (`zcode doctor` shows `images` / `no images` per model). Plan sessions, units, resume, `run` and `agent` all default to Flash too, and the visual verifier always uses an image-capable model. Subagent profiles copied from your desktop that pin a GLM-5.3 id are rewritten in the session's copy to the session's model.
55
55
 
56
56
  | setting | flag | values | default |
57
57
  |---|---|---|---|
@@ -116,13 +116,12 @@ Flags cover the common fields. For richer handoffs, pass `--task contract.json`,
116
116
  "references": ["design/team.png described in design/team.md"],
117
117
  "context": ["Copy lives in content/team.json"],
118
118
  "owns": ["src/pages/team/**", "content/team.json"],
119
- "limits": { "timeoutMs": 1800000, "maxRepairs": 2, "checkTimeoutMs": 300000 },
120
- "model": "glm-5.3",
119
+ "limits": { "timeoutMs": 7200000, "maxRepairs": 2, "checkTimeoutMs": 300000 },
121
120
  "effort": "high"
122
121
  }
123
122
  ```
124
123
 
125
- `decisions` (flags `--decision "…"`, repeatable, and `--decisions @file`; `design` is an alias) are presented to the model as binding decisions it must implement, not revisit. `@file` entries inside contract `decisions`/`context` lists are inlined (relative to the contract file). If something user-visible or behavioural is left open, the executor does not guess: when it blocks the work it reports `blocked` with a structured question `{"q", "options": [...], "recommend"}` (plain strings still work); when it does not block, it takes the most conservative choice and lists it in the handback `decisions: [{what, why}]`, which the brief carries so the orchestrator can overrule it.
124
+ `decisions` (flags `--decision "…"`, repeatable, and `--decisions @file`; `design` is an alias) are presented to the model as binding decisions it must implement, not revisit. `@file` entries inside contract `decisions`/`context` lists are inlined (relative to the contract file, then to `--cwd`; the error names both paths). If something user-visible or behavioural is left open, the executor does not guess: when it blocks the work it reports `blocked` with a structured question `{"q", "options": [...], "recommend"}` (plain strings still work); when it does not block, it takes the most conservative choice and lists it in the handback `decisions: [{what, why}]`, which the brief carries so the orchestrator can overrule it.
126
125
 
127
126
  A file passed with `--file` and also inlined with `--context @file` reaches the executor once (as context); identical context and acceptance entries are deduped.
128
127
 
@@ -130,7 +129,7 @@ A file passed with `--file` and also inlined with `--context @file` reaches the
130
129
 
131
130
  **Units.** `--unit "name: objective [owns a,b]"` (repeatable; contract `units: [{name, objective, owns?}]`) makes the executor dispatch each unit to its own subagent inside this one session, in parallel where independent, then integrate and verify the whole. Unit `owns` must lie within the session's `--owns` and be disjoint from each other (exit 2 otherwise). The handback `units: [{name, status, note}]` is listed under `notVerified` as executor claims; zcode's checks still verify the integrated result.
132
131
 
133
- **Visual verification.** `--visual "criterion"` (repeatable; contract `visual`), `--viewport WxH` (repeatable, default `390x844` and `1440x900`; contract `viewports`) and `--serve "cmd"` (contract `serve`; must honour `$ZCODE_PORT`, default: the executor reads `package.json`) have the executor serve the app on `$ZCODE_PORT`, capture Playwright screenshots into `$ZCODE_OUT/shots` at each viewport, look at them, fix and re-capture, and report `visual: [{criterion, viewport, met, note, shot}]`. zcode runs no pixel check: results are executor judgement (`notVerified`). Unmet criteria spend the repair budget like failing checks (one combined repair turn when both occur); still unmet afterwards, the result is `partial` with the issue `visual criteria unmet: …`. Without `--visual` nothing changes.
132
+ **Visual verification.** `--visual "criterion"` (repeatable; contract `visual`), `--viewport WxH` (repeatable, default `390x844` and `1440x900`; contract `viewports`) and `--serve "cmd"` (contract `serve`; must honour `$ZCODE_PORT`, default: the executor reads `package.json`) make zcode, not the implementer, judge the rendered result. After the implementer's turn (and once the checks pass) zcode opens a fresh engine session on the first image-capable model (GLM-5.3-Flash, effort `high`, same workspace and session environment) that did not build the result. It serves the app on `$ZCODE_PORT`, waits for fonts and animations, captures viewport and full-page screenshots into `$ZCODE_OUT/verify/<round>/` at each viewport, looks at them, makes no edits, and answers with a `zcode-visual` block: `[{criterion, viewport, met, seen, note, shot}]`, quoting the text it can read (`seen`). It also always judges one built-in criterion: all text the page means to show is readable after animations finish (not clipped, hidden, transparent, off-screen or overlapped). Those entries are the result's `visual`; the implementer's own claims are kept only as `executorVisual` in the full record. Unmet criteria spend the repair budget like failing checks (the repair turn goes to the implementer with the verifier's `seen`, note and shot paths; then checks and a new verifier round run). A verifier that fails or returns no parsable block is retried once; still nothing gives `met: null`, status `partial` and the issue `visual not verified`. Still unmet after the budget: `partial` with `visual criteria unmet: …`. An implementer on a model that cannot see images is told so and to self-check visibility in the DOM instead. Files the verifier changes outside `.zcode/` are reported as the issue `verifier edited files: …`. Verifier turns count in `usage`. Without `--visual` nothing changes.
134
133
 
135
134
  ### Status: what a result means
136
135
 
@@ -192,7 +191,7 @@ zcode cancel page-1 # stop the turn and any running check; recorded as
192
191
  ] }
193
192
  ```
194
193
 
195
- Optional top-level keys: `cwd` (relative to the plan file), `decisions`, `context`, `files` (prepended to every session; `@file` entries are read relative to the plan file), `model`, `effort`. Each session has an `id` (its zcode session id), optional `after` (ids) and any task contract keys.
194
+ Optional top-level keys: `cwd` (relative to the plan file), `decisions`, `context`, `files` (prepended to every session; `@file` entries are read relative to the plan file, then against the plan's cwd), `model`, `effort`, `limits` (`timeoutMs`, `maxRepairs`, `checkTimeoutMs`: the default for every session; a session's own `limits` override per key). Each session has an `id` (its zcode session id), optional `after` (ids), optional `label` (the session label becomes `plan:<name> <label>`) and any task contract keys. In `plan run --preflight`, a session with `after` is told which upstream sessions have not run yet and to assume they deliver their objectives.
196
195
 
197
196
  ```sh
198
197
  zcode plan check plan.json # validate: exit 2, one line per problem
@@ -213,7 +212,7 @@ A session starts once everything it is `after` has completed; sessions that can
213
212
 
214
213
  ```sh
215
214
  zcode "What is 17*23? Reply with only the number."
216
- zcode --model glm-5.3 --effort high "Draft a commit message for the staged diff"
215
+ zcode --effort high "Draft a commit message for the staged diff"
217
216
  echo "$PROMPT" | zcode
218
217
  ```
219
218
 
@@ -237,7 +236,7 @@ The final line is the compact **brief** (`zcode.brief/1`), sized for an orchestr
237
236
  "result":"/…/sessions/page-1/result.json"}
238
237
  ```
239
238
 
240
- `run` and `agent` briefs also carry `response`, because the text answer is the product there. Passing checks are reduced to `{command, ok:true}`; a failing check carries `exitCode`, `timedOut` and the last 1500 characters of its output as `tail`. `result` is the path to the full record: `result.json` on disk is always the full `zcode.result/1` record, and `--full` prints that same record instead of the brief (on `task`/`run`/`agent`/`resume`/`wait`/`result`):
239
+ The brief lists unmet visual criteria only (`visualMet: "M/N"` counts them all), at most 6 `decisions` (`decisionsMore`) and 40 `files` (`filesMore`, concurrent sessions' files excluded). `run` and `agent` briefs also carry `response`, because the text answer is the product there. Only failing checks are listed (`checksPassed: N` counts the passing ones); a failing check carries `exitCode`, `timedOut` and the last 1500 characters of its output as `tail`. `result` is the path to the full record: `result.json` on disk is always the full `zcode.result/1` record, and `--full` prints that same record instead of the brief (on `task`/`run`/`agent`/`resume`/`wait`/`result`):
241
240
 
242
241
  ```json
243
242
  {"type":"result","schema":"zcode.result/1","ok":true,"status":"completed","id":"page-1","kind":"task",
@@ -263,7 +262,7 @@ The final line is the compact **brief** (`zcode.brief/1`), sized for an orchestr
263
262
  | `{"type":"mcp","name","status"}` · `{"type":"retry",…}` · `{"type":"model_request_started",…}` | MCP settling, start-up retry, engine model evidence |
264
263
  | `{"type":"stream","kind":"text_delta\|reasoning_delta","delta"}` | live text (run/agent; task with `--stream`) |
265
264
 
266
- `zcode status <id> --json` returns `{"type":"status", state, status, phase, pid, live, activity, recent:[…], result?}`. `zcode wait --json` prints one brief per session as each finishes (`--full`: full records; `--any`: stop at the first). When `--timeout-ms` elapses first it prints exactly one line per session still running, `{"type":"pending","id","state","phase","elapsedMs"}` (human mode: `<id> <state> <phase> 12m`), and exits 124. `zcode result <id> [--json] [--full]` prints a finished session's handback without waiting (exit 7 if it is still running). `zcode status` without an id is one line per session. Usage errors are always a single line (`zcode: unknown flag (did you mean …?)` for a mistyped option), never the help text.
265
+ `zcode status <id> --json` returns `{"type":"status", state, status, phase, pid, live, activity, recent:[…], result?}`. `zcode wait --json` prints one brief per session as each finishes (`--full`: full records; `--any`: stop at the first). When `--timeout-ms` elapses first it prints exactly one line per session still running, `{"type":"pending","id","state","phase","elapsedMs"}` (human mode: `<id> <state> <phase> 12m`), and exits 124. `zcode watch [<id>… | --plan <name>] [--stall-min 8]` is a live view for humans (no model tokens): one line per session, `id state phase[ n/m] elapsed last: <tool> <age> ago req <n> sub <running>`, with `STALLED?` appended when a running session has had no engine event for `--stall-min` minutes (default 8). On a TTY it redraws every 2 s; piped, it prints a line when a session changes; it exits 0 when every watched session has finished (no arguments: live sessions and those finished in the last 10 minutes). `status <id>` (human and `--json`) gains `idleMs` and `stalled: true` for running sessions by the same rule, and `wait` / `plan wait` pending lines carry `idleMs` (`stalled: true` when so). Nothing is done automatically. `zcode result <id> [--json] [--full]` prints a finished session's handback without waiting (exit 7 if it is still running). `zcode status` without an id is one line for each of the 15 most recent sessions plus `… N more (zcode status --all)`. `zcode <command> --help` prints only that command's usage (under 2000 bytes); bare `zcode --help` is the full reference. Usage errors are always a single line (`zcode: unknown flag (did you mean …?)` for a mistyped option), never the help text.
267
266
 
268
267
  &nbsp;
269
268
 
@@ -282,12 +281,12 @@ The final line is the compact **brief** (`zcode.brief/1`), sized for an orchestr
282
281
  | `--decision`, `--decisions` | task | | repeatable binding decisions; `--decisions @file` reads a file |
283
282
  | `--preflight` | task | off | read-only turn that returns the open questions; `resume <id> "<answers>"` then executes |
284
283
  | `--unit` | task | | repeatable `"name: objective [owns a,b]"`; one subagent per unit |
285
- | `--visual`, `--viewport`, `--serve` | task | viewports `390x844`, `1440x900` | executor screenshots the app with Playwright and judges each criterion |
284
+ | `--visual`, `--viewport`, `--serve` | task | viewports `390x844`, `1440x900` | an independent image-capable verifier screenshots the app and judges each criterion |
286
285
  | `--max-repairs` | task, resume | `2` | repair turns after failing checks |
287
286
  | `--check-timeout-ms` | task, resume | `300000` | per check; checks get `CI=1` when unset |
288
287
  | `--model` | all turns | `glm-5.3-flash` | `glm-5.3` plus aliases `5.3`, `53`, `flash`, `5.3-flash`, `53f` |
289
288
  | `--effort` | all turns | `max` | the model's levels: `low`, `high`, `max` |
290
- | `--timeout-ms` | all turns; wait | run 600000, task 1800000 | the whole invocation incl. checks and repairs |
289
+ | `--timeout-ms` | all turns; wait | run 600000, task 7200000 (2 h) | the whole invocation incl. checks and repairs |
291
290
  | `--session` | run | | 0.3 compat: continue by zcode id or engine `sess_` id |
292
291
  | `--profile` | agent | `general-purpose` | subagent profile |
293
292
  | `--mcp` / `--mcp-inherit` / `--mcp-list` | run, task, agent | hermetic, plus the desktop's `playwright` | MCP servers (inline JSON or `@file`) |
package/bin/zcode.js CHANGED
@@ -9,8 +9,10 @@ const USAGE = `usage:
9
9
  zcode [run] [options] <prompt…> one-shot turn (prompt via stdin when piped)
10
10
  zcode agent [options] <instructions…> turn that MUST dispatch a subagent
11
11
  zcode resume <id> [message…] continue a session (answer, follow-up, retry)
12
- zcode status [<id>] [--json] one session in detail, or all sessions
12
+ zcode status [<id>] [--all] [--json] one session in detail, or the 15 most recent (--all: every session)
13
13
  zcode wait <id…> [--any] [--json] block until sessions finish; print handbacks
14
+ zcode watch [<id…> | --plan <name>] [--stall-min n]
15
+ live one-line-per-session view (no model tokens); exits when all finish
14
16
  zcode result <id> [--full] [--json] print a finished session's handback (brief by default)
15
17
  zcode cancel <id> [--json] stop a running session
16
18
  zcode plan check <file> validate a plan file (ids, after, cycles, owns, effort)
@@ -55,16 +57,17 @@ task contract (task only):
55
57
  zcode resume <id> "<answers>" executes the task
56
58
  --unit "n: goal [owns a,b]" repeatable: one subagent per unit inside this
57
59
  session; owns must lie within --owns and be disjoint
58
- --visual <criterion> repeatable: the executor serves the app on $ZCODE_PORT,
59
- screenshots it with Playwright and judges each criterion
60
+ --visual <criterion> repeatable: after the work an independent image-capable
61
+ verifier (GLM-5.3-Flash, fresh session) serves the app on
62
+ $ZCODE_PORT, screenshots it and judges each criterion
60
63
  --viewport <WxH> repeatable viewport for --visual (default 390x844, 1440x900)
61
64
  --serve <cmd> command that serves the app for --visual (honours $ZCODE_PORT)
62
65
  --max-repairs <n> repair turns after failing checks (default 2)
63
66
  --check-timeout-ms <n> per-check timeout (default 300000)
64
67
 
65
68
  plan (zcode plan run <plan.json>):
66
- plan.json {cwd?, decisions?, context?, files?, model?, effort?,
67
- sessions:[{id, after?:[ids], ...task contract keys}]}; the
69
+ plan.json {cwd?, decisions?, context?, files?, model?, effort?, limits?,
70
+ sessions:[{id, after?:[ids], label?, ...task contract keys}]}; the
68
71
  top-level decisions/context/files are prepended to every
69
72
  session. A session starts when everything it is "after" has
70
73
  completed; sessions that can run together must own disjoint
@@ -82,7 +85,7 @@ execution:
82
85
  engine's levels per model); an unsupported level exits 2
83
86
  before any spawn. default: max
84
87
  --timeout-ms <n> whole invocation incl. checks/repairs (default 600000;
85
- task default 1800000)
88
+ task default 7200000 = 2 h)
86
89
  --profile <name> subagent profile (agent command; default general-purpose)
87
90
  --mcp <json|@f> add MCP servers (repeatable); hermetic by default
88
91
  --mcp-inherit also inherit the desktop's enabled MCP servers
@@ -143,7 +146,7 @@ function parseMcpSpec(value) {
143
146
  return parsed;
144
147
  }
145
148
 
146
- const COMMANDS = new Set(['run', 'task', 'agent', 'resume', 'status', 'list', 'wait', 'cancel', 'login', 'doctor', 'skills', 'result', 'plan', '__plan', '__exec']);
149
+ const COMMANDS = new Set(['run', 'task', 'agent', 'resume', 'status', 'list', 'wait', 'watch', 'cancel', 'login', 'doctor', 'skills', 'result', 'plan', '__plan', '__exec']);
147
150
  const TASK_ONLY = new Set(['task', 'accept', 'check', 'file', 'context', 'touch', 'maxRepairs', 'checkTimeoutMs', 'decisions', 'units', 'visual', 'viewports', 'serve', 'preflight']);
148
151
  const FULL_COMMANDS = new Set(['run', 'task', 'agent', 'resume', 'wait', 'result', '__exec']);
149
152
  const START_ONLY = new Set(['id', 'label', 'owns', 'touch', 'preflight', 'allowOverlap', 'mcpSpecs', 'mcpInherit', 'noBrowser', 'skillsDirs', 'noSkills', 'profile', 'cwd']);
@@ -192,15 +195,18 @@ function parseArgv(argv) {
192
195
  quiet: false,
193
196
  thinking: undefined,
194
197
  stream: false,
198
+ plan: undefined,
199
+ stallMin: undefined,
195
200
  full: false,
196
201
  any: false,
202
+ all: false,
197
203
  },
198
204
  };
199
205
  const valueFlags = new Map([
200
206
  ['--model', 'model'], ['--effort', 'effort'], ['--cwd', 'cwd'], ['--profile', 'profile'],
201
207
  ['--timeout-ms', 'timeoutMs'], ['-p', 'prompt'], ['--prompt', 'prompt'], ['--session', 'session'],
202
208
  ['--id', 'id'], ['--label', 'label'], ['--task', 'task'], ['--max-repairs', 'maxRepairs'],
203
- ['--check-timeout-ms', 'checkTimeoutMs'], ['--serve', 'serve'],
209
+ ['--check-timeout-ms', 'checkTimeoutMs'], ['--serve', 'serve'], ['--plan', 'plan'], ['--stall-min', 'stallMin'],
204
210
  ]);
205
211
  const listFlags = new Map([
206
212
  ['--mcp', 'mcpSpecs'], ['--skills', 'skillsDirs'], ['--accept', 'accept'], ['--check', 'check'],
@@ -214,7 +220,7 @@ function parseArgv(argv) {
214
220
  ['--include-reasoning', ['includeReasoning', true]], ['--quiet', ['quiet', true]],
215
221
  ['--detach', ['detach', true]], ['--allow-overlap', ['allowOverlap', true]],
216
222
  ['--thinking', ['thinking', true]], ['--no-thinking', ['thinking', false]], ['--stream', ['stream', true]],
217
- ['--full', ['full', true]], ['--any', ['any', true]], ['--preflight', ['preflight', true]],
223
+ ['--full', ['full', true]], ['--any', ['any', true]], ['--all', ['all', true]], ['--preflight', ['preflight', true]],
218
224
  ]);
219
225
  const numeric = (flag, value, { min = 1 } = {}) => {
220
226
  const n = Number(value);
@@ -230,6 +236,7 @@ function parseArgv(argv) {
230
236
  if (value === undefined) fail(2, `option ${arg} requires a value.`);
231
237
  out.given.add(key);
232
238
  if (key === 'timeoutMs' || key === 'checkTimeoutMs') out.flags[key] = numeric(arg, value);
239
+ else if (key === 'stallMin') out.flags[key] = numeric(arg, value, { min: 0 });
233
240
  else if (key === 'maxRepairs') out.flags[key] = numeric(arg, value, { min: 0 });
234
241
  else if (key === 'mcpSpecs') out.flags.mcpSpecs.push(parseMcpSpec(value));
235
242
  else if (key === 'skillsDirs') {
@@ -247,7 +254,11 @@ function parseArgv(argv) {
247
254
  continue;
248
255
  }
249
256
  if (arg === '--help' || arg === '-h') {
250
- process.stdout.write(USAGE + '\n');
257
+ // Scan the rest of argv for the command: `zcode --help task` works too.
258
+ let command = out.commandSet ? out.command : null;
259
+ if (!command) for (const a of argv) if (COMMANDS.has(a) && !a.startsWith('__')) { command = a === 'list' ? 'status' : a; break; }
260
+ const text = command && require('../lib/help.js').commandHelp(command);
261
+ process.stdout.write((text || USAGE) + '\n');
251
262
  process.exit(0);
252
263
  }
253
264
  if (arg === '--version') {
@@ -290,6 +301,10 @@ function parseArgv(argv) {
290
301
  if (out.given.has(key)) fail(2, `--${flagName(key)} cannot change on resume; it is fixed when the session starts.`);
291
302
  }
292
303
  }
304
+ if (out.given.has('all') && cmd !== 'status') fail(2, '--all is only valid with "zcode status".');
305
+ for (const k of ['plan', 'stallMin']) {
306
+ if (out.given.has(k) && cmd !== 'watch') fail(2, `--${flagName(k)} is only valid with "zcode watch".`);
307
+ }
293
308
  if (out.given.has('any') && cmd !== 'wait') fail(2, '--any is only valid with "zcode wait".');
294
309
  if (out.given.has('full') && !FULL_COMMANDS.has(cmd)) fail(2, `--full is not valid with "zcode ${cmd}".`);
295
310
  if (out.given.has('session') && cmd !== 'run' && cmd !== 'plan') fail(2, '--session is only valid with "zcode run"; use "zcode resume <id>".');
@@ -360,6 +375,7 @@ async function main() {
360
375
  return require('../lib/commands/plan.js')(parsed);
361
376
  case 'status':
362
377
  case 'wait':
378
+ case 'watch':
363
379
  case 'result':
364
380
  case 'cancel':
365
381
  return require('../lib/commands/sessions.js')(parsed);
package/lib/appserver.js CHANGED
@@ -365,6 +365,7 @@ async function runTurn(client, session, {
365
365
  reasoningText: '',
366
366
  toolCallCount: null,
367
367
  toolEvents: 0,
368
+ modelRequests: 0,
368
369
  userInput: null,
369
370
  durationMs: 0,
370
371
  };
@@ -418,6 +419,7 @@ async function runTurn(client, session, {
418
419
  } else if (type === 'turn.started') {
419
420
  onEvent({ type: 'turn', phase: 'started', turnId: params.turnId });
420
421
  } else if (type === 'session.updated' && payload && payload.type === 'model_request_started') {
422
+ turn.modelRequests++;
421
423
  turn.engineProvider = payload.providerId ?? turn.engineProvider;
422
424
  turn.engineModel = payload.modelId ?? turn.engineModel;
423
425
  turn.engineReasoningLevel = payload.modelCall?.reasoning?.requestedLevel ?? turn.engineReasoningLevel;
@@ -475,6 +477,9 @@ async function runTurn(client, session, {
475
477
  if (['timeout', 'cancelled', 'user_input'].includes(failed.kind)) {
476
478
  await client.stopSession(session.sessionId);
477
479
  }
480
+ // The partial facts (model requests so far) survive a failed turn.
481
+ turn.durationMs = Date.now() - started;
482
+ failed.turn = turn;
478
483
  throw failed;
479
484
  }
480
485
  if (completed.resultType && completed.resultType !== 'success') {
package/lib/brief.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // `zcode result`, `zcode wait --json`). A projection of the full result
4
4
  // record (schema zcode.result/1, still what result.json holds on disk and
5
5
  // what `--full` prints): decision-relevant fields only, empty keys omitted,
6
- // long text bounded, passing checks reduced to {command, ok:true}, and a
7
- // pointer (`result`) to the full record for anything else.
6
+ // long text bounded, passing checks and met visual criteria reduced to a count
7
+ // (checksPassed, visualMet), decisions/files capped, and a pointer (`result`) to the full record for anything else.
8
8
  const BRIEF_SCHEMA = 'zcode.brief/1';
9
9
  const SUMMARY_MAX = 600;
10
10
  const ERROR_MAX = 500;
@@ -26,6 +26,32 @@ function briefCheck(c) {
26
26
  return out;
27
27
  }
28
28
 
29
+ const DECISIONS_MAX = 6;
30
+ const FILES_MAX = 40;
31
+ const OPTION_MAX = 300;
32
+
33
+ function briefDecision(d) {
34
+ if (d && typeof d === 'object') return { what: clip(d.what, 160), why: clip(d.why, 120) };
35
+ return clip(d, 160);
36
+ }
37
+
38
+ function briefVisual(v) {
39
+ return {
40
+ criterion: clip(v.criterion, 160),
41
+ viewport: v.viewport,
42
+ met: v.met === null ? null : false,
43
+ seen: v.seen ? clip(v.seen, 200) : undefined,
44
+ note: clip(v.note, 300),
45
+ shot: v.shot,
46
+ };
47
+ }
48
+
49
+ // Questions stay whole (the decision surface); only option strings are bounded.
50
+ function briefQuestion(q) {
51
+ if (!q || typeof q !== 'object' || !Array.isArray(q.options)) return q;
52
+ return { ...q, options: q.options.map((o) => (typeof o === 'string' ? clip(o, OPTION_MAX) : o)) };
53
+ }
54
+
29
55
  const ALWAYS = new Set(['type', 'schema', 'status', 'ok', 'id']);
30
56
 
31
57
  function buildBrief(result) {
@@ -37,6 +63,16 @@ function buildBrief(result) {
37
63
  : null;
38
64
  // The failure message usually repeats as an issue; say it once.
39
65
  const issues = (result.issues || []).filter((i) => !(error && i === result.error.message));
66
+ const checks = result.checks || [];
67
+ const failing = checks.filter((c) => !c.ok);
68
+ const passed = checks.length - failing.length;
69
+ const visual = Array.isArray(result.visual) ? result.visual : [];
70
+ const visualUnmet = visual.filter((v) => !(v && v.met === true));
71
+ const decisions = Array.isArray(result.decisions) ? result.decisions : [];
72
+ // Files a concurrent session changed are that session's, not this handback's.
73
+ const files = ((result.changes && result.changes.files) || [])
74
+ .filter((f) => !f.concurrentSession)
75
+ .map((f) => f.path);
40
76
  const raw = {
41
77
  type: 'result',
42
78
  schema: BRIEF_SCHEMA,
@@ -48,15 +84,19 @@ function buildBrief(result) {
48
84
  summary: clip(result.summary, SUMMARY_MAX),
49
85
  // run/agent: the answer is the product. task: the handback is.
50
86
  response: result.kind === 'task' ? undefined : result.response,
51
- files: ((result.changes && result.changes.files) || []).map((f) => f.path),
52
- checks: (result.checks || []).map(briefCheck),
53
- questions: result.questions,
87
+ files: files.slice(0, FILES_MAX),
88
+ filesMore: files.length > FILES_MAX ? files.length - FILES_MAX : undefined,
89
+ checks: failing.map(briefCheck),
90
+ checksPassed: passed > 0 ? passed : undefined,
91
+ questions: Array.isArray(result.questions) ? result.questions.map(briefQuestion) : result.questions,
54
92
  issues,
55
93
  unresolved: result.unresolved,
56
94
  scopeViolations: result.scopeViolations,
57
95
  touched: result.touched,
58
- decisions: result.decisions,
59
- visual: result.visual,
96
+ decisions: decisions.slice(0, DECISIONS_MAX).map(briefDecision),
97
+ decisionsMore: decisions.length > DECISIONS_MAX ? decisions.length - DECISIONS_MAX : undefined,
98
+ visual: visualUnmet.map(briefVisual),
99
+ visualMet: visual.length ? `${visual.length - visualUnmet.length}/${visual.length}` : undefined,
60
100
  units: result.units,
61
101
  error,
62
102
  result: result.files && result.files.result,
@@ -8,7 +8,7 @@ const { sha256File, engineVersion } = require('../engine.js');
8
8
  const { resolveRuntime, ConfigError } = require('../bootstrap.js');
9
9
  const { buildEngineHome, engineEnv, rootDir, liveDir, effectiveMcpServers } = require('../isolation.js');
10
10
  const store = require('../store.js');
11
- const { modelLevels } = require('../models.js');
11
+ const { modelLevels, modelVision } = require('../models.js');
12
12
 
13
13
  module.exports = async function doctor(parsed) {
14
14
  const flags = parsed.flags;
@@ -53,6 +53,7 @@ module.exports = async function doctor(parsed) {
53
53
  builtinProviderConfig: builtinConfig,
54
54
  },
55
55
  models: modelLevels({ builtinConfig }),
56
+ vision: modelVision({ builtinConfig }),
56
57
  isolation: {
57
58
  root,
58
59
  home: built.home,
@@ -85,7 +86,7 @@ module.exports = async function doctor(parsed) {
85
86
  ` sha256 ${report.engine.sha256}`,
86
87
  ` version ${version ?? 'unknown'}`,
87
88
  ` builtin provider config ${builtinConfig}`,
88
- ` models ${Object.entries(report.models).map(([id, l]) => `${id} (effort ${l.join('|')})`).join(', ')}`,
89
+ ` models ${Object.entries(report.models).map(([id, l]) => `${id} (effort ${l.join('|')}, ${report.vision[id] ? 'images' : 'no images'})`).join(', ')}`,
89
90
  ` isolation root ${root} (one engine HOME per session; config mode: ${built.mode})`,
90
91
  ` skills inherited ${built.skillsCopied}, mcp servers available with --mcp-inherit ${report.isolation.mcpServersInherited}`,
91
92
  ` sessions ${report.sessions.total} (${report.sessions.running} running, ${report.sessions.interrupted} interrupted)`,
@@ -25,7 +25,7 @@ const { makeReporter, formatResult, useColor } = require('../ui.js');
25
25
  const { normalizeModel, normalizeEffort, effortProblem } = require('../models.js');
26
26
 
27
27
  const BIN = path.join(__dirname, '..', '..', 'bin', 'zcode.js');
28
- const DEFAULT_TIMEOUT = { run: 600000, agent: 600000, task: 1800000 };
28
+ const DEFAULT_TIMEOUT = { run: 600000, agent: 600000, task: 7200000 };
29
29
 
30
30
  // Thrown (not exited) so builders can be reused by the plan supervisor; the
31
31
  // command entry points below turn it into the one-line exit 2.
@@ -99,7 +99,7 @@ function brief(name, flags, status) {
99
99
  if (status) report.status = status;
100
100
  if (flags.json) return print(report, flags);
101
101
  // Human mode: the whole timeout/detach summary is one stdout line.
102
- out(`plan ${report.name}: ${report.status} (${report.sessions.map((s) => `${s.id} ${s.status}`).join(', ')})`);
102
+ out(`plan ${report.name}: ${report.status} (${report.sessions.map((s) => `${s.id} ${s.status}${s.stalled ? ` idle ${Math.round(s.idleMs / 60000)}m STALLED?` : ''}`).join(', ')})`);
103
103
  }
104
104
 
105
105
  // Supervises in-process until the plan settles (or the deadline). On a
@@ -215,7 +215,8 @@ async function preflight(parsed, r, name, state) {
215
215
  if (Date.now() >= deadline) {
216
216
  for (const id of pending) {
217
217
  const v = store.viewSession(store.readSession(id));
218
- out(flags.json ? JSON.stringify({ type: 'pending', id, state: v.state, phase: v.phase }) : `${id} ${v.state} ${v.phase}`);
218
+ const idle = store.idleInfo(v);
219
+ out(flags.json ? JSON.stringify({ type: 'pending', id, state: v.state, phase: v.phase, ...idle }) : `${id} ${v.state} ${v.phase}${idle && idle.stalled ? ' STALLED?' : ''}`);
219
220
  }
220
221
  process.exit(124);
221
222
  }
@@ -4,7 +4,7 @@
4
4
  // engine or needs the API key, so they are instant and safe to poll.
5
5
  const store = require('../store.js');
6
6
  const { buildBrief } = require('../brief.js');
7
- const { formatResult, formatSessionRow, formatSessionView, useColor } = require('../ui.js');
7
+ const { formatResult, formatSessionRow, formatSessionView, phaseText, useColor } = require('../ui.js');
8
8
 
9
9
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
10
 
@@ -39,27 +39,35 @@ function finalResult(view) {
39
39
  }
40
40
 
41
41
  function statusPayload(view, { events = 10 } = {}) {
42
- const payload = { type: 'status', ...view, recent: store.readEvents(view.id, { tail: events }) };
42
+ const payload = { type: 'status', ...view, ...store.idleInfo(view), recent: store.readEvents(view.id, { tail: events }) };
43
43
  if (view.state === 'finished' || view.state === 'interrupted') payload.result = finalResult(view);
44
44
  return payload;
45
45
  }
46
46
 
47
+ const STATUS_RECENT = 15;
48
+
47
49
  async function status(parsed) {
48
50
  const { flags, prompts } = parsed;
49
51
  if (prompts.length === 0) {
50
- const all = store.listSessions().slice(0, 50);
52
+ const everything = store.listSessions();
51
53
  if (flags.json) {
52
- process.stdout.write(JSON.stringify({ type: 'sessions', sessions: all.map(summaryOf) }) + '\n');
53
- } else if (all.length === 0) {
54
+ process.stdout.write(JSON.stringify({ type: 'sessions', sessions: everything.slice(0, 50).map(summaryOf) }) + '\n');
55
+ } else if (everything.length === 0) {
54
56
  process.stdout.write('no sessions\n');
55
57
  } else {
56
- for (const s of all) process.stdout.write(formatSessionRow(s) + '\n');
58
+ // Most recent first; the rest is one line, `--all` lists everything.
59
+ const shown = flags.all ? everything : everything.slice(0, STATUS_RECENT);
60
+ for (const s of shown) process.stdout.write(formatSessionRow(s) + '\n');
61
+ if (shown.length < everything.length) {
62
+ process.stdout.write(`… ${everything.length - shown.length} more (zcode status --all)\n`);
63
+ }
57
64
  }
58
65
  process.exit(0);
59
66
  }
60
67
  const id = resolveOrExit(prompts[0], flags.json);
61
68
  const view = store.viewSession(store.readSession(id));
62
69
  const payload = statusPayload(view);
70
+ Object.assign(view, store.idleInfo(view));
63
71
  if (flags.json) {
64
72
  process.stdout.write(JSON.stringify(payload) + '\n');
65
73
  } else {
@@ -97,8 +105,9 @@ function formatElapsed(ms) {
97
105
  // one line, nothing else.
98
106
  function pendingLine(view, json) {
99
107
  const elapsedMs = Math.max(0, Date.now() - Date.parse(view.createdAt || view.updatedAt || Date.now()));
100
- if (json) return JSON.stringify({ type: 'pending', id: view.id, state: view.state, phase: view.phase, elapsedMs });
101
- return `${view.id} ${view.state} ${view.phase} ${formatElapsed(elapsedMs)}`;
108
+ const idle = store.idleInfo(view);
109
+ if (json) return JSON.stringify({ type: 'pending', id: view.id, state: view.state, phase: view.phase, elapsedMs, ...idle });
110
+ return `${view.id} ${view.state} ${view.phase} ${formatElapsed(elapsedMs)}${idle && idle.stalled ? ` idle ${formatElapsed(idle.idleMs)} STALLED?` : ''}`;
102
111
  }
103
112
 
104
113
  // A finished session's handback in the requested form.
@@ -220,7 +229,85 @@ async function cancel(parsed) {
220
229
  return report('killed');
221
230
  }
222
231
 
232
+ // One line per watched session: `id state phase[ n/m] elapsed last: <tool>
233
+ // <age> ago req <n> sub <running>` (+ STALLED? when running and silent).
234
+ function watchLine(view, { stallMin, now = Date.now() }) {
235
+ const done = isDone(view);
236
+ const state = view.state === 'finished' ? view.status || 'finished' : view.state;
237
+ const a = view.activity || {};
238
+ const idle = store.idleInfo(view, { stallMin, now });
239
+ const end = done ? Date.parse(view.updatedAt) : now;
240
+ const elapsed = formatElapsed(end - Date.parse(view.createdAt || view.updatedAt));
241
+ const last = a.lastTool
242
+ ? `last: ${a.lastTool}${a.lastToolAt ? ` ${formatElapsed(now - Date.parse(a.lastToolAt))} ago` : ''}`
243
+ : 'last: -';
244
+ const phase = done ? '' : ` ${phaseText(view)}`;
245
+ const stalled = idle && idle.stalled ? ' STALLED?' : '';
246
+ const text = `${view.id} ${state}${phase} ${elapsed} ${last} req ${a.modelRequests || 0} sub ${a.subagentsRunning || 0}${stalled}`;
247
+ // The change signature leaves out the clocks (elapsed, ages).
248
+ const sig = [view.id, state, phase, a.lastTool, a.modelRequests || 0, a.subagentsRunning || 0, Boolean(stalled)].join('|');
249
+ return { text, sig, done };
250
+ }
251
+
252
+ // watch [<id>… | --plan <name>] [--stall-min n]: live view, no Opus tokens.
253
+ // TTY: redraw every 2 s; otherwise print a session's line when it changes.
254
+ // No args: live sessions plus those finished in the last 10 min. Exits when
255
+ // every watched session is finished; Ctrl-C exits 0.
256
+ async function watch(parsed) {
257
+ const { flags, prompts } = parsed;
258
+ const stallMin = parsed.given.has('stallMin') ? flags.stallMin : store.STALL_MIN;
259
+ const poll = Number(process.env.ZCODE_WATCH_POLL_MS) || 2000;
260
+ const tty = Boolean(process.stdout.isTTY);
261
+ process.on('SIGINT', () => process.exit(0));
262
+ let planCtx = null;
263
+ if (flags.plan) {
264
+ const plans = require('../plan.js');
265
+ const state = store.readPlanState(flags.plan);
266
+ if (!state) {
267
+ process.stderr.write(`zcode: no plan "${flags.plan}" (zcode plan run <file> starts one).\n`);
268
+ process.exit(2);
269
+ }
270
+ planCtx = { plans, state, r: plans.resolvePlan(state.file, state.cwd) };
271
+ }
272
+ const ids = prompts.map((p) => resolveOrExit(p, false));
273
+ const pick = () => {
274
+ if (ids.length) return ids.map((id) => store.viewSession(store.readSession(id))).filter(Boolean);
275
+ if (planCtx) {
276
+ return planCtx.plans.sessionList(planCtx.r.plan)
277
+ .map((s) => store.readSession(s.id)).filter(Boolean).map((rec) => store.viewSession(rec));
278
+ }
279
+ const cutoff = Date.now() - 10 * 60000;
280
+ return store.listSessions().filter((v) => !isDone(v) || Date.parse(v.updatedAt) >= cutoff).reverse();
281
+ };
282
+ const planDone = () => planCtx.plans.planReport(flags.plan, planCtx.r, store.readPlanState(flags.plan), { briefs: false }).status !== 'running';
283
+ const seen = new Map();
284
+ let drawn = 0;
285
+ for (;;) {
286
+ const views = pick();
287
+ if (!views.length && !planCtx) {
288
+ process.stdout.write('no live sessions\n');
289
+ process.exit(0);
290
+ }
291
+ const lines = views.map((v) => watchLine(v, { stallMin }));
292
+ if (tty) {
293
+ process.stdout.write(`${drawn ? `\x1b[${drawn}F\x1b[J` : ''}${lines.map((l) => l.text).join('\n')}${lines.length ? '\n' : ''}`);
294
+ drawn = lines.length;
295
+ } else {
296
+ for (const l of lines) {
297
+ const id = l.text.split(' ')[0];
298
+ if (seen.get(id) === l.sig) continue;
299
+ seen.set(id, l.sig);
300
+ process.stdout.write(l.text + '\n');
301
+ }
302
+ }
303
+ const finished = planCtx ? planDone() : lines.every((l) => l.done);
304
+ if (finished && (lines.length || planCtx)) process.exit(0);
305
+ await sleep(poll);
306
+ }
307
+ }
308
+
223
309
  module.exports = async function sessions(parsed) {
310
+ if (parsed.command === 'watch') return watch(parsed);
224
311
  if (parsed.command === 'status') return status(parsed);
225
312
  if (parsed.command === 'wait') return wait(parsed);
226
313
  if (parsed.command === 'result') return result(parsed);