dsh-connect 0.2.0 → 0.5.2

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,32 +5,183 @@
5
5
  * @module dsh-connect/runner
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
- import { existsSync, statSync } from "node:fs";
9
- import { isAbsolute } from "node:path";
8
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
9
+ import { copyFile, readFile } from "node:fs/promises";
10
+ import { basename, isAbsolute, join } from "node:path";
10
11
  import { SessionId } from "@deepseek-ai/dsh-session";
11
- import { installModelSelection } from "@deepseek-ai/dsh-agent";
12
12
  import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
13
13
  import { createAsyncQueue } from "./stream.js";
14
- import { HELP_TEXT, parseCommand } from "./commands.js";
14
+ import { helpText, parseCommand } from "./commands.js";
15
+ import { messages } from "./i18n.js";
15
16
  export function resolveConnectConfig(config) {
16
17
  return {
17
18
  agentPreset: config.agentPreset,
18
19
  workDir: config.workDir,
19
20
  workspaces: config.workspaces ?? [],
21
+ visionModel: config.visionModel,
22
+ language: config.language ?? "zh",
20
23
  allowUsers: config.allowUsers ?? [],
21
24
  allowChats: config.allowChats ?? [],
22
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
23
30
  };
24
31
  }
25
- const REASON_LABELS = {
26
- completed: "✅ 完成",
27
- aborted: "⏹️ 已中止",
28
- blocked: "🚫 阻塞",
29
- error: "❌ 出错",
30
- "max-tokens": "⚠️ 达到输出上限",
31
- interrupted: "⚠️ 被中断",
32
- unknown: "❓ 未知",
33
- };
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
+ }
34
185
  function mapReason(kind) {
35
186
  switch (kind) {
36
187
  case "completed":
@@ -50,16 +201,44 @@ export function summarizeTurn(events, firstSeq) {
50
201
  let reason = "unknown";
51
202
  let code;
52
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;
53
213
  for (const event of events) {
54
214
  if (event.seq < firstSeq)
55
215
  continue;
56
216
  if (event.type === "turn/start") {
57
217
  started = true;
218
+ turnStartTime = typeof event.time === "number" ? event.time : undefined;
58
219
  continue;
59
220
  }
60
221
  if (!started)
61
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
+ }
62
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
+ }
63
242
  const joined = event.data.message.content
64
243
  .filter((block) => block.type === "text")
65
244
  .map((block) => block.text)
@@ -68,6 +247,7 @@ export function summarizeTurn(events, firstSeq) {
68
247
  text = joined;
69
248
  }
70
249
  if (event.type === "turn/end") {
250
+ turnEndTime = typeof event.time === "number" ? event.time : undefined;
71
251
  reason = mapReason(event.data.reason.kind);
72
252
  if (event.data.reason.kind === "error") {
73
253
  code = event.data.reason.error.code;
@@ -75,11 +255,31 @@ export function summarizeTurn(events, firstSeq) {
75
255
  }
76
256
  }
77
257
  }
78
- 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
+ };
79
275
  }
80
276
  function truncate(text, max = 500) {
81
277
  return text.length <= max ? text : `${text.slice(0, max)}…`;
82
278
  }
