Documentation
¶
Overview ¶
Package session holds the state machine (kb:anchor/state) and the in-memory session registry that feeds it. It operates purely on claudecode.StateInput values and its own neutral fields — no Claude Code payload key or event name appears here (CLAUDE.md hard rule).
Index ¶
- Constants
- Variables
- func CheckSessionFieldCoverage() []string
- func Elsewhere(launchTop, claudeTop *string, launchDir, claudeDir string) bool
- func NormalizeGroupName(raw string) (string, bool)
- func ValidPermissionMode(s string) bool
- type Attention
- type BatchResult
- type Config
- type Context
- type CreateParams
- type Failure
- type Group
- type GroupDeleteResult
- type GroupDisposition
- type GroupRef
- type InterruptChecker
- type Location
- type LocationRepo
- type Manager
- func (m *Manager) AliveByClaudeSessionID(claudeSessionID string) (int64, bool)
- func (m *Manager) Apply(ctx context.Context, musterSessionID int64, claudeSessionID string, ...) (*Session, error)
- func (m *Manager) ApplyPlanScan(ctx context.Context, id int64, claudeSessionID, foundPath string) (*Session, bool, error)
- func (m *Manager) ApplyStatus(ctx context.Context, musterSessionID int64, update claudecode.StatusUpdate) (*Session, error)
- func (m *Manager) CreateGroup(ctx context.Context, name string, sessionIDs []int64) (Group, error)
- func (m *Manager) CreateLaunchGroup(ctx context.Context, name string) (Group, error)
- func (m *Manager) CreateSession(ctx context.Context, p CreateParams) (*Session, error)
- func (m *Manager) DeleteGroup(ctx context.Context, id int64, disposition GroupDisposition, to int64) (GroupDeleteResult, error)
- func (m *Manager) DeleteSession(ctx context.Context, id int64) error
- func (m *Manager) DiscardLaunchGroup(ctx context.Context, id int64) error
- func (m *Manager) End(ctx context.Context, id int64) (*Session, error)
- func (m *Manager) EndAll(ctx context.Context) int
- func (m *Manager) EndMany(ctx context.Context, ids []int64) (BatchResult, error)
- func (m *Manager) Exists(id int64) bool
- func (m *Manager) Get(id int64) (*Session, bool)
- func (m *Manager) GroupExists(id int64) bool
- func (m *Manager) Groups() ([]Group, UngroupedLayout)
- func (m *Manager) KillAllShells(ctx context.Context) (int, error)
- func (m *Manager) List() []*Session
- func (m *Manager) LoadAll(ctx context.Context) error
- func (m *Manager) LockClaudeSession(claudeSessionID string) (unlock func())
- func (m *Manager) LockSession(id int64) (unlock func())
- func (m *Manager) MarkPlanWritten(ctx context.Context, id int64, claudeSessionID, expectedPath string) (*Session, bool, error)
- func (m *Manager) MarkSeen(ctx context.Context, id int64) error
- func (m *Manager) Nudge(ctx context.Context, sessionID int64)
- func (m *Manager) PaneOf(id int64) (pane string, ok bool)
- func (m *Manager) Reconcile(ctx context.Context) ReconcileReport
- func (m *Manager) RecordLaunch(ctx context.Context, id int64, tmuxTarget, tmuxPane string) (*Session, error)
- func (m *Manager) RecordResume(ctx context.Context, id int64, tmuxTarget, tmuxPane string) (*Session, error)
- func (m *Manager) Remove(ctx context.Context, id int64) error
- func (m *Manager) RemoveMany(ctx context.Context, ids []int64) (BatchResult, error)
- func (m *Manager) RepairOwnedSession(ctx context.Context, id int64) (*Session, error)
- func (m *Manager) RepoTargets() []RepoTarget
- func (m *Manager) Resolve(claudeSessionID string) (int64, bool)
- func (m *Manager) SetAllCollapsed(ctx context.Context, collapsed bool) error
- func (m *Manager) SetGroupsOrder(ctx context.Context, order []int64) error
- func (m *Manager) SetOrder(ctx context.Context, ids []int64, pinnedCount int, group *GroupRef) error
- func (m *Manager) SetPinned(ctx context.Context, id int64, pinned bool) error
- func (m *Manager) SetPlan(ctx context.Context, id int64, claudeSessionID, path string, exists bool) (*Session, bool, error)
- func (m *Manager) SetRepoState(ctx context.Context, id int64, state RepoState) error
- func (m *Manager) SetSessionsGroup(ctx context.Context, ids []int64, groupID *int64) error
- func (m *Manager) SetTitle(ctx context.Context, id int64, title *string) (bool, error)
- func (m *Manager) SetTranscript(ctx context.Context, id int64, claudeSessionID, path string) (bool, error)
- func (m *Manager) ShellCount(ctx context.Context) (int, error)
- func (m *Manager) ShellNames(ctx context.Context) ([]string, error)
- func (m *Manager) Snapshot(id int64) (text string, at time.Time, ok bool)
- func (m *Manager) Start()
- func (m *Manager) Stop(ctx context.Context)
- func (m *Manager) UpdateGroup(ctx context.Context, id int64, name *string, collapsed *bool) error
- type Model
- type PaneChecker
- type PaneSnapshotter
- type PermissionMode
- type ReconcileReport
- type RepoState
- type RepoTarget
- type Session
- type State
- type TmuxSessions
- type UngroupedLayout
- type Watcher
Constants ¶
const MaxGroupNameLen = 40
MaxGroupNameLen is a group name's length limit, in characters after trimming (kb:adr/rail-groups-daemon-rows-whole-list-broadcast).
Variables ¶
var ( // ErrUnknownGroup names a group id no group has (404 unknown_group); ErrUnknownTargetGroup // is the same for DeleteGroup's move target, so the response can say which one. ErrUnknownGroup = errors.New("unknown group") ErrUnknownTargetGroup = errors.New("unknown target group") // ErrInvalidGroupName is a name that is not 1-40 characters after trimming. ErrInvalidGroupName = errors.New("invalid group name") // ErrInvalidGroupOrder is an order list that does not name every group and Ungrouped // exactly once. ErrInvalidGroupOrder = errors.New("invalid group order") // ErrUngroupedSection is a rename or delete aimed at the Ungrouped section. ErrUngroupedSection = errors.New("the ungrouped section cannot be renamed or deleted") // ErrInvalidDisposition is a DeleteGroup disposition the manager cannot carry out: an // unknown value, a move without a target, or a move onto the group being deleted. ErrInvalidDisposition = errors.New("invalid delete disposition") )
Group errors the internal/server package branches on to pick an HTTP status.
var ( ErrUnknownSession = errors.New("unknown session") ErrSessionNotAlive = errors.New("session not alive") )
Sentinel errors the internal/server package branches on to pick an HTTP status (kb:anchor/sessions.resume / kb:anchor/sessions.end / kb:anchor/sessions.remove) — the one place callers of End/Remove/RecordResume must inspect a specific error rather than treating every failure alike.
var ErrInvalidOrder = errors.New("invalid order")
ErrInvalidOrder is the validation failure of the list-taking operations (kb:anchor/sessions.order): applyOrder for a duplicate or unknown id or a pinnedCount outside [0, len(ids)], validateSessionIDs for a repeated id in a group request, and runBatch (actions.go) for a repeated id in an End or Remove batch. The server package maps it to 400 invalid_request; validation happens before anything is computed or run. applyPin's unknown-id case reuses the existing ErrUnknownSession sentinel (manager.go) since the HTTP mapping is the same 404 unknown_session either way.
Functions ¶
func CheckSessionFieldCoverage ¶ added in v0.18.3
func CheckSessionFieldCoverage() []string
CheckSessionFieldCoverage reports every Session field name that restoredSessionFields and immutableSessionFields between them fail to account for exactly once: left out of both (a new mutable field that forgot to be added to restoreChangedFields, and would otherwise silently survive a failed write's restore), or named in both (ambiguous). An empty result means the two lists are a complete, non-overlapping partition of Session's actual fields — CLAUDE.md's "no init() magic" rule keeps this out of an init(), so a caller (daemon-tests: one assertion against an empty slice) runs it instead.
func Elsewhere ¶ added in v0.19.4
Elsewhere reports whether Claude's directory is a different checkout from the launch directory. Callers pass every path already symlink-resolved and cleaned, so this stays a pure rule; launchTop and claudeTop are the directories' git top levels, nil when not in a checkout.
Inside a checkout the top levels decide, with a nil launch top counting as different. A worktree under the launch path's own .claude/worktrees has its own top level, so it is elsewhere even though its path lies inside the launch directory. Outside any checkout the paths decide: elsewhere unless claudeDir is launchDir or inside it.
func NormalizeGroupName ¶ added in v0.20.0
NormalizeGroupName trims raw and reports whether the result is a valid group name: 1 to MaxGroupNameLen characters, counted in runes like a session title.
func ValidPermissionMode ¶ added in v0.18.3
ValidPermissionMode reports whether s is one of PermissionModes. Delegates to claudecode.ValidPermissionMode, the one owner of the underlying set (internal/server's launcher validates incoming requests against claudecode.PermissionModes directly) — one validation, not two.
Types ¶
type BatchResult ¶ added in v0.20.0
type BatchResult struct {
Done, Skipped, Failed []int64
}
BatchResult is what EndMany and RemoveMany did, by session id (kb:anchor/sessions.end-many, kb:adr/actions-bulk-stop-remove-are-daemon-batches). Skipped names a session that was unknown at its turn or, for End, no longer alive; Failed one whose kill genuinely failed, which a Remove leaves in place.
type Config ¶
type Config struct {
Store *store.Store
Logger zerolog.Logger
// PaneChecker, PaneSnapshotter and TmuxSessions are the tmux ports: required (see
// NewManager) — production always wires all three to the same *tmux.Client
// (internal/server/server.go).
PaneChecker PaneChecker
PaneSnapshotter PaneSnapshotter
TmuxSessions TmuxSessions
// OnUpsert, OnRemoved, OnGroups and Watcher are wired by production exactly like the three tmux
// ports above, but stay nil-tolerant rather than required: a nil tmux port has no safe
// meaning (NewManager panics on one), whereas a nil value here already has a real,
// defined meaning documented on the field itself (skip the broadcast; count every
// session as unwatched). Tests lean on that tolerance to build a Manager without wiring
// a websocket hub or a terminal registry.
OnUpsert func(*Session) // broadcasts a sessionUpsert; may be nil in tests
OnRemoved func(id int64) // broadcasts sessionRemoved (kb:anchor/ws.session-removed); may be nil in tests
// OnGroups broadcasts the whole group list and the Ungrouped layout (kb:anchor/ws.groups)
// after every change to either, outside mu like OnUpsert; may be nil in tests.
OnGroups func([]Group, UngroupedLayout)
Watcher Watcher // nil counts every session as unwatched
PollInterval time.Duration // 0 uses defaultPollInterval
// OnClaudeDirChange fires after Apply or ApplyStatus persisted a changed ClaudeDir, so
// the repo poll can derive claudeLocation without waiting out its interval. Nil-tolerant
// like OnUpsert. It must not block: it runs on the ingest worker.
OnClaudeDirChange func()
// InterruptChecker reports whether the transcript at path records an interrupt of
// promptID. Injected so this package never reads transcript bytes
// (kb:adr/lifecycle-interrupt-read-from-transcript); nil disables the interrupt sweep.
InterruptChecker InterruptChecker
}
Config wires a Manager. Built by internal/server; nothing here starts a goroutine until Start is called.
type Context ¶
Context is the session's live context-window gauge (kb:anchor/ws.session value semantics): UsedPct/TotalInputTokens/WindowSize are always all present together — a nil *Context means unknown, never a zero value. Populated only by a routed status-line post whose payload carries a non-null used-percentage; reset to nil by `/clear`.
type CreateParams ¶
type CreateParams struct {
RepoID int64
Directory string
Branch *string
IsWorktree bool
Title *string
PermissionMode PermissionMode
Model string
FirstLaunchHere bool
// GroupID is the group the new session joins, nil = Ungrouped; ErrUnknownGroup from
// CreateSession when it names none. The session lands at the end of that section.
GroupID *int64
// ResumeClaudeSessionID is set only by a resume-from-list spawn (launchResume): the
// Claude session id this new row was launched to resume, held via
// pendingResumeClaudeSessionID until its own bind lands
// (kb:adr/launch-resume-pending-hold-persisted). "" for an ordinary launch, which
// binds to whatever id Claude Code's own first SessionStart names instead.
ResumeClaudeSessionID string
// MinID floors the allocated id above this value (0 = no floor) — the launcher's
// MaxSessionID probe of the tmux socket, passed straight through to
// store.InsertSessionParams.MinID (kb:adr/lifecycle-session-ids-monotonic-never-reused).
MinID int64
}
CreateParams seeds a new launched session.
type Failure ¶
type Failure struct {
Error string // raw token, displayed verbatim, never switched on
Message string
}
Failure is non-nil iff State == StateFailed.
type Group ¶ added in v0.20.0
Group is a named, ordered, collapsible rail section the developer made (kb:spec/rail). Membership is each Session's GroupID, never a field here. Pos is the section's place among every section, Ungrouped included.
type GroupDeleteResult ¶ added in v0.20.0
type GroupDeleteResult struct {
Deleted bool
Sessions BatchResult
}
GroupDeleteResult is DeleteGroup's outcome: Deleted is false only when a remove left a member behind, and Sessions reports each member by outcome.
type GroupDisposition ¶ added in v0.20.0
type GroupDisposition string
GroupDisposition is what DeleteGroup does with the group's sessions.
const ( // DispositionUngroup moves the members to Ungrouped, keeping every railPos. DispositionUngroup GroupDisposition = "ungroup" // DispositionMove moves the members to the end of another group. DispositionMove GroupDisposition = "move" // DispositionRemove removes every member as DELETE /api/sessions/{id} does. DispositionRemove GroupDisposition = "remove" )
type GroupRef ¶ added in v0.20.0
type GroupRef struct {
ID *int64
}
GroupRef is a request's optional target section: a nil *GroupRef means the request named none, a non-nil one with a nil ID names Ungrouped. PUT /api/sessions/order's groupId needs all three of absent, null and an id.
type InterruptChecker ¶ added in v0.19.3
InterruptChecker reports whether the transcript at path records an interrupt of promptID. The one declaration of the seam: session.Config, server.Config and the Manager field all use it, so a signature change happens here.
type Location ¶ added in v0.19.4
type Location struct {
Directory string
// Repo is nil when Directory isn't a git checkout or HEAD is detached.
Repo *LocationRepo
}
Location is where Claude is working when that is a different checkout from the launch directory (kb:adr/lifecycle-card-shows-launch-directory-marks-claude-elsewhere): the resolved directory it last reported, and that checkout's repo readout.
type LocationRepo ¶ added in v0.19.4
LocationRepo is the checkout Location.Directory belongs to. Name is the basename of the checkout's top level.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager is the in-memory session registry and the kb:anchor/state state machine's home. Every mutation persists the full row and (unless OnUpsert is nil) broadcasts the fresh Session — the "whole-object sessionUpsert" design (kb:anchor/ws.session).
func NewManager ¶
NewManager builds a Manager. PaneChecker, PaneSnapshotter and TmuxSessions are all required: production always wires all three to the same *tmux.Client, and panicking here on a missing one means Reconcile's ownership classification has exactly one code path, never a second, untested one a caller could reach by skipping a port. Call LoadAll then Start once the caller is ready.
func (*Manager) AliveByClaudeSessionID ¶ added in v0.19.0
AliveByClaudeSessionID returns the alive Muster session holding claudeSessionID, if any (kb:adr/launch-resume-one-alive-row-per-claude-session's one-alive-row guard) — shared by GET /api/past-sessions' openSessionId, POST /api/sessions' resumeSessionId already_open check and the Resume action's not_resumable check, so the three can never disagree about which session id counts as "already open" for a given Claude session. Scans m.sessions directly rather than the byClaude routing index: byClaude never deletes a session's previous claude id on rebind (Apply's own doc), so after a /clear it can still name a session that has since moved on to a different id, and LoadAll fills it in ListSessions' unordered rows, so which of a dead and an alive row that once shared an id wins depends on load order — both would make this guard answer differently for the same state depending on history or a restart. Checking Alive first on the row itself, instead, needs neither. "Holding" also counts a still-unbound row a resume path has just claimed for this id (kb:adr/launch-resume-pending-hold-persisted): pendingResumeClaudeSessionID, set at CreateSession and cleared by applyBind once the row's own resume bind lands.
func (*Manager) Apply ¶
func (m *Manager) Apply(ctx context.Context, musterSessionID int64, claudeSessionID string, promptID *string, input claudecode.StateInput, enveloped bool) (*Session, error)
Apply feeds one already-routed, already-persisted event into the state machine (kb:anchor/state.transitions) and persists + broadcasts the result. claudeSessionID and promptID are generic identifiers, not Claude Code payload vocabulary; input is the neutral StateInput the claudecode interpreter derived.
enveloped is true iff the ingest post carried the kb:anchor/ingest.envelope envelope (i.e. arrived through Muster's own command wrapper, never a raw/legacy post); an enveloped post is the sole binding authority (kb:adr/ingest-envelope-authoritative-binding). For an enveloped event whose Kind is not itself a binder (KindBind/KindClearRebind/ KindResumeBind — those already carry their own bind logic via applyInput/applyBind below), this session's *actual* claudeSessionID is compared against the one this event names before applyInput runs:
- never bound (ClaudeSessionID == "") → bind it, no state transition (this covers a lost SessionStart: the pane's later events still bind it).
- bound to a different id, and that id has never been this session's → the same rebind SessionStart(source:"clear") gets (reset context/compactions, → started), applied first, so the event that follows lands on a freshly-started session — the shape /clear produces (kb:fact/clear-mints-new-session-id).
- bound to a different id, but `byClaude[claudeSessionID]` already points at *this* session — meaning this session left that id for its current one already — the event is a reordered straggler from a conversation this session has moved on from (typically the `/clear` pair's own `SessionEnd(reason:"clear")`, since delivery is unordered per CLAUDE.md's hard rule). Rebinding is **monotonic** (kb:adr/ingest-monotonic-rebind): it is routed and applied below, but it never rebinds *backwards* — the current binding, context gauge and compaction counter are left untouched.
Raw (non-enveloped) events never take this path: they keep routing by the existing byClaude mapping and never bind or rebind. Status-line posts never call Apply at all — they go through ApplyStatus.
Either way, this function does exactly one persist and one broadcast, whether or not the rebind branch ran.
func (*Manager) ApplyPlanScan ¶ added in v0.18.0
func (m *Manager) ApplyPlanScan(ctx context.Context, id int64, claudeSessionID, foundPath string) (*Session, bool, error)
ApplyPlanScan commits one transcript scan's result (kb:adr/reader-plan-sticky-once-named) — the sole place the sticky-once-named retention rule is decided. foundPath == "" is a scan that found nothing (a planless transcript, or one that no longer exists); foundPath != "" is a scan that named a plan and always replaces. The retain-or-replace decision, the stat that re-derives exists, and the write all happen inside one Manager.mu critical section, so a scan that finds nothing can never read a plan another goroutine is about to commit, decide to keep "no plan", and then write that decision over the newer one — the read of the currently-committed PlanPath and the write of the final PlanPath/PlanExists are atomic with each other. The stat is a local os.Stat, so it runs under the lock rather than in a separate step that could go stale between reading and committing. Refused (no persist, changed=false) when claudeSessionID no longer names id's current binding (kb:spec/reader), same as SetPlan.
func (*Manager) ApplyStatus ¶
func (m *Manager) ApplyStatus(ctx context.Context, musterSessionID int64, update claudecode.StatusUpdate) (*Session, error)
ApplyStatus applies one routed status-line post's neutral StatusUpdate: title, model, and context refresh, whichever fields the payload actually carried. Persists and broadcasts sessionUpsert only when a surfaced field actually changed (applyStatusUpdate's return value) — status posts fire on every tool use, and a no-op upsert on every one of them would spam the wire (kb:adr/connection-whole-object-session-upserts). Never a state source: applyStatusUpdate has no path to state/stateSince/attention/failure/alive/compactions/ permissionMode — status posts never drive the state machine (kb:anchor/state.transitions).
func (*Manager) CreateGroup ¶ added in v0.20.0
CreateGroup creates a group named name just above Ungrouped, optionally moving sessionIDs into it at the end of the new section in listed order (kb:anchor/groups.create). Broadcasts the groups message, then one sessionUpsert per moved session. Returns ErrInvalidGroupName, ErrUnknownSession for an unknown id and ErrInvalidOrder for a repeated one; nothing is created on those paths.
func (*Manager) CreateLaunchGroup ¶ added in v0.20.0
CreateLaunchGroup inserts a group for a launch that has not yet spawned its session (kb:adr/launch-new-group-created-with-the-row-or-not-at-all). The group stays out of every snapshot and groups broadcast until RecordLaunch announces it with its first member, and DiscardLaunchGroup deletes it again if the launch fails, so a refused launch leaves no group and tells no client of one.
func (*Manager) CreateSession ¶
CreateSession inserts the session row (tmuxTarget still a placeholder — the launcher needs this id to build the tmux pane environment before it can spawn the window) and registers it in memory. No broadcast yet: staying silent until any hook can arrive is satisfied by RecordLaunch, once the real tmux target is known. A non-empty p.ResumeClaudeSessionID also registers the row's resume claim immediately (kb:adr/launch-resume-pending-hold-persisted): before this call returns, the row already counts as alive-and-holding that Claude session id for AliveByClaudeSessionID, since InsertSession persists a new row alive from the start.
func (*Manager) DeleteGroup ¶ added in v0.20.0
func (m *Manager) DeleteGroup(ctx context.Context, id int64, disposition GroupDisposition, to int64) (GroupDeleteResult, error)
DeleteGroup deletes group id (kb:anchor/groups.delete) and does disposition to its members: ungroup keeps their railPos in Ungrouped, move puts them at the end of group to, remove removes each as Remove does (to is read only for move). Member upserts and sessionRemoveds go out first, then the groups message. A remove that leaves a member behind keeps the group, and reports Deleted false.
func (*Manager) DeleteSession ¶
DeleteSession removes a session that failed to launch after its row was inserted — on a spawn failure the row is deleted rather than left behind. No OnRemoved broadcast: the row was never announced with a sessionUpsert either.
func (*Manager) DiscardLaunchGroup ¶ added in v0.20.0
DiscardLaunchGroup deletes a group CreateLaunchGroup made once its launch failed, after the launch has rolled its session row back. A group still held back is deleted silently: no client was ever told of it. One RecordLaunch already announced is deleted and announced gone, but only while it is empty — a session another request moved in is no longer the launch's to undo.
func (*Manager) End ¶
End kills a live session's tmux session (kb:anchor/sessions.end): a final pane snapshot is captured, then the tmux session is killed, then the existing liveness nudge path (checkOneLiveness/markEnded) observes the now-missing pane and does the alive:=false persist + broadcast — the same single code path every other death goes through (kb:adr/lifecycle-liveness-from-pane-existence). Returns ErrUnknownSession (404) or ErrSessionNotAlive (409 not_alive, already ended).
id's per-session lock is held for the whole check-then-act (kb:adr/actions-serialized-per-session), so two concurrent Ends can never both pass the alive gate. endLocked is split out because Remove also needs to run this same body while already holding the lock itself (End's own LockSession call would otherwise deadlock against Remove's).
func (*Manager) EndAll ¶
EndAll ends every currently alive session — the `-on-exit=kill` shutdown path (kb:adr/lifecycle-shutdown-leaves-sessions-running): each gets a final snapshot, is killed, and its row is marked alive:false before the daemon exits. One session's failure is logged and does not stop the rest. Returns how many were successfully ended.
func (*Manager) EndMany ¶ added in v0.20.0
EndMany runs End over ids in listed order, each under its own per-session lock, and reports the outcome per id. A session already ended or removed is skipped, never an error. A repeated id is ErrInvalidOrder and nothing runs.
func (*Manager) Exists ¶
Exists reports whether id is a known (not necessarily alive) Muster session — used by the ingest worker to validate an envelope's musterSession before trusting it: a stale envelope naming an id that no longer exists must persist unrouted, never bind.
func (*Manager) Get ¶
Get returns session id's current snapshot, if known — used by the terminal bridge's pre-upgrade checks (kb:anchor/terminal.ws: 404 unknown id, 409 not_attachable when Alive is false).
func (*Manager) GroupExists ¶ added in v0.20.0
GroupExists reports whether id names a group.
func (*Manager) Groups ¶ added in v0.20.0
func (m *Manager) Groups() ([]Group, UngroupedLayout)
Groups returns every group the dashboard may know of, sorted by pos, and the Ungrouped layout — the snapshot's groups and ungrouped. A group a launch has created but not yet recorded is left out.
func (*Manager) KillAllShells ¶ added in v0.14.1
KillAllShells kills every muster-<n>-shell tmux session on the socket — the `-on-exit=kill` shutdown path's shell companion to EndAll (kb:adr/surfaces-shell-dies-at-kill-shutdown-too). An already-gone shell counts as killed (KillSession's own idempotence, kb:adr/actions-kill-is-idempotent); one shell's failure is logged and does not stop the rest. Returns how many were successfully killed.
func (*Manager) LoadAll ¶
LoadAll reloads every persisted group and session into memory (daemon-restart reconcile) — the liveness poll re-evaluates alive on its own next tick.
func (*Manager) LockClaudeSession ¶ added in v0.19.0
LockClaudeSession acquires claudeSessionID's resume-claim lock and returns the func that releases it (kb:adr/launch-resume-one-alive-row-per-claude-session). Unlike LockSession, it is never held for a whole session's lifetime — only from a resume path's AliveByClaudeSessionID check through the point its claim becomes durably visible in m.sessions (CreateSession's pendingResumeClaudeSessionID, or RecordResume's Alive flip), after which AliveByClaudeSessionID alone keeps the guard without this lock held.
func (*Manager) LockSession ¶ added in v0.13.2
LockSession acquires id's per-session lock (kb:adr/actions-serialized-per-session) and returns the func that releases it. Held across a whole logical action — including its tmux I/O — never across mu itself; internal/server's sessionLauncher and shellFeature use this directly to serialise their own actions the same way End/Remove do internally, so a shell can never be spawned for an id whose Remove has started or finished — both go through this same lock. A caller always re-validates id's existence under m.mu once it has the lock (every action here does): that check, not id never being lockable again, is what makes reclaiming a removed id's lock entry safe against a client-supplied id racing a Remove (keyedlock.Locks.Forget's doc).
func (*Manager) MarkPlanWritten ¶ added in v0.18.3
func (m *Manager) MarkPlanWritten(ctx context.Context, id int64, claudeSessionID, expectedPath string) (*Session, bool, error)
MarkPlanWritten flips id's PlanExists to true iff its currently-committed PlanPath is still expectedPath and PlanExists is still false — internal/server/reader.go's observeWrite exists-flip: the decision and the write happen in one Manager.mu critical section against the value actually committed *now*, never against a path the caller read earlier through Get() outside the lock. Without this, a concurrent ApplyPlanScan naming a different plan could be overwritten back to the stale path SetPlan's generic (path, exists) signature would otherwise blindly write. Refused (no persist, changed=false) when claudeSessionID no longer names id's current binding (kb:spec/reader), same as SetPlan.
func (*Manager) MarkSeen ¶ added in v0.16.0
MarkSeen clears id's Unread flag (kb:adr/rail-unread-inferred-from-live-terminal-client): the attach side effect on either terminal surface, called before the first byte is forwarded. Persists and broadcasts one sessionUpsert only when Unread was actually true — an already-read session's attach neither writes nor broadcasts. Returns ErrUnknownSession for a missing id. Lives here, not beside SetTitle: Unread is a rail concept — set by Apply's turn_closed handling (apply.go), cleared here, read by the rail's own unread badge — not a title one.
func (*Manager) Nudge ¶
Nudge immediately re-checks one session's pane liveness rather than waiting for the next poll tick (kb:anchor/terminal.ws: a PTY EOF should promptly flip alive:false — "the daemon also nudges the liveness poll" — rather than lagging up to the ~5s interval).
func (*Manager) PaneOf ¶ added in v0.14.1
PaneOf returns id's recorded tmux pane, if id is known — the ingest worker's corroboration read (kb:adr/ingest-envelope-pane-must-corroborate): an empty pane with ok true is the spawn-to-record window, not "unknown".
func (*Manager) Reconcile ¶
func (m *Manager) Reconcile(ctx context.Context) ReconcileReport
Reconcile runs once at daemon startup, synchronously, before the first snapshot is served (kb:anchor/state.liveness, kb:adr/lifecycle-reconcile-before-first-snapshot). Call after LoadAll and before Start. Classification runs from **one** ListSessions snapshot, by tmux name ownership, never from the stored alive/target alone (kb:adr/lifecycle-reconcile-converges-with-the-socket):
- "muster-<id>" present on the socket → the row owns it: re-derive tmux_target/tmux_pane from tmux, keep alive:=true, persist+broadcast only on an actual change (reviveOwnedSession). This is what stops a live pane from ever being deleted, whatever the stored alive said.
- "muster-<id>" absent, row alive=true → marked ended and kept (the resume chance is not lost; the *following* startup's absent case sweeps it).
- "muster-<id>" absent, row alive=false → swept (deleted; already had its resume chance in an earlier daemon lifetime).
- "muster-<n>" with no matching row → reported in UnknownSessions and warn-logged; never adopted, never killed (kb:adr/lifecycle-reconcile-before-first-snapshot) — its id still raises the watermark so it can never be handed to a new row.
- "muster-<n>-shell" → killed unconditionally, whatever its <n>; never adopted or reported unknown; its id also raises the watermark.
PaneChecker/PaneSnapshotter/TmuxSessions are all required at construction (NewManager panics otherwise), so ownership classification is the only classification path — a ListSessions call that itself fails is a different case and must NOT read as "every not-alive row's pane is confirmed gone": Reconcile instead acts on nothing this cycle and leaves every row untouched, and the next successful poll reconciles once the socket is reachable again.
A markEnded/DeleteSession failure during the act phase is logged and does not stop the rest — in particular the shell sweep, which runs before the act phase, always happens regardless.
func (*Manager) RecordLaunch ¶
func (m *Manager) RecordLaunch(ctx context.Context, id int64, tmuxTarget, tmuxPane string) (*Session, error)
RecordLaunch stamps the real tmux target/pane once the window has been spawned, and broadcasts the session for the first time. A persist failure rolls the tmux target/pane back to what they were, so memory never claims a target the DB never recorded. A fresh launch has reported no directory yet, so Claude's recorded one is cleared. A session launched into a new group (CreateLaunchGroup) announces that group first, so no client ever sees a groupId it has no group for.
func (*Manager) RecordResume ¶
func (m *Manager) RecordResume(ctx context.Context, id int64, tmuxTarget, tmuxPane string) (*Session, error)
RecordResume stamps the new tmux target/pane after a successful resume spawn (kb:anchor/sessions.resume): alive:=true, endedAt cleared, the stale snapshot cleared (a fresh pane has nothing captured yet), Claude's recorded directory cleared (it starts in the launch directory again), persisted and broadcast. state is left untouched — it becomes idle only once the enveloped SessionStart(source:"resume") arrives (kb:adr/lifecycle-resume-rebinds-existing-session, via the ordinary Apply/KindResumeBind path).
func (*Manager) Remove ¶
Remove deletes id's row (kb:anchor/sessions.remove, kb:adr/actions-remove-allowed-on-live-session): if alive, the End path runs first; a failing kill (End's own error) leaves the row untouched and propagates — never a deleted row with a running pane. On success the in-memory entry and the row are both gone and OnRemoved fires (the sessionRemoved broadcast).
id's per-session lock is held for the whole check-then-act (kb:adr/actions-serialized-per-session), so a Remove can never race a concurrent End/Resume for the same id. It calls endLocked directly (not End) — End would try to reacquire the same lock and deadlock.
func (*Manager) RemoveMany ¶ added in v0.20.0
RemoveMany runs Remove over ids in listed order, each under its own per-session lock, and reports the outcome per id. A session removed before its turn is skipped; a failing kill leaves the row (Remove's own rule) and reports the id as failed. A repeated id is ErrInvalidOrder and nothing runs.
func (*Manager) RepairOwnedSession ¶ added in v0.13.2
RepairOwnedSession re-derives id's tmux_target/tmux_pane from a muster-<id> tmux session that is live even though the row does not currently believe it owns it — the resume repair for when sessionLauncher.Resume's own spawn collides with ErrSessionExists: a live muster-<id> under a not-alive row *is* that row's own pane (a SIGKILL landed between an earlier resume's spawn and its persist, or a race with Reconcile — kb:adr/lifecycle-reconcile-converges-with-the-socket, kb:adr/lifecycle-resume-rebinds-existing-session). Returns ErrUnknownSession for an unknown id, or an error naming what went wrong if no live pane can actually be confirmed (the tmux session named by id turns out not to exist after all, or ResolveSessionTarget isn't supported) — the caller turns that into 500 launch_failed without ever quoting raw tmux stderr.
func (*Manager) RepoTargets ¶ added in v0.19.4
func (m *Manager) RepoTargets() []RepoTarget
RepoTargets returns every session's launch and Claude directories for the repo poll.
func (*Manager) Resolve ¶
Resolve returns the Muster session bound to a Claude session id, if any — the routing rule for every non-binding event (kb:adr/ingest-envelope-authoritative-binding).
func (*Manager) SetAllCollapsed ¶ added in v0.20.0
SetAllCollapsed sets every group's and the Ungrouped section's collapsed flag in one write (kb:anchor/groups.collapsed); the groups message goes out only when something changed.
func (*Manager) SetGroupsOrder ¶ added in v0.20.0
SetGroupsOrder applies PUT /api/groups/order (kb:anchor/groups.order): every section's pos becomes its index in order, which must list every group a client has been told of and 0 (Ungrouped) exactly once (ErrInvalidGroupOrder). A group a launch holds back (launchGroups) is not one of them: it keeps its place just above Ungrouped until the launch announces it. Broadcasts the groups message only when a pos changed.
func (*Manager) SetOrder ¶
func (m *Manager) SetOrder(ctx context.Context, ids []int64, pinnedCount int, group *GroupRef) error
SetOrder applies kb:anchor/sessions.order's full rail-order mutation: the pure applyOrder computes the new (pinned, railPos, group) for every session, and this persists + broadcasts only the ones that changed. group, when non-nil, first moves every listed id into that section (ErrUnknownGroup if it names none). Returns ErrInvalidOrder for a malformed request (the server maps it to 400 invalid_request) — nothing changes on that path.
func (*Manager) SetPinned ¶
SetPinned applies kb:anchor/sessions.pin's pin mutation to id: pins or unpins it, renumbering whatever the invariant requires (railorder.go's applyPin), and persists + broadcasts every session whose pinned or railPos changed — nothing when id was already in the requested state. Returns ErrUnknownSession if id doesn't exist (the server maps it to 404 unknown_session).
func (*Manager) SetPlan ¶ added in v0.13.0
func (m *Manager) SetPlan(ctx context.Context, id int64, claudeSessionID, path string, exists bool) (*Session, bool, error)
SetPlan records id's derived plan file, persisting and broadcasting a sessionUpsert only when path or exists actually changed. Refused (no persist, changed=false, the session's current snapshot returned) when claudeSessionID no longer names id's current binding (kb:spec/reader) — a straggler's scan or write must never move the plan. path == "" is the wire plan:null.
No production code calls SetPlan: ApplyPlanScan is production's one writer for a transcript scan's result, and MarkPlanWritten is production's one writer for the exists-flip. SetPlan stays as a lower-level primitive tests use to seed PlanPath/PlanExists directly, without going through ApplyPlanScan's sticky-retention decision or MarkPlanWritten's compare-and-set — it bypasses both, which is exactly why production never calls it.
func (*Manager) SetRepoState ¶ added in v0.19.4
SetRepoState adopts one repo-poll reading and, only when the wire-visible repo or claudeLocation changed, persists and broadcasts it as one sessionUpsert. A reading that finds nothing new touches nothing.
The reading was taken outside the lock, so it is checked against the session as it stands now. A reading taken before the session died is dropped whole, branch included, however soon a resume made it alive again: a checkout made after the card showed ended must not reach the dead card, and the resumed session's first tick reads it afresh. A Location derived from a ClaudeDir that has since been replaced or cleared is dropped alone; the ClaudeDir change that outdated it has already nudged the poll for a fresh reading.
func (*Manager) SetSessionsGroup ¶ added in v0.20.0
SetSessionsGroup applies PUT /api/sessions/group (kb:anchor/sessions.group): every listed session not already in groupID (nil is Ungrouped) joins the end of that section in listed order. Returns ErrUnknownSession for an unknown id, ErrInvalidOrder for a repeated one and ErrUnknownGroup; nothing changes on those paths.
func (*Manager) SetTitle ¶
SetTitle applies kb:anchor/sessions.title's PUT .../title mutation (kb:adr/rename-muster-owned-title-override-wins): sets or clears id's title override under the lock and persists+broadcasts iff the wire title or the override itself changed — a request that would leave both unchanged neither writes nor broadcasts. Returns ErrUnknownSession for a missing id (the server maps it to 404 unknown_session).
func (*Manager) SetTranscript ¶ added in v0.13.0
func (m *Manager) SetTranscript(ctx context.Context, id int64, claudeSessionID, path string) (bool, error)
SetTranscript records id's latest known transcript path, persisting only when it actually changed and never broadcasting — every hook carries the same field, and a no-op write per hook would double the ingest worker's SQLite traffic. Refused (no persist, changed=false) when claudeSessionID no longer names id's current binding (kb:spec/reader: "a hook whose Claude session id the session has already left never moves the transcript or the plan") — a straggler from before a `/clear` must never move the transcript backwards. Returns ErrUnknownSession for an unknown id.
func (*Manager) ShellCount ¶ added in v0.14.1
ShellCount counts every muster-<n>-shell tmux session on the socket — the on-exit prompt's shell count. A caller treats an error as zero shells and proceeds with shutdown regardless (never a blocker).
func (*Manager) ShellNames ¶ added in v0.18.3
ShellNames lists every muster-<n>-shell tmux session name currently on the socket — KillAllShells and ShellCount's shared read, matching the same tmux.IsShellSessionName predicate reconcile.go's own unconditional shell-kill loop uses. Exported so internal/server's restart-impact endpoint asks this one place too, rather than listing tmux sessions on its own.
func (*Manager) Snapshot ¶
Snapshot returns id's last captured pane screen, if any (kb:anchor/sessions.pane's read path) — display source only, never a state source.
func (*Manager) Start ¶
func (m *Manager) Start()
Start begins the liveness poll loop. Call once, after LoadAll.
func (*Manager) Stop ¶
Stop cancels the poll loop and waits for it to exit, giving up when ctx is done. Also flags checkOneLiveness's opportunistic callers (see the stopped field doc) to stop persisting — set first, so it takes effect for a Nudge racing this call regardless of how long the poll-loop wait below takes.
func (*Manager) UpdateGroup ¶ added in v0.20.0
UpdateGroup renames group id and/or sets its collapsed flag (kb:anchor/groups.update); id 0 is the Ungrouped section, which has no name to change (ErrUngroupedSection). Broadcasts the groups message only when something changed.
type Model ¶
Model is the session's model readout (kb:anchor/ws.session value semantics); both fields start at the launch value verbatim, a bind naming a different id sets both to that id, and a routed status-line post refreshes both whenever its model object is present. DisplayName is never empty.
type PaneChecker ¶
PaneChecker reports whether a tmux pane still exists — the liveness poll's only signal (kb:anchor/state.liveness; never terminal output, CLAUDE.md hard rule). Defined here because internal/session is the consumer; internal/tmux.Client satisfies it.
type PaneSnapshotter ¶
PaneSnapshotter captures a live pane's current screen text (kb:anchor/sessions.pane) — display source only, never read by the state machine and never logged (may hold prompt text). internal/tmux.Client satisfies it.
type PermissionMode ¶
type PermissionMode string
PermissionMode is the latched last-known permission mode (kb:anchor/state.tracked). The four values are Claude Code's own `--permission-mode` vocabulary (kb:fact/permission-mode-no-flag-follows-configured-default), owned by internal/claudecode (CLAUDE.md hard rule) — this package's constants derive from that owner rather than re-declaring the literals, since session already imports claudecode (for StateInput), so this direction never cycles.
const ( PermissionDefault PermissionMode = PermissionMode(claudecode.PermissionDefault) PermissionPlan PermissionMode = PermissionMode(claudecode.PermissionPlan) PermissionAcceptEdits PermissionMode = PermissionMode(claudecode.PermissionAcceptEdits) // PermissionAuto is Claude Code's distinct "auto" mode, measured 2026-09-03 against // 2.1.259 (docs/history/spikes/canary-fields.md § Hook payloads, "Permission-mode probe"): reports // permission_mode "auto" on hooks; model-gated (falls back to "default" on haiku). PermissionAuto PermissionMode = PermissionMode(claudecode.PermissionAuto) // PermissionBypass is Claude Code's danger mode (kb:adr/launch-bypass-offered-with-danger-guardrails): // reports permission_mode "bypassPermissions" verbatim (kb:fact/bypass-permission-mode-on-wire). PermissionBypass PermissionMode = PermissionMode(claudecode.PermissionBypass) )
type ReconcileReport ¶
type ReconcileReport struct {
KeptAlive int
MarkedEnded int
Swept int
UnknownSessions []string // muster-<n> tmux sessions on the socket with no row
// ShellsKilled counts "muster-<n>-shell" tmux sessions killed unconditionally
// (kb:anchor/state.liveness) — never adopted, and never listed in UnknownSessions.
ShellsKilled int
}
ReconcileReport summarizes what Reconcile did, for its own log line and for tests to assert against.
type RepoState ¶ added in v0.19.4
type RepoState struct {
Branch *string
IsWorktree bool
ClaudeDir string
Location *Location
Epoch uint64
}
RepoState is one repo-poll reading of a session: the launch directory's branch and worktree flag, and where Claude is when that is another checkout (nil when not). ClaudeDir is the recorded directory the Location was derived from; Epoch is the target's.
type RepoTarget ¶ added in v0.19.4
type RepoTarget struct {
ID int64
Directory string
ClaudeDir string
Alive bool
// Epoch is how many times the session had died when this was copied; the reading taken
// for it carries it back in RepoState.Epoch.
Epoch uint64
}
RepoTarget is what the repo poll needs to know about one session, copied under m.mu so the poll can run git outside the lock.
type Session ¶
type Session struct {
ID int64
TmuxTarget string
TmuxPane string
ClaudeSessionID string // "" until bound
RepoID int64
Directory string // the launch directory, fixed for the row's lifetime
Branch *string // nil iff Directory isn't a git checkout or HEAD is detached; refreshed by the repo poll
IsWorktree bool // refreshed with Branch
Title *string // Claude's last-known name (status-line session_name, or launch --name until then)
State State
StateSince time.Time
PermissionMode PermissionMode
PermissionModeSource string // "seed" | "hook"
Model *Model
Context *Context
Compactions int
Attention *Attention
Failure *Failure
LastActivity *string
Alive bool
EndedAt *time.Time
FirstLaunchHere bool
CreatedAt time.Time
// LastSnapshot/LastSnapshotAt (kb:anchor/sessions.pane): the last capture-pane text
// and when it was captured. "" / zero means "never captured yet" — display source
// only, never read by the state machine, never logged (may hold prompt text).
LastSnapshot string
LastSnapshotAt time.Time
// Pinned/RailPos (kb:adr/rail-order-daemon-owned-per-session-fields): user-owned
// rail order. Display-only — never read by the state machine; mutated only by
// railorder.go's pure applyPin/applyOrder, via Manager.SetPinned/SetOrder.
Pinned bool
RailPos int64
// GroupID (kb:adr/rail-groups-daemon-rows-whole-list-broadcast): the rail group this
// session belongs to, nil = the Ungrouped section. Display-only — never read by the state
// machine or the status path, and untouched by /clear, resume, reconcile, rename and pin.
// Written under Manager.mu by CreateSession and applyRailChangesLocked (the group
// endpoints' rail writes); always replaced with a fresh pointer, never written through, so
// restoreChangedFields can compare it by identity.
GroupID *int64
// TitleOverride (kb:adr/rename-muster-owned-title-override-wins): the user's rename
// via PUT .../title, nil = none. Display-only, wins over Title in DisplayTitle() —
// never read by the state machine or the status path; mutated only by
// Manager.SetTitle.
TitleOverride *string
// TranscriptPath/PlanPath/PlanExists (kb:spec/reader): the latest transcript path a
// routed hook named, and the plan derived from it via claudecode.LocatePlanFile.
// Display-only, never read by machine.go. PlanPath "" (the wire plan:null) means no
// transcript has ever named a plan; once a plan has been named, a planless scan
// keeps it rather than clearing it back to "" (kb:adr/reader-plan-sticky-once-named).
// Mutated only by Manager.SetTranscript/SetPlan/ApplyPlanScan.
TranscriptPath string
PlanPath string
PlanExists bool
// Unread (kb:adr/rail-unread-inferred-from-live-terminal-client): true iff the turn
// closed with no terminal client attached to this session, since cleared. The
// invariant Unread implies State == idle is enforced by setState — the one place
// every applyInput arm routes through. Set by Manager.Apply after a turn_closed
// input per the watcher's answer; cleared by Manager.MarkSeen (attach on either
// surface). Display-only, independent of Alive, survives a restart.
Unread bool
// LastPrompt (kb:adr/rail-activity-line-turn-aware-default-with-pref): the user's most
// recent prompt, truncated to 200 chars, nil until a first prompt or after /clear.
// Set by KindTurnActivity when the adapter supplied one; display-only, never read by
// the state machine.
LastPrompt *string
// BackgroundTasks (kb:adr/lifecycle-background-tasks-count-not-state): the running
// background work the latest Stop reported. Display-only, never read by the state
// machine. Written by applyInput's KindTurnClosed arm (set) and applyBind (reset) —
// both under Manager.mu.
BackgroundTasks int
// AttentionAgent (kb:adr/lifecycle-attention-owned-by-raising-agent) is who raised
// Attention: "" for the main agent, else a subagent's opaque id. Non-empty only while
// Attention is non-nil (clearAttention is the only place either is cleared). Persisted,
// never on the wire. Written by applyInput under Manager.mu.
AttentionAgent string
// ClaudeDir (kb:adr/lifecycle-card-shows-launch-directory-marks-claude-elsewhere) is the
// working directory Claude last reported, "" = none since the last launch or resume.
// Display-only: nothing but the repo poll's derivation of ClaudeLocation reads it. Written
// by Apply and ApplyStatus (adoptClaudeDir) and cleared by RecordLaunch/RecordResume, all
// under Manager.mu.
ClaudeDir string
// ClaudeLocation is the derived, in-memory-only wire readout of ClaudeDir: non-nil only
// while Claude works in a different checkout from Directory (Elsewhere). Written by
// SetRepoState and cleared by markEnded, RecordLaunch and RecordResume, under
// Manager.mu; always replaced with a fresh pointer, never mutated in place.
ClaudeLocation *Location
// contains filtered or unexported fields
}
Session is one row of the kb:anchor/state state machine, held in memory and persisted on every mutation. Guard fields (currentPromptID/closedPromptIDs) are in-memory only — a daemon restart resets them.
func (*Session) Clone ¶
Clone returns a value copy safe to hand outside the manager's lock (Attention, Failure and Model are pointers to otherwise-immutable snapshots — callers must treat them as read-only).
func (*Session) DisplayTitle ¶
DisplayTitle returns the wire "title" (kb:adr/rename-muster-owned-title-override-wins): TitleOverride when non-nil, else Claude's last-known name (Title), else nil. The daemon owns this precedence; no client computes it (kb:anchor/ws.session).
type State ¶
type State string
State is one of the six displayed states (kb:anchor/state.displayed).
type TmuxSessions ¶ added in v0.18.3
type TmuxSessions interface {
KillSession(ctx context.Context, name string) error
ListSessions(ctx context.Context) ([]string, error)
ResolveSessionTarget(ctx context.Context, name string) (target, pane string, err error)
}
TmuxSessions is the Manager's live view of tmux itself: enumerating every session on the socket, killing one by name, and resolving a live session's current window/pane target. Reconcile's ownership classification and repair (kb:adr/lifecycle-reconcile-converges-with-the-socket), End/EndAll/Remove's kill path (kb:adr/actions-serialized-per-session) and the shell-lifecycle helpers all go through this one port — a *tmux.Client satisfies it. A fake that doesn't support ResolveSessionTarget just returns an error, the same shape a real failure already took.
type UngroupedLayout ¶ added in v0.20.0
UngroupedLayout is the Ungrouped section's place and collapsed flag (kb:adr/rail-ungrouped-is-section-zero-on-the-wire): the section that holds every session with no group has no row of its own.
type Watcher ¶ added in v0.16.0
Watcher answers whether a session currently has a live terminal client attached, on either surface (kb:adr/rail-unread-inferred-from-live-terminal-client, kb:anchor/state.tracked) — internal/server's terminal registry satisfies it. Apply's turn_closed handling treats a nil Watcher (e.g. a Manager built without one, most unit tests) as reporting false for every id — unwatched, never a panic.