@khorsheed/dsh-local-agent-dsh 0.2.0-rc.3 → 0.2.1

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.
@@ -1,3 +1,3 @@
1
- import { a as MEMBER_BRIDGE_ENTRY_ENV, c as dshTextTask, d as startDshCliRun, i as DshCliProvider, l as registerMemberRun, n as DEFAULT_DISPOSE_GRACE_MS, o as dshCliVersion, r as DEFAULT_LIVE_MIRROR_INTERVAL_MS, s as dshLaunchArgv, t as CONTAINER_NODE_OPTIONS, u as resolveApiKey } from "./dsh-cli-provider-B7s3zH0m.js";
1
+ import { a as MEMBER_BRIDGE_ENTRY_ENV, c as dshTextTask, d as startDshCliRun, i as DshCliProvider, l as registerMemberRun, n as DEFAULT_DISPOSE_GRACE_MS, o as dshCliVersion, r as DEFAULT_LIVE_MIRROR_INTERVAL_MS, s as dshLaunchArgv, t as CONTAINER_NODE_OPTIONS, u as resolveApiKey } from "./dsh-cli-provider-DYRuRpVz.js";
2
2
  import "./provision.js";
3
3
  export { CONTAINER_NODE_OPTIONS, DEFAULT_DISPOSE_GRACE_MS, DEFAULT_LIVE_MIRROR_INTERVAL_MS, DshCliProvider, MEMBER_BRIDGE_ENTRY_ENV, dshCliVersion, dshLaunchArgv, dshTextTask, registerMemberRun, resolveApiKey, startDshCliRun };
package/lib/index.js CHANGED
@@ -1,11 +1,10 @@
1
- import { f as DEFAULT_LIVE_IDLE_MS, h as localModelLaunch, i as DshCliProvider, m as canInheritModelProvider, o as dshCliVersion, p as DshLiveDriver, t as CONTAINER_NODE_OPTIONS } from "./dsh-cli-provider-B7s3zH0m.js";
1
+ import { f as DEFAULT_LIVE_IDLE_MS, h as localModelLaunch, i as DshCliProvider, m as canInheritModelProvider, o as dshCliVersion, p as DshLiveDriver, t as CONTAINER_NODE_OPTIONS } from "./dsh-cli-provider-DYRuRpVz.js";
2
2
  import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope } from "./provision.js";
3
3
  import { listDshSessions } from "./records.js";
