@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
@@ -30,6 +30,7 @@ import { appendFileSync, chmodSync, mkdirSync, readFileSync, statSync } from 'no
30
30
  import { join } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import z from '@deepseek-ai/schemastery';
33
+ import * as sessionModule from '@deepseek-ai/dsh-session';
33
34
  import { SessionId } from '@deepseek-ai/dsh-session';
34
35
  // The durable reader validates plugin vocabulary before replay. Register in
35
36
  // both resolution planes: source-based host toolchains and installed bundles
@@ -46,6 +47,7 @@ for (const catalog of streamCatalogs) {
46
47
  }
47
48
  import LocalAgentGateway from './gateway.js';
48
49
  import { MemberChannel } from './member-channel.js';
50
+ import { PresetToolHost } from './preset-tool-host.js';
49
51
  import { MemberInbox } from './member-inbox.js';
50
52
  import { PreparedMembers } from './prepared-members.js';
51
53
  import { MemberControls } from './member-controls.js';
@@ -56,6 +58,7 @@ export const name = 'local-agent';
56
58
  export const inject = ['commands'];
57
59
  export { delegationEnv } from './env.js';
58
60
  export { ModelDirectoryCache, extendModelDirectory, modelDirectoryContextKey } from './model-directory.js';
61
+ export { MODEL_LIST_FILE, firstModelOf, normalizeModelList, readModelList, readModelListSync, writeModelList } from './model-list.js';
59
62
  export { MemberConfigurationController } from './member-control.js';
60
63
  export { FileMemberControlStorage } from './member-control-storage.js';
61
64
  export { LiveFlush, LIVE_FLUSH_INTERVAL_MS } from './live-flush.js';
@@ -318,12 +321,43 @@ function delegationIntentKey(parentSessionId, provider) {
318
321
  * `cancel()` and the caller's own abort channel both reach the run.
319
322
  * `AbortSignal.any` is the harness's own fusion primitive (agent-loop's resume
320
323
  * uses it); either source aborts the run.
324
+ *
325
+ * Host 0.2.1 note: the fused signal reaches `startActivation`'s `spec.signal`,
326
+ * which owns cancellation only up to publication — after the activation is
327
+ * published the manager unlinks it and `dispose()` is the sole cancel channel.
328
+ * {@link activationRun} therefore re-wires the caller signal to disposal.
321
329
  */
322
330
  function fusedSignal(controller, caller) {
323
331
  if (caller === undefined)
324
332
  return controller.signal;
325
333
  return AbortSignal.any([controller.signal, caller]);
326
334
  }
335
+ /**
336
+ * Adapt a 0.2.1 managed activation to the family's published `SubagentRun`
337
+ * shape. The adapter's `id` is the dsh child session id — the family's
338
+ * tracking key, resume handle, and client-facing identity — NOT the
339
+ * activation's `childId`, which for an external (CLI) provider is the
340
+ * provider's per-execution id and never names the mirrored live Session.
341
+ * `dispose()` is the activation's exact-epoch cancellation: it settles the
342
+ * result with an `aborted`-class stop reason and runs teardown, so a caller
343
+ * signal aborted mid-run is re-wired to it (the start signal the facade fused
344
+ * is unlinked by the manager at publication). The listener detaches when the
345
+ * result settles, so a long-lived caller signal never accumulates them.
346
+ */
347
+ function activationRun(activation, childSessionId, caller, warn) {
348
+ const dispose = () => activation.dispose();
349
+ if (caller !== undefined) {
350
+ const onAbort = () => {
351
+ void dispose().catch((error) => {
352
+ warn(`localAgent: caller-signal disposal of ${childSessionId} failed: ${String(error)}`);
353
+ });
354
+ };
355
+ caller.addEventListener('abort', onAbort, { once: true });
356
+ const detach = () => { caller.removeEventListener('abort', onAbort); };
357
+ void activation.result.then(detach, detach);
358
+ }
359
+ return { id: SessionId(childSessionId), result: activation.result, dispose };
360
+ }
327
361
  /**
328
362
  * The per-harness append-only delegation-mapping log inside the harness's
329
363
  * scoped home (`<homeDir>/<harness>/delegations.jsonl`): one JSON record per
@@ -508,6 +542,11 @@ export function parseScopeFlag(input) {
508
542
  }
509
543
  return { input: rest.join(' ').trim(), ...scope === undefined ? {} : { scope } };
510
544
  }
545
+ /** A delegation record without its requested model. */
546
+ function withoutModel(record) {
547
+ const { model: _model, ...rest } = record;
548
+ return rest;
549
+ }
511
550
  /** Map a login failure to a command error, naming the swallowed signal. */
