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 CHANGED
@@ -16,15 +16,23 @@ independent axes:
16
16
  ## Status
17
17
 
18
18
  The package is published to the public npm registry as
19
- `external-subagent@0.1.0` (macOS arm64 only; `os`/`cpu` in `package.json`
20
- reject installation on other platforms at npm install time). `init` installs
21
- the standalone daemon service only — no host is bound implicitly; hosts are
22
- bound through the explicit `install-plugin` (Codex or ZCode) /
23
- `install-mcp` (Codex-only TOML binding) commands. Installing from a packed
24
- artifact and explicitly initializing to a launchd-resident service
25
- (idempotent repeat init/start) is live-verified on a real GUI session;
26
- active-task upgrade and standalone initialization are implemented and
27
- reviewed.
19
+ `external-subagent@0.5.0` for macOS arm64 and Linux x86_64. `os`/`cpu` in
20
+ `package.json` are independent npm arrays, so npm admits any darwin/linux +
21
+ arm64/x64 tuple at install time; the CLI then enforces the real support at
22
+ first use — a host outside macOS/Linux is rejected with `UNSUPPORTED_PLATFORM`
23
+ before any HOME write, and `darwin-x64`/`linux-arm64` (no native payload) are
24
+ rejected at `init` with `UNSUPPORTED_PAYLOAD_PLATFORM`.
25
+ `init` installs the standalone daemon service only — no host is bound
26
+ implicitly; hosts are bound through the explicit `install-plugin` (Codex or
27
+ ZCode) / `install-mcp` (Codex-only TOML binding) commands. Installing from a
28
+ packed artifact and explicitly initializing to a service is live-verified on
29
+ macOS arm64 (launchd, real GUI session) and on Linux x86_64 (systemd user
30
+ service, Ubuntu; idempotent repeat init/start); active-task upgrade and
31
+ standalone initialization are implemented and reviewed. On Linux x86_64 the
32
+ `codex` and `zcode` subagents are live-verified (OBSERVED), while `dsh` and
33
+ `agy` remain runtime-gated there: their runtimes are not installed on the
34
+ verification host, so they stay disabled by default with an explicit status,
35
+ exactly as on macOS.
28
36
 
29
37
  All four subagents are **disabled by default** and are enabled per name with
30
38
  `external-subagent agents enable <zcode|dsh|codex|agy>`: a successful local probe
@@ -62,12 +70,12 @@ acceptance matrix is recorded in
62
70
 
63
71
  ## Versioning
64
72
 
65
- The product version has a single source: `package.json` (`0.1.0`). The CLI
73
+ The product version has a single source: `package.json` (`0.5.0`). The CLI
66
74
  constant (`cli/constants.mjs`), the daemon crates, and the native payload
67
75
  manifest all carry the same version, and `scripts/release/check-native-tarball.mjs`
68
76
  enforces payload/package agreement. The managed Codex plugin manifest
69
77
  (`plugins/codex/external-subagent/.codex-plugin/plugin.json`, currently
70
- `0.1.3`) is deliberately **not** tied to the product version: it is codex's
78
+ `0.1.5`) is deliberately **not** tied to the product version: it is codex's
71
79
  plugin-cache identity (`plugin@marketplace@version`; under the reserved
72
80
  `personal` marketplace name codex resolves the real user root
73
81
  machine-globally, ignoring `CODEX_HOME`), which
@@ -80,7 +88,7 @@ intentionally diverge; see
80
88
  ## Install
81
89
 
82
90
  ```
83
- npm install -g external-subagent # stages package + payload only (macOS arm64)
91
+ npm install -g external-subagent # stages package + payload only (macOS arm64, Linux x64)
84
92
  external-subagent init # explicit: standalone daemon service only
85
93
  external-subagent install-plugin zcode # optional host binding (or: install-plugin codex / install-mcp)
86
94
  ```
@@ -91,12 +99,62 @@ stale or missing binaries) and `postpack` runs the static tarball checks. A
91
99
  development checkout packs the same way (`npm pack` after `cargo` is
92
100
  available); see [docs/operations.md](docs/operations.md) for release checks.
93
101
 
