dsh-connect 0.8.0 → 0.9.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 (94) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +134 -17
  3. package/README.zh.md +110 -17
  4. package/client/client.js +510 -80
  5. package/client/client.js.map +4 -4
  6. package/client/locale.mjs +227 -0
  7. package/client/panel-state.mjs +54 -0
  8. package/client/settings-client.mjs +272 -60
  9. package/docs/images/settings-advanced-en.png +0 -0
  10. package/docs/images/settings-advanced-zh.png +0 -0
  11. package/docs/images/settings-defaults-en.png +0 -0
  12. package/docs/images/settings-defaults-zh.png +0 -0
  13. package/docs/images/settings-overview-en.png +0 -0
  14. package/docs/images/settings-overview-zh.png +0 -0
  15. package/lib/binding.d.ts.map +1 -1
  16. package/lib/binding.js +2 -2
  17. package/lib/binding.js.map +1 -1
  18. package/lib/channels/dingtalk/message.d.ts.map +1 -1
  19. package/lib/channels/dingtalk/message.js +7 -0
  20. package/lib/channels/dingtalk/message.js.map +1 -1
  21. package/lib/channels/feishu/adapter.d.ts +6 -12
  22. package/lib/channels/feishu/adapter.d.ts.map +1 -1
  23. package/lib/channels/feishu/adapter.js +2 -17
  24. package/lib/channels/feishu/adapter.js.map +1 -1
  25. package/lib/channels/feishu/i18n.d.ts +2 -0
  26. package/lib/channels/feishu/i18n.d.ts.map +1 -1
  27. package/lib/channels/feishu/i18n.js +2 -0
  28. package/lib/channels/feishu/i18n.js.map +1 -1
  29. package/lib/channels/feishu/index.d.ts +23 -2
  30. package/lib/channels/feishu/index.d.ts.map +1 -1
  31. package/lib/channels/feishu/index.js +32 -6
  32. package/lib/channels/feishu/index.js.map +1 -1
  33. package/lib/channels/telegram/adapter.d.ts.map +1 -1
  34. package/lib/channels/telegram/adapter.js +7 -1
  35. package/lib/channels/telegram/adapter.js.map +1 -1
  36. package/lib/chat-key.d.ts +29 -0
  37. package/lib/chat-key.d.ts.map +1 -0
  38. package/lib/chat-key.js +38 -0
  39. package/lib/chat-key.js.map +1 -0
  40. package/lib/index.d.ts +15 -5
  41. package/lib/index.d.ts.map +1 -1
  42. package/lib/index.js +201 -31
  43. package/lib/index.js.map +1 -1
  44. package/lib/runner.d.ts +54 -0
  45. package/lib/runner.d.ts.map +1 -1
  46. package/lib/runner.js +158 -23
  47. package/lib/runner.js.map +1 -1
  48. package/lib/scheduler.d.ts.map +1 -1
  49. package/lib/scheduler.js +2 -2
  50. package/lib/scheduler.js.map +1 -1
  51. package/lib/service.d.ts +16 -0
  52. package/lib/service.d.ts.map +1 -1
  53. package/lib/service.js +46 -2
  54. package/lib/service.js.map +1 -1
  55. package/lib/settings/channel-runtime.d.ts +75 -0
  56. package/lib/settings/channel-runtime.d.ts.map +1 -0
  57. package/lib/settings/channel-runtime.js +165 -0
  58. package/lib/settings/channel-runtime.js.map +1 -0
  59. package/lib/settings/channels.d.ts +11 -0
  60. package/lib/settings/channels.d.ts.map +1 -1
  61. package/lib/settings/channels.js +31 -0
  62. package/lib/settings/channels.js.map +1 -1
  63. package/lib/settings/credential-store.d.ts +52 -6
  64. package/lib/settings/credential-store.d.ts.map +1 -1
  65. package/lib/settings/credential-store.js +92 -18
  66. package/lib/settings/credential-store.js.map +1 -1
  67. package/lib/settings/namespace.d.ts +175 -0
  68. package/lib/settings/namespace.d.ts.map +1 -0
  69. package/lib/settings/namespace.js +200 -0
  70. package/lib/settings/namespace.js.map +1 -0
  71. package/lib/settings/secret-disclosure.d.ts +50 -0
  72. package/lib/settings/secret-disclosure.d.ts.map +1 -0
  73. package/lib/settings/secret-disclosure.js +132 -0
  74. package/lib/settings/secret-disclosure.js.map +1 -0
  75. package/lib/settings/settings-model.d.ts +17 -0
  76. package/lib/settings/settings-model.d.ts.map +1 -1
  77. package/lib/settings/settings-model.js +8 -0
  78. package/lib/settings/settings-model.js.map +1 -1
  79. package/lib/settings/settings-rpc.d.ts +71 -3
  80. package/lib/settings/settings-rpc.d.ts.map +1 -1
  81. package/lib/settings/settings-rpc.js +167 -24
  82. package/lib/settings/settings-rpc.js.map +1 -1
  83. package/lib/settings/settings-service.d.ts +9 -1
  84. package/lib/settings/settings-service.d.ts.map +1 -1
  85. package/lib/settings/settings-service.js +74 -6
  86. package/lib/settings/settings-service.js.map +1 -1
  87. package/lib/state-dir.d.ts +28 -0
  88. package/lib/state-dir.d.ts.map +1 -0
  89. package/lib/state-dir.js +33 -0
  90. package/lib/state-dir.js.map +1 -0
  91. package/lib/stream.d.ts.map +1 -1
  92. package/lib/stream.js +3 -2
  93. package/lib/stream.js.map +1 -1
  94. package/package.json +11 -9
