# ctop as an Agent Surface

Exposing session data via CLI + MCP so AI agents can introspect themselves and coordinate across terminals.

Design Plan 2026-05-18 Status: Draft Issue: @imjaredz idea
TL;DR

Ship two surfaces on top of ctop's existing session data — a thin `ctop` CLI in JSON-output mode (lives in core, zero-dep) and a separate `ctop-mcp` package (depends on the MCP SDK).

Expose ~10 tools split across READ SELF ALERT WRITE. The `whoami` and `get_alerts` tools are the killer features — they let a session ask "should I compact?" or "which of my sister sessions is in trouble?"

Phase 1 (CLI) is ~1 day. Phase 2 (MCP wrapper) is ~1 day. Phase 3 (streaming resources) is a follow-up.

## 01Why

From the tweet thread: "this is sick! can I have my master agent control my ctop??" — Jared Zoneraich

Today, every Claude/Codex/OpenCode session runs in its own terminal, blind to its siblings. The user is the only entity holding the cross-session picture, and they're holding it via eyeballs on the ctop TUI. That doesn't compose with agents:

- An agent burning context can't see it's about to compact until the editor warns it — by then the next message will trigger a costly compaction.
- A "master" agent dispatching to sub-agents has no way to read what they're working on, kill ghost sessions, or notice one is stalled.
- Cross-cutting checks (am I duplicating work happening in branch `feat/x` over in another window?) require human cross-referencing.

ctop already aggregates this data — it's just trapped inside a TUI render loop. Exposing it programmatically costs us ~300 lines of glue.

### The mental model

terminal 1 terminal 2 terminal 3 ┌──────────┐ ┌──────────┐ ┌──────────┐ │ claude │ │ codex │ │ opencode │ ← agent sessions │ (master) │ │ (sub) │ │ (sub) │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ session JSONL │ session JSONL │ session sqlite ▼ ▼ ▼ ┌────────────────────────────────────────────────────────┐ │ ctop core (getAllAgentProcesses, readSessionLog…) │ └──────────────────────┬─────────────────────────────────┘ │ ┌───────────────┴───────────────┐ ▼ ▼ ┌──────────────┐ ┌──────────────┐ │ ctop CLI │ │ ctop-mcp │ ← new surfaces │ --json mode │ │ stdio JSONRPC│ └──────┬───────┘ └──────┬───────┘ │ │ ▼ ▼ any tool: shell, Claude / Codex / cron, agent's Bash OpenCode tool registry

## 02What ctop already knows

For each session, ctop has these fields populated by `getAllAgentProcesses()` and friends. Everything below is free — no new collection needed.

36
Fields / session
pid, model, tokens, cost, branch, cwd, status, contextPct, …
3
Agent backends
Claude · Codex · OpenCode
~5s
Refresh cadence
Already polling on TUI tick
0
Network calls
All reads are local fs / ps
Full per-session field list →
pid
agentType (claude · codex · opencode)
title (Claude Code · Codex CLI · …)
sessionId
sessionTitle
slug
cwd
command
startDate / startTime
status (ACTIVE · SLEEPING · STOPPED · ZOMBIE)
cpu (%)
mem (%)
model
contextPct (% free)
inputTokens
outputTokens
cacheCreateTokens
cacheReadTokens
serviceTier
stopReason
cost (USD)
gitBranch
tokenRate (tokens/sec)
lastTurnMs
compacted (bool)
compactionCount
rateLimits
timestamp
requestId
userType
version
+ derived: conversation log, git diff, search

## 03Two surfaces, one core

#### CLI PHASE 1

Subcommands of the existing `ctop` binary. JSON output, pipeable, zero-dep. Works for any agent that can shell out, plus humans + cron.

$ ctop ls --json $ ctop get 18472 --json $ ctop log 18472 --tail 50 $ ctop whoami --json $ ctop alerts $ ctop diff 18472 $ ctop kill 18472 --force

#### MCP server PHASE 2

Separate `ctop-mcp` npm package speaking stdio JSON-RPC. Schema-discoverable by Claude Code, Codex, Cursor, etc. Same tool surface, agent-native.

# ~/.claude.json { "mcpServers": { "ctop": { "command": "npx", "args": ["-y", "ctop-mcp"] } } }
DESIGN CALL Both surfaces share one implementation. `ctop-mcp` imports core functions from `ctop-claude` as a peer dep and wraps each in an MCP tool definition. No duplicated logic, no extra polling loop.

## 04Tool catalog

Ten tools, four categories. Every tool is also a CLI subcommand of the same name.

