dsh-connect 0.5.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/runner.js CHANGED
@@ -5,11 +5,10 @@
5
5
  * @module dsh-connect/runner
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
- import { existsSync, mkdirSync, statSync } from "node:fs";
8
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
9
9
  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
- import { installModelSelection } from "@deepseek-ai/dsh-agent";
13
12
  import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
14
13
  import { createAsyncQueue } from "./stream.js";
15
14
  import { helpText, parseCommand } from "./commands.js";
@@ -24,8 +23,165 @@ export function resolveConnectConfig(config) {
24
23
  allowUsers: config.allowUsers ?? [],
25
24
  allowChats: config.allowChats ?? [],
26
25
  stateDir: config.stateDir,
26
+ autoMirror: config.autoMirror ?? true, // Enabled by default
27
+ streamHeartbeatMs: config.streamHeartbeatMs ?? 60_000,
28
+ notifyLevel: config.notifyLevel ?? "important",
29
+ progressTimeoutMs: config.progressTimeoutMs ?? 5 * 60_000, // Proactive progress notice after 5 min of silence
27
30
  };
28
31
  }
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
+ }
29
185
  function mapReason(kind) {
30
186
  switch (kind) {
31
187
  case "completed":
@@ -45,16 +201,44 @@ export function summarizeTurn(events, firstSeq) {
45
201
  let reason = "unknown";
46
202
  let code;
47
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;
48
213
  for (const event of events) {
49
214
  if (event.seq < firstSeq)
50
215
  continue;
51
216
  if (event.type === "turn/start") {
52
217
  started = true;
218
+ turnStartTime = typeof event.time === "number" ? event.time : undefined;
53
219
  continue;
54
220
  }
55
221
  if (!started)
56
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
+ }
57
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
+ }
58
242
  const joined = event.data.message.content
59
243
  .filter((block) => block.type === "text")
60
244
  .map((block) => block.text)
@@ -63,6 +247,7 @@ export function summarizeTurn(events, firstSeq) {
63
247
  text = joined;
64
248
  }
65
249
  if (event.type === "turn/end") {
250
+ turnEndTime = typeof event.time === "number" ? event.time : undefined;
66
251
  reason = mapReason(event.data.reason.kind);
67
252
  if (event.data.reason.kind === "error") {
68
253
  code = event.data.reason.error.code;
@@ -70,11 +255,42 @@ export function summarizeTurn(events, firstSeq) {
70
255
  }
71
256
  }
72
257
  }
73
- return { reason, text, ...(code === undefined ? {} : { code }), ...(message === undefined ? {} : { message }) };
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
+ };
74
275
  }
