@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.
- package/CHANGELOG.md +31 -0
- package/README.en.md +23 -164
- package/README.i18n.yaml +2 -2
- package/README.md +23 -164
- package/lib/client.js +23707 -4768
- package/lib/index.js +827 -62
- package/lib/member-bridge.js +195 -25
- package/lib/tool-factory.js +52 -19
- package/lib/typert.host.js +1440 -353
- package/lib/typert.remote-client.d.ts +43 -3
- package/lib/typert.remote-client.js +973 -335
- package/lib/types/client/FamilyCard.d.ts +37 -0
- package/lib/types/client/FamilyCard.js +55 -0
- package/lib/types/client/HarnessModelPicker.d.ts +21 -1
- package/lib/types/client/HarnessModelPicker.js +31 -3
- package/lib/types/client/MemberLiveNode.js +38 -4
- package/lib/types/client/ModelListSection.d.ts +49 -0
- package/lib/types/client/ModelListSection.js +132 -0
- package/lib/types/client/ProviderAuthBlock.d.ts +10 -2
- package/lib/types/client/ProviderAuthBlock.js +13 -6
- package/lib/types/client/auth-status.d.ts +6 -0
- package/lib/types/client/auth-status.js +8 -0
- package/lib/types/client/harness-cards.d.ts +72 -0
- package/lib/types/client/harness-cards.js +57 -0
- package/lib/types/client/icons.d.ts +2 -1
- package/lib/types/client/icons.js +1 -0
- package/lib/types/client/index.d.ts +32 -7
- package/lib/types/client/index.js +66 -9
- package/lib/types/client/locales.d.ts +25 -1
- package/lib/types/client/locales.js +50 -2
- package/lib/types/client/model-directory-scope.d.ts +14 -0
- package/lib/types/client/model-directory-scope.js +20 -0
- package/lib/types/gateway.d.ts +49 -1
- package/lib/types/gateway.js +89 -0
- package/lib/types/index.d.ts +91 -38
- package/lib/types/index.js +190 -58
- package/lib/types/live-stream.d.ts +11 -0
- package/lib/types/live-stream.js +26 -0
- package/lib/types/member-bridge.d.ts +11 -1
- package/lib/types/member-bridge.js +81 -13
- package/lib/types/member-channel.d.ts +44 -1
- package/lib/types/member-channel.js +65 -2
- package/lib/types/member-controls.d.ts +8 -0
- package/lib/types/member-controls.js +15 -2
- package/lib/types/mode-skills.d.ts +18 -0
- package/lib/types/mode-skills.js +56 -0
- package/lib/types/model-list.d.ts +55 -0
- package/lib/types/model-list.js +101 -0
- package/lib/types/preset-tool-host.d.ts +70 -0
- package/lib/types/preset-tool-host.js +210 -0
- package/lib/types/tool-factory.d.ts +9 -3
- package/lib/types/tool-factory.js +55 -18
- package/lib/types/types.d.ts +62 -5
- package/package.json +27 -23
- package/lib/invariant.js +0 -85
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -105
package/lib/types/index.js
CHANGED
|
@@ -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.
|
|
670
|
-
* start, so fresh and resume rounds stay paired even under
|
|
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.
|
|
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.
|
|
727
|
-
* cancel lever (its fused controller
|
|
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
|
-
|
|
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.
|
|
1190
|
-
* {@link takeDelegationIntent}, so the per-(parent, provider)
|
|
1191
|
-
* fresh and resume rounds even under parallel delegations from
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
1593
|
-
// the provider consumes it as
|
|
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
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
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
|
-
|
|
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:
|
|
1785
|
-
* the internal controller
|
|
1786
|
-
*
|
|
1787
|
-
*
|
|
1788
|
-
* reason. A caller holding the `SubagentRun` may also
|
|
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
|
|
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.
|
|
1812
|
-
* (not the facade), so without this registration its in-flight
|
|
1813
|
-
* be invisible to the stop command. The caller owns the cancel
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
1816
|
-
* result settles (any stop reason); an
|
|
1817
|
-
* child session wins (it already owns
|
|
1818
|
-
*
|
|
1819
|
-
* @param
|
|
1820
|
-
* @param
|
|
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.
|
|
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
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
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 ${
|
|
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
|
-
|
|
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.
|
|
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;
|
package/lib/types/live-stream.js
CHANGED
|
@@ -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).
|