@edspencer/paddock 0.64.0 → 0.66.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 (97) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/packages/server/dist/cli/args.d.ts +4 -4
  4. package/packages/server/dist/cli/args.js +5 -5
  5. package/packages/server/dist/cli/paddock.js +1 -1
  6. package/packages/server/dist/config.d.ts +7 -0
  7. package/packages/server/dist/config.js +9 -1
  8. package/packages/server/dist/herdctl.d.ts +89 -0
  9. package/packages/server/dist/herdctl.js +169 -2
  10. package/packages/server/dist/instance-config.js +12 -1
  11. package/packages/server/dist/management-ops.js +40 -1
  12. package/packages/server/dist/management-policy.d.ts +4 -3
  13. package/packages/server/dist/management-policy.js +5 -2
  14. package/packages/server/dist/projects.d.ts +23 -3
  15. package/packages/server/dist/projects.js +54 -7
  16. package/packages/server/dist/routes/chats.js +77 -6
  17. package/packages/server/dist/routes/projects.js +22 -2
  18. package/packages/server/dist/schema-version.d.ts +111 -0
  19. package/packages/server/dist/schema-version.js +142 -0
  20. package/packages/server/dist/self-mcp-descriptions.d.ts +1 -0
  21. package/packages/server/dist/self-mcp-descriptions.js +14 -1
  22. package/packages/server/dist/self-mcp-types.d.ts +26 -4
  23. package/packages/server/dist/self-mcp-write.d.ts +21 -0
  24. package/packages/server/dist/self-mcp-write.js +44 -0
  25. package/packages/server/dist/self-mcp.d.ts +7 -6
  26. package/packages/server/dist/self-mcp.js +36 -10
  27. package/packages/server/dist/subagents.d.ts +65 -0
  28. package/packages/server/dist/subagents.js +159 -19
  29. package/packages/server/dist/tooldetails.d.ts +10 -4
  30. package/packages/server/dist/tooldetails.js +23 -6
  31. package/packages/server/dist/trusted-proxy.js +1 -1
  32. package/packages/server/dist/turn-interlock.d.ts +101 -0
  33. package/packages/server/dist/turn-interlock.js +76 -0
  34. package/packages/server/package.json +1 -1
  35. package/packages/web/dist/assets/InstanceConfigPage-DBrwtZjx.js +2 -0
  36. package/packages/web/dist/assets/{MarkdownRenderer-R6YzfI9Y.js → MarkdownRenderer-Bn6VPBdL.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectRedirect-Cy-7Thc-.js → ProjectRedirect-CQmDBtd-.js} +1 -1
  38. package/packages/web/dist/assets/ProjectView-BVKu9oIg.js +13 -0
  39. package/packages/web/dist/assets/{ProjectsGrid-C9XHf4eS.js → ProjectsGrid-BW56zqke.js} +1 -1
  40. package/packages/web/dist/assets/{arc-B_42F7Yk.js → arc-CGy6WkLl.js} +1 -1
  41. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BAXzSOyw.js → architectureDiagram-3BPJPVTR-DJH1WIxY.js} +1 -1
  42. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BdYEdA-3.js → blockDiagram-GPEHLZMM-Cufb_pvX.js} +1 -1
  43. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-Cc4o15R5.js → c4Diagram-AAUBKEIU-glqeg1Ky.js} +1 -1
  44. package/packages/web/dist/assets/channel-C4kPYn_d.js +2 -0
  45. package/packages/web/dist/assets/{chunk-2J33WTMH-CeuxHjIm.js → chunk-2J33WTMH-SOFTHYgh.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-4BX2VUAB-DnZfrAaV.js → chunk-4BX2VUAB-DFxFAiG8.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-55IACEB6-DFE6UYOT.js → chunk-55IACEB6-CEEDxZVG.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-727SXJPM-YVHQ5cHc.js → chunk-727SXJPM--3eiptsh.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-AQP2D5EJ-5BYSbg-F.js → chunk-AQP2D5EJ-DIHIugCo.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-FMBD7UC4-HBcelndR.js → chunk-FMBD7UC4-BmxWZl5c.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-ND2GUHAM-2AwEsdxJ.js → chunk-ND2GUHAM-hMHtJs_I.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-QZHKN3VN-BPpaZ6ct.js → chunk-QZHKN3VN-CRnnasyy.js} +1 -1
  53. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCg6gTVh.js +2 -0
  54. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCg6gTVh.js +2 -0
  55. package/packages/web/dist/assets/{core-Cu_4vw1U.js → core-CGW2CVAY.js} +1 -1
  56. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-DWkF0NrF.js → cose-bilkent-S5V4N54A-kcl9qSfK.js} +1 -1
  57. package/packages/web/dist/assets/{dagre-BM42HDAG-DTgNmO9I.js → dagre-BM42HDAG-BiLsFx1W.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-2AECGRRQ-DC28b7YS.js → diagram-2AECGRRQ-BcIpoY66.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-5GNKFQAL-_WvuLno6.js → diagram-5GNKFQAL-BK5p5kJa.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-KO2AKTUF-BEYxPfWq.js → diagram-KO2AKTUF-BmJYalCP.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-LMA3HP47-Biyev7x0.js → diagram-LMA3HP47-BHs0SWEM.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-OG6HWLK6-u47NLW3S.js → diagram-OG6HWLK6-CSs8hie4.js} +1 -1
  63. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Bud2ISlK.js → erDiagram-TEJ5UH35-4qd_Td27.js} +1 -1
  64. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-D-18vLQ1.js → flowDiagram-I6XJVG4X-CstUgr7V.js} +1 -1
  65. package/packages/web/dist/assets/{format-CQ2BDlby.js → format-B_kAvAW3.js} +1 -1
  66. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CzyRX39N.js → ganttDiagram-6RSMTGT7-DTn9OuIc.js} +1 -1
  67. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DLogfrjg.js → gitGraphDiagram-PVQCEYII-E_bCor2O.js} +1 -1
  68. package/packages/web/dist/assets/{index-B0gJ0qWe.js → index-B-QAOq9a.js} +2 -2
  69. package/packages/web/dist/assets/index-B3K5nsLe.css +1 -0
  70. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-OgNPt6RG.js → infoDiagram-5YYISTIA-foRUrIOQ.js} +1 -1
  71. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CrM0t0_X.js → ishikawaDiagram-YF4QCWOH-CTON8v5t.js} +1 -1
  72. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-xbxfHfwR.js → journeyDiagram-JHISSGLW-D-qwGjr7.js} +1 -1
  73. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DpszBaZb.js → kanban-definition-UN3LZRKU-5l4y83rE.js} +1 -1
  74. package/packages/web/dist/assets/{linear-BLujdbjQ.js → linear-B1GiUWpI.js} +1 -1
  75. package/packages/web/dist/assets/{mermaid.core-DzEhy9Qq.js → mermaid.core-btQIOU6d.js} +4 -4
  76. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DaH3Lmoo.js → mindmap-definition-RKZ34NQL-DduczfMv.js} +1 -1
  77. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BxeW1kuf.js → pieDiagram-4H26LBE5-D86uGusc.js} +1 -1
  78. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BKs2CT0r.js → quadrantDiagram-W4KKPZXB-Bo5EW3MZ.js} +1 -1
  79. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CVdO-hXD.js → requirementDiagram-4Y6WPE33-C9aZJK93.js} +1 -1
  80. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-yusj_kJC.js → sankeyDiagram-5OEKKPKP-b__W6SAJ.js} +1 -1
  81. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C0h-9c8Y.js → sequenceDiagram-3UESZ5HK-Lr4Df8kX.js} +1 -1
  82. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-BSBY8Psb.js → stateDiagram-AJRCARHV-R0XxGxD5.js} +1 -1
  83. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-i2pZN-iR.js +2 -0
  84. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DEFUdNb8.js → timeline-definition-PNZ67QCA-D42Je9LW.js} +1 -1
  85. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-DunhMqxK.js → vennDiagram-CIIHVFJN-KCu14-QB.js} +1 -1
  86. package/packages/web/dist/assets/{wardley-L42UT6IY-CXJsBGKl.js → wardley-L42UT6IY-Cfir3V9o.js} +1 -1
  87. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DfT_-dqX.js → wardleyDiagram-YWT4CUSO-BkXjbTXu.js} +1 -1
  88. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BysTtULW.js → xychartDiagram-2RQKCTM6-BVT60Pln.js} +1 -1
  89. package/packages/web/dist/index.html +2 -2
  90. package/packages/web/dist/sw.js +1 -1
  91. package/packages/web/dist/assets/InstanceConfigPage-Blr1a-hx.js +0 -2
  92. package/packages/web/dist/assets/ProjectView-bMX4GEau.js +0 -13
  93. package/packages/web/dist/assets/channel-BZnRb_8C.js +0 -2
  94. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BGCpL0-K.js +0 -2
  95. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BGCpL0-K.js +0 -2
  96. package/packages/web/dist/assets/index-B6etJCHf.css +0 -1
  97. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-4DGE0Z1g.js +0 -2
