@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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -1
  3. package/package.json +4 -3
  4. package/packages/server/dist/adoptable.d.ts +108 -5
  5. package/packages/server/dist/adoptable.js +279 -33
  6. package/packages/server/dist/app.js +15 -0
  7. package/packages/server/dist/claude-home.d.ts +125 -0
  8. package/packages/server/dist/claude-home.js +276 -0
  9. package/packages/server/dist/cli/import-chats.js +9 -6
  10. package/packages/server/dist/cli/paddock.js +9 -31
  11. package/packages/server/dist/config.d.ts +75 -12
  12. package/packages/server/dist/config.js +58 -11
  13. package/packages/server/dist/herdctl-agent-config.d.ts +15 -7
  14. package/packages/server/dist/herdctl-agent-config.js +21 -9
  15. package/packages/server/dist/herdctl.d.ts +95 -0
  16. package/packages/server/dist/herdctl.js +209 -6
  17. package/packages/server/dist/hook-config.d.ts +17 -15
  18. package/packages/server/dist/hook-config.js +11 -9
  19. package/packages/server/dist/routes/chats.js +92 -9
  20. package/packages/server/dist/run-provenance.d.ts +11 -0
  21. package/packages/server/dist/run-provenance.js +18 -0
  22. package/packages/server/dist/transcripts.d.ts +25 -6
  23. package/packages/server/dist/transcripts.js +12 -24
  24. package/packages/server/dist/trigger-config.d.ts +18 -8
  25. package/packages/server/dist/trigger-config.js +18 -8
  26. package/packages/server/dist/ws-turn.d.ts +0 -13
  27. package/packages/server/dist/ws-turn.js +50 -0
  28. package/packages/server/dist/ws.js +43 -11
  29. package/packages/server/package.json +1 -1
  30. package/packages/web/dist/assets/{InstanceConfigPage-CAwSnyga.js → InstanceConfigPage-CzMqTdUU.js} +1 -1
  31. package/packages/web/dist/assets/{MarkdownRenderer-DpGpkMzG.js → MarkdownRenderer-YSEZHWDe.js} +1 -1
  32. package/packages/web/dist/assets/{ProjectRedirect-D1ipv4jr.js → ProjectRedirect-DLWxZbqK.js} +1 -1
  33. package/packages/web/dist/assets/ProjectView-BFX8ff2n.js +13 -0
  34. package/packages/web/dist/assets/{ProjectsGrid-Dwngb-m1.js → ProjectsGrid-DLOeY_ww.js} +1 -1
  35. package/packages/web/dist/assets/{arc-BZh6Z9_D.js → arc-BwC1ik-b.js} +1 -1
  36. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-iCtJiW4k.js → architectureDiagram-3BPJPVTR-CsVvXQWw.js} +1 -1
  37. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BeI9Wi0h.js → blockDiagram-GPEHLZMM-DXQk2bIa.js} +1 -1
  38. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D_j2LKZ-.js → c4Diagram-AAUBKEIU-DXiA26It.js} +1 -1
  39. package/packages/web/dist/assets/channel-DDgTXrGP.js +2 -0
  40. package/packages/web/dist/assets/{chunk-2J33WTMH-DRlfPROq.js → chunk-2J33WTMH-Dt5nC02A.js} +1 -1
  41. package/packages/web/dist/assets/{chunk-4BX2VUAB-BMY66MLs.js → chunk-4BX2VUAB-DcjnWvl6.js} +1 -1
  42. package/packages/web/dist/assets/{chunk-55IACEB6-BAQ92trf.js → chunk-55IACEB6-CRa3LkU4.js} +1 -1
  43. package/packages/web/dist/assets/{chunk-727SXJPM-Dxgb_nNN.js → chunk-727SXJPM-H3V-11qu.js} +1 -1
  44. package/packages/web/dist/assets/{chunk-AQP2D5EJ-D5cqv3Jv.js → chunk-AQP2D5EJ-DPCrENYV.js} +1 -1
  45. package/packages/web/dist/assets/{chunk-FMBD7UC4-BOEqxzyA.js → chunk-FMBD7UC4-C4UO0ZTc.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-ND2GUHAM-CkTZgv_W.js → chunk-ND2GUHAM-BtI37CnI.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-QZHKN3VN-DIr8TL7M.js → chunk-QZHKN3VN-C5bTaK5j.js} +1 -1
  48. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU1kd_Vu.js +2 -0
  49. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU1kd_Vu.js +2 -0
  50. package/packages/web/dist/assets/{core-DyOZ2e1O.js → core-DB3cpm5N.js} +1 -1
  51. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-D8Muyf4o.js → cose-bilkent-S5V4N54A-q-cjTZO4.js} +1 -1
  52. package/packages/web/dist/assets/{dagre-BM42HDAG-B_EUbUz6.js → dagre-BM42HDAG-DYEV5HFo.js} +1 -1
  53. package/packages/web/dist/assets/{diagram-2AECGRRQ-DpAM0K6Q.js → diagram-2AECGRRQ-D16XCw78.js} +1 -1
  54. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dhs9RMKk.js → diagram-5GNKFQAL-CeztryYE.js} +1 -1
  55. package/packages/web/dist/assets/{diagram-KO2AKTUF-LBeTHReu.js → diagram-KO2AKTUF-D5SLIMDS.js} +1 -1
  56. package/packages/web/dist/assets/{diagram-LMA3HP47-CxboRwue.js → diagram-LMA3HP47-Bw-sVPxM.js} +1 -1
  57. package/packages/web/dist/assets/{diagram-OG6HWLK6-BK_P-OSi.js → diagram-OG6HWLK6-DsRMITgx.js} +1 -1
  58. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-CPSKRHI5.js → erDiagram-TEJ5UH35-T8Qqc_ku.js} +1 -1
  59. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CbUumAP5.js → flowDiagram-I6XJVG4X-Cyx47UWU.js} +1 -1
  60. package/packages/web/dist/assets/{format-DDL0Q7H2.js → format-D2I0duZa.js} +1 -1
  61. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-l-p25ckA.js → ganttDiagram-6RSMTGT7-DxYJmG9o.js} +1 -1
  62. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-i8kDIgga.js → gitGraphDiagram-PVQCEYII-COK5YOTD.js} +1 -1
  63. package/packages/web/dist/assets/index-B6etJCHf.css +1 -0
  64. package/packages/web/dist/assets/index-wBXQMkGI.js +71 -0
  65. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CbyLnHIe.js → infoDiagram-5YYISTIA-BamRN_Nn.js} +1 -1
  66. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-C1_3z8-9.js → ishikawaDiagram-YF4QCWOH-DuOHdpEH.js} +1 -1
  67. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BzyAtQdy.js → journeyDiagram-JHISSGLW-Bri7GKTW.js} +1 -1
  68. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Fp2VVm-b.js → kanban-definition-UN3LZRKU-BdkgUnzI.js} +1 -1
  69. package/packages/web/dist/assets/{linear-6ZKEeWQ5.js → linear-DOcNPCpB.js} +1 -1
  70. package/packages/web/dist/assets/{mermaid.core-Dorg4Dl2.js → mermaid.core-D9SbVBpN.js} +4 -4
  71. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bk4Ax0bI.js → mindmap-definition-RKZ34NQL-CVmLm7zn.js} +1 -1
  72. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DoTn5jHm.js → pieDiagram-4H26LBE5-D0N10eVU.js} +1 -1
  73. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ClNmIKCj.js → quadrantDiagram-W4KKPZXB-B5gnYdTa.js} +1 -1
  74. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DMF7i5KW.js → requirementDiagram-4Y6WPE33-CwtxezNZ.js} +1 -1
  75. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-C-ALbVLg.js → sankeyDiagram-5OEKKPKP-D79O3cux.js} +1 -1
  76. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DsUxj8IQ.js → sequenceDiagram-3UESZ5HK-xhKcI6tI.js} +1 -1
  77. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-OCZiRRsz.js → stateDiagram-AJRCARHV-DBYMAjcX.js} +1 -1
  78. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CZtr9Jvq.js +2 -0
  79. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-5ifjxa-s.js → timeline-definition-PNZ67QCA-e-MYF4lm.js} +1 -1
  80. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Bqvqpot5.js → vennDiagram-CIIHVFJN-B8UsvRDM.js} +1 -1
  81. package/packages/web/dist/assets/{wardley-L42UT6IY-DwgnVd48.js → wardley-L42UT6IY-1ucACjTl.js} +1 -1
  82. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-F-VCH-oE.js → wardleyDiagram-YWT4CUSO-DJufLd_u.js} +1 -1
  83. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-L4q6oV_7.js → xychartDiagram-2RQKCTM6-qqaZNlu9.js} +1 -1
  84. package/packages/web/dist/index.html +2 -2
  85. package/packages/web/dist/sw.js +1 -1
  86. package/packages/web/dist/assets/ProjectView-BI1TtrOK.js +0 -13
  87. package/packages/web/dist/assets/channel-DszBVZgr.js +0 -2
  88. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuGqlnbT.js +0 -2
  89. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuGqlnbT.js +0 -2
  90. package/packages/web/dist/assets/index-CvGiQddO.css +0 -1
  91. package/packages/web/dist/assets/index-DKDgTDXa.js +0 -71
  92. 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 or a session that is
