dsh-connect 0.5.2 → 0.6.3

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
@@ -276,6 +276,17 @@ export function summarizeTurn(events, firstSeq) {
276
276
  function truncate(text, max = 500) {
277
277
  return text.length <= max ? text : `${text.slice(0, max)}…`;
278
278
  }
279
+ /** Classify an error message into an advice bucket (keyword-based, best-effort). */
280
+ export function classifyError(detail) {
281
+ const d = detail.toLowerCase();
282
+ if (/(permission|forbidden|denied|not have access|no access|unauthorized|403|sandbox|acl |isn't permitted|not permitted|access denied)/.test(d))
283
+ return "permission";
284
+ if (/(timeout|econnrefused|econnreset|enotfound|enetunreach|etimedout|socket|network|proxy|tls|certificate|fetch failed|connect |unreachable|dns)/.test(d))
285
+ return "network";
286
+ if (/(model|quota|rate ?limit|429|insufficient|balance|token(s)? (limit|length)|max_tokens|context length|credit)/.test(d))
287
+ return "model";
288
+ return "generic";
289
+ }
279
290
  /** Thousands-separated token count for display. */
280
291
  function fmtTokens(n) {
281
292
  return n.toLocaleString("en-US");
@@ -355,8 +366,6 @@ export class AgentRunner {
355
366
  handle;
356
367
  turn;
357
368
  workDir;
358
- streamEnabled = true;
359
- summaryEnabled = true;
360
369
  language;
361
370
  notifyLevel;
362
371
  progressTimeoutMs;
@@ -435,7 +444,12 @@ export class AgentRunner {
435
444
  enqueue(msg) {
436
445
  const command = parseCommand(msg.text);
437
446
  if (command.kind !== "message") {
438
- void this.handleCommand(command, msg);
447
+ // Command handlers touch the adapter directly (no per-call catch) — a
448
+ // transient channel error here must never become an unhandled rejection
449
+ // (Node ≥ 15 crashes the process on those by default).
450
+ void this.handleCommand(command, msg).catch((error) => {
451
+ this.log(`connect: command ${command.kind} failed: ${error instanceof Error ? error.message : String(error)}`);
452
+ });
439
453
  return;
440
454
  }
441
455
  this.queue.push(msg);
@@ -464,6 +478,7 @@ export class AgentRunner {
464
478
  if (typeof name !== "string" || name === "")
465
479
  return;
466
480
  const summary = toolCallSummary(event.data.arguments);
481
+ turn.toolCount += 1;
467
482
  // The proactive progress notice reports the latest milestone even when
468
483
  // `result` mode hides the streaming details.
469
484
  if (name === "ask_user_question") {
@@ -474,14 +489,15 @@ export class AgentRunner {
474
489
  turn.milestone = this.t.toolCalling(name, undefined);
475
490
  }
476
491
  // `result` mode shows nothing until the final answer; `important` shows
477
- // the tool name only, `full` also carries the one-line summary.
492
+ // the tool name with a step counter, `full` also carries the summary.
478
493
  if (this.notifyLevel === "result")
479
494
  return;
480
- const label = name === "ask_user_question"
495
+ const base = name === "ask_user_question"
481
496
  ? this.t.questionToolCall(questionTextOf(event.data.arguments))
482
- : this.notifyLevel === "full"
483
- ? this.t.toolCalling(name, summary)
484
- : this.t.toolCalling(name, undefined);
497
+ : this.t.toolStepLabel(turn.toolCount, name);
498
+ const label = name === "ask_user_question" || this.notifyLevel !== "full" || summary === undefined
499
+ ? base
500
+ : `${base} — ${summary}`;
485
501
  applyToolCall(turn, label);
486
502
  }
487
503
  }
@@ -492,6 +508,57 @@ export class AgentRunner {
492
508
  ...(msg.replyRef === undefined ? {} : { replyRef: msg.replyRef }),
493
509
  };
494
510
  }
511
+ /** Completion-card target: in groups, @ the requester so they notice the result. */
512
+ taskTarget(msg) {
513
+ return this.chatType === "group"
514
+ ? { ...this.target(msg), atUsers: [msg.senderKey] }
515
+ : this.target(msg);
516
+ }
517
+ /** Send the one-time welcome card on a chat's first message (persisted marker). */
518
+ async maybeSendWelcome(msg) {
519
+ const binding = this.bindings.get(this.channel, this.chatKey);
520
+ if (binding !== undefined && binding.welcomedAt !== undefined)
521
+ return;
522
+ const base = binding ?? {
523
+ channel: this.channel,
524
+ chatKey: this.chatKey,
525
+ chatType: this.chatType,
526
+ sessionId: "",
527
+ ownerKey: msg.senderKey,
528
+ createdAt: Date.now(),
529
+ lastActiveAt: Date.now(),
530
+ sessions: [],
531
+ };
532
+ this.bindings.put({ ...base, welcomedAt: Date.now(), lastActiveAt: Date.now() });
533
+ await this.adapter
534
+ .sendCard(this.target(msg), { markdown: `${this.t.welcomeTitle}\n\n${this.t.welcomeBody(this.workDir)}` })
535
+ .catch(() => undefined);
536
+ }
537
+ /** Pick the localized advice text for a failed turn. */
538
+ errorAdvice(detail) {
539
+ switch (classifyError(detail)) {
540
+ case "permission":
541
+ return this.t.errorAdvicePermission;
542
+ case "network":
543
+ return this.t.errorAdviceNetwork;
544
+ case "model":
545
+ return this.t.errorAdviceModel;
546
+ default:
547
+ return this.t.errorAdviceGeneric;
548
+ }
549
+ }
550
+ /** Present a destructive-action confirmation; true only when the user confirms. */
551
+ async confirmAction(target, promptText) {
552
+ const { choice } = await this.adapter.promptChoice(target, {
553
+ title: this.t.confirmTitle,
554
+ description: promptText,
555
+ options: [
556
+ { id: "confirm:yes", label: this.t.confirmYes },
557
+ { id: "confirm:no", label: this.t.confirmNo },
558
+ ],
559
+ });
560
+ return choice === "confirm:yes";
561
+ }
495
562
  async drain() {
496
563
  if (this.running)
497
564
  return;
@@ -509,11 +576,16 @@ export class AgentRunner {
509
576
  }
510
577
  }
511
578
  async runTurn(msg) {
512
- // Check session lock - only allow if this channel owns the lock or no lock exists
579
+ // The mutual-exclusion lock arbitrates between the Feishu side and the Web
580
+ // mirror of the *same* session. Channels without a mirror (Telegram /
581
+ // DingTalk) must not be classified as "web" and must not take part in the
582
+ // lock at all.
583
+ const usesLock = this.channel === "feishu" || this.channel === "web";
513
584
  const currentChannel = this.channel === "feishu" ? "feishu" : "web";
514
585
  // Check timeout before attempting to acquire
515
- this.checkAndReleaseTimeoutLock();
516
- if (!this.acquireLock(currentChannel)) {
586
+ if (usesLock)
587
+ this.checkAndReleaseTimeoutLock();
588
+ if (usesLock && !this.acquireLock(currentChannel)) {
517
589
  const binding = this.bindings.get(this.channel, this.chatKey);
518
590
  const lockedBy = binding?.lockOwner ?? "unknown";
519
591
  // If from Web channel, queue the message instead of rejecting
@@ -533,27 +605,33 @@ export class AgentRunner {
533
605
  }
534
606
  try {
535
607
  // Acknowledge receipt before the (possibly slow) agent spin-up so the
536
- // user knows processing started.
608
+ // user knows processing started. When more messages are queued, say so.
609
+ const ack = this.t.processingStarted(truncate(msg.text, 60));
610
+ const ackText = this.queue.length > 0 ? `${ack}\n${this.t.queuedHint(this.queue.length)}` : ack;
537
611
  await this.adapter
538
- .sendText(this.target(msg), this.t.processingStarted(truncate(msg.text, 60)))
612
+ .sendText(this.target(msg), ackText)
539
613
  .catch(() => undefined);
614
+ // First message in this chat: one-time welcome card.
615
+ await this.maybeSendWelcome(msg);
540
616
  const agent = await this.ensureAgent(msg);
541
617
  const outcome = await this.driveAgent(agent, msg);
542
618
  this.touchBinding(msg);
543
619
  // Task-end stats: model, tokens, elapsed, and a compaction suggestion.
544
620
  await this.sendTurnStats(msg, outcome);
545
621
  // Release lock after task completion
546
- this.releaseLock();
547
- if (outcome.reason !== "completed" && this.summaryEnabled) {
622
+ if (usesLock)
623
+ this.releaseLock();
624
+ if (outcome.reason !== "completed") {
548
625
  await this.sendSummary(msg, outcome);
549
626
  }
550
627
  }
551
628
  catch (error) {
552
629
  // Release lock even on error
553
- this.releaseLock();
630
+ if (usesLock)
631
+ this.releaseLock();
554
632
  const detail = error instanceof Error ? error.message : String(error);
555
633
  await this.adapter
556
- .sendText(this.target(msg), this.t.processingFailed(truncate(detail)))
634
+ .sendText(this.target(msg), this.t.processingFailedAdvice(truncate(detail, 400), this.errorAdvice(detail)))
557
635
  .catch(() => undefined);
558
636
  }
559
637
  }
@@ -690,22 +768,12 @@ export class AgentRunner {
690
768
  const content = await this.buildUserContent(msg);
691
769
  return createUserMessage({ content, source: { kind: "user" } });
692
770
  };
693
- if (!this.streamEnabled) {
694
- agent.followup(await userMessage());
695
- await agent.whenIdle();
696
- await this.sessions.flush(agent.session);
697
- const outcome = summarizeTurn(agent.session.events, firstSeq);
698
- if (outcome.text !== "") {
699
- await this.adapter.sendText(this.target(msg), outcome.text).catch(() => undefined);
700
- }
701
- return outcome;
702
- }
703
771
  const chunks = createAsyncQueue();
704
772
  const now = Date.now();
705
773
  this.turn = {
706
774
  firstSeq, chunks, lastText: "",
707
775
  reasoning: false, hintPushed: false, lastIndex: undefined, pushedAny: false,
708
- startedAt: now, lastPushAt: now,
776
+ startedAt: now, lastPushAt: now, toolCount: 0,
709
777
  };
710
778
  // Liveness heartbeat: while the agent is working, keep the streaming card
711
779
  // visibly alive even through long reasoning or tool-execution stretches
@@ -748,13 +816,31 @@ export class AgentRunner {
748
816
  .catch(() => undefined);
749
817
  }, PROGRESS_WATCHDOG_CHECK_MS)
750
818
  : undefined;
819
+ // End the chunk stream unconditionally: if `followup` / `whenIdle` /
820
+ // `flush` throws, the adapter's `for await` over `chunks` must still be
821
+ // released, otherwise the streaming card hangs in "streaming" forever and
822
+ // the chunk queue / adapter promise leak. On the error path we also await
823
+ // (and swallow) the stream promise so its rejection can't escape as an
824
+ // unhandled rejection while the original error propagates.
825
+ const endChunks = () => {
826
+ try {
827
+ chunks.end();
828
+ }
829
+ catch {
830
+ // no-op
831
+ }
832
+ };
833
+ let streamPromise;
751
834
  try {
752
- const streamPromise = this.adapter.streamText(this.target(msg), chunks);
835
+ streamPromise = this.adapter.streamText(this.target(msg), chunks);
753
836
  agent.followup(await userMessage());
754
837
  await agent.whenIdle();
755
838
  await this.sessions.flush(agent.session);
756
- chunks.end();
757
- await streamPromise;
839
+ }
840
+ catch (error) {
841
+ endChunks();
842
+ await streamPromise?.catch(() => undefined);
843
+ throw error;
758
844
  }
759
845
  finally {
760
846
  if (heartbeat !== undefined)
@@ -762,6 +848,8 @@ export class AgentRunner {
762
848
  if (progressWatchdog !== undefined)
763
849
  clearInterval(progressWatchdog);
764
850
  }
851
+ endChunks();
852
+ await streamPromise;
765
853
  const outcome = summarizeTurn(agent.session.events, firstSeq);
766
854
  const text = outcome.text !== "" ? outcome.text : this.turn.lastText;
767
855
  this.turn = undefined;
@@ -776,30 +864,35 @@ export class AgentRunner {
776
864
  async buildUserContent(msg) {
777
865
  const content = [{ type: "text", text: msg.text }];
778
866
  const rawImages = msg.images;
779
- if (rawImages === undefined || rawImages.length === 0) {
780
- if (msg.imageError !== undefined) {
781
- content.push({ type: "text", text: this.t.imageDownloadFailed(msg.imageError) });
782
- }
783
- return content;
784
- }
785
- // Copy images into the agent's work directory so its tools can access them.
786
- const paths = await this.stageImages(rawImages);
787
- const locationText = paths.map((p) => `- ${p}`).join("\n");
788
- const mainVision = await this.modelSupportsVision(this.agent?.options.provider, this.agent?.options.model);
789
- if (mainVision) {
790
- const imageBlocks = await this.imageBlocks(paths);
791
- if (imageBlocks.length > 0) {
792
- this.log(`connect: main model supports vision — passing ${imageBlocks.length} image block(s) directly`);
793
- return [{ type: "text", text: msg.text }, ...imageBlocks];
794
- }
795
- this.log("connect: main model supports vision but image blocks failed to attach — falling back to paths");
796
- return content;
797
- }
798
- const description = await this.describeImages(paths);
799
- const note = this.t.imagesStaged(locationText, description);
800
- content.push({ type: "text", text: note });
801
- // Non-image attachments (files / audio / video): stage them into the work
802
- // directory and hand the agent the paths — it can open them with its tools.
867
+ // Images: pass directly to the main model when it supports vision,
868
+ // otherwise run a vision sub-task and inject its description as text —
869
+ // a text-only main model never stalls on images.
870
+ if (rawImages !== undefined && rawImages.length > 0) {
871
+ const paths = await this.stageImages(rawImages);
872
+ const locationText = paths.map((p) => `- ${p}`).join("\n");
873
+ const mainVision = await this.modelSupportsVision(this.agent?.options.provider, this.agent?.options.model);
874
+ if (mainVision) {
875
+ const imageBlocks = await this.imageBlocks(paths);
876
+ if (imageBlocks.length > 0) {
877
+ this.log(`connect: main model supports vision — passing ${imageBlocks.length} image block(s) directly`);
878
+ content.push(...imageBlocks);
879
+ }
880
+ else {
881
+ this.log("connect: main model supports vision but image blocks failed to attach — falling back to paths");
882
+ content.push({ type: "text", text: this.t.imagesStaged(locationText, "") });
883
+ }
884
+ }
885
+ else {
886
+ const description = await this.describeImages(paths);
887
+ content.push({ type: "text", text: this.t.imagesStaged(locationText, description) });
888
+ }
889
+ }
890
+ else if (msg.imageError !== undefined) {
891
+ content.push({ type: "text", text: this.t.imageDownloadFailed(msg.imageError) });
892
+ }
893
+ // Non-image attachments (files / audio / video) are staged independently of
894
+ // images: a pure-file message, or a vision-capable main model, must never
895
+ // silently drop them.
803
896
  const rawFiles = msg.files;
804
897
  if (rawFiles !== undefined && rawFiles.length > 0) {
805
898
  const stagedFiles = await this.stageFiles(rawFiles);
@@ -1010,7 +1103,7 @@ export class AgentRunner {
1010
1103
  if (outcome.text !== "")
1011
1104
  lines.push(this.t.produced(truncate(outcome.text, 300)));
1012
1105
  const card = { markdown: lines.join("\n") };
1013
- await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
1106
+ await this.adapter.sendCard(this.taskTarget(msg), card).catch(() => undefined);
1014
1107
  }
1015
1108
  /** Context-window usage at or above this percentage suggests compaction. */
1016
1109
  static COMPACT_THRESHOLD_PCT = 75;
@@ -1039,17 +1132,25 @@ export class AgentRunner {
1039
1132
  lines.push(pct >= AgentRunner.COMPACT_THRESHOLD_PCT ? this.t.taskStatsCompactSuggest : this.t.taskStatsCompactOk);
1040
1133
  }
1041
1134
  const card = { markdown: lines.join("\n") };
1042
- await this.adapter.sendCard(this.target(msg), card).catch(() => undefined);
1135
+ await this.adapter.sendCard(this.taskTarget(msg), card).catch(() => undefined);
1043
1136
  }
1044
1137
  async handleCommand(command, msg) {
1045
1138
  const target = this.target(msg);
1046
1139
  switch (command.kind) {
1047
1140
  case "new": {
1141
+ if (!(await this.confirmAction(target, this.t.confirmNewText))) {
1142
+ await this.adapter.sendText(target, this.t.actionCancelled);
1143
+ break;
1144
+ }
1048
1145
  await this.newChat(msg);
1049
1146
  await this.adapter.sendText(target, this.t.newChatDone);
1050
1147
  break;
1051
1148
  }
1052
1149
  case "clear": {
1150
+ if (!(await this.confirmAction(target, this.t.confirmClearText))) {
1151
+ await this.adapter.sendText(target, this.t.actionCancelled);
1152
+ break;
1153
+ }
1053
1154
  await this.clearChat(msg);
1054
1155
  await this.adapter.sendText(target, this.t.chatCleared);
1055
1156
  break;
@@ -1191,10 +1292,10 @@ export class AgentRunner {
1191
1292
  { sessionId, title, createdAt: Date.now(), lastActiveAt: Date.now(), workDir: this.workDir },
1192
1293
  ...others,
1193
1294
  ].slice(0, 50);
1295
+ // Spread the existing binding so per-chat settings (language, notify
1296
+ // level, progress interval, welcomedAt…) survive the first session record.
1194
1297
  this.bindings.put({
1195
- channel: this.channel,
1196
- chatKey: this.chatKey,
1197
- chatType: this.chatType,
1298
+ ...(binding ?? { channel: this.channel, chatKey: this.chatKey, chatType: this.chatType }),
1198
1299
  sessionId,
1199
1300
  ownerKey,
1200
1301
  createdAt: binding?.createdAt ?? Date.now(),
@@ -1237,7 +1338,18 @@ export class AgentRunner {
1237
1338
  await this.disposeAgent();
1238
1339
  const binding = this.bindings.get(this.channel, this.chatKey);
1239
1340
  if (binding !== undefined) {
1240
- this.bindings.put({ ...binding, sessionId: "", ownerKey: msg.senderKey, lastActiveAt: Date.now() });
1341
+ // A new chat gets a brand-new session; the previous mirror points at the
1342
+ // abandoned session and must be cleared so the next session re-mirrors.
1343
+ this.bindings.put({
1344
+ ...binding,
1345
+ sessionId: "",
1346
+ webMirrorSessionId: undefined,
1347
+ lockOwner: undefined,
1348
+ lockAcquiredAt: undefined,
1349
+ queuedMessages: [],
1350
+ ownerKey: msg.senderKey,
1351
+ lastActiveAt: Date.now(),
1352
+ });
1241
1353
  }
1242
1354
  }
1243
1355
  async clearChat(msg) {
@@ -1246,7 +1358,17 @@ export class AgentRunner {
1246
1358
  await this.disposeAgent();
1247
1359
  if (binding !== undefined && current !== undefined && current !== "") {
1248
1360
  const sessions = binding.sessions.filter((s) => s.sessionId !== current);
1249
- this.bindings.put({ ...binding, sessionId: "", sessions, ownerKey: msg.senderKey, lastActiveAt: Date.now() });
1361
+ this.bindings.put({
1362
+ ...binding,
1363
+ sessionId: "",
1364
+ sessions,
1365
+ webMirrorSessionId: undefined,
1366
+ lockOwner: undefined,
1367
+ lockAcquiredAt: undefined,
1368
+ queuedMessages: [],
1369
+ ownerKey: msg.senderKey,
1370
+ lastActiveAt: Date.now(),
1371
+ });
1250
1372
  }
1251
1373
  }
1252
1374
  async switchTo(sessionId, ownerKey) {
@@ -1254,7 +1376,19 @@ export class AgentRunner {
1254
1376
  if (binding === undefined)
1255
1377
  return;
1256
1378
  const sessions = binding.sessions.map((s) => (s.sessionId === sessionId ? { ...s, lastActiveAt: Date.now() } : s));
1257
- this.bindings.put({ ...binding, sessionId, sessions, ownerKey, lastActiveAt: Date.now() });
1379
+ // The mirror follows the active session: clear the stale mapping and let
1380
+ // `autoCreateWebMirror` re-create it for the newly selected session.
1381
+ this.bindings.put({
1382
+ ...binding,
1383
+ sessionId,
1384
+ sessions,
1385
+ webMirrorSessionId: undefined,
1386
+ lockOwner: undefined,
1387
+ lockAcquiredAt: undefined,
1388
+ queuedMessages: [],
1389
+ ownerKey,
1390
+ lastActiveAt: Date.now(),
1391
+ });
1258
1392
  await this.disposeAgent();
1259
1393
  }
1260
1394
  listWorkspaces() {
@@ -1363,7 +1497,12 @@ export class AgentRunner {
1363
1497
  label: this.t.menuNewChat,
1364
1498
  leaf: true,
1365
1499
  onSelect: async (t, m) => {
1366
- await this.newChat(m);
1500
+ if (await this.confirmAction(t, this.t.confirmNewText)) {
1501
+ await this.newChat(m);
1502
+ }
1503
+ else {
1504
+ await this.adapter.sendText(t, this.t.actionCancelled);
1505
+ }
1367
1506
  },
1368
1507
  });
1369
1508
  return items;
@@ -1488,7 +1627,7 @@ export class AgentRunner {
1488
1627
  if (event.type === "turn/end") {
1489
1628
  const reason = mapReason(event.data.reason.kind);
1490
1629
  // Use the event's timestamp if available, otherwise approximate
1491
- const completedAt = new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
1630
+ const completedAt = new Date().toLocaleTimeString(this.language === "en" ? "en-US" : "zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
1492
1631
  return { reason, completedAt };
1493
1632
  }
1494
1633
  }
@@ -1522,8 +1661,6 @@ export class AgentRunner {
1522
1661
  for (const w of this.config.workspaces)
1523
1662
  lines.push(` - ${w}`);
1524
1663
  lines.push(this.t.chatCountField(binding?.sessions.length ?? 0));
1525
- lines.push(this.t.streamLabel(this.streamEnabled));
1526
- lines.push(this.t.summaryLabel(this.summaryEnabled));
1527
1664
  lines.push(this.t.progressSetting(this.progressTimeoutMs === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(this.progressTimeoutMs / 60_000))));
1528
1665
  lines.push(this.t.allowUsersField(this.config.allowUsers.length, this.config.allowUsers.length === 0));
1529
1666
  lines.push(this.t.allowChatsField(this.config.allowChats.length, this.config.allowChats.length === 0));
@@ -1876,7 +2013,7 @@ export class AgentRunner {
1876
2013
  return;
1877
2014
  }
1878
2015
  const now = Date.now();
1879
- const locale = this.config.language === "en" ? "en-US" : "zh-CN";
2016
+ const locale = this.language === "en" ? "en-US" : "zh-CN";
1880
2017
  const lines = reminders.map((r) => {
1881
2018
  const due = Date.parse(r.scheduledAt) <= now;
1882
2019
  const state = due ? this.t.reminderDue : this.t.reminderClock;