package/lib/runner.js CHANGED
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Per-chat agent driver: serializes inbound messages, creates/resumes the bound
3
- * DSH agent (with preset composition + model selection), and bridges the live
4
- * `session/event` stream into the adapter's streaming reply.
3
+ * DSH agent (with preset composition + model selection), and bridges two live
4
+ * feeds into the adapter's streaming reply: the durable `session/event` stream
5
+ * (turn, tool, todo and settlement events) and the transient
6
+ * `agent/assistant-stream` frames that carry the model's deltas. Since
7
+ * 0.1.5-rc.2 the deltas are no longer session events — `assistant/chunk` is
8
+ * gone — so the two subscriptions are separate and both are required.
5
9
  * @module dsh-connect/runner
6
10
  */
7
11
  import { randomUUID } from "node:crypto";
@@ -12,6 +16,7 @@ import { SessionId } from "@deepseek-ai/dsh-session";
12
16
  import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
13
17
  import { createAsyncQueue, applyStreamChunk, applyToolCall, toolCallSummary, questionTextOf, summarizeTurn, classifyError, mapReason, truncate, fmtTokens, textOf, } from "./stream.js";
14
18
  import { helpText, parseCommand } from "./commands.js";
19
+ import { resolveStateDir } from "./state-dir.js";
15
20
  import { menuTitle, rootMenuSections, reasonLabel, goalPhaseLabel, listWorkspaces, PROGRESS_PRESET_MS } from "./menus.js";
16
21
  import { MenuController } from "./menu-controller.js";
17
22
  import { messages } from "./i18n.js";
@@ -89,6 +94,19 @@ function sameDir(a, b) {
89
94
  }
90
95
  /** How often the proactive progress watchdog re-checks whether a status card is due (ms). */
91
96
  const PROGRESS_WATCHDOG_CHECK_MS = 15_000;