Tool | Category | Purpose | Args
--- | --- | --- | ---
list_sessions | READ | Summary array of every running agent session | agent? · cwd? · status?
get_session | READ | Full detail object for one PID | pid
read_session_log | READ | Conversation transcript (user + assistant messages) | pid · tail? · since?
search_sessions | READ | Full-text search across all session JSONL content | query · agent? · cwd?
get_git_diff | READ | Uncommitted changes in a session's working dir | pid or cwd
get_aggregate_stats | READ | Totals across all sessions (cost, tokens, active count, …) | —
whoami | SELF | Session object for the calling agent (auto-detected) | —
get_alerts | ALERT | Computed warnings: low-context, idle, compacting, ghost, rate-limited | severity?
kill_session | WRITE | SIGTERM or SIGKILL a PID. Self-only by default; opt-in to kill others | pid · force?
send_notification | WRITE | Desktop notification (macOS notification center / Linux libnotify) | title · message

## 05The killer tool: `whoami`

Every other read tool requires the agent to know which session it's looking at. `whoami` closes that loop — it returns "you are this session." That changes what the agent can do:

- "Am I running out of context?" → `whoami().contextPct < 15` → suggest `/compact` before next turn
- "What have I cost so far?" → `whoami().cost`
- "Am I duplicating work in another session on this same branch?" → `list_sessions({cwd: whoami().cwd})`

#### Detection strategy (in priority order)

1. `CTOP_PID` env var (most explicit; CLI/MCP server may set it for child agents)
2. Walk `process.ppid` chain looking for a known agent binary in `getAllAgentProcesses()`
3. Fall back to matching `$PWD` against session `cwd` — pick the most-recent ACTIVE one (single-match heuristic)
4. Return `null` if none of the above resolve — never guess across users

FALLBACK RISK The cwd-match heuristic can return the wrong session if the user runs two Claude sessions in the same directory. Always include `matchConfidence: "exact" | "ppid" | "cwd-guess"` in the response so agents know whether to trust it.

## 06Per-tool detail

list_sessions READ
Returns a compact summary of every running agent session. Optimized for cheap calls — strips the verbose fields. Use `get_session` for full detail.
agent?: "claude" | "codex" | "opencode" // filter by backend cwd?: string // filter by directory (prefix match) status?: "active" | "sleeping" | "all" // default: "active"
→ Array<{pid, agent, model, cwd, branch, contextPct, cost, status, startedAgo}>
// Example return [ { "pid": 18472, "agent": "claude", "model": "claude-opus-4-7", "cwd": "~/code/ctop", "branch": "mcp-cli", "contextPct": 72, "cost": 1.84, "status": "ACTIVE", "startedAgo": "22m" }, { "pid": 19103, "agent": "codex", ... } ]
get_alerts ALERT
Returns computed warnings across all sessions. Lets a master agent ask "what needs attention right now?" without re-deriving thresholds. Levels are `info | warn | critical`.
severity?: "warn" | "critical" // minimum level to return; default: "warn"
→ Array<{pid, kind, severity, message, suggested}>
// Example return [ { "pid": 19103, "kind": "low_context", "severity": "critical", "message": "codex session at 8% context, will compact next turn", "suggested": "run /compact or hand off to fresh session" }, { "pid": 15001, "kind": "ghost", "severity": "warn", "message": "claude session STOPPED 47m ago, holding 412MB RAM", "suggested": "kill_session(15001)" } ]

#### Alert kinds

- `low_context` — contextPct < 15 (warn) or < 8 (critical)
- `compacting` — compaction event detected this tick
- `idle` — ACTIVE with tokenRate=0 for > 10 minutes
- `ghost` — STOPPED/ZOMBIE consuming > 100MB
- `rate_limited` — rateLimits non-null and near quota
- `cost_spike` — > $5 cost on a single session

kill_session WRITE
Send SIGTERM (default) or SIGKILL (`force: true`) to a session. The only destructive tool. Subject to auth model in §07.
pid: number // required, no kill-all shortcut force?: boolean = false // SIGKILL instead of SIGTERM
→ {killed: bool, signal: "SIGTERM" | "SIGKILL", message: string}
Remaining 7 tool specs →
get_session READ
Full detail for one session — every field from the per-session list above.
pid: number
→ Session (all 36 fields)
read_session_log READ
Conversation transcript for one session (user + assistant text only — tool_use blocks stripped). Already implemented as `readSessionLog`.
pid: number tail?: number = 50 // last N messages, 0 = all since?: ISO timestamp // only messages after this
→ Array<{role: "user"|"assistant", text, timestamp}>
search_sessions READ
Substring search across all JSONL session content. Returns matching sessions with snippet context.
query: string agent?: "claude" | "codex" | "opencode" cwd?: string
→ Array<{pid, sessionFile, snippets: string[]}>
get_git_diff READ
Uncommitted changes in a session's cwd. Wraps existing `getGitDiffSummary`.
pid?: number // either pid or cwd required cwd?: string
→ {insertions, deletions, untracked, files: [{file, insertions, deletions}]}
get_aggregate_stats READ
Totals across all sessions. Already implemented as `calculateAggregateStats`.
// no args
→ {totalCost, totalInput, totalOutput, totalCache, avgContextUtil, active, dead, total}
whoami SELF
Returns the session object for the calling agent (or null if unresolvable). Detection: CTOP_PID → ppid walk → cwd match.
// no args
→ Session | null, plus matchConfidence: "exact" | "ppid" | "cwd-guess" | "none"
send_notification WRITE
Desktop notification via macOS osascript / libnotify / win10toast. Already wired in `sendNotification`.
title: string message: string
→ {sent: boolean}

