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.
- package/dist-dashboard/assets/index-BEKs0u32.js +81 -0
- package/dist-dashboard/assets/index-CxBsFpD6.css +1 -0
- package/dist-dashboard/index.html +2 -2
- package/dist-mcp/mcp-server.js +404 -35
- package/package.json +2 -2
- package/dist-dashboard/assets/index-BRxsq5sV.js +0 -81
- package/dist-dashboard/assets/index-CSLO2_PP.css +0 -1
package/dist-mcp/mcp-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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, {
|
|
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.
|
|
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.
|
|
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"
|