@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.
Files changed (76) 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/queued-message.d.ts +57 -0
  5. package/packages/server/dist/queued-message.js +135 -1
  6. package/packages/server/dist/session-hub.d.ts +69 -1
  7. package/packages/server/dist/session-hub.js +73 -1
  8. package/packages/server/dist/turn-interlock.js +8 -0
  9. package/packages/server/dist/ws-protocol.d.ts +76 -5
  10. package/packages/server/dist/ws-protocol.js +2 -0
  11. package/packages/server/dist/ws.d.ts +0 -52
  12. package/packages/server/dist/ws.js +472 -119
  13. package/packages/server/package.json +1 -1
  14. package/packages/web/dist/assets/{InstanceConfigPage-DBrwtZjx.js → InstanceConfigPage-1BzMnJPo.js} +1 -1
  15. package/packages/web/dist/assets/{MarkdownRenderer-Bn6VPBdL.js → MarkdownRenderer-CRQP3Cfe.js} +1 -1
  16. package/packages/web/dist/assets/{ProjectRedirect-CQmDBtd-.js → ProjectRedirect-Dd3h5V8h.js} +1 -1
  17. package/packages/web/dist/assets/ProjectView-BRk6l152.js +13 -0
  18. package/packages/web/dist/assets/{ProjectsGrid-BW56zqke.js → ProjectsGrid-BaUE8zaF.js} +1 -1
  19. package/packages/web/dist/assets/{arc-CGy6WkLl.js → arc-DqMSW0As.js} +1 -1
  20. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DJH1WIxY.js → architectureDiagram-3BPJPVTR-CNkQD1jU.js} +1 -1
  21. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Cufb_pvX.js → blockDiagram-GPEHLZMM-BCMiOmt6.js} +1 -1
  22. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-glqeg1Ky.js → c4Diagram-AAUBKEIU-CU2soCji.js} +1 -1
  23. package/packages/web/dist/assets/channel-Ynyq4GPh.js +2 -0
  24. package/packages/web/dist/assets/{chunk-2J33WTMH-SOFTHYgh.js → chunk-2J33WTMH-nKhlJNTt.js} +1 -1
  25. package/packages/web/dist/assets/{chunk-4BX2VUAB-DFxFAiG8.js → chunk-4BX2VUAB-YRTZj9zI.js} +1 -1
  26. package/packages/web/dist/assets/{chunk-55IACEB6-CEEDxZVG.js → chunk-55IACEB6-HO60VE45.js} +1 -1
  27. package/packages/web/dist/assets/{chunk-727SXJPM--3eiptsh.js → chunk-727SXJPM-WZJH-boe.js} +1 -1
  28. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DIHIugCo.js → chunk-AQP2D5EJ-PvDC7gYB.js} +1 -1
  29. package/packages/web/dist/assets/{chunk-FMBD7UC4-BmxWZl5c.js → chunk-FMBD7UC4-BNRMTplH.js} +1 -1
  30. package/packages/web/dist/assets/{chunk-ND2GUHAM-hMHtJs_I.js → chunk-ND2GUHAM-2Q2ei8TR.js} +1 -1
  31. package/packages/web/dist/assets/{chunk-QZHKN3VN-CRnnasyy.js → chunk-QZHKN3VN-CG9VV7M6.js} +1 -1
  32. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +2 -0
  33. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +2 -0
  34. package/packages/web/dist/assets/{core-CGW2CVAY.js → core-DwSEw_oE.js} +1 -1
  35. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-kcl9qSfK.js → cose-bilkent-S5V4N54A-BlMrBI7K.js} +1 -1
  36. package/packages/web/dist/assets/{dagre-BM42HDAG-BiLsFx1W.js → dagre-BM42HDAG-tNoaleNU.js} +1 -1
  37. package/packages/web/dist/assets/{diagram-2AECGRRQ-BcIpoY66.js → diagram-2AECGRRQ-DRVCTpHL.js} +1 -1
  38. package/packages/web/dist/assets/{diagram-5GNKFQAL-BK5p5kJa.js → diagram-5GNKFQAL-BdS-96WV.js} +1 -1
  39. package/packages/web/dist/assets/{diagram-KO2AKTUF-BmJYalCP.js → diagram-KO2AKTUF-Dv1yxz3s.js} +1 -1
  40. package/packages/web/dist/assets/{diagram-LMA3HP47-BHs0SWEM.js → diagram-LMA3HP47-BNXgifuS.js} +1 -1
  41. package/packages/web/dist/assets/{diagram-OG6HWLK6-CSs8hie4.js → diagram-OG6HWLK6-wlO0bxwP.js} +1 -1
  42. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-4qd_Td27.js → erDiagram-TEJ5UH35-ChoppAEq.js} +1 -1
  43. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CstUgr7V.js → flowDiagram-I6XJVG4X-C__aYyei.js} +1 -1
  44. package/packages/web/dist/assets/{format-B_kAvAW3.js → format-u-YtEZFd.js} +1 -1
  45. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DTn9OuIc.js → ganttDiagram-6RSMTGT7-CjRe8foW.js} +1 -1
  46. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-E_bCor2O.js → gitGraphDiagram-PVQCEYII-BGDQjiWq.js} +1 -1
  47. package/packages/web/dist/assets/index-Cc5bQxZk.js +71 -0
  48. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-foRUrIOQ.js → infoDiagram-5YYISTIA-BP-JaX-8.js} +1 -1
  49. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CTON8v5t.js → ishikawaDiagram-YF4QCWOH-YnsAJBvC.js} +1 -1
  50. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-D-qwGjr7.js → journeyDiagram-JHISSGLW-BM6JWTV9.js} +1 -1
  51. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-5l4y83rE.js → kanban-definition-UN3LZRKU-gR2O0IaL.js} +1 -1
  52. package/packages/web/dist/assets/{linear-B1GiUWpI.js → linear-Dolq_eLR.js} +1 -1
  53. package/packages/web/dist/assets/{mermaid.core-btQIOU6d.js → mermaid.core-D1T_GQW7.js} +4 -4
  54. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DduczfMv.js → mindmap-definition-RKZ34NQL-C3bzIyLZ.js} +1 -1
  55. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D86uGusc.js → pieDiagram-4H26LBE5-BrZ3ANGf.js} +1 -1
  56. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js → quadrantDiagram-W4KKPZXB-BvLCHAEi.js} +1 -1
  57. package/packages/web/dist/assets/queued-DGmoZW59.js +2 -0
  58. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C9aZJK93.js → requirementDiagram-4Y6WPE33-CCzXAeaZ.js} +1 -1
  59. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-b__W6SAJ.js → sankeyDiagram-5OEKKPKP-2Gf-ERTg.js} +1 -1
  60. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Lr4Df8kX.js → sequenceDiagram-3UESZ5HK-DMjDe28d.js} +1 -1
  61. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-R0XxGxD5.js → stateDiagram-AJRCARHV-CENtj5dw.js} +1 -1
  62. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +2 -0
  63. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-D42Je9LW.js → timeline-definition-PNZ67QCA-BW4gCpBB.js} +1 -1
  64. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-KCu14-QB.js → vennDiagram-CIIHVFJN-CiIuqcT9.js} +1 -1
  65. package/packages/web/dist/assets/{wardley-L42UT6IY-Cfir3V9o.js → wardley-L42UT6IY-BQ1BPr5R.js} +1 -1
  66. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BkXjbTXu.js → wardleyDiagram-YWT4CUSO-DBq9Ailv.js} +1 -1
  67. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BVT60Pln.js → xychartDiagram-2RQKCTM6-D24VDECy.js} +1 -1
  68. package/packages/web/dist/index.html +1 -1
  69. package/packages/web/dist/sw.js +1 -1
  70. package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +0 -13
  71. package/packages/web/dist/assets/channel-C4kPYn_d.js +0 -2
  72. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +0 -2
  73. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +0 -2
  74. package/packages/web/dist/assets/index-B-QAOq9a.js +0 -71
  75. package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
  76. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.66.0",
