@0xgeorgemathew/zcode 0.3.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.
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+ // lib/commands/agent.js — subagent-dispatching turn, verified programmatically
3
+ // (DESIGN §11): dispatch-forcing preamble, yolo run, then session/subagents
4
+ // RPC joined with the scratch agents dir. Never trusts the model's prose;
5
+ // exit 6 when no subagent ran or none ended "success".
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const { executeTurn, TurnError } = require('../appserver.js');
9
+ const { bootstrap } = require('../bootstrap.js');
10
+
11
+ function readJsonSafe(file) {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ // Join session/subagents ended items with <home>/.zcode/cli/agents/<parent>/
20
+ // agent_<uuid>/{metadata.json,output.txt} (all under the scratch HOME we own).
21
+ function collectSubagents({ client, sessionId, isolationHome, scrub }) {
22
+ return client
23
+ .request('session/subagents', { sessionId }, { timeoutMs: 15000 })
24
+ .catch((err) => {
25
+ throw new TurnError(`session/subagents failed: ${scrub(err.message)}`, {
26
+ kind: 'subagents_rpc',
27
+ });
28
+ })
29
+ .then((result) => {
30
+ const items = (result && result.ended && result.ended.items) || [];
31
+ const running = (result && result.running) || [];
32
+ const agentsDir = path.join(
33
+ isolationHome,
34
+ '.zcode',
35
+ 'cli',
36
+ 'agents',
37
+ sessionId
38
+ );
39
+ const out = [];
40
+ for (const item of items) {
41
+ const dir = path.join(agentsDir, `agent_${(item.agentId || '').replace(/^agent_/, '')}`);
42
+ const metadata =
43
+ readJsonSafe(path.join(dir, 'metadata.json')) || null;
44
+ let output = null;
45
+ try {
46
+ output = fs.readFileSync(path.join(dir, 'output.txt'), 'utf8');
47
+ } catch {
48
+ /* subagent may not have flushed output */
49
+ }
50
+ out.push({
51
+ agentId: item.agentId || (metadata && metadata.agentId) || null,
52
+ childSessionId:
53
+ item.childSessionId || (metadata && metadata.childSessionId) || null,
54
+ profileId: (metadata && metadata.profileId) || item.subagentType || null,
55
+ title: item.title || (metadata && metadata.description) || null,
56
+ status: item.status || null,
57
+ summary: item.summary || null,
58
+ output,
59
+ startedAt: item.startedAt || null,
60
+ endedAt: item.endedAt || null,
61
+ durationMs:
62
+ item.startedAt && item.endedAt ? item.endedAt - item.startedAt : null,
63
+ });
64
+ }
65
+ return { subagents: out, runningCount: running.length };
66
+ });
67
+ }
68
+
69
+ module.exports = async function agent(parsed) {
70
+ const flags = parsed.flags;
71
+ const instructions = parsed.prompts.join(' ');
72
+ if (!instructions.trim()) {
73
+ process.stderr.write('zcode: agent needs instructions.\n');
74
+ process.exit(2);
75
+ }
76
+ const profile = flags.profile || 'general-purpose';
77
+ const prompt =
78
+ `You MUST use the Agent tool (profileId: "${profile}") to accomplish the ` +
79
+ `task below — do not do the work yourself. When the subagent finishes, ` +
80
+ `report its agent id and result.\n\nTask: ${instructions}`;
81
+
82
+ const { client, scrub, isolation, overlay } = bootstrap({
83
+ fresh: flags.fresh,
84
+ mcpSpecs: flags.mcpSpecs,
85
+ mcpInherit: flags.mcpInherit,
86
+ skillsDirs: flags.skillsDirs,
87
+ noSkills: flags.noSkills,
88
+ });
89
+ client.start();
90
+ process.on('SIGINT', () => {
91
+ client.kill();
92
+ process.exit(130);
93
+ });
94
+ const reporter = require('../ui.js').makePlainReporter({ quiet: flags.quiet });
95
+ try {
96
+ const turn = await executeTurn(client, {
97
+ workspacePath: flags.cwd,
98
+ modelId: flags.model,
99
+ effort: flags.effort,
100
+ prompt,
101
+ timeoutMs: flags.timeoutMs,
102
+ resumeSessionId: flags.session || null,
103
+ requiredMcp: overlay.mcp.added,
104
+ onEvent: (ev) => {
105
+ if (flags.json) process.stdout.write(JSON.stringify(ev) + '\n');
106
+ else reporter.onEvent(ev);
107
+ },
108
+ });
109
+
110
+ const { subagents, runningCount } = await collectSubagents({
111
+ client,
112
+ sessionId: turn.sessionId,
113
+ isolationHome: isolation.home,
114
+ scrub,
115
+ });
116
+
117
+ const ok = subagents.length >= 1 && subagents.every((s) => s.status === 'success');
118
+
119
+ if (flags.json) {
120
+ process.stdout.write(
121
+ JSON.stringify({
122
+ type: 'result',
123
+ ok,
124
+ response: turn.response,
125
+ model: turn.engineModel || flags.model,
126
+ modelRequested: flags.model,
127
+ effort: turn.engineReasoningLevel || flags.effort,
128
+ effortRequested: flags.effort,
129
+ mode: turn.mode,
130
+ sessionId: turn.sessionId,
131
+ resumed: turn.resumed,
132
+ usage: turn.usage
133
+ ? {
134
+ inputTokens: turn.usage.inputTokens ?? null,
135
+ outputTokens: turn.usage.outputTokens ?? null,
136
+ }
137
+ : null,
138
+ reasoningChars: turn.reasoningText.length,
139
+ toolCalls: turn.toolCallCount,
140
+ mcpServers: Object.keys(overlay.mcp.servers),
141
+ skillsInjected: overlay.skills.injected,
142
+ subagents,
143
+ runningSubagents: runningCount,
144
+ durationMs: turn.durationMs,
145
+ }) + '\n'
146
+ );
147
+ } else if (!ok) {
148
+ process.stdout.write(turn.response + '\n');
149
+ } else {
150
+ process.stdout.write(turn.response + '\n');
151
+ reporter.footer({
152
+ model: turn.engineModel || flags.model,
153
+ effort: turn.engineReasoningLevel || flags.effort,
154
+ toolCalls: turn.toolCallCount,
155
+ durationMs: turn.durationMs,
156
+ sessionId: turn.sessionId,
157
+ });
158
+ for (const s of subagents) {
159
+ const secs = typeof s.durationMs === 'number' ? ` ${(s.durationMs / 1000).toFixed(1)}s` : '';
160
+ process.stderr.write(
161
+ (process.stderr.isTTY ? '\x1b[2m' : '') +
162
+ `✓ subagent ${s.profileId}${secs}` +
163
+ (process.stderr.isTTY ? '\x1b[0m' : '') + '\n'
164
+ );
165
+ if (s.summary) {
166
+ process.stderr.write(
167
+ (process.stderr.isTTY ? '\x1b[2m' : '') +
168
+ ` ${s.summary.replace(/\s+/g, ' ').trim().slice(0, 200)}` +
169
+ (process.stderr.isTTY ? '\x1b[0m' : '') + '\n'
170
+ );
171
+ }
172
+ }
173
+ }
174
+ client.kill();
175
+ if (!ok) {
176
+ const why =
177
+ subagents.length === 0
178
+ ? 'no subagent was dispatched'
179
+ : `subagent statuses: ${subagents.map((s) => s.status).join(', ')}`;
180
+ process.stderr.write(`zcode: agent verification failed: ${why}\n`);
181
+ process.exit(6);
182
+ }
183
+ process.exit(0);
184
+ } catch (err) {
185
+ client.kill();
186
+ if (flags.json) {
187
+ process.stdout.write(
188
+ JSON.stringify({
189
+ type: 'result',
190
+ ok: false,
191
+ error: err.kind || 'error',
192
+ message: scrub(err.message),
193
+ }) + '\n'
194
+ );
195
+ }
196
+ if (err instanceof TurnError && err.kind === 'timeout') {
197
+ process.stderr.write(`zcode: ${scrub(err.message)}\n`);
198
+ process.exit(124);
199
+ }
200
+ process.stderr.write(`zcode: agent turn failed: ${scrub(err.message)}\n`);
201
+ process.exit(5);
202
+ }
203
+ }
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+ // lib/commands/doctor.js — engine + isolation + key report (DESIGN §10).
3
+ // Never prints the key. Warns when the spawning node major < 26 (R11).
4
+ const {
5
+ resolveEngine,
6
+ resolveBuiltinProviderConfig,
7
+ sha256File,
8
+ engineVersion,
9
+ EngineNotFoundError,
10
+ BuiltinConfigNotFoundError,
11
+ } = require('../engine.js');
12
+ const { resolveKey, NoKeyError, rootDir } = require('../key.js');
13
+ const { ensureIsolation, engineEnv } = require('../isolation.js');
14
+ const { effectiveMcpServers } = require('../overlay.js');
15
+
16
+ module.exports = async function doctor(parsed) {
17
+ const flags = parsed.flags;
18
+ let enginePath;
19
+ try {
20
+ enginePath = resolveEngine(process.env);
21
+ } catch (err) {
22
+ if (err instanceof EngineNotFoundError) {
23
+ process.stderr.write(`zcode: ${err.message}\n`);
24
+ process.exit(4);
25
+ }
26
+ throw err;
27
+ }
28
+
29
+ let key;
30
+ try {
31
+ key = resolveKey(process.env);
32
+ } catch (err) {
33
+ if (err instanceof NoKeyError) {
34
+ process.stderr.write(`zcode: ${err.message}\n`);
35
+ process.exit(3);
36
+ }
37
+ throw err;
38
+ }
39
+
40
+ let builtinConfig;
41
+ try {
42
+ builtinConfig = resolveBuiltinProviderConfig(enginePath, process.env);
43
+ } catch (err) {
44
+ if (err instanceof BuiltinConfigNotFoundError) {
45
+ process.stderr.write(`zcode: ${err.message}\n`);
46
+ process.exit(4);
47
+ }
48
+ throw err;
49
+ }
50
+
51
+ const isolation = ensureIsolation(key, { fresh: flags.fresh, env: process.env });
52
+ const nodeBin = process.env.ZAI_CODE_NODE || process.execPath;
53
+ const childEnv = engineEnv({
54
+ home: isolation.home,
55
+ builtinProviderConfig: builtinConfig,
56
+ });
57
+ const version = engineVersion(nodeBin, enginePath, childEnv);
58
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
59
+ const nodeWarning = nodeMajor < 26;
60
+
61
+ const report = {
62
+ ok: true,
63
+ node: {
64
+ version: process.versions.node,
65
+ major: nodeMajor,
66
+ engineBin: nodeBin,
67
+ recommendation:
68
+ nodeMajor < 26
69
+ ? 'engine has only been tested under node >= 26; set ZAI_CODE_NODE to a newer interpreter'
70
+ : 'ok',
71
+ warning: nodeWarning,
72
+ },
73
+ engine: {
74
+ path: enginePath,
75
+ sha256: sha256File(enginePath),
76
+ version,
77
+ builtinProviderConfig: builtinConfig,
78
+ },
79
+ isolation: {
80
+ root: isolation.root,
81
+ home: isolation.home,
82
+ mode: isolation.mode,
83
+ builtNow: isolation.built,
84
+ skillsCopied:
85
+ (isolation.stamp && typeof isolation.stamp.skillsCopied === 'number'
86
+ ? isolation.stamp.skillsCopied
87
+ : 0) || 0,
88
+ mcpServersInherited: Object.keys(effectiveMcpServers({})).length,
89
+ experimental:
90
+ isolation.mode === 'synthesized'
91
+ ? 'synthesized config path is unproven; copy a real ~/.zcode/v2 or verify once on a clean HOME'
92
+ : null,
93
+ },
94
+ key: { configured: true },
95
+ };
96
+
97
+ if (flags.json) {
98
+ process.stdout.write(JSON.stringify(report) + '\n');
99
+ } else {
100
+ process.stdout.write(
101
+ [
102
+ `zcode doctor`,
103
+ ` node ${report.node.version} (${nodeBin})${nodeWarning ? ' ⚠ ' + report.node.recommendation : ''}`,
104
+ ` engine ${enginePath}`,
105
+ ` sha256 ${report.engine.sha256}`,
106
+ ` version ${version ?? 'unknown'}`,
107
+ ` builtin provider config ${builtinConfig}`,
108
+ ` isolation home ${isolation.home} (mode: ${isolation.mode}${isolation.built ? ', built now' : ', cached'})`,
109
+ ` skills inherited ${report.isolation.skillsCopied}, mcp servers available with --mcp-inherit ${report.isolation.mcpServersInherited}`,
110
+ ` api key configured (never printed)`,
111
+ report.isolation.experimental ? ` ⚠ ${report.isolation.experimental}` : '',
112
+ ]
113
+ .filter(Boolean)
114
+ .join('\n') + '\n'
115
+ );
116
+ }
117
+ process.exit(0);
118
+ };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ // lib/commands/login.js — read the API key from stdin and store it at
3
+ // <root>/credentials.json (mode 600, root mode 700) (DESIGN §5, §10).
4
+ // The key is never echoed. Output deliberately contains no key material.
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { rootDir } = require('../key.js');
8
+
9
+ module.exports = async function login() {
10
+ if (process.stdin.isTTY) {
11
+ process.stderr.write(
12
+ 'zcode login: paste your API key on stdin (it will not be echoed back):\n'
13
+ );
14
+ }
15
+ let input = '';
16
+ await new Promise((resolve) => {
17
+ process.stdin.setEncoding('utf8');
18
+ process.stdin.on('data', (c) => (input += c));
19
+ process.stdin.on('end', resolve);
20
+ process.stdin.on('error', resolve);
21
+ if (process.stdin.isTTY) process.stdin.resume();
22
+ });
23
+ const key = input.trim();
24
+ if (!key) {
25
+ process.stderr.write('zcode login: no key received on stdin.\n');
26
+ process.exit(2);
27
+ }
28
+
29
+ const root = rootDir(process.env);
30
+ fs.mkdirSync(root, { recursive: true });
31
+ fs.chmodSync(root, 0o700);
32
+ const file = path.join(root, 'credentials.json');
33
+ fs.writeFileSync(file, JSON.stringify({ apiKey: key }, null, 2) + '\n', {
34
+ mode: 0o600,
35
+ });
36
+ fs.chmodSync(file, 0o600);
37
+ // Invalidate the isolation stamp: the scratch key must be rebuilt from the
38
+ // new credentials on the next run (key fingerprint change triggers it, but
39
+ // be explicit when the credentials file is the source).
40
+ const stamp = path.join(root, 'build-stamp.json');
41
+ try {
42
+ fs.rmSync(stamp, { force: true });
43
+ } catch {
44
+ /* nothing to invalidate */
45
+ }
46
+ process.stdout.write(`zcode login: key stored at ${file} (mode 600)\n`);
47
+ process.exit(0);
48
+ };
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+ // lib/commands/mcp-list.js — print the effective MCP server set for this
3
+ // invocation as one JSON line and exit 0. Never spawns the engine; the
4
+ // value is exactly what a run with the same flags would register.
5
+ const { bootstrap } = require('../bootstrap.js');
6
+
7
+ module.exports = async function mcpList(parsed) {
8
+ const flags = parsed.flags;
9
+ const { overlay } = bootstrap({
10
+ fresh: flags.fresh,
11
+ mcpSpecs: flags.mcpSpecs,
12
+ mcpInherit: flags.mcpInherit,
13
+ skillsDirs: flags.skillsDirs,
14
+ noSkills: flags.noSkills,
15
+ });
16
+ process.stdout.write(
17
+ JSON.stringify({ type: 'mcp', servers: overlay.mcp.servers }) + '\n'
18
+ );
19
+ process.exit(0);
20
+ };
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+ // lib/commands/run.js — one-shot turn (DESIGN §9, §10), agent-first: NDJSON
3
+ // stream on stdout with --json (incl. reasoning_delta = thinking and tool
4
+ // lifecycle), reasoning mirrored to stderr live in plain mode, session
5
+ // continuity via --session, MCP/skills overlays applied per run.
6
+ const { executeTurn, TurnError } = require('../appserver.js');
7
+ const { bootstrap } = require('../bootstrap.js');
8
+ const { makePlainReporter } = require('../ui.js');
9
+
10
+ // Prompt: positional argv joined, else stdin (when piped).
11
+ function readPrompt(parsed) {
12
+ if (parsed.prompts.length > 0) return parsed.prompts.join(' ');
13
+ if (!process.stdin.isTTY) {
14
+ return require('node:fs').readFileSync(0, 'utf8');
15
+ }
16
+ return '';
17
+ }
18
+
19
+ module.exports = async function run(parsed) {
20
+ const flags = parsed.flags;
21
+ const prompt = readPrompt(parsed);
22
+ if (!prompt.trim()) {
23
+ process.stderr.write('zcode: no prompt given (positional args or stdin).\n');
24
+ process.exit(2);
25
+ }
26
+
27
+ const { client, scrub, overlay } = bootstrap({
28
+ fresh: flags.fresh,
29
+ mcpSpecs: flags.mcpSpecs,
30
+ mcpInherit: flags.mcpInherit,
31
+ skillsDirs: flags.skillsDirs,
32
+ noSkills: flags.noSkills,
33
+ });
34
+ const workspacePath = flags.cwd;
35
+ let streamedText = '';
36
+ const reporter = makePlainReporter({ quiet: flags.quiet });
37
+ const onEvent = flags.json
38
+ ? (ev) => process.stdout.write(JSON.stringify(ev) + '\n')
39
+ : (ev) => {
40
+ if (ev.type === 'stream' && ev.kind === 'text_delta' && ev.delta) {
41
+ reporter.closeThinking();
42
+ streamedText += ev.delta;
43
+ process.stdout.write(ev.delta);
44
+ } else {
45
+ reporter.onEvent(ev);
46
+ }
47
+ };
48
+
49
+ client.start();
50
+ process.on('SIGINT', () => {
51
+ client.kill();
52
+ process.exit(130);
53
+ });
54
+ try {
55
+ const turn = await executeTurn(client, {
56
+ workspacePath,
57
+ modelId: flags.model,
58
+ effort: flags.effort,
59
+ prompt,
60
+ timeoutMs: flags.timeoutMs,
61
+ resumeSessionId: flags.session || null,
62
+ requiredMcp: overlay.mcp.added,
63
+ onEvent,
64
+ });
65
+ if (flags.json) {
66
+ const result = {
67
+ type: 'result',
68
+ ok: true,
69
+ response: turn.response,
70
+ model: turn.engineModel || flags.model,
71
+ modelRequested: flags.model,
72
+ effort: turn.engineReasoningLevel || flags.effort,
73
+ effortRequested: flags.effort,
74
+ mode: turn.mode,
75
+ sessionId: turn.sessionId,
76
+ resumed: turn.resumed,
77
+ usage: turn.usage
78
+ ? {
79
+ inputTokens: turn.usage.inputTokens ?? null,
80
+ outputTokens: turn.usage.outputTokens ?? null,
81
+ }
82
+ : null,
83
+ reasoningChars: turn.reasoningText.length,
84
+ toolCalls: turn.toolCallCount,
85
+ toolEvents: turn.toolEvents,
86
+ mcpServers: Object.keys(overlay.mcp.servers),
87
+ skillsInjected: overlay.skills.injected,
88
+ terminalStatus: turn.terminalStatus,
89
+ streamingEvents: turn.streamingEvents,
90
+ durationMs: turn.durationMs,
91
+ };
92
+ if (flags.includeReasoning) result.reasoning = turn.reasoningText;
93
+ process.stdout.write(JSON.stringify(result) + '\n');
94
+ } else {
95
+ reporter.footer({
96
+ model: turn.engineModel || flags.model,
97
+ effort: turn.engineReasoningLevel || flags.effort,
98
+ toolCalls: turn.toolCallCount,
99
+ durationMs: turn.durationMs,
100
+ sessionId: turn.sessionId,
101
+ });
102
+ if (!streamedText.trim()) process.stdout.write(turn.response);
103
+ process.stdout.write('\n');
104
+ }
105
+ client.kill();
106
+ process.exit(0);
107
+ } catch (err) {
108
+ client.kill();
109
+ if (flags.json) {
110
+ process.stdout.write(
111
+ JSON.stringify({
112
+ type: 'result',
113
+ ok: false,
114
+ error: err.kind || 'error',
115
+ message: scrub(err.message),
116
+ }) + '\n'
117
+ );
118
+ }
119
+ if (err instanceof TurnError && err.kind === 'timeout') {
120
+ process.stderr.write(`zcode: ${scrub(err.message)}\n`);
121
+ process.exit(124);
122
+ }
123
+ process.stderr.write(`zcode: turn failed: ${scrub(err.message)}\n`);
124
+ process.exit(5);
125
+ }
126
+ };
package/lib/engine.js ADDED
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+ // Engine resolution + builtin provider config (DESIGN §4).
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const crypto = require('node:crypto');
7
+ const { spawnSync } = require('node:child_process');
8
+
9
+ const APP_BUNDLE_ENGINE =
10
+ '/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs';
11
+
12
+ class EngineNotFoundError extends Error {
13
+ constructor(probed) {
14
+ super(
15
+ `ZCode engine not found. Probed:\n ${probed.join('\n ')}\n` +
16
+ 'Set ZAI_CODE_ENGINE to the zcode.cjs bundle path.'
17
+ );
18
+ this.code = 'ZAI_NO_ENGINE';
19
+ this.probed = probed;
20
+ }
21
+ }
22
+
23
+ class BuiltinConfigNotFoundError extends Error {
24
+ constructor(probed) {
25
+ super(
26
+ `ZCode builtin provider config not found. Probed:\n ${probed.join('\n ')}\n` +
27
+ 'Set ZAI_CODE_BUILTIN_PROVIDER_CONFIG to the zcode-builtin.json path.'
28
+ );
29
+ this.code = 'ZAI_NO_BUILTIN_CONFIG';
30
+ this.probed = probed;
31
+ }
32
+ }
33
+
34
+ // Resolves a candidate that is either the zcode.cjs file itself or a
35
+ // directory containing it. Returns an absolute file path or null.
36
+ function resolveCandidate(candidate) {
37
+ let st;
38
+ try {
39
+ st = fs.statSync(candidate);
40
+ } catch {
41
+ return null;
42
+ }
43
+ if (st.isFile()) return path.resolve(candidate);
44
+ if (st.isDirectory()) {
45
+ const inner = path.join(candidate, 'zcode.cjs');
46
+ try {
47
+ if (fs.statSync(inner).isFile()) return path.resolve(inner);
48
+ } catch {
49
+ /* not there */
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function whichOnPath(bin, envPath) {
56
+ if (!envPath) return null;
57
+ for (const dir of envPath.split(path.delimiter)) {
58
+ if (!dir) continue;
59
+ const candidate = path.join(dir, bin);
60
+ try {
61
+ if (fs.statSync(candidate).isFile()) return candidate;
62
+ } catch {
63
+ /* keep scanning */
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ // First hit wins (DESIGN §4). Throws EngineNotFoundError listing all probes.
70
+ function resolveEngine(env = process.env) {
71
+ const probed = [];
72
+ const candidates = [];
73
+ if (env.ZAI_CODE_ENGINE) candidates.push(env.ZAI_CODE_ENGINE);
74
+ const onPath = whichOnPath('zcode', env.PATH);
75
+ if (onPath) candidates.push(onPath);
76
+ candidates.push(path.join(os.homedir(), '.local', 'bin', 'zcode'));
77
+ candidates.push(path.join(os.homedir(), '.zcode', 'runtime', 'zcode.cjs'));
78
+ candidates.push(APP_BUNDLE_ENGINE);
79
+ for (const candidate of candidates) {
80
+ const resolved = resolveCandidate(candidate);
81
+ if (resolved) return resolved;
82
+ probed.push(candidate);
83
+ }
84
+ throw new EngineNotFoundError(probed);
85
+ }
86
+
87
+ // Engine-adjacent builtin provider config, or env override. Throws
88
+ // BuiltinConfigNotFoundError with the probed paths.
89
+ function resolveBuiltinProviderConfig(enginePath, env = process.env) {
90
+ const probed = [];
91
+ const candidates = [];
92
+ if (env.ZAI_CODE_BUILTIN_PROVIDER_CONFIG) {
93
+ candidates.push(env.ZAI_CODE_BUILTIN_PROVIDER_CONFIG);
94
+ }
95
+ candidates.push(
96
+ path.resolve(
97
+ path.dirname(enginePath),
98
+ '..',
99
+ 'config',
100
+ 'provider',
101
+ 'zcode-builtin.json'
102
+ )
103
+ );
104
+ for (const candidate of candidates) {
105
+ try {
106
+ if (fs.statSync(candidate).isFile()) return path.resolve(candidate);
107
+ } catch {
108
+ /* fall through */
109
+ }
110
+ probed.push(candidate);
111
+ }
112
+ throw new BuiltinConfigNotFoundError(probed);
113
+ }
114
+
115
+ function sha256File(file) {
116
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
117
+ }
118
+
119
+ // `zcode.cjs version` run under the ISOLATED env (never the live HOME).
120
+ // Returns trimmed stdout or null.
121
+ function engineVersion(nodeBin, enginePath, childEnv) {
122
+ const res = spawnSync(nodeBin, [enginePath, 'version'], {
123
+ env: childEnv,
124
+ encoding: 'utf8',
125
+ timeout: 20000,
126
+ });
127
+ if (res.error || res.status !== 0) return null;
128
+ return (res.stdout || '').trim() || null;
129
+ }
130
+
131
+ module.exports = {
132
+ resolveEngine,
133
+ resolveBuiltinProviderConfig,
134
+ sha256File,
135
+ engineVersion,
136
+ EngineNotFoundError,
137
+ BuiltinConfigNotFoundError,
138
+ APP_BUNDLE_ENGINE,
139
+ };