dsh-connect 0.6.7 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/binding.d.ts +2 -0
- package/lib/binding.d.ts.map +1 -1
- package/lib/binding.js +1 -1
- package/lib/binding.js.map +1 -1
- package/lib/commands.d.ts +10 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +18 -6
- package/lib/commands.js.map +1 -1
- package/lib/dedup.d.ts +31 -0
- package/lib/dedup.d.ts.map +1 -0
- package/lib/dedup.js +52 -0
- package/lib/dedup.js.map +1 -0
- package/lib/i18n.d.ts +17 -1
- package/lib/i18n.d.ts.map +1 -1
- package/lib/i18n.js +34 -2
- package/lib/i18n.js.map +1 -1
- package/lib/index.d.ts +17 -4
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +9 -2
- package/lib/index.js.map +1 -1
- package/lib/menu-controller.d.ts +71 -0
- package/lib/menu-controller.d.ts.map +1 -0
- package/lib/menu-controller.js +284 -0
- package/lib/menu-controller.js.map +1 -0
- package/lib/menus.d.ts +62 -0
- package/lib/menus.d.ts.map +1 -0
- package/lib/menus.js +99 -0
- package/lib/menus.js.map +1 -0
- package/lib/mirror-lock.d.ts +51 -0
- package/lib/mirror-lock.d.ts.map +1 -0
- package/lib/mirror-lock.js +56 -0
- package/lib/mirror-lock.js.map +1 -0
- package/lib/retry.d.ts +35 -0
- package/lib/retry.d.ts.map +1 -0
- package/lib/retry.js +41 -0
- package/lib/retry.js.map +1 -0
- package/lib/runner.d.ts +93 -95
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +259 -714
- package/lib/runner.js.map +1 -1
- package/lib/scheduler.d.ts +46 -0
- package/lib/scheduler.d.ts.map +1 -0
- package/lib/scheduler.js +162 -0
- package/lib/scheduler.js.map +1 -0
- package/lib/service.d.ts +27 -0
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +106 -3
- package/lib/service.js.map +1 -1
- package/lib/stream.d.ts +62 -2
- package/lib/stream.d.ts.map +1 -1
- package/lib/stream.js +270 -0
- package/lib/stream.js.map +1 -1
- package/lib/types.d.ts +19 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +2 -2
package/lib/runner.js
CHANGED
|
@@ -10,9 +10,13 @@ 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
12
|
import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
|
|
13
|
-
import { createAsyncQueue } from "./stream.js";
|
|
13
|
+
import { createAsyncQueue, applyStreamChunk, applyToolCall, toolCallSummary, questionTextOf, summarizeTurn, classifyError, mapReason, truncate, fmtTokens, textOf, } from "./stream.js";
|
|
14
14
|
import { helpText, parseCommand } from "./commands.js";
|
|
15
|
+
import { menuTitle, rootMenuSections, reasonLabel, goalPhaseLabel, listWorkspaces } from "./menus.js";
|
|
16
|
+
import { MenuController } from "./menu-controller.js";
|
|
15
17
|
import { messages } from "./i18n.js";
|
|
18
|
+
import { DEFAULT_LOCK_TIMEOUT_MS, acquire as lockAcquire, canWrite as lockCanWrite, isLockTimedOut as lockIsTimedOut, release as lockRelease, } from "./mirror-lock.js";
|
|
19
|
+
import { formatRemindAt, parseRemindTime } from "./scheduler.js";
|
|
16
20
|
export function resolveConnectConfig(config) {
|
|
17
21
|
return {
|
|
18
22
|
agentPreset: config.agentPreset,
|
|
@@ -25,280 +29,10 @@ export function resolveConnectConfig(config) {
|
|
|
25
29
|
stateDir: config.stateDir,
|
|
26
30
|
autoMirror: config.autoMirror ?? true, // Enabled by default
|
|
27
31
|
streamHeartbeatMs: config.streamHeartbeatMs ?? 60_000,
|
|
28
|
-
notifyLevel: config.notifyLevel ?? "
|
|
32
|
+
notifyLevel: config.notifyLevel ?? "result",
|
|
29
33
|
progressTimeoutMs: config.progressTimeoutMs ?? 5 * 60_000, // Proactive progress notice after 5 min of silence
|
|
30
34
|
};
|
|
31
35
|
}
|
|
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
|
-
}
|
|
185
|
-
function mapReason(kind) {
|
|
186
|
-
switch (kind) {
|
|
187
|
-
case "completed":
|
|
188
|
-
case "aborted":
|
|
189
|
-
case "blocked":
|
|
190
|
-
case "error":
|
|
191
|
-
case "max-tokens":
|
|
192
|
-
case "interrupted":
|
|
193
|
-
return kind;
|
|
194
|
-
default:
|
|
195
|
-
return "unknown";
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
export function summarizeTurn(events, firstSeq) {
|
|
199
|
-
let started = false;
|
|
200
|
-
let text = "";
|
|
201
|
-
let reason = "unknown";
|
|
202
|
-
let code;
|
|
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;
|
|
213
|
-
for (const event of events) {
|
|
214
|
-
if (event.seq < firstSeq)
|
|
215
|
-
continue;
|
|
216
|
-
if (event.type === "turn/start") {
|
|
217
|
-
started = true;
|
|
218
|
-
turnStartTime = typeof event.time === "number" ? event.time : undefined;
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
if (!started)
|
|
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
|
-
}
|
|
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
|
-
}
|
|
242
|
-
const joined = event.data.message.content
|
|
243
|
-
.filter((block) => block.type === "text")
|
|
244
|
-
.map((block) => block.text)
|
|
245
|
-
.join("");
|
|
246
|
-
if (joined !== "")
|
|
247
|
-
text = joined;
|
|
248
|
-
}
|
|
249
|
-
if (event.type === "turn/end") {
|
|
250
|
-
turnEndTime = typeof event.time === "number" ? event.time : undefined;
|
|
251
|
-
reason = mapReason(event.data.reason.kind);
|
|
252
|
-
if (event.data.reason.kind === "error") {
|
|
253
|
-
code = event.data.reason.error.code;
|
|
254
|
-
message = event.data.reason.error.message;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
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
|
-
};
|
|
275
|
-
}
|
|
276
|
-
function truncate(text, max = 500) {
|
|
277
|
-
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
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
|
-
}
|
|
294
|
-
function textOf(message) {
|
|
295
|
-
if (message === undefined)
|
|
296
|
-
return "";
|
|
297
|
-
return message.content
|
|
298
|
-
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
299
|
-
.map((b) => b.text ?? "")
|
|
300
|
-
.join("");
|
|
301
|
-
}
|
|
302
36
|
/** Lightweight fold of `schedule/change` events (create / delete / dispatch). */
|
|
303
37
|
function foldReminders(events) {
|
|
304
38
|
const active = new Map();
|
|
@@ -364,6 +98,8 @@ export class AgentRunner {
|
|
|
364
98
|
adapter;
|
|
365
99
|
bindings;
|
|
366
100
|
adapters;
|
|
101
|
+
requeue;
|
|
102
|
+
reminders;
|
|
367
103
|
queue = [];
|
|
368
104
|
running = false;
|
|
369
105
|
agent;
|
|
@@ -374,7 +110,16 @@ export class AgentRunner {
|
|
|
374
110
|
notifyLevel;
|
|
375
111
|
progressTimeoutMs;
|
|
376
112
|
t;
|
|
377
|
-
|
|
113
|
+
menu;
|
|
114
|
+
constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters,
|
|
115
|
+
/**
|
|
116
|
+
* Route a replayed queued message back into the runner of its own channel
|
|
117
|
+
* (wired by ConnectService). Without it, replayed messages would run in
|
|
118
|
+
* whichever runner happened to release the lock.
|
|
119
|
+
*/
|
|
120
|
+
requeue,
|
|
121
|
+
/** Persistent chat-level reminders (`/remind`), wired by ConnectService. */
|
|
122
|
+
reminders) {
|
|
378
123
|
this.ctx = ctx;
|
|
379
124
|
this.config = config;
|
|
380
125
|
this.channel = channel;
|
|
@@ -383,6 +128,8 @@ export class AgentRunner {
|
|
|
383
128
|
this.adapter = adapter;
|
|
384
129
|
this.bindings = bindings;
|
|
385
130
|
this.adapters = adapters;
|
|
131
|
+
this.requeue = requeue;
|
|
132
|
+
this.reminders = reminders;
|
|
386
133
|
this.workDir = config.workDir ?? this.resolveDefaultWorkDir();
|
|
387
134
|
// A per-chat language override (set via the settings menu) wins over config.
|
|
388
135
|
const stored = this.bindings.get(channel, chatKey);
|
|
@@ -390,54 +137,16 @@ export class AgentRunner {
|
|
|
390
137
|
this.notifyLevel = stored?.notifyLevel ?? config.notifyLevel;
|
|
391
138
|
this.progressTimeoutMs = stored?.progressTimeoutMs ?? config.progressTimeoutMs;
|
|
392
139
|
this.t = messages(this.language);
|
|
140
|
+
this.menu = new MenuController(this);
|
|
393
141
|
}
|
|
394
142
|
menuTitle(menuId) {
|
|
395
|
-
|
|
396
|
-
case "root":
|
|
397
|
-
return this.t.menuRoot;
|
|
398
|
-
case "workspace":
|
|
399
|
-
return this.t.menuWorkspace;
|
|
400
|
-
case "chat":
|
|
401
|
-
return this.t.menuChat;
|
|
402
|
-
case "settings":
|
|
403
|
-
return this.t.menuSettings;
|
|
404
|
-
case "model":
|
|
405
|
-
return this.t.menuModel;
|
|
406
|
-
case "reasoning":
|
|
407
|
-
return this.t.menuReasoning;
|
|
408
|
-
case "notify":
|
|
409
|
-
return this.t.menuNotify;
|
|
410
|
-
case "language":
|
|
411
|
-
return this.t.menuSettingsLanguage;
|
|
412
|
-
case "progress":
|
|
413
|
-
return this.t.progressMenuTitle;
|
|
414
|
-
}
|
|
143
|
+
return menuTitle(menuId, this.t);
|
|
415
144
|
}
|
|
416
145
|
rootMenuSections() {
|
|
417
|
-
return
|
|
418
|
-
{ title: this.t.rootSectionWorkspace, ids: ["workspace"], columnsPerRow: 1 },
|
|
419
|
-
{ title: this.t.rootSectionChat, ids: ["chat", "history"], columnsPerRow: 1 },
|
|
420
|
-
{ title: this.t.rootSectionTask, ids: ["task", "goals", "schedule"], columnsPerRow: 2 },
|
|
421
|
-
{ title: this.t.rootSectionSystem, ids: ["status", "plugins", "compact", "settings"], columnsPerRow: 2 },
|
|
422
|
-
];
|
|
146
|
+
return rootMenuSections(this.t);
|
|
423
147
|
}
|
|
424
148
|
reasonLabel(reason) {
|
|
425
|
-
|
|
426
|
-
case "completed":
|
|
427
|
-
return this.t.reasonCompleted;
|
|
428
|
-
case "aborted":
|
|
429
|
-
return this.t.reasonAborted;
|
|
430
|
-
case "blocked":
|
|
431
|
-
return this.t.reasonBlocked;
|
|
432
|
-
case "error":
|
|
433
|
-
return this.t.reasonError;
|
|
434
|
-
case "max-tokens":
|
|
435
|
-
return this.t.reasonMaxTokens;
|
|
436
|
-
case "interrupted":
|
|
437
|
-
return this.t.reasonInterrupted;
|
|
438
|
-
default:
|
|
439
|
-
return this.t.reasonUnknown;
|
|
440
|
-
}
|
|
149
|
+
return reasonLabel(reason, this.t);
|
|
441
150
|
}
|
|
442
151
|
/** Prefer the user's first DSH workspace over the process cwd as default. */
|
|
443
152
|
resolveDefaultWorkDir() {
|
|
@@ -514,16 +223,18 @@ export class AgentRunner {
|
|
|
514
223
|
else {
|
|
515
224
|
turn.milestone = this.t.toolCalling(name, undefined);
|
|
516
225
|
}
|
|
517
|
-
// `result` mode shows nothing until the final answer
|
|
518
|
-
|
|
519
|
-
|
|
226
|
+
// `result` mode shows nothing until the final answer.
|
|
227
|
+
if (this.notifyLevel === 'result')
|
|
228
|
+
return;
|
|
229
|
+
// Keep the streaming card short: push a status line only once per distinct
|
|
230
|
+
// tool (not once per call, which would log "tool #51" spam), and label it
|
|
231
|
+
// with the tool name rather than a running counter.
|
|
232
|
+
if (turn.lastToolName === name)
|
|
520
233
|
return;
|
|
521
|
-
|
|
234
|
+
turn.lastToolName = name;
|
|
235
|
+
const label = name === 'ask_user_question'
|
|
522
236
|
? this.t.questionToolCall(questionTextOf(event.data.arguments))
|
|
523
|
-
: this.t.
|
|
524
|
-
const label = name === "ask_user_question" || this.notifyLevel !== "full" || summary === undefined
|
|
525
|
-
? base
|
|
526
|
-
: `${base} — ${summary}`;
|
|
237
|
+
: this.t.toolCalling(name, this.notifyLevel === 'full' ? summary : undefined);
|
|
527
238
|
applyToolCall(turn, label);
|
|
528
239
|
}
|
|
529
240
|
}
|
|
@@ -685,7 +396,7 @@ export class AgentRunner {
|
|
|
685
396
|
await this.sendTurnStats(msg, outcome);
|
|
686
397
|
// Release lock after task completion
|
|
687
398
|
if (usesLock)
|
|
688
|
-
this.releaseLock();
|
|
399
|
+
await this.releaseLock();
|
|
689
400
|
if (outcome.reason !== "completed") {
|
|
690
401
|
await this.sendSummary(msg, outcome);
|
|
691
402
|
}
|
|
@@ -693,7 +404,7 @@ export class AgentRunner {
|
|
|
693
404
|
catch (error) {
|
|
694
405
|
// Release lock even on error
|
|
695
406
|
if (usesLock)
|
|
696
|
-
this.releaseLock();
|
|
407
|
+
await this.releaseLock();
|
|
697
408
|
const detail = error instanceof Error ? error.message : String(error);
|
|
698
409
|
await this.adapter
|
|
699
410
|
.sendText(this.target(msg), this.t.processingFailedAdvice(truncate(detail, 400), this.errorAdvice(detail)))
|
|
@@ -901,7 +612,7 @@ export class AgentRunner {
|
|
|
901
612
|
this.turn = {
|
|
902
613
|
firstSeq, chunks, lastText: "",
|
|
903
614
|
reasoning: false, hintPushed: false, lastIndex: undefined, pushedAny: false,
|
|
904
|
-
startedAt: now, lastPushAt: now, toolCount: 0, contextNudged: false, compactAfterTurn: false,
|
|
615
|
+
startedAt: now, lastPushAt: now, toolCount: 0, lastToolName: undefined, contextNudged: false, compactAfterTurn: false,
|
|
905
616
|
};
|
|
906
617
|
// Liveness heartbeat: while the agent is working, keep the streaming card
|
|
907
618
|
// visibly alive even through long reasoning or tool-execution stretches
|
|
@@ -922,26 +633,29 @@ export class AgentRunner {
|
|
|
922
633
|
}, heartbeatMs)
|
|
923
634
|
: undefined;
|
|
924
635
|
// Proactive progress watchdog: when no standalone card/text has been sent
|
|
925
|
-
// for `progressTimeoutMs` (default 5 min, user-configurable),
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
636
|
+
// for `progressTimeoutMs` (default 5 min, user-configurable), re-sync the
|
|
637
|
+
// latest milestone INTO the streaming card (via its chunk stream) so a long
|
|
638
|
+
// turn never looks frozen — WITHOUT posting a separate message. The card is
|
|
639
|
+
// edited in place, keeping the chat history clean. Streaming-card heartbeats
|
|
640
|
+
// deliberately do NOT reset this: the progress watchdog only fires when no
|
|
641
|
+
// explicit milestone sync has been pushed for the configured interval.
|
|
929
642
|
const progressTimeoutMs = this.progressTimeoutMs;
|
|
930
|
-
let
|
|
643
|
+
let lastProgressNoticeAt = now;
|
|
931
644
|
const progressWatchdog = progressTimeoutMs > 0
|
|
932
645
|
? setInterval(() => {
|
|
933
646
|
const turn = this.turn;
|
|
934
647
|
if (turn === undefined)
|
|
935
648
|
return;
|
|
936
|
-
const elapsed = Date.now() -
|
|
649
|
+
const elapsed = Date.now() - lastProgressNoticeAt;
|
|
937
650
|
if (elapsed < progressTimeoutMs)
|
|
938
651
|
return;
|
|
939
|
-
|
|
652
|
+
lastProgressNoticeAt = Date.now();
|
|
940
653
|
const minutes = Math.max(1, Math.round((Date.now() - turn.startedAt) / 60_000));
|
|
941
654
|
const status = turn.milestone ?? this.t.progressThinking;
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
655
|
+
// Edit the existing streaming card in place instead of sending a new
|
|
656
|
+
// message: the milestone is appended to the same card via the chunk
|
|
657
|
+
// stream, so progress updates never clutter the chat with new bubbles.
|
|
658
|
+
turn.chunks.push(`\n\n${this.t.progressReminder(minutes, status)}\n\n`);
|
|
945
659
|
}, PROGRESS_WATCHDOG_CHECK_MS)
|
|
946
660
|
: undefined;
|
|
947
661
|
// End the chunk stream unconditionally: if `followup` / `whenIdle` /
|
|
@@ -1306,7 +1020,7 @@ export class AgentRunner {
|
|
|
1306
1020
|
}
|
|
1307
1021
|
case "dir": {
|
|
1308
1022
|
if (command.path === undefined) {
|
|
1309
|
-
await this.openMenu(target, msg, "workspace", ["root"]);
|
|
1023
|
+
await this.menu.openMenu(target, msg, "workspace", ["root"]);
|
|
1310
1024
|
break;
|
|
1311
1025
|
}
|
|
1312
1026
|
const path = command.path;
|
|
@@ -1330,15 +1044,15 @@ export class AgentRunner {
|
|
|
1330
1044
|
break;
|
|
1331
1045
|
}
|
|
1332
1046
|
case "chat": {
|
|
1333
|
-
await this.openMenu(target, msg, "chat", ["root"]);
|
|
1047
|
+
await this.menu.openMenu(target, msg, "chat", ["root"]);
|
|
1334
1048
|
break;
|
|
1335
1049
|
}
|
|
1336
1050
|
case "menu": {
|
|
1337
|
-
await this.openMenu(target, msg, "root");
|
|
1051
|
+
await this.menu.openMenu(target, msg, "root");
|
|
1338
1052
|
break;
|
|
1339
1053
|
}
|
|
1340
1054
|
case "settings": {
|
|
1341
|
-
await this.openMenu(target, msg, "settings", ["root"]);
|
|
1055
|
+
await this.menu.openMenu(target, msg, "settings", ["root"]);
|
|
1342
1056
|
break;
|
|
1343
1057
|
}
|
|
1344
1058
|
case "plugins": {
|
|
@@ -1370,7 +1084,7 @@ export class AgentRunner {
|
|
|
1370
1084
|
break;
|
|
1371
1085
|
}
|
|
1372
1086
|
case "model": {
|
|
1373
|
-
await this.openMenu(target, msg, "model", ["root"]);
|
|
1087
|
+
await this.menu.openMenu(target, msg, "model", ["root"]);
|
|
1374
1088
|
break;
|
|
1375
1089
|
}
|
|
1376
1090
|
case "notify": {
|
|
@@ -1405,6 +1119,18 @@ export class AgentRunner {
|
|
|
1405
1119
|
await this.handleAppend(command.text, target, msg);
|
|
1406
1120
|
break;
|
|
1407
1121
|
}
|
|
1122
|
+
case "remind": {
|
|
1123
|
+
await this.handleRemind(command.text, target, msg);
|
|
1124
|
+
break;
|
|
1125
|
+
}
|
|
1126
|
+
case "send": {
|
|
1127
|
+
await this.handleSend(command.path, target);
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
case "broadcast": {
|
|
1131
|
+
await this.handleBroadcast(command.text, target, msg);
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1408
1134
|
case "help": {
|
|
1409
1135
|
await this.adapter.sendText(target, helpText(this.t));
|
|
1410
1136
|
break;
|
|
@@ -1446,6 +1172,103 @@ export class AgentRunner {
|
|
|
1446
1172
|
this.touchBinding(msg);
|
|
1447
1173
|
await this.adapter.sendText(target, this.t.psReceived(text));
|
|
1448
1174
|
}
|
|
1175
|
+
/**
|
|
1176
|
+
* `/remind <time> <text>` — persist a chat-level reminder that fires
|
|
1177
|
+
* without waking the agent (see src/scheduler.ts).
|
|
1178
|
+
*/
|
|
1179
|
+
async handleRemind(argText, target, msg) {
|
|
1180
|
+
if (this.reminders === undefined) {
|
|
1181
|
+
await this.adapter.sendText(target, this.t.reminderNoText);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const arg = argText.trim();
|
|
1185
|
+
if (arg === "") {
|
|
1186
|
+
await this.adapter.sendText(target, this.t.reminderNoText);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const space = arg.indexOf(" ");
|
|
1190
|
+
const time = space === -1 ? arg : arg.slice(0, space);
|
|
1191
|
+
const body = space === -1 ? "" : arg.slice(space + 1).trim();
|
|
1192
|
+
const dueAt = parseRemindTime(time);
|
|
1193
|
+
if (dueAt === undefined) {
|
|
1194
|
+
await this.adapter.sendText(target, this.t.reminderParseFailed(time));
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (body === "") {
|
|
1198
|
+
await this.adapter.sendText(target, this.t.reminderNoText);
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
this.reminders.add({
|
|
1202
|
+
channel: this.channel,
|
|
1203
|
+
chatKey: this.chatKey,
|
|
1204
|
+
chatType: this.chatType,
|
|
1205
|
+
text: body,
|
|
1206
|
+
dueAt,
|
|
1207
|
+
ownerKey: msg.senderKey,
|
|
1208
|
+
});
|
|
1209
|
+
await this.adapter.sendText(target, this.t.reminderSet(formatRemindAt(dueAt, this.language), body));
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* `/send <path>` — deliver a file from the workspace through the channel's
|
|
1213
|
+
* `sendFile` capability; falls back to sending the path as text.
|
|
1214
|
+
*/
|
|
1215
|
+
async handleSend(pathArg, target) {
|
|
1216
|
+
if (pathArg === "") {
|
|
1217
|
+
await this.adapter.sendText(target, this.t.sendUsage);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
const path = isAbsolute(pathArg) ? pathArg : join(this.workDir, pathArg);
|
|
1221
|
+
let size;
|
|
1222
|
+
try {
|
|
1223
|
+
if (!existsSync(path)) {
|
|
1224
|
+
await this.adapter.sendText(target, this.t.sendNotFound(path));
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const stat = statSync(path);
|
|
1228
|
+
if (stat.isDirectory()) {
|
|
1229
|
+
await this.adapter.sendText(target, this.t.sendIsDir(path));
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
size = stat.size;
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
await this.adapter.sendText(target, this.t.sendNotFound(path));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (size > 20 * 1024 * 1024) {
|
|
1239
|
+
await this.adapter.sendText(target, this.t.sendTooLarge(path));
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
if (this.adapter.sendFile === undefined) {
|
|
1243
|
+
await this.adapter.sendText(target, `${this.t.sendUnsupported}\n${path}`);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
try {
|
|
1247
|
+
await this.adapter.sendFile(target, path);
|
|
1248
|
+
await this.adapter.sendText(target, this.t.sendSent(path));
|
|
1249
|
+
}
|
|
1250
|
+
catch (error) {
|
|
1251
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1252
|
+
await this.adapter.sendText(target, this.t.sendFailed(truncate(detail)));
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* `/broadcast <text>` — admins push a message to every bound chat.
|
|
1257
|
+
* Admin gating lives in the connect service (allowUsers).
|
|
1258
|
+
*/
|
|
1259
|
+
async handleBroadcast(text, target, msg) {
|
|
1260
|
+
const service = this.ctx.get("connect");
|
|
1261
|
+
if (service?.broadcast === undefined) {
|
|
1262
|
+
await this.adapter.sendText(target, this.t.broadcastUnsupported);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const result = await service.broadcast(msg.senderKey, text);
|
|
1266
|
+
if (!result.ok) {
|
|
1267
|
+
await this.adapter.sendText(target, result.reason === "disabled" ? this.t.broadcastDisabled : this.t.broadcastNotAdmin);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
await this.adapter.sendText(target, this.t.broadcastSent(result.count ?? 0));
|
|
1271
|
+
}
|
|
1449
1272
|
recordSession(sessionId, title, ownerKey) {
|
|
1450
1273
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1451
1274
|
const others = (binding?.sessions ?? []).filter((s) => s.sessionId !== sessionId);
|
|
@@ -1553,198 +1376,12 @@ export class AgentRunner {
|
|
|
1553
1376
|
await this.disposeAgent();
|
|
1554
1377
|
}
|
|
1555
1378
|
listWorkspaces() {
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
const key = path.replace(/[\\/]+$/, "").toLowerCase();
|
|
1563
|
-
if (seen.has(key))
|
|
1564
|
-
return;
|
|
1565
|
-
seen.add(key);
|
|
1566
|
-
out.push({ path, title: title || path });
|
|
1567
|
-
};
|
|
1568
|
-
add(this.workDir, this.t.currentDir);
|
|
1569
|
-
for (const w of registry?.list?.() ?? [])
|
|
1570
|
-
add(w.path, w.title);
|
|
1571
|
-
for (const p of this.config.workspaces)
|
|
1572
|
-
add(p, p);
|
|
1573
|
-
return out;
|
|
1574
|
-
}
|
|
1575
|
-
async openMenu(target, msg, menuId, stack = [], cardId) {
|
|
1576
|
-
const items = await this.menuItems(menuId);
|
|
1577
|
-
const options = items.map((i) => ({ id: i.id, label: i.label }));
|
|
1578
|
-
options.push({ id: "menu:exit", label: this.t.menuExit });
|
|
1579
|
-
if (stack.length > 0)
|
|
1580
|
-
options.push({ id: "menu:back", label: this.t.menuBack });
|
|
1581
|
-
// Determine columns per row based on menu type
|
|
1582
|
-
// Workspace and chat lists use 1 column (full width), others use 2 columns
|
|
1583
|
-
const columnsPerRow = (menuId === "workspace" || menuId === "chat") ? 1 : 2;
|
|
1584
|
-
const { choice, messageId } = await this.adapter.promptChoice(target, {
|
|
1585
|
-
title: this.menuTitle(menuId),
|
|
1586
|
-
options,
|
|
1587
|
-
columnsPerRow,
|
|
1588
|
-
...(menuId === "root" ? { sections: this.rootMenuSections(), footer: this.t.rootMenuFooter } : {}),
|
|
1589
|
-
}, cardId);
|
|
1590
|
-
if (choice === undefined)
|
|
1591
|
-
return;
|
|
1592
|
-
if (choice === "menu:exit") {
|
|
1593
|
-
await this.adapter.closeMenu(messageId, this.t.menuClosed);
|
|
1594
|
-
return;
|
|
1595
|
-
}
|
|
1596
|
-
if (choice === "menu:back") {
|
|
1597
|
-
const parent = stack[stack.length - 1];
|
|
1598
|
-
await this.openMenu(target, msg, parent, stack.slice(0, -1), messageId);
|
|
1599
|
-
return;
|
|
1600
|
-
}
|
|
1601
|
-
const item = items.find((i) => i.id === choice);
|
|
1602
|
-
if (item === undefined) {
|
|
1603
|
-
// The tap belonged to a previous card generation (e.g. a rapid second
|
|
1604
|
-
// tap on the old menu while the new one was still being redrawn). Don't
|
|
1605
|
-
// silently swallow it: redraw the current menu so the chain stays usable.
|
|
1606
|
-
await this.openMenu(target, msg, menuId, stack, messageId);
|
|
1607
|
-
return;
|
|
1608
|
-
}
|
|
1609
|
-
const feedback = await item.onSelect(target, msg, messageId);
|
|
1610
|
-
if (item.leaf === true) {
|
|
1611
|
-
// Every leaf press must answer visibly: send the action's result feedback
|
|
1612
|
-
// (if any) before returning to the root menu on the same card.
|
|
1613
|
-
if (feedback !== undefined && feedback !== "") {
|
|
1614
|
-
await this.adapter.sendText(target, feedback).catch(() => undefined);
|
|
1615
|
-
}
|
|
1616
|
-
await this.openMenu(target, msg, "root", [], messageId);
|
|
1617
|
-
}
|
|
1618
|
-
}
|
|
1619
|
-
async menuItems(menuId) {
|
|
1620
|
-
switch (menuId) {
|
|
1621
|
-
case "root":
|
|
1622
|
-
return [
|
|
1623
|
-
{ id: "workspace", label: this.t.menuWorkspaceAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "workspace", ["root"], cardId) },
|
|
1624
|
-
{ id: "chat", label: this.t.menuChatAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "chat", ["root"], cardId) },
|
|
1625
|
-
{ id: "status", label: this.t.menuStatusAction, leaf: true, onSelect: async (t) => { await this.showStatus(t); } },
|
|
1626
|
-
{ id: "task", label: this.t.menuTaskAction, leaf: true, onSelect: async (t) => { await this.showTasks(t); } },
|
|
1627
|
-
{ id: "history", label: this.t.menuHistoryAction, leaf: true, onSelect: async (t) => { await this.showHistory(t, 10); } },
|
|
1628
|
-
{ id: "goals", label: this.t.menuGoalsAction, leaf: true, onSelect: async (t) => { await this.showGoals(t); } },
|
|
1629
|
-
{ id: "schedule", label: this.t.menuScheduleAction, leaf: true, onSelect: async (t) => { await this.showSchedule(t); } },
|
|
1630
|
-
{ id: "compact", label: this.t.menuCompactAction, leaf: true, onSelect: async (t) => { await this.compact(t); } },
|
|
1631
|
-
{ id: "plugins", label: this.t.menuPluginsAction, leaf: true, onSelect: async (t) => { await this.showPlugins(t); } },
|
|
1632
|
-
{ id: "settings", label: this.t.menuSettingsAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "settings", ["root"], cardId) },
|
|
1633
|
-
];
|
|
1634
|
-
case "workspace": {
|
|
1635
|
-
const workspaces = this.listWorkspaces();
|
|
1636
|
-
return workspaces.map((w) => ({
|
|
1637
|
-
id: `dir:${w.path}`,
|
|
1638
|
-
label: `${w.path === this.workDir ? "● " : ""}${w.title}${w.title !== w.path ? ` (${w.path})` : ""}`,
|
|
1639
|
-
leaf: true,
|
|
1640
|
-
onSelect: async (t, m) => {
|
|
1641
|
-
this.workDir = w.path;
|
|
1642
|
-
await this.newChat(m);
|
|
1643
|
-
return this.t.dirSwitched(w.path);
|
|
1644
|
-
},
|
|
1645
|
-
}));
|
|
1646
|
-
}
|
|
1647
|
-
case "chat": {
|
|
1648
|
-
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1649
|
-
const active = binding?.sessionId;
|
|
1650
|
-
const hasWebMirror = binding?.webMirrorSessionId !== undefined;
|
|
1651
|
-
const items = (await this.collectWorkdirSessions()).map(({ sessionId, title }) => {
|
|
1652
|
-
const isMirrored = hasWebMirror && binding?.webMirrorSessionId === sessionId;
|
|
1653
|
-
const mirrorIndicator = isMirrored ? ` ${this.t.webMirrorIndicator}` : "";
|
|
1654
|
-
const activeIndicator = sessionId === active ? "●" : "○";
|
|
1655
|
-
return {
|
|
1656
|
-
id: `session:${sessionId}`,
|
|
1657
|
-
label: `${activeIndicator} ${title || sessionId}${mirrorIndicator}`,
|
|
1658
|
-
leaf: true,
|
|
1659
|
-
onSelect: async (t, m) => {
|
|
1660
|
-
await this.switchTo(sessionId, m.senderKey);
|
|
1661
|
-
return this.t.sessionSwitched(title || sessionId);
|
|
1662
|
-
},
|
|
1663
|
-
};
|
|
1664
|
-
});
|
|
1665
|
-
if (items.length === 0) {
|
|
1666
|
-
items.push({
|
|
1667
|
-
id: "none:history",
|
|
1668
|
-
label: this.t.noSessionsInWorkdir(this.workDir),
|
|
1669
|
-
leaf: true,
|
|
1670
|
-
onSelect: async () => this.t.noSessionsInWorkdir(this.workDir),
|
|
1671
|
-
});
|
|
1672
|
-
}
|
|
1673
|
-
items.push({
|
|
1674
|
-
id: "action:new",
|
|
1675
|
-
label: this.t.menuNewChat,
|
|
1676
|
-
leaf: true,
|
|
1677
|
-
onSelect: async (t, m, messageId) => {
|
|
1678
|
-
// Reuse the menu card for the confirm prompt so no stale card is left behind.
|
|
1679
|
-
if (await this.confirmAction(t, this.t.confirmNewText, messageId)) {
|
|
1680
|
-
await this.newChat(m);
|
|
1681
|
-
return this.t.newChatDone;
|
|
1682
|
-
}
|
|
1683
|
-
return this.t.actionCancelled;
|
|
1684
|
-
},
|
|
1685
|
-
});
|
|
1686
|
-
return items;
|
|
1687
|
-
}
|
|
1688
|
-
case "settings":
|
|
1689
|
-
return [
|
|
1690
|
-
{ id: "model", label: this.t.menuSettingsModel, onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
|
|
1691
|
-
{ id: "reasoning", label: this.t.menuSettingsReasoning, onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
|
|
1692
|
-
{ id: "notify", label: this.t.menuSettingsNotify, onSelect: (t, m, cardId) => this.openMenu(t, m, "notify", ["settings", "root"], cardId) },
|
|
1693
|
-
{ id: "progress", label: this.t.menuSettingsProgress, onSelect: (t, m, cardId) => this.openMenu(t, m, "progress", ["settings", "root"], cardId) },
|
|
1694
|
-
{ id: "language", label: this.t.menuSettingsLanguage, onSelect: (t, m, cardId) => this.openMenu(t, m, "language", ["settings", "root"], cardId) },
|
|
1695
|
-
{ id: "overview", label: this.t.menuSettingsOverview, leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
|
|
1696
|
-
];
|
|
1697
|
-
case "language":
|
|
1698
|
-
return [
|
|
1699
|
-
{
|
|
1700
|
-
id: "lang:zh",
|
|
1701
|
-
label: `${this.language === "zh" ? "● " : ""}${this.t.languageZh}`,
|
|
1702
|
-
leaf: true,
|
|
1703
|
-
onSelect: async (t, m) => { await this.setLanguage("zh", t, m); },
|
|
1704
|
-
},
|
|
1705
|
-
{
|
|
1706
|
-
id: "lang:en",
|
|
1707
|
-
label: `${this.language === "en" ? "● " : ""}${this.t.languageEn}`,
|
|
1708
|
-
leaf: true,
|
|
1709
|
-
onSelect: async (t, m) => { await this.setLanguage("en", t, m); },
|
|
1710
|
-
},
|
|
1711
|
-
];
|
|
1712
|
-
case "model":
|
|
1713
|
-
return await this.modelMenuItems();
|
|
1714
|
-
case "reasoning":
|
|
1715
|
-
return this.reasoningMenuItems();
|
|
1716
|
-
case "notify":
|
|
1717
|
-
return [
|
|
1718
|
-
{ id: "full", label: this.t.notifyFull },
|
|
1719
|
-
{ id: "important", label: this.t.notifyImportant },
|
|
1720
|
-
{ id: "result", label: this.t.notifyResult },
|
|
1721
|
-
].map((o) => ({
|
|
1722
|
-
id: `notify:${o.id}`,
|
|
1723
|
-
label: `${this.notifyLevel === o.id ? "● " : ""}${o.label}`,
|
|
1724
|
-
leaf: true,
|
|
1725
|
-
onSelect: async (t, m) => {
|
|
1726
|
-
await this.setNotifyLevel(o.id, t, m);
|
|
1727
|
-
},
|
|
1728
|
-
}));
|
|
1729
|
-
case "progress": {
|
|
1730
|
-
const presets = [
|
|
1731
|
-
{ id: "progress:0", label: this.t.progressOff, ms: 0 },
|
|
1732
|
-
{ id: "progress:120000", label: this.t.progressMinutes(2), ms: 2 * 60_000 },
|
|
1733
|
-
{ id: "progress:300000", label: this.t.progressMinutes(5), ms: 5 * 60_000 },
|
|
1734
|
-
{ id: "progress:600000", label: this.t.progressMinutes(10), ms: 10 * 60_000 },
|
|
1735
|
-
{ id: "progress:900000", label: this.t.progressMinutes(15), ms: 15 * 60_000 },
|
|
1736
|
-
{ id: "progress:1800000", label: this.t.progressMinutes(30), ms: 30 * 60_000 },
|
|
1737
|
-
];
|
|
1738
|
-
return presets.map((o) => ({
|
|
1739
|
-
id: o.id,
|
|
1740
|
-
label: `${this.progressTimeoutMs === o.ms ? "● " : ""}${o.label}`,
|
|
1741
|
-
leaf: true,
|
|
1742
|
-
onSelect: async (t, m) => {
|
|
1743
|
-
await this.setProgressTimeout(o.ms, t, m);
|
|
1744
|
-
},
|
|
1745
|
-
}));
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1379
|
+
return listWorkspaces({
|
|
1380
|
+
registry: this.ctx.get("workspaceRegistry"),
|
|
1381
|
+
workDir: this.workDir,
|
|
1382
|
+
currentDirLabel: this.t.currentDir,
|
|
1383
|
+
workspaces: this.config.workspaces,
|
|
1384
|
+
});
|
|
1748
1385
|
}
|
|
1749
1386
|
async showStatus(target) {
|
|
1750
1387
|
const agent = this.agent;
|
|
@@ -1845,41 +1482,6 @@ export class AgentRunner {
|
|
|
1845
1482
|
lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
|
|
1846
1483
|
await this.adapter.sendText(target, lines.join("\n"));
|
|
1847
1484
|
}
|
|
1848
|
-
async modelMenuItems() {
|
|
1849
|
-
const choices = await this.listModelChoices();
|
|
1850
|
-
const current = this.defaultSelection();
|
|
1851
|
-
const items = choices.map((c) => ({
|
|
1852
|
-
id: `model:${c.provider}:${c.model}`,
|
|
1853
|
-
label: `${c.provider === current.provider && c.model === current.model ? "● " : ""}${c.name}`,
|
|
1854
|
-
leaf: true,
|
|
1855
|
-
onSelect: async (t, m) => {
|
|
1856
|
-
await this.setModel(c.provider, c.model, m);
|
|
1857
|
-
return this.t.modelSet(c.name);
|
|
1858
|
-
},
|
|
1859
|
-
}));
|
|
1860
|
-
if (items.length === 0) {
|
|
1861
|
-
items.push({ id: "model:none", label: this.t.noModelsFound, leaf: true, onSelect: async () => this.t.noModelsFound });
|
|
1862
|
-
}
|
|
1863
|
-
return items;
|
|
1864
|
-
}
|
|
1865
|
-
async listModelChoices() {
|
|
1866
|
-
const llm = this.ctx.get("llm");
|
|
1867
|
-
const providers = llm?.listProviders?.() ?? [];
|
|
1868
|
-
const out = [];
|
|
1869
|
-
for (const p of providers) {
|
|
1870
|
-
let models = [];
|
|
1871
|
-
try {
|
|
1872
|
-
models = (await llm?.listModels?.(p.id)) ?? [];
|
|
1873
|
-
}
|
|
1874
|
-
catch {
|
|
1875
|
-
// Skip providers whose catalog cannot be listed.
|
|
1876
|
-
}
|
|
1877
|
-
for (const m of models) {
|
|
1878
|
-
out.push({ provider: p.id, model: m.id, name: this.t.modelName(m.name || m.id, p.name || p.id) });
|
|
1879
|
-
}
|
|
1880
|
-
}
|
|
1881
|
-
return out;
|
|
1882
|
-
}
|
|
1883
1485
|
async setModel(provider, model, msg) {
|
|
1884
1486
|
const svc = this.ctx.get("agentDefaultModel");
|
|
1885
1487
|
const cur = this.defaultSelection();
|
|
@@ -1890,37 +1492,6 @@ export class AgentRunner {
|
|
|
1890
1492
|
});
|
|
1891
1493
|
await this.newChat(msg);
|
|
1892
1494
|
}
|
|
1893
|
-
reasoningMenuItems() {
|
|
1894
|
-
const current = this.defaultSelection();
|
|
1895
|
-
const efforts = [
|
|
1896
|
-
{ id: "low", name: this.t.effortLow },
|
|
1897
|
-
{ id: "medium", name: this.t.effortMedium },
|
|
1898
|
-
{ id: "high", name: this.t.effortHigh },
|
|
1899
|
-
];
|
|
1900
|
-
const items = [
|
|
1901
|
-
{
|
|
1902
|
-
id: "effort:default",
|
|
1903
|
-
label: `${current.reasoningEffort === undefined ? "● " : ""}${this.t.effortDefault}`,
|
|
1904
|
-
leaf: true,
|
|
1905
|
-
onSelect: async (t, m) => {
|
|
1906
|
-
await this.setReasoning(undefined, m);
|
|
1907
|
-
return this.t.reasoningSet(this.t.effortDefault);
|
|
1908
|
-
},
|
|
1909
|
-
},
|
|
1910
|
-
];
|
|
1911
|
-
for (const e of efforts) {
|
|
1912
|
-
items.push({
|
|
1913
|
-
id: `effort:${e.id}`,
|
|
1914
|
-
label: `${e.id === current.reasoningEffort ? "● " : ""}${e.name}`,
|
|
1915
|
-
leaf: true,
|
|
1916
|
-
onSelect: async (t, m) => {
|
|
1917
|
-
await this.setReasoning(e.id, m);
|
|
1918
|
-
return this.t.reasoningSet(e.name);
|
|
1919
|
-
},
|
|
1920
|
-
});
|
|
1921
|
-
}
|
|
1922
|
-
return items;
|
|
1923
|
-
}
|
|
1924
1495
|
async setReasoning(effort, msg) {
|
|
1925
1496
|
const svc = this.ctx.get("agentDefaultModel");
|
|
1926
1497
|
const cur = this.defaultSelection();
|
|
@@ -2181,40 +1752,41 @@ export class AgentRunner {
|
|
|
2181
1752
|
await this.adapter.sendText(target, lines.join("\n"));
|
|
2182
1753
|
}
|
|
2183
1754
|
goalPhaseLabel(phase) {
|
|
2184
|
-
|
|
2185
|
-
case "active":
|
|
2186
|
-
return this.t.goalPhaseActive;
|
|
2187
|
-
case "paused":
|
|
2188
|
-
return this.t.goalPhasePaused;
|
|
2189
|
-
case "blocked":
|
|
2190
|
-
return this.t.goalPhaseBlocked;
|
|
2191
|
-
case "complete":
|
|
2192
|
-
return this.t.goalPhaseComplete;
|
|
2193
|
-
default:
|
|
2194
|
-
return phase;
|
|
2195
|
-
}
|
|
1755
|
+
return goalPhaseLabel(phase, this.t);
|
|
2196
1756
|
}
|
|
2197
1757
|
async showSchedule(target) {
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
}
|
|
2203
|
-
const reminders = foldReminders(agent.session.events);
|
|
2204
|
-
if (reminders.length === 0) {
|
|
2205
|
-
await this.adapter.sendText(target, this.t.noReminders);
|
|
2206
|
-
return;
|
|
2207
|
-
}
|
|
1758
|
+
// Agent-level reminders (session `schedule` tool) + persistent chat-level
|
|
1759
|
+
// reminders (`/remind`), merged into one list.
|
|
1760
|
+
const agentReminders = this.agent === undefined ? [] : foldReminders(this.agent.session.events);
|
|
1761
|
+
const persisted = this.reminders?.listFor(this.channel, this.chatKey) ?? [];
|
|
2208
1762
|
const now = Date.now();
|
|
2209
1763
|
const locale = this.language === "en" ? "en-US" : "zh-CN";
|
|
2210
|
-
const
|
|
1764
|
+
const agentLines = agentReminders.map((r) => {
|
|
2211
1765
|
const due = Date.parse(r.scheduledAt) <= now;
|
|
2212
1766
|
const state = due ? this.t.reminderDue : this.t.reminderClock;
|
|
2213
1767
|
const kind = r.kind === "every" ? this.t.reminderEvery(Math.round((r.everySeconds ?? 0) / 60)) : this.t.reminderOnce;
|
|
2214
1768
|
const when = new Date(Date.parse(r.scheduledAt)).toLocaleString(locale);
|
|
2215
1769
|
return this.t.reminderLine(state, r.id, r.prompt, kind, when);
|
|
2216
1770
|
});
|
|
2217
|
-
|
|
1771
|
+
const persistedLines = persisted.map((r) => {
|
|
1772
|
+
const due = r.dueAt <= now;
|
|
1773
|
+
const state = due ? this.t.reminderDue : this.t.reminderClock;
|
|
1774
|
+
const when = new Date(r.dueAt).toLocaleString(locale);
|
|
1775
|
+
return this.t.reminderLine(state, r.id, r.text, this.t.reminderOnce, when);
|
|
1776
|
+
});
|
|
1777
|
+
const total = agentLines.length + persistedLines.length;
|
|
1778
|
+
if (total === 0) {
|
|
1779
|
+
await this.adapter.sendText(target, this.t.noReminders);
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
const parts = [];
|
|
1783
|
+
if (agentLines.length > 0)
|
|
1784
|
+
parts.push(...agentLines);
|
|
1785
|
+
if (persistedLines.length > 0) {
|
|
1786
|
+
parts.push(this.t.reminderPersistedHeader);
|
|
1787
|
+
parts.push(...persistedLines);
|
|
1788
|
+
}
|
|
1789
|
+
await this.adapter.sendText(target, this.t.remindersCount(total, parts.join("\n")));
|
|
2218
1790
|
}
|
|
2219
1791
|
async showAllWorkspaces(target) {
|
|
2220
1792
|
const registry = this.ctx.get("workspaceRegistry");
|
|
@@ -2299,7 +1871,7 @@ export class AgentRunner {
|
|
|
2299
1871
|
}
|
|
2300
1872
|
// Process any queued messages before releasing
|
|
2301
1873
|
const queuedCount = binding.queuedMessages?.length ?? 0;
|
|
2302
|
-
this.releaseLock();
|
|
1874
|
+
await this.releaseLock();
|
|
2303
1875
|
let message = this.t.unlockSuccess;
|
|
2304
1876
|
if (queuedCount > 0) {
|
|
2305
1877
|
message += `\n${this.t.queueProcessed(queuedCount)}`;
|
|
@@ -2332,7 +1904,8 @@ export class AgentRunner {
|
|
|
2332
1904
|
await this.adapter.sendText(target, this.t.lockRenewed(timeoutMin));
|
|
2333
1905
|
}
|
|
2334
1906
|
/**
|
|
2335
|
-
* Export conversation history
|
|
1907
|
+
* Export conversation history as Markdown (a future PDF pipeline can be
|
|
1908
|
+
* added here; `/export pdf` currently falls back to Markdown).
|
|
2336
1909
|
*/
|
|
2337
1910
|
async handleExport(target, format) {
|
|
2338
1911
|
const agent = this.agent;
|
|
@@ -2340,12 +1913,6 @@ export class AgentRunner {
|
|
|
2340
1913
|
await this.adapter.sendText(target, this.t.exportNoSession);
|
|
2341
1914
|
return;
|
|
2342
1915
|
}
|
|
2343
|
-
// Default to markdown if not specified
|
|
2344
|
-
const exportFormat = format ?? "markdown";
|
|
2345
|
-
if (exportFormat === "pdf") {
|
|
2346
|
-
await this.adapter.sendText(target, this.t.exportPdfNotSupported);
|
|
2347
|
-
return;
|
|
2348
|
-
}
|
|
2349
1916
|
try {
|
|
2350
1917
|
const markdown = this.generateMarkdown(agent);
|
|
2351
1918
|
const fileName = `conversation-${agent.id}-${Date.now()}.md`;
|
|
@@ -2414,54 +1981,35 @@ export class AgentRunner {
|
|
|
2414
1981
|
*/
|
|
2415
1982
|
canWrite(channel) {
|
|
2416
1983
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2417
|
-
if (binding
|
|
2418
|
-
return true; // No
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
1984
|
+
if (binding === undefined)
|
|
1985
|
+
return true; // No binding, free access
|
|
1986
|
+
if (lockCanWrite(binding, channel)) {
|
|
1987
|
+
// A timed-out lock counts as free; release it so the next acquire sees a clean state.
|
|
1988
|
+
if (lockIsTimedOut(binding))
|
|
1989
|
+
this.releaseTimeoutLock(binding);
|
|
2422
1990
|
return true;
|
|
2423
1991
|
}
|
|
2424
|
-
return
|
|
1992
|
+
return false;
|
|
2425
1993
|
}
|
|
2426
1994
|
/**
|
|
2427
1995
|
* Acquire session lock for the given channel.
|
|
2428
1996
|
* Returns true if lock acquired, false if already locked by another channel.
|
|
2429
1997
|
*/
|
|
2430
1998
|
acquireLock(channel) {
|
|
2431
|
-
|
|
1999
|
+
let binding = this.bindings.get(this.channel, this.chatKey);
|
|
2432
2000
|
if (binding === undefined)
|
|
2433
2001
|
return true;
|
|
2434
|
-
//
|
|
2435
|
-
|
|
2002
|
+
// A timed-out lock is treated as free: release it (with the user notice)
|
|
2003
|
+
// before acquiring so the next state is clean.
|
|
2004
|
+
if (lockIsTimedOut(binding)) {
|
|
2436
2005
|
this.releaseTimeoutLock(binding);
|
|
2437
|
-
|
|
2438
|
-
const refreshed = this.bindings.get(this.channel, this.chatKey);
|
|
2439
|
-
if (refreshed !== undefined && (refreshed.lockOwner === undefined || refreshed.lockOwner === channel)) {
|
|
2440
|
-
const defaultTimeoutMs = refreshed.lockTimeoutMs ?? 5 * 60 * 1000;
|
|
2441
|
-
this.bindings.put({
|
|
2442
|
-
...refreshed,
|
|
2443
|
-
lockOwner: channel,
|
|
2444
|
-
lockAcquiredAt: Date.now(),
|
|
2445
|
-
lockTimeoutMs: defaultTimeoutMs,
|
|
2446
|
-
lastActiveAt: Date.now()
|
|
2447
|
-
});
|
|
2448
|
-
return true;
|
|
2449
|
-
}
|
|
2450
|
-
}
|
|
2451
|
-
if (binding.lockOwner === undefined || binding.lockOwner === channel) {
|
|
2452
|
-
// Lock is free or already owned by this channel
|
|
2453
|
-
const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000;
|
|
2454
|
-
this.bindings.put({
|
|
2455
|
-
...binding,
|
|
2456
|
-
lockOwner: channel,
|
|
2457
|
-
lockAcquiredAt: Date.now(),
|
|
2458
|
-
lockTimeoutMs: timeoutMs,
|
|
2459
|
-
lastActiveAt: Date.now()
|
|
2460
|
-
});
|
|
2461
|
-
return true;
|
|
2006
|
+
binding = this.bindings.get(this.channel, this.chatKey) ?? binding;
|
|
2462
2007
|
}
|
|
2463
|
-
|
|
2464
|
-
|
|
2008
|
+
const next = lockAcquire(binding, channel);
|
|
2009
|
+
if (next === undefined)
|
|
2010
|
+
return false; // Locked by another channel
|
|
2011
|
+
this.bindings.put({ ...next, lastActiveAt: Date.now() });
|
|
2012
|
+
return true;
|
|
2465
2013
|
}
|
|
2466
2014
|
/**
|
|
2467
2015
|
* Queue a message for later execution when lock is released.
|
|
@@ -2479,6 +2027,8 @@ export class AgentRunner {
|
|
|
2479
2027
|
replyRef: msg.replyRef,
|
|
2480
2028
|
images: msg.images,
|
|
2481
2029
|
files: msg.files,
|
|
2030
|
+
// Keep the true source channel so replay routes to the right runner.
|
|
2031
|
+
channel: msg.channel,
|
|
2482
2032
|
});
|
|
2483
2033
|
this.bindings.put({ ...binding, queuedMessages: queued });
|
|
2484
2034
|
return queued.length;
|
|
@@ -2496,69 +2046,64 @@ export class AgentRunner {
|
|
|
2496
2046
|
const count = messages.length;
|
|
2497
2047
|
// Clear queue immediately to prevent duplicate processing
|
|
2498
2048
|
this.bindings.put({ ...binding, queuedMessages: [] });
|
|
2499
|
-
// Notify about queued messages being processed
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2049
|
+
// Notify about queued messages being processed.
|
|
2050
|
+
await this.adapter
|
|
2051
|
+
.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.queueProcessed(count))
|
|
2052
|
+
.catch(() => undefined);
|
|
2053
|
+
// Replay each queued message through the service routing so it lands in
|
|
2054
|
+
// the runner of its own channel (a web message goes to the web runner,
|
|
2055
|
+
// never the releasing feishu runner). Without a router we fall back to
|
|
2056
|
+
// the local queue so behavior stays functional in isolation.
|
|
2057
|
+
for (const queued of messages) {
|
|
2058
|
+
const inboundMsg = {
|
|
2059
|
+
channel: queued.channel ?? "web",
|
|
2060
|
+
chatKey: this.chatKey,
|
|
2061
|
+
chatType: this.chatType,
|
|
2062
|
+
senderKey: queued.senderKey,
|
|
2063
|
+
text: queued.text,
|
|
2064
|
+
replyRef: queued.replyRef,
|
|
2065
|
+
images: queued.images,
|
|
2066
|
+
files: queued.files,
|
|
2067
|
+
};
|
|
2068
|
+
if (this.requeue !== undefined) {
|
|
2069
|
+
this.requeue(inboundMsg);
|
|
2070
|
+
}
|
|
2071
|
+
else {
|
|
2519
2072
|
this.queue.push(inboundMsg);
|
|
2520
2073
|
}
|
|
2521
|
-
// Trigger processing if not already running
|
|
2522
|
-
void this.drain();
|
|
2523
2074
|
}
|
|
2075
|
+
if (this.requeue === undefined)
|
|
2076
|
+
void this.drain();
|
|
2524
2077
|
}
|
|
2525
|
-
/**
|
|
2526
|
-
|
|
2078
|
+
/**
|
|
2079
|
+
* Release session lock. Awaits the queued-message drain, then clears the
|
|
2080
|
+
* lock from a FRESH read: the drain's queue-clearing put must not be
|
|
2081
|
+
* overwritten by a stale binding object (which would resurrect the queue
|
|
2082
|
+
* and cause duplicate processing on the next release).
|
|
2083
|
+
*/
|
|
2084
|
+
async releaseLock() {
|
|
2527
2085
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2528
|
-
if (binding
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
}
|
|
2534
|
-
/** Check if lock has timed out. */
|
|
2535
|
-
isLockTimedOut(binding) {
|
|
2536
|
-
if (binding.lockOwner === undefined || binding.lockAcquiredAt === undefined) {
|
|
2537
|
-
return false;
|
|
2538
|
-
}
|
|
2539
|
-
const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000; // Default 5 minutes
|
|
2540
|
-
const elapsed = Date.now() - binding.lockAcquiredAt;
|
|
2541
|
-
return elapsed > timeoutMs;
|
|
2086
|
+
if (binding === undefined || binding.lockOwner === undefined)
|
|
2087
|
+
return;
|
|
2088
|
+
await this.processQueuedMessages();
|
|
2089
|
+
const fresh = this.bindings.get(this.channel, this.chatKey) ?? binding;
|
|
2090
|
+
this.bindings.put(lockRelease(fresh));
|
|
2542
2091
|
}
|
|
2543
|
-
/** Check and release timed-out locks. */
|
|
2092
|
+
/** Check and release timed-out locks (delegates to the pure lock module). */
|
|
2544
2093
|
checkAndReleaseTimeoutLock() {
|
|
2545
2094
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2546
|
-
if (binding &&
|
|
2095
|
+
if (binding !== undefined && lockIsTimedOut(binding)) {
|
|
2547
2096
|
this.releaseTimeoutLock(binding);
|
|
2548
2097
|
}
|
|
2549
2098
|
}
|
|
2550
2099
|
/** Release a timed-out lock and notify. */
|
|
2551
2100
|
releaseTimeoutLock(binding) {
|
|
2552
|
-
const timeoutMin = Math.round((binding.lockTimeoutMs ??
|
|
2101
|
+
const timeoutMin = Math.round((binding.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS) / 60000);
|
|
2553
2102
|
const lockedBy = binding.lockOwner;
|
|
2554
|
-
this.bindings.put(
|
|
2555
|
-
...binding,
|
|
2556
|
-
lockOwner: undefined,
|
|
2557
|
-
lockAcquiredAt: undefined
|
|
2558
|
-
});
|
|
2103
|
+
this.bindings.put(lockRelease(binding));
|
|
2559
2104
|
// Notify users about timeout release
|
|
2560
2105
|
const adapter = this.adapters?.get(this.channel);
|
|
2561
|
-
if (adapter && lockedBy) {
|
|
2106
|
+
if (adapter !== undefined && lockedBy !== undefined) {
|
|
2562
2107
|
void adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.lockTimeoutReleased(timeoutMin)).catch(() => undefined);
|
|
2563
2108
|
}
|
|
2564
2109
|
}
|