external-subagent 0.5.2 → 0.5.3
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 +8 -5
- package/bin/external-subagent-mcp.mjs +3 -2
- package/cli/commands/agents.mjs +4 -9
- package/cli/commands/config.mjs +1 -1
- package/cli/commands/daemon.mjs +8 -3
- package/cli/commands/maintenance.mjs +158 -1
- package/cli/commands/plugin.mjs +28 -3
- package/cli/commands/tasks.mjs +7 -1
- package/cli/commands/update.mjs +18 -7
- package/cli/config/write.mjs +195 -14
- package/cli/constants.mjs +2 -2
- package/cli/install/codex.mjs +1 -1
- package/cli/install/dsh.mjs +606 -0
- package/cli/install/init.mjs +23 -33
- package/cli/install/layout.mjs +10 -2
- package/cli/install/payload.mjs +59 -12
- package/cli/install/service-activation.mjs +10 -2
- package/cli/install/service-linux.mjs +1 -1
- package/cli/install/service-macos.mjs +40 -14
- package/cli/install/service-windows.mjs +463 -0
- package/cli/install/update.mjs +6 -5
- package/cli/main.mjs +10 -14
- package/cli/maintenance.mjs +7 -1
- package/cli/paths.mjs +152 -8
- package/npm/native/README.md +2 -2
- 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 +5 -5
- 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 +5 -5
- package/package.json +3 -2
- package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +5 -3
- package/schema/external-subagent-public-api.json +3 -3
- package/scripts/release/build-native-payload.mjs +22 -5
- package/scripts/release/check-native-tarball.mjs +34 -14
- package/scripts/release/stage-debug-plugin.mjs +9 -3
- package/scripts/release/test-installed-tarball.mjs +1 -1
- package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +0 -55
- package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +0 -19
- package/plugins/codex/external-subagent/hooks/guard-bash.mjs +0 -15
- package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +0 -206
- package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +0 -149
- package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +0 -58
package/cli/config/write.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import { spawn } from 'node:child_process';
|
|
4
5
|
import { atomicWrite, jsonBytes } from '../fs-atomic.mjs';
|
|
5
6
|
import { validateConfig } from './schema.mjs';
|
|
@@ -13,32 +14,201 @@ export function writeConfig(file, value) {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
// The read-modify-write config transaction holds an exclusive lock on
|
|
16
|
-
// `<config>.lock` for its whole duration.
|
|
17
|
-
// process so a crashed writer releases it automatically: the helper
|
|
18
|
-
// the lock, creates a `ready` acknowledgement, then holds the lock
|
|
19
|
-
// process kills the helper group. macOS uses lockf and Linux uses
|
|
20
|
-
// take an exclusive advisory lock on a file, run a command while
|
|
21
|
-
// and give up after a bounded wait, so the
|
|
22
|
-
// semantics are equivalent. The binary is
|
|
23
|
-
// capability), not by the
|
|
17
|
+
// `<config>.lock` for its whole duration. On POSIX the lock is owned by a
|
|
18
|
+
// helper process so a crashed writer releases it automatically: the helper
|
|
19
|
+
// acquires the lock, creates a `ready` acknowledgement, then holds the lock
|
|
20
|
+
// until this process kills the helper group. macOS uses lockf and Linux uses
|
|
21
|
+
// flock — both take an exclusive advisory lock on a file, run a command while
|
|
22
|
+
// holding it, and give up after a bounded wait, so the
|
|
23
|
+
// timeout/mutual-exclusion/failure semantics are equivalent. The binary is
|
|
24
|
+
// chosen by the host OS (a real capability), not by the
|
|
25
|
+
// EXTERNAL_SUBAGENT_TEST_PLATFORM layout seam.
|
|
26
|
+
//
|
|
27
|
+
// Windows has neither helper. The transaction locks by exclusive creation of
|
|
28
|
+
// the same `<config>.lock` file (the atomic `wx` flag). A crashed writer
|
|
29
|
+
// leaves the file behind, so a waiter snapshots it, renames it to a unique
|
|
30
|
+
// tombstone (the atomic claim), re-reads the tombstone, and only removes that
|
|
31
|
+
// tombstone when it still matches the verified snapshot and is stale. A file
|
|
32
|
+
// that was replaced in between belongs to a concurrent writer and is never
|
|
33
|
+
// deleted; it is restored with create-or-fail hard-link semantics, which
|
|
34
|
+
// cannot displace a newer lock at the canonical path. Release applies the
|
|
35
|
+
// same snapshot check to the acquisition's exact bytes/token. Both backends
|
|
36
|
+
// keep the same 2s deadline and the same CONFIG_LOCK_TIMEOUT failure contract.
|
|
24
37
|
const LOCK_TIMEOUT_SECONDS = 2;
|
|
38
|
+
const LOCK_DEADLINE_MS = 2500;
|
|
39
|
+
const LOCK_STALE_MS = 60_000;
|
|
40
|
+
// A creator writes pid/token immediately after the exclusive create. Do not
|
|
41
|
+
// reclaim an unparseable/empty lock during that window (crash between create
|
|
42
|
+
// and write is reclaimed after this grace period).
|
|
43
|
+
const LOCK_WRITE_GRACE_MS = 5_000;
|
|
25
44
|
const LOCK_READY_SCRIPT = 'ready="$1"; parent="$2"; printf ready > "$ready"; while kill -0 "$parent" 2>/dev/null; do sleep 0.05; done';
|
|
26
45
|
|
|
46
|
+
export function lockStrategy(platform = process.platform) {
|
|
47
|
+
return platform === 'win32' ? 'exclusive-create' : 'helper';
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
export function lockHelperInvocation(lock, ready, platform = process.platform) {
|
|
51
|
+
if (platform === 'win32') return { strategy: 'exclusive-create', lock, ready };
|
|
28
52
|
const tail = ['/bin/sh', '-c', LOCK_READY_SCRIPT, 'external-subagent-lock', ready, String(process.pid)];
|
|
29
53
|
return platform === 'darwin'
|
|
30
54
|
? { command: '/usr/bin/lockf', args: ['-t', String(LOCK_TIMEOUT_SECONDS), lock, ...tail] }
|
|
31
55
|
: { command: '/usr/bin/flock', args: ['-w', String(LOCK_TIMEOUT_SECONDS), lock, ...tail] };
|
|
32
56
|
}
|
|
33
57
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
58
|
+
function processIsAlive(pid) {
|
|
59
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return error?.code === 'EPERM';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// An immutable snapshot of the lock file: the exact bytes plus the stat
|
|
69
|
+
// identity. Reclaiming renames the file first and then compares the *moved*
|
|
70
|
+
// snapshot, so the verify-then-remove sequence never races a concurrent
|
|
71
|
+
// creator at the original path.
|
|
72
|
+
function lockSnapshot(lock) {
|
|
73
|
+
let raw;
|
|
74
|
+
let stat;
|
|
75
|
+
try {
|
|
76
|
+
raw = fs.readFileSync(lock, 'utf8');
|
|
77
|
+
stat = fs.statSync(lock);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
let pid = null;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(raw);
|
|
84
|
+
if (Number.isInteger(parsed) && parsed > 0) pid = parsed;
|
|
85
|
+
else if (Number.isInteger(parsed?.pid) && parsed.pid > 0) pid = parsed.pid;
|
|
86
|
+
} catch {
|
|
87
|
+
const parsed = Number.parseInt(String(raw).trim(), 10);
|
|
88
|
+
if (Number.isInteger(parsed) && parsed > 0) pid = parsed;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
raw,
|
|
92
|
+
pid,
|
|
93
|
+
ageMs: Date.now() - stat.mtimeMs,
|
|
94
|
+
mtimeMs: stat.mtimeMs,
|
|
95
|
+
size: stat.size,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function snapshotIsStale(snapshot) {
|
|
100
|
+
if (snapshot === null) return false;
|
|
101
|
+
if (snapshot.pid === null) return snapshot.ageMs > LOCK_WRITE_GRACE_MS;
|
|
102
|
+
return !processIsAlive(snapshot.pid) || snapshot.ageMs > LOCK_STALE_MS;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sameLockSnapshot(left, right) {
|
|
106
|
+
return left !== null
|
|
107
|
+
&& right !== null
|
|
108
|
+
&& left.raw === right.raw
|
|
109
|
+
&& left.size === right.size
|
|
110
|
+
&& left.mtimeMs === right.mtimeMs;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Restore a lock file without ever displacing a newer one. `rename` REPLACES
|
|
114
|
+
// an existing destination on both POSIX and Windows, so it cannot be used for
|
|
115
|
+
// a restore. `linkSync` is create-or-fail: EEXIST means a concurrent writer
|
|
116
|
+
// owns the canonical path, and the saved file is left untouched for that
|
|
117
|
+
// writer's own release. On success the temporary name is removed.
|
|
118
|
+
export function restoreLockWithoutDisplacing(saved, target) {
|
|
119
|
+
try {
|
|
120
|
+
fs.linkSync(saved, target);
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
try { fs.unlinkSync(saved); } catch {}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function reclaimStaleLock(lock) {
|
|
129
|
+
const before = lockSnapshot(lock);
|
|
130
|
+
if (!snapshotIsStale(before)) return false;
|
|
131
|
+
// Rename is the atomic claim: a losing racer gets ENOENT and never touches
|
|
132
|
+
// the winner's file. Everything after this line operates on the tombstone,
|
|
133
|
+
// never on the original path.
|
|
134
|
+
const tombstone = `${lock}.stale-${process.pid}-${crypto.randomUUID()}`;
|
|
135
|
+
try {
|
|
136
|
+
fs.renameSync(lock, tombstone);
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
const moved = lockSnapshot(tombstone);
|
|
141
|
+
if (sameLockSnapshot(before, moved) && snapshotIsStale(moved)) {
|
|
142
|
+
try { fs.unlinkSync(tombstone); } catch {}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
// The file we moved was replaced between the snapshot and the rename: it is
|
|
146
|
+
// a concurrent writer's fresh lock. Never delete it, and restore only with
|
|
147
|
+
// create-or-fail semantics so a newer lock at the canonical path is never
|
|
148
|
+
// overwritten.
|
|
149
|
+
restoreLockWithoutDisplacing(tombstone, lock);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function releaseExclusiveFileLock(lock, expectedRaw) {
|
|
154
|
+
// Verify-then-remove on an immutable snapshot: only a lock whose exact
|
|
155
|
+
// bytes are still this acquisition's may be removed. The rename is the
|
|
156
|
+
// atomic claim; if the original path was replaced between the read and the
|
|
157
|
+
// rename, the moved file belongs to another writer and is restored without
|
|
158
|
+
// displacing whatever now owns the canonical path.
|
|
159
|
+
const current = lockSnapshot(lock);
|
|
160
|
+
if (current === null || current.raw !== expectedRaw) return;
|
|
161
|
+
const tombstone = `${lock}.release-${process.pid}-${crypto.randomUUID()}`;
|
|
162
|
+
try {
|
|
163
|
+
fs.renameSync(lock, tombstone);
|
|
164
|
+
} catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const moved = lockSnapshot(tombstone);
|
|
168
|
+
if (moved !== null && moved.raw === expectedRaw) {
|
|
169
|
+
try { fs.unlinkSync(tombstone); } catch {}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
restoreLockWithoutDisplacing(tombstone, lock);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function acquireExclusiveFileLock(lock) {
|
|
176
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
177
|
+
const deadline = Date.now() + LOCK_DEADLINE_MS;
|
|
178
|
+
for (;;) {
|
|
179
|
+
let fd;
|
|
180
|
+
try {
|
|
181
|
+
fd = fs.openSync(lock, 'wx', 0o600);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
// Windows may surface a concurrent create/delete race as EACCES/EPERM
|
|
184
|
+
// instead of EEXIST; both are contention, not a configuration failure.
|
|
185
|
+
if (!['EEXIST', 'EACCES', 'EPERM'].includes(error?.code)) throw error;
|
|
186
|
+
}
|
|
187
|
+
if (fd !== undefined) {
|
|
188
|
+
const token = crypto.randomUUID();
|
|
189
|
+
const contents = JSON.stringify({ pid: process.pid, token, started_at_ms: Date.now() });
|
|
190
|
+
try {
|
|
191
|
+
fs.writeSync(fd, contents);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
try { fs.unlinkSync(lock); } catch {}
|
|
194
|
+
throw error;
|
|
195
|
+
} finally {
|
|
196
|
+
fs.closeSync(fd);
|
|
197
|
+
}
|
|
198
|
+
return () => releaseExclusiveFileLock(lock, contents);
|
|
199
|
+
}
|
|
200
|
+
if (reclaimStaleLock(lock)) continue;
|
|
201
|
+
if (Date.now() >= deadline) throw new Error('CONFIG_LOCK_TIMEOUT');
|
|
202
|
+
Atomics.wait(wait, 0, 0, 5);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function acquireHelperLock(lock) {
|
|
37
207
|
const ready = `${lock}.${process.pid}.${Date.now()}.ready`;
|
|
38
208
|
const { command, args } = lockHelperInvocation(lock, ready);
|
|
39
209
|
const helper = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
40
210
|
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
41
|
-
const deadline = Date.now() +
|
|
211
|
+
const deadline = Date.now() + LOCK_DEADLINE_MS;
|
|
42
212
|
while (!fs.existsSync(ready)) {
|
|
43
213
|
if (helper.exitCode !== null || Date.now() >= deadline) {
|
|
44
214
|
try { process.kill(-helper.pid, 'SIGTERM'); } catch {}
|
|
@@ -46,12 +216,23 @@ export function updateConfig(file, updater) {
|
|
|
46
216
|
}
|
|
47
217
|
Atomics.wait(wait, 0, 0, 5);
|
|
48
218
|
}
|
|
219
|
+
return () => {
|
|
220
|
+
try { process.kill(-helper.pid, 'SIGTERM'); } catch {}
|
|
221
|
+
try { fs.unlinkSync(ready); } catch {}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function updateConfig(file, updater) {
|
|
226
|
+
const lock = `${path.resolve(file)}.lock`;
|
|
227
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
|
|
228
|
+
const release = lockStrategy() === 'exclusive-create'
|
|
229
|
+
? acquireExclusiveFileLock(lock)
|
|
230
|
+
: acquireHelperLock(lock);
|
|
49
231
|
try {
|
|
50
232
|
const current = readConfig(file);
|
|
51
233
|
const next = updater(current);
|
|
52
234
|
return next === current ? current : writeConfig(file, next);
|
|
53
235
|
} finally {
|
|
54
|
-
|
|
55
|
-
try { fs.unlinkSync(ready); } catch {}
|
|
236
|
+
release();
|
|
56
237
|
}
|
|
57
238
|
}
|
package/cli/constants.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const IS_DEBUG_VARIANT = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug';
|
|
|
12
12
|
|
|
13
13
|
export const PRODUCT_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug' : 'external-subagent';
|
|
14
14
|
export const PRODUCT_ID = IS_DEBUG_VARIANT ? 'external_subagent_debug' : 'external_subagent';
|
|
15
|
-
export const VERSION = '0.5.
|
|
15
|
+
export const VERSION = '0.5.3';
|
|
16
16
|
export const LAUNCH_AGENT_LABEL = IS_DEBUG_VARIANT ? 'com.external-subagent-debug.daemon' : 'com.external-subagent.daemon';
|
|
17
17
|
export const DAEMON_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debugd' : 'external-subagentd';
|
|
18
18
|
export const MCP_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug-mcp' : 'external-subagent-mcp';
|
|
@@ -51,7 +51,7 @@ export function zcodeRuntimePath(home = os.homedir(), env = process.env) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export const BUSINESS_COMMANDS = new Set([
|
|
54
|
-
'init', '
|
|
54
|
+
'init', 'status', 'diagnose', 'backup', 'restore', 'start', 'stop',
|
|
55
55
|
'uninstall', 'purge', 'install-plugin', 'install-mcp', 'create', 'spawn', 'wait', 'list', 'send',
|
|
56
56
|
'respond', 'cancel', 'result', 'close', 'config', 'agents', 'subagents',
|
|
57
57
|
'observe', 'update', 'reconcile',
|
package/cli/install/codex.mjs
CHANGED
|
@@ -176,7 +176,7 @@ function verifyCodexCache(add, { codexHome, staging, marketplaceName }) {
|
|
|
176
176
|
// plugin tree with no host-generated additions inside it (see
|
|
177
177
|
// docs/compatibility/codex.md), so the staged file set IS the managed
|
|
178
178
|
// set; any difference fails closed through the existing error chain
|
|
179
|
-
// (
|
|
179
|
+
// (staging/marketplace stay recoverable) instead of reporting
|
|
180
180
|
// cache_verified for stale bytes.
|
|
181
181
|
const stagedMcp = readCacheJson(path.join(staging, '.mcp.json'), staging, identity);
|
|
182
182
|
const cacheMcp = readCacheJson(path.join(cache, '.mcp.json'), cache, identity);
|