@khorsheed/dsh-local-agent 0.2.0-rc.3 → 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.
package/lib/index.js CHANGED
@@ -4,9 +4,11 @@ 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";
11
13
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
12
14
  //#region lib/types/cli-version.js
@@ -1260,6 +1262,65 @@ let LocalAgentGateway = (() => {
1260
1262
  };
1261
1263
  })();
1262
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
1263
1324
  //#region lib/types/member-channel.js
1264
1325
  /**
1265
1326
  * Member channel, host side: the loopback listener the bridge MCP servers call
@@ -1288,6 +1349,8 @@ let LocalAgentGateway = (() => {
1288
1349
  * `busy`; else the facade `resume` with a provenance-tagged prompt.
1289
1350
  * @module @khorsheed/dsh-local-agent/member-channel
1290
1351
  */
1352
+ /** How long one bridged mode-tool call may run before it is cancelled. */
1353
+ const MODE_TOOL_TIMEOUT_MS = 18e5;
1291
1354
  /**
1292
1355
  * The member-channel host: owns the loopback unix-socket listener and the
1293
1356
  * delivery chain. Mounted by the plugin's `apply` and disposed with its fiber.
@@ -1296,11 +1359,13 @@ var MemberChannel = class {
1296
1359
  ctx;
1297
1360
  registry;
1298
1361
  socketPath;
1362
+ modeTools;
1299
1363
  server;
1300
- constructor(ctx, registry, socketPath) {
1364
+ constructor(ctx, registry, socketPath, modeTools) {
1301
1365
  this.ctx = ctx;
1302
1366
  this.registry = registry;
1303
1367
  this.socketPath = socketPath;
1368
+ this.modeTools = modeTools;
1304
1369
  }
1305
1370
  /**
1306
1371
  * Start listening. A listener failure (e.g. a socket path beyond the
@@ -1360,7 +1425,8 @@ var MemberChannel = class {
1360
1425
  async answer(socket, line) {
1361
1426
  let outcome;
1362
1427
  try {
1363
- 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);
1364
1430
  } catch (error) {
1365
1431
  outcome = {
1366
1432
  ok: false,
@@ -1458,6 +1524,75 @@ var MemberChannel = class {
1458
1524
  receipt: "sent"
1459
1525
  };
1460
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
+ }
1461
1596
  /** Probe the optional room gate without importing any room package. */
