dsh-connect 0.5.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,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,31 @@ 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
+ /** Thousands-separated token count for display. */
280
+ function fmtTokens(n) {
281
+ return n.toLocaleString("en-US");
282
+ }
78
283
  function textOf(message) {
79
284
  if (message === undefined)
80
285
  return "";
@@ -133,6 +338,8 @@ function detectImageMediaType(buf) {
133
338
  return "image/gif";
134
339
  return "image/png";
135
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;
136
343
  export class AgentRunner {
137
344
  ctx;
138
345
  config;
@@ -141,6 +348,7 @@ export class AgentRunner {
141
348
  chatType;
142
349
  adapter;
143
350
  bindings;
351
+ adapters;
144
352
  queue = [];
145
353
  running = false;
146
354
  agent;
@@ -149,8 +357,11 @@ export class AgentRunner {
149
357
  workDir;
150
358
  streamEnabled = true;
151
359
  summaryEnabled = true;
360
+ language;
361
+ notifyLevel;
362
+ progressTimeoutMs;
152
363
  t;
153
- constructor(ctx, config, channel, chatKey, chatType, adapter, bindings) {
364
+ constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters) {
154
365
  this.ctx = ctx;
155
366
  this.config = config;
156
367
  this.channel = channel;
@@ -158,8 +369,14 @@ export class AgentRunner {
158
369
  this.chatType = chatType;
159
370
  this.adapter = adapter;
160
371
  this.bindings = bindings;
372
+ this.adapters = adapters;
161
373
  this.workDir = config.workDir ?? this.resolveDefaultWorkDir();
162
- this.t = messages(config.language);
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);
163
380
  }
164
381
  menuTitle(menuId) {
165
382
  switch (menuId) {
@@ -177,14 +394,18 @@ export class AgentRunner {
177
394
  return this.t.menuReasoning;
178
395
  case "notify":
179
396
  return this.t.menuNotify;
397
+ case "language":
398
+ return this.t.menuSettingsLanguage;
399
+ case "progress":
400
+ return this.t.progressMenuTitle;
180
401
  }
181
402
  }
182
403
  rootMenuSections() {
183
404
  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"] },
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 },
188
409
  ];
189
410
  }
190
411
  reasonLabel(reason) {
@@ -231,14 +452,37 @@ export class AgentRunner {
231
452
  return;
232
453
  if (event.type === "assistant/chunk") {
233
454
  const chunk = event.data.chunk;
234
- if (chunk.type === "text-delta") {
235
- turn.chunks.push(chunk.text);
236
- 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;
458
+ }
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);
237
472
  }
238
- else if (chunk.type === "reasoning-delta" && !turn.reasoning) {
239
- turn.reasoning = true;
240
- turn.chunks.push(this.t.thinkingHint);
473
+ else {
474
+ turn.milestone = this.t.toolCalling(name, undefined);
241
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);
242
486
  }
243
487
  }
244
488
  target(msg) {
@@ -265,15 +509,48 @@ export class AgentRunner {
265
509
  }
266
510
  }
267
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
+ }
268
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);
269
540
  const agent = await this.ensureAgent(msg);
270
541
  const outcome = await this.driveAgent(agent, msg);
271
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();
272
547
  if (outcome.reason !== "completed" && this.summaryEnabled) {
273
548
  await this.sendSummary(msg, outcome);
274
549
  }
275
550
  }
276
551
  catch (error) {
552
+ // Release lock even on error
553
+ this.releaseLock();
277
554
  const detail = error instanceof Error ? error.message : String(error);
278
555
  await this.adapter
279
556
  .sendText(this.target(msg), this.t.processingFailed(truncate(detail)))
@@ -286,6 +563,10 @@ export class AgentRunner {
286
563
  const live = binding === undefined ? undefined : this.agents.get(SessionId(binding.sessionId));
287
564
  if (live !== undefined) {
288
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);
289
570
  return live;
290
571
  }
291
572
  const selection = this.defaultSelection();
@@ -300,6 +581,10 @@ export class AgentRunner {
300
581
  this.handle = handle;
301
582
  this.agent = handle.agent;
302
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);
303
588
  return handle.agent;
304
589
  }
305
590
  catch (error) {
@@ -320,8 +605,29 @@ export class AgentRunner {
320
605
  this.agent = handle.agent;
321
606
  this.watchAgent(handle.agent);
322
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));
323
612
  return handle.agent;
324
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
+ }
325
631
  // Service lookups must go through ctx.get(): property access (ctx.agents) is
326
632
  // fiber-scoped and rejected from the async Feishu callback with "without inject".
