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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iBobby
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # external-subagent
2
+
3
+ A local service that manages bounded, repository-scoped external subagent
4
+ tasks and exposes them over MCP. Hosts (the applications that call the MCP
5
+ tools) and subagents (the execution targets that run the tasks) are two
6
+ independent axes:
7
+
8
+ - **Hosts** — Codex (`install-plugin codex` or `install-mcp`), ZCode
9
+ (`install-plugin zcode`), and any custom local MCP client that connects
10
+ directly to the facade without registration.
11
+ - **Subagents** — ZCode, DeepSeek Harness (DSH), and Codex, each routed
12
+ through an internal adapter and subject to the per-subagent limitations
13
+ listed in [docs/product.md](docs/product.md) and
14
+ [docs/mcp-api.md](docs/mcp-api.md).
15
+
16
+ ## Status
17
+
18
+ Current work happens on the `audit/external-subagent-20260917` branch; the
19
+ productization closeout below is the historical record of that stage. DSH and
20
+ ZCode installed-artifact lifecycles, active-task upgrade, and standalone
21
+ initialization are implemented and reviewed. `init` installs the standalone
22
+ daemon service only — no host is bound implicitly; hosts are bound through the
23
+ explicit `install-plugin` (Codex or ZCode) / `install-mcp` (Codex-only TOML
24
+ binding) commands. Installing from a packed artifact and explicitly
25
+ initializing to a launchd-resident service (idempotent repeat init/start) is
26
+ live-verified on a real GUI session. Both productization
27
+ candidates passed the four-cell consumer matrix — DSH/ZCode × public CLI /
28
+ real Codex CLI via the managed plugin over MCP, each against its own tarball:
29
+ the pre-fix candidate C1 (`0.1.1`, historical) and the post-installer-fix
30
+ final candidate C2 (`0.1.2`), whose fresh consumer verification passed
31
+ through the public install surface — all four cells COMPLETED with the
32
+ plugin cache verified against the staged binding (`cache_verified: true`;
33
+ see [docs/acceptance/productization.md](docs/acceptance/productization.md)).
34
+ The managed plugin manifest is versioned per released candidate because its
35
+ version is codex's plugin-cache identity, and codex resolves the reserved
36
+ `personal` marketplace name machine-globally to the real user root
37
+ (regardless of `CODEX_HOME`), so each candidate's consumer runs pin that
38
+ identity's bytes there and any later candidate bumps again. The installer
39
+ re-verified on 0.154.0; DSH admission currently supports `build` and strict
40
+ `plan`; its model selection is explicit spawn model, configured default, then
41
+ the native default. The package is published to the public npm registry as
42
+ `external-subagent@0.1.0` (macOS arm64 only; `os`/`cpu` in `package.json`
43
+ reject installation on other platforms at npm install time).
44
+
45
+ ## Versioning
46
+
47
+ The product version has a single source: `package.json` (`0.1.0`). The CLI
48
+ constant (`cli/constants.mjs`), the daemon crates, and the native payload
49
+ manifest all carry the same version, and `scripts/release/check-native-tarball.mjs`
50
+ enforces payload/package agreement. The managed Codex plugin manifest
51
+ (`plugins/codex/external-subagent/.codex-plugin/plugin.json`, currently
52
+ `0.1.3`) is deliberately **not** tied to the product version: it is codex's
53
+ plugin-cache identity (`plugin@marketplace@version`; under the reserved
54
+ `personal` marketplace name codex resolves the real user root
55
+ machine-globally, ignoring `CODEX_HOME`), which
56
+ must carry a fresh version per released candidate (`0.1.1` for C1, `0.1.2`
57
+ for C2, `0.1.3` for the boundary-fixes candidate) so no home silently
58
+ receives another installation's cached bytes. The two numbers therefore
59
+ intentionally diverge; see
60
+ [docs/operations.md](docs/operations.md) for the full rule.
61
+
62
+ ## Install
63
+
64
+ ```
65
+ npm install -g external-subagent # stages package + payload only (macOS arm64)
66
+ external-subagent init # explicit: standalone daemon service only
67
+ external-subagent install-plugin zcode # optional host binding (or: install-plugin codex / install-mcp)
68
+ ```
69
+
70
+ Packing and publishing are gated by npm lifecycle scripts: `prepack` always
71
+ rebuilds the native payload from source (so a tarball can never silently ship
72
+ stale or missing binaries) and `postpack` runs the static tarball checks. A
73
+ development checkout packs the same way (`npm pack` after `cargo` is
74
+ available); see [docs/operations.md](docs/operations.md) for release checks.
75
+
76
+ ## Publishing
77
+
78
+ Releases publish from CI via npm Trusted Publishing (OIDC): pushing a `vX.Y.Z`
79
+ tag runs [.github/workflows/npm-publish.yml](.github/workflows/npm-publish.yml)
80
+ on an Apple Silicon runner, which rebuilds the payload, runs the pack gates,
81
+ and publishes with no npm token in the environment — the runner's OIDC
82
+ identity is the credential. npm does not support trusted publishing for a
83
+ package's first release, so the initial version was published manually once
84
+ and the trusted publisher was then linked on npmjs.com; the workflow filename
85
+ is part of that link and must not be renamed.
86
+
87
+ A plain install stages the package and payload only. `init` never binds a
88
+ host or touches Codex state; host binding is a separate explicit step
89
+ (`install-plugin codex|zcode`, or `install-mcp` for the direct Codex TOML
90
+ binding), and any custom local MCP client may connect without either. On an
91
+ already initialized installation, npm's postinstall hook detects a local
92
+ version drift and reuses
93
+ the existing reconcile/update owner; it never initializes a fresh home or
94
+ probes a provider. A same-version reinstall does not drift and therefore
95
+ does not trigger that update path — rebind hosts explicitly with
96
+ `external-subagent reconcile` and restart the service per
97
+ [docs/operations.md](docs/operations.md) instead of expecting npm reinstall
98
+ to refresh a running daemon. `init` remains the explicit first activation
99
+ step. A
100
+ completed update whose registered Codex homes only partially rebind reports
101
+ `CODEX_SYNC_PARTIAL` per home and keeps the verified activation; the public
102
+ `stop` confirms launchd removal before returning, and `uninstall` boots out
103
+ the ES-owned service before removing its registration while retaining all
104
+ data. Supported platform: macOS arm64 — `os`/`cpu` in `package.json` make npm
105
+ refuse installation elsewhere up front; the CLI additionally keeps
106
+ `help`/`version` working on other platforms and rejects business commands
107
+ without writing HOME.
108
+
109
+ See [docs/operations.md](docs/operations.md) for service control, PATH
110
+ behavior, the Codex homes registry, backup/removal, and release checks; and
111
+ [docs/compatibility/codex.md](docs/compatibility/codex.md) for the verified
112
+ codex-cli interface. See [docs/product.md](docs/product.md) for the product
113
+ workflow, provider configuration, and CLI/MCP acceptance boundary.
114
+
115
+ See [docs/mcp-api.md](docs/mcp-api.md) for the complete MCP tool reference,
116
+ field usage, defaults, design rationale, and omission/removal impacts.
117
+
118
+ ## Development
119
+
120
+ ```
121
+ cargo test --workspace
122
+ node --test tests/install/*.test.mjs tests/platform/*.test.mjs tests/cli/*.test.mjs tests/contract/*.test.mjs
123
+ node --test tests/integration/*.test.mjs tools/probes/dsh-acp/probe.test.mjs
124
+ ```
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // Debug-variant MCP facade entry: selects the debug payload before
3
+ // delegating to the shared spawn shim.
4
+ process.env.EXTERNAL_SUBAGENT_VARIANT = 'debug';
5
+ await import('./external-subagent-mcp.mjs');
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // Debug-variant CLI entry: selects the parallel external-subagent-debug
3
+ // installation (own payload, state directory, LaunchAgent, plugin identity,
4
+ // and socket) before the CLI derives any name from the variant token. The
5
+ // release installation is never touched by commands run through this shim.
6
+ process.env.EXTERNAL_SUBAGENT_VARIANT = 'debug';
7
+ const { main } = await import('../cli/main.mjs');
8
+
9
+ main(process.argv.slice(2)).catch((error) => {
10
+ const code = typeof error?.code === 'string' ? error.code : 'INTERNAL_ERROR';
11
+ process.stderr.write(`${JSON.stringify({ ok: false, error: { code, message: error.message, ...(error.agentId ? { agent_id: error.agentId } : {}), ...(Number.isInteger(error.promptCount) ? { prompt_count: error.promptCount } : {}) } })}\n`);
12
+ process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
13
+ });
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ // The debug shims set EXTERNAL_SUBAGENT_VARIANT before re-entering this file,
9
+ // so the facade resolves to the variant payload staged beside the release one.
10
+ const debug = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug';
11
+ const facade = path.join(packageRoot, 'npm', debug ? 'native-debug' : 'native', 'darwin-arm64',
12
+ debug ? 'external-subagent-debug-mcp' : 'external-subagent-mcp');
13
+ const child = spawn(facade, process.argv.slice(2), { stdio: 'inherit', env: process.env });
14
+ child.on('error', (error) => {
15
+ console.error(error.message);
16
+ process.exitCode = 1;
17
+ });
18
+ child.on('exit', (code, signal) => {
19
+ process.exitCode = code ?? (signal ? 1 : 0);
20
+ });
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../cli/main.mjs';
3
+
4
+ main(process.argv.slice(2)).catch((error) => {
5
+ const code = typeof error?.code === 'string' ? error.code : 'INTERNAL_ERROR';
6
+ process.stderr.write(`${JSON.stringify({ ok: false, error: { code, message: error.message, ...(error.agentId ? { agent_id: error.agentId } : {}), ...(Number.isInteger(error.promptCount) ? { prompt_count: error.promptCount } : {}) } })}\n`);
7
+ process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
8
+ });
package/bin/zas.mjs ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../cli/main.mjs';
3
+
4
+ main(process.argv.slice(2)).catch((error) => {
5
+ const code = typeof error?.code === 'string' ? error.code : 'INTERNAL_ERROR';
6
+ process.stderr.write(`${JSON.stringify({ ok: false, error: { code, message: error.message, ...(error.agentId ? { agent_id: error.agentId } : {}) } })}\n`);
7
+ process.exitCode = Number.isInteger(error?.exitCode) ? error.exitCode : 1;
8
+ });
@@ -0,0 +1,117 @@
1
+ import { readConfig } from '../config/read.mjs';
2
+ import { CliError } from '../errors.mjs';
3
+
4
+ const INPUT_FIELDS = new Set(['operation', 'subagent', 'through', 'workspace', 'home']);
5
+
6
+ function validateInput(input) {
7
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CliError('INVALID_ARGUMENT', 'subagents input must be an object', 2);
8
+ for (const key of Object.keys(input)) {
9
+ if (!INPUT_FIELDS.has(key)) throw new CliError('INVALID_ARGUMENT', `subagents contains unsupported field: ${key}`, 2);
10
+ }
11
+ if (Object.prototype.hasOwnProperty.call(input, 'subagent')) {
12
+ if (input.subagent === null) throw new CliError('INVALID_ARGUMENT', 'subagent must be omitted or a supported subagent id; null is invalid', 2);
13
+ if (typeof input.subagent !== 'string' || input.subagent.length === 0) throw new CliError('INVALID_ARGUMENT', 'subagent must be a non-empty string', 2);
14
+ }
15
+ }
16
+
17
+ function parseProbeArgs(rest) {
18
+ const subagent = rest.shift();
19
+ if (!subagent || subagent.startsWith('--')) throw new CliError('subagent_required', 'subagents probe requires a subagent', 2);
20
+ let through = 'local';
21
+ let selectedLayer = false;
22
+ const input = { operation: 'probe', subagent };
23
+ while (rest.length > 0) {
24
+ const option = rest.shift();
25
+ if (['--local', '--auth', '--hi'].includes(option)) {
26
+ if (selectedLayer) throw new CliError('INVALID_ARGUMENT', 'subagents probe accepts exactly one of --local, --auth, or --hi', 2);
27
+ through = option.slice(2);
28
+ selectedLayer = true;
29
+ continue;
30
+ }
31
+ if (option === '--workspace' || option === '--home') {
32
+ const name = option.slice(2);
33
+ const value = rest.shift();
34
+ if (!value || value.startsWith('--')) throw new CliError('INVALID_ARGUMENT', `${option} requires a value`, 2);
35
+ if (input[name] !== undefined) throw new CliError('INVALID_ARGUMENT', `${option} may be provided only once`, 2);
36
+ input[name] = value;
37
+ continue;
38
+ }
39
+ throw new CliError('INVALID_ARGUMENT', `unsupported subagents probe option: ${option}`, 2);
40
+ }
41
+ return { ...input, through };
42
+ }
43
+
44
+ function parseModelsArgs(rest) {
45
+ const subagent = rest.shift();
46
+ if (!subagent || subagent.startsWith('--')) throw new CliError('subagent_required', 'subagents models requires a subagent', 2);
47
+ const input = { operation: 'models', subagent };
48
+ while (rest.length > 0) {
49
+ const option = rest.shift();
50
+ if (option !== '--workspace' && option !== '--home') throw new CliError('INVALID_ARGUMENT', `unsupported subagents models option: ${option}`, 2);
51
+ const name = option.slice(2);
52
+ const value = rest.shift();
53
+ if (!value || value.startsWith('--')) throw new CliError('INVALID_ARGUMENT', `${option} requires a value`, 2);
54
+ if (input[name] !== undefined) throw new CliError('INVALID_ARGUMENT', `${option} may be provided only once`, 2);
55
+ input[name] = value;
56
+ }
57
+ return input;
58
+ }
59
+
60
+ export function parseSubagentsArgs(args) {
61
+ if (args.length === 0) return { operation: 'list' };
62
+ const [operation, ...rest] = args;
63
+ if (!['list', 'status', 'probe', 'models'].includes(operation)) throw new CliError('INVALID_ARGUMENT', `unsupported subagents operation: ${operation}`, 2);
64
+ if (operation === 'probe') return parseProbeArgs(rest);
65
+ if (operation === 'models') return parseModelsArgs(rest);
66
+ if (operation === 'list' && rest.length !== 0) throw new CliError('INVALID_ARGUMENT', 'usage: subagents list', 2);
67
+ if (operation !== 'list' && rest.length > 1) throw new CliError('INVALID_ARGUMENT', `usage: subagents ${operation} [subagent]`, 2);
68
+ return { operation, ...(rest[0] ? { subagent: rest[0] } : {}) };
69
+ }
70
+
71
+ export async function subagentsCommand(paths, input = {}, options = {}) {
72
+ validateInput(input);
73
+ const operation = input.operation ?? 'list';
74
+ const config = readConfig(paths.config);
75
+ if (operation === 'list') {
76
+ return { default_subagent: config.default_subagent, config_revision: config.revision, subagents: Object.entries(config.subagents).map(([id, value]) => ({ subagent: id, ...value })) };
77
+ }
78
+ if (operation === 'models') {
79
+ if (typeof options.callDaemon !== 'function' || typeof options.socket !== 'string') throw new CliError('INTERNAL_ERROR', 'subagents models requires a daemon connection');
80
+ if (!input.subagent) throw new CliError('subagent_required', 'subagents models requires a subagent', 2);
81
+ const scope = {};
82
+ if (input.workspace !== undefined) scope.workspace = input.workspace;
83
+ if (input.home !== undefined) scope.home = input.home;
84
+ const result = await options.callDaemon(options.socket, 'agent-models', { subagent: input.subagent, scope });
85
+ return subagentView(result);
86
+ }
87
+ if (operation === 'probe') {
88
+ if (typeof options.callDaemon !== 'function' || typeof options.socket !== 'string') throw new CliError('INTERNAL_ERROR', 'subagents probe requires a daemon connection');
89
+ if (!input.subagent) throw new CliError('subagent_required', 'subagents probe requires a subagent', 2);
90
+ const scope = {};
91
+ if (input.workspace !== undefined) scope.workspace = input.workspace;
92
+ if (input.home !== undefined) scope.home = input.home;
93
+ const result = await options.callDaemon(options.socket, 'agent-probe', { subagent: input.subagent, through: input.through ?? 'local', scope });
94
+ return { ...result, evidence: subagentView(result.evidence), status: subagentView(result.status) };
95
+ }
96
+ if (operation !== 'status') throw new CliError('INVALID_ARGUMENT', `unsupported subagents operation: ${operation}`, 2);
97
+ if (typeof options.callDaemon !== 'function' || typeof options.socket !== 'string') throw new CliError('INTERNAL_ERROR', 'subagents status requires a daemon connection');
98
+ const status = await options.callDaemon(options.socket, 'status', {});
99
+ const entries = status.subagents ?? status.agents;
100
+ if (!Array.isArray(entries)) throw new CliError('PROTOCOL_ERROR', 'daemon status did not include subagent status');
101
+ const subagents = entries.filter((entry) => input.subagent === undefined || (entry.subagent ?? entry.agent) === input.subagent).map(subagentView);
102
+ if (input.subagent !== undefined && subagents.length === 0) throw new CliError('subagent_unknown', `daemon did not report subagent: ${input.subagent}`, 2);
103
+ return { service_generation: status.service_generation ?? null, subagents };
104
+ }
105
+
106
+ // Source compatibility for internal callers while the public CLI name is
107
+ // `subagents`; wire-level agent fields remain S02's responsibility.
108
+ export const agentsCommand = subagentsCommand;
109
+ export const parseAgentsArgs = parseSubagentsArgs;
110
+
111
+ // The daemon wire contract is migrated by S02; the CLI already exposes the
112
+ // canonical execution-target name at its own boundary.
113
+ function subagentView(value) {
114
+ if (!value) return value;
115
+ const { agent, ...rest } = value;
116
+ return { subagent: value.subagent ?? agent, ...rest };
117
+ }
@@ -0,0 +1,117 @@
1
+ import { readConfig } from '../config/read.mjs';
2
+ import { updateConfig, writeConfig } from '../config/write.mjs';
3
+ import { SUBAGENT_IDS } from '../config/schema.mjs';
4
+ import { CliError } from '../errors.mjs';
5
+
6
+ const CONFIG_INPUT_FIELDS = new Set(['operation', 'patch', 'key']);
7
+ const GET_KEYS = new Set([
8
+ 'default_subagent',
9
+ ...SUBAGENT_IDS.flatMap((agent) => [`subagents.${agent}.enabled`, `subagents.${agent}.spawn_supported`, `subagents.${agent}.default_model`, `subagents.${agent}.runtime_path`, `subagents.${agent}.home`, `subagents.${agent}.profile`, `subagents.${agent}.version`]),
10
+ ]);
11
+ const SET_KEYS = GET_KEYS;
12
+
13
+ function rejectUnknownInput(input, allowed = CONFIG_INPUT_FIELDS) {
14
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CliError('INVALID_ARGUMENT', 'config input must be an object', 2);
15
+ for (const key of Object.keys(input)) {
16
+ if (!allowed.has(key)) throw new CliError('INVALID_ARGUMENT', `config contains unsupported field: ${key}`, 2);
17
+ }
18
+ }
19
+
20
+ function parseValue(raw) {
21
+ if (raw === 'null') return null;
22
+ if (raw === 'true') return true;
23
+ if (raw === 'false') return false;
24
+ return raw;
25
+ }
26
+
27
+ function patchFor(key, value) {
28
+ if (!SET_KEYS.has(key)) throw new CliError('INVALID_ARGUMENT', `unsupported config key: ${key}`, 2);
29
+ const parts = key.split('.');
30
+ if (parts.length === 1) return { [key]: value };
31
+ return { subagents: { [parts[1]]: { [parts[2]]: value } } };
32
+ }
33
+
34
+ function unsetPatch(key) {
35
+ if (!SET_KEYS.has(key)) throw new CliError('INVALID_ARGUMENT', `unsupported config key: ${key}`, 2);
36
+ if (key === 'default_subagent') return { default_subagent: null };
37
+ const [, agent, field] = key.split('.');
38
+ const defaults = {
39
+ enabled: agent === 'zcode',
40
+ spawn_supported: agent === 'zcode',
41
+ default_model: null, runtime_path: null, home: null, profile: null, version: null,
42
+ };
43
+ return { subagents: { [agent]: { [field]: defaults[field] } } };
44
+ }
45
+
46
+ function valueFor(config, key) {
47
+ if (!GET_KEYS.has(key)) throw new CliError('INVALID_ARGUMENT', `unsupported config key: ${key}`, 2);
48
+ return key.split('.').reduce((value, part) => value?.[part], config);
49
+ }
50
+
51
+ function mergePatch(current, patch) {
52
+ return {
53
+ ...current,
54
+ ...patch,
55
+ subagents: {
56
+ ...current.subagents,
57
+ ...Object.fromEntries(Object.entries(patch.subagents || {}).map(([agent, value]) => [
58
+ agent,
59
+ { ...current.subagents[agent], ...value },
60
+ ])),
61
+ },
62
+ };
63
+ }
64
+
65
+ export function parseConfigArgs(args) {
66
+ if (args.length === 0) return { operation: 'get' };
67
+ const [operation, ...rest] = args;
68
+ if (operation === 'get') {
69
+ if (rest.length > 1) throw new CliError('INVALID_ARGUMENT', 'usage: config get [key]', 2);
70
+ return { operation, ...(rest[0] ? { key: rest[0] } : {}) };
71
+ }
72
+ if (operation === 'set') {
73
+ if (rest.length !== 2) throw new CliError('INVALID_ARGUMENT', 'usage: config set <key> <value>', 2);
74
+ return { operation, patch: patchFor(rest[0], parseValue(rest[1])) };
75
+ }
76
+ if (operation === 'show') {
77
+ if (rest.length !== 0) throw new CliError('INVALID_ARGUMENT', 'usage: config show', 2);
78
+ return { operation };
79
+ }
80
+ if (operation === 'unset') {
81
+ if (rest.length !== 1) throw new CliError('INVALID_ARGUMENT', 'usage: config unset <key>', 2);
82
+ unsetPatch(rest[0]);
83
+ return { operation, key: rest[0] };
84
+ }
85
+ throw new CliError('INVALID_ARGUMENT', `unsupported config operation: ${operation}`, 2);
86
+ }
87
+
88
+ export function configCommand(paths, input = {}) {
89
+ rejectUnknownInput(input);
90
+ const operation = input.operation ?? 'get';
91
+ const current = readConfig(paths.config);
92
+ if (operation === 'set') {
93
+ if (!input.patch || typeof input.patch !== 'object' || Array.isArray(input.patch)) throw new CliError('INVALID_ARGUMENT', 'config set requires an object patch', 2);
94
+ if (Object.hasOwn(input.patch, 'revision')) throw new CliError('INVALID_ARGUMENT', 'config revision is managed by the writer', 2);
95
+ if (input.patch.subagents !== undefined && (!input.patch.subagents || typeof input.patch.subagents !== 'object' || Array.isArray(input.patch.subagents))) {
96
+ throw new CliError('INVALID_ARGUMENT', 'config subagents patch must be an object', 2);
97
+ }
98
+ for (const [agent, value] of Object.entries(input.patch.subagents || {})) {
99
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new CliError('CONFIG_INVALID', `subagents.${agent} must be an object`, 2);
100
+ }
101
+ return { config: updateConfig(paths.config, (latest) => mergePatch(latest, input.patch)) };
102
+ }
103
+ if (operation === 'show') {
104
+ if (input.patch !== undefined || input.key !== undefined) throw new CliError('INVALID_ARGUMENT', 'config show does not accept key or patch', 2);
105
+ return { config: current };
106
+ }
107
+ if (operation === 'unset') {
108
+ if (input.patch !== undefined || typeof input.key !== 'string') throw new CliError('INVALID_ARGUMENT', 'config unset requires exactly one supported key', 2);
109
+ return { config: updateConfig(paths.config, (latest) => mergePatch(latest, unsetPatch(input.key))) };
110
+ }
111
+ if (operation === 'get') {
112
+ if (input.patch !== undefined) throw new CliError('INVALID_ARGUMENT', 'config get does not accept patch', 2);
113
+ if (input.key === undefined) return { config: current };
114
+ return { key: input.key, value: valueFor(current, input.key), revision: current.revision };
115
+ }
116
+ throw new CliError('INVALID_ARGUMENT', `unsupported config operation: ${operation}`, 2);
117
+ }
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs';
2
+ import { bootoutService, bootstrapService } from '../install/service-macos.mjs';
3
+ import { loadCodexHomes } from '../install/reconcile.mjs';
4
+ import { verifyPayload } from '../install/payload.mjs';
5
+
6
+ // Daemon/service command surface: explicit start/stop through the managed
7
+ // LaunchAgent, and the local installation summary consumed by `status`.
8
+
9
+ export function startDaemon(paths) {
10
+ return bootstrapService(paths);
11
+ }
12
+
13
+ export function stopDaemon(paths) {
14
+ return bootoutService(paths);
15
+ }
16
+
17
+ export function localInstallStatus(paths, { verbose = false } = {}) {
18
+ let payload = { status: 'unverified' };
19
+ try { payload = verifyPayload(); } catch (error) {
20
+ payload = { status: 'invalid', error: { code: error.code || 'PAYLOAD_INVALID', message: error.message } };
21
+ }
22
+ const registry = loadCodexHomes(paths);
23
+ const publicPayload = verbose
24
+ ? payload
25
+ : { status: payload.status, platform: payload.platform, version: payload.version };
26
+ return {
27
+ installed: fs.existsSync(paths.state),
28
+ launch_agent: fs.existsSync(paths.launchAgent),
29
+ data: fs.existsSync(paths.data),
30
+ payload: publicPayload,
31
+ // Home paths are installation internals; status reports only the
32
+ // aggregate binding state. Detailed paths remain available to diagnose.
33
+ codex_homes: registry.registry.homes.map((entry) => ({ version: entry.version, last_status: entry.last_status })),
34
+ ...(verbose && registry.recovery ? { codex_homes_recovery: registry.recovery } : {}),
35
+ };
36
+ }
@@ -0,0 +1,27 @@
1
+ import { backupData, cleanupLegacy, purge, restoreData, uninstall as removeService } from '../maintenance.mjs';
2
+ import { unregisterAllCodexHomes } from '../install/reconcile.mjs';
3
+ import { uninstallZcodePlugin } from '../install/zcode.mjs';
4
+
5
+ // Maintenance command coordination. The ES-owned service is booted out and
6
+ // its definition removed before any D08 claim is released: a bootout that
7
+ // cannot complete fails the command with the registry exactly as it was, so
8
+ // an uninstall retry (or a reconcile) still sees every claimed home instead
9
+ // of a wiped registry. Only a fully removed service releases the claims;
10
+ // product data is retained by contract, and the emptied registry travels
11
+ // with it so no home remains claimed. The stateless ZCode binding is
12
+ // released the same way: the product-owned staging tree and its single
13
+ // plugins.dirs entry go away together.
14
+
15
+ export function uninstall(paths, options = {}) {
16
+ const removed = removeService(paths, options);
17
+ const released = unregisterAllCodexHomes(paths);
18
+ // An unreadable ZCode config is foreign state, not a product claim: report
19
+ // the release failure instead of failing the whole uninstall over it.
20
+ let zcode;
21
+ try { zcode = uninstallZcodePlugin(paths); } catch (error) {
22
+ zcode = { uninstalled: false, error: { code: error.code || 'ZCODE_BINDING_FAILED', message: error.message } };
23
+ }
24
+ return { ...removed, codex_homes_unregistered: released.unregistered, zcode_binding: zcode };
25
+ }
26
+
27
+ export { backupData, cleanupLegacy, purge, restoreData };
@@ -0,0 +1,96 @@
1
+ import { CliError } from '../errors.mjs';
2
+ import { installMcp, installPlugin, uninstallPlugin } from '../install/codex.mjs';
3
+ import { installZcodePlugin, uninstallZcodePlugin } from '../install/zcode.mjs';
4
+ import { registerCodexHome, unregisterCodexHome } from '../install/reconcile.mjs';
5
+ import { verifyPayload } from '../install/payload.mjs';
6
+
7
+ // Command surface for the managed host bindings. `install-plugin` takes an
8
+ // optional host argument: codex (the default, for backward compatibility)
9
+ // stages the plugin and goes through the official codex CLI, claiming the
10
+ // Codex home in the D08 registry; zcode stages the same plugin into a
11
+ // product-owned tree and registers it through the ZCode config's inline
12
+ // plugin dirs. The MCP TOML binding stays an explicit codex-only
13
+ // alternative for hosts without plugin support.
14
+
15
+ const PLUGIN_HOSTS = ['codex', 'zcode'];
16
+
17
+ function parseCommon(args, flags) {
18
+ const options = {};
19
+ for (let index = 0; index < args.length; index += 1) {
20
+ const arg = args[index];
21
+ if (!arg.startsWith('--')) {
22
+ if (options.host) throw new CliError('INVALID_ARGUMENT', `plugin host was already given: ${options.host}`, 2);
23
+ if (!PLUGIN_HOSTS.includes(arg)) throw new CliError('INVALID_ARGUMENT', `unsupported plugin host: ${arg} (expected ${PLUGIN_HOSTS.join(' or ')})`, 2);
24
+ options.host = arg;
25
+ } else if (flags.includes(arg)) {
26
+ options[flagName(arg)] = true;
27
+ } else if (arg === '--codex-home') {
28
+ const value = args[index + 1];
29
+ if (!value || value.startsWith('--')) throw new CliError('INVALID_ARGUMENT', '--codex-home requires a value', 2);
30
+ options.codexHome = value;
31
+ index += 1;
32
+ } else {
33
+ throw new CliError('INVALID_ARGUMENT', `unsupported plugin option: ${arg}`, 2);
34
+ }
35
+ }
36
+ return options;
37
+ }
38
+
39
+ function flagName(flag) {
40
+ return flag.replace(/^--/u, '').replaceAll('-', '_');
41
+ }
42
+
43
+ export function pluginCommand(paths, args) {
44
+ const options = parseCommon(args, ['--dry-run', '--uninstall']);
45
+ const installerOptions = withInstallerFlags(options);
46
+ if (options.host === 'zcode') {
47
+ if (options.codexHome) throw new CliError('INVALID_ARGUMENT', '--codex-home applies to the codex host only', 2);
48
+ return options.uninstall ? uninstallZcodePlugin(paths, installerOptions) : installZcodePlugin(paths, installerOptions);
49
+ }
50
+ if (options.uninstall) {
51
+ const result = uninstallPlugin(paths, installerOptions);
52
+ if (!options.dry_run && result.uninstalled) {
53
+ const claim = unregisterCodexHome(paths, result.codex_home, 'plugin');
54
+ result.claim_released = claim.unregistered;
55
+ }
56
+ return result;
57
+ }
58
+ const result = installPlugin(paths, installerOptions);
59
+ if (!options.dry_run && result.installed) {
60
+ const payload = safePayloadVersion();
61
+ const claim = registerCodexHome(paths, result.codex_home, { version: payload, digest: result.digest, binding_mode: 'plugin' });
62
+ result.claim = { registered: claim.registered, deduplicated: claim.deduplicated, homes: claim.homes };
63
+ }
64
+ return result;
65
+ }
66
+
67
+ export function mcpCommand(paths, args) {
68
+ const options = parseCommon(args, ['--dry-run', '--uninstall']);
69
+ // The MCP TOML binding is codex-only (see the header note): a non-codex
70
+ // host must fail here, before any installer or registry call, instead of
71
+ // being silently ignored while the codex config is rewritten.
72
+ if (options.host && options.host !== 'codex') {
73
+ throw new CliError('INVALID_ARGUMENT', `unsupported mcp host: ${options.host} (install-mcp binds the codex host only)`, 2);
74
+ }
75
+ const installerOptions = withInstallerFlags(options);
76
+ const result = installMcp(paths, installerOptions);
77
+ if (!options.dry_run && result.installed) {
78
+ const claim = registerCodexHome(paths, result.codex_home || installerOptions.codexHome, { binding_mode: 'mcp', status: 'claimed' });
79
+ result.claim = { registered: claim.registered, deduplicated: claim.deduplicated, homes: claim.homes };
80
+ } else if (!options.dry_run && options.uninstall) {
81
+ const claim = unregisterCodexHome(paths, result.codex_home || installerOptions.codexHome, 'mcp');
82
+ result.claim_released = claim.unregistered;
83
+ }
84
+ return result;
85
+ }
86
+
87
+ // The install layer speaks camelCase (dryRun); CLI parsing produces snake_case
88
+ // flags. Normalize at the boundary so --dry-run actually holds the installers
89
+ // back instead of running a real install/uninstall.
90
+ function withInstallerFlags(options) {
91
+ return { ...options, dryRun: Boolean(options.dry_run) };
92
+ }
93
+
94
+ function safePayloadVersion() {
95
+ try { return verifyPayload().version; } catch { return null; }
96
+ }