@khorsheed/dsh-local-agent 0.2.0-rc.1 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.en.md +23 -164
  3. package/README.i18n.yaml +2 -2
  4. package/README.md +23 -164
  5. package/lib/client.js +23707 -4768
  6. package/lib/index.js +827 -62
  7. package/lib/member-bridge.js +195 -25
  8. package/lib/tool-factory.js +52 -19
  9. package/lib/typert.host.js +1440 -353
  10. package/lib/typert.remote-client.d.ts +43 -3
  11. package/lib/typert.remote-client.js +973 -335
  12. package/lib/types/client/FamilyCard.d.ts +37 -0
  13. package/lib/types/client/FamilyCard.js +55 -0
  14. package/lib/types/client/HarnessModelPicker.d.ts +21 -1
  15. package/lib/types/client/HarnessModelPicker.js +31 -3
  16. package/lib/types/client/MemberLiveNode.js +38 -4
  17. package/lib/types/client/ModelListSection.d.ts +49 -0
  18. package/lib/types/client/ModelListSection.js +132 -0
  19. package/lib/types/client/ProviderAuthBlock.d.ts +10 -2
  20. package/lib/types/client/ProviderAuthBlock.js +13 -6
  21. package/lib/types/client/auth-status.d.ts +6 -0
  22. package/lib/types/client/auth-status.js +8 -0
  23. package/lib/types/client/harness-cards.d.ts +72 -0
  24. package/lib/types/client/harness-cards.js +57 -0
  25. package/lib/types/client/icons.d.ts +2 -1
  26. package/lib/types/client/icons.js +1 -0
  27. package/lib/types/client/index.d.ts +32 -7
  28. package/lib/types/client/index.js +66 -9
  29. package/lib/types/client/locales.d.ts +25 -1
  30. package/lib/types/client/locales.js +50 -2
  31. package/lib/types/client/model-directory-scope.d.ts +14 -0
  32. package/lib/types/client/model-directory-scope.js +20 -0
  33. package/lib/types/gateway.d.ts +49 -1
  34. package/lib/types/gateway.js +89 -0
  35. package/lib/types/index.d.ts +91 -38
  36. package/lib/types/index.js +190 -58
  37. package/lib/types/live-stream.d.ts +11 -0
  38. package/lib/types/live-stream.js +26 -0
  39. package/lib/types/member-bridge.d.ts +11 -1
  40. package/lib/types/member-bridge.js +81 -13
  41. package/lib/types/member-channel.d.ts +44 -1
  42. package/lib/types/member-channel.js +65 -2
  43. package/lib/types/member-controls.d.ts +8 -0
  44. package/lib/types/member-controls.js +15 -2
  45. package/lib/types/mode-skills.d.ts +18 -0
  46. package/lib/types/mode-skills.js +56 -0
  47. package/lib/types/model-list.d.ts +55 -0
  48. package/lib/types/model-list.js +101 -0
  49. package/lib/types/preset-tool-host.d.ts +70 -0
  50. package/lib/types/preset-tool-host.js +210 -0
  51. package/lib/types/tool-factory.d.ts +9 -3
  52. package/lib/types/tool-factory.js +55 -18
  53. package/lib/types/types.d.ts +62 -5
  54. package/package.json +27 -23
  55. package/lib/invariant.js +0 -85
  56. package/lib/types/invariant.d.ts +0 -16
  57. package/lib/types/invariant.js +0 -105
