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.
@@ -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, ZCODE_RUNTIME } from '../constants.mjs';
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 one macOS service template for the product daemon. The plist pins
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 ?? ZCODE_RUNTIME) : config.subagents[agent].runtime_path;
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 only when that installation exists;
42
- // without it the daemon still starts and the zcode adapter fails closed at
43
- // spawn ("ZCODE_RUNTIME_PATH is unavailable") instead of the whole service
44
- // dying on launchd because --runtime cannot canonicalize. Installing (or
45
- // removing) ZCode later is picked up by the next init, which rewrites the
46
- // plist. The seam exists so tests can pin both branches deterministically.
47
- const zcodeRuntime = options.zcodeRuntime ?? ZCODE_RUNTIME;
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 serviceRegistrationStatus(uid = process.getuid(), options = {}) {
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 bootstrapService(paths, uid = process.getuid(), options = {}) {
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 = serviceRegistrationStatus(uid, { launchctl: control });
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 = serviceRegistrationStatus(uid, { launchctl: control });
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 bootoutService(paths, uid = process.getuid(), options = {}) {
148
+ export function bootoutLaunchdService(paths, uid = process.getuid(), options = {}) {
142
149
  const control = options.launchctl || launchctl;
143
- const existing = serviceRegistrationStatus(uid, { launchctl: control });
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 = serviceRegistrationStatus(uid, { launchctl: control });
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 = serviceRegistrationStatus(uid, { launchctl: control });
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 };
@@ -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, ZCODE_RUNTIME } from './constants.mjs';
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
- 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 LaunchAgent\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`;
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
- configured_artifact: fileArtifact(ZCODE_RUNTIME, 'cli_packaged_configuration'),
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
- if (platform() !== 'darwin') throw new CliError('UNSUPPORTED_PLATFORM', `${command} is supported only on macOS`);
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 launchd view (registered job + process);
377
- // `daemon_status` stays the RPC view, so a loaded-but-unready or
378
- // ready-but-unregistered install reads differently instead of blurring.
379
- const serviceRaw = serviceRegistrationStatus();
380
- // PID is an implementation detail and may be reused by another process;
381
- // keep it out of the ordinary status projection.
382
- const { pid: _pid, ...service } = serviceRaw;
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) {
@@ -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 plist alone leaves a job launchd already loaded running until
79
- // the next logout (observed live: uninstall reported success while the daemon
80
- // process, the launchd registration, and the RPC socket all stayed alive).
81
- // The ES-owned service is therefore booted out — with the bounded
82
- // removal-confirmation bootoutService owns — before its definition file is
83
- // removed. A service that is not registered is not an error (idempotent
84
- // uninstall); a bootout that cannot complete fails the command rather than
85
- // deleting the definition out from under a still-running service.
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 service = bootoutService(paths, process.getuid(), options);
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: removeOne(paths.launchAgent),
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
- export function platform() {
7
- return process.env.EXTERNAL_SUBAGENT_TEST_PLATFORM || process.platform;
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
- // State paths derive from PRODUCT_NAME, so the debug variant gets a fully
11
- // parallel tree (its own data directory, database, socket, and logs) and
12
- // never touches the released product's state.
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);
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "product": "external-subagent",
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "platform": "darwin-arm64",
6
6
  "files": [
7
7
  {
8
8
  "name": "external-subagentd",
9
- "bytes": 10115152,
9
+ "bytes": 10058880,
10
10
  "mode": "755",
11
- "sha256": "2fbb31359c60e4834f3ad90733c03942cafe5fd8ab1bc5f2f9512128a8746237"
11
+ "sha256": "48fe9286f4986cbbadbb460d455ce5f90ca77b10ce2ff1f6f36fd9ab8a949428"
12
12
  },
13
13
  {
14
14
  "name": "external-subagent-mcp",
15
15
  "bytes": 935888,
16
16
  "mode": "755",
17
- "sha256": "73fc786b07873983fb347af756cc316cebb071a14380442777c27c3928e6f2ec"
17
+ "sha256": "23eb00373ef1c6f4812c81098347d0f95a226bc1f8cfc7610a16ef3fecfe55b7"
18
18
  }
19
19
  ]
20
20
  }
@@ -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.4.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
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "external-subagent",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Manage bounded external agent tasks through the external-subagent MCP service.",
5
5
  "author": { "name": "external-subagent maintainers" },
6
6
  "skills": "./skills/",
@@ -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
- const provenancePath = path.join(home, 'Library', 'Application Support', 'external-subagent', 'zcode-agent-hook-provenance.json');
29
- if (!fs.existsSync(provenancePath)) process.exit(2);
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/darwin-arm64 together with a deterministic payload.json manifest
5
- // (version, platform, per-file bytes/sha256/mode). Installed-package code
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/darwin-arm64
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, 'darwin-arm64');
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 !== 'darwin-arm64' || manifest.product !== productName) return false;
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: 'darwin-arm64' };
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;