external-subagent 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
1
  import { CliError } from '../errors.mjs';
2
2
  import { installMcp, installPlugin, uninstallPlugin } from '../install/codex.mjs';
3
3
  import { installZcodePlugin, uninstallZcodePlugin } from '../install/zcode.mjs';
4
- import { installDshPlugin, uninstallDshPlugin } from '../install/dsh.mjs';
4
+ import { installDshPlugin, uninstallDshPlugin, dshHomeFor } from '../install/dsh.mjs';
5
5
  import { registerCodexHome, unregisterCodexHome } from '../install/reconcile.mjs';
6
+ import { registerDshBinding, unregisterDshBinding } from '../install/dsh-registry.mjs';
6
7
  import { verifyPayload } from '../install/payload.mjs';
7
8
 
8
9
  // Command surface for the managed host bindings. `install-plugin` takes an
@@ -68,7 +69,23 @@ export function pluginCommand(paths, args) {
68
69
  // error at the CLI parsing boundary with the other flag-combination
69
70
  // errors instead of deep inside the installer.
70
71
  if (!options.profile) throw new CliError('INVALID_ARGUMENT', 'the dsh host requires --profile <name>', 2);
71
- return options.uninstall ? uninstallDshPlugin(paths, installerOptions) : installDshPlugin(paths, installerOptions);
72
+ const result = options.uninstall ? uninstallDshPlugin(paths, installerOptions) : installDshPlugin(paths, installerOptions);
73
+ // Registry bookkeeping survives a dsh-desktop rewrite that drops the
74
+ // managed block: a registered profile is RESTORED by reconcile, an
75
+ // unregistered one is forgotten. Dry-runs never touch the registry.
76
+ if (!options.dry_run) {
77
+ const dshHome = dshHomeFor(installerOptions, paths);
78
+ if (options.uninstall) {
79
+ if (result.uninstalled) {
80
+ const claim = unregisterDshBinding(paths, dshHome, options.profile);
81
+ result.claim_released = claim.unregistered;
82
+ }
83
+ } else if (result.installed) {
84
+ const claim = registerDshBinding(paths, dshHome, options.profile, { status: 'claimed' });
85
+ result.claim = { registered: claim.registered, deduplicated: claim.deduplicated, bindings: claim.bindings };
86
+ }
87
+ }
88
+ return result;
72
89
  }
73
90
  if (options.profile) throw new CliError('INVALID_ARGUMENT', '--profile applies to the dsh host only', 2);
74
91
  if (options.dshHome) throw new CliError('INVALID_ARGUMENT', '--dsh-home applies to the dsh host only', 2);
package/cli/constants.mjs CHANGED
@@ -12,7 +12,7 @@ const IS_DEBUG_VARIANT = process.env.EXTERNAL_SUBAGENT_VARIANT === 'debug';
12
12
 
13
13
  export const PRODUCT_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug' : 'external-subagent';
14
14
  export const PRODUCT_ID = IS_DEBUG_VARIANT ? 'external_subagent_debug' : 'external_subagent';
15
- export const VERSION = '0.6.1';
15
+ export const VERSION = '0.6.3';
16
16
  export const LAUNCH_AGENT_LABEL = IS_DEBUG_VARIANT ? 'com.external-subagent-debug.daemon' : 'com.external-subagent.daemon';
17
17
  export const DAEMON_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debugd' : 'external-subagentd';
18
18
  export const MCP_BIN_NAME = IS_DEBUG_VARIANT ? 'external-subagent-debug-mcp' : 'external-subagent-mcp';
