@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 +10 -11
- package/bin/zcode.js +26 -10
- package/lib/appserver.js +5 -0
- package/lib/brief.js +47 -7
- package/lib/commands/doctor.js +3 -2
- package/lib/commands/exec.js +1 -1
- package/lib/commands/plan.js +3 -2
- package/lib/commands/sessions.js +95 -8
- package/lib/contract.js +63 -38
- package/lib/executor.js +152 -34
- package/lib/help.js +111 -0
- package/lib/isolation.js +20 -1
- package/lib/models.js +42 -0
- package/lib/plan.js +44 -9
- package/lib/store.js +14 -0
- package/lib/taskspec.js +19 -12
- package/lib/ui.js +13 -3
- package/lib/verify.js +98 -0
- package/lib/workspace.js +30 -0
- package/package.json +1 -1
- package/skills/claude-code/zcode/SKILL.md +17 -13
- package/skills/codex/zcode/SKILL.md +17 -13
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":
|
|
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`)
|
|
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
|
|
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 --
|
|
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.
|
|
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
|
|
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
|
|
|
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` |
|
|
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
|
|
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]
|
|
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
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
52
|
-
|
|
53
|
-
|
|
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:
|
|
59
|
-
|
|
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,
|
package/lib/commands/doctor.js
CHANGED
|
@@ -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)`,
|
package/lib/commands/exec.js
CHANGED
|
@@ -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:
|
|
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.
|
package/lib/commands/plan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/commands/sessions.js
CHANGED
|
@@ -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
|
|
52
|
+
const everything = store.listSessions();
|
|
51
53
|
if (flags.json) {
|
|
52
|
-
process.stdout.write(JSON.stringify({ type: 'sessions', sessions:
|
|
53
|
-
} else if (
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
return
|
|
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);
|