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.
- 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 +407 -7
- 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
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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)
|
|
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
|
|
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, {
|
|
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
|
+
"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"
|