external-subagent 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/bin/external-subagent-debug-mcp.mjs +5 -0
  4. package/bin/external-subagent-debug.mjs +13 -0
  5. package/bin/external-subagent-mcp.mjs +20 -0
  6. package/bin/external-subagent.mjs +8 -0
  7. package/bin/zas.mjs +8 -0
  8. package/cli/commands/agents.mjs +117 -0
  9. package/cli/commands/config.mjs +117 -0
  10. package/cli/commands/daemon.mjs +36 -0
  11. package/cli/commands/maintenance.mjs +27 -0
  12. package/cli/commands/plugin.mjs +96 -0
  13. package/cli/commands/tasks.mjs +64 -0
  14. package/cli/commands/update.mjs +219 -0
  15. package/cli/config/read.mjs +24 -0
  16. package/cli/config/schema.mjs +75 -0
  17. package/cli/config/write.mjs +39 -0
  18. package/cli/constants.mjs +24 -0
  19. package/cli/errors.mjs +7 -0
  20. package/cli/fs-atomic.mjs +36 -0
  21. package/cli/install/codex.mjs +352 -0
  22. package/cli/install/init.mjs +206 -0
  23. package/cli/install/layout.mjs +54 -0
  24. package/cli/install/npm-hook.mjs +49 -0
  25. package/cli/install/path.mjs +43 -0
  26. package/cli/install/payload.mjs +84 -0
  27. package/cli/install/plugin-stage.mjs +232 -0
  28. package/cli/install/reconcile.mjs +209 -0
  29. package/cli/install/recovery.mjs +79 -0
  30. package/cli/install/service-activation.mjs +133 -0
  31. package/cli/install/service-macos.mjs +146 -0
  32. package/cli/install/update.mjs +170 -0
  33. package/cli/install/zcode.mjs +239 -0
  34. package/cli/main.mjs +429 -0
  35. package/cli/maintenance.mjs +104 -0
  36. package/cli/paths.mjs +40 -0
  37. package/cli/rpc.mjs +261 -0
  38. package/launchd/com.external-subagent.daemon.plist.template +37 -0
  39. package/npm/native/README.md +19 -0
  40. package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
  41. package/npm/native/darwin-arm64/external-subagentd +0 -0
  42. package/npm/native/darwin-arm64/payload.json +20 -0
  43. package/package.json +60 -0
  44. package/plugins/codex/external-subagent/.codex-plugin/plugin.json +17 -0
  45. package/plugins/codex/external-subagent/.mcp.json +9 -0
  46. package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +55 -0
  47. package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +19 -0
  48. package/plugins/codex/external-subagent/hooks/guard-bash.mjs +15 -0
  49. package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +206 -0
  50. package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +149 -0
  51. package/plugins/codex/external-subagent/scripts/mcp-stdio-bridge.mjs +36 -0
  52. package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +44 -0
  53. package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +55 -0
  54. package/schema/external-subagent-public-api.json +185 -0
  55. package/schema/zas-observation-v1.1.schema.json +58 -0
  56. package/schema/zas-public-reasoning-source.json +14 -0
  57. package/scripts/release/build-native-payload.mjs +119 -0
  58. package/scripts/release/check-native-tarball.mjs +125 -0
  59. package/scripts/release/stage-debug-plugin.mjs +60 -0
  60. package/scripts/release/test-installed-tarball.mjs +73 -0