3
+ "version": "0.66.1",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -16,6 +16,7 @@ import { HerdctlService } from "./herdctl.js";
16
16
  import { GitService } from "./git.js";
17
17
  import { GithubAuth } from "./github-auth.js";
18
18
  import { type Transcriber } from "./transcribe.js";
19
+ import type { SessionHub } from "./session-hub.js";
19
20
  import { SweepService } from "./sweep.js";
20
21
  import { ArchiveStore } from "./archive.js";
21
22
  import { StarStore } from "./star.js";
@@ -49,6 +50,13 @@ export interface BuiltApp {
49
50
  events: PaddockEventBus;
50
51
  /** Unified trigger registry (Epic T / T1) — the sole trigger CRUD surface. */
51
52
  triggers: TriggerService;
53
+ /**
54
+ * The WS layer's session hub — every in-flight turn, and the fan-out to the
55
+ * sockets watching it. Exposed for callers that need to observe or annotate a
56
+ * turn without holding a socket (the destructive-op interlock reaches it the
57
+ * same way, via the route context).
58
+ */
59
+ hub: SessionHub;
52
60
  /** Tear down the fleet + close the server (no process.exit, for tests). */
53
61
  close: () => Promise<void>;
54
62
  }
@@ -399,5 +399,8 @@ export async function buildApp(opts = {}) {
399
399
  await herdctl.stop().catch(() => undefined);
400
400
  await app.close().catch(() => undefined);
401
401
  };
