@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.66.1",
3
+ "version": "0.67.0",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -72,6 +72,26 @@ export declare function listProjectRuns(stateDir: string, project: Project, limi
72
72
  * view degrades to config-only rather than failing to render.
73
73
  */
74
74
  export declare function listRunsForAgents(stateDir: string, agents: string[], limit?: number): Promise<JobMetadata[]>;
75
+ /**
76
+ * Drop the job records of one or more CHATS (#732) — called when Paddock removes
77
+ * a chat from a project, so the record cannot outlive the transcript and keep
78
+ * the sidebar badge counting a chat that is no longer there to open.
79
+ */
80
+ export declare function purgeSessionJobs(stateDir: string, sessionIds: string[]): Promise<number>;
81
+ /**
82
+ * Drop the job records of one or more AGENTS (#734) — called when a project is
83
+ * deleted, for every agent name that project owned (keeper, sweeper, hooks,
84
+ * triggers). Agent-keyed rather than project-keyed because the agent name is
85
+ * what the record actually carries.
86
+ *
87
+ * This is the containment half of the fix rather than the structural one: the
88
+ * durable answer to "a re-created project inherits the old one's records" is to
89
+ * key them by a stable project id instead of the user-controlled, reusable slug.
90
+ * That is a herdctl-side identity change (records are herdctl's format, written
91
+ * by its runtimes); purging on delete makes the leak impossible NOW without
92
+ * waiting for it, and stays correct afterwards.
93
+ */
94
+ export declare function purgeAgentJobs(stateDir: string, agents: string[]): Promise<number>;
75
95
  /**
76
96
  * Point every herdctl job record for `sessionId` at the project's keeper so
77
97
  * the core attribution index (last-write-wins per session) lists the session
@@ -138,6 +138,100 @@ export async function listRunsForAgents(stateDir, agents, limit = 200) {
138
138
  const filtered = jobs.filter((j) => wanted.has(j.agent));
139
139
  return limit > 0 ? filtered.slice(0, limit) : filtered;
140
140
  }
141
+ /**
142
+ * Delete every job record matching `match`, plus each one's sibling `.jsonl`
143
+ * output file. Returns how many records were removed.
144
+ *
145
+ * The shared inverse of the writes above (#732 / #734). The jobs directory is
146
+ * append-only and fleet-wide, and until now NOTHING ever removed a record — so a
147
+ * record outlived whatever it described. Two user-visible consequences, one
148
+ * mechanism:
149
+ *
150
+ * - A deleted CHAT kept feeding {@link lastTurnCompletedAtByProject}, i.e. the
151
+ * sidebar unread badge, with no chat left to open to clear it (#732).
152
+ * - A deleted PROJECT left its keeper's whole history behind, keyed by the
153
+ * agent name — which is derived from the slug, which is derived from the
154
+ * NAME. Re-creating a project called the same thing therefore inherited the
155
+ * previous incarnation's `/runs`, prompt text and reply summaries included
156
+ * (#734).
157
+ *
158
+ * Records are matched by reading them, not by parsing filenames: a job id
159
+ * encodes only a date and six characters of the session id, which is neither an
160
+ * agent nor a reliable session key.
161
+ *
162
+ * Best-effort per file, deliberately: a record we cannot read is a record we
163
+ * cannot prove is ours, so it is left alone rather than removed on a guess, and
164
+ * an unlink failure never fails the delete the user actually asked for. The
165
+ * caller invalidates {@link JobsDirIndex} afterwards — the index also drops
166
+ * vanished files on its own next scan, so this is belt-and-braces.
167
+ */
168
+ async function purgeJobs(stateDir, match) {
169
+ const jobsDir = jobsDirOf(stateDir);
170
+ let entries = [];
171
+ try {
172
+ entries = await fs.readdir(jobsDir);
173
+ }
174
+ catch {
175
+ return 0; // no jobs dir yet — nothing to purge
176
+ }
177
+ let removed = 0;
178
+ for (const name of entries) {
179
+ if (!name.endsWith(".yaml"))
180
+ continue;
181
+ const file = path.join(jobsDir, name);
182
+ let parsed = null;
183
+ try {
184
+ parsed = YAML.parse(await fs.readFile(file, "utf8"));
185
+ }
186
+ catch {
187
+ continue;
188
+ }
189
+ if (!parsed || !match(parsed))
190
+ continue;
191
+ await fs.rm(file, { force: true }).catch(() => undefined);
192
+ // The record names its own output file; fall back to the `<id>.jsonl`
193
+ // convention {@link writeAgentAdoptionJob} writes when it does not.
194
+ const output = typeof parsed.output_file === "string" && parsed.output_file.length > 0
195
+ ? parsed.output_file
196
+ : path.join(jobsDir, `${name.slice(0, -".yaml".length)}.jsonl`);
197
+ // Only ever unlink an output file that lives in the jobs dir we just read.
198
+ if (path.dirname(path.resolve(output)) === path.resolve(jobsDir)) {
199
+ await fs.rm(output, { force: true }).catch(() => undefined);
200
+ }
201
+ removed++;
202
+ }
203
+ return removed;
204
+ }
205
+ /**
206
+ * Drop the job records of one or more CHATS (#732) — called when Paddock removes
207
+ * a chat from a project, so the record cannot outlive the transcript and keep
208
+ * the sidebar badge counting a chat that is no longer there to open.
209
+ */
210
+ export async function purgeSessionJobs(stateDir, sessionIds) {
211
+ if (sessionIds.length === 0)
212
+ return 0;
213
+ const wanted = new Set(sessionIds);
214
+ return purgeJobs(stateDir, (r) => typeof r.session_id === "string" && wanted.has(r.session_id));
215
+ }
216
+ /**
217
+ * Drop the job records of one or more AGENTS (#734) — called when a project is
218
+ * deleted, for every agent name that project owned (keeper, sweeper, hooks,
219
+ * triggers). Agent-keyed rather than project-keyed because the agent name is
220
+ * what the record actually carries.
221
+ *
222
+ * This is the containment half of the fix rather than the structural one: the
223
+ * durable answer to "a re-created project inherits the old one's records" is to
224
+ * key them by a stable project id instead of the user-controlled, reusable slug.
225
+ * That is a herdctl-side identity change (records are herdctl's format, written
226
+ * by its runtimes); purging on delete makes the leak impossible NOW without
227
+ * waiting for it, and stays correct afterwards.
228
+ */
229
+ export async function purgeAgentJobs(stateDir, agents) {
230
+ if (agents.length === 0)
231
+ return 0;
232
+ const wanted = new Set(agents);
233
+ return purgeJobs(stateDir, (r) => typeof r.agent === "string" && wanted.has(r.agent));
234
+ }
141
235
  /**
142
236
  * Point every herdctl job record for `sessionId` at the project's keeper so
143
237
  * the core attribution index (last-write-wins per session) lists the session
@@ -515,6 +515,52 @@ export declare class HerdctlService {
515
515
  * — so `retained` is how a caller tells "gone" from "no longer shown here".
516
516
  */
517
517
  deleteSession(agentName: string, sessionId: string): Promise<ChatDeletion>;
518
+ /**
519
+ * Drop the herdctl job records for chats that have left this instance (#732),
520
+ * so a record cannot outlive its transcript and keep the sidebar unread badge
521
+ * counting a chat there is no longer anything to open.
522
+ *
523
+ * Takes a LIST rather than one id, and is called by the delete routes rather
524
+ * than from inside {@link deleteSession}, for one reason: purging is a scan of
525
+ * the whole shared jobs directory, and the batch route deletes up to 500 chats
526
+ * in a loop. Per-chat purging would re-scan a 2,000-record directory 500 times
527
+ * (see `herdctl-jobs-index.ts` for what that costs). One pass, all ids.
528
+ *
529
+ * Callers pass only the chats whose transcript was actually REMOVED. Under
530
+ * `claude.transcripts: host` a delete RELEASES instead — the chat is still
531
+ * listed there, a known deferred gap (#693, asserted by
532
+ * `delete-chat-host-transcripts.test.ts`), and purging would close it as a
533
+ * side effect, because the job record is part of what re-attributes a
534
+ * structurally-discovered session to the keeper. Resolving somebody else's
535
+ * deferred decision by accident is not this change's business, and keeping the
536
+ * record for a chat that is still in the list is coherent anyway:
537
+ * `buildChatTurns` prunes against the live chat list, so the badge follows the
538
+ * list either way — and will stop counting the chat the moment #693 makes it
539
+ * leave.
540
+ *
541
+ * Best-effort: tidying state must never fail the delete the user asked for.
542
+ */
543
+ purgeSessionJobs(sessionIds: string[]): Promise<void>;
544
+ /**
545
+ * Drop the herdctl job records of every agent a project owned (#734) — the
546
+ * records half of a project delete.
547
+ *
548
+ * Job records are keyed by AGENT NAME, and an agent name is derived from the
549
+ * slug, which is derived from the project NAME. So without this, deleting
550
+ * "Foo" and creating a new "Foo" hands the new project the old one's run
551
+ * history: prompts, reply summaries, and a phantom `chatTurns` entry that
552
+ * shows an unread badge on a project with zero chats. Files, `.chats/` and
553
+ * `read-state.json` were already cleaned; these were the one thing left.
554
+ *
555
+ * Takes the same hook/trigger name lists {@link removeProjectAgent} does, so
556
+ * the two together are the full inverse of the create flow — and so they stay
557
+ * in step: if a delete ever has to sweep DESCENDANT workspaces (nesting), both
558
+ * need the same widening, not just one. Matching is on the exact agent name,
559
+ * so deleting `foo` cannot touch a sibling `foo-bar`.
560
+ *
561
+ * Best-effort — the project directory is already gone by the time we get here.
562
+ */
563
+ purgeProjectJobs(slug: string, hookNames?: string[], triggerNames?: string[]): Promise<void>;
518
564
  /**
519
565
  * Drop the agent-level session pointer when it names a transcript we just
520
566
  * removed. Issue #730 — silent data loss, and the fix is one `rm` of a file
@@ -903,6 +903,64 @@ export class HerdctlService {
903
903
  this.invalidateSessions(agentName);
904
904
  return { removed: false, retained: true };
905
905
  }
906
+ /**
907
+ * Drop the herdctl job records for chats that have left this instance (#732),
908
+ * so a record cannot outlive its transcript and keep the sidebar unread badge
909
+ * counting a chat there is no longer anything to open.
910
+ *
911
+ * Takes a LIST rather than one id, and is called by the delete routes rather
912
+ * than from inside {@link deleteSession}, for one reason: purging is a scan of
913
+ * the whole shared jobs directory, and the batch route deletes up to 500 chats
914
+ * in a loop. Per-chat purging would re-scan a 2,000-record directory 500 times
915
+ * (see `herdctl-jobs-index.ts` for what that costs). One pass, all ids.
916
+ *
917
+ * Callers pass only the chats whose transcript was actually REMOVED. Under
918
+ * `claude.transcripts: host` a delete RELEASES instead — the chat is still
919
+ * listed there, a known deferred gap (#693, asserted by
920
+ * `delete-chat-host-transcripts.test.ts`), and purging would close it as a
921
+ * side effect, because the job record is part of what re-attributes a
922
+ * structurally-discovered session to the keeper. Resolving somebody else's
923
+ * deferred decision by accident is not this change's business, and keeping the
924
+ * record for a chat that is still in the list is coherent anyway:
925
+ * `buildChatTurns` prunes against the live chat list, so the badge follows the
926
+ * list either way — and will stop counting the chat the moment #693 makes it
927
+ * leave.
928
+ *
929
+ * Best-effort: tidying state must never fail the delete the user asked for.
930
+ */
931
+ async purgeSessionJobs(sessionIds) {
932
+ await jobs.purgeSessionJobs(this.cfg.stateDir, sessionIds).catch(() => 0);
933
+ this.jobsIndexOrNull?.invalidate();
934
+ }
935
+ /**
936
+ * Drop the herdctl job records of every agent a project owned (#734) — the
937
+ * records half of a project delete.
938
+ *
939
+ * Job records are keyed by AGENT NAME, and an agent name is derived from the
940
+ * slug, which is derived from the project NAME. So without this, deleting
941
+ * "Foo" and creating a new "Foo" hands the new project the old one's run
942
+ * history: prompts, reply summaries, and a phantom `chatTurns` entry that
943
+ * shows an unread badge on a project with zero chats. Files, `.chats/` and
944
+ * `read-state.json` were already cleaned; these were the one thing left.
945
+ *
946
+ * Takes the same hook/trigger name lists {@link removeProjectAgent} does, so
947
+ * the two together are the full inverse of the create flow — and so they stay
948
+ * in step: if a delete ever has to sweep DESCENDANT workspaces (nesting), both
949
+ * need the same widening, not just one. Matching is on the exact agent name,
950
+ * so deleting `foo` cannot touch a sibling `foo-bar`.
951
+ *
952
+ * Best-effort — the project directory is already gone by the time we get here.
953
+ */
954
+ async purgeProjectJobs(slug, hookNames = [], triggerNames = []) {
955
+ const agents = [
956
+ keeperAgentName(slug),
957
+ sweeperAgentName(slug),
958
+ ...hookNames.map((name) => hookAgentName(slug, name)),
959
+ ...triggerNames.map((name) => triggerAgentName(slug, name)),
960
+ ];
961
+ await jobs.purgeAgentJobs(this.cfg.stateDir, agents).catch(() => 0);
962
+ this.jobsIndexOrNull?.invalidate();
963
+ }
906
964
  /**
907
965
  * Drop the agent-level session pointer when it names a transcript we just
908
966
  * removed. Issue #730 — silent data loss, and the fix is one `rm` of a file
@@ -74,7 +74,17 @@ export interface InstanceConfigFieldDto {
74
74
  help?: string;
75
75
  type: FieldType;
76
76
  enumValues?: readonly string[];
77
+ /** EFFECTIVE now: the value the running (frozen) process resolved at boot. */
77
78
  value: unknown;
79
+ /**
80
+ * PENDING: what this field would resolve to if the process restarted right
81
+ * now — i.e. what is in `paddock.config.yaml` this instant (or the built-in
82
+ * default where the file says nothing, or `value` where an env var wins). This
83
+ * is what the editor renders and what a save round-trips through (#722).
84
+ */
85
+ pendingValue: unknown;
86
+ /** `pendingValue` differs from `value` — the file has diverged from the process. */
87
+ pendingRestart: boolean;
78
88
  default: unknown;
79
89
  editable: boolean;
80
90
  sensitive: boolean;
@@ -92,9 +102,33 @@ export interface InstanceConfigDto {
92
102
  groups: InstanceConfigGroupDto[];
93
103
  /** Absolute path of the file a PUT writes to (informational). */
94
104
  configPath: string;
95
- /** Always false — instance config is frozen at boot; edits need a restart. */
96
- restartRequired: false;
105
+ /**
106
+ * At least one editable field's `pendingValue` differs from its `value`: the
107
+ * file on disk no longer describes the running process, and a restart would
108
+ * change behaviour. Was hardcoded `false` before #722 — which meant nothing
109
+ * ever told an operator their own save was unapplied.
110
+ */
111
+ restartRequired: boolean;
112
+ /**
113
+ * Fingerprint of the config file as read for THIS response (`null` when the
114
+ * file does not exist yet). A client echoes it back as `expectedVersion` on
115
+ * the PUT; the server refuses the write if the file changed meanwhile, so two
116
+ * tabs conflict loudly instead of silently last-writer-winning (#722).
117
+ */
118
+ configVersion: string | null;
119
+ /**
120
+ * Set when the file exists but could not be read or parsed. Pending values are
121
+ * unknowable in that state, so they fall back to the effective ones — better
122
+ * to say why than to silently report "nothing pending".
123
+ */
124
+ configFileError?: string;
97
125
  }
126
+ /**
127
+ * The current fingerprint of the config file (`null` when absent) — the PUT
128
+ * path's half of the optimistic-concurrency check. Deliberately re-reads rather
129
+ * than trusting a cached value: the point is to notice a write we did not make.
130
+ */
131
+ export declare function instanceConfigVersion(configPath: string): string | null;
98
132
  /**
99
133
  * Resolve the path a PUT writes to: an explicit `PADDOCK_CONFIG` env var wins
100
134
  * (the same rule {@link import("./config.js").loadConfigFile} reads it back
@@ -102,9 +136,25 @@ export interface InstanceConfigDto {
102
136
  */
103
137
  export declare function instanceConfigPath(cfg: PaddockConfig): string;
104
138
  /**
105
- * Build the grouped GET DTO from the resolved (frozen) config + live env. Each
106
- * field reports its current `value`, built-in `default`, and whether an env var
107
- * currently shadows it (so the UI renders env-shadowed fields read-only).
139
+ * Build the grouped GET DTO from the resolved (frozen) config, the live env AND
140
+ * the config file on disk. Each field reports:
141
+ *
142
+ * - `value` — EFFECTIVE now, out of the frozen boot config;
143
+ * - `pendingValue` — what a restart would resolve, out of the file this instant;
144
+ * - `pendingRestart` — those two differ.
145
+ *
146
+ * Reading the file is the load-bearing part (#722). While this built the DTO
147
+ * from the frozen config alone, `GET` could not observe ANY write — including
148
+ * the one the caller had just made — so a successful save appeared to revert,
149
+ * two tabs clobbered each other with nothing able to notice, and
150
+ * `restartRequired` had no choice but to be a hardcoded `false`.
151
+ *
152
+ * Pending values are computed for EDITABLE, non-env-shadowed fields only:
153
+ * - an env-shadowed field resolves to the same env value after a restart, so
154
+ * the file cannot make it diverge;
155
+ * - the read-only `advanced` bindings are normalised at boot (`dataDir` and
156
+ * friends are canonicalised, `port` is `Number()`-ed), so comparing them
157
+ * against raw file text would manufacture divergence that isn't there.
108
158
  */
109
159
  export declare function buildInstanceConfig(cfg: PaddockConfig): InstanceConfigDto;
110
160
  /** A rejected PUT: which field failed and why (surfaced as a 400). */
@@ -117,6 +167,7 @@ export declare class InstanceConfigError extends Error {
117
167
  * {@link InstanceConfigError} on the first offending field:
118
168
  * - an unknown key,
119
169
  * - a read-only / non-editable key,
170
+ * - a key an env var currently shadows,
120
171
  * - a value the field's `coerce` rejects.
121
172
  * Returns the coerced `{ key, value }` pairs ready to write. A field whose value
122
173
  * is `null` (an editable optional being cleared) is returned with `value: null`
@@ -29,7 +29,8 @@
29
29
  */
30
30
  import fs from "node:fs";
31
31
  import path from "node:path";
32
- import { parseDocument } from "yaml";
32
+ import { createHash } from "node:crypto";
33
+ import { parse, parseDocument } from "yaml";
33
34
  import { DRIVE_MODES, DEFAULT_DRIVE_MODE, MODELS, isKnownModel } from "./models.js";
34
35
  import { DEFAULT_MAX_SPAWN_DEPTH, isValidMaxSpawnDepth } from "./spawn-capability.js";
35
36
  import { DEFAULT_RECOVERY } from "./recovery-config.js";
@@ -57,32 +58,82 @@ export const GROUPS = [
57
58
  ];
58
59
  // --- coercion helpers -------------------------------------------------------
59
60
  const asBool = (raw) => typeof raw === "boolean" ? { ok: true, value: raw } : { ok: false, error: "must be a boolean" };
61
+ /**
62
+ * Parse a JSON patch value that is meant to be a number, WITHOUT `Number()`'s
63
+ * coercions (issue #723). `Number()` happily turns `null` into `0`, `true` into
64
+ * `1` and `[7]` into `7` — so `{"recovery.maxRetries": null}`, documented to
65
+ * CLEAR the override, used to write a very meaningful `0` instead. Only a real
66
+ * number, or a non-blank numeric string (what an `<input type=number>` sends),
67
+ * counts; everything else is `NaN` and gets rejected by the callers below.
68
+ */
69
+ const numeric = (raw) => {
70
+ if (typeof raw === "number")
71
+ return raw;
72
+ if (typeof raw === "string" && raw.trim() !== "")
73
+ return Number(raw.trim());
74
+ return NaN;
75
+ };
76
+ /**
77
+ * Ceiling for the numeric fields. Nothing downstream bounds these — a paste-o of
78
+ * `20000000` into a millisecond timeout is a 231-day debounce, and a token budget
79
+ * of 1e15 disables curation silently. A billion is far past any deliberate value
80
+ * (≈31 years in ms) and keeps every one of them a safe integer.
81
+ */
82
+ const MAX_NUMBER = 1e9;
83
+ /**
84
+ * Ceiling for a plain `string` field, in characters. These are names, paths and
85
+ * URLs; without a bound a 200 KB paste into `brand.name` becomes a 200 KB
86
+ * `paddock.config.yaml` that every boot must parse. The prompt-shaped `text`
87
+ * fields have their own, much larger {@link MAX_PROMPT_CHARS}.
88
+ */
89
+ const MAX_STRING_CHARS = 1024;
60
90
  const posInt = (raw) => {
61
- const n = Number(raw);
62
- return Number.isFinite(n) && Number.isInteger(n) && n > 0
91
+ const n = numeric(raw);
92
+ return Number.isInteger(n) && n > 0 && n <= MAX_NUMBER
63
93
  ? { ok: true, value: n }
64
- : { ok: false, error: "must be a positive integer" };
94
+ : { ok: false, error: `must be a positive integer (at most ${MAX_NUMBER})` };
65
95
  };
96
+ /** A non-negative integer, OR null/empty-string to clear the override (#723). */
66
97
  const nonNegInt = (raw) => {
67
- const n = Number(raw);
68
- return Number.isFinite(n) && Number.isInteger(n) && n >= 0
98
+ if (raw === null || raw === "" || raw === undefined)
99
+ return { ok: true, value: null };
100
+ const n = numeric(raw);
101
+ return Number.isInteger(n) && n >= 0 && n <= MAX_NUMBER
69
102
  ? { ok: true, value: n }
70
- : { ok: false, error: "must be a non-negative integer" };
103
+ : {
104
+ ok: false,
105
+ error: `must be a non-negative integer up to ${MAX_NUMBER} (or blank to use the default)`,
106
+ };
71
107
  };
72
108
  /** A non-negative number, OR null/empty-string to clear the override. */
73
109
  const optNonNegNumber = (raw) => {
74
110
  if (raw === null || raw === "" || raw === undefined)
75
111
  return { ok: true, value: null };
76
- const n = Number(raw);
77
- return Number.isFinite(n) && n >= 0
112
+ const n = numeric(raw);
113
+ return Number.isFinite(n) && n >= 0 && n <= MAX_NUMBER
78
114
  ? { ok: true, value: n }
79
- : { ok: false, error: "must be a non-negative number (or blank to use the default)" };
115
+ : { ok: false, error: `must be a non-negative number up to ${MAX_NUMBER} (or blank to use the default)` };
116
+ };
117
+ const nonEmptyString = (raw) => {
118
+ if (typeof raw !== "string")
119
+ return { ok: false, error: "must be a non-empty string" };
120
+ const trimmed = raw.trim();
121
+ if (trimmed.length === 0)
122
+ return { ok: false, error: "must be a non-empty string" };
123
+ if (trimmed.length > MAX_STRING_CHARS) {
124
+ return { ok: false, error: `must be at most ${MAX_STRING_CHARS} characters (got ${trimmed.length})` };
125
+ }
126
+ return { ok: true, value: trimmed };
80
127
  };
81
- const nonEmptyString = (raw) => typeof raw === "string" && raw.trim().length > 0
82
- ? { ok: true, value: raw.trim() }
83
- : { ok: false, error: "must be a non-empty string" };
84
128
  /** A string that may be blank (blank clears the override → falls back to default). */
85
- const optString = (raw) => typeof raw === "string" ? { ok: true, value: raw.trim() } : { ok: false, error: "must be a string" };
129
+ const optString = (raw) => {
130
+ if (typeof raw !== "string")
131
+ return { ok: false, error: "must be a string" };
132
+ const trimmed = raw.trim();
133
+ return trimmed.length <= MAX_STRING_CHARS
134
+ ? { ok: true, value: trimmed }
135
+ : { ok: false, error: `must be at most ${MAX_STRING_CHARS} characters (got ${trimmed.length})` };
136
+ };
86
137
  /**
87
138
  * Upper bound on a prompt-shaped field, in characters. Nothing enforces a limit
88
139
  * downstream — the text is concatenated into the system prompt of every single
@@ -120,17 +171,33 @@ const oneOf = (values) => (raw) => typeof raw === "string" && values.includes(ra
120
171
  ? { ok: true, value: raw }
121
172
  : { ok: false, error: `must be one of: ${values.join(", ")}` };
122
173
  const spawnDepth = (raw) => {
123
- const n = Number(raw);
174
+ // Same shape as `nonNegInt`, for the same #723 reason: `Number(null)` is 0,
175
+ // which IS a valid depth — so "clear this override" wrote the one depth that
176
+ // switches every child's self-MCP off. Depth 0 stays settable; it just has to
177
+ // be asked for.
178
+ if (raw === null || raw === "" || raw === undefined)
179
+ return { ok: true, value: null };
180
+ const n = numeric(raw);
124
181
  return isValidMaxSpawnDepth(n)
125
182
  ? { ok: true, value: n }
126
- : { ok: false, error: "must be a small non-negative integer" };
183
+ : { ok: false, error: "must be a small non-negative integer (or blank to use the default)" };
127
184
  };
128
185
  const hexColor = (raw) => typeof raw === "string" && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw.trim())
129
186
  ? { ok: true, value: raw.trim() }
130
187
  : { ok: false, error: "must be a hex color like #c2603c" };
188
+ /** Bounds on a free-form list field, for the same reason as {@link MAX_STRING_CHARS}. */
189
+ const MAX_LIST_ITEMS = 64;
190
+ const MAX_LIST_ITEM_CHARS = 256;
131
191
  const stringList = (raw) => {
132
192
  const list = sanitizeAllowedTypes(raw);
133
- return list ? { ok: true, value: list } : { ok: false, error: "must be a non-empty list of type/extension strings" };
193
+ if (!list)
194
+ return { ok: false, error: "must be a non-empty list of type/extension strings" };
195
+ if (list.length > MAX_LIST_ITEMS)
196
+ return { ok: false, error: `must have at most ${MAX_LIST_ITEMS} entries` };
197
+ if (list.some((s) => s.length > MAX_LIST_ITEM_CHARS)) {
198
+ return { ok: false, error: `each entry must be at most ${MAX_LIST_ITEM_CHARS} characters` };
199
+ }
200
+ return { ok: true, value: list };
134
201
  };
135
202
  /**
136
203
  * Coerce a raw model allow-list (issue #457 Step 2) into a de-duped list of
@@ -290,6 +357,55 @@ function envOverride(envVars, whenDefined = false) {
290
357
  }
291
358
  return undefined;
292
359
  }
360
+ /**
361
+ * Read + parse `paddock.config.yaml` for the GET path. Never throws: a missing
362
+ * file is the normal case, and a malformed one must not take down the screen
363
+ * that exists to fix it (the boot loader is the strict reader — see
364
+ * `loadConfigFile`).
365
+ */
366
+ function readConfigFileSnapshot(configPath) {
367
+ let raw;
368
+ try {
369
+ raw = fs.readFileSync(configPath, "utf8");
370
+ }
371
+ catch (err) {
372
+ if (err.code === "ENOENT")
373
+ return { data: null, version: null };
374
+ return { data: null, version: null, error: `failed to read ${configPath}: ${err.message}` };
375
+ }
376
+ const version = createHash("sha256").update(raw).digest("hex").slice(0, 16);
377
+ let parsed;
378
+ try {
379
+ parsed = parse(raw);
380
+ }
381
+ catch (err) {
382
+ return { data: null, version, error: `failed to parse ${configPath}: ${err.message}` };
383
+ }
384
+ // Empty / comments-only file → no overrides, same reading as loadConfigFile.
385
+ if (parsed === null || parsed === undefined)
386
+ return { data: {}, version };
387
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
388
+ return { data: null, version, error: `${configPath} is not a YAML mapping` };
389
+ }
390
+ return { data: parsed, version };
391
+ }
392
+ /**
393
+ * The current fingerprint of the config file (`null` when absent) — the PUT
394
+ * path's half of the optimistic-concurrency check. Deliberately re-reads rather
395
+ * than trusting a cached value: the point is to notice a write we did not make.
396
+ */
397
+ export function instanceConfigVersion(configPath) {
398
+ return readConfigFileSnapshot(configPath).version;
399
+ }
400
+ /** Structural equality for the value shapes a field can hold (scalars + string lists). */
401
+ function valuesEqual(a, b) {
402
+ if (Array.isArray(a) || Array.isArray(b)) {
403
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
404
+ return false;
405
+ return a.every((v, i) => v === b[i]);
406
+ }
407
+ return a === b;
408
+ }
293
409
  /**
294
410
  * Default filename for the instance-config file, resolved under the data dir.
295
411
  * Kept in sync with config.ts's private constant (both point at the same file);
@@ -310,9 +426,25 @@ export function instanceConfigPath(cfg) {
310
426
  return path.join(cfg.dataDir, DEFAULT_CONFIG_FILENAME);
311
427
  }
312
428
  /**
313
- * Build the grouped GET DTO from the resolved (frozen) config + live env. Each
314
- * field reports its current `value`, built-in `default`, and whether an env var
315
- * currently shadows it (so the UI renders env-shadowed fields read-only).
429
+ * Build the grouped GET DTO from the resolved (frozen) config, the live env AND
430
+ * the config file on disk. Each field reports:
431
+ *
432
+ * - `value` — EFFECTIVE now, out of the frozen boot config;
433
+ * - `pendingValue` — what a restart would resolve, out of the file this instant;
434
+ * - `pendingRestart` — those two differ.
435
+ *
436
+ * Reading the file is the load-bearing part (#722). While this built the DTO
437
+ * from the frozen config alone, `GET` could not observe ANY write — including
438
+ * the one the caller had just made — so a successful save appeared to revert,
439
+ * two tabs clobbered each other with nothing able to notice, and
440
+ * `restartRequired` had no choice but to be a hardcoded `false`.
441
+ *
442
+ * Pending values are computed for EDITABLE, non-env-shadowed fields only:
443
+ * - an env-shadowed field resolves to the same env value after a restart, so
444
+ * the file cannot make it diverge;
445
+ * - the read-only `advanced` bindings are normalised at boot (`dataDir` and
446
+ * friends are canonicalised, `port` is `Number()`-ed), so comparing them
447
+ * against raw file text would manufacture divergence that isn't there.
316
448
  */
317
449
  export function buildInstanceConfig(cfg) {
318
450
  const groups = GROUPS.map((g) => ({
@@ -322,9 +454,31 @@ export function buildInstanceConfig(cfg) {
322
454
  fields: [],
323
455
  }));
324
456
  const groupById = new Map(groups.map((g) => [g.id, g]));
457
+ const configPath = instanceConfigPath(cfg);
458
+ const file = readConfigFileSnapshot(configPath);
459
+ let restartRequired = false;
325
460
  for (const f of FIELDS) {
326
461
  const shadow = envOverride(f.envVars, f.envShadowWhenDefined);
327
462
  const raw = readPath(cfg, f.key);
463
+ // `undefined` (e.g. sweepMinIntervalMs unset, optional endpoint) → null so
464
+ // it JSON-serializes as an explicit absence rather than dropping the key.
465
+ const value = raw === undefined ? null : raw;
466
+ let pendingValue = value;
467
+ let pendingRestart = false;
468
+ if (f.editable && shadow === undefined && file.data) {
469
+ // Absent, or present-but-null (`debounceMs:` with nothing after it), both
470
+ // mean "no override" — `fileOr`/`fileOpt` degrade a null to the default.
471
+ const fileRaw = readPath(file.data, f.key);
472
+ pendingValue = fileRaw == null ? (f.default ?? null) : coerceForDisplay(f, fileRaw);
473
+ // A field the frozen config leaves unset IS its built-in default (that is
474
+ // what `default` documents), so normalise before comparing — otherwise
475
+ // e.g. `models`, unset and therefore null, would read as forever diverging
476
+ // from the catalog list the file's absence implies.
477
+ const effective = value === null ? (f.default ?? null) : value;
478
+ pendingRestart = !valuesEqual(pendingValue, effective);
479
+ if (pendingRestart)
480
+ restartRequired = true;
481
+ }
328
482
  const dto = {
329
483
  key: f.key,
330
484
  group: f.group,
@@ -332,9 +486,9 @@ export function buildInstanceConfig(cfg) {
332
486
  help: f.help,
333
487
  type: f.type,
334
488
  enumValues: f.enumValues,
335
- // `undefined` (e.g. sweepMinIntervalMs unset, optional endpoint) → null so
336
- // it JSON-serializes as an explicit absence rather than dropping the key.
337
- value: raw === undefined ? null : raw,
489
+ value,
490
+ pendingValue,
491
+ pendingRestart,
338
492
  default: f.default,
339
493
  editable: f.editable,
340
494
  sensitive: f.sensitive ?? false,
@@ -343,7 +497,26 @@ export function buildInstanceConfig(cfg) {
343
497
  };
344
498
  groupById.get(f.group)?.fields.push(dto);
345
499
  }
346
- return { groups, configPath: instanceConfigPath(cfg), restartRequired: false };
500
+ return {
501
+ groups,
502
+ configPath,
503
+ restartRequired,
504
+ configVersion: file.version,
505
+ ...(file.error ? { configFileError: file.error } : {}),
506
+ };
507
+ }
508
+ /**
509
+ * Normalise a value read back out of the file into the shape the writer would
510
+ * have produced, so a hand-typed `"1234"` doesn't read as diverging from a
511
+ * written `1234`. A value the field's own validator rejects is reported
512
+ * verbatim: the file really does say that, and the screen showing it is how an
513
+ * operator finds out (the boot loader would degrade it to the default).
514
+ */
515
+ function coerceForDisplay(f, raw) {
516
+ if (!f.coerce)
517
+ return raw;
518
+ const res = f.coerce(raw);
519
+ return res.ok ? res.value : raw;
347
520
  }
348
521
  /** A rejected PUT: which field failed and why (surfaced as a 400). */
349
522
  export class InstanceConfigError extends Error {
@@ -359,6 +532,7 @@ export class InstanceConfigError extends Error {
359
532
  * {@link InstanceConfigError} on the first offending field:
360
533
  * - an unknown key,
361
534
  * - a read-only / non-editable key,
535
+ * - a key an env var currently shadows,
362
536
  * - a value the field's `coerce` rejects.
363
537
  * Returns the coerced `{ key, value }` pairs ready to write. A field whose value
364
538
  * is `null` (an editable optional being cleared) is returned with `value: null`
@@ -373,6 +547,14 @@ export function validatePatch(patch) {
373
547
  if (!spec.editable || !spec.coerce) {
374
548
  throw new InstanceConfigError(`Setting is read-only: ${key}`, key);
375
549
  }
550
+ // `env > file > default`: writing a shadowed field to the file changes
551
+ // nothing an operator can observe, now or after a restart. The UI already
552
+ // renders these read-only; without this check the API happily returned 200 +
553
+ // `restartRequired: true` for a write that could never take effect.
554
+ const shadow = envOverride(spec.envVars, spec.envShadowWhenDefined);
555
+ if (shadow !== undefined) {
556
+ throw new InstanceConfigError(`${spec.label} (${key}) is set by the environment variable ${shadow}, which wins over the config file — change it there instead`, key);
557
+ }
376
558
  const res = spec.coerce(raw);
377
559
  if (!res.ok)
378
560
  throw new InstanceConfigError(`${spec.label} (${key}) ${res.error}`, key);