4
4
  import { dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import z from "@deepseek-ai/schemastery";
7
7
  import { ModelDirectoryCache, extendModelDirectory, settingsFace, vol } from "@khorsheed/dsh-local-agent";
8
- import { mountSubagentDelegationTool } from "@khorsheed/dsh-local-agent/tool-factory";
9
8
  //#region lib/types/live-switch.js
10
9
  /**
11
10
  * The settings-driven live-driver switch: owns the active driver generation
@@ -404,7 +403,7 @@ const inject = [
404
403
  */
405
404
  const SETTINGS_FIELDS = {
406
405
  enabled: z.boolean().default(false),
407
- live: z.boolean().default(false),
406
+ live: z.boolean().default(true),
408
407
  liveMirrorGranularity: z.union([z.const("event"), z.const("token")]).default("token"),
409
408
  model: z.string(),
410
409
  recentModels: z.array(z.string()).default([])
@@ -446,34 +445,6 @@ const DSH_SETTINGS_NAMESPACE = "local-agent-dsh";
446
445
  * the YAML config's deployment defaults through the composition base.
447
446
  */
448
447
  const DSH_SETTINGS_SCHEMA = z.object(SETTINGS_FIELDS);
449
- /** The delegation tool the toggle mounts while ON. */
450
- const DSH_TOOL_NAME = "subagent_dsh";
451
- /**
452
- * This package's own name, passed to the family tool factory as the
453
- * tool-origin owner (AGENTS.md § Tool origin tagging — a shared factory never
454
- * hardcodes an owner), the same pattern the worktrees/room `./tool` entries
455
- * use.
456
- */
457
- const PACKAGE_NAME = "@khorsheed/dsh-local-agent-dsh";
458
- /**
459
- * The `subagent_dsh` mount, as a plugin object over the family factory: the
460
- * toggle owns its lifecycle, so it mounts dynamically through `ctx.plugin`
461
- * instead of shipping a `./tool` composition row like the CLI providers.
462
- */
463
- const dshSubagentTool = {
464
- name: "local-agent-dsh-tool",
465
- inject: [
466
- "tools",
467
- "subagents",
468
- "localAgent"
469
- ],
470
- apply: (toolCtx) => {
471
- mountSubagentDelegationTool(toolCtx, {
472
- provider: "dsh-cli",
473
- toolName: DSH_TOOL_NAME
474
- }, PACKAGE_NAME);
475
- }
476
- };
477
448
  /**
478
449
  * Mount the toggle controller: register the settings namespace, then mirror
479
450
  * its value into harness/provider/tool registrations, live.
@@ -485,7 +456,7 @@ function apply(ctx, config) {
485
456
  ctx.effect(() => {
486
457
  const readSettings = () => ({
487
458
  enabled: vol(config.enabled) ?? false,
488
- live: vol(config.live) ?? false,
459
+ live: vol(config.live) ?? true,
489
460
  liveMirrorGranularity: vol(config.liveMirrorGranularity) ?? "token",
490
461
  model: vol(config.model),
491
462
  recentModels: vol(config.recentModels) ?? []
@@ -502,6 +473,7 @@ function apply(ctx, config) {
502
473
  }
503
474
  const face = settingsFace(ctx, {
504
475
  namespace: DSH_SETTINGS_NAMESPACE,
476
+ harness: "dsh",
505
477
  legacySchema: DSH_SETTINGS_SCHEMA,
506
478
  legacyBase,
507
479
  read: readSettings
@@ -620,7 +592,6 @@ function apply(ctx, config) {
620
592
  if (enabled === currentEnabled) return;
621
593
  currentEnabled = enabled;
622
594
  generation += 1;
623
- const gen = generation;
624
595
  for (const dispose of disposers.splice(0)) dispose();
625
596
  if (!enabled) return;
626
597
  provisionDshScope(homeDir, config, profileDirOption());
@@ -632,25 +603,10 @@ function apply(ctx, config) {
632
603
  if (currentLiveSwitch === liveSwitch) currentLiveSwitch = void 0;
633
604
  liveSwitch.dispose();
634
605
  });
635
- ctx.plugin(dshSubagentTool).then((fiber) => {
636
- if (gen !== generation) {
637
- fiber.dispose();
638
- return;
639
- }
640
- disposers.push(() => {
641
- fiber.dispose();
642
- });
643
- }, (error) => {
644
- ctx.logger.warn(`local-agent-dsh: delegation tool mount failed: ${error instanceof Error ? error.message : String(error)}`);
645
- });
646
606
  };
647
- sync(face.get().enabled);
648
- const unwatch = face.watch((next) => {
649
- sync(next.enabled);
650
- });
607
+ sync(true);
651
608
  return () => {
652
609
  modelCatalog.dispose();
653
- unwatch();
654
610
  generation += 1;
655
611
  for (const dispose of disposers.splice(0)) dispose();
656
612
  };
package/lib/tool.js ADDED
@@ -0,0 +1,40 @@
1
+ import { mountSubagentDelegationTool, subagentToolConfigSchema } from "@khorsheed/dsh-local-agent/tool-factory";
2
+ //#region lib/types/tool.js
3
+ const PACKAGE_NAME = "@khorsheed/dsh-local-agent-dsh";
4
+ /** This entry's own defaults: a preset row needs no config. */
5
+ const DEFAULT_PROVIDER = "dsh-cli";
6
+ const DEFAULT_TOOL_NAME = "subagent_dsh";
7
+ /** Cordis plugin name used by loader diagnostics. */
8
+ const name = "local-agent-dsh-tool";
9
+ /**
10
+ * The family core's `localAgent` service is a declared inject (the
11
+ * owning-family companion exception to the community-service probe rule): a
12
+ * preset's standing scope mounts at registry-activation time, BEFORE the
13
+ * profile's later bundle rows provide the core, so a one-shot ctx.get probe
14
+ * at apply saw ABSENT there and nothing re-ran the row (rc.1 boot order; the
15
+ * worktrees `./tool` entry documents the incident). The declared inject pends
16
+ * the row until the core provides, then the body applies.
17
+ */
18
+ const inject = [
19
+ "tools",
20
+ "subagents",
21
+ "localAgent"
22
+ ];
23
+ const Config = subagentToolConfigSchema(DEFAULT_PROVIDER, DEFAULT_TOOL_NAME);
24
+ /**
25
+ * Plugin body: mount the delegation tool through the family factory, stamped
26
+ * with THIS package as the tool-origin owner (AGENTS.md § Tool origin
27
+ * tagging — the shared factory never hardcodes an owner).
28
+ * @param ctx - Cordis context (the preset's agent-plane mount).
29
+ * @param config - row config (schema defaults already applied; the `??`
30
+ * fallbacks cover a direct ctx.plugin caller that bypassed validation).
31
+ */
32
+ function apply(ctx, config) {
33
+ const resolved = {
34
+ provider: config.provider ?? DEFAULT_PROVIDER,
35
+ toolName: config.toolName ?? DEFAULT_TOOL_NAME
36
+ };
37
+ mountSubagentDelegationTool(ctx, resolved, PACKAGE_NAME);
38
+ }
39
+ //#endregion
40
+ export { Config, apply, inject, name };
@@ -78,7 +78,11 @@ function useCardState(active, useSettings, scope, harnessModel, t) {
78
78
  const write = (field, value) => {
79
79
  setSaved(false);
80
80
  setError(false);
81
- void scope.set(field, value).then(() => { setSaved(true); }, () => { setError(true); });
81
+ // The host answers a refused write with `false` (ConfigForm), not a rejection.
82
+ void scope.set(field, value).then((accepted) => { if (accepted === false)
83
+ setError(true);
84
+ else
85
+ setSaved(true); }, () => { setError(true); });
82
86
  };
83
87
  // The suggestion vocabulary: the broker's deduped union (settings + host
84
88
  // default selection + recent) when the surface answered, the card's own
@@ -152,13 +156,13 @@ function useCardState(active, useSettings, scope, harnessModel, t) {
152
156
  }
153
157
  /** The card body both settings surfaces share: the auth block, the delegation switch, the default-model block, and the resident-mode block. */
154
158
  function CardBody({ state, auth, authT, renderModelPicker, useSessions, t }) {
155
- const { snapshot, saved, error, ready, enabled, live, storedModel, modelValue, modelSaved, modelError, choices, modelMenuOpen, modelMenuUp, modelFieldRef, modelPlaceholder, modelDefaultItem, saveModel, write, toggleModelMenu, setModelDraft, setModelMenuOpen, } = state;
159
+ const { snapshot, saved, error, ready, live, storedModel, modelValue, modelSaved, modelError, choices, modelMenuOpen, modelMenuUp, modelFieldRef, modelPlaceholder, modelDefaultItem, saveModel, write, toggleModelMenu, setModelDraft, setModelMenuOpen, } = state;
156
160
  // The free-text input, single-sourced for both presentations: bare when no
157
161
  // vocabulary exists (a fresh install degrades to exactly the pre-picker
158
162
  // field), or inside the select-like field next to its chevron. No datalist:
159
163
  // the chevron menu is the single choice list.
160
164
  const modelInputElement = (_jsx("input", { type: "text", className: css.modelInput, value: modelValue, placeholder: modelPlaceholder, "aria-label": t('model.title'), disabled: !ready, onChange: (event) => { setModelDraft(event.target.value); } }));
161
- return (_jsxs("div", { className: css.body, children: [_jsxs("section", { className: css.block, children: [_jsx("h3", { className: css.blockTitle, children: t('auth.title') }), _jsx(ProviderAuthBlock, { harness: { id: 'dsh', label: 'dsh' }, useSessions: useSessions, status: auth.status, runCommand: auth.runCommand, t: authT }), _jsx("span", { className: css.hint, children: t('auth.host') })] }), _jsxs("section", { className: css.block, children: [_jsx("h3", { className: css.blockTitle, children: t('enable.title') }), _jsxs("div", { className: css.row, children: [_jsx("span", { className: css.rowLabel, children: t('enable.switch') }), _jsx("button", { type: "button", role: "switch", "aria-checked": enabled, "aria-label": t('enable.switch.aria'), className: enabled ? `${css.switch} ${css.switchOn}` : css.switch, disabled: !ready, onClick: () => { write('enabled', !enabled); }, children: _jsx("span", { className: css.knob }) })] }), _jsx("span", { className: css.hint, children: t(enabled ? 'enable.on' : 'enable.off') })] }), _jsxs("section", { className: css.block, children: [_jsxs("h3", { className: css.blockTitle, children: [t('model.title'), _jsx(Tooltip, { label: t('model.info'), side: "bottom", maxWidth: 360, children: _jsx("button", { type: "button", className: css.info, "aria-label": t('model.info.aria'), children: "\u24D8" }) })] }), _jsxs("div", { className: css.row, children: [renderModelPicker?.({ value: modelValue, onChange: setModelDraft, disabled: !ready, defaultLabel: modelDefaultItem }) ?? (choices.length > 0 ? (_jsxs("div", { className: css.modelField, ref: modelFieldRef, children: [modelInputElement, _jsx("button", { type: "button", className: css.modelMenuButton, "aria-label": t('model.menu'), "aria-haspopup": "menu", "aria-expanded": modelMenuOpen, disabled: !ready, onClick: () => { toggleModelMenu(); }, children: _jsx(IconChevronDownOutlineMedium, { className: modelMenuOpen ? `${css.modelMenuChevron} ${css.modelMenuChevronOpen}` : css.modelMenuChevron }) }), modelMenuOpen && (_jsxs("div", { className: modelMenuUp ? `${css.modelMenu} ${css.modelMenuUp}` : css.modelMenu, role: "menu", "aria-label": t('model.menu'), children: [_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": modelValue.trim() === '', className: modelValue.trim() === '' ? `${css.modelItemDefault} ${css.modelItemCurrent}` : css.modelItemDefault, onClick: () => { setModelDraft(''); setModelMenuOpen(false); }, children: [_jsx("span", { className: css.modelItemLabel, children: modelDefaultItem }), _jsx("span", { className: css.modelItemCheck, "aria-hidden": true, children: modelValue.trim() === '' ? '✓' : '' })] }), choices.map(choice => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": choice === modelValue, className: choice === modelValue ? css.modelItemCurrent : css.modelItem, onClick: () => { setModelDraft(choice); setModelMenuOpen(false); }, children: [_jsx("span", { className: css.modelItemLabel, children: choice }), _jsx("span", { className: css.modelItemCheck, "aria-hidden": true, children: choice === modelValue ? '✓' : '' })] }, choice)))] }))] })) : modelInputElement), _jsx("button", { type: "button", className: css.modelSave, disabled: !ready || modelValue.trim() === storedModel, onClick: () => { saveModel(); }, children: t('model.save') })] }), modelSaved && _jsx("span", { className: css.saved, children: t('model.applied') }), modelError && _jsx("span", { className: css.errorText, children: t('model.error') })] }), _jsxs("section", { className: css.block, children: [_jsxs("div", { className: css.row, children: [_jsxs("span", { className: css.rowLabel, children: [t('live.title'), _jsx(Tooltip, { label: t('live.info'), side: "bottom", maxWidth: 360, children: _jsx("button", { type: "button", className: css.info, "aria-label": t('live.info.aria'), children: "\u24D8" }) })] }), _jsx("button", { type: "button", role: "switch", "aria-checked": live, "aria-label": t('live.title'), className: live ? `${css.switch} ${css.switchOn}` : css.switch, disabled: !ready, onClick: () => { write('live', !live); }, children: _jsx("span", { className: css.knob }) })] }), snapshot.status === 'unavailable' && (_jsx("span", { className: css.hint, children: t('live.unavailable') }))] }), saved && _jsx("span", { className: css.saved, children: t('live.applied') }), error && _jsx("span", { className: css.errorText, children: t('live.error') })] }));
165
+ return (_jsxs("div", { className: css.body, children: [_jsxs("section", { className: css.block, children: [_jsx("h3", { className: css.blockTitle, children: t('auth.title') }), _jsx(ProviderAuthBlock, { harness: { id: 'dsh', label: 'dsh' }, useSessions: useSessions, status: auth.status, runCommand: auth.runCommand, t: authT }), _jsx("span", { className: css.hint, children: t('auth.host') })] }), _jsxs("section", { className: css.block, children: [_jsxs("h3", { className: css.blockTitle, children: [t('model.title'), _jsx(Tooltip, { label: t('model.info'), side: "bottom", maxWidth: 360, children: _jsx("button", { type: "button", className: css.info, "aria-label": t('model.info.aria'), children: "\u24D8" }) })] }), _jsxs("div", { className: css.row, children: [renderModelPicker?.({ value: modelValue, onChange: setModelDraft, disabled: !ready, defaultLabel: modelDefaultItem }) ?? (choices.length > 0 ? (_jsxs("div", { className: css.modelField, ref: modelFieldRef, children: [modelInputElement, _jsx("button", { type: "button", className: css.modelMenuButton, "aria-label": t('model.menu'), "aria-haspopup": "menu", "aria-expanded": modelMenuOpen, disabled: !ready, onClick: () => { toggleModelMenu(); }, children: _jsx(IconChevronDownOutlineMedium, { className: modelMenuOpen ? `${css.modelMenuChevron} ${css.modelMenuChevronOpen}` : css.modelMenuChevron }) }), modelMenuOpen && (_jsxs("div", { className: modelMenuUp ? `${css.modelMenu} ${css.modelMenuUp}` : css.modelMenu, role: "menu", "aria-label": t('model.menu'), children: [_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": modelValue.trim() === '', className: modelValue.trim() === '' ? `${css.modelItemDefault} ${css.modelItemCurrent}` : css.modelItemDefault, onClick: () => { setModelDraft(''); setModelMenuOpen(false); }, children: [_jsx("span", { className: css.modelItemLabel, children: modelDefaultItem }), _jsx("span", { className: css.modelItemCheck, "aria-hidden": true, children: modelValue.trim() === '' ? '✓' : '' })] }), choices.map(choice => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": choice === modelValue, className: choice === modelValue ? css.modelItemCurrent : css.modelItem, onClick: () => { setModelDraft(choice); setModelMenuOpen(false); }, children: [_jsx("span", { className: css.modelItemLabel, children: choice }), _jsx("span", { className: css.modelItemCheck, "aria-hidden": true, children: choice === modelValue ? '✓' : '' })] }, choice)))] }))] })) : modelInputElement), _jsx("button", { type: "button", className: css.modelSave, disabled: !ready || modelValue.trim() === storedModel, onClick: () => { saveModel(); }, children: t('model.save') })] }), modelSaved && _jsx("span", { className: css.saved, children: t('model.applied') }), modelError && _jsx("span", { className: css.errorText, children: t('model.error') })] }), _jsxs("section", { className: css.block, children: [_jsxs("div", { className: css.row, children: [_jsxs("span", { className: css.rowLabel, children: [t('live.title'), _jsx(Tooltip, { label: t('live.info'), side: "bottom", maxWidth: 360, children: _jsx("button", { type: "button", className: css.info, "aria-label": t('live.info.aria'), children: "\u24D8" }) })] }), _jsx("button", { type: "button", role: "switch", "aria-checked": live, "aria-label": t('live.title'), className: live ? `${css.switch} ${css.switchOn}` : css.switch, disabled: !ready, onClick: () => { write('live', !live); }, children: _jsx("span", { className: css.knob }) })] }), snapshot.status === 'unavailable' && (_jsx("span", { className: css.hint, children: t('live.unavailable') }))] }), saved && _jsx("span", { className: css.saved, children: t('live.applied') }), error && _jsx("span", { className: css.errorText, children: t('live.error') })] }));
162
166
  }
