session

package
v1.0.258 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: AGPL-3.0 Imports: 44 Imported by: 0

Documentation

Index

Constants

View Source
const (
	PromptDelivered       = tmux.PromptDelivered
	PromptNotDelivered    = tmux.PromptNotDelivered
	PromptSentUnverified  = tmux.PromptSentUnverified
	PromptCouldNotConfirm = tmux.PromptCouldNotConfirm
)
View Source
const (
	ConversationCaptureInjected     = "injected"
	ConversationCaptureCodexRollout = "codex_rollout"
)
View Source
const (
	HandoffReasonUsageLimit = "usage limit"
	HandoffReasonManual     = "manual"
)

Handoff reasons. Only the usage-limit reason exists today; the constant is named rather than inlined because the automatic trigger (deferred, see docs/design/agent-handoff.md §2.2) will add its own and the ledger has to distinguish "a human chose this" from "af chose this" after the fact.

View Source
const (
	// AfPluginDescription is the plugin's one-line summary. Unlike
	// AfSkillDescription (which tells an agent when to activate the skill),
	// this is what a human reads in a marketplace listing.
	AfPluginDescription = "Run and schedule AI coding agents in isolated git worktrees with the Agent Factory (af) CLI"
	// AfPluginAuthorName is the publisher name every manifest carries.
	AfPluginAuthorName = "Agent Factory"
	// AfPluginHomepage is the project URL every manifest carries.
	AfPluginHomepage = "https://github.com/sachiniyer/agent-factory"
)

