gossipcat 0.6.4 → 0.6.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.
@@ -44350,6 +44350,83 @@ var init_api_mirror_events = __esm({
44350
44350
  });
44351
44351
 
44352
44352
  // packages/relay/src/dashboard/api-bridge.ts
44353
+ function validateAskQuestions(raw) {
44354
+ if (!Array.isArray(raw) || raw.length === 0) {
44355
+ return { error: "questions must be a non-empty array" };
44356
+ }
44357
+ if (raw.length > ASK_MAX_QUESTIONS) {
44358
+ return { error: `too many questions (max ${ASK_MAX_QUESTIONS})` };
44359
+ }
44360
+ const out = [];
44361
+ for (let i = 0; i < raw.length; i++) {
44362
+ const q = raw[i];
44363
+ if (!q || typeof q !== "object") {
44364
+ return { error: `question ${i} must be an object` };
44365
+ }
44366
+ if (typeof q.header !== "string" || q.header.trim().length === 0) {
44367
+ return { error: `question ${i} header must be a non-empty string` };
44368
+ }
44369
+ if (q.header.length > ASK_MAX_HEADER) {
44370
+ return { error: `question ${i} header too long (max ${ASK_MAX_HEADER})` };
44371
+ }
44372
+ if (typeof q.question !== "string" || q.question.trim().length === 0) {
44373
+ return { error: `question ${i} question must be a non-empty string` };
44374
+ }
44375
+ if (q.question.length > ASK_MAX_QUESTION) {
44376
+ return { error: `question ${i} question too long (max ${ASK_MAX_QUESTION})` };
44377
+ }
44378
+ if (q.multiSelect !== void 0 && typeof q.multiSelect !== "boolean") {
44379
+ return { error: `question ${i} multiSelect must be a boolean` };
44380
+ }
44381
+ if (q.allowOther !== void 0 && typeof q.allowOther !== "boolean") {
44382
+ return { error: `question ${i} allowOther must be a boolean` };
44383
+ }
44384
+ if (!Array.isArray(q.options) || q.options.length === 0) {
44385
+ return { error: `question ${i} options must be a non-empty array` };
44386
+ }
44387
+ if (q.options.length > ASK_MAX_OPTIONS) {
44388
+ return { error: `question ${i} has too many options (max ${ASK_MAX_OPTIONS})` };
44389
+ }
44390
+ const options = [];
44391
+ const labels = /* @__PURE__ */ new Set();
44392
+ for (let j = 0; j < q.options.length; j++) {
44393
+ const o = q.options[j];
44394
+ if (!o || typeof o !== "object") {
44395
+ return { error: `question ${i} option ${j} must be an object` };
44396
+ }
44397
+ if (typeof o.label !== "string" || o.label.trim().length === 0) {
44398
+ return { error: `question ${i} option ${j} label must be a non-empty string` };
44399
+ }
44400
+ if (o.label.length > ASK_MAX_LABEL) {
44401
+ return { error: `question ${i} option ${j} label too long (max ${ASK_MAX_LABEL})` };
44402
+ }
44403
+ if (labels.has(o.label)) {
44404
+ return { error: `question ${i} has a duplicate option label` };
44405
+ }
44406
+ labels.add(o.label);
44407
+ const opt = { label: o.label };
44408
+ if (o.description !== void 0 && o.description !== null) {
44409
+ if (typeof o.description !== "string") {
44410
+ return { error: `question ${i} option ${j} description must be a string` };
44411
+ }
44412
+ if (o.description.length > ASK_MAX_DESCRIPTION) {
44413
+ return { error: `question ${i} option ${j} description too long (max ${ASK_MAX_DESCRIPTION})` };
44414
+ }
44415
+ opt.description = o.description;
44416
+ }
44417
+ options.push(opt);
44418
+ }
44419
+ out.push({
44420
+ questionId: `q${i}`,
44421
+ header: q.header,
44422
+ question: q.question,
44423
+ ...q.multiSelect === true ? { multiSelect: true } : {},
44424
+ options,
44425
+ ...q.allowOther === true ? { allowOther: true } : {}
44426
+ });
44427
+ }
44428
+ return { questions: out };
44429
+ }
44353
44430
  function isMirrorRole(v) {
44354
44431
  return typeof v === "string" && MIRROR_ROLES.includes(v);
44355
44432
  }
@@ -44359,12 +44436,19 @@ function validateChatId(raw) {
44359
44436
  if (!CHAT_ID_RE.test(v)) return null;
44360
44437
  return v;
44361
44438
  }
