@0xgeorgemathew/zcode 0.4.4 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -13
- package/bin/zcode.js +119 -19
- package/lib/bootstrap.js +2 -2
- package/lib/brief.js +69 -0
- package/lib/checks.js +3 -2
- package/lib/commands/doctor.js +3 -0
- package/lib/commands/exec.js +100 -35
- package/lib/commands/plan.js +301 -0
- package/lib/commands/sessions.js +60 -11
- package/lib/contract.js +220 -17
- package/lib/executor.js +146 -34
- package/lib/isolation.js +4 -1
- package/lib/models.js +84 -0
- package/lib/plan.js +424 -0
- package/lib/ports.js +45 -0
- package/lib/store.js +50 -0
- package/lib/taskspec.js +107 -0
- package/lib/ui.js +7 -2
- package/lib/workspace.js +24 -7
- package/package.json +1 -1
- package/skills/README.md +1 -1
- package/skills/claude-code/zcode/SKILL.md +104 -74
- package/skills/codex/zcode/SKILL.md +104 -76
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', 'noBrowser',
|
|
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
|
-
|
|
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}:
|
|
295
|
-
|
|
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),
|
|
@@ -348,7 +386,7 @@ async function execute(inv, opts = {}) {
|
|
|
348
386
|
// nothing has been sent to the model yet).
|
|
349
387
|
setPhase('starting');
|
|
350
388
|
for (let attempt = 1; ; attempt++) {
|
|
351
|
-
client = makeClient(runtime, { home, cwd: record.cwd });
|
|
389
|
+
client = makeClient(runtime, { home, cwd: record.cwd, sessionEnv });
|
|
352
390
|
client.start();
|
|
353
391
|
update({ enginePid: client.child.pid });
|
|
354
392
|
try {
|
|
@@ -373,10 +411,21 @@ async function execute(inv, opts = {}) {
|
|
|
373
411
|
// First prompt for this invocation.
|
|
374
412
|
let prompt;
|
|
375
413
|
if (inv.kind === 'task') {
|
|
376
|
-
|
|
377
|
-
|
|
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() +
|
|
378
423
|
(resuming && inv.resume.message ? `\n\n## Message from the orchestrator\n${inv.resume.message}` : '')
|
|
379
|
-
: buildResumePrompt(inv.resume ? inv.resume.message : '', {
|
|
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
|
+
});
|
|
380
429
|
} else if (inv.kind === 'agent') {
|
|
381
430
|
prompt =
|
|
382
431
|
`You MUST use the Agent tool (profileId: "${inv.profile}") to accomplish the ` +
|
|
@@ -387,6 +436,7 @@ async function execute(inv, opts = {}) {
|
|
|
387
436
|
}
|
|
388
437
|
|
|
389
438
|
let askedForHandback = false;
|
|
439
|
+
let askedForVisual = false;
|
|
390
440
|
let phase = 'executing';
|
|
391
441
|
for (;;) {
|
|
392
442
|
setPhase(phase, phase === 'repairing' ? { attempt: repairs, of: inv.maxRepairs } : {});
|
|
@@ -404,6 +454,10 @@ async function execute(inv, opts = {}) {
|
|
|
404
454
|
if (inv.kind !== 'task') break;
|
|
405
455
|
|
|
406
456
|
const next = parseHandback(turn.response);
|
|
457
|
+
// A report-only visual follow-up that comes back without a valid block
|
|
458
|
+
// keeps the earlier (valid) handback instead of losing it.
|
|
459
|
+
const visualReport = phase === 'reporting' && askedForVisual;
|
|
460
|
+
if (visualReport && !next.handback && parsed.handback) break;
|
|
407
461
|
// A handback-only follow-up keeps the substantive report from before.
|
|
408
462
|
parsed = { ...next, report: next.report || parsed.report };
|
|
409
463
|
if (parsed.error && !askedForHandback && Date.now() < deadline) {
|
|
@@ -412,28 +466,45 @@ async function execute(inv, opts = {}) {
|
|
|
412
466
|
phase = 'reporting';
|
|
413
467
|
continue;
|
|
414
468
|
}
|
|
469
|
+
if (preflight) break; // read-only: zcode runs nothing
|
|
415
470
|
if (parsed.handback && parsed.handback.status === 'blocked') break;
|
|
416
|
-
if (!inv.checks.length) break;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
471
|
+
if (!inv.checks.length && !visualDeclared.length) break;
|
|
472
|
+
|
|
473
|
+
// The visual report-only turn changes nothing: the checks already passed.
|
|
474
|
+
if (inv.checks.length && !visualReport) {
|
|
475
|
+
setPhase('checking', { count: inv.checks.length });
|
|
476
|
+
const preChecks = before ? snapshot(root) : null;
|
|
477
|
+
checks = await runChecks(inv.checks, {
|
|
478
|
+
cwd: record.cwd,
|
|
479
|
+
env: { ...env, ...sessionEnv },
|
|
480
|
+
timeoutMs: Math.max(1000, Math.min(inv.checkTimeoutMs, deadline - Date.now())),
|
|
481
|
+
signal,
|
|
482
|
+
onEvent: relay,
|
|
483
|
+
});
|
|
484
|
+
if (preChecks) {
|
|
485
|
+
for (const f of diffSnapshots(preChecks, snapshot(root)) || []) checkTouched.add(f.path);
|
|
486
|
+
}
|
|
487
|
+
if (Date.now() >= deadline && checks.some((c) => c.timedOut)) {
|
|
488
|
+
throw new TurnError('run exceeded --timeout-ms during acceptance checks', { kind: 'timeout' });
|
|
489
|
+
}
|
|
431
490
|
}
|
|
432
491
|
const failures = checks.filter((c) => !c.ok);
|
|
433
|
-
|
|
492
|
+
// Unmet visual criteria share the repair budget with failing checks.
|
|
493
|
+
const gaps = visualGaps(parsed.handback, visualDeclared);
|
|
494
|
+
// Criteria merely left out of the handback get one cheap report-only
|
|
495
|
+
// follow-up that does not consume the repair budget.
|
|
496
|
+
if (!failures.length && gaps.missing.length && !gaps.failed.length && !askedForVisual
|
|
497
|
+
&& !(signal && signal.aborted) && Date.now() < deadline) {
|
|
498
|
+
askedForVisual = true;
|
|
499
|
+
prompt = buildVisualReportRequest(gaps.missing);
|
|
500
|
+
phase = 'reporting';
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const visualUnmet = unmetVisual(parsed.handback, visualDeclared);
|
|
504
|
+
if ((!failures.length && !visualUnmet.length) || repairs >= inv.maxRepairs) break;
|
|
434
505
|
if ((signal && signal.aborted) || Date.now() >= deadline) break;
|
|
435
506
|
repairs++;
|
|
436
|
-
prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs });
|
|
507
|
+
prompt = buildRepairPrompt(failures, { attempt: repairs, maxRepairs: inv.maxRepairs, visual: visualUnmet });
|
|
437
508
|
phase = 'repairing';
|
|
438
509
|
}
|
|
439
510
|
|
|
@@ -451,6 +522,7 @@ async function execute(inv, opts = {}) {
|
|
|
451
522
|
error = err;
|
|
452
523
|
} finally {
|
|
453
524
|
if (client) client.kill();
|
|
525
|
+
if (releasePort) releasePort();
|
|
454
526
|
lease.release();
|
|
455
527
|
releaseClaims();
|
|
456
528
|
}
|
|
@@ -475,21 +547,38 @@ async function execute(inv, opts = {}) {
|
|
|
475
547
|
const after = before ? snapshot(root) : null;
|
|
476
548
|
const diff = before ? diffSnapshots(before, after) : null;
|
|
477
549
|
const owns = inv.owns || [];
|
|
550
|
+
const touch = inv.touch || [];
|
|
478
551
|
const files = (diff || [])
|
|
479
|
-
.filter((f) => !f.path
|
|
552
|
+
.filter((f) => !isZcodePath(f.path))
|
|
480
553
|
.map((f) => {
|
|
481
554
|
const owned = isOwned(f.path, owns);
|
|
482
|
-
|
|
555
|
+
// --touch: edits allowed but reported, never a scope violation.
|
|
556
|
+
const touchedByDesign = !owned && touch.length > 0 && isOwned(f.path, touch);
|
|
557
|
+
const by = owned || touchedByDesign
|
|
483
558
|
? null
|
|
484
559
|
: [...concurrent.entries()].find(([, o]) => o.length && isOwned(f.path, o));
|
|
485
|
-
const fromChecks = !owned && !by && checkTouched.has(f.path);
|
|
486
|
-
return {
|
|
560
|
+
const fromChecks = !owned && !touchedByDesign && !by && checkTouched.has(f.path);
|
|
561
|
+
return {
|
|
562
|
+
...f,
|
|
563
|
+
owned,
|
|
564
|
+
...(touchedByDesign ? { touched: true } : {}),
|
|
565
|
+
...(by ? { concurrentSession: by[0] } : {}),
|
|
566
|
+
...(fromChecks ? { fromChecks: true } : {}),
|
|
567
|
+
};
|
|
487
568
|
});
|
|
569
|
+
const touched = files.filter((f) => f.touched).map((f) => f.path);
|
|
488
570
|
const scopeViolations = files
|
|
489
|
-
.filter((f) => !f.owned && !f.concurrentSession && !f.fromChecks)
|
|
571
|
+
.filter((f) => !f.owned && !f.touched && !f.concurrentSession && !f.fromChecks)
|
|
490
572
|
.map((f) => f.path);
|
|
491
573
|
|
|
492
574
|
const handback = parsed.handback;
|
|
575
|
+
// Preflight is read-only: anything it changed (that no concurrent session
|
|
576
|
+
// owns) is a protocol breach.
|
|
577
|
+
const preflightWrote = preflight
|
|
578
|
+
? files
|
|
579
|
+
.filter((f) => ![...concurrent.values()].some((o) => o.length && isOwned(f.path, o)))
|
|
580
|
+
.map((f) => f.path)
|
|
581
|
+
: [];
|
|
493
582
|
let { status, issues } = deriveStatus({
|
|
494
583
|
kind: inv.kind,
|
|
495
584
|
error: error && !cancelled && error.kind !== 'user_input' ? error : null,
|
|
@@ -498,12 +587,16 @@ async function execute(inv, opts = {}) {
|
|
|
498
587
|
handback,
|
|
499
588
|
handbackError: parsed.error,
|
|
500
589
|
checks,
|
|
501
|
-
scopeViolations,
|
|
590
|
+
scopeViolations: preflight ? [] : scopeViolations,
|
|
591
|
+
preflight,
|
|
592
|
+
preflightWrote,
|
|
593
|
+
visualDeclared,
|
|
502
594
|
});
|
|
503
595
|
|
|
504
596
|
let errorOut = null;
|
|
505
597
|
if (status === 'failed') {
|
|
506
598
|
if (error) errorOut = { kind: error.kind || 'error', message: scrub(error.message) };
|
|
599
|
+
else if (preflightWrote.length) errorOut = { kind: 'preflight_wrote', message: issues[0] };
|
|
507
600
|
else if (checks.some((c) => !c.ok)) errorOut = { kind: 'checks_failed', message: issues[0] };
|
|
508
601
|
} else if (status === 'cancelled') {
|
|
509
602
|
errorOut = { kind: 'cancelled', message: 'cancelled' };
|
|
@@ -521,10 +614,19 @@ async function execute(inv, opts = {}) {
|
|
|
521
614
|
}
|
|
522
615
|
|
|
523
616
|
const notVerified = [];
|
|
524
|
-
if (inv.kind === 'task') {
|
|
617
|
+
if (inv.kind === 'task' && preflight) {
|
|
618
|
+
notVerified.push('preflight: the questions come from reading only; nothing was built, run or checked');
|
|
619
|
+
} else if (inv.kind === 'task') {
|
|
525
620
|
if (!inv.checks.length) notVerified.push('no acceptance checks were declared; nothing was executed by zcode to verify the work');
|
|
526
621
|
if (inv.acceptanceDeclared) notVerified.push('prose acceptance criteria are confirmed only by the executor\'s own report');
|
|
527
622
|
if (subagents && subagents.length) notVerified.push('subagent "success" means the subagent returned; only the integrated result is verified');
|
|
623
|
+
if (handback && handback.units.length) {
|
|
624
|
+
notVerified.push(`unit results are executor claims: ${handback.units.map((u) => `${u.name} ${u.status || '?'}`).join(', ')}; only the integrated result is verified`);
|
|
625
|
+
}
|
|
626
|
+
if (visualDeclared.length) {
|
|
627
|
+
const reported = handback ? handback.visual : [];
|
|
628
|
+
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`);
|
|
629
|
+
}
|
|
528
630
|
}
|
|
529
631
|
if (before && !diff) notVerified.push('changed files could not be tracked in this workspace');
|
|
530
632
|
if (subagentNote) issues.push(subagentNote);
|
|
@@ -542,7 +644,9 @@ async function execute(inv, opts = {}) {
|
|
|
542
644
|
id,
|
|
543
645
|
kind: inv.kind,
|
|
544
646
|
label: inv.label || null,
|
|
545
|
-
summary:
|
|
647
|
+
summary: preflight && status === 'completed'
|
|
648
|
+
? 'no open questions'
|
|
649
|
+
: handback && handback.summary ? handback.summary : '',
|
|
546
650
|
response: inv.kind === 'task' ? parsed.report : response,
|
|
547
651
|
issues,
|
|
548
652
|
questions,
|
|
@@ -553,6 +657,14 @@ async function execute(inv, opts = {}) {
|
|
|
553
657
|
files,
|
|
554
658
|
},
|
|
555
659
|
scopeViolations,
|
|
660
|
+
touched,
|
|
661
|
+
...(preflight ? { preflight: true } : {}),
|
|
662
|
+
decisions: handback ? handback.decisions : [],
|
|
663
|
+
visual: handback ? handback.visual : [],
|
|
664
|
+
units: handback ? handback.units : [],
|
|
665
|
+
sessionEnv: sessionEnv
|
|
666
|
+
? { port: Number(sessionEnv.ZCODE_PORT), out: sessionEnv.ZCODE_OUT, tmp: sessionEnv.ZCODE_TMP }
|
|
667
|
+
: null,
|
|
556
668
|
checks: checks.map((c) => ({
|
|
557
669
|
command: c.command,
|
|
558
670
|
ok: c.ok,
|
package/lib/isolation.js
CHANGED
|
@@ -360,7 +360,9 @@ function migrateRoot(env = process.env) {
|
|
|
360
360
|
|
|
361
361
|
// Env for the engine child process (§6.3): HOME=scratch, pinned builtin
|
|
362
362
|
// config, and the uncontrolled overrides deleted. cwd is set by the caller.
|
|
363
|
-
|
|
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 = {} }) {
|
|
364
366
|
const childEnv = { ...parentEnv };
|
|
365
367
|
const realHome = parentEnv.HOME || os.homedir();
|
|
366
368
|
// The scratch HOME hides the user's tool caches. Point Playwright and npm
|
|
@@ -385,6 +387,7 @@ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
|
|
|
385
387
|
]) {
|
|
386
388
|
delete childEnv[name];
|
|
387
389
|
}
|
|
390
|
+
Object.assign(childEnv, sessionEnv);
|
|
388
391
|
return childEnv;
|
|
389
392
|
}
|
|
390
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,
|