327
633
  get agents() {
@@ -334,26 +640,46 @@ export class AgentRunner {
334
640
  const service = this.ctx.get("agentDefaultModel");
335
641
  return service?.currentSelection?.() ?? { provider: "", model: "" };
336
642
  }
337
- 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) {
338
659
  const presets = this.ctx.get("agentPresets");
339
660
  if (presets === undefined) {
340
661
  return {
341
- setup: (agentCtx) => {
342
- installModelSelection(agentCtx, { current: selection, assembled: undefined });
343
- },
662
+ setup: () => undefined,
344
663
  };
345
664
  }
346
665
  const resolved = await presets.resolve(this.config.agentPreset);
347
666
  return {
348
667
  agentPreset: resolved.id,
349
668
  setup: async (agentCtx) => {
350
- installModelSelection(agentCtx, { current: selection, assembled: undefined });
351
669
  await presets.mount(agentCtx, resolved.id);
352
670
  },
353
671
  };
354
672
  }
673
+ /** Agents this runner has already attached a `session/event` listener to. */
674
+ watchedAgents = new WeakSet();
355
675
  watchAgent(agent) {
356
- // 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);
357
683
  agent.ctx.on("session/event", (session, event) => {
358
684
  this.onSessionEvent(session, event);
359
685
  });
@@ -375,13 +701,67 @@ export class AgentRunner {
375
701
  return outcome;
376
702
  }
377
703
  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;
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
+ }
385
765
  const outcome = summarizeTurn(agent.session.events, firstSeq);
386
766
  const text = outcome.text !== "" ? outcome.text : this.turn.lastText;
387
767
  this.turn = undefined;
@@ -418,6 +798,19 @@ export class AgentRunner {
418
798
  const description = await this.describeImages(paths);
419
799
  const note = this.t.imagesStaged(locationText, description);
420
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
+ }
421
814
  return content;
422
815
  }
423
816
  /** Copy images into `<workDir>/.dsh-connect-images` so the agent's tools can reach them. */
@@ -442,6 +835,28 @@ export class AgentRunner {
442
835
  }
443
836
  return staged;
444
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
+ }
445
860
  async imageBlocks(paths) {
446
861
  const attachments = this.ctx.get("attachments");
447
862
  if (attachments?.saveImage === undefined)
@@ -597,6 +1012,35 @@ export class AgentRunner {
597
1012
  const card = { markdown: lines.join("\n") };
598
1013
  await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
599
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
+ }
1041
+ const card = { markdown: lines.join("\n") };
1042
+ await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
1043
+ }
600
1044
  async handleCommand(command, msg) {
601
1045
  const target = this.target(msg);
602
1046
  switch (command.kind) {
@@ -692,10 +1136,34 @@ export class AgentRunner {
692
1136
  await this.openMenu(target, msg, "model", ["root"]);
693
1137
  break;
694
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
+ }
695
1147
  case "workspaces": {
696
1148
  await this.showAllWorkspaces(target);
697
1149
  break;
698
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
+ }
699
1167
  case "help": {
700
1168
  await this.adapter.sendText(target, helpText(this.t));
701
1169
  break;
@@ -734,6 +1202,37 @@ export class AgentRunner {
734
1202
  sessions,
735
1203
  });
736
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
+ }
737
1236
  async newChat(msg) {
738
1237
  await this.disposeAgent();
739
1238
  const binding = this.bindings.get(this.channel, this.chatKey);
@@ -784,9 +1283,13 @@ export class AgentRunner {
784
1283
  options.push({ id: "menu:exit", label: this.t.menuExit });
785
1284
  if (stack.length > 0)
786
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;
787
1289
  const { choice, messageId } = await this.adapter.promptChoice(target, {
788
1290
  title: this.menuTitle(menuId),
789
1291
  options,
1292
+ columnsPerRow,
790
1293
  ...(menuId === "root" ? { sections: this.rootMenuSections(), footer: this.t.rootMenuFooter } : {}),
791
1294
  }, cardId);
792
1295
  if (choice === undefined)
@@ -840,14 +1343,21 @@ export class AgentRunner {
840
1343
  const binding = this.bindings.get(this.channel, this.chatKey);
841
1344
  const sessions = binding?.sessions ?? [];
842
1345
  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
- }));
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
+ });
851
1361
  items.push({
852
1362
  id: "action:new",
853
1363
  label: this.t.menuNewChat,
@@ -863,31 +1373,60 @@ export class AgentRunner {
863
1373
  { id: "model", label: this.t.menuSettingsModel, onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
864
1374
  { id: "reasoning", label: this.t.menuSettingsReasoning, onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
865
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) },
866
1378
  { id: "overview", label: this.t.menuSettingsOverview, leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
867
1379
  ];
868
- case "model":
869
- return await this.modelMenuItems();
870
- case "reasoning":
871
- return this.reasoningMenuItems();
872
- case "notify":
1380
+ case "language":
873
1381
  return [
874
1382
  {
875
- id: "stream",
876
- label: this.t.streamLabel(this.streamEnabled),
1383
+ id: "lang:zh",
1384
+ label: `${this.language === "zh" ? "● " : ""}${this.t.languageZh}`,
877
1385
  leaf: true,
878
- onSelect: async () => {
879
- this.streamEnabled = !this.streamEnabled;
880
- },
1386
+ onSelect: async (t, m) => { await this.setLanguage("zh", t, m); },
881
1387
  },
882
1388
  {
883
- id: "summary",
884
- label: this.t.summaryLabel(this.summaryEnabled),
1389
+ id: "lang:en",
1390
+ label: `${this.language === "en" ? "● " : ""}${this.t.languageEn}`,
885
1391
  leaf: true,
886
- onSelect: async () => {
887
- this.summaryEnabled = !this.summaryEnabled;
888
- },
1392
+ onSelect: async (t, m) => { await this.setLanguage("en", t, m); },
889
1393
  },
890
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
+ }
891
1430
  }
892
1431
  }
893
1432
  async showStatus(target) {
@@ -896,15 +1435,40 @@ export class AgentRunner {
896
1435
  await this.adapter.sendText(target, this.t.statusNoSession(this.workDir));
897
1436
  return;
898
1437
  }
899
- const label = agent.status === "running" ? this.t.statusRunning : this.t.statusIdle;
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
+ }
900
1449
  const model = `${agent.options.provider ?? "-"}/${agent.options.model ?? "-"}`;
901
1450
  const lines = [
902
- this.t.statusField(label),
1451
+ this.t.statusField(statusLabel),
903
1452
  this.t.modelField(model),
904
1453
  this.t.workdirField(this.workDir),
905
- this.t.queuedField(this.queue.length),
906
- this.t.sessionField(agent.id),
907
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));
908
1472
  const tokenMeter = this.ctx.get("tokenMeter");
