@edspencer/paddock 0.59.0 → 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.
Files changed (77) hide show
  1. package/package.json +2 -2
  2. package/packages/server/dist/adoptable.d.ts +72 -3
  3. package/packages/server/dist/adoptable.js +231 -25
  4. package/packages/server/dist/cli/args.d.ts +56 -0
  5. package/packages/server/dist/cli/args.js +159 -0
  6. package/packages/server/dist/cli/paddock.d.ts +1 -30
  7. package/packages/server/dist/cli/paddock.js +6 -146
  8. package/packages/server/dist/herdctl.d.ts +80 -0
  9. package/packages/server/dist/herdctl.js +159 -0
  10. package/packages/server/dist/routes/chats.js +59 -3
  11. package/packages/server/dist/run-provenance.d.ts +11 -0
  12. package/packages/server/dist/run-provenance.js +18 -0
  13. package/packages/server/dist/ws-turn.d.ts +0 -13
  14. package/packages/server/dist/ws-turn.js +50 -0
  15. package/packages/server/package.json +1 -1
  16. package/packages/web/dist/assets/{InstanceConfigPage-Cli_WHsM.js → InstanceConfigPage-xn9Rvoah.js} +1 -1
  17. package/packages/web/dist/assets/{MarkdownRenderer-BYK9nmr4.js → MarkdownRenderer-DMONssKA.js} +1 -1
  18. package/packages/web/dist/assets/{ProjectRedirect-DWcMpdZJ.js → ProjectRedirect-DHoWtvtY.js} +1 -1
  19. package/packages/web/dist/assets/ProjectView-BmSdUwY2.js +13 -0
  20. package/packages/web/dist/assets/{ProjectsGrid-Bf2cx9-Z.js → ProjectsGrid-CjfwpeFj.js} +1 -1
  21. package/packages/web/dist/assets/{arc-CZ9CFd-g.js → arc-DdFdbD-c.js} +1 -1
  22. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BzOXUlLu.js → architectureDiagram-3BPJPVTR-DC8UKtwV.js} +1 -1
  23. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Bnkcx-Fn.js → blockDiagram-GPEHLZMM-DqFFe_5c.js} +1 -1
  24. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-C-nqckPS.js → c4Diagram-AAUBKEIU-DbffFSM-.js} +1 -1
  25. package/packages/web/dist/assets/channel-QxsJGL6F.js +2 -0
  26. package/packages/web/dist/assets/{chunk-2J33WTMH-jVn7J_5N.js → chunk-2J33WTMH-8PP2RHft.js} +1 -1
  27. package/packages/web/dist/assets/{chunk-4BX2VUAB-D5eys7Hi.js → chunk-4BX2VUAB-Hag68B5N.js} +1 -1
  28. package/packages/web/dist/assets/{chunk-55IACEB6-CqpdXIoL.js → chunk-55IACEB6-BXp_gQIa.js} +1 -1
  29. package/packages/web/dist/assets/{chunk-727SXJPM-SQikk09l.js → chunk-727SXJPM-DlntjQux.js} +1 -1
  30. package/packages/web/dist/assets/{chunk-AQP2D5EJ-rfBNpUwD.js → chunk-AQP2D5EJ-BhI9KPrE.js} +1 -1
  31. package/packages/web/dist/assets/{chunk-FMBD7UC4-CnL0tA20.js → chunk-FMBD7UC4-WhfORs2z.js} +1 -1
  32. package/packages/web/dist/assets/{chunk-ND2GUHAM-CZGi4swC.js → chunk-ND2GUHAM-CIskmv20.js} +1 -1
  33. package/packages/web/dist/assets/{chunk-QZHKN3VN-Du4WKuCj.js → chunk-QZHKN3VN-CNOTpCW4.js} +1 -1
  34. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CI7p84hR.js +2 -0
  35. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CI7p84hR.js +2 -0
  36. package/packages/web/dist/assets/{core-LJ--cHnT.js → core-usOVRkLM.js} +1 -1
  37. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CEovKrYZ.js → cose-bilkent-S5V4N54A-CniuMIIW.js} +1 -1
  38. package/packages/web/dist/assets/{dagre-BM42HDAG-jQhK8147.js → dagre-BM42HDAG-5CteYfQY.js} +1 -1
  39. package/packages/web/dist/assets/{diagram-2AECGRRQ-BmiauiWQ.js → diagram-2AECGRRQ-BIQjD2LA.js} +1 -1
  40. package/packages/web/dist/assets/{diagram-5GNKFQAL-VYdTkqGm.js → diagram-5GNKFQAL-1vy3ErTt.js} +1 -1
  41. package/packages/web/dist/assets/{diagram-KO2AKTUF-DcOTKmwo.js → diagram-KO2AKTUF-D45iqFk6.js} +1 -1
  42. package/packages/web/dist/assets/{diagram-LMA3HP47-DaehIKcD.js → diagram-LMA3HP47-DNktDktn.js} +1 -1
  43. package/packages/web/dist/assets/{diagram-OG6HWLK6-DTqk6D4N.js → diagram-OG6HWLK6-BQ9JgV70.js} +1 -1
  44. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Dj4rI8M1.js → erDiagram-TEJ5UH35-oBasZ1uv.js} +1 -1
  45. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-D0FWSohH.js → flowDiagram-I6XJVG4X-DxnJHMBS.js} +1 -1
  46. package/packages/web/dist/assets/{format-CgkZTf5F.js → format-CIAat6sU.js} +1 -1
  47. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DgaRh12R.js → ganttDiagram-6RSMTGT7-CtJQ2m7z.js} +1 -1
  48. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-5qiry4ao.js → gitGraphDiagram-PVQCEYII-5RPscwD_.js} +1 -1
  49. package/packages/web/dist/assets/index-B6etJCHf.css +1 -0
  50. package/packages/web/dist/assets/{index-CcYeBUNk.js → index-DtRblEY_.js} +4 -4
  51. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CZmDwouw.js → infoDiagram-5YYISTIA-zQu3xkW4.js} +1 -1
  52. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-knkHstDa.js → ishikawaDiagram-YF4QCWOH-ChoEjcdw.js} +1 -1
  53. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-ByfdmIVy.js → journeyDiagram-JHISSGLW-DM2KWmDr.js} +1 -1
  54. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-DSzly2xb.js → kanban-definition-UN3LZRKU-DWzTpYgh.js} +1 -1
  55. package/packages/web/dist/assets/{linear-BX6qYIpB.js → linear-Bpy9Aj-K.js} +1 -1
  56. package/packages/web/dist/assets/{mermaid.core-BXPz6_-1.js → mermaid.core-D43x5bFb.js} +4 -4
  57. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-fzfZWy91.js → mindmap-definition-RKZ34NQL-BhceB-cv.js} +1 -1
  58. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-M99j1K9t.js → pieDiagram-4H26LBE5-BgcGlFFA.js} +1 -1
  59. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B7fKq1ce.js → quadrantDiagram-W4KKPZXB-ZRVaOoSp.js} +1 -1
  60. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-xCBFrXav.js → requirementDiagram-4Y6WPE33-2hHcJHBt.js} +1 -1
  61. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-Dp9KLMhd.js → sankeyDiagram-5OEKKPKP-KPUQ9Mu5.js} +1 -1
  62. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CNNVWpta.js → sequenceDiagram-3UESZ5HK-D4BXQSSM.js} +1 -1
  63. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CzKRXREb.js → stateDiagram-AJRCARHV-DftS7dIo.js} +1 -1
  64. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CgBC1jpO.js +2 -0
  65. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DfvQTe5J.js → timeline-definition-PNZ67QCA-C0OAxyWY.js} +1 -1
  66. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-2PBM01eq.js → vennDiagram-CIIHVFJN-C94fINN3.js} +1 -1
  67. package/packages/web/dist/assets/{wardley-L42UT6IY-DC1EIjci.js → wardley-L42UT6IY-CbFrlwHE.js} +1 -1
  68. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-4kYoKkfS.js → wardleyDiagram-YWT4CUSO-CHG2nndd.js} +1 -1
  69. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Bin8O1V8.js → xychartDiagram-2RQKCTM6-U9ESc19z.js} +1 -1
  70. package/packages/web/dist/index.html +2 -2
  71. package/packages/web/dist/sw.js +1 -1
  72. package/packages/web/dist/assets/ProjectView-CvD9WQsW.js +0 -13
  73. package/packages/web/dist/assets/channel-By51cUN7.js +0 -2
  74. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-sIDgFl-L.js +0 -2
  75. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-sIDgFl-L.js +0 -2
  76. package/packages/web/dist/assets/index-CvGiQddO.css +0 -1
  77. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-LKJwobEd.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.59.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.29.1",
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
- /** Adoptable session ids, newest transcript first. */
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. Both values are OURS (paddock-side noise
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 or a session that is
19
- * nothing but a `/mcp` slash command is not a chat anyone wants imported.
18
+ * 2. **Filter low-value noise** — a zero-byte transcript, a session that is
19
+ * nothing but a `/mcp` slash command, or one of paddock's OWN sweeper runs
20
+ * (#658) is not a chat anyone wants imported.
20
21
  * 3. **Cache** — the scan is O(all transcript folders on the machine), so it is
21
22
  * keyed on directory mtimes and only redone when something could have moved.
22
23
  *
@@ -35,16 +36,35 @@
35
36
  *
36
37
  * ## The matching heuristic, and what it cannot do
37
38
  *
38
- * For a REPO-BACKED project a folder matches when its recorded cwd's BASENAME
39
- * equals the project's checkout name (`repoCheckoutName(repo)`) — i.e. "the user
40
- * has their own clone of this repo elsewhere". This is deliberately simple and
41
- * is NOT identity-precise: two unrelated checkouts that happen to share a
42
- * basename (`~/work/api` and `~/oss/api`) both match, and a clone the user
43
- * renamed on disk does not match at all. Verifying the checkout's git remote
44
- * would be exact; it also means running git in every candidate directory, which
45
- * is the wrong cost for a count shown in a header. Noted as a known limit
46
- * (#588 gotcha 6) rather than pretended away — the import is per-source, so a
47
- * user who sees a source they don't recognise can decline it.
39
+ * For a REPO-BACKED project a folder matches when BOTH hold: its recorded cwd's
40
+ * BASENAME equals the project's checkout name (`repoCheckoutName(repo)`), AND
41
+ * that directory is a git checkout with a remote pointing at the project's repo
42
+ * — i.e. "the user really does have their own clone of this repo elsewhere".
43
+ *
44
+ * The basename test alone used to be the whole rule, documented as deliberately
45
+ * loose. It is unbounded across the filesystem, and on the dogfooding instance it
46
+ * was reaching into a THROWAWAY QA instance's data directory: the `hushpod`
47
+ * project offered 15 chats out of `/data/scratch/paddock-video/data/projects/
48
+ * hushpod`, which belong to a different Paddock instance entirely (#659). "Two
49
+ * unrelated checkouts that share a basename" turns out not to be a corner case on
50
+ * a machine that hosts scratch copies, backups and QA clones.
51
+ *
52
+ * The original objection to checking the remote was cost — "running git in every
53
+ * candidate directory is the wrong cost for a count shown in a header". That
54
+ * objection is answered by not running git: the remote is READ out of
55
+ * `<cwd>/.git/config`, one small file, and only for the handful of directories
56
+ * that already passed the basename test. Results are memoised on the config
57
+ * file's own mtime+size.
58
+ *
59
+ * A candidate with no readable git config, or whose remotes all point somewhere
60
+ * else, is not offered. That is stricter than before by design — a directory that
61
+ * is not a clone of this repo has no claim to be imported into it. The project's
62
+ * OWN working directory is exempt: it is appended unconditionally below, so a
63
+ * project whose checkout has an unusual remote still offers its own history.
64
+ *
65
+ * Still NOT identity-precise, and deliberately so: a clone the user renamed on
66
+ * disk does not match (the basename test runs first), and a fork with a different
67
+ * remote does not match. Both fail CLOSED — nothing is offered that shouldn't be.
48
68
  *
49
69
  * For a NOTEBOOK project the match is exact cwd equality: its working directory
50
70
  * IS the project directory, and nothing else is plausibly "the same project".
@@ -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
- /** A directory's cache identity: its mtime + size, or `null` when absent. */
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 matches = (cwd) => {
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", matched by the
212
- // checkout basename. Deliberately loose — see the module header.
213
- if (project.repo !== undefined && project.repo !== "") {
214
- return path.basename(cwd) === repoCheckoutName(project.repo);
215
- }
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 || !matches(folder.cwd))
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 ids = [];
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
- ids.push(session.sessionId);
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 (ids.length > 0) {
288
- out.push({ sourceCwd, sessionIds: ids });
289
- count += ids.length;
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 };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure argument parsing and message formatting for the `paddock` CLI.
3
+ *
4
+ * ## Why this is its own module
5
+ *
6
+ * These functions want unit tests, and the entrypoint they used to live in
7
+ * cannot be imported without running. The first attempt kept them in
8
+ * `paddock.ts` behind a run-directly guard:
9
+ *
10
+ * ```ts
11
+ * if (pathToFileURL(process.argv[1]).href === import.meta.url) main();
12
+ * ```
13
+ *
14
+ * **That guard shipped a silent no-op to npm.** npm installs a `bin` as a
15
+ * SYMLINK at `node_modules/.bin/paddock`, so `process.argv[1]` is the symlink
16
+ * path while `import.meta.url` is the module's realpath. They never match, the
17
+ * condition is always false, and `npx @edspencer/paddock` printed nothing and
18
+ * exited 0 — in 0.57.0, 0.58.0 and 0.59.0. It went unnoticed because the manual
19
+ * checks invoked `node <file>` directly, which is the one path where the guard
20
+ * does hold.
21
+ *
22
+ * `realpathSync(argv[1])` would fix that instance. Splitting the module removes
23
+ * the need for the guard entirely, so there is no condition left to get wrong on
24
+ * the next shim, platform or package manager. `paddock.ts` now always runs.
25
+ */
26
+ export interface CliOptions {
27
+ port?: string;
28
+ host?: string;
29
+ dataDir?: string;
30
+ here: boolean;
31
+ open: boolean;
32
+ verbose: boolean;
33
+ help: boolean;
34
+ version: boolean;
35
+ }
36
+ /** A usage error. Thrown rather than exiting, so parsing stays testable. */
37
+ export declare class CliError extends Error {
38
+ }
39
+ export declare const MIN_NODE_MAJOR = 22;
40
+ export declare function parseArgs(argv: string[]): CliOptions;
41
+ /**
42
+ * Why this Node is unusable, or `undefined` if it is fine.
43
+ *
44
+ * `engines` only warns by default, and the failure it eventually produces is an
45
+ * unexplained syntax error deep inside a dependency.
46
+ */
47
+ export declare function nodeVersionProblem(nodeVersion: string): string | undefined;
48
+ /**
49
+ * Translate a listen failure into something a human can act on.
50
+ *
51
+ * `EADDRINUSE` on 4000 is the likeliest first-run failure — a popular port and
52
+ * Paddock's own default — and Node's raw error names neither the port nor the
53
+ * flag that fixes it.
54
+ */
55
+ export declare function explainListenError(err: unknown, host: string, port: string): string;
56
+ export declare const USAGE = "paddock \u2014 run a Paddock instance locally\n\nUsage\n npx @edspencer/paddock [options]\n\nOptions\n -p, --port <port> HTTP/WS port (default 4000, or $PORT)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Projects + state (default ~/.paddock, or $PADDOCK_DATA_DIR)\n --here Open the CURRENT directory as the workspace (see below)\n -o, --open Open the app in your browser once it is listening\n --verbose Show the server's own logs (quiet by default)\n -v, --version Print the Paddock version and exit\n -h, --help Show this help\n\nOpening a directory (--here)\n Run `paddock --here` inside a project and Paddock opens THAT directory as its\n workspace: Claude works in your files, and any Claude Code sessions you already\n have for the directory are offered for import. It:\n\n \u00B7 creates .paddock/ in the directory for this workspace's own state\n \u00B7 creates .chats/ for transcripts, and adds both to .gitignore\n \u00B7 links ~/.claude/projects/<encoded-dir> at this workspace\n\n Once done, later runs in the same directory resume it \u2014 no flag needed.\n Without --here, Paddock never touches the directory you ran it from.\n\nCredentials\n Paddock drives Claude Code, so it needs Claude credentials in the\n environment: CLAUDE_CODE_OAUTH_TOKEN (Max/Pro) or ANTHROPIC_API_KEY (API\n billing). If you already use Claude Code on this machine, an existing login\n under ~/.claude is picked up automatically. Otherwise run `claude setup-token`.\n\nYour data\n Everything lives in one directory \u2014 ~/.paddock unless you pass --data-dir.\n Projects, chat transcripts and settings all persist there between runs. Move\n that directory to move your instance; delete it to start over. Nothing is\n stored anywhere else.\n\nNotes\n Binds loopback with authentication disabled, which is safe for a laptop. To\n expose it on a network, set PADDOCK_AUTH_MODE first \u2014 Paddock refuses to bind\n a routable interface wide open. See AUTH.md.\n\nDocs: https://github.com/edspencer/paddock";