package/README.md CHANGED
@@ -7,7 +7,7 @@ sessions organized by project, in a web UI.
7
7
  npx @edspencer/paddock
8
8
  ```
9
9
 
10
- Then open <http://127.0.0.1:4000>.
10
+ Then open <http://127.0.0.1:7233>.
11
11
 
12
12
  Or point it at work you've already done — see [Open your own project](#open-your-own-project).
13
13
 
@@ -107,7 +107,7 @@ Without `--here`, Paddock never touches the directory you ran it from.
107
107
  ## Options
108
108
 
109
109
  ```
110
- -p, --port <port> HTTP/WS port (default 4000)
110
+ -p, --port <port> HTTP/WS port (default 7233)
111
111
  --host <host> Bind address (default 127.0.0.1)
112
112
  -d, --data-dir <path> Projects + state (default ~/.paddock)
113
113
  --here Open the CURRENT directory as the workspace
@@ -138,7 +138,7 @@ A multi-arch Docker image is published alongside this package, and is the better
138
138
  fit for a server deployment:
139
139
 
140
140
  ```sh
141
- docker run -d -p 127.0.0.1:4000:4000 -v /srv/paddock-data:/data \
141
+ docker run -d -p 127.0.0.1:7233:7233 -v /srv/paddock-data:/data \
142
142
  -e CLAUDE_CODE_OAUTH_TOKEN=… \
143
143
  -e PADDOCK_DANGEROUSLY_ALLOW_OPEN=1 \
144
144
  ghcr.io/edspencer/paddock:latest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.64.0",
3
+ "version": "0.66.0",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -48,9 +48,9 @@ export declare function nodeVersionProblem(nodeVersion: string): string | undefi
48
48
  /**
49
49
  * Translate a listen failure into something a human can act on.
50
50
  *
51
- * `EADDRINUSE` on 4000 is the likeliest first-run failure — a popular port and
52
- * Paddock's own default — and Node's raw error names neither the port nor the
53
- * flag that fixes it.
51
+ * `EADDRINUSE` is the likeliest first-run failure — usually a second Paddock, or
52
+ * a Temporal frontend, which defaults to the same 7233 — and Node's raw error
53
+ * names neither the port nor the flag that fixes it.
54
54
  */
55
55
  export declare function explainListenError(err: unknown, host: string, port: string): string;
56
- export declare const USAGE = "paddock \u2014 run a Paddock instance locally\n\nUsage\n npx @edspencer/paddock [options]\n\nOptions\n -p, --port <port> HTTP/WS port (default 4000, or $PORT)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Projects + state (default ~/.paddock, or $PADDOCK_DATA_DIR)\n --here Open the CURRENT directory as the workspace (see below)\n -o, --open Open the app in your browser once it is listening\n --verbose Show the server's own logs (quiet by default)\n -v, --version Print the Paddock version and exit\n -h, --help Show this help\n\nOpening a directory (--here)\n Run `paddock --here` inside a project and Paddock opens THAT directory as its\n workspace: Claude works in your files, and any Claude Code sessions you already\n have for the directory are offered for import. It:\n\n \u00B7 creates .paddock/ in the directory for this workspace's own state\n \u00B7 creates .chats/ for transcripts, and adds both to .gitignore\n \u00B7 offers your ~/.claude sessions for this directory for import \u2014 nothing\n there is moved, copied or linked until you confirm\n\n Once done, later runs in the same directory resume it \u2014 no flag needed.\n Without --here, Paddock never touches the directory you ran it from.\n\nCredentials\n Paddock drives Claude Code, so it needs Claude credentials \u2014 and if you\n already use Claude Code on this machine, it uses the login you already have.\n That is a macOS Keychain entry on a Mac, or your ~/.claude/.credentials.json\n elsewhere (symlinked in, never copied). Reading a login writes nothing.\n\n Otherwise: a CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment,\n or a one-off `CLAUDE_CONFIG_DIR=<data-dir>/claude-home claude login`. With no\n login at all anywhere, run `claude setup-token`. Paddock says at startup when\n it can find none.\n\nSharing your Claude Code state\n Apart from that login, Paddock writes nothing outside its data dir by\n default: transcripts go to each project's .chats/, and your ~/.claude is read\n for config only. Each thing it can share is one key in\n <data-dir>/paddock.config.yaml:\n\n claude:\n transcripts: host # own | host, default own\n credentials: host # own | host, default host\n instructions: host # own | host, default own\n hooks: host # own | host, default own\n mcpServers: host # own | host, default own\n\n transcripts: host makes a chat and a `claude --resume` in the same directory\n the same file, live in both directions; deleting such a chat in Paddock\n releases it rather than removing it, because it is your history rather than\n Paddock's copy. credentials: own is the opt-out from sharing the login above.\n\n instructions: host loads your ~/.claude CLAUDE.md, agents/, commands/ and\n plugins/. Off by default, which is a change: your curated CLAUDE.md does not\n reach Paddock's agents unless you say so. Each project's own CLAUDE.md always\n does.\n\n hooks: host runs the shell commands your ~/.claude/settings.json binds to\n tool use. Off by default \u2014 inheriting someone's hooks is not something to\n discover after the fact. Its other keys (permissions, model, statusline)\n apply either way: under hooks: own Paddock writes its own settings.json\n carrying them with hooks dropped, regenerated at startup.\n\n mcpServers: host attaches the MCP servers declared in your ~/.claude.json \u2014\n the top-level ones, plus a project's own when its directory matches. To give\n this instance a server your machine doesn't have, declare it instead in a\n sibling mcpServers: block of the same file; use env:VAR_NAME anywhere a\n string goes so tokens stay out of it. That keeps a token out of the file, not\n out of `ps`: leave driveMode on its default (session) for a server holding\n one, since batch passes the definition to claude as a command-line argument.\n\nYour data\n Everything lives in one directory \u2014 ~/.paddock unless you pass --data-dir.\n Projects, chat transcripts and settings all persist there between runs. Move\n that directory to move your instance; delete it to start over. Nothing is\n stored anywhere else.\n\nNotes\n Binds loopback with authentication disabled, which is safe for a laptop. To\n expose it on a network, set PADDOCK_AUTH_MODE first \u2014 Paddock refuses to bind\n a routable interface wide open. See AUTH.md.\n\nDocs: https://github.com/edspencer/paddock";
56
+ export declare const USAGE = "paddock \u2014 run a Paddock instance locally\n\nUsage\n npx @edspencer/paddock [options]\n\nOptions\n -p, --port <port> HTTP/WS port (default 7233, or $PORT)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Projects + state (default ~/.paddock, or $PADDOCK_DATA_DIR)\n --here Open the CURRENT directory as the workspace (see below)\n -o, --open Open the app in your browser once it is listening\n --verbose Show the server's own logs (quiet by default)\n -v, --version Print the Paddock version and exit\n -h, --help Show this help\n\nOpening a directory (--here)\n Run `paddock --here` inside a project and Paddock opens THAT directory as its\n workspace: Claude works in your files, and any Claude Code sessions you already\n have for the directory are offered for import. It:\n\n \u00B7 creates .paddock/ in the directory for this workspace's own state\n \u00B7 creates .chats/ for transcripts, and adds both to .gitignore\n \u00B7 offers your ~/.claude sessions for this directory for import \u2014 nothing\n there is moved, copied or linked until you confirm\n\n Once done, later runs in the same directory resume it \u2014 no flag needed.\n Without --here, Paddock never touches the directory you ran it from.\n\nCredentials\n Paddock drives Claude Code, so it needs Claude credentials \u2014 and if you\n already use Claude Code on this machine, it uses the login you already have.\n That is a macOS Keychain entry on a Mac, or your ~/.claude/.credentials.json\n elsewhere (symlinked in, never copied). Reading a login writes nothing.\n\n Otherwise: a CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment,\n or a one-off `CLAUDE_CONFIG_DIR=<data-dir>/claude-home claude login`. With no\n login at all anywhere, run `claude setup-token`. Paddock says at startup when\n it can find none.\n\nSharing your Claude Code state\n Apart from that login, Paddock writes nothing outside its data dir by\n default: transcripts go to each project's .chats/, and your ~/.claude is read\n for config only. Each thing it can share is one key in\n <data-dir>/paddock.config.yaml:\n\n claude:\n transcripts: host # own | host, default own\n credentials: host # own | host, default host\n instructions: host # own | host, default own\n hooks: host # own | host, default own\n mcpServers: host # own | host, default own\n\n transcripts: host makes a chat and a `claude --resume` in the same directory\n the same file, live in both directions; deleting such a chat in Paddock\n releases it rather than removing it, because it is your history rather than\n Paddock's copy. credentials: own is the opt-out from sharing the login above.\n\n instructions: host loads your ~/.claude CLAUDE.md, agents/, commands/ and\n plugins/. Off by default, which is a change: your curated CLAUDE.md does not\n reach Paddock's agents unless you say so. Each project's own CLAUDE.md always\n does.\n\n hooks: host runs the shell commands your ~/.claude/settings.json binds to\n tool use. Off by default \u2014 inheriting someone's hooks is not something to\n discover after the fact. Its other keys (permissions, model, statusline)\n apply either way: under hooks: own Paddock writes its own settings.json\n carrying them with hooks dropped, regenerated at startup.\n\n mcpServers: host attaches the MCP servers declared in your ~/.claude.json \u2014\n the top-level ones, plus a project's own when its directory matches. To give\n this instance a server your machine doesn't have, declare it instead in a\n sibling mcpServers: block of the same file; use env:VAR_NAME anywhere a\n string goes so tokens stay out of it. That keeps a token out of the file, not\n out of `ps`: leave driveMode on its default (session) for a server holding\n one, since batch passes the definition to claude as a command-line argument.\n\nYour data\n Everything lives in one directory \u2014 ~/.paddock unless you pass --data-dir.\n Projects, chat transcripts and settings all persist there between runs. Move\n that directory to move your instance; delete it to start over. Nothing is\n stored anywhere else.\n\nNotes\n Binds loopback with authentication disabled, which is safe for a laptop. To\n expose it on a network, set PADDOCK_AUTH_MODE first \u2014 Paddock refuses to bind\n a routable interface wide open. See AUTH.md.\n\nDocs: https://github.com/edspencer/paddock";
@@ -95,9 +95,9 @@ export function nodeVersionProblem(nodeVersion) {
95
95
  /**
96
96
  * Translate a listen failure into something a human can act on.
97
97
  *
98
- * `EADDRINUSE` on 4000 is the likeliest first-run failure — a popular port and
99
- * Paddock's own default — and Node's raw error names neither the port nor the
100
- * flag that fixes it.
98
+ * `EADDRINUSE` is the likeliest first-run failure — usually a second Paddock, or
99
+ * a Temporal frontend, which defaults to the same 7233 — and Node's raw error
100
+ * names neither the port nor the flag that fixes it.
101
101
  */
102
102
  export function explainListenError(err, host, port) {
103
103
  const code = err?.code;
@@ -108,7 +108,7 @@ export function explainListenError(err, host, port) {
108
108
  }
109
109
  if (code === "EACCES") {
110
110
  return (`not allowed to bind ${host}:${port}.\n` +
111
- `Ports below 1024 need elevated privileges — use a higher one: paddock --port 4000`);
111
+ `Ports below 1024 need elevated privileges — use a higher one: paddock --port 7233`);
112
112
  }
113
113
  return String(err?.message ?? err);
114
114
  }
@@ -118,7 +118,7 @@ Usage
118
118
  npx @edspencer/paddock [options]
119
119
 
120
120
  Options
121
- -p, --port <port> HTTP/WS port (default 4000, or $PORT)
121
+ -p, --port <port> HTTP/WS port (default 7233, or $PORT)
122
122
  --host <host> Bind address (default 127.0.0.1)
123
123
  -d, --data-dir <path> Projects + state (default ~/.paddock, or $PADDOCK_DATA_DIR)
124
124
  --here Open the CURRENT directory as the workspace (see below)
@@ -276,7 +276,7 @@ async function main() {
276
276
  // the login `CLAUDE_CONFIG_DIR` scoping hides (#683). It logs at `warn`, which
277
277
  // survives the quiet default set just above. Continuity (chosen above) usually
278
278
  // means it has nothing to say, because there is nothing left to bridge.
279
- const port = process.env.PORT ?? "4000";
279
+ const port = process.env.PORT ?? "7233";
280
280
  const host = process.env.HOST ?? "127.0.0.1";
281
281
  // Imported dynamically, AFTER the env above is set: a static import would
282
282
  // pull in app.ts -> config.ts and resolve config against the wrong data dir.
@@ -475,6 +475,13 @@ export interface PaddockConfig {
475
475
  * top-level record sections here.
476
476
  */
477
477
  export interface PaddockConfigFile {
478
+ /**
479
+ * Which version of THIS format the file is written in (#724). Absent reads as
480
+ * `1` — the shape every pre-adoption file already has — and a version newer
481
+ * than `CONFIG_SCHEMA_VERSION` refuses the boot rather than being
482
+ * lenient-parsed. See `schema-version.ts` for when to bump it.
483
+ */
484
+ schemaVersion?: number | string;
478
485
  port?: number | string;
479
486
  host?: string;
480
487
  dataDir?: string;
@@ -23,6 +23,7 @@ import fs from "node:fs";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import YAML from "yaml";
25
25
  import { DEFAULT_DRIVE_MODE, isKnownDriveMode, isKnownModel, } from "./models.js";
26
+ import { SCHEMA_VERSION_KEY, configSchemaRefusal } from "./schema-version.js";
26
27
  import { DEFAULT_MAX_SPAWN_DEPTH, isValidMaxSpawnDepth } from "./spawn-capability.js";
27
28
  import { DEFAULT_TRANSCRIPTS_MODE, isKnownTranscriptsMode, } from "./transcripts.js";
28
29
  import { DEFAULT_CREDENTIALS_MODE, isKnownCredentialsMode, } from "./claude-credentials.js";
@@ -154,6 +155,13 @@ export function loadConfigFile(dataDir) {
154
155
  // an object (which would crash with an unclear TypeError). Deeper `null`s and
155
156
  // wrong-typed sections already degrade to defaults via fileOr/fileOpt.
156
157
  const obj = parsed;
158
+ // The downgrade guard (#724), BEFORE any lenient interpretation below. A file
159
+ // from the future must never be half-read: every unknown key here is dropped,
160
+ // and `writeInstanceConfig` would then persist the loss. Fail closed, exactly
161
+ // as `resolveClaudeHome` does.
162
+ const refusal = configSchemaRefusal(obj[SCHEMA_VERSION_KEY], configPath);
163
+ if (refusal !== undefined)
164
+ throw new Error(refusal);
157
165
  for (const key of Object.keys(obj)) {
158
166
  if (obj[key] === null)
159
167
  delete obj[key];
@@ -276,7 +284,7 @@ export function loadPaddockConfig() {
276
284
  const legacyClaudeHome = userClaudeHome();
277
285
  const defaultWebDist = resolveDefaultWebDist(import.meta.url);
278
286
  return Object.freeze({
279
- port: Number(envOr("PORT", fileOr(file.port, "4000"))),
287
+ port: Number(envOr("PORT", fileOr(file.port, "7233"))),
280
288
  // Safe by default (#435): bind loopback unless explicitly told otherwise, so
281
289
  // a fresh source/tarball run is network-closed. Non-loopback + no auth is then
282
290
  // gated by the bind-safety guard (see bind-safety.ts). Existing deployments
@@ -119,6 +119,31 @@ export interface ChatTurnOptions {
119
119
  */
120
120
  export { keeperAgentName, keeperSlugFromAgent, SWEEPER_PREFIX, sweeperAgentName, HOOK_AGENT_PREFIX, hookAgentName, TRIGGER_AGENT_PREFIX, triggerAgentName, visibleProjectAgentNames, BROWSER_MCP_TOOL, DENIED_TOOLS, browserMcpServers, KEEPER_MAX_CONCURRENT, KEEPER_SESSION_TIMEOUT, } from "./herdctl-agent-names.js";
121
121
  export declare function sliceTranscriptAtUuid(raw: string, cutUuid: string, endOnCompletedAssistant?: boolean): string | null;
122
+ /**
123
+ * Trim a transcript back to the last point at which every `tool_use` has its
124
+ * `tool_result` (issue #731).
125
+ *
126
+ * FORK is the caller. Unlike delete/promote/revert, forking mid-turn is not an
127
+ * accident to be interlocked away — it is a designed, load-bearing feature: the
128
+ * `fork_chat` fan-out (#214) is invoked BY a keeper from inside its own running
129
+ * turn, and `forkKickoffPrompt` even apologises to the child for the truncated
130
+ * final exchange. Refusing a fork while the source is running would break that
131
+ * contract outright.
132
+ *
133
+ * What we can fix is the artifact. A snapshot taken mid-tool-call ends on an
134
+ * assistant `tool_use` whose `tool_result` had not been written yet, and the
135
+ * real Anthropic Messages API REJECTS that shape on resume ("tool_use ids were
136
+ * found without tool_result blocks"), so the fork is born unresumable. Cutting
137
+ * the copy back to the last balanced boundary costs the child an exchange it was
138
+ * told to ignore anyway, and buys a transcript that loads.
139
+ *
140
+ * The source transcript is never touched — this only ever shapes the COPY.
141
+ *
142
+ * Returns `raw` byte-identical when nothing is unpaired (the overwhelmingly
143
+ * common idle-fork case), and also when trimming would leave nothing at all — an
144
+ * empty chat is a worse outcome than a suspect one.
145
+ */
146
+ export declare function trimToPairedToolBoundary(raw: string): string;
122
147
  export declare class HerdctlService {
123
148
  private readonly cfg;
124
149
  private readonly mcpSources;
@@ -490,6 +515,58 @@ export declare class HerdctlService {
490
515
  * — so `retained` is how a caller tells "gone" from "no longer shown here".
491
516
  */
492
517
  deleteSession(agentName: string, sessionId: string): Promise<ChatDeletion>;
518
+ /**
519
+ * Drop the agent-level session pointer when it names a transcript we just
520
+ * removed. Issue #730 — silent data loss, and the fix is one `rm` of a file
521
+ * that is already dangling by the time we get here.
522
+ *
523
+ * herdctl keeps ONE "this agent's current session" pointer per agent at
524
+ * `<stateDir>/sessions/<agent>.json`, rewritten after every batch turn — so it
525
+ * always names whichever chat ran most recently. It exists for exactly one
526
+ * purpose: resume that session when a caller asks for a turn WITHOUT naming a
527
+ * session. Paddock's chat sends always name one, so we never read it.
528
+ *
529
+ * It still reaches us, via how the JobExecutor reacts to it going stale.
530
+ * Deleting the most-recently-active chat leaves the pointer naming a transcript
531
+ * that is no longer on disk. On the NEXT turn — any chat, `resume: A`
532
+ * explicitly — the executor's timeout-aware read of the pointer judges it
533
+ * `file_not_found`, clears it, and reports "no session". The branch that
534
+ * handles that case then refuses to honor the caller's `resume`, because a
535
+ * pointer HAD existed a moment ago and the executor reads that as "this agent's
536
+ * session just expired, so start fresh" (herdctl's `hadAgentSession` gate,
537
+ * job-executor.js). It is the wrong reading: the pointer named B, the caller
538
+ * asked for A, and A's transcript is right there. The turn starts a brand-new
539
+ * session, the UI keeps showing A, and the user's message lands somewhere they
540
+ * will only find by reloading.
541
+ *
542
+ * That misreading is an upstream bug and the real fix belongs there — the gate
543
+ * should compare the cleared pointer's id against the caller's `resume` rather
544
+ * than treating any prior pointer as disqualifying (filed as
545
+ * edspencer/herdctl#448).
546
+ * What we do here is narrower and true regardless: a pointer to a transcript
547
+ * Paddock has just deleted is dangling, and herdctl would clear it at the next
548
+ * turn anyway — we clear it now, at the moment we make it dangle, so the next
549
+ * turn sees a clean "this agent owns no session" and adopts the caller's
550
+ * explicit `resume` (which is the same path a process restart takes, and why
551
+ * this always "worked after a restart").
552
+ *
553
+ * Only clears a pointer that names THIS session: another chat's pointer is
554
+ * live state and none of our business. Best-effort throughout — failing to
555
+ * tidy state must never fail the delete the user asked for.
556
+ *
557
+ * Called from every place Paddock removes a transcript from an agent's working
558
+ * directory: the `own` delete path, {@link promoteSession} (which moves the
559
+ * file out from under the source keeper by the same call), and the two
560
+ * import-undo paths that delete a copy an import placed. `archive` leaves the
561
+ * file alone, so it never dangles the pointer — which matches the reproduction
562
+ * exactly: archive is fine, delete and promote are not. Under
563
+ * `claude.transcripts: host` nothing is removed, so there is nothing to drop.
564
+ *
565
+ * The SDK session path (`driveMode: session`, the default) never reads the
566
+ * pointer for an explicit resume, so it is structurally immune; this is a
567
+ * `driveMode: batch` fix.
568
+ */
569
+ private dropAgentSessionPointer;
493
570
  /**
494
571
  * Set (or clear) a chat's custom display name. Writes through the fleet's
495
572
  * shared SessionMetadataStore so a subsequent getAgentSessions reflects it
@@ -622,6 +699,18 @@ export declare class HerdctlService {
622
699
  * failure, so a broken Stop looked like a no-op).
623
700
  */
624
701
  cancel(jobId: string): Promise<boolean>;
702
+ /**
703
+ * End a managed chat SESSION outright, by session id (issue #731). Where
704
+ * {@link cancel} ends the model turn — and on the session runtime deliberately
705
+ * leaves the session (and its `claude` subprocess) open for the next message —
706
+ * this closes the session itself, so nothing is left holding the transcript
707
+ * path open ahead of a destructive operation.
708
+ *
709
+ * Best-effort and null-safe: `false` when there was no live managed session to
710
+ * reap (the batch runtime never has one), or when the fleet isn't up. Callers
711
+ * decide liveness from {@link isSdkSessionLive}, not from this return value.
712
+ */
713
+ reapSession(sessionId: string): boolean;
625
714
  /**
626
715
  * Run a project's sweeper (curator) agent with a fresh session and the given
627
716
  * prompt. Used OUT OF BAND by SweepService — never from the user-chat path —
@@ -46,7 +46,7 @@
46
46
  * That last nuance is now closed too: core exposes `fleet.invalidateSessions(name)`
47
47
  * and paddock calls it after each turn (`ws-turn.ts`) and around fork/promote.
48
48
  */
49
- import { FleetManager, countPendingAsyncQueueEntries, getCliSessionFile, } from "@herdctl/core";
49
+ import { FleetManager, clearSession, countPendingAsyncQueueEntries, getCliSessionFile, getSessionInfo, } from "@herdctl/core";
50
50
  import { consumeResumedTurn, consumeBackgroundTurns } from "./resume-drain.js";
51
51
  import { createSDKMessageHandler } from "@herdctl/chat";
52
52
  import { promises as fs } from "node:fs";
@@ -162,6 +162,71 @@ export function sliceTranscriptAtUuid(raw, cutUuid, endOnCompletedAssistant = fa
162
162
  }
163
163
  return lines.slice(0, end + 1).join("\n") + "\n";
164
164
  }
165
+ /**
166
+ * Trim a transcript back to the last point at which every `tool_use` has its
167
+ * `tool_result` (issue #731).
168
+ *
169
+ * FORK is the caller. Unlike delete/promote/revert, forking mid-turn is not an
170
+ * accident to be interlocked away — it is a designed, load-bearing feature: the
171
+ * `fork_chat` fan-out (#214) is invoked BY a keeper from inside its own running
172
+ * turn, and `forkKickoffPrompt` even apologises to the child for the truncated
173
+ * final exchange. Refusing a fork while the source is running would break that
174
+ * contract outright.
175
+ *
176
+ * What we can fix is the artifact. A snapshot taken mid-tool-call ends on an
177
+ * assistant `tool_use` whose `tool_result` had not been written yet, and the
178
+ * real Anthropic Messages API REJECTS that shape on resume ("tool_use ids were
179
+ * found without tool_result blocks"), so the fork is born unresumable. Cutting
180
+ * the copy back to the last balanced boundary costs the child an exchange it was
181
+ * told to ignore anyway, and buys a transcript that loads.
182
+ *
183
+ * The source transcript is never touched — this only ever shapes the COPY.
184
+ *
185
+ * Returns `raw` byte-identical when nothing is unpaired (the overwhelmingly
186
+ * common idle-fork case), and also when trimming would leave nothing at all — an
187
+ * empty chat is a worse outcome than a suspect one.
188
+ */
189
+ export function trimToPairedToolBoundary(raw) {
190
+ const lines = raw.split("\n");
191
+ const open = new Set();
192
+ /** Last index after which no tool_use is awaiting its result. */
193
+ let lastBalanced = -1;
194
+ for (let i = 0; i < lines.length; i++) {
195
+ const t = lines[i].trim();
196
+ if (!t)
197
+ continue;
198
+ let o;
199
+ try {
200
+ o = JSON.parse(t);
201
+ }
202
+ catch {
203
+ // An unparseable line can't be reasoned about; treat it as opaque filler
204
+ // and let the surrounding balance decide.
205
+ if (open.size === 0)
206
+ lastBalanced = i;
207
+ continue;
208
+ }
209
+ const content = o.message?.content;
210
+ if (Array.isArray(content)) {
211
+ for (const block of content) {
212
+ const b = block;
213
+ if (b?.type === "tool_use" && typeof b.id === "string")
214
+ open.add(b.id);
215
+ else if (b?.type === "tool_result" && typeof b.tool_use_id === "string") {
216
+ open.delete(b.tool_use_id);
217
+ }
218
+ }
219
+ }
220
+ if (open.size === 0)
221
+ lastBalanced = i;
222
+ }
223
+ // Ends balanced (the idle-fork case) — hand back the exact bytes.
224
+ if (open.size === 0)
225
+ return raw;
226
+ if (lastBalanced < 0)
227
+ return raw;
228
+ return lines.slice(0, lastBalanced + 1).join("\n") + "\n";
229
+ }
165
230
  export class HerdctlService {
166
231
  cfg;
167
232
  mcpSources;
@@ -736,6 +801,10 @@ export class HerdctlService {
736
801
  if (copies.has(sessionId)) {
737
802
  const placed = getCliSessionFile(project.workingDir, sessionId, this.cfg.claudeHome);
738
803
  await fs.rm(placed, { force: true }).catch(() => undefined);
804
+ // Same removal, same hazard as a chat delete: a pointer left naming a
805
+ // transcript we just removed breaks the NEXT resume of a DIFFERENT chat
806
+ // in this project. See dropAgentSessionPointer (#730).
807
+ await this.dropAgentSessionPointer(agent, sessionId);
739
808
  }
740
809
  released.push(sessionId);
741
810
  }
@@ -767,6 +836,7 @@ export class HerdctlService {
767
836
  if (path.resolve(placed) === path.resolve(origin))
768
837
  return;
769
838
  await fs.rm(placed, { force: true }).catch(() => undefined);
839
+ await this.dropAgentSessionPointer(agent, sessionId); // #730, as in undoImport
770
840
  }
771
841
  /**
772
842
  * Unregister a project's keeper + sweeper (+ any event-hook) agents at runtime.
@@ -825,12 +895,79 @@ export class HerdctlService {
825
895
  */
826
896
  async deleteSession(agentName, sessionId) {
827
897
  if (this.cfg.claude.transcripts === "own") {
828
- return { removed: await this.manager.deleteSession(agentName, sessionId), retained: false };
898
+ const removed = await this.manager.deleteSession(agentName, sessionId);
899
+ await this.dropAgentSessionPointer(agentName, sessionId);
900
+ return { removed, retained: false };
829
901
  }
830
902
  await this.manager.unadoptSession(agentName, sessionId).catch(() => undefined);
831
903
  this.invalidateSessions(agentName);
832
904
  return { removed: false, retained: true };
833
905
  }
906
+ /**
907
+ * Drop the agent-level session pointer when it names a transcript we just
908
+ * removed. Issue #730 — silent data loss, and the fix is one `rm` of a file
909
+ * that is already dangling by the time we get here.
910
+ *
911
+ * herdctl keeps ONE "this agent's current session" pointer per agent at
912
+ * `<stateDir>/sessions/<agent>.json`, rewritten after every batch turn — so it
913
+ * always names whichever chat ran most recently. It exists for exactly one
914
+ * purpose: resume that session when a caller asks for a turn WITHOUT naming a
915
+ * session. Paddock's chat sends always name one, so we never read it.
916
+ *
917
+ * It still reaches us, via how the JobExecutor reacts to it going stale.
918
+ * Deleting the most-recently-active chat leaves the pointer naming a transcript
919
+ * that is no longer on disk. On the NEXT turn — any chat, `resume: A`
920
+ * explicitly — the executor's timeout-aware read of the pointer judges it
921
+ * `file_not_found`, clears it, and reports "no session". The branch that
922
+ * handles that case then refuses to honor the caller's `resume`, because a
923
+ * pointer HAD existed a moment ago and the executor reads that as "this agent's
924
+ * session just expired, so start fresh" (herdctl's `hadAgentSession` gate,
925
+ * job-executor.js). It is the wrong reading: the pointer named B, the caller
926
+ * asked for A, and A's transcript is right there. The turn starts a brand-new
927
+ * session, the UI keeps showing A, and the user's message lands somewhere they
928
+ * will only find by reloading.
929
+ *
930
+ * That misreading is an upstream bug and the real fix belongs there — the gate
931
+ * should compare the cleared pointer's id against the caller's `resume` rather
932
+ * than treating any prior pointer as disqualifying (filed as
933
+ * edspencer/herdctl#448).
934
+ * What we do here is narrower and true regardless: a pointer to a transcript
935
+ * Paddock has just deleted is dangling, and herdctl would clear it at the next
936
+ * turn anyway — we clear it now, at the moment we make it dangle, so the next
937
+ * turn sees a clean "this agent owns no session" and adopts the caller's
938
+ * explicit `resume` (which is the same path a process restart takes, and why
939
+ * this always "worked after a restart").
940
+ *
941
+ * Only clears a pointer that names THIS session: another chat's pointer is
942
+ * live state and none of our business. Best-effort throughout — failing to
943
+ * tidy state must never fail the delete the user asked for.
944
+ *
945
+ * Called from every place Paddock removes a transcript from an agent's working
946
+ * directory: the `own` delete path, {@link promoteSession} (which moves the
947
+ * file out from under the source keeper by the same call), and the two
948
+ * import-undo paths that delete a copy an import placed. `archive` leaves the
949
+ * file alone, so it never dangles the pointer — which matches the reproduction
950
+ * exactly: archive is fine, delete and promote are not. Under
951
+ * `claude.transcripts: host` nothing is removed, so there is nothing to drop.
952
+ *
953
+ * The SDK session path (`driveMode: session`, the default) never reads the
954
+ * pointer for an explicit resume, so it is structurally immune; this is a
955
+ * `driveMode: batch` fix.
956
+ */
957
+ async dropAgentSessionPointer(agentName, sessionId) {
958
+ try {
959
+ const sessionsDir = path.join(this.cfg.stateDir, "sessions");
960
+ // Read WITHOUT a timeout: the timeout-aware read is the one that clears,
961
+ // and we want to decide for ourselves, on the id alone.
962
+ const pointer = await getSessionInfo(sessionsDir, agentName);
963
+ if (pointer?.session_id !== sessionId)
964
+ return;
965
+ await clearSession(sessionsDir, agentName);
966
+ }
967
+ catch {
968
+ // Best-effort: the delete has already happened and stands.
969
+ }
970
+ }
834
971
  /**
835
972
  * Set (or clear) a chat's custom display name. Writes through the fleet's
836
973
  * shared SessionMetadataStore so a subsequent getAgentSessions reflects it
@@ -1173,6 +1310,25 @@ export class HerdctlService {
1173
1310
  return false;
1174
1311
  }
1175
1312
  }
1313
+ /**
1314
+ * End a managed chat SESSION outright, by session id (issue #731). Where
1315
+ * {@link cancel} ends the model turn — and on the session runtime deliberately
1316
+ * leaves the session (and its `claude` subprocess) open for the next message —
1317
+ * this closes the session itself, so nothing is left holding the transcript
1318
+ * path open ahead of a destructive operation.
1319
+ *
1320
+ * Best-effort and null-safe: `false` when there was no live managed session to
1321
+ * reap (the batch runtime never has one), or when the fleet isn't up. Callers
1322
+ * decide liveness from {@link isSdkSessionLive}, not from this return value.
1323
+ */
1324
+ reapSession(sessionId) {
1325
+ try {
1326
+ return this.fleet?.reapChatSession(sessionId) ?? false;
1327
+ }
1328
+ catch {
1329
+ return false;
1330
+ }
1331
+ }
1176
1332
  /**
1177
1333
  * Run a project's sweeper (curator) agent with a fresh session and the given
1178
1334
  * prompt. Used OUT OF BAND by SweepService — never from the user-chat path —
@@ -1321,6 +1477,10 @@ export class HerdctlService {
1321
1477
  const fromAgent = keeperAgentName(from.slug);
1322
1478
  await this.manager.deleteSession(fromAgent, sessionId).catch(() => undefined);
1323
1479
  await fs.rm(fromFile, { force: true });
1480
+ // The source keeper's agent-level session pointer may name the chat we just
1481
+ // moved out from under it. Left dangling it breaks the NEXT resume of any
1482
+ // OTHER chat in the source project — see dropAgentSessionPointer (#730).
1483
+ await this.dropAgentSessionPointer(fromAgent, sessionId);
1324
1484
  // Re-attribute the session to the new keeper, then drop the discovery +
1325
1485
  // attribution caches so the move shows immediately.
1326
1486
  await jobs.reattributeSession(this.cfg.stateDir, sessionId, to, st ? st.mtime : new Date());
@@ -1378,6 +1538,13 @@ export class HerdctlService {
1378
1538
  }
1379
1539
  raw = sliced;
1380
1540
  }
1541
+ // #731: the source may be MID-TURN — always is, for the agent-initiated
1542
+ // `fork_chat` fan-out — so the snapshot can end on a `tool_use` whose
1543
+ // `tool_result` hasn't been written yet. Copying that shape gives the child
1544
+ // a transcript the Messages API rejects on resume, i.e. a fork that exists
1545
+ // but cannot be talked to. Cut the COPY back to the last balanced boundary;
1546
+ // the source is untouched either way.
1547
+ raw = trimToPairedToolBoundary(raw);
1381
1548
  const newId = randomUUID();
1382
1549
  // Rewrite the embedded session id on every line. Same compact-JSON
1383
1550
  // assumption `promoteSession` makes for `cwd` (`"sessionId":"<id>"` /
@@ -36,6 +36,7 @@ import { DEFAULT_RECOVERY } from "./recovery-config.js";
36
36
  import { DEFAULT_ATTACHMENTS, sanitizeAllowedTypes } from "./attachments-config.js";
37
37
  import { DEFAULT_CURATION } from "./curation-config.js";
38
38
  import { DEFAULT_ENVIRONMENT_PROMPT } from "./environment-prompt.js";
39
+ import { CONFIG_SCHEMA_VERSION, SCHEMA_VERSION_KEY } from "./schema-version.js";
39
40
  import { DEFAULT_TRANSCRIPTS_MODE } from "./transcripts.js";
40
41
  import { DEFAULT_CREDENTIALS_MODE } from "./claude-credentials.js";
41
42
  import { DEFAULT_INSTRUCTIONS_MODE } from "./claude-instructions.js";
@@ -217,7 +218,7 @@ export const FIELDS = [
217
218
  // Logging.
218
219
  { key: "logLevel", group: "logging", label: "Log level", type: "enum", enumValues: LOG_LEVELS, envVars: ["LOG_LEVEL"], default: "info", editable: true, coerce: oneOf(LOG_LEVELS) },
219
220
  // Advanced — read-only display (process / filesystem bindings).
220
- { key: "port", group: "advanced", label: "Port", type: "number", envVars: ["PORT"], default: 4000, editable: false },
221
+ { key: "port", group: "advanced", label: "Port", type: "number", envVars: ["PORT"], default: 7233, editable: false },
221
222
  { key: "host", group: "advanced", label: "Host", type: "string", envVars: ["HOST", "PADDOCK_HOST"], default: "127.0.0.1", editable: false },
222
223
  { key: "dataDir", group: "advanced", label: "Data dir", type: "string", envVars: ["PADDOCK_DATA_DIR"], default: null, editable: false },
223
224
  { key: "projectsRoot", group: "advanced", label: "Projects root", type: "string", envVars: ["PADDOCK_PROJECTS_DIR"], default: null, editable: false },
@@ -411,6 +412,16 @@ export function writeInstanceConfig(configPath, pairs) {
411
412
  else
412
413
  doc.setIn(p, value);
413
414
  }
415
+ // Stamp the schema version on a file that does not declare one yet (#724) —
416
+ // which includes every file written before adoption and every fresh one this
417
+ // branch creates. Written only when ABSENT, unlike `project.yaml`: this is a
418
+ // partial patch over a document whose other keys are round-tripped untouched,
419
+ // so it is not in a position to assert what version the WHOLE file is. A file
420
+ // that already declares one has been through `loadConfigFile`'s guard, so it
421
+ // is at or below this build's version and re-stamping would say nothing new.
422
+ if (doc.getIn([SCHEMA_VERSION_KEY]) === undefined) {
423
+ doc.setIn([SCHEMA_VERSION_KEY], CONFIG_SCHEMA_VERSION);
424
+ }
414
425
  const serialized = doc.toString();
415
426
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
416
427
  // Atomic write: same-dir temp file + rename, so a reader never sees a
@@ -314,6 +314,37 @@ export function buildManagementOps(ctx, params) {
314
314
  agentRegistered,
315
315
  };
316
316
  },
317
+ // Promotion (#470), sharing create's gate. Same discipline: the SAME two calls,
318
+ // in the same order, that `POST /api/projects/:slug/promote` makes — store
319
+ // promote, then agent re-registration. Every guard (already unmanaged, the
320
+ // root workspace, an existing checkout dir, a bad URL) and the rollback to an
321
+ // untouched notebook on clone failure live in `ProjectStore.promote` (#213).
322
+ promoteProject: async (projectSlug, repo) => {
323
+ const project = await deps.projects.promote(projectSlug, repo);
324
+ // Non-fatal, as in the route — but it matters more than it does for create:
325
+ // re-registering is what re-symlinks the NEW working dir at the project's
326
+ // existing `.chats/` store, which is what keeps its chats listed + resumable.
327
+ let agentRegistered = true;
328
+ try {
329
+ await deps.herdctl.ensureProjectAgent(project);
330
+ }
331
+ catch {
332
+ agentRegistered = false;
333
+ }
334
+ return {
335
+ slug: project.slug,
336
+ name: project.name,
337
+ dir: project.dir,
338
+ workingDir: project.workingDir,
339
+ // #206 replaced `repoBacked` with the `managed` axis. Promotion always
340
+ // lands on `managed: false` — that IS what promoting means now — but read
341
+ // it off the project rather than hard-coding, so this reports what the
342
+ // store actually did.
343
+ managed: project.managed,
344
+ ...(project.repo ? { repo: project.repo } : {}),
345
+ agentRegistered,
346
+ };
347
+ },
317
348
  triggersMcpEnabled: includeTriggers,
318
349
  listTriggers: async (projectSlug) => {
319
350
  if (!deps.triggers)
@@ -405,12 +436,20 @@ export function enforceManagementPolicy(ops, principal) {
405
436
  const write = {
406
437
  currentProjectSlug: w.currentProjectSlug,
407
438
  currentSessionId: w.currentSessionId,
439
+ // The project block is offered when EITHER of its verbs is granted; the
440
+ // per-tool filter (`managementToolFilter`) is what hides the other one, so a
441
+ // client scoped to just one of them isn't denied both.
442
+ projectsMcpEnabled: w.projectsMcpEnabled &&
443
+ (isOperationAllowed(scope, "create_project") || isOperationAllowed(scope, "promote_project")),
408
444
  // Instance-level, so there is no project to scope-check — only the verb.
409
- projectsMcpEnabled: w.projectsMcpEnabled && isOperationAllowed(scope, "create_project"),
410
445
  createProject: (input) => {
411
446
  assertOperation(principal, "create_project");
412
447
  return w.createProject(input);
413
448
  },
449
+ // Promotion DOES name an existing project, so unlike create it is scope-checked
450
+ // against `projects` too — a client confined to one project must not restructure
451
+ // another.
452
+ promoteProject: (projectSlug, repo) => guard("promote_project", projectSlug, () => w.promoteProject(projectSlug, repo)),
414
453
  createChat: (projectSlug, prompt, o) => guard("create_chat", projectSlug, () => w.createChat(projectSlug, prompt, o)),
415
454
  // NOTE: `fork_chat_batch` is a convenience wrapper that calls this same
416
455
  // callback N times, so it is gated on `fork_chat` here AND on
@@ -66,7 +66,7 @@ export interface ManagementPrincipal {
66
66
  /** Read-only operations: no state change, no turn started. */
67
67
  export declare const READ_OPERATIONS: readonly ["list_projects", "list_chats", "read_chat"];
68
68
  /** Chat write operations. The first four START TURNS (see the RCE note above). */
69
- export declare const WRITE_OPERATIONS: readonly ["create_project", "create_chat", "fork_chat", "send_message", "fork_chat_batch", "archive_chat", "unarchive_chat"];
69
+ export declare const WRITE_OPERATIONS: readonly ["create_project", "promote_project", "create_chat", "fork_chat", "send_message", "fork_chat_batch", "archive_chat", "unarchive_chat"];
70
70
  /** Unified trigger operations (Epic T / T3). `list_triggers` is the read one. */
71
71
  export declare const TRIGGER_OPERATIONS: readonly ["list_triggers", "set_trigger", "remove_trigger", "run_trigger"];
72
72
  /** Every operation the management surface knows about. */
@@ -81,8 +81,9 @@ export declare const ALL_OPERATIONS: readonly string[];
81
81
  /**
82
82
  * Operations that grant code execution on the host, and so define the risk class
83
83
  * a write scope really is. A superset of {@link TURN_SPAWNING_OPERATIONS}:
84
- * `create_project` starts no turn, but it clones a CALLER-SUPPLIED git URL and
85
- * creates instance-level state, which belongs in the same warning.
84
+ * `create_project`/`promote_project` start no turn, but they clone a
85
+ * CALLER-SUPPLIED git URL and create (or restructure) instance-level state, which
86
+ * belongs in the same warning.
86
87
  */
87
88
  export declare const HIGH_RISK_OPERATIONS: readonly string[];
88
89
  export declare const TURN_SPAWNING_OPERATIONS: readonly string[];
@@ -34,6 +34,7 @@ export const READ_OPERATIONS = ["list_projects", "list_chats", "read_chat"];
34
34
  /** Chat write operations. The first four START TURNS (see the RCE note above). */
35
35
  export const WRITE_OPERATIONS = [
36
36
  "create_project",
37
+ "promote_project",
37
38
  "create_chat",
38
39
  "fork_chat",
39
40
  "send_message",
@@ -64,11 +65,13 @@ export const ALL_OPERATIONS = [
64
65
  /**
65
66
  * Operations that grant code execution on the host, and so define the risk class
66
67
  * a write scope really is. A superset of {@link TURN_SPAWNING_OPERATIONS}:
67
- * `create_project` starts no turn, but it clones a CALLER-SUPPLIED git URL and
68
- * creates instance-level state, which belongs in the same warning.
68
+ * `create_project`/`promote_project` start no turn, but they clone a
69
+ * CALLER-SUPPLIED git URL and create (or restructure) instance-level state, which
70
+ * belongs in the same warning.
69
71
  */
70
72
  export const HIGH_RISK_OPERATIONS = [
71
73
  "create_project",
74
+ "promote_project",
72
75
  "create_chat",
73
76
  "fork_chat",
74
77
  "fork_chat_batch",