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/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
- const agent = this.agent;
2105
- if (agent === undefined) {
2106
- await this.adapter.sendText(target, this.t.noActiveSession);
2107
- return;
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 lines = reminders.map((r) => {
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
- await this.adapter.sendText(target, this.t.remindersCount(reminders.length, lines.join("\n")));
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 to Markdown or PDF.
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?.lockOwner === undefined)
2324
- return true; // No lock, free access
2325
- // Check timeout
2326
- if (this.isLockTimedOut(binding)) {
2327
- this.releaseTimeoutLock(binding);
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 binding.lockOwner === channel;
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
- const binding = this.bindings.get(this.channel, this.chatKey);
2562
+ let binding = this.bindings.get(this.channel, this.chatKey);
2338
2563
  if (binding === undefined)
2339
2564
  return true;
2340
- // Check timeout before acquiring
2341
- if (this.isLockTimedOut(binding)) {
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
- // After timeout release, try to acquire again
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
- // Locked by other channel - queue the message if from Web
2370
- return false;
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
- if (count > 0) {
2407
- const adapter = this.adapters?.get(this.channel);
2408
- if (adapter) {
2409
- await adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.queueProcessed(count)).catch(() => undefined);
2410
- }
2411
- // Re-enqueue messages into the runner's processing queue
2412
- // Construct minimal InboundMessage objects from queued data
2413
- for (const queued of messages) {
2414
- const inboundMsg = {
2415
- channel: "web", // Queued messages are from Web channel
2416
- chatKey: this.chatKey,
2417
- chatType: this.chatType,
2418
- senderKey: queued.senderKey,
2419
- text: queued.text,
2420
- replyRef: queued.replyRef,
2421
- images: queued.images,
2422
- files: queued.files,
2423
- };
2424
- // Add to the runner's internal queue for processing
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
- /** Release session lock. */
2432
- releaseLock() {
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 !== undefined && binding.lockOwner !== undefined) {
2435
- // Process queued messages before releasing
2436
- void this.processQueuedMessages();
2437
- this.bindings.put({ ...binding, lockOwner: undefined, lockAcquiredAt: undefined });
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 && this.isLockTimedOut(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 ?? 5 * 60 * 1000) / 60000);
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
  }