@@ -0,0 +1,143 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CliError } from '../errors.mjs';
4
+ import { atomicWrite, jsonBytes, readOptional } from '../fs-atomic.mjs';
5
+
6
+ // D28: the persistent registry of bound DSH profiles. The dsh binding is
7
+ // derivable from the patch sentinel in daily operation, but the sentinel
8
+ // itself lives inside a file the dsh desktop rewrites wholesale on upgrade
9
+ // (OBSERVED 2026-10-28, dsh 0.2.1-rc.3: both bound profiles lost the
10
+ // managed block AND the staged skill, while the product's sentinel-only
11
+ // reconcile reported bound:false and silently forgot the machines ever
12
+ // bound). This registry remembers what the user explicitly bound so
13
+ // reconcile can RESTORE a dropped binding instead of merely refreshing
14
+ // surviving ones. It follows the codex-homes D08 model: atomic writes,
15
+ // per-entry status bookkeeping, and corruption recovery that preserves the
16
+ // bad bytes as evidence and continues with an empty table rather than
17
+ // aborting the caller.
18
+
19
+ const REGISTRY_SCHEMA_VERSION = 1;
20
+
21
+ export function dshBindingsRegistryPath(paths) {
22
+ return path.join(paths.data, 'dsh-bindings.json');
23
+ }
24
+
25
+ function emptyRegistry() {
26
+ return { schema_version: REGISTRY_SCHEMA_VERSION, product: 'external-subagent', bindings: [] };
27
+ }
28
+
29
+ // Resolve a home to its canonical registry key. A plain realpath fails on
30
+ // a not-yet-created directory (a dsh home registered before its first
31
+ // boot), and on macOS the /var -> /private/var symlink then yields the
32
+ // UNRESOLVED prefix — so the same home would register and reconcile under
33
+ // two different strings depending on whether it existed at the time.
34
+ // Resolve the deepest EXISTING ancestor and append the remainder instead:
35
+ // the key is stable across creation, removal, and symlinked tmpdirs.
36
+ export function canonicalHome(home) {
37
+ if (typeof home !== 'string' || home.length === 0) throw new CliError('INVALID_ARGUMENT', 'dsh home must be a non-empty string', 2);
38
+ const absolute = path.resolve(home);
39
+ try { return fs.realpathSync(absolute); } catch { /* fall through to ancestor resolution */ }
40
+ const parent = path.dirname(absolute);
41
+ if (parent === absolute) return absolute;
42
+ return path.join(canonicalHome(parent), path.basename(absolute));
43
+ }
44
+
45
+ function resolveHome(home) {
46
+ return canonicalHome(home);
47
+ }
48
+
49
+ function normalizeEntry(entry) {
50
+ if (!entry || typeof entry.home !== 'string' || typeof entry.profile !== 'string') {
51
+ throw new CliError('DSH_BINDINGS_REGISTRY_INVALID', 'registry entry is missing its home or profile path');
52
+ }
53
+ if (/[\\/]/u.test(entry.profile)) {
54
+ throw new CliError('DSH_BINDINGS_REGISTRY_INVALID', 'registry entry profile must not contain path separators');
55
+ }
56
+ return {
57
+ home: entry.home,
58
+ profile: entry.profile,
59
+ claimed_at_ms: Number.isInteger(entry.claimed_at_ms) ? entry.claimed_at_ms : null,
60
+ last_sync_ms: Number.isInteger(entry.last_sync_ms) ? entry.last_sync_ms : null,
61
+ last_status: typeof entry.last_status === 'string' ? entry.last_status : null,
62
+ };
63
+ }
64
+
65
+ function parseRegistry(bytes, file) {
66
+ const doc = JSON.parse(bytes.toString('utf8'));
67
+ if (doc?.schema_version !== REGISTRY_SCHEMA_VERSION || doc?.product !== 'external-subagent' || !Array.isArray(doc.bindings)) {
68
+ throw new CliError('DSH_BINDINGS_REGISTRY_INVALID', 'registry identity or bindings table is invalid');
69
+ }
70
+ const registry = emptyRegistry();
71
+ registry.bindings = doc.bindings.map(normalizeEntry);
72
+ return registry;
73
+ }
74
+
75
+ // Corrupted registry: preserve the bytes for inspection, atomically stage a
76
+ // fresh registry, and report a recoverable error — never abort the caller.
77
+ export function loadDshBindings(paths) {
78
+ const file = dshBindingsRegistryPath(paths);
79
+ const bytes = readOptional(file);
80
+ if (bytes === null) return { registry: emptyRegistry(), recovery: null };
81
+ try {
82
+ return { registry: parseRegistry(bytes, file), recovery: null };
83
+ } catch (error) {
84
+ const backup = `${file}.corrupt-${Date.now()}`;
85
+ try { fs.renameSync(file, backup); } catch (renameError) {
86
+ throw new CliError('DSH_BINDINGS_REGISTRY_CORRUPT', `registry is corrupted and could not be preserved: ${renameError.message}`);
87
+ }
88
+ atomicWrite(file, jsonBytes(emptyRegistry()));
89
+ return {
90
+ registry: emptyRegistry(),
91
+ recovery: { status: 'recovered', code: 'DSH_BINDINGS_REGISTRY_CORRUPT', backup, error: error.message },
92
+ };
93
+ }
94
+ }
95
+
96
+ export function persistRegistry(paths, registry) {
97
+ atomicWrite(dshBindingsRegistryPath(paths), jsonBytes(registry));
98
+ }
99
+
100
+ export function registerDshBinding(paths, dshHome, profile, meta = {}) {
101
+ const { registry, recovery } = loadDshBindings(paths);
102
+ const home = resolveHome(dshHome);
103
+ const existing = registry.bindings.find((entry) => entry.home === home && entry.profile === profile);
104
+ let entry;
105
+ if (existing) {
106
+ entry = existing;
107
+ if (typeof meta.status === 'string') entry.last_status = meta.status;
108
+ } else {
109
+ entry = {
110
+ home,
111
+ profile,
112
+ claimed_at_ms: Date.now(),
113
+ last_sync_ms: null,
114
+ last_status: typeof meta.status === 'string' ? meta.status : 'claimed',
115
+ };
116
+ registry.bindings.push(entry);
117
+ }
118
+ persistRegistry(paths, registry);
119
+ return { registered: true, home, profile, deduplicated: Boolean(existing), bindings: registry.bindings.length, recovery };
120
+ }
121
+
122
+ export function unregisterDshBinding(paths, dshHome, profile) {
123
+ const { registry } = loadDshBindings(paths);
124
+ const home = resolveHome(dshHome);
125
+ const remaining = registry.bindings.filter((entry) => !(entry.home === home && entry.profile === profile));
126
+ const unregistered = remaining.length !== registry.bindings.length;
127
+ registry.bindings = remaining;
128
+ if (unregistered) persistRegistry(paths, registry);
129
+ return { unregistered, home, profile, bindings: registry.bindings.length };
130
+ }
131
+
132
+ // Product uninstall: no profile remains claimed. The registry file itself
133
+ // stays inside retained product data, matching the codex-homes posture.
134
+ export function unregisterAllDshBindings(paths) {
135
+ const { registry } = loadDshBindings(paths);
136
+ const released = registry.bindings.map((entry) => ({ home: entry.home, profile: entry.profile }));
137
+ const file = dshBindingsRegistryPath(paths);
138
+ if (released.length > 0 || fs.existsSync(file)) {
139
+ registry.bindings = [];
140
+ persistRegistry(paths, registry);
141
+ }
142
+ return { unregistered: released.length, bindings: released };
143
+ }
@@ -7,6 +7,7 @@ import { atomicWrite, readOptional, restoreOptional } from '../fs-atomic.mjs';
7
7
  import { MCP_BIN_NAME } from '../constants.mjs';
8
8
  import { nativeBinary, pluginSourceRoot, PLUGIN_NAME } from './layout.mjs';
9
9
  import { guardedRestores } from './plugin-stage.mjs';
10
+ import { loadDshBindings, persistRegistry as persistDshRegistry, dshBindingsRegistryPath, canonicalHome } from './dsh-registry.mjs';
10
11
 
11
12
  // Managed DSH host binding. DSH boots profiles as an ordered stack of
12
13
  // plugin-bundle patch layers under the user's own overrides; the user layer
@@ -17,9 +18,11 @@ import { guardedRestores } from './plugin-stage.mjs';
17
18
  // into the user skill root (<dshHome>/skills), which dsh-skill-filesystem
18
19
  // scans by default (rank 400; OBSERVED against dsh 0.2.0-rc.2). It never
19
20
  // touches the profile's package.json, bundles, node_modules, or any dsh
20
- // market/plugin-install state. There is no registry file: the binding is
21
- // fully derivable from the patch sentinel plus the staged skill, so
22
- // reconcile and uninstall detect rather than remember it (the zcode model).
21
+ // market/plugin-install state. Claims also live in the persistent
22
+ // dsh-bindings.json registry (dsh-registry.mjs): the dsh desktop rewrites
23
+ // cordis.patch.yml wholesale on upgrade and DROPS the managed block
24
+ // (OBSERVED 2026-10-28), so reconcile pairs the sentinel scan with the
25
+ // registry to RESTORE a dropped binding instead of forgetting it.
23
26
 
24
27
  // The MCP client plugin is resolved by the dsh loader from the CLI's own
25
28
  // install tree when the bare package name is used (OBSERVED: a fresh
@@ -546,61 +549,135 @@ export function uninstallDshPlugin(paths, options = {}) {
546
549
  };
547
550
  }
548
551
 