402
- return { app, cfg, projects, herdctl, git, githubAuth, sweep, archive, star, readState, unread, parentDetach, runProvenance, queuedMessage, transcriber, events, triggers, close };
402
+ // `hub` rides along like every other built piece: it is the WS layer's turn
403
+ // bookkeeping, and the only place a caller can observe or annotate a turn
404
+ // without a live socket (see SessionHub.noteCancel).
405
+ return { app, cfg, projects, herdctl, git, githubAuth, sweep, archive, star, readState, unread, parentDetach, runProvenance, queuedMessage, transcriber, events, triggers, hub: chatHandler.managementOpsContext.hub, close };
403
406
  }
@@ -1,8 +1,38 @@
1
+ /**
2
+ * One identity a chat's queued message has been known by.
3
+ *
4
+ * `id` is OPAQUE and compared only for EQUALITY — never ordered — so a client with
5
+ * a skewed clock can't poison anything (#736). Two kinds of id land here: the one a
6
+ * CLIENT sent with its `chat:set_queue`, and the slot VERSION the server minted in
7
+ * response and broadcast back. A client re-asserting a queue on reload sends one of
8
+ * the two, which is exactly what makes the drain able to recognise it (see
9
+ * `drainQueue` in ws.ts).
10
+ */
11
+ export interface QueuedPart {
12
+ id: string;
13
+ text: string;
14
+ }
1
15
  /** A queued message awaiting auto-send. */
2
16
  export interface QueuedMessage {
17
+ /** The slot's full text — what gets sent. */
3
18
  text: string;
19
+ /** SERVER-stamped enqueue time. Informational — never load-bearing (#736). */
4
20
  createdAtMs: number;
21
+ /**
22
+ * The slot's current VERSION, minted on every write and broadcast to every
23
+ * attached client. A client echoes back the version it last saw, which is how
24
+ * the server can tell "this client is editing the slot it can see" from "this
25
+ * client composed a queue of its own, not knowing about the other one" (#629).
26
+ */
27
+ id?: string;
28
+ /**
29
+ * Every (id, text) this slot has been known by — the dedup ledger. Absent on an
30
+ * entry written by an older server; readers treat that as one legacy identity.
31
+ */
32
+ parts?: QueuedPart[];
5
33
  }
