An original plugin (not a fork) that lets Claude Code send slash commands to its own session by routing the request through a parent process called mirza-cc. That wrapper runs claude inside a node-pty pseudo-terminal, then writes keystrokes to CC's stdin — so commands like /clear, /compact, /resume <id> actually get executed at the PTY level, not faked by AI state.
Why is this needed? The AI inside CC can't "command itself" to clear context — that's CC's internal state, and the only legit way to trigger it is a keystroke from the TTY. With this plugin + wrapper, the AI just calls one MCP tool and the wrapper does the injection.
[ user terminal ]
│ (raw stdin)
▼
[ mirza-cc wrapper (node-pty, src/wrapper.ts) ]
│ bidirectional pipe (PTY stdin/stdout)
▼
[ claude CLI ] ←─ MCP stdio ──→ [ pty-controller MCP server (server.ts) ]
▲ │
│ inject keystrokes │ write JSON request
│ ▼
└─────────── reads pending/<uuid>.json ◄─ <project>/.claude/channels/pty-controller/pending/
▲
│ heartbeat every 5s
<state>/wrapper.heartbeat
The plugin alone is useless — all it does is write JSON files to an inbox directory. It's the wrapper that reads those files and injects keystrokes into the PTY. If the wrapper isn't running, the MCP tool pty_send_slash returns an explicit error ("wrapper not detected") and the slash command becomes a no-op.
See the marketplace README for the /plugin install steps. Pick the user scope when asked, same as the other plugins.
The plugin itself needs no configuration — the manifest in .claude-plugin/plugin.json declares the MCP server pty-controller with the command bun server.ts (via the start script in package.json).
The wrapper source lives in wrapper/ inside the same plugin folder. The wrapper isn't something you install into Claude Code — it's a separate binary you run from your terminal, and it's the one that spawns claude for you.
cd plugins/pty-controller/wrapper
npm installnpm install pulls down the prebuilt native node-pty binary for your platform. For uncommon platforms (Alpine, ARM Windows) it may compile from source — install the build tools first.
Prereqs:
- Node.js 20+ (required by the
node-ptyprebuild). claudeonPATH. Verify with:claude --version.- Windows: ConPTY is already bundled in Windows 10 1809+, nothing to install.
There's no "install into PATH" step. You run it directly from the wrapper folder via npm run wrapper.
cd plugins/pty-controller/wrapper
npm run wrapperWhat's different vs. plain claude:
-
The wrapper decides how it runs
claude, not you. The default args it passes:--dangerously-skip-permissions --dangerously-load-development-channels plugin:telegram@mirza-marketplaceOverride with the
CLAUDE_ARGSenv (empty it for vanilla, or pass a custom string). The CC binary can be overridden withCLAUDE_BIN. -
Auto-resume. At startup the wrapper checks
~/.claude/projects/<encoded-cwd>/, and if there's a session.jsonlin there, it spawnsclaude --resume <latest-session-id>(newest mtime wins). If the folder is empty → spawn fresh. -
Per-project state. The wrapper creates
<CLAUDE_PROJECT_DIR>/.claude/channels/pty-controller/(orcwdif the env isn't set) containing:pending/— the inbox the plugin writes to.wrapper.heartbeat— touched every 5 seconds, used by the plugin to check "alive".wrapper.log— best-effort log file.wrapper.current_session_id— the id of the session currently live in the PTY (used by the telegram plugin to exclude the active session from the/deletepicker).wrapper.current_session_name— the display name of that session, as far as the wrapper knows it (written on the/clear+sessionNamechain, on/renameinjections, on/switch, and at startup resume; empty file = unnamed). Read by the agent-bus plugin'sagent_statusas the authoritative name when the telegramlast-status.jsonis stale.
-
Raw mode stdin. The wrapper puts the host terminal into raw mode so keypresses get forwarded straight through. If the wrapper crashes mid-run, your terminal can get stuck in raw mode — recover with
reset(Unix) or close-and-reopen the window. -
Signal handling.
SIGINT(Ctrl+C) in the wrapper terminal is forwarded to the PTY so it cancels the running AI operation, rather than killing the wrapper.SIGTERMkills the PTY. -
Side effects for the
telegramplugin. The wrapper writes asession-changeevent to<project>/.claude/channels/telegram/system-outbox/every time the session changes (initial spawn, post-/clear, post-/resume). It also writes an "idle" label to<telegram-state>/session-names.jsonon first run, if that label isn't already taken — "idle" so a freshly booted bot is born READY per the handoff-v2 session-name convention. This coupling is intentional — the wrapper and the telegram plugin are maintained by the same maintainer. -
Registration into the global agent registry. At boot, the wrapper registers itself (name = basename of the project dir) into
~/.claude/agent-registry.json— heartbeat every 5 seconds, unregister on shutdown. This registry is whatpty_list_agentsand theagent-busplugin read for bot-to-bot communication. Writes are serialized with a file-lock + atomic rename (with retries for the EPERM/EBUSY races typical of Windows antivirus).
Quit with /exit inside Claude or Ctrl+C in the wrapper terminal.
wrapper.state.json / wrapper.current_session_name self-heals from name
divergence: on every 500ms poll tick (outside a /clear transition), the
wrapper compares CC's statusline snapshot (last-status.json) against its
own state — if the snapshot describes the same session and is STRICTLY
NEWER (captured_at_ms > state's updated_at_ms) with a different name,
the snapshot's name is adopted (state + registry), logged as
session name revalidated…. On boot-resume, the name is seeded via
freshness arbitration between last-status and the registry (tie → registry),
logged as resume name resolution…. Because a snapshot's captured_at_ms
is CAPTURE time, not CONTENT time, a snapshot fired in the same turn as a
wrapper-initiated rename (the /rename sniffer, the post-/clear chain, or
/switch) can still carry the OLD name while looking "fresher" — so while
such a rename is pending, revalidation refuses to adopt a snapshot whose
name doesn't match what the wrapper itself just wrote, until the statusline
confirms it or a 10-minute timeout expires (so genuine divergence still
heals). Known limitation: a mid-turn rename immediately followed by a
wrapper restart can make boot arbitration seed the stale statusline name
(expectations don't survive restart); continuous revalidation heals it at
the next statusline fire.
Design: docs/superpowers/specs/2026-07-20-wrapper-session-name-self-healing-design.md.
The server in server.ts exposes three tools. Called over the stdio MCP transport.
Queue a slash command — or an atomic batch of them — for the wrapper to inject into the current (self) CC session's PTY. Self-only by design (neighbor-autonomy decision 2026-06-07): there is no target parameter. Bots never inject keystrokes into a peer; to have another bot do something, send it an agent-bus kind:"prompt" and let its own AI act (and refuse, if it wants). Passing target now returns a teaching error.
- Input (exactly one of):
command: string— a single slash command, starts with/. Must match the regex/^\/[a-z][a-z0-9_:-]{0,63}(\s[\s\S]{0,256})?$/. Supports bare commands (/clear) and namespaced plugin commands (/telegram:notify-user brief). The tool rejects raw text injection by design — if it's not a structurally valid slash command, it errors. It also rejects telegram-layer commands (/new,/switch,/delete,/effort) — those only exist in the telegram plugin / wrapper layer, so injecting them wedges CC's TUI on an unknown command; the error message names the correct alternative (e.g./new→commands:["/clear", "/rename <name>"],/switch→ inject/resume <sessionId>). Seeslash-guards.ts.commands: string[]— an ordered batch (max 8), each validated likecommand. Written as ONE pending file (a JSON array root); the wrapper enqueues all items contiguously, so no foreign payload can interleave between them. Use it for sequences that must stay together — the canonical case is a handoff self-reset["/rename done-<slug>-<ts>", "/clear", "/rename idle"]. When the batch contains/clear, the wrapper defers thesession-changenotification to the end of the batch so it carries the final session name instead of "(unnamed)". Batch needs a running wrapper ≥ 0.0.7 — the tool readswrapper.versionand errors on older wrappers, telling the caller to fall back to sequential single-command calls and restartmirza-cc.
- Behavior: check the local wrapper heartbeat → write to
<state>/pending/<uuid>.jsonatomically (tmp + rename). A single command writes the original object shape; a batch writes an array root. - Return: text with
idandpath(batch: also the item count and the ordered command list). - Use cases:
/clear,/compact,/telegram:notify-userin your own session; an atomic multi-step reset viacommands.
Probe whether the wrapper is running.
- Input: none.
- Behavior: check
<state>/wrapper.heartbeat. If it exists AND its timestamp is < 30 seconds from now →wrapper_alive: true. Otherwise → false. - Return: JSON
{ wrapper_alive: boolean, state_dir: string }.
List all peer agents (other Claude Code sessions) registered in the shared agent registry ~/.claude/agent-registry.json. That registry is written by every mirza-cc wrapper at startup + on each heartbeat tick. Read-only discovery — since pty_send_slash is self-only, this tool no longer feeds a target; to actually interact with a peer, use the agent-bus tools (kind:"prompt").
- Input: optional
only_alive: boolean— iftrue, filters out entries whose heartbeat is stale (>30 seconds). - Behavior: read the registry file (or use the
AGENT_REGISTRY_PATHenv var), project each entry into{name, project_dir, state_dir, last_heartbeat, last_heartbeat_age_s, alive, wrapper_pid}. - Return: JSON
{ registry_path, agents: [...] }.
Just one, in commands/:
Clear the current CC session and start fresh. The flow the AI executes:
- Call
pty_status. Ifwrapper_alive: false→ abort and tell the user to launch viamirza-cc. - Call
pty_send_slashwithcommand: "/clear". - If the request originated from Telegram (visible from the
<channel source="telegram">block in the input), send a Telegram acknowledgement first so the user knows the clear is being processed. - Stop the response. Don't go on to do any work — the next thing CC processes is the
/clearthat was just queued.
The "fresh session ready" notification is not the AI's responsibility — the wrapper triggers it after the new session materializes (see the post-/clear chain below).
server.ts writes either { type: "slash", command } (single) or a JSON array root (batch), but the wrapper (src/wrapper.ts) accepts these shapes (tagged union; the type and kind fields are synonyms, default slash):
type |
Extra fields | Wrapper action |
|---|---|---|
slash (default if type/kind is absent) |
command: string, optional sessionName (for /clear), optional confirmAfterMs |
Write command then \r (separated by 250ms) to PTY stdin. confirmAfterMs (clamped 50–5000ms) sends one extra \r after the delay — to commit the confirmation picker of commands like /effort. |
prompt |
text: string (already composed by the sender, including the anti-bounce marker) |
Type text into the PTY as a normal user turn, then submit. Written in chunks of 100 code points with a 30ms gap — a single big write on Windows ConPTY overflows the input buffer and the head of the message silently goes missing (only the tail survives). Chunking on code points (not UTF-16 units) so emoji surrogate pairs don't get split. |
switch |
sessionId: string, optional sessionName |
Inject /resume <sessionId> into the PTY, write current_session_id + current_session_name, and emit a session-change event to the telegram system-outbox after 1 second. |
| array (batch) | [{command, sessionName?, confirmAfterMs?}, …] (max 8) |
Validated by src/batch.ts, then all items pushed onto the injection queue in one synchronous block — Node's single thread guarantees no other consumed payload can splice between them (the atomicity three separate pending files could never provide). Each item then flows through the gate/barrier exactly like a standalone slash. A /clear mid-batch defers its session-change notification to the final item so the event carries the final session name. |
Payloads carrying a from field (inter-agent messages sent via the agent-bus plugin) are subject to a hop limit: hop_count > 5 is dropped — a loop guard between bots. Local messages (no from) pass without this check.
The compound {command:"/clear", sessionName} form (a /clear that chains a /rename) is still accepted for the transition — the telegram /new handler emits it. New self-reset sequences should prefer the batch form (commands:["/clear", "/rename <name>"]).
Consumed payloads are not dispatched the moment their file is read — they enter a FIFO queue drained by a single processor behind an InjectionGate (src/injection-gate.ts):
- Min-gap: every injection holds the gate for its own duration (the chunked typing window for prompts) plus
MIN_INJECTION_GAP_MS = 1500, so two payloads can never interleave their keystrokes (each injection writes its text and the submitting\r250ms apart — back-to-back dispatches used to splice into each other). - Post-/clear barrier: injecting
/cleararms a hard barrier; nothing else is injected until the fresh session jsonl is detected (CC may not process the keystroke until the current AI turn ends, so this can take minutes — that's correct, anything injected earlier would be silently dropped by the rebuilding TUI), plus a settle window covering the/renamechain (CLEAR_SETTLE_MS = 1500). A 10-minute safety timeout force-releases the barrier (with a WARNING log) if the fresh session never materialises, so a lost/clearcan't deadlock the queue. - Because
/renamenow only dispatches when the gate is open, thewrapper.current_session_namerecord written at dispatch time reflects an injection that actually landed. - Batch atomicity (wrapper ≥ 0.0.7): the items of a batch payload are enqueued as one contiguous block, so the barrier serializes the whole sequence. This is what makes a handoff self-reset (
/rename done-…→/clear→/rename idle) immune to a foreign payload splicing in mid-reset — the failure mode that previously corrupted a bot's session state when a handoff arrived between the/clearand the trailing/rename idle.
Live symptoms this fixes (2026-06-07): an agent-bus handoff prompt swallowed while the target was mid-/clear; a /rename idle following a /clear eaten → CC session left unnamed while the fleet state said idle (diverging sources of truth); a /clear itself eaten → session id never changed across tasks (idle-creep); and (post-batch) a foreign payload interleaving between the steps of a multi-command self-reset.
The pty-controller plugin emits slash payloads (single or batch) via pty_send_slash; it has no path to emit a switch or prompt payload — switch is emitted by the telegram plugin (when the user picks a session in the picker), prompt is emitted by the agent-bus plugin (a natural-language instruction from a peer bot). Any slash command valid per the regex can be injected. Examples verified to work: /clear, /compact, /resume <id>, /rename <name>, /notify-user <msg> (namespaced: /telegram:notify-user), /exit. The rest depend on whether CC recognizes that slash command in the session currently running.
If the command just injected is exactly /clear, the wrapper enters a special state machine:
- Snapshot the current list of
.jsonlsessions in~/.claude/projects/<encoded-cwd>/. - Poll every 500ms until a new file appears (= the fresh session is live).
- As soon as it's found: write
wrapper.current_session_id+wrapper.current_session_name(the payload'ssessionName, or empty when the/clearcame without a name — never the previous session's name), optionally inject/rename <sessionName>if the payload carries a name, then emit asession-changeevent to the telegram system-outbox (the Telegram message is sent by the telegram plugin, with no AI roundtrip). Exception — batch: when this/clearis part of a batch with later items (e.g. a trailing/rename idle), the notification here is suppressed; the batch's final item emits it instead, so the event reports the real final name rather than "(unnamed)". The user is always notified — only the timing shifts.
Pacing between injections: the constants POST_INJECTION_DELAY_MS = 1000 and SUBMIT_DELAY_MS = 250. The second one separates the text write from the trailing \r — needed because for namespaced commands (/telegram:foo), if the \r lands in the same chunk, CC's autocomplete picker swallows it instead of submitting. While this state machine is waiting for the fresh session, the pending queue is held by the injection barrier (see above) — queued payloads resume only after step 3 completes plus the settle window.
The state directory is resolved by ipc.ts in this order:
- The
PTY_CONTROLLER_STATE_DIRenv (escape hatch, set by the wrapper when it spawns CC). <CLAUDE_PROJECT_DIR>/.claude/channels/pty-controller/.
If neither exists, the MCP server exits with an explicit error on stderr.
Per-project state layout:
<project>/.claude/channels/pty-controller/
├── pending/ # plugin writes here
│ └── <uuid>.json
├── wrapper.heartbeat # wrapper updates every 5 seconds
├── wrapper.pid # wrapper PID, second liveness signal (unlinked on clean shutdown)
├── wrapper.current_session_id # the live CC session id
├── wrapper.current_session_name # the live CC session's display name ('' = unnamed; read by agent-bus agent_status)
├── wrapper.version # {plugin_version, wrapper_version} written at boot (read by telegram /status)
└── wrapper.log # wrapper log (best-effort)
Format of the pending/<uuid>.json file — a single command:
{
"id": "<uuid>",
"ts": "2026-05-19T00:00:00.000Z",
"command": "/clear"
}…or a batch (JSON array root), injected as one contiguous block:
[
{ "command": "/rename done-task-202606071500" },
{ "command": "/clear" },
{ "command": "/rename idle" }
]Atomic writes are used on all sides: write to <final>.tmp.<pid>, then rename to the final name. The wrapper skips files still named .tmp.* in its fallback sweep. The wrapper reads the file (via fs.watch + a 2-second interval sweep as belt-and-suspenders), deletes the file immediately (before dispatch) so it won't double-process if it crashes mid-handle.
wrapperLikelyRunning() uses a two-signal check: (1) the heartbeat file — its timestamp must be less than 30 seconds old; (2) PID liveness — if wrapper.pid exists, probe with process.kill(pid, 0); ESRCH (process gone) → false, catching the "wrapper just crashed but the heartbeat still looks fresh" case. The PID check is best-effort: if the PID file is absent (older wrapper build) or can't be probed → trust the heartbeat alone. The plugin uses this metric to gate pty_send_slash and for the pty_status answer.
- Without the wrapper, the plugin is a no-op. The plugin loads fine into CC but
pty_send_slashwill always error until the wrapper is running.pty_statusis the safest way to check. pty_send_slashis self-only. Cross-agent control was removed (neighbor autonomy, 2026-06-07); a peer is reached only via anagent-buskind:"prompt", which its own AI chooses to act on. Rescuing a wedged bot is the user's job, via that bot's own Telegram chat.- Batch needs a fresh wrapper.
commands:[…]requires the running wrapper ≥ 0.0.7. The wrapper keeps executing old code untilmirza-ccis restarted, so right after an upgrade the tool errors on batch sends until the restart — callers fall back to sequential single-command calls. - Single CC per project. The wrapper assumes one Claude session per project at a time. Run two wrappers against the same project → the inbox files can get double-processed and downstream channels (the Telegram bot) conflict.
- Windows quirks.
fs.watchon Windows has historically been flaky for fast create+delete, which is why the wrapper has a 2-second interval sweep as a backup.node-ptyis spawned viacmd.exe /con Windows vs.$SHELL -l -i -con Unix. - First-run timing isn't relevant for the production wrapper. The
auto-cleardiagnostic has aREADY_DELAY_MSenv; the production wrapper doesn't need it because requests only arrive once CC is idle. - The liveness check is still a heuristic. The two signals (heartbeat + PID probe) cover the crash case, but older wrapper builds without
wrapper.pidstill rely on the 30-second heartbeat alone. - Coupling to the telegram plugin. The wrapper writes to
<project>/.claude/channels/telegram/system-outbox/and<telegram-state>/session-names.json. If the telegram plugin isn't installed, those files land in a directory that may or may not get created — the wrapper still runs, but those events become orphans. - Agent names can collide. Name = basename of the project dir. Two different projects with the same basename will fight over the registry slot (logged as a WARNING, not blocked in v1).
In the wrapper/ folder:
npm run interactive # spawn claude in a PTY, bidirectional pipe, no plugin loop
npm run auto-clear # spawn claude, programmatically inject /clear, capture, exitauto-clear dumps the PTY capture to last-capture.ansi (raw) and last-capture.txt (ANSI-stripped) on exit. Don't run this diagnostic alongside the production wrapper — both will fight over CC's PTY.
- Author: Mirza (@mirzaakhena)
- License: MIT (see
LICENSE).