@edspencer/paddock 0.59.1 → 0.60.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/package.json +2 -2
- package/packages/server/dist/adoptable.d.ts +72 -3
- package/packages/server/dist/adoptable.js +231 -25
- package/packages/server/dist/herdctl.d.ts +80 -0
- package/packages/server/dist/herdctl.js +159 -0
- package/packages/server/dist/routes/chats.js +59 -3
- package/packages/server/dist/run-provenance.d.ts +11 -0
- package/packages/server/dist/run-provenance.js +18 -0
- package/packages/server/dist/ws-turn.d.ts +0 -13
- package/packages/server/dist/ws-turn.js +50 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-CAwSnyga.js → InstanceConfigPage-xn9Rvoah.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DpGpkMzG.js → MarkdownRenderer-DMONssKA.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-D1ipv4jr.js → ProjectRedirect-DHoWtvtY.js} +1 -1
- package/packages/web/dist/assets/ProjectView-BmSdUwY2.js +13 -0
- package/packages/web/dist/assets/{ProjectsGrid-Dwngb-m1.js → ProjectsGrid-CjfwpeFj.js} +1 -1
- package/packages/web/dist/assets/{arc-BZh6Z9_D.js → arc-DdFdbD-c.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-iCtJiW4k.js → architectureDiagram-3BPJPVTR-DC8UKtwV.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BeI9Wi0h.js → blockDiagram-GPEHLZMM-DqFFe_5c.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D_j2LKZ-.js → c4Diagram-AAUBKEIU-DbffFSM-.js} +1 -1
- package/packages/web/dist/assets/channel-QxsJGL6F.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-DRlfPROq.js → chunk-2J33WTMH-8PP2RHft.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-BMY66MLs.js → chunk-4BX2VUAB-Hag68B5N.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BAQ92trf.js → chunk-55IACEB6-BXp_gQIa.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-Dxgb_nNN.js → chunk-727SXJPM-DlntjQux.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-D5cqv3Jv.js → chunk-AQP2D5EJ-BhI9KPrE.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-BOEqxzyA.js → chunk-FMBD7UC4-WhfORs2z.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-CkTZgv_W.js → chunk-ND2GUHAM-CIskmv20.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-DIr8TL7M.js → chunk-QZHKN3VN-CNOTpCW4.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CI7p84hR.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CI7p84hR.js +2 -0
- package/packages/web/dist/assets/{core-DyOZ2e1O.js → core-usOVRkLM.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-D8Muyf4o.js → cose-bilkent-S5V4N54A-CniuMIIW.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-B_EUbUz6.js → dagre-BM42HDAG-5CteYfQY.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-DpAM0K6Q.js → diagram-2AECGRRQ-BIQjD2LA.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Dhs9RMKk.js → diagram-5GNKFQAL-1vy3ErTt.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-LBeTHReu.js → diagram-KO2AKTUF-D45iqFk6.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CxboRwue.js → diagram-LMA3HP47-DNktDktn.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-BK_P-OSi.js → diagram-OG6HWLK6-BQ9JgV70.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-CPSKRHI5.js → erDiagram-TEJ5UH35-oBasZ1uv.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-CbUumAP5.js → flowDiagram-I6XJVG4X-DxnJHMBS.js} +1 -1
- package/packages/web/dist/assets/{format-DDL0Q7H2.js → format-CIAat6sU.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-l-p25ckA.js → ganttDiagram-6RSMTGT7-CtJQ2m7z.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-i8kDIgga.js → gitGraphDiagram-PVQCEYII-5RPscwD_.js} +1 -1
- package/packages/web/dist/assets/index-B6etJCHf.css +1 -0
- package/packages/web/dist/assets/{index-DKDgTDXa.js → index-DtRblEY_.js} +4 -4
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CbyLnHIe.js → infoDiagram-5YYISTIA-zQu3xkW4.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-C1_3z8-9.js → ishikawaDiagram-YF4QCWOH-ChoEjcdw.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BzyAtQdy.js → journeyDiagram-JHISSGLW-DM2KWmDr.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-Fp2VVm-b.js → kanban-definition-UN3LZRKU-DWzTpYgh.js} +1 -1
- package/packages/web/dist/assets/{linear-6ZKEeWQ5.js → linear-Bpy9Aj-K.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Dorg4Dl2.js → mermaid.core-D43x5bFb.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Bk4Ax0bI.js → mindmap-definition-RKZ34NQL-BhceB-cv.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DoTn5jHm.js → pieDiagram-4H26LBE5-BgcGlFFA.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-ClNmIKCj.js → quadrantDiagram-W4KKPZXB-ZRVaOoSp.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-DMF7i5KW.js → requirementDiagram-4Y6WPE33-2hHcJHBt.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-C-ALbVLg.js → sankeyDiagram-5OEKKPKP-KPUQ9Mu5.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DsUxj8IQ.js → sequenceDiagram-3UESZ5HK-D4BXQSSM.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-OCZiRRsz.js → stateDiagram-AJRCARHV-DftS7dIo.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CgBC1jpO.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-5ifjxa-s.js → timeline-definition-PNZ67QCA-C0OAxyWY.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Bqvqpot5.js → vennDiagram-CIIHVFJN-C94fINN3.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-DwgnVd48.js → wardley-L42UT6IY-CbFrlwHE.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-F-VCH-oE.js → wardleyDiagram-YWT4CUSO-CHG2nndd.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-L4q6oV_7.js → xychartDiagram-2RQKCTM6-U9ESc19z.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/ProjectView-BI1TtrOK.js +0 -13
- package/packages/web/dist/assets/channel-DszBVZgr.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DuGqlnbT.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DuGqlnbT.js +0 -2
- package/packages/web/dist/assets/index-CvGiQddO.css +0 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BwcJspgG.js +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edspencer/paddock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@fastify/swagger-ui": "4.2.0",
|
|
38
38
|
"@fastify/websocket": "10.0.1",
|
|
39
39
|
"@herdctl/chat": "0.8.0",
|
|
40
|
-
"@herdctl/core": "5.
|
|
40
|
+
"@herdctl/core": "5.31.0",
|
|
41
41
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
42
42
|
"fastify": "4.28.1",
|
|
43
43
|
"jose": "6.2.3",
|
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
import { type AdoptableSession } from "@herdctl/core";
|
|
2
2
|
import type { Project } from "./projects.js";
|
|
3
|
+
/**
|
|
4
|
+
* One session on offer, with enough about it to decide (#660).
|
|
5
|
+
*
|
|
6
|
+
* The import used to be a single unconfirmed click, so an id was all the count
|
|
7
|
+
* needed. A confirmation dialog has to show the user WHAT it is about to import —
|
|
8
|
+
* and on the instance that prompted this, three quarters of the offer was things
|
|
9
|
+
* the user would have declined on sight. Every field here is already computed by
|
|
10
|
+
* the engine's scan; none of it costs an extra transcript parse.
|
|
11
|
+
*/
|
|
12
|
+
export interface AdoptableCandidate {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
/** ISO 8601 last-modified time of the transcript (also the sort order). */
|
|
15
|
+
mtime: string;
|
|
16
|
+
/** First user message, truncated by the engine at 100 chars. */
|
|
17
|
+
preview?: string;
|
|
18
|
+
/** Auto-generated session name, when the transcript carries a summary. */
|
|
19
|
+
autoName?: string;
|
|
20
|
+
sizeBytes: number;
|
|
21
|
+
}
|
|
3
22
|
/** One working directory that has adoptable sessions, and which ones. */
|
|
4
23
|
export interface AdoptableSource {
|
|
5
24
|
/** The working directory whose transcript folder holds these sessions. */
|
|
6
25
|
sourceCwd: string;
|
|
7
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Adoptable session ids, newest transcript first.
|
|
28
|
+
*
|
|
29
|
+
* Exactly the ids of {@link sessions}, both built from one array at one site
|
|
30
|
+
* below so they cannot drift. Kept because it is the documented response shape
|
|
31
|
+
* and the narrower thing most callers want.
|
|
32
|
+
*/
|
|
8
33
|
sessionIds: string[];
|
|
34
|
+
/** The same sessions, with what a confirmation dialog needs to describe them. */
|
|
35
|
+
sessions: AdoptableCandidate[];
|
|
9
36
|
}
|
|
10
37
|
/** A candidate that detection deliberately did NOT offer, and why. */
|
|
11
38
|
export interface FilteredSession {
|
|
@@ -14,11 +41,11 @@ export interface FilteredSession {
|
|
|
14
41
|
reason: FilterReason;
|
|
15
42
|
}
|
|
16
43
|
/**
|
|
17
|
-
* Why a candidate was withheld.
|
|
44
|
+
* Why a candidate was withheld. All three values are OURS (paddock-side noise
|
|
18
45
|
* filtering); the engine's own exclusions (sidechain / already-adopted /
|
|
19
46
|
* attributed-to-run) never reach us as candidates at all.
|
|
20
47
|
*/
|
|
21
|
-
export type FilterReason = "too-small" | "slash-command-only";
|
|
48
|
+
export type FilterReason = "too-small" | "slash-command-only" | "sweeper-run";
|
|
22
49
|
/** What a project could import right now. */
|
|
23
50
|
export interface AdoptableSummary {
|
|
24
51
|
/** Total adoptable sessions across every source, after de-dup and filtering. */
|
|
@@ -55,6 +82,33 @@ export declare const MIN_TRANSCRIPT_BYTES = 256;
|
|
|
55
82
|
* more than a command plus its output, comfortably less than a conversation.
|
|
56
83
|
*/
|
|
57
84
|
export declare const SLASH_COMMAND_MAX_BYTES = 4096;
|
|
85
|
+
/**
|
|
86
|
+
* Does this transcript preview look like paddock's own sweeper run (#658)?
|
|
87
|
+
*
|
|
88
|
+
* Exported so `sweeper-prompt-contract.test.ts` can assert it against the prompt
|
|
89
|
+
* `SweepService` REALLY builds. The wording has drifted once already; a copy of
|
|
90
|
+
* it pasted into a test here would go stale silently and take the filter with it.
|
|
91
|
+
*/
|
|
92
|
+
export declare function isSweeperPrompt(preview: string): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Reduce a git remote URL to a comparable `host/path` identity (#659).
|
|
95
|
+
*
|
|
96
|
+
* The same repo is written many ways, and a project's `repo` field and a clone's
|
|
97
|
+
* configured remote routinely disagree on form while naming the same thing:
|
|
98
|
+
*
|
|
99
|
+
* https://github.com/edspencer/hushpod.git ┐
|
|
100
|
+
* git@github.com:edspencer/hushpod ├─ all → github.com/edspencer/hushpod
|
|
101
|
+
* ssh://git@github.com/edspencer/hushpod/ ┘
|
|
102
|
+
*
|
|
103
|
+
* So: drop any scheme, drop `user@`, turn the scp-style `host:path` colon into a
|
|
104
|
+
* slash, drop a trailing `.git` and any trailing slashes, and lowercase. A local
|
|
105
|
+
* path remote (`/srv/git/foo.git`) normalises to `/srv/git/foo` and still
|
|
106
|
+
* compares correctly against itself.
|
|
107
|
+
*
|
|
108
|
+
* Returns `""` for anything that reduces to nothing, which never compares equal
|
|
109
|
+
* to a real remote — the caller does not have to special-case it.
|
|
110
|
+
*/
|
|
111
|
+
export declare function normalizeRemote(url: string): string;
|
|
58
112
|
/** The engine surface this module needs — narrowed so tests can fake it. */
|
|
59
113
|
export interface AdoptableFleet {
|
|
60
114
|
listAdoptableSessions(agentName: string, fromWorkingDir?: string): Promise<AdoptableSession[]>;
|
|
@@ -90,6 +144,13 @@ export declare class AdoptableIndex {
|
|
|
90
144
|
private readonly stateDir;
|
|
91
145
|
/** folder name → last-seen entry, reused while its `key` is unchanged. */
|
|
92
146
|
private readonly folders;
|
|
147
|
+
/**
|
|
148
|
+
* checkout dir → its normalised remotes, memoised on the git config's own
|
|
149
|
+
* mtime+size (#659). Keyed on the config rather than the transcript folder
|
|
150
|
+
* because that is the file whose CHANGE would change the answer: adding or
|
|
151
|
+
* re-pointing a remote must be picked up, and it does not touch `~/.claude`.
|
|
152
|
+
*/
|
|
153
|
+
private readonly remotes;
|
|
93
154
|
/** project key → cached summary + the composite key it was computed under. */
|
|
94
155
|
private readonly summaries;
|
|
95
156
|
constructor(claudeHomePath: string, stateDir: string);
|
|
@@ -108,6 +169,14 @@ export declare class AdoptableIndex {
|
|
|
108
169
|
* folders whose file set changed since last time.
|
|
109
170
|
*/
|
|
110
171
|
private scanFolders;
|
|
172
|
+
/**
|
|
173
|
+
* A checkout's normalised git remotes, memoised on its config's mtime+size.
|
|
174
|
+
*
|
|
175
|
+
* The memo is keyed by directory and re-validated on every call, so a remote
|
|
176
|
+
* added or re-pointed after the last scan is picked up without waiting for
|
|
177
|
+
* anything in `~/.claude` to change.
|
|
178
|
+
*/
|
|
179
|
+
private remotesFor;
|
|
111
180
|
/**
|
|
112
181
|
* The candidate source working directories for a project, de-duplicated by the
|
|
113
182
|
* transcript folder they resolve to.
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
*
|
|
16
16
|
* 1. **Assemble candidate sources** — the project's own `workingDir`, plus any
|
|
17
17
|
* `<claudeHome>/projects/*` folder whose RECORDED cwd matches the project.
|
|
18
|
-
* 2. **Filter low-value noise** — a zero-byte transcript
|
|
19
|
-
* nothing but a `/mcp` slash command
|
|
18
|
+
* 2. **Filter low-value noise** — a zero-byte transcript, a session that is
|
|
19
|
+
* nothing but a `/mcp` slash command, or one of paddock's OWN sweeper runs
|
|
20
|
+
* (#658) is not a chat anyone wants imported.
|
|
20
21
|
* 3. **Cache** — the scan is O(all transcript folders on the machine), so it is
|
|
21
22
|
* keyed on directory mtimes and only redone when something could have moved.
|
|
22
23
|
*
|
|
@@ -35,16 +36,35 @@
|
|
|
35
36
|
*
|
|
36
37
|
* ## The matching heuristic, and what it cannot do
|
|
37
38
|
*
|
|
38
|
-
* For a REPO-BACKED project a folder matches when its recorded cwd's
|
|
39
|
-
* equals the project's checkout name (`repoCheckoutName(repo)`)
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
39
|
+
* For a REPO-BACKED project a folder matches when BOTH hold: its recorded cwd's
|
|
40
|
+
* BASENAME equals the project's checkout name (`repoCheckoutName(repo)`), AND
|
|
41
|
+
* that directory is a git checkout with a remote pointing at the project's repo
|
|
42
|
+
* — i.e. "the user really does have their own clone of this repo elsewhere".
|
|
43
|
+
*
|
|
44
|
+
* The basename test alone used to be the whole rule, documented as deliberately
|
|
45
|
+
* loose. It is unbounded across the filesystem, and on the dogfooding instance it
|
|
46
|
+
* was reaching into a THROWAWAY QA instance's data directory: the `hushpod`
|
|
47
|
+
* project offered 15 chats out of `/data/scratch/paddock-video/data/projects/
|
|
48
|
+
* hushpod`, which belong to a different Paddock instance entirely (#659). "Two
|
|
49
|
+
* unrelated checkouts that share a basename" turns out not to be a corner case on
|
|
50
|
+
* a machine that hosts scratch copies, backups and QA clones.
|
|
51
|
+
*
|
|
52
|
+
* The original objection to checking the remote was cost — "running git in every
|
|
53
|
+
* candidate directory is the wrong cost for a count shown in a header". That
|
|
54
|
+
* objection is answered by not running git: the remote is READ out of
|
|
55
|
+
* `<cwd>/.git/config`, one small file, and only for the handful of directories
|
|
56
|
+
* that already passed the basename test. Results are memoised on the config
|
|
57
|
+
* file's own mtime+size.
|
|
58
|
+
*
|
|
59
|
+
* A candidate with no readable git config, or whose remotes all point somewhere
|
|
60
|
+
* else, is not offered. That is stricter than before by design — a directory that
|
|
61
|
+
* is not a clone of this repo has no claim to be imported into it. The project's
|
|
62
|
+
* OWN working directory is exempt: it is appended unconditionally below, so a
|
|
63
|
+
* project whose checkout has an unusual remote still offers its own history.
|
|
64
|
+
*
|
|
65
|
+
* Still NOT identity-precise, and deliberately so: a clone the user renamed on
|
|
66
|
+
* disk does not match (the basename test runs first), and a fork with a different
|
|
67
|
+
* remote does not match. Both fail CLOSED — nothing is offered that shouldn't be.
|
|
48
68
|
*
|
|
49
69
|
* For a NOTEBOOK project the match is exact cwd equality: its working directory
|
|
50
70
|
* IS the project directory, and nothing else is plausibly "the same project".
|
|
@@ -91,10 +111,55 @@ export const SLASH_COMMAND_MAX_BYTES = 4096;
|
|
|
91
111
|
* `/review the auth refactor` all do.
|
|
92
112
|
*/
|
|
93
113
|
const SLASH_COMMAND_ONLY_RE = /^\s*(?:<command-name>\s*)?\/[a-z][a-z0-9:_-]*(?![^\s<])/i;
|
|
114
|
+
/**
|
|
115
|
+
* A preview that opens with paddock's OWN sweeper prompt (#658).
|
|
116
|
+
*
|
|
117
|
+
* The sweeper is a one-shot `claude -p` subprocess (`sweep.ts` `runSweeper`), so
|
|
118
|
+
* it writes a perfectly ordinary transcript into the project's own chat folder.
|
|
119
|
+
* When no run record binds that session — the sweeper's own history on the
|
|
120
|
+
* dogfooding instance has ten such transcripts with no `session_id` in any
|
|
121
|
+
* `job-*.yaml` — attribution cannot tell it from a session the user typed in a
|
|
122
|
+
* terminal, and paddock ends up offering the user its own curation output as a
|
|
123
|
+
* chat to "import".
|
|
124
|
+
*
|
|
125
|
+
* Matched on the prompt's two stable opening features: the `Project: <name>
|
|
126
|
+
* (slug: <slug>)` header `buildPrompt` always emits first, and the `You are
|
|
127
|
+
* curating` sentence that follows it. Both are required. The header alone is too
|
|
128
|
+
* weak (a user could plausibly open a chat that way), and the sentence alone
|
|
129
|
+
* would match someone quoting the sweeper prompt to talk ABOUT it.
|
|
130
|
+
*
|
|
131
|
+
* Whitespace between the two is `\s+` rather than `\n`: the header is followed by
|
|
132
|
+
* an optional `Summary:` line and a blank line, and the engine's preview
|
|
133
|
+
* normalises runs of whitespace. The wording of the sentence has already drifted
|
|
134
|
+
* once ("curating two files in this project directory" → "curating this
|
|
135
|
+
* project's three context files"), so only the stable `You are curating` stem is
|
|
136
|
+
* matched.
|
|
137
|
+
*
|
|
138
|
+
* The engine truncates a preview at 100 characters, so a project whose name is
|
|
139
|
+
* long enough to push `You are curating` past that boundary will not match and
|
|
140
|
+
* its sweeper chats stay on offer. That is the deliberate direction to fail in:
|
|
141
|
+
* a sweeper chat wrongly offered is noise the user can decline, while a real chat
|
|
142
|
+
* wrongly withheld is history they cannot get at. Withheld candidates are
|
|
143
|
+
* reported under `filtered` for the same reason.
|
|
144
|
+
*/
|
|
145
|
+
const SWEEPER_PROMPT_RE = /^Project: .*\(slug: [^)]*\)\s+(?:Summary:.*\s+)?You are curating\b/;
|
|
146
|
+
/**
|
|
147
|
+
* Does this transcript preview look like paddock's own sweeper run (#658)?
|
|
148
|
+
*
|
|
149
|
+
* Exported so `sweeper-prompt-contract.test.ts` can assert it against the prompt
|
|
150
|
+
* `SweepService` REALLY builds. The wording has drifted once already; a copy of
|
|
151
|
+
* it pasted into a test here would go stale silently and take the filter with it.
|
|
152
|
+
*/
|
|
153
|
+
export function isSweeperPrompt(preview) {
|
|
154
|
+
return SWEEPER_PROMPT_RE.test(preview);
|
|
155
|
+
}
|
|
94
156
|
/** Classify a candidate as noise, or `null` to keep it. */
|
|
95
157
|
function filterReasonFor(session) {
|
|
96
158
|
if (session.sizeBytes < MIN_TRANSCRIPT_BYTES)
|
|
97
159
|
return "too-small";
|
|
160
|
+
if (session.preview !== undefined && isSweeperPrompt(session.preview)) {
|
|
161
|
+
return "sweeper-run";
|
|
162
|
+
}
|
|
98
163
|
if (session.sizeBytes < SLASH_COMMAND_MAX_BYTES &&
|
|
99
164
|
session.preview !== undefined &&
|
|
100
165
|
SLASH_COMMAND_ONLY_RE.test(session.preview)) {
|
|
@@ -102,7 +167,96 @@ function filterReasonFor(session) {
|
|
|
102
167
|
}
|
|
103
168
|
return null;
|
|
104
169
|
}
|
|
105
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* Reduce a git remote URL to a comparable `host/path` identity (#659).
|
|
172
|
+
*
|
|
173
|
+
* The same repo is written many ways, and a project's `repo` field and a clone's
|
|
174
|
+
* configured remote routinely disagree on form while naming the same thing:
|
|
175
|
+
*
|
|
176
|
+
* https://github.com/edspencer/hushpod.git ┐
|
|
177
|
+
* git@github.com:edspencer/hushpod ├─ all → github.com/edspencer/hushpod
|
|
178
|
+
* ssh://git@github.com/edspencer/hushpod/ ┘
|
|
179
|
+
*
|
|
180
|
+
* So: drop any scheme, drop `user@`, turn the scp-style `host:path` colon into a
|
|
181
|
+
* slash, drop a trailing `.git` and any trailing slashes, and lowercase. A local
|
|
182
|
+
* path remote (`/srv/git/foo.git`) normalises to `/srv/git/foo` and still
|
|
183
|
+
* compares correctly against itself.
|
|
184
|
+
*
|
|
185
|
+
* Returns `""` for anything that reduces to nothing, which never compares equal
|
|
186
|
+
* to a real remote — the caller does not have to special-case it.
|
|
187
|
+
*/
|
|
188
|
+
export function normalizeRemote(url) {
|
|
189
|
+
let s = url.trim();
|
|
190
|
+
if (s === "")
|
|
191
|
+
return "";
|
|
192
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); // https://, ssh://, git://
|
|
193
|
+
s = s.replace(/^[^/@]+@/, ""); // git@
|
|
194
|
+
// scp-style `host:owner/repo` — but not a port (`host:22/owner/repo`) and not
|
|
195
|
+
// an absolute local path, neither of which has this shape.
|
|
196
|
+
s = s.replace(/^([^/:]+):(?!\/)/, "$1/");
|
|
197
|
+
s = s.replace(/\.git$/i, "");
|
|
198
|
+
s = s.replace(/\/+$/, "");
|
|
199
|
+
return s.toLowerCase();
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Every remote URL configured in a git checkout, normalised.
|
|
203
|
+
*
|
|
204
|
+
* Reads `.git/config` directly rather than shelling out to git: this runs for
|
|
205
|
+
* candidate directories behind a count rendered in a header, and a subprocess per
|
|
206
|
+
* candidate is exactly the cost that made checking the remote look unaffordable
|
|
207
|
+
* in the first place.
|
|
208
|
+
*
|
|
209
|
+
* Handles a linked WORKTREE, where `.git` is a FILE holding `gitdir: <path>` and
|
|
210
|
+
* the config lives in the MAIN repository's git dir — found by resolving the
|
|
211
|
+
* `commondir` pointer. This repo is developed with worktrees, so a contributor's
|
|
212
|
+
* `wt-*` checkout is a real case, not a hypothetical.
|
|
213
|
+
*
|
|
214
|
+
* Returns `[]` for anything unreadable or not a checkout at all. An INI parse is
|
|
215
|
+
* enough here — `url = …` inside a `[remote "…"]` section — because the question
|
|
216
|
+
* is only "does any remote name this repo", not "what is the exact config".
|
|
217
|
+
*/
|
|
218
|
+
async function gitRemotes(cwd) {
|
|
219
|
+
const dot = path.join(cwd, ".git");
|
|
220
|
+
const st = await fs.stat(dot).catch(() => null);
|
|
221
|
+
if (st === null)
|
|
222
|
+
return [];
|
|
223
|
+
let gitDir = dot;
|
|
224
|
+
if (!st.isDirectory()) {
|
|
225
|
+
const pointer = await fs.readFile(dot, "utf8").catch(() => "");
|
|
226
|
+
const m = /^\s*gitdir:\s*(.+?)\s*$/m.exec(pointer);
|
|
227
|
+
if (m === null)
|
|
228
|
+
return [];
|
|
229
|
+
gitDir = path.resolve(cwd, m[1]);
|
|
230
|
+
// A linked worktree's own git dir has no `config`; `commondir` points at the
|
|
231
|
+
// main repository's git dir, which does.
|
|
232
|
+
const common = await fs.readFile(path.join(gitDir, "commondir"), "utf8").catch(() => null);
|
|
233
|
+
if (common !== null)
|
|
234
|
+
gitDir = path.resolve(gitDir, common.trim());
|
|
235
|
+
}
|
|
236
|
+
const text = await fs.readFile(path.join(gitDir, "config"), "utf8").catch(() => null);
|
|
237
|
+
if (text === null)
|
|
238
|
+
return [];
|
|
239
|
+
const out = [];
|
|
240
|
+
let inRemote = false;
|
|
241
|
+
for (const raw of text.split("\n")) {
|
|
242
|
+
const line = raw.trim();
|
|
243
|
+
if (line.startsWith("[")) {
|
|
244
|
+
inRemote = /^\[remote\b/i.test(line);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (!inRemote)
|
|
248
|
+
continue;
|
|
249
|
+
const m = /^url\s*=\s*(.+)$/i.exec(line);
|
|
250
|
+
if (m === null)
|
|
251
|
+
continue;
|
|
252
|
+
const url = normalizeRemote(m[1]);
|
|
253
|
+
if (url !== "")
|
|
254
|
+
out.push(url);
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
/** A path's cache identity: its mtime + size, or `"-"` when absent. Used for
|
|
259
|
+
* directories (has the file SET changed) and for single files alike. */
|
|
106
260
|
async function dirKey(dir) {
|
|
107
261
|
const st = await fs.stat(dir).catch(() => null);
|
|
108
262
|
return st ? `${st.mtimeMs}:${st.size}` : "-";
|
|
@@ -138,6 +292,13 @@ export class AdoptableIndex {
|
|
|
138
292
|
stateDir;
|
|
139
293
|
/** folder name → last-seen entry, reused while its `key` is unchanged. */
|
|
140
294
|
folders = new Map();
|
|
295
|
+
/**
|
|
296
|
+
* checkout dir → its normalised remotes, memoised on the git config's own
|
|
297
|
+
* mtime+size (#659). Keyed on the config rather than the transcript folder
|
|
298
|
+
* because that is the file whose CHANGE would change the answer: adding or
|
|
299
|
+
* re-pointing a remote must be picked up, and it does not touch `~/.claude`.
|
|
300
|
+
*/
|
|
301
|
+
remotes = new Map();
|
|
141
302
|
/** project key → cached summary + the composite key it was computed under. */
|
|
142
303
|
summaries = new Map();
|
|
143
304
|
constructor(claudeHomePath, stateDir) {
|
|
@@ -194,6 +355,29 @@ export class AdoptableIndex {
|
|
|
194
355
|
this.folders.delete(name);
|
|
195
356
|
return out;
|
|
196
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* A checkout's normalised git remotes, memoised on its config's mtime+size.
|
|
360
|
+
*
|
|
361
|
+
* The memo is keyed by directory and re-validated on every call, so a remote
|
|
362
|
+
* added or re-pointed after the last scan is picked up without waiting for
|
|
363
|
+
* anything in `~/.claude` to change.
|
|
364
|
+
*/
|
|
365
|
+
async remotesFor(cwd) {
|
|
366
|
+
// Cheap enough to re-key every time: two `stat`s. Both halves are needed —
|
|
367
|
+
// `.git/config` is the file that answers the question for an ordinary
|
|
368
|
+
// checkout, and `.git` itself covers a linked worktree, where `.git` is a
|
|
369
|
+
// FILE and `.git/config` does not exist at all.
|
|
370
|
+
const key = [
|
|
371
|
+
await dirKey(path.join(cwd, ".git")),
|
|
372
|
+
await dirKey(path.join(cwd, ".git", "config")),
|
|
373
|
+
].join("|");
|
|
374
|
+
const cached = this.remotes.get(cwd);
|
|
375
|
+
if (cached && cached.key === key)
|
|
376
|
+
return cached.urls;
|
|
377
|
+
const urls = await gitRemotes(cwd);
|
|
378
|
+
this.remotes.set(cwd, { key, urls });
|
|
379
|
+
return urls;
|
|
380
|
+
}
|
|
197
381
|
/**
|
|
198
382
|
* The candidate source working directories for a project, de-duplicated by the
|
|
199
383
|
* transcript folder they resolve to.
|
|
@@ -205,24 +389,34 @@ export class AdoptableIndex {
|
|
|
205
389
|
const own = project.workingDir;
|
|
206
390
|
const ownFolder = path.join(this.claudeHomePath, "projects", encodePathForCli(own));
|
|
207
391
|
const ownReal = await fs.realpath(ownFolder).catch(() => ownFolder);
|
|
208
|
-
const
|
|
392
|
+
const repo = project.repo !== undefined && project.repo !== "" ? project.repo : null;
|
|
393
|
+
const wantRemote = repo === null ? "" : normalizeRemote(repo);
|
|
394
|
+
/** The CHEAP test: worth reading a git config for? */
|
|
395
|
+
const nameMatches = (cwd) => {
|
|
209
396
|
if (cwd === own)
|
|
210
397
|
return true;
|
|
211
|
-
// Repo-backed: "the user's own clone of the same repo"
|
|
212
|
-
//
|
|
213
|
-
if (
|
|
214
|
-
return path.basename(cwd) === repoCheckoutName(
|
|
215
|
-
}
|
|
398
|
+
// Repo-backed: "the user's own clone of the same repo". A necessary but
|
|
399
|
+
// NOT sufficient condition — `remoteMatches` below is the other half.
|
|
400
|
+
if (repo !== null)
|
|
401
|
+
return path.basename(cwd) === repoCheckoutName(repo);
|
|
216
402
|
// Notebook: exact cwd equality only.
|
|
217
403
|
return false;
|
|
218
404
|
};
|
|
219
405
|
const seen = new Set([ownReal]);
|
|
220
406
|
const sources = [];
|
|
221
407
|
for (const folder of folders) {
|
|
222
|
-
if (folder.cwd === null || !
|
|
408
|
+
if (folder.cwd === null || !nameMatches(folder.cwd))
|
|
223
409
|
continue;
|
|
224
410
|
if (seen.has(folder.realPath))
|
|
225
411
|
continue;
|
|
412
|
+
// A same-named directory has to prove it is a clone of THIS repo (#659).
|
|
413
|
+
// Only reached for repo-backed projects, and only after the basename test,
|
|
414
|
+
// so this is a handful of small file reads rather than a filesystem sweep.
|
|
415
|
+
if (repo !== null && folder.cwd !== own) {
|
|
416
|
+
const urls = await this.remotesFor(folder.cwd);
|
|
417
|
+
if (!urls.includes(wantRemote))
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
226
420
|
seen.add(folder.realPath);
|
|
227
421
|
sources.push(folder.cwd);
|
|
228
422
|
}
|
|
@@ -269,7 +463,7 @@ export class AdoptableIndex {
|
|
|
269
463
|
// Fault-isolated per source: an unreadable folder costs its own sessions,
|
|
270
464
|
// not the whole count.
|
|
271
465
|
const sessions = await fleet.listAdoptableSessions(agentName, sourceCwd).catch(() => []);
|
|
272
|
-
const
|
|
466
|
+
const candidates = [];
|
|
273
467
|
for (const session of sessions) {
|
|
274
468
|
// De-dup by session id. Two candidate cwds can collide onto one folder
|
|
275
469
|
// under the lossy encoding, and a transcript may have been copied; either
|
|
@@ -282,11 +476,23 @@ export class AdoptableIndex {
|
|
|
282
476
|
filtered.push({ sessionId: session.sessionId, sourceCwd, reason });
|
|
283
477
|
continue;
|
|
284
478
|
}
|
|
285
|
-
|
|
479
|
+
candidates.push({
|
|
480
|
+
sessionId: session.sessionId,
|
|
481
|
+
mtime: session.mtime,
|
|
482
|
+
preview: session.preview,
|
|
483
|
+
autoName: session.autoName,
|
|
484
|
+
sizeBytes: session.sizeBytes,
|
|
485
|
+
});
|
|
286
486
|
}
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
487
|
+
if (candidates.length > 0) {
|
|
488
|
+
// Both projections built HERE, from one array, so `sessionIds` can never
|
|
489
|
+
// disagree with `sessions`.
|
|
490
|
+
out.push({
|
|
491
|
+
sourceCwd,
|
|
492
|
+
sessionIds: candidates.map((c) => c.sessionId),
|
|
493
|
+
sessions: candidates,
|
|
494
|
+
});
|
|
495
|
+
count += candidates.length;
|
|
290
496
|
}
|
|
291
497
|
}
|
|
292
498
|
const summary = { count, sources: out, filtered };
|
|
@@ -134,6 +134,24 @@ export declare class HerdctlService {
|
|
|
134
134
|
* {@link cancel}). Entries are removed when the turn's stream ends.
|
|
135
135
|
*/
|
|
136
136
|
private liveSessions;
|
|
137
|
+
/**
|
|
138
|
+
* Background-phase turns keyed by the synthetic job id the sink minted for
|
|
139
|
+
* them, valued by the chat session they belong to (paddock#528).
|
|
140
|
+
*
|
|
141
|
+
* After a turn's primary `result` lands, {@link chatSession} returns and drops
|
|
142
|
+
* its {@link liveSessions} entry — but the session itself may live on, held
|
|
143
|
+
* open by the reaper because the turn launched background work, streaming
|
|
144
|
+
* autonomous re-invocation turns. Those turns are real, running, and shown as
|
|
145
|
+
* such; until now they were also uncancellable, because nothing registered
|
|
146
|
+
* them anywhere Stop could reach.
|
|
147
|
+
*
|
|
148
|
+
* `interrupt()` would be the wrong tool even if it could: it targets an
|
|
149
|
+
* in-flight model turn, and the wedge case is a session sitting idle holding
|
|
150
|
+
* background work that will never finish. So these route to a force-reap
|
|
151
|
+
* instead — end the session, let the stream end, and let the ordinary unwind
|
|
152
|
+
* emit `chat:complete`. Entries are removed when the background stream ends.
|
|
153
|
+
*/
|
|
154
|
+
private backgroundTurns;
|
|
137
155
|
/**
|
|
138
156
|
* Incremental index of `<stateDir>/jobs`, backing the unread-badge reads
|
|
139
157
|
* (`lastTurnCompletedAt*`) — one per service, reused across requests so a warm
|
|
@@ -325,12 +343,61 @@ export declare class HerdctlService {
|
|
|
325
343
|
*/
|
|
326
344
|
adoptChats(project: Project, opts?: {
|
|
327
345
|
sourceCwd?: string;
|
|
346
|
+
/**
|
|
347
|
+
* Import only these sessions (#660). Anything else the engine adopts along
|
|
348
|
+
* the way is released again, exactly as a noise-filtered session is — see
|
|
349
|
+
* the "honouring the noise filter" note above, which this reuses wholesale.
|
|
350
|
+
* Undefined means "everything on offer", which is what a confirmed
|
|
351
|
+
* select-all sends and what the CLI has always sent.
|
|
352
|
+
*/
|
|
353
|
+
sessionIds?: string[];
|
|
328
354
|
mode?: AdoptionPlacementMode;
|
|
329
355
|
dryRun?: boolean;
|
|
330
356
|
}): Promise<{
|
|
331
357
|
adopted: string[];
|
|
332
358
|
skipped: AdoptSkipped[];
|
|
333
359
|
}>;
|
|
360
|
+
/**
|
|
361
|
+
* Did an import of `sessionId` from `fromWorkingDir` place a NEW file?
|
|
362
|
+
*
|
|
363
|
+
* The same test `releaseWithheld` makes before deleting anything: an in-place
|
|
364
|
+
* adoption resolves the destination and the origin to one path, so there is no
|
|
365
|
+
* copy and nothing that may be removed.
|
|
366
|
+
*/
|
|
367
|
+
private placedACopy;
|
|
368
|
+
/**
|
|
369
|
+
* What the LAST import into each workspace did, so it can be undone (#660).
|
|
370
|
+
*
|
|
371
|
+
* Keyed by workspace key (which may be `""` — the root). Holds the sessions
|
|
372
|
+
* that import actually brought in, and of those, the ones where it PLACED a
|
|
373
|
+
* copy and may therefore delete one again.
|
|
374
|
+
*
|
|
375
|
+
* Server-side rather than a token the client hands back: undo deletes files, so
|
|
376
|
+
* the set of paths it may touch must come from what this process actually did,
|
|
377
|
+
* not from a request body. A restart forgets it, which is the right failure —
|
|
378
|
+
* the offer to undo is a transient toast, not a durable feature.
|
|
379
|
+
*
|
|
380
|
+
* Only the most recent import per workspace is kept. Undoing an import two
|
|
381
|
+
* imports ago is not offered and would be a lie to describe as "undo".
|
|
382
|
+
*/
|
|
383
|
+
private readonly lastImport;
|
|
384
|
+
/**
|
|
385
|
+
* Undo the import this process most recently performed into `project` (#660).
|
|
386
|
+
*
|
|
387
|
+
* Releases each adoption and deletes the copies that import placed, leaving the
|
|
388
|
+
* user's own `~/.claude` history exactly as it was. Returns the sessions it
|
|
389
|
+
* released; an empty array means there was nothing to undo (nothing imported
|
|
390
|
+
* yet, already undone, or the server restarted since).
|
|
391
|
+
*
|
|
392
|
+
* `sessionIds` narrows it to part of that import — the rest stays. Ids that
|
|
393
|
+
* were not part of it are ignored rather than rejected: undo is a best-effort
|
|
394
|
+
* safety net, and the alternative is a 400 for a button the user pressed once.
|
|
395
|
+
*/
|
|
396
|
+
unadoptChats(project: Project, opts?: {
|
|
397
|
+
sessionIds?: string[];
|
|
398
|
+
}): Promise<{
|
|
399
|
+
released: string[];
|
|
400
|
+
}>;
|
|
334
401
|
/**
|
|
335
402
|
* Undo the adoption of a session detection had withheld, restoring the state
|
|
336
403
|
* that existed before this import. See {@link adoptChats} for why the file
|
|
@@ -452,11 +519,24 @@ export declare class HerdctlService {
|
|
|
452
519
|
}): Promise<{
|
|
453
520
|
sessionId: string | null;
|
|
454
521
|
}>;
|
|
522
|
+
/**
|
|
523
|
+
* Register a background-phase turn so Stop can reach it (paddock#528). Called
|
|
524
|
+
* by the background turn sink once it knows both the synthetic job id it
|
|
525
|
+
* published and the session that stream belongs to. Re-registering the same
|
|
526
|
+
* job id with a later session id is fine — the sink refreshes the mapping if
|
|
527
|
+
* the stream resolves a different session.
|
|
528
|
+
*/
|
|
529
|
+
registerBackgroundTurn(jobId: string, sessionId: string): void;
|
|
530
|
+
/** Forget a background-phase turn once its stream has ended. */
|
|
531
|
+
unregisterBackgroundTurn(jobId: string): void;
|
|
455
532
|
/**
|
|
456
533
|
* Cancel a running turn (Stop button → WS chat:cancel). Handles BOTH drive
|
|
457
534
|
* modes off the single id the client holds as `jobId`:
|
|
458
535
|
* - **session mode** — the id is a synthetic turn id in {@link liveSessions};
|
|
459
536
|
* interrupt the live `RuntimeSession` (there is no herdctl job to cancel).
|
|
537
|
+
* - **background phase** — the id is a synthetic id in
|
|
538
|
+
* {@link backgroundTurns}; there is no model turn to interrupt, so reap the
|
|
539
|
+
* session (paddock#528).
|
|
460
540
|
* - **batch mode** — the id is a real herdctl job id; abort it via `cancelJob`
|
|
461
541
|
* (which kills the CLI subprocess / aborts the SDK query).
|
|
462
542
|
*
|