163
167
  /**
164
168
  * The dsh harness settings card of the retired 0.1.5 plugin configuration
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Generated by scripts/sync-icon-artwork.mts from @deepseek-ai/dsh-client-ui-primitives
3
- * (v0.1.7-rc.1, harness 46a7f68b09) — do not edit by hand. The 0.1.5 and rc.1 host lines
3
+ * (v0.2.1-rc.1, harness 1600d8a1e1) — do not edit by hand. The 0.1.5 and rc.1 host lines
4
4
  * share no icon export name, so the rc.1 artwork is inlined here: pure SVG,
5
5
  * no host runtime identity, identical on every line. Re-run the generator
6
6
  * after adding an icon import or bumping the harness checkout.
@@ -1,4 +1,4 @@
1
- import { bindSettingsScope } from '@khorsheed/dsh-local-agent/src/client/settings-scope.ts';
1
+ import { bindCardSettingsScope } from '@khorsheed/dsh-local-agent/src/client/settings-scope.ts';
2
2
  /**
3
3
  * Local-agent-dsh plugin, browser half: the dsh settings card — a section of
4
4
  * the family core's「本机 Agent 委派」family card on the Plugins page
@@ -45,7 +45,7 @@ export const inject = ['slots', 'remote', 'remote.commands', 'locale'];
45
45
  export function apply(ctx) {
46
46
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'local-agent-dsh: dictionaries');
47
47
  const t = ctx.locale.bind(NS);
48
- const scope = bindSettingsScope(ctx, 'local-agent-dsh');
48
+ const scope = bindCardSettingsScope(ctx, 'dsh', 'local-agent-dsh');
49
49
  // The auth block's copy lives in the family core's dictionary; binding is
50
50
  // stable per namespace and late dictionary registration still resolves.
51
51
  const authT = ctx.locale.bind('local-agent');
@@ -21,11 +21,6 @@ export declare const zh: {
21
21
  'model.applied': string;
22
22
  'model.error': string;
23
23
  'auth.host': string;
24
- 'enable.title': string;
25
- 'enable.switch': string;
26
- 'enable.switch.aria': string;
27
- 'enable.on': string;
28
- 'enable.off': string;
29
24
  'live.title': string;
30
25
  'live.info.aria': string;
31
26
  'live.info': string;
@@ -61,11 +56,6 @@ export declare const en: {
61
56
  'model.applied': string;
62
57
  'model.error': string;
63
58
  'auth.host': string;
64
- 'enable.title': string;
65
- 'enable.switch': string;
66
- 'enable.switch.aria': string;
67
- 'enable.on': string;
68
- 'enable.off': string;
69
59
  'live.title': string;
70
60
  'live.info.aria': string;
71
61
  'live.info': string;
@@ -21,11 +21,6 @@ export const zh = {
21
21
  'model.applied': '已保存,下一轮委派生效;进行中的轮次不受影响',
22
22
  'model.error': '保存失败,请重试',
23
23
  'auth.host': 'dsh 凭据走宿主实例的凭证设置,无需单独登录。',
24
- 'enable.title': 'DeepSeek 委派',
25
- 'enable.switch': '启用',
26
- 'enable.switch.aria': 'DeepSeek 委派开关',
27
- 'enable.on': '已开启:模型可使用 subagent_dsh 委派给本机 dsh',
28
- 'enable.off': '已关闭:委派只走官方内置子代理工具',
29
24
  'live.title': '常驻模式(live)',
30
25
  'live.info.aria': '常驻模式说明',
31
26
  'live.info': '常驻模式 = 成员进程常驻:输出实时流入成员会话、取消不杀进程、崩溃后自动续上原会话;关闭则每轮独立进程,跑完一次性出结果。设置为全局偏好,对所有后续委派生效。',
@@ -53,11 +48,6 @@ export const en = {
53
48
  'model.applied': 'Saved — takes effect on the next delegation round; rounds in flight are unaffected',
54
49
  'model.error': 'Save failed, please retry',
55
50
  'auth.host': 'dsh authenticates through the host instance’s credential settings — no separate sign-in.',
56
- 'enable.title': 'DeepSeek delegation',
57
- 'enable.switch': 'Enabled',
58
- 'enable.switch.aria': 'DeepSeek delegation switch',
59
- 'enable.on': 'ON: the model can delegate to the local dsh via subagent_dsh',
60
- 'enable.off': 'OFF: delegation goes through the official in-process subagent tool only',
61
51
  'live.title': 'Resident mode (live)',
62
52
  'live.info.aria': 'About resident mode',
63
53
  'live.info': 'Resident mode keeps the member process alive: output streams into the member session in real time, cancelling does not kill the process, and a crash resumes the same session. Off means every round runs as an independent one-shot process. The setting is global and applies to every later delegation.',
@@ -15,11 +15,11 @@ import { randomUUID } from 'node:crypto';
15
15
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
16
16
  import { SessionId } from '@deepseek-ai/dsh-session';
17
17
  import { NO_START_CAPABILITIES, SUBAGENT_DESCRIPTOR_VERSION, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
18
- import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
18
+ import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, persistChildSession, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
19
19
  import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
20
20
  import { LiveChannelUnavailableError } from './live-driver.js';
21
21
  import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope, unitSubProfileName } from './provision.js';
22
- import { mirrorDshSession } from './session-mirror.js';
22
+ import { closeMirroredOpenStep, mirrorDshSession } from './session-mirror.js';
23
23
  /**
24
24
  * Spawn-env name carrying the bridge entry path into the sub-dsh process,
25
25
  * where the headless bundle's mcp-client row reads it (`!!js`). The name is a
@@ -685,39 +685,61 @@ export async function startDshCliRun(request, spec) {
685
685
  // reached its endpoint and produced an answer.
686
686
  if (settled.stopReason === 'completed')
687
687
  markCredentialVerified(ctx, spec.scope);
688
- // The turn closes at the real settle moment, so the timing projection's
689
- // duration equals the actual CLI runtime. Every terminal path closes the
690
- // window — a failed or cancelled run settles 'error'/'aborted' instead of
691
- // leaving the turn open with a distorted tiny duration.
692
- if (spec.childSession !== undefined) {
693
- if (settled.stopReason === 'completed') {
694
- spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
695
- }
696
- else if (settled.stopReason === 'aborted') {
697
- spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
698
- }
699
- else {
700
- spec.childSession.append('turn/end', {
701
- turn,
702
- reason: { kind: 'error', error: { message: 'dsh exited before the run completed', code: 'UNKNOWN' } },
703
- });
704
- }
705
- }
688
+ // The turn does NOT close here: turn/end lands in the mirror queue after
689
+ // the settle pass (see below), so every mirrored event precedes it in the
690
+ // stored log — the released-log reader rejects content outside an open
691
+ // turn on every cold read.
706
692
  return settled;
707
693
  });
708
694
  // After the child EXITS — however it ended (completed, killed by dispose, or
709
695
  // crashed) — mirror whatever the sub-dsh session already produced into the
710
696
  // dsh subagent session, so every round preserves its conversation and real
711
697
  // token usage instead of showing turn boundaries only. Waits for the settle
712
- // chain first (so turn/end is already appended) AND for the process to
713
- // actually exit (the sub-dsh flushed its session before exiting) before
714
- // reading.
698
+ // chain first AND for the process to actually exit (the sub-dsh flushed its
699
+ // session before exiting) before reading. The turn closes INSIDE the mirror
700
+ // queue, after the settle pass: the copied tail's open step first receives
701
+ // its closure (an interrupted sub-dsh leaves its step — and any advertised
702
+ // tool calls — open, and the released-log reader rejects a turn/end over
703
+ // it), then turn/end lands and is persisted explicitly (the mirror's own
704
+ // persist skips a no-op pass, which is exactly the case that needs the
705
+ // boundary written). Every terminal path closes the window — a failed or
706
+ // cancelled run settles 'error'/'aborted' instead of leaving the turn open.
715
707
  void result.then(() => child.done).then(() => {
716
708
  if (spec.childSession !== undefined) {
717
709
  const childSession = spec.childSession;
718
710
  // The settle pass reports the final mirror count even when polling
719
711
  // already covered the round (empty delta): it is authoritative.
720
- return enqueueMirror(() => mirrorAndReport(childSession, true));
712
+ return enqueueMirror(async () => {
713
+ const settled = await result;
714
+ try {
715
+ await mirrorAndReport(childSession, true);
716
+ }
717
+ catch (error) {
718
+ // The mirror degrades to a warn; the turn boundary still closes —
719
+ // an open turn at EOF reads as an interrupted tail only until the
720
+ // next resume round tries to open its turn over it.
721
+ ctx.logger.warn(`subagent-dsh: settle mirror failed: ${error instanceof Error ? error.message : String(error)}`);
722
+ }
723
+ closeMirroredOpenStep(childSession);
724
+ // The turn closes at the real settle moment (plus the settle-mirror
725
+ // quiescence), so the timing projection's duration equals the actual
726
+ // CLI runtime.
727
+ if (settled.stopReason === 'completed') {
728
+ childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
729
+ }
730
+ else if (settled.stopReason === 'aborted') {
731
+ childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
732
+ }
733
+ else {
734
+ childSession.append('turn/end', {
735
+ turn,
736
+ reason: { kind: 'error', error: { message: 'dsh exited before the run completed', code: 'UNKNOWN' } },
737
+ });
738
+ }
739
+ await persistChildSession(ctx, childSession).catch((error) => {
740
+ ctx.logger.warn(`subagent-dsh: turn/end persistence failed: ${error instanceof Error ? error.message : String(error)}`);
741
+ });
742
+ });
721
743
  }
722
744
  return undefined;
723
745
  }, () => { });
@@ -26,7 +26,6 @@ import { dirname } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import z from '@deepseek-ai/schemastery';
28
28
  import { settingsFace, vol } from '@khorsheed/dsh-local-agent';
29
- import { mountSubagentDelegationTool } from '@khorsheed/dsh-local-agent/tool-factory';
30
29
  import { CONTAINER_NODE_OPTIONS, DshCliProvider, dshCliVersion } from './dsh-cli-provider.js';
31
30
  import { DEFAULT_LIVE_IDLE_MS } from './live-driver.js';
32
31
  import { LiveDriverSwitch } from './live-switch.js';
@@ -57,7 +56,7 @@ export const inject = ['localAgent', 'subagents', 'subprocess', 'credentials', '
57
56
  */