102
+ ## Spawn profiles
103
+
104
+ A spawn profile is a named JSON preset for the spawn inputs, stored as one
105
+ JSON file per profile in the `profiles/` directory next to `config.json`
106
+ (`~/Library/Application Support/external-subagent/profiles/` by default;
107
+ `profile list` reports the exact path in use). Every `*.json` file in the
108
+ directory is discovered by its `name` field; the same name defined in two
109
+ files is rejected, and invalid files are reported as warnings and excluded
110
+ from the available set.
111
+
112
+ ```json
113
+ {
114
+ "name": "codex-yolo",
115
+ "subagent": "codex",
116
+ "model": "gpt-5",
117
+ "permission_mode": "yolo",
118
+ "developer_instructions": "Always reply in English."
119
+ }
120
+ ```
121
+
122
+ | Field | Meaning |
123
+ |---|---|
124
+ | `name` | Required, unique across files; trimmed, non-empty, at most 128 bytes |
125
+ | `subagent` | `zcode`, `dsh`, `codex`, or `agy` |
126
+ | `model` | Model token, named per target: `dsh` wants `provider:model`, `zcode` accepts `provider/model` or a bare token, `codex` and `agy` take a bare slug |
127
+ | `effort` | Effort token; some runtime models require one (for example `agy` rejects `gemini-3.8-flash` without `--effort`) and the spawn error surfaces the runtime's message verbatim |
128
+ | `permission_mode` | `build`, `edit`, `plan`, or `yolo`; defaults to `build` when unset |
129
+ | `developer_instructions` | Free-form instructions, delivered as described below |
130
+
131
+ `--profile` cannot be combined with `--subagent`, `--model`, `--effort`, or
132
+ `--permission_mode`; put those in the profile JSON or omit `profile`. Fields
133
+ left out of the profile fall back to the same defaults as an unprofiled
134
+ spawn. `developer_instructions` rides the codex runtime's native
135
+ `developerInstructions` channel when the effective subagent is `codex`; every
136
+ other subagent receives it silently concatenated in front of the prompt:
137
+
138
+ ```
139
+ Developer Instructions: {developer_instructions}
140
+ ----------
141
+ {prompt}
142
+ ```
143
+
144
+ Inspect what is loaded with `external-subagent profile list` and
145
+ `external-subagent profile show <name>`; an empty `profile list` result
146
+ carries a `hint` field with this format summary.
147
+
94
148
  ## Publishing
95
149
 
96
150
  Releases publish from CI via npm Trusted Publishing (OIDC): pushing a `vX.Y.Z`
97
- tag runs [.github/workflows/npm-publish.yml](.github/workflows/npm-publish.yml)
98
- on an Apple Silicon runner, which rebuilds the payload, runs the pack gates,
99
- and publishes with no npm token in the environment — the runner's OIDC
151
+ tag runs [.github/workflows/npm-publish.yml](.github/workflows/npm-publish.yml).
152
+ One tarball carries both platform payloads, so the workflow builds them on two
153
+ runners: the `build-linux` job builds the `linux-x64` payload on Ubuntu and
154
+ uploads it as an artifact, and the `publish` job runs on an Apple Silicon
155
+ runner (where `prepack` rebuilds the `darwin-arm64` payload), merges the Linux
156
+ payload into `npm/native/` before the pack gates and release-path test suites
157
+ run, and publishes with no npm token in the environment — the runner's OIDC
100
158
  identity is the credential. npm does not support trusted publishing for a
101
159
  package's first release, so the initial version was published manually once
102
160
  and the trusted publisher was then linked on npmjs.com; the workflow filename
@@ -119,10 +177,16 @@ completed update whose registered Codex homes only partially rebind reports
119
177
  `CODEX_SYNC_PARTIAL` per home and keeps the verified activation; the public
120
178
  `stop` confirms launchd removal before returning, and `uninstall` boots out
121
179
  the ES-owned service before removing its registration while retaining all
122
- data. Supported platform: macOS arm64 — `os`/`cpu` in `package.json` make npm
123
- refuse installation elsewhere up front; the CLI additionally keeps
124
- `help`/`version` working on other platforms and rejects business commands
125
- without writing HOME.
180
+ data. Supported platforms: macOS arm64 and Linux x86_64. npm's independent
181
+ `os`/`cpu` arrays only bound the tuple to darwin/linux + arm64/x64 (so
182
+ `darwin-x64` and `linux-arm64` install as well); the CLI keeps `help`/`version`
183
+ working everywhere, rejects a host outside macOS/Linux with
184
+ `UNSUPPORTED_PLATFORM`, and rejects `darwin-x64`/`linux-arm64` at `init` with
185
+ `UNSUPPORTED_PAYLOAD_PLATFORM`, never writing HOME on a rejected host. macOS arm64 is live-verified
186
+ (launchd). Linux x86_64 (Ubuntu, systemd user service) is verified for the
187
+ install/init/service/CLI path and the codex and zcode subagents; `dsh` and
188
+ `agy` remain runtime-gated there (their runtimes are not present on the
189
+ verification host).
126
190
 
