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
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.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
`
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
`
|
|
125
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
process.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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();
|
package/cli/commands/config.mjs
CHANGED
|
@@ -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;
|
package/cli/commands/daemon.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
38
|
+
...serviceDefinition,
|
|
29
39
|
data: fs.existsSync(paths.data),
|
|
30
40
|
payload: publicPayload,
|
|
31
41
|
// Home paths are installation internals; status reports only the
|
package/cli/commands/tasks.mjs
CHANGED
|
@@ -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
|
|
package/cli/config/write.mjs
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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.
|
|
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',
|
package/cli/install/init.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
24
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/cli/install/layout.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
33
|
+
if (!NATIVE_PLATFORMS.includes(platform)) return null;
|
|
27
34
|
return path.join(packageRoot(), 'npm', NATIVE_DIR_NAME, platform);
|
|
28
35
|
}
|
|
29
36
|
|
package/cli/install/path.mjs
CHANGED
|
@@ -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
|
|
6
|
-
// Every managed entry point therefore resolves to an
|
|
7
|
-
// installed package, and the fixed
|
|
8
|
-
// may use to locate system tools. This module
|
|
9
|
-
// PATH findings are reported so an explicit
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|