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.
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 +606 -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
package/cli/paths.mjs CHANGED
@@ -30,7 +30,7 @@ function darwinProductPaths(home) {
30
30
  };
31
31
  }
32
32
 
33
- // XDG Base Directory resolution (D-S02). A variable selects its directory only
33
+ // XDG Base Directory resolution. A variable selects its directory only
34
34
  // when it is a non-empty absolute path; relative or empty values are ignored and
35
35
  // the spec fallback under $HOME applies. Only the three XDG variables the
36
36
  // product owns are read — no other user XDG directory is probed.
@@ -46,7 +46,7 @@ function xdgDirectory(env, variable, home, fallback) {
46
46
  // state, database, socket, hook provenance, plugin staging, profiles all live
47
47
  // inside the product data directory) so the two platforms stay structurally
48
48
  // parallel. Data and logs follow XDG; the systemd user unit directory is
49
- // provided for the S03 service backend.
49
+ // provided for the service backend.
50
50
  function linuxProductPaths(home, env) {
51
51
  const data = path.join(xdgDirectory(env, 'XDG_DATA_HOME', home, ['.local', 'share']), PRODUCT_NAME);
52
52
  const logs = path.join(xdgDirectory(env, 'XDG_STATE_HOME', home, ['.local', 'state']), PRODUCT_NAME);
@@ -67,13 +67,55 @@ function linuxProductPaths(home, env) {
67
67
  };
68
68
  }
69
69
 
70
+ // Windows base directory resolution: %LOCALAPPDATA% is used only when it is a
71
+ // non-empty absolute path; relative or empty values are ignored and the fallback
72
+ // under %USERPROFILE%\AppData\Local applies. APPDATA (Roaming) is deliberately
73
+ // not consulted as the frozen field set has no roaming consumers.
74
+ function localAppDataDirectory(env, home) {
75
+ const configured = env.LOCALAPPDATA;
76
+ if (typeof configured === 'string' && configured !== '' && path.isAbsolute(configured)) {
77
+ return configured;
78
+ }
79
+ return path.join(home, 'AppData', 'Local');
80
+ }
81
+
82
+ // Windows product layout: structurally parallel to darwin/linux.
83
+ // Data, database, config, state, hook provenance, plugin staging, and profiles
84
+ // live under %LOCALAPPDATA%\<product>.
85
+ // Socket uses the Windows named pipe namespace (\\.\pipe\<product>-daemon).
86
+ // launchAgent is null as Windows uses SCM rather than a file-based unit definition.
87
+ function windowsProductPaths(home, env) {
88
+ const localAppData = localAppDataDirectory(env, home);
89
+ const data = path.join(localAppData, PRODUCT_NAME);
90
+ return {
91
+ home,
92
+ data,
93
+ config: path.join(data, 'config.json'),
94
+ state: path.join(data, 'install-state.json'),
95
+ hookProvenance: path.join(data, 'zcode-agent-hook-provenance.json'),
96
+ database: path.join(data, `${PRODUCT_NAME}.sqlite3`),
97
+ // Named pipe namespace placeholder (pending phase 1 real-machine implementation,
98
+ // not a filesystem path; prefix matches the windows.rs \\.\pipe\ literal).
99
+ socket: `\\\\.\\pipe\\${PRODUCT_NAME}-daemon`,
100
+ logs: path.join(localAppData, PRODUCT_NAME, 'logs'),
101
+ // Windows has no file-based service definition; pending phase 3 SCM integration decision.
102
+ launchAgent: null,
103
+ zcodeConfig: path.join(home, '.zcode', 'cli', 'config.json'),
104
+ zcodePlugin: path.join(data, 'zcode-plugin', PLUGIN_NAME),
105
+ profiles: path.join(data, 'profiles'),
106
+ };
107
+ }
108
+
70
109
  // State paths derive from PRODUCT_NAME, so the debug variant gets a fully
71
110
  // parallel tree (its own data directory, database, socket, and logs) and
72
111
  // never touches the released product's state. The layout is selected by the
73
- // process platform: macOS `~/Library`, every other supported host the XDG
74
- // baseline. `zcodeConfig` is `~/.zcode/cli/config.json` on both platforms.
112
+ // process platform: macOS `~/Library`, Windows `%LOCALAPPDATA%`, every other
113
+ // supported host the XDG baseline. `zcodeConfig` is `~/.zcode/cli/config.json`.
75
114
  export function productPaths(home = os.homedir(), env = process.env) {
76
- return platform(env) === 'darwin' ? darwinProductPaths(home) : linuxProductPaths(home, env);
115
+ const currentPlatform = platform(env);
116
+ if (currentPlatform === 'darwin') return darwinProductPaths(home);
117
+ if (currentPlatform === 'win32') return windowsProductPaths(home, env);
118
+ return linuxProductPaths(home, env);
77
119
  }
78
120
 
79
121
  // Mirror the daemon's `profiles_directory()` exactly (rpc/profiles.rs):
@@ -92,7 +134,7 @@ export function productPaths(home = os.homedir(), env = process.env) {
92
134
  // separators and any `.` component that is not the first, so a trailing `.` is
93
135
  // not a component of its own: `a/.` has parent `Some("")`, `a/b/.` has parent
94
136
  // `Some("a")`, and `/.` is the root with no parent (`None`). `..` is retained.
95
- function rustPathParent(value) {
137
+ function rustPathParentUnix(value) {
96
138
  if (value === '') return null;
97
139
  let end = value.length;
98
140
  for (;;) {
@@ -121,6 +163,107 @@ function joinProfiles(parent) {
121
163
  return `${parent}/profiles`;
122
164
  }
123
165
 
166
+ // ---------------------------------------------------------------------------
167
+ // Windows twin of the two helpers above.
168
+ //
169
+ // Rust compiles `std::path` against the TARGET platform's grammar, so the
170
+ // daemon running on Windows reads config paths as Win32 paths: both `\` and
171
+ // `/` are separators, a Disk/UNC/Verbatim prefix is part of the path, and
172
+ // `Path::parent()` never returns a parent for a root. The functions below are
173
+ // derived case-by-case from real `Path::parent()` / `Path::join("profiles")`
174
+ // output on Windows (see tests/platform/windows-paths.test.mjs); they preserve
175
+ // the original bytes (mixed separators included) and never lexically fold
176
+ // `..`, exactly like the Unix implementation above.
177
+ //
178
+ // The Unix helpers stay untouched: `profilesDir` selects the pair by the same
179
+ // process-platform seam that selects the product layout.
180
+ function isWindowsSeparator(char) {
181
+ return char === '\\' || char === '/';
182
+ }
183
+
184
+ // Prefix grammar (the subset the product can encounter):
185
+ // VerbatimDisk \\?\C:
186
+ // VerbatimUNC \\?\UNC\server\share
187
+ // Verbatim \\?\...
188
+ // UNC \\server\share (requires both components)
189
+ // Disk C:
190
+ // A path with fewer UNC components (`\\server`, `//foo`) is NOT a prefix in
191
+ // Rust: the leading separator is the root instead.
192
+ function windowsPrefixEnd(value) {
193
+ if (value.startsWith('\\\\?\\') || value.startsWith('//?/')) {
194
+ let index = 4;
195
+ if (value.slice(index, index + 3).toUpperCase() === 'UNC'
196
+ && index + 3 < value.length && isWindowsSeparator(value[index + 3])) {
197
+ index += 4;
198
+ // server
199
+ while (index < value.length && !isWindowsSeparator(value[index])) index += 1;
200
+ while (index < value.length && isWindowsSeparator(value[index])) index += 1;
201
+ // share
202
+ while (index < value.length && !isWindowsSeparator(value[index])) index += 1;
203
+ return index;
204
+ }
205
+ if (index + 1 < value.length && /[A-Za-z]/u.test(value[index]) && value[index + 1] === ':') {
206
+ return index + 2;
207
+ }
208
+ return 4;
209
+ }
210
+ if (value.length >= 2 && isWindowsSeparator(value[0]) && isWindowsSeparator(value[1])) {
211
+ let index = 2;
212
+ const serverStart = index;
213
+ while (index < value.length && !isWindowsSeparator(value[index])) index += 1;
214
+ if (index === serverStart) return 0; // repeated separator: root-relative
215
+ while (index < value.length && isWindowsSeparator(value[index])) index += 1;
216
+ const shareStart = index;
217
+ while (index < value.length && !isWindowsSeparator(value[index])) index += 1;
218
+ if (index === shareStart) return 0; // no share: not a UNC prefix
219
+ return index;
220
+ }
221
+ if (value.length >= 2 && /[A-Za-z]/u.test(value[0]) && value[1] === ':') return 2;
222
+ return 0;
223
+ }
224
+
225
+ function windowsComponents(value, start) {
226
+ const components = [];
227
+ let index = start;
228
+ while (index < value.length) {
229
+ while (index < value.length && isWindowsSeparator(value[index])) index += 1;
230
+ if (index >= value.length) break;
231
+ const componentStart = index;
232
+ while (index < value.length && !isWindowsSeparator(value[index])) index += 1;
233
+ // Rust's Path::components drops a non-leading "." (a leading "." with no
234
+ // prefix/root is CurDir and stays a component).
235
+ if (value.slice(componentStart, index) === '.' && componentStart !== 0) continue;
236
+ components.push({ start: componentStart, end: index });
237
+ }
238
+ return components;
239
+ }
240
+
241
+ function rustPathParentWindows(value) {
242
+ if (value === '') return null;
243
+ const prefixEnd = windowsPrefixEnd(value);
244
+ let rootEnd = prefixEnd;
245
+ if (rootEnd < value.length && isWindowsSeparator(value[rootEnd])) rootEnd += 1;
246
+ const components = windowsComponents(value, rootEnd);
247
+ if (components.length === 0) return null; // root, prefix-only, and empty paths
248
+ if (components.length === 1) return rootEnd === 0 ? '' : value.slice(0, rootEnd);
249
+ return value.slice(0, components[components.length - 2].end);
250
+ }
251
+
252
+ // Platform-selected Rust `Path::parent()` (the target platform's grammar, not
253
+ // the host JS module's). Exported so the path contract is directly testable
254
+ // without going through the product layout.
255
+ export function rustPathParent(value, currentPlatform = process.platform) {
256
+ return currentPlatform === 'win32' ? rustPathParentWindows(value) : rustPathParentUnix(value);
257
+ }
258
+
259
+ // Rust `Path::join` on Windows appends with `\`; a trailing separator (roots)
260
+ // and a drive-relative `C:` prefix append directly.
261
+ function joinProfilesWindows(parent) {
262
+ if (parent === '') return 'profiles';
263
+ if (/[\\/]$/u.test(parent) || /^[A-Za-z]:$/u.test(parent)) return `${parent}profiles`;
264
+ return `${parent}\\profiles`;
265
+ }
266
+
124
267
  export function profilesDir(env = {}, home = os.homedir()) {
125
268
  const names = ['EXTERNAL_SUBAGENT_CONFIG', 'ZCODE_AGENT_CONFIG'];
126
269
  let envPath;
@@ -133,7 +276,8 @@ export function profilesDir(env = {}, home = os.homedir()) {
133
276
  }
134
277
  }
135
278
  if (!exported) return path.join(productPaths(home, env).data, 'profiles');
136
- const parent = rustPathParent(envPath);
279
+ const windows = platform(env) === 'win32';
280
+ const parent = rustPathParent(envPath, platform(env));
137
281
  if (parent === null) return null;
138
- return joinProfiles(parent);
282
+ return windows ? joinProfilesWindows(parent) : joinProfiles(parent);
139
283
  }
@@ -14,6 +14,6 @@ node scripts/release/build-native-payload.mjs --if-stale
14
14
 
15
15
  Supported platforms install without compiling Rust; the installer verifies
16
16
  the payload manifest, digest, permissions (755), and Mach-O arm64 image
17
- before any `init` step runs. There is no payload for other platforms in
18
- S05; unsupported platforms keep `help`/`version` working and reject business
17
+ before any `init` step runs. There is no payload for other platforms yet;
18
+ unsupported platforms keep `help`/`version` working and reject business
19
19
  commands without writing HOME.
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "product": "external-subagent",
4
- "version": "0.5.2",
4
+ "version": "0.5.3",
5
5
  "platform": "darwin-arm64",
6
6
  "files": [
7
7
  {
8
8
  "name": "external-subagentd",
9
- "bytes": 10118624,
9
+ "bytes": 10169696,
10
10
  "mode": "755",
11
- "sha256": "d0effa783502353dc474ab4c961b35bbe82ba8cb2f1ccbdadae3aac7e68dec7a"
11
+ "sha256": "2a253cc82755453ce7e56a625871f7fc2144d853f37dda1498daa18d0800dfc0"
12
12
  },
13
13
  {
14
14
  "name": "external-subagent-mcp",
15
- "bytes": 935760,
15
+ "bytes": 935776,
16
16
  "mode": "755",
17
- "sha256": "76e52e11bf1d30553c26278f34f363005714f30012a7fca14df54cd025666461"
17
+ "sha256": "f941106af0c3135087a1122d3e6075223996ab09831768a1a495facb91ca6ad7"
18
18
  }
19
19
  ]
20
20
  }
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "product": "external-subagent",
4
- "version": "0.5.2",
4
+ "version": "0.5.3",
5
5
  "platform": "linux-x64",
6
6
  "files": [
7
7
  {
8
8
  "name": "external-subagentd",
9
- "bytes": 11434656,
9
+ "bytes": 11488752,
10
10
  "mode": "755",
11
- "sha256": "28b3bf99d7ea6e24747044620d6a6976849d434637e18372327848fcab3d202f"
11
+ "sha256": "d960dc25d6f4ac77027f81575b91428bb521c8f75b5b39f30e148b15707f1ebb"
12
12
  },
13
13
  {
14
14
  "name": "external-subagent-mcp",
15
- "bytes": 1025400,
15
+ "bytes": 1025512,
16
16
  "mode": "755",
17
- "sha256": "115d466e4480db0d0ebd45b2930e87b1510560e9cd4a2896bd8442318a967c19"
17
+ "sha256": "44717d7bada5ed71066f93348fa26f74391475fa147c816c3574a91b3ad65f22"
18
18
  }
19
19
  ]
20
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "external-subagent",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Manage bounded ZCode, DeepSeek Harness (DSH), Codex, and Google Antigravity (agy) subagent tasks through a local MCP service for any local host (Codex, ZCode, or a custom MCP client)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,7 +22,8 @@
22
22
  ],
23
23
  "os": [
24
24
  "darwin",
25
- "linux"
25
+ "linux",
26
+ "win32"
26
27
  ],
27
28
  "cpu": [
28
29
  "arm64",
@@ -31,7 +31,7 @@ existing wire default; zcode and dsh send no effort field or call at all).
31
31
  pass it through to the runtime, because the supported set is only known at
32
32
  runtime. A malformed or unsupported token is rejected before dispatch, so no
33
33
  task is created. Example: spawn with
34
- `{"subagent": "zcode", "repository": "/absolute/path", "prompt": "...", "effort": "high"}`.
34
+ `{"subagent": "zcode", "model": "provider/model", "repository": "/absolute/path", "prompt": "...", "effort": "high"}`.
35
35
 
36
36
  The caller is a `host`. Any unregistered local MCP client can act as `custom` and
37
37
  call status or spawn without a host record or Codex home. Built-in `host.codex`
@@ -47,8 +47,10 @@ the configuration only on success (a daemon restart is required for `dsh`/`codex
47
47
  Actual per-subagent limitations (from the accepted code):
48
48
 
49
49
  - `zcode` — disabled until explicitly enabled; all four permission modes
50
- (build/edit/plan/yolo); an explicit spawn `model` is rejected
51
- (`model_selection_unsupported`).
50
+ (build/edit/plan/yolo); spawn requires an explicit `{provider}/{model}`
51
+ token from the spawn request or a named profile. A bare token and an
52
+ omitted model are rejected at admission, and `agents.zcode.default_model`
53
+ is rejected with `model_selection_unsupported`.
52
54
  - `dsh` — disabled until explicitly enabled and configured
53
55
  (`runtime_path`/`home`/`profile`/`version`); admits only `build` and strict
54
56
  `plan`.
@@ -68,7 +68,7 @@
68
68
  "repository": { "type": "string", "minLength": 1 },
69
69
  "permission_mode": { "$ref": "#/properties/permission_mode", "description": "codex supports build/edit (workspace-write), plan (read-only), and yolo (danger-full-access), always with approvalPolicy=never; agy supports only build (--mode accept-edits) and yolo (--dangerously-skip-permissions)." },
70
70
  "prompt": { "type": "string", "minLength": 1 },
71
- "model": { "type": "string", "minLength": 1, "description": "Task model. dsh requires provider:model, split at the first colon (the model side may contain further colons; a missing colon or an empty side is rejected before any task). codex uses its own model id; zcode uses provider/model (a bare token keeps its backward-compatible zai/<token> reading). agy uses a bare model slug with no ':' or '/' separator or whitespace; admission validates only that shape and the daemon does not verify catalog membership, so an unknown slug fails loudly at session start when the agy CLI rejects it. Omission falls back to the configured default_model for dsh, zcode and agy, then to the provider-native model; codex requires an explicit model or agents.codex.default_model and never falls back to a provider default." },
71
+ "model": { "type": "string", "minLength": 1, "description": "Task model. dsh requires provider:model, split at the first colon (the model side may contain further colons; a missing colon or an empty side is rejected before any task). codex uses its own model id; zcode requires an explicit provider/model token from the spawn request or a named profile, and rejects a bare token, an omitted model, and agents.zcode.default_model at admission. agy uses a bare model slug with no ':' or '/' separator or whitespace; admission validates only that shape and the daemon does not verify catalog membership, so an unknown slug fails loudly at session start when the agy CLI rejects it. Omission falls back to the configured default_model for dsh and agy, then to the provider-native model; codex requires an explicit model or agents.codex.default_model; zcode and codex never fall back to a provider default." },
72
72
  "effort": { "type": "string", "minLength": 1, "maxLength": 24, "pattern": "^[a-z0-9_]+$", "description": "Reasoning-effort token: 1..24 bytes of [a-z0-9_]. codex admits only low, medium, high or xhigh; agy admits only low, medium, high or max; zcode and dsh pass the bounded token through. Omission keeps the runtime default." },
73
73
  "write_manifest": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "codex requires an empty or omitted write_manifest; non-empty input returns codex_write_manifest_unsupported before task creation. agy likewise requires an empty or omitted write_manifest; non-empty input returns agy_write_manifest_unsupported before task creation. dsh admits a non-empty manifest in build through the guarded manifest-build composition (workspace-write sandbox, only tool-fs writable, out-of-manifest paths rejected as FS_WRITE_MANIFEST_DENIED), at most 256 entries; an explicit [\".\"] keeps the existing build composition. plan requires an empty manifest for every subagent." }
74
74
  },
@@ -104,7 +104,7 @@
104
104
  },
105
105
  "wait": {
106
106
  "type": "object",
107
- "description": "Wait up to 290 seconds by default. Returns early only when an actionable pending request exists (permission requests need allow/deny; user-input requests carry the full embedded question and need answer with non-empty content) or the terminal result is available; ordinary progress, message receipts, already-responded and unsupported requests never wake it, and its timeout still returns timed_out=true. When the final result is embedded with complete=true no further result call is needed; a partial page directs you to external_subagent_result with next_offset. A failed terminal result also carries failure_message: usually the daemon's bounded single-line JSON failure detail, but an early fail_claim row may be plain text, so treat it as an opaque bounded string and attempt JSON parsing defensively (null when the task has no persisted detail or the outcome is not a failure). Read failure_message before falling back to daemon logs or sqlite. The pending_requests projection stays capped at 100 records while the wake decision scans the full pending set, and the request that woke the wait is always part of the returned projection so its request_id can be answered directly. activity carries the text tail incrementally: each wait returns only the text appended since the previous wait (an empty tail means nothing new), every byte is delivered by wait at most once, and latest_text_truncated flags the case where undelivered bytes rolled out of the bounded window so this response degraded to the whole window (on the terminal response, bytes repeating the embedded result page verbatim are stripped so they are not shipped twice), a 200-char verified reasoning tail, tool calls started in the last 60 seconds, and telemetry status; activity also exposes last_tool_calls as up to three [tool, arg] string pairs in chronological order across turns (tool up to 64 characters, arg up to 512 UTF-8 bytes), last_activity_age_ms (null before any activity), and model_request_active; additionally in_flight_tool_count counts tracked in-flight tool calls (scheduled or started without a matching result, error or permission resolution; 0 when there is no snapshot or the adapter has no per-call lifecycle tracking, such as codex) and oldest_in_flight_tool_age_ms is the age since the oldest tracked in-flight call first appeared (null when none is tracked); use observe only when these suggest a meaningless loop.",
107
+ "description": "Wait up to 290 seconds by default. Returns early only when an actionable pending request exists (permission requests need allow/deny; user-input requests carry the full embedded question and need answer with non-empty content) or the terminal result is available; ordinary progress, message receipts, already-responded and unsupported requests never wake it, and its timeout still returns timed_out=true. When the final result is embedded with complete=true no further result call is needed; a partial page directs you to external_subagent_result with next_offset. A failed terminal result also carries failure_message: usually the daemon's bounded single-line JSON failure detail, but an early fail_claim row may be plain text, so treat it as an opaque bounded string and attempt JSON parsing defensively (null when the task has no persisted detail or the outcome is not a failure). Read failure_message before falling back to daemon logs or sqlite. The pending_requests projection stays capped at 100 records while the wake decision scans the full pending set, and the request that woke the wait is always part of the returned projection so its request_id can be answered directly. activity carries the text tail incrementally: each wait returns only the text appended since the previous wait (an empty tail means nothing new), every byte is delivered by wait at most once, and latest_text_truncated flags the case where undelivered bytes rolled out of the bounded window so this response degraded to the whole window (on the terminal response, bytes repeating the embedded result page verbatim are stripped so they are not shipped twice), tool calls started in the last 60 seconds, and telemetry status; activity also exposes last_activity_age_ms (null before any activity) and model_request_active; additionally in_flight_tool_count counts tracked in-flight tool calls (scheduled or started without a matching result, error or permission resolution; 0 when there is no snapshot or the adapter has no per-call lifecycle tracking, such as codex) and oldest_in_flight_tool_age_ms is the age since the oldest tracked in-flight call first appeared (null when none is tracked); use external_subagent_observe only when these liveness signals suggest a meaningless loop or a stuck command, to inspect recent tool-call detail.",
108
108
  "required": ["agent_id"],
109
109
  "properties": {
110
110
  "agent_id": { "type": "integer", "minimum": 10000000, "maximum": 99999999 },
@@ -184,7 +184,7 @@
184
184
  "additionalProperties": false,
185
185
  "properties": {
186
186
  "external_subagent_status": { "input": [], "output": ["mcp_version", "components", "capabilities", "subagents"] },
187
- "external_subagent_spawn": { "input": ["profile", "subagent", "repository", "permission_mode", "prompt", "model", "effort", "write_manifest"], "output": ["agent_id", "status", "session_id"], "idempotent": false },
187
+ "external_subagent_spawn": { "input": ["profile", "subagent", "repository", "permission_mode", "prompt", "model", "effort", "write_manifest"], "output": ["agent_id", "status", "session_id", "instruction"], "idempotent": false },
188
188
  "external_subagent_wait": { "input": ["agent_id", "wait_time", "message_id"], "output": ["task", "pending_requests", "result_available", "activity", "result", "instruction", "timed_out", "message_receipt"] },
189
189
  "external_subagent_observe": { "input": ["agent_id"], "output": ["tools", "reasoning", "coverage"], "idempotent": true },
190
190
  "external_subagent_list": { "input": ["subagent", "repository", "phase", "outcome", "cursor", "limit"], "output": ["tasks", "next_cursor"] },
@@ -29,11 +29,19 @@ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '
29
29
  // correct tuple everywhere (release: darwin-arm64, linux-x64) and an
30
30
  // unsupported host fails before touching the tree. The debug variant reuses
31
31
  // the same tuple under its parallel native-debug directory.
32
- const platform = nativePlatform();
32
+ //
33
+ // win32-x64 is staged as a controlled test artifact before NATIVE_PLATFORMS
34
+ // is opened for Windows distribution: the tuple is explicit here, never resolved by
35
+ // nativePlatform(), so `npm pack` can build and inspect the Windows payload
36
+ // on a real Windows host without activating distribution. Windows cargo
37
+ // output carries the `.exe` suffix, and the manifest records the same names.
38
+ const platform = nativePlatform()
39
+ ?? ((process.platform === 'win32' && process.arch === 'x64') ? 'win32-x64' : null);
33
40
  if (platform === null) {
34
41
  process.stderr.write(`no native payload for ${process.platform}-${process.arch}\n`);
35
42
  process.exit(2);
36
43
  }
44
+ const executableSuffix = platform === 'win32-x64' ? '.exe' : '';
37
45
  const variantIndex = process.argv.indexOf('--variant');
38
46
  const variant = variantIndex === -1 ? '' : (process.argv[variantIndex + 1] ?? '');
39
47
  if (variant !== '' && variant !== 'debug') {
@@ -51,9 +59,10 @@ if (cargoProfile !== 'release' && cargoProfile !== 'debug') {
51
59
  }
52
60
  const nativeDirName = isDebug ? 'native-debug' : 'native';
53
61
  const productName = isDebug ? 'external-subagent-debug' : 'external-subagent';
54
- const binaries = isDebug
62
+ const binaries = (isDebug
55
63
  ? [['external-subagentd', 'external-subagent-debugd'], ['external-subagent-mcp', 'external-subagent-debug-mcp']]
56
- : [['external-subagentd', 'external-subagentd'], ['external-subagent-mcp', 'external-subagent-mcp']];
64
+ : [['external-subagentd', 'external-subagentd'], ['external-subagent-mcp', 'external-subagent-mcp']])
65
+ .map(([source, name]) => [source, `${name}${executableSuffix}`]);
57
66
  const platformDir = path.join(packageRoot, 'npm', nativeDirName, platform);
58
67
  const manifestPath = path.join(platformDir, 'payload.json');
59
68
  const sourceRoots = ['crates', 'profiles'].map((dir) => path.join(packageRoot, dir))
@@ -121,7 +130,7 @@ if (build.status !== 0) {
121
130
  fs.mkdirSync(platformDir, { recursive: true });
122
131
  const files = [];
123
132
  for (const [source, name] of binaries) {
124
- const sourcePath = path.join(packageRoot, 'target', cargoProfile, source);
133
+ const sourcePath = path.join(packageRoot, 'target', cargoProfile, `${source}${executableSuffix}`);
125
134
  if (!fs.existsSync(sourcePath)) {
126
135
  process.stderr.write(`${cargoProfile} binary missing: ${sourcePath}\n`);
127
136
  process.exit(1);
@@ -130,7 +139,15 @@ for (const [source, name] of binaries) {
130
139
  fs.copyFileSync(sourcePath, target);
131
140
  fs.chmodSync(target, 0o755);
132
141
  const bytes = fs.readFileSync(target);
133
- files.push({ name, bytes: bytes.length, mode: '755', sha256: sha256(bytes) });
142
+ // The POSIX mode is part of the darwin/linux manifest contract (and the
143
+ // install verifier enforces it there). Windows has no POSIX mode; omit the
144
+ // field instead of recording a meaningless "755".
145
+ files.push({
146
+ name,
147
+ bytes: bytes.length,
148
+ ...(executableSuffix === '' ? { mode: '755' } : {}),
149
+ sha256: sha256(bytes),
150
+ });
134
151
  }
135
152
 
136
153
  const manifest = { schema_version: 1, product: productName, version, platform };
@@ -26,23 +26,32 @@
26
26
  import fs from 'node:fs';
27
27
  import path from 'node:path';
28
28
  import { spawnSync } from 'node:child_process';
29
- import { elfArch, machoArch } from '../../cli/install/payload.mjs';
29
+ import { elfArch, machoArch, peArch } from '../../cli/install/payload.mjs';
30
30
  import { nativePlatform } from '../../cli/install/layout.mjs';
31
31
 
32
32
  const stagedOnly = process.argv.includes('--staged');
33
33
  const explicit = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : null;
34
34
  const dryRun = process.env.npm_config_dry_run === 'true';
35
35
 
36
- // Every platform the single release tarball ships, and the image identity each
36
+ // A Windows host packs a CONTROLLED win32-x64 artifact, not the
37
+ // multi-platform release bundle: only the Windows payload is required and
38
+ // checked. Windows npm publishing is intentionally not activated yet, so
39
+ // macOS/Linux hosts keep the existing dual-platform publish gate; the merged
40
+ // release tarball is assembled there from the platform artifacts.
41
+ const win32Controlled = process.platform === 'win32' && process.arch === 'x64';
42
+ const hostPlatform = nativePlatform() ?? (win32Controlled ? 'win32-x64' : null);
43
+ // Every platform the packed artifact carries, and the image identity each
37
44
  // payload must carry. Keeping both lists here (rather than a fixed
38
- // darwin-arm64 path) is what makes the gate dual-platform.
39
- const RELEASE_PLATFORMS = Object.freeze(['darwin-arm64', 'linux-x64']);
40
- const BINARIES = Object.freeze(['external-subagentd', 'external-subagent-mcp']);
45
+ // darwin-arm64 path) is what makes the gate multi-platform.
46
+ const RELEASE_PLATFORMS = Object.freeze(win32Controlled ? ['win32-x64'] : ['darwin-arm64', 'linux-x64']);
47
+ const binariesFor = (platform) => (platform === 'win32-x64'
48
+ ? Object.freeze(['external-subagentd.exe', 'external-subagent-mcp.exe'])
49
+ : Object.freeze(['external-subagentd', 'external-subagent-mcp']));
41
50
  const IMAGE_CHECKS = Object.freeze({
42
51
  'darwin-arm64': { describe: 'Mach-O arm64 executable', probe: machoArch, arch: 'arm64' },
43
52
  'linux-x64': { describe: 'ELF x86-64 executable', probe: elfArch, arch: 'x64' },
53
+ 'win32-x64': { describe: 'PE32+ x86-64 executable', probe: peArch, arch: 'x64' },
44
54
  });
45
- const hostPlatform = nativePlatform();
46
55
 
47
56
  function findTarball() {
48
57
  if (explicit) return explicit;
@@ -87,15 +96,22 @@ if (!stagedOnly) {
87
96
  // rejected here (and by the --staged publish gate) before it can be
88
97
  // published.
89
98
  for (const platform of RELEASE_PLATFORMS) {
90
- for (const name of [...BINARIES, 'payload.json']) required.push(`package/npm/native/${platform}/${name}`);
99
+ for (const name of [...binariesFor(platform), 'payload.json']) required.push(`package/npm/native/${platform}/${name}`);
91
100
  }
92
101
 
93
- const listing = spawnSync('tar', ['-tzf', tgz], { encoding: 'utf8' });
102
+ // GNU tar (git-bash on Windows) reads a drive-letter path such as
103
+ // `C:\...` as a remote host. A cwd-relative path is unambiguous on every
104
+ // platform and names the same file the pack just produced.
105
+ const tarArgument = path.relative(process.cwd(), path.resolve(tgz)) || tgz;
106
+ const listing = spawnSync('tar', ['-tzf', tarArgument], { encoding: 'utf8' });
94
107
  if (listing.status !== 0) {
95
108
  process.stderr.write(`cannot list tarball: ${listing.stderr}\n`);
96
109
  process.exit(1);
97
110
  }
98
- const entries = listing.stdout.split('\n').filter(Boolean);
111
+ // Windows' bundled System32 tar (libarchive bsdtar) lists entries with CRLF
112
+ // line endings while GNU tar writes LF; accept both so entry matching does
113
+ // not depend on which tar the host resolves.
114
+ const entries = listing.stdout.split(/\r?\n/).filter(Boolean);
99
115
 
100
116
  for (const entry of required) {
101
117
  if (!entries.includes(entry)) { process.stderr.write(`missing release entry: ${entry}\n`); failed = true; }
@@ -108,9 +124,9 @@ if (!stagedOnly) {
108
124
  if (forbidden.test(entry)) { process.stderr.write(`forbidden release material: ${entry}\n`); failed = true; }
109
125
  }
110
126
 
111
- const packedPackage = JSON.parse(spawnSync('tar', ['-xzf', tgz, '-O', 'package/package.json'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).stdout);
127
+ const packedPackage = JSON.parse(spawnSync('tar', ['-xzf', tarArgument, '-O', 'package/package.json'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).stdout);
112
128
  for (const platform of RELEASE_PLATFORMS) {
113
- const manifestText = spawnSync('tar', ['-xzf', tgz, '-O', `package/npm/native/${platform}/payload.json`], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).stdout;
129
+ const manifestText = spawnSync('tar', ['-xzf', tarArgument, '-O', `package/npm/native/${platform}/payload.json`], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).stdout;
114
130
  let manifest;
115
131
  try {
116
132
  manifest = JSON.parse(manifestText);
@@ -162,7 +178,7 @@ function checkStagedPlatform(platform) {
162
178
  failed = true;
163
179
  }
164
180
  const image = IMAGE_CHECKS[platform];
165
- for (const name of BINARIES) {
181
+ for (const name of binariesFor(platform)) {
166
182
  const local = path.join(dir, name);
167
183
  let bytes;
168
184
  try {
@@ -176,8 +192,12 @@ function checkStagedPlatform(platform) {
176
192
  process.stderr.write(`${platform}/${name} is not a ${image.describe}\n`);
177
193
  failed = true;
178
194
  }
179
- const mode = (fs.statSync(local).mode & 0o777).toString(8);
180
- if (mode !== '755') { process.stderr.write(`${platform}/${name} has release mode ${mode}, expected 755\n`); failed = true; }
195
+ // Windows has no POSIX execute bit; the PE image check above is the
196
+ // executable identity there, exactly as verifyPayload reasons.
197
+ if (platform !== 'win32-x64') {
198
+ const mode = (fs.statSync(local).mode & 0o777).toString(8);
199
+ if (mode !== '755') { process.stderr.write(`${platform}/${name} has release mode ${mode}, expected 755\n`); failed = true; }
200
+ }
181
201
  }
182
202
  }
183
203
 
@@ -3,12 +3,13 @@
3
3
  // from the release source at plugins/codex/external-subagent: a byte-level copy
4
4
  // with the plugin identity renamed (plugin.json name/displayName, the skill
5
5
  // directory and its frontmatter name). The .mcp.json placeholder and the
6
- // hooks/scripts/lib payloads stay identical — the host bindings rewrite the
6
+ // scripts payload stay identical — the host bindings rewrite the
7
7
  // placeholder at staging time, and tool names are the daemon's, not the
8
8
  // plugin's. The generated tree is a build artifact (gitignored) owned by
9
9
  // build-native-payload.mjs --variant debug.
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
12
13
 
13
14
  const DEBUG_PLUGIN_NAME = 'external-subagent-debug';
14
15
  const SOURCE_PLUGIN_NAME = 'external-subagent';
@@ -43,13 +44,18 @@ export function stageDebugPlugin(packageRoot) {
43
44
  fs.renameSync(path.join(target, 'skills', SOURCE_PLUGIN_NAME), path.join(target, 'skills', DEBUG_PLUGIN_NAME));
44
45
  const skillPath = path.join(target, 'skills', DEBUG_PLUGIN_NAME, 'SKILL.md');
45
46
  const skill = fs.readFileSync(skillPath, 'utf8');
46
- const rewritten = skill.replace(`name: ${SOURCE_PLUGIN_NAME}\n`, `name: ${DEBUG_PLUGIN_NAME}\n`, 1);
47
+ // Preserve the source file's line endings: a Windows checkout has CRLF and
48
+ // the debug rename must not normalize the whole document.
49
+ const rewritten = skill.replace(
50
+ new RegExp(`name: ${SOURCE_PLUGIN_NAME}(\\r?\\n)`, 'u'),
51
+ (_, newline) => `name: ${DEBUG_PLUGIN_NAME}${newline}`,
52
+ );
47
53
  fs.writeFileSync(skillPath, rewritten);
48
54
  return target;
49
55
  }
50
56
 
51
57
  if (import.meta.url === `file://${process.argv[1]}`) {
52
- const packageRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', '..');
58
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
53
59
  try {
54
60
  const target = stageDebugPlugin(packageRoot);
55
61
  process.stdout.write(`debug plugin source staged: ${target}\n`);
@@ -39,7 +39,7 @@ try {
39
39
  run('npm', ['install', '--global', `--prefix=${prefix}`, '--no-audit', '--no-fund', tgz], { cwd: work, env });
40
40
  const cli = path.join(prefix, 'bin', 'external-subagent');
41
41
  const packageRoot = path.join(prefix, 'lib', 'node_modules', 'external-subagent');
42
- // The packed payload directory is the running host's tuple (S01), so the
42
+ // The packed payload directory is the running host's tuple, so the
43
43
  // smoke checks the platform it is actually installing rather than a fixed
44
44
  // darwin-arm64 path.
45
45
  const payloadPlatform = nativePlatform() ?? `${process.platform}-${process.arch}`;
@@ -1,55 +0,0 @@
1
- #!/usr/bin/env node
2
- import crypto from 'node:crypto';
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
-
6
- let raw = '';
7
- process.stdin.setEncoding('utf8');
8
- for await (const chunk of process.stdin) raw += chunk;
9
-
10
- function boundedText(value, maxBytes = 1_048_576) {
11
- if (typeof value !== 'string') return '';
12
- const bytes = Buffer.from(value, 'utf8');
13
- return bytes.length <= maxBytes ? value : bytes.subarray(0, maxBytes).toString('utf8');
14
- }
15
-
16
- try {
17
- const input = JSON.parse(raw);
18
- const tool = input?.tool_name ?? input?.toolName ?? '';
19
- if (tool !== 'Bash') process.exit(0);
20
-
21
- const toolInput = input?.tool_input ?? input?.input ?? {};
22
- const command = String(toolInput?.command ?? '');
23
- const cwd = String(input?.cwd ?? '');
24
- const response = input?.tool_response ?? input?.toolResponse ?? {};
25
- const stdout = boundedText(response?.stdout ?? response?.output ?? '');
26
- const stderr = boundedText(response?.stderr ?? input?.error ?? '');
27
- const record = {
28
- schema: 'zcode-agent-bash-fact/v2',
29
- at: new Date().toISOString(),
30
- session_id: input?.session_id ?? input?.sessionId ?? null,
31
- tool_use_id: input?.tool_use_id ?? input?.toolUseId ?? null,
32
- hook_event_name: input?.hook_event_name ?? input?.hookEventName ?? null,
33
- cwd_sha256: crypto.createHash('sha256').update(cwd).digest('hex'),
34
- command_sha256: crypto.createHash('sha256').update(command).digest('hex'),
35
- status_code: Number.isInteger(response?.status_code) ? response.status_code : (Number.isInteger(response?.exitCode) ? response.exitCode : null),
36
- duration_ms: Number.isFinite(input?.duration_ms) ? Math.max(0, Math.floor(input.duration_ms))
37
- : (Number.isFinite(response?.duration_ms) ? Math.max(0, Math.floor(response.duration_ms)) : null),
38
- stdout_sha256: crypto.createHash('sha256').update(stdout).digest('hex'),
39
- stderr_sha256: crypto.createHash('sha256').update(stderr).digest('hex'),
40
- stdout_bytes_observed: Buffer.byteLength(stdout),
41
- stderr_bytes_observed: Buffer.byteLength(stderr),
42
- failed: Boolean(input?.error) || input?.hook_event_name === 'PostToolUseFailure' || input?.hookEventName === 'PostToolUseFailure',
43
- permission_mode: ['build', 'edit', 'plan', 'yolo'].includes(process.env.ZCODE_PERMISSION_MODE || process.env.ZCODE_AGENT_PERMISSION_MODE)
44
- ? (process.env.ZCODE_PERMISSION_MODE || process.env.ZCODE_AGENT_PERMISSION_MODE)
45
- : null,
46
- };
47
-
48
- const dataRoot = process.env.ZCODE_PLUGIN_DATA;
49
- if (!dataRoot) process.exit(0);
50
- fs.mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
51
- const logPath = path.join(dataRoot, 'readonly-bash-audit.jsonl');
52
- fs.appendFileSync(logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'a' });
53
- } catch (error) {
54
- process.stderr.write(`[zcode-agent-bash-audit] ${error instanceof Error ? error.message : String(error)}\n`);
55
- }