512
551
  function loginFailure(harness, exitCode, signal) {
513
552
  return `${harness.name} login exited with ${signal ?? `code ${exitCode ?? 'unknown'}`}; try /${harness.name} login again.`;
@@ -666,9 +705,9 @@ export class LocalAgentRegistry {
666
705
  /**
667
706
  * Per-(parent session, provider) FIFO of delegation intents. The family
668
707
  * resume tool stages exactly one intent per call before awaiting
669
- * `ctx.subagents.start()`, and the owning provider consumes exactly one per
670
- * start, so fresh and resume rounds stay paired even under parallel
671
- * delegations from one parent.
708
+ * `ctx.subagents.startActivation()`, and the owning provider consumes
709
+ * exactly one per start, so fresh and resume rounds stay paired even under
710
+ * parallel delegations from one parent.
672
711
  */
673
712
  delegationIntents = new Map();
674
713
  /**
@@ -721,10 +760,12 @@ export class LocalAgentRegistry {
721
760
  * - **facade-tracked** ({@link trackRun}): runs started through
722
761
  * {@link LocalAgentRegistry.start} / {@link LocalAgentRegistry.resume},
723
762
  * carrying the fused AbortController whose signal reached
724
- * `ctx.subagents.start`; cancel aborts it.
763
+ * `ctx.subagents.startActivation`; cancel aborts the pre-publication
764
+ * signal and disposes the published activation.
725
765
  * - **tool-registered** ({@link trackDelegationRun}): runs the family tool
726
- * starts directly through `ctx.subagents.start`, carrying an explicit
727
- * cancel lever (its fused controller); cancel calls it.
766
+ * starts directly through `ctx.subagents.startActivation`, carrying an
767
+ * explicit cancel lever (its fused controller plus the activation's
768
+ * dispose); cancel calls it.
728
769
  *
729
770
  * Every entry clears itself when the run's result settles (any stop
730
771
  * reason), and facade entries also stop their heartbeat timer then.
@@ -1137,7 +1178,12 @@ export class LocalAgentRegistry {
1137
1178
  const binding = this.memberControls.binding(record.childSessionId);
1138
1179
  if (binding !== undefined)
1139
1180
  record = {
1140
- ...record,
1181
+ // The model a resume re-requests is the bound one, verbatim: a provider
1182
+ // recording its resolved model ('opus' → 'claude-opus-5-5', or the
1183
+ // default a model-less start resolved to) would make the next round's
1184
+ // binding differ and the member refuse it ("identity changed: model").
1185
+ ...withoutModel(record),
1186
+ ...binding.model === undefined ? {} : { model: binding.model },
1141
1187
  ...binding.effort === undefined ? {} : { effort: binding.effort },
1142
1188
  ...binding.configurationLock === undefined ? {} : { configurationLock: binding.configurationLock },
1143
1189
  };
@@ -1186,9 +1232,10 @@ export class LocalAgentRegistry {
1186
1232
  /**
1187
1233
  * Stage one delegation intent for a provider's next `start()`. The family
1188
1234
  * resume tool stages exactly one intent per call before awaiting
1189
- * `ctx.subagents.start()`; the provider consumes exactly one per start via
1190
- * {@link takeDelegationIntent}, so the per-(parent, provider) FIFO pairs
1191
- * fresh and resume rounds even under parallel delegations from one parent.
1235
+ * `ctx.subagents.startActivation()`; the provider consumes exactly one per
1236
+ * start via {@link takeDelegationIntent}, so the per-(parent, provider)
1237
+ * FIFO pairs fresh and resume rounds even under parallel delegations from
1238
+ * one parent.
1192
1239
  * @param parentSessionId - the delegating parent session id.
1193
1240
  * @param provider - the `ctx.subagents` provider name.
1194
1241
  * @param intent - the fresh or resume intent for the next start.
@@ -1222,7 +1269,7 @@ export class LocalAgentRegistry {
1222
1269
  }
1223
1270
  /**
1224
1271
  * Remove one previously staged intent by reference, returning whether it was
1225
- * still queued. The delegation facade calls this when `ctx.subagents.start()`
1272
+ * still queued. The delegation facade calls this when `ctx.subagents.startActivation()`
1226
1273
  * throws: an intent the provider never consumed must not linger in the FIFO,
1227
1274
  * where the NEXT same-(parent, provider) start would misconsume it as its own
1228
1275
  * (orphan-intent crosstalk). When the provider already consumed the intent
@@ -1248,9 +1295,7 @@ export class LocalAgentRegistry {
1248
1295
  return true;
1249
1296
  }
1250
1297
  /**
1251
- * Every recorded delegation, in recording order. The invariant companion
1252
- * cross-checks each record's provider against the mounted subagent
1253
- * providers so a provider rename cannot leave a stale resume mapping.
1298
+ * Every recorded delegation, in recording order.
1254
1299
  * @returns the recorded delegations.
1255
1300
  */
1256
1301
  listDelegations() {
@@ -1554,7 +1599,9 @@ export class LocalAgentRegistry {
1554
1599
  * {@link unstageDelegationIntent} (a no-op once the provider consumed it).
1555
1600
  *
1556
1601
  * The returned run is tracked under its `run.id` (== the new dsh child
1557
- * session id) until `run.result` settles; {@link cancel} aborts it by that
1602
+ * session id, minted here and carried to the provider through the staged
1603
+ * intent — host 0.2.1's `startActivation` never surfaces it on the
1604
+ * activation) until `run.result` settles; {@link cancel} aborts it by that
1558
1605
  * id. Note on trust: cordis plugins share one process, so the ownership
1559
1606
  * checks here and in {@link resume} guard against caller MISTAKES, not
1560
1607
  * against a malicious plugin — their real protection target is untrusted
@@ -1588,13 +1635,21 @@ export class LocalAgentRegistry {
1588
1635
  const subagents = this.requireSubagents();
1589
1636
  this.requireProvider(subagents, provider);
1590
1637
  const parent = this.requireLiveParent(parentSessionId);
1638
+ // The durable child identity is caller-minted here: host 0.2.1 activations
1639
+ // forbid a provider run id that names a live Session (the manager rejects
1640
+ // it as DUPLICATE_CHILD), so the provider's returned run carries a
1641
+ // per-execution id and the child session id rides the staged intent — the
1642
+ // same channel the prepared-member path already used for it.
1643
+ const childSessionId = prepared?.binding.childSessionId ?? randomUUID();
1591
1644
  // The cwd override rides the staged intent, not the host seam (the
1592
- // SubagentStartRequest contract has no cwd field and must not grow one):
1593
- // the provider consumes it as the resolveChildCwd override.
1645
+ // SubagentStartRequest contract's cwd is the manager-resolved directory,
1646
+ // not the family's resolveChildCwd override): the provider consumes it as
1647
+ // the resolveChildCwd override.
1594
1648
  const intent = {
1595
1649
  kind: 'fresh',
1596
1650
  ...options?.onAdmitted === undefined ? {} : { onAdmitted: options.onAdmitted },
1597
1651
  ...options?.preparedMemberId === undefined ? {} : { preparedMemberId: options.preparedMemberId },
1652
+ ...prepared === undefined ? { childSessionId } : {},
1598
1653
  ...options?.cwd === undefined ? {} : { cwd: options.cwd },
1599
1654
  // The container exec target rides the same channel for the same
1600
1655
  // reason: it is a family-private start fact, and the host
@@ -1615,18 +1670,25 @@ export class LocalAgentRegistry {
1615
1670
  const controller = new AbortController();
1616
1671
  let run;
1617
1672
  try {
1618
- run = await subagents.start(provider, {
1619
- prompt,
1620
- parent,
1673
+ // Caller delivery: the family collects the result itself and pushes it
1674
+ // through its own Remote; the parent agent gets no completion notice.
1675
+ // The activation spec's label is required, so the absent-description
1676
+ // case rides through as the empty string, which the provider's label
1677
+ // composition reads as "no description" (the harness display name wins).
1678
+ const activation = await subagents.startActivation({
1679
+ provider,
1680
+ label: options?.label ?? '',
1681
+ request: { prompt, parent },
1621
1682
  signal: fusedSignal(controller, options?.signal),
1622
- ...options?.label === undefined ? {} : { label: options.label },
1683
+ delivery: 'caller',
1623
1684
  });
1685
+ run = activationRun(activation, childSessionId, options?.signal, message => this.ctx.logger.warn(message));
1624
1686
  }
1625
1687
  catch (error) {
1626
1688
  this.unstageDelegationIntent(parentSessionId, provider, intent);
1627
1689
  throw error;
1628
1690
  }
1629
- this.trackRun(run.id, controller, run, options?.onProgress);
1691
+ this.trackRun(childSessionId, controller, run, options?.onProgress);
1630
1692
  if (prepared !== undefined) {
1631
1693
  try {
1632
1694
  this.preparedMembers.write({ ...prepared, phase: 'used' });
@@ -1652,12 +1714,12 @@ export class LocalAgentRegistry {
1652
1714
  * without staging; the provider still takes the authoritative lock inside
1653
1715
  * its `start()`;
1654
1716
  * 4. live parent agent resolution (`ctx.agents.get`) — a harness hard
1655
- * constraint, since `ctx.subagents.start` requires a live parent;
1717
+ * constraint, since `ctx.subagents.startActivation` requires a live parent;
1656
1718
  * 5. child-session REATTACH when `ctx.sessions.get(childSessionId)` is
1657
1719
  * undefined (recipe below; `opts.reattach === false` disables this step
1658
1720
  * and fails loud on a non-live child instead);
1659
1721
  * 6. stage `{ kind: 'resume', childSessionId, cliSessionId }` and, in the
1660
- * same synchronous flow, `await ctx.subagents.start(provider, …)`;
1722
+ * same synchronous flow, `await ctx.subagents.startActivation({…})`;
1661
1723
  * 7. on start failure, roll the intent back via
1662
1724
  * {@link unstageDelegationIntent} (no-op once consumed);
1663
1725
  * 8. on success, track the run under `childSessionId` for {@link cancel}
@@ -1670,8 +1732,12 @@ export class LocalAgentRegistry {
1670
1732
  * const handle = await ctx.sessionPersistence.open(SessionId(childSessionId), 'write')
1671
1733
  * try {
1672
1734
  * const cold = await handle.read(0)
1735
+ * // Close a turn a restart interrupted, or the next round's turn/start
1736
+ * // makes the stored log unreadable.
1737
+ * const closers = interruptedTurnClosers(cold.events)
1738
+ * if (closers.length > 0) await handle.append(closers)
1673
1739
  * const session = ctx.sessions.prepare(SessionId(childSessionId), {
1674
- * seed: [...cold.events],
1740
+ * seed: [...cold.events, ...closers],
1675
1741
  * meta: structuredClone(handle.header),
1676
1742
  * inheritedEventCount: handle.inheritedEventCount,
1677
1743
  * eventState: cold.eventState,
@@ -1679,7 +1745,7 @@ export class LocalAgentRegistry {
1679
1745
  * // The seeded constructor's session/end-seed marker lands before enter()
1680
1746
  * // installs publication hooks — push the unstored suffix through the
1681
1747
  * // handle directly, or the writer's contiguous cursor diverges.
1682
- * const unstored = session.snapshotEvents().slice(cold.events.length)
1748
+ * const unstored = session.snapshotEvents().slice(cold.events.length + closers.length)
1683
1749
  * if (unstored.length > 0) await handle.append(unstored)
1684
1750
  * const detach = ctx.sessions.enter(session)
1685
1751
  * try { ctx.sessions.announce(session) }
@@ -1766,12 +1832,18 @@ export class LocalAgentRegistry {
1766
1832
  const controller = new AbortController();
1767
1833
  let run;
1768
1834
  try {
1769
- run = await subagents.start(provider, {
1770
- prompt,
1771
- parent,
1835
+ // Caller delivery (see start): the family self-collects the result and
1836
+ // pushes it through its own Remote; the resumed child session id stays
1837
+ // the tracking key while the activation carries the provider's
1838
+ // per-execution id.
1839
+ const activation = await subagents.startActivation({
1840
+ provider,
1841
+ label: options?.label ?? '',
1842
+ request: { prompt, parent },
1772
1843
  signal: fusedSignal(controller, options?.signal),
1773
- ...options?.label === undefined ? {} : { label: options.label },
1844
+ delivery: 'caller',
1774
1845
  });
1846
+ run = activationRun(activation, childSessionId, options?.signal, message => this.ctx.logger.warn(message));
1775
1847
  }
1776
1848
  catch (error) {
1777
1849
  this.unstageDelegationIntent(parentSessionId, provider, intent);
@@ -1781,11 +1853,12 @@ export class LocalAgentRegistry {
1781
1853
  return run;
1782
1854
  }
1783
1855
  /**
1784
- * Cancel an in-flight facade-started run by its dsh child session id: aborts
1785
- * the internal controller whose (fused) signal was passed to
1786
- * `ctx.subagents.start`, which is the canonical cancellation channel for a
1787
- * one-shot run. The provider settles the run with an `aborted`-class stop
1788
- * reason. A caller holding the `SubagentRun` may also `run.dispose()`.
1856
+ * Cancel an in-flight facade-started run by its dsh child session id:
1857
+ * aborts the internal controller (the pre-publication channel fused into
1858
+ * the activation's start signal) and disposes the activation — host 0.2.1's
1859
+ * canonical POST-publication cancellation, which settles the run with an
1860
+ * `aborted`-class stop reason. A caller holding the `SubagentRun` may also
1861
+ * `run.dispose()`.
1789
1862
  * @param childSessionId - the dsh child session id of the run to cancel.
1790
1863
  * @returns whether an in-flight run was found and signalled (false on a
1791
1864
  * miss — never a silent success, never a throw).
@@ -1796,10 +1869,17 @@ export class LocalAgentRegistry {
1796
1869
  return this.memberInbox.cancelStarting(childSessionId);
1797
1870
  if (entry.controller !== undefined) {
1798
1871
  entry.controller.abort();
1872
+ // The start signal is unlinked at publication, so disposal is the lever
1873
+ // that actually reaches a running round. Idempotent and memoized by the
1874
+ // manager; a teardown failure is reported, never thrown from here.
1875
+ void entry.run.dispose().catch((error) => {
1876
+ this.ctx.logger.warn(`localAgent: cancel disposal of ${childSessionId} failed: ${String(error)}`);
1877
+ });
1799
1878
  }
1800
1879
  else {
1801
1880
  // Tool-registered run: the caller-supplied cancel lever (its fused
1802
- // controller), the same abort channel the facade entry aborts.
1881
+ // controller plus the activation's dispose), the same channels the
1882
+ // facade entry drives.
1803
1883
  entry.cancel?.();
1804
1884
  }
1805
1885
  return true;
@@ -1808,16 +1888,17 @@ export class LocalAgentRegistry {
1808
1888
  * Register a NON-facade in-flight delegation run with the active-delegation
1809
1889
  * registry, keyed by the dsh child session id, so the `/local-agent stop`
1810
1890
  * command and {@link cancel} can reach it. This is the family TOOL's entry
1811
- * point: the tool starts its runs through `ctx.subagents.start` directly
1812
- * (not the facade), so without this registration its in-flight runs would
1813
- * be invisible to the stop command. The caller owns the cancel lever — a
1814
- * controller fused with the run's request signal, aborted exactly like the
1815
- * facade's tracked controller. The entry clears itself when the run's
1816
- * result settles (any stop reason); an existing facade entry for the same
1817
- * child session wins (it already owns cancellation and progress).
1818
- * @param childSessionId - the dsh child session id (the run id).
1819
- * @param run - the published subagent run.
1820
- * @param cancel - the lever that aborts the run's request signal.
1891
+ * point: the tool starts its runs through `ctx.subagents.startActivation`
1892
+ * directly (not the facade), so without this registration its in-flight
1893
+ * runs would be invisible to the stop command. The caller owns the cancel
1894
+ * lever — aborting the fused controller (pre-publication) and disposing the
1895
+ * activation (post-publication), the same channels the facade drives. The
1896
+ * entry clears itself when the run's result settles (any stop reason); an
1897
+ * existing facade entry for the same child session wins (it already owns
1898
+ * cancellation and progress).
1899
+ * @param childSessionId - the dsh child session id.
1900
+ * @param run - the published subagent run (the tool's activation adapter).
1901
+ * @param cancel - the lever that cancels the run.
1821
1902
  */
1822
1903
  trackDelegationRun(childSessionId, run, cancel) {
1823
1904
  if (this.runs.has(childSessionId))
@@ -1917,7 +1998,7 @@ export class LocalAgentRegistry {
1917
1998
  * from persistence when it is not — a host restart evicts every child from
1918
1999
  * the live store. This is the public entry of the facade resume's reattach
1919
2000
  * recipe for callers that start resume rounds WITHOUT the facade (the
1920
- * family subagent tool starts runs directly through `ctx.subagents.start`;
2001
+ * family subagent tool starts runs directly through `ctx.subagents.startActivation`;
1921
2002
  * without this call the provider refuses the resume as "not live").
1922
2003
  * Idempotent; a child that was never persisted fails through the
1923
2004
  * persistence layer's own not-found error.
@@ -1954,8 +2035,18 @@ export class LocalAgentRegistry {
1954
2035
  const handle = await this.acquireChildWriteHandle(childSessionId);
1955
2036
  try {
1956
2037
  const cold = await handle.read(0);
2038
+ // A host restart mid-round leaves the child's last turn open on disk;
2039
+ // the next round's turn/start would then follow an unclosed turn and
2040
+ // the strict reader rejects the whole log. Close it the way the agent
2041
+ // loop's own resume does: synthetic closers (missing tool errors,
2042
+ // step/end, an `interrupted` turn/end) appended through the handle.
2043
+ // Older hosts without the helper keep the previous behavior.
2044
+ const closeOpenTurn = sessionModule.interruptedTurnClosers;
2045
+ const closers = closeOpenTurn === undefined ? [] : closeOpenTurn(cold.events);
2046
+ if (closers.length > 0)
2047
+ await handle.append(closers);
1957
2048
  const session = sessions.prepare(SessionId(childSessionId), {
1958
- seed: [...cold.events],
2049
+ seed: [...cold.events, ...closers],
1959
2050
  meta: structuredClone(handle.header),
1960
2051
  inheritedEventCount: handle.inheritedEventCount,
1961
2052
  eventState: cold.eventState,
@@ -1965,7 +2056,7 @@ export class LocalAgentRegistry {
1965
2056
  // — push the unstored suffix through the handle directly (the agent
1966
2057
  // loop's resume does the same via appendUnstoredSuffix), or the writer's
1967
2058
  // contiguous cursor and the session log diverge.
1968
- const unstored = session.snapshotEvents().slice(cold.events.length);
2059
+ const unstored = session.snapshotEvents().slice(cold.events.length + closers.length);
1969
2060
  if (unstored.length > 0)
1970
2061
  await handle.append(unstored);
1971
2062
  const detach = sessions.enter(session);
@@ -2000,20 +2091,48 @@ export class LocalAgentRegistry {
2000
2091
  * @param session - the live child session to sync.
2001
2092
  */
2002
2093
  async syncChildSession(session, strict = false) {
2094
+ const id = String(session.id);
2003
2095
  try {
2004
- const handle = await this.acquireChildWriteHandle(String(session.id), session.header);
2005
- const stored = await handle.read(0);
2006
- const suffix = session.snapshotEvents().slice(stored.events.length);
2007
- if (suffix.length > 0)
2008
- await handle.append(suffix);
2009
- await handle.flush();
2096
+ try {
2097
+ await this.syncChildSessionOnce(session);
2098
+ }
2099
+ catch (error) {
2100
+ if (!/append seq mismatch/.test(error instanceof Error ? error.message : String(error)))
2101
+ throw error;
2102
+ // A routed live buffer the explicit suffix raced (see
2103
+ // syncChildSessionOnce) stays poisoned for the handle's lifetime:
2104
+ // drop the handle with it and catch up from disk on a fresh one.
2105
+ await this.releaseChildWriteHandle(id);
2106
+ await this.syncChildSessionOnce(session);
2107
+ }
2010
2108
  }
2011
2109
  catch (error) {
2012
2110
  if (strict)
2013
2111
  throw error;
2014
- this.ctx.logger.warn(`localAgent: syncing child session ${String(session.id)} to persistence failed: ${error instanceof Error ? error.message : String(error)}`);
2112
+ this.ctx.logger.warn(`localAgent: syncing child session ${id} to persistence failed: ${error instanceof Error ? error.message : String(error)}`);
2015
2113
  }
2016
2114
  }
2115
+ /**
2116
+ * One sync pass. While the write handle is open the host routes every live
2117
+ * append into the writer's buffer, so the routed events are drained first
2118
+ * (`sessions.flush`: drain + flush) and only what is STILL missing — events
2119
+ * from before the handle opened, or a host that routes nothing — goes in
2120
+ * explicitly, bounded to the events that existed before the drain. Appending
2121
+ * routed-but-undrained events explicitly as well would land them twice: the
2122
+ * explicit write wins, the buffered copy then fails contiguity forever, and
2123
+ * every later flush — the host's tool-execution checkpoint included —
2124
+ * throws "append seq mismatch".
2125
+ */
2126
+ async syncChildSessionOnce(session) {
2127
+ const handle = await this.acquireChildWriteHandle(String(session.id), session.header);
2128
+ const known = session.snapshotEvents().length;
2129
+ await this.ctx.get('sessions')?.flush(session);
2130
+ const stored = await handle.read(0);
2131
+ const missing = session.snapshotEvents().slice(stored.events.length, known);
2132
+ if (missing.length > 0)
2133
+ await handle.append(missing);
2134
+ await handle.flush();
2135
+ }
2017
2136
  /**
2018
2137
  * Get or open the child session's registry-owned WRITE handle (the
2019
2138
  * {@link childWriteHandles} cache). When the caller carries the session
@@ -2670,9 +2789,22 @@ export async function apply(ctx, config) {
2670
2789
  // The member channel's loopback listener (bridge MCP servers call back here).
2671
2790
  // Degrade, don't explode: a failed bind leaves member_message failing with a
2672
2791
  // tool error while everything else works.
2673
- const memberChannel = new MemberChannel(ctx, registry, registry.memberBridgeSocketPath());
2792
+ // Mode tools: a member with a mode reaches that preset's plugin tools
2793
+ // through the same bridge (see preset-tool-host.ts).
2794
+ const modeTools = new PresetToolHost(ctx, join(config.homesRoot, 'mode-tools.jsonl'));
2795
+ const memberChannel = new MemberChannel(ctx, registry, registry.memberBridgeSocketPath(), modeTools);
2674
2796
  void memberChannel.start();
2675
- ctx.effect(() => () => { void memberChannel.dispose(); });
2797
+ ctx.on('approval/request', async (request, next) => {
2798
+ const child = modeTools.memberOf(request.agent);
2799
+ const delegation = child === undefined ? undefined : registry.getDelegation(child);
2800
+ const room = ctx.get.call(ctx, 'room');
2801
+ if (child === undefined || delegation === undefined || room?.askMemberApproval === undefined)
2802
+ return next();
2803
+ const reason = request.displayReason ?? request.reason;
2804
+ const outcome = await room.askMemberApproval({ childSessionId: child, parentSessionId: delegation.parentSessionId, provider: delegation.provider }, { toolName: request.toolName, ...reason === undefined ? {} : { reason }, ...request.signal === undefined ? {} : { signal: request.signal } });
2805
+ return outcome === 'unavailable' ? next() : outcome;
2806
+ }, { prepend: true });
2807
+ ctx.effect(() => () => { void memberChannel.dispose(); void modeTools.dispose(); });
2676
2808
  ctx.commands.register({
2677
2809
  name: 'local-agent',
2678
2810
  description: 'list the registered local code-agent harnesses, or stop an in-flight delegation',
@@ -27,7 +27,18 @@ export declare class LiveStreamPublisher {
27
27
  private readonly persist;
28
28
  private readonly onError;
29
29
  private readonly states;
30
+ /** Items shown live but not yet in the log: their step cannot open before the earlier steps close. */
31
+ private readonly previews;
30
32
  constructor(streams: LocalAgentStreams, session: Session, turn: number, persist: () => void, onError: (error: unknown) => void);
33
+ /**
34
+ * Show an item live without writing it to the session: a step may open in
35
+ * the log only after every earlier step has closed, so a later item that
36
+ * streams first waits here (browser-only) until the driver can open it.
37
+ */
38
+ preview(step: number, kind: 'think' | 'text', text: string, options?: {
39
+ itemId?: string;
40
+ receivedAt?: number;
41
+ }): void;
31
42
  update(step: number, kind: 'think' | 'text', text: string, options?: {
32
43
  itemId?: string;
33
44
  receivedAt?: number;
@@ -97,6 +97,8 @@ export class LiveStreamPublisher {
97
97
  persist;
98
98
  onError;
99
99
  states = new Map();
100
+ /** Items shown live but not yet in the log: their step cannot open before the earlier steps close. */
101
+ previews = new Map();
100
102
  constructor(streams, session, turn, persist, onError) {
101
103
  this.streams = streams;
102
104
  this.session = session;
@@ -104,8 +106,23 @@ export class LiveStreamPublisher {
104
106
  this.persist = persist;
105
107
  this.onError = onError;
106
108
  }
109
+ /**
110
+ * Show an item live without writing it to the session: a step may open in
111
+ * the log only after every earlier step has closed, so a later item that
112
+ * streams first waits here (browser-only) until the driver can open it.
113
+ */
114
+ preview(step, kind, text, options = {}) {
115
+ const id = `${this.turn}:${step}${options.itemId === undefined ? '' : `:${options.itemId}`}`;
116
+ const previous = this.previews.get(id);
117
+ if (previous?.text === text && previous.kind === kind)
118
+ return;
119
+ const item = { id, turn: this.turn, step, kind, text, receivedAt: previous?.receivedAt ?? options.receivedAt ?? Date.now() };
120
+ this.previews.set(id, item);
121
+ this.streams.publish(String(this.session.id), item);
122
+ }
107
123
  update(step, kind, text, options = {}) {
108
124
  const id = `${this.turn}:${step}${options.itemId === undefined ? '' : `:${options.itemId}`}`;
125
+ this.previews.delete(id);
109
126
  let state = this.states.get(id);
110
127
  if (state?.item.text === text && state.item.kind === kind)
111
128
  return;
@@ -150,6 +167,12 @@ export class LiveStreamPublisher {
150
167
  }
151
168
  /** Call only after the corresponding native final message has been appended. */
152
169
  finish(step) {
170
+ for (const [id, item] of this.previews) {
171
+ if (item.step !== step)
172
+ continue;
173
+ this.previews.delete(id);
174
+ this.streams.finish(String(this.session.id), id);
175
+ }
153
176
  for (const [id, state] of this.states) {
154
177
  if (state.item.step !== step)
155
178
  continue;
@@ -167,6 +190,9 @@ export class LiveStreamPublisher {
167
190
  }
168
191
  /** A producer failure keeps a durable partial, without claiming completion. */
169
192
  dispose() {
193
+ for (const id of this.previews.keys())
194
+ this.streams.finish(String(this.session.id), id);
195
+ this.previews.clear();
170
196
  for (const [id, state] of this.states) {
171
197
  this.checkpoint(id);
172
198
  if (state.timer !== undefined)
@@ -10,7 +10,9 @@
10
10
  * it bare: `node <this file>`, with the socket path and token in its
11
11
  * environment (written into the CLI's scoped MCP config by the provider).
12
12
  * Covers the MCP handshake subset a client needs: `initialize`,
13
- * `notifications/initialized`, `ping`, `tools/list`, `tools/call`.
13
+ * `notifications/initialized`, `ping`, `tools/list`, `tools/call`. A member
14
+ * with a mode also gets that preset's plugin tools: `tools/list` asks the
15
+ * host for them and any other tool name is forwarded as a mode-tool call.
14
16
  * @module @khorsheed/dsh-local-agent/member-bridge
15
17
  */
16
18
  import type { Readable, Writable } from 'node:stream';
@@ -25,6 +27,10 @@ export interface MemberBridgeEnv {
25
27
  /** This run's member-channel token. */
26
28
  readonly token?: string | undefined;
27
29
  }
30
+ /** The member's mode tools (the preset's plugin tools), empty when it has none or the host is unreachable. */
31
+ export declare function listModeTools(env: MemberBridgeEnv): Promise<readonly Record<string, unknown>[]>;
32
+ /** Run one mode tool on the host; the host's MCP result passes through. */
33
+ export declare function callModeTool(env: MemberBridgeEnv, name: string, args: unknown): Promise<Record<string, unknown>>;
28
34
  /**
29
35
  * Handle one `member_message` tool call: forward to the host listener and map
30
36
  * the outcome to a tool result. Channel failures (missing env, unreachable
@@ -36,6 +42,10 @@ export interface MemberBridgeEnv {
36
42
  export declare function callMemberMessage(env: MemberBridgeEnv, args: {
37
43
  to?: unknown;
38
44
  text?: unknown;
45
+ kind?: unknown;
46
+ inReplyTo?: unknown;
47
+ note?: unknown;
48
+ task?: unknown;
39
49
  }): Promise<Record<string, unknown>>;
40
50
  /**
41
51
  * Run the stdio JSON-RPC loop over the given streams (injectable for tests).