549
- // Stateless reconcile for update flows (the zcode model): every profile
550
- // directory whose patch file carries the sentinel is rebound; the skill is
551
- // refreshed alongside. Unknown/corrupt states are reported, never fatal to
552
- // the caller's broader reconcile.
552
+ // Reconcile for update flows: the persistent registry (dsh-bindings.json)
553
+ // UNION the sentinel scan. The scan alone follows the zcode model — detect
554
+ // rather than remember — but the dsh desktop rewrites cordis.patch.yml
555
+ // wholesale on upgrade and DROPS the managed block (OBSERVED 2026-10-28),
556
+ // after which a sentinel-only reconcile silently forgets the profile was
557
+ // ever bound. Registry entries therefore drive a RESTORE: a registered
558
+ // profile whose patch lost the sentinel is re-bound (status `rebound`),
559
+ // and one whose profile directory itself vanished is reported
560
+ // (`missing_profile_dir`) without failing the reconcile. Profiles bearing
561
+ // the sentinel but absent from the registry (bound by an older build, or
562
+ // by hand) are adopted into the registry so future drops restore too.
553
563
  export function reconcileDshBindings(paths, options = {}) {
554
564
  const dshHome = dshHomeFor(options, paths);
565
+ // Registry entries store realpath-resolved homes (registerDshBinding
566
+ // resolves); compare and dedupe against the SAME resolved form, or a
567
+ // symlinked dshHome (/var -> /private/var on macOS tmpdirs) would never
568
+ // match its own registry claims.
569
+ const resolvedHome = canonicalHome(dshHome);
555
570
  const profilesRoot = path.join(dshHome, 'profiles');
556
- let entries;
571
+ const { registry, recovery } = loadDshBindings(paths);
572
+ const results = [];
573
+ const visited = new Set();
574
+ let directories = [];
575
+ let profilesRootAbsent = false;
557
576
  try {
558
- entries = fs.readdirSync(profilesRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory());
577
+ directories = fs.readdirSync(profilesRoot, { withFileTypes: true })
578
+ .filter((entry) => entry.isDirectory())
579
+ .map((entry) => entry.name);
559
580
  } catch (error) {
560
- if (error?.code === 'ENOENT') return { bound: false, profiles: [], note: 'no dsh profiles directory' };
561
- return { bound: false, profiles: [], error: { code: 'DSH_PROFILES_UNREADABLE', message: error.message } };
581
+ if (error?.code === 'ENOENT') profilesRootAbsent = true;
582
+ else {
583
+ return { bound: false, profiles: [], error: { code: 'DSH_PROFILES_UNREADABLE', message: error.message }, ...(recovery ? { recovery } : {}) };
584
+ }
562
585
  }
563
- const results = [];
564
- for (const entry of entries) {
565
- if (DSH_FORBIDDEN_PROFILES.includes(entry.name)) continue;
566
- const patchFile = path.join(profilesRoot, entry.name, 'cordis.patch.yml');
586
+ const directorySet = new Set(directories);
587
+ // Sentinel scan: refresh every surviving binding and adopt unregistered ones.
588
+ for (const name of directories) {
589
+ if (DSH_FORBIDDEN_PROFILES.includes(name)) continue;
590
+ const patchFile = path.join(profilesRoot, name, 'cordis.patch.yml');
567
591
  const bytes = readOptional(patchFile);
568
592
  if (bytes === null || !bytes.toString('utf8').includes(SENTINEL)) continue;
593
+ visited.add(`${resolvedHome}\u0000${name}`);
594
+ try {
595
+ installDshPlugin(paths, { ...options, profile: name });
596
+ upsertRegistryEntry(registry, resolvedHome, name, 'updated');
597
+ results.push({ profile: name, status: 'updated', idempotent: true });
598
+ } catch (error) {
599
+ results.push({ profile: name, status: 'failed', error: { code: error.code || 'DSH_BINDING_FAILED', message: error.message } });
600
+ }
601
+ }
602
+ // Registry pass: restore registered profiles whose sentinel was dropped.
603
+ for (const entry of registry.bindings) {
604
+ if (entry.home !== resolvedHome) continue;
605
+ if (visited.has(`${entry.home}\u0000${entry.profile}`)) continue;
606
+ if (DSH_FORBIDDEN_PROFILES.includes(entry.profile)) continue;
607
+ if (!directorySet.has(entry.profile)) {
608
+ entry.last_status = 'missing_profile_dir';
609
+ results.push({ profile: entry.profile, status: 'missing_profile_dir' });
610
+ continue;
611
+ }
569
612
  try {
570
- const install = installDshPlugin(paths, { ...options, profile: entry.name });
571
- results.push({ profile: entry.name, status: 'updated', idempotent: Boolean(install.idempotent) });
613
+ installDshPlugin(paths, { ...options, dshHome: entry.home, profile: entry.profile });
614
+ entry.last_status = 'rebound';
615
+ entry.last_sync_ms = Date.now();
616
+ results.push({ profile: entry.profile, status: 'rebound' });
572
617
  } catch (error) {
573
- results.push({ profile: entry.name, status: 'failed', error: { code: error.code || 'DSH_BINDING_FAILED', message: error.message } });
618
+ entry.last_status = 'failed';
619
+ results.push({ profile: entry.profile, status: 'failed', error: { code: error.code || 'DSH_BINDING_FAILED', message: error.message } });
574
620
  }
575
621
  }
576
- return { bound: results.length > 0, profiles: results };
622
+ if (results.length > 0 || fs.existsSync(dshBindingsRegistryPath(paths))) {
623
+ persistDshRegistry(paths, registry);
624
+ }
625
+ return {
626
+ bound: results.length > 0,
627
+ profiles: results,
628
+ ...(profilesRootAbsent ? { note: 'no dsh profiles directory' } : {}),
629
+ ...(recovery ? { recovery } : {}),
630
+ };
631
+ }
632
+
633
+ // Insert or refresh a registry entry in place (the loaded registry object is
634
+ // the caller's working copy; persisting happens once at the end).
635
+ function upsertRegistryEntry(registry, dshHome, profile, status) {
636
+ const home = canonicalHome(dshHome);
637
+ const existing = registry.bindings.find((entry) => entry.home === home && entry.profile === profile);
638
+ if (existing) {
639
+ existing.last_status = status;
640
+ existing.last_sync_ms = Date.now();
641
+ } else {
642
+ registry.bindings.push({
643
+ home,
644
+ profile,
645
+ claimed_at_ms: Date.now(),
646
+ last_sync_ms: Date.now(),
647
+ last_status: status,
648
+ });
649
+ }
577
650
  }
578
651
 
579
652
  // Product uninstall: release every detected binding at once. Detection is
580
- // the same sentinel scan as reconcile; each profile is uninstalled
581
- // independently (a failure on one never skips the rest, matching the
582
- // per-home codex release posture), and the shared staged skill is removed
583
- // when no bound profile remains.
653
+ // the same sentinel scan plus the registry, so a desktop-rewritten patch
654
+ // (sentinel dropped) is still unregistered even though its block is gone.
655
+ // Each profile is uninstalled independently (a failure on one never skips
656
+ // the rest, matching the per-home codex release posture), and the shared
657
+ // staged skill is removed when no bound profile remains.
584
658
  export function uninstallAllDshBindings(paths, options = {}) {
585
659
  const dshHome = dshHomeFor(options, paths);
586
660
  const profilesRoot = path.join(dshHome, 'profiles');
587
- let entries;
661
+ let entries = [];
588
662
  try {
589
- entries = fs.readdirSync(profilesRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory());
590
- } catch {
591
- return { released: 0, profiles: [] };
663
+ entries = fs.readdirSync(profilesRoot, { withFileTypes: true })
664
+ .filter((entry) => entry.isDirectory())
665
+ .map((entry) => entry.name);
666
+ } catch { /* registry pass below still releases claims */ }
667
+ const names = new Set(entries);
668
+ const resolvedHome = canonicalHome(dshHome);
669
+ const { registry } = loadDshBindings(paths);
670
+ for (const entry of registry.bindings) {
671
+ if (entry.home === resolvedHome) names.add(entry.profile);
592
672
  }
593
673
  const results = [];
594
- for (const entry of entries) {
595
- if (DSH_FORBIDDEN_PROFILES.includes(entry.name)) continue;
596
- const patchFile = path.join(profilesRoot, entry.name, 'cordis.patch.yml');
597
- const bytes = readOptional(patchFile);
598
- if (bytes === null || !bytes.toString('utf8').includes(SENTINEL)) continue;
674
+ for (const name of names) {
675
+ if (DSH_FORBIDDEN_PROFILES.includes(name)) continue;
599
676
  try {
600
- const removed = uninstallDshPlugin(paths, { ...options, profile: entry.name });
601
- results.push({ profile: entry.name, status: removed.uninstalled ? 'released' : 'absent' });
677
+ const removed = uninstallDshPlugin(paths, { ...options, profile: name });
678
+ results.push({ profile: name, status: removed.uninstalled ? 'released' : 'absent' });
602
679
  } catch (error) {
603
- results.push({ profile: entry.name, status: 'failed', error: { code: error.code || 'DSH_BINDING_RELEASE_FAILED', message: error.message } });
680
+ results.push({ profile: name, status: 'failed', error: { code: error.code || 'DSH_BINDING_RELEASE_FAILED', message: error.message } });
604
681
  }
605
682
  }
606
683
  // The skill is shared across profiles; remove it once no binding remains.
@@ -609,5 +686,10 @@ export function uninstallAllDshBindings(paths, options = {}) {
609
686
  unstageSkill(dshHome, options.source || pluginSourceRoot());
610
687
  } catch { /* reported per-profile above; a stuck skill never fails the release */ }
611
688
  }
612
- return { released: results.filter((result) => result.status === 'released').length, profiles: results };
689
+ // Release every registry claim for THIS dsh home (other homes keep theirs).
690
+ const before = registry.bindings.length;
691
+ registry.bindings = registry.bindings.filter((entry) => entry.home !== resolvedHome);
692
+ const registry_cleared = before !== registry.bindings.length || fs.existsSync(dshBindingsRegistryPath(paths));
693
+ if (registry_cleared) persistDshRegistry(paths, registry);
694
+ return { released: results.filter((result) => result.status === 'released').length, profiles: results, registry_cleared };
613
695
  }
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "product": "external-subagent",
4
- "version": "0.6.1",
4
+ "version": "0.6.3",
5
5
  "platform": "darwin-arm64",
6
6
  "files": [
7
7
  {
8
8
  "name": "external-subagentd",
9
- "bytes": 10436368,
9
+ "bytes": 10454160,
10
10
  "mode": "755",
11
- "sha256": "4aebf0b109a33e9b2b1a7897287d1249f7e3792ae7848560f0d08d9365495d06"
11
+ "sha256": "8365dfc712e5cca2f3332dcab5db0e7b5eb905eb2e0e7dc231016b45c7275094"
12
12
  },
13
13
  {
14
14
  "name": "external-subagent-mcp",
15
15
  "bytes": 935776,
16
16
  "mode": "755",
17
- "sha256": "1960fd2fb95e5e7125eec8ea21456cddc60e266b65ae67f247f611bd855a233c"
17
+ "sha256": "98e60f3a0ff2840318842232a6f7689326a1e7d75f487cbcb0878ddce7b64719"
18
18
  }
19
19
  ]
20
20
  }
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "product": "external-subagent",
4
- "version": "0.6.1",
4
+ "version": "0.6.3",
5
5
  "platform": "linux-x64",
