@edspencer/paddock 0.66.0 → 0.66.1
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/package.json +1 -1
- package/packages/server/dist/app.d.ts +8 -0
- package/packages/server/dist/app.js +4 -1
- package/packages/server/dist/queued-message.d.ts +57 -0
- package/packages/server/dist/queued-message.js +135 -1
- package/packages/server/dist/session-hub.d.ts +69 -1
- package/packages/server/dist/session-hub.js +73 -1
- package/packages/server/dist/turn-interlock.js +8 -0
- package/packages/server/dist/ws-protocol.d.ts +76 -5
- package/packages/server/dist/ws-protocol.js +2 -0
- package/packages/server/dist/ws.d.ts +0 -52
- package/packages/server/dist/ws.js +472 -119
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-DBrwtZjx.js → InstanceConfigPage-1BzMnJPo.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-CRQP3Cfe.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-Dd3h5V8h.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BRk6l152.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BaUE8zaF.js} +1 -1
- package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DqMSW0As.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-CNkQD1jU.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-BCMiOmt6.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-CU2soCji.js} +1 -1
- package/packages/web/dist/assets/channel-Ynyq4GPh.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-nKhlJNTt.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-YRTZj9zI.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-HO60VE45.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-WZJH-boe.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-PvDC7gYB.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-BNRMTplH.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-2Q2ei8TR.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-CG9VV7M6.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +2 -0
- package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DwSEw_oE.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BlMrBI7K.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-tNoaleNU.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-DRVCTpHL.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-BdS-96WV.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-Dv1yxz3s.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-BNXgifuS.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-wlO0bxwP.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-ChoppAEq.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-C__aYyei.js} +1 -1
- package/packages/web/dist/assets/{format-B_kAvAW3.js → format-u-YtEZFd.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-CjRe8foW.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BGDQjiWq.js} +1 -1
- package/packages/web/dist/assets/index-Cc5bQxZk.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BP-JaX-8.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-YnsAJBvC.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-BM6JWTV9.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-gR2O0IaL.js} +1 -1
- package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-Dolq_eLR.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-D1T_GQW7.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-C3bzIyLZ.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-BrZ3ANGf.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB-BvLCHAEi.js} +1 -1
- package/packages/web/dist/assets/queued-DGmoZW59.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-CCzXAeaZ.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-2Gf-ERTg.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-DMjDe28d.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CENtj5dw.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-BW4gCpBB.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-CiIuqcT9.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-BQ1BPr5R.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-DBq9Ailv.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-D24VDECy.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +0 -13
- package/packages/web/dist/assets/channel-C4kPYn_d.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +0 -2
- package/packages/web/dist/assets/index-B-QAOq9a.js +0 -71
- package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +0 -2
|
@@ -1,9 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket chat transport.
|
|
3
|
+
*
|
|
4
|
+
* Protocol (the contract the frontend agent matches):
|
|
5
|
+
*
|
|
6
|
+
* client -> server:
|
|
7
|
+
* { type: "chat:send", payload: {
|
|
8
|
+
* projectSlug: string, // workspace key: a project slug, or "" for the root
|
|
9
|
+
* sessionId: string | null, // resume an existing chat, or null = new
|
|
10
|
+
* message: string,
|
|
11
|
+
* preloadContext?: boolean, // new chat: prepend the project OVERVIEW.md
|
|
12
|
+
* model?: string, // per-chat model override (a known model id)
|
|
13
|
+
* } }
|
|
14
|
+
* { type: "chat:cancel", payload: { jobId } } // optional: stop a running turn
|
|
15
|
+
* { type: "ping" }
|
|
16
|
+
*
|
|
17
|
+
* server -> client (all carry projectSlug + sessionId + jobId for routing):
|
|
18
|
+
* { type: "chat:response", payload: { projectSlug, sessionId, jobId, chunk } }
|
|
19
|
+
* { type: "chat:tool_call", payload: { projectSlug, sessionId, jobId,
|
|
20
|
+
* toolName, inputSummary, output,
|
|
21
|
+
* isError, durationMs } }
|
|
22
|
+
* { type: "chat:message_boundary", payload: { projectSlug, sessionId, jobId } }
|
|
23
|
+
* { type: "chat:complete", payload: { projectSlug, sessionId, jobId, success, error?,
|
|
24
|
+
* model?, usage? } }
|
|
25
|
+
* // model: the model the turn ran on (lastModel ?? effectiveModel).
|
|
26
|
+
* // usage: { inputTokens, outputTokens, cacheReadTokens,
|
|
27
|
+
* // cacheCreationTokens, contextTokens, contextLimit } — the
|
|
28
|
+
* // LAST per-turn usage observed; omitted (with model) if none.
|
|
29
|
+
* // contextTokens = input + cacheRead + cacheCreation;
|
|
30
|
+
* // contextLimit = getContextLimit(model). Stale-by-one-turn
|
|
31
|
+
* // by design (it reflects the just-completed turn's input).
|
|
32
|
+
* { type: "chat:injected", payload: { projectSlug, sessionId, jobId,
|
|
33
|
+
* sender, content, timestamp } }
|
|
34
|
+
* // A machine-injected user turn (issue #290 Part 2): another chat
|
|
35
|
+
* // send_message'd / a schedule fired into this session. Emitted so a
|
|
36
|
+
* // client already viewing the recipient chat renders the injected user
|
|
37
|
+
* // bubble LIVE (with its sender attribution) instead of only seeing the
|
|
38
|
+
* // assistant reply and needing a refresh. `sender` is the MessageSender.
|
|
39
|
+
* { type: "chat:error", payload: { projectSlug, error } }
|
|
40
|
+
* { type: "pong" }
|
|
41
|
+
*
|
|
42
|
+
* Streaming is wired for real via HerdctlService.chat()'s onMessage callback
|
|
43
|
+
* (the public trigger API supports it). The SDKMessage -> chat-event translation
|
|
44
|
+
* (assistant text deltas, message boundaries, and paired tool_use -> tool_result
|
|
45
|
+
* calls enriched with input summaries + wall-clock durations) is done by
|
|
46
|
+
* @herdctl/chat's `createSDKMessageHandler` — the shared, transport-agnostic
|
|
47
|
+
* translator every herdctl chat surface uses — so paddock no longer reimplements
|
|
48
|
+
* it (and as of @herdctl/chat@0.4.1 it pairs CLI tool results correctly, so the
|
|
49
|
+
* prior `normalizeForTranslator` shim is gone). We compose it with a tiny wrapper
|
|
50
|
+
* that also captures, from each raw SDK message, the session id and the per-turn
|
|
51
|
+
* usage + model (the translator only exposes text/boundary/tool events).
|
|
52
|
+
*/
|
|
53
|
+
import { randomUUID } from "node:crypto";
|
|
1
54
|
import { createSDKMessageHandler } from "@herdctl/chat";
|
|
2
55
|
import { extractSubagentLaunches, subagentLaunchFields, } from "./subagents.js";
|
|
3
56
|
import { keeperAgentName, keeperSlugFromAgent, } from "./herdctl.js";
|
|
4
57
|
import { consumeResumedTurn } from "./resume-drain.js";
|
|
5
58
|
import { isKnownModel, getContextLimit, DEFAULT_MODEL, isKnownDriveMode, } from "./models.js";
|
|
6
59
|
import { SessionHub } from "./session-hub.js";
|
|
60
|
+
import { partsOf } from "./queued-message.js";
|
|
7
61
|
import { resolveAttachmentsConfig } from "./attachments-config.js";
|
|
8
62
|
import { wrapAttachments, inferAttachmentKind } from "./attachments-hint.js";
|
|
9
63
|
import { sendFileServerDef, SEND_FILE_SERVER_KEY } from "./send-file-mcp.js";
|
|
@@ -40,6 +94,48 @@ const SERVER_PING_INTERVAL_MS = 30_000;
|
|
|
40
94
|
// ripgrep/grep treat this file as binary and skip it during directory traversal
|
|
41
95
|
// (silently, exit 1, indistinguishable from "no matches"). See issue #570.
|
|
42
96
|
const KEY_SEP = "\u0000";
|
|
97
|
+
/**
|
|
98
|
+
* Cap on a single queued message. The sidecar is rewritten in full on every queue
|
|
99
|
+
* mutation and the text is persisted verbatim, so an unbounded queue is a cheap
|
|
100
|
+
* way to bloat the data dir (a 2 MB `chat:set_queue` was accepted as-is). Well
|
|
101
|
+
* above any hand-typed follow-up, including a pasted stack trace.
|
|
102
|
+
*/
|
|
103
|
+
const MAX_QUEUED_CHARS = 100_000;
|
|
104
|
+
/**
|
|
105
|
+
* The opaque identity of the client queue a `chat:set_queue` belongs to (#736).
|
|
106
|
+
*
|
|
107
|
+
* Whatever the client supplies is used for EQUALITY ONLY — never compared as a
|
|
108
|
+
* clock — so a skewed clock can't poison anything. A legacy client sends only the
|
|
109
|
+
* enqueue `ts`, which is folded into an id (still a perfectly good per-client
|
|
110
|
+
* identity, just no longer an ordering). A client that sends neither gets a fresh
|
|
111
|
+
* id, so its queue is always treated as new: with nothing stable to match on, the
|
|
112
|
+
* safe failure is to send the message rather than to discard it.
|
|
113
|
+
*/
|
|
114
|
+
function queueIdOf(payload) {
|
|
115
|
+
if (typeof payload.qid === "string" && payload.qid.length > 0)
|
|
116
|
+
return payload.qid;
|
|
117
|
+
if (typeof payload.ts === "number")
|
|
118
|
+
return `ts:${payload.ts}`;
|
|
119
|
+
return `anon:${randomUUID()}`;
|
|
120
|
+
}
|
|
121
|
+
/** Write one frame straight to a single socket, skipping a closed one. */
|
|
122
|
+
function writeFrame(socket, msg) {
|
|
123
|
+
if (socket.readyState !== socket.OPEN)
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
socket.send(JSON.stringify(msg));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* a socket that throws on send is effectively gone */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The identity of a slot as a whole (not of one contribution). Written by every
|
|
134
|
+
* `upsert`; synthesised for an entry an older server wrote without one.
|
|
135
|
+
*/
|
|
136
|
+
function slotVersion(m) {
|
|
137
|
+
return m.id ?? `legacy:${m.createdAtMs}`;
|
|
138
|
+
}
|
|
43
139
|
export function makeChatHandler(deps) {
|
|
44
140
|
// ONE hub shared across every socket this handler serves: it tracks each
|
|
45
141
|
// session's in-flight turn and fans its frames out to whichever socket(s) are
|
|
@@ -53,14 +149,61 @@ export function makeChatHandler(deps) {
|
|
|
53
149
|
// double-send only survives a server restart, when the persisted store is
|
|
54
150
|
// already empty anyway.
|
|
55
151
|
//
|
|
56
|
-
// A message's identity is the (
|
|
57
|
-
// The client
|
|
58
|
-
// queue (#245 stable identity), so "same
|
|
59
|
-
// message — deduping on the
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
152
|
+
// A queued message's identity is the (queue id, text) TUPLE, matched by
|
|
153
|
+
// EQUALITY (#628/#736). The client KEEPS one queue id when APPENDING to an
|
|
154
|
+
// existing queue (#245 stable identity), so "same id, different text" is a
|
|
155
|
+
// genuinely new message — deduping on the id alone would silently destroy the
|
|
156
|
+
// appended text.
|
|
157
|
+
//
|
|
158
|
+
// This used to be a (ts, text) tuple with an ORDERING rule on top: anything
|
|
159
|
+
// older than the last flushed ts was "stale by construction, timestamps being
|
|
160
|
+
// monotonic". They are monotonic within ONE client — but the ts came from
|
|
161
|
+
// whichever browser queued the message, so a single client with a fast clock
|
|
162
|
+
// parked the marker in the future and every subsequent queued message on that
|
|
163
|
+
// chat, from ANY client, was silently destroyed until wall-clock time caught up
|
|
164
|
+
// (#736). Equality assumes nothing about clocks, so the ordering rule is gone;
|
|
165
|
+
// in exchange every flushed id has to be remembered rather than just the newest.
|
|
166
|
+
//
|
|
167
|
+
// Bounded on both axes: MAX_FLUSHED_SESSIONS chats, MAX_FLUSHED_IDS ids each,
|
|
168
|
+
// oldest evicted first (insertion-ordered Maps). Overflow can only cost a rare
|
|
169
|
+
// double-send of a re-asserted message, the same exposure a server restart has.
|
|
63
170
|
const lastFlushed = new Map();
|
|
171
|
+
const MAX_FLUSHED_SESSIONS = 512;
|
|
172
|
+
const MAX_FLUSHED_IDS = 32;
|
|
173
|
+
const MAX_FLUSHED_TEXTS = 8;
|
|
174
|
+
/** This session's flushed-parts ledger (id -> the texts flushed under it). */
|
|
175
|
+
const flushedLedger = (markerKey) => {
|
|
176
|
+
let m = lastFlushed.get(markerKey);
|
|
177
|
+
if (!m) {
|
|
178
|
+
m = new Map();
|
|
179
|
+
lastFlushed.set(markerKey, m);
|
|
180
|
+
if (lastFlushed.size > MAX_FLUSHED_SESSIONS) {
|
|
181
|
+
lastFlushed.delete(lastFlushed.keys().next().value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return m;
|
|
185
|
+
};
|
|
186
|
+
// Slots we could NOT hand back to a composer on Stop, keyed `agent \0 sessionId`
|
|
187
|
+
// → the slot version that was parked. A parked message stays queued and visible,
|
|
188
|
+
// but no turn end auto-sends it: the user pressed Stop, and firing their
|
|
189
|
+
// follow-up later — behind whatever they type next — is the stranding this whole
|
|
190
|
+
// change exists to remove. Any real edit mints a new slot version, so the parked
|
|
191
|
+
// id simply stops matching and the message becomes ordinary again; a bare
|
|
192
|
+
// re-assert keeps the version and stays parked. Same bound as the ledger above.
|
|
193
|
+
const parked = new Map();
|
|
194
|
+
/** Record one flushed part, trimming the oldest entries past the bounds. */
|
|
195
|
+
const rememberFlushed = (ledger, id, text) => {
|
|
196
|
+
let texts = ledger.get(id);
|
|
197
|
+
if (!texts) {
|
|
198
|
+
texts = new Set();
|
|
199
|
+
ledger.set(id, texts);
|
|
200
|
+
if (ledger.size > MAX_FLUSHED_IDS)
|
|
201
|
+
ledger.delete(ledger.keys().next().value);
|
|
202
|
+
}
|
|
203
|
+
texts.add(text);
|
|
204
|
+
if (texts.size > MAX_FLUSHED_TEXTS)
|
|
205
|
+
texts.delete(texts.values().next().value);
|
|
206
|
+
};
|
|
64
207
|
// Every currently-connected socket, so a turn's start/stop transition can be
|
|
65
208
|
// broadcast to all clients — powering the per-chat sidebar streaming dots that
|
|
66
209
|
// must update even for chats whose pane isn't mounted (issue #53).
|
|
@@ -253,84 +396,19 @@ export function makeChatHandler(deps) {
|
|
|
253
396
|
// consumes the returned surface.
|
|
254
397
|
const engine = makeTurnEngine({ deps, hub });
|
|
255
398
|
const { startAgentTurn, wakeInjection, selfMcpCtx, injectRecoveryNudge, recoveryEngine, emitAfterTurn, composePreloadedPrompt, fireTrigger, makeBackgroundTurnSink, } = engine;
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// terminated on the next tick — freeing server resources and letting the
|
|
270
|
-
// client's own reconnect take over. Cleared when the socket closes.
|
|
271
|
-
let isAlive = true;
|
|
272
|
-
socket.on("pong", () => {
|
|
273
|
-
isAlive = true;
|
|
274
|
-
});
|
|
275
|
-
const heartbeat = setInterval(() => {
|
|
276
|
-
if (!isAlive) {
|
|
277
|
-
socket.terminate();
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
isAlive = false;
|
|
281
|
-
try {
|
|
282
|
-
socket.ping();
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
socket.terminate();
|
|
286
|
-
}
|
|
287
|
-
}, SERVER_PING_INTERVAL_MS);
|
|
288
|
-
socket.on("close", () => {
|
|
289
|
-
clearInterval(heartbeat);
|
|
290
|
-
// Drop this socket from every session fan-out set so the hub stops trying
|
|
291
|
-
// to write to it (a running turn keeps going for other attached sockets).
|
|
292
|
-
hub.unsubscribeSocket(socket);
|
|
293
|
-
clients.delete(socket);
|
|
294
|
-
});
|
|
295
|
-
socket.on("message", (raw) => {
|
|
296
|
-
let parsed;
|
|
297
|
-
try {
|
|
298
|
-
parsed = JSON.parse(raw.toString());
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
send({ type: "chat:error", payload: { projectSlug: "?", error: "Invalid JSON" } });
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (!isClientMessage(parsed)) {
|
|
305
|
-
send({ type: "chat:error", payload: { projectSlug: "?", error: "Unknown message" } });
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (parsed.type === "ping") {
|
|
309
|
-
send({ type: "pong" });
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
if (parsed.type === "chat:cancel") {
|
|
313
|
-
void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
if (parsed.type === "chat:subscribe") {
|
|
317
|
-
onSubscribe(parsed);
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
if (parsed.type === "chat:set_queue") {
|
|
321
|
-
void onSetQueue(parsed);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
if (parsed.type === "chat:command") {
|
|
325
|
-
void onChatCommand(parsed);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
if (parsed.type === "chat:continue") {
|
|
329
|
-
void onChatContinue(parsed);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
void onChatSend(parsed);
|
|
333
|
-
});
|
|
399
|
+
/**
|
|
400
|
+
* Build the chat operations for ONE connection: the handlers behind every
|
|
401
|
+
* client frame, plus the queue drain they share.
|
|
402
|
+
*
|
|
403
|
+
* A factory rather than plain closures inside {@link handle} because the drain
|
|
404
|
+
* has to run with NO socket at all (#627). A turn started by a trigger, a
|
|
405
|
+
* scheduler wake or a spawn has no originating connection, and a queued
|
|
406
|
+
* follow-up must still go out when it ends — so `socket` is nullable and the
|
|
407
|
+
* ops are built once, socket-less, for those paths (see `headlessOps` below).
|
|
408
|
+
* A socket-less send is a no-op: everything a client needs to see travels over
|
|
409
|
+
* the hub, which fans out to every socket attached to the session.
|
|
410
|
+
*/
|
|
411
|
+
const makeChatOps = (socket, send) => {
|
|
334
412
|
/**
|
|
335
413
|
* Manual keeper recovery (issue #301, Layer 2). Re-drive a hung keeper whose
|
|
336
414
|
* background task was killed at the turn boundary by injecting the recovery
|
|
@@ -369,6 +447,8 @@ export function makeChatHandler(deps) {
|
|
|
369
447
|
});
|
|
370
448
|
};
|
|
371
449
|
const onSubscribe = (msg) => {
|
|
450
|
+
if (!socket)
|
|
451
|
+
return; // the socket-less ops never receive client frames
|
|
372
452
|
const { sessionId, wantReplay, lastSeq } = msg.payload;
|
|
373
453
|
const result = hub.attach(sessionId, socket, {
|
|
374
454
|
wantReplay: wantReplay === true,
|
|
@@ -387,30 +467,78 @@ export function makeChatHandler(deps) {
|
|
|
387
467
|
const active = hub.activeInfo(sessionId);
|
|
388
468
|
if (active)
|
|
389
469
|
send(activeFrame(active));
|
|
470
|
+
// …and what is currently QUEUED on it (#629). The queue is shared chat
|
|
471
|
+
// state, but a pane could only ever learn it from its own localStorage, so
|
|
472
|
+
// a message queued on another device — or one parked by a Stop nobody was
|
|
473
|
+
// left to hand it back to — was invisible to whoever opened the chat next.
|
|
474
|
+
const slug = msg.payload.projectSlug;
|
|
475
|
+
if (deps.queuedMessage && typeof slug === "string") {
|
|
476
|
+
void deps.queuedMessage
|
|
477
|
+
.get(keeperAgentName(slug), sessionId)
|
|
478
|
+
.then((queued) => {
|
|
479
|
+
if (!queued?.text)
|
|
480
|
+
return;
|
|
481
|
+
send({
|
|
482
|
+
type: "chat:queued_state",
|
|
483
|
+
payload: {
|
|
484
|
+
projectSlug: slug,
|
|
485
|
+
sessionId,
|
|
486
|
+
text: queued.text,
|
|
487
|
+
...(queued.id ? { qid: queued.id } : {}),
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
})
|
|
491
|
+
.catch(() => undefined);
|
|
492
|
+
}
|
|
390
493
|
};
|
|
391
494
|
// Server-authoritative queue drain (#245): auto-send a persisted queued
|
|
392
|
-
// message as the next turn, exactly once. Called (a)
|
|
393
|
-
//
|
|
394
|
-
// that arrived (e.g. via the
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
495
|
+
// message as the next turn, exactly once. Called (a) whenever a turn ends —
|
|
496
|
+
// through the hub, so EVERY turn path drains (#627) — and (b) when a queue is
|
|
497
|
+
// set while the session is idle, i.e. a queue that arrived (e.g. via the
|
|
498
|
+
// reconnect outbox) after the turn it was meant to follow already ended.
|
|
499
|
+
// `take` makes the read+clear atomic so two concurrent drains can never both
|
|
500
|
+
// send it; the flushed-parts ledger skips what was already drained (a stale
|
|
501
|
+
// client re-assert on reload) so it isn't sent twice.
|
|
398
502
|
const drainQueue = async (slug, sessionId) => {
|
|
399
503
|
if (!deps.queuedMessage)
|
|
400
504
|
return;
|
|
505
|
+
// Another turn is already live on this session (a drain racing a fresh send,
|
|
506
|
+
// or a turn ending while its successor has started). Leave the queue for
|
|
507
|
+
// that turn's own end — sending now would interleave two turns.
|
|
508
|
+
if (hub.isRunning(sessionId))
|
|
509
|
+
return;
|
|
401
510
|
const agent = keeperAgentName(slug);
|
|
402
511
|
const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
|
|
403
512
|
if (!queued?.text)
|
|
404
513
|
return;
|
|
405
514
|
const markerKey = `${agent}${KEY_SEP}${sessionId}`;
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
515
|
+
// Parked by a Stop we couldn't hand back (see returnQueueToComposer): the
|
|
516
|
+
// user asked for control, so it waits for them, not for the next turn end.
|
|
517
|
+
// Put it back — `take` already removed it.
|
|
518
|
+
if (parked.get(markerKey) === slotVersion(queued)) {
|
|
519
|
+
await deps.queuedMessage.set(agent, sessionId, queued).catch(() => undefined);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const ledger = flushedLedger(markerKey);
|
|
523
|
+
// Have we already sent this? (#628/#736.)
|
|
524
|
+
//
|
|
525
|
+
// Identity is the (id, text) TUPLE, matched by EQUALITY, against every
|
|
526
|
+
// identity the slot has been known by — so a client re-asserting its stored
|
|
527
|
+
// copy on reload is recognised whether it holds the id it sent or the slot
|
|
528
|
+
// version the server broadcast back.
|
|
529
|
+
//
|
|
530
|
+
// It used to be a (ts, text) tuple with an ORDERING rule on top: "older than
|
|
531
|
+
// the last flush ⇒ stale by construction, timestamps being monotonic". They
|
|
532
|
+
// are monotonic within one client, but the ts came from whichever BROWSER
|
|
533
|
+
// queued the message — so a single client with a fast clock left the marker
|
|
534
|
+
// five minutes in the future, and every later queued message on that chat,
|
|
535
|
+
// from any client, was taken, classified already-flushed, deleted and never
|
|
536
|
+
// sent, until wall-clock time caught up (#736). Equality assumes nothing
|
|
537
|
+
// about clocks. An APPEND keeps its identity with LONGER text (#245), so it
|
|
538
|
+
// still reads as a genuinely new message and is sent — #628, unchanged.
|
|
539
|
+
const parts = partsOf(queued);
|
|
540
|
+
const stale = parts.some((p) => ledger.get(p.id)?.has(p.text));
|
|
541
|
+
const text = stale ? "" : queued.text;
|
|
414
542
|
// Tell every attached client (origin + reconnected sockets) to clear its copy
|
|
415
543
|
// of this message. When we're really sending it, carry the text so the client
|
|
416
544
|
// renders the sent bubble in-transcript; on a stale re-assert we only clear.
|
|
@@ -419,31 +547,29 @@ export function makeChatHandler(deps) {
|
|
|
419
547
|
payload: {
|
|
420
548
|
projectSlug: slug,
|
|
421
549
|
sessionId,
|
|
422
|
-
...(
|
|
550
|
+
...(text ? { text } : {}),
|
|
423
551
|
},
|
|
424
552
|
});
|
|
425
|
-
if (
|
|
553
|
+
if (!text)
|
|
426
554
|
return;
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
else
|
|
432
|
-
lastFlushed.set(markerKey, { ts: queued.createdAtMs, texts: new Set([queued.text]) });
|
|
555
|
+
// Every identity this slot carried is now flushed, so a re-assert from any
|
|
556
|
+
// client that held one of them is recognised rather than sent again.
|
|
557
|
+
for (const p of parts)
|
|
558
|
+
rememberFlushed(ledger, p.id, p.text);
|
|
433
559
|
// Broadcast the flush frame BEFORE kicking the turn so the user bubble renders
|
|
434
560
|
// above the reply. Run it detached, like a human send. A leading-slash queued
|
|
435
561
|
// message is a slash command (e.g. "/compact"): route it through the command
|
|
436
562
|
// path so the CLI dispatches it — matching how the composer sends one live.
|
|
437
|
-
if (
|
|
563
|
+
if (text.startsWith("/")) {
|
|
438
564
|
void onChatCommand({
|
|
439
565
|
type: "chat:command",
|
|
440
|
-
payload: { projectSlug: slug, command:
|
|
566
|
+
payload: { projectSlug: slug, command: text, sessionId },
|
|
441
567
|
});
|
|
442
568
|
}
|
|
443
569
|
else {
|
|
444
570
|
void onChatSend({
|
|
445
571
|
type: "chat:send",
|
|
446
|
-
payload: { projectSlug: slug, sessionId, message:
|
|
572
|
+
payload: { projectSlug: slug, sessionId, message: text },
|
|
447
573
|
});
|
|
448
574
|
}
|
|
449
575
|
};
|
|
@@ -460,14 +586,48 @@ export function makeChatHandler(deps) {
|
|
|
460
586
|
const agent = keeperAgentName(slug);
|
|
461
587
|
if (!sessionId) {
|
|
462
588
|
// New chat: queue isn't stored until the session id exists. The client
|
|
463
|
-
// re-asserts it (
|
|
589
|
+
// re-asserts it (same qid) once the id resolves, so it persists then.
|
|
464
590
|
return;
|
|
465
591
|
}
|
|
466
592
|
// Store or clear the queued message.
|
|
467
593
|
if (text && text.trim().length > 0) {
|
|
468
|
-
|
|
469
|
-
|
|
594
|
+
// A queued message is persisted verbatim and the whole sidecar is rewritten
|
|
595
|
+
// on every mutation, so an unbounded one is a cheap way to bloat the data
|
|
596
|
+
// dir. Refuse it loudly rather than silently truncating the user's text.
|
|
597
|
+
if (text.length > MAX_QUEUED_CHARS) {
|
|
598
|
+
send({
|
|
599
|
+
type: "chat:error",
|
|
600
|
+
payload: {
|
|
601
|
+
projectSlug: slug,
|
|
602
|
+
error: `Queued message is too long (${text.length} characters; the limit is ${MAX_QUEUED_CHARS}).`,
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
// Merge into the chat's single slot rather than overwriting it (#629). The
|
|
608
|
+
// id the client wrote under says which this is: a client editing the slot
|
|
609
|
+
// it can currently see (replace) or one that queued without knowing what
|
|
610
|
+
// was already there (append). The old unconditional overwrite destroyed the
|
|
611
|
+
// first client's message with no signal to anyone — and then rendered it,
|
|
612
|
+
// in that client's own transcript, as a user bubble they never typed.
|
|
613
|
+
const next = await deps.queuedMessage
|
|
614
|
+
.upsert(agent, sessionId, { id: queueIdOf(msg.payload), text }, Date.now(), randomUUID)
|
|
470
615
|
.catch(() => undefined);
|
|
616
|
+
// The queue is shared chat state, so announce it: every attached client
|
|
617
|
+
// renders the same slot, and echoing `qid` back on its next write is how a
|
|
618
|
+
// client proves it is editing the slot as it stands rather than replacing
|
|
619
|
+
// someone else's text with its own.
|
|
620
|
+
if (next !== undefined) {
|
|
621
|
+
hub.broadcast(sessionId, {
|
|
622
|
+
type: "chat:queued_state",
|
|
623
|
+
payload: {
|
|
624
|
+
projectSlug: slug,
|
|
625
|
+
sessionId,
|
|
626
|
+
text: next?.text ?? null,
|
|
627
|
+
...(next?.id ? { qid: next.id } : {}),
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
}
|
|
471
631
|
// If no turn is running, this queue arrived after the turn it was meant to
|
|
472
632
|
// follow already ended — drain it now rather than wait for a completion
|
|
473
633
|
// that won't come (the reported stranding bug, #245).
|
|
@@ -475,7 +635,14 @@ export function makeChatHandler(deps) {
|
|
|
475
635
|
await drainQueue(slug, sessionId);
|
|
476
636
|
}
|
|
477
637
|
else {
|
|
638
|
+
// A clear wipes the WHOLE slot, including another client's contribution:
|
|
639
|
+
// with the state broadcast the chip is shared, so clearing it is a
|
|
640
|
+
// deliberate act on something the user can actually see.
|
|
478
641
|
await deps.queuedMessage.set(agent, sessionId, null).catch(() => undefined);
|
|
642
|
+
hub.broadcast(sessionId, {
|
|
643
|
+
type: "chat:queued_state",
|
|
644
|
+
payload: { projectSlug: slug, sessionId, text: null },
|
|
645
|
+
});
|
|
479
646
|
}
|
|
480
647
|
};
|
|
481
648
|
const onChatSend = async (msg) => {
|
|
@@ -838,13 +1005,13 @@ export function makeChatHandler(deps) {
|
|
|
838
1005
|
...(completeUsage ? { model: completeModel, usage: completeUsage } : {}),
|
|
839
1006
|
},
|
|
840
1007
|
});
|
|
1008
|
+
// Ending the turn drains any queued follow-up (#197/#245) via the hub's
|
|
1009
|
+
// turn-end hook — the ONE place every turn path converges (#627), rather
|
|
1010
|
+
// than this call site alone. It no longer waits for a SUCCESSFUL turn:
|
|
1011
|
+
// holding the queue on a failed/Stopped turn didn't keep it for the user,
|
|
1012
|
+
// it stranded it, to be flushed by the NEXT `chat:send` — landing behind a
|
|
1013
|
+
// message typed later, with a stale chip in between. See drainQueue.
|
|
841
1014
|
turn.end();
|
|
842
|
-
// After a SUCCESSFUL turn, auto-send any queued follow-up (#197/#245). A
|
|
843
|
-
// Stop/failed turn holds the queue for the user (no drain). drainQueue owns
|
|
844
|
-
// the take + client notify + next-turn kickoff, shared with the idle path.
|
|
845
|
-
if (effectiveSuccess && finalSession) {
|
|
846
|
-
await drainQueue(slug, finalSession);
|
|
847
|
-
}
|
|
848
1015
|
// Layer 3 (issue #301): arm a post-turn recovery watch for a session-mode
|
|
849
1016
|
// keeper turn. If this turn launched a background task that the runtime kills
|
|
850
1017
|
// at the turn boundary (herdctl#374) and the keeper doesn't wake on its own,
|
|
@@ -1040,6 +1207,192 @@ export function makeChatHandler(deps) {
|
|
|
1040
1207
|
turn.end();
|
|
1041
1208
|
}
|
|
1042
1209
|
};
|
|
1210
|
+
return { onChatContinue, onSubscribe, drainQueue, onSetQueue, onChatSend, onChatCommand };
|
|
1211
|
+
};
|
|
1212
|
+
// The ops used when a turn ends with no socket behind it — a trigger, a wake, a
|
|
1213
|
+
// spawned turn, a background sub-agent stretch — so the drain still has a way to
|
|
1214
|
+
// dispatch the follow-up. Built once: it holds no per-connection state.
|
|
1215
|
+
const headlessOps = makeChatOps(null, () => { });
|
|
1216
|
+
// Prefer the ending turn's ORIGIN socket when there is one, so a drained turn
|
|
1217
|
+
// keeps the same fan-out (and error routing) the turn it followed had; a turn
|
|
1218
|
+
// with no origin falls back to the headless ops.
|
|
1219
|
+
const opsFor = (origin) => origin
|
|
1220
|
+
? makeChatOps(origin, (m) => {
|
|
1221
|
+
if (origin.readyState === origin.OPEN)
|
|
1222
|
+
origin.send(JSON.stringify(m));
|
|
1223
|
+
})
|
|
1224
|
+
: headlessOps;
|
|
1225
|
+
/**
|
|
1226
|
+
* Stop hands the queued message BACK to the user instead of sending it.
|
|
1227
|
+
*
|
|
1228
|
+
* Stop means "give me control back", so the one thing it must not do is have
|
|
1229
|
+
* the agent start working again on the follow-up the instant it is stopped.
|
|
1230
|
+
* Holding the message server-side isn't the answer either — that is exactly
|
|
1231
|
+
* what stranded it (#627): it sat there and went out behind whatever the user
|
|
1232
|
+
* typed next. So it goes back to the composer of the client that pressed Stop,
|
|
1233
|
+
* where it is visible, editable, and sent only when they say so. The client
|
|
1234
|
+
* runs its existing "Edit queued message" path on the frame; the composer draft
|
|
1235
|
+
* is persisted to localStorage by the same effect that persists any draft.
|
|
1236
|
+
*
|
|
1237
|
+
* Every OTHER turn-ending path — normal completion, a slash command, a trigger,
|
|
1238
|
+
* a wake, a spawn, a background stretch — still drains. This is the single
|
|
1239
|
+
* explicit exception, not a gap.
|
|
1240
|
+
*/
|
|
1241
|
+
const returnQueueToComposer = async (slug, sessionId, to) => {
|
|
1242
|
+
if (!deps.queuedMessage)
|
|
1243
|
+
return;
|
|
1244
|
+
// A successor turn is already live (a Stop racing the next send): leave the
|
|
1245
|
+
// queue to that turn's own end rather than yanking it mid-flight.
|
|
1246
|
+
if (hub.isRunning(sessionId))
|
|
1247
|
+
return;
|
|
1248
|
+
const agent = keeperAgentName(slug);
|
|
1249
|
+
const queued = await deps.queuedMessage.take(agent, sessionId).catch(() => null);
|
|
1250
|
+
if (!queued?.text)
|
|
1251
|
+
return;
|
|
1252
|
+
const live = to && to.readyState === to.OPEN ? to : null;
|
|
1253
|
+
if (!live) {
|
|
1254
|
+
// Nobody to hand it to — the tab closed between the Stop and this turn
|
|
1255
|
+
// ending, or the cancel came from no socket at all. Put it back and PARK
|
|
1256
|
+
// it: still queued, still visible (every attached client is told, and a
|
|
1257
|
+
// pane opening this chat is told on subscribe), but no turn end will
|
|
1258
|
+
// auto-send it. Dropping it here would be exactly the silent loss this
|
|
1259
|
+
// change is about, and sending it would be the surprise Stop must not have.
|
|
1260
|
+
await deps.queuedMessage.set(agent, sessionId, queued).catch(() => undefined);
|
|
1261
|
+
const key = `${agent}${KEY_SEP}${sessionId}`;
|
|
1262
|
+
parked.set(key, slotVersion(queued));
|
|
1263
|
+
if (parked.size > MAX_FLUSHED_SESSIONS) {
|
|
1264
|
+
parked.delete(parked.keys().next().value);
|
|
1265
|
+
}
|
|
1266
|
+
hub.broadcast(sessionId, {
|
|
1267
|
+
type: "chat:queued_state",
|
|
1268
|
+
payload: {
|
|
1269
|
+
projectSlug: slug,
|
|
1270
|
+
sessionId,
|
|
1271
|
+
text: queued.text,
|
|
1272
|
+
...(queued.id ? { qid: queued.id } : {}),
|
|
1273
|
+
},
|
|
1274
|
+
});
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
// The stopping client gets the text; everyone else just sees the shared slot
|
|
1278
|
+
// clear, WITH a reason — a chip vanishing from under you with no explanation
|
|
1279
|
+
// is the same silence that made #629 bite.
|
|
1280
|
+
writeFrame(live, {
|
|
1281
|
+
type: "chat:queued_returned",
|
|
1282
|
+
payload: { projectSlug: slug, sessionId, text: queued.text },
|
|
1283
|
+
});
|
|
1284
|
+
hub.broadcast(sessionId, {
|
|
1285
|
+
type: "chat:queued_state",
|
|
1286
|
+
payload: { projectSlug: slug, sessionId, text: null, reason: "returned" },
|
|
1287
|
+
}, { except: live });
|
|
1288
|
+
};
|
|
1289
|
+
// #627: EVERY turn-ending path deals with the queue, because every one of them
|
|
1290
|
+
// ends its turn through the hub. Previously this was a single call after a
|
|
1291
|
+
// successful `chat:send`, so a message queued during a slash command, a trigger,
|
|
1292
|
+
// a wake or a background stretch was stranded until some later `chat:send`
|
|
1293
|
+
// flushed it — out of order, behind a message the user typed afterwards.
|
|
1294
|
+
hub.onTurnEnd = ({ sessionId, projectSlug, origin, cancel }) => {
|
|
1295
|
+
// A destructive op stopped this turn so it can delete/revert/promote the
|
|
1296
|
+
// chat (#731). Its transcript is about to be rewritten or removed, so a
|
|
1297
|
+
// drained follow-up would either race that or resurrect the chat (#730) —
|
|
1298
|
+
// and, by making the session busy again, would make the interlock time out
|
|
1299
|
+
// and refuse the operation. Leave the queue exactly where it is.
|
|
1300
|
+
if (cancel?.reason === "quiesce")
|
|
1301
|
+
return;
|
|
1302
|
+
if (cancel?.reason === "stop") {
|
|
1303
|
+
void returnQueueToComposer(projectSlug, sessionId, cancel.origin);
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
void opsFor(origin).drainQueue(projectSlug, sessionId);
|
|
1307
|
+
};
|
|
1308
|
+
const handle = async function handle(socket) {
|
|
1309
|
+
const send = (m) => {
|
|
1310
|
+
if (socket.readyState === socket.OPEN)
|
|
1311
|
+
socket.send(JSON.stringify(m));
|
|
1312
|
+
};
|
|
1313
|
+
// This connection's operations. The frame dispatcher below closes over them.
|
|
1314
|
+
const { onChatContinue, onSubscribe, onSetQueue, onChatSend, onChatCommand } = makeChatOps(socket, send);
|
|
1315
|
+
// Register this socket for active-turn broadcasts, and immediately catch it
|
|
1316
|
+
// up on which sessions are currently running (so the sidebar dots and a
|
|
1317
|
+
// returning pane's Stop button reflect reality from the first paint).
|
|
1318
|
+
clients.add(socket);
|
|
1319
|
+
for (const info of hub.runningSessions())
|
|
1320
|
+
send(activeFrame(info));
|
|
1321
|
+
// Heartbeat: browsers auto-answer protocol ping frames with a pong, so a
|
|
1322
|
+
// client whose TCP has silently died (idle drop, sleep) fails to pong and is
|
|
1323
|
+
// terminated on the next tick — freeing server resources and letting the
|
|
1324
|
+
// client's own reconnect take over. Cleared when the socket closes.
|
|
1325
|
+
let isAlive = true;
|
|
1326
|
+
socket.on("pong", () => {
|
|
1327
|
+
isAlive = true;
|
|
1328
|
+
});
|
|
1329
|
+
const heartbeat = setInterval(() => {
|
|
1330
|
+
if (!isAlive) {
|
|
1331
|
+
socket.terminate();
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
isAlive = false;
|
|
1335
|
+
try {
|
|
1336
|
+
socket.ping();
|
|
1337
|
+
}
|
|
1338
|
+
catch {
|
|
1339
|
+
socket.terminate();
|
|
1340
|
+
}
|
|
1341
|
+
}, SERVER_PING_INTERVAL_MS);
|
|
1342
|
+
socket.on("close", () => {
|
|
1343
|
+
clearInterval(heartbeat);
|
|
1344
|
+
// Drop this socket from every session fan-out set so the hub stops trying
|
|
1345
|
+
// to write to it (a running turn keeps going for other attached sockets).
|
|
1346
|
+
hub.unsubscribeSocket(socket);
|
|
1347
|
+
clients.delete(socket);
|
|
1348
|
+
});
|
|
1349
|
+
socket.on("message", (raw) => {
|
|
1350
|
+
let parsed;
|
|
1351
|
+
try {
|
|
1352
|
+
parsed = JSON.parse(raw.toString());
|
|
1353
|
+
}
|
|
1354
|
+
catch {
|
|
1355
|
+
send({ type: "chat:error", payload: { projectSlug: "?", error: "Invalid JSON" } });
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (!isClientMessage(parsed)) {
|
|
1359
|
+
send({ type: "chat:error", payload: { projectSlug: "?", error: "Unknown message" } });
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
if (parsed.type === "ping") {
|
|
1363
|
+
send({ type: "pong" });
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (parsed.type === "chat:cancel") {
|
|
1367
|
+
// Tell the hub this end is a user Stop, and which socket asked, so the
|
|
1368
|
+
// turn end it causes hands the queued follow-up back to THAT composer
|
|
1369
|
+
// instead of sending it. `chat:cancel` names only a job id, so the hub
|
|
1370
|
+
// resolves the chat it belongs to; a stale Stop for a turn that already
|
|
1371
|
+
// finished resolves to nothing and marks nothing.
|
|
1372
|
+
const target = hub.sessionForJob(parsed.payload.jobId);
|
|
1373
|
+
if (target)
|
|
1374
|
+
hub.noteCancel(target.sessionId, { reason: "stop", origin: socket });
|
|
1375
|
+
void deps.herdctl.cancel(parsed.payload.jobId).catch(() => undefined);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (parsed.type === "chat:subscribe") {
|
|
1379
|
+
onSubscribe(parsed);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
if (parsed.type === "chat:set_queue") {
|
|
1383
|
+
void onSetQueue(parsed);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (parsed.type === "chat:command") {
|
|
1387
|
+
void onChatCommand(parsed);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (parsed.type === "chat:continue") {
|
|
1391
|
+
void onChatContinue(parsed);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
void onChatSend(parsed);
|
|
1395
|
+
});
|
|
1043
1396
|
};
|
|
1044
1397
|
// The socket handler PLUS the manual trigger-fire entrypoint: a "trigger now"
|
|
1045
1398
|
// action fires a schedule-type trigger on demand through the exact same hub path a
|