97
+ /**
98
+ * Preset composed when the configured one cannot be used.
99
+ *
100
+ * `standard` is the roster's general-purpose preset and ships in every build,
101
+ * so it is the safest thing to fall back to. The roster still gets asked first
102
+ * — if it disagrees the first mountable row wins, and only a roster with no
103
+ * usable row at all leaves the agent uncomposted.
104
+ */
105
+ const FALLBACK_PRESET_ID = "standard";
106
+ /** How a preset id reads in a log line: a quoted id, or the roster's default. */
107
+ function describePreset(id) {
108
+ return id === undefined || id === "" ? "the roster default" : JSON.stringify(id);
109
+ }
92
110
  export class AgentRunner {
93
111
  ctx;
94
112
  config;
@@ -102,6 +120,8 @@ export class AgentRunner {
102
120
  reminders;
103
121
  queue = [];
104
122
  running = false;
123
+ /** Set by `dispose()`; makes the runner permanently inert. */
124
+ disposed = false;
105
125
  agent;
106
126
  handle;
107
127
  turn;
@@ -154,7 +174,31 @@ export class AgentRunner {
154
174
  const first = registry?.list?.()[0];
155
175
  return first?.path ?? process.cwd();
156
176
  }
177
+ /**
178
+ * Tear the runner down for good — the counterpoint to `enqueue`.
179
+ *
180
+ * Called when the runner's channel adapter is deactivated (a settings change
181
+ * removed that channel from `channels[]`) or the service shuts down. The
182
+ * runner is inert afterwards: `enqueue` drops work and the drain loop stops
183
+ * before its next turn. `ConnectService` deletes its map entry, so a later
184
+ * message builds a fresh runner instead of reviving this one.
185
+ *
186
+ * `queuedMessages` is cleared rather than replayed: those messages belong to
187
+ * a channel that no longer has an adapter to answer on, so replaying them
188
+ * would run agent turns whose output has nowhere to go.
189
+ */
190
+ async dispose() {
191
+ this.disposed = true;
192
+ this.queue.length = 0;
193
+ await this.disposeAgent();
194
+ const binding = this.bindings.get(this.channel, this.chatKey);
195
+ if (binding !== undefined && binding.lockOwner !== undefined) {
196
+ this.bindings.put({ ...lockRelease(binding), queuedMessages: [] });
197
+ }
198
+ }
157
199
  enqueue(msg) {
200
+ if (this.disposed)
201
+ return;
158
202
  const command = parseCommand(msg.text);
159
203
  if (command.kind !== "message") {
160
204
  // Command handlers touch the adapter directly (no per-call catch) — a
@@ -199,15 +243,6 @@ export class AgentRunner {
199
243
  this.maybeNudgeContext(turn);
200
244
  }
201
245
  }
202
- if (event.type === "assistant/chunk") {
203
- const chunk = event.data.chunk;
204
- // First reasoning delta = the milestone the proactive progress notice reports.
205
- if (chunk.type === "reasoning-delta" && turn.milestone === undefined) {
206
- turn.milestone = this.t.progressThinking;
207
- }
208
- applyStreamChunk(turn, this.t.thinkingHint, chunk, this.notifyLevel);
209
- return;
210
- }
211
246
  if (event.type === "tool/call") {
212
247
  const name = event.data.name;
213
248
  if (typeof name !== "string" || name === "")
@@ -336,11 +371,11 @@ export class AgentRunner {
336
371
  return choice === "confirm:yes";
337
372
  }
338
373
  async drain() {
339
- if (this.running)
374
+ if (this.running || this.disposed)
340
375
  return;
341
376
  this.running = true;
342
377
  try {
343
- while (this.queue.length > 0) {
378
+ while (this.queue.length > 0 && !this.disposed) {
344
379
  const msg = this.queue.shift();
345
380
  if (msg === undefined)
346
381
  break;
@@ -571,6 +606,10 @@ export class AgentRunner {
571
606
  * report it) while the actual LLM requests keep using the old default.
572
607
  * The default model still seeds the agent through `agentOptions` in
573
608
  * `ensureAgent`, which `buildRequest` uses as its fallback route.
609
+ *
610
+ * Preset resolution is best-effort — see {@link resolvePresetId}. This method
611
+ * never throws for a preset reason, so no configuration id can stop a turn
612
+ * from composing an agent at all.
574
613
  */
575
614
  async composeSetup(_selection) {
576
615
  const presets = this.ctx.get("agentPresets");
@@ -579,14 +618,86 @@ export class AgentRunner {
579
618
  setup: () => undefined,
580
619
  };
581
620
  }
582
- const resolved = await presets.resolve(this.config.agentPreset);
621
+ const id = await this.resolvePresetId(presets);
622
+ if (id === undefined) {
623
+ return {
624
+ setup: () => undefined,
625
+ };
626
+ }
583
627
  return {
584
- agentPreset: resolved.id,
628
+ agentPreset: id,
585
629
  setup: async (agentCtx) => {
586
- await presets.mount(agentCtx, resolved.id);
630
+ await presets.mount(agentCtx, id);
587
631
  },
588
632
  };
589
633
  }
634
+ /**
635
+ * Resolve the preset to compose, degrading instead of aborting the turn.
636
+ *
637
+ * The host looks a preset up by id, so one stale id — `agentPresets.default`
638
+ * naming a preset this install does not ship, or a preset directory edited
639
+ * into an unreadable state — used to throw straight out of `composeSetup`.
640
+ * That happens before an agent exists, so *every* turn of every bound chat
641
+ * died with the raw host error and nothing in the session log to explain it.
642
+ * Resolution is therefore best-effort: the configured id is tried, then
643
+ * {@link FALLBACK_PRESET_ID}, and when neither composes the agent is built
644
+ * without a preset — the same shape a host with no `agentPresets` service
645
+ * already produced — so the turn still runs.
646
+ */
647
+ async resolvePresetId(presets) {
648
+ const wanted = this.config.agentPreset;
649
+ const resolved = await this.tryPreset(presets, wanted);
650
+ if (resolved !== undefined)
651
+ return resolved;
652
+ const recovered = await this.tryPreset(presets, await this.fallbackPresetId(presets));
653
+ if (recovered !== undefined) {
654
+ this.log(`connect: falling back to agent preset ${JSON.stringify(recovered)}`);
655
+ return recovered;
656
+ }
657
+ this.log("connect: no usable agent preset; composing the agent without one");
658
+ return undefined;
659
+ }
660
+ /**
661
+ * Resolve `id`, reporting why it is unusable instead of throwing.
662
+ *
663
+ * A preset that resolves but reports `broken` counts as unusable: the host's
664
+ * mounting paths refuse those, so composing one would only move this failure
665
+ * to `mount`, after the log line that would have explained it.
666
+ *
667
+ * `undefined` id means "the roster default", which is what `resolve` does
668
+ * with it — the default is as fallible as an explicit id, so it is routed
669
+ * through the same recovery.
670
+ */
671
+ async tryPreset(presets, id) {
672
+ try {
673
+ const resolved = await presets.resolve(id);
674
+ if (resolved.broken !== undefined) {
675
+ this.log(`connect: agent preset ${describePreset(id)} failed to load: ${resolved.broken}`);
676
+ return undefined;
677
+ }
678
+ return resolved.id;
679
+ }
680
+ catch (error) {
681
+ this.log(`connect: agent preset ${describePreset(id)} is unusable: ${String(error)}`);
682
+ return undefined;
683
+ }
684
+ }
685
+ /**
686
+ * The id to retry with: {@link FALLBACK_PRESET_ID} while the roster still
687
+ * offers it, otherwise the roster's first mountable row.
688
+ *
689
+ * Never throws — the caller is already handling a failure, and a roster that
690
+ * cannot be listed should still leave the hardcoded id to be tried.
691
+ */
692
+ async fallbackPresetId(presets) {
693
+ try {
694
+ const mountable = ((await presets.list?.()) ?? []).filter((row) => row.broken === undefined);
695
+ return mountable.find((row) => row.id === FALLBACK_PRESET_ID)?.id ?? mountable[0]?.id ?? FALLBACK_PRESET_ID;
696
+ }
697
+ catch {
698
+ return FALLBACK_PRESET_ID;
699
+ }
700
+ }
590
701
  /** Agents this runner has already attached a `session/event` listener to. */
591
702
  watchedAgents = new WeakSet();
592
703
  watchAgent(agent) {
@@ -600,6 +711,28 @@ export class AgentRunner {
600
711
  agent.ctx.on("session/event", (session, event) => {
601
712
  this.onSessionEvent(session, event);
602
713
  });
714
+ // Live model deltas. Deltas used to arrive as `assistant/chunk` session
715
+ // events; that type is gone, and a turn's stream is now durable only at
716
+ // settlement (embedded in `assistant/message` / `assistant/attempt`). The
717
+ // live feed is this separate agent-scoped event instead — `dsh-scope`
718
+ // routes it by its `agent` subject, so a listener on `agent.ctx` sees only
719
+ // this agent's frames, exactly like the `session/event` subscription above
720
+ // (no id filter needed). Frames carry real `StreamChunk`s, so
721
+ // `applyStreamChunk` is unchanged.
722
+ agent.ctx.on("agent/assistant-stream", ({ frame }) => {
723
+ const turn = this.turn;
724
+ if (turn === undefined)
725
+ return;
726
+ // `start` / `end` only bracket the attempt; the card is driven by chunks.
727
+ if (frame.type !== "chunk")
728
+ return;
729
+ const chunk = frame.chunk;
730
+ // First reasoning delta = the milestone the proactive progress notice reports.
731
+ if (chunk.type === "reasoning-delta" && turn.milestone === undefined) {
732
+ turn.milestone = this.t.progressThinking;
733
+ }
734
+ applyStreamChunk(turn, this.t.thinkingHint, chunk, this.notifyLevel);
735
+ });
603
736
  }
604
737
  async driveAgent(agent, msg) {
605
738
  const firstSeq = agent.session.seq;
@@ -700,7 +833,7 @@ export class AgentRunner {
700
833
  this.log(`connect: deferred compaction failed: ${error instanceof Error ? error.message : String(error)}`);
701
834
  });
702
835
  }
703
- const outcome = summarizeTurn(agent.session.events, firstSeq);
836
+ const outcome = summarizeTurn(agent.session.snapshotEvents(), firstSeq);
704
837
  const text = outcome.text !== "" ? outcome.text : this.turn.lastText;
705
838
  this.turn = undefined;
706
839
  return { ...outcome, text };
@@ -930,7 +1063,7 @@ export class AgentRunner {
930
1063
  const agent = this.agent;
931
1064
  if (agent === undefined)
932
1065
  return [];
933
- const events = agent.session.events;
1066
+ const events = agent.session.snapshotEvents();
934
1067
  for (let i = events.length - 1; i >= 0; i--) {
935
1068
  const event = events[i];
936
1069
  if (event.type === "todo/write")
@@ -1449,7 +1582,7 @@ export class AgentRunner {
1449
1582
  }
1450
1583
  /** Extract the most recent turn's completion info from session events. */
1451
1584
  getLastTurnInfo(agent) {
1452
- const events = agent.session.events;
1585
+ const events = agent.session.snapshotEvents();
1453
1586
  for (let i = events.length - 1; i >= 0; i--) {
1454
1587
  const event = events[i];
1455
1588
  if (event.type === "turn/end") {
@@ -1492,7 +1625,9 @@ export class AgentRunner {
1492
1625
  lines.push(this.t.progressSetting(this.progressTimeoutMs === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(this.progressTimeoutMs / 60_000))));
1493
1626
  lines.push(this.t.allowUsersField(this.config.allowUsers.length, this.config.allowUsers.length === 0));
1494
1627
  lines.push(this.t.allowChatsField(this.config.allowChats.length, this.config.allowChats.length === 0));
1495
- lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
1628
+ // Show the directory the stores actually resolve to (config → env → default),
1629
+ // not the raw config value, so the status card can't disagree with disk.
1630
+ lines.push(this.t.stateDirField(resolveStateDir(this.config)));
1496
1631
  await this.adapter.sendText(target, lines.join("\n"));
1497
1632
  }
1498
1633
  async setModel(provider, model, msg) {
@@ -1717,7 +1852,7 @@ export class AgentRunner {
1717
1852
  await this.adapter.sendText(target, this.t.historySessions(sessions.length, lines.join("\n")));
1718
1853
  return;
1719
1854
  }
1720
- const events = agent.session.events;
1855
+ const events = agent.session.snapshotEvents();
1721
1856
  const rows = [];
1722
1857
  for (let i = events.length - 1; i >= 0 && rows.length < limit; i--) {
1723
1858
  const e = events[i];
@@ -1766,7 +1901,7 @@ export class AgentRunner {
1766
1901
  async showSchedule(target) {
1767
1902
  // Agent-level reminders (session `schedule` tool) + persistent chat-level
1768
1903
  // reminders (`/remind`), merged into one list.
1769
- const agentReminders = this.agent === undefined ? [] : foldReminders(this.agent.session.events);
1904
+ const agentReminders = this.agent === undefined ? [] : foldReminders(this.agent.session.snapshotEvents());
1770
1905
  const persisted = this.reminders?.listFor(this.channel, this.chatKey) ?? [];
1771
1906
  const now = Date.now();
1772
1907
  const locale = this.language === "en" ? "en-US" : "zh-CN";
@@ -1939,7 +2074,7 @@ export class AgentRunner {
1939
2074
  * Generate Markdown representation of the conversation history.
1940
2075
  */
1941
2076
  generateMarkdown(agent) {
1942
- const events = agent.session.events;
2077
+ const events = agent.session.snapshotEvents();
1943
2078
  const lines = [];
1944
2079
  // Header
1945
2080
  lines.push(`# Conversation History`);