From cf5893317531fd69ece257f502024acdbe84f77a Mon Sep 17 00:00:00 2001 From: Roy Padina <4943097+roypadina@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:55:54 +0200 Subject: [PATCH 1/2] feat(resume): ask Claude to find a session (S, agentctl -p) Describe a session from memory ("the one where we deployed the WAF last week") and Claude picks the matches from a metadata catalog (name, title, cwd, ClaudWork folder, labels, note, first prompt, cached recap). Results list like full-text search hits (enter resumes, p peeks); follow-ups keep the same conversation, tab flips between the ask box and the results. - core/find.ts: catalog + history -> isolated `claude -p --json-schema` (no tools, plugins, MCP, hooks or saved transcript; haiku, ~10 s; AGENTCTL_FIND_MODEL overrides). Ids validated against known sessions, control characters stripped from model text. - `agentctl -p ""` opens Resume already asking; piped stdout prints the matches. Root options are now positional so `resume -p ` keeps working. - spawnRun takes cwd + abort signal; listSessions skips the finder dir. --- CHANGELOG.md | 8 + CLAUDE.md | 7 + README.md | 7 +- src/cli/index.ts | 27 ++- src/cli/router.tsx | 3 +- src/cli/screens/AppShell.tsx | 11 +- src/cli/tui.tsx | 276 ++++++++++++++++++++++++---- src/core/config/paths.ts | 5 + src/core/find.ts | 201 ++++++++++++++++++++ src/core/recap.ts | 9 +- src/core/sessionRepo.ts | 10 +- src/core/types.ts | 2 + tests/cli/index.integration.test.ts | 26 ++- tests/cli/tui.test.tsx | 102 ++++++++++ tests/core/find.test.ts | 155 ++++++++++++++++ tests/core/sessionRepo.test.ts | 17 ++ 16 files changed, 819 insertions(+), 47 deletions(-) create mode 100644 src/core/find.ts create mode 100644 tests/core/find.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 648d83b..b109ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to Agentctl are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com); versions follow [SemVer](https://semver.org). +## [Unreleased] + +### Added + +- Resume: **ask Claude to find a session.** `S` opens an ask box — describe the session from memory ("the one where we deployed the WAF last week"); Claude picks the matching sessions from their names, folders, first prompts, notes and labels, and they list like search results (`↵` resume, `p` peek). Keep typing to narrow it down: follow-ups continue the same conversation. `⇥` flips between the ask box and the results. +- `agentctl -p ""` opens Resume already asking; with stdout piped it prints the matches instead. +- The finder runs `claude -p` isolated (no tools, plugins, MCP servers, hooks or saved transcript) on haiku in about 10 seconds; `AGENTCTL_FIND_MODEL` picks another model. + ## [0.12.1] — 2026-10-28 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index b65f1fd..2e2fb0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ src/core/ zero ink/react imports — pure data annotations.ts user metadata per session (name/note/flags/done/reminder), one JSON file each under ~/.config/agentctl/annotations/, atomic writes recap.ts on-demand AI recap: claude -p --model haiku on head+tail excerpt, cached to ~/.config/agentctl/recaps/.md (DI'd I/O + spawn) + find.ts "ask Claude to find a session": metadata catalog + chat history → isolated claude -p --json-schema (DI'd spawn) config/ Agentctl launcher config (TOML, smol-toml) types.ts GroupConfig/ToolConfig/IdeConfig/ThemeConfig, ConfigError paths.ts configPath chain + expandPath (~ / allowlisted $VAR) @@ -78,6 +79,12 @@ bin/agentctl Node shebang shim → dist/cli.js - **Session names** come from JSONL in priority order: `type:custom-title.customTitle` (from `/rename`) → `type:ai-title.aiTitle` (auto-generated) → first user prompt (with tag stripping) → `(no prompt yet)`. - **Status detection**: a session is `busy`/`idle` only when a matching `/sessions/.json` file exists, `kill -0 ` succeeds, and `ps -o comm= -p ` matches `/claude/i`. Otherwise `inactive`. - **Scan every Claude profile.** `CLAUDE_CONFIG_DIR` lets one machine run several homes (`~/.claude`, `~/.claude2`, `~/.claude-work`). Each keeps its own `sessions/` dir, so scanning only `~/.claude` reports every side-profile session as `inactive`. `claudeHomes()` globs `~/.claude*` dirs (primary first); `projectsDirs()`/`sessionsDirs()` map + realpath-dedupe them (profiles often symlink `projects/` to the primary — without the dedupe every session would list twice). `AGENTCTL_HOME` still pins the scan to a single home. +- **The session finder must stay isolated.** `find.ts` runs `claude -p` in the empty + `~/.config/agentctl/find/` with `--setting-sources project --strict-mcp-config + --disable-slash-commands --system-prompt … --tools "" --no-session-persistence`. Without them the + user's plugins, MCP servers, hooks and CLAUDE.md load into every run (measured $0.75 / 32 s vs + $0.0003 / 3 s). Never `--bare` — it skips keychain reads, so subscription logins fail. Chat history + is held by agentctl and resent each turn; `listSessions` skips the finder's project dir as a backstop. ## Agentctl launcher conventions diff --git a/README.md b/README.md index 4e5dd76..14787b3 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ It's **local-only** — it reads `~/.claude/` and your project folders, and runs | 🆕 **New-session launcher** | Grouped project dirs, frecency-sorted (`z`, falls back to mtime), fuzzy filter, git branch per row. | | 🔁 **Resume anything** | Every Claude Code session, fuzzy-matched on name / path / id — or full-text searched across transcripts. | | 👀 **Transcript peek** | Preview a session's conversation before resuming — inline in the terminal. | +| 🔎 **Ask Claude to find it** | `S` (or `agentctl -p "the WAF deploy last week"`) — describe a session from memory; Claude picks the matches, you resume one or keep chatting to narrow it down. | | 🧠 **AI recap** | `r` summarizes a session (what it was doing, decisions, state, follow-ups) via `claude -p` on haiku, cached. Decide whether to resume without reading the whole transcript. | | ⌨️ **Two tabs, one keystroke** | Start in **New**; `⇥` flips to **Resume**; `⇧⇥` cycles the tool. | | 🧰 **Your tools & IDEs** | Configure any agent command (`claude`, `codex`, …) and `^`-key IDE binds (VS Code, Rider, …). | @@ -127,6 +128,7 @@ First run sets up a starter config: `agentctl config --setup`. |---|---| | `agentctl` | New-session menu (`⇥` to Resume) | | `agentctl -r` | Resume menu | +| `agentctl -p ""` | Resume, already asking Claude which session you mean (piped: prints the matches) | Plus non-interactive subcommands: @@ -146,7 +148,7 @@ agentctl config --setup | --edit | --path # manage the shared config `^t` tmux · `^p` pull · `^f` file manager · `^`-key IDEs · `?` full keymap · `esc` back. **Resume** — `↑/↓` (or `j/k`) move · `pgup/pgdn` page · `g/G` first/last · `↵` resume · `p` peek · `r` recap · -`/` fuzzy-filter · `s` full-text search · `^r` refresh · `⇥` New · `?` help · `q` quit. +`/` fuzzy-filter · `s` full-text search · `S` ask Claude (`⇥` flips ask / results) · `^r` refresh · `⇥` New · `?` help · `q` quit. Annotate the highlighted session in place: `e` name · `n` note · `l` labels · `f` flags · `t` reminder · `u` due date · `d` done. `c` copies a resume command, `h` hides a session, `x` deletes it (twice), `v` shows the hidden ones, `H` shows/hides done ones, `T` filters tool runs. (`l` pre-fills the issue @@ -291,6 +293,9 @@ names by walking the filesystem — and flagged when the result isn't certain. - **Open source (MIT).** Read or build every line yourself. - **Local-only. No network calls of its own, no analytics, no accounts.** It reads `~/.claude/` and the project folders you configure, and launches your terminal. That's it. +- **AI features go through your own `claude` CLI.** `r` recap sends a transcript excerpt; `S` / `-p` find + sends session metadata (names, paths, first prompts, notes, labels) plus your question. Nothing runs + unless you press the key. - **Permissions.** Claude sessions start and resume in Claude Code's **auto** permission mode (`--permission-mode auto`). To opt into bypass, set `permission_mode = "bypassPermissions"` at the top of your config (adds `--dangerously-skip-permissions` to Resume and the built-in `claude` tool). The default diff --git a/src/cli/index.ts b/src/cli/index.ts index ec30be4..bf655c7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,6 +9,8 @@ import { renderTable } from './render.js'; import { renderPeek } from './peek.js'; import { resume, ResumeError } from './resume.js'; import { getRecap, RecapError } from '../core/recap.js'; +import { findSessions, FindError } from '../core/find.js'; +import { formatDate } from './format.js'; import { registerConfigCommands } from './config.js'; import { registerAnnotateCommands } from './annotate.js'; import { registerHookCommands } from './hook.js'; @@ -38,10 +40,15 @@ export function buildProgram(): Command { .description('Agentctl — start a new Claude/Codex session in a project, or search + resume an existing one') .version(PKG_VERSION) .option('-r, --resume', 'open the Resume menu (default opens New)') + .option('-p, --prompt ', 'ask Claude which session you mean, then pick it from its answer') + // Root options only BEFORE a subcommand — otherwise root -p would swallow `resume -p `. + .enablePositionalOptions() .addHelpText('after', ` Two menus agentctl New — pick a project directory, start a session there agentctl -r Resume — search every past session and pick one up + agentctl -p "the WAF deploy last week" + ask Claude which session it was; chat to narrow it down ? inside either menu the full keymap and what every marker means Annotate a session @@ -68,8 +75,10 @@ Where things live Support Agentctl: https://ko-fi.com/roypadina `) - .action(async (opts: { resume?: boolean }) => { + .action(async (opts: { resume?: boolean; prompt?: string }) => { + if (opts.prompt !== undefined && !process.stdout.isTTY) { await printFind(opts.prompt); return; } const { runApp } = await import('./router.js'); + if (opts.prompt !== undefined) { await runApp('resume', { find: opts.prompt }); return; } await runApp(opts.resume ? 'resume' : 'new'); }); @@ -187,6 +196,22 @@ Support Agentctl: https://ko-fi.com/roypadina return program; } +/** `agentctl -p` with stdout piped/redirected: plain answer, no TUI. Exit 1 when nothing matched. */ +async function printFind(question: string): Promise { + try { + const r = await findSessions(await listSessions({ view: 'normal' }), [], question); + if (r.reply) console.log(r.reply); + for (const m of r.matches) { + console.log(`${m.session.id.slice(0, 8)} ${formatDate(m.session.lastUpdatedAt)} ${m.session.name}`); + if (m.why) console.log(` ${m.why}`); + } + if (r.matches.length === 0) process.exit(1); + } catch (e) { + if (e instanceof FindError) { console.error(e.message); process.exit(e.code); } + throw e; + } +} + async function main() { // Bare `agentctl` → New; `-r`/`--resume` → Resume; both via the root action. await buildProgram().parseAsync(process.argv); diff --git a/src/cli/router.tsx b/src/cli/router.tsx index 92ad3aa..61bef30 100644 --- a/src/cli/router.tsx +++ b/src/cli/router.tsx @@ -32,7 +32,7 @@ function drainTty(): void { } /** Mount the app (New by default, switchable to Resume), then run any deferred launch. */ -export async function runApp(initial: Screen): Promise { +export async function runApp(initial: Screen, opts: { find?: string } = {}): Promise { pending = { kind: 'quit' } as ScreenResult; const tab: Tab = initial === 'resume' ? 'resume' : 'new'; @@ -57,6 +57,7 @@ export async function runApp(initial: Screen): Promise { projects={projects} configError={configError} initialSessions={initialSessions} + initialFind={opts.find} />, ); await instance.waitUntilExit(); diff --git a/src/cli/screens/AppShell.tsx b/src/cli/screens/AppShell.tsx index f0bb26d..b610f22 100644 --- a/src/cli/screens/AppShell.tsx +++ b/src/cli/screens/AppShell.tsx @@ -17,6 +17,8 @@ interface AppShellProps { projects: ProjectDir[][]; configError?: ConfigError; initialSessions: SessionRecord[] | null; + /** `agentctl -p ""`: open Resume already asking Claude this. */ + initialFind?: string; } function TabBar({ tab }: { tab: Tab }) { @@ -35,9 +37,11 @@ function TabBar({ tab }: { tab: Tab }) { } /** Single mount holding both screens; ⇥ flips the tab (instant, no remount). */ -export function AppShell({ initialTab, config, warnings, projects, configError, initialSessions }: AppShellProps) { +export function AppShell({ initialTab, config, warnings, projects, configError, initialSessions, initialFind }: AppShellProps) { const { exit } = useApp(); const [tab, setTab] = useState(initialTab); + // Ask once: switching tabs remounts Resume, which must not re-run (and re-pay for) the question. + const [find, setFind] = useState(initialFind); const [sessions, setSessions] = useState(initialSessions); const startedResume = useRef(false); @@ -73,10 +77,11 @@ export function AppShell({ initialTab, config, warnings, projects, configError, ) : ( setTab('new')} + initialFind={find} + onSwitchTab={() => { setFind(undefined); setTab('new'); }} // Terminal actions must exit ink — runApp performs the resume only after unmount. onResume={(s) => { setScreenResult({ kind: 'resume', record: s }); exit(); }} - onBack={() => setTab('new')} + onBack={() => { setFind(undefined); setTab('new'); }} onQuit={() => { setScreenResult({ kind: 'quit' }); exit(); }} /> )} diff --git a/src/cli/tui.tsx b/src/cli/tui.tsx index c3b1545..a02808e 100644 --- a/src/cli/tui.tsx +++ b/src/cli/tui.tsx @@ -13,6 +13,7 @@ import { copyToClipboard, resumeCommand } from '../core/clipboard.js'; import { listProfiles, profileAccount, type Profile } from '../core/profiles.js'; import { fuzzyRank } from '../core/fuzzy.js'; import { getRecap, readCachedRecap, spawnRun } from '../core/recap.js'; +import { findSessions, FindError, type FindDeps, type FindResult, type FindTurn } from '../core/find.js'; import { loadPermissionMode } from '../core/config/loadConfig.js'; import { permissionArgs } from '../core/config/defaults.js'; import { windowFor, scrollbar } from './viewport.js'; @@ -36,7 +37,7 @@ function tildify(p: string): string { return p.startsWith(home) ? '~' + p.slice(home.length) : p; } -type Mode = 'list' | 'filter' | 'peek' | 'search-input' | 'search-results' | 'help' | 'annotate'; +type Mode = 'list' | 'filter' | 'peek' | 'search-input' | 'search-results' | 'help' | 'annotate' | 'find'; type PromptKind = 'name' | 'note' | 'flag' | 'label' | 'remind' | 'due'; const PROMPTS: Record = { @@ -56,9 +57,13 @@ interface AppProps { onQuit?: () => void; /** ⇥ switches to the New tab (handled by AppShell). */ onSwitchTab?: () => void; + /** `agentctl -p ""`: open in find mode, already asking Claude this. */ + initialFind?: string; + /** Session-finder DI (tests pass a fake `run`). */ + findDeps?: FindDeps; } -export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps) { +export function App({ initial, onResume, onBack, onQuit, onSwitchTab, initialFind, findDeps }: AppProps) { const { exit } = useApp(); const doResume = (raw: SessionRecord) => { @@ -78,7 +83,7 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps const [filter, setFilter] = useState(''); const [peek, setPeek] = useState(null); const [peekOffset, setPeekOffset] = useState(0); - const [peekFrom, setPeekFrom] = useState<'list' | 'search'>('list'); + const [peekFrom, setPeekFrom] = useState<'list' | 'search' | 'find'>('list'); const [searchQuery, setSearchQuery] = useState(''); const [searchInput, setSearchInput] = useState(''); const [searchResults, setSearchResults] = useState([]); @@ -258,6 +263,70 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps useEffect(() => () => { searchAbortRef.current?.abort(); }, []); + // Find (S / `agentctl -p`): ask Claude which session the user means. The chat history is kept + // here and resent each turn (core/find.ts); only the last exchange is drawn. + const [findInput, setFindInput] = useState(''); + const [findQuestion, setFindQuestion] = useState(''); + const [findHistory, setFindHistory] = useState([]); + const [findResult, setFindResult] = useState(null); + const [findCursor, setFindCursor] = useState(0); + const [findBusy, setFindBusy] = useState(false); + const [findError, setFindError] = useState(null); + const [findStartedAt, setFindStartedAt] = useState(0); + // ink-text-input eats every letter, so the input and the result list take turns (⇥). + const [findFocus, setFindFocus] = useState<'input' | 'list'>('input'); + const [, setTick] = useState(0); + const findAbortRef = useRef(null); + const findInputRef = useRef(''); + findInputRef.current = findInput; + const findHits = findResult?.matches ?? []; + + const stopFind = () => { + findAbortRef.current?.abort(); + findAbortRef.current = null; + setFindBusy(false); + }; + + const ask = (raw: string) => { + const question = raw.trim(); + if (!question) { if (findHits.length > 0) setFindFocus('list'); return; } + stopFind(); + const ac = new AbortController(); + findAbortRef.current = ac; + setFindInput(''); + setFindQuestion(question); + setFindError(null); + setFindBusy(true); + setFindStartedAt(Date.now()); + findSessions(visible, findHistory, question, { ...findDeps, signal: ac.signal }) + .then(r => { + if (findAbortRef.current !== ac) return; // cancelled or superseded + setFindHistory(h => [...h, { role: 'user', text: question }, + { role: 'finder', text: r.reply, ids: r.matches.map(m => m.session.id.slice(0, 8)) }]); + setFindResult(r); + setFindCursor(0); + // ⏎ / p work at once — unless a follow-up is already being typed (letters would turn into keys). + if (r.matches.length > 0 && !findInputRef.current) setFindFocus('list'); + }) + .catch(e => { + if (findAbortRef.current !== ac || (e instanceof FindError && e.code === 130)) return; + setFindError(e instanceof Error ? e.message : 'find failed'); + }) + .finally(() => { + if (findAbortRef.current === ac) { findAbortRef.current = null; setFindBusy(false); } + }); + }; + + useEffect(() => { + if (!findBusy) return; + const t = setInterval(() => setTick(n => n + 1), 1000); + return () => clearInterval(t); + }, [findBusy]); + useEffect(() => { + if (initialFind !== undefined) { setMode('find'); ask(initialFind); } + return () => { findAbortRef.current?.abort(); }; + }, []); + const keyChunk = useKeyChunk(); useInput((input, key) => { @@ -284,7 +353,7 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps } if (mode === 'peek') { if (key.escape || input === 'p' || input === 'q') { - setMode('list'); + setMode(peekFrom === 'find' ? 'find' : 'list'); setPeek(null); setPeekOffset(0); return; @@ -299,6 +368,43 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps if (input === 'G') { setPeekOffset(0); return; } return; } + if (mode === 'find') { + if (key.escape) { + if (findBusy) { stopFind(); return; } + if (findFocus === 'input' && findInput) { setFindInput(''); return; } + setMode('list'); + return; + } + if (key.tab) { setFindFocus(f => (f === 'input' ? 'list' : 'input')); return; } + const last = Math.max(0, findHits.length - 1); + if (findFocus === 'input') { + // ↑/↓ still move the results while typing (TextInput ignores them); j/k are text here. + if (movedArrows !== 0) setFindCursor(c => Math.max(0, Math.min(last, c + movedArrows))); + return; // TextInput owns the rest; ⏎ → ask + } + if (moved !== 0) setFindCursor(c => Math.max(0, Math.min(last, c + moved))); + if (key.pageUp) setFindCursor(c => Math.max(0, c - findPerView)); + if (key.pageDown) setFindCursor(c => Math.min(last, c + findPerView)); + if (input === 'g') setFindCursor(0); + if (input === 'G') setFindCursor(last); + if (input === 'x' && findBusy) { stopFind(); return; } + if (input === 'q') { stopFind(); setMode('list'); return; } + if (input === 'i' || input === 'S') { setFindFocus('input'); return; } + const hit = findHits[findCursor]; + if (input === 'p' && hit) { + stopFind(); // a late answer would swap the list under the peek + readTranscript(hit.session.jsonlPath) + .then(turns => { setPeek(turns); setPeekOffset(0); }) + .catch(() => { setPeek([]); setPeekOffset(0); }); + setPeekFrom('find'); + setMode('peek'); + } + if (key.return && hit) { + stopFind(); + doResume(hit.session); + } + return; + } if (mode === 'search-results') { if (key.escape || input === 'q') { stopSearch(); @@ -432,6 +538,11 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps setSearchInput(searchQuery); setMode('search-input'); } + if (input === 'S') { + setFindFocus('input'); + setMode('find'); + return; + } if (input === 'p') { const s = filtered[clamped]; if (s) { @@ -505,6 +616,14 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps const searchPerView = Math.max(2, Math.floor((rows - 10) / 2)); const { start: searchStart, end: searchEnd } = windowFor(searchResults.length, searchCursor, searchPerView); const searchView = searchResults.slice(searchStart, searchEnd); + // Find: chrome ≈ tab bar 2 + header 2 + gaps 3 + status/you 2 + input 1 + footer 2, then the reply. + const replyWidth = Math.max(20, cols - 13); + const reply = findResult?.reply ? truncReply(findResult.reply, replyWidth * 3) : ''; + // Word-wrap needs more lines than chars/width; the slack + the fixed-height box keep the frame bounded. + const replyLines = reply ? Math.min(3, Math.ceil(reply.length / (replyWidth * 0.85))) : 0; + const findPerView = Math.max(1, Math.floor((rows - 12 - replyLines - (findError ? 1 : 0)) / 2)); + const { start: findStart, end: findEnd } = windowFor(findHits.length, findCursor, findPerView); + const findView = findHits.slice(findStart, findEnd); const renderHeader = () => ( @@ -560,7 +679,7 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps {filter} ) : null} - {mode !== 'search-results' ? ( + {mode !== 'search-results' && mode !== 'find' ? ( <> · {filtered.length === 0 ? 0 : clamped + 1}/{filtered.length} @@ -684,37 +803,76 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps ) : ( - {searchView.map((m, vi) => { - const i = searchStart + vi; - const isSel = i === searchCursor; - const marker = isSel ? '▶' : ' '; - return ( - - - {marker} - {STATUS_DOT[m.session.status]} - - {m.session.name} - - {m.session.id.slice(0, 8)} - - {formatDate(m.session.lastUpdatedAt)} - ( - {timeAgo(m.session.lastUpdatedAt)} - ) - - - … - {highlight(m.excerpt, searchQuery, cols - 10)} - - - ); - })} + {searchView.map((m, vi) => ( + + ))} )} ) : null} + {mode === 'find' ? ( + + + ✱ + ask Claude + + {findBusy ? ( + <> + thinking… {Math.round((Date.now() - findStartedAt) / 1000)}s + esc cancel + + ) : findResult ? ( + <> + {findHits.length} + matches + {findHits.length > 0 ? ( + <> + + {findCursor + 1}/{findHits.length} + + ) : null} + + ) : null} + + {findQuestion ? ( + + you › + {findQuestion} + + ) : null} + {reply ? ( + + claude › + {reply} + + ) : null} + {findError ? {findError} : null} + + {findHits.length === 0 ? ( + + {findBusy ? ' ' : findResult ? '(no matching sessions)' + : '(describe the session, e.g. "the one where we deployed the WAF last week")'} + + ) : findView.map((m, vi) => ( + + ))} + + + ask: + 0 ? 'narrow it down…' : 'which session are you looking for?'} + /> + + + ) : null} + {mode === 'help' ? ( keys — Resume @@ -728,6 +886,7 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps + @@ -770,7 +929,8 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps const sliceStart = Math.max(0, end - peekWindow); const visible = peek.slice(sliceStart, end); const fromSearch = peekFrom === 'search' && !!searchResults[searchCursor]; - const sel = fromSearch ? searchResults[searchCursor].session : filtered[clamped]; + const fromFind = peekFrom === 'find' && !!findHits[findCursor]; + const sel = fromFind ? findHits[findCursor].session : fromSearch ? searchResults[searchCursor].session : filtered[clamped]; const peekBox = ( @@ -812,8 +972,8 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps if (!wide) return {peekBox}; // Wide terminal: keep a slim session rail on the left for context while reading. - const list = fromSearch ? searchResults.map((m) => m.session) : filtered; - const cur = fromSearch ? searchCursor : clamped; + const list = fromFind ? findHits.map((m) => m.session) : fromSearch ? searchResults.map((m) => m.session) : filtered; + const cur = fromFind ? findCursor : fromSearch ? searchCursor : clamped; const railStart = Math.max(0, Math.min(Math.max(0, list.length - peekWindow), cur - Math.floor(peekWindow / 2))); const railSlice = list.slice(railStart, railStart + peekWindow); return ( @@ -848,6 +1008,23 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps {copied ? ( ✓ copied to clipboard: {copied} + ) : mode === 'find' ? ( + findFocus === 'input' ? ( + <> + ⏎ ask + · ↑/↓ move + · ⇥ results + · esc {findBusy ? 'cancel' : 'back'} + + ) : ( + <> + ↑/↓ move + · ⏎ resume + · p peek + · ⇥ ask more + · esc {findBusy ? 'cancel' : 'back'} + + ) ) : mode === 'search-results' ? ( <> ↑/↓ @@ -1094,6 +1271,37 @@ function HelpRow({ k, v }: { k: string; v: string }) { ); } +/** Two-line result row shared by full-text search and find: session line + excerpt / why. */ +function SearchHit({ session, excerpt, selected }: { session: SessionRecord; excerpt: string; selected: boolean }) { + return ( + + + {selected ? '▶' : ' '} + {STATUS_DOT[session.status]} + + {session.name} + + {session.id.slice(0, 8)} + + {formatDate(session.lastUpdatedAt)} + ( + {timeAgo(session.lastUpdatedAt)} + ) + + + … + {/* exactly two lines per hit — the windowing math counts on it */} + {excerpt} + + + ); +} + +function truncReply(text: string, maxLen: number): string { + const t = text.replace(/\s+/g, ' ').trim(); + return t.length > maxLen ? t.slice(0, maxLen - 1) + '…' : t; +} + function highlight(text: string, query: string, maxLen: number): string { const t = text.length > maxLen ? text.slice(0, maxLen - 1) + '…' : text; return t; diff --git a/src/core/config/paths.ts b/src/core/config/paths.ts index 8f3b3a1..db8e7f8 100644 --- a/src/core/config/paths.ts +++ b/src/core/config/paths.ts @@ -14,6 +14,11 @@ export function configDir(): string { return join(base, 'agentctl'); } +/** Empty cwd the session finder runs `claude -p` in (see core/find.ts). */ +export function findDir(): string { + return join(configDir(), 'find'); +} + export function configPath(): string { const explicit = process.env.AGENTCTL_CONFIG; if (explicit && explicit.trim()) return explicit; diff --git a/src/core/find.ts b/src/core/find.ts new file mode 100644 index 0000000..f2ed537 --- /dev/null +++ b/src/core/find.ts @@ -0,0 +1,201 @@ +import { mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename } from 'node:path'; +import { findDir } from './config/paths.js'; +import { readCachedRecap, spawnRun, type RecapRun } from './recap.js'; +import type { SessionRecord } from './types.js'; + +// "Find me the session where we…" — ask Claude to pick sessions from a compact metadata catalog. +// One isolated `claude -p` turn, no tools. The chat history lives here (resent each turn), so no +// finder transcript is ever written. Isolation flags are mandatory: without them Roy's plugins, MCP +// servers and CLAUDE.md load into every run (measured $0.75 / 32 s vs $0.0003 / 3 s). + +export class FindError extends Error { + constructor(public code: number, message: string) { + super(message); + this.name = 'FindError'; + } +} + +export interface FindMatch { session: SessionRecord; why: string } +export interface FindTurn { role: 'user' | 'finder'; text: string; ids?: string[] } +export interface FindResult { reply: string; matches: FindMatch[] } + +export interface FindDeps { + /** Agent spawn (tests). Default: spawnRun(bin, { cwd: findDir(), signal }). */ + run?: (args: string[], input: string) => RecapRun | Promise; + signal?: AbortSignal; + /** Default $AGENTCTL_FIND_MODEL → 'haiku'. */ + model?: string; + /** Default $AGENTCTL_CLAUDE_BIN → 'claude'. */ + bin?: string; + now?: () => Date; + /** Cached recap text for a session, or null. Default: readCachedRecap (never generates). */ + readRecap?: (id: string) => string | null; +} + +// shortcut: catalog = metadata only (no transcript grep), newest 400 interactive + 100 tool rows; +// add a Grep tool (`--tools Grep --add-dir `) if real questions miss mid-transcript topics. +export const MAX_INTERACTIVE = 400; +export const MAX_TOOL = 100; +export const MAX_HISTORY = 8; +export const MAX_MATCHES = 10; + +export const FIND_SCHEMA = JSON.stringify({ + type: 'object', + additionalProperties: false, + required: ['reply', 'matches'], + properties: { + reply: { type: 'string' }, + matches: { + type: 'array', + maxItems: MAX_MATCHES, + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'why'], + properties: { id: { type: 'string' }, why: { type: 'string' } }, + }, + }, + }, +}); + +const stamp = (d: Date) => d.toLocaleString('sv-SE').slice(0, 16); // 2026-10-27 14:03, local time + +export function systemPrompt(now: Date): string { + const human = now.toLocaleString('en-US', { + weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, + }); + return [ + 'You are the session finder inside agentctl, a terminal tool that lists past Claude Code sessions.', + 'The user describes a session from memory. Pick the catalog rows that match and explain briefly.', + `Now: ${stamp(now)} local (${human}). Catalog rows are newest first. Row fields:`, + 'id | used (last activity) | started | [tool] marks an automated run | name | title (original', + 'title when the user renamed it) | cwd | work (ClaudWork folder) | labels | note | first (first', + 'prompt) | recap.', + 'Rules:', + '- Read relative time ("past few days", "last week") against Now and prefer rows whose "used" date', + ' fits; if nothing recent fits, include the best older match and say it is older.', + '- Match meaning, not exact words (WAF = web application firewall; "deploying" covers helm,', + ' terraform, argocd, release). A Jira key, repo or folder name in the question is a strong signal.', + '- Prefer interactive sessions. [tool] rows are automated helpers (recaps, subagents): include them', + ' only when the question clearly asks for one or nothing else fits.', + '- A row whose first prompt is itself a request to find a session is an earlier search, not the work:', + ' rank it below the sessions where the work happened.', + `- At most ${MAX_MATCHES} matches, best first. Copy the id exactly from the row. Never invent an id.`, + '- reply: 1-3 plain sentences for a terminal: what you found, what separates the top candidates,', + ' or which detail would narrow it down. No markdown, no lists.', + '- why: at most 15 words per match: what in the row matches.', + '- Follow-up messages refine the same search: keep earlier matches unless the user rules them out.', + ].join('\n'); +} + +/** Model text goes straight to the terminal: no ESC/CSI/OSC (e.g. an injected OSC 52 clipboard write). */ +const plain = (s: string) => s.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').replace(/\s+/g, ' ').trim(); + +const clip = (s: string, n: number) => { + const t = s.replace(/\s+/g, ' ').trim(); + return t.length > n ? t.slice(0, n - 1) + '…' : t; +}; + +function tildify(p: string): string { + const home = homedir(); + return p.startsWith(home) ? '~' + p.slice(home.length) : p; +} + +function catalogRow(r: SessionRecord, readRecap: (id: string) => string | null): string { + const a = r.annotation; + const parts = [r.id.slice(0, 8), `used ${stamp(r.lastUpdatedAt)}`, `started ${stamp(r.startedAt).slice(0, 10)}`]; + if (r.kind === 'tool') parts.push('[tool]'); + parts.push(`name: ${clip(r.name, 100)}`); + if (r.transcriptName !== r.name && r.transcriptName !== r.firstPrompt) parts.push(`title: ${clip(r.transcriptName, 100)}`); + parts.push(`cwd: ${tildify(r.cwd)}`); + if (r.workDir) parts.push(`work: ${basename(r.workDir)}`); + if (a?.labels.length) parts.push(`labels: ${a.labels.join(' ')}`); + if (a?.note) parts.push(`note: ${clip(a.note, 80)}`); + if (r.firstPrompt && r.firstPrompt !== r.name) parts.push(`first: ${clip(r.firstPrompt, 140)}`); + const recap = readRecap(r.id)?.split('\n').find(l => l.trim()); + if (recap) parts.push(`recap: ${clip(recap, 120)}`); + return parts.join(' | '); +} + +/** Newest-first catalog block (header + one row per session), capped per kind. Pure. */ +export function buildCatalog(records: SessionRecord[], deps: FindDeps = {}): string { + const readRecap = deps.readRecap ?? ((id: string) => readCachedRecap(id)?.text ?? null); + let interactive = 0; + let tool = 0; + const picked = [...records] + .sort((a, b) => b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime()) + .filter(r => (r.kind === 'tool' ? tool++ < MAX_TOOL : interactive++ < MAX_INTERACTIVE)); + return [`=== CATALOG: ${picked.length} sessions, newest first ===`, ...picked.map(r => catalogRow(r, readRecap))].join('\n'); +} + +/** Parse `claude -p --output-format json` stdout into matches against known sessions. Pure. */ +export function parseFindOutput(stdout: string, records: SessionRecord[]): FindResult { + let out: { is_error?: boolean; result?: unknown; structured_output?: { reply?: unknown; matches?: unknown } }; + try { + out = JSON.parse(stdout); + } catch { + const last = stdout.trim().split('\n').pop() ?? ''; + try { out = JSON.parse(last); } catch { + throw new FindError(1, `claude returned no JSON: ${clip(stdout, 120)}`); + } + } + if (out.is_error) throw new FindError(1, typeof out.result === 'string' && out.result ? out.result : 'claude reported an error'); + const so = out.structured_output; + if (!so || typeof so !== 'object') throw new FindError(1, 'claude did not return a structured answer'); + const seen = new Set(); + const matches: FindMatch[] = []; + for (const m of Array.isArray(so.matches) ? so.matches : []) { + const id = (m as { id?: unknown })?.id; + if (typeof id !== 'string' || id.length < 4) continue; + const hits = records.filter(r => r.id.startsWith(id)); + if (hits.length !== 1 || seen.has(hits[0].id)) continue; // unknown or ambiguous id → dropped + seen.add(hits[0].id); + const why = (m as { why?: unknown }).why; + matches.push({ session: hits[0], why: typeof why === 'string' ? plain(why) : '' }); + } + return { reply: typeof so.reply === 'string' ? plain(so.reply) : '', matches: matches.slice(0, MAX_MATCHES) }; +} + +function turnLine(t: FindTurn): string { + if (t.role === 'user') return `user: ${t.text}`; + return `finder: ${t.text}${t.ids?.length ? ` [matched: ${t.ids.join(', ')}]` : ''}`; +} + +/** Ask Claude which of `records` the user means. `history` = earlier turns of this chat. */ +export async function findSessions( + records: SessionRecord[], + history: FindTurn[], + question: string, + deps: FindDeps = {}, +): Promise { + const model = deps.model ?? process.env.AGENTCTL_FIND_MODEL ?? 'haiku'; + const bin = deps.bin ?? process.env.AGENTCTL_CLAUDE_BIN ?? 'claude'; + const now = (deps.now ?? (() => new Date()))(); + const args = [ + '-p', '--model', model, '--output-format', 'json', '--json-schema', FIND_SCHEMA, + '--system-prompt', systemPrompt(now), '--tools', '', '--no-session-persistence', + '--setting-sources', 'project', '--strict-mcp-config', '--disable-slash-commands', + ]; + const input = [ + buildCatalog(records, deps), + '=== CONVERSATION ===', + ...history.slice(-MAX_HISTORY).map(turnLine), + `user: ${question}`, + ].join('\n') + '\n'; + let run = deps.run; + if (!run) { + // Empty dedicated cwd: no project CLAUDE.md or settings for `--setting-sources project` to find. + const cwd = findDir(); + mkdirSync(cwd, { recursive: true }); + run = spawnRun(bin, { cwd, signal: deps.signal }); + } + const res = await run(args, input); + if (deps.signal?.aborted || res.error?.name === 'AbortError') throw new FindError(130, 'cancelled'); + if ((res.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { + throw new FindError(127, `${bin} not found on PATH (override via AGENTCTL_CLAUDE_BIN)`); + } + if (!res.stdout.trim()) throw new FindError(res.status || 1, `claude exited ${res.status ?? '?'} with no output`); + return parseFindOutput(res.stdout, records); +} diff --git a/src/core/recap.ts b/src/core/recap.ts index ccf5a80..4bc23d0 100644 --- a/src/core/recap.ts +++ b/src/core/recap.ts @@ -104,14 +104,18 @@ function defaultRun(bin: string): (args: string[], input: string) => RecapRun { * Non-blocking spawn runner for in-process callers (the ink TUI) — `spawnSync` would freeze the * event loop while the agent thinks. Resolves once the child exits. */ -export function spawnRun(bin = process.env.AGENTCTL_CLAUDE_BIN ?? 'claude') { +export function spawnRun( + bin = process.env.AGENTCTL_CLAUDE_BIN ?? 'claude', + opts: { cwd?: string; signal?: AbortSignal } = {}, +) { return (args: string[], input: string): Promise => new Promise((resolve) => { let stdout = ''; let settled = false; let child; try { - child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'ignore'] }); + // `signal` kills the child on abort and surfaces as an AbortError on 'error'. + child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'ignore'], cwd: opts.cwd, signal: opts.signal }); } catch (error) { resolve({ stdout: '', status: null, error: error as Error }); return; @@ -120,6 +124,7 @@ export function spawnRun(bin = process.env.AGENTCTL_CLAUDE_BIN ?? 'claude') { child.stdout?.on('data', (d: string) => { stdout += d; }); child.on('error', (error) => { if (!settled) { settled = true; resolve({ stdout, status: null, error }); } }); child.on('close', (status) => { if (!settled) { settled = true; resolve({ stdout, status }); } }); + child.stdin?.on('error', () => { /* EPIPE when the child exits early; 'close' reports it */ }); child.stdin?.end(input); }); } diff --git a/src/core/sessionRepo.ts b/src/core/sessionRepo.ts index 0d2910b..59dc1c3 100644 --- a/src/core/sessionRepo.ts +++ b/src/core/sessionRepo.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { projectsDirs } from './paths.js'; @@ -7,6 +7,7 @@ import { scanJsonl, kindOf } from './jsonlScan.js'; import { liveSessionMap } from './liveState.js'; import { readGitBranch } from './git.js'; import { readAllAnnotations } from './annotations.js'; +import { findDir } from './config/paths.js'; import type { Stats } from 'node:fs'; import type { Annotation, ListOptions, LiveSession, SessionRecord, SessionView } from './types.js'; @@ -14,6 +15,10 @@ export async function listSessions(opts: ListOptions = {}): Promise = {}) { return spawnSync('npx', ['tsx', entry, ...args], { encoding: 'utf8', - env: { ...process.env, AGENTCTL_HOME: home, XDG_CONFIG_HOME: home, FORCE_COLOR: '0' }, + env: { ...process.env, AGENTCTL_HOME: home, XDG_CONFIG_HOME: home, FORCE_COLOR: '0', ...env }, }); } @@ -35,6 +35,26 @@ afterAll(() => { }); describe('agentctl CLI', () => { + it('root -p does not swallow `resume -p `', () => { + const r = runCli(['resume', 'aaaaaaaa', '-p', 'nope']); + expect(r.status).toBe(2); + expect(r.stderr).toContain("no logged-in Claude profile matches 'nope'"); + }); + + it('-p with piped stdout prints Claude\'s pick without the TUI', () => { + // Fake claude: answers with the fixture session, and records argv to prove the isolation flags. + const bin = join(home, 'fake-claude.sh'); + const out = JSON.stringify({ type: 'result', is_error: false, structured_output: { + reply: 'Found the fixture.', matches: [{ id: 'aaaaaaaa', why: 'it says hi' }] } }); + writeFileSync(bin, `#!/bin/sh\necho "$@" > "${home}/argv.txt"\ncat > /dev/null\necho '${out}'\n`, { mode: 0o755 }); + const r = runCli(['-p', 'the hi one'], { AGENTCTL_CLAUDE_BIN: bin }); + expect(r.stderr).toBe(''); + expect(r.status).toBe(0); + expect(r.stdout).toContain('Found the fixture.'); + expect(r.stdout).toMatch(/aaaaaaaa {2}.* {2}hi from fixture\n {4}it says hi/); + expect(readFileSync(join(home, 'argv.txt'), 'utf8')).toContain('--no-session-persistence'); + }); + it('ls --json returns an array with the fixture session', () => { const r = runCli(['ls', '--json']); expect(r.status).toBe(0); diff --git a/tests/cli/tui.test.tsx b/tests/cli/tui.test.tsx index 95112e9..93eb883 100644 --- a/tests/cli/tui.test.tsx +++ b/tests/cli/tui.test.tsx @@ -122,3 +122,105 @@ describe('short terminals', () => { expect(lastFrame()).toContain('resume'); // header survived }); }); + +describe('find (S / agentctl -p)', () => { + const waf = rec('aaaa1111-0000-0000-0000-000000000000', 'WAF deploy to prod'); + const other = rec('bbbb2222-0000-0000-0000-000000000000', 'mongo indexes'); + const answer = JSON.stringify({ + type: 'result', is_error: false, + structured_output: { reply: 'The Oct 27 session did the terraform apply.', matches: [{ id: 'aaaa1111', why: 'name says WAF deploy' }] }, + }); + const settle = () => new Promise(r => setTimeout(r, 50)); + + it('S opens the ask box; a question renders the reply and the match, ⏎ resumes it', async () => { + const questions: string[] = []; + const resumed: SessionRecord[] = []; + const { stdin, lastFrame } = render( + resumed.push(s)} + findDeps={{ readRecap: () => null, run: (_a, input) => { questions.push(input); return { stdout: answer, status: 0 }; } }} />, + ); + await sendKey(stdin, 'S'); + expect(lastFrame()).toContain('ask:'); + await sendKey(stdin, 'the waf one'); + await sendKey(stdin, '\r'); + await settle(); + expect(questions[0]).toContain('user: the waf one'); + expect(lastFrame()).toContain('terraform apply'); + expect(lastFrame()).toContain('name says WAF deploy'); + await sendKey(stdin, '\r'); // focus jumped to the results — ⏎ resumes the top match + expect(resumed.map(s => s.id)).toEqual([waf.id]); + }); + + it('initialFind asks on mount; a follow-up resends the earlier exchange', async () => { + const inputs: string[] = []; + const { stdin, lastFrame } = render( + null, run: (_a, input) => { inputs.push(input); return { stdout: answer, status: 0 }; } }} />, + ); + await settle(); + expect(inputs).toHaveLength(1); + expect(lastFrame()).toContain('waf last week'); + await sendKey(stdin, '\t'); + await sendKey(stdin, 'the terraform one'); + await sendKey(stdin, '\r'); + await settle(); + expect(inputs[1]).toContain('user: waf last week\nfinder: The Oct 27 session did the terraform apply. [matched: aaaa1111]\nuser: the terraform one'); + }); + + it('a late answer leaves focus in the ask box while a follow-up is being typed', async () => { + let release: (v: { stdout: string; status: number }) => void = () => {}; + const { stdin, lastFrame } = render( + null, run: () => new Promise(r => { release = r; }) }} />, + ); + await sendKey(stdin, 'S'); + await sendKey(stdin, 'waf'); + await sendKey(stdin, '\r'); + await sendKey(stdin, 'quick'); // typing the next question while Claude thinks + release({ stdout: answer, status: 0 }); + await settle(); + expect(lastFrame()).toContain('name says WAF deploy'); + expect(lastFrame()).toContain('ask: quick'); // q/u/i/c/k went into the box, not to the list + expect(lastFrame()).toContain('ask Claude'); + }); + + it('esc: cancels a running ask, then clears the box, then leaves find', async () => { + const { stdin, lastFrame } = render( + null, run: () => new Promise(() => {}) }} />, + ); + await sendKey(stdin, 'S'); + await sendKey(stdin, 'waf'); + await sendKey(stdin, '\r'); + expect(lastFrame()).toContain('thinking…'); + await sendKey(stdin, '\x1B'); + expect(lastFrame()).not.toContain('thinking…'); + await sendKey(stdin, 'abc'); + await sendKey(stdin, '\x1B'); + expect(lastFrame()).toContain('ask:'); + expect(lastFrame()).not.toContain('abc'); + await sendKey(stdin, '\x1B'); + expect(lastFrame()).not.toContain('ask:'); + }); + + it('a long reply and ten long matches still fit a 30-row terminal', async () => { + const recs = Array.from({ length: 10 }, (_, i) => rec(`${String(i).padStart(8, '0')}-0000-0000-0000-000000000000`, `session-${i}`)); + const long = JSON.stringify({ type: 'result', is_error: false, structured_output: { + reply: 'word '.repeat(200), matches: recs.map(r => ({ id: r.id.slice(0, 8), why: 'because '.repeat(40) })) } }); + const { lastFrame } = render( + null, run: () => ({ stdout: long, status: 0 }) }} />, + ); + await settle(); + expect(lastFrame()).toContain('10 matches'); + expect(lastFrame()!.split('\n').length).toBeLessThanOrEqual(28); // + 2-line tab bar in AppShell = 30 + }); + + it('shows a claude error instead of crashing', async () => { + const { stdin, lastFrame } = render( + null, run: () => ({ stdout: JSON.stringify({ is_error: true, result: 'Not logged in' }), status: 1 }) }} />, + ); + await sendKey(stdin, 'S'); + await sendKey(stdin, 'x'); + await sendKey(stdin, '\r'); + await settle(); + expect(lastFrame()).toContain('Not logged in'); + }); +}); diff --git a/tests/core/find.test.ts b/tests/core/find.test.ts new file mode 100644 index 0000000..30cfc7f --- /dev/null +++ b/tests/core/find.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + buildCatalog, parseFindOutput, findSessions, FindError, FIND_SCHEMA, MAX_HISTORY, type FindTurn, +} from '../../src/core/find.js'; +import { spawnRun, type RecapRun } from '../../src/core/recap.js'; +import type { SessionRecord } from '../../src/core/types.js'; + +function rec(id: string, name: string, extra: Partial = {}): SessionRecord { + return { + id, name, transcriptName: name, cwd: '/tmp/proj', launchCwd: '/tmp/proj', + cwdDecodeConfident: true, kind: 'interactive', jsonlPath: '/x.jsonl', sizeBytes: 0, + startedAt: new Date('2026-10-20T10:00:00'), lastUpdatedAt: new Date('2026-10-27T14:03:00'), + active: false, status: 'inactive', ...extra, + }; +} + +const A = rec('aaaa1111-0000-0000-0000-000000000000', 'WAF deploy to prod', { + firstPrompt: 'deploy the WAF with terraform', workDir: '/Users/x/ClaudWork/2026-10-25-reeco-waf', + annotation: { sessionId: 'aaaa1111', flags: [], labels: ['RD-14001'], note: 'waiting on Dor', done: false, hidden: false, deleted: false }, +}); +const B = rec('bbbb2222-0000-0000-0000-000000000000', 'mongo indexes', { lastUpdatedAt: new Date('2026-10-28T09:00:00') }); +const T = rec('cccc3333-0000-0000-0000-000000000000', 'recap run', { kind: 'tool' }); + +const ok = (so: unknown) => JSON.stringify({ type: 'result', is_error: false, result: '', structured_output: so }); +const noRecap = { readRecap: () => null }; + +describe('buildCatalog', () => { + it('lists newest first with the row fields, omitting empty ones', () => { + const cat = buildCatalog([A, B, T], { readRecap: id => (id === A.id ? '\n- Terraform WAF on CloudFront\n- more' : null) }); + const lines = cat.split('\n'); + expect(lines[0]).toBe('=== CATALOG: 3 sessions, newest first ==='); + expect(lines[1]).toMatch(/^bbbb2222 \| used 2026-10-28 09:00 \| started 2026-10-20 \| name: mongo indexes \| cwd: \/tmp\/proj$/); + const a = lines.find(l => l.startsWith('aaaa1111'))!; + expect(a).toContain('work: 2026-10-25-reeco-waf'); + expect(a).toContain('labels: RD-14001'); + expect(a).toContain('note: waiting on Dor'); + expect(a).toContain('first: deploy the WAF with terraform'); + expect(a).toContain('recap: - Terraform WAF on CloudFront'); + expect(lines.find(l => l.startsWith('cccc3333'))).toContain('| [tool] |'); + }); + + it('shows the original title only when a rename overrides it', () => { + const renamed = rec('dddd4444-0000-0000-0000-000000000000', 'my name', { transcriptName: 'ai title here' }); + expect(buildCatalog([renamed], noRecap)).toContain('title: ai title here'); + expect(buildCatalog([B], noRecap)).not.toContain('title:'); + }); + + it('caps interactive and tool rows separately', () => { + const many = Array.from({ length: 450 }, (_, i) => rec(`i${String(i).padStart(7, '0')}-x`, `s${i}`)); + const tools = Array.from({ length: 150 }, (_, i) => rec(`t${String(i).padStart(7, '0')}-x`, `t${i}`, { kind: 'tool' })); + expect(buildCatalog([...many, ...tools], noRecap).split('\n')[0]).toContain('500 sessions'); + }); +}); + +describe('parseFindOutput', () => { + it('maps ids (full or prefix) to sessions in model order and drops unknown/ambiguous/dupes', () => { + const recs = [A, B, T, rec('bbbb2299-0000-0000-0000-000000000000', 'other')]; + const r = parseFindOutput(ok({ + reply: 'Found it.', + matches: [ + { id: 'aaaa1111', why: 'name says WAF' }, + { id: 'zzzz9999', why: 'invented' }, + { id: 'bbbb', why: 'ambiguous prefix' }, + { id: A.id, why: 'dupe' }, + { id: 'cccc3333-0000-0000-0000-000000000000', why: 'tool' }, + ], + }), recs); + expect(r.reply).toBe('Found it.'); + expect(r.matches.map(m => m.session.id.slice(0, 8))).toEqual(['aaaa1111', 'cccc3333']); + expect(r.matches[0].why).toBe('name says WAF'); + }); + + it('accepts an empty match list', () => { + expect(parseFindOutput(ok({ reply: 'nothing', matches: [] }), [A]).matches).toEqual([]); + }); + + it('throws FindError on non-JSON, is_error and missing structured_output', () => { + expect(() => parseFindOutput('boom', [A])).toThrow(/no JSON/); + expect(() => parseFindOutput(JSON.stringify({ is_error: true, result: 'Not logged in' }), [A])).toThrow('Not logged in'); + expect(() => parseFindOutput(JSON.stringify({ is_error: false, result: 'hi' }), [A])).toThrow(/structured answer/); + }); + + it('strips terminal control sequences from model text', () => { + const r = parseFindOutput(ok({ reply: 'hi\x1b]52;c;ZXZpbA==\x07 there', matches: [{ id: 'aaaa1111', why: '\x1b[2Jboom' }] }), [A]); + expect(r.reply).toBe('hi ]52;c;ZXZpbA== there'); + expect(r.matches[0].why).toBe('[2Jboom'); + }); + + it('reads the last line when something precedes the JSON', () => { + expect(parseFindOutput('warning\n' + ok({ reply: 'r', matches: [] }), [A]).reply).toBe('r'); + }); +}); + +describe('findSessions', () => { + afterEach(() => { delete process.env.AGENTCTL_FIND_MODEL; }); + + it('runs an isolated, tool-less claude -p and sends catalog + conversation on stdin', async () => { + let args: string[] = []; + let input = ''; + const history: FindTurn[] = [ + { role: 'user', text: 'the waf one' }, + { role: 'finder', text: 'Two candidates.', ids: ['aaaa1111', 'bbbb2222'] }, + ]; + const r = await findSessions([A, B], history, 'the terraform one', { + ...noRecap, + now: () => new Date('2026-10-29T12:00:00'), + run: (a, i) => { args = a; input = i; return { stdout: ok({ reply: 'This one.', matches: [{ id: 'aaaa1111', why: 'terraform' }] }), status: 0 }; }, + }); + expect(r.matches[0].session).toBe(A); + expect(args.slice(0, 3)).toEqual(['-p', '--model', 'haiku']); + expect(args[args.indexOf('--json-schema') + 1]).toBe(FIND_SCHEMA); + expect(args[args.indexOf('--output-format') + 1]).toBe('json'); + expect(args[args.indexOf('--tools') + 1]).toBe(''); + expect(args[args.indexOf('--setting-sources') + 1]).toBe('project'); + expect(args).toEqual(expect.arrayContaining(['--no-session-persistence', '--strict-mcp-config', '--disable-slash-commands'])); + expect(args).not.toContain('--bare'); + expect(args[args.indexOf('--system-prompt') + 1]).toContain('Now: 2026-10-29 12:00'); + expect(input).toMatch(/^=== CATALOG: 2 sessions/); + expect(input).toContain('=== CONVERSATION ===\nuser: the waf one\nfinder: Two candidates. [matched: aaaa1111, bbbb2222]\nuser: the terraform one\n'); + }); + + it('honours AGENTCTL_FIND_MODEL and caps history', async () => { + process.env.AGENTCTL_FIND_MODEL = 'sonnet'; + let args: string[] = []; + let input = ''; + const history: FindTurn[] = Array.from({ length: 12 }, (_, i) => ({ role: 'user' as const, text: `q${i}` })); + await findSessions([A], history, 'now', { + ...noRecap, run: (a, i) => { args = a; input = i; return { stdout: ok({ reply: '', matches: [] }), status: 0 }; }, + }); + expect(args[2]).toBe('sonnet'); + expect(input).not.toContain('user: q3\n'); + expect(input).toContain(`user: q${12 - MAX_HISTORY}\n`); + }); + + it('maps runner failures to FindError codes', async () => { + const fail = (res: RecapRun) => findSessions([A], [], 'x', { ...noRecap, bin: 'nope', run: () => res }); + const enoent = Object.assign(new Error('spawn nope ENOENT'), { code: 'ENOENT' }); + await expect(fail({ stdout: '', status: null, error: enoent })).rejects.toMatchObject({ code: 127, message: expect.stringContaining('AGENTCTL_CLAUDE_BIN') }); + const abort = Object.assign(new Error('aborted'), { name: 'AbortError' }); + await expect(fail({ stdout: '', status: null, error: abort })).rejects.toMatchObject({ code: 130 }); + await expect(fail({ stdout: '', status: 2 })).rejects.toBeInstanceOf(FindError); + await expect(fail({ stdout: '', status: 2 })).rejects.toMatchObject({ code: 2 }); + await expect(fail({ stdout: '', status: 0 })).rejects.toMatchObject({ code: 1 }); // never a "success" exit + }); +}); + +describe('spawnRun abort', () => { + it('kills the child and resolves with an AbortError', async () => { + const ac = new AbortController(); + const p = spawnRun('/bin/sleep', { signal: ac.signal })(['5'], ''); + ac.abort(); + const r = await p; + expect(r.error?.name).toBe('AbortError'); + }); +}); diff --git a/tests/core/sessionRepo.test.ts b/tests/core/sessionRepo.test.ts index 63df398..825e0ca 100644 --- a/tests/core/sessionRepo.test.ts +++ b/tests/core/sessionRepo.test.ts @@ -48,6 +48,23 @@ describe('listSessions', () => { expect(none).toHaveLength(0); }); + it('never lists a session finder transcript, and exposes the first prompt', async () => { + const origXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = join(home, 'xdg.cfg'); + try { + const finder = join(home, 'projects', join(home, 'xdg.cfg', 'agentctl', 'find').replace(/[^A-Za-z0-9]/g, '-')); + mkdirSync(finder, { recursive: true }); + writeFileSync(join(finder, 'cccccccc-cccc-cccc-cccc-cccccccccccc.jsonl'), + `{"type":"user","timestamp":1700000020000,"message":{"role":"user","content":"catalog"}}\n`); + const records = await listSessions(); + expect(records.map(r => r.id[0]).sort()).toEqual(['a', 'b']); + expect(records.map(r => r.firstPrompt).sort()).toEqual(['first session', 'second session']); + } finally { + if (origXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = origXdg; + } + }); + it('separates tool runs from interactive sessions, and filters on it', async () => { // A third transcript, stamped the way `claude -p` stamps a headless run. const encoded = cwd.replaceAll('/', '-'); From 44a7c0355f312c5d121cfc3300600e93a2a721ec Mon Sep 17 00:00:00 2001 From: Roy Padina <4943097+roypadina@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:33:54 +0200 Subject: [PATCH 2/2] chore(release): 0.13.0 --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b109ebd..090095c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to Agentctl are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com); versions follow [SemVer](https://semver.org). -## [Unreleased] +## [0.13.0] — 2026-10-29 ### Added diff --git a/package-lock.json b/package-lock.json index 0b3d94f..49baa9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@roypadina/agentctl", - "version": "0.12.1", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@roypadina/agentctl", - "version": "0.12.1", + "version": "0.13.0", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index f4ef314..af49f47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@roypadina/agentctl", - "version": "0.12.1", + "version": "0.13.0", "description": "Start and resume Claude Code & Codex sessions from one terminal menu — fuzzy-resume past sessions with transcript preview.", "type": "module", "license": "MIT",