dsh-connect 0.7.0 → 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/index.d.ts +10 -4
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -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/runner.d.ts +51 -89
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +45 -608
- package/lib/runner.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 +11 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +2 -2
package/lib/runner.js
CHANGED
|
@@ -10,8 +10,10 @@ 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";
|
|
16
18
|
import { DEFAULT_LOCK_TIMEOUT_MS, acquire as lockAcquire, canWrite as lockCanWrite, isLockTimedOut as lockIsTimedOut, release as lockRelease, } from "./mirror-lock.js";
|
|
17
19
|
import { formatRemindAt, parseRemindTime } from "./scheduler.js";
|
|
@@ -27,280 +29,10 @@ export function resolveConnectConfig(config) {
|
|
|
27
29
|
stateDir: config.stateDir,
|
|
28
30
|
autoMirror: config.autoMirror ?? true, // Enabled by default
|
|
29
31
|
streamHeartbeatMs: config.streamHeartbeatMs ?? 60_000,
|
|
30
|
-
notifyLevel: config.notifyLevel ?? "
|
|
32
|
+
notifyLevel: config.notifyLevel ?? "result",
|
|
31
33
|
progressTimeoutMs: config.progressTimeoutMs ?? 5 * 60_000, // Proactive progress notice after 5 min of silence
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
|
-
/**
|
|
35
|
-
* Assemble one `assistant/chunk` event into the streaming queue with visible
|
|
36
|
-
* structure. DSH streams one block per LLM content block and block boundaries
|
|
37
|
-
* carry no newline of their own, so without separators every block (and the
|
|
38
|
-
* reasoning phase vs the final answer) would be glued together in the chat
|
|
39
|
-
* card. This inserts a blank line between blocks and before the answer, keeps
|
|
40
|
-
* the thinking hint once per turn, expands reasoning soft breaks (Feishu's
|
|
41
|
-
* streaming card collapses single `\n`), and falls back to whole-block text
|
|
42
|
-
* for blocks that arrived only via `block-end`. Every push refreshes
|
|
43
|
-
* `lastPushAt` so the liveness heartbeat knows the card is still moving.
|
|
44
|
-
* @param state - the turn's mutable assembly state.
|
|
45
|
-
* @param thinkingHint - localized hint text pushed at the first reasoning delta.
|
|
46
|
-
* @param chunk - the assistant chunk event's `chunk` payload.
|
|
47
|
-
*/
|
|
48
|
-
/** Every line-break run becomes a paragraph break (Feishu's streaming card collapses single `\n`). */
|
|
49
|
-
function expandLineBreaks(text) {
|
|
50
|
-
return text.replace(/\r\n?/g, "\n").replace(/\n+/g, "\n\n");
|
|
51
|
-
}
|
|
52
|
-
export function applyStreamChunk(state, thinkingHint, chunk, level = "full") {
|
|
53
|
-
if (chunk.type === "text-delta" || chunk.type === "reasoning-delta") {
|
|
54
|
-
const isReasoning = chunk.type === "reasoning-delta";
|
|
55
|
-
// Reasoning content is only streamed in `full` mode; `important` shows
|
|
56
|
-
// just the thinking hint and `result` shows nothing until the answer.
|
|
57
|
-
if (isReasoning && level !== "full") {
|
|
58
|
-
if (level === "important" && !state.hintPushed) {
|
|
59
|
-
state.hintPushed = true;
|
|
60
|
-
state.chunks.push(thinkingHint);
|
|
61
|
-
state.pushedAny = true;
|
|
62
|
-
state.lastPushAt = Date.now();
|
|
63
|
-
}
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
if (state.pushedAny) {
|
|
67
|
-
const newBlock = chunk.index !== undefined && state.lastIndex !== undefined && chunk.index !== state.lastIndex;
|
|
68
|
-
const reasoningToAnswer = !isReasoning && state.reasoning;
|
|
69
|
-
if (newBlock || reasoningToAnswer)
|
|
70
|
-
state.chunks.push("\n\n");
|
|
71
|
-
}
|
|
72
|
-
if (isReasoning) {
|
|
73
|
-
if (!state.hintPushed) {
|
|
74
|
-
state.hintPushed = true;
|
|
75
|
-
state.chunks.push(thinkingHint);
|
|
76
|
-
}
|
|
77
|
-
const text = chunk.text ?? "";
|
|
78
|
-
if (text !== "") {
|
|
79
|
-
state.chunks.push(expandLineBreaks(text));
|
|
80
|
-
}
|
|
81
|
-
state.reasoning = true;
|
|
82
|
-
}
|
|
83
|
-
else {
|
|
84
|
-
const text = chunk.text ?? "";
|
|
85
|
-
state.chunks.push(text);
|
|
86
|
-
state.lastText += text;
|
|
87
|
-
state.reasoning = false;
|
|
88
|
-
}
|
|
89
|
-
state.lastIndex = chunk.index;
|
|
90
|
-
state.pushedAny = true;
|
|
91
|
-
state.lastPushAt = Date.now();
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (chunk.type === "block-end") {
|
|
95
|
-
// Short blocks may arrive whole here with no preceding delta chunks.
|
|
96
|
-
// Fall back to the block text when this index never streamed deltas, so
|
|
97
|
-
// no content is silently dropped (guarded by index against duplication;
|
|
98
|
-
// each step restarts block indices, and a tool call resets `lastIndex`,
|
|
99
|
-
// so a same-index block-end after streamed deltas is reliably detected).
|
|
100
|
-
const index = chunk.index;
|
|
101
|
-
const blockType = chunk.block?.type;
|
|
102
|
-
const text = chunk.block?.text;
|
|
103
|
-
if (blockType === "reasoning" && level !== "full") {
|
|
104
|
-
// Reasoning block text is never streamed outside `full` mode.
|
|
105
|
-
if (level === "important" && !state.hintPushed) {
|
|
106
|
-
state.hintPushed = true;
|
|
107
|
-
state.chunks.push(thinkingHint);
|
|
108
|
-
state.pushedAny = true;
|
|
109
|
-
state.lastPushAt = Date.now();
|
|
110
|
-
}
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
if (typeof text === "string" && text.length > 0 && (index === undefined || index !== state.lastIndex)) {
|
|
114
|
-
if (state.pushedAny)
|
|
115
|
-
state.chunks.push("\n\n");
|
|
116
|
-
if (blockType === "reasoning" && !state.hintPushed) {
|
|
117
|
-
state.hintPushed = true;
|
|
118
|
-
state.chunks.push(thinkingHint);
|
|
119
|
-
}
|
|
120
|
-
state.chunks.push(blockType === "reasoning" ? expandLineBreaks(text) : text);
|
|
121
|
-
if (blockType !== "reasoning")
|
|
122
|
-
state.lastText += text;
|
|
123
|
-
state.lastIndex = index;
|
|
124
|
-
state.pushedAny = true;
|
|
125
|
-
state.reasoning = blockType === "reasoning";
|
|
126
|
-
state.lastPushAt = Date.now();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Push a localized tool-call status line into the streaming queue so long
|
|
132
|
-
* tool-execution stretches stay visibly alive (the Web GUI shows tool calls;
|
|
133
|
-
* the chat card should too). Ends the reasoning phase and drops the block
|
|
134
|
-
* index so the next content block gets its own separator instead of stacking
|
|
135
|
-
* one on top of the tool line's own blank lines.
|
|
136
|
-
* @param state - the turn's mutable assembly state.
|
|
137
|
-
* @param label - the localized status line, e.g. "🔧 调用工具 `pwsh` — …".
|
|
138
|
-
*/
|
|
139
|
-
export function applyToolCall(state, label) {
|
|
140
|
-
state.chunks.push(`\n\n${label}\n\n`);
|
|
141
|
-
state.reasoning = false;
|
|
142
|
-
state.lastIndex = undefined;
|
|
143
|
-
state.pushedAny = true;
|
|
144
|
-
state.lastPushAt = Date.now();
|
|
145
|
-
}
|
|
146
|
-
/** Pull a short human summary out of a tool call's `arguments` JSON (whitespace folded to one line). */
|
|
147
|
-
export function toolCallSummary(argumentsJson) {
|
|
148
|
-
if (typeof argumentsJson !== "string")
|
|
149
|
-
return undefined;
|
|
150
|
-
const fold = (value) => value.replace(/\s+/g, " ").trim();
|
|
151
|
-
try {
|
|
152
|
-
const parsed = JSON.parse(argumentsJson);
|
|
153
|
-
const description = parsed?.description;
|
|
154
|
-
if (typeof description === "string" && description !== "") {
|
|
155
|
-
const folded = fold(description);
|
|
156
|
-
return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
|
|
157
|
-
}
|
|
158
|
-
for (const key of ["file_path", "path"]) {
|
|
159
|
-
const value = parsed?.[key];
|
|
160
|
-
if (typeof value === "string" && value !== "") {
|
|
161
|
-
const folded = fold(value);
|
|
162
|
-
return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
// Not JSON — no summary.
|
|
168
|
-
}
|
|
169
|
-
return undefined;
|
|
170
|
-
}
|
|
171
|
-
/** Pull the first question's text out of an `ask_user_question` arguments JSON (whitespace folded to one line). */
|
|
172
|
-
export function questionTextOf(argumentsJson) {
|
|
173
|
-
if (typeof argumentsJson !== "string")
|
|
174
|
-
return "";
|
|
175
|
-
try {
|
|
176
|
-
const parsed = JSON.parse(argumentsJson);
|
|
177
|
-
const first = Array.isArray(parsed?.questions) ? parsed.questions[0] : undefined;
|
|
178
|
-
const text = typeof first?.question === "string" ? first.question : "";
|
|
179
|
-
const folded = text.replace(/\s+/g, " ").trim();
|
|
180
|
-
return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
// Not JSON — no question text.
|
|
184
|
-
}
|
|
185
|
-
return "";
|
|
186
|
-
}
|
|
187
|
-
function mapReason(kind) {
|
|
188
|
-
switch (kind) {
|
|
189
|
-
case "completed":
|
|
190
|
-
case "aborted":
|
|
191
|
-
case "blocked":
|
|
192
|
-
case "error":
|
|
193
|
-
case "max-tokens":
|
|
194
|
-
case "interrupted":
|
|
195
|
-
return kind;
|
|
196
|
-
default:
|
|
197
|
-
return "unknown";
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
export function summarizeTurn(events, firstSeq) {
|
|
201
|
-
let started = false;
|
|
202
|
-
let text = "";
|
|
203
|
-
let reason = "unknown";
|
|
204
|
-
let code;
|
|
205
|
-
let message;
|
|
206
|
-
let model;
|
|
207
|
-
let inputTokens = 0;
|
|
208
|
-
let outputTokens = 0;
|
|
209
|
-
let cacheReadTokens = 0;
|
|
210
|
-
let contextSize;
|
|
211
|
-
let contextWindow;
|
|
212
|
-
let steps = 0;
|
|
213
|
-
let turnStartTime;
|
|
214
|
-
let turnEndTime;
|
|
215
|
-
for (const event of events) {
|
|
216
|
-
if (event.seq < firstSeq)
|
|
217
|
-
continue;
|
|
218
|
-
if (event.type === "turn/start") {
|
|
219
|
-
started = true;
|
|
220
|
-
turnStartTime = typeof event.time === "number" ? event.time : undefined;
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
if (!started)
|
|
224
|
-
continue;
|
|
225
|
-
if (event.type === "step/start")
|
|
226
|
-
steps += 1;
|
|
227
|
-
if (event.type === "request/context") {
|
|
228
|
-
const provider = typeof event.data.provider === "string" ? event.data.provider : undefined;
|
|
229
|
-
const m = typeof event.data.model === "string" ? event.data.model : undefined;
|
|
230
|
-
if (provider !== undefined && m !== undefined)
|
|
231
|
-
model = `${provider}/${m}`;
|
|
232
|
-
if (typeof event.data.contextWindow === "number")
|
|
233
|
-
contextWindow = event.data.contextWindow;
|
|
234
|
-
}
|
|
235
|
-
if (event.type === "assistant/message") {
|
|
236
|
-
const usage = event.data.usage;
|
|
237
|
-
if (usage !== undefined) {
|
|
238
|
-
inputTokens += typeof usage.inputTokens === "number" ? usage.inputTokens : 0;
|
|
239
|
-
outputTokens += typeof usage.outputTokens === "number" ? usage.outputTokens : 0;
|
|
240
|
-
cacheReadTokens += typeof usage.cacheReadTokens === "number" ? usage.cacheReadTokens : 0;
|
|
241
|
-
if (typeof usage.inputTokens === "number")
|
|
242
|
-
contextSize = usage.inputTokens;
|
|
243
|
-
}
|
|
244
|
-
const joined = event.data.message.content
|
|
245
|
-
.filter((block) => block.type === "text")
|
|
246
|
-
.map((block) => block.text)
|
|
247
|
-
.join("");
|
|
248
|
-
if (joined !== "")
|
|
249
|
-
text = joined;
|
|
250
|
-
}
|
|
251
|
-
if (event.type === "turn/end") {
|
|
252
|
-
turnEndTime = typeof event.time === "number" ? event.time : undefined;
|
|
253
|
-
reason = mapReason(event.data.reason.kind);
|
|
254
|
-
if (event.data.reason.kind === "error") {
|
|
255
|
-
code = event.data.reason.error.code;
|
|
256
|
-
message = event.data.reason.error.message;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
const elapsedMs = turnStartTime !== undefined && turnEndTime !== undefined && turnEndTime >= turnStartTime
|
|
261
|
-
? turnEndTime - turnStartTime
|
|
262
|
-
: undefined;
|
|
263
|
-
return {
|
|
264
|
-
reason,
|
|
265
|
-
text,
|
|
266
|
-
...(code === undefined ? {} : { code }),
|
|
267
|
-
...(message === undefined ? {} : { message }),
|
|
268
|
-
...(model === undefined ? {} : { model }),
|
|
269
|
-
...(inputTokens > 0 ? { inputTokens } : {}),
|
|
270
|
-
...(outputTokens > 0 ? { outputTokens } : {}),
|
|
271
|
-
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
|
|
272
|
-
...(contextSize === undefined ? {} : { contextSize }),
|
|
273
|
-
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
274
|
-
...(steps > 0 ? { steps } : {}),
|
|
275
|
-
...(elapsedMs === undefined ? {} : { elapsedMs }),
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
function truncate(text, max = 500) {
|
|
279
|
-
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
280
|
-
}
|
|
281
|
-
/** Classify an error message into an advice bucket (keyword-based, best-effort). */
|
|
282
|
-
export function classifyError(detail) {
|
|
283
|
-
const d = detail.toLowerCase();
|
|
284
|
-
if (/(permission|forbidden|denied|not have access|no access|unauthorized|403|sandbox|acl |isn't permitted|not permitted|access denied)/.test(d))
|
|
285
|
-
return "permission";
|
|
286
|
-
if (/(timeout|econnrefused|econnreset|enotfound|enetunreach|etimedout|socket|network|proxy|tls|certificate|fetch failed|connect |unreachable|dns)/.test(d))
|
|
287
|
-
return "network";
|
|
288
|
-
if (/(model|quota|rate ?limit|429|insufficient|balance|token(s)? (limit|length)|max_tokens|context length|credit)/.test(d))
|
|
289
|
-
return "model";
|
|
290
|
-
return "generic";
|
|
291
|
-
}
|
|
292
|
-
/** Thousands-separated token count for display. */
|
|
293
|
-
function fmtTokens(n) {
|
|
294
|
-
return n.toLocaleString("en-US");
|
|
295
|
-
}
|
|
296
|
-
function textOf(message) {
|
|
297
|
-
if (message === undefined)
|
|
298
|
-
return "";
|
|
299
|
-
return message.content
|
|
300
|
-
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
301
|
-
.map((b) => b.text ?? "")
|
|
302
|
-
.join("");
|
|
303
|
-
}
|
|
304
36
|
/** Lightweight fold of `schedule/change` events (create / delete / dispatch). */
|
|
305
37
|
function foldReminders(events) {
|
|
306
38
|
const active = new Map();
|
|
@@ -378,6 +110,7 @@ export class AgentRunner {
|
|
|
378
110
|
notifyLevel;
|
|
379
111
|
progressTimeoutMs;
|
|
380
112
|
t;
|
|
113
|
+
menu;
|
|
381
114
|
constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters,
|
|
382
115
|
/**
|
|
383
116
|
* Route a replayed queued message back into the runner of its own channel
|
|
@@ -404,54 +137,16 @@ export class AgentRunner {
|
|
|
404
137
|
this.notifyLevel = stored?.notifyLevel ?? config.notifyLevel;
|
|
405
138
|
this.progressTimeoutMs = stored?.progressTimeoutMs ?? config.progressTimeoutMs;
|
|
406
139
|
this.t = messages(this.language);
|
|
140
|
+
this.menu = new MenuController(this);
|
|
407
141
|
}
|
|
408
142
|
menuTitle(menuId) {
|
|
409
|
-
|
|
410
|
-
case "root":
|
|
411
|
-
return this.t.menuRoot;
|
|
412
|
-
case "workspace":
|
|
413
|
-
return this.t.menuWorkspace;
|
|
414
|
-
case "chat":
|
|
415
|
-
return this.t.menuChat;
|
|
416
|
-
case "settings":
|
|
417
|
-
return this.t.menuSettings;
|
|
418
|
-
case "model":
|
|
419
|
-
return this.t.menuModel;
|
|
420
|
-
case "reasoning":
|
|
421
|
-
return this.t.menuReasoning;
|
|
422
|
-
case "notify":
|
|
423
|
-
return this.t.menuNotify;
|
|
424
|
-
case "language":
|
|
425
|
-
return this.t.menuSettingsLanguage;
|
|
426
|
-
case "progress":
|
|
427
|
-
return this.t.progressMenuTitle;
|
|
428
|
-
}
|
|
143
|
+
return menuTitle(menuId, this.t);
|
|
429
144
|
}
|
|
430
145
|
rootMenuSections() {
|
|
431
|
-
return
|
|
432
|
-
{ title: this.t.rootSectionWorkspace, ids: ["workspace"], columnsPerRow: 1 },
|
|
433
|
-
{ title: this.t.rootSectionChat, ids: ["chat", "history"], columnsPerRow: 1 },
|
|
434
|
-
{ title: this.t.rootSectionTask, ids: ["task", "goals", "schedule"], columnsPerRow: 2 },
|
|
435
|
-
{ title: this.t.rootSectionSystem, ids: ["status", "plugins", "compact", "settings"], columnsPerRow: 2 },
|
|
436
|
-
];
|
|
146
|
+
return rootMenuSections(this.t);
|
|
437
147
|
}
|
|
438
148
|
reasonLabel(reason) {
|
|
439
|
-
|
|
440
|
-
case "completed":
|
|
441
|
-
return this.t.reasonCompleted;
|
|
442
|
-
case "aborted":
|
|
443
|
-
return this.t.reasonAborted;
|
|
444
|
-
case "blocked":
|
|
445
|
-
return this.t.reasonBlocked;
|
|
446
|
-
case "error":
|
|
447
|
-
return this.t.reasonError;
|
|
448
|
-
case "max-tokens":
|
|
449
|
-
return this.t.reasonMaxTokens;
|
|
450
|
-
case "interrupted":
|
|
451
|
-
return this.t.reasonInterrupted;
|
|
452
|
-
default:
|
|
453
|
-
return this.t.reasonUnknown;
|
|
454
|
-
}
|
|
149
|
+
return reasonLabel(reason, this.t);
|
|
455
150
|
}
|
|
456
151
|
/** Prefer the user's first DSH workspace over the process cwd as default. */
|
|
457
152
|
resolveDefaultWorkDir() {
|
|
@@ -528,16 +223,18 @@ export class AgentRunner {
|
|
|
528
223
|
else {
|
|
529
224
|
turn.milestone = this.t.toolCalling(name, undefined);
|
|
530
225
|
}
|
|
531
|
-
// `result` mode shows nothing until the final answer
|
|
532
|
-
|
|
533
|
-
if (this.notifyLevel === "result")
|
|
226
|
+
// `result` mode shows nothing until the final answer.
|
|
227
|
+
if (this.notifyLevel === 'result')
|
|
534
228
|
return;
|
|
535
|
-
|
|
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)
|
|
233
|
+
return;
|
|
234
|
+
turn.lastToolName = name;
|
|
235
|
+
const label = name === 'ask_user_question'
|
|
536
236
|
? this.t.questionToolCall(questionTextOf(event.data.arguments))
|
|
537
|
-
: this.t.
|
|
538
|
-
const label = name === "ask_user_question" || this.notifyLevel !== "full" || summary === undefined
|
|
539
|
-
? base
|
|
540
|
-
: `${base} — ${summary}`;
|
|
237
|
+
: this.t.toolCalling(name, this.notifyLevel === 'full' ? summary : undefined);
|
|
541
238
|
applyToolCall(turn, label);
|
|
542
239
|
}
|
|
543
240
|
}
|
|
@@ -915,7 +612,7 @@ export class AgentRunner {
|
|
|
915
612
|
this.turn = {
|
|
916
613
|
firstSeq, chunks, lastText: "",
|
|
917
614
|
reasoning: false, hintPushed: false, lastIndex: undefined, pushedAny: false,
|
|
918
|
-
startedAt: now, lastPushAt: now, toolCount: 0, contextNudged: false, compactAfterTurn: false,
|
|
615
|
+
startedAt: now, lastPushAt: now, toolCount: 0, lastToolName: undefined, contextNudged: false, compactAfterTurn: false,
|
|
919
616
|
};
|
|
920
617
|
// Liveness heartbeat: while the agent is working, keep the streaming card
|
|
921
618
|
// visibly alive even through long reasoning or tool-execution stretches
|
|
@@ -936,26 +633,29 @@ export class AgentRunner {
|
|
|
936
633
|
}, heartbeatMs)
|
|
937
634
|
: undefined;
|
|
938
635
|
// Proactive progress watchdog: when no standalone card/text has been sent
|
|
939
|
-
// for `progressTimeoutMs` (default 5 min, user-configurable),
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
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.
|
|
943
642
|
const progressTimeoutMs = this.progressTimeoutMs;
|
|
944
|
-
let
|
|
643
|
+
let lastProgressNoticeAt = now;
|
|
945
644
|
const progressWatchdog = progressTimeoutMs > 0
|
|
946
645
|
? setInterval(() => {
|
|
947
646
|
const turn = this.turn;
|
|
948
647
|
if (turn === undefined)
|
|
949
648
|
return;
|
|
950
|
-
const elapsed = Date.now() -
|
|
649
|
+
const elapsed = Date.now() - lastProgressNoticeAt;
|
|
951
650
|
if (elapsed < progressTimeoutMs)
|
|
952
651
|
return;
|
|
953
|
-
|
|
652
|
+
lastProgressNoticeAt = Date.now();
|
|
954
653
|
const minutes = Math.max(1, Math.round((Date.now() - turn.startedAt) / 60_000));
|
|
955
654
|
const status = turn.milestone ?? this.t.progressThinking;
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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`);
|
|
959
659
|
}, PROGRESS_WATCHDOG_CHECK_MS)
|
|
960
660
|
: undefined;
|
|
961
661
|
// End the chunk stream unconditionally: if `followup` / `whenIdle` /
|
|
@@ -1320,7 +1020,7 @@ export class AgentRunner {
|
|
|
1320
1020
|
}
|
|
1321
1021
|
case "dir": {
|
|
1322
1022
|
if (command.path === undefined) {
|
|
1323
|
-
await this.openMenu(target, msg, "workspace", ["root"]);
|
|
1023
|
+
await this.menu.openMenu(target, msg, "workspace", ["root"]);
|
|
1324
1024
|
break;
|
|
1325
1025
|
}
|
|
1326
1026
|
const path = command.path;
|
|
@@ -1344,15 +1044,15 @@ export class AgentRunner {
|
|
|
1344
1044
|
break;
|
|
1345
1045
|
}
|
|
1346
1046
|
case "chat": {
|
|
1347
|
-
await this.openMenu(target, msg, "chat", ["root"]);
|
|
1047
|
+
await this.menu.openMenu(target, msg, "chat", ["root"]);
|
|
1348
1048
|
break;
|
|
1349
1049
|
}
|
|
1350
1050
|
case "menu": {
|
|
1351
|
-
await this.openMenu(target, msg, "root");
|
|
1051
|
+
await this.menu.openMenu(target, msg, "root");
|
|
1352
1052
|
break;
|
|
1353
1053
|
}
|
|
1354
1054
|
case "settings": {
|
|
1355
|
-
await this.openMenu(target, msg, "settings", ["root"]);
|
|
1055
|
+
await this.menu.openMenu(target, msg, "settings", ["root"]);
|
|
1356
1056
|
break;
|
|
1357
1057
|
}
|
|
1358
1058
|
case "plugins": {
|
|
@@ -1384,7 +1084,7 @@ export class AgentRunner {
|
|
|
1384
1084
|
break;
|
|
1385
1085
|
}
|
|
1386
1086
|
case "model": {
|
|
1387
|
-
await this.openMenu(target, msg, "model", ["root"]);
|
|
1087
|
+
await this.menu.openMenu(target, msg, "model", ["root"]);
|
|
1388
1088
|
break;
|
|
1389
1089
|
}
|
|
1390
1090
|
case "notify": {
|
|
@@ -1676,198 +1376,12 @@ export class AgentRunner {
|
|
|
1676
1376
|
await this.disposeAgent();
|
|
1677
1377
|
}
|
|
1678
1378
|
listWorkspaces() {
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
const key = path.replace(/[\\/]+$/, "").toLowerCase();
|
|
1686
|
-
if (seen.has(key))
|
|
1687
|
-
return;
|
|
1688
|
-
seen.add(key);
|
|
1689
|
-
out.push({ path, title: title || path });
|
|
1690
|
-
};
|
|
1691
|
-
add(this.workDir, this.t.currentDir);
|
|
1692
|
-
for (const w of registry?.list?.() ?? [])
|
|
1693
|
-
add(w.path, w.title);
|
|
1694
|
-
for (const p of this.config.workspaces)
|
|
1695
|
-
add(p, p);
|
|
1696
|
-
return out;
|
|
1697
|
-
}
|
|
1698
|
-
async openMenu(target, msg, menuId, stack = [], cardId) {
|
|
1699
|
-
const items = await this.menuItems(menuId);
|
|
1700
|
-
const options = items.map((i) => ({ id: i.id, label: i.label }));
|
|
1701
|
-
options.push({ id: "menu:exit", label: this.t.menuExit });
|
|
1702
|
-
if (stack.length > 0)
|
|
1703
|
-
options.push({ id: "menu:back", label: this.t.menuBack });
|
|
1704
|
-
// Determine columns per row based on menu type
|
|
1705
|
-
// Workspace and chat lists use 1 column (full width), others use 2 columns
|
|
1706
|
-
const columnsPerRow = (menuId === "workspace" || menuId === "chat") ? 1 : 2;
|
|
1707
|
-
const { choice, messageId } = await this.adapter.promptChoice(target, {
|
|
1708
|
-
title: this.menuTitle(menuId),
|
|
1709
|
-
options,
|
|
1710
|
-
columnsPerRow,
|
|
1711
|
-
...(menuId === "root" ? { sections: this.rootMenuSections(), footer: this.t.rootMenuFooter } : {}),
|
|
1712
|
-
}, cardId);
|
|
1713
|
-
if (choice === undefined)
|
|
1714
|
-
return;
|
|
1715
|
-
if (choice === "menu:exit") {
|
|
1716
|
-
await this.adapter.closeMenu(messageId, this.t.menuClosed);
|
|
1717
|
-
return;
|
|
1718
|
-
}
|
|
1719
|
-
if (choice === "menu:back") {
|
|
1720
|
-
const parent = stack[stack.length - 1];
|
|
1721
|
-
await this.openMenu(target, msg, parent, stack.slice(0, -1), messageId);
|
|
1722
|
-
return;
|
|
1723
|
-
}
|
|
1724
|
-
const item = items.find((i) => i.id === choice);
|
|
1725
|
-
if (item === undefined) {
|
|
1726
|
-
// The tap belonged to a previous card generation (e.g. a rapid second
|
|
1727
|
-
// tap on the old menu while the new one was still being redrawn). Don't
|
|
1728
|
-
// silently swallow it: redraw the current menu so the chain stays usable.
|
|
1729
|
-
await this.openMenu(target, msg, menuId, stack, messageId);
|
|
1730
|
-
return;
|
|
1731
|
-
}
|
|
1732
|
-
const feedback = await item.onSelect(target, msg, messageId);
|
|
1733
|
-
if (item.leaf === true) {
|
|
1734
|
-
// Every leaf press must answer visibly: send the action's result feedback
|
|
1735
|
-
// (if any) before returning to the root menu on the same card.
|
|
1736
|
-
if (feedback !== undefined && feedback !== "") {
|
|
1737
|
-
await this.adapter.sendText(target, feedback).catch(() => undefined);
|
|
1738
|
-
}
|
|
1739
|
-
await this.openMenu(target, msg, "root", [], messageId);
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
async menuItems(menuId) {
|
|
1743
|
-
switch (menuId) {
|
|
1744
|
-
case "root":
|
|
1745
|
-
return [
|
|
1746
|
-
{ id: "workspace", label: this.t.menuWorkspaceAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "workspace", ["root"], cardId) },
|
|
1747
|
-
{ id: "chat", label: this.t.menuChatAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "chat", ["root"], cardId) },
|
|
1748
|
-
{ id: "status", label: this.t.menuStatusAction, leaf: true, onSelect: async (t) => { await this.showStatus(t); } },
|
|
1749
|
-
{ id: "task", label: this.t.menuTaskAction, leaf: true, onSelect: async (t) => { await this.showTasks(t); } },
|
|
1750
|
-
{ id: "history", label: this.t.menuHistoryAction, leaf: true, onSelect: async (t) => { await this.showHistory(t, 10); } },
|
|
1751
|
-
{ id: "goals", label: this.t.menuGoalsAction, leaf: true, onSelect: async (t) => { await this.showGoals(t); } },
|
|
1752
|
-
{ id: "schedule", label: this.t.menuScheduleAction, leaf: true, onSelect: async (t) => { await this.showSchedule(t); } },
|
|
1753
|
-
{ id: "compact", label: this.t.menuCompactAction, leaf: true, onSelect: async (t) => { await this.compact(t); } },
|
|
1754
|
-
{ id: "plugins", label: this.t.menuPluginsAction, leaf: true, onSelect: async (t) => { await this.showPlugins(t); } },
|
|
1755
|
-
{ id: "settings", label: this.t.menuSettingsAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "settings", ["root"], cardId) },
|
|
1756
|
-
];
|
|
1757
|
-
case "workspace": {
|
|
1758
|
-
const workspaces = this.listWorkspaces();
|
|
1759
|
-
return workspaces.map((w) => ({
|
|
1760
|
-
id: `dir:${w.path}`,
|
|
1761
|
-
label: `${w.path === this.workDir ? "● " : ""}${w.title}${w.title !== w.path ? ` (${w.path})` : ""}`,
|
|
1762
|
-
leaf: true,
|
|
1763
|
-
onSelect: async (t, m) => {
|
|
1764
|
-
this.workDir = w.path;
|
|
1765
|
-
await this.newChat(m);
|
|
1766
|
-
return this.t.dirSwitched(w.path);
|
|
1767
|
-
},
|
|
1768
|
-
}));
|
|
1769
|
-
}
|
|
1770
|
-
case "chat": {
|
|
1771
|
-
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1772
|
-
const active = binding?.sessionId;
|
|
1773
|
-
const hasWebMirror = binding?.webMirrorSessionId !== undefined;
|
|
1774
|
-
const items = (await this.collectWorkdirSessions()).map(({ sessionId, title }) => {
|
|
1775
|
-
const isMirrored = hasWebMirror && binding?.webMirrorSessionId === sessionId;
|
|
1776
|
-
const mirrorIndicator = isMirrored ? ` ${this.t.webMirrorIndicator}` : "";
|
|
1777
|
-
const activeIndicator = sessionId === active ? "●" : "○";
|
|
1778
|
-
return {
|
|
1779
|
-
id: `session:${sessionId}`,
|
|
1780
|
-
label: `${activeIndicator} ${title || sessionId}${mirrorIndicator}`,
|
|
1781
|
-
leaf: true,
|
|
1782
|
-
onSelect: async (t, m) => {
|
|
1783
|
-
await this.switchTo(sessionId, m.senderKey);
|
|
1784
|
-
return this.t.sessionSwitched(title || sessionId);
|
|
1785
|
-
},
|
|
1786
|
-
};
|
|
1787
|
-
});
|
|
1788
|
-
if (items.length === 0) {
|
|
1789
|
-
items.push({
|
|
1790
|
-
id: "none:history",
|
|
1791
|
-
label: this.t.noSessionsInWorkdir(this.workDir),
|
|
1792
|
-
leaf: true,
|
|
1793
|
-
onSelect: async () => this.t.noSessionsInWorkdir(this.workDir),
|
|
1794
|
-
});
|
|
1795
|
-
}
|
|
1796
|
-
items.push({
|
|
1797
|
-
id: "action:new",
|
|
1798
|
-
label: this.t.menuNewChat,
|
|
1799
|
-
leaf: true,
|
|
1800
|
-
onSelect: async (t, m, messageId) => {
|
|
1801
|
-
// Reuse the menu card for the confirm prompt so no stale card is left behind.
|
|
1802
|
-
if (await this.confirmAction(t, this.t.confirmNewText, messageId)) {
|
|
1803
|
-
await this.newChat(m);
|
|
1804
|
-
return this.t.newChatDone;
|
|
1805
|
-
}
|
|
1806
|
-
return this.t.actionCancelled;
|
|
1807
|
-
},
|
|
1808
|
-
});
|
|
1809
|
-
return items;
|
|
1810
|
-
}
|
|
1811
|
-
case "settings":
|
|
1812
|
-
return [
|
|
1813
|
-
{ id: "model", label: this.t.menuSettingsModel, onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
|
|
1814
|
-
{ id: "reasoning", label: this.t.menuSettingsReasoning, onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
|
|
1815
|
-
{ id: "notify", label: this.t.menuSettingsNotify, onSelect: (t, m, cardId) => this.openMenu(t, m, "notify", ["settings", "root"], cardId) },
|
|
1816
|
-
{ id: "progress", label: this.t.menuSettingsProgress, onSelect: (t, m, cardId) => this.openMenu(t, m, "progress", ["settings", "root"], cardId) },
|
|
1817
|
-
{ id: "language", label: this.t.menuSettingsLanguage, onSelect: (t, m, cardId) => this.openMenu(t, m, "language", ["settings", "root"], cardId) },
|
|
1818
|
-
{ id: "overview", label: this.t.menuSettingsOverview, leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
|
|
1819
|
-
];
|
|
1820
|
-
case "language":
|
|
1821
|
-
return [
|
|
1822
|
-
{
|
|
1823
|
-
id: "lang:zh",
|
|
1824
|
-
label: `${this.language === "zh" ? "● " : ""}${this.t.languageZh}`,
|
|
1825
|
-
leaf: true,
|
|
1826
|
-
onSelect: async (t, m) => { await this.setLanguage("zh", t, m); },
|
|
1827
|
-
},
|
|
1828
|
-
{
|
|
1829
|
-
id: "lang:en",
|
|
1830
|
-
label: `${this.language === "en" ? "● " : ""}${this.t.languageEn}`,
|
|
1831
|
-
leaf: true,
|
|
1832
|
-
onSelect: async (t, m) => { await this.setLanguage("en", t, m); },
|
|
1833
|
-
},
|
|
1834
|
-
];
|
|
1835
|
-
case "model":
|
|
1836
|
-
return await this.modelMenuItems();
|
|
1837
|
-
case "reasoning":
|
|
1838
|
-
return this.reasoningMenuItems();
|
|
1839
|
-
case "notify":
|
|
1840
|
-
return [
|
|
1841
|
-
{ id: "full", label: this.t.notifyFull },
|
|
1842
|
-
{ id: "important", label: this.t.notifyImportant },
|
|
1843
|
-
{ id: "result", label: this.t.notifyResult },
|
|
1844
|
-
].map((o) => ({
|
|
1845
|
-
id: `notify:${o.id}`,
|
|
1846
|
-
label: `${this.notifyLevel === o.id ? "● " : ""}${o.label}`,
|
|
1847
|
-
leaf: true,
|
|
1848
|
-
onSelect: async (t, m) => {
|
|
1849
|
-
await this.setNotifyLevel(o.id, t, m);
|
|
1850
|
-
},
|
|
1851
|
-
}));
|
|
1852
|
-
case "progress": {
|
|
1853
|
-
const presets = [
|
|
1854
|
-
{ id: "progress:0", label: this.t.progressOff, ms: 0 },
|
|
1855
|
-
{ id: "progress:120000", label: this.t.progressMinutes(2), ms: 2 * 60_000 },
|
|
1856
|
-
{ id: "progress:300000", label: this.t.progressMinutes(5), ms: 5 * 60_000 },
|
|
1857
|
-
{ id: "progress:600000", label: this.t.progressMinutes(10), ms: 10 * 60_000 },
|
|
1858
|
-
{ id: "progress:900000", label: this.t.progressMinutes(15), ms: 15 * 60_000 },
|
|
1859
|
-
{ id: "progress:1800000", label: this.t.progressMinutes(30), ms: 30 * 60_000 },
|
|
1860
|
-
];
|
|
1861
|
-
return presets.map((o) => ({
|
|
1862
|
-
id: o.id,
|
|
1863
|
-
label: `${this.progressTimeoutMs === o.ms ? "● " : ""}${o.label}`,
|
|
1864
|
-
leaf: true,
|
|
1865
|
-
onSelect: async (t, m) => {
|
|
1866
|
-
await this.setProgressTimeout(o.ms, t, m);
|
|
1867
|
-
},
|
|
1868
|
-
}));
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1379
|
+
return listWorkspaces({
|
|
1380
|
+
registry: this.ctx.get("workspaceRegistry"),
|
|
1381
|
+
workDir: this.workDir,
|
|
1382
|
+
currentDirLabel: this.t.currentDir,
|
|
1383
|
+
workspaces: this.config.workspaces,
|
|
1384
|
+
});
|
|
1871
1385
|
}
|
|
1872
1386
|
async showStatus(target) {
|
|
1873
1387
|
const agent = this.agent;
|
|
@@ -1968,41 +1482,6 @@ export class AgentRunner {
|
|
|
1968
1482
|
lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
|
|
1969
1483
|
await this.adapter.sendText(target, lines.join("\n"));
|
|
1970
1484
|
}
|
|
1971
|
-
async modelMenuItems() {
|
|
1972
|
-
const choices = await this.listModelChoices();
|
|
1973
|
-
const current = this.defaultSelection();
|
|
1974
|
-
const items = choices.map((c) => ({
|
|
1975
|
-
id: `model:${c.provider}:${c.model}`,
|
|
1976
|
-
label: `${c.provider === current.provider && c.model === current.model ? "● " : ""}${c.name}`,
|
|
1977
|
-
leaf: true,
|
|
1978
|
-
onSelect: async (t, m) => {
|
|
1979
|
-
await this.setModel(c.provider, c.model, m);
|
|
1980
|
-
return this.t.modelSet(c.name);
|
|
1981
|
-
},
|
|
1982
|
-
}));
|
|
1983
|
-
if (items.length === 0) {
|
|
1984
|
-
items.push({ id: "model:none", label: this.t.noModelsFound, leaf: true, onSelect: async () => this.t.noModelsFound });
|
|
1985
|
-
}
|
|
1986
|
-
return items;
|
|
1987
|
-
}
|
|
1988
|
-
async listModelChoices() {
|
|
1989
|
-
const llm = this.ctx.get("llm");
|
|
1990
|
-
const providers = llm?.listProviders?.() ?? [];
|
|
1991
|
-
const out = [];
|
|
1992
|
-
for (const p of providers) {
|
|
1993
|
-
let models = [];
|
|
1994
|
-
try {
|
|
1995
|
-
models = (await llm?.listModels?.(p.id)) ?? [];
|
|
1996
|
-
}
|
|
1997
|
-
catch {
|
|
1998
|
-
// Skip providers whose catalog cannot be listed.
|
|
1999
|
-
}
|
|
2000
|
-
for (const m of models) {
|
|
2001
|
-
out.push({ provider: p.id, model: m.id, name: this.t.modelName(m.name || m.id, p.name || p.id) });
|
|
2002
|
-
}
|
|
2003
|
-
}
|
|
2004
|
-
return out;
|
|
2005
|
-
}
|
|
2006
1485
|
async setModel(provider, model, msg) {
|
|
2007
1486
|
const svc = this.ctx.get("agentDefaultModel");
|
|
2008
1487
|
const cur = this.defaultSelection();
|
|
@@ -2013,37 +1492,6 @@ export class AgentRunner {
|
|
|
2013
1492
|
});
|
|
2014
1493
|
await this.newChat(msg);
|
|
2015
1494
|
}
|
|
2016
|
-
reasoningMenuItems() {
|
|
2017
|
-
const current = this.defaultSelection();
|
|
2018
|
-
const efforts = [
|
|
2019
|
-
{ id: "low", name: this.t.effortLow },
|
|
2020
|
-
{ id: "medium", name: this.t.effortMedium },
|
|
2021
|
-
{ id: "high", name: this.t.effortHigh },
|
|
2022
|
-
];
|
|
2023
|
-
const items = [
|
|
2024
|
-
{
|
|
2025
|
-
id: "effort:default",
|
|
2026
|
-
label: `${current.reasoningEffort === undefined ? "● " : ""}${this.t.effortDefault}`,
|
|
2027
|
-
leaf: true,
|
|
2028
|
-
onSelect: async (t, m) => {
|
|
2029
|
-
await this.setReasoning(undefined, m);
|
|
2030
|
-
return this.t.reasoningSet(this.t.effortDefault);
|
|
2031
|
-
},
|
|
2032
|
-
},
|
|
2033
|
-
];
|
|
2034
|
-
for (const e of efforts) {
|
|
2035
|
-
items.push({
|
|
2036
|
-
id: `effort:${e.id}`,
|
|
2037
|
-
label: `${e.id === current.reasoningEffort ? "● " : ""}${e.name}`,
|
|
2038
|
-
leaf: true,
|
|
2039
|
-
onSelect: async (t, m) => {
|
|
2040
|
-
await this.setReasoning(e.id, m);
|
|
2041
|
-
return this.t.reasoningSet(e.name);
|
|
2042
|
-
},
|
|
2043
|
-
});
|
|
2044
|
-
}
|
|
2045
|
-
return items;
|
|
2046
|
-
}
|
|
2047
1495
|
async setReasoning(effort, msg) {
|
|
2048
1496
|
const svc = this.ctx.get("agentDefaultModel");
|
|
2049
1497
|
const cur = this.defaultSelection();
|
|
@@ -2304,18 +1752,7 @@ export class AgentRunner {
|
|
|
2304
1752
|
await this.adapter.sendText(target, lines.join("\n"));
|
|
2305
1753
|
}
|
|
2306
1754
|
goalPhaseLabel(phase) {
|
|
2307
|
-
|
|
2308
|
-
case "active":
|
|
2309
|
-
return this.t.goalPhaseActive;
|
|
2310
|
-
case "paused":
|
|
2311
|
-
return this.t.goalPhasePaused;
|
|
2312
|
-
case "blocked":
|
|
2313
|
-
return this.t.goalPhaseBlocked;
|
|
2314
|
-
case "complete":
|
|
2315
|
-
return this.t.goalPhaseComplete;
|
|
2316
|
-
default:
|
|
2317
|
-
return phase;
|
|
2318
|
-
}
|
|
1755
|
+
return goalPhaseLabel(phase, this.t);
|
|
2319
1756
|
}
|
|
2320
1757
|
async showSchedule(target) {
|
|
2321
1758
|
// Agent-level reminders (session `schedule` tool) + persistent chat-level
|