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