@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/README.md +81 -15
- package/bin/zcode.js +126 -20
- package/lib/appserver.js +16 -1
- 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 +101 -35
- package/lib/commands/mcp-list.js +1 -1
- package/lib/commands/plan.js +301 -0
- package/lib/commands/sessions.js +60 -11
- package/lib/commands/skills.js +39 -0
- package/lib/contract.js +220 -17
- package/lib/executor.js +142 -34
- package/lib/isolation.js +36 -4
- package/lib/models.js +84 -0
- package/lib/plan.js +424 -0
- package/lib/ports.js +45 -0
- package/lib/skills.js +64 -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 -2
- package/skills/README.md +9 -3
- package/skills/claude-code/zcode/SKILL.md +104 -74
- package/skills/codex/zcode/SKILL.md +104 -76
- package/lib/install-skills.js +0 -54
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
|
-
|
|
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),
|
|
@@ -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
|
-
|
|
376
|
-
|
|
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 : '', {
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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
|
|
547
|
+
.filter((f) => !isZcodePath(f.path))
|
|
479
548
|
.map((f) => {
|
|
480
549
|
const owned = isOwned(f.path, owns);
|
|
481
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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,
|