6
6
  "files": [
7
7
  {
8
8
  "name": "external-subagentd",
9
- "bytes": 12031656,
9
+ "bytes": 12033072,
10
10
  "mode": "755",
11
- "sha256": "3e114f16cc88e5ce287013694f5c239616195769178af3b8f0c81d7ee8373c95"
11
+ "sha256": "5d8f39b76e22c24c8f848f9c753b7ba4336e9ecb57b82ef25d511ed21039dce7"
12
12
  },
13
13
  {
14
14
  "name": "external-subagent-mcp",
15
- "bytes": 1025536,
15
+ "bytes": 1025520,
16
16
  "mode": "755",
17
- "sha256": "d859ad4eed3e7e44faf377982fe0d641fb78157bd4da7a05256eeadc8eb545cb"
17
+ "sha256": "11999c141398a0009f33d740123d381ad4aae58c0a56fb2131629b378da96e89"
18
18
  }
19
19
  ]
20
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "external-subagent",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Manage bounded ZCode, DeepSeek Harness (DSH), Codex, and Google Antigravity (agy) subagent tasks through a local MCP service for any local host (Codex, ZCode, or a custom MCP client)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,181 +4,544 @@
4
4
  "title": "external-subagent MCP public API",
5
5
  "description": "The intentionally small external_subagent_* wire catalog.",
6
6
  "type": "object",
