From d64b6d4247f9998d5c2003f8b47a7dd95be5d0bd Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 20:34:20 +0800 Subject: [PATCH 1/9] feat(sessions): query operators, PR refs in any spelling, cache, open N --- CHANGELOG.md | 14 ++ README.md | 16 +- docs/session-finding-plan.md | 60 +++++- package.json | 2 +- src/claude-session-utility.ts | 363 +++++++++++++++++++++++++++++-- src/electron-api.d.ts | 9 + src/enrichment-cache.test.ts | 218 +++++++++++++++++++ src/enrichment-cache.ts | 278 ++++++++++++++++++++++++ src/main.ts | 41 ++++ src/preload.ts | 3 + src/session-search.test.ts | 303 ++++++++++++++++++++++++++ src/session-search.ts | 393 ++++++++++++++++++++++++++++++++++ src/switcher-ui.tsx | 319 ++++++++++++++++++++++++--- 13 files changed, 1959 insertions(+), 60 deletions(-) create mode 100644 src/enrichment-cache.test.ts create mode 100644 src/enrichment-cache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f82c5f..87e14be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.0.88 + +- Feat: aim the search — field-scoped terms, PR references in any spelling, `is:live`, and a persisted enrichment cache ([#140](https://github.com/grimmerk/codev/issues/140), [#134](https://github.com/grimmerk/codev/issues/134)) + - **Operators**: `title:x` `branch:x` `msg:x` `project:x` `account:x` `recap:x` search one field; `has:pr|title|branch|recap`; `is:live|pinned`; `after:7d` / `after:2026-09-01` / `after:today` and `before:…` by last activity; `"two words"` keeps a phrase together. Bare words keep today's meaning. Every term must hold. A `?` chip beside the search box shows the list; an operator with an unreadable value is reported under the box and ignored rather than silently matching nothing + - **A pull request in any spelling**: `#147`, `pr:147`, `owner/repo#147` and the GitHub URL all find each other, in your prompts and in what the assistant said. Measured on the reference machine's prompts: 80.6% of PR mentions were reachable by only one form before. Delimited forms only — `#147` never hits `#1475` or `15980` — and a repo in the query rules out the same number in another repo's URL + - **The assistant's replies are mined for the PRs it mentioned** (its text and the commands it ran; never tool output, so a session that ran `gh pr list` did not "work on" twenty PRs), so "the PR you opened for me" is findable by number. Measured: 91 transcripts / 739MB mined in 2.0s cold, then incrementally from the byte where the last pass stopped; 3,889 references across 65 sessions + - **One matcher, two callers**: the main-side full-prompt search and the renderer's filter now compile the same query into one matcher and differ only in what each side can put on the target (`is:` is judged in the renderer, everything else on the main side, which also sees title / branch / recap for every session now). Search stays behind the 180ms debounce: 7–10ms per keystroke for words, ~20ms for a PR reference, over 560 sessions + - **The enrichment cache survives a restart** (`~/.config/codev/enrichment-cache.json`): title, branch, PR badge, recap, mined references and per-file mtime+size, written a few seconds after a scan and flushed on quit. A second launch starts warm and re-reads only transcripts that changed. A bad file is a cold start, never an error. **One background pass over every session** runs 20s after launch, in chunks with pauses, so operators see every session and not just the loaded window; measured 6.8s cold for the whole corpus, stat-only afterwards +- Feat: `▶ open N` on a saved list — resume the members that are **not** running ([#145](https://github.com/grimmerk/codev/issues/145)) + - For the moments that want the whole set back: after a reboot or macOS update, after closing everything to reclaim memory, or to move the set to another terminal app (change the terminal in Settings, then press it). Reverses 1.0.87's "deliberately no open all" — same reasoning, different moments: the cost is real, so it is shown on the button (`open 12 · ~1.7GB?`, from the mean size of the processes currently running) and confirmed with a second click + - Opens only members without a running process by the `ps` join, so pressing it twice opens nothing new; launches one session every 0.7s rather than all in one tick; reports members skipped because their project folder or transcript is gone. Under the embedded CodeV terminal it explains itself instead of opening twelve sessions into one pane + - Fix on the way: each terminal launch now writes its own temp AppleScript file — the shared name meant one launch's cleanup could delete the script the next osascript was about to read once launches were 0.7s apart +- 30 new unit tests (query tokenizer / parser / matcher, PR-reference boundaries, cache round-trip, the miner against real-shaped records) — 168 total + ## 1.0.87 - Feat: saved session lists and a live-process view, on the Session Buddy model ([#145](https://github.com/grimmerk/codev/issues/145), [#94](https://github.com/grimmerk/codev/issues/94)) diff --git a/README.md b/README.md index 157ad5c..c41af25 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,19 @@ Press `⌃+⌘+R` or click the menu bar icon to launch the Quick Switcher. Searc CodeV can list, search, and resume Claude Code sessions. Press `⌃+⌘+R` to open the Quick Switcher, then `Tab` to toggle to Sessions mode. Live status dots show session state: working (orange pulse), idle (green), needs attention (orange blink). -Search covers **every session and every user prompt you ever typed** (not just the ~100 most recent sessions shown in the list) plus titles, branches, PR links, last AI replies, and the **session id** (a prefix of four or more hex characters — type the id your terminal status line shows to find that exact session, then `⌘D` to pin it; the row shows an `id 4ed7505a` marker since the id is not otherwise on screen). When a match sits in the middle of a conversation, the row shows an amber `match #N` snippet with the surrounding context, and every capped line — title, first/last message, branch, last reply — **moves its window to the match** so you can see *why* the row is there. Long titles are shortened **from the middle** (`head … tail`), so a title written as an `A -> B > C` chain keeps its newest step; hover for the full title. Closed one-shot sessions (≤2 messages, untitled, no PR) fold into an expandable "minor sessions" row to keep the list scannable. +Search covers **every session and every user prompt you ever typed** (not just the ~100 most recent sessions shown in the list) plus titles, branches, PR links, last AI replies, the recap line, and the **session id** (a prefix of four or more hex characters — type the id your terminal status line shows to find that exact session, then `⌘D` to pin it; the row shows an `id 4ed7505a` marker since the id is not otherwise on screen). When a match sits in the middle of a conversation, the row shows an amber `match #N` snippet with the surrounding context, and every capped line — title, first/last message, branch, last reply — **moves its window to the match** so you can see *why* the row is there. Long titles are shortened **from the middle** (`head … tail`), so a title written as an `A -> B > C` chain keeps its newest step; hover for the full title. Closed one-shot sessions (≤2 messages, untitled, no PR) fold into an expandable "minor sessions" row to keep the list scannable. + +Bare words search everything; **operators aim the query** (the `?` chip beside the search box shows this list): + +| Term | Matches | +|---|---| +| `title:x` `branch:x` `msg:x` `project:x` `account:x` `recap:x` | only that field (`msg:` = your prompts); `"two words"` keeps a phrase together, also after an operator | +| `has:pr` `has:title` `has:branch` `has:recap` | sessions that carry the thing | +| `is:live` `is:pinned` | sessions with a running process (by the `ps` join below) / pinned ones | +| `after:7d` `after:2026-09-01` `after:today` `before:…` | by the session's last activity (`Nh` `Nd` `Nw`, a date, `today`, `yesterday`) | +| `#147` `pr:147` `owner/repo#147` `https://github.com/owner/repo/pull/147` | **a pull request in any spelling** — the query form and the form in the text no longer have to agree (measured: 80.6% of PR mentions in prompts used only one form). A repo in the query rules out the same number in another repo's URL. Never a bare number: `#147` does not hit `1475` or `#1475` | + +Every term must hold. The assistant's own replies are mined for the PRs it mentioned (its text and the commands it ran — not tool output, so a session that merely listed twenty PRs did not "work on" them), so "the PR you opened for me" is findable by number even when you never typed it. An operator with an unreadable value (`after:soon`) is reported under the box and ignored rather than silently matching nothing. **Pin** the sessions you keep coming back to (hover 📌 on a row, or `⌘D` on the selected row): they **move into** a **📌 Pinned** zone at the top, ordered by recency like the rest of the list — works even for old sessions found via deep search. **Hide** one-offs you never want in the main flow (hover ⊘, or `⇧⌘D`): they move into the minor-sessions fold, stay searchable, and can be unhidden from inside the fold (they carry a persistent ⊘ marker there). Pins and hides live in `~/.config/codev/session-marks.json`, shared across accounts. @@ -45,7 +57,7 @@ Two chips beside the search box, on the [Session Buddy](https://sessionbuddy.com | `save list…` | Appears whenever the list is scoped (`● live`, `only`, or a search): **saves exactly what is on screen as a named list**. The default name is today's `MMDD`, then `MMDD-2`, `MMDD-3` — a label, not an identity. | | `🗂 N` | Shows the saved lists. Click one to view its members **in the order they were captured** and resume any of them; `✎` renames, `✕` (then `delete?`) deletes. | -A saved member stores what you recognise a session by — title, branch, pin state at capture, the last messages, and the **recap** line Claude Code writes into the transcript (the `※ recap:` "where we are, what's next" line), which replaces the last-reply line on the member's row; a recap much older than the session's last activity is marked `⏱`, since its "next step" may already be done. A member whose transcript is gone still reads as the session it was. Opening a session from a list or from the live scope **leaves you in that scope** when you come back (only the search box is cleared), so you can work through a set one session at a time. There is deliberately no "open all": 22 sessions is a few GB of processes, which is the very thing a saved list exists to relieve. Lists live in `~/.config/codev/session-lists.json`; a file that cannot be trusted as written is reported at load, never rewritten. +A saved member stores what you recognise a session by — title, branch, pin state at capture, the last messages, and the **recap** line Claude Code writes into the transcript (the `※ recap:` "where we are, what's next" line), which replaces the last-reply line on the member's row; a recap much older than the session's last activity is marked `⏱`, since its "next step" may already be done. A member whose transcript is gone still reads as the session it was. Opening a session from a list or from the live scope **leaves you in that scope** when you come back (only the search box is cleared), so you can work through a set one session at a time. A list's header also carries **`▶ open N`** — resume the N members that are **not** running, for the moments that call for the whole set: after a reboot or a macOS update, after closing everything to reclaim memory, or to move the set to another terminal app (change the terminal in Settings, then press it). It asks once, showing the projected cost (`open 12 · ~1.7GB?`, from the mean size of the processes currently running), opens only what is not already running (so pressing it twice opens nothing new), launches one session every 0.7s rather than all at once, and reports members it skipped because their project folder or transcript is gone. Lists live in `~/.config/codev/session-lists.json`; a file that cannot be trusted as written is reported at load, never rewritten. **Simple rule**: when running multiple sessions in the same project directory at the same time, give each running session a name. Closed sessions don't need names — they won't cause issues. diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index 9325a34..b8fb3d9 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -280,6 +280,44 @@ in the PR. Free follow-on: the same parser gives the §6 `B4` filters (`is:pinned`, `has:pr`, `pr:1598`, `project:`, `branch:`, `after:`). +**Shipped (2026-09-05, the PR after #147) — and the shape is slightly different from the +sketch above.** `session-search.ts` now holds `parseQuery` (tokens → terms, quotes honoured) and +`compileQuery` (terms → ONE matcher), and both search paths run that matcher over a +`QueryTarget` describing what each side knows about a session. The two callers differ only in +what they can put on the target: the main side has every prompt, the enrichment caches and the +mined PR references, and drops `is:` (the `ps` join and the pin store live in the renderer); the +renderer has first/last prompt, enrichment, a saved-list member's captured fields, and `is:`, +and re-applies only `is:` to what the main side sends back. A term aimed at a field the target +does not carry simply fails, so "what can this side judge" is decided by what it compiles, never +by the matcher forgiving. + +Three things the sketch did not have: + +- **The assistant's replies are mined for PR references** (`enrichment-cache.ts`, `minePrRefs`): + text blocks and tool-call inputs of assistant records only — never tool output, so a session + that merely ran `gh pr list` did not "work on" twenty PRs. Measured on the reference machine: + 91 transcripts / 739MB mined in 2.0s cold (largest file, 65MB, in 157ms), 3,889 references, + 65 sessions with at least one. It runs **in process over whole lines**, not through grep: a + fixed-string grep on the 65MB file takes 0.03s, `grep -oE` with the reference alternation + takes 4.0s, and reading lines lets the miner skip the ~70% of bytes that are not assistant + records. Because a transcript is append-only the miner remembers the byte it stopped at, so the + steady state reads only what was appended. **The record layout is parsed, not pattern-matched**: + an assistant record carries `message` *before* its top-level `type`, so "the first `"type":"` + on the line is the record's" is false — an earlier version keyed on that and mined nothing from + 91 real transcripts while its hand-made fixture passed. The fixture is now copied from a real + record. +- **The enrichment cache is persisted** (`~/.config/codev/enrichment-cache.json`, issue #134): + mtime+size per transcript, title / branch / PR badge / recap, the mined references and the + miner's offset. Written a few seconds after a scan changed something and flushed on quit; a bad + file is a cold start, never a refusal — it is a cache, not a store. **One background pass over + every session in history** runs 20s after launch in chunks of 10 with a pause between them + (foreground scans share the queue and interleave), so `title:` / `has:pr` / `#N` see every + session, not just the loaded window; on the second launch that pass is stat-only. +- **A repo in the query rules out the same number elsewhere**: `grimmerk/codev#147` does not + match a `fireflies/fred/pull/147` URL, while a bare `#147` in the target still matches either, + because a bare number cannot say which repo it meant. Delimited forms only, as promised: + `#147` never hits `#1475`, `/pull/1470` or `15980`. + ### 4.7 `/branch` creates generation chains (measured 2026-09-05) Full evidence and task breakdown: **issue #142**. Recorded here because two claims in @@ -376,10 +414,22 @@ fallback and marks a recap `⏱` when it is more than 30 minutes older than the activity: its final sentence is usually "next: …", and acting on a stale one is the failure mode. -**Deliberately absent: "open all".** In a browser, restoring 22 tabs is cheap. Here, 22 -sessions is ~3GB of processes — the very problem the feature exists to relieve. Restore is -per-row (the existing click-to-resume), and a whole-set restore, if it ever comes, has to -show the projected cost first. +**"Open all" — left out of PR #147, then added, with its cost on the button.** In a browser, +restoring 22 tabs is cheap. Here, 22 sessions is ~3GB of processes — the very problem the +feature exists to relieve — so #147 shipped restore as per-row only. Three real moments then +turned out to want the whole set, and in all three the cost is one the user is choosing on +purpose: after a reboot or a macOS update; after closing every session to reclaim memory (or to +confirm what was using it); and when switching terminal app (close the set in one, reopen it in +the other). So the list header carries `▶ open N` (#144 decision 14), under four constraints +that make it deliberate rather than blind: it opens only members that are **not running** by +the `ps` join (idempotent — pressing it twice opens nothing new); it asks once, with the +projected cost on the button (`open 12 · ~1.7GB?`, the mean RSS of the processes currently +running, or 145MB each when nothing is); it launches one session every 700ms rather than all in +one tick (each launch is an osascript, and the temp script files are now unique per launch — a +shared name would let one callback delete the script the next osascript was about to read); and +a member whose project folder or transcript is gone is reported, not launched (`cd` would fail +and `claude --resume` would then look in the wrong project). The embedded CodeV terminal holds +one session, so the button explains itself instead of opening twelve there. **Drift across `/branch` is shared with pins.** A list's members are keyed by sessionId (each carrying the captured title, branch, pin state, messages and recap), so §4.7 applies @@ -484,7 +534,7 @@ no released build produces, and a real format change is a versioned migration's | Item | Content | Note | |---|---|---| | D3 `/pin` | Custom slash command: leverages Claude Code's slash **autocomplete** (answers the user's dislike of `!` having none); the command runs `codev pin`; sessionId from the **`CLAUDE_CODE_SESSION_ID` env var** ([FACT], §7); accepts one LLM turn (user OK'd). Args possible: `/pin as "…"` | UI pin remains primary | -| B4 filters | `project:` `branch:` `account:` `has:pr` `msgs:>10` `after:` `is:pinned` chips | **Promoted** (2026-08-20): the §4.6 term parser does most of the work, and `is:pinned` overlaps the pinned-only scope | +| B4 filters | `project:` `branch:` `account:` `has:pr` `msgs:>10` `after:` `is:pinned` chips | **Shipped 2026-09-05 as typed operators, not chips** (§4.6): `title:` `branch:` `msg:` `project:` `account:` `recap:` `has:` `is:live|pinned` `after:` `before:` `pr:`, with a `?` cheat-sheet chip. `msgs:>10` was left out — nothing asked for it yet | | D4 "Frequent" list | A frecency scope (`distinct active days × log(1+prompts) × exp(-age/14d)`) alongside the pinned scope. **Derive it from `history.jsonl`, which is already loaded** — no click instrumentation, so no weeks-long cold start | **Measured 2026-08-20, and the numbers argue for modest expectations**: only 3 of the frecency top-10 are outside the recency top-20, so most of it is a re-ordering of rows you can already see. It does **not** subsume pins — of the user's 7 real pins, 3 rank in the frecency top-10 but three others rank #26/#63/#72, because a pin is often exactly the *low*-activity session you refuse to lose. The two intents are complementary: frecency = "I keep coming back", pin = "I decided this matters" | | A4-lite | "Generate title" button in the preview (haiku, writes a custom title) | No batch auto-summarizing | | C3 chain collapse | **Live, not defunct — this entry was wrong.** It said generation chains do not exist because normal resumes reuse the sessionId. `/branch` also creates one, and it is a daily action: **21 of 88 transcripts on disk (23.9%) carry `forkedFrom`** (measured 2026-09-05, §4.7). | Tracked in issue #142 | diff --git a/package.json b/package.json index 7cf69ef..c246acb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "CodeV", "productName": "CodeV", - "version": "1.0.87", + "version": "1.0.88", "description": "Quick switcher for VS Code, Cursor, and Claude Code sessions", "repository": { "type": "git", diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index ee56863..bbf58d1 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -14,10 +14,21 @@ import { getAccountByLabel, } from './accounts'; import { + compileQuery, findPromptMatch, - matchesAllWordsOrId, + isEmptyQuery, + parseQuery, PromptMatch, + promptNeedles, } from './session-search'; +import { + EnrichmentState, + getEnrichmentCachePath, + minePrRefs, + readEnrichmentCacheFile, + writeEnrichmentCacheFile, +} from './enrichment-cache'; +import { readSessionRegistrations } from './live-sessions'; export interface ClaudeSession { sessionId: string; @@ -97,7 +108,12 @@ export const invalidateSessionCache = () => { cachedBranches = null; cachedPRLinks = null; cachedRecaps = null; + cachedPRRefs = null; enrichedFileState.clear(); + prRefBytes.clear(); + // The disk cache is still valid (freshness is per file, by mtime+size); + // reload it on the next scan rather than paying a cold scan. + enrichmentLoadedFromDisk = false; }; /** @@ -213,13 +229,25 @@ export interface SessionSearchResult { * Full search across ALL sessions (not just the ~100 the UI loads) and ALL * user prompts (not just first/last) — fixes issue #131. Sessions come back * newest-first; prompt text stays in this process (only snippets cross IPC). + * + * The query language (issue #140) is judged here with the SAME matcher the + * renderer runs, over everything this process knows: prompts, project, and + * the enrichment caches (title, branch, PR badge, recap, and the PR + * references the assistant mentioned) — which the persisted cache and the + * background scan fill for every session, not just the loaded window. The + * one kind of term this side cannot judge is `is:` (the ps join and the pin + * store live in the renderer), so it is left out here and the renderer + * applies it to whatever comes back. */ export const searchClaudeSessions = ( query: string, limit = 100, ): SessionSearchResult => { - const words = query.toLowerCase().split(/\s+/).filter(Boolean); - if (words.length === 0) return { sessions: [], snippets: {} }; + const parsed = parseQuery(query); + if (isEmptyQuery(parsed)) return { sessions: [], snippets: {} }; + ensureEnrichmentLoaded(); + const matcher = compileQuery({ ...parsed, is: [] }); + const needles = promptNeedles(parsed); // Recency-sorted full set; also (re)builds promptsBySession when stale. const allSessions = readClaudeSessions(Number.MAX_SAFE_INTEGER); @@ -227,18 +255,46 @@ export const searchClaudeSessions = ( const snippets: Record = {}; for (const s of allSessions) { - const sessionPrompts = promptsBySession.get(s.sessionId) || []; - // The id shown in a terminal status line finds the session too — by the - // prefix rule in matchesSessionId, shared with the renderer's - // filterSessionsLocally so the two paths agree on what an id query is. - const target = - `${s.projectName} ${s.project} ${sessionPrompts.join('\n')}`.toLowerCase(); - if (!matchesAllWordsOrId(target, s.sessionId, words)) continue; + const id = s.sessionId; + const sessionPrompts = promptsBySession.get(id) || []; + const title = cachedCustomTitles?.get(id); + const branch = cachedBranches?.get(id); + const prLink = cachedPRLinks?.get(id); + const recap = cachedRecaps?.get(id)?.text; + const refs = cachedPRRefs?.get(id); + const text = [ + s.projectName, + s.project, + sessionPrompts.join('\n'), + title, + branch, + prLink ? `PR #${prLink.prNumber} ${prLink.prUrl}` : '', + recap, + refs?.join(' '), + ] + .filter(Boolean) + .join('\n'); + if ( + !matcher.test({ + sessionId: id, + text, + title, + branch, + project: `${s.projectName} ${s.project}`, + account: s.accountLabel, + recap, + prompts: sessionPrompts, + hasPr: !!prLink, + lastTimestamp: s.lastTimestamp, + }) + ) { + continue; + } sessions.push(s); - const match = findPromptMatch(sessionPrompts, words); + const match = findPromptMatch(sessionPrompts, needles, parsed.prRefs); if (match) { - snippets[s.sessionId] = { + snippets[id] = { ...match, isLastPrompt: match.promptIndex === sessionPrompts.length - 1, }; @@ -1291,6 +1347,14 @@ export const setLaunchInCodevTerminalCallback = (cb: (projectPath: string) => vo * For Ghostty: `claudeCmd` is the bare command (no cd), projectPath sets initial working directory. * For others: `fullCommand` is the full command string (cd + claude). */ +// One script file per launch. A fixed name was fine while launches were one +// click apart; opening a saved list runs several a few hundred ms apart, and +// each callback deletes its file, so a shared name could delete the script +// osascript was about to read. +let launchScriptSeq = 0; +const launchScriptPath = (name: string): string => + path.join(os.tmpdir(), `codev-${name}-${process.pid}-${++launchScriptSeq}.scpt`); + export const runCommandInTerminal = ( fullCommand: string, claudeCmd: string, @@ -1302,7 +1366,7 @@ export const runCommandInTerminal = ( switch (terminalApp) { case 'ghostty': { - const tmpScript = '/tmp/codev-ghostty-launch.scpt'; + const tmpScript = launchScriptPath('ghostty-launch'); const launchScript = terminalMode === 'window' ? `tell application "Ghostty" set cfg to new surface configuration from {initial working directory:"${projectPath}", initial input:"${claudeCmd}\\n"} @@ -1327,7 +1391,7 @@ end tell`; break; } case 'terminal': { - const tmpScript = '/tmp/codev-terminal-launch.scpt'; + const tmpScript = launchScriptPath('terminal-launch'); const escapedCommand = fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const launchScript = terminalMode === 'window' ? `set wasRunning to (do shell script "pgrep -x Terminal >/dev/null 2>&1 && echo 1 || echo 0") @@ -1408,7 +1472,7 @@ end tell`; } case 'iterm2': default: { - const tmpScript = '/tmp/codev-iterm-launch.scpt'; + const tmpScript = launchScriptPath('iterm-launch'); const escapedCommand = fullCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const launchScript = terminalMode === 'window' ? `set wasRunning to (do shell script "pgrep -x iTerm2 >/dev/null 2>&1 && echo 1 || echo 0") @@ -1734,10 +1798,11 @@ export interface SessionEnrichment { recaps: Map; } -// Cache for branches, PR links and recaps +// Cache for branches, PR links, recaps and mined PR references let cachedBranches: Map | null = null; let cachedPRLinks: Map | null = null; let cachedRecaps: Map | null = null; +let cachedPRRefs: Map | null = null; // Per-file enrichment scan state: a transcript unchanged since its last scan // (same mtime+size) is never re-grepped — the stat check IS the freshness @@ -1745,10 +1810,130 @@ let cachedRecaps: Map | null = null; // longer than the TTL itself: the just-written cache was already stale, so // every popup interaction kicked off another multi-second full rescan. const enrichedFileState = new Map(); +// How far the PR-reference miner has read each transcript (whole lines). +// Transcripts are append-only, so the next pass reads only what was added. +const prRefBytes = new Map(); // Serialize scans: concurrent callers queue up and then mostly hit the // accumulated cache (correct even when they pass different session sets). let enrichmentQueue: Promise = Promise.resolve(); +// --- Persisted cache (issue #134) --- +// Everything above is written to ~/.config/codev/enrichment-cache.json a few +// seconds after a scan changed it, and read back on the first scan of the +// next run, so a launch starts warm: the stat check alone decides what to +// re-read. A cache, not a store — a bad file is a cold start, never a refusal. +let enrichmentLoadedFromDisk = false; +let enrichmentCacheDirty = false; +let enrichmentSaveTimer: ReturnType | null = null; +const ENRICHMENT_SAVE_DEBOUNCE_MS = 3000; + +const currentEnrichmentState = (): EnrichmentState => ({ + fileState: enrichedFileState, + titles: (cachedCustomTitles ??= new Map()), + branches: (cachedBranches ??= new Map()), + prLinks: (cachedPRLinks ??= new Map()), + recaps: (cachedRecaps ??= new Map()), + prRefs: (cachedPRRefs ??= new Map()), + prRefBytes, +}); + +const ensureEnrichmentLoaded = (): void => { + if (enrichmentLoadedFromDisk) return; + enrichmentLoadedFromDisk = true; + const t0 = Date.now(); + const disk = readEnrichmentCacheFile(getEnrichmentCachePath()); + const mem = currentEnrichmentState(); + // In-memory wins: it is at least as fresh as anything on disk. + const fill = (into: Map, from: Map) => { + for (const [k, v] of from) if (!into.has(k)) into.set(k, v); + }; + fill(mem.fileState, disk.fileState); + fill(mem.titles, disk.titles); + fill(mem.branches, disk.branches); + fill(mem.prLinks, disk.prLinks); + fill(mem.recaps, disk.recaps); + fill(mem.prRefs, disk.prRefs); + fill(mem.prRefBytes, disk.prRefBytes); + if (disk.fileState.size > 0) { + console.log( + `[enrichment] cache loaded: ${disk.fileState.size} sessions in ${Date.now() - t0}ms`, + ); + } +}; + +/** Write the cache now if anything changed since the last write. */ +export const flushEnrichmentCache = (): void => { + if (enrichmentSaveTimer) { + clearTimeout(enrichmentSaveTimer); + enrichmentSaveTimer = null; + } + if (!enrichmentCacheDirty) return; + enrichmentCacheDirty = false; + try { + // Sessions gone from history leave the cache with them. + const keep = new Set( + readClaudeSessions(Number.MAX_SAFE_INTEGER).map((s) => s.sessionId), + ); + writeEnrichmentCacheFile(getEnrichmentCachePath(), currentEnrichmentState(), keep); + } catch (err) { + console.error('[enrichment] cache write failed:', err); + } +}; + +const scheduleEnrichmentSave = (): void => { + enrichmentCacheDirty = true; + if (enrichmentSaveTimer) return; + enrichmentSaveTimer = setTimeout(() => { + enrichmentSaveTimer = null; + flushEnrichmentCache(); + }, ENRICHMENT_SAVE_DEBOUNCE_MS); +}; + +// Mine PR references from `[from, size)` of a transcript, whole lines only, +// in 4MB chunks with a yield between them so a 65MB cold pass never holds +// the event loop for long. Returns the offset just past the last complete +// line, which is where the next pass starts. Byte-level carry, so a +// multi-byte character split by a chunk boundary is neither mangled nor +// counted twice. +const PR_MINE_CHUNK_BYTES = 4 * 1024 * 1024; +const minePrRefsFromFile = async ( + filePath: string, + from: number, + size: number, + seen: Set, +): Promise<{ refs: string[]; scannedTo: number } | null> => { + let fh: fs.promises.FileHandle | null = null; + try { + fh = await fs.promises.open(filePath, 'r'); + const refs: string[] = []; + let pos = from; + let carry: Buffer = Buffer.alloc(0); + while (pos < size) { + const len = Math.min(PR_MINE_CHUNK_BYTES, size - pos); + const buf = Buffer.alloc(len); + // Cast: this @types/node version mistypes Buffer vs ArrayBufferView. + const { bytesRead } = await fh.read(buf as unknown as Uint8Array, 0, len, pos); + if (bytesRead === 0) break; + pos += bytesRead; + // Casts: same @types/node Buffer-vs-Uint8Array mismatch as above. + const chunk = Buffer.concat([carry, buf.subarray(0, bytesRead)] as unknown as Uint8Array[]); + const lastNl = chunk.lastIndexOf(0x0a); + if (lastNl === -1) { + carry = chunk; + continue; + } + refs.push(...minePrRefs(chunk.toString('utf-8', 0, lastNl + 1), seen)); + carry = Buffer.from(chunk.subarray(lastNl + 1) as unknown as Uint8Array); + await new Promise((resolve) => setImmediate(resolve)); + } + return { refs, scannedTo: pos - carry.length }; + } catch { + return null; + } finally { + await fh?.close().catch(() => {}); + } +}; + // Run per-session async work in bounded batches. ~100 sessions × several // exec() greps each used to spawn hundreds of concurrent processes at once, // starving the biggest (busiest) transcripts into the exec timeout — whose @@ -1788,11 +1973,13 @@ const readTailUtf8 = async ( export const loadSessionEnrichment = async ( sessions: ClaudeSession[], ): Promise => { + ensureEnrichmentLoaded(); // Accumulator maps persist across calls; scans only fill/refresh entries. const titles = (cachedCustomTitles ??= new Map()); const branches = (cachedBranches ??= new Map()); const prLinks = (cachedPRLinks ??= new Map()); const recaps = (cachedRecaps ??= new Map()); + const prRefs = (cachedPRRefs ??= new Map()); const { execFile } = require('child_process'); // Shell-free (no interpolated paths anywhere near a shell). Resolves null @@ -1819,6 +2006,7 @@ export const loadSessionEnrichment = async ( }; const scan = async () => { + let changed = false; // Re-scan only transcripts that are new or changed since their last scan. const toScan: { session: ClaudeSession; @@ -1840,7 +2028,14 @@ export const loadSessionEnrichment = async ( continue; } const prev = enrichedFileState.get(session.sessionId); - if (prev && prev.mtimeMs === stat.mtimeMs && prev.size === stat.size) { + // A file whose miner pass failed has state but no offset; it is + // re-read rather than skipped forever. + if ( + prev && + prev.mtimeMs === stat.mtimeMs && + prev.size === stat.size && + prRefBytes.has(session.sessionId) + ) { continue; } toScan.push({ @@ -1934,6 +2129,21 @@ export const loadSessionEnrichment = async ( } } + // PR references the assistant mentioned (issue #140): incremental + // from the last offset; from the start when the file shrank (a + // rewritten transcript), in which case the old references go too. + const id = session.sessionId; + const prevBytes = prRefBytes.get(id) ?? 0; + const from = prevBytes <= size ? prevBytes : 0; + const known = from === 0 ? [] : (prRefs.get(id) ?? []); + const mined = await minePrRefsFromFile(jsonlPath, from, size, new Set(known)); + if (mined) { + const all = [...known, ...mined.refs]; + if (all.length > 0) prRefs.set(id, all); + else prRefs.delete(id); + prRefBytes.set(id, mined.scannedTo); + } + // Mark fresh ONLY when every read succeeded — a timed-out or failed // pass stays unrecorded so the next call retries it. if ( @@ -1943,10 +2153,12 @@ export const loadSessionEnrichment = async ( recapOutput !== null && tailOutput !== null ) { - enrichedFileState.set(session.sessionId, { mtimeMs, size }); + enrichedFileState.set(id, { mtimeMs, size }); + changed = true; } }, ); + if (changed) scheduleEnrichmentSave(); }; enrichmentQueue = enrichmentQueue.then(scan, scan); @@ -1954,6 +2166,121 @@ export const loadSessionEnrichment = async ( return { titles, branches, prLinks, recaps }; }; +/** + * One pass over EVERY session in history (issue #140, "one background full + * scan"), in small chunks with a pause between them so foreground calls — + * which share the scan queue — interleave rather than wait. With the + * persisted cache this is a stat pass on the second launch; the first one + * reads every transcript once. Runs at most once per process. + */ +let backgroundScanStarted = false; +export const runBackgroundEnrichmentScan = async ( + opts: { chunk?: number; pauseMs?: number } = {}, +): Promise<{ sessions: number; ms: number } | null> => { + if (backgroundScanStarted) return null; + backgroundScanStarted = true; + const chunk = opts.chunk ?? 10; + const pauseMs = opts.pauseMs ?? 400; + const t0 = Date.now(); + const all = readClaudeSessions(Number.MAX_SAFE_INTEGER); + for (let i = 0; i < all.length; i += chunk) { + await loadSessionEnrichment(all.slice(i, i + chunk)); + await new Promise((resolve) => setTimeout(resolve, pauseMs)); + } + const ms = Date.now() - t0; + console.log(`[enrichment] background scan: ${all.length} sessions in ${ms}ms`); + return { sessions: all.length, ms }; +}; + +/** + * Where a session's transcript is, or null when no account has it. Tries the + * account history knows the session by, then the account a saved list + * captured, then every account — a `/branch` child is in no history yet. + */ +export const findTranscriptPath = ( + sessionId: string, + project: string, + accountLabel?: string, +): string | null => { + if (!/^[0-9a-f-]+$/i.test(sessionId)) return null; + const encodedProject = project.replace(/[^a-zA-Z0-9-]/g, '-'); + const known = readClaudeSessions(Number.MAX_SAFE_INTEGER).find( + (s) => s.sessionId === sessionId, + ); + const dirs = new Set( + [ + known?.accountDir, + accountLabel ? getAccountByLabel(accountLabel).dir : undefined, + ...getScannableAccounts().map((a) => a.dir), + ].filter((d): d is string => !!d), + ); + for (const dir of dirs) { + const p = path.join(getProjectsDir(dir), encodedProject, `${sessionId}.jsonl`); + if (fs.existsSync(p)) return p; + } + return null; +}; + +export interface OpenListMember { + sessionId: string; + project: string; + accountLabel?: string; + title?: string; +} + +export interface OpenListMembersResult { + opened: string[]; + skipped: { sessionId: string; reason: string }[]; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Resume the members of a saved list that are not running (issue #145: + * after a reboot, after closing everything to reclaim memory, or to move the + * set to another terminal app). The renderer already dropped the running + * ones by the ps join; the registrations are checked again here because the + * renderer's report can be seconds old. Launches are staggered — twenty + * osascripts in one tick is rough on the terminal app — and a member whose + * project folder or transcript is gone is reported, not launched (`cd` would + * fail and `claude --resume` would then look in the wrong project). + */ +export const openSessionListMembers = async ( + members: OpenListMember[], + terminalApp: string, + terminalMode: string, + staggerMs = 700, +): Promise => { + const result: OpenListMembersResult = { opened: [], skipped: [] }; + const running = new Set(); + for (const reg of readSessionRegistrations()) { + try { + process.kill(reg.pid, 0); + running.add(reg.sessionId); + } catch { + // dead pid: a stale registration, not a running session + } + } + for (const m of members) { + if (running.has(m.sessionId)) { + result.skipped.push({ sessionId: m.sessionId, reason: 'already running' }); + continue; + } + if (!m.project || !fs.existsSync(m.project)) { + result.skipped.push({ sessionId: m.sessionId, reason: 'project folder missing' }); + continue; + } + if (!findTranscriptPath(m.sessionId, m.project, m.accountLabel)) { + result.skipped.push({ sessionId: m.sessionId, reason: 'transcript missing' }); + continue; + } + if (result.opened.length > 0) await sleep(staggerMs); + await openSession(m.sessionId, m.project, false, undefined, terminalApp, terminalMode, m.title); + result.opened.push(m.sessionId); + } + return result; +}; + /** * Load last assistant response for active sessions. * Uses tail -n 100 to read the end of the JSONL file (fast even on 80MB files). diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index a2d1331..d289411 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -235,6 +235,15 @@ interface IElectronAPI { scanClosedVSCodeSessions: (activeSessionIds: string[]) => Promise; refreshSessionPreview: (sessions: any[]) => Promise>; openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => void; + /** Resume the given saved-list members that are not running; staggered, skips report why. */ + openSessionListMembers: ( + members: { sessionId: string; project: string; accountLabel?: string; title?: string }[], + ) => Promise<{ + opened: string[]; + skipped: { sessionId: string; reason: string }[]; + /** Set when nothing could be attempted at all (e.g. the embedded terminal is selected). */ + error?: string; + }>; launchNewClaudeSession: (projectPath: string, accountLabel?: string) => void; launchNewClaudeSessionInCodev: (projectPath: string) => void; copyClaudeSessionCommand: (sessionId: string, projectPath: string) => void; diff --git a/src/enrichment-cache.test.ts b/src/enrichment-cache.test.ts new file mode 100644 index 0000000..f662140 --- /dev/null +++ b/src/enrichment-cache.test.ts @@ -0,0 +1,218 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + deserializeEnrichment, + ENRICHMENT_CACHE_VERSION, + EnrichmentState, + assistantTextOfLine, + minePrRefs, + readEnrichmentCacheFile, + serializeEnrichment, + writeEnrichmentCacheFile, +} from './enrichment-cache'; + +const state = (): EnrichmentState => ({ + fileState: new Map([ + ['a', { mtimeMs: 1, size: 10 }], + ['b', { mtimeMs: 2, size: 20 }], + ['gone', { mtimeMs: 3, size: 30 }], + ]), + titles: new Map([['a', 'title a']]), + branches: new Map([['a', 'main']]), + prLinks: new Map([ + ['a', { prNumber: 7, prUrl: 'https://github.com/o/r/pull/7' }], + ]), + recaps: new Map([['b', { text: 'next: ship', at: '2026-09-05T00:00:00Z' }]]), + prRefs: new Map([ + ['a', ['o/r#7', '#8']], + ['b', []], + ]), + prRefBytes: new Map([ + ['a', 10], + ['b', 0], + ]), +}); + +describe('serializeEnrichment / deserializeEnrichment', () => { + it('round-trips every field and omits empties', () => { + const file = serializeEnrichment(state()); + expect(file.version).toBe(ENRICHMENT_CACHE_VERSION); + expect(file.sessions.a).toEqual({ + mtimeMs: 1, + size: 10, + title: 'title a', + branch: 'main', + prLink: { prNumber: 7, prUrl: 'https://github.com/o/r/pull/7' }, + prRefs: ['o/r#7', '#8'], + prRefsScannedBytes: 10, + }); + expect(file.sessions.b).toEqual({ + mtimeMs: 2, + size: 20, + recap: { text: 'next: ship', at: '2026-09-05T00:00:00Z' }, + }); + const back = deserializeEnrichment(JSON.parse(JSON.stringify(file))); + expect(back.fileState).toEqual(state().fileState); + expect(back.titles).toEqual(state().titles); + expect(back.branches).toEqual(state().branches); + expect(back.prLinks).toEqual(state().prLinks); + expect(back.recaps).toEqual(state().recaps); + expect(back.prRefs).toEqual(new Map([['a', ['o/r#7', '#8']]])); + expect(back.prRefBytes).toEqual(new Map([['a', 10]])); + }); + + it('prunes sessions outside `keep`, so deleted sessions leave the cache', () => { + const file = serializeEnrichment(state(), new Set(['a', 'b'])); + expect(Object.keys(file.sessions).sort()).toEqual(['a', 'b']); + }); + + it('starts cold on a version mismatch, a non-object, or a malformed entry', () => { + expect(deserializeEnrichment(null).fileState.size).toBe(0); + expect(deserializeEnrichment('x').fileState.size).toBe(0); + expect( + deserializeEnrichment({ + version: 99, + sessions: { a: { mtimeMs: 1, size: 1 } }, + }).fileState.size, + ).toBe(0); + const partial = deserializeEnrichment({ + version: ENRICHMENT_CACHE_VERSION, + sessions: { + ok: { + mtimeMs: 1, + size: 1, + title: 'x', + prLink: { prNumber: 'no', prUrl: 'u' }, + prRefs: ['#1', 2, ''], + prRefsScannedBytes: 'many', + }, + bad: { mtimeMs: 'one', size: 1 }, + alsoBad: null, + }, + }); + expect([...partial.fileState.keys()]).toEqual(['ok']); + expect(partial.titles.get('ok')).toBe('x'); + expect(partial.prLinks.has('ok')).toBe(false); + expect(partial.prRefs.get('ok')).toEqual(['#1']); + expect(partial.prRefBytes.has('ok')).toBe(false); + }); +}); + +describe('minePrRefs', () => { + // Record shapes copied from a real transcript (2026-09-05): an assistant + // record carries `message` BEFORE its top-level `type`, and the message + // has its own `"type":"message"`; a user record has `type` first. + const assistant = (content: unknown) => + JSON.stringify({ + parentUuid: '1e20089d', + isSidechain: false, + message: { + model: 'claude-fable-5', + id: 'msg_1', + type: 'message', + role: 'assistant', + content, + }, + requestId: 'req_1', + type: 'assistant', + uuid: 'u1', + timestamp: '2026-09-05T00:00:00Z', + }); + const user = (content: unknown) => + JSON.stringify({ + parentUuid: '137369c9', + isSidechain: false, + type: 'user', + message: { role: 'user', content }, + uuid: 'u2', + }); + + it('reads only assistant records and canonicalises each form, lowercased and deduplicated', () => { + const text = [ + user('see #1 and https://github.com/o/r/pull/2'), + assistant([ + { type: 'thinking', thinking: 'maybe #99', signature: 'x' }, + { + type: 'text', + text: 'opened #3, https://github.com/Grimmerk/CodeV/pull/147, o/r#5, again #3', + }, + ]), + user([ + { + type: 'tool_result', + content: 'o/r#6 listed by gh; "type":"assistant" quoted', + }, + ]), + assistant([ + { + type: 'tool_use', + name: 'Bash', + input: { command: 'gh pr view 7 (#7)' }, + }, + ]), + '{"type":"system","subtype":"away_summary","content":"recap mentions #8"}', + 'not json at all #10', + ].join('\n'); + expect(minePrRefs(text)).toEqual([ + '#3', + 'grimmerk/codev#147', + 'o/r#5', + '#7', + ]); + }); + + it('skips what `seen` already holds and reports only the new ones, in order', () => { + const seen = new Set(['#3']); + expect(minePrRefs(assistant('#3 then #4'), seen)).toEqual(['#4']); + expect([...seen]).toEqual(['#3', '#4']); + }); + + it('never reads a bare number, an HTML entity, a leading zero, or a hex-looking anchor', () => { + expect( + minePrRefs( + assistant('15980 and & and #0 and #012 and a#9 and x/y#1a'), + ), + ).toEqual(['x/y#1']); + }); + + it('assistantTextOfLine joins text and tool inputs, and rejects everything else', () => { + expect( + assistantTextOfLine( + assistant([ + { type: 'text', text: 'a' }, + { type: 'tool_use', input: { command: 'b' } }, + { type: 'thinking', thinking: 'c' }, + ]), + ), + ).toBe('a\n{"command":"b"}'); + expect(assistantTextOfLine(assistant('plain'))).toBe('plain'); + expect(assistantTextOfLine(user('"type":"assistant"'))).toBeNull(); + expect(assistantTextOfLine('{"type":"assistant"')).toBeNull(); + expect(assistantTextOfLine('')).toBeNull(); + }); +}); + +describe('enrichment cache file layer', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codev-enrich-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes and reads back; a missing or corrupt file is a cold start', () => { + const file = path.join(dir, 'enrichment-cache.json'); + expect(readEnrichmentCacheFile(file).fileState.size).toBe(0); + writeEnrichmentCacheFile(file, state(), new Set(['a'])); + const back = readEnrichmentCacheFile(file); + expect([...back.fileState.keys()]).toEqual(['a']); + expect(back.titles.get('a')).toBe('title a'); + fs.writeFileSync(file, '{not json'); + expect(readEnrichmentCacheFile(file).fileState.size).toBe(0); + }); +}); diff --git a/src/enrichment-cache.ts b/src/enrichment-cache.ts new file mode 100644 index 0000000..652dd74 --- /dev/null +++ b/src/enrichment-cache.ts @@ -0,0 +1,278 @@ +/** + * The persisted half of session enrichment (issue #134) and the PR-reference + * miner (issue #140, plan §4.6). + * + * Enrichment — custom title, branch, PR badge, recap, and now the PR + * references the assistant mentioned — is derived from each transcript by a + * scan keyed on the file's mtime and size. Within a run that key makes the + * scan incremental; across runs nothing survived, so every launch paid one + * full cold scan (~4s on the reference machine) before titles appeared. This + * module writes the scan state and its results to one JSON file so the next + * launch starts warm and the stat check alone decides what to re-read. + * + * It is a CACHE, not a store: nothing in it is the only copy of anything, so + * a bad read is answered by starting cold rather than by refusing to write — + * the opposite of the authority rule the marks and lists stores live by. + * + * Pure pieces (`serializeEnrichment`, `deserializeEnrichment`, + * `minePrRefs`) are unit-tested; the file layer is two small wrappers over + * the shared atomic writer. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { writeStoreFile } from './atomic-json-store'; + +export const ENRICHMENT_CACHE_VERSION = 1; + +export interface CachedSessionEnrichment { + mtimeMs: number; + size: number; + title?: string; + branch?: string; + prLink?: { prNumber: number; prUrl: string }; + recap?: { text: string; at: string }; + /** Canonical PR references the assistant mentioned: `owner/repo#N` or `#N`, lowercased. */ + prRefs?: string[]; + /** How far the miner has read (whole lines only); the next pass starts here. */ + prRefsScannedBytes?: number; +} + +export interface EnrichmentCacheFile { + version: number; + sessions: Record; +} + +/** What the scanner keeps in memory; the cache file is a projection of it. */ +export interface EnrichmentState { + fileState: Map; + titles: Map; + branches: Map; + prLinks: Map; + recaps: Map; + prRefs: Map; + /** sessionId -> bytes of the transcript already mined for PR references. */ + prRefBytes: Map; +} + +/** + * Project the in-memory state onto the file shape. Only sessions with scan + * state are written — a value with no state would be re-scanned anyway. The + * optional `keep` set prunes sessions that no longer exist in history, so a + * deleted session does not sit in the cache forever. + */ +export const serializeEnrichment = ( + state: EnrichmentState, + keep?: Set, +): EnrichmentCacheFile => { + const sessions: Record = {}; + for (const [id, fileState] of state.fileState) { + if (keep && !keep.has(id)) continue; + const entry: CachedSessionEnrichment = { ...fileState }; + const title = state.titles.get(id); + if (title) entry.title = title; + const branch = state.branches.get(id); + if (branch) entry.branch = branch; + const prLink = state.prLinks.get(id); + if (prLink) entry.prLink = prLink; + const recap = state.recaps.get(id); + if (recap) entry.recap = recap; + const refs = state.prRefs.get(id); + if (refs && refs.length > 0) entry.prRefs = refs; + const bytes = state.prRefBytes.get(id); + if (bytes !== undefined && bytes > 0) entry.prRefsScannedBytes = bytes; + sessions[id] = entry; + } + return { version: ENRICHMENT_CACHE_VERSION, sessions }; +}; + +const isFiniteNumber = (v: unknown): v is number => + typeof v === 'number' && Number.isFinite(v); + +/** + * Load a cache file into fresh maps. Forgiving per entry — one malformed + * session is dropped, the rest load — and empty on a version mismatch or + * anything that is not the expected shape, which simply means a cold scan. + */ +export const deserializeEnrichment = (raw: unknown): EnrichmentState => { + const state: EnrichmentState = { + fileState: new Map(), + titles: new Map(), + branches: new Map(), + prLinks: new Map(), + recaps: new Map(), + prRefs: new Map(), + prRefBytes: new Map(), + }; + if (!raw || typeof raw !== 'object') return state; + const file = raw as Partial; + if (file.version !== ENRICHMENT_CACHE_VERSION) return state; + if (!file.sessions || typeof file.sessions !== 'object') return state; + for (const [id, entry] of Object.entries(file.sessions)) { + if (!id || !entry || typeof entry !== 'object') continue; + const e = entry as Partial; + if (!isFiniteNumber(e.mtimeMs) || !isFiniteNumber(e.size)) continue; + state.fileState.set(id, { mtimeMs: e.mtimeMs, size: e.size }); + if (typeof e.title === 'string' && e.title) state.titles.set(id, e.title); + if (typeof e.branch === 'string' && e.branch) + state.branches.set(id, e.branch); + if ( + e.prLink && + typeof e.prLink === 'object' && + isFiniteNumber(e.prLink.prNumber) && + typeof e.prLink.prUrl === 'string' + ) { + state.prLinks.set(id, { + prNumber: e.prLink.prNumber, + prUrl: e.prLink.prUrl, + }); + } + if ( + e.recap && + typeof e.recap === 'object' && + typeof e.recap.text === 'string' && + e.recap.text + ) { + state.recaps.set(id, { + text: e.recap.text, + at: typeof e.recap.at === 'string' ? e.recap.at : '', + }); + } + if (Array.isArray(e.prRefs)) { + const refs = e.prRefs.filter( + (r): r is string => typeof r === 'string' && !!r, + ); + if (refs.length > 0) state.prRefs.set(id, refs); + } + if (isFiniteNumber(e.prRefsScannedBytes) && e.prRefsScannedBytes > 0) { + state.prRefBytes.set(id, e.prRefsScannedBytes); + } + } + return state; +}; + +// --------------------------------------------------------------------------- +// PR-reference mining +// --------------------------------------------------------------------------- + +/** + * Mining runs IN PROCESS over whole lines, not through grep. Measured on the + * largest transcript on the reference machine (65MB): a fixed-string grep + * takes 0.03s, but `grep -oE` with this alternation takes 4.0s — 130× — + * which is not something to pay on every scan of a live session. Reading + * lines here lets the miner skip the 70% of bytes that are not assistant + * records at all, and — because a transcript is append-only — resume from + * the byte it stopped at last time (`prRefsScannedBytes`), so the steady + * state reads only what was appended. + * + * Forms: the GitHub URL (pull or issues), `owner/repo#N`, and `#N` with a + * non-word character before it. `&#N` is excluded so HTML entities are not + * read as references; a leading zero is excluded so `#0` is not. + */ +export const PR_REF_RE = + /github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/(?:pull|issues)\/([1-9][0-9]*)|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)|(?:^|[^A-Za-z0-9&#])#([1-9][0-9]*)/g; + +/** + * The words the assistant actually wrote in one JSONL record, or null when + * the line is not an assistant record: its text blocks and the inputs of + * its tool calls (a commit message, a `gh pr comment` body). Not thinking, + * not the signature blobs, and never a user record — prompts and, far more + * often, tool results such as a `gh pr list`: a session that merely listed + * twenty PRs did not work on them. + * + * Parsed, not pattern-matched, because the record layout does not put the + * top-level `type` first: on the reference machine an assistant record + * reads `{"parentUuid":…,"isSidechain":false,"message":{…,"type":"message", + * "role":"assistant",…},"type":"assistant",…}` — the first `"type":"` on the + * line belongs to the message. An earlier version keyed on that and mined + * nothing at all from 91 real transcripts; its hand-made fixture had passed. + * The cheap `includes` keeps the parse off the ~70% of bytes that cannot be + * assistant records. + */ +export const assistantTextOfLine = (line: string): string | null => { + if (!line.includes('"type":"assistant"')) return null; + let rec: { + type?: unknown; + message?: { content?: unknown }; + }; + try { + rec = JSON.parse(line); + } catch { + return null; + } + if (!rec || rec.type !== 'assistant') return null; + const content = rec.message?.content; + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return null; + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== 'object') continue; + const b = block as { type?: unknown; text?: unknown; input?: unknown }; + if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text); + else if (b.type === 'tool_use' && b.input !== undefined) { + parts.push( + typeof b.input === 'string' ? b.input : JSON.stringify(b.input), + ); + } + } + return parts.join('\n'); +}; + +/** + * Canonical references the ASSISTANT wrote in `text` (one or more whole + * JSONL lines): `owner/repo#N` when the form carried a repo, `#N` otherwise, + * lowercased, deduplicated against `seen`, in first-seen order. + */ +export const minePrRefs = ( + text: string, + seen = new Set(), +): string[] => { + const out: string[] = []; + let start = 0; + while (start < text.length) { + let end = text.indexOf('\n', start); + if (end === -1) end = text.length; + const line = text.slice(start, end); + start = end + 1; + const said = assistantTextOfLine(line); + if (!said) continue; + PR_REF_RE.lastIndex = 0; + for (let m = PR_REF_RE.exec(said); m; m = PR_REF_RE.exec(said)) { + const canonical = ( + m[1] ? `${m[1]}#${m[2]}` : m[3] ? `${m[3]}#${m[4]}` : `#${m[5]}` + ).toLowerCase(); + if (seen.has(canonical)) continue; + seen.add(canonical); + out.push(canonical); + } + } + return out; +}; + +// --------------------------------------------------------------------------- +// File layer +// --------------------------------------------------------------------------- + +const CACHE_FILENAME = 'enrichment-cache.json'; + +export const getEnrichmentCachePath = (): string => + path.join(os.homedir(), '.config', 'codev', CACHE_FILENAME); + +/** Read the cache; any failure is a cold start, never an error. */ +export const readEnrichmentCacheFile = (filePath: string): EnrichmentState => { + try { + return deserializeEnrichment( + JSON.parse(fs.readFileSync(filePath, 'utf-8')), + ); + } catch { + return deserializeEnrichment(null); + } +}; + +export const writeEnrichmentCacheFile = ( + filePath: string, + state: EnrichmentState, + keep?: Set, +): void => writeStoreFile(filePath, serializeEnrichment(state, keep)); diff --git a/src/main.ts b/src/main.ts index e40bb84..8229ff7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,6 +31,9 @@ import { launchNewClaudeSession, scanClosedVSCodeSessions, getSessionsByIds, + flushEnrichmentCache, + openSessionListMembers, + runBackgroundEnrichmentScan, } from './claude-session-utility'; import { mutateSessionMarks, @@ -1336,6 +1339,15 @@ const trayToggleEvtHandler = async () => { console.log('when ready'); } + // One background pass over every session (issue #140), well after the + // window's own first scan so it never competes with the first paint. With + // the persisted cache (issue #134) a second launch's pass is stat-only. + setTimeout(() => { + runBackgroundEnrichmentScan().catch((err) => { + console.error('[enrichment] background scan failed:', err); + }); + }, 20000); + // Set callback for CodeV embedded terminal sessions setCodevTerminalCallback((_sessionId: string) => { showSwitcherWindow(); @@ -2022,6 +2034,9 @@ app.once('before-quit', () => { if (serverProcess) { serverProcess.kill(); } + // The enrichment cache is written a few seconds after a scan; do not lose + // the last scan of the run to a quit that lands inside that window. + flushEnrichmentCache(); }); ipcMain.handle('get-app-version', () => { @@ -2606,6 +2621,32 @@ ipcMain.on('open-claude-session', async (_event, sessionId: string, projectPath: openSession(sessionId, projectPath, isActive, activePid, terminalApp, terminalMode, customTitle); }); +// Resume every member of a saved list that is not running (issue #145). +ipcMain.handle( + 'open-session-list-members', + async ( + _event, + members: { sessionId: string; project: string; accountLabel?: string; title?: string }[], + ) => { + const terminalApp = ((await settings.get('session-terminal-app')) || 'iterm2') as string; + const terminalMode = ((await settings.get('session-terminal-mode')) || 'tab') as string; + if (terminalApp === 'codev') { + // The embedded terminal holds one session; "open twelve" has no meaning there. + return { + opened: [], + skipped: [], + error: 'The embedded terminal holds one session — pick a terminal app in Settings first', + }; + } + const safe = Array.isArray(members) + ? members.filter( + (m) => m && typeof m.sessionId === 'string' && typeof m.project === 'string', + ) + : []; + return openSessionListMembers(safe, terminalApp, terminalMode); + }, +); + ipcMain.on('launch-new-claude-session', async (_event, projectPath: string, accountLabel?: string) => { if (!existsSync(projectPath)) { console.log('[launch-new-claude-session] path does not exist:', projectPath); diff --git a/src/preload.ts b/src/preload.ts index aea6b1d..32e7e13 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -111,6 +111,9 @@ contextBridge.exposeInMainWorld('electronAPI', { refreshSessionPreview: (sessions: any[]) => ipcRenderer.invoke('refresh-session-preview', sessions), openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => ipcRenderer.send('open-claude-session', sessionId, projectPath, isActive, activePid, customTitle), + openSessionListMembers: ( + members: { sessionId: string; project: string; accountLabel?: string; title?: string }[], + ) => ipcRenderer.invoke('open-session-list-members', members), launchNewClaudeSession: (projectPath: string, accountLabel?: string) => ipcRenderer.send('launch-new-claude-session', projectPath, accountLabel), launchNewClaudeSessionInCodev: (projectPath: string) => diff --git a/src/session-search.test.ts b/src/session-search.test.ts index b60ac17..f9c0906 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -1,12 +1,22 @@ import { describe, expect, it } from 'vitest'; import { + compileQuery, + emptyQuery, extractSnippet, + findPrRef, findPromptMatch, + highlightNeedles, + isEmptyQuery, isMinorSession, matchesAllWords, matchesAllWordsOrId, matchesSessionId, + parsePrRef, + parseQuery, + parseQueryDate, + promptNeedles, + tokenizeQuery, truncateMiddle, windowAroundMatch, } from './session-search'; @@ -313,3 +323,296 @@ describe('matchesSessionId — the id is a prefix target, not a substring', () = ); }); }); + +describe('tokenizeQuery', () => { + it('splits on whitespace and keeps a quoted phrase together, quotes removed', () => { + expect(tokenizeQuery('a b\tc')).toEqual(['a', 'b', 'c']); + expect(tokenizeQuery('title:"foo bar" x')).toEqual(['title:foo bar', 'x']); + expect(tokenizeQuery('"foo bar"')).toEqual(['foo bar']); + }); + + it('lets an unterminated quote run to the end (someone is still typing)', () => { + expect(tokenizeQuery('a "foo bar')).toEqual(['a', 'foo bar']); + }); +}); + +describe('parsePrRef', () => { + it('reads the URL, owner/repo#N and #N forms, never a bare number', () => { + expect(parsePrRef('https://github.com/grimmerk/codev/pull/147')).toEqual({ + number: 147, + repo: 'grimmerk/codev', + }); + expect( + parsePrRef('https://github.com/grimmerk/codev/issues/144#issuecomment-1'), + ).toEqual({ number: 144, repo: 'grimmerk/codev' }); + expect(parsePrRef('github.com/o/r/pull/9/files')).toEqual({ + number: 9, + repo: 'o/r', + }); + expect(parsePrRef('grimmerk/codev#147')).toEqual({ + number: 147, + repo: 'grimmerk/codev', + }); + expect(parsePrRef('#147')).toEqual({ number: 147 }); + expect(parsePrRef('147')).toBeNull(); + expect(parsePrRef('#abc')).toBeNull(); + expect(parsePrRef('https://github.com/grimmerk/codev')).toBeNull(); + }); +}); + +describe('parseQueryDate', () => { + const now = new Date(2026, 8, 5, 15, 30).getTime(); + it('reads YYYY-MM-DD as local midnight, Nh/Nd/Nw as ago, today and yesterday', () => { + expect(parseQueryDate('2026-09-01', now)).toBe( + new Date(2026, 8, 1).getTime(), + ); + expect(parseQueryDate('7d', now)).toBe(now - 7 * 86400000); + expect(parseQueryDate('2w', now)).toBe(now - 14 * 86400000); + expect(parseQueryDate('3h', now)).toBe(now - 3 * 3600000); + expect(parseQueryDate('today', now)).toBe(new Date(2026, 8, 5).getTime()); + expect(parseQueryDate('yesterday', now)).toBe( + new Date(2026, 8, 4).getTime(), + ); + expect(parseQueryDate('soon', now)).toBeNull(); + expect(parseQueryDate('2026-13-40', now)).toBeNull(); + }); +}); + +describe('parseQuery', () => { + const now = new Date(2026, 8, 5, 15, 30).getTime(); + + it('keeps a query with no operators exactly as before: lowercased bare words', () => { + const q = parseQuery('Multi Sessions', now); + expect(q.words).toEqual(['multi', 'sessions']); + expect(q.fields).toEqual([]); + expect(q.prRefs).toEqual([]); + expect(q.ignored).toEqual([]); + }); + + it('routes each operator: fields, has:, is:, after:/before:, pr:', () => { + const q = parseQuery( + 'title:PR2 branch:mcp msg:"deploy now" project:fred account:work recap:next has:pr is:live after:7d before:2026-09-01 pr:147', + now, + ); + expect(q.fields).toEqual([ + { field: 'title', value: 'pr2' }, + { field: 'branch', value: 'mcp' }, + { field: 'msg', value: 'deploy now' }, + { field: 'project', value: 'fred' }, + { field: 'account', value: 'work' }, + { field: 'recap', value: 'next' }, + ]); + expect(q.has).toEqual(['pr']); + expect(q.is).toEqual(['live']); + expect(q.after).toBe(now - 7 * 86400000); + expect(q.before).toBe(new Date(2026, 8, 1).getTime()); + expect(q.prRefs).toEqual([{ number: 147 }]); + expect(q.words).toEqual([]); + }); + + it('recognises PR references in every spelling, and pr: with a repo or URL', () => { + const q = parseQuery( + '#147 grimmerk/codev#148 https://github.com/o/r/pull/9 pr:o/r#10 pr:https://github.com/o/r/issues/11', + now, + ); + expect(q.prRefs).toEqual([ + { number: 147 }, + { number: 148, repo: 'grimmerk/codev' }, + { number: 9, repo: 'o/r' }, + { number: 10, repo: 'o/r' }, + { number: 11, repo: 'o/r' }, + ]); + expect(q.words).toEqual([]); + }); + + it('leaves an unknown key:, a clock time and a non-PR URL as bare words', () => { + const q = parseQuery('error:ENOENT 12:30 https://example.com/x foo', now); + expect(q.words).toEqual([ + 'error:enoent', + '12:30', + 'https://example.com/x', + 'foo', + ]); + expect(q.ignored).toEqual([]); + }); + + it('reports an operator whose value is unusable instead of silently dropping it', () => { + const q = parseQuery('title: has:tea is:cold after:soon pr:abc ok', now); + expect(q.ignored).toEqual([ + 'title:', + 'has:tea', + 'is:cold', + 'after:soon', + 'pr:abc', + ]); + expect(q.words).toEqual(['ok']); + }); + + it('accepts is:running and is:active as is:live', () => { + expect(parseQuery('is:running', now).is).toEqual(['live']); + expect(parseQuery('is:active', now).is).toEqual(['live']); + }); + + it('narrows repeated bounds: the latest after:, the earliest before:', () => { + const q = parseQuery( + 'after:2026-09-01 after:2026-09-03 before:2026-09-04 before:2026-09-02', + now, + ); + expect(q.after).toBe(new Date(2026, 8, 3).getTime()); + expect(q.before).toBe(new Date(2026, 8, 2).getTime()); + }); + + it('isEmptyQuery is true only when nothing constrains the result', () => { + expect(isEmptyQuery(parseQuery('', now))).toBe(true); + expect(isEmptyQuery(parseQuery('title:', now))).toBe(true); + expect(isEmptyQuery(emptyQuery())).toBe(true); + expect(isEmptyQuery(parseQuery('is:live', now))).toBe(false); + expect(isEmptyQuery(parseQuery('after:1d', now))).toBe(false); + expect(isEmptyQuery(parseQuery('x', now))).toBe(false); + }); +}); + +describe('findPrRef', () => { + it('matches #N, owner/repo#N, /pull/N and /issues/N — delimited, never a bare number', () => { + expect(findPrRef('see #147 for details', { number: 147 })).toEqual({ + index: 4, + length: 4, + }); + expect(findPrRef('grimmerk/codev#147', { number: 147 })).toEqual({ + index: 0, + length: 18, + }); + expect( + findPrRef('x https://github.com/o/r/pull/147/files', { number: 147 }), + ).toEqual({ + index: 10, + length: 23, + }); + expect( + findPrRef('https://github.com/o/r/issues/147', { number: 147 }), + ).not.toBeNull(); + expect(findPrRef('#1475 and 15980 and 147', { number: 147 })).toBeNull(); + expect(findPrRef('/pull/1470', { number: 147 })).toBeNull(); + expect(findPrRef('a#147', { number: 147 })).toBeNull(); + }); + + it('honours the repo when the reference names one; a bare #N in the target still matches', () => { + const ref = { number: 147, repo: 'grimmerk/codev' }; + expect( + findPrRef('https://github.com/fireflies/fred/pull/147', ref), + ).toBeNull(); + expect(findPrRef('fireflies/fred#147', ref)).toBeNull(); + expect( + findPrRef('https://github.com/grimmerk/codev/pull/147', ref), + ).not.toBeNull(); + expect(findPrRef('grimmerk/codev#147', ref)).not.toBeNull(); + expect(findPrRef('opened #147 today', ref)).not.toBeNull(); + // A wrong-repo hit does not hide a right one further on. + expect( + findPrRef('fireflies/fred#147 then grimmerk/codev#147', ref), + ).toEqual({ + index: 24, + length: 18, + }); + }); +}); + +describe('compileQuery', () => { + const now = new Date(2026, 8, 5, 15, 30).getTime(); + const target = { + sessionId: '4ed7505a-eae6-43a5-827b-465c8b5eb759', + text: 'codev /Users/g/git/codev open the PR for me #147 harden again', + title: 'agentic-fred harden again - pr2-1533', + branch: 'feat-mcp-arch', + project: 'codev /Users/g/git/codev', + account: 'work', + recap: 'Next: push the fix', + prompts: ['open the PR for me', 'now address #147'], + hasPr: true, + isLive: true, + isPinned: false, + lastTimestamp: new Date(2026, 8, 4).getTime(), + }; + const matches = (query: string, t = target) => + compileQuery(parseQuery(query, now)).test(t); + + it('bare words search the text, case-insensitively, and the session id by prefix', () => { + expect(matches('HARDEN codev')).toBe(true); + expect(matches('harden absent')).toBe(false); + expect(matches('4ed7')).toBe(true); + expect(matches('4ed')).toBe(false); + }); + + it('scoped terms look only at their field', () => { + expect(matches('title:pr2')).toBe(true); + expect(matches('title:codev')).toBe(false); + expect(matches('branch:mcp')).toBe(true); + expect(matches('project:git/codev')).toBe(true); + expect(matches('account:work')).toBe(true); + expect(matches('account:home')).toBe(false); + expect(matches('recap:push')).toBe(true); + expect(matches('msg:address')).toBe(true); + expect(matches('msg:harden')).toBe(false); + }); + + it('a scoped term fails on a target that lacks the field, so a caller must not compile what it cannot judge', () => { + expect(matches('title:pr2', { ...target, title: undefined })).toBe(false); + expect(matches('msg:open', { ...target, prompts: undefined })).toBe(false); + expect(matches('is:live', { ...target, isLive: undefined })).toBe(false); + }); + + it('has:, is:, after:, before: read the flags and the timestamp', () => { + expect(matches('has:pr has:title has:branch has:recap')).toBe(true); + expect(matches('has:pr', { ...target, hasPr: false })).toBe(false); + expect(matches('has:recap', { ...target, recap: '' })).toBe(false); + expect(matches('is:live')).toBe(true); + expect(matches('is:pinned')).toBe(false); + expect(matches('after:2026-09-04')).toBe(true); + expect(matches('after:2026-09-05')).toBe(false); + expect(matches('before:2026-09-05')).toBe(true); + expect(matches('before:2026-09-04')).toBe(false); + expect(matches('after:1d', { ...target, lastTimestamp: undefined })).toBe( + false, + ); + }); + + it('PR references match any spelling in the text, and every term must hold', () => { + expect(matches('#147')).toBe(true); + expect(matches('pr:147 title:pr2')).toBe(true); + expect(matches('pr:148')).toBe(false); + expect(matches('#147 title:nope')).toBe(false); + }); + + it('an ignored operator does not constrain the match', () => { + expect(matches('after:soon harden')).toBe(true); + }); +}); + +describe('highlightNeedles / promptNeedles', () => { + it('collects words, scoped values and the spellings of each PR reference, deduplicated', () => { + const q = parseQuery('foo title:bar msg:baz #147 foo', 0); + expect(highlightNeedles(q)).toEqual([ + 'foo', + 'bar', + 'baz', + '#147', + '/pull/147', + '/issues/147', + ]); + expect(promptNeedles(q)).toEqual(['foo', 'foo', 'baz']); + }); +}); + +describe('findPromptMatch with PR references', () => { + it('lands on the prompt carrying the reference when no word matches', () => { + const m = findPromptMatch( + ['setup', 'please look at o/r#147 now'], + [], + [{ number: 147 }], + ); + expect(m).toEqual({ + promptIndex: 1, + snippet: 'please look at o/r#147 now', + }); + }); +}); diff --git a/src/session-search.ts b/src/session-search.ts index 6a36e4c..24d1dd4 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -4,7 +4,390 @@ * Used by: * - claude-session-utility.ts — main-side full-prompt search (issue #131) * - switcher-ui.tsx — minor-session folding predicate + * + * The query language (issue #140, plan §4.6) lives here too: `parseQuery` + * turns the search box into terms, `compileQuery` turns the terms into ONE + * matcher that both search paths run. Two implementations of "does this + * session match" is how PR #137 and #147 each produced a round of drift bugs; + * the two callers now differ only in what they can put on the target. + */ + +// --------------------------------------------------------------------------- +// Query language +// --------------------------------------------------------------------------- + +/** Fields a term can be aimed at with `field:value`. */ +export type ScopedField = + | 'title' + | 'branch' + | 'msg' + | 'project' + | 'account' + | 'recap'; + +const SCOPED_FIELDS: ReadonlySet = new Set([ + 'title', + 'branch', + 'msg', + 'project', + 'account', + 'recap', +]); +const HAS_VALUES: ReadonlySet = new Set([ + 'pr', + 'title', + 'branch', + 'recap', +]); +const IS_VALUES: ReadonlySet = new Set(['live', 'pinned']); +const IS_ALIASES: Record = { running: 'live', active: 'live' }; + +/** + * A pull-request (or issue — GitHub numbers them together) reference. The + * repo is kept when the query carried one, so `grimmerk/codev#147` does not + * match a `fireflies/x/pull/147` URL; a bare `#147` in the target still + * matches either, because a bare number cannot say which repo it meant. + */ +export interface PrRef { + number: number; + /** `owner/repo`, lowercased. */ + repo?: string; +} + +export interface ScopedTerm { + field: ScopedField; + /** Lowercased. */ + value: string; +} + +export interface ParsedQuery { + /** Bare words (lowercased): today's "search everything" semantics. */ + words: string[]; + fields: ScopedTerm[]; + prRefs: PrRef[]; + /** `has:` values: `pr` `title` `branch` `recap`. */ + has: string[]; + /** `is:` values: `live` `pinned`. */ + is: string[]; + /** `after:` — sessions last active at or after this epoch ms. */ + after?: number; + /** `before:` — sessions last active before this epoch ms. */ + before?: number; + /** + * Terms recognised as operators whose value could not be used + * (`after:soon`, `has:tea`, `title:`). Dropped from matching and reported, + * so the search box can say so rather than silently ignoring them. + */ + ignored: string[]; +} + +export const emptyQuery = (): ParsedQuery => ({ + words: [], + fields: [], + prRefs: [], + has: [], + is: [], + ignored: [], +}); + +/** True when nothing in the query constrains the result. */ +export const isEmptyQuery = (q: ParsedQuery): boolean => + q.words.length === 0 && + q.fields.length === 0 && + q.prRefs.length === 0 && + q.has.length === 0 && + q.is.length === 0 && + q.after === undefined && + q.before === undefined; + +/** + * Split on whitespace, honouring double quotes: `title:"foo bar"` is one + * token with the quotes removed (`title:foo bar`). An unterminated quote runs + * to the end of the query, which is what someone still typing expects. + */ +export const tokenizeQuery = (query: string): string[] => { + const out: string[] = []; + let cur = ''; + let quoted = false; + for (const ch of query) { + if (ch === '"') { + quoted = !quoted; + continue; + } + if (!quoted && /\s/.test(ch)) { + if (cur) out.push(cur); + cur = ''; + continue; + } + cur += ch; + } + if (cur) out.push(cur); + return out; +}; + +const REPO = '[a-z0-9_.-]+\\/[a-z0-9_.-]+'; +const PR_URL_RE = new RegExp( + `^(?:https?:\\/\\/)?(?:www\\.)?github\\.com\\/(${REPO})\\/(?:pull|issues)\\/(\\d+)(?:[/?#].*)?$`, +); +const REPO_HASH_RE = new RegExp(`^(${REPO})#(\\d+)$`); +const BARE_HASH_RE = /^#(\d+)$/; + +/** + * Read a token (lowercased) as a PR reference, or null. Forms: the GitHub + * URL (pull or issues, trailing path allowed), `owner/repo#N`, `#N`. Never a + * bare number — `1598` is a word. + */ +export const parsePrRef = (tokenLower: string): PrRef | null => { + let m = PR_URL_RE.exec(tokenLower); + if (m) return { number: Number(m[2]), repo: m[1] }; + m = REPO_HASH_RE.exec(tokenLower); + if (m) return { number: Number(m[2]), repo: m[1] }; + m = BARE_HASH_RE.exec(tokenLower); + if (m) return { number: Number(m[1]) }; + return null; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; +const RELATIVE_UNIT_MS: Record = { + h: 60 * 60 * 1000, + d: DAY_MS, + w: 7 * DAY_MS, +}; + +/** + * A point in time for `after:` / `before:`: `YYYY-MM-DD` (local midnight), + * `Nh` / `Nd` / `Nw` ago, `today`, `yesterday`. Null when unreadable. + */ +export const parseQueryDate = (value: string, now: number): number | null => { + const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (iso) { + const [y, mo, d] = [Number(iso[1]), Number(iso[2]), Number(iso[3])]; + const date = new Date(y, mo - 1, d); + // Date rolls an out-of-range month or day forward (2026-13-40 becomes a + // real day in 2027); a typo must be reported, not silently moved. + return date.getMonth() === mo - 1 && date.getDate() === d + ? date.getTime() + : null; + } + const rel = /^(\d+)([hdw])$/.exec(value); + if (rel) return now - Number(rel[1]) * RELATIVE_UNIT_MS[rel[2]]; + if (value === 'today' || value === 'yesterday') { + const d = new Date(now); + d.setHours(0, 0, 0, 0); + return d.getTime() - (value === 'yesterday' ? DAY_MS : 0); + } + return null; +}; + +/** + * Parse the search box. Everything is lowercased; a token with an unknown + * `key:` prefix (`error:`, `12:30`, a URL that is not a PR) stays a bare + * word, so the operators cost nothing to queries that do not use them. + */ +export const parseQuery = (query: string, now = Date.now()): ParsedQuery => { + const q = emptyQuery(); + for (const raw of tokenizeQuery(query)) { + const token = raw.toLowerCase(); + const pr = parsePrRef(token); + if (pr) { + q.prRefs.push(pr); + continue; + } + const colon = token.indexOf(':'); + const key = colon > 0 ? token.slice(0, colon) : ''; + const value = colon > 0 ? token.slice(colon + 1) : ''; + if (key === 'pr') { + // `pr:147`, `pr:o/r#147`, `pr:` — a number alone is allowed here + // because the key already says what it is. + const ref = /^\d+$/.test(value) + ? { number: Number(value) } + : parsePrRef(value); + if (ref) q.prRefs.push(ref); + else q.ignored.push(raw); + } else if (SCOPED_FIELDS.has(key)) { + if (value) q.fields.push({ field: key as ScopedField, value }); + else q.ignored.push(raw); + } else if (key === 'has') { + if (HAS_VALUES.has(value)) q.has.push(value); + else q.ignored.push(raw); + } else if (key === 'is') { + const v = IS_ALIASES[value] ?? value; + if (IS_VALUES.has(v)) q.is.push(v); + else q.ignored.push(raw); + } else if (key === 'after' || key === 'before') { + const t = parseQueryDate(value, now); + if (t === null) q.ignored.push(raw); + else if (key === 'after') q.after = Math.max(q.after ?? -Infinity, t); + else q.before = Math.min(q.before ?? Infinity, t); + } else if (token) { + q.words.push(token); + } + } + return q; +}; + +/** + * What a matcher needs to know about one session. Everything optional: a + * caller supplies what it has, and a term aimed at a field the caller could + * not supply simply fails for that session — so a caller that cannot judge a + * term must leave it out of the query it compiles (see the two callers). */ +export interface QueryTarget { + sessionId: string; + /** Free text bare words and PR references search. Raw case. */ + text: string; + title?: string; + branch?: string; + /** Project name and path together. */ + project?: string; + account?: string; + recap?: string; + /** For `msg:` — every prompt the caller has (all of them on the main side; first/last in the renderer). */ + prompts?: string[]; + hasPr?: boolean; + isLive?: boolean; + isPinned?: boolean; + lastTimestamp?: number; +} + +/** + * Where a PR reference occurs in `textLower`, honouring the repo when the + * reference carries one. Delimited forms only — `#147` never matches inside + * `#1475` or `15980` — which is the rule §4.6 insists on: **never match a + * bare number**. + */ +interface PrRefPatterns { + ref: PrRef; + n: string; + hash: RegExp; + url: RegExp; +} + +// Built once per query, not once per session. +const prRefPatterns = (ref: PrRef): PrRefPatterns => { + const n = String(ref.number); + return { + ref, + n, + // `#N` and `owner/repo#N`. The optional repo group is anchored by the + // boundary before it, so `foo/bar#12` captures `foo/bar`, not `o/bar`. + hash: new RegExp(`(?:^|[^0-9a-z_./-])((?:${REPO})?)#${n}(?!\\d)`, 'g'), + url: new RegExp( + `github\\.com\\/(${REPO})\\/(?:pull|issues)\\/${n}(?!\\d)`, + 'g', + ), + }; +}; + +const findPrRefWith = ( + textLower: string, + p: PrRefPatterns, +): { index: number; length: number } | null => { + const { ref, n, hash, url } = p; + // Most sessions never mention the number at all; a substring check is far + // cheaper than either regex over a session's whole prompt text. Measured + // over 560 sessions: a PR-reference keystroke cost ~20ms without this, + // against ~8ms for a bare word. + if (!textLower.includes(n)) return null; + hash.lastIndex = 0; + for (let m = hash.exec(textLower); m; m = hash.exec(textLower)) { + const repo = m[1]; + if (ref.repo && repo && repo !== ref.repo) continue; + const at = m.index + m[0].length - n.length - 1 - repo.length; + return { index: at, length: repo.length + 1 + n.length }; + } + url.lastIndex = 0; + for (let m = url.exec(textLower); m; m = url.exec(textLower)) { + if (ref.repo && m[1] !== ref.repo) continue; + return { index: m.index, length: m[0].length }; + } + return null; +}; + +export const findPrRef = ( + textLower: string, + ref: PrRef, +): { index: number; length: number } | null => + findPrRefWith(textLower, prRefPatterns(ref)); + +export interface QueryMatcher { + test: (target: QueryTarget) => boolean; + query: ParsedQuery; +} + +/** + * One matcher for one query. Every term must hold (AND), and a term that + * looks at a field the target does not carry fails — the caller decides what + * it can judge by what it compiles, not by how the matcher forgives. + */ +export const compileQuery = (query: ParsedQuery): QueryMatcher => { + const lower = (s: string | undefined) => (s ?? '').toLowerCase(); + const prPatterns = query.prRefs.map(prRefPatterns); + const test = (t: QueryTarget): boolean => { + const textLower = t.text.toLowerCase(); + for (const w of query.words) { + if (!textLower.includes(w) && !matchesSessionId(t.sessionId, w)) + return false; + } + for (const p of prPatterns) { + if (!findPrRefWith(textLower, p)) return false; + } + for (const { field, value } of query.fields) { + if (field === 'msg') { + if (!(t.prompts ?? []).some((p) => p.toLowerCase().includes(value))) + return false; + } else if (!lower(t[field]).includes(value)) { + return false; + } + } + for (const h of query.has) { + const present = + h === 'pr' + ? !!t.hasPr + : h === 'title' + ? !!t.title + : h === 'branch' + ? !!t.branch + : !!t.recap; + if (!present) return false; + } + for (const i of query.is) { + if (i === 'live' ? !t.isLive : !t.isPinned) return false; + } + if ( + query.after !== undefined && + !((t.lastTimestamp ?? -Infinity) >= query.after) + ) + return false; + if ( + query.before !== undefined && + !((t.lastTimestamp ?? Infinity) < query.before) + ) + return false; + return true; + }; + return { test, query }; +}; + +/** + * Strings worth highlighting in a matched row: bare words, scoped values, and + * the `#N` / `/pull/N` spellings of each PR reference. Highlighting is + * cosmetic — `#147` will also light up inside `#1475` on a row that matched + * for another reason — so it deliberately does not re-run the matcher. + */ +export const highlightNeedles = (q: ParsedQuery): string[] => { + const out = [...q.words, ...q.fields.map((f) => f.value)]; + for (const r of q.prRefs) { + out.push(`#${r.number}`, `/pull/${r.number}`, `/issues/${r.number}`); + } + return [...new Set(out.filter(Boolean))]; +}; + +/** The words a prompt snippet should be centred on: bare words and `msg:` values. */ +export const promptNeedles = (q: ParsedQuery): string[] => [ + ...q.words, + ...q.fields.filter((f) => f.field === 'msg').map((f) => f.value), +]; export interface PromptMatch { /** 0-based index into the session's prompt list (0 = first user message). */ @@ -73,6 +456,7 @@ export const extractSnippet = ( export const findPromptMatch = ( prompts: string[], wordsLower: string[], + prRefs: PrRef[] = [], ): PromptMatch | null => { for (let i = 0; i < prompts.length; i++) { const lower = prompts[i].toLowerCase(); @@ -85,6 +469,15 @@ export const findPromptMatch = ( }; } } + for (const ref of prRefs) { + const hit = findPrRef(lower, ref); + if (hit) { + return { + promptIndex: i, + snippet: extractSnippet(prompts[i], hit.index, hit.length), + }; + } + } } return null; }; diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 0ae23ca..b783bbb 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -13,8 +13,12 @@ import { mergeSessionsById, } from './session-list-view'; import { - matchesAllWordsOrId, + compileQuery, + emptyQuery, + highlightNeedles, + isEmptyQuery, matchesSessionId, + parseQuery, truncateMiddle, windowAroundMatch, } from './session-search'; @@ -702,6 +706,13 @@ function SwitcherApp() { }); const [viewingListId, setViewingListId] = useState(null); const [confirmDeleteListId, setConfirmDeleteListId] = useState(null); + // "Open the members not running" (issue #145): armed by a first click, + // fired by the second, same shape as delete. `openingList` while the main + // process is still staggering the launches. + const [confirmOpenListId, setConfirmOpenListId] = useState(null); + const [openingList, setOpeningList] = useState(false); + // The query-language cheat sheet under the search box (issue #140). + const [searchHelpOpen, setSearchHelpOpen] = useState(false); const [listsNotice, setListsNotice] = useState(null); // Set when the lists store exists but cannot be trusted as written — a // file an earlier build wrote, or hand-edited. Persistent until repaired @@ -761,24 +772,70 @@ function SwitcherApp() { fetchVSCodeBasedIDESqlite(); //retryFetchRecentProjectRecord(); }; + // Sessions the ps join saw running — the `is:live` answer, and the "not + // running" side of opening a list. A registration-only fallback for the + // moment before the first report lands. + const liveSessionIds = () => { + const ids = new Set(); + for (const p of liveReport?.live ?? []) if (p.sessionId) ids.add(p.sessionId); + if (!liveReport) for (const id of Object.keys(activeStateRef.current)) ids.add(id); + return ids; + }; + const isRunning = (s: { sessionId: string; isActive?: boolean }, liveIds: Set) => + liveIds.has(s.sessionId) || (!liveReport && !!s.isActive); + + // The SAME matcher the main-side search runs (session-search.ts), over + // what this side has: first/last prompt, enrichment, a saved-list member's + // captured fields, and the two things only this side can judge — `is:live` + // (the ps join) and `is:pinned` (the pin store). The session id is + // searchable too, by the prefix rule in matchesSessionId (#142). const filterSessionsLocally = (allItems: any[], query: string) => { - if (!query.trim()) return allItems; - const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const parsed = parseQuery(query); + if (isEmptyQuery(parsed)) return allItems; + const matcher = compileQuery(parsed); + const liveIds = liveSessionIds(); return allItems.filter((s) => { - const prInfo = prLinks[s.sessionId]; - const terminalBadge = terminalApps[s.sessionId] || ((s as any).entrypoint === 'claude-vscode' ? 'vscode' : ''); - // The session id is searchable too — by the prefix rule in - // matchesSessionId, shared with the main-side searchClaudeSessions so - // both paths agree on what an id query is (#142: the id is the one - // field that stays unique when several sessions share a name). + const id = s.sessionId; + const prInfo = prLinks[id]; + const terminalBadge = terminalApps[id] || ((s as any).entrypoint === 'claude-vscode' ? 'vscode' : ''); // A saved-list member whose session is gone has only what was captured; // search those fields too, or a query on its captured title drops it. - const captured = s.__listMember; - const capturedText = captured - ? `${captured.title || ''} ${captured.branch || ''} ${captured.recap?.text || ''} ${captured.lastUserMessage || ''} ${captured.lastAssistantMessage || ''}` - : ''; - const searchTarget = `${s.projectName} ${s.project} ${s.firstUserMessage} ${s.lastUserMessage} ${customTitles[s.sessionId] || ''} ${branches[s.sessionId] || ''} ${prInfo ? `PR #${prInfo.prNumber} ${prInfo.prUrl}` : ''} ${assistantResponses[s.sessionId] || ''} ${terminalBadge} ${capturedText}`.toLowerCase(); - return matchesAllWordsOrId(searchTarget, s.sessionId, words); + const captured = s.__listMember as SessionListMember | undefined; + const title = customTitles[id] || captured?.title; + const branch = branches[id] || captured?.branch; + const recap = recaps[id]?.text || captured?.recap?.text; + const prompts = [s.firstUserMessage, s.lastUserMessage, captured?.lastUserMessage].filter( + (p): p is string => !!p, + ); + const text = [ + s.projectName, + s.project, + s.firstUserMessage, + s.lastUserMessage, + title, + branch, + prInfo ? `PR #${prInfo.prNumber} ${prInfo.prUrl}` : '', + assistantResponses[id], + terminalBadge, + recap, + captured?.lastAssistantMessage, + ] + .filter(Boolean) + .join('\n'); + return matcher.test({ + sessionId: id, + text, + title, + branch, + project: `${s.projectName ?? ''} ${s.project ?? ''}`, + account: s.accountLabel, + recap, + prompts, + hasPr: !!prInfo, + isLive: isRunning(s, liveIds), + isPinned: id in sessionMarks.pins, + lastTimestamp: s.lastTimestamp, + }); }); }; @@ -838,13 +895,30 @@ function SwitcherApp() { const base = filterSessionsLocally(candidates, query); if (!query.trim() || deepMatchesRef.current.length === 0) return base; const seen = new Set(base.map((s: any) => s.sessionId)); + // The main side judged every term it can see; `is:` it cannot (the ps + // join and the pin store live here), so a deep match still has to pass + // those before it is appended. + const parsed = parseQuery(query); + const isMatcher = + parsed.is.length > 0 ? compileQuery({ ...emptyQuery(), is: parsed.is }) : null; + const liveIds = liveSessionIds(); const extra = deepMatchesRef.current .filter((s: any) => !seen.has(s.sessionId)) .map((s: any) => ({ ...s, isActive: s.sessionId in activeStateRef.current, activePid: activeStateRef.current[s.sessionId], - })); + })) + .filter( + (s: any) => + !isMatcher || + isMatcher.test({ + sessionId: s.sessionId, + text: '', + isLive: isRunning(s, liveIds), + isPinned: s.sessionId in sessionMarks.pins, + }), + ); if (extra.length === 0) return base; const merged = [...base, ...extra]; merged.sort( @@ -943,17 +1017,22 @@ function SwitcherApp() { customTitles, branches, prLinks, + recaps, assistantResponses, terminalApps, extraPinnedSessions, extraScopeSessions, + // `is:live` and `is:pinned` read these; a row must (dis)appear when + // they change, not on the next keystroke. + liveReport, + sessionMarks, ]); const isSearchingSessions = sessionSearchValue.trim().length > 0; - const searchWordsLower = sessionSearchValue - .toLowerCase() - .split(/\s+/) - .filter(Boolean); + // One parse per render for everything that highlights or annotates a row. + const parsedSearch = parseQuery(sessionSearchValue); + const searchHighlightWords = highlightNeedles(parsedSearch); + const searchWordsLower = searchHighlightWords.map((w) => w.toLowerCase()); // One rule for every length-capped line in a row: while searching, the window // moves to the first match so you can see WHY the row is in the results; // otherwise it keeps both ends, because these titles put the newest step last. @@ -1276,6 +1355,7 @@ function SwitcherApp() { setLiveOnly(false); setViewingListId(id); setConfirmDeleteListId(null); + setConfirmOpenListId(null); setSelectedSessionIndex(0); // Members resolve their running state from the ps join as well as the // registration map (see activeFrom in session-list-view.ts), so viewing a @@ -1285,8 +1365,40 @@ function SwitcherApp() { }; const closeList = () => { setViewingListId(null); + setConfirmOpenListId(null); setSelectedSessionIndex(0); }; + // Resume the members of a list that are not running (issue #145). The + // main process staggers the launches and reports what it skipped; the + // running set is re-read on return so the rows turn green as they register. + const openListMembers = async (members: SessionListMember[]) => { + setConfirmOpenListId(null); + if (members.length === 0 || openingList) return; + setOpeningList(true); + try { + const r = await window.electronAPI.openSessionListMembers( + members.map((m) => ({ + sessionId: m.sessionId, + project: m.project, + accountLabel: m.accountLabel, + title: m.title, + })), + ); + if (r?.error) { + showListsNotice(r.error); + } else if (r && r.skipped.length > 0) { + const byReason = new Map(); + for (const s of r.skipped) byReason.set(s.reason, (byReason.get(s.reason) ?? 0) + 1); + const why = [...byReason].map(([reason, n]) => `${n} ${reason}`).join(', '); + showListsNotice(`Opened ${r.opened.length}, skipped ${r.skipped.length} (${why})`); + } + } catch { + showListsNotice('open failed'); + } finally { + setOpeningList(false); + refreshLiveReport(); + } + }; const deleteList = async (id: string) => { try { const r = await window.electronAPI.deleteSessionList(id); @@ -2435,6 +2547,30 @@ function SwitcherApp() { outline: 'none', }} /> + {/* The query language (issue #140) has no discoverability of its + own; one chip opens the cheat sheet. */} + e.preventDefault()} + onClick={() => setSearchHelpOpen((v) => !v)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSearchHelpOpen((v) => !v); + } + }} + style={{ + ...SCOPE_CHIP_STYLE, + border: `1px solid ${searchHelpOpen ? '#888' : '#444'}`, + color: searchHelpOpen ? '#ddd' : '#888', + padding: '1px 5px', + }} + > + ? + {/* Scope chips: live processes (issue #94) and saved lists (issue #145). Here, not in the list, because this row has spare width and the list has no spare height. */} @@ -2547,6 +2683,52 @@ function SwitcherApp() { : `${pinnedOnlyActive ? displayedSessions.length : sessions.length} sessions`} + {searchHelpOpen && ( +
+
+ title:x{' '} + branch:x{' '} + msg:x{' '} + project:x{' '} + account:x{' '} + recap:x{' '} + "two words" +
+
+ has:pr|title|branch|recap{' '} + is:live|pinned{' '} + after:7d|2026-09-01|today{' '} + before:… +
+
+ #147{' '} + pr:147{' '} + owner/repo#147{' '} + https://github.com/…/pull/147 + {' '}— any spelling finds the others, in prompts and in what the assistant said +
+
+ bare words search everything; a session id matches by prefix (4+ hex chars); every term must hold +
+
+ )} + {parsedSearch.ignored.length > 0 && ( +
+ ⚠ ignored: {parsedSearch.ignored.join(' ')} + {' '}— unreadable value (see ?) +
+ )} {/* The store exists but cannot be trusted as written. Say so, with what it holds — never rewrite it from here (same policy as the marks store; a future format change gets a migration, not a @@ -2590,6 +2772,69 @@ function SwitcherApp() { ✎ + {(() => { + // Only the members NOT running, by the ps join: pressing + // it twice opens nothing the second time. The projected + // cost is on the button because that cost — a process per + // session — is exactly why this is a deliberate act. + const liveIds = liveSessionIds(); + const notRunning = viewingList.members.filter((m) => !isRunning(m, liveIds)); + const n = notRunning.length; + const meanKb = + liveReport && liveReport.live.length > 0 + ? liveReport.totalRssKb / liveReport.live.length + : 145 * 1024; + const projected = formatMb(n * meanKb); + const armed = confirmOpenListId === viewingList.id; + const idle = n === 0 || openingList; + const label = openingList + ? 'opening…' + : n === 0 + ? 'all running' + : armed + ? `open ${n} · ~${projected}?` + : `▶ open ${n}`; + const fire = () => { + if (idle) return; + if (armed) openListMembers(notRunning); + else setConfirmOpenListId(viewingList.id); + }; + return ( + e.preventDefault()} + onClick={(e) => { + e.stopPropagation(); + fire(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fire(); + } + }} + style={{ + ...LISTS_CHIP_STYLE, + marginLeft: 'auto', + marginRight: '6px', + cursor: idle ? 'default' : 'pointer', + color: idle ? '#666' : armed ? '#e0b060' : LISTS_CHIP_STYLE.color, + border: `1px solid ${idle ? '#3a3a3a' : armed ? '#e0b060' : '#4a6a8a'}`, + }} + > + {label} + + ); + })()} {' '}* {' '}[ matchesSessionId(session.sessionId, w)) && ( + parsedSearch.words.some((w) => matchesSessionId(session.sessionId, w)) && ( { const prInfo = prLinks[session.sessionId]; - const searchWords = sessionSearchValue.split(/\s+/).filter(Boolean); + const searchWords = searchHighlightWords; // Highlight badge when search matches PR URL (not just badge text) - const urlMatch = searchWords.length > 0 && searchWords.some((w: string) => - prInfo.prUrl.toLowerCase().includes(w.toLowerCase())); + // or names this PR by number in any spelling. + const urlMatch = + (searchWords.length > 0 && + searchWords.some((w: string) => + prInfo.prUrl.toLowerCase().includes(w.toLowerCase()))) || + parsedSearch.prRefs.some((r) => r.number === prInfo.prNumber); return ( @@ -3048,7 +3298,8 @@ function SwitcherApp() { textTransform: 'uppercase', }}> @@ -3069,7 +3320,7 @@ function SwitcherApp() { {(sessionDisplayMode === 'first' || sessionDisplayMode === 'both') && session.firstUserMessage && ( {' → '} { const m = searchSnippets[session.sessionId]; if (!m || !isSearchingSessions) return null; - const words = sessionSearchValue.split(/\s+/).filter(Boolean); + const words = searchHighlightWords; // Stale guard: snippet must still match the current query if (!words.some((w) => m.snippet.toLowerCase().includes(w.toLowerCase()))) return null; const dupFirst = m.promptIndex === 0 && (sessionDisplayMode === 'first' || sessionDisplayMode === 'both'); @@ -3163,7 +3414,7 @@ function SwitcherApp() { recap{stale ? ' ⏱' : ''} {' '} Date: Sat, 5 Sep 2026 21:11:35 +0800 Subject: [PATCH 2/9] fix(sessions): [Image #N] paste marker is not a PR reference --- CHANGELOG.md | 2 +- README.md | 2 +- src/enrichment-cache.test.ts | 8 +++++--- src/enrichment-cache.ts | 4 ++++ src/session-search.test.ts | 17 +++++++++++++++++ src/session-search.ts | 12 ++++++++++++ 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e14be..92ef899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Feat: aim the search — field-scoped terms, PR references in any spelling, `is:live`, and a persisted enrichment cache ([#140](https://github.com/grimmerk/codev/issues/140), [#134](https://github.com/grimmerk/codev/issues/134)) - **Operators**: `title:x` `branch:x` `msg:x` `project:x` `account:x` `recap:x` search one field; `has:pr|title|branch|recap`; `is:live|pinned`; `after:7d` / `after:2026-09-01` / `after:today` and `before:…` by last activity; `"two words"` keeps a phrase together. Bare words keep today's meaning. Every term must hold. A `?` chip beside the search box shows the list; an operator with an unreadable value is reported under the box and ignored rather than silently matching nothing - - **A pull request in any spelling**: `#147`, `pr:147`, `owner/repo#147` and the GitHub URL all find each other, in your prompts and in what the assistant said. Measured on the reference machine's prompts: 80.6% of PR mentions were reachable by only one form before. Delimited forms only — `#147` never hits `#1475` or `15980` — and a repo in the query rules out the same number in another repo's URL + - **A pull request in any spelling**: `#147`, `pr:147`, `owner/repo#147` and the GitHub URL all find each other, in your prompts and in what the assistant said. Measured on the reference machine's prompts: 80.6% of PR mentions were reachable by only one form before. Delimited forms only — `#147` never hits `#1475` or `15980` — and a repo in the query rules out the same number in another repo's URL. Claude Code's `[Image #N]` marker for a pasted screenshot is not a PR reference (the first live test found `pr:151` matching a session whose only "#151" was `[Image #151]`) - **The assistant's replies are mined for the PRs it mentioned** (its text and the commands it ran; never tool output, so a session that ran `gh pr list` did not "work on" twenty PRs), so "the PR you opened for me" is findable by number. Measured: 91 transcripts / 739MB mined in 2.0s cold, then incrementally from the byte where the last pass stopped; 3,889 references across 65 sessions - **One matcher, two callers**: the main-side full-prompt search and the renderer's filter now compile the same query into one matcher and differ only in what each side can put on the target (`is:` is judged in the renderer, everything else on the main side, which also sees title / branch / recap for every session now). Search stays behind the 180ms debounce: 7–10ms per keystroke for words, ~20ms for a PR reference, over 560 sessions - **The enrichment cache survives a restart** (`~/.config/codev/enrichment-cache.json`): title, branch, PR badge, recap, mined references and per-file mtime+size, written a few seconds after a scan and flushed on quit. A second launch starts warm and re-reads only transcripts that changed. A bad file is a cold start, never an error. **One background pass over every session** runs 20s after launch, in chunks with pauses, so operators see every session and not just the loaded window; measured 6.8s cold for the whole corpus, stat-only afterwards diff --git a/README.md b/README.md index c41af25..7da43f5 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Bare words search everything; **operators aim the query** (the `?` chip beside t | `has:pr` `has:title` `has:branch` `has:recap` | sessions that carry the thing | | `is:live` `is:pinned` | sessions with a running process (by the `ps` join below) / pinned ones | | `after:7d` `after:2026-09-01` `after:today` `before:…` | by the session's last activity (`Nh` `Nd` `Nw`, a date, `today`, `yesterday`) | -| `#147` `pr:147` `owner/repo#147` `https://github.com/owner/repo/pull/147` | **a pull request in any spelling** — the query form and the form in the text no longer have to agree (measured: 80.6% of PR mentions in prompts used only one form). A repo in the query rules out the same number in another repo's URL. Never a bare number: `#147` does not hit `1475` or `#1475` | +| `#147` `pr:147` `owner/repo#147` `https://github.com/owner/repo/pull/147` | **a pull request in any spelling** — the query form and the form in the text no longer have to agree (measured: 80.6% of PR mentions in prompts used only one form). A repo in the query rules out the same number in another repo's URL. Never a bare number: `#147` does not hit `1475` or `#1475`, and the `[Image #N]` marker of a pasted screenshot is not a reference | Every term must hold. The assistant's own replies are mined for the PRs it mentioned (its text and the commands it ran — not tool output, so a session that merely listed twenty PRs did not "work on" them), so "the PR you opened for me" is findable by number even when you never typed it. An operator with an unreadable value (`after:soon`) is reported under the box and ignored rather than silently matching nothing. diff --git a/src/enrichment-cache.test.ts b/src/enrichment-cache.test.ts index f662140..bb715ba 100644 --- a/src/enrichment-cache.test.ts +++ b/src/enrichment-cache.test.ts @@ -171,12 +171,14 @@ describe('minePrRefs', () => { expect([...seen]).toEqual(['#3', '#4']); }); - it('never reads a bare number, an HTML entity, a leading zero, or a hex-looking anchor', () => { + it('never reads a bare number, an HTML entity, a leading zero, a hex-looking anchor, or a paste marker', () => { expect( minePrRefs( - assistant('15980 and & and #0 and #012 and a#9 and x/y#1a'), + assistant( + '15980 and & and #0 and #012 and a#9 and x/y#1a and [Image #4] but #5', + ), ), - ).toEqual(['x/y#1']); + ).toEqual(['x/y#1', '#5']); }); it('assistantTextOfLine joins text and tool inputs, and rejects everything else', () => { diff --git a/src/enrichment-cache.ts b/src/enrichment-cache.ts index 652dd74..a5b9b2b 100644 --- a/src/enrichment-cache.ts +++ b/src/enrichment-cache.ts @@ -24,6 +24,7 @@ import * as os from 'os'; import * as path from 'path'; import { writeStoreFile } from './atomic-json-store'; +import { isImageMarkerHash } from './session-search'; export const ENRICHMENT_CACHE_VERSION = 1; @@ -240,6 +241,9 @@ export const minePrRefs = ( if (!said) continue; PR_REF_RE.lastIndex = 0; for (let m = PR_REF_RE.exec(said); m; m = PR_REF_RE.exec(said)) { + // The bare form's match starts at the delimiter before the `#` (if any). + if (m[5] && isImageMarkerHash(said, m.index + m[0].indexOf('#'))) + continue; const canonical = ( m[1] ? `${m[1]}#${m[2]}` : m[3] ? `${m[3]}#${m[4]}` : `#${m[5]}` ).toLowerCase(); diff --git a/src/session-search.test.ts b/src/session-search.test.ts index f9c0906..bee01dc 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -8,6 +8,7 @@ import { findPromptMatch, highlightNeedles, isEmptyQuery, + isImageMarkerHash, isMinorSession, matchesAllWords, matchesAllWordsOrId, @@ -515,6 +516,22 @@ describe('findPrRef', () => { length: 18, }); }); + + // Live finding: `pr:151` listed a session whose only "#151" was the marker + // Claude Code writes for a pasted screenshot. + it('does not read the [Image #N] paste marker as a reference, but still finds a real one after it', () => { + expect( + findPrRef('[image #151] 我剛測試打包版', { number: 151 }), + ).toBeNull(); + expect(findPrRef('[Image #151]'.toLowerCase(), { number: 151 })).toBeNull(); + expect(findPrRef('[image #151] then see #151', { number: 151 })).toEqual({ + index: 22, + length: 4, + }); + expect(isImageMarkerHash('[Image #3]', 7)).toBe(true); + expect(isImageMarkerHash('image #3', 6)).toBe(false); + expect(isImageMarkerHash('#3', 0)).toBe(false); + }); }); describe('compileQuery', () => { diff --git a/src/session-search.ts b/src/session-search.ts index 24d1dd4..018b4a6 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -279,6 +279,17 @@ const prRefPatterns = (ref: PrRef): PrRefPatterns => { }; }; +/** + * Is the `#` at `hashIndex` the one in Claude Code's `[Image #N]` marker for + * a pasted screenshot? Those are numbered per session from 1 — exactly the + * range small PR numbers live in. Seen in the first live test: `pr:151` + * listed a session whose only "#151" was `[Image #151]`. One rule for the + * query matcher and the transcript miner, so they cannot disagree about it. + */ +export const isImageMarkerHash = (text: string, hashIndex: number): boolean => + hashIndex >= 7 && + text.slice(hashIndex - 7, hashIndex).toLowerCase() === '[image '; + const findPrRefWith = ( textLower: string, p: PrRefPatterns, @@ -294,6 +305,7 @@ const findPrRefWith = ( const repo = m[1]; if (ref.repo && repo && repo !== ref.repo) continue; const at = m.index + m[0].length - n.length - 1 - repo.length; + if (!repo && isImageMarkerHash(textLower, at)) continue; return { index: at, length: repo.length + 1 + n.length }; } url.lastIndex = 0; From 75de64287aebcee7847a117e77f2621f27384487 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 21:12:55 +0800 Subject: [PATCH 3/9] test(sessions): ASCII fixture for the paste-marker case --- src/session-search.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/session-search.test.ts b/src/session-search.test.ts index bee01dc..94c81d0 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -521,7 +521,7 @@ describe('findPrRef', () => { // Claude Code writes for a pasted screenshot. it('does not read the [Image #N] paste marker as a reference, but still finds a real one after it', () => { expect( - findPrRef('[image #151] 我剛測試打包版', { number: 151 }), + findPrRef('[image #151] tested the packaged build', { number: 151 }), ).toBeNull(); expect(findPrRef('[Image #151]'.toLowerCase(), { number: 151 })).toBeNull(); expect(findPrRef('[image #151] then see #151', { number: 151 })).toEqual({ From ecdd6f193512c4055907f13785d1d13a6e81bd9d Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 22:32:35 +0800 Subject: [PATCH 4/9] fix(sessions): round 1 - member account, flush order, PR-ref bounds --- src/claude-session-utility.ts | 92 ++++++++++++++++++++++++++--------- src/electron-api.d.ts | 17 ++++++- src/enrichment-cache.test.ts | 16 +++++- src/enrichment-cache.ts | 14 ++++-- src/main.ts | 13 ++++- src/preload.ts | 26 ++++++++-- src/session-search.test.ts | 23 ++++++++- src/session-search.ts | 18 ++++--- src/switcher-ui.tsx | 4 +- 9 files changed, 178 insertions(+), 45 deletions(-) diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index bbf58d1..609e62c 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -109,6 +109,10 @@ export const invalidateSessionCache = () => { cachedPRLinks = null; cachedRecaps = null; cachedPRRefs = null; + // Persist what the last scan found BEFORE clearing it: a debounced write + // still pending would otherwise fire after the clear and replace the disk + // cache with `sessions: {}`. Flushing also cancels that timer. + flushEnrichmentCache(); enrichedFileState.clear(); prRefBytes.clear(); // The disk cache is still valid (freshness is per file, by mtime+size); @@ -1188,19 +1192,29 @@ const detectActiveSessionsLegacy = async (activeMap: Map): Promi * cached session list, which tags each session with its config dir) and return * the CLAUDE_CONFIG_DIR to prefix at resume — or null for the default account. */ -const getResumeConfigDirEnv = (sessionId: string): string | null => { +const getResumeConfigDirEnv = ( + sessionId: string, + accountLabel?: string, +): string | null => { const s = readClaudeSessions(Number.MAX_SAFE_INTEGER).find( (x) => x.sessionId === sessionId, ); - return s?.accountConfigDirEnv ?? null; + if (s) return s.accountConfigDirEnv; + // Not in any history (a /branch child, a pruned history): trust the label + // a saved list captured, or the resume lands under the anchor account and + // never finds its transcript. + return accountLabel ? getAccountByLabel(accountLabel).configDirEnv : null; }; /** * Build `command claude --resume `, prefixed with CLAUDE_CONFIG_DIR when the * session belongs to a non-default account so it resumes under the right account. */ -const buildResumeCommand = (sessionId: string): string => { - const configDir = getResumeConfigDirEnv(sessionId); +const buildResumeCommand = ( + sessionId: string, + accountLabel?: string, +): string => { + const configDir = getResumeConfigDirEnv(sessionId, accountLabel); // Single-quote the value: some terminal injections (Ghostty `initial input`) // don't escape the command, so a double-quoted prefix would break their // AppleScript string. Single quotes are safe across all terminals + handle spaces. @@ -1229,6 +1243,7 @@ export const openSession = async ( terminalApp: string = 'iterm2', terminalMode: string = 'tab', customTitle?: string, + accountLabel?: string, ): Promise => { let effectiveTerminal = terminalApp; @@ -1259,17 +1274,17 @@ export const openSession = async ( openSessionInCodeV(sessionId); break; case 'cmux': - openSessionInCmux(sessionId, projectPath, isActive, activePid, customTitle); + openSessionInCmux(sessionId, projectPath, isActive, activePid, customTitle, accountLabel); break; case 'ghostty': - openSessionInGhostty(sessionId, projectPath, isActive, terminalMode, customTitle); + openSessionInGhostty(sessionId, projectPath, isActive, terminalMode, customTitle, accountLabel); break; case 'terminal': - openSessionInTerminalApp(sessionId, projectPath, isActive, activePid, terminalMode, customTitle); + openSessionInTerminalApp(sessionId, projectPath, isActive, activePid, terminalMode, customTitle, accountLabel); break; case 'iterm2': default: - openSessionInITerm2(sessionId, projectPath, isActive, activePid, terminalMode, customTitle); + openSessionInITerm2(sessionId, projectPath, isActive, activePid, terminalMode, customTitle, accountLabel); break; } }; @@ -1707,6 +1722,7 @@ export const openSessionInITerm2 = ( activePid?: number, terminalMode: string = 'tab', customTitle?: string, + accountLabel?: string, ): void => { const { exec } = require('child_process'); @@ -1762,7 +1778,7 @@ end tell`; try { fs.unlinkSync(tmpScript); } catch {} }); } else { - const resumeCmd = buildResumeCommand(sessionId); + const resumeCmd = buildResumeCommand(sessionId, accountLabel); runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'iterm2', terminalMode); } }; @@ -2235,6 +2251,24 @@ export interface OpenListMembersResult { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +// Sessions a list-open is launching right now. A second invocation that +// overlaps the first (the renderer disables the button, but the IPC does not +// know that) must not launch the same session again before it registers. +const membersInFlight = new Set(); + +const runningSessionIds = (): Set => { + const running = new Set(); + for (const reg of readSessionRegistrations()) { + try { + process.kill(reg.pid, 0); + running.add(reg.sessionId); + } catch { + // dead pid: a stale registration, not a running session + } + } + return running; +}; + /** * Resume the members of a saved list that are not running (issue #145: * after a reboot, after closing everything to reclaim memory, or to move the @@ -2252,17 +2286,9 @@ export const openSessionListMembers = async ( staggerMs = 700, ): Promise => { const result: OpenListMembersResult = { opened: [], skipped: [] }; - const running = new Set(); - for (const reg of readSessionRegistrations()) { - try { - process.kill(reg.pid, 0); - running.add(reg.sessionId); - } catch { - // dead pid: a stale registration, not a running session - } - } for (const m of members) { - if (running.has(m.sessionId)) { + // Re-read per launch: the previous launch may have registered by now. + if (runningSessionIds().has(m.sessionId) || membersInFlight.has(m.sessionId)) { result.skipped.push({ sessionId: m.sessionId, reason: 'already running' }); continue; } @@ -2275,7 +2301,22 @@ export const openSessionListMembers = async ( continue; } if (result.opened.length > 0) await sleep(staggerMs); - await openSession(m.sessionId, m.project, false, undefined, terminalApp, terminalMode, m.title); + membersInFlight.add(m.sessionId); + try { + await openSession( + m.sessionId, + m.project, + false, + undefined, + terminalApp, + terminalMode, + m.title, + m.accountLabel, + ); + } finally { + // Long enough for the new process to write its registration. + setTimeout(() => membersInFlight.delete(m.sessionId), 10000); + } result.opened.push(m.sessionId); } return result; @@ -2345,6 +2386,7 @@ export const openSessionInGhostty = ( isActive: boolean, terminalMode: string = 'tab', customTitle?: string, + accountLabel?: string, ): void => { const { exec } = require('child_process'); @@ -2393,7 +2435,7 @@ end tell`; try { fs.unlinkSync(tmpScript); } catch {} }); } else { - const resumeCmd = buildResumeCommand(sessionId); + const resumeCmd = buildResumeCommand(sessionId, accountLabel); runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'ghostty', terminalMode); } }; @@ -2410,6 +2452,7 @@ export const openSessionInTerminalApp = ( activePid?: number, terminalMode: string = 'tab', customTitle?: string, + accountLabel?: string, ): void => { const { exec } = require('child_process'); @@ -2455,7 +2498,7 @@ end tell`; try { fs.unlinkSync(tmpScript); } catch {} }); } else { - const resumeCmd = buildResumeCommand(sessionId); + const resumeCmd = buildResumeCommand(sessionId, accountLabel); runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'terminal', terminalMode); } }; @@ -2473,9 +2516,10 @@ export const openSessionInCmux = ( isActive: boolean, activePid?: number, customTitle?: string, + accountLabel?: string, ): void => { const { exec } = require('child_process'); - const command = `cd "${projectPath}" && ${buildResumeCommand(sessionId)}`; + const command = `cd "${projectPath}" && ${buildResumeCommand(sessionId, accountLabel)}`; console.log('[cmux] openSession:', { sessionId, projectPath, isActive, activePid, customTitle }); if (isActive) { @@ -2600,7 +2644,7 @@ export const openSessionInCmux = ( exec('osascript -e \'tell application "cmux" to activate\''); })(); } else { - const resumeCmd = buildResumeCommand(sessionId); + const resumeCmd = buildResumeCommand(sessionId, accountLabel); runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'cmux'); } }; diff --git a/src/electron-api.d.ts b/src/electron-api.d.ts index d289411..6c238e4 100644 --- a/src/electron-api.d.ts +++ b/src/electron-api.d.ts @@ -234,10 +234,23 @@ interface IElectronAPI { detectTerminalApps: (pidMap: Record, entrypointMap?: Record) => Promise>; scanClosedVSCodeSessions: (activeSessionIds: string[]) => Promise; refreshSessionPreview: (sessions: any[]) => Promise>; - openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => void; + /** `accountLabel` resolves the account for a session no history knows (a saved-list member). */ + openClaudeSession: ( + sessionId: string, + projectPath: string, + isActive: boolean, + activePid?: number, + customTitle?: string, + accountLabel?: string, + ) => void; /** Resume the given saved-list members that are not running; staggered, skips report why. */ openSessionListMembers: ( - members: { sessionId: string; project: string; accountLabel?: string; title?: string }[], + members: { + sessionId: string; + project: string; + accountLabel?: string; + title?: string; + }[], ) => Promise<{ opened: string[]; skipped: { sessionId: string; reason: string }[]; diff --git a/src/enrichment-cache.test.ts b/src/enrichment-cache.test.ts index bb715ba..85665b4 100644 --- a/src/enrichment-cache.test.ts +++ b/src/enrichment-cache.test.ts @@ -90,15 +90,27 @@ describe('serializeEnrichment / deserializeEnrichment', () => { prRefs: ['#1', 2, ''], prRefsScannedBytes: 'many', }, + // A cursor past the file, or not a whole byte, cannot be resumed from. + tooFar: { mtimeMs: 1, size: 10, prRefsScannedBytes: 11 }, + fractional: { mtimeMs: 1, size: 10, prRefsScannedBytes: 2.5 }, + atEnd: { mtimeMs: 1, size: 10, prRefsScannedBytes: 10 }, bad: { mtimeMs: 'one', size: 1 }, alsoBad: null, }, }); - expect([...partial.fileState.keys()]).toEqual(['ok']); + expect([...partial.fileState.keys()]).toEqual([ + 'ok', + 'tooFar', + 'fractional', + 'atEnd', + ]); expect(partial.titles.get('ok')).toBe('x'); expect(partial.prLinks.has('ok')).toBe(false); expect(partial.prRefs.get('ok')).toEqual(['#1']); expect(partial.prRefBytes.has('ok')).toBe(false); + expect(partial.prRefBytes.has('tooFar')).toBe(false); + expect(partial.prRefBytes.has('fractional')).toBe(false); + expect(partial.prRefBytes.get('atEnd')).toBe(10); }); }); @@ -138,7 +150,7 @@ describe('minePrRefs', () => { { type: 'thinking', thinking: 'maybe #99', signature: 'x' }, { type: 'text', - text: 'opened #3, https://github.com/Grimmerk/CodeV/pull/147, o/r#5, again #3', + text: 'opened #3, https://GitHub.com/Grimmerk/CodeV/Pull/147, o/r#5, again #3', }, ]), user([ diff --git a/src/enrichment-cache.ts b/src/enrichment-cache.ts index a5b9b2b..4b3f742 100644 --- a/src/enrichment-cache.ts +++ b/src/enrichment-cache.ts @@ -147,8 +147,16 @@ export const deserializeEnrichment = (raw: unknown): EnrichmentState => { ); if (refs.length > 0) state.prRefs.set(id, refs); } - if (isFiniteNumber(e.prRefsScannedBytes) && e.prRefsScannedBytes > 0) { - state.prRefBytes.set(id, e.prRefsScannedBytes); + // A cursor is only usable as a whole-line byte offset inside the file + // it was recorded for: an integer in (0, size]. Anything else (a hand + // edit, a truncated write) is dropped so mining restarts at zero rather + // than skipping bytes or failing to allocate a chunk. + if ( + Number.isInteger(e.prRefsScannedBytes) && + (e.prRefsScannedBytes as number) > 0 && + (e.prRefsScannedBytes as number) <= e.size + ) { + state.prRefBytes.set(id, e.prRefsScannedBytes as number); } } return state; @@ -173,7 +181,7 @@ export const deserializeEnrichment = (raw: unknown): EnrichmentState => { * read as references; a leading zero is excluded so `#0` is not. */ export const PR_REF_RE = - /github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/(?:pull|issues)\/([1-9][0-9]*)|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)|(?:^|[^A-Za-z0-9&#])#([1-9][0-9]*)/g; + /github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/(?:pull|issues)\/([1-9][0-9]*)|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)|(?:^|[^A-Za-z0-9&#])#([1-9][0-9]*)/gi; /** * The words the assistant actually wrote in one JSONL record, or null when diff --git a/src/main.ts b/src/main.ts index 8229ff7..b5fbc66 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2615,10 +2615,19 @@ ipcMain.handle('scan-closed-vscode-sessions', async (_event, activeSessionIds: s return scanClosedVSCodeSessions(new Set(activeSessionIds), vsCodeIndex); }); -ipcMain.on('open-claude-session', async (_event, sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => { +ipcMain.on('open-claude-session', async (_event, sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string, accountLabel?: string) => { const terminalApp = ((await settings.get('session-terminal-app')) || 'iterm2') as string; const terminalMode = ((await settings.get('session-terminal-mode')) || 'tab') as string; - openSession(sessionId, projectPath, isActive, activePid, terminalApp, terminalMode, customTitle); + openSession( + sessionId, + projectPath, + isActive, + activePid, + terminalApp, + terminalMode, + customTitle, + typeof accountLabel === 'string' ? accountLabel : undefined, + ); }); // Resume every member of a saved list that is not running (issue #145). diff --git a/src/preload.ts b/src/preload.ts index 32e7e13..235f64b 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -109,10 +109,30 @@ contextBridge.exposeInMainWorld('electronAPI', { detectTerminalApps: (pidMap: Record, entrypointMap?: Record) => ipcRenderer.invoke('detect-terminal-apps', pidMap, entrypointMap), scanClosedVSCodeSessions: (activeSessionIds: string[]) => ipcRenderer.invoke('scan-closed-vscode-sessions', activeSessionIds), refreshSessionPreview: (sessions: any[]) => ipcRenderer.invoke('refresh-session-preview', sessions), - openClaudeSession: (sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string) => - ipcRenderer.send('open-claude-session', sessionId, projectPath, isActive, activePid, customTitle), + openClaudeSession: ( + sessionId: string, + projectPath: string, + isActive: boolean, + activePid?: number, + customTitle?: string, + accountLabel?: string, + ) => + ipcRenderer.send( + 'open-claude-session', + sessionId, + projectPath, + isActive, + activePid, + customTitle, + accountLabel, + ), openSessionListMembers: ( - members: { sessionId: string; project: string; accountLabel?: string; title?: string }[], + members: { + sessionId: string; + project: string; + accountLabel?: string; + title?: string; + }[], ) => ipcRenderer.invoke('open-session-list-members', members), launchNewClaudeSession: (projectPath: string, accountLabel?: string) => ipcRenderer.send('launch-new-claude-session', projectPath, accountLabel), diff --git a/src/session-search.test.ts b/src/session-search.test.ts index 94c81d0..1e5e202 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -357,6 +357,11 @@ describe('parsePrRef', () => { expect(parsePrRef('#147')).toEqual({ number: 147 }); expect(parsePrRef('147')).toBeNull(); expect(parsePrRef('#abc')).toBeNull(); + // Leading zeros are not a PR number, here as in the miner. + expect(parsePrRef('#012')).toBeNull(); + expect(parsePrRef('#0')).toBeNull(); + expect(parsePrRef('o/r#012')).toBeNull(); + expect(parsePrRef('https://github.com/o/r/pull/012')).toBeNull(); expect(parsePrRef('https://github.com/grimmerk/codev')).toBeNull(); }); }); @@ -438,17 +443,27 @@ describe('parseQuery', () => { }); it('reports an operator whose value is unusable instead of silently dropping it', () => { - const q = parseQuery('title: has:tea is:cold after:soon pr:abc ok', now); + const q = parseQuery( + 'title: has:tea is:cold after:soon pr:abc pr:012 ok', + now, + ); expect(q.ignored).toEqual([ 'title:', 'has:tea', 'is:cold', 'after:soon', 'pr:abc', + 'pr:012', ]); expect(q.words).toEqual(['ok']); }); + it('keeps a leading-zero hash as a bare word rather than reading it as a PR', () => { + const q = parseQuery('#012', now); + expect(q.prRefs).toEqual([]); + expect(q.words).toEqual(['#012']); + }); + it('accepts is:running and is:active as is:live', () => { expect(parseQuery('is:running', now).is).toEqual(['live']); expect(parseQuery('is:active', now).is).toEqual(['live']); @@ -495,6 +510,12 @@ describe('findPrRef', () => { expect(findPrRef('#1475 and 15980 and 147', { number: 147 })).toBeNull(); expect(findPrRef('/pull/1470', { number: 147 })).toBeNull(); expect(findPrRef('a#147', { number: 147 })).toBeNull(); + // The number must end there: an identifier or a longer number is not it. + expect(findPrRef('see #147abc', { number: 147 })).toBeNull(); + expect(findPrRef('see #147_x', { number: 147 })).toBeNull(); + expect(findPrRef('/pull/147abc', { number: 147 })).toBeNull(); + expect(findPrRef('see #147, done', { number: 147 })).not.toBeNull(); + expect(findPrRef('(see #147)', { number: 147 })).not.toBeNull(); }); it('honours the repo when the reference names one; a bare #N in the target still matches', () => { diff --git a/src/session-search.ts b/src/session-search.ts index 018b4a6..ede5abf 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -126,11 +126,13 @@ export const tokenizeQuery = (query: string): string[] => { }; const REPO = '[a-z0-9_.-]+\\/[a-z0-9_.-]+'; +// Numbers never start with a zero, matching the miner: `#012` is not PR 12. +const NUMBER = '[1-9][0-9]*'; const PR_URL_RE = new RegExp( - `^(?:https?:\\/\\/)?(?:www\\.)?github\\.com\\/(${REPO})\\/(?:pull|issues)\\/(\\d+)(?:[/?#].*)?$`, + `^(?:https?:\\/\\/)?(?:www\\.)?github\\.com\\/(${REPO})\\/(?:pull|issues)\\/(${NUMBER})(?:[/?#].*)?$`, ); -const REPO_HASH_RE = new RegExp(`^(${REPO})#(\\d+)$`); -const BARE_HASH_RE = /^#(\d+)$/; +const REPO_HASH_RE = new RegExp(`^(${REPO})#(${NUMBER})$`); +const BARE_HASH_RE = new RegExp(`^#(${NUMBER})$`); /** * Read a token (lowercased) as a PR reference, or null. Forms: the GitHub @@ -199,7 +201,7 @@ export const parseQuery = (query: string, now = Date.now()): ParsedQuery => { if (key === 'pr') { // `pr:147`, `pr:o/r#147`, `pr:` — a number alone is allowed here // because the key already says what it is. - const ref = /^\d+$/.test(value) + const ref = /^[1-9][0-9]*$/.test(value) ? { number: Number(value) } : parsePrRef(value); if (ref) q.prRefs.push(ref); @@ -271,9 +273,13 @@ const prRefPatterns = (ref: PrRef): PrRefPatterns => { n, // `#N` and `owner/repo#N`. The optional repo group is anchored by the // boundary before it, so `foo/bar#12` captures `foo/bar`, not `o/bar`. - hash: new RegExp(`(?:^|[^0-9a-z_./-])((?:${REPO})?)#${n}(?!\\d)`, 'g'), + // The number must END there too: `#147abc` is an identifier, not PR 147. + hash: new RegExp( + `(?:^|[^0-9a-z_./-])((?:${REPO})?)#${n}(?![0-9a-z_])`, + 'g', + ), url: new RegExp( - `github\\.com\\/(${REPO})\\/(?:pull|issues)\\/${n}(?!\\d)`, + `github\\.com\\/(${REPO})\\/(?:pull|issues)\\/${n}(?![0-9a-z_])`, 'g', ), }; diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index b783bbb..7829878 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -2512,7 +2512,7 @@ function SwitcherApp() { if (s && !s.__liveOrphan) { // Arm before opening, in case the bridge triggers the focus cycle synchronously. clearSessionSearchOnShowRef.current = true; - window.electronAPI.openClaudeSession(s.sessionId, s.project, s.isActive, s.activePid, customTitles[s.sessionId]); + window.electronAPI.openClaudeSession(s.sessionId, s.project, s.isActive, s.activePid, customTitles[s.sessionId], s.accountLabel); } } else if ((e.metaKey || e.ctrlKey) && (e.key === 'd' || e.key === 'D')) { // ⌘D toggles pin, ⇧⌘D toggles hide on the selected row. @@ -3058,7 +3058,7 @@ function SwitcherApp() { // A running process with no session id has nothing to resume. if (session.__liveOrphan) return; clearSessionSearchOnShowRef.current = true; - window.electronAPI.openClaudeSession(session.sessionId, session.project, session.isActive, session.activePid, customTitles[session.sessionId]); + window.electronAPI.openClaudeSession(session.sessionId, session.project, session.isActive, session.activePid, customTitles[session.sessionId], session.accountLabel); }} style={{ display: 'flex', From c3fa46feab95345ed8281f17b4156380ffd537fd Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 22:51:59 +0800 Subject: [PATCH 5/9] fix(sessions): round 2 - strict PR refs, miner bounds, safe paths --- CHANGELOG.md | 2 +- README.md | 2 +- docs/session-finding-plan.md | 19 +++-- src/claude-session-utility.ts | 126 +++++++++++++++++++++++++++------- src/enrichment-cache.test.ts | 14 +++- src/enrichment-cache.ts | 12 +++- src/main.ts | 54 +++++++++++---- src/session-search.test.ts | 73 ++++++++++++++++++-- src/session-search.ts | 83 +++++++++++++++++++--- src/switcher-ui.tsx | 25 ++++++- 10 files changed, 345 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ef899..20d6b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Feat: aim the search — field-scoped terms, PR references in any spelling, `is:live`, and a persisted enrichment cache ([#140](https://github.com/grimmerk/codev/issues/140), [#134](https://github.com/grimmerk/codev/issues/134)) - **Operators**: `title:x` `branch:x` `msg:x` `project:x` `account:x` `recap:x` search one field; `has:pr|title|branch|recap`; `is:live|pinned`; `after:7d` / `after:2026-09-01` / `after:today` and `before:…` by last activity; `"two words"` keeps a phrase together. Bare words keep today's meaning. Every term must hold. A `?` chip beside the search box shows the list; an operator with an unreadable value is reported under the box and ignored rather than silently matching nothing - - **A pull request in any spelling**: `#147`, `pr:147`, `owner/repo#147` and the GitHub URL all find each other, in your prompts and in what the assistant said. Measured on the reference machine's prompts: 80.6% of PR mentions were reachable by only one form before. Delimited forms only — `#147` never hits `#1475` or `15980` — and a repo in the query rules out the same number in another repo's URL. Claude Code's `[Image #N]` marker for a pasted screenshot is not a PR reference (the first live test found `pr:151` matching a session whose only "#151" was `[Image #151]`) + - **A pull request in any spelling**: `#147`, `pr:147`, `owner/repo#147` and the GitHub URL all find each other, in your prompts and in what the assistant said. Measured on the reference machine's prompts: 80.6% of PR mentions were reachable by only one form before. Delimited forms only — `#147` never hits `#1475` or `15980`. Three levels of strictness, set by the first live test: `#147` is broad (any mention, any repo); `pr:147` counts only a session's own PR badge or a mention that names its repo; a repo in the query (`owner/repo#147`, the URL, `pr:owner/repo#147`) accepts a bare `#147` only in sessions whose own badge or references name that repo. (That test listed eight sessions for `pr:151`; most were the miner reading `#151` out of longer identifiers such as the hex colour `#151e2b` — fixed by a right-side boundary on both sides — and the strict levels stay because every repo does have its own 151.) Claude Code's `[Image #N]` marker for a pasted screenshot is not a PR reference (found matching a session whose only "#151" was `[Image #151]`) - **The assistant's replies are mined for the PRs it mentioned** (its text and the commands it ran; never tool output, so a session that ran `gh pr list` did not "work on" twenty PRs), so "the PR you opened for me" is findable by number. Measured: 91 transcripts / 739MB mined in 2.0s cold, then incrementally from the byte where the last pass stopped; 3,889 references across 65 sessions - **One matcher, two callers**: the main-side full-prompt search and the renderer's filter now compile the same query into one matcher and differ only in what each side can put on the target (`is:` is judged in the renderer, everything else on the main side, which also sees title / branch / recap for every session now). Search stays behind the 180ms debounce: 7–10ms per keystroke for words, ~20ms for a PR reference, over 560 sessions - **The enrichment cache survives a restart** (`~/.config/codev/enrichment-cache.json`): title, branch, PR badge, recap, mined references and per-file mtime+size, written a few seconds after a scan and flushed on quit. A second launch starts warm and re-reads only transcripts that changed. A bad file is a cold start, never an error. **One background pass over every session** runs 20s after launch, in chunks with pauses, so operators see every session and not just the loaded window; measured 6.8s cold for the whole corpus, stat-only afterwards diff --git a/README.md b/README.md index 7da43f5..64c3481 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Bare words search everything; **operators aim the query** (the `?` chip beside t | `has:pr` `has:title` `has:branch` `has:recap` | sessions that carry the thing | | `is:live` `is:pinned` | sessions with a running process (by the `ps` join below) / pinned ones | | `after:7d` `after:2026-09-01` `after:today` `before:…` | by the session's last activity (`Nh` `Nd` `Nw`, a date, `today`, `yesterday`) | -| `#147` `pr:147` `owner/repo#147` `https://github.com/owner/repo/pull/147` | **a pull request in any spelling** — the query form and the form in the text no longer have to agree (measured: 80.6% of PR mentions in prompts used only one form). A repo in the query rules out the same number in another repo's URL. Never a bare number: `#147` does not hit `1475` or `#1475`, and the `[Image #N]` marker of a pasted screenshot is not a reference | +| `#147` `pr:147` `owner/repo#147` `https://github.com/owner/repo/pull/147` | **a pull request in any spelling** — the query form and the form in the text no longer have to agree (measured: 80.6% of PR mentions in prompts used only one form). Three levels of strictness: `#147` is broad (any mention, any repo); `pr:147` counts only a session's own PR badge or a mention that names its repo (every repo has a #147 of its own); a repo in the query (`owner/repo#147`, the URL, `pr:owner/repo#147`) accepts a bare `#147` only in sessions whose own badge or references name that repo. Never a bare number: `#147` does not hit `1475` or `#1475`, and the `[Image #N]` marker of a pasted screenshot is not a reference | Every term must hold. The assistant's own replies are mined for the PRs it mentioned (its text and the commands it ran — not tool output, so a session that merely listed twenty PRs did not "work on" them), so "the PR you opened for me" is findable by number even when you never typed it. An operator with an unreadable value (`after:soon`) is reported under the box and ignored rather than silently matching nothing. diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index b8fb3d9..c482b94 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -313,10 +313,21 @@ Three things the sketch did not have: every session in history** runs 20s after launch in chunks of 10 with a pause between them (foreground scans share the queue and interleave), so `title:` / `has:pr` / `#N` see every session, not just the loaded window; on the second launch that pass is stat-only. -- **A repo in the query rules out the same number elsewhere**: `grimmerk/codev#147` does not - match a `fireflies/fred/pull/147` URL, while a bare `#147` in the target still matches either, - because a bare number cannot say which repo it meant. Delimited forms only, as promised: - `#147` never hits `#1475`, `/pull/1470` or `15980`. +- **Three levels of strictness, and the first live test set them.** The sketch said a bare + `#147` in the target should match any query, "because a bare number cannot say which repo it + meant". In practice a bare `pr:151` listed **eight sessions**, and `pr:grimmerk/codev#151` + still listed them because the bare `#151` counted regardless. Most of those eight turned out + to be the miner's fault — it had no right-side boundary, so `#151e2b` (a hex colour in a + slide-deck session) was persisted as `#151`; the matcher had the boundary and the miner did + not, the one-rule-two-paths drift this file keeps warning about, and re-mining with the + boundary took the broad `#151` from 8 hits to 1. The strict levels stay anyway, because every + repo really does have its own 151. So: + `#147` in the query is broad (any mention, any repo); `pr:147` is strict — only a session's own + PR badge or a repo-qualified mention counts; a repo in the query (`owner/repo#147`, the URL, + `pr:owner/repo#147`) requires the same repo on qualified forms and accepts a bare `#147` only + when the session's own repo context — its badge URL and its qualified references + (`sessionRepos`) — names that repo. Delimited forms only, as promised: `#147` never hits + `#1475`, `#147abc`, `/pull/1470` or `15980`. ### 4.7 `/branch` creates generation chains (measured 2026-09-05) diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 609e62c..cdf8bb5 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -9,6 +9,8 @@ import * as os from 'os'; import * as fs from 'fs'; import { getCurrentIDEBundleId } from './vscode-based-ide-utility'; import { + CodevAccount, + getAccounts, getScannableAccounts, getProjectsDir, getAccountByLabel, @@ -20,6 +22,7 @@ import { parseQuery, PromptMatch, promptNeedles, + sessionRepos, } from './session-search'; import { EnrichmentState, @@ -100,6 +103,12 @@ const ACTIVE_CACHE_TTL_MS = 5000; let cachedCustomTitles: Map | null = null; export const invalidateSessionCache = () => { + // Persist what the last scan found BEFORE anything is cleared: a debounced + // write still pending would otherwise fire after the clear and replace the + // disk cache with `sessions: {}` — and the flush itself reads the maps + // below, so it must run before they are reset, not merely before the file + // state is. Flushing also cancels the timer. + flushEnrichmentCache(); cachedSessions = null; cachedActiveMap = null; cachedVSCodeSessions = null; @@ -109,10 +118,6 @@ export const invalidateSessionCache = () => { cachedPRLinks = null; cachedRecaps = null; cachedPRRefs = null; - // Persist what the last scan found BEFORE clearing it: a debounced write - // still pending would otherwise fire after the clear and replace the disk - // cache with `sessions: {}`. Flushing also cancels that timer. - flushEnrichmentCache(); enrichedFileState.clear(); prRefBytes.clear(); // The disk cache is still valid (freshness is per file, by mtime+size); @@ -266,6 +271,7 @@ export const searchClaudeSessions = ( const prLink = cachedPRLinks?.get(id); const recap = cachedRecaps?.get(id)?.text; const refs = cachedPRRefs?.get(id); + const repos = sessionRepos(prLink?.prUrl, refs); const text = [ s.projectName, s.project, @@ -290,13 +296,19 @@ export const searchClaudeSessions = ( prompts: sessionPrompts, hasPr: !!prLink, lastTimestamp: s.lastTimestamp, + repos, }) ) { continue; } sessions.push(s); - const match = findPromptMatch(sessionPrompts, needles, parsed.prRefs); + const match = findPromptMatch( + sessionPrompts, + needles, + parsed.prRefs, + repos, + ); if (match) { snippets[id] = { ...match, @@ -1202,10 +1214,28 @@ const getResumeConfigDirEnv = ( if (s) return s.accountConfigDirEnv; // Not in any history (a /branch child, a pruned history): trust the label // a saved list captured, or the resume lands under the anchor account and - // never finds its transcript. - return accountLabel ? getAccountByLabel(accountLabel).configDirEnv : null; + // never finds its transcript. Strict lookup — a label no account carries + // (renamed, removed) is rejected by the launch paths before they get here. + const account = accountLabel ? findAccountByLabel(accountLabel) : undefined; + return account ? account.configDirEnv : null; }; +/** The account with exactly this label, or undefined — never a fallback. */ +export const findAccountByLabel = (label: string): CodevAccount | undefined => + getAccounts().find((a) => a.label === label); + +/** + * A project path safe to embed in the launch commands. Every terminal + * launcher interpolates the path into a double-quoted shell string and, for + * Ghostty and Terminal.app, into an AppleScript string literal; a path + * carrying a quote, a backslash, `$`, a backtick or a line break could end + * either literal. Real project paths never contain these, so refusing them + * costs nothing and closes the hole for every caller — the saved-list file + * and history.jsonl are both plain files a user (or a stray tool) can edit. + */ +export const isSafeLaunchPath = (p: unknown): p is string => + typeof p === 'string' && p.length > 0 && !/["\\$`\n\r\u0000]/.test(p); + /** * Build `command claude --resume `, prefixed with CLAUDE_CONFIG_DIR when the * session belongs to a non-default account so it resumes under the right account. @@ -1274,17 +1304,47 @@ export const openSession = async ( openSessionInCodeV(sessionId); break; case 'cmux': - openSessionInCmux(sessionId, projectPath, isActive, activePid, customTitle, accountLabel); + openSessionInCmux( + sessionId, + projectPath, + isActive, + activePid, + customTitle, + accountLabel, + ); break; case 'ghostty': - openSessionInGhostty(sessionId, projectPath, isActive, terminalMode, customTitle, accountLabel); + openSessionInGhostty( + sessionId, + projectPath, + isActive, + terminalMode, + customTitle, + accountLabel, + ); break; case 'terminal': - openSessionInTerminalApp(sessionId, projectPath, isActive, activePid, terminalMode, customTitle, accountLabel); + openSessionInTerminalApp( + sessionId, + projectPath, + isActive, + activePid, + terminalMode, + customTitle, + accountLabel, + ); break; case 'iterm2': default: - openSessionInITerm2(sessionId, projectPath, isActive, activePid, terminalMode, customTitle, accountLabel); + openSessionInITerm2( + sessionId, + projectPath, + isActive, + activePid, + terminalMode, + customTitle, + accountLabel, + ); break; } }; @@ -2287,24 +2347,36 @@ export const openSessionListMembers = async ( ): Promise => { const result: OpenListMembersResult = { opened: [], skipped: [] }; for (const m of members) { + const id = m.sessionId; + const skip = (reason: string) => result.skipped.push({ sessionId: id, reason }); // Re-read per launch: the previous launch may have registered by now. - if (runningSessionIds().has(m.sessionId) || membersInFlight.has(m.sessionId)) { - result.skipped.push({ sessionId: m.sessionId, reason: 'already running' }); + if (runningSessionIds().has(id) || membersInFlight.has(id)) { + skip('already running'); continue; } if (!m.project || !fs.existsSync(m.project)) { - result.skipped.push({ sessionId: m.sessionId, reason: 'project folder missing' }); + skip('project folder missing'); continue; } - if (!findTranscriptPath(m.sessionId, m.project, m.accountLabel)) { - result.skipped.push({ sessionId: m.sessionId, reason: 'transcript missing' }); + if (!isSafeLaunchPath(m.project)) { + skip('unsafe project path'); continue; } - if (result.opened.length > 0) await sleep(staggerMs); - membersInFlight.add(m.sessionId); + if (m.accountLabel && !findAccountByLabel(m.accountLabel)) { + skip(`unknown account "${m.accountLabel}"`); + continue; + } + if (!findTranscriptPath(id, m.project, m.accountLabel)) { + skip('transcript missing'); + continue; + } + // Claimed BEFORE the stagger wait: an overlapping call arriving during + // the wait must see this session as taken. + membersInFlight.add(id); try { + if (result.opened.length > 0) await sleep(staggerMs); await openSession( - m.sessionId, + id, m.project, false, undefined, @@ -2313,11 +2385,11 @@ export const openSessionListMembers = async ( m.title, m.accountLabel, ); + result.opened.push(id); } finally { // Long enough for the new process to write its registration. - setTimeout(() => membersInFlight.delete(m.sessionId), 10000); + setTimeout(() => membersInFlight.delete(id), 10000); } - result.opened.push(m.sessionId); } return result; }; @@ -2430,7 +2502,7 @@ end tell`; const result = (stdout || '').trim(); console.log('[ghostty] switch result:', result); if (result === 'not found') { - copyResumeCommand(sessionId, projectPath); + copyResumeCommand(sessionId, projectPath, accountLabel); } try { fs.unlinkSync(tmpScript); } catch {} }); @@ -2549,7 +2621,7 @@ export const openSessionInCmux = ( // Single tree --all call for title matching, project name fallback, and workspace ID extraction. const treeOutput = await execPromise(`${CMUX_CLI} tree --all 2>/dev/null`); if (!treeOutput) { - copyResumeCommand(sessionId, projectPath); + copyResumeCommand(sessionId, projectPath, accountLabel); exec('osascript -e \'tell application "cmux" to activate\''); return; } @@ -2652,8 +2724,12 @@ export const openSessionInCmux = ( /** * Copy resume command to clipboard (fallback for unsupported terminals) */ -export const copyResumeCommand = (sessionId: string, projectPath: string): string => { - const command = `cd "${projectPath}" && ${buildResumeCommand(sessionId)}`; +export const copyResumeCommand = ( + sessionId: string, + projectPath: string, + accountLabel?: string, +): string => { + const command = `cd "${projectPath}" && ${buildResumeCommand(sessionId, accountLabel)}`; const { execFileSync } = require('child_process'); execFileSync('pbcopy', { input: command }); return command; diff --git a/src/enrichment-cache.test.ts b/src/enrichment-cache.test.ts index 85665b4..51432a5 100644 --- a/src/enrichment-cache.test.ts +++ b/src/enrichment-cache.test.ts @@ -190,7 +190,19 @@ describe('minePrRefs', () => { '15980 and & and #0 and #012 and a#9 and x/y#1a and [Image #4] but #5', ), ), - ).toEqual(['x/y#1', '#5']); + ).toEqual(['#5']); + }); + + // The matcher had these boundaries and the miner did not: `#147abc` was + // persisted as `#147`, and `notgithub.com/…/pull/9` as `o/r#9`. + it('applies the same right-side and host boundaries as the query matcher', () => { + expect( + minePrRefs( + assistant( + '#147abc #147_x /pull/147abc o/r#12abc https://notgithub.com/o/r/pull/9 then #147, and https://github.com/o/r/pull/9', + ), + ), + ).toEqual(['#147', 'o/r#9']); }); it('assistantTextOfLine joins text and tool inputs, and rejects everything else', () => { diff --git a/src/enrichment-cache.ts b/src/enrichment-cache.ts index 4b3f742..082386b 100644 --- a/src/enrichment-cache.ts +++ b/src/enrichment-cache.ts @@ -26,7 +26,9 @@ import * as path from 'path'; import { writeStoreFile } from './atomic-json-store'; import { isImageMarkerHash } from './session-search'; -export const ENRICHMENT_CACHE_VERSION = 1; +// 2: references mined under version 1 could carry a `#147` taken from +// `#147abc`; a version bump re-mines everything on the next launch. +export const ENRICHMENT_CACHE_VERSION = 2; export interface CachedSessionEnrichment { mtimeMs: number; @@ -180,8 +182,14 @@ export const deserializeEnrichment = (raw: unknown): EnrichmentState => { * non-word character before it. `&#N` is excluded so HTML entities are not * read as references; a leading zero is excluded so `#0` is not. */ +// Boundaries on both sides, the same ones the query matcher applies: the +// host must not be the tail of a longer name (`notgithub.com`), and the +// number must END there (`#147abc` is an identifier, not PR 147). The two +// sides drifted once — the matcher had the suffix rule and the miner did +// not, so `#147abc` was persisted as `#147` — which is why the cache +// version below moved to 2. export const PR_REF_RE = - /github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/(?:pull|issues)\/([1-9][0-9]*)|([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)|(?:^|[^A-Za-z0-9&#])#([1-9][0-9]*)/gi; + /(? { - const terminalApp = ((await settings.get('session-terminal-app')) || 'iterm2') as string; - const terminalMode = ((await settings.get('session-terminal-mode')) || 'tab') as string; - openSession( - sessionId, - projectPath, - isActive, - activePid, - terminalApp, - terminalMode, - customTitle, - typeof accountLabel === 'string' ? accountLabel : undefined, - ); -}); +ipcMain.on( + 'open-claude-session', + async ( + _event, + sessionId: string, + projectPath: string, + isActive: boolean, + activePid?: number, + customTitle?: string, + accountLabel?: string, + ) => { + // The path is embedded in shell and AppleScript strings by every + // launcher; refuse one that could end either literal (see isSafeLaunchPath). + if (!isSafeLaunchPath(projectPath)) { + console.warn('[open-claude-session] refusing unsafe project path:', projectPath); + return; + } + const terminalApp = ((await settings.get('session-terminal-app')) || 'iterm2') as string; + const terminalMode = ((await settings.get('session-terminal-mode')) || 'tab') as string; + // A label no account carries is dropped, not trusted: history decides the + // account for any session it knows, and the anchor is the documented + // fallback for one it does not. + const label = + typeof accountLabel === 'string' && findAccountByLabel(accountLabel) + ? accountLabel + : undefined; + openSession( + sessionId, + projectPath, + isActive, + activePid, + terminalApp, + terminalMode, + customTitle, + label, + ); + }, +); // Resume every member of a saved list that is not running (issue #145). ipcMain.handle( diff --git a/src/session-search.test.ts b/src/session-search.test.ts index 1e5e202..9576882 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -17,6 +17,8 @@ import { parseQuery, parseQueryDate, promptNeedles, + QueryTarget, + sessionRepos, tokenizeQuery, truncateMiddle, windowAroundMatch, @@ -346,6 +348,10 @@ describe('parsePrRef', () => { expect( parsePrRef('https://github.com/grimmerk/codev/issues/144#issuecomment-1'), ).toEqual({ number: 144, repo: 'grimmerk/codev' }); + expect(parsePrRef('grimmerk/codev/pull/137')).toEqual({ + number: 137, + repo: 'grimmerk/codev', + }); expect(parsePrRef('github.com/o/r/pull/9/files')).toEqual({ number: 9, repo: 'o/r', @@ -412,7 +418,7 @@ describe('parseQuery', () => { expect(q.is).toEqual(['live']); expect(q.after).toBe(now - 7 * 86400000); expect(q.before).toBe(new Date(2026, 8, 1).getTime()); - expect(q.prRefs).toEqual([{ number: 147 }]); + expect(q.prRefs).toEqual([{ number: 147, strict: true }]); expect(q.words).toEqual([]); }); @@ -514,11 +520,18 @@ describe('findPrRef', () => { expect(findPrRef('see #147abc', { number: 147 })).toBeNull(); expect(findPrRef('see #147_x', { number: 147 })).toBeNull(); expect(findPrRef('/pull/147abc', { number: 147 })).toBeNull(); + // The host must be github.com itself, not the tail of another name. + expect( + findPrRef('https://notgithub.com/o/r/pull/147', { number: 147 }), + ).toBeNull(); + expect( + findPrRef('https://www.github.com/o/r/pull/147', { number: 147 }), + ).not.toBeNull(); expect(findPrRef('see #147, done', { number: 147 })).not.toBeNull(); expect(findPrRef('(see #147)', { number: 147 })).not.toBeNull(); }); - it('honours the repo when the reference names one; a bare #N in the target still matches', () => { + it("honours the repo when the reference names one; a bare #N counts only inside that repo's own sessions", () => { const ref = { number: 147, repo: 'grimmerk/codev' }; expect( findPrRef('https://github.com/fireflies/fred/pull/147', ref), @@ -528,7 +541,14 @@ describe('findPrRef', () => { findPrRef('https://github.com/grimmerk/codev/pull/147', ref), ).not.toBeNull(); expect(findPrRef('grimmerk/codev#147', ref)).not.toBeNull(); - expect(findPrRef('opened #147 today', ref)).not.toBeNull(); + // Live finding: `pr:grimmerk/codev#151` listed eight sessions because a + // bare `#151` counted regardless of repo. It counts only when the + // session's own repo context (badge URL, qualified refs) names the repo. + expect(findPrRef('opened #147 today', ref)).toBeNull(); + expect(findPrRef('opened #147 today', ref, ['fireflies/fred'])).toBeNull(); + expect( + findPrRef('opened #147 today', ref, ['fireflies/fred', 'Grimmerk/CodeV']), + ).not.toBeNull(); // A wrong-repo hit does not hide a right one further on. expect( findPrRef('fireflies/fred#147 then grimmerk/codev#147', ref), @@ -538,6 +558,30 @@ describe('findPrRef', () => { }); }); + it('pr:N is strict: a badge URL or a qualified mention counts, a bare #N does not', () => { + const strict = { number: 147, strict: true }; + expect(findPrRef('opened #147 today', strict)).toBeNull(); + expect(findPrRef('opened #147 today', strict, ['o/r'])).toBeNull(); + expect( + findPrRef('pr #147 https://github.com/o/r/pull/147', strict), + ).not.toBeNull(); + expect(findPrRef('o/r#147', strict)).not.toBeNull(); + // The bare query form stays broad. + expect(findPrRef('opened #147 today', { number: 147 })).not.toBeNull(); + }); + + it('sessionRepos derives the repo context from the badge URL and qualified refs', () => { + expect( + sessionRepos('https://github.com/Grimmerk/CodeV/pull/151', [ + '#3', + 'o/r#5', + 'grimmerk/codev#151', + ]), + ).toEqual(['grimmerk/codev', 'o/r']); + expect(sessionRepos(undefined, undefined)).toEqual([]); + expect(sessionRepos('not a url', ['#1'])).toEqual([]); + }); + // Live finding: `pr:151` listed a session whose only "#151" was the marker // Claude Code writes for a pasted screenshot. it('does not read the [Image #N] paste marker as a reference, but still finds a real one after it', () => { @@ -557,7 +601,7 @@ describe('findPrRef', () => { describe('compileQuery', () => { const now = new Date(2026, 8, 5, 15, 30).getTime(); - const target = { + const target: QueryTarget = { sessionId: '4ed7505a-eae6-43a5-827b-465c8b5eb759', text: 'codev /Users/g/git/codev open the PR for me #147 harden again', title: 'agentic-fred harden again - pr2-1533', @@ -616,9 +660,26 @@ describe('compileQuery', () => { it('PR references match any spelling in the text, and every term must hold', () => { expect(matches('#147')).toBe(true); - expect(matches('pr:147 title:pr2')).toBe(true); - expect(matches('pr:148')).toBe(false); expect(matches('#147 title:nope')).toBe(false); + // The fixture text carries only a bare `#147`: broad finds it, strict + // needs the badge URL (what both callers append when a badge exists) or + // the session's own repo context. + expect(matches('pr:147')).toBe(false); + const badged = { + ...target, + text: `${target.text} PR #147 https://github.com/grimmerk/codev/pull/147`, + }; + expect(matches('pr:147 title:pr2', badged)).toBe(true); + expect(matches('pr:148', badged)).toBe(false); + expect(matches('grimmerk/codev#147', badged)).toBe(true); + expect(matches('fireflies/fred#147', badged)).toBe(false); + expect(matches('grimmerk/codev#147')).toBe(false); + expect( + matches('grimmerk/codev#147', { ...target, repos: ['grimmerk/codev'] }), + ).toBe(true); + expect( + matches('pr:https://github.com/grimmerk/codev/pull/147', badged), + ).toBe(true); }); it('an ignored operator does not constrain the match', () => { diff --git a/src/session-search.ts b/src/session-search.ts index ede5abf..cf0f119 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -52,6 +52,12 @@ export interface PrRef { number: number; /** `owner/repo`, lowercased. */ repo?: string; + /** + * `pr:N` with no repo: only a session's own PR badge or a repo-qualified + * mention counts, never a bare `#N` — every repo has its own N. A bare + * `#N` in the query stays broad on purpose. + */ + strict?: boolean; } export interface ScopedTerm { @@ -128,8 +134,10 @@ export const tokenizeQuery = (query: string): string[] => { const REPO = '[a-z0-9_.-]+\\/[a-z0-9_.-]+'; // Numbers never start with a zero, matching the miner: `#012` is not PR 12. const NUMBER = '[1-9][0-9]*'; +// The host is optional: `owner/repo/pull/N` is unambiguous on its own, and it +// is what people type when they shorten a URL by hand. const PR_URL_RE = new RegExp( - `^(?:https?:\\/\\/)?(?:www\\.)?github\\.com\\/(${REPO})\\/(?:pull|issues)\\/(${NUMBER})(?:[/?#].*)?$`, + `^(?:https?:\\/\\/)?(?:www\\.)?(?:github\\.com\\/)?(${REPO})\\/(?:pull|issues)\\/(${NUMBER})(?:[/?#].*)?$`, ); const REPO_HASH_RE = new RegExp(`^(${REPO})#(${NUMBER})$`); const BARE_HASH_RE = new RegExp(`^#(${NUMBER})$`); @@ -149,6 +157,27 @@ export const parsePrRef = (tokenLower: string): PrRef | null => { return null; }; +/** + * The repos a session is known to be about, for `QueryTarget.repos`: the + * `owner/repo` of its PR badge URL and of every repo-qualified reference + * (`owner/repo#N`) it carries. One derivation for both search paths. + */ +export const sessionRepos = ( + prUrl?: string, + refs?: readonly string[], +): string[] => { + const out = new Set(); + if (prUrl) { + const m = /github\.com\/([^/\s]+\/[^/\s#]+)\//i.exec(prUrl); + if (m) out.add(m[1].toLowerCase()); + } + for (const r of refs ?? []) { + const hash = r.indexOf('#'); + if (hash > 0) out.add(r.slice(0, hash).toLowerCase()); + } + return [...out]; +}; + const DAY_MS = 24 * 60 * 60 * 1000; const RELATIVE_UNIT_MS: Record = { h: 60 * 60 * 1000, @@ -202,7 +231,7 @@ export const parseQuery = (query: string, now = Date.now()): ParsedQuery => { // `pr:147`, `pr:o/r#147`, `pr:` — a number alone is allowed here // because the key already says what it is. const ref = /^[1-9][0-9]*$/.test(value) - ? { number: Number(value) } + ? { number: Number(value), strict: true } : parsePrRef(value); if (ref) q.prRefs.push(ref); else q.ignored.push(raw); @@ -250,6 +279,13 @@ export interface QueryTarget { isLive?: boolean; isPinned?: boolean; lastTimestamp?: number; + /** + * The repos this session is known to be about (`owner/repo`, any case): + * its PR badge's URL and any repo-qualified reference it carries. A + * repo-qualified query accepts a bare `#N` in the text only when the + * session's own context names that repo. + */ + repos?: string[]; } /** @@ -278,8 +314,9 @@ const prRefPatterns = (ref: PrRef): PrRefPatterns => { `(?:^|[^0-9a-z_./-])((?:${REPO})?)#${n}(?![0-9a-z_])`, 'g', ), + // The host must not be the tail of a longer name: `notgithub.com`. url: new RegExp( - `github\\.com\\/(${REPO})\\/(?:pull|issues)\\/${n}(?![0-9a-z_])`, + `(? hashIndex >= 7 && text.slice(hashIndex - 7, hashIndex).toLowerCase() === '[image '; +/** + * Which occurrences count, by how much the QUERY said: + * + * | query form | qualified `o/r#N` / URL in text | bare `#N` in text | + * |-------------------------|----------------------------------|--------------------------------------| + * | `#N` | any repo | yes | + * | `pr:N` | any repo | no | + * | `o/r#N`, URL, `pr:o/r#N`| same repo only | only if the session's own repos include `o/r` | + * + * The first live test of a bare `pr:151` listed eight sessions, and + * `pr:grimmerk/codev#151` still listed them because a bare `#151` used to + * count regardless — hence the two strict rows. (Most of the eight were the + * miner's missing right boundary reading `#151e2b` as `#151`; the rows stay + * because every repo has its own N.) + */ const findPrRefWith = ( textLower: string, p: PrRefPatterns, + reposLower?: string[], ): { index: number; length: number } | null => { const { ref, n, hash, url } = p; // Most sessions never mention the number at all; a substring check is far @@ -306,12 +359,19 @@ const findPrRefWith = ( // over 560 sessions: a PR-reference keystroke cost ~20ms without this, // against ~8ms for a bare word. if (!textLower.includes(n)) return null; + const bareAllowed = ref.repo + ? !!reposLower && reposLower.includes(ref.repo) + : !ref.strict; hash.lastIndex = 0; for (let m = hash.exec(textLower); m; m = hash.exec(textLower)) { const repo = m[1]; - if (ref.repo && repo && repo !== ref.repo) continue; const at = m.index + m[0].length - n.length - 1 - repo.length; - if (!repo && isImageMarkerHash(textLower, at)) continue; + if (repo) { + if (ref.repo && repo !== ref.repo) continue; + } else { + if (!bareAllowed) continue; + if (isImageMarkerHash(textLower, at)) continue; + } return { index: at, length: repo.length + 1 + n.length }; } url.lastIndex = 0; @@ -325,8 +385,13 @@ const findPrRefWith = ( export const findPrRef = ( textLower: string, ref: PrRef, + repos?: string[], ): { index: number; length: number } | null => - findPrRefWith(textLower, prRefPatterns(ref)); + findPrRefWith( + textLower, + prRefPatterns(ref), + repos?.map((r) => r.toLowerCase()), + ); export interface QueryMatcher { test: (target: QueryTarget) => boolean; @@ -343,12 +408,13 @@ export const compileQuery = (query: ParsedQuery): QueryMatcher => { const prPatterns = query.prRefs.map(prRefPatterns); const test = (t: QueryTarget): boolean => { const textLower = t.text.toLowerCase(); + const reposLower = t.repos?.map((r) => r.toLowerCase()); for (const w of query.words) { if (!textLower.includes(w) && !matchesSessionId(t.sessionId, w)) return false; } for (const p of prPatterns) { - if (!findPrRefWith(textLower, p)) return false; + if (!findPrRefWith(textLower, p, reposLower)) return false; } for (const { field, value } of query.fields) { if (field === 'msg') { @@ -475,6 +541,7 @@ export const findPromptMatch = ( prompts: string[], wordsLower: string[], prRefs: PrRef[] = [], + repos?: string[], ): PromptMatch | null => { for (let i = 0; i < prompts.length; i++) { const lower = prompts[i].toLowerCase(); @@ -488,7 +555,7 @@ export const findPromptMatch = ( } } for (const ref of prRefs) { - const hit = findPrRef(lower, ref); + const hit = findPrRef(lower, ref, repos); if (hit) { return { promptIndex: i, diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index 7829878..122bcae 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -19,6 +19,7 @@ import { isEmptyQuery, matchesSessionId, parseQuery, + sessionRepos, truncateMiddle, windowAroundMatch, } from './session-search'; @@ -832,6 +833,8 @@ function SwitcherApp() { recap, prompts, hasPr: !!prInfo, + // This side has no mined references; the badge is the repo context. + repos: sessionRepos(prInfo?.prUrl), isLive: isRunning(s, liveIds), isPinned: id in sessionMarks.pins, lastTimestamp: s.lastTimestamp, @@ -2512,7 +2515,15 @@ function SwitcherApp() { if (s && !s.__liveOrphan) { // Arm before opening, in case the bridge triggers the focus cycle synchronously. clearSessionSearchOnShowRef.current = true; - window.electronAPI.openClaudeSession(s.sessionId, s.project, s.isActive, s.activePid, customTitles[s.sessionId], s.accountLabel); + window.electronAPI.openClaudeSession( + s.sessionId, + s.project, + s.isActive, + s.activePid, + customTitles[s.sessionId], + // A list member's captured account, when the row itself has none. + s.accountLabel ?? s.__listMember?.accountLabel, + ); } } else if ((e.metaKey || e.ctrlKey) && (e.key === 'd' || e.key === 'D')) { // ⌘D toggles pin, ⇧⌘D toggles hide on the selected row. @@ -2716,7 +2727,8 @@ function SwitcherApp() { pr:147{' '} owner/repo#147{' '} https://github.com/…/pull/147 - {' '}— any spelling finds the others, in prompts and in what the assistant said + {' '}— any spelling finds the others, in prompts and in what the assistant said; + #N is broad, pr:N and a repo are strict (badge or a mention naming the repo)
bare words search everything; a session id matches by prefix (4+ hex chars); every term must hold @@ -3058,7 +3070,14 @@ function SwitcherApp() { // A running process with no session id has nothing to resume. if (session.__liveOrphan) return; clearSessionSearchOnShowRef.current = true; - window.electronAPI.openClaudeSession(session.sessionId, session.project, session.isActive, session.activePid, customTitles[session.sessionId], session.accountLabel); + window.electronAPI.openClaudeSession( + session.sessionId, + session.project, + session.isActive, + session.activePid, + customTitles[session.sessionId], + session.accountLabel ?? session.__listMember?.accountLabel, + ); }} style={{ display: 'flex', From 5895d65585d7ef5423a81ae8522dfba2d44b4127 Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 23:00:38 +0800 Subject: [PATCH 6/9] =?UTF-8?q?docs:=20README=20=E2=80=94=20how=20a=20pull?= =?UTF-8?q?=20request=20is=20found,=20with=20the=20strictness=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 64c3481..a747281 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,36 @@ Bare words search everything; **operators aim the query** (the `?` chip beside t | `has:pr` `has:title` `has:branch` `has:recap` | sessions that carry the thing | | `is:live` `is:pinned` | sessions with a running process (by the `ps` join below) / pinned ones | | `after:7d` `after:2026-09-01` `after:today` `before:…` | by the session's last activity (`Nh` `Nd` `Nw`, a date, `today`, `yesterday`) | -| `#147` `pr:147` `owner/repo#147` `https://github.com/owner/repo/pull/147` | **a pull request in any spelling** — the query form and the form in the text no longer have to agree (measured: 80.6% of PR mentions in prompts used only one form). Three levels of strictness: `#147` is broad (any mention, any repo); `pr:147` counts only a session's own PR badge or a mention that names its repo (every repo has a #147 of its own); a repo in the query (`owner/repo#147`, the URL, `pr:owner/repo#147`) accepts a bare `#147` only in sessions whose own badge or references name that repo. Never a bare number: `#147` does not hit `1475` or `#1475`, and the `[Image #N]` marker of a pasted screenshot is not a reference | +| `#137` `pr:137` `owner/repo#137` `owner/repo/pull/137` `https://github.com/owner/repo/pull/137` | **a pull request in any spelling** — the query form and the form in the text no longer have to agree (measured: 80.6% of PR mentions in prompts used only one form). Three levels of strictness; see *Finding a pull request* below | -Every term must hold. The assistant's own replies are mined for the PRs it mentioned (its text and the commands it ran — not tool output, so a session that merely listed twenty PRs did not "work on" them), so "the PR you opened for me" is findable by number even when you never typed it. An operator with an unreadable value (`after:soon`) is reported under the box and ignored rather than silently matching nothing. +Every term must hold. An operator with an unreadable value (`after:soon`) is reported under the box and ignored rather than silently matching nothing. + +#### Finding a pull request + +A PR can be found from two kinds of evidence, both indexed: + +- **(a) Claude Code's own record.** When a PR is created inside a session, Claude Code writes a `pr-link` record into that session's transcript — `{"type":"pr-link","prNumber":137,"prUrl":"https://github.com/grimmerk/codev/pull/137","prRepository":"grimmerk/codev"}` — and that is the `PR #137` badge on the row. Only a session that actually opened the PR has one (39 of 91 transcripts on the reference machine). +- **(b) Mentions in the conversation.** The URL, `owner/repo#137` or a bare `#137` in **your prompts**, and the same forms in **the assistant's replies and the commands it ran**, mined from the transcript (never from tool output, so a session that ran `gh pr list` did not "work on" twenty PRs). So "the PR you opened for me" is findable by number even when you never typed it. + +A **bare mention** is a `#137` with nothing around it saying which repo it belongs to — "take a look at #137", or `Fixes #137` in a commit message. A **repo-qualified mention** is `owner/repo#137` or the URL; the badge counts as one too. + +The query form decides how much evidence is required: + +| What the session contains | `#137` | `pr:137` | `grimmerk/codev#137`, the URL, or `pr:` + either | +|---|---|---|---| +| The badge: this session opened codev PR #137 | ✓ | ✓ | ✓ | +| You pasted `https://github.com/grimmerk/codev/pull/137` | ✓ | ✓ | ✓ | +| The assistant wrote `grimmerk/codev#137` | ✓ | ✓ | ✓ | +| A codev session where you typed "how is #137 going" — no badge, no URL anywhere | ✓ | ✗ | ✗ — nothing in the session proves which repo that 137 is; use `project:codev #137` | +| A codev session whose badge is **PR #151**, and you typed "also check #137" | ✓ | ✗ (137 is neither the badge nor repo-qualified) | ✓ (the badge proves the session's repo is grimmerk/codev, so its bare #137 counts) | +| A fred-service session where the assistant wrote `Fixes #137` | ✓ | ✗ | ✗ | +| The assistant pasted `https://github.com/firefliesai/fred-service/pull/137` | ✓ | ✓ (any repo, as long as the mention names one) | ✗ | +| Only `#137abc`, or the hex colour `#137e2b` | ✗ | ✗ | ✗ | +| Only the paste marker `[Image #137]` | ✗ | ✗ | ✗ | + +In words: `#137` accepts any mention; `pr:137` requires that *someone said which repo's 137 it is* (badge, URL or `owner/repo#137`), in any repo; a repo in the query requires the same repo on qualified mentions and accepts a bare `#137` only in sessions whose own badge or references prove they belong to that repo. Never a bare number: `#137` does not hit `1375`, `#1375`, `#137abc` or `notgithub.com/…/pull/137`, and a leading zero (`#0137`) is not a reference. + +Practical rule: to find *the codev PR 137*, paste the URL or type `grimmerk/codev#137`; if that session never saw a URL and Claude did not open the PR there, use `project:codev #137`. **Pin** the sessions you keep coming back to (hover 📌 on a row, or `⌘D` on the selected row): they **move into** a **📌 Pinned** zone at the top, ordered by recency like the rest of the list — works even for old sessions found via deep search. **Hide** one-offs you never want in the main flow (hover ⊘, or `⇧⌘D`): they move into the minor-sessions fold, stay searchable, and can be unhidden from inside the fold (they carry a persistent ⊘ marker there). Pins and hides live in `~/.config/codev/session-marks.json`, shared across accounts. From 991580b23be99e49d7306556cd4eeb16b8ad25ee Mon Sep 17 00:00:00 2001 From: Grimmer Kang Date: Sat, 5 Sep 2026 23:06:58 +0800 Subject: [PATCH 7/9] fix(sessions): round 3 - GitHub host only, owner shape, boundary tests --- CHANGELOG.md | 2 +- src/enrichment-cache.test.ts | 7 +++++-- src/enrichment-cache.ts | 5 ++++- src/session-search.test.ts | 13 +++++++++++++ src/session-search.ts | 19 +++++++++++++------ 5 files changed, 36 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20d6b12..b650104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Feat: aim the search — field-scoped terms, PR references in any spelling, `is:live`, and a persisted enrichment cache ([#140](https://github.com/grimmerk/codev/issues/140), [#134](https://github.com/grimmerk/codev/issues/134)) - **Operators**: `title:x` `branch:x` `msg:x` `project:x` `account:x` `recap:x` search one field; `has:pr|title|branch|recap`; `is:live|pinned`; `after:7d` / `after:2026-09-01` / `after:today` and `before:…` by last activity; `"two words"` keeps a phrase together. Bare words keep today's meaning. Every term must hold. A `?` chip beside the search box shows the list; an operator with an unreadable value is reported under the box and ignored rather than silently matching nothing - - **A pull request in any spelling**: `#147`, `pr:147`, `owner/repo#147` and the GitHub URL all find each other, in your prompts and in what the assistant said. Measured on the reference machine's prompts: 80.6% of PR mentions were reachable by only one form before. Delimited forms only — `#147` never hits `#1475` or `15980`. Three levels of strictness, set by the first live test: `#147` is broad (any mention, any repo); `pr:147` counts only a session's own PR badge or a mention that names its repo; a repo in the query (`owner/repo#147`, the URL, `pr:owner/repo#147`) accepts a bare `#147` only in sessions whose own badge or references name that repo. (That test listed eight sessions for `pr:151`; most were the miner reading `#151` out of longer identifiers such as the hex colour `#151e2b` — fixed by a right-side boundary on both sides — and the strict levels stay because every repo does have its own 151.) Claude Code's `[Image #N]` marker for a pasted screenshot is not a PR reference (found matching a session whose only "#151" was `[Image #151]`) + - **A pull request in any spelling**: `#147`, `pr:147`, `owner/repo#147` and the GitHub URL all find each other, in your prompts and in what the assistant said. Measured on the reference machine's prompts: 80.6% of PR mentions were reachable by only one form before. Delimited forms only — `#147` never hits `#1475` or `15980`. Three levels of strictness, set by the first live test: `#147` is broad (any mention, any repo); `pr:147` counts only a session's own PR badge or a repo-qualified mention (`owner/repo#147` or the URL, in any repo); a repo in the query (`owner/repo#147`, the URL, `pr:owner/repo#147`) accepts a bare `#147` only in sessions whose own badge or references name that repo. (That test listed eight sessions for `pr:151`; most were the miner reading `#151` out of longer identifiers such as the hex colour `#151e2b` — fixed by a right-side boundary on both sides — and the strict levels stay because every repo does have its own 151.) Claude Code's `[Image #N]` marker for a pasted screenshot is not a PR reference (found matching a session whose only "#151" was `[Image #151]`) - **The assistant's replies are mined for the PRs it mentioned** (its text and the commands it ran; never tool output, so a session that ran `gh pr list` did not "work on" twenty PRs), so "the PR you opened for me" is findable by number. Measured: 91 transcripts / 739MB mined in 2.0s cold, then incrementally from the byte where the last pass stopped; 3,889 references across 65 sessions - **One matcher, two callers**: the main-side full-prompt search and the renderer's filter now compile the same query into one matcher and differ only in what each side can put on the target (`is:` is judged in the renderer, everything else on the main side, which also sees title / branch / recap for every session now). Search stays behind the 180ms debounce: 7–10ms per keystroke for words, ~20ms for a PR reference, over 560 sessions - **The enrichment cache survives a restart** (`~/.config/codev/enrichment-cache.json`): title, branch, PR badge, recap, mined references and per-file mtime+size, written a few seconds after a scan and flushed on quit. A second launch starts warm and re-reads only transcripts that changed. A bad file is a cold start, never an error. **One background pass over every session** runs 20s after launch, in chunks with pauses, so operators see every session and not just the loaded window; measured 6.8s cold for the whole corpus, stat-only afterwards diff --git a/src/enrichment-cache.test.ts b/src/enrichment-cache.test.ts index 51432a5..de9deef 100644 --- a/src/enrichment-cache.test.ts +++ b/src/enrichment-cache.test.ts @@ -195,14 +195,17 @@ describe('minePrRefs', () => { // The matcher had these boundaries and the miner did not: `#147abc` was // persisted as `#147`, and `notgithub.com/…/pull/9` as `o/r#9`. + // Every invalid form carries its own number, so a boundary that stops + // working shows up as an extra entry rather than being masked by a valid + // mention of the same number. it('applies the same right-side and host boundaries as the query matcher', () => { expect( minePrRefs( assistant( - '#147abc #147_x /pull/147abc o/r#12abc https://notgithub.com/o/r/pull/9 then #147, and https://github.com/o/r/pull/9', + '#141abc #142_x /pull/143abc o/r#144abc https://notgithub.com/o/r/pull/145 https://evil.github.com/o/r/pull/146 then #151, and https://github.com/o/r/pull/152 and https://www.github.com/o/r/issues/153', ), ), - ).toEqual(['#147', 'o/r#9']); + ).toEqual(['#151', 'o/r#152', 'o/r#153']); }); it('assistantTextOfLine joins text and tool inputs, and rejects everything else', () => { diff --git a/src/enrichment-cache.ts b/src/enrichment-cache.ts index 082386b..d180472 100644 --- a/src/enrichment-cache.ts +++ b/src/enrichment-cache.ts @@ -188,8 +188,11 @@ export const deserializeEnrichment = (raw: unknown): EnrichmentState => { // sides drifted once — the matcher had the suffix rule and the miner did // not, so `#147abc` was persisted as `#147` — which is why the cache // version below moved to 2. +// Host: `github.com` or `www.github.com`, never a subdomain (`evil.github.com`) +// and never the tail of another name (`notgithub.com`). Owner: alphanumerics +// and hyphens only, as on GitHub. Same rules as `session-search.ts`. export const PR_REF_RE = - /(? { expect(parsePrRef('o/r#012')).toBeNull(); expect(parsePrRef('https://github.com/o/r/pull/012')).toBeNull(); expect(parsePrRef('https://github.com/grimmerk/codev')).toBeNull(); + // A scheme must be followed by the GitHub host; a dotted owner is not an + // owner; a subdomain is not github.com. + expect(parsePrRef('https://example.com/o/pull/1')).toBeNull(); + expect(parsePrRef('example.com/o/pull/1')).toBeNull(); + expect(parsePrRef('https://evil.github.com/o/r/pull/1')).toBeNull(); + expect(parsePrRef('https://www.github.com/o/r/pull/1')).toEqual({ + number: 1, + repo: 'o/r', + }); }); }); @@ -527,6 +536,9 @@ describe('findPrRef', () => { expect( findPrRef('https://www.github.com/o/r/pull/147', { number: 147 }), ).not.toBeNull(); + expect( + findPrRef('https://evil.github.com/o/r/pull/147', { number: 147 }), + ).toBeNull(); expect(findPrRef('see #147, done', { number: 147 })).not.toBeNull(); expect(findPrRef('(see #147)', { number: 147 })).not.toBeNull(); }); @@ -580,6 +592,7 @@ describe('findPrRef', () => { ).toEqual(['grimmerk/codev', 'o/r']); expect(sessionRepos(undefined, undefined)).toEqual([]); expect(sessionRepos('not a url', ['#1'])).toEqual([]); + expect(sessionRepos('https://evil.github.com/o/r/pull/1')).toEqual([]); }); // Live finding: `pr:151` listed a session whose only "#151" was the marker diff --git a/src/session-search.ts b/src/session-search.ts index cf0f119..46831b0 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -131,13 +131,21 @@ export const tokenizeQuery = (query: string): string[] => { return out; }; -const REPO = '[a-z0-9_.-]+\\/[a-z0-9_.-]+'; +// A GitHub owner is alphanumerics and hyphens only — no dot — which is what +// keeps `example.com/o/pull/1` from reading as owner `example.com`. +const OWNER = '[a-z0-9-]+'; +const REPO = `${OWNER}\\/[a-z0-9_.-]+`; +// `github.com` or `www.github.com`, never `evil.github.com` (the lookbehind +// refuses a dot before the host) and never the tail of `notgithub.com`. +const HOST = '(? { const out = new Set(); if (prUrl) { - const m = /github\.com\/([^/\s]+\/[^/\s#]+)\//i.exec(prUrl); + const m = new RegExp(`${HOST}\\/(${REPO})\\/`, 'i').exec(prUrl); if (m) out.add(m[1].toLowerCase()); } for (const r of refs ?? []) { @@ -314,9 +322,8 @@ const prRefPatterns = (ref: PrRef): PrRefPatterns => { `(?:^|[^0-9a-z_./-])((?:${REPO})?)#${n}(?![0-9a-z_])`, 'g', ), - // The host must not be the tail of a longer name: `notgithub.com`. url: new RegExp( - `(? Date: Sat, 5 Sep 2026 23:19:22 +0800 Subject: [PATCH 8/9] fix(sessions): round 4 - host boundary, launch failures reported --- src/claude-session-utility.ts | 9 ++++++++- src/enrichment-cache.test.ts | 2 +- src/enrichment-cache.ts | 23 +++++++++++++---------- src/session-search.test.ts | 15 +++++++++++++++ src/session-search.ts | 15 ++++++++++----- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index cdf8bb5..322e4a8 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -1232,9 +1232,12 @@ export const findAccountByLabel = (label: string): CodevAccount | undefined => * either literal. Real project paths never contain these, so refusing them * costs nothing and closes the hole for every caller — the saved-list file * and history.jsonl are both plain files a user (or a stray tool) can edit. + * An EMPTY path is not this function's concern: switching to a running + * session never embeds it (iTerm2 matches by tty), and the launch paths that + * do need one already check existence. */ export const isSafeLaunchPath = (p: unknown): p is string => - typeof p === 'string' && p.length > 0 && !/["\\$`\n\r\u0000]/.test(p); + typeof p === 'string' && !/["\\$`\n\r\u0000]/.test(p); /** * Build `command claude --resume `, prefixed with CLAUDE_CONFIG_DIR when the @@ -2386,6 +2389,10 @@ export const openSessionListMembers = async ( m.accountLabel, ); result.opened.push(id); + } catch (err) { + // Reported, not swallowed: "opened 5" must not hide a sixth that threw. + console.error('[open-session-list-members] launch failed:', id, err); + skip('launch failed'); } finally { // Long enough for the new process to write its registration. setTimeout(() => membersInFlight.delete(id), 10000); diff --git a/src/enrichment-cache.test.ts b/src/enrichment-cache.test.ts index de9deef..09c0983 100644 --- a/src/enrichment-cache.test.ts +++ b/src/enrichment-cache.test.ts @@ -202,7 +202,7 @@ describe('minePrRefs', () => { expect( minePrRefs( assistant( - '#141abc #142_x /pull/143abc o/r#144abc https://notgithub.com/o/r/pull/145 https://evil.github.com/o/r/pull/146 then #151, and https://github.com/o/r/pull/152 and https://www.github.com/o/r/issues/153', + '#141abc #142_x /pull/143abc o/r#144abc https://notgithub.com/o/r/pull/145 https://evil.github.com/o/r/pull/146 not_github.com/o/r/pull/147 https://example.com/github.com/o/r/pull/148 then #151, and https://github.com/o/r/pull/152 and https://www.github.com/o/r/issues/153', ), ), ).toEqual(['#151', 'o/r#152', 'o/r#153']); diff --git a/src/enrichment-cache.ts b/src/enrichment-cache.ts index d180472..2157ece 100644 --- a/src/enrichment-cache.ts +++ b/src/enrichment-cache.ts @@ -182,17 +182,20 @@ export const deserializeEnrichment = (raw: unknown): EnrichmentState => { * non-word character before it. `&#N` is excluded so HTML entities are not * read as references; a leading zero is excluded so `#0` is not. */ -// Boundaries on both sides, the same ones the query matcher applies: the -// host must not be the tail of a longer name (`notgithub.com`), and the -// number must END there (`#147abc` is an identifier, not PR 147). The two -// sides drifted once — the matcher had the suffix rule and the miner did -// not, so `#147abc` was persisted as `#147` — which is why the cache -// version below moved to 2. -// Host: `github.com` or `www.github.com`, never a subdomain (`evil.github.com`) -// and never the tail of another name (`notgithub.com`). Owner: alphanumerics -// and hyphens only, as on GitHub. Same rules as `session-search.ts`. +// Boundaries on both sides, the same ones the query matcher applies (`HOST`, +// `OWNER` and the suffix rule in `session-search.ts`): +// - the host is `github.com` or `www.github.com`, only where a host can +// stand — at the start, after `://`, or after a character no host or path +// contains — so never a subdomain (`evil.github.com`), never the tail of +// another name (`notgithub.com`, `not_github.com`), never a path segment +// of some other URL (`https://example.com/github.com/o/r/…`); +// - an owner is alphanumerics and hyphens only, as on GitHub; +// - the number must END there: `#147abc` is an identifier, not PR 147. +// The two sides drifted once — the matcher had the suffix rule and the miner +// did not, so `#147abc` was persisted as `#147` — which is why the cache +// version above moved to 2. export const PR_REF_RE = - /(? { expect( findPrRef('https://evil.github.com/o/r/pull/147', { number: 147 }), ).toBeNull(); + // A path segment inside some other URL is not the host either. + expect( + findPrRef('https://example.com/github.com/o/r/pull/147', { + number: 147, + }), + ).toBeNull(); + expect( + findPrRef('not_github.com/o/r/pull/147', { number: 147 }), + ).toBeNull(); + expect( + findPrRef('(github.com/o/r/pull/147)', { number: 147 }), + ).not.toBeNull(); expect(findPrRef('see #147, done', { number: 147 })).not.toBeNull(); expect(findPrRef('(see #147)', { number: 147 })).not.toBeNull(); }); @@ -593,6 +605,9 @@ describe('findPrRef', () => { expect(sessionRepos(undefined, undefined)).toEqual([]); expect(sessionRepos('not a url', ['#1'])).toEqual([]); expect(sessionRepos('https://evil.github.com/o/r/pull/1')).toEqual([]); + expect(sessionRepos('https://example.com/github.com/o/r/pull/1')).toEqual( + [], + ); }); // Live finding: `pr:151` listed a session whose only "#151" was the marker diff --git a/src/session-search.ts b/src/session-search.ts index 46831b0..cea49cc 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -45,8 +45,10 @@ const IS_ALIASES: Record = { running: 'live', active: 'live' }; /** * A pull-request (or issue — GitHub numbers them together) reference. The * repo is kept when the query carried one, so `grimmerk/codev#147` does not - * match a `fireflies/x/pull/147` URL; a bare `#147` in the target still - * matches either, because a bare number cannot say which repo it meant. + * match a `fireflies/x/pull/147` URL, and a bare `#147` in the target counts + * for it only when the session's own repo context names `grimmerk/codev` + * (`QueryTarget.repos`) — a bare number cannot say which repo it meant, so + * the session has to. The full table is on `findPrRefWith`. */ export interface PrRef { number: number; @@ -135,9 +137,12 @@ export const tokenizeQuery = (query: string): string[] => { // keeps `example.com/o/pull/1` from reading as owner `example.com`. const OWNER = '[a-z0-9-]+'; const REPO = `${OWNER}\\/[a-z0-9_.-]+`; -// `github.com` or `www.github.com`, never `evil.github.com` (the lookbehind -// refuses a dot before the host) and never the tail of `notgithub.com`. -const HOST = '(? Date: Sat, 5 Sep 2026 23:29:38 +0800 Subject: [PATCH 9/9] fix(sessions): round 5 - launches report failure, resume needs a path --- src/claude-session-utility.ts | 177 +++++++++++++++++++--------------- src/main.ts | 10 +- 2 files changed, 106 insertions(+), 81 deletions(-) diff --git a/src/claude-session-utility.ts b/src/claude-session-utility.ts index 322e4a8..d92a849 100644 --- a/src/claude-session-utility.ts +++ b/src/claude-session-utility.ts @@ -1307,7 +1307,7 @@ export const openSession = async ( openSessionInCodeV(sessionId); break; case 'cmux': - openSessionInCmux( + return openSessionInCmux( sessionId, projectPath, isActive, @@ -1315,9 +1315,8 @@ export const openSession = async ( customTitle, accountLabel, ); - break; case 'ghostty': - openSessionInGhostty( + return openSessionInGhostty( sessionId, projectPath, isActive, @@ -1325,9 +1324,8 @@ export const openSession = async ( customTitle, accountLabel, ); - break; case 'terminal': - openSessionInTerminalApp( + return openSessionInTerminalApp( sessionId, projectPath, isActive, @@ -1336,10 +1334,9 @@ export const openSession = async ( customTitle, accountLabel, ); - break; case 'iterm2': default: - openSessionInITerm2( + return openSessionInITerm2( sessionId, projectPath, isActive, @@ -1348,7 +1345,6 @@ export const openSession = async ( customTitle, accountLabel, ); - break; } }; @@ -1439,8 +1435,35 @@ export const runCommandInTerminal = ( projectPath: string, terminalApp: string = 'iterm2', terminalMode: string = 'tab', -): void => { +): Promise => { const { exec } = require('child_process'); + // One osascript launch as a promise: the script file is removed either + // way, and an error REJECTS, so a caller that counts launches (`open N`) + // learns about a failure instead of counting it as opened. + const runOsascript = ( + tmpScript: string, + script: string, + label: string, + ): Promise => { + fs.writeFileSync(tmpScript, script); + return new Promise((resolve, reject) => { + exec( + `osascript ${tmpScript}`, + { encoding: 'utf-8', timeout: 5000 }, + (error: any) => { + try { + fs.unlinkSync(tmpScript); + } catch {} + if (error) { + console.error(`[runCommandInTerminal] ${label} error:`, error.message); + reject(error); + } else { + resolve(); + } + }, + ); + }); + }; switch (terminalApp) { case 'ghostty': { @@ -1461,12 +1484,7 @@ end tell` activate end if end tell`; - fs.writeFileSync(tmpScript, launchScript); - exec(`osascript ${tmpScript}`, { encoding: 'utf-8', timeout: 5000 }, (error: any) => { - if (error) console.error('[runCommandInTerminal] ghostty error:', error.message); - try { fs.unlinkSync(tmpScript); } catch {} - }); - break; + return runOsascript(tmpScript, launchScript, 'ghostty'); } case 'terminal': { const tmpScript = launchScriptPath('terminal-launch'); @@ -1496,57 +1514,57 @@ end tell` activate end if end tell`; - fs.writeFileSync(tmpScript, launchScript); - exec(`osascript ${tmpScript}`, { encoding: 'utf-8', timeout: 5000 }, (error: any) => { - if (error) console.error('[runCommandInTerminal] Terminal.app error:', error.message); - try { fs.unlinkSync(tmpScript); } catch {} - }); - break; + return runOsascript(tmpScript, launchScript, 'Terminal.app'); } case 'cmux': { - const launchInCmux = () => { - const cmuxCmd = `${CMUX_CLI} new-workspace --cwd "${projectPath}" --command "${claudeCmd}"`; - console.log('[cmux] launch cmd:', cmuxCmd); - exec(cmuxCmd, - { encoding: 'utf-8', timeout: 5000 }, - (error: any, stdout: string, stderr: string) => { - console.log('[cmux] launch result:', { error: error?.message, stdout, stderr }); - if (error) { - console.error('cmux new-workspace failed:', error.message); - } else { + return new Promise((resolve, reject) => { + const launchInCmux = () => { + const cmuxCmd = `${CMUX_CLI} new-workspace --cwd "${projectPath}" --command "${claudeCmd}"`; + console.log('[cmux] launch cmd:', cmuxCmd); + exec( + cmuxCmd, + { encoding: 'utf-8', timeout: 5000 }, + (error: any, stdout: string, stderr: string) => { + console.log('[cmux] launch result:', { error: error?.message, stdout, stderr }); + if (error) { + console.error('cmux new-workspace failed:', error.message); + reject(error); + return; + } const wsMatch = stdout.match(/workspace:\d+/); if (wsMatch) { exec(`${CMUX_CLI} select-workspace --workspace ${wsMatch[0]}`); } exec('osascript -e \'tell application "cmux" to activate\''); - } + resolve(); + }, + ); + }; + exec('pgrep -x cmux', (error: any) => { + if (error) { + console.log('[cmux] not running, launching...'); + exec('open -a cmux'); + let attempts = 0; + const waitForCmux = () => { + attempts++; + exec(`${CMUX_CLI} tree 2>/dev/null`, { timeout: 2000 }, (err: any) => { + if (!err) { + console.log(`[cmux] ready after ${attempts * 500}ms`); + launchInCmux(); + } else if (attempts < 10) { + setTimeout(waitForCmux, 500); + } else { + console.error('[cmux] timed out waiting for cmux'); + reject(new Error('cmux did not start')); + } + }); + }; + setTimeout(waitForCmux, 500); + } else { + launchInCmux(); } - ); - }; - exec('pgrep -x cmux', (error: any) => { - if (error) { - console.log('[cmux] not running, launching...'); - exec('open -a cmux'); - let attempts = 0; - const waitForCmux = () => { - attempts++; - exec(`${CMUX_CLI} tree 2>/dev/null`, { timeout: 2000 }, (err: any) => { - if (!err) { - console.log(`[cmux] ready after ${attempts * 500}ms`); - launchInCmux(); - } else if (attempts < 10) { - setTimeout(waitForCmux, 500); - } else { - console.error('[cmux] timed out waiting for cmux'); - } - }); - }; - setTimeout(waitForCmux, 500); - } else { - launchInCmux(); - } + }); }); - break; } case 'iterm2': default: { @@ -1578,12 +1596,7 @@ end tell` end tell end tell end tell`; - fs.writeFileSync(tmpScript, launchScript); - exec(`osascript ${tmpScript}`, (error: any) => { - if (error) console.error('[runCommandInTerminal] iTerm2 error:', error.message); - try { fs.unlinkSync(tmpScript); } catch {} - }); - break; + return runOsascript(tmpScript, launchScript, 'iTerm2'); } } }; @@ -1657,7 +1670,9 @@ export const launchNewClaudeSession = ( // Pass claudeCmd as the 2nd arg too — Ghostty/cmux build their launch // scripts from it (iTerm2/Terminal.app use fullCommand), so the account // env prefix must be present in both. - runCommandInTerminal(`cd "${projectPath}" && ${claudeCmd}`, claudeCmd, projectPath, terminalApp, terminalMode); + runCommandInTerminal(`cd "${projectPath}" && ${claudeCmd}`, claudeCmd, projectPath, terminalApp, terminalMode).catch( + (err) => console.error('[launchNewClaudeSession] launch failed:', err), + ); }; /** @@ -1778,7 +1793,7 @@ export const openSessionInCodeV = (sessionId: string): void => { } }; -export const openSessionInITerm2 = ( +export const openSessionInITerm2 = async ( sessionId: string, projectPath: string, isActive: boolean, @@ -1786,7 +1801,7 @@ export const openSessionInITerm2 = ( terminalMode: string = 'tab', customTitle?: string, accountLabel?: string, -): void => { +): Promise => { const { exec } = require('child_process'); if (isActive && activePid) { @@ -1842,7 +1857,7 @@ end tell`; }); } else { const resumeCmd = buildResumeCommand(sessionId, accountLabel); - runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'iterm2', terminalMode); + return runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'iterm2', terminalMode); } }; @@ -2389,13 +2404,15 @@ export const openSessionListMembers = async ( m.accountLabel, ); result.opened.push(id); + // Long enough for the new process to write its registration. + setTimeout(() => membersInFlight.delete(id), 10000); } catch (err) { - // Reported, not swallowed: "opened 5" must not hide a sixth that threw. + // Reported, not swallowed: "opened 5" must not hide a sixth that threw + // — and the claim is released at once, so a retry is not told "already + // running" for a session that never started. console.error('[open-session-list-members] launch failed:', id, err); skip('launch failed'); - } finally { - // Long enough for the new process to write its registration. - setTimeout(() => membersInFlight.delete(id), 10000); + membersInFlight.delete(id); } } return result; @@ -2459,14 +2476,14 @@ export const loadLastAssistantResponses = async ( * Open a Claude Code session in Ghostty. * Full AppleScript support: working directory matching, focus, new tab with command. */ -export const openSessionInGhostty = ( +export const openSessionInGhostty = async ( sessionId: string, projectPath: string, isActive: boolean, terminalMode: string = 'tab', customTitle?: string, accountLabel?: string, -): void => { +): Promise => { const { exec } = require('child_process'); if (isActive) { @@ -2515,7 +2532,7 @@ end tell`; }); } else { const resumeCmd = buildResumeCommand(sessionId, accountLabel); - runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'ghostty', terminalMode); + return runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'ghostty', terminalMode); } }; @@ -2524,7 +2541,7 @@ end tell`; * Similar to iTerm2 but simpler structure: window → tab (no session layer). * TTY matching works via `tty of tab`. Uses `do script` for command execution. */ -export const openSessionInTerminalApp = ( +export const openSessionInTerminalApp = async ( sessionId: string, projectPath: string, isActive: boolean, @@ -2532,7 +2549,7 @@ export const openSessionInTerminalApp = ( terminalMode: string = 'tab', customTitle?: string, accountLabel?: string, -): void => { +): Promise => { const { exec } = require('child_process'); if (isActive && activePid) { @@ -2578,7 +2595,7 @@ end tell`; }); } else { const resumeCmd = buildResumeCommand(sessionId, accountLabel); - runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'terminal', terminalMode); + return runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'terminal', terminalMode); } }; @@ -2589,14 +2606,14 @@ end tell`; */ const CMUX_CLI = '/Applications/cmux.app/Contents/Resources/bin/cmux'; -export const openSessionInCmux = ( +export const openSessionInCmux = async ( sessionId: string, projectPath: string, isActive: boolean, activePid?: number, customTitle?: string, accountLabel?: string, -): void => { +): Promise => { const { exec } = require('child_process'); const command = `cd "${projectPath}" && ${buildResumeCommand(sessionId, accountLabel)}`; @@ -2724,7 +2741,7 @@ export const openSessionInCmux = ( })(); } else { const resumeCmd = buildResumeCommand(sessionId, accountLabel); - runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'cmux'); + return runCommandInTerminal(`cd "${projectPath}" && ${resumeCmd}`, resumeCmd, projectPath, 'cmux'); } }; diff --git a/src/main.ts b/src/main.ts index 587fdbd..9c95b70 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2634,6 +2634,12 @@ ipcMain.on( console.warn('[open-claude-session] refusing unsafe project path:', projectPath); return; } + // Switching to a running session never needs the path (iTerm2 matches by + // tty); a resume does — `cd ""` would start the session in the wrong place. + if (!isActive && !projectPath) { + console.warn('[open-claude-session] refusing to resume without a project path:', sessionId); + return; + } const terminalApp = ((await settings.get('session-terminal-app')) || 'iterm2') as string; const terminalMode = ((await settings.get('session-terminal-mode')) || 'tab') as string; // A label no account carries is dropped, not trusted: history decides the @@ -2652,7 +2658,9 @@ ipcMain.on( terminalMode, customTitle, label, - ); + ).catch((err) => { + console.error('[open-claude-session] launch failed:', err); + }); }, );