@edspencer/paddock 0.67.0 → 0.68.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.
Files changed (102) hide show
  1. package/README.md +24 -30
  2. package/package.json +1 -1
  3. package/packages/server/dist/adoptable.d.ts +57 -0
  4. package/packages/server/dist/adoptable.js +26 -3
  5. package/packages/server/dist/agent-errors.js +1 -1
  6. package/packages/server/dist/claude-home.d.ts +4 -4
  7. package/packages/server/dist/claude-home.js +4 -4
  8. package/packages/server/dist/claude-mcp.d.ts +4 -2
  9. package/packages/server/dist/cli/args.d.ts +41 -2
  10. package/packages/server/dist/cli/args.js +119 -17
  11. package/packages/server/dist/cli/paddock.js +87 -96
  12. package/packages/server/dist/cli/service/backend.d.ts +38 -0
  13. package/packages/server/dist/cli/service/backend.js +19 -0
  14. package/packages/server/dist/cli/service/index.d.ts +28 -0
  15. package/packages/server/dist/cli/service/index.js +179 -0
  16. package/packages/server/dist/cli/service/launchd.d.ts +47 -0
  17. package/packages/server/dist/cli/service/launchd.js +206 -0
  18. package/packages/server/dist/cli/service/spec.d.ts +106 -0
  19. package/packages/server/dist/cli/service/spec.js +124 -0
  20. package/packages/server/dist/cli/service/systemd.d.ts +48 -0
  21. package/packages/server/dist/cli/service/systemd.js +155 -0
  22. package/packages/server/dist/discover.d.ts +170 -0
  23. package/packages/server/dist/discover.js +365 -0
  24. package/packages/server/dist/herdctl.d.ts +25 -2
  25. package/packages/server/dist/herdctl.js +30 -1
  26. package/packages/server/dist/routes/discover.d.ts +3 -0
  27. package/packages/server/dist/routes/discover.js +140 -0
  28. package/packages/server/dist/routes.d.ts +1 -0
  29. package/packages/server/dist/routes.js +2 -0
  30. package/packages/server/package.json +1 -1
  31. package/packages/web/dist/assets/Chip-NMeHUE94.js +2 -0
  32. package/packages/web/dist/assets/DiscoverPage-LY0CRBVz.js +2 -0
  33. package/packages/web/dist/assets/DiscoverView-Bt4FUTjB.js +2 -0
  34. package/packages/web/dist/assets/InstanceConfigPage-DIQJtIYC.js +2 -0
  35. package/packages/web/dist/assets/{MarkdownRenderer-MaSe5aAI.js → MarkdownRenderer-BVMZB_mP.js} +11 -11
  36. package/packages/web/dist/assets/{ProjectMenu-DmWpr9od.js → ProjectMenu-D9Nsqjxy.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectRedirect-BJeDERUd.js → ProjectRedirect-B8rJ9qqe.js} +1 -1
  38. package/packages/web/dist/assets/{ProjectView-Bbecs1u7.js → ProjectView-D-21ULHU.js} +10 -10
  39. package/packages/web/dist/assets/{ProjectsGrid-BE4cAtu5.js → ProjectsGrid-CDqvd4bj.js} +1 -1
  40. package/packages/web/dist/assets/RootHome-CmiJG9wb.js +2 -0
  41. package/packages/web/dist/assets/{Surfaces-DtSsbnLh.js → Surfaces-c410wTbw.js} +1 -1
  42. package/packages/web/dist/assets/{arc-NLb6J_GZ.js → arc-DN2jEPE9.js} +1 -1
  43. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BbiSIctn.js → architectureDiagram-3BPJPVTR-BLMFd81z.js} +1 -1
  44. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Br1sRzRE.js → blockDiagram-GPEHLZMM-BweEECqE.js} +1 -1
  45. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DUvJurko.js → c4Diagram-AAUBKEIU-DpN_9YFO.js} +1 -1
  46. package/packages/web/dist/assets/channel-B_SiD-tA.js +2 -0
  47. package/packages/web/dist/assets/{chunk-2J33WTMH-Cm_E_LAD.js → chunk-2J33WTMH-BxWEBoli.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-4BX2VUAB-CK6R2MCN.js → chunk-4BX2VUAB-C5nkcPRR.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-55IACEB6-D7HXvxGy.js → chunk-55IACEB6-B8P3CNX8.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-727SXJPM-BLwevzlY.js → chunk-727SXJPM-D3yuzl-m.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-AQP2D5EJ-CpLWD5so.js → chunk-AQP2D5EJ-CzWxcEIw.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-FMBD7UC4-BNlt1t19.js → chunk-FMBD7UC4-CCB91otB.js} +1 -1
  53. package/packages/web/dist/assets/{chunk-ND2GUHAM-CIt5gAq8.js → chunk-ND2GUHAM-BymMfuY4.js} +1 -1
  54. package/packages/web/dist/assets/{chunk-QZHKN3VN-B1uwIQ9c.js → chunk-QZHKN3VN-CezHuNcf.js} +1 -1
  55. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Detq0lSJ.js +2 -0
  56. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Detq0lSJ.js +2 -0
  57. package/packages/web/dist/assets/{core-HXUN76Po.js → core-LjceHm3Z.js} +1 -1
  58. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-DJ4_5jxB.js → cose-bilkent-S5V4N54A-BWhmWR_V.js} +1 -1
  59. package/packages/web/dist/assets/{dagre-BM42HDAG-Bc9-haAu.js → dagre-BM42HDAG-BTXWnJYV.js} +1 -1
  60. package/packages/web/dist/assets/diagram-2AECGRRQ-BY2LZdf0.js +44 -0
  61. package/packages/web/dist/assets/diagram-5GNKFQAL-BZ78Imy8.js +11 -0
  62. package/packages/web/dist/assets/{diagram-KO2AKTUF-C5hcR1vA.js → diagram-KO2AKTUF-BV4Ax_0D.js} +3 -3
  63. package/packages/web/dist/assets/{diagram-LMA3HP47-BEFzrSHy.js → diagram-LMA3HP47-1kcTal-d.js} +1 -1
  64. package/packages/web/dist/assets/{diagram-OG6HWLK6-BY5lPsX7.js → diagram-OG6HWLK6-CX3RfHlO.js} +1 -1
  65. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-y_kB8ETg.js → erDiagram-TEJ5UH35-BBS46D95.js} +1 -1
  66. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Cuhha5cF.js → flowDiagram-I6XJVG4X-CpPHahLZ.js} +1 -1
  67. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DFJpciK5.js → ganttDiagram-6RSMTGT7-CxL_QCuL.js} +1 -1
  68. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-FoA0yuh-.js → gitGraphDiagram-PVQCEYII-BeTkPHaG.js} +2 -2
  69. package/packages/web/dist/assets/index-DYccdR9x.css +1 -0
  70. package/packages/web/dist/assets/index-DjhUG9sA.js +71 -0
  71. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CKYqD8-c.js → infoDiagram-5YYISTIA-CiMnl36I.js} +1 -1
  72. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CCIxHIBw.js → ishikawaDiagram-YF4QCWOH-CjASKQeF.js} +2 -2
  73. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-lGYoSyk0.js → journeyDiagram-JHISSGLW-BZzehSqh.js} +1 -1
  74. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-B3D7CIwy.js → kanban-definition-UN3LZRKU-Cn-l0UIy.js} +1 -1
  75. package/packages/web/dist/assets/{linear-BiplJBex.js → linear-DyELsH8s.js} +1 -1
  76. package/packages/web/dist/assets/{mermaid.core-BJ7aEinZ.js → mermaid.core-C1adXzqy.js} +5 -5
  77. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BiXcOtHo.js → mindmap-definition-RKZ34NQL-DP6iSaA_.js} +1 -1
  78. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-tnkj45YF.js → pieDiagram-4H26LBE5-DHpN8Fse.js} +3 -3
  79. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-LqReq3Gl.js → quadrantDiagram-W4KKPZXB-DAfc21Kd.js} +1 -1
  80. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C1uk9Jty.js → requirementDiagram-4Y6WPE33-X3FfIiGh.js} +3 -3
  81. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-eQQ3OKrV.js → sankeyDiagram-5OEKKPKP-Ch319LBM.js} +4 -4
  82. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C6-a0lH4.js → sequenceDiagram-3UESZ5HK-CAyajDJc.js} +1 -1
  83. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CsTOkKqz.js → stateDiagram-AJRCARHV-C1G4309_.js} +1 -1
  84. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C0-BEM28.js +2 -0
  85. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DUBXH1x-.js → timeline-definition-PNZ67QCA-Bj5ihSj4.js} +1 -1
  86. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CcQrNnou.js → vennDiagram-CIIHVFJN-WIZuVHvV.js} +1 -1
  87. package/packages/web/dist/assets/{wardley-L42UT6IY-Br823uUV.js → wardley-L42UT6IY-CwRQf2fI.js} +1 -1
  88. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DRBECQc_.js → wardleyDiagram-YWT4CUSO-Bug_Zcav.js} +2 -2
  89. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6--HtSFP24.js → xychartDiagram-2RQKCTM6-Cw5xgfwo.js} +1 -1
  90. package/packages/web/dist/index.html +2 -2
  91. package/packages/web/dist/sw.js +1 -1
  92. package/packages/server/dist/cli/here.d.ts +0 -25
  93. package/packages/server/dist/cli/here.js +0 -104
  94. package/packages/web/dist/assets/InstanceConfigPage-sDbCoFxC.js +0 -2
  95. package/packages/web/dist/assets/channel-bezC0w5b.js +0 -2
  96. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DMgrTmQU.js +0 -2
  97. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DMgrTmQU.js +0 -2
  98. package/packages/web/dist/assets/diagram-2AECGRRQ-nLOtPDzu.js +0 -44
  99. package/packages/web/dist/assets/diagram-5GNKFQAL-DThV84R3.js +0 -11
  100. package/packages/web/dist/assets/index-BxLurJvz.css +0 -1
  101. package/packages/web/dist/assets/index-C_PXsGTD.js +0 -71
  102. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bsc-VlVI.js +0 -2