34
+ /** The identities of an entry, synthesising one for a legacy entry without any. */
35
+ export declare function partsOf(m: QueuedMessage): QueuedPart[];
6
36
  export declare class QueuedMessageStore {
7
37
  private readonly stateFile;
8
38
  /** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
@@ -30,6 +60,33 @@ export declare class QueuedMessageStore {
30
60
  * Setting `null` deletes the key, keeping the file compact.
31
61
  */
32
62
  set(agent: string, sessionId: string, message: QueuedMessage | null): Promise<void>;
63
+ /**
64
+ * Write one client's queue into a chat's single slot, MERGING rather than
65
+ * overwriting (#629), and return the resulting entry. The read-modify-write
66
+ * happens with no `await` between the load and the mutation, so two clients
67
+ * queueing at once can't lose one another's text the way the old unconditional
68
+ * `set` overwrite did.
69
+ *
70
+ * `incoming.id` is the identity the client is writing under — either the slot
71
+ * VERSION it last saw (broadcast to it via `chat:queued_state`) or, if it has
72
+ * never seen one, its own queue id. That is what distinguishes the two cases:
73
+ *
74
+ * - **it matches the current version** ⇒ the client can see the slot as it is
75
+ * now, so its text IS the whole slot: replace. This covers the ordinary
76
+ * single-client edit/append (#245 stable identity) and any client that has
77
+ * caught up with a merge.
78
+ * - **it doesn't** ⇒ the client composed this queue without knowing the
79
+ * slot's current contents (a second device, or a tab that missed the
80
+ * broadcast): APPEND, so neither message is lost.
81
+ *
82
+ * A write whose text the slot has already been known by is a re-assert — a
83
+ * reconnecting client pushing its stored copy — and is a no-op, so reconnecting
84
+ * can't duplicate text into the slot.
85
+ *
86
+ * `nowMs` stamps a fresh slot's `createdAtMs` SERVER-side; an existing slot keeps
87
+ * the one it has. The client's clock never enters the store (#736).
88
+ */
89
+ upsert(agent: string, sessionId: string, incoming: QueuedPart, nowMs: number, mintVersion: () => string): Promise<QueuedMessage | null>;
33
90
  /** Write-through, serialised so overlapping writes can't corrupt the file. */
34
91
  private persist;
35
92
  }
@@ -15,6 +15,26 @@
15
15
  * When a turn completes, the server checks for a queued message, auto-sends it as
16
16
  * the next message, and deletes the stored entry. The web client receives a
17
17
  * `chat:queued_flushed` frame to clear its localStorage.
