Remote Sessions
Bahulam sessions can survive Ctrl-D, terminal restarts, and SSH drops.
Other terminals — and paired mobile devices — can attach to a running
session, watch events live, and answer approvals. This is what PRD-092
delivers.
Three moving pieces:
- Daemon — a background bahulam process that owns a session’s event
stream. Written to
~/.bahulam/sessions/<sess_id>/events.jsonlin strict monotonic order. - Attach client —
bahulam attach <sess_id>in any terminal mirrors the live stream and lets you approve / interrupt. - Relay — an optional hosted WebSocket (
gateway.bahulam.ai/relay) that lets a paired mobile device do the same over the internet.
Nothing here is enabled by default. The classic bahulam REPL still
works the same. Opt-in one env var (BAHULAM_DAEMON_EVENTLOG=1) or one
subcommand (bahulam daemonize) to turn the daemon on.
Quick start
# 1. Sign in as usual
bahulam login
# 2. Run a task in the background
bahulam daemonize "refactor foo.py to use async"
# → ✓ sess_mt77sz6e_af2cd3e1 (attach: bahulam attach sess_mt77sz6e_af2cd3e1)
# 3. In another terminal (or another SSH session): watch it work
bahulam attach sess_mt77sz6e_af2cd3e1That’s it. You’ve decoupled your terminal from the running agent.
The Daemon
Turning it on
The daemon is off by default. Two ways to activate it:
BAHULAM_DAEMON_EVENTLOG=1 bahulam— normal interactive REPL, plus a Unix socket at~/.bahulam/sockets/<sess_id>.sock(perms0600) that attach clients can connect to.bahulam daemonize [prompt]— fork a detached background bahulam. Prints the new session id and exits. Seebahulam listto find it later.
Session storage
Each daemon-owned session gets its own directory:
~/.bahulam/sessions/<sess_id>/
├── meta.json cwd, model, product, opened_at, pid, sock_path
├── events.jsonl append-only, monotonic seq, one event per line
├── snapshot-<seq>.json periodic compaction for fast attach replay
├── approvals/ pending + decided approvals
└── daemon.pid pid of the owning daemonThe event log rotates at 100 MB (events.jsonl → events-1.jsonl,
events-2.jsonl, …) and readers concatenate in seq order. Malformed
lines from a torn write are skipped with a stderr warning; ordering
never breaks.
Listing / stopping
bahulam list # all daemon sessions with cwd, model, pid
bahulam stop <sess_id> # sends SIGTERM; daemon flushes and exitsAttaching from Another Terminal
bahulam attach <sess_id>You’ll see a banner, a replay of everything that happened since
you last left (batched as … replayed N event(s)), then live events
as they arrive.
Keyboard shortcuts inside the attach client:
| Key | Action |
|---|---|
a | Approve the latest pending approval |
d | Deny the latest pending approval |
Ctrl-C | Send interrupt — cancels the current turn |
Ctrl-D or q | Bye (detach); daemon keeps running |
Attach works from any terminal on the same machine that shares the
user’s home directory (SSH into a remote box, bahulam attach there,
mirror a session running on a laptop you’re SSH’d back to, etc.).
Auto-attach
Set BAHULAM_AUTO_ATTACH=1 and bahulam (no args) in a directory
where a live daemon exists will attach to it automatically instead of
starting a new REPL. bahulam --no-attach overrides even with the env
var set. This flag is opt-in so existing muscle memory (bahulam →
fresh REPL) is preserved for users who haven’t opted in.
Multi-attach + Input Lock
Two attach clients on the same session both see the same event stream.
Exactly one holds the input lock at a time — the right to
interrupt, send_message, switch_model. Others are watchers.
- The first attach implicitly holds the lock.
- A second attach joins in watch mode. It can still
approve/deny(that’s authorization, not typing), but not send messages or interrupt. - To take over, send
take_input_lock(mobile clients expose a button for this). There’s a 3-second grace during which the current holder canrelease_input_lockgracefully, then the transfer is forced.
State transitions broadcast as input_lock_changed events, so every
attach knows who’s typing.
Approval Timeouts
By default, the agent waits forever on an approval prompt. For unattended
runs this can hang. Set BAHULAM_APPROVAL_TIMEOUT to a policy string:
| Policy | Effect |
|---|---|
hold | (default) Never times out. |
deny:300 | Auto-deny after 300 seconds if no human answers. |
allow:300 | Auto-approve after 300 seconds. Only meaningful when combined with --dangerously-skip-permissions — the CLI won’t silently escalate for you. |
The timeout only fires when no attach is present. A watching human just answering the prompt races the timer and wins.
Pair a Device (mobile / second machine)
To connect a phone or a second laptop, you pair the two devices once, then enable the relay.
# On DEVICE A (already running bahulam):
bahulam pair get-code
# 123456
# Enter this code on the other device with:
# bahulam pair 123456
# On DEVICE B (new device, signed in as the same user):
bahulam pair 123456 "phone-work"
# ✓ device paired
# id: device_a1b2c3d4e5f60708
# peers: 1 (device_...)Ed25519 keys are generated per-device by Node’s built-in crypto — no npm dependency and the private key never leaves the machine.
Under the hood: POST /v1/devices/pair on gateway.bahulam.ai
registers the device, returns the peer pubkeys, and writes both into
your Supabase account (RLS-scoped to your user_id).
List / revoke
bahulam device list # every paired device + status
bahulam device whoami # this device's id + pubkey fingerprint
bahulam device revoke <device_id> # kill switchAny paired device can revoke any other. Revoking a device flips
status → revoked in Supabase and marks every session grant revoked.
The daemon on the revoked machine polls this every 10s and drops its
relay connection.
Revoking THIS device also clears local pairing state, so a daemon here stops trying to reconnect with a dead key.
Enable the Relay
Pairing establishes trust; enabling the relay actually connects a
running daemon to wss://gateway.bahulam.ai/relay/session/<sess_id> so
paired devices can see and control it.
bahulam remote enable # daemon dials the relay on next session start
bahulam remote status
# enabled: yes
# relay: wss://gateway.bahulam.ai
# device: device_a1b2c3d4e5f60708 (phone-work)
bahulam remote disable # kill switch — drops connection within one 10s heartbeatdisable is a hard local switch: even if the mobile side is compromised,
disabling remote here stops all outbound traffic.
What travels the relay
Every event the daemon writes to its local events.jsonl also goes
out to the relay as an envelope:
{
"v": 1,
"sess": "sess_...",
"from": "device_a1b2c3d4e5f60708",
"to": "session",
"control": { <PRD-092 event as JSON> }
}The relay routes envelopes between paired devices for your user_id.
It never decrypts payloads — envelope headers (v, sess, from,
to) are cleartext for routing; the payload will be end-to-end
encrypted with ChaCha20-Poly1305 in a future update (envelope format
already reserves the aead: {alg, nonce, ct} field for the swap).
Kill switches
Three ways to cut off remote access:
bahulam remote disable— local flag; daemon disconnects within 10s.bahulam device revoke <device_id>— server-side revocation; the relay refuses reconnection attempts and every daemon polling the revocation list drops within 10s.BAHULAM_RELAY_KILL=1 bahulam— hard runtime lock; the daemon refuses to dial regardless ofremote.enabled.
Mobile Companion
Install the Bahulam mobile PWA at m.bahulam.ai. Once installed on
the home screen, it works offline for the last-seen snapshot and shows:
- Session list — cloud + local, with origin badges and pending-approval dots.
- Session detail — live event feed, approval cards (one-tap approve/deny), control bar (interrupt, send message once wired, switch model), input-lock badge, diff previews, sub-agent tree.
Push notifications for approval_required land in a future update.
Security summary
- Nothing is enabled by default. Both the daemon (
BAHULAM_DAEMON_EVENTLOG=1orbahulam daemonize) and the relay (bahulam remote enable) are opt-in per user. - Local socket perms —
~/.bahulam/sockets/*.sockat0600, owning UID only. - Pairing — short-code exchange authenticated by your Bahulam Supabase JWT. 6-digit codes are SHA-256 hashed at rest, expire in 5 minutes, one-time use with a claim-race guard.
- Per-device Ed25519 keys — generated on-device by Node’s built-in crypto. Private key stays local; only the pubkey is uploaded.
- Relay is dumb transport — envelope headers only, never plaintext payloads. AEAD swap for the payload is in-progress (Phase 2.5).
- Audit trail — every approval decision records the deciding
attach or device id in
approvals/decided.jsonl. - Kill switches in three directions (§ Kill switches above).
Full protocol spec: PRD-092-relay-protocol.md (in the platform-docs repo, sections §3 envelope, §4 AEAD, §5 events, §6 commands, §8 pairing).
Environment variables
| Variable | Effect |
|---|---|
BAHULAM_DAEMON_EVENTLOG=1 | Start a socket server + write PRD-092 events to disk on session_info. |
BAHULAM_AUTO_ATTACH=1 | bahulam in a cwd with a live daemon → auto-attach. |
BAHULAM_APPROVAL_TIMEOUT=<policy> | hold (default), deny:<sec>, or allow:<sec>. |
BAHULAM_RELAY_KILL=1 | Refuse to dial the relay even when remote.enabled = true. |
BAHULAM_GATEWAY_URL | Override the gateway base (default https://gateway.bahulam.ai). |
BAHULAM_RELAY_URL | Override the relay base (default wss://relay.bahulam.ai). |
Troubleshooting
bahulam list shows “No daemon sessions.” — either no daemon is
running for your user, or the daemon didn’t write meta.json yet.
Verify with ls ~/.bahulam/sessions/.
bahulam attach <id> says “No socket at …” — the daemon has
exited. Its event log stays on disk for post-mortem; use
cat ~/.bahulam/sessions/<id>/events.jsonl to see what happened.
bahulam remote status shows enabled: yes but the mobile can’t
see it. — check bahulam device whoami matches an entry in
bahulam device list with status: active. Then confirm the daemon
process is actually running (bahulam list), because the relay dial
only starts when a session’s session_info event fires.
Watcher trying to interrupt gets not_input_holder. — expected.
Send take_input_lock first (mobile: use the input-lock button);
the current holder has 3s to release gracefully or you steal.