Documentation
¶
Overview ¶
C1 — DSR/DA/XTWINOPS query stubber.
When the daemon spawns a child (claude, vim, less, …) with NO iOS client attached, the child still emits attach-time capability queries — Primary Device Attributes (DA1), Secondary (DA2), Cursor-Position Report (DSR 6), Status Report (DSR 5), XTWINOPS window-size queries — to negotiate what the terminal supports. With no client attached and no daemon-side VT, the queries go unanswered; the child times out and falls back to a degraded mode (dumb terminal, no color, no cursor positioning) whose bytes get baked into the ring. iOS reconnecting later replays those degraded bytes — VT rebuild cannot recover capabilities the child already gave up on so terminal capability probes cannot stall an unattended PTY.
The stubber is the minimal mitigation: it scans the PTY output stream for the common attach-time queries and writes a fixed, plausible reply back to the PTY so the child sees "a VT220 + ANSI color terminal answered" and renders in color from the very first byte. This is NOT a full VT:
- Answers are stateless (cursor always reports 1;1, winsize always the spawn default 24×80). When iOS attaches + resizes, the child gets SIGWINCH and re-queries — at which point SwiftTerm on iOS answers authoritatively. The stubber's stale answers are a transient that the resize corrects.
- Only CSI queries (ESC[…<final>) are answered. OSC (ESC]…) and DCS (ESC P…) are ignored; most programs do not block on those. Documented gap, revisit if the POC smoke test shows a hang.
Single-producer: Scan is called only from Session.readLoop, so the carryover buffer needs no mutex.
Package pty owns the daemon's per-session PTY machinery: process lifecycle, the raw-byte ring buffer used for reconnect replay, the stateless DSR/DA query stubber, and multi-client resize reconciliation. The daemon stays full-VT-free forever — only byte forwarding and stateless query answers live here; the client owns the actual VT and runs agent detection against its own snapshot.
Session lifecycle matches the previous implementation's NativePtySession:
- The child is spawned as a session + process-group leader via creack/pty's forced Setsid (see Start for why Setpgid is intentionally not also set), so kill(-pgid) reaches grandchildren (claude → node, vim → shellescape subshells). Without this, a SIGTERM to the direct child leaves orphaned grandchildren pinning the PTY open.
- Destroy escalates SIGTERM → 3 s grace → SIGKILL on the whole group, preventing stubborn descendants from retaining the PTY.
- The ring captures every output byte for warm reconnect; the daemon never interprets them as terminal state.
Index ¶
- Constants
- Variables
- func NewID() string
- type Config
- type InputHook
- type Manager
- func (m *Manager) Create(cfg Config) (*Session, error)
- func (m *Manager) Delete(id string)
- func (m *Manager) Get(id string) (*Session, bool)
- func (m *Manager) InjectOutput(id string, data []byte)
- func (m *Manager) KillAll()
- func (m *Manager) Range(f func(*Session))
- func (m *Manager) SetInputHook(hook InputHook)
- type QueryStubber
- type Ring
- func (r *Ring) BytesSince(offset uint64) (data []byte, nextOffset uint64, gap bool)
- func (r *Ring) Capacity() int
- func (r *Ring) Clear()
- func (r *Ring) Offset() uint64
- func (r *Ring) Recent(max int) []byte
- func (r *Ring) Snapshot() []byte
- func (r *Ring) SnapshotWithOffset() (snap []byte, offset uint64)
- func (r *Ring) Write(p []byte) (newTotalWritten uint64, dropped bool)
- type Session
- func (s *Session) Done() <-chan struct{}
- func (s *Session) DriverState() (hasDriver bool, rows, cols uint16)
- func (s *Session) ExitCode() int
- func (s *Session) ID() string
- func (s *Session) InjectOutput(data []byte)
- func (s *Session) Input(p []byte) error
- func (s *Session) Kill(reason string) error
- func (s *Session) LastNotify() []byte
- func (s *Session) Pid() int
- func (s *Session) PushState(frame []byte)
- func (s *Session) RawInput(p []byte) error
- func (s *Session) RecentOutput(max int) []byte
- func (s *Session) RecordSubscriberResize(ch chan []byte, rows, cols uint16)
- func (s *Session) Resize(rows, cols uint16) error
- func (s *Session) ResizeDebounced(rows, cols uint16)
- func (s *Session) RevertTitle()
- func (s *Session) Ring() *Ring
- func (s *Session) SetOnCmdDone(fn func(exitCode *int))
- func (s *Session) SetOnCommandAgent(fn func(agent string))
- func (s *Session) SetOnTitle(fn func())
- func (s *Session) StateCh(ch chan []byte) <-chan []byte
- func (s *Session) Subscribe() chan []byte
- func (s *Session) SubscribeSnapshot() (ch chan []byte, snap []byte, offset uint64, ok bool)
- func (s *Session) Title() string
- func (s *Session) Unsubscribe(ch chan []byte)
Constants ¶
const DefaultRingCapacity = 2 * 1024 * 1024 // 2 MB
DefaultRingCapacity is the per-session byte budget — large enough to cover a typical multi-minute agent turn for warm reconnect, small enough to bound memory across many sessions. Bytes past the ring are gone forever; the daemon cannot re-pull them from the PTY, so a reconnect that finds its offset rotated below the ring tail must fall back to whatever the client-side VT still has.
Variables ¶
var ErrSessionClosed = errors.New("pty: session closed")
ErrSessionClosed is returned by Input/Resize after Kill or after the child has exited naturally. Callers may treat it as a clean end-of-session.
Functions ¶
func NewID ¶
func NewID() string
NewID mints a UUIDv4 string (crypto/rand, no external dep). Format matches what iOS expects for session IDs (UUID parseable) and what the previous implementation mints server-side. Exported so the sessions.Manager can mint an ID BEFORE spawn (needed to inject RMOTE_SESSION_ID=<id> into the child env).
Types ¶
type Config ¶
type Config struct {
ID string
Command string
Args []string
Env []string // nil → scrubEnv(os.Environ())
Dir string // empty → current dir
Rows uint16
Cols uint16
}
Config describes a session to spawn. Command is the executable path; Args excludes argv[0]. Env, if nil, defaults to a scrubbed copy of os.Environ (see scrubEnv). Rows/Cols seed the initial PTY winsize; the first WS client to attach typically resizes again.
type InputHook ¶
InputHook intercepts user input before it reaches the PTY. Returns passThrough (bytes to write to PTY; nil = all intercepted) and echo (bytes to show the client via InjectOutput). Used for peer-messaging interception.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager owns the live PTY sessions. Goroutine-safe.
func (*Manager) Create ¶
Create spawns a new session and registers it under cfg.ID (a fresh UUIDv4 is minted via NewID if cfg.ID is empty). Returns the session. The session is auto-removed from the manager when the child exits naturally.
func (*Manager) Delete ¶
Delete kills and removes a session by ID. Idempotent — missing ID is a no-op. Used by the future DELETE /api/sessions/{id} route.
func (*Manager) Get ¶
Get returns the live session by ID. A session that has exited is returned as !ok so callers (the WS handler) reject reconnects to dead IDs rather than subscribing to a closed chan.
func (*Manager) InjectOutput ¶
InjectOutput sends bytes to a session's live WS subscribers without writing to the ring buffer. Used for ephemeral confirmation echoes (peer-message delivery). No-op if the session doesn't exist.
func (*Manager) KillAll ¶
func (m *Manager) KillAll()
KillAll terminates every live session. Used at daemon shutdown so the launchd restart path doesn't leave orphaned children across the epoch bump.
func (*Manager) Range ¶
Range calls f on every live session. It snapshots the map under the lock, then invokes f outside the lock so a slow f cannot block Create/Delete/Get. Used to fan out a control frame (e.g. MSG_UNIFIED_UPDATE) to every session's WS subscribers. f must not acquire the manager lock.
func (*Manager) SetInputHook ¶
SetInputHook sets a global input interceptor on the manager. New sessions inherit it at creation. Existing sessions are updated too. Used for peer-messaging input interception (#name message).
type QueryStubber ¶
type QueryStubber struct {
// contains filtered or unexported fields
}
QueryStubber scans PTY output for attach-time capability queries and emits fixed replies. The carryover buffer holds a trailing partial sequence (e.g. a chunk ending with "\x1b[" alone) so a query split across two PTY reads is still answered.
func (*QueryStubber) Scan ¶
func (q *QueryStubber) Scan(chunk []byte) []byte
Scan inspects chunk for query sequences and returns the concatenated reply bytes (nil if no query matched). The caller writes the reply back to the PTY so the child sees the answer. Partial sequences at the chunk boundary are retained internally and prepended to the next chunk.
The returned slice may be a fresh allocation or reuse internal scratch; callers must not retain it across calls.
type Ring ¶
type Ring struct {
// contains filtered or unexported fields
}
Ring is a fixed-capacity circular byte buffer for PTY output. Oldest bytes are silently overwritten when full (drop-oldest). It tracks a monotonic totalWritten counter so a reconnecting client can request "bytes since offset O" — the foundation of warm delta reconnect.
Implements behavior formerly owned by RingBuffer.swift. Two intentional omissions vs the previous implementation:
- withTotalWrittenLocked existed only to atomically tag a VT snapshot with its ring offset. The daemon runs no VT, so plain BytesSince (returning the offset captured under the same lock as the data) is sufficient — a separate Offset() call after a slow chunked send would double-count bytes produced mid-send.
- recentSnapshot is renamed Recent for Go idiom.
All methods are goroutine-safe via a single mutex; critical sections are tiny (copy + arithmetic) so contention is negligible at the daemon's expected scale (one PTY writer + a handful of WS readers per session).
func New ¶
New constructs a ring of the given capacity. Panics on capacity <= 0: a zero-capacity ring can neither store nor replay anything and would panic on the modulo in Write; that is a programmer error, not a runtime state.
func (*Ring) BytesSince ¶
BytesSince returns the bytes from offset up to the current write head, plus the offset captured atomically under the same lock as the data snapshot (the client's next resume anchor). gap is true when offset is out of range — either rotated out (offset < oldest retained) or in the future (offset > totalWritten, e.g. after a daemon restart with stale state) — in which case data is nil and the caller must fall back to a full replay.
A nil result with gap=false means the client is already caught up (offset == totalWritten); the WS handler sends an empty delta, not a replay. The returned slice is a fresh copy; callers may mutate freely.
func (*Ring) Clear ¶
func (r *Ring) Clear()
Clear drops all retained bytes without resetting the monotonic offset — used on terminal `/clear`. A client that reconnects after a clear must not receive pre-clear history: with filled=0, any pre-clear offset falls below start (== totalWritten) and forces a full replay, which is itself blank because the client-side VT was also reset. Live bytes written after the clear rebuild the ring normally.
func (*Ring) Offset ¶
Offset returns the current monotonic write offset (totalWritten). Safe to call from any goroutine.
func (*Ring) Recent ¶
Recent returns at most max of the most recent bytes (for capped full replay when a client requests more than the ring retains). If max <= 0 or the buffer holds fewer than max bytes, the whole snapshot is returned.
func (*Ring) Snapshot ¶
Snapshot returns the entire retained buffer in chronological (write) order. Used by the full-replay path (when BytesSince reports a gap) and by tests. Returns a fresh slice; caller may mutate.
func (*Ring) SnapshotWithOffset ¶
SnapshotWithOffset returns the entire retained buffer AND the monotonic offset captured under the same lock — atomically. This is the variant SubscribeSnapshot needs: Snapshot() followed by Offset() would let a readLoop write land between the two calls, making the offset not match the snapshot (iOS would anchor against a future offset and miss the tail bytes on next reconnect).
Without this atomic pair, the WS replay path has a second, more severe bug: SubscribeSnapshot used BytesSince(0), which returns nil once the ring has wrapped past offset 0 (totalWritten > filled ⟹ start > 0 ⟹ offset 0 is "rotated out" ⟹ gap). For an always-on session — the plan's headline use case — the 2 MB ring wraps within minutes, and every reconnect after that delivered an EMPTY replay. SnapshotWithOffset sidesteps BytesSince's offset-range semantics entirely: it always returns whatever the ring retains, paired with the true high-water offset.
func (*Ring) Write ¶
Write appends p to the ring, overwriting oldest bytes if full. Returns the new totalWritten (the high-water mark after this write — also the resume offset a client uses for any byte it has not yet seen) and dropped (true if any byte was lost: ring overflow OR input larger than capacity).
An empty write is a no-op and returns the current offset unchanged with dropped=false.
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session is a live PTY owned by the daemon. Goroutine-safe after Start returns; all public methods may be called concurrently.
Ownership:
- ptmx: master FD. Read by readLoop; written by Input; closed by cleanup exactly once (closeOnce).
- ring: ring buffer of every output byte. Fed by readLoop; read by WS replay/delta handlers.
- subs: live-output fan-out. Fed by readLoop; drained by WS per-client goroutines (ws phase). Drop-oldest on full.
func Start ¶
Start spawns the configured child under a fresh PTY and begins the read loop. The caller owns the returned *Session and MUST call Kill when done (or rely on the child exiting naturally + Done() being observed).
Returns an error if the spawn fails; the returned *Session is nil in that case so the caller cannot accidentally use a half-built session.
func (*Session) Done ¶
func (s *Session) Done() <-chan struct{}
Done is closed when the read loop has exited and cleanup has run. After Done is closed, all subscribers have been dropped and Input/Resize return ErrSessionClosed.
func (*Session) DriverState ¶
DriverState returns the current driver flag + size. The WS handler builds the MsgControlState frame from this both for live broadcast (after a resize) and for reconnect replay (alongside the last MSG_NOTIFY), so a joining client learns whether someone is already driving and at what width — the basis of its "tap to take control" chip and its first-layout takeover decision.
func (*Session) ExitCode ¶
ExitCode returns the child's exit code once available, or -1 if the session is still running. Meaningful only after Done is closed.
func (*Session) InjectOutput ¶
InjectOutput sends data to all current subscribers as terminal output, WITHOUT writing to the ring buffer. Used for peer-messaging echo: the user sees what they type in peer mode, but the bytes are ephemeral (not in scrollback on reconnect). Equivalent to fanoutLocked without ring.Write.
func (*Session) Input ¶
Input processes user input through the interceptor hook (if set), then writes to the PTY. Used by the WS handler for client keystrokes. When the hook intercepts input (peer messaging), the bytes are not written to the PTY — the agent never sees them.
func (*Session) Kill ¶
Kill ends the session: SIGTERM the process group, escalate to SIGKILL after killGrace if grandchildren survive, then wait for readLoop to exit. Idempotent — a second call returns nil immediately. The reason string is logged for diagnostics only.
func (*Session) LastNotify ¶
LastNotify returns the most recent MSG_NOTIFY (0x05) frame, or nil if none has been pushed. The WS handler replays it to a (re)subscribing client so notify-derived state resyncs after a dropped connection — notably the tab-bar thinking indicator, which clears only on the turn_done notify that a backgrounded iOS client otherwise misses.
func (*Session) Pid ¶
Pid returns the child process's PID. Used by git routes to resolve the shell's LIVE working directory (which changes on `cd`) via /proc or lsof.
func (*Session) PushState ¶
PushState sends a pre-framed control message to WS subscribers. The frame is the complete WS binary payload: [type byte][payload]. Drop-oldest if the chan is full (state updates are idempotent — the latest wins). Used by the hook ingest to push MSG_CLAUDE_STATE when an agent lifecycle event arrives (SessionStart → skills bar appears, SessionEnd → clears).
func (*Session) RawInput ¶
rawInput writes keystroke/paste bytes directly to the PTY master WITHOUT interception. Used by the daemon for programmatic input (peer message injection, session seeding, resume launch lines) so injected text starting with '#' is not re-intercepted. Concurrent writes are serialized by writeMu.
func (*Session) RecentOutput ¶
RecentOutput returns up to max of the most recent raw PTY output bytes for this session, drawn from the reconnect ring. The summarizer feeds this tail to the Foundation Model as a "what's happening now" observation. Returns a fresh copy the caller may mutate; safe from any goroutine (the ring is mutex-guarded). Matches Ring.Recent: max<=0 or fewer-than-max retained bytes returns the whole snapshot.
func (*Session) RecordSubscriberResize ¶
RecordSubscriberResize is the "tap to drive" (Option D) entry point. A MsgResize means the caller is taking control of the shared PTY: the PTY is sized to the caller's own dimensions (last-resizer-wins) and the caller becomes the driver. The WS handler broadcasts the new driver state (MsgControlState) right after this so non-driver clients update their chip.
WHY this replaced smallest-attached-wins: smallest-wins depended on every client auto-reporting its size on every connect, which client doesn't do (connect sends no resize; the re-announce hook only fires on cold replay, not warm delta, and is absent on Mac). A reconnecting client's fresh chan never entered subSizes → it was skipped → the PTY stuck at Mac width. Under "tap to drive", connecting sends nothing (so it can't get stuck), and a takeover is a deliberate resize that always fires. ch is the subscriber chan from SubscribeSnapshot, used purely to confirm the caller is still attached.
func (*Session) Resize ¶
Resize updates the PTY window size and sends SIGWINCH to the foreground process group. Raw mechanism — single Setsize under writeMu. WS callers go through RecordSubscriberResize (the "tap to drive" entry), which feeds this via ResizeDebounced to coalesce drag storms; a resize = taking control of the shared PTY at the caller's dimensions.
Setsize is guarded by writeMu because creack/pty.Setsize calls ptmx.Fd(), which reads the underlying poll.FD.Sysfd WITHOUT the fd-mutex that Read/ Write/Close use internally — so an unguarded Setsize races cleanup's Close.
func (*Session) ResizeDebounced ¶
ResizeDebounced queues a resize, collapsing any further ResizeDebounced calls inside the next 200ms into one final Setsize — last-value-fed wins within the window. Under "tap to drive" the value fed is always the current driver's own size, so "last wins" simply means the most recent driver.
Returns immediately; the actual Setsize runs on a timer goroutine. Errors from the deferred Setsize are swallowed (best-effort — a dead session's resize failure is not actionable by the caller).
func (*Session) RevertTitle ¶
func (s *Session) RevertTitle()
RevertTitle clears the captured OSC title and fires the onTitle callback so sessions.Manager.List() stops feeding the stale title into the tab name. Used on agent exit (revertTitleOnAgentExit) AND when an agent session ID changes mid-process (e.g., /clear generates a new session ID without the process exiting — the old prompt-based title would otherwise persist on the tab, LA, and notifications until the agent emits a new one).
func (*Session) Ring ¶
Ring returns the session's output ring buffer. WS replay/delta handlers call BytesSince to serve warm reconnects.
func (*Session) SetOnCmdDone ¶
SetOnCmdDone wires a callback fired when the OSC 133;D shell-integration marker is detected (command finished). The callback receives the exit code (nil if the marker carried none). Used by sessions.Manager to bridge cmdDone to the hooks.Bus → iOS long-poll → push notification path. Safe to call after Start (the readLoop checks the callback under cmdDoneMu on each fire, so a late wire takes effect on the next completion).
func (*Session) SetOnCommandAgent ¶
SetOnCommandAgent wires a callback fired when the foreground agent for this tab changes (codex/grok/claude). The manager relabels the tab so iOS's status-bar chip + skills bar render the agent that's actually running — without waiting for the agent's SessionStart hook (codex/grok only hook on the first turn). Fires only on CHANGE: a new agent (codex→grok overrides) AND "" when the agent exits so the manager reverts the tab to shell and the chip falls back to the default. Driven by the debounced process-tree walk in the read loop (scheduleFgCheck) — see fgagent.go.
func (*Session) SetOnTitle ¶
func (s *Session) SetOnTitle(fn func())
SetOnTitle wires a callback when OSC title output changes. The callback is invoked by the read loop after the title lock is released.
func (*Session) Subscribe ¶
Subscribe returns a new output channel fed with live PTY bytes (drop-oldest on slow subscriber). The caller MUST call Unsubscribe when the tab closes to release the buffer. Returns nil if the session is already done — a nil channel signals "session over" cleanly to a select.
Prefer SubscribeSnapshot for WS reconnect: it returns the chan AND the ring snapshot atomically, so a byte produced mid-subscribe cannot land in both the snapshot AND the live chan (which would duplicate on iOS render). Subscribe is kept for tests that don't care about the snapshot.
func (*Session) SubscribeSnapshot ¶
SubscribeSnapshot registers a subscriber chan AND captures the ring snapshot+offset under s.mu — atomically w.r.t. readLoop's ring.Write + fanoutLocked (which run under the same lock). Without this atomicity, a byte produced between "register chan" and "snapshot ring" would land in BOTH the snapshot (replayed as initial scrollback) AND the live chan (delivered as MsgOutput after replay) → iOS SwiftTerm renders it twice.
The WS handler sends `snap` as the full replay bracket, then drains `ch` for live bytes; the two byte streams are disjoint by construction. Returns ok=false if the session is already done (ch=nil, snap=nil).
Uses Ring.SnapshotWithOffset (NOT BytesSince(0)) because BytesSince(0) returns nil once the ring wraps past offset 0 — an always-on session's 2 MB ring wraps within minutes, and the pre-fix path delivered an EMPTY replay on every reconnect after the first wrap. SnapshotWithOffset always returns the retained bytes paired with the true high-water offset.
func (*Session) Title ¶
Title returns the last OSC 0/2 title captured from the PTY output (set by shells on every prompt redraw). Empty until the shell emits its first title. Used by sessions.Manager.List() for tab auto-naming — the title typically reflects user@host:cwd, giving each tab a meaningful name without the user typing one.
func (*Session) Unsubscribe ¶
Unsubscribe removes and drains the channel. Safe to call with a channel that was never subscribed or already removed (no-op). Idempotent so the WS handler can defer it unconditionally.