75
276
  function truncate(text, max = 500) {
76
277
  return text.length <= max ? text : `${text.slice(0, max)}…`;
77
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
+ }
78
294
  function textOf(message) {
79
295
  if (message === undefined)
80
296
  return "";
@@ -133,6 +349,8 @@ function detectImageMediaType(buf) {
133
349
  return "image/gif";
134
350
  return "image/png";
135
351
  }
352
+ /** How often the proactive progress watchdog re-checks whether a status card is due (ms). */
353
+ const PROGRESS_WATCHDOG_CHECK_MS = 15_000;
136
354
  export class AgentRunner {
137
355
  ctx;
138
356
  config;
@@ -141,6 +359,7 @@ export class AgentRunner {
141
359
  chatType;
142
360
  adapter;
143
361
  bindings;
362
+ adapters;
144
363
  queue = [];
145
364
  running = false;
146
365
  agent;
@@ -149,8 +368,11 @@ export class AgentRunner {
149
368
  workDir;
150
369
  streamEnabled = true;
151
370
  summaryEnabled = true;
371
+ language;
372
+ notifyLevel;
373
+ progressTimeoutMs;
152
374
  t;
153
- constructor(ctx, config, channel, chatKey, chatType, adapter, bindings) {
375
+ constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters) {
154
376
  this.ctx = ctx;
155
377
  this.config = config;
156
378
  this.channel = channel;
@@ -158,8 +380,14 @@ export class AgentRunner {
158
380
  this.chatType = chatType;
159
381
  this.adapter = adapter;
160
382
  this.bindings = bindings;
383
+ this.adapters = adapters;
161
384
  this.workDir = config.workDir ?? this.resolveDefaultWorkDir();
162
- this.t = messages(config.language);
385
+ // A per-chat language override (set via the settings menu) wins over config.
386
+ const stored = this.bindings.get(channel, chatKey);
387
+ this.language = stored?.language ?? config.language ?? "zh";
388
+ this.notifyLevel = stored?.notifyLevel ?? config.notifyLevel;
389
+ this.progressTimeoutMs = stored?.progressTimeoutMs ?? config.progressTimeoutMs;
390
+ this.t = messages(this.language);
163
391
  }
164
392
  menuTitle(menuId) {
165
393
  switch (menuId) {
@@ -177,14 +405,18 @@ export class AgentRunner {
177
405
  return this.t.menuReasoning;
178
406
  case "notify":
179
407
  return this.t.menuNotify;
408
+ case "language":
409
+ return this.t.menuSettingsLanguage;
410
+ case "progress":
411
+ return this.t.progressMenuTitle;
180
412
  }
181
413
  }
182
414
  rootMenuSections() {
183
415
  return [
184
- { title: this.t.rootSectionWorkspace, ids: ["workspace"] },
185
- { title: this.t.rootSectionChat, ids: ["chat", "history"] },
186
- { title: this.t.rootSectionTask, ids: ["task", "goals", "schedule"] },
187
- { title: this.t.rootSectionSystem, ids: ["status", "plugins", "compact", "settings"] },
416
+ { title: this.t.rootSectionWorkspace, ids: ["workspace"], columnsPerRow: 1 },
417
+ { title: this.t.rootSectionChat, ids: ["chat", "history"], columnsPerRow: 1 },
418
+ { title: this.t.rootSectionTask, ids: ["task", "goals", "schedule"], columnsPerRow: 2 },
419
+ { title: this.t.rootSectionSystem, ids: ["status", "plugins", "compact", "settings"], columnsPerRow: 2 },
188
420
  ];
189
421
  }
190
422
  reasonLabel(reason) {
@@ -231,14 +463,39 @@ export class AgentRunner {
231
463
  return;
232
464
  if (event.type === "assistant/chunk") {
233
465
  const chunk = event.data.chunk;
234
- if (chunk.type === "text-delta") {
235
- turn.chunks.push(chunk.text);
236
- turn.lastText += chunk.text;
466
+ // First reasoning delta = the milestone the proactive progress notice reports.
467
+ if (chunk.type === "reasoning-delta" && turn.milestone === undefined) {
468
+ turn.milestone = this.t.progressThinking;
237
469
  }
238
- else if (chunk.type === "reasoning-delta" && !turn.reasoning) {
239
- turn.reasoning = true;
240
- turn.chunks.push(this.t.thinkingHint);
470
+ applyStreamChunk(turn, this.t.thinkingHint, chunk, this.notifyLevel);
471
+ return;
472
+ }
473
+ if (event.type === "tool/call") {
474
+ const name = event.data.name;
475
+ if (typeof name !== "string" || name === "")
476
+ return;
477
+ const summary = toolCallSummary(event.data.arguments);
478
+ turn.toolCount += 1;
479
+ // The proactive progress notice reports the latest milestone even when
480
+ // `result` mode hides the streaming details.
481
+ if (name === "ask_user_question") {
482
+ const question = questionTextOf(event.data.arguments);
483
+ turn.milestone = this.t.questionToolCall(question);
484
+ }
485
+ else {
486
+ turn.milestone = this.t.toolCalling(name, undefined);
241
487
  }
488
+ // `result` mode shows nothing until the final answer; `important` shows
489
+ // the tool name with a step counter, `full` also carries the summary.
490
+ if (this.notifyLevel === "result")
491
+ return;
492
+ const base = name === "ask_user_question"
493
+ ? this.t.questionToolCall(questionTextOf(event.data.arguments))
494
+ : this.t.toolStepLabel(turn.toolCount, name);
495
+ const label = name === "ask_user_question" || this.notifyLevel !== "full" || summary === undefined
496
+ ? base
497
+ : `${base} — ${summary}`;
498
+ applyToolCall(turn, label);
242
499
  }
243
500
  }
244
501
  target(msg) {
@@ -248,6 +505,57 @@ export class AgentRunner {
248
505
  ...(msg.replyRef === undefined ? {} : { replyRef: msg.replyRef }),
249
506
  };
250
507
  }
508
+ /** Completion-card target: in groups, @ the requester so they notice the result. */
509
+ taskTarget(msg) {
510
+ return this.chatType === "group"
511
+ ? { ...this.target(msg), atUsers: [msg.senderKey] }
512
+ : this.target(msg);
513
+ }
514
+ /** Send the one-time welcome card on a chat's first message (persisted marker). */
515
+ async maybeSendWelcome(msg) {
516
+ const binding = this.bindings.get(this.channel, this.chatKey);
517
+ if (binding !== undefined && binding.welcomedAt !== undefined)
518
+ return;
519
+ const base = binding ?? {
520
+ channel: this.channel,
521
+ chatKey: this.chatKey,
522
+ chatType: this.chatType,
523
+ sessionId: "",
524
+ ownerKey: msg.senderKey,
525
+ createdAt: Date.now(),
526
+ lastActiveAt: Date.now(),
527
+ sessions: [],
528
+ };
529
+ this.bindings.put({ ...base, welcomedAt: Date.now(), lastActiveAt: Date.now() });
530
+ await this.adapter
531
+ .sendCard(this.target(msg), { markdown: `${this.t.welcomeTitle}\n\n${this.t.welcomeBody(this.workDir)}` })
532
+ .catch(() => undefined);
533
+ }
534
+ /** Pick the localized advice text for a failed turn. */
535
+ errorAdvice(detail) {
536
+ switch (classifyError(detail)) {
537
+ case "permission":
538
+ return this.t.errorAdvicePermission;
539
+ case "network":
540
+ return this.t.errorAdviceNetwork;
541
+ case "model":
542
+ return this.t.errorAdviceModel;
543
+ default:
544
+ return this.t.errorAdviceGeneric;
545
+ }
546
+ }
547
+ /** Present a destructive-action confirmation; true only when the user confirms. */
548
+ async confirmAction(target, promptText) {
549
+ const { choice } = await this.adapter.promptChoice(target, {
550
+ title: this.t.confirmTitle,
551
+ description: promptText,
552
+ options: [
553
+ { id: "confirm:yes", label: this.t.confirmYes },
554
+ { id: "confirm:no", label: this.t.confirmNo },
555
+ ],
556
+ });
557
+ return choice === "confirm:yes";
558
+ }
251
559
  async drain() {
252
560
  if (this.running)
253
561
  return;
@@ -265,18 +573,55 @@ export class AgentRunner {
265
573
  }
266
574
  }
267
575
  async runTurn(msg) {
576
+ // Check session lock - only allow if this channel owns the lock or no lock exists
577
+ const currentChannel = this.channel === "feishu" ? "feishu" : "web";
578
+ // Check timeout before attempting to acquire
579
+ this.checkAndReleaseTimeoutLock();
580
+ if (!this.acquireLock(currentChannel)) {
581
+ const binding = this.bindings.get(this.channel, this.chatKey);
582
+ const lockedBy = binding?.lockOwner ?? "unknown";
583
+ // If from Web channel, queue the message instead of rejecting
584
+ if (currentChannel === "web") {
585
+ const position = this.queueMessage(msg);
586
+ await this.adapter
587
+ .sendText(this.target(msg), this.t.messageQueued(position))
588
+ .catch(() => undefined);
589
+ }
590
+ else {
591
+ // Feishu gets immediate feedback
592
+ await this.adapter
593
+ .sendText(this.target(msg), this.t.sessionLockedBy(lockedBy))
594
+ .catch(() => undefined);
595
+ }
596
+ return;
597
+ }
268
598
  try {
599
+ // Acknowledge receipt before the (possibly slow) agent spin-up so the
600
+ // user knows processing started. When more messages are queued, say so.
601
+ const ack = this.t.processingStarted(truncate(msg.text, 60));
602
+ const ackText = this.queue.length > 0 ? `${ack}\n${this.t.queuedHint(this.queue.length)}` : ack;
603
+ await this.adapter
604
+ .sendText(this.target(msg), ackText)
605
+ .catch(() => undefined);
606
+ // First message in this chat: one-time welcome card.
607
+ await this.maybeSendWelcome(msg);
269
608
  const agent = await this.ensureAgent(msg);
270
609
  const outcome = await this.driveAgent(agent, msg);
271
610
  this.touchBinding(msg);
611
+ // Task-end stats: model, tokens, elapsed, and a compaction suggestion.
612
+ await this.sendTurnStats(msg, outcome);
613
+ // Release lock after task completion
614
+ this.releaseLock();
272
615
  if (outcome.reason !== "completed" && this.summaryEnabled) {
273
616
  await this.sendSummary(msg, outcome);
274
617
  }
275
618
  }
276
619
  catch (error) {
620
+ // Release lock even on error
621
+ this.releaseLock();
277
622
  const detail = error instanceof Error ? error.message : String(error);
278
623
  await this.adapter
279
- .sendText(this.target(msg), this.t.processingFailed(truncate(detail)))
624
+ .sendText(this.target(msg), this.t.processingFailedAdvice(truncate(detail, 400), this.errorAdvice(detail)))
280
625
  .catch(() => undefined);
281
626
  }
282
627
  }
@@ -286,6 +631,10 @@ export class AgentRunner {
286
631
  const live = binding === undefined ? undefined : this.agents.get(SessionId(binding.sessionId));
287
632
  if (live !== undefined) {
288
633
  this.agent = live;
634
+ // The live agent may have been recreated outside the runner (e.g. by
635
+ // the Web GUI's api-proxy) since we last saw it — make sure we are
636
+ // listening to the current instance so streaming can't silently die.
637
+ this.watchAgent(live);
289
638
  return live;
290
639
  }
291
640
  const selection = this.defaultSelection();
@@ -300,6 +649,10 @@ export class AgentRunner {
300
649
  this.handle = handle;
301
650
  this.agent = handle.agent;
302
651
  this.watchAgent(handle.agent);
652
+ // Ensure Web mirror exists for resumed sessions
653
+ this.autoCreateWebMirror(binding.sessionId, msg.senderKey);
654
+ // Ensure the session shows up under its workspace in the Web GUI
655
+ void this.attachToWorkspace(binding.sessionId);
303
656
  return handle.agent;
304
657
  }
305
658
  catch (error) {
@@ -320,8 +673,29 @@ export class AgentRunner {
320
673
  this.agent = handle.agent;
321
674
  this.watchAgent(handle.agent);
322
675
  this.recordSession(String(sessionId), truncate(msg.text, 40), msg.senderKey);
676
+ // Auto-create Web mirror for new sessions (if enabled)
677
+ this.autoCreateWebMirror(String(sessionId), msg.senderKey);
678
+ // Ensure the new session shows up under its workspace in the Web GUI
679
+ void this.attachToWorkspace(String(sessionId));
323
680
  return handle.agent;
324
681
  }
682
+ /**
683
+ * Attach a session to the DSH workspace matching its work directory so the
684
+ * Web GUI groups it under the same workspace the Feishu side uses.
685
+ * Best-effort: without a workspace registry this is a no-op.
686
+ */
687
+ async attachToWorkspace(sessionId) {
688
+ const registry = this.ctx.get("workspaceRegistry");
689
+ if (registry?.resolveByPath === undefined)
690
+ return;
691
+ try {
692
+ const ws = await registry.resolveByPath(this.workDir);
693
+ await ws?.attachSession?.(sessionId);
694
+ }
695
+ catch (error) {
696
+ this.log(`connect: attachToWorkspace(${sessionId}) skipped: ${String(error)}`);
697
+ }
698
+ }
325
699
  // Service lookups must go through ctx.get(): property access (ctx.agents) is
326
700
  // fiber-scoped and rejected from the async Feishu callback with "without inject".
327
701
  get agents() {
@@ -334,26 +708,46 @@ export class AgentRunner {
334
708
  const service = this.ctx.get("agentDefaultModel");
335
709
  return service?.currentSelection?.() ?? { provider: "", model: "" };
336
710
  }
337
- async composeSetup(selection) {
711
+ /**
712
+ * Compose the preset-only agent setup.
713
+ *
714
+ * Deliberately does NOT install a static model selection on the agent. The
715
+ * DSH Web GUI switches models through the host api-proxy's session-scoped
716
+ * selection (`selectionFor` + `installModelSelection`), which is installed
717
+ * lazily when a session's model directory is loaded and wins the
718
+ * `agent/request` waterfall. A static selection captured here at create /
719
+ * resume time would take precedence over that and silently pin every request
720
+ * to the default model captured at composition — so a model the user picks
721
+ * in the Web GUI would appear to succeed (the seat and `session.models`
722
+ * report it) while the actual LLM requests keep using the old default.
723
+ * The default model still seeds the agent through `agentOptions` in
724
+ * `ensureAgent`, which `buildRequest` uses as its fallback route.
725
+ */
726
+ async composeSetup(_selection) {
338
727
  const presets = this.ctx.get("agentPresets");
339
728
  if (presets === undefined) {
340
729
  return {
341
- setup: (agentCtx) => {
342
- installModelSelection(agentCtx, { current: selection, assembled: undefined });
343
- },
730
+ setup: () => undefined,
344
731
  };
345
732
  }
346
733
  const resolved = await presets.resolve(this.config.agentPreset);
347
734
  return {
348
735
  agentPreset: resolved.id,
349
736
  setup: async (agentCtx) => {
350
- installModelSelection(agentCtx, { current: selection, assembled: undefined });
351
737
  await presets.mount(agentCtx, resolved.id);
352
738
  },
353
739
  };
354
740
  }
741
+ /** Agents this runner has already attached a `session/event` listener to. */
742
+ watchedAgents = new WeakSet();
355
743
  watchAgent(agent) {
356
- // Scope-filtered: receives only this agent's session events.
744
+ // Scope-filtered: receives only this agent's session events. Deduplicated
745
+ // by object so a re-resumed agent (possibly recreated outside the runner,
746
+ // e.g. by the Web GUI's api-proxy) is re-watched without double-firing on
747
+ // a still-live agent.
748
+ if (this.watchedAgents.has(agent))
749
+ return;
750
+ this.watchedAgents.add(agent);
357
751
  agent.ctx.on("session/event", (session, event) => {
358
752
  this.onSessionEvent(session, event);
359
753
  });
@@ -375,13 +769,67 @@ export class AgentRunner {
375
769
  return outcome;
376
770
  }
377
771
  const chunks = createAsyncQueue();
378
- this.turn = { firstSeq, chunks, lastText: "", reasoning: false };
379
- const streamPromise = this.adapter.streamText(this.target(msg), chunks);
380
- agent.followup(await userMessage());
381
- await agent.whenIdle();
382
- await this.sessions.flush(agent.session);
383
- chunks.end();
384
- await streamPromise;
772
+ const now = Date.now();
773
+ this.turn = {
774
+ firstSeq, chunks, lastText: "",
775
+ reasoning: false, hintPushed: false, lastIndex: undefined, pushedAny: false,
776
+ startedAt: now, lastPushAt: now, toolCount: 0,
777
+ };
778
+ // Liveness heartbeat: while the agent is working, keep the streaming card
779
+ // visibly alive even through long reasoning or tool-execution stretches
780
+ // (a card that sits on "Thinking…" for minutes looks frozen). Configurable
781
+ // via `streamHeartbeatMs`; 0 disables it.
782
+ const heartbeatMs = this.config.streamHeartbeatMs;
783
+ const heartbeat = heartbeatMs > 0 && this.notifyLevel !== "result"
784
+ ? setInterval(() => {
785
+ const turn = this.turn;
786
+ if (turn === undefined)
787
+ return;
788
+ const elapsed = Date.now() - turn.lastPushAt;
789
+ if (elapsed < heartbeatMs)
790
+ return;
791
+ turn.lastPushAt = Date.now();
792
+ const minutes = Math.max(1, Math.round((turn.lastPushAt - turn.startedAt) / 60_000));
793
+ turn.chunks.push(`\n\n${this.t.processingHeartbeat(minutes)}\n\n`);
794
+ }, heartbeatMs)
795
+ : undefined;
796
+ // Proactive progress watchdog: when no standalone card/text has been sent
797
+ // for `progressTimeoutMs` (default 5 min, user-configurable), send a status
798
+ // card reporting the latest milestone so a long turn never looks frozen.
799
+ // Streaming-card heartbeats deliberately do NOT reset this — the user asked
800
+ // for an explicit, separate nudge.
801
+ const progressTimeoutMs = this.progressTimeoutMs;
802
+ let lastStandaloneNoticeAt = now;
803
+ const progressWatchdog = progressTimeoutMs > 0
804
+ ? setInterval(() => {
805
+ const turn = this.turn;
806
+ if (turn === undefined)
807
+ return;
808
+ const elapsed = Date.now() - lastStandaloneNoticeAt;
809
+ if (elapsed < progressTimeoutMs)
810
+ return;
811
+ lastStandaloneNoticeAt = Date.now();
812
+ const minutes = Math.max(1, Math.round((Date.now() - turn.startedAt) / 60_000));
813
+ const status = turn.milestone ?? this.t.progressThinking;
814
+ void this.adapter
815
+ .sendText(this.target(msg), this.t.progressReminder(minutes, status))
816
+ .catch(() => undefined);
817
+ }, PROGRESS_WATCHDOG_CHECK_MS)
818
+ : undefined;
819
+ try {
820
+ const streamPromise = this.adapter.streamText(this.target(msg), chunks);
821
+ agent.followup(await userMessage());
822
+ await agent.whenIdle();
823
+ await this.sessions.flush(agent.session);
824
+ chunks.end();
825
+ await streamPromise;
826
+ }
827
+ finally {
828
+ if (heartbeat !== undefined)
829
+ clearInterval(heartbeat);
830
+ if (progressWatchdog !== undefined)
831
+ clearInterval(progressWatchdog);
832
+ }
385
833
  const outcome = summarizeTurn(agent.session.events, firstSeq);
386
834
  const text = outcome.text !== "" ? outcome.text : this.turn.lastText;
387
835
  this.turn = undefined;
@@ -418,6 +866,19 @@ export class AgentRunner {
418
866
  const description = await this.describeImages(paths);
419
867
  const note = this.t.imagesStaged(locationText, description);
420
868
  content.push({ type: "text", text: note });
869
+ // Non-image attachments (files / audio / video): stage them into the work
870
+ // directory and hand the agent the paths — it can open them with its tools.
871
+ const rawFiles = msg.files;
872
+ if (rawFiles !== undefined && rawFiles.length > 0) {
873
+ const stagedFiles = await this.stageFiles(rawFiles);
874
+ content.push({
875
+ type: "text",
876
+ text: this.t.filesStaged(stagedFiles.length, stagedFiles.map((p) => `- ${p}`).join("\n")),
877
+ });
878
+ }
879
+ if (msg.fileError !== undefined) {
880
+ content.push({ type: "text", text: this.t.fileDownloadFailed(msg.fileError) });
881
+ }
421
882
  return content;
422
883
  }
423
884
  /** Copy images into `<workDir>/.dsh-connect-images` so the agent's tools can reach them. */
@@ -442,6 +903,28 @@ export class AgentRunner {
442
903
  }
443
904
  return staged;
444
905
  }
906
+ /** Copy non-image attachments into `<workDir>/.dsh-connect-files` so the agent's tools can reach them. */
907
+ async stageFiles(paths) {
908
+ const dir = join(this.workDir, ".dsh-connect-files");
909
+ try {
910
+ mkdirSync(dir, { recursive: true });
911
+ }
912
+ catch {
913
+ return [...paths];
914
+ }
915
+ const staged = [];
916
+ for (const p of paths) {
917
+ const target = join(dir, basename(p));
918
+ try {
919
+ await copyFile(p, target);
920
+ staged.push(target);
921
+ }
922
+ catch {
923
+ staged.push(p);
924
+ }
925
+ }
926
+ return staged;
927
+ }
445
928
  async imageBlocks(paths) {
446
929
  const attachments = this.ctx.get("attachments");
447
930
  if (attachments?.saveImage === undefined)
@@ -595,17 +1078,54 @@ export class AgentRunner {
595
1078
  if (outcome.text !== "")
596
1079
  lines.push(this.t.produced(truncate(outcome.text, 300)));
597
1080
  const card = { markdown: lines.join("\n") };
598
- await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
1081
+ await this.adapter.sendCard(this.taskTarget(msg), card).catch(() => undefined);
1082
+ }
1083
+ /** Context-window usage at or above this percentage suggests compaction. */
1084
+ static COMPACT_THRESHOLD_PCT = 75;
1085
+ /**
1086
+ * Send a compact task-end card: the model used, input/output tokens, elapsed
1087
+ * time, and whether compaction is advisable given context-window usage.
1088
+ */
1089
+ async sendTurnStats(msg, outcome) {
1090
+ if (outcome.model === undefined && outcome.inputTokens === undefined && outcome.outputTokens === undefined)
1091
+ return;
1092
+ const lines = [
1093
+ this.t.taskStatsHeader(outcome.elapsedMs === undefined ? "—" : this.t.taskDuration(outcome.elapsedMs)),
1094
+ ];
1095
+ if (outcome.model !== undefined)
1096
+ lines.push(this.t.taskStatsModel(outcome.model));
1097
+ if (outcome.inputTokens !== undefined) {
1098
+ lines.push(this.t.taskStatsTokensIn(fmtTokens(outcome.inputTokens), outcome.cacheReadTokens !== undefined ? fmtTokens(outcome.cacheReadTokens) : undefined));
1099
+ }
1100
+ if (outcome.outputTokens !== undefined)
1101
+ lines.push(this.t.taskStatsTokensOut(fmtTokens(outcome.outputTokens)));
1102
+ if (outcome.steps !== undefined)
1103
+ lines.push(this.t.taskStatsSteps(outcome.steps));
1104
+ if (outcome.contextSize !== undefined && outcome.contextWindow !== undefined && outcome.contextWindow > 0) {
1105
+ const pct = Math.round((outcome.contextSize / outcome.contextWindow) * 100);
1106
+ lines.push(this.t.taskStatsContext(`${pct}`, fmtTokens(outcome.contextWindow)));
1107
+ lines.push(pct >= AgentRunner.COMPACT_THRESHOLD_PCT ? this.t.taskStatsCompactSuggest : this.t.taskStatsCompactOk);
1108
+ }
1109
+ const card = { markdown: lines.join("\n") };
1110
+ await this.adapter.sendCard(this.taskTarget(msg), card).catch(() => undefined);
599
1111
  }
600
1112
  async handleCommand(command, msg) {
601
1113
  const target = this.target(msg);
602
1114
  switch (command.kind) {
603
1115
  case "new": {
1116
+ if (!(await this.confirmAction(target, this.t.confirmNewText))) {
1117
+ await this.adapter.sendText(target, this.t.actionCancelled);
1118
+ break;
1119
+ }
604
1120
  await this.newChat(msg);
605
1121
  await this.adapter.sendText(target, this.t.newChatDone);
606
1122
  break;
607
1123
  }
608
1124
  case "clear": {
1125
+ if (!(await this.confirmAction(target, this.t.confirmClearText))) {
1126
+ await this.adapter.sendText(target, this.t.actionCancelled);
1127
+ break;
1128
+ }
609
1129
  await this.clearChat(msg);
610
1130
  await this.adapter.sendText(target, this.t.chatCleared);
611
1131
  break;
@@ -692,10 +1212,34 @@ export class AgentRunner {
692
1212
  await this.openMenu(target, msg, "model", ["root"]);
693
1213
  break;
694
1214
  }
1215
+ case "notify": {
1216
+ await this.openNotifyPicker(target, msg);
1217
+ break;
1218
+ }
1219
+ case "progress": {
1220
+ await this.openProgressPicker(target, msg);
1221
+ break;
1222
+ }
695
1223
  case "workspaces": {
696
1224
  await this.showAllWorkspaces(target);
697
1225
  break;
698
1226
  }
1227
+ case "mirror": {
1228
+ await this.handleMirror(target, msg);
1229
+ break;
1230
+ }
1231
+ case "unlock": {
1232
+ await this.handleUnlock(target);
1233
+ break;
1234
+ }
1235
+ case "renew": {
1236
+ await this.handleRenewLock(target);
1237
+ break;
1238
+ }
1239
+ case "export": {
1240
+ await this.handleExport(target, command.format);
1241
+ break;
1242
+ }
699
1243
  case "help": {
700
1244
  await this.adapter.sendText(target, helpText(this.t));
701
1245
  break;
@@ -723,10 +1267,10 @@ export class AgentRunner {
723
1267
  { sessionId, title, createdAt: Date.now(), lastActiveAt: Date.now(), workDir: this.workDir },
724
1268
  ...others,
725
1269
  ].slice(0, 50);
1270
+ // Spread the existing binding so per-chat settings (language, notify
1271
+ // level, progress interval, welcomedAt…) survive the first session record.
726
1272
  this.bindings.put({
727
- channel: this.channel,
728
- chatKey: this.chatKey,
729
- chatType: this.chatType,
1273
+ ...(binding ?? { channel: this.channel, chatKey: this.chatKey, chatType: this.chatType }),
730
1274
  sessionId,
731
1275
  ownerKey,
732
1276
  createdAt: binding?.createdAt ?? Date.now(),
@@ -734,6 +1278,37 @@ export class AgentRunner {
734
1278
  sessions,
735
1279
  });
736
1280
  }
1281
+ /**
1282
+ * Automatically create a Web mirror for the session.
1283
+ * This enables DSH Web GUI to view and interact with the conversation
1284
+ * without requiring manual `/mirror` command.
1285
+ */
1286
+ autoCreateWebMirror(sessionId, ownerKey) {
1287
+ // Check if auto-mirror is enabled in config
1288
+ if (!this.config.autoMirror)
1289
+ return;
1290
+ // Only auto-create for Feishu channel (not for Web itself)
1291
+ if (this.channel !== "feishu")
1292
+ return;
1293
+ const binding = this.bindings.get(this.channel, this.chatKey);
1294
+ if (binding === undefined)
1295
+ return;
1296
+ // Check if mirror already exists
1297
+ if (binding.webMirrorSessionId !== undefined) {
1298
+ return; // Mirror already configured
1299
+ }
1300
+ // Auto-create mirror with default settings
1301
+ const defaultTimeoutMs = 5 * 60 * 1000; // 5 minutes
1302
+ this.bindings.put({
1303
+ ...binding,
1304
+ webMirrorSessionId: sessionId,
1305
+ lockOwner: "feishu", // Feishu owns the lock initially
1306
+ lockAcquiredAt: Date.now(),
1307
+ lockTimeoutMs: defaultTimeoutMs,
1308
+ lastActiveAt: Date.now(),
1309
+ });
1310
+ this.log(`connect: auto-created Web mirror for session ${sessionId}`);
1311
+ }
737
1312
  async newChat(msg) {
738
1313
  await this.disposeAgent();
739
1314
  const binding = this.bindings.get(this.channel, this.chatKey);
@@ -784,9 +1359,13 @@ export class AgentRunner {
784
1359
  options.push({ id: "menu:exit", label: this.t.menuExit });
785
1360
  if (stack.length > 0)
786
1361
  options.push({ id: "menu:back", label: this.t.menuBack });
1362
+ // Determine columns per row based on menu type
1363
+ // Workspace and chat lists use 1 column (full width), others use 2 columns
1364
+ const columnsPerRow = (menuId === "workspace" || menuId === "chat") ? 1 : 2;
787
1365
  const { choice, messageId } = await this.adapter.promptChoice(target, {
788
1366
  title: this.menuTitle(menuId),
789
1367
  options,
1368
+ columnsPerRow,
790
1369
  ...(menuId === "root" ? { sections: this.rootMenuSections(), footer: this.t.rootMenuFooter } : {}),
791
1370
  }, cardId);
792
1371
  if (choice === undefined)
@@ -840,20 +1419,32 @@ export class AgentRunner {
840
1419
  const binding = this.bindings.get(this.channel, this.chatKey);
841
1420
  const sessions = binding?.sessions ?? [];
842
1421
  const active = binding?.sessionId;
843
- const items = sessions.map((s) => ({
844
- id: `session:${s.sessionId}`,
845
- label: `${s.sessionId === active ? "●" : "○"} ${s.title || s.sessionId}`,
846
- leaf: true,
847
- onSelect: async (t, m) => {
848
- await this.switchTo(s.sessionId, m.senderKey);
849
- },
850
- }));
1422
+ const hasWebMirror = binding?.webMirrorSessionId !== undefined;
1423
+ const items = sessions.map((s) => {
1424
+ // Check if this session is mirrored to Web
1425
+ const isMirrored = hasWebMirror && binding.webMirrorSessionId === s.sessionId;
1426
+ const mirrorIndicator = isMirrored ? ` ${this.t.webMirrorIndicator}` : "";
1427
+ const activeIndicator = s.sessionId === active ? "●" : "○";
1428
+ return {
1429
+ id: `session:${s.sessionId}`,
1430
+ label: `${activeIndicator} ${s.title || s.sessionId}${mirrorIndicator}`,
1431
+ leaf: true,
1432
+ onSelect: async (t, m) => {
1433
+ await this.switchTo(s.sessionId, m.senderKey);
1434
+ },
1435
+ };
1436
+ });
851
1437
  items.push({
852
1438
  id: "action:new",
853
1439
  label: this.t.menuNewChat,
854
1440
  leaf: true,
855
1441
  onSelect: async (t, m) => {
856
- await this.newChat(m);
1442
+ if (await this.confirmAction(t, this.t.confirmNewText)) {
1443
+ await this.newChat(m);
1444
+ }
1445
+ else {
1446
+ await this.adapter.sendText(t, this.t.actionCancelled);
1447
+ }
857
1448
  },
858
1449
  });
859
1450
  return items;
@@ -863,31 +1454,60 @@ export class AgentRunner {
863
1454
  { id: "model", label: this.t.menuSettingsModel, onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
864
1455
  { id: "reasoning", label: this.t.menuSettingsReasoning, onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
865
1456
  { id: "notify", label: this.t.menuSettingsNotify, onSelect: (t, m, cardId) => this.openMenu(t, m, "notify", ["settings", "root"], cardId) },
1457
+ { id: "progress", label: this.t.menuSettingsProgress, onSelect: (t, m, cardId) => this.openMenu(t, m, "progress", ["settings", "root"], cardId) },
1458
+ { id: "language", label: this.t.menuSettingsLanguage, onSelect: (t, m, cardId) => this.openMenu(t, m, "language", ["settings", "root"], cardId) },
866
1459
  { id: "overview", label: this.t.menuSettingsOverview, leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
867
1460
  ];
868
- case "model":
869
- return await this.modelMenuItems();
870
- case "reasoning":
871
- return this.reasoningMenuItems();
872
- case "notify":
1461
+ case "language":
873
1462
  return [
874
1463
  {
875
- id: "stream",
876
- label: this.t.streamLabel(this.streamEnabled),
1464
+ id: "lang:zh",
1465
+ label: `${this.language === "zh" ? "● " : ""}${this.t.languageZh}`,
877
1466
  leaf: true,
878
- onSelect: async () => {
879
- this.streamEnabled = !this.streamEnabled;
880
- },
1467
+ onSelect: async (t, m) => { await this.setLanguage("zh", t, m); },
881
1468
  },
882
1469
  {
883
- id: "summary",
884
- label: this.t.summaryLabel(this.summaryEnabled),
1470
+ id: "lang:en",
1471
+ label: `${this.language === "en" ? "● " : ""}${this.t.languageEn}`,
885
1472
  leaf: true,
886
- onSelect: async () => {
887
- this.summaryEnabled = !this.summaryEnabled;
888
- },
1473
+ onSelect: async (t, m) => { await this.setLanguage("en", t, m); },
1474
+ },
1475
+ ];
1476
+ case "model":
1477
+ return await this.modelMenuItems();
1478
+ case "reasoning":
1479
+ return this.reasoningMenuItems();
1480
+ case "notify":
1481
+ return [
1482
+ { id: "full", label: this.t.notifyFull },
1483
+ { id: "important", label: this.t.notifyImportant },
1484
+ { id: "result", label: this.t.notifyResult },
1485
+ ].map((o) => ({
1486
+ id: `notify:${o.id}`,
1487
+ label: `${this.notifyLevel === o.id ? "● " : ""}${o.label}`,
1488
+ leaf: true,
1489
+ onSelect: async (t, m) => {
1490
+ await this.setNotifyLevel(o.id, t, m);
889
1491
  },
1492
+ }));
1493
+ case "progress": {
1494
+ const presets = [
1495
+ { id: "progress:0", label: this.t.progressOff, ms: 0 },
1496
+ { id: "progress:120000", label: this.t.progressMinutes(2), ms: 2 * 60_000 },
1497
+ { id: "progress:300000", label: this.t.progressMinutes(5), ms: 5 * 60_000 },
1498
+ { id: "progress:600000", label: this.t.progressMinutes(10), ms: 10 * 60_000 },
1499
+ { id: "progress:900000", label: this.t.progressMinutes(15), ms: 15 * 60_000 },
1500
+ { id: "progress:1800000", label: this.t.progressMinutes(30), ms: 30 * 60_000 },
890
1501
  ];
1502
+ return presets.map((o) => ({
1503
+ id: o.id,
1504
+ label: `${this.progressTimeoutMs === o.ms ? "● " : ""}${o.label}`,
1505
+ leaf: true,
1506
+ onSelect: async (t, m) => {
1507
+ await this.setProgressTimeout(o.ms, t, m);
1508
+ },
1509
+ }));
1510
+ }
891
1511
  }
892
1512
  }
893
1513
  async showStatus(target) {
@@ -896,15 +1516,40 @@ export class AgentRunner {
896
1516
  await this.adapter.sendText(target, this.t.statusNoSession(this.workDir));
897
1517
  return;
898
1518
  }
899
- const label = agent.status === "running" ? this.t.statusRunning : this.t.statusIdle;
1519
+ // Determine detailed execution state
1520
+ let statusLabel;
1521
+ if (agent.status === "running") {
1522
+ statusLabel = this.t.statusExecuting;
1523
+ }
1524
+ else if (this.queue.length > 0) {
1525
+ statusLabel = this.t.statusWaiting;
1526
+ }
1527
+ else {
1528
+ statusLabel = this.t.statusIdle;
1529
+ }
900
1530
  const model = `${agent.options.provider ?? "-"}/${agent.options.model ?? "-"}`;
901
1531
  const lines = [
902
- this.t.statusField(label),
1532
+ this.t.statusField(statusLabel),
903
1533
  this.t.modelField(model),
904
1534
  this.t.workdirField(this.workDir),
905
- this.t.queuedField(this.queue.length),
906
- this.t.sessionField(agent.id),
907
1535
  ];
1536
+ // Queue status with detail
1537
+ if (this.queue.length === 0) {
1538
+ lines.push(`${this.t.queuedField(0)} ${this.t.queueEmpty}`);
1539
+ }
1540
+ else {
1541
+ lines.push(`${this.t.queuedField(this.queue.length)} ${this.t.queueDetail(this.queue.length)}`);
1542
+ }
1543
+ // Last turn completion info
1544
+ const lastTurn = this.getLastTurnInfo(agent);
1545
+ if (lastTurn !== undefined) {
1546
+ lines.push(this.t.lastTurnReason(this.reasonLabel(lastTurn.reason)));
1547
+ lines.push(this.t.lastTurnTime(lastTurn.completedAt));
1548
+ }
1549
+ else {
1550
+ lines.push(this.t.noTurnHistory);
1551
+ }
1552
+ lines.push(this.t.sessionField(agent.id));
908
1553
  const tokenMeter = this.ctx.get("tokenMeter");
909
1554
  try {
910
1555
  const m = tokenMeter?.measure?.(agent.session);
@@ -916,6 +1561,20 @@ export class AgentRunner {
916
1561
  }
917
1562
  await this.adapter.sendText(target, lines.join("\n"));
918
1563
  }
1564
+ /** Extract the most recent turn's completion info from session events. */
1565
+ getLastTurnInfo(agent) {
1566
+ const events = agent.session.events;
1567
+ for (let i = events.length - 1; i >= 0; i--) {
1568
+ const event = events[i];
1569
+ if (event.type === "turn/end") {
1570
+ const reason = mapReason(event.data.reason.kind);
1571
+ // Use the event's timestamp if available, otherwise approximate
1572
+ const completedAt = new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
1573
+ return { reason, completedAt };
1574
+ }
1575
+ }
1576
+ return undefined;
1577
+ }
919
1578
  async showTasks(target) {
920
1579
  const todos = this.readTodos();
921
1580
  if (todos.length === 0) {
@@ -946,6 +1605,7 @@ export class AgentRunner {
946
1605
  lines.push(this.t.chatCountField(binding?.sessions.length ?? 0));
947
1606
  lines.push(this.t.streamLabel(this.streamEnabled));
948
1607
  lines.push(this.t.summaryLabel(this.summaryEnabled));
1608
+ lines.push(this.t.progressSetting(this.progressTimeoutMs === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(this.progressTimeoutMs / 60_000))));
949
1609
  lines.push(this.t.allowUsersField(this.config.allowUsers.length, this.config.allowUsers.length === 0));
950
1610
  lines.push(this.t.allowChatsField(this.config.allowChats.length, this.config.allowChats.length === 0));
951
1611
  lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
@@ -1034,6 +1694,118 @@ export class AgentRunner {
1034
1694
  });
1035
1695
  await this.newChat(msg);
1036
1696
  }
1697
+ /** Switch the user-facing language for this chat, persisted in its binding. */
1698
+ async setLanguage(lang, target, msg) {
1699
+ if (lang === this.language)
1700
+ return;
1701
+ this.language = lang;
1702
+ this.t = messages(lang);
1703
+ // Persist into the chat's binding (create one if the chat has no session yet).
1704
+ const binding = this.bindings.get(this.channel, this.chatKey);
1705
+ if (binding !== undefined) {
1706
+ this.bindings.put({ ...binding, language: lang, lastActiveAt: Date.now() });
1707
+ }
1708
+ else {
1709
+ this.bindings.put({
1710
+ channel: this.channel,
1711
+ chatKey: this.chatKey,
1712
+ chatType: this.chatType,
1713
+ sessionId: "",
1714
+ ownerKey: msg.senderKey,
1715
+ createdAt: Date.now(),
1716
+ lastActiveAt: Date.now(),
1717
+ language: lang,
1718
+ sessions: [],
1719
+ });
1720
+ }
1721
+ await this.adapter.sendText(target, this.t.languageSet(lang === "zh" ? this.t.languageZh : this.t.languageEn));
1722
+ }
1723
+ /**
1724
+ * Switch the notification level for this chat (persisted in its binding and
1725
+ * effective immediately for the next streaming reply).
1726
+ */
1727
+ async setNotifyLevel(level, target, msg) {
1728
+ this.notifyLevel = level;
1729
+ const binding = this.bindings.get(this.channel, this.chatKey);
1730
+ if (binding !== undefined) {
1731
+ this.bindings.put({ ...binding, notifyLevel: level, lastActiveAt: Date.now() });
1732
+ }
1733
+ else {
1734
+ this.bindings.put({
1735
+ channel: this.channel,
1736
+ chatKey: this.chatKey,
1737
+ chatType: this.chatType,
1738
+ sessionId: "",
1739
+ ownerKey: msg.senderKey,
1740
+ createdAt: Date.now(),
1741
+ lastActiveAt: Date.now(),
1742
+ notifyLevel: level,
1743
+ sessions: [],
1744
+ });
1745
+ }
1746
+ const label = level === "full" ? this.t.notifyFull : level === "important" ? this.t.notifyImportant : this.t.notifyResult;
1747
+ const desc = level === "full" ? this.t.notifyFullDesc : level === "important" ? this.t.notifyImportantDesc : this.t.notifyResultDesc;
1748
+ await this.adapter.sendText(target, this.t.notifySet(label, desc));
1749
+ }
1750
+ /** Show the three-level notification picker (used by the `/notify` command). */
1751
+ async openNotifyPicker(target, msg) {
1752
+ const options = [
1753
+ { id: "notify:full", label: this.t.notifyFull },
1754
+ { id: "notify:important", label: this.t.notifyImportant },
1755
+ { id: "notify:result", label: this.t.notifyResult },
1756
+ ];
1757
+ const { choice } = await this.adapter.promptChoice(target, { title: this.t.menuNotify, options });
1758
+ if (choice === undefined)
1759
+ return;
1760
+ const level = choice.startsWith("notify:") ? choice.slice("notify:".length) : undefined;
1761
+ if (level !== "full" && level !== "important" && level !== "result")
1762
+ return;
1763
+ await this.setNotifyLevel(level, target, msg);
1764
+ }
1765
+ /**
1766
+ * Switch the proactive progress-notice interval for this chat (persisted in
1767
+ * its binding and effective for the next turn; 0 disables the watchdog).
1768
+ */
1769
+ async setProgressTimeout(ms, target, msg) {
1770
+ this.progressTimeoutMs = ms;
1771
+ const binding = this.bindings.get(this.channel, this.chatKey);
1772
+ if (binding !== undefined) {
1773
+ this.bindings.put({ ...binding, progressTimeoutMs: ms, lastActiveAt: Date.now() });
1774
+ }
1775
+ else {
1776
+ this.bindings.put({
1777
+ channel: this.channel,
1778
+ chatKey: this.chatKey,
1779
+ chatType: this.chatType,
1780
+ sessionId: "",
1781
+ ownerKey: msg.senderKey,
1782
+ createdAt: Date.now(),
1783
+ lastActiveAt: Date.now(),
1784
+ progressTimeoutMs: ms,
1785
+ sessions: [],
1786
+ });
1787
+ }
1788
+ const label = ms === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(ms / 60_000));
1789
+ await this.adapter.sendText(target, this.t.progressSet(label));
1790
+ }
1791
+ /** Show the progress-interval picker (used by the `/progress` command). */
1792
+ async openProgressPicker(target, msg) {
1793
+ const options = [
1794
+ { id: "progress:0", label: this.t.progressOff },
1795
+ { id: "progress:120000", label: this.t.progressMinutes(2) },
1796
+ { id: "progress:300000", label: this.t.progressMinutes(5) },
1797
+ { id: "progress:600000", label: this.t.progressMinutes(10) },
1798
+ { id: "progress:900000", label: this.t.progressMinutes(15) },
1799
+ { id: "progress:1800000", label: this.t.progressMinutes(30) },
1800
+ ];
1801
+ const { choice } = await this.adapter.promptChoice(target, { title: this.t.progressMenuTitle, options });
1802
+ if (choice === undefined || !choice.startsWith("progress:"))
1803
+ return;
1804
+ const ms = Number(choice.slice("progress:".length));
1805
+ if (!Number.isInteger(ms) || ms < 0)
1806
+ return;
1807
+ await this.setProgressTimeout(ms, target, msg);
1808
+ }
1037
1809
  async showPlugins(target) {
1038
1810
  const loader = this.ctx.get("loader");
1039
1811
  const all = [...(loader?.entries?.() ?? [])];
@@ -1095,12 +1867,14 @@ export class AgentRunner {
1095
1867
  return;
1096
1868
  }
1097
1869
  try {
1870
+ // Compaction can take a while — tell the user it started, then report the outcome.
1871
+ await this.adapter.sendText(target, this.t.compactStarted);
1098
1872
  const result = await compaction.compactNow(agent, new AbortController().signal);
1099
1873
  if (result === null) {
1100
1874
  await this.adapter.sendText(target, this.t.nothingToCompact);
1101
1875
  }
1102
1876
  else {
1103
- await this.adapter.sendText(target, this.t.contextCompacted);
1877
+ await this.adapter.sendText(target, this.t.compactDone);
1104
1878
  }
1105
1879
  }
1106
1880
  catch (error) {
@@ -1206,5 +1980,338 @@ export class AgentRunner {
1206
1980
  });
1207
1981
  await this.adapter.sendText(target, this.t.workspacesCount(all.length, lines.join("\n")));
1208
1982
  }
1983
+ /**
1984
+ * Create or show Web mirror session for this chat.
1985
+ * The mirror shares the same DSH session but enforces mutual exclusion:
1986
+ * when Feishu is executing a task, Web is read-only, and vice versa.
1987
+ */
1988
+ async handleMirror(target, msg) {
1989
+ const binding = this.bindings.get(this.channel, this.chatKey);
1990
+ // Check timeout before showing status
1991
+ this.checkAndReleaseTimeoutLock();
1992
+ // Reload binding after potential timeout release
1993
+ const updatedBinding = this.bindings.get(this.channel, this.chatKey);
1994
+ // Check if mirror already exists
1995
+ if (updatedBinding?.webMirrorSessionId !== undefined) {
1996
+ const queuedCount = updatedBinding.queuedMessages?.length ?? 0;
1997
+ const timeoutMin = updatedBinding.lockTimeoutMs ? Math.round(updatedBinding.lockTimeoutMs / 60000) : undefined;
1998
+ await this.adapter.sendText(target, this.t.mirrorStatus(updatedBinding.webMirrorSessionId, updatedBinding.lockOwner, timeoutMin, queuedCount));
1999
+ return;
2000
+ }
2001
+ // Create new mirror session (reuse current session)
2002
+ const agent = this.agent;
2003
+ if (agent === undefined) {
2004
+ await this.adapter.sendText(target, this.t.mirrorNotConfigured);
2005
+ return;
2006
+ }
2007
+ // The mirror uses the same session ID - it's a shared view
2008
+ const mirrorSessionId = agent.id;
2009
+ // Use custom timeout if provided, otherwise default to 5 minutes
2010
+ const command = parseCommand(msg.text);
2011
+ const customTimeoutMin = command.kind === "mirror" ? command.timeoutMin : undefined;
2012
+ const defaultTimeoutMs = (customTimeoutMin ?? 5) * 60 * 1000;
2013
+ // Update binding with mirror info and set initial lock to feishu
2014
+ const newBinding = updatedBinding !== undefined
2015
+ ? {
2016
+ ...updatedBinding,
2017
+ webMirrorSessionId: mirrorSessionId,
2018
+ lockOwner: "feishu",
2019
+ lockAcquiredAt: Date.now(),
2020
+ lockTimeoutMs: defaultTimeoutMs,
2021
+ }
2022
+ : {
2023
+ channel: this.channel,
2024
+ chatKey: this.chatKey,
2025
+ chatType: this.chatType,
2026
+ sessionId: mirrorSessionId,
2027
+ ownerKey: msg.senderKey,
2028
+ createdAt: Date.now(),
2029
+ lastActiveAt: Date.now(),
2030
+ webMirrorSessionId: mirrorSessionId,
2031
+ lockOwner: "feishu",
2032
+ lockAcquiredAt: Date.now(),
2033
+ lockTimeoutMs: defaultTimeoutMs,
2034
+ sessions: [],
2035
+ };
2036
+ this.bindings.put(newBinding);
2037
+ const timeoutMsg = customTimeoutMin !== undefined ? `(超时时间:${customTimeoutMin} 分钟)` : "";
2038
+ await this.adapter.sendText(target, this.t.mirrorCreated(mirrorSessionId) + timeoutMsg);
2039
+ }
2040
+ /**
2041
+ * Manually release session lock.
2042
+ */
2043
+ async handleUnlock(target) {
2044
+ // Check timeout first
2045
+ this.checkAndReleaseTimeoutLock();
2046
+ const binding = this.bindings.get(this.channel, this.chatKey);
2047
+ if (binding?.lockOwner === undefined) {
2048
+ await this.adapter.sendText(target, this.t.unlockNoLock);
2049
+ return;
2050
+ }
2051
+ // Process any queued messages before releasing
2052
+ const queuedCount = binding.queuedMessages?.length ?? 0;
2053
+ this.releaseLock();
2054
+ let message = this.t.unlockSuccess;
2055
+ if (queuedCount > 0) {
2056
+ message += `\n${this.t.queueProcessed(queuedCount)}`;
2057
+ }
2058
+ await this.adapter.sendText(target, message);
2059
+ }
2060
+ /**
2061
+ * Renew the current session lock timeout.
2062
+ * Extends the lock by another full timeout period from now.
2063
+ */
2064
+ async handleRenewLock(target) {
2065
+ const binding = this.bindings.get(this.channel, this.chatKey);
2066
+ if (binding?.lockOwner === undefined) {
2067
+ await this.adapter.sendText(target, this.t.unlockNoLock);
2068
+ return;
2069
+ }
2070
+ const currentChannel = this.channel === "feishu" ? "feishu" : "web";
2071
+ if (binding.lockOwner !== currentChannel) {
2072
+ await this.adapter.sendText(target, this.t.sessionLockedBy(binding.lockOwner));
2073
+ return;
2074
+ }
2075
+ // Renew the lock by resetting the acquisition time
2076
+ const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000;
2077
+ this.bindings.put({
2078
+ ...binding,
2079
+ lockAcquiredAt: Date.now(),
2080
+ lastActiveAt: Date.now()
2081
+ });
2082
+ const timeoutMin = Math.round(timeoutMs / 60000);
2083
+ await this.adapter.sendText(target, this.t.lockRenewed(timeoutMin));
2084
+ }
2085
+ /**
2086
+ * Export conversation history to Markdown or PDF.
2087
+ */
2088
+ async handleExport(target, format) {
2089
+ const agent = this.agent;
2090
+ if (agent === undefined) {
2091
+ await this.adapter.sendText(target, this.t.exportNoSession);
2092
+ return;
2093
+ }
2094
+ // Default to markdown if not specified
2095
+ const exportFormat = format ?? "markdown";
2096
+ if (exportFormat === "pdf") {
2097
+ await this.adapter.sendText(target, this.t.exportPdfNotSupported);
2098
+ return;
2099
+ }
2100
+ try {
2101
+ const markdown = this.generateMarkdown(agent);
2102
+ const fileName = `conversation-${agent.id}-${Date.now()}.md`;
2103
+ const filePath = join(this.workDir, fileName);
2104
+ // Write to file
2105
+ writeFileSync(filePath, markdown, "utf8");
2106
+ await this.adapter.sendText(target, this.t.exportMarkdown(filePath));
2107
+ }
2108
+ catch (error) {
2109
+ const detail = error instanceof Error ? error.message : String(error);
2110
+ await this.adapter.sendText(target, this.t.exportFailed(truncate(detail)));
2111
+ }
2112
+ }
2113
+ /**
2114
+ * Generate Markdown representation of the conversation history.
2115
+ */
2116
+ generateMarkdown(agent) {
2117
+ const events = agent.session.events;
2118
+ const lines = [];
2119
+ // Header
2120
+ lines.push(`# Conversation History`);
2121
+ lines.push(``);
2122
+ lines.push(`- **Session ID**: ${agent.id}`);
2123
+ lines.push(`- **Exported At**: ${new Date().toISOString()}`);
2124
+ lines.push(`- **Total Events**: ${events.length}`);
2125
+ lines.push(``);
2126
+ lines.push(`---`);
2127
+ lines.push(``);
2128
+ // Messages
2129
+ for (const event of events) {
2130
+ if (event.type === "user/message") {
2131
+ const text = textOf(event.data);
2132
+ if (text) {
2133
+ lines.push(`**👤 User**:`);
2134
+ lines.push(``);
2135
+ lines.push(text);
2136
+ lines.push(``);
2137
+ }
2138
+ }
2139
+ else if (event.type === "assistant/message") {
2140
+ const text = textOf(event.data.message);
2141
+ if (text) {
2142
+ lines.push(`**🤖 Assistant**:`);
2143
+ lines.push(``);
2144
+ lines.push(text);
2145
+ lines.push(``);
2146
+ }
2147
+ }
2148
+ else if (event.type === "turn/start") {
2149
+ lines.push(`*Turn started at ${new Date().toLocaleTimeString()}*`);
2150
+ lines.push(``);
2151
+ }
2152
+ else if (event.type === "turn/end") {
2153
+ const reason = mapReason(event.data.reason.kind);
2154
+ lines.push(`*Turn ended: ${this.reasonLabel(reason)}*`);
2155
+ lines.push(``);
2156
+ lines.push(`---`);
2157
+ lines.push(``);
2158
+ }
2159
+ }
2160
+ return lines.join("\n");
2161
+ }
2162
+ /**
2163
+ * Check if the current channel has write access to the session.
2164
+ * Returns true if allowed to send messages, false if read-only.
2165
+ */
2166
+ canWrite(channel) {
2167
+ const binding = this.bindings.get(this.channel, this.chatKey);
2168
+ if (binding?.lockOwner === undefined)
2169
+ return true; // No lock, free access
2170
+ // Check timeout
2171
+ if (this.isLockTimedOut(binding)) {
2172
+ this.releaseTimeoutLock(binding);
2173
+ return true;
2174
+ }
2175
+ return binding.lockOwner === channel;
2176
+ }
2177
+ /**
2178
+ * Acquire session lock for the given channel.
2179
+ * Returns true if lock acquired, false if already locked by another channel.
2180
+ */
2181
+ acquireLock(channel) {
2182
+ const binding = this.bindings.get(this.channel, this.chatKey);
2183
+ if (binding === undefined)
2184
+ return true;
2185
+ // Check timeout before acquiring
2186
+ if (this.isLockTimedOut(binding)) {
2187
+ this.releaseTimeoutLock(binding);
2188
+ // After timeout release, try to acquire again
2189
+ const refreshed = this.bindings.get(this.channel, this.chatKey);
2190
+ if (refreshed !== undefined && (refreshed.lockOwner === undefined || refreshed.lockOwner === channel)) {
2191
+ const defaultTimeoutMs = refreshed.lockTimeoutMs ?? 5 * 60 * 1000;
2192
+ this.bindings.put({
2193
+ ...refreshed,
2194
+ lockOwner: channel,
2195
+ lockAcquiredAt: Date.now(),
2196
+ lockTimeoutMs: defaultTimeoutMs,
2197
+ lastActiveAt: Date.now()
2198
+ });
2199
+ return true;
2200
+ }
2201
+ }
2202
+ if (binding.lockOwner === undefined || binding.lockOwner === channel) {
2203
+ // Lock is free or already owned by this channel
2204
+ const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000;
2205
+ this.bindings.put({
2206
+ ...binding,
2207
+ lockOwner: channel,
2208
+ lockAcquiredAt: Date.now(),
2209
+ lockTimeoutMs: timeoutMs,
2210
+ lastActiveAt: Date.now()
2211
+ });
2212
+ return true;
2213
+ }
2214
+ // Locked by other channel - queue the message if from Web
2215
+ return false;
2216
+ }
2217
+ /**
2218
+ * Queue a message for later execution when lock is released.
2219
+ * Returns the position in queue.
2220
+ */
2221
+ queueMessage(msg) {
2222
+ const binding = this.bindings.get(this.channel, this.chatKey);
2223
+ if (binding === undefined)
2224
+ return -1;
2225
+ const queued = binding.queuedMessages ?? [];
2226
+ queued.push({
2227
+ text: msg.text,
2228
+ senderKey: msg.senderKey,
2229
+ timestamp: Date.now(),
2230
+ replyRef: msg.replyRef,
2231
+ images: msg.images,
2232
+ files: msg.files,
2233
+ });
2234
+ this.bindings.put({ ...binding, queuedMessages: queued });
2235
+ return queued.length;
2236
+ }
2237
+ /**
2238
+ * Process all queued messages after lock release.
2239
+ * Re-enqueues them into the runner's message queue for actual execution.
2240
+ */
2241
+ async processQueuedMessages() {
2242
+ const binding = this.bindings.get(this.channel, this.chatKey);
2243
+ if (binding === undefined || !binding.queuedMessages || binding.queuedMessages.length === 0) {
2244
+ return;
2245
+ }
2246
+ const messages = [...binding.queuedMessages];
2247
+ const count = messages.length;
2248
+ // Clear queue immediately to prevent duplicate processing
2249
+ this.bindings.put({ ...binding, queuedMessages: [] });
2250
+ // Notify about queued messages being processed
2251
+ if (count > 0) {
2252
+ const adapter = this.adapters?.get(this.channel);
2253
+ if (adapter) {
2254
+ await adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.queueProcessed(count)).catch(() => undefined);
2255
+ }
2256
+ // Re-enqueue messages into the runner's processing queue
2257
+ // Construct minimal InboundMessage objects from queued data
2258
+ for (const queued of messages) {
2259
+ const inboundMsg = {
2260
+ channel: "web", // Queued messages are from Web channel
2261
+ chatKey: this.chatKey,
2262
+ chatType: this.chatType,
2263
+ senderKey: queued.senderKey,
2264
+ text: queued.text,
2265
+ replyRef: queued.replyRef,
2266
+ images: queued.images,
2267
+ files: queued.files,
2268
+ };
2269
+ // Add to the runner's internal queue for processing
2270
+ this.queue.push(inboundMsg);
2271
+ }
2272
+ // Trigger processing if not already running
2273
+ void this.drain();
2274
+ }
2275
+ }
2276
+ /** Release session lock. */
2277
+ releaseLock() {
2278
+ const binding = this.bindings.get(this.channel, this.chatKey);
2279
+ if (binding !== undefined && binding.lockOwner !== undefined) {
2280
+ // Process queued messages before releasing
2281
+ void this.processQueuedMessages();
2282
+ this.bindings.put({ ...binding, lockOwner: undefined, lockAcquiredAt: undefined });
2283
+ }
2284
+ }
2285
+ /** Check if lock has timed out. */
2286
+ isLockTimedOut(binding) {
2287
+ if (binding.lockOwner === undefined || binding.lockAcquiredAt === undefined) {
2288
+ return false;
2289
+ }
2290
+ const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000; // Default 5 minutes
2291
+ const elapsed = Date.now() - binding.lockAcquiredAt;
2292
+ return elapsed > timeoutMs;
2293
+ }
2294
+ /** Check and release timed-out locks. */
2295
+ checkAndReleaseTimeoutLock() {
2296
+ const binding = this.bindings.get(this.channel, this.chatKey);
2297
+ if (binding && this.isLockTimedOut(binding)) {
2298
+ this.releaseTimeoutLock(binding);
2299
+ }
2300
+ }
2301
+ /** Release a timed-out lock and notify. */
2302
+ releaseTimeoutLock(binding) {
2303
+ const timeoutMin = Math.round((binding.lockTimeoutMs ?? 5 * 60 * 1000) / 60000);
2304
+ const lockedBy = binding.lockOwner;
2305
+ this.bindings.put({
2306
+ ...binding,
2307
+ lockOwner: undefined,
2308
+ lockAcquiredAt: undefined
2309
+ });
2310
+ // Notify users about timeout release
2311
+ const adapter = this.adapters?.get(this.channel);
2312
+ if (adapter && lockedBy) {
2313
+ void adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.lockTimeoutReleased(timeoutMin)).catch(() => undefined);
2314
+ }
2315
+ }
1209
2316
  }
1210
2317
  //# sourceMappingURL=runner.js.map