@edspencer/paddock 0.66.1 → 0.67.0

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 (105) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/herdctl-jobs.d.ts +20 -0
  3. package/packages/server/dist/herdctl-jobs.js +94 -0
  4. package/packages/server/dist/herdctl.d.ts +46 -0
  5. package/packages/server/dist/herdctl.js +58 -0
  6. package/packages/server/dist/instance-config.d.ts +56 -5
  7. package/packages/server/dist/instance-config.js +206 -24
  8. package/packages/server/dist/project-paths.d.ts +46 -1
  9. package/packages/server/dist/project-paths.js +66 -2
  10. package/packages/server/dist/project-types.d.ts +23 -0
  11. package/packages/server/dist/project-types.js +35 -0
  12. package/packages/server/dist/projects.d.ts +5 -2
  13. package/packages/server/dist/projects.js +71 -17
  14. package/packages/server/dist/queued-message.d.ts +32 -2
  15. package/packages/server/dist/queued-message.js +87 -8
  16. package/packages/server/dist/routes/chats.js +41 -10
  17. package/packages/server/dist/routes/meta.js +32 -5
  18. package/packages/server/dist/routes/projects.js +71 -7
  19. package/packages/server/dist/session-hub.d.ts +10 -0
  20. package/packages/server/dist/session-hub.js +16 -2
  21. package/packages/server/dist/ws-protocol.d.ts +67 -0
  22. package/packages/server/dist/ws-protocol.js +28 -13
  23. package/packages/server/dist/ws.js +90 -24
  24. package/packages/server/package.json +1 -1
  25. package/packages/web/dist/assets/InstanceConfigPage-sDbCoFxC.js +2 -0
  26. package/packages/web/dist/assets/{MarkdownRenderer-CRQP3Cfe.js → MarkdownRenderer-MaSe5aAI.js} +11 -11
  27. package/packages/web/dist/assets/ProjectMenu-DmWpr9od.js +2 -0
  28. package/packages/web/dist/assets/ProjectRedirect-BJeDERUd.js +2 -0
  29. package/packages/web/dist/assets/ProjectView-Bbecs1u7.js +13 -0
  30. package/packages/web/dist/assets/ProjectsGrid-BE4cAtu5.js +2 -0
  31. package/packages/web/dist/assets/Surfaces-DtSsbnLh.js +2 -0
  32. package/packages/web/dist/assets/{arc-DqMSW0As.js → arc-NLb6J_GZ.js} +1 -1
  33. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CNkQD1jU.js → architectureDiagram-3BPJPVTR-BbiSIctn.js} +1 -1
  34. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BCMiOmt6.js → blockDiagram-GPEHLZMM-Br1sRzRE.js} +1 -1
  35. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CU2soCji.js → c4Diagram-AAUBKEIU-DUvJurko.js} +1 -1
  36. package/packages/web/dist/assets/channel-bezC0w5b.js +2 -0
  37. package/packages/web/dist/assets/{chunk-2J33WTMH-nKhlJNTt.js → chunk-2J33WTMH-Cm_E_LAD.js} +1 -1
  38. package/packages/web/dist/assets/{chunk-4BX2VUAB-YRTZj9zI.js → chunk-4BX2VUAB-CK6R2MCN.js} +1 -1
  39. package/packages/web/dist/assets/{chunk-55IACEB6-HO60VE45.js → chunk-55IACEB6-D7HXvxGy.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-727SXJPM-WZJH-boe.js → chunk-727SXJPM-BLwevzlY.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-AQP2D5EJ-PvDC7gYB.js → chunk-AQP2D5EJ-CpLWD5so.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-FMBD7UC4-BNRMTplH.js → chunk-FMBD7UC4-BNlt1t19.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-ND2GUHAM-2Q2ei8TR.js → chunk-ND2GUHAM-CIt5gAq8.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-QZHKN3VN-CG9VV7M6.js → chunk-QZHKN3VN-B1uwIQ9c.js} +1 -1
  45. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DMgrTmQU.js +2 -0
  46. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DMgrTmQU.js +2 -0
  47. package/packages/web/dist/assets/{core-DwSEw_oE.js → core-HXUN76Po.js} +1 -1
  48. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BlMrBI7K.js → cose-bilkent-S5V4N54A-DJ4_5jxB.js} +1 -1
  49. package/packages/web/dist/assets/{dagre-BM42HDAG-tNoaleNU.js → dagre-BM42HDAG-Bc9-haAu.js} +1 -1
  50. package/packages/web/dist/assets/{diagram-2AECGRRQ-DRVCTpHL.js → diagram-2AECGRRQ-nLOtPDzu.js} +1 -1
  51. package/packages/web/dist/assets/{diagram-5GNKFQAL-BdS-96WV.js → diagram-5GNKFQAL-DThV84R3.js} +1 -1
  52. package/packages/web/dist/assets/{diagram-KO2AKTUF-Dv1yxz3s.js → diagram-KO2AKTUF-C5hcR1vA.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-LMA3HP47-BNXgifuS.js → diagram-LMA3HP47-BEFzrSHy.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-OG6HWLK6-wlO0bxwP.js → diagram-OG6HWLK6-BY5lPsX7.js} +1 -1
  55. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ChoppAEq.js → erDiagram-TEJ5UH35-y_kB8ETg.js} +1 -1
  56. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-C__aYyei.js → flowDiagram-I6XJVG4X-Cuhha5cF.js} +1 -1
  57. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CjRe8foW.js → ganttDiagram-6RSMTGT7-DFJpciK5.js} +1 -1
  58. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BGDQjiWq.js → gitGraphDiagram-PVQCEYII-FoA0yuh-.js} +1 -1
  59. package/packages/web/dist/assets/index-BxLurJvz.css +1 -0
  60. package/packages/web/dist/assets/index-C_PXsGTD.js +71 -0
  61. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BP-JaX-8.js → infoDiagram-5YYISTIA-CKYqD8-c.js} +1 -1
  62. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-YnsAJBvC.js → ishikawaDiagram-YF4QCWOH-CCIxHIBw.js} +1 -1
  63. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BM6JWTV9.js → journeyDiagram-JHISSGLW-lGYoSyk0.js} +1 -1
  64. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-gR2O0IaL.js → kanban-definition-UN3LZRKU-B3D7CIwy.js} +1 -1
  65. package/packages/web/dist/assets/{linear-Dolq_eLR.js → linear-BiplJBex.js} +1 -1
  66. package/packages/web/dist/assets/{mermaid.core-D1T_GQW7.js → mermaid.core-BJ7aEinZ.js} +4 -4
  67. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-C3bzIyLZ.js → mindmap-definition-RKZ34NQL-BiXcOtHo.js} +1 -1
  68. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BrZ3ANGf.js → pieDiagram-4H26LBE5-tnkj45YF.js} +1 -1
  69. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BvLCHAEi.js → quadrantDiagram-W4KKPZXB-LqReq3Gl.js} +1 -1
  70. package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
  71. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CCzXAeaZ.js → requirementDiagram-4Y6WPE33-C1uk9Jty.js} +1 -1
  72. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-2Gf-ERTg.js → sankeyDiagram-5OEKKPKP-eQQ3OKrV.js} +1 -1
  73. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DMjDe28d.js → sequenceDiagram-3UESZ5HK-C6-a0lH4.js} +1 -1
  74. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CENtj5dw.js → stateDiagram-AJRCARHV-CsTOkKqz.js} +1 -1
  75. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bsc-VlVI.js +2 -0
  76. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BW4gCpBB.js → timeline-definition-PNZ67QCA-DUBXH1x-.js} +1 -1
  77. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CiIuqcT9.js → vennDiagram-CIIHVFJN-CcQrNnou.js} +1 -1
  78. package/packages/web/dist/assets/{wardley-L42UT6IY-BQ1BPr5R.js → wardley-L42UT6IY-Br823uUV.js} +1 -1
  79. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DBq9Ailv.js → wardleyDiagram-YWT4CUSO-DRBECQc_.js} +1 -1
  80. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D24VDECy.js → xychartDiagram-2RQKCTM6--HtSFP24.js} +1 -1
  81. package/packages/web/dist/fonts/alegreya-latin-italic.woff2 +0 -0
  82. package/packages/web/dist/fonts/alegreya-latin.woff2 +0 -0
  83. package/packages/web/dist/fonts/alegreya-sc-latin-500.woff2 +0 -0
  84. package/packages/web/dist/fonts/alegreya-sc-latin-700.woff2 +0 -0
  85. package/packages/web/dist/fonts/courier-prime-latin-400.woff2 +0 -0
  86. package/packages/web/dist/fonts/courier-prime-latin-700.woff2 +0 -0
  87. package/packages/web/dist/fonts/courier-prime-latin-italic.woff2 +0 -0
  88. package/packages/web/dist/fonts/iosevka-terminal-400.woff2 +0 -0
  89. package/packages/web/dist/fonts/iosevka-terminal-700.woff2 +0 -0
  90. package/packages/web/dist/fonts/martian-mono-latin.woff2 +0 -0
  91. package/packages/web/dist/fonts/saira-latin.woff2 +0 -0
  92. package/packages/web/dist/index.html +61 -12
  93. package/packages/web/dist/sw.js +1 -1
  94. package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +0 -2
  95. package/packages/web/dist/assets/ProjectRedirect-Dd3h5V8h.js +0 -2
  96. package/packages/web/dist/assets/ProjectView-BRk6l152.js +0 -13
  97. package/packages/web/dist/assets/ProjectsGrid-BaUE8zaF.js +0 -2
  98. package/packages/web/dist/assets/channel-Ynyq4GPh.js +0 -2
  99. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +0 -2
  100. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +0 -2
  101. package/packages/web/dist/assets/format-u-YtEZFd.js +0 -2
  102. package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
  103. package/packages/web/dist/assets/index-Cc5bQxZk.js +0 -71
  104. package/packages/web/dist/assets/queued-DGmoZW59.js +0 -2
  105. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +0 -2