7
- "required": ["tools"],
7
+ "required": [
8
+ "tools"
9
+ ],
8
10
  "properties": {
9
11
  "tools": {
10
12
  "type": "array",
11
- "const": ["external_subagent_cancel", "external_subagent_close", "external_subagent_list", "external_subagent_observe", "external_subagent_wait", "external_subagent_respond", "external_subagent_result", "external_subagent_send", "external_subagent_spawn", "external_subagent_status"]
13
+ "const": [
14
+ "external_subagent_cancel",
15
+ "external_subagent_close",
16
+ "external_subagent_list",
17
+ "external_subagent_observe",
18
+ "external_subagent_wait",
19
+ "external_subagent_respond",
20
+ "external_subagent_result",
21
+ "external_subagent_send",
22
+ "external_subagent_spawn",
23
+ "external_subagent_status"
24
+ ]
25
+ },
26
+ "permission_mode": {
27
+ "enum": [
28
+ "build",
29
+ "edit",
30
+ "plan",
31
+ "yolo"
32
+ ],
33
+ "default": "build"
12
34
  },
13
- "permission_mode": { "enum": ["build", "edit", "plan", "yolo"], "default": "build" },
14
35
  "subagent_status": {
15
36
  "type": "object",
16
- "required": ["subagent", "configured", "enabled", "spawn_supported", "permission_modes", "model_selection", "effort_selection", "local", "auth", "hi"],
37
+ "required": [
38
+ "subagent",
39
+ "configured",
40
+ "enabled",
41
+ "spawn_supported",
42
+ "permission_modes",
43
+ "model_selection",
44
+ "effort_selection",
45
+ "local",
46
+ "auth",
47
+ "hi"
48
+ ],
17
49
  "properties": {
18
- "subagent": { "enum": ["zcode", "dsh", "codex", "agy"] },
19
- "configured": { "type": "boolean" },
20
- "enabled": { "type": "boolean" },
21
- "spawn_supported": { "type": "boolean" },
50
+ "subagent": {
51
+ "enum": [
52
+ "zcode",
53
+ "dsh",
54
+ "codex",
55
+ "agy"
56
+ ]
57
+ },
58
+ "configured": {
59
+ "type": "boolean"
60
+ },
61
+ "enabled": {
62
+ "type": "boolean"
63
+ },
64
+ "spawn_supported": {
65
+ "type": "boolean"
66
+ },
22
67
  "permission_modes": {
23
68
  "type": "array",
24
- "items": { "enum": ["build", "edit", "plan", "yolo"] },
69
+ "items": {
70
+ "enum": [
71
+ "build",
72
+ "edit",
73
+ "plan",
74
+ "yolo"
75
+ ]
76
+ },
25
77
  "uniqueItems": true
26
78
  },
27
79
  "model_selection": {
28
80
  "type": "object",
29
- "required": ["supported", "mode"],
81
+ "required": [
82
+ "supported",
83
+ "mode"
84
+ ],
30
85
  "properties": {
31
- "supported": { "type": "boolean" },
32
- "mode": { "enum": ["native_only", "catalog_token"] }
86
+ "supported": {
87
+ "type": "boolean"
88
+ },
89
+ "mode": {
90
+ "enum": [
91
+ "native_only",
92
+ "catalog_token"
93
+ ]
94
+ }
33
95
  },
34
96
  "additionalProperties": false
35
97
  },
36
98
  "effort_selection": {
37
99
  "type": "object",
38
100
  "description": "Reasoning-effort steering capability. codex admits only its closed set; zcode and dsh pass a bounded [a-z0-9_] token through to the runtime.",
39
- "required": ["supported", "mode"],
101
+ "required": [
102
+ "supported",
103
+ "mode"
104
+ ],
40
105
  "properties": {
41
- "supported": { "type": "boolean" },
42
- "mode": { "enum": ["closed_set", "passthrough_token"] }
106
+ "supported": {
107
+ "type": "boolean"
108
+ },
109
+ "mode": {
110
+ "enum": [
111
+ "closed_set",
112
+ "passthrough_token"
113
+ ]
114
+ }
43
115
  },
44
116
  "additionalProperties": false
45
117
  },
46
- "local": { "$ref": "#/properties/subagent_scope_status" },
47
- "auth": { "$ref": "#/properties/subagent_scope_status" },
48
- "hi": { "$ref": "#/properties/subagent_scope_status" }
118
+ "local": {
119
+ "$ref": "#/properties/subagent_scope_status"
120
+ },
121
+ "auth": {
122
+ "$ref": "#/properties/subagent_scope_status"
123
+ },
124
+ "hi": {
125
+ "$ref": "#/properties/subagent_scope_status"
126
+ }
49
127
  },
50
128
  "additionalProperties": false
51
129
  },
52
130
  "subagent_scope_status": {
53
131
  "type": "object",
54
132
  "description": "Readiness conclusion only. Probe evidence (scope paths, version, checked_at_ms, reason) stays on the daemon RPC view for the CLI diagnose owner.",
55
- "required": ["state"],
133
+ "required": [
134
+ "state"
135
+ ],
56
136
  "properties": {
57
- "state": { "enum": ["READY", "DEGRADED", "UNAVAILABLE", "UNKNOWN"] }
137
+ "state": {
138
+ "enum": [
139
+ "READY",
140
+ "DEGRADED",
141
+ "UNAVAILABLE",
142
+ "UNKNOWN"
143
+ ]
144
+ }
58
145
  },
59
146
  "additionalProperties": false
60
147
  },
61
148
  "spawn": {
62
149
  "type": "object",
63
150
  "description": "Start one durable subagent in an absolute repository workspace, returning after establishing the new session.",
64
- "required": ["repository", "prompt"],
151
+ "required": [
152
+ "repository",
153
+ "prompt"
154
+ ],
65
155
  "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." },
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." },
68
- "repository": { "type": "string", "minLength": 1 },
69
- "permission_mode": { "$ref": "#/properties/permission_mode", "description": "codex supports build/edit (workspace-write), plan (read-only), and yolo (danger-full-access), always with approvalPolicy=never; agy supports only build (--mode accept-edits) and yolo (--dangerously-skip-permissions)." },
70
- "prompt": { "type": "string", "minLength": 1 },
71
- "model": { "type": "string", "minLength": 1, "description": "Task model. dsh requires provider:model, split at the first colon (the model side may contain further colons; a missing colon or an empty side is rejected before any task). codex uses its own model id; zcode requires an explicit provider/model token from the spawn request or a named profile, and rejects a bare token, an omitted model, and agents.zcode.default_model at admission. agy uses a bare model slug with no ':' or '/' separator or whitespace; admission validates only that shape and the daemon does not verify catalog membership, so an unknown slug fails loudly at session start when the agy CLI rejects it. Omission falls back to the configured default_model for dsh and agy, then to the provider-native model; codex requires an explicit model or agents.codex.default_model; zcode and codex never fall back to a provider default." },
72
- "effort": { "type": "string", "minLength": 1, "maxLength": 24, "pattern": "^[a-z0-9_]+$", "description": "Reasoning-effort token: 1..24 bytes of [a-z0-9_]. codex admits only low, medium, high or xhigh; agy admits only low, medium, high or max; zcode and dsh pass the bounded token through. Omission keeps the runtime default." },
73
- "write_manifest": { "type": "array", "items": { "type": "string", "minLength": 1 }, "description": "codex requires an empty or omitted write_manifest; non-empty input returns codex_write_manifest_unsupported before task creation. agy likewise requires an empty or omitted write_manifest; non-empty input returns agy_write_manifest_unsupported before task creation. dsh admits a non-empty manifest in build through the guarded manifest-build composition (workspace-write sandbox, only tool-fs writable, out-of-manifest paths rejected as FS_WRITE_MANIFEST_DENIED), at most 256 entries; an explicit [\".\"] keeps the existing build composition. plan requires an empty manifest for every subagent." }
156
+ "profile": {
157
+ "type": "string",
158
+ "minLength": 1,
159
+ "description": "Profile name referencing a JSON preset in profiles/*.json. Mutually exclusive with subagent, permission_mode, model, and effort."
160
+ },
161
+ "subagent": {
162
+ "type": "string",
163
+ "minLength": 1,
164
+ "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."
165
+ },
166
+ "repository": {
167
+ "type": "string",
168
+ "minLength": 1
169
+ },
170
+ "permission_mode": {
171
+ "$ref": "#/properties/permission_mode",
172
+ "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)."
173
+ },
174
+ "prompt": {
175
+ "type": "string",
176
+ "minLength": 1
177
+ },
178
+ "model": {
179
+ "type": "string",
180
+ "minLength": 1,
181
+ "description": "Task model. dsh requires provider:model, split at the first colon (the model side may contain further colons; a missing colon or an empty side is rejected before any task). codex uses its own model id; zcode requires an explicit provider/model token from the spawn request or a named profile, and rejects a bare token, an omitted model, and agents.zcode.default_model at admission. agy uses a bare model slug with no ':' or '/' separator or whitespace; admission validates only that shape and the daemon does not verify catalog membership, so an unknown slug fails loudly at session start when the agy CLI rejects it. Omission falls back to the configured default_model for dsh and agy, then to the provider-native model; codex requires an explicit model or agents.codex.default_model; zcode and codex never fall back to a provider default."
182
+ },
183
+ "effort": {
184
+ "type": "string",
185
+ "minLength": 1,
186
+ "maxLength": 24,
187
+ "pattern": "^[a-z0-9_]+$",
188
+ "description": "Reasoning-effort token: 1..24 bytes of [a-z0-9_]. codex admits only low, medium, high or xhigh; agy admits only low, medium, high or max; zcode and dsh pass the bounded token through. Omission keeps the runtime default."
189
+ },
190
+ "write_manifest": {
191
+ "type": "array",
192
+ "items": {
193
+ "type": "string",
194
+ "minLength": 1
195
+ },
196
+ "description": "codex requires an empty or omitted write_manifest; non-empty input returns codex_write_manifest_unsupported before task creation. agy likewise requires an empty or omitted write_manifest; non-empty input returns agy_write_manifest_unsupported before task creation. dsh admits a non-empty manifest in build through the guarded manifest-build composition (workspace-write sandbox, only tool-fs writable, out-of-manifest paths rejected as FS_WRITE_MANIFEST_DENIED), at most 256 entries; an explicit [\".\"] keeps the existing build composition. plan requires an empty manifest for every subagent."
197
+ }
74
198
  },
