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/README.md +184 -8
- package/lib/binding.d.ts +51 -0
- package/lib/binding.d.ts.map +1 -1
- package/lib/binding.js +71 -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 +75 -0
- package/lib/i18n.d.ts.map +1 -1
- package/lib/i18n.js +164 -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 +169 -2
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +1082 -56
- 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 +24 -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,31 @@ 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
|
+
/** 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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
turn.
|
|
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
|
|
239
|
-
turn.
|
|
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
|
-
|
|
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: (
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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 "
|
|
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: "
|
|
876
|
-
label: this.t.
|
|
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: "
|
|
884
|
-
label: this.t.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|