dsh-connect 0.6.7 → 0.7.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.
Files changed (55) hide show
  1. package/lib/binding.d.ts +2 -0
  2. package/lib/binding.d.ts.map +1 -1
  3. package/lib/binding.js +1 -1
  4. package/lib/binding.js.map +1 -1
  5. package/lib/commands.d.ts +10 -1
  6. package/lib/commands.d.ts.map +1 -1
  7. package/lib/commands.js +18 -6
  8. package/lib/commands.js.map +1 -1
  9. package/lib/dedup.d.ts +31 -0
  10. package/lib/dedup.d.ts.map +1 -0
  11. package/lib/dedup.js +52 -0
  12. package/lib/dedup.js.map +1 -0
  13. package/lib/i18n.d.ts +17 -1
  14. package/lib/i18n.d.ts.map +1 -1
  15. package/lib/i18n.js +34 -2
  16. package/lib/i18n.js.map +1 -1
  17. package/lib/index.d.ts +17 -4
  18. package/lib/index.d.ts.map +1 -1
  19. package/lib/index.js +9 -2
  20. package/lib/index.js.map +1 -1
  21. package/lib/menu-controller.d.ts +71 -0
  22. package/lib/menu-controller.d.ts.map +1 -0
  23. package/lib/menu-controller.js +284 -0
  24. package/lib/menu-controller.js.map +1 -0
  25. package/lib/menus.d.ts +62 -0
  26. package/lib/menus.d.ts.map +1 -0
  27. package/lib/menus.js +99 -0
  28. package/lib/menus.js.map +1 -0
  29. package/lib/mirror-lock.d.ts +51 -0
  30. package/lib/mirror-lock.d.ts.map +1 -0
  31. package/lib/mirror-lock.js +56 -0
  32. package/lib/mirror-lock.js.map +1 -0
  33. package/lib/retry.d.ts +35 -0
  34. package/lib/retry.d.ts.map +1 -0
  35. package/lib/retry.js +41 -0
  36. package/lib/retry.js.map +1 -0
  37. package/lib/runner.d.ts +93 -95
  38. package/lib/runner.d.ts.map +1 -1
  39. package/lib/runner.js +259 -714
  40. package/lib/runner.js.map +1 -1
  41. package/lib/scheduler.d.ts +46 -0
  42. package/lib/scheduler.d.ts.map +1 -0
  43. package/lib/scheduler.js +162 -0
  44. package/lib/scheduler.js.map +1 -0
  45. package/lib/service.d.ts +27 -0
  46. package/lib/service.d.ts.map +1 -1
  47. package/lib/service.js +106 -3
  48. package/lib/service.js.map +1 -1
  49. package/lib/stream.d.ts +62 -2
  50. package/lib/stream.d.ts.map +1 -1
  51. package/lib/stream.js +270 -0
  52. package/lib/stream.js.map +1 -1
  53. package/lib/types.d.ts +19 -0
  54. package/lib/types.d.ts.map +1 -1
  55. package/package.json +2 -2
package/lib/runner.js CHANGED
@@ -10,9 +10,13 @@ import { copyFile, readFile } from "node:fs/promises";
10
10
  import { basename, isAbsolute, join } from "node:path";
11
11
  import { SessionId } from "@deepseek-ai/dsh-session";
12
12
  import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
13
- import { createAsyncQueue } from "./stream.js";
13
+ import { createAsyncQueue, applyStreamChunk, applyToolCall, toolCallSummary, questionTextOf, summarizeTurn, classifyError, mapReason, truncate, fmtTokens, textOf, } from "./stream.js";
14
14
  import { helpText, parseCommand } from "./commands.js";
15
+ import { menuTitle, rootMenuSections, reasonLabel, goalPhaseLabel, listWorkspaces } from "./menus.js";
16
+ import { MenuController } from "./menu-controller.js";
15
17
  import { messages } from "./i18n.js";