1462
1597
  probeRoom() {
1463
1598
  const room = this.ctx.get.call(this.ctx, "room");
@@ -1479,6 +1614,10 @@ var MemberChannel = class {
1479
1614
  to: request.to,
1480
1615
  content: request.text,
1481
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 } : {},
1482
1621
  provenance: {
1483
1622
  childSessionId: from.childSessionId,
1484
1623
  provider: from.provider,
@@ -1507,6 +1646,251 @@ var MemberChannel = class {
1507
1646
  }
1508
1647
  };
1509
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
1510
1894
  //#region lib/types/member-inbox.js
1511
1895
  /** Durable human input; the provider FIFO remains the sole execution order. */
1512
1896
  var MemberInbox = class {
@@ -2793,6 +3177,8 @@ var LiveStreamPublisher = class {
2793
3177
  persist;
2794
3178
  onError;
2795
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();
2796
3182
  constructor(streams, session, turn, persist, onError) {
2797
3183
  this.streams = streams;
2798
3184
  this.session = session;
@@ -2800,8 +3186,29 @@ var LiveStreamPublisher = class {
2800
3186
  this.persist = persist;
2801
3187
  this.onError = onError;
2802
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
+ }
2803
3209
  update(step, kind, text, options = {}) {
2804
3210
  const id = `${this.turn}:${step}${options.itemId === void 0 ? "" : `:${options.itemId}`}`;
3211
+ this.previews.delete(id);
2805
3212
  let state = this.states.get(id);
2806
3213
  if (state?.item.text === text && state.item.kind === kind) return;
2807
3214
  const receivedAt = options.receivedAt ?? Date.now();
@@ -2860,6 +3267,11 @@ var LiveStreamPublisher = class {
2860
3267
  }
2861
3268
  /** Call only after the corresponding native final message has been appended. */
2862
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
+ }
2863
3275
  for (const [id, state] of this.states) {
2864
3276
  if (state.item.step !== step) continue;
2865
3277
  if (state.timer !== void 0) clearTimeout(state.timer);
@@ -2879,6 +3291,8 @@ var LiveStreamPublisher = class {
2879
3291
  }
2880
3292
  /** A producer failure keeps a durable partial, without claiming completion. */
2881
3293
  dispose() {
3294
+ for (const id of this.previews.keys()) this.streams.finish(String(this.session.id), id);
3295
+ this.previews.clear();
2882
3296
  for (const [id, state] of this.states) {
2883
3297
  this.checkpoint(id);
2884
3298
  if (state.timer !== void 0) clearTimeout(state.timer);
@@ -3536,6 +3950,11 @@ function parseScopeFlag(input) {
3536
3950
  ...scope === void 0 ? {} : { scope }
3537
3951
  };
3538
3952
  }
3953
+ /** A delegation record without its requested model. */
3954
+ function withoutModel(record) {
3955
+ const { model: _model, ...rest } = record;
3956
+ return rest;
3957
+ }
3539
3958
  /** Map a login failure to a command error, naming the swallowed signal. */
3540
3959
  function loginFailure(harness, exitCode, signal) {
3541
3960
  return `${harness.name} login exited with ${signal ?? `code ${exitCode ?? "unknown"}`}; try /${harness.name} login again.`;
@@ -4142,7 +4561,8 @@ var LocalAgentRegistry = class {
4142
4561
  recordDelegation(record) {
4143
4562
  const binding = this.memberControls.binding(record.childSessionId);
4144
4563
  if (binding !== void 0) record = {
4145
- ...record,
4564
+ ...withoutModel(record),
4565
+ ...binding.model === void 0 ? {} : { model: binding.model },
4146
4566
  ...binding.effort === void 0 ? {} : { effort: binding.effort },
4147
4567
  ...binding.configurationLock === void 0 ? {} : { configurationLock: binding.configurationLock }
4148
4568
  };
@@ -4667,8 +5087,12 @@ var LocalAgentRegistry = class {
4667
5087
  * const handle = await ctx.sessionPersistence.open(SessionId(childSessionId), 'write')
4668
5088
  * try {
4669
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)
4670
5094
  * const session = ctx.sessions.prepare(SessionId(childSessionId), {
4671
- * seed: [...cold.events],
5095
+ * seed: [...cold.events, ...closers],
4672
5096
  * meta: structuredClone(handle.header),
4673
5097
  * inheritedEventCount: handle.inheritedEventCount,
4674
5098
  * eventState: cold.eventState,
@@ -4676,7 +5100,7 @@ var LocalAgentRegistry = class {
4676
5100
  * // The seeded constructor's session/end-seed marker lands before enter()
4677
5101
  * // installs publication hooks — push the unstored suffix through the
4678
5102
  * // handle directly, or the writer's contiguous cursor diverges.
4679
- * const unstored = session.snapshotEvents().slice(cold.events.length)
5103
+ * const unstored = session.snapshotEvents().slice(cold.events.length + closers.length)
4680
5104
  * if (unstored.length > 0) await handle.append(unstored)
4681
5105
  * const detach = ctx.sessions.enter(session)
4682
5106
  * try { ctx.sessions.announce(session) }
@@ -4904,13 +5328,16 @@ var LocalAgentRegistry = class {
4904
5328
  const handle = await this.acquireChildWriteHandle(childSessionId);
4905
5329
  try {
4906
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);
4907
5334
  const session = sessions.prepare(SessionId(childSessionId), {
4908
- seed: [...cold.events],
5335
+ seed: [...cold.events, ...closers],
4909
5336
  meta: structuredClone(handle.header),
4910
5337
  inheritedEventCount: handle.inheritedEventCount,
4911
5338
  eventState: cold.eventState
4912
5339
  });
4913
- const unstored = session.snapshotEvents().slice(cold.events.length);
5340
+ const unstored = session.snapshotEvents().slice(cold.events.length + closers.length);
4914
5341
  if (unstored.length > 0) await handle.append(unstored);
4915
5342
  const detach = sessions.enter(session);
4916
5343
  try {
@@ -4940,18 +5367,41 @@ var LocalAgentRegistry = class {
4940
5367
  * @param session - the live child session to sync.
4941
5368
  */
4942
5369
  async syncChildSession(session, strict = false) {
5370
+ const id = String(session.id);
4943
5371
  try {
4944
- const handle = await this.acquireChildWriteHandle(String(session.id), session.header);
4945
- const stored = await handle.read(0);
4946
- const suffix = session.snapshotEvents().slice(stored.events.length);
4947
- if (suffix.length > 0) await handle.append(suffix);
4948
- 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
+ }
4949
5379
  } catch (error) {
4950
5380
  if (strict) throw error;
4951
- 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)}`);
4952
5382
  }
4953
5383
  }
4954
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
+ /**
4955
5405
  * Get or open the child session's registry-owned WRITE handle (the
4956
5406
  * {@link childWriteHandles} cache). When the caller carries the session
4957
5407
  * header (a sync), a missing stored session is created from it; a create
@@ -5543,10 +5993,29 @@ async function apply(ctx, config) {
5543
5993
  const registry = new LocalAgentRegistry(ctx, config.homesRoot, config.loginPromptTimeoutMs ?? 1e4);
5544
5994
  ctx.provide(LOCAL_AGENT_SERVICE, registry);
5545
5995
  new LocalAgentGateway(ctx);
5546
- 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);
5547
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 });
5548
6016
  ctx.effect(() => () => {
5549
6017
  memberChannel.dispose();
6018
+ modeTools.dispose();
5550
6019
  });
5551
6020
  ctx.commands.register({
5552
6021
  name: "local-agent",