@0xgeorgemathew/zcode 0.3.2 → 0.4.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/lib/isolation.js CHANGED
@@ -1,93 +1,71 @@
1
1
  'use strict';
2
- // Scratch-HOME isolation (DESIGN §6): the engine runs with HOME pointed at a
3
- // copy we own (~/.zcode/home, mode 700) so the live ~/.zcode — which the
2
+ // Scratch-HOME isolation (DESIGN §6, reworked in 0.4): the engine runs with
3
+ // HOME pointed at a directory we own so the live ~/.zcode — which the
4
4
  // desktop app is actively using — is never written by us, and the user's
5
5
  // OAuth credentials.json is never copied.
6
6
  //
7
- // Invariant (§6.4): zcode performs no writes to the live ~/.zcode. The
8
- // fallback ladder NEVER re-adds credentials.json.
7
+ // 0.4: one engine HOME per zcode session (<root>/sessions/<id>/home) instead
8
+ // of one shared HOME. The shared HOME was rm -rf'd and rebuilt whenever a
9
+ // volatile desktop file (agents-state.json, bot-config.v3.json, …) changed
10
+ // mtime — which deleted the engine's session database under every other
11
+ // session and made --session resume 404 — and per-run MCP/skills overlays
12
+ // rewrote files that concurrent runs were reading. Per-session HOMEs give
13
+ // each session its own engine DB, config and skills; config files are
14
+ // refreshed in place on every invocation and engine state (cli/db,
15
+ // cli/agents, rollout, logs) is never deleted.
16
+ //
17
+ // Invariant (§6.4): zcode performs no writes to the live ~/.zcode, and
18
+ // credentials.json is never copied.
9
19
  const fs = require('node:fs');
10
20
  const path = require('node:path');
11
21
  const os = require('node:os');
12
- const crypto = require('node:crypto');
13
22
 
14
- const LIVE_ZCODE = path.join(os.homedir(), '.zcode');
23
+ // The desktop app's live directory. ZAI_CODE_LIVE_DIR points elsewhere
24
+ // (e.g. a second desktop profile, or a fixture in tests).
25
+ function liveDir(env = process.env) {
26
+ return env.ZAI_CODE_LIVE_DIR || path.join(os.homedir(), '.zcode');
27
+ }
28
+
29
+ const LIVE_ZCODE = liveDir({});
15
30
  const LIVE_V2 = path.join(LIVE_ZCODE, 'v2');
16
31
 
17
32
  // v2 top-level entries we deliberately never copy.
18
33
  const V2_EXCLUDE_FILES = new Set(['credentials.json', '.DS_Store']);
19
34
  function isV2Excluded(name) {
20
- return (
21
- V2_EXCLUDE_FILES.has(name) || name.startsWith('tasks-index.sqlite')
22
- );
35
+ return V2_EXCLUDE_FILES.has(name) || name.startsWith('tasks-index.sqlite');
23
36
  }
24
37
 
25
- const keySha256 = (key) =>
26
- crypto.createHash('sha256').update(key).digest('hex');
38
+ // Root of zcode's own state. MUST NOT be ~/.zcode: that is the desktop
39
+ // app's live directory (0.3.0/0.3.1 regression — the package rename made
40
+ // rootDir collide with it). Ours is ~/.zcode-cli.
41
+ function rootDir(env = process.env) {
42
+ return env.ZAI_CODE_HOME || path.join(os.homedir(), '.zcode-cli');
43
+ }
27
44
 
28
- // --- source inventory -------------------------------------------------------
45
+ // --- small fs helpers ---------------------------------------------------------
29
46
 