279
+ /** Thousands-separated token count for display. */
280
+ function fmtTokens(n) {
281
+ return n.toLocaleString("en-US");
282
+ }
83
283
  function textOf(message) {
84
284
  if (message === undefined)
85
285
  return "";
@@ -88,15 +288,58 @@ function textOf(message) {
88
288
  .map((b) => b.text ?? "")
89
289
  .join("");
90
290
  }
91
- const MENU_TITLES = {
92
- root: "主菜单",
93
- workspace: "切换工作目录",
94
- chat: "切换对话",
95
- settings: "设置",
96
- model: "切换模型",
97
- reasoning: "推理强度",
98
- notify: "通知设置",
99
- };
291
+ /** Lightweight fold of `schedule/change` events (create / delete / dispatch). */
292
+ function foldReminders(events) {
293
+ const active = new Map();
294
+ for (const e of events) {
295
+ // `schedule/change` is a plugin-extended event type outside dsh-session's map.
296
+ const evt = e;
297
+ if (evt.type !== "schedule/change")
298
+ continue;
299
+ const d = evt.data;
300
+ if (d.version !== 1)
301
+ continue;
302
+ if (d.operation === "create" && d.schedule !== undefined) {
303
+ active.set(d.schedule.id, {
304
+ id: d.schedule.id,
305
+ kind: d.schedule.kind,
306
+ prompt: d.schedule.prompt,
307
+ scheduledAt: d.schedule.scheduledAt,
308
+ everySeconds: d.schedule.everySeconds,
309
+ });
310
+ }
311
+ else if (d.operation === "delete" && d.id !== undefined) {
312
+ active.delete(d.id);
313
+ }
314
+ else if (d.operation === "dispatch" && d.id !== undefined) {
315
+ const rec = active.get(d.id);
316
+ if (rec === undefined)
317
+ continue;
318
+ if (rec.kind === "every" && rec.everySeconds !== undefined && d.acceptedAt !== undefined) {
319
+ const next = Date.parse(d.acceptedAt) + rec.everySeconds * 1000;
320
+ active.set(d.id, { ...rec, scheduledAt: new Date(next).toISOString() });
321
+ }
322
+ else {
323
+ active.delete(d.id);
324
+ }
325
+ }
326
+ }
327
+ return [...active.values()];
328
+ }
329
+ /** Sniff an image's media type from its magic bytes (defaults to PNG). */
330
+ function detectImageMediaType(buf) {
331
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
332
+ return "image/png";
333
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
334
+ return "image/jpeg";
335
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP")
336
+ return "image/webp";
337
+ if (buf.length >= 6 && buf.toString("ascii", 0, 3) === "GIF")
338
+ return "image/gif";
339
+ return "image/png";
340
+ }
341
+ /** How often the proactive progress watchdog re-checks whether a status card is due (ms). */
342
+ const PROGRESS_WATCHDOG_CHECK_MS = 15_000;
100
343
  export class AgentRunner {
101
344
  ctx;
102
345
  config;
@@ -105,6 +348,7 @@ export class AgentRunner {
105
348
  chatType;
106
349
  adapter;
107
350
  bindings;
351
+ adapters;
108
352
  queue = [];
109
353
  running = false;
110
354
  agent;
@@ -113,7 +357,11 @@ export class AgentRunner {
113
357
  workDir;
114
358
  streamEnabled = true;
115
359
  summaryEnabled = true;
116
- constructor(ctx, config, channel, chatKey, chatType, adapter, bindings) {
360
+ language;
361
+ notifyLevel;
362
+ progressTimeoutMs;
363
+ t;
364
+ constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters) {
117
365
  this.ctx = ctx;
118
366
  this.config = config;
119
367
  this.channel = channel;
@@ -121,7 +369,62 @@ export class AgentRunner {
121
369
  this.chatType = chatType;
122
370
  this.adapter = adapter;
123
371
  this.bindings = bindings;
372
+ this.adapters = adapters;
124
373
  this.workDir = config.workDir ?? this.resolveDefaultWorkDir();
374
+ // A per-chat language override (set via the settings menu) wins over config.
375
+ const stored = this.bindings.get(channel, chatKey);
376
+ this.language = stored?.language ?? config.language ?? "zh";
377
+ this.notifyLevel = stored?.notifyLevel ?? config.notifyLevel;
378
+ this.progressTimeoutMs = stored?.progressTimeoutMs ?? config.progressTimeoutMs;
379
+ this.t = messages(this.language);
380
+ }
381
+ menuTitle(menuId) {
382
+ switch (menuId) {
383
+ case "root":
384
+ return this.t.menuRoot;
385
+ case "workspace":
386
+ return this.t.menuWorkspace;
387
+ case "chat":
388
+ return this.t.menuChat;
389
+ case "settings":
390
+ return this.t.menuSettings;
391
+ case "model":
392
+ return this.t.menuModel;
393
+ case "reasoning":
394
+ return this.t.menuReasoning;
395
+ case "notify":
396
+ return this.t.menuNotify;
397
+ case "language":
398
+ return this.t.menuSettingsLanguage;
399
+ case "progress":
400
+ return this.t.progressMenuTitle;
401
+ }
402
+ }
403
+ rootMenuSections() {
404
+ return [
405
+ { title: this.t.rootSectionWorkspace, ids: ["workspace"], columnsPerRow: 1 },
406
+ { title: this.t.rootSectionChat, ids: ["chat", "history"], columnsPerRow: 1 },
407
+ { title: this.t.rootSectionTask, ids: ["task", "goals", "schedule"], columnsPerRow: 2 },
408
+ { title: this.t.rootSectionSystem, ids: ["status", "plugins", "compact", "settings"], columnsPerRow: 2 },
409
+ ];
410
+ }
411
+ reasonLabel(reason) {
412
+ switch (reason) {
413
+ case "completed":
414
+ return this.t.reasonCompleted;
415
+ case "aborted":
416
+ return this.t.reasonAborted;
417
+ case "blocked":
418
+ return this.t.reasonBlocked;
419
+ case "error":
420
+ return this.t.reasonError;
421
+ case "max-tokens":
422
+ return this.t.reasonMaxTokens;
423
+ case "interrupted":
424
+ return this.t.reasonInterrupted;
425
+ default:
426
+ return this.t.reasonUnknown;
427
+ }
125
428
  }
126
429
  /** Prefer the user's first DSH workspace over the process cwd as default. */
127
430
  resolveDefaultWorkDir() {
@@ -149,14 +452,37 @@ export class AgentRunner {
149
452
  return;
150
453
  if (event.type === "assistant/chunk") {
151
454
  const chunk = event.data.chunk;
152
- if (chunk.type === "text-delta") {
153
- turn.chunks.push(chunk.text);
154
- turn.lastText += chunk.text;
455
+ // First reasoning delta = the milestone the proactive progress notice reports.
456
+ if (chunk.type === "reasoning-delta" && turn.milestone === undefined) {
457
+ turn.milestone = this.t.progressThinking;
155
458
  }
156
- else if (chunk.type === "reasoning-delta" && !turn.reasoning) {
157
- turn.reasoning = true;
158
- turn.chunks.push("🤔 深度思考中…\n");
459
+ applyStreamChunk(turn, this.t.thinkingHint, chunk, this.notifyLevel);
460
+ return;
461
+ }
462
+ if (event.type === "tool/call") {
463
+ const name = event.data.name;
464
+ if (typeof name !== "string" || name === "")
465
+ return;
466
+ const summary = toolCallSummary(event.data.arguments);
467
+ // The proactive progress notice reports the latest milestone even when
468
+ // `result` mode hides the streaming details.
469
+ if (name === "ask_user_question") {
470
+ const question = questionTextOf(event.data.arguments);
471
+ turn.milestone = this.t.questionToolCall(question);
159
472
  }
473
+ else {
474
+ turn.milestone = this.t.toolCalling(name, undefined);
475
+ }
476
+ // `result` mode shows nothing until the final answer; `important` shows
477
+ // the tool name only, `full` also carries the one-line summary.
478
+ if (this.notifyLevel === "result")
479
+ return;
480
+ const label = name === "ask_user_question"
481
+ ? this.t.questionToolCall(questionTextOf(event.data.arguments))
482
+ : this.notifyLevel === "full"
483
+ ? this.t.toolCalling(name, summary)
484
+ : this.t.toolCalling(name, undefined);
485
+ applyToolCall(turn, label);
160
486
  }
161
487
  }
162
488
  target(msg) {
@@ -183,18 +509,51 @@ export class AgentRunner {
183
509
  }
184
510
  }
185
511
  async runTurn(msg) {
512
+ // Check session lock - only allow if this channel owns the lock or no lock exists
513
+ const currentChannel = this.channel === "feishu" ? "feishu" : "web";
514
+ // Check timeout before attempting to acquire
515
+ this.checkAndReleaseTimeoutLock();
516
+ if (!this.acquireLock(currentChannel)) {
517
+ const binding = this.bindings.get(this.channel, this.chatKey);
518
+ const lockedBy = binding?.lockOwner ?? "unknown";
519
+ // If from Web channel, queue the message instead of rejecting
520
+ if (currentChannel === "web") {
521
+ const position = this.queueMessage(msg);
522
+ await this.adapter
523
+ .sendText(this.target(msg), this.t.messageQueued(position))
524
+ .catch(() => undefined);
525
+ }
526
+ else {
527
+ // Feishu gets immediate feedback
528
+ await this.adapter
529
+ .sendText(this.target(msg), this.t.sessionLockedBy(lockedBy))
530
+ .catch(() => undefined);
531
+ }
532
+ return;
533
+ }
186
534
  try {
535
+ // Acknowledge receipt before the (possibly slow) agent spin-up so the
536
+ // user knows processing started.
537
+ await this.adapter
538
+ .sendText(this.target(msg), this.t.processingStarted(truncate(msg.text, 60)))
539
+ .catch(() => undefined);
187
540
  const agent = await this.ensureAgent(msg);
188
541
  const outcome = await this.driveAgent(agent, msg);
189
542
  this.touchBinding(msg);
543
+ // Task-end stats: model, tokens, elapsed, and a compaction suggestion.
544
+ await this.sendTurnStats(msg, outcome);
545
+ // Release lock after task completion
546
+ this.releaseLock();
190
547
  if (outcome.reason !== "completed" && this.summaryEnabled) {
191
548
  await this.sendSummary(msg, outcome);
192
549
  }
193
550
  }
194
551
  catch (error) {
552
+ // Release lock even on error
553
+ this.releaseLock();
195
554
  const detail = error instanceof Error ? error.message : String(error);
196
555
  await this.adapter
197
- .sendText(this.target(msg), `⚠️ 处理失败:${truncate(detail)}`)
556
+ .sendText(this.target(msg), this.t.processingFailed(truncate(detail)))
198
557
  .catch(() => undefined);
199
558
  }
200
559
  }
@@ -204,6 +563,10 @@ export class AgentRunner {
204
563
  const live = binding === undefined ? undefined : this.agents.get(SessionId(binding.sessionId));
205
564
  if (live !== undefined) {
206
565
  this.agent = live;
566
+ // The live agent may have been recreated outside the runner (e.g. by
567
+ // the Web GUI's api-proxy) since we last saw it — make sure we are
568
+ // listening to the current instance so streaming can't silently die.
569
+ this.watchAgent(live);
207
570
  return live;
208
571
  }
209
572
  const selection = this.defaultSelection();
@@ -218,6 +581,10 @@ export class AgentRunner {
218
581
  this.handle = handle;
219
582
  this.agent = handle.agent;
220
583
  this.watchAgent(handle.agent);
584
+ // Ensure Web mirror exists for resumed sessions
585
+ this.autoCreateWebMirror(binding.sessionId, msg.senderKey);
586
+ // Ensure the session shows up under its workspace in the Web GUI
587
+ void this.attachToWorkspace(binding.sessionId);
221
588
  return handle.agent;
222
589
  }
223
590
  catch (error) {
@@ -238,8 +605,29 @@ export class AgentRunner {
238
605
  this.agent = handle.agent;
239
606
  this.watchAgent(handle.agent);
240
607
  this.recordSession(String(sessionId), truncate(msg.text, 40), msg.senderKey);
608
+ // Auto-create Web mirror for new sessions (if enabled)
609
+ this.autoCreateWebMirror(String(sessionId), msg.senderKey);
610
+ // Ensure the new session shows up under its workspace in the Web GUI
611
+ void this.attachToWorkspace(String(sessionId));
241
612
  return handle.agent;
242
613
  }
614
+ /**
615
+ * Attach a session to the DSH workspace matching its work directory so the
616
+ * Web GUI groups it under the same workspace the Feishu side uses.
617
+ * Best-effort: without a workspace registry this is a no-op.
618
+ */
619
+ async attachToWorkspace(sessionId) {
620
+ const registry = this.ctx.get("workspaceRegistry");
621
+ if (registry?.resolveByPath === undefined)
622
+ return;
623
+ try {
624
+ const ws = await registry.resolveByPath(this.workDir);
625
+ await ws?.attachSession?.(sessionId);
626
+ }
627
+ catch (error) {
628
+ this.log(`connect: attachToWorkspace(${sessionId}) skipped: ${String(error)}`);
629
+ }
630
+ }
243
631
  // Service lookups must go through ctx.get(): property access (ctx.agents) is
244
632
  // fiber-scoped and rejected from the async Feishu callback with "without inject".
245
633
  get agents() {
@@ -252,37 +640,58 @@ export class AgentRunner {
252
640
  const service = this.ctx.get("agentDefaultModel");
253
641
  return service?.currentSelection?.() ?? { provider: "", model: "" };
254
642
  }
255
- async composeSetup(selection) {
643
+ /**
644
+ * Compose the preset-only agent setup.
645
+ *
646
+ * Deliberately does NOT install a static model selection on the agent. The
647
+ * DSH Web GUI switches models through the host api-proxy's session-scoped
648
+ * selection (`selectionFor` + `installModelSelection`), which is installed
649
+ * lazily when a session's model directory is loaded and wins the
650
+ * `agent/request` waterfall. A static selection captured here at create /
651
+ * resume time would take precedence over that and silently pin every request
652
+ * to the default model captured at composition — so a model the user picks
653
+ * in the Web GUI would appear to succeed (the seat and `session.models`
654
+ * report it) while the actual LLM requests keep using the old default.
655
+ * The default model still seeds the agent through `agentOptions` in
656
+ * `ensureAgent`, which `buildRequest` uses as its fallback route.
657
+ */
658
+ async composeSetup(_selection) {
256
659
  const presets = this.ctx.get("agentPresets");
257
660
  if (presets === undefined) {
258
661
  return {
259
- setup: (agentCtx) => {
260
- installModelSelection(agentCtx, { current: selection, assembled: undefined });
261
- },
662
+ setup: () => undefined,
262
663
  };
263
664
  }
264
665
  const resolved = await presets.resolve(this.config.agentPreset);
265
666
  return {
266
667
  agentPreset: resolved.id,
267
668
  setup: async (agentCtx) => {
268
- installModelSelection(agentCtx, { current: selection, assembled: undefined });
269
669
  await presets.mount(agentCtx, resolved.id);
270
670
  },
271
671
  };
272
672
  }
673
+ /** Agents this runner has already attached a `session/event` listener to. */
674
+ watchedAgents = new WeakSet();
273
675
  watchAgent(agent) {
274
- // Scope-filtered: receives only this agent's session events.
676
+ // Scope-filtered: receives only this agent's session events. Deduplicated
677
+ // by object so a re-resumed agent (possibly recreated outside the runner,
678
+ // e.g. by the Web GUI's api-proxy) is re-watched without double-firing on
679
+ // a still-live agent.
680
+ if (this.watchedAgents.has(agent))
681
+ return;
682
+ this.watchedAgents.add(agent);
275
683
  agent.ctx.on("session/event", (session, event) => {
276
684
  this.onSessionEvent(session, event);
277
685
  });
278
686
  }
279
687
  async driveAgent(agent, msg) {
280
688
  const firstSeq = agent.session.seq;
689
+ const userMessage = async () => {
690
+ const content = await this.buildUserContent(msg);
691
+ return createUserMessage({ content, source: { kind: "user" } });
692
+ };
281
693
  if (!this.streamEnabled) {
282
- agent.followup(createUserMessage({
283
- content: [{ type: "text", text: msg.text }],
284
- source: { kind: "user" },
285
- }));
694
+ agent.followup(await userMessage());
286
695
  await agent.whenIdle();
287
696
  await this.sessions.flush(agent.session);
288
697
  const outcome = summarizeTurn(agent.session.events, firstSeq);
@@ -292,21 +701,288 @@ export class AgentRunner {
292
701
  return outcome;
293
702
  }
294
703
  const chunks = createAsyncQueue();
295
- this.turn = { firstSeq, chunks, lastText: "", reasoning: false };
296
- const streamPromise = this.adapter.streamText(this.target(msg), chunks);
297
- agent.followup(createUserMessage({
298
- content: [{ type: "text", text: msg.text }],
299
- source: { kind: "user" },
300
- }));
301
- await agent.whenIdle();
302
- await this.sessions.flush(agent.session);
303
- chunks.end();
304
- await streamPromise;
704
+ const now = Date.now();
705
+ this.turn = {
706
+ firstSeq, chunks, lastText: "",
707
+ reasoning: false, hintPushed: false, lastIndex: undefined, pushedAny: false,
708
+ startedAt: now, lastPushAt: now,
709
+ };
710
+ // Liveness heartbeat: while the agent is working, keep the streaming card
711
+ // visibly alive even through long reasoning or tool-execution stretches
712
+ // (a card that sits on "Thinking…" for minutes looks frozen). Configurable
713
+ // via `streamHeartbeatMs`; 0 disables it.
714
+ const heartbeatMs = this.config.streamHeartbeatMs;
715
+ const heartbeat = heartbeatMs > 0 && this.notifyLevel !== "result"
716
+ ? setInterval(() => {
717
+ const turn = this.turn;
718
+ if (turn === undefined)
719
+ return;
720
+ const elapsed = Date.now() - turn.lastPushAt;
721
+ if (elapsed < heartbeatMs)
722
+ return;
723
+ turn.lastPushAt = Date.now();
724
+ const minutes = Math.max(1, Math.round((turn.lastPushAt - turn.startedAt) / 60_000));
725
+ turn.chunks.push(`\n\n${this.t.processingHeartbeat(minutes)}\n\n`);
726
+ }, heartbeatMs)
727
+ : undefined;
728
+ // Proactive progress watchdog: when no standalone card/text has been sent
729
+ // for `progressTimeoutMs` (default 5 min, user-configurable), send a status
730
+ // card reporting the latest milestone so a long turn never looks frozen.
731
+ // Streaming-card heartbeats deliberately do NOT reset this — the user asked
732
+ // for an explicit, separate nudge.
733
+ const progressTimeoutMs = this.progressTimeoutMs;
734
+ let lastStandaloneNoticeAt = now;
735
+ const progressWatchdog = progressTimeoutMs > 0
736
+ ? setInterval(() => {
737
+ const turn = this.turn;
738
+ if (turn === undefined)
739
+ return;
740
+ const elapsed = Date.now() - lastStandaloneNoticeAt;
741
+ if (elapsed < progressTimeoutMs)
742
+ return;
743
+ lastStandaloneNoticeAt = Date.now();
744
+ const minutes = Math.max(1, Math.round((Date.now() - turn.startedAt) / 60_000));
745
+ const status = turn.milestone ?? this.t.progressThinking;
746
+ void this.adapter
747
+ .sendText(this.target(msg), this.t.progressReminder(minutes, status))
748
+ .catch(() => undefined);
749
+ }, PROGRESS_WATCHDOG_CHECK_MS)
750
+ : undefined;
751
+ try {
752
+ const streamPromise = this.adapter.streamText(this.target(msg), chunks);
753
+ agent.followup(await userMessage());
754
+ await agent.whenIdle();
755
+ await this.sessions.flush(agent.session);
756
+ chunks.end();
757
+ await streamPromise;
758
+ }
759
+ finally {
760
+ if (heartbeat !== undefined)
761
+ clearInterval(heartbeat);
762
+ if (progressWatchdog !== undefined)
763
+ clearInterval(progressWatchdog);
764
+ }
305
765
  const outcome = summarizeTurn(agent.session.events, firstSeq);
306
766
  const text = outcome.text !== "" ? outcome.text : this.turn.lastText;
307
767
  this.turn = undefined;
308
768
  return { ...outcome, text };
309
769
  }
770
+ /**
771
+ * Build the user content for one inbound message. When the message carries
772
+ * images, either pass them directly to the main model (if it supports
773
+ * vision) or run a vision sub-task with a capable model and inject its
774
+ * description as text — so a text-only main model never stalls on images.
775
+ */
776
+ async buildUserContent(msg) {
777
+ const content = [{ type: "text", text: msg.text }];
778
+ const rawImages = msg.images;
779
+ if (rawImages === undefined || rawImages.length === 0) {
780
+ if (msg.imageError !== undefined) {
781
+ content.push({ type: "text", text: this.t.imageDownloadFailed(msg.imageError) });
782
+ }
783
+ return content;
784
+ }
785
+ // Copy images into the agent's work directory so its tools can access them.
786
+ const paths = await this.stageImages(rawImages);
787
+ const locationText = paths.map((p) => `- ${p}`).join("\n");
788
+ const mainVision = await this.modelSupportsVision(this.agent?.options.provider, this.agent?.options.model);
789
+ if (mainVision) {
790
+ const imageBlocks = await this.imageBlocks(paths);
791
+ if (imageBlocks.length > 0) {
792
+ this.log(`connect: main model supports vision — passing ${imageBlocks.length} image block(s) directly`);
793
+ return [{ type: "text", text: msg.text }, ...imageBlocks];
794
+ }
795
+ this.log("connect: main model supports vision but image blocks failed to attach — falling back to paths");
796
+ return content;
797
+ }
798
+ const description = await this.describeImages(paths);
799
+ const note = this.t.imagesStaged(locationText, description);
800
+ content.push({ type: "text", text: note });
801
+ // Non-image attachments (files / audio / video): stage them into the work
802
+ // directory and hand the agent the paths — it can open them with its tools.
803
+ const rawFiles = msg.files;
804
+ if (rawFiles !== undefined && rawFiles.length > 0) {
805
+ const stagedFiles = await this.stageFiles(rawFiles);
806
+ content.push({
807
+ type: "text",
808
+ text: this.t.filesStaged(stagedFiles.length, stagedFiles.map((p) => `- ${p}`).join("\n")),
809
+ });
810
+ }
811
+ if (msg.fileError !== undefined) {
812
+ content.push({ type: "text", text: this.t.fileDownloadFailed(msg.fileError) });
813
+ }
814
+ return content;
815
+ }
816
+ /** Copy images into `<workDir>/.dsh-connect-images` so the agent's tools can reach them. */
817
+ async stageImages(paths) {
818
+ const dir = join(this.workDir, ".dsh-connect-images");
819
+ try {
820
+ mkdirSync(dir, { recursive: true });
821
+ }
822
+ catch {
823
+ return [...paths];
824
+ }
825
+ const staged = [];
826
+ for (const p of paths) {
827
+ const target = join(dir, basename(p));
828
+ try {
829
+ await copyFile(p, target);
830
+ staged.push(target);
831
+ }
832
+ catch {
833
+ staged.push(p);
834
+ }
835
+ }
836
+ return staged;
837
+ }
838
+ /** Copy non-image attachments into `<workDir>/.dsh-connect-files` so the agent's tools can reach them. */
839
+ async stageFiles(paths) {
840
+ const dir = join(this.workDir, ".dsh-connect-files");
841
+ try {
842
+ mkdirSync(dir, { recursive: true });
843
+ }
844
+ catch {
845
+ return [...paths];
846
+ }
847
+ const staged = [];
848
+ for (const p of paths) {
849
+ const target = join(dir, basename(p));
850
+ try {
851
+ await copyFile(p, target);
852
+ staged.push(target);
853
+ }
854
+ catch {
855
+ staged.push(p);
856
+ }
857
+ }
858
+ return staged;
859
+ }
860
+ async imageBlocks(paths) {
861
+ const attachments = this.ctx.get("attachments");
862
+ if (attachments?.saveImage === undefined)
863
+ return [];
864
+ const blocks = [];
865
+ for (const path of paths) {
866
+ try {
867
+ const buf = await readFile(path);
868
+ const mediaType = detectImageMediaType(buf);
869
+ const ref = await attachments.saveImage({ data: new Uint8Array(buf), mediaType });
870
+ blocks.push({ type: "image", attachment: ref });
871
+ }
872
+ catch {
873
+ // Skip unreadable / unsupported images.
874
+ }
875
+ }
876
+ return blocks;
877
+ }
878
+ async describeImages(paths) {
879
+ const vision = await this.findVisionModel();
880
+ if (vision === null) {
881
+ this.log("connect: describeImages skipped — no vision model available");
882
+ return "";
883
+ }
884
+ const attachments = this.ctx.get("attachments");
885
+ const llm = this.ctx.get("llm");
886
+ if (attachments?.saveImage === undefined || llm?.stream === undefined) {
887
+ this.log(`connect: describeImages skipped — attachments.saveImage=${attachments?.saveImage !== undefined}, llm.stream=${llm?.stream !== undefined}`);
888
+ return "";
889
+ }
890
+ const refs = [];
891
+ for (const path of paths) {
892
+ try {
893
+ const buf = await readFile(path);
894
+ refs.push(await attachments.saveImage({ data: new Uint8Array(buf), mediaType: detectImageMediaType(buf) }));
895
+ }
896
+ catch (error) {
897
+ this.log(`connect: saveImage failed for ${path}: ${String(error)}`);
898
+ }
899
+ }
900
+ if (refs.length === 0) {
901
+ this.log("connect: describeImages skipped — no image could be attached");
902
+ return "";
903
+ }
904
+ this.log(`connect: describing ${refs.length} image(s) with ${vision.provider}/${vision.model}`);
905
+ const messages = [
906
+ {
907
+ role: "user",
908
+ content: [
909
+ { type: "text", text: this.t.visionPrompt },
910
+ ...refs.map((ref) => ({ type: "image", attachment: ref })),
911
+ ],
912
+ },
913
+ ];
914
+ try {
915
+ let text = "";
916
+ const stream = llm.stream({ provider: vision.provider, model: vision.model, messages, maxTokens: 2048 });
917
+ for await (const chunk of stream) {
918
+ if (chunk.type === "text-delta" && typeof chunk.text === "string")
919
+ text += chunk.text;
920
+ }
921
+ this.log(`connect: vision description produced ${text.length} char(s)`);
922
+ return text.trim();
923
+ }
924
+ catch (error) {
925
+ this.log(`connect: vision description call failed: ${error instanceof Error ? error.message : String(error)}`);
926
+ return "";
927
+ }
928
+ }
929
+ async modelSupportsVision(provider, model) {
930
+ if (provider === undefined || model === undefined)
931
+ return false;
932
+ const llm = this.ctx.get("llm");
933
+ try {
934
+ const info = await llm?.resolveModelInfo?.(provider, model);
935
+ const supports = info?.inputModalities?.includes("image") ?? false;
936
+ this.log(`connect: main model vision check ${provider}/${model} -> ${supports} (inputModalities=${JSON.stringify(info?.inputModalities)})`);
937
+ return supports;
938
+ }
939
+ catch (error) {
940
+ this.log(`connect: main model vision check ${provider}/${model} failed: ${String(error)}`);
941
+ return false;
942
+ }
943
+ }
944
+ async findVisionModel() {
945
+ if (this.config.visionModel !== undefined) {
946
+ this.log(`connect: vision model configured: ${this.config.visionModel.provider}/${this.config.visionModel.model}`);
947
+ return this.config.visionModel;
948
+ }
949
+ const llm = this.ctx.get("llm");
950
+ if (llm?.listProviders === undefined || llm.resolveModelInfo === undefined) {
951
+ this.log("connect: vision auto-detection unavailable — llm service lacks listProviders/resolveModelInfo");
952
+ return null;
953
+ }
954
+ const providers = llm.listProviders() ?? [];
955
+ this.log(`connect: vision auto-detection scanning ${providers.length} provider(s): ${providers.map((p) => p.id).join(", ") || "(none)"}`);
956
+ for (const p of providers) {
957
+ let models = [];
958
+ try {
959
+ models = (await llm.listModels?.(p.id)) ?? [];
960
+ }
961
+ catch (error) {
962
+ this.log(`connect: listModels(${p.id}) failed: ${String(error)}`);
963
+ continue;
964
+ }
965
+ this.log(`connect: provider ${p.id} advertises ${models.length} model(s)`);
966
+ for (const m of models) {
967
+ try {
968
+ const info = await llm.resolveModelInfo(p.id, m.id);
969
+ this.log(`connect: ${p.id}/${m.id} inputModalities=${JSON.stringify(info.inputModalities)}`);
970
+ if (info.inputModalities?.includes("image")) {
971
+ this.log(`connect: vision model selected: ${p.id}/${m.id}`);
972
+ return { provider: p.id, model: m.id };
973
+ }
974
+ }
975
+ catch (error) {
976
+ this.log(`connect: resolveModelInfo(${p.id}/${m.id}) failed: ${String(error)}`);
977
+ }
978
+ }
979
+ }
980
+ this.log("connect: no image-capable model found via auto-detection");
981
+ return null;
982
+ }
983
+ log(message) {
984
+ this.ctx.get("logger")?.info?.(message);
985
+ }
310
986
  readTodos() {
311
987
  const agent = this.agent;
312
988
  if (agent === undefined)
@@ -326,13 +1002,42 @@ export class AgentRunner {
326
1002
  this.bindings.put({ ...binding, ownerKey: msg.senderKey, lastActiveAt: Date.now() });
327
1003
  }
328
1004
  async sendSummary(msg, outcome) {
329
- const lines = [`**任务结束**:${REASON_LABELS[outcome.reason]}`];
1005
+ const lines = [this.t.taskEnded(this.reasonLabel(outcome.reason))];
330
1006
  if (outcome.code !== undefined)
331
- lines.push(`错误码:\`${outcome.code}\``);
1007
+ lines.push(this.t.errorCode(outcome.code));
332
1008
  if (outcome.message !== undefined)
333
- lines.push(`原因:${truncate(outcome.message)}`);
1009
+ lines.push(this.t.reasonMessage(truncate(outcome.message)));
334
1010
  if (outcome.text !== "")
335
- lines.push(`已产出:${truncate(outcome.text, 300)}`);
1011
+ lines.push(this.t.produced(truncate(outcome.text, 300)));
1012
+ const card = { markdown: lines.join("\n") };
1013
+ await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
1014
+ }
1015
+ /** Context-window usage at or above this percentage suggests compaction. */
1016
+ static COMPACT_THRESHOLD_PCT = 75;
1017
+ /**
1018
+ * Send a compact task-end card: the model used, input/output tokens, elapsed
1019
+ * time, and whether compaction is advisable given context-window usage.
1020
+ */
1021
+ async sendTurnStats(msg, outcome) {
1022
+ if (outcome.model === undefined && outcome.inputTokens === undefined && outcome.outputTokens === undefined)
1023
+ return;
1024
+ const lines = [
1025
+ this.t.taskStatsHeader(outcome.elapsedMs === undefined ? "—" : this.t.taskDuration(outcome.elapsedMs)),
1026
+ ];
1027
+ if (outcome.model !== undefined)
1028
+ lines.push(this.t.taskStatsModel(outcome.model));
1029
+ if (outcome.inputTokens !== undefined) {
1030
+ lines.push(this.t.taskStatsTokensIn(fmtTokens(outcome.inputTokens), outcome.cacheReadTokens !== undefined ? fmtTokens(outcome.cacheReadTokens) : undefined));
1031
+ }
1032
+ if (outcome.outputTokens !== undefined)
1033
+ lines.push(this.t.taskStatsTokensOut(fmtTokens(outcome.outputTokens)));
1034
+ if (outcome.steps !== undefined)
1035
+ lines.push(this.t.taskStatsSteps(outcome.steps));
1036
+ if (outcome.contextSize !== undefined && outcome.contextWindow !== undefined && outcome.contextWindow > 0) {
1037
+ const pct = Math.round((outcome.contextSize / outcome.contextWindow) * 100);
1038
+ lines.push(this.t.taskStatsContext(`${pct}`, fmtTokens(outcome.contextWindow)));
1039
+ lines.push(pct >= AgentRunner.COMPACT_THRESHOLD_PCT ? this.t.taskStatsCompactSuggest : this.t.taskStatsCompactOk);
1040
+ }
336
1041
  const card = { markdown: lines.join("\n") };
337
1042
  await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
338
1043
  }
@@ -341,17 +1046,17 @@ export class AgentRunner {
341
1046
  switch (command.kind) {
342
1047
  case "new": {
343
1048
  await this.newChat(msg);
344
- await this.adapter.sendText(target, "已开启新会话。");
1049
+ await this.adapter.sendText(target, this.t.newChatDone);
345
1050
  break;
346
1051
  }
347
1052
  case "clear": {
348
1053
  await this.clearChat(msg);
349
- await this.adapter.sendText(target, "会话已清空。");
1054
+ await this.adapter.sendText(target, this.t.chatCleared);
350
1055
  break;
351
1056
  }
352
1057
  case "stop": {
353
1058
  this.agent?.cancel({ kind: "user" });
354
- await this.adapter.sendText(target, "已请求停止当前任务。");
1059
+ await this.adapter.sendText(target, this.t.stopRequested);
355
1060
  break;
356
1061
  }
357
1062
  case "status": {
@@ -369,22 +1074,22 @@ export class AgentRunner {
369
1074
  }
370
1075
  const path = command.path;
371
1076
  if (!isAbsolute(path)) {
372
- await this.adapter.sendText(target, "请输入绝对路径,例如 `/dir D:\\projects\\my-app`。");
1077
+ await this.adapter.sendText(target, this.t.dirUsage);
373
1078
  break;
374
1079
  }
375
1080
  try {
376
1081
  if (!existsSync(path) || !statSync(path).isDirectory()) {
377
- await this.adapter.sendText(target, `目录不存在或不是文件夹:${path}`);
1082
+ await this.adapter.sendText(target, this.t.dirNotExists(path));
378
1083
  break;
379
1084
  }
380
1085
  }
381
1086
  catch {
382
- await this.adapter.sendText(target, `无法访问目录:${path}`);
1087
+ await this.adapter.sendText(target, this.t.dirUnreadable(path));
383
1088
  break;
384
1089
  }
385
1090
  this.workDir = path;
386
1091
  await this.newChat(msg);
387
- await this.adapter.sendText(target, `工作目录已切换为:\n${path}\n(已开启新会话)`);
1092
+ await this.adapter.sendText(target, this.t.dirSwitched(path));
388
1093
  break;
389
1094
  }
390
1095
  case "chat": {
@@ -405,7 +1110,7 @@ export class AgentRunner {
405
1110
  }
406
1111
  case "workspace": {
407
1112
  if (command.path === undefined) {
408
- await this.adapter.sendText(target, "用法:`/workspace <绝对路径>`,例如 `/workspace D:\\projects\\new-app`。");
1113
+ await this.adapter.sendText(target, this.t.workspaceUsage);
409
1114
  break;
410
1115
  }
411
1116
  await this.createWorkspace(command.path, target);
@@ -423,16 +1128,44 @@ export class AgentRunner {
423
1128
  await this.showGoals(target);
424
1129
  break;
425
1130
  }
1131
+ case "schedule": {
1132
+ await this.showSchedule(target);
1133
+ break;
1134
+ }
426
1135
  case "model": {
427
1136
  await this.openMenu(target, msg, "model", ["root"]);
428
1137
  break;
429
1138
  }
1139
+ case "notify": {
1140
+ await this.openNotifyPicker(target, msg);
1141
+ break;
1142
+ }
1143
+ case "progress": {
1144
+ await this.openProgressPicker(target, msg);
1145
+ break;
1146
+ }
430
1147
  case "workspaces": {
431
1148
  await this.showAllWorkspaces(target);
432
1149
  break;
433
1150
  }
1151
+ case "mirror": {
1152
+ await this.handleMirror(target, msg);
1153
+ break;
1154
+ }
1155
+ case "unlock": {
1156
+ await this.handleUnlock(target);
1157
+ break;
1158
+ }
1159
+ case "renew": {
1160
+ await this.handleRenewLock(target);
1161
+ break;
1162
+ }
1163
+ case "export": {
1164
+ await this.handleExport(target, command.format);
1165
+ break;
1166
+ }
434
1167
  case "help": {
435
- await this.adapter.sendText(target, HELP_TEXT);
1168
+ await this.adapter.sendText(target, helpText(this.t));
436
1169
  break;
437
1170
  }
438
1171
  default:
@@ -469,6 +1202,37 @@ export class AgentRunner {
469
1202
  sessions,
470
1203
  });
471
1204
  }
1205
+ /**
1206
+ * Automatically create a Web mirror for the session.
1207
+ * This enables DSH Web GUI to view and interact with the conversation
1208
+ * without requiring manual `/mirror` command.
1209
+ */
1210
+ autoCreateWebMirror(sessionId, ownerKey) {
1211
+ // Check if auto-mirror is enabled in config
1212
+ if (!this.config.autoMirror)
1213
+ return;
1214
+ // Only auto-create for Feishu channel (not for Web itself)
1215
+ if (this.channel !== "feishu")
1216
+ return;
1217
+ const binding = this.bindings.get(this.channel, this.chatKey);
1218
+ if (binding === undefined)
1219
+ return;
1220
+ // Check if mirror already exists
1221
+ if (binding.webMirrorSessionId !== undefined) {
1222
+ return; // Mirror already configured
1223
+ }
1224
+ // Auto-create mirror with default settings
1225
+ const defaultTimeoutMs = 5 * 60 * 1000; // 5 minutes
1226
+ this.bindings.put({
1227
+ ...binding,
1228
+ webMirrorSessionId: sessionId,
1229
+ lockOwner: "feishu", // Feishu owns the lock initially
1230
+ lockAcquiredAt: Date.now(),
1231
+ lockTimeoutMs: defaultTimeoutMs,
1232
+ lastActiveAt: Date.now(),
1233
+ });
1234
+ this.log(`connect: auto-created Web mirror for session ${sessionId}`);
1235
+ }
472
1236
  async newChat(msg) {
473
1237
  await this.disposeAgent();
474
1238
  const binding = this.bindings.get(this.channel, this.chatKey);
@@ -506,7 +1270,7 @@ export class AgentRunner {
506
1270
  seen.add(key);
507
1271
  out.push({ path, title: title || path });
508
1272
  };
509
- add(this.workDir, "当前目录");
1273
+ add(this.workDir, this.t.currentDir);
510
1274
  for (const w of registry?.list?.() ?? [])
511
1275
  add(w.path, w.title);
512
1276
  for (const p of this.config.workspaces)
@@ -516,14 +1280,22 @@ export class AgentRunner {
516
1280
  async openMenu(target, msg, menuId, stack = [], cardId) {
517
1281
  const items = await this.menuItems(menuId);
518
1282
  const options = items.map((i) => ({ id: i.id, label: i.label }));
519
- options.push({ id: "menu:exit", label: "❌ 退出" });
1283
+ options.push({ id: "menu:exit", label: this.t.menuExit });
520
1284
  if (stack.length > 0)
521
- options.push({ id: "menu:back", label: "🔙 返回" });
522
- const { choice, messageId } = await this.adapter.promptChoice(target, { title: MENU_TITLES[menuId], options }, cardId);
1285
+ options.push({ id: "menu:back", label: this.t.menuBack });
1286
+ // Determine columns per row based on menu type
1287
+ // Workspace and chat lists use 1 column (full width), others use 2 columns
1288
+ const columnsPerRow = (menuId === "workspace" || menuId === "chat") ? 1 : 2;
1289
+ const { choice, messageId } = await this.adapter.promptChoice(target, {
1290
+ title: this.menuTitle(menuId),
1291
+ options,
1292
+ columnsPerRow,
1293
+ ...(menuId === "root" ? { sections: this.rootMenuSections(), footer: this.t.rootMenuFooter } : {}),
1294
+ }, cardId);
523
1295
  if (choice === undefined)
524
1296
  return;
525
1297
  if (choice === "menu:exit") {
526
- await this.adapter.closeMenu(messageId, "菜单已关闭。");
1298
+ await this.adapter.closeMenu(messageId, this.t.menuClosed);
527
1299
  return;
528
1300
  }
529
1301
  if (choice === "menu:back") {
@@ -544,15 +1316,16 @@ export class AgentRunner {
544
1316
  switch (menuId) {
545
1317
  case "root":
546
1318
  return [
547
- { id: "workspace", label: "📁 切换工作目录", onSelect: (t, m, cardId) => this.openMenu(t, m, "workspace", ["root"], cardId) },
548
- { id: "chat", label: "💬 切换对话", onSelect: (t, m, cardId) => this.openMenu(t, m, "chat", ["root"], cardId) },
549
- { id: "status", label: "📊 查看状态", leaf: true, onSelect: async (t) => { await this.showStatus(t); } },
550
- { id: "task", label: "📋 查看任务", leaf: true, onSelect: async (t) => { await this.showTasks(t); } },
551
- { id: "history", label: "🗒️ 历史消息", leaf: true, onSelect: async (t) => { await this.showHistory(t, 10); } },
552
- { id: "goals", label: "🎯 目标", leaf: true, onSelect: async (t) => { await this.showGoals(t); } },
553
- { id: "compact", label: "🗜️ 压缩上下文", leaf: true, onSelect: async (t) => { await this.compact(t); } },
554
- { id: "plugins", label: "🔌 查看插件", leaf: true, onSelect: async (t) => { await this.showPlugins(t); } },
555
- { id: "settings", label: "⚙️ 设置", onSelect: (t, m, cardId) => this.openMenu(t, m, "settings", ["root"], cardId) },
1319
+ { id: "workspace", label: this.t.menuWorkspaceAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "workspace", ["root"], cardId) },
1320
+ { id: "chat", label: this.t.menuChatAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "chat", ["root"], cardId) },
1321
+ { id: "status", label: this.t.menuStatusAction, leaf: true, onSelect: async (t) => { await this.showStatus(t); } },
1322
+ { id: "task", label: this.t.menuTaskAction, leaf: true, onSelect: async (t) => { await this.showTasks(t); } },
1323
+ { id: "history", label: this.t.menuHistoryAction, leaf: true, onSelect: async (t) => { await this.showHistory(t, 10); } },
1324
+ { id: "goals", label: this.t.menuGoalsAction, leaf: true, onSelect: async (t) => { await this.showGoals(t); } },
1325
+ { id: "schedule", label: this.t.menuScheduleAction, leaf: true, onSelect: async (t) => { await this.showSchedule(t); } },
1326
+ { id: "compact", label: this.t.menuCompactAction, leaf: true, onSelect: async (t) => { await this.compact(t); } },
1327
+ { id: "plugins", label: this.t.menuPluginsAction, leaf: true, onSelect: async (t) => { await this.showPlugins(t); } },
1328
+ { id: "settings", label: this.t.menuSettingsAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "settings", ["root"], cardId) },
556
1329
  ];
557
1330
  case "workspace": {
558
1331
  const workspaces = this.listWorkspaces();
@@ -570,17 +1343,24 @@ export class AgentRunner {
570
1343
  const binding = this.bindings.get(this.channel, this.chatKey);
571
1344
  const sessions = binding?.sessions ?? [];
572
1345
  const active = binding?.sessionId;
573
- const items = sessions.map((s) => ({
574
- id: `session:${s.sessionId}`,
575
- label: `${s.sessionId === active ? "●" : "○"} ${s.title || s.sessionId}`,
576
- leaf: true,
577
- onSelect: async (t, m) => {
578
- await this.switchTo(s.sessionId, m.senderKey);
579
- },
580
- }));
1346
+ const hasWebMirror = binding?.webMirrorSessionId !== undefined;
1347
+ const items = sessions.map((s) => {
1348
+ // Check if this session is mirrored to Web
1349
+ const isMirrored = hasWebMirror && binding.webMirrorSessionId === s.sessionId;
1350
+ const mirrorIndicator = isMirrored ? ` ${this.t.webMirrorIndicator}` : "";
1351
+ const activeIndicator = s.sessionId === active ? "●" : "○";
1352
+ return {
1353
+ id: `session:${s.sessionId}`,
1354
+ label: `${activeIndicator} ${s.title || s.sessionId}${mirrorIndicator}`,
1355
+ leaf: true,
1356
+ onSelect: async (t, m) => {
1357
+ await this.switchTo(s.sessionId, m.senderKey);
1358
+ },
1359
+ };
1360
+ });
581
1361
  items.push({
582
1362
  id: "action:new",
583
- label: "➕ 新建对话",
1363
+ label: this.t.menuNewChat,
584
1364
  leaf: true,
585
1365
  onSelect: async (t, m) => {
586
1366
  await this.newChat(m);
@@ -590,63 +1370,141 @@ export class AgentRunner {
590
1370
  }
591
1371
  case "settings":
592
1372
  return [
593
- { id: "model", label: "🤖 切换模型", onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
594
- { id: "reasoning", label: "🧠 推理强度", onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
595
- { id: "notify", label: "🔔 通知设置", onSelect: (t, m, cardId) => this.openMenu(t, m, "notify", ["settings", "root"], cardId) },
596
- { id: "overview", label: "📄 配置总览", leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
1373
+ { id: "model", label: this.t.menuSettingsModel, onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
1374
+ { id: "reasoning", label: this.t.menuSettingsReasoning, onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
1375
+ { id: "notify", label: this.t.menuSettingsNotify, onSelect: (t, m, cardId) => this.openMenu(t, m, "notify", ["settings", "root"], cardId) },
1376
+ { id: "progress", label: this.t.menuSettingsProgress, onSelect: (t, m, cardId) => this.openMenu(t, m, "progress", ["settings", "root"], cardId) },
1377
+ { id: "language", label: this.t.menuSettingsLanguage, onSelect: (t, m, cardId) => this.openMenu(t, m, "language", ["settings", "root"], cardId) },
1378
+ { id: "overview", label: this.t.menuSettingsOverview, leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
597
1379
  ];
598
- case "model":
599
- return await this.modelMenuItems();
600
- case "reasoning":
601
- return this.reasoningMenuItems();
602
- case "notify":
1380
+ case "language":
603
1381
  return [
604
1382
  {
605
- id: "stream",
606
- label: `流式输出:${this.streamEnabled ? "开" : "关"}`,
1383
+ id: "lang:zh",
1384
+ label: `${this.language === "zh" ? "● " : ""}${this.t.languageZh}`,
607
1385
  leaf: true,
608
- onSelect: async () => {
609
- this.streamEnabled = !this.streamEnabled;
610
- },
1386
+ onSelect: async (t, m) => { await this.setLanguage("zh", t, m); },
611
1387
  },
612
1388
  {
613
- id: "summary",
614
- label: `结束摘要:${this.summaryEnabled ? "开" : "关"}`,
1389
+ id: "lang:en",
1390
+ label: `${this.language === "en" ? "● " : ""}${this.t.languageEn}`,
615
1391
  leaf: true,
616
- onSelect: async () => {
617
- this.summaryEnabled = !this.summaryEnabled;
618
- },
1392
+ onSelect: async (t, m) => { await this.setLanguage("en", t, m); },
619
1393
  },
620
1394
  ];
1395
+ case "model":
1396
+ return await this.modelMenuItems();
1397
+ case "reasoning":
1398
+ return this.reasoningMenuItems();
1399
+ case "notify":
1400
+ return [
1401
+ { id: "full", label: this.t.notifyFull },
1402
+ { id: "important", label: this.t.notifyImportant },
1403
+ { id: "result", label: this.t.notifyResult },
1404
+ ].map((o) => ({
1405
+ id: `notify:${o.id}`,
1406
+ label: `${this.notifyLevel === o.id ? "● " : ""}${o.label}`,
1407
+ leaf: true,
1408
+ onSelect: async (t, m) => {
1409
+ await this.setNotifyLevel(o.id, t, m);
1410
+ },
1411
+ }));
1412
+ case "progress": {
1413
+ const presets = [
1414
+ { id: "progress:0", label: this.t.progressOff, ms: 0 },
1415
+ { id: "progress:120000", label: this.t.progressMinutes(2), ms: 2 * 60_000 },
1416
+ { id: "progress:300000", label: this.t.progressMinutes(5), ms: 5 * 60_000 },
1417
+ { id: "progress:600000", label: this.t.progressMinutes(10), ms: 10 * 60_000 },
1418
+ { id: "progress:900000", label: this.t.progressMinutes(15), ms: 15 * 60_000 },
1419
+ { id: "progress:1800000", label: this.t.progressMinutes(30), ms: 30 * 60_000 },
1420
+ ];
1421
+ return presets.map((o) => ({
1422
+ id: o.id,
1423
+ label: `${this.progressTimeoutMs === o.ms ? "● " : ""}${o.label}`,
1424
+ leaf: true,
1425
+ onSelect: async (t, m) => {
1426
+ await this.setProgressTimeout(o.ms, t, m);
1427
+ },
1428
+ }));
1429
+ }
621
1430
  }
622
1431
  }
623
1432
  async showStatus(target) {
624
1433
  const agent = this.agent;
625
1434
  if (agent === undefined) {
626
- await this.adapter.sendText(target, `状态:无活动会话\n工作目录:${this.workDir}`);
1435
+ await this.adapter.sendText(target, this.t.statusNoSession(this.workDir));
627
1436
  return;
628
1437
  }
629
- const label = agent.status === "running" ? "🟢 运行中" : "⚪ 空闲";
1438
+ // Determine detailed execution state
1439
+ let statusLabel;
1440
+ if (agent.status === "running") {
1441
+ statusLabel = this.t.statusExecuting;
1442
+ }
1443
+ else if (this.queue.length > 0) {
1444
+ statusLabel = this.t.statusWaiting;
1445
+ }
1446
+ else {
1447
+ statusLabel = this.t.statusIdle;
1448
+ }
630
1449
  const model = `${agent.options.provider ?? "-"}/${agent.options.model ?? "-"}`;
631
- await this.adapter.sendText(target, [
632
- `状态:${label}`,
633
- `模型:${model}`,
634
- `工作目录:${this.workDir}`,
635
- `待处理消息:${this.queue.length}`,
636
- `会话:${agent.id}`,
637
- ].join("\n"));
1450
+ const lines = [
1451
+ this.t.statusField(statusLabel),
1452
+ this.t.modelField(model),
1453
+ this.t.workdirField(this.workDir),
1454
+ ];
1455
+ // Queue status with detail
1456
+ if (this.queue.length === 0) {
1457
+ lines.push(`${this.t.queuedField(0)} ${this.t.queueEmpty}`);
1458
+ }
1459
+ else {
1460
+ lines.push(`${this.t.queuedField(this.queue.length)} ${this.t.queueDetail(this.queue.length)}`);
1461
+ }
1462
+ // Last turn completion info
1463
+ const lastTurn = this.getLastTurnInfo(agent);
1464
+ if (lastTurn !== undefined) {
1465
+ lines.push(this.t.lastTurnReason(this.reasonLabel(lastTurn.reason)));
1466
+ lines.push(this.t.lastTurnTime(lastTurn.completedAt));
1467
+ }
1468
+ else {
1469
+ lines.push(this.t.noTurnHistory);
1470
+ }
1471
+ lines.push(this.t.sessionField(agent.id));
1472
+ const tokenMeter = this.ctx.get("tokenMeter");
1473
+ try {
1474
+ const m = tokenMeter?.measure?.(agent.session);
1475
+ if (m !== undefined)
1476
+ lines.push(this.t.contextField(m.totalTokens, m.surfaceTokens));
1477
+ }
1478
+ catch {
1479
+ // Token meter unavailable: skip.
1480
+ }
1481
+ await this.adapter.sendText(target, lines.join("\n"));
1482
+ }
1483
+ /** Extract the most recent turn's completion info from session events. */
1484
+ getLastTurnInfo(agent) {
1485
+ const events = agent.session.events;
1486
+ for (let i = events.length - 1; i >= 0; i--) {
1487
+ const event = events[i];
1488
+ if (event.type === "turn/end") {
1489
+ const reason = mapReason(event.data.reason.kind);
1490
+ // Use the event's timestamp if available, otherwise approximate
1491
+ const completedAt = new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
1492
+ return { reason, completedAt };
1493
+ }
1494
+ }
1495
+ return undefined;
638
1496
  }
639
1497
  async showTasks(target) {
640
1498
  const todos = this.readTodos();
641
1499
  if (todos.length === 0) {
642
- await this.adapter.sendText(target, "当前没有任务清单。");
1500
+ await this.adapter.sendText(target, this.t.noTodos);
643
1501
  return;
644
1502
  }
645
1503
  const lines = todos.map((todo) => {
646
1504
  const mark = todo.status === "completed" ? "✅" : todo.status === "in_progress" ? "🔄" : "⬜";
647
1505
  return `${mark} ${todo.content}`;
648
1506
  });
649
- await this.adapter.sendText(target, `当前任务(${todos.length} 项):\n${lines.join("\n")}`);
1507
+ await this.adapter.sendText(target, this.t.currentTodos(todos.length, lines.join("\n")));
650
1508
  }
651
1509
  async showSettings(target) {
652
1510
  const binding = this.bindings.get(this.channel, this.chatKey);
@@ -654,21 +1512,22 @@ export class AgentRunner {
654
1512
  const model = agent ? `${agent.options.provider ?? "-"}/${agent.options.model ?? "-"}` : "-";
655
1513
  const sel = this.defaultSelection();
656
1514
  const lines = [
657
- "⚙️ 设置:",
658
- `模型:${model}`,
659
- `推理强度:${sel.reasoningEffort ?? "默认"}`,
660
- `Agent 预设:${this.config.agentPreset ?? "默认"}`,
661
- `工作目录:${this.workDir}`,
662
- `工作区列表:${this.config.workspaces.length === 0 ? "(未配置)" : ""}`,
1515
+ this.t.settingsHeader,
1516
+ this.t.modelSetting(model),
1517
+ this.t.reasoningSetting(sel.reasoningEffort ?? this.t.effortDefault),
1518
+ this.t.agentPresetSetting(this.config.agentPreset ?? this.t.defaultLabel),
1519
+ this.t.workdirSetting(this.workDir),
1520
+ this.t.workspacesSetting + (this.config.workspaces.length === 0 ? this.t.notConfigured : ""),
663
1521
  ];
664
1522
  for (const w of this.config.workspaces)
665
1523
  lines.push(` - ${w}`);
666
- lines.push(`对话数:${binding?.sessions.length ?? 0}`);
667
- lines.push(`流式输出:${this.streamEnabled ? "开" : "关"}`);
668
- lines.push(`结束摘要:${this.summaryEnabled ? "开" : "关"}`);
669
- lines.push(`发送者白名单:${this.config.allowUsers.length === 0 ? "全部放行" : `${this.config.allowUsers.length} 人`}`);
670
- lines.push(`会话白名单:${this.config.allowChats.length === 0 ? "全部放行" : `${this.config.allowChats.length} 个`}`);
671
- lines.push(`状态目录:${this.config.stateDir ?? ".dsh-connect"}`);
1524
+ lines.push(this.t.chatCountField(binding?.sessions.length ?? 0));
1525
+ lines.push(this.t.streamLabel(this.streamEnabled));
1526
+ lines.push(this.t.summaryLabel(this.summaryEnabled));
1527
+ lines.push(this.t.progressSetting(this.progressTimeoutMs === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(this.progressTimeoutMs / 60_000))));
1528
+ lines.push(this.t.allowUsersField(this.config.allowUsers.length, this.config.allowUsers.length === 0));
1529
+ lines.push(this.t.allowChatsField(this.config.allowChats.length, this.config.allowChats.length === 0));
1530
+ lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
672
1531
  await this.adapter.sendText(target, lines.join("\n"));
673
1532
  }
674
1533
  async modelMenuItems() {
@@ -683,7 +1542,7 @@ export class AgentRunner {
683
1542
  },
684
1543
  }));
685
1544
  if (items.length === 0) {
686
- items.push({ id: "model:none", label: "(未发现可用模型)", onSelect: async () => { } });
1545
+ items.push({ id: "model:none", label: this.t.noModelsFound, onSelect: async () => { } });
687
1546
  }
688
1547
  return items;
689
1548
  }
@@ -700,7 +1559,7 @@ export class AgentRunner {
700
1559
  // Skip providers whose catalog cannot be listed.
701
1560
  }
702
1561
  for (const m of models) {
703
- out.push({ provider: p.id, model: m.id, name: `${m.name || m.id}(${p.name || p.id})` });
1562
+ out.push({ provider: p.id, model: m.id, name: this.t.modelName(m.name || m.id, p.name || p.id) });
704
1563
  }
705
1564
  }
706
1565
  return out;
@@ -718,14 +1577,14 @@ export class AgentRunner {
718
1577
  reasoningMenuItems() {
719
1578
  const current = this.defaultSelection();
720
1579
  const efforts = [
721
- { id: "low", name: "低 (low)" },
722
- { id: "medium", name: "中 (medium)" },
723
- { id: "high", name: "高 (high)" },
1580
+ { id: "low", name: this.t.effortLow },
1581
+ { id: "medium", name: this.t.effortMedium },
1582
+ { id: "high", name: this.t.effortHigh },
724
1583
  ];
725
1584
  const items = [
726
1585
  {
727
1586
  id: "effort:default",
728
- label: `${current.reasoningEffort === undefined ? "● " : ""}默认(跟随模型)`,
1587
+ label: `${current.reasoningEffort === undefined ? "● " : ""}${this.t.effortDefault}`,
729
1588
  leaf: true,
730
1589
  onSelect: async (t, m) => {
731
1590
  await this.setReasoning(undefined, m);
@@ -754,84 +1613,198 @@ export class AgentRunner {
754
1613
  });
755
1614
  await this.newChat(msg);
756
1615
  }
1616
+ /** Switch the user-facing language for this chat, persisted in its binding. */
1617
+ async setLanguage(lang, target, msg) {
1618
+ if (lang === this.language)
1619
+ return;
1620
+ this.language = lang;
1621
+ this.t = messages(lang);
1622
+ // Persist into the chat's binding (create one if the chat has no session yet).
1623
+ const binding = this.bindings.get(this.channel, this.chatKey);
1624
+ if (binding !== undefined) {
1625
+ this.bindings.put({ ...binding, language: lang, lastActiveAt: Date.now() });
1626
+ }
1627
+ else {
1628
+ this.bindings.put({
1629
+ channel: this.channel,
1630
+ chatKey: this.chatKey,
1631
+ chatType: this.chatType,
1632
+ sessionId: "",
1633
+ ownerKey: msg.senderKey,
1634
+ createdAt: Date.now(),
1635
+ lastActiveAt: Date.now(),
1636
+ language: lang,
1637
+ sessions: [],
1638
+ });
1639
+ }
1640
+ await this.adapter.sendText(target, this.t.languageSet(lang === "zh" ? this.t.languageZh : this.t.languageEn));
1641
+ }
1642
+ /**
1643
+ * Switch the notification level for this chat (persisted in its binding and
1644
+ * effective immediately for the next streaming reply).
1645
+ */
1646
+ async setNotifyLevel(level, target, msg) {
1647
+ this.notifyLevel = level;
1648
+ const binding = this.bindings.get(this.channel, this.chatKey);
1649
+ if (binding !== undefined) {
1650
+ this.bindings.put({ ...binding, notifyLevel: level, lastActiveAt: Date.now() });
1651
+ }
1652
+ else {
1653
+ this.bindings.put({
1654
+ channel: this.channel,
1655
+ chatKey: this.chatKey,
1656
+ chatType: this.chatType,
1657
+ sessionId: "",
1658
+ ownerKey: msg.senderKey,
1659
+ createdAt: Date.now(),
1660
+ lastActiveAt: Date.now(),
1661
+ notifyLevel: level,
1662
+ sessions: [],
1663
+ });
1664
+ }
1665
+ const label = level === "full" ? this.t.notifyFull : level === "important" ? this.t.notifyImportant : this.t.notifyResult;
1666
+ const desc = level === "full" ? this.t.notifyFullDesc : level === "important" ? this.t.notifyImportantDesc : this.t.notifyResultDesc;
1667
+ await this.adapter.sendText(target, this.t.notifySet(label, desc));
1668
+ }
1669
+ /** Show the three-level notification picker (used by the `/notify` command). */
1670
+ async openNotifyPicker(target, msg) {
1671
+ const options = [
1672
+ { id: "notify:full", label: this.t.notifyFull },
1673
+ { id: "notify:important", label: this.t.notifyImportant },
1674
+ { id: "notify:result", label: this.t.notifyResult },
1675
+ ];
1676
+ const { choice } = await this.adapter.promptChoice(target, { title: this.t.menuNotify, options });
1677
+ if (choice === undefined)
1678
+ return;
1679
+ const level = choice.startsWith("notify:") ? choice.slice("notify:".length) : undefined;
1680
+ if (level !== "full" && level !== "important" && level !== "result")
1681
+ return;
1682
+ await this.setNotifyLevel(level, target, msg);
1683
+ }
1684
+ /**
1685
+ * Switch the proactive progress-notice interval for this chat (persisted in
1686
+ * its binding and effective for the next turn; 0 disables the watchdog).
1687
+ */
1688
+ async setProgressTimeout(ms, target, msg) {
1689
+ this.progressTimeoutMs = ms;
1690
+ const binding = this.bindings.get(this.channel, this.chatKey);
1691
+ if (binding !== undefined) {
1692
+ this.bindings.put({ ...binding, progressTimeoutMs: ms, lastActiveAt: Date.now() });
1693
+ }
1694
+ else {
1695
+ this.bindings.put({
1696
+ channel: this.channel,
1697
+ chatKey: this.chatKey,
1698
+ chatType: this.chatType,
1699
+ sessionId: "",
1700
+ ownerKey: msg.senderKey,
1701
+ createdAt: Date.now(),
1702
+ lastActiveAt: Date.now(),
1703
+ progressTimeoutMs: ms,
1704
+ sessions: [],
1705
+ });
1706
+ }
1707
+ const label = ms === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(ms / 60_000));
1708
+ await this.adapter.sendText(target, this.t.progressSet(label));
1709
+ }
1710
+ /** Show the progress-interval picker (used by the `/progress` command). */
1711
+ async openProgressPicker(target, msg) {
1712
+ const options = [
1713
+ { id: "progress:0", label: this.t.progressOff },
1714
+ { id: "progress:120000", label: this.t.progressMinutes(2) },
1715
+ { id: "progress:300000", label: this.t.progressMinutes(5) },
1716
+ { id: "progress:600000", label: this.t.progressMinutes(10) },
1717
+ { id: "progress:900000", label: this.t.progressMinutes(15) },
1718
+ { id: "progress:1800000", label: this.t.progressMinutes(30) },
1719
+ ];
1720
+ const { choice } = await this.adapter.promptChoice(target, { title: this.t.progressMenuTitle, options });
1721
+ if (choice === undefined || !choice.startsWith("progress:"))
1722
+ return;
1723
+ const ms = Number(choice.slice("progress:".length));
1724
+ if (!Number.isInteger(ms) || ms < 0)
1725
+ return;
1726
+ await this.setProgressTimeout(ms, target, msg);
1727
+ }
757
1728
  async showPlugins(target) {
758
1729
  const loader = this.ctx.get("loader");
759
1730
  const all = [...(loader?.entries?.() ?? [])];
760
1731
  if (all.length === 0) {
761
- await this.adapter.sendText(target, "未获取到插件清单。");
1732
+ await this.adapter.sendText(target, this.t.noPlugins);
762
1733
  return;
763
1734
  }
764
1735
  const shown = all.slice(0, 50);
765
1736
  const lines = shown.map((e) => {
766
- const status = e.disabled ? "⛔ 禁用" : "✅ 启用";
1737
+ const status = e.disabled ? this.t.pluginDisabled : this.t.pluginEnabled;
767
1738
  return `${status} ${e.id}${e.options.name ? ` (${e.options.name})` : ""}`;
768
1739
  });
769
1740
  if (all.length > shown.length)
770
- lines.push(`… 共 ${all.length} 个,仅显示前 ${shown.length} 个`);
771
- await this.adapter.sendText(target, `插件(${all.length}):\n${lines.join("\n")}`);
1741
+ lines.push(this.t.pluginsTruncated(all.length, shown.length));
1742
+ await this.adapter.sendText(target, this.t.pluginsCount(all.length) + lines.join("\n"));
772
1743
  }
773
1744
  async createWorkspace(path, target) {
774
1745
  if (!isAbsolute(path)) {
775
- await this.adapter.sendText(target, "请输入绝对路径,例如 `/workspace D:\\projects\\new-app`。");
1746
+ await this.adapter.sendText(target, this.t.workspaceUsage);
776
1747
  return;
777
1748
  }
778
1749
  try {
779
1750
  if (!existsSync(path) || !statSync(path).isDirectory()) {
780
- await this.adapter.sendText(target, `目录不存在或不是文件夹:${path}\n(请先创建该目录,再执行 /workspace)`);
1751
+ await this.adapter.sendText(target, this.t.dirNotExistsHint(path));
781
1752
  return;
782
1753
  }
783
1754
  }
784
1755
  catch {
785
- await this.adapter.sendText(target, `无法访问目录:${path}`);
1756
+ await this.adapter.sendText(target, this.t.dirUnreadable(path));
786
1757
  return;
787
1758
  }
788
1759
  const registry = this.ctx.get("workspaceRegistry");
789
1760
  if (registry?.create === undefined) {
790
- await this.adapter.sendText(target, "工作区服务不可用。");
1761
+ await this.adapter.sendText(target, this.t.workspaceServiceUnavailable);
791
1762
  return;
792
1763
  }
793
1764
  try {
794
1765
  const ws = await registry.create(path);
795
- await this.adapter.sendText(target, `工作区已创建:\n${ws.title} (${ws.path})`);
1766
+ await this.adapter.sendText(target, this.t.workspaceCreated(ws.title, ws.path));
796
1767
  }
797
1768
  catch (error) {
798
- await this.adapter.sendText(target, `创建失败:${error instanceof Error ? error.message : String(error)}`);
1769
+ await this.adapter.sendText(target, this.t.createFailed(error instanceof Error ? error.message : String(error)));
799
1770
  }
800
1771
  }
801
1772
  async compact(target) {
802
1773
  const agent = this.agent;
803
1774
  if (agent === undefined) {
804
- await this.adapter.sendText(target, "当前没有活动会话,无法压缩。");
1775
+ await this.adapter.sendText(target, this.t.noActiveSessionCompact);
805
1776
  return;
806
1777
  }
807
1778
  if (agent.status !== "idle") {
808
- await this.adapter.sendText(target, "当前会话正在运行,请稍后再试(或先 /stop)。");
1779
+ await this.adapter.sendText(target, this.t.sessionRunning);
809
1780
  return;
810
1781
  }
811
1782
  const presets = this.ctx.get("agentPresets");
812
1783
  const compaction = presets?.serviceFor?.(agent, "compaction");
813
1784
  if (compaction?.compactNow === undefined) {
814
- await this.adapter.sendText(target, "压缩服务不可用(当前预设未挂载压缩后端)。");
1785
+ await this.adapter.sendText(target, this.t.compactionUnavailable);
815
1786
  return;
816
1787
  }
817
1788
  try {
1789
+ // Compaction can take a while — tell the user it started, then report the outcome.
1790
+ await this.adapter.sendText(target, this.t.compactStarted);
818
1791
  const result = await compaction.compactNow(agent, new AbortController().signal);
819
1792
  if (result === null) {
820
- await this.adapter.sendText(target, "没有可压缩的历史。");
1793
+ await this.adapter.sendText(target, this.t.nothingToCompact);
821
1794
  }
822
1795
  else {
823
- await this.adapter.sendText(target, "上下文已压缩。");
1796
+ await this.adapter.sendText(target, this.t.compactDone);
824
1797
  }
825
1798
  }
826
1799
  catch (error) {
827
1800
  const msg = error instanceof Error ? error.message : String(error);
828
- await this.adapter.sendText(target, `压缩失败:${truncate(msg)}`);
1801
+ await this.adapter.sendText(target, this.t.compactFailed(truncate(msg)));
829
1802
  }
830
1803
  }
831
1804
  async showHistory(target, limit) {
832
1805
  const agent = this.agent;
833
1806
  if (agent === undefined) {
834
- await this.adapter.sendText(target, "当前没有活动会话。");
1807
+ await this.adapter.sendText(target, this.t.noActiveSession);
835
1808
  return;
836
1809
  }
837
1810
  const events = agent.session.events;
@@ -850,51 +1823,414 @@ export class AgentRunner {
850
1823
  }
851
1824
  }
852
1825
  if (rows.length === 0) {
853
- await this.adapter.sendText(target, "会话里还没有消息。");
1826
+ await this.adapter.sendText(target, this.t.noMessagesYet);
854
1827
  return;
855
1828
  }
856
- await this.adapter.sendText(target, `最近 ${rows.length} 条消息:\n${rows.reverse().join("\n\n")}`);
1829
+ await this.adapter.sendText(target, this.t.recentMessages(rows.length, rows.reverse().join("\n\n")));
857
1830
  }
858
1831
  async showGoals(target) {
859
1832
  const agent = this.agent;
860
1833
  if (agent === undefined) {
861
- await this.adapter.sendText(target, "当前没有活动会话。");
1834
+ await this.adapter.sendText(target, this.t.noActiveSession);
862
1835
  return;
863
1836
  }
864
1837
  const goals = this.ctx.get("goals");
865
1838
  const view = goals?.get?.(agent);
866
1839
  if (view === undefined) {
867
- await this.adapter.sendText(target, "当前没有进行中的目标。");
1840
+ await this.adapter.sendText(target, this.t.noActiveGoals);
868
1841
  return;
869
1842
  }
870
- const phaseLabels = {
871
- active: "🟢 进行中",
872
- paused: "⏸️ 已暂停",
873
- blocked: "🚫 受阻",
874
- complete: "✅ 已完成",
875
- };
876
1843
  const lines = [
877
- `🎯 目标:${view.objective}`,
878
- `状态:${phaseLabels[view.phase] ?? view.phase}`,
879
- `轮次:${view.roundsStarted}/${view.maxGoalRounds}`,
880
- `自动续跑:${view.activation === "armed" ? "已武装" : "已解除"}`,
1844
+ this.t.goalObjective(view.objective),
1845
+ this.t.goalStatus(this.goalPhaseLabel(view.phase)),
1846
+ this.t.goalRounds(view.roundsStarted, view.maxGoalRounds),
1847
+ this.t.goalAutoRun(view.activation === "armed"),
881
1848
  ];
882
1849
  if (view.blockedReason !== undefined)
883
- lines.push(`受阻原因:${view.blockedReason.message}`);
1850
+ lines.push(this.t.goalBlockedReason(view.blockedReason.message));
884
1851
  await this.adapter.sendText(target, lines.join("\n"));
885
1852
  }
1853
+ goalPhaseLabel(phase) {
1854
+ switch (phase) {
1855
+ case "active":
1856
+ return this.t.goalPhaseActive;
1857
+ case "paused":
1858
+ return this.t.goalPhasePaused;
1859
+ case "blocked":
1860
+ return this.t.goalPhaseBlocked;
1861
+ case "complete":
1862
+ return this.t.goalPhaseComplete;
1863
+ default:
1864
+ return phase;
1865
+ }
1866
+ }
1867
+ async showSchedule(target) {
1868
+ const agent = this.agent;
1869
+ if (agent === undefined) {
1870
+ await this.adapter.sendText(target, this.t.noActiveSession);
1871
+ return;
1872
+ }
1873
+ const reminders = foldReminders(agent.session.events);
1874
+ if (reminders.length === 0) {
1875
+ await this.adapter.sendText(target, this.t.noReminders);
1876
+ return;
1877
+ }
1878
+ const now = Date.now();
1879
+ const locale = this.config.language === "en" ? "en-US" : "zh-CN";
1880
+ const lines = reminders.map((r) => {
1881
+ const due = Date.parse(r.scheduledAt) <= now;
1882
+ const state = due ? this.t.reminderDue : this.t.reminderClock;
1883
+ const kind = r.kind === "every" ? this.t.reminderEvery(Math.round((r.everySeconds ?? 0) / 60)) : this.t.reminderOnce;
1884
+ const when = new Date(Date.parse(r.scheduledAt)).toLocaleString(locale);
1885
+ return this.t.reminderLine(state, r.id, r.prompt, kind, when);
1886
+ });
1887
+ await this.adapter.sendText(target, this.t.remindersCount(reminders.length, lines.join("\n")));
1888
+ }
886
1889
  async showAllWorkspaces(target) {
887
1890
  const registry = this.ctx.get("workspaceRegistry");
888
1891
  const all = registry?.list?.() ?? [];
889
1892
  if (all.length === 0) {
890
- await this.adapter.sendText(target, "还没有工作区。可用 `/workspace <绝对路径>` 新建。");
1893
+ await this.adapter.sendText(target, this.t.noWorkspaces);
891
1894
  return;
892
1895
  }
893
1896
  const lines = all.map((w, i) => {
894
- const sess = w.sessionIds !== undefined ? ` · ${w.sessionIds.length} 会话` : "";
1897
+ const sess = w.sessionIds !== undefined ? this.t.workspaceSessions(w.sessionIds.length) : "";
895
1898
  return `${i + 1}. ${w.title}${w.title !== w.path ? ` (${w.path})` : ""}${sess}`;
896
1899
  });
897
- await this.adapter.sendText(target, `工作区(${all.length}):\n${lines.join("\n")}`);
1900
+ await this.adapter.sendText(target, this.t.workspacesCount(all.length, lines.join("\n")));
1901
+ }
1902
+ /**
1903
+ * Create or show Web mirror session for this chat.
1904
+ * The mirror shares the same DSH session but enforces mutual exclusion:
1905
+ * when Feishu is executing a task, Web is read-only, and vice versa.
1906
+ */
1907
+ async handleMirror(target, msg) {
1908
+ const binding = this.bindings.get(this.channel, this.chatKey);
1909
+ // Check timeout before showing status
1910
+ this.checkAndReleaseTimeoutLock();
1911
+ // Reload binding after potential timeout release
1912
+ const updatedBinding = this.bindings.get(this.channel, this.chatKey);
1913
+ // Check if mirror already exists
1914
+ if (updatedBinding?.webMirrorSessionId !== undefined) {
1915
+ const queuedCount = updatedBinding.queuedMessages?.length ?? 0;
1916
+ const timeoutMin = updatedBinding.lockTimeoutMs ? Math.round(updatedBinding.lockTimeoutMs / 60000) : undefined;
1917
+ await this.adapter.sendText(target, this.t.mirrorStatus(updatedBinding.webMirrorSessionId, updatedBinding.lockOwner, timeoutMin, queuedCount));
1918
+ return;
1919
+ }
1920
+ // Create new mirror session (reuse current session)
1921
+ const agent = this.agent;
1922
+ if (agent === undefined) {
1923
+ await this.adapter.sendText(target, this.t.mirrorNotConfigured);
1924
+ return;
1925
+ }
1926
+ // The mirror uses the same session ID - it's a shared view
1927
+ const mirrorSessionId = agent.id;
1928
+ // Use custom timeout if provided, otherwise default to 5 minutes
1929
+ const command = parseCommand(msg.text);
1930
+ const customTimeoutMin = command.kind === "mirror" ? command.timeoutMin : undefined;
1931
+ const defaultTimeoutMs = (customTimeoutMin ?? 5) * 60 * 1000;
1932
+ // Update binding with mirror info and set initial lock to feishu
1933
+ const newBinding = updatedBinding !== undefined
1934
+ ? {
1935
+ ...updatedBinding,
1936
+ webMirrorSessionId: mirrorSessionId,
1937
+ lockOwner: "feishu",
1938
+ lockAcquiredAt: Date.now(),
1939
+ lockTimeoutMs: defaultTimeoutMs,
1940
+ }
1941
+ : {
1942
+ channel: this.channel,
1943
+ chatKey: this.chatKey,
1944
+ chatType: this.chatType,
1945
+ sessionId: mirrorSessionId,
1946
+ ownerKey: msg.senderKey,
1947
+ createdAt: Date.now(),
1948
+ lastActiveAt: Date.now(),
1949
+ webMirrorSessionId: mirrorSessionId,
1950
+ lockOwner: "feishu",
1951
+ lockAcquiredAt: Date.now(),
1952
+ lockTimeoutMs: defaultTimeoutMs,
1953
+ sessions: [],
1954
+ };
1955
+ this.bindings.put(newBinding);
1956
+ const timeoutMsg = customTimeoutMin !== undefined ? `(超时时间:${customTimeoutMin} 分钟)` : "";
1957
+ await this.adapter.sendText(target, this.t.mirrorCreated(mirrorSessionId) + timeoutMsg);
1958
+ }
1959
+ /**
1960
+ * Manually release session lock.
1961
+ */
1962
+ async handleUnlock(target) {
1963
+ // Check timeout first
1964
+ this.checkAndReleaseTimeoutLock();
1965
+ const binding = this.bindings.get(this.channel, this.chatKey);
1966
+ if (binding?.lockOwner === undefined) {
1967
+ await this.adapter.sendText(target, this.t.unlockNoLock);
1968
+ return;
1969
+ }
1970
+ // Process any queued messages before releasing
1971
+ const queuedCount = binding.queuedMessages?.length ?? 0;
1972
+ this.releaseLock();
1973
+ let message = this.t.unlockSuccess;
1974
+ if (queuedCount > 0) {
1975
+ message += `\n${this.t.queueProcessed(queuedCount)}`;
1976
+ }
1977
+ await this.adapter.sendText(target, message);
1978
+ }
1979
+ /**
1980
+ * Renew the current session lock timeout.
1981
+ * Extends the lock by another full timeout period from now.
1982
+ */
1983
+ async handleRenewLock(target) {
1984
+ const binding = this.bindings.get(this.channel, this.chatKey);
1985
+ if (binding?.lockOwner === undefined) {
1986
+ await this.adapter.sendText(target, this.t.unlockNoLock);
1987
+ return;
1988
+ }
1989
+ const currentChannel = this.channel === "feishu" ? "feishu" : "web";
1990
+ if (binding.lockOwner !== currentChannel) {
1991
+ await this.adapter.sendText(target, this.t.sessionLockedBy(binding.lockOwner));
1992
+ return;
1993
+ }
1994
+ // Renew the lock by resetting the acquisition time
1995
+ const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000;
1996
+ this.bindings.put({
1997
+ ...binding,
1998
+ lockAcquiredAt: Date.now(),
1999
+ lastActiveAt: Date.now()
2000
+ });
2001
+ const timeoutMin = Math.round(timeoutMs / 60000);
2002
+ await this.adapter.sendText(target, this.t.lockRenewed(timeoutMin));
2003
+ }
2004
+ /**
2005
+ * Export conversation history to Markdown or PDF.
2006
+ */
2007
+ async handleExport(target, format) {
2008
+ const agent = this.agent;
2009
+ if (agent === undefined) {
2010
+ await this.adapter.sendText(target, this.t.exportNoSession);
2011
+ return;
2012
+ }
2013
+ // Default to markdown if not specified
2014
+ const exportFormat = format ?? "markdown";
2015
+ if (exportFormat === "pdf") {
2016
+ await this.adapter.sendText(target, this.t.exportPdfNotSupported);
2017
+ return;
2018
+ }
2019
+ try {
2020
+ const markdown = this.generateMarkdown(agent);
2021
+ const fileName = `conversation-${agent.id}-${Date.now()}.md`;
2022
+ const filePath = join(this.workDir, fileName);
2023
+ // Write to file
2024
+ writeFileSync(filePath, markdown, "utf8");
2025
+ await this.adapter.sendText(target, this.t.exportMarkdown(filePath));
2026
+ }
2027
+ catch (error) {
2028
+ const detail = error instanceof Error ? error.message : String(error);
2029
+ await this.adapter.sendText(target, this.t.exportFailed(truncate(detail)));
2030
+ }
2031
+ }
2032
+ /**
2033
+ * Generate Markdown representation of the conversation history.
2034
+ */
2035
+ generateMarkdown(agent) {
2036
+ const events = agent.session.events;
2037
+ const lines = [];
2038
+ // Header
2039
+ lines.push(`# Conversation History`);
2040
+ lines.push(``);
2041
+ lines.push(`- **Session ID**: ${agent.id}`);
2042
+ lines.push(`- **Exported At**: ${new Date().toISOString()}`);
2043
+ lines.push(`- **Total Events**: ${events.length}`);
2044
+ lines.push(``);
2045
+ lines.push(`---`);
2046
+ lines.push(``);
2047
+ // Messages
2048
+ for (const event of events) {
2049
+ if (event.type === "user/message") {
2050
+ const text = textOf(event.data);
2051
+ if (text) {
2052
+ lines.push(`**👤 User**:`);
2053
+ lines.push(``);
2054
+ lines.push(text);
2055
+ lines.push(``);
2056
+ }
2057
+ }
2058
+ else if (event.type === "assistant/message") {
2059
+ const text = textOf(event.data.message);
2060
+ if (text) {
2061
+ lines.push(`**🤖 Assistant**:`);
2062
+ lines.push(``);
2063
+ lines.push(text);
2064
+ lines.push(``);
2065
+ }
2066
+ }
2067
+ else if (event.type === "turn/start") {
2068
+ lines.push(`*Turn started at ${new Date().toLocaleTimeString()}*`);
2069
+ lines.push(``);
2070
+ }
2071
+ else if (event.type === "turn/end") {
2072
+ const reason = mapReason(event.data.reason.kind);
2073
+ lines.push(`*Turn ended: ${this.reasonLabel(reason)}*`);
2074
+ lines.push(``);
2075
+ lines.push(`---`);
2076
+ lines.push(``);
2077
+ }
2078
+ }
2079
+ return lines.join("\n");
2080
+ }
2081
+ /**
2082
+ * Check if the current channel has write access to the session.
2083
+ * Returns true if allowed to send messages, false if read-only.
2084
+ */
2085
+ canWrite(channel) {
2086
+ const binding = this.bindings.get(this.channel, this.chatKey);
2087
+ if (binding?.lockOwner === undefined)
2088
+ return true; // No lock, free access
2089
+ // Check timeout
2090
+ if (this.isLockTimedOut(binding)) {
2091
+ this.releaseTimeoutLock(binding);
2092
+ return true;
2093
+ }
2094
+ return binding.lockOwner === channel;
2095
+ }
2096
+ /**
2097
+ * Acquire session lock for the given channel.
2098
+ * Returns true if lock acquired, false if already locked by another channel.
2099
+ */
2100
+ acquireLock(channel) {
2101
+ const binding = this.bindings.get(this.channel, this.chatKey);
2102
+ if (binding === undefined)
2103
+ return true;
2104
+ // Check timeout before acquiring
2105
+ if (this.isLockTimedOut(binding)) {
2106
+ this.releaseTimeoutLock(binding);
2107
+ // After timeout release, try to acquire again
2108
+ const refreshed = this.bindings.get(this.channel, this.chatKey);
2109
+ if (refreshed !== undefined && (refreshed.lockOwner === undefined || refreshed.lockOwner === channel)) {
2110
+ const defaultTimeoutMs = refreshed.lockTimeoutMs ?? 5 * 60 * 1000;
2111
+ this.bindings.put({
2112
+ ...refreshed,
2113
+ lockOwner: channel,
2114
+ lockAcquiredAt: Date.now(),
2115
+ lockTimeoutMs: defaultTimeoutMs,
2116
+ lastActiveAt: Date.now()
2117
+ });
2118
+ return true;
2119
+ }
2120
+ }
2121
+ if (binding.lockOwner === undefined || binding.lockOwner === channel) {
2122
+ // Lock is free or already owned by this channel
2123
+ const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000;
2124
+ this.bindings.put({
2125
+ ...binding,
2126
+ lockOwner: channel,
2127
+ lockAcquiredAt: Date.now(),
2128
+ lockTimeoutMs: timeoutMs,
2129
+ lastActiveAt: Date.now()
2130
+ });
2131
+ return true;
2132
+ }
2133
+ // Locked by other channel - queue the message if from Web
2134
+ return false;
2135
+ }
2136
+ /**
2137
+ * Queue a message for later execution when lock is released.
2138
+ * Returns the position in queue.
2139
+ */
2140
+ queueMessage(msg) {
2141
+ const binding = this.bindings.get(this.channel, this.chatKey);
2142
+ if (binding === undefined)
2143
+ return -1;
2144
+ const queued = binding.queuedMessages ?? [];
2145
+ queued.push({
2146
+ text: msg.text,
2147
+ senderKey: msg.senderKey,
2148
+ timestamp: Date.now(),
2149
+ replyRef: msg.replyRef,
2150
+ images: msg.images,
2151
+ files: msg.files,
2152
+ });
2153
+ this.bindings.put({ ...binding, queuedMessages: queued });
2154
+ return queued.length;
2155
+ }
2156
+ /**
2157
+ * Process all queued messages after lock release.
2158
+ * Re-enqueues them into the runner's message queue for actual execution.
2159
+ */
2160
+ async processQueuedMessages() {
2161
+ const binding = this.bindings.get(this.channel, this.chatKey);
2162
+ if (binding === undefined || !binding.queuedMessages || binding.queuedMessages.length === 0) {
2163
+ return;
2164
+ }
2165
+ const messages = [...binding.queuedMessages];
2166
+ const count = messages.length;
2167
+ // Clear queue immediately to prevent duplicate processing
2168
+ this.bindings.put({ ...binding, queuedMessages: [] });
2169
+ // Notify about queued messages being processed
2170
+ if (count > 0) {
2171
+ const adapter = this.adapters?.get(this.channel);
2172
+ if (adapter) {
2173
+ await adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.queueProcessed(count)).catch(() => undefined);
2174
+ }
2175
+ // Re-enqueue messages into the runner's processing queue
2176
+ // Construct minimal InboundMessage objects from queued data
2177
+ for (const queued of messages) {
2178
+ const inboundMsg = {
2179
+ channel: "web", // Queued messages are from Web channel
2180
+ chatKey: this.chatKey,
2181
+ chatType: this.chatType,
2182
+ senderKey: queued.senderKey,
2183
+ text: queued.text,
2184
+ replyRef: queued.replyRef,
2185
+ images: queued.images,
2186
+ files: queued.files,
2187
+ };
2188
+ // Add to the runner's internal queue for processing
2189
+ this.queue.push(inboundMsg);
2190
+ }
2191
+ // Trigger processing if not already running
2192
+ void this.drain();
2193
+ }
2194
+ }
2195
+ /** Release session lock. */
2196
+ releaseLock() {
2197
+ const binding = this.bindings.get(this.channel, this.chatKey);
2198
+ if (binding !== undefined && binding.lockOwner !== undefined) {
2199
+ // Process queued messages before releasing
2200
+ void this.processQueuedMessages();
2201
+ this.bindings.put({ ...binding, lockOwner: undefined, lockAcquiredAt: undefined });
2202
+ }
2203
+ }
2204
+ /** Check if lock has timed out. */
2205
+ isLockTimedOut(binding) {
2206
+ if (binding.lockOwner === undefined || binding.lockAcquiredAt === undefined) {
2207
+ return false;
2208
+ }
2209
+ const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000; // Default 5 minutes
2210
+ const elapsed = Date.now() - binding.lockAcquiredAt;
2211
+ return elapsed > timeoutMs;
2212
+ }
2213
+ /** Check and release timed-out locks. */
2214
+ checkAndReleaseTimeoutLock() {
2215
+ const binding = this.bindings.get(this.channel, this.chatKey);
2216
+ if (binding && this.isLockTimedOut(binding)) {
2217
+ this.releaseTimeoutLock(binding);
2218
+ }
2219
+ }
2220
+ /** Release a timed-out lock and notify. */
2221
+ releaseTimeoutLock(binding) {
2222
+ const timeoutMin = Math.round((binding.lockTimeoutMs ?? 5 * 60 * 1000) / 60000);
2223
+ const lockedBy = binding.lockOwner;
2224
+ this.bindings.put({
2225
+ ...binding,
2226
+ lockOwner: undefined,
2227
+ lockAcquiredAt: undefined
2228
+ });
2229
+ // Notify users about timeout release
2230
+ const adapter = this.adapters?.get(this.channel);
2231
+ if (adapter && lockedBy) {
2232
+ void adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.lockTimeoutReleased(timeoutMin)).catch(() => undefined);
2233
+ }
898
2234
  }
899
2235
  }
900
2236
  //# sourceMappingURL=runner.js.map