75
199
  "allOf": [
76
- { "if": { "required": ["subagent"], "properties": { "subagent": { "const": "codex" } } },
77
- "then": { "properties": { "write_manifest": { "maxItems": 0 } } } },
78
- { "if": { "required": ["subagent"], "properties": { "subagent": { "const": "dsh" } } },
79
- "then": { "properties": { "write_manifest": { "maxItems": 256 } } } },
80
- { "if": { "required": ["subagent"], "properties": { "subagent": { "const": "agy" } } },
81
- "then": { "properties": {
82
- "permission_mode": { "enum": ["build", "yolo"] },
83
- "model": { "type": "string", "minLength": 1, "maxLength": 512, "pattern": "^[^:/\\s]+$" },
84
- "effort": { "enum": ["low", "medium", "high", "max"] },
85
- "write_manifest": { "maxItems": 0 }
86
- } } },
87
- { "if": { "required": ["permission_mode"], "properties": { "permission_mode": { "const": "plan" } } },
88
- "then": { "properties": { "write_manifest": { "maxItems": 0 } } } }
200
+ {
201
+ "if": {
202
+ "required": [
203
+ "subagent"
204
+ ],
205
+ "properties": {
206
+ "subagent": {
207
+ "const": "codex"
208
+ }
209
+ }
210
+ },
211
+ "then": {
212
+ "properties": {
213
+ "write_manifest": {
214
+ "maxItems": 0
215
+ }
216
+ }
217
+ }
218
+ },
219
+ {
220
+ "if": {
221
+ "required": [
222
+ "subagent"
223
+ ],
224
+ "properties": {
225
+ "subagent": {
226
+ "const": "dsh"
227
+ }
228
+ }
229
+ },
230
+ "then": {
231
+ "properties": {
232
+ "write_manifest": {
233
+ "maxItems": 256
234
+ }
235
+ }
236
+ }
237
+ },
238
+ {
239
+ "if": {
240
+ "required": [
241
+ "subagent"
242
+ ],
243
+ "properties": {
244
+ "subagent": {
245
+ "const": "agy"
246
+ }
247
+ }
248
+ },
249
+ "then": {
250
+ "properties": {
251
+ "permission_mode": {
252
+ "enum": [
253
+ "build",
254
+ "yolo"
255
+ ]
256
+ },
257
+ "model": {
258
+ "type": "string",
259
+ "minLength": 1,
260
+ "maxLength": 512,
261
+ "pattern": "^[^:/\\s]+$"
262
+ },
263
+ "effort": {
264
+ "enum": [
265
+ "low",
266
+ "medium",
267
+ "high",
268
+ "max"
269
+ ]
270
+ },
271
+ "write_manifest": {
272
+ "maxItems": 0
273
+ }
274
+ }
275
+ }
276
+ },
277
+ {
278
+ "if": {
279
+ "required": [
280
+ "permission_mode"
281
+ ],
282
+ "properties": {
283
+ "permission_mode": {
284
+ "const": "plan"
285
+ }
286
+ }
287
+ },
288
+ "then": {
289
+ "properties": {
290
+ "write_manifest": {
291
+ "maxItems": 0
292
+ }
293
+ }
294
+ }
295
+ }
89
296
  ],
90
297
  "additionalProperties": false
91
298
  },
92
299
  "list": {
93
300
  "type": "object",
94
- "required": ["repository"],
301
+ "required": [
302
+ "repository"
303
+ ],
95
304
  "properties": {
96
- "subagent": { "type": "string", "minLength": 1 },
97
- "repository": { "type": "string", "minLength": 1 },
98
- "phase": { "type": ["string", "null"], "default": null },
99
- "outcome": { "type": ["string", "null"], "default": null },
100
- "cursor": { "type": ["string", "null"], "default": null },
101
- "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 100 }
305
+ "subagent": {
306
+ "type": "string",
307
+ "minLength": 1
308
+ },
309
+ "repository": {
310
+ "type": "string",
311
+ "minLength": 1
312
+ },
313
+ "phase": {
314
+ "type": [
315
+ "string",
316
+ "null"
317
+ ],
318
+ "default": null
319
+ },
320
+ "outcome": {
321
+ "type": [
322
+ "string",
323
+ "null"
324
+ ],
325
+ "default": null
326
+ },
327
+ "cursor": {
328
+ "type": [
329
+ "string",
330
+ "null"
331
+ ],
332
+ "default": null
333
+ },
334
+ "limit": {
335
+ "type": "integer",
336
+ "minimum": 1,
337
+ "maximum": 100,
338
+ "default": 100
339
+ }
102
340
  },
103
341
  "additionalProperties": false
104
342
  },
