dsh-connect 0.6.6 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -17
- package/README.zh.md +16 -17
- 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 +13 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +22 -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 +25 -1
- package/lib/i18n.d.ts.map +1 -1
- package/lib/i18n.js +50 -2
- package/lib/i18n.js.map +1 -1
- package/lib/index.d.ts +8 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +7 -1
- package/lib/index.js.map +1 -1
- 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 +54 -6
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +309 -107
- 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 +28 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +114 -4
- package/lib/service.js.map +1 -1
- package/lib/types.d.ts +8 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/LICENSE +0 -21
package/lib/runner.js
CHANGED
|
@@ -13,6 +13,8 @@ import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
|
|
|
13
13
|
import { createAsyncQueue } from "./stream.js";
|
|
14
14
|
import { helpText, parseCommand } from "./commands.js";
|
|
15
15
|
import { messages } from "./i18n.js";
|
|
16
|
+
import { DEFAULT_LOCK_TIMEOUT_MS, acquire as lockAcquire, canWrite as lockCanWrite, isLockTimedOut as lockIsTimedOut, release as lockRelease, } from "./mirror-lock.js";
|
|
17
|
+
import { formatRemindAt, parseRemindTime } from "./scheduler.js";
|
|
16
18
|
export function resolveConnectConfig(config) {
|
|
17
19
|
return {
|
|
18
20
|
agentPreset: config.agentPreset,
|
|
@@ -364,6 +366,8 @@ export class AgentRunner {
|
|
|
364
366
|
adapter;
|
|
365
367
|
bindings;
|
|
366
368
|
adapters;
|
|
369
|
+
requeue;
|
|
370
|
+
reminders;
|
|
367
371
|
queue = [];
|
|
368
372
|
running = false;
|
|
369
373
|
agent;
|
|
@@ -374,7 +378,15 @@ export class AgentRunner {
|
|
|
374
378
|
notifyLevel;
|
|
375
379
|
progressTimeoutMs;
|
|
376
380
|
t;
|
|
377
|
-
constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters
|
|
381
|
+
constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters,
|
|
382
|
+
/**
|
|
383
|
+
* Route a replayed queued message back into the runner of its own channel
|
|
384
|
+
* (wired by ConnectService). Without it, replayed messages would run in
|
|
385
|
+
* whichever runner happened to release the lock.
|
|
386
|
+
*/
|
|
387
|
+
requeue,
|
|
388
|
+
/** Persistent chat-level reminders (`/remind`), wired by ConnectService. */
|
|
389
|
+
reminders) {
|
|
378
390
|
this.ctx = ctx;
|
|
379
391
|
this.config = config;
|
|
380
392
|
this.channel = channel;
|
|
@@ -383,6 +395,8 @@ export class AgentRunner {
|
|
|
383
395
|
this.adapter = adapter;
|
|
384
396
|
this.bindings = bindings;
|
|
385
397
|
this.adapters = adapters;
|
|
398
|
+
this.requeue = requeue;
|
|
399
|
+
this.reminders = reminders;
|
|
386
400
|
this.workDir = config.workDir ?? this.resolveDefaultWorkDir();
|
|
387
401
|
// A per-chat language override (set via the settings menu) wins over config.
|
|
388
402
|
const stored = this.bindings.get(channel, chatKey);
|
|
@@ -456,6 +470,13 @@ export class AgentRunner {
|
|
|
456
470
|
});
|
|
457
471
|
return;
|
|
458
472
|
}
|
|
473
|
+
// A task is already running (or queued): tell the user they can append a
|
|
474
|
+
// note to the in-flight task with /ps instead of queueing a brand-new turn
|
|
475
|
+
// behind it. The message still queues as before.
|
|
476
|
+
if (this.running) {
|
|
477
|
+
const hint = this.t.busyHint(truncate(msg.text, 20));
|
|
478
|
+
void this.adapter.sendText(this.target(msg), hint).catch(() => undefined);
|
|
479
|
+
}
|
|
459
480
|
this.queue.push(msg);
|
|
460
481
|
void this.drain();
|
|
461
482
|
}
|
|
@@ -468,6 +489,21 @@ export class AgentRunner {
|
|
|
468
489
|
return;
|
|
469
490
|
if (event.seq < turn.firstSeq)
|
|
470
491
|
return;
|
|
492
|
+
// Track context usage while the turn runs so we can nudge the user to
|
|
493
|
+
// compact before the window fills up (deduped to one nudge per turn).
|
|
494
|
+
if (event.type === "request/context") {
|
|
495
|
+
if (typeof event.data.contextWindow === "number")
|
|
496
|
+
turn.contextWindow = event.data.contextWindow;
|
|
497
|
+
this.maybeNudgeContext(turn);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (event.type === "assistant/message") {
|
|
501
|
+
const usage = event.data.usage;
|
|
502
|
+
if (usage !== undefined && typeof usage.inputTokens === "number") {
|
|
503
|
+
turn.contextSize = usage.inputTokens;
|
|
504
|
+
this.maybeNudgeContext(turn);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
471
507
|
if (event.type === "assistant/chunk") {
|
|
472
508
|
const chunk = event.data.chunk;
|
|
473
509
|
// First reasoning delta = the milestone the proactive progress notice reports.
|
|
@@ -505,6 +541,45 @@ export class AgentRunner {
|
|
|
505
541
|
applyToolCall(turn, label);
|
|
506
542
|
}
|
|
507
543
|
}
|
|
544
|
+
/**
|
|
545
|
+
* Proactive context-high nudge: when the turn's observed context usage
|
|
546
|
+
* crosses the compaction threshold, ask the user (once per turn) whether to
|
|
547
|
+
* compact now, instead of waiting until the turn ends to report it.
|
|
548
|
+
*/
|
|
549
|
+
maybeNudgeContext(turn) {
|
|
550
|
+
if (turn.contextNudged)
|
|
551
|
+
return;
|
|
552
|
+
const size = turn.contextSize;
|
|
553
|
+
const window = turn.contextWindow;
|
|
554
|
+
if (size === undefined || window === undefined || window <= 0)
|
|
555
|
+
return;
|
|
556
|
+
const pct = Math.round((size / window) * 100);
|
|
557
|
+
if (pct < AgentRunner.COMPACT_THRESHOLD_PCT)
|
|
558
|
+
return;
|
|
559
|
+
turn.contextNudged = true;
|
|
560
|
+
void (async () => {
|
|
561
|
+
const target = { chatKey: this.chatKey, chatType: this.chatType };
|
|
562
|
+
const nudge = this.t.contextHighPrompt(pct);
|
|
563
|
+
try {
|
|
564
|
+
if (await this.confirmAction(target, nudge)) {
|
|
565
|
+
if (this.agent?.status === "running" || this.running) {
|
|
566
|
+
// Compaction needs an idle agent; run it right after this turn ends.
|
|
567
|
+
turn.compactAfterTurn = true;
|
|
568
|
+
await this.adapter.sendText(target, this.t.compactQueued);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
await this.compact(target);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
await this.adapter.sendText(target, this.t.actionCancelled);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
this.log(`connect: context nudge failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
580
|
+
}
|
|
581
|
+
})();
|
|
582
|
+
}
|
|
508
583
|
target(msg) {
|
|
509
584
|
return {
|
|
510
585
|
chatKey: this.chatKey,
|
|
@@ -624,7 +699,7 @@ export class AgentRunner {
|
|
|
624
699
|
await this.sendTurnStats(msg, outcome);
|
|
625
700
|
// Release lock after task completion
|
|
626
701
|
if (usesLock)
|
|
627
|
-
this.releaseLock();
|
|
702
|
+
await this.releaseLock();
|
|
628
703
|
if (outcome.reason !== "completed") {
|
|
629
704
|
await this.sendSummary(msg, outcome);
|
|
630
705
|
}
|
|
@@ -632,7 +707,7 @@ export class AgentRunner {
|
|
|
632
707
|
catch (error) {
|
|
633
708
|
// Release lock even on error
|
|
634
709
|
if (usesLock)
|
|
635
|
-
this.releaseLock();
|
|
710
|
+
await this.releaseLock();
|
|
636
711
|
const detail = error instanceof Error ? error.message : String(error);
|
|
637
712
|
await this.adapter
|
|
638
713
|
.sendText(this.target(msg), this.t.processingFailedAdvice(truncate(detail, 400), this.errorAdvice(detail)))
|
|
@@ -840,7 +915,7 @@ export class AgentRunner {
|
|
|
840
915
|
this.turn = {
|
|
841
916
|
firstSeq, chunks, lastText: "",
|
|
842
917
|
reasoning: false, hintPushed: false, lastIndex: undefined, pushedAny: false,
|
|
843
|
-
startedAt: now, lastPushAt: now, toolCount: 0,
|
|
918
|
+
startedAt: now, lastPushAt: now, toolCount: 0, contextNudged: false, compactAfterTurn: false,
|
|
844
919
|
};
|
|
845
920
|
// Liveness heartbeat: while the agent is working, keep the streaming card
|
|
846
921
|
// visibly alive even through long reasoning or tool-execution stretches
|
|
@@ -917,6 +992,14 @@ export class AgentRunner {
|
|
|
917
992
|
}
|
|
918
993
|
endChunks();
|
|
919
994
|
await streamPromise;
|
|
995
|
+
// The user confirmed a compaction nudge while this turn was still running:
|
|
996
|
+
// the agent is idle now, so compact before summarizing.
|
|
997
|
+
const compactAfterTurn = this.turn.compactAfterTurn;
|
|
998
|
+
if (compactAfterTurn) {
|
|
999
|
+
await this.compact(this.target(msg)).catch((error) => {
|
|
1000
|
+
this.log(`connect: deferred compaction failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
920
1003
|
const outcome = summarizeTurn(agent.session.events, firstSeq);
|
|
921
1004
|
const text = outcome.text !== "" ? outcome.text : this.turn.lastText;
|
|
922
1005
|
this.turn = undefined;
|
|
@@ -1332,6 +1415,22 @@ export class AgentRunner {
|
|
|
1332
1415
|
await this.handleExport(target, command.format);
|
|
1333
1416
|
break;
|
|
1334
1417
|
}
|
|
1418
|
+
case "ps": {
|
|
1419
|
+
await this.handleAppend(command.text, target, msg);
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
case "remind": {
|
|
1423
|
+
await this.handleRemind(command.text, target, msg);
|
|
1424
|
+
break;
|
|
1425
|
+
}
|
|
1426
|
+
case "send": {
|
|
1427
|
+
await this.handleSend(command.path, target);
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
case "broadcast": {
|
|
1431
|
+
await this.handleBroadcast(command.text, target, msg);
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1335
1434
|
case "help": {
|
|
1336
1435
|
await this.adapter.sendText(target, helpText(this.t));
|
|
1337
1436
|
break;
|
|
@@ -1352,6 +1451,124 @@ export class AgentRunner {
|
|
|
1352
1451
|
}
|
|
1353
1452
|
await handle?.dispose().catch(() => undefined);
|
|
1354
1453
|
}
|
|
1454
|
+
/**
|
|
1455
|
+
* `/ps <note>` — append a note to the running task. Uses the agent's `steer`
|
|
1456
|
+
* inbox: a running driver consumes the note at its next step boundary (so it
|
|
1457
|
+
* steers the in-flight task), and an idle driver starts a new turn with it.
|
|
1458
|
+
*/
|
|
1459
|
+
async handleAppend(text, target, msg) {
|
|
1460
|
+
if (text === "") {
|
|
1461
|
+
await this.adapter.sendText(target, this.t.psUsage);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const agent = this.agent;
|
|
1465
|
+
if (agent === undefined) {
|
|
1466
|
+
await this.adapter.sendText(target, this.t.psNoActiveSession);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
const note = this.t.psAppendLabel(text);
|
|
1470
|
+
const content = await this.buildUserContent({ ...msg, text: note });
|
|
1471
|
+
agent.steer(createUserMessage({ content, source: { kind: "user" } }));
|
|
1472
|
+
this.touchBinding(msg);
|
|
1473
|
+
await this.adapter.sendText(target, this.t.psReceived(text));
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* `/remind <time> <text>` — persist a chat-level reminder that fires
|
|
1477
|
+
* without waking the agent (see src/scheduler.ts).
|
|
1478
|
+
*/
|
|
1479
|
+
async handleRemind(argText, target, msg) {
|
|
1480
|
+
if (this.reminders === undefined) {
|
|
1481
|
+
await this.adapter.sendText(target, this.t.reminderNoText);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const arg = argText.trim();
|
|
1485
|
+
if (arg === "") {
|
|
1486
|
+
await this.adapter.sendText(target, this.t.reminderNoText);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
const space = arg.indexOf(" ");
|
|
1490
|
+
const time = space === -1 ? arg : arg.slice(0, space);
|
|
1491
|
+
const body = space === -1 ? "" : arg.slice(space + 1).trim();
|
|
1492
|
+
const dueAt = parseRemindTime(time);
|
|
1493
|
+
if (dueAt === undefined) {
|
|
1494
|
+
await this.adapter.sendText(target, this.t.reminderParseFailed(time));
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (body === "") {
|
|
1498
|
+
await this.adapter.sendText(target, this.t.reminderNoText);
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
this.reminders.add({
|
|
1502
|
+
channel: this.channel,
|
|
1503
|
+
chatKey: this.chatKey,
|
|
1504
|
+
chatType: this.chatType,
|
|
1505
|
+
text: body,
|
|
1506
|
+
dueAt,
|
|
1507
|
+
ownerKey: msg.senderKey,
|
|
1508
|
+
});
|
|
1509
|
+
await this.adapter.sendText(target, this.t.reminderSet(formatRemindAt(dueAt, this.language), body));
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* `/send <path>` — deliver a file from the workspace through the channel's
|
|
1513
|
+
* `sendFile` capability; falls back to sending the path as text.
|
|
1514
|
+
*/
|
|
1515
|
+
async handleSend(pathArg, target) {
|
|
1516
|
+
if (pathArg === "") {
|
|
1517
|
+
await this.adapter.sendText(target, this.t.sendUsage);
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const path = isAbsolute(pathArg) ? pathArg : join(this.workDir, pathArg);
|
|
1521
|
+
let size;
|
|
1522
|
+
try {
|
|
1523
|
+
if (!existsSync(path)) {
|
|
1524
|
+
await this.adapter.sendText(target, this.t.sendNotFound(path));
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
const stat = statSync(path);
|
|
1528
|
+
if (stat.isDirectory()) {
|
|
1529
|
+
await this.adapter.sendText(target, this.t.sendIsDir(path));
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
size = stat.size;
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
await this.adapter.sendText(target, this.t.sendNotFound(path));
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
if (size > 20 * 1024 * 1024) {
|
|
1539
|
+
await this.adapter.sendText(target, this.t.sendTooLarge(path));
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (this.adapter.sendFile === undefined) {
|
|
1543
|
+
await this.adapter.sendText(target, `${this.t.sendUnsupported}\n${path}`);
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
try {
|
|
1547
|
+
await this.adapter.sendFile(target, path);
|
|
1548
|
+
await this.adapter.sendText(target, this.t.sendSent(path));
|
|
1549
|
+
}
|
|
1550
|
+
catch (error) {
|
|
1551
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1552
|
+
await this.adapter.sendText(target, this.t.sendFailed(truncate(detail)));
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* `/broadcast <text>` — admins push a message to every bound chat.
|
|
1557
|
+
* Admin gating lives in the connect service (allowUsers).
|
|
1558
|
+
*/
|
|
1559
|
+
async handleBroadcast(text, target, msg) {
|
|
1560
|
+
const service = this.ctx.get("connect");
|
|
1561
|
+
if (service?.broadcast === undefined) {
|
|
1562
|
+
await this.adapter.sendText(target, this.t.broadcastUnsupported);
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
const result = await service.broadcast(msg.senderKey, text);
|
|
1566
|
+
if (!result.ok) {
|
|
1567
|
+
await this.adapter.sendText(target, result.reason === "disabled" ? this.t.broadcastDisabled : this.t.broadcastNotAdmin);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
await this.adapter.sendText(target, this.t.broadcastSent(result.count ?? 0));
|
|
1571
|
+
}
|
|
1355
1572
|
recordSession(sessionId, title, ownerKey) {
|
|
1356
1573
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
1357
1574
|
const others = (binding?.sessions ?? []).filter((s) => s.sessionId !== sessionId);
|
|
@@ -2101,26 +2318,38 @@ export class AgentRunner {
|
|
|
2101
2318
|
}
|
|
2102
2319
|
}
|
|
2103
2320
|
async showSchedule(target) {
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
}
|
|
2109
|
-
const reminders = foldReminders(agent.session.events);
|
|
2110
|
-
if (reminders.length === 0) {
|
|
2111
|
-
await this.adapter.sendText(target, this.t.noReminders);
|
|
2112
|
-
return;
|
|
2113
|
-
}
|
|
2321
|
+
// Agent-level reminders (session `schedule` tool) + persistent chat-level
|
|
2322
|
+
// reminders (`/remind`), merged into one list.
|
|
2323
|
+
const agentReminders = this.agent === undefined ? [] : foldReminders(this.agent.session.events);
|
|
2324
|
+
const persisted = this.reminders?.listFor(this.channel, this.chatKey) ?? [];
|
|
2114
2325
|
const now = Date.now();
|
|
2115
2326
|
const locale = this.language === "en" ? "en-US" : "zh-CN";
|
|
2116
|
-
const
|
|
2327
|
+
const agentLines = agentReminders.map((r) => {
|
|
2117
2328
|
const due = Date.parse(r.scheduledAt) <= now;
|
|
2118
2329
|
const state = due ? this.t.reminderDue : this.t.reminderClock;
|
|
2119
2330
|
const kind = r.kind === "every" ? this.t.reminderEvery(Math.round((r.everySeconds ?? 0) / 60)) : this.t.reminderOnce;
|
|
2120
2331
|
const when = new Date(Date.parse(r.scheduledAt)).toLocaleString(locale);
|
|
2121
2332
|
return this.t.reminderLine(state, r.id, r.prompt, kind, when);
|
|
2122
2333
|
});
|
|
2123
|
-
|
|
2334
|
+
const persistedLines = persisted.map((r) => {
|
|
2335
|
+
const due = r.dueAt <= now;
|
|
2336
|
+
const state = due ? this.t.reminderDue : this.t.reminderClock;
|
|
2337
|
+
const when = new Date(r.dueAt).toLocaleString(locale);
|
|
2338
|
+
return this.t.reminderLine(state, r.id, r.text, this.t.reminderOnce, when);
|
|
2339
|
+
});
|
|
2340
|
+
const total = agentLines.length + persistedLines.length;
|
|
2341
|
+
if (total === 0) {
|
|
2342
|
+
await this.adapter.sendText(target, this.t.noReminders);
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
const parts = [];
|
|
2346
|
+
if (agentLines.length > 0)
|
|
2347
|
+
parts.push(...agentLines);
|
|
2348
|
+
if (persistedLines.length > 0) {
|
|
2349
|
+
parts.push(this.t.reminderPersistedHeader);
|
|
2350
|
+
parts.push(...persistedLines);
|
|
2351
|
+
}
|
|
2352
|
+
await this.adapter.sendText(target, this.t.remindersCount(total, parts.join("\n")));
|
|
2124
2353
|
}
|
|
2125
2354
|
async showAllWorkspaces(target) {
|
|
2126
2355
|
const registry = this.ctx.get("workspaceRegistry");
|
|
@@ -2205,7 +2434,7 @@ export class AgentRunner {
|
|
|
2205
2434
|
}
|
|
2206
2435
|
// Process any queued messages before releasing
|
|
2207
2436
|
const queuedCount = binding.queuedMessages?.length ?? 0;
|
|
2208
|
-
this.releaseLock();
|
|
2437
|
+
await this.releaseLock();
|
|
2209
2438
|
let message = this.t.unlockSuccess;
|
|
2210
2439
|
if (queuedCount > 0) {
|
|
2211
2440
|
message += `\n${this.t.queueProcessed(queuedCount)}`;
|
|
@@ -2238,7 +2467,8 @@ export class AgentRunner {
|
|
|
2238
2467
|
await this.adapter.sendText(target, this.t.lockRenewed(timeoutMin));
|
|
2239
2468
|
}
|
|
2240
2469
|
/**
|
|
2241
|
-
* Export conversation history
|
|
2470
|
+
* Export conversation history as Markdown (a future PDF pipeline can be
|
|
2471
|
+
* added here; `/export pdf` currently falls back to Markdown).
|
|
2242
2472
|
*/
|
|
2243
2473
|
async handleExport(target, format) {
|
|
2244
2474
|
const agent = this.agent;
|
|
@@ -2246,12 +2476,6 @@ export class AgentRunner {
|
|
|
2246
2476
|
await this.adapter.sendText(target, this.t.exportNoSession);
|
|
2247
2477
|
return;
|
|
2248
2478
|
}
|
|
2249
|
-
// Default to markdown if not specified
|
|
2250
|
-
const exportFormat = format ?? "markdown";
|
|
2251
|
-
if (exportFormat === "pdf") {
|
|
2252
|
-
await this.adapter.sendText(target, this.t.exportPdfNotSupported);
|
|
2253
|
-
return;
|
|
2254
|
-
}
|
|
2255
2479
|
try {
|
|
2256
2480
|
const markdown = this.generateMarkdown(agent);
|
|
2257
2481
|
const fileName = `conversation-${agent.id}-${Date.now()}.md`;
|
|
@@ -2320,54 +2544,35 @@ export class AgentRunner {
|
|
|
2320
2544
|
*/
|
|
2321
2545
|
canWrite(channel) {
|
|
2322
2546
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2323
|
-
if (binding
|
|
2324
|
-
return true; // No
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2547
|
+
if (binding === undefined)
|
|
2548
|
+
return true; // No binding, free access
|
|
2549
|
+
if (lockCanWrite(binding, channel)) {
|
|
2550
|
+
// A timed-out lock counts as free; release it so the next acquire sees a clean state.
|
|
2551
|
+
if (lockIsTimedOut(binding))
|
|
2552
|
+
this.releaseTimeoutLock(binding);
|
|
2328
2553
|
return true;
|
|
2329
2554
|
}
|
|
2330
|
-
return
|
|
2555
|
+
return false;
|
|
2331
2556
|
}
|
|
2332
2557
|
/**
|
|
2333
2558
|
* Acquire session lock for the given channel.
|
|
2334
2559
|
* Returns true if lock acquired, false if already locked by another channel.
|
|
2335
2560
|
*/
|
|
2336
2561
|
acquireLock(channel) {
|
|
2337
|
-
|
|
2562
|
+
let binding = this.bindings.get(this.channel, this.chatKey);
|
|
2338
2563
|
if (binding === undefined)
|
|
2339
2564
|
return true;
|
|
2340
|
-
//
|
|
2341
|
-
|
|
2565
|
+
// A timed-out lock is treated as free: release it (with the user notice)
|
|
2566
|
+
// before acquiring so the next state is clean.
|
|
2567
|
+
if (lockIsTimedOut(binding)) {
|
|
2342
2568
|
this.releaseTimeoutLock(binding);
|
|
2343
|
-
|
|
2344
|
-
const refreshed = this.bindings.get(this.channel, this.chatKey);
|
|
2345
|
-
if (refreshed !== undefined && (refreshed.lockOwner === undefined || refreshed.lockOwner === channel)) {
|
|
2346
|
-
const defaultTimeoutMs = refreshed.lockTimeoutMs ?? 5 * 60 * 1000;
|
|
2347
|
-
this.bindings.put({
|
|
2348
|
-
...refreshed,
|
|
2349
|
-
lockOwner: channel,
|
|
2350
|
-
lockAcquiredAt: Date.now(),
|
|
2351
|
-
lockTimeoutMs: defaultTimeoutMs,
|
|
2352
|
-
lastActiveAt: Date.now()
|
|
2353
|
-
});
|
|
2354
|
-
return true;
|
|
2355
|
-
}
|
|
2356
|
-
}
|
|
2357
|
-
if (binding.lockOwner === undefined || binding.lockOwner === channel) {
|
|
2358
|
-
// Lock is free or already owned by this channel
|
|
2359
|
-
const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000;
|
|
2360
|
-
this.bindings.put({
|
|
2361
|
-
...binding,
|
|
2362
|
-
lockOwner: channel,
|
|
2363
|
-
lockAcquiredAt: Date.now(),
|
|
2364
|
-
lockTimeoutMs: timeoutMs,
|
|
2365
|
-
lastActiveAt: Date.now()
|
|
2366
|
-
});
|
|
2367
|
-
return true;
|
|
2569
|
+
binding = this.bindings.get(this.channel, this.chatKey) ?? binding;
|
|
2368
2570
|
}
|
|
2369
|
-
|
|
2370
|
-
|
|
2571
|
+
const next = lockAcquire(binding, channel);
|
|
2572
|
+
if (next === undefined)
|
|
2573
|
+
return false; // Locked by another channel
|
|
2574
|
+
this.bindings.put({ ...next, lastActiveAt: Date.now() });
|
|
2575
|
+
return true;
|
|
2371
2576
|
}
|
|
2372
2577
|
/**
|
|
2373
2578
|
* Queue a message for later execution when lock is released.
|
|
@@ -2385,6 +2590,8 @@ export class AgentRunner {
|
|
|
2385
2590
|
replyRef: msg.replyRef,
|
|
2386
2591
|
images: msg.images,
|
|
2387
2592
|
files: msg.files,
|
|
2593
|
+
// Keep the true source channel so replay routes to the right runner.
|
|
2594
|
+
channel: msg.channel,
|
|
2388
2595
|
});
|
|
2389
2596
|
this.bindings.put({ ...binding, queuedMessages: queued });
|
|
2390
2597
|
return queued.length;
|
|
@@ -2402,69 +2609,64 @@ export class AgentRunner {
|
|
|
2402
2609
|
const count = messages.length;
|
|
2403
2610
|
// Clear queue immediately to prevent duplicate processing
|
|
2404
2611
|
this.bindings.put({ ...binding, queuedMessages: [] });
|
|
2405
|
-
// Notify about queued messages being processed
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2612
|
+
// Notify about queued messages being processed.
|
|
2613
|
+
await this.adapter
|
|
2614
|
+
.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.queueProcessed(count))
|
|
2615
|
+
.catch(() => undefined);
|
|
2616
|
+
// Replay each queued message through the service routing so it lands in
|
|
2617
|
+
// the runner of its own channel (a web message goes to the web runner,
|
|
2618
|
+
// never the releasing feishu runner). Without a router we fall back to
|
|
2619
|
+
// the local queue so behavior stays functional in isolation.
|
|
2620
|
+
for (const queued of messages) {
|
|
2621
|
+
const inboundMsg = {
|
|
2622
|
+
channel: queued.channel ?? "web",
|
|
2623
|
+
chatKey: this.chatKey,
|
|
2624
|
+
chatType: this.chatType,
|
|
2625
|
+
senderKey: queued.senderKey,
|
|
2626
|
+
text: queued.text,
|
|
2627
|
+
replyRef: queued.replyRef,
|
|
2628
|
+
images: queued.images,
|
|
2629
|
+
files: queued.files,
|
|
2630
|
+
};
|
|
2631
|
+
if (this.requeue !== undefined) {
|
|
2632
|
+
this.requeue(inboundMsg);
|
|
2633
|
+
}
|
|
2634
|
+
else {
|
|
2425
2635
|
this.queue.push(inboundMsg);
|
|
2426
2636
|
}
|
|
2427
|
-
// Trigger processing if not already running
|
|
2428
|
-
void this.drain();
|
|
2429
2637
|
}
|
|
2638
|
+
if (this.requeue === undefined)
|
|
2639
|
+
void this.drain();
|
|
2430
2640
|
}
|
|
2431
|
-
/**
|
|
2432
|
-
|
|
2641
|
+
/**
|
|
2642
|
+
* Release session lock. Awaits the queued-message drain, then clears the
|
|
2643
|
+
* lock from a FRESH read: the drain's queue-clearing put must not be
|
|
2644
|
+
* overwritten by a stale binding object (which would resurrect the queue
|
|
2645
|
+
* and cause duplicate processing on the next release).
|
|
2646
|
+
*/
|
|
2647
|
+
async releaseLock() {
|
|
2433
2648
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2434
|
-
if (binding
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
}
|
|
2440
|
-
/** Check if lock has timed out. */
|
|
2441
|
-
isLockTimedOut(binding) {
|
|
2442
|
-
if (binding.lockOwner === undefined || binding.lockAcquiredAt === undefined) {
|
|
2443
|
-
return false;
|
|
2444
|
-
}
|
|
2445
|
-
const timeoutMs = binding.lockTimeoutMs ?? 5 * 60 * 1000; // Default 5 minutes
|
|
2446
|
-
const elapsed = Date.now() - binding.lockAcquiredAt;
|
|
2447
|
-
return elapsed > timeoutMs;
|
|
2649
|
+
if (binding === undefined || binding.lockOwner === undefined)
|
|
2650
|
+
return;
|
|
2651
|
+
await this.processQueuedMessages();
|
|
2652
|
+
const fresh = this.bindings.get(this.channel, this.chatKey) ?? binding;
|
|
2653
|
+
this.bindings.put(lockRelease(fresh));
|
|
2448
2654
|
}
|
|
2449
|
-
/** Check and release timed-out locks. */
|
|
2655
|
+
/** Check and release timed-out locks (delegates to the pure lock module). */
|
|
2450
2656
|
checkAndReleaseTimeoutLock() {
|
|
2451
2657
|
const binding = this.bindings.get(this.channel, this.chatKey);
|
|
2452
|
-
if (binding &&
|
|
2658
|
+
if (binding !== undefined && lockIsTimedOut(binding)) {
|
|
2453
2659
|
this.releaseTimeoutLock(binding);
|
|
2454
2660
|
}
|
|
2455
2661
|
}
|
|
2456
2662
|
/** Release a timed-out lock and notify. */
|
|
2457
2663
|
releaseTimeoutLock(binding) {
|
|
2458
|
-
const timeoutMin = Math.round((binding.lockTimeoutMs ??
|
|
2664
|
+
const timeoutMin = Math.round((binding.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS) / 60000);
|
|
2459
2665
|
const lockedBy = binding.lockOwner;
|
|
2460
|
-
this.bindings.put(
|
|
2461
|
-
...binding,
|
|
2462
|
-
lockOwner: undefined,
|
|
2463
|
-
lockAcquiredAt: undefined
|
|
2464
|
-
});
|
|
2666
|
+
this.bindings.put(lockRelease(binding));
|
|
2465
2667
|
// Notify users about timeout release
|
|
2466
2668
|
const adapter = this.adapters?.get(this.channel);
|
|
2467
|
-
if (adapter && lockedBy) {
|
|
2669
|
+
if (adapter !== undefined && lockedBy !== undefined) {
|
|
2468
2670
|
void adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.lockTimeoutReleased(timeoutMin)).catch(() => undefined);
|
|
2469
2671
|
}
|
|
2470
2672
|
}
|