18
+ *
19
+ * ## One slot, many contributors (#629)
20
+ *
21
+ * The slot is per CHAT, not per client — a chat has one "next message", the same
22
+ * model the composer renders. It used to be a bare overwrite, so a second client
23
+ * queueing on the same chat silently destroyed the first client's text (and the
24
+ * first client's transcript then rendered a user bubble nobody there had typed).
25
+ *
26
+ * The slot now holds an ordered list of {@link QueuedPart}s: one per contributing
27
+ * client queue, identified by that client's opaque queue id. A client updating ITS
28
+ * OWN part replaces it in place (an edit/append — #245's stable identity); a
29
+ * DIFFERENT client's queue appends a new part. `text` is the joined result and
30
+ * stays the thing that gets sent, so nothing downstream — nor an older reader of
31
+ * this file — needs to know about parts.
32
+ *
33
+ * The parts double as the drain's dedup ledger. A client that never saw the
34
+ * `chat:queued_flushed` clear (it is broadcast un-buffered) re-asserts its own
35
+ * (id, text) on reload; matching that against the parts actually flushed is what
36
+ * lets the drain tell a stale re-assert from a genuinely new message WITHOUT
37
+ * comparing wall clocks (#736). See `drainQueue` in ws.ts.
18
38
  */
19
39
  import { promises as fs } from "node:fs";
20
40
  import path from "node:path";
@@ -25,6 +45,45 @@ const KEY_SEP = "\u0000";
25
45
  function keyOf(agent, sessionId) {
26
46
  return `${agent}${KEY_SEP}${sessionId}`;
27
47
  }
48
+ /** Joiner when a second client's queue is merged in — what the composer uses. */
49
+ const MERGE_SEP = "\n";
50
+ /** How many (id, text) identities one slot remembers. Bounds the sidecar entry. */
51
+ const MAX_KNOWN = 24;
52
+ /**
53
+ * Fold a write from a client that ISN'T on the slot's current version into the
54
+ * slot's text.
55
+ *
56
+ * Usually that client is simply appending to its own queue: it holds text the slot
57
+ * already contains and has added a line to the end of it. That is the ordinary
58
+ * single-client append — the composer keeps one queue id across an append (#245),
59
+ * and an older client sends only its enqueue `ts`, so neither ever carries the
60
+ * current version. It also covers the plain race of appending faster than the
61
+ * broadcast round trip. In every one of those, the client's text EXTENDS something
62
+ * the slot already holds, so we substitute in place rather than appending — or the
63
+ * client's own earlier text would appear twice.
64
+ *
65
+ * Otherwise the client queued without knowing what was already there (a second
66
+ * device), and its text is appended so neither message is lost (#629).
67
+ */
68
+ function mergeInto(current, known, incoming) {
69
+ // The most specific thing the incoming text extends — longest first, since a
70
+ // queue that has been appended to is known by every prefix it has passed through.
71
+ const extended = known
72
+ .filter((p) => p.text.length > 0 && incoming.startsWith(`${p.text}${MERGE_SEP}`))
73
+ .sort((a, b) => b.text.length - a.text.length)[0];
74
+ const at = extended ? current.indexOf(extended.text) : -1;
75
+ if (at < 0)
76
+ return `${current}${MERGE_SEP}${incoming}`;
77
+ // Splice rather than String.replace: the texts are user prose and `$&`-style
78
+ // patterns in a replacement string would be interpreted.
79
+ return current.slice(0, at) + incoming + current.slice(at + extended.text.length);
80
+ }
81
+ /** The identities of an entry, synthesising one for a legacy entry without any. */
82
+ export function partsOf(m) {
83
+ if (m.parts && m.parts.length > 0)
84
+ return m.parts;
85
+ return [{ id: m.id ?? `legacy:${m.createdAtMs}`, text: m.text }];
86
+ }
28
87
  export class QueuedMessageStore {
29
88
  stateFile;
30
89
  /** In-memory map of key -> QueuedMessage (loaded once, written through on change). */
@@ -49,7 +108,24 @@ export class QueuedMessageStore {
49
108
  typeof v === "object" &&
50
109
  typeof v.text === "string" &&
51
110
  typeof v.createdAtMs === "number") {
52
- map.set(k, v);
111
+ const entry = v;
112
+ // `parts` is optional (an older server wrote entries without it) and
113
+ // corruption-tolerant like everything else here: a malformed parts
114
+ // array is dropped, leaving a legacy single-part entry, not a throw.
115
+ const raw = v.parts;
116
+ const parts = Array.isArray(raw)
117
+ ? raw.filter((p) => Boolean(p) &&
118
+ typeof p === "object" &&
119
+ typeof p.id === "string" &&
120
+ typeof p.text === "string")
121
+ : [];
122
+ const id = typeof v.id === "string" ? entry.id : undefined;
123
+ map.set(k, {
124
+ text: entry.text,
125
+ createdAtMs: entry.createdAtMs,
126
+ ...(id ? { id } : {}),
127
+ ...(parts.length > 0 ? { parts } : {}),
128
+ });
53
129
  }
54
130
  }
55
131
  }
@@ -102,6 +178,64 @@ export class QueuedMessageStore {
102
178
  }
103
179
  await this.persist(map);
104
180
  }
