Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

## [0.13.0] — 2026-10-29

### 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 "<question>"` 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
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.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)
Expand Down Expand Up @@ -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 `<profile>/sessions/<pid>.json` file exists, `kill -0 <pid>` succeeds, and `ps -o comm= -p <pid>` 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

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, …). |
Expand Down Expand Up @@ -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 "<question>"` | Resume, already asking Claude which session you mean (piped: prints the matches) |

Plus non-interactive subcommands:

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 26 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <question>', '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 <profile>`.
.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
Expand All @@ -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');
});

Expand Down Expand Up @@ -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<void> {
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);
Expand Down
3 changes: 2 additions & 1 deletion src/cli/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
export async function runApp(initial: Screen, opts: { find?: string } = {}): Promise<void> {
pending = { kind: 'quit' } as ScreenResult;
const tab: Tab = initial === 'resume' ? 'resume' : 'new';

Expand All @@ -57,6 +57,7 @@ export async function runApp(initial: Screen): Promise<void> {
projects={projects}
configError={configError}
initialSessions={initialSessions}
initialFind={opts.find}
/>,
);
await instance.waitUntilExit();
Expand Down
11 changes: 8 additions & 3 deletions src/cli/screens/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ interface AppShellProps {
projects: ProjectDir[][];
configError?: ConfigError;
initialSessions: SessionRecord[] | null;
/** `agentctl -p "<question>"`: open Resume already asking Claude this. */
initialFind?: string;
}

function TabBar({ tab }: { tab: Tab }) {
Expand All @@ -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<Tab>(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<SessionRecord[] | null>(initialSessions);
const startedResume = useRef(false);

Expand Down Expand Up @@ -73,10 +77,11 @@ export function AppShell({ initialTab, config, warnings, projects, configError,
) : (
<ResumeApp
initial={sessions}
onSwitchTab={() => 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(); }}
/>
)}
Expand Down
Loading
Loading