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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/external-subagent-debug-mcp.mjs +5 -0
- package/bin/external-subagent-debug.mjs +13 -0
- package/bin/external-subagent-mcp.mjs +20 -0
- package/bin/external-subagent.mjs +8 -0
- package/bin/zas.mjs +8 -0
- package/cli/commands/agents.mjs +117 -0
- package/cli/commands/config.mjs +117 -0
- package/cli/commands/daemon.mjs +36 -0
- package/cli/commands/maintenance.mjs +27 -0
- package/cli/commands/plugin.mjs +96 -0
- package/cli/commands/tasks.mjs +64 -0
- package/cli/commands/update.mjs +219 -0
- package/cli/config/read.mjs +24 -0
- package/cli/config/schema.mjs +75 -0
- package/cli/config/write.mjs +39 -0
- package/cli/constants.mjs +24 -0
- package/cli/errors.mjs +7 -0
- package/cli/fs-atomic.mjs +36 -0
- package/cli/install/codex.mjs +352 -0
- package/cli/install/init.mjs +206 -0
- package/cli/install/layout.mjs +54 -0
- package/cli/install/npm-hook.mjs +49 -0
- package/cli/install/path.mjs +43 -0
- package/cli/install/payload.mjs +84 -0
- package/cli/install/plugin-stage.mjs +232 -0
- package/cli/install/reconcile.mjs +209 -0
- package/cli/install/recovery.mjs +79 -0
- package/cli/install/service-activation.mjs +133 -0
- package/cli/install/service-macos.mjs +146 -0
- package/cli/install/update.mjs +170 -0
- package/cli/install/zcode.mjs +239 -0
- package/cli/main.mjs +429 -0
- package/cli/maintenance.mjs +104 -0
- package/cli/paths.mjs +40 -0
- package/cli/rpc.mjs +261 -0
- package/launchd/com.external-subagent.daemon.plist.template +37 -0
- package/npm/native/README.md +19 -0
- package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
- package/npm/native/darwin-arm64/external-subagentd +0 -0
- package/npm/native/darwin-arm64/payload.json +20 -0
- package/package.json +60 -0
- package/plugins/codex/external-subagent/.codex-plugin/plugin.json +17 -0
- package/plugins/codex/external-subagent/.mcp.json +9 -0
- package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +55 -0
- package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +19 -0
- package/plugins/codex/external-subagent/hooks/guard-bash.mjs +15 -0
- package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +206 -0
- package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +149 -0
- package/plugins/codex/external-subagent/scripts/mcp-stdio-bridge.mjs +36 -0
- package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +44 -0
- package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +55 -0
- package/schema/external-subagent-public-api.json +185 -0
- package/schema/zas-observation-v1.1.schema.json +58 -0
- package/schema/zas-public-reasoning-source.json +14 -0
- package/scripts/release/build-native-payload.mjs +119 -0
- package/scripts/release/check-native-tarball.mjs +125 -0
- package/scripts/release/stage-debug-plugin.mjs +60 -0
- 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,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
|
+
}
|