181
+ /**
182
+ * Write one client's queue into a chat's single slot, MERGING rather than
183
+ * overwriting (#629), and return the resulting entry. The read-modify-write
184
+ * happens with no `await` between the load and the mutation, so two clients
185
+ * queueing at once can't lose one another's text the way the old unconditional
186
+ * `set` overwrite did.
187
+ *
188
+ * `incoming.id` is the identity the client is writing under — either the slot
189
+ * VERSION it last saw (broadcast to it via `chat:queued_state`) or, if it has
190
+ * never seen one, its own queue id. That is what distinguishes the two cases:
191
+ *
192
+ * - **it matches the current version** ⇒ the client can see the slot as it is
193
+ * now, so its text IS the whole slot: replace. This covers the ordinary
194
+ * single-client edit/append (#245 stable identity) and any client that has
195
+ * caught up with a merge.
196
+ * - **it doesn't** ⇒ the client composed this queue without knowing the
197
+ * slot's current contents (a second device, or a tab that missed the
198
+ * broadcast): APPEND, so neither message is lost.
199
+ *
200
+ * A write whose text the slot has already been known by is a re-assert — a
201
+ * reconnecting client pushing its stored copy — and is a no-op, so reconnecting
202
+ * can't duplicate text into the slot.
203
+ *
204
+ * `nowMs` stamps a fresh slot's `createdAtMs` SERVER-side; an existing slot keeps
205
+ * the one it has. The client's clock never enters the store (#736).
206
+ */
207
+ async upsert(agent, sessionId, incoming, nowMs, mintVersion) {
208
+ const map = await this.ensureLoaded();
209
+ const key = keyOf(agent, sessionId);
210
+ const current = map.get(key) ?? null;
211
+ if (!incoming.text.trim())
212
+ return current;
213
+ const known = current ? partsOf(current) : [];
214
+ // Already folded in under some identity ⇒ a re-assert, not new text.
215
+ if (known.some((p) => p.text === incoming.text))
216
+ return current;
217
+ const text = !current
218
+ ? incoming.text
219
+ : incoming.id === current.id
220
+ ? // The client can see the slot as it stands, so its text IS the slot.
221
+ incoming.text
222
+ : mergeInto(current.text, known, incoming.text);
223
+ const version = mintVersion();
224
+ // Remember BOTH identities this write creates: what the client sent (which it
225
+ // will re-assert on reload) and the version we hand back (which it will
226
+ // re-assert once it adopts the broadcast). Either one reaching a later drain
227
+ // has to be recognisable as already-flushed.
228
+ const parts = [...known, { id: incoming.id, text: incoming.text }, { id: version, text }].slice(-MAX_KNOWN);
229
+ const next = {
230
+ text,
231
+ createdAtMs: current?.createdAtMs ?? nowMs,
232
+ id: version,
233
+ parts,
234
+ };
235
+ map.set(key, next);
236
+ await this.persist(map);
237
+ return next;
238
+ }
105
239
  /** Write-through, serialised so overlapping writes can't corrupt the file. */