## 07Authorization model

Read tools surface data the agent could read off disk anyway — no new privilege. Write tools are different: a "master agent" killing a sister session is the kind of footgun where we want defense-in-depth.

Capability | Default | Opt-in via
--- | --- | ---
All read tools | allowed | —
`kill_session` on own PID | allowed | —
`kill_session` on other PIDs | denied | `CTOP_MCP_ALLOW_KILL=1`
`send_notification` | allowed | —
Cross-user access | denied | not supported — process owner check

SECURITY The MCP server only reads what the user running it already has filesystem access to. No new privilege escalation. Cross-user kill is explicitly blocked by checking `process.geteuid()` against the target PID's owner.

## 08Zero-dep constraint

The package README badge says "Zero Dependencies." This is a real promise worth preserving — it's part of why ctop installs cleanly under `npx`. Two options for the MCP server:

#### Option A · Separate package RECOMMENDED

Ship `ctop-mcp` as its own npm package. Peer-depends on `ctop-claude`. Users who want MCP install both; users who want just the TUI keep zero deps.

- Clean separation — TUI stays pristine
- Familiar pattern (eslint vs eslint-config-x)
- MCP SDK version can move independently

#### Option B · Hand-roll JSON-RPC

MCP is stdio JSON-RPC 2.0 with a known message schema. ~200 lines of Node, no SDK. Could live in core.

- Preserves zero-dep across the board
- More code to maintain as MCP spec evolves
- Loses SDK conveniences (validation, logging helpers)

Recommendation: Option A. The trade — adding one optional package — is worth the SDK's protocol coverage and version-tracking. If the SDK proves heavy, fall back to B as a v2 refactor.

## 09Rollout phases

Phase 1
CLI subcommands
~1 day · ships in next minor

- Add subcommand router to `claude-manager` entrypoint
- Implement `ls · get · log · search · diff · stats · whoami · alerts · kill` as one-shot commands
- All output JSON with `--json`, human-readable by default
- Add tests under `test/cli/`
- Update README with the new commands

Phase 2
ctop-mcp package
~1 day · separate npm release

- New `packages/ctop-mcp/` directory (or sibling repo)
- Depends on `@modelcontextprotocol/sdk` + `ctop-claude`
- Wraps each CLI subcommand as an MCP tool with JSON schema
- Stdio transport; auto-detected `CTOP_PID` for whoami
- Example claude.json / codex.toml configs in README

Phase 3
Streaming resources
follow-up · ~3 days

- Expose `ctop://sessions` as an MCP resource list
- Expose `ctop://sessions/{pid}/log` for live tailing
- fs.watch the JSONL files; emit MCP `notifications/resources/updated`
- Lets master agent subscribe to sister sessions instead of polling

## 10Open questions

Should `list_sessions` include the current calling session, or filter it out by default?
Filtering it out simplifies "list of others" use cases (the master/sub pattern), but is a sharp edge for "give me the global picture." Lean: include it, label with `isCaller: true`, and let the agent filter.
How does `whoami` behave when called from a non-agent shell (e.g. via cron or a wrapper script)?
Return `{session: null, matchConfidence: "none"}` — never guess. The cwd-match fallback only fires if there's exactly one ACTIVE session matching `$PWD`.
Should we expose the conversation log of other sessions, or only the caller's own?
Yes to others — the data is on disk anyway, and the master-agent pattern requires it. But add a flag to `ctop-mcp` (`CTOP_MCP_READ_OWN_ONLY=1`) for users who want stricter isolation.
Token-usage and cost are observable side channels. Any privacy concern with cross-process reads?
Same trust model as the TUI today — single-user box, single user's processes. Cross-user is blocked by uid check. Anything sensitive in conversation logs is already on disk in plaintext.
Do we need rate limiting on `get_alerts` / `list_sessions` when called by an agent?
Probably not — the underlying `ps + lsof + fs.read` path is already cached at ~5s granularity. Add a simple 1s minimum interval between identical calls in `ctop-mcp` to be safe.

## 11What this unlocks

#### Pattern: self-aware compaction

// In an agent's system prompt or hook if (whoami().contextPct < 15) { user.suggest("context low — /compact recommended"); }

#### Pattern: master agent coordinating subs

const subs = list_sessions({ cwd: whoami().cwd }) .filter(s => !s.isCaller); for (const sub of subs) { const recent = read_session_log({ pid: sub.pid, tail: 10 }); // dispatch based on what subs are working on }

#### Pattern: cleanup on session end

// Stop hook const ghosts = get_alerts({ severity: "warn" }) .filter(a => a.kind === "ghost"); for (const g of ghosts) kill_session({ pid: g.pid });