Skip to content

feat(server): GET /api/discover — find directories with existing Claude Code history (#745) - #799

Merged
edspencer merged 1 commit into
mainfrom
feat/745-discover-api
Aug 9, 2026
Merged

feat(server): GET /api/discover — find directories with existing Claude Code history (#745)#799
edspencer merged 1 commit into
mainfrom
feat/745-discover-api

Conversation

@edspencer

Copy link
Copy Markdown
Owner

The server half of #745. Two read-only, instance-level endpoints and the
heuristic behind them, with tests. No UI — a separate change builds the
Discover view against this API. --here (#798) is untouched.

Endpoints

GET /api/discover[?includeNonGit=1][?includeOutsideHome=1]
 → { claudeHome, homeDir, scanned, candidates[], excluded{} }

GET /api/discover/sessions?dir=<directory>
 → { path, sessions[], filtered[] }   400 on anything not discovered

A candidate carries path (resolved — and therefore exactly what the created
project's workingDir becomes), recordedPath when the transcripts spell it
differently, name, suggestedSlug, hasGit, gitRemote, insideHome,
sessionCount, filteredCount, lastSessionAt. A session carries
sessionId / mtime / preview / autoName / sizeBytes — the same shape
GET …/adoptable-chats returns, so AdoptChatsModal's idiom transfers and
sessionId feeds straight into adopt-chats's sessionIds.

Why two paths, not one with ?dir=

#745 sketches a single /api/discover that changes shape when dir is present.
Paddock publishes an OpenAPI 3 document generated from these Fastify route
schemas
, and one path cannot describe two different 200 bodies there — it
would have to be declared shapeless, and the published contract would silently
stop describing the endpoint. The two also fail differently: the listing cannot
400, the expansion must.

The heuristic

A naive scan is unusable — ~166 transcript folders on a real machine, ~150
ephemeral, plus /, ~/Downloads, /tmp. Rules run cheap-to-expensive so the
~150 die on string comparisons before anything reads a transcript:

# rule kind
1 no-recorded-cwd — no cwd recoverable from any transcript hard
2 missing — gone, or not a directory hard
3 system-path/ + the #720 denylist, on both the resolved and the as-written spelling (/proc/self/cwd) hard
4 temp-root/tmp, /var/folders, $TMPDIRthe ~150 hard
5 paddock-internal — inside the projects root, data dir, or either Claude home hard
6 home-root$HOME itself hard
7 outside-home soft (includeOutsideHome=1)
8 already-managed — overlaps an existing project's cwd, either direction derived
9 no-git — one stat soft (includeNonGit=1)
10 no-sessions — nothing survives the noise filter expensive, runs last

Then: group by resolved real path (one directory, however many transcript
folders record it), rank by non-noise session count → recency → path.

excluded reports how many directories each rule ate, which is what lets a
container that legitimately finds nothing say why instead of rendering blank,
and lets the UI offer the soft toggles only when they'd reveal something.

Reuses adoptable.ts rather than reimplementing it — recordedCwd,
filterReasonFor, MIN_TRANSCRIPT_BYTES, and the mtime-cached folder scan with
its #620 legacy mirroring (without which Discovery would find nothing on a
laptop). A row's count and the import dialog's offer are therefore the same
number by construction. The recorded cwd is always read from a transcript;
encodePathForCli is never inverted.

Security

POST …/adopt-chats is not loosened. Its 400 on an unrecognised sourceCwd
is untouched. This is a separate surface with its own containment:

Exposure noted in the module header: the listing reveals which directories this
machine's user has run Claude Code in. Narrower than a file browser, but new.

Answers to #745's open questions

  • Require .git, or rank it first? Require by default (it is the rule that
    does the work), includeNonGit=1 to relax, always report the withheld count —
    so the notebook case stays reachable without a page of ~/Downloads.
  • Slug collisions on a shared basename? Solved server-side: suggestedSlug
    is slugify'd, qualified by the parent directory, and unique against both
    existing project slugs and the rest of the result set.
  • A manual "add a directory by path" row? Needs nothing here —
    POST /api/projects already takes an arbitrary path.

Tests

32 unit (test/unit/discover.test.ts) — a table over a real fixture tree,
one case per rule, plus soft-rule relaxation, cheap-before-expensive ordering
(asserted by watching which directories cost a session read), folder grouping +
session de-dup, symlink resolution, ranking, noise accounting, slug collisions,
and five cases on the ?dir= boundary.

7 integration (test/integration/discover.test.ts) — the real app, real
transcripts in a real second Claude home. The last one asserts #745's claim that
only steps 1–2 are new, rather than assuming it: discover → POST /api/projects
POST …/adopt-chats with a sessionIds subset → the chat lists → the
directory drops out of the next scan.

Full server suite: 160 files / 2085 tests green, typecheck clean.

Closes nothing — #745 stays open for the UI half.

…de Code history (#745)

The server half of Discover. Two read-only, instance-level endpoints:

  GET /api/discover                  ranked candidate directories + counts
  GET /api/discover/sessions?dir=…   one directory's sessions, for lazy expansion

No UI — a separate change builds that against this API.

## The heuristic is the feature

A naive scan is unusable: on a real developer machine it surfaces ~166
transcript folders, ~150 of them ephemeral temp-dir sessions, plus `/`,
`~/Downloads` and `/tmp`. `discover.ts` recovers each folder's RECORDED cwd
(never by inverting the lossy encoded folder name) and applies the rules
cheap-to-expensive, so the ~150 die on string comparisons before anything reads
a transcript: no-recorded-cwd, missing, system-path (the #720 floor, on both the
resolved and the as-written spelling), temp-root, paddock-internal, home-root,
outside-home, already-managed, no-git, no-sessions. Survivors are ranked by
non-noise session count then recency.

`includeNonGit=1` / `includeOutsideHome=1` relax the two soft rules — which is
how #745's open "require .git, or rank it first?" question is answered: require
by default, keep the notebook case reachable. `excluded` reports what each rule
ate, so a container that legitimately finds nothing can say why rather than
render a blank page.

## A new boundary, not a loosened one

`POST …/adopt-chats` deliberately 400s a `sourceCwd` its project does not offer,
"rather than an invitation to scan arbitrary directories". That is untouched.
Discovery's own containment: the only paths it will ever read are ones a
transcript folder already records AND that clear the same path floor a linked
project must, so `?dir=` is a lookup into a computed set rather than a path
parameter. Nothing walks or globs the filesystem.

Steps 3–4 of the flow need no new code, and an integration test asserts that
rather than assuming it: the reported `path` IS what the created project's
`workingDir` becomes, so `POST /api/projects` + `POST …/adopt-chats` with a
`sessionIds` subset work unchanged, and the imported directory drops out of the
next scan.

Reuses `adoptable.ts`'s primitives (`recordedCwd`, `filterReasonFor`, the
mtime-cached folder scan with its #620 legacy mirroring) rather than
reimplementing them, so a Discover row's count and the import dialog's offer are
the same number by construction.

32 unit tests over a fixture tree + 7 integration tests over the real app.

Co-Authored-By: Claude <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7cd0267
Status: ✅  Deploy successful!
Preview URL: https://80ef28cf.paddock-7u2.pages.dev
Branch Preview URL: https://feat-745-discover-api.paddock-7u2.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant