external-subagent 0.4.0 → 0.5.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/README.md +83 -19
- package/bin/external-subagent-debug-mcp.mjs +2 -1
- package/bin/external-subagent-mcp.mjs +36 -11
- package/cli/commands/config.mjs +6 -1
- package/cli/commands/daemon.mjs +12 -2
- package/cli/commands/tasks.mjs +9 -0
- package/cli/config/write.mjs +21 -3
- package/cli/constants.mjs +35 -1
- package/cli/install/init.mjs +30 -11
- package/cli/install/layout.mjs +11 -4
- package/cli/install/path.mjs +35 -10
- package/cli/install/payload.mjs +86 -6
- package/cli/install/service-activation.mjs +133 -1
- package/cli/install/service-linux.mjs +327 -0
- package/cli/install/service-macos.mjs +59 -18
- package/cli/install/update.mjs +7 -1
- package/cli/main.mjs +25 -11
- package/cli/maintenance.mjs +16 -11
- package/cli/paths.mjs +57 -8
- 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 +4 -4
- package/npm/native/linux-x64/external-subagent-mcp +0 -0
- package/npm/native/linux-x64/external-subagentd +0 -0
- package/npm/native/linux-x64/payload.json +20 -0
- package/package.json +6 -3
- package/plugins/codex/external-subagent/.codex-plugin/plugin.json +1 -1
- package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +16 -2
- package/scripts/release/build-native-payload.mjs +18 -6
- package/scripts/release/check-native-tarball.mjs +99 -31
- package/scripts/release/test-installed-tarball.mjs +6 -1
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { DAEMON_BIN_NAME, LAUNCH_AGENT_LABEL,
|
|
4
|
+
import { DAEMON_BIN_NAME, LAUNCH_AGENT_LABEL, zcodeRuntimePath } from '../constants.mjs';
|
|
5
5
|
import { CliError } from '../errors.mjs';
|
|
6
6
|
import { atomicWrite } from '../fs-atomic.mjs';
|
|
7
7
|
import { nativeBinary } from './layout.mjs';
|
|
8
8
|
import { LAUNCHD_FIXED_PATH } from './path.mjs';
|
|
9
9
|
import { readConfig } from '../config/read.mjs';
|
|
10
|
+
import { platform } from '../paths.mjs';
|
|
11
|
+
import {
|
|
12
|
+
SYSTEMD_UNIT_NAME, bootstrapServiceSystemd, bootoutServiceSystemd, installServiceUnit,
|
|
13
|
+
systemdServiceRegistrationStatus,
|
|
14
|
+
} from './service-linux.mjs';
|
|
10
15
|
|
|
11
|
-
// The
|
|
16
|
+
// The macOS service backend for the product daemon, plus the platform
|
|
17
|
+
// dispatch into the systemd user backend (service-linux.mjs). The plist pins
|
|
12
18
|
// absolute payload paths and a fixed PATH so the GUI/launchd environment can
|
|
13
19
|
// never depend on the interactive shell. Launchctl activation honors the
|
|
14
20
|
// EXTERNAL_SUBAGENT_TEST_NO_LAUNCHCTL seam so fixture runs never load real
|
|
@@ -22,7 +28,7 @@ function escapeXml(value) {
|
|
|
22
28
|
export function runtimeObservations(paths, options = {}) {
|
|
23
29
|
const config = readConfig(paths.config);
|
|
24
30
|
return Object.fromEntries(['zcode', 'dsh', 'codex', 'agy'].map((agent) => {
|
|
25
|
-
const runtime = agent === 'zcode' ? (options.zcodeRuntime ??
|
|
31
|
+
const runtime = agent === 'zcode' ? (options.zcodeRuntime ?? zcodeRuntimePath(paths.home)) : config.subagents[agent].runtime_path;
|
|
26
32
|
return [agent, { path: runtime, present: Boolean(runtime && fs.existsSync(runtime)), enabled: config.subagents[agent].enabled }];
|
|
27
33
|
}));
|
|
28
34
|
}
|
|
@@ -38,13 +44,14 @@ export function launchAgentPlist(paths, options = {}) {
|
|
|
38
44
|
const { runtime_path: agyRuntime } = config.subagents.agy;
|
|
39
45
|
const configRevision = config.revision;
|
|
40
46
|
// AUD-005/D1: the standalone service never depends on an unrelated runtime.
|
|
41
|
-
// The pinned ZCode runtime is forwarded
|
|
42
|
-
// without it the daemon still starts and
|
|
43
|
-
//
|
|
44
|
-
// dying on launchd because
|
|
45
|
-
// removing) ZCode later is
|
|
46
|
-
// plist. The seam exists so
|
|
47
|
-
|
|
47
|
+
// The pinned ZCode runtime (platform-discovered, constants.mjs) is forwarded
|
|
48
|
+
// only when that installation exists; without it the daemon still starts and
|
|
49
|
+
// the zcode adapter fails closed at spawn ("ZCODE_RUNTIME_PATH is
|
|
50
|
+
// unavailable") instead of the whole service dying on launchd because
|
|
51
|
+
// --runtime cannot canonicalize. Installing (or removing) ZCode later is
|
|
52
|
+
// picked up by the next init, which rewrites the plist. The seam exists so
|
|
53
|
+
// tests can pin both branches deterministically.
|
|
54
|
+
const zcodeRuntime = options.zcodeRuntime ?? zcodeRuntimePath(paths.home);
|
|
48
55
|
const programArguments = [
|
|
49
56
|
`<string>${escapeXml(daemon)}</string>`,
|
|
50
57
|
'<string>--database</string>',
|
|
@@ -103,7 +110,7 @@ export function launchctl(args) {
|
|
|
103
110
|
// A repeat init/start must stay idempotent instead of failing the whole run,
|
|
104
111
|
// so the registration lookup below — the same print/absent discriminator the
|
|
105
112
|
// update owner uses — decides between "already loaded" and a real failure.
|
|
106
|
-
export function
|
|
113
|
+
export function launchdServiceRegistrationStatus(uid = process.getuid(), options = {}) {
|
|
107
114
|
const control = options.launchctl || launchctl;
|
|
108
115
|
const result = control(['print', `gui/${uid}/${LAUNCH_AGENT_LABEL}`]);
|
|
109
116
|
if (result.skipped) return { query: 'skipped', registered: null, reason: result.reason };
|
|
@@ -113,20 +120,20 @@ export function serviceRegistrationStatus(uid = process.getuid(), options = {})
|
|
|
113
120
|
return { registered: true, state, pid: Number.isInteger(pid) && pid > 0 ? pid : null };
|
|
114
121
|
}
|
|
115
122
|
|
|
116
|
-
export function
|
|
123
|
+
export function bootstrapLaunchdService(paths, uid = process.getuid(), options = {}) {
|
|
117
124
|
const control = options.launchctl || launchctl;
|
|
118
125
|
const alreadyLoaded = (lookup) => ({
|
|
119
126
|
action: 'bootstrap', label: LAUNCH_AGENT_LABEL, already_loaded: true,
|
|
120
127
|
state: lookup.state ?? null, pid: lookup.pid ?? null,
|
|
121
128
|
});
|
|
122
|
-
const existing =
|
|
129
|
+
const existing = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
123
130
|
if (existing.registered) return alreadyLoaded(existing);
|
|
124
131
|
try {
|
|
125
132
|
return control(['bootstrap', `gui/${uid}`, paths.launchAgent]);
|
|
126
133
|
} catch (error) {
|
|
127
134
|
// Another loader (a racing init, or launchd itself re-reading the plist)
|
|
128
135
|
// may have won after the check above; confirm before reporting failure.
|
|
129
|
-
const loaded =
|
|
136
|
+
const loaded = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
130
137
|
if (loaded.registered) return alreadyLoaded(loaded);
|
|
131
138
|
throw error;
|
|
132
139
|
}
|
|
@@ -138,9 +145,9 @@ export function bootstrapService(paths, uid = process.getuid(), options = {}) {
|
|
|
138
145
|
// service unregistered roughly one time in three). The stop owner therefore
|
|
139
146
|
// confirms the job is actually gone — bounded, with the same print/absent
|
|
140
147
|
// discriminator the update owner's unload uses — before reporting success.
|
|
141
|
-
export function
|
|
148
|
+
export function bootoutLaunchdService(paths, uid = process.getuid(), options = {}) {
|
|
142
149
|
const control = options.launchctl || launchctl;
|
|
143
|
-
const existing =
|
|
150
|
+
const existing = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
144
151
|
if (!existing.registered) return { action: 'bootout', label: LAUNCH_AGENT_LABEL, already_stopped: true };
|
|
145
152
|
let result;
|
|
146
153
|
try {
|
|
@@ -149,15 +156,49 @@ export function bootoutService(paths, uid = process.getuid(), options = {}) {
|
|
|
149
156
|
// A racing removal (another stop, or launchd itself) may have taken the
|
|
150
157
|
// job out between the registration probe and the bootout; confirm before
|
|
151
158
|
// reporting failure, so a repeated stop stays idempotent.
|
|
152
|
-
const settled =
|
|
159
|
+
const settled = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
153
160
|
if (!settled.registered) return { action: 'bootout', label: LAUNCH_AGENT_LABEL, already_stopped: true };
|
|
154
161
|
throw error;
|
|
155
162
|
}
|
|
156
163
|
const deadline = Date.now() + (options.unloadTimeoutMs ?? 10_000);
|
|
157
164
|
for (;;) {
|
|
158
|
-
const probe =
|
|
165
|
+
const probe = launchdServiceRegistrationStatus(uid, { launchctl: control });
|
|
159
166
|
if (!probe.registered) return { ...result, label: LAUNCH_AGENT_LABEL, removed: true };
|
|
160
167
|
if (Date.now() >= deadline) throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'launchd service remained registered after bootout');
|
|
161
168
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
162
169
|
}
|
|
163
170
|
}
|
|
171
|
+
|
|
172
|
+
// Platform dispatch (S03): the service backend is selected by the process
|
|
173
|
+
// platform — launchd on macOS, the systemd user manager on Linux — behind the
|
|
174
|
+
// same call surface every caller (init, start/stop, uninstall, status) already
|
|
175
|
+
// uses. The macOS arms below are the verbatim launchd implementations; the
|
|
176
|
+
// Linux arms carry the same idempotence/removal-confirmation contract through
|
|
177
|
+
// `systemctl --user` (options.systemctl is the injectable seam, the twin of
|
|
178
|
+
// options.launchctl).
|
|
179
|
+
export function serviceRegistrationStatus(uid = process.getuid(), options = {}) {
|
|
180
|
+
return platform() === 'darwin'
|
|
181
|
+
? launchdServiceRegistrationStatus(uid, options)
|
|
182
|
+
: systemdServiceRegistrationStatus(options);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function bootstrapService(paths, uid = process.getuid(), options = {}) {
|
|
186
|
+
return platform() === 'darwin'
|
|
187
|
+
? bootstrapLaunchdService(paths, uid, options)
|
|
188
|
+
: bootstrapServiceSystemd(paths, uid, options);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function bootoutService(paths, uid = process.getuid(), options = {}) {
|
|
192
|
+
return platform() === 'darwin'
|
|
193
|
+
? bootoutLaunchdService(paths, uid, options)
|
|
194
|
+
: bootoutServiceSystemd(paths, uid, options);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The service-definition installer behind init's install-launch-agent step:
|
|
198
|
+
// the launchd plist on macOS, the systemd user unit (0600, absolute verified
|
|
199
|
+
// payload, Restart=always) on Linux, both at paths.launchAgent.
|
|
200
|
+
export function installServiceDefinition(paths, options = {}) {
|
|
201
|
+
return platform() === 'darwin' ? installLaunchAgent(paths, options) : installServiceUnit(paths, options);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export { SYSTEMD_UNIT_NAME };
|
package/cli/install/update.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { CLI_ENTRY_NAME, DAEMON_BIN_NAME, NATIVE_DIR_NAME } from '../constants.m
|
|
|
7
7
|
import { reconcileCodexHomes } from './reconcile.mjs';
|
|
8
8
|
import { packageVersion, packageRoot } from './layout.mjs';
|
|
9
9
|
import { loadUpdateState } from './recovery.mjs';
|
|
10
|
-
import { verifyPayload } from './payload.mjs';
|
|
10
|
+
import { normalizePayloadFiles, verifyPayload } from './payload.mjs';
|
|
11
11
|
|
|
12
12
|
const SCHEMA_VERSION = 2;
|
|
13
13
|
const lockPath = (paths) => path.join(paths.data, 'install.lock');
|
|
@@ -113,6 +113,12 @@ export function preflightUpdate(options = {}) {
|
|
|
113
113
|
if (typeof version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(version) || !available.includes(version)) {
|
|
114
114
|
throw new CliError('PAYLOAD_VERSION_UNAVAILABLE', `requested payload version is unavailable: ${version}`);
|
|
115
115
|
}
|
|
116
|
+
// Repair a candidate payload mode masked by the installing umask before the
|
|
117
|
+
// strict verifier runs; the bytes are untouched, so the digest/arch checks
|
|
118
|
+
// below still bind the candidate to the release manifest.
|
|
119
|
+
for (const entry of normalizePayloadFiles({ root: candidateRoot, platform: options.platform })) {
|
|
120
|
+
process.stderr.write(`normalized payload mode ${entry.from} -> ${entry.to} (${entry.name})\n`);
|
|
121
|
+
}
|
|
116
122
|
const payload = verifyPayload({ root: candidateRoot, platform: options.platform });
|
|
117
123
|
if (version !== payload.version) throw new CliError('PAYLOAD_VERSION_MISMATCH', `requested version ${version} differs from candidate ${payload.version}`);
|
|
118
124
|
// Reject a doomed candidate up front: an unusable stable entry or daemon
|
package/cli/main.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
-
import { BUSINESS_COMMANDS, PRODUCT_NAME, VERSION,
|
|
4
|
+
import { BUSINESS_COMMANDS, PRODUCT_NAME, VERSION, zcodeRuntimePath } from './constants.mjs';
|
|
5
5
|
import { CliError } from './errors.mjs';
|
|
6
6
|
import { installHooks, installPlan, runInit } from './install/init.mjs';
|
|
7
7
|
import { nativeBinary } from './install/layout.mjs';
|
|
@@ -17,7 +17,12 @@ import { parseConfigArgs } from './commands/config.mjs';
|
|
|
17
17
|
import { subagentsCommand, parseSubagentsArgs } from './commands/agents.mjs';
|
|
18
18
|
import { parseProfileArgs, parseSpawnArgs, prepareSpawnInput, profileCommand } from './commands/tasks.mjs';
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Hosts that ship a supported service backend and payload: macOS (launchd)
|
|
21
|
+
// and Linux (systemd user services). The gate is structural, so any other
|
|
22
|
+
// platform is rejected before product paths are resolved or written.
|
|
23
|
+
const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux']);
|
|
24
|
+
|
|
25
|
+
const HELP = `external-subagent ${VERSION}\n\nUsage: external-subagent <command> [options]\n\nCommands:\n help, version Show basic product information\n init [--dry-run] [--resume] [--install-hooks] [--skip-service-start]\n Install the standalone daemon service only; bind a\n host afterwards with install-plugin or install-mcp\n hooks install [--dry-run] Install ZCode policy hooks explicitly\n install-plugin [codex|zcode] [--dry-run|--uninstall] [--codex-home <path>]\n Install or remove the managed host plugin (MCP + skill);\n the host defaults to codex, --codex-home is codex-only\n install-mcp [--dry-run|--uninstall] [--codex-home <path>]\n Install or remove the direct Codex MCP TOML binding\n status, diagnose Inspect local service and runtime state\n start, stop Bootstrap or boot out the daemon service\n profile list | profile show <name>\n Inspect global spawn profiles (profiles/*.json);\n invalid files are reported as path-based warnings,\n and warned or duplicate files are excluded from the\n available name set\n backup --output <dir> Back up retained product data\n restore --input <dir> Verify and restore product data\n uninstall Release Codex/ZCode bindings; remove service registration; retain data\n purge --yes Explicitly delete new product data\n`;
|
|
21
26
|
const DAEMON_HELP = ` config get [key] | config set <key> <value>\n subagents list | subagents status [subagent] | subagents probe/models [subagent]\n create/spawn [--profile <name>], wait, list, send, respond, cancel, result, close, observe\n Daemon calls accept --json '<object>' or JSON stdin\n list JSON requires repository (workspace is an alias)\n observe JSON requires only agent_id\n`;
|
|
22
27
|
|
|
23
28
|
function structuredInput(args, parser) {
|
|
@@ -262,7 +267,11 @@ async function diagnose(paths, args) {
|
|
|
262
267
|
scope: agent ? { agent_id: agent } : { kind: 'global' },
|
|
263
268
|
platform: platform(),
|
|
264
269
|
runtime: {
|
|
265
|
-
|
|
270
|
+
// The same platform-aware resolution the service generators use
|
|
271
|
+
// (constants.mjs): explicit ZCODE_RUNTIME_PATH, then the platform's
|
|
272
|
+
// conventional installation, then the packaged pin — reported as an
|
|
273
|
+
// artifact observation, never a probe.
|
|
274
|
+
configured_artifact: fileArtifact(zcodeRuntimePath(paths.home), 'cli_resolved_configuration'),
|
|
266
275
|
running_identity: null,
|
|
267
276
|
running_identity_source: 'not_observed_by_cli',
|
|
268
277
|
},
|
|
@@ -332,7 +341,10 @@ export async function main(args) {
|
|
|
332
341
|
process.stdout.write(`${VERSION}\n`); return;
|
|
333
342
|
}
|
|
334
343
|
if (command !== 'profile' && !BUSINESS_COMMANDS.has(command)) throw new CliError('UNKNOWN_COMMAND', `unknown command: ${command}`, 2);
|
|
335
|
-
|
|
344
|
+
// macOS and Linux are supported hosts; every other platform (Windows, BSD,
|
|
345
|
+
// an unknown tuple) is refused structurally before any filesystem side
|
|
346
|
+
// effect, so an unsupported host never writes HOME state.
|
|
347
|
+
if (!SUPPORTED_PLATFORMS.has(platform())) throw new CliError('UNSUPPORTED_PLATFORM', `${command} is supported only on macOS and Linux`);
|
|
336
348
|
|
|
337
349
|
const paths = productPaths();
|
|
338
350
|
if (command === 'init') {
|
|
@@ -373,13 +385,15 @@ export async function main(args) {
|
|
|
373
385
|
if (command === 'status') {
|
|
374
386
|
const verbose = args.includes('--verbose');
|
|
375
387
|
const local = localInstallStatus(paths, { verbose });
|
|
376
|
-
// `service` is the read-only
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
388
|
+
// `service` is the read-only backend view (launchd registered job or the
|
|
389
|
+
// systemd user unit's real state — `failed` included, never a fake
|
|
390
|
+
// healthy) selected by the platform dispatch in service-macos.mjs; a Linux
|
|
391
|
+
// host without a reachable user manager degrades to an explicit
|
|
392
|
+
// `unavailable` view with the linger hint instead of failing the whole
|
|
393
|
+
// read-only command. `daemon_status` stays the RPC view, so a
|
|
394
|
+
// loaded-but-unready or ready-but-unregistered install reads differently
|
|
395
|
+
// instead of blurring.
|
|
396
|
+
const { pid: _pid, ...service } = serviceRegistrationStatus();
|
|
383
397
|
try {
|
|
384
398
|
output({ ...local, service, daemon_status: publicDaemonStatus(await callDaemon(process.env.EXTERNAL_SUBAGENT_SOCKET || paths.socket, 'status', {}), { verbose }) });
|
|
385
399
|
} catch (error) {
|
package/cli/maintenance.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { CliError } from './errors.mjs';
|
|
4
4
|
import { jsonBytes, sha256 } from './fs-atomic.mjs';
|
|
5
|
-
import { productPaths } from './paths.mjs';
|
|
5
|
+
import { platform, productPaths } from './paths.mjs';
|
|
6
6
|
import { bootoutService } from './install/service-macos.mjs';
|
|
7
7
|
|
|
8
8
|
function copyTree(source, destination, records, root = source) {
|
|
@@ -75,20 +75,25 @@ function removeOne(target) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
// Deleting the
|
|
79
|
-
// the next logout (observed live: uninstall reported
|
|
80
|
-
// process, the launchd registration, and the RPC
|
|
81
|
-
// The ES-owned service is therefore booted out —
|
|
82
|
-
// removal
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
78
|
+
// Deleting the definition alone leaves a job the service manager already
|
|
79
|
+
// loaded running until the next logout (observed live: uninstall reported
|
|
80
|
+
// success while the daemon process, the launchd registration, and the RPC
|
|
81
|
+
// socket all stayed alive). The ES-owned service is therefore booted out —
|
|
82
|
+
// with the bounded removal confirmation bootoutService owns on BOTH backends
|
|
83
|
+
// (launchd bootout on macOS, systemctl --user disable --now plus the inactive
|
|
84
|
+
// confirmation on Linux) — before its definition file is removed. A service
|
|
85
|
+
// that is not registered is not an error (idempotent uninstall); a stop that
|
|
86
|
+
// cannot complete fails the command rather than deleting the definition out
|
|
87
|
+
// from under a still-running service. `removed_launch_agent` is macOS-only;
|
|
88
|
+
// Linux reports the same removal as `removed_service_definition`.
|
|
86
89
|
export function uninstall(paths = productPaths(), options = {}) {
|
|
87
|
-
const
|
|
90
|
+
const macos = platform() === 'darwin';
|
|
91
|
+
const service = bootoutService(paths, process.getuid(), { launchctl: options.launchctl, systemctl: options.systemctl });
|
|
92
|
+
const removed = removeOne(paths.launchAgent);
|
|
88
93
|
return {
|
|
89
94
|
service_stopped: true,
|
|
90
95
|
service_already_stopped: Boolean(service.already_stopped),
|
|
91
|
-
removed_launch_agent:
|
|
96
|
+
...(macos ? { removed_launch_agent: removed } : { removed_service_definition: removed }),
|
|
92
97
|
data_retained: true,
|
|
93
98
|
data: paths.data,
|
|
94
99
|
};
|
package/cli/paths.mjs
CHANGED
|
@@ -3,14 +3,16 @@ import path from 'node:path';
|
|
|
3
3
|
import { LAUNCH_AGENT_LABEL, PRODUCT_NAME } from './constants.mjs';
|
|
4
4
|
import { PLUGIN_NAME } from './install/layout.mjs';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
// The process platform, overridable by the EXTERNAL_SUBAGENT_TEST_PLATFORM seam
|
|
7
|
+
// so both layouts are exercisable on any host. The seam keeps its original
|
|
8
|
+
// meaning: it is a *process platform* override, nothing else.
|
|
9
|
+
export function platform(env = process.env) {
|
|
10
|
+
return env.EXTERNAL_SUBAGENT_TEST_PLATFORM || process.platform;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
export function productPaths(home = os.homedir()) {
|
|
13
|
+
// macOS derives the whole tree from `~/Library`; the bytes of every field below
|
|
14
|
+
// are contractually frozen (existing installs must keep resolving their state).
|
|
15
|
+
function darwinProductPaths(home) {
|
|
14
16
|
const data = path.join(home, 'Library', 'Application Support', PRODUCT_NAME);
|
|
15
17
|
return {
|
|
16
18
|
home,
|
|
@@ -28,6 +30,52 @@ export function productPaths(home = os.homedir()) {
|
|
|
28
30
|
};
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// XDG Base Directory resolution (D-S02). A variable selects its directory only
|
|
34
|
+
// when it is a non-empty absolute path; relative or empty values are ignored and
|
|
35
|
+
// the spec fallback under $HOME applies. Only the three XDG variables the
|
|
36
|
+
// product owns are read — no other user XDG directory is probed.
|
|
37
|
+
function xdgDirectory(env, variable, home, fallback) {
|
|
38
|
+
const configured = env[variable];
|
|
39
|
+
if (typeof configured === 'string' && configured !== '' && path.isAbsolute(configured)) {
|
|
40
|
+
return configured;
|
|
41
|
+
}
|
|
42
|
+
return path.join(home, ...fallback);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Linux keeps the same internal relative layout as macOS (config, install
|
|
46
|
+
// state, database, socket, hook provenance, plugin staging, profiles all live
|
|
47
|
+
// inside the product data directory) so the two platforms stay structurally
|
|
48
|
+
// parallel. Data and logs follow XDG; the systemd user unit directory is
|
|
49
|
+
// provided for the S03 service backend.
|
|
50
|
+
function linuxProductPaths(home, env) {
|
|
51
|
+
const data = path.join(xdgDirectory(env, 'XDG_DATA_HOME', home, ['.local', 'share']), PRODUCT_NAME);
|
|
52
|
+
const logs = path.join(xdgDirectory(env, 'XDG_STATE_HOME', home, ['.local', 'state']), PRODUCT_NAME);
|
|
53
|
+
const configHome = xdgDirectory(env, 'XDG_CONFIG_HOME', home, ['.config']);
|
|
54
|
+
return {
|
|
55
|
+
home,
|
|
56
|
+
data,
|
|
57
|
+
config: path.join(data, 'config.json'),
|
|
58
|
+
state: path.join(data, 'install-state.json'),
|
|
59
|
+
hookProvenance: path.join(data, 'zcode-agent-hook-provenance.json'),
|
|
60
|
+
database: path.join(data, `${PRODUCT_NAME}.sqlite3`),
|
|
61
|
+
socket: path.join(data, `${PRODUCT_NAME}.sock`),
|
|
62
|
+
logs,
|
|
63
|
+
launchAgent: path.join(configHome, 'systemd', 'user', `${PRODUCT_NAME}.service`),
|
|
64
|
+
zcodeConfig: path.join(home, '.zcode', 'cli', 'config.json'),
|
|
65
|
+
zcodePlugin: path.join(data, 'zcode-plugin', PLUGIN_NAME),
|
|
66
|
+
profiles: path.join(data, 'profiles'),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// State paths derive from PRODUCT_NAME, so the debug variant gets a fully
|
|
71
|
+
// parallel tree (its own data directory, database, socket, and logs) and
|
|
72
|
+
// never touches the released product's state. The layout is selected by the
|
|
73
|
+
// process platform: macOS `~/Library`, every other supported host the XDG
|
|
74
|
+
// baseline. `zcodeConfig` is `~/.zcode/cli/config.json` on both platforms.
|
|
75
|
+
export function productPaths(home = os.homedir(), env = process.env) {
|
|
76
|
+
return platform(env) === 'darwin' ? darwinProductPaths(home) : linuxProductPaths(home, env);
|
|
77
|
+
}
|
|
78
|
+
|
|
31
79
|
// Mirror the daemon's `profiles_directory()` exactly (rpc/profiles.rs):
|
|
32
80
|
// `EXTERNAL_SUBAGENT_CONFIG` wins when it is *exported*, even when exported as
|
|
33
81
|
// an empty string, and only then does `ZCODE_AGENT_CONFIG` apply; presence, not
|
|
@@ -35,7 +83,8 @@ export function productPaths(home = os.homedir()) {
|
|
|
35
83
|
// exported path has no parent in Rust (`Path::new("").parent() == None`), so the
|
|
36
84
|
// daemon loads no profiles directory at all; return null to represent that.
|
|
37
85
|
// Otherwise take the sibling `profiles/` of the config file, or fall back to the
|
|
38
|
-
// product data directory
|
|
86
|
+
// product data directory (the daemon's data fallback is the same XDG/macOS data
|
|
87
|
+
// root selected here, so both sides resolve one directory).
|
|
39
88
|
|
|
40
89
|
// Rust `Path::parent()` semantics on Unix, implemented with plain string
|
|
41
90
|
// slicing (never `path.dirname`/`path.join`, which lexically normalise and would
|
|
@@ -83,7 +132,7 @@ export function profilesDir(env = {}, home = os.homedir()) {
|
|
|
83
132
|
break;
|
|
84
133
|
}
|
|
85
134
|
}
|
|
86
|
-
if (!exported) return path.join(productPaths(home).data, 'profiles');
|
|
135
|
+
if (!exported) return path.join(productPaths(home, env).data, 'profiles');
|
|
87
136
|
const parent = rustPathParent(envPath);
|
|
88
137
|
if (parent === null) return null;
|
|
89
138
|
return joinProfiles(parent);
|
|
Binary file
|
|
Binary file
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"product": "external-subagent",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"platform": "darwin-arm64",
|
|
6
6
|
"files": [
|
|
7
7
|
{
|
|
8
8
|
"name": "external-subagentd",
|
|
9
|
-
"bytes":
|
|
9
|
+
"bytes": 10058880,
|
|
10
10
|
"mode": "755",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "48fe9286f4986cbbadbb460d455ce5f90ca77b10ce2ff1f6f36fd9ab8a949428"
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"name": "external-subagent-mcp",
|
|
15
15
|
"bytes": 935888,
|
|
16
16
|
"mode": "755",
|
|
17
|
-
"sha256": "
|
|
17
|
+
"sha256": "23eb00373ef1c6f4812c81098347d0f95a226bc1f8cfc7610a16ef3fecfe55b7"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
20
20
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"product": "external-subagent",
|
|
4
|
+
"version": "0.5.0",
|
|
5
|
+
"platform": "linux-x64",
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"name": "external-subagentd",
|
|
9
|
+
"bytes": 11414088,
|
|
10
|
+
"mode": "755",
|
|
11
|
+
"sha256": "feefc938b692dbf26bb4cbbb25c7799679641a1fb62acbd75327044542ef6418"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "external-subagent-mcp",
|
|
15
|
+
"bytes": 1025424,
|
|
16
|
+
"mode": "755",
|
|
17
|
+
"sha256": "455bf85256887b87228c495a2207a410e6d07d3399f2d1fa16ca03dd58c665ad"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "external-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Manage bounded ZCode, DeepSeek Harness (DSH), Codex, and Google Antigravity (agy) subagent tasks through a local MCP service for any local host (Codex, ZCode, or a custom MCP client)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,10 +21,12 @@
|
|
|
21
21
|
"agent-tasks"
|
|
22
22
|
],
|
|
23
23
|
"os": [
|
|
24
|
-
"darwin"
|
|
24
|
+
"darwin",
|
|
25
|
+
"linux"
|
|
25
26
|
],
|
|
26
27
|
"cpu": [
|
|
27
|
-
"arm64"
|
|
28
|
+
"arm64",
|
|
29
|
+
"x64"
|
|
28
30
|
],
|
|
29
31
|
"bin": {
|
|
30
32
|
"external-subagent": "bin/external-subagent.mjs",
|
|
@@ -52,6 +54,7 @@
|
|
|
52
54
|
"postpack": "node scripts/release/check-native-tarball.mjs",
|
|
53
55
|
"prepublishOnly": "node scripts/release/check-native-tarball.mjs --staged",
|
|
54
56
|
"test:install": "node --test tests/install/*.test.mjs",
|
|
57
|
+
"test:release": "node --test tests/release/*.test.mjs",
|
|
55
58
|
"test:affected": "node scripts/test/affected.mjs",
|
|
56
59
|
"check": "node --check bin/external-subagent.mjs && node --check bin/external-subagent-mcp.mjs && node --check bin/external-subagent-debug.mjs && node --check bin/external-subagent-debug-mcp.mjs && node --check cli/main.mjs"
|
|
57
60
|
},
|
|
@@ -25,8 +25,22 @@ const script = hook?.args?.find((candidate) => typeof candidate === 'string' &&
|
|
|
25
25
|
const guard = events.PreToolUse.find((entry) => entry?.matcher === 'Bash')?.hooks?.find((entry) => entry?.type === 'process')?.args?.[0];
|
|
26
26
|
const audit = events.PostToolUse?.find((entry) => entry?.matcher === 'Bash')?.hooks?.find((entry) => entry?.type === 'process')?.args?.[0];
|
|
27
27
|
if (!script || !fs.statSync(script).isFile() || hook.command !== process.execPath || !guard || !fs.statSync(guard).isFile() || !audit || !fs.statSync(audit).isFile()) process.exit(2);
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// The provenance is staged by install-agent-hooks.mjs beside this verifier,
|
|
29
|
+
// inside the product data directory of the probe home. The candidates mirror
|
|
30
|
+
// the daemon's policy_verifier_candidates exactly: the frozen macOS
|
|
31
|
+
// `~/Library/Application Support` bytes on darwin; elsewhere an exported
|
|
32
|
+
// absolute $XDG_DATA_HOME is probed first, then the `~/.local/share` XDG
|
|
33
|
+
// fallback for the scope home.
|
|
34
|
+
const provenanceCandidates = process.platform === 'darwin'
|
|
35
|
+
? [path.join(home, 'Library', 'Application Support', 'external-subagent', 'zcode-agent-hook-provenance.json')]
|
|
36
|
+
: [
|
|
37
|
+
...(typeof process.env.XDG_DATA_HOME === 'string' && path.isAbsolute(process.env.XDG_DATA_HOME)
|
|
38
|
+
? [path.join(process.env.XDG_DATA_HOME, 'external-subagent', 'zcode-agent-hook-provenance.json')]
|
|
39
|
+
: []),
|
|
40
|
+
path.join(home, '.local', 'share', 'external-subagent', 'zcode-agent-hook-provenance.json'),
|
|
41
|
+
];
|
|
42
|
+
const provenancePath = provenanceCandidates.find((candidate) => fs.existsSync(candidate));
|
|
43
|
+
if (!provenancePath) process.exit(2);
|
|
30
44
|
const provenance = JSON.parse(fs.readFileSync(provenancePath, 'utf8'));
|
|
31
45
|
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
32
46
|
const policy = path.resolve(path.dirname(script), '../lib/agent-file-policy.mjs');
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Build the versioned native payload for the npm package: release binaries
|
|
3
3
|
// for external-subagentd and external-subagent-mcp are copied into
|
|
4
|
-
// npm/native
|
|
5
|
-
// (version, platform, per-file bytes/sha256/mode)
|
|
4
|
+
// npm/native/<platform> together with a deterministic payload.json manifest
|
|
5
|
+
// (version, platform, per-file bytes/sha256/mode), where <platform> is derived
|
|
6
|
+
// from the running host (darwin-arm64, linux-x64). Installed-package code
|
|
6
7
|
// verifies against this manifest, so it must never contain volatile fields.
|
|
7
8
|
//
|
|
8
9
|
// `--variant debug` stages the parallel development payload instead: the same
|
|
9
|
-
// cargo artifacts are copied under debug names into npm/native-debug
|
|
10
|
+
// cargo artifacts are copied under debug names into npm/native-debug/<platform>
|
|
10
11
|
// (manifest product external-subagent-debug), and the debug plugin source is
|
|
11
12
|
// staged at plugins/codex/external-subagent-debug. The debug payload is a
|
|
12
13
|
// development checkout product and is rejected from release tarballs by
|
|
@@ -20,8 +21,19 @@ import crypto from 'node:crypto';
|
|
|
20
21
|
import { spawnSync } from 'node:child_process';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
22
23
|
import { stageDebugPlugin } from './stage-debug-plugin.mjs';
|
|
24
|
+
import { nativePlatform } from '../../cli/install/layout.mjs';
|
|
23
25
|
|
|
24
26
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
27
|
+
// The staged directory and the manifest `platform` field derive from the
|
|
28
|
+
// running host through the shared platform table, so one script stages the
|
|
29
|
+
// correct tuple everywhere (release: darwin-arm64, linux-x64) and an
|
|
30
|
+
// unsupported host fails before touching the tree. The debug variant reuses
|
|
31
|
+
// the same tuple under its parallel native-debug directory.
|
|
32
|
+
const platform = nativePlatform();
|
|
33
|
+
if (platform === null) {
|
|
34
|
+
process.stderr.write(`no native payload for ${process.platform}-${process.arch}\n`);
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
25
37
|
const variantIndex = process.argv.indexOf('--variant');
|
|
26
38
|
const variant = variantIndex === -1 ? '' : (process.argv[variantIndex + 1] ?? '');
|
|
27
39
|
if (variant !== '' && variant !== 'debug') {
|
|
@@ -42,7 +54,7 @@ const productName = isDebug ? 'external-subagent-debug' : 'external-subagent';
|
|
|
42
54
|
const binaries = isDebug
|
|
43
55
|
? [['external-subagentd', 'external-subagent-debugd'], ['external-subagent-mcp', 'external-subagent-debug-mcp']]
|
|
44
56
|
: [['external-subagentd', 'external-subagentd'], ['external-subagent-mcp', 'external-subagent-mcp']];
|
|
45
|
-
const platformDir = path.join(packageRoot, 'npm', nativeDirName,
|
|
57
|
+
const platformDir = path.join(packageRoot, 'npm', nativeDirName, platform);
|
|
46
58
|
const manifestPath = path.join(platformDir, 'payload.json');
|
|
47
59
|
const sourceRoots = ['crates', 'profiles'].map((dir) => path.join(packageRoot, dir))
|
|
48
60
|
.concat([path.join(packageRoot, 'Cargo.toml'), path.join(packageRoot, 'Cargo.lock')]);
|
|
@@ -73,7 +85,7 @@ function stagedIsCurrent(version) {
|
|
|
73
85
|
if (!fs.existsSync(manifestPath)) return false;
|
|
74
86
|
let manifest;
|
|
75
87
|
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return false; }
|
|
76
|
-
if (manifest.version !== version || manifest.platform !==
|
|
88
|
+
if (manifest.version !== version || manifest.platform !== platform || manifest.product !== productName) return false;
|
|
77
89
|
if ((manifest.profile ?? 'release') !== cargoProfile) return false;
|
|
78
90
|
const inputs = newestInputMs();
|
|
79
91
|
for (const file of manifest.files) {
|
|
@@ -121,7 +133,7 @@ for (const [source, name] of binaries) {
|
|
|
121
133
|
files.push({ name, bytes: bytes.length, mode: '755', sha256: sha256(bytes) });
|
|
122
134
|
}
|
|
123
135
|
|
|
124
|
-
const manifest = { schema_version: 1, product: productName, version, platform
|
|
136
|
+
const manifest = { schema_version: 1, product: productName, version, platform };
|
|
125
137
|
// The debug profile is recorded so --if-stale can tell a debug-staged payload
|
|
126
138
|
// from a release one; the release manifest stays byte-identical to before.
|
|
127
139
|
if (cargoProfile !== 'release') manifest.profile = cargoProfile;
|