@@ -0,0 +1,170 @@
1
+ import type { AdoptableSession } from "@herdctl/core";
2
+ import { type AdoptableCandidate, type FilterReason, type TranscriptFolder } from "./adoptable.js";
3
+ /** Why a transcript folder did not become a candidate. */
4
+ export type DiscoverExclusion = "no-recorded-cwd" | "missing" | "system-path" | "temp-root" | "paddock-internal" | "home-root" | "outside-home" | "already-managed" | "no-git" | "no-sessions";
5
+ /**
6
+ * The rules a caller may relax. Both default OFF, and both are reported in
7
+ * {@link DiscoverResult.excluded} when they fire so a client can offer the
8
+ * toggle only when it would actually reveal something.
9
+ *
10
+ * Neither weakens containment: a relaxed candidate still has to be a directory
11
+ * with a transcript folder that cleared every hard rule above.
12
+ */
13
+ export interface DiscoverOptions {
14
+ /**
15
+ * Offer directories that are not git checkouts.
16
+ *
17
+ * The `.git` test is what took 166 candidates to ~4 on the dogfooding machine,
18
+ * and issue #745 left "require it, or merely rank it first?" open. Requiring
19
+ * it is the right DEFAULT — a notebook directory that is not a repo is the
20
+ * minority case, and the majority case is a page full of `~/Downloads` — but
21
+ * requiring it with no way back would make the notebook case unreachable,
22
+ * which is why this exists rather than the rule being absolute.
23
+ */
24
+ includeNonGit?: boolean;
25
+ /**
26
+ * Offer directories outside `$HOME`.
27
+ *
28
+ * Off by default because on a laptop everything the user means is under their
29
+ * home, and everything outside it is a system path, a scratch mount or another
30
+ * account's tree. Worth turning on in a container, where `$HOME` is
31
+ * `/root`-ish and the interesting directories are bind mounts.
32
+ */
33
+ includeOutsideHome?: boolean;
34
+ }
35
+ /** One directory Discovery proposes as a project. */
36
+ export interface DiscoverCandidate {
37
+ /**
38
+ * The directory, symlinks resolved.
39
+ *
40
+ * This is the value to POST as `path` to `POST /api/projects`: `validatePath`
41
+ * canonicalises before storing, so this is also exactly what the created
42
+ * project's `workingDir` will be — and therefore the `sourceCwd`
43
+ * `POST …/adopt-chats` will then accept.
44
+ */
45
+ path: string;
46
+ /**
47
+ * The cwd the transcripts actually record, when it is a DIFFERENT spelling of
48
+ * {@link path} (a symlinked home, a `/private/var` vs `/var` mount).
49
+ *
50
+ * Absent in the ordinary case. Present, it is a warning worth surfacing:
51
+ * `adoptable.ts` matches a notebook project's sources on exact cwd equality
52
+ * against the stored (resolved) working directory, so a divergent spelling is
53
+ * how an import comes back empty despite a healthy-looking count here.
54
+ */
55
+ recordedPath?: string;
56
+ /** Basename — the name a project created here should default to. */
57
+ name: string;
58
+ /**
59
+ * A slug free on this instance: {@link slugify}'d basename, qualified with the
60
+ * parent directory when that basename is already taken by an existing project
61
+ * or by an earlier (higher-ranked) candidate in this same result.
62
+ *
63
+ * Advisory. `POST /api/projects` derives its own slug from `name` when none is
64
+ * sent; this exists so a client does not have to reimplement `slugify` to
65
+ * avoid the collision issue #745 flagged (two candidates sharing a basename).
66
+ */
67
+ suggestedSlug: string;
68
+ hasGit: boolean;
69
+ /** First normalised git remote, when the checkout has one — disambiguates two
70
+ * same-named directories far better than their paths do. */
71
+ gitRemote?: string;
72
+ insideHome: boolean;
73
+ /** Sessions on offer here, after the shared noise filter. The ranking key. */
74
+ sessionCount: number;
75
+ /** Sessions the noise filter withheld — the difference between this and the
76
+ * raw file count, so "why 5 and not 12?" always has an answer. */
77
+ filteredCount: number;
78
+ /** ISO 8601 mtime of the newest offered session. */
79
+ lastSessionAt?: string;
80
+ }
81
+ /** What Discovery found, and what it threw away. */
82
+ export interface DiscoverResult {
83
+ /** The Claude home that was scanned — named so a container that legitimately
84
+ * finds nothing can point at the directory to bind-mount. */
85
+ claudeHome: string;
86
+ homeDir: string;
87
+ /** Transcript folders enumerated. `0` means "no history at all" (the container
88
+ * case); a large number with no candidates means "all of it was filtered". */
89
+ scanned: number;
90
+ candidates: DiscoverCandidate[];
91
+ /**
92
+ * Rule → how many DIRECTORIES it excluded (not folders: one directory often
93
+ * has two transcript folders). Only non-zero entries appear.
94
+ */
95
+ excluded: Partial<Record<DiscoverExclusion, number>>;
96
+ }
97
+ /** One directory's importable sessions, for lazy expansion of a row. */
98
+ export interface DiscoverSessions {
99
+ path: string;
100
+ sessions: AdoptableCandidate[];
101
+ /** Withheld candidates and why — same vocabulary as `adoptable-chats`. */
102
+ filtered: Array<{
103
+ sessionId: string;
104
+ reason: FilterReason;
105
+ }>;
106
+ }
107
+ /** Everything discovery needs that it must not go and find for itself. */
108
+ export interface DiscoverContext {
109
+ /** `<claudeHome>/projects/*`, recorded cwds already recovered. */
110
+ folders: TranscriptFolder[];
111
+ claudeHome: string;
112
+ homeDir: string;
113
+ /**
114
+ * Directories a project may never be linked inside: the projects root, the
115
+ * data dir, and both Claude homes. Mirrors `ProjectStore.validatePath`'s own
116
+ * refusals so a candidate can never be a row that fails on submit.
117
+ */
118
+ reservedRoots: string[];
119
+ /**
120
+ * Ephemeral roots whose contents are scratch — normally
121
+ * {@link defaultTempRoots}. Passed in rather than read here for the same
122
+ * reason `homeDir` is: what counts as ephemeral is a fact about the machine,
123
+ * and a caller (a test rig, a container image with an unusual `$TMPDIR`) has
124
+ * to be able to state it rather than have it assumed.
125
+ */
126
+ tempRoots: string[];
127
+ /** Every existing project's working directory (the root workspace included). */
128
+ managedDirs: string[];
129
+ /** Slugs already in use, so {@link DiscoverCandidate.suggestedSlug} is free. */
130
+ takenSlugs: string[];
131
+ /**
132
+ * List one transcript folder's adoptable sessions. Supplied by the caller
133
+ * because it is the ENGINE's answer (sidechains, already-adopted sessions and
134
+ * anything a run record owns are excluded there, not here).
135
+ *
136
+ * Takes the folder's `engineCwd`, never its recorded cwd: the user's own
137
+ * `~/.claude` folders are reached through a mirror under a synthetic path, and
138
+ * handing the engine the recorded cwd would read the wrong directory (#620).
139
+ */
140
+ listSessions(engineCwd: string): Promise<AdoptableSession[]>;
141
+ }
142
+ /** The ephemeral roots on THIS machine — the well-known set plus `$TMPDIR`. */
143
+ export declare function defaultTempRoots(): string[];
144
+ /**
145
+ * Is this an ephemeral scratch directory?
146
+ *
147
+ * **`$HOME` wins.** A home directory that itself sits under a temp root is not a
148
+ * scratch dir — it is a container, a CI sandbox or a test rig, and applying the
149
+ * rule literally there would exclude every candidate and make Discovery look
150
+ * uniformly broken on exactly the deployments least able to diagnose it. The
151
+ * rule exists to kill `/tmp/foo` sessions from a shell that `cd`'d somewhere
152
+ * disposable, and none of those are inside anybody's home.
153
+ */
154
+ export declare function isTempPath(candidate: string, homeDir: string, roots: string[]): boolean;
155
+ /** The candidate directories this instance could import, ranked. */
156
+ export declare function discoverCandidates(ctx: DiscoverContext, opts?: DiscoverOptions): Promise<DiscoverResult>;
157
+ /** Raised when `?dir=` names something Discovery will not read. */
158
+ export declare class DiscoverPathError extends Error {
159
+ }
160
+ /**
161
+ * One directory's importable sessions.
162
+ *
163
+ * `dir` is matched against the SAME set {@link discoverCandidates} builds — by
164
+ * resolved path or by recorded cwd, since a client may legitimately hold either
165
+ * — with only the soft preference rules relaxed. Anything else throws
166
+ * {@link DiscoverPathError}, which is what keeps this from being a filesystem
167
+ * probe: it reads `<claudeHome>/projects/<encoded>` for directories the machine
168
+ * has already recorded, never a path the caller chose.
169
+ */
170
+ export declare function discoverSessions(ctx: DiscoverContext, dir: string): Promise<DiscoverSessions>;
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Instance-level DISCOVERY of directories worth turning into projects (#745).
3
+ *
4
+ * A fresh instance has nothing in it, and the user very often already has months
5
+ * of terminal `claude` history on the same machine. `<claudeHome>/projects/*`
6
+ * knows every directory they have ever run Claude Code in; this module decides
7
+ * which of those a human would actually recognise as a project.
8
+ *
9
+ * ## Discovery is not adoption, and needs its own boundary
10
+ *
11
+ * `adoptable.ts` answers "what could THIS project import?" — it is scoped to a
12
+ * project that already exists, and `POST …/adopt-chats` deliberately 400s a
13
+ * `sourceCwd` that project does not offer, "rather than an invitation to scan
14
+ * arbitrary directories". Discovery enumerates directories across the machine by
15
+ * definition, so it must NOT be built by loosening that rule. It is a separate,
16
+ * instance-level surface with a containment rule of its own:
17
+ *
18
+ * - the ONLY directories it will ever name are ones with a transcript folder
19
+ * under `<claudeHome>/projects/` — it never lists, walks or globs the
20
+ * filesystem, and a path that is not already in that set cannot be probed
21
+ * through it;
22
+ * - every candidate is put through the same path floor a linked project has to
23
+ * clear ({@link isSystemPath}, #718–#721) plus the reserved-root and
24
+ * project-overlap checks `ProjectStore.validatePath` applies, so Discovery
25
+ * can never propose a row that project creation would then refuse;
26
+ * - the per-directory session listing ({@link discoverSessions}) re-derives
27
+ * that same set and refuses anything outside it, so `?dir=` is a lookup into
28
+ * a computed allow-list rather than a path parameter.
29
+ *
30
+ * ## The heuristic IS the feature
31
+ *
32
+ * A naive "folder exists" scan is unusable. Dogfooded on a real developer
33
+ * machine it surfaced ~166 transcript folders, ~150 of them ephemeral sessions
34
+ * under temp roots, plus junk like `/`, `~/Downloads` and `/tmp` itself. The
35
+ * rules below take that to a handful, and they are ordered cheap-to-expensive so
36
+ * the ~150 die on string comparisons before anything reads a transcript:
37
+ *
38
+ * 1. `no-recorded-cwd` — the folder's transcripts record no cwd, so there is no
39
+ * honest way to say what directory it belongs to. NEVER inverted from the
40
+ * folder name: `encodePathForCli` is lossy (`/a/b-c`, `/a-b/c` and `/a/b/c`
41
+ * all encode alike), so a name-derived path is wrong in ways nothing
42
+ * downstream could detect.
43
+ * 2. `missing` — the directory is gone, or is not a directory.
44
+ * 3. `system-path` — `/` and the `SYSTEM_PATH_DENYLIST` floor (#720), tested
45
+ * against BOTH the resolved and the as-written spelling, exactly as
46
+ * `validatePath` does (`/proc/self/cwd` canonicalises to something ordinary).
47
+ * 4. `temp-root` — `/tmp`, `/var/folders`, `$TMPDIR` and friends. This is the
48
+ * ~150. See {@link isTempPath} for why `$HOME` wins a conflict.
49
+ * 5. `paddock-internal` — inside the projects root, the data dir or either
50
+ * Claude home. `validatePath` refuses those, so offering one would be a row
51
+ * that fails on submit.
52
+ * 6. `home-root` — `$HOME` itself. Someone has run `claude` in their home
53
+ * directory; a project spanning the whole home is never what they meant.
54
+ * 7. `outside-home` — SOFT (see {@link DiscoverOptions.includeOutsideHome}).
55
+ * 8. `already-managed` — the directory is, contains, or sits inside an existing
56
+ * project's working directory. `validatePath` rejects overlap in either
57
+ * direction, so this mirrors it rather than only checking equality.
58
+ * 9. `no-git` — SOFT (see {@link DiscoverOptions.includeNonGit}). One `stat`.
59
+ * 10. `no-sessions` — nothing survives the noise filter. The only expensive
60
+ * rule, and by now it runs over a handful of directories rather than 166.
61
+ *
62
+ * Surviving directories are ranked by non-noise session count, then by recency.
63
+ *
64
+ * ## Why counts are reported rather than dropped
65
+ *
66
+ * `DiscoverResult.excluded` carries how many directories each rule ate. Without it a
67
+ * containerised instance — whose Claude home is `<dataDir>/claude-home` and
68
+ * whose only visible directories are bind mounts — renders a blank page that
69
+ * looks broken, and there is no way to tell "you have no history" from "all 40
70
+ * of your directories are outside `$HOME`". It is also what lets the UI offer
71
+ * the two soft rules as toggles instead of hard-coding a guess.
72
+ */
73
+ import { promises as fs } from "node:fs";
74
+ import os from "node:os";
75
+ import path from "node:path";
76
+ import { filterReasonFor, gitRemotes, } from "./adoptable.js";
77
+ import { isPathInside, isSystemPath, slugify } from "./project-paths.js";
78
+ /**
79
+ * Roots whose contents are ephemeral by construction.
80
+ *
81
+ * macOS spellings are listed in both their `/private`-prefixed and unprefixed
82
+ * forms because either can be the recorded one depending on how the shell got
83
+ * there, and {@link siftDirectories} tests the as-written path as well as the
84
+ * resolved one.
85
+ */
86
+ const TEMP_PATH_ROOTS = [
87
+ "/tmp",
88
+ "/var/tmp",
89
+ "/private/tmp",
90
+ "/private/var/tmp",
91
+ "/var/folders",
92
+ "/private/var/folders",
93
+ "/dev/shm",
94
+ ];
95
+ /** The ephemeral roots on THIS machine — the well-known set plus `$TMPDIR`. */
96
+ export function defaultTempRoots() {
97
+ return [...TEMP_PATH_ROOTS, os.tmpdir()];
98
+ }
99
+ /**
100
+ * Is this an ephemeral scratch directory?
101
+ *
102
+ * **`$HOME` wins.** A home directory that itself sits under a temp root is not a
103
+ * scratch dir — it is a container, a CI sandbox or a test rig, and applying the
104
+ * rule literally there would exclude every candidate and make Discovery look
105
+ * uniformly broken on exactly the deployments least able to diagnose it. The
106
+ * rule exists to kill `/tmp/foo` sessions from a shell that `cd`'d somewhere
107
+ * disposable, and none of those are inside anybody's home.
108
+ */
109
+ export function isTempPath(candidate, homeDir, roots) {
110
+ const p = path.resolve(candidate);
111
+ if (isPathInside(p, homeDir))
112
+ return false;
113
+ return roots.some((root) => isPathInside(p, root));
114
+ }
115
+ /**
116
+ * Apply the path rules to every transcript folder, grouping survivors by the
117
+ * directory they resolve to.
118
+ *
119
+ * `hard` runs the rules that are containment rather than preference — the set
120
+ * {@link discoverSessions} validates `?dir=` against. The soft rules
121
+ * (`no-git`, `outside-home`) are skipped entirely in that mode, because a client
122
+ * can only have learnt a path from a listing that already applied them.
123
+ */
124
+ async function siftDirectories(ctx, opts, mode) {
125
+ const excluded = {};
126
+ const drop = (reason) => {
127
+ excluded[reason] = (excluded[reason] ?? 0) + 1;
128
+ };
129
+ const kept = [];
130
+ /** realPath → its entry in `kept`, so two folders for one directory merge. */
131
+ const byRealPath = new Map();
132
+ /**
133
+ * Directories already ruled out, so the tally counts DIRECTORIES rather than
134
+ * folders. One directory routinely has two transcript folders (the user's own
135
+ * and paddock's copy, post-#620 and after any import), and reporting "2 temp
136
+ * roots" for one temp directory would make the number the UI shows a lie.
137
+ *
138
+ * A kept directory never reaches this set — `byRealPath` catches it first —
139
+ * and `no-recorded-cwd` never enters it, because a folder with no cwd has no
140
+ * directory to be counted under. That one is per-FOLDER by necessity.
141
+ */
142
+ const rejected = new Set();
143
+ for (const folder of ctx.folders) {
144
+ const recorded = folder.cwd;
145
+ if (recorded === null || recorded === "" || folder.engineCwd === null) {
146
+ drop("no-recorded-cwd");
147
+ continue;
148
+ }
149
+ // Resolve BEFORE any containment test — `/tmp/innocent -> ~/real` would
150
+ // otherwise be judged on its spelling. Both spellings are then checked, the
151
+ // same pairing `validatePath` uses.
152
+ const real = await fs.realpath(recorded).catch(() => null);
153
+ const resolved = real ?? path.resolve(recorded);
154
+ // An already-merged directory has cleared every rule below; just union in
155
+ // this folder's engine path.
156
+ const merged = byRealPath.get(resolved);
157
+ if (merged !== undefined) {
158
+ if (!merged.engineCwds.includes(folder.engineCwd))
159
+ merged.engineCwds.push(folder.engineCwd);
160
+ continue;
161
+ }
162
+ if (rejected.has(resolved))
163
+ continue;
164
+ // From here on every `continue` is a decision about this DIRECTORY, so it is
165
+ // recorded once no matter how many folders record it.
166
+ rejected.add(resolved);
167
+ if (real === null) {
168
+ drop("missing");
169
+ continue;
170
+ }
171
+ const st = await fs.stat(resolved).catch(() => null);
172
+ if (st === null || !st.isDirectory()) {
173
+ drop("missing");
174
+ continue;
175
+ }
176
+ if (isSystemPath(resolved) || isSystemPath(recorded)) {
177
+ drop("system-path");
178
+ continue;
179
+ }
180
+ if (isTempPath(resolved, ctx.homeDir, ctx.tempRoots) ||
181
+ isTempPath(recorded, ctx.homeDir, ctx.tempRoots)) {
182
+ drop("temp-root");
183
+ continue;
184
+ }
185
+ if (ctx.reservedRoots.some((root) => isPathInside(resolved, root))) {
186
+ drop("paddock-internal");
187
+ continue;
188
+ }
189
+ if (resolved === path.resolve(ctx.homeDir)) {
190
+ drop("home-root");
191
+ continue;
192
+ }
193
+ const insideHome = isPathInside(resolved, ctx.homeDir);
194
+ if (mode === "full" && !insideHome && opts.includeOutsideHome !== true) {
195
+ drop("outside-home");
196
+ continue;
197
+ }
198
+ // Overlap in EITHER direction, matching `validatePath`: linking a parent of
199
+ // another project's working tree is as broken as linking a child of one.
200
+ //
201
+ // Not a containment rule, so it does not apply in `hard-only` mode: it says
202
+ // "there is nothing left to CREATE here", which has no bearing on whether a
203
+ // directory's transcripts may be listed. Applying it there would also make
204
+ // the import loop race itself — a client that creates the project and then
205
+ // re-expands the row would get a 400 for the directory it just imported.
206
+ if (mode === "full" &&
207
+ ctx.managedDirs.some((dir) => isPathInside(resolved, dir) || isPathInside(dir, resolved))) {
208
+ drop("already-managed");
209
+ continue;
210
+ }
211
+ // One `stat` — and only now, for what is left after the string rules.
212
+ const dotGit = await fs.stat(path.join(resolved, ".git")).catch(() => null);
213
+ const hasGit = dotGit !== null;
214
+ if (mode === "full" && !hasGit && opts.includeNonGit !== true) {
215
+ drop("no-git");
216
+ continue;
217
+ }
218
+ const entry = {
219
+ path: resolved,
220
+ ...(recorded === resolved ? {} : { recordedPath: recorded }),
221
+ name: path.basename(resolved),
222
+ hasGit,
223
+ // Display-only, and `discoverSessions` never returns it — so don't read a
224
+ // git config per directory on the expansion path, which sifts the whole
225
+ // set again just to validate one `?dir=`.
226
+ ...(hasGit && mode === "full" ? await remoteOf(resolved) : {}),
227
+ insideHome,
228
+ engineCwds: [folder.engineCwd],
229
+ };
230
+ kept.push(entry);
231
+ byRealPath.set(resolved, entry);
232
+ }
233
+ return { kept, excluded };
234
+ }
235
+ /** The checkout's first normalised remote, as a spreadable partial. */
236
+ async function remoteOf(dir) {
237
+ const remotes = await gitRemotes(dir).catch(() => []);
238
+ return remotes.length > 0 ? { gitRemote: remotes[0] } : {};
239
+ }
240
+ /** Collect a directory's offered sessions across all of its transcript folders. */
241
+ async function sessionsIn(ctx, engineCwds) {
242
+ const seen = new Set();
243
+ const sessions = [];
244
+ const filtered = [];
245
+ for (const engineCwd of engineCwds) {
246
+ // Fault-isolated per folder: one unreadable folder costs its own sessions,
247
+ // never the whole directory's count.
248
+ const found = await ctx.listSessions(engineCwd).catch(() => []);
249
+ for (const session of found) {
250
+ // One session, once. Two engine paths can reach the same transcript, and a
251
+ // transcript may have been copied between them.
252
+ if (seen.has(session.sessionId))
253
+ continue;
254
+ seen.add(session.sessionId);
255
+ const reason = filterReasonFor(session);
256
+ if (reason !== null) {
257
+ filtered.push({ sessionId: session.sessionId, reason });
258
+ continue;
259
+ }
260
+ sessions.push({
261
+ sessionId: session.sessionId,
262
+ mtime: session.mtime,
263
+ preview: session.preview,
264
+ autoName: session.autoName,
265
+ sizeBytes: session.sizeBytes,
266
+ });
267
+ }
268
+ }
269
+ sessions.sort((a, b) => b.mtime.localeCompare(a.mtime));
270
+ return { sessions, filtered };
271
+ }
272
+ /**
273
+ * Rank candidates: most sessions first, then most recently used, then by path so
274
+ * two equal rows never swap places between two requests.
275
+ */
276
+ function rank(a, b) {
277
+ if (a.sessionCount !== b.sessionCount)
278
+ return b.sessionCount - a.sessionCount;
279
+ const at = a.lastSessionAt ?? "";
280
+ const bt = b.lastSessionAt ?? "";
281
+ if (at !== bt)
282
+ return bt.localeCompare(at);
283
+ return a.path.localeCompare(b.path);
284
+ }
285
+ /**
286
+ * A slug free of `taken`, qualified by the parent directory before falling back
287
+ * to a counter. `taken` is mutated so a whole result set stays internally
288
+ * unique, not merely unique against the instance.
289
+ */
290
+ function freeSlug(dir, taken) {
291
+ const base = slugify(path.basename(dir)) || "project";
292
+ if (!taken.has(base)) {
293
+ taken.add(base);
294
+ return base;
295
+ }
296
+ const parent = slugify(path.basename(path.dirname(dir)));
297
+ const qualified = parent === "" ? base : `${parent}-${base}`;
298
+ if (!taken.has(qualified)) {
299
+ taken.add(qualified);
300
+ return qualified;
301
+ }
302
+ for (let n = 2;; n++) {
303
+ const numbered = `${qualified}-${n}`;
304
+ if (!taken.has(numbered)) {
305
+ taken.add(numbered);
306
+ return numbered;
307
+ }
308
+ }
309
+ }
310
+ /** The candidate directories this instance could import, ranked. */
311
+ export async function discoverCandidates(ctx, opts = {}) {
312
+ const { kept, excluded } = await siftDirectories(ctx, opts, "full");
313
+ const candidates = [];
314
+ for (const dir of kept) {
315
+ const { sessions, filtered } = await sessionsIn(ctx, dir.engineCwds);
316
+ if (sessions.length === 0) {
317
+ excluded["no-sessions"] = (excluded["no-sessions"] ?? 0) + 1;
318
+ continue;
319
+ }
320
+ const { engineCwds: _engineCwds, ...rest } = dir;
321
+ candidates.push({
322
+ ...rest,
323
+ // Placeholder: assigned below, in rank order, so the best row keeps the
324
+ // unqualified slug when two candidates share a basename.
325
+ suggestedSlug: "",
326
+ sessionCount: sessions.length,
327
+ filteredCount: filtered.length,
328
+ // Sorted newest-first above, so element 0 is the most recent.
329
+ ...(sessions[0] === undefined ? {} : { lastSessionAt: sessions[0].mtime }),
330
+ });
331
+ }
332
+ candidates.sort(rank);
333
+ const taken = new Set(ctx.takenSlugs);
334
+ for (const candidate of candidates)
335
+ candidate.suggestedSlug = freeSlug(candidate.path, taken);
336
+ return {
337
+ claudeHome: ctx.claudeHome,
338
+ homeDir: ctx.homeDir,
339
+ scanned: ctx.folders.length,
340
+ candidates,
341
+ excluded,
342
+ };
343
+ }
344
+ /** Raised when `?dir=` names something Discovery will not read. */
345
+ export class DiscoverPathError extends Error {
346
+ }
347
+ /**
348
+ * One directory's importable sessions.
349
+ *
350
+ * `dir` is matched against the SAME set {@link discoverCandidates} builds — by
351
+ * resolved path or by recorded cwd, since a client may legitimately hold either
352
+ * — with only the soft preference rules relaxed. Anything else throws
353
+ * {@link DiscoverPathError}, which is what keeps this from being a filesystem
354
+ * probe: it reads `<claudeHome>/projects/<encoded>` for directories the machine
355
+ * has already recorded, never a path the caller chose.
356
+ */
357
+ export async function discoverSessions(ctx, dir) {
358
+ const { kept } = await siftDirectories(ctx, {}, "hard-only");
359
+ const wanted = path.resolve(dir.trim());
360
+ const match = kept.find((d) => d.path === wanted || d.recordedPath === dir.trim());
361
+ if (match === undefined) {
362
+ throw new DiscoverPathError(`Not a discoverable directory: ${dir}`);
363
+ }
364
+ return { path: match.path, ...(await sessionsIn(ctx, match.engineCwds)) };
365
+ }
@@ -46,13 +46,13 @@
46
46
  * That last nuance is now closed too: core exposes `fleet.invalidateSessions(name)`
47
47
  * and paddock calls it after each turn (`ws-turn.ts`) and around fork/promote.
48
48
  */
49
- import { FleetManager, type DiscoveredSession, type ChatMessage, type SDKMessage, type TriggerResult, type AgentInfo, type FleetStatus, type SlashCommand, type InjectedMcpServerDef, type SessionWakeHandler, type ResolveInjectedMcpServers, type ScheduleTriggerHandler, type ScheduleInfo, type JobMetadata, type AdoptionPlacementMode, type AdoptSkippedSession } from "@herdctl/core";
49
+ import { FleetManager, type DiscoveredSession, type ChatMessage, type SDKMessage, type TriggerResult, type AgentInfo, type FleetStatus, type SlashCommand, type InjectedMcpServerDef, type SessionWakeHandler, type ResolveInjectedMcpServers, type ScheduleTriggerHandler, type ScheduleInfo, type JobMetadata, type AdoptionPlacementMode, type AdoptableSession, type AdoptSkippedSession } from "@herdctl/core";
50
50
  import type { PaddockConfig } from "./config.js";
51
51
  import type { Project } from "./projects.js";
52
52
  import { type PaddockTrigger } from "./trigger-config.js";
53
53
  import { type McpSources } from "./claude-mcp.js";
54
54
  import { type HostPluginSource } from "./claude-plugins.js";
55
- import { type AdoptableSummary, type FilterReason } from "./adoptable.js";
55
+ import { type AdoptableSummary, type FilterReason, type TranscriptFolder } from "./adoptable.js";
56
56
  /**
57
57
  * One session an import did NOT bring in, with a reason a UI can show verbatim.
58
58
  *
@@ -370,6 +370,29 @@ export declare class HerdctlService {
370
370
  */
371
371
  private adoptableIndexOrNull;
372
372
  private get adoptableIndex();
373
+ /**
374
+ * Every `<claudeHome>/projects/*` folder with its RECORDED cwd — the raw
375
+ * material `discover.ts` filters into candidate project directories.
376
+ *
377
+ * Served off the same cached, legacy-mirroring scan adoption uses, so
378
+ * Discovery sees the user's own `~/.claude` history (#620) and costs a
379
+ * `readdir` on a warm cache.
380
+ */
381
+ transcriptFolders(): Promise<TranscriptFolder[]>;
382
+ /**
383
+ * The engine's adoptable sessions for ONE transcript folder, addressed by the
384
+ * path the engine reads it through (a folder's `engineCwd`).
385
+ *
386
+ * Attributed under the ROOT workspace's keeper, which is the only agent
387
+ * guaranteed to exist — Discovery runs before any project does, and that is
388
+ * its whole point. The name is a metadata cache key and nothing more here: the
389
+ * engine's exclusions (sidechain / already-adopted / owned by a run record)
390
+ * are instance-wide, not per-agent, so the answer does not depend on which
391
+ * agent asked. Warming previews under the root's key rather than the
392
+ * eventual project's costs one re-extraction at import time and no
393
+ * correctness.
394
+ */
395
+ listAdoptableIn(engineCwd: string): Promise<AdoptableSession[]>;
373
396
  /** What `project` could import right now (see `./adoptable.ts`). */
374
397
  listAdoptable(project: Project): Promise<AdoptableSummary>;
375
398
  /**
@@ -60,7 +60,8 @@ import { EMPTY_MCP_SOURCES } from "./claude-mcp.js";
60
60
  import { EMPTY_HOST_PLUGINS } from "./claude-plugins.js";
61
61
  import * as jobs from "./herdctl-jobs.js";
62
62
  import { JobsDirIndex } from "./herdctl-jobs-index.js";
63
- import { AdoptableIndex } from "./adoptable.js";
63
+ import { AdoptableIndex, } from "./adoptable.js";
64
+ import { ROOT_KEY } from "./project-paths.js";
64
65
  /**
65
66
  * The name/visibility helpers + tool/model constants are pure (they only read
66
67
  * their arguments, no fleet/cfg/session state), so they live in a sibling module
@@ -595,6 +596,34 @@ export class HerdctlService {
595
596
  get adoptableIndex() {
596
597
  return (this.adoptableIndexOrNull ??= new AdoptableIndex(this.cfg.claudeHome, this.cfg.stateDir, this.cfg.legacyClaudeHome));
597
598
  }
599
+ // --- instance-level discovery (#745) -----------------------------------
600
+ /**
601
+ * Every `<claudeHome>/projects/*` folder with its RECORDED cwd — the raw
602
+ * material `discover.ts` filters into candidate project directories.
603
+ *
604
+ * Served off the same cached, legacy-mirroring scan adoption uses, so
605
+ * Discovery sees the user's own `~/.claude` history (#620) and costs a
606
+ * `readdir` on a warm cache.
607
+ */
608
+ async transcriptFolders() {
609
+ return this.adoptableIndex.transcriptFolders();
610
+ }
611
+ /**
612
+ * The engine's adoptable sessions for ONE transcript folder, addressed by the
613
+ * path the engine reads it through (a folder's `engineCwd`).
614
+ *
615
+ * Attributed under the ROOT workspace's keeper, which is the only agent
616
+ * guaranteed to exist — Discovery runs before any project does, and that is
617
+ * its whole point. The name is a metadata cache key and nothing more here: the
618
+ * engine's exclusions (sidechain / already-adopted / owned by a run record)
619
+ * are instance-wide, not per-agent, so the answer does not depend on which
620
+ * agent asked. Warming previews under the root's key rather than the
621
+ * eventual project's costs one re-extraction at import time and no
622
+ * correctness.
623
+ */
624
+ async listAdoptableIn(engineCwd) {
625
+ return this.manager.listAdoptableSessions(keeperAgentName(ROOT_KEY), engineCwd);
626
+ }
598
627
  /** What `project` could import right now (see `./adoptable.ts`). */
599
628
  async listAdoptable(project) {
600
629
  return this.adoptableIndex.adoptableFor(this.manager, project, keeperAgentName(project.slug));
@@ -0,0 +1,3 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import type { RouteCtx } from "../route-context.js";
3
+ export declare function registerDiscoverRoutes(app: FastifyInstance, ctx: RouteCtx): void;