CodeRemote Sessions

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.jsonl in strict monotonic order.
  • Attach clientbahulam 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_af2cd3e1

That’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 (perms 0600) that attach clients can connect to.
  • bahulam daemonize [prompt] — fork a detached background bahulam. Prints the new session id and exits. See bahulam list to 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 daemon

The event log rotates at 100 MB (events.jsonlevents-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 exits

Attaching 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:

KeyAction
aApprove the latest pending approval
dDeny the latest pending approval
Ctrl-CSend interrupt — cancels the current turn
Ctrl-D or qBye (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 can release_input_lock gracefully, 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:

PolicyEffect
hold(default) Never times out.
deny:300Auto-deny after 300 seconds if no human answers.
allow:300Auto-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 switch

Any 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 heartbeat

disable 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 of remote.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=1 or bahulam daemonize) and the relay (bahulam remote enable) are opt-in per user.
  • Local socket perms~/.bahulam/sockets/*.sock at 0600, 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

VariableEffect
BAHULAM_DAEMON_EVENTLOG=1Start a socket server + write PRD-092 events to disk on session_info.
BAHULAM_AUTO_ATTACH=1bahulam in a cwd with a live daemon → auto-attach.
BAHULAM_APPROVAL_TIMEOUT=<policy>hold (default), deny:<sec>, or allow:<sec>.
BAHULAM_RELAY_KILL=1Refuse to dial the relay even when remote.enabled = true.
BAHULAM_GATEWAY_URLOverride the gateway base (default https://gateway.bahulam.ai).
BAHULAM_RELAY_URLOverride 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.