18
+ import { DEFAULT_LOCK_TIMEOUT_MS, acquire as lockAcquire, canWrite as lockCanWrite, isLockTimedOut as lockIsTimedOut, release as lockRelease, } from "./mirror-lock.js";
19
+ import { formatRemindAt, parseRemindTime } from "./scheduler.js";
16
20
  export function resolveConnectConfig(config) {
17
21
  return {
18
22
  agentPreset: config.agentPreset,
@@ -25,280 +29,10 @@ export function resolveConnectConfig(config) {
25
29
  stateDir: config.stateDir,
26
30
  autoMirror: config.autoMirror ?? true, // Enabled by default
27
31
  streamHeartbeatMs: config.streamHeartbeatMs ?? 60_000,
28
- notifyLevel: config.notifyLevel ?? "important",
32
+ notifyLevel: config.notifyLevel ?? "result",
29
33
  progressTimeoutMs: config.progressTimeoutMs ?? 5 * 60_000, // Proactive progress notice after 5 min of silence
30
34
  };
31
35
  }
32
- /**
33
- * Assemble one `assistant/chunk` event into the streaming queue with visible
34
- * structure. DSH streams one block per LLM content block and block boundaries
35
- * carry no newline of their own, so without separators every block (and the
36
- * reasoning phase vs the final answer) would be glued together in the chat
37
- * card. This inserts a blank line between blocks and before the answer, keeps
38
- * the thinking hint once per turn, expands reasoning soft breaks (Feishu's
39
- * streaming card collapses single `\n`), and falls back to whole-block text
40
- * for blocks that arrived only via `block-end`. Every push refreshes
41
- * `lastPushAt` so the liveness heartbeat knows the card is still moving.
42
- * @param state - the turn's mutable assembly state.
43
- * @param thinkingHint - localized hint text pushed at the first reasoning delta.
44
- * @param chunk - the assistant chunk event's `chunk` payload.
45
- */
46
- /** Every line-break run becomes a paragraph break (Feishu's streaming card collapses single `\n`). */
47
- function expandLineBreaks(text) {
48
- return text.replace(/\r\n?/g, "\n").replace(/\n+/g, "\n\n");
49
- }
50
- export function applyStreamChunk(state, thinkingHint, chunk, level = "full") {
51
- if (chunk.type === "text-delta" || chunk.type === "reasoning-delta") {
52
- const isReasoning = chunk.type === "reasoning-delta";
53
- // Reasoning content is only streamed in `full` mode; `important` shows
54
- // just the thinking hint and `result` shows nothing until the answer.
55
- if (isReasoning && level !== "full") {
56
- if (level === "important" && !state.hintPushed) {
57
- state.hintPushed = true;
58
- state.chunks.push(thinkingHint);
59
- state.pushedAny = true;
60
- state.lastPushAt = Date.now();
61
- }
62
- return;
63
- }
64
- if (state.pushedAny) {
65
- const newBlock = chunk.index !== undefined && state.lastIndex !== undefined && chunk.index !== state.lastIndex;
66
- const reasoningToAnswer = !isReasoning && state.reasoning;
67
- if (newBlock || reasoningToAnswer)
68
- state.chunks.push("\n\n");
69
- }
70
- if (isReasoning) {
71
- if (!state.hintPushed) {
72
- state.hintPushed = true;
73
- state.chunks.push(thinkingHint);
74
- }
75
- const text = chunk.text ?? "";
76
- if (text !== "") {
77
- state.chunks.push(expandLineBreaks(text));
78
- }
79
- state.reasoning = true;
80
- }
81
- else {
82
- const text = chunk.text ?? "";
83
- state.chunks.push(text);
84
- state.lastText += text;
85
- state.reasoning = false;
86
- }
87
- state.lastIndex = chunk.index;
88
- state.pushedAny = true;
89
- state.lastPushAt = Date.now();
90
- return;
91
- }
92
- if (chunk.type === "block-end") {
93
- // Short blocks may arrive whole here with no preceding delta chunks.
94
- // Fall back to the block text when this index never streamed deltas, so
95
- // no content is silently dropped (guarded by index against duplication;
96
- // each step restarts block indices, and a tool call resets `lastIndex`,
97
- // so a same-index block-end after streamed deltas is reliably detected).
98
- const index = chunk.index;
99
- const blockType = chunk.block?.type;
100
- const text = chunk.block?.text;
101
- if (blockType === "reasoning" && level !== "full") {
102
- // Reasoning block text is never streamed outside `full` mode.
103
- if (level === "important" && !state.hintPushed) {
104
- state.hintPushed = true;
105
- state.chunks.push(thinkingHint);
106
- state.pushedAny = true;
107
- state.lastPushAt = Date.now();
108
- }
109
- return;
110
- }
111
- if (typeof text === "string" && text.length > 0 && (index === undefined || index !== state.lastIndex)) {
112
- if (state.pushedAny)
113
- state.chunks.push("\n\n");
114
- if (blockType === "reasoning" && !state.hintPushed) {
115
- state.hintPushed = true;
116
- state.chunks.push(thinkingHint);
117
- }
118
- state.chunks.push(blockType === "reasoning" ? expandLineBreaks(text) : text);
119
- if (blockType !== "reasoning")
120
- state.lastText += text;
121
- state.lastIndex = index;
122
- state.pushedAny = true;
123
- state.reasoning = blockType === "reasoning";
124
- state.lastPushAt = Date.now();
125
- }
126
- }
127
- }
128
- /**
129
- * Push a localized tool-call status line into the streaming queue so long
130
- * tool-execution stretches stay visibly alive (the Web GUI shows tool calls;
131
- * the chat card should too). Ends the reasoning phase and drops the block
132
- * index so the next content block gets its own separator instead of stacking
133
- * one on top of the tool line's own blank lines.
134
- * @param state - the turn's mutable assembly state.
135
- * @param label - the localized status line, e.g. "🔧 调用工具 `pwsh` — …".
136
- */
137
- export function applyToolCall(state, label) {
138
- state.chunks.push(`\n\n${label}\n\n`);
139
- state.reasoning = false;
140
- state.lastIndex = undefined;
141
- state.pushedAny = true;
142
- state.lastPushAt = Date.now();
143
- }
144
- /** Pull a short human summary out of a tool call's `arguments` JSON (whitespace folded to one line). */
145
- export function toolCallSummary(argumentsJson) {
146
- if (typeof argumentsJson !== "string")
147
- return undefined;
148
- const fold = (value) => value.replace(/\s+/g, " ").trim();
149
- try {
150
- const parsed = JSON.parse(argumentsJson);
151
- const description = parsed?.description;
152
- if (typeof description === "string" && description !== "") {
153
- const folded = fold(description);
154
- return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
155
- }
156
- for (const key of ["file_path", "path"]) {
157
- const value = parsed?.[key];
158
- if (typeof value === "string" && value !== "") {
159
- const folded = fold(value);
160
- return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
161
- }
162
- }
163
- }
164
- catch {
165
- // Not JSON — no summary.
166
- }
167
- return undefined;
168
- }
169
- /** Pull the first question's text out of an `ask_user_question` arguments JSON (whitespace folded to one line). */
170
- export function questionTextOf(argumentsJson) {
171
- if (typeof argumentsJson !== "string")
172
- return "";
173
- try {
174
- const parsed = JSON.parse(argumentsJson);
175
- const first = Array.isArray(parsed?.questions) ? parsed.questions[0] : undefined;
176
- const text = typeof first?.question === "string" ? first.question : "";
177
- const folded = text.replace(/\s+/g, " ").trim();
178
- return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
179
- }
180
- catch {
181
- // Not JSON — no question text.
182
- }
183
- return "";
184
- }
185
- function mapReason(kind) {
186
- switch (kind) {
187
- case "completed":
188
- case "aborted":
189
- case "blocked":
190
- case "error":
191
- case "max-tokens":
192
- case "interrupted":
193
- return kind;
194
- default:
195
- return "unknown";
196
- }
197
- }
198
- export function summarizeTurn(events, firstSeq) {
199
- let started = false;
200
- let text = "";
201
- let reason = "unknown";
202
- let code;
203
- let message;
204
- let model;
205
- let inputTokens = 0;
206
- let outputTokens = 0;
207
- let cacheReadTokens = 0;
208
- let contextSize;
209
- let contextWindow;
210
- let steps = 0;
211
- let turnStartTime;
212
- let turnEndTime;
213
- for (const event of events) {
214
- if (event.seq < firstSeq)
215
- continue;
216
- if (event.type === "turn/start") {
217
- started = true;
218
- turnStartTime = typeof event.time === "number" ? event.time : undefined;
219
- continue;
220
- }
221
- if (!started)
222
- continue;
223
- if (event.type === "step/start")
224
- steps += 1;
225
- if (event.type === "request/context") {
226
- const provider = typeof event.data.provider === "string" ? event.data.provider : undefined;
227
- const m = typeof event.data.model === "string" ? event.data.model : undefined;
228
- if (provider !== undefined && m !== undefined)
229
- model = `${provider}/${m}`;
230
- if (typeof event.data.contextWindow === "number")
231
- contextWindow = event.data.contextWindow;
232
- }
233
- if (event.type === "assistant/message") {
234
- const usage = event.data.usage;
235
- if (usage !== undefined) {
236
- inputTokens += typeof usage.inputTokens === "number" ? usage.inputTokens : 0;
237
- outputTokens += typeof usage.outputTokens === "number" ? usage.outputTokens : 0;
238
- cacheReadTokens += typeof usage.cacheReadTokens === "number" ? usage.cacheReadTokens : 0;
239
- if (typeof usage.inputTokens === "number")
240
- contextSize = usage.inputTokens;
241
- }
242
- const joined = event.data.message.content
243
- .filter((block) => block.type === "text")
244
- .map((block) => block.text)
245
- .join("");
246
- if (joined !== "")
247
- text = joined;
248
- }
249
- if (event.type === "turn/end") {
250
- turnEndTime = typeof event.time === "number" ? event.time : undefined;
251
- reason = mapReason(event.data.reason.kind);
252
- if (event.data.reason.kind === "error") {
253
- code = event.data.reason.error.code;
254
- message = event.data.reason.error.message;
255
- }
256
- }
257
- }
258
- const elapsedMs = turnStartTime !== undefined && turnEndTime !== undefined && turnEndTime >= turnStartTime
259
- ? turnEndTime - turnStartTime
260
- : undefined;
261
- return {
262
- reason,
263
- text,
264
- ...(code === undefined ? {} : { code }),
265
- ...(message === undefined ? {} : { message }),
266
- ...(model === undefined ? {} : { model }),
267
- ...(inputTokens > 0 ? { inputTokens } : {}),
268
- ...(outputTokens > 0 ? { outputTokens } : {}),
269
- ...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
270
- ...(contextSize === undefined ? {} : { contextSize }),
271
- ...(contextWindow === undefined ? {} : { contextWindow }),
272
- ...(steps > 0 ? { steps } : {}),
273
- ...(elapsedMs === undefined ? {} : { elapsedMs }),
274
- };
275
- }
276
- function truncate(text, max = 500) {
277
- return text.length <= max ? text : `${text.slice(0, max)}…`;
278
- }
279
- /** Classify an error message into an advice bucket (keyword-based, best-effort). */
280
- export function classifyError(detail) {
281
- const d = detail.toLowerCase();
282
- if (/(permission|forbidden|denied|not have access|no access|unauthorized|403|sandbox|acl |isn't permitted|not permitted|access denied)/.test(d))
283
- return "permission";
284
- if (/(timeout|econnrefused|econnreset|enotfound|enetunreach|etimedout|socket|network|proxy|tls|certificate|fetch failed|connect |unreachable|dns)/.test(d))
285
- return "network";
286
- if (/(model|quota|rate ?limit|429|insufficient|balance|token(s)? (limit|length)|max_tokens|context length|credit)/.test(d))
287
- return "model";
288
- return "generic";
289
- }
290
- /** Thousands-separated token count for display. */
291
- function fmtTokens(n) {
292
- return n.toLocaleString("en-US");
293
- }
294
- function textOf(message) {
295
- if (message === undefined)
296
- return "";
297
- return message.content
298
- .filter((b) => b.type === "text" && typeof b.text === "string")
299
- .map((b) => b.text ?? "")
300
- .join("");
301
- }
302
36
  /** Lightweight fold of `schedule/change` events (create / delete / dispatch). */
303
37
  function foldReminders(events) {
304
38
  const active = new Map();
@@ -364,6 +98,8 @@ export class AgentRunner {
364
98
  adapter;
365
99
  bindings;
366
100
  adapters;
101
+ requeue;
102
+ reminders;
367
103
  queue = [];
368
104
  running = false;
369
105
  agent;
@@ -374,7 +110,16 @@ export class AgentRunner {
374
110
  notifyLevel;
375
111
  progressTimeoutMs;
376
112
  t;
377
- constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters) {
113
+ menu;
114
+ constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters,
115
+ /**
116
+ * Route a replayed queued message back into the runner of its own channel
117
+ * (wired by ConnectService). Without it, replayed messages would run in
118
+ * whichever runner happened to release the lock.
119
+ */
120
+ requeue,
121
+ /** Persistent chat-level reminders (`/remind`), wired by ConnectService. */
122
+ reminders) {
378
123
  this.ctx = ctx;
379
124
  this.config = config;
380
125
  this.channel = channel;
@@ -383,6 +128,8 @@ export class AgentRunner {
383
128
  this.adapter = adapter;
384
129
  this.bindings = bindings;
385
130
  this.adapters = adapters;
131
+ this.requeue = requeue;
132
+ this.reminders = reminders;
386
133
  this.workDir = config.workDir ?? this.resolveDefaultWorkDir();
387
134
  // A per-chat language override (set via the settings menu) wins over config.
388
135
  const stored = this.bindings.get(channel, chatKey);
@@ -390,54 +137,16 @@ export class AgentRunner {
390
137
  this.notifyLevel = stored?.notifyLevel ?? config.notifyLevel;
391
138
  this.progressTimeoutMs = stored?.progressTimeoutMs ?? config.progressTimeoutMs;
392
139
  this.t = messages(this.language);
140
+ this.menu = new MenuController(this);
393
141
  }
394
142
  menuTitle(menuId) {
395
- switch (menuId) {
396
- case "root":
397
- return this.t.menuRoot;
398
- case "workspace":
399
- return this.t.menuWorkspace;
400
- case "chat":
401
- return this.t.menuChat;
402
- case "settings":
403
- return this.t.menuSettings;
404
- case "model":
405
- return this.t.menuModel;
406
- case "reasoning":
407
- return this.t.menuReasoning;
408
- case "notify":
409
- return this.t.menuNotify;
410
- case "language":
411
- return this.t.menuSettingsLanguage;
412
- case "progress":
413
- return this.t.progressMenuTitle;
414
- }
143
+ return menuTitle(menuId, this.t);
415
144
  }
416
145
  rootMenuSections() {
417
- return [
418
- { title: this.t.rootSectionWorkspace, ids: ["workspace"], columnsPerRow: 1 },
419
- { title: this.t.rootSectionChat, ids: ["chat", "history"], columnsPerRow: 1 },
420
- { title: this.t.rootSectionTask, ids: ["task", "goals", "schedule"], columnsPerRow: 2 },
421
- { title: this.t.rootSectionSystem, ids: ["status", "plugins", "compact", "settings"], columnsPerRow: 2 },
422
- ];
146
+ return rootMenuSections(this.t);
423
147
  }
424
148
  reasonLabel(reason) {
425
- switch (reason) {
426
- case "completed":
427
- return this.t.reasonCompleted;
428
- case "aborted":
429
- return this.t.reasonAborted;
430
- case "blocked":
431
- return this.t.reasonBlocked;
432
- case "error":
433
- return this.t.reasonError;
434
- case "max-tokens":
435
- return this.t.reasonMaxTokens;
436
- case "interrupted":
437
- return this.t.reasonInterrupted;
438
- default:
439
- return this.t.reasonUnknown;
440
- }
149
+ return reasonLabel(reason, this.t);
441
150
  }
442
151
  /** Prefer the user's first DSH workspace over the process cwd as default. */
443
152
  resolveDefaultWorkDir() {
@@ -514,16 +223,18 @@ export class AgentRunner {
514
223
  else {
515
224
  turn.milestone = this.t.toolCalling(name, undefined);
516
225
  }
517
- // `result` mode shows nothing until the final answer; `important` shows
518
- // the tool name with a step counter, `full` also carries the summary.
519
- if (this.notifyLevel === "result")
226
+ // `result` mode shows nothing until the final answer.
227
+ if (this.notifyLevel === 'result')
228
+ return;
229
+ // Keep the streaming card short: push a status line only once per distinct
230
+ // tool (not once per call, which would log "tool #51" spam), and label it
231
+ // with the tool name rather than a running counter.
232
+ if (turn.lastToolName === name)
520
233
  return;
521
- const base = name === "ask_user_question"
234
+ turn.lastToolName = name;
235
+ const label = name === 'ask_user_question'
522
236
  ? this.t.questionToolCall(questionTextOf(event.data.arguments))
523
- : this.t.toolStepLabel(turn.toolCount, name);
524
- const label = name === "ask_user_question" || this.notifyLevel !== "full" || summary === undefined
525
- ? base
526
- : `${base} — ${summary}`;
237
+ : this.t.toolCalling(name, this.notifyLevel === 'full' ? summary : undefined);
527
238
  applyToolCall(turn, label);
528
239
  }
529
240
  }
@@ -685,7 +396,7 @@ export class AgentRunner {
685
396
  await this.sendTurnStats(msg, outcome);
686
397
  // Release lock after task completion
687
398
  if (usesLock)
688
- this.releaseLock();
399
+ await this.releaseLock();
689
400
  if (outcome.reason !== "completed") {
690
401
  await this.sendSummary(msg, outcome);
691
402
  }
@@ -693,7 +404,7 @@ export class AgentRunner {
693
404
  catch (error) {
694
405
  // Release lock even on error
695
406
  if (usesLock)
696
- this.releaseLock();
407
+ await this.releaseLock();
697
408
  const detail = error instanceof Error ? error.message : String(error);
698
409
  await this.adapter
699
410
  .sendText(this.target(msg), this.t.processingFailedAdvice(truncate(detail, 400), this.errorAdvice(detail)))
@@ -901,7 +612,7 @@ export class AgentRunner {
901
612
  this.turn = {
902
613
  firstSeq, chunks, lastText: "",
903
614
  reasoning: false, hintPushed: false, lastIndex: undefined, pushedAny: false,
904
- startedAt: now, lastPushAt: now, toolCount: 0, contextNudged: false, compactAfterTurn: false,
615
+ startedAt: now, lastPushAt: now, toolCount: 0, lastToolName: undefined, contextNudged: false, compactAfterTurn: false,
905
616
  };
906
617
  // Liveness heartbeat: while the agent is working, keep the streaming card
907
618
  // visibly alive even through long reasoning or tool-execution stretches
@@ -922,26 +633,29 @@ export class AgentRunner {
922
633
  }, heartbeatMs)
923
634
  : undefined;
924
635
  // Proactive progress watchdog: when no standalone card/text has been sent
925
- // for `progressTimeoutMs` (default 5 min, user-configurable), send a status
926
- // card reporting the latest milestone so a long turn never looks frozen.
927
- // Streaming-card heartbeats deliberately do NOT reset this — the user asked
928
- // for an explicit, separate nudge.
636
+ // for `progressTimeoutMs` (default 5 min, user-configurable), re-sync the
637
+ // latest milestone INTO the streaming card (via its chunk stream) so a long
638
+ // turn never looks frozen — WITHOUT posting a separate message. The card is
639
+ // edited in place, keeping the chat history clean. Streaming-card heartbeats
640
+ // deliberately do NOT reset this: the progress watchdog only fires when no
641
+ // explicit milestone sync has been pushed for the configured interval.
929
642
  const progressTimeoutMs = this.progressTimeoutMs;
930
- let lastStandaloneNoticeAt = now;
643
+ let lastProgressNoticeAt = now;
931
644
  const progressWatchdog = progressTimeoutMs > 0
932
645
  ? setInterval(() => {
933
646
  const turn = this.turn;
934
647
  if (turn === undefined)
935
648
  return;
936
- const elapsed = Date.now() - lastStandaloneNoticeAt;
649
+ const elapsed = Date.now() - lastProgressNoticeAt;
937
650
  if (elapsed < progressTimeoutMs)
938
651
  return;
939
- lastStandaloneNoticeAt = Date.now();
652
+ lastProgressNoticeAt = Date.now();
940
653
  const minutes = Math.max(1, Math.round((Date.now() - turn.startedAt) / 60_000));
941
654
  const status = turn.milestone ?? this.t.progressThinking;
942
- void this.adapter
943
- .sendText(this.target(msg), this.t.progressReminder(minutes, status))
944
- .catch(() => undefined);
655
+ // Edit the existing streaming card in place instead of sending a new
656
+ // message: the milestone is appended to the same card via the chunk
657
+ // stream, so progress updates never clutter the chat with new bubbles.
658
+ turn.chunks.push(`\n\n${this.t.progressReminder(minutes, status)}\n\n`);
945
659
  }, PROGRESS_WATCHDOG_CHECK_MS)
946
660
  : undefined;
947
661
  // End the chunk stream unconditionally: if `followup` / `whenIdle` /
@@ -1306,7 +1020,7 @@ export class AgentRunner {
1306
1020
  }
1307
1021
  case "dir": {
1308
1022
  if (command.path === undefined) {
1309
- await this.openMenu(target, msg, "workspace", ["root"]);
1023
+ await this.menu.openMenu(target, msg, "workspace", ["root"]);
1310
1024
  break;
1311
1025
  }
1312
1026
  const path = command.path;
@@ -1330,15 +1044,15 @@ export class AgentRunner {
1330
1044
  break;
1331
1045
  }
1332
1046
  case "chat": {
1333
- await this.openMenu(target, msg, "chat", ["root"]);
1047
+ await this.menu.openMenu(target, msg, "chat", ["root"]);
1334
1048
  break;
1335
1049
  }
1336
1050
  case "menu": {
1337
- await this.openMenu(target, msg, "root");
1051
+ await this.menu.openMenu(target, msg, "root");
1338
1052
  break;
1339
1053
  }
1340
1054
  case "settings": {
1341
- await this.openMenu(target, msg, "settings", ["root"]);
1055
+ await this.menu.openMenu(target, msg, "settings", ["root"]);
1342
1056
  break;
1343
1057
  }
1344
1058
  case "plugins": {
@@ -1370,7 +1084,7 @@ export class AgentRunner {
1370
1084
  break;
1371
1085
  }
1372
1086
  case "model": {
1373
- await this.openMenu(target, msg, "model", ["root"]);
1087
+ await this.menu.openMenu(target, msg, "model", ["root"]);
1374
1088
  break;
1375
1089
  }
1376
1090
  case "notify": {
@@ -1405,6 +1119,18 @@ export class AgentRunner {
1405
1119
  await this.handleAppend(command.text, target, msg);
1406
1120
  break;
1407
1121
  }
1122
+ case "remind": {
1123
+ await this.handleRemind(command.text, target, msg);
1124
+ break;
1125
+ }
1126
+ case "send": {
1127
+ await this.handleSend(command.path, target);
1128
+ break;
1129
+ }
1130
+ case "broadcast": {
1131
+ await this.handleBroadcast(command.text, target, msg);
1132
+ break;
1133
+ }
1408
1134
  case "help": {
1409
1135
  await this.adapter.sendText(target, helpText(this.t));
1410
1136
  break;
@@ -1446,6 +1172,103 @@ export class AgentRunner {
1446
1172
  this.touchBinding(msg);
1447
1173
  await this.adapter.sendText(target, this.t.psReceived(text));
1448
1174
  }
1175
+ /**
1176
+ * `/remind <time> <text>` — persist a chat-level reminder that fires
1177
+ * without waking the agent (see src/scheduler.ts).
1178
+ */
1179
+ async handleRemind(argText, target, msg) {
1180
+ if (this.reminders === undefined) {
1181
+ await this.adapter.sendText(target, this.t.reminderNoText);
1182
+ return;
1183
+ }
1184
+ const arg = argText.trim();
1185
+ if (arg === "") {
1186
+ await this.adapter.sendText(target, this.t.reminderNoText);
1187
+ return;
1188
+ }
1189
+ const space = arg.indexOf(" ");
1190
+ const time = space === -1 ? arg : arg.slice(0, space);
1191
+ const body = space === -1 ? "" : arg.slice(space + 1).trim();
1192
+ const dueAt = parseRemindTime(time);
1193
+ if (dueAt === undefined) {
1194
+ await this.adapter.sendText(target, this.t.reminderParseFailed(time));
1195
+ return;
1196
+ }
1197
+ if (body === "") {
1198
+ await this.adapter.sendText(target, this.t.reminderNoText);
1199
+ return;
1200
+ }
1201
+ this.reminders.add({
1202
+ channel: this.channel,
1203
+ chatKey: this.chatKey,
1204
+ chatType: this.chatType,
1205
+ text: body,
1206
+ dueAt,
1207
+ ownerKey: msg.senderKey,
1208
+ });
1209
+ await this.adapter.sendText(target, this.t.reminderSet(formatRemindAt(dueAt, this.language), body));
1210
+ }
1211
+ /**
1212
+ * `/send <path>` — deliver a file from the workspace through the channel's
1213
+ * `sendFile` capability; falls back to sending the path as text.
1214
+ */
1215
+ async handleSend(pathArg, target) {
1216
+ if (pathArg === "") {
1217
+ await this.adapter.sendText(target, this.t.sendUsage);
1218
+ return;
1219
+ }
1220
+ const path = isAbsolute(pathArg) ? pathArg : join(this.workDir, pathArg);
1221
+ let size;
1222
+ try {
1223
+ if (!existsSync(path)) {
1224
+ await this.adapter.sendText(target, this.t.sendNotFound(path));
1225
+ return;
1226
+ }
1227
+ const stat = statSync(path);
1228
+ if (stat.isDirectory()) {
1229
+ await this.adapter.sendText(target, this.t.sendIsDir(path));
1230
+ return;
1231
+ }
1232
+ size = stat.size;
1233
+ }
1234
+ catch {
1235
+ await this.adapter.sendText(target, this.t.sendNotFound(path));
1236
+ return;
1237
+ }
1238
+ if (size > 20 * 1024 * 1024) {
1239
+ await this.adapter.sendText(target, this.t.sendTooLarge(path));
1240
+ return;
1241
+ }
1242
+ if (this.adapter.sendFile === undefined) {
1243
+ await this.adapter.sendText(target, `${this.t.sendUnsupported}\n${path}`);
1244
+ return;
1245
+ }
1246
+ try {
1247
+ await this.adapter.sendFile(target, path);
1248
+ await this.adapter.sendText(target, this.t.sendSent(path));
1249
+ }
1250
+ catch (error) {
1251
+ const detail = error instanceof Error ? error.message : String(error);
1252
+ await this.adapter.sendText(target, this.t.sendFailed(truncate(detail)));
1253
+ }
1254
+ }
1255
+ /**
1256
+ * `/broadcast <text>` — admins push a message to every bound chat.
1257
+ * Admin gating lives in the connect service (allowUsers).
1258
+ */
1259
+ async handleBroadcast(text, target, msg) {
1260
+ const service = this.ctx.get("connect");
1261
+ if (service?.broadcast === undefined) {
1262
+ await this.adapter.sendText(target, this.t.broadcastUnsupported);
1263
+ return;
1264
+ }
1265
+ const result = await service.broadcast(msg.senderKey, text);
1266
+ if (!result.ok) {
1267
+ await this.adapter.sendText(target, result.reason === "disabled" ? this.t.broadcastDisabled : this.t.broadcastNotAdmin);
1268
+ return;
1269
+ }
1270
+ await this.adapter.sendText(target, this.t.broadcastSent(result.count ?? 0));
1271
+ }
1449
1272
  recordSession(sessionId, title, ownerKey) {
1450
1273
  const binding = this.bindings.get(this.channel, this.chatKey);
1451
1274
  const others = (binding?.sessions ?? []).filter((s) => s.sessionId !== sessionId);
@@ -1553,198 +1376,12 @@ export class AgentRunner {
1553
1376
  await this.disposeAgent();
1554
1377
  }
1555
1378
  listWorkspaces() {
1556
- const registry = this.ctx.get("workspaceRegistry");
1557
- const out = [];
1558
- const seen = new Set();
1559
- const add = (path, title) => {
1560
- if (path === "")
1561
- return;
1562
- const key = path.replace(/[\\/]+$/, "").toLowerCase();
1563
- if (seen.has(key))
1564
- return;
1565
- seen.add(key);
1566
- out.push({ path, title: title || path });
1567
- };
1568
- add(this.workDir, this.t.currentDir);
1569
- for (const w of registry?.list?.() ?? [])
1570
- add(w.path, w.title);
1571
- for (const p of this.config.workspaces)
1572
- add(p, p);
1573
- return out;
1574
- }
1575
- async openMenu(target, msg, menuId, stack = [], cardId) {
1576
- const items = await this.menuItems(menuId);
1577
- const options = items.map((i) => ({ id: i.id, label: i.label }));
1578
- options.push({ id: "menu:exit", label: this.t.menuExit });
1579
- if (stack.length > 0)
1580
- options.push({ id: "menu:back", label: this.t.menuBack });
1581
- // Determine columns per row based on menu type
1582
- // Workspace and chat lists use 1 column (full width), others use 2 columns
1583
- const columnsPerRow = (menuId === "workspace" || menuId === "chat") ? 1 : 2;
1584
- const { choice, messageId } = await this.adapter.promptChoice(target, {
1585
- title: this.menuTitle(menuId),
1586
- options,
1587
- columnsPerRow,
1588
- ...(menuId === "root" ? { sections: this.rootMenuSections(), footer: this.t.rootMenuFooter } : {}),
1589
- }, cardId);
1590
- if (choice === undefined)
1591
- return;
1592
- if (choice === "menu:exit") {
1593
- await this.adapter.closeMenu(messageId, this.t.menuClosed);
1594
- return;
1595
- }
1596
- if (choice === "menu:back") {
1597
- const parent = stack[stack.length - 1];
1598
- await this.openMenu(target, msg, parent, stack.slice(0, -1), messageId);
1599
- return;
1600
- }
1601
- const item = items.find((i) => i.id === choice);
1602
- if (item === undefined) {
1603
- // The tap belonged to a previous card generation (e.g. a rapid second
1604
- // tap on the old menu while the new one was still being redrawn). Don't
1605
- // silently swallow it: redraw the current menu so the chain stays usable.
1606
- await this.openMenu(target, msg, menuId, stack, messageId);
1607
- return;
1608
- }
1609
- const feedback = await item.onSelect(target, msg, messageId);
1610
- if (item.leaf === true) {
1611
- // Every leaf press must answer visibly: send the action's result feedback
1612
- // (if any) before returning to the root menu on the same card.
1613
- if (feedback !== undefined && feedback !== "") {
1614
- await this.adapter.sendText(target, feedback).catch(() => undefined);
1615
- }
1616
- await this.openMenu(target, msg, "root", [], messageId);
1617
- }
1618
- }
1619
- async menuItems(menuId) {
1620
- switch (menuId) {
1621
- case "root":
1622
- return [
1623
- { id: "workspace", label: this.t.menuWorkspaceAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "workspace", ["root"], cardId) },
1624
- { id: "chat", label: this.t.menuChatAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "chat", ["root"], cardId) },
1625
- { id: "status", label: this.t.menuStatusAction, leaf: true, onSelect: async (t) => { await this.showStatus(t); } },
1626
- { id: "task", label: this.t.menuTaskAction, leaf: true, onSelect: async (t) => { await this.showTasks(t); } },
1627
- { id: "history", label: this.t.menuHistoryAction, leaf: true, onSelect: async (t) => { await this.showHistory(t, 10); } },
1628
- { id: "goals", label: this.t.menuGoalsAction, leaf: true, onSelect: async (t) => { await this.showGoals(t); } },
1629
- { id: "schedule", label: this.t.menuScheduleAction, leaf: true, onSelect: async (t) => { await this.showSchedule(t); } },
1630
- { id: "compact", label: this.t.menuCompactAction, leaf: true, onSelect: async (t) => { await this.compact(t); } },
1631
- { id: "plugins", label: this.t.menuPluginsAction, leaf: true, onSelect: async (t) => { await this.showPlugins(t); } },
1632
- { id: "settings", label: this.t.menuSettingsAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "settings", ["root"], cardId) },
1633
- ];
1634
- case "workspace": {
1635
- const workspaces = this.listWorkspaces();
1636
- return workspaces.map((w) => ({
1637
- id: `dir:${w.path}`,
1638
- label: `${w.path === this.workDir ? "● " : ""}${w.title}${w.title !== w.path ? ` (${w.path})` : ""}`,
1639
- leaf: true,
1640
- onSelect: async (t, m) => {
1641
- this.workDir = w.path;
1642
- await this.newChat(m);
1643
- return this.t.dirSwitched(w.path);
1644
- },
1645
- }));
1646
- }
1647
- case "chat": {
1648
- const binding = this.bindings.get(this.channel, this.chatKey);
1649
- const active = binding?.sessionId;
1650
- const hasWebMirror = binding?.webMirrorSessionId !== undefined;
1651
- const items = (await this.collectWorkdirSessions()).map(({ sessionId, title }) => {
1652
- const isMirrored = hasWebMirror && binding?.webMirrorSessionId === sessionId;
1653
- const mirrorIndicator = isMirrored ? ` ${this.t.webMirrorIndicator}` : "";
1654
- const activeIndicator = sessionId === active ? "●" : "○";
1655
- return {
1656
- id: `session:${sessionId}`,
1657
- label: `${activeIndicator} ${title || sessionId}${mirrorIndicator}`,
1658
- leaf: true,
1659
- onSelect: async (t, m) => {
1660
- await this.switchTo(sessionId, m.senderKey);
1661
- return this.t.sessionSwitched(title || sessionId);
1662
- },
1663
- };
1664
- });
1665
- if (items.length === 0) {
1666
- items.push({
1667
- id: "none:history",
1668
- label: this.t.noSessionsInWorkdir(this.workDir),
1669
- leaf: true,
1670
- onSelect: async () => this.t.noSessionsInWorkdir(this.workDir),
1671
- });
1672
- }
1673
- items.push({
1674
- id: "action:new",
1675
- label: this.t.menuNewChat,
1676
- leaf: true,
1677
- onSelect: async (t, m, messageId) => {
1678
- // Reuse the menu card for the confirm prompt so no stale card is left behind.
1679
- if (await this.confirmAction(t, this.t.confirmNewText, messageId)) {
1680
- await this.newChat(m);
1681
- return this.t.newChatDone;
1682
- }
1683
- return this.t.actionCancelled;
1684
- },
1685
- });
1686
- return items;
1687
- }
1688
- case "settings":
1689
- return [
1690
- { id: "model", label: this.t.menuSettingsModel, onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
1691
- { id: "reasoning", label: this.t.menuSettingsReasoning, onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
1692
- { id: "notify", label: this.t.menuSettingsNotify, onSelect: (t, m, cardId) => this.openMenu(t, m, "notify", ["settings", "root"], cardId) },
1693
- { id: "progress", label: this.t.menuSettingsProgress, onSelect: (t, m, cardId) => this.openMenu(t, m, "progress", ["settings", "root"], cardId) },
1694
- { id: "language", label: this.t.menuSettingsLanguage, onSelect: (t, m, cardId) => this.openMenu(t, m, "language", ["settings", "root"], cardId) },
1695
- { id: "overview", label: this.t.menuSettingsOverview, leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
1696
- ];
1697
- case "language":
1698
- return [
1699
- {
1700
- id: "lang:zh",
1701
- label: `${this.language === "zh" ? "● " : ""}${this.t.languageZh}`,
1702
- leaf: true,
1703
- onSelect: async (t, m) => { await this.setLanguage("zh", t, m); },
1704
- },
1705
- {
1706
- id: "lang:en",
1707
- label: `${this.language === "en" ? "● " : ""}${this.t.languageEn}`,
1708
- leaf: true,
1709
- onSelect: async (t, m) => { await this.setLanguage("en", t, m); },
1710
- },
1711
- ];
1712
- case "model":
1713
- return await this.modelMenuItems();
1714
- case "reasoning":
1715
- return this.reasoningMenuItems();
1716
- case "notify":
1717
- return [
1718
- { id: "full", label: this.t.notifyFull },
1719
- { id: "important", label: this.t.notifyImportant },
1720
- { id: "result", label: this.t.notifyResult },
1721
- ].map((o) => ({
1722
- id: `notify:${o.id}`,
1723
- label: `${this.notifyLevel === o.id ? "● " : ""}${o.label}`,
1724
- leaf: true,
1725
- onSelect: async (t, m) => {
1726
- await this.setNotifyLevel(o.id, t, m);
1727
- },
1728
- }));
1729
- case "progress": {
1730
- const presets = [
1731
- { id: "progress:0", label: this.t.progressOff, ms: 0 },
1732
- { id: "progress:120000", label: this.t.progressMinutes(2), ms: 2 * 60_000 },
1733
- { id: "progress:300000", label: this.t.progressMinutes(5), ms: 5 * 60_000 },
1734
- { id: "progress:600000", label: this.t.progressMinutes(10), ms: 10 * 60_000 },
1735
- { id: "progress:900000", label: this.t.progressMinutes(15), ms: 15 * 60_000 },
1736
- { id: "progress:1800000", label: this.t.progressMinutes(30), ms: 30 * 60_000 },
1737
- ];
1738
- return presets.map((o) => ({
1739
- id: o.id,
1740
- label: `${this.progressTimeoutMs === o.ms ? "● " : ""}${o.label}`,
1741
- leaf: true,
1742
- onSelect: async (t, m) => {
1743
- await this.setProgressTimeout(o.ms, t, m);
1744
- },
1745
- }));
1746
- }
1747
- }
1379
+ return listWorkspaces({
1380
+ registry: this.ctx.get("workspaceRegistry"),
1381
+ workDir: this.workDir,
1382
+ currentDirLabel: this.t.currentDir,
1383
+ workspaces: this.config.workspaces,
1384
+ });
1748
1385
  }
1749
1386
  async showStatus(target) {
1750
1387
  const agent = this.agent;
@@ -1845,41 +1482,6 @@ export class AgentRunner {
1845
1482
  lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
1846
1483
  await this.adapter.sendText(target, lines.join("\n"));
1847
1484
  }
1848
- async modelMenuItems() {
1849
- const choices = await this.listModelChoices();
1850
- const current = this.defaultSelection();
1851
- const items = choices.map((c) => ({
1852
- id: `model:${c.provider}:${c.model}`,
1853
- label: `${c.provider === current.provider && c.model === current.model ? "● " : ""}${c.name}`,
1854
- leaf: true,
1855
- onSelect: async (t, m) => {
1856
- await this.setModel(c.provider, c.model, m);
1857
- return this.t.modelSet(c.name);
1858
- },
1859
- }));
1860
- if (items.length === 0) {
1861
- items.push({ id: "model:none", label: this.t.noModelsFound, leaf: true, onSelect: async () => this.t.noModelsFound });
1862
- }
1863
- return items;
1864
- }
1865
- async listModelChoices() {
1866
- const llm = this.ctx.get("llm");
1867
- const providers = llm?.listProviders?.() ?? [];
1868
- const out = [];
1869
- for (const p of providers) {
1870
- let models = [];
1871
- try {
1872
- models = (await llm?.listModels?.(p.id)) ?? [];
1873
- }
1874
- catch {
1875
- // Skip providers whose catalog cannot be listed.
1876
- }
1877
- for (const m of models) {
1878
- out.push({ provider: p.id, model: m.id, name: this.t.modelName(m.name || m.id, p.name || p.id) });
1879
- }
1880
- }
1881
- return out;
1882
- }
1883
1485
  async setModel(provider, model, msg) {
1884
1486
  const svc = this.ctx.get("agentDefaultModel");
1885
1487
  const cur = this.defaultSelection();
@@ -1890,37 +1492,6 @@ export class AgentRunner {
1890
1492
  });
1891
1493
  await this.newChat(msg);
1892
1494
  }
1893
- reasoningMenuItems() {
1894
- const current = this.defaultSelection();
1895
- const efforts = [
1896
- { id: "low", name: this.t.effortLow },
1897
- { id: "medium", name: this.t.effortMedium },
1898
- { id: "high", name: this.t.effortHigh },
1899
- ];
1900
- const items = [
1901
- {
1902
- id: "effort:default",
1903
- label: `${current.reasoningEffort === undefined ? "● " : ""}${this.t.effortDefault}`,
1904
- leaf: true,
1905
- onSelect: async (t, m) => {
1906
- await this.setReasoning(undefined, m);
1907
- return this.t.reasoningSet(this.t.effortDefault);
1908
- },
1909
- },
1910
- ];
1911
- for (const e of efforts) {
1912
- items.push({
1913
- id: `effort:${e.id}`,
1914
- label: `${e.id === current.reasoningEffort ? "● " : ""}${e.name}`,
1915
- leaf: true,
1916
- onSelect: async (t, m) => {
1917
- await this.setReasoning(e.id, m);
1918
- return this.t.reasoningSet(e.name);
1919
- },
1920
- });
1921
- }
1922
- return items;
1923
- }
1924
1495
  async setReasoning(effort, msg) {
1925
1496
  const svc = this.ctx.get("agentDefaultModel");
1926
1497
  const cur = this.defaultSelection();
@@ -2181,40 +1752,41 @@ export class AgentRunner {
2181
1752
  await this.adapter.sendText(target, lines.join("\n"));
2182
1753
  }
2183
1754
  goalPhaseLabel(phase) {
2184
- switch (phase) {
2185
- case "active":
2186
- return this.t.goalPhaseActive;
2187
- case "paused":
2188
- return this.t.goalPhasePaused;
2189
- case "blocked":
2190
- return this.t.goalPhaseBlocked;
2191
- case "complete":
2192
- return this.t.goalPhaseComplete;
2193
- default:
2194
- return phase;
2195
- }
1755
+ return goalPhaseLabel(phase, this.t);
2196
1756
  }
2197
1757
  async showSchedule(target) {
2198
- const agent = this.agent;
2199
- if (agent === undefined) {
2200
- await this.adapter.sendText(target, this.t.noActiveSession);
2201
- return;
2202
- }
2203
- const reminders = foldReminders(agent.session.events);
2204
- if (reminders.length === 0) {
2205
- await this.adapter.sendText(target, this.t.noReminders);
2206
- return;
2207
- }
1758
+ // Agent-level reminders (session `schedule` tool) + persistent chat-level
1759
+ // reminders (`/remind`), merged into one list.
1760
+ const agentReminders = this.agent === undefined ? [] : foldReminders(this.agent.session.events);
1761
+ const persisted = this.reminders?.listFor(this.channel, this.chatKey) ?? [];
2208
1762
  const now = Date.now();
2209
1763
  const locale = this.language === "en" ? "en-US" : "zh-CN";
2210
- const lines = reminders.map((r) => {
1764
+ const agentLines = agentReminders.map((r) => {
2211
1765
  const due = Date.parse(r.scheduledAt) <= now;
2212
1766
  const state = due ? this.t.reminderDue : this.t.reminderClock;
2213
1767
  const kind = r.kind === "every" ? this.t.reminderEvery(Math.round((r.everySeconds ?? 0) / 60)) : this.t.reminderOnce;
2214
1768
  const when = new Date(Date.parse(r.scheduledAt)).toLocaleString(locale);
2215
1769
  return this.t.reminderLine(state, r.id, r.prompt, kind, when);
2216
1770
  });
2217
- await this.adapter.sendText(target, this.t.remindersCount(reminders.length, lines.join("\n")));
1771
+ const persistedLines = persisted.map((r) => {
1772
+ const due = r.dueAt <= now;
1773
+ const state = due ? this.t.reminderDue : this.t.reminderClock;
1774
+ const when = new Date(r.dueAt).toLocaleString(locale);
1775
+ return this.t.reminderLine(state, r.id, r.text, this.t.reminderOnce, when);
1776
+ });
1777
+ const total = agentLines.length + persistedLines.length;
1778
+ if (total === 0) {
1779
+ await this.adapter.sendText(target, this.t.noReminders);
1780
+ return;
1781
+ }
1782
+ const parts = [];
1783
+ if (agentLines.length > 0)
1784
+ parts.push(...agentLines);
1785
+ if (persistedLines.length > 0) {
1786
+ parts.push(this.t.reminderPersistedHeader);
1787
+ parts.push(...persistedLines);
1788
+ }
1789
+ await this.adapter.sendText(target, this.t.remindersCount(total, parts.join("\n")));
2218
1790
  }
2219
1791
  async showAllWorkspaces(target) {
2220
1792
  const registry = this.ctx.get("workspaceRegistry");
@@ -2299,7 +1871,7 @@ export class AgentRunner {
2299
1871
  }
2300
1872
  // Process any queued messages before releasing
2301
1873
  const queuedCount = binding.queuedMessages?.length ?? 0;
2302
- this.releaseLock();
1874
+ await this.releaseLock();
2303
1875
  let message = this.t.unlockSuccess;
2304
1876
  if (queuedCount > 0) {
2305
1877
  message += `\n${this.t.queueProcessed(queuedCount)}`;
@@ -2332,7 +1904,8 @@ export class AgentRunner {
2332
1904
  await this.adapter.sendText(target, this.t.lockRenewed(timeoutMin));
2333
1905
  }
2334
1906
  /**
2335
- * Export conversation history to Markdown or PDF.
1907
+ * Export conversation history as Markdown (a future PDF pipeline can be
1908
+ * added here; `/export pdf` currently falls back to Markdown).
2336
1909
  */
2337
1910
  async handleExport(target, format) {
2338
1911
  const agent = this.agent;
@@ -2340,12 +1913,6 @@ export class AgentRunner {
2340
1913
  await this.adapter.sendText(target, this.t.exportNoSession);
2341
1914
  return;
2342
1915
  }
2343
- // Default to markdown if not specified
2344
- const exportFormat = format ?? "markdown";
2345
- if (exportFormat === "pdf") {
2346
- await this.adapter.sendText(target, this.t.exportPdfNotSupported);
2347
- return;
2348
- }
2349
1916
  try {
2350
1917
  const markdown = this.generateMarkdown(agent);
2351
1918
  const fileName = `conversation-${agent.id}-${Date.now()}.md`;
@@ -2414,54 +1981,35 @@ export class AgentRunner {
2414
1981
  */
2415
1982
  canWrite(channel) {
2416
1983
  const binding = this.bindings.get(this.channel, this.chatKey);
2417
- if (binding?.lockOwner === undefined)
2418
- return true; // No lock, free access
2419
- // Check timeout
2420
- if (this.isLockTimedOut(binding)) {
2421
- this.releaseTimeoutLock(binding);
1984
+ if (binding === undefined)
1985
+ return true; // No binding, free access
1986
+ if (lockCanWrite(binding, channel)) {
1987
+ // A timed-out lock counts as free; release it so the next acquire sees a clean state.
1988
+ if (lockIsTimedOut(binding))
1989
+ this.releaseTimeoutLock(binding);
2422
1990
  return true;
2423
1991
  }
2424
- return binding.lockOwner === channel;
1992
+ return false;
2425
1993
  }
2426
1994
  /**
2427
1995
  * Acquire session lock for the given channel.
2428
1996
  * Returns true if lock acquired, false if already locked by another channel.
2429
1997
  */
2430
1998
  acquireLock(channel) {
2431
- const binding = this.bindings.get(this.channel, this.chatKey);
1999
+ let binding = this.bindings.get(this.channel, this.chatKey);
2432
2000
  if (binding === undefined)
2433
2001
  return true;
2434
- // Check timeout before acquiring
2435
- if (this.isLockTimedOut(binding)) {
2002
+ // A timed-out lock is treated as free: release it (with the user notice)
2003
+ // before acquiring so the next state is clean.
2004
+ if (lockIsTimedOut(binding)) {
2436
2005
  this.releaseTimeoutLock(binding);
2437
- // After timeout release, try to acquire again
2438
- const refreshed = this.bindings.get(this.channel, this.chatKey);
2439
- if (refreshed !== undefined && (refreshed.lockOwner === undefined || refreshed.lockOwner === channel)) {
2440
- const defaultTimeoutMs = refreshed.lockTimeoutMs ?? 5 * 60 * 1000;
2441
- this.bindings.put({
2442
- ...refreshed,
2443
- lockOwner: channel,
2444
- lockAcquiredAt: Date.now(),
2445
- lockTimeoutMs: defaultTimeoutMs,
2446
- lastActiveAt: Date.now()
2447
- });
2448
- return true;
2449
- }
2450
- }
2451
- if (binding.lockOwner === undefined || binding.lockOwner === channel) {
2452
- // Lock is free or already owned by this channel
2453
- const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000;
2454
- this.bindings.put({
2455
- ...binding,
2456
- lockOwner: channel,
2457
- lockAcquiredAt: Date.now(),
2458
- lockTimeoutMs: timeoutMs,
2459
- lastActiveAt: Date.now()
2460
- });
2461
- return true;
2006
+ binding = this.bindings.get(this.channel, this.chatKey) ?? binding;
2462
2007
  }
2463
- // Locked by other channel - queue the message if from Web
2464
- return false;
2008
+ const next = lockAcquire(binding, channel);
2009
+ if (next === undefined)
2010
+ return false; // Locked by another channel
2011
+ this.bindings.put({ ...next, lastActiveAt: Date.now() });
2012
+ return true;
2465
2013
  }
2466
2014
  /**
2467
2015
  * Queue a message for later execution when lock is released.
@@ -2479,6 +2027,8 @@ export class AgentRunner {
2479
2027
  replyRef: msg.replyRef,
2480
2028
  images: msg.images,
2481
2029
  files: msg.files,
2030
+ // Keep the true source channel so replay routes to the right runner.
2031
+ channel: msg.channel,
2482
2032
  });
2483
2033
  this.bindings.put({ ...binding, queuedMessages: queued });
2484
2034
  return queued.length;
@@ -2496,69 +2046,64 @@ export class AgentRunner {
2496
2046
  const count = messages.length;
2497
2047
  // Clear queue immediately to prevent duplicate processing
2498
2048
  this.bindings.put({ ...binding, queuedMessages: [] });
2499
- // Notify about queued messages being processed
2500
- if (count > 0) {
2501
- const adapter = this.adapters?.get(this.channel);
2502
- if (adapter) {
2503
- await adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.queueProcessed(count)).catch(() => undefined);
2504
- }
2505
- // Re-enqueue messages into the runner's processing queue
2506
- // Construct minimal InboundMessage objects from queued data
2507
- for (const queued of messages) {
2508
- const inboundMsg = {
2509
- channel: "web", // Queued messages are from Web channel
2510
- chatKey: this.chatKey,
2511
- chatType: this.chatType,
2512
- senderKey: queued.senderKey,
2513
- text: queued.text,
2514
- replyRef: queued.replyRef,
2515
- images: queued.images,
2516
- files: queued.files,
2517
- };
2518
- // Add to the runner's internal queue for processing
2049
+ // Notify about queued messages being processed.
2050
+ await this.adapter
2051
+ .sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.queueProcessed(count))
2052
+ .catch(() => undefined);
2053
+ // Replay each queued message through the service routing so it lands in
2054
+ // the runner of its own channel (a web message goes to the web runner,
2055
+ // never the releasing feishu runner). Without a router we fall back to
2056
+ // the local queue so behavior stays functional in isolation.
2057
+ for (const queued of messages) {
2058
+ const inboundMsg = {
2059
+ channel: queued.channel ?? "web",
2060
+ chatKey: this.chatKey,
2061
+ chatType: this.chatType,
2062
+ senderKey: queued.senderKey,
2063
+ text: queued.text,
2064
+ replyRef: queued.replyRef,
2065
+ images: queued.images,
2066
+ files: queued.files,
2067
+ };
2068
+ if (this.requeue !== undefined) {
2069
+ this.requeue(inboundMsg);
2070
+ }
2071
+ else {
2519
2072
  this.queue.push(inboundMsg);
2520
2073
  }
2521
- // Trigger processing if not already running
2522
- void this.drain();
2523
2074
  }
2075
+ if (this.requeue === undefined)
2076
+ void this.drain();
2524
2077
  }
2525
- /** Release session lock. */
2526
- releaseLock() {
2078
+ /**
2079
+ * Release session lock. Awaits the queued-message drain, then clears the
2080
+ * lock from a FRESH read: the drain's queue-clearing put must not be
2081
+ * overwritten by a stale binding object (which would resurrect the queue
2082
+ * and cause duplicate processing on the next release).
2083
+ */
2084
+ async releaseLock() {
2527
2085
  const binding = this.bindings.get(this.channel, this.chatKey);
2528
- if (binding !== undefined && binding.lockOwner !== undefined) {
2529
- // Process queued messages before releasing
2530
- void this.processQueuedMessages();
2531
- this.bindings.put({ ...binding, lockOwner: undefined, lockAcquiredAt: undefined });
2532
- }
2533
- }
2534
- /** Check if lock has timed out. */
2535
- isLockTimedOut(binding) {
2536
- if (binding.lockOwner === undefined || binding.lockAcquiredAt === undefined) {
2537
- return false;
2538
- }
2539
- const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000; // Default 5 minutes
2540
- const elapsed = Date.now() - binding.lockAcquiredAt;
2541
- return elapsed > timeoutMs;
2086
+ if (binding === undefined || binding.lockOwner === undefined)
2087
+ return;
2088
+ await this.processQueuedMessages();
2089
+ const fresh = this.bindings.get(this.channel, this.chatKey) ?? binding;
2090
+ this.bindings.put(lockRelease(fresh));
2542
2091
  }
2543
- /** Check and release timed-out locks. */
2092
+ /** Check and release timed-out locks (delegates to the pure lock module). */
2544
2093
  checkAndReleaseTimeoutLock() {
2545
2094
  const binding = this.bindings.get(this.channel, this.chatKey);
2546
- if (binding && this.isLockTimedOut(binding)) {
2095
+ if (binding !== undefined && lockIsTimedOut(binding)) {
2547
2096
  this.releaseTimeoutLock(binding);
2548
2097
  }
2549
2098
  }
2550
2099
  /** Release a timed-out lock and notify. */
2551
2100
  releaseTimeoutLock(binding) {
2552
- const timeoutMin = Math.round((binding.lockTimeoutMs ?? 5 * 60 * 1000) / 60000);
2101
+ const timeoutMin = Math.round((binding.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS) / 60000);
2553
2102
  const lockedBy = binding.lockOwner;
2554
- this.bindings.put({
2555
- ...binding,
2556
- lockOwner: undefined,
2557
- lockAcquiredAt: undefined
2558
- });
2103
+ this.bindings.put(lockRelease(binding));
2559
2104
  // Notify users about timeout release
2560
2105
  const adapter = this.adapters?.get(this.channel);
2561
- if (adapter && lockedBy) {
2106
+ if (adapter !== undefined && lockedBy !== undefined) {
2562
2107
  void adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.lockTimeoutReleased(timeoutMin)).catch(() => undefined);
2563
2108
  }
2564
2109
  }