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:

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:

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

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_sessionREAD
Full detail for one session — every field from the per-session list above.
pid: number
→ Session (all 36 fields)
read_session_logREAD
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_sessionsREAD
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_diffREAD
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_statsREAD
Totals across all sessions. Already implemented as calculateAggregateStats.
// no args
→ {totalCost, totalInput, totalOutput, totalCache, avgContextUtil, active, dead, total}
whoamiSELF
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_notificationWRITE
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.

CapabilityDefaultOpt-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 });