@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.
package/lib/executor.js CHANGED
@@ -19,9 +19,11 @@ const { resolveRuntime, makeClient } = require('./bootstrap.js');
19
19
  const { buildEngineHome, rootDir, liveDir } = require('./isolation.js');
20
20
  const { openSession, runTurn, TurnError } = require('./appserver.js');
21
21
  const { runChecks } = require('./checks.js');
22
+ const { reservePort } = require('./ports.js');
22
23
  const {
23
24
  workspaceRoot,
24
25
  isOwned,
26
+ isZcodePath,
25
27
  acquireLease,
26
28
  liveLeases,
27
29
  snapshot,
@@ -34,6 +36,9 @@ const {
34
36
  HANDBACK_REQUEST,
35
37
  parseHandback,
36
38
  deriveStatus,
39
+ unmetVisual,
40
+ visualGaps,
41
+ buildVisualReportRequest,
37
42
  } = require('./contract.js');
38
43
 
39
44
  const RESULT_SCHEMA = 'zcode.result/1';
@@ -52,7 +57,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
52
57
  // Settings a session keeps across invocations (stored as invocation.json).
53
58
  const SETTING_KEYS = [
54
59
  'kind', 'label', 'cwd', 'model', 'effort', 'timeoutMs', 'maxRepairs', 'checkTimeoutMs',
55
- 'checks', 'owns', 'ownsDisplay', 'acceptanceDeclared', 'mcpSpecs', 'mcpInherit',
60
+ 'checks', 'preflight', 'owns', 'ownsDisplay', 'touch', 'touchDisplay', 'acceptanceDeclared', 'mcpSpecs', 'mcpInherit', 'noBrowser',
56
61
  'skillsDirs', 'noSkills', 'profile', 'includeReasoning', 'allowOverlap', 'stream',
57
62
  ];
58
63
 
@@ -159,6 +164,8 @@ function prepareSession(inv, env = process.env) {
159
164
  workspaceRoot: workspaceRoot(inv.cwd),
160
165
  owns: inv.ownsDisplay || [],
161
166
  ownsRoot: inv.owns || [],
167
+ touch: inv.touchDisplay || [],
168
+ preflight: Boolean(inv.preflight),
162
169
  checks: inv.checks || [],
163
170
  model: inv.model,
164
171
  effort: inv.effort,
@@ -210,7 +217,13 @@ async function execute(inv, opts = {}) {
210
217
  if (releaseSlot) releaseSlot();
211
218
  releaseOwner();
212
219
  };
220
+ // Resuming a preflight session proceeds to the real task: answers + the
221
+ // full protocol, checks and visual verification from here on.
222
+ const leavingPreflight = resuming && Boolean(inv.preflight);
223
+ if (leavingPreflight) inv.preflight = false;
224
+ const preflight = Boolean(inv.preflight);
213
225
  if (resuming) writeSettings(id, inv, env);
226
+ if (leavingPreflight) store.updateSession(id, { preflight: false }, env);
214
227
  const isFirstInvocation = !record.engineSessionId;
215
228
  const update = (patch) => store.updateSession(id, patch, env);
216
229
 
@@ -285,14 +298,19 @@ async function execute(inv, opts = {}) {
285
298
  }
286
299
 
287
300
  // --- lease ------------------------------------------------------------------
288
- const lease = acquireLease({ id, root, owns: inv.owns || [], allowOverlap: inv.allowOverlap, env });
301
+ // A preflight is read-only: it reserves nothing and conflicts with nobody.
302
+ const lease = preflight
303
+ ? { ok: true, conflicts: [], others: [], release() {} }
304
+ : acquireLease({ id, root, owns: inv.owns || [], touch: inv.touch || [], allowOverlap: inv.allowOverlap, env });
289
305
  if (!lease.ok) {
290
306
  const names = lease.conflicts
291
307
  .map((c) => `${c.id} (owns ${c.owns && c.owns.length ? c.owns.join(', ') : 'the whole workspace'})`)
292
308
  .join('; ');
293
- const message =
294
- `workspace conflict in ${root}: overlapping ownership with running session(s) ${names}. ` +
295
- 'Declare disjoint --owns paths, use a separate worktree, wait for them, or pass --allow-overlap.';
309
+ const message = lease.touch
310
+ ? `workspace conflict in ${root}: --touch ${(inv.touchDisplay || []).join(', ')} is owned by running session(s) ${names}. ` +
311
+ 'Wait for them, touch something else, or pass --allow-overlap.'
312
+ : `workspace conflict in ${root}: overlapping ownership with running session(s) ${names}. ` +
313
+ 'Declare disjoint --owns paths, use a separate worktree, wait for them, or pass --allow-overlap.';
296
314
  if (inv.adopt) {
297
315
  // A detached worker: the caller is polling this id, so leave a result.
298
316
  store.writeResult(id, {
@@ -323,6 +341,9 @@ async function execute(inv, opts = {}) {
323
341
  let subagentNote = null;
324
342
  let built = null;
325
343
  let before = null;
344
+ let releasePort = null;
345
+ let sessionEnv = null;
346
+ const visualDeclared = (inv.contract && inv.contract.visual) || [];
326
347
  const concurrent = new Map(lease.others.map((o) => [o.id, o.owns || []]));
327
348
  const checkTouched = new Set(); // files written by zcode's own check runs
328
349
  const invocationStart = new Date(started).toISOString();
@@ -331,6 +352,23 @@ async function execute(inv, opts = {}) {
331
352
  if (signal && signal.aborted) throw new TurnError('cancelled', { kind: 'cancelled' });
332
353
  if (!releaseSlot) throw new TurnError('run exceeded --timeout-ms while queued', { kind: 'timeout' });
333
354
 
355
+ // Per-session environment for the executor AND the checks: a port no
356
+ // other live session holds, plus output and scratch dirs of its own.
357
+ const reserved = await reservePort({ env, preferred: record.port });
358
+ if (!reserved) throw new ExecError('no free port for ZCODE_PORT in 41000-48999', { exitCode: 5, kind: 'no_port' });
359
+ releasePort = reserved.release;
360
+ const outDir = path.join(record.cwd, '.zcode', 'out', id);
361
+ const tmpDir = path.join(dir, 'tmp');
362
+ fs.mkdirSync(outDir, { recursive: true });
363
+ fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
364
+ sessionEnv = {
365
+ ZCODE_SESSION_ID: id,
366
+ ZCODE_PORT: String(reserved.port),
367
+ ZCODE_OUT: outDir,
368
+ ZCODE_TMP: tmpDir,
369
+ };
370
+ update({ port: reserved.port, out: outDir, tmp: tmpDir });
371
+
334
372
  built = buildEngineHome({
335
373
  home,
336
374
  root: rootDir(env),
@@ -338,6 +376,7 @@ async function execute(inv, opts = {}) {
338
376
  live: liveDir(env),
339
377
  mcpSpecs: inv.mcpSpecs || [],
340
378
  mcpInherit: inv.mcpInherit,
379
+ browser: !inv.noBrowser,
341
380
  skillsDirs: inv.skillsDirs || [],
342
381
  noSkills: inv.noSkills,
343
382
  });
@@ -347,7 +386,7 @@ async function execute(inv, opts = {}) {
347
386
  // nothing has been sent to the model yet).
348
387
  setPhase('starting');
349
388
  for (let attempt = 1; ; attempt++) {
350
- client = makeClient(runtime, { home, cwd: record.cwd });
389
+ client = makeClient(runtime, { home, cwd: record.cwd, sessionEnv });
351
390
  client.start();
352
391
  update({ enginePid: client.child.pid });
353
392
  try {
@@ -372,10 +411,21 @@ async function execute(inv, opts = {}) {
372
411
  // First prompt for this invocation.
373
412
  let prompt;
374
413
  if (inv.kind === 'task') {
375
- prompt = isFirstInvocation
376
- ? buildTaskPrompt(inv.contract, { ownsDisplay: inv.ownsDisplay || [] }) +
414
+ const taskPrompt = () => buildTaskPrompt(inv.contract, {
415
+ ownsDisplay: inv.ownsDisplay || [],
416
+ session: { id, port: sessionEnv.ZCODE_PORT, out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP },
417
+ preflight,
418
+ });
419
+ prompt = leavingPreflight
420
+ ? `${taskPrompt()}\n\n## Answers to your preflight questions\nThe preflight is over: implement the task now (edits are allowed). The orchestrator's answers are binding:\n${(inv.resume.message || '').trim() || '(none: take the most conservative choice for anything open and list it in handback `decisions`)'}`
421
+ : isFirstInvocation
422
+ ? taskPrompt() +
377
423
  (resuming && inv.resume.message ? `\n\n## Message from the orchestrator\n${inv.resume.message}` : '')
378
- : buildResumePrompt(inv.resume ? inv.resume.message : '', { kind: 'task' });
424
+ : buildResumePrompt(inv.resume ? inv.resume.message : '', {
425
+ kind: 'task',
426
+ // The port is the one thing that may have changed since the first prompt.
427
+ portChange: record.port && String(record.port) !== sessionEnv.ZCODE_PORT ? sessionEnv.ZCODE_PORT : null,
428
+ });
379
429
  } else if (inv.kind === 'agent') {
380
430
  prompt =
381
431
  `You MUST use the Agent tool (profileId: "${inv.profile}") to accomplish the ` +
@@ -386,6 +436,7 @@ async function execute(inv, opts = {}) {
386
436
  }
387
437
 
388
438
  let askedForHandback = false;
439
+ let askedForVisual = false;
389
440
  let phase = 'executing';
390
441
  for (;;) {
391
442
  setPhase(phase, phase === 'repairing' ? { attempt: repairs, of: inv.maxRepairs } : {});
@@ -411,28 +462,44 @@ async function execute(inv, opts = {}) {
411
462
  phase = 'reporting';
412
463
  continue;
413
464
  }
465
+ if (preflight) break; // read-only: zcode runs nothing
414
466
  if (parsed.handback && parsed.handback.status === 'blocked') break;
415
- if (!inv.checks.length) break;
416
-
417
- setPhase('checking', { count: inv.checks.length });
418
- const preChecks = before ? snapshot(root) : null;
419
- checks = await runChecks(inv.checks, {
420
- cwd: record.cwd,
421
- timeoutMs: Math.max(1000, Math.min(inv.checkTimeoutMs, deadline - Date.now())),
422
- signal,
423
- onEvent: relay,
424
- });
425
- if (preChecks) {
426
- for (const f of diffSnapshots(preChecks, snapshot(root)) || []) checkTouched.add(f.path);
427
- }
428
- if (Date.now() >= deadline && checks.some((c) => c.timedOut)) {
429
- throw new TurnError('run exceeded --timeout-ms during acceptance checks', { kind: 'timeout' });
467
+ if (!inv.checks.length && !visualDeclared.length) break;
468
+
469
+ if (inv.checks.length) {
470
+ setPhase('checking', { count: inv.checks.length });
471
+ const preChecks = before ? snapshot(root) : null;
472
+ checks = await runChecks(inv.checks, {
473
+ cwd: record.cwd,
474
+ env: { ...env, ...sessionEnv },
475
+ timeoutMs: Math.max(1000, Math.min(inv.checkTimeoutMs, deadline - Date.now())),
476
+ signal,
477
+ onEvent: relay,
478
+ });
479
+ if (preChecks) {
480
+ for (const f of diffSnapshots(preChecks, snapshot(root)) || []) checkTouched.add(f.path);
481
+ }
482
+ if (Date.now() >= deadline && checks.some((c) => c.timedOut)) {
483
+ throw new TurnError('run exceeded --timeout-ms during acceptance checks', { kind: 'timeout' });
484
+ }
430
485
  }
431
486
  const failures = checks.filter((c) => !c.ok);
432
- if (!failures.length || repairs >= inv.maxRepairs) break;
487
+ // Unmet visual criteria share the repair budget with failing checks.
488
+ const gaps = visualGaps(parsed.handback, visualDeclared);
489
+ // Criteria merely left out of the handback get one cheap report-only
490
+ // follow-up that does not consume the repair budget.
491
+ if (!failures.length && gaps.missing.length && !gaps.failed.length && !askedForVisual
492
+ && !(signal && signal.aborted) && Date.now() < deadline) {
493
+ askedForVisual = true;
494
+ prompt = buildVisualReportRequest(gaps.missing);
495
+ phase = 'reporting';
496
+ continue;
497
+ }
498
+ const visualUnmet = unmetVisual(parsed.handback, visualDeclared);
499
+ if ((!failures.length && !visualUnmet.length) || repairs >= inv.maxRepairs) break;
433
500
  if ((signal && signal.aborted) || Date.now() >= deadline) break;
434
501
  repairs++;
435
- prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs });
502
+ prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs, visual: visualUnmet });
436
503
  phase = 'repairing';
437
504
  }
438
505
 
@@ -450,6 +517,7 @@ async function execute(inv, opts = {}) {
450
517
  error = err;
451
518
  } finally {
452
519
  if (client) client.kill();
520
+ if (releasePort) releasePort();
453
521
  lease.release();
454
522
  releaseClaims();
455
523
  }
@@ -474,21 +542,38 @@ async function execute(inv, opts = {}) {
474
542
  const after = before ? snapshot(root) : null;
475
543
  const diff = before ? diffSnapshots(before, after) : null;
476
544
  const owns = inv.owns || [];
545
+ const touch = inv.touch || [];
477
546
  const files = (diff || [])
478
- .filter((f) => !f.path.startsWith('.zcode/'))
547
+ .filter((f) => !isZcodePath(f.path))
479
548
  .map((f) => {
480
549
  const owned = isOwned(f.path, owns);
481
- const by = owned
550
+ // --touch: edits allowed but reported, never a scope violation.
551
+ const touchedByDesign = !owned && touch.length > 0 && isOwned(f.path, touch);
552
+ const by = owned || touchedByDesign
482
553
  ? null
483
554
  : [...concurrent.entries()].find(([, o]) => o.length && isOwned(f.path, o));
484
- const fromChecks = !owned && !by && checkTouched.has(f.path);
485
- return { ...f, owned, ...(by ? { concurrentSession: by[0] } : {}), ...(fromChecks ? { fromChecks: true } : {}) };
555
+ const fromChecks = !owned && !touchedByDesign && !by && checkTouched.has(f.path);
556
+ return {
557
+ ...f,
558
+ owned,
559
+ ...(touchedByDesign ? { touched: true } : {}),
560
+ ...(by ? { concurrentSession: by[0] } : {}),
561
+ ...(fromChecks ? { fromChecks: true } : {}),
562
+ };
486
563
  });
564
+ const touched = files.filter((f) => f.touched).map((f) => f.path);
487
565
  const scopeViolations = files
488
- .filter((f) => !f.owned && !f.concurrentSession && !f.fromChecks)
566
+ .filter((f) => !f.owned && !f.touched && !f.concurrentSession && !f.fromChecks)
489
567
  .map((f) => f.path);
490
568
 
491
569
  const handback = parsed.handback;
570
+ // Preflight is read-only: anything it changed (that no concurrent session
571
+ // owns) is a protocol breach.
572
+ const preflightWrote = preflight
573
+ ? files
574
+ .filter((f) => ![...concurrent.values()].some((o) => o.length && isOwned(f.path, o)))
575
+ .map((f) => f.path)
576
+ : [];
492
577
  let { status, issues } = deriveStatus({
493
578
  kind: inv.kind,
494
579
  error: error && !cancelled && error.kind !== 'user_input' ? error : null,
@@ -497,12 +582,16 @@ async function execute(inv, opts = {}) {
497
582
  handback,
498
583
  handbackError: parsed.error,
499
584
  checks,
500
- scopeViolations,
585
+ scopeViolations: preflight ? [] : scopeViolations,
586
+ preflight,
587
+ preflightWrote,
588
+ visualDeclared,
501
589
  });
502
590
 
503
591
  let errorOut = null;
504
592
  if (status === 'failed') {
505
593
  if (error) errorOut = { kind: error.kind || 'error', message: scrub(error.message) };
594
+ else if (preflightWrote.length) errorOut = { kind: 'preflight_wrote', message: issues[0] };
506
595
  else if (checks.some((c) => !c.ok)) errorOut = { kind: 'checks_failed', message: issues[0] };
507
596
  } else if (status === 'cancelled') {
508
597
  errorOut = { kind: 'cancelled', message: 'cancelled' };
@@ -520,10 +609,19 @@ async function execute(inv, opts = {}) {
520
609
  }
521
610
 
522
611
  const notVerified = [];
523
- if (inv.kind === 'task') {
612
+ if (inv.kind === 'task' && preflight) {
613
+ notVerified.push('preflight: the questions come from reading only; nothing was built, run or checked');
614
+ } else if (inv.kind === 'task') {
524
615
  if (!inv.checks.length) notVerified.push('no acceptance checks were declared; nothing was executed by zcode to verify the work');
525
616
  if (inv.acceptanceDeclared) notVerified.push('prose acceptance criteria are confirmed only by the executor\'s own report');
526
617
  if (subagents && subagents.length) notVerified.push('subagent "success" means the subagent returned; only the integrated result is verified');
618
+ if (handback && handback.units.length) {
619
+ notVerified.push(`unit results are executor claims: ${handback.units.map((u) => `${u.name} ${u.status || '?'}`).join(', ')}; only the integrated result is verified`);
620
+ }
621
+ if (visualDeclared.length) {
622
+ const reported = handback ? handback.visual : [];
623
+ notVerified.push(`visual criteria are the executor's judgement from its own screenshots (${reported.filter((v) => v.met === true).length} of ${reported.length} reported met); zcode ran no pixel check`);
624
+ }
527
625
  }
528
626
  if (before && !diff) notVerified.push('changed files could not be tracked in this workspace');
529
627
  if (subagentNote) issues.push(subagentNote);
@@ -541,7 +639,9 @@ async function execute(inv, opts = {}) {
541
639
  id,
542
640
  kind: inv.kind,
543
641
  label: inv.label || null,
544
- summary: handback && handback.summary ? handback.summary : '',
642
+ summary: preflight && status === 'completed'
643
+ ? 'no open questions'
644
+ : handback && handback.summary ? handback.summary : '',
545
645
  response: inv.kind === 'task' ? parsed.report : response,
546
646
  issues,
547
647
  questions,
@@ -552,6 +652,14 @@ async function execute(inv, opts = {}) {
552
652
  files,
553
653
  },
554
654
  scopeViolations,
655
+ touched,
656
+ ...(preflight ? { preflight: true } : {}),
657
+ decisions: handback ? handback.decisions : [],
658
+ visual: handback ? handback.visual : [],
659
+ units: handback ? handback.units : [],
660
+ sessionEnv: sessionEnv
661
+ ? { port: Number(sessionEnv.ZCODE_PORT), out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP }
662
+ : null,
555
663
  checks: checks.map((c) => ({
556
664
  command: c.command,
557
665
  ok: c.ok,
package/lib/isolation.js CHANGED
@@ -142,8 +142,18 @@ function liveMcpServers(live = LIVE_ZCODE) {
142
142
  // base (specs still win by name). Hermetic default is deliberate: the
143
143
  // desktop set includes OAuth servers that can never complete headless and
144
144
  // stall the engine's connect pool.
145
- function effectiveMcpServers({ inherit = false, mcpSpecs = [], live = LIVE_ZCODE } = {}) {
146
- const servers = inherit ? liveMcpServers(live) : {};
145
+ // Exception: the desktop's local browser server (stdio, no OAuth) is always
146
+ // inherited, so browser prompts open the same Playwright window the desktop
147
+ // opens. --no-browser drops it.
148
+ const BROWSER_SERVERS = ['playwright'];
149
+
150
+ function effectiveMcpServers({ inherit = false, browser = true, mcpSpecs = [], live = LIVE_ZCODE } = {}) {
151
+ const liveSet = liveMcpServers(live);
152
+ const servers = inherit ? liveSet : {};
153
+ for (const name of BROWSER_SERVERS) {
154
+ if (!browser) delete servers[name];
155
+ else if (liveSet[name]) servers[name] = liveSet[name];
156
+ }
147
157
  for (const spec of mcpSpecs) {
148
158
  for (const [name, def] of Object.entries(spec)) servers[name] = def;
149
159
  }
@@ -200,6 +210,11 @@ function isDanglingLink(p) {
200
210
  // sources + this invocation's flags; engine state under the HOME (cli/db,
201
211
  // cli/agents, cli/rollout, logs, memories) is never touched, so a session
202
212
  // stays resumable across invocations. Returns facts for reporting.
213
+ // Desktop skills that drive the app's own UI (the built-in browser pane).
214
+ // The CLI cannot serve them, and loading one steers the agent away from
215
+ // Playwright into a dead end.
216
+ const HOST_ONLY_SKILLS = new Set(['control-browser']);
217
+
203
218
  function buildEngineHome({
204
219
  home,
205
220
  root,
@@ -207,6 +222,7 @@ function buildEngineHome({
207
222
  live = LIVE_ZCODE,
208
223
  mcpSpecs = [],
209
224
  mcpInherit = false,
225
+ browser = true,
210
226
  skillsDirs = [],
211
227
  noSkills = false,
212
228
  }) {
@@ -237,7 +253,7 @@ function buildEngineHome({
237
253
  zai.options = zai.options || {};
238
254
  zai.options.apiKey = key;
239
255
  }
240
- const servers = effectiveMcpServers({ inherit: mcpInherit, mcpSpecs, live });
256
+ const servers = effectiveMcpServers({ inherit: mcpInherit, browser, mcpSpecs, live });
241
257
  cfg.mcp = { ...(cfg.mcp || {}), servers };
242
258
  writeJson600(path.join(cliDir, 'config.json'), cfg);
243
259
 
@@ -268,6 +284,7 @@ function buildEngineHome({
268
284
  for (const entry of listDir(liveSkills)) {
269
285
  if (!entry.isDirectory()) continue;
270
286
  if (!fs.existsSync(path.join(liveSkills, entry.name, 'SKILL.md'))) continue;
287
+ if (HOST_ONLY_SKILLS.has(entry.name)) continue;
271
288
  fs.cpSync(path.join(liveSkills, entry.name), path.join(skillsDir, entry.name), {
272
289
  recursive: true,
273
290
  });
@@ -343,8 +360,22 @@ function migrateRoot(env = process.env) {
343
360
 
344
361
  // Env for the engine child process (§6.3): HOME=scratch, pinned builtin
345
362
  // config, and the uncontrolled overrides deleted. cwd is set by the caller.
346
- function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
363
+ // `sessionEnv` (ZCODE_SESSION_ID, ZCODE_PORT, …) is applied last so nothing
364
+ // inherited can shadow it; the engine's tools inherit it.
365
+ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env, sessionEnv = {} }) {
347
366
  const childEnv = { ...parentEnv };
367
+ const realHome = parentEnv.HOME || os.homedir();
368
+ // The scratch HOME hides the user's tool caches. Point Playwright and npm
369
+ // back at the real ones so installed browsers and packages are found.
370
+ if (!childEnv.PLAYWRIGHT_BROWSERS_PATH) {
371
+ const cache = process.platform === 'darwin'
372
+ ? path.join(realHome, 'Library', 'Caches', 'ms-playwright')
373
+ : path.join(realHome, '.cache', 'ms-playwright');
374
+ if (fs.existsSync(cache)) childEnv.PLAYWRIGHT_BROWSERS_PATH = cache;
375
+ }
376
+ if (!childEnv.npm_config_cache && !childEnv.NPM_CONFIG_CACHE) {
377
+ childEnv.npm_config_cache = path.join(realHome, '.npm');
378
+ }
348
379
  childEnv.HOME = home;
349
380
  childEnv.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = builtinProviderConfig;
350
381
  for (const name of [
@@ -356,6 +387,7 @@ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
356
387
  ]) {
357
388
  delete childEnv[name];
358
389
  }
390
+ Object.assign(childEnv, sessionEnv);
359
391
  return childEnv;
360
392
  }
361
393
 
package/lib/models.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // every engine request itself, so nothing outside this allowlist can reach
4
4
  // the wire.
5
5
 
6
+ const fs = require('node:fs');
7
+
6
8
  const DEFAULT_MODEL = 'GLM-5.3-Flash';
7
9
 
8
10
  // Case-insensitive aliases → official ids (exact ids pass through verbatim;
@@ -43,7 +45,89 @@ function unknownModelMessage(input) {
43
45
  );
44
46
  }
45
47
 
48
+ // --- supported effort levels per model ---------------------------------------
49
+ //
50
+ // The engine rejects an unsupported reasoning level only after it has been
51
+ // spawned and a session opened (GLM-5.3 refused `medium` that way in a real
52
+ // run). The levels live in the engine's builtin provider config
53
+ // (config/provider/zcode-builtin.json, the path `zcode doctor` reports):
54
+ // modelConfigRules.modelRules[] = {modelMatch: <regex>, config:
55
+ // {optionSpecs: {reasoningLevel: {values: […]}}}}. Later rules refine
56
+ // earlier ones (a generic `glm-5` rule lists disabled/enabled; the
57
+ // `glm-5.3(-flash)?` rule after it lists low/high/max), so the LAST matching
58
+ // rule that lists values wins. Unreadable or unmatched → the table below
59
+ // (observed in zcode-builtin.json shipped with ZCode 0.16.x).
60
+ const FALLBACK_LEVELS = {
61
+ 'GLM-5.3': ['low', 'high', 'max'],
62
+ 'GLM-5.3-Flash': ['low', 'high', 'max'],
63
+ };
64
+ const OFFICIAL_IDS = Object.keys(FALLBACK_LEVELS);
65
+
66
+ const levelCache = new Map(); // path:mtime:size → parsed rules
67
+
68
+ function readRules(file) {
69
+ try {
70
+ const st = fs.statSync(file);
71
+ const key = `${file}:${st.mtimeMs}:${st.size}`;
72
+ if (!levelCache.has(key)) {
73
+ const json = JSON.parse(fs.readFileSync(file, 'utf8'));
74
+ const rules = json && json.config && json.config.modelConfigRules && json.config.modelConfigRules.modelRules;
75
+ levelCache.set(key, Array.isArray(rules) ? rules : []);
76
+ }
77
+ return levelCache.get(key);
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function builtinConfigPath(env) {
84
+ try {
85
+ const { resolveEngine, resolveBuiltinProviderConfig } = require('./engine.js');
86
+ return resolveBuiltinProviderConfig(resolveEngine(env), env);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // Efforts (a subset of EFFORT_LEVELS, in enum order) the model accepts.
93
+ function supportedEfforts(model, { env = process.env, builtinConfig } = {}) {
94
+ const file = builtinConfig || builtinConfigPath(env);
95
+ let values = null;
96
+ if (file) {
97
+ for (const rule of readRules(file)) {
98
+ let match = false;
99
+ try {
100
+ match = new RegExp(`^(?:${rule.modelMatch})$`, 'i').test(model);
101
+ } catch {
102
+ /* a malformed pattern matches nothing */
103
+ }
104
+ const v = match && rule.config && rule.config.optionSpecs && rule.config.optionSpecs.reasoningLevel
105
+ && rule.config.optionSpecs.reasoningLevel.values;
106
+ if (Array.isArray(v)) values = v;
107
+ }
108
+ }
109
+ const levels = values ? EFFORT_LEVELS.filter((l) => values.includes(l)) : [];
110
+ return levels.length ? levels : FALLBACK_LEVELS[model] || [];
111
+ }
112
+
113
+ // {modelId: [levels]} for every supported model (doctor).
114
+ function modelLevels(opts) {
115
+ return Object.fromEntries(OFFICIAL_IDS.map((id) => [id, supportedEfforts(id, opts)]));
116
+ }
117
+
118
+ // null when `effort` is usable with `model`, else the one-line refusal.
119
+ // `source` names where the value came from ("--effort", "contract effort").
120
+ function effortProblem(model, effort, { source = '--effort', ...opts } = {}) {
121
+ const levels = supportedEfforts(model, opts);
122
+ if (levels.includes(effort)) return null;
123
+ return `${source} ${effort} is not supported by ${model} (supported: ${levels.join(', ')}).`;
124
+ }
125
+
46
126
  module.exports = {
127
+ supportedEfforts,
128
+ modelLevels,
129
+ effortProblem,
130
+ FALLBACK_LEVELS,
47
131
  DEFAULT_MODEL,
48
132
  normalizeModel,
49
133
  unknownModelMessage,