909
1473
  try {
910
1474
  const m = tokenMeter?.measure?.(agent.session);
@@ -916,6 +1480,20 @@ export class AgentRunner {
916
1480
  }
917
1481
  await this.adapter.sendText(target, lines.join("\n"));
918
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;
1496
+ }
919
1497
  async showTasks(target) {
920
1498
  const todos = this.readTodos();
921
1499
  if (todos.length === 0) {
@@ -946,6 +1524,7 @@ export class AgentRunner {
946
1524
  lines.push(this.t.chatCountField(binding?.sessions.length ?? 0));
947
1525
  lines.push(this.t.streamLabel(this.streamEnabled));
948
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))));
949
1528
  lines.push(this.t.allowUsersField(this.config.allowUsers.length, this.config.allowUsers.length === 0));
950
1529
  lines.push(this.t.allowChatsField(this.config.allowChats.length, this.config.allowChats.length === 0));
951
1530
  lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
@@ -1034,6 +1613,118 @@ export class AgentRunner {
1034
1613
  });
1035
1614
  await this.newChat(msg);
1036
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
+ }
1037
1728
  async showPlugins(target) {
1038
1729
  const loader = this.ctx.get("loader");
1039
1730
  const all = [...(loader?.entries?.() ?? [])];
@@ -1095,12 +1786,14 @@ export class AgentRunner {
1095
1786
  return;
1096
1787
  }
1097
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);
1098
1791
  const result = await compaction.compactNow(agent, new AbortController().signal);
1099
1792
  if (result === null) {
1100
1793
  await this.adapter.sendText(target, this.t.nothingToCompact);
1101
1794
  }
1102
1795
  else {
1103
- await this.adapter.sendText(target, this.t.contextCompacted);
1796
+ await this.adapter.sendText(target, this.t.compactDone);
1104
1797
  }
1105
1798
  }
1106
1799
  catch (error) {
@@ -1206,5 +1899,338 @@ export class AgentRunner {
1206
1899
  });
1207
1900
  await this.adapter.sendText(target, this.t.workspacesCount(all.length, lines.join("\n")));
1208
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
+ }
2234
+ }
1209
2235
  }
1210
2236
  //# sourceMappingURL=runner.js.map