127
191
  See [docs/operations.md](docs/operations.md) for service control, PATH
128
192
  behavior, the Codex homes registry, backup/removal, and release checks; and
@@ -2,4 +2,5 @@
2
2
  // Debug-variant MCP facade entry: selects the debug payload before
3
3
  // delegating to the shared spawn shim.
4
4
  process.env.EXTERNAL_SUBAGENT_VARIANT = 'debug';
5
- await import('./external-subagent-mcp.mjs');
5
+ const { runMcpFacade } = await import('./external-subagent-mcp.mjs');
6
+ runMcpFacade();
@@ -1,20 +1,45 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
+ import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
7
+ import { nativePlatform } from '../cli/install/layout.mjs';
6
8
 
7
9
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+
8
11
  // The debug shims set EXTERNAL_SUBAGENT_VARIANT before re-entering this file,
9
12
  // so the facade resolves to the variant payload staged beside the release one.
10
- const debug = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug';
11
- const facade = path.join(packageRoot, 'npm', debug ? 'native-debug' : 'native', 'darwin-arm64',
12
- debug ? 'external-subagent-debug-mcp' : 'external-subagent-mcp');
13
- const child = spawn(facade, process.argv.slice(2), { stdio: 'inherit', env: process.env });
14
- child.on('error', (error) => {
15
- console.error(error.message);
16
- process.exitCode = 1;
17
- });
18
- child.on('exit', (code, signal) => {
19
- process.exitCode = code ?? (signal ? 1 : 0);
20
- });
13
+ // The platform tuple derives from the running host (falling back to the raw
14
+ // process tuple on an unsupported host so the diagnostic names the path it
15
+ // tried), matching cli/install/layout.mjs — a linux-x64 install must spawn the
16
+ // linux-x64 payload, never a hard-coded macOS directory.
17
+ export function mcpFacadePath(
18
+ platform = nativePlatform() ?? `${process.platform}-${process.arch}`,
19
+ debug = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug',
20
+ ) {
21
+ return path.join(packageRoot, 'npm', debug ? 'native-debug' : 'native', platform,
22
+ debug ? 'external-subagent-debug-mcp' : 'external-subagent-mcp');
23
+ }
24
+
25
+ export function runMcpFacade() {
26
+ const child = spawn(mcpFacadePath(), process.argv.slice(2), { stdio: 'inherit', env: process.env });
27
+ child.on('error', (error) => {
28
+ console.error(error.message);
29
+ process.exitCode = 1;
30
+ });
31
+ child.on('exit', (code, signal) => {
32
+ process.exitCode = code ?? (signal ? 1 : 0);
33
+ });
34
+ }
35
+
36
+ // Launch only when this shim is the process entry point (directly or through
37
+ // an npm bin symlink). Importing it — the debug shim does so explicitly, and
38
+ // the tests import the pure path resolver — must never spawn a facade.
39
+ function isEntryPoint() {
40
+ if (process.argv[1] === undefined) return false;
41
+ try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
42
+ catch { return false; }
43
+ }
44
+
45
+ if (isEntryPoint()) runMcpFacade();
@@ -5,6 +5,7 @@ import { CliError } from '../errors.mjs';
5
5
  import { atomicWrite } from '../fs-atomic.mjs';
6
6
  import { hasInstalledService } from '../install/service-activation.mjs';
7
7
  import { launchAgentPlist } from '../install/service-macos.mjs';
8
+ import { platform } from '../paths.mjs';
8
9
 
9
10
  const CONFIG_INPUT_FIELDS = new Set(['operation', 'patch', 'key']);
