@edspencer/paddock 0.59.1 → 0.61.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.
- package/LICENSE +21 -0
- package/README.md +31 -1
- package/package.json +4 -3
- package/packages/server/dist/adoptable.d.ts +108 -5
- package/packages/server/dist/adoptable.js +279 -33
- package/packages/server/dist/app.js +15 -0
- package/packages/server/dist/claude-home.d.ts +125 -0
- package/packages/server/dist/claude-home.js +276 -0
- package/packages/server/dist/cli/import-chats.js +9 -6
- package/packages/server/dist/cli/paddock.js +9 -31
- 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 +95 -0
- package/packages/server/dist/herdctl.js +209 -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 +92 -9
- package/packages/server/dist/run-provenance.d.ts +11 -0
- package/packages/server/dist/run-provenance.js +18 -0
- package/packages/server/dist/transcripts.d.ts +25 -6
- package/packages/server/dist/transcripts.js +12 -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-turn.d.ts +0 -13
- package/packages/server/dist/ws-turn.js +50 -0
- package/packages/server/dist/ws.js +43 -11
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-CAwSnyga.js → InstanceConfigPage-CzMqTdUU.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DpGpkMzG.js → MarkdownRenderer-YSEZHWDe.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-D1ipv4jr.js → ProjectRedirect-DLWxZbqK.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BFX8ff2n.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-Dwngb-m1.js → ProjectsGrid-DLOeY_ww.js} +1 -1
- package/packages/web/dist/assets/{arc-BZh6Z9_D.js → arc-BwC1ik-b.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-iCtJiW4k.js → architectureDiagram-3BPJPVTR-CsVvXQWw.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BeI9Wi0h.js → blockDiagram-GPEHLZMM-DXQk2bIa.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D_j2LKZ-.js → c4Diagram-AAUBKEIU-DXiA26It.js} +1 -1
- package/packages/web/dist/assets/channel-DDgTXrGP.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-DRlfPROq.js → chunk-2J33WTMH-Dt5nC02A.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-BMY66MLs.js → chunk-4BX2VUAB-DcjnWvl6.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BAQ92trf.js → chunk-55IACEB6-CRa3LkU4.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-Dxgb_nNN.js → chunk-727SXJPM-H3V-11qu.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-D5cqv3Jv.js → chunk-AQP2D5EJ-DPCrENYV.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BOEqxzyA.js → chunk-FMBD7UC4-C4UO0ZTc.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-CkTZgv_W.js → chunk-ND2GUHAM-BtI37CnI.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-DIr8TL7M.js → chunk-QZHKN3VN-C5bTaK5j.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU1kd_Vu.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU1kd_Vu.js +2 -0
- package/packages/web/dist/assets/{core-DyOZ2e1O.js → core-DB3cpm5N.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-D8Muyf4o.js → cose-bilkent-S5V4N54A-q-cjTZO4.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-B_EUbUz6.js → dagre-BM42HDAG-DYEV5HFo.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DpAM0K6Q.js → diagram-2AECGRRQ-D16XCw78.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Dhs9RMKk.js → diagram-5GNKFQAL-CeztryYE.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-LBeTHReu.js → diagram-KO2AKTUF-D5SLIMDS.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CxboRwue.js → diagram-LMA3HP47-Bw-sVPxM.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-BK_P-OSi.js → diagram-OG6HWLK6-DsRMITgx.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-CPSKRHI5.js → erDiagram-TEJ5UH35-T8Qqc_ku.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CbUumAP5.js → flowDiagram-I6XJVG4X-Cyx47UWU.js} +1 -1
- package/packages/web/dist/assets/{format-DDL0Q7H2.js → format-D2I0duZa.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-l-p25ckA.js → ganttDiagram-6RSMTGT7-DxYJmG9o.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-i8kDIgga.js → gitGraphDiagram-PVQCEYII-COK5YOTD.js} +1 -1
- package/packages/web/dist/assets/index-B6etJCHf.css +1 -0
- package/packages/web/dist/assets/index-wBXQMkGI.js +71 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CbyLnHIe.js → infoDiagram-5YYISTIA-BamRN_Nn.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-C1_3z8-9.js → ishikawaDiagram-YF4QCWOH-DuOHdpEH.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BzyAtQdy.js → journeyDiagram-JHISSGLW-Bri7GKTW.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Fp2VVm-b.js → kanban-definition-UN3LZRKU-BdkgUnzI.js} +1 -1
- package/packages/web/dist/assets/{linear-6ZKEeWQ5.js → linear-DOcNPCpB.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Dorg4Dl2.js → mermaid.core-D9SbVBpN.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bk4Ax0bI.js → mindmap-definition-RKZ34NQL-CVmLm7zn.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DoTn5jHm.js → pieDiagram-4H26LBE5-D0N10eVU.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ClNmIKCj.js → quadrantDiagram-W4KKPZXB-B5gnYdTa.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DMF7i5KW.js → requirementDiagram-4Y6WPE33-CwtxezNZ.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-C-ALbVLg.js → sankeyDiagram-5OEKKPKP-D79O3cux.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DsUxj8IQ.js → sequenceDiagram-3UESZ5HK-xhKcI6tI.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-OCZiRRsz.js → stateDiagram-AJRCARHV-DBYMAjcX.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CZtr9Jvq.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-5ifjxa-s.js → timeline-definition-PNZ67QCA-e-MYF4lm.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Bqvqpot5.js → vennDiagram-CIIHVFJN-B8UsvRDM.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DwgnVd48.js → wardley-L42UT6IY-1ucACjTl.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-F-VCH-oE.js → wardleyDiagram-YWT4CUSO-DJufLd_u.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-L4q6oV_7.js → xychartDiagram-2RQKCTM6-qqaZNlu9.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/ProjectView-BI1TtrOK.js +0 -13
- package/packages/web/dist/assets/channel-DszBVZgr.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuGqlnbT.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuGqlnbT.js +0 -2
- package/packages/web/dist/assets/index-CvGiQddO.css +0 -1
- package/packages/web/dist/assets/index-DKDgTDXa.js +0 -71
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BwcJspgG.js +0 -2
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
*
|
|
16
16
|
* 1. **Assemble candidate sources** — the project's own `workingDir`, plus any
|
|
17
17
|
* `<claudeHome>/projects/*` folder whose RECORDED cwd matches the project.
|
|
18
|
-
* 2. **Filter low-value noise** — a zero-byte transcript
|
|
19
|
-
* nothing but a `/mcp` slash command
|
|
18
|
+
* 2. **Filter low-value noise** — a zero-byte transcript, a session that is
|
|
19
|
+
* nothing but a `/mcp` slash command, or one of paddock's OWN sweeper runs
|
|
20
|
+
* (#658) is not a chat anyone wants imported.
|
|
20
21
|
* 3. **Cache** — the scan is O(all transcript folders on the machine), so it is
|
|
21
22
|
* keyed on directory mtimes and only redone when something could have moved.
|
|
22
23
|
*
|
|
@@ -35,16 +36,35 @@
|
|
|
35
36
|
*
|
|
36
37
|
* ## The matching heuristic, and what it cannot do
|
|
37
38
|
*
|
|
38
|
-
* For a REPO-BACKED project a folder matches when its recorded cwd's
|
|
39
|
-
* equals the project's checkout name (`repoCheckoutName(repo)`)
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
39
|
+
* For a REPO-BACKED project a folder matches when BOTH hold: its recorded cwd's
|
|
40
|
+
* BASENAME equals the project's checkout name (`repoCheckoutName(repo)`), AND
|
|
41
|
+
* that directory is a git checkout with a remote pointing at the project's repo
|
|
42
|
+
* — i.e. "the user really does have their own clone of this repo elsewhere".
|
|
43
|
+
*
|
|
44
|
+
* The basename test alone used to be the whole rule, documented as deliberately
|
|
45
|
+
* loose. It is unbounded across the filesystem, and on the dogfooding instance it
|
|
46
|
+
* was reaching into a THROWAWAY QA instance's data directory: the `hushpod`
|
|
47
|
+
* project offered 15 chats out of `/data/scratch/paddock-video/data/projects/
|
|
48
|
+
* hushpod`, which belong to a different Paddock instance entirely (#659). "Two
|
|
49
|
+
* unrelated checkouts that share a basename" turns out not to be a corner case on
|
|
50
|
+
* a machine that hosts scratch copies, backups and QA clones.
|
|
51
|
+
*
|
|
52
|
+
* The original objection to checking the remote was cost — "running git in every
|
|
53
|
+
* candidate directory is the wrong cost for a count shown in a header". That
|
|
54
|
+
* objection is answered by not running git: the remote is READ out of
|
|
55
|
+
* `<cwd>/.git/config`, one small file, and only for the handful of directories
|
|
56
|
+
* that already passed the basename test. Results are memoised on the config
|
|
57
|
+
* file's own mtime+size.
|
|
58
|
+
*
|
|
59
|
+
* A candidate with no readable git config, or whose remotes all point somewhere
|
|
60
|
+
* else, is not offered. That is stricter than before by design — a directory that
|
|
61
|
+
* is not a clone of this repo has no claim to be imported into it. The project's
|
|
62
|
+
* OWN working directory is exempt: it is appended unconditionally below, so a
|
|
63
|
+
* project whose checkout has an unusual remote still offers its own history.
|
|
64
|
+
*
|
|
65
|
+
* Still NOT identity-precise, and deliberately so: a clone the user renamed on
|
|
66
|
+
* disk does not match (the basename test runs first), and a fork with a different
|
|
67
|
+
* remote does not match. Both fail CLOSED — nothing is offered that shouldn't be.
|
|
48
68
|
*
|
|
49
69
|
* For a NOTEBOOK project the match is exact cwd equality: its working directory
|
|
50
70
|
* IS the project directory, and nothing else is plausibly "the same project".
|
|
@@ -53,6 +73,7 @@ import { promises as fs } from "node:fs";
|
|
|
53
73
|
import path from "node:path";
|
|
54
74
|
import { encodePathForCli, readSessionCwd } from "@herdctl/core";
|
|
55
75
|
import { repoCheckoutName } from "./project-paths.js";
|
|
76
|
+
import { mirrorLegacyTranscriptFolders } from "./claude-home.js";
|
|
56
77
|
/**
|
|
57
78
|
* Minimum transcript size to be worth offering, in bytes.
|
|
58
79
|
*
|
|
@@ -91,10 +112,55 @@ export const SLASH_COMMAND_MAX_BYTES = 4096;
|
|
|
91
112
|
* `/review the auth refactor` all do.
|
|
92
113
|
*/
|
|
93
114
|
const SLASH_COMMAND_ONLY_RE = /^\s*(?:<command-name>\s*)?\/[a-z][a-z0-9:_-]*(?![^\s<])/i;
|
|
115
|
+
/**
|
|
116
|
+
* A preview that opens with paddock's OWN sweeper prompt (#658).
|
|
117
|
+
*
|
|
118
|
+
* The sweeper is a one-shot `claude -p` subprocess (`sweep.ts` `runSweeper`), so
|
|
119
|
+
* it writes a perfectly ordinary transcript into the project's own chat folder.
|
|
120
|
+
* When no run record binds that session — the sweeper's own history on the
|
|
121
|
+
* dogfooding instance has ten such transcripts with no `session_id` in any
|
|
122
|
+
* `job-*.yaml` — attribution cannot tell it from a session the user typed in a
|
|
123
|
+
* terminal, and paddock ends up offering the user its own curation output as a
|
|
124
|
+
* chat to "import".
|
|
125
|
+
*
|
|
126
|
+
* Matched on the prompt's two stable opening features: the `Project: <name>
|
|
127
|
+
* (slug: <slug>)` header `buildPrompt` always emits first, and the `You are
|
|
128
|
+
* curating` sentence that follows it. Both are required. The header alone is too
|
|
129
|
+
* weak (a user could plausibly open a chat that way), and the sentence alone
|
|
130
|
+
* would match someone quoting the sweeper prompt to talk ABOUT it.
|
|
131
|
+
*
|
|
132
|
+
* Whitespace between the two is `\s+` rather than `\n`: the header is followed by
|
|
133
|
+
* an optional `Summary:` line and a blank line, and the engine's preview
|
|
134
|
+
* normalises runs of whitespace. The wording of the sentence has already drifted
|
|
135
|
+
* once ("curating two files in this project directory" → "curating this
|
|
136
|
+
* project's three context files"), so only the stable `You are curating` stem is
|
|
137
|
+
* matched.
|
|
138
|
+
*
|
|
139
|
+
* The engine truncates a preview at 100 characters, so a project whose name is
|
|
140
|
+
* long enough to push `You are curating` past that boundary will not match and
|
|
141
|
+
* its sweeper chats stay on offer. That is the deliberate direction to fail in:
|
|
142
|
+
* a sweeper chat wrongly offered is noise the user can decline, while a real chat
|
|
143
|
+
* wrongly withheld is history they cannot get at. Withheld candidates are
|
|
144
|
+
* reported under `filtered` for the same reason.
|
|
145
|
+
*/
|
|
146
|
+
const SWEEPER_PROMPT_RE = /^Project: .*\(slug: [^)]*\)\s+(?:Summary:.*\s+)?You are curating\b/;
|
|
147
|
+
/**
|
|
148
|
+
* Does this transcript preview look like paddock's own sweeper run (#658)?
|
|
149
|
+
*
|
|
150
|
+
* Exported so `sweeper-prompt-contract.test.ts` can assert it against the prompt
|
|
151
|
+
* `SweepService` REALLY builds. The wording has drifted once already; a copy of
|
|
152
|
+
* it pasted into a test here would go stale silently and take the filter with it.
|
|
153
|
+
*/
|
|
154
|
+
export function isSweeperPrompt(preview) {
|
|
155
|
+
return SWEEPER_PROMPT_RE.test(preview);
|
|
156
|
+
}
|
|
94
157
|
/** Classify a candidate as noise, or `null` to keep it. */
|
|
95
158
|
function filterReasonFor(session) {
|
|
96
159
|
if (session.sizeBytes < MIN_TRANSCRIPT_BYTES)
|
|
97
160
|
return "too-small";
|
|
161
|
+
if (session.preview !== undefined && isSweeperPrompt(session.preview)) {
|
|
162
|
+
return "sweeper-run";
|
|
163
|
+
}
|
|
98
164
|
if (session.sizeBytes < SLASH_COMMAND_MAX_BYTES &&
|
|
99
165
|
session.preview !== undefined &&
|
|
100
166
|
SLASH_COMMAND_ONLY_RE.test(session.preview)) {
|
|
@@ -102,7 +168,96 @@ function filterReasonFor(session) {
|
|
|
102
168
|
}
|
|
103
169
|
return null;
|
|
104
170
|
}
|
|
105
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Reduce a git remote URL to a comparable `host/path` identity (#659).
|
|
173
|
+
*
|
|
174
|
+
* The same repo is written many ways, and a project's `repo` field and a clone's
|
|
175
|
+
* configured remote routinely disagree on form while naming the same thing:
|
|
176
|
+
*
|
|
177
|
+
* https://github.com/edspencer/hushpod.git ┐
|
|
178
|
+
* git@github.com:edspencer/hushpod ├─ all → github.com/edspencer/hushpod
|
|
179
|
+
* ssh://git@github.com/edspencer/hushpod/ ┘
|
|
180
|
+
*
|
|
181
|
+
* So: drop any scheme, drop `user@`, turn the scp-style `host:path` colon into a
|
|
182
|
+
* slash, drop a trailing `.git` and any trailing slashes, and lowercase. A local
|
|
183
|
+
* path remote (`/srv/git/foo.git`) normalises to `/srv/git/foo` and still
|
|
184
|
+
* compares correctly against itself.
|
|
185
|
+
*
|
|
186
|
+
* Returns `""` for anything that reduces to nothing, which never compares equal
|
|
187
|
+
* to a real remote — the caller does not have to special-case it.
|
|
188
|
+
*/
|
|
189
|
+
export function normalizeRemote(url) {
|
|
190
|
+
let s = url.trim();
|
|
191
|
+
if (s === "")
|
|
192
|
+
return "";
|
|
193
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); // https://, ssh://, git://
|
|
194
|
+
s = s.replace(/^[^/@]+@/, ""); // git@
|
|
195
|
+
// scp-style `host:owner/repo` — but not a port (`host:22/owner/repo`) and not
|
|
196
|
+
// an absolute local path, neither of which has this shape.
|
|
197
|
+
s = s.replace(/^([^/:]+):(?!\/)/, "$1/");
|
|
198
|
+
s = s.replace(/\.git$/i, "");
|
|
199
|
+
s = s.replace(/\/+$/, "");
|
|
200
|
+
return s.toLowerCase();
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Every remote URL configured in a git checkout, normalised.
|
|
204
|
+
*
|
|
205
|
+
* Reads `.git/config` directly rather than shelling out to git: this runs for
|
|
206
|
+
* candidate directories behind a count rendered in a header, and a subprocess per
|
|
207
|
+
* candidate is exactly the cost that made checking the remote look unaffordable
|
|
208
|
+
* in the first place.
|
|
209
|
+
*
|
|
210
|
+
* Handles a linked WORKTREE, where `.git` is a FILE holding `gitdir: <path>` and
|
|
211
|
+
* the config lives in the MAIN repository's git dir — found by resolving the
|
|
212
|
+
* `commondir` pointer. This repo is developed with worktrees, so a contributor's
|
|
213
|
+
* `wt-*` checkout is a real case, not a hypothetical.
|
|
214
|
+
*
|
|
215
|
+
* Returns `[]` for anything unreadable or not a checkout at all. An INI parse is
|
|
216
|
+
* enough here — `url = …` inside a `[remote "…"]` section — because the question
|
|
217
|
+
* is only "does any remote name this repo", not "what is the exact config".
|
|
218
|
+
*/
|
|
219
|
+
async function gitRemotes(cwd) {
|
|
220
|
+
const dot = path.join(cwd, ".git");
|
|
221
|
+
const st = await fs.stat(dot).catch(() => null);
|
|
222
|
+
if (st === null)
|
|
223
|
+
return [];
|
|
224
|
+
let gitDir = dot;
|
|
225
|
+
if (!st.isDirectory()) {
|
|
226
|
+
const pointer = await fs.readFile(dot, "utf8").catch(() => "");
|
|
227
|
+
const m = /^\s*gitdir:\s*(.+?)\s*$/m.exec(pointer);
|
|
228
|
+
if (m === null)
|
|
229
|
+
return [];
|
|
230
|
+
gitDir = path.resolve(cwd, m[1]);
|
|
231
|
+
// A linked worktree's own git dir has no `config`; `commondir` points at the
|
|
232
|
+
// main repository's git dir, which does.
|
|
233
|
+
const common = await fs.readFile(path.join(gitDir, "commondir"), "utf8").catch(() => null);
|
|
234
|
+
if (common !== null)
|
|
235
|
+
gitDir = path.resolve(gitDir, common.trim());
|
|
236
|
+
}
|
|
237
|
+
const text = await fs.readFile(path.join(gitDir, "config"), "utf8").catch(() => null);
|
|
238
|
+
if (text === null)
|
|
239
|
+
return [];
|
|
240
|
+
const out = [];
|
|
241
|
+
let inRemote = false;
|
|
242
|
+
for (const raw of text.split("\n")) {
|
|
243
|
+
const line = raw.trim();
|
|
244
|
+
if (line.startsWith("[")) {
|
|
245
|
+
inRemote = /^\[remote\b/i.test(line);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!inRemote)
|
|
249
|
+
continue;
|
|
250
|
+
const m = /^url\s*=\s*(.+)$/i.exec(line);
|
|
251
|
+
if (m === null)
|
|
252
|
+
continue;
|
|
253
|
+
const url = normalizeRemote(m[1]);
|
|
254
|
+
if (url !== "")
|
|
255
|
+
out.push(url);
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
/** A path's cache identity: its mtime + size, or `"-"` when absent. Used for
|
|
260
|
+
* directories (has the file SET changed) and for single files alike. */
|
|
106
261
|
async function dirKey(dir) {
|
|
107
262
|
const st = await fs.stat(dir).catch(() => null);
|
|
108
263
|
return st ? `${st.mtimeMs}:${st.size}` : "-";
|
|
@@ -136,13 +291,32 @@ async function dirKey(dir) {
|
|
|
136
291
|
export class AdoptableIndex {
|
|
137
292
|
claudeHomePath;
|
|
138
293
|
stateDir;
|
|
294
|
+
legacyClaudeHomePath;
|
|
139
295
|
/** folder name → last-seen entry, reused while its `key` is unchanged. */
|
|
140
296
|
folders = new Map();
|
|
297
|
+
/**
|
|
298
|
+
* checkout dir → its normalised remotes, memoised on the git config's own
|
|
299
|
+
* mtime+size (#659). Keyed on the config rather than the transcript folder
|
|
300
|
+
* because that is the file whose CHANGE would change the answer: adding or
|
|
301
|
+
* re-pointing a remote must be picked up, and it does not touch `~/.claude`.
|
|
302
|
+
*/
|
|
303
|
+
remotes = new Map();
|
|
141
304
|
/** project key → cached summary + the composite key it was computed under. */
|
|
142
305
|
summaries = new Map();
|
|
143
|
-
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) {
|
|
144
317
|
this.claudeHomePath = claudeHomePath;
|
|
145
318
|
this.stateDir = stateDir;
|
|
319
|
+
this.legacyClaudeHomePath = legacyClaudeHomePath;
|
|
146
320
|
}
|
|
147
321
|
/**
|
|
148
322
|
* Drop a project's cached summary (or every project's, with no argument).
|
|
@@ -164,6 +338,12 @@ export class AdoptableIndex {
|
|
|
164
338
|
* folders whose file set changed since last time.
|
|
165
339
|
*/
|
|
166
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]));
|
|
167
347
|
const root = path.join(this.claudeHomePath, "projects");
|
|
168
348
|
const names = await fs.readdir(root).catch(() => []);
|
|
169
349
|
const out = [];
|
|
@@ -178,11 +358,16 @@ export class AdoptableIndex {
|
|
|
178
358
|
out.push(cached);
|
|
179
359
|
continue;
|
|
180
360
|
}
|
|
361
|
+
const cwd = await recordedCwd(dir);
|
|
181
362
|
const entry = {
|
|
182
363
|
name,
|
|
183
364
|
realPath: await fs.realpath(dir).catch(() => dir),
|
|
184
365
|
key,
|
|
185
|
-
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,
|
|
186
371
|
};
|
|
187
372
|
this.folders.set(name, entry);
|
|
188
373
|
out.push(entry);
|
|
@@ -194,6 +379,29 @@ export class AdoptableIndex {
|
|
|
194
379
|
this.folders.delete(name);
|
|
195
380
|
return out;
|
|
196
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* A checkout's normalised git remotes, memoised on its config's mtime+size.
|
|
384
|
+
*
|
|
385
|
+
* The memo is keyed by directory and re-validated on every call, so a remote
|
|
386
|
+
* added or re-pointed after the last scan is picked up without waiting for
|
|
387
|
+
* anything in `~/.claude` to change.
|
|
388
|
+
*/
|
|
389
|
+
async remotesFor(cwd) {
|
|
390
|
+
// Cheap enough to re-key every time: two `stat`s. Both halves are needed —
|
|
391
|
+
// `.git/config` is the file that answers the question for an ordinary
|
|
392
|
+
// checkout, and `.git` itself covers a linked worktree, where `.git` is a
|
|
393
|
+
// FILE and `.git/config` does not exist at all.
|
|
394
|
+
const key = [
|
|
395
|
+
await dirKey(path.join(cwd, ".git")),
|
|
396
|
+
await dirKey(path.join(cwd, ".git", "config")),
|
|
397
|
+
].join("|");
|
|
398
|
+
const cached = this.remotes.get(cwd);
|
|
399
|
+
if (cached && cached.key === key)
|
|
400
|
+
return cached.urls;
|
|
401
|
+
const urls = await gitRemotes(cwd);
|
|
402
|
+
this.remotes.set(cwd, { key, urls });
|
|
403
|
+
return urls;
|
|
404
|
+
}
|
|
197
405
|
/**
|
|
198
406
|
* The candidate source working directories for a project, de-duplicated by the
|
|
199
407
|
* transcript folder they resolve to.
|
|
@@ -205,32 +413,48 @@ export class AdoptableIndex {
|
|
|
205
413
|
const own = project.workingDir;
|
|
206
414
|
const ownFolder = path.join(this.claudeHomePath, "projects", encodePathForCli(own));
|
|
207
415
|
const ownReal = await fs.realpath(ownFolder).catch(() => ownFolder);
|
|
208
|
-
const
|
|
416
|
+
const repo = project.repo !== undefined && project.repo !== "" ? project.repo : null;
|
|
417
|
+
const wantRemote = repo === null ? "" : normalizeRemote(repo);
|
|
418
|
+
/** The CHEAP test: worth reading a git config for? */
|
|
419
|
+
const nameMatches = (cwd) => {
|
|
209
420
|
if (cwd === own)
|
|
210
421
|
return true;
|
|
211
|
-
// Repo-backed: "the user's own clone of the same repo"
|
|
212
|
-
//
|
|
213
|
-
if (
|
|
214
|
-
return path.basename(cwd) === repoCheckoutName(
|
|
215
|
-
}
|
|
422
|
+
// Repo-backed: "the user's own clone of the same repo". A necessary but
|
|
423
|
+
// NOT sufficient condition — `remoteMatches` below is the other half.
|
|
424
|
+
if (repo !== null)
|
|
425
|
+
return path.basename(cwd) === repoCheckoutName(repo);
|
|
216
426
|
// Notebook: exact cwd equality only.
|
|
217
427
|
return false;
|
|
218
428
|
};
|
|
219
429
|
const seen = new Set([ownReal]);
|
|
220
430
|
const sources = [];
|
|
221
431
|
for (const folder of folders) {
|
|
222
|
-
if (folder.cwd === null || !
|
|
432
|
+
if (folder.cwd === null || folder.engineCwd === null || !nameMatches(folder.cwd))
|
|
223
433
|
continue;
|
|
224
434
|
if (seen.has(folder.realPath))
|
|
225
435
|
continue;
|
|
436
|
+
// A same-named directory has to prove it is a clone of THIS repo (#659).
|
|
437
|
+
// Only reached for repo-backed projects, and only after the basename test,
|
|
438
|
+
// so this is a handful of small file reads rather than a filesystem sweep.
|
|
439
|
+
if (repo !== null && folder.cwd !== own) {
|
|
440
|
+
const urls = await this.remotesFor(folder.cwd);
|
|
441
|
+
if (!urls.includes(wantRemote))
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
226
444
|
seen.add(folder.realPath);
|
|
227
|
-
|
|
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 });
|
|
228
449
|
}
|
|
229
450
|
// The project's own working directory goes LAST so that a session offered by
|
|
230
451
|
// both it and an external checkout is attributed to the external one — the
|
|
231
452
|
// origin, where the transcript actually lives, so a copy has something to
|
|
232
|
-
// copy (#588 gotcha 2).
|
|
233
|
-
|
|
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 });
|
|
234
458
|
return sources;
|
|
235
459
|
}
|
|
236
460
|
/**
|
|
@@ -248,8 +472,9 @@ export class AdoptableIndex {
|
|
|
248
472
|
// so an empty cwd is a value that genuinely occurs here. Written as the
|
|
249
473
|
// escape, never a raw NUL byte: a raw NUL makes ripgrep classify the whole
|
|
250
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));
|
|
251
476
|
const matchedKeys = folders
|
|
252
|
-
.filter((f) =>
|
|
477
|
+
.filter((f) => matchedEngineCwds.has(f.engineCwd ?? "\u0000"))
|
|
253
478
|
.map((f) => `${f.name}=${f.key}`)
|
|
254
479
|
.sort();
|
|
255
480
|
const key = [
|
|
@@ -265,11 +490,16 @@ export class AdoptableIndex {
|
|
|
265
490
|
const out = [];
|
|
266
491
|
const filtered = [];
|
|
267
492
|
let count = 0;
|
|
268
|
-
for (const
|
|
493
|
+
for (const source of sources) {
|
|
494
|
+
const sourceCwd = source.display;
|
|
269
495
|
// Fault-isolated per source: an unreadable folder costs its own sessions,
|
|
270
496
|
// not the whole count.
|
|
271
|
-
|
|
272
|
-
|
|
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(() => []);
|
|
502
|
+
const candidates = [];
|
|
273
503
|
for (const session of sessions) {
|
|
274
504
|
// De-dup by session id. Two candidate cwds can collide onto one folder
|
|
275
505
|
// under the lossy encoding, and a transcript may have been copied; either
|
|
@@ -282,11 +512,27 @@ export class AdoptableIndex {
|
|
|
282
512
|
filtered.push({ sessionId: session.sessionId, sourceCwd, reason });
|
|
283
513
|
continue;
|
|
284
514
|
}
|
|
285
|
-
|
|
515
|
+
candidates.push({
|
|
516
|
+
sessionId: session.sessionId,
|
|
517
|
+
mtime: session.mtime,
|
|
518
|
+
preview: session.preview,
|
|
519
|
+
autoName: session.autoName,
|
|
520
|
+
sizeBytes: session.sizeBytes,
|
|
521
|
+
});
|
|
286
522
|
}
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
523
|
+
if (candidates.length > 0) {
|
|
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`.
|
|
529
|
+
out.push({
|
|
530
|
+
sourceCwd,
|
|
531
|
+
...(source.engine === sourceCwd ? {} : { importFrom: source.engine }),
|
|
532
|
+
sessionIds: candidates.map((c) => c.sessionId),
|
|
533
|
+
sessions: candidates,
|
|
534
|
+
});
|
|
535
|
+
count += candidates.length;
|
|
290
536
|
}
|
|
291
537
|
}
|
|
292
538
|
const summary = { count, sources: out, filtered };
|
|
@@ -19,6 +19,7 @@ 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 } from "./claude-home.js";
|
|
22
23
|
import { ProjectStore, ROOT_KEY } from "./projects.js";
|
|
23
24
|
import { AttachmentStore } from "./attachments.js";
|
|
24
25
|
import { HerdctlService } from "./herdctl.js";
|
|
@@ -150,6 +151,20 @@ export async function buildApp(opts = {}) {
|
|
|
150
151
|
// root workspace itself is never in it. It always exists — the instance's own
|
|
151
152
|
// directory — so resolve it explicitly and register its keeper alongside every
|
|
152
153
|
// child's. The root keeper is an ordinary keeper in every respect.
|
|
154
|
+
// Paddock's own Claude home (#620) — created and bridged BEFORE the fleet
|
|
155
|
+
// starts, because `herdctl.init` immediately plants every project's transcript
|
|
156
|
+
// symlink inside it and hands the same path to the FleetManager.
|
|
157
|
+
for (const notice of (await ensureClaudeHome(cfg)).notices) {
|
|
158
|
+
app.log[notice.level](notice.message);
|
|
159
|
+
}
|
|
160
|
+
if (cfg.ownsClaudeHome) {
|
|
161
|
+
const stale = await countLegacyTranscriptLinks(cfg.legacyClaudeHome, cfg.dataDir);
|
|
162
|
+
if (stale > 0) {
|
|
163
|
+
app.log.info(`${stale} transcript symlink(s) from a previous layout still point into ${cfg.dataDir} ` +
|
|
164
|
+
`from ${path.join(cfg.legacyClaudeHome, "projects")}. Nothing reads them any more; ` +
|
|
165
|
+
`paddock leaves them alone because it does not write to ~/.claude. Safe to delete.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
153
168
|
const rootWorkspace = await projects.get(ROOT_KEY);
|
|
154
169
|
const initialProjects = [...(await projects.list()), rootWorkspace];
|
|
155
170
|
try {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { PaddockConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Entries of a Claude home that are USER-LEVEL configuration rather than
|
|
4
|
+
* per-instance state, symlinked from the user's home into paddock's own when
|
|
5
|
+
* paddock's does not already have its own.
|
|
6
|
+
*
|
|
7
|
+
* Without this, relocating the home silently changes agent behaviour: the user's
|
|
8
|
+
* `~/.claude/CLAUDE.md` is auto-loaded into every session, `settings.json`
|
|
9
|
+
* carries permissions and hooks, and `agents/` `commands/` `plugins/` are
|
|
10
|
+
* capability. Dropping them is not "a clean home", it is a behaviour regression
|
|
11
|
+
* nobody asked for.
|
|
12
|
+
*
|
|
13
|
+
* `.credentials.json` is the one that matters most: on Linux (and in the Docker
|
|
14
|
+
* image) it is where `claude login` puts the OAuth token, so without the bridge
|
|
15
|
+
* every file-authenticated install would come back logged out. Symlinked rather
|
|
16
|
+
* than copied — a copy would duplicate a live secret onto disk AND diverge the
|
|
17
|
+
* moment Claude Code refreshed the token.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately NOT bridged: `projects/` (that's the whole point), and everything
|
|
20
|
+
* that is per-instance runtime state — `todos/`, `shell-snapshots/`, `statsig/`,
|
|
21
|
+
* `sessions/`. Those are exactly what should now be per-instance.
|
|
22
|
+
*/
|
|
23
|
+
export declare const BRIDGED_ENTRIES: readonly [".credentials.json", "settings.json", "CLAUDE.md", "agents", "commands", "plugins"];
|
|
24
|
+
/** A message for the operator, at the level it should be logged at. */
|
|
25
|
+
export interface ClaudeHomeNotice {
|
|
26
|
+
level: "info" | "warn";
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ClaudeHomeReport {
|
|
30
|
+
/** Entries symlinked in from the user's home this boot. */
|
|
31
|
+
bridged: string[];
|
|
32
|
+
/** Messages for `app.ts` to log — config resolution has no logger of its own. */
|
|
33
|
+
notices: ClaudeHomeNotice[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create paddock's Claude home and bridge the user-level config into it.
|
|
37
|
+
*
|
|
38
|
+
* Idempotent and non-clobbering: an entry paddock's home already has (whether a
|
|
39
|
+
* real file the user put there or a symlink from a previous boot) is left
|
|
40
|
+
* completely alone, so this is safe to run on every start.
|
|
41
|
+
*
|
|
42
|
+
* Never throws. A home that cannot be created is not a reason to refuse to boot
|
|
43
|
+
* — Claude Code falls back to its own default and chat still works, which is the
|
|
44
|
+
* same failure mode `ensureProjectChats` has always had.
|
|
45
|
+
*/
|
|
46
|
+
export declare function ensureClaudeHome(cfg: Pick<PaddockConfig, "claudeHome" | "legacyClaudeHome" | "ownsClaudeHome">, env?: NodeJS.ProcessEnv): Promise<ClaudeHomeReport>;
|
|
47
|
+
/** One of the user's transcript folders, made readable through paddock's home. */
|
|
48
|
+
export interface LegacyMirror {
|
|
49
|
+
/** Folder name inside `<claudeHome>/projects/` — `encodePathForCli(engineCwd)`. */
|
|
50
|
+
name: string;
|
|
51
|
+
/**
|
|
52
|
+
* The working directory the ENGINE must be handed to read this folder.
|
|
53
|
+
*
|
|
54
|
+
* Not the real cwd the transcripts were recorded in — see
|
|
55
|
+
* {@link mirrorLegacyTranscriptFolders} for why it cannot be.
|
|
56
|
+
*/
|
|
57
|
+
engineCwd: string;
|
|
58
|
+
/** The user's real folder in `<legacyHome>/projects/`, which we only read. */
|
|
59
|
+
legacyDir: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The synthetic working directory a legacy transcript folder is offered to the
|
|
63
|
+
* engine under: the folder's own path inside the user's home.
|
|
64
|
+
*
|
|
65
|
+
* Any stable, per-folder, collision-free path would do. This one is the obvious
|
|
66
|
+
* choice because it already exists, is unique by construction, and is honest
|
|
67
|
+
* about where the transcripts actually are.
|
|
68
|
+
*/
|
|
69
|
+
export declare function legacySourcePath(legacyHome: string, folderName: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* Make the user's own transcript folders visible through paddock's Claude home,
|
|
72
|
+
* so adoption (#588) still finds them.
|
|
73
|
+
*
|
|
74
|
+
* Adoption's whole premise is "you already have terminal `claude` history for
|
|
75
|
+
* this directory" — and that history is, by definition, in `~/.claude`. But the
|
|
76
|
+
* engine resolves every adoption path against the ONE home the FleetManager was
|
|
77
|
+
* built with, with no per-call override, so once paddock owns its home the
|
|
78
|
+
* user's folders are out of reach. Without this, #620 would silently gut #588.
|
|
79
|
+
*
|
|
80
|
+
* The fix is a read-only view: symlink each of the user's folders into
|
|
81
|
+
* `<claudeHome>/projects/`. The engine then reads their transcripts through it,
|
|
82
|
+
* and adoption's default `copy` mode writes the copy into the project's own
|
|
83
|
+
* folder (itself the symlink to `.chats/`) — originals read, never touched.
|
|
84
|
+
*
|
|
85
|
+
* ## Why the mirror is not named after the recorded cwd
|
|
86
|
+
*
|
|
87
|
+
* The obvious mirror name — the same encoded name the user's folder has — is
|
|
88
|
+
* wrong, and wrong in exactly the case adoption exists for. A folder's name is
|
|
89
|
+
* `encodePathForCli(cwd)`, so the user's history for `/code/thing` and a paddock
|
|
90
|
+
* project whose working directory IS `/code/thing` want the SAME name in the
|
|
91
|
+
* same directory. Paddock's `.chats/` symlink is already there and must stay, so
|
|
92
|
+
* the user's folder loses — and "I pointed a project at a directory I already
|
|
93
|
+
* have `claude` history for", the headline case, silently offers nothing.
|
|
94
|
+
*
|
|
95
|
+
* (Pre-#620 this case worked only because `ensureProjectChats` had already MOVED
|
|
96
|
+
* those transcripts into `.chats/` and deleted the originals. That destruction is
|
|
97
|
+
* the thing #620 removes, so the case now needs a real mechanism.)
|
|
98
|
+
*
|
|
99
|
+
* So each mirror is named for a SYNTHETIC working directory
|
|
100
|
+
* ({@link legacySourcePath}) that no agent will ever have, which makes
|
|
101
|
+
* collisions structurally impossible rather than merely unlikely. The caller
|
|
102
|
+
* keeps the folder's real recorded cwd for display and hands `engineCwd` to the
|
|
103
|
+
* engine — see `AdoptableSource.importFrom`.
|
|
104
|
+
*
|
|
105
|
+
* Entries in the legacy home that are themselves SYMLINKS are skipped: those are
|
|
106
|
+
* ones paddock planted under the old layout, pointing back at some `.chats/`, and
|
|
107
|
+
* mirroring them would re-offer paddock's own chats as "adoptable". That also
|
|
108
|
+
* makes the leftovers from before this change inert rather than confusing.
|
|
109
|
+
*
|
|
110
|
+
* Idempotent and never throws. Returns every mirror it maintains, not only the
|
|
111
|
+
* ones planted this call, so a caller can rely on the mapping after any call.
|
|
112
|
+
*/
|
|
113
|
+
export declare function mirrorLegacyTranscriptFolders(claudeHome: string, legacyHome: string): Promise<LegacyMirror[]>;
|
|
114
|
+
/**
|
|
115
|
+
* Count the transcript symlinks a paddock instance planted in the user's home
|
|
116
|
+
* under the pre-#620 layout that point back into THIS data dir.
|
|
117
|
+
*
|
|
118
|
+
* Purely informational. They are harmless — they still resolve, and nothing
|
|
119
|
+
* reads them any more — but they accumulate one per project per instance and go
|
|
120
|
+
* stale as soon as a data dir is deleted, so an operator is better off being
|
|
121
|
+
* told they exist than discovering a pile of dangling links months later.
|
|
122
|
+
* Paddock does not remove them itself: they live in `~/.claude`, and not writing
|
|
123
|
+
* there is the entire point.
|
|
124
|
+
*/
|
|
125
|
+
export declare function countLegacyTranscriptLinks(legacyHome: string, dataDir: string): Promise<number>;
|