gossipcat 0.6.3 → 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
@@ -44510,6 +44611,165 @@ var init_api_bridge = __esm({
44510
44611
  this.broadcast({ type: "ack", chat_id: chatId, ts: (/* @__PURE__ */ new Date()).toISOString() });
44511
44612
  return true;
44512
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
+ }
44513
44773
  /**
44514
44774
  * MIRROR ingress (spec v2 §3). Validates the (already body-capped + role-
44515
44775
  * checked-at-route) batch a SECOND time at the trust boundary, resolves the
@@ -44582,7 +44842,24 @@ var init_api_bridge = __esm({
44582
44842
  } else if (this.dropUnresolvedMirror) {
44583
44843
  return { status: 202, payload: { ok: true, chat_id: null, dropped: validated.length } };
44584
44844
  } else {
44585
- 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
+ }
44586
44863
  }
44587
44864
  }
44588
44865
  const stamped = [];
@@ -44652,6 +44929,7 @@ var init_api_bridge = __esm({
44652
44929
  }
44653
44930
  this.backpressure.set(res, 0);
44654
44931
  this.clients.add(res);
44932
+ this.clientChatId.set(res, chatId);
44655
44933
  const keepalive = setInterval(() => {
44656
44934
  if (chatId !== null) this.touchMirrorChatId(chatId);
44657
44935
  try {
@@ -44665,6 +44943,7 @@ var init_api_bridge = __esm({
44665
44943
  req.on("close", () => {
44666
44944
  this.clearKeepalive(res);
44667
44945
  this.clients.delete(res);
44946
+ this.clientChatId.delete(res);
44668
44947
  });
44669
44948
  }
44670
44949
  /**
@@ -44703,7 +44982,16 @@ var init_api_bridge = __esm({
44703
44982
  return false;
44704
44983
  }
44705
44984
  }
44706
- /** 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
+ */
44707
44995
  broadcast(frame) {
44708
44996
  const idLine = "id" in frame && typeof frame.id === "number" ? `id: ${frame.id}
44709
44997
  ` : "";
@@ -44711,6 +44999,8 @@ var init_api_bridge = __esm({
44711
44999
 
44712
45000
  `;
44713
45001
  for (const res of this.clients) {
45002
+ const subscribedChatId = this.clientChatId.get(res) ?? null;
45003
+ if (subscribedChatId === null || subscribedChatId !== frame.chat_id) continue;
44714
45004
  try {
44715
45005
  const ok = res.write(data);
44716
45006
  if (ok) {
@@ -44722,11 +45012,13 @@ var init_api_bridge = __esm({
44722
45012
  this.clearKeepalive(res);
44723
45013
  res.destroy();
44724
45014
  this.clients.delete(res);
45015
+ this.clientChatId.delete(res);
44725
45016
  }
44726
45017
  }
44727
45018
  } catch {
44728
45019
  this.clearKeepalive(res);
44729
45020
  this.clients.delete(res);
45021
+ this.clientChatId.delete(res);
44730
45022
  }
44731
45023
  }
44732
45024
  }
@@ -44775,6 +45067,34 @@ var init_api_bridge = __esm({
44775
45067
  if (now - v > _BridgeHub.CHAT_ID_TTL_MS) this.knownChatIds.delete(k);
44776
45068
  }
44777
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
+ }
44778
45098
  // ── Mirror chat-id registry (SEPARATE from knownChatIds — P1#1/sonnet:f7) ──
44779
45099
  // Same bounded + TTL eviction discipline as knownChatIds, but a DISTINCT map:
44780
45100
  // mirrorChatIds gates emitMirror; knownChatIds gates emitReply. Keeping them
@@ -44822,7 +45142,9 @@ var init_api_bridge = __esm({
44822
45142
  }
44823
45143
  evictMirrorChatIds(now) {
44824
45144
  for (const [k, v] of this.mirrorChatIds) {
44825
- if (now - v > _BridgeHub.CHAT_ID_TTL_MS) this.mirrorChatIds.delete(k);
45145
+ if (now - v > _BridgeHub.CHAT_ID_TTL_MS) {
45146
+ this.mirrorChatIds.delete(k);
45147
+ }
44826
45148
  }
44827
45149
  }
44828
45150
  /**
@@ -45182,6 +45504,16 @@ var init_routes = __esm({
45182
45504
  emitBridgeReply(chatId, text) {
45183
45505
  return this.bridge.emitReply(chatId, text);
45184
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
+ }
45185
45517
  /** Update live context (call when agents connect/disconnect) */
45186
45518
  updateContext(ctx2) {
45187
45519
  if (ctx2.agentConfigs !== void 0) this.ctx.agentConfigs = ctx2.agentConfigs;
@@ -45578,7 +45910,8 @@ var init_routes = __esm({
45578
45910
  this.json(res, 400, { error: "Invalid JSON body" });
45579
45911
  return true;
45580
45912
  }
45581
- 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);
45582
45915
  this.json(res, status, payload);
45583
45916
  return true;
45584
45917
  }
@@ -45639,7 +45972,10 @@ var init_routes = __esm({
45639
45972
  return true;
45640
45973
  }
45641
45974
  const html = (0, import_fs70.readFileSync)(htmlPath, "utf-8");
45642
- 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
+ });
45643
45979
  res.end(html);
45644
45980
  return true;
45645
45981
  }
@@ -46315,6 +46651,19 @@ var init_server = __esm({
46315
46651
  emitBridgeReply(chatId, text) {
46316
46652
  return this.dashboardRouter?.emitBridgeReply(chatId, text) ?? false;
46317
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
+ }
46318
46667
  };
46319
46668
  }
46320
46669
  });
@@ -46350,7 +46699,8 @@ __export(src_exports4, {
46350
46699
  emitDashboardEvent: () => emitDashboardEvent,
46351
46700
  hasMemoryQuery: () => hasMemoryQuery,
46352
46701
  recordMemoryQueryAttribution: () => recordMemoryQueryAttribution,
46353
- sweepExpiredAgents: () => sweepExpiredAgents
46702
+ sweepExpiredAgents: () => sweepExpiredAgents,
46703
+ validateAskQuestions: () => validateAskQuestions
46354
46704
  });
46355
46705
  var init_src5 = __esm({
46356
46706
  "packages/relay/src/index.ts"() {
@@ -80089,6 +80439,56 @@ Note: write-mode classification unavailable on this native-only install \u2014 a
80089
80439
  return { content: [{ type: "text", text: `Sent reply to dashboard (chat_id ${chat_id}).` }] };
80090
80440
  }
80091
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
+ );
80092
80492
  server.tool(
80093
80493
  "gossip_status",
80094
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.3",
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"