10
11
  const GET_KEYS = new Set([
@@ -94,8 +95,12 @@ export function parseConfigArgs(args) {
94
95
  // launchd (subagent homes, config revision), so every successful write also
95
96
  // regenerates the service definition from the new config. Only the file is
96
97
  // rewritten — launchd picks the environment up at its next load, so a running
97
- // daemon is never restarted or disturbed here.
98
+ // daemon is never restarted or disturbed here. The launchd plist is macOS
99
+ // only; the Linux systemd unit is re-rendered by the S03 service backend, so a
100
+ // Linux config write refreshes nothing yet instead of writing a plist to the
101
+ // unit path.
98
102
  function refreshServiceDefinition(paths) {
103
+ if (platform() !== 'darwin') return false;
99
104
  if (!hasInstalledService(paths)) return false;
100
105
  atomicWrite(paths.launchAgent, launchAgentPlist(paths), 0o600);
101
106
  return true;
@@ -2,9 +2,13 @@ import fs from 'node:fs';
2
2
  import { bootoutService, bootstrapService } from '../install/service-macos.mjs';
3
3
  import { loadCodexHomes } from '../install/reconcile.mjs';
4
4
  import { verifyPayload } from '../install/payload.mjs';
5
+ import { platform } from '../paths.mjs';
5
6
 
6
7
  // Daemon/service command surface: explicit start/stop through the managed
7
- // LaunchAgent, and the local installation summary consumed by `status`.
8
+ // service definition (launchd bootstrap/bootout on macOS, systemctl --user
9
+ // daemon-reload+enable --now / disable --now on Linux, via the platform
10
+ // dispatch in service-macos.mjs), and the local installation summary consumed
11
+ // by `status`.
8
12
 
9
13
  export function startDaemon(paths) {
10
14
  return bootstrapService(paths);
@@ -23,9 +27,15 @@ export function localInstallStatus(paths, { verbose = false } = {}) {
23
27
  const publicPayload = verbose
24
28
  ? payload
25
29
  : { status: payload.status, platform: payload.platform, version: payload.version };
30
+ // The service-definition path is the macOS LaunchAgent plist or the Linux
31
+ // systemd user unit (paths.mjs). The reported field stays platform-truthful
32
+ // so a Linux install never reads as a launchd agent.
33
+ const serviceDefinition = platform() === 'darwin'
34
+ ? { launch_agent: fs.existsSync(paths.launchAgent) }
35
+ : { service_definition: fs.existsSync(paths.launchAgent) };
26
36
  return {
27
37
  installed: fs.existsSync(paths.state),
28
- launch_agent: fs.existsSync(paths.launchAgent),
38
+ ...serviceDefinition,
29
39
  data: fs.existsSync(paths.data),
30
40
  payload: publicPayload,
31
41
  // Home paths are installation internals; status reports only the
@@ -499,6 +499,13 @@ export function parseProfileArgs(args) {
499
499
  throw new CliError('INVALID_ARGUMENT', `unsupported profile operation: ${operation}`, 2);
500
500
  }
501
501
 
502
+ // Attached to `profile list` only when no usable profile exists, so a fresh
503
+ // installation sees the format reference instead of an unexplained empty list.
504
+ const EMPTY_PROFILES_HINT = 'no profiles found; create one JSON file per profile in this directory, '
505
+ + 'e.g. {"name":"codex-yolo","subagent":"codex","model":"gpt-5","permission_mode":"yolo"}; '
506
+ + 'fields: name (required), subagent, model, effort, permission_mode (build|edit|plan|yolo), '
507
+ + 'developer_instructions; see the "Spawn profiles" section of README.md';
508
+
502
509
  export function profileCommand(paths, inputOrArgs, env = process.env) {
503
510
  const input = Array.isArray(inputOrArgs) ? parseProfileArgs(inputOrArgs) : inputOrArgs;
504
511
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
@@ -526,6 +533,7 @@ export function profileCommand(paths, inputOrArgs, env = process.env) {
526
533
  profiles: [],
527
534
  warnings: [],
528
535
  message: `profiles directory does not exist: ${dir}`,
536
+ hint: EMPTY_PROFILES_HINT,
529
537
  };
530
538
  }
531
539
  return {
@@ -533,6 +541,7 @@ export function profileCommand(paths, inputOrArgs, env = process.env) {
533
541
  directory: dir,
534
542
  profiles: scanned.availableNames,
535
543
  warnings: scanned.warnings.map((w) => ({ file: w.file, diagnostic: w.diagnostic })),
544
+ ...(scanned.availableNames.length === 0 ? { hint: EMPTY_PROFILES_HINT } : {}),
536
545
  };
537
546
  }
538
547
 
@@ -12,13 +12,31 @@ export function writeConfig(file, value) {
12
12
  return config;
13
13
  }
14
14
 
15
+ // The read-modify-write config transaction holds an exclusive lock on
16
+ // `<config>.lock` for its whole duration. The lock is owned by a helper
17
+ // process so a crashed writer releases it automatically: the helper acquires
18
+ // the lock, creates a `ready` acknowledgement, then holds the lock until this
19
+ // process kills the helper group. macOS uses lockf and Linux uses flock — both
20
+ // take an exclusive advisory lock on a file, run a command while holding it,
21
+ // and give up after a bounded wait, so the timeout/mutual-exclusion/failure
22
+ // semantics are equivalent. The binary is chosen by the host OS (a real
23
+ // capability), not by the EXTERNAL_SUBAGENT_TEST_PLATFORM layout seam.
24
+ const LOCK_TIMEOUT_SECONDS = 2;
25
+ const LOCK_READY_SCRIPT = 'ready="$1"; parent="$2"; printf ready > "$ready"; while kill -0 "$parent" 2>/dev/null; do sleep 0.05; done';
26
+
27
+ export function lockHelperInvocation(lock, ready, platform = process.platform) {
28
+ const tail = ['/bin/sh', '-c', LOCK_READY_SCRIPT, 'external-subagent-lock', ready, String(process.pid)];
29
+ return platform === 'darwin'
30
+ ? { command: '/usr/bin/lockf', args: ['-t', String(LOCK_TIMEOUT_SECONDS), lock, ...tail] }
31
+ : { command: '/usr/bin/flock', args: ['-w', String(LOCK_TIMEOUT_SECONDS), lock, ...tail] };
32
+ }
33
+
15
34
  export function updateConfig(file, updater) {
16
35
  const lock = `${path.resolve(file)}.lock`;
17
36
  fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
18
37
  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 });
38
+ const { command, args } = lockHelperInvocation(lock, ready);
39
+ const helper = spawn(command, args, { stdio: 'ignore', detached: true });
22
40
  const wait = new Int32Array(new SharedArrayBuffer(4));
23
41
  const deadline = Date.now() + 2500;
24
42
  while (!fs.existsSync(ready)) {
package/cli/constants.mjs CHANGED
@@ -4,11 +4,15 @@
4
4
  // debug build beside the released product without touching it. The token is
5
5
  // read once at module load; the debug bin shims set it before importing the
6
6
  // CLI, so every derived name below is stable for the process lifetime.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
7
11
  const IS_DEBUG_VARIANT = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug';
8
12
 
9
13
  export const PRODUCT_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug' : 'external-subagent';
10
14
  export const PRODUCT_ID = IS_DEBUG_VARIANT ? 'external_subagent_debug' : 'external_subagent';
11
- export const VERSION = '0.4.0';
15
+ export const VERSION = '0.5.0';
12
16
  export const LAUNCH_AGENT_LABEL = IS_DEBUG_VARIANT ? 'com.external-subagent-debug.daemon' : 'com.external-subagent.daemon';
13
17
  export const DAEMON_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debugd' : 'external-subagentd';
14
18
  export const MCP_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug-mcp' : 'external-subagent-mcp';
@@ -16,6 +20,36 @@ export const NATIVE_DIR_NAME = IS_DEBUG_VARIANT ? 'native-debug' : 'native';
16
20
  export const CLI_ENTRY_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug.mjs' : 'external-subagent.mjs';
17
21
  export const ZCODE_RUNTIME = '/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs';
18
22
 
23
+ // The zcode runtime the CLI pins for the daemon, resolved per installation:
24
+ // explicit configuration first, then the platform's conventional location,
25
+ // and — when neither yields a file — the packaged macOS constant, reported
26
+ // honestly as an absent pinned location (presence is the caller's
27
+ // `fs.existsSync` observation, never an assertion).
28
+ // explicit ZCODE_RUNTIME_PATH, the same variable the daemon itself
29
+ // resolves (external-subagentd --runtime / ZCODE_RUNTIME_PATH).
30
+ // A configured-but-absent path is returned verbatim; forwarding
31
+ // stays presence-gated so the daemon never starts against a
32
+ // --runtime that is not a regular file.
33
+ // darwin the app bundle resource (frozen bytes above).
34
+ // linux the ZCode server runtime the desktop's attached-remote flow
35
+ // deploys under `~/.zcode/server` (`agents/glm/zcode.cjs`) —
36
+ // the same default root the official `zcode-agent` launcher
37
+ // resolves (OBSERVED 2026-10-09; docs/compatibility/zcode.md).
38
+ // The deployment's ZCODE_SERVER_RUNTIME_ROOT variable is
39
+ // deliberately NOT consulted: service generation must stay
40
+ // independent of the interactive shell environment (the same
41
+ // design the fixed unit PATH follows), and the product home
42
+ // already owns `~/.zcode`.
43
+ export function zcodeRuntimePath(home = os.homedir(), env = process.env) {
44
+ const configured = env.ZCODE_RUNTIME_PATH;
45
+ if (typeof configured === 'string' && configured !== '') return configured;
46
+ // The process-platform seam, the same expression cli/paths.mjs owns;
47
+ // duplicated here because paths.mjs imports this module (no import cycle).
48
+ if ((env.EXTERNAL_SUBAGENT_TEST_PLATFORM || process.platform) === 'darwin') return ZCODE_RUNTIME;
49
+ const conventional = path.join(home, '.zcode', 'server', 'agents', 'glm', 'zcode.cjs');
50
+ return fs.existsSync(conventional) ? conventional : ZCODE_RUNTIME;
51
+ }
52
+
19
53
  export const BUSINESS_COMMANDS = new Set([
20
54
  'init', 'hooks', 'status', 'diagnose', 'backup', 'restore', 'start', 'stop',
21
55
  'uninstall', 'purge', 'install-plugin', 'install-mcp', 'create', 'spawn', 'wait', 'list', 'send',
@@ -1,16 +1,17 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { LAUNCH_AGENT_LABEL } from '../constants.mjs';
4
+ import { LAUNCH_AGENT_LABEL, NATIVE_DIR_NAME } from '../constants.mjs';
5
5
  import { CliError } from '../errors.mjs';
6
+ import { platform } from '../paths.mjs';
6
7
  import { parseConfig } from '../config/read.mjs';
7
8
  import { writeConfig } from '../config/write.mjs';
8
- import { verifyPayload } from './payload.mjs';
9
+ import { normalizePayloadFiles, verifyPayload } from './payload.mjs';
9
10
  import { pathReport } from './path.mjs';
10
11
  import { packageRoot, payloadManifestPath } from './layout.mjs';
11
12
  import { updateInstallation } from './update.mjs';
12
13
  import { loadInstallState, markInstallStep, removeCreatedDirectories, rollbackFiles, snapshotFile } from './recovery.mjs';
13
- import { bootstrapService, bootoutService, installLaunchAgent, runtimeObservations } from './service-macos.mjs';
14
+ import { SYSTEMD_UNIT_NAME, bootstrapService, bootoutService, installServiceDefinition, runtimeObservations } from './service-macos.mjs';
14
15
 
15
16
  // Standalone-install coordination (S05, AUD-005 decision D1). A plain npm
16
17
  // install only stages the package and payload; an explicit `init` installs
@@ -20,8 +21,12 @@ import { bootstrapService, bootoutService, installLaunchAgent, runtimeObservatio
20
21
  // check-path PATH findings are reported, never written
21
22
  // create-data private data/log directories
22
23
  // write-product-config republish the current agent config schema
23
- // install-launch-agent the one macOS service template
24
- // start-service launchctl bootstrap (best-effort, reported)
24
+ // install-launch-agent the one service template: the launchd LaunchAgent
25
+ // plist on macOS, the systemd user unit on Linux (the
26
+ // step id predates the Linux backend and stays stable so
27
+ // resume journals keep matching)
28
+ // start-service launchctl bootstrap / systemctl --user daemon-reload +
29
+ // enable --now (best-effort, reported)
25
30
  // publish-active-payload the verified active identity and retained bytes
26
31
  // (B-3): a successful init establishes the version and
27
32
  // retention baseline itself, so the standard `npm A ->
@@ -49,14 +54,20 @@ function hookInstallerPath() {
49
54
  }
50
55
 
51
56
  export function installPlan(paths, options = {}) {
52
- const manifest = payloadManifestPath() ?? path.join(packageRoot(), 'npm', 'native', 'darwin-arm64', 'payload.json');
57
+ // The plan reports the payload the verifier will read. When the running
58
+ // tuple has a supported payload this is the resolved manifest path; on an
59
+ // unsupported host the fallback derives the same tuple from the process so
60
+ // the reported path matches the layout instead of a hard-coded platform.
61
+ const manifest = payloadManifestPath()
62
+ ?? path.join(packageRoot(), 'npm', NATIVE_DIR_NAME, `${process.platform}-${process.arch}`, 'payload.json');
63
+ const darwin = platform() === 'darwin';
53
64
  const plan = [
54
65
  { id: 'verify-payload', action: 'verify staged native payload', path: manifest },
55
66
  { id: 'check-path', action: 'report PATH availability without writing profiles' },
56
67
  { id: 'create-data', action: 'create private product data and log directories', paths: [paths.data, paths.logs] },
57
68
  { id: 'write-product-config', action: 'republish the product agent config schema', path: paths.config },
58
- { id: 'install-launch-agent', action: 'install daemon LaunchAgent', path: paths.launchAgent, label: LAUNCH_AGENT_LABEL },
59
- { id: 'start-service', action: 'bootstrap the daemon service', path: paths.launchAgent, label: LAUNCH_AGENT_LABEL },
69
+ { id: 'install-launch-agent', action: darwin ? 'install daemon LaunchAgent' : 'install daemon systemd user unit', path: paths.launchAgent, ...(darwin ? { label: LAUNCH_AGENT_LABEL } : { unit: SYSTEMD_UNIT_NAME }) },
70
+ { id: 'start-service', action: darwin ? 'bootstrap the daemon service' : 'enable and start the daemon service', path: paths.launchAgent, ...(darwin ? { label: LAUNCH_AGENT_LABEL } : { unit: SYSTEMD_UNIT_NAME }) },
60
71
  { id: 'publish-active-payload', action: 'publish the verified active payload and retained-byte baseline', path: paths.state },
61
72
  ];
62
73
  if (options.installHooks) plan.splice(plan.findIndex((step) => step.id === 'publish-active-payload'), 0, { id: 'install-hooks', action: 'install ZCode policy hooks', path: paths.zcodeConfig, provenance: paths.hookProvenance });
@@ -82,6 +93,14 @@ export function runInit(options = {}) {
82
93
  if (options.dryRun) return { dry_run: true, plan };
83
94
 
84
95
  const skipPayload = Boolean(options.skipPayloadProbe);
96
+ if (!skipPayload) {
97
+ // Repair an extraction mode masked by the installer's umask before the
98
+ // strict verify-payload gate reads the staged files (a 0775 extract from a
99
+ // 0755 archive is a real npm-install artifact, not a payload defect).
100
+ for (const entry of normalizePayloadFiles()) {
101
+ process.stderr.write(`normalized payload mode ${entry.from} -> ${entry.to} (${entry.name})\n`);
102
+ }
103
+ }
85
104
  const payload = skipPayload ? { status: 'skipped', platform: null, version: null, files: [] } : verifyPayload();
86
105
  const pathFindings = pathReport();
87
106
 
@@ -139,12 +158,12 @@ export function runInit(options = {}) {
139
158
  mark('write-product-config');
140
159
  }
141
160
  if (!completed.has('install-launch-agent')) {
142
- installLaunchAgent(paths);
161
+ installServiceDefinition(paths);
143
162
  failAt('install-launch-agent');
144
163
  mark('install-launch-agent');
145
164
  }
146
165
  if (!completed.has('start-service') && !options.skipServiceStart) {
147
- service = bootstrapService(paths, process.getuid(), { launchctl: options.launchctl });
166
+ service = bootstrapService(paths, process.getuid(), { launchctl: options.launchctl, systemctl: options.systemctl });
148
167
  serviceLoadedByThisRun = !service.skipped && !service.already_loaded;
149
168
  mark('start-service');
150
169
  }
@@ -183,7 +202,7 @@ export function runInit(options = {}) {
183
202
  ...removeCreatedDirectories(directories),
184
203
  ];
185
204
  if (serviceLoadedByThisRun) {
186
- try { bootoutService(paths, process.getuid(), { launchctl: options.launchctl }); } catch (bootoutError) {
205
+ try { bootoutService(paths, process.getuid(), { launchctl: options.launchctl, systemctl: options.systemctl }); } catch (bootoutError) {
187
206
  rollbackErrors.push(`service rollback (bootout) failed: ${bootoutError.message}`);
188
207
  }
189
208
  }
@@ -10,7 +10,14 @@ import { DAEMON_BIN_NAME, MCP_BIN_NAME, NATIVE_DIR_NAME, PRODUCT_NAME, VERSION }
10
10
  // constants.mjs, so the debug variant resolves its own payload directory,
11
11
  // binary names, and plugin source without a parallel code path.
12
12
  export const PLUGIN_NAME = PRODUCT_NAME;
13
- export const NATIVE_PLATFORM = 'darwin-arm64';
13
+ // Every platform tuple that ships a prebuilt payload maps to its own staged
14
+ // directory name. The tuple is the package's platform identity: the payload
15
+ // directory, the manifest `platform` field, and the image check in
16
+ // payload.mjs all key off the same value. A tuple outside this table has no
17
+ // payload and is rejected by the installer, never silently downgraded.
18
+ // NATIVE_PLATFORM stays the reference platform (the released macOS identity).
19
+ export const NATIVE_PLATFORMS = Object.freeze(['darwin-arm64', 'linux-x64']);
20
+ export const NATIVE_PLATFORM = NATIVE_PLATFORMS[0];
14
21
  export const NATIVE_BINARIES = Object.freeze([DAEMON_BIN_NAME, MCP_BIN_NAME]);
15
22
 
16
23
  export function packageRoot() {
@@ -18,12 +25,12 @@ export function packageRoot() {
18
25
  }
19
26
 
20
27
  export function nativePlatform(platform = process.platform, arch = process.arch) {
21
- if (platform === 'darwin' && arch === 'arm64') return NATIVE_PLATFORM;
22
- return null;
28
+ const tuple = `${platform}-${arch}`;
29
+ return NATIVE_PLATFORMS.includes(tuple) ? tuple : null;
23
30
  }
24
31
 
25
32
  export function nativePayloadDir(platform = nativePlatform()) {
26
- if (platform !== NATIVE_PLATFORM) return null;
33
+ if (!NATIVE_PLATFORMS.includes(platform)) return null;
27
34
  return path.join(packageRoot(), 'npm', NATIVE_DIR_NAME, platform);
28
35
  }
29
36
 
@@ -1,13 +1,37 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { nativeBinary } from './layout.mjs';
4
+ import { platform } from '../paths.mjs';
4
5
 
5
- // The launchd/GUI environment does not inherit the interactive shell PATH.
6
- // Every managed entry point therefore resolves to an absolute file inside the
7
- // installed package, and the fixed PATH below is only what the daemon itself
8
- // may use to locate system tools. This module never writes user profiles;
9
- // PATH findings are reported so an explicit repair stays a user decision.
6
+ // The launchd/GUI and systemd/user-manager environments do not inherit the
7
+ // interactive shell PATH. Every managed entry point therefore resolves to an
8
+ // absolute file inside the installed package, and the fixed PATHs below are
9
+ // only what the daemon itself may use to locate system tools. This module
10
+ // never writes user profiles; PATH findings are reported so an explicit
11
+ // repair stays a user decision.
10
12
  export const LAUNCHD_FIXED_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
13
+ // The Linux fixed set covers systemctl and the payload's system-tool
14
+ // dependencies. systemdServicePath() below appends the interpreter directory
15
+ // of the Node that rendered the unit, so a persisted shim-style subagent
16
+ // runtime (an nvm-installed Codex launcher) resolves `node` from the service
17
+ // environment instead of relying on the interactive shell PATH.
18
+ export const SYSTEMD_FIXED_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
19
+
20
+ // The Linux service PATH is the fixed system-tool set plus the interpreter
21
+ // directory of the node that rendered the unit. The persisted Codex runtime
22
+ // is commonly an nvm-installed Node launcher whose `#!/usr/bin/env node`
23
+ // shebang resolves `node` from PATH; without that directory the
24
+ // systemd-managed daemon cannot execute it. This mirrors the macOS fixed PATH
25
+ // carrying the Homebrew bin that holds its own `node`. The appended directory
26
+ // is absolute and newline-free by construction, so the unit generator's
27
+ // assertUnitLineSafe admits it unconditionally.
28
+ export function systemdServicePath() {
29
+ return `${SYSTEMD_FIXED_PATH}:${path.dirname(process.execPath)}`;
30
+ }
31
+
32
+ export function fixedServicePath(env = process.env) {
33
+ return platform(env) === 'darwin' ? LAUNCHD_FIXED_PATH : systemdServicePath();
34
+ }
11
35
 
12
36
  export function which(name, env = process.env) {
13
37
  const searchPath = (env.PATH || '').split(path.delimiter).filter(Boolean);
@@ -25,17 +49,18 @@ export function pathReport(options = {}) {
25
49
  const env = options.env || process.env;
26
50
  const daemon = nativeBinary('external-subagentd');
27
51
  const facade = nativeBinary('external-subagent-mcp');
52
+ const darwin = platform(env) === 'darwin';
28
53
  return {
29
54
  shell: {
30
55
  path: env.PATH || null,
31
56
  cli_on_path: which('external-subagent', env),
32
57
  mcp_on_path: which('external-subagent-mcp', env),
33
58
  },
34
- launchd: {
35
- path: LAUNCHD_FIXED_PATH,
36
- daemon_entry: daemon,
37
- mcp_entry: facade,
38
- },
59
+ // The fixed service-environment PATH, keyed by the backend that owns it
60
+ // so the report never calls a systemd unit "launchd".
61
+ ...(darwin
62
+ ? { launchd: { path: LAUNCHD_FIXED_PATH, daemon_entry: daemon, mcp_entry: facade } }
63
+ : { systemd: { path: systemdServicePath(), daemon_entry: daemon, mcp_entry: facade } }),
39
64
  stable_entries_absolute: Boolean(daemon && facade && path.isAbsolute(daemon) && path.isAbsolute(facade)),
40
65
  shell_profile_writes: 'none',
41
66
  node_executable: process.execPath,