@edspencer/paddock 0.66.0 → 0.66.2
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/herdctl-jobs.d.ts +20 -0
- package/packages/server/dist/herdctl-jobs.js +94 -0
- package/packages/server/dist/herdctl.d.ts +46 -0
- package/packages/server/dist/herdctl.js +58 -0
- package/packages/server/dist/instance-config.d.ts +56 -5
- package/packages/server/dist/instance-config.js +206 -24
- package/packages/server/dist/project-paths.d.ts +46 -1
- package/packages/server/dist/project-paths.js +66 -2
- package/packages/server/dist/project-types.d.ts +23 -0
- package/packages/server/dist/project-types.js +35 -0
- package/packages/server/dist/projects.d.ts +5 -2
- package/packages/server/dist/projects.js +71 -17
- package/packages/server/dist/queued-message.d.ts +87 -0
- package/packages/server/dist/queued-message.js +214 -1
- package/packages/server/dist/routes/chats.js +27 -8
- package/packages/server/dist/routes/meta.js +32 -5
- package/packages/server/dist/routes/projects.js +71 -7
- 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 +128 -5
- package/packages/server/dist/ws-protocol.js +30 -13
- package/packages/server/dist/ws.d.ts +0 -52
- package/packages/server/dist/ws.js +545 -127
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-WczXTV0_.js} +1 -1
- package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
- package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DXqVnx_r.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
- package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
- package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DPY0seI-.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
- package/packages/web/dist/assets/{format-B_kAvAW3.js → format-CvRqOKZU.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
- package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
- package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
- package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-LqRymMD_.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-DPrhSs95.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
- package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +0 -2
- 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/index-B3K5nsLe.css +0 -1
- package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +0 -2
|
@@ -13,7 +13,14 @@ import { quiesceProject, turnRunningError } from "../turn-interlock.js";
|
|
|
13
13
|
* (#553): the badge's semantics are "whatever this function returns", and there
|
|
14
14
|
* is no second implementation for the root to drift from. The completed-turn
|
|
15
15
|
* side comes from the cheap job-record scan (grouped by keeper agent), so this
|
|
16
|
-
* costs no `
|
|
16
|
+
* costs no transcript parse; `live` adds one cached session listing per
|
|
17
|
+
* workspace (see {@link liveSessionIds}).
|
|
18
|
+
*
|
|
19
|
+
* Two things are pruned OUT of the raw job-record fold, and the invariant they
|
|
20
|
+
* add up to is: **the badge counts exactly what the Home Unread feed counts.**
|
|
21
|
+
* A chat that no longer exists, and a chat the user archived. #488 made read
|
|
22
|
+
* state server-authoritative so surfaces could not diverge; a badge counting
|
|
23
|
+
* chats `/chats/attention` does not is that divergence one layer up.
|
|
17
24
|
*
|
|
18
25
|
* `key` is a WORKSPACE key, so `""` (the root) is a perfectly ordinary argument:
|
|
19
26
|
* `turnsByProject` is already keyed that way (`lastTurnCompletedAtByProject`
|
|
@@ -21,12 +28,27 @@ import { quiesceProject, turnRunningError } from "../turn-interlock.js";
|
|
|
21
28
|
* `keeperAgentName("")` encodes to `keeper-_root`. Nothing here may test `key`
|
|
22
29
|
* for truthiness.
|
|
23
30
|
*/
|
|
24
|
-
async function buildChatTurns(key, turnsByProject, user, readState, unread) {
|
|
31
|
+
async function buildChatTurns(key, turnsByProject, live, user, readState, unread, archive) {
|
|
25
32
|
const bySession = turnsByProject.get(key);
|
|
26
33
|
if (!bySession)
|
|
27
34
|
return [];
|
|
28
35
|
const keeper = keeperAgentName(key);
|
|
29
|
-
|
|
36
|
+
const rows = await Promise.all([...bySession].map(async ([sessionId, lastTurnCompletedAt]) => {
|
|
37
|
+
// #732, prune 1 — a chat that no longer exists. The job records this fold
|
|
38
|
+
// reads are removed with the chat now, but that cannot heal a badge
|
|
39
|
+
// already stuck, and a transcript can leave by routes that are not a
|
|
40
|
+
// delete at all (an adoption undone, a JSONL removed by hand). `live` is
|
|
41
|
+
// `null` when the listing FAILED, which is not the same as empty: an
|
|
42
|
+
// unreadable chat store must leave the badge alone, not silently zero it.
|
|
43
|
+
if (live && !live.has(sessionId))
|
|
44
|
+
return null;
|
|
45
|
+
// #732, prune 2 — archiving is the user saying "stop bothering me about
|
|
46
|
+
// this one", and `/chats/attention` has always honoured that. The badge
|
|
47
|
+
// did not, so the sidebar could read 3 while the Home Unread feed showed
|
|
48
|
+
// nothing. One rule, both surfaces: archived is silent. It is silenced,
|
|
49
|
+
// not consumed — unarchiving brings the chat back to both.
|
|
50
|
+
if (await archive.isArchived(keeper, sessionId).catch(() => false))
|
|
51
|
+
return null;
|
|
30
52
|
const lastSeen = await readState.getLastSeen(user, keeper, sessionId).catch(() => 0);
|
|
31
53
|
// Manual unread override (#458): carry it so the sidebar's workspace
|
|
32
54
|
// unread badge counts a manually-flagged chat, staying in step with
|
|
@@ -39,14 +61,42 @@ async function buildChatTurns(key, turnsByProject, user, readState, unread) {
|
|
|
39
61
|
...(isUnread ? { unread: true } : {}),
|
|
40
62
|
};
|
|
41
63
|
}));
|
|
64
|
+
return rows.filter((r) => r !== null);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The session ids a workspace actually still has, for {@link buildChatTurns}'s
|
|
68
|
+
* prune — or `null` if we could not find out.
|
|
69
|
+
*
|
|
70
|
+
* That distinction is the whole point: a failed listing must NOT be read as "no
|
|
71
|
+
* chats", or a transient discovery error would empty the badge instead of
|
|
72
|
+
* leaving it alone. `listSessions` is a superset of the keeper chats the fold
|
|
73
|
+
* covers (it also returns hook-agent chats), which is the safe direction — it
|
|
74
|
+
* can only fail to prune, never over-prune.
|
|
75
|
+
*
|
|
76
|
+
* This costs one session listing per workspace on `GET /api/projects`, which
|
|
77
|
+
* #529 was careful to keep off this route. It is affordable because it is not
|
|
78
|
+
* the expensive half: the listing is a cached directory read (core's discovery
|
|
79
|
+
* cache is mtime-keyed with a TTL), whereas the 61%-of-CPU problem #529 fixed
|
|
80
|
+
* was YAML-parsing every job record. `/chats/attention` already does exactly
|
|
81
|
+
* this listing across the whole fleet on the Home tab.
|
|
82
|
+
*/
|
|
83
|
+
async function liveSessionIds(herdctl, project) {
|
|
84
|
+
if (!project)
|
|
85
|
+
return null;
|
|
86
|
+
try {
|
|
87
|
+
return new Set((await herdctl.listSessions(project)).map((s) => s.sessionId));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
42
92
|
}
|
|
43
93
|
export function registerProjectRoutes(app, ctx) {
|
|
44
|
-
const { projects, herdctl, git, readState, unread, readStateUser } = ctx;
|
|
94
|
+
const { projects, herdctl, git, readState, unread, archive, readStateUser } = ctx;
|
|
45
95
|
app.get("/api/projects", {
|
|
46
96
|
schema: {
|
|
47
97
|
tags: ["Projects"],
|
|
48
98
|
summary: "List projects",
|
|
49
|
-
description: "Returns `{ projects, root }`. `projects` is an array of the root workspace's CHILDREN; each entry carries the project metadata plus a compact `chatTurns` array (per-chat `{ sessionId, lastTurnCompletedAt, lastSeen? }` for the sidebar UNREAD count) and a `dirty` uncommitted-file count. `root` is the ROOT workspace itself, carrying the same `chatTurns` field so the sidebar's Home entry gets the identical badge; it is `null` only if the root record could not be read.",
|
|
99
|
+
description: "Returns `{ projects, root }`. `projects` is an array of the root workspace's CHILDREN; each entry carries the project metadata plus a compact `chatTurns` array (per-chat `{ sessionId, lastTurnCompletedAt, lastSeen? }` for the sidebar UNREAD count) and a `dirty` uncommitted-file count. `chatTurns` covers only chats that still EXIST and are not ARCHIVED, so the badge cannot outlive its chats (#732) and agrees with `/chats/attention` on archived ones. `root` is the ROOT workspace itself, carrying the same `chatTurns` field so the sidebar's Home entry gets the identical badge; it is `null` only if the root record could not be read.",
|
|
50
100
|
response: {
|
|
51
101
|
200: {
|
|
52
102
|
description: "Object with a `projects` array (each item a project enriched with `chatTurns` and `dirty`) and a `root` workspace object enriched with `chatTurns`.",
|
|
@@ -79,7 +129,7 @@ export function registerProjectRoutes(app, ctx) {
|
|
|
79
129
|
const user = readStateUser(req);
|
|
80
130
|
const projectsOut = await Promise.all(list.map(async (p) => ({
|
|
81
131
|
...p,
|
|
82
|
-
chatTurns: await buildChatTurns(p.slug, turnsByProject, user, readState, unread),
|
|
132
|
+
chatTurns: await buildChatTurns(p.slug, turnsByProject, await liveSessionIds(herdctl, p), user, readState, unread, archive),
|
|
83
133
|
// The cheap store-wide sweep above buckets by path segment under
|
|
84
134
|
// `projectsRoot`, so it can only speak for projects whose working dir is
|
|
85
135
|
// the metadata dir. For one pointing somewhere else (a linked checkout,
|
|
@@ -94,7 +144,10 @@ export function registerProjectRoutes(app, ctx) {
|
|
|
94
144
|
// a file directly in the projects root has no bucket at all. Reporting 0
|
|
95
145
|
// would be a fabricated clean; the field is optional, so it stays absent.
|
|
96
146
|
const rootOut = root
|
|
97
|
-
? {
|
|
147
|
+
? {
|
|
148
|
+
...root,
|
|
149
|
+
chatTurns: await buildChatTurns(ROOT_KEY, turnsByProject, await liveSessionIds(herdctl, root), user, readState, unread, archive),
|
|
150
|
+
}
|
|
98
151
|
: null;
|
|
99
152
|
return { projects: projectsOut, root: rootOut };
|
|
100
153
|
});
|
|
@@ -437,6 +490,17 @@ export function registerProjectWorkspaceRoutes(app, ctx) {
|
|
|
437
490
|
catch (err) {
|
|
438
491
|
req.log.warn({ err }, "keeper-agent unregister failed (project dir already removed)");
|
|
439
492
|
}
|
|
493
|
+
// #734: and its herdctl JOB RECORDS, which `remove()` cannot reach — they
|
|
494
|
+
// live in the shared fleet-wide jobs dir, not under the project. They are
|
|
495
|
+
// keyed by agent name, which is derived from the slug, which is derived
|
|
496
|
+
// from the NAME: leaving them behind means a project created later with
|
|
497
|
+
// the same name inherits this one's run history — prompt text and reply
|
|
498
|
+
// summaries — plus a phantom unread badge over zero chats.
|
|
499
|
+
await herdctl
|
|
500
|
+
.purgeProjectJobs(project.slug, Object.keys(project.hooks ?? {}), Object.keys(project.triggers ?? {}))
|
|
501
|
+
.catch((err) => {
|
|
502
|
+
req.log.warn({ err }, "job-record purge failed (project is still deleted)");
|
|
503
|
+
});
|
|
440
504
|
return reply.code(200).send({ ok: true, slug: project.slug });
|
|
441
505
|
}
|
|
442
506
|
catch (err) {
|
|
@@ -98,6 +98,17 @@ export interface ActiveInfo {
|
|
|
98
98
|
jobId: string | null;
|
|
99
99
|
running: boolean;
|
|
100
100
|
}
|
|
101
|
+
/** Why a turn is being cancelled, and which socket asked for it. */
|
|
102
|
+
export interface CancelIntent {
|
|
103
|
+
/**
|
|
104
|
+
* `stop` — a user pressed Stop (`chat:cancel`): they are taking control back.
|
|
105
|
+
* `quiesce` — a destructive op is stopping the turn so it can safely mutate
|
|
106
|
+
* the transcript (#731); the chat is being deleted, reverted or promoted.
|
|
107
|
+
*/
|
|
108
|
+
reason: "stop" | "quiesce";
|
|
109
|
+
/** The socket that asked, if any. */
|
|
110
|
+
origin: HubSocket | null;
|
|
111
|
+
}
|
|
101
112
|
export declare class SessionHub {
|
|
102
113
|
/** sessionId → the current (running or recently-completed) turn for that session. */
|
|
103
114
|
private bySession;
|
|
@@ -109,6 +120,51 @@ export declare class SessionHub {
|
|
|
109
120
|
* button restore (#52) and streaming indicators (#53). Set by the WS layer.
|
|
110
121
|
*/
|
|
111
122
|
onActive: ((info: ActiveInfo) => void) | null;
|
|
123
|
+
/**
|
|
124
|
+
* Notified once per turn, when that turn stops running (#627). Set by the WS
|
|
125
|
+
* layer, which uses it to drain a queued follow-up.
|
|
126
|
+
*
|
|
127
|
+
* This exists because "after a turn" was, for a long time, spelled as a call at
|
|
128
|
+
* ONE of the eight `turn.end()` sites — the `chat:send` completion. A message
|
|
129
|
+
* queued during a slash command, a trigger/spawn turn, a scheduler wake, a
|
|
130
|
+
* background sub-agent stretch or a Stopped turn was therefore persisted and
|
|
131
|
+
* then stranded, escaping only when some LATER `chat:send` happened to flush it —
|
|
132
|
+
* arriving after a message the user typed afterwards. Every one of those paths
|
|
133
|
+
* already funnels through {@link end}, so hanging the drain here makes "drain on
|
|
134
|
+
* every turn-ending path" structural rather than a list somebody has to remember
|
|
135
|
+
* to extend — including for turn paths that don't exist yet.
|
|
136
|
+
*
|
|
137
|
+
* Fires only once a turn's session id is known (a turn that died before its id
|
|
138
|
+
* resolved has no chat to drain), and always AFTER the terminal frame its caller
|
|
139
|
+
* emitted, so a drained follow-up's bubble lands below the turn it followed.
|
|
140
|
+
*/
|
|
141
|
+
onTurnEnd: ((info: {
|
|
142
|
+
sessionId: string;
|
|
143
|
+
projectSlug: string;
|
|
144
|
+
origin: HubSocket | null;
|
|
145
|
+
/** Set when this end was CAUSED by an explicit cancel — see {@link noteCancel}. */
|
|
146
|
+
cancel?: CancelIntent;
|
|
147
|
+
}) => void) | null;
|
|
148
|
+
/**
|
|
149
|
+
* Why a turn was cancelled, and by whom, pending the end it causes.
|
|
150
|
+
* Consumed by the next {@link end} for that session.
|
|
151
|
+
*/
|
|
152
|
+
private cancels;
|
|
153
|
+
/**
|
|
154
|
+
* Record that a cancel was just issued for `sessionId`, so the turn end it
|
|
155
|
+
* causes can be told apart from a turn that finished on its own.
|
|
156
|
+
*
|
|
157
|
+
* The distinction is not cosmetic: "the turn ended" and "the user took control
|
|
158
|
+
* back" call for opposite treatment of anything queued behind that turn. A
|
|
159
|
+
* `stop` hands the queued message back to the client that pressed Stop; a
|
|
160
|
+
* `quiesce` (the destructive-op interlock, #731) must not touch it at all —
|
|
161
|
+
* that caller is about to delete or rewrite the transcript, and starting a
|
|
162
|
+
* fresh turn into it is the resurrection #730 was about.
|
|
163
|
+
*
|
|
164
|
+
* `origin` is the socket that asked, so the WS layer knows where to hand the
|
|
165
|
+
* message back; null when the cancel came from no socket at all.
|
|
166
|
+
*/
|
|
167
|
+
noteCancel(sessionId: string, intent: CancelIntent): void;
|
|
112
168
|
/**
|
|
113
169
|
* Begin tracking a turn. Pass `sessionId` when it's already known (a resumed
|
|
114
170
|
* chat / a slash command) so the turn is re-attachable from its very first
|
|
@@ -143,7 +199,19 @@ export declare class SessionHub {
|
|
|
143
199
|
* like `chat:queued_flushed` that must reach a client that reconnected on a new
|
|
144
200
|
* socket after the origin died.
|
|
145
201
|
*/
|
|
146
|
-
broadcast(sessionId: string, msg: HubMessage
|
|
202
|
+
broadcast(sessionId: string, msg: HubMessage, opts?: {
|
|
203
|
+
except?: HubSocket | null;
|
|
204
|
+
}): void;
|
|
205
|
+
/**
|
|
206
|
+
* The session a cancellable job id belongs to, or null if no tracked turn
|
|
207
|
+
* carries it. `chat:cancel` names only a job id, so this is how the WS layer
|
|
208
|
+
* learns WHICH chat a Stop was aimed at. Covers a background stretch too — it
|
|
209
|
+
* publishes its synthetic id on its own hub turn (#528).
|
|
210
|
+
*/
|
|
211
|
+
sessionForJob(jobId: string): {
|
|
212
|
+
sessionId: string;
|
|
213
|
+
projectSlug: string;
|
|
214
|
+
} | null;
|
|
147
215
|
/** Whether a session currently has a RUNNING turn plus its job id, or null. */
|
|
148
216
|
activeInfo(sessionId: string): ActiveInfo | null;
|
|
149
217
|
/** Snapshot of every session with a running turn (for a newly-connected client). */
|
|
@@ -50,6 +50,47 @@ export class SessionHub {
|
|
|
50
50
|
* button restore (#52) and streaming indicators (#53). Set by the WS layer.
|
|
51
51
|
*/
|
|
52
52
|
onActive = null;
|
|
53
|
+
/**
|
|
54
|
+
* Notified once per turn, when that turn stops running (#627). Set by the WS
|
|
55
|
+
* layer, which uses it to drain a queued follow-up.
|
|
56
|
+
*
|
|
57
|
+
* This exists because "after a turn" was, for a long time, spelled as a call at
|
|
58
|
+
* ONE of the eight `turn.end()` sites — the `chat:send` completion. A message
|
|
59
|
+
* queued during a slash command, a trigger/spawn turn, a scheduler wake, a
|
|
60
|
+
* background sub-agent stretch or a Stopped turn was therefore persisted and
|
|
61
|
+
* then stranded, escaping only when some LATER `chat:send` happened to flush it —
|
|
62
|
+
* arriving after a message the user typed afterwards. Every one of those paths
|
|
63
|
+
* already funnels through {@link end}, so hanging the drain here makes "drain on
|
|
64
|
+
* every turn-ending path" structural rather than a list somebody has to remember
|
|
65
|
+
* to extend — including for turn paths that don't exist yet.
|
|
66
|
+
*
|
|
67
|
+
* Fires only once a turn's session id is known (a turn that died before its id
|
|
68
|
+
* resolved has no chat to drain), and always AFTER the terminal frame its caller
|
|
69
|
+
* emitted, so a drained follow-up's bubble lands below the turn it followed.
|
|
70
|
+
*/
|
|
71
|
+
onTurnEnd = null;
|
|
72
|
+
/**
|
|
73
|
+
* Why a turn was cancelled, and by whom, pending the end it causes.
|
|
74
|
+
* Consumed by the next {@link end} for that session.
|
|
75
|
+
*/
|
|
76
|
+
cancels = new Map();
|
|
77
|
+
/**
|
|
78
|
+
* Record that a cancel was just issued for `sessionId`, so the turn end it
|
|
79
|
+
* causes can be told apart from a turn that finished on its own.
|
|
80
|
+
*
|
|
81
|
+
* The distinction is not cosmetic: "the turn ended" and "the user took control
|
|
82
|
+
* back" call for opposite treatment of anything queued behind that turn. A
|
|
83
|
+
* `stop` hands the queued message back to the client that pressed Stop; a
|
|
84
|
+
* `quiesce` (the destructive-op interlock, #731) must not touch it at all —
|
|
85
|
+
* that caller is about to delete or rewrite the transcript, and starting a
|
|
86
|
+
* fresh turn into it is the resurrection #730 was about.
|
|
87
|
+
*
|
|
88
|
+
* `origin` is the socket that asked, so the WS layer knows where to hand the
|
|
89
|
+
* message back; null when the cancel came from no socket at all.
|
|
90
|
+
*/
|
|
91
|
+
noteCancel(sessionId, intent) {
|
|
92
|
+
this.cancels.set(sessionId, intent);
|
|
93
|
+
}
|
|
53
94
|
/**
|
|
54
95
|
* Begin tracking a turn. Pass `sessionId` when it's already known (a resumed
|
|
55
96
|
* chat / a slash command) so the turn is re-attachable from its very first
|
|
@@ -129,7 +170,7 @@ export class SessionHub {
|
|
|
129
170
|
* like `chat:queued_flushed` that must reach a client that reconnected on a new
|
|
130
171
|
* socket after the origin died.
|
|
131
172
|
*/
|
|
132
|
-
broadcast(sessionId, msg) {
|
|
173
|
+
broadcast(sessionId, msg, opts = {}) {
|
|
133
174
|
const set = new Set();
|
|
134
175
|
const turn = this.bySession.get(sessionId);
|
|
135
176
|
if (turn?.origin)
|
|
@@ -138,9 +179,24 @@ export class SessionHub {
|
|
|
138
179
|
if (subs)
|
|
139
180
|
for (const s of subs)
|
|
140
181
|
set.add(s);
|
|
182
|
+
if (opts.except)
|
|
183
|
+
set.delete(opts.except);
|
|
141
184
|
for (const s of set)
|
|
142
185
|
SessionHub.write(s, msg);
|
|
143
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The session a cancellable job id belongs to, or null if no tracked turn
|
|
189
|
+
* carries it. `chat:cancel` names only a job id, so this is how the WS layer
|
|
190
|
+
* learns WHICH chat a Stop was aimed at. Covers a background stretch too — it
|
|
191
|
+
* publishes its synthetic id on its own hub turn (#528).
|
|
192
|
+
*/
|
|
193
|
+
sessionForJob(jobId) {
|
|
194
|
+
for (const [sessionId, turn] of this.bySession) {
|
|
195
|
+
if (turn.jobId === jobId)
|
|
196
|
+
return { sessionId, projectSlug: turn.projectSlug };
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
144
200
|
// --- internals, driven by TurnHandle -------------------------------------
|
|
145
201
|
/** Whether a session currently has a RUNNING turn plus its job id, or null. */
|
|
146
202
|
activeInfo(sessionId) {
|
|
@@ -191,11 +247,27 @@ export class SessionHub {
|
|
|
191
247
|
}
|
|
192
248
|
/** Mark a turn finished; retain its buffer briefly for an end-of-turn reconnect. */
|
|
193
249
|
end(turn) {
|
|
250
|
+
if (!turn.running)
|
|
251
|
+
return; // already ended — never fire onTurnEnd twice
|
|
194
252
|
turn.running = false;
|
|
195
253
|
if (turn.sessionId) {
|
|
196
254
|
this.fireActive(turn);
|
|
197
255
|
turn.evictTimer = setTimeout(() => this.evict(turn), COMPLETED_TTL_MS);
|
|
198
256
|
turn.evictTimer.unref?.();
|
|
257
|
+
// #627: the session is idle again — let the WS layer deal with anything
|
|
258
|
+
// queued behind this turn. Never allowed to throw into a caller finalizing
|
|
259
|
+
// its turn. A pending cancel intent belongs to THIS end, so consume it.
|
|
260
|
+
const cancel = this.cancels.get(turn.sessionId);
|
|
261
|
+
this.cancels.delete(turn.sessionId);
|
|
262
|
+
if (this.onTurnEnd) {
|
|
263
|
+
const { sessionId, projectSlug, origin } = turn;
|
|
264
|
+
try {
|
|
265
|
+
this.onTurnEnd({ sessionId, projectSlug, origin, ...(cancel ? { cancel } : {}) });
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* a drain that throws must not break turn teardown */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
199
271
|
}
|
|
200
272
|
}
|
|
201
273
|
/**
|
|
@@ -29,6 +29,14 @@ export async function quiesceSession(deps, sessionId, opts = {}) {
|
|
|
29
29
|
const deadline = Date.now() + (opts.timeoutMs ?? DEFAULT_QUIESCE_TIMEOUT_MS);
|
|
30
30
|
const cancelled = new Set();
|
|
31
31
|
let reaped = false;
|
|
32
|
+
// Mark the turn end this is about to cause as OURS, not a user's Stop. The WS
|
|
33
|
+
// layer treats the two oppositely for anything queued behind the turn: a Stop
|
|
34
|
+
// hands the message back to the user's composer, while this caller is about to
|
|
35
|
+
// delete, revert or promote the transcript — so the queue must be left strictly
|
|
36
|
+
// alone. Draining it here would start a fresh turn on a chat being removed (the
|
|
37
|
+
// #730 resurrection), and would make the session busy again so the poll below
|
|
38
|
+
// could never settle.
|
|
39
|
+
deps.hub?.noteCancel(sessionId, { reason: "quiesce", origin: null });
|
|
32
40
|
for (;;) {
|
|
33
41
|
const jobId = deps.hub?.activeInfo(sessionId)?.jobId ?? null;
|
|
34
42
|
if (jobId && !cancelled.has(jobId)) {
|
|
@@ -214,12 +214,45 @@ export interface ChatSetQueueMessage {
|
|
|
214
214
|
/** The queued message text, or null/empty to clear. */
|
|
215
215
|
text?: string | null;
|
|
216
216
|
/**
|
|
217
|
-
*
|
|
218
|
-
* when
|
|
219
|
-
*
|
|
220
|
-
*
|
|
217
|
+
* OPAQUE identity of this client's queue (#245 stable identity, #736). Minted
|
|
218
|
+
* once when the queue is first created and kept across edits, appends and
|
|
219
|
+
* reloads, so the server can tell (a) this client updating its own queue apart
|
|
220
|
+
* from another client queueing alongside it (#629) and (b) a stale re-assert of
|
|
221
|
+
* an already-drained message apart from a new one — by EQUALITY, never ordering.
|
|
222
|
+
*
|
|
223
|
+
* Absent from an older client, which sends only `ts`; the server folds that
|
|
224
|
+
* into an id.
|
|
225
|
+
*/
|
|
226
|
+
qid?: string | null;
|
|
227
|
+
/**
|
|
228
|
+
* Legacy client-stamped enqueue time (#245). Superseded by `qid`: it was used
|
|
229
|
+
* as an ORDERED identity, so one client with a fast clock poisoned the dedup
|
|
230
|
+
* marker and every later queued message on that chat — from any client — was
|
|
231
|
+
* silently destroyed (#736). Still accepted (older clients send it, and it
|
|
232
|
+
* still identifies a message) but only ever compared for equality, and never
|
|
233
|
+
* stored as the enqueue time — the server stamps that itself.
|
|
221
234
|
*/
|
|
222
235
|
ts?: number | null;
|
|
236
|
+
/**
|
|
237
|
+
* Files staged in the composer that must ride the queued message (#728).
|
|
238
|
+
*
|
|
239
|
+
* Attachments used to be consumed ONLY by `chat:send`, so a message queued
|
|
240
|
+
* during a live turn left them staged: the tray never cleared and the file
|
|
241
|
+
* silently rode whatever the user sent next — a message they never meant to
|
|
242
|
+
* attach it to. They belong to the queued message, so they travel with it into
|
|
243
|
+
* the shared slot and are sent by the drain.
|
|
244
|
+
*
|
|
245
|
+
* Merged into the slot as a UNION BY ID, never a replace: a write can only
|
|
246
|
+
* ever ADD to what is staged, so one client's `set_queue` cannot silently drop
|
|
247
|
+
* another's file, and a client re-asserting its queue on reload (which by then
|
|
248
|
+
* has an empty tray) is a no-op rather than a wipe. Attachments leave the slot
|
|
249
|
+
* only when it is cleared outright or popped back to a composer.
|
|
250
|
+
*/
|
|
251
|
+
attachments?: Array<{
|
|
252
|
+
id: string;
|
|
253
|
+
filename: string;
|
|
254
|
+
kind?: string;
|
|
255
|
+
}>;
|
|
223
256
|
};
|
|
224
257
|
}
|
|
225
258
|
/**
|
|
@@ -380,6 +413,96 @@ export interface ChatQueuedFlushedMessage {
|
|
|
380
413
|
* when the frame is just telling the client to clear a stale/already-sent copy.
|
|
381
414
|
*/
|
|
382
415
|
text?: string;
|
|
416
|
+
/**
|
|
417
|
+
* The attachments that rode the drained message (#728), so the client renders
|
|
418
|
+
* the auto-sent user bubble with its files exactly as a live send does. Only
|
|
419
|
+
* present alongside `text` (a clear-only frame carries neither).
|
|
420
|
+
*/
|
|
421
|
+
attachments?: Array<{
|
|
422
|
+
id: string;
|
|
423
|
+
filename: string;
|
|
424
|
+
kind?: string;
|
|
425
|
+
}>;
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The chat's queued message changed (#629). Broadcast to EVERY socket attached to
|
|
430
|
+
* the session whenever the slot is written, so the queue is shared chat state that
|
|
431
|
+
* all clients render identically instead of invisible per-client state.
|
|
432
|
+
*
|
|
433
|
+
* Before this, a queue was only ever written and never announced: a second client
|
|
434
|
+
* queueing on the same chat overwrote the first client's text with no signal to
|
|
435
|
+
* anyone. The first user's chip sat there showing a message that no longer existed,
|
|
436
|
+
* and when the drain finally fired, their transcript rendered — as their own user
|
|
437
|
+
* bubble — a message they had never typed. The slot now MERGES contributions, and
|
|
438
|
+
* this frame is what makes the merge visible.
|
|
439
|
+
*/
|
|
440
|
+
export interface ChatQueuedStateMessage {
|
|
441
|
+
type: "chat:queued_state";
|
|
442
|
+
payload: {
|
|
443
|
+
projectSlug: string;
|
|
444
|
+
sessionId: string;
|
|
445
|
+
/** The chat's full queued text, or null when the slot is now empty. */
|
|
446
|
+
text: string | null;
|
|
447
|
+
/**
|
|
448
|
+
* The files staged on the slot (#728). Shared exactly like the text is: every
|
|
449
|
+
* attached client renders the same chips, so a file queued on one device is
|
|
450
|
+
* visible on the other rather than being invisible state that surfaces only
|
|
451
|
+
* when the drain sends it. Absent/empty when the slot holds no attachments.
|
|
452
|
+
*/
|
|
453
|
+
attachments?: Array<{
|
|
454
|
+
id: string;
|
|
455
|
+
filename: string;
|
|
456
|
+
kind?: string;
|
|
457
|
+
}>;
|
|
458
|
+
/**
|
|
459
|
+
* The slot's identity. A client adopts it as its own queue id, so its next
|
|
460
|
+
* edit updates this shared slot in place rather than appending beside it.
|
|
461
|
+
*/
|
|
462
|
+
qid?: string;
|
|
463
|
+
/**
|
|
464
|
+
* Why the slot changed, when it is worth explaining. `returned` means a user
|
|
465
|
+
* pressed Stop and the message went back to THEIR composer — sent to the
|
|
466
|
+
* other clients so a chip that just vanished from under them has a reason
|
|
467
|
+
* attached, rather than looking like the message evaporated. The client that
|
|
468
|
+
* pressed Stop is excluded from this broadcast; it gets
|
|
469
|
+
* {@link ChatQueuedReturnedMessage} instead.
|
|
470
|
+
*/
|
|
471
|
+
reason?: "returned";
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* A user pressed Stop, so the message queued behind that turn is handed BACK to
|
|
476
|
+
* them (#751 follow-up). Sent only to the socket that asked to cancel.
|
|
477
|
+
*
|
|
478
|
+
* Stop means "give me control back". Auto-sending the follow-up would have the
|
|
479
|
+
* agent start working again the instant the user stopped it, and holding the
|
|
480
|
+
* message server-side is what stranded it (#627) — so it returns to the composer,
|
|
481
|
+
* where it is visible, editable, and sent only when the user says so.
|
|
482
|
+
*
|
|
483
|
+
* Deliberately NOT `chat:queued_flushed` with a flag: that frame means "this text
|
|
484
|
+
* was sent, render it as the user's bubble", and a returned message is precisely
|
|
485
|
+
* one that was NOT sent. Overloading it risks a phantom user turn in the
|
|
486
|
+
* transcript for a message the agent never received.
|
|
487
|
+
*/
|
|
488
|
+
export interface ChatQueuedReturnedMessage {
|
|
489
|
+
type: "chat:queued_returned";
|
|
490
|
+
payload: {
|
|
491
|
+
projectSlug: string;
|
|
492
|
+
sessionId: string;
|
|
493
|
+
/** The queued text, for the client to put back in its composer. */
|
|
494
|
+
text: string;
|
|
495
|
+
/**
|
|
496
|
+
* The files that were staged on the slot (#728), for the client to put back in
|
|
497
|
+
* its attachment tray. Stop returns the whole message, not just its prose —
|
|
498
|
+
* dropping the files here would be the same silent loss under a different name,
|
|
499
|
+
* and leaving them on the (now cleared) slot would strand them server-side.
|
|
500
|
+
*/
|
|
501
|
+
attachments?: Array<{
|
|
502
|
+
id: string;
|
|
503
|
+
filename: string;
|
|
504
|
+
kind?: string;
|
|
505
|
+
}>;
|
|
383
506
|
};
|
|
384
507
|
}
|
|
385
508
|
/**
|
|
@@ -418,6 +541,6 @@ export interface ChatNoticeMessage {
|
|
|
418
541
|
export interface PongMessage {
|
|
419
542
|
type: "pong";
|
|
420
543
|
}
|
|
421
|
-
export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatKilledTaskMessage | ChatNoticeMessage | PongMessage;
|
|
544
|
+
export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatQueuedStateMessage | ChatQueuedReturnedMessage | ChatKilledTaskMessage | ChatNoticeMessage | PongMessage;
|
|
422
545
|
export declare function isClientMessage(data: unknown): data is ClientMessage;
|
|
423
546
|
export {};
|
|
@@ -166,6 +166,28 @@ export function resolveTurnUsage(state) {
|
|
|
166
166
|
outputTokens: Math.max(base.outputTokens, state.cumulative?.outputTokens ?? 0),
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* Validate an optional wire list of composer attachment refs (#328/#728). Shared
|
|
171
|
+
* by `chat:send` and `chat:set_queue` — the queue carries the same refs the send
|
|
172
|
+
* does, so it validates them the same way rather than by a second, drifting copy.
|
|
173
|
+
* `undefined` is valid (the field is optional everywhere it appears).
|
|
174
|
+
*/
|
|
175
|
+
function isAttachmentRefList(v) {
|
|
176
|
+
if (v === undefined)
|
|
177
|
+
return true;
|
|
178
|
+
if (!Array.isArray(v))
|
|
179
|
+
return false;
|
|
180
|
+
for (const a of v) {
|
|
181
|
+
if (!a || typeof a !== "object")
|
|
182
|
+
return false;
|
|
183
|
+
const ao = a;
|
|
184
|
+
if (typeof ao.id !== "string" || typeof ao.filename !== "string")
|
|
185
|
+
return false;
|
|
186
|
+
if (ao.kind !== undefined && typeof ao.kind !== "string")
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
169
191
|
export function isClientMessage(data) {
|
|
170
192
|
if (typeof data !== "object" || data === null)
|
|
171
193
|
return false;
|
|
@@ -187,19 +209,8 @@ export function isClientMessage(data) {
|
|
|
187
209
|
return false;
|
|
188
210
|
if (p.model !== undefined && typeof p.model !== "string")
|
|
189
211
|
return false;
|
|
190
|
-
if (p.attachments
|
|
191
|
-
|
|
192
|
-
return false;
|
|
193
|
-
for (const a of p.attachments) {
|
|
194
|
-
if (!a || typeof a !== "object")
|
|
195
|
-
return false;
|
|
196
|
-
const ao = a;
|
|
197
|
-
if (typeof ao.id !== "string" || typeof ao.filename !== "string")
|
|
198
|
-
return false;
|
|
199
|
-
if (ao.kind !== undefined && typeof ao.kind !== "string")
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
212
|
+
if (!isAttachmentRefList(p.attachments))
|
|
213
|
+
return false;
|
|
203
214
|
return p.sessionId === undefined || p.sessionId === null || typeof p.sessionId === "string";
|
|
204
215
|
}
|
|
205
216
|
if (m.type === "chat:command") {
|
|
@@ -245,6 +256,12 @@ export function isClientMessage(data) {
|
|
|
245
256
|
return false;
|
|
246
257
|
if (p.ts !== undefined && p.ts !== null && typeof p.ts !== "number")
|
|
247
258
|
return false;
|
|
259
|
+
if (p.qid !== undefined && p.qid !== null && typeof p.qid !== "string")
|
|
260
|
+
return false;
|
|
261
|
+
// The queued message carries the composer's staged files (#728), validated
|
|
262
|
+
// exactly as `chat:send` validates its own.
|
|
263
|
+
if (!isAttachmentRefList(p.attachments))
|
|
264
|
+
return false;
|
|
248
265
|
return true;
|
|
249
266
|
}
|
|
250
267
|
return false;
|
|
@@ -1,55 +1,3 @@
|
|
|
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
1
|
import type { WebSocket } from "@fastify/websocket";
|
|
54
2
|
export * from "./ws-protocol.js";
|
|
55
3
|
import type { ChatHandlerDeps } from "./ws-context.js";
|