dsh-advisor-group 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -119,6 +119,7 @@ const SwarmConfig = Schema.object({
119
119
  defaultMode: Schema.union([Schema.const("wait"), Schema.const("detach")]).default("wait").description("Default response mode when the caller omits both waitMs and detach: wait (block for the answer) or detach (return an askId, read later with ask_result)."),
120
120
  defaultWaitMs: Schema.number().min(1e3).max(6e5).default(18e4).description("Default wait ceiling in milliseconds when the caller omits waitMs (1–10 minutes)."),
121
121
  resultTtlHours: Schema.number().min(1).max(720).default(24).description("How long detached ask results are kept on disk, in hours (clamped to 1–720 before pruning)."),
122
+ deliverBack: Schema.boolean().default(true).description("Copy a settled answer back into the asking session automatically (done only; timeout and error are never delivered back). Off leaves results readable only through ask_result."),
122
123
  rosterIncludeSubagents: Schema.boolean().default(false).description("List subagent sessions in the session roster."),
123
124
  routingEnabled: Schema.boolean().default(true).description("Route an unnamed question to candidate sessions by keyword (no model call) instead of guessing a target; off keeps the old missing-target refusal."),
124
125
  routingTopN: Schema.number().min(1).max(8).default(3).description("How many routing candidates ask_session returns when the caller names no target (1–8)."),
@@ -3508,13 +3509,20 @@ async function judgeCrossSessionWithJev(text, textEn, candidates, config, signal
3508
3509
  * atomic tmp+rename writes, and reads that degrade to `undefined` instead of
3509
3510
  * throwing on a missing or damaged file.
3510
3511
  */
3512
+ /**
3513
+ * Identity of this host process. A record created by another process can no
3514
+ * longer be waited on (its background loop died with that process), so a
3515
+ * `waiting` record carrying a different boot id is reconciled when read.
3516
+ */
3517
+ const ASK_BOOT_ID = randomUUID();
3511
3518
  /** askId doubles as a file name, so it must never escape the store directory. */
3512
3519
  const ASK_ID_PATTERN = /^[A-Za-z0-9._-]{1,80}$/;
3513
3520
  const STATES = /* @__PURE__ */ new Set([
3514
3521
  "waiting",
3515
3522
  "done",
3516
3523
  "timeout",
3517
- "error"
3524
+ "error",
3525
+ "interrupted"
3518
3526
  ]);
3519
3527
  /** The store directory for a base dir such as `$DSH_HOME/storages/advisor-group`. */
3520
3528
  function askStoreDir(baseDir) {
@@ -3538,6 +3546,21 @@ function parseRecord(text) {
3538
3546
  return isAskRecord(value) ? value : void 0;
3539
3547
  }
3540
3548
  /**
3549
+ * Settle a `waiting` record whose wait belonged to a previous host process.
3550
+ * The background loop that would have collected the answer died with that
3551
+ * process, so leaving the record `waiting` promises something that will never
3552
+ * happen. Returns the rewritten record, or undefined when nothing is stale.
3553
+ */
3554
+ function reconcileStaleAsk(record, bootId = ASK_BOOT_ID) {
3555
+ if (record.state !== "waiting" || record.bootId === bootId) return void 0;
3556
+ return {
3557
+ ...record,
3558
+ state: "interrupted",
3559
+ settledAt: Date.now(),
3560
+ note: record.bootId === void 0 ? "这条异步提问来自更早的插件版本,等待状态已无法确认;对方可能已经作答,但答案没有被自动收回。请到对方会话里查看,或重新提问。" : "宿主在等待期间重启,等待被中断;对方可能已经作答,但答案没有被自动收回。请到对方会话里查看,或重新提问。"
3561
+ };
3562
+ }
3563
+ /**
3541
3564
  * Write one record atomically: tmp file + rename, so a concurrent reader never
3542
3565
  * sees a half-written JSON document. Creates `dir` when missing.
3543
3566
  */
@@ -3648,7 +3671,7 @@ const DRIVE_SEGMENT$1 = /^[A-Za-z]:$/u;
3648
3671
  * and a bare greeting is an empty chat that never named itself.
3649
3672
  */
3650
3673
  const NOISE_TITLE_PATTERN = /生成会话标题|^你好[,,\s]*$/u;
3651
- function isRecord(value) {
3674
+ function isRecord$1(value) {
3652
3675
  return value !== null && typeof value === "object";
3653
3676
  }
3654
3677
  /**
@@ -3656,11 +3679,11 @@ function isRecord(value) {
3656
3679
  * `{ title }` wrapper. Returns '' when neither yields a non-empty string.
3657
3680
  */
3658
3681
  function titleOf$1(projections) {
3659
- if (!isRecord(projections)) return "";
3682
+ if (!isRecord$1(projections)) return "";
3660
3683
  const values = projections.values;
3661
- if (!isRecord(values)) return "";
3684
+ if (!isRecord$1(values)) return "";
3662
3685
  const raw = values.title;
3663
- return (typeof raw === "string" ? raw : isRecord(raw) && typeof raw.title === "string" ? raw.title : "").trim();
3686
+ return (typeof raw === "string" ? raw : isRecord$1(raw) && typeof raw.title === "string" ? raw.title : "").trim();
3664
3687
  }
3665
3688
  /**
3666
3689
  * Whether a raw roster row must stay out of the roster and the routing pool:
@@ -3670,7 +3693,7 @@ function titleOf$1(projections) {
3670
3693
  * @param currentSessionId - the asking session, dropped when its id matches.
3671
3694
  */
3672
3695
  function isNoiseSession(session, currentSessionId) {
3673
- if (!isRecord(session)) return false;
3696
+ if (!isRecord$1(session)) return false;
3674
3697
  if (session.blank === true) return true;
3675
3698
  const title = titleOf$1(session.projections);
3676
3699
  if (title.length > 0 && NOISE_TITLE_PATTERN.test(title)) return true;
@@ -3847,12 +3870,12 @@ function withSessionLogSizes(sessions) {
3847
3870
  const rows = Array.isArray(sessions) ? sessions : [];
3848
3871
  const missing = [];
3849
3872
  for (const row of rows) {
3850
- if (!isRecord(row) || bytesOf$1(row.sizeBytes) !== void 0) continue;
3873
+ if (!isRecord$1(row) || bytesOf$1(row.sizeBytes) !== void 0) continue;
3851
3874
  if (typeof row.sessionId === "string" && row.sessionId.length > 0) missing.push(row.sessionId);
3852
3875
  }
3853
3876
  const sizes = sessionLogSizes(missing);
3854
3877
  return rows.map((row) => {
3855
- if (!isRecord(row) || bytesOf$1(row.sizeBytes) !== void 0) return row;
3878
+ if (!isRecord$1(row) || bytesOf$1(row.sizeBytes) !== void 0) return row;
3856
3879
  const size = typeof row.sessionId === "string" ? sizes.get(row.sessionId) : void 0;
3857
3880
  return size === void 0 ? row : {
3858
3881
  ...row,
@@ -3872,7 +3895,7 @@ function buildSessionRoster(sessions, options) {
3872
3895
  const query = typeof options?.query === "string" ? options.query.trim().toLowerCase() : "";
3873
3896
  const rows = [];
3874
3897
  for (const session of sessions) {
3875
- if (!isRecord(session)) continue;
3898
+ if (!isRecord$1(session)) continue;
3876
3899
  if (isNoiseSession(session)) continue;
3877
3900
  const raw = session;
3878
3901
  if (options?.hideSubagents === true && raw.origin === "subagent") continue;
@@ -3909,17 +3932,20 @@ function buildSessionRoster(sessions, options) {
3909
3932
  });
3910
3933
  }
3911
3934
  /**
3912
- * Render the roster as the text the model reads. `hasTitle` stays out of the
3913
- * text on purpose: it is a diagnostic fact, not a hint for the model.
3935
+ * Render the roster as the text the model reads. The sessionId is appended to
3936
+ * every line so identical titles stay distinguishable (the structured rows
3937
+ * already carry it). `hasTitle` stays out of the text on purpose: it is a
3938
+ * diagnostic fact, not a hint for the model.
3914
3939
  * @param rows - ordered rows from {@link buildSessionRoster}.
3915
3940
  * @param options.filtered - true when a query produced these rows, so the head reads 匹配.
3916
- * @returns heading plus one `- <name> — <hint>` line per row, or the empty notice.
3941
+ * @returns heading plus one `- <name> · <sessionId> — <hint>` line per row, or the empty notice.
3917
3942
  */
3918
3943
  function renderRoster(rows, options) {
3919
3944
  if (rows.length === 0) return "没有匹配的会话。";
3920
3945
  return [`${options?.filtered === true ? "匹配" : "共"} ${rows.length} 个会话`, ...rows.map((row) => {
3921
3946
  const flags = `${row.running ? " · 运行中" : ""}${row.subagent ? " · 子代理" : ""}`;
3922
- return `- ${shortSessionName(row.name, row.cwd)} — ${row.hint}${flags}`;
3947
+ const corps = row.roleName === void 0 ? "" : ` · 顾问团「${row.roleName}」${row.duty !== void 0 && row.duty.length > 0 ? `(${row.duty})` : ""}`;
3948
+ return `- ${shortSessionName(row.name, row.cwd)} · ${row.sessionId} — ${row.hint}${flags}${corps}`;
3923
3949
  })].join("\n");
3924
3950
  }
3925
3951
  //#endregion
@@ -4955,6 +4981,10 @@ const ASK_WAIT_DEFAULT_MS = 18e4;
4955
4981
  const ASK_WAIT_MAX_MS = 6e5;
4956
4982
  /** Poll interval while waiting for the target to reach whole-session idle. */
4957
4983
  const ASK_POLL_INTERVAL_MS = 1e3;
4984
+ /** Refusal/skip reason for a session the user archived in the workspace. */
4985
+ const ARCHIVED_SESSION_MESSAGE = "这个会话已归档,先在工作区里取消归档才能唤醒它";
4986
+ /** Refusal/skip reason for a session no workspace registers (the sidebar hides it). */
4987
+ const UNREGISTERED_SESSION_MESSAGE = "这个会话不在任何工作区里(可能已被移出工作区),先从工作区里找到它才能唤醒";
4958
4988
  /**
4959
4989
  * Envelope the receiving model reads. The Host offers no registration point for
4960
4990
  * this framing (the official Agent Teams build their own too), so the identity
@@ -5020,13 +5050,79 @@ function sessionDisplayName(summary) {
5020
5050
  if (title.length > 0) return title;
5021
5051
  return fallbackSessionName(summary.cwd, summary.updatedAt);
5022
5052
  }
5023
- function candidateOf(summary) {
5053
+ const AGE_MINUTE_MS = 6e4;
5054
+ /**
5055
+ * Human age for one session, for display only: 「刚刚」 / 「N 分钟前」 /
5056
+ * 「N 小时前」 / 「N 天前」 / 「N 个月前」 / 「N 年前」.
5057
+ * @param updatedAt - last-activity timestamp in ms.
5058
+ * @param now - clock to measure against.
5059
+ */
5060
+ function relativeAge(updatedAt, now) {
5061
+ const age = now - updatedAt;
5062
+ if (!Number.isFinite(age)) return "(未知时间)";
5063
+ if (age < AGE_MINUTE_MS) return "刚刚";
5064
+ const minutes = Math.floor(age / AGE_MINUTE_MS);
5065
+ if (minutes < 60) return `${minutes} 分钟前`;
5066
+ const hours = Math.floor(minutes / 60);
5067
+ if (hours < 24) return `${hours} 小时前`;
5068
+ const days = Math.floor(hours / 24);
5069
+ if (days < 30) return `${days} 天前`;
5070
+ const months = Math.floor(days / 30);
5071
+ if (months < 12) return `${months} 个月前`;
5072
+ return `${Math.floor(months / 12)} 年前`;
5073
+ }
5074
+ function candidateOf(summary, now = Date.now()) {
5075
+ const cwd = summary.cwd;
5024
5076
  return {
5025
5077
  sessionId: summary.sessionId,
5026
5078
  name: sessionDisplayName(summary),
5027
- hint: fallbackSessionName(summary.cwd, summary.updatedAt)
5079
+ hint: fallbackSessionName(cwd, summary.updatedAt),
5080
+ ...typeof cwd === "string" && cwd.length > 0 ? { cwd } : {},
5081
+ age: relativeAge(summary.updatedAt, now)
5028
5082
  };
5029
5083
  }
5084
+ /** `- name · sessionId · cwd · 多久前` for one candidate (display only). */
5085
+ function candidateLine(candidate) {
5086
+ const cwd = typeof candidate.cwd === "string" && candidate.cwd.length > 0 ? candidate.cwd : "(未知工作目录)";
5087
+ const age = typeof candidate.age === "string" && candidate.age.length > 0 ? candidate.age : "(未知时间)";
5088
+ return `- ${candidate.name} · ${candidate.sessionId} · ${cwd} · ${age}`;
5089
+ }
5090
+ /**
5091
+ * The candidate block appended to an ambiguous / not-found refusal and to batch
5092
+ * skip reasons. Display only — matching never uses it.
5093
+ *
5094
+ * - One hint line when a title repeats.
5095
+ * - At most {@link CANDIDATE_DISPLAY_LIMIT} candidate lines, each carrying
5096
+ * name · sessionId · cwd · 多久前.
5097
+ * - A remainder note when the list was truncated; the rest stay addressable by
5098
+ * sessionId or directory, they are just not spread out.
5099
+ * - Ordering is the caller's: running first, then most recently updated. Names
5100
+ * and ids are never used for sorting.
5101
+ * @param candidates - the ordered candidate pool (may be longer than the cap).
5102
+ * @param options.total - full match count when the pool is already capped.
5103
+ * @param options.sameTitle - false makes the remainder note say 「会话」 rather
5104
+ * than 「同名会话」 (used for the not-found recent list).
5105
+ */
5106
+ function renderCandidateBlock(candidates, options) {
5107
+ const pool = Array.isArray(candidates) ? candidates : [];
5108
+ const shown = pool.slice(0, 10);
5109
+ const total = Math.max(shown.length, Math.floor(options?.total ?? pool.length));
5110
+ const nameCounts = /* @__PURE__ */ new Map();
5111
+ for (const candidate of pool) nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
5112
+ const repeated = [...nameCounts.values()].some((count) => count > 1);
5113
+ const lines = [];
5114
+ if (repeated) lines.push("同名会话较多:按下面的 sessionId 或目录指定");
5115
+ lines.push(...shown.map(candidateLine));
5116
+ if (total > shown.length) {
5117
+ const noun = options?.sameTitle === false ? "个会话" : "个同名会话";
5118
+ lines.push(`…还有 ${total - shown.length} ${noun},可用目录关键词进一步收窄`);
5119
+ }
5120
+ return lines.join("\n");
5121
+ }
5122
+ /** Every listed session as a display candidate: running first, then most recent. */
5123
+ function recentCandidates(sessions, now = Date.now()) {
5124
+ return orderCandidates(sessions).map((session) => candidateOf(session, now));
5125
+ }
5030
5126
  /** Most-recently-active first, running sessions ahead of idle ones. */
5031
5127
  function orderCandidates(rows) {
5032
5128
  return [...rows].sort((a, b) => {
@@ -5034,10 +5130,8 @@ function orderCandidates(rows) {
5034
5130
  return b.updatedAt - a.updatedAt;
5035
5131
  });
5036
5132
  }
5037
- /** Partial candidates are capped: a broad needle must stay a readable list. */
5038
- const PARTIAL_CANDIDATE_LIMIT = 10;
5039
5133
  /** When nothing matches, this many recent sessions come back as candidates. */
5040
- const NOT_FOUND_CANDIDATE_LIMIT = 8;
5134
+ const NOT_FOUND_CANDIDATE_LIMIT = 10;
5041
5135
  /** The exclusion entries carried by `swarm.routingExclude`; malformed values are ignored. */
5042
5136
  function exclusionEntries$1(value) {
5043
5137
  return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
@@ -5081,10 +5175,14 @@ function matchTargetSession(sessions, reference) {
5081
5175
  name: sessionDisplayName(exact[0])
5082
5176
  }
5083
5177
  };
5084
- if (exact.length > 1) return {
5085
- kind: "ambiguous",
5086
- candidates: orderCandidates(exact).map(candidateOf)
5087
- };
5178
+ if (exact.length > 1) {
5179
+ const ordered = orderCandidates(exact).map((session) => candidateOf(session));
5180
+ return {
5181
+ kind: "ambiguous",
5182
+ candidates: ordered.slice(0, 10),
5183
+ total: ordered.length
5184
+ };
5185
+ }
5088
5186
  const partial = sessions.filter((session) => sessionDisplayName(session).toLowerCase().includes(folded) || session.cwd !== void 0 && session.cwd.toLowerCase().includes(folded));
5089
5187
  if (partial.length === 1) return {
5090
5188
  kind: "unique",
@@ -5094,9 +5192,11 @@ function matchTargetSession(sessions, reference) {
5094
5192
  }
5095
5193
  };
5096
5194
  if (partial.length === 0) return { kind: "missing" };
5195
+ const ordered = orderCandidates(partial).map((session) => candidateOf(session));
5097
5196
  return {
5098
5197
  kind: "ambiguous",
5099
- candidates: orderCandidates(partial).slice(0, PARTIAL_CANDIDATE_LIMIT).map(candidateOf)
5198
+ candidates: ordered.slice(0, 10),
5199
+ total: ordered.length
5100
5200
  };
5101
5201
  }
5102
5202
  /**
@@ -5164,6 +5264,55 @@ function collectAnswer(events, fromSeq) {
5164
5264
  function joinAnswerPieces(pieces) {
5165
5265
  return pieces.join("\n\n");
5166
5266
  }
5267
+ /** A provisional body longer than this is truncated for display. */
5268
+ const PROVISIONAL_BODY_LIMIT = 2e3;
5269
+ /**
5270
+ * Reconstruct the delivery watermark from the recorded delivery time: the
5271
+ * highest event seq at or before `fromMs`. The waiting record does not persist
5272
+ * the seq cut, so this recovers an equivalent floor from timestamps for a
5273
+ * read-only probe (settling still uses the real recorded watermark).
5274
+ * @param events - the target's log events in order.
5275
+ * @param fromMs - delivery time in ms since epoch.
5276
+ */
5277
+ function deliveryWatermark(events, fromMs) {
5278
+ if (!Number.isFinite(fromMs)) return -1;
5279
+ let highest = -1;
5280
+ for (const event of events) {
5281
+ if (!Number.isFinite(event.time) || event.time > fromMs) continue;
5282
+ if (typeof event.seq === "number" && event.seq > highest) highest = event.seq;
5283
+ }
5284
+ return highest;
5285
+ }
5286
+ /**
5287
+ * Read-only probe for a still-waiting detached ask: did the target already
5288
+ * write body text after this delivery? Reuses {@link collectAnswer} with the
5289
+ * reconstructed watermark. Display only — nothing is written back and the
5290
+ * record stays `waiting`. Any failure (bad timestamp, missing agent, host
5291
+ * error) returns undefined so the caller keeps the old output.
5292
+ */
5293
+ async function probeWaitingBody(controller, record) {
5294
+ try {
5295
+ const fromMs = Date.parse(record.from);
5296
+ if (!Number.isFinite(fromMs)) return void 0;
5297
+ const resolved = await controller.resolveAgent(record.targetSessionId);
5298
+ if (!("agent" in resolved) || !resolved.agent) return void 0;
5299
+ const events = agentEvents(resolved.agent);
5300
+ const text = joinAnswerPieces(collectAnswer(events, deliveryWatermark(events, fromMs)).pieces).trim();
5301
+ return text.length > 0 ? text : void 0;
5302
+ } catch {
5303
+ return;
5304
+ }
5305
+ }
5306
+ /**
5307
+ * One labelled provisional-body block (display only), truncated with a note.
5308
+ * @param text - the body collected so far.
5309
+ * @returns a two-part block, or undefined when there is no body.
5310
+ */
5311
+ function provisionalBodySection(text) {
5312
+ const trimmed = text.trim();
5313
+ if (trimmed.length === 0) return void 0;
5314
+ return `(尚未落定:目标可能仍在继续,下面是目前已收到的正文)\n${trimmed.slice(0, PROVISIONAL_BODY_LIMIT)}${trimmed.length > 2e3 ? "\n…(正文过长,仅显示前 2000 字)" : ""}`;
5315
+ }
5167
5316
  /** One 「结论:…」-style line, tolerating markdown bullets and bold markers. */
5168
5317
  const REPLY_FIELD = /^[\s>*#`-]*(结论|依据|不确定)[*\s]*[::]\s*(.*)$/u;
5169
5318
  /** Best-effort parse of the reply convention; undefined when the text ignores it. */
@@ -5280,6 +5429,17 @@ function logJevDecision(entry) {
5280
5429
  }
5281
5430
  /** Background loops started by `detach: true`; cancelled when the plugin unloads. */
5282
5431
  const detachedControllers = /* @__PURE__ */ new Set();
5432
+ /**
5433
+ * Track one background AbortController so plugin dispose ({@link
5434
+ * cancelDetachedAsks}) aborts it. Returns a disposer that stops tracking it.
5435
+ * The batch fan-out uses it so every in-flight target is cancelled on unload.
5436
+ */
5437
+ function trackBackgroundController(controller) {
5438
+ detachedControllers.add(controller);
5439
+ return () => {
5440
+ detachedControllers.delete(controller);
5441
+ };
5442
+ }
5283
5443
  /** Cancel every detached wait (plugin dispose). Safe to call repeatedly. */
5284
5444
  function cancelDetachedAsks() {
5285
5445
  for (const controller of detachedControllers) controller.abort();
@@ -5420,6 +5580,68 @@ async function deliverViaPrompt(controller, _signal, envelope, input, why) {
5420
5580
  };
5421
5581
  }
5422
5582
  }
5583
+ /** One answer-copy message: source line plus the FULL answer (never truncated). */
5584
+ function buildAnswerBackText(targetName, askId, answer) {
5585
+ return `【来自「${targetName}」 · askId=${askId}】\n${answer}`;
5586
+ }
5587
+ /**
5588
+ * Copy-back text used when the ask already rendered a card in the asking
5589
+ * session: the full answer lives in that card and in the stored record, so the
5590
+ * chat message points at it instead of printing the same text a second time.
5591
+ */
5592
+ function buildAnswerBackNotice(targetName, askId) {
5593
+ return `【来自「${targetName}」 · askId=${askId}】答复已就绪,并显示在跨会话卡片中。需要原文时调用 ask_result(askId=${askId})。`;
5594
+ }
5595
+ /** The merged batch copy-back: one message for every done answer. */
5596
+ function buildBatchAnswerBackText(entries) {
5597
+ if (entries.length === 1) {
5598
+ const only = entries[0];
5599
+ return buildAnswerBackText(only.targetName, only.askId, only.answer);
5600
+ }
5601
+ const blocks = entries.map((entry) => buildAnswerBackText(entry.targetName, entry.askId, entry.answer));
5602
+ return [`【批量回送 · 共 ${entries.length} 条】`, ...blocks].join("\n\n");
5603
+ }
5604
+ /**
5605
+ * Copy one settled answer back into the asking session. Reuses the same two
5606
+ * delivery paths as a question (native `followup`, then the host `prompt` queue
5607
+ * when the agent is not resolvable), so a busy asker simply receives it through
5608
+ * the host's inbox. Every failure is returned, never thrown.
5609
+ */
5610
+ async function deliverAnswerBack(controller, input, text) {
5611
+ const content = [{
5612
+ type: "text",
5613
+ text
5614
+ }];
5615
+ const source = {
5616
+ kind: "session-ask",
5617
+ form: "relay",
5618
+ askId: `${input.askId}-answer`,
5619
+ senderSessionId: input.targetSessionId,
5620
+ senderName: input.targetName,
5621
+ hop: 0
5622
+ };
5623
+ try {
5624
+ const resolved = await controller.resolveAgent(input.askerSessionId);
5625
+ if ("agent" in resolved && resolved.agent) {
5626
+ resolved.agent.followup(buildAskMessage(content, source));
5627
+ return { ok: true };
5628
+ }
5629
+ } catch {}
5630
+ try {
5631
+ await controller.prompt({
5632
+ requestId: `${input.askId}-answer`,
5633
+ sessionId: input.askerSessionId,
5634
+ mode: "queue",
5635
+ content
5636
+ });
5637
+ return { ok: true };
5638
+ } catch (error) {
5639
+ return {
5640
+ ok: false,
5641
+ error: describeHostError(error)
5642
+ };
5643
+ }
5644
+ }
5423
5645
  async function watermarkViaResolve(controller, sessionId) {
5424
5646
  try {
5425
5647
  const resolved = await controller.resolveAgent(sessionId);
@@ -5532,6 +5754,167 @@ async function collectTargetAnswer(controller, sessionId, fromSeq) {
5532
5754
  }
5533
5755
  }
5534
5756
  /**
5757
+ * Wait for the target's next whole-session idle, then collect what it said in
5758
+ * the interval. Shared by `ask_session` and the `ask_sessions` fan-out, so the
5759
+ * wait/collect/classify semantics have exactly one implementation.
5760
+ *
5761
+ * All failure paths are already converted into outcome notes by
5762
+ * {@link waitForTargetIdle} / {@link collectTargetAnswer}; this helper only
5763
+ * throws if the caller's own abort mechanism throws.
5764
+ */
5765
+ async function collectDeliveredAsk(controller, signal, input) {
5766
+ const outcome = await waitForTargetIdle(controller, signal, {
5767
+ sessionId: input.target.sessionId,
5768
+ targetWasRunning: input.targetWasRunning,
5769
+ waitMs: input.waitMs,
5770
+ log: input.log,
5771
+ fromSeq: input.fromSeq
5772
+ });
5773
+ const collected = await collectTargetAnswer(controller, input.target.sessionId, input.fromSeq);
5774
+ const rawAnswer = joinAnswerPieces(collected.pieces);
5775
+ const reply = parseAskReply(rawAnswer);
5776
+ const status = askStatus({
5777
+ endedBecause: outcome.endedBecause,
5778
+ answer: rawAnswer
5779
+ });
5780
+ const to = outcome.endedBecause === "idle" ? (/* @__PURE__ */ new Date()).toISOString() : null;
5781
+ const notes = [
5782
+ "区间语义:从投递回执到对方会话下一次整体空闲,这两个时刻之间对方产出的文字都算在内——这是「这个区间里对方说的话」,不是逐条对应的回答。",
5783
+ input.deliveryNote,
5784
+ outcome.note,
5785
+ input.targetWasRunning ? "投递时对方正在忙,问题已排队,会在它当前回合结束后进入新回合。" : "投递时对方不忙,宿主已唤醒它开一个新回合。",
5786
+ collected.readError === void 0 ? "" : `读取对方会话日志时出错,answer 可能不完整:${collected.readError}`
5787
+ ].filter((line) => line.length > 0);
5788
+ return {
5789
+ interval: {
5790
+ from: input.from,
5791
+ to,
5792
+ endedBecause: outcome.endedBecause
5793
+ },
5794
+ rawAnswer,
5795
+ ...reply === void 0 ? {} : { reply },
5796
+ status,
5797
+ note: notes.join("\n"),
5798
+ answer: rawAnswer
5799
+ };
5800
+ }
5801
+ let registryProbeLogged = false;
5802
+ /** Print the registry probe at most once per process, for host-log diagnosis. */
5803
+ function logRegistryProbe(found, workspaces, sessions) {
5804
+ if (registryProbeLogged) return;
5805
+ registryProbeLogged = true;
5806
+ console.log(found ? `[dsh-advisor-group] workspaceRegistry: ok (${workspaces} 个工区, ${sessions} 个已登记会话)` : "[dsh-advisor-group] workspaceRegistry: unavailable — 已退回不过滤");
5807
+ }
5808
+ /**
5809
+ * Resolve the host workspace registry from the plugin context. Cordis exposes a
5810
+ * service either as a plain property or through reflection (`ctx.reflect.get`,
5811
+ * which in non-strict mode returns undefined when the service is absent); a
5812
+ * plain `ctx.get` is accepted as the last resort. Every path is tried in order
5813
+ * and any throw falls through, so a host without the service simply yields
5814
+ * `undefined` (no filtering) instead of failing the caller.
5815
+ */
5816
+ function workspaceRegistryService(ctx) {
5817
+ const paths = [
5818
+ () => ctx?.workspaceRegistry,
5819
+ () => {
5820
+ const reflect = ctx?.reflect;
5821
+ const get = reflect?.get;
5822
+ if (typeof get !== "function") return void 0;
5823
+ return get.call(reflect, "workspaceRegistry", false);
5824
+ },
5825
+ () => {
5826
+ const get = ctx?.get;
5827
+ if (typeof get !== "function") return void 0;
5828
+ return get.call(ctx, "workspaceRegistry");
5829
+ }
5830
+ ];
5831
+ for (const resolve of paths) try {
5832
+ const value = resolve();
5833
+ if (value !== null && typeof value === "object") return value;
5834
+ } catch {}
5835
+ }
5836
+ /**
5837
+ * Archived session ids from the host workspace registry
5838
+ * (`workspaceRegistry.archivedSessionIds`, iterable). Used for the explicit
5839
+ * "this session is archived" refusal, and subtracted from the registered set.
5840
+ *
5841
+ * The registry is resolved through {@link workspaceRegistryService}; a missing
5842
+ * service (older host) or a throwing accessor yields an empty set: no
5843
+ * filtering, no error.
5844
+ */
5845
+ function archivedSessionIds(ctx) {
5846
+ try {
5847
+ const ids = workspaceRegistryService(ctx)?.archivedSessionIds;
5848
+ if (ids === null || ids === void 0) return /* @__PURE__ */ new Set();
5849
+ if (typeof ids[Symbol.iterator] !== "function") return /* @__PURE__ */ new Set();
5850
+ return new Set([...ids].map((id) => String(id)));
5851
+ } catch {
5852
+ return /* @__PURE__ */ new Set();
5853
+ }
5854
+ }
5855
+ /**
5856
+ * The session ids the host's sidebar shows: the union of every workspace's
5857
+ * `sessionIds` (`workspaceRegistry.list()`), minus the archived ones.
5858
+ *
5859
+ * This is the display universe: `sessionController.list()` also returns
5860
+ * sessions that no workspace registers (e.g. sessions the user moved out of
5861
+ * every workspace), and the sidebar hides those. Callers filter with this set.
5862
+ *
5863
+ * The registry itself is resolved through {@link workspaceRegistryService}
5864
+ * (plain property, then `ctx.reflect.get(..., false)`, then `ctx.get`), so it
5865
+ * works on hosts that only expose the service through reflection.
5866
+ *
5867
+ * Returns `undefined` when the host has no workspace registry, its `list` is not
5868
+ * a function, `list()` does not return an array, or anything throws — callers
5869
+ * then skip the filter entirely (old-host behaviour), never fail. The outcome
5870
+ * is logged once per process for diagnosis.
5871
+ */
5872
+ function registeredSessionIds(ctx) {
5873
+ try {
5874
+ const registry = workspaceRegistryService(ctx);
5875
+ if (registry === void 0) {
5876
+ logRegistryProbe(false, 0, 0);
5877
+ return;
5878
+ }
5879
+ const list = registry.list;
5880
+ if (typeof list !== "function") {
5881
+ logRegistryProbe(false, 0, 0);
5882
+ return;
5883
+ }
5884
+ const workspaces = list.call(registry);
5885
+ if (!Array.isArray(workspaces)) {
5886
+ logRegistryProbe(false, 0, 0);
5887
+ return;
5888
+ }
5889
+ const ids = /* @__PURE__ */ new Set();
5890
+ let workspaceCount = 0;
5891
+ for (const workspace of workspaces) {
5892
+ if (workspace === null || typeof workspace !== "object") continue;
5893
+ workspaceCount += 1;
5894
+ const sessionIds = workspace.sessionIds;
5895
+ if (sessionIds === null || sessionIds === void 0) continue;
5896
+ if (typeof sessionIds[Symbol.iterator] !== "function") continue;
5897
+ for (const id of sessionIds) ids.add(String(id));
5898
+ }
5899
+ for (const archived of archivedSessionIds(ctx)) ids.delete(archived);
5900
+ logRegistryProbe(true, workspaceCount, ids.size);
5901
+ return ids;
5902
+ } catch {
5903
+ logRegistryProbe(false, 0, 0);
5904
+ return;
5905
+ }
5906
+ }
5907
+ /**
5908
+ * The listed sessions that may take part in an ask: `swarm.routingExclude`
5909
+ * matches are dropped, and so is every session the workspace registry does not
5910
+ * register. A `undefined` registered set means "no registry": no filtering.
5911
+ * Shared by `ask_session` and `ask_sessions`.
5912
+ */
5913
+ function allowedAskSessions(sessions, exclude, registered) {
5914
+ const entries = exclusionEntries$1(exclude);
5915
+ return sessions.filter((session) => !summaryExcluded(entries, session) && (registered === void 0 || registered.has(session.sessionId)));
5916
+ }
5917
+ /**
5535
5918
  * Every pre-delivery guard, in one place. Pure: takes plain facts, returns a
5536
5919
  * rejection or nothing. Order matters — identity before ownership, ownership
5537
5920
  * before budget, budget before hop.
@@ -5539,16 +5922,24 @@ async function collectTargetAnswer(controller, sessionId, fromSeq) {
5539
5922
  */
5540
5923
  function checkAskGuards(input) {
5541
5924
  const { guards, match } = input;
5542
- if (match.kind === "missing") return {
5543
- code: "target-not-found",
5544
- message: "没有找到匹配的会话。下面列出最近的会话,请把候选交给用户挑选(标题或 sessionId 都可以);不要再去翻会话日志或目录找 id。",
5545
- candidates: orderCandidates(input.sessions).slice(0, NOT_FOUND_CANDIDATE_LIMIT).map(candidateOf)
5546
- };
5547
- if (match.kind === "ambiguous") return {
5548
- code: "target-ambiguous",
5549
- message: `有 ${match.candidates.length} 个会话都叫这个名字,不能猜。请把候选交给用户挑选(用 sessionId 指定即可精确命中)。`,
5550
- candidates: match.candidates
5551
- };
5925
+ if (match.kind === "missing") {
5926
+ const pool = recentCandidates(input.sessions);
5927
+ const block = renderCandidateBlock(pool, { sameTitle: false });
5928
+ return {
5929
+ code: "target-not-found",
5930
+ message: `没有找到匹配的会话。下面列出最近的会话,请把候选交给用户挑选(标题或 sessionId 都可以);不要再去翻会话日志或目录找 id。${block.length > 0 ? `\n${block}` : ""}`,
5931
+ candidates: pool.slice(0, NOT_FOUND_CANDIDATE_LIMIT)
5932
+ };
5933
+ }
5934
+ if (match.kind === "ambiguous") {
5935
+ const total = Math.max(match.candidates.length, match.total ?? match.candidates.length);
5936
+ const block = renderCandidateBlock(match.candidates, { total });
5937
+ return {
5938
+ code: "target-ambiguous",
5939
+ message: `有 ${total} 个会话都叫这个名字,不能猜。请把候选交给用户挑选(用 sessionId 指定即可精确命中)。${block.length > 0 ? `\n${block}` : ""}`,
5940
+ candidates: match.candidates
5941
+ };
5942
+ }
5552
5943
  const target = input.target ?? match.target;
5553
5944
  if (target.sessionId === guards.currentSessionId) return {
5554
5945
  code: "self-target",
@@ -5591,15 +5982,32 @@ async function askSession(ctx, signal, input) {
5591
5982
  const sessions = [];
5592
5983
  const allowed = [];
5593
5984
  const entries = exclusionEntries$1(input.exclude);
5985
+ const archived = archivedSessionIds(ctx);
5986
+ const registered = registeredSessionIds(ctx);
5594
5987
  let match = { kind: "missing" };
5595
5988
  try {
5596
5989
  const listed = await controller.list({}, signal);
5597
5990
  sessions.push(...listed.items);
5598
- for (const session of sessions) if (!summaryExcluded(entries, session)) allowed.push(session);
5599
- match = matchTargetSession(allowed, input.target ?? "");
5600
- if (match.kind === "missing" && entries.length > 0) {
5601
- const full = matchTargetSession(sessions, input.target ?? "");
5602
- if ((full.kind === "unique" ? sessions.some((session) => session.sessionId === full.target.sessionId && summaryExcluded(entries, session)) : full.kind === "ambiguous" && full.candidates.some((candidate) => !allowed.some((session) => session.sessionId === candidate.sessionId))) && (input.target ?? "").trim().length > 0) return toToolError({
5991
+ for (const session of sessions) {
5992
+ if (summaryExcluded(entries, session)) continue;
5993
+ if (registered !== void 0 && !registered.has(session.sessionId)) continue;
5994
+ allowed.push(session);
5995
+ }
5996
+ const targetText = (input.target ?? "").trim();
5997
+ match = matchTargetSession(allowed, targetText);
5998
+ if (match.kind === "missing" && targetText.length > 0) {
5999
+ const full = matchTargetSession(sessions, targetText);
6000
+ if (full.kind === "unique") {
6001
+ if (archived.has(full.target.sessionId)) return toToolError({
6002
+ code: "session-archived",
6003
+ message: ARCHIVED_SESSION_MESSAGE
6004
+ });
6005
+ if (registered !== void 0 && !registered.has(full.target.sessionId)) return toToolError({
6006
+ code: "session-unregistered",
6007
+ message: UNREGISTERED_SESSION_MESSAGE
6008
+ });
6009
+ }
6010
+ if (full.kind === "unique" ? sessions.some((session) => session.sessionId === full.target.sessionId && summaryExcluded(entries, session)) : full.kind === "ambiguous" && full.candidates.some((candidate) => !allowed.some((session) => session.sessionId === candidate.sessionId))) return toToolError({
5603
6011
  code: "swarm-excluded",
5604
6012
  message: "这个会话被设置为不参与蜂群(设置 → 插件 → 顾问群 → 全局蜂群 → 不参与路由的会话)。"
5605
6013
  });
@@ -5684,6 +6092,8 @@ async function runAsk(ctx, controller, signal, input, listed) {
5684
6092
  state: "waiting",
5685
6093
  createdAt: Date.now(),
5686
6094
  from,
6095
+ bootId: ASK_BOOT_ID,
6096
+ fromSeq: delivery.receipt.fromSeq,
5687
6097
  ...parentAskId === void 0 ? {} : { parentAskId }
5688
6098
  };
5689
6099
  try {
@@ -5721,15 +6131,47 @@ async function runAsk(ctx, controller, signal, input, listed) {
5721
6131
  error: error instanceof Error ? error.message : String(error)
5722
6132
  };
5723
6133
  } finally {
6134
+ let finalRecord = {
6135
+ ...record,
6136
+ ...settled,
6137
+ settledAt: Date.now()
6138
+ };
5724
6139
  try {
5725
- writeAskRecord(storeDir, {
5726
- ...record,
5727
- ...settled,
5728
- settledAt: Date.now()
5729
- });
6140
+ writeAskRecord(storeDir, finalRecord);
5730
6141
  } catch (error) {
5731
6142
  console.warn("[dsh-advisor-group] 异步结果落盘失败:", error);
5732
6143
  }
6144
+ if (input.onSettled) try {
6145
+ input.onSettled({
6146
+ askId,
6147
+ question: input.question,
6148
+ targetName: target.name,
6149
+ answer: finalRecord.answer ?? "",
6150
+ note: finalRecord.note ?? (finalRecord.error ? `等待/收集失败:${finalRecord.error}` : "等待结束。")
6151
+ });
6152
+ } catch (error) {
6153
+ console.warn("[dsh-advisor-group] 跨会话卡片(结束)回调失败:", error);
6154
+ }
6155
+ if (finalRecord.state === "done" && input.deliverBack !== false) {
6156
+ const outcome = await deliverAnswerBack(controller, {
6157
+ askerSessionId: finalRecord.sessionId,
6158
+ targetSessionId: finalRecord.targetSessionId,
6159
+ targetName: finalRecord.targetName,
6160
+ askId: finalRecord.askId
6161
+ }, input.onSettled ? buildAnswerBackNotice(finalRecord.targetName, finalRecord.askId) : buildAnswerBackText(finalRecord.targetName, finalRecord.askId, finalRecord.answer ?? ""));
6162
+ finalRecord = outcome.ok ? {
6163
+ ...finalRecord,
6164
+ deliveredBack: true
6165
+ } : {
6166
+ ...finalRecord,
6167
+ deliverBackNote: `自动回送失败:${outcome.error ?? "unknown"}(答案仍在记录里,可用 ask_result 取回)`
6168
+ };
6169
+ try {
6170
+ writeAskRecord(storeDir, finalRecord);
6171
+ } catch (error) {
6172
+ console.warn("[dsh-advisor-group] 回送状态落盘失败:", error);
6173
+ }
6174
+ }
5733
6175
  detachedControllers.delete(background);
5734
6176
  }
5735
6177
  })();
@@ -5751,35 +6193,21 @@ async function runAsk(ctx, controller, signal, input, listed) {
5751
6193
  note: `已投递(异步):问题已交给「${target.name}」,本次调用不等待答复。稍后用 ask_result(askId=${askId})读取结果。`
5752
6194
  };
5753
6195
  }
5754
- const outcome = await waitForTargetIdle(controller, signal, {
5755
- sessionId: target.sessionId,
5756
- targetWasRunning: delivery.receipt.targetWasRunning,
6196
+ const collected = await collectDeliveredAsk(controller, signal, {
6197
+ target,
5757
6198
  waitMs,
5758
- log: listed.log,
5759
- fromSeq: delivery.receipt.fromSeq
5760
- });
5761
- const collected = await collectTargetAnswer(controller, target.sessionId, delivery.receipt.fromSeq);
5762
- const rawAnswer = joinAnswerPieces(collected.pieces);
5763
- const answer = rawAnswer;
5764
- const reply = parseAskReply(rawAnswer);
5765
- const status = askStatus({
5766
- endedBecause: outcome.endedBecause,
5767
- answer
6199
+ from,
6200
+ targetWasRunning: delivery.receipt.targetWasRunning,
6201
+ fromSeq: delivery.receipt.fromSeq,
6202
+ deliveryNote: delivery.receipt.note,
6203
+ log: listed.log
5768
6204
  });
5769
- const to = outcome.endedBecause === "idle" ? (/* @__PURE__ */ new Date()).toISOString() : null;
5770
- const note = [
5771
- "区间语义:从投递回执到对方会话下一次整体空闲,这两个时刻之间对方产出的文字都算在内——这是「这个区间里对方说的话」,不是逐条对应的回答。",
5772
- delivery.receipt.note,
5773
- outcome.note,
5774
- delivery.receipt.targetWasRunning ? "投递时对方正在忙,问题已排队,会在它当前回合结束后进入新回合。" : "投递时对方不忙,宿主已唤醒它开一个新回合。",
5775
- collected.readError === void 0 ? "" : `读取对方会话日志时出错,answer 可能不完整:${collected.readError}`
5776
- ].filter((line) => line.length > 0).join("\n");
5777
6205
  input.onSettled?.({
5778
6206
  askId,
5779
6207
  question: input.question,
5780
6208
  targetName: target.name,
5781
- answer: rawAnswer,
5782
- note
6209
+ answer: collected.rawAnswer,
6210
+ note: collected.note
5783
6211
  });
5784
6212
  return {
5785
6213
  ok: true,
@@ -5789,15 +6217,11 @@ async function runAsk(ctx, controller, signal, input, listed) {
5789
6217
  },
5790
6218
  delivered: delivery.receipt.delivered,
5791
6219
  askId,
5792
- interval: {
5793
- from,
5794
- to,
5795
- endedBecause: outcome.endedBecause
5796
- },
5797
- answer,
5798
- ...reply === void 0 ? {} : { reply },
5799
- status,
5800
- note
6220
+ interval: collected.interval,
6221
+ answer: collected.answer,
6222
+ ...collected.reply === void 0 ? {} : { reply: collected.reply },
6223
+ status: collected.status,
6224
+ note: collected.note
5801
6225
  };
5802
6226
  }
5803
6227
  function toToolError(rejection) {
@@ -5808,46 +6232,536 @@ function toToolError(rejection) {
5808
6232
  ...rejection.candidates ? { candidates: rejection.candidates } : {}
5809
6233
  };
5810
6234
  }
6235
+ const SESSION_ID_MAX = 200;
6236
+ /** Path of the role-card fixture under the DSH home. */
6237
+ function corpsFile() {
6238
+ const home = process.env.DSH_HOME || join(homedir(), ".dsh");
6239
+ return join(home, "storages", "advisor-group", "corps.json");
6240
+ }
6241
+ function isRecord(value) {
6242
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6243
+ }
6244
+ /**
6245
+ * Read the card map. A missing, damaged or partially invalid file degrades to
6246
+ * the valid cards it still contains (never throws): the panel and the target
6247
+ * resolver must keep working.
6248
+ */
6249
+ function loadCorpsCards(file = corpsFile()) {
6250
+ let parsed;
6251
+ try {
6252
+ parsed = JSON.parse(readFileSync(file, "utf8"));
6253
+ } catch {
6254
+ return {};
6255
+ }
6256
+ if (!isRecord(parsed) || !isRecord(parsed.cards)) return {};
6257
+ const cards = {};
6258
+ for (const [sessionId, value] of Object.entries(parsed.cards)) {
6259
+ if (sessionId.length === 0 || sessionId.length > SESSION_ID_MAX || !isRecord(value)) continue;
6260
+ const name = typeof value.name === "string" ? value.name.trim() : "";
6261
+ const duty = typeof value.duty === "string" ? value.duty.trim() : "";
6262
+ if (name.length === 0 || name.length > 40 || duty.length > 200) continue;
6263
+ cards[sessionId] = {
6264
+ name,
6265
+ duty,
6266
+ updatedAt: typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt) ? value.updatedAt : Date.now()
6267
+ };
6268
+ }
6269
+ return cards;
6270
+ }
6271
+ /** Atomic tmp+rename write; creates the directory when missing. */
6272
+ function saveCorpsCards(cards, file = corpsFile()) {
6273
+ mkdirSync(dirname(file), { recursive: true });
6274
+ const tmp = `${file}.tmp`;
6275
+ writeFileSync(tmp, JSON.stringify({
6276
+ version: 1,
6277
+ cards
6278
+ }, null, 2), "utf8");
6279
+ renameSync(tmp, file);
6280
+ }
6281
+ /**
6282
+ * Upsert one card. An empty name deletes that session's card (the panel's
6283
+ * 「移除」 action). Names are unique case-insensitively across the whole map —
6284
+ * `ask_session` resolves a role name to exactly one session, never a guess.
6285
+ */
6286
+ function upsertCorpsCard(cards, sessionId, rawName, rawDuty, now = Date.now()) {
6287
+ const id = sessionId.trim();
6288
+ if (id.length === 0 || id.length > SESSION_ID_MAX) return {
6289
+ ok: false,
6290
+ code: "invalid-session",
6291
+ error: "会话 id 不合法。"
6292
+ };
6293
+ const name = rawName.trim();
6294
+ const duty = rawDuty.trim();
6295
+ if (name.length === 0) {
6296
+ const next = { ...cards };
6297
+ delete next[id];
6298
+ return {
6299
+ ok: true,
6300
+ cards: next
6301
+ };
6302
+ }
6303
+ if (name.length > 40) return {
6304
+ ok: false,
6305
+ code: "invalid-name",
6306
+ error: `姓名不能超过 40 个字符。`
6307
+ };
6308
+ if (duty.length > 200) return {
6309
+ ok: false,
6310
+ code: "invalid-duty",
6311
+ error: `职责不能超过 200 个字符。`
6312
+ };
6313
+ const lower = name.toLowerCase();
6314
+ for (const [otherId, card] of Object.entries(cards)) if (otherId !== id && card.name.toLowerCase() === lower) return {
6315
+ ok: false,
6316
+ code: "duplicate-name",
6317
+ error: `姓名「${name}」已经被另一个会话使用,请换一个。`
6318
+ };
6319
+ const card = {
6320
+ name,
6321
+ duty,
6322
+ updatedAt: now
6323
+ };
6324
+ return {
6325
+ ok: true,
6326
+ cards: {
6327
+ ...cards,
6328
+ [id]: card
6329
+ },
6330
+ card
6331
+ };
6332
+ }
6333
+ /**
6334
+ * Exact, case-insensitive role-name lookup. Duplicate names (only possible in a
6335
+ * hand-edited file) resolve to nothing, so a target is never guessed.
6336
+ */
6337
+ function findCorpsSession(cards, target) {
6338
+ const wanted = target.trim().toLowerCase();
6339
+ if (wanted.length === 0) return void 0;
6340
+ const hits = Object.entries(cards).filter(([, card]) => card.name.toLowerCase() === wanted);
6341
+ return hits.length === 1 ? hits[0][0] : void 0;
6342
+ }
5811
6343
  //#endregion
5812
- //#region src/tools.ts
5813
- function registerAdvisorTools(service, ctx) {
5814
- return [
5815
- defineTool({
5816
- name: "ask_advisors",
5817
- description: "Consult configured expert advisor models when the question is professional, long-tail world knowledge, high-risk, or the main model is uncertain. ALWAYS provide context with (a) a brief overview of the current project and (b) concrete details of the problem, including what has been tried and what went wrong. Also call immediately when the user @顾问群 or repeats the same question three times without resolution. Do NOT use for simple facts that web search can answer. Starting a new consultation runs the auto-deepen pipeline: up to maxRounds rounds of [driver deep-question → advisor A → advisor B (sees A) → advisor C (sees A+B) → …], closed by a driver-generated conclusion.",
5818
- parameters: {
5819
- question: {
5820
- type: "string",
5821
- description: "The question to ask the advisors. Required when starting a new consultation."
5822
- },
5823
- context: {
5824
- type: "string",
5825
- description: "Required background: (a) current project overview (domain/goal/stack/constraints) and (b) problem details (steps tried, expected vs actual, errors). Write \"该项背景未知\" for anything unknown; never leave it empty."
5826
- },
5827
- sessionId: {
5828
- type: "string",
5829
- description: "Existing consultation session id to continue."
5830
- },
5831
- followUp: {
5832
- type: "string",
5833
- description: "Follow-up question to send to the advisors in an existing session."
5834
- },
5835
- advisorIds: {
5836
- type: "array",
5837
- items: { type: "string" },
5838
- description: "Optional advisor ids to include; defaults to all configured advisors (capped by maxAdvisorsPerCall)."
5839
- },
5840
- confidence: {
5841
- type: "number",
5842
- description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
5843
- },
5844
- questionEn: {
5845
- type: "string",
5846
- description: "Optional self-contained English gist of the question plus key background. Used as the Jev pre-classification input only when the「Jev 英文判定」setting is enabled; the displayed question, session records, and duplicate detection always keep using question/context in Chinese. Omit it otherwise."
5847
- }
5848
- },
5849
- output: {
5850
- schema: {
6344
+ //#region src/multi-ask.ts
6345
+ /**
6346
+ * Batch cross-session ask (`ask_sessions`): one question to 1–8 targets in one
6347
+ * call, with every result collected into one summary. Each target gets its own
6348
+ * delivery, its own wait and its own AskRecord, so `ask_result <askId>` keeps
6349
+ * working afterwards and `parentAskId` is filled the same way `ask_session`
6350
+ * fills it.
6351
+ *
6352
+ * This module owns no delivery or collection logic: it reuses
6353
+ * {@link deliverAsk} for the handover and {@link collectDeliveredAsk} for the
6354
+ * wait/collect half, plus {@link matchTargetSession} / {@link checkAskGuards}
6355
+ * for resolution and the per-target guards. There is no routing here: an
6356
+ * unnamed or ambiguous target is skipped with a reason, never guessed.
6357
+ */
6358
+ /** Schema cap for one batch; extra targets are dropped (the schema says 1–8). */
6359
+ const BATCH_MAX_TARGETS = 8;
6360
+ const BATCH_SWARM_DISABLED = "蜂群模式已在设置里关闭(设置 → 插件 → 顾问群 → 全局蜂群)。";
6361
+ /** Concise per-target reason for a guard rejection (ask_session wording is long). */
6362
+ function skipReasonFor(rejection) {
6363
+ switch (rejection.code) {
6364
+ case "self-target": return "目标是当前会话自己";
6365
+ case "subagent-target": return "目标会话是子代理(subagent)会话";
6366
+ case "hop-limit": return "本次提问会超过 2 跳转发上限";
6367
+ default: return rejection.message;
6368
+ }
6369
+ }
6370
+ /** One child signal that aborts with its parent and on plugin dispose. */
6371
+ function linkedSignal(parent) {
6372
+ const controller = new AbortController();
6373
+ const onAbort = () => controller.abort();
6374
+ if (parent.aborted) controller.abort();
6375
+ else parent.addEventListener("abort", onAbort, { once: true });
6376
+ const untrack = trackBackgroundController(controller);
6377
+ return {
6378
+ signal: controller.signal,
6379
+ dispose: () => {
6380
+ parent.removeEventListener("abort", onAbort);
6381
+ untrack();
6382
+ }
6383
+ };
6384
+ }
6385
+ /**
6386
+ * Deliver to one target, write its waiting record, then wait/collect and write
6387
+ * the settled record. Never throws: a failure becomes an `error` result (and,
6388
+ * once delivered, an `error` record so `ask_result` still reports it).
6389
+ */
6390
+ async function askOne(plan, batch) {
6391
+ const askId = nextAskId();
6392
+ const from = (/* @__PURE__ */ new Date()).toISOString();
6393
+ const linked = linkedSignal(batch.parentSignal);
6394
+ let record;
6395
+ try {
6396
+ const source = {
6397
+ kind: "session-ask",
6398
+ form: "relay",
6399
+ askId,
6400
+ senderSessionId: batch.currentSessionId,
6401
+ senderName: batch.senderName,
6402
+ hop: batch.hop
6403
+ };
6404
+ const delivery = await deliverAsk(batch.controller, linked.signal, {
6405
+ target: plan.target,
6406
+ question: batch.question,
6407
+ context: batch.context,
6408
+ source,
6409
+ targetWasRunning: plan.targetWasRunning
6410
+ });
6411
+ if (!delivery.ok) return {
6412
+ sessionId: plan.target.sessionId,
6413
+ name: plan.target.name,
6414
+ askId,
6415
+ state: "error",
6416
+ answer: "",
6417
+ error: `${delivery.code}: ${delivery.message}`
6418
+ };
6419
+ const waitingRecord = {
6420
+ askId,
6421
+ sessionId: batch.currentSessionId,
6422
+ targetSessionId: plan.target.sessionId,
6423
+ targetName: plan.target.name,
6424
+ question: batch.question,
6425
+ state: "waiting",
6426
+ createdAt: Date.now(),
6427
+ from,
6428
+ bootId: ASK_BOOT_ID,
6429
+ fromSeq: delivery.receipt.fromSeq,
6430
+ ...batch.parentAskId === void 0 ? {} : { parentAskId: batch.parentAskId }
6431
+ };
6432
+ record = waitingRecord;
6433
+ try {
6434
+ writeAskRecord(batch.storeDir, waitingRecord);
6435
+ } catch (error) {
6436
+ console.warn("[dsh-advisor-group] 批量提问记录落盘失败(投递仍已发出):", error);
6437
+ }
6438
+ const collected = await collectDeliveredAsk(batch.controller, linked.signal, {
6439
+ target: plan.target,
6440
+ waitMs: batch.waitMs,
6441
+ from,
6442
+ targetWasRunning: delivery.receipt.targetWasRunning,
6443
+ fromSeq: delivery.receipt.fromSeq,
6444
+ deliveryNote: delivery.receipt.note,
6445
+ log: batch.log
6446
+ });
6447
+ const state = collected.interval.endedBecause === "idle" ? "done" : collected.interval.endedBecause === "timeout" ? "timeout" : "error";
6448
+ try {
6449
+ writeAskRecord(batch.storeDir, {
6450
+ ...waitingRecord,
6451
+ state,
6452
+ answer: collected.rawAnswer,
6453
+ ...collected.reply === void 0 ? {} : { reply: collected.reply },
6454
+ status: collected.status,
6455
+ note: collected.note,
6456
+ settledAt: Date.now()
6457
+ });
6458
+ } catch (error) {
6459
+ console.warn("[dsh-advisor-group] 批量提问记录落盘失败:", error);
6460
+ }
6461
+ return {
6462
+ sessionId: plan.target.sessionId,
6463
+ name: plan.target.name,
6464
+ askId,
6465
+ state,
6466
+ answer: collected.answer,
6467
+ status: collected.status,
6468
+ note: collected.note
6469
+ };
6470
+ } catch (error) {
6471
+ const message = error instanceof Error ? error.message : String(error);
6472
+ if (record !== void 0) try {
6473
+ writeAskRecord(batch.storeDir, {
6474
+ ...record,
6475
+ state: "error",
6476
+ error: message,
6477
+ settledAt: Date.now()
6478
+ });
6479
+ } catch {}
6480
+ return {
6481
+ sessionId: plan.target.sessionId,
6482
+ name: plan.target.name,
6483
+ askId,
6484
+ state: "error",
6485
+ answer: "",
6486
+ error: message
6487
+ };
6488
+ } finally {
6489
+ linked.dispose();
6490
+ }
6491
+ }
6492
+ /**
6493
+ * One merged copy-back for the whole batch: only `done` answers are included
6494
+ * (timeout / error stay silent), sent after every target settled, and the
6495
+ * done records are marked (or annotated on failure). Best effort throughout:
6496
+ * a copy-back failure must never throw or lose the stored answer.
6497
+ */
6498
+ async function deliverMergedBack(batch, results) {
6499
+ if (!batch.deliverBack) return;
6500
+ const done = results.filter((row) => row.state === "done");
6501
+ if (done.length === 0) return;
6502
+ const text = buildBatchAnswerBackText(done.map((row) => ({
6503
+ targetName: row.name,
6504
+ askId: row.askId,
6505
+ answer: row.answer
6506
+ })));
6507
+ const outcome = await deliverAnswerBack(batch.controller, {
6508
+ askerSessionId: batch.currentSessionId,
6509
+ targetSessionId: done[0].sessionId,
6510
+ targetName: done[0].name,
6511
+ askId: nextAskId()
6512
+ }, text);
6513
+ for (const row of done) try {
6514
+ const record = readAskRecord(batch.storeDir, row.askId);
6515
+ if (record === void 0 || record.deliveredBack === true) continue;
6516
+ writeAskRecord(batch.storeDir, outcome.ok ? {
6517
+ ...record,
6518
+ deliveredBack: true
6519
+ } : {
6520
+ ...record,
6521
+ deliverBackNote: `自动回送失败:${outcome.error ?? "unknown"}(答案仍在记录里,可用 ask_result 取回)`
6522
+ });
6523
+ } catch {}
6524
+ }
6525
+ /** `targets` → trimmed, de-duplicated (exact spelling), capped at the schema limit. */
6526
+ function readTargets(targets) {
6527
+ const values = Array.isArray(targets) ? targets : [];
6528
+ const seen = /* @__PURE__ */ new Set();
6529
+ const picked = [];
6530
+ for (const value of values) {
6531
+ if (typeof value !== "string") continue;
6532
+ const trimmed = value.trim();
6533
+ if (seen.has(trimmed)) continue;
6534
+ seen.add(trimmed);
6535
+ picked.push(trimmed);
6536
+ if (picked.length >= BATCH_MAX_TARGETS) break;
6537
+ }
6538
+ return picked;
6539
+ }
6540
+ /** All-skipped answer: `ok: false` with the reasons, never an exception. */
6541
+ function skippedOnly(skipped) {
6542
+ return {
6543
+ ok: false,
6544
+ count: 0,
6545
+ results: [],
6546
+ skipped
6547
+ };
6548
+ }
6549
+ /**
6550
+ * Run one batch ask: resolve targets, apply every per-target guard, deliver
6551
+ * concurrently and collect the results.
6552
+ *
6553
+ * - `all`: wait for every target to settle (or its own ceiling).
6554
+ * - `any`: return the first settled target; the others keep running in the
6555
+ * background and still write their own settled AskRecords.
6556
+ *
6557
+ * Never guesses: unnamed, ambiguous, missing, excluded or guard-rejected
6558
+ * targets land in `skipped`. When every target is skipped the call reports
6559
+ * `ok: false` with those reasons instead of failing with an exception.
6560
+ */
6561
+ async function askSessions(ctx, signal, input) {
6562
+ const mode = input.mode === "any" ? "any" : "all";
6563
+ const waitMs = normalizeWaitMs(typeof input.waitMs === "number" ? input.waitMs : input.defaultWaitMs);
6564
+ const log = input.log ?? (() => {});
6565
+ const targets = readTargets(input.targets);
6566
+ const skipped = [];
6567
+ if (targets.length === 0) return skippedOnly(skipped);
6568
+ if (!input.swarmEnabled) {
6569
+ for (const target of targets) skipped.push({
6570
+ target,
6571
+ reason: BATCH_SWARM_DISABLED
6572
+ });
6573
+ return skippedOnly(skipped);
6574
+ }
6575
+ if (typeof input.question !== "string" || input.question.trim().length === 0) {
6576
+ for (const target of targets) skipped.push({
6577
+ target,
6578
+ reason: "缺少问题"
6579
+ });
6580
+ return skippedOnly(skipped);
6581
+ }
6582
+ const controller = getSessionController(ctx);
6583
+ if (controller === void 0) {
6584
+ for (const target of targets) skipped.push({
6585
+ target,
6586
+ reason: "宿主没有提供 sessionController"
6587
+ });
6588
+ return skippedOnly(skipped);
6589
+ }
6590
+ if (typeof input.currentSessionId !== "string" || input.currentSessionId.length === 0) {
6591
+ for (const target of targets) skipped.push({
6592
+ target,
6593
+ reason: "无法确定当前会话的身份"
6594
+ });
6595
+ return skippedOnly(skipped);
6596
+ }
6597
+ let full;
6598
+ try {
6599
+ full = [...(await controller.list({}, signal)).items];
6600
+ } catch {
6601
+ for (const target of targets) skipped.push({
6602
+ target,
6603
+ reason: "读取会话列表失败"
6604
+ });
6605
+ return skippedOnly(skipped);
6606
+ }
6607
+ const archived = archivedSessionIds(ctx);
6608
+ const registered = registeredSessionIds(ctx);
6609
+ const allowed = allowedAskSessions(full, input.exclude, registered);
6610
+ const own = full.find((session) => session.sessionId === input.currentSessionId);
6611
+ const senderName = own === void 0 ? "未命名会话" : sessionDisplayName(own);
6612
+ const hop = nextAskHop(input.currentEvents);
6613
+ const parentAskId = inboundAskId(input.currentEvents);
6614
+ const plans = [];
6615
+ const plannedIds = /* @__PURE__ */ new Set();
6616
+ for (const target of targets) {
6617
+ if (target.length === 0) {
6618
+ skipped.push({
6619
+ target,
6620
+ reason: "没点名"
6621
+ });
6622
+ continue;
6623
+ }
6624
+ const match = matchTargetSession(allowed, target);
6625
+ if (match.kind === "ambiguous") {
6626
+ const total = Math.max(match.candidates.length, match.total ?? match.candidates.length);
6627
+ const block = renderCandidateBlock(match.candidates, { total });
6628
+ skipped.push({
6629
+ target,
6630
+ reason: `有 ${total} 个同名会话${block.length > 0 ? `\n${block}` : ""}`
6631
+ });
6632
+ continue;
6633
+ }
6634
+ if (match.kind === "missing") {
6635
+ const fullMatch = matchTargetSession(full, target);
6636
+ if (fullMatch.kind === "unique" && archived.has(fullMatch.target.sessionId)) skipped.push({
6637
+ target,
6638
+ reason: ARCHIVED_SESSION_MESSAGE
6639
+ });
6640
+ else if (fullMatch.kind === "unique" && registered !== void 0 && !registered.has(fullMatch.target.sessionId)) skipped.push({
6641
+ target,
6642
+ reason: UNREGISTERED_SESSION_MESSAGE
6643
+ });
6644
+ else if (fullMatch.kind === "unique" && !allowed.some((session) => session.sessionId === fullMatch.target.sessionId)) skipped.push({
6645
+ target,
6646
+ reason: "这个会话被设置为不参与蜂群"
6647
+ });
6648
+ else if (fullMatch.kind === "ambiguous") {
6649
+ const total = Math.max(fullMatch.candidates.length, fullMatch.total ?? fullMatch.candidates.length);
6650
+ const block = renderCandidateBlock(fullMatch.candidates, { total });
6651
+ skipped.push({
6652
+ target,
6653
+ reason: `有 ${total} 个同名会话${block.length > 0 ? `\n${block}` : ""}`
6654
+ });
6655
+ } else {
6656
+ const block = renderCandidateBlock(recentCandidates(allowed), { sameTitle: false });
6657
+ skipped.push({
6658
+ target,
6659
+ reason: `没有找到匹配的会话${block.length > 0 ? `\n${block}` : ""}`
6660
+ });
6661
+ }
6662
+ continue;
6663
+ }
6664
+ if (plannedIds.has(match.target.sessionId)) continue;
6665
+ const rejection = checkAskGuards({
6666
+ guards: {
6667
+ currentSessionId: input.currentSessionId,
6668
+ hop
6669
+ },
6670
+ match,
6671
+ sessions: allowed,
6672
+ question: input.question,
6673
+ context: input.context
6674
+ });
6675
+ if (rejection !== void 0) {
6676
+ skipped.push({
6677
+ target,
6678
+ reason: skipReasonFor(rejection)
6679
+ });
6680
+ continue;
6681
+ }
6682
+ const row = allowed.find((session) => session.sessionId === match.target.sessionId);
6683
+ plannedIds.add(match.target.sessionId);
6684
+ plans.push({
6685
+ target: match.target,
6686
+ targetWasRunning: row?.running === true
6687
+ });
6688
+ }
6689
+ if (plans.length === 0) return skippedOnly(skipped);
6690
+ const batch = {
6691
+ controller,
6692
+ currentSessionId: input.currentSessionId,
6693
+ senderName,
6694
+ question: input.question,
6695
+ context: input.context,
6696
+ hop,
6697
+ parentAskId,
6698
+ waitMs,
6699
+ storeDir: askStoreRoot(),
6700
+ log,
6701
+ parentSignal: signal,
6702
+ deliverBack: input.deliverBack !== false
6703
+ };
6704
+ const tasks = plans.map((plan) => askOne(plan, batch));
6705
+ if (mode === "any") {
6706
+ const allSettled = Promise.all(tasks);
6707
+ const results = [await Promise.race(tasks)];
6708
+ allSettled.then((all) => deliverMergedBack(batch, all));
6709
+ return {
6710
+ ok: true,
6711
+ count: results.length,
6712
+ results,
6713
+ skipped
6714
+ };
6715
+ }
6716
+ const results = await Promise.all(tasks);
6717
+ await deliverMergedBack(batch, results);
6718
+ return {
6719
+ ok: true,
6720
+ count: results.length,
6721
+ results,
6722
+ skipped
6723
+ };
6724
+ }
6725
+ //#endregion
6726
+ //#region src/tools.ts
6727
+ function registerAdvisorTools(service, ctx) {
6728
+ return [
6729
+ defineTool({
6730
+ name: "ask_advisors",
6731
+ description: "Consult configured expert advisor models when the question is professional, long-tail world knowledge, high-risk, or the main model is uncertain. ALWAYS provide context with (a) a brief overview of the current project and (b) concrete details of the problem, including what has been tried and what went wrong. Also call immediately when the user @顾问群 or repeats the same question three times without resolution. Do NOT use for simple facts that web search can answer. Starting a new consultation runs the auto-deepen pipeline: up to maxRounds rounds of [driver deep-question → advisor A → advisor B (sees A) → advisor C (sees A+B) → …], closed by a driver-generated conclusion.",
6732
+ parameters: {
6733
+ question: {
6734
+ type: "string",
6735
+ description: "The question to ask the advisors. Required when starting a new consultation."
6736
+ },
6737
+ context: {
6738
+ type: "string",
6739
+ description: "Required background: (a) current project overview (domain/goal/stack/constraints) and (b) problem details (steps tried, expected vs actual, errors). Write \"该项背景未知\" for anything unknown; never leave it empty."
6740
+ },
6741
+ sessionId: {
6742
+ type: "string",
6743
+ description: "Existing consultation session id to continue."
6744
+ },
6745
+ followUp: {
6746
+ type: "string",
6747
+ description: "Follow-up question to send to the advisors in an existing session."
6748
+ },
6749
+ advisorIds: {
6750
+ type: "array",
6751
+ items: { type: "string" },
6752
+ description: "Optional advisor ids to include; defaults to all configured advisors (capped by maxAdvisorsPerCall)."
6753
+ },
6754
+ confidence: {
6755
+ type: "number",
6756
+ description: "Optional self-assessed confidence 0-1. Values below the configured threshold escalate even without domain keywords."
6757
+ },
6758
+ questionEn: {
6759
+ type: "string",
6760
+ description: "Optional self-contained English gist of the question plus key background. Used as the Jev pre-classification input only when the「Jev 英文判定」setting is enabled; the displayed question, session records, and duplicate detection always keep using question/context in Chinese. Omit it otherwise."
6761
+ }
6762
+ },
6763
+ output: {
6764
+ schema: {
5851
6765
  type: "object",
5852
6766
  additionalProperties: false,
5853
6767
  properties: {
@@ -6076,7 +6990,7 @@ function registerAdvisorTools(service, ctx) {
6076
6990
  parameters: {
6077
6991
  target: {
6078
6992
  type: "string",
6079
- description: "The target session, named the way the user names it: its exact title as shown in the session list (the session name in the sidebar), a natural partial name, or a full sessionId. A title or the workspace/directory name is usually enough (「极简遥控器」 matches sessions under I:\\极简遥控器\\…). One unique match is used directly; several matches return the candidates (running / most recently active first) and a miss returns the recent sessions — both for the USER to choose: never guess, and do NOT search session logs or directories for session ids."
6993
+ description: "The target session, named the way the user names it: its exact title as shown in the session list (the session name in the sidebar), a natural partial name, a full sessionId, or an exact advisor-corps role name (see list_sessions). A title or the workspace/directory name is usually enough (「极简遥控器」 matches sessions under I:\\极简遥控器\\…). An exact role name resolves to the session it is assigned to and takes precedence over a same-named title. One unique match is used directly; several matches return the candidates (running / most recently active first) and a miss returns the recent sessions — both for the USER to choose: never guess, and do NOT search session logs or directories for session ids."
6080
6994
  },
6081
6995
  question: {
6082
6996
  type: "string",
@@ -6097,7 +7011,7 @@ function registerAdvisorTools(service, ctx) {
6097
7011
  },
6098
7012
  detach: {
6099
7013
  type: "boolean",
6100
- description: "Deliver without waiting: return immediately with an askId, then read the answer later with ask_result. Use it when the other session may take minutes, so this turn is not blocked. Ignored when waitMs is also given: pass only one."
7014
+ description: "Deliver without waiting: return immediately with an askId, then read the answer later with ask_result. Use it when the other session may take minutes, so this turn is not blocked. When detach is omitted, passing waitMs means wait; pass only one of them."
6101
7015
  }
6102
7016
  },
6103
7017
  output: {
@@ -6195,7 +7109,10 @@ function registerAdvisorTools(service, ctx) {
6195
7109
  hint: {
6196
7110
  type: "string",
6197
7111
  required: true
6198
- }
7112
+ },
7113
+ /** Display-only fields that tell identical titles apart. */
7114
+ cwd: { type: "string" },
7115
+ age: { type: "string" }
6199
7116
  }
6200
7117
  }
6201
7118
  },
@@ -6235,7 +7152,7 @@ function registerAdvisorTools(service, ctx) {
6235
7152
  ...value.reply === null || typeof value.reply !== "object" ? {} : { reply: value.reply }
6236
7153
  }) : [{
6237
7154
  type: "text",
6238
- text: `【ask_session 未完成:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}${Array.isArray(value.candidates) && value.candidates.length > 0 ? `\n候选会话:\n${value.candidates.map((item) => `- ${String(item.name ?? "")} · ${String(item.sessionId ?? "")}`).join("\n")}` : ""}${renderRouteCandidates(value.routeCandidates)}`
7155
+ text: `【ask_session 未完成:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}${renderRouteCandidates(value.routeCandidates)}`
6239
7156
  }]
6240
7157
  },
6241
7158
  async execute(args, exec) {
@@ -6278,8 +7195,11 @@ function registerAdvisorTools(service, ctx) {
6278
7195
  const own = listed.items.find((item) => item.sessionId === sessionId);
6279
7196
  if (own) senderName = sessionDisplayName(own);
6280
7197
  } catch {}
6281
- if ((typeof args.target === "string" ? args.target.trim() : "").length === 0 && swarm.routingEnabled !== false) {
6282
- const roster = visibleRosterSessions(listedItems, swarm.rosterIncludeSubagents).filter((row) => !isSessionExcluded(hidden, {
7198
+ const targetText = typeof args.target === "string" ? args.target.trim() : "";
7199
+ const corpsTarget = targetText.length > 0 ? findCorpsSession(loadCorpsCards(), targetText) : void 0;
7200
+ if (targetText.length === 0 && swarm.routingEnabled !== false) {
7201
+ const registered = registeredSessionIds(ctx);
7202
+ const roster = visibleRosterSessions(listedItems, swarm.rosterIncludeSubagents).filter((row) => (registered === void 0 || registered.has(row.sessionId)) && !isSessionExcluded(hidden, {
6283
7203
  sessionId: row.sessionId,
6284
7204
  names: [sessionDisplayName(row)]
6285
7205
  }));
@@ -6327,12 +7247,13 @@ function registerAdvisorTools(service, ctx) {
6327
7247
  currentSessionId: sessionId,
6328
7248
  senderName,
6329
7249
  currentEvents: readCurrentEvents(session),
6330
- target: typeof args.target === "string" ? args.target : "",
7250
+ target: corpsTarget ?? targetText,
6331
7251
  question,
6332
7252
  context,
6333
7253
  waitMs: answerMode.waitMs,
6334
7254
  detach: answerMode.detach,
6335
7255
  exclude: hidden,
7256
+ deliverBack: swarm.deliverBack !== false,
6336
7257
  log: (message) => console.log(`[dsh-advisor-group] ask_session: ${message}`)
6337
7258
  });
6338
7259
  if (value.ok !== true) return {
@@ -6357,9 +7278,165 @@ function registerAdvisorTools(service, ctx) {
6357
7278
  };
6358
7279
  }
6359
7280
  }),
7281
+ defineTool({
7282
+ name: "ask_sessions",
7283
+ description: "Ask ONE question to 1–8 existing sessions in a single call and collect the results. targets uses the same spelling as ask_session (exact sessionId, exact title, or a natural partial name). mode \"all\" (default) waits until every target settles or its own ceiling passes; mode \"any\" returns as soon as the first target settles while the others keep running in the background (their ask_result records still settle). There is NO routing here: an unnamed or ambiguous target is skipped with a reason instead of being guessed. Every delivered target gets its own askId; read a later result with ask_result.",
7284
+ parameters: {
7285
+ targets: {
7286
+ type: "array",
7287
+ items: { type: "string" },
7288
+ description: "The sessions to ask, 1–8 entries, same spelling as ask_session (exact id, exact title, exact advisor-corps role name, or a natural partial name). Duplicates collapse into one; unresolvable or excluded targets are skipped with a reason.",
7289
+ required: true
7290
+ },
7291
+ question: {
7292
+ type: "string",
7293
+ description: "The question to hand to every target. It is permanently recorded in each target session log, so write it self-contained.",
7294
+ required: true
7295
+ },
7296
+ context: {
7297
+ type: "string",
7298
+ description: "Optional background every target needs. Default: none. Anything sent here stays in every target session log forever, so send only what is necessary."
7299
+ },
7300
+ mode: {
7301
+ type: "string",
7302
+ enum: ["all", "any"],
7303
+ description: "all (default): wait for every target. any: return at the first settled target; the rest keep running in the background."
7304
+ },
7305
+ waitMs: {
7306
+ type: "number",
7307
+ description: `Per-target wait ceiling in milliseconds (default ${ASK_WAIT_DEFAULT_MS}, max ${ASK_WAIT_MAX_MS}). A ceiling stops only this call's wait; the target turn is never cancelled.`
7308
+ }
7309
+ },
7310
+ output: {
7311
+ schema: {
7312
+ type: "object",
7313
+ additionalProperties: false,
7314
+ properties: {
7315
+ ok: {
7316
+ type: "boolean",
7317
+ required: true
7318
+ },
7319
+ count: {
7320
+ type: "number",
7321
+ required: true
7322
+ },
7323
+ results: {
7324
+ type: "array",
7325
+ required: true,
7326
+ items: {
7327
+ type: "object",
7328
+ additionalProperties: false,
7329
+ properties: {
7330
+ sessionId: {
7331
+ type: "string",
7332
+ required: true
7333
+ },
7334
+ name: {
7335
+ type: "string",
7336
+ required: true
7337
+ },
7338
+ askId: {
7339
+ type: "string",
7340
+ required: true
7341
+ },
7342
+ state: {
7343
+ type: "string",
7344
+ enum: [
7345
+ "done",
7346
+ "timeout",
7347
+ "error"
7348
+ ],
7349
+ required: true
7350
+ },
7351
+ answer: {
7352
+ type: "string",
7353
+ required: true
7354
+ },
7355
+ status: {
7356
+ type: "string",
7357
+ enum: [
7358
+ "ok",
7359
+ "partial",
7360
+ "failed"
7361
+ ]
7362
+ },
7363
+ note: { type: "string" },
7364
+ error: { type: "string" }
7365
+ }
7366
+ }
7367
+ },
7368
+ skipped: {
7369
+ type: "array",
7370
+ required: true,
7371
+ items: {
7372
+ type: "object",
7373
+ additionalProperties: false,
7374
+ properties: {
7375
+ target: {
7376
+ type: "string",
7377
+ required: true
7378
+ },
7379
+ reason: {
7380
+ type: "string",
7381
+ required: true
7382
+ }
7383
+ }
7384
+ }
7385
+ }
7386
+ }
7387
+ },
7388
+ render: (_args, value) => {
7389
+ const results = Array.isArray(value.results) ? value.results : [];
7390
+ const skipped = Array.isArray(value.skipped) ? value.skipped : [];
7391
+ const stateLabel = {
7392
+ done: "已完成",
7393
+ timeout: "超时",
7394
+ error: "出错"
7395
+ };
7396
+ const blocks = results.map((row) => {
7397
+ const head = `- ${String(row.name ?? "")} · ${stateLabel[String(row.state ?? "")] ?? String(row.state ?? "")}`;
7398
+ const answer = String(row.answer ?? "").trim();
7399
+ const note = String(row.note ?? "").trim();
7400
+ const error = String(row.error ?? "").trim();
7401
+ return `${head}\n ${answer.length > 0 ? answer : error.length > 0 ? `错误:${error}` : note}`.trimEnd();
7402
+ });
7403
+ if (skipped.length > 0) blocks.push("跳过:", ...skipped.map((row) => {
7404
+ const reason = String(row.reason ?? "").split("\n").join("\n ");
7405
+ return `- ${String(row.target ?? "")}:${reason}`;
7406
+ }));
7407
+ return [{
7408
+ type: "text",
7409
+ text: blocks.length > 0 ? blocks.join("\n") : "没有投递任何会话。"
7410
+ }];
7411
+ }
7412
+ },
7413
+ async execute(args, exec) {
7414
+ const swarm = service.getConfig().swarm;
7415
+ const session = exec.agent?.session ?? getCurrentSessionFrom(exec);
7416
+ const sessionId = session?.id;
7417
+ const corpsCards = loadCorpsCards();
7418
+ return await askSessions(ctx, exec.signal, {
7419
+ currentSessionId: typeof sessionId === "string" ? sessionId : "",
7420
+ currentEvents: readCurrentEvents(session),
7421
+ targets: Array.isArray(args.targets) ? args.targets.map((target) => {
7422
+ const text = typeof target === "string" ? target : "";
7423
+ return findCorpsSession(corpsCards, text) ?? text;
7424
+ }) : [],
7425
+ question: typeof args.question === "string" ? args.question : "",
7426
+ context: typeof args.context === "string" ? args.context : void 0,
7427
+ mode: args.mode === "any" ? "any" : "all",
7428
+ waitMs: typeof args.waitMs === "number" ? args.waitMs : void 0,
7429
+ defaultWaitMs: swarm.defaultWaitMs,
7430
+ exclude: exclusionEntries(swarm),
7431
+ swarmEnabled: swarm.enabled !== false,
7432
+ deliverBack: swarm.deliverBack !== false,
7433
+ log: (message) => console.log(`[dsh-advisor-group] ask_sessions: ${message}`)
7434
+ });
7435
+ }
7436
+ }),
6360
7437
  defineTool({
6361
7438
  name: "list_sessions",
6362
- description: "List the sessions this host knows: the title shown in the session list, its sessionId, working directory, how long ago it was active, and whether it is running. Use it when the user names a session but you are unsure which one they mean, or after ask_session returns target-not-found / target-ambiguous — show the rows to the user instead of digging session ids out of log files. Read-only: it never wakes a session and costs no model call.",
7439
+ description: "List the sessions this host knows: the title shown in the session list, its sessionId, working directory, how long ago it was active, whether it is running, and its advisor-corps role name and duty when assigned. Use it when the user names a session but you are unsure which one they mean, when you want to address a member by role name, or after ask_session returns target-not-found / target-ambiguous — show the rows to the user instead of digging session ids out of log files. Read-only: it never wakes a session and costs no model call.",
6363
7440
  parameters: {
6364
7441
  query: {
6365
7442
  type: "string",
@@ -6419,17 +7496,20 @@ function registerAdvisorTools(service, ctx) {
6419
7496
  type: "boolean",
6420
7497
  required: true
6421
7498
  },
7499
+ roleName: { type: "string" },
7500
+ duty: { type: "string" },
6422
7501
  sizeHint: { type: "number" }
6423
7502
  }
6424
7503
  }
6425
7504
  },
6426
7505
  error: { type: "string" },
6427
- message: { type: "string" }
7506
+ message: { type: "string" },
7507
+ note: { type: "string" }
6428
7508
  }
6429
7509
  },
6430
7510
  render: (args, value) => value.ok === true ? [{
6431
7511
  type: "text",
6432
- text: renderRoster(value.sessions, { filtered: String(args.query ?? "").trim().length > 0 })
7512
+ text: renderRoster(value.sessions, { filtered: String(args.query ?? "").trim().length > 0 }) + (typeof value.note === "string" && value.note.length > 0 ? `\n\n${value.note}` : "")
6433
7513
  }] : [{
6434
7514
  type: "text",
6435
7515
  text: `【list_sessions 失败:${String(value.error ?? "unknown")}】\n${String(value.message ?? "")}`
@@ -6453,18 +7533,30 @@ function registerAdvisorTools(service, ctx) {
6453
7533
  message: "宿主没有提供 sessionController,无法列出会话。"
6454
7534
  };
6455
7535
  try {
6456
- const sessions = buildSessionRoster((await controller.list({}, exec.signal)).items.filter((item) => !isHiddenSession(swarm, item)), {
7536
+ const listed = await controller.list({}, exec.signal);
7537
+ const registered = registeredSessionIds(ctx);
7538
+ const rows = buildSessionRoster(listed.items.filter((item) => !isHiddenSession(swarm, item) && (registered === void 0 || registered.has(item.sessionId))), {
6457
7539
  query: typeof args.query === "string" ? args.query : "",
6458
7540
  limit: typeof args.limit === "number" ? args.limit : void 0,
6459
7541
  hideSubagents: swarm.rosterIncludeSubagents !== true
6460
- }).map((row) => ({
6461
- ...row,
6462
- name: shortSessionName(row.name, row.cwd)
6463
- }));
7542
+ });
7543
+ const corpsCards = loadCorpsCards();
7544
+ const sessions = rows.map((row) => {
7545
+ const card = corpsCards[row.sessionId];
7546
+ return {
7547
+ ...row,
7548
+ name: shortSessionName(row.name, row.cwd),
7549
+ ...card === void 0 ? {} : {
7550
+ roleName: card.name,
7551
+ duty: card.duty
7552
+ }
7553
+ };
7554
+ });
6464
7555
  return {
6465
7556
  ok: true,
6466
7557
  count: sessions.length,
6467
- sessions
7558
+ sessions,
7559
+ ...registered === void 0 ? { note: "当前宿主未提供工作区登记信息,名录未做「只显示侧栏可见会话」的过滤。" } : {}
6468
7560
  };
6469
7561
  } catch (error) {
6470
7562
  return {
@@ -6479,7 +7571,7 @@ function registerAdvisorTools(service, ctx) {
6479
7571
  }),
6480
7572
  defineTool({
6481
7573
  name: "ask_result",
6482
- description: "Read the result of a detached ask_session (called with detach: true). Pass askId for one result, or omit it to list this session's recent asks. States: waiting (the peer is still working — this is NOT a failure and NOT a reason to ask again; the send was already durable), done (answer ready), timeout (the wait window ended; a partial answer may exist), error. Read-only: it never wakes the target.",
7574
+ description: "Read the result of a detached ask_session (called with detach: true). Pass askId for one result, or omit it to list this session's recent asks. States: waiting (the peer is still working — this is NOT a failure and NOT a reason to ask again; the send was already durable), done (answer ready), timeout (the wait window ended; a partial answer may exist), error, interrupted (the host restarted while waiting, so the answer was never collected — check the target session or ask again). Read-only: it never wakes the target.",
6483
7575
  parameters: { askId: {
6484
7576
  type: "string",
6485
7577
  description: "The askId returned by ask_session with detach: true. Omit it to list the recent asks this session started."
@@ -6549,7 +7641,13 @@ function registerAdvisorTools(service, ctx) {
6549
7641
  }
6550
7642
  },
6551
7643
  note: { type: "string" },
6552
- error: { type: "string" }
7644
+ error: { type: "string" },
7645
+ /** Display only: body probed from a still-waiting target. */
7646
+ provisional: { type: "string" },
7647
+ /** True when the settled answer was copied back automatically. */
7648
+ deliveredBack: { type: "boolean" },
7649
+ /** Why the automatic copy-back did not happen, when it failed. */
7650
+ deliverBackNote: { type: "string" }
6553
7651
  }
6554
7652
  }
6555
7653
  },
@@ -6591,11 +7689,15 @@ function registerAdvisorTools(service, ctx) {
6591
7689
  pruneAskRecords(dir, ttlHours * 36e5);
6592
7690
  } catch {}
6593
7691
  const askId = typeof args.askId === "string" ? args.askId.trim() : "";
6594
- const results = askId.length > 0 ? [readAskRecord(dir, askId)].filter((row) => row !== void 0) : listAskRecords(dir, sessionId.length > 0 ? { sessionId } : void 0).slice(0, 20);
7692
+ const records = askId.length > 0 ? [readAskRecord(dir, askId)].filter((row) => row !== void 0) : listAskRecords(dir, sessionId.length > 0 ? { sessionId } : void 0).slice(0, 20);
7693
+ const visible = (await withProvisionalBodies(getSessionController(ctx), records)).map((row) => {
7694
+ const { bootId: _bootId, fromSeq: _fromSeq, ...rest } = row;
7695
+ return rest;
7696
+ });
6595
7697
  return {
6596
7698
  ok: true,
6597
- count: results.length,
6598
- results
7699
+ count: visible.length,
7700
+ results: visible
6599
7701
  };
6600
7702
  } catch (error) {
6601
7703
  return {
@@ -6610,24 +7712,59 @@ function registerAdvisorTools(service, ctx) {
6610
7712
  })
6611
7713
  ];
6612
7714
  }
7715
+ /**
7716
+ * Attach the provisional body to still-waiting records (display only). Skips
7717
+ * the probe entirely when the host exposes no session controller, and a failed
7718
+ * probe leaves the record unchanged, so the old output survives.
7719
+ */
7720
+ async function withProvisionalBodies(controller, records) {
7721
+ return Promise.all(records.map(async (row) => {
7722
+ const stale = reconcileStaleAsk(row);
7723
+ if (stale !== void 0) {
7724
+ try {
7725
+ writeAskRecord(askStoreRoot(), stale);
7726
+ } catch (error) {
7727
+ console.warn("[dsh-advisor-group] 中断记录落盘失败:", error);
7728
+ }
7729
+ return { ...stale };
7730
+ }
7731
+ if (row.state !== "waiting" || controller === void 0) return { ...row };
7732
+ const text = await probeWaitingBody(controller, row);
7733
+ return text === void 0 ? { ...row } : {
7734
+ ...row,
7735
+ provisional: text
7736
+ };
7737
+ }));
7738
+ }
6613
7739
  /** One compact block per detached ask result, for the model to read. */
6614
7740
  function renderAskRecords(rows) {
6615
7741
  const stateLabel = {
6616
7742
  waiting: "等待中",
6617
7743
  done: "已完成",
6618
7744
  timeout: "超时",
6619
- error: "出错"
7745
+ error: "出错",
7746
+ interrupted: "已中断"
6620
7747
  };
6621
7748
  return rows.map((row) => {
7749
+ const head = `- [${stateLabel[row.state]}] ${row.targetName} · askId=${row.askId}${row.deliveredBack === true ? " · 已自动回送" : ""}`;
7750
+ const parent = row.parentAskId === void 0 ? "" : ` (由 ${row.parentAskId} 转问)`;
7751
+ const question = ` 问题:${row.question.replace(/\s+/gu, " ").slice(0, 120)}`;
7752
+ const backNote = row.deliverBackNote === void 0 ? "" : ` 回送说明:${row.deliverBackNote.replace(/\s+/gu, " ")}`;
7753
+ const body = row.reply !== void 0 ? [
7754
+ row.reply.conclusion === void 0 ? "" : ` 结论:${row.reply.conclusion}`,
7755
+ row.reply.basis === void 0 ? "" : ` 依据:${row.reply.basis}`,
7756
+ row.reply.uncertainty === void 0 ? "" : ` 不确定:${row.reply.uncertainty}`
7757
+ ].filter((line) => line.length > 0).join("\n") : row.answer !== void 0 && row.answer.length > 0 ? ` ${row.answer.slice(0, 600)}` : row.error !== void 0 ? ` 错误:${row.error}` : "";
7758
+ const provisional = row.provisional === void 0 ? "" : provisionalBodySection(row.provisional) ?? "";
7759
+ const provisionalLines = provisional.length === 0 ? [] : provisional.split("\n").map((line) => ` ${line}`);
6622
7760
  return [
6623
- `- [${stateLabel[row.state]}] ${row.targetName} · askId=${row.askId}`,
6624
- row.parentAskId === void 0 ? "" : ` (由 ${row.parentAskId} 转问)`,
6625
- ` 问题:${row.question.replace(/\s+/gu, " ").slice(0, 120)}`,
6626
- row.reply !== void 0 ? [
6627
- row.reply.conclusion === void 0 ? "" : ` 结论:${row.reply.conclusion}`,
6628
- row.reply.basis === void 0 ? "" : ` 依据:${row.reply.basis}`,
6629
- row.reply.uncertainty === void 0 ? "" : ` 不确定:${row.reply.uncertainty}`
6630
- ].filter((line) => line.length > 0).join("\n") : row.answer !== void 0 && row.answer.length > 0 ? ` ${row.answer.slice(0, 600)}` : row.error !== void 0 ? ` 错误:${row.error}` : ""
7761
+ head,
7762
+ parent,
7763
+ question,
7764
+ body,
7765
+ row.note === void 0 || row.note.length === 0 ? "" : ` 说明:${row.note.replace(/\s+/gu, " ")}`,
7766
+ backNote,
7767
+ ...provisionalLines
6631
7768
  ].filter((line) => line.length > 0).join("\n");
6632
7769
  }).join("\n\n");
6633
7770
  }
@@ -6662,9 +7799,18 @@ const SWARM_DISABLED_MESSAGE = "蜂群模式已在设置里关闭(设置 →
6662
7799
  * one falls back to the configured default (`defaultMode` / `defaultWaitMs`).
6663
7800
  */
6664
7801
  function resolveAskDefaults(args, swarm) {
7802
+ const waitMs = typeof args.waitMs === "number" ? args.waitMs : swarm.defaultWaitMs;
7803
+ if (typeof args.detach === "boolean") return {
7804
+ waitMs,
7805
+ detach: args.detach
7806
+ };
7807
+ if (typeof args.waitMs === "number") return {
7808
+ waitMs,
7809
+ detach: false
7810
+ };
6665
7811
  return {
6666
- waitMs: typeof args.waitMs === "number" ? args.waitMs : swarm.defaultWaitMs,
6667
- detach: typeof args.detach === "boolean" ? args.detach : swarm.defaultMode === "detach"
7812
+ waitMs,
7813
+ detach: swarm.defaultMode === "detach"
6668
7814
  };
6669
7815
  }
6670
7816
  /**
@@ -7572,6 +8718,18 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
7572
8718
  }
7573
8719
  return;
7574
8720
  }
8721
+ if (req.method === "GET" && (pathname === "/advisor-group/corps" || pathname === "/advisor-group/corps/")) {
8722
+ await handleCorpsGet(ctx, service, res);
8723
+ return;
8724
+ }
8725
+ if (req.method === "POST" && (pathname === "/advisor-group/corps" || pathname === "/advisor-group/corps/")) {
8726
+ await handleCorpsPost(req, res);
8727
+ return;
8728
+ }
8729
+ if (req.method === "POST" && (pathname === "/advisor-group/corps/generate" || pathname === "/advisor-group/corps/generate/")) {
8730
+ await handleCorpsGenerate(ctx, service, req, res);
8731
+ return;
8732
+ }
7575
8733
  if (req.method === "GET" && (pathname === "/advisor-group/shadow" || pathname === "/advisor-group/shadow/")) {
7576
8734
  sendJson(res, 200, {
7577
8735
  ok: true,
@@ -7641,6 +8799,247 @@ function handleConfigRequest(ctx, scopedCtx, service, authToken) {
7641
8799
  });
7642
8800
  };
7643
8801
  }
8802
+ function isPlainRecord(value) {
8803
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8804
+ }
8805
+ /**
8806
+ * The same visibility universe as list_sessions/ask_session: workspace-
8807
+ * registered, not archived, not swarm-excluded; subagents only when enabled.
8808
+ * `undefined` means the host exposed no session controller at all.
8809
+ */
8810
+ async function collectCorpsRoster(ctx, service) {
8811
+ const controller = getSessionController(ctx);
8812
+ if (controller === void 0) return void 0;
8813
+ const listed = await controller.list({}, new AbortController().signal);
8814
+ const swarm = service.getConfig().swarm;
8815
+ const registered = registeredSessionIds(ctx);
8816
+ const archived = archivedSessionIds(ctx);
8817
+ const exclude = Array.isArray(swarm.routingExclude) ? swarm.routingExclude : [];
8818
+ const includeSubagents = swarm.rosterIncludeSubagents === true;
8819
+ const visible = listed.items.filter((item) => !archived.has(item.sessionId) && (registered === void 0 || registered.has(item.sessionId)) && !isSessionExcluded(exclude, {
8820
+ sessionId: item.sessionId,
8821
+ names: [sessionDisplayName(item)]
8822
+ }) && (includeSubagents || item.origin !== "subagent"));
8823
+ return {
8824
+ rows: buildSessionRoster(visible, {
8825
+ limit: 50,
8826
+ hideSubagents: !includeSubagents
8827
+ }),
8828
+ visibleIds: new Set(visible.map((item) => item.sessionId)),
8829
+ filtered: registered !== void 0
8830
+ };
8831
+ }
8832
+ function corpsOrphans(cards, visibleIds) {
8833
+ return Object.entries(cards).filter(([sessionId]) => !visibleIds.has(sessionId)).map(([sessionId, card]) => ({
8834
+ sessionId,
8835
+ ...card
8836
+ })).sort((a, b) => a.name.localeCompare(b.name, "zh"));
8837
+ }
8838
+ /** One panel read: the visible session roster plus every saved role card. */
8839
+ async function handleCorpsGet(ctx, service, res) {
8840
+ const cards = loadCorpsCards();
8841
+ try {
8842
+ const roster = await collectCorpsRoster(ctx, service);
8843
+ if (roster === void 0) {
8844
+ sendJson(res, 200, {
8845
+ ok: true,
8846
+ filtered: false,
8847
+ sessions: [],
8848
+ orphans: corpsOrphans(cards, /* @__PURE__ */ new Set()),
8849
+ note: "宿主没有提供会话列表服务,当前只能看到已保存的角色卡。"
8850
+ });
8851
+ return;
8852
+ }
8853
+ sendJson(res, 200, {
8854
+ ok: true,
8855
+ filtered: roster.filtered,
8856
+ sessions: roster.rows.map((row) => ({
8857
+ ...row,
8858
+ card: cards[row.sessionId] ?? null
8859
+ })),
8860
+ orphans: corpsOrphans(cards, roster.visibleIds)
8861
+ });
8862
+ } catch (error) {
8863
+ console.warn("[dsh-advisor-group] corps 读取失败:", error instanceof Error ? error.message : String(error));
8864
+ sendJson(res, 500, {
8865
+ ok: false,
8866
+ error: error instanceof Error ? error.message : String(error)
8867
+ });
8868
+ }
8869
+ }
8870
+ /** How many unassigned sessions one generation pass asks the model about. */
8871
+ const CORPS_GENERATE_LIMIT = 12;
8872
+ const CORPS_GENERATE_SYSTEM = `你是 DSH 顾问团的名册编辑。根据每个会话的标题、工作目录和活跃提示,推断它最适合承担什么任务,为它起一个简短的中文身份姓名,并写一句职责。只输出 JSON,不要解释。`;
8873
+ /** Accept a bare JSON object, a fenced block, or an array; validate against the roster. */
8874
+ function parseCorpsProposals(text, rows, cards) {
8875
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
8876
+ const raw = (fenced === null ? text : fenced[1]).trim();
8877
+ const objectStart = raw.indexOf("{");
8878
+ const objectEnd = raw.lastIndexOf("}");
8879
+ const arrayStart = raw.indexOf("[");
8880
+ const arrayEnd = raw.lastIndexOf("]");
8881
+ const candidate = arrayStart >= 0 && (objectStart < 0 || arrayStart < objectStart) && arrayEnd > arrayStart ? raw.slice(arrayStart, arrayEnd + 1) : objectStart >= 0 && objectEnd > objectStart ? raw.slice(objectStart, objectEnd + 1) : "";
8882
+ if (candidate.length === 0) return [];
8883
+ let parsed;
8884
+ try {
8885
+ parsed = JSON.parse(candidate);
8886
+ } catch {
8887
+ return [];
8888
+ }
8889
+ const items = isPlainRecord(parsed) && Array.isArray(parsed.cards) ? parsed.cards : Array.isArray(parsed) ? parsed : [];
8890
+ const allowed = new Set(rows.map((row) => row.sessionId));
8891
+ const usedNames = new Set(Object.values(cards).map((card) => card.name.trim().toLowerCase()));
8892
+ const usedIds = /* @__PURE__ */ new Set();
8893
+ const out = [];
8894
+ for (const item of items) {
8895
+ if (!isPlainRecord(item)) continue;
8896
+ const sessionId = typeof item.sessionId === "string" ? item.sessionId : "";
8897
+ const name = typeof item.name === "string" ? item.name.trim() : "";
8898
+ const duty = typeof item.duty === "string" ? item.duty.trim() : "";
8899
+ if (!allowed.has(sessionId) || usedIds.has(sessionId)) continue;
8900
+ if (name.length === 0 || name.length > 40 || duty.length > 200) continue;
8901
+ const lower = name.toLowerCase();
8902
+ if (usedNames.has(lower)) continue;
8903
+ usedNames.add(lower);
8904
+ usedIds.add(sessionId);
8905
+ out.push({
8906
+ sessionId,
8907
+ name,
8908
+ duty
8909
+ });
8910
+ }
8911
+ return out;
8912
+ }
8913
+ /**
8914
+ * Ask the CURRENT conversation's main model for unassigned role cards. The
8915
+ * panel then shows the proposals as drafts; nothing is saved until the user
8916
+ * confirms each row. Existing cards are never overwritten.
8917
+ */
8918
+ async function handleCorpsGenerate(ctx, service, req, res) {
8919
+ let sessionId = "";
8920
+ try {
8921
+ const parsed = JSON.parse(await readBody(req));
8922
+ sessionId = typeof parsed.sessionId === "string" ? parsed.sessionId : "";
8923
+ } catch (error) {
8924
+ sendJson(res, 400, {
8925
+ ok: false,
8926
+ error: `请求格式不正确:${error instanceof Error ? error.message : String(error)}`
8927
+ });
8928
+ return;
8929
+ }
8930
+ if (sessionId.length === 0) {
8931
+ sendJson(res, 400, {
8932
+ ok: false,
8933
+ error: "缺少 sessionId"
8934
+ });
8935
+ return;
8936
+ }
8937
+ try {
8938
+ const roster = await collectCorpsRoster(ctx, service);
8939
+ if (roster === void 0) {
8940
+ sendJson(res, 400, {
8941
+ ok: false,
8942
+ error: "宿主没有提供会话列表服务,无法生成。"
8943
+ });
8944
+ return;
8945
+ }
8946
+ const cards = loadCorpsCards();
8947
+ const unassigned = roster.rows.filter((row) => cards[row.sessionId] === void 0).slice(0, CORPS_GENERATE_LIMIT);
8948
+ if (unassigned.length === 0) {
8949
+ sendJson(res, 200, {
8950
+ ok: true,
8951
+ proposals: [],
8952
+ note: "所有可见会话都已有角色卡。"
8953
+ });
8954
+ return;
8955
+ }
8956
+ const sessions = ctx.sessions;
8957
+ const source = resolveDriverSource(typeof sessions?.get === "function" ? sessions.get(sessionId) : void 0, service.getConfig().discussion.driverModel);
8958
+ if (source === void 0) {
8959
+ sendJson(res, 400, {
8960
+ ok: false,
8961
+ error: "当前会话没有可用的主模型配置,无法生成角色卡。"
8962
+ });
8963
+ return;
8964
+ }
8965
+ const prompt = [
8966
+ "请为下列 DSH 会话各生成一张顾问团角色卡。",
8967
+ "",
8968
+ ...unassigned.map((row, index) => `${index + 1}. sessionId=${row.sessionId} | 标题=${row.name} | 目录=${row.cwd ?? "未知"} | ${row.hint}`),
8969
+ "",
8970
+ "要求:",
8971
+ "- 姓名简短,像同事的称呼,彼此不同,不要照抄标题;",
8972
+ "- 职责一句话,说明最适合承担什么任务,不超过 30 个汉字;",
8973
+ "- sessionId 必须原样照抄;",
8974
+ "- 只输出 JSON:{\"cards\":[{\"sessionId\":\"...\",\"name\":\"...\",\"duty\":\"...\"}]}"
8975
+ ].join("\n");
8976
+ const text = await generateWith(ctx, source, sessionId, CORPS_GENERATE_SYSTEM, prompt, AbortSignal.timeout(9e4), 9e4);
8977
+ if (text === void 0 || text.trim().length === 0) {
8978
+ sendJson(res, 502, {
8979
+ ok: false,
8980
+ error: "主模型没有返回内容。"
8981
+ });
8982
+ return;
8983
+ }
8984
+ const proposals = parseCorpsProposals(text, unassigned, cards);
8985
+ if (proposals.length === 0) {
8986
+ sendJson(res, 200, {
8987
+ ok: false,
8988
+ error: "主模型没有返回可用的角色卡,请重试。",
8989
+ raw: text.slice(0, 500)
8990
+ });
8991
+ return;
8992
+ }
8993
+ sendJson(res, 200, {
8994
+ ok: true,
8995
+ proposals,
8996
+ ...proposals.length < unassigned.length ? { note: `只生成了 ${proposals.length} / ${unassigned.length} 张,可以再点一次补齐。` } : {},
8997
+ model: {
8998
+ provider: source.provider,
8999
+ model: source.model
9000
+ }
9001
+ });
9002
+ } catch (error) {
9003
+ console.warn("[dsh-advisor-group] corps 生成失败:", error instanceof Error ? error.message : String(error));
9004
+ sendJson(res, 500, {
9005
+ ok: false,
9006
+ error: error instanceof Error ? error.message : String(error)
9007
+ });
9008
+ }
9009
+ }
9010
+ /** One panel write: upsert or delete a single role card. */
9011
+ async function handleCorpsPost(req, res) {
9012
+ try {
9013
+ const parsed = JSON.parse(await readBody(req));
9014
+ const result = upsertCorpsCard(loadCorpsCards(), typeof parsed.sessionId === "string" ? parsed.sessionId : "", typeof parsed.name === "string" ? parsed.name : "", typeof parsed.duty === "string" ? parsed.duty : "");
9015
+ if (!result.ok) {
9016
+ sendJson(res, result.code === "duplicate-name" ? 409 : 400, {
9017
+ ok: false,
9018
+ error: result.error,
9019
+ code: result.code
9020
+ });
9021
+ return;
9022
+ }
9023
+ try {
9024
+ saveCorpsCards(result.cards);
9025
+ } catch (error) {
9026
+ sendJson(res, 500, {
9027
+ ok: false,
9028
+ error: `保存角色卡失败:${error instanceof Error ? error.message : String(error)}`
9029
+ });
9030
+ return;
9031
+ }
9032
+ sendJson(res, 200, {
9033
+ ok: true,
9034
+ cards: result.cards
9035
+ });
9036
+ } catch (error) {
9037
+ sendJson(res, 400, {
9038
+ ok: false,
9039
+ error: `请求格式不正确:${error instanceof Error ? error.message : String(error)}`
9040
+ });
9041
+ }
9042
+ }
7644
9043
  async function sendProviders(ctx, res) {
7645
9044
  const providers = [];
7646
9045
  try {
@@ -7734,7 +9133,8 @@ function buildAdvisorPrompt(config, repeatPressure) {
7734
9133
  " b) 遇到问题的具体细节(做了什么、期望什么、实际发生了什么、已尝试哪些步骤、相关报错或现象)。",
7735
9134
  " 如果某项信息确实未知,明确写“该项背景未知”,不要留空 context,也不要让顾问在缺少背景的情况下猜测。",
7736
9135
  "6. 拿到顾问结论后:自行判断、综合并执行,不要原样复制整段顾问回复。",
7737
- "7. 发起新咨询:插件会自动进行多轮深挖(默认 autoDeepen:每轮结束由驱动模型基于全部讨论生成更深入的追问,顾问按顺序 A→B→C 接力回答,直到 maxRounds 轮),并自动产出综合结论——你无需再手动 followUp;只有想追加一轮固定主题时才传 sessionId+followUp。"
9136
+ "7. 发起新咨询:插件会自动进行多轮深挖(默认 autoDeepen:每轮结束由驱动模型基于全部讨论生成更深入的追问,顾问按顺序 A→B→C 接力回答,直到 maxRounds 轮),并自动产出综合结论——你无需再手动 followUp;只有想追加一轮固定主题时才传 sessionId+followUp。",
9137
+ "8. 跨会话提问(ask_session)时,可以先调用 list_sessions 查看「顾问团」的姓名与职责,再直接用姓名作为 target;原来的会话标题、目录名和 sessionId 仍然可用。"
7738
9138
  ];
7739
9139
  if (repeatPressure && repeatPressure.count >= 3) lines.push("", `【强制升级】当前会话中同一问题已被用户重复 ${repeatPressure.count} 次仍未解决。你必须立即调用 ask_advisors,把用户最新一次的问题原样作为 question 传入,并在 context 中补充项目概况与已尝试步骤;不得再让用户重复或自行空转。`);
7740
9140
  else if (repeatPressure && repeatPressure.count > 1) lines.push("", `【提醒】当前会话中同一问题已出现 ${repeatPressure.count} 次。若本轮仍无法解决,必须调用 ask_advisors 升级到顾问群。`);
@@ -7783,6 +9183,22 @@ const inject = [
7783
9183
  "sessions"
7784
9184
  ];
7785
9185
  /**
9186
+ * Recall candidates for the background hint, restricted to the sessions the
9187
+ * host actually shows: the registered set (workspace `sessionIds` minus
9188
+ * archived). `registered === undefined` means "this host has no workspace
9189
+ * registry" and filters nothing; a session no workspace registers must never be
9190
+ * suggested by a hint. Pure except for the bounded log-size probe inside the
9191
+ * router.
9192
+ */
9193
+ function recallHintCandidates(input) {
9194
+ return routeSessions(withSessionLogSizes(input.sessions.filter((session) => input.registered === void 0 || input.registered.has(session.sessionId))), input.text, {
9195
+ topN: input.topN,
9196
+ currentSessionId: input.currentSessionId,
9197
+ cache: false,
9198
+ exclude: input.exclude ?? []
9199
+ });
9200
+ }
9201
+ /**
7786
9202
  * One background cross-session suggestion pass: recall the local candidates
7787
9203
  * for this turn, ask Jev the batched judgment, and remember a one-turn hint.
7788
9204
  * Everything is best effort — no hint, no error, no delivery on any failure.
@@ -7792,10 +9208,12 @@ async function suggestCrossSession(input) {
7792
9208
  const controller = getSessionController(input.ctx);
7793
9209
  if (controller === void 0) return;
7794
9210
  const config = input.service.getConfig();
7795
- const candidates = routeSessions(withSessionLogSizes(visibleRosterSessions((await controller.list({}, input.signal)).items, config.swarm.rosterIncludeSubagents)), input.text, {
9211
+ const candidates = recallHintCandidates({
9212
+ sessions: visibleRosterSessions((await controller.list({}, input.signal)).items, config.swarm.rosterIncludeSubagents),
9213
+ registered: registeredSessionIds(input.ctx),
9214
+ text: input.text,
7796
9215
  topN: config.swarm.routingTopN,
7797
9216
  currentSessionId: input.sessionId,
7798
- cache: false,
7799
9217
  exclude: Array.isArray(config.swarm.routingExclude) ? config.swarm.routingExclude : []
7800
9218
  });
7801
9219
  const judge = async (turn) => judgeCrossSessionWithJev(turn.text, turn.textEn, turn.candidates.map((candidate) => ({
@@ -7885,4 +9303,4 @@ function apply(ctx, config) {
7885
9303
  }), "dsh-advisor-group: system prompt section");
7886
9304
  }
7887
9305
  //#endregion
7888
- export { Config, apply, inject, name };
9306
+ export { Config, apply, inject, name, recallHintCandidates };