@edspencer/paddock 0.65.0 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/packages/server/dist/app.d.ts +8 -0
- package/packages/server/dist/app.js +4 -1
- package/packages/server/dist/cli/args.d.ts +4 -4
- package/packages/server/dist/cli/args.js +5 -5
- package/packages/server/dist/cli/paddock.js +1 -1
- package/packages/server/dist/config.d.ts +7 -0
- package/packages/server/dist/config.js +9 -1
- package/packages/server/dist/herdctl.d.ts +89 -0
- package/packages/server/dist/herdctl.js +169 -2
- package/packages/server/dist/instance-config.js +12 -1
- package/packages/server/dist/projects.d.ts +11 -0
- package/packages/server/dist/projects.js +40 -2
- package/packages/server/dist/queued-message.d.ts +57 -0
- package/packages/server/dist/queued-message.js +135 -1
- package/packages/server/dist/routes/chats.js +77 -6
- package/packages/server/dist/routes/projects.js +22 -2
- package/packages/server/dist/schema-version.d.ts +111 -0
- package/packages/server/dist/schema-version.js +142 -0
- package/packages/server/dist/session-hub.d.ts +69 -1
- package/packages/server/dist/session-hub.js +73 -1
- package/packages/server/dist/subagents.d.ts +65 -0
- package/packages/server/dist/subagents.js +159 -19
- package/packages/server/dist/tooldetails.d.ts +10 -4
- package/packages/server/dist/tooldetails.js +23 -6
- package/packages/server/dist/trusted-proxy.js +1 -1
- package/packages/server/dist/turn-interlock.d.ts +101 -0
- package/packages/server/dist/turn-interlock.js +84 -0
- package/packages/server/dist/ws-protocol.d.ts +76 -5
- package/packages/server/dist/ws-protocol.js +2 -0
- package/packages/server/dist/ws.d.ts +0 -52
- package/packages/server/dist/ws.js +472 -119
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-Ri1LwAUN.js → MarkdownRenderer-CRQP3Cfe.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-Dze5H8Xy.js → ProjectRedirect-Dd3h5V8h.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BRk6l152.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-BidsM7ah.js → ProjectsGrid-BaUE8zaF.js} +1 -1
- package/packages/web/dist/assets/{arc-eQ3ryE9c.js → arc-DqMSW0As.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Dc6Tj2Ai.js → architectureDiagram-3BPJPVTR-CNkQD1jU.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DBX5pma_.js → blockDiagram-GPEHLZMM-BCMiOmt6.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CnWrtTpa.js → c4Diagram-AAUBKEIU-CU2soCji.js} +1 -1
- package/packages/web/dist/assets/channel-Ynyq4GPh.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-2qmLvtsr.js → chunk-2J33WTMH-nKhlJNTt.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-Dn-LUDMV.js → chunk-4BX2VUAB-YRTZj9zI.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-DB0augUY.js → chunk-55IACEB6-HO60VE45.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-BPQQM0i8.js → chunk-727SXJPM-WZJH-boe.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DOhfwA-x.js → chunk-AQP2D5EJ-PvDC7gYB.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-CatHHdFD.js → chunk-FMBD7UC4-BNRMTplH.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-B645aAlG.js → chunk-ND2GUHAM-2Q2ei8TR.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-D1fAaPSZ.js → chunk-QZHKN3VN-CG9VV7M6.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +2 -0
- package/packages/web/dist/assets/{core-B4t7D8Om.js → core-DwSEw_oE.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BLdKDAwa.js → cose-bilkent-S5V4N54A-BlMrBI7K.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-CbTbTVKv.js → dagre-BM42HDAG-tNoaleNU.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-CorSS29_.js → diagram-2AECGRRQ-DRVCTpHL.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-PLKivts0.js → diagram-5GNKFQAL-BdS-96WV.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-CUSIBacZ.js → diagram-KO2AKTUF-Dv1yxz3s.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CZ69mAZs.js → diagram-LMA3HP47-BNXgifuS.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-Cmf042Rh.js → diagram-OG6HWLK6-wlO0bxwP.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-DyIKGj6e.js → erDiagram-TEJ5UH35-ChoppAEq.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-B79pLdLD.js → flowDiagram-I6XJVG4X-C__aYyei.js} +1 -1
- package/packages/web/dist/assets/{format-C5nhFL8q.js → format-u-YtEZFd.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-BFTR_oNA.js → ganttDiagram-6RSMTGT7-CjRe8foW.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DdGUlJxS.js → gitGraphDiagram-PVQCEYII-BGDQjiWq.js} +1 -1
- package/packages/web/dist/assets/index-B3K5nsLe.css +1 -0
- package/packages/web/dist/assets/index-Cc5bQxZk.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-B80FyUOy.js → infoDiagram-5YYISTIA-BP-JaX-8.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-_wRlphn9.js → ishikawaDiagram-YF4QCWOH-YnsAJBvC.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-qu4yvjW8.js → journeyDiagram-JHISSGLW-BM6JWTV9.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-D2DEUp2-.js → kanban-definition-UN3LZRKU-gR2O0IaL.js} +1 -1
- package/packages/web/dist/assets/{linear-D-CLRUEW.js → linear-Dolq_eLR.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-DdihBJl4.js → mermaid.core-D1T_GQW7.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BEh_TDBf.js → mindmap-definition-RKZ34NQL-C3bzIyLZ.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BbrmLVaZ.js → pieDiagram-4H26LBE5-BrZ3ANGf.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-DfVuygAv.js → quadrantDiagram-W4KKPZXB-BvLCHAEi.js} +1 -1
- package/packages/web/dist/assets/queued-DGmoZW59.js +2 -0
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BHp2QpZt.js → requirementDiagram-4Y6WPE33-CCzXAeaZ.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D2G6lRMY.js → sankeyDiagram-5OEKKPKP-2Gf-ERTg.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Dj-jzR-c.js → sequenceDiagram-3UESZ5HK-DMjDe28d.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-C5D6bT13.js → stateDiagram-AJRCARHV-CENtj5dw.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DE4gGjhP.js → timeline-definition-PNZ67QCA-BW4gCpBB.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C9QNrl92.js → vennDiagram-CIIHVFJN-CiIuqcT9.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-U2fSGy-d.js → wardley-L42UT6IY-BQ1BPr5R.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-B9RhObdH.js → wardleyDiagram-YWT4CUSO-DBq9Ailv.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-BnlPrtQ9.js → xychartDiagram-2RQKCTM6-D24VDECy.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/InstanceConfigPage-DcprTH2W.js +0 -2
- package/packages/web/dist/assets/ProjectView-PrA8purE.js +0 -13
- package/packages/web/dist/assets/channel-B4sED4OP.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BlVeHFir.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BlVeHFir.js +0 -2
- package/packages/web/dist/assets/index-B6etJCHf.css +0 -1
- package/packages/web/dist/assets/index-DVSsta3B.js +0 -71
- package/packages/web/dist/assets/queued-LamEU0GH.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C8NDOqTq.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:
|
|
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
|
|
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:
|
|
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
|
@@ -16,6 +16,7 @@ import { HerdctlService } from "./herdctl.js";
|
|
|
16
16
|
import { GitService } from "./git.js";
|
|
17
17
|
import { GithubAuth } from "./github-auth.js";
|
|
18
18
|
import { type Transcriber } from "./transcribe.js";
|
|
19
|
+
import type { SessionHub } from "./session-hub.js";
|
|
19
20
|
import { SweepService } from "./sweep.js";
|
|
20
21
|
import { ArchiveStore } from "./archive.js";
|
|
21
22
|
import { StarStore } from "./star.js";
|
|
@@ -49,6 +50,13 @@ export interface BuiltApp {
|
|
|
49
50
|
events: PaddockEventBus;
|
|
50
51
|
/** Unified trigger registry (Epic T / T1) — the sole trigger CRUD surface. */
|
|
51
52
|
triggers: TriggerService;
|
|
53
|
+
/**
|
|
54
|
+
* The WS layer's session hub — every in-flight turn, and the fan-out to the
|
|
55
|
+
* sockets watching it. Exposed for callers that need to observe or annotate a
|
|
56
|
+
* turn without holding a socket (the destructive-op interlock reaches it the
|
|
57
|
+
* same way, via the route context).
|
|
58
|
+
*/
|
|
59
|
+
hub: SessionHub;
|
|
52
60
|
/** Tear down the fleet + close the server (no process.exit, for tests). */
|
|
53
61
|
close: () => Promise<void>;
|
|
54
62
|
}
|
|
@@ -399,5 +399,8 @@ export async function buildApp(opts = {}) {
|
|
|
399
399
|
await herdctl.stop().catch(() => undefined);
|
|
400
400
|
await app.close().catch(() => undefined);
|
|
401
401
|
};
|
|
402
|
-
|
|
402
|
+
// `hub` rides along like every other built piece: it is the WS layer's turn
|
|
403
|
+
// bookkeeping, and the only place a caller can observe or annotate a turn
|
|
404
|
+
// without a live socket (see SessionHub.noteCancel).
|
|
405
|
+
return { app, cfg, projects, herdctl, git, githubAuth, sweep, archive, star, readState, unread, parentDetach, runProvenance, queuedMessage, transcriber, events, triggers, hub: chatHandler.managementOpsContext.hub, close };
|
|
403
406
|
}
|
|
@@ -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`
|
|
52
|
-
*
|
|
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
|
|
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`
|
|
99
|
-
*
|
|
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
|
|
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
|
|
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 ?? "
|
|
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, "
|
|
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
|
-
|
|
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:
|
|
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
|
|
@@ -23,6 +23,8 @@ export declare class ProjectStore {
|
|
|
23
23
|
constructor(root: string, dataDir?: string | undefined, log?: {
|
|
24
24
|
warn: (message: string) => void;
|
|
25
25
|
});
|
|
26
|
+
/** `${file} ${declaredVersion}` pairs already warned about — see {@link warnOnceAboutSchema}. */
|
|
27
|
+
private readonly schemaWarned;
|
|
26
28
|
/** Ensure the projects root exists. Call once at startup. */
|
|
27
29
|
init(): Promise<void>;
|
|
28
30
|
/**
|
|
@@ -355,6 +357,15 @@ export declare class ProjectStore {
|
|
|
355
357
|
*/
|
|
356
358
|
private ensureSidecarGitignore;
|
|
357
359
|
private writeYaml;
|
|
360
|
+
/**
|
|
361
|
+
* Say out loud that a project was skipped for being from the future (#724),
|
|
362
|
+
* once per (file, declared version).
|
|
363
|
+
*
|
|
364
|
+
* `list()` runs on every `GET /api/projects`, so an unconditional warn would
|
|
365
|
+
* bury the log. Keyed on the declared version as well as the path so a
|
|
366
|
+
* hand-edit — or the same instance being downgraded further — speaks up again.
|
|
367
|
+
*/
|
|
368
|
+
private warnOnceAboutSchema;
|
|
358
369
|
private toDto;
|
|
359
370
|
/**
|
|
360
371
|
* Drop the derived DTO-only fields, leaving the on-disk shape.
|