external-subagent 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/cli/commands/config.mjs +2 -0
- package/cli/commands/tasks.mjs +520 -1
- package/cli/config/schema.mjs +5 -1
- package/cli/constants.mjs +1 -1
- package/cli/main.mjs +12 -4
- package/cli/paths.mjs +62 -0
- package/cli/rpc.mjs +11 -1
- package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
- package/npm/native/darwin-arm64/external-subagentd +0 -0
- package/npm/native/darwin-arm64/payload.json +5 -5
- package/package.json +1 -1
- package/schema/external-subagent-public-api.json +2 -1
package/README.md
CHANGED
|
@@ -91,6 +91,52 @@ stale or missing binaries) and `postpack` runs the static tarball checks. A
|
|
|
91
91
|
development checkout packs the same way (`npm pack` after `cargo` is
|
|
92
92
|
available); see [docs/operations.md](docs/operations.md) for release checks.
|
|
93
93
|
|
|
94
|
+
## Spawn profiles
|
|
95
|
+
|
|
96
|
+
A spawn profile is a named JSON preset for the spawn inputs, stored as one
|
|
97
|
+
JSON file per profile in the `profiles/` directory next to `config.json`
|
|
98
|
+
(`~/Library/Application Support/external-subagent/profiles/` by default;
|
|
99
|
+
`profile list` reports the exact path in use). Every `*.json` file in the
|
|
100
|
+
directory is discovered by its `name` field; the same name defined in two
|
|
101
|
+
files is rejected, and invalid files are reported as warnings and excluded
|
|
102
|
+
from the available set.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"name": "codex-yolo",
|
|
107
|
+
"subagent": "codex",
|
|
108
|
+
"model": "gpt-5",
|
|
109
|
+
"permission_mode": "yolo",
|
|
110
|
+
"developer_instructions": "Always reply in English."
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
| Field | Meaning |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `name` | Required, unique across files; trimmed, non-empty, at most 128 bytes |
|
|
117
|
+
| `subagent` | `zcode`, `dsh`, `codex`, or `agy` |
|
|
118
|
+
| `model` | Model token, named per target: `dsh` wants `provider:model`, `zcode` accepts `provider/model` or a bare token, `codex` and `agy` take a bare slug |
|
|
119
|
+
| `effort` | Effort token; some runtime models require one (for example `agy` rejects `gemini-3.8-flash` without `--effort`) and the spawn error surfaces the runtime's message verbatim |
|
|
120
|
+
| `permission_mode` | `build`, `edit`, `plan`, or `yolo`; defaults to `build` when unset |
|
|
121
|
+
| `developer_instructions` | Free-form instructions, delivered as described below |
|
|
122
|
+
|
|
123
|
+
`--profile` cannot be combined with `--subagent`, `--model`, `--effort`, or
|
|
124
|
+
`--permission_mode`; put those in the profile JSON or omit `profile`. Fields
|
|
125
|
+
left out of the profile fall back to the same defaults as an unprofiled
|
|
126
|
+
spawn. `developer_instructions` rides the codex runtime's native
|
|
127
|
+
`developerInstructions` channel when the effective subagent is `codex`; every
|
|
128
|
+
other subagent receives it silently concatenated in front of the prompt:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
Developer Instructions: {developer_instructions}
|
|
132
|
+
----------
|
|
133
|
+
{prompt}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Inspect what is loaded with `external-subagent profile list` and
|
|
137
|
+
`external-subagent profile show <name>`; an empty `profile list` result
|
|
138
|
+
carries a `hint` field with this format summary.
|
|
139
|
+
|
|
94
140
|
## Publishing
|
|
95
141
|
|
|
96
142
|
Releases publish from CI via npm Trusted Publishing (OIDC): pushing a `vX.Y.Z`
|
package/cli/commands/config.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { launchAgentPlist } from '../install/service-macos.mjs';
|
|
|
9
9
|
const CONFIG_INPUT_FIELDS = new Set(['operation', 'patch', 'key']);
|
|
10
10
|
const GET_KEYS = new Set([
|
|
11
11
|
'default_subagent',
|
|
12
|
+
'runtime_process_model',
|
|
12
13
|
...SUBAGENT_IDS.flatMap((agent) => [`subagents.${agent}.enabled`, `subagents.${agent}.spawn_supported`, `subagents.${agent}.default_model`, `subagents.${agent}.runtime_path`, `subagents.${agent}.home`, `subagents.${agent}.profile`, `subagents.${agent}.version`]),
|
|
13
14
|
]);
|
|
14
15
|
const SET_KEYS = GET_KEYS;
|
|
@@ -37,6 +38,7 @@ function patchFor(key, value) {
|
|
|
37
38
|
function unsetPatch(key) {
|
|
38
39
|
if (!SET_KEYS.has(key)) throw new CliError('INVALID_ARGUMENT', `unsupported config key: ${key}`, 2);
|
|
39
40
|
if (key === 'default_subagent') return { default_subagent: null };
|
|
41
|
+
if (key === 'runtime_process_model') return { runtime_process_model: 'auto' };
|
|
40
42
|
const [, agent, field] = key.split('.');
|
|
41
43
|
const defaults = {
|
|
42
44
|
enabled: false,
|
package/cli/commands/tasks.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import { CliError } from '../errors.mjs';
|
|
3
|
+
import { profilesDir } from '../paths.mjs';
|
|
2
4
|
|
|
3
|
-
const SPAWN_FIELDS = new Set(['subagent', 'repository', 'permission_mode', 'prompt', 'model', 'effort', 'write_manifest']);
|
|
5
|
+
const SPAWN_FIELDS = new Set(['profile', 'subagent', 'repository', 'permission_mode', 'prompt', 'model', 'effort', 'write_manifest']);
|
|
4
6
|
const FLAG_FIELDS = new Map([
|
|
7
|
+
['--profile', 'profile'],
|
|
5
8
|
['--subagent', 'subagent'],
|
|
6
9
|
['--repository', 'repository'],
|
|
7
10
|
['--prompt', 'prompt'],
|
|
@@ -10,6 +13,8 @@ const FLAG_FIELDS = new Map([
|
|
|
10
13
|
['--effort', 'effort'],
|
|
11
14
|
]);
|
|
12
15
|
|
|
16
|
+
const PROFILE_MUTEX_ERROR = 'profile cannot be combined with subagent, permission_mode, model, or effort; specify these in the profile JSON or omit profile';
|
|
17
|
+
|
|
13
18
|
function flagValue(args, option) {
|
|
14
19
|
const value = args.shift();
|
|
15
20
|
if (value === undefined || value === '' || value.startsWith('--')) {
|
|
@@ -32,6 +37,11 @@ export function parseSpawnArgs(args) {
|
|
|
32
37
|
if (Object.hasOwn(input, field)) throw new CliError('INVALID_ARGUMENT', `${option} may be provided only once`, 2);
|
|
33
38
|
input[field] = flagValue(remaining, option);
|
|
34
39
|
}
|
|
40
|
+
if (Object.hasOwn(input, 'profile')) {
|
|
41
|
+
if (Object.hasOwn(input, 'subagent') || Object.hasOwn(input, 'permission_mode') || Object.hasOwn(input, 'model') || Object.hasOwn(input, 'effort')) {
|
|
42
|
+
throw new CliError('INVALID_ARGUMENT', PROFILE_MUTEX_ERROR, 2);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
35
45
|
if (!Object.hasOwn(input, 'repository')) throw new CliError('INVALID_ARGUMENT', '--repository is required', 2);
|
|
36
46
|
if (!Object.hasOwn(input, 'prompt')) throw new CliError('INVALID_ARGUMENT', '--prompt is required', 2);
|
|
37
47
|
return prepareSpawnInput(input);
|
|
@@ -42,6 +52,17 @@ export function prepareSpawnInput(input) {
|
|
|
42
52
|
for (const key of Object.keys(input)) {
|
|
43
53
|
if (!SPAWN_FIELDS.has(key)) throw new CliError('INVALID_ARGUMENT', `spawn contains unsupported field: ${key}`, 2);
|
|
44
54
|
}
|
|
55
|
+
if (Object.prototype.hasOwnProperty.call(input, 'profile') && input.profile === null) {
|
|
56
|
+
throw new CliError('INVALID_ARGUMENT', 'profile must be omitted or a non-null profile name; null is invalid', 2);
|
|
57
|
+
}
|
|
58
|
+
if (input.profile !== undefined && (typeof input.profile !== 'string' || input.profile.length === 0)) {
|
|
59
|
+
throw new CliError('INVALID_ARGUMENT', 'profile must be a non-empty string', 2);
|
|
60
|
+
}
|
|
61
|
+
if (input.profile !== undefined) {
|
|
62
|
+
if (input.subagent !== undefined || input.permission_mode !== undefined || input.model !== undefined || input.effort !== undefined) {
|
|
63
|
+
throw new CliError('INVALID_ARGUMENT', PROFILE_MUTEX_ERROR, 2);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
45
66
|
if (Object.prototype.hasOwnProperty.call(input, 'subagent') && input.subagent === null) {
|
|
46
67
|
throw new CliError('INVALID_ARGUMENT', 'subagent must be omitted or a supported subagent id; null is invalid', 2);
|
|
47
68
|
}
|
|
@@ -62,3 +83,501 @@ export function prepareSpawnInput(input) {
|
|
|
62
83
|
}
|
|
63
84
|
return { ...input };
|
|
64
85
|
}
|
|
86
|
+
|
|
87
|
+
// Rust `str::trim` strips the Unicode White_Space property (which includes
|
|
88
|
+
// U+0085 NEL and U+00A0 NBSP) but NOT U+FEFF, whereas JavaScript `String.trim`
|
|
89
|
+
// strips U+FEFF and leaves U+0085. Name identity must match the daemon's
|
|
90
|
+
// `raw.name.trim()`, so trim against Rust's exact character set.
|
|
91
|
+
const RUST_WHITESPACE = /[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/u;
|
|
92
|
+
|
|
93
|
+
function trimRust(value) {
|
|
94
|
+
let start = 0;
|
|
95
|
+
let end = value.length;
|
|
96
|
+
while (start < end && RUST_WHITESPACE.test(value[start])) start += 1;
|
|
97
|
+
while (end > start && RUST_WHITESPACE.test(value[end - 1])) end -= 1;
|
|
98
|
+
return value.slice(start, end);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A profile file is strict JSON, and the scanner must reproduce the daemon's
|
|
102
|
+
// `serde_json` decisions. `JSON.parse` differs from serde_json on three points
|
|
103
|
+
// that matter here, so the raw document text is validated *before* parsing.
|
|
104
|
+
// Validating the text (rather than the collapsed object) makes duplicate-key
|
|
105
|
+
// shadowing structurally irrelevant: a shadowed bad value is still visible.
|
|
106
|
+
// 1. lone surrogate escapes (`"\uD800"`) are accepted by `JSON.parse` but
|
|
107
|
+
// rejected by serde_json, so every `\u` escape in a string is paired here
|
|
108
|
+
// (high D800-DBFF immediately followed by low DC00-DFFF) and an unpaired
|
|
109
|
+
// escape rejects the whole document;
|
|
110
|
+
// 2. `JSON.parse` accepts lossless integer literals of any magnitude, while
|
|
111
|
+
// serde_json parses an integer that does not fit `u64`/`i64` as an `f64`
|
|
112
|
+
// and rejects the document only when that conversion is infinite (de.rs
|
|
113
|
+
// `parse_exponent_overflow` / `f64_long_from_parts`); every number literal
|
|
114
|
+
// outside a string is therefore rejected iff `Number(token)` is not finite.
|
|
115
|
+
// This uses correctly-rounded IEEE rounding; serde_json's default
|
|
116
|
+
// accumulator can round to infinity within one ulp of `f64::MAX` (a
|
|
117
|
+
// recorded residual, see S02-HANDOFF).
|
|
118
|
+
// 3. `JSON.parse` has no container-depth limit (it is iterative), while
|
|
119
|
+
// serde_json starts with `remaining_depth = 128` (de.rs:63) and
|
|
120
|
+
// `check_recursion` decrements before descending and errors when it reaches
|
|
121
|
+
// zero (de.rs:1372-1387); the 128th nested `{`/`[` is rejected, so at most
|
|
122
|
+
// 127 containers may nest.
|
|
123
|
+
// A leading UTF-8 BOM is also stripped by `TextDecoder` unless `ignoreBOM` is
|
|
124
|
+
// set, and serde_json rejects a BOM; `scanProfilesDir` therefore preserves the
|
|
125
|
+
// BOM so that `JSON.parse` fails exactly like serde_json.
|
|
126
|
+
const PROFILE_FIELDS = ['name', 'subagent', 'permission_mode', 'model', 'effort', 'developer_instructions'];
|
|
127
|
+
|
|
128
|
+
// Match a complete JSON number literal (RFC 8259) at the sticky lastIndex.
|
|
129
|
+
const JSON_NUMBER = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y;
|
|
130
|
+
|
|
131
|
+
function decodeHex4(text, index) {
|
|
132
|
+
const hex = text.slice(index, index + 4);
|
|
133
|
+
if (!/^[0-9a-fA-F]{4}$/u.test(hex)) return null;
|
|
134
|
+
return parseInt(hex, 16);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Validate the raw JSON text against serde_json's lexical rules and recursion
|
|
138
|
+
// limit that `JSON.parse` does not enforce. Returns a diagnostic fragment, or
|
|
139
|
+
// null when the text is acceptable. Structural syntax errors are left to
|
|
140
|
+
// `JSON.parse`; this walk only needs to track string/escape state so that
|
|
141
|
+
// braces, digits and `\u` escapes are interpreted in the right context.
|
|
142
|
+
function rawJsonDiagnostic(text) {
|
|
143
|
+
let inString = false;
|
|
144
|
+
let depth = 0;
|
|
145
|
+
let i = 0;
|
|
146
|
+
while (i < text.length) {
|
|
147
|
+
const ch = text[i];
|
|
148
|
+
if (inString) {
|
|
149
|
+
if (ch === '"') {
|
|
150
|
+
inString = false;
|
|
151
|
+
i += 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === '\\') {
|
|
155
|
+
const esc = text[i + 1];
|
|
156
|
+
if (esc === 'u') {
|
|
157
|
+
const code = decodeHex4(text, i + 2);
|
|
158
|
+
if (code === null) {
|
|
159
|
+
// Malformed escape: let JSON.parse report the syntax error.
|
|
160
|
+
i += 2;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
164
|
+
let paired = false;
|
|
165
|
+
if (text[i + 6] === '\\' && text[i + 7] === 'u') {
|
|
166
|
+
const low = decodeHex4(text, i + 8);
|
|
167
|
+
paired = low !== null && low >= 0xdc00 && low <= 0xdfff;
|
|
168
|
+
}
|
|
169
|
+
if (!paired) return 'lone surrogate code point';
|
|
170
|
+
i += 12; // consume both escapes of a valid surrogate pair
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (code >= 0xdc00 && code <= 0xdfff) return 'lone surrogate code point';
|
|
174
|
+
i += 6;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
// \" \\ \/ \b \f \n \r \t: an escaped backslash never starts a `\u`.
|
|
178
|
+
i += 2;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (ch === '"') {
|
|
185
|
+
inString = true;
|
|
186
|
+
i += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (ch === '{' || ch === '[') {
|
|
190
|
+
depth += 1;
|
|
191
|
+
if (depth >= 128) return 'recursion limit exceeded';
|
|
192
|
+
i += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (ch === '}' || ch === ']') {
|
|
196
|
+
if (depth > 0) depth -= 1;
|
|
197
|
+
i += 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (ch === '-' || (ch >= '0' && ch <= '9')) {
|
|
201
|
+
JSON_NUMBER.lastIndex = i;
|
|
202
|
+
const match = JSON_NUMBER.exec(text);
|
|
203
|
+
if (match !== null) {
|
|
204
|
+
if (!Number.isFinite(Number(match[0]))) return `number out of range: ${match[0]}`;
|
|
205
|
+
i += match[0].length;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
i += 1;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A decoded astral character is a legitimate UTF-16 surrogate pair in a JS
|
|
215
|
+
// string; an unpaired code unit is a lone surrogate.
|
|
216
|
+
function containsLoneSurrogate(text) {
|
|
217
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
218
|
+
const code = text.charCodeAt(i);
|
|
219
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
220
|
+
const next = text.charCodeAt(i + 1);
|
|
221
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
|
|
222
|
+
i += 1; // consume the matching low surrogate of a valid pair
|
|
223
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Iterative traversal (never recursive) so an arbitrarily deep decoded value
|
|
231
|
+
// can never raise a `RangeError`; the raw depth gate already rejects documents
|
|
232
|
+
// deeper than serde_json's limit before `JSON.parse` runs.
|
|
233
|
+
function hasLoneSurrogate(value) {
|
|
234
|
+
const stack = [value];
|
|
235
|
+
while (stack.length > 0) {
|
|
236
|
+
const current = stack.pop();
|
|
237
|
+
if (typeof current === 'string') {
|
|
238
|
+
if (containsLoneSurrogate(current)) return true;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (Array.isArray(current)) {
|
|
242
|
+
for (const entry of current) stack.push(entry);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (current !== null && typeof current === 'object') {
|
|
246
|
+
for (const [key, entry] of Object.entries(current)) {
|
|
247
|
+
if (containsLoneSurrogate(key)) return true;
|
|
248
|
+
stack.push(entry);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function invalidJson(diagnostic) {
|
|
256
|
+
return { name: null, rawName: null, valid: false, syntaxValid: false, errors: [`invalid JSON: ${diagnostic}`] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Scan one profile document. `name` is the authoritative owner identity used
|
|
260
|
+
// for duplicate detection and mirrors the daemon's `loose_top_level_name`: a
|
|
261
|
+
// document that parses as JSON, has a plain-object top level, and a string
|
|
262
|
+
// `name` owns that name (trimmed, non-empty, at most 128 bytes, no NUL) even
|
|
263
|
+
// when the profile shape is otherwise invalid. `valid` additionally requires the
|
|
264
|
+
// canonical `RawProfile` shape (only the six known fields; a string `name`; each
|
|
265
|
+
// optional field a string or null) and a usable name. Field *value* validation
|
|
266
|
+
// (for example the permission_mode enum) is deliberately left to the daemon.
|
|
267
|
+
export function scanProfileJson(content, filePath = '<unknown>') {
|
|
268
|
+
const rawDiagnostic = rawJsonDiagnostic(content);
|
|
269
|
+
if (rawDiagnostic !== null) return invalidJson(`${rawDiagnostic} in ${filePath}`);
|
|
270
|
+
|
|
271
|
+
let value;
|
|
272
|
+
try {
|
|
273
|
+
value = JSON.parse(content);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
return invalidJson(err.message);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (hasLoneSurrogate(value)) {
|
|
279
|
+
return invalidJson(`lone surrogate code point in ${filePath}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const isObject = value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
283
|
+
|
|
284
|
+
let name = null;
|
|
285
|
+
if (isObject && typeof value.name === 'string') {
|
|
286
|
+
const raw = value.name;
|
|
287
|
+
const trimmed = trimRust(raw);
|
|
288
|
+
if (trimmed.length > 0 && Buffer.byteLength(raw, 'utf8') <= 128 && !raw.includes('\0')) {
|
|
289
|
+
name = trimmed;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const errors = [];
|
|
294
|
+
let shapeValid = true;
|
|
295
|
+
|
|
296
|
+
if (!isObject) {
|
|
297
|
+
errors.push('top-level value must be an object');
|
|
298
|
+
shapeValid = false;
|
|
299
|
+
} else {
|
|
300
|
+
for (const key of Object.keys(value)) {
|
|
301
|
+
if (!PROFILE_FIELDS.includes(key)) {
|
|
302
|
+
errors.push(`unknown top-level key '${key}'`);
|
|
303
|
+
shapeValid = false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!Object.hasOwn(value, 'name')) {
|
|
307
|
+
errors.push("missing required field 'name'");
|
|
308
|
+
shapeValid = false;
|
|
309
|
+
} else if (typeof value.name !== 'string') {
|
|
310
|
+
errors.push("field 'name': must be a string");
|
|
311
|
+
shapeValid = false;
|
|
312
|
+
}
|
|
313
|
+
for (const key of PROFILE_FIELDS) {
|
|
314
|
+
if (key === 'name' || !Object.hasOwn(value, key)) continue;
|
|
315
|
+
const fieldValue = value[key];
|
|
316
|
+
if (fieldValue !== null && typeof fieldValue !== 'string') {
|
|
317
|
+
errors.push(`field '${key}': must be a string`);
|
|
318
|
+
shapeValid = false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (shapeValid && name === null && typeof value.name === 'string') {
|
|
322
|
+
const raw = value.name;
|
|
323
|
+
if (trimRust(raw).length === 0) errors.push("field 'name': cannot be empty");
|
|
324
|
+
else if (Buffer.byteLength(raw, 'utf8') > 128) errors.push("field 'name': exceeds 128 bytes");
|
|
325
|
+
else errors.push("field 'name': cannot contain NUL byte");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { name, rawName: name, valid: shapeValid && name !== null, syntaxValid: true, errors };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Rust `Path::extension()` semantics for candidate selection: the extension is
|
|
333
|
+
// derived from the file name only and is absent when the name has no dot or
|
|
334
|
+
// begins with a dot and has no further dot, so a file named exactly ".json" is
|
|
335
|
+
// not a JSON candidate. Comparison stays case-sensitive.
|
|
336
|
+
function rustExtension(fileName) {
|
|
337
|
+
const dot = fileName.lastIndexOf('.');
|
|
338
|
+
if (dot <= 0) return null;
|
|
339
|
+
return fileName.slice(dot + 1);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Build a child path by string concatenation, never `path.join`, which
|
|
343
|
+
// lexically folds `..` away. Mirrors Rust `Path::join` for the shapes produced
|
|
344
|
+
// by `profilesDir` (an empty directory yields the bare name).
|
|
345
|
+
function joinChild(dir, name) {
|
|
346
|
+
if (dir === '') return name;
|
|
347
|
+
if (dir.endsWith('/')) return dir + name;
|
|
348
|
+
return `${dir}/${name}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function scanProfilesDir(dir) {
|
|
352
|
+
const profiles = new Map();
|
|
353
|
+
const fileErrors = [];
|
|
354
|
+
const nameOwners = new Map();
|
|
355
|
+
|
|
356
|
+
if (!fs.existsSync(dir)) {
|
|
357
|
+
return {
|
|
358
|
+
exists: false,
|
|
359
|
+
directory: dir,
|
|
360
|
+
profiles,
|
|
361
|
+
availableNames: [],
|
|
362
|
+
warnings: fileErrors,
|
|
363
|
+
duplicates: new Map(),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let entries = [];
|
|
368
|
+
try {
|
|
369
|
+
const dirEntries = fs.readdirSync(dir, { withFileTypes: true });
|
|
370
|
+
for (const entry of dirEntries) {
|
|
371
|
+
// Match the daemon's candidate selection exactly: `Path::extension()`
|
|
372
|
+
// yields "json" (case-sensitive) from the file name, so a file named
|
|
373
|
+
// exactly ".json" has no extension and is never a candidate.
|
|
374
|
+
if (rustExtension(entry.name) !== 'json') continue;
|
|
375
|
+
const full = joinChild(dir, entry.name);
|
|
376
|
+
// The daemon selects candidates with `Path::is_file()`, which follows
|
|
377
|
+
// symbolic links; `Dirent.isFile()` does not. Use stat, and silently
|
|
378
|
+
// skip entries that stat cannot resolve (e.g. dangling links), matching
|
|
379
|
+
// `is_file() == false`.
|
|
380
|
+
let stat;
|
|
381
|
+
try {
|
|
382
|
+
stat = fs.statSync(full);
|
|
383
|
+
} catch {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (stat.isFile()) entries.push(full);
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
fileErrors.push({
|
|
390
|
+
file: dir,
|
|
391
|
+
diagnostic: `profile directory '${dir}' is unreadable: ${err.message}`,
|
|
392
|
+
});
|
|
393
|
+
return {
|
|
394
|
+
exists: true,
|
|
395
|
+
directory: dir,
|
|
396
|
+
profiles,
|
|
397
|
+
availableNames: [],
|
|
398
|
+
warnings: fileErrors,
|
|
399
|
+
duplicates: new Map(),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
entries.sort();
|
|
404
|
+
|
|
405
|
+
const candidates = [];
|
|
406
|
+
|
|
407
|
+
for (const filePath of entries) {
|
|
408
|
+
let content;
|
|
409
|
+
try {
|
|
410
|
+
// Read raw bytes and decode strictly: the daemon uses
|
|
411
|
+
// `fs::read_to_string`, which fails on invalid UTF-8 and skips the file
|
|
412
|
+
// without registering a name owner. `readFileSync(path, 'utf8')` would
|
|
413
|
+
// instead substitute U+FFFD and wrongly accept the file. `ignoreBOM: true`
|
|
414
|
+
// keeps a leading BOM in the decoded string so `JSON.parse` rejects it,
|
|
415
|
+
// matching serde_json; the default decoder would silently strip it.
|
|
416
|
+
const bytes = fs.readFileSync(filePath);
|
|
417
|
+
content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
fileErrors.push({
|
|
420
|
+
file: filePath,
|
|
421
|
+
diagnostic: `profile file '${filePath}' is unreadable: ${err.message}`,
|
|
422
|
+
});
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const res = scanProfileJson(content, filePath);
|
|
427
|
+
if (res.name) {
|
|
428
|
+
if (!nameOwners.has(res.name)) nameOwners.set(res.name, []);
|
|
429
|
+
nameOwners.get(res.name).push(filePath);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (res.valid) {
|
|
433
|
+
candidates.push({ name: res.name, filePath, content });
|
|
434
|
+
} else {
|
|
435
|
+
fileErrors.push({
|
|
436
|
+
file: filePath,
|
|
437
|
+
profileName: res.name || null,
|
|
438
|
+
diagnostic: `profile file '${filePath}' is invalid: ${res.errors.join('; ')}`,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const candidate of candidates) {
|
|
444
|
+
const owners = nameOwners.get(candidate.name) || [];
|
|
445
|
+
if (owners.length > 1) {
|
|
446
|
+
const other = owners.find((p) => p !== candidate.filePath);
|
|
447
|
+
fileErrors.push({
|
|
448
|
+
file: candidate.filePath,
|
|
449
|
+
profileName: candidate.name,
|
|
450
|
+
diagnostic: `profile file '${candidate.filePath}' is invalid: duplicate profile name '${candidate.name}' already defined in '${other}'`,
|
|
451
|
+
});
|
|
452
|
+
} else {
|
|
453
|
+
profiles.set(candidate.name, candidate);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
for (const [name, owners] of nameOwners.entries()) {
|
|
458
|
+
if (owners.length > 1) {
|
|
459
|
+
for (const owner of owners) {
|
|
460
|
+
const hasDupDiag = fileErrors.some((w) => w.file === owner && w.diagnostic.includes('duplicate profile name'));
|
|
461
|
+
if (!hasDupDiag) {
|
|
462
|
+
const other = owners.find((p) => p !== owner);
|
|
463
|
+
fileErrors.push({
|
|
464
|
+
file: owner,
|
|
465
|
+
profileName: name,
|
|
466
|
+
diagnostic: `profile file '${owner}' is invalid: duplicate profile name '${name}' already defined in '${other}'`,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const availableNames = Array.from(profiles.keys()).sort();
|
|
474
|
+
const duplicates = new Map([...nameOwners.entries()].filter(([, v]) => v.length > 1));
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
exists: true,
|
|
478
|
+
directory: dir,
|
|
479
|
+
profiles,
|
|
480
|
+
availableNames,
|
|
481
|
+
warnings: fileErrors,
|
|
482
|
+
duplicates,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function parseProfileArgs(args) {
|
|
487
|
+
if (!args || args.length === 0) throw new CliError('INVALID_ARGUMENT', 'usage: profile list | profile show <name>', 2);
|
|
488
|
+
const [operation, ...rest] = args;
|
|
489
|
+
if (operation === 'list') {
|
|
490
|
+
if (rest.length !== 0) throw new CliError('INVALID_ARGUMENT', 'usage: profile list', 2);
|
|
491
|
+
return { operation: 'list' };
|
|
492
|
+
}
|
|
493
|
+
if (operation === 'show') {
|
|
494
|
+
if (rest.length !== 1 || !rest[0] || rest[0].startsWith('--')) {
|
|
495
|
+
throw new CliError('INVALID_ARGUMENT', 'usage: profile show <name>', 2);
|
|
496
|
+
}
|
|
497
|
+
return { operation: 'show', name: rest[0] };
|
|
498
|
+
}
|
|
499
|
+
throw new CliError('INVALID_ARGUMENT', `unsupported profile operation: ${operation}`, 2);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Attached to `profile list` only when no usable profile exists, so a fresh
|
|
503
|
+
// installation sees the format reference instead of an unexplained empty list.
|
|
504
|
+
const EMPTY_PROFILES_HINT = 'no profiles found; create one JSON file per profile in this directory, '
|
|
505
|
+
+ 'e.g. {"name":"codex-yolo","subagent":"codex","model":"gpt-5","permission_mode":"yolo"}; '
|
|
506
|
+
+ 'fields: name (required), subagent, model, effort, permission_mode (build|edit|plan|yolo), '
|
|
507
|
+
+ 'developer_instructions; see the "Spawn profiles" section of README.md';
|
|
508
|
+
|
|
509
|
+
export function profileCommand(paths, inputOrArgs, env = process.env) {
|
|
510
|
+
const input = Array.isArray(inputOrArgs) ? parseProfileArgs(inputOrArgs) : inputOrArgs;
|
|
511
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
512
|
+
throw new CliError('INVALID_ARGUMENT', 'profile input must be an object', 2);
|
|
513
|
+
}
|
|
514
|
+
// Resolution is a pure function of the injected paths/env; there is no
|
|
515
|
+
// test-only production branch keyed on os.homedir().
|
|
516
|
+
const dir = profilesDir(env, paths?.home);
|
|
517
|
+
|
|
518
|
+
if (input.operation === 'list') {
|
|
519
|
+
if (dir === null) {
|
|
520
|
+
return {
|
|
521
|
+
operation: 'list',
|
|
522
|
+
directory: null,
|
|
523
|
+
profiles: [],
|
|
524
|
+
warnings: [],
|
|
525
|
+
message: 'profiles are disabled: the configuration path environment variable is exported empty',
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const scanned = scanProfilesDir(dir);
|
|
529
|
+
if (!scanned.exists) {
|
|
530
|
+
return {
|
|
531
|
+
operation: 'list',
|
|
532
|
+
directory: dir,
|
|
533
|
+
profiles: [],
|
|
534
|
+
warnings: [],
|
|
535
|
+
message: `profiles directory does not exist: ${dir}`,
|
|
536
|
+
hint: EMPTY_PROFILES_HINT,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
operation: 'list',
|
|
541
|
+
directory: dir,
|
|
542
|
+
profiles: scanned.availableNames,
|
|
543
|
+
warnings: scanned.warnings.map((w) => ({ file: w.file, diagnostic: w.diagnostic })),
|
|
544
|
+
...(scanned.availableNames.length === 0 ? { hint: EMPTY_PROFILES_HINT } : {}),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (input.operation === 'show') {
|
|
549
|
+
const name = input.name;
|
|
550
|
+
if (!name || typeof name !== 'string') throw new CliError('INVALID_ARGUMENT', 'usage: profile show <name>', 2);
|
|
551
|
+
if (dir === null) throw new CliError('INVALID_ARGUMENT', `profile '${name}' not found; available profiles: none`, 2);
|
|
552
|
+
const scanned = scanProfilesDir(dir);
|
|
553
|
+
if (!scanned.exists) {
|
|
554
|
+
throw new CliError('INVALID_ARGUMENT', `profile '${name}' not found; available profiles: none`, 2);
|
|
555
|
+
}
|
|
556
|
+
if (scanned.duplicates.has(name)) {
|
|
557
|
+
const files = scanned.duplicates.get(name);
|
|
558
|
+
throw new CliError('INVALID_ARGUMENT', `profile '${name}' is ambiguous; defined in multiple files: ${files.join(', ')}`, 2);
|
|
559
|
+
}
|
|
560
|
+
if (scanned.profiles.has(name)) {
|
|
561
|
+
const profile = scanned.profiles.get(name);
|
|
562
|
+
return {
|
|
563
|
+
operation: 'show',
|
|
564
|
+
name,
|
|
565
|
+
file: profile.filePath,
|
|
566
|
+
content: profile.content,
|
|
567
|
+
warnings: scanned.warnings.map((w) => ({ file: w.file, diagnostic: w.diagnostic })),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
const availableList = scanned.availableNames.length > 0 ? `[${scanned.availableNames.join(', ')}]` : 'none';
|
|
571
|
+
const matchedInvalid = scanned.warnings.find((w) => w.profileName === name);
|
|
572
|
+
if (matchedInvalid) {
|
|
573
|
+
throw new CliError('INVALID_ARGUMENT', `${matchedInvalid.diagnostic}; available profiles: ${availableList}`, 2);
|
|
574
|
+
}
|
|
575
|
+
if (scanned.warnings.length > 0) {
|
|
576
|
+
const details = scanned.warnings.map((w) => w.diagnostic).join('; ');
|
|
577
|
+
throw new CliError('INVALID_ARGUMENT', `profile '${name}' not found (directory contains invalid files: ${details}); available profiles: ${availableList}`, 2);
|
|
578
|
+
}
|
|
579
|
+
throw new CliError('INVALID_ARGUMENT', `profile '${name}' not found; available profiles: ${availableList}`, 2);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
throw new CliError('INVALID_ARGUMENT', `unsupported profile operation: ${input.operation}`, 2);
|
|
583
|
+
}
|
package/cli/config/schema.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import { CliError } from '../errors.mjs';
|
|
|
2
2
|
|
|
3
3
|
export const CONFIG_SCHEMA_VERSION = 2;
|
|
4
4
|
export const SUBAGENT_IDS = Object.freeze(['zcode', 'dsh', 'codex', 'agy']);
|
|
5
|
-
const
|
|
5
|
+
export const RUNTIME_PROCESS_MODELS = Object.freeze(['auto', 'detached', 'attached']);
|
|
6
|
+
const CONFIG_FIELDS = new Set(['schema_version', 'revision', 'default_subagent', 'subagents', 'runtime_process_model']);
|
|
6
7
|
const AGENT_FIELDS = new Set(['enabled', 'spawn_supported', 'default_model', 'runtime_path', 'home', 'profile', 'version']);
|
|
7
8
|
|
|
8
9
|
function plainObject(value) {
|
|
@@ -20,6 +21,7 @@ export function defaultConfig() {
|
|
|
20
21
|
schema_version: CONFIG_SCHEMA_VERSION,
|
|
21
22
|
revision: 0,
|
|
22
23
|
default_subagent: null,
|
|
24
|
+
runtime_process_model: 'auto',
|
|
23
25
|
subagents: {
|
|
24
26
|
zcode: { enabled: false, spawn_supported: false, default_model: null },
|
|
25
27
|
dsh: { enabled: false, spawn_supported: false, default_model: null, runtime_path: null, home: null, profile: null, version: null },
|
|
@@ -52,8 +54,10 @@ export function validateConfig(input) {
|
|
|
52
54
|
if (input.schema_version !== undefined && input.schema_version !== CONFIG_SCHEMA_VERSION) throw new CliError('CONFIG_INVALID', 'unsupported config schema version', 2);
|
|
53
55
|
if (input.revision !== undefined && (!Number.isInteger(input.revision) || input.revision < 0)) throw new CliError('CONFIG_INVALID', 'revision must be a non-negative integer', 2);
|
|
54
56
|
if (input.default_subagent !== undefined && input.default_subagent !== null && !SUBAGENT_IDS.includes(input.default_subagent)) throw new CliError('CONFIG_INVALID', 'default_subagent is unknown', 2);
|
|
57
|
+
if (input.runtime_process_model !== undefined && !RUNTIME_PROCESS_MODELS.includes(input.runtime_process_model)) throw new CliError('CONFIG_INVALID', `runtime_process_model must be one of ${RUNTIME_PROCESS_MODELS.join(', ')}`, 2);
|
|
55
58
|
config.revision = input.revision ?? 0;
|
|
56
59
|
config.default_subagent = input.default_subagent ?? null;
|
|
60
|
+
config.runtime_process_model = input.runtime_process_model ?? 'auto';
|
|
57
61
|
if (input.subagents !== undefined && !plainObject(input.subagents)) throw new CliError('CONFIG_INVALID', 'subagents must be an object', 2);
|
|
58
62
|
for (const agent of Object.keys(input.subagents || {})) {
|
|
59
63
|
if (!SUBAGENT_IDS.includes(agent)) throw new CliError('CONFIG_INVALID', `subagents contains unknown subagent: ${agent}`, 2);
|
package/cli/constants.mjs
CHANGED
|
@@ -8,7 +8,7 @@ const IS_DEBUG_VARIANT = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug';
|
|
|
8
8
|
|
|
9
9
|
export const PRODUCT_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug' : 'external-subagent';
|
|
10
10
|
export const PRODUCT_ID = IS_DEBUG_VARIANT ? 'external_subagent_debug' : 'external_subagent';
|
|
11
|
-
export const VERSION = '0.
|
|
11
|
+
export const VERSION = '0.4.1';
|
|
12
12
|
export const LAUNCH_AGENT_LABEL = IS_DEBUG_VARIANT ? 'com.external-subagent-debug.daemon' : 'com.external-subagent.daemon';
|
|
13
13
|
export const DAEMON_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debugd' : 'external-subagentd';
|
|
14
14
|
export const MCP_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug-mcp' : 'external-subagent-mcp';
|
package/cli/main.mjs
CHANGED
|
@@ -15,10 +15,10 @@ import { callDaemon, parseDaemonInput } from './rpc.mjs';
|
|
|
15
15
|
import { configCommand } from './commands/config.mjs';
|
|
16
16
|
import { parseConfigArgs } from './commands/config.mjs';
|
|
17
17
|
import { subagentsCommand, parseSubagentsArgs } from './commands/agents.mjs';
|
|
18
|
-
import { parseSpawnArgs, prepareSpawnInput } from './commands/tasks.mjs';
|
|
18
|
+
import { parseProfileArgs, parseSpawnArgs, prepareSpawnInput, profileCommand } from './commands/tasks.mjs';
|
|
19
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`;
|
|
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`;
|
|
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 profile list | profile show <name>\n Inspect global spawn profiles (profiles/*.json);\n invalid files are reported as path-based warnings,\n and warned or duplicate files are excluded from the\n available name set\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`;
|
|
21
|
+
const DAEMON_HELP = ` config get [key] | config set <key> <value>\n subagents list | subagents status [subagent] | subagents probe/models [subagent]\n create/spawn [--profile <name>], 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
22
|
|
|
23
23
|
function structuredInput(args, parser) {
|
|
24
24
|
return args.length > 0 && !args[0].startsWith('--') ? parser(args) : parseDaemonInput(args);
|
|
@@ -331,7 +331,7 @@ export async function main(args) {
|
|
|
331
331
|
if (command === 'version' || command === '--version' || command === '-v') {
|
|
332
332
|
process.stdout.write(`${VERSION}\n`); return;
|
|
333
333
|
}
|
|
334
|
-
if (!BUSINESS_COMMANDS.has(command)) throw new CliError('UNKNOWN_COMMAND', `unknown command: ${command}`, 2);
|
|
334
|
+
if (command !== 'profile' && !BUSINESS_COMMANDS.has(command)) throw new CliError('UNKNOWN_COMMAND', `unknown command: ${command}`, 2);
|
|
335
335
|
if (platform() !== 'darwin') throw new CliError('UNSUPPORTED_PLATFORM', `${command} is supported only on macOS`);
|
|
336
336
|
|
|
337
337
|
const paths = productPaths();
|
|
@@ -387,6 +387,14 @@ export async function main(args) {
|
|
|
387
387
|
}
|
|
388
388
|
return;
|
|
389
389
|
}
|
|
390
|
+
if (command === 'profile') {
|
|
391
|
+
const profileArgs = args.slice(1);
|
|
392
|
+
const input = profileArgs.some((arg) => arg === '--json' || arg.startsWith('--json='))
|
|
393
|
+
? parseDaemonInput(profileArgs)
|
|
394
|
+
: parseProfileArgs(profileArgs);
|
|
395
|
+
output(profileCommand(paths, input));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
390
398
|
if (command === 'config' || command === 'subagents' || command === 'agents') {
|
|
391
399
|
const input = structuredInput(args.slice(1), command === 'config' ? parseConfigArgs : parseSubagentsArgs);
|
|
392
400
|
output(command === 'config'
|
package/cli/paths.mjs
CHANGED
|
@@ -24,5 +24,67 @@ export function productPaths(home = os.homedir()) {
|
|
|
24
24
|
launchAgent: path.join(home, 'Library', 'LaunchAgents', `${LAUNCH_AGENT_LABEL}.plist`),
|
|
25
25
|
zcodeConfig: path.join(home, '.zcode', 'cli', 'config.json'),
|
|
26
26
|
zcodePlugin: path.join(data, 'zcode-plugin', PLUGIN_NAME),
|
|
27
|
+
profiles: path.join(data, 'profiles'),
|
|
27
28
|
};
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
// Mirror the daemon's `profiles_directory()` exactly (rpc/profiles.rs):
|
|
32
|
+
// `EXTERNAL_SUBAGENT_CONFIG` wins when it is *exported*, even when exported as
|
|
33
|
+
// an empty string, and only then does `ZCODE_AGENT_CONFIG` apply; presence, not
|
|
34
|
+
// truthiness, selects the variable (`var_os(...).or_else(...)`). An empty
|
|
35
|
+
// exported path has no parent in Rust (`Path::new("").parent() == None`), so the
|
|
36
|
+
// daemon loads no profiles directory at all; return null to represent that.
|
|
37
|
+
// Otherwise take the sibling `profiles/` of the config file, or fall back to the
|
|
38
|
+
// product data directory.
|
|
39
|
+
|
|
40
|
+
// Rust `Path::parent()` semantics on Unix, implemented with plain string
|
|
41
|
+
// slicing (never `path.dirname`/`path.join`, which lexically normalise and would
|
|
42
|
+
// fold `..` components away). Rust's `Path::components` drops trailing
|
|
43
|
+
// separators and any `.` component that is not the first, so a trailing `.` is
|
|
44
|
+
// not a component of its own: `a/.` has parent `Some("")`, `a/b/.` has parent
|
|
45
|
+
// `Some("a")`, and `/.` is the root with no parent (`None`). `..` is retained.
|
|
46
|
+
function rustPathParent(value) {
|
|
47
|
+
if (value === '') return null;
|
|
48
|
+
let end = value.length;
|
|
49
|
+
for (;;) {
|
|
50
|
+
while (end > 1 && value[end - 1] === '/') end -= 1;
|
|
51
|
+
const componentStart = value.lastIndexOf('/', end - 1) + 1;
|
|
52
|
+
if (end - componentStart === 1 && value[componentStart] === '.' && componentStart > 0) {
|
|
53
|
+
end = componentStart - 1; // drop the non-leading "." and its separator
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
const trimmed = value.slice(0, end);
|
|
59
|
+
if (trimmed === '') return null; // "/." and friends normalise to the root
|
|
60
|
+
if (/^\/+$/u.test(trimmed)) return null; // root has no parent
|
|
61
|
+
const slash = trimmed.lastIndexOf('/');
|
|
62
|
+
if (slash < 0) return ''; // bare name: parent is Some("")
|
|
63
|
+
if (slash === 0) return '/';
|
|
64
|
+
return trimmed.slice(0, slash);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Rust `Path::parent().join("profiles")` without normalisation: appending to the
|
|
68
|
+
// empty path yields "profiles", and existing `..`/`.` components are preserved.
|
|
69
|
+
function joinProfiles(parent) {
|
|
70
|
+
if (parent === '') return 'profiles';
|
|
71
|
+
if (parent === '/') return '/profiles';
|
|
72
|
+
return `${parent}/profiles`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function profilesDir(env = {}, home = os.homedir()) {
|
|
76
|
+
const names = ['EXTERNAL_SUBAGENT_CONFIG', 'ZCODE_AGENT_CONFIG'];
|
|
77
|
+
let envPath;
|
|
78
|
+
let exported = false;
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
if (env[name] !== undefined) {
|
|
81
|
+
envPath = env[name];
|
|
82
|
+
exported = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!exported) return path.join(productPaths(home).data, 'profiles');
|
|
87
|
+
const parent = rustPathParent(envPath);
|
|
88
|
+
if (parent === null) return null;
|
|
89
|
+
return joinProfiles(parent);
|
|
90
|
+
}
|
package/cli/rpc.mjs
CHANGED
|
@@ -66,16 +66,25 @@ function readJsonInput(args) {
|
|
|
66
66
|
function requestId() { return `cli-${process.pid}-${crypto.randomUUID()}`; }
|
|
67
67
|
|
|
68
68
|
function manifest(input) {
|
|
69
|
-
const allowed = new Set(['subagent', 'repository', 'permission_mode', 'prompt', 'model', 'effort', 'write_manifest']);
|
|
69
|
+
const allowed = new Set(['profile', 'subagent', 'repository', 'permission_mode', 'prompt', 'model', 'effort', 'write_manifest']);
|
|
70
70
|
for (const key of Object.keys(input)) {
|
|
71
71
|
if (!allowed.has(key)) throw new CliError('INVALID_ARGUMENT', `spawn contains unsupported field: ${key}`, 2);
|
|
72
72
|
}
|
|
73
|
+
if (input.profile !== undefined && (typeof input.profile !== 'string' || input.profile.length === 0)) throw new CliError('INVALID_ARGUMENT', 'profile must be a non-empty string', 2);
|
|
73
74
|
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);
|
|
74
75
|
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);
|
|
75
76
|
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);
|
|
76
77
|
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);
|
|
77
78
|
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);
|
|
78
79
|
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);
|
|
80
|
+
if (input.profile !== undefined) {
|
|
81
|
+
return {
|
|
82
|
+
schema: 'zcode-general-task/v1', agent_id: requestId(), repository: input.repository,
|
|
83
|
+
...(input.permission_mode !== undefined ? { permission_mode: input.permission_mode } : {}),
|
|
84
|
+
prompt: input.prompt,
|
|
85
|
+
write_manifest: input.write_manifest || [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
79
88
|
return {
|
|
80
89
|
schema: 'zcode-general-task/v1', agent_id: requestId(), repository: input.repository,
|
|
81
90
|
permission_mode: input.permission_mode || 'build', prompt: input.prompt,
|
|
@@ -101,6 +110,7 @@ function methodFor(command, input) {
|
|
|
101
110
|
case 'create': case 'spawn': return {
|
|
102
111
|
method: 'submit_general',
|
|
103
112
|
params: {
|
|
113
|
+
...(input.profile !== undefined ? { profile: input.profile } : {}),
|
|
104
114
|
...(input.subagent !== undefined ? { subagent: input.subagent } : {}),
|
|
105
115
|
...(input.model !== undefined ? { model: input.model } : {}),
|
|
106
116
|
...(input.effort !== undefined ? { effort: input.effort } : {}),
|
|
Binary file
|
|
Binary file
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
3
|
"product": "external-subagent",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.1",
|
|
5
5
|
"platform": "darwin-arm64",
|
|
6
6
|
"files": [
|
|
7
7
|
{
|
|
8
8
|
"name": "external-subagentd",
|
|
9
|
-
"bytes":
|
|
9
|
+
"bytes": 10116128,
|
|
10
10
|
"mode": "755",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "9c650b9c096f4060e927eced4d05665749123177e3c7d105a13a8a466633ed77"
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"name": "external-subagent-mcp",
|
|
15
|
-
"bytes":
|
|
15
|
+
"bytes": 935888,
|
|
16
16
|
"mode": "755",
|
|
17
|
-
"sha256": "
|
|
17
|
+
"sha256": "e519c71451bc27bf217316c57fe92d82e8e8fc546c13194455ad60dbc0cdc9f7"
|
|
18
18
|
}
|
|
19
19
|
]
|
|
20
20
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "external-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"description": "Start one durable subagent in an absolute repository workspace, returning after establishing the new session.",
|
|
64
64
|
"required": ["repository", "prompt"],
|
|
65
65
|
"properties": {
|
|
66
|
+
"profile": { "type": "string", "minLength": 1, "description": "Profile name referencing a JSON preset in profiles/*.json. Mutually exclusive with subagent, permission_mode, model, and effort." },
|
|
66
67
|
"subagent": { "type": "string", "minLength": 1, "description": "Subagent route key; omission uses configured default_subagent or returns subagent_required. Null is invalid; dsh production spawn requires its enabled + spawn_supported + pinned-runtime configuration." },
|
|
67
68
|
"repository": { "type": "string", "minLength": 1 },
|
|
68
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)." },
|
|
@@ -183,7 +184,7 @@
|
|
|
183
184
|
"additionalProperties": false,
|
|
184
185
|
"properties": {
|
|
185
186
|
"external_subagent_status": { "input": [], "output": ["mcp_version", "components", "capabilities", "subagents"] },
|
|
186
|
-
"external_subagent_spawn": { "input": ["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"], "idempotent": false },
|
|
187
188
|
"external_subagent_wait": { "input": ["agent_id", "wait_time", "message_id"], "output": ["task", "pending_requests", "result_available", "activity", "result", "instruction", "timed_out", "message_receipt"] },
|
|
188
189
|
"external_subagent_observe": { "input": ["agent_id"], "output": ["tools", "reasoning", "coverage"], "idempotent": true },
|
|
189
190
|
"external_subagent_list": { "input": ["subagent", "repository", "phase", "outcome", "cursor", "limit"], "output": ["tasks", "next_cursor"] },
|