external-subagent 0.5.2 → 0.5.4

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.
Files changed (44) hide show
  1. package/README.md +8 -5
  2. package/bin/external-subagent-mcp.mjs +3 -2
  3. package/cli/commands/agents.mjs +4 -9
  4. package/cli/commands/config.mjs +1 -1
  5. package/cli/commands/daemon.mjs +8 -3
  6. package/cli/commands/maintenance.mjs +158 -1
  7. package/cli/commands/plugin.mjs +28 -3
  8. package/cli/commands/tasks.mjs +7 -1
  9. package/cli/commands/update.mjs +18 -7
  10. package/cli/config/write.mjs +195 -14
  11. package/cli/constants.mjs +2 -2
  12. package/cli/install/codex.mjs +1 -1
  13. package/cli/install/dsh.mjs +613 -0
  14. package/cli/install/init.mjs +23 -33
  15. package/cli/install/layout.mjs +10 -2
  16. package/cli/install/payload.mjs +59 -12
  17. package/cli/install/service-activation.mjs +10 -2
  18. package/cli/install/service-linux.mjs +1 -1
  19. package/cli/install/service-macos.mjs +40 -14
  20. package/cli/install/service-windows.mjs +463 -0
  21. package/cli/install/update.mjs +6 -5
  22. package/cli/main.mjs +10 -14
  23. package/cli/maintenance.mjs +7 -1
  24. package/cli/paths.mjs +152 -8
  25. package/npm/native/README.md +2 -2
  26. package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
  27. package/npm/native/darwin-arm64/external-subagentd +0 -0
  28. package/npm/native/darwin-arm64/payload.json +5 -5
  29. package/npm/native/linux-x64/external-subagent-mcp +0 -0
  30. package/npm/native/linux-x64/external-subagentd +0 -0
  31. package/npm/native/linux-x64/payload.json +5 -5
  32. package/package.json +3 -2
  33. package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +5 -3
  34. package/schema/external-subagent-public-api.json +3 -3
  35. package/scripts/release/build-native-payload.mjs +22 -5
  36. package/scripts/release/check-native-tarball.mjs +34 -14
  37. package/scripts/release/stage-debug-plugin.mjs +9 -3
  38. package/scripts/release/test-installed-tarball.mjs +1 -1
  39. package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +0 -55
  40. package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +0 -19
  41. package/plugins/codex/external-subagent/hooks/guard-bash.mjs +0 -15
  42. package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +0 -206
  43. package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +0 -149
  44. package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +0 -58
@@ -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. 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.
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
- export function updateConfig(file, updater) {
35
- const lock = `${path.resolve(file)}.lock`;
36
- fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
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() + 2500;
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
- try { process.kill(-helper.pid, 'SIGTERM'); } catch {}
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.2';
15
+ export const VERSION = '0.5.4';
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', 'hooks', 'status', 'diagnose', 'backup', 'restore', 'start', 'stop',
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',
@@ -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
- // (S01 keeps staging/marketplace recoverable) instead of reporting
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);