The plugin's identity — the fields any agent's plugin manifest carries. They are constants rather than literals inside the manifest below because the SAME identity is emitted by the generated, installable per-agent plugins (commands/plugins_gen.go, #2172): the manifest af writes at runtime for the session it is launching, and the manifest a user installs from the repo marketplace, must describe the same plugin.

View Source
const AccountWriteBackRationale = "An account is a writable agent home, so the agent writes refreshed " +
	"authentication back into it. For ssh, sandbox and hook, af cannot establish that those writes come " +
	"back, so a rotated token can be lost. If your provider rotates refresh tokens, losing it also " +
	"invalidates the copy on this machine — so a feature meant to NARROW where an identity is used could " +
	"break it."

AccountWriteBackRationale is the single operator-facing reason ssh, sandbox and hook cannot safely honour a writable credential account. It states only the guarantee af lacks, without asserting where a hook runs or which mechanism could supply that guarantee.

View Source
const AfInstallCommand = `curl -fsSL https://raw.githubusercontent.com/sachiniyer/agent-factory/master/install.sh | sh`

AfInstallCommand is the one command that installs af, as published in the README and the getting-started guide. It is a constant because two generated surfaces quote it — the plugin framing above and the plugin's detect-only preflight hook (commands/plugins_gen.go) — and an install command that has drifted is worse than none.

View Source
const AfPluginUsageReference = afUsageIntroPlugin + " " + afUsageBody + "\n\n" + afUsageOutroPlugin + "\n\n" + afUsageOutro

AfPluginUsageReference is afUsageReference reframed for an agent that is NOT running inside af: the same body and the same command reference, but an opening that introduces af as a separate CLI the agent drives (and that this plugin does not install), and a closing that tells it how to leave the sessions it created behind. It is what the generated per-agent plugin artifacts carry (see `af gen-docs --plugin-root`).

View Source
const AfSkillDescription = "Manage Agent Factory (af) sessions, tabs, scheduled tasks, and the daemon via the af CLI"

AfSkillDescription is the one-line description every surface presents for the af skill — the SKILL.md frontmatter for amp/codex/gemini, the Claude Code slash command (plugin.go), and the generated plugin artifacts. It is what each agent surfaces for lazy activation, so there is exactly one of it.

View Source
const AfSkillName = afSkillDirName

AfSkillName is afSkillDirName, exported for the plugin generator (commands/plugins_gen.go), which names the skill directory and the plugin itself after it so the installed artifact matches what af writes at runtime.

View Source
const AgentArchiveCallTimeout = 3 * time.Minute

call POSTs req as JSON to the agent-server control route at `path`, decodes the shared {data,error} envelope, and unmarshals the data member into resp (nil resp ⇒ success/failure only). It is the client-side twin of the agent-server's rpcHandler dispatch — the same envelope the daemon's own httpserver speaks — with the bearer token on every call. agentArchiveCallTimeout is the archive route's own budget. Archive is not a control round-trip like the others: the in-sandbox side commits any uncommitted work and pushes a branch to origin, which legitimately takes tens of seconds on a large tree or a slow link. Under the shared 30s ceiling a perfectly healthy push was reported as a failure, and recovery refuses on a failed push — so a slow sandbox became repeatedly unrestorable while its push was still progressing server-side (Codex on #2923). Exported so the daemon's own pre-reap bound can be ordered ABOVE it: a caller that gave up first would leave the in-sandbox git work running unbounded.

View Source
const RootSessionTitle = "root"

RootSessionTitle is the reserved title of the always-ensured root agent (#1106): an in-place session the daemon creates at the repo root for repos opted in via the root_agents config key, and re-creates when it dies.

Variables

View Source
var (
	// ErrPromptReceiptUnavailable means the provider exposes no receiver-side
	// acknowledgement af knows how to read. It is distinct from a missing
	// receipt: unsupported is a capability question; not-observed means a
	// provider that does expose receipts never recorded this prompt.
	ErrPromptReceiptUnavailable = errors.New("prompt receipt unavailable")
	// ErrPromptReceiptNotObserved means the receiver never recorded the prompt
	// before the acknowledgement deadline. A tmux send returning nil does not
	// override this observation (#2220).
	ErrPromptReceiptNotObserved = errors.New("prompt receipt not observed")
	// ErrPromptReceiptAmbiguous means more than one new receiver conversation
	// appeared after the snapshot. Prompt text cannot identify which process owns
	// which rollout, even when only one has recorded it so far (#2228 review).
	ErrPromptReceiptAmbiguous = errors.New("prompt receipt ambiguous")
)
View Source
var (
	// ErrTabIDNotFound: a stable id was supplied and is not in the roster.
	ErrTabIDNotFound = errors.New("no tab with that id")
	// ErrTabNameNotFound: a name was supplied and matches no tab.
	ErrTabNameNotFound = errors.New("no tab with that name")
	// ErrTabIndexOutOfRange: no id or name was supplied and the ordinal is not a slot.
	ErrTabIndexOutOfRange = errors.New("tab index out of range")
)

Sentinel misses from ResolveTabIndex. They are values rather than formatted strings because the CALLER owns the message: the daemon names the session and lists the tabs that exist, the CLI says which flag was wrong. Only the RULE is shared.

View Source
var ErrAmbiguousTitle = errors.New("ambiguous session title")

ErrAmbiguousTitle marks a bare-title lookup that matched sessions in more than one repo. Session titles are unique PER-REPO, not globally, so a bare title with no repo scope (no --repo, cwd outside a repo) can legitimately name more than one session. Resolving it by picking the first match is worse than failing: the all-repo scans iterate a Go map, so the "winner" is nondeterministic across runs, and a destructive command (kill/archive) could hit a different repo's session than the one the user meant.

Callers match it with errors.Is and build the user-facing message with AmbiguousTitleError. A title matching exactly ONE session globally still resolves — the convenience of a bare title is kept for the common case.

View Source
var ErrCleanupHandleUnusable = errors.New("this cleanup handle cannot be completed by retrying it")

TeardownStateUnknown reports whether err means "we do not know whether this session's workspace still exists" — as opposed to any other teardown failure.

This distinction is the whole taxonomy, and getting it wrong inverts the design (#1917 round 5). A caller that blocks the record delete on ANY teardown error turns safe-by-default into STUCK-by-default: a remote session whose sandbox was successfully reaped but whose in-sandbox /kill call failed reports an error whose subject is a dead HTTP endpoint, not the workspace — the workspace is provably gone. Refusing to delete that record makes the finisher retry a dead endpoint forever, and the tombstone never clears.

So only these two block, and they exist for exactly one reason each: the pane's liveness was never established, or a worktree removal was cut off mid-flight. Both mean the workspace may still be on disk with this record as its only handle. Everything else — an endpoint that did not answer, a tmux that answered with a failure, a sandbox reap that reported a problem — is a teardown that TOLD us something, and the record may go.

It lives here, beside the sentinels, because the teardown choke points are their only producers: teardownTabs raises them, ghostCleanup forwards them, and no other code constructs them. One producer, one predicate, one place to change. ErrCleanupHandleUnusable marks a retained cleanup handle that CANNOT be made to work by retrying it. It is not "the workspace state is unknown" — that is ErrWorkspaceStateUnknown, which is retried precisely because a later attempt may learn more. This is the narrower claim that a later attempt is the same attempt: the handle itself is missing something no retry can supply.

The case it was added for is a pre-#2704 SSH kill tombstone (#2737). Those records carry no host-key posture, so they restore as strict; when the host was learned under the af-owned accept-new store, the reconnect can never verify it and the cleanup retried once per second forever. Wrapping that failure lets the daemon retire the record instead of looping on it.

It composes with ErrWorkspaceStateUnknown rather than replacing it: the workspace state IS still unknown, so the record must not be deleted — it is only the RETRYING that stops.

View Source
var ErrHandoffUnsupported = errors.New("agent handoff is only supported for local-worktree sessions")

ErrHandoffUnsupported is returned when an agent handoff (#2013) is requested on a backend that cannot swap its agent in place. It is a typed sentinel so callers can render the restriction rather than match on prose.

View Source
var ErrInPlaceRemoteBackend = errors.New("an in-place session cannot run on a non-local backend")

ErrInPlaceRemoteBackend marks the refusal of an in-place create whose session would not run on this machine. An in-place session attaches the agent to the repo's OWN working tree; a docker/ssh/hook session works in a sandbox clone and has no local worktree at all, so the two requests cannot both be honored.

A sentinel rather than a bare message because the surfaces that offer in-place (the CLI's --here, the daemon's create RPC, the root agent) need to tell this refusal apart from the provisioning-config errors it used to hide behind.

View Source
var ErrPaneMayBeLive = errors.New("tmux did not confirm the session is dead; its pane may still be running")

ErrPaneMayBeLive reports that tmux never confirmed a session dead — the server did not answer within its deadline — so the pane may still be RUNNING.

It is the difference between "tmux says the session is gone" and "tmux did not say anything". The first is teardown's goal and is best-effort by design (#478/#967); the second is an unknown, and the worktree step is not safe to run on an unknown: deleting (kill) or moving (archive) the workspace of an agent that is still writing to it destroys the user's work on a guess. Callers must treat this as "retry later", never as "the tmux part failed, carry on".

View Source
var ErrRemoteSandboxNotProvisioned = errors.New("remote sandbox is not provisioned")

ErrRemoteSandboxNotProvisioned marks the clientless AgentServer sentinel used when af knows this session has no provisioned runtime to contact. It is session-specific absence evidence, unlike a transport error from an endpoint that may still be alive behind a broken network path.

View Source
var ErrTabClosed = fmt.Errorf("tab closed: %w", io.EOF)

ErrTabClosed ends a PTY subscription whose TAB was closed (#2136), as opposed to the session-wide teardown that ends every tab's stream at once (Kill). It is the end-of-stream error CloseTab hands the closed tab's subscribers so the WS writer can name the cause instead of leaving them blocked until the keepalive gives up.

It WRAPS io.EOF deliberately: every consumer of a subscription already treats io.EOF as "this stream is over" (daemon/ws_pty.go, the attach clients, the tests), and a tab close IS that — only with a known cause. Wrapping means the distinction is opt-in for the one caller that renders it, and no existing errors.Is(err, io.EOF) check has to learn about tabs.

View Source
var ErrTabGone = errors.New("no tab with that id")

ErrTabGone reports that a stable tab id (#1738) names no live tab — it was closed, or it never existed. It is the REFUSAL the id-addressed data plane returns instead of falling back to a positional tab: once a client addresses a tab by its stable id, silently serving whatever tab now sits at some ordinal is the misroute the id exists to prevent (#1779). Callers map it to a 404/gone.

View Source
var ErrWorkspaceLeftBehind = errors.New("the session's workspace was left on disk: its cleanup was cut off by a deadline")

ErrWorkspaceLeftBehind reports that a session was abandoned while its worktree was still (partly) on disk, because the cleanup that should have removed it was cut off by its own deadline.

It exists for the paths that DISCARD an instance rather than tear one down — a failed create, whose instance was never registered or persisted. Those paths have no record to keep, so the leftovers have no handle at all: the caller must at least refuse to hand the title back out over them (#1917).

View Source
var ErrWorkspaceStateUnknown = errors.New("the worktree action was cut off by its deadline; the workspace may be partially removed")

ErrWorkspaceStateUnknown reports that a worktree action was cut off by its own deadline, so the workspace may be half-removed and is still (partly) on disk.

The caller must keep the session's record: it is the only handle the user — or the daemon's own retry — has on the leftovers. Dropping it orphans a registered worktree with nothing left pointing at it.

View Source
var InstanceDeleteLockTimeout = 10 * time.Second

InstanceDeleteLockTimeout bounds how long DeleteInstanceByStableID waits for the per-repo instances flock. A var so tests can shorten it; production never reassigns.

The delete is the LAST step of a session kill, and the daemon runs it holding that session's kill guard, so an unbounded wait here does not just stall one write — it strands a session whose kill-intent tombstone is already on disk, leaving it undeletable for the daemon's whole lifetime (#1917). The budget is generous: this lock is held only across a read-modify-write of one small JSON file, so exceeding it means a peer is genuinely wedged, not merely slow.

Functions

func AmbiguousTitleError added in v1.0.185

func AmbiguousTitleError(title string, repoPaths []string) error

AmbiguousTitleError builds the user-facing error for a bare title that matches sessions in several repos, naming each repo so the user can pick one. Paths are sorted and de-duplicated so the message is stable across runs (the underlying scans walk a map in nondeterministic order).

func BackendConfigError added in v1.0.200

func BackendConfigError(kind BackendKind, cfg *config.ResolvedConfig) error

BackendConfigError reports why kind's repo CONFIG is insufficient, or nil when the config satisfies it. This is the config-key half only — the runtimes call it as their first config-dependent check, and BackendUnusableReason calls it first too, so all three surfaces name the missing key in the same words.

A nil cfg is treated as an empty config (every optional section absent), which is the correct reading for a repo with no in-repo config file. It is NOT the reading for a config that failed to LOAD — the caller must not conflate "no config" with "unreadable config"; see ListBackends, which reports the latter as unknown.

func BackendUnusableReason added in v1.0.200

func BackendUnusableReason(kind BackendKind, cfg *config.ResolvedConfig, repoRoot string) error

BackendUnusableReason reports why kind cannot be used for a session in the repo at repoRoot, or nil when every checkable precondition passes. cfg must be the repo's RESOLVED config (a caller that could not resolve it has an unknown answer, not a nil-cfg one).

This is the choose-time question a picker must ask before offering a backend. It checks config keys, then the environment facts the runtime will need — a backend that is configured but whose command is missing is exactly the "offered, then fails later somewhere less obvious" trap this is here to close.

func CheckWorktreeOccupants added in v1.0.221

func CheckWorktreeOccupants(worktreePath string) error

CheckWorktreeOccupants reports processes still working inside a workspace that is about to be deleted or moved, for a teardown whose marker evidence was BLIND (#2998).

When it applies

Only after a session vanished with no pane ever observed. There, tmux has forgotten the ancestry and the AF_SESSION scan is the whole evidence — and that scan is vacuous for a session that never exported a marker (tmux < 3.2, or a pre-marker build), reporting the same empty result whether a descendant escaped or not. A cwd inside the workspace needs no marker and is the one signal that still works there.

Call it ONCE, after every tab is closed

Tabs of one instance share a worktree, so a scan run while a sibling is still live reports that sibling as an occupant and refuses a teardown that was about to close it anyway. Both callers run this after their close loop, never inside it.

It reports; it never kills

A match proves OCCUPANCY, not ownership: an operator's shell in the worktree is indistinguishable from an escaped agent child. The error names the pids and leaves the workspace intact and the record retryable, so the decision stays with the operator.

An unreadable process table is UNSAFE, not empty

It returns the error. On this branch the marker sweep already refuses when the process table cannot be read, so swallowing it here would make the newer check weaker than the one it supplements — and would let a workspace be deleted on a check that never ran. An empty path means no workspace is in play and is the one silent nil.

func CleanupHandleUnusable added in v1.0.218

func CleanupHandleUnusable(err error) bool

CleanupHandleUnusable reports whether err came from a handle no retry can fix.

func CleanupRetrySettledInterval added in v1.0.218

func CleanupRetrySettledInterval() time.Duration

CleanupRetrySettledInterval is the cadence a never-healing retry settles at, for callers that report it.

func DedupeSorted added in v1.0.185

func DedupeSorted(in []string) []string

DedupeSorted returns the distinct non-empty entries of in, sorted. Callers use it to collapse per-session matches down to the set of repos that hold the title: several sessions can share one repo path, and the scans that feed it walk a map, so the result must be de-duplicated and ordered to be stable.

func FindSlugCollision added in v1.0.59

func FindSlugCollision(candidate string, existing []*Instance) string

FindSlugCollision returns the title of the first existing remote instance whose hook slug collides with candidate, or "" if none do. Two titles that slugify to the same value would key delete_cmd on the same remote sandbox, so the create path rejects the collision before provisioning.

func InPlaceBackendConflict added in v1.0.217

func InPlaceBackendConflict(opts InstanceOptions, absPath string) error

InPlaceBackendConflict reports the in-place/remote contradiction for a create with these options against absPath, or nil when there is none. NewInstance enforces it, so an ordinary caller never needs this.

It is exported for callers that MUTATE state before reaching NewInstance. The daemon's reserveCreate is the one that matters: for an explicit title held only by an archived session it renames that session — relocating its worktree and rewriting its durable record — and only later builds the instance. A refusal raised at NewInstance would therefore land after an irreversible rename done for a create that could never have succeeded, which is exactly the state reserveCreate promises never to leave behind (#2127, #2415). Asking the same question ahead of the mutation is what keeps that promise, and asking it through THIS function is what keeps the two answers from drifting.

Judged on the RESOLVED kind, not on opts.Backend (#2778). An empty opts.Backend does not mean local — it means "resolve from the repo's `backend` key" — so a flag-only test waves a repo-configured docker/ssh/hook create straight through with InPlace still set. In a half-configured repo that surfaces as a provisioning-config error naming nothing about in-place; in a fully configured one it SUCCEEDS, and the session's record claims the user's working tree while its agent runs in a sandbox clone that cannot see it.

Resolving here mirrors LocalPrereqsRequired (#2592) for the same reason: a check that reimplements the backend precedence rules drifts from them.

A kind that will not RESOLVE yields nil. That is not a local create and not a remote one — it is an unusable `backend` value, and the runtime factory reports it in one place. Converting it into an in-place refusal would name the wrong problem.

func IsArchivedData added in v1.0.183

func IsArchivedData(data InstanceData) bool

IsArchivedData reports whether a serialized session record is archived, resolving its effective liveness with the same rollforward livenessFromData applies (so a pre-#1195 record with only a legacy status still classifies correctly). It is the []InstanceData analogue of Instance.ShownArchived for callers that iterate the daemon Snapshot rather than live instances — e.g. the "active projects" derivation, which counts only non-archived sessions so a project whose sessions are all archived drops out of the active list (#1735).

func IsLoopbackWebTarget added in v1.0.183

func IsLoopbackWebTarget(rawURL string) bool

IsLoopbackWebTarget reports whether rawURL points at a loopback host (localhost, 127.0.0.0/8, ::1). Only loopback targets are reverse-proxied by the daemon; every other host is treated as external and iframed directly by the web UI (never proxied — the daemon must not become an open proxy / SSRF vector). A URL that does not parse is treated as non-loopback (fail closed).

func IsReservedTitle added in v1.0.139

func IsReservedTitle(title string) bool

IsReservedTitle reports whether a session title is reserved for the daemon-managed root agent and therefore unavailable to normal session creation (TUI, CLI, API, task runs). Matching is case-insensitive on the trimmed title so "Root"/" ROOT " cannot masquerade as a distinct session next to the reserved one.

func LocalPrereqsRequired added in v1.0.211

func LocalPrereqsRequired(opts InstanceOptions, absPath string) (bool, error)

LocalPrereqsRequired reports whether a create with these options against absPath will run its agent on THIS machine — the only case where the local prerequisites (tmux, the agent binary on PATH) decide whether the create can succeed. A docker/ssh/hook create runs tmux and the agent inside the sandbox, so checking the client's PATH for them refuses a session that would have worked (#2592).

It is the ONE predicate behind that gate: the CLI's `sessions create` and `send-prompt --create` and the TUI's naming form all ask this rather than each deciding for itself which backends are local. That matters because the selection has precedence rules (explicit --backend, then ForceRemote, then the repo's `backend` key) and a surface that reimplements them drifts — gating on the explicit flag alone silently misses the repo-config case, which is the shape #2592 arrived in.

The answer is THREE-valued, which is why it returns an error rather than a bare bool. A backend value that names nothing resolvable is neither a local create nor a sandbox one: the question has no answer, and neither default is honest. Reporting it as "local" makes the user hear about missing tmux when their `backend` key is the problem; reporting it as "sandbox" skips a check that should have run. Callers surface the error.

It answers from the backend KIND rather than a provisioned backend's Capabilities on purpose: Capabilities is per-instance, so reading it means having provisioned a runtime — the exact thing a pre-create gate must not do (#2599).

func NewInstanceID added in v1.0.206

func NewInstanceID() string

NewInstanceID reserves the same stable identity NewInstance would mint. The daemon calls it before a potentially slow backend factory so it can publish an authoritative OpCreating projection whose id the finished Instance inherits.

func NormalizeWebTabURL added in v1.0.183

func NormalizeWebTabURL(raw string) (string, error)

NormalizeWebTabURL validates and normalizes a web-tab target into an absolute http(s) URL. It accepts a full URL ("http://localhost:3000", "https://x.com/y") or a bare host[:port] ("localhost:3000", "127.0.0.1:5173"), defaulting a missing scheme to http:// (the common dev-server case). It rejects a blank target, a non-http(s) scheme, or a URL with no host — the target must be something a browser can load. The returned URL is what the tab stores and what both the daemon proxy (loopback targets) and the web UI (external targets) act on, so there is one canonical form.

func RemoteHookTitleHasSpecificSlug added in v1.0.213

func RemoteHookTitleHasSpecificSlug(title string) bool

RemoteHookTitleHasSpecificSlug reports whether the exact sanitization and truncation Slugify applies retains a title-derived name. A raw title may have ASCII content only after the bounded slug prefix (for example, 200 hyphens followed by "a"), so scanning the unbounded input is not equivalent.

Do not infer this from Slugify(title) == "session": valid titles such as "SESSION!" deliberately derive that same slug.

func RemoteHooksConfiguredForPath added in v1.0.162

func RemoteHooksConfiguredForPath(absPath string) (bool, error)

RemoteHooksConfiguredForPath reports whether absPath's repo has a validated remote hook backend configured. A repo with no remote hooks is a normal empty state, so it returns false, nil rather than an error.

func ResolveTabIndex added in v1.0.204

func ResolveTabIndex(tabs []*Tab, tabID, tabName string, tabIndex int) (int, error)

ResolveTabIndex resolves which tab of tabs a caller addresses, in the repo-wide precedence every tab verb shares: the stable tabID first, then tabName, then the ordinal tabIndex.

The id comes first because it is the only handle that is not REUSABLE (#1929). A name is freed by a close and handed to the next tab that asks for it; an ordinal shifts on every close and reorder. So a client that resolved a tab and then sends its name is asking for "whatever is called that NOW", which after a concurrent close+create or rename is a different tab — and a name-keyed resolve does not fail, it succeeds on the wrong tab.

A non-empty id or name that does not resolve is REFUSED, never fallen back to the ordinal. That is the #1779/#1929 rule: falling back would address whatever tab has since taken the slot — the precise misroute the stable id exists to prevent, wearing a backward-compatible face. The ordinal is used ONLY when neither an id nor a name was supplied.

This lives beside the Tab type rather than in the daemon because the daemon is no longer the only resolver: `af sessions preview` reads a tab on the LOCAL path without going through a daemon RPC at all (#1948), and a second copy of the precedence is how the two would drift into disagreeing about what `--tab-name` means.

func SetBackendFactoryForTest added in v1.0.53

func SetBackendFactoryForTest(f func(opts InstanceOptions, absPath string) (Backend, error)) func()

SetBackendFactoryForTest replaces the backend factory with f and returns a restore function. Intended for use in tests that need to swap in a FakeBackend so NewInstance-driven creation flows stay on the hot path. f returns just the Backend — the common case for a local FakeBackend — and is adapted to the internal ProvisionResult factory here, so a test that only wants to inject a backend needs no knowledge of the endpoint/teardown seam.

func SetDockerExecForTest added in v1.0.202

func SetDockerExecForTest(f func(ctx context.Context, environ []string, args ...string) ([]byte, error)) func()

SetDockerExecForTest overrides the docker CLI runner and returns a restore func.

func SetDockerSelfBinaryForTest added in v1.0.181

func SetDockerSelfBinaryForTest(path string) func()

SetDockerSelfBinaryForTest overrides the `af` binary the docker runtime copies into the sandbox and returns a restore function. The round-trip integration test uses it to point at a freshly built static binary compatible with its test image (the test binary itself is not `af`).

func SetIllegalTransitionHook added in v1.0.148

func SetIllegalTransitionHook(fn func(msg string)) (restore func())

SetIllegalTransitionHook installs fn as the illegal-transition hook and returns a restore func. It exists so test binaries in OTHER packages (app, daemon) can install the same panic-on-illegal guard the session tests install directly — a mis-ordered transition must be a loud failure everywhere a writer routes through the chokepoint, not only in session-package tests. Production never calls this: the hook stays nil and an illegal edge degrades to the soft error.

func SetLookPathForTest added in v1.0.200

func SetLookPathForTest(f func(string) (string, error)) func()

SetLookPathForTest replaces the executable resolver with f and returns a restore function. Mirrors the SetRuntimeForTest / SetBackendFactoryForTest seam pattern.

func SetRuntimeForTest added in v1.0.183

func SetRuntimeForTest(kind BackendKind, ctor func() Runtime) func()

SetRuntimeForTest replaces the Runtime registered for kind with ctor and returns a restore function. It is the exported form of the registry swap the in-package sandbox tests already do by hand, so tests OUTSIDE this package (the daemon's remote limit-resume regression, #1786) can drive the real re-provision path — reprovisionRemote resolves the runtime through this registry — against a mock sandbox instead of a real docker/ssh host. Mirrors the SetBackendFactoryForTest / SetDockerSelfBinaryForTest seam pattern.

func SetRuntimeTeardownForTest added in v1.0.222

func SetRuntimeTeardownForTest(i *Instance, teardown func() error)

SetRuntimeTeardownForTest installs the physical reap a sandbox runtime would normally supply through ProvisionResult.Teardown.

It exists because the daemon's remote fixtures could not construct one: the field is unexported, so a test in package daemon could only observe the /v1/agent/kill REST call and not the reap it is supposed to trigger. That let a regression which emitted the right message while leaving the container running pass — and a sandbox left alive is a VM still billing with no session record pointing at it, so nothing ever cleans it up (#3042).

Deliberately narrow: it installs the callback and nothing else, so a test asserts the EFFECT through the same field production populates rather than through a better-chosen proxy. A better proxy is still a proxy.

It clears the derived agentSrv cache in the SAME i.mu section, which every production writer of this field already does (bindProvisionResult, retainProvisionResultCleanup, resetRemoteRuntime) and which #1729 is about. remoteAgentServer captures teardown BY VALUE at build time, so without this a reap installed while the cache is warm — after any poll, preview or probe — is never invoked: the fixture observes zero reaps whatever production does, and a test asserting "nothing was reaped" passes unconditionally. That is #3042's own blind spot reproduced inside the helper meant to close it, and leaving it to call order across thirty-odd fixture call sites is not a guarantee.

func SetSSHRelayBinaryForTest added in v1.0.226

func SetSSHRelayBinaryForTest(path string) func()

SetSSHRelayBinaryForTest overrides the local relay binary and returns a restore function.

func SetSSHSelfBinaryForTest added in v1.0.181

func SetSSHSelfBinaryForTest(path string) func()

SetSSHSelfBinaryForTest overrides the `af` binary the ssh runtime streams onto the remote and returns a restore function. The round-trip integration test uses it to point at a freshly built static binary (the test binary itself is not `af`).

func Slugify added in v1.0.59

func Slugify(title string) string

Slugify converts a title to a slug-safe string for the remote hook scripts. The slug is the stable identifier launch_cmd and delete_cmd receive via --name (docs/remote-hooks.md): launch_cmd tags the provisioned sandbox with it and delete_cmd reaps by it, so two sessions whose titles slugify the same must not coexist (FindSlugCollision guards that at create time — including two long titles that truncate to the same slug).

func TabIdentifiers added in v1.0.201

func TabIdentifiers(t *Tab) string

TabIdentifiers renders a tab as the strings that help a user address it in an error: its canonical Name, plus the label the UI shows when that differs. Because the label is presentation-only and never accepted (TabMatches keys on Name), naming it here is what lets a user who read "Terminal" off the bar find the `shell` they must type — the discoverability the #1986 split relies on in place of the label alias. Used to make "no tab named X" list the valid options instead of asserting an absence the user can see is false.

func TabKindNameList added in v1.0.188

func TabKindNameList() []string

TabKindNameList returns the sorted `--kind` values that select an explicit tab kind, for help text and "expected one of …" error messages, so those strings are generated from the vocabulary rather than hand-maintained beside it.

func TabKindRenameable added in v1.0.201

func TabKindRenameable(kind TabKind) bool

TabKindRenameable reports whether a tab of this kind actually displays its Tab.Name, and so whether renaming it would have any visible effect.

This is the canonical predicate behind the rename guard, and it is a mirror of the label mapping (ui/tree/labels.go textForTab and its web twin): an agent tab always renders "Agent" and a shell tab always renders "Terminal", both ignoring Name entirely, while web, process and VS Code tabs render Name (falling back to "Web"/"Tab"/"VS Code"). Renaming an agent or shell tab would therefore write a field no surface reads — a silent no-op the user would reasonably read as a bug — so callers reject it up front with an actionable message instead.

If the label mapping ever starts reading Name for another kind, this predicate must change with it; keeping the rule in one exported place is what stops the two from drifting apart unnoticed. TabKindVSCode is renameable for exactly that reason: it landed in #1817 rendering Name || "VS Code", so the rule that admits web and process admits it too.

func TabLabel added in v1.0.201

func TabLabel(t *Tab) string

TabLabel returns the presentation-only string a user SEES for a tab — its display label. It is NEVER an identifier: no surface resolves a tab by it (TabMatches keys on Name alone), which is exactly what frees it to be a prettier, non-unique string than the canonical Name. Agent and shell tabs render fixed labels ("Agent", "Terminal") that are deliberately not their names (`agent`/`shell`); every other kind shows its Name.

This is the #1986 split: Name is the one handle a user types, the label is the one string a user reads, and they are allowed to differ because the label carries no identity. It lives beside the Tab type — not in the TUI — so the definition of "what a user reads" sits next to Name, the definition of "what a user types": whenever the two differ, TabIdentifiers surfaces the label in a "no tab named …" error, so a user who read "Terminal" off the bar is told the real name `shell` rather than left to guess it. That discoverability is what replaces the label-as-alias #1937 shipped (#1984): the label never resolves, but it is never a dead end either.

func TabMatches added in v1.0.201

func TabMatches(t *Tab, token string) bool

TabMatches reports whether token identifies this tab. It keys on the canonical Name ONLY: the display label (TabLabel) is presentation and is never an identifier (#1986). A person who typed a label they read off the screen is not matched here — accepting it would make two strings address one tab, the ambiguity #1929/#1904 removed from the tab surface. The label is not a dead end either: TabIdentifiers names it in the resulting "no tab named …" error, so the user learns the real name to type (the discoverability half of #1984).

func TeardownStateUnknown added in v1.0.200

func TeardownStateUnknown(err error) bool

func WaitForPromptReceipt added in v1.0.206

func WaitForPromptReceipt(
	ctx context.Context,
	agent string,
	snap ConversationCaptureSnapshot,
	prompt string,
	timeout time.Duration,
) error

WaitForPromptReceipt waits for the agent's own durable conversation store to record prompt as a user turn. This is deliberately receiver-side: successful tmux load-buffer, paste-buffer and send-keys calls establish only that af's input path did not error, not that a modal/composer accepted a turn (#2220).

Codex is currently the only provider with a local receipt af can correlate to a just-spawned process. Callers must take snap before spawning the pane. Other providers return ErrPromptReceiptUnavailable rather than manufacturing an acknowledgement from pane pixels.

func WebTabURLForPort added in v1.0.183

func WebTabURLForPort(port int) (string, error)

WebTabURLForPort builds the loopback URL a `--port N` convenience flag targets.

Types

type Activity added in v1.0.200

type Activity int

Activity is the derived answer to "is this session still busy?" — the single question both `af sessions watch` and the watch-task concurrency limit (#1892) ask of a session record. It is a projection of the two-axis state (#1195), not a fourth stored axis: nothing persists it, ClassifyActivity computes it.

It lives here, in the leaf session package, because both consumers need it and daemon/ cannot import api/ (api/ imports daemon/). Two copies of this state machine would drift — and the two callers disagreeing about whether a session is busy is exactly the class of bug #1892 reports from userland, where a monitor inferred busyness from titles and liveness and overshot its own cap.

const (
	// ActivityPending: the session is still settling or working — an operation is
	// in flight (create/kill/archive/restore), the agent is running, or it is
	// parked on a usage limit the daemon auto-resumes (#1146). It holds a
	// concurrency slot and `sessions watch` keeps polling.
	ActivityPending Activity = iota
	// ActivityIdle: the agent went idle and awaits input — done working, ready for
	// review. Releases a concurrency slot; `sessions watch` exits 0.
	ActivityIdle
	// ActivityTerminal: the session reached a state it cannot leave ON ITS OWN
	// (lost/dead/archived) — it needs a restore, a kill, or the daemon's restore
	// loop. `sessions watch` exits non-zero with the reason.
	//
	// "Cannot leave on its own" is not the same as "gone for good", and consumers
	// must not read it that way. A LiveLost session in particular is one the
	// daemon's restore loop may be actively reviving, so the watch-task
	// concurrency limit (#1892) keeps counting it — see
	// daemon.canAutoRestoreLostSession, which composes this verdict with that
	// question rather than changing it here.
	ActivityTerminal
)

func ClassifyActivity added in v1.0.200

func ClassifyActivity(data InstanceData) (Activity, string)

ClassifyActivity maps a session record onto the activity projection, returning the outcome and a human clause explaining a terminal (or idle) result.

It reads the canonical two-axis state (#1195): an in-flight client/executor operation means the session is still settling, so it wins over the liveness axis — this is what makes a brand-new session count as busy from the moment its create begins, before any liveness exists and while its asynchronous post-worktree hooks still run (#1892).

The LivenessUnset branch falls back to the composed legacy Status for records that predate the liveness field. That fallback is load-bearing, not vestigial: LivenessForStatus maps the transient Loading/Deleting to LiveReady, so resolving a legacy record through the liveness axis alone would report a mid-create session as idle — releasing a concurrency slot it should hold, and telling `sessions watch` a session is ready before it ever started.

type AgentConversationData added in v1.0.146

type AgentConversationData struct {
	Agent       string    `json:"agent,omitempty"`
	ID          string    `json:"id,omitempty"`
	CapturedAt  time.Time `json:"captured_at,omitempty"`
	CaptureKind string    `json:"capture_kind,omitempty"`
}

AgentConversationData is the provider-specific conversation identity for a tab. It is additive/rollforward data: older records simply have no conversation object, and recovery falls back to the provider's latest-session behavior until a new id is captured.

func CaptureAgentConversation added in v1.0.146

func CaptureAgentConversation(agent string, snap ConversationCaptureSnapshot, timeout time.Duration) (AgentConversationData, error)

CaptureAgentConversation waits until a supported provider exposes the conversation id for a just-spawned tab. Unsupported providers return empty data and nil error so callers gracefully keep existing --last/latest behavior.

func (AgentConversationData) Empty added in v1.0.146

func (c AgentConversationData) Empty() bool

func (AgentConversationData) HasID added in v1.0.146

func (c AgentConversationData) HasID() bool

type AgentHandoff added in v1.0.206

type AgentHandoff struct {
	// From is the outgoing agent's conversation identity, as far as it was
	// known. Its Agent field is the outgoing agent name even when no
	// conversation id was ever captured.
	From AgentConversationData `json:"from,omitempty"`
	// To is the incoming agent (a tmux.SupportedPrograms name).
	To string `json:"to"`
	// At is when the swap was recorded.
	At time.Time `json:"at"`
	// HeadSHA is the branch tip at swap time — everything at or before it is the
	// outgoing agent's work. Empty when the branch had no commits yet.
	HeadSHA string `json:"head_sha,omitempty"`
	// Reason is why the swap happened (HandoffReason*).
	Reason string `json:"reason,omitempty"`
	// Automatic is false for a user-confirmed handoff. Always false today: only
	// the prompted path is built (design D1). It is recorded anyway so a reviewer
	// reading a ledger written by a future af still learns whether a human was in
	// the loop, rather than inferring it from the af version.
	Automatic bool `json:"automatic,omitempty"`
}

AgentHandoff is one entry in a tab's append-only handoff ledger (#2013): the record that this session's agent was swapped for another one, mid-work, on the same worktree and branch.

It carries the outgoing agent's conversation identity because the swap destroys it on the live tab — Tab.Conversation holds exactly one provider id and the incoming agent's capture overwrites it. Preserving it here is what keeps a handoff reversible: hand back later and the original conversation is still addressable, instead of degrading to that provider's "resume whatever was most recent in this directory" behavior.

HeadSHA is the attribution boundary. af authors none of the agent's commits and writes no commit trailers, so it cannot mark the work itself; what it can do is pin the branch tip at the instant of the swap, which turns "who wrote which half" into a git range a reviewer can verify rather than a label they have to trust.

func (AgentHandoff) String added in v1.0.206

func (h AgentHandoff) String() string

From/To agent names for display, e.g. "codex → claude".

type AgentModelChange added in v1.0.207

type AgentModelChange struct {
	Before string `json:"before"`
	After  string `json:"after"`
}

AgentModelChange is the live, verified model transition observed after Agent Factory handled an agent safety dialog. It is projection state: clients need the before/after values to explain a degraded-looking healthy row, but the daemon derives it from the running agent and never restores it from disk.

func NewAgentModelChange added in v1.0.207

func NewAgentModelChange(before, after string) *AgentModelChange

NewAgentModelChange constructs only meaningful transitions. Keeping invalid equal/empty pairs out at the boundary means every renderer can treat a non-nil value as an actionable diagnostic without duplicating validation policy.

type AgentObservationGeneration added in v1.0.240

type AgentObservationGeneration struct {
	// contains filtered or unexported fields
}

AgentObservationGeneration identifies the concrete runtime that answered a SnapshotAgent call. Its value is deliberately opaque outside session; daemon-owned liveness bookkeeping may compare it through Instance, but cannot manufacture or advance it.

type AgentRuntimeToken added in v1.0.207

type AgentRuntimeToken struct {
	// contains filtered or unexported fields
}

AgentRuntimeToken binds asynchronous provider discovery to one concrete process generation. Its fields stay private so callers can only obtain a valid token from an Instance snapshot, not reconstruct one from a matching agent name after the runtime has moved on.

func (AgentRuntimeToken) Agent added in v1.0.207

func (t AgentRuntimeToken) Agent() string

Agent reports the provider the captured runtime actually launched.

type AgentServer added in v1.0.176

type AgentServer interface {
	// Provision establishes WHERE the agent runs — the local git worktree for the
	// local runtime, or an off-box workspace for docker, SSH, and hook runtimes.
	// It is the first half of Phase-1's Start split (backend provision phase).
	Provision(firstTimeSetup bool) error
	// Launch starts WHAT runs in the provisioned workspace — the agent process and
	// its tabs. It is the second half of Phase-1's Start split (backend launch
	// phase). firstTimeSetup mirrors Provision: a fresh create materializes the
	// worktree and spawns; a restore reconnects.
	Launch(firstTimeSetup bool) error
	// Expose returns where the session's data plane is reachable. For the local
	// in-process agent-server it is an in-process handle; a Phase-4 runtime returns
	// an authed URL. The WS PTY broker (PR5) consumes it.
	Expose() (StreamEndpoint, error)

	// Snapshot returns the current non-interactive observation the daemon's
	// liveness poll reads each tick, dismissing any pending trust/permission
	// prompt as a side effect (the poll always did both, in that order). See
	// Observation. The local implementation never errors; off-box AgentServer
	// observation can fail across the network.
	Snapshot() (Observation, error)
	// Preview returns tab `tab`'s visible output; full=true returns the entire
	// scrollback history. tab 0 is the agent tab (the backend preview); tab>0 is a
	// shell/process tab. This is the daemon's SOLE capture path for scroll-mode
	// scrollback and the transient preview target — the TUI no longer captures tmux
	// itself (#1592 Phase 2 PR6), and off-box snapshots use this same path.
	Preview(tab int, full bool) (PreviewSnapshot, error)
	// PreviewByID is Preview addressed by the tab's stable identity. Implementations
	// must either bind directly to the identified capture target or keep identity
	// resolution and the ordinal capture in one critical section; resolving first
	// and using that ordinal against a later roster can expose another tab (#2200).
	// ErrTabGone reports that the exact target no longer exists.
	PreviewByID(tabID string, full bool) (PreviewSnapshot, error)
	// Alive reports whether the underlying session process is still running, and
	// whether the probe could be ANSWERED at all. Kept separate from Snapshot
	// (rather than folded into Observation) so the daemon probes liveness ONLY on
	// the idle branch, exactly as before — folding it in would add a liveness
	// probe to every non-idle tick.
	//
	// A non-nil error means UNKNOWN, not dead: the probe itself failed. For the
	// remote runtime that is a REST call to the sandbox's agent-server that never
	// completed — a dropped ssh forward, a docker-proxy hiccup, a blackholed
	// route. The local runtime probes in-process and never errors.
	//
	// The distinction is load-bearing, which is why the error is on the signature
	// rather than swallowed into a bare bool. "Unreachable" and "reachable, and
	// the agent is gone" are the same `false` but demand OPPOSITE responses: the
	// first may be a transient blip that must be waited out, the second is an
	// authoritative answer that may be acted on at once. Collapsing them is what
	// let a single transport blip re-provision a live sandbox and destroy its
	// unpushed work (#1794) — so callers that act destructively on `false` MUST
	// branch on the error. Callers for whom both cases warrant the same response
	// may ignore it.
	Alive() (bool, error)

	// SendPrompt delivers a prompt over the reliable command path (tmux send-keys
	// for the local runtime) — the path automated/scheduled deliveries use, which
	// survives a PTY that is not currently attached. This is the daemon's delivery
	// primitive; interactive per-keystroke input is Input, on the data plane.
	SendPrompt(prompt string) error
	// Subscribe returns a fan-out read of tab `tab`'s PTY stream from cursor
	// `since` (0 = from the ring-buffer tail / live), so a reconnecting client
	// replays the gap it missed. tab 0 is the agent tab; tab>0 is a shell/process
	// tab — each tab has its own bounded ring buffer and clientless capture (#1592
	// Phase 2 PR6, tab-aware). The local agent-server drives a clientless tmux
	// channel — pipe-pane for output capture — and fans the bytes to every
	// subscriber; a subscriber that falls behind or dies is dropped without touching
	// the PTY (§6). Read-write: Input/Resize below are accepted from every
	// subscriber (multi-writer, no lease).
	Subscribe(tab int, since Seq) (PTYSubscription, error)
	// Input writes raw bytes to tab `tab`'s PTY (the multi-writer input path that
	// subsumes the old tmux-shaped SendKeys). For the local runtime it is a
	// clientless tmux send-keys, accepted from any subscriber.
	Input(tab int, b []byte) error
	// Resize sets tab `tab`'s PTY size; last-resize-wins across subscribers. The
	// local runtime drives a clientless tmux resize-window and broadcasts an
	// authoritative size echo to every subscriber so their emulators reflow (§6.2).
	Resize(tab int, rows, cols uint16) error

	// Kill terminates the session and releases its backing resources.
	Kill() error

	// Archive makes the workspace durable before its sandbox is torn down (#1592
	// Phase 4 PR6): it commits any uncommitted work and pushes the session branch
	// to origin (GitHub is the durable workspace store, epic decision 4),
	// returning the pushed branch so the orchestrator can clone it back on
	// restore. It is the primitive the disposable off-box runtimes (docker/ssh/hook)
	// archive through — the daemon calls it over the wire, and the in-sandbox
	// local agent-server pushes the branch it owns. The local in-process runtime
	// implements it too (a plain commit+push of its worktree), but the daemon
	// never drives a LOCAL session's archive through here — a local session
	// archives by relocating its worktree (§5.1), so this stays dormant for it.
	Archive() (string, error)
}

AgentServer is the uniform contract the daemon speaks to a session's runtime, regardless of where that runtime physically lives (#1592 Phase 2 — the OpenHands-style agent-server seam). The daemon's observation and delivery paths depend ONLY on this interface; the tmux mechanism is an internal detail of the LOCAL in-process implementation (agentserver_local.go), no longer visible on the daemon's path. A Phase-4 runtime (container/ssh) implements the same interface over a native PTY behind an authed URL, and the daemon code above it does not change.

This is the locality leak the epic set out to remove: before PR4 the daemon called tmux-shaped Backend methods (HasUpdated/IsAlive/ SendPromptCommand/Preview) directly, baking "the session is local tmux" into the orchestrator. Those methods now live behind the agent-server.

MULTI-WRITER (locked #1592): the data plane has NO lease. Subscribe is read-write for every subscriber, Input/Resize are accepted from any of them, and the PTY size is last-resize-wins. There is deliberately no mode argument — af is single-owner, so gating typing would be needless machinery. A lease is additive/reversible later if the rare two-active-clients resize-flap ever bites.

func NewRemoteAgentServer added in v1.0.181

func NewRemoteAgentServer(ep AgentServerEndpoint, title string) (AgentServer, error)

NewRemoteAgentServer builds a remoteAgentServer that drives the `af agent-server` reachable at ep.URL for a single workspace titled `title`. It validates the URL up front (no dial) so a bad endpoint fails at construction rather than on first use — which is why Instance.AgentServer() (infallible) can build one from an endpoint validated at NewInstance. The integration test constructs one here directly against a real out-of-process agent-server.

type AgentServerEndpoint added in v1.0.181

type AgentServerEndpoint struct {
	// URL is the agent-server's plain-HTTP base URL — `http://host:port` or
	// `ws://host:port` (equivalent; both select the same plaintext transport,
	// only the authority is used). A wss://host:port is rejected — the
	// agent-server is HTTP-only.
	URL string
	// Token is the bearer credential presented on every REST call and WS handshake.
	Token string
}

AgentServerEndpoint is the runtime handle that points an Instance at a remote agent-server (#1592 Phase 4 PR2): the authed URL of the `af agent-server` in the sandbox plus the auth material to reach it. A nil endpoint ⇒ the local in-process runtime (the default, unchanged). Phase 4 PR3 generalizes this into a Runtime that PROVISIONS the sandbox and fills these in; PR2 only consumes them.

type AgentSwapPlan added in v1.0.207

type AgentSwapPlan struct {
	// contains filtered or unexported fields
}

AgentSwapPlan is the immutable boundary between handoff preflight and runtime replacement. Its fields are intentionally private: only a Backend can produce a plan, and callers can only hand that same value back to SwapAgent. That makes it impossible for the destructive half to silently launch a command different from the one that was checked while the outgoing agent was still alive.

func (AgentSwapPlan) ConversationCapture added in v1.0.207

func (p AgentSwapPlan) ConversationCapture() ConversationCaptureSnapshot

ConversationCapture returns the provider-store before-image frozen by preflight. The snapshot is opaque outside session: callers can pass it to the capture API, but cannot retarget it after the outgoing runtime is stopped.

type Backend added in v1.0.46

type Backend interface {
	// Start initialises the session. When firstTimeSetup is true a brand-new
	// session is created; otherwise an existing one is restored from storage.
	//
	// Each backend implements Start as two phases (#1592 Phase 1 PR4): a PROVISION
	// step that establishes WHERE the agent runs (the local git worktree, or a
	// provisioned off-box workspace) and a LAUNCH step that starts WHAT runs in it
	// (the tmux/PTY/agent process and its tabs). Start is Provision then Launch.
	// The two halves are on the interface (#1592 Phase 2 PR4) so the local
	// agent-server's provision-and-expose model can drive them separately; Start
	// stays as the combined lifecycle entry point its existing callers use.
	Start(instance *Instance, firstTimeSetup bool) error

	// Provision establishes WHERE the session runs without starting the agent
	// process — the local git worktree + tmux binding, or a remote/off-box
	// workspace. The first half of Start. See each backend's implementation for the
	// precise on-disk vs in-memory boundary.
	Provision(instance *Instance, firstTimeSetup bool) error

	// Launch starts (or restores) WHAT runs in the workspace Provision established
	// — the agent process and its tabs. The second half of Start; it owns the
	// failure-cleanup scope. A fresh worktree is removed only when the launcher
	// positively establishes that no runtime began; an unknown startup outcome
	// leaves it in place for the caller to retain (#2207).
	Launch(instance *Instance, firstTimeSetup bool) error

	// Kill terminates the session and cleans up all associated resources.
	Kill(instance *Instance) error

	// CloseAttachOnly releases resources this Instance opened to view or drive the
	// session WITHOUT destroying the underlying session, worktree, or off-box
	// workspace. It is the non-destructive sibling of Kill, used to discard a
	// duplicate Instance built from disk that lost a race to the canonical,
	// still-tracked Instance — see the daemon's findSession (#867). Killing such a duplicate
	// would tear down state the canonical Instance shares; closing only its
	// attach resources reclaims the PTY without that collateral damage.
	CloseAttachOnly(instance *Instance) error

	// Preview returns the current visible output of the session.
	Preview(instance *Instance) (string, error)

	// PreviewFullHistory returns the full scrollback history.
	PreviewFullHistory(instance *Instance) (string, error)

	// HasUpdated reports whether the session output changed since the last
	// check and whether the program is showing a prompt, and returns the raw
	// captured pane content so the daemon's usage-limit detector (#1146) can
	// inspect it without a second capture. content is "" when the capture or
	// remote observation is unavailable.
	HasUpdated(instance *Instance) (updated bool, hasPrompt bool, content string)

	// SendPromptCommand sends a prompt using a reliable command-based approach
	// (tmux send-keys for the local runtime). This is the SOLE prompt-delivery
	// primitive: AgentServer.SendPrompt delegates here, and it lands whether or
	// not a PTY is currently attached — the raw PTY-write SendPrompt (a 100ms
	// send-then-Enter) was deleted as dead post-migration (#1626).
	SendPromptCommand(instance *Instance, prompt string) error

	// IsAlive returns true if the underlying session is still running.
	// IsAlive reports whether the instance's agent is running, and returns an
	// error when the runtime could not be ASKED (#1917 round 8). The error is the
	// tri-state: a bool alone forces a timed-out probe to pick yes or no, and the
	// convenient pick — "yes" — is what let a wedged tmux server be counted as
	// affirmative proof of life all the way up in the daemon's poll. An
	// implementation that cannot answer must say so rather than guess.
	IsAlive(instance *Instance) (bool, error)

	// CheckAndHandleTrustPrompt auto-dismisses trust/permission prompts
	// for supported programs.
	CheckAndHandleTrustPrompt(instance *Instance) bool

	// AgentModelChange returns the active, runtime-derived model diagnostic after
	// prompt handling has had a chance to observe a safety dialog. It is read by
	// AgentServer.Snapshot so local and off-box runtimes cross the same choke point.
	AgentModelChange(instance *Instance) *AgentModelChange
	// Recover re-establishes a Lost session's backing resources — the tmux
	// session vanished out from under a live record with no kill on record
	// (#1108) — re-spawning the program in the instance's worktree. It is
	// invoked by the daemon's restore loop and by user-initiated restore
	// (af sessions restore), never as a load-time side effect (the #970 guard
	// in Start stays authoritative for loads). Every backend services it at full
	// parity since #1592 Phase 4: the sandbox runtimes (docker/ssh/hook)
	// re-provision a fresh sandbox that clones the durable branch back from
	// origin (recoverSandbox, §5.1) — there is no ErrRecoverUnsupported anymore.
	Recover(instance *Instance) error

	// Respawn re-establishes an instance's backing session in place — re-spawning
	// the agent program via the resume path (resumeProgram: claude --continue,
	// codex resume --last) — WITHOUT any liveness precondition. It is the
	// guard-free core Recover wraps with its Lost guard; the usage-limit
	// manual-retry (#1146) uses it directly because a LimitReached session (which
	// Recover's !Lost guard rejects) needs the identical re-spawn. Callers own the
	// precondition. The sandbox runtimes (docker/ssh/hook) service it through the
	// same recoverSandbox re-provision-and-clone path as Recover — no backend
	// returns an unsupported sentinel.
	Respawn(instance *Instance) error

	// SwapAgent tears down the running agent process and launches the instance's
	// CURRENT program in its place, as a fresh conversation (#2013). The caller
	// has already rewritten Instance.Program to the incoming agent and owns every
	// precondition; this is only the runtime half.
	//
	// It is deliberately not Respawn. Respawn goes through the resume path, which
	// appends the provider's "continue the most recent conversation here" flag —
	// correct when the SAME agent is coming back after its session vanished, and
	// wrong for a handoff, where the incoming agent has no conversation in this
	// worktree to continue and would be asked to resume one that does not exist.
	// A handoff is a first launch for the new agent, so it takes the first-launch
	// path.
	//
	// It is also not Restore-based: Restore on a session tmux still reports as
	// live is a pure logical rebind that never re-execs the program, and a
	// usage-limit-blocked agent IS live. Routing a swap through it would rewrite
	// the program string and leave the old agent running — a silent no-op. The
	// teardown below is what makes the re-launch actually happen.
	//
	// Backends whose workspace is off-box do not implement this: swapping the
	// agent inside a provisioned sandbox is a different lifecycle (re-launch
	// inside the sandbox, not re-provision it), so they return
	// ErrHandoffUnsupported and the Handoff capability bit is false.
	// PrepareAgentSwap resolves and validates the exact command that a handoff
	// would launch. It runs before the outgoing process is touched; SwapAgent must
	// consume this plan rather than resolving configuration again after teardown.
	PrepareAgentSwap(instance *Instance, target string) (AgentSwapPlan, error)
	SwapAgent(instance *Instance, plan AgentSwapPlan) error

	// Type returns the persisted backend identifier (local, docker, ssh, or
	// remote). Since #1592 Phase 1 this is the serialization discriminator only (the
	// load-time factory in instance_data.go) — runtime branching goes through
	// Capabilities, never Type().
	Type() string

	// Capabilities reports which optional operations this backend can service,
	// replacing Type()-based special-casing (#1592 Phase 1).
	Capabilities() Capabilities
}

Backend abstracts the session lifecycle so instances can be backed by local tmux+git worktrees (the default) or an off-box docker, SSH, or hook runtime.

type BackendKind added in v1.0.181

type BackendKind string

BackendKind names a session runtime family (#1592 Phase 4 PR3). It is the value of the in-repo `backend` config key and the `--backend` create flag, and the key the runtime registry maps to a Runtime constructor.

const (
	// BackendLocal is the in-process runtime: the agent runs as a tmux
	// session in a git worktree on the daemon's own box. The default — an empty
	// `backend` selection resolves here, unchanged from before Phase 4.
	BackendLocal BackendKind = config.BackendLocal
	// BackendDocker runs the workspace + agent in a container (Phase 4 PR4).
	BackendDocker BackendKind = config.BackendDocker
	// BackendSSH runs the workspace + agent on a remote host over ssh (PR5).
	BackendSSH BackendKind = config.BackendSSH
	// BackendSandbox runs the workspace + agent on whatever the operator's own
	// `sandbox_ssh` command reaches — the free-form sibling of BackendSSH, for
	// targets structured ssh.host cannot express (jump hosts, ProxyCommand,
	// bastions). #2476 PR2.
	BackendSandbox BackendKind = config.BackendSandbox
	// BackendHook is the remote-hook backend: the bring-your-own-provisioner
	// escape hatch, migrated to the same provision-and-expose contract as
	// docker/ssh (#1592 Phase 4 PR7). launch_cmd provisions the workspace on the
	// user's infra and exposes an `af agent-server` URL.
	BackendHook BackendKind = config.BackendHook
)

func BackendKindFor added in v1.0.185

func BackendKindFor(opts InstanceOptions, absPath string) (BackendKind, error)

BackendKindFor reports which runtime a create with these options against absPath will use, WITHOUT creating anything. It is the same decision (and the same precedence) NewInstance makes internally.

The daemon needs this before it provisions: remote hook names are a global namespace (the slug reaches launch_cmd/delete_cmd verbatim), so the hook-name checks must run for every create that will end up on the hook backend — not just the legacy ForceRemote selector. `--backend hook` and a repo's `backend = "hook"` config both reach BackendHook with ForceRemote false, and gating on ForceRemote alone let those creates skip the check entirely.

func ParseBackendKind added in v1.0.181

func ParseBackendKind(s string) (BackendKind, error)

ParseBackendKind validates a raw `backend` value (from the `--backend` flag or the in-repo config) and returns the corresponding BackendKind. An empty value is the default, local. An unknown value is a misconfiguration and errors — mirroring RemoteHooks.Validate, this validation runs when a runtime is resolved at create time, not at config load.

func (BackendKind) CarriesAccount added in v1.0.226

func (k BackendKind) CarriesAccount() bool

CarriesAccount reports whether this kind's provisioner will SAFELY HONOUR a registered credential account — including the agent's persistent WRITES back into it — on the machine that runs the agent (#3082, #3103).

Deliberately not "can place". #3103 established that ssh can physically place an account and still answers FALSE here, so callers depend on the stronger predicate; leaving the contract phrased as placement would invite a future backend author to answer true for a copy-only mechanism, which is precisely the unsafe behaviour that decision rejects.

THIS IS A CLAIM ABOUT A PROVEN MECHANISM, NOT A CAPABILITY, and the difference decides what a per-KIND answer may say (#3103).

Only docker, because only docker has one: an account is an agent HOME directory, the docker runtime already bind-mounts host paths into the container, and a MOUNT is shared with the host — so the agent's writes, including a refreshed token, land in the operator's real registry rather than in a copy that teardown deletes.

ssh, sandbox and hook answer false for AccountWriteBackRationale — NOT because a copy is the only thing they could ever do. hook's launch_cmd may provision anything and only has to hand back an agent-server endpoint, so it could use shared storage or even run on the daemon host; a mount-like transport for ssh or sandbox would qualify too. What is missing is the guarantee, not the possibility (#3103 review).

Nor is this "every off-box kind": docker is off-box (backendProvisionsOffBox) and answers TRUE, so off-box is the wrong axis to reason on here.

Placement is not the axis either — sandboxProvisioner.provision creates the session dir and streams af's binary into it, and the provision-hook path reuses that provisioner. See offBoxAccountRefusal.

Because a kind that answers FALSE promises nothing, a per-KIND answer is safe here even for hook, whose modes differ from one another — the objection to a blanket per-kind answer only bites for a kind claiming TRUE, where the kind would have to determine the machine's shape in order to be making a promise it can keep.

Local is deliberately NOT listed: it needs no placing, applies the account through the exec shim, and is handled by its own branch. A kind that answers true here is promising it will honour ProvisionSpec.Account.

func (BackendKind) InjectsSandboxCallback added in v1.0.224

func (k BackendKind) InjectsSandboxCallback() bool

InjectsSandboxCallback reports whether this kind's provisioner actually delivers the #2999 callback credential into the workspace.

Narrower than ProvisionsOffBox on purpose (#3012 review). Only the ssh and sandbox provisioners call writeCallbackEnv; docker and hook do not read the spec fields at all. Minting for them would have been strictly harmful — the create would newly fail whenever require_token was off, and buy the agent nothing when it succeeded, because nothing carries the credential in. A capability nobody delivers must not impose its precondition.

func (BackendKind) ProvisionsOffBox added in v1.0.219

func (k BackendKind) ProvisionsOffBox() bool

ProvisionsOffBox reports whether kind runs the session's workspace off the local filesystem. It is what decides whether a create resolves the repo's origin URL for the runtime to clone from: an off-box runtime clones from the durable store, a local one uses the worktree in place.

An unregistered kind reports false, which is the conservative answer — ParseBackendKind rejects those before they reach a runtime.

type BareSessionStreamer added in v1.0.210

type BareSessionStreamer struct {
	// contains filtered or unexported fields
}

BareSessionStreamer fans a BARE tmux session's PTY to WS subscribers WITHOUT an Instance behind it (#2467). The config assistant (#2453/configagent.go) is a daemon-owned tmux session with no session.Instance — no row in instances.json, no entry in m.instances — so the normal PTY stream route cannot reach it: that route resolves its byte source by looking the session up in m.instances (agentServerForStream). To be streamable through it IS to be a row, and the config agent must not be one.

The data plane does not need the row, though. The WS broker (#1592 PR5) fans bytes from a clientlessChannel — a `tmux pipe-pane` capture that needs only a tmux session, not an Instance — so this reuses newPTYBroker/newTmuxClientlessChannel directly, giving the config agent the same ring buffer, reconnect replay, multi-writer input, and last-detach capture teardown every session's pane has, with none of the Instance machinery. It is localAgentServer's data-plane half (ensureBroker/Subscribe/Input/Resize/Kill) with a single fixed pane and no tab, no backend, and no instance lock.

func NewBareSessionStreamer added in v1.0.210

func NewBareSessionStreamer(ts *tmux.TmuxSession) *BareSessionStreamer

NewBareSessionStreamer streams the PTY of a bare tmux session — a config agent (#2467). The session is driven clientlessly (pipe-pane/send-keys/resize-window), so this never opens a `tmux attach-session` render client the way the TUI's config-agent takeover does.

func (*BareSessionStreamer) Close added in v1.0.210

func (s *BareSessionStreamer) Close()

Close latches the streamer shut and tears the broker down: its clientless capture stops and every subscriber's NextEvent returns io.EOF, so a PTY-only client learns the stream ended at once. Idempotent. After Close a Subscribe is refused rather than resurrecting a capture on a reaped session (#1632). It does NOT reap the tmux session itself — the owner (configAssistantHub) does that.

func (*BareSessionStreamer) Input added in v1.0.210

func (s *BareSessionStreamer) Input(b []byte) error

Input writes raw bytes to the pane (multi-writer, from any subscriber).

func (*BareSessionStreamer) Resize added in v1.0.210

func (s *BareSessionStreamer) Resize(rows, cols uint16) error

Resize sets the pane size (last-resize-wins, echoed to every subscriber).

func (*BareSessionStreamer) Subscribe added in v1.0.210

func (s *BareSessionStreamer) Subscribe(since Seq) (PTYSubscription, error)

Subscribe opens one subscriber's read side of the stream, replaying from since (0 for the live tail). Refused once Close has run.

type Capabilities added in v1.0.173

type Capabilities struct {
	// Workspace records where the workspace lives (local worktree vs off-box).
	Workspace WorkspaceKind

	// Archive: the session can be archived/restored (local-worktree relocation
	// today; push/pull the branch once every backend clones from GitHub).
	Archive bool
	// Recover: a Lost session can be reconnected / re-spawned in place.
	Recover bool
	// TabManagement: the user can add/close tabs that RUN A PROCESS — a shell or
	// process tab, which needs a PTY in the daemon-side worktree. It does not
	// govern metadata-only tabs (a web tab is a name and a URL): those spawn
	// nothing, so gating them on this bit refused them for a reason that did not
	// apply to them (#3053). Ask TabKindRequires what a kind needs, and
	// RefuseTabKind whether this backend can serve it.
	TabManagement bool
	// TerminalTab: an interactive terminal surface is available. Off-box runtimes
	// provide it through their AgentServer stream rather than a daemon-local tmux.
	TerminalTab bool
	// InteractiveInput: raw key / prompt injection works — SendKeys/
	// SendPrompt drive a live PTY rather than returning "not supported".
	InteractiveInput bool
	// Handoff: the session's agent program can be swapped in place, keeping the
	// same workspace and branch (#2013). Local-worktree only for now — a sandbox
	// backend would have to re-launch the agent inside the provisioned sandbox
	// rather than re-provision it, which is a separate lifecycle.
	Handoff bool
}

Capabilities is a backend's self-description: which optional session operations it can service. The daemon and UI branch on these instead of on Type()=="remote" (#1592 Phase 1), so a NEW backend declares what it supports rather than every call-site learning its name. The end state is full parity — every backend implements every capability — but the descriptor stays so a surface can gray out an op a given runtime hasn't wired up yet.

func (Capabilities) RefuseTabKind added in v1.0.224

func (c Capabilities) RefuseTabKind(kind TabKind, target string) error

RefuseTabKind reports why this backend cannot serve a tab of this kind, or nil if it can. It is the single gate for "may this session gain THIS tab", and it asks what the KIND needs rather than whether the session is off-box (#3053): the old form refused every kind on off-box sessions and explained each refusal as a missing worktree, which is untrue of a tab that spawns nothing.

target is the web tab's resolved URL and is ignored for every other kind. It is a parameter because a web tab's blockers depend on WHERE it points: only a loopback target is reverse-proxied, so only a loopback target is affected by the daemon-host routing gap. Naming that blocker for an external URL would state a requirement the caller's tab does not have — the exact defect this function exists to remove, one level down.

Each refusal names the requirement that is actually unmet, because a user told "not supported on this backend" cannot tell that from a kind that could work.

type CleanupRetry added in v1.0.218

type CleanupRetry struct {
	// contains filtered or unexported fields
}

CleanupRetry paces the daemon's retry of a retained kill tombstone.

Before this, a tombstone whose cleanup could not complete was retried on EVERY status poll — once a second, forever, two log lines per attempt (#2737). That is not resilience: for a cause that cannot heal on its own it is a hot loop burning a user's CPU and log volume with nothing to act on.

So two bounds, for the two different kinds of failure:

  • A failure that MIGHT heal (an unreachable host, a busy remote) backs off exponentially to a settled cadence and keeps trying, because an outage that ends must recover without a daemon restart.
  • A failure that CANNOT heal by retrying — a cleanup handle that is structurally unusable, such as a pre-#2704 SSH tombstone whose host-key posture was never recorded — is RETIRED. Repeating identical inputs cannot produce a different answer, so the record stops being retried and is surfaced once for the operator to act on.

The zero value is ready to use. State is in-memory on purpose: a daemon restart re-attempts a retired record once, which is the right behavior when an operator may have fixed the cause (added the host key, restored the remote) in between.

func (*CleanupRetry) Due added in v1.0.218

func (r *CleanupRetry) Due(now time.Time) bool

Due reports whether another attempt is allowed now. A retired entry never is.

func (*CleanupRetry) Failures added in v1.0.218

func (r *CleanupRetry) Failures() int

Failures is the consecutive-failure count, for reporting.

func (*CleanupRetry) RecordFailure added in v1.0.218

func (r *CleanupRetry) RecordFailure(now time.Time, err error) bool

RecordFailure books one failed attempt and schedules the next. It reports whether this failure earns an operator-visible escalation — true exactly once per streak, either when the cause becomes unusable (retire) or when the failure count crosses the threshold.

func (*CleanupRetry) RecordSuccess added in v1.0.218

func (r *CleanupRetry) RecordSuccess()

RecordSuccess clears the streak, so a cause that healed leaves no backoff behind for the next unrelated failure.

func (*CleanupRetry) Retired added in v1.0.218

func (r *CleanupRetry) Retired() bool

Retired reports that this cleanup can never succeed by retrying and has been given up on. It stays true until the entry is dropped (daemon restart) or a caller records a success.

type CommittedTabClose added in v1.0.213

type CommittedTabClose struct {
	TeardownErr error
	Settled     *InstanceData
}

CommittedTabClose separates the durable roster decision from the best-effort runtime teardown that follows it. A nil TeardownErr confirms both the PTY stream and tmux close completed; a non-nil value means the tab is durably absent but runtime teardown could not be confirmed. Callers must not turn the latter into a failed commit and retry the state mutation as though it never happened.

The unconfirmed case is not merely reported, it is RETAINED: the commit persists a TabCleanupData handle for the tab's tmux session, and only a confirmed teardown retires it. Settled is that retirement — the projection with the handle already dropped, which the caller should persist so the next daemon does not retry a kill that already succeeded. It is nil when there was nothing to retire (a tmux-less tab, or a teardown left unconfirmed), and persisting it is best-effort: losing that write only costs one idempotent re-kill of an absent session, whereas losing the handle itself is the leak this whole mechanism exists to prevent.

type ConversationCaptureSnapshot added in v1.0.146

type ConversationCaptureSnapshot struct {
	// contains filtered or unexported fields
}

ConversationCaptureSnapshot records provider-local state before a pane is spawned. It lets post-spawn capture identify a newly-created conversation without reading transcript contents.

func BeginConversationCapture added in v1.0.146

func BeginConversationCapture() ConversationCaptureSnapshot

BeginConversationCapture snapshots local provider transcript stores before spawning a tab. Today only Codex exposes a local file id we can safely observe; other providers degrade to their existing latest-session resume path unless a deterministic id was injected before spawn (Claude).

func BeginConversationCaptureAtCodexHome added in v1.0.206

func BeginConversationCaptureAtCodexHome(home string) ConversationCaptureSnapshot

BeginConversationCaptureAtCodexHome snapshots an exact Codex store rather than the daemon's inherited CODEX_HOME. Config-agent commands can carry an inline environment assignment, so the process-specific path must be resolved before launch and supplied here (#2228 review).

type CreateLaunchPlan added in v1.0.207

type CreateLaunchPlan struct {
	// contains filtered or unexported fields
}

CreateLaunchPlan freezes the provider-local facts that must be decided after provisioning fixes the final workspace path but before the agent process is spawned. Its fields are private so a caller cannot retarget a capture or swap the checked command between the two halves.

func (CreateLaunchPlan) ConversationCapture added in v1.0.207

func (p CreateLaunchPlan) ConversationCapture() ConversationCaptureSnapshot

ConversationCapture returns the provider-store before-image frozen before process launch. The daemon passes it unchanged to generation-scoped capture.

type DockerRuntimeCleanupData added in v1.0.207

type DockerRuntimeCleanupData struct {
	ContainerID string `json:"container_id"`
	// EngineID is Docker's stable, non-secret daemon ID. Empty means a legacy
	// tombstone that cannot be targeted safely and must fail closed.
	EngineID string `json:"engine_id,omitempty"`
}

type FakeBackend added in v1.0.53

type FakeBackend struct {
	// contains filtered or unexported fields
}

FakeBackend is a Backend implementation for tests that need to drive the creation flow without spawning real tmux sessions or git worktrees.

The only method with nontrivial behavior is Start: it blocks until the test calls CompleteStart or FailStart, so tests can observe the app state while an instance is mid-creation (e.g. send navigation keys before the instance is marked Running). All other methods are safe no-ops so the preview/metadata ticks don't crash when they sweep the sidebar.

Exported (in the session package rather than a _test.go file) so that app/ e2e tests can reach it via session.NewFakeBackend.

func NewFakeBackend added in v1.0.53

func NewFakeBackend() *FakeBackend

NewFakeBackend returns a FakeBackend with its Start call pre-armed to block. Tests must arrange for CompleteStart/FailStart to be invoked, otherwise the creation goroutine will hang forever.

func (*FakeBackend) AgentModelChange added in v1.0.207

func (b *FakeBackend) AgentModelChange(*Instance) *AgentModelChange

func (*FakeBackend) Capabilities added in v1.0.173

func (b *FakeBackend) Capabilities() Capabilities

Capabilities reports local full parity by default so the fake stands in for a local session (#1592 Phase 1). Test doubles that impersonate a remote backend override this to return a WorkspaceRemote descriptor.

It DELEGATES to LocalBackend rather than restating the descriptor. A copy here would only be a claim to mirror the local runtime, and nothing would check it: a capability added to Capabilities and wired into LocalBackend would leave every FakeBackend-driven test silently asserting against a stale descriptor — the fake would keep reporting the op unsupported while the real local backend supports it. Delegation makes the mirror true by construction.

func (*FakeBackend) CheckAndHandleTrustPrompt added in v1.0.53

func (b *FakeBackend) CheckAndHandleTrustPrompt(*Instance) bool

func (*FakeBackend) CloseAttachOnly added in v1.0.114

func (b *FakeBackend) CloseAttachOnly(*Instance) error

CloseAttachOnly is a no-op for the fake backend: it holds no real PTY to release. Tests that need to distinguish a non-destructive close from a Kill embed FakeBackend and override this (and Kill) to record the call.

func (*FakeBackend) CompleteStart added in v1.0.53

func (b *FakeBackend) CompleteStart()

CompleteStart releases a blocked Start with no error.

func (*FakeBackend) FailStart added in v1.0.53

func (b *FakeBackend) FailStart(err error)

FailStart releases a blocked Start with the given error.

func (*FakeBackend) HasUpdated added in v1.0.53

func (b *FakeBackend) HasUpdated(*Instance) (bool, bool, string)

func (*FakeBackend) IsAlive added in v1.0.53

func (b *FakeBackend) IsAlive(*Instance) (bool, error)

func (*FakeBackend) Kill added in v1.0.53

func (b *FakeBackend) Kill(instance *Instance) error

func (*FakeBackend) Launch added in v1.0.176

func (b *FakeBackend) Launch(instance *Instance, _ bool) error

func (*FakeBackend) PrepareAgentSwap added in v1.0.207

func (b *FakeBackend) PrepareAgentSwap(_ *Instance, target string) (AgentSwapPlan, error)

func (*FakeBackend) Preview added in v1.0.53

func (b *FakeBackend) Preview(*Instance) (string, error)

func (*FakeBackend) PreviewFullHistory added in v1.0.53

func (b *FakeBackend) PreviewFullHistory(*Instance) (string, error)

func (*FakeBackend) Provision added in v1.0.176

func (b *FakeBackend) Provision(*Instance, bool) error

Provision is a no-op for the fake backend: it holds no workspace. The blocking start semantics (startCalled/startBlock, startErr, SetStartedForTest) live in Launch so Start = Provision then Launch matches the real backends (#1592 Phase 2 PR4).

func (*FakeBackend) Recover added in v1.0.139

func (b *FakeBackend) Recover(*Instance) error

func (*FakeBackend) Respawn added in v1.0.140

func (b *FakeBackend) Respawn(*Instance) error

func (*FakeBackend) SendPromptCommand added in v1.0.53

func (b *FakeBackend) SendPromptCommand(*Instance, string) error

func (*FakeBackend) Start added in v1.0.53

func (b *FakeBackend) Start(instance *Instance, firstTimeSetup bool) error

func (*FakeBackend) StartCalled added in v1.0.53

func (b *FakeBackend) StartCalled() <-chan struct{}

StartCalled returns a channel that is closed when Start is first invoked.

func (*FakeBackend) SwapAgent added in v1.0.206

func (b *FakeBackend) SwapAgent(*Instance, AgentSwapPlan) error

func (*FakeBackend) Type added in v1.0.53

func (b *FakeBackend) Type() string

type GitWorktreeData

type GitWorktreeData struct {
	RepoPath          string `json:"repo_path"`
	WorktreePath      string `json:"worktree_path"`
	SessionName       string `json:"session_name"`
	BranchName        string `json:"branch_name"`
	BaseCommitSHA     string `json:"base_commit_sha"`
	ExternalWorktree  bool   `json:"external_worktree,omitempty"`
	BranchCreatedByUs *bool  `json:"branch_created_by_us,omitempty"`
	// RelocationRecovery qualifies WorktreePath whenever a bounded lifecycle step
	// did not establish a safe outcome. Some states retain a second pathname;
	// every state blocks consumers until its owning retry resolves it.
	RelocationRecovery *GitWorktreeRelocationRecoveryData `json:"relocation_recovery,omitempty"`
}

GitWorktreeData represents the serializable data of a GitWorktree.

BranchCreatedByUs indicates whether the session created the underlying branch itself (vs. reused a pre-existing one). It is serialized via a pointer so that "missing" (nil, for data written before this field was added) can be distinguished from an explicit false. Missing values are treated as true to preserve the prior behavior for sessions that existed before this flag was introduced.

type GitWorktreeRelocationRecoveryData added in v1.0.229

type GitWorktreeRelocationRecoveryData struct {
	State git.RelocationRecoveryState `json:"state,omitempty"`
	// CleanupLifecycle carries cleanup-only states additively while State is
	// projected to claim_stale for previous releases which reject new enum values.
	CleanupLifecycle                   git.RelocationRecoveryState `json:"cleanup_lifecycle,omitempty"`
	AlternatePath                      string                      `json:"alternate_path"`
	IdentityKnown                      bool                        `json:"identity_known,omitempty"`
	Device                             uint64                      `json:"device"`
	Inode                              uint64                      `json:"inode"`
	FileType                           uint32                      `json:"file_type"`
	CleanupGeneration                  string                      `json:"cleanup_generation,omitempty"`
	CleanupOriginalExternalWorktree    *bool                       `json:"cleanup_original_external_worktree,omitempty"`
	CleanupOriginalBranchCreatedByUs   *bool                       `json:"cleanup_original_branch_created_by_us,omitempty"`
	CleanupOriginalStartupStateUnknown *bool                       `json:"cleanup_original_startup_state_unknown,omitempty"`
	OriginalExternalWorktree           *bool                       `json:"original_external_worktree,omitempty"`
	OriginalBranchCreatedByUs          *bool                       `json:"original_branch_created_by_us,omitempty"`
	OriginalStartupStateUnknown        *bool                       `json:"original_startup_state_unknown,omitempty"`
}

type HandoffSwap added in v1.0.207

type HandoffSwap struct {
	AgentHandoff
	// contains filtered or unexported fields
}

HandoffSwap is the process-local transaction token returned when the ledger and Program are rewritten. AgentHandoff is the durable completed-swap record; previousProgram is deliberately kept out of it because rollback is synchronous and a successful ledger entry must not retain transaction-only state forever.

type HookBackend added in v1.0.46

type HookBackend struct {
	// contains filtered or unexported fields
}

HookBackend is the in-process Backend for a remote-hook session (#1592 Phase 4 PR7). Like sshBackend/dockerBackend, its agent-facing operations delegate to the instance's remote AgentServer (the HTTP/WS client to the user-provisioned `af agent-server`) — so lifecycle, preview, prompt, and liveness all go over the wire. Its ONE local responsibility is running delete_cmd to reap the provisioned sandbox, shared via the same idempotent closure with the AgentServer Kill path.

It stays EXPORTED (unlike sshBackend/dockerBackend) because it is the public bring-your-own-provisioner escape hatch and the canonical remote-backend stand-in in cross-package tests. A zero-value &HookBackend{} is a valid INERT hook backend (nil reap — nothing live to tear down), which is exactly what FromInstanceData rebuilds for a "remote" record loaded from disk and what restore replaces wholesale via a fresh hookRuntime.Provision.

func (*HookBackend) AgentModelChange added in v1.0.207

func (b *HookBackend) AgentModelChange(*Instance) *AgentModelChange

AgentModelChange is carried by the remote AgentServer's Snapshot; asking the daemon-side backend would recurse through that same server.

func (*HookBackend) Capabilities added in v1.0.173

func (b *HookBackend) Capabilities() Capabilities

Capabilities reports the common off-box runtime contract. Tab management is false because the AgentServer's tab API is data-plane only: it can drive an existing tab but cannot create the daemon-side git worktree required for a new one (#1874).

func (*HookBackend) CheckAndHandleTrustPrompt added in v1.0.46

func (b *HookBackend) CheckAndHandleTrustPrompt(*Instance) bool

CheckAndHandleTrustPrompt is a daemon-side no-op: each remote AgentServer handles it before returning a snapshot.

func (*HookBackend) CloseAttachOnly added in v1.0.114

func (b *HookBackend) CloseAttachOnly(i *Instance) error

CloseAttachOnly discards a duplicate instance's local view without reaping the remote workspace its canonical instance still owns.

func (*HookBackend) HasUpdated added in v1.0.46

func (b *HookBackend) HasUpdated(i *Instance) (updated bool, hasPrompt bool, content string)

func (*HookBackend) IsAlive added in v1.0.46

func (b *HookBackend) IsAlive(i *Instance) (bool, error)

IsAlive intentionally collapses an unanswerable AgentServer probe to false: its callers only use it for non-destructive TUI affordances. Destructive recovery paths call AgentServer.Alive directly so they can distinguish an unreachable remote from a dead one (#1794).

func (*HookBackend) Kill added in v1.0.46

func (b *HookBackend) Kill(i *Instance) error

func (*HookBackend) Launch added in v1.0.176

func (b *HookBackend) Launch(i *Instance, firstTimeSetup bool) error

Launch starts the remote agent and seeds the daemon-side mirror with its agent tab, if it does not already have one.

func (*HookBackend) PrepareAgentSwap added in v1.0.207

func (b *HookBackend) PrepareAgentSwap(*Instance, string) (AgentSwapPlan, error)

SwapAgent is not serviced off-box (#2013). Swapping the agent inside a provisioned sandbox means re-launching a different process INSIDE it while keeping the workspace — but every re-spawn path these runtimes have (recoverSandbox) re-provisions the sandbox and re-clones the branch from origin, which would discard unpushed work rather than hand it over. Wiring a genuine in-sandbox relaunch is its own change; until then this says so instead of quietly doing the destructive thing.

func (*HookBackend) Preview added in v1.0.46

func (b *HookBackend) Preview(i *Instance) (string, error)

func (*HookBackend) PreviewFullHistory added in v1.0.46

func (b *HookBackend) PreviewFullHistory(i *Instance) (string, error)

func (*HookBackend) Provision added in v1.0.176

func (b *HookBackend) Provision(i *Instance, firstTimeSetup bool) error

func (*HookBackend) Recover added in v1.0.139

func (b *HookBackend) Recover(i *Instance) error

Recover and Respawn both re-provision a disposable remote workspace from the session branch, then launch it again.

func (*HookBackend) Respawn added in v1.0.140

func (b *HookBackend) Respawn(i *Instance) error

func (*HookBackend) SendPromptCommand added in v1.0.46

func (b *HookBackend) SendPromptCommand(i *Instance, prompt string) error

func (*HookBackend) Start added in v1.0.46

func (b *HookBackend) Start(i *Instance, firstTimeSetup bool) error

Start provisions then launches the remote workspace through its AgentServer.

func (*HookBackend) SwapAgent added in v1.0.206

func (b *HookBackend) SwapAgent(*Instance, AgentSwapPlan) error

func (*HookBackend) Type added in v1.0.46

func (b *HookBackend) Type() string

type HookRuntimeCleanupData added in v1.0.207

type HookRuntimeCleanupData struct {
	DeleteCmd             string   `json:"delete_cmd"`
	Slug                  string   `json:"slug"`
	Agent                 string   `json:"agent,omitempty"`
	AgentResolved         bool     `json:"agent_resolved,omitempty"`
	AuthSelectors         []string `json:"auth_selectors,omitempty"`
	AuthSelectorsResolved bool     `json:"auth_selectors_resolved,omitempty"`
	SessionEnvPassthrough []string `json:"session_env_passthrough,omitempty"`
}

type IdleReason added in v1.0.240

type IdleReason string

IdleReason is the daemon's mechanically established explanation for why a session is not doing visible work. It deliberately excludes semantic guesses about pane content: a question, a completed task, and a wedged agent can render alike, so none of those is a value in this vocabulary.

const (
	IdleReasonNone                      IdleReason = ""
	IdleReasonUsageLimit                IdleReason = "usage-limit"
	IdleReasonProcessExited             IdleReason = "process-exited"
	IdleReasonRecreatePending           IdleReason = "recreate-pending"
	IdleReasonPromptNotDelivered        IdleReason = "prompt-not-delivered"
	IdleReasonDeliveryUnconfirmed       IdleReason = "delivery-unconfirmed"
	IdleReasonNoPaneChangeSinceDelivery IdleReason = "no-pane-change-since-delivery"
	IdleReasonSettledAfterPaneChange    IdleReason = "settled-after-pane-change"
)

func IdleReasonFor added in v1.0.240

func IdleReasonFor(data InstanceData) IdleReason

IdleReasonFor derives the public reason from closed, mechanically observed facts. It does not trust InstanceData.IdleReason: that field is a projection, and deriving it here keeps persisted evidence the source of truth.

func (IdleReason) Label added in v1.0.240

func (r IdleReason) Label() string

Label is the short human wording shared by row renderers. Unknown future values render nothing rather than inviting an older client to interpret them.

type InFlightOp added in v1.0.140

type InFlightOp int

InFlightOp is the client/executor-owned axis: the operation a client (or the daemon executor) is mid-way through, overlaid on the liveness. It is carried in daemon Snapshots so read-only TUIs can cold-start into the exact archive/restore operation, but disk writers scrub it before persistence: a transient overlay must not survive a daemon restart.

const (
	// OpNone: no client operation in flight.
	OpNone InFlightOp = iota
	// OpCreating: a create is in flight (was Loading).
	OpCreating
	// OpKilling: an optimistic kill is in flight (was Deleting).
	OpKilling
	// OpArchiving: an archive teardown+move is in flight (was Deleting used as
	// the archive fence) — a distinct value from OpKilling so the two owners no
	// longer collide (#1187). Populated only through the shim in Phase 1b; the
	// daemon archive executor sets it directly in 1c.
	OpArchiving
	// OpRestoring: an archive restore is in flight (replaces the RestoreFromArchive
	// "park it in Lost to trigger the re-spawn loop" hack). Wired in 1c.
	OpRestoring
	// OpReplacing: an agent handoff is between its outgoing and incoming runtime.
	// The status poll must not observe the close/start gap or settle a result from
	// the outgoing pane after the incoming pane has taken its place.
	OpReplacing
	// OpRespawning: a limit resume is re-spawning an EXISTING session's runtime —
	// for a remote backend, pushing the old sandbox's work and provisioning a fresh
	// one, which outlasts a poll interval (#2997). Its only job is to be an
	// in-flight op: refreshInstanceStatus skips a session that has one, and raising
	// it advances the state epoch so an observation the poll already decided is
	// dropped rather than applied.
	//
	// Deliberately NOT a client overlay, which is what separates it from
	// OpCreating. A respawn acts on a session that is already established, so
	// composeStatus lets it fall through to the settled liveness instead of masking
	// it with Loading. Masking it would be wrong twice over: SaveInstances drops
	// ordinary Loading rows, so a shutdown checkpoint mid-respawn would erase an
	// established session's only on-disk record and orphan its workspace; and the
	// TUI's reconcile clears adopted overlays per-op, so a create overlay it never
	// expected on an established row would strand it as Loading with its lifecycle
	// actions disabled until restart. There is nothing here for a client to mirror.
	OpRespawning
)

type Instance

type Instance struct {

	// ID is the instance's stable identity (#1195): a random UUID minted once at
	// NewInstance, persisted, and never mutated. The reconcile uses it to tell
	// "same session" from "title reused" (#765) without leaning on CreatedAt
	// equality — the audit's identity-by-circumstance gotcha (a manufactured or
	// zero-CreatedAt record silently degraded a swap into an in-place corpse
	// mutation). Legacy records persisted before #1195 carry no ID; the reconcile
	// receives a freshly minted in-memory ID from FromInstanceData, while daemon
	// load durably backfills that ID before materialization. Immutable after
	// construction, so cross-goroutine readers may read it without the mutex (like
	// Title).
	ID string
	// TaskID is the id of the task whose delivery spawned this session, empty for
	// a user-created one (#1892). It is the daemon's association between a task
	// delivery and its session, replacing title-prefix guessing; the watch-task
	// concurrency limit counts a task's in-flight sessions by it. Immutable after
	// construction, so cross-goroutine readers may read it without the mutex
	// (like ID and Title).
	TaskID string
	// Title is the title of the instance.
	Title string
	// Path is the path to the workspace.
	Path string
	// Branch is the branch of the instance.
	Branch string

	// Program is the program to run in the instance.
	Program string
	// Account is the credential account this instance's agent runs as, or empty
	// for the ambient identity — which is the behaviour every session had before
	// #3051 and remains the default.
	//
	// Persisted, because the identity a session runs as must survive a restart:
	// a restored session that silently reverted to the ambient account would
	// spend the wrong quota while still displaying the account it was created
	// with.
	Account string `json:"account,omitempty"`
	// Height is the height of the instance.
	Height int
	// Width is the width of the instance.
	Width int
	// CreatedAt is the time the instance was created.
	CreatedAt time.Time
	// UpdatedAt is the time the instance was last updated.
	UpdatedAt time.Time
	// Prompt is the initial prompt to pass to the instance on startup
	Prompt string

	// Tabs is the instance's ordered list of tabs. In PR 1 of the #930
	// ephemeral-tabs epic this holds exactly one Agent-kind tab (Tabs[0]) that
	// wraps the instance's single tmux session; every tmux-touching method
	// routes through it via tmuxLocked/setTmuxLocked. Remote/hook-backed
	// instances drive their agent session through hook commands and so carry no
	// tmux-backed tab. Later PRs add shell/process tabs, lifecycle, and per-tab
	// persistence.
	Tabs []*Tab
	// contains filtered or unexported fields
}

Instance is a running instance of claude code.

func FromInstanceData

func FromInstanceData(data InstanceData) (*Instance, error)

FromInstanceData creates a new Instance from serialized data

func NewInstance

func NewInstance(opts InstanceOptions) (*Instance, error)

func (*Instance) AcknowledgeRootRecreateContext added in v1.0.218

func (i *Instance) AcknowledgeRootRecreateContext() bool

AcknowledgeRootRecreateContext clears the one-shot marker and reports whether it was set, so the caller persists and announces exactly once. Seeing the session's pane IS the acknowledgement: the note exists to tell a user something they would otherwise learn only by reading the log, and once they are looking at the agent it has done its job.

It clears only a value THIS binary actually renders (Note() != ""). A record written by a newer daemon can carry an outcome this version does not know: it renders nothing for it, deliberately, and a value nobody was ever shown has not been acknowledged by anybody. Clearing it would let an older binary erase roll-forward state on a stream open — silently destroying the notice a newer daemon was going to display. Leaving it is the safe direction: the binary that understands it is the one that gets to clear it.

func (*Instance) AddProcessTab added in v1.0.123

func (i *Instance) AddProcessTab(command, requestedName string) (*Tab, error)

AddProcessTab spawns a new Process-kind tab running command in the instance's worktree, appends it to Tabs, and returns it (#930 PR 5). It is the CLI/agent-driven counterpart of AddShellTab: instead of $SHELL it runs an arbitrary command, so an agent can prompt-spawn a tab hosting a data explorer, test watcher, etc. Local instances only — remote instances have no local worktree, so callers must reject backends without the TabManagement capability before calling. The name is requestedName when non-empty, otherwise derived from the command's basename; it is sanitized and made unique within the instance ("btop", "btop-2", …) so its derived tmux session name is collision-free and restorable by exact name across a restart. Errors on an empty command, or an instance that is not started / has no worktree.

func (*Instance) AddShellTab added in v1.0.123

func (i *Instance) AddShellTab() (*Tab, error)

AddShellTab spawns a new Shell-kind tab running $SHELL in the instance's worktree, appends it to Tabs, and returns it. Local instances only — remote instances have no local worktree, so callers must reject backends without the TabManagement capability before calling. The new tab's name is unique within the instance ("shell", then "shell-2", "shell-3", …) and its tmux session name is derived from it so it is collision-free and restorable by exact name across a restart (#930 PR 4). Errors when the instance is not started or has no agent session/worktree.

func (*Instance) AddTabForTest added in v1.0.139

func (i *Instance) AddTabForTest(name string, kind TabKind)

AddTabForTest appends a tmux-less tab record. Test-only: UI tests (the sidebar tree, tab labels) need instances with a populated tab LIST without spinning up real tmux sessions; the tab is never attachable or previewable.

func (*Instance) AddVSCodeTab added in v1.0.188

func (i *Instance) AddVSCodeTab(requestedName string) (*Tab, error)

AddVSCodeTab appends a new VSCode-kind tab to the instance's Tabs and returns it. Like a web tab it spawns nothing here and holds no tmux session, so the append under the single write lock is the whole operation and no orphan window opens. It takes no target: a vscode tab ALWAYS edits this instance's worktree, and the code-server serving it is daemon-managed per session and resolved lazily at proxy time (see TabKindVSCode), so there is no URL to store. The name is requestedName when non-empty, otherwise "vscode", made unique within the instance ("vscode", "vscode-2", …). Errors when the instance is not started or has no worktree.

func (*Instance) AddWebTab added in v1.0.183

func (i *Instance) AddWebTab(url, requestedName string) (*Tab, error)

AddWebTab appends a new Web-kind tab pointing at url to the instance's Tabs and returns it. Unlike shell/process tabs a web tab has NO tmux session — it is pure metadata (a URL the web UI iframes and, for loopback targets, the daemon reverse-proxies), so there is nothing to spawn: the append itself is the whole operation. url must already be normalized (session.NormalizeWebTabURL); the name is requestedName when non-empty, otherwise "web", made unique within the instance ("web", "web-2", …). A web tab is persisted on the instance record and rebuilt from it on restart; capability admission decides which targets a local or off-box backend can serve. Errors when the instance is not started or its agent tab has not been materialized yet.

func (*Instance) AddWebTabForTest added in v1.0.188

func (i *Instance) AddWebTabForTest(name, url string)

AddWebTabForTest appends a web tab carrying url. Test-only: the URL is the whole payload of a web tab, so tests that assert it survives a lifecycle step (archive → restore, #1809) need to seed one. It bypasses AddWebTab's started / tmux-bound preconditions, which a fake-backend instance cannot satisfy.

func (*Instance) AgentConversation added in v1.0.146

func (i *Instance) AgentConversation() AgentConversationData

AgentConversation returns the Agent tab's recorded provider conversation, if one has been captured.

func (*Instance) AgentModelChange added in v1.0.207

func (i *Instance) AgentModelChange() *AgentModelChange

AgentModelChange returns an isolated copy of the active model diagnostic.

func (*Instance) AgentObservationCurrent added in v1.0.240

func (i *Instance) AgentObservationCurrent(generation AgentObservationGeneration) bool

AgentObservationCurrent reports whether generation still names the runtime whose transport produced an observation. Callers that use the result to mutate separately locked state must perform this comparison inside that state's critical section: replacement invalidates the generation before its own later bookkeeping reaches the same lock.

func (*Instance) AgentProgram added in v1.0.206

func (i *Instance) AgentProgram() string

AgentProgram returns the instance's configured agent program enum under the instance lock.

Program became mutable with #2013 (a handoff rewrites it in place), so the reads that can run concurrently with a swap must go through here. The write side holds i.mu; an unguarded read of the bare field races it. Construction and restore paths touch i.Program directly and are safe: the instance is not yet shared at that point.

func (*Instance) AgentRuntimeToken added in v1.0.207

func (i *Instance) AgentRuntimeToken() AgentRuntimeToken

AgentRuntimeToken snapshots the provider and runtime generation atomically. Capture callers take it before starting their goroutine and must use SetAgentConversationForRuntime to commit the eventual result.

func (*Instance) AgentServer added in v1.0.176

func (i *Instance) AgentServer() AgentServer

AgentServer returns the cached agent-server for this instance's runtime (#1592 Phase 2). The daemon speaks to a session ONLY through this interface, so its observation/delivery paths never assume the session is local tmux. Cached so the data-plane ring buffer and subscribers persist across calls.

This is the per-runtime factory (#1592 Phase 4 PR2): a session whose runtime exposes a remote agent-server (i.remoteClient, set at NewInstance from InstanceOptions.RemoteAgentServer) gets a remoteAgentServer HTTP/WS client; every other session gets the local in-process impl over tmux — the default, unchanged. The client was validated at construction, so this stays infallible.

func (*Instance) ArchiveSandbox added in v1.0.181

func (i *Instance) ArchiveSandbox() (string, error)

ArchiveSandbox makes an off-box session (docker/ssh/hook) durable and reaps its sandbox (#1592 Phase 4 PR6/PR7): it pushes the branch to origin over the agent-server, then tears the in-sandbox workspace down and reaps the sandbox (the AgentServer.Kill path), and finally clears the now-dead remote wiring so a later restore rebuilds it. Returns the pushed branch, which the caller records so restore knows which branch to clone back.

This is the raw mechanic the daemon's ArchiveSession wraps with its locks + state transitions + persistence (and which the round-trip test drives directly, like Start/Kill). It is a no-op-and-error for a session with no remote runtime — a local session archives by relocating its worktree, not here.

func (*Instance) ArchiveTeardown added in v1.0.139

func (i *Instance) ArchiveTeardown(dest string) error

ArchiveTeardown tears down every tab's tmux session for an archive AND relocates the worktree to dest in one operation (#1028) — the tmux half of Kill, but it PRESERVES the record and MOVES the worktree instead of deleting it. It routes through the shared teardownTabs core in the archive mode, so the #802 "wait for every pane to exit before touching the worktree" ordering is shared code with Kill rather than the duplicated prose it was when the move lived in a separate daemon step (#1195 Phase 2b). It is deliberately best-effort for tmux (a stuck session only logs, mirroring Kill) and:

  • keeps the AGENT tab's tmux binding (its session name) so a failed archive can re-spawn it in place via the Lost-restore loop;
  • drops the shell/process tabs entirely — their tmux sessions were just torn down, so only the agent session is brought back for them (Sachin's #1028 requirement);
  • KEEPS the web tabs (#1809): a web tab has no tmux session and no process — it is just a URL — so nothing was torn down and it round-trips through the archived record to render again on un-archive;
  • leaves gitWorktree and started untouched, so the daemon caller controls the final state (started=false + Archived on success; Lost on a failed move — returned here — where started stays true so the loop re-spawns the agent).

Returns the worktree-move error (nil on success). Local instances only — remote sessions have no local tmux/worktree and the daemon rejects archiving them before reaching here.

func (*Instance) ArchiveTeardownWithClaim added in v1.0.229

func (i *Instance) ArchiveTeardownWithClaim(dest string, claim git.RelocationClaim, beforeMove func() error) (hookErr, archiveErr error)

ArchiveTeardownWithClaim carries the source claim obtained before teardown to the hook and move use boundaries. Each boundary revalidates it independently.

func (*Instance) ArchiveTeardownWithHook added in v1.0.213

func (i *Instance) ArchiveTeardownWithHook(dest string, beforeMove func() error) (hookErr, archiveErr error)

ArchiveTeardownWithHook is ArchiveTeardown with one additional operator callback at the only safe cleanup point: every pane has been confirmed dead, but the worktree still occupies its live path. A callback failure is returned separately from the relocation result and never prevents the move.

func (*Instance) ArchiveWarning added in v1.0.238

func (i *Instance) ArchiveWarning() string

ArchiveWarning returns the bounded live notice for an incomplete archive. It is projection-only: the complete durable ownership report stays on the GitWorktree and storage projections scrub this string before writing disk.

func (*Instance) ArchivedBranchForReclaim added in v1.0.209

func (i *Instance) ArchivedBranchForReclaim() (string, bool)

ArchivedBranchForReclaim reports the branch an archived session is holding when — and only when — that branch may safely be renamed aside so a new session can take its title (#2127). ok is false whenever it may not be, and the caller must then leave the branch alone.

It lives here rather than in the daemon because the archived instance's worktree is not reachable through GetGitWorktree: that accessor is gated on `started`, which archiving clears, so a caller outside this package cannot ask the question at all. Answering it here also keeps every read of gitWorktree under i.mu, like the rest of this file.

Four declines, each one a case where renaming the user's branch is worse than refusing the create:

  • Not a local worktree (hook/docker/ssh): there is no local branch to move.
  • No worktree or no recorded branch: nothing to reclaim.
  • An EXTERNAL worktree (`--here`, or a pre-#930 adopted checkout). af adopted that branch rather than creating it; renaming it is not af's call.
  • PUBLISHED, or an upstream that could not be determined. A rename desyncs a pushed branch's local name from the remote it tracks and from any open PR. The unknown case declines for the same reason: a probe that cannot answer must not be what authorizes rewriting a user's branch.

func (*Instance) ArchivedCandidateBranchIsFree added in v1.0.209

func (i *Instance) ArchivedCandidateBranchIsFree(candidate string) bool

ArchivedCandidateBranchIsFree reports whether `candidate` is a branch name the archived session's worktree can be renamed ONTO — free, and confirmed free (#2127, P3 on #2465). It exists for the same reason as ArchivedBranchForReclaim: the archived worktree is not reachable through GetGitWorktree, so the daemon cannot run the check itself.

free is false BOTH when a branch of that name already exists (git refuses to rename onto it) and when existence could not be determined — an unknown answer is treated as taken, so the reclaim declines rather than renaming onto a name it could not rule out.

func (*Instance) AttachShellTab added in v1.0.125

func (i *Instance) AttachShellTab(name, tmuxName, tabID string) (*Tab, error)

AttachShellTab reconnects this local instance's in-memory tab list to a shell tab that already exists server-side — one the daemon's CreateTab RPC just spawned out-of-band (#960 PR 2). It is the no-spawn counterpart of AddShellTab: the daemon owns the spawn (so its authoritative view holds the tab and can't be clobbered), and the TUI only needs to reflect the new tab locally for instant display. It binds to the EXACT tmux session the daemon spawned and Restores (reconnects) it, mirroring restoreLocalTabs + LocalBackend.setupTabs, so the tab is immediately previewable/attachable without a second, colliding spawn.

name and tmuxName are BOTH the daemon's, as returned by CreateTab. tmuxName is passed, not re-derived as "<agent>__<name>", because the two are independent namespaces (#1957, see tab_names.go): after a rename the daemon spawns "…__shell-2" for a tab named "shell", and re-deriving would bind this projection to the OLDER tab's still-live session. Empty falls back to the derivation — right for its only cause, a daemon predating the field.

Local instances only — callers reject backends without TabManagement first. A tab with that name already present (a refresh raced ahead) makes this a no-op returning it. Errors when the instance is not started or has no session.

func (*Instance) AttachVSCodeTab added in v1.0.206

func (i *Instance) AttachVSCodeTab(name, tabID string) (*Tab, error)

AttachVSCodeTab reflects a VS Code tab that the daemon's CreateTab RPC has already created and persisted. It is the metadata-only counterpart of AttachShellTab: no editor or tmux session is spawned here; the TUI appends a projection immediately so the new tab is visible without waiting for the next snapshot.

tabID is the daemon-minted identity returned by CreateTab. An empty ID is the explicit mixed-version fallback for an older daemon; this projection stays name-keyed only until the next authoritative snapshot.

func (*Instance) BeginLimitResume added in v1.0.221

func (i *Instance) BeginLimitResume() error

Respawn re-establishes the instance's backing session in place without a liveness precondition — the guard-free core of Recover. The usage-limit manual-retry (#1146, resumeFromLimit) uses it to re-spawn an agent that exited while blocked at a limit wall: that session is LiveLimitReached, which Recover's !Lost guard rejects, but the re-spawn mechanics are identical. The caller owns the precondition, enforced here before the guard-free backend core runs. BeginLimitResume validates the limit-resume precondition and raises the OpRespawning fence for the WHOLE resume, in one critical section. The caller owns it until EndLimitResume (or until ConfirmLive clears it on success).

It is a separate method from Respawn for the reason SwapAgentProgram and RecordHandoffSwap are separate: the two legal orderings are made explicit rather than folded into one re-entrant call that silently does different things.

The fence must cover the resume's whole destructive sequence, not just the backend call (#2997, and #3004 review). For an answered-dead REMOTE agent the daemon probes, then pushes the sandbox's unpushed work and durably records its branch, and only then re-spawns. That push is a network git operation and the longest phase of the resume; run unfenced it is exactly the window the poll walks into, observing the dead agent and applying LiveLost while no op is in flight. The resume then loses its own precondition and refuses, so the queued prompt is never delivered — the harm the fence exists to prevent, reached before the fence existed.

Validation and the raise share i.mu because splitting them is its own race: a current observation landing in the gap is not stale, so the epoch guard cannot drop it, and tkBeginRespawn is keyed on the op axis alone — it would happily fence a liveness that had just been clobbered. See lifecycleViewLocked, which documents this as the pattern.

func (*Instance) BeginPRInfoWrite added in v1.0.258

func (i *Instance) BeginPRInfoWrite(info *git.PRInfo) PRInfoRollback

BeginPRInfoWrite applies info exactly as SetPRInfo does and returns the rollback point for the state it replaced.

func (*Instance) BuildMissionBrief added in v1.0.206

func (i *Instance) BuildMissionBrief(to, override, reason string) MissionBrief

BuildMissionBrief assembles the brief for handing this instance to `to`.

override wins over the stored prompt when non-empty: it is what a user typed at the moment of the handoff, so it is both more specific and more current than a prompt stored at create time. When neither exists the brief simply has no goal line — see Render, which says so out loud rather than fabricating one.

Best-effort on the git side: a worktree that cannot be summarized still yields a brief, minus the "work already done" section.

func (*Instance) CanKill added in v1.0.207

func (i *Instance) CanKill() bool

CanKill reports whether interactive clients may offer explicit teardown for this instance. It is a separate domain axis from LifecycleAction so exceptional retained records do not become impossible to remove.

func (*Instance) Capabilities added in v1.0.173

func (i *Instance) Capabilities() Capabilities

Capabilities returns the backing runtime's capability descriptor (#1592 Phase 1). A nil backend (a not-yet-initialised instance) reports local full parity: the UI treats a backend-less instance as a capable local session, so returning the zero value instead would be an incoherent descriptor (local workspace but every capability off) and would regress e.g. the tab-management footer.

The backend read is synchronized (#2096): the daemon's restore loops consult Capabilities().Recover BEFORE taking the instance's opLock, so it runs concurrently with a restore rebinding the backend.

func (*Instance) CheckAndHandleTrustPrompt

func (i *Instance) CheckAndHandleTrustPrompt() bool

CheckAndHandleTrustPrompt checks for and dismisses the trust prompt for supported programs.

func (*Instance) ClaimWorktreeRelocationForRetry added in v1.0.229

func (i *Instance) ClaimWorktreeRelocationForRetry() (git.RelocationClaim, error)

ClaimWorktreeRelocationForRetry atomically consumes any durable recovery record and returns the point-in-time directory claim later archive steps must revalidate. Resolution never leaves a settled record behind for another reader to reinterpret.

func (*Instance) ClearAgentModelChange added in v1.0.207

func (i *Instance) ClearAgentModelChange() bool

ClearAgentModelChange retires the diagnostic at an authoritative runtime boundary. Positive observations must use SetAgentModelChangeAtEpoch instead.

func (*Instance) ClearIdleEvidence added in v1.0.240

func (i *Instance) ClearIdleEvidence() bool

ClearIdleEvidence retires delivery and pane facts owned by a replaced runtime. Its epoch bump also rejects a predecessor observation still applying.

func (*Instance) ClearLimitReached added in v1.0.140

func (i *Instance) ClearLimitReached()

func (*Instance) ClearPendingHandoffMission added in v1.0.207

func (i *Instance) ClearPendingHandoffMission(mission string) bool

ClearPendingHandoffMission clears the marker only if it still names mission. The compare makes a delayed recovery attempt unable to erase a newer handoff's brief after the same session has moved on.

func (*Instance) CloseAttachOnly added in v1.0.114

func (i *Instance) CloseAttachOnly() error

CloseAttachOnly releases resources this instance opened to view or drive its session without destroying the session, worktree, or off-box workspace. Use it — never Kill — to discard a duplicate Instance built from disk that lost a race to the canonical tracked Instance (#867); see Backend.CloseAttachOnly.

func (*Instance) CloseTab added in v1.0.123

func (i *Instance) CloseTab(idx int) error

CloseTab kills the tab at idx, ends its PTY stream, and removes it from Tabs. The agent tab (idx 0) is unclosable; CloseTab errors on idx 0 or any out-of-range index. The tab is removed from Tabs regardless of whether the tmux teardown succeeds (best-effort, matching LocalBackend.Kill) so a broken session can't wedge the tab list. Unlike Kill this does not wait for the pane to exit: the worktree is not being removed, so there is no #802 delete race to guard against.

func (*Instance) CloseTabByID added in v1.0.206

func (i *Instance) CloseTabByID(tabID string) error

CloseTabByID removes the tab with stable id, selecting and removing it in the same critical section. An ordinal resolved from an earlier snapshot is never applied to the live roster (#2200).

func (*Instance) CloseTabByIDWithCommit added in v1.0.213

func (i *Instance) CloseTabByIDWithCommit(
	tabID string,
	commit func(InstanceData) error,
) (CommittedTabClose, error)

CloseTabByIDWithCommit stages removal of the stable tab, calls commit with the resulting InstanceData, and only then performs irreversible stream/tmux teardown. If commit fails, the exact tab is restored at its original position before the error is returned, leaving both the roster and runtime available for a safe retry (#2669).

The projection commit receives has the tab off Tabs AND its tmux identity on PendingTabCleanup, so the durable record never describes a state where the session is neither a tab nor a tracked cleanup. That handoff is the point: the roster shrinks irrevocably at the commit, but teardown runs afterwards and may time out, so something durable has to keep naming the tmux session until a kill is confirmed. A tombstone rather than a retained tab, because the tab is genuinely closed — it must not render, and a restore must not respawn it.

Selection, staging, snapshotting, and commit run under the same instance lock. Besides keeping the rollback exact, this prevents another tab mutation from producing a projection between the staged roster and the one commit receives. commit must not call back into Instance: it receives the already-built projection for that reason.

func (*Instance) ConsumeLoadRuntimeReplacement added in v1.0.240

func (i *Instance) ConsumeLoadRuntimeReplacement() bool

ConsumeLoadRuntimeReplacement reports one load-time replacement exactly once. It is process-local coordination, never a persisted fact about the session.

func (*Instance) CurrentAgentName added in v1.0.206

func (i *Instance) CurrentAgentName() string

CurrentAgentName reports which agent enum this session should be treated AS. It returns "" only when that is genuinely unknowable, and every handoff surface — the picker's filter, the same-agent guard, the confirmation copy, the ledger's outgoing entry — resolves it through here so they cannot disagree about who is being replaced.

It is deliberately NOT ResolvedAgent, and the difference is load-bearing. ResolvedAgent answers a different question — "which binary is this pane actually running" — for decisions like claude-only flag injection and readiness detection (#1116). For those, a wrapper script that af cannot identify SHOULD come back empty: injecting claude's flags into an unknown command would break it. configuration.md documents that contract ("if you wrap an agent in a script, name the script after the agent").

Identity is not that question. A session created as claude is claude even when it launches through ~/bin/my-claude-wrapper, and answering "" there is not conservative — it is what let the picker offer claude as a handoff target for a session already running claude, and let the same-agent guard pass it. A self-handoff kills a working agent and restarts it with no conversation, so the empty answer authorized the destructive path rather than blocking it.

Precedence runs from most to least direct evidence:

  1. the running command, when af can identify it — it beats any record, because an override pointing "claude" at codex really is running codex;
  2. the conversation the agent actually opened, captured at runtime;
  3. the configured enum, which is what the user asked for and what a handoff rewrites — this is the one that rescues the wrapper-script case.

func (*Instance) DropClosedTab added in v1.0.125

func (i *Instance) DropClosedTab(idx int) error

DropClosedTab removes the tab at idx from the in-memory list WITHOUT killing its tmux session (#960 PR 2). It is the no-kill counterpart of CloseTab, used when the daemon's CloseTab RPC has already torn the tmux session down: the daemon owns the kill+persist, and the TUI only needs to drop the now-dead tab from its local view for instant display. Killing again here would shell out a second tmux kill-session that errors ("session not found") on the already-gone session and surface a spurious failure. The agent tab (idx 0) is undroppable; errors on idx 0 or any out-of-range index, mirroring CloseTab.

func (*Instance) EndLimitResume added in v1.0.221

func (i *Instance) EndLimitResume() bool

EndLimitResume lowers the fence BeginLimitResume raised. Safe to defer unconditionally: it is a no-op once ConfirmLive has cleared the op on the success path, and it never disturbs an op some other owner raised.

Lowering it is not optional on the failure paths. A stranded fence leaves the session permanently busy — the poll skips it forever and every runtime action and lifecycle control refuses it as in-flight — which is worse than the clobber this whole mechanism exists to prevent. ClearOp is unconditionally legal (it only ever moves the op axis back to None and leaves liveness alone), so the OpRespawning check is not about legality: it makes sure a kill or archive overlay that SUPERSEDED this fence is not cleared out from under its own owner. Reports whether it actually lowered the fence, so a caller that must announce the released state to its clients can tell an effective release from a no-op and not publish a duplicate settled event on the path that already published one.

func (*Instance) FetchPRInfoSnapshot added in v1.0.53

func (i *Instance) FetchPRInfoSnapshot() (repoPath, branch string)

FetchPRInfoSnapshot returns the data needed to fetch PR info for this instance off the main event loop. The returned repoPath is empty when the instance is not ready for fetching (not started, no worktree, or remote).

func (*Instance) GetArchiveReport added in v1.0.238

func (i *Instance) GetArchiveReport() git.ArchiveReport

GetArchiveReport returns durable metadata for files omitted from this session's archive. The empty report means the archive was complete.

func (*Instance) GetBackend added in v1.0.46

func (i *Instance) GetBackend() Backend

GetBackend returns the backend for the instance (mainly for testing).

func (*Instance) GetBaseCommitSHA added in v1.0.202

func (i *Instance) GetBaseCommitSHA() string

GetBaseCommitSHA returns the recorded base commit SHA of the instance's worktree, or "" when there is no worktree. Deliberately NOT gated on started (unlike GetGitWorktree): the kill-confirmation's unmerged-work check must run for a session that has a worktree even if it was never started — a restore- failed session's branch still gets force-deleted by the kill (#2029). Mirrors GetWorktreePath, which is likewise ungated so both loss checks cover the same session states.

func (*Instance) GetBranch added in v1.0.54

func (i *Instance) GetBranch() string

GetBranch returns the current worktree branch name under the Instance's mutex. Readers that run from goroutines other than the one mutating the instance (notably the bubbletea renderer) must use this accessor rather than reading i.Branch directly, or the race detector flags a write in LocalBackend.Start vs a read in InstanceRenderer.Render.

func (*Instance) GetGitWorktree

func (i *Instance) GetGitWorktree() (*git.GitWorktree, error)

GetGitWorktree returns the git worktree for the instance

func (*Instance) GetInFlightOp added in v1.0.140

func (i *Instance) GetInFlightOp() InFlightOp

GetInFlightOp returns the client/executor op axis under the instance mutex.

func (*Instance) GetLiveness added in v1.0.140

func (i *Instance) GetLiveness() Liveness

GetLiveness returns the daemon-owned liveness axis under the Instance's mutex (#1146/#1195). Readers use it where the composed Status is lossy: the snapshot reconcile mirrors liveness (not Status) so LiveLimitReached — which composes to Ready — propagates from the daemon to the read-only TUI.

func (*Instance) GetPRInfo

func (i *Instance) GetPRInfo() *git.PRInfo

GetPRInfo returns the associated GitHub PR info, or nil if none.

func (*Instance) GetPrompt added in v1.0.207

func (i *Instance) GetPrompt() string

GetPrompt returns the session's current durable goal.

func (*Instance) GetRepoPath added in v1.0.91

func (i *Instance) GetRepoPath() string

GetRepoPath returns the resolved git repo path stored in the instance's worktree, or empty string when no worktree is attached (e.g. a remote- backend instance). Callers using the result to derive a repo ID must fall back to Instance.Path when this is empty (#667).

func (*Instance) GetStatus added in v1.0.54

func (i *Instance) GetStatus() Status

GetStatus returns the current status under the Instance's mutex, so cross-goroutine readers don't race with SetStatus (legacy shim — composes the value from the two axes).

func (*Instance) GetTabs added in v1.0.123

func (i *Instance) GetTabs() []*Tab

GetTabs returns a snapshot of the instance's tab list under the instance mutex. The returned slice is a copy, so callers (the UI tab bar) can iterate it without racing concurrent tab mutation.

The *Tab elements are the LIVE pointers, not copies, and callers read their Name/Kind/ID off-lock (tree.TabLabels on the render path, the TUI's tabNameAt/tabIndexByName). That is safe because those fields are never assigned in place once a tab is in i.Tabs: a tab's name can change, but the writers that change it — RenameTab and ReconcileTabsFromData — swap in a COPY carrying the new value (replaceTabFieldLocked) instead of writing the object a reader is already holding. So a snapshot keeps reading the values it was taken with, and the next GetTabs observes the new ones. Anything that wants to change one of those fields must go through replaceTabFieldLocked; assigning to a handed-out tab's Name is a data race, not a stale read (#1930).

This does NOT extend to the whole struct: tmux and Conversation are still assigned IN PLACE under i.mu (setTmuxLocked, setupTabs' dead-shell replacement, teardown's ref clearing). The package does read those off a snapshot in places — setupTabs and teardownTabs both capture tabs under the lock and work outside it — and that is safe only because the daemon's per-instance op-lock serializes start/teardown against every other mutation. That discipline lives in the daemon, not in this type: prefer the locking accessors (TabTmuxByID, ToInstanceData), and if you read tmux/Conversation off a snapshot, know that is what you are leaning on.

func (*Instance) GetWorktreeBranch added in v1.0.206

func (i *Instance) GetWorktreeBranch() string

GetWorktreeBranch returns the canonical branch recorded by the GitWorktree, or empty when the instance has no worktree. Unlike GetGitWorktree, this is not gated on started: kill/archive cleanup still acts on a restore-failed row's recorded worktree and branch, so safety checks must be able to inspect the exact ref cleanup would delete (#2209 review).

func (*Instance) GetWorktreeCleanupImpact added in v1.0.206

func (i *Instance) GetWorktreeCleanupImpact() (WorktreeCleanupImpact, bool)

GetWorktreeCleanupImpact returns a coherent description of Cleanup's targets. The GitWorktree ownership fields are immutable after construction.

func (*Instance) GetWorktreePath

func (i *Instance) GetWorktreePath() string

GetWorktreePath returns the worktree path for the instance, or empty string if unavailable

func (*Instance) GetWorktreeRelocationCandidates added in v1.0.229

func (i *Instance) GetWorktreeRelocationCandidates() (primary, alternate string, ok bool)

GetWorktreeRelocationCandidates returns both durable pathnames retained after a bounded worktree move ended without an answer. Neither path is authoritative while ok is true; lifecycle retry resolves their captured identity.

func (*Instance) Handoffs added in v1.0.206

func (i *Instance) Handoffs() []AgentHandoff

Handoffs returns a copy of the agent tab's handoff ledger, oldest first.

func (*Instance) HasInFlightOp added in v1.0.140

func (i *Instance) HasInFlightOp() bool

HasInFlightOp reports whether any client op is in flight (the render/gate replacement for the old Loading||Deleting "is this row transient" check).

func (*Instance) IdleReasonSnapshot added in v1.0.240

func (i *Instance) IdleReasonSnapshot() (IdleReason, time.Time)

IdleReasonSnapshot returns the derived reason and last observed pane churn in one lock hold for row renderers.

func (*Instance) InFlightOpAndEpoch added in v1.0.221

func (i *Instance) InFlightOpAndEpoch() (InFlightOp, uint64)

InFlightOpAndEpoch reads the op axis and the state epoch TOGETHER under one lock.

An observer that reads them separately has a window that neither read closes on its own (#2997). The daemon poll skips a session that has an op in flight and, further down, captures the epoch its observation will be scoped to. If a fence goes up BETWEEN those two reads, the skip has already passed and the epoch it then captures is the POST-fence one — so the observation it settles minutes later looks current, is applied rather than dropped, and overwrites the liveness the in-flight operation depends on. That is the same clobber the fence exists to prevent, reached by a path the fence cannot see.

Reading both at once removes the window instead of narrowing it: the epoch is necessarily from a moment when the op was None, so any fence raised afterwards advances it and the epoch guard drops the observation. Callers must skip on a non-None op here, not merely earlier.

func (*Instance) IsArchived added in v1.0.188

func (i *Instance) IsArchived() bool

IsArchived reports whether the session is archived on the liveness axis, i.e. INERT: its tmux is gone and its worktree has been moved out to the archive dir, so nothing may be spawned in it, closed from it, or served out of it until a restore brings it back. It is the gate for interacting with an archived session's PRESERVED tabs (#1809 follow-up): archive now keeps web tabs, which made an archived session the first one to carry a non-agent tab — a tab the web-tab proxy would happily resolve and CloseTab would happily delete, neither of which had a reason to check for archived before.

Unlike ShownArchived (a RENDER predicate, which yields the row to the live section the moment a restore starts, #1210) this reads the liveness axis ALONE. It is therefore SETTLED-state only: it does not cover a session mid-archive (OpArchiving, liveness still live) — see WebTabServeBlocked for the serve-side gate that does.

It deliberately opens again the moment a restore begins. BeginRestore moves the session to LiveLost + OpRestoring, but both callers (RestoreArchived and undoCommittedArchive) move the worktree back home BEFORE that transition, so an OpRestoring session's worktree is already in place — there is no mid-move window to protect, and the tab it serves is the same one it will serve a moment later when the restore completes.

func (*Instance) IsCreating added in v1.0.140

func (i *Instance) IsCreating() bool

IsCreating reports whether a create is in flight (the render/gate replacement for the old GetStatus()==Loading check).

func (*Instance) IsExternalWorktree added in v1.0.139

func (i *Instance) IsExternalWorktree() bool

IsExternalWorktree reports whether the instance's worktree is external/in-place (`af sessions create --here`, or a legacy external record) — the same flag MoveWorktree checks. Such a worktree is the user's own working tree and must never be relocated, so the daemon rejects archiving it (#1028). Returns false when the instance has no worktree yet.

func (*Instance) IsTearingDown added in v1.0.140

func (i *Instance) IsTearingDown() bool

IsTearingDown reports whether a kill or archive teardown is in flight (the render/gate replacement for the old GetStatus()==Deleting check — both owners now live on distinct ops, but both read as "going away").

func (*Instance) Kill

func (i *Instance) Kill() error

Kill terminates the instance and cleans up all resources. It delegates to the agent-server's Kill (not backend.Kill directly) so the WS PTY broker is torn down FIRST: every open subscriber's NextEvent returns io.EOF and the clientless capture goroutine stops, instead of hanging until the WS keepalive lapses and leaking the capture goroutine when a session is killed with a live stream open (#1632). The agent-server then kills the underlying session.

func (*Instance) LastHandoff added in v1.0.206

func (i *Instance) LastHandoff() (AgentHandoff, bool)

LastHandoff returns the most recent ledger entry, if any.

func (*Instance) LaunchPreparedCreate added in v1.0.207

func (i *Instance) LaunchPreparedCreate(plan CreateLaunchPlan) error

LaunchPreparedCreate consumes exactly the plan returned above. The instance pointer fence prevents a valid plan from being replayed against another row.

func (*Instance) LifecycleAction added in v1.0.206

func (i *Instance) LifecycleAction() LifecycleAction

LifecycleAction returns the shared lifecycle verb for this instance. TUI menus and handlers use this method; browser clients receive the same value from InstanceData.LifecycleAction.

func (*Instance) LifecycleView added in v1.0.200

func (i *Instance) LifecycleView() LifecycleView

LifecycleView snapshots the session's lifecycle state under ONE lock. Every field a caller needs to reach a verdict must come from here rather than from a follow-up accessor call, or the verdict spans a window the restore loop can move through.

func (*Instance) LimitReached added in v1.0.140

func (i *Instance) LimitReached() bool

LimitReached reports whether the instance is blocked on a usage limit (#1146). Every render/serialize site keys its [limit] badge off this rather than the composed Status, which has no limit value (LiveLimitReached composes to Ready).

func (*Instance) LimitResetAt added in v1.0.140

func (i *Instance) LimitResetAt() (time.Time, bool)

LimitResetAt returns the parsed usage-limit reset time and whether one is known (#1146). It reports (zero, false) when the session is not limit-blocked or the banner carried no parseable reset time, so a stale reset value can never leak onto a recovered session.

func (*Instance) MarkLoadRuntimeReplacedForTest added in v1.0.240

func (i *Instance) MarkLoadRuntimeReplacedForTest()

MarkLoadRuntimeReplacedForTest seeds the loader settlement owed by a confirmed Start(false) respawn. Production sets it only from LocalBackend.

func (*Instance) MarkPRInfoFetched added in v1.0.53

func (i *Instance) MarkPRInfoFetched()

MarkPRInfoFetched bumps the fetch timestamp without touching the cached value. Used after a transient fetch error so we don't re-try on every subsequent selection change.

func (*Instance) MarkStartupStateUnknown added in v1.0.206

func (i *Instance) MarkStartupStateUnknown()

MarkStartupStateUnknown retains a failed create as an inert record. Clearing started prevents attach/probe paths from treating the requested runtime name as confirmed; StartupStateUnknown keeps storage checkpoints from dropping the record merely because it is not started.

func (*Instance) MarkUserKilled added in v1.0.139

func (i *Instance) MarkUserKilled()

MarkUserKilled records kill intent on the instance (#1108). Callers persist the instance afterwards so the tombstone survives a daemon crash mid-kill. Daemon callers reach this commit at serialized points: an explicit kill owns the per-session operation lock, while failed-create retention still owns the repo start lock and has not exposed the instance to another operation. Any carried process-local operation therefore has no live owner and must not outrank the durable tombstone or hide its retry action. A TUI retry owns its OpKilling on a separate projection instance and is preserved by snapshot reconciliation.

func (*Instance) NoteRecreateContext added in v1.0.218

func (i *Instance) NoteRecreateContext()

NoteRecreateContext classifies the launch this instance just completed and records the resulting one-shot marker.

The CALLER decides that this create is a heal — only the daemon knows it just reaped a record, and it is the one fact the carried conversation cannot supply: a heal whose reaped root recorded no conversation carries nothing at all, and that is exactly the heal whose root comes back with no history. An ordinary create must never be asked, because starting fresh is what an ordinary create IS, and marking it would be noise on every new session.

Called once the launch has settled, because the conversation committed to Tabs[0] is the answer: it is what the record will hold and what a future recovery resumes from. Calling it before the create's own persist is what keeps the marker in the SAME projection the create writes and publishes, rather than a second write racing the first client to render the row.

func (*Instance) PRInfoAge added in v1.0.53

func (i *Instance) PRInfoAge() time.Duration

PRInfoAge returns how long ago PR info was last fetched. Returns a very large duration if PR info has never been fetched in this process.

func (*Instance) PRInfoGeneration added in v1.0.258

func (i *Instance) PRInfoGeneration() uint64

PRInfoGeneration counts every write to this instance's PR info or its freshness clock. A slow producer captures it at kickoff and compares before recording, so a result that raced a NEWER producer's write is discarded instead of overwriting it (#3287 review) — the badge equivalent of a CAS expectation, best-effort because the final write happens under the write path's own locks.

func (*Instance) PendingHandoffMission added in v1.0.207

func (i *Instance) PendingHandoffMission() string

PendingHandoffMission returns the takeover brief awaiting confirmed delivery.

func (*Instance) PendingTabCleanup added in v1.0.213

func (i *Instance) PendingTabCleanup() []TabCleanupData

PendingTabCleanup returns a copy of the instance's unconfirmed tab-teardown handles. Copied because the caller iterates outside i.mu while a concurrent close may append.

func (*Instance) PostWorktreeHooksDone added in v1.0.169

func (i *Instance) PostWorktreeHooksDone() <-chan struct{}

PostWorktreeHooksDone returns a channel that is closed once the instance's post-worktree hooks (post_worktree_commands) have finished running, or nil when no hook run is in flight — no worktree yet, an external worktree that skips hooks, or a repo with no hooks configured. The readiness wait uses it so a slow build hook running concurrently with the agent is not charged against the agent's startup budget (see task.WaitForReady).

func (*Instance) PrepareAgentSwap added in v1.0.207

func (i *Instance) PrepareAgentSwap(target string) (AgentSwapPlan, error)

PrepareAgentSwap resolves and validates the incoming launch while the outgoing agent is still untouched. The returned immutable plan is the only value SwapAgent accepts, so the checked command and the launched command cannot drift.

func (*Instance) PrepareCreateLaunch added in v1.0.207

func (i *Instance) PrepareCreateLaunch() (CreateLaunchPlan, error)

PrepareCreateLaunch snapshots the create's launch context without spawning its agent. A backend with an exact prepared boundary is provisioned first so relative paths resolve against the final workspace path. Other local test backends preserve the legacy daemon-environment snapshot and Start path.

func (*Instance) PrepareWorktreeRelocationClaimForCleanup added in v1.0.245

func (i *Instance) PrepareWorktreeRelocationClaimForCleanup(claim git.RelocationClaim) error

PrepareWorktreeRelocationClaimForCleanup persists a resolved archived-path identity as a cleanup-only obligation. It is the non-relocating completion path used when the origin repo is gone.

func (*Instance) PreserveWorktreeRelocationClaimAsUnresolved added in v1.0.245

func (i *Instance) PreserveWorktreeRelocationClaimAsUnresolved(claim git.RelocationClaim)

PreserveWorktreeRelocationClaimAsUnresolved fences a resolved archive when a later read-only gate cannot answer. Unlike the ordinary abort helper, it also materializes record-free claims so kill cannot read absence as permission.

func (*Instance) PreserveWorktreeRelocationClaimForRetry added in v1.0.229

func (i *Instance) PreserveWorktreeRelocationClaimForRetry(claim git.RelocationClaim)

PreserveWorktreeRelocationClaimForRetry returns ownership of a consumed recovery claim when an earlier archive gate aborts before the worktree use boundary. Claims made from record-free state are no-ops.

func (*Instance) PreviewTab added in v1.0.123

func (i *Instance) PreviewTab(idx int) (string, error)

PreviewTab captures the detached content of the tab currently at idx. The ordinal form exists for legacy callers that never supplied a stable tab id. An out-of-range ordinal is an explicit error: returning ("", nil) would claim that a nonexistent pane was merely blank (#2200).

func (*Instance) PreviewTabByID added in v1.0.206

func (i *Instance) PreviewTabByID(tabID string, full bool) (string, error)

PreviewTabByID captures the tab named by stable id without ever converting that identity into an ordinal used by a later live-list lookup. The instance lock resolves the id directly to the target tmux pointer and stays held through the bounded non-agent capture. A concurrent close/reorder therefore waits; it cannot redirect the capture to a sibling or a same-name replacement (#2200).

The agent tab retains the backend-specific preview path. It is pinned at slot zero and cannot be closed or reordered, so snapshotting its backend under the same lock preserves both its identity and the formatting contract.

func (*Instance) PreviewTabFullHistory added in v1.0.123

func (i *Instance) PreviewTabFullHistory(idx int) (string, error)

PreviewTabFullHistory is PreviewTab's full-scrollback counterpart. It keeps the same explicit out-of-range refusal.

func (*Instance) PreviewTabSnapshot added in v1.0.207

func (i *Instance) PreviewTabSnapshot(idx int, full bool) (PreviewSnapshot, error)

PreviewTabSnapshot is PreviewTab with an authoritative terminal-mode observation when the selected runtime exposes a tmux pane.

func (*Instance) PreviewTabSnapshotByID added in v1.0.207

func (i *Instance) PreviewTabSnapshotByID(tabID string, full bool) (PreviewSnapshot, error)

PreviewTabSnapshotByID binds content and terminal modes to one stable tab identity. A mode read can fail without losing a valid capture; HasModes=false makes that uncertainty explicit to routing clients.

func (*Instance) ReconcileAgentModelChange added in v1.0.207

func (i *Instance) ReconcileAgentModelChange(change *AgentModelChange) bool

ReconcileAgentModelChange applies the daemon's already-correlated projection to a client-side Instance. It is intentionally separate from the epoch-bound observation API above: a client's local lifecycle epoch is unrelated to the daemon epoch that validated the observation.

func (*Instance) ReconcileArchiveWarning added in v1.0.238

func (i *Instance) ReconcileArchiveWarning(warning string) bool

ReconcileArchiveWarning mirrors the daemon's bounded incomplete-archive notice onto an existing client projection. It is independent of liveness: a Lost row can acquire the warning during repair, and a repaired row can clear it without any status transition.

func (*Instance) ReconcileIdleEvidence added in v1.0.240

func (i *Instance) ReconcileIdleEvidence(attemptedAt time.Time, status PromptDeliveryStatus, churnAt time.Time) bool

ReconcileIdleEvidence mirrors the daemon's evidence onto a client row model. It applies both directions because runtime replacement can clear or replace the evidence, and the daemon snapshot is authoritative for all three fields.

func (*Instance) ReconcileRootRecreateContext added in v1.0.218

func (i *Instance) ReconcileRootRecreateContext(ctx RootRecreateContext) bool

ReconcileRootRecreateContext mirrors the daemon's authoritative value onto a client's projection of this row, reporting whether anything changed.

It is applied unconditionally in BOTH directions, unlike a monotonic marker like the kill tombstone: the note appears when the daemon heals a root and disappears when any client acknowledges it, and a projection that could only adopt it would leave the note burned onto every other open rail forever.

func (*Instance) ReconcileTabsFromData added in v1.0.125

func (i *Instance) ReconcileTabsFromData(target []TabData) (bool, error)

ReconcileTabsFromData updates this started local instance's tab list to match `target`, the daemon's authoritative serialized tab list (#960 PR 3). The daemon is the single owner of tab state, so the TUI mirrors it: tabs the daemon added out-of-band (present in target, absent locally) are reconnected to their EXACT persisted tmux session by name — like restoreLocalTabs — and appended, so an out-of-band tab appears in the running TUI and is immediately previewable/attachable (the #959 "live display" fix). A tmux-less kind (web, vscode — see TabKind.HasTmux) has no session to reconnect and is appended directly, so it lands in the TUI as its placeholder pane rather than being mistaken for a tab whose tmux session went missing; tabs the daemon closed (absent from target) are dropped locally WITHOUT re-killing their tmux session (the daemon already tore it down — killing again would error on the gone session). The agent tab (index 0) is never added or dropped: it is the instance's own session and is always present. Returns whether the local list changed. A no-op for a not-started instance, one without an agent session, or a remote instance (callers skip backends without TabManagement — remote tabs come from hook config, not the snapshot). Per-tab reconnect failures are collected into the returned error after every other change is applied, so one bad tab can't wedge the reconcile.

func (*Instance) ReconcileUserKilledSnapshot added in v1.0.213

func (i *Instance) ReconcileUserKilledSnapshot(userKilled bool) bool

ReconcileUserKilledSnapshot applies the durable tombstone carried by a daemon snapshot to an already-materialized projection row. Tombstones are monotonic: an older snapshot cannot make a killed row live again. When the tombstone is first adopted, stale daemon operation markers must be cleared with it. OpKilling is different: snapshot reconciliation runs on the TUI's projection instance, so that marker belongs to the user's current teardown request and must survive even the first tombstone snapshot.

func (*Instance) RecordHandoffSwap added in v1.0.207

func (i *Instance) RecordHandoffSwap(target, reason, headSHA string, automatic bool) (HandoffSwap, error)

RecordHandoffSwap is the transaction-owned mutation used by the daemon after BeginHandoff has raised OpReplacing. Keeping it separate from SwapAgentProgram makes both legal orderings explicit: ordinary state-only tests require a settled live row, while production replacement requires the fence and cannot accidentally validate itself as "busy".

func (*Instance) RecordPaneChurnAtEpoch added in v1.0.240

func (i *Instance) RecordPaneChurnAtEpoch(churnAt time.Time, observedEpoch uint64) bool

RecordPaneChurnAtEpoch records that an Observation reported Updated for the same runtime generation the caller observed. A lifecycle fence raised during capture invalidates the observation exactly as it invalidates liveness.

func (*Instance) RecordPaneChurnCheckpointAtEpoch added in v1.0.240

func (i *Instance) RecordPaneChurnCheckpointAtEpoch(churnAt time.Time, observedEpoch uint64) (bool, bool)

RecordPaneChurnCheckpointAtEpoch additionally reports the first accepted pane churn after the latest prompt. That edge must be persisted even while the row remains Running; later spinner churn needs no write on every poll.

func (*Instance) RecordPromptAttempt added in v1.0.240

func (i *Instance) RecordPromptAttempt(status PromptDeliveryStatus, attemptedAt time.Time) bool

RecordPromptAttempt stores the observation made by an actual prompt send. attemptedAt must be captured before delivery begins, so a pane observation racing the send can still be ordered after it. Invalid local values normalize to honest uncertainty; a zero timestamp establishes no order and is ignored.

func (*Instance) Recover added in v1.0.139

func (i *Instance) Recover() error

Recover re-establishes a Lost instance's backing session (#1108).

func (*Instance) RecoverWithLiveBoundary added in v1.0.240

func (i *Instance) RecoverWithLiveBoundary(beforeLive func()) error

RecoverWithLiveBoundary is Recover with a callback at the replacement's ConfirmLive edge. It exists for lifecycle facts that must be settled after a backend has successfully created the runtime but before the restore fence is dropped. The callback cannot veto recovery: settlement failures remain owed for retry, and a full disk must not turn a running replacement back into Lost.

func (*Instance) RefreshRecreateContext added in v1.0.218

func (i *Instance) RefreshRecreateContext() bool

RefreshRecreateContext re-runs the classification against evidence that arrived AFTER the launch settled — the async provider capture — and reports whether the note changed.

It exists because a root whose command pins its own conversation selection (`codex resume --last`) records nothing synchronously, so the launch can only answer "unknown". Minutes later the capture goroutine discovers the id the agent actually came up on, and if that is the carried conversation, af has PROVEN the continuity it could not see before. Leaving the row warning `context unknown` after that is a stale warning, and a stale warning is how a real one stops being read.

Gated on a notice still being pending, which is what keeps this from resurrecting one the user already acknowledged: acknowledgement is final, and later evidence about a launch nobody is being warned about changes nothing. It cannot silently downgrade a notice inherited from an EARLIER heal either — noteRecreateContextLocked floors every result at that carried notice.

func (*Instance) RenameArchived added in v1.0.183

func (i *Instance) RenameArchived(newTitle, dest, newBranch string) error

RenameArchived atomically relocates an archived instance's worktree to dest (a new title-keyed archive dir) and updates its Title, so a fresh session can reuse the archived session's name (feat: reuse archived name). Both mutations happen under i.mu so a concurrent Snapshot/ToInstanceData never observes a torn state (new title paired with the old worktree path, or vice versa). Archived instances are inert — no async Start/Recover goroutine touches them — so holding i.mu across the git move only blocks a brief Snapshot RLock, never a live operation.

The stable id and worktree contents are preserved: only the on-disk directory + git's two-way registration move, and only the display title changes. On a relocation failure the worktree and title are left untouched and the error is surfaced, so the caller can abort the reuse without having half-renamed the archived session.

newBranch, when non-empty, moves the BRANCH aside with the title (#2127). Freeing the title alone was never enough: archiving relocates the worktree rather than removing it (#2013), so the archived session keeps its branch checked out, and the new session — which derives that same branch — then failed at `git worktree add` on a name the rename was supposed to have freed. Empty keeps the branch where it is, which is what a session with no local branch to move (a hook/sandbox workspace) needs.

Branch first, worktree second, and the branch is put back if the worktree move fails. Both orders leave a window; this one's window is the cheap, exactly reversible half — a renamed branch with the worktree still at its old path is undone by one more rename, whereas a moved worktree whose branch rename then failed would need the bytes moved back to recover.

func (*Instance) RenameTab added in v1.0.201

func (i *Instance) RenameTab(idx int, requestedName string) (string, error)

RenameTab sets a new name on the tab at idx and returns the RESOLVED name — sanitized, and suffixed ("dup" -> "dup-2") when the sanitized name is already taken — so callers can render what actually happened rather than what was asked for.

The requested name is sanitized to the tmux-safe token set exactly as tab creation sanitizes it (sanitizeTabName). A name that sanitizes to nothing is an error rather than a silent fall back to a default: at creation "web" is a sensible default for an unnamed tab, but a user explicitly renaming a tab to "...." asked for something specific, and quietly naming it "web" instead is the silent mangling #1813 calls out.

Only kinds that display their name can be renamed (TabKindRenameable); the agent tab is additionally pinned at index 0. Resolution and mutation are atomic under the write lock so two concurrent renames cannot both resolve to the same free name.

Renaming does NOT touch the tab's live tmux session — restore rebinds by the persisted TmuxName, not by re-deriving from the name, so the tab survives a restart. The name it renames AWAY from is therefore free immediately: names are unique among the roster's current names and nothing else (#1957), and the still-live tmux session it leaves behind is dodged at SPAWN by uniqueTabTmuxName rather than by holding the user's old name hostage. See the two-namespace note at the top of tab_names.go.

func (*Instance) RenameTabByID added in v1.0.206

func (i *Instance) RenameTabByID(tabID, requestedName string) (string, error)

RenameTabByID selects and renames the stable target under one write lock, so a caller never applies an ordinal from an older roster to a different tab.

func (*Instance) ReorderTab added in v1.0.201

func (i *Instance) ReorderTab(from, to int) error

ReorderTab moves the tab at index from to index to, where to is read in the FINAL roster ("the tab ends up at this index"): moving 1 to 3 in [A,B,C,D] yields [A,C,D,B].

Index 0 is pinned in both directions: the agent tab cannot be moved, and nothing can be moved in front of it. That is a correctness invariant, not a display preference — Tabs[0] IS the agent tab to the rest of the package (archive keeps Tabs[0] in teardown.go, ToInstanceData reads the agent conversation off Tabs[0], and the agent tmux session is resolved through it), so permuting it would silently re-point all of those at a shell tab. Only 1..n-1 may be permuted.

func (*Instance) ReorderTabByID added in v1.0.206

func (i *Instance) ReorderTabByID(tabID string, to int) error

ReorderTabByID selects and moves the stable target under one write lock.

func (*Instance) ReparkLimitUnderResumeFence added in v1.0.221

func (i *Instance) ReparkLimitUnderResumeFence(resetAt time.Time) error

ClearLimitReached moves a limit-blocked instance back to LiveRunning so the daemon poll re-resolves its real state on the next tick and the [limit] badge clears (#1146). A no-op when the instance is not limit-blocked, so the resume action (and the auto-resume scheduler) can call it unconditionally. ReparkLimitUnderResumeFence restores a limit window while the caller holds the resume fence. It is the transaction-owned twin of SetLimitReached, in the same shape as RecordHandoffSwap is to SwapAgentProgram: the plain setter refuses while ANY op is in flight, which is right for every other writer and wrong for the one that owns the operation.

The resume needs it because it re-parks BEFORE delivering its prompt (#2997). With the fence held across that stretch — which is what keeps the poll from settling the fresh runtime Ready and ending the task run — the plain setter would no-op and this episode's reset time would be lost, leaving the auto-resume scheduler nothing to schedule off. Silently, too: it reports the refusal only through a bool that path had no reason to read.

Refuses unless OpRespawning is actually held, so it cannot become a back door around the guard it deliberately steps past.

func (*Instance) RepoName

func (i *Instance) RepoName() (string, error)

func (*Instance) ResolvedAgent added in v1.0.139

func (i *Instance) ResolvedAgent() string

ResolvedAgent returns the canonical agent (one of tmux.SupportedPrograms) this instance's pane will actually run, or "" when the resolved command runs no known agent — e.g. a program_overrides entry pointing an agent name at a plain shell (#1131). Agent-specific behavior (readiness heuristics, trust-prompt handling, flag injection) must key off this, never off Instance.Program: Program is the config-name enum the instance was created with, and an override may point it at a different program entirely (#1116).

Once the tmux session exists, its program string (override-resolved and flag-injected by Start) is the ground truth. Before Start — or in tests that never attach a tmux session — detection falls back to the raw Program value, which also covers legacy free-form persisted values like "/home/foo/bin/claude --plugin-dir x" (#677).

func (*Instance) ResolvedPaneAgent added in v1.0.207

func (i *Instance) ResolvedPaneAgent() string

ResolvedPaneAgent returns the canonical agent proven by this instance's concrete local tmux binding, or "" when there is no such binding or its command names no known agent. Unlike ResolvedAgent it deliberately never falls back to Instance.Program: callers describing an already-attached pane must not invent agent-specific behavior for remote tabs, whose real command was resolved inside the sandbox and is not represented by a local tmux session (#2210).

func (*Instance) Respawn added in v1.0.140

func (i *Instance) Respawn() error

Respawn re-spawns this session's runtime. It REQUIRES the caller to hold the limit-resume fence (BeginLimitResume): the fence has to be up before the daemon's probe-and-preserve phase, which runs well before this call, so raising one here would be too late to protect the sequence it belongs to.

On success the backend ends in ConfirmLive, which is allowed from OpRespawning and clears it. On failure the fence stays up and the caller's EndLimitResume lowers it.

func (*Instance) RespawnWithLiveBoundary added in v1.0.240

func (i *Instance) RespawnWithLiveBoundary(beforeLive func()) error

RespawnWithLiveBoundary is Respawn with the same pre-ConfirmLive callback as RecoverWithLiveBoundary. Limit recovery uses it to retire facts owned by the exited, limit-blocked process before its replacement becomes visible.

func (*Instance) RestoreArchivedWorktree added in v1.0.139

func (i *Instance) RestoreArchivedWorktree(dest string) error

RestoreArchivedWorktree moves this instance's archived worktree back to dest and re-registers it against the origin repo (#1028). Surfaces git.ErrRepoGone when the repo has been deleted so the caller can leave the archive intact.

func (*Instance) RestoreArchivedWorktreeWithClaim added in v1.0.229

func (i *Instance) RestoreArchivedWorktreeWithClaim(dest string, claim git.RelocationClaim) error

RestoreArchivedWorktreeWithClaim carries recovery ownership obtained before restore admission through to the relocation boundary, avoiding a second reader between source resolution and use.

func (*Instance) RestoreFromArchive added in v1.0.139

func (i *Instance) RestoreFromArchive() error

RestoreFromArchive re-spawns an archived instance's agent after its worktree has been moved back into place (#1028), flipping it live. It marks the instance started + Lost so the Recover re-spawn path is eligible (the same re-spawn the #1108 Lost-restore loop drives), then Recover brings the agent session up and sets Running (markLive clears the OpRestoring fence). On a Recover failure the instance is dropped to a plain Lost (op cleared), so the daemon's Lost-restore loop keeps retrying — the worktree is already back in place, so the session self-heals rather than stranding as Archived with no tmux. The agent tab and any web tabs are restored; shell/process tabs were dropped at archive time (#1028), while web tabs — pure metadata with no tmux to re-spawn — ride back on the record and render again (#1809).

liveness is set to Lost (so Recover's ==Lost gate accepts it) and OpRestoring fences the re-spawn window: the daemon poll skips an instance with an in-flight op, so it never probes the half-spawned session and marks it Lost out from under the restore. This replaces the old "park it in Lost purely to trigger the re-spawn loop" overload (#1195).

func (*Instance) RestoreReprovisionsSandbox added in v1.0.210

func (i *Instance) RestoreReprovisionsSandbox() bool

RestoreReprovisionsSandbox reports whether a Lost/Dead restore of this session re-provisions a FRESH off-box sandbox (cloning the last pushed commit) rather than re-spawning it in place. Only the sandbox runtimes (docker/ssh/hook) route their Recover through recoverSandbox → reprovisionRemote; a local session re-spawns its tmux in the same worktree. A backend-less instance is treated as local. This is the backend primitive — RestoreWouldDiscardUnpushedWork gates it on the liveness that actually takes the re-provision path.

func (*Instance) RestoreSandbox added in v1.0.181

func (i *Instance) RestoreSandbox() error

RestoreSandbox re-provisions a fresh sandbox for an archived sandbox session, cloning the pushed branch back, and relaunches the agent (#1592 Phase 4 PR6). It is the raw restore mechanic (the daemon wraps it with locks + the restore transition; the round-trip test drives it directly). The session resumes from the pushed branch state — the code survives via GitHub; a fresh agent runs on it (the pre-archive conversation lived only in the disposed sandbox).

func (*Instance) RestoreWouldDiscardUnpushedWork added in v1.0.210

func (i *Instance) RestoreWouldDiscardUnpushedWork() bool

RestoreWouldDiscardUnpushedWork reports whether restoring this row re-provisions a fresh sandbox and thereby risks discarding work that was never pushed off the old one (#1794). It is true only for a Lost/Dead REMOTE session: the daemon's restore re-provisions a fresh sandbox when the old one can't be reached (daemon/restore.go), losing anything unpushed. A local session re-spawns in place, and an archived session (any backend) restores from the branch the archive already pushed — neither loses anything. Interactive clients confirm a restore for which this is true before triggering it.

func (*Instance) RetryPendingTabCleanup added in v1.0.213

func (i *Instance) RetryPendingTabCleanup() (retired, remaining int)

RetryPendingTabCleanup re-attempts the tmux teardown of every tab whose close was durably committed but never confirmed, and retires each handle whose session is now positively gone. It reports how many handles were retired and how many remain unconfirmed.

Retiring only on a CONFIRMED kill is the whole discipline. tmux Close is idempotent — killing an already-absent session succeeds (#967) — so a nil error genuinely means "no such session remains", which is the one answer that justifies dropping the last durable pointer to it. A timeout keeps the handle: an unanswered server is not evidence of absence, and #2669 is precisely the bug where an unknown outcome was read as a finished one.

A zero retired count means no handle was retired and therefore nothing needs persisting, so the caller can skip the write entirely.

func (*Instance) RevertHandoff added in v1.0.206

func (i *Instance) RevertHandoff(swap HandoffSwap) error

RevertHandoff undoes a SwapAgentProgram whose runtime swap then failed, restoring the exact outgoing Program value and putting its conversation id back. The exact value matters for free-form commands that have no provider identity from which a launch command could be reconstructed.

This exists because a failed replacement did not establish the incoming runtime. Leaving Program set to that unconfirmed agent would make every later decision — respawn flag injection, readiness heuristics, the next handoff's same-agent check — act as though the swap committed. A stale ledger entry for a swap that never completed is the same class of lie, so the entry comes off too.

It removes only the trailing entry, and only when it is the one passed in: if anything else has appended since, this is no longer an unwind and refusing is safer than truncating someone else's record.

func (*Instance) RollbackPRInfoWrite added in v1.0.258

func (i *Instance) RollbackPRInfoWrite(rollback PRInfoRollback)

RollbackPRInfoWrite reinstates the state BeginPRInfoWrite replaced — the generation and freshness clock included, not just the value. A failed persist committed nothing, so it must leave no trace: a generation left advanced would spuriously fail a concurrent producer's CAS and make it discard a still-valid result, and a refreshed clock would keep the old value looking fresh for another staleness window (#3287 review).

func (*Instance) RootRecreateContext added in v1.0.218

func (i *Instance) RootRecreateContext() RootRecreateContext

RootRecreateContext reports the note-worthy outcome of this session's re-create, if it was one and nobody has looked at it yet.

func (*Instance) SandboxCredentialsAttached added in v1.0.224

func (i *Instance) SandboxCredentialsAttached() bool

SandboxCredentialsAttached reports whether this instance can mint a callback credential for a sandbox provisioned on its behalf.

Exported narrowly so the daemon can ASSERT its own wiring: the failure this guards is an instance materialized from disk that nobody attached a minter to, which produces no error and no symptom until a restore quietly comes back without a callback (#3065 review). A predicate the daemon's tests can read is the difference between that being caught and being shipped.

func (*Instance) SendPromptWithEvidence added in v1.0.240

func (i *Instance) SendPromptWithEvidence(prompt string, now func() time.Time) (PromptDeliveryStatus, error)

SendPromptWithEvidence starts the attempt timestamp only after any pane snapshot already in flight has completed, then records the delivery verdict before another snapshot can begin.

func (*Instance) SetAgentConversation added in v1.0.146

func (i *Instance) SetAgentConversation(conv AgentConversationData) bool

SetAgentConversation records a provider conversation on the Agent tab. Returns true when the in-memory value changed and should be persisted.

func (*Instance) SetAgentConversationForRuntime added in v1.0.207

func (i *Instance) SetAgentConversationForRuntime(token AgentRuntimeToken, conv AgentConversationData) bool

SetAgentConversationForRuntime commits conv only while token still names the live process generation it was captured for. This catches A→B as well as A→B→A handoffs; an agent-name comparison alone cannot distinguish the latter.

func (*Instance) SetAgentModelChangeAtEpoch added in v1.0.207

func (i *Instance) SetAgentModelChangeAtEpoch(change *AgentModelChange, observedEpoch uint64) bool

SetAgentModelChangeAtEpoch applies a diagnostic observed from the running agent only while the lifecycle epoch still matches the one captured before that observation. Every runtime replacement crosses a lifecycle transition, so an outgoing process cannot write its warning back after handoff/recovery retired it. Invalid transitions normalize to nil so malformed wire data fails closed instead of rendering a false alarm.

func (*Instance) SetArchived added in v1.0.139

func (i *Instance) SetArchived()

SetArchived flips the instance into the inert Archived state atomically: started=false (no tmux binding backs it) and liveness=Archived, clearing any in-flight op. Called by the daemon after a successful archive move.

func (*Instance) SetBackend added in v1.0.46

func (i *Instance) SetBackend(b Backend)

SetBackend sets the backend for the instance (mainly for testing). It writes under i.mu to match bindProvisionResult, so a test swapping the backend cannot race a reader on a background tick.

func (*Instance) SetGitWorktreeForTest added in v1.0.53

func (i *Instance) SetGitWorktreeForTest(gw *git.GitWorktree)

SetGitWorktreeForTest assigns a git worktree to this instance. Test-only: the real flow sets this inside LocalBackend.Start, which isn't available in unit tests that use FakeBackend.

func (*Instance) SetInFlightOpForTest added in v1.0.149

func (i *Instance) SetInFlightOpForTest(op InFlightOp)

SetInFlightOpForTest writes the op axis directly, for TEST scaffolding only — establishing a precondition state rather than exercising a transition (#1195 Phase 2e). Production code never sets the op axis directly: every op write goes through the Transition chokepoint (BeginCreate/BeginKill/BeginArchive/ BeginRestore/MarkRestoring raise an op; ConfirmLive/RevertKill/CommitArchive/ AbortArchive/AbortRestore/ClearOp clear it). Mirrors the SetStartedForTest / SetGitWorktreeForTest scaffolding pattern.

func (*Instance) SetLimitReached added in v1.0.140

func (i *Instance) SetLimitReached(resetAt time.Time)

SetLimitReached marks the instance blocked on a usage-limit wall (#1146): it sets the LiveLimitReached liveness and stores the parsed reset time (zero when the banner carried none) for the sidebar badge and the auto-resume scheduler (daemon/limitresume.go). There is no legacy Status value for SetStatus to decompose onto, so the daemon single-writer (#960) sets the liveness axis directly here. Skips a row with any operation in flight so it never clobbers that operation's fence.

func (*Instance) SetLimitReachedAtEpoch added in v1.0.205

func (i *Instance) SetLimitReachedAtEpoch(resetAt time.Time, epoch uint64) bool

SetLimitReachedAtEpoch is SetLimitReached for a decision derived from an OBSERVATION — the daemon poll's usage-limit detection over captured pane content (#2135). It applies the block only while the instance's state epoch is still the one the observation was captured at; if a newer authoritative transition has landed since (a resume's ClearLimitReached above all, but equally a kill or an archive) the decision is known-stale and is dropped. Reports whether it applied.

The check and the write are one critical section under i.mu, which is the whole point: an epoch read followed by a separate SetLimitReached would leave the same window it closes.

func (*Instance) SetLimitResetAt added in v1.0.140

func (i *Instance) SetLimitResetAt(resetAt time.Time)

SetLimitResetAt records only the display-only reset time (#1146), leaving both axes untouched. The read-only TUI reconcile uses it to mirror the daemon's parsed reset time after it has already applied LiveLimitReached on the liveness axis (Phase 1d applies liveness UNCONDITIONALLY via SetLiveness): the reset time rides the liveness as pure display metadata, so it is set on its own rather than through SetLimitReached, which would re-drive the liveness axis and carry SetLimitReached's transient-op guard into a path that must be unconditional.

func (*Instance) SetPRInfo

func (i *Instance) SetPRInfo(info *git.PRInfo)

SetPRInfo sets the associated GitHub PR info.

func (*Instance) SetPRInfoFetchedAtForTest added in v1.0.258

func (i *Instance) SetPRInfoFetchedAtForTest(at time.Time)

SetPRInfoFetchedAtForTest backdates the freshness clock so staleness-window tests can age an entry without sleeping. Test-only; nothing is derived from the timestamp beyond PRInfoAge itself.

func (*Instance) SetPendingHandoffMission added in v1.0.207

func (i *Instance) SetPendingHandoffMission(mission string)

SetPendingHandoffMission records the rendered takeover brief before the irreversible runtime-swap checkpoint. A daemon restart can then recover the exact context that still needs delivery instead of guessing from Prompt.

func (*Instance) SetPendingTabCleanupForTest added in v1.0.213

func (i *Instance) SetPendingTabCleanupForTest(pending []TabCleanupData)

SetPendingTabCleanupForTest seeds the unconfirmed tab-teardown handles a previous daemon would have left behind (#2669). Test-only: the real flow writes them from CloseTab's commit and reads them back through FromInstanceData, neither of which a daemon-package test can reach without staging a whole crashed close.

func (*Instance) SetPrompt added in v1.0.207

func (i *Instance) SetPrompt(prompt string)

SetPrompt replaces the durable goal used by later limit resumes and handoffs. Prompt became mutable when handoff gained an operator-supplied brief, so the write and every concurrent reader must use the instance lock.

func (*Instance) SetRepoGoneFinalizationCheckpoint added in v1.0.247

func (i *Instance) SetRepoGoneFinalizationCheckpoint(checkpoint func() error) func()

SetRepoGoneFinalizationCheckpoint installs the daemon's durable writer for the post-content, pre-root cleanup boundary. Kill runs backend teardown outside i.mu, so the callback may safely snapshot this instance for persistence.

func (*Instance) SetSandboxBranch added in v1.0.220

func (i *Instance) SetSandboxBranch(branch string)

SetSandboxBranch records the branch a SANDBOX session's own runtime reports, under the same mutex GetBranch reads it with.

It exists because a sandbox session's daemon-side Branch has no other honest source. The in-sandbox provision creates the branch with the SANDBOX's config and never mutates this Instance, so the name reaches the daemon only as an Archive() return — from ArchiveSandbox, and now from the push recovery performs before replacing a reachable sandbox (#2923/#2925). The daemon must not derive it instead: the sandbox's branch_prefix may differ, and BranchForTitle appends a random suffix for titles that sanitize away, so a derived name would be confidently wrong — worse than the empty one it replaced.

func (*Instance) SetSandboxCredentials added in v1.0.224

func (i *Instance) SetSandboxCredentials(c SandboxCredentials)

SetSandboxCredentials attaches the daemon-backed minter to an instance the daemon materialized from disk.

Exported for exactly that: FromInstanceData rebuilds an inert instance and cannot know about the daemon, so without this every session loaded after a restart would provision its replacement with no credential and no error — which is what the first version of this shipped (#3065 review).

Set-once in practice, but written under i.mu because reprovisionRemote reads it under the read lock while a restore may be materializing instances.

func (*Instance) SetStartedForTest added in v1.0.53

func (i *Instance) SetStartedForTest(started bool)

SetStartedForTest toggles the started flag for testing purposes. Prefer Start() in non-test code; this exists so unit tests can exercise flows gated on Started() without spinning up a real tmux session.

func (*Instance) SetStatusForTest added in v1.0.149

func (i *Instance) SetStatusForTest(status Status)

SetStatusForTest sets the status under the instance mutex by decomposing the legacy composed Status onto the two axes — TEST scaffolding only (#1195 Phase 2e), for establishing a precondition state via the familiar single-value API. Production code never writes lifecycle state through the legacy Status. Mirrors the SetInFlightOpForTest / SetStartedForTest scaffolding pattern. (GetStatus stays — the composed value is still a legitimate read for rendering and test assertions, pending a separate retirement of the legacy Status enum.)

func (*Instance) SetTitle

func (i *Instance) SetTitle(title string) error

SetTitle sets the title of the instance. Returns an error if the instance has started. We cant change the title once it's been used for a tmux session etc.

func (*Instance) SetTmuxSession

func (i *Instance) SetTmuxSession(session *tmux.TmuxSession)

SetTmuxSession sets the agent tab's tmux session for testing purposes, materializing the single Agent tab if needed.

func (*Instance) ShownArchived added in v1.0.141

func (i *Instance) ShownArchived() bool

ShownArchived reports whether the row belongs in the sidebar's Archived section (#1028): it is archived on the liveness axis AND not mid-restore. An OpRestoring overlay re-homes the row into the live Instances section EAGERLY (#1210) — the visible feedback the archive epic owes restore — WITHOUT touching the liveness axis. Leaving liveness LiveArchived is load-bearing: the snapshot reconcile keys its Archived→live REBUILD (re-Start, restoring started + the agent-tmux binding, #1203) on seeing that exact transition, so an eager liveness flip here would make the reconcile see live→live and SKIP the rebuild, stranding the restored row "live but not started" — the #1203 regression. The rebuild replaces the row with a fresh started instance (OpNone), which clears the overlay; a restore FAILURE clears OpRestoring so the row drops back into the Archived section.

func (*Instance) SnapshotAgent added in v1.0.240

SnapshotAgent serializes pane observation with prompt delivery for one concrete runtime. It samples the operation axis and state epoch only AFTER it owns that runtime's observation lock, so a delivery that won the lock cannot advance the epoch and then have its post-delivery snapshot rejected with the older one. A target retired while this call waited is retried against its successor. A replacement during Snapshot is revalidated after transport I/O and retried too: callers perform transport-liveness side effects before some epoch-scoped applies, so returning a retired observation is not safe merely because its later state mutation would be rejected.

func (*Instance) Start

func (i *Instance) Start(firstTimeSetup bool) error

firstTimeSetup is true if this is a new instance. Otherwise, it's one loaded from storage.

func (*Instance) Started

func (i *Instance) Started() bool

func (*Instance) StartupStateUnknown added in v1.0.206

func (i *Instance) StartupStateUnknown() bool

StartupStateUnknown reports whether a create may have launched a runtime but could not confirm its identity or liveness.

func (*Instance) StateEpoch added in v1.0.205

func (i *Instance) StateEpoch() uint64

StateEpoch returns the instance's observation generation counter (#2135). Capture it BEFORE the observation a decision will be made from, and hand it back to the epoch-scoped applier (TransitionEvent.AtEpoch / SetLimitReachedAtEpoch) so a decision that a newer transition has superseded is dropped instead of applied. See the file comment for why this is a counter and not a lock.

func (*Instance) SwapAgent added in v1.0.206

func (i *Instance) SwapAgent(plan AgentSwapPlan) (InstanceData, error)

SwapAgent executes a prepared runtime replacement. The daemon must already have raised OpReplacing and recorded plan.target as Instance.Program. Success deliberately leaves that fence raised: the replacement is not a completed handoff until the daemon has delivered (or explicitly parked) its mission.

func (*Instance) SwapAgentProgram added in v1.0.206

func (i *Instance) SwapAgentProgram(target, reason, headSHA string, automatic bool) (HandoffSwap, error)

SwapAgentProgram rewrites the instance's agent program in place and appends the handoff to the tab's ledger. It mutates state only — the caller re-spawns the pane and delivers the mission — so it is safe to call before any irreversible teardown and cheap to test on its own.

Clearing Tab.Conversation is load-bearing, not tidiness. respawn feeds the program through prepareResumeConversation, which would otherwise hand the INCOMING agent the outgoing agent's recorded conversation id. That specific call is already guarded (ResumeProgramWithConversationID refuses a provider mismatch), but leaving a stale codex id on a tab now running claude is a lie in the record that the next reader has to re-derive the guard for. The ledger keeps the id; the live slot describes the live agent.

The caller must hold whatever serialization the daemon requires; this method takes only the instance lock.

func (*Instance) TabAlive added in v1.0.123

func (i *Instance) TabAlive(idx int) bool

TabAlive reports whether the tab at idx has a live tmux session, as a LOSSY bool: true means "alive OR could-not-determine (wedged/timeout)", false means "no binding, or definitively gone". It is deliberately kept lossy because its only consumers (ui/tab_pane.go) act solely on !TabAlive — they swap to the "Terminal session not available" fallback. A wedged server reads as alive here, which keeps the read-only TUI rendering the pane instead of falsely declaring a merely-unreachable terminal dead: the safe direction for a view (#1962). A caller that needs existence as EVIDENCE must use TmuxSession.ProbeSession directly and handle !known.

func (*Instance) TabCount added in v1.0.123

func (i *Instance) TabCount() int

TabCount returns the number of tabs the instance currently holds.

func (*Instance) TabIDAt added in v1.0.183

func (i *Instance) TabIDAt(idx int) (string, bool)

TabIDAt returns the stable id (#1738) of the tab at ordinal idx, and whether idx is in range. It is the index→id direction the data plane keys its per-tab broker on, so a broker follows its tab across a reorder/close instead of being pinned to a shifting ordinal.

func (*Instance) TabIndexByID added in v1.0.183

func (i *Instance) TabIndexByID(id string) (int, bool)

TabIndexByID returns the CURRENT ordinal of the tab with stable id (#1738), and whether such a tab exists. It is the id→index resolution the stream endpoint runs per operation: a client addresses a tab by its stable id and the daemon maps it to wherever that tab now sits, so a reorder/close on another client can never make the client's captured position refer to a different tab. An empty id never matches (a legacy/absent id is not addressable by id).

Prefer a single-lock primitive (TabTmuxByID, TabTargetByID) where one exists for what the caller actually needs: an ordinal handed back to a SECOND lookup reopens the close/reorder window this resolution is meant to close.

func (*Instance) TabSpawnBlocked added in v1.0.140

func (i *Instance) TabSpawnBlocked() error

TabSpawnBlocked is the locking form of tabSpawnBlockedLocked, for callers that don't already hold i.mu (the daemon's archive-exclusive tab lock).

func (*Instance) TabTargetByID added in v1.0.192

func (i *Instance) TabTargetByID(id string) (kind TabKind, url string, exists bool)

TabTargetByID resolves a tab's stable id (#1738) DIRECTLY to what the web-tab proxy addresses it by — its kind, and the target URL a TabKindWeb tab stores — under a SINGLE lock acquisition. It is TabTmuxByID's counterpart for the iframe plane, and exists for the same reason: id→ordinal followed by ordinal→tab takes i.mu TWICE, and a concurrent close between the two lands the second lookup on a DIFFERENT tab.

A bounds check does not close that window, because the racing list is SHORTER, not out of range: with tabs [agent, A, B, C], resolving B yields ordinal 2, and a close of A before the second lookup leaves [agent, B, C] — where ordinal 2 is now C, in range and wrong. The proxy would then serve C's dev server under B's stable id, which is the exact misroute keying the route by id (#1810) exists to prevent.

url is "" for every kind but TabKindWeb; a VSCODE tab deliberately stores none (its editor is resolved per request), so callers must not read absence of a URL as absence of a tab — that is what exists is for.

func (*Instance) TabTmuxByID added in v1.0.184

func (i *Instance) TabTmuxByID(id string) (ts *tmux.TmuxSession, exists bool)

TabTmuxByID resolves a tab's stable id (#1738) DIRECTLY to the tmux session it currently backs, under a SINGLE lock acquisition. It is the atomic primitive the id-addressed data plane binds on: resolving an id to an ordinal and then that ordinal to a tmux session takes i.mu twice, and a concurrent close/reorder between the two makes the second lookup land on a DIFFERENT tab — exactly the misroute the stable id exists to prevent (#1779). Resolving both under one lock closes that window.

The two return values answer two DIFFERENT questions, and callers must not conflate them:

  • exists=false — the id names no tab at all: it was closed, or never minted. This is the "gone" the id-addressed plane refuses on.
  • exists=true, ts=nil — the tab is real but has no local PTY right now: the instance has not started, or it is a remote runtime with no local tmux. NOT gone; a caller must not report it as such, since a not-yet-started tab may still come up and a client should keep addressing it.

func (*Instance) TabTmuxName added in v1.0.139

func (i *Instance) TabTmuxName(idx int) string

TabTmuxName returns the sanitized tmux session name of the tab at idx, or "" when the instance is not started or the tab has no local session (remote tabs, out-of-range idx). The embedded terminal pane (#1089) uses it to attach its own render client to the tab's session; it never creates or mutates the session.

func (*Instance) TaskRunActive added in v1.0.200

func (i *Instance) TaskRunActive() bool

TaskRunActive reports whether this session's task run is still in flight (#1892). Prefer LifecycleView when the answer is combined with any other piece of state: a verdict assembled from separate accessor calls can straddle a concurrent transition.

func (*Instance) TmuxAlive

func (i *Instance) TmuxAlive() bool

TmuxAlive returns true if the underlying session is alive. For remote backends this delegates to IsAlive.

It collapses IsAlive's tri-state to a bool, treating "could not ask" as NOT alive. That is safe for its callers — the TUI's attach/pane guards, which only refuse to attach — but it must never be used as evidence of liveness: take IsAlive directly for that (#1917 round 8).

func (*Instance) ToInstanceData

func (i *Instance) ToInstanceData() InstanceData

ToInstanceData converts an Instance to its serializable form

func (*Instance) ToInstanceDataWithEpoch added in v1.0.205

func (i *Instance) ToInstanceDataWithEpoch() (InstanceData, uint64)

ToInstanceDataWithEpoch returns the serializable form together with the observation epoch it was read at, both under ONE hold of i.mu (#2135). The epoch deliberately does not cover every field in InstanceData (tabs are the notable example), so it may correlate pane observations but must not be used as a whole-projection freshness guard. Writers of the whole payload re-read it in their ordering domain instead; see daemon.persistPollChange.

func (*Instance) Transition added in v1.0.146

func (i *Instance) Transition(ev TransitionEvent) error

Transition applies a lifecycle event to the two-axis (liveness, inFlightOp) state under i.mu, validating it against the allowed-edge table. It is the single writer-side chokepoint for lifecycle-state changes (#1195 Phase 2c) and the enforcement point for the I1–I4 ordering invariants. An illegal edge returns an error AND fires onIllegalTransition (panic in test builds); a yielding edge (ObserveLiveness always, ConfirmLive under a teardown op) that is out-of-set is a silent no-op. INERT until Phase 2d migrates the writers.

func (*Instance) TryTmuxTeardownCount added in v1.0.222

func (i *Instance) TryTmuxTeardownCount() (int, bool)

TryTmuxTeardownCount reports how many tmux sessions a teardown of this instance would have to close, which is the count that predicts teardown WORK.

Two contributions, because teardownTabs closes both through one sequential loop: live tabs whose tab.tmux is non-nil — web and vscode tabs own none and cost nothing per-tab — and the PENDING CLEANUP handles of tabs already removed from the roster whose kill was never confirmed (#2669). Omitting either direction gets a caller budgeting against this wrong: counting the whole roster charges a session for iframe tabs it never tears down, while ignoring the pending handles under-budgets a session that has accumulated them.

The pending handles are counted unconditionally even though only a destructive mode reaps them. A budget should bound the worst case: over-counting delays a watchdog, under-counting fires one on healthy work (#3023). It never BLOCKS, and that is a correctness requirement rather than a nicety. Its caller is the kill watchdog, which is armed while the kill already holds killsInFlight and the operation lock — so waiting on i.mu here would stall the arming on precisely the stuck-lock wedge the watchdog exists to report, leaving the session undeletable with no diagnostics at all (#3023 review). A caller that cannot read the roster gets ok=false and is expected to budget from the persisted record instead, which needs no lock.

func (*Instance) UserKilled added in v1.0.139

func (i *Instance) UserKilled() bool

UserKilled reports whether an explicit kill was recorded for this instance.

func (*Instance) ValidateHandoffTarget added in v1.0.206

func (i *Instance) ValidateHandoffTarget(target string) error

ValidateHandoffTarget checks that target is a usable handoff destination for this instance, without mutating anything. It is the shared precondition for the CLI, the RPC, and the TUI so all three refuse the same inputs with the same words.

The target is compared against CurrentAgentName, not ResolvedAgent. See that function for why: ResolvedAgent answers "which binary is running" and is documented to return "" for a wrapper script, which silently disables this guard exactly when a user has customized their setup.

func (*Instance) ValidateRuntimeAction added in v1.0.206

func (i *Instance) ValidateRuntimeAction(action RuntimeAction) error

ValidateRuntimeAction is the locking form for live instances.

func (*Instance) ValidateWorktreeDestructionAdmission added in v1.0.229

func (i *Instance) ValidateWorktreeDestructionAdmission() error

ValidateWorktreeDestructionAdmission is the pre-commit guard. The local backend separately consumes and revalidates the exact cleanup identity before pane teardown; it must not repeat the origin-path admission after the durable kill has committed.

func (*Instance) WebTabServeBlocked added in v1.0.189

func (i *Instance) WebTabServeBlocked() error

WebTabServeBlocked is the serve-side analogue of TabSpawnBlocked: "may this session's preserved web tab be resolved and proxied right now?" It answers no for a settled archive AND for the teardown window that precedes one. The returned error is a REASON fragment, not a sentence: the proxy prefixes it with the session it could not serve, so the two read as one message.

The in-flight ops matter here for the same reason they matter to a tab spawn. BeginArchive raises OpArchiving BEFORE tmux comes down and the worktree moves, and leaves liveness live until CommitArchive lands at the very end (#1195 Phase 2d). A gate reading only the settled LiveArchived would keep proxying a preserved loopback URL throughout that teardown — an iframe that was open when the user hit archive would go on reaching a port on the daemon's machine while the session it belongs to is being dismantled. Terminal streams already fence this window via killsInFlight; the proxy route is not serialized with ArchiveSession at all, so it needs the fence on the instance itself.

OpKilling rides along for the same reason: a session being removed must not serve. OpRestoring deliberately does NOT — see IsArchived.

type InstanceData

type InstanceData struct {
	// ID is the instance's stable identity (#1195), minted at NewInstance and
	// used as the reconcile identity key. omitempty + additive: records written
	// before #1195 simply have no id, and the reconcile falls back to
	// title+CreatedAt for them (rollforward, mirroring the BranchCreatedByUs
	// precedent).
	ID string `json:"id,omitempty"`
	// TaskID is the id of the task whose delivery spawned this session, empty for
	// a user-created one (#1892). It is the daemon-owned association between a
	// task delivery and its session: the watch-task concurrency limit counts a
	// task's in-flight sessions by this field, never by a title prefix. A prefix
	// scan cannot do the job — nextAvailableTitleLocked auto-suffixes a taken base
	// to "<base>-2", which is indistinguishable from a session a user named
	// "<base>-2" themselves, and from a task whose name is another's prefix.
	// omitempty + additive: records written before #1892 simply have no task_id
	// and count against no limit (rollforward, mirroring the ID precedent above).
	TaskID string `json:"task_id,omitempty"`
	Title  string `json:"title"`
	Path   string `json:"path"`
	Branch string `json:"branch"`
	// Status is the legacy single-axis status int (#1195). Still written for one
	// release for rollback safety and read as the fallback source for records
	// that predate the `liveness` field. New code should read Liveness.
	Status Status `json:"status"`
	// Liveness is the daemon-owned health axis (#1195), the new canonical
	// persisted state. omitempty + additive: records written before #1195 have
	// no `liveness` key and decode to LivenessUnset, signaling FromInstanceData
	// to fall back to the legacy `status` int (rollforward).
	Liveness Liveness `json:"liveness,omitempty"`
	// InFlightOp is the transient operation axis (#1195/#1436) carried by the
	// daemon Snapshot so secondary TUIs can reconstruct non-round-trippable ops
	// exactly (OpArchiving vs OpKilling; OpRestoring vs plain Lost). It is scrubbed
	// at disk write/load boundaries: in-flight operations are process-local and
	// must not be resurrected after a daemon restart.
	InFlightOp InFlightOp `json:"in_flight_op,omitempty"`
	// LifecycleAction is a projection-only capability shared by the TUI and web
	// (#2234): "archive", "restore", or omitted when the row has no safe
	// lifecycle target (creating or id-less). It is derived from live state by
	// ToInstanceData and scrubbed by ForStorage; instances.json must not preserve
	// a UI decision that can go stale across restart.
	LifecycleAction LifecycleAction `json:"lifecycle_action,omitempty"`
	// CanKill is the independent projection-only teardown capability. It is true
	// for any stable, non-creating row, including StartupStateUnknown: that state
	// vetoes runtime reuse but must remain explicitly removable. Like
	// LifecycleAction, this is derived live and scrubbed before disk persistence.
	CanKill bool `json:"can_kill,omitempty"`
	// CanHandoff is the projection-only agent-swap capability shared by the TUI and
	// web (#2013): true when this session's agent can be handed off in place — a
	// local-worktree backend (Capabilities().Handoff) in a runtime state that admits
	// the swap (ValidateRuntimeAction(RuntimeActionHandoff)). It is derived live by
	// ToInstanceData from the SAME two predicates the TUI's handoff gate reads
	// (app/handle_handoff.go), so a browser — which cannot run those Go predicates —
	// renders the daemon's decision instead of re-deriving the rule. Scrubbed before
	// disk persistence like LifecycleAction/CanKill.
	CanHandoff bool `json:"can_handoff,omitempty"`
	// CurrentAgent is the agent enum this session is treated AS
	// (session.CurrentAgentName). Projection-only, carried so a client's handoff
	// picker can exclude the running agent exactly as the daemon's same-agent guard
	// does (session/handoff.go) — filtering on Program instead would drift from the
	// guard on a wrapper-script session. Derived live and scrubbed before disk.
	CurrentAgent string `json:"current_agent,omitempty"`
	// IsRoot is the projection-only reserved-root decision shared by the TUI and web
	// (#2513): the daemon's own session.IsReservedTitle applied to the title. It is
	// projected so the web pins root to the top of the rail (and draws the
	// demarcation rule) by CONSUMING the daemon's decision rather than
	// re-implementing IsReservedTitle in TypeScript against a duplicated title
	// constant — the exact one-concept-two-representations drift #2513 called out.
	// Derived live by ToInstanceData and scrubbed before disk like CanKill/CanHandoff.
	IsRoot bool `json:"is_root,omitempty"`
	// ModelChange is the projection-only agent diagnostic carried to the CLI,
	// TUI, and web row. It is derived from the live runtime's Observation and
	// scrubbed by ForStorage so a resolved or replaced process cannot inherit a
	// stale warning after daemon restart.
	ModelChange *AgentModelChange `json:"model_change,omitempty"`
	// IdleReason is the smallest mechanically established explanation for a
	// non-working row (#3168). It is derived from the evidence fields below and
	// never from pane wording. Projection-only: ForStorage scrubs it, while live
	// snapshots and daemonless list fallback recompute it with IdleReasonFor.
	IdleReason IdleReason `json:"idle_reason,omitempty"`
	// LastPromptAttemptAt orders the most recent actual prompt send against later
	// pane churn. Callers capture it before sending so churn racing the delivery is
	// still known to be later. Persisted across daemon restarts.
	LastPromptAttemptAt time.Time `json:"last_prompt_attempt_at,omitzero"`
	// LastPromptDeliveryStatus is the closed observation returned by the delivery
	// path. sent-unverified and could-not-confirm remain uncertainty (#3162), never
	// failed delivery.
	LastPromptDeliveryStatus PromptDeliveryStatus `json:"last_prompt_delivery_status,omitempty"`
	// LastPaneChurnAt is when the daemon most recently observed Observation.Updated.
	// It proves bytes changed, not who produced them or what they meant.
	LastPaneChurnAt time.Time `json:"last_pane_churn_at,omitzero"`
	// TaskRunActive records whether this session's task run is still in flight
	// (#1892) — true from creation, false once the agent goes idle or startup
	// settles terminal-unknown. It is the one fact the watch-task concurrency cap
	// counts, and it is stored rather than
	// re-derived because every neighbouring signal answers a different question:
	// Lost cannot tell a finished run from an interrupted one, and an in-flight op
	// means the DAEMON is busy (archiving a completed session is teardown, not
	// work). Both of those, read as "is the run in flight", let a run that already
	// finished reclaim a cap slot and park a task's events behind it.
	//
	// Persisted because an outage that loses sessions is the same event that
	// restarts the daemon, so an in-memory answer would be gone exactly when it is
	// needed. omitempty + additive: a record written without it decodes to false —
	// the session is treated as finished and holds no slot. That is the safe
	// direction for the one-time upgrade window (a daemon replaced mid-run reads its
	// in-flight sessions as done and may admit one extra event, which self-heals as
	// they finish); defaulting true would let a fleet of completed sessions load as
	// active and wedge a capped task permanently.
	TaskRunActive bool `json:"task_run_active,omitempty"`
	// LimitResetAt is the parsed usage-limit reset time (#1146), display-only:
	// written (and carried in the daemon snapshot to the read-only TUI) only for a
	// LiveLimitReached row so the sidebar [limit] badge can show "resets <t>" and
	// survive a restart, and so PR3's auto-resume scheduler can read it. omitempty
	// drops it for every normal session; additive + rollforward, mirroring the
	// Liveness precedent.
	LimitResetAt time.Time `json:"limit_reset_at,omitempty"`
	Height       int       `json:"height"`
	Width        int       `json:"width"`
	CreatedAt    time.Time `json:"created_at"`
	UpdatedAt    time.Time `json:"updated_at"`
	Prompt       string    `json:"prompt,omitempty"`
	// PendingHandoffMission is a rendered takeover brief whose incoming runtime
	// has been established but whose delivery has not been durably confirmed.
	// Unlike Prompt, it is an at-least-once recovery marker and is cleared after
	// the exact mission lands (or is transferred to the usage-limit retry path).
	PendingHandoffMission string `json:"pending_handoff_mission,omitempty"`

	Program string `json:"program"`
	// Account is the credential account this session runs its agent as (#3051).
	// Persisted because the identity a session runs as must survive a daemon
	// restart and an archive/restore: a session that silently reverted to the
	// ambient account would spend the wrong quota while still displaying the
	// account it was created with.
	Account string `json:"account,omitempty"`
	// UserKilled is the kill-intent tombstone (#1108): persisted by
	// Manager.KillSession before teardown begins. Present only in the crash
	// window between tombstone write and record deletion — a surviving
	// tombstoned record means "finish this kill", never "restore this".
	UserKilled bool `json:"user_killed,omitempty"`
	// StartupStateUnknown retains a create that crossed the launch boundary but
	// whose runtime could not be confirmed. Unlike UserKilled, it does NOT commit
	// an automatic teardown: retrying the same uncertain binding could mistake a
	// differently stored tmux name for absence and delete a live workspace
	// (#2207). Additive + omitempty keeps older records unchanged.
	StartupStateUnknown bool      `json:"startup_state_unknown,omitempty"`
	TmuxName            string    `json:"tmux_name,omitempty"`
	Tabs                []TabData `json:"tabs,omitempty"`
	// PendingTabCleanup retains the tmux identity of tabs whose removal from Tabs
	// is already durable but whose teardown could not be confirmed (#2669). It is
	// the tab-scoped analogue of RuntimeCleanup, and it exists because CloseTab
	// commits the shrunken roster BEFORE killing tmux: without it, a kill-session
	// that times out (or answers while the session still exists) would drop the
	// closed tab's only tmux identity, leaking that process untracked forever and
	// letting a later same-named tab derive the same tmux name and collide with
	// the survivor. Entries are cleanup handles, never tabs — nothing renders or
	// respawns from them. Additive + omitempty on the TabData.ID rollforward
	// precedent: records written before this field simply have none.
	PendingTabCleanup []TabCleanupData `json:"pending_tab_cleanup,omitempty"`
	// AgentConversation mirrors the Agent tab's provider conversation id for
	// API/CLI consumers. The per-tab source of truth is TabData.Conversation.
	AgentConversation *AgentConversationData `json:"agent_conversation,omitempty"`
	// RootRecreateContext is the one-shot note a re-created root agent carries
	// when it did not demonstrably come back on its prior conversation (#2629):
	// the rails render it on the row so a root that lost its history is
	// discoverable in `af`, not only in the application log.
	//
	// PERSISTED, unlike the projection-only diagnostics above — ForStorage
	// deliberately leaves it alone. A root that came back amnesiac is still
	// amnesiac after a daemon restart, and the restart is a likely part of the
	// same outage; scrubbing it would erase the notice in exactly the situation
	// that produced it. It is cleared instead by acknowledgement — the first
	// time a client opens the session's pane. Additive + omitempty on the
	// TaskRunActive rollforward precedent: a record written before this field
	// decodes to no note.
	RootRecreateContext RootRecreateContext `json:"root_recreate_context,omitempty"`
	Worktree            GitWorktreeData     `json:"worktree"`
	PRInfo              PRInfoData          `json:"pr_info,omitempty"`
	BackendType         string              `json:"backend_type,omitempty"`
	// TabKinds is the daemon's own answer, per tab kind, to "may this session gain
	// one of these" — Capabilities.RefuseTabKind projected onto the snapshot.
	//
	// It exists so a CLIENT never re-derives that rule. The web UI used to compute
	// it in TypeScript from BackendType, which agreed with the daemon only by
	// coincidence and would disagree the moment a refusal lifted (#3060). Projecting
	// the verdict means an affordance and the call behind it cannot drift: whatever
	// RefuseTabKind decides is what the UI offers, and a kind that becomes available
	// off-box (#3062, #3054) needs no client change at all.
	//
	// Each entry carries the daemon's OWN refusal text rather than a client-invented
	// one, because a user told "not supported" cannot tell a kind that could work
	// from one that genuinely cannot.
	// PendingTabs are rows restored from this record whose workspace did not
	// survive and which have not been drained onto the roster yet. They are a
	// SEPARATE field, not folded into Tabs, because Tabs has an ordering contract:
	// index 0 is the agent. A recovery that fails before Launch leaves Tabs empty,
	// so emitting a staged web tab there would put it in the agent's slot — clients
	// would render it as the unclosable agent, and daemon mutations would report it
	// missing because the row lives only in the staging area (#3062).
	PendingTabs []TabData          `json:"pending_tabs,omitempty"`
	TabKinds    []TabKindAllowance `json:"tab_kinds,omitempty"`

	// TabRosterMutable is Capabilities.TabManagement projected: whether this
	// session's tab ROSTER may be mutated (rename, reorder). It is a different
	// question from either creating a kind or closing a tab, and it has a different
	// answer — tabMutationTarget still gates on TabManagement — so a client that
	// reuses a create-or-close verdict for it offers controls the daemon refuses.
	// A POINTER so that false survives the wire. With a plain bool, omitempty
	// erased exactly the verdict that matters: a backend allowing a metadata-only
	// kind while keeping TabManagement false — the forward-compatibility case this
	// projection exists for (#3062) — serialized to nothing, and a client cannot
	// tell "the daemon said no" from "the daemon is too old to say", so it falls
	// back to the create verdict and offers a rename tabMutationTarget rejects.
	// nil therefore means only "not projected", and ForStorage sets it back to nil
	// so a derived verdict still never reaches instances.json.
	TabRosterMutable *bool `json:"tab_roster_mutable,omitempty"`
	// RuntimeCleanupStateUnknown is the independent retention marker for a sandbox
	// teardown whose completion could not be determined. The next restore must
	// retry RuntimeCleanup before it can safely provision a replacement. It is not
	// a UserKilled tombstone: the session remains wanted after cleanup is settled.
	RuntimeCleanupStateUnknown bool `json:"runtime_cleanup_state_unknown,omitempty"`
	// RuntimeCleanup is written for a committed UserKilled tombstone or the
	// unknown-cleanup state above. It is the durable identity needed to resume
	// off-box teardown after a daemon restart; ordinary snapshots keep it nil and
	// stage the live handle in the private field below until ForStorage reaches a
	// retention boundary.
	RuntimeCleanup *RuntimeCleanupData `json:"runtime_cleanup,omitempty"`

	// ArchiveWarning is the bounded live projection of an incomplete archive. It
	// may ride snapshots and lifecycle events; the full report is storage-only so
	// a large unreadable tree cannot turn every status response into megabytes.
	ArchiveWarning string `json:"archive_warning,omitempty"`
	// ArchiveReport makes a deliberately incomplete archive discoverable across
	// daemon restarts and at restore time. It lives beside the session record,
	// never inside the copied tree where a user path could collide with it. Live
	// projections stage only the source below until ForStorage requests a clone.
	ArchiveReport *git.ArchiveReport `json:"archive_report,omitempty"`
	// contains filtered or unexported fields
}

InstanceData represents the serializable data of an Instance

func (InstanceData) ForClientRead added in v1.0.238

func (d InstanceData) ForClientRead() InstanceData

ForClientRead converts a storage row into the same bounded shape emitted by a live daemon snapshot. Disk fallback callers must not expose the complete report or the compatibility-only ownership flags merely because the daemon is unavailable.

func (InstanceData) ForStorage added in v1.0.154

func (d InstanceData) ForStorage() InstanceData

ForStorage returns data suitable for instances.json. InstanceData is also the daemon Snapshot payload, so it can carry transient in-flight operation state; disk persistence must not.

func (InstanceData) IsRemoteHook added in v1.0.173

func (d InstanceData) IsRemoteHook() bool

IsRemoteHook reports whether this serialized record is a remote hook session, reading the persisted BackendType discriminator. It centralizes the raw-data remote check (#1592 Phase 1 PR3) so daemon logic that iterates []InstanceData — where no backend is reconstructed and Capabilities() is unavailable — never hard-codes the "remote" magic string. The load-time factory (NewInstanceFromData) remains the one place that maps the discriminator to a concrete backend.

func (InstanceData) ProjectIdleReason added in v1.0.240

func (d InstanceData) ProjectIdleReason() InstanceData

ProjectIdleReason recomputes the projection field from its evidence. It is used by daemonless disk-list fallback as well as live Instance snapshots.

func (InstanceData) RestoreArchiveRollbackFence added in v1.0.238

func (d InstanceData) RestoreArchiveRollbackFence() InstanceData

RestoreArchiveRollbackFence removes the previous-release safety projection from a persisted row. FromInstanceData uses it before reconstructing an Instance; storage-only cleanup paths use it before manually reconstructing a GitWorktree. Keeping that decoding here prevents a current daemon from mistaking its own old-reader fence for the session's real ownership.

func (InstanceData) RestoreRelocationRecoveryOriginals added in v1.0.248

func (data InstanceData) RestoreRelocationRecoveryOriginals() (InstanceData, error)

RestoreRelocationRecoveryOriginals reverses the rollback-safe ownership and lifecycle projection made by ForStorage before a current reader interprets a recovery record. Normal instance loading and daemon ghost cleanup share this boundary so neither can read compatibility fields as deletion authority.

func (InstanceData) UsesLocalTmux added in v1.0.207

func (d InstanceData) UsesLocalTmux() bool

UsesLocalTmux reports whether this persisted row belongs to the in-process local backend and therefore claims a repo-scoped tmux name. Empty is the pre-backend-discriminator legacy encoding and also means local. Keeping this decoding beside BackendType prevents daemon admission from growing its own backend-name list.

func (InstanceData) WithoutIdleEvidence added in v1.0.240

func (d InstanceData) WithoutIdleEvidence() InstanceData

WithoutIdleEvidence returns a checkpoint that cannot attribute observations from a retired runtime to its replacement.

type InstanceOptions

type InstanceOptions struct {
	// ID, when set, is the stable identity already announced for this instance.
	// The daemon uses it to keep an OpCreating projection and the completed
	// instance on one identity across slow provisioning. Empty mints a new id,
	// which remains the normal path for every direct constructor call.
	ID string
	// CreatedAt, when set, is the creation time already announced with ID. The
	// daemon supplies both together so a pending row does not jump in rail order
	// when provisioning completes. Zero uses the current time.
	CreatedAt time.Time
	// Title is the title of the instance.
	Title string
	// TaskID marks the session as spawned by a task's delivery (#1892). Empty for
	// a user-created session. It is what lets the daemon count a task's in-flight
	// sessions for the watch-task concurrency limit without guessing from titles.
	TaskID string
	// SandboxCredentials mints and revokes the per-session credential a provisioned
	// sandbox uses to call back into the daemon (#2999, #3068). An INTERFACE rather
	// than a pair of values so it runs only for off-box kinds — session cannot
	// import daemon, and the daemon must not mint (or refuse) for a local create
	// that will never use one — and rather than a bare mint function because the
	// runtime's lifetime drives BOTH halves: a replacement sandbox mints, a reaped
	// runtime revokes. Nil ⇒ no callback, which is every non-daemon caller.
	//
	// Held on the Instance too, so restore and recovery can provision a replacement
	// through the same path as the original create (see sandbox_credentials.go).
	SandboxCredentials SandboxCredentials
	// Path is the path to the workspace.
	Path string
	// Program is the program to run in the instance (e.g. "claude", "aider --model ollama_chat/gemma3:1b")
	Program string
	// Account scopes the session to a registered credential account (#3051).
	Account string
	// ProgramResolved marks Program as the final command selected by an outer
	// runtime. It is internal to the sandbox agent-server handoff; ordinary
	// callers pass an agent enum and leave this false.
	ProgramResolved bool
	// ForceRemote forces the instance to use the remote hook backend,
	// even if the repo config would default to local. It is the pre-Phase-4
	// hook selector, equivalent to Backend == BackendHook, and takes precedence
	// over a config-declared backend (it is set by the TUI's "new remote
	// session" action, which means "hook now" regardless of config).
	ForceRemote bool
	// Backend, when set, selects the session's runtime explicitly (the
	// `--backend` create flag, #1592 Phase 4 PR3), overriding the repo's
	// `backend` config key. Empty means "resolve from config" — which defaults
	// to local, so an unset Backend keeps the local default byte-identical.
	Backend BackendKind
	// InPlace attaches the session to the repo's existing working tree at its
	// current branch (`af sessions create --here`) instead of creating a new
	// git worktree+branch. The worktree is marked external so kill/cleanup
	// never removes the user's tree or branch. Local backend only.
	InPlace bool
	// BranchPrefix is the resolved snapshot used to name a fresh local
	// worktree's branch. The daemon supplies its frozen startup value because
	// branch_prefix is EffectNextDaemonStart; nil preserves the direct-constructor
	// behavior of resolving the current on-disk value when provisioning begins.
	// A pointer distinguishes an explicitly configured empty prefix from nil.
	BranchPrefix *string
	// ResumeConversation asks the first launch to come up on a provider
	// conversation a previous record held, rather than starting a new one
	// (#2616). Set only by the daemon's root-agent heal, which replaces the
	// vanished root's record instead of re-spawning it; every other Lost session
	// keeps its record and resumes through Recover. Empty for every ordinary
	// create, which is why the fresh-injection path is unchanged.
	ResumeConversation AgentConversationData
	// RestoreTabs asks the first launch to rebuild the tab roster a previous
	// record held, rather than coming up with only its agent tab (#2628). Set by
	// the same single caller as ResumeConversation — the daemon's root-agent
	// heal — because it has the same cause: replacing the record throws away
	// state the general Lost-restore path keeps, and the tab list is the largest
	// piece of it. Index 0 (the agent tab) is ignored: the launch spawns its own.
	// Empty for every ordinary create, which still comes up with just the agent
	// tab (#1100).
	RestoreTabs []TabData
	// PendingRecreateNotice carries an unacknowledged re-create notice from the
	// record this create replaces (#2629), so a second heal cannot erase a
	// warning about the first that nobody has seen yet. Set by the same single
	// caller as the two fields above. Empty for every ordinary create.
	PendingRecreateNotice RootRecreateContext
	// RemoteAgentServer, when set, points the instance's AgentServer() at a REMOTE
	// `af agent-server` reachable at the endpoint's authed URL (#1592 Phase 4)
	// instead of the local in-process runtime. Validated at NewInstance (a bad URL
	// or a malformed URL fails there). The off-box runtimes (docker, sandbox, ssh,
	// hook) do not use this field — backendFactory hands their endpoint back
	// directly (res.Endpoint); this is the explicit-caller seam.
	RemoteAgentServer *AgentServerEndpoint
	// SessionEnvPassthrough carries durable exact-name grants delegated by an
	// outer agent-server. Ordinary daemon/local callers leave it empty and read
	// the current global session_env_passthrough config on each launch.
	SessionEnvPassthrough []string
	// ProvisionSessionEnvPassthrough carries a current global-config snapshot to
	// the runtime factory for this create only. It is deliberately not retained
	// on the Instance: a removed global grant must disappear from later respawns,
	// handoffs, archive restores, and config-agent launches without a restart.
	ProvisionSessionEnvPassthrough []string
}

Options for creating a new instance

type LifecycleAction added in v1.0.206

type LifecycleAction string

LifecycleAction is the session domain's answer to which reversible lifecycle verb a visible row supports. The zero value means no lifecycle controls at all; a client must not infer one from liveness or from the fact that the row rendered. It is serialized into daemon projections so the TUI and web consume one decision instead of maintaining parallel state tables (#2234).

const (
	LifecycleActionNone    LifecycleAction = ""
	LifecycleActionArchive LifecycleAction = "archive"
	LifecycleActionRestore LifecycleAction = "restore"
)

type LifecycleView added in v1.0.200

type LifecycleView struct {
	// Title and TaskID are immutable after construction; carried so a caller can
	// judge a session entirely from the view.
	Title  string
	TaskID string
	// Liveness and InFlightOp are the two canonical axes (#1195); Status is their
	// composed legacy value, resolved under the same lock so a caller reading the
	// composed form cannot disagree with one reading the axes.
	Liveness   Liveness
	InFlightOp InFlightOp
	Status     Status
	Started    bool
	UserKilled bool
	// StartupStateUnknown is the retained-create fence: the launch may have
	// succeeded under an identity af could not confirm, so no runtime or workspace
	// action may infer ordinary LiveReady semantics from this view.
	StartupStateUnknown bool
	// TaskRunActive is whether this session's task run is still in flight — the one
	// fact the concurrency cap counts. See Instance.taskRunActive.
	TaskRunActive bool
	// Recoverable is the backend's Recover capability: whether a lost session can
	// be revived in place at all.
	Recoverable bool
}

LifecycleView is a CONSISTENT snapshot of one session's lifecycle state, taken under a single instance lock. It exists because a predicate that reads a live Instance more than once is not a predicate — it is a race.

The daemon's Lost-restore loop mutates a session WITHOUT holding the manager lock (restoreLostSession releases m.mu before calling Recover, which ends in Transition(ConfirmLive) → LiveRunning). So a caller that asked "is it busy?" and then "is it a restorable lost run?" through two separate accessors could have the restore land between them: the first read sees LiveLost (not busy), the second sees LiveRunning (not Lost), and the session falls through BOTH arms — counted by neither, which silently undercounts the watch-task concurrency cap and admits a run over the limit (#1892). More checks cannot fix that; only one snapshot can.

It is deliberately narrow rather than reusing ToInstanceData, which walks an instance's tabs, worktree, and PR state: the cap classifies every session in a repo while holding the manager lock, the same reason Snapshot keeps its serialization outside that lock.

func (LifecycleView) Activity added in v1.0.200

func (v LifecycleView) Activity() Activity

Activity classifies a snapshot through the same state machine ClassifyActivity runs, so a live instance and its persisted record can never disagree about whether a session is busy.

The legacy Status axis is not consulted: a live in-memory instance always has a resolved liveness (NewInstance sets it, FromInstanceData rolls a legacy record forward at load), so ClassifyActivity's LivenessUnset fallback never applies.

func (LifecycleView) ValidateRuntimeAction added in v1.0.206

func (v LifecycleView) ValidateRuntimeAction(action RuntimeAction) error

ValidateRuntimeAction checks whether one consistent lifecycle snapshot may perform action. It returns user-facing errors because callers must explain why retrying cannot work, especially when a durable kill tombstone owns the row.

type Liveness added in v1.0.140

type Liveness int

Liveness is the daemon-owned health axis: what state the backing tmux/worktree is actually in, independent of any client operation in flight. It is the persisted half of the old Status enum — exactly the values SaveInstances ever wrote to disk (transients are skipped) — now named on its own axis.

const (
	// LivenessUnset is the zero value. It is never a live in-memory state: it
	// exists so an InstanceData decoded from a record written before #1195 (no
	// `liveness` key) lands here and FromInstanceData falls back to the legacy
	// `status` int. omitempty drops it on write.
	LivenessUnset Liveness = iota
	// LiveRunning: the agent is working.
	LiveRunning
	// LiveReady: idle, waiting for user input.
	LiveReady
	// LiveLost: the backing session vanished under a live record — recovery-
	// eligible (#1108/#1104).
	LiveLost
	// LiveDead: legacy observed-death. Write-never since #1108 (deaths record
	// Lost); FromInstanceData maps persisted Dead→Lost. Retained so the shim
	// round-trips; it goes away with the legacy Status enum's retirement (1e,
	// still open).
	LiveDead
	// LiveArchived: deliberately shelved, worktree moved out, inert (#1028).
	LiveArchived
	// LiveLimitReached: the agent hit a usage-limit wall (#1146). Folded in here
	// from the start so the limit epic consumes a Liveness value rather than
	// appending to the flat Status enum.
	LiveLimitReached
)

func EffectiveLiveness added in v1.0.221

func EffectiveLiveness(data InstanceData) Liveness

EffectiveLiveness resolves the liveness a serialized record actually has, applying the same rollforward IsArchivedData relies on: prefer the `liveness` field, fall back to the legacy `status` int for records written before #1195.

Exported because reading data.Liveness directly is a trap for any caller that iterates records rather than live instances — a pre-#1195 record carries the ZERO liveness (LivenessUnset) while its real state lives in the legacy status, so a direct comparison silently misclassifies it as "not archived", "not lost", and so on. IsArchivedData answers one question; this answers the general one for callers that must distinguish several states (#2983's quota report needs "is an agent actually running", which is four states wide).

func LivenessForStatus added in v1.0.140

func LivenessForStatus(s Status) Liveness

LivenessForStatus maps a settled (non-transient) legacy Status to its Liveness axis. Transient values (Loading/Deleting) are handled by setStatusLocked, which sets the op and leaves liveness untouched, so they never reach here.

type LocalBackend added in v1.0.46

type LocalBackend struct{}

LocalBackend implements Backend using local tmux sessions and git worktrees.

func (*LocalBackend) AgentModelChange added in v1.0.207

func (b *LocalBackend) AgentModelChange(i *Instance) *AgentModelChange

func (*LocalBackend) Capabilities added in v1.0.173

func (b *LocalBackend) Capabilities() Capabilities

Capabilities reports the local runtime's full-parity descriptor: a local git worktree driven by tmux, supporting every optional operation (#1592 Phase 1).

func (*LocalBackend) CheckAndHandleTrustPrompt added in v1.0.46

func (b *LocalBackend) CheckAndHandleTrustPrompt(i *Instance) bool

func (*LocalBackend) CloseAttachOnly added in v1.0.114

func (b *LocalBackend) CloseAttachOnly(i *Instance) error

CloseAttachOnly releases this instance's hold on its tmux sessions — the attach PTYs and the `tmux attach-session` child processes — WITHOUT running `tmux kill-session`. The server-side tmux sessions and the git worktree behind them are left untouched. The daemon uses this to discard a duplicate Instance built from disk that turned out to already be tracked in memory (#867): the duplicate must surrender the PTYs it opened during restore without tearing down the live sessions the canonical Instance shares.

func (*LocalBackend) HasUpdated added in v1.0.46

func (b *LocalBackend) HasUpdated(i *Instance) (updated bool, hasPrompt bool, content string)

func (*LocalBackend) HasUpdatedWithBaseline added in v1.0.240

func (b *LocalBackend) HasUpdatedWithBaseline(i *Instance) (updated bool, hasPrompt bool, content string, baseline bool)

HasUpdatedWithBaseline preserves the local tmux monitor's reattach-baseline signal for AgentServer.Snapshot. Backend.HasUpdated remains the compatibility projection for callers that only need changed/prompt/content.

func (*LocalBackend) IsAlive added in v1.0.46

func (b *LocalBackend) IsAlive(i *Instance) (bool, error)

func (*LocalBackend) Kill added in v1.0.46

func (b *LocalBackend) Kill(i *Instance) error

Kill is best-effort: each cleanup step runs independently and a failure in one (e.g. a broken git worktree) only logs a warning rather than aborting the rest once destructive admission succeeds. Unknown recovery state is returned before pane teardown so the daemon retains the persisted handle. See issue #478.

func (*LocalBackend) Launch added in v1.0.176

func (b *LocalBackend) Launch(i *Instance, firstTimeSetup bool) error

Launch starts (or restores) the agent PROCESS in the workspace Provision established (#1592 Phase 1 PR4): it materializes the worktree on disk (worktree.Setup on a fresh create), spawns or reconnects the tmux session, and brings up the non-agent tabs. It owns the failure-cleanup scope: a fresh worktree is removed only when the failure positively proves no runtime began, while an unknown post-spawn outcome preserves it; a restore failure releases only the attach PTY. worktree.Setup deliberately stays here rather than in provision because it is the first on-disk mutation and therefore belongs inside this cleanup scope.

func (*LocalBackend) PrepareAgentSwap added in v1.0.207

func (b *LocalBackend) PrepareAgentSwap(i *Instance, target string) (AgentSwapPlan, error)

PrepareAgentSwap freezes and validates the exact first-launch command before handoff tears down the outgoing pane. Configuration is resolved once here; SwapAgent consumes the returned plan and cannot drift to a different override in the destructive close/start gap.

func (*LocalBackend) Preview added in v1.0.46

func (b *LocalBackend) Preview(i *Instance) (string, error)

func (*LocalBackend) PreviewContext added in v1.0.183

func (b *LocalBackend) PreviewContext(ctx context.Context, i *Instance) (string, error)

PreviewContext is Preview bound to ctx: the pane capture is cancellable, so a cancelled readiness wait tears down the in-flight `tmux capture-pane` subprocess instead of letting it run to completion (task.WaitForReady's capturePreview).

func (*LocalBackend) PreviewFullHistory added in v1.0.46

func (b *LocalBackend) PreviewFullHistory(i *Instance) (string, error)

func (*LocalBackend) Provision added in v1.0.176

func (b *LocalBackend) Provision(i *Instance, firstTimeSetup bool) error

Provision establishes the local workspace a session will run in WITHOUT starting any agent process (#1592 Phase 1 PR4): it binds the instance's tmux session handle and, on a first-time create, computes the git worktree record + branch name. Nothing here spawns a tmux server session, materializes the worktree on disk, or launches the agent program — those are launch's job. NewGitWorktree is purely in-memory (the disk-mutating `git worktree add` runs in worktree.Setup(), which launch owns), so a provision failure leaves nothing on disk to clean up and returns before launch's cleanup scope is ever entered — exactly as the pre-split Start did (its NewGitWorktree failure returned before the deferred cleanup handler was registered).

func (*LocalBackend) Recover added in v1.0.139

func (b *LocalBackend) Recover(i *Instance) error

Recover re-establishes a Lost instance's tmux sessions (#1108): re-spawn the agent program in its worktree with the same resolved-program flag injection as a first-time launch (#1132 choke-point — never hand-rolled flag logic), then bring the other tabs back through the same setupTabs path a restore uses. Invoked by the daemon's restore loop and by user-initiated restore (#1300); the #970 guard in Start keeps loads side-effect free.

Idempotence across retries: the injected program is recomputed from the clean persisted i.Program on every attempt (SetProgram replaces, never appends), so repeated failures never accumulate duplicate flags. On failure only the agent tab's attach resources are released (the #1065 rule: no other tab has opened a PTY yet on this path) and the tmux refs are kept, so the next tick's retry reconnects each tab by its exact persisted name; the instance stays a killable Lost row throughout.

func (*LocalBackend) Respawn added in v1.0.140

func (b *LocalBackend) Respawn(i *Instance) error

Respawn re-establishes an instance's backing tmux session in place WITHOUT any liveness precondition — the guard-free core Recover wraps. It exists so the usage-limit manual-retry (#1146) can re-spawn an agent that exited while blocked at a limit wall: that session is LiveLimitReached, which Recover's !Lost guard would reject, but the re-spawn mechanics are identical. Callers own the precondition (Recover enforces Lost/no-tombstone; resumeFromLimit enforces LimitReached/no-tombstone under the target lock).

func (*LocalBackend) SendPromptCommand added in v1.0.46

func (b *LocalBackend) SendPromptCommand(i *Instance, prompt string) error

func (*LocalBackend) SendPromptCommandWithStatus added in v1.0.226

func (b *LocalBackend) SendPromptCommandWithStatus(i *Instance, prompt string) (PromptDeliveryStatus, error)

func (*LocalBackend) Start added in v1.0.46

func (b *LocalBackend) Start(i *Instance, firstTimeSetup bool) error

Start brings a local session up in two explicit phases (#1592 Phase 1 PR4): provision establishes WHERE the agent will run (the tmux session handle bound to the instance, plus — for a fresh create — the git worktree record and its branch), then launch starts WHAT runs in it (materializing the worktree on disk and spawning/reconnecting the agent process and its tabs). The split is behavior-preserving: Start = provision then launch is exactly the monolithic Start it replaced (same order, side effects, and errors), and it is the same split the off-box runtimes follow, where Provision spins up the remote workspace (and its agent-server endpoint) and Launch starts the agent in it.

func (*LocalBackend) SwapAgent added in v1.0.206

func (b *LocalBackend) SwapAgent(i *Instance, plan AgentSwapPlan) error

SwapAgent replaces the running agent with the instance's current program (#2013). Instance.Program has already been rewritten to the incoming agent by SwapAgentProgram; this performs the runtime half.

Order is the whole correctness argument:

  1. Close the agent pane and WAIT for its process to exit. Until the old agent is gone there is nothing to replace it with — and the wait is the #802 ordering that keeps its final writes from racing the new agent's first ones in the same worktree.
  2. Only then start the new program from the already-prepared FIRST-LAUNCH plan, never the resume path. The incoming agent has no conversation in this worktree; asking it to continue one would at best start fresh noisily and at worst fail to boot.

A teardown whose outcome tmux could not confirm ABORTS the swap. This is the one place the honest answer costs something: refusing leaves the session on its old agent, still blocked, and the user has to retry. Proceeding on an unconfirmed teardown risks two agents writing the same worktree at once, which is unrecoverable in a way a retry is not. The instance keeps its rewritten Program either way — the caller rolls that back on error.

The worktree is never cleaned up on failure, unlike the first-launch path this otherwise mirrors: on a create, a failed Start means the workspace holds nothing worth keeping; here it holds everything the outgoing agent did.

func (*LocalBackend) Type added in v1.0.46

func (b *LocalBackend) Type() string

type MissionBrief added in v1.0.206

type MissionBrief struct {
	// Goal is the mission: the session's stored prompt, or an operator-supplied
	// override. Empty when the session never carried one.
	Goal string
	// From and To are the outgoing and incoming agent names.
	From string
	To   string
	// Reason is why the handoff happened, rendered into the brief so the new
	// agent knows its predecessor stopped for an external reason and did not
	// simply fail.
	Reason string
	// Work is the branch state at handoff time.
	Work git.WorkSummary
}

MissionBrief is what an incoming agent is told when a session is handed to it (#2013, design decision D2). It is the entire state transfer: the goal, the branch, and what is already on it.

Deliberately absent: any summary of what the previous agent was thinking, or of what its diff means. af did not do that work and cannot describe it truthfully — any prose it invented would be an inference presented to the new agent as established fact, which is exactly the blended-context hazard #2013 asks us to avoid. The diff is the ground truth and the incoming agent can read it, so the brief points at it instead of paraphrasing it.

func (MissionBrief) Render added in v1.0.206

func (m MissionBrief) Render() string

Render produces the prompt delivered to the incoming agent.

Every clause is something af actually knows. Where it knows nothing — no stored goal, no commits — it says that plainly, because a brief that invents a goal is worse than one that admits it has none: the agent would pursue the invention.

type Observation added in v1.0.176

type Observation struct {
	// Updated is true if the session output changed since the last probe.
	Updated bool
	// Baseline is true for the first successful pane capture after reattaching to
	// an existing runtime. It proves neither pane churn nor observed idleness.
	Baseline bool
	// HasPrompt is true if the program is showing a yes/no prompt awaiting input.
	HasPrompt bool
	// Content is the raw captured pane content, handed back so the idle branch can
	// run the usage-limit detector without a second capture (#1146). Empty for a
	// runtime with no live pane.
	Content string
	// ModelChange is the retained, verified model transition observed after an
	// agent safety dialog. The runtime reports it on every snapshot until the
	// model returns to Before, so no consumer has to race a one-shot log line.
	ModelChange *AgentModelChange
}

Observation is the non-interactive snapshot the daemon's liveness poll reads each tick (#1592 Phase 2): whether the pane changed since the last probe, whether this capture only established a reattach baseline, whether the program is showing a prompt awaiting input, and the raw captured pane content so the usage-limit detector (#1146) can inspect it without a second capture. It replaces the daemon reading the tmux-shaped HasUpdated probe directly.

type OrphanSweepResult added in v1.0.210

type OrphanSweepResult struct {
	Listed  int // af containers on this engine+home the sweep saw
	Reaped  int // orphans removed
	Skipped int // containers spared as a live or mid-create session
	Unknown int // orphans left for a later sweep (state unknown)
	Errors  int // orphans docker refused to remove for a definite reason
}

OrphanSweepResult summarizes one orphan-container sweep (#2194).

func SweepOrphanContainers added in v1.0.210

func SweepOrphanContainers(homeID string, protectedSlugs map[string]bool) OrphanSweepResult

SweepOrphanContainers removes docker containers this daemon leaked — a session container that outlived its session because the daemon died without reaping, the session record is gone, or a reap raced a create. Nothing else ever cleans these up, so they hold memory, disk, and a published port on the box forever.

It is safe by construction — the hard part of reaping here is not destroying a workload you do not own:

  • It lists ONLY containers labelled af.session AND af.home=<homeID> (this daemon's home). A container with no af.home label — one a pre-upgrade af created, or one from another af home — never matches, so an unlabelled container is treated as NOT ours and left alone, never as ours.
  • `docker ps` runs under the daemon's own docker environment, so the query is scoped to the currently-targeted engine; the sweep never sees, and so can never reap, a container on another engine (the #2382 cross-engine hazard).
  • A listed container whose af.session slug is in protectedSlugs — the slug of a live OR still-provisioning session (the #2549 mid-create window) — is spared. The label is a many-to-one title slug, so this errs toward sparing: a genuinely orphaned same-slug container is left for a later sweep once the colliding session ends, which is the correct default for a destructive pass.
  • Each orphan is removed through the SAME reap the per-session Kill uses (dockerProvisioner.reap with verifyEngineOnReap), inheriting its engine-identity guard, its three-valued outcome (an ErrWorkspaceStateUnknown result leaves the container for the next sweep rather than claiming it gone), and its bounded exec — no raw `docker rm -f`, no unbounded wait.

homeID must equal the value runContainer stamps into af.home (config.GetConfigDir()). An empty homeID disables the sweep — there is nothing to scope to safely, and a broad sweep is exactly what must never happen.

type PRInfoData

type PRInfoData struct {
	Number int    `json:"number,omitempty"`
	Title  string `json:"title,omitempty"`
	URL    string `json:"url,omitempty"`
	State  string `json:"state,omitempty"`
	// Branch binds cached state to the exact ref used for the lookup. Legacy
	// records omit it and are therefore never trusted for destructive decisions.
	Branch string `json:"branch,omitempty"`
}

PRInfoData represents the serializable data of a PRInfo

type PRInfoRollback added in v1.0.258

type PRInfoRollback struct {
	// contains filtered or unexported fields
}

PRInfoRollback captures the complete pre-write PR-info state — value, freshness clock, and generation — so a write whose durable half failed can be undone without trace. Opaque on purpose: the fields only mean anything restored together.

type PTYEvent added in v1.0.177

type PTYEvent struct {
	Kind PTYEventKind
	// Data is the verbatim PTY output, valid only when Kind == PTYData.
	Data []byte
	// Rows/Cols are the authoritative size, valid only when Kind == PTYResize.
	Rows uint16
	Cols uint16
	// Seq is the subscription's authoritative output cursor, valid only when
	// Kind == PTYCursor.
	Seq Seq
	// Modes accompany PTYRepaint when HasModes is true. They are snapshot
	// metadata, not ring bytes, and therefore do not advance Seq.
	Modes             terminal.Modes
	HasModes          bool
	RepaintProvenance PTYRepaintProvenance
}

PTYEvent is one event delivered to a subscriber: output bytes, the authoritative resize echo, a screen repaint, or a cursor re-seed, selected by Kind.

type PTYEventKind added in v1.0.177

type PTYEventKind int

PTYEventKind discriminates a PTYEvent between output bytes and a resize echo.

const (
	// PTYData carries verbatim PTY output bytes (Data), mapped to an OpPTYOut wire
	// frame by the WS broker.
	PTYData PTYEventKind = iota
	// PTYResize carries the authoritative last-resize-wins size (Rows/Cols),
	// mapped to a resize control frame so every subscriber's emulator reflows.
	PTYResize
	// PTYRepaint carries a one-shot initial screen repaint (Data) for a fresh
	// subscriber, mapped to an OpRepaint frame — rendered like output but NOT
	// counted toward the client's replay cursor (it is not part of the ring seq).
	PTYRepaint
	// PTYCursor carries this subscription's authoritative cursor (Seq) after the
	// SERVER moved it non-contiguously — a ring eviction, or the #1840 recovery
	// discard, fast-forwarded the subscriber over bytes that no longer exist. A
	// client derives its own replay cursor as start + bytes-received, which silently
	// desyncs across such a jump: it would then reconnect with a ?since BELOW the
	// broker's base, get clamped back up, and be re-sent bytes it already rendered
	// (duplicated output). Mapped to an OpHello frame — the same in-band cursor seed
	// the subscription opens with — so the client re-seeds instead of counting on.
	// Carries no PTY bytes and is not itself part of the ring seq.
	PTYCursor
)

type PTYRepaintProvenance added in v1.0.207

type PTYRepaintProvenance uint8

PTYRepaintProvenance distinguishes a fresh/reconnect snapshot from the recovery barrier repaint that intentionally covers the immediately following cursor jump. Defaulting to Fresh is fail-closed for callers that construct an event without provenance.

const (
	PTYRepaintFresh PTYRepaintProvenance = iota
	PTYRepaintRecovery
)

type PTYSubscription added in v1.0.176

type PTYSubscription interface {
	// NextEvent blocks until the next stream event (output bytes or a resize
	// echo), ctx cancellation, or Close. It returns io.EOF once the stream ends
	// (the session's PTY vanished or the broker closed), or ErrTabClosed — which
	// wraps io.EOF — when the end came from THIS tab being closed (#2136). A client
	// that reconnects resumes from Seq() via Subscribe(since).
	NextEvent(ctx context.Context) (PTYEvent, error)
	// Seq reports the cursor of the next output byte this subscriber will read, so
	// a client that reconnects can resume the gap with Subscribe(since).
	Seq() Seq
	io.Closer
}

PTYSubscription is one subscriber's read side of a session's PTY stream (#1592 Phase 2 PR5), fanned out from the local agent-server's per-session ring buffer. It is event-oriented rather than a bare byte reader so a single consumer goroutine (the daemon's WS writer) can multiplex the two things that travel to a client on one connection — raw output bytes and the authoritative resize echo — without a second concurrent writer on the socket.

type PaneSnapshot added in v1.0.182

type PaneSnapshot struct {
	Screen    []byte
	CursorRow int
	CursorCol int
	HasCursor bool
	// Modes are the ownership-affecting terminal modes that were already active
	// before this subscriber existed. HasModes distinguishes a truthful all-off
	// primary-screen snapshot from a source that cannot report modes.
	Modes    terminal.Modes
	HasModes bool
}

PaneSnapshot is a fresh-subscriber repaint source: the pane's current visible screen (with escapes) plus the pane cursor position. CursorRow/CursorCol are 0-based; they are meaningful only when HasCursor is true.

Screen MUST be GRID-form — one line per PHYSICAL pane row, NOT -J-joined logical lines. buildRepaint places each line at its own absolute row, so line index i is taken to be pane row i; feeding it -J-joined lines (where one logical line spans several pane rows) would mis-map the rows. The local tmux channel captures grid form (CaptureVisiblePaneGrid). The remote channel's REST preview is -J-joined and carries no cursor (HasCursor=false) — a known screen-only best-effort limitation, see remoteClientlessChannel.Snapshot.

type PreviewSnapshot added in v1.0.207

type PreviewSnapshot struct {
	Content  string
	Modes    terminal.Modes
	HasModes bool
	// LinesAbove is how many scrollback lines sit ABOVE the captured region, and
	// LinesAboveKnown says whether anyone measured (#3169).
	//
	// Two fields rather than one, because 0 and "unmeasured" are different answers
	// and collapsing them is the bug being fixed: a visible-screen capture reported
	// as having nothing above it reads as COMPLETE. A remote sandbox does not carry
	// the count over its REST preview, so unknown is a real state, not a defensive
	// one — and it must render as "not measured", never as "nothing above".
	LinesAbove      int
	LinesAboveKnown bool
}

PreviewSnapshot keeps a captured terminal grid and the ownership-affecting modes observed for that same target in one value. HasModes is explicit: the zero-value Modes is a valid primary-screen/no-mouse observation, not an invitation for a client to guess. Runtimes that cannot report modes leave it false and scrolling remains unavailable until an authoritative snapshot lands.

type PromptDeliveryReporter added in v1.0.226

type PromptDeliveryReporter interface {
	SendPromptWithStatus(prompt string) (PromptDeliveryStatus, error)
}

PromptDeliveryReporter is the additive status-bearing form of SendPrompt. Keeping it separate preserves compatibility for test and third-party AgentServer implementations while every production runtime can report the observation it actually made.

type PromptDeliveryStatus added in v1.0.226

type PromptDeliveryStatus = tmux.PromptDeliveryStatus

PromptDeliveryStatus is the closed, wire-visible result of observing a prompt submission. It is an alias of the tmux-layer type so the observation can cross the runtime boundary without duplicating a second vocabulary.

func SendPromptWithStatus added in v1.0.226

func SendPromptWithStatus(server AgentServer, prompt string) (PromptDeliveryStatus, error)

SendPromptWithStatus uses a runtime's observation when available. A legacy runtime that only implements SendPrompt is honest uncertainty, not delivery: nil error proves the command completed, not that the pane received the text.

type ProvisionResult added in v1.0.181

type ProvisionResult struct {
	// Backend is the in-process Backend an Instance is built with. Always set
	// on success.
	Backend Backend
	// Endpoint is the authed `af agent-server` URL + token a sandbox runtime
	// exposes (#1592 Phase 4). nil only for the local in-process runtime; the
	// docker/ssh/hook runtimes fill this in. NewInstance threads a non-nil endpoint
	// into the instance's remote agent-server client. The runtime-contract test
	// exercises the non-nil path via a fake runtime.
	Endpoint *AgentServerEndpoint
	// Teardown reaps the sandbox this Runtime provisioned — `docker rm -f` the
	// container (PR4), close the ssh tunnel + remove the remote dir (PR5), or run
	// the hook's delete_cmd (PR7). nil for the in-process local runtime. NewInstance
	// stashes it on the instance so the agent-server Kill path runs it AFTER tearing
	// the remote workspace down over REST, and NewInstance runs it if wiring the
	// remote client fails, so a provisioned sandbox never leaks. Repeated calls are
	// serialized by the runtime; docker/SSH latch answered outcomes and leave
	// unknown outcomes retryable, while hook runs its idempotent delete once.
	Teardown func() error
}

ProvisionResult is what a Runtime hands back: the in-process Backend that drives the session plus, for a runtime that runs the agent in a remote sandbox, the authed endpoint the daemon's remoteAgentServer (PR2) dials.

type ProvisionSpec added in v1.0.181

type ProvisionSpec struct {
	// RepoRoot is the absolute repo root the session is created against.
	RepoRoot string
	// Title is the session title. A sandbox runtime uses it as the single
	// workspace's agent-server title (the /v1/sessions/{title}/stream path id the
	// daemon's remote client dials) and — inside the sandbox — as the git branch
	// seed, exactly as the local runtime does.
	Title string
	// Program is the agent program to run in the workspace (empty ⇒ the config
	// default). Passed through to the sandbox's `af agent-server --program`.
	Program string
	// CallbackURL and CallbackToken let the agent INSIDE a sandbox call back into
	// the daemon (#2999) — the reverse of the daemon-drives-sandbox direction. They
	// are AF_DAEMON_URL / AF_DAEMON_TOKEN, which apiclient already reads, so no new
	// CLI surface exists for them.
	//
	// The token is per-session, scoped and revocable — never the operator's, which
	// grants DeliverPrompt over every agent on the machine. The scope it currently
	// carries is deliberately one route (SuggestSessionName): it does not include
	// creating a session, which would start an agent on the host with a
	// caller-supplied program, nor the repo/path reads, whose caller-supplied paths
	// are a host oracle (#3012 review). See HTTPRoute.sandboxAllowed for the rule
	// and what widening it waits on. Empty means no callback was granted, which is
	// the normal case for a local session and also what a refusal leaves behind.
	CallbackURL   string
	CallbackToken string
	// CloneURL is the git remote an off-box runtime clones the workspace from —
	// the repo's `origin` for a real repo, or a file:// / bind-mounted path for a
	// self-contained test. Empty for the in-process local runtime. On a fresh
	// create the sandbox clones its default branch and the in-sandbox worktree
	// derives the session branch from Title.
	CloneURL string
	// RestoreBranch, when set, makes this a RESTORE provision rather than a fresh
	// create (#1592 Phase 4 PR6): after cloning, the sandbox materializes this
	// exact branch (the one archive pushed to origin) as a LOCAL ref so the
	// in-sandbox local backend's Setup reuses it — bringing the pushed commits
	// back — instead of branching fresh off the default. Empty on a fresh create.
	// Only off-box runtimes (docker/ssh/hook) honor it; the in-process local
	// runtime never clones, so it is a no-op there.
	RestoreBranch string
	// Account scopes this session to a registered credential account (#3082), for
	// the off-box kinds that can carry one. Zero value means unscoped.
	//
	// The whole Account travels, not just its name, and that is the point: only the
	// NAME goes into argv on the local path, because the `af` shim there resolves it
	// against its own AF home. A provisioned machine has no such registry — the
	// container's `af` would look up an account that does not exist in it — so an
	// off-box backend has to carry the resolved DIRECTORY and place it itself.
	Account sessionenv.Account
	// SessionEnvPassthrough contains exact global-only variable names approved
	// for the agent. Sandboxed runtimes pass names, never values, to their
	// version-matched agent-server.
	SessionEnvPassthrough []string
}

ProvisionSpec is the input a Runtime needs to establish a session's execution environment. The local runtime provisions from the repo root; off-box runtimes (docker/ssh/hook) additionally need the session identity (Title/Program), clone source, and optional restore branch used to start an `af agent-server` for one workspace. Each runtime reads its own settings from the resolved repo config.

type RootRecreateContext added in v1.0.218

type RootRecreateContext string

RootRecreateContext is what a re-created root agent's conversation carry actually did, in the vocabulary a rail row can render. It is deliberately three-valued rather than a "started fresh" bool, because the middle case is real and af must not claim to know it: a root whose resolved command selects its own conversation (`codex resume --last`, a program that pins its own resume flag) records nothing, so whether continuity survived is genuinely unknown. Rendering that as "fresh context" would be a confident answer to a question nobody asked af.

Persisted as a string so an unrecognized value written by a newer binary degrades to no note at all rather than to a wrong one (the rollforward precedent Liveness and TabKind follow).

const (
	// RootRecreateContextNone is every ordinary session, and a re-created root
	// that came back on exactly the conversation it had. Nothing to report.
	RootRecreateContextNone RootRecreateContext = ""
	// RootRecreateContextFresh means the root is demonstrably NOT on its prior
	// conversation: it had none to carry, or the launch committed a different
	// one. Its context is gone, and the user's next prompt lands on an agent
	// with no memory of what it was doing.
	RootRecreateContextFresh RootRecreateContext = "fresh"
	// RootRecreateContextUnknown means the replacement recorded no conversation
	// at all, so af cannot say whether the agent continued or started over. The
	// user still needs to know not to assume continuity — which is the same
	// action the fresh case calls for, arrived at honestly.
	RootRecreateContextUnknown RootRecreateContext = "unknown"
)

func ClassifyRootRecreateContext added in v1.0.218

func ClassifyRootRecreateContext(carried AgentConversationData, created *AgentConversationData, launchedAgent string) RootRecreateContext

ClassifyRootRecreateContext decides what a root heal's conversation carry did, from the conversation the reaped record held, the one the replacement actually came up with, and the agent the replacement actually launched.

It reads the conversation the new record CARRIES rather than what the create was asked to do: a create can be handed a conversation and still come up on a different one, and the record is the thing that will be resumed from next time.

launchedAgent is what disambiguates the one genuinely ambiguous outcome. A replacement that recorded NO conversation reaches that state two ways, and they deserve opposite answers:

  • The launch runs a DIFFERENT agent than the carried conversation belongs to (a claude root re-created as codex, because the root program was repointed). ResumeProgramWithConversationID refuses on the agent mismatch, and the launch starts that agent's own new conversation — so the carried one is provably not resumed, whether or not the new id is captured synchronously. codex ids are discovered asynchronously, so this case ALWAYS arrives here with no recorded conversation; reading it as "unknown" would hide the documented agent-change fallback behind the one word that means af cannot tell.
  • The launch runs the SAME agent but recorded nothing, which happens only when the resolved command pins its own conversation selection (`claude --continue`, `codex resume --last`) and both the resume rewrite and the fresh-id injection therefore decline. There the agent may well have continued something; af genuinely cannot say.

This is the single authority for that judgment — the daemon's log line and the note on the row both come from here — so the log and the rail can never disagree about whether a root resumed.

func (RootRecreateContext) Note added in v1.0.218

func (c RootRecreateContext) Note() string

Note returns the short note a rail row renders for this outcome, or "" when there is nothing to say. Sentence case, static, no animation — the copy rules every user-facing surface follows. It lives here, next to the values, so the TUI rail, the web rail, and `af sessions get` cannot render three different words for one fact; an unrecognized value renders nothing.

type Runtime added in v1.0.181

type Runtime interface {
	// Provision establishes the session's execution environment and returns the
	// backend (+ optional remote endpoint) the instance is built with.
	Provision(spec ProvisionSpec) (ProvisionResult, error)
}

Runtime is the provision-and-expose seam (#1592 Phase 4 PR3): given a session spec it establishes the workspace/sandbox and exposes an agent-server in it, returning the wiring a new Instance needs. It is the extensibility point the docker, SSH, and hook runtimes plug into — the registry below maps a `backend` value to a Runtime, and session creation resolves one from config.

The local runtime exposes an in-process endpoint (Endpoint nil) and uses the local AgentServer over tmux. Every off-box runtime provisions a workspace, starts an `af agent-server`, and exposes its authed http:// URL; the session then drives it through the remoteAgentServer client. All satisfy this one interface, so the create flow does not branch on locality.

func ResolveRuntime added in v1.0.181

func ResolveRuntime(kind BackendKind) (Runtime, error)

ResolveRuntime returns the Runtime registered for kind, or an error naming the unknown backend. Every registered kind is constructible.

type RuntimeAction added in v1.0.206

type RuntimeAction int

RuntimeAction names every operation that can start, replace, or resume a session runtime after creation. Backend capability and lifecycle eligibility are deliberately separate questions: a backend can know how to perform an action while this particular row is Archived, Lost, or pending deletion.

Keep this list exhaustive. Every production runtime-entry chokepoint validates one of these actions before delegating to a backend:

  • RestoreArchivedWorktree / RestoreFromArchive: RuntimeActionRestoreArchived
  • the daemon's manual Lost/Dead router: RuntimeActionRestoreLostOrDead
  • Instance.Recover: RuntimeActionRecoverLost
  • Instance.Respawn: RuntimeActionResumeLimit
  • SwapAgentProgram / Instance.SwapAgent: RuntimeActionHandoff

The universal pending-kill veto lives in ValidateRuntimeAction, outside the per-action switch, so adding a new action cannot accidentally omit it.

const (
	RuntimeActionRestoreArchived RuntimeAction = iota
	RuntimeActionRestoreLostOrDead
	RuntimeActionRecoverLost
	RuntimeActionResumeLimit
	RuntimeActionHandoff
)

type RuntimeCleanupData added in v1.0.207

type RuntimeCleanupData struct {
	Docker  *DockerRuntimeCleanupData  `json:"docker,omitempty"`
	SSH     *SSHRuntimeCleanupData     `json:"ssh,omitempty"`
	Sandbox *SandboxRuntimeCleanupData `json:"sandbox,omitempty"`
	Hook    *HookRuntimeCleanupData    `json:"hook,omitempty"`
}

RuntimeCleanupData is the storage-only teardown identity committed alongside a remote session's kill tombstone or an unknown cleanup outcome. It is deliberately a tagged union rather than a bag of shared strings: each backend restores only its own exact handle, and a malformed record carrying two variants is refused instead of guessed at.

InstanceData uses a private staging field to keep this out of daemon snapshots; ForStorage publishes it only at those two retention boundaries. Bug reports drop it in full because host names, command paths, and container ids are operator-private.

type SSHRuntimeCleanupData added in v1.0.207

type SSHRuntimeCleanupData struct {
	Config     config.SSHConfig `json:"config"`
	SessionDir string           `json:"session_dir"`
	RemotePID  string           `json:"remote_pid,omitempty"`
	// DialAddress is the literal address this session was provisioned on: the one
	// thing the record knows and no re-resolution can recover. Without it, reaping a
	// multi-address name could reach a DIFFERENT machine, find nothing, report
	// success and retire the only tombstone — a permanent, silent leak (#3086).
	//
	// HOW IT IS APPLIED CHANGED, WHAT IT MEANS DID NOT. #3090 wrote this field and
	// dialled it as ssh's destination, which forced a `-o HostKeyAlias` and rejected
	// host certificates on every non-default port; #3100 reverted the writing but
	// kept honouring it. It is now applied as a `-o ProxyCommand` that pins only the
	// TCP dial while ssh's destination stays the configured NAME, so the certificate
	// conflict is gone and there is nothing left to accept as a cost.
	//
	// So a record from EITHER era reads the same way, and both reap correctly under
	// the current mechanism. An empty value — a record written before #3090, or
	// between #3100 and this change, or one whose provision could not settle on an
	// address — dials the name, exactly as it always did.
	// DialAddress is the machine this session was provisioned on, and the ONE field
	// that carries the pin. Two spellings, both of which every release reads or
	// round-trips safely:
	//
	//	"198.51.100.8"    an ADDRESS pin (#3086/#3118), reached on the configured port
	//	"198.51.100.8:22" a MACHINE pin (#3122), carrying the port that machine serves
	//
	// The second spelling exists because behind an L4 balancer the backend listens on
	// a different port than its VIP is reached on, so the port has to travel with the
	// address. It is deliberately NOT a separate field: a daemon rolled back to a
	// release without it would DROP that field on its next checkpoint — the pin and
	// the machine gone for good — while still honouring a bare DialAddress and
	// reaping some other backend. One field it already knows round-trips intact.
	//
	// And it fails closed there: that release appends the configured port itself, so
	// "198.51.100.8:22" becomes an unresolvable "[198.51.100.8:22]:2200", the relay
	// cannot dial, ssh exits 255, and the record is RETAINED and retried rather than
	// reaping the wrong machine. Measured. Retained-and-retried beats
	// silently-wrong-and-retired.
	//
	// A bare address is written whenever the machine's port IS the configured one, so
	// an older release keeps a pin it reads correctly. See sshRecordPinnedMachine and
	// sshPinnedCleanupTarget.
	DialAddress         string `json:"dial_address,omitempty"`
	HostKeyVerification string `json:"host_key_verification,omitempty"`
}

type SandboxCredential added in v1.0.224

type SandboxCredential struct {
	// URL and Token are injected into the sandbox as AF_DAEMON_URL and
	// AF_DAEMON_TOKEN. Empty when no credential was granted.
	URL, Token string
	// Revalidate reports whether the grant still holds. Provisioning is the long
	// window — an ssh clone and a binary copy — and a listener move or an auth
	// posture change inside it revokes the credential and closes its URL while the
	// sandbox has already had the stale pair written in. Every path that provisions
	// must call this AFTER Provision returns, not only the create path: doing it in
	// one and not the other is how the two drifted the first time.
	//
	// nil when there is nothing to revalidate.
	Revalidate func() error
}

SandboxCredential is one minted callback credential: what to inject, plus the check that says it is still valid.

func (SandboxCredential) Granted added in v1.0.224

func (c SandboxCredential) Granted() bool

Granted reports whether a credential was actually issued.

type SandboxCredentials added in v1.0.224

type SandboxCredentials interface {
	// Mint issues a fresh credential for this session, REPLACING and thereby
	// revoking any credential the session already held. Replacing is the point on
	// the reprovision path: the sandbox being replaced may still hold the old
	// secret, and it must stop working.
	Mint() (SandboxCredential, error)
	// Revoke drops the session's credential. Idempotent — every teardown path
	// calls it and none should fail for it.
	Revoke()
}

SandboxCredentials mints and revokes one session's callback credential. The daemon implements it; package session holds only this interface, so the runtime lifecycle can drive the credential without session depending on the daemon's registry.

Nil on an Instance means no daemon is backing it — a local session, or an instance built by a test or by the agent-server inside a sandbox — and every call site treats nil as "no credential", never as an error.

type SandboxRuntimeCleanupData added in v1.0.221

type SandboxRuntimeCleanupData struct {
	SSHCommand string `json:"ssh_command"`
	SessionDir string `json:"session_dir"`
	RemotePID  string `json:"remote_pid,omitempty"`
}

SandboxRuntimeCleanupData is the restart-safe teardown handle for the sandbox runtime (#2476 PR2). Without it, the unknown-state retention in sandboxProvisioner.reap would be pointless: the row is deliberately RETAINED when a reap cannot prove it ran, and after a daemon restart there would be no handle to retry with, so the remote agent-server and session dir would leak permanently.

SSHCommand is operator-private (it can name hosts, ports and jump hosts), so it is subject to the same ForStorage scrubbing as its siblings.

type Seq added in v1.0.176

type Seq uint64

Seq is a monotonic cursor into a session's PTY output ring buffer, used by Subscribe(since) to replay the gap after a reconnect (#1592 Phase 2). Defined here so the data-plane signatures are stable; the ring buffer that mints these lands with the WS PTY broker in PR5.

type Status

type Status int
const (
	// Running is the status when the instance is running and claude is working.
	Running Status = iota
	// Ready is if the claude instance is ready to be interacted with (waiting for user input).
	Ready
	// Loading is if the instance is loading (if we are setting it up).
	Loading
	// Deleting is if the instance is being torn down asynchronously after the
	// user confirmed a kill. Like Loading it is transient in-memory state: it
	// is never persisted (SaveInstances skips Loading/Deleting) and the row is
	// removed or reverted when the background teardown finishes (#844).
	Deleting
	// Dead is when the underlying tmux/remote session has vanished out from
	// under us (e.g. the tmux server was killed externally). The row is a
	// corpse: the user can no longer attach to it (handleEnter surfaces an
	// error instead of silently swallowing Enter) but can still kill it. A
	// dead session's HasUpdated latches (false,false) — the same value a
	// healthy idle session returns — so without an explicit liveness probe the
	// metadata tick would repaint it Ready (green dot) forever, making a corpse
	// masquerade as healthy (#935). Unlike Loading/Deleting this is NOT
	// in-flight TUI state: it is persisted and background syncs may still reap
	// or replace the row, so it is deliberately excluded from isTransientStatus.
	//
	// As of #1108 Dead is write-never: observed disappearance is recorded as
	// Lost instead, and FromInstanceData rewrites persisted Dead to Lost on
	// load (rollforward — the only writers of persisted Dead were
	// observed-death paths; user kills delete the record). The value stays in
	// the enum because Status serializes as an int: appending, never
	// renumbering, is what keeps old records readable.
	Dead
	// Lost is when the underlying tmux/remote session vanished out from under
	// a live session with no user kill on record — the tmux server was killed,
	// an outage/OOM starved it (#1104), or the box rebooted while the daemon
	// had already observed the death. Unlike a user-killed session (whose
	// record is deleted, with a UserKilled tombstone covering the teardown
	// crash window), a Lost session is wanted: it is recovery-eligible and the
	// daemon restores it best-effort (#1108). Persisted, like Dead; excluded
	// from isTransientStatus for the same reason.
	Lost
	// Archived is the deliberate counterpart of Lost (#1028): the user ran
	// `af sessions archive`, so the daemon tore down every tmux session (agent
	// + shell/process tabs; web tabs have no tmux and survive with their URLs,
	// #1809) and MOVED the worktree out to the global archive
	// dir (<AGENT_FACTORY_HOME>/archived/<repoID>/<title>/). Where Lost is a
	// wanted, actively-self-healing state (tmux vanished under a live record;
	// the restore loop re-spawns it every poll), Archived is a wanted,
	// QUIESCENT state: it is never probed, never marked Lost, and never
	// auto-restored — only an explicit `af sessions restore` moves the worktree
	// back and re-spawns the agent. It therefore loads INERT (FromInstanceData
	// skips Start: no tmux binding, started=false), which is what keeps the
	// status poll (skips !Started), the Lost-restore loop (gates on ==Lost),
	// and the root ensure loop from touching it. Persisted, like Dead/Lost;
	// appended, never renumbered (Status serializes as an int), so old records
	// stay readable — the same rollforward discipline #658/#1108 rely on.
	Archived
)

type Storage

type Storage struct {
	// contains filtered or unexported fields
}

Storage handles saving and loading instances using the state interface. When repoID is set (TUI mode), operations are scoped to that repo. When repoID is empty (daemon mode), operations span all repos.

func NewStorage

func NewStorage(state config.InstanceStorage, repoID string) (*Storage, error)

NewStorage creates a new storage instance. Pass a non-empty repoID for TUI (repo-scoped) mode, or "" for daemon (all-repo) mode.

func (*Storage) DeleteAllInstances

func (s *Storage) DeleteAllInstances() error

DeleteAllInstances removes all stored instances

func (*Storage) DeleteInstance

func (s *Storage) DeleteInstance(title string) error

DeleteInstance removes an instance from storage by filtering raw JSON directly, avoiding the need to reconstruct live Instance objects (which may fail if tmux/worktree has already been destroyed).

func (*Storage) DeleteInstanceByStableID added in v1.0.143

func (s *Storage) DeleteInstanceByStableID(title, id string) (bool, error)

DeleteInstanceByStableID removes an instance from storage only when the record still matches the stable session identity captured by the caller. A false nil result means a same-titled record exists but belongs to a different instance, so the caller must treat the delete as stale and leave it alone. Empty IDs are legacy-compatible and fall back to title matching.

It takes the instances flock with a DEADLINE (config.WithFileLockTimeout), not the blocking WithFileLock every other Storage writer uses: a contended lock surfaces as a retryable config.ErrLockTimeout instead of parking the caller forever. See InstanceDeleteLockTimeout for why this writer in particular cannot afford an unbounded wait.

func (*Storage) LoadInstanceData added in v1.0.26

func (s *Storage) LoadInstanceData() ([]InstanceData, error)

LoadInstanceData reads and unmarshals instance data from disk without constructing live Instance objects (no tmux session restoration). Used for lightweight comparison against in-memory state.

func (*Storage) LoadInstances

func (s *Storage) LoadInstances() ([]*Instance, error)

LoadInstances loads the list of instances from disk.

func (*Storage) SaveInstances

func (s *Storage) SaveInstances(instances []*Instance) error

SaveInstances persists the daemon's authoritative in-memory instances to disk, grouped by repo. As of #960 PR 4 the daemon is the SOLE writer of instances.json, so this is a straight marshal of the manager's per-repo state, NOT a merge: there is no competing full-list writer to reconcile against, so the old mergeInstancesWithDisk rule-zoo (#551/#766/#808/#819/#844/#959) is gone. With one writer a clobber is impossible by construction.

Only repos with at least one persistable in-memory instance are rewritten; repos the daemon holds nothing for are left untouched — their records were already removed by the targeted DeleteInstance on kill, or were never loaded. Generic Loading/Deleting/non-started instances are skipped: their worktree is not yet populated (Loading) or is mid-teardown (Deleting), so FromInstanceData cannot restore them. Explicit durable retention markers override that legacy projection; in particular, a pending handoff names a live replacement and a staged archive report is the only durable handle to retained source trees.

The targeted writers (appendInstanceData / persistInstanceData / DeleteInstance) keep the disk current on every mutation; this full save is the shutdown checkpoint. Records are deduped by title (#808) before marshaling. Because the manager's memory is the source of truth, the save deliberately does NOT read disk first: the file is overwritten with authoritative state, so a corrupt or momentarily-stale file on disk is simply replaced, not merged.

type StreamEndpoint added in v1.0.176

type StreamEndpoint struct {
	// Local marks an in-process endpoint with no network hop — the only kind in
	// Phase 2 (local runtime).
	Local bool
	// URL is the authed endpoint a remote/container runtime exposes (empty for a
	// local in-process endpoint). Filled in Phase 4.
	URL string
}

StreamEndpoint identifies where a session's data plane is reachable. For the local in-process agent-server it is an in-process handle (Local=true); a Phase-4 remote runtime returns an authed URL. Auth-ready-by-shape for Phase 3.

type Tab added in v1.0.123

type Tab struct {
	// ID is the tab's stable identity (#1738), minted at creation and persisted.
	// It is the collision-proof key streams and pane bindings address the tab by —
	// unlike the ordinal position (shifts on reorder/close) or the name
	// (reused on close+recreate). Empty only for a legacy persisted tab written
	// before #1738, which restoreLocalTabs backfills with a fresh id on load.
	ID string
	// Name is the tab's canonical handle: the stable, human-typable string a user
	// addresses it by (`agent`, `shell`, `btop`, or a name set at create/rename),
	// unique within the instance. It is what every tab verb resolves against
	// (`--name`, via TabMatches), alongside the stable ID above.
	//
	// It is NOT the display label. What the UI renders is TabLabel, a
	// presentation-only string that is never resolved against — the two
	// deliberately differ for agent/shell tabs (named `agent`/`shell`, shown as
	// "Agent"/"Terminal"). This field's doc once claimed to be "the display
	// label"; that confusion was #1986, and the split into Name (the one handle)
	// and TabLabel (the one display string) is its resolution.
	Name string
	// Kind selects the tab's process behavior.
	Kind TabKind
	// Command is the process to run; empty means the kind's default. Unused in
	// PR 1 — the agent program is still resolved by the local backend.
	Command string
	// URL is the target of a TabKindWeb tab: a normalized absolute URL, either a
	// loopback dev-server address (http://localhost:PORT) the daemon
	// reverse-proxies, or an external absolute URL the web UI iframes directly.
	// Empty for every other kind.
	URL string
	// Conversation is the provider-specific id that resumes this tab's agent
	// conversation exactly. Empty means recovery falls back to the provider's
	// existing latest-session behavior.
	Conversation AgentConversationData
	// Handoffs is the append-only record of agent swaps on this tab (#2013),
	// oldest first. Empty for the overwhelming majority of tabs — a session that
	// was never handed off.
	//
	// It is the tab's history, and Conversation is only its present. A swap
	// overwrites Conversation with the incoming agent's id, so without this list
	// the outgoing agent's conversation would be unrecoverable and a hand-back
	// could only reach that provider's latest-session fallback. Each entry also
	// pins the branch tip at swap time, which is what makes per-agent attribution
	// a checkable git range.
	Handoffs []AgentHandoff
	// contains filtered or unexported fields
}

Tab is one slot in an instance's tab roster (#930): the Agent tab at Tabs[0] and any shell/process tabs each run a process backed by their own tmux session, while web and VS Code tabs carry no tmux PTY (TabKind.HasTmux). The instance's tmux-touching methods route through it; lifecycle lives in tab_spawn.go (create) and tab_close.go (close), and each tab persists as a TabData record (storage.go).

type TabAddressableServer added in v1.0.184

type TabAddressableServer interface {
	// SubscribeTab is Subscribe addressed by stable tab id. ErrTabGone when the id
	// names no live tab.
	SubscribeTab(tabID string, since Seq) (PTYSubscription, error)
	// InputTab is Input addressed by stable tab id. ErrTabGone when the id names no
	// live tab.
	InputTab(tabID string, b []byte) error
	// ResizeTab is Resize addressed by stable tab id. ErrTabGone when the id names
	// no live tab.
	ResizeTab(tabID string, rows, cols uint16) error
}

TabAddressableServer is implemented by an agent-server whose data plane can be addressed by a tab's STABLE id (#1738) instead of a shifting ordinal. It is the id-native half of AgentServer's ordinal data plane: the ordinal methods stay for legacy clients that never supplied a ?tab_id=, while a client that DID supply one binds through here, so the id is resolved exactly ONCE — atomically, at the moment the operation binds — and never round-trips through an ordinal that a concurrent close/reorder can shift underneath it (#1779).

The local runtime implements it (its brokers are already keyed by stable id, so id-addressing is strictly simpler than the ordinal round-trip it replaces). A runtime whose wire protocol is ordinal-shaped — the remote agent-server — does not. Its roster is fixed (TabManagement=false), so the handler's compatibility bridge cannot race a close/reorder; mutable remote tabs must add this id-native plane before that capability is enabled.

type TabCleanupData added in v1.0.213

type TabCleanupData struct {
	// TabID is the closed tab's stable id (#1738). Ids are never reused, so it
	// names the exact close this handle belongs to and keeps the retry's logs and
	// deduplication honest across restarts.
	TabID string `json:"tab_id,omitempty"`
	// TmuxName is the cleanup handle proper: the EXACT tmux session name the retry
	// must kill, and the token a later spawn must not re-derive. An entry with no
	// name would be untargetable, so CloseTab never records one.
	TmuxName string `json:"tmux_name"`
}

TabCleanupData is one durable cleanup handle for a closed tab whose tmux teardown was never confirmed. It deliberately carries only what a retry needs — no Kind, Command, or URL — so it cannot be mistaken for, or restored as, a TabData: a tombstone that could round-trip into a tab would resurrect exactly the closed tab #2669 exists to keep buried.

type TabData added in v1.0.123

type TabData struct {
	// ID is the tab's stable identity (#1738), minted at creation and never
	// reused. It is the collision-proof key the PTY stream (?tab_id=) and the web
	// DnD/pane bindings address the tab by, so a reorder/close can't misroute.
	// omitempty + additive, mirroring the InstanceData.ID / BranchCreatedByUs
	// rollforward precedent: a record written before #1738 has no id, and
	// restoreLocalTabs backfills a fresh one on load.
	ID       string  `json:"id,omitempty"`
	Name     string  `json:"name"`
	Kind     TabKind `json:"kind"`
	Command  string  `json:"command,omitempty"`
	TmuxName string  `json:"tmux_name,omitempty"`
	// URL is the target of a TabKindWeb tab (the iframe/proxy address); empty for
	// every other kind. Surfaced in the snapshot so the web UI can iframe it and
	// so `af sessions get` shows the target.
	URL string `json:"url,omitempty"`
	// Conversation is the provider-specific conversation id for this tab, when
	// the underlying agent exposes a durable resume id. Omitted for legacy rows
	// and providers where af can only resume "latest".
	Conversation *AgentConversationData `json:"conversation,omitempty"`
	// Handoffs is the tab's append-only agent-swap ledger (#2013), oldest first.
	// omitempty + additive on the same rollforward precedent as ID and
	// Conversation: a record written before #2013 has none, which is
	// indistinguishable from a session that was never handed off — and those two
	// deserve the same treatment, so nothing has to be backfilled.
	Handoffs []AgentHandoff `json:"handoffs,omitempty"`
}

type TabKind added in v1.0.123

type TabKind int

TabKind enumerates the categories of roster entry a Tab can be (the #930 ephemeral-tabs epic). Not every kind owns a process: web tabs have none, and a VS Code tab references a session-shared daemon-managed one — see HasTmux and TabKindRequires.

const (
	// TabKindAgent is the agent session: the resolved agent program with
	// system-prompt injection and trust-prompt handling. Exactly one
	// per instance today, at Tabs[0].
	TabKindAgent TabKind = iota
	// TabKindShell is a plain $SHELL session in the worktree — the
	// human-spawned terminal tab (spawned via tab_spawn.go).
	TabKindShell
	// TabKindProcess runs an arbitrary command in the worktree — the
	// CLI-spawned tab (spawned via tab_spawn.go).
	TabKindProcess
	// TabKindWeb is a URL/iframe tab: it has NO tmux PTY and no process. It
	// carries a target URL (a loopback dev-server address the daemon
	// reverse-proxies, or an external absolute URL the web UI iframes directly)
	// so an agent can inject a live browser view into the user's screen. Rendered
	// as an iframe in the web UI and as a placeholder in the TUI (which cannot
	// render a browser). Created only through `af sessions tab-create --kind web`
	// / the CreateTab API — never a TUI hotkey.
	TabKindWeb
	// TabKindVSCode is a VS Code editor tab: a full code-server (or
	// openvscode-server) editor rooted at the session's WORKTREE, rendered as an
	// iframe in the web UI and as a placeholder in the TUI.
	//
	// Like TabKindWeb it has NO tmux PTY, and — deliberately — no URL either. The
	// editor process is DAEMON-managed and keyed by SESSION, not by tab: one
	// code-server per session, shared by every vscode tab in it, spawned lazily on
	// loopback with an EPHEMERAL port. Persisting a URL would therefore bake in a
	// port that is stale the moment the daemon restarts, so the target is resolved
	// dynamically at proxy time (Manager.WebTabTarget), which is also what makes
	// restore-then-respawn-lazily work with no stored state.
	//
	// Created through `af sessions tab-create --kind vscode` / the CreateTab API /
	// the web UI's + New tab flow — never a TUI hotkey. The target is ALWAYS the
	// session's worktree, so unlike a web tab it takes no --url/--port.
	TabKindVSCode
)

func ParseTabKindName added in v1.0.188

func ParseTabKindName(name string) (kind TabKind, ok bool)

ParseTabKindName resolves a `--kind` / CreateTabRequest.Kind wire value to its TabKind. ok is false for any unknown value AND for the empty string, which is the caller's shell/process default rather than a kind.

func (TabKind) HasTmux added in v1.0.189

func (k TabKind) HasTmux() bool

HasTmux reports whether a tab of this kind owns a tmux PTY session — i.e. whether its persisted TmuxName is expected to be non-empty.

Agent, shell, and process tabs run a real process behind a PTY. Web and vscode tabs are pure PROJECTIONS (an iframe target; a daemon-managed per-session code-server resolved at proxy time) and deliberately hold none. That property was stated only in the prose above, so every site that meets a tmux-less tab had to re-derive it from an empty TmuxName — and reading "" as "this tab's session is missing" rather than "this kind never had one" is what made an out-of-band web tab invisible in a running TUI (post-merge Codex finding on #1815). This is the single place that answers it.

An unrecognized kind answers true: a tab claiming a process whose session can't be found is skipped by its caller, which is the conservative failure (a tab that doesn't show) rather than materializing a terminal tab with no PTY behind it.

type TabKindAllowance added in v1.0.225

type TabKindAllowance struct {
	// Kind is the `--kind` spelling, the same vocabulary the CLI validates against
	// (session.TabKindNameList), so a client switches on the name the user types.
	Kind string `json:"kind"`
	// Allowed is RefuseTabKind returning nil for this kind on this session.
	Allowed bool `json:"allowed"`
	// Reason is the daemon's refusal text, empty when allowed. Rendered verbatim by
	// clients; it names the requirement that is actually unmet (#3053).
	Reason string `json:"reason,omitempty"`
}

TabData is the serializable form of a session.Tab. The full list is persisted (and restored by exact TmuxName) so every tab — agent and shell alike — reconnects to its tmux session across an af/daemon restart (#930). The field is omitempty + additive, mirroring the BranchCreatedByUs back-compat precedent: instances.json written before #930 PR 2 simply has no Tabs, and FromInstanceData synthesizes [agent, shell] from the legacy TmuxName/Program. TabKindAllowance is the projected verdict for one creatable tab kind.

type TabKindNeed added in v1.0.224

type TabKindNeed int

TabKindNeed states what a tab kind needs from the session's WORKSPACE. It is the predicate the tab gates test, replacing "is this session off-box?" — that question has the wrong shape, because it refuses tab kinds that need nothing from the workspace and it explains every refusal in terms of a worktree some kinds never touch (#3053).

Keeping the classification in one exported place is what stops the gates from drifting apart, the same reason TabKindRenameable exists. A NEW tab kind is classified here once and every gate follows; the default is the conservative one, so a kind nobody classified is treated as spawning a process rather than silently admitted everywhere.

const (
	// TabNeedsMetadataOnly: the tab is a name and at most a URL. It spawns
	// nothing and reads nothing, so it works on every backend.
	TabNeedsMetadataOnly TabKindNeed = iota
	// TabNeedsLocalWorktreeRead: no process, but the DAEMON must be able to read
	// the workspace on its own filesystem — the vscode tab's editor is rooted at
	// the worktree path. Off-box workspaces cannot serve this yet (#3054).
	TabNeedsLocalWorktreeRead
	// TabNeedsLocalProcess: the tab runs a process behind a PTY in the local
	// worktree, which an off-box workspace has no way to provide.
	TabNeedsLocalProcess
)

func TabKindRequires added in v1.0.224

func TabKindRequires(kind TabKind) TabKindNeed

TabKindRequires classifies a tab kind by what it needs from the workspace.

An unrecognized kind answers TabNeedsLocalProcess: refusing a tab that might have worked is recoverable, while admitting one that needs a worktree it does not have fails later and further from the cause.

type TransitionEvent added in v1.0.146

type TransitionEvent struct {
	// contains filtered or unexported fields
}

TransitionEvent is a lifecycle event handed to Instance.Transition. Construct one with the exported constructors below; lv is meaningful only for ObserveLiveness. epoch/epochScoped are set only by AtEpoch.

func AbortArchiveToLost added in v1.0.146

func AbortArchiveToLost() TransitionEvent

AbortArchiveToLost rolls a failed archive move back to Lost so the restore loop heals the agent in place.

func AbortHandoff added in v1.0.207

func AbortHandoff() TransitionEvent

AbortHandoff drops a replacement fence whose runtime swap did not complete.

func AbortRestoreToLost added in v1.0.146

func AbortRestoreToLost() TransitionEvent

AbortRestoreToLost drops a failed restore's fence to a plain Lost so the #1108 loop retries against the now-restored worktree.

func BeginArchive added in v1.0.146

func BeginArchive() TransitionEvent

BeginArchive raises the OpArchiving fence over an archive teardown+move (I4).

func BeginCreate added in v1.0.146

func BeginCreate() TransitionEvent

BeginCreate overlays OpCreating for an optimistic create (was SetStatus(Loading)).

func BeginHandoff added in v1.0.207

func BeginHandoff() TransitionEvent

BeginHandoff raises the OpReplacing fence before the outgoing pane is touched.

func BeginKill added in v1.0.146

func BeginKill() TransitionEvent

BeginKill overlays OpKilling for an optimistic kill. It is always legal — a kill is the terminal user intent and supersedes any in-flight op. I1 (tombstone-before-teardown) is NOT enforced here: the tombstone is a daemon- side field the client optimistic overlay never has, and the daemon's KillSession enforces I1 by ordering (MarkUserKilled before Instance.Kill) while setting NO op (the snapshot stays pure liveness for out-of-band kills).

func BeginRespawn added in v1.0.221

func BeginRespawn() TransitionEvent

BeginRespawn raises the OpRespawning fence for a limit resume re-spawning an established session's runtime (#2997). Liveness is preserved.

func BeginRestore added in v1.0.146

func BeginRestore() TransitionEvent

BeginRestore enters the restore fence for a restorable session (I3): Lost + OpRestoring (replaces RestoreFromArchive's "park in Lost" head).

func CancelArchive added in v1.0.213

func CancelArchive() TransitionEvent

CancelArchive clears an archive fence before teardown changed the runtime or worktree, preserving the liveness that was present when the fence was raised.

func ClearOp added in v1.0.149

func ClearOp() TransitionEvent

ClearOp drops any in-flight optimistic op back to None, leaving liveness untouched — the client-projection bookkeeping for when an optimistic op's outcome is confirmed by the reconcile or the op's RPC failed and the overlay must revert to the underlying daemon liveness.

func CommitArchive added in v1.0.146

func CommitArchive() TransitionEvent

CommitArchive flips the session to the inert Archived state, started=false, on a successful archive move (the daemon path). Reachable ONLY from the OpArchiving fence (I2). The TUI's finalize is a separate unconditional projection-mirror (SetArchived), not this fenced commit — it copies the daemon's already-committed Archived state onto the read-only row, so it is not subject to I2.

func CommitHandoff added in v1.0.207

func CommitHandoff() TransitionEvent

CommitHandoff settles a successfully launched incoming agent as Running.

func ConfirmLive added in v1.0.146

func ConfirmLive() TransitionEvent

ConfirmLive marks a completed create/recover live — Running, op cleared (was MarkLive). It YIELDS (no-op) when a kill/archive op is in flight, so a completing spawn never resurrects a session a teardown owns.

func MarkRestoring added in v1.0.149

func MarkRestoring() TransitionEvent

MarkRestoring overlays OpRestoring WITHOUT touching liveness — the TUI's optimistic restore action. It keeps whatever liveness the row had (the target is {s.liveness, OpRestoring}), unlike BeginRestore, the daemon edge, which flips to Lost. For the common archived→restore that means liveness stays Archived, so the reconcile still sees the Archived→live transition and rebuilds the row (#1203) while ShownArchived re-homes it into the live section eagerly (#1210). handleRestore also offers `r` on Lost/Dead rows, where this preserves that liveness instead.

func ObserveLiveness added in v1.0.146

func ObserveLiveness(lv Liveness) TransitionEvent

ObserveLiveness applies the daemon's authoritative liveness (was SetLiveness). It is the unconditional daemon-truth edge: it sets liveness and preserves the op axis, and never rejects — which is what keeps the #1187 strand impossible.

func ParkHandoff added in v1.0.207

func ParkHandoff(resetAt time.Time) TransitionEvent

ParkHandoff settles an incoming agent that launched but reached its own usage limit before the takeover mission could be delivered.

func RevertKill added in v1.0.146

func RevertKill() TransitionEvent

RevertKill clears an optimistic kill overlay (kill aborted / reverted).

func (TransitionEvent) AtEpoch added in v1.0.205

func (ev TransitionEvent) AtEpoch(epoch uint64) TransitionEvent

AtEpoch scopes an event to the state epoch its decision was made at (#2135): Transition applies it only while the instance is still at that epoch, and silently DROPS it once a newer authoritative transition has moved the state on. Use it wherever the event is a conclusion drawn from an observation taken earlier — the daemon poll settling liveness from pane content it captured a moment ago — so a decision about a state the session has already left cannot overwrite the one it moved to. Events constructed without it are unscoped and apply unconditionally, exactly as before. See session/state_epoch.go.

type WorkspaceKind added in v1.0.173

type WorkspaceKind int

WorkspaceKind describes where a backend's workspace physically lives, so callers reason about locality without asking "is this the remote type" (#1592 Phase 1). New runtimes (ssh/container) pick the kind that matches where the git worktree lands.

const (
	// WorkspaceLocalWorktree: a git worktree on the daemon's own machine, driven
	// by tmux (LocalBackend). Zero value — a backend-less instance reads
	// as a local workspace.
	WorkspaceLocalWorktree WorkspaceKind = iota
	// WorkspaceRemote: the workspace lives off-box; there is no local worktree or
	// tmux to drive (docker, SSH, and remote-hook runtimes).
	WorkspaceRemote
)

type WorktreeCleanupImpact added in v1.0.206

type WorktreeCleanupImpact struct {
	Path           string
	Branch         string
	BaseCommitSHA  string
	RemoveWorktree bool
	DeleteBranch   bool
}

WorktreeCleanupImpact snapshots exactly what GitWorktree.Cleanup will remove. Destructive confirmation code consumes this instead of reconstructing cleanup ownership from capability flags, which do not distinguish AF-owned linked worktrees from in-place or user-branch worktrees.

type WorktreeUnavailableError added in v1.0.146

type WorktreeUnavailableError struct {
	Title        string
	WorktreePath string
	Err          error
}

WorktreeUnavailableError marks a recover/respawn failure caused by the persisted worktree path being unavailable before tmux is touched. The daemon uses the typed shape to add one-shot diagnostics for vanished live worktrees without parsing error strings (#1303).

func (*WorktreeUnavailableError) Error added in v1.0.146

func (e *WorktreeUnavailableError) Error() string

func (*WorktreeUnavailableError) Unwrap added in v1.0.146

func (e *WorktreeUnavailableError) Unwrap() error

Directories

Path Synopsis
Hard-link reproduction's content check, split from worktree_copy_tree.go when that file reached the 1000-line limit (#1145).
Hard-link reproduction's content check, split from worktree_copy_tree.go when that file reached the 1000-line limit (#1145).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL