external-subagent 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/bin/external-subagent-debug-mcp.mjs +5 -0
  4. package/bin/external-subagent-debug.mjs +13 -0
  5. package/bin/external-subagent-mcp.mjs +20 -0
  6. package/bin/external-subagent.mjs +8 -0
  7. package/bin/zas.mjs +8 -0
  8. package/cli/commands/agents.mjs +117 -0
  9. package/cli/commands/config.mjs +117 -0
  10. package/cli/commands/daemon.mjs +36 -0
  11. package/cli/commands/maintenance.mjs +27 -0
  12. package/cli/commands/plugin.mjs +96 -0
  13. package/cli/commands/tasks.mjs +64 -0
  14. package/cli/commands/update.mjs +219 -0
  15. package/cli/config/read.mjs +24 -0
  16. package/cli/config/schema.mjs +75 -0
  17. package/cli/config/write.mjs +39 -0
  18. package/cli/constants.mjs +24 -0
  19. package/cli/errors.mjs +7 -0
  20. package/cli/fs-atomic.mjs +36 -0
  21. package/cli/install/codex.mjs +352 -0
  22. package/cli/install/init.mjs +206 -0
  23. package/cli/install/layout.mjs +54 -0
  24. package/cli/install/npm-hook.mjs +49 -0
  25. package/cli/install/path.mjs +43 -0
  26. package/cli/install/payload.mjs +84 -0
  27. package/cli/install/plugin-stage.mjs +232 -0
  28. package/cli/install/reconcile.mjs +209 -0
  29. package/cli/install/recovery.mjs +79 -0
  30. package/cli/install/service-activation.mjs +133 -0
  31. package/cli/install/service-macos.mjs +146 -0
  32. package/cli/install/update.mjs +170 -0
  33. package/cli/install/zcode.mjs +239 -0
  34. package/cli/main.mjs +429 -0
  35. package/cli/maintenance.mjs +104 -0
  36. package/cli/paths.mjs +40 -0
  37. package/cli/rpc.mjs +261 -0
  38. package/launchd/com.external-subagent.daemon.plist.template +37 -0
  39. package/npm/native/README.md +19 -0
  40. package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
  41. package/npm/native/darwin-arm64/external-subagentd +0 -0
  42. package/npm/native/darwin-arm64/payload.json +20 -0
  43. package/package.json +60 -0
  44. package/plugins/codex/external-subagent/.codex-plugin/plugin.json +17 -0
  45. package/plugins/codex/external-subagent/.mcp.json +9 -0
  46. package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +55 -0
  47. package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +19 -0
  48. package/plugins/codex/external-subagent/hooks/guard-bash.mjs +15 -0
  49. package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +206 -0
  50. package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +149 -0
  51. package/plugins/codex/external-subagent/scripts/mcp-stdio-bridge.mjs +36 -0
  52. package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +44 -0
  53. package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +55 -0
  54. package/schema/external-subagent-public-api.json +185 -0
  55. package/schema/zas-observation-v1.1.schema.json +58 -0
  56. package/schema/zas-public-reasoning-source.json +14 -0
  57. package/scripts/release/build-native-payload.mjs +119 -0
  58. package/scripts/release/check-native-tarball.mjs +125 -0
  59. package/scripts/release/stage-debug-plugin.mjs +60 -0
  60. package/scripts/release/test-installed-tarball.mjs +73 -0