@@ -134,20 +134,23 @@ export function registerChatWorkspaceRoutes(app, ctx) {
134
134
  *
135
135
  * `running` is authoritative (the live hub), not inferred from timestamps.
136
136
  * `unread` reuses the sidebar badge's derivation — the manual override (#458)
137
- * or a completed turn newer than the read watermark (#189) — with ONE
138
- * deliberate difference: archived chats are filed away on purpose, so they
139
- * are excluded here. That means an archived chat with an unread turn still
140
- * counts toward the sidebar's badge (`buildChatTurns` never consults
141
- * `ArchiveStore`) while not appearing in this list. The two can therefore
142
- * disagree by exactly that set; making them agree means teaching the badge
143
- * about archiving, which is a behaviour change to the badge, not to this
144
- * route.
137
+ * or a completed turn newer than the read watermark (#189) — INCLUDING the
138
+ * archived exclusion below.
139
+ *
140
+ * That last part used to be this route's alone: the badge never consulted
141
+ * `ArchiveStore`, so an archived unread chat counted toward the sidebar while
142
+ * being absent from this feed, and the two surfaces disagreed by exactly that
143
+ * set. #732 settled it the way this route already read it — archiving is the
144
+ * user filing a chat away, so it silences both — and `buildChatTurns` now
145
+ * applies the same rule. Keep the two in step: read state was made
146
+ * server-authoritative (#488) so surfaces could not diverge, and a rule that
147
+ * lives in only one of them is that divergence again.
145
148
  */
146
149
  app.get("/chats/attention", {
147
150
  schema: {
148
151
  tags: ["Chats"],
149
152
  summary: "List the chats in this workspace's subtree that are running or unread",
150
- description: "Returns `{ running, unread }` — the chats in this workspace AND its descendants that currently have a live turn (`running`) or hold an unread reply (`unread`). Each row is a chat DTO plus the `projectSlug`/`projectName` it belongs to, so a fleet-wide list stays attributable. On the root mount (`/api/root/chats/attention`) the subtree is the whole instance, because the root workspace's key (`\"\"`) prefixes every workspace key; on a project mount it is that project alone. A chat is never in both lists: a live turn hasn't landed a reply yet, so running wins.",
153
+ description: "Returns `{ running, unread }` — the chats in this workspace AND its descendants that currently have a live turn (`running`) or hold an unread reply (`unread`). Each row is a chat DTO plus the `projectSlug`/`projectName` it belongs to, so a fleet-wide list stays attributable. On the root mount (`/api/root/chats/attention`) the subtree is the whole instance, because the root workspace's key (`\"\"`) prefixes every workspace key; on a project mount it is that project alone. A chat is never in both lists: a live turn hasn't landed a reply yet, so running wins. `running` rows additionally carry resolved `contextTokens`/`contextLimit` so a caller can draw a context gauge for live work; `unread` rows do not, because resolving usage streams a transcript per chat and only the running set is bounded by how many turns can be in flight at once.",
151
154
  params: {
152
155
  type: "object",
153
156
  properties: {
@@ -194,7 +197,19 @@ export function registerChatWorkspaceRoutes(app, ctx) {
194
197
  const provenanceOf = (s) => runProvenance.get(s.sessionId);
195
198
  const triggerOf = makeTriggerResolver(p);
196
199
  const parentOf = makeParentResolver(runProvenance, messageProvenance, p.slug, (id) => parentDetach.isDetached(keeper, id));
197
- const chats = await buildProjectChats(p.dir, sessions, undefined, archivedOf, lastTurnAt, lastSeenOf, provenanceOf, triggerOf, starredOf, unreadOf, parentOf);
200
+ // Usage is deliberately NOT resolved for the whole subtree: it streams
201
+ // a transcript per chat, and this route refetches on every turn
202
+ // boundary across the fleet. A RUNNING chat is the one exception —
203
+ // every context gauge in the app (Home's running rows, the fleet
204
+ // readout) reads `contextTokens`/`contextLimit` off these rows, and
205
+ // with no resolver they were always null, so the gauges rendered
206
+ // blank. The running set is bounded by how many turns can be in
207
+ // flight at once (a handful), not by how many chats the instance
208
+ // holds, so the cost stays proportional to live work and not to
209
+ // history.
210
+ const usageOf = chatUsageResolver(p.dir, p.model ?? DEFAULT_MODEL);
211
+ const usageIfRunning = async (s) => isRunning(s.sessionId) ? usageOf(s) : null;
212
+ const chats = await buildProjectChats(p.dir, sessions, usageIfRunning, archivedOf, lastTurnAt, lastSeenOf, provenanceOf, triggerOf, starredOf, unreadOf, parentOf);
198
213
  for (const c of chats) {
199
214
  const row = { ...c, projectSlug: p.slug, projectName: p.name };
200
215
  if (isRunning(c.sessionId)) {
@@ -632,6 +647,13 @@ export function registerChatWorkspaceRoutes(app, ctx) {
632
647
  }
633
648
  await cleanupAttachments(agent, req.params.sessionId);
634
649
  const { removed, retained } = await herdctl.deleteSession(agent, req.params.sessionId);
650
+ // #732: and the chat's herdctl job records, which are what feeds the
651
+ // sidebar unread badge. Left behind, they kept the badge counting a chat
652
+ // there was nothing left to open to clear. Only when the transcript was
653
+ // actually REMOVED — see `purgeSessionJobs` for why a `retained`
654
+ // (released) chat keeps its records.
655
+ if (removed)
656
+ await herdctl.purgeSessionJobs([req.params.sessionId]);
635
657
  // Drop any archived/starred/unread/detached flag so a future session id
636
658
  // can't inherit it. (Read-state watermarks, other users' unread overrides,
637
659
  // provenance and any queued message are deliberately NOT swept here — that
@@ -1248,6 +1270,10 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1248
1270
  const user = readStateUser(req);
1249
1271
  const removed = [];
1250
1272
  const failed = [];
1273
+ // The subset whose transcript was genuinely deleted — the ids whose job
1274
+ // records may be purged (#732). Narrower than `removed`, which also
1275
+ // holds RELEASED chats: those are still listed, so their records stay.
1276
+ const deleted = [];
1251
1277
  for (const sessionId of sessionIds) {
1252
1278
  try {
1253
1279
  // #731: same interlock as the single delete, per id. A chat whose
@@ -1270,11 +1296,16 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1270
1296
  removed.push(sessionId);
1271
1297
  else
1272
1298
  failed.push(sessionId);
1299
+ if (gone.removed)
1300
+ deleted.push(sessionId);
1273
1301
  }
1274
1302
  catch {
1275
1303
  failed.push(sessionId);
1276
1304
  }
1277
1305
  }
1306
+ // ONE jobs-dir pass for the whole batch (#732) — purging per chat would
1307
+ // re-scan the shared directory once per id, up to 500 times.
1308
+ await herdctl.purgeSessionJobs(deleted);
1278
1309
  // Drop the archived/starred/unread/detached flags of the chats that were
1279
1310
  // ACTUALLY removed, so a recycled session id can't inherit stale state.
1280
1311
  //
@@ -1,6 +1,6 @@
1
1
  import { resolveAttachmentsConfig, maxFileBytes, isTypeAllowed, } from "../attachments-config.js";
2
2
  import { inferAttachmentKind } from "../attachments-hint.js";
3
- import { buildInstanceConfig, writeInstanceConfig, validatePatch, instanceConfigPath, InstanceConfigError, } from "../instance-config.js";
3
+ import { buildInstanceConfig, writeInstanceConfig, validatePatch, instanceConfigPath, instanceConfigVersion, InstanceConfigError, } from "../instance-config.js";
4
4
  import { TranscriptionError } from "../transcribe.js";
5
5
  import { resolveModels, resolveDefaultModel } from "../models.js";
6
6
  import { sendProjectError } from "../route-errors.js";
@@ -134,7 +134,7 @@ export function registerMetaRoutes(app, ctx) {
134
134
  schema: {
135
135
  tags: ["System"],
136
136
  summary: "Read instance-wide config",
137
- description: "Reports every surfaced instance-config field (value / default / editable / sensitive / env-shadow) for the top-level admin screen. Returns a JSON object keyed by field.",
137
+ description: "Reports every surfaced instance-config field for the top-level admin screen: `value` (effective in the running, boot-frozen process), `pendingValue` (what a restart would resolve, read from paddock.config.yaml on every request), `pendingRestart`, plus default / editable / sensitive / env-shadow flags. Top level carries `restartRequired` (any field diverges), `configPath`, and `configVersion` — a fingerprint of the file to echo back as `expectedVersion` on the PUT.",
138
138
  response: {
139
139
  200: {
140
140
  description: "Instance config snapshot.",
@@ -148,13 +148,16 @@ export function registerMetaRoutes(app, ctx) {
148
148
  schema: {
149
149
  tags: ["System"],
150
150
  summary: "Write instance-wide config",
151
- description: "Writes the editable subset of the instance config to paddock.config.yaml (comment-preserving, atomic). Writes DO NOT hot-apply — the config is frozen at boot — so a successful write returns `{ restartRequired: true, configPath }` and the process keeps its current config until it restarts. Returns 400 on a malformed patch or invalid field, 500 on write failure.",
151
+ description: "Writes the editable subset of the instance config to paddock.config.yaml (comment-preserving, atomic). Writes DO NOT hot-apply — the config is frozen at boot — so a successful write returns `{ restartRequired: true, configPath, configVersion }` and the process keeps its current config until it restarts. A `null` value clears that key (the built-in default applies again). Pass `expectedVersion` (the `configVersion` from the GET you were editing) to make the write conditional: 409 if the file changed underneath you, instead of silently overwriting another editor. Returns 400 on a malformed patch, an invalid/read-only/env-shadowed field, 500 on write failure.",
152
152
  body: {
153
153
  // Documentation-only (no validation/coercion): accept any/empty body.
154
154
  type: ["object", "null"],
155
155
  additionalProperties: true,
156
156
  properties: {
157
157
  patch: { description: "Map of config field name to new value to write." },
158
+ expectedVersion: {
159
+ description: "Optional. The `configVersion` this patch was composed against; the write is refused with 409 if the file no longer matches.",
160
+ },
158
161
  },
159
162
  },
160
163
  response: {
@@ -163,6 +166,11 @@ export function registerMetaRoutes(app, ctx) {
163
166
  type: "object",
164
167
  additionalProperties: true,
165
168
  },
169
+ 409: {
170
+ description: "`expectedVersion` no longer matches the file — another writer got there first. Nothing was written.",
171
+ type: "object",
172
+ additionalProperties: true,
173
+ },
166
174
  },
167
175
  },
168
176
  }, async (req, reply) => {
@@ -180,14 +188,33 @@ export function registerMetaRoutes(app, ctx) {
180
188
  }
181
189
  throw err;
182
190
  }
191
+ const configPath = instanceConfigPath(cfg);
192
+ // Optimistic concurrency (#722). Opt-in — a client that sends no
193
+ // `expectedVersion` (curl, a script) writes unconditionally, as before —
194
+ // but the UI always sends one, so two tabs editing the same file now
195
+ // collide loudly instead of the second silently erasing the first.
196
+ if (Object.prototype.hasOwnProperty.call(req.body ?? {}, "expectedVersion")) {
197
+ const current = instanceConfigVersion(configPath);
198
+ if (current !== (req.body?.expectedVersion ?? null)) {
199
+ return reply.code(409).send({
200
+ error: "The config file changed on disk since this page loaded (another tab, or an editor). Nothing was written — reload the settings and apply your changes again.",
201
+ code: "config_conflict",
202
+ configVersion: current,
203
+ });
204
+ }
205
+ }
183
206
  try {
184
- writeInstanceConfig(instanceConfigPath(cfg), pairs);
207
+ writeInstanceConfig(configPath, pairs);
185
208
  }
186
209
  catch (err) {
187
210
  return reply.code(500).send({ error: `failed to write config file: ${err.message}` });
188
211
  }
189
212
  // The write lands in the file but NOT in the running (frozen) config.
190
- return { restartRequired: true, configPath: instanceConfigPath(cfg) };
213
+ return {
214
+ restartRequired: true,
215
+ configPath,
216
+ configVersion: instanceConfigVersion(configPath),
217
+ };
191
218
  });
192
219
  // Selectable models + the keeper/sweeper defaults (CONTRACT-v3 §3). The offered
193
220
  // models are the instance ALLOW-LIST (issue #457 Step 2): the built-in catalog
@@ -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) {
@@ -72,6 +72,14 @@ interface Turn {
72
72
  baseSeq: number;
73
73
  nextSeq: number;
74
74
  running: boolean;
75
+ /**
76
+ * Epoch-ms the turn began. The hub is the only place that knows this: a job
77
+ * record is written when a turn ENDS, and the transcript's own timestamps are
78
+ * the model's, not the run's. Surfaced on {@link ActiveInfo} so a client that
79
+ * loads mid-turn — or reconnects — can still say how long the fleet has been
80
+ * working, rather than restarting every clock at zero on every page load.
81
+ */
82
+ startedAt: number;
75
83
  evictTimer: ReturnType<typeof setTimeout> | null;
76
84
  }
77
85
  /** How long a completed turn's buffer lingers so an end-of-turn reconnect still gets the tail. */
@@ -97,6 +105,8 @@ export interface ActiveInfo {
97
105
  /** The running turn's cancellable job id, if known yet (null early in a turn). */
98
106
  jobId: string | null;
99
107
  running: boolean;
108
+ /** Epoch-ms this turn started. See {@link Turn.startedAt}. */
109
+ startedAt: number;
100
110
  }
101
111
  /** Why a turn is being cancelled, and which socket asked for it. */
102
112
  export interface CancelIntent {
@@ -107,6 +107,7 @@ export class SessionHub {
107
107
  baseSeq: 0,
108
108
  nextSeq: 0,
109
109
  running: true,
110
+ startedAt: Date.now(),
110
111
  evictTimer: null,
111
112
  };
112
113
  const handle = new TurnHandle(this, turn);
@@ -203,14 +204,26 @@ export class SessionHub {
203
204
  const turn = this.bySession.get(sessionId);
204
205
  if (!turn || !turn.running)
205
206
  return null;
206
- return { sessionId, projectSlug: turn.projectSlug, jobId: turn.jobId, running: true };
207
+ return {
208
+ sessionId,
209
+ projectSlug: turn.projectSlug,
210
+ jobId: turn.jobId,
211
+ running: true,
212
+ startedAt: turn.startedAt,
213
+ };
207
214
  }
208
215
  /** Snapshot of every session with a running turn (for a newly-connected client). */
209
216
  runningSessions() {
210
217
  const out = [];
211
218
  for (const [sessionId, turn] of this.bySession) {
212
219
  if (turn.running) {
213
- out.push({ sessionId, projectSlug: turn.projectSlug, jobId: turn.jobId, running: true });
220
+ out.push({
221
+ sessionId,
222
+ projectSlug: turn.projectSlug,
223
+ jobId: turn.jobId,
224
+ running: true,
225
+ startedAt: turn.startedAt,
226
+ });
214
227
  }
215
228
  }
216
229
  return out;
@@ -288,6 +301,7 @@ export class SessionHub {
288
301
  projectSlug: turn.projectSlug,
289
302
  jobId: turn.jobId,
290
303
  running: turn.running,
304
+ startedAt: turn.startedAt,
291
305
  });
292
306
  }
293
307
  recipients(turn) {
@@ -233,6 +233,26 @@ export interface ChatSetQueueMessage {
233
233
  * stored as the enqueue time — the server stamps that itself.
234
234
  */
235
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
+ }>;
236
256
  };
237
257
  }
238
258
  /**
@@ -373,6 +393,21 @@ export interface ChatActiveMessage {
373
393
  /** The running turn's cancellable job id, when known. */
374
394
  jobId: string | null;
375
395
  running: boolean;
396
+ /**
397
+ * Epoch-ms the running turn began, from the session hub. The hub is the only
398
+ * thing that knows: a job record is written when a turn ENDS, and the
399
+ * transcript's timestamps are the model's rather than the run's. Because the
400
+ * server replays its whole running snapshot to every socket on connect, a
401
+ * client that reloads mid-turn still learns the true start — which is what
402
+ * lets a fleet readout say "running 12:04" instead of restarting at zero on
403
+ * every page load.
404
+ *
405
+ * Sent on the STOP frame too (`running: false`), where it describes the turn
406
+ * that just ended and a client has no use for it — the hub emits one frame
407
+ * shape for both transitions rather than branching. Optional on the wire so
408
+ * a client built against an older server still parses the frame.
409
+ */
410
+ startedAt?: number;
376
411
  };
377
412
  }
378
413
  /**
@@ -393,6 +428,16 @@ export interface ChatQueuedFlushedMessage {
393
428
  * when the frame is just telling the client to clear a stale/already-sent copy.
394
429
  */
395
430
  text?: string;
431
+ /**
432
+ * The attachments that rode the drained message (#728), so the client renders
433
+ * the auto-sent user bubble with its files exactly as a live send does. Only
434
+ * present alongside `text` (a clear-only frame carries neither).
435
+ */
436
+ attachments?: Array<{
437
+ id: string;
438
+ filename: string;
439
+ kind?: string;
440
+ }>;
396
441
  };
397
442
  }
398
443
  /**
@@ -414,6 +459,17 @@ export interface ChatQueuedStateMessage {
414
459
  sessionId: string;
415
460
  /** The chat's full queued text, or null when the slot is now empty. */
416
461
  text: string | null;
462
+ /**
463
+ * The files staged on the slot (#728). Shared exactly like the text is: every
464
+ * attached client renders the same chips, so a file queued on one device is
465
+ * visible on the other rather than being invisible state that surfaces only
466
+ * when the drain sends it. Absent/empty when the slot holds no attachments.
467
+ */
468
+ attachments?: Array<{
469
+ id: string;
470
+ filename: string;
471
+ kind?: string;
472
+ }>;
417
473
  /**
418
474
  * The slot's identity. A client adopts it as its own queue id, so its next
419
475
  * edit updates this shared slot in place rather than appending beside it.
@@ -451,6 +507,17 @@ export interface ChatQueuedReturnedMessage {
451
507
  sessionId: string;
452
508
  /** The queued text, for the client to put back in its composer. */
453
509
  text: string;
510
+ /**
511
+ * The files that were staged on the slot (#728), for the client to put back in
512
+ * its attachment tray. Stop returns the whole message, not just its prose —
513
+ * dropping the files here would be the same silent loss under a different name,
514
+ * and leaving them on the (now cleared) slot would strand them server-side.
515
+ */
516
+ attachments?: Array<{
517
+ id: string;
518
+ filename: string;
519
+ kind?: string;
520
+ }>;
454
521
  };
455
522
  }
456
523
  /**
@@ -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") {
@@ -247,6 +258,10 @@ export function isClientMessage(data) {
247
258
  return false;
248
259
  if (p.qid !== undefined && p.qid !== null && typeof p.qid !== "string")
249
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;
250
265
  return true;
251
266
  }
252
267
  return false;