58
57
  const SETTINGS_FIELDS = {
59
58
  enabled: z.boolean().default(false),
60
- live: z.boolean().default(false),
59
+ live: z.boolean().default(true),
61
60
  liveMirrorGranularity: z.union([z.const('event'), z.const('token')]).default('token'),
62
61
  // `model` deliberately carries NO default: an unset key must resolve to
63
62
  // undefined, which is what keeps the pre-key behavior byte-identical.
@@ -97,27 +96,6 @@ export const DSH_SETTINGS_NAMESPACE = 'local-agent-dsh';
97
96
  * the YAML config's deployment defaults through the composition base.
98
97
  */
99
98
  const DSH_SETTINGS_SCHEMA = z.object(SETTINGS_FIELDS);
100
- /** The delegation tool the toggle mounts while ON. */
101
- const DSH_TOOL_NAME = 'subagent_dsh';
102
- /**
103
- * This package's own name, passed to the family tool factory as the
104
- * tool-origin owner (AGENTS.md § Tool origin tagging — a shared factory never
105
- * hardcodes an owner), the same pattern the worktrees/room `./tool` entries
106
- * use.
107
- */
108
- const PACKAGE_NAME = '@khorsheed/dsh-local-agent-dsh';
109
- /**
110
- * The `subagent_dsh` mount, as a plugin object over the family factory: the
111
- * toggle owns its lifecycle, so it mounts dynamically through `ctx.plugin`
112
- * instead of shipping a `./tool` composition row like the CLI providers.
113
- */
114
- const dshSubagentTool = {
115
- name: 'local-agent-dsh-tool',
116
- inject: ['tools', 'subagents', 'localAgent'],
117
- apply: (toolCtx) => {
118
- mountSubagentDelegationTool(toolCtx, { provider: 'dsh-cli', toolName: DSH_TOOL_NAME }, PACKAGE_NAME);
119
- },
120
- };
121
99
  /**
122
100
  * Mount the toggle controller: register the settings namespace, then mirror
123
101
  * its value into harness/provider/tool registrations, live.
@@ -133,7 +111,7 @@ export function apply(ctx, config) {
133
111
  // card's writes without a reload.
134
112
  const readSettings = () => ({
135
113
  enabled: vol(config.enabled) ?? false,
136
- live: vol(config.live) ?? false,
114
+ live: vol(config.live) ?? true,
137
115
  liveMirrorGranularity: vol(config.liveMirrorGranularity) ?? 'token',
138
116
  model: vol(config.model),
139
117
  recentModels: vol(config.recentModels) ?? [],
@@ -146,6 +124,8 @@ export function apply(ctx, config) {
146
124
  }
147
125
  const face = settingsFace(ctx, {
148
126
  namespace: DSH_SETTINGS_NAMESPACE,
127
+ // Card writes live in the harness's own store (card-settings.ts), never the profile patch.
128
+ harness: 'dsh',
149
129
  legacySchema: DSH_SETTINGS_SCHEMA,
150
130
  legacyBase,
151
131
  read: readSettings,
@@ -326,7 +306,6 @@ export function apply(ctx, config) {
326
306
  return;
327
307
  currentEnabled = enabled;
328
308
  generation += 1;
329
- const gen = generation;
330
309
  for (const dispose of disposers.splice(0))
331
310
  dispose();
332
311
  if (!enabled)
@@ -353,20 +332,13 @@ export function apply(ctx, config) {
353
332
  currentLiveSwitch = undefined;
354
333
  liveSwitch.dispose();
355
334
  });
356
- // The family delegation tool is mounted dynamically so the toggle owns
357
- // its lifecycle — while OFF the model never sees `subagent_dsh`.
358
- void ctx.plugin(dshSubagentTool).then((fiber) => {
359
- if (gen !== generation) {
360
- void fiber.dispose();
361
- return;
362
- }
363
- disposers.push(() => { void fiber.dispose(); });
364
- }, (error) => {
365
- ctx.logger.warn(`local-agent-dsh: delegation tool mount failed: ${error instanceof Error ? error.message : String(error)}`);
366
- });
335
+ // The delegation tool is this package's `./tool` entry, granted per
336
+ // session by a preset (src/tool.ts) — never mounted at the profile root.
367
337
  };
368
- sync(face.get().enabled);
369
- const unwatch = face.watch((next) => { sync(next.enabled); });
338
+ // Always on: installing the plugin is the intent (the card's 启用 switch
339
+ // is gone); a preset still decides which sessions see `subagent_dsh`.
340
+ sync(true);
341
+ const unwatch = () => { };
370
342
  return () => {
371
343
  modelCatalog.dispose();
372
344
  unwatch();
@@ -28,7 +28,7 @@ import { LIVE_SERVER_NAME, LIVE_WIRE_PROTOCOL_VERSION, } from '@khorsheed/dsh-lo
28
28
  import { DEFAULT_DISPOSE_GRACE_MS, dshLaunchArgv, dshTextTask, registerMemberRun, } from './dsh-cli-provider.js';
29
29
  import { localModelLaunch } from './model-launch.js';
30
30
  import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope } from './provision.js';
31
- import { mirrorDshLiveEvent, mirrorDshSession } from './session-mirror.js';
31
+ import { mirrorDshLiveEvent, mirrorDshSession, closeMirroredOpenStep, mirroredOpenStep } from './session-mirror.js';
32
32
  /** Default idle lifetime of an unused resident runtime before reclaim. */
33
33
  export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
34
34
  /** Default timeout for a wire request acknowledgement. */
@@ -785,7 +785,13 @@ export class DshLiveDriver {
785
785
  }).then(async (settled) => {
786
786
  roundSettled = true;
787
787
  liveFlush.dispose();
788
- if (turnOpened && streaming !== undefined && streamPublisher !== undefined) {
788
+ // The interrupted stream's partial content folds durably only while its
789
+ // step is still OPEN: a graceful sub-dsh unwind writes its own
790
+ // interrupted assistant/message and step/end (both crossed the wire),
791
+ // and an append at the closed step would fail the released-log reader.
792
+ const openStep = turnOpened ? mirroredOpenStep(childSession) : undefined;
793
+ if (turnOpened && streaming !== undefined && streamPublisher !== undefined
794
+ && openStep !== undefined && openStep.step === streaming.step) {
789
795
  const blocks = streaming.assembler.interruptedBlocks();
790
796
  if (blocks.length > 0) {
791
797
  childSession.append('assistant/message', {
@@ -798,22 +804,6 @@ export class DshLiveDriver {
798
804
  }
799
805
  }
800
806
  streamPublisher?.dispose();
801
- // Identical turn/end bookkeeping to the exec path — but only for a turn
802
- // that actually opened (a pre-accept cancel records no turn at all).
803
- if (turnOpened) {
804
- if (settled.stopReason === 'completed') {
805
- childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
806
- }
807
- else if (settled.stopReason === 'aborted') {
808
- childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
809
- }
810
- else {
811
- childSession.append('turn/end', {
812
- turn,
813
- reason: { kind: 'error', error: nativeFailure ?? { message: 'the live round did not complete', code: 'UNKNOWN' } },
814
- });
815
- }
816
- }
817
807
  // Settlement clears the round's handlers: a late notification drops at
818
808
  // the runtime frame instead of landing in a dead closure.
819
809
  if (runtime !== undefined) {
@@ -821,24 +811,18 @@ export class DshLiveDriver {
821
811
  runtime.onEvent = undefined;
822
812
  runtime.onIdle = undefined;
823
813
  }
824
- // A completed answer is not a durable turn until its final boundary
825
- // and any closing stream checkpoints have reached the child store.
826
814
  if (turnOpened) {
827
- persist();
828
- await persistQueue;
829
- }
830
- return settled;
831
- });
832
- // Settle reconciliation: the file-based mirror pass (the serve side flushed
833
- // before its idle notification) dedupes against the live-mirrored prefix
834
- // and catches anything the wire dropped; the final mirror progress report
835
- // is authoritative, exactly as in the exec path. Then re-arm the reaper. A
836
- // round that never opened owns no span — skip the pass entirely (it would
837
- // re-scan the previous round or, under exec fallback, fight its polling).
838
- void result.then(async () => {
839
- try {
840
- if (turnOpened) {
841
- await persistQueue;
815
+ // Settle reconciliation IN-CHAIN: the file-based mirror pass (the serve
816
+ // side flushed before its idle notification) dedupes against the
817
+ // live-mirrored prefix and catches anything the wire dropped; the
818
+ // final mirror progress report is authoritative, exactly as in the
819
+ // exec path. It runs before turn/end so every mirrored event lands
820
+ // inside the turn window — the released-log reader rejects content
821
+ // outside an open turn on every cold read — and inside the result
822
+ // chain so the round serializer (and a resume round's turn/start)
823
+ // never overtakes the boundary. A mirror fault degrades to a warn;
824
+ // the boundary still closes.
825
+ try {
842
826
  if (mirroredMessages > 0)
843
827
  persist();
844
828
  await persistQueue.catch(() => { });
@@ -863,13 +847,35 @@ export class DshLiveDriver {
863
847
  registry?.recordRoundSettled?.(childSession.id, round);
864
848
  }
865
849
  }
850
+ catch (error) {
851
+ this.ctx.logger.warn(`subagent-dsh: live settle mirror failed: ${thrown(error).message}`);
852
+ }
853
+ // The copied tail's open step (an interrupted round leaves it open —
854
+ // the reader rejects a turn/end over it) receives its closure first.
855
+ closeMirroredOpenStep(childSession);
856
+ // Identical turn/end bookkeeping to the exec path — a completed,
857
+ // interrupted, or failed round each closes its window.
858
+ if (settled.stopReason === 'completed') {
859
+ childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
860
+ }
861
+ else if (settled.stopReason === 'aborted') {
862
+ childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
863
+ }
864
+ else {
865
+ childSession.append('turn/end', {
866
+ turn,
867
+ reason: { kind: 'error', error: nativeFailure ?? { message: 'the live round did not complete', code: 'UNKNOWN' } },
868
+ });
869
+ }
870
+ // A completed answer is not a durable turn until its final boundary
871
+ // and any closing stream checkpoints have reached the child store.
872
+ persist();
873
+ await persistQueue;
866
874
  }
867
- catch (error) {
868
- this.ctx.logger.warn(`subagent-dsh: live settle mirror failed: ${thrown(error).message}`);
869
- }
870
- finally {
871
- this.armIdleTimer(spec.sessionId);
872
- }
875
+ // Re-arm the idle reaper for every settled round — including one that
876
+ // never opened its turn (the runtime is up and must not leak).
877
+ this.armIdleTimer(spec.sessionId);
878
+ return settled;
873
879
  });
874
880
  // Publication gate: hold start() until the turn is accepted (channel
875
881
  // errors still throw for the provider's exec fallback), but NEVER hold it