105
343
  "wait": {
106
344
  "type": "object",
107
345
  "description": "agent_id accepts one agent id (number) or a list of 1..=16 ids. A single id returns the single wait object described below. A list blocks until every listed agent is terminal OR any one of them raises an actionable pending request, then returns a FLAT object: one key per distinct agent id carrying that agent's ordinary wait object (a repeated id is awaited once like any other), plus one \"error\" key mapping problematic ids to markers - duplicated for ids repeated in the input and unknown_agent for ids that do not exist; the remaining ids are still awaited normally. Recommended parallel pattern: spawn every subagent first, then call wait once with all ids. In list mode message_id is not supported (pass it only with a scalar id), every element carries message_receipt=null, and each element's timed_out flag follows the same shared deadline. Wait up to 290 seconds by default. Returns early only when an actionable pending request exists (permission requests need allow/deny; user-input requests carry the full embedded question and need answer with non-empty content) or the terminal result is available; ordinary progress, message receipts, already-responded and unsupported requests never wake it, and its timeout still returns timed_out=true. When the embedded result page carries next_offset=null it is the whole final result and no further result call is needed; otherwise continue reading with external_subagent_result from next_offset. A failed terminal result also carries failure_message: usually the daemon's bounded single-line JSON failure detail, but an early fail_claim row may be plain text, so treat it as an opaque bounded string and attempt JSON parsing defensively (null when the task has no persisted detail or the outcome is not a failure). Read failure_message before falling back to daemon logs or sqlite. The pending_requests projection stays capped at 100 records while the wake decision scans the full pending set, and the request that woke the wait is always part of the returned projection so its request_id can be answered directly. activity carries the text tail incrementally: each wait returns only the text appended since the previous wait (an empty tail means nothing new), every byte is delivered by wait at most once, and latest_text_truncated flags the case where undelivered bytes rolled out of the bounded window so this response degraded to the whole window (on the terminal response, bytes repeating the embedded result page verbatim are stripped so they are not shipped twice), tool calls started in the last 60 seconds, and telemetry status; activity also exposes last_activity_age_ms (null before any activity) and model_request_active; additionally in_flight_tool_count counts tracked in-flight tool calls (scheduled or started without a matching result, error or permission resolution; 0 when there is no snapshot or the adapter has no per-call lifecycle tracking, such as codex) and oldest_in_flight_tool_age_ms is the age since the oldest tracked in-flight call first appeared (null when none is tracked); use external_subagent_observe only when these liveness signals suggest a meaningless loop or a stuck command, to inspect recent tool-call detail.Wait up to 290 seconds by default. Returns early only when an actionable pending request exists (permission requests need allow/deny; user-input requests carry the full embedded question and need answer with non-empty content) or the terminal result is available; ordinary progress, message receipts, already-responded and unsupported requests never wake it, and its timeout still returns timed_out=true. When the embedded result page carries next_offset=null it is the whole final result and no further result call is needed; otherwise continue reading with external_subagent_result from next_offset. A failed terminal result also carries failure_message: usually the daemon's bounded single-line JSON failure detail, but an early fail_claim row may be plain text, so treat it as an opaque bounded string and attempt JSON parsing defensively (null when the task has no persisted detail or the outcome is not a failure). Read failure_message before falling back to daemon logs or sqlite. The pending_requests projection stays capped at 100 records while the wake decision scans the full pending set, and the request that woke the wait is always part of the returned projection so its request_id can be answered directly. activity carries the text tail incrementally: each wait returns only the text appended since the previous wait (an empty tail means nothing new), every byte is delivered by wait at most once, and latest_text_truncated flags the case where undelivered bytes rolled out of the bounded window so this response degraded to the whole window (on the terminal response, bytes repeating the embedded result page verbatim are stripped so they are not shipped twice), tool calls started in the last 60 seconds, and telemetry status; activity also exposes last_activity_age_ms (null before any activity) and model_request_active; additionally in_flight_tool_count counts tracked in-flight tool calls (scheduled or started without a matching result, error or permission resolution; 0 when there is no snapshot or the adapter has no per-call lifecycle tracking, such as codex) and oldest_in_flight_tool_age_ms is the age since the oldest tracked in-flight call first appeared (null when none is tracked); use external_subagent_observe only when these liveness signals suggest a meaningless loop or a stuck command, to inspect recent tool-call detail.",
108
- "required": ["agent_id"],
346
+ "required": [
347
+ "agent_id"
348
+ ],
109
349
  "properties": {
110
350
  "agent_id": {
111
351
  "anyOf": [
112
- { "type": "integer", "minimum": 10000000, "maximum": 99999999 },
113
- { "type": "array", "minItems": 1, "maxItems": 16, "items": { "type": "integer", "minimum": 10000000, "maximum": 99999999 } }
352
+ {
353
+ "type": "integer",
354
+ "minimum": 10000000,
355
+ "maximum": 99999999
356
+ },
357
+ {
358
+ "type": "array",
359
+ "minItems": 1,
360
+ "maxItems": 16,
361
+ "items": {
362
+ "type": "integer",
363
+ "minimum": 10000000,
364
+ "maximum": 99999999
365
+ }
366
+ }
114
367
  ],
115
368
  "description": "One agent id (scalar response) or a list of 1..=16 ids awaited together (response is an object keyed by agent id, each value the ordinary wait output or {\"error\":\"duplicated\"}/{\"error\":\"unknown_agent\"})."
116
369
  },
117
- "wait_time": { "type": "integer", "minimum": 0, "maximum": 299, "default": 290 }
118
- ,"message_id": { "type": ["string", "null"], "description": "Only supported with a scalar agent_id." }
370
+ "wait_time": {
371
+ "type": "integer",
372
+ "minimum": 0,
373
+ "maximum": 299,
374
+ "default": 290
375
+ },
376
+ "message_id": {
377
+ "type": [
378
+ "string",
379
+ "null"
380
+ ],
381
+ "description": "Only supported with a scalar agent_id."
382
+ }
119
383
  },
120
384
  "additionalProperties": false
121
385
  },
122
386
  "observe": {
123
387
  "description": "Read captured task observations for zcode, dsh, codex or agy. zcode and dsh expose at most 200 Unicode characters of public reasoning; codex and agy reasoning is null. Coverage reports capture gaps and unsupported history truthfully.",
124
388
  "type": "object",
125
- "required": ["agent_id"],
389
+ "required": [
390
+ "agent_id"
391
+ ],
126
392
  "properties": {
127
- "agent_id": { "type": "integer", "minimum": 10000000, "maximum": 99999999 }
393
+ "agent_id": {
394
+ "type": "integer",
395
+ "minimum": 10000000,
396
+ "maximum": 99999999
397
+ }
128
398
  },
129
399
  "additionalProperties": false
130
400
  },
131
401
  "result": {
132
402
  "type": "object",
133
403
  "description": "Pages bounded terminal task-owned text with the offset and limit continuation contract. Questions from user-input requests are embedded in full in wait projections, not paged here.",
134
- "required": ["agent_id"],
404
+ "required": [
405
+ "agent_id"
406
+ ],
135
407
  "properties": {
136
- "agent_id": { "type": "integer", "minimum": 10000000, "maximum": 99999999 },
137
- "offset": { "type": "integer", "minimum": 0, "default": 0 },
138
- "limit": { "type": "integer", "minimum": 1, "maximum": 262144, "default": 262144 }
408
+ "agent_id": {
409
+ "type": "integer",
410
+ "minimum": 10000000,
411
+ "maximum": 99999999
412
+ },
413
+ "offset": {
414
+ "type": "integer",
415
+ "minimum": 0,
416
+ "default": 0
417
+ },
418
+ "limit": {
419
+ "type": "integer",
420
+ "minimum": 1,
421
+ "maximum": 262144,
422
+ "default": 262144
423
+ }
139
424
  },
140
425
  "additionalProperties": false
141
426
  },
142
427
  "result_projection": {
143
428
  "type": "object",
144
- "required": ["outcome", "final_text", "partial", "offset", "total_bytes", "next_offset", "reason_code", "failure_message"],
429
+ "required": [
430
+ "outcome",
431
+ "final_text",
432
+ "partial",
433
+ "offset",
434
+ "total_bytes",
435
+ "next_offset",
436
+ "reason_code",
437
+ "failure_message"
438
+ ],
145
439
  "properties": {
146
- "outcome": { "enum": ["COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT", "RUNTIME_LOST", "RESULT_INVALID"] },
147
- "final_text": { "type": "string" },
148
- "partial": { "type": "boolean" },
149
- "offset": { "type": "integer", "minimum": 0 },
150
- "total_bytes": { "type": "integer", "minimum": 0 },
151
- "next_offset": { "type": ["integer", "null"], "minimum": 0 },
152
- "reason_code": { "type": ["string", "null"] },
153
- "failure_message": { "type": ["string", "null"] }
440
+ "outcome": {
441
+ "enum": [
442
+ "COMPLETED",
443
+ "FAILED",
444
+ "CANCELLED",
445
+ "TIMED_OUT",
446
+ "RUNTIME_LOST",
447
+ "RESULT_INVALID"
448
+ ]
449
+ },
450
+ "final_text": {
451
+ "type": "string"
452
+ },
453
+ "partial": {
454
+ "type": "boolean"
455
+ },
456
+ "offset": {
457
+ "type": "integer",
458
+ "minimum": 0
459
+ },
460
+ "total_bytes": {
461
+ "type": "integer",
462
+ "minimum": 0
463
+ },
464
+ "next_offset": {
465
+ "type": [
466
+ "integer",
467
+ "null"
468
+ ],
469
+ "minimum": 0
470
+ },
471
+ "reason_code": {
472
+ "type": [
473
+ "string",
474
+ "null"
475
+ ]
476
+ },
477
+ "failure_message": {
478
+ "type": [
479
+ "string",
480
+ "null"
481
+ ]
482
+ }
154
483
  },
155
484
  "additionalProperties": false
156
485
  },