106
240
  persist(map) {
107
241
  this.writing = this.writing.then(async () => {
@@ -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,10 +214,23 @@ 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;
223
236
  };
@@ -382,6 +395,64 @@ export interface ChatQueuedFlushedMessage {
382
395
  text?: string;
383
396
  };
384
397
  }
398
+ /**
399
+ * The chat's queued message changed (#629). Broadcast to EVERY socket attached to
400
+ * the session whenever the slot is written, so the queue is shared chat state that
401
+ * all clients render identically instead of invisible per-client state.
402
+ *
403
+ * Before this, a queue was only ever written and never announced: a second client
404
+ * queueing on the same chat overwrote the first client's text with no signal to
405
+ * anyone. The first user's chip sat there showing a message that no longer existed,
406
+ * and when the drain finally fired, their transcript rendered — as their own user
407
+ * bubble — a message they had never typed. The slot now MERGES contributions, and
408
+ * this frame is what makes the merge visible.
409
+ */
410
+ export interface ChatQueuedStateMessage {
411
+ type: "chat:queued_state";
412
+ payload: {
413
+ projectSlug: string;
414
+ sessionId: string;
415
+ /** The chat's full queued text, or null when the slot is now empty. */
416
+ text: string | null;
417
+ /**
418
+ * The slot's identity. A client adopts it as its own queue id, so its next
419
+ * edit updates this shared slot in place rather than appending beside it.
420
+ */
421
+ qid?: string;
422
+ /**
423
+ * Why the slot changed, when it is worth explaining. `returned` means a user
424
+ * pressed Stop and the message went back to THEIR composer — sent to the
425
+ * other clients so a chip that just vanished from under them has a reason
426
+ * attached, rather than looking like the message evaporated. The client that
427
+ * pressed Stop is excluded from this broadcast; it gets
428
+ * {@link ChatQueuedReturnedMessage} instead.
429
+ */
430
+ reason?: "returned";
431
+ };
432
+ }
433
+ /**
434
+ * A user pressed Stop, so the message queued behind that turn is handed BACK to
435
+ * them (#751 follow-up). Sent only to the socket that asked to cancel.
436
+ *
437
+ * Stop means "give me control back". Auto-sending the follow-up would have the
438
+ * agent start working again the instant the user stopped it, and holding the
439
+ * message server-side is what stranded it (#627) — so it returns to the composer,
440
+ * where it is visible, editable, and sent only when the user says so.
441
+ *
442
+ * Deliberately NOT `chat:queued_flushed` with a flag: that frame means "this text
443
+ * was sent, render it as the user's bubble", and a returned message is precisely
444
+ * one that was NOT sent. Overloading it risks a phantom user turn in the
445
+ * transcript for a message the agent never received.
446
+ */
447
+ export interface ChatQueuedReturnedMessage {
448
+ type: "chat:queued_returned";
449
+ payload: {
450
+ projectSlug: string;
451
+ sessionId: string;
452
+ /** The queued text, for the client to put back in its composer. */
453
+ text: string;
454
+ };
455
+ }
385
456
  /**
386
457
  * A background task was killed at the turn boundary and the keeper is idle
387
458
  * (issue #347). Broadcast LIVE by the recovery engine the moment the kill is
@@ -418,6 +489,6 @@ export interface ChatNoticeMessage {
418
489
  export interface PongMessage {
419
490
  type: "pong";
420
491
  }
421
- export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatKilledTaskMessage | ChatNoticeMessage | PongMessage;
492
+ export type ServerMessage = ChatResponseMessage | ChatToolCallMessage | ChatToolStartMessage | ChatMessageBoundaryMessage | ChatCompleteMessage | ChatErrorMessage | ChatResyncMessage | ChatActiveMessage | ChatQueuedFlushedMessage | ChatQueuedStateMessage | ChatQueuedReturnedMessage | ChatKilledTaskMessage | ChatNoticeMessage | PongMessage;
422
493
  export declare function isClientMessage(data: unknown): data is ClientMessage;
423
494
  export {};
@@ -245,6 +245,8 @@ export function isClientMessage(data) {
245
245
  return false;
246
246
  if (p.ts !== undefined && p.ts !== null && typeof p.ts !== "number")
247
247
  return false;
248
+ if (p.qid !== undefined && p.qid !== null && typeof p.qid !== "string")
249
+ return false;
248
250
  return true;
249
251
  }
250
252
  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";