Skip to content

feat: M2 runtime (ConPTY, supervisor, hooks, terminal replay) - #2

Merged
httpsphl merged 8 commits into
mainfrom
feat/m2-runtime
Sep 28, 2026
Merged

httpsphl merged 8 commits into
mainfrom
feat/m2-runtime

Conversation

@httpsphl

Copy link
Copy Markdown
Owner

Milestone M2 — runtime (spec section 17): ConPTY, supervisor with states and backoff, Job Objects, hooks, terminal with replay, FakeRuntime.

Stacked on #1 (M1). This branch starts from feat/m1-daemon-base, so until #1 merges the diff also shows the M1 commits. Review only the last commit, feat(daemon): run bots in ConPTY under a supervisor (M2), and merge #1 first.

Done when (spec): a real bot opens on Windows, states change through hooks, reattach does not lose the screen. All three were checked with Claude Code 2.1.283 (see the test plan).

What's in it

  • Runtime (runtime/): bots run in ConPTY through portable-pty, each in its own Job Object with kill-on-close. FakeRuntime implements the same contract for tests. Claude Code is found through claude_path or PATH and must be the native claude.exe >= 2.1.234; when it is missing, system.status.runtimeError says why.
  • Supervisor (supervisor/): keeps active bots running, reconciles every 5 s or when woken, restarts with exponential backoff and jitter, uses --continue after the first SessionStart, starts fresh after a quick death, leaves auth_error alone until bots.restart, and stops bots on pause or archive.
  • Hooks (hooks/): POST /hooks/{event} authenticated by a per-generation bot token that exists only in memory. botloftd hook <event> always exits 0 and never writes to stdout.
  • Terminal (terminal.rs): a ring buffer per bot with replay by generation and offset, and 8 ms / 32 KiB coalescing. Generations are unique across daemon restarts.
  • RPC: bots.restart, terminal.attach/detach/write/resize, plus the bot.state and terminal.data notifications.