157
486
  "question_projection": {
158
487
  "type": "object",
159
488
  "description": "Embedded question of an answerable user-input request in the wait projection's pending_requests entries; wait is the only retrieval surface, so a question beyond the embed cap is marked truncated instead of paged.",
160
- "required": ["text", "truncated"],
489
+ "required": [
490
+ "text",
491
+ "truncated"
492
+ ],
161
493
  "properties": {
162
- "text": { "type": "string" },
163
- "truncated": { "type": "boolean" }
494
+ "text": {
495
+ "type": "string"
496
+ },
497
+ "truncated": {
498
+ "type": "boolean"
499
+ }
164
500
  },
165
501
  "additionalProperties": false
166
502
  },
167
503
  "error_projection": {
168
504
  "type": "object",
169
- "required": ["error"],
505
+ "required": [
506
+ "error"
507
+ ],
170
508
  "properties": {
171
509
  "error": {
172
510
  "type": "object",
173
- "required": ["code", "message"],
511
+ "required": [
512
+ "code",
513
+ "message"
514
+ ],
174
515
  "properties": {
175
- "code": { "type": "string", "minLength": 1 },
176
- "message": { "type": "string", "minLength": 1 },
177
- "component": { "type": "string", "minLength": 1 },
178
- "operation": { "type": "string", "minLength": 1 },
179
- "request_id": { "type": "string", "minLength": 1 },
180
- "agent_id": { "type": "integer", "minimum": 10000000, "maximum": 99999999 },
181
- "prompt_count": { "type": "integer", "minimum": 0 }
516
+ "code": {
517
+ "type": "string",
518
+ "minLength": 1
519
+ },
520
+ "message": {
521
+ "type": "string",
522
+ "minLength": 1
523
+ },
524
+ "component": {
525
+ "type": "string",
526
+ "minLength": 1
527
+ },
528
+ "operation": {
529
+ "type": "string",
530
+ "minLength": 1
531
+ },
532
+ "request_id": {
533
+ "type": "string",
534
+ "minLength": 1
535
+ },
536
+ "agent_id": {
537
+ "type": "integer",
538
+ "minimum": 10000000,
539
+ "maximum": 99999999
540
+ },
541
+ "prompt_count": {
542
+ "type": "integer",
543
+ "minimum": 0
544
+ }
182
545
  },
183
546
  "additionalProperties": false
184
547
  }
@@ -189,16 +552,140 @@
189
552
  "type": "object",
190
553
  "additionalProperties": false,
191
554
  "properties": {
192
- "external_subagent_status": { "input": [], "output": ["mcp_version", "components", "capabilities", "subagents"] },
193
- "external_subagent_spawn": { "input": ["profile", "subagent", "repository", "permission_mode", "prompt", "model", "effort", "write_manifest"], "output": ["agent_id", "status", "session_id", "instruction"], "idempotent": false },
194
- "external_subagent_wait": { "input": ["agent_id", "wait_time", "message_id"], "output": ["task", "pending_requests", "activity", "result", "timed_out", "message_receipt"], "output_many": ["<agent_id> keys", "error"], "agent_id_forms": "scalar or array of 1..=16", "parallel_note": "spawn all subagents first, then wait once with the full id list" },
195
- "external_subagent_observe": { "input": ["agent_id"], "output": ["tools", "reasoning", "coverage"], "idempotent": true },
196
- "external_subagent_list": { "input": ["subagent", "repository", "phase", "outcome", "cursor", "limit"], "output": ["tasks", "next_cursor"] },
197
- "external_subagent_send": { "input": ["agent_id", "message_id", "mode", "content"], "required": ["agent_id", "mode", "content"], "mode": ["queue", "steer"], "output": ["message_id", "disposition"] },
198
- "external_subagent_respond": { "input": ["agent_id", "request_id", "decision", "content"], "output": ["disposition", "requested_decision", "effective_decision", "policy_overrode", "policy_reason_code"] },
199
- "external_subagent_cancel": { "input": ["agent_id"], "output": ["task"] },
200
- "external_subagent_result": { "input": ["agent_id", "offset", "limit"], "output": ["task", "result"] },
201
- "external_subagent_close": { "input": ["agent_id"], "output": ["task"] }
555
+ "external_subagent_status": {
556
+ "input": [],
557
+ "output": [
558
+ "mcp_version",
559
+ "components",
560
+ "capabilities",
561
+ "subagents"
562
+ ]
563
+ },
564
+ "external_subagent_spawn": {
565
+ "input": [
566
+ "profile",
567
+ "subagent",
568
+ "repository",
569
+ "permission_mode",
570
+ "prompt",
571
+ "model",
572
+ "effort",
573
+ "write_manifest"
574
+ ],
575
+ "output": [
576
+ "agent_id",
577
+ "status",
578
+ "session_id",
579
+ "instruction"
580
+ ],
581
+ "idempotent": false
582
+ },
583
+ "external_subagent_wait": {
584
+ "input": [
585
+ "agent_id",
586
+ "wait_time",
587
+ "message_id"
588
+ ],
589
+ "output": [
590
+ "task",
591
+ "pending_requests",
592
+ "activity",
593
+ "result",
594
+ "timed_out",
595
+ "message_receipt"
596
+ ],
597
+ "output_many": "<agent_id> keys, \"error\" only when an id was duplicated or unknown",
598
+ "agent_id_forms": "scalar or array of 1..=16",
599
+ "parallel_note": "spawn all subagents first, then wait once with the full id list"
600
+ },
601
+ "external_subagent_observe": {
602
+ "input": [
603
+ "agent_id"
604
+ ],
605
+ "output": [
606
+ "tools",
607
+ "reasoning",
608
+ "coverage"
609
+ ],
610
+ "idempotent": true
611
+ },
612
+ "external_subagent_list": {
613
+ "input": [
614
+ "subagent",
615
+ "repository",
616
+ "phase",
617
+ "outcome",
618
+ "cursor",
619
+ "limit"
620
+ ],
621
+ "output": [
622
+ "tasks",
623
+ "next_cursor"
624
+ ]
625
+ },
626
+ "external_subagent_send": {
627
+ "input": [
628
+ "agent_id",
629
+ "message_id",
630
+ "mode",
631
+ "content"
632
+ ],
633
+ "required": [
634
+ "agent_id",
635
+ "mode",
636
+ "content"
637
+ ],
638
+ "mode": [
639
+ "queue",
640
+ "steer"
641
+ ],
642
+ "output": [
643
+ "message_id",
644
+ "disposition"
645
+ ]
646
+ },
647
+ "external_subagent_respond": {
648
+ "input": [
649
+ "agent_id",
650
+ "request_id",
651
+ "decision",
652
+ "content"
653
+ ],
654
+ "output": [
655
+ "disposition",
656
+ "requested_decision",
657
+ "effective_decision",
658
+ "policy_overrode",
659
+ "policy_reason_code"
660
+ ]
661
+ },
662
+ "external_subagent_cancel": {
663
+ "input": [
664
+ "agent_id"
665
+ ],
666
+ "output": [
667
+ "task"
668
+ ]
669
+ },
670
+ "external_subagent_result": {
671
+ "input": [
672
+ "agent_id",
673
+ "offset",
674
+ "limit"
675
+ ],
676
+ "output": [
677
+ "task",
678
+ "result"
679
+ ]
680
+ },
681
+ "external_subagent_close": {
682
+ "input": [
683
+ "agent_id"
684
+ ],
685
+ "output": [
686
+ "task"
687
+ ]
688
+ }
202
689
  }
203
690
  }
204
691
  },