dsh-connect 0.6.7 → 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);
@@ -685,7 +699,7 @@ export class AgentRunner {
685
699
  await this.sendTurnStats(msg, outcome);
686
700
  // Release lock after task completion
687
701
  if (usesLock)
688
- this.releaseLock();
702
+ await this.releaseLock();
689
703
  if (outcome.reason !== "completed") {
690
704
  await this.sendSummary(msg, outcome);
691
705
  }
@@ -693,7 +707,7 @@ export class AgentRunner {
693
707
  catch (error) {
694
708
  // Release lock even on error
695
709
  if (usesLock)
696
- this.releaseLock();
710
+ await this.releaseLock();
697
711
  const detail = error instanceof Error ? error.message : String(error);
698
712
  await this.adapter
699
713
  .sendText(this.target(msg), this.t.processingFailedAdvice(truncate(detail, 400), this.errorAdvice(detail)))
@@ -1405,6 +1419,18 @@ export class AgentRunner {
1405
1419
  await this.handleAppend(command.text, target, msg);
1406
1420
  break;
1407
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
+ }
1408
1434
  case "help": {
1409
1435
  await this.adapter.sendText(target, helpText(this.t));
1410
1436
  break;
@@ -1446,6 +1472,103 @@ export class AgentRunner {
1446
1472
  this.touchBinding(msg);
1447
1473
  await this.adapter.sendText(target, this.t.psReceived(text));
1448
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
+ }
1449
1572
  recordSession(sessionId, title, ownerKey) {
1450
1573
  const binding = this.bindings.get(this.channel, this.chatKey);
1451
1574
  const others = (binding?.sessions ?? []).filter((s) => s.sessionId !== sessionId);
@@ -2195,26 +2318,38 @@ export class AgentRunner {
2195
2318
  }
2196
2319
  }
2197
2320
  async showSchedule(target) {
2198
- const agent = this.agent;
2199
- if (agent === undefined) {
2200
- await this.adapter.sendText(target, this.t.noActiveSession);
2201
- return;
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
- }
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) ?? [];
2208
2325
  const now = Date.now();
2209
2326
  const locale = this.language === "en" ? "en-US" : "zh-CN";
2210
- const lines = reminders.map((r) => {
2327
+ const agentLines = agentReminders.map((r) => {
2211
2328
  const due = Date.parse(r.scheduledAt) <= now;
2212
2329
  const state = due ? this.t.reminderDue : this.t.reminderClock;
2213
2330
  const kind = r.kind === "every" ? this.t.reminderEvery(Math.round((r.everySeconds ?? 0) / 60)) : this.t.reminderOnce;
2214
2331
  const when = new Date(Date.parse(r.scheduledAt)).toLocaleString(locale);
2215
2332
  return this.t.reminderLine(state, r.id, r.prompt, kind, when);
2216
2333
  });
2217
- 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")));
2218
2353
  }
2219
2354
  async showAllWorkspaces(target) {
2220
2355
  const registry = this.ctx.get("workspaceRegistry");
@@ -2299,7 +2434,7 @@ export class AgentRunner {
2299
2434
  }
2300
2435
  // Process any queued messages before releasing
2301
2436
  const queuedCount = binding.queuedMessages?.length ?? 0;
2302
- this.releaseLock();
2437
+ await this.releaseLock();
2303
2438
  let message = this.t.unlockSuccess;
2304
2439
  if (queuedCount > 0) {
2305
2440
  message += `\n${this.t.queueProcessed(queuedCount)}`;
@@ -2332,7 +2467,8 @@ export class AgentRunner {
2332
2467
  await this.adapter.sendText(target, this.t.lockRenewed(timeoutMin));
2333
2468
  }
2334
2469
  /**
2335
- * 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).
2336
2472
  */
2337
2473
  async handleExport(target, format) {
2338
2474
  const agent = this.agent;
@@ -2340,12 +2476,6 @@ export class AgentRunner {
2340
2476
  await this.adapter.sendText(target, this.t.exportNoSession);
2341
2477
  return;
2342
2478
  }
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
2479
  try {
2350
2480
  const markdown = this.generateMarkdown(agent);
2351
2481
  const fileName = `conversation-${agent.id}-${Date.now()}.md`;
@@ -2414,54 +2544,35 @@ export class AgentRunner {
2414
2544
  */
2415
2545
  canWrite(channel) {
2416
2546
  const binding = this.bindings.get(this.channel, this.chatKey);
2417
- if (binding?.lockOwner === undefined)
2418
- return true; // No lock, free access
2419
- // Check timeout
2420
- if (this.isLockTimedOut(binding)) {
2421
- 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);
2422
2553
  return true;
2423
2554
  }
2424
- return binding.lockOwner === channel;
2555
+ return false;
2425
2556
  }
2426
2557
  /**
2427
2558
  * Acquire session lock for the given channel.
2428
2559
  * Returns true if lock acquired, false if already locked by another channel.
2429
2560
  */
2430
2561
  acquireLock(channel) {
2431
- const binding = this.bindings.get(this.channel, this.chatKey);
2562
+ let binding = this.bindings.get(this.channel, this.chatKey);
2432
2563
  if (binding === undefined)
2433
2564
  return true;
2434
- // Check timeout before acquiring
2435
- 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)) {
2436
2568
  this.releaseTimeoutLock(binding);
2437
- // After timeout release, try to acquire again
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
- }
2569
+ binding = this.bindings.get(this.channel, this.chatKey) ?? binding;
2450
2570
  }
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;
2462
- }
2463
- // Locked by other channel - queue the message if from Web
2464
- 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;
2465
2576
  }
2466
2577
  /**
2467
2578
  * Queue a message for later execution when lock is released.
@@ -2479,6 +2590,8 @@ export class AgentRunner {
2479
2590
  replyRef: msg.replyRef,
2480
2591
  images: msg.images,
2481
2592
  files: msg.files,
2593
+ // Keep the true source channel so replay routes to the right runner.
2594
+ channel: msg.channel,
2482
2595
  });
2483
2596
  this.bindings.put({ ...binding, queuedMessages: queued });
2484
2597
  return queued.length;
@@ -2496,69 +2609,64 @@ export class AgentRunner {
2496
2609
  const count = messages.length;
2497
2610
  // Clear queue immediately to prevent duplicate processing
2498
2611
  this.bindings.put({ ...binding, queuedMessages: [] });
2499
- // Notify about queued messages being processed
2500
- if (count > 0) {
2501
- const adapter = this.adapters?.get(this.channel);
2502
- if (adapter) {
2503
- await adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.queueProcessed(count)).catch(() => undefined);
2504
- }
2505
- // Re-enqueue messages into the runner's processing queue
2506
- // Construct minimal InboundMessage objects from queued data
2507
- for (const queued of messages) {
2508
- const inboundMsg = {
2509
- channel: "web", // Queued messages are from Web channel
2510
- chatKey: this.chatKey,
2511
- chatType: this.chatType,
2512
- senderKey: queued.senderKey,
2513
- text: queued.text,
2514
- replyRef: queued.replyRef,
2515
- images: queued.images,
2516
- files: queued.files,
2517
- };
2518
- // 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 {
2519
2635
  this.queue.push(inboundMsg);
2520
2636
  }
2521
- // Trigger processing if not already running
2522
- void this.drain();
2523
2637
  }
2638
+ if (this.requeue === undefined)
2639
+ void this.drain();
2524
2640
  }
2525
- /** Release session lock. */
2526
- 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() {
2527
2648
  const binding = this.bindings.get(this.channel, this.chatKey);
2528
- if (binding !== undefined && binding.lockOwner !== undefined) {
2529
- // Process queued messages before releasing
2530
- void this.processQueuedMessages();
2531
- this.bindings.put({ ...binding, lockOwner: undefined, lockAcquiredAt: undefined });
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;
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));
2542
2654
  }
2543
- /** Check and release timed-out locks. */
2655
+ /** Check and release timed-out locks (delegates to the pure lock module). */
2544
2656
  checkAndReleaseTimeoutLock() {
2545
2657
  const binding = this.bindings.get(this.channel, this.chatKey);
2546
- if (binding && this.isLockTimedOut(binding)) {
2658
+ if (binding !== undefined && lockIsTimedOut(binding)) {
2547
2659
  this.releaseTimeoutLock(binding);
2548
2660
  }
2549
2661
  }
2550
2662
  /** Release a timed-out lock and notify. */
2551
2663
  releaseTimeoutLock(binding) {
2552
- const timeoutMin = Math.round((binding.lockTimeoutMs ?? 5 * 60 * 1000) / 60000);
2664
+ const timeoutMin = Math.round((binding.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS) / 60000);
2553
2665
  const lockedBy = binding.lockOwner;
2554
- this.bindings.put({
2555
- ...binding,
2556
- lockOwner: undefined,
2557
- lockAcquiredAt: undefined
2558
- });
2666
+ this.bindings.put(lockRelease(binding));
2559
2667
  // Notify users about timeout release
2560
2668
  const adapter = this.adapters?.get(this.channel);
2561
- if (adapter && lockedBy) {
2669
+ if (adapter !== undefined && lockedBy !== undefined) {
2562
2670
  void adapter.sendText({ chatKey: this.chatKey, chatType: this.chatType }, this.t.lockTimeoutReleased(timeoutMin)).catch(() => undefined);
2563
2671
  }
2564
2672
  }