44362
- var import_crypto26, MIRROR_ROLES, MIRROR_MAX_TEXT, MIRROR_MAX_FRAMES, CHAT_ID_RE, MAX_MESSAGE_LENGTH, MAX_BRIDGE_CLIENTS, KEEPALIVE_MS2, BACKPRESSURE_EVICT_THRESHOLD2, BridgeHub;
44439
+ var import_crypto26, ASK_MAX_QUESTIONS, ASK_MAX_OPTIONS, ASK_MAX_HEADER, ASK_MAX_QUESTION, ASK_MAX_LABEL, ASK_MAX_DESCRIPTION, ASK_MAX_OTHER, MIRROR_ROLES, MIRROR_MAX_TEXT, MIRROR_MAX_FRAMES, CHAT_ID_RE, MAX_MESSAGE_LENGTH, MAX_BRIDGE_CLIENTS, KEEPALIVE_MS2, BACKPRESSURE_EVICT_THRESHOLD2, BridgeHub;
44363
44440
  var init_api_bridge = __esm({
44364
44441
  "packages/relay/src/dashboard/api-bridge.ts"() {
44365
44442
  "use strict";
44366
44443
  import_crypto26 = require("crypto");
44367
44444
  init_api_mirror_events();
44445
+ ASK_MAX_QUESTIONS = 4;
44446
+ ASK_MAX_OPTIONS = 8;
44447
+ ASK_MAX_HEADER = 120;
44448
+ ASK_MAX_QUESTION = 600;
44449
+ ASK_MAX_LABEL = 120;
44450
+ ASK_MAX_DESCRIPTION = 240;
44451
+ ASK_MAX_OTHER = 400;
44368
44452
  MIRROR_ROLES = ["user", "assistant", "activity"];
44369
44453
  MIRROR_MAX_TEXT = 2 * 1024;
44370
44454
  MIRROR_MAX_FRAMES = 64;
@@ -44381,6 +44465,13 @@ var init_api_bridge = __esm({
44381
44465
  // eviction can clearInterval too — the req 'close' handler may never fire on
44382
44466
  // an abrupt res.destroy(), which would otherwise leak the timer.
44383
44467
  keepalives = /* @__PURE__ */ new Map();
44468
+ // Per-client subscribed chat_id (null = no ?chat_id supplied = live-only legacy
44469
+ // consumer). Set when the client is added to this.clients in handleStream.
44470
+ // Server-side filtering in broadcast() uses this: each frame is only delivered
44471
+ // to the client(s) whose subscribed chat_id matches frame.chat_id. A null-
44472
+ // subscribed client receives nothing — the old "shared broadcast to all"
44473
+ // behaviour is intentionally replaced with per-client routing.
44474
+ clientChatId = /* @__PURE__ */ new Map();
44384
44475
  // chat_ids we have seen on an INBOUND POST. Outbound replies bind against this
44385
44476
  // set (consensus f5): the live session can only address a stream the dashboard
44386
44477
  // actually opened, never an arbitrary/forged id. Bounded + TTL'd so a long
@@ -44389,6 +44480,16 @@ var init_api_bridge = __esm({
44389
44480
  static MAX_KNOWN_CHAT_IDS = 64;
44390
44481
  static CHAT_ID_TTL_MS = 2 * 60 * 60 * 1e3;
44391
44482
  // 2h, mirror chat-session-store
44483
+ // ── Outstanding-question registry (gossip_ask round-trip) ───────────────────
44484
+ // qid → {chatId, questions, at}. An inbound answer is validated against the
44485
+ // EXACT set that was asked: the questionId must exist, each selected label must
44486
+ // be one of that question's option labels, `other` only when allowOther. The
44487
+ // registry is bounded + TTL'd (fail-closed eviction) so a long-running session
44488
+ // can't grow it unbounded and a stale qid can never be answered.
44489
+ outstandingQuestions = /* @__PURE__ */ new Map();
44490
+ static MAX_OUTSTANDING_QUESTIONS = 64;
44491
+ static QUESTION_TTL_MS = 2 * 60 * 60 * 1e3;
44492
+ // 2h, mirror knownChatIds
44392
44493
  // ── Mirror state (spec v2 §3) ──────────────────────────────────────────────
44393
44494
  // chat_ids eligible to RECEIVE mirror frames. SEPARATE from knownChatIds
44394
44495
  // (sonnet:f7/f12). P1#1: this set is seeded ONLY from the relay's validated
@@ -44398,19 +44499,6 @@ var init_api_bridge = __esm({
44398
44499
  // emitReply STILL gates on knownChatIds, so mirroring never widens the
44399
44500
  // outbound reply boundary (api-bridge.ts emitReply isKnownChatId check).
44400
44501
  mirrorChatIds = /* @__PURE__ */ new Map();
44401
- // The most-recently-registered active mirror chat_id (set in
44402
- // registerMirrorChatId, cleared/recomputed when that id is TTL-evicted).
44403
- // Used by emitMirror to route unresolved terminal frames to a live observer
44404
- // instead of the provisional buffer when one exists (fix: mirror frames
44405
- // previously fell into _provisional and were dropped by the dashboard client
44406
- // because bindSession never ran — the browser cannot send a session_id it
44407
- // doesn't know). SECURITY: only ever a chat_id already in mirrorChatIds.
44408
- // KNOWN LIMITATION (consensus 96350953-8ce94428): single-pointer, so with
44409
- // multiple concurrent dashboard tabs only the most-recently-registered one
44410
- // receives unresolved terminal frames; earlier tabs see no live activity until
44411
- // they send again. Accepted tradeoff — a strict improvement over the prior
44412
- // behaviour where the frames went to _provisional and EVERY tab saw nothing.
44413
- latestMirrorChatId = null;
44414
44502
  // Session→chat_id map (P1#5). The relay has no native "active session
44415
44503
  // chat_id" notion — a no-chat_id inbound POST mints a fresh UUID. We learn the
44416
44504
  // mapping from the dashboard turn: a turn carries BOTH a chat_id (validated)
@@ -44523,6 +44611,165 @@ var init_api_bridge = __esm({
44523
44611
  this.broadcast({ type: "ack", chat_id: chatId, ts: (/* @__PURE__ */ new Date()).toISOString() });
44524
44612
  return true;
44525
44613
  }
44614
+ /**
44615
+ * OUTBOUND: the MCP `gossip_ask` tool asks the dashboard a selection question.
44616
+ * Gates on knownChatIds EXACTLY like emitReply — a question may only target a
44617
+ * stream the dashboard actually opened, never an arbitrary/forged id. On
44618
+ * success the validated question set is registered under `qid` (so a later
44619
+ * inbound answer can be validated against what was asked) and a `question`
44620
+ * frame is fanned out over SSE.
44621
+ *
44622
+ * LIVE-ONLY: like reply/ack, a `question` frame has NO per-chat_id ring — an
44623
+ * unanswered question is LOST on a dashboard reload (acceptable v1). The
44624
+ * registry entry survives a reload (so an answer typed after reconnect would
44625
+ * still validate), but the rendered card does not.
44626
+ *
44627
+ * Returns false (no registration, no broadcast) when the chat_id is
44628
+ * malformed/unbound or no SSE client is connected, so the MCP tool can no-op
44629
+ * honestly — same posture as emitReply.
44630
+ */
44631
+ emitQuestion(rawChatId, qid, questions) {
44632
+ const chatId = validateChatId(rawChatId);
44633
+ if (chatId === null) return false;
44634
+ if (!this.isKnownChatId(chatId)) return false;
44635
+ if (this.clients.size === 0) return false;
44636
+ if (typeof qid !== "string" || qid.length === 0) return false;
44637
+ if (!Array.isArray(questions) || questions.length === 0) return false;
44638
+ this.registerOutstandingQuestion(qid, chatId, questions);
44639
+ this.broadcast({ type: "question", chat_id: chatId, qid, questions, ts: (/* @__PURE__ */ new Date()).toISOString() });
44640
+ return true;
44641
+ }
44642
+ /**
44643
+ * INBOUND ANSWER (UNTRUSTED — this is the security boundary). The dashboard
44644
+ * POSTs `{chat_id, answer:{qid, responses:[{questionId, selected[], other?}]}}`.
44645
+ * VALIDATES fail-closed against the registered question set:
44646
+ * - chat_id is known (bound to an opened stream);
44647
+ * - qid is an outstanding question FOR THAT chat_id (cross-chat reuse → 400);
44648
+ * - each questionId exists in the asked set, with no duplicates / no missing;
44649
+ * - each `selected` label is one of THAT question's option labels (an unknown
44650
+ * label → 400; a single-select question accepts at most one label);
44651
+ * - `other` is only allowed when that question had allowOther, and is trimmed
44652
+ * + length-capped.
44653
+ * On VALID: a concise channel turn is formatted and delivered to the live CC
44654
+ * session via the SAME sink path inbound chat messages use (so it arrives as a
44655
+ * normal turn), then the qid is deleted (single-use). Returns a route-shaped
44656
+ * {status,payload}: 202 on accept, 400 on any validation failure, 503 when no
44657
+ * sink is wired.
44658
+ */
44659
+ handleAnswer(body) {
44660
+ const b = body ?? {};
44661
+ const chatId = validateChatId(b.chat_id);
44662
+ if (chatId === null) {
44663
+ return { status: 400, payload: { error: "invalid chat_id" } };
44664
+ }
44665
+ if (!this.isKnownChatId(chatId)) {
44666
+ return { status: 400, payload: { error: "unknown chat_id (not an open dashboard stream)" } };
44667
+ }
44668
+ const answer = b.answer;
44669
+ if (!answer || typeof answer !== "object") {
44670
+ return { status: 400, payload: { error: "answer must be an object" } };
44671
+ }
44672
+ const qid = answer.qid;
44673
+ if (typeof qid !== "string" || qid.length === 0) {
44674
+ return { status: 400, payload: { error: "answer.qid must be a non-empty string" } };
44675
+ }
44676
+ this.evictOutstandingQuestions(Date.now());
44677
+ const outstanding = this.outstandingQuestions.get(qid);
44678
+ if (!outstanding) {
44679
+ return { status: 400, payload: { error: "unknown or expired qid" } };
44680
+ }
44681
+ if (outstanding.chatId !== chatId) {
44682
+ return { status: 400, payload: { error: "qid does not belong to this chat_id" } };
44683
+ }
44684
+ const responses = answer.responses;
44685
+ if (!Array.isArray(responses) || responses.length === 0) {
44686
+ return { status: 400, payload: { error: "answer.responses must be a non-empty array" } };
44687
+ }
44688
+ if (responses.length > outstanding.questions.length) {
44689
+ return { status: 400, payload: { error: "too many responses" } };
44690
+ }
44691
+ const seen = /* @__PURE__ */ new Set();
44692
+ const parts = [];
44693
+ for (const r of responses) {
44694
+ if (!r || typeof r !== "object") {
44695
+ return { status: 400, payload: { error: "each response must be an object" } };
44696
+ }
44697
+ const rr = r;
44698
+ if (typeof rr.questionId !== "string") {
44699
+ return { status: 400, payload: { error: "response.questionId must be a string" } };
44700
+ }
44701
+ const questionId = rr.questionId;
44702
+ if (seen.has(questionId)) {
44703
+ return { status: 400, payload: { error: "duplicate questionId in responses" } };
44704
+ }
44705
+ const q = outstanding.questions.find((x) => x.questionId === questionId);
44706
+ if (!q) {
44707
+ return { status: 400, payload: { error: `unknown questionId "${questionId}"` } };
44708
+ }
44709
+ seen.add(questionId);
44710
+ if (!Array.isArray(rr.selected)) {
44711
+ return { status: 400, payload: { error: "response.selected must be an array" } };
44712
+ }
44713
+ const selected = rr.selected;
44714
+ if (!q.multiSelect && selected.length > 1) {
44715
+ return { status: 400, payload: { error: `question "${questionId}" is single-select` } };
44716
+ }
44717
+ if (selected.length > q.options.length) {
44718
+ return { status: 400, payload: { error: "too many selected labels" } };
44719
+ }
44720
+ const labels = new Set(q.options.map((o) => o.label));
44721
+ const chosen = [];
44722
+ const seenLabels = /* @__PURE__ */ new Set();
44723
+ for (const s of selected) {
44724
+ if (typeof s !== "string" || !labels.has(s)) {
44725
+ return { status: 400, payload: { error: `unknown option label for question "${questionId}"` } };
44726
+ }
44727
+ if (seenLabels.has(s)) {
44728
+ return { status: 400, payload: { error: "duplicate selected label" } };
44729
+ }
44730
+ seenLabels.add(s);
44731
+ chosen.push(s);
44732
+ }
44733
+ let otherText = null;
44734
+ if (rr.other !== void 0 && rr.other !== null) {
44735
+ if (!q.allowOther) {
44736
+ return { status: 400, payload: { error: `question "${questionId}" does not allow other` } };
44737
+ }
44738
+ if (typeof rr.other !== "string") {
44739
+ return { status: 400, payload: { error: "response.other must be a string" } };
44740
+ }
44741
+ const sanitized = rr.other.replace(/[\x00-\x1F\x7F]/g, " ").replace(/\s+/g, " ").replace(/\[answer/gi, "(answer").trim();
44742
+ if (sanitized.length > ASK_MAX_OTHER) {
44743
+ return { status: 400, payload: { error: "other text too long" } };
44744
+ }
44745
+ if (sanitized.length > 0) {
44746
+ otherText = sanitized.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
44747
+ }
44748
+ }
44749
+ if (chosen.length === 0 && otherText === null) {
44750
+ return { status: 400, payload: { error: `question "${questionId}" has no selection` } };
44751
+ }
44752
+ const segs = [];
44753
+ if (chosen.length > 0) segs.push(chosen.join(", "));
44754
+ if (otherText !== null) segs.push(`other: "${otherText}"`);
44755
+ parts.push(`${q.header}: ${segs.join(" \xB7 ")}`);
44756
+ }
44757
+ if (!this.sink) {
44758
+ return { status: 503, payload: { error: "No live Claude Code bridge session is active" } };
44759
+ }
44760
+ const turn = `[answer qid=${qid}] ${parts.join(" \xB7 ")}`;
44761
+ let delivered;
44762
+ try {
44763
+ delivered = this.sink(chatId, turn);
44764
+ } catch {
44765
+ delivered = false;
44766
+ }
44767
+ if (!delivered) {
44768
+ return { status: 503, payload: { error: "Bridge sink rejected the answer" } };
44769
+ }
44770
+ this.outstandingQuestions.delete(qid);
44771
+ return { status: 202, payload: { ok: true, qid, chat_id: chatId } };
44772
+ }
44526
44773
  /**
44527
44774
  * MIRROR ingress (spec v2 §3). Validates the (already body-capped + role-
44528
44775
  * checked-at-route) batch a SECOND time at the trust boundary, resolves the
@@ -44594,10 +44841,25 @@ var init_api_bridge = __esm({
44594
44841
  chatId = resolved;
44595
44842
  } else if (this.dropUnresolvedMirror) {
44596
44843
  return { status: 202, payload: { ok: true, chat_id: null, dropped: validated.length } };
44597
- } else if (this.latestMirrorChatId !== null && this.isMirrorChatId(this.latestMirrorChatId)) {
44598
- chatId = this.latestMirrorChatId;
44599
44844
  } else {
44600
- chatId = _BridgeHub.PROVISIONAL_CHAT_ID;
44845
+ this.evictMirrorChatIds(Date.now());
44846
+ const liveIds = Array.from(this.mirrorChatIds.keys());
44847
+ if (liveIds.length > 0) {
44848
+ let totalFrames = 0;
44849
+ for (const id of liveIds) {
44850
+ for (const { role, text } of validated) {
44851
+ const frame = this.mirror.push(id, role, text);
44852
+ this.broadcast(frame);
44853
+ totalFrames++;
44854
+ }
44855
+ }
44856
+ return {
44857
+ status: 202,
44858
+ payload: { ok: true, chat_id: null, fanout: liveIds.length, frames: totalFrames }
44859
+ };
44860
+ } else {
44861
+ chatId = _BridgeHub.PROVISIONAL_CHAT_ID;
44862
+ }
44601
44863
  }
44602
44864
  }
44603
44865
  const stamped = [];
@@ -44667,6 +44929,7 @@ var init_api_bridge = __esm({
44667
44929
  }
44668
44930
  this.backpressure.set(res, 0);
44669
44931
  this.clients.add(res);
44932
+ this.clientChatId.set(res, chatId);
44670
44933
  const keepalive = setInterval(() => {
44671
44934
  if (chatId !== null) this.touchMirrorChatId(chatId);
44672
44935
  try {
@@ -44680,6 +44943,7 @@ var init_api_bridge = __esm({
44680
44943
  req.on("close", () => {
44681
44944
  this.clearKeepalive(res);
44682
44945
  this.clients.delete(res);
44946
+ this.clientChatId.delete(res);
44683
44947
  });
44684
44948
  }
44685
44949
  /**
@@ -44718,7 +44982,16 @@ var init_api_bridge = __esm({
44718
44982
  return false;
44719
44983
  }
44720
44984
  }
44721
- /** Fan out one frame to every connected SSE client, with backpressure eviction. */
44985
+ /**
44986
+ * Fan out one frame to matching connected SSE clients (server-side filter),
44987
+ * with backpressure eviction.
44988
+ *
44989
+ * Each frame carries a chat_id. Only the client(s) subscribed to that
44990
+ * chat_id receive the write — clients subscribed to a different chat_id or
44991
+ * with a null subscription (legacy live-only consumer) are skipped. This
44992
+ * eliminates the prior behaviour of broadcasting EVERY frame to ALL clients
44993
+ * (the bandwidth/confidentiality gap noted in the multi-tab fix spec).
44994
+ */
44722
44995
  broadcast(frame) {
44723
44996
  const idLine = "id" in frame && typeof frame.id === "number" ? `id: ${frame.id}
44724
44997
  ` : "";
@@ -44726,6 +44999,8 @@ var init_api_bridge = __esm({
44726
44999
 
44727
45000
  `;
44728
45001
  for (const res of this.clients) {
45002
+ const subscribedChatId = this.clientChatId.get(res) ?? null;
45003
+ if (subscribedChatId === null || subscribedChatId !== frame.chat_id) continue;
44729
45004
  try {
44730
45005
  const ok = res.write(data);
44731
45006
  if (ok) {
@@ -44737,11 +45012,13 @@ var init_api_bridge = __esm({
44737
45012
  this.clearKeepalive(res);
44738
45013
  res.destroy();
44739
45014
  this.clients.delete(res);
45015
+ this.clientChatId.delete(res);
44740
45016
  }
44741
45017
  }
44742
45018
  } catch {
44743
45019
  this.clearKeepalive(res);
44744
45020
  this.clients.delete(res);
45021
+ this.clientChatId.delete(res);
44745
45022
  }
44746
45023
  }
44747
45024
  }
@@ -44790,6 +45067,34 @@ var init_api_bridge = __esm({
44790
45067
  if (now - v > _BridgeHub.CHAT_ID_TTL_MS) this.knownChatIds.delete(k);
44791
45068
  }
44792
45069
  }
45070
+ // ── Outstanding-question registry helpers (gossip_ask) ──────────────────────
45071
+ /**
45072
+ * Register a validated question set under `qid`. Bounded + TTL'd: a stale
45073
+ * entry is reaped before insert, and at capacity the oldest entry is dropped
45074
+ * to make room (fail-closed — an unanswered old question is forgotten rather
45075
+ * than letting the map grow unbounded).
45076
+ */
45077
+ registerOutstandingQuestion(qid, chatId, questions) {
45078
+ const now = Date.now();
45079
+ this.evictOutstandingQuestions(now);
45080
+ if (!this.outstandingQuestions.has(qid) && this.outstandingQuestions.size >= _BridgeHub.MAX_OUTSTANDING_QUESTIONS) {
45081
+ let oldestKey = null;
45082
+ let oldest = Infinity;
45083
+ for (const [k, v] of this.outstandingQuestions) {
45084
+ if (v.at < oldest) {
45085
+ oldest = v.at;
45086
+ oldestKey = k;
45087
+ }
45088
+ }
45089
+ if (oldestKey !== null) this.outstandingQuestions.delete(oldestKey);
45090
+ }
45091
+ this.outstandingQuestions.set(qid, { chatId, questions, at: now });
45092
+ }
45093
+ evictOutstandingQuestions(now) {
45094
+ for (const [k, v] of this.outstandingQuestions) {
45095
+ if (now - v.at > _BridgeHub.QUESTION_TTL_MS) this.outstandingQuestions.delete(k);
45096
+ }
45097
+ }
44793
45098
  // ── Mirror chat-id registry (SEPARATE from knownChatIds — P1#1/sonnet:f7) ──
44794
45099
  // Same bounded + TTL eviction discipline as knownChatIds, but a DISTINCT map:
44795
45100
  // mirrorChatIds gates emitMirror; knownChatIds gates emitReply. Keeping them
@@ -44816,7 +45121,6 @@ var init_api_bridge = __esm({
44816
45121
  if (oldestKey !== null) this.mirrorChatIds.delete(oldestKey);
44817
45122
  }
44818
45123
  this.mirrorChatIds.set(chatId, now);
44819
- this.latestMirrorChatId = chatId;
44820
45124
  if (isNew) this.backfillProvisional(chatId, now);
44821
45125
  }
44822
45126
  /**
@@ -44837,23 +45141,10 @@ var init_api_bridge = __esm({
44837
45141
  return this.mirrorChatIds.has(chatId);
44838
45142
  }
44839
45143
  evictMirrorChatIds(now) {
44840
- let evictedLatest = false;
44841
45144
  for (const [k, v] of this.mirrorChatIds) {
44842
45145
  if (now - v > _BridgeHub.CHAT_ID_TTL_MS) {
44843
45146
  this.mirrorChatIds.delete(k);
44844
- if (k === this.latestMirrorChatId) evictedLatest = true;
44845
- }
44846
- }
44847
- if (evictedLatest) {
44848
- let newestKey = null;
44849
- let newest = -Infinity;
44850
- for (const [k, v] of this.mirrorChatIds) {
44851
- if (v > newest) {
44852
- newest = v;
44853
- newestKey = k;
44854
- }
44855
45147
  }
44856
- this.latestMirrorChatId = newestKey;
44857
45148
  }
44858
45149
  }
44859
45150
  /**
@@ -45213,6 +45504,16 @@ var init_routes = __esm({
45213
45504
  emitBridgeReply(chatId, text) {
45214
45505
  return this.bridge.emitReply(chatId, text);
45215
45506
  }
45507
+ /**
45508
+ * Ask the dashboard a structured selection question (MCP `gossip_ask` tool).
45509
+ * The hub gates chat_id on knownChatIds exactly like emitReply, registers the
45510
+ * validated question set under `qid`, and fans out a `question` frame. Returns
45511
+ * false when the id was unbound/malformed or no SSE client is connected so the
45512
+ * caller can no-op honestly.
45513
+ */
45514
+ emitBridgeQuestion(chatId, qid, questions) {
45515
+ return this.bridge.emitQuestion(chatId, qid, questions);
45516
+ }
45216
45517
  /** Update live context (call when agents connect/disconnect) */
45217
45518
  updateContext(ctx2) {
45218
45519
  if (ctx2.agentConfigs !== void 0) this.ctx.agentConfigs = ctx2.agentConfigs;
@@ -45609,7 +45910,8 @@ var init_routes = __esm({
45609
45910
  this.json(res, 400, { error: "Invalid JSON body" });
45610
45911
  return true;
45611
45912
  }
45612
- const { status, payload } = this.bridge.handlePost(body);
45913
+ const hasAnswer = body !== null && typeof body === "object" && "answer" in body;
45914
+ const { status, payload } = hasAnswer ? this.bridge.handleAnswer(body) : this.bridge.handlePost(body);
45613
45915
  this.json(res, status, payload);
45614
45916
  return true;
45615
45917
  }
@@ -45670,7 +45972,10 @@ var init_routes = __esm({
45670
45972
  return true;
45671
45973
  }
45672
45974
  const html = (0, import_fs70.readFileSync)(htmlPath, "utf-8");
45673
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
45975
+ res.writeHead(200, {
45976
+ "Content-Type": "text/html; charset=utf-8",
45977
+ "Cache-Control": "no-cache"
45978
+ });
45674
45979
  res.end(html);
45675
45980
  return true;
45676
45981
  }
@@ -46346,6 +46651,19 @@ var init_server = __esm({
46346
46651
  emitBridgeReply(chatId, text) {
46347
46652
  return this.dashboardRouter?.emitBridgeReply(chatId, text) ?? false;
46348
46653
  }
46654
+ /**
46655
+ * Ask the dashboard a structured selection question (MCP `gossip_ask` tool).
46656
+ * Mirrors emitBridgeReply: chat_id is re-validated + bound to a stream the
46657
+ * dashboard actually opened (the same knownChatIds gate as emitReply), the
46658
+ * validated question set is registered under `qid` so a later inbound answer
46659
+ * can be validated against what was asked, and a `question` frame fans out
46660
+ * over SSE. Returns false (no registration, no broadcast) when the dashboard
46661
+ * is disabled, the chat_id is unbound/malformed, or no SSE client is connected
46662
+ * — so the caller can no-op honestly rather than claim a delivery.
46663
+ */
46664
+ emitBridgeQuestion(chatId, qid, questions) {
46665
+ return this.dashboardRouter?.emitBridgeQuestion(chatId, qid, questions) ?? false;
46666
+ }
46349
46667
  };
46350
46668
  }
46351
46669
  });
@@ -46381,7 +46699,8 @@ __export(src_exports4, {
46381
46699
  emitDashboardEvent: () => emitDashboardEvent,
46382
46700
  hasMemoryQuery: () => hasMemoryQuery,
46383
46701
  recordMemoryQueryAttribution: () => recordMemoryQueryAttribution,
46384
- sweepExpiredAgents: () => sweepExpiredAgents
46702
+ sweepExpiredAgents: () => sweepExpiredAgents,
46703
+ validateAskQuestions: () => validateAskQuestions
46385
46704
  });
46386
46705
  var init_src5 = __esm({
46387
46706
  "packages/relay/src/index.ts"() {
@@ -80120,6 +80439,56 @@ Note: write-mode classification unavailable on this native-only install \u2014 a
80120
80439
  return { content: [{ type: "text", text: `Sent reply to dashboard (chat_id ${chat_id}).` }] };
80121
80440
  }
80122
80441
  );
80442
+ server.tool(
80443
+ "gossip_ask",
80444
+ `Dashboard bridge ONLY: ask the dashboard chat (identified by chat_id) a structured selection question. The dashboard renders radios (single-select) or checkboxes (multiSelect) plus an optional free-text "Other"; the user's answer comes back to you as a NORMAL channel turn (a later message prefixed "[answer qid=\u2026]"). This is the dashboard-answerable parallel to the terminal-only AskUserQuestion \u2014 use it when a <channel source="gossipcat" chat_id="..."> dashboard chat is active. NON-BLOCKING: returns at once; do NOT wait for the answer in this call. Pass the chat_id verbatim. Returns an error (no side effect) when no matching dashboard bridge stream is open.`,
80445
+ {
80446
+ chat_id: external_exports.string().describe('The chat_id verbatim from the <channel ... chat_id="..."> dashboard message.'),
80447
+ questions: external_exports.array(
80448
+ external_exports.object({
80449
+ header: external_exports.string().describe('Short label for the question (e.g. "Approach").'),
80450
+ question: external_exports.string().describe("The prompt text shown above the options."),
80451
+ multiSelect: external_exports.boolean().optional().describe("true \u2192 checkboxes (multiple allowed); false/omitted \u2192 radios (one)."),
80452
+ options: external_exports.array(
80453
+ external_exports.object({
80454
+ label: external_exports.string().describe("The option label the user selects; must be unique within the question."),
80455
+ description: external_exports.string().optional().describe("Optional one-line clarification shown under the label.")
80456
+ })
80457
+ ).describe("Selectable options (\u22648)."),
80458
+ allowOther: external_exports.boolean().optional().describe('When true, the dashboard shows a free-text "Other" input.')
80459
+ })
80460
+ ).describe("1\u20134 questions, each with \u22648 options.")
80461
+ },
80462
+ async ({ chat_id, questions }) => {
80463
+ if (typeof chat_id !== "string" || chat_id.trim().length === 0) {
80464
+ return { content: [{ type: "text", text: "gossip_ask error: chat_id is required." }], isError: true };
80465
+ }
80466
+ if (!ctx.relay) {
80467
+ return { content: [{ type: "text", text: "gossip_ask error: no dashboard bridge is active (relay not started)." }], isError: true };
80468
+ }
80469
+ const { validateAskQuestions: validateAskQuestions2 } = await Promise.resolve().then(() => (init_src5(), src_exports4));
80470
+ const validated = validateAskQuestions2(questions);
80471
+ if ("error" in validated) {
80472
+ return { content: [{ type: "text", text: `gossip_ask error: ${validated.error}.` }], isError: true };
80473
+ }
80474
+ const qid = (0, import_crypto37.randomUUID)();
80475
+ let emitted = false;
80476
+ try {
80477
+ emitted = ctx.relay.emitBridgeQuestion(chat_id, qid, validated.questions) === true;
80478
+ } catch {
80479
+ emitted = false;
80480
+ }
80481
+ if (!emitted) {
80482
+ return {
80483
+ content: [{ type: "text", text: `gossip_ask error: no open dashboard bridge stream for chat_id "${chat_id}". This tool only sends to the dashboard channel.` }],
80484
+ isError: true
80485
+ };
80486
+ }
80487
+ return {
80488
+ content: [{ type: "text", text: `Asked the dashboard (qid=${qid}); the user's answer will arrive as a channel turn prefixed "[answer qid=${qid}]". Do not wait for it here.` }]
80489
+ };
80490
+ }
80491
+ );
80123
80492
  server.tool(
80124
80493
  "gossip_status",
80125
80494
  "Check Gossip Mesh system status, host environment, available agents, dashboard URL/key, and agent list with provider/model/skills. Pass slim:true to skip the handbook inline (~21KB savings) on reconnect refreshes.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gossipcat",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Multi-agent orchestration for Claude Code — parallel review, consensus, adaptive dispatch",
5
5
  "mcpName": "io.github.ataberk-xyz/gossipcat",
6
6
  "repository": {
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "overrides": {
61
61
  "ip-address": "^10.1.1",
62
- "hono": ">=4.12.16 <5",
62
+ "hono": ">=4.12.25 <5",
63
63
  "fast-uri": ">=3.1.2",
64
64
  "postcss": "^8.5.10",
65
65
  "js-yaml": "^4.2.0"