Run real Claude Code sessions side by side, each in its own git worktree, on the Claude subscription you already pay for. When one of them has a question, you answer it with a click — in the browser, or from Telegram when you are not at the desk.
npx shadok-ai
It does not stop at one. An agent can start other agents — each with its own worktree, branch and role — and it is told when one of its own finishes, blocks on a question, or dies. Nothing else wakes it.
It keeps working when you are not there. Mirror an agent to a Telegram topic and answer from your phone, one topic per agent. Put a prompt on a schedule with a guard that runs without the model: prints nothing, nothing happened, zero tokens spent.
It stays on a leash. A profile is a role plus guardrails — forbid git commit
and the agent cannot commit. Secrets live in a vault outside your repository and are injected only into the agents that need them. Quota gauges show the 5h and 7d windows, and can hold an agent back when it is burning faster than the window elapses.
It wrote itself. shadok-ai is largely built by agents running inside shadok-ai, in their own worktrees, landed as reviewed pull requests. The section below is what that produced.
npx shadok-ai
It opens your browser for you once the server is listening — at the port it actually landed on, which is not always 3789 (a busy port walks to the next free one). Only on the launch you asked for: an auto-update respawn never pops a tab. Skipped where it would make no sense — in a container, over SSH, or on a Linux box with no display — and --no-open
turns it off.
Prerequisites: Node ≥ 20. shadok-ai drives the claude CLI on
your Claude subscription — if it isn't installed, the cockpit installs it for you on the first agent launch (
npm i -g @anthropic-ai/claude-code
; if that can't write the global dir it tells you the one command to run). Either way you sign in to your Claude account once on first use — that step is yours and can't be skipped (see below).
tmux
makes agents survive the server restarting, so shadok-ai auto-installs it at boot if it's missing (
brew
on macOS, apt
/apk
/dnf
/pacman
on
Linux — with sudo
when not root). If it can't (no package manager, or a sudo
password it can't provide), it falls back to node-pty and tells you the one
command to run. SHADOK_TMUX=0
skips all of that.On the first run it asks once for an optional Telegram bot token (press Enter to skip; you can add it later from the web UI).
| Flag | Effect |
|---|---|
--port, -p <n> |
|
| HTTP/WS port (default 3789; falls back to the next free one) | |
--no-telegram |
|
| web-only; don't prompt for or use a bot token | |
--no-open |
|
| don't open the browser on launch | |
--password <p> |
|
| require this password to open the GUI (stored in config) | |
--version, -v · --help, -h |
|
| version / help |
Channels— oneclaude
process each, running in parallel. Renaming, grouping, and closing all sync live between the browser and Telegram: it's one list, server-owned, not two copies.Worktree isolation— spawned agents get their own git worktree and branch by default, so parallel agents never collide. Work is never auto-discarded: an empty worktree is reclaimed on close, anything with changes or commits stays (andRecover
reopens it).Interactive dialogs— the TUI's permission prompts and multiple-choice questions become clickable buttons in the chat, and an inline keyboard in Telegram.Agent profiles— a named bundle of role prompt, permission guardrails (e.g. forbidgit commit
), model, and which secrets to inject. Applied at spawn, remembered across resume.Secret vault— stored under~/.shadok-ai
, never in your repo, injected as env vars into the agents that need them. An agent thatobtains a credential (a CLI login, a key it just provisioned) can add it to the vault itself so the next agent doesn't start from nothing — write-only, the value piped in rather than typed as an argument, and never overwriting an existing name without you saying so.Scheduled prompts— give a channel a recurring prompt (every N minutes, or daily at HH:MM in a time zone you choose) for monitoring and reporting. Each schedule can carry adeterministic guard command that runswithout the model: prints nothing → nothing to report, the agent is never woken and the run costszero tokens; prints something → that output is prepended to the prompt and the agent runs. A watcher that is quiet most of the day costs nothing most of the day.Agents that report back— an agent that launches other agents is told when each one finishes, blocks on a question, or dies. It hears about** its ownchildren and nothing else, so a busy channel never wakes it. What arrives is the child's own summary plus pointers (branch, diff link) — never the diff itself, which on a large session costs far more than it is worth. The link is set automatically at spawn; you can also attach or detach an agent by hand from its ⋯ menu.Quota gauges + pace guard— 5h and 7d subscription usage, with an optional block when you're burning faster than the window elapses (any message can force through).Notifications— favicon, title badge and an optional sound when an agent needs you. It only blinks when the tab is hiddenandan unmuted channel is actually waiting.Engine room— the raw TUI screen, live, with clickable keys, for anything the chat can't express. With tmux there is also an experimental real terminal**(xterm.js over the pane's byte stream) when a snapshot isn't enough.** Diff panel**— what an agent actually changed, against its base.** Context sent**— a collapsed block at the head of every conversation showing what shadok added to that agent: its role, its guardrails and permission mode, thenames of the variables injected (never the values), the cockpit prompt. It also lists thecapabilities shadok installs — the skills that let an agent start other agents, schedule its own prompts or store a credential — by description, since that is what Claude Code actually holds in context. Capturedat spawn, so it is what the running process was actually given — not a recomputation that would quietly show today's profile. Also on the ⋯ menu of each channel. It says plainly that it is shadok's half: Claude Code adds its own context, which shadok never sees.Phone layout— the cockpit is a fixed chassis that never pans sideways, sized on thevisualviewport so the composer stays clear of the browser's own bar and, when the keyboard opens, sits right above it (the instrument bar folds away while you type). Fields are 16px, which is what stops Safari from zooming in on focus. The agents column is replaced by a channel selector in the channel bar.Tweak Shadok-AI— a card pinned at the bottom of the agents column. One click clones shadok-ai's own source into~/.shadok-ai/self/shadok-ai
and starts an agent on it in its own worktree; that agent delivers its change as apull request— a fork under your GitHub account, since you need no rights on the repo. Nothing to configure and no token to paste: the clone is anonymous, so you describe an idea, watch it work and read the diff first, and it only asks for GitHub (viagh auth login
, device code relayed in the chat) when there is something worth pushing. It isone channel, not a launcher: the card becomes that channel's tab, with the usual menu — mute, reload, rename, change profile, mirror to Telegram, close — and closing it brings the card back. Desktop only: the agents column is hidden on phones.Guided tour— on a browser's first visit, a welcome card explains what shadok-ai is, then a spotlight walks the real controls: the agents column, an agent's ⋯ menu, the toolbar, the quota dials. Skip and Escape end it at any point; ⋯ →Guided tourreplays it. A step whose target isn't on screen isdropped rather than faked, so the phone (no agents column) and an empty cockpit (no agent tab) get a shorter tour instead of a spotlight on nothing. On a brand-new instance it comesbefore the sign-in card — being asked to authorise an OAuth flow before you know what the thing is takes it backwards.A lead agent from the start— an instance with no channel at all gets one:general
, running theShadok-Boss profile, in the launch directory and without a worktree. It is created at boot, or right after the first sign-in on a brand-new instance (which is signed out at boot, so there is nothing to spawn yet). The condition is "no channel", which makes it idempotent: it never appears twice. Nothing is prompted — the agent waits for you. It is the environment'shome base: pinned at the top of the list, with no close button and noClose agentin its menu. You can still reload, rename, re-profile or mute it — "permanent" should not mean "stuck". A cockpit that already had ageneral
at its launch directory adopts it on the next start, and only ever when exactly one channel matches: a wrong adoption would be irreversible from the UI, since that channel becomes the one you cannot close.Self-update— polls npm and can update and reload itself in place.
Add your bot to a group with Topics enabled, make it an admin with Manage
topics (and Delete messages so /secret
can scrub values), then in the group:
| Command | |
|---|---|
/setup |
|
| bind this group as the board (one group per instance) | |
/spawn [profile] <name> |
|
| new isolated agent in its own topic (also a web tab) | |
/stop (alias /esc ) |
|
| interrupt the current turn — does not end the session | |
/new · /end |
|
| reset · kill the session | |
/restart |
|
| respawn the agent in place (e.g. to pick up new secrets) | |
/profiles · /list |
|
| list profiles · list bindings | |
/cron every 30m <prompt> · /cron daily 09:00 <prompt> |
|
| schedule a recurring prompt on this agent | |
/cron once 2026-08-25T08:42 <prompt> |
|
fire it once on that date, then the schedule is left disabled and marked (fired) |
|
/cron list · `/cron on |
off |
manage them (<id> accepts the printed 8-char prefix) |
|
/secrets · /secret KEY value · /unsecret KEY |
|
| the secret vault | |
/update |
|
fetch @latest and respawn |
Creating a topic by hand also spawns an agent. Anything that isn't a command is sent to that topic's agent as a prompt — including photos and files, which are downloaded and handed to Claude Code.
Direct messages belong to one person. The first user to DM the bot claims it; everyone else is refused. On startup the owner is adopted from an existing DM or from the board group's creator, so an instance that already has an owner never hands itself to whoever messages next — a bot username is public, and a DM is a shell.
The repo ships an official Dockerfile
:
docker build -t shadok-ai .
docker run -d --name shadok-ai --restart unless-stopped \
-p 127.0.0.1:3789:3789 \
-v shadok-data:/root/.shadok-ai \
-v shadok-claude:/root/.claude \
-v shadok-workspace:/workspace \
-e SHADOK_HOST=0.0.0.0 -e SHADOK_GUI_PASSWORD=change-me \
shadok-ai
The image bundles Claude Code, shadok-ai and a headless browser
(Playwright Chromium, at PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
) so
an agent can screenshot or drive the web with no first-run download; git
, gh
,
tmux
, jq
and a C toolchain are there for agents' own work. The three volumes
are what survive docker restart
and docker rm
+recreate: ~/.shadok-ai
(vault, profiles, channels, crons, SSH identity), ~/.claude
(credentials,
transcripts), /workspace
(agents' files). A fresh container needs a one-time
Claude login: docker exec -it shadok-ai claude
.
The cockpit runs arbitrary commands on the host by design, so it binds
127.0.0.1
— this machine only — unless you say otherwise. Two things to know before opening it up:
SHADOK_HOST=0.0.0.0
requires a password(--password
, orSHADOK_GUI_PASSWORD
); without one the server refuses to start rather than hand the network a shell.In Docker,SHADOK_HOST=0.0.0.0
is the only value that works (the container's own loopback isn't reachable from the host). Publish the port on the host's loopback:-p 127.0.0.1:3789:3789
. Plain-p 3789:3789
publishes on every host interfaceand bypasses ufw/firewalld, because Docker inserts its own iptables rules upstream of them.
Browsers may only talk to the cockpit from its own origin — a WebSocket ignores
the same-origin policy, so without that check any page you happen to visit could
drive your agents. Behind a reverse proxy that rewrites Host
, list the public
origin in SHADOK_ORIGINS
.
Serving the cockpit over HTTPS works, with one requirement: the proxy must
forward the WebSocket upgrade. Everything live — the channel list included —
travels over /ws
, and the page itself is static HTML, so a proxy that drops
Upgrade
/Connection
produces a cockpit that loads perfectly and then never connects. In nginx:
location / {
proxy_pass http://127.0.0.1:3789;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
The client picks wss://
on its own when the page is HTTPS, so there is nothing to configure on that side.
Idle timeout. A resting agent's /ws
carries no traffic, and proxies
idle-close a quiet socket (nginx proxy_read_timeout
, 60s by default;
Cloudflare ~100s) — the symptom is a cockpit that keeps flashing
"reconnecting…" even though nothing is wrong. The server sends a WebSocket ping
every 25s (tunable with SHADOK_WS_PING_MS
) to keep the connection warm, so no
proxy change is normally needed; if your proxy still cuts it, raise
proxy_read_timeout
above the ping interval.
A fresh machine — most often a fresh container — has two things in its way, and shadok handles both so you never need a shell on the host.
The first-run screens are pre-answered. A virgin claude
opens on a theme
picker, then on a per-directory trust dialog, and an agent started there never
reaches a prompt: no input box, every prompt fails, the tab reads "failed to
start". shadok seeds the answers in ~/.claude.json
— at boot for the global ones, and before every spawn for the session's directory, because a worktree is a brand-new directory and therefore a brand-new trust dialog. The seeding is purely additive: a value already there is never overwritten, so on a machine that has used Claude Code before, nothing is written at all.
The sign-in is offered in the cockpit. When the instance isn't signed in, a card takes over the page with the OAuth link and a field for the code you get back; agents refuse to start until it's done, so you can't accidentally manufacture an agent with no credentials. The card exists only while it's needed — there is no button that opens it when everything is fine.
From Telegram, which is where you usually are when a session signs out mid-life:
/login → replies with the link
/code <code> → finishes the sign-in (your message is deleted afterwards)
Both are restricted to the bound board group or the owner's DM, like /secret
— an OAuth code grants access to the account. The sign-in is instance-global, so the link is the same on both sides and a code pasted in Telegram also closes the card open in a browser. When a spawn is refused for lack of credentials, shadok posts one message to the board group; it won't repeat until the state flips back.
In Docker, this replaces the old recreate ritual. /root/.claude.json
is
not on a volume, so docker rm
- recreate used to lose the onboarding state and
the respawned agents landed on the first-run screen — which meant restoring the
file before starting the container (
docker create
→ docker cp
→
docker start
). That ordering is no longer necessary: shadok writes the file itself, before any session spawns.
When shadok-ai runs in a container it gives itself an SSH key on first boot
so agents can git clone/push
private repos and ssh
into servers. The key
lives under ~/.shadok-ai/ssh/
— i.e. on the ** shadok-data volume you already mount** — so it
survives(unlike a plain
docker restart
and docker rm
+recreate~/.ssh
, which is wiped on recreate). Each container has its own volume,
hence its own unique key; ~/.ssh
is symlinked to it, so git
/ssh
use it with
no extra config. Nothing to add to docker run
.Read the public key to register it (GitHub deploy key, or the target hosts'
authorized_keys
):
docker logs <name> | grep 'ssh identity' # printed on every boot
docker exec <name> cat /root/.shadok-ai/ssh/id_ed25519.pub
On a normal (non-Docker) host this is a no-op — shadok never touches your
~/.ssh
. Detection is /.dockerenv
; SHADOK_SSH_IDENTITY=0
disables it,
SHADOK_FORCE_SSH_IDENTITY=1
forces it on.
Config lives in ~/.shadok-ai/config.json
(mode 600) and is authoritative over the environment once set from the GUI. The Telegram token, allowed chats, and the bridge on/off switch are per launch directory — running the server from another repo gives you a different cockpit and a different bot. So are the channel list and the scheduled prompts.
timezone
(an IANA name like Europe/Paris
, settable via /timezone
) is the
default zone for reading a daily
schedule. Without it the hour follows the machine, which silently shifts every daily prompt on a server running in UTC.
| Env var | |
|---|---|
PORT |
|
| HTTP/WS port | |
SHADOK_HOST |
|
interface to bind (default 127.0.0.1 , this machine only) |
|
SHADOK_GUI_PASSWORD |
|
| require a password for the GUI | |
SHADOK_ORIGINS |
|
| extra browser origins allowed, comma-separated (reverse proxy) | |
SHADOK_TMUX=0 |
|
| force the node-pty transport instead of tmux | |
SHADOK_IDLE_MIN |
|
| minutes with no client before a session is reclaimed (60) | |
SHADOK_PERMISSION_MODE |
|
mode new agents start in (default acceptEdits ) |
|
SHADOK_AUTOUPDATE |
|
| fallback only — the GUI setting wins once used | |
SHADOK_PILOT_PROMPT=0 |
|
| don't inject the cockpit system prompt | |
SHADOK_LEDGER=1 |
|
| opt into the shared-ledger reflex (OFF by default; the GUI/config setting wins once used) | |
SHADOK_RESUME_SUMMARY=1 |
|
| don't auto-answer the resume-from-summary prompt | |
SHADOK_SSH_IDENTITY=0 · SHADOK_FORCE_SSH_IDENTITY=1 |
|
| disable / force the Docker SSH identity | |
TELEGRAM_BOT_TOKEN · TELEGRAM_ALLOWED_CHATS |
|
| override the stored config | |
CLAUDE_CODE_OAUTH_TOKEN |
|
only for the usage gauges; claude itself uses the keychain |
Hacking on shadok-ai?Read[(map, build/run, invariants) and]CLAUDE.md
[first.]docs/architecture.md
Three documents, three jobs — and they ship with the change that makes them wrong, not in a catch-up pass afterwards:
| Document | Holds | Update it when |
|---|---|---|
README.md |
||
| what shadok-ai does and how to drive it | a user-visible feature, flag, command, endpoint or protocol message changes | |
CLAUDE.md |
||
| the file→responsibility map, the build/run recipe, the invariants | you add a module, or you lose an afternoon to something the next person would lose it to as well | |
docs/architecture.md |
||
| how a subsystem actually works and why it was built that way | you add or reshape a subsystem, or you make a design trade-off worth remembering | |
docs/superpowers/specs/ |
||
| one design per feature, dated | before building anything non-trivial |
This is not bookkeeping. A doc that lags is worse than no doc: it is confidently
wrong, and it is read as current. docs/architecture.md
once went 48 commits without an update, and by then it was missing entire subsystems while its line references pointed at code that had moved hundreds of lines — a reader in that window gets misled, not merely under-informed.
Two habits that keep it cheap:
Cite symbols, not just line numbers.finishTurn
survives a refactor;server.ts:781
does not. Where a line number helps, say which commit it was read at.Write the The what is in the diff. What the diff cannot say is which alternative you rejected and what it cost you to find out — that is the whole value ofwhy, not thewhat.architecture.md
, and the reason the invariants list reads like a scar tissue map.
Drives the Claude Code TUI (the interactive claude
CLI) through a real terminal, and reads the answers from Claude Code's own transcript.
⚠️ The Claude Code TUI is not a stable API: a CLI update can break the detection heuristics (❯
,⏺
,esc to interrupt
markers). For production use, prefer the[Claude Agent SDK]orclaude -p --output-format stream-json
.
Two transports, same interface:
tmux (default when installed)—claude
runs in a detached tmux session, so it survives the server restarting or crashing and is reattached on the next start. tmux answers terminal queries itself, which removes a whole class of PTY hacks. It does not survive a machine reboot.node-pty—claude
runs as a child of the server inside a pseudo-terminal (the TUI believes it talks to a human), with**@xterm/headless** replaying the ANSI stream into a virtual screen we can read. Terminal query responses (cursor position\x1b[?6n
, identification\x1b[c
…) must be forwarded back to the PTY, or the TUI ignores every keystroke. Dies with the server.
In both cases prompts are sent via bracketed paste with retries (the TUI
flushes stdin received during initialization), and "Claude is done" is detected
heuristically: no esc to interrupt
marker + screen stable for N ms.
Responses are not scraped from the screen. Claude Code writes every turn to
a .jsonl
transcript; that file is the source of truth for chat content, which is what makes long answers reliable. The screen is used only for control — submitting, detecting the end of a turn, reading dialogs, and mirroring the raw TUI in the engine room.
npm install
npm run build # tsc → dist/
npm test
npm run web # run the server directly (no supervisor, no auto-update)
An instance follows one of two release streams, picked in the version menu (click the version next to the brand):
beta— the default, and what a freshnpx shadok-ai
installs. Moves only when a version is promoted, so the instance stays put for days at a time.alpha— moves on every merge tomain
. Newest work, freshest bugs.
Promoting is deliberate: someone bumps the minor in package.json
and merges, and that merge becomes the new beta for everyone.
The numbers tell you which channel a version came from. An alpha carries the
commit count (0.3.78
); a promotion is a round 0.4.0
. The auto-update switch and the reload behaviour are identical on both.
The postinstall
script fixes the executable bit of node-pty's
spawn-helper
(a known npm prebuilds bug on macOS).
Note that npx shadok-ai
does not run your working tree: it runs a supervisor that manages an auto-updating npm-installed copy. To test a local build, see "Running YOUR build" in CLAUDE.md.
Separate from the cockpit: run a single prompt and print the answer.
shadok-ai-run [options] "<prompt>" # or: node dist/cli.js …
--cwd <dir> working directory of the claude session
--continue, -c resume the latest session of this directory
--resume <id>, -r resume a specific session (id printed at end of run)
--watch mirror the TUI live on stdout
--keep keep the session open at the end
--timeout <sec> max wait for the response (default 600)
Without --continue
/--resume
, every run starts a new session. The
session id is printed at the end of the run (found in
~/.claude/projects/<encoded cwd>/
, like any Claude Code session):
shadok-ai-run --cwd ~/my-project "Explain this project's structure"
shadok-ai-run --cwd ~/my-project --resume 5fe046dd-… "Now refactor it"
shadok-ai-run --cwd ~/my-project -c "Continue on the latest session"
The interface is a plain static page: all the intelligence lives server-side.
Any client (another front-end, a bot, a script…) can replace it by speaking
this JSON protocol on ws://…/ws
. The built-in Telegram bridge is such a client — it connects to the same server over loopback.
Shared sessions: the server keeps a single claude process per session id.
If several clients (tabs, browsers, interfaces) start
the same id, they
attach to the same process and all receive the same events — other clients'
prompts (prompt-echo
), answers, dialogs, screen. Closing a connection
detaches the client; the session is reclaimed after SHADOK_IDLE_MIN
with no
client, or immediately on an explicit stop
(which ends it for everyone).
client → server
| Message | Purpose |
|---|---|
{type:"start", cwd?, resume?, continue?, worktree?, branch?, repo?, profile?, parent?, origin?} |
|
starts or attaches to the session (once per connection). origin ("web" , "cron" , "telegram" , "cli" …) travels with prompt-echo so other clients can say who spoke. parent records who launched this agent — a refused link is dropped and logged, never fatal to the spawn |
|
{type:"prompt", text, force?} |
|
sends a prompt (force bypasses the pace guard) |
|
{type:"choose", n} |
|
| single-select dialog: picks and validates option n | |
{type:"toggle", n} / {type:"confirm"} |
|
| multi-select: toggles option n / submits | |
{type:"freetext", n, text} |
|
| "Type something" option: sends a free-form answer | |
{type:"key", key} |
|
raw keystroke (enter , escape , up , down , tab , ctrl-c , or a single character) |
|
{type:"settle"} |
|
| after a manual intervention: waits for the turn to finish | |
{type:"restart"} |
|
| respawns the agent in place (picks up new secrets/profile) | |
{type:"set-parent", parent} |
|
attach this channel under another (null detaches). Refused explicitly on a self-link, a cycle, an unknown parent, or a chain/fan-out past its cap |
|
{type:"term-attach"} · {type:"term-detach"} |
|
| experimental, tmux only — open/close the pane's raw byte pipe | |
{type:"term-input", data} · {type:"term-resize", cols, rows} |
|
| raw input (base64) / match the pane to the viewport | |
{type:"stop", sessionId?} |
|
ends the session for all clients; sessionId targets another channel (zombie cleanup) |
server → client
| Message | Purpose |
|---|---|
{type:"ready", sessionId, cwd} |
|
| session started (or attached) | |
{type:"working"} / {type:"turn-done", sessionId} |
|
| turn started / finished | |
{type:"stream-text", text, at?} |
|
a complete assistant text block, from the transcript. at is when it was written, not when we read it |
|
{type:"stream-tool", id, name, summary} / {type:"stream-result", …} |
|
| tool call / tool result | |
{type:"tokens", tokens} / {type:"context", pct} |
|
| token usage / context fill | |
{type:"parent", parent} |
|
| the channel's parent changed (broadcast, so every tab follows) | |
{type:"prompt-echo", text} |
|
| prompt sent by another client of the session | |
{type:"dialog", question, options:[{n,label,hint,checked?}], multi} |
|
| choice pending | |
{type:"history", turns:[…]} |
|
| transcript replayed when resuming/attaching | |
{type:"screen", text, working} |
|
| rendered TUI screen (whenever it changes) | |
{type:"pace-blocked"} / {type:"pace-hold"} / {type:"pace-resumed"} |
|
| quota guardrail | |
{type:"auto-retry"} / -cancelled / -gave-up |
|
| transient API error being retried | |
{type:"version", …} / {type:"server-reload", version} |
|
| update available / server updated, reload | |
{type:"term-data", data} |
|
| experimental — raw pane output (base64) for a client-side terminal emulator | |
{type:"gone"} / {type:"error", message, code?} / {type:"exited", code} / {type:"stopped"} |
|
session lost, errors, termination. error.code is "busy" for a prompt refused mid-turn and "logged-out" for a spawn refused because the instance isn't signed in — so a machine client needn't match on the message text |
HTTP endpoints (same auth): /usage
, /live
, /sessions
, /recover
,
/diff
, /channels
(its GET adds a derived crons
field — never stored),
/channel
(DELETE), /groups
, /crons
, /timezone
, /title
(GET/PUT — the
cockpit's name, per launch directory: click the header brand to rename it, so
several cockpits stay apart in the tab bar; an empty PUT reverts to the default),
/theme
(GET/PUT — the cockpit's colour palette, per launch directory: an accent
key picked from the ⋯ menu, e.g. emerald
; unknown/amber
reverts to default),
/profiles
, /secrets
, /telegram
, /defaults
, /version
, /autoupdate
,
/update-channel
(POST {channel}
— alpha
or beta
), /permission-mode
, /tweak/prepare
(POST — clone/refresh shadok-ai's own source,
returns the cwd to start the tweak agent in), and the sign-in group: /auth
(GET
— {loggedIn, email?, subscriptionType?}
), /auth/login
(POST — start a flow,
returns {url}
; DELETE — cancel it), /auth/code
(POST {code}
).
import { PtyPilot } from "shadok-ai";
const pilot = new PtyPilot({ cwd: "/my/project" });
pilot.start();
await pilot.waitForIdle(); // TUI ready
await pilot.submit("Fix the bug in auth.ts");
await pilot.waitForIdle({ timeoutMs: 600_000 });
console.log(pilot.screen()); // rendered screen (visible transcript)
await pilot.submit("Now add a test"); // same session, second turn
await pilot.waitForIdle();
await pilot.stop(); // clean /exit, kill as fallback
Main API:
| Method | Purpose |
|---|---|
start() / stop() / kill() |
|
| process lifecycle | |
submit(text) |
|
| types a prompt + Enter, with verification and retries | |
write(text) / press(key) |
|
low-level keystrokes (enter , escape , up , tab , ctrl-c …) |
|
screen() / fullBuffer() |
|
| rendered screen / full buffer | |
waitForIdle({stableMs, timeoutMs}) |
|
| waits for the end of a turn | |
waitFor(predicate) |
|
| waits for an arbitrary screen condition (dialogs, permissions…) | |
isWorking() |
|
| true while Claude is working | |
onData(cb) / onExit(cb) |
|
| raw ANSI stream (mirror mode) / process exit |
TmuxPilot
exposes the same surface and additionally survives a server
restart. examples/two-turns.mjs
shows a two-turn conversation;
debug/probe.mjs
logs the terminal sequences exchanged (useful when a CLI update breaks the detection).
- Every session launched consumes your Claude quota like a normal session.
- Profile guardrails are
soft: agents run as the same OS user. It prevents misfires, it is not a sandbox. - Agent worktrees are branched at spawn and never rebased, so a long-running
agent drifts from a moving main branch. A design for this exists and was
deliberately deferred:
docs/superpowers/specs/2026-07-28-worktree-rebase-drift-design.md
. - The interactive terminal (xterm.js over the raw pane stream) is
experimental and requires tmux; with node-pty the engine room is all there is. - Dialogs the chat cannot handle in one click are managed through the
engine room (
waitFor()
+press()
in library mode). - The TUI runs in the alternate screen:
fullBuffer()
≈ visible screen; very long responses scroll out of view (increaserows
if needed).
Les Shadoks were French cartoon birds, drawn by Jacques Rouxel in 1968, and the whole joke was that they pumped. Relentlessly, exhaustingly, and for no result anyone could ever identify. One of their mottos: the more it fails, the more likely it is to work.
An agent woken every hour to report that nothing happened is pumping. You pay a
model call to be told there was nothing to do, and it will tell you that
twenty-three more times today. Hence the guard in crons.ts
: the check runs without the model, and a quiet hour costs nothing at all.
The name went unexplained here for the project's first month, which is a strange thing to leave out of a README when the joke is the design.