@@ -0,0 +1,352 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+ import { CliError } from '../errors.mjs';
7
+ import { atomicWrite, jsonBytes, readOptional, restoreOptional } from '../fs-atomic.mjs';
8
+ import { MCP_BIN_NAME, PRODUCT_ID } from '../constants.mjs';
9
+ import { nativeBinary, pluginSourceRoot, PLUGIN_NAME } from './layout.mjs';
10
+ import { pluginManifest, treeDigest, preparePluginStage, guardedRestores } from './plugin-stage.mjs';
11
+
12
+ // Managed Codex binding. The staging tree plus a local source marketplace are
13
+ // the product-owned half; every installation/removal goes through the official
14
+ // codex CLI (verified against codex-cli 0.153.4 and re-verified on 0.154.0,
15
+ // see docs/compatibility/codex.md):
16
+ // codex plugin marketplace add <root> --json
17
+ // codex plugin add <name> --marketplace <marketplace> --json
18
+ // codex plugin remove <name>@<marketplace> --json
19
+ // The codex CLI owns its own config/cache writes; this module never edits the
20
+ // Codex cache directly.
21
+
22
+ const CODEX_MCP_SECTION = `mcp_servers.${PRODUCT_ID}`;
23
+ const MARKETPLACE_NAME = 'personal';
24
+
25
+ export const CODEX_TOOLS = Object.freeze([
26
+ 'external_subagent_cancel',
27
+ 'external_subagent_close',
28
+ 'external_subagent_list',
29
+ 'external_subagent_observe',
30
+ 'external_subagent_respond',
31
+ 'external_subagent_result',
32
+ 'external_subagent_send',
33
+ 'external_subagent_spawn',
34
+ 'external_subagent_status',
35
+ 'external_subagent_wait',
36
+ ]);
37
+
38
+ export function codexHomeFor(options = {}, paths) {
39
+ if (typeof options.codexHome === 'string' && options.codexHome.length > 0) return options.codexHome;
40
+ if (process.env.CODEX_HOME) return process.env.CODEX_HOME;
41
+ return path.join((paths && paths.home) || os.homedir(), '.codex');
42
+ }
43
+
44
+ function runCodex(args, options = {}) {
45
+ const cli = options.codexCli || 'codex';
46
+ const result = spawnSync(cli, args, { encoding: 'utf8', env: { ...process.env, ...(options.env || {}) } });
47
+ if (result.error) throw new CliError('CODEX_CLI_UNAVAILABLE', result.error.message);
48
+ if (result.status !== 0) throw new CliError('CODEX_CLI_FAILED', (result.stderr || result.stdout || '').trim() || `codex exited ${result.status}`);
49
+ let parsed = null;
50
+ if ((result.stdout || '').trim()) { try { parsed = JSON.parse(result.stdout); } catch { /* help text is not JSON */ } }
51
+ return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '', json: parsed };
52
+ }
53
+
54
+ // Claiming a Codex home includes bringing it into existence; codex itself
55
+ // refuses to resolve a CODEX_HOME that is missing.
56
+ function ensureCodexHome(codexHome) {
57
+ if (!fs.existsSync(codexHome)) fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
58
+ return codexHome;
59
+ }
60
+
61
+ function marketplaceRootFor(file) {
62
+ return path.basename(path.dirname(file)) === 'plugins' && path.basename(path.dirname(path.dirname(file))) === '.agents'
63
+ ? path.dirname(path.dirname(path.dirname(file)))
64
+ : path.dirname(file);
65
+ }
66
+
67
+ function updateMarketplace(file, staging) {
68
+ const root = marketplaceRootFor(file);
69
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
70
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
71
+ let doc = { name: MARKETPLACE_NAME, interface: { displayName: 'Personal' }, plugins: [] };
72
+ if (fs.existsSync(file)) doc = JSON.parse(fs.readFileSync(file, 'utf8'));
73
+ if (!Array.isArray(doc.plugins)) throw new CliError('PLUGIN_MARKETPLACE_CONFLICT', 'marketplace file is not a source marketplace manifest');
74
+ const rel = `./${path.relative(root, staging)}`;
75
+ const entry = { name: PLUGIN_NAME, source: { source: 'local', path: rel }, policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, category: 'Productivity' };
76
+ const index = doc.plugins.findIndex((p) => p.name === PLUGIN_NAME);
77
+ if (index >= 0) {
78
+ const current = doc.plugins[index];
79
+ const currentPath = current?.source?.path;
80
+ const resolved = currentPath ? path.resolve(root, currentPath) : null;
81
+ const manifest = resolved && fs.existsSync(path.join(resolved, '.codex-plugin', 'plugin.json')) ? JSON.parse(fs.readFileSync(path.join(resolved, '.codex-plugin', 'plugin.json'), 'utf8')) : null;
82
+ if (currentPath !== entry.source.path || !manifest || manifest.name !== PLUGIN_NAME) throw new CliError('PLUGIN_MARKETPLACE_CONFLICT', 'marketplace entry is not a managed external-subagent source');
83
+ return { marketplace: file, marketplace_name: doc.name || MARKETPLACE_NAME, entry: currentPath, digest: treeDigest(staging) };
84
+ }
85
+ doc.plugins.push(entry);
86
+ // atomicWrite, not a bare writeFileSync: a failed/partial write either
87
+ // lands whole or leaves the prior bytes untouched, so the rollback below
88
+ // never inherits a half-written manifest it might skip restoring.
89
+ atomicWrite(file, jsonBytes(doc));
90
+ return { marketplace: file, marketplace_name: doc.name || MARKETPLACE_NAME, entry: rel, digest: treeDigest(staging) };
91
+ }
92
+
93
+ // The staged managed binding this install just wrote: the manifest identity
94
+ // plus the facade command and daemon socket stagePlugin pinned. The cache
95
+ // verification below compares the codex-materialized copy against exactly
96
+ // these values, never against recomputed guesses.
97
+ function stagedManagedBinding(staging) {
98
+ const manifest = JSON.parse(fs.readFileSync(path.join(staging, '.codex-plugin', 'plugin.json'), 'utf8'));
99
+ const server = JSON.parse(fs.readFileSync(path.join(staging, '.mcp.json'), 'utf8')).mcpServers?.external_subagent;
100
+ if (!server || typeof server.command !== 'string' || typeof server.env?.ZCODE_AGENTD_SOCKET !== 'string') {
101
+ throw new CliError('INVALID_PLUGIN_SOURCE', 'staged plugin MCP binding is incomplete');
102
+ }
103
+ return { manifest, command: server.command, socket: server.env.ZCODE_AGENTD_SOCKET };
104
+ }
105
+
106
+ function readCacheJson(file, cache, identity) {
107
+ try {
108
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
109
+ } catch (error) {
110
+ throw new CliError('CODEX_CACHE_UNVERIFIABLE', `codex plugin cache for ${identity} is present but unreadable (${file}: ${error.message})`);
111
+ }
112
+ }
113
+
114
+ // Shared remediation for any cache that kept another installation's bytes
115
+ // for this identity: the identity (plugin version) must move, or the
116
+ // resolved root must be refreshed — the codex-owned cache is never edited.
117
+ const CACHE_REUSE_REMEDIATION = 'The cache kept another installation\'s content for the same identity: when the CLI freezes cached content for an identity (codex 0.153.4 store behavior), release a distinct plugin version instead of accepting it; when the CLI re-materializes the cache from the registered root (codex 0.154.0, non-reserved marketplace names), refresh the registered marketplace root and re-add';
118
+
119
+ // codex materializes `plugin add` caches from a resolved root, and the
120
+ // resolution differs by codex version and marketplace name (verified on
121
+ // 0.153.4 and 0.154.0, see docs/compatibility/codex.md). 0.153.4 froze the
122
+ // first-cached bytes in a machine-global store keyed by
123
+ // plugin@marketplace@version, so the same identity reinstalled from a
124
+ // different binding (another staging root, installed facade, or daemon
125
+ // socket) or from newer managed content received those foreign bytes while
126
+ // `plugin add` still reported success. 0.154.0 re-materializes non-reserved
127
+ // names from the registered root on every add, but resolves the reserved
128
+ // marketplace name `personal` machine-globally to the real user root,
129
+ // ignoring CODEX_HOME — so an isolated home under the reserved name still
130
+ // meets the real root's foreign bytes. Before installPlugin may return
131
+ // success, the materialized cache is read back and compared with this run's
132
+ // staged binding AND staged managed content; any surprise fails closed
133
+ // below (the checks stay even where 0.154.0 re-materializes, as defense
134
+ // against freeze-behavior CLIs and future regressions). The cache is only
135
+ // ever READ here — codex-owned state is never edited, and the remediation
136
+ // is a distinct release identity (frozen stores), refreshing/re-registering
137
+ // the resolved root, or a non-reserved marketplace name in isolated homes —
138
+ // never a rewrite.
139
+ function verifyCodexCache(add, { codexHome, staging, marketplaceName }) {
140
+ const expected = stagedManagedBinding(staging);
141
+ const identity = `${PLUGIN_NAME}@${marketplaceName}@${expected.manifest.version}`;
142
+ const candidates = [];
143
+ const reported = add.json?.installedPath || add.json?.installed_path;
144
+ if (typeof reported === 'string' && reported.length > 0) candidates.push(reported);
145
+ candidates.push(path.join(codexHome, 'plugins', 'cache', add.json?.marketplaceName || marketplaceName, PLUGIN_NAME, String(expected.manifest.version)));
146
+ for (const cache of [...new Set(candidates)]) {
147
+ if (!fs.existsSync(cache)) continue;
148
+ const manifest = readCacheJson(path.join(cache, '.codex-plugin', 'plugin.json'), cache, identity);
149
+ for (const field of ['name', 'version', 'skills', 'mcpServers']) {
150
+ if (manifest[field] !== expected.manifest[field]) {
151
+ throw new CliError('CODEX_CACHE_BINDING_MISMATCH', `codex plugin cache for ${identity} has manifest ${field}=${JSON.stringify(manifest[field])}, expected the staged ${JSON.stringify(expected.manifest[field])} (${cache})`);
152
+ }
153
+ }
154
+ const server = readCacheJson(path.join(cache, '.mcp.json'), cache, identity).mcpServers?.external_subagent;
155
+ const socket = server?.env?.ZCODE_AGENTD_SOCKET;
156
+ if (!server || server.command !== expected.command || socket !== expected.socket) {
157
+ throw new CliError('CODEX_CACHE_BINDING_MISMATCH', `codex plugin cache for ${identity} carries a different managed binding (command=${server?.command}, socket=${socket}); expected this install's staged binding (command=${expected.command}, socket=${expected.socket}). The cache resolved to another installation's bytes (${cache}); either the machine-global content store reused them for the same identity (release a distinct plugin version instead of accepting them), or the reserved marketplace name personal resolved machine-globally to the real user root regardless of CODEX_HOME (refresh or re-register that root, or use a non-reserved marketplace name in isolated homes)`);
158
+ }
159
+ // AUD-010: identity and binding alone do not prove the cache carries THIS
160
+ // candidate. A freeze-behavior CLI (0.153.4's machine-global store keyed
161
+ // by plugin@marketplace@version) keeps whatever bytes the FIRST install
162
+ // of that identity staged, so a same-identity refresh can meet every
163
+ // metadata/binding check above while the materialized tree still holds
164
+ // an older SKILL, lacks a file this candidate added, or retains a file
165
+ // the source deleted. codex 0.154.0 re-materializes non-reserved names
166
+ // from the registered root before this read, so on that baseline this
167
+ // comparison is defense-in-depth rather than a live stale-content
168
+ // catch; it stays for freeze-behavior CLIs and future regressions. The
169
+ // managed content is therefore compared with this run's staged tree via
170
+ // treeDigest (every file and byte except `.mcp.json` — that exclusion
171
+ // is treeDigest's existing rule, because the machine-local binding must
172
+ // not churn the content digest). The `.mcp.json` gap that exclusion
173
+ // would leave is closed by a direct comparison right after the binding
174
+ // check: command and socket there, then the whole managed document.
175
+ // Real codex materializes the cache as a verbatim copy of the staged
176
+ // plugin tree with no host-generated additions inside it (see
177
+ // docs/compatibility/codex.md), so the staged file set IS the managed
178
+ // set; any difference fails closed through the existing error chain
179
+ // (S01 keeps staging/marketplace recoverable) instead of reporting
180
+ // cache_verified for stale bytes.
181
+ const stagedMcp = readCacheJson(path.join(staging, '.mcp.json'), staging, identity);
182
+ const cacheMcp = readCacheJson(path.join(cache, '.mcp.json'), cache, identity);
183
+ if (!isDeepStrictEqual(cacheMcp, stagedMcp)) {
184
+ throw new CliError('CODEX_CACHE_CONTENT_MISMATCH', `codex plugin cache for ${identity} carries a different managed MCP config (command and socket already matched, so the divergence is in the remaining .mcp.json fields such as args or env; ${cache}). ${CACHE_REUSE_REMEDIATION}`);
185
+ }
186
+ const stagedDigest = treeDigest(staging);
187
+ const cacheDigest = treeDigest(cache);
188
+ if (cacheDigest !== stagedDigest) {
189
+ throw new CliError('CODEX_CACHE_CONTENT_MISMATCH', `codex plugin cache for ${identity} does not carry this candidate's managed content (staged tree digest ${stagedDigest}, cache digest ${cacheDigest}; ${cache}). ${CACHE_REUSE_REMEDIATION}`);
190
+ }
191
+ return cache;
192
+ }
193
+ // `plugin add` reported success, yet neither the CLI-reported path nor the
194
+ // derived cache location exists on disk — there are no bytes to verify this
195
+ // install against. Real codex always materializes the cache, so a success
196
+ // without one is an unverifiable install: fail closed here instead of
197
+ // reporting installed/cache_verified:false, which let public installs and
198
+ // reconciles record success (claims, last_status=updated) for an install
199
+ // whose binding this run never saw.
200
+ const lookedIn = [...new Set(candidates)].join(', ');
201
+ throw new CliError('CODEX_CACHE_UNVERIFIABLE', `codex plugin add reported success for ${identity} but no plugin cache was materialized to verify (looked in: ${lookedIn}); refusing to record an unverified install`);
202
+ }
203
+
204
+ export function resolveStaging(home, options) {
205
+ let staging = options.stagingPath || path.join(home, 'plugins', PLUGIN_NAME);
206
+ let marketplace = options.marketplacePath || path.join(home, '.agents', 'plugins', 'marketplace.json');
207
+ // The default ~/.agents file can be an installed-registry projection, not a
208
+ // source. Never overwrite it; use a private local marketplace root instead.
209
+ if (!options.marketplacePath && fs.existsSync(marketplace)) {
210
+ const reprobe = () => {
211
+ const root = path.join(home, '.external-subagent-marketplace');
212
+ staging = path.join(root, 'plugins', PLUGIN_NAME);
213
+ marketplace = path.join(root, '.agents', 'plugins', 'marketplace.json');
214
+ };
215
+ try {
216
+ if (!Array.isArray(JSON.parse(fs.readFileSync(marketplace, 'utf8')).plugins)) reprobe();
217
+ } catch { reprobe(); }
218
+ }
219
+ return { staging, marketplace };
220
+ }
221
+
222
+ export function installPlugin(paths, options = {}) {
223
+ const source = options.source || pluginSourceRoot();
224
+ const home = options.home || paths.home;
225
+ const { staging, marketplace } = resolveStaging(home, options);
226
+ const codexHome = ensureCodexHome(codexHomeFor(options, paths));
227
+ const env = { ...(options.env || {}), CODEX_HOME: codexHome };
228
+ const cli = { codexCli: options.codexCli, env };
229
+ const probe = runCodex(['plugin', 'add', '--help'], cli);
230
+ if (options.dryRun) {
231
+ return { dry_run: true, operation: options.uninstall ? 'uninstall' : 'install', source, staging, marketplace, codex_home: codexHome, cli: 'codex', help_exit: probe.status };
232
+ }
233
+ if (options.uninstall) return uninstallPlugin(paths, options);
234
+ pluginManifest(source);
235
+ const priorMarketplace = fs.existsSync(marketplace) ? fs.readFileSync(marketplace) : null;
236
+ const staged = preparePluginStage(source, staging, paths);
237
+ // AUD-002: stage by validated replacement. publish() is self-contained
238
+ // (a failed copy/parse/swap never leaves the live tree moved or damaged);
239
+ // every later failure rolls the marketplace back to its prior bytes and
240
+ // restores the prior coherent staging tree — each as an INDEPENDENT
241
+ // guarded attempt, so a restore that itself fails (a marketplace parent
242
+ // that stays read-only) neither skips the staging restore nor masks the
243
+ // original error, and the unrestored resources ride the thrown error.
244
+ const restoreStaging = () => {
245
+ if (!staged.restore()) {
246
+ throw new CliError('PLUGIN_STAGING_RESTORE_FAILED', `the prior staging tree could not be restored at ${staging}; the retained prior copy is left beside it as recovery material`);
247
+ }
248
+ };
249
+ const restoreMarketplace = () => restoreOptional(marketplace, priorMarketplace);
250
+ let market;
251
+ try {
252
+ staged.publish();
253
+ market = updateMarketplace(marketplace, staging);
254
+ } catch (error) {
255
+ // The marketplace restore runs here too: updateMarketplace may already
256
+ // have written the managed entry (its write is atomic, but a later step
257
+ // in it can still fail), and those bytes must go back to the prior
258
+ // backup — foreign entries included — alongside the staging restore.
259
+ throw guardedRestores(error, [
260
+ { resource: marketplace, restore: restoreMarketplace },
261
+ { resource: staging, restore: restoreStaging },
262
+ ]);
263
+ }
264
+ // Explicitly configured marketplaces must be registered; the personal
265
+ // default is implicit. Both registrations are idempotent in codex.
266
+ let add;
267
+ let verifiedCache = null;
268
+ try {
269
+ if (options.registerMarketplace !== false) runCodex(['plugin', 'marketplace', 'add', marketplaceRootFor(marketplace), '--json'], cli);
270
+ add = runCodex(['plugin', 'add', PLUGIN_NAME, '--marketplace', market.marketplace_name, '--json'], cli);
271
+ verifiedCache = verifyCodexCache(add, { codexHome, staging, marketplaceName: market.marketplace_name });
272
+ } catch (error) {
273
+ throw guardedRestores(error, [
274
+ { resource: marketplace, restore: restoreMarketplace },
275
+ { resource: staging, restore: restoreStaging },
276
+ ]);
277
+ }
278
+ staged.complete();
279
+ return {
280
+ installed: true, source, staging, marketplace, codex_home: codexHome,
281
+ cache: verifiedCache,
282
+ cache_verified: true,
283
+ codex: add.json || add.stdout.trim(), ...market,
284
+ };
285
+ }
286
+
287
+ export function uninstallPlugin(paths, options = {}) {
288
+ const source = options.source || pluginSourceRoot();
289
+ const home = options.home || paths.home;
290
+ const { staging, marketplace } = resolveStaging(home, options);
291
+ const codexHome = codexHomeFor(options, paths);
292
+ const env = { ...(options.env || {}), CODEX_HOME: codexHome };
293
+ if (options.dryRun) {
294
+ return { dry_run: true, operation: 'uninstall', source, staging, marketplace, codex_home: codexHome, cli: 'codex' };
295
+ }
296
+ let marketplaceName = MARKETPLACE_NAME;
297
+ try {
298
+ if (fs.existsSync(marketplace)) marketplaceName = JSON.parse(fs.readFileSync(marketplace, 'utf8')).name || MARKETPLACE_NAME;
299
+ } catch { /* keep the default marketplace name */ }
300
+ if (!fs.existsSync(codexHome)) {
301
+ return { uninstalled: true, source, staging, marketplace, codex_home: codexHome, marketplace_name: marketplaceName, codex: null, note: 'codex home absent; nothing to remove' };
302
+ }
303
+ // The verified removal form is <plugin>@<marketplace>; a bare name is
304
+ // rejected by codex 0.153.4.
305
+ const result = runCodex(['plugin', 'remove', `${PLUGIN_NAME}@${marketplaceName}`, '--json'], { codexCli: options.codexCli, env });
306
+ return { uninstalled: true, source, staging, marketplace, codex_home: codexHome, marketplace_name: marketplaceName, codex: result.json || result.stdout.trim() };
307
+ }
308
+
309
+ function codexMcpConfig(paths) {
310
+ const command = nativeBinary(MCP_BIN_NAME);
311
+ const tools = CODEX_TOOLS.map((tool) => ` "${tool}",`).join('\n');
312
+ return `[${CODEX_MCP_SECTION}]\ncommand = ${JSON.stringify(command)}\nenabled = true\nrequired = true\nstartup_timeout_sec = 10\ntool_timeout_sec = 304\nenabled_tools = [\n${tools}\n]\ndefault_tools_approval_mode = "prompt"\n\n[${CODEX_MCP_SECTION}.env]\nZCODE_AGENTD_SOCKET = ${JSON.stringify(paths.socket)}\n\n[${CODEX_MCP_SECTION}.tools.external_subagent_status]\napproval_mode = "auto"\n\n[${CODEX_MCP_SECTION}.tools.external_subagent_list]\napproval_mode = "auto"\n\n[${CODEX_MCP_SECTION}.tools.external_subagent_wait]\napproval_mode = "auto"\n\n[${CODEX_MCP_SECTION}.tools.external_subagent_result]\napproval_mode = "auto"\n`;
313
+ }
314
+
315
+ function removeTomlSection(text, section) {
316
+ const lines = text.split(/(?<=\n)/u);
317
+ let removing = false;
318
+ const kept = [];
319
+ for (const line of lines) {
320
+ const match = line.match(/^\s*\[([^\]]+)\]\s*\r?\n?$/u);
321
+ if (match) removing = match[1] === section || match[1].startsWith(`${section}.`);
322
+ if (!removing) kept.push(line);
323
+ }
324
+ return kept.join('').replace(/\n{3,}$/u, '\n\n');
325
+ }
326
+
327
+ export function installMcp(paths, options = {}) {
328
+ // The TOML binding lives inside the Codex home, so an explicit --codex-home
329
+ // (then CODEX_HOME) selects the config file for install and removal alike.
330
+ const config = options.configPath || path.join(codexHomeFor(options, paths), 'config.toml');
331
+ const codexHome = codexHomeFor(options, paths);
332
+ const command = nativeBinary(MCP_BIN_NAME);
333
+ if (options.dryRun) {
334
+ return { dry_run: true, operation: options.uninstall ? 'uninstall' : 'install', platform: 'codex', config, codex_home: codexHome, command, socket: paths.socket };
335
+ }
336
+ if (options.uninstall) {
337
+ const prior = readOptional(config);
338
+ if (prior === null) return { uninstalled: false, platform: 'codex', config, codex_home: codexHome };
339
+ const preserved = removeTomlSection(prior.toString('utf8'), CODEX_MCP_SECTION).replace(/^\n+|\n+$/gu, '');
340
+ atomicWrite(config, Buffer.from(preserved ? `${preserved}\n` : ''));
341
+ return { uninstalled: true, platform: 'codex', config, codex_home: codexHome };
342
+ }
343
+ if (!fs.existsSync(command) && !options.skipNativeProbe) {
344
+ throw new CliError('NATIVE_BINARY_NOT_FOUND', 'npm package does not contain the macOS MCP binary');
345
+ }
346
+ const prior = readOptional(config);
347
+ const base = prior === null ? '' : prior.toString('utf8');
348
+ const preserved = removeTomlSection(base, CODEX_MCP_SECTION).replace(/\s*$/u, '');
349
+ const next = `${preserved ? `${preserved}\n\n` : ''}${codexMcpConfig(paths)}`;
350
+ atomicWrite(config, Buffer.from(next));
351
+ return { installed: true, platform: 'codex', config, codex_home: codexHome, command, socket: paths.socket, tools: [...CODEX_TOOLS] };
352
+ }
@@ -0,0 +1,206 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { LAUNCH_AGENT_LABEL, ZCODE_RUNTIME } from '../constants.mjs';
5
+ import { CliError } from '../errors.mjs';
6
+ import { parseConfig } from '../config/read.mjs';
7
+ import { writeConfig } from '../config/write.mjs';
8
+ import { verifyPayload } from './payload.mjs';
9
+ import { pathReport } from './path.mjs';
10
+ import { packageRoot, payloadManifestPath } from './layout.mjs';
11
+ import { updateInstallation } from './update.mjs';
12
+ import { loadInstallState, markInstallStep, removeCreatedDirectories, rollbackFiles, snapshotFile } from './recovery.mjs';
13
+ import { bootstrapService, bootoutService, installLaunchAgent } from './service-macos.mjs';
14
+
15
+ // Standalone-install coordination (S05, AUD-005 decision D1). A plain npm
16
+ // install only stages the package and payload; an explicit `init` installs
17
+ // the STANDALONE SERVICE ONLY — every state-changing step below is
18
+ // host-neutral:
19
+ // verify-payload the staged native payload matches the package version
20
+ // check-path PATH findings are reported, never written
21
+ // create-data private data/log directories
22
+ // write-product-config republish the current agent config schema
23
+ // install-launch-agent the one macOS service template
24
+ // start-service launchctl bootstrap (best-effort, reported)
25
+ // publish-active-payload the verified active identity and retained bytes
26
+ // (B-3): a successful init establishes the version and
27
+ // retention baseline itself, so the standard `npm A ->
28
+ // init A -> use A -> npm B` sequence never depends on an
29
+ // extra "A update" the user was never told to run. The
30
+ // publication reuses the existing locked update owner —
31
+ // no second lifecycle or scheduler — and only runs when
32
+ // this init verified the payload it installed.
33
+ // init no longer probes a fixed ZCode runtime, no longer installs the managed
34
+ // Codex plugin, and no longer claims a Codex home (the pre-D1 steps
35
+ // probe-runtime, install-codex-plugin, and claim-codex-home are gone). A
36
+ // host is bound through the existing EXPLICIT commands — `install-plugin
37
+ // codex|zcode` or `install-mcp` — which own staging, the official codex add,
38
+ // and the D08 registry claim. The pinned ZCode runtime is forwarded to the
39
+ // service only when that installation exists (see service-macos.mjs), so a
40
+ // missing ZCode app or Codex home never blocks standalone setup.
41
+ // Failures roll tracked files back through recovery.mjs; a resumable journal
42
+ // lets `init --resume` continue after environmental failures. Journals from
43
+ // pre-D1 inits may still list the retired codex steps; unknown completed ids
44
+ // are simply never consulted again.
45
+ const HOOK_INSTALLER = ['plugins', 'codex', 'external-subagent', 'scripts', 'install-agent-hooks.mjs'];
46
+
47
+ function hookInstallerPath() {
48
+ return path.join(packageRoot(), ...HOOK_INSTALLER);
49
+ }
50
+
51
+ export function installPlan(paths, options = {}) {
52
+ const manifest = payloadManifestPath() ?? path.join(packageRoot(), 'npm', 'native', 'darwin-arm64', 'payload.json');
53
+ const plan = [
54
+ { id: 'verify-payload', action: 'verify staged native payload', path: manifest },
55
+ { id: 'check-path', action: 'report PATH availability without writing profiles' },
56
+ { id: 'create-data', action: 'create private product data and log directories', paths: [paths.data, paths.logs] },
57
+ { id: 'write-product-config', action: 'republish the product agent config schema', path: paths.config },
58
+ { id: 'install-launch-agent', action: 'install daemon LaunchAgent', path: paths.launchAgent, label: LAUNCH_AGENT_LABEL },
59
+ { id: 'start-service', action: 'bootstrap the daemon service', path: paths.launchAgent, label: LAUNCH_AGENT_LABEL },
60
+ { id: 'publish-active-payload', action: 'publish the verified active payload and retained-byte baseline', path: paths.state },
61
+ ];
62
+ if (options.installHooks) plan.splice(plan.findIndex((step) => step.id === 'publish-active-payload'), 0, { id: 'install-hooks', action: 'install ZCode policy hooks', path: paths.zcodeConfig, provenance: paths.hookProvenance });
63
+ return plan;
64
+ }
65
+
66
+ export function installHooks(paths, options = {}) {
67
+ if (options.dryRun) return { dry_run: true, plan: [{ id: 'install-hooks', action: 'install ZCode policy hooks', path: paths.zcodeConfig, provenance: paths.hookProvenance }] };
68
+ const result = spawnSync(process.execPath, [hookInstallerPath(), '--config', paths.zcodeConfig, '--provenance', paths.hookProvenance], { encoding: 'utf8' });
69
+ if (result.error) throw result.error;
70
+ if (result.status !== 0) {
71
+ let failure;
72
+ try { failure = JSON.parse((result.stderr || '').trim()); } catch { /* plain-text failure */ }
73
+ throw new CliError(failure?.code || 'HOOK_INSTALL_FAILED', failure?.error || (result.stderr || 'hook installation failed').trim());
74
+ }
75
+ try { return JSON.parse(result.stdout); } catch { throw new CliError('HOOK_INSTALL_FAILED', 'hook installer returned invalid JSON'); }
76
+ }
77
+
78
+ export function runInit(options = {}) {
79
+ const paths = options.paths;
80
+ if (!paths) throw new CliError('INTERNAL_ERROR', 'runInit requires product paths');
81
+ const plan = installPlan(paths, options);
82
+ if (options.dryRun) return { dry_run: true, plan };
83
+
84
+ const skipPayload = Boolean(options.skipPayloadProbe);
85
+ const payload = skipPayload ? { status: 'skipped', platform: null, version: null, files: [] } : verifyPayload();
86
+ const pathFindings = pathReport();
87
+
88
+ const tracked = {
89
+ zcodeConfig: { file: paths.zcodeConfig, snapshot: snapshotFile(paths.zcodeConfig) },
90
+ hookProvenance: { file: paths.hookProvenance, snapshot: snapshotFile(paths.hookProvenance) },
91
+ state: { file: paths.state, snapshot: snapshotFile(paths.state) },
92
+ config: { file: paths.config, snapshot: snapshotFile(paths.config) },
93
+ launchAgent: { file: paths.launchAgent, snapshot: snapshotFile(paths.launchAgent) },
94
+ };
95
+ const directories = {
96
+ data: { path: paths.data, existed: fs.existsSync(paths.data) },
97
+ logs: { path: paths.logs, existed: fs.existsSync(paths.logs) },
98
+ };
99
+ const state = options.resume ? loadInstallState(paths.state) : { schema_version: 1, completed: [] };
100
+ const completed = new Set(state.completed || []);
101
+ const mark = (id) => markInstallStep(paths.state, completed, id);
102
+ const failAt = (id) => {
103
+ if (options._failStep === id) throw new Error(`injected failure at ${id}`);
104
+ };
105
+
106
+ let service = { action: 'bootstrap', skipped: true, reason: 'not attempted' };
107
+ // A rollback must also undo what this run itself loaded: a failure after a
108
+ // successful bootstrap would otherwise leave a launchd job running binary
109
+ // bytes the rolled-back plist/install-state no longer describe (observed
110
+ // live: a codex-binding conflict stranded a running candidate daemon).
111
+ // A service that was already loaded before this init is never touched.
112
+ let serviceLoadedByThisRun = false;
113
+ let baseline = null;
114
+ try {
115
+ if (!completed.has('verify-payload')) mark('verify-payload');
116
+ if (!completed.has('check-path')) mark('check-path');
117
+ if (!completed.has('create-data')) {
118
+ fs.mkdirSync(paths.data, { recursive: true, mode: 0o700 });
119
+ fs.mkdirSync(paths.logs, { recursive: true, mode: 0o700 });
120
+ mark('create-data');
121
+ }
122
+ if (!completed.has('write-product-config')) {
123
+ // The daemon takes its database/socket/runtime from service arguments,
124
+ // never from this file; drop the retired top-level path fields so an
125
+ // install over an older local config republishes the current schema.
126
+ // S05-F01: `subagents.zcode.runtime_path` was a first-class config key
127
+ // before D1 rejected it, so a pre-S05 install can carry it in either
128
+ // the schema-2 or the legacy schema-1 shape. Retire it here — before
129
+ // parseConfig migrates/validates — so `npm new -> init` self-heals such
130
+ // configs instead of failing closed at the read gate (which stays
131
+ // fully strict for every other read/write path).
132
+ const prior = fs.existsSync(paths.config)
133
+ ? JSON.parse(fs.readFileSync(paths.config, 'utf8'))
134
+ : {};
135
+ for (const field of ['runtime', 'database', 'socket']) delete prior[field];
136
+ for (const map of ['subagents', 'agents']) delete prior[map]?.zcode?.runtime_path;
137
+ writeConfig(paths.config, parseConfig(prior));
138
+ failAt('write-product-config');
139
+ mark('write-product-config');
140
+ }
141
+ if (!completed.has('install-launch-agent')) {
142
+ installLaunchAgent(paths);
143
+ failAt('install-launch-agent');
144
+ mark('install-launch-agent');
145
+ }
146
+ if (!completed.has('start-service') && !options.skipServiceStart) {
147
+ service = bootstrapService(paths, process.getuid(), { launchctl: options.launchctl });
148
+ serviceLoadedByThisRun = !service.skipped && !service.already_loaded;
149
+ mark('start-service');
150
+ }
151
+ if (options.installHooks && !completed.has('install-hooks')) {
152
+ installHooks(paths);
153
+ mark('install-hooks');
154
+ }
155
+ // The baseline publication runs LAST and only for an init that verified
156
+ // the payload it installed (a skipped probe — test harnesses only —
157
+ // leaves the resume journal as the sole state). It reuses the locked
158
+ // update owner, so the active identity and retained bytes are published
159
+ // under the exact verification/retention rules every later update
160
+ // follows. The Codex-home sync stays deferred: init binds no host, so
161
+ // any registered homes belong to explicit install-plugin/install-mcp
162
+ // bindings and remain the reconcile owner's to refresh. The step is
163
+ // recorded in the in-memory report only — writing the schema-1 journal
164
+ // here would clobber the schema-2 activation state it just published.
165
+ if (payload.status === 'verified') {
166
+ failAt('publish-active-payload');
167
+ const published = updateInstallation(paths, { deferCodexSync: true });
168
+ if (published.phase !== 'active' || !published.active) {
169
+ throw new CliError('INIT_BASELINE_FAILED', `activation baseline publication did not complete (phase=${published.phase ?? 'none'})`);
170
+ }
171
+ completed.add('publish-active-payload');
172
+ baseline = {
173
+ version: published.active.version,
174
+ entry_sha256: published.active.entry_sha256,
175
+ daemon_entry: published.active.daemon_entry,
176
+ daemon_entry_sha256: published.active.daemon_entry_sha256,
177
+ retained_root: published.active.retained?.root ?? null,
178
+ };
179
+ }
180
+ } catch (error) {
181
+ const rollbackErrors = [
182
+ ...rollbackFiles(tracked),
183
+ ...removeCreatedDirectories(directories),
184
+ ];
185
+ if (serviceLoadedByThisRun) {
186
+ try { bootoutService(paths, process.getuid(), { launchctl: options.launchctl }); } catch (bootoutError) {
187
+ rollbackErrors.push(`service rollback (bootout) failed: ${bootoutError.message}`);
188
+ }
189
+ }
190
+ if (rollbackErrors.length > 0) error.rollbackErrors = rollbackErrors;
191
+ throw error;
192
+ }
193
+ return {
194
+ installed: true,
195
+ resumed: Boolean(options.resume),
196
+ completed: [...completed],
197
+ // Honest runtime report instead of a hard dependency: the pinned ZCode
198
+ // runtime is an adapter capability, observed and forwarded by the service
199
+ // template only when present.
200
+ runtime: { path: ZCODE_RUNTIME, present: fs.existsSync(ZCODE_RUNTIME) },
201
+ payload,
202
+ baseline,
203
+ path_report: pathFindings,
204
+ service,
205
+ };
206
+ }
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { DAEMON_BIN_NAME, MCP_BIN_NAME, NATIVE_DIR_NAME, PRODUCT_NAME, VERSION } from '../constants.mjs';
5
+
6
+ // Package and payload layout is the single source of truth for where the
7
+ // staged npm artifact keeps its managed entry points. The MCP binding and
8
+ // the LaunchAgent reference absolute paths below this root, never a shell or
9
+ // GUI PATH lookup. Identity names derive from the variant token in
10
+ // constants.mjs, so the debug variant resolves its own payload directory,
11
+ // binary names, and plugin source without a parallel code path.
12
+ export const PLUGIN_NAME = PRODUCT_NAME;
13
+ export const NATIVE_PLATFORM = 'darwin-arm64';
14
+ export const NATIVE_BINARIES = Object.freeze([DAEMON_BIN_NAME, MCP_BIN_NAME]);
15
+
16
+ export function packageRoot() {
17
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
18
+ }
19
+
20
+ export function nativePlatform(platform = process.platform, arch = process.arch) {
21
+ if (platform === 'darwin' && arch === 'arm64') return NATIVE_PLATFORM;
22
+ return null;
23
+ }
24
+
25
+ export function nativePayloadDir(platform = nativePlatform()) {
26
+ if (platform !== NATIVE_PLATFORM) return null;
27
+ return path.join(packageRoot(), 'npm', NATIVE_DIR_NAME, platform);
28
+ }
29
+
30
+ export function nativeBinary(name, platform = nativePlatform()) {
31
+ const dir = nativePayloadDir(platform);
32
+ if (!dir || !NATIVE_BINARIES.includes(name)) return null;
33
+ return path.join(dir, name);
34
+ }
35
+
36
+ export function payloadManifestPath(platform = nativePlatform()) {
37
+ const dir = nativePayloadDir(platform);
38
+ return dir === null ? null : path.join(dir, 'payload.json');
39
+ }
40
+
41
+ export function pluginSourceRoot() {
42
+ return path.join(packageRoot(), 'plugins', 'codex', PLUGIN_NAME);
43
+ }
44
+
45
+ // The npm package version and the in-repo CLI version must agree; payload
46
+ // verification rejects any drift between the two.
47
+ export function packageVersion() {
48
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
49
+ return manifest.version;
50
+ }
51
+
52
+ export function cliVersion() {
53
+ return VERSION;
54
+ }