19
- * nothing but a `/mcp` slash command is not a chat anyone wants imported.
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 BASENAME
39
- * equals the project's checkout name (`repoCheckoutName(repo)`) — i.e. "the user
40
- * has their own clone of this repo elsewhere". This is deliberately simple and
41
- * is NOT identity-precise: two unrelated checkouts that happen to share a
42
- * basename (`~/work/api` and `~/oss/api`) both match, and a clone the user
43
- * renamed on disk does not match at all. Verifying the checkout's git remote
44
- * would be exact; it also means running git in every candidate directory, which
45
- * is the wrong cost for a count shown in a header. Noted as a known limit
46
- * (#588 gotcha 6) rather than pretended away — the import is per-source, so a
47
- * user who sees a source they don't recognise can decline it.
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
- /** A directory's cache identity: its mtime + size, or `null` when absent. */
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: await recordedCwd(dir),
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 matches = (cwd) => {
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", matched by the
212
- // checkout basename. Deliberately loose — see the module header.
213
- if (project.repo !== undefined && project.repo !== "") {
214
- return path.basename(cwd) === repoCheckoutName(project.repo);
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 || !matches(folder.cwd))
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
- sources.push(folder.cwd);
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
- sources.push(own);
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) => sources.includes(f.cwd ?? "\u0000"))
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 sourceCwd of sources) {
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
- const sessions = await fleet.listAdoptableSessions(agentName, sourceCwd).catch(() => []);
272
- const ids = [];
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
- ids.push(session.sessionId);
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 (ids.length > 0) {
288
- out.push({ sourceCwd, sessionIds: ids });
289
- count += ids.length;
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>;