30
- // Files the scratch copy depends on: every source path + mtimeMs. A change
31
- // in the set, an mtime, or the key fingerprint invalidates the stamp.
32
- function sourceInventory() {
33
- const sources = [];
34
- const pushFile = (abs) => {
35
- try {
36
- sources.push({ file: abs, mtimeMs: fs.statSync(abs).mtimeMs });
37
- } catch {
38
- /* source vanished — not copied */
39
- }
40
- };
41
- try {
42
- for (const entry of fs.readdirSync(LIVE_V2, { withFileTypes: true })) {
43
- if (!entry.isFile() || isV2Excluded(entry.name)) continue;
44
- pushFile(path.join(LIVE_V2, entry.name));
45
- }
46
- } catch {
47
- /* no live v2 — synthesized path */
48
- }
49
- pushFile(path.join(LIVE_ZCODE, 'cli', 'config.json'));
50
- pushFile(path.join(LIVE_ZCODE, 'AGENTS.md'));
47
+ function writeJson600(file, value) {
48
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
49
+ fs.chmodSync(file, 0o600);
50
+ }
51
+
52
+ function readJson(file) {
51
53
  try {
52
- for (const entry of fs.readdirSync(path.join(LIVE_ZCODE, 'skills'), {
53
- withFileTypes: true,
54
- })) {
55
- if (entry.isDirectory()) {
56
- pushFile(path.join(LIVE_ZCODE, 'skills', entry.name, 'SKILL.md'));
57
- }
58
- }
54
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
59
55
  } catch {
60
- /* no skills dir */
56
+ return null;
61
57
  }
58
+ }
59
+
60
+ function listDir(dir) {
62
61
  try {
63
- for (const entry of fs.readdirSync(path.join(LIVE_ZCODE, 'agents'), {
64
- withFileTypes: true,
65
- })) {
66
- if (entry.isFile() && entry.name.endsWith('.md')) {
67
- pushFile(path.join(LIVE_ZCODE, 'agents', entry.name));
68
- }
69
- }
62
+ return fs.readdirSync(dir, { withFileTypes: true });
70
63
  } catch {
71
- /* no agents dir */
64
+ return [];
72
65
  }
73
- return sources;
74
66
  }
75
67
 
76
- // --- build ------------------------------------------------------------------
77
-
78
- function writeJson600(file, value) {
79
- fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', {
80
- mode: 0o600,
81
- });
82
- fs.chmodSync(file, 0o600);
83
- }
84
-
85
- function copyFileTo(src, destDir) {
86
- fs.mkdirSync(destDir, { recursive: true });
87
- const dest = path.join(destDir, path.basename(src));
88
- fs.copyFileSync(src, dest);
89
- return dest;
90
- }
68
+ // --- provider/key rewriting -----------------------------------------------------
91
69
 
92
70
  // Prune to exactly the zai-api rule and swap in the package key (§6.2 item 2).
93
71
  function rewriteProviderConfig(target, key) {
@@ -101,32 +79,16 @@ function rewriteProviderConfig(target, key) {
101
79
  }
102
80
  const zaiRules = rules.filter((r) => r && r.providerId === 'zai-api');
103
81
  if (zaiRules.length === 0) {
104
- throw new Error(
105
- `no zai-api rule found in provider_config.json at ${target}`
106
- );
82
+ throw new Error(`no zai-api rule found in provider_config.json at ${target}`);
107
83
  }
108
84
  const rule = zaiRules[0];
109
85
  rule.config = rule.config || {};
110
86
  rule.config.group = rule.config.group || 'standard-personal';
111
- rule.config.access = {
112
- type: 'api-key',
113
- apiKey: key,
114
- };
87
+ rule.config.access = { type: 'api-key', apiKey: key };
115
88
  parsed.config.providerConfigRules.providerRules = [rule];
116
89
  writeJson600(target, parsed);
117
90
  }
118
91
 
