@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.
Files changed (94) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.d.ts +8 -0
  3. package/packages/server/dist/app.js +4 -1
  4. package/packages/server/dist/herdctl-jobs.d.ts +20 -0
  5. package/packages/server/dist/herdctl-jobs.js +94 -0
  6. package/packages/server/dist/herdctl.d.ts +46 -0
  7. package/packages/server/dist/herdctl.js +58 -0
  8. package/packages/server/dist/instance-config.d.ts +56 -5
  9. package/packages/server/dist/instance-config.js +206 -24
  10. package/packages/server/dist/project-paths.d.ts +46 -1
  11. package/packages/server/dist/project-paths.js +66 -2
  12. package/packages/server/dist/project-types.d.ts +23 -0
  13. package/packages/server/dist/project-types.js +35 -0
  14. package/packages/server/dist/projects.d.ts +5 -2
  15. package/packages/server/dist/projects.js +71 -17
  16. package/packages/server/dist/queued-message.d.ts +87 -0
  17. package/packages/server/dist/queued-message.js +214 -1
  18. package/packages/server/dist/routes/chats.js +27 -8
  19. package/packages/server/dist/routes/meta.js +32 -5
  20. package/packages/server/dist/routes/projects.js +71 -7
  21. package/packages/server/dist/session-hub.d.ts +69 -1
  22. package/packages/server/dist/session-hub.js +73 -1
  23. package/packages/server/dist/turn-interlock.js +8 -0
  24. package/packages/server/dist/ws-protocol.d.ts +128 -5
  25. package/packages/server/dist/ws-protocol.js +30 -13
  26. package/packages/server/dist/ws.d.ts +0 -52
  27. package/packages/server/dist/ws.js +545 -127
  28. package/packages/server/package.json +1 -1
  29. package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
  30. package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
  31. package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-WczXTV0_.js} +1 -1
  32. package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
  33. package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
  34. package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DXqVnx_r.js} +1 -1
  35. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
  36. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
  37. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
  38. package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
  39. package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
  47. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
  48. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
  49. package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DPY0seI-.js} +1 -1
  50. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
  51. package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
  57. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
  58. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
  59. package/packages/web/dist/assets/{format-B_kAvAW3.js → format-CvRqOKZU.js} +1 -1
  60. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
  61. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
  62. package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
  63. package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
  64. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
  65. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
  66. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
  67. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
  68. package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-LqRymMD_.js} +1 -1
  69. package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-DPrhSs95.js} +4 -4
  70. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
  71. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
  72. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
  73. package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
  74. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
  75. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
  76. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
  77. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
  78. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
  79. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
  80. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
  81. package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
  82. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
  83. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
  84. package/packages/web/dist/index.html +2 -2
  85. package/packages/web/dist/sw.js +1 -1
  86. package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +0 -2
  87. package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +0 -13
  88. package/packages/web/dist/assets/channel-C4kPYn_d.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +0 -2
  90. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +0 -2
  91. package/packages/web/dist/assets/index-B-QAOq9a.js +0 -71
  92. package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
  93. package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
  94. 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 `listSessions` fan-out and no transcript parse.
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
- return Promise.all([...bySession].map(async ([sessionId, lastTurnCompletedAt]) => {
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
- ? { ...root, chatTurns: await buildChatTurns(ROOT_KEY, turnsByProject, user, readState, unread) }
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): void;
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
- * Client-stamped identity of this queued message (#245): the ms timestamp of
218
- * when it was first enqueued, stable across reloads. The server records the ts
219
- * of the message it last drained per session so it can skip re-sending a stale
220
- * copy a reloaded client re-asserts. Absent → the server stamps `Date.now()`.
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 !== undefined) {
191
- if (!Array.isArray(p.attachments))
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";