@edspencer/paddock 0.60.0 → 0.61.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/LICENSE +21 -0
- package/README.md +44 -3
- package/package.json +3 -2
- package/packages/server/dist/adoptable.d.ts +36 -2
- package/packages/server/dist/adoptable.js +51 -11
- package/packages/server/dist/agent-errors.d.ts +41 -0
- package/packages/server/dist/agent-errors.js +127 -0
- package/packages/server/dist/app.js +36 -0
- package/packages/server/dist/claude-home.d.ts +192 -0
- package/packages/server/dist/claude-home.js +387 -0
- package/packages/server/dist/cli/args.d.ts +9 -1
- package/packages/server/dist/cli/args.js +21 -5
- package/packages/server/dist/cli/claude-home-choice.d.ts +44 -0
- package/packages/server/dist/cli/claude-home-choice.js +108 -0
- package/packages/server/dist/cli/import-chats.js +9 -6
- package/packages/server/dist/cli/paddock.js +54 -33
- package/packages/server/dist/config.d.ts +75 -12
- package/packages/server/dist/config.js +58 -11
- package/packages/server/dist/herdctl-agent-config.d.ts +15 -7
- package/packages/server/dist/herdctl-agent-config.js +21 -9
- package/packages/server/dist/herdctl.d.ts +15 -0
- package/packages/server/dist/herdctl.js +50 -6
- package/packages/server/dist/hook-config.d.ts +17 -15
- package/packages/server/dist/hook-config.js +11 -9
- package/packages/server/dist/routes/chats.js +33 -6
- package/packages/server/dist/sweep.js +13 -1
- package/packages/server/dist/transcripts.d.ts +32 -6
- package/packages/server/dist/transcripts.js +30 -24
- package/packages/server/dist/trigger-config.d.ts +18 -8
- package/packages/server/dist/trigger-config.js +18 -8
- package/packages/server/dist/ws.js +43 -11
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-xn9Rvoah.js → InstanceConfigPage-BgNXqAox.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DMONssKA.js → MarkdownRenderer-DfD_UtqU.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-DHoWtvtY.js → ProjectRedirect-1EXdHXmO.js} +1 -1
- package/packages/web/dist/assets/ProjectView-DHb5vNUp.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-CjfwpeFj.js → ProjectsGrid-Br-EhLZE.js} +1 -1
- package/packages/web/dist/assets/{arc-DdFdbD-c.js → arc-C2Jekujs.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-DC8UKtwV.js → architectureDiagram-3BPJPVTR-CflCA-em.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DqFFe_5c.js → blockDiagram-GPEHLZMM-Ds7GLNnC.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DbffFSM-.js → c4Diagram-AAUBKEIU-D4WyGO6I.js} +1 -1
- package/packages/web/dist/assets/channel-B1K_gwAH.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-8PP2RHft.js → chunk-2J33WTMH-tQunO_M5.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-Hag68B5N.js → chunk-4BX2VUAB-DT5dOWd2.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BXp_gQIa.js → chunk-55IACEB6-CfTUqUEF.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-DlntjQux.js → chunk-727SXJPM-sK5bOAA0.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BhI9KPrE.js → chunk-AQP2D5EJ-DcMgVe-P.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-WhfORs2z.js → chunk-FMBD7UC4-In3h_-7v.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-CIskmv20.js → chunk-ND2GUHAM-DuaIeRaC.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-CNOTpCW4.js → chunk-QZHKN3VN-DeT5mR5T.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DYQQOKrW.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DYQQOKrW.js +2 -0
- package/packages/web/dist/assets/{core-usOVRkLM.js → core-DgOgLvnM.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CniuMIIW.js → cose-bilkent-S5V4N54A-CyO-rznJ.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-5CteYfQY.js → dagre-BM42HDAG-B_9Ihzgn.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-BIQjD2LA.js → diagram-2AECGRRQ-z5mvZ5YT.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-1vy3ErTt.js → diagram-5GNKFQAL-Bsz0cgfn.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-D45iqFk6.js → diagram-KO2AKTUF-B29k5f4q.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-DNktDktn.js → diagram-LMA3HP47-CFrwsHoI.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-BQ9JgV70.js → diagram-OG6HWLK6-D9Sjc2HR.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-oBasZ1uv.js → erDiagram-TEJ5UH35-C3vbkD7M.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DxnJHMBS.js → flowDiagram-I6XJVG4X-DkMIgntd.js} +1 -1
- package/packages/web/dist/assets/{format-CIAat6sU.js → format-BFDqyFuw.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CtJQ2m7z.js → ganttDiagram-6RSMTGT7-nvHanjpW.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-5RPscwD_.js → gitGraphDiagram-PVQCEYII-DbCNfOu7.js} +1 -1
- package/packages/web/dist/assets/index-DGCs1YIO.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-zQu3xkW4.js → infoDiagram-5YYISTIA-aJjH9x3D.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-ChoEjcdw.js → ishikawaDiagram-YF4QCWOH-DoifwNnZ.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-DM2KWmDr.js → journeyDiagram-JHISSGLW-Cz9kG9HN.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DWzTpYgh.js → kanban-definition-UN3LZRKU-BoXnfr7A.js} +1 -1
- package/packages/web/dist/assets/{linear-Bpy9Aj-K.js → linear-BlTTO9yR.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-D43x5bFb.js → mermaid.core-YAG8eSPQ.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BhceB-cv.js → mindmap-definition-RKZ34NQL-DtUBKfkr.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BgcGlFFA.js → pieDiagram-4H26LBE5-B3Rwpmy0.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ZRVaOoSp.js → quadrantDiagram-W4KKPZXB-B8eJtTyk.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-2hHcJHBt.js → requirementDiagram-4Y6WPE33-CpAluiY9.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-KPUQ9Mu5.js → sankeyDiagram-5OEKKPKP-Cl_Rd1tN.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-D4BXQSSM.js → sequenceDiagram-3UESZ5HK-CRpqKSGm.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DftS7dIo.js → stateDiagram-AJRCARHV-h3h8gXv_.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BKRI9TnH.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C0OAxyWY.js → timeline-definition-PNZ67QCA-PVr5V_pG.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C94fINN3.js → vennDiagram-CIIHVFJN-De-4NPAX.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CbFrlwHE.js → wardley-L42UT6IY-Dtv10D2d.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CHG2nndd.js → wardleyDiagram-YWT4CUSO-p2fJuQ6C.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-U9ESc19z.js → xychartDiagram-2RQKCTM6-DyYF-k92.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/ProjectView-BmSdUwY2.js +0 -13
- package/packages/web/dist/assets/channel-QxsJGL6F.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CI7p84hR.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CI7p84hR.js +0 -2
- package/packages/web/dist/assets/index-DtRblEY_.js +0 -71
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CgBC1jpO.js +0 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ed Spencer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@ npx @edspencer/paddock
|
|
|
9
9
|
|
|
10
10
|
Then open <http://127.0.0.1:4000>.
|
|
11
11
|
|
|
12
|
+
Or point it at work you've already done — see [Open your own project](#open-your-own-project).
|
|
13
|
+
|
|
12
14
|
> **First run downloads ~250 MB.** Paddock drives Claude Code, and the Claude
|
|
13
15
|
> Agent SDK ships a per-platform binary of that size. It cannot be skipped —
|
|
14
16
|
> installing with `--omit=optional` produces a Paddock whose chats all fail.
|
|
@@ -24,8 +26,40 @@ claude setup-token # Claude Max/Pro
|
|
|
24
26
|
export ANTHROPIC_API_KEY=sk-ant-… # or API billing
|
|
25
27
|
```
|
|
26
28
|
|
|
27
|
-
If you already use Claude Code on this machine,
|
|
28
|
-
|
|
29
|
+
If you already use Claude Code on this machine, there is nothing to do: with no token
|
|
30
|
+
in the environment, Paddock runs against your own `~/.claude` and uses the login
|
|
31
|
+
already there — including one held in the **macOS Keychain**, which cannot be shared
|
|
32
|
+
any other way. Pass `--isolated-claude-home` to give Paddock a Claude home of its own
|
|
33
|
+
instead.
|
|
34
|
+
|
|
35
|
+
## Open your own project
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
cd ~/code/some-project # somewhere you've used Claude Code
|
|
39
|
+
npx @edspencer/paddock --here
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`--here` opens **that directory** as Paddock's workspace: Claude works in your files,
|
|
43
|
+
and any Claude Code sessions you already have for the directory are offered for
|
|
44
|
+
import. It's the fastest way to see what Paddock does with real history instead of an
|
|
45
|
+
empty instance. Later runs in the same directory resume it — no flag needed.
|
|
46
|
+
|
|
47
|
+
The flag is the consent, so here is everything it does:
|
|
48
|
+
|
|
49
|
+
- creates **`.paddock/`** for the workspace's state and **`.chats/`** for transcripts
|
|
50
|
+
- appends those two entries to **`.gitignore`**
|
|
51
|
+
|
|
52
|
+
**Nothing is written into your `~/.claude`, ever** — no file, no symlink, in any
|
|
53
|
+
configuration. Sessions found there are *offered* for import: nothing is moved, copied
|
|
54
|
+
or linked until you confirm, and your terminal `claude` keeps working exactly as
|
|
55
|
+
before. What varies is which Claude home Paddock runs against, and it says which on
|
|
56
|
+
startup — with a token in the environment (or `--isolated-claude-home`) it uses its own
|
|
57
|
+
under `.paddock/` and relocates transcripts into `.chats/`; without one it uses your
|
|
58
|
+
`~/.claude`, and transcripts stay there instead.
|
|
59
|
+
|
|
60
|
+
To undo it: `rm -rf .paddock .chats` and drop the two `.gitignore` lines.
|
|
61
|
+
|
|
62
|
+
Without `--here`, Paddock never touches the directory you ran it from.
|
|
29
63
|
|
|
30
64
|
## Options
|
|
31
65
|
|
|
@@ -33,11 +67,18 @@ automatically and there is nothing to do.
|
|
|
33
67
|
-p, --port <port> HTTP/WS port (default 4000)
|
|
34
68
|
--host <host> Bind address (default 127.0.0.1)
|
|
35
69
|
-d, --data-dir <path> Projects + state (default ~/.paddock)
|
|
70
|
+
--here Open the CURRENT directory as the workspace
|
|
71
|
+
-o, --open Open the app in your browser once it is listening
|
|
72
|
+
--verbose Show the server's own logs (quiet by default)
|
|
73
|
+
--isolated-claude-home
|
|
74
|
+
Use Paddock's own Claude home instead of ~/.claude
|
|
36
75
|
-v, --version Print the version
|
|
37
76
|
-h, --help Show help
|
|
38
77
|
```
|
|
39
78
|
|
|
40
|
-
Your projects, chats and settings
|
|
79
|
+
Your projects, chats and settings persist between runs in `~/.paddock` — or in
|
|
80
|
+
`<dir>/.paddock` when you use `--here`. Either way it's one directory: move it to
|
|
81
|
+
move your instance, delete it to start over.
|
|
41
82
|
|
|
42
83
|
## Security
|
|
43
84
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edspencer/paddock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.1",
|
|
4
4
|
"description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -53,7 +53,8 @@
|
|
|
53
53
|
"packages/server/package.json",
|
|
54
54
|
"packages/web/dist",
|
|
55
55
|
"install-notice.mjs",
|
|
56
|
-
"README.md"
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE"
|
|
57
58
|
],
|
|
58
59
|
"publishConfig": {
|
|
59
60
|
"access": "public",
|
|
@@ -21,8 +21,22 @@ export interface AdoptableCandidate {
|
|
|
21
21
|
}
|
|
22
22
|
/** One working directory that has adoptable sessions, and which ones. */
|
|
23
23
|
export interface AdoptableSource {
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The working directory whose transcript folder holds these sessions — the
|
|
26
|
+
* DISPLAY identity, and the value a client round-trips back to import one
|
|
27
|
+
* source. Always a directory the user would recognise.
|
|
28
|
+
*/
|
|
25
29
|
sourceCwd: string;
|
|
30
|
+
/**
|
|
31
|
+
* The path the ENGINE must be handed to read this source, when that differs
|
|
32
|
+
* from {@link sourceCwd} (#620).
|
|
33
|
+
*
|
|
34
|
+
* It differs for the user's own `~/.claude` history, which paddock reaches
|
|
35
|
+
* through a mirror named for a synthetic path — see
|
|
36
|
+
* `mirrorLegacyTranscriptFolders`. Server-internal: absent means "same as
|
|
37
|
+
* sourceCwd", and clients neither send it nor need to know it exists.
|
|
38
|
+
*/
|
|
39
|
+
importFrom?: string;
|
|
26
40
|
/**
|
|
27
41
|
* Adoptable session ids, newest transcript first.
|
|
28
42
|
*
|
|
@@ -142,6 +156,16 @@ export interface AdoptableFleet {
|
|
|
142
156
|
export declare class AdoptableIndex {
|
|
143
157
|
private readonly claudeHomePath;
|
|
144
158
|
private readonly stateDir;
|
|
159
|
+
/**
|
|
160
|
+
* The user's own `~/.claude` (#620). Now that paddock owns its Claude home,
|
|
161
|
+
* the user's CLI history — the entire point of adoption — is no longer under
|
|
162
|
+
* `claudeHomePath`, and the engine resolves every adoption path against the
|
|
163
|
+
* one home the FleetManager was built with, with no per-call override.
|
|
164
|
+
* {@link scanFolders} therefore mirrors the legacy folders in first,
|
|
165
|
+
* read-only. Defaults to `claudeHomePath` (i.e. no mirroring at all) for
|
|
166
|
+
* callers still running in the user's own home.
|
|
167
|
+
*/
|
|
168
|
+
private readonly legacyClaudeHomePath;
|
|
145
169
|
/** folder name → last-seen entry, reused while its `key` is unchanged. */
|
|
146
170
|
private readonly folders;
|
|
147
171
|
/**
|
|
@@ -153,7 +177,17 @@ export declare class AdoptableIndex {
|
|
|
153
177
|
private readonly remotes;
|
|
154
178
|
/** project key → cached summary + the composite key it was computed under. */
|
|
155
179
|
private readonly summaries;
|
|
156
|
-
constructor(claudeHomePath: string, stateDir: string
|
|
180
|
+
constructor(claudeHomePath: string, stateDir: string,
|
|
181
|
+
/**
|
|
182
|
+
* The user's own `~/.claude` (#620). Now that paddock owns its Claude home,
|
|
183
|
+
* the user's CLI history — the entire point of adoption — is no longer under
|
|
184
|
+
* `claudeHomePath`, and the engine resolves every adoption path against the
|
|
185
|
+
* one home the FleetManager was built with, with no per-call override.
|
|
186
|
+
* {@link scanFolders} therefore mirrors the legacy folders in first,
|
|
187
|
+
* read-only. Defaults to `claudeHomePath` (i.e. no mirroring at all) for
|
|
188
|
+
* callers still running in the user's own home.
|
|
189
|
+
*/
|
|
190
|
+
legacyClaudeHomePath?: string);
|
|
157
191
|
/**
|
|
158
192
|
* Drop a project's cached summary (or every project's, with no argument).
|
|
159
193
|
* Called after an import, which changes the adoptable set in a way the caller
|
|
@@ -73,6 +73,7 @@ import { promises as fs } from "node:fs";
|
|
|
73
73
|
import path from "node:path";
|
|
74
74
|
import { encodePathForCli, readSessionCwd } from "@herdctl/core";
|
|
75
75
|
import { repoCheckoutName } from "./project-paths.js";
|
|
76
|
+
import { mirrorLegacyTranscriptFolders } from "./claude-home.js";
|
|
76
77
|
/**
|
|
77
78
|
* Minimum transcript size to be worth offering, in bytes.
|
|
78
79
|
*
|
|
@@ -290,6 +291,7 @@ async function dirKey(dir) {
|
|
|
290
291
|
export class AdoptableIndex {
|
|
291
292
|
claudeHomePath;
|
|
292
293
|
stateDir;
|
|
294
|
+
legacyClaudeHomePath;
|
|
293
295
|
/** folder name → last-seen entry, reused while its `key` is unchanged. */
|
|
294
296
|
folders = new Map();
|
|
295
297
|
/**
|
|
@@ -301,9 +303,20 @@ export class AdoptableIndex {
|
|
|
301
303
|
remotes = new Map();
|
|
302
304
|
/** project key → cached summary + the composite key it was computed under. */
|
|
303
305
|
summaries = new Map();
|
|
304
|
-
constructor(claudeHomePath, stateDir
|
|
306
|
+
constructor(claudeHomePath, stateDir,
|
|
307
|
+
/**
|
|
308
|
+
* The user's own `~/.claude` (#620). Now that paddock owns its Claude home,
|
|
309
|
+
* the user's CLI history — the entire point of adoption — is no longer under
|
|
310
|
+
* `claudeHomePath`, and the engine resolves every adoption path against the
|
|
311
|
+
* one home the FleetManager was built with, with no per-call override.
|
|
312
|
+
* {@link scanFolders} therefore mirrors the legacy folders in first,
|
|
313
|
+
* read-only. Defaults to `claudeHomePath` (i.e. no mirroring at all) for
|
|
314
|
+
* callers still running in the user's own home.
|
|
315
|
+
*/
|
|
316
|
+
legacyClaudeHomePath = claudeHomePath) {
|
|
305
317
|
this.claudeHomePath = claudeHomePath;
|
|
306
318
|
this.stateDir = stateDir;
|
|
319
|
+
this.legacyClaudeHomePath = legacyClaudeHomePath;
|
|
307
320
|
}
|
|
308
321
|
/**
|
|
309
322
|
* Drop a project's cached summary (or every project's, with no argument).
|
|
@@ -325,6 +338,12 @@ export class AdoptableIndex {
|
|
|
325
338
|
* folders whose file set changed since last time.
|
|
326
339
|
*/
|
|
327
340
|
async scanFolders() {
|
|
341
|
+
// Bring the user's own transcript folders into view first (#620). Idempotent,
|
|
342
|
+
// and it must happen BEFORE the readdir below rather than once at boot: a
|
|
343
|
+
// directory the user `claude`s in for the first time today should become
|
|
344
|
+
// adoptable without restarting paddock.
|
|
345
|
+
const mirrors = await mirrorLegacyTranscriptFolders(this.claudeHomePath, this.legacyClaudeHomePath);
|
|
346
|
+
const mirrorCwds = new Map(mirrors.map((m) => [m.name, m.engineCwd]));
|
|
328
347
|
const root = path.join(this.claudeHomePath, "projects");
|
|
329
348
|
const names = await fs.readdir(root).catch(() => []);
|
|
330
349
|
const out = [];
|
|
@@ -339,11 +358,16 @@ export class AdoptableIndex {
|
|
|
339
358
|
out.push(cached);
|
|
340
359
|
continue;
|
|
341
360
|
}
|
|
361
|
+
const cwd = await recordedCwd(dir);
|
|
342
362
|
const entry = {
|
|
343
363
|
name,
|
|
344
364
|
realPath: await fs.realpath(dir).catch(() => dir),
|
|
345
365
|
key,
|
|
346
|
-
cwd
|
|
366
|
+
cwd,
|
|
367
|
+
// A mirror is reachable ONLY under its synthetic path: its folder name is
|
|
368
|
+
// not `encodePathForCli(cwd)`, so handing the engine the recorded cwd
|
|
369
|
+
// would send it to the project's own folder instead (#620).
|
|
370
|
+
engineCwd: mirrorCwds.get(name) ?? cwd,
|
|
347
371
|
};
|
|
348
372
|
this.folders.set(name, entry);
|
|
349
373
|
out.push(entry);
|
|
@@ -405,7 +429,7 @@ export class AdoptableIndex {
|
|
|
405
429
|
const seen = new Set([ownReal]);
|
|
406
430
|
const sources = [];
|
|
407
431
|
for (const folder of folders) {
|
|
408
|
-
if (folder.cwd === null || !nameMatches(folder.cwd))
|
|
432
|
+
if (folder.cwd === null || folder.engineCwd === null || !nameMatches(folder.cwd))
|
|
409
433
|
continue;
|
|
410
434
|
if (seen.has(folder.realPath))
|
|
411
435
|
continue;
|
|
@@ -418,13 +442,19 @@ export class AdoptableIndex {
|
|
|
418
442
|
continue;
|
|
419
443
|
}
|
|
420
444
|
seen.add(folder.realPath);
|
|
421
|
-
|
|
445
|
+
// Display the cwd the user recognises; hand the engine whatever actually
|
|
446
|
+
// reaches this folder (#620). For everything but a legacy mirror the two
|
|
447
|
+
// are the same string.
|
|
448
|
+
sources.push({ display: folder.cwd, engine: folder.engineCwd });
|
|
422
449
|
}
|
|
423
450
|
// The project's own working directory goes LAST so that a session offered by
|
|
424
451
|
// both it and an external checkout is attributed to the external one — the
|
|
425
452
|
// origin, where the transcript actually lives, so a copy has something to
|
|
426
|
-
// copy (#588 gotcha 2).
|
|
427
|
-
|
|
453
|
+
// copy (#588 gotcha 2). Post-#620 that ordering earns its keep again: the
|
|
454
|
+
// user's `~/.claude` folder for this very cwd and the project's own `.chats/`
|
|
455
|
+
// are now two DIFFERENT directories that both display as `own`, and the
|
|
456
|
+
// user's is the one a copy can be taken from.
|
|
457
|
+
sources.push({ display: own, engine: own });
|
|
428
458
|
return sources;
|
|
429
459
|
}
|
|
430
460
|
/**
|
|
@@ -442,8 +472,9 @@ export class AdoptableIndex {
|
|
|
442
472
|
// so an empty cwd is a value that genuinely occurs here. Written as the
|
|
443
473
|
// escape, never a raw NUL byte: a raw NUL makes ripgrep classify the whole
|
|
444
474
|
// file as binary and skip it, so the file vanishes from code search (#570).
|
|
475
|
+
const matchedEngineCwds = new Set(sources.map((s) => s.engine));
|
|
445
476
|
const matchedKeys = folders
|
|
446
|
-
.filter((f) =>
|
|
477
|
+
.filter((f) => matchedEngineCwds.has(f.engineCwd ?? "\u0000"))
|
|
447
478
|
.map((f) => `${f.name}=${f.key}`)
|
|
448
479
|
.sort();
|
|
449
480
|
const key = [
|
|
@@ -459,10 +490,15 @@ export class AdoptableIndex {
|
|
|
459
490
|
const out = [];
|
|
460
491
|
const filtered = [];
|
|
461
492
|
let count = 0;
|
|
462
|
-
for (const
|
|
493
|
+
for (const source of sources) {
|
|
494
|
+
const sourceCwd = source.display;
|
|
463
495
|
// Fault-isolated per source: an unreadable folder costs its own sessions,
|
|
464
496
|
// not the whole count.
|
|
465
|
-
|
|
497
|
+
// Read through `source.engine`, NOT `sourceCwd`: a legacy mirror is only
|
|
498
|
+
// reachable under its synthetic path (#620).
|
|
499
|
+
const sessions = await fleet
|
|
500
|
+
.listAdoptableSessions(agentName, source.engine)
|
|
501
|
+
.catch(() => []);
|
|
466
502
|
const candidates = [];
|
|
467
503
|
for (const session of sessions) {
|
|
468
504
|
// De-dup by session id. Two candidate cwds can collide onto one folder
|
|
@@ -485,10 +521,14 @@ export class AdoptableIndex {
|
|
|
485
521
|
});
|
|
486
522
|
}
|
|
487
523
|
if (candidates.length > 0) {
|
|
488
|
-
//
|
|
489
|
-
//
|
|
524
|
+
// `importFrom` is carried only when it actually differs, so an ordinary
|
|
525
|
+
// source's DTO is byte-identical to what it was before #620.
|
|
526
|
+
//
|
|
527
|
+
// Both id/detail projections are built HERE, from one array, so
|
|
528
|
+
// `sessionIds` can never disagree with `sessions`.
|
|
490
529
|
out.push({
|
|
491
530
|
sourceCwd,
|
|
531
|
+
...(source.engine === sourceCwd ? {} : { importFrom: source.engine }),
|
|
492
532
|
sessionIds: candidates.map((c) => c.sessionId),
|
|
493
533
|
sessions: candidates,
|
|
494
534
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-line, actionable reason this failed, if it is one we recognise.
|
|
3
|
+
*
|
|
4
|
+
* `undefined` means "no idea" — and an unknown failure keeps its full detail,
|
|
5
|
+
* because that is exactly the case where somebody needs it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function classifyAgentError(err: unknown): string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* The same error with the reconstructed `claude` argv cut out.
|
|
10
|
+
*
|
|
11
|
+
* execa's message quotes the entire command it ran, which for the sweeper is
|
|
12
|
+
* ~2 KB of system prompt followed by the full `--disallowedTools` list. It is
|
|
13
|
+
* noise to an end user and it buries the signal; it also means the curator
|
|
14
|
+
* prompt is copied into any log or paste of the failure. Everything before the
|
|
15
|
+
* argv (`Command failed with exit code 1`) is kept, because the exit status is
|
|
16
|
+
* the part that is actually diagnostic.
|
|
17
|
+
*
|
|
18
|
+
* Full text is still one env var away — see {@link installHerdctlLogBridge}.
|
|
19
|
+
*/
|
|
20
|
+
export declare function summariseAgentError(message: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Route `@herdctl/core`'s logging through a handler that shapes agent failures.
|
|
23
|
+
*
|
|
24
|
+
* Two behaviours, and they are separate on purpose:
|
|
25
|
+
*
|
|
26
|
+
* - **Always** summarise the argv. The system prompt in a `claude -p` command
|
|
27
|
+
* line is never what somebody is reading the log for, on a laptop or a
|
|
28
|
+
* server. `HERDCTL_LOG_LEVEL=debug` turns it back on — at debug you have
|
|
29
|
+
* asked for everything, and the argv is genuinely useful there.
|
|
30
|
+
* - **In quiet mode** (the CLI's default), collapse a recognised failure to its
|
|
31
|
+
* cause. `LOG_LEVEL=warn` was never enough for this: core logs these at
|
|
32
|
+
* `error`, which passes every level filter there is, and one of them is a
|
|
33
|
+
* bare `console.error`. Quiet-by-default meant "quiet when nothing goes
|
|
34
|
+
* wrong", which is the easy half.
|
|
35
|
+
*
|
|
36
|
+
* Idempotent; installing again replaces the handler. Call `setLogHandler(null)`
|
|
37
|
+
* to restore core's own output.
|
|
38
|
+
*/
|
|
39
|
+
export declare function installHerdctlLogBridge(options: {
|
|
40
|
+
quiet: boolean;
|
|
41
|
+
}): void;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making a failed agent run readable (#684).
|
|
3
|
+
*
|
|
4
|
+
* A credential-less `npx @edspencer/paddock --here` — the path the README, the
|
|
5
|
+
* docs site and the front page all point new users at — greeted them with a
|
|
6
|
+
* multi-screen stack trace containing the entire sweeper system prompt, four
|
|
7
|
+
* times over: once in the `ExecaError`, once in the `[fleet-manager]` line, once
|
|
8
|
+
* in the serialised `err.message` and once inside `err.stack`. The one useful
|
|
9
|
+
* string, `Not logged in · Please run /login`, was on line 40 of the dump. The
|
|
10
|
+
* server was fine and still serving; nothing about the output said so.
|
|
11
|
+
*
|
|
12
|
+
* Three separate things produce that wall, and only one of them is ours:
|
|
13
|
+
*
|
|
14
|
+
* 1. `sweep.ts` logging `{ err }` at pino level 50 — ours, fixed there.
|
|
15
|
+
* 2. `[CLIRuntime] Process failed: …` — `@herdctl/core`.
|
|
16
|
+
* 3. `[fleet-manager] Job … error: …` — `@herdctl/core`.
|
|
17
|
+
*
|
|
18
|
+
* We cannot edit core from here, but we do not have to: `setLogHandler` is an
|
|
19
|
+
* exported hook that replaces the `console.*` output of EVERY `createLogger` in
|
|
20
|
+
* the engine. {@link installHerdctlLogBridge} uses it to shape both of core's
|
|
21
|
+
* lines on the way past. That is a better fix than patching core anyway — it
|
|
22
|
+
* keeps the decision about how much noise a paddock instance emits inside
|
|
23
|
+
* paddock, where the CLI's quiet mode lives.
|
|
24
|
+
*/
|
|
25
|
+
import { setLogHandler, getLogLevel, LOG_LEVEL_ORDER } from "@herdctl/core";
|
|
26
|
+
/**
|
|
27
|
+
* Failures that are USER STATE, not exceptions — a known cause with a known
|
|
28
|
+
* remedy, where a stack trace tells the reader nothing they can act on.
|
|
29
|
+
*
|
|
30
|
+
* Matched against the whole error text because the signal arrives in the failed
|
|
31
|
+
* command's own output, not in the message execa constructs.
|
|
32
|
+
*/
|
|
33
|
+
const KNOWN_CAUSES = [
|
|
34
|
+
{
|
|
35
|
+
pattern: /Not logged in|Please run \/login|Invalid API key|OAuth token has expired/i,
|
|
36
|
+
cause: "Claude Code is not logged in — run `claude setup-token`, " +
|
|
37
|
+
"or set CLAUDE_CODE_OAUTH_TOKEN",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
pattern: /Credit balance is too low|insufficient[_ ]quota/i,
|
|
41
|
+
cause: "the Claude account is out of credit",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
// Anchored to `claude` specifically. A bare /ENOENT/ would claim a missing
|
|
45
|
+
// CLI for any file the engine failed to open, which is a confident wrong
|
|
46
|
+
// answer — worse than the raw error it replaces.
|
|
47
|
+
pattern: /ENOENT[^\n]{0,40}claude|claude: (command )?not found/i,
|
|
48
|
+
cause: "the `claude` CLI is not on PATH (needed by the sweeper and triggers)",
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* The one-line, actionable reason this failed, if it is one we recognise.
|
|
53
|
+
*
|
|
54
|
+
* `undefined` means "no idea" — and an unknown failure keeps its full detail,
|
|
55
|
+
* because that is exactly the case where somebody needs it.
|
|
56
|
+
*/
|
|
57
|
+
export function classifyAgentError(err) {
|
|
58
|
+
const text = agentErrorText(err);
|
|
59
|
+
return KNOWN_CAUSES.find((k) => k.pattern.test(text))?.cause;
|
|
60
|
+
}
|
|
61
|
+
/** Everything an error carries as text — message, and stdio execa attached. */
|
|
62
|
+
function agentErrorText(err) {
|
|
63
|
+
if (typeof err === "string")
|
|
64
|
+
return err;
|
|
65
|
+
const e = err;
|
|
66
|
+
return [e?.message, e?.stdout, e?.stderr].filter((v) => typeof v === "string").join("\n");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The same error with the reconstructed `claude` argv cut out.
|
|
70
|
+
*
|
|
71
|
+
* execa's message quotes the entire command it ran, which for the sweeper is
|
|
72
|
+
* ~2 KB of system prompt followed by the full `--disallowedTools` list. It is
|
|
73
|
+
* noise to an end user and it buries the signal; it also means the curator
|
|
74
|
+
* prompt is copied into any log or paste of the failure. Everything before the
|
|
75
|
+
* argv (`Command failed with exit code 1`) is kept, because the exit status is
|
|
76
|
+
* the part that is actually diagnostic.
|
|
77
|
+
*
|
|
78
|
+
* Full text is still one env var away — see {@link installHerdctlLogBridge}.
|
|
79
|
+
*/
|
|
80
|
+
export function summariseAgentError(message) {
|
|
81
|
+
return message.replace(
|
|
82
|
+
// Up to and including "claude", then a flag, then everything after it.
|
|
83
|
+
/(:\s*)claude(\.exe)?\s+-{1,2}[\s\S]*/, "$1claude … (argv omitted — set HERDCTL_LOG_LEVEL=debug for the full command)");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Route `@herdctl/core`'s logging through a handler that shapes agent failures.
|
|
87
|
+
*
|
|
88
|
+
* Two behaviours, and they are separate on purpose:
|
|
89
|
+
*
|
|
90
|
+
* - **Always** summarise the argv. The system prompt in a `claude -p` command
|
|
91
|
+
* line is never what somebody is reading the log for, on a laptop or a
|
|
92
|
+
* server. `HERDCTL_LOG_LEVEL=debug` turns it back on — at debug you have
|
|
93
|
+
* asked for everything, and the argv is genuinely useful there.
|
|
94
|
+
* - **In quiet mode** (the CLI's default), collapse a recognised failure to its
|
|
95
|
+
* cause. `LOG_LEVEL=warn` was never enough for this: core logs these at
|
|
96
|
+
* `error`, which passes every level filter there is, and one of them is a
|
|
97
|
+
* bare `console.error`. Quiet-by-default meant "quiet when nothing goes
|
|
98
|
+
* wrong", which is the easy half.
|
|
99
|
+
*
|
|
100
|
+
* Idempotent; installing again replaces the handler. Call `setLogHandler(null)`
|
|
101
|
+
* to restore core's own output.
|
|
102
|
+
*/
|
|
103
|
+
export function installHerdctlLogBridge(options) {
|
|
104
|
+
const debug = LOG_LEVEL_ORDER[getLogLevel()] <= LOG_LEVEL_ORDER.debug;
|
|
105
|
+
setLogHandler((level, prefix, message, data) => {
|
|
106
|
+
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[getLogLevel()])
|
|
107
|
+
return;
|
|
108
|
+
let text = debug ? message : summariseAgentError(message);
|
|
109
|
+
if (options.quiet && level === "error") {
|
|
110
|
+
const cause = classifyAgentError(message);
|
|
111
|
+
// A known cause replaces the message rather than annotating it: the
|
|
112
|
+
// reader does not need the exit code as well as the reason, and the
|
|
113
|
+
// failure is non-fatal — the server is still up and still serving.
|
|
114
|
+
if (cause !== undefined)
|
|
115
|
+
text = `${cause} — run with --verbose for the full error`;
|
|
116
|
+
}
|
|
117
|
+
const line = data === undefined ? `[${prefix}] ${text}` : `[${prefix}] ${text} ${JSON.stringify(data)}`;
|
|
118
|
+
if (level === "error")
|
|
119
|
+
console.error(line);
|
|
120
|
+
else if (level === "warn")
|
|
121
|
+
console.warn(line);
|
|
122
|
+
else if (level === "info")
|
|
123
|
+
console.info(line);
|
|
124
|
+
else
|
|
125
|
+
console.debug(line);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -19,6 +19,8 @@ import { createRequire } from "node:module";
|
|
|
19
19
|
import { promises as fs } from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { loadPaddockConfig } from "./config.js";
|
|
22
|
+
import { ensureClaudeHome, countLegacyTranscriptLinks, findPlantedChatsLinks, } from "./claude-home.js";
|
|
23
|
+
import { installHerdctlLogBridge } from "./agent-errors.js";
|
|
22
24
|
import { ProjectStore, ROOT_KEY } from "./projects.js";
|
|
23
25
|
import { AttachmentStore } from "./attachments.js";
|
|
24
26
|
import { HerdctlService } from "./herdctl.js";
|
|
@@ -73,6 +75,11 @@ function hasFileExtension(pathname) {
|
|
|
73
75
|
*/
|
|
74
76
|
export async function buildApp(opts = {}) {
|
|
75
77
|
const cfg = opts.config ?? loadPaddockConfig();
|
|
78
|
+
// Shape the engine's own logging before anything can start a job (#684). Two
|
|
79
|
+
// of the three sources of the credential-failure stack-trace wall are inside
|
|
80
|
+
// `@herdctl/core`, and `setLogHandler` is the supported way to reach them from
|
|
81
|
+
// out here. `PADDOCK_QUIET` is set by `cli/paddock.ts` unless `--verbose`.
|
|
82
|
+
installHerdctlLogBridge({ quiet: (process.env.PADDOCK_QUIET ?? "") !== "" });
|
|
76
83
|
const app = Fastify({
|
|
77
84
|
logger: { level: cfg.logLevel },
|
|
78
85
|
});
|
|
@@ -150,6 +157,35 @@ export async function buildApp(opts = {}) {
|
|
|
150
157
|
// root workspace itself is never in it. It always exists — the instance's own
|
|
151
158
|
// directory — so resolve it explicitly and register its keeper alongside every
|
|
152
159
|
// child's. The root keeper is an ordinary keeper in every respect.
|
|
160
|
+
// Paddock's own Claude home (#620) — created and bridged BEFORE the fleet
|
|
161
|
+
// starts, because `herdctl.init` immediately plants every project's transcript
|
|
162
|
+
// symlink inside it and hands the same path to the FleetManager.
|
|
163
|
+
for (const notice of (await ensureClaudeHome(cfg)).notices) {
|
|
164
|
+
app.log[notice.level](notice.message);
|
|
165
|
+
}
|
|
166
|
+
if (cfg.ownsClaudeHome) {
|
|
167
|
+
const stale = await countLegacyTranscriptLinks(cfg.legacyClaudeHome, cfg.dataDir);
|
|
168
|
+
if (stale > 0) {
|
|
169
|
+
app.log.info(`${stale} transcript symlink(s) from a previous layout still point into ${cfg.dataDir} ` +
|
|
170
|
+
`from ${path.join(cfg.legacyClaudeHome, "projects")}. Nothing reads them any more; ` +
|
|
171
|
+
`paddock leaves them alone because it does not write to ~/.claude. Safe to delete.`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// #682: links an affected build planted at a path the user had not used yet.
|
|
175
|
+
// Unlike the residue above these are still LIVE — they redirect the user's own
|
|
176
|
+
// future `claude` sessions — so they warn, and name every path. Paddock does
|
|
177
|
+
// not remove them; it does not write to `~/.claude`.
|
|
178
|
+
const poisoned = await findPlantedChatsLinks(cfg.legacyClaudeHome, cfg.dataDir);
|
|
179
|
+
if (poisoned.length > 0) {
|
|
180
|
+
app.log.warn(`${poisoned.length} symlink(s) in ${path.join(cfg.legacyClaudeHome, "projects")} redirect ` +
|
|
181
|
+
`Claude Code's transcripts into a paddock .chats/ store (#682). While they exist, ` +
|
|
182
|
+
`\`claude\` sessions you start in those directories are written to paddock's store, and ` +
|
|
183
|
+
`deleting that store destroys them. Paddock does not remove anything under ~/.claude — ` +
|
|
184
|
+
`delete them yourself if you did not intend this:\n` +
|
|
185
|
+
poisoned
|
|
186
|
+
.map((p) => ` ${p.link} -> ${p.target}${p.dangling ? " (target is missing)" : ""}`)
|
|
187
|
+
.join("\n"));
|
|
188
|
+
}
|
|
153
189
|
const rootWorkspace = await projects.get(ROOT_KEY);
|
|
154
190
|
const initialProjects = [...(await projects.list()), rootWorkspace];
|
|
155
191
|
try {
|