external-subagent 0.3.2 → 0.4.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.
@@ -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,
@@ -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,492 @@ 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
+ export function profileCommand(paths, inputOrArgs, env = process.env) {
503
+ const input = Array.isArray(inputOrArgs) ? parseProfileArgs(inputOrArgs) : inputOrArgs;
504
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
505
+ throw new CliError('INVALID_ARGUMENT', 'profile input must be an object', 2);
506
+ }
507
+ // Resolution is a pure function of the injected paths/env; there is no
508
+ // test-only production branch keyed on os.homedir().
509
+ const dir = profilesDir(env, paths?.home);
510
+
511
+ if (input.operation === 'list') {
512
+ if (dir === null) {
513
+ return {
514
+ operation: 'list',
515
+ directory: null,
516
+ profiles: [],
517
+ warnings: [],
518
+ message: 'profiles are disabled: the configuration path environment variable is exported empty',
519
+ };
520
+ }
521
+ const scanned = scanProfilesDir(dir);
522
+ if (!scanned.exists) {
523
+ return {
524
+ operation: 'list',
525
+ directory: dir,
526
+ profiles: [],
527
+ warnings: [],
528
+ message: `profiles directory does not exist: ${dir}`,
529
+ };
530
+ }
531
+ return {
532
+ operation: 'list',
533
+ directory: dir,
534
+ profiles: scanned.availableNames,
535
+ warnings: scanned.warnings.map((w) => ({ file: w.file, diagnostic: w.diagnostic })),
536
+ };
537
+ }
538
+
539
+ if (input.operation === 'show') {
540
+ const name = input.name;
541
+ if (!name || typeof name !== 'string') throw new CliError('INVALID_ARGUMENT', 'usage: profile show <name>', 2);
542
+ if (dir === null) throw new CliError('INVALID_ARGUMENT', `profile '${name}' not found; available profiles: none`, 2);
543
+ const scanned = scanProfilesDir(dir);
544
+ if (!scanned.exists) {
545
+ throw new CliError('INVALID_ARGUMENT', `profile '${name}' not found; available profiles: none`, 2);
546
+ }
547
+ if (scanned.duplicates.has(name)) {
548
+ const files = scanned.duplicates.get(name);
549
+ throw new CliError('INVALID_ARGUMENT', `profile '${name}' is ambiguous; defined in multiple files: ${files.join(', ')}`, 2);
550
+ }
551
+ if (scanned.profiles.has(name)) {
552
+ const profile = scanned.profiles.get(name);
553
+ return {
554
+ operation: 'show',
555
+ name,
556
+ file: profile.filePath,
557
+ content: profile.content,
558
+ warnings: scanned.warnings.map((w) => ({ file: w.file, diagnostic: w.diagnostic })),
559
+ };
560
+ }
561
+ const availableList = scanned.availableNames.length > 0 ? `[${scanned.availableNames.join(', ')}]` : 'none';
562
+ const matchedInvalid = scanned.warnings.find((w) => w.profileName === name);
563
+ if (matchedInvalid) {
564
+ throw new CliError('INVALID_ARGUMENT', `${matchedInvalid.diagnostic}; available profiles: ${availableList}`, 2);
565
+ }
566
+ if (scanned.warnings.length > 0) {
567
+ const details = scanned.warnings.map((w) => w.diagnostic).join('; ');
568
+ throw new CliError('INVALID_ARGUMENT', `profile '${name}' not found (directory contains invalid files: ${details}); available profiles: ${availableList}`, 2);
569
+ }
570
+ throw new CliError('INVALID_ARGUMENT', `profile '${name}' not found; available profiles: ${availableList}`, 2);
571
+ }
572
+
573
+ throw new CliError('INVALID_ARGUMENT', `unsupported profile operation: ${input.operation}`, 2);
574
+ }
@@ -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 CONFIG_FIELDS = new Set(['schema_version', 'revision', 'default_subagent', 'subagents']);
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.3.2';
11
+ export const VERSION = '0.4.0';
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 } : {}),
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "product": "external-subagent",
4
- "version": "0.3.2",
4
+ "version": "0.4.0",
5
5
  "platform": "darwin-arm64",
6
6
  "files": [
7
7
  {
8
8
  "name": "external-subagentd",
9
- "bytes": 9907776,
9
+ "bytes": 10115152,
10
10
  "mode": "755",
11
- "sha256": "b0dc9625006f6bbce38e78e46aa7ada47c9ba00c193e51b54f430302d931bfa6"
11
+ "sha256": "2fbb31359c60e4834f3ad90733c03942cafe5fd8ab1bc5f2f9512128a8746237"
12
12
  },
13
13
  {
14
14
  "name": "external-subagent-mcp",
15
- "bytes": 935760,
15
+ "bytes": 935888,
16
16
  "mode": "755",
17
- "sha256": "0b26f44eaa3407016529aec3b948a56e3318cbc29f6d9b169056f426440b8478"
17
+ "sha256": "73fc786b07873983fb347af756cc316cebb071a14380442777c27c3928e6f2ec"
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.2",
3
+ "version": "0.4.0",
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"] },