@0xgeorgemathew/zcode 0.4.1 → 0.5.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.
@@ -8,6 +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
12
 
12
13
  module.exports = async function doctor(parsed) {
13
14
  const flags = parsed.flags;
@@ -51,6 +52,7 @@ module.exports = async function doctor(parsed) {
51
52
  version,
52
53
  builtinProviderConfig: builtinConfig,
53
54
  },
55
+ models: modelLevels({ builtinConfig }),
54
56
  isolation: {
55
57
  root,
56
58
  home: built.home,
@@ -83,6 +85,7 @@ module.exports = async function doctor(parsed) {
83
85
  ` sha256 ${report.engine.sha256}`,
84
86
  ` version ${version ?? 'unknown'}`,
85
87
  ` builtin provider config ${builtinConfig}`,
88
+ ` models ${Object.entries(report.models).map(([id, l]) => `${id} (effort ${l.join('|')})`).join(', ')}`,
86
89
  ` isolation root ${root} (one engine HOME per session; config mode: ${built.mode})`,
87
90
  ` skills inherited ${built.skillsCopied}, mcp servers available with --mcp-inherit ${report.isolation.mcpServersInherited}`,
88
91
  ` sessions ${report.sessions.total} (${report.sessions.running} running, ${report.sessions.interrupted} interrupted)`,
@@ -6,7 +6,9 @@
6
6
  //
7
7
  // Output contract:
8
8
  // --json stdout = NDJSON events + exactly one final {"type":"result"}
9
- // line (--quiet: only that line). stderr = diagnostics only.
9
+ // line (--quiet: only that line): the compact brief
10
+ // (zcode.brief/1, lib/brief.js), or the full record with --full.
11
+ // stderr = diagnostics only.
10
12
  // plain stdout = the answer (run/agent) or the handback summary (task);
11
13
  // stderr = the live feed (header, phases, tools, checks).
12
14
  const fs = require('node:fs');
@@ -15,17 +17,22 @@ const { spawn } = require('node:child_process');
15
17
  const store = require('../store.js');
16
18
  const { execute, prepareSession, exitCodeFor, ExecError, readSettings } = require('../executor.js');
17
19
  const { ConfigError } = require('../bootstrap.js');
18
- const { normalizeContract, ContractError } = require('../contract.js');
20
+ const { ContractError } = require('../contract.js');
21
+ const { buildContract } = require('../taskspec.js');
19
22
  const { workspaceRoot, normalizeOwns, OwnershipError } = require('../workspace.js');
23
+ const { buildBrief } = require('../brief.js');
20
24
  const { makeReporter, formatResult, useColor } = require('../ui.js');
21
- const { normalizeModel, normalizeEffort } = require('../models.js');
25
+ const { normalizeModel, normalizeEffort, effortProblem } = require('../models.js');
22
26
 
23
27
  const BIN = path.join(__dirname, '..', '..', 'bin', 'zcode.js');
24
28
  const DEFAULT_TIMEOUT = { run: 600000, agent: 600000, task: 1800000 };
25
29
 
30
+ // Thrown (not exited) so builders can be reused by the plan supervisor; the
31
+ // command entry points below turn it into the one-line exit 2.
32
+ class UsageError extends Error {}
33
+
26
34
  function usage(message) {
27
- process.stderr.write(`zcode: ${message}\n`);
28
- process.exit(2);
35
+ throw new UsageError(message);
29
36
  }
30
37
 
31
38
  function readStdin() {
@@ -39,7 +46,7 @@ function readStdin() {
39
46
  // --task <file|->: .json (or text starting with "{") is a contract object,
40
47
  // anything else is a markdown brief that becomes the objective.
41
48
  function loadTaskBase(spec) {
42
- if (spec === undefined) return {};
49
+ if (spec === undefined) return { base: {}, dir: null };
43
50
  const text = spec === '-' ? readStdin() : (() => {
44
51
  try {
45
52
  return fs.readFileSync(spec, 'utf8');
@@ -48,19 +55,24 @@ function loadTaskBase(spec) {
48
55
  }
49
56
  })();
50
57
  const trimmed = text.trim();
58
+ // "@file" entries inside a contract resolve next to the contract file.
59
+ const dir = spec === '-' ? null : path.dirname(path.resolve(spec));
51
60
  if (spec.endsWith('.json') || trimmed.startsWith('{')) {
52
61
  try {
53
- return JSON.parse(trimmed);
62
+ return { base: JSON.parse(trimmed), dir };
54
63
  } catch (err) {
55
64
  return usage(`--task JSON is invalid: ${err.message}`);
56
65
  }
57
66
  }
58
- return { objective: trimmed };
67
+ return { base: { objective: trimmed }, dir };
59
68
  }
60
69
 
61
- function buildInvocation(parsed) {
70
+ // `spec` ({base, dir}) replaces --task: the plan supervisor hands over an
71
+ // assembled contract instead of a file.
72
+ function buildInvocation(parsed, spec) {
62
73
  const { command, flags, prompts } = parsed;
63
74
  const kind = command;
75
+ let effortSource = '--effort';
64
76
  const inv = {
65
77
  kind,
66
78
  id: flags.id || store.newId(),
@@ -77,6 +89,7 @@ function buildInvocation(parsed) {
77
89
  acceptanceDeclared: false,
78
90
  mcpSpecs: flags.mcpSpecs,
79
91
  mcpInherit: flags.mcpInherit,
92
+ noBrowser: flags.noBrowser,
80
93
  skillsDirs: flags.skillsDirs.map((d) => path.resolve(d)),
81
94
  noSkills: flags.noSkills,
82
95
  profile: flags.profile,
@@ -87,19 +100,30 @@ function buildInvocation(parsed) {
87
100
 
88
101
  if (kind === 'task') {
89
102
  const objective = prompts.join(' ');
90
- let base = loadTaskBase(flags.task);
91
- if (!objective.trim() && flags.task === undefined && !process.stdin.isTTY) {
103
+ let { base, dir } = spec || loadTaskBase(flags.task);
104
+ if (!spec && !objective.trim() && flags.task === undefined && !process.stdin.isTTY) {
92
105
  base = { objective: readStdin().trim() };
93
106
  }
94
107
  let contract;
95
108
  try {
96
- contract = normalizeContract(base, {
109
+ contract = buildContract({
110
+ base,
111
+ baseDir: dir || process.cwd(),
112
+ cwd: inv.cwd,
97
113
  objective,
98
- acceptance: flags.accept,
99
- checks: flags.check,
100
- files: flags.file,
101
- context: flags.context,
102
- owns: flags.owns,
114
+ additions: {
115
+ acceptance: flags.accept,
116
+ checks: flags.check,
117
+ files: flags.file,
118
+ context: flags.context,
119
+ decisions: flags.decisions,
120
+ units: flags.units,
121
+ visual: flags.visual,
122
+ viewports: flags.viewports,
123
+ serve: flags.serve,
124
+ owns: flags.owns,
125
+ touch: flags.touch,
126
+ },
103
127
  });
104
128
  } catch (err) {
105
129
  if (err instanceof ContractError) usage(err.message);
@@ -110,6 +134,7 @@ function buildInvocation(parsed) {
110
134
  inv.model = normalizeModel(contract.model) || usage(`contract model "${contract.model}" is not supported.`);
111
135
  }
112
136
  if (contract.effort && !parsed.given.has('effort')) {
137
+ effortSource = 'contract effort';
113
138
  inv.effort = normalizeEffort(contract.effort) || usage(`contract effort "${contract.effort}" is not valid.`);
114
139
  }
115
140
  const lim = contract.limits;
@@ -119,7 +144,9 @@ function buildInvocation(parsed) {
119
144
  inv.contract = contract;
120
145
  inv.checks = contract.checks;
121
146
  inv.ownsDisplay = contract.owns;
147
+ inv.touchDisplay = contract.touch;
122
148
  inv.acceptanceDeclared = contract.acceptance.length > 0;
149
+ inv.preflight = Boolean(flags.preflight);
123
150
  } else {
124
151
  let prompt = prompts.join(' ');
125
152
  if (!prompt && kind === 'run' && !process.stdin.isTTY) prompt = readStdin();
@@ -127,8 +154,14 @@ function buildInvocation(parsed) {
127
154
  inv.prompt = prompt;
128
155
  }
129
156
 
157
+ // The model/effort pair that will actually run, refused before any spawn.
158
+ const problem = effortProblem(inv.model, inv.effort, { source: effortSource });
159
+ if (problem) usage(problem);
160
+
130
161
  try {
131
- inv.owns = normalizeOwns(inv.ownsDisplay, inv.cwd, workspaceRoot(inv.cwd));
162
+ const root = workspaceRoot(inv.cwd);
163
+ inv.owns = normalizeOwns(inv.ownsDisplay, inv.cwd, root);
164
+ inv.touch = normalizeOwns(inv.touchDisplay || [], inv.cwd, root, '--touch');
132
165
  } catch (err) {
133
166
  if (err instanceof OwnershipError) usage(err.message);
134
167
  throw err;
@@ -144,6 +177,10 @@ function buildResume(parsed, ref, message) {
144
177
  if (!settings) usage(`session "${id}" has no stored settings; it cannot be resumed.`);
145
178
  const { flags, given } = parsed;
146
179
  if (settings.kind !== 'task' && !message.trim()) usage(`resume of a ${settings.kind} session needs a message.`);
180
+ const model = given.has('model') ? flags.model : settings.model;
181
+ const effort = given.has('effort') ? flags.effort : settings.effort;
182
+ const problem = effortProblem(model, effort);
183
+ if (problem) usage(problem);
147
184
  return {
148
185
  ...settings,
149
186
  id,
@@ -167,23 +204,19 @@ function readTask(id) {
167
204
  }
168
205
  }
169
206
 
170
- // --detach: claim the id, hand the invocation to a detached worker
171
- // (`zcode __exec <id>`), print the handle, exit 0. The worker writes its
172
- // NDJSON to <session>/stdout.ndjson and diagnostics to stderr.log.
173
- function detach(inv, flags) {
207
+ // Claims the id, hands the invocation to a detached worker
208
+ // (`zcode __exec <id>`) and returns its handle. The worker writes its NDJSON
209
+ // to <session>/stdout.ndjson and diagnostics to stderr.log. Throws
210
+ // store.SessionExistsError for a taken id, ExecError (exit 7) when a resumed
211
+ // session is still running.
212
+ function startDetached(inv) {
174
213
  if (inv.resume) {
175
214
  const view = store.viewSession(store.readSession(inv.id));
176
215
  if (view && view.live) {
177
- process.stderr.write(`zcode: session "${inv.id}" is still running (pid ${view.pid}).\n`);
178
- process.exit(7);
216
+ throw new ExecError(`session "${inv.id}" is still running (pid ${view.pid}).`, { exitCode: 7, kind: 'busy' });
179
217
  }
180
218
  } else {
181
- try {
182
- prepareSession(inv);
183
- } catch (err) {
184
- if (err instanceof store.SessionExistsError) usage(err.message);
185
- throw err;
186
- }
219
+ prepareSession(inv);
187
220
  }
188
221
  const dir = store.sessionDir(inv.id);
189
222
  store.writeJsonAtomic(path.join(dir, 'pending.json'), inv);
@@ -199,8 +232,10 @@ function detach(inv, flags) {
199
232
  env: process.env,
200
233
  });
201
234
  child.unref();
235
+ fs.closeSync(out);
236
+ fs.closeSync(err);
202
237
  store.updateSession(inv.id, { state: 'queued', phase: 'queued', pid: child.pid, status: null });
203
- const handle = {
238
+ return {
204
239
  type: 'detached',
205
240
  id: inv.id,
206
241
  pid: child.pid,
@@ -208,17 +243,32 @@ function detach(inv, flags) {
208
243
  status: 'queued',
209
244
  files: { session: dir, stdout: path.join(dir, 'stdout.ndjson'), stderr: path.join(dir, 'stderr.log') },
210
245
  };
246
+ }
247
+
248
+ // --detach: start in the background, print the handle, exit 0.
249
+ function detach(inv, flags) {
250
+ let handle;
251
+ try {
252
+ handle = startDetached(inv);
253
+ } catch (err) {
254
+ if (err instanceof store.SessionExistsError) usage(err.message);
255
+ if (err instanceof ExecError) {
256
+ process.stderr.write(`zcode: ${err.message}\n`);
257
+ process.exit(err.exitCode);
258
+ }
259
+ throw err;
260
+ }
211
261
  if (flags.json) process.stdout.write(JSON.stringify(handle) + '\n');
212
262
  else {
213
263
  process.stdout.write(`${inv.id}\n`);
214
264
  if (!flags.quiet) {
215
- process.stderr.write(`zcode: started ${inv.id} in the background (pid ${child.pid}). zcode status ${inv.id} · zcode wait ${inv.id}\n`);
265
+ process.stderr.write(`zcode: started ${inv.id} in the background (pid ${handle.pid}). zcode status ${inv.id} · zcode wait ${inv.id}\n`);
216
266
  }
217
267
  }
218
268
  process.exit(0);
219
269
  }
220
270
 
221
- module.exports = async function exec(parsed) {
271
+ async function exec(parsed) {
222
272
  const { command, flags } = parsed;
223
273
  let inv;
224
274
  let worker = false;
@@ -250,6 +300,7 @@ module.exports = async function exec(parsed) {
250
300
  // Workers always emit NDJSON (to their stdout file) and never a TTY feed.
251
301
  const json = worker || flags.json;
252
302
  const quiet = flags.quiet && !worker;
303
+ const full = flags.full && !worker;
253
304
  const kind = inv.kind;
254
305
  const reporter = makeReporter({
255
306
  quiet,
@@ -294,7 +345,7 @@ module.exports = async function exec(parsed) {
294
345
  type: 'result', ok: false, status: 'failed', id: inv.id, kind,
295
346
  error: { kind: err.kind || 'config', message: err.message }, issues: [err.message],
296
347
  };
297
- if (json) process.stdout.write(JSON.stringify(failed) + '\n');
348
+ if (json) process.stdout.write(JSON.stringify(full ? failed : buildBrief(failed)) + '\n');
298
349
  // A detached worker's caller polls the store: record why it never ran
299
350
  // (conflicts already record themselves).
300
351
  if (worker && err.kind !== 'conflict' && store.readSession(inv.id)) {
@@ -310,7 +361,7 @@ module.exports = async function exec(parsed) {
310
361
 
311
362
  const code = exitCodeFor(result);
312
363
  if (json) {
313
- process.stdout.write(JSON.stringify(result) + '\n');
364
+ process.stdout.write(JSON.stringify(full ? result : buildBrief(result)) + '\n');
314
365
  } else if (kind === 'task') {
315
366
  reporter.closeThinking();
316
367
  process.stdout.write(formatResult(result, { color: useColor(process.stdout) }));
@@ -324,4 +375,19 @@ module.exports = async function exec(parsed) {
324
375
  }
325
376
  }
326
377
  process.exit(code);
378
+ }
379
+
380
+ module.exports = async function execCommand(parsed) {
381
+ try {
382
+ return await exec(parsed);
383
+ } catch (err) {
384
+ if (!(err instanceof UsageError)) throw err;
385
+ process.stderr.write(`zcode: ${err.message}\n`);
386
+ process.exit(2);
387
+ }
327
388
  };
389
+
390
+ module.exports.buildInvocation = buildInvocation;
391
+ module.exports.buildResume = buildResume;
392
+ module.exports.startDetached = startDetached;
393
+ module.exports.UsageError = UsageError;
@@ -6,7 +6,7 @@ const { effectiveMcpServers } = require('../isolation.js');
6
6
 
7
7
  module.exports = async function mcpList(parsed) {
8
8
  const { flags } = parsed;
9
- const servers = effectiveMcpServers({ inherit: flags.mcpInherit, mcpSpecs: flags.mcpSpecs });
9
+ const servers = effectiveMcpServers({ inherit: flags.mcpInherit, browser: !flags.noBrowser, mcpSpecs: flags.mcpSpecs });
10
10
  process.stdout.write(JSON.stringify({ type: 'mcp', servers }) + '\n');
11
11
  process.exit(0);
12
12
  };
@@ -0,0 +1,301 @@
1
+ 'use strict';
2
+ // lib/commands/plan.js — plan check|run|wait|status|resume, and __plan (the
3
+ // detached supervisor). Logic lives in lib/plan.js.
4
+ //
5
+ // Output contract: JSON mode prints exactly one line per call (plan check:
6
+ // {type:"plan-check"}; run/wait/status/resume: {type:"plan",name,status,
7
+ // sessions:[{id,status}],briefs:[…]}; a timeout or --detach: the same
8
+ // without briefs). `plan run --preflight` prints one brief per session with
9
+ // questions. Exit codes: 0 completed · 10 blocked · 8 partial/failed ·
10
+ // 124 timeout · 2 usage · 7 plan exists.
11
+ const store = require('../store.js');
12
+ const plans = require('../plan.js');
13
+ const { buildBrief } = require('../brief.js');
14
+ const { exitCodeFor } = require('../executor.js');
15
+ const { questionText } = require('../contract.js');
16
+ const { formatResult, useColor } = require('../ui.js');
17
+
18
+ const out = (line) => process.stdout.write(line + '\n');
19
+
20
+ function die(code, message) {
21
+ process.stderr.write(`zcode: ${message}\n`);
22
+ process.exit(code);
23
+ }
24
+
25
+ function load(file, parsed) {
26
+ if (!file) die(2, 'plan needs a plan file.');
27
+ try {
28
+ return plans.resolvePlan(file, parsed.given.has('cwd') ? parsed.flags.cwd : undefined);
29
+ } catch (err) {
30
+ if (err instanceof plans.PlanError) return die(2, err.message);
31
+ throw err;
32
+ }
33
+ }
34
+
35
+ function nameOf(parsed, file) {
36
+ try {
37
+ return plans.planName(parsed.flags.id, file);
38
+ } catch (err) {
39
+ if (err instanceof plans.PlanError) return die(2, err.message);
40
+ throw err;
41
+ }
42
+ }
43
+
44
+ // A plan by name (state + its plan file), or exit 2.
45
+ function open(name) {
46
+ const state = store.readPlanState(name);
47
+ if (!state) die(2, `no plan "${name}" (zcode plan run <file> starts one).`);
48
+ try {
49
+ return { state, r: plans.resolvePlan(state.file, state.cwd) };
50
+ } catch (err) {
51
+ if (err instanceof plans.PlanError) return die(2, err.message);
52
+ throw err;
53
+ }
54
+ }
55
+
56
+ function problemsOrExit(problems, json) {
57
+ if (!problems.length) return;
58
+ if (json) out(JSON.stringify({ type: 'plan-check', ok: false, problems }));
59
+ for (const p of problems) process.stderr.write(`zcode: ${p}\n`);
60
+ process.exit(2);
61
+ }
62
+
63
+ // One line per session; a non-completed session adds its one-line summary,
64
+ // a blocked one its questions.
65
+ function humanReport(report) {
66
+ const brief = new Map((report.briefs || []).map((b) => [b.id, b]));
67
+ const lines = [];
68
+ for (const s of report.sessions) {
69
+ const b = brief.get(s.id);
70
+ const note = b && (b.summary || (b.error && b.error.message) || '');
71
+ lines.push(`${s.id} ${s.status}${note ? ` - ${note.replace(/\s+/g, ' ').slice(0, 120)}` : ''}`);
72
+ if (b && b.questions) for (const q of b.questions) lines.push(` ? ${questionText(q).replace(/\n/g, '\n ')}`);
73
+ }
74
+ return lines.join('\n');
75
+ }
76
+
77
+ function print(report, flags) {
78
+ if (flags.json) out(JSON.stringify(report));
79
+ else {
80
+ process.stderr.write(`plan ${report.name}: ${report.status}\n`);
81
+ out(humanReport(report));
82
+ }
83
+ }
84
+
85
+ const reportOf = (name) => {
86
+ const { state, r } = open(name);
87
+ return plans.planReport(name, r, state);
88
+ };
89
+
90
+ function finish(report, flags) {
91
+ print(report, flags);
92
+ process.exit(plans.exitCodeForPlan(report.status));
93
+ }
94
+
95
+ // Timeout / detach summary: statuses only.
96
+ function brief(name, flags, status) {
97
+ const { state, r } = open(name);
98
+ const report = plans.planReport(name, r, state, { briefs: false });
99
+ if (status) report.status = status;
100
+ if (flags.json) return print(report, flags);
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(', ')})`);
103
+ }
104
+
105
+ // Supervises in-process until the plan settles (or the deadline). On a
106
+ // deadline the supervision is handed to a detached supervisor so the plan
107
+ // keeps going.
108
+ async function superviseAndReport(name, flags, given) {
109
+ const deadline = given.has('timeoutMs') ? Date.now() + flags.timeoutMs : Infinity;
110
+ const res = await plans.supervise(name, { deadline });
111
+ if (res.timedOut) {
112
+ plans.startSupervisor(name);
113
+ brief(name, flags, 'running');
114
+ process.exit(124);
115
+ }
116
+ if (res.busy) {
117
+ // Another supervisor already drives it: just wait for it.
118
+ return waitFor(name, flags, given);
119
+ }
120
+ return finish(reportOf(name), flags);
121
+ }
122
+
123
+ async function waitFor(name, flags, given) {
124
+ const deadline = given.has('timeoutMs') ? Date.now() + flags.timeoutMs : Infinity;
125
+ for (;;) {
126
+ const report = reportOf(name);
127
+ if (report.status !== 'running') return finish(report, flags);
128
+ if (Date.now() >= deadline) {
129
+ brief(name, flags, 'running');
130
+ process.exit(124);
131
+ }
132
+ await plans.sleep(plans.POLL_MS);
133
+ }
134
+ }
135
+
136
+ async function check(parsed, file) {
137
+ const r = load(file, parsed);
138
+ const name = nameOf(parsed, file);
139
+ const problems = plans.checkPlan(r, { name, state: store.readPlanState(name) });
140
+ problemsOrExit(problems, parsed.flags.json);
141
+ const n = plans.sessionList(r.plan).length;
142
+ if (parsed.flags.json) out(JSON.stringify({ type: 'plan-check', ok: true, name, sessions: n }));
143
+ else out(`plan ${name}: ${n} session${n === 1 ? '' : 's'}, ok`);
144
+ process.exit(0);
145
+ }
146
+
147
+ async function run(parsed, file) {
148
+ const { flags, given } = parsed;
149
+ const r = load(file, parsed);
150
+ const name = nameOf(parsed, file);
151
+ const state = store.readPlanState(name);
152
+ if (flags.preflight) return preflight(parsed, r, name, state);
153
+ if (state) {
154
+ die(7, `plan "${name}" already exists. Continue it with "zcode plan resume ${name}" (status: "zcode plan status ${name}"), or run again with another --id.`);
155
+ }
156
+ problemsOrExit(plans.checkPlan(r, { name }), flags.json);
157
+ const ok = store.createPlanState(name, {
158
+ file: r.file,
159
+ cwd: r.cwd,
160
+ sessions: plans.sessionList(r.plan).map((s) => s.id),
161
+ supervisor: null,
162
+ launchErrors: {},
163
+ });
164
+ if (!ok) die(7, `plan "${name}" already exists. Continue it with "zcode plan resume ${name}".`);
165
+ return startSupervision(name, flags, given);
166
+ }
167
+
168
+ async function startSupervision(name, flags, given) {
169
+ if (flags.detach) {
170
+ plans.startSupervisor(name);
171
+ out(JSON.stringify({ type: 'plan', name, status: 'running' }));
172
+ process.exit(0);
173
+ }
174
+ return superviseAndReport(name, flags, given);
175
+ }
176
+
177
+ // Preflight every session (read-only, in parallel, as "<id>.pre"), wait for
178
+ // all, print one brief per session that has questions. Creates no plan state.
179
+ async function preflight(parsed, r, name, state) {
180
+ const { flags, given } = parsed;
181
+ problemsOrExit(plans.checkPlan(r, { name, state }), flags.json);
182
+ const sessions = plans.sessionList(r.plan);
183
+ const e = require('./exec.js');
184
+ const ids = [];
185
+ const failed = [];
186
+ for (const s of sessions) {
187
+ const pre = `${s.id}.pre`;
188
+ const old = store.readSession(pre);
189
+ if (old) {
190
+ if (store.viewSession(old).live) die(7, `preflight session "${pre}" is still running; cancel it first (zcode cancel ${pre}).`);
191
+ store.deleteSession(pre);
192
+ }
193
+ try {
194
+ e.startDetached(plans.sessionInvocation(r, name, s, { id: pre, preflight: true }));
195
+ ids.push(pre);
196
+ } catch (err) {
197
+ if (err instanceof e.UsageError || err.exitCode || err instanceof store.SessionExistsError) {
198
+ failed.push(plans.launchFailure(pre, err.message));
199
+ } else throw err;
200
+ }
201
+ }
202
+ const { finalResult } = require('./sessions.js');
203
+ const deadline = Date.now() + (given.has('timeoutMs') ? flags.timeoutMs : 600000);
204
+ const pending = new Set(ids);
205
+ const results = [...failed];
206
+ for (;;) {
207
+ for (const id of [...pending]) {
208
+ const view = store.viewSession(store.readSession(id));
209
+ if (view.state === 'finished' || view.state === 'interrupted') {
210
+ pending.delete(id);
211
+ results.push(finalResult(view));
212
+ }
213
+ }
214
+ if (!pending.size) break;
215
+ if (Date.now() >= deadline) {
216
+ for (const id of pending) {
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}`);
219
+ }
220
+ process.exit(124);
221
+ }
222
+ await plans.sleep(plans.POLL_MS);
223
+ }
224
+ const asked = results.filter((x) => x.questions && x.questions.length);
225
+ const broken = results.filter((x) => !(x.questions && x.questions.length) && x.status !== 'completed');
226
+ for (const x of [...asked, ...broken]) {
227
+ if (flags.json) out(JSON.stringify(buildBrief(x)));
228
+ else out(formatResult(x, { color: useColor(process.stdout) }));
229
+ }
230
+ if (!asked.length && !broken.length) {
231
+ if (flags.json) out(JSON.stringify({ type: 'preflight', name, status: 'completed', sessions: ids.length }));
232
+ else out(`plan ${name}: preflight found no open questions (${ids.length} session${ids.length === 1 ? '' : 's'})`);
233
+ }
234
+ process.exit(asked.length ? 10 : broken.length ? Math.max(...broken.map((x) => exitCodeFor(x))) : 0);
235
+ }
236
+
237
+ async function resume(parsed, name, words) {
238
+ const { flags, given } = parsed;
239
+ const { state, r } = open(name);
240
+ const text = words.join(' ').trim();
241
+ const statuses = plans.sessionStatuses(r, state);
242
+ const RESUMABLE = new Set(['failed', 'partial', 'unverified', 'blocked', 'cancelled', 'interrupted']);
243
+ let targets;
244
+ if (flags.session) {
245
+ const e = statuses.get(flags.session);
246
+ if (!e) die(2, `plan "${name}" has no session "${flags.session}".`);
247
+ if (!RESUMABLE.has(e.status) || !e.view) die(2, `session "${flags.session}" is ${e.status}; only failed, partial, unverified, blocked, cancelled or interrupted sessions resume.`);
248
+ targets = [flags.session];
249
+ } else {
250
+ targets = plans.sessionList(r.plan).map((s) => s.id).filter((id) => RESUMABLE.has(statuses.get(id).status) && statuses.get(id).view);
251
+ }
252
+ const blocked = targets.filter((id) => statuses.get(id).status === 'blocked');
253
+ if (blocked.length && !text) {
254
+ die(2, `${blocked.join(', ')} ${blocked.length === 1 ? 'is' : 'are'} blocked: answer the questions: zcode plan resume ${name} "…" (or --session <id> "…" for one session)`);
255
+ }
256
+ if (!targets.length && !flags.session && ![...statuses.values()].some((x) => x.status === 'pending' || x.status === 'skipped')) {
257
+ // nothing to resume: report as is
258
+ return finish(plans.planReport(name, r, state), flags);
259
+ }
260
+ const message = text || 'Continue: fix the remaining issues.';
261
+ for (const id of targets) {
262
+ const refusal = plans.resumeSession(id, message);
263
+ if (refusal) die(7, refusal);
264
+ }
265
+ // Launch failures recorded earlier are retried by the supervisor.
266
+ if (state.launchErrors && Object.keys(state.launchErrors).length) {
267
+ store.updatePlanState(name, { launchErrors: {} });
268
+ }
269
+ return startSupervision(name, flags, given);
270
+ }
271
+
272
+ // Detached supervisor process: `zcode __plan <name>`.
273
+ async function supervisorWorker(name) {
274
+ if (!store.readPlanState(name)) die(2, `no plan "${name}".`);
275
+ await plans.supervise(name);
276
+ process.exit(0);
277
+ }
278
+
279
+ module.exports = async function plan(parsed) {
280
+ if (parsed.command === '__plan') return supervisorWorker(parsed.prompts[0]);
281
+ const [sub, ...rest] = parsed.prompts;
282
+ switch (sub) {
283
+ case 'check':
284
+ return check(parsed, rest[0]);
285
+ case 'run':
286
+ return run(parsed, rest[0]);
287
+ case 'wait':
288
+ case 'status': {
289
+ if (!rest[0]) die(2, `plan ${sub} needs a plan name.`);
290
+ open(rest[0]);
291
+ if (sub === 'wait') return waitFor(rest[0], parsed.flags, parsed.given);
292
+ print(reportOf(rest[0]), parsed.flags);
293
+ process.exit(0);
294
+ }
295
+ case 'resume':
296
+ if (!rest[0]) die(2, 'plan resume needs a plan name.');
297
+ return resume(parsed, rest[0], rest.slice(1));
298
+ default:
299
+ return die(2, 'plan needs a subcommand: check | run | wait | status | resume (zcode --help).');
300
+ }
301
+ };