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,64 @@
1
+ import { CliError } from '../errors.mjs';
2
+
3
+ const SPAWN_FIELDS = new Set(['subagent', 'repository', 'permission_mode', 'prompt', 'model', 'effort', 'write_manifest']);
4
+ const FLAG_FIELDS = new Map([
5
+ ['--subagent', 'subagent'],
6
+ ['--repository', 'repository'],
7
+ ['--prompt', 'prompt'],
8
+ ['--permission-mode', 'permission_mode'],
9
+ ['--model', 'model'],
10
+ ['--effort', 'effort'],
11
+ ]);
12
+
13
+ function flagValue(args, option) {
14
+ const value = args.shift();
15
+ if (value === undefined || value === '' || value.startsWith('--')) {
16
+ throw new CliError('INVALID_ARGUMENT', `${option} requires a non-null value`, 2);
17
+ }
18
+ return value;
19
+ }
20
+
21
+ export function parseSpawnArgs(args) {
22
+ const input = {};
23
+ const remaining = [...args];
24
+ while (remaining.length > 0) {
25
+ const option = remaining.shift();
26
+ if (option === '--write-manifest') {
27
+ (input.write_manifest ||= []).push(flagValue(remaining, option));
28
+ continue;
29
+ }
30
+ const field = FLAG_FIELDS.get(option);
31
+ if (!field) throw new CliError('INVALID_ARGUMENT', `unsupported spawn option: ${option}`, 2);
32
+ if (Object.hasOwn(input, field)) throw new CliError('INVALID_ARGUMENT', `${option} may be provided only once`, 2);
33
+ input[field] = flagValue(remaining, option);
34
+ }
35
+ if (!Object.hasOwn(input, 'repository')) throw new CliError('INVALID_ARGUMENT', '--repository is required', 2);
36
+ if (!Object.hasOwn(input, 'prompt')) throw new CliError('INVALID_ARGUMENT', '--prompt is required', 2);
37
+ return prepareSpawnInput(input);
38
+ }
39
+
40
+ export function prepareSpawnInput(input) {
41
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CliError('INVALID_ARGUMENT', 'spawn input must be an object', 2);
42
+ for (const key of Object.keys(input)) {
43
+ if (!SPAWN_FIELDS.has(key)) throw new CliError('INVALID_ARGUMENT', `spawn contains unsupported field: ${key}`, 2);
44
+ }
45
+ if (Object.prototype.hasOwnProperty.call(input, 'subagent') && input.subagent === null) {
46
+ throw new CliError('INVALID_ARGUMENT', 'subagent must be omitted or a supported subagent id; null is invalid', 2);
47
+ }
48
+ if (input.subagent !== undefined && (typeof input.subagent !== 'string' || input.subagent.length === 0)) {
49
+ throw new CliError('INVALID_ARGUMENT', 'subagent must be a non-empty string', 2);
50
+ }
51
+ if (Object.prototype.hasOwnProperty.call(input, 'model') && input.model === null) {
52
+ throw new CliError('INVALID_ARGUMENT', 'model must be omitted or a non-null model token', 2);
53
+ }
54
+ if (input.model !== undefined && (typeof input.model !== 'string' || input.model.length === 0)) {
55
+ throw new CliError('INVALID_ARGUMENT', 'model must be a non-empty string', 2);
56
+ }
57
+ if (Object.prototype.hasOwnProperty.call(input, 'effort') && input.effort === null) {
58
+ throw new CliError('INVALID_ARGUMENT', 'effort must be omitted or a non-null effort token', 2);
59
+ }
60
+ if (input.effort !== undefined && (typeof input.effort !== 'string' || input.effort.length === 0)) {
61
+ throw new CliError('INVALID_ARGUMENT', 'effort must be a non-empty string', 2);
62
+ }
63
+ return { ...input };
64
+ }
@@ -0,0 +1,219 @@
1
+ import { npmUpdateCoordination, reconcileCodexHomes } from '../install/reconcile.mjs';
2
+ import { reconcileZcodeBinding } from '../install/zcode.mjs';
3
+ import { preflightUpdate, reconcileInstallation, updateInstallation } from '../install/update.mjs';
4
+ import { callDaemon, canonicalDaemonCode } from '../rpc.mjs';
5
+ import { CliError } from '../errors.mjs';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { atomicWrite, jsonBytes } from '../fs-atomic.mjs';
9
+ import { activateService, hasInstalledService } from '../install/service-activation.mjs';
10
+
11
+ const receiptPath = (paths) => `${paths.state}.activation.json`;
12
+
13
+ // AUD-003: the ONE bounded reducer over the two supported host integrations.
14
+ // Codex homes and the ZCode binding refresh as part of the same activation,
15
+ // so the top-level verdict must reduce BOTH outcomes on every
16
+ // update/reconcile path — including pure reconcile and service-less runs,
17
+ // where a failed host integration used to hide behind a success receipt.
18
+ // Per-host detail is preserved verbatim for the receipt and the error; an
19
+ // absent zcode binding is not a failure, and an unbound host is never
20
+ // touched. Returns null when every host is fully reconciled.
21
+ function partialHostSync(result) {
22
+ const homes = Array.isArray(result?.homes?.homes) ? result.homes.homes : [];
23
+ const failures = (homes.length > 0 && !result.homes.all_updated
24
+ ? homes.filter((home) => home.status !== 'updated')
25
+ : []).map((home) => `codex ${home.home}: ${home.status}${home.error ? ` (${home.error.code}: ${home.error.message})` : ''}`);
26
+ const zcodeFailed = result?.zcode?.status === 'failed';
27
+ if (zcodeFailed) {
28
+ const { error } = result.zcode;
29
+ failures.push(`zcode: ${error ? `${error.code}: ${error.message}` : 'binding refresh failed'}`);
30
+ }
31
+ return failures.length > 0 ? { zcodeFailed, failures } : null;
32
+ }
33
+
34
+ export async function updateCommand(paths, args = [], daemon = {}) {
35
+ const cancelActive = args.includes('--cancel-active');
36
+ const yes = args.includes('--yes');
37
+ if (cancelActive && !yes) throw new Error('--cancel-active requires --yes');
38
+ const prior = (() => { try { return JSON.parse(fs.readFileSync(receiptPath(paths), 'utf8')); } catch { return null; } })();
39
+ const priorStateBytes = (() => { try { return fs.readFileSync(paths.state); } catch { return null; } })();
40
+ const registryPath = paths.data ? path.join(paths.data, 'codex-homes.json') : null;
41
+ const priorRegistryBytes = (() => { try { return fs.readFileSync(registryPath); } catch { return null; } })();
42
+ const requestedVersion = args.find((arg) => arg.startsWith('--version='))?.slice(10) || 'current';
43
+ const socket = daemon.socket || process.env.ZCODE_AGENTD_SOCKET || paths.socket;
44
+ const rpc = daemon.callDaemon || callDaemon;
45
+ const drainDeadline = Number.isFinite(daemon.drainTimeoutMs) && daemon.drainTimeoutMs > 0
46
+ ? Date.now() + daemon.drainTimeoutMs : null;
47
+ // npm auto-coordination (U06): an already-initialized product whose
48
+ // installed package drifted from the published active payload coordinates
49
+ // through the full update path even when invoked as `reconcile` — the
50
+ // ignore-scripts flow, where no lifecycle hook ever fired. A matching
51
+ // version merely re-affirms, and a never-initialized product stays
52
+ // stage-only.
53
+ const detected = npmUpdateCoordination(paths);
54
+ const reconciling = args.includes('reconcile');
55
+ const coordinate = !reconciling || detected.update_pending;
56
+ // A doomed candidate must never take the working daemon offline: whenever
57
+ // this run may activate a payload, the SAME pure validation the locked
58
+ // updater owns runs BEFORE the drain RPC, so a bad version or manifest
59
+ // returns with the daemon still serving spawns. A pure reaffirmal that
60
+ // activates nothing drains only for its claim and needs no candidate.
61
+ const preflight = daemon.preflightUpdate || preflightUpdate;
62
+ if (!(reconciling && !coordinate)) preflight({ version: requestedVersion });
63
+ let status = null;
64
+ let online = true;
65
+ try {
66
+ status = await rpc(socket, 'drain', cancelActive ? { cancel_active: true } : {});
67
+ } catch (error) {
68
+ // A legacy daemon answers the cancel_active param with the raw wire code
69
+ // ("validation"), so the check matches the canonicalized form.
70
+ if (cancelActive && ['VALIDATION', 'UNKNOWN_METHOD'].includes(canonicalDaemonCode(error.code))) {
71
+ throw new CliError('CANCEL_ACTIVE_UNSUPPORTED', 'running daemon does not support explicit drain cancellation; cancellation was not downgraded to passive drain');
72
+ }
73
+ // No daemon is listening and none answered: an already-initialized
74
+ // service is (re)started by the activation itself, so there is nothing
75
+ // to drain and the CLI coordination stays reachable after an
76
+ // ignore-scripts install. A daemon that ANSWERED with a protocol-level
77
+ // error still fails loudly instead of being treated as offline.
78
+ if (error.code !== 'SOCKET_UNAVAILABLE' || error.daemonResponded) throw error;
79
+ online = false;
80
+ }
81
+ if (online) {
82
+ while (!status.ready_for_activation) {
83
+ if (drainDeadline !== null && Date.now() >= drainDeadline) {
84
+ try { await rpc(socket, 'drain-abort', {}); } catch { /* preserve timeout as the primary error */ }
85
+ throw new CliError('UPDATE_DRAIN_TIMEOUT', 'update drain exceeded its bounded deadline; daemon drain was aborted');
86
+ }
87
+ await new Promise((resolve) => setTimeout(resolve, 50));
88
+ status = await rpc(socket, 'drain-status', {});
89
+ }
90
+ status = await rpc(socket, 'activate-ready', {});
91
+ }
92
+ const claim = online ? status.activation_claim : `offline-${process.pid}-${Date.now()}-activation`;
93
+ if (!claim) return { ...status, activation_claim: null, update: 'not_activated' };
94
+ if (prior?.status === 'success' && prior.claim === claim
95
+ && (requestedVersion === 'current' || prior.version === requestedVersion)) return prior.result;
96
+ // The rollback source for service activation: the retained copy of the
97
+ // PREVIOUS active payload, staged outside the npm directory the new
98
+ // version may already have replaced.
99
+ const previousActive = (() => {
100
+ if (!priorStateBytes) return null;
101
+ try { return JSON.parse(priorStateBytes)?.active ?? null; } catch { return null; }
102
+ })();
103
+ const rollbackPayload = previousActive?.retained?.daemon_entry
104
+ ? { path: previousActive.retained.daemon_entry, sha256: previousActive.retained.daemon_entry_sha256 ?? previousActive.daemon_entry_sha256 }
105
+ : (previousActive?.daemon_entry ? { path: previousActive.daemon_entry, sha256: previousActive.daemon_entry_sha256 } : null);
106
+ let result;
107
+ // Set when the verified activation completed but a host binding sync
108
+ // (Codex homes, ZCode config) is partial or failed: every such host is
109
+ // retryable through the public reconcile owner, so the completed
110
+ // activation stays published and the partial sync is reported AFTER the
111
+ // guarded block — never as success, and never as a reason to roll a
112
+ // healthy service or payload back.
113
+ let partialHosts = null;
114
+ try {
115
+ const serviceInstalled = typeof daemon.hasInstalledService === 'function' ? daemon.hasInstalledService(paths) : hasInstalledService(paths);
116
+ const serviceDue = serviceInstalled && !daemon.skipServiceActivation;
117
+ result = (reconciling && !coordinate)
118
+ ? { ...reconcileInstallation(paths, { cancelActive, yes }), homes: reconcileCodexHomes(paths), zcode: reconcileZcodeBinding(paths) }
119
+ : await (daemon.updateInstallation || updateInstallation)(paths, { version: requestedVersion, deferCodexSync: serviceDue });
120
+ // The zcode binding has neither a registry nor a service dependency, so
121
+ // it refreshes on every activation attempt — including service-less
122
+ // installs, where the codex homes sync runs inside updateInstallation
123
+ // instead of the service activation block below.
124
+ if (!result.zcode) result.zcode = reconcileZcodeBinding(paths);
125
+ if (!result || typeof result !== 'object' || result.phase !== 'active') {
126
+ throw new Error(`installation update did not activate payload (phase=${result?.phase ?? 'none'})`);
127
+ }
128
+ if (serviceDue && coordinate && (!result.active?.entry || !result.active?.entry_sha256
129
+ || !result.active?.daemon_entry || !result.active?.daemon_entry_sha256)) {
130
+ // An explicit update exists to switch the service onto the verified
131
+ // daemon payload; an identity it cannot verify must fail rather than
132
+ // silently skip activation.
133
+ throw new CliError('ACTIVE_ENTRY_UNVERIFIED', 'activated payload has no verified daemon artifact to activate');
134
+ }
135
+ if (result?.active?.daemon_entry && result?.active?.daemon_entry_sha256 && serviceDue) {
136
+ const activate = daemon.activateService || activateService;
137
+ // The LaunchAgent runs the native daemon, so activation carries the
138
+ // verified daemon artifact identity — never the npm bin shim. Prefer
139
+ // the retained immutable copy so a later npm replacement cannot alter
140
+ // the bytes the service executes.
141
+ const serviceArtifact = result.active.retained?.daemon_entry
142
+ ? { path: result.active.retained.daemon_entry, sha256: result.active.retained.daemon_entry_sha256 ?? result.active.daemon_entry_sha256 }
143
+ : { path: result.active.daemon_entry, sha256: result.active.daemon_entry_sha256 };
144
+ result.service = await activate(paths, {
145
+ path: serviceArtifact.path,
146
+ sha256: serviceArtifact.sha256,
147
+ version: result.active.version,
148
+ }, { ...daemon, rollbackPayload });
149
+ result.homes = reconcileCodexHomes(paths);
150
+ }
151
+ const receiptVersion = result?.active?.version || result?.version || requestedVersion;
152
+ partialHosts = partialHostSync(result);
153
+ atomicWrite(receiptPath(paths), jsonBytes(partialHosts
154
+ ? { claim, version: receiptVersion, status: 'partial', retryable: true, homes: result.homes, zcode: result.zcode, result }
155
+ : { claim, version: receiptVersion, status: 'success', result }));
156
+ if (partialHosts === null) return result;
157
+ } catch (error) {
158
+ // Snapshot what the updater recorded before rollback replaces it, so the
159
+ // receipt keeps the candidate and active evidence the retry will need.
160
+ const failedInstall = (() => {
161
+ try { return JSON.parse(fs.readFileSync(paths.state, 'utf8')); } catch { return null; }
162
+ })();
163
+ let rollback = { attempted: false, restored: false };
164
+ if (priorStateBytes && paths.state) {
165
+ rollback.attempted = true;
166
+ try { atomicWrite(paths.state, priorStateBytes); rollback.restored = true; } catch (restoreError) { rollback.error = restoreError.message; }
167
+ } else if (paths.state) {
168
+ rollback.attempted = true;
169
+ try { fs.rmSync(paths.state, { force: true }); rollback.restored = true; } catch (restoreError) { rollback.error = restoreError.message; }
170
+ }
171
+ if (registryPath) {
172
+ try {
173
+ if (priorRegistryBytes) atomicWrite(registryPath, priorRegistryBytes);
174
+ else if (fs.existsSync(registryPath)) fs.rmSync(registryPath, { force: true });
175
+ } catch (restoreError) {
176
+ rollback.registry_error = restoreError.message;
177
+ }
178
+ }
179
+ if (result?.service?.rollback) {
180
+ try { await result.service.rollback(); rollback.service_restored = true; } catch (restoreError) { rollback.service_error = restoreError.message; }
181
+ }
182
+ if (error.rollback) {
183
+ rollback.service = error.rollback;
184
+ rollback.service_restored = Boolean(error.rollback.pid && error.rollback.artifact);
185
+ }
186
+ const receipt = { claim, version: requestedVersion, status: 'failed', retryable: true, error: error.message, rollback };
187
+ // This run drained a live daemon and never completed an activation:
188
+ // bounded recovery reopens admission on the still-running old daemon so
189
+ // it keeps serving spawns, while its completed-task and reap facts stay
190
+ // untouched. A daemon that cannot abort (legacy, answering the raw
191
+ // snake_case wire code) or will not yet (a --cancel-active worker still
192
+ // in flight) keeps that evidence in the receipt — the canonical abort
193
+ // code plus an explicit marker that the daemon may still be draining —
194
+ // and the failure stays retryable; the abort outcome never masks the
195
+ // original update error.
196
+ if (online && !result?.service) {
197
+ try {
198
+ await rpc(socket, 'drain-abort', {});
199
+ receipt.drain_aborted = true;
200
+ } catch (abortError) {
201
+ receipt.drain_aborted = false;
202
+ receipt.drain_abort_error = { code: canonicalDaemonCode(abortError.code), message: abortError.message };
203
+ receipt.daemon_may_be_draining = true;
204
+ }
205
+ }
206
+ if (failedInstall && typeof failedInstall === 'object' && failedInstall.phase) {
207
+ receipt.install_state = { phase: failedInstall.phase, candidate: failedInstall.candidate ?? null, active: failedInstall.active ?? null };
208
+ }
209
+ atomicWrite(receiptPath(paths), jsonBytes(receipt));
210
+ throw error;
211
+ }
212
+ // Reached only with a completed, verified activation whose host binding
213
+ // sync is partial (AUD-003). The per-host facts stay actionable — in the
214
+ // error, the receipt, and the per-host registries the reconcile owners
215
+ // already persisted — and the public reconcile command finishes the
216
+ // remaining hosts idempotently. The activation itself stays published.
217
+ throw new CliError(partialHosts.zcodeFailed ? 'ZCODE_SYNC_PARTIAL' : 'CODEX_SYNC_PARTIAL',
218
+ `activation completed at ${result?.active?.version || requestedVersion}, but host binding reconciliation is partial — ${partialHosts.failures.join('; ')}; run 'external-subagent reconcile' after fixing the listed hosts`);
219
+ }
@@ -0,0 +1,24 @@
1
+ import fs from 'node:fs';
2
+ import { defaultConfig, validateConfig } from './schema.mjs';
3
+ import { CliError } from '../errors.mjs';
4
+
5
+ function migrateLegacyConfig(value) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
7
+ if (value.schema_version !== undefined && value.schema_version !== 1) return value;
8
+ const legacy = Object.hasOwn(value, 'agents') || Object.hasOwn(value, 'default_agent');
9
+ if (Object.hasOwn(value, 'subagents') || Object.hasOwn(value, 'default_subagent')) {
10
+ if (legacy || value.schema_version === 1) throw new CliError('CONFIG_INVALID', 'schema-1 config cannot contain canonical subagent fields', 2);
11
+ return value;
12
+ }
13
+ const { agents, default_agent, ...rest } = value;
14
+ return { ...rest, schema_version: 2, ...(agents === undefined ? {} : { subagents: agents }), ...(default_agent === undefined ? {} : { default_subagent: default_agent }) };
15
+ }
16
+
17
+ export function parseConfig(value) {
18
+ return validateConfig(migrateLegacyConfig(value));
19
+ }
20
+
21
+ export function readConfig(file) {
22
+ try { return parseConfig(JSON.parse(fs.readFileSync(file, 'utf8'))); }
23
+ catch (error) { if (error?.code === 'ENOENT') return defaultConfig(); if (error instanceof SyntaxError) error.code = 'CONFIG_INVALID'; throw error; }
24
+ }
@@ -0,0 +1,75 @@
1
+ import { CliError } from '../errors.mjs';
2
+
3
+ export const CONFIG_SCHEMA_VERSION = 2;
4
+ export const SUBAGENT_IDS = Object.freeze(['zcode', 'dsh', 'codex']);
5
+ const CONFIG_FIELDS = new Set(['schema_version', 'revision', 'default_subagent', 'subagents']);
6
+ const AGENT_FIELDS = new Set(['enabled', 'spawn_supported', 'default_model', 'runtime_path', 'home', 'profile', 'version']);
7
+
8
+ function plainObject(value) {
9
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
10
+ }
11
+
12
+ function rejectUnknownFields(value, allowed, scope) {
13
+ for (const key of Object.keys(value)) {
14
+ if (!allowed.has(key)) throw new CliError('CONFIG_INVALID', `${scope} contains unknown field: ${key}`, 2);
15
+ }
16
+ }
17
+
18
+ export function defaultConfig() {
19
+ return {
20
+ schema_version: CONFIG_SCHEMA_VERSION,
21
+ revision: 0,
22
+ default_subagent: null,
23
+ subagents: {
24
+ zcode: { enabled: true, spawn_supported: true, default_model: null },
25
+ dsh: { enabled: false, spawn_supported: false, default_model: null, runtime_path: null, home: null, profile: null, version: null },
26
+ codex: { enabled: false, spawn_supported: false, default_model: null, runtime_path: null, home: null, profile: null, version: null },
27
+ },
28
+ };
29
+ }
30
+
31
+ function rejectModel(agent, value, field) {
32
+ if (agent === 'zcode' && value != null) {
33
+ throw new CliError('model_selection_unsupported', `${field} is unsupported for zcode`, 2);
34
+ }
35
+ }
36
+
37
+ // AUD-005/D1: a zcode runtime_path has no consumer — the service resolves the
38
+ // pinned packaged ZCode runtime itself (see service-macos.mjs) — so the config
39
+ // gate must reject it instead of silently accepting a no-op. dsh and codex
40
+ // consume runtime_path on every service path and stay configurable.
41
+ function rejectZcodeRuntimePath(agent, value) {
42
+ if (agent === 'zcode' && value != null) {
43
+ throw new CliError('runtime_path_unsupported', 'runtime_path is unsupported for zcode: the service resolves the pinned packaged ZCode runtime; only dsh and codex consume runtime_path', 2);
44
+ }
45
+ }
46
+
47
+ export function validateConfig(input) {
48
+ if (!plainObject(input)) throw new CliError('CONFIG_INVALID', 'config must be an object', 2);
49
+ rejectUnknownFields(input, CONFIG_FIELDS, 'config');
50
+ const config = defaultConfig();
51
+ if (input.schema_version !== undefined && input.schema_version !== CONFIG_SCHEMA_VERSION) throw new CliError('CONFIG_INVALID', 'unsupported config schema version', 2);
52
+ if (input.revision !== undefined && (!Number.isInteger(input.revision) || input.revision < 0)) throw new CliError('CONFIG_INVALID', 'revision must be a non-negative integer', 2);
53
+ if (input.default_subagent !== undefined && input.default_subagent !== null && !SUBAGENT_IDS.includes(input.default_subagent)) throw new CliError('CONFIG_INVALID', 'default_subagent is unknown', 2);
54
+ config.revision = input.revision ?? 0;
55
+ config.default_subagent = input.default_subagent ?? null;
56
+ if (input.subagents !== undefined && !plainObject(input.subagents)) throw new CliError('CONFIG_INVALID', 'subagents must be an object', 2);
57
+ for (const agent of Object.keys(input.subagents || {})) {
58
+ if (!SUBAGENT_IDS.includes(agent)) throw new CliError('CONFIG_INVALID', `subagents contains unknown subagent: ${agent}`, 2);
59
+ }
60
+ for (const agent of SUBAGENT_IDS) {
61
+ const value = input.subagents?.[agent];
62
+ if (value === undefined) continue;
63
+ if (!plainObject(value)) throw new CliError('CONFIG_INVALID', `subagents.${agent} must be an object`, 2);
64
+ rejectUnknownFields(value, AGENT_FIELDS, `subagents.${agent}`);
65
+ if (value.enabled !== undefined && typeof value.enabled !== 'boolean') throw new CliError('CONFIG_INVALID', `subagents.${agent}.enabled must be boolean`, 2);
66
+ if (value.spawn_supported !== undefined && typeof value.spawn_supported !== 'boolean') throw new CliError('CONFIG_INVALID', `subagents.${agent}.spawn_supported must be boolean`, 2);
67
+ if (value.default_model !== undefined && value.default_model !== null && (typeof value.default_model !== 'string' || value.default_model.length === 0)) throw new CliError('CONFIG_INVALID', `subagents.${agent}.default_model must be a non-empty string or null`, 2);
68
+ for (const field of ['runtime_path', 'home', 'profile', 'version']) { if (value[field] !== undefined && value[field] !== null && (typeof value[field] !== 'string' || value[field].length === 0)) throw new CliError('CONFIG_INVALID', `subagents.${agent}.${field} must be a non-empty string or null`, 2); }
69
+ rejectModel(agent, value.default_model, 'default_model');
70
+ rejectZcodeRuntimePath(agent, value.runtime_path);
71
+ config.subagents[agent] = { ...config.subagents[agent], ...value };
72
+ }
73
+ if (config.default_subagent && !config.subagents[config.default_subagent].enabled) throw new CliError('CONFIG_INVALID', 'default_subagent must be enabled', 2);
74
+ return config;
75
+ }
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { atomicWrite, jsonBytes } from '../fs-atomic.mjs';
5
+ import { validateConfig } from './schema.mjs';
6
+ import { readConfig } from './read.mjs';
7
+
8
+ export function writeConfig(file, value) {
9
+ const config = validateConfig(value);
10
+ config.revision += 1;
11
+ atomicWrite(file, jsonBytes(config));
12
+ return config;
13
+ }
14
+
15
+ export function updateConfig(file, updater) {
16
+ const lock = `${path.resolve(file)}.lock`;
17
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
18
+ const ready = `${lock}.${process.pid}.${Date.now()}.ready`;
19
+ const helper = spawn('/usr/bin/lockf', ['-t', '2', lock, '/bin/sh', '-c',
20
+ 'ready="$1"; parent="$2"; printf ready > "$ready"; while kill -0 "$parent" 2>/dev/null; do sleep 0.05; done',
21
+ 'external-subagent-lock', ready, String(process.pid)], { stdio: 'ignore', detached: true });
22
+ const wait = new Int32Array(new SharedArrayBuffer(4));
23
+ const deadline = Date.now() + 2500;
24
+ while (!fs.existsSync(ready)) {
25
+ if (helper.exitCode !== null || Date.now() >= deadline) {
26
+ try { process.kill(-helper.pid, 'SIGTERM'); } catch {}
27
+ throw new Error('CONFIG_LOCK_TIMEOUT');
28
+ }
29
+ Atomics.wait(wait, 0, 0, 5);
30
+ }
31
+ try {
32
+ const current = readConfig(file);
33
+ const next = updater(current);
34
+ return writeConfig(file, next);
35
+ } finally {
36
+ try { process.kill(-helper.pid, 'SIGTERM'); } catch {}
37
+ try { fs.unlinkSync(ready); } catch {}
38
+ }
39
+ }
@@ -0,0 +1,24 @@
1
+ // Development variant: EXTERNAL_SUBAGENT_VARIANT=debug selects a fully
2
+ // parallel installation (its own binaries, state directory, LaunchAgent
3
+ // label, plugin identity, and socket) so a development checkout can run a
4
+ // debug build beside the released product without touching it. The token is
5
+ // read once at module load; the debug bin shims set it before importing the
6
+ // CLI, so every derived name below is stable for the process lifetime.
7
+ const IS_DEBUG_VARIANT = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug';
8
+
9
+ export const PRODUCT_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug' : 'external-subagent';
10
+ export const PRODUCT_ID = IS_DEBUG_VARIANT ? 'external_subagent_debug' : 'external_subagent';
11
+ export const VERSION = '0.1.0';
12
+ export const LAUNCH_AGENT_LABEL = IS_DEBUG_VARIANT ? 'com.external-subagent-debug.daemon' : 'com.external-subagent.daemon';
13
+ export const DAEMON_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debugd' : 'external-subagentd';
14
+ export const MCP_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug-mcp' : 'external-subagent-mcp';
15
+ export const NATIVE_DIR_NAME = IS_DEBUG_VARIANT ? 'native-debug' : 'native';
16
+ export const CLI_ENTRY_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug.mjs' : 'external-subagent.mjs';
17
+ export const ZCODE_RUNTIME = '/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs';
18
+
19
+ export const BUSINESS_COMMANDS = new Set([
20
+ 'init', 'hooks', 'status', 'diagnose', 'backup', 'restore', 'start', 'stop',
21
+ 'uninstall', 'purge', 'cleanup-legacy', 'install-plugin', 'install-mcp', 'create', 'spawn', 'wait', 'list', 'send',
22
+ 'respond', 'cancel', 'result', 'close', 'config', 'agents', 'subagents',
23
+ 'observe', 'update', 'reconcile',
24
+ ]);
package/cli/errors.mjs ADDED
@@ -0,0 +1,7 @@
1
+ export class CliError extends Error {
2
+ constructor(code, message, exitCode = 1) {
3
+ super(message);
4
+ this.code = code;
5
+ this.exitCode = exitCode;
6
+ }
7
+ }
@@ -0,0 +1,36 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
6
+ export const jsonBytes = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
7
+
8
+ export function atomicWrite(file, bytes, mode = 0o600) {
9
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
10
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
11
+ try {
12
+ fs.writeFileSync(temporary, bytes, { mode, flag: 'wx' });
13
+ fs.renameSync(temporary, file);
14
+ } finally {
15
+ try { fs.unlinkSync(temporary); } catch (error) {
16
+ if (error?.code !== 'ENOENT') throw error;
17
+ }
18
+ }
19
+ }
20
+
21
+ export function readOptional(file) {
22
+ try { return fs.readFileSync(file); } catch (error) {
23
+ if (error?.code === 'ENOENT') return null;
24
+ throw error;
25
+ }
26
+ }
27
+
28
+ export function restoreOptional(file, bytes) {
29
+ if (bytes === null) {
30
+ try { fs.unlinkSync(file); } catch (error) {
31
+ if (error?.code !== 'ENOENT') throw error;
32
+ }
33
+ } else {
34
+ atomicWrite(file, bytes);
35
+ }
36
+ }