external-subagent 0.1.4 → 0.2.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.
@@ -2,6 +2,9 @@ import { readConfig } from '../config/read.mjs';
2
2
  import { updateConfig, writeConfig } from '../config/write.mjs';
3
3
  import { SUBAGENT_IDS } from '../config/schema.mjs';
4
4
  import { CliError } from '../errors.mjs';
5
+ import { atomicWrite } from '../fs-atomic.mjs';
6
+ import { hasInstalledService } from '../install/service-activation.mjs';
7
+ import { launchAgentPlist } from '../install/service-macos.mjs';
5
8
 
6
9
  const CONFIG_INPUT_FIELDS = new Set(['operation', 'patch', 'key']);
7
10
  const GET_KEYS = new Set([
@@ -85,6 +88,17 @@ export function parseConfigArgs(args) {
85
88
  throw new CliError('INVALID_ARGUMENT', `unsupported config operation: ${operation}`, 2);
86
89
  }
87
90
 
91
+ // A config change moves the very values the installed LaunchAgent forwards to
92
+ // launchd (subagent homes, config revision), so every successful write also
93
+ // regenerates the service definition from the new config. Only the file is
94
+ // rewritten — launchd picks the environment up at its next load, so a running
95
+ // daemon is never restarted or disturbed here.
96
+ function refreshServiceDefinition(paths) {
97
+ if (!hasInstalledService(paths)) return false;
98
+ atomicWrite(paths.launchAgent, launchAgentPlist(paths), 0o600);
99
+ return true;
100
+ }
101
+
88
102
  export function configCommand(paths, input = {}) {
89
103
  rejectUnknownInput(input);
90
104
  const operation = input.operation ?? 'get';
@@ -98,7 +112,8 @@ export function configCommand(paths, input = {}) {
98
112
  for (const [agent, value] of Object.entries(input.patch.subagents || {})) {
99
113
  if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new CliError('CONFIG_INVALID', `subagents.${agent} must be an object`, 2);
100
114
  }
101
- return { config: updateConfig(paths.config, (latest) => mergePatch(latest, input.patch)) };
115
+ const config = updateConfig(paths.config, (latest) => mergePatch(latest, input.patch));
116
+ return { config, service_definition_refreshed: refreshServiceDefinition(paths) };
102
117
  }
103
118
  if (operation === 'show') {
104
119
  if (input.patch !== undefined || input.key !== undefined) throw new CliError('INVALID_ARGUMENT', 'config show does not accept key or patch', 2);
@@ -106,7 +121,8 @@ export function configCommand(paths, input = {}) {
106
121
  }
107
122
  if (operation === 'unset') {
108
123
  if (input.patch !== undefined || typeof input.key !== 'string') throw new CliError('INVALID_ARGUMENT', 'config unset requires exactly one supported key', 2);
109
- return { config: updateConfig(paths.config, (latest) => mergePatch(latest, unsetPatch(input.key))) };
124
+ const config = updateConfig(paths.config, (latest) => mergePatch(latest, unsetPatch(input.key)));
125
+ return { config, service_definition_refreshed: refreshServiceDefinition(paths) };
110
126
  }
111
127
  if (operation === 'get') {
112
128
  if (input.patch !== undefined) throw new CliError('INVALID_ARGUMENT', 'config get does not accept patch', 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.1.4';
11
+ export const VERSION = '0.2.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';
@@ -4,7 +4,7 @@ import { atomicWrite } from '../fs-atomic.mjs';
4
4
  import { CliError } from '../errors.mjs';
5
5
  import { callDaemon } from '../rpc.mjs';
6
6
  import { LAUNCH_AGENT_LABEL } from '../constants.mjs';
7
- import { launchctl } from './service-macos.mjs';
7
+ import { launchAgentPlist, launchctl } from './service-macos.mjs';
8
8
 
9
9
  const xml = (s) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
10
10
  const unxml = (s) => s.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&');
@@ -15,8 +15,11 @@ export function hasInstalledService(paths) {
15
15
  return Boolean(paths.launchAgent && fs.existsSync(paths.launchAgent));
16
16
  }
17
17
 
18
- // The service definition is the executable entry: change only its first argv,
19
- // retaining the installed configuration/environment and rollback bytes exactly.
18
+ // The service definition is derived from the CURRENT config: activation
19
+ // regenerates the whole plist (environment, revision, runtime forwarding) so a
20
+ // reconfigured installation can never keep serving stale launchd environment.
21
+ // Only rollback restores the previous definition bytes — the exact known state
22
+ // the service ran before — with its executable repointed.
20
23
  export async function activateService(paths, candidate, options = {}) {
21
24
  const control = options.launchctl || launchctl;
22
25
  const rpc = options.callDaemon || callDaemon;
@@ -93,17 +96,21 @@ export async function activateService(paths, candidate, options = {}) {
93
96
  } while (Date.now() < deadline);
94
97
  throw new CliError('SERVICE_UNLOAD_TIMEOUT', 'launchd service remained registered after bootout');
95
98
  };
96
- const writeProgram = (entryPath) => atomicWrite(paths.launchAgent, Buffer.from(oldPlist.toString().replace(program, (_, a, b, c) => `${a}${xml(entryPath)}${c}`)), 0o600);
99
+ const writeDefinition = (entryPath) => atomicWrite(paths.launchAgent, launchAgentPlist(paths, {
100
+ daemonPath: entryPath,
101
+ ...(options.zcodeRuntime !== undefined ? { zcodeRuntime: options.zcodeRuntime } : {}),
102
+ }), 0o600);
103
+ const restoreDefinition = (entryPath) => atomicWrite(paths.launchAgent, Buffer.from(oldPlist.toString().replace(program, (_, a, b, c) => `${a}${xml(entryPath)}${c}`)), 0o600);
97
104
  const rollbackService = async () => {
98
105
  await unload();
99
106
  const previous = rollbackExecutable();
100
- writeProgram(previous.path);
107
+ restoreDefinition(previous.path);
101
108
  await control(['bootstrap', `gui/${process.getuid()}`, paths.launchAgent]);
102
109
  return healthy({ path: previous.path, sha256: previous.sha256, version: oldStatus?.identity?.daemon?.version }, oldPid, oldStatus?.service_generation);
103
110
  };
104
111
  try {
105
112
  await unload();
106
- writeProgram(candidate.path);
113
+ writeDefinition(candidate.path);
107
114
  await control(['bootstrap', `gui/${process.getuid()}`, paths.launchAgent]);
108
115
  const health = await healthy(candidate, oldPid, oldStatus?.service_generation);
109
116
  return {
@@ -28,7 +28,10 @@ export function runtimeObservations(paths, options = {}) {
28
28
  }
29
29
 
30
30
  export function launchAgentPlist(paths, options = {}) {
31
- const daemon = nativeBinary(DAEMON_BIN_NAME);
31
+ // An activation carries its own verified daemon artifact (the retained
32
+ // payload copy), so the entry may be pinned explicitly; every other caller
33
+ // derives it from the installed package.
34
+ const daemon = options.daemonPath || nativeBinary(DAEMON_BIN_NAME);
32
35
  const config = readConfig(paths.config);
33
36
  const { runtime_path: dshRuntime, home: dshHome, profile: dshProfile, version: dshVersion } = config.subagents.dsh;
34
37
  const { runtime_path: codexRuntime, home: codexHome } = config.subagents.codex;
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "product": "external-subagent",
4
- "version": "0.1.4",
4
+ "version": "0.2.0",
5
5
  "platform": "darwin-arm64",
6
6
  "files": [
7
7
  {
8
8
  "name": "external-subagentd",
9
- "bytes": 9332032,
9
+ "bytes": 9529168,
10
10
  "mode": "755",
11
- "sha256": "68bda5d29cdf6c78335575f3e69e5016eff6842e95884d7085d1cf1db2e6f383"
11
+ "sha256": "3ad4a342178a1550ee8c10614493d7a4f2c45588ca1a8409397fdd04e123f343"
12
12
  },
13
13
  {
14
14
  "name": "external-subagent-mcp",
15
15
  "bytes": 937840,
16
16
  "mode": "755",
17
- "sha256": "5d2d9739a777053ee792c61c3d01c8b6c94f01e131bc735d00802eb558b24223"
17
+ "sha256": "e3ccf595590286c5718a35eeefa6ff08d0549a3acf05a828f96b98ed713b5e70"
18
18
  }
19
19
  ]
20
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "external-subagent",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Manage bounded ZCode, DeepSeek Harness (DSH), and Codex subagent tasks through a local MCP service for any local host (Codex, ZCode, or a custom MCP client)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -66,7 +66,7 @@
66
66
  "repository": { "type": "string", "minLength": 1 },
67
67
  "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." },
68
68
  "prompt": { "type": "string", "minLength": 1 },
69
- "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 rejects model selection." },
69
+ "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 uses provider/model (a bare token keeps its backward-compatible zai/<token> reading). Omission falls back to the configured default_model for dsh and zcode, then to the provider-native model; codex requires an explicit model or agents.codex.default_model and never falls back to a provider default." },
70
70
  "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; zcode and dsh pass the bounded token through. Omission keeps the runtime default." },
71
71
  "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. 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." }
72
72
  },
@@ -95,7 +95,7 @@
95
95
  },
96
96
  "wait": {
97
97
  "type": "object",
98
- "description": "Wait returns early only for an actionable pending request (permission requests need allow/deny; user-input requests carry the full embedded question and need answer with non-empty content) or a terminal result; ordinary progress, message receipts, already-responded and unsupported requests never wake it. The pending_requests output projection is capped at 100 actionable records, while the wake decision scans the full pending set. activity carries the latest text tail, a 200-char verified reasoning tail, tool calls started in the last 60 seconds, and telemetry status.",
98
+ "description": "Wait returns early only for an actionable pending request (permission requests need allow/deny; user-input requests carry the full embedded question and need answer with non-empty content) or a terminal result; ordinary progress, message receipts, already-responded and unsupported requests never wake it. The pending_requests output projection is capped at 100 actionable records, while the wake decision scans the full pending set. 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 undelivered bytes that rolled out of the bounded window (that response degrades to the whole window); a terminal response also strips bytes repeating the embedded result page verbatim. activity also carries a 200-char verified reasoning tail, tool calls started in the last 60 seconds, and telemetry status.",
99
99
  "required": ["agent_id"],
100
100
  "properties": {
101
101
  "agent_id": { "type": "integer", "minimum": 10000000, "maximum": 99999999 },