@edspencer/paddock 0.66.2 → 0.68.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/README.md +24 -30
- package/package.json +1 -1
- package/packages/server/dist/adoptable.d.ts +57 -0
- package/packages/server/dist/adoptable.js +26 -3
- package/packages/server/dist/agent-errors.js +1 -1
- package/packages/server/dist/claude-home.d.ts +4 -4
- package/packages/server/dist/claude-home.js +4 -4
- package/packages/server/dist/claude-mcp.d.ts +4 -2
- package/packages/server/dist/cli/args.d.ts +41 -2
- package/packages/server/dist/cli/args.js +119 -17
- package/packages/server/dist/cli/paddock.js +87 -96
- package/packages/server/dist/cli/service/backend.d.ts +38 -0
- package/packages/server/dist/cli/service/backend.js +19 -0
- package/packages/server/dist/cli/service/index.d.ts +28 -0
- package/packages/server/dist/cli/service/index.js +179 -0
- package/packages/server/dist/cli/service/launchd.d.ts +47 -0
- package/packages/server/dist/cli/service/launchd.js +206 -0
- package/packages/server/dist/cli/service/spec.d.ts +106 -0
- package/packages/server/dist/cli/service/spec.js +124 -0
- package/packages/server/dist/cli/service/systemd.d.ts +48 -0
- package/packages/server/dist/cli/service/systemd.js +155 -0
- package/packages/server/dist/discover.d.ts +170 -0
- package/packages/server/dist/discover.js +365 -0
- package/packages/server/dist/herdctl.d.ts +25 -2
- package/packages/server/dist/herdctl.js +30 -1
- package/packages/server/dist/routes/chats.js +14 -2
- package/packages/server/dist/routes/discover.d.ts +3 -0
- package/packages/server/dist/routes/discover.js +140 -0
- package/packages/server/dist/routes.d.ts +1 -0
- package/packages/server/dist/routes.js +2 -0
- package/packages/server/dist/session-hub.d.ts +10 -0
- package/packages/server/dist/session-hub.js +16 -2
- package/packages/server/dist/ws-protocol.d.ts +15 -0
- package/packages/server/dist/ws.js +1 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/Chip-DIdRqcqU.js +2 -0
- package/packages/web/dist/assets/DiscoverPage-BmCWnkjN.js +2 -0
- package/packages/web/dist/assets/DiscoverView-DwhtBdN2.js +2 -0
- package/packages/web/dist/assets/InstanceConfigPage-D01x1BUg.js +2 -0
- package/packages/web/dist/assets/{MarkdownRenderer-DDVO0zhl.js → MarkdownRenderer-BCheNET2.js} +2 -2
- package/packages/web/dist/assets/ProjectMenu-BqlUQuxM.js +2 -0
- package/packages/web/dist/assets/ProjectRedirect-D8QDxYLG.js +2 -0
- package/packages/web/dist/assets/ProjectView-cevSYdcl.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-yd18DXdV.js +2 -0
- package/packages/web/dist/assets/RootHome-DZEkOjko.js +2 -0
- package/packages/web/dist/assets/Surfaces-DvRztndg.js +2 -0
- package/packages/web/dist/assets/{arc-DXqVnx_r.js → arc-DFqBni1E.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-FgkfD1Bk.js → architectureDiagram-3BPJPVTR-DXFeq3pL.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-WwkIIJsI.js → blockDiagram-GPEHLZMM-DpeYsIwP.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BIzkTibx.js → c4Diagram-AAUBKEIU-Bw5r8wjW.js} +1 -1
- package/packages/web/dist/assets/channel-DU94S7yB.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-C4TBH3R6.js → chunk-2J33WTMH-B0SAvGOr.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DTKJZlRM.js → chunk-4BX2VUAB-BaUGs1hD.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BC9_Qc8h.js → chunk-55IACEB6-BDHRluF5.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-D3gQ4rP3.js → chunk-727SXJPM-BX93DBZO.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-My2AXBvz.js → chunk-AQP2D5EJ-3RiQGxW2.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-D09zE6Ow.js → chunk-FMBD7UC4-DMkjv9SP.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-CUSgeCLl.js → chunk-ND2GUHAM-CHiQfq4F.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-B6jVPYgy.js → chunk-QZHKN3VN-B1wCSHbM.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-BcRv1OPp.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-BcRv1OPp.js +2 -0
- package/packages/web/dist/assets/{core-DPY0seI-.js → core-C-49RP59.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BasCiARv.js → cose-bilkent-S5V4N54A-BIwwgSTZ.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-Cimn11Qe.js → dagre-BM42HDAG-BN45kshV.js} +1 -1
- package/packages/web/dist/assets/diagram-2AECGRRQ-C5NekbfR.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-De7wdssd.js +11 -0
- package/packages/web/dist/assets/{diagram-KO2AKTUF-dLORrSiu.js → diagram-KO2AKTUF-CgG0UA0n.js} +3 -3
- package/packages/web/dist/assets/{diagram-LMA3HP47-iUMeaR-J.js → diagram-LMA3HP47-DC-bDqxV.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-oEH6olHK.js → diagram-OG6HWLK6-XXfHDMhE.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-VtQqF9SY.js → erDiagram-TEJ5UH35-CHEd8JiS.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-wvF2EkJa.js → flowDiagram-I6XJVG4X-BbuYxd15.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-ng4qJ0E1.js → ganttDiagram-6RSMTGT7-BkF77xn5.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BV4aWy8k.js → gitGraphDiagram-PVQCEYII-BdL5zVez.js} +2 -2
- package/packages/web/dist/assets/index-C3MdCCzF.js +71 -0
- package/packages/web/dist/assets/index-DYccdR9x.css +1 -0
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BpGTxAjU.js → infoDiagram-5YYISTIA-vhTRATYg.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-Cj36LyoS.js → ishikawaDiagram-YF4QCWOH-CxjN-7YF.js} +2 -2
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-0vNlg3um.js → journeyDiagram-JHISSGLW-DJ414TaX.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-wijkEDaR.js → kanban-definition-UN3LZRKU-gaQeFRvZ.js} +1 -1
- package/packages/web/dist/assets/{linear-LqRymMD_.js → linear-CcvdFgxd.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-DPrhSs95.js → mermaid.core-Es6hedDB.js} +5 -5
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-en-uGqgC.js → mindmap-definition-RKZ34NQL-Dk7qf3FR.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DKBZ2Vnx.js → pieDiagram-4H26LBE5-CSWXwU6d.js} +3 -3
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB--g-WtxyL.js → quadrantDiagram-W4KKPZXB-CBVdURbr.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-Dl4eYxDI.js → requirementDiagram-4Y6WPE33-CbU18IZA.js} +3 -3
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-7lt4gqSw.js → sankeyDiagram-5OEKKPKP-BeAlNgjE.js} +4 -4
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-COgq-dXg.js → sequenceDiagram-3UESZ5HK-CKVKzxTs.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CL6XC79S.js → stateDiagram-AJRCARHV-B-aSaO4W.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-TptFh32C.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C5egKeGT.js → timeline-definition-PNZ67QCA-CP9JHEBr.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-C_UhtC8u.js → vennDiagram-CIIHVFJN-Dbtwwvvd.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Bj9chUqz.js → wardley-L42UT6IY-NonvEhoT.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-D7VnZoRp.js → wardleyDiagram-YWT4CUSO-CDnpv9Ws.js} +2 -2
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-VrWLDoi4.js → xychartDiagram-2RQKCTM6-C1i-Wk0H.js} +1 -1
- package/packages/web/dist/fonts/alegreya-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-latin.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-500.woff2 +0 -0
- package/packages/web/dist/fonts/alegreya-sc-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-400.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-700.woff2 +0 -0
- package/packages/web/dist/fonts/courier-prime-latin-italic.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-400.woff2 +0 -0
- package/packages/web/dist/fonts/iosevka-terminal-700.woff2 +0 -0
- package/packages/web/dist/fonts/martian-mono-latin.woff2 +0 -0
- package/packages/web/dist/fonts/saira-latin.woff2 +0 -0
- package/packages/web/dist/index.html +61 -12
- package/packages/web/dist/sw.js +1 -1
- package/packages/server/dist/cli/here.d.ts +0 -25
- package/packages/server/dist/cli/here.js +0 -104
- package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +0 -2
- package/packages/web/dist/assets/ProjectRedirect-WczXTV0_.js +0 -2
- package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +0 -13
- package/packages/web/dist/assets/ProjectsGrid-BeJj0q6Y.js +0 -2
- package/packages/web/dist/assets/channel-DUsOl5_p.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +0 -2
- package/packages/web/dist/assets/diagram-2AECGRRQ-fe9cDHyn.js +0 -44
- package/packages/web/dist/assets/diagram-5GNKFQAL-T0tQlfbl.js +0 -11
- package/packages/web/dist/assets/format-CvRqOKZU.js +0 -2
- package/packages/web/dist/assets/index-DGNSeqL-.js +0 -71
- package/packages/web/dist/assets/index-DMRbU1Jl.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.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));
|
|
@@ -150,7 +150,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
150
150
|
schema: {
|
|
151
151
|
tags: ["Chats"],
|
|
152
152
|
summary: "List the chats in this workspace's subtree that are running or unread",
|
|
153
|
-
description: "Returns `{ running, unread }` — the chats in this workspace AND its descendants that currently have a live turn (`running`) or hold an unread reply (`unread`). Each row is a chat DTO plus the `projectSlug`/`projectName` it belongs to, so a fleet-wide list stays attributable. On the root mount (`/api/root/chats/attention`) the subtree is the whole instance, because the root workspace's key (`\"\"`) prefixes every workspace key; on a project mount it is that project alone. A chat is never in both lists: a live turn hasn't landed a reply yet, so running wins.",
|
|
153
|
+
description: "Returns `{ running, unread }` — the chats in this workspace AND its descendants that currently have a live turn (`running`) or hold an unread reply (`unread`). Each row is a chat DTO plus the `projectSlug`/`projectName` it belongs to, so a fleet-wide list stays attributable. On the root mount (`/api/root/chats/attention`) the subtree is the whole instance, because the root workspace's key (`\"\"`) prefixes every workspace key; on a project mount it is that project alone. A chat is never in both lists: a live turn hasn't landed a reply yet, so running wins. `running` rows additionally carry resolved `contextTokens`/`contextLimit` so a caller can draw a context gauge for live work; `unread` rows do not, because resolving usage streams a transcript per chat and only the running set is bounded by how many turns can be in flight at once.",
|
|
154
154
|
params: {
|
|
155
155
|
type: "object",
|
|
156
156
|
properties: {
|
|
@@ -197,7 +197,19 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
197
197
|
const provenanceOf = (s) => runProvenance.get(s.sessionId);
|
|
198
198
|
const triggerOf = makeTriggerResolver(p);
|
|
199
199
|
const parentOf = makeParentResolver(runProvenance, messageProvenance, p.slug, (id) => parentDetach.isDetached(keeper, id));
|
|
200
|
-
|
|
200
|
+
// Usage is deliberately NOT resolved for the whole subtree: it streams
|
|
201
|
+
// a transcript per chat, and this route refetches on every turn
|
|
202
|
+
// boundary across the fleet. A RUNNING chat is the one exception —
|
|
203
|
+
// every context gauge in the app (Home's running rows, the fleet
|
|
204
|
+
// readout) reads `contextTokens`/`contextLimit` off these rows, and
|
|
205
|
+
// with no resolver they were always null, so the gauges rendered
|
|
206
|
+
// blank. The running set is bounded by how many turns can be in
|
|
207
|
+
// flight at once (a handful), not by how many chats the instance
|
|
208
|
+
// holds, so the cost stays proportional to live work and not to
|
|
209
|
+
// history.
|
|
210
|
+
const usageOf = chatUsageResolver(p.dir, p.model ?? DEFAULT_MODEL);
|
|
211
|
+
const usageIfRunning = async (s) => isRunning(s.sessionId) ? usageOf(s) : null;
|
|
212
|
+
const chats = await buildProjectChats(p.dir, sessions, usageIfRunning, archivedOf, lastTurnAt, lastSeenOf, provenanceOf, triggerOf, starredOf, unreadOf, parentOf);
|
|
201
213
|
for (const c of chats) {
|
|
202
214
|
const row = { ...c, projectSlug: p.slug, projectName: p.name };
|
|
203
215
|
if (isRunning(c.sessionId)) {
|