@@ -0,0 +1,239 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { CliError } from '../errors.mjs';
5
+ import { atomicWrite, jsonBytes, readOptional, restoreOptional } from '../fs-atomic.mjs';
6
+ import { pluginSourceRoot, PLUGIN_NAME } from './layout.mjs';
7
+ import { pluginManifest, treeDigest, preparePluginStage, guardedRestores } from './plugin-stage.mjs';
8
+
9
+ // Managed ZCode host binding. ZCode has no headless CLI for plugin
10
+ // management (the GUI drives internal IPC only), but its user config
11
+ // exposes an inline plugin discovery source: every path in
12
+ // `plugins.dirs` under ~/.zcode/cli/config.json is loaded as a plugin
13
+ // root with id <name>@inline and default-enabled status (verified
14
+ // against ZCode 25.6.0, see docs/compatibility/zcode.md). This module
15
+ // stages the plugin into a product-owned tree and registers exactly that
16
+ // one directory entry; it never touches marketplace, cache, or install
17
+ // record state that belongs to the ZCode client. There is no registry
18
+ // file: the binding is fully derivable from the config (ZCode has a
19
+ // single user config, no multi-home installs), so reconcile and uninstall
20
+ // detect rather than remember it.
21
+
22
+ const ZCODE_PLUGIN_ID = `${PLUGIN_NAME}@inline`;
23
+ const MANIFEST_PROBES = ['.zcode-plugin/plugin.json', '.claude-plugin/plugin.json', '.codex-plugin/plugin.json'];
24
+
25
+ // ZCode spawns plugin MCP servers inside a restricted execution context
26
+ // that kills ad-hoc-signed native binaries (observed live: SIGKILL,
27
+ // "Connection closed"), while node processes and unix sockets are
28
+ // allowed. The zcode binding therefore pins the installing node running
29
+ // the staged stdio bridge instead of the native facade binary. It also
30
+ // pins `timeoutMs` on the server entry: the zcode host's MCP client caps
31
+ // every tool call at a default 30000ms unless the server config overrides
32
+ // it, which would cut `external_subagent_wait` (default 290s, ceiling
33
+ // 299s) at 30s; 300000ms covers that ceiling with margin.
34
+ const ZCODE_MCP_TIMEOUT_MS = 300000;
35
+
36
+ export function zcodeMcpBinding(staging) {
37
+ return { command: process.execPath, args: [path.join(staging, 'scripts', 'mcp-stdio-bridge.mjs')], timeoutMs: ZCODE_MCP_TIMEOUT_MS };
38
+ }
39
+
40
+ export function zcodeConfigFor(options = {}, paths) {
41
+ if (typeof options.zcodeConfig === 'string' && options.zcodeConfig.length > 0) return options.zcodeConfig;
42
+ return (paths && paths.zcodeConfig) || path.join((paths && paths.home) || os.homedir(), '.zcode', 'cli', 'config.json');
43
+ }
44
+
45
+ // Same-path comparison the way the host resolves plugin dirs: absolute,
46
+ // realpath'd when it exists, resolved when it does not.
47
+ function normalizeDir(target) {
48
+ const absolute = path.resolve(target);
49
+ try { return fs.realpathSync(absolute); } catch { return absolute; }
50
+ }
51
+
52
+ function sameDir(entry, dir) {
53
+ return typeof entry === 'string' && normalizeDir(entry) === normalizeDir(dir);
54
+ }
55
+
56
+ function pluginNameAt(dir) {
57
+ for (const probe of MANIFEST_PROBES) {
58
+ try {
59
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, probe), 'utf8'));
60
+ return typeof manifest?.name === 'string' ? manifest.name : null;
61
+ } catch { /* probe the next manifest location */ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function readZcodeConfig(file) {
67
+ const bytes = readOptional(file);
68
+ if (bytes === null) return { doc: {}, bytes: null };
69
+ let doc;
70
+ try { doc = JSON.parse(bytes.toString('utf8')); } catch (error) {
71
+ throw new CliError('ZCODE_CONFIG_INVALID', `ZCode config is not readable JSON and will not be overwritten (${file}: ${error.message})`);
72
+ }
73
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
74
+ throw new CliError('ZCODE_CONFIG_INVALID', `ZCode config must be a JSON object (${file})`);
75
+ }
76
+ return { doc, bytes };
77
+ }
78
+
79
+ // Extract the plugins.dirs array the way the host validates it (zod:
80
+ // array of non-empty strings). Anything else is config this module does
81
+ // not understand; fail closed instead of rewriting it.
82
+ function pluginDirs(doc, file) {
83
+ const plugins = doc.plugins;
84
+ if (plugins === undefined) return [];
85
+ if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) {
86
+ throw new CliError('ZCODE_CONFIG_INVALID', `plugins must be a JSON object in ${file}`);
87
+ }
88
+ const dirs = plugins.dirs;
89
+ if (dirs === undefined) return [];
90
+ if (!Array.isArray(dirs) || dirs.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
91
+ throw new CliError('ZCODE_CONFIG_INVALID', `plugins.dirs must be an array of non-empty directory paths in ${file}`);
92
+ }
93
+ return dirs;
94
+ }
95
+
96
+ // Read back what this binding actually wrote: the staging entry in the
97
+ // config plus the interpreter command, bridge script, and daemon socket
98
+ // the staging pinned.
99
+ function verifyZcodeBinding({ config, staging, dir }) {
100
+ const doc = JSON.parse(fs.readFileSync(config, 'utf8'));
101
+ if (!pluginDirs(doc, config).some((entry) => sameDir(entry, dir))) {
102
+ throw new CliError('ZCODE_BINDING_UNVERIFIABLE', `the staged plugin directory is absent from plugins.dirs after install (${config})`);
103
+ }
104
+ const server = JSON.parse(fs.readFileSync(path.join(staging, '.mcp.json'), 'utf8')).mcpServers?.external_subagent;
105
+ const binding = zcodeMcpBinding(staging);
106
+ if (!server || server.command !== binding.command || JSON.stringify(server.args || []) !== JSON.stringify(binding.args)
107
+ || server.timeoutMs !== binding.timeoutMs
108
+ || server.env?.ZCODE_AGENTD_SOCKET === undefined) {
109
+ throw new CliError('ZCODE_BINDING_UNVERIFIABLE', `the staged plugin MCP binding is not the managed endpoint (${staging})`);
110
+ }
111
+ return server;
112
+ }
113
+
114
+ export function installZcodePlugin(paths, options = {}) {
115
+ const source = options.source || pluginSourceRoot();
116
+ const staging = options.stagingPath || paths.zcodePlugin;
117
+ const config = options.configPath || zcodeConfigFor(options, paths);
118
+ if (options.dryRun) {
119
+ return { dry_run: true, operation: options.uninstall ? 'uninstall' : 'install', host: 'zcode', source, staging, config, plugin_id: ZCODE_PLUGIN_ID, cli: 'zcode-config' };
120
+ }
121
+ if (options.uninstall) return uninstallZcodePlugin(paths, options);
122
+ pluginManifest(source);
123
+ const prior = readZcodeConfig(config);
124
+ const dirs = pluginDirs(prior.doc, config);
125
+ // Only one external-subagent may be bound through plugins.dirs: a
126
+ // foreign directory exposing the same plugin name would shadow or be
127
+ // shadowed by ours under the shared inline id.
128
+ for (const entry of dirs) {
129
+ if (!sameDir(entry, staging) && pluginNameAt(entry) === PLUGIN_NAME) {
130
+ throw new CliError('ZCODE_PLUGIN_CONFLICT', `another ${PLUGIN_NAME} plugin directory is already registered in plugins.dirs (${entry})`);
131
+ }
132
+ }
133
+ // AUD-002: stage by validated replacement. publish() is self-contained,
134
+ // so unlike the overlay staging it used to be, a failed copy/parse/swap
135
+ // never damages the live managed tree; everything that can fail after the
136
+ // swap stays inside the config transaction below, whose rollback restores
137
+ // both the prior config bytes and the prior coherent tree.
138
+ const staged = preparePluginStage(source, staging, paths, zcodeMcpBinding(staging));
139
+ staged.publish();
140
+ try {
141
+ const next = structuredClone(prior.doc);
142
+ next.plugins ??= {};
143
+ if (!(next.plugins.dirs || []).some((entry) => sameDir(entry, staging))) next.plugins.dirs = [...(next.plugins.dirs || []), path.resolve(staging)];
144
+ atomicWrite(config, jsonBytes(next));
145
+ const server = verifyZcodeBinding({ config, staging, dir: staging });
146
+ const result = {
147
+ installed: true, host: 'zcode', source, staging, config,
148
+ plugin_id: ZCODE_PLUGIN_ID,
149
+ digest: treeDigest(staging),
150
+ config_verified: true,
151
+ binding: { command: server.command, socket: server.env.ZCODE_AGENTD_SOCKET },
152
+ };
153
+ if (prior.doc.plugins?.enabled === false) {
154
+ result.warning = 'plugins.enabled is false in the ZCode config; the binding will not load until plugins are enabled';
155
+ }
156
+ staged.complete();
157
+ return result;
158
+ } catch (error) {
159
+ // AUD-002: the config restore and the staging restore are INDEPENDENT
160
+ // guarded attempts. A config parent that stays read-only fails the
161
+ // install write and then the config restore too, but that must neither
162
+ // skip the staging restore nor mask this original error; whatever could
163
+ // not be restored is attached to the thrown error instead of only being
164
+ // logged.
165
+ throw guardedRestores(error, [
166
+ { resource: config, restore: () => restoreOptional(config, prior.bytes) },
167
+ { resource: staging, restore: () => {
168
+ if (!staged.restore()) {
169
+ throw new CliError('PLUGIN_STAGING_RESTORE_FAILED', `the prior staging tree could not be restored at ${staging}; the retained prior copy is left beside it as recovery material`);
170
+ }
171
+ } },
172
+ ]);
173
+ }
174
+ }
175
+
176
+ export function uninstallZcodePlugin(paths, options = {}) {
177
+ const source = options.source || pluginSourceRoot();
178
+ const staging = options.stagingPath || paths.zcodePlugin;
179
+ const config = options.configPath || zcodeConfigFor(options, paths);
180
+ if (options.dryRun) {
181
+ return { dry_run: true, operation: 'uninstall', host: 'zcode', source, staging, config, plugin_id: ZCODE_PLUGIN_ID, cli: 'zcode-config' };
182
+ }
183
+ const prior = readZcodeConfig(config);
184
+ const dirs = pluginDirs(prior.doc, config);
185
+ const kept = dirs.filter((entry) => !sameDir(entry, staging));
186
+ const bound = kept.length !== dirs.length;
187
+ if (!bound && !fs.existsSync(staging)) {
188
+ return { uninstalled: false, host: 'zcode', config, staging, note: 'no zcode binding present' };
189
+ }
190
+ if (bound) {
191
+ const next = structuredClone(prior.doc);
192
+ if (kept.length === 0) delete next.plugins.dirs; else next.plugins.dirs = kept;
193
+ if (Object.keys(next.plugins).length === 0) delete next.plugins;
194
+ atomicWrite(config, jsonBytes(next));
195
+ const doc = JSON.parse(fs.readFileSync(config, 'utf8'));
196
+ if (pluginDirs(doc, config).some((entry) => sameDir(entry, staging))) {
197
+ restoreOptional(config, prior.bytes);
198
+ throw new CliError('ZCODE_BINDING_UNVERIFIABLE', `the staged plugin directory survived removal from plugins.dirs (${config})`);
199
+ }
200
+ }
201
+ // The staged tree is only deleted when it is unmistakably ours; a
202
+ // foreign tree at the staging path is left in place and reported.
203
+ let stagingRemoved = false;
204
+ if (fs.existsSync(staging)) {
205
+ if (pluginNameAt(staging) === PLUGIN_NAME) {
206
+ fs.rmSync(staging, { recursive: true, force: true });
207
+ stagingRemoved = true;
208
+ }
209
+ }
210
+ const result = { uninstalled: true, host: 'zcode', config, staging, staging_removed: stagingRemoved };
211
+ if (fs.existsSync(staging) && !stagingRemoved) result.note = 'staging path is not managed by this product; directory left in place';
212
+ return result;
213
+ }
214
+
215
+ // Stateless reconcile for update flows: the binding exists iff the config
216
+ // still points at the staged directory, so detection replaces a registry.
217
+ export function reconcileZcodeBinding(paths, options = {}) {
218
+ // Callers without product-shaped paths (test fixtures, partial states)
219
+ // must not leak into the real user config through the home fallback.
220
+ if (!paths || (!paths.zcodeConfig && !paths.home)) {
221
+ return { bound: false, status: 'absent', note: 'zcode binding paths are unavailable' };
222
+ }
223
+ const config = options.configPath || zcodeConfigFor(options, paths);
224
+ const staging = options.stagingPath || paths.zcodePlugin;
225
+ let bound;
226
+ try {
227
+ const { doc } = readZcodeConfig(config);
228
+ bound = pluginDirs(doc, config).some((entry) => sameDir(entry, staging));
229
+ } catch (error) {
230
+ return { bound: false, status: 'failed', error: { code: error.code || 'ZCODE_CONFIG_INVALID', message: error.message } };
231
+ }
232
+ if (!bound) return { bound: false, status: 'absent' };
233
+ try {
234
+ const install = installZcodePlugin(paths, options);
235
+ return { bound: true, status: 'updated', digest: install.digest };
236
+ } catch (error) {
237
+ return { bound: true, status: 'failed', error: { code: error.code || 'ZCODE_BINDING_FAILED', message: error.message } };
238
+ }
239
+ }
package/cli/main.mjs ADDED
@@ -0,0 +1,429 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { BUSINESS_COMMANDS, PRODUCT_NAME, VERSION, ZCODE_RUNTIME } from './constants.mjs';
5
+ import { CliError } from './errors.mjs';
6
+ import { installHooks, installPlan, runInit } from './install/init.mjs';
7
+ import { nativeBinary } from './install/layout.mjs';
8
+ import { cleanupLegacy, purge, restoreData, backupData, uninstall } from './commands/maintenance.mjs';
9
+ import { updateCommand } from './commands/update.mjs';
10
+ import { localInstallStatus, startDaemon, stopDaemon } from './commands/daemon.mjs';
11
+ import { serviceRegistrationStatus } from './install/service-macos.mjs';
12
+ import { mcpCommand, pluginCommand } from './commands/plugin.mjs';
13
+ import { platform, productPaths } from './paths.mjs';
14
+ import { callDaemon, parseDaemonInput } from './rpc.mjs';
15
+ import { configCommand } from './commands/config.mjs';
16
+ import { parseConfigArgs } from './commands/config.mjs';
17
+ import { subagentsCommand, parseSubagentsArgs } from './commands/agents.mjs';
18
+ import { parseSpawnArgs, prepareSpawnInput } from './commands/tasks.mjs';
19
+
20
+ const HELP = `external-subagent ${VERSION}\n\nUsage: external-subagent <command> [options]\n\nCommands:\n help, version Show basic product information\n init [--dry-run] [--resume] [--install-hooks] [--skip-service-start]\n Install the standalone daemon service only; bind a\n host afterwards with install-plugin or install-mcp\n hooks install [--dry-run] Install ZCode policy hooks explicitly\n install-plugin [codex|zcode] [--dry-run|--uninstall] [--codex-home <path>]\n Install or remove the managed host plugin (MCP + skill);\n the host defaults to codex, --codex-home is codex-only\n install-mcp [--dry-run|--uninstall] [--codex-home <path>]\n Install or remove the direct Codex MCP TOML binding\n status, diagnose Inspect local service and runtime state\n start, stop Bootstrap or boot out the daemon LaunchAgent\n backup --output <dir> Back up retained product data\n restore --input <dir> Verify and restore product data\n uninstall Release Codex/ZCode bindings; remove service registration; retain data\n purge --yes Explicitly delete new product data\n cleanup-legacy --yes Delete old unpublished installation (no migration)\n`;
21
+ const DAEMON_HELP = ` config get [key] | config set <key> <value>\n subagents list | subagents status [subagent] | subagents probe/models [subagent]\n create/spawn, wait, list, send, respond, cancel, result, close, observe\n Daemon calls accept --json '<object>' or JSON stdin\n list JSON requires repository (workspace is an alias)\n observe JSON requires only agent_id\n`;
22
+
23
+ function structuredInput(args, parser) {
24
+ return args.length > 0 && !args[0].startsWith('--') ? parser(args) : parseDaemonInput(args);
25
+ }
26
+
27
+ function value(args, name) {
28
+ const index = args.indexOf(name);
29
+ if (index < 0) return undefined;
30
+ const result = args[index + 1];
31
+ if (!result || result.startsWith('--')) throw new CliError('INVALID_ARGUMENT', `${name} requires a value`, 2);
32
+ return result;
33
+ }
34
+
35
+ function output(valueToWrite) {
36
+ process.stdout.write(`${JSON.stringify({ ok: true, product: PRODUCT_NAME, ...valueToWrite }, null, 2)}\n`);
37
+ }
38
+
39
+ function summarizeAgentStatus(agent) {
40
+ if (!agent || typeof agent !== 'object') return agent;
41
+ const scopes = {};
42
+ for (const scope of ['local', 'auth', 'hi']) {
43
+ if (agent[scope] && typeof agent[scope] === 'object') {
44
+ scopes[scope] = {
45
+ state: agent[scope].state ?? 'UNKNOWN',
46
+ checked_at_ms: agent[scope].checked_at_ms ?? null,
47
+ };
48
+ }
49
+ }
50
+ return {
51
+ subagent: agent.subagent ?? agent.agent,
52
+ configured: agent.configured,
53
+ enabled: agent.enabled,
54
+ spawn_supported: agent.spawn_supported,
55
+ ...scopes,
56
+ };
57
+ }
58
+
59
+ function publicDaemonStatus(status, { verbose = false } = {}) {
60
+ if (!status || typeof status !== 'object') return status;
61
+ if (verbose) return status;
62
+ return {
63
+ ...(status.mcp_version === undefined ? {} : { mcp_version: status.mcp_version }),
64
+ ...(status.components === undefined ? {} : { components: status.components }),
65
+ ...(Array.isArray(status.subagents ?? status.agents) ? { subagents: (status.subagents ?? status.agents).map(summarizeAgentStatus) } : {}),
66
+ };
67
+ }
68
+
69
+ const DIAGNOSTIC_TAIL_BYTES = 16 * 1024;
70
+ const DIAGNOSTIC_TOTAL_BYTES = 32 * 1024;
71
+ const DIAGNOSTIC_LOG_NAMES = ['daemon.log', 'daemon-error.log'];
72
+
73
+ function fileArtifact(target, source, capturedAtMs = Date.now()) {
74
+ const artifact = { path: target, source, captured_at_ms: capturedAtMs };
75
+ try {
76
+ const stat = fs.lstatSync(target);
77
+ if (!stat.isFile() || stat.isSymbolicLink()) return artifact;
78
+ artifact.sha256 = crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex');
79
+ } catch {}
80
+ return artifact;
81
+ }
82
+
83
+ function preserveDiagnosticText(text) { return text; }
84
+
85
+ function diagnosticFields(record) {
86
+ return JSON.stringify(Object.fromEntries(
87
+ ['agent_id', 'session_id', 'stage', 'error_code', 'message', 'stderr_tail', 'operation', 'remote_code', 'remote_message', 'cleanup_result']
88
+ .filter((field) => typeof record[field] === 'string' || record[field] === null || (field === 'remote_code' && Number.isSafeInteger(record[field])))
89
+ .map((field) => [field, record[field]]),
90
+ ));
91
+ }
92
+
93
+ // Budget the serialized fields, so escaping and UTF-8 cannot invalidate JSON.
94
+ // Metadata has bounded prefixes; stderr receives the remaining budget as a tail.
95
+ function boundedFailureRecord(record) {
96
+ const projected = diagnosticFields(record);
97
+ if (Buffer.byteLength(projected) <= DIAGNOSTIC_TAIL_BYTES) return { text: projected, truncated: false };
98
+ const fields = JSON.parse(projected);
99
+ for (const key of Object.keys(fields)) {
100
+ if (key !== 'stderr_tail' && typeof fields[key] === 'string') {
101
+ fields[key] = Array.from(fields[key]).slice(0, key === 'message' ? 512 : 256).join('');
102
+ }
103
+ }
104
+ const tail = Array.from(fields.stderr_tail || '');
105
+ let low = 0;
106
+ let high = tail.length;
107
+ while (low < high) {
108
+ const keep = Math.ceil((low + high) / 2);
109
+ fields.stderr_tail = tail.slice(tail.length - keep).join('');
110
+ if (Buffer.byteLength(JSON.stringify(fields)) <= DIAGNOSTIC_TAIL_BYTES) low = keep;
111
+ else high = keep - 1;
112
+ }
113
+ fields.stderr_tail = tail.slice(tail.length - low).join('');
114
+ return { text: JSON.stringify(fields), truncated: true };
115
+ }
116
+
117
+ function diagnosticTail(text) {
118
+ let incomplete = false;
119
+ const decoded = text.split('\n').map((line) => {
120
+ const match = line.match(/^(\[zcode-agentd\] failure agent=[^:\r\n]+: )(.+)$/u);
121
+ if (match && match[2].startsWith('{')) {
122
+ try {
123
+ const record = JSON.parse(match[2]);
124
+ if (record && typeof record.agent_id === 'string') return preserveDiagnosticText(match[1]) + diagnosticFields(record);
125
+ } catch {
126
+ incomplete = true;
127
+ return match[1] + '[INCOMPLETE_FAILURE_RECORD]';
128
+ }
129
+ }
130
+ return line;
131
+ }).join('\n');
132
+ return { text: preserveDiagnosticText(decoded), incomplete };
133
+ }
134
+
135
+ function diagnosticLogs(logDirectory) {
136
+ const incomplete = [];
137
+ if (!fs.existsSync(logDirectory)) return { directory: logDirectory, complete: false, incomplete: ['log_directory_missing'], files: [] };
138
+ const files = [];
139
+ let totalBytes = 0;
140
+ for (const name of DIAGNOSTIC_LOG_NAMES) {
141
+ const target = path.join(logDirectory, name);
142
+ let targetStat;
143
+ try { targetStat = fs.lstatSync(target); } catch (error) { targetStat = null; }
144
+ const rotated = ['.1', '.2', '.old', '.gz'].some((suffix) => {
145
+ try { return fs.lstatSync(`${target}${suffix}`) != null; } catch { return false; }
146
+ });
147
+ if (rotated) incomplete.push(`log_rotated:${name}`);
148
+ if (!fs.existsSync(target)) continue;
149
+ if (!targetStat || !targetStat.isFile() || targetStat.isSymbolicLink()) {
150
+ incomplete.push(`log_unreadable:${name}:symlink_or_non_file`);
151
+ continue;
152
+ }
153
+ try {
154
+ const stat = fs.statSync(target);
155
+ const remaining = Math.max(0, DIAGNOSTIC_TOTAL_BYTES - totalBytes);
156
+ const take = Math.min(DIAGNOSTIC_TAIL_BYTES, remaining);
157
+ const start = Math.max(0, stat.size - take);
158
+ // A complete producer record can exceed the display window. Keep a
159
+ // bounded record-sized lookbehind so its prefix survives until decoding.
160
+ const readStart = Math.max(0, start - DIAGNOSTIC_RECORD_BYTES);
161
+ const readLength = Math.min(take + (start - readStart), stat.size - readStart);
162
+ const fd = fs.openSync(target, 'r');
163
+ const buffer = Buffer.alloc(readLength);
164
+ const read = fs.readSync(fd, buffer, 0, readLength, readStart);
165
+ fs.closeSync(fd);
166
+ let decodeStart = Math.max(0, start - 256) - readStart;
167
+ if (decodeStart > 0) {
168
+ const lineStart = buffer.lastIndexOf(0x0a, decodeStart - 1) + 1;
169
+ if (buffer.subarray(lineStart, read).toString('utf8').startsWith('[zcode-agentd] failure agent=')) decodeStart = lineStart;
170
+ }
171
+ // Legacy text keeps its original lookbehind; known records are decoded
172
+ // (or marked incomplete) before any display clipping can hide the prefix.
173
+ const projected = diagnosticTail(buffer.subarray(decodeStart, read).toString('utf8'));
174
+ if (projected.incomplete) incomplete.push(`record_incomplete:${name}`);
175
+ const encoded = Buffer.from(projected.text, 'utf8');
176
+ let tailStart = Math.max(0, encoded.length - Math.min(take, remaining));
177
+ while (tailStart < encoded.length && (encoded[tailStart] & 0xc0) === 0x80) tailStart += 1;
178
+ const bounded = encoded.subarray(tailStart);
179
+ const tail = bounded.toString('utf8');
180
+ totalBytes += Buffer.byteLength(tail);
181
+ if (start > 0 || take < stat.size || bounded.length < encoded.length) incomplete.push(`log_truncated:${name}`);
182
+ files.push({ name, read_status: 'read', bytes: stat.size, modified_at_ms: stat.mtimeMs, rotated, truncated: start > 0 || take < stat.size || bounded.length < encoded.length, tail });
183
+ } catch (error) { incomplete.push(`log_unreadable:${name}:${error.code || 'error'}`); }
184
+ }
185
+ if (files.length === 0) incomplete.push('log_files_missing');
186
+ return { directory: logDirectory, complete: incomplete.length === 0, incomplete, total_bytes: totalBytes, files };
187
+ }
188
+
189
+ // The writer retains the current file and two 1 MiB rotations. Search that
190
+ // finite window, independently of the much smaller global display tails.
191
+ const DIAGNOSTIC_RETAINED_BYTES = 1024 * 1024;
192
+ const DIAGNOSTIC_RECORD_BYTES = 192 * 1024;
193
+ function agentDiagnosticLogs(logDirectory, agentId) {
194
+ const report = { status: 'target_record_missing', scope: 'retained_logs', scan_complete: true, scanned_bytes: 0, record: null, incomplete: [] };
195
+ for (const name of ['daemon-error.log', 'daemon-error.log.1', 'daemon-error.log.2']) {
196
+ let fd;
197
+ try {
198
+ const target = path.join(logDirectory, name);
199
+ const stat = fs.lstatSync(target);
200
+ if (!stat.isFile() || stat.isSymbolicLink()) throw Object.assign(new Error('not a regular file'), { code: 'NON_FILE' });
201
+ fd = fs.openSync(target, 'r');
202
+ const size = fs.fstatSync(fd).size;
203
+ const start = Math.max(0, size - DIAGNOSTIC_RETAINED_BYTES);
204
+ const bytes = Buffer.alloc(Math.min(size, DIAGNOSTIC_RETAINED_BYTES));
205
+ const read = fs.readSync(fd, bytes, 0, bytes.length, start);
206
+ report.scanned_bytes += read;
207
+ if (start > 0 || read < bytes.length) report.incomplete.push(`scan_truncated:${name}`);
208
+ const text = bytes.subarray(0, read).toString('utf8');
209
+ const lines = text.split('\n');
210
+ if (start > 0) lines.shift(); // Never associate a partial first record.
211
+ if (lines.pop()) report.incomplete.push(`record_incomplete:${name}`); // The writer may still be appending.
212
+ for (const line of lines.reverse()) {
213
+ if (Buffer.byteLength(line) > DIAGNOSTIC_RECORD_BYTES) { report.incomplete.push(`record_truncated:${name}`); continue; }
214
+ const prefix = `[zcode-agentd] failure agent=${agentId}: `;
215
+ if (!line.startsWith(prefix)) continue;
216
+ const raw = line.slice(prefix.length);
217
+ // JSON records repeat the identifier; reject misleading prefix matches.
218
+ let structured;
219
+ try { structured = JSON.parse(raw); } catch { structured = null; }
220
+ if (structured && structured.agent_id !== agentId) continue;
221
+ if (structured) {
222
+ report.record = { file: name, ...boundedFailureRecord(structured) };
223
+ } else {
224
+ const encoded = Buffer.from(preserveDiagnosticText(raw));
225
+ let start = Math.max(0, encoded.length - DIAGNOSTIC_TAIL_BYTES);
226
+ while (start < encoded.length && (encoded[start] & 0xc0) === 0x80) start += 1;
227
+ report.record = { file: name, text: encoded.subarray(start).toString('utf8'), truncated: start > 0 };
228
+ }
229
+ report.status = 'found';
230
+ break;
231
+ }
232
+ } catch (error) {
233
+ if (error.code !== 'ENOENT') report.incomplete.push(`scan_unreadable:${name}:${error.code || 'error'}`);
234
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
235
+ if (report.record) break;
236
+ }
237
+ report.scan_complete = report.incomplete.length === 0;
238
+ return report;
239
+ }
240
+
241
+ function diagnoseInput(args) {
242
+ const rawAgent = value(args, '--agent');
243
+ const agent = rawAgent === undefined ? undefined : Number(rawAgent);
244
+ if (rawAgent !== undefined && (!Number.isInteger(agent) || agent < 10_000_000 || agent > 99_999_999 || String(agent) !== rawAgent)) {
245
+ throw new CliError('INVALID_ARGUMENT', 'agent_id must be an integer between 10000000 and 99999999', 2);
246
+ }
247
+ const outputDirectory = value(args, '--output');
248
+ for (let index = 0; index < args.length; index += 1) {
249
+ const arg = args[index];
250
+ if (!arg.startsWith('--')) throw new CliError('INVALID_ARGUMENT', `unexpected diagnose argument: ${arg}`, 2);
251
+ if (!['--agent', '--output'].includes(arg)) throw new CliError('INVALID_ARGUMENT', `unsupported diagnose option: ${arg}`, 2);
252
+ index += 1;
253
+ }
254
+ return { agent, outputDirectory };
255
+ }
256
+
257
+ async function diagnose(paths, args) {
258
+ const { agent, outputDirectory } = diagnoseInput(args);
259
+ const socket = process.env.ZCODE_AGENTD_SOCKET || paths.socket;
260
+ const report = {
261
+ schema_version: 1,
262
+ scope: agent ? { agent_id: agent } : { kind: 'global' },
263
+ platform: platform(),
264
+ runtime: {
265
+ configured_artifact: fileArtifact(ZCODE_RUNTIME, 'cli_packaged_configuration'),
266
+ running_identity: null,
267
+ running_identity_source: 'not_observed_by_cli',
268
+ },
269
+ facade: {
270
+ running_identity: null,
271
+ running_identity_source: 'not_observed_by_cli',
272
+ packaged_artifact: fileArtifact(nativeBinary('external-subagent-mcp'), 'distributed_payload'),
273
+ },
274
+ daemon: { socket, socket_exists: fs.existsSync(socket), query_status: 'unqueried', available: null },
275
+ logs: diagnosticLogs(paths.logs),
276
+ };
277
+ try {
278
+ report.daemon.status = await callDaemon(socket, 'status', {});
279
+ report.daemon.available = true;
280
+ report.daemon.query_status = 'queried';
281
+ } catch (error) {
282
+ report.daemon.available = Boolean(error.daemonResponded);
283
+ report.daemon.query_status = error.daemonResponded ? 'query_failed' : 'unavailable';
284
+ report.daemon.error = { code: error.code || 'DAEMON_ERROR', message: error.message };
285
+ }
286
+ if (agent) {
287
+ const diagnostics = agentDiagnosticLogs(paths.logs, String(agent));
288
+ try {
289
+ const snapshot = await callDaemon(socket, 'wait', { agent_id: agent, wait_time: 0 });
290
+ report.daemon.available = true;
291
+ report.agent = {
292
+ diagnostics,
293
+ task: snapshot.task,
294
+ activity: snapshot.activity,
295
+ session_id: snapshot.task?.session_id ?? null,
296
+ turn_id: snapshot.task?.turn_id ?? null,
297
+ request_ids: Array.isArray(snapshot.pending_requests) ? snapshot.pending_requests.map((request) => request.request_id).filter(Boolean) : [],
298
+ query_request_id: snapshot.__request_id ?? null,
299
+ identifiers_complete: Boolean(snapshot.task?.turn_id || (Array.isArray(snapshot.pending_requests) && snapshot.pending_requests.some((request) => request.request_id))),
300
+ pending_request_count: Array.isArray(snapshot.pending_requests) ? snapshot.pending_requests.length : null,
301
+ result_available: snapshot.result_available ?? false,
302
+ observed_at_ms: Date.now(),
303
+ };
304
+ } catch (error) {
305
+ report.daemon.error = { code: error.code || 'DAEMON_ERROR', message: error.message };
306
+ const missing = error.code === 'not_found';
307
+ report.logs.incomplete.push(missing ? 'agent_missing' : 'agent_store_unavailable');
308
+ report.logs.complete = false;
309
+ report.agent = { agent_id: agent, query_status: missing ? 'missing' : 'unavailable', unavailable: !missing, missing, diagnostics };
310
+ }
311
+ }
312
+ if (outputDirectory) {
313
+ const destination = path.resolve(outputDirectory);
314
+ const target = path.join(destination, 'diagnose.json');
315
+ report.output = { path: target, complete: report.logs.complete && report.daemon.query_status === 'queried' && !report.agent?.unavailable && !report.agent?.missing };
316
+ try {
317
+ fs.mkdirSync(destination, { recursive: true, mode: 0o700 });
318
+ fs.writeFileSync(target, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
319
+ } catch (error) {
320
+ report.output = { path: target, complete: false, error: { code: error.code || 'OUTPUT_WRITE_FAILED', message: error.message } };
321
+ }
322
+ }
323
+ return report;
324
+ }
325
+
326
+ export async function main(args) {
327
+ const command = args[0] || 'help';
328
+ if (command === 'help' || command === '--help' || command === '-h') {
329
+ process.stdout.write(HELP.replace('status, diagnose Inspect local service and runtime state', 'status [--verbose] Inspect essential service and runtime state\n diagnose Export bounded diagnostic details') + DAEMON_HELP); return;
330
+ }
331
+ if (command === 'version' || command === '--version' || command === '-v') {
332
+ process.stdout.write(`${VERSION}\n`); return;
333
+ }
334
+ if (!BUSINESS_COMMANDS.has(command)) throw new CliError('UNKNOWN_COMMAND', `unknown command: ${command}`, 2);
335
+ if (platform() !== 'darwin') throw new CliError('UNSUPPORTED_PLATFORM', `${command} is supported only on macOS`);
336
+
337
+ const paths = productPaths();
338
+ if (command === 'init') {
339
+ // AUD-005/D1: init installs the standalone service only. The retired
340
+ // --skip-runtime-probe/--skip-codex-plugin/--codex-home flags are rejected
341
+ // (never silently ignored): there is no runtime probe to skip, init binds
342
+ // no Codex host, and a host home is chosen by the explicit install
343
+ // commands instead.
344
+ const flags = args.slice(1);
345
+ const known = ['--dry-run', '--resume', '--install-hooks', '--skip-service-start'];
346
+ for (let index = 0; index < flags.length; index += 1) {
347
+ if (!known.includes(flags[index])) {
348
+ if (flags[index] === '--skip-runtime-probe' || flags[index] === '--skip-codex-plugin' || flags[index] === '--codex-home') {
349
+ throw new CliError('INVALID_ARGUMENT', `${flags[index]} is no longer supported: init installs the standalone service only and binds no host (use install-plugin or install-mcp)`, 2);
350
+ }
351
+ throw new CliError('INVALID_ARGUMENT', `unsupported init option: ${flags[index]}`, 2);
352
+ }
353
+ }
354
+ output(runInit({
355
+ paths,
356
+ dryRun: flags.includes('--dry-run'),
357
+ resume: flags.includes('--resume'),
358
+ installHooks: flags.includes('--install-hooks'),
359
+ skipServiceStart: flags.includes('--skip-service-start'),
360
+ }));
361
+ return;
362
+ }
363
+ if (command === 'hooks') {
364
+ if (args[1] !== 'install') throw new CliError('INVALID_ARGUMENT', 'usage: hooks install [--dry-run]', 2);
365
+ output(installHooks(paths, { dryRun: args.includes('--dry-run') })); return;
366
+ }
367
+ if (command === 'install-plugin') {
368
+ output(pluginCommand(paths, args.slice(1))); return;
369
+ }
370
+ if (command === 'install-mcp') {
371
+ output(mcpCommand(paths, args.slice(1))); return;
372
+ }
373
+ if (command === 'status') {
374
+ const verbose = args.includes('--verbose');
375
+ const local = localInstallStatus(paths, { verbose });
376
+ // `service` is the read-only launchd view (registered job + process);
377
+ // `daemon_status` stays the RPC view, so a loaded-but-unready or
378
+ // ready-but-unregistered install reads differently instead of blurring.
379
+ const serviceRaw = serviceRegistrationStatus();
380
+ // PID is an implementation detail and may be reused by another process;
381
+ // keep it out of the ordinary status projection.
382
+ const { pid: _pid, ...service } = serviceRaw;
383
+ try {
384
+ output({ ...local, service, daemon_status: publicDaemonStatus(await callDaemon(process.env.ZCODE_AGENTD_SOCKET || paths.socket, 'status', {}), { verbose }) });
385
+ } catch (error) {
386
+ output({ ...local, service, daemon_status: null, daemon_error: { code: error.code || 'DAEMON_ERROR', message: error.message } });
387
+ }
388
+ return;
389
+ }
390
+ if (command === 'config' || command === 'subagents' || command === 'agents') {
391
+ const input = structuredInput(args.slice(1), command === 'config' ? parseConfigArgs : parseSubagentsArgs);
392
+ output(command === 'config'
393
+ ? configCommand(paths, input)
394
+ : await subagentsCommand(paths, input, { callDaemon, socket: process.env.ZCODE_AGENTD_SOCKET || paths.socket }));
395
+ return;
396
+ }
397
+ if (command === 'diagnose') {
398
+ const report = await diagnose(paths, args.slice(1));
399
+ output({
400
+ ...report,
401
+ daemon_packaged_artifact: fileArtifact(nativeBinary('external-subagentd'), 'distributed_payload'),
402
+ }); return;
403
+ }
404
+ if (command === 'backup') { output(backupData(value(args, '--output'), paths)); return; }
405
+ if (command === 'restore') { output(restoreData(value(args, '--input'), paths)); return; }
406
+ if (command === 'start') { output(startDaemon(paths)); return; }
407
+ if (command === 'stop') { output(stopDaemon(paths)); return; }
408
+ if (command === 'update' || command === 'reconcile') { output(await updateCommand(paths, command === 'reconcile' ? ['reconcile', ...args.slice(1)] : args.slice(1), { socket: process.env.ZCODE_AGENTD_SOCKET || paths.socket })); return; }
409
+ if (command === 'uninstall') { output(uninstall(paths)); return; }
410
+ if (command === 'purge') {
411
+ if (!args.includes('--yes')) throw new CliError('CONFIRMATION_REQUIRED', 'purge requires --yes');
412
+ output(purge(paths)); return;
413
+ }
414
+ if (command === 'cleanup-legacy') {
415
+ if (!args.includes('--yes')) throw new CliError('CONFIRMATION_REQUIRED', 'cleanup-legacy requires --yes');
416
+ output(cleanupLegacy(paths.home)); return;
417
+ }
418
+ let input;
419
+ if (command === 'create' || command === 'spawn') {
420
+ const spawnArgs = args.slice(1);
421
+ input = spawnArgs.length === 0 || spawnArgs.some((arg) => arg === '--json' || arg.startsWith('--json='))
422
+ ? prepareSpawnInput(parseDaemonInput(spawnArgs))
423
+ : parseSpawnArgs(spawnArgs);
424
+ } else input = parseDaemonInput(args.slice(1));
425
+ const result = await callDaemon(process.env.ZCODE_AGENTD_SOCKET || paths.socket, command, input);
426
+ output({ command, result });
427
+ }
428
+
429
+ export { DAEMON_HELP, HELP, installPlan, diagnose, diagnosticLogs, fileArtifact, publicDaemonStatus, summarizeAgentStatus };