119
- // Overwrite builtin:zai apiKey with the package key (§6.2 item 3).
120
- function rewriteCliConfig(target, key) {
121
- const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
122
- const zai = parsed.provider && parsed.provider['builtin:zai'];
123
- if (zai) {
124
- zai.options = zai.options || {};
125
- zai.options.apiKey = key;
126
- }
127
- writeJson600(target, parsed);
128
- }
129
-
130
92
  function synthesizedProviderConfig(key) {
131
93
  return {
132
94
  schemaVersion: 1,
@@ -147,130 +109,231 @@ function synthesizedProviderConfig(key) {
147
109
  };
148
110
  }
149
111
 
150
- // Tier (a), the review-proven recipe: top-level v2 files, no directories, no
151
- // credentials.json, no sqlite. Fresh machines (no live v2) synthesize instead.
152
- function buildScratch(home, key) {
153
- fs.rmSync(home, { recursive: true, force: true });
154
- const scratchV2 = path.join(home, '.zcode', 'v2');
155
- fs.mkdirSync(scratchV2, { recursive: true });
112
+ // --- skills -------------------------------------------------------------------
156
113
 
157
- let mode = 'synthesized';
158
- let copied = 0;
159
- try {
160
- const entries = fs.readdirSync(LIVE_V2, { withFileTypes: true });
161
- const files = entries.filter(
162
- (e) => e.isFile() && !isV2Excluded(e.name)
163
- );
164
- if (files.length > 0 || fs.existsSync(path.join(LIVE_V2, 'provider_config.json'))) {
165
- mode = 'copied-v2-top-level';
166
- }
167
- for (const entry of files) {
168
- const dest = copyFileTo(path.join(LIVE_V2, entry.name), scratchV2);
169
- if (entry.name === 'provider_config.json') {
170
- rewriteProviderConfig(dest, key);
114
+ // A --skills dir is either one skill (<dir>/SKILL.md) or a skills root
115
+ // (<dir>/<name>/SKILL.md). Returns [{name, dir}] of copyable skills.
116
+ function discoverSkillDirs(dir) {
117
+ if (fs.existsSync(path.join(dir, 'SKILL.md'))) {
118
+ return [{ name: path.basename(path.resolve(dir)), dir }];
119
+ }
120
+ return listDir(dir)
121
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')))
122
+ .map((e) => ({ name: e.name, dir: path.join(dir, e.name) }));
123
+ }
124
+
125
+ // --- MCP ----------------------------------------------------------------------
126
+
127
+ // The live desktop MCP servers, read-only, honoring `enabled`.
128
+ function liveMcpServers(live = LIVE_ZCODE) {
129
+ const cfg = readJson(path.join(live, 'cli', 'config.json'));
130
+ const servers = cfg && cfg.mcp && cfg.mcp.servers;
131
+ if (!servers || typeof servers !== 'object') return {};
132
+ const out = {};
133
+ for (const [name, def] of Object.entries(servers)) {
134
+ if (!def || typeof def !== 'object' || def.enabled === false) continue;
135
+ out[name] = def;
136
+ }
137
+ return out;
138
+ }
139
+
140
+ // Effective set for one invocation. Hermetic by default: ONLY the caller's
141
+ // --mcp servers. --mcp-inherit pulls in the desktop's enabled servers as a
142
+ // base (specs still win by name). Hermetic default is deliberate: the
143
+ // desktop set includes OAuth servers that can never complete headless and
144
+ // stall the engine's connect pool.
145
+ function effectiveMcpServers({ inherit = false, mcpSpecs = [], live = LIVE_ZCODE } = {}) {
146
+ const servers = inherit ? liveMcpServers(live) : {};
147
+ for (const spec of mcpSpecs) {
148
+ for (const [name, def] of Object.entries(spec)) servers[name] = def;
149
+ }
150
+ return servers;
151
+ }
152
+
153
+ // --- shared caches ------------------------------------------------------------
154
+
155
+ // Engine-populated caches that are expensive to recreate per session (the
156
+ // plugin marketplace cache is ~30 MB) are shared through symlinks into
157
+ // <root>/shared. A pre-0.4 shared HOME donates its caches once.
158
+ const SHARED_CACHES = [
159
+ { name: 'plugins', rel: path.join('.zcode', 'cli', 'plugins') },
160
+ { name: 'runtime', rel: path.join('.zcode', 'v2', 'runtime') },
161
+ ];
162
+
163
+ function linkSharedCaches(home, root) {
164
+ for (const cache of SHARED_CACHES) {
165
+ const shared = path.join(root, 'shared', cache.name);
166
+ if (!fs.existsSync(shared)) {
167
+ const legacy = path.join(root, 'home', cache.rel);
168
+ fs.mkdirSync(path.dirname(shared), { recursive: true, mode: 0o700 });
169
+ try {
170
+ if (fs.existsSync(legacy) && !fs.lstatSync(legacy).isSymbolicLink()) {
171
+ fs.renameSync(legacy, shared);
172
+ }
173
+ } catch {
174
+ /* fall through to an empty cache */
171
175
  }
172
- copied++;
176
+ fs.mkdirSync(shared, { recursive: true, mode: 0o700 });
177
+ }
178
+ const target = path.join(home, cache.rel);
179
+ if (fs.existsSync(target) || isDanglingLink(target)) continue;
180
+ fs.mkdirSync(path.dirname(target), { recursive: true });
181
+ try {
182
+ fs.symlinkSync(shared, target, 'dir');
183
+ } catch {
184
+ /* engine recreates the cache itself */
173
185
  }
186
+ }
187
+ }
188
+
189
+ function isDanglingLink(p) {
190
+ try {
191
+ return fs.lstatSync(p).isSymbolicLink() && !fs.existsSync(p);
174
192
  } catch {
175
- /* no live v2 at all */
193
+ return false;
176
194
  }
195
+ }
177
196
 
178
- const scratchProviderConfig = path.join(scratchV2, 'provider_config.json');
179
- if (!fs.existsSync(scratchProviderConfig)) {
180
- writeJson600(scratchProviderConfig, synthesizedProviderConfig(key));
181
- } else {
182
- // Ensure key swap even when the copy step found an unexpected shape.
183
- rewriteProviderConfig(scratchProviderConfig, key);
197
+ // --- build ------------------------------------------------------------------
198
+
199
+ // Creates or refreshes one engine HOME. Config is (re)written from the live
200
+ // sources + this invocation's flags; engine state under the HOME (cli/db,
201
+ // cli/agents, cli/rollout, logs, memories) is never touched, so a session
202
+ // stays resumable across invocations. Returns facts for reporting.
203
+ function buildEngineHome({
204
+ home,
205
+ root,
206
+ key,
207
+ live = LIVE_ZCODE,
208
+ mcpSpecs = [],
209
+ mcpInherit = false,
210
+ skillsDirs = [],
211
+ noSkills = false,
212
+ }) {
213
+ const zdir = path.join(home, '.zcode');
214
+ const v2 = path.join(zdir, 'v2');
215
+ const cliDir = path.join(zdir, 'cli');
216
+ fs.mkdirSync(v2, { recursive: true, mode: 0o700 });
217
+ fs.mkdirSync(cliDir, { recursive: true, mode: 0o700 });
218
+ fs.chmodSync(home, 0o700);
219
+
220
+ // v2: top-level files only, no credentials.json, no sqlite (§6.2 item 1).
221
+ const liveV2 = path.join(live, 'v2');
222
+ const v2Files = listDir(liveV2).filter((e) => e.isFile() && !isV2Excluded(e.name));
223
+ for (const entry of v2Files) {
224
+ fs.copyFileSync(path.join(liveV2, entry.name), path.join(v2, entry.name));
184
225
  }
226
+ const providerConfig = path.join(v2, 'provider_config.json');
227
+ const mode = v2Files.some((e) => e.name === 'provider_config.json')
228
+ ? 'copied-v2-top-level'
229
+ : 'synthesized';
230
+ if (mode === 'synthesized') writeJson600(providerConfig, synthesizedProviderConfig(key));
231
+ else rewriteProviderConfig(providerConfig, key);
185
232
 
186
- const liveCliConfig = path.join(LIVE_ZCODE, 'cli', 'config.json');
187
- if (fs.existsSync(liveCliConfig)) {
188
- const dest = copyFileTo(liveCliConfig, path.join(home, '.zcode', 'cli'));
189
- rewriteCliConfig(dest, key);
233
+ // cli/config.json: live copy (builtin:zai key swapped) + this run's MCP set.
234
+ const cfg = readJson(path.join(live, 'cli', 'config.json')) || {};
235
+ const zai = cfg.provider && cfg.provider['builtin:zai'];
236
+ if (zai) {
237
+ zai.options = zai.options || {};
238
+ zai.options.apiKey = key;
190
239
  }
240
+ const servers = effectiveMcpServers({ inherit: mcpInherit, mcpSpecs, live });
241
+ cfg.mcp = { ...(cfg.mcp || {}), servers };
242
+ writeJson600(path.join(cliDir, 'config.json'), cfg);
191
243
 
192
- const liveAgents = path.join(LIVE_ZCODE, 'agents');
193
- if (fs.existsSync(liveAgents)) {
194
- for (const entry of fs.readdirSync(liveAgents, { withFileTypes: true })) {
195
- if (entry.isFile() && entry.name.endsWith('.md')) {
196
- copyFileTo(path.join(liveAgents, entry.name), path.join(home, '.zcode', 'agents'));
197
- }
198
- }
244
+ // Agent profiles + AGENTS.md: mirror the live set exactly.
245
+ const agentsDir = path.join(zdir, 'agents');
246
+ fs.rmSync(agentsDir, { recursive: true, force: true });
247
+ for (const entry of listDir(path.join(live, 'agents'))) {
248
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
249
+ fs.mkdirSync(agentsDir, { recursive: true });
250
+ fs.copyFileSync(path.join(live, 'agents', entry.name), path.join(agentsDir, entry.name));
199
251
  }
200
- const liveAgentsMd = path.join(LIVE_ZCODE, 'AGENTS.md');
201
- if (fs.existsSync(liveAgentsMd)) {
202
- copyFileTo(liveAgentsMd, path.join(home, '.zcode'));
252
+ const agentsMd = path.join(zdir, 'AGENTS.md');
253
+ if (fs.existsSync(path.join(live, 'AGENTS.md'))) {
254
+ fs.copyFileSync(path.join(live, 'AGENTS.md'), agentsMd);
255
+ } else {
256
+ fs.rmSync(agentsMd, { force: true });
203
257
  }
204
258
 
205
- // Skills: copy each <name>/SKILL.md dir from the live user scope so the
206
- // engine (running under this scratch HOME) sees the same skills as the
207
- // desktop. Project skills (<cwd>/.zcode/skills) need no copy — the engine
208
- // reads them from the real workspace.
259
+ // Skills: live user skills (unless --no-skills) + this run's --skills.
260
+ // Project skills (<cwd>/.zcode/skills) need no copy — the engine reads
261
+ // them from the real workspace.
262
+ const skillsDir = path.join(zdir, 'skills');
263
+ fs.rmSync(skillsDir, { recursive: true, force: true });
264
+ fs.mkdirSync(skillsDir, { recursive: true });
209
265
  let skillsCopied = 0;
210
- const liveSkills = path.join(LIVE_ZCODE, 'skills');
211
- if (fs.existsSync(liveSkills)) {
212
- for (const entry of fs.readdirSync(liveSkills, { withFileTypes: true })) {
266
+ if (!noSkills) {
267
+ const liveSkills = path.join(live, 'skills');
268
+ for (const entry of listDir(liveSkills)) {
213
269
  if (!entry.isDirectory()) continue;
214
270
  if (!fs.existsSync(path.join(liveSkills, entry.name, 'SKILL.md'))) continue;
215
- fs.cpSync(
216
- path.join(liveSkills, entry.name),
217
- path.join(home, '.zcode', 'skills', entry.name),
218
- { recursive: true }
219
- );
271
+ fs.cpSync(path.join(liveSkills, entry.name), path.join(skillsDir, entry.name), {
272
+ recursive: true,
273
+ });
220
274
  skillsCopied++;
221
275
  }
222
276
  }
277
+ const skillsInjected = [];
278
+ if (!noSkills) {
279
+ for (const dir of skillsDirs) {
280
+ for (const { name, dir: skillDir } of discoverSkillDirs(dir)) {
281
+ fs.rmSync(path.join(skillsDir, name), { recursive: true, force: true });
282
+ fs.cpSync(skillDir, path.join(skillsDir, name), { recursive: true });
283
+ skillsInjected.push(name);
284
+ }
285
+ }
286
+ }
223
287
 
224
- return { mode, copied, skillsCopied };
225
- }
226
-
227
- // --- public API -------------------------------------------------------------
288
+ if (root) linkSharedCaches(home, root);
228
289
 
229
- // Root of zcode's own state. MUST NOT be ~/.zcode: that is the desktop
230
- // app's live directory (0.3.0/0.3.1 regression — the package rename made
231
- // rootDir collide with it, so login/scratch files landed inside the live
232
- // config). Ours is ~/.zcode-cli.
233
- function rootDir(env = process.env) {
234
- return env.ZAI_CODE_HOME || path.join(os.homedir(), '.zcode-cli');
290
+ return {
291
+ home,
292
+ mode,
293
+ copiedFiles: v2Files.length,
294
+ skillsCopied,
295
+ skillsInjected,
296
+ mcp: {
297
+ servers,
298
+ // Names the caller explicitly added — the CLI waits for these to
299
+ // settle before sending the prompt. Inherited servers are best-effort.
300
+ added: mcpSpecs.flatMap((spec) => Object.keys(spec)),
301
+ },
302
+ };
235
303
  }
236
304
 
237
- // The only file/dir names this package ever creates at its root.
238
- // migrateRoot moves exactly these out of legacy locations and never
239
- // touches anything else.
305
+ // --- migration ----------------------------------------------------------------
306
+
307
+ // The only names pre-0.4 versions created at a root. migrateRoot moves
308
+ // exactly these out of legacy locations and never touches anything else.
240
309
  const OUR_ARTIFACTS = ['credentials.json', 'build-stamp.json', 'injected-skills.json', 'home'];
241
310
 
242
311
  // 0.3.2 migration: vacate our artifacts from (a) the live ~/.zcode, where
243
312
  // 0.3.0/0.3.1 wrongly wrote them, and (b) the pre-rename ~/.zai-code root,
244
- // into the new ~/.zcode-cli root. Idempotent; never writes or deletes
245
- // anything else in the live directory.
313
+ // into the ~/.zcode-cli root. Idempotent; never writes or deletes anything
314
+ // else in the live directory.
246
315
  function migrateRoot(env = process.env) {
247
316
  const root = rootDir(env);
248
317
  try {
249
- fs.mkdirSync(root, { recursive: true });
318
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
250
319
  } catch {
251
- return; // unwritable home — leave everything as-is; ensureIsolation surfaces it
320
+ return; // unwritable home — callers surface it
252
321
  }
253
- const sources = [
254
- LIVE_ZCODE, // live dir: move ours out, keep everything else
255
- path.join(os.homedir(), '.zai-code'), // pre-rename root: wholly ours
256
- ];
322
+ const sources = [liveDir(env), path.join(os.homedir(), '.zai-code')];
257
323
  for (const dir of sources) {
258
324
  let entries;
259
325
  try {
260
326
  entries = new Set(fs.readdirSync(dir));
261
327
  } catch {
262
- continue; // nothing to migrate from this source
328
+ continue;
263
329
  }
264
330
  for (const name of OUR_ARTIFACTS) {
265
331
  if (!entries.has(name)) continue;
266
332
  const src = path.join(dir, name);
267
333
  const dest = path.join(root, name);
268
334
  try {
269
- if (fs.existsSync(dest)) {
270
- fs.rmSync(src, { recursive: true, force: true });
271
- } else {
272
- fs.renameSync(src, dest);
273
- }
335
+ if (fs.existsSync(dest)) fs.rmSync(src, { recursive: true, force: true });
336
+ else fs.renameSync(src, dest);
274
337
  } catch {
275
338
  /* keep going; a missing artifact just rebuilds */
276
339
  }
@@ -278,64 +341,8 @@ function migrateRoot(env = process.env) {
278
341
  }
279
342
  }
280
343
 
281
- // Ensures the scratch HOME exists and is current. Returns
282
- // {root, home, mode, built, stamp}.
283
- function ensureIsolation(key, { fresh = false, env = process.env } = {}) {
284
- migrateRoot(env);
285
- const root = rootDir(env);
286
- fs.mkdirSync(root, { recursive: true });
287
- fs.chmodSync(root, 0o700);
288
- const home = path.join(root, 'home');
289
- const stampFile = path.join(root, 'build-stamp.json');
290
-
291
- const sources = sourceInventory();
292
- const fingerprint = keySha256(key);
293
- const desired = {
294
- version: 1,
295
- keySha256: fingerprint,
296
- sources,
297
- };
298
-
299
- if (!fresh && fs.existsSync(home) && fs.existsSync(stampFile)) {
300
- try {
301
- const stamp = JSON.parse(fs.readFileSync(stampFile, 'utf8'));
302
- const sameSources =
303
- Array.isArray(stamp.sources) &&
304
- stamp.sources.length === desired.sources.length &&
305
- stamp.sources.every(
306
- (s, i) =>
307
- s.file === desired.sources[i].file && s.mtimeMs === desired.sources[i].mtimeMs
308
- );
309
- if (stamp.keySha256 === fingerprint && sameSources) {
310
- return {
311
- root,
312
- home,
313
- mode: stamp.mode,
314
- built: false,
315
- stamp,
316
- };
317
- }
318
- } catch {
319
- /* corrupt stamp — rebuild */
320
- }
321
- }
322
-
323
- const { mode, copied, skillsCopied } = buildScratch(home, key);
324
- fs.chmodSync(home, 0o700);
325
- const stamp = {
326
- ...desired,
327
- mode,
328
- copiedFiles: copied,
329
- skillsCopied,
330
- builtAt: new Date().toISOString(),
331
- };
332
- writeJson600(stampFile, stamp);
333
- return { root, home, mode, built: true, stamp };
334
- }
335
-
336
344
  // Env for the engine child process (§6.3): HOME=scratch, pinned builtin
337
- // config, and the uncontrolled overrides deleted. cwd is untouched here —
338
- // the caller sets it to the user's real workspace.
345
+ // config, and the uncontrolled overrides deleted. cwd is set by the caller.
339
346
  function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
340
347
  const childEnv = { ...parentEnv };
341
348
  childEnv.HOME = home;
@@ -353,11 +360,14 @@ function engineEnv({ home, builtinProviderConfig, parentEnv = process.env }) {
353
360
  }
354
361
 
355
362
  module.exports = {
356
- ensureIsolation,
363
+ buildEngineHome,
357
364
  engineEnv,
358
365
  rootDir,
366
+ liveDir,
359
367
  migrateRoot,
360
- keySha256,
368
+ effectiveMcpServers,
369
+ liveMcpServers,
370
+ discoverSkillDirs,
361
371
  LIVE_ZCODE,
362
372
  LIVE_V2,
363
373
  };
package/lib/key.js CHANGED
@@ -5,16 +5,7 @@
5
5
  // in this package.
6
6
  const fs = require('node:fs');
7
7
  const path = require('node:path');
8
- const os = require('node:os');
9
- const { migrateRoot } = require('./isolation.js');
10
-
11
- // Root of zcode's own state (scratch HOME lives under <root>/home).
12
- // MUST NOT be ~/.zcode — that is the desktop app's live directory the
13
- // engine itself owns (0.3.0/0.3.1 regression: the rename made the CLI
14
- // write credentials and the scratch HOME straight into it).
15
- function rootDir(env = process.env) {
16
- return env.ZAI_CODE_HOME || path.join(os.homedir(), '.zcode-cli');
17
- }
8
+ const { migrateRoot, rootDir } = require('./isolation.js');
18
9
 
19
10
  function credentialsPath(env = process.env) {
20
11
  return path.join(rootDir(env), 'credentials.json');