中文 | English
A background runtime and observability console for multiple CLI coding agents. Run opencode, codex, kiro-cli, qwen, cursor-agent and others in the background, switch between them like ghostty tabs, record every execution in full, and talk to them — including approving permissions remotely — from your terminal or from Feishu / DingTalk / QQ bots.
- The problem
- Features
- Quick start
- Usage: command line
- Usage: TUI console
- Usage: Feishu bot
- Configuration
- Architecture
- Development
- Known limitations
CLI coding agents are designed for a human sitting in front of a terminal. Real work needs them to run unattended for long stretches while remaining interruptible from anywhere:
- Running three agents in parallel means three terminal windows, and remembering which is doing what
- Close the terminal and the task dies; come back and all you have is scrollback
- The agent asks "allow
rm -rf build?" while you're away from the keyboard, and the task blocks indefinitely - You want to know which files an agent touched and which commands it ran — scroll past it and it's gone
- Multiple agents editing the same repository step on each other
CodeMonkey turns these agents into session objects that can be hosted in the background and observed or driven remotely.
Agents start inside a real PTY, detached from the terminal that launched them. Close the terminal, shut the laptop — the session keeps running under the daemon.
A real PTY rather than a pipe is mandatory: nearly every CLI agent checks isatty. Behind a pipe they downgrade to non-interactive mode, disable colour, or refuse to start their TUI at all.
Supported agents:
| Agent | Modes | Events & tools | Interactive approval | Terminal screen |
|---|---|---|---|---|
| opencode | hybrid / native / pty | structured (HTTP + SSE) | yes | yes |
| codex | native / hybrid / pty | structured (stdio JSON-RPC) | yes | yes |
| kiro-cli | pty | screen inference | yes (dialog keystrokes) | yes |
| qwen | pty | screen inference | no | yes |
| cursor-agent | pty | screen inference | no | yes |
Adding another terminal-only agent usually means appending one profile to src/adapters/generic-pty/profiles.ts (launch command plus screen-feature regexes) — no code required.
Supported IM channels:
| Channel | Transport | Approval card | Voice input | Status |
|---|---|---|---|---|
| Feishu / Lark | long connection (no public domain needed) | real buttons, one tap decides | yes (ASR + confirmation) | ✅ supported |
| DingTalk | webhook (public URL required) | markdown, buttons degrade to commands | no | 🔜 planned |
| webhook (public URL required) | plain text, buttons degrade to commands | no | 🔜 planned |
Sessions are independent objects, not bound to any frontend. Run as many agents of as many kinds as you like: hand a refactor to opencode, test coverage to codex, docs to qwen — each with its own workspace, recording and state.
Each agent can expose two channels. They complement rather than replace each other:
| Mode | Native channel | PTY channel | Use for |
|---|---|---|---|
hybrid |
events & approvals | screen | preferred for opencode |
native |
everything | not started | structured observation only |
pty |
none | everything | agents without an API |
The native channel is semantically precise (tool calls, file changes and approval requests arrive as structured data). The PTY channel is universal (it works for any CLI agent, and the screen matches ghostty exactly).
The native channel supports two transports, declared by the adapter: http (opencode's serve + SSE) and stdio (codex's app-server JSON-RPC).
This is the biggest difference from comparable tools. Approval requests are broadcast to every frontend with full context, and whichever frontend answers first wins:
agent requests approval
→ denylist match (rm -rf / git push -f / writes to ~/.ssh / sudo …)
hit → human confirmation required, and cannot be overridden by "always allow"
→ allowlist match → auto-approved, audited
→ neither → queued as pending, broadcast to CLI / TUI / bots
any frontend answers → CAS claim → the others are told "already handled by X"
timeout → hold (default, wait forever) / reject / allow (opt-in, warns)
→ audit appended to ~/.codemonkey/audit.jsonl
kiro-cli deserves a note: it has no API whatsoever, but its approval dialog has a fixed structure (three options, cursor always on the first). That's enough for CodeMonkey to recognise the request, push it to you, and answer with keystrokes. The full loop — capture, remote approve, file written — has been verified against the real binary.
Other PTY-only agents have not had their dialogs verified, so CodeMonkey does not claim approval support for them. Claiming it and then letting something through unchecked is worse than admitting it isn't supported.
summary answers four questions: what has been done, what is happening now, what is blocked, what's next. It defaults to a rule-based summary computed directly from structured events — zero latency, zero cost, always available:
Done 7 tool calls (read×3, write×2, bash×2); touched src/auth/index.ts and 3 more
Doing waiting for approval: run command: npm publish
Blocked high risk, matched rule "npm publish", awaiting human decision
Next run `codemonkey allow cm_a1b2c3:per_xxx` to approve, or `deny` to refuse
LLM summarisation is an optional enhancement that must be configured explicitly. If the model is unavailable it falls back to the rule-based summary rather than failing.
Two layers, each with a purpose:
stream.cast— raw terminal stream in asciicast v2, replayable withcodemonkey replayorasciinema playevents.jsonl— structured events for search, summarisation and bot notifications
The worktree sandbox gives each session its own git worktree, so several agents can edit the same repository without collisions and your main branch stays untouched. When the session ends the worktree is removed but the branch is kept — that branch is the agent's output.
A bot is just another frontend, on equal footing with the CLI and TUI. Feishu, DingTalk and QQ are supported.
Feishu is the most complete: long-connection transport needs no public domain, approval cards use real buttons (one tap decides), and voice input works (transcribed, echoed back for confirmation, then executed).
Requirements: Node.js 22+, macOS (Linux best-effort).
git clone <repo> && cd codemonkey
npm installStart your first session:
# launch opencode in the background, workspace = the given directory
npm run cm -- start opencode --dir ~/proj/api
# output
# 已启动 cm_a1b2c3d4e5f6 opencode (hybrid)
# 工作区 ~/proj/api
# 版本 1.18.6You can close the terminal now — the session keeps running under the daemon:
npm run cm -- list # still there
npm run cm -- tui # open the tab-switching consoleDrive it:
npm run cm -- say cm_a1b2c3d4e5f6 "split the auth module into its own package"When the agent asks for approval:
npm run cm -- perms # list pending requests
npm run cm -- allow cm_a1b2c3d4e5f6:per_xxx # approve onceThe CLI currently prints Chinese messages. English output is not yet implemented — see Known limitations.
Prefixing everything with npm run cm -- gets old. After building you can call codemonkey directly:
npm run build
npm link
codemonkey listThe rest of this document uses codemonkey; substitute npm run cm -- if you haven't built.
# start a session
codemonkey start <agent> [--dir <path>] [--mode <mode>] [--sandbox <policy>]
codemonkey start opencode # current directory, hybrid mode
codemonkey start codex --dir ~/proj --mode native # explicit directory and mode
codemonkey start opencode --sandbox worktree # isolate with git worktree
codemonkey start kiro-cli --dir ~/proj # PTY-only agent
# list running sessions
codemonkey list
# SESSION AGENT MODE STATE PENDING WORKSPACE STARTED
# ● cm_a1b2c3d4e5f6 opencode hybrid busy - ~/proj/api 3m12s ago
# ! cm_f6e5d4c3b2a1 codex native awaiting 1 ~/proj/web 1m05s ago
# session detail
codemonkey status cm_a1b2c3d4e5f6
# past sessions (finished or orphaned)
codemonkey history
# stop a session (terminates the agent process)
codemonkey kill cm_a1b2c3d4e5f6State badges: ● busy ○ idle ! awaiting approval ? awaiting input × error ⚠ orphaned · exited
Modes:
| Mode | Meaning |
|---|---|
hybrid |
native channel for events and approvals, PTY channel for the screen. opencode default |
native |
structured channel only — precise events and approvals, no terminal screen |
pty |
real terminal only — perfect fidelity, but approvals cannot be captured reliably |
One constraint of hybrid is worth knowing: the PTY and native channels are two independent sessions on the agent's side. Conversation and approvals go through the native channel; the PTY screen is for observation only — so typing in the TUI will not change what the PTY screen shows.
Sandbox policies:
| Policy | Behaviour |
|---|---|
none |
the agent works directly in the given directory (default) |
worktree |
git worktree creates an isolated tree on branch codemonkey/<session-id>. On exit the tree is removed and the branch is kept |
If the directory isn't a git repository, or the repository has no commits, worktree degrades to none and says so explicitly — it will never let you believe you have isolation when you don't.
# send a message
codemonkey say cm_a1b2c3 "refactor the login flow"
# forward the agent's own slash command verbatim
codemonkey keys cm_a1b2c3 /model
codemonkey keys cm_a1b2c3 /status
# send terminal keystrokes
codemonkey keys cm_a1b2c3 Up Enter
codemonkey keys cm_a1b2c3 CtrlC
# interrupt the current turn
codemonkey abort cm_a1b2c3/keys is the unified pass-through command for an agent terminal. It does not interpret or rewrite the agent command after it, and it is not limited to /model or /status. The content after /keys is handled by the current coding agent, and the resulting agent terminal content is returned verbatim without a delivery prefix, Markdown fence, or application-level clipping.
| Where | Syntax |
|---|---|
| Bot with a bound session | /keys <agent-command-or-keys...> |
| Bot with an explicit session | /keys <session-id> <agent-command-or-keys...> |
| Local CLI | codemonkey keys <session-id> <agent-command-or-keys...> |
The available slash commands are defined by the agent itself. Common examples follow.
View the agent's own status:
/keys /status
Open the model menu and make a selection:
/keys /model
/keys Down
/keys Enter
Multiple consecutive keys can also be sent together:
/keys Down Down Enter
Open the agent's own help or settings:
/keys /help
/keys /settings
Close a menu or interrupt the current terminal operation:
/keys Escape
/keys CtrlC
If no current session is bound, specify one explicitly:
/keys cm_a1b2c3 /status
A slash command is forwarded as one complete command. If the command opens an interactive menu, continue with arrow keys, Enter, or Escape; each operation returns the updated agent terminal content. In the local CLI, quote the complete agent command when consecutive spaces or shell-special characters must be preserved:
codemonkey keys cm_a1b2c3 '/command value'Key names: Enter Escape Tab ShiftTab Backspace Up Down Left Right Home End PageUp PageDown CtrlC CtrlD CtrlL CtrlP CtrlR. Anything else is sent verbatim.
# progress summary (done / doing / blocked / next)
codemonkey summary cm_a1b2c3
codemonkey summary cm_a1b2c3 --minutes 30 # last half hour only
codemonkey summary cm_a1b2c3 --full # read all events from disk
# structured progress: tool timeline and change surface
codemonkey progress cm_a1b2c3
# current terminal screen
codemonkey screen cm_a1b2c3
# structured execution record
codemonkey logs cm_a1b2c3
codemonkey logs cm_a1b2c3 --follow # tail in real time
codemonkey logs cm_a1b2c3 --type tool.started # filter by event type
# replay the terminal session
codemonkey replay cm_a1b2c3
codemonkey replay cm_a1b2c3 --speed 4Event types accepted by --type: session.created session.state session.exited session.error screen.updated message.user message.assistant.delta message.assistant.done tool.started tool.finished file.changed permission.asked permission.resolved input.needed
Records are plain files, so standard tools work too:
asciinema play ~/.codemonkey/sessions/cm_a1b2c3/stream.cast
jq 'select(.type=="tool.finished")' ~/.codemonkey/sessions/cm_a1b2c3/events.jsonlcodemonkey perms # list all pending requests (with diff preview)
codemonkey allow <request-id> # approve once
codemonkey allow <request-id> --always # auto-approve similar requests from now on
codemonkey deny <request-id> # refuseRequest IDs look like cm_a1b2c3:per_xxxxx. "Already handled" means another frontend (the TUI or a bot) answered first — that's the race resolving normally, not an error.
codemonkey daemon # run the daemon in the foreground with visible logs (debugging)
codemonkey stop-daemon # stop the daemon (drains sessions first)
codemonkey adapters # list registered agents and their per-mode capabilities
codemonkey bots # check bot configuration and surface security warningsThe first start launches the daemon automatically, detached from your terminal. You never need to start it by hand.
codemonkey tuiThree regions: a tab bar at the top (one tab per session with a state badge), that session's live terminal screen in the middle, and a status bar plus approval area at the bottom.
| Key | Action |
|---|---|
← → or [ ] |
switch tabs |
1–9 |
jump to the Nth session |
i |
input mode — send a message to the current session (Esc cancels, Enter sends) |
y / a / n |
when awaiting approval: allow once / always allow / deny |
b |
interrupt the current turn |
r |
refresh now |
q |
quit (sessions keep running in the background) |
When a request is pending, the bottom pane shows the risk level, any matched dangerous rule, and a diff preview; answer with y/a/n.
Feishu connects over a long-lived connection: no public domain, no encryption setup, and no listening port at all (zero attack surface). Full walkthrough in docs/03-feishu.md; the essentials follow.
- Create an internal app at open.feishu.cn
- From "Credentials & Basic Info", note the
App IDandApp Secret - Under "Events & Callbacks", choose receive events over long connection, note the
Verification Token, and subscribe toim.message.receive_v1 - Under "Permissions", grant
im:message,im:message:send_as_bot,im:chat— plusspeech_to_text:speechfor voice - Publish the app and add the bot to a group (or message it directly)
Write ~/.codemonkey/config.json:
chmod 600 ~/.codemonkey/config.json # it holds credentials
codemonkey stop-daemon && codemonkey daemon
codemonkey bots # self-check; reports anything missingFinally, message the bot with /whoami. It replies with two lines you can paste straight into allowedUsers and subscribers; restart the daemon afterwards.
Once a session is selected, send messages directly — no need to repeat the session ID:
you: /new opencode /Users/me/proj
bot: 已启动 cm_a1b2c3 opencode (hybrid)
已设为当前会话,直接发消息即可与它对话。
you: analyse the architecture of this project
bot: 任务已收到,马不停蹄的开展工作ing......
bot: [approval card] edit file: docs/arch.md [Allow once][Always allow][Deny]
you: (tap "Allow once"; the card immediately becomes "handled")
bot: ✅ 回合完成 · 工具 read×8、write×1 · 改动 docs/arch.md
详情:/summary cm_a1b2c3
The rule: once a session is bound, only messages starting with / are treated as commands; everything else is forwarded to the agent. This matters because you might reasonably say "list the files you changed" — under a "slash optional" rule that would be executed as /list. When no session is bound, the slash is optional.
session /new <agent> <dir> create and select a session
/use <id> switch current session (aliases /switch /attach)
/close end the conversation binding; session keeps running (alias /detach)
/exit [id] stop the session and terminate the agent (aliases /stop /kill /quit)
/resume <id> resume a stopped session, restoring context where possible
/list session list, ▶ marks the current one
query /status <id> session detail
/summary <id> [mins] progress summary
/progress <id> tool timeline and change surface
/screen <id> current terminal screen
/perms pending approvals (returns a card with buttons)
action /say <id> <message> send a message to a specific session
/keys [id] <command-or-keys...> operate the agent terminal verbatim and return its content
/abort <id> interrupt the current turn
approval /allow <request-id> approve once
/always <request-id> auto-approve similar requests from now on
/deny <request-id> refuse
other /whoami show your open_id and the group chat_id
/ok confirm transcribed voice input (aliases /yes /y)
/help usage
Most commands also accept Chinese aliases (/列表, /状态, /进展, /批准, /拒绝, /切换, /断开, /退出, /恢复, …).
Send a voice message and CodeMonkey downloads the audio and transcribes it with Feishu's ASR. The transcript is not executed directly — it's echoed back for confirmation first:
you: [voice] split the auth module into its own package
bot: 🎤 听到:split the auth module into its own package
确认发送给 cm_a1b2c3 请回复 /ok,重说请直接再发一条语音。
you: /ok
The extra step exists because transcription has an error rate, and the resulting text drives real operations against your machine. The cost of a misrecognition is asymmetric. Recording again overwrites the pending transcript.
Limit: 60 seconds per message (longer is rejected explicitly).
Only four kinds of event are pushed: pending approvals, fatal errors, requests for human input, and end-of-turn progress.
Deliberately not pushed: the agent's prose replies (often thousands of words), token-level streaming, and individual tool calls (dozens per minute). Pushing those turns the chat into a log tail and gets the bot muted. Pull the detail with /summary or /progress instead.
Both use webhook mode, which needs a publicly reachable address (use cloudflared or ngrok locally). Capability differences are imposed by the platforms, not by CodeMonkey:
| Platform | Inbound verification | Approval card |
|---|---|---|
| Feishu | Verification Token (supports encrypt) | real buttons, one tap decides |
| DingTalk | HMAC-SHA256 signature + timestamp window | markdown; buttons degrade to tappable commands |
| Ed25519 signature + callback verification | plain text; buttons degrade to copyable commands |
Start with Feishu.
The daemon itself listens only on a Unix domain socket and 127.0.0.1. In Feishu long-connection mode it listens on no port at all.
Webhook mode has three gates, all of which must pass before a command runs:
- Path token — the request must carry
?token=, compared in constant time to avoid timing probes - Platform signature — proves the request really came from that platform. Missing credentials means reject, not allow
- User allowlist — an empty
allowedUsersrejects everything, including read-only commands (progress data is itself sensitive)
The allowlist applies identically to text, card buttons and voice. Buttons and voice are not back doors; the integration suite asserts this.
~/.codemonkey/config.json. Every field is optional; without the file, defaults apply.
{
"cols": 120, // virtual terminal size
"rows": 40,
"sandbox": "none", // default sandbox policy: none | worktree
"httpPort": 0, // >0 also listens on 127.0.0.1 over HTTP
"permission": {
"allow": ["git status", "npm test"], // auto-approve (never overrides the built-in denylist)
"deny": [], // extra dangerous rules; the 17 built-ins always apply
"timeoutPolicy": "hold", // hold (default) | reject | allow
"timeoutMs": 600000
},
"summarizer": { // LLM summaries, off by default; rule-based always available
"enabled": false,
"baseUrl": "https://api.example.com/v1", // any OpenAI-compatible endpoint
"apiKey": "sk-...",
"model": "qwen-plus"
},
"bots": {
"enabled": false,
"port": 45800, // webhook mode only
"host": "127.0.0.1",
"feishu": {
"enabled": true,
"mode": "longconn", // longconn (recommended) | webhook
"appId": "cli_xxx",
"appSecret": "xxx",
"secret": "Verification Token",
"encryptKey": "", // only if encryption is enabled in the Feishu console
"allowedUsers": ["ou_xxx"], // empty array = reject everything
"subscribers": ["oc_xxx"] // groups that receive pushed notifications
},
"dingtalk": {
"enabled": false,
"mode": "webhook",
"token": "your own path token",
"secret": "DingTalk signing secret",
"outgoingUrl": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
"allowedUsers": [],
"subscribers": []
}
}
}The file holds credentials, so chmod 600 it. codemonkey bots checks the permission bits and warns, and never prints credential values.
The built-in denylist has 17 entries covering rm -rf, sudo, git push --force, git reset --hard, chmod 777, curl … | sh, writes to ~/.ssh and /etc, dd, mkfs and similar. They always apply and cannot be overridden by allow or by choosing "always allow" — one careless tap shouldn't create standing risk.
Pattern matching supports only *, and matches the whole string. That's deliberate: an ls rule will not approve rm -rf /; ls.
~/.codemonkey/
├── config.json global configuration
├── audit.jsonl approval audit trail (append-only)
├── daemon.sock UDS listener
├── daemon.pid
└── sessions/<session-id>/
├── meta.json session metadata (agent, mode, workspace, branch, native session id)
├── stream.cast raw terminal stream, asciicast v2
├── events.jsonl structured events
├── screen.txt latest screen snapshot
└── worktree/ the worktree sandbox tree (removed when the session ends)
frontends/{cli,tui,http,bots} frontends with equal capabilities, all over one core interface
daemon/{server,client,main} the daemon; sessions outlive the terminal that started them
core/{events,session,manager, core, standard library only
permission,recorder,progress,summarizer,registry,config}
runtime/{pty,screen,net, PTY, terminal emulation, HTTP, JSON-RPC, sandbox
jsonrpc,sandbox}
adapters/{types,opencode,codex, adapters — the single point where agents differ
generic-pty,mock}
Dependencies flow one way: core/ must not import adapters/ or frontends/, and adapters never import each other. core/ is forbidden from containing checks like if (adapter === 'opencode') — differences between agents may only be expressed through the Adapter interface and its capability declarations.
Every observability feature is built on one event contract (core/events.ts), the single source of truth: recording, TUI rendering, progress summaries and bot notifications all consume the same events.
Only translate() on the Adapter interface is allowed to contain agent-specific logic, which is why adding an agent requires no changes to the core.
npm test # unit tests (492 cases)
npm run test:integration # integration tests (the opencode suite needs the real binary)
npm run typecheck
npm run buildIntegration tests come in two flavours. tests/integration/bots.test.ts and daemon.test.ts use test doubles and need no external services. opencode.test.ts actually launches opencode, sends a real prompt and completes a real approval; set CM_SKIP_INTEGRATION=1 to skip it.
When debugging bots, run the daemon in the foreground so logs are visible:
codemonkey daemonEvery hop is logged inbound and outbound — connection established, message received, forwarded to session, replied — and outbound failures carry the platform error code plus an actionable hint.
- The CLI and bot messages are Chinese-only. There is no i18n layer yet; this English document describes an interface that still speaks Chinese at runtime.
- qwen's native channel is not wired up.
qwen serve --http-bridgeis still marked Stage 1 experimental upstream; the protocol may change at any time, so the maintenance cost outweighs the benefit. It uses the PTY channel today. - cursor-agent's post-login screen features are unverified. The not-logged-in prompt is verified and reported correctly (you get "log in first" rather than a silent hang), but logging in requires a browser and can't be done unattended.
- DingTalk and QQ have not been tested against real apps. Signature algorithms, message parsing and card structures are all covered by tests, but no full round trip has run against a live application. Feishu has been verified end to end.
- Feishu ASR quality is unmeasured. Feishu's speech recognition targets general use; how well it handles mixed Chinese/English technical phrasing needs real verification.
transcribeAudiotakes its client through an interface, so swapping in local whisper is straightforward. - seatbelt / docker sandboxes are not implemented. Only
noneandworktreeexist. Apple has deprecated macOSsandbox-exec, so whether to invest there is undecided. - Existing sessions cannot survive a daemon restart. They are drained and marked orphaned in
history./resumecan restore the agent-side conversation context (supported by opencode and codex), but under a new session ID. - macOS only, as a guarantee. Linux is best-effort (the PTY layer itself is portable); Windows is untested.
docs/00-spike-report.md— feasibility spike: dual-channel verification, approval-flow evidence, technology choicesdocs/01-prd.md— product design: target scenarios, requirements, non-goals, milestonesdocs/02-architecture.md— architecture: layering, event contract, state machine, approval flowdocs/03-feishu.md— Feishu setup walkthrough and troubleshooting table
These design documents are written in Chinese.
{ "bots": { "enabled": true, "feishu": { "enabled": true, "mode": "longconn", "appId": "cli_xxxxxxxx", "appSecret": "xxxxxxxx", "secret": "Verification Token", "allowedUsers": [], "subscribers": [] } } }