package/lib/index.js CHANGED
@@ -4,10 +4,13 @@ import { appendFileSync, chmodSync, closeSync, fsyncSync, mkdirSync, openSync, r
4
4
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import z from "@deepseek-ai/schemastery";
7
+ import * as sessionModule from "@deepseek-ai/dsh-session";
7
8
  import { SessionId } from "@deepseek-ai/dsh-session";
8
9
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
9
10
  import { createServer } from "node:net";
11
+ import { createScope } from "@deepseek-ai/dsh-scope";
10
12
  import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
13
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
11
14
  //#region lib/types/cli-version.js
12
15
  /**
13
16
  * The family's CLI version probe: one bounded `<cli> --version` spawn whose
@@ -503,6 +506,11 @@ let LocalAgentGateway = (() => {
503
506
  let _sessions_decorators;
504
507
  let _memberOf_decorators;
505
508
  let _harnessModel_decorators;
509
+ let _checkEndpoint_decorators;
510
+ let _endpointAuth_decorators;
511
+ let _configureEndpoint_decorators;
512
+ let _modelList_decorators;
513
+ let _configureModelList_decorators;
506
514
  let _memberModel_decorators;
507
515
  let _setMemberModel_decorators;
508
516
  let _memberConfiguration_decorators;
@@ -529,6 +537,11 @@ let LocalAgentGateway = (() => {
529
537
  _sessions_decorators = [Remote("sessions")];
530
538
  _memberOf_decorators = [Remote("memberOf")];
531
539
  _harnessModel_decorators = [Remote("harnessModel")];
540
+ _checkEndpoint_decorators = [Remote("checkEndpoint")];
541
+ _endpointAuth_decorators = [Remote("endpointAuth")];
542
+ _configureEndpoint_decorators = [Remote("configureEndpoint")];
543
+ _modelList_decorators = [Remote("modelList")];
544
+ _configureModelList_decorators = [Remote("configureModelList")];
532
545
  _memberModel_decorators = [Remote("memberModel")];
533
546
  _setMemberModel_decorators = [Remote("setMemberModel")];
534
547
  _memberConfiguration_decorators = [Remote("memberConfiguration")];
@@ -672,6 +685,61 @@ let LocalAgentGateway = (() => {
672
685
  },
673
686
  metadata: _metadata
674
687
  }, null, _instanceExtraInitializers);
688
+ __esDecorate(this, null, _checkEndpoint_decorators, {
689
+ kind: "method",
690
+ name: "checkEndpoint",
691
+ static: false,
692
+ private: false,
693
+ access: {
694
+ has: (obj) => "checkEndpoint" in obj,
695
+ get: (obj) => obj.checkEndpoint
696
+ },
697
+ metadata: _metadata
698
+ }, null, _instanceExtraInitializers);
699
+ __esDecorate(this, null, _endpointAuth_decorators, {
700
+ kind: "method",
701
+ name: "endpointAuth",
702
+ static: false,
703
+ private: false,
704
+ access: {
705
+ has: (obj) => "endpointAuth" in obj,
706
+ get: (obj) => obj.endpointAuth
707
+ },
708
+ metadata: _metadata
709
+ }, null, _instanceExtraInitializers);
710
+ __esDecorate(this, null, _configureEndpoint_decorators, {
711
+ kind: "method",
712
+ name: "configureEndpoint",
713
+ static: false,
714
+ private: false,
715
+ access: {
716
+ has: (obj) => "configureEndpoint" in obj,
717
+ get: (obj) => obj.configureEndpoint
718
+ },
719
+ metadata: _metadata
720
+ }, null, _instanceExtraInitializers);
721
+ __esDecorate(this, null, _modelList_decorators, {
722
+ kind: "method",
723
+ name: "modelList",
724
+ static: false,
725
+ private: false,
726
+ access: {
727
+ has: (obj) => "modelList" in obj,
728
+ get: (obj) => obj.modelList
729
+ },
730
+ metadata: _metadata
731
+ }, null, _instanceExtraInitializers);
732
+ __esDecorate(this, null, _configureModelList_decorators, {
733
+ kind: "method",
734
+ name: "configureModelList",
735
+ static: false,
736
+ private: false,
737
+ access: {
738
+ has: (obj) => "configureModelList" in obj,
739
+ get: (obj) => obj.configureModelList
740
+ },
741
+ metadata: _metadata
742
+ }, null, _instanceExtraInitializers);
675
743
  __esDecorate(this, null, _memberModel_decorators, {
676
744
  kind: "method",
677
745
  name: "memberModel",
@@ -981,6 +1049,75 @@ let LocalAgentGateway = (() => {
981
1049
  });
982
1050
  }
983
1051
  /**
1052
+ * Verify one harness's saved endpoint from the host — the settings card's
1053
+ * verify button. The answer is the harness's human-readable verdict text.
1054
+ * Session-less by design: the commands channel needs a main agent session,
1055
+ * which a settings card may not have. Null when the harness is unknown or
1056
+ * declares no probe (the card then hides the button — declare-and-degrade).
1057
+ * @param name - the harness name.
1058
+ * @returns the verdict text, or null.
1059
+ */
1060
+ async checkEndpoint(name) {
1061
+ const harness = this.ctx.localAgent.get(name);
1062
+ if (typeof harness?.checkEndpoint !== "function") return null;
1063
+ return harness.checkEndpoint(this.ctx.localAgent.homeDir(name));
1064
+ }
1065
+ /**
1066
+ * The settings card's read of one harness's resolved endpoint state —
1067
+ * baseUrl verbatim, credentials as presence booleans (secrets never cross).
1068
+ * Session-less like the rest of the gateway. Null when the harness is
1069
+ * unknown or declares no endpoint face (the card then hides the form).
1070
+ * @param name - the harness name.
1071
+ * @returns the resolved per-field state, or null.
1072
+ */
1073
+ async endpointAuth(name) {
1074
+ const harness = this.ctx.localAgent.get(name);
1075
+ if (harness?.endpointAuth === void 0) return null;
1076
+ return harness.endpointAuth.read(this.ctx.localAgent.homeDir(name));
1077
+ }
1078
+ /**
1079
+ * Apply one endpoint-credential edit into the harness's own credential
1080
+ * store (a string sets, null clears, absent leaves), answering the
1081
+ * post-write resolved state so the card can flip immediately. The write
1082
+ * deliberately bypasses the volatile row-config channel — a credential
1083
+ * never lands in the profile patch document. Null when the harness is
1084
+ * unknown or declares no endpoint face.
1085
+ * @param name - the harness name.
1086
+ * @param fields - the field edits.
1087
+ * @returns the post-write resolved state, or null.
1088
+ */
1089
+ async configureEndpoint(name, fields) {
1090
+ const harness = this.ctx.localAgent.get(name);
1091
+ if (harness?.endpointAuth === void 0) return null;
1092
+ await harness.endpointAuth.write(this.ctx.localAgent.homeDir(name), fields);
1093
+ return harness.endpointAuth.read(this.ctx.localAgent.homeDir(name));
1094
+ }
1095
+ /**
1096
+ * The settings card's read of one harness's curated model list — ordered
1097
+ * identifiers, first is the default. Null when the harness is unknown or
1098
+ * declares no list face (the card then hides the editor).
1099
+ * @param name - the harness name.
1100
+ * @returns the ordered list, or null.
1101
+ */
1102
+ async modelList(name) {
1103
+ const harness = this.ctx.localAgent.get(name);
1104
+ if (harness?.modelList === void 0) return null;
1105
+ return harness.modelList.read(this.ctx.localAgent.homeDir(name));
1106
+ }
1107
+ /**
1108
+ * Replace one harness's curated model list wholesale (reorders, removals
1109
+ * and additions all ride the same write), answering the list as persisted.
1110
+ * Null when the harness is unknown or declares no list face.
1111
+ * @param name - the harness name.
1112
+ * @param models - the new ordered list.
1113
+ * @returns the persisted list, or null.
1114
+ */
1115
+ async configureModelList(name, models) {
1116
+ const harness = this.ctx.localAgent.get(name);
1117
+ if (harness?.modelList === void 0) return null;
1118
+ return harness.modelList.write(this.ctx.localAgent.homeDir(name), models);
1119
+ }
1120
+ /**
984
1121
  * The model surface of one member — the composer's picker read. The
985
1122
  * delegation record's requested model rides in from the core so the broker
986
1123
  * can rank it between the override and the settings layer; the record's
@@ -1125,6 +1262,65 @@ let LocalAgentGateway = (() => {
1125
1262
  };
1126
1263
  })();
1127
1264
  //#endregion
1265
+ //#region lib/types/mode-skills.js
1266
+ /**
1267
+ * Mode skills, installed the CLI's own way: a member with a mode gets the
1268
+ * skills that mode contributes (e.g. the 剪辑 mode's `video-studio`) written
1269
+ * into its harness home's skill directory — `skills/<name>/SKILL.md`, the
1270
+ * layout Codex (`$CODEX_HOME`), Claude Code (`$CLAUDE_CONFIG_DIR`) and Kimi
1271
+ * (`$KIMI_CODE_HOME`, its auto-discovered user skill directory) load. The home is shared by every member of that harness, so each skill is
1272
+ * named `dsh-<name>` and says it applies only alongside the mode's tools.
1273
+ * Harnesses without a known skill directory get none. Best-effort:
1274
+ * a failed write never fails the member's tool listing.
1275
+ * @module @khorsheed/dsh-local-agent/mode-skills
1276
+ */
1277
+ /** Harnesses whose CLI loads `<home>/skills/<name>/SKILL.md`. */
1278
+ const SKILL_HOMES = /* @__PURE__ */ new Set([
1279
+ "codex",
1280
+ "claude-code",
1281
+ "kimi"
1282
+ ]);
1283
+ /** The skill directory of a harness home, or undefined when its CLI has none we know. */
1284
+ function harnessSkillsDir(harness, home) {
1285
+ return SKILL_HOMES.has(harness) ? join(home, "skills") : void 0;
1286
+ }
1287
+ /** The SKILL.md a mode skill installs as. */
1288
+ function modeSkillFile(skill, mode) {
1289
+ const name = `dsh-${skill.name}`;
1290
+ const description = `${skill.description} (dsh mode "${mode}": use only when your tools include this mode's tools.)`.replace(/\s+/g, " ");
1291
+ return {
1292
+ name,
1293
+ text: `---\nname: ${name}\ndescription: ${JSON.stringify(description)}\n---\n\n${skill.content.trim()}\n`
1294
+ };
1295
+ }
1296
+ /**
1297
+ * Write the mode's skills into the harness's skill directory, skipping files
1298
+ * already up to date.
1299
+ * @param dir - the harness skill directory.
1300
+ * @param mode - the member's mode id.
1301
+ * @param skills - the mode's skills.
1302
+ * @returns the installed skill names.
1303
+ */
1304
+ function installModeSkills(dir, mode, skills) {
1305
+ const installed = [];
1306
+ for (const skill of skills) {
1307
+ const file = modeSkillFile(skill, mode);
1308
+ const target = join(dir, file.name, "SKILL.md");
1309
+ let current;
1310
+ try {
1311
+ current = readFileSync(target, "utf8");
1312
+ } catch {
1313
+ current = void 0;
1314
+ }
1315
+ if (current !== file.text) {
1316
+ mkdirSync(join(dir, file.name), { recursive: true });
1317
+ writeFileSync(target, file.text);
1318
+ }
1319
+ installed.push(file.name);
1320
+ }
1321
+ return installed;
1322
+ }
1323
+ //#endregion
1128
1324
  //#region lib/types/member-channel.js
1129
1325
  /**
1130
1326
  * Member channel, host side: the loopback listener the bridge MCP servers call
@@ -1153,6 +1349,8 @@ let LocalAgentGateway = (() => {
1153
1349
  * `busy`; else the facade `resume` with a provenance-tagged prompt.
1154
1350
  * @module @khorsheed/dsh-local-agent/member-channel
1155
1351
  */
1352
+ /** How long one bridged mode-tool call may run before it is cancelled. */
1353
+ const MODE_TOOL_TIMEOUT_MS = 18e5;
1156
1354
  /**
1157
1355
  * The member-channel host: owns the loopback unix-socket listener and the
1158
1356
  * delivery chain. Mounted by the plugin's `apply` and disposed with its fiber.
@@ -1161,11 +1359,13 @@ var MemberChannel = class {
1161
1359
  ctx;
1162
1360
  registry;
1163
1361
  socketPath;
1362
+ modeTools;
1164
1363
  server;
1165
- constructor(ctx, registry, socketPath) {
1364
+ constructor(ctx, registry, socketPath, modeTools) {
1166
1365
  this.ctx = ctx;
1167
1366
  this.registry = registry;
1168
1367
  this.socketPath = socketPath;
1368
+ this.modeTools = modeTools;
1169
1369
  }
1170
1370
  /**
1171
1371
  * Start listening. A listener failure (e.g. a socket path beyond the
@@ -1225,7 +1425,8 @@ var MemberChannel = class {
1225
1425
  async answer(socket, line) {
1226
1426
  let outcome;
1227
1427
  try {
1228
- outcome = await this.handle(JSON.parse(line));
1428
+ const request = JSON.parse(line);
1429
+ outcome = request !== null && typeof request === "object" && "modeTools" in request ? await this.handleModeTools(request) : await this.handle(request);
1229
1430
  } catch (error) {
1230
1431
  outcome = {
1231
1432
  ok: false,
@@ -1323,6 +1524,75 @@ var MemberChannel = class {
1323
1524
  receipt: "sent"
1324
1525
  };
1325
1526
  }
1527
+ /**
1528
+ * The member's mode tools: the room says which mode the member has; the
1529
+ * preset tool host lists or runs that mode's plugin tools. No room, no mode
1530
+ * or no host = an empty list.
1531
+ * @param request - the bridge callback.
1532
+ * @returns the list or the call result.
1533
+ */
1534
+ async handleModeTools(request) {
1535
+ const run = typeof request.token === "string" ? this.registry.resolveMemberRun(request.token) : void 0;
1536
+ if (run === void 0) return {
1537
+ ok: false,
1538
+ error: "localAgent: unknown or expired member token"
1539
+ };
1540
+ const mode = this.modeTools === void 0 ? void 0 : await this.probeRoom()?.memberMode?.(run);
1541
+ if ("list" in request.modeTools) {
1542
+ if (mode === void 0) {
1543
+ this.modeTools?.note({
1544
+ childSessionId: run.childSessionId,
1545
+ provider: run.provider,
1546
+ mode: false
1547
+ });
1548
+ return {
1549
+ ok: true,
1550
+ tools: []
1551
+ };
1552
+ }
1553
+ try {
1554
+ const tools = await this.modeTools.list(run.childSessionId, mode.preset, mode.cwd);
1555
+ await this.installSkills(run, mode.preset);
1556
+ return {
1557
+ ok: true,
1558
+ tools
1559
+ };
1560
+ } catch (error) {
1561
+ this.ctx.logger.warn(`localAgent: mode tools for ${run.childSessionId} unavailable: ${String(error)}`);
1562
+ return {
1563
+ ok: true,
1564
+ tools: []
1565
+ };
1566
+ }
1567
+ }
1568
+ if (mode === void 0) return {
1569
+ ok: false,
1570
+ error: "This member has no mode tools"
1571
+ };
1572
+ const { name, arguments: args } = request.modeTools.call;
1573
+ try {
1574
+ return {
1575
+ ok: true,
1576
+ result: await this.modeTools.call(run.childSessionId, mode.preset, name, args ?? {}, AbortSignal.timeout(MODE_TOOL_TIMEOUT_MS), mode.cwd)
1577
+ };
1578
+ } catch (error) {
1579
+ return {
1580
+ ok: false,
1581
+ error: String(error)
1582
+ };
1583
+ }
1584
+ }
1585
+ /** Install the mode's skills into the member's harness skill directory (best-effort). */
1586
+ async installSkills(run, preset) {
1587
+ try {
1588
+ const harness = this.registry.list().map((name) => this.registry.get(name)).find((candidate) => candidate?.delegationProvider === run.provider);
1589
+ const dir = harness === void 0 ? void 0 : harnessSkillsDir(harness.name, this.registry.homeDir(harness.name));
1590
+ if (dir === void 0) return;
1591
+ installModeSkills(dir, preset, await this.modeTools.skills(run.childSessionId, preset));
1592
+ } catch (error) {
1593
+ this.ctx.logger.warn(`localAgent: mode skills for ${run.childSessionId} not installed: ${String(error)}`);
1594
+ }
1595
+ }
1326
1596
  /** Probe the optional room gate without importing any room package. */
1327
1597
  probeRoom() {
1328
1598
  const room = this.ctx.get.call(this.ctx, "room");
@@ -1344,6 +1614,10 @@ var MemberChannel = class {
1344
1614
  to: request.to,
1345
1615
  content: request.text,
1346
1616
  parentSessionId: from.parentSessionId,
1617
+ ...request.kind === "task" || request.kind === "notify" ? { kind: request.kind } : {},
1618
+ ...typeof request.inReplyTo === "string" ? { inReplyTo: request.inReplyTo } : {},
1619
+ ...typeof request.note === "string" ? { note: request.note } : {},
1620
+ ...typeof request.task === "string" ? { task: request.task } : {},
1347
1621
  provenance: {
1348
1622
  childSessionId: from.childSessionId,
1349
1623
  provider: from.provider,
@@ -1372,6 +1646,251 @@ var MemberChannel = class {
1372
1646
  }
1373
1647
  };
1374
1648
  //#endregion
1649
+ //#region lib/types/preset-tool-host.js
1650
+ /**
1651
+ * Preset tool host: lets an external CLI member use the plugin tools of a
1652
+ * host agent preset (a mode), through its member bridge, ON THE MEMBER'S OWN
1653
+ * SESSION. The tool registry keys scoped tools by an agent identity, and
1654
+ * plugin tools read only `agent.id` and `agent.session` from it — so the
1655
+ * member gets a model-less stand-in: `{ id, session }` over its own child
1656
+ * session, given a scope (`createScope`) with the preset mounted. Calls run
1657
+ * through the registry's normal pipeline (policies, approvals, timeouts) as
1658
+ * that stand-in, so plugin state (a video project) belongs to the member's
1659
+ * session and the mode's panels show there; the session also records the mode
1660
+ * (`agent-preset/selected`), so per-session plugin visibility follows it.
1661
+ * Only PLUGIN tools are offered (`Symbol.for('dsh.tool.origin')` channel
1662
+ * `plugin`; the CLI brings its own shell, files and todo).
1663
+ *
1664
+ * Everything host-side is probed, never injected: without `sessions`,
1665
+ * `agentPresets` or `tools` the member simply gets no extra tools.
1666
+ * @module @khorsheed/dsh-local-agent/preset-tool-host
1667
+ */
1668
+ /** Owners whose tools the bridge already serves itself (room), or that delegate from dsh (the local-agent family). */
1669
+ const skippedOwner = (owner) => owner === "@khorsheed/dsh-room" || owner.startsWith("@khorsheed/dsh-local-agent");
1670
+ /** How long a freshly mounted preset may take to finish registering its tools, and how often to look. */
1671
+ const SETTLE_MS = 3e3;
1672
+ const SETTLE_STEP_MS = 150;
1673
+ const ORIGIN = Symbol.for("dsh.tool.origin");
1674
+ /** The mode a session records: its header, overridden by the latest selection. */
1675
+ function sessionPreset(session) {
1676
+ let preset = session.header.agentPreset ?? void 0;
1677
+ for (const event of session.snapshotEvents()) if (event.type === "agent-preset/selected") preset = event.data?.agentPreset ?? preset;
1678
+ return preset;
1679
+ }
1680
+ /** Whether a definition is a plugin tool the bridge may forward. */
1681
+ function isPluginTool(definition) {
1682
+ const origin = definition?.[ORIGIN];
1683
+ return origin?.channel === "plugin" && !skippedOwner(String(origin.owner));
1684
+ }
1685
+ /** Map registry content blocks to MCP content (text and images; anything else as text). */
1686
+ function mcpContent(blocks) {
1687
+ return blocks.map((block) => {
1688
+ if (block.type === "text") return {
1689
+ type: "text",
1690
+ text: block.text ?? ""
1691
+ };
1692
+ if (block.type === "image" && typeof block.data === "string") return {
1693
+ type: "image",
1694
+ data: block.data,
1695
+ mimeType: block.mediaType ?? block.mimeType ?? "image/png"
1696
+ };
1697
+ return {
1698
+ type: "text",
1699
+ text: JSON.stringify(block)
1700
+ };
1701
+ });
1702
+ }
1703
+ /** The per-member preset agents and the list/call operations the bridge forwards. */
1704
+ var PresetToolHost = class {
1705
+ ctx;
1706
+ diagnostics;
1707
+ hosts = /* @__PURE__ */ new Map();
1708
+ /** Stand-in → the member's child session, for routing approvals its calls raise. */
1709
+ standIns = /* @__PURE__ */ new WeakMap();
1710
+ /** The member a stand-in agent acts for, if it is one of ours. */
1711
+ memberOf(agent) {
1712
+ return typeof agent === "object" && agent !== null ? this.standIns.get(agent) : void 0;
1713
+ }
1714
+ /**
1715
+ * @param ctx - the local-agent plugin context.
1716
+ * @param diagnostics - optional JSONL file each listing appends a summary to
1717
+ * (what the preset agent's scope saw and which tools passed the filter).
1718
+ */
1719
+ constructor(ctx, diagnostics) {
1720
+ this.ctx = ctx;
1721
+ this.diagnostics = diagnostics;
1722
+ }
1723
+ /** Append one diagnostics line (best-effort). */
1724
+ note(entry) {
1725
+ if (this.diagnostics === void 0) return;
1726
+ try {
1727
+ appendFileSync(this.diagnostics, `${JSON.stringify({
1728
+ time: (/* @__PURE__ */ new Date()).toISOString(),
1729
+ ...entry
1730
+ })}\n`);
1731
+ } catch {}
1732
+ }
1733
+ probe(name) {
1734
+ return this.ctx.get.call(this.ctx, name);
1735
+ }
1736
+ /** The member's stand-in, scoped with its preset on first use (and re-scoped when the preset changed). */
1737
+ async agentFor(childSessionId, preset, _cwd) {
1738
+ const sessions = this.probe("sessions");
1739
+ const presets = this.probe("agentPresets");
1740
+ const session = sessions?.get(SessionId(childSessionId));
1741
+ if (presets?.mount === void 0 || session === void 0) return void 0;
1742
+ const current = this.hosts.get(childSessionId);
1743
+ if (current !== void 0 && current.preset === preset && (await current.agent).agent.session === session) return (await current.agent).agent;
1744
+ if (current !== void 0) {
1745
+ this.hosts.delete(childSessionId);
1746
+ current.agent.then((handle) => handle.dispose()).catch(() => {});
1747
+ }
1748
+ const agent = (async () => {
1749
+ const resolved = await presets.resolve(preset);
1750
+ const member = {
1751
+ id: childSessionId,
1752
+ session
1753
+ };
1754
+ this.standIns.set(member, childSessionId);
1755
+ const scope = createScope(this.ctx, member);
1756
+ member.ctx = scope.ctx;
1757
+ try {
1758
+ await presets.mount(scope.ctx, resolved.id);
1759
+ } catch (error) {
1760
+ await scope.dispose();
1761
+ throw error;
1762
+ }
1763
+ if (sessionPreset(session) !== resolved.id) session.append("agent-preset/selected", { agentPreset: resolved.id });
1764
+ return {
1765
+ agent: member,
1766
+ dispose: () => scope.dispose()
1767
+ };
1768
+ })();
1769
+ this.hosts.set(childSessionId, {
1770
+ preset,
1771
+ agent
1772
+ });
1773
+ try {
1774
+ return (await agent).agent;
1775
+ } catch (error) {
1776
+ this.hosts.delete(childSessionId);
1777
+ throw error;
1778
+ }
1779
+ }
1780
+ /**
1781
+ * The preset's plugin tools, for a member's `tools/list`.
1782
+ * @param childSessionId - the member's child session.
1783
+ * @param preset - the member's mode.
1784
+ * @param cwd - the member's working directory.
1785
+ * @returns the tools, empty when the host lacks the capabilities.
1786
+ */
1787
+ async list(childSessionId, preset, cwd) {
1788
+ const tools = this.probe("tools");
1789
+ const fresh = this.hosts.get(childSessionId)?.preset !== preset;
1790
+ const agent = await this.agentFor(childSessionId, preset, cwd);
1791
+ if (tools === void 0 || agent === void 0) {
1792
+ this.note({
1793
+ childSessionId,
1794
+ preset,
1795
+ tools: tools !== void 0,
1796
+ agent: agent !== void 0
1797
+ });
1798
+ return [];
1799
+ }
1800
+ let schemas = tools.schemas(agent);
1801
+ if (fresh) for (let waited = 0; waited < SETTLE_MS; waited += SETTLE_STEP_MS) {
1802
+ await new Promise((resolve) => setTimeout(resolve, SETTLE_STEP_MS));
1803
+ const next = tools.schemas(agent);
1804
+ if (next.length === schemas.length && waited >= 450) break;
1805
+ schemas = next;
1806
+ }
1807
+ const listed = schemas.filter((schema) => isPluginTool(tools.get(schema.name, agent)));
1808
+ this.note({
1809
+ childSessionId,
1810
+ preset,
1811
+ agent: agent.id,
1812
+ scope: schemas.map((schema) => {
1813
+ const origin = tools.get(schema.name, agent)?.[ORIGIN];
1814
+ return origin === void 0 ? schema.name : `${schema.name}<${String(origin.channel)}:${String(origin.owner)}>`;
1815
+ }),
1816
+ listed: listed.map((schema) => schema.name)
1817
+ });
1818
+ return listed.map((schema) => ({
1819
+ name: schema.name,
1820
+ description: schema.description ?? "",
1821
+ inputSchema: schema.parameters ?? {
1822
+ type: "object",
1823
+ properties: {}
1824
+ }
1825
+ }));
1826
+ }
1827
+ /**
1828
+ * Run one plugin tool for a member through the registry's execution
1829
+ * pipeline (policies, approvals, timeouts), as the member's preset agent.
1830
+ * @param childSessionId - the member's child session.
1831
+ * @param preset - the member's mode.
1832
+ * @param name - the tool name.
1833
+ * @param args - the tool arguments.
1834
+ * @param signal - cancellation.
1835
+ * @param cwd - the member's working directory.
1836
+ * @returns the MCP result.
1837
+ */
1838
+ async call(childSessionId, preset, name, args, signal, cwd) {
1839
+ const tools = this.probe("tools");
1840
+ const agent = await this.agentFor(childSessionId, preset, cwd);
1841
+ if (tools === void 0 || agent === void 0) return {
1842
+ content: [{
1843
+ type: "text",
1844
+ text: "Mode tools are unavailable on this host"
1845
+ }],
1846
+ isError: true
1847
+ };
1848
+ if (!isPluginTool(tools.get(name, agent))) return {
1849
+ content: [{
1850
+ type: "text",
1851
+ text: `Unknown tool ${name} in mode ${preset}`
1852
+ }],
1853
+ isError: true
1854
+ };
1855
+ const result = await tools.execute({
1856
+ callId: `bridge-${randomUUID()}`,
1857
+ name,
1858
+ arguments: args,
1859
+ agent,
1860
+ signal
1861
+ });
1862
+ return {
1863
+ content: mcpContent(result.content),
1864
+ isError: result.isError
1865
+ };
1866
+ }
1867
+ /**
1868
+ * The skills the member's mode contributes: visible in its scope, absent
1869
+ * from the global view (installed skills every CLI already sees stay out).
1870
+ * @param childSessionId - the member's child session.
1871
+ * @param preset - the member's mode.
1872
+ * @returns the mode's skills with their content; empty without a skill registry.
1873
+ */
1874
+ async skills(childSessionId, preset) {
1875
+ const registry = this.probe("skills");
1876
+ const agent = await this.agentFor(childSessionId, preset, void 0);
1877
+ if (registry?.list === void 0 || agent === void 0) return [];
1878
+ const global = new Set((await registry.list()).map((skill) => skill.name));
1879
+ const own = (await registry.list({ scope: agent })).filter((skill) => !global.has(skill.name));
1880
+ return (await Promise.all(own.map((skill) => registry.get(skill.name, { scope: agent })))).flatMap((skill) => skill === void 0 ? [] : [{
1881
+ name: skill.name,
1882
+ description: skill.description,
1883
+ content: skill.content
1884
+ }]);
1885
+ }
1886
+ /** Dispose every preset agent. */
1887
+ async dispose() {
1888
+ const hosts = [...this.hosts.values()];
1889
+ this.hosts.clear();
1890
+ await Promise.all(hosts.map((host) => host.agent.then((handle) => handle.dispose()).catch(() => {})));
1891
+ }
1892
+ };
1893
+ //#endregion
1375
1894
  //#region lib/types/member-inbox.js
1376
1895
  /** Durable human input; the provider FIFO remains the sole execution order. */
1377
1896
  var MemberInbox = class {
@@ -2059,9 +2578,20 @@ var MemberControls = class {
2059
2578
  adapter;
2060
2579
  onError;
2061
2580
  admitted = /* @__PURE__ */ new WeakMap();
2581
+ /**
2582
+ * Latest admitted configuration per child session id. Host 0.2.1 note: the
2583
+ * activation manager wraps the provider's run, so a facade/tool caller never
2584
+ * holds the exact object {@link admitted} keys — its run adapter names the
2585
+ * child session id instead. Rounds of one child serialize through this
2586
+ * class's queue, so the latest entry at read time is the reader's own round.
2587
+ */
2588
+ admittedByChild = /* @__PURE__ */ new Map();
2062
2589
  configurationOf(run) {
2063
- const configuration = this.admitted.get(run);
2064
- return configuration === void 0 ? void 0 : structuredClone(configuration);
2590
+ const exact = this.admitted.get(run);
2591
+ if (exact !== void 0) return structuredClone(exact);
2592
+ const id = run.id;
2593
+ const byChild = typeof id === "string" ? this.admittedByChild.get(id) : void 0;
2594
+ return byChild === void 0 ? void 0 : structuredClone(byChild);
2065
2595
  }
2066
2596
  members = /* @__PURE__ */ new Map();
2067
2597
  constructor(storage, adapter, onError) {
@@ -2176,6 +2706,7 @@ var MemberControls = class {
2176
2706
  nativeStarted = true;
2177
2707
  const run = await start(lease.configuration);
2178
2708
  this.admitted.set(run, lease.configuration);
2709
+ this.admittedByChild.set(binding.childSessionId, lease.configuration);
2179
2710
  const release = () => {
2180
2711
  try {
2181
2712
  lease.release();
@@ -2394,6 +2925,99 @@ function delegationEnv(extra) {
2394
2925
  };
2395
2926
  }
2396
2927
  //#endregion
2928
+ //#region lib/types/model-list.js
2929
+ /**
2930
+ * A harness's curated model list: `<scopedHome>/models.json` holds an
2931
+ * ORDERED array of model identifiers the user picked from the endpoint's
2932
+ * served catalog. The list's FIRST entry is the harness's default model —
2933
+ * it answers wherever the plugin-config `model` key answered before (round
2934
+ * resolution, the broker's settings layer, the eval snapshot's model
2935
+ * field), ahead of that key; an absent or empty list leaves the
2936
+ * pre-existing chain untouched (settings key → the scoped config file →
2937
+ * the CLI's own default). One store shape serves every harness in the
2938
+ * family (claude-code, codex, kimi): the file is keyed by the scoped home
2939
+ * alone, so a scope carries its own list exactly like its own endpoint
2940
+ * credential.
2941
+ *
2942
+ * Why a file and not the settings channel: the credential form's
2943
+ * endpoint-auth.json proved the pattern — the harness owns the store, the
2944
+ * volatile row-config channel rejects writes a deployment cannot explain,
2945
+ * and the file keeps every scope free to carry its own list. The settings
2946
+ * card edits the default scope's file through the gateway's
2947
+ * `modelList`/`configureModelList` Remotes; nothing here is secret (0600 is
2948
+ * for uniformity with the credential file, not necessity).
2949
+ * @module @khorsheed/dsh-local-agent/model-list
2950
+ */
2951
+ /** The scoped-home model list file. */
2952
+ const MODEL_LIST_FILE = "models.json";
2953
+ /** Normalize one list: trimmed, blank-dropped, deduplicated keeping first occurrence. */
2954
+ function normalizeModelList(models) {
2955
+ const out = [];
2956
+ for (const candidate of models) {
2957
+ if (typeof candidate !== "string") continue;
2958
+ const trimmed = candidate.trim();
2959
+ if (trimmed !== "" && !out.includes(trimmed)) out.push(trimmed);
2960
+ }
2961
+ return out;
2962
+ }
2963
+ /** Fold a parsed file payload into a list; any other shape reads as empty. */
2964
+ function fold(parsed) {
2965
+ if (Array.isArray(parsed)) return normalizeModelList(parsed);
2966
+ if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed.models)) return normalizeModelList(parsed.models);
2967
+ return [];
2968
+ }
2969
+ /**
2970
+ * Read the scope's model list. Absent, unreadable, or malformed content all
2971
+ * read as an empty list — the fallback chain then decides, exactly as
2972
+ * before the file existed.
2973
+ * @param homeDir - the harness's scoped home.
2974
+ * @returns the ordered model identifiers.
2975
+ */
2976
+ async function readModelList(homeDir) {
2977
+ try {
2978
+ return fold(JSON.parse(await readFile(join(homeDir, MODEL_LIST_FILE), "utf8")));
2979
+ } catch {
2980
+ return [];
2981
+ }
2982
+ }
2983
+ /**
2984
+ * The sync twin of {@link readModelList} for the per-round model resolver —
2985
+ * one small file read per resolution, no caching, so a card write always
2986
+ * reaches the very next round.
2987
+ * @param homeDir - the scope's scoped home.
2988
+ * @returns the ordered model identifiers.
2989
+ */
2990
+ function readModelListSync(homeDir) {
2991
+ try {
2992
+ return fold(JSON.parse(readFileSync(join(homeDir, MODEL_LIST_FILE), "utf8")));
2993
+ } catch {
2994
+ return [];
2995
+ }
2996
+ }
2997
+ /** The list's default model: its first entry, or undefined when empty. */
2998
+ function firstModelOf(models) {
2999
+ return models.length === 0 ? void 0 : models[0];
3000
+ }
3001
+ /**
3002
+ * Persist the scope's model list (normalized; an empty list DELETES the
3003
+ * file rather than shadowing the fallback chain with nothing). Answers the
3004
+ * normalized list as written, so the writer can flip its surface without a
3005
+ * re-read.
3006
+ * @param homeDir - the scope's scoped home.
3007
+ * @param models - the new ordered list.
3008
+ * @returns the normalized list.
3009
+ */
3010
+ async function writeModelList(homeDir, models) {
3011
+ const normalized = normalizeModelList(models);
3012
+ if (normalized.length === 0) {
3013
+ await rm(join(homeDir, MODEL_LIST_FILE), { force: true });
3014
+ return [];
3015
+ }
3016
+ await mkdir(homeDir, { recursive: true });
3017
+ await writeFile(join(homeDir, MODEL_LIST_FILE), JSON.stringify({ models: normalized }, void 0, 2) + "\n", { mode: 384 });
3018
+ return normalized;
3019
+ }
3020
+ //#endregion
2397
3021
  //#region lib/types/live-flush.js
2398
3022
  /** Maximum batching delay for a live mirror; independent of persistence cadence. */
2399
3023
  const LIVE_FLUSH_INTERVAL_MS = 50;
@@ -2553,6 +3177,8 @@ var LiveStreamPublisher = class {
2553
3177
  persist;
2554
3178
  onError;
2555
3179
  states = /* @__PURE__ */ new Map();
3180
+ /** Items shown live but not yet in the log: their step cannot open before the earlier steps close. */
3181
+ previews = /* @__PURE__ */ new Map();
2556
3182
  constructor(streams, session, turn, persist, onError) {
2557
3183
  this.streams = streams;
2558
3184
  this.session = session;
@@ -2560,8 +3186,29 @@ var LiveStreamPublisher = class {
2560
3186
  this.persist = persist;
2561
3187
  this.onError = onError;
2562
3188
  }
3189
+ /**
3190
+ * Show an item live without writing it to the session: a step may open in
3191
+ * the log only after every earlier step has closed, so a later item that
3192
+ * streams first waits here (browser-only) until the driver can open it.
3193
+ */
3194
+ preview(step, kind, text, options = {}) {
3195
+ const id = `${this.turn}:${step}${options.itemId === void 0 ? "" : `:${options.itemId}`}`;
3196
+ const previous = this.previews.get(id);
3197
+ if (previous?.text === text && previous.kind === kind) return;
3198
+ const item = {
3199
+ id,
3200
+ turn: this.turn,
3201
+ step,
3202
+ kind,
3203
+ text,
3204
+ receivedAt: previous?.receivedAt ?? options.receivedAt ?? Date.now()
3205
+ };
3206
+ this.previews.set(id, item);
3207
+ this.streams.publish(String(this.session.id), item);
3208
+ }
2563
3209
  update(step, kind, text, options = {}) {
2564
3210
  const id = `${this.turn}:${step}${options.itemId === void 0 ? "" : `:${options.itemId}`}`;
3211
+ this.previews.delete(id);
2565
3212
  let state = this.states.get(id);
2566
3213
  if (state?.item.text === text && state.item.kind === kind) return;
2567
3214
  const receivedAt = options.receivedAt ?? Date.now();
@@ -2620,6 +3267,11 @@ var LiveStreamPublisher = class {
2620
3267
  }
2621
3268
  /** Call only after the corresponding native final message has been appended. */
2622
3269
  finish(step) {
3270
+ for (const [id, item] of this.previews) {
3271
+ if (item.step !== step) continue;
3272
+ this.previews.delete(id);
3273
+ this.streams.finish(String(this.session.id), id);
3274
+ }
2623
3275
  for (const [id, state] of this.states) {
2624
3276
  if (state.item.step !== step) continue;
2625
3277
  if (state.timer !== void 0) clearTimeout(state.timer);
@@ -2639,6 +3291,8 @@ var LiveStreamPublisher = class {
2639
3291
  }
2640
3292
  /** A producer failure keeps a durable partial, without claiming completion. */
2641
3293
  dispose() {
3294
+ for (const id of this.previews.keys()) this.streams.finish(String(this.session.id), id);
3295
+ this.previews.clear();
2642
3296
  for (const [id, state] of this.states) {
2643
3297
  this.checkpoint(id);
2644
3298
  if (state.timer !== void 0) clearTimeout(state.timer);
@@ -3094,12 +3748,49 @@ function delegationIntentKey(parentSessionId, provider) {
3094
3748
  * `cancel()` and the caller's own abort channel both reach the run.
3095
3749
  * `AbortSignal.any` is the harness's own fusion primitive (agent-loop's resume
3096
3750
  * uses it); either source aborts the run.
3751
+ *
3752
+ * Host 0.2.1 note: the fused signal reaches `startActivation`'s `spec.signal`,
3753
+ * which owns cancellation only up to publication — after the activation is
3754
+ * published the manager unlinks it and `dispose()` is the sole cancel channel.
3755
+ * {@link activationRun} therefore re-wires the caller signal to disposal.
3097
3756
  */
3098
3757
  function fusedSignal(controller, caller) {
3099
3758
  if (caller === void 0) return controller.signal;
3100
3759
  return AbortSignal.any([controller.signal, caller]);
3101
3760
  }
3102
3761
  /**
3762
+ * Adapt a 0.2.1 managed activation to the family's published `SubagentRun`
3763
+ * shape. The adapter's `id` is the dsh child session id — the family's
3764
+ * tracking key, resume handle, and client-facing identity — NOT the
3765
+ * activation's `childId`, which for an external (CLI) provider is the
3766
+ * provider's per-execution id and never names the mirrored live Session.
3767
+ * `dispose()` is the activation's exact-epoch cancellation: it settles the
3768
+ * result with an `aborted`-class stop reason and runs teardown, so a caller
3769
+ * signal aborted mid-run is re-wired to it (the start signal the facade fused
3770
+ * is unlinked by the manager at publication). The listener detaches when the
3771
+ * result settles, so a long-lived caller signal never accumulates them.
3772
+ */
3773
+ function activationRun(activation, childSessionId, caller, warn) {
3774
+ const dispose = () => activation.dispose();
3775
+ if (caller !== void 0) {
3776
+ const onAbort = () => {
3777
+ dispose().catch((error) => {
3778
+ warn(`localAgent: caller-signal disposal of ${childSessionId} failed: ${String(error)}`);
3779
+ });
3780
+ };
3781
+ caller.addEventListener("abort", onAbort, { once: true });
3782
+ const detach = () => {
3783
+ caller.removeEventListener("abort", onAbort);
3784
+ };
3785
+ activation.result.then(detach, detach);
3786
+ }
3787
+ return {
3788
+ id: SessionId(childSessionId),
3789
+ result: activation.result,
3790
+ dispose
3791
+ };
3792
+ }
3793
+ /**
3103
3794
  * The per-harness append-only delegation-mapping log inside the harness's
3104
3795
  * scoped home (`<homeDir>/<harness>/delegations.jsonl`): one JSON record per
3105
3796
  * line, the last line per childSessionId wins — the same replace semantics as
@@ -3259,6 +3950,11 @@ function parseScopeFlag(input) {
3259
3950
  ...scope === void 0 ? {} : { scope }
3260
3951
  };
3261
3952
  }
3953
+ /** A delegation record without its requested model. */
3954
+ function withoutModel(record) {
3955
+ const { model: _model, ...rest } = record;
3956
+ return rest;
3957
+ }
3262
3958
  /** Map a login failure to a command error, naming the swallowed signal. */
3263
3959
  function loginFailure(harness, exitCode, signal) {
3264
3960
  return `${harness.name} login exited with ${signal ?? `code ${exitCode ?? "unknown"}`}; try /${harness.name} login again.`;
@@ -3428,9 +4124,9 @@ var LocalAgentRegistry = class {
3428
4124
  /**
3429
4125
  * Per-(parent session, provider) FIFO of delegation intents. The family
3430
4126
  * resume tool stages exactly one intent per call before awaiting
3431
- * `ctx.subagents.start()`, and the owning provider consumes exactly one per
3432
- * start, so fresh and resume rounds stay paired even under parallel
3433
- * delegations from one parent.
4127
+ * `ctx.subagents.startActivation()`, and the owning provider consumes
4128
+ * exactly one per start, so fresh and resume rounds stay paired even under
4129
+ * parallel delegations from one parent.
3434
4130
  */
3435
4131
  delegationIntents = /* @__PURE__ */ new Map();
3436
4132
  /**
@@ -3483,10 +4179,12 @@ var LocalAgentRegistry = class {
3483
4179
  * - **facade-tracked** ({@link trackRun}): runs started through
3484
4180
  * {@link LocalAgentRegistry.start} / {@link LocalAgentRegistry.resume},
3485
4181
  * carrying the fused AbortController whose signal reached
3486
- * `ctx.subagents.start`; cancel aborts it.
4182
+ * `ctx.subagents.startActivation`; cancel aborts the pre-publication
4183
+ * signal and disposes the published activation.
3487
4184
  * - **tool-registered** ({@link trackDelegationRun}): runs the family tool
3488
- * starts directly through `ctx.subagents.start`, carrying an explicit
3489
- * cancel lever (its fused controller); cancel calls it.
4185
+ * starts directly through `ctx.subagents.startActivation`, carrying an
4186
+ * explicit cancel lever (its fused controller plus the activation's
4187
+ * dispose); cancel calls it.
3490
4188
  *
3491
4189
  * Every entry clears itself when the run's result settles (any stop
3492
4190
  * reason), and facade entries also stop their heartbeat timer then.
@@ -3863,7 +4561,8 @@ var LocalAgentRegistry = class {
3863
4561
  recordDelegation(record) {
3864
4562
  const binding = this.memberControls.binding(record.childSessionId);
3865
4563
  if (binding !== void 0) record = {
3866
- ...record,
4564
+ ...withoutModel(record),
4565
+ ...binding.model === void 0 ? {} : { model: binding.model },
3867
4566
  ...binding.effort === void 0 ? {} : { effort: binding.effort },
3868
4567
  ...binding.configurationLock === void 0 ? {} : { configurationLock: binding.configurationLock }
3869
4568
  };
@@ -3899,9 +4598,10 @@ var LocalAgentRegistry = class {
3899
4598
  /**
3900
4599
  * Stage one delegation intent for a provider's next `start()`. The family
3901
4600
  * resume tool stages exactly one intent per call before awaiting
3902
- * `ctx.subagents.start()`; the provider consumes exactly one per start via
3903
- * {@link takeDelegationIntent}, so the per-(parent, provider) FIFO pairs
3904
- * fresh and resume rounds even under parallel delegations from one parent.
4601
+ * `ctx.subagents.startActivation()`; the provider consumes exactly one per
4602
+ * start via {@link takeDelegationIntent}, so the per-(parent, provider)
4603
+ * FIFO pairs fresh and resume rounds even under parallel delegations from
4604
+ * one parent.
3905
4605
  * @param parentSessionId - the delegating parent session id.
3906
4606
  * @param provider - the `ctx.subagents` provider name.
3907
4607
  * @param intent - the fresh or resume intent for the next start.
@@ -3933,7 +4633,7 @@ var LocalAgentRegistry = class {
3933
4633
  }
3934
4634
  /**
3935
4635
  * Remove one previously staged intent by reference, returning whether it was
3936
- * still queued. The delegation facade calls this when `ctx.subagents.start()`
4636
+ * still queued. The delegation facade calls this when `ctx.subagents.startActivation()`
3937
4637
  * throws: an intent the provider never consumed must not linger in the FIFO,
3938
4638
  * where the NEXT same-(parent, provider) start would misconsume it as its own
3939
4639
  * (orphan-intent crosstalk). When the provider already consumed the intent
@@ -3956,9 +4656,7 @@ var LocalAgentRegistry = class {
3956
4656
  return true;
3957
4657
  }
3958
4658
  /**
3959
- * Every recorded delegation, in recording order. The invariant companion
3960
- * cross-checks each record's provider against the mounted subagent
3961
- * providers so a provider rename cannot leave a stale resume mapping.
4659
+ * Every recorded delegation, in recording order.
3962
4660
  * @returns the recorded delegations.
3963
4661
  */
3964
4662
  listDelegations() {
@@ -4276,7 +4974,9 @@ var LocalAgentRegistry = class {
4276
4974
  * {@link unstageDelegationIntent} (a no-op once the provider consumed it).
4277
4975
  *
4278
4976
  * The returned run is tracked under its `run.id` (== the new dsh child
4279
- * session id) until `run.result` settles; {@link cancel} aborts it by that
4977
+ * session id, minted here and carried to the provider through the staged
4978
+ * intent — host 0.2.1's `startActivation` never surfaces it on the
4979
+ * activation) until `run.result` settles; {@link cancel} aborts it by that
4280
4980
  * id. Note on trust: cordis plugins share one process, so the ownership
4281
4981
  * checks here and in {@link resume} guard against caller MISTAKES, not
4282
4982
  * against a malicious plugin — their real protection target is untrusted
@@ -4314,10 +5014,12 @@ var LocalAgentRegistry = class {
4314
5014
  const subagents = this.requireSubagents();
4315
5015
  this.requireProvider(subagents, provider);
4316
5016
  const parent = this.requireLiveParent(parentSessionId);
5017
+ const childSessionId = prepared?.binding.childSessionId ?? randomUUID();
4317
5018
  const intent = {
4318
5019
  kind: "fresh",
4319
5020
  ...options?.onAdmitted === void 0 ? {} : { onAdmitted: options.onAdmitted },
4320
5021
  ...options?.preparedMemberId === void 0 ? {} : { preparedMemberId: options.preparedMemberId },
5022
+ ...prepared === void 0 ? { childSessionId } : {},
4321
5023
  ...options?.cwd === void 0 ? {} : { cwd: options.cwd },
4322
5024
  ...options?.exec === void 0 ? {} : { exec: options.exec },
4323
5025
  ...options?.scope === void 0 ? {} : { scope: options.scope },
@@ -4329,17 +5031,21 @@ var LocalAgentRegistry = class {
4329
5031
  const controller = new AbortController();
4330
5032
  let run;
4331
5033
  try {
4332
- run = await subagents.start(provider, {
4333
- prompt,
4334
- parent,
5034
+ run = activationRun(await subagents.startActivation({
5035
+ provider,
5036
+ label: options?.label ?? "",
5037
+ request: {
5038
+ prompt,
5039
+ parent
5040
+ },
4335
5041
  signal: fusedSignal(controller, options?.signal),
4336
- ...options?.label === void 0 ? {} : { label: options.label }
4337
- });
5042
+ delivery: "caller"
5043
+ }), childSessionId, options?.signal, (message) => this.ctx.logger.warn(message));
4338
5044
  } catch (error) {
4339
5045
  this.unstageDelegationIntent(parentSessionId, provider, intent);
4340
5046
  throw error;
4341
5047
  }
4342
- this.trackRun(run.id, controller, run, options?.onProgress);
5048
+ this.trackRun(childSessionId, controller, run, options?.onProgress);
4343
5049
  if (prepared !== void 0) try {
4344
5050
  this.preparedMembers.write({
4345
5051
  ...prepared,
@@ -4363,12 +5069,12 @@ var LocalAgentRegistry = class {
4363
5069
  * without staging; the provider still takes the authoritative lock inside
4364
5070
  * its `start()`;
4365
5071
  * 4. live parent agent resolution (`ctx.agents.get`) — a harness hard
4366
- * constraint, since `ctx.subagents.start` requires a live parent;
5072
+ * constraint, since `ctx.subagents.startActivation` requires a live parent;
4367
5073
  * 5. child-session REATTACH when `ctx.sessions.get(childSessionId)` is
4368
5074
  * undefined (recipe below; `opts.reattach === false` disables this step
4369
5075
  * and fails loud on a non-live child instead);
4370
5076
  * 6. stage `{ kind: 'resume', childSessionId, cliSessionId }` and, in the
4371
- * same synchronous flow, `await ctx.subagents.start(provider, …)`;
5077
+ * same synchronous flow, `await ctx.subagents.startActivation({…})`;
4372
5078
  * 7. on start failure, roll the intent back via
4373
5079
  * {@link unstageDelegationIntent} (no-op once consumed);
4374
5080
  * 8. on success, track the run under `childSessionId` for {@link cancel}
@@ -4381,8 +5087,12 @@ var LocalAgentRegistry = class {
4381
5087
  * const handle = await ctx.sessionPersistence.open(SessionId(childSessionId), 'write')
4382
5088
  * try {
4383
5089
  * const cold = await handle.read(0)
5090
+ * // Close a turn a restart interrupted, or the next round's turn/start
5091
+ * // makes the stored log unreadable.
5092
+ * const closers = interruptedTurnClosers(cold.events)
5093
+ * if (closers.length > 0) await handle.append(closers)
4384
5094
  * const session = ctx.sessions.prepare(SessionId(childSessionId), {
4385
- * seed: [...cold.events],
5095
+ * seed: [...cold.events, ...closers],
4386
5096
  * meta: structuredClone(handle.header),
4387
5097
  * inheritedEventCount: handle.inheritedEventCount,
4388
5098
  * eventState: cold.eventState,
@@ -4390,7 +5100,7 @@ var LocalAgentRegistry = class {
4390
5100
  * // The seeded constructor's session/end-seed marker lands before enter()
4391
5101
  * // installs publication hooks — push the unstored suffix through the
4392
5102
  * // handle directly, or the writer's contiguous cursor diverges.
4393
- * const unstored = session.snapshotEvents().slice(cold.events.length)
5103
+ * const unstored = session.snapshotEvents().slice(cold.events.length + closers.length)
4394
5104
  * if (unstored.length > 0) await handle.append(unstored)
4395
5105
  * const detach = ctx.sessions.enter(session)
4396
5106
  * try { ctx.sessions.announce(session) }
@@ -4448,12 +5158,16 @@ var LocalAgentRegistry = class {
4448
5158
  const controller = new AbortController();
4449
5159
  let run;
4450
5160
  try {
4451
- run = await subagents.start(provider, {
4452
- prompt,
4453
- parent,
5161
+ run = activationRun(await subagents.startActivation({
5162
+ provider,
5163
+ label: options?.label ?? "",
5164
+ request: {
5165
+ prompt,
5166
+ parent
5167
+ },
4454
5168
  signal: fusedSignal(controller, options?.signal),
4455
- ...options?.label === void 0 ? {} : { label: options.label }
4456
- });
5169
+ delivery: "caller"
5170
+ }), childSessionId, options?.signal, (message) => this.ctx.logger.warn(message));
4457
5171
  } catch (error) {
4458
5172
  this.unstageDelegationIntent(parentSessionId, provider, intent);
4459
5173
  throw error;
@@ -4462,11 +5176,12 @@ var LocalAgentRegistry = class {
4462
5176
  return run;
4463
5177
  }
4464
5178
  /**
4465
- * Cancel an in-flight facade-started run by its dsh child session id: aborts
4466
- * the internal controller whose (fused) signal was passed to
4467
- * `ctx.subagents.start`, which is the canonical cancellation channel for a
4468
- * one-shot run. The provider settles the run with an `aborted`-class stop
4469
- * reason. A caller holding the `SubagentRun` may also `run.dispose()`.
5179
+ * Cancel an in-flight facade-started run by its dsh child session id:
5180
+ * aborts the internal controller (the pre-publication channel fused into
5181
+ * the activation's start signal) and disposes the activation — host 0.2.1's
5182
+ * canonical POST-publication cancellation, which settles the run with an
5183
+ * `aborted`-class stop reason. A caller holding the `SubagentRun` may also
5184
+ * `run.dispose()`.
4470
5185
  * @param childSessionId - the dsh child session id of the run to cancel.
4471
5186
  * @returns whether an in-flight run was found and signalled (false on a
4472
5187
  * miss — never a silent success, never a throw).
@@ -4474,24 +5189,29 @@ var LocalAgentRegistry = class {
4474
5189
  cancel(childSessionId) {
4475
5190
  const entry = this.runs.get(childSessionId);
4476
5191
  if (entry === void 0) return this.memberInbox.cancelStarting(childSessionId);
4477
- if (entry.controller !== void 0) entry.controller.abort();
4478
- else entry.cancel?.();
5192
+ if (entry.controller !== void 0) {
5193
+ entry.controller.abort();
5194
+ entry.run.dispose().catch((error) => {
5195
+ this.ctx.logger.warn(`localAgent: cancel disposal of ${childSessionId} failed: ${String(error)}`);
5196
+ });
5197
+ } else entry.cancel?.();
4479
5198
  return true;
4480
5199
  }
4481
5200
  /**
4482
5201
  * Register a NON-facade in-flight delegation run with the active-delegation
4483
5202
  * registry, keyed by the dsh child session id, so the `/local-agent stop`
4484
5203
  * command and {@link cancel} can reach it. This is the family TOOL's entry
4485
- * point: the tool starts its runs through `ctx.subagents.start` directly
4486
- * (not the facade), so without this registration its in-flight runs would
4487
- * be invisible to the stop command. The caller owns the cancel lever — a
4488
- * controller fused with the run's request signal, aborted exactly like the
4489
- * facade's tracked controller. The entry clears itself when the run's
4490
- * result settles (any stop reason); an existing facade entry for the same
4491
- * child session wins (it already owns cancellation and progress).
4492
- * @param childSessionId - the dsh child session id (the run id).
4493
- * @param run - the published subagent run.
4494
- * @param cancel - the lever that aborts the run's request signal.
5204
+ * point: the tool starts its runs through `ctx.subagents.startActivation`
5205
+ * directly (not the facade), so without this registration its in-flight
5206
+ * runs would be invisible to the stop command. The caller owns the cancel
5207
+ * lever — aborting the fused controller (pre-publication) and disposing the
5208
+ * activation (post-publication), the same channels the facade drives. The
5209
+ * entry clears itself when the run's result settles (any stop reason); an
5210
+ * existing facade entry for the same child session wins (it already owns
5211
+ * cancellation and progress).
5212
+ * @param childSessionId - the dsh child session id.
5213
+ * @param run - the published subagent run (the tool's activation adapter).
5214
+ * @param cancel - the lever that cancels the run.
4495
5215
  */
4496
5216
  trackDelegationRun(childSessionId, run, cancel) {
4497
5217
  if (this.runs.has(childSessionId)) return;
@@ -4581,7 +5301,7 @@ var LocalAgentRegistry = class {
4581
5301
  * from persistence when it is not — a host restart evicts every child from
4582
5302
  * the live store. This is the public entry of the facade resume's reattach
4583
5303
  * recipe for callers that start resume rounds WITHOUT the facade (the
4584
- * family subagent tool starts runs directly through `ctx.subagents.start`;
5304
+ * family subagent tool starts runs directly through `ctx.subagents.startActivation`;
4585
5305
  * without this call the provider refuses the resume as "not live").
4586
5306
  * Idempotent; a child that was never persisted fails through the
4587
5307
  * persistence layer's own not-found error.
@@ -4608,13 +5328,16 @@ var LocalAgentRegistry = class {
4608
5328
  const handle = await this.acquireChildWriteHandle(childSessionId);
4609
5329
  try {
4610
5330
  const cold = await handle.read(0);
5331
+ const closeOpenTurn = sessionModule.interruptedTurnClosers;
5332
+ const closers = closeOpenTurn === void 0 ? [] : closeOpenTurn(cold.events);
5333
+ if (closers.length > 0) await handle.append(closers);
4611
5334
  const session = sessions.prepare(SessionId(childSessionId), {
4612
- seed: [...cold.events],
5335
+ seed: [...cold.events, ...closers],
4613
5336
  meta: structuredClone(handle.header),
4614
5337
  inheritedEventCount: handle.inheritedEventCount,
4615
5338
  eventState: cold.eventState
4616
5339
  });
4617
- const unstored = session.snapshotEvents().slice(cold.events.length);
5340
+ const unstored = session.snapshotEvents().slice(cold.events.length + closers.length);
4618
5341
  if (unstored.length > 0) await handle.append(unstored);
4619
5342
  const detach = sessions.enter(session);
4620
5343
  try {
@@ -4644,18 +5367,41 @@ var LocalAgentRegistry = class {
4644
5367
  * @param session - the live child session to sync.
4645
5368
  */
4646
5369
  async syncChildSession(session, strict = false) {
5370
+ const id = String(session.id);
4647
5371
  try {
4648
- const handle = await this.acquireChildWriteHandle(String(session.id), session.header);
4649
- const stored = await handle.read(0);
4650
- const suffix = session.snapshotEvents().slice(stored.events.length);
4651
- if (suffix.length > 0) await handle.append(suffix);
4652
- await handle.flush();
5372
+ try {
5373
+ await this.syncChildSessionOnce(session);
5374
+ } catch (error) {
5375
+ if (!/append seq mismatch/.test(error instanceof Error ? error.message : String(error))) throw error;
5376
+ await this.releaseChildWriteHandle(id);
5377
+ await this.syncChildSessionOnce(session);
5378
+ }
4653
5379
  } catch (error) {
4654
5380
  if (strict) throw error;
4655
- this.ctx.logger.warn(`localAgent: syncing child session ${String(session.id)} to persistence failed: ${error instanceof Error ? error.message : String(error)}`);
5381
+ this.ctx.logger.warn(`localAgent: syncing child session ${id} to persistence failed: ${error instanceof Error ? error.message : String(error)}`);
4656
5382
  }
4657
5383
  }
4658
5384
  /**
5385
+ * One sync pass. While the write handle is open the host routes every live
5386
+ * append into the writer's buffer, so the routed events are drained first
5387
+ * (`sessions.flush`: drain + flush) and only what is STILL missing — events
5388
+ * from before the handle opened, or a host that routes nothing — goes in
5389
+ * explicitly, bounded to the events that existed before the drain. Appending
5390
+ * routed-but-undrained events explicitly as well would land them twice: the
5391
+ * explicit write wins, the buffered copy then fails contiguity forever, and
5392
+ * every later flush — the host's tool-execution checkpoint included —
5393
+ * throws "append seq mismatch".
5394
+ */
5395
+ async syncChildSessionOnce(session) {
5396
+ const handle = await this.acquireChildWriteHandle(String(session.id), session.header);
5397
+ const known = session.snapshotEvents().length;
5398
+ await this.ctx.get("sessions")?.flush(session);
5399
+ const stored = await handle.read(0);
5400
+ const missing = session.snapshotEvents().slice(stored.events.length, known);
5401
+ if (missing.length > 0) await handle.append(missing);
5402
+ await handle.flush();
5403
+ }
5404
+ /**
4659
5405
  * Get or open the child session's registry-owned WRITE handle (the
4660
5406
  * {@link childWriteHandles} cache). When the caller carries the session
4661
5407
  * header (a sync), a missing stored session is created from it; a create
@@ -5247,10 +5993,29 @@ async function apply(ctx, config) {
5247
5993
  const registry = new LocalAgentRegistry(ctx, config.homesRoot, config.loginPromptTimeoutMs ?? 1e4);
5248
5994
  ctx.provide(LOCAL_AGENT_SERVICE, registry);
5249
5995
  new LocalAgentGateway(ctx);
5250
- const memberChannel = new MemberChannel(ctx, registry, registry.memberBridgeSocketPath());
5996
+ const modeTools = new PresetToolHost(ctx, join(config.homesRoot, "mode-tools.jsonl"));
5997
+ const memberChannel = new MemberChannel(ctx, registry, registry.memberBridgeSocketPath(), modeTools);
5251
5998
  memberChannel.start();
5999
+ ctx.on("approval/request", async (request, next) => {
6000
+ const child = modeTools.memberOf(request.agent);
6001
+ const delegation = child === void 0 ? void 0 : registry.getDelegation(child);
6002
+ const room = ctx.get.call(ctx, "room");
6003
+ if (child === void 0 || delegation === void 0 || room?.askMemberApproval === void 0) return next();
6004
+ const reason = request.displayReason ?? request.reason;
6005
+ const outcome = await room.askMemberApproval({
6006
+ childSessionId: child,
6007
+ parentSessionId: delegation.parentSessionId,
6008
+ provider: delegation.provider
6009
+ }, {
6010
+ toolName: request.toolName,
6011
+ ...reason === void 0 ? {} : { reason },
6012
+ ...request.signal === void 0 ? {} : { signal: request.signal }
6013
+ });
6014
+ return outcome === "unavailable" ? next() : outcome;
6015
+ }, { prepend: true });
5252
6016
  ctx.effect(() => () => {
5253
6017
  memberChannel.dispose();
6018
+ modeTools.dispose();
5254
6019
  });
5255
6020
  ctx.commands.register({
5256
6021
  name: "local-agent",
@@ -5278,4 +6043,4 @@ async function apply(ctx, config) {
5278
6043
  });
5279
6044
  }
5280
6045
  //#endregion
5281
- export { CLI_VERSION_FAILURE_TTL_MS, CLI_VERSION_PROBE_TIMEOUT_MS, Config, DELEGATIONS_FILENAME, FileMemberControlStorage, LIVE_CHECKPOINT_INTERVAL_MS, LIVE_FLUSH_INTERVAL_MS, LOCAL_AGENT_SERVICE, LiveFlush, LiveStreamPublisher, LocalAgentRegistry, LocalAgentStreams, MANUAL_LOGIN_LIMIT_MS, MANUAL_LOGIN_POLL_MS, MEMBER_BRIDGE_SOCKET_FILENAME, MemberConfigurationController, ModelDirectoryCache, REPLACE_LOGIN_GRACE_MS, RUN_PROGRESS_HEARTBEAT_MS, RUN_PROGRESS_SETTLE_GRACE_MS, SCOPE_NAME_RE, apply, assertModelExecOnly, assertResumeCwdUnchanged, assertResumeScopeUnchanged, assertScopeExecOnly, clearCliVersionCache, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, inject, modelDirectoryContextKey, name, parseCliVersion, parseScopeFlag, persistChildSession, probeCliVersion, resolveChildCwd, resolveRoundModel, scopedHomeName, settingsFace, stripAnsi, subagentDelegationLabel, vol };
6046
+ export { CLI_VERSION_FAILURE_TTL_MS, CLI_VERSION_PROBE_TIMEOUT_MS, Config, DELEGATIONS_FILENAME, FileMemberControlStorage, LIVE_CHECKPOINT_INTERVAL_MS, LIVE_FLUSH_INTERVAL_MS, LOCAL_AGENT_SERVICE, LiveFlush, LiveStreamPublisher, LocalAgentRegistry, LocalAgentStreams, MANUAL_LOGIN_LIMIT_MS, MANUAL_LOGIN_POLL_MS, MEMBER_BRIDGE_SOCKET_FILENAME, MODEL_LIST_FILE, MemberConfigurationController, ModelDirectoryCache, REPLACE_LOGIN_GRACE_MS, RUN_PROGRESS_HEARTBEAT_MS, RUN_PROGRESS_SETTLE_GRACE_MS, SCOPE_NAME_RE, apply, assertModelExecOnly, assertResumeCwdUnchanged, assertResumeScopeUnchanged, assertScopeExecOnly, clearCliVersionCache, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, firstModelOf, inject, modelDirectoryContextKey, name, normalizeModelList, parseCliVersion, parseScopeFlag, persistChildSession, probeCliVersion, readModelList, readModelListSync, resolveChildCwd, resolveRoundModel, scopedHomeName, settingsFace, stripAnsi, subagentDelegationLabel, vol, writeModelList };