Found by running a real bot

  • ConPTY startup handshake. portable-pty creates the pseudo console with PSEUDOCONSOLE_INHERIT_CURSOR, so ConPTY sends ESC[6n and holds the child until something answers. With no terminal attached the screen stayed empty forever. The daemon now answers the first query of each generation.
  • Bot environment. A daemon started from inside a Claude Code session inherits CLAUDECODE, CLAUDE_CODE_MESSAGING_SOCKET, ANTHROPIC_BASE_URL and more. Bots now get the user's default environment block (CreateEnvironmentBlock), not the daemon's.
  • PATH with a stray quote. std::env::split_paths treats quotes as grouping, so a single malformed entry hid every later directory, including .local\bin. PATH is now split on ; only, as Windows does.
  • Restart during launching. A restart before SessionStart did not announce the new generation to the app. Every new process now emits bot.state.
  • Workspace trust. In interactive sessions Claude Code holds back all hooks until the folder is trusted, so a new bot sits in launching behind the trust dialog. Spec 7.4.1 proposes that M4 onboarding trusts the workspaces root once.

Spec and design changes

  • 7.2: oauth_org_not_allowed also means auth_error; other StopFailure errors mean idle.
  • 7.3: no --continue on a bot's first start.
  • 7.4: native claude.exe only (quoting through cmd.exe breaks in the PTY), the user environment block, BOTLOFT_HOME added to the bot env, the PATH rule.
  • 7.4.1: first run and workspace trust.
  • 8: the ConPTY handshake, generation uniqueness, attach/write/resize details.
  • 13: bot token hashes live only in memory, because every bot dies with the daemon; bots.token_hash is unused.
  • 19: results of the real test.
  • CLAUDE.md: new Windows pitfalls.

Test plan

  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (99 tests)
  • Supervisor tests on a paused clock with FakeRuntime (backoff, --continue, fresh start, auth_error, pause, archive, restart, resize), repeated 5 times with no flakes
  • End-to-end tests over WebSocket and HTTP: hooks with the bot token, terminal replay and live stream, reattach, restart as a new generation
  • pnpm check with the regenerated protocol.gen.ts
  • With Claude Code 2.1.283 on Windows 11, via botloftd serve with BOTLOFT_HOME=.dev\home and workspaces outside any repository:
    • trust dialog shown; the bot stays launching until it is accepted
    • launching -> idle -> busy -> idle driven by real SessionStart, UserPromptSubmit and Stop hooks (exec form, no shell)
    • the bot answered its name, handle and crew from .claude/rules/botloft.md
    • reading secrets\owner.token was denied: "File is in a directory that is denied by your permission settings"
    • reattach: up to date gets 0 bytes, 500 behind gets exactly those 500 bytes, an old generation gets a reset
    • killing botloftd also killed the bot's claude.exe (Job Object)

Still needs real Claude Code

  1. A real --continue resuming a conversation after a crash.
  2. A real StopFailure (rate_limit) and a real Notification with permission_prompt, to see rate_limited and needs_approval end to end.
  3. MCP header expansion of ${BOTLOFT_BOT_TOKEN}: needs the /mcp server from M3.

Not in this PR: deleting the workspace when a bot is archived (spec 14), keep-awake and the scheduled task (M5).

Section 19 now lists what the official documentation confirms and which
real test (M2/M3) still has to run. Two corrections came out of it:

- Absolute paths in permission rules need the `//` prefix and the POSIX
  form Claude Code uses on Windows (`//c/Users/...`). A single leading
  slash is relative to the settings file, so the old wording would have
  left the secrets folder readable.
- The inbox message line format is not documented; the courier must not
  depend on it before the M3 real test.
- Prefixed ULID ids (crw_, bot_, msg_, dlv_, tsk_) that validate their
  prefix on parse and deserialize
- Slugs and handles from display names: transliterated, max 32 chars,
  Windows device names avoided, numeric suffix for collisions
- Avatar palette and #RRGGBB validation (spec 15.3)
- Field validation for names, roles and instructions
- M1 protocol types (hello, status, crews and bots) exported to
  app/src/lib/protocol.gen.ts by the export_bindings test; ts-rs is a
  dev-dependency only, and CI fails if the generated file is stale
- WAL, foreign keys and a 5 s busy timeout on every connection (spec 12)
- Numbered migrations tracked in PRAGMA user_version; a database from a
  newer build is refused instead of being modified
- 0001_init: crews, bots and settings. Handles are unique among a
  crew's active bots (partial index); slugs stay unique forever so an
  archived bot's folder is never reused. Bots get a color column for
  the avatar (spec 15.3)
- Crew archiving cascades to its bots in one transaction
- config.toml with the spec 6 defaults; unknown keys are rejected
- BOTLOFT_HOME / %LOCALAPPDATA%\Botloft and %USERPROFILE%\Botloft
  resolution, plus the //c/... form Claude Code expects in permission
  rules
- Owner token: 32 random bytes in secrets\owner.token, only its
  SHA-256 kept in memory, compared in constant time
- platform: protected DACL with a single user SID on Windows (tested
  against the real ACL), 0700/0600 elsewhere; Windows console
  close/logoff/shutdown signals
- Single instance through an exclusive lock file per data folder
  instead of a fixed-name mutex, so a dev daemon can run next to an
  installed one; spec 14 updated
Writes the files of spec 5.1 into <root>\<crew>\<bot>:

- .claude/settings.json: exec-form hooks calling botloftd.exe,
  crossSessionInbound=accept and a deny rule for the secrets folder in
  the //c/... form Claude Code matches on Windows
- .botloft/mcp.json: the local MCP endpoint with the bot token expanded
  from the environment, so it never touches disk
- .claude/rules/botloft.md: identity, role, instructions, shared folder
  and how to use the crew tools; regenerated on every change
- CLAUDE.md: created once and then left to the bot

Every write goes through a temp file and a rename.
- /rpc: JSON-RPC 2.0 over WebSocket. session.hello with the owner token
  must come first (10 s); anything else, a wrong token or another
  protocol version closes the connection. Browser origins other than
  the app get 403; clients without Origin still need the token
- /health for the app's daemon check
- crews.* and bots.* (spec 11.2) run under the store lock, write the
  workspace before the database and broadcast crew.changed and
  bot.changed to every connection; a client that falls 1024 events
  behind is dropped and reloads on reconnect
- Handles follow the bot's name and must be unique among active bots of
  the crew; folders keep their slug. Bots get a palette color unless
  one is given
- Integration tests run a real daemon on an ephemeral port with a
  WebSocket client: the M1 acceptance flow (create a crew and a bot via
  RPC and get a ready workspace), updates, archiving, auth and errors

Spec: color in bots.create/update and the bots table, slug and handle
rules, Origin and hello details, standard JSON-RPC codes.
`botloftd serve [--config <file>]` resolves the data folder, loads the
config, starts daily-rotated logs (14 kept), takes the instance lock,
prepares the owner token and database and listens on 127.0.0.1 until
Ctrl+C or a Windows close/logoff/shutdown signal. log_level applies to
Botloft's crates only; dependencies stay at warn because the WebSocket
stack logs frames at debug and trace.
- runtime: ConPTY via portable-pty with one Job Object per bot
  (kill-on-close), FakeRuntime for tests, Claude Code discovery and
  version probe (>= 2.1.234, native claude.exe only)
- supervisor: states driven by hooks, reconcile loop, exponential
  backoff with jitter, --continue after the first SessionStart, fresh
  start after a quick death, auth_error waits for bots.restart, pause
  and archive stop the process
- hooks: POST /hooks/{event} authenticated by a per-generation bot
  token kept only in memory; `botloftd hook <event>` always exits 0
  and never writes to stdout
- terminal: ring buffer per bot with replay by generation/offset,
  8 ms / 32 KiB coalescing; the daemon answers ConPTY's startup cursor
  query, without which the child never draws
- bots get the user's default environment block, not the daemon's
- rpc: bots.restart, terminal.attach/detach/write/resize, bot.state
  and terminal.data notifications; runtimeError in system.status

Tested with Claude Code 2.1.283: trust dialog, launching -> idle ->
busy -> idle through real hooks, rules loaded, deny rule on secrets,
reattach replays only missing bytes, bot dies with the daemon.
Spec and CLAUDE.md updated with these findings.
@httpsphl
httpsphl merged commit ac00970 into main Sep 28, 2026
3 checks passed
@httpsphl
httpsphl deleted the feat/m2-runtime branch September 28, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant