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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/external-subagent-debug-mcp.mjs +5 -0
- package/bin/external-subagent-debug.mjs +13 -0
- package/bin/external-subagent-mcp.mjs +20 -0
- package/bin/external-subagent.mjs +8 -0
- package/bin/zas.mjs +8 -0
- package/cli/commands/agents.mjs +117 -0
- package/cli/commands/config.mjs +117 -0
- package/cli/commands/daemon.mjs +36 -0
- package/cli/commands/maintenance.mjs +27 -0
- package/cli/commands/plugin.mjs +96 -0
- package/cli/commands/tasks.mjs +64 -0
- package/cli/commands/update.mjs +219 -0
- package/cli/config/read.mjs +24 -0
- package/cli/config/schema.mjs +75 -0
- package/cli/config/write.mjs +39 -0
- package/cli/constants.mjs +24 -0
- package/cli/errors.mjs +7 -0
- package/cli/fs-atomic.mjs +36 -0
- package/cli/install/codex.mjs +352 -0
- package/cli/install/init.mjs +206 -0
- package/cli/install/layout.mjs +54 -0
- package/cli/install/npm-hook.mjs +49 -0
- package/cli/install/path.mjs +43 -0
- package/cli/install/payload.mjs +84 -0
- package/cli/install/plugin-stage.mjs +232 -0
- package/cli/install/reconcile.mjs +209 -0
- package/cli/install/recovery.mjs +79 -0
- package/cli/install/service-activation.mjs +133 -0
- package/cli/install/service-macos.mjs +146 -0
- package/cli/install/update.mjs +170 -0
- package/cli/install/zcode.mjs +239 -0
- package/cli/main.mjs +429 -0
- package/cli/maintenance.mjs +104 -0
- package/cli/paths.mjs +40 -0
- package/cli/rpc.mjs +261 -0
- package/launchd/com.external-subagent.daemon.plist.template +37 -0
- package/npm/native/README.md +19 -0
- package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
- package/npm/native/darwin-arm64/external-subagentd +0 -0
- package/npm/native/darwin-arm64/payload.json +20 -0
- package/package.json +60 -0
- package/plugins/codex/external-subagent/.codex-plugin/plugin.json +17 -0
- package/plugins/codex/external-subagent/.mcp.json +9 -0
- package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +55 -0
- package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +19 -0
- package/plugins/codex/external-subagent/hooks/guard-bash.mjs +15 -0
- package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +206 -0
- package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +149 -0
- package/plugins/codex/external-subagent/scripts/mcp-stdio-bridge.mjs +36 -0
- package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +44 -0
- package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +55 -0
- package/schema/external-subagent-public-api.json +185 -0
- package/schema/zas-observation-v1.1.schema.json +58 -0
- package/schema/zas-public-reasoning-source.json +14 -0
- package/scripts/release/build-native-payload.mjs +119 -0
- package/scripts/release/check-native-tarball.mjs +125 -0
- package/scripts/release/stage-debug-plugin.mjs +60 -0
- package/scripts/release/test-installed-tarball.mjs +73 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliError } from './errors.mjs';
|
|
4
|
+
import { jsonBytes, sha256 } from './fs-atomic.mjs';
|
|
5
|
+
import { legacyPaths, productPaths } from './paths.mjs';
|
|
6
|
+
import { bootoutService } from './install/service-macos.mjs';
|
|
7
|
+
|
|
8
|
+
function copyTree(source, destination, records, root = source) {
|
|
9
|
+
if (!fs.existsSync(source)) return;
|
|
10
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
11
|
+
const from = path.join(source, entry.name);
|
|
12
|
+
const relative = path.relative(root, from);
|
|
13
|
+
const to = path.join(destination, relative);
|
|
14
|
+
if (entry.isSymbolicLink()) throw new CliError('UNSAFE_BACKUP_ENTRY', `refusing symlink: ${from}`);
|
|
15
|
+
if (entry.isDirectory()) copyTree(from, destination, records, root);
|
|
16
|
+
else if (entry.isFile()) {
|
|
17
|
+
fs.mkdirSync(path.dirname(to), { recursive: true, mode: 0o700 });
|
|
18
|
+
const bytes = fs.readFileSync(from);
|
|
19
|
+
fs.writeFileSync(to, bytes, { mode: 0o600 });
|
|
20
|
+
records.push({ path: relative, bytes: bytes.length, sha256: sha256(bytes) });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function backupData(destination, paths = productPaths()) {
|
|
26
|
+
const resolved = path.resolve(destination);
|
|
27
|
+
const source = path.resolve(paths.data);
|
|
28
|
+
const relativeToSource = path.relative(source, resolved);
|
|
29
|
+
if (relativeToSource === '' || (!relativeToSource.startsWith('..') && !path.isAbsolute(relativeToSource))) {
|
|
30
|
+
throw new CliError('BACKUP_DESTINATION_IN_DATA', 'backup destination must not be inside product data');
|
|
31
|
+
}
|
|
32
|
+
if (fs.existsSync(resolved)) throw new CliError('BACKUP_EXISTS', 'backup destination already exists');
|
|
33
|
+
fs.mkdirSync(resolved, { recursive: false, mode: 0o700 });
|
|
34
|
+
const records = [];
|
|
35
|
+
copyTree(paths.data, path.join(resolved, 'data'), records);
|
|
36
|
+
const manifest = { schema_version: 1, product: 'external-subagent', files: records.sort((a, b) => a.path.localeCompare(b.path)) };
|
|
37
|
+
fs.writeFileSync(path.join(resolved, 'manifest.json'), jsonBytes(manifest), { mode: 0o600 });
|
|
38
|
+
return { destination: resolved, files: records.length };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function restoreData(source, paths = productPaths()) {
|
|
42
|
+
const resolved = path.resolve(source);
|
|
43
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(resolved, 'manifest.json'), 'utf8'));
|
|
44
|
+
if (manifest.product !== 'external-subagent' || !Array.isArray(manifest.files)) throw new CliError('BACKUP_INVALID', 'backup manifest is invalid');
|
|
45
|
+
for (const record of manifest.files) {
|
|
46
|
+
if (path.isAbsolute(record.path) || record.path.split(path.sep).includes('..')) throw new CliError('BACKUP_INVALID', 'backup contains an unsafe path');
|
|
47
|
+
const bytes = fs.readFileSync(path.join(resolved, 'data', record.path));
|
|
48
|
+
if (bytes.length !== record.bytes || sha256(bytes) !== record.sha256) throw new CliError('BACKUP_CORRUPT', `backup verification failed: ${record.path}`);
|
|
49
|
+
}
|
|
50
|
+
const temporary = `${paths.data}.restore-${process.pid}`;
|
|
51
|
+
if (fs.existsSync(temporary)) fs.rmSync(temporary, { recursive: true });
|
|
52
|
+
fs.mkdirSync(temporary, { recursive: true, mode: 0o700 });
|
|
53
|
+
copyTree(path.join(resolved, 'data'), temporary, []);
|
|
54
|
+
const displaced = `${paths.data}.previous-${process.pid}`;
|
|
55
|
+
try {
|
|
56
|
+
if (fs.existsSync(paths.data)) fs.renameSync(paths.data, displaced);
|
|
57
|
+
fs.renameSync(temporary, paths.data);
|
|
58
|
+
if (fs.existsSync(displaced)) fs.rmSync(displaced, { recursive: true });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (!fs.existsSync(paths.data) && fs.existsSync(displaced)) fs.renameSync(displaced, paths.data);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
return { restored: true, files: manifest.files.length };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function removeOne(target) {
|
|
67
|
+
try {
|
|
68
|
+
const stat = fs.lstatSync(target);
|
|
69
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) fs.rmSync(target, { recursive: true });
|
|
70
|
+
else fs.unlinkSync(target);
|
|
71
|
+
return true;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (error?.code === 'ENOENT') return false;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Deleting the plist alone leaves a job launchd already loaded running until
|
|
79
|
+
// the next logout (observed live: uninstall reported success while the daemon
|
|
80
|
+
// process, the launchd registration, and the RPC socket all stayed alive).
|
|
81
|
+
// The ES-owned service is therefore booted out — with the bounded
|
|
82
|
+
// removal-confirmation bootoutService owns — before its definition file is
|
|
83
|
+
// removed. A service that is not registered is not an error (idempotent
|
|
84
|
+
// uninstall); a bootout that cannot complete fails the command rather than
|
|
85
|
+
// deleting the definition out from under a still-running service.
|
|
86
|
+
export function uninstall(paths = productPaths(), options = {}) {
|
|
87
|
+
const service = bootoutService(paths, process.getuid(), options);
|
|
88
|
+
return {
|
|
89
|
+
service_stopped: true,
|
|
90
|
+
service_already_stopped: Boolean(service.already_stopped),
|
|
91
|
+
removed_launch_agent: removeOne(paths.launchAgent),
|
|
92
|
+
data_retained: true,
|
|
93
|
+
data: paths.data,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function purge(paths = productPaths()) {
|
|
98
|
+
return { purged: removeOne(paths.data), logs_purged: removeOne(paths.logs) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function cleanupLegacy(home) {
|
|
102
|
+
const removed = legacyPaths(home).filter(removeOne);
|
|
103
|
+
return { migration: false, aliases_created: [], removed };
|
|
104
|
+
}
|
package/cli/paths.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { LAUNCH_AGENT_LABEL, PRODUCT_NAME } from './constants.mjs';
|
|
4
|
+
import { PLUGIN_NAME } from './install/layout.mjs';
|
|
5
|
+
|
|
6
|
+
export function platform() {
|
|
7
|
+
return process.env.ZCODE_AS_SUBAGENT_TEST_PLATFORM || process.platform;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// State paths derive from PRODUCT_NAME, so the debug variant gets a fully
|
|
11
|
+
// parallel tree (its own data directory, database, socket, and logs) and
|
|
12
|
+
// never touches the released product's state.
|
|
13
|
+
export function productPaths(home = os.homedir()) {
|
|
14
|
+
const data = path.join(home, 'Library', 'Application Support', PRODUCT_NAME);
|
|
15
|
+
return {
|
|
16
|
+
home,
|
|
17
|
+
data,
|
|
18
|
+
config: path.join(data, 'config.json'),
|
|
19
|
+
state: path.join(data, 'install-state.json'),
|
|
20
|
+
hookProvenance: path.join(data, 'zcode-agent-hook-provenance.json'),
|
|
21
|
+
database: path.join(data, `${PRODUCT_NAME}.sqlite3`),
|
|
22
|
+
socket: path.join(data, `${PRODUCT_NAME}.sock`),
|
|
23
|
+
logs: path.join(home, 'Library', 'Logs', PRODUCT_NAME),
|
|
24
|
+
launchAgent: path.join(home, 'Library', 'LaunchAgents', `${LAUNCH_AGENT_LABEL}.plist`),
|
|
25
|
+
zcodeConfig: path.join(home, '.zcode', 'cli', 'config.json'),
|
|
26
|
+
zcodePlugin: path.join(data, 'zcode-plugin', PLUGIN_NAME),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function legacyPaths(home = os.homedir()) {
|
|
31
|
+
return [
|
|
32
|
+
path.join(home, 'Library', 'LaunchAgents', 'com.zcode-reviewd.plist'),
|
|
33
|
+
path.join(home, 'Library', 'LaunchAgents', 'com.zcode-review-mcp.plist'),
|
|
34
|
+
path.join(home, 'Library', 'Logs', 'zcode-reviewd'),
|
|
35
|
+
path.join(home, 'Library', 'Application Support', 'zcode-review-mcp'),
|
|
36
|
+
path.join(home, 'Library', 'Application Support', 'zcode-reviewd'),
|
|
37
|
+
path.join(home, '.local', 'bin', 'zcode-reviewd'),
|
|
38
|
+
path.join(home, '.local', 'bin', 'zcode-review-mcp'),
|
|
39
|
+
];
|
|
40
|
+
}
|
package/cli/rpc.mjs
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { CliError } from './errors.mjs';
|
|
5
|
+
|
|
6
|
+
export const MAX_FRAME_BYTES = 512 * 1024;
|
|
7
|
+
export const MAX_RESPONSE_FRAME_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
export const MAX_RESULT_CHUNK_BYTES = 256 * 1024;
|
|
9
|
+
export const AGENT_PROBE_TRANSPORT_TIMEOUT_MS = 200_000;
|
|
10
|
+
export const MIN_TASK_ID = 10_000_000;
|
|
11
|
+
export const MAX_TASK_ID = 99_999_999;
|
|
12
|
+
|
|
13
|
+
// Daemon RpcErrorCode values serialize snake_case on the wire
|
|
14
|
+
// ("unknown_method", "validation"), while CLI error codes are
|
|
15
|
+
// SCREAMING_SNAKE. callDaemon preserves the raw daemon code verbatim —
|
|
16
|
+
// diagnose compares 'not_found' directly — so upgrade-surface consumers
|
|
17
|
+
// canonicalize through this mapping before matching or recording codes.
|
|
18
|
+
export function canonicalDaemonCode(code) {
|
|
19
|
+
return typeof code === 'string' && code.length > 0 ? code.toUpperCase() : 'DAEMON_ERROR';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function taskId(value) {
|
|
23
|
+
if (!Number.isInteger(value) || value < MIN_TASK_ID || value > MAX_TASK_ID) {
|
|
24
|
+
throw new CliError('INVALID_ARGUMENT', 'agent_id must be an integer between 10000000 and 99999999', 2);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function daemonTaskId(value) { return String(taskId(value)); }
|
|
30
|
+
|
|
31
|
+
export function waitTransportTimeoutMs(waitTime = 290) {
|
|
32
|
+
if (!Number.isInteger(waitTime) || waitTime < 0 || waitTime > 299) {
|
|
33
|
+
throw new CliError('INVALID_ARGUMENT', 'wait_time must be an integer between 0 and 299', 2);
|
|
34
|
+
}
|
|
35
|
+
return waitTime * 1000 + 5000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function daemonTransportTimeoutMs(command, params = {}) {
|
|
39
|
+
if (command === 'wait') return waitTransportTimeoutMs(params.wait_time);
|
|
40
|
+
if (command === 'agent-probe' || command === 'agent-models') return AGENT_PROBE_TRANSPORT_TIMEOUT_MS;
|
|
41
|
+
return 6000;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function publicTaskId(value) {
|
|
45
|
+
const id = Number(value);
|
|
46
|
+
if (!Number.isSafeInteger(id) || String(id) !== String(value) || id < MIN_TASK_ID || id > MAX_TASK_ID) {
|
|
47
|
+
throw new CliError('PROTOCOL_ERROR', 'daemon returned an invalid agent_id');
|
|
48
|
+
}
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readJsonInput(args) {
|
|
53
|
+
const inline = args.find((arg) => arg.startsWith('--json='));
|
|
54
|
+
if (inline) return JSON.parse(inline.slice('--json='.length));
|
|
55
|
+
const index = args.indexOf('--json');
|
|
56
|
+
if (index >= 0 && args[index + 1] && !args[index + 1].startsWith('--')) return JSON.parse(args[index + 1]);
|
|
57
|
+
if (index >= 0 || !process.stdin.isTTY) {
|
|
58
|
+
const input = fs.readFileSync(0, 'utf8').trim();
|
|
59
|
+
if (input) return JSON.parse(input);
|
|
60
|
+
}
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requestId() { return `cli-${process.pid}-${crypto.randomUUID()}`; }
|
|
65
|
+
|
|
66
|
+
function manifest(input) {
|
|
67
|
+
const allowed = new Set(['subagent', 'repository', 'permission_mode', 'prompt', 'model', 'effort', 'write_manifest']);
|
|
68
|
+
for (const key of Object.keys(input)) {
|
|
69
|
+
if (!allowed.has(key)) throw new CliError('INVALID_ARGUMENT', `spawn contains unsupported field: ${key}`, 2);
|
|
70
|
+
}
|
|
71
|
+
if (input.subagent !== undefined && (typeof input.subagent !== 'string' || input.subagent.length === 0)) throw new CliError('INVALID_ARGUMENT', 'subagent must be a non-empty string', 2);
|
|
72
|
+
if (input.model !== undefined && (typeof input.model !== 'string' || input.model.length === 0)) throw new CliError('INVALID_ARGUMENT', 'model must be a non-empty string', 2);
|
|
73
|
+
if (input.effort !== undefined && (typeof input.effort !== 'string' || input.effort.length === 0)) throw new CliError('INVALID_ARGUMENT', 'effort must be a non-empty string', 2);
|
|
74
|
+
if (typeof input.repository !== 'string' || input.repository.length === 0 || typeof input.prompt !== 'string' || input.prompt.length === 0) throw new CliError('INVALID_ARGUMENT', 'create requires non-empty repository and prompt strings', 2);
|
|
75
|
+
if (input.permission_mode !== undefined && (typeof input.permission_mode !== 'string' || input.permission_mode.length === 0)) throw new CliError('INVALID_ARGUMENT', 'permission_mode must be a non-empty string', 2);
|
|
76
|
+
if (input.write_manifest !== undefined && (!Array.isArray(input.write_manifest) || input.write_manifest.some((value) => typeof value !== 'string' || value.length === 0))) throw new CliError('INVALID_ARGUMENT', 'write_manifest must contain non-empty strings', 2);
|
|
77
|
+
return {
|
|
78
|
+
schema: 'zcode-general-task/v1', agent_id: requestId(), repository: input.repository,
|
|
79
|
+
permission_mode: input.permission_mode || 'build', prompt: input.prompt,
|
|
80
|
+
write_manifest: input.write_manifest || [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rejectUnknownFields(command, input, allowed) {
|
|
85
|
+
for (const key of Object.keys(input)) {
|
|
86
|
+
if (!allowed.has(key)) throw new CliError('INVALID_ARGUMENT', `${command} contains unsupported field: ${key}`, 2);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function methodFor(command, input) {
|
|
91
|
+
switch (command) {
|
|
92
|
+
case 'status': return { method: 'system_status' };
|
|
93
|
+
case 'drain': return { method: 'daemon_begin_drain', ...(input.cancel_active === true ? { params: { cancel_active: true } } : {}) };
|
|
94
|
+
case 'drain-status': return { method: 'daemon_drain_status' };
|
|
95
|
+
case 'drain-abort': return { method: 'daemon_abort_drain' };
|
|
96
|
+
case 'activate-ready': return { method: 'daemon_activate_ready' };
|
|
97
|
+
case 'agent-probe': return { method: 'agent_probe', params: input };
|
|
98
|
+
case 'agent-models': return { method: 'agent_models', params: input };
|
|
99
|
+
case 'create': case 'spawn': return {
|
|
100
|
+
method: 'submit_general',
|
|
101
|
+
params: {
|
|
102
|
+
...(input.subagent !== undefined ? { subagent: input.subagent } : {}),
|
|
103
|
+
...(input.model !== undefined ? { model: input.model } : {}),
|
|
104
|
+
...(input.effort !== undefined ? { effort: input.effort } : {}),
|
|
105
|
+
manifest: manifest(input),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
case 'wait': {
|
|
109
|
+
rejectUnknownFields('wait', input, new Set(['agent_id', 'wait_time', 'message_id']));
|
|
110
|
+
return {
|
|
111
|
+
method: 'task_wait',
|
|
112
|
+
params: {
|
|
113
|
+
agent_id: daemonTaskId(input.agent_id),
|
|
114
|
+
wait_time: input.wait_time ?? 290,
|
|
115
|
+
...(input.message_id ? { message_id: input.message_id } : {}),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
case 'list': {
|
|
120
|
+
const allowed = new Set(['subagent', 'repository', 'workspace', 'phase', 'outcome', 'cursor', 'limit']);
|
|
121
|
+
for (const key of Object.keys(input)) {
|
|
122
|
+
if (!allowed.has(key)) throw new CliError('INVALID_ARGUMENT', `list contains unsupported field: ${key}`, 2);
|
|
123
|
+
}
|
|
124
|
+
if (input.subagent === null || (input.subagent !== undefined && (typeof input.subagent !== 'string' || input.subagent.length === 0))) {
|
|
125
|
+
throw new CliError('INVALID_ARGUMENT', 'list subagent must be omitted or a non-empty string', 2);
|
|
126
|
+
}
|
|
127
|
+
const repository = input.repository ?? input.workspace;
|
|
128
|
+
if (!repository) throw new CliError('INVALID_ARGUMENT', 'list requires repository or workspace scope', 2);
|
|
129
|
+
return { method: 'task_list', params: {
|
|
130
|
+
...(input.subagent !== undefined ? { subagent: input.subagent } : {}),
|
|
131
|
+
repository,
|
|
132
|
+
phase: input.phase ?? null,
|
|
133
|
+
outcome: input.outcome ?? null,
|
|
134
|
+
cursor: input.cursor ?? null,
|
|
135
|
+
limit: input.limit ?? 100,
|
|
136
|
+
} };
|
|
137
|
+
}
|
|
138
|
+
case 'send': {
|
|
139
|
+
rejectUnknownFields('send', input, new Set(['agent_id', 'message_id', 'content']));
|
|
140
|
+
return { method: 'task_message', params: {
|
|
141
|
+
agent_id: daemonTaskId(input.agent_id),
|
|
142
|
+
...(input.message_id ? { message_id: input.message_id } : {}),
|
|
143
|
+
content: input.content,
|
|
144
|
+
} };
|
|
145
|
+
}
|
|
146
|
+
case 'respond': {
|
|
147
|
+
rejectUnknownFields('respond', input, new Set(['agent_id', 'request_id', 'decision', 'content']));
|
|
148
|
+
return { method: 'task_respond', params: { agent_id: daemonTaskId(input.agent_id), request_id: input.request_id, decision: input.decision, content: input.content ?? null } };
|
|
149
|
+
}
|
|
150
|
+
case 'cancel': return { method: 'task_cancel', params: { agent_id: daemonTaskId(input.agent_id) } };
|
|
151
|
+
case 'result': {
|
|
152
|
+
rejectUnknownFields('result', input, new Set(['agent_id', 'offset', 'limit']));
|
|
153
|
+
return { method: 'task_result', params: {
|
|
154
|
+
agent_id: daemonTaskId(input.agent_id),
|
|
155
|
+
offset: input.offset ?? 0,
|
|
156
|
+
limit: input.limit ?? MAX_RESULT_CHUNK_BYTES,
|
|
157
|
+
} };
|
|
158
|
+
}
|
|
159
|
+
case 'close': return { method: 'task_close', params: { agent_id: daemonTaskId(input.agent_id) } };
|
|
160
|
+
case 'observe': return { method: 'task_observe', params: { agent_id: daemonTaskId(input.agent_id) } };
|
|
161
|
+
default: throw new CliError('UNKNOWN_COMMAND', `unsupported daemon command: ${command}`, 2);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The CLI projection is field-for-field identical to the MCP tool outputs:
|
|
166
|
+
// both pass the daemon's public wire views through unchanged, except the
|
|
167
|
+
// numeric agent_id coercion.
|
|
168
|
+
function publicTask(task) {
|
|
169
|
+
return {
|
|
170
|
+
agent_id: publicTaskId(task.agent_id),
|
|
171
|
+
status: task.status,
|
|
172
|
+
session_id: task.session_id ?? null,
|
|
173
|
+
input_identity: task.input_identity ?? null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function publicResult(result) {
|
|
178
|
+
if (result == null) return null;
|
|
179
|
+
return {
|
|
180
|
+
outcome: result.outcome,
|
|
181
|
+
final_text: result.final_text,
|
|
182
|
+
partial: result.partial,
|
|
183
|
+
offset: result.offset,
|
|
184
|
+
total_bytes: result.total_bytes,
|
|
185
|
+
next_offset: result.next_offset ?? null,
|
|
186
|
+
complete: result.complete,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function projectDaemonResult(command, result) {
|
|
191
|
+
switch (command) {
|
|
192
|
+
case 'status': return result.status;
|
|
193
|
+
case 'drain': case 'drain-status': case 'drain-abort': case 'activate-ready': return result;
|
|
194
|
+
case 'agent-probe': return { evidence: result.evidence, status: result.status };
|
|
195
|
+
case 'agent-models': return result.catalog ?? result;
|
|
196
|
+
case 'create': case 'spawn':
|
|
197
|
+
return { agent_id: publicTaskId(result.task.agent_id), status: result.task.status };
|
|
198
|
+
case 'wait': {
|
|
199
|
+
const { result: taskResult, task, activity, kind: _kind, ...rest } = result;
|
|
200
|
+
return { ...rest, task: publicTask(task), activity, result: publicResult(taskResult) };
|
|
201
|
+
}
|
|
202
|
+
case 'list': return { tasks: result.tasks.map(publicTask), next_cursor: result.next_cursor ?? null };
|
|
203
|
+
case 'send': return { message_id: result.message_id, disposition: result.disposition };
|
|
204
|
+
case 'respond': return { ...result.outcome, policy_reason_code: result.outcome.policy_reason_code ?? null };
|
|
205
|
+
case 'cancel': case 'close': return { task: publicTask(result.task) };
|
|
206
|
+
case 'result': return { task: publicTask(result.task), result: publicResult(result.result) };
|
|
207
|
+
case 'observe': return { ...result.observation, agent_id: publicTaskId(result.observation.agent_id) };
|
|
208
|
+
default: throw new CliError('PROTOCOL_ERROR', `daemon returned an unsupported result for ${command}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function callDaemon(socketPath, command, input, timeoutMs) {
|
|
213
|
+
const { method, params } = methodFor(command, input);
|
|
214
|
+
const effectiveTimeoutMs = timeoutMs ?? daemonTransportTimeoutMs(command, params);
|
|
215
|
+
const request_id = requestId();
|
|
216
|
+
const request = JSON.stringify({ request_id, method, params }) + '\n';
|
|
217
|
+
if (Buffer.byteLength(request, 'utf8') > MAX_FRAME_BYTES) {
|
|
218
|
+
throw new CliError('OVERSIZED', 'encoded RPC request exceeds frame cap', 2);
|
|
219
|
+
}
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const socket = net.createConnection(socketPath); let data = ''; let dataBytes = 0; let settled = false;
|
|
222
|
+
const finish = (fn, value) => { if (!settled) { settled = true; socket.destroy(); fn(value); } };
|
|
223
|
+
const timer = setTimeout(() => finish(reject, new CliError('SOCKET_UNAVAILABLE', `daemon socket unavailable: ${socketPath}`)), effectiveTimeoutMs);
|
|
224
|
+
socket.setEncoding('utf8');
|
|
225
|
+
// Keep the client write side open while the daemon processes the request.
|
|
226
|
+
// A half-close is interpreted by the daemon as peer disconnect, which
|
|
227
|
+
// would incorrectly interrupt long-polling wait requests.
|
|
228
|
+
socket.on('connect', () => socket.write(request));
|
|
229
|
+
socket.on('data', (chunk) => {
|
|
230
|
+
dataBytes += Buffer.byteLength(chunk, 'utf8');
|
|
231
|
+
if (dataBytes > MAX_RESPONSE_FRAME_BYTES) {
|
|
232
|
+
finish(reject, new CliError('OVERSIZED', 'encoded RPC response exceeds frame cap', 2));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
data += chunk;
|
|
236
|
+
const newline = data.indexOf('\n');
|
|
237
|
+
if (newline < 0) return;
|
|
238
|
+
const line = data.slice(0, newline);
|
|
239
|
+
if (!line) return;
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
try {
|
|
242
|
+
const response = JSON.parse(line);
|
|
243
|
+
if (response.request_id !== request_id) finish(reject, new CliError('PROTOCOL_ERROR', 'daemon returned an RPC response for a different request'));
|
|
244
|
+
else if (response.outcome === 'error') { const daemon = response.error || {}; const error = new CliError(daemon.code || 'DAEMON_ERROR', daemon.message || 'daemon request failed'); error.agentId = daemon.active_agent_id == null ? undefined : publicTaskId(daemon.active_agent_id); error.daemonResponded = true; finish(reject, error); }
|
|
245
|
+
else if (response.outcome === 'success') {
|
|
246
|
+
try {
|
|
247
|
+
const projected = projectDaemonResult(command, response.result);
|
|
248
|
+
Object.defineProperty(projected, '__request_id', { value: request_id, enumerable: false });
|
|
249
|
+
finish(resolve, projected);
|
|
250
|
+
}
|
|
251
|
+
catch (error) { finish(reject, error instanceof CliError ? error : new CliError('PROTOCOL_ERROR', 'daemon returned an invalid public result')); }
|
|
252
|
+
}
|
|
253
|
+
else finish(reject, new CliError('PROTOCOL_ERROR', 'daemon returned an invalid RPC response'));
|
|
254
|
+
} catch { finish(reject, new CliError('PROTOCOL_ERROR', 'daemon returned invalid JSON')); }
|
|
255
|
+
});
|
|
256
|
+
socket.on('error', () => { clearTimeout(timer); finish(reject, new CliError('SOCKET_UNAVAILABLE', `daemon socket unavailable: ${socketPath}`)); });
|
|
257
|
+
socket.on('close', () => { clearTimeout(timer); if (!settled) finish(reject, new CliError('SOCKET_UNAVAILABLE', 'daemon closed the RPC connection')); });
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function parseDaemonInput(args) { try { return readJsonInput(args); } catch (error) { throw new CliError('INVALID_JSON', error.message, 2); } }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<!-- Reference template for the managed daemon LaunchAgent. The installed
|
|
4
|
+
plist is generated by cli/install/service-macos.mjs with absolute payload
|
|
5
|
+
paths and the fixed GUI PATH; this file documents the managed shape and
|
|
6
|
+
is not read at runtime. -->
|
|
7
|
+
<plist version="1.0">
|
|
8
|
+
<dict>
|
|
9
|
+
<key>Label</key>
|
|
10
|
+
<string>com.external-subagent.daemon</string>
|
|
11
|
+
<key>ProgramArguments</key>
|
|
12
|
+
<array>
|
|
13
|
+
<string>/<npm-prefix>/lib/node_modules/external-subagent/npm/native/darwin-arm64/external-subagentd</string>
|
|
14
|
+
<string>--database</string>
|
|
15
|
+
<string><home>/Library/Application Support/external-subagent/external-subagent.sqlite3</string>
|
|
16
|
+
<string>--socket</string>
|
|
17
|
+
<string><home>/Library/Application Support/external-subagent/external-subagent.sock</string>
|
|
18
|
+
<string>--runtime</string>
|
|
19
|
+
<string>/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs</string>
|
|
20
|
+
<string>--diagnostic-log</string>
|
|
21
|
+
<string><home>/Library/Logs/external-subagent/daemon-error.log</string>
|
|
22
|
+
</array>
|
|
23
|
+
<key>EnvironmentVariables</key>
|
|
24
|
+
<dict>
|
|
25
|
+
<key>PATH</key>
|
|
26
|
+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
27
|
+
</dict>
|
|
28
|
+
<key>RunAtLoad</key>
|
|
29
|
+
<true/>
|
|
30
|
+
<key>KeepAlive</key>
|
|
31
|
+
<true/>
|
|
32
|
+
<key>StandardOutPath</key>
|
|
33
|
+
<string><home>/Library/Logs/external-subagent/daemon.log</string>
|
|
34
|
+
<key>StandardErrorPath</key>
|
|
35
|
+
<string><home>/Library/Logs/external-subagent/daemon-error.log</string>
|
|
36
|
+
</dict>
|
|
37
|
+
</plist>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Native payload
|
|
2
|
+
|
|
3
|
+
`npm/native/darwin-arm64/` carries the prebuilt native payload for the npm
|
|
4
|
+
package: the `external-subagentd` daemon and the `external-subagent-mcp`
|
|
5
|
+
stdio facade, plus the generated `payload.json` manifest (product version,
|
|
6
|
+
platform, and per-file bytes/sha256/mode).
|
|
7
|
+
|
|
8
|
+
The directory is build output, not source:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
node scripts/release/build-native-payload.mjs # build + manifest
|
|
12
|
+
node scripts/release/build-native-payload.mjs --if-stale
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Supported platforms install without compiling Rust; the installer verifies
|
|
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
|
|
19
|
+
commands without writing HOME.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"product": "external-subagent",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"platform": "darwin-arm64",
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"name": "external-subagentd",
|
|
9
|
+
"bytes": 9185936,
|
|
10
|
+
"mode": "755",
|
|
11
|
+
"sha256": "31b8d52d84d3ffa04eb17d3ce7e6b57fd3f8d5d4e40572edc1996ef328893bd6"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "external-subagent-mcp",
|
|
15
|
+
"bytes": 930896,
|
|
16
|
+
"mode": "755",
|
|
17
|
+
"sha256": "7da0c995d192fd166b9bd69c0b75bd02e3b3835bab2abbf48f32a47e01c38848"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "external-subagent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Manage bounded ZCode, DeepSeek Harness (DSH), and Codex subagent tasks through a local MCP service for any local host (Codex, ZCode, or a custom MCP client)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/iBobbyTS/external-subagent.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/iBobbyTS/external-subagent#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/iBobbyTS/external-subagent/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"subagent",
|
|
18
|
+
"codex",
|
|
19
|
+
"zcode",
|
|
20
|
+
"dsh",
|
|
21
|
+
"agent-tasks"
|
|
22
|
+
],
|
|
23
|
+
"os": [
|
|
24
|
+
"darwin"
|
|
25
|
+
],
|
|
26
|
+
"cpu": [
|
|
27
|
+
"arm64"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"external-subagent": "bin/external-subagent.mjs",
|
|
31
|
+
"external-subagent-mcp": "bin/external-subagent-mcp.mjs",
|
|
32
|
+
"external-subagent-debug": "bin/external-subagent-debug.mjs",
|
|
33
|
+
"external-subagent-debug-mcp": "bin/external-subagent-debug-mcp.mjs"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"bin/",
|
|
40
|
+
"cli/",
|
|
41
|
+
"npm/native/",
|
|
42
|
+
"plugins/codex/external-subagent/",
|
|
43
|
+
"schema/",
|
|
44
|
+
"launchd/",
|
|
45
|
+
"scripts/release/",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"README.md"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"postinstall": "node cli/install/npm-hook.mjs",
|
|
51
|
+
"prepack": "node scripts/release/build-native-payload.mjs",
|
|
52
|
+
"postpack": "node scripts/release/check-native-tarball.mjs",
|
|
53
|
+
"prepublishOnly": "node scripts/release/check-native-tarball.mjs --staged",
|
|
54
|
+
"test:install": "node --test tests/install/*.test.mjs",
|
|
55
|
+
"check": "node --check bin/external-subagent.mjs && node --check bin/external-subagent-mcp.mjs && node --check bin/external-subagent-debug.mjs && node --check bin/external-subagent-debug-mcp.mjs && node --check cli/main.mjs"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "external-subagent",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Manage bounded external agent tasks through the external-subagent MCP service.",
|
|
5
|
+
"author": { "name": "external-subagent maintainers" },
|
|
6
|
+
"skills": "./skills/",
|
|
7
|
+
"interface": {
|
|
8
|
+
"displayName": "External Subagent",
|
|
9
|
+
"shortDescription": "Run bounded local external agent tasks.",
|
|
10
|
+
"longDescription": "Connects this host (Codex or ZCode) to the external-subagent lifecycle service for repository-scoped tasks.",
|
|
11
|
+
"developerName": "external-subagent maintainers",
|
|
12
|
+
"category": "Productivity",
|
|
13
|
+
"capabilities": ["Local MCP", "Durable tasks", "Agent tasks"],
|
|
14
|
+
"defaultPrompt": ["Check external-subagent status.", "Spawn a repository-scoped task.", "Review a complete task result."]
|
|
15
|
+
},
|
|
16
|
+
"mcpServers": "./.mcp.json"
|
|
17
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createAgentFileHookOutput, evaluateAgentFileInput } from '../lib/agent-file-policy.mjs';
|
|
3
|
+
|
|
4
|
+
let raw = '';
|
|
5
|
+
process.stdin.setEncoding('utf8');
|
|
6
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
7
|
+
try {
|
|
8
|
+
const input = JSON.parse(raw);
|
|
9
|
+
// Hooks are optional and must not claim ordinary, unmanaged ZCode sessions.
|
|
10
|
+
if (process.env.ZCODE_AGENT_POLICY !== '1') process.exit(0);
|
|
11
|
+
process.stdout.write(`${JSON.stringify(createAgentFileHookOutput(evaluateAgentFileInput(input)))}\n`);
|
|
12
|
+
} catch {
|
|
13
|
+
if (process.env.ZCODE_AGENT_POLICY !== '1') process.exit(0);
|
|
14
|
+
// Never echo input, paths, or parser details: hook failures are denied and redacted.
|
|
15
|
+
process.stdout.write(`${JSON.stringify(createAgentFileHookOutput({
|
|
16
|
+
decision: 'deny',
|
|
17
|
+
reason: 'zcode-agent-file-policy/v1.0.0: hook_internal_error',
|
|
18
|
+
}))}\n`);
|
|
19
|
+
}
|