Ask AI
Ask AI Start conversation ↵

I'm an AI assistant with Grove's codebase and documentation in context.

Ask me anything about Grove.

EXAMPLE QUESTIONS

The importable surface

The grove.core package re-exports its contract from one file. Internal modules (config, git, tmux, store, manager, workspace, activity, sessions, registry) are renamable. Clients import from the package root. The names below are what from grove.core import X is allowed to reach for.

The list below is grouped by concern. Where a docstring is missing, the rendered page surfaces the gap. That is the right pressure to write one, not to paper over it here.

Configuration

grove.core.GroveConfig

Bases: BaseModel

Merged, validated configuration. Built once per load_config call.

activity_admission: AdmissionLimits = Field(default_factory=AdmissionLimits) class-attribute instance-attribute

Item and byte reservations for pending and running activity transitions.

builtin_agents: bool = True class-attribute instance-attribute

Whether Grove's own agents are offered alongside the ones you declare. Off makes your agents list the whole roster.

true (the default) is the standing contract: the built-ins are literally layer 0 of the cascade, so declaring agents REFINES them and can never hide one by omission. That is right for a first-run install and wrong for an operator whose fleet runs its own curated profiles — the stock three still show up in every picker with no way to say no. Flip this to false and the roster is exactly what your layers declare; name a built-in (even bare {"name": "claude"}) to opt it back in and inherit its full built-in spec through the same merge-by-name. Forward-compatible by construction: a built-in added in a future Grove release stays hidden too, instead of appearing on upgrade.

Hiding an agent removes it from find_agent as well, so create rejects it on every surface (CLI/MCP/HTTP) and an existing workspace pinned to it can no longer resume/respawn — it gets the typed "no longer present in config" error. That blast radius is the point: the roster is one list, and hiding an agent is the operator saying nothing should run it anymore.

default_brief: bool property

Whether an unopinionated create hands its agent the first-turn brief.

default_runtime: Literal['host', 'container'] property

Where an unopinionated create runs — the saved answer, else the cascade.

default_skip_init: bool property

Whether an unopinionated create skips the init script.

lifecycle_max_pending: int = Field(default=64, gt=0) class-attribute instance-attribute

Maximum accepted lifecycle operations, including lock waiters and running work.

panels: list[PanelConfig] = Field(default_factory=list) class-attribute instance-attribute

Embeddable compose-service views this project permits in each workspace.

Empty by default. A panel is visible only while the workspace's persisted compose project is verified, running, and contains the named service; a declaration alone never opens a network destination.

projects: list[str] = Field(default_factory=list) class-attribute instance-attribute

Repo roots kept visible in every picker even with zero workspaces. ~ expands, and a path that is not a git repo is ignored.

grove.core.AgentSpec

Bases: BaseModel

One selectable agent in the create picker. Anything terminal based works.

command: str instance-attribute

Shell command sent to the agent window. Quoted arguments and $VAR expansion work.

description: str = '' class-attribute instance-attribute

One line label shown in the picker.

env: dict[str, str] = Field(default_factory=dict) class-attribute instance-attribute

Extra environment variables exported into the agent's window before launch.

env_unset: tuple[str, ...] = () class-attribute instance-attribute

Variable names cleared before env is applied, so an ambient value cannot leak into the agent's window.

The hermetic half of the launch env: a tmux pane inherits the tmux server's environment, which inherited the daemon's, so an ambient value (a profile selector like CLAUDE_CONFIG_DIR) silently leaks daemon → server → pane → agent. Listing a var here unsets it at the pane boundary, so the agent starts from a known base and env — or, when env is silent, the tool's own default — decides instead of whatever the daemon happened to carry. Unset runs first, so a key present in both env_unset and env ends up exported. Pure mechanism, not policy: the launcher just clears whatever vars the config names — no var name is hard-coded anywhere — and a future container launcher applies the same env / env_unset set at create time.

kind: AgentKind = 'generic' class-attribute instance-attribute

Which adapter reads this agent's session. claude_code and codex read transcripts for live state and tokens, mewbo reads a remote session over REST, generic tracks nothing.

models: tuple[str, ...] = () class-attribute instance-attribute

Model ids offered in the create form picker. A convenience list, never a validated allowlist. Empty uses the adapter's native discovery or maintained fallback catalog.

name: str instance-attribute

Picker identifier, and the merge key across cascade layers.

native: bool = True class-attribute instance-attribute

Run the agent as a Grove owned native session instead of its interactive terminal. On by default for Claude Code and Codex, ignored by other kinds, and every create surface can override it per workspace.

On by default for Claude Code and Codex: Grove launches claude -p on the stream-json protocol or codex app-server on stdio, holds the session's control channel (interrupt, model switch, peer mail, live facts) and prints the agent's output in the pane. Native permissions still apply. Set it to false for the interactive terminal UI instead — the built-in claude-terminal and codex-terminal entries are exactly that. Ignored for generic and mewbo agents, which have no native protocol to own. A native session cannot pause or resume; stop or recreate it instead.

owns_native_session: bool property

Whether a launch of this agent is a Grove-owned native session.

The ONE predicate every launch-shaped decision reads: native says what the operator wants, the kind says whether a protocol exists to want it on. Reading the flag alone would make a generic shell try to speak stream-json.

tools_offline: bool = False class-attribute instance-attribute

Launch with network facing tools disallowed. Claude Code drops WebFetch and WebSearch, Codex turns off sandbox networking. No effect on generic or mewbo.

native_for(choice: bool | None) -> bool

The launch mode one create takes: the request's choice, else this entry's.

choice is CreateWorkspaceRequest.native; None means the caller left it to the roster. The kind gate applies to both roads, so a checkbox on a shell entry cannot ask for a protocol that does not exist.

grove.core.load_config(repo_root: Path | None, cli_overrides: dict[str, Any] | None = None, env: Mapping[str, str] | None = None) -> GroveConfig

Resolve the full cascade and return a validated GroveConfig.

Layers (last wins): built-in defaults → user JSON → project JSON → project-local JSON → per-field declared env vars (DeclaredEnvVars) → GROVE_* env vars → caller-supplied CLI overrides.

Once the layers have merged, any ${VAR} a string value carries is resolved against the same environment (EnvReferences) — before validation, so a resolved value is checked exactly like a literal one.

Lifecycle

grove.core.WorkspaceManager

Orchestrates workspace lifecycle for one repo + one merged config.

provides_pane: bool property

Whether the launch backend hosts a tmux pane (False = headless).

The one seam every tmux-only path consults: reconciliation skips the has-session/pane-activity derivation when False, the activity blend treats the workspace like a remote adapter (pane not authoritative), and pane-bound steering/snapshot raise CapabilityUnavailable.

ticket_providers: TicketProviderRegistry property

The repo's enabled ticket providers, built once from cfg.tickets.

The single surface both the engine (pure branch parse/format, used by create/attach_ticket) and the daemon's /tickets routes (network list/get) share — no client re-implements parsing or linking.

Caching it for this manager's whole life — which in a daemon is the whole process — is safe only because no provider captures a credential: each resolves its token_env per request through the registry's live env mapping, so a token that appears later (an init script's dotenv, a rotated secret) is picked up without rebuilding anything. repo_root goes with it so a repo-relative tickets.env_file resolves against the repo whose cascade produced this config.

add_attachment(workspace_id: str, name: str, data: bytes) -> AttachmentView

Store one file for this workspace and say where the AGENT will find it.

Attachments land under the worktree (.grove/attachments/) for one reason that decides everything else: the worktree is the only directory a host process and a containerized agent both see, so a host temp directory would be an address half the fleet could not open. The translation into the agent's namespace is the same re-rooting GROVE_PHASE_FILE gets, and it happens HERE rather than in the store because only the workspace record knows whether there is a container to translate for.

The git exclude is re-asserted on every upload rather than only at create. That is not defensiveness: a workspace that predates this feature has no such exclude, and its first attachment is the moment an untracked file starts blocking its own pause and kill.

Loud by contract, unlike the read paths: an over-size payload (GroveError) or an unwritable worktree must never report success and leave the agent pointed at a file that is not there.

add_container_agent(workspace_id: str, *, agent: str | None = None, name: str | None = None, model: str | None = None, initial_prompt: str | None = None) -> ContainerAgent

Start ANOTHER agent inside this workspace's container. Returns it.

The whole of the user's ask: "multiple Claude Code instances can also run within the same container if necessary by the person of choice". Never automatic — no lifecycle verb calls this.

The launch is composed through exactly the seams the workspace's own agent uses — _compose_launch for the decoration (session id, hook settings, channels, model, the trailing prompt positional) and _launch_env for the hermetic env — so the second agent is configured identically to the first and the two cannot drift. It differs in three things only: its own in-container session name, -A -d (start it, do not drop the caller into it), and no remain-on-exit. That last is deliberate and follows the rule story 3 established: keeping a corpse is only safe where something owns the clearing, and for the primary agent the launch backend owns it. Nothing owns it here, so an additional agent that exits leaves no session — which container_agents then honestly stops listing.

Not persisted, and that is the design rather than a shortcut: the agent's transcript reaches the workspace's agent axis on its own, since the existing discovery/adoption path adopts any session of the workspace's kind born in its cwd after it was created (see ActivityService.sessions_for). The one honest limitation: an agent of a DIFFERENT kind from the workspace's is not surfaced there, because that scan is kind-scoped on purpose — it runs, it is listable, attachable, peekable and steerable, but the workspace card stays about the workspace's own kind.

A remote-steered kind is refused: a mewbo session runs on a backend, so "add one to this container" names nothing.

agent_exit(state: WorkspaceState) -> AgentExit | None

How this workspace's agent command exited, or None if it has not.

THE dead-agent seam the activity blend reads, with one answer per runtime because the two record the fact in different places:

  • A pane-hosted agent on this host writes its status into a file the launch installed a recorder for.
  • A containerized agent under an in-container tmux is the pane's OWN process, so tmux itself holds the fact: remain-on-exit keeps the dead pane, and #{pane_dead_status} is the code. The shell recorder cannot see this — it captures the exit of what the HOST pane ran, which under tmux is the tmux client, so an agent dying inside a live session recorded nothing. That narrowing is what this restores.

Absence keeps meaning "the agent has not exited" on both roads, which is what keeps a slow start from ever reading as a failure: an unreadable docker, a session that is simply gone, and a healthy agent all answer None. Never raises — the caller is a per-tick render path.

answer_question(workspace_id: str, request: QuestionAnswerRequest) -> None

Answer a pending question by DISMISSING it and restating the batch as text.

Grove used to drive the provider's own question widget by keystroke, and the cost of that was structural rather than incidental. A picker is a stateful TUI, so a digit's meaning depends on what is currently painted; every provider paints something different; and a batch of four questions with a tab strip and one shared Submit has no keystroke grammar that a second provider shares. The measured failure was the worst possible shape — answers silently mapped onto the wrong questions while the request reported success.

So the widget is not driven at all. Escape takes the provider's own cancel route, the whole batch is rendered into one Grove-fenced message, and that message goes down the ordinary steering path. What is left is provider-neutral by construction: every agent Grove can steer can be sent Escape and a line of text, which is why this now works for Codex and for whatever ships next, where the keystroke grammar worked for exactly one build of one tool.

Dispatch semantics, like send_message: this returns once the message is delivered, and the agent's actual response lands later on the transcript. Gates, in order: the workspace must exist (WorkspaceNotFound → 404); session_id must be the session Grove minted for this workspace's pane (QuestionNotPending → 409, else a foreign session_id could steer into the wrong pane); a captured question for session_id must still match tool_use_id (QuestionNotPending → 409, the human may have answered in the terminal); the plan must fit the captured questions (QuestionAnswerInvalid → 422); a pane must resolve (PaneNotFound → 409).

The capture is re-checked immediately before the send to shrink — never close — the terminal race. Losing that race is now benign in a way it never was under keystrokes: if the human answered a moment earlier, the Escape hits a composer that has nothing to cancel and the text arrives as an ordinary steering message, which is a duplicate answer rather than a wrong one.

attach(workspace_id: str) -> AttachInstruction

Where the client should attach, and to WHOSE multiplexer.

Two arms, chosen by the one predicate the launch already branches on — whether this workspace's container can run a tmux (_container_tmux, i.e. ContainerRuntimeState.tmux_command):

  • Container — the container's own tmux owns the session, so the client execs straight into it. There is deliberately no host session to target: a host pane running devcontainer exec … tmux attach is a shadow client that clamps every later client's terminal size to its own (measured: a client asking for 200x50 got 161x41).
  • Host — every host workspace, and the container that has no tmux inside it, where the agent really does run in a host pane.

The container argv is composed from the SAME :class:~grove.core.container_agent.ContainerAgentEntry that starts the agent, so the way in cannot drift from the way it was launched.

A NATIVE workspace attaches READ-ONLY on both arms: its pane is Grove's worker printing the session's protocol frames, so a person can watch the wire but a stray keystroke can never reach — or kill — the one process holding the control channel. Steering goes through the verbs.

Attach whatever a human typed — a URL, #42, 42, owner/repo#42.

The one seam between raw text and :meth:attach_ticket: the repo's provider registry resolves the link (inferring provider and issue-vs-PR) and the attach itself is unchanged, so every surface that accepts a pasted link gets identical parsing, identical ambiguity refusal, and identical idempotency. Raises TicketLinkError / TicketLinkAmbiguous before touching the store.

attach_ticket(workspace_id: str, selector: TicketSelector) -> WorkspaceState

Manually associate a ticket with a workspace (the branch-parse override).

Idempotent by (provider, id): re-attaching the same ticket is a no-op. A pull request is attached the same way, by the same selector carrying kind="pull_request" — one list, no parallel PR surface. The key stays (provider, id) because that is what the forge links on, so re-attaching a ref whose kind was wrong (a branch parse assumed issue) CORRECTS it in place rather than silently keeping the stale kind or growing a duplicate row.

The ref is stored bare (provider + id + kind) — display enrichment (title/status) is the daemon's on-demand fetch, never persisted here, so attach stays pure and offline-safe. Permitted in any status except ORPHANED (same gate as update — a doomed record gains nothing).

Seeds the phase file with an entry for the newly attached key (:meth:PhaseFile.seed, best-effort) right after the store save, so an agent that attaches a ticket mid-task finds the key already there instead of composing it. Deliberately NOT mirrored on :meth:detach_ticket — see that method's docstring, and :meth:PhaseFile.seed's, for why a detached entry is left standing.

bind_runtime_liveness(*, container: Callable[[str], ContainerState | None], host_tmux: Callable[[str], bool | None], host_tmux_activity: Callable[[str], datetime | None]) -> None

Use daemon-maintained runtime facts instead of render-path probes.

The source owner supplies an explicit initial witness before binding; a None result remains an unreadable boundary, never an absence claim. Standalone managers deliberately retain the existing direct probes.

can_receive(state: WorkspaceState) -> bool

Whether :meth:send_message could deliver to state right now.

The mailbox's whole admission rule, and it is deliberately the same predicate steering already enforces rather than a second opinion about liveness — a contact the directory advertises must be one the send can actually reach. Takes a reconciled state, so a listing pays no second reconciliation per row.

A native workspace whose owner has died still answers True: the steer path revives it (_revive_for_steer), so refusing here would hide a peer that one message brings back.

commits(workspace_id: str) -> tuple[CommitSummary, ...]

Comprehensive commit history for a workspace, newest first.

git log <diff_base>..branch — every commit made in this workspace since it was created. The anchor is the commit recorded at create (WorkspaceState.diff_base), NOT the base branch: for a ROOT workspace the branch and the base branch are the same ref, so the branch-derived range collapsed to empty however much work was done. Distinct from peek.recent_commits which walks all of branch history (no fork-point filter) and is capped at 3 for the TUI's tight rail.

A record with no recorded anchor is answered by TIME, not by a ref. diff_base's fallback is base_branch, and this is the one consumer for which that degradation does not survive contact with ROOT placement: a root workspace's base_branch is the literal "HEAD", so the range collapses to empty however much work happened, and the card then prints a confident false claim ("no commits on this branch yet") rather than a degraded one. Reproduced on this repo's own root workspace: 0 commits from the ref range against 106 from the --since form. So a null anchor takes branch_commits_since(branch, created_at) — a recorded fact answering a different, well-posed question — and the answer says which question it was via CommitScope, because the count errs high. With an anchor present nothing changes: diff_base IS base_commit there, so the call is byte-identical to what it always was.

A merge-base backfill stays refused, for the reason this module already records: it would be indistinguishable on the wire from a recorded fact. A timestamp is not, and never claims to be the anchor.

The window is taken only where the ref range CANNOT answer, which is a property of the refs and not of the record's shape. On a legacy Grove-created branch base_branch is a real, different ref and the range is exact, so trading it for a window that can over-report would lose precision this method already had. The test is therefore whether the two ends resolve to the same commit — true for root placement (where base_branch is the literal "HEAD"), and the one condition under which the answer is structurally zero rather than measured. An unresolvable base is degenerate for the same reason: branch_commits could only raise on it.

Best-effort: degrades to () on git failure, mirroring the peek-helpers' never-raise contract for read paths. The daemon's GET /workspaces/{id}/commits is the wire shape consumers receive; the TUI doesn't call this method today (its rail keeps the truncated summary).

container_agent_argv(workspace_id: str, name: str) -> _Argv

The argv that attaches a terminal to one in-container agent.

Returned rather than executed for the same reason attach returns an AttachInstruction: the core does not own the caller's process model — the CLI execvps this and becomes the exec.

The session is verified to exist first, which is what lets the argv omit a command entirely (-A ignores one when attaching anyway) instead of re-deriving an agent command tmux would throw away — and it turns "that agent has ended" into Grove's own sentence rather than tmux's.

container_agents(workspace_id: str) -> tuple[ContainerAgent, ...]

Every agent running inside this workspace's container.

Read live from the container's own tmux server, never from the record: that server is the only thing that knows an agent ended on its own, and a persisted roster would be a migration plus a way to be wrong. The workspace's own agent is included and flagged primary, because "what is running in here" that omitted the main one would be a trap.

Raises rather than returning empty when docker cannot be read: an empty roster and an unreadable one look identical to a caller and mean opposite things — the standing rule that "cannot tell" must never be spelled as an answer.

create(request: CreateWorkspaceRequest) -> WorkspaceState

Spin up a fresh workspace from a validated client request.

Validation order (no side effects until all pass):

  1. The requested agent must exist in the merged config.
  2. The resolved BranchPlan must agree with live git state — per-mode rules in _validate_branch_plan: a NEW name must not collide with an existing branch and the base ref must exist; a CHECKOUT name must exist locally and not already be checked out at another worktree.
  3. Worktree → init script → tmux session, in that order, with _rollback_create cleaning up any partial state on failure.

Branch provenance (GROVE_CREATED vs USER_ATTACHED) is derived from the resolved plan and persisted on the state, so kill() later knows whether the branch is safe to delete.

current_branch() -> str | None

The local branch HEAD points to, or None if HEAD is detached.

default_branch() -> str

Best-effort default branch (origin/HEADinit.defaultBranchmain).

detach_ticket(workspace_id: str, provider: str, ticket_id: str) -> WorkspaceState

Remove a ticket association. Idempotent — a missing ref is a no-op.

effective_kind(state: WorkspaceState) -> AgentKind

The adapter kind for state — persisted at create, else config.

Prefers the kind persisted at create (resolves a repo-scoped agent the daemon's global config never loaded); falls back to a config lookup for legacy records written before agent_kind existed, then generic. THE single definition, so the remap/create kind gate and the dashboard can't disagree about what a workspace runs. ActivityService used to carry a verbatim copy (_effective_kind); it was deleted in favour of calling this, because two implementations of "which adapter reads this workspace" is exactly the drift that kind-scoping was cleaning up. Public because SessionExplorer also restricts its per-workspace scan to this one kind, so the picker never offers a foreign-kind session the remap gate below would reject.

find_by_ticket(provider: str, ticket_id: str) -> WorkspaceState | None

Resolve the workspace tracking ticket (provider, ticket_id), if any.

The issue-ops routing seam: "does a workspace already exist for this ticket, so steer it instead of creating a new one." Scans list() (already reconciled to each workspace's displayed status) for a ticket_refs match — no new persisted state, no separate index.

kill() deletes the persisted record outright rather than marking it KILLED, so a killed workspace can never surface here — "the newest non-killed match" is true of everything list() returns by construction. When more than one live workspace tracks the same ticket (hand-attached twice, or a fresh workspace opened for a ticket an older one already tracks), the tie-break is the NEWEST by created_at — the most recent workspace is the one issue-ops should steer.

interrupt(workspace_id: str) -> None

Interrupt one supported agent without sending a process signal.

Claude Code's REPL routes Escape to its abort controller, the same path its native remote-control interrupt frame uses. A pending permission prompt instead takes its onAbort() path, so Escape's effect is deliberately state-dependent. SIGINT is never used: Claude Code has no interactive SIGINT handler and the signal can kill the session. Other tmux-hosted kinds retain SteeringUnsupported rather than receiving a guessed cancel key.

invoke_control(workspace_id: str, name: str) -> None

Invoke a named session control — a slash command or a skill.

Thin trigger: composes the tool's /name invocation and delivers it through the EXISTING steer path (:meth:send_message → tmux keystroke / native channel / remote), so it reuses the whole dispatch, the settle window, and the audit trail rather than adding a second delivery mechanism. Kind-gated to the tools that actually expose a slash-control surface (_CONTROL_KINDS); a generic shell or a remote orchestrator raises CapabilityUnavailable (well-formed, but the runtime can't act on it). Best-effort dispatch semantics like send_message — 204/return is "delivered", not "ran".

kill(workspace_id: str, *, delete_branch: bool | None = None) -> None

Tear down the workspace's tmux session (always) and worktree.

For worktree placement the worktree is always removed; the local branch is deleted only when delete_branch is True. Default (None) resolves from state.branch_provenance: GROVE_CREATED → True (Grove made the branch; safe to drop), USER_ATTACHED → False (the user's pre-existing branch stays).

For root placement the worktree IS the repo root and the branch is the live checkout, so kill never removes the directory and never deletes the branch — even if the caller passes delete_branch=True. It only stops the session and forgets the record.

Remote branches are never touched. Period — there is no flag to opt into remote deletion. That's git push --delete territory and stays in the user's shell, with their own credentials. Best-effort on each step; a failure on one stage does not prevent later stages from running — but it IS reported: the killed event's branch_deleted is the outcome (not the request) and a residue key names every stage that failed, because the record that could otherwise be used to find the leftovers is deleted a line later.

One failure is not forgiven: a container teardown that did not happen. Every other stage fails toward something the user can still see and fix by hand — a worktree on disk, a branch that stayed. A container Grove cannot remove is nameable only through this record, so deleting the record would leave it running and unreachable forever. The workspace is kept as ERROR carrying the reason and this raises instead.

kill_container_agent(workspace_id: str, name: str) -> None

End one ADDITIONAL agent's in-container session. Idempotent.

Refuses the workspace's own agent by name: that one has lifecycle verbs of its own (pause / respawn / kill), which also deal with the container, the worktree and the record — ending its session from here would leave the workspace looking alive with nothing in it, and no verb would report why.

latest_task(workspace_id: str) -> str | None

The workspace's current task text, UNCAPPED — the engine seam the issueops sticky-comment publisher reads in-process.

The :meth:latest_todo sibling in every respect: same :meth:_todo_session_id resolution (NOT agent_session_id alone — codex mints no id and a rotated claude id is a dead pointer), same AgentSessionNotFound on a genuinely sessionless workspace so a caller that NAMED a workspace can tell 404 from "a session exists and carries no task text yet" (None), same best-effort adapter read.

The text is the one AgentActivity.current_task carries, minus the adapters' _TASK_TEXT_CAP truncation. The wire field stays capped: it rides the ~1 Hz activity delta for every workspace on the host plus every TUI row and webapp card, where an arbitrarily large pasted prompt is a real cost. A reader that renders the text ONCE per flush — the sticky comment, inside a collapsed <details> — pays nothing for the whole text, so it reads it here instead of re-deriving a truncated one.

latest_task_for(state: WorkspaceState, *, session_id: str | None = None) -> str | None

:meth:latest_task for a state a caller ALREADY holds reconciled — the :meth:latest_todo_for split, for the identical reason.

Degrades to None for a sessionless workspace rather than raising (the 404-vs-None distinction is the id seam's contract), does NO discovery of its own (a scan per workspace per tick is the daemon-CPU bug the transcript cache exists to prevent), and reads transcript_scan_cwds[0] because both filesystem adapters resolve a transcript by globbing the session id. Scoped, like every transcript read here: an unscoped read on a profile-pinned workspace answers None and the sticky comment silently drops the task line.

latest_todo(workspace_id: str) -> TodoList | None

The workspace's current todo/checklist state — the engine seam both the issueops sticky-comment publisher (in-process) and the GET /workspaces/{id}/todo daemon route read.

Resolves the workspace's session through :meth:_todo_session_id — which is NOT agent_session_id alone: a codex workspace mints no id at all and a rotated/ended claude id is a dead pointer, so keying the axis on the mint made it blank for a whole provider. RAISES AgentSessionNotFound only when neither the mint nor discovery names a session — the same convention _steer_remote/_steer_native use, so a caller can tell "no session yet" (404) apart from "a session exists but no todo tool has been called yet" (None, a real answer). The adapter read itself stays best-effort (never raises) like every projection here.

latest_todo_for(state: WorkspaceState, *, session_id: str | None = None) -> TodoList | None

:meth:latest_todo for a state a caller ALREADY holds reconciled.

The pane_target/pane_target_for split, for the same reason: the activity poll hands every read its own tick-reconciled WorkspaceState, and an id-taking sibling would re-fetch it from the store and re-run _reconcile_status — per workspace, per tick — for an answer the caller already has. Degrades to None for a sessionless workspace rather than raising: the 404-vs-None distinction is the id seam's contract (a caller that named a workspace is owed the difference), whereas a render path enumerating every workspace only ever wants "nothing to show".

session_id is the session a caller has ALREADY resolved, and passing it is what keeps this seam cheap AND correct. ActivityService hands over the primary its own tick just adopted — which is the codex / dead-pointer answer :meth:_todo_session_id reaches by scanning, for free and one step more accurate (it carries the tick's dead-mint promotion). Omitted, this falls back to agent_session_id and does NO discovery of its own: a scan per workspace per ~1 Hz tick is the daemon-CPU bug the incremental transcript cache exists to prevent, so the expensive arm lives only on the per-request id seam.

The cwd handed to the adapter stays transcript_scan_cwds[0], and iterating the union here would be inert surface: both filesystem adapters resolve a transcript by GLOBBING the session id (Claude across every projects folder, Codex across the date-partitioned tree) and use cwd only to break a tie between files claiming the same id — so a session recorded at the worktree root of a nested workspace already resolves from the first entry. The union matters where the SESSION is being found rather than read, which is _scan_workspace's job.

Scoped. This seam has THREE consumers now — the daemon route, the issueops sticky-comment publisher in-process, and the dashboard card — so an unscoped read here doesn't just blank a panel, it posts an empty checklist into a real issue comment. The adapter read stays best-effort on its own.

list() -> list[WorkspaceState]

Workspaces in this repo, with each persisted intent promoted to its currently-displayed status (ACTIVE / IDLE / OFFLINE / ORPHANED).

Reconciliation calls live tmux + filesystem helpers per running workspace — bounded subprocess work, the same shape we already do for has_session. See _reconcile_status for the policy.

list_local_branches() -> tuple[BranchInfo, ...]

Every local branch in the repo, with HEAD marker, upstream, and checkout site.

Tuple, not list, because the return is a point-in-time snapshot — mutating it after the call would mislead the caller about live repo state. A fresh call rebuilds. Same shape for the remote helper below.

list_remote_branches() -> tuple[BranchInfo, ...]

Every remote-tracking branch (excluding origin/HEAD symref).

open_diagram(workspace_id: str, request: DiagramOpenRequest) -> DiagramDocumentView

Begin one revisioned collaboration over an existing worktree diagram.

The descriptor is persisted only after the file has been safely opened. Reopening the active path is idempotent; opening another path is refused rather than silently abandoning a browser that can still autosave.

pane_for(state: WorkspaceState) -> tmux.TmuxPane | None

Resolve the shared pane identity and transport for event observation.

pane_target(workspace_id: str) -> str | None

Resolve the tmux target the rail should capture / resize for workspace_id.

Fetches and reconciles fresh from the store — the right call when a caller has only an id. A caller that already holds a WorkspaceState reconciled THIS tick (ActivityService.poll_once/snapshot, off mgr.list()) must use pane_target_for instead: re-reconciling here redid a full _reconcile_status (has_session + list_windows + pane_activity_seconds_ago) that list() had just paid for one line earlier — 3 redundant tmux forks per workspace per poll, ~4 once the target resolution's own list_windows call is added, at fleet scale (24 workspaces, every poll).

Policy (in order): 1. Workspace not RUNNING → None. 2. The agent runs under a tmux INSIDE its container → that session, reached through the container. It is the agent's OWN pane rather than the host viewport onto it. 3. Configured agent_window_name exists → "<session>:agent". 4. Any non-shell window exists → "<session>:<first-non-shell>" (a renamed agent, an init window, etc. — usually where the live work is). 5. Only shell exists → "<session>:shell" (last-resort fallback so the rail at least shows the bare prompt). 6. Session reports no windows at all → None.

Best-effort: tmux.list_windows never raises, so this is safe to call from the peek hot path. Returning None is the contract for "no live pane to look at"; callers should render the empty state.

pane_target_for(state: WorkspaceState) -> str | None

Resolve the tmux target for a WorkspaceState ALREADY reconciled this tick — the pane_target(workspace_id) sibling for a caller (ActivityService._workspace_activity) that got its state from list() and would otherwise pay for a second full reconciliation to ask the same question list() already answered.

peek(workspace_id: str) -> WorkspacePeek

Rich snapshot for the rail: branch metrics, recent commits, and a one-shot agent-pane capture. Recompute it whenever you want a fresh frame; nothing here is cached or animated.

Failures in the underlying git/tmux helpers degrade to zeros / empty rather than raise — peek must never break a render loop. Status reconciliation (RUNNING → ACTIVE/IDLE/OFFLINE/ORPHANED, see _reconcile_status) is applied to the returned state but never persisted — list() does the same promotion for the table.

peek_pane(workspace_id: str, *, agent: str = '') -> tuple[str | None, datetime | None]

Tmux-only fast path: just the agent-pane snapshot. Used by the rail's fast pane-tick (~250 ms) so we don't redo git ahead/behind and diff stats — those move at human pace, the pane moves at agent pace. Best-effort: returns (None, None) on any failure or for non-live workspaces.

agent names one of the ADDITIONAL agents a container may host; empty is the workspace's own agent and is unchanged. Naming one on a workspace that cannot host several is a typed refusal rather than a silent empty snapshot — this is the one place best-effort would be actively misleading, because "that agent printed nothing" and "there is no such agent" are different answers a user acts on differently.

A workspace that no longer exists is one of those failures, and it is the ordinary one on this path: the ~250 ms tick fires against whatever the rail last selected, so a kill between two ticks leaves the next one asking about a deleted record. Only the lookup is guarded — the typed refusal above still raises, for the reason its own paragraph gives.

pending_queue(workspace_id: str) -> tuple[QueuedMessage, ...]

What the harness is holding for this workspace but has not delivered — the engine seam GET /workspaces/{id}/queue reads.

The :meth:latest_todo sibling in every respect that matters, and for the same reasons. Resolution is :meth:_todo_session_id, NOT agent_session_id: codex mints no id at all and a rotated claude id is a dead pointer, so keying on the mint would exclude a whole provider by construction — the exact bug the todo route already documents. AgentSessionNotFound is raised only when neither the mint nor discovery names a session, so a caller that NAMED a workspace can tell 404 from "a session exists and nothing is queued" (an empty tuple, a real answer). The adapter read stays best-effort on its own.

pending_queue_for(state: WorkspaceState, *, session_id: str | None = None) -> tuple[QueuedMessage, ...]

:meth:pending_queue for a state a caller ALREADY holds reconciled — the :meth:latest_todo_for split, for the identical reason.

Degrades to () for a sessionless workspace rather than raising (the 404-vs-empty distinction is the id seam's contract), does NO discovery of its own (a scan per workspace per ~1 Hz tick is the daemon-CPU bug the transcript cache exists to prevent), and reads transcript_scan_cwds[0] because a filesystem adapter resolves a session by globbing its id — and because the codex arm ignores cwd outright, its queue being keyed by thread id in a config-root-global store.

Scoped, like every adapter read here: an unscoped read on a profile-pinned workspace resolves the wrong config dir and answers empty, which on this axis is indistinguishable from "nothing queued".

phase(workspace_id: str) -> PhaseReport | None

:meth:phase_for by workspace id — the CLI/MCP read seam.

phase_for(state: WorkspaceState) -> PhaseReport | None

The agent's own claim about how far through its task it is, or None.

Read from <worktree>/.grove/phase/<workspace id>.jsonthe worktree root, never agent_cwd, and the two differ for a nested project_subpath. Three reasons, in ascending order of how expensive getting it wrong would be:

  1. .grove/ is already a worktree-root concept (it holds the project's committed config.json); a second .grove inside a subdirectory would be a new location a user has to learn.
  2. The worktree root is the one directory that is unique per workspace AND is the bind-mount root inside a container, so host and containerized agents name the same file.
  3. The git exclude that keeps pause/kill working is ANCHORED. A pattern containing a slash in info/exclude matches only relative to the working-tree root, so .grove/phase/ excludes <worktree>/.grove/phase/ and NOT <worktree>/sub/.grove/phase/ (verified against real git). Anchoring at agent_cwd would therefore leave every nested-project workspace's phase file untracked, and git worktree remove refuses on untracked files — pause and kill would break for exactly the workspaces the subpath feature exists to serve.

The per-workspace key is what stops two ROOT workspaces (whose worktree IS the shared repo root) from overwriting each other; the legacy single file is read only when the keyed one is absent, so a workspace that reported before that layout landed keeps its badge.

Best-effort by contract, like peek: a paused workspace whose worktree is gone, an unreadable file, or an agent's typo yields None rather than breaking a caller's snapshot.

primary_transcript(workspace_id: str) -> tuple[Path, ...]

Transcript file(s) for the workspace's agent session, or () if untracked.

Resolves the adapter from the agent's kind and the persisted session id, then asks it to locate the file(s) under the worktree cwd. Read-only and best-effort — the adapter never raises. Empty for a generic/shell agent (no session id), a legacy record, or before the transcript is first written (the STARTING window). The ActivityService builds on this to parse activity.

A tuple (point-in-time snapshot), matching list_local_branches — the files on disk may change after the call, so an immutable return can't mislead the caller about live state.

Honors state.transcript_context: a session launched under a different runtime context records a cwd/config dir the host reader can't guess, so an override substitutes that recorded cwd and scopes the adapter's config-dir env var for this one call. No override (the default) is byte-for-byte the behavior that predates the override.

Kind comes from effective_kind — the persisted-agent_kind-wins rule every other read path follows. A bare find_agent lookup fell to "generic" (blank result) whenever the agent wasn't in this manager's config, which builtin_agents: false and repo-scoped rosters make far more reachable than the legacy-record case it was written for.

provision_progress(workspace_id: str) -> ProvisionProgress

How far along this workspace's container provision is.

The id seam over :meth:ProvisionProgress.read, which is the state seam a caller holding a reconciled state should use instead — the pane_target_for / latest_todo_for split, for the same reason: this one re-fetches from the store.

Answers for a FINISHED provision too, and deliberately so: a user who arrives late still wants the log of the build they waited through, and a failed one is exactly when its tail is worth reading.

queue_supported(workspace_id: str) -> bool

Whether this workspace's provider can report an empty queue honestly.

read_diagram(workspace_id: str) -> DiagramDocumentView

Return the current persisted document, never a browser-side draft.

read_diagram_preview(workspace_id: str) -> DiagramPreviewView

Return the current revision's browser-rendered first-page PNG only.

reconciled(workspace_id: str) -> WorkspaceState

One workspace with its persisted intent promoted, exactly as list does.

The single-workspace counterpart to list, and it exists because a per-workspace read is now the ORDINARY path rather than a special case: an event-driven projection refreshes one row at a time, where the old poll rebuilt the fleet through list and got reconciliation for free. Reading get there published the RAW persisted status — so a container mid-build showed RUNNING instead of PROVISIONING, and every other computed status (ORPHANED, OFFLINE) was equally unreachable for any workspace the projection had already seen.

Status is the first element of WorkspaceActivity.fingerprint, which is what made this invisible: the row still changed, still emitted, and still looked alive — it was simply answering with a different question's answer. _maybe_emit_status_drift fires here too, so a drift observed through one row is reported exactly as one observed through the fleet.

remap_session(workspace_id: str, session_ref: str) -> WorkspaceState

Manually pin an existing agent session as this workspace's primary.

The trusted-operator counterpart to the automatic discovery/adoption path: when /clear rotated the id (the minted pointer went dead), or a hand-started session should own the card, the user names it and Grove records it as agent_session_id — the very field create() mints — so every read path (the dashboard blend, sessions_for, the CLI inspector) tracks it by construction, no discovery heuristic needed.

Resolution runs through the project's :class:SessionExplorer (resolve accepts a unique id-prefix, scoped to this repo's worktrees), so a typo or a foreign id fails loudly before the write — re-raised as :class:AgentSessionNotFound (404) rather than the bare GroveError resolve emits. Idempotent by resolved id, mirroring attach_ticket: re-pinning the same session is a no-op (no re-persist, no event).

Manual pinning is TRUSTED: unlike discovery it applies no created_at birth-gate — the operator's explicit choice outranks the heuristic, exactly as a grove_launched session is never gated. It DOES enforce adapter-kind equality: pinning a codex session onto a claude_code workspace would leave the workspace's adapter permanently unable to read it (a dead pointer that returns 200) — so a kind mismatch is rejected as AgentSessionNotFound naming both kinds. Permitted in any status except ORPHANED (the attach_ticket gate — a doomed record gains nothing). Emits updated with session_remapped: <id>.

respawn(workspace_id: str) -> WorkspaceState

Recover a missing session or explicitly restart a host-native owner.

OFFLINE means the persisted intent is RUNNING but the tmux session has vanished externally (the Grove user closed it, the host rebooted, a peer killed it). The worktree is intact, so we don't touch git — we only spin up a fresh session in the existing worktree and restart the agent. Init script is NOT re-run by default (the worktree was already initialized at create time); set init_script.run_on_resume to opt in for parity with resume. Root placement never re-runs init on respawn even with run_on_resume — init for a root workspace is a deliberate create-time choice and must not fire unattended in the user's real repo root.

save_diagram_preview(workspace_id: str, request: DiagramPreviewUploadRequest) -> DiagramPreviewView

Save a browser-rendered first-page PNG only for the current document.

The attachment is intentionally stored only after the active descriptor and raw diagram bytes both match the browser's echoed session/revision. The operation lock makes that pair, attachment publication, and a concurrent stop/reopen one serialized transaction.

send_keys(workspace_id: str, key: tmux.SendKey) -> None

Deliver one named key, without interpreting what the application does.

Unlike interrupt's provider-level intent, this requires a live terminal. The caller names only a workspace and a closed enum member: pane and server resolution stay identical to peek and ordinary message delivery. Remote agents must not receive keys in an unrelated local shell.

send_message(workspace_id: str, text: str, *, agent: str = '', attachments: Sequence[str] = ()) -> None

Type text into the workspace's agent pane and submit it.

Grove's follow-up/steer surface for tmux-hosted agents. Policy lives here; the literal-safe injection mechanism is tmux.send_text. Gates, in order: remote-steered kinds dispatch to the adapter arm; the session must be live (OFFLINE/PAUSED → typed WorkspaceStateError); a pane must resolve via the same pane_target policy peek captures from (None → PaneNotFound).

attachments names files already uploaded through :meth:add_attachment. They are appended to the human's own text as a Grove-fenced block naming each file and its path — the agent reads a file with the tools it already has, so an attachment needs an address rather than a new capability. The client sends IDS, never paths: a path supplied by a caller is a caller choosing what the agent opens.

agent names one of the ADDITIONAL agents a container may host; empty is the workspace's own agent and every gate below is unchanged. A named agent takes the container arm and only that arm: a remote (mewbo) session and a paneless runtime have no container tmux to hold a second agent, so naming one there is refused rather than misdelivered to the primary — which would be exactly the "silently pick one and pretend" this story is not allowed to do.

The message_sent audit event carries the resolved target and the text length, never the content — steering text can hold secrets and events fan out to every subscriber and log sink.

session_controls(workspace_id: str) -> SessionControls

Enumerate the input controls available to this workspace's session.

The read behind the webapp's control panel: TIER 1 filesystem scan via the workspace's adapter (slash commands / skills / MCP servers — cheap, works with NO running session), plus the config-derived model catalog (resolve_models — the single catalog seam every surface shares) and the Grove-hosted permission posture. current_model is a best-effort transcript read (the running session's model), guarded so a parse hiccup just leaves it None.

Best-effort by contract — it feeds a render panel, so a scan/parse failure degrades the surface rather than raising (the peek discipline). The workspace must exist (store.get raises WorkspaceNotFound); past that, everything is guarded.

set_phase(workspace_id: str, phase: TaskPhase, note: str | None = None, *, blocked: bool = False, ticket: str | None = None) -> PhaseReport

Record a phase claim for a workspace, or for one of its tickets — the CLI verb / MCP tool seam.

The in-workspace agent never comes through here; it writes the file directly (that is the whole point of the file channel). This is for an orchestrator or a human setting or correcting a claim from outside, so it is LOUD where :meth:phase_for is best-effort — a caller that asked to write is entitled to know it did not happen.

ticket, when given, MUST be one of state.ticket_refs' own .key — never validated against free text, because a key this workspace never attached would seed a per-ticket claim the store, the sticky publisher and every other joiner can never reach. Checked against a FRESH read of the store (never a caller-cached list), so a detach that raced this call is honoured rather than silently overwritten. Raises :class:TicketNotAttached, naming both the rejected key and the workspace's actual attached keys, before touching the file. Omit it to set the workspace's own claim, unchanged from before.

No event is emitted: the phase is derived per poll tick from the file rather than persisted on the record, so the existing session_activity delta already streams it (the fingerprint carries it). A workspace_changed wake-up would only tell clients to re-fetch a record that does not carry the phase at all.

shared_session_id(state: WorkspaceState) -> str | None

Which transcript this workspace's PUBLIC link shows.

THE SINGLE RESOLVER, and being single is the whole point. The public overview names a session and the public turns route serves one; while those were two independent derivations they disagreed in production — the overview reported the workspace's own session while the transcript rendered whichever file in the scan cwd had been touched most recently, which under ROOT placement is another workspace's conversation entirely. Any future public read that needs a session calls THIS, never a listing index.

The recorded pin wins. None — a link issued before pinning existed, or one whose pin was never captured — falls back to :meth:_todo_session_id, the same per-request resolution the todo and task-text axes use, which is the workspace's own primary session. So an unpinned link is not broken, merely un-frozen: it follows the workspace the way the authenticated surface does.

The answer may name a session with no transcript on disk yet (a mint inside the STARTING window). That is honest rather than a failure — the turns route finds no listing for it and says the workspace has nothing readable, which is exactly the state it is in.

stop_diagram(workspace_id: str, request: DiagramStopRequest) -> DiagramDocumentView

Fence an active collaboration and retain the document as read-only.

subscribe(callback: Callable[[WorkspaceEvent], None]) -> Callable[[], None]

Register a sync callback. Returns an unsubscribe handle.

switch_model(workspace_id: str, model: str) -> None

Switch the running session's model where the agent exposes a switch control.

claude_code/codex expose it as the interactive /model <id> slash command, delivered through the same steer path as :meth:invoke_control (the provider boundary — the command shape is the tool's, the id forwarded verbatim, never interpreted). A kind with no model-switch channel (a bare shell, a remote session whose model is fixed at create) raises CapabilityUnavailable.

transcript_config_dir_scope(kind: str, config_dir: str | None) -> contextlib.AbstractContextManager[None] staticmethod

Apply kind's root override for one adapter read.

Filesystem adapters resolve their roots from the task-local helper before consulting os.environ. This preserves the established signature and None no-op behavior without process-global mutation: executor workers reading different workspace profiles cannot cross-read each other's transcripts. An empty string remains an explicit clearing override, matching the existing scope contract.

transcript_scope(state: WorkspaceState) -> contextlib.AbstractContextManager[None]

THE scope every adapter read for state must run inside.

Wrap each adapter call that resolves a transcript, a rollout, or a config-dir-derived surface in this. The workspace's agent may have been launched under a pinned CLAUDE_CONFIG_DIR/CODEX_HOME that this process — the daemon, the TUI — does not share, and every filesystem adapter resolves that env ambiently at call time. An unscoped read therefore searches the reader's own profile and finds nothing.

Resolves the kind itself rather than taking one, so no caller can scope a read with a kind that disagrees with the workspace's own (effective_kind is already the single answer to "which adapter is this workspace's"). Pass the state; there is nothing else to get wrong.

Returns a context manager instead of being one so the unpinned case — no transcript_context, the overwhelming majority — is a bare nullcontext: the hot poll path allocates nothing else and behaves byte-for-byte as it did before any of this existed. The env it mutates is process-global, so hold the returned scope around the adapter call ONLY, never across a whole request or a whole workspace.

Why this is a public seam rather than four inline with blocks: the invariant was independently forgotten at four separate read sites (the fleet drill-in 404, an empty todo posted to a Gitea issue, the wrong profile's slash commands, a blank current_model). Four bespoke blocks would distribute the same forgettable rule four more ways; one named seam gives it a home a new read path can find.

update(workspace_id: str, *, title: str | _Unset = _UNSET, description: str | None | _Unset = _UNSET, share: bool | _Unset = _UNSET, share_ttl_seconds: int | None | _Unset = _UNSET, share_session_id: str | _Unset = _UNSET) -> WorkspaceState

Rename the title, set/clear the description, or share the workspace.

Metadata-only — never touches the worktree, the tmux session, or the branch. Title is the slug seed for the worktree path and tmux session name at create time; both are persisted strings after that and renaming the title does NOT rebuild them. The user keeps the on-disk worktree dir and the live tmux session they already have; only the displayed title changes.

Sentinel semantics: _UNSET (the default) means "leave alone". title="..." sets a new title (must be 1..120 chars after stripping). description=None or description="" clears it (stored as None — empty string and None are equivalent and we normalize on write). description="..." sets it.

share=True mints a public capability token (and share=False clears it), which is what makes the workspace readable through the daemon's unauthenticated /public namespace. It lives here rather than on a share() method of its own because this method already owns all four things such a method would need: the ORPHANED gate, the read-modify-write against persisted state, the no-op short-circuit, and the updated event. A second copy of those is how two mutation paths drift.

Enabling is IDEMPOTENT — an already-shared workspace keeps the token it has, so re-enabling never invalidates a link somebody is already holding. Disabling clears it outright, which permanently kills that link; a later re-share mints a fresh one rather than resurrecting it.

Minting a token also PINS the transcript the link will show, to the workspace's session as resolved at that moment (:meth:_todo_session_id). A link is a capability handed to somebody who cannot see this record, so what it renders must not change identity under them — and derived at read time it does, three ways: a respawn mints a new session id, adoption can promote a different one, and under ROOT placement the scan cwd is the shared repo root holding every other workspace's transcript. Revoking clears the pin with the token.

share_session_id RE-PINS explicitly, and it is the only way to move a live link: enabling is idempotent, so re-sharing an already-shared workspace cannot re-pin by itself, and that silence is deliberate — clicking "share" twice must not quietly change what a circulated URL shows. It resolves through :meth:_resolve_session_ref (unique prefix, kind-checked, no birth-gate) BEFORE any write, exactly like remap_session, and requires share=True because a pin without a link is a claim about nothing.

Refuses if every arg is unset (nothing to do) and if the workspace is ORPHANED (worktree gone; record headed for kill). Emits an "updated" event with title_changed / description_changed / share_changed / share_session_changed flags so subscribers know what shifted without diffing themselves. The event carries only the FLAGS, never the token — an event bus fans out to subscribers with no business holding a credential. The pinned session id is not a credential and rides the ordinary state view, but it is still not on the event: a subscriber that needs it re-reads the record, the same rule share_changed follows.

update_diagram(workspace_id: str, request: DiagramUpdateRequest) -> DiagramDocumentView

Replace the active document only when its generation and bytes match.

working_diff(workspace_id: str, *, path: str | None = None, max_bytes: int = WORKING_DIFF_MAX_BYTES) -> WorkspaceDiff

Every file this workspace has changed since it was created.

Scope is the worktree against the recorded creation anchor (WorkspaceState.base_commit), INCLUDING untracked files — so a file the agent committed an hour ago is still in the answer. Anchoring on HEAD instead made the patch go blank the moment the agent committed, which is precisely when a reviewer wants it, and it is the only axis a ROOT workspace has: there the branch IS the base branch, so the committed-vs-base stats collapse to zero.

The anchor is a fact recorded once, never a range re-derived here — the base branch moving on afterwards must not change what this workspace is credited with. A record written before the anchor existed falls back to HEAD, i.e. exactly the historical uncommitted-only patch; that is the honest degradation, not a fabricated baseline. This is why the patch's file count no longer matches peek's dirty_files once anything has been committed: that counter deliberately stays the uncommitted churn signal the activity stream fingerprints.

Untracked files are appended as individual all-additions patches because git diff omits them entirely (see untracked_patch for why the index is never touched to get them).

Bounded by max_bytes, cut at a whole-file boundary so the result is always a parseable patch, with truncated saying so. A caller wanting one file passes path — the per-file form is what lets a client read a big tree one file at a time instead of raising the cap.

Never raises: an unreadable worktree is available=False with a reason, which is a materially different answer from an empty patch.

grove.core.WorkspaceEvent dataclass

Lifecycle notification for clients. Pull-based render still preferred — treat events as wake-ups, then re-call list() for state.

grove.core.build(repo_root: Path | None = None, *, cli_overrides: dict[str, object] | None = None, store: JsonWorkspaceStore | None = None) -> WorkspaceManager

Build a manager bound to repo_root (or the cwd's repo if not given).

With no repo_root, binds to the cwd repo's MAIN worktree root — the key the workspace store uses. From inside a linked worktree, detect_root returns that worktree's own root, and a manager keyed by it would list zero workspaces. The rule lives here so every cwd-bound caller (CLI verbs, grove ls, the TUI entry) inherits it.

Multi-repo and activity

grove.core.RepoRegistry

Lazy WorkspaceManager cache keyed by canonical repo_root.

get(repo_root: Path) -> WorkspaceManager

Return (or create) a Manager for repo_root.

repo_root is canonicalized via Path.resolve() so distinct symlink paths to the same repo collapse to a single Manager. The Manager's config is resolved once per repo via config_loader (or the shared cfg when none was injected) and cached with it.

known_projects() -> list[Project]

Listable projects, by union of two sources — the listing seam.

Where known_roots() answers "which repos exist" (the Manager-dispatch + repo-validation seam), this answers "which projects should a client offer", which is a superset: a single repo can expose several nested subdirectory projects. The deduped (by cwd) union of:

  • store-derived repo roots, each as a repo-level Project (cwd == repo_root) — the empty-project-visibility arm carried forward;
  • config-declared projects, each Project(repo_root=<enclosing repo>, cwd=<declared path>) so a declared subdirectory lists distinctly while still anchoring its worktrees at the true repo root.

Reads fresh from the store each call (new repos appear without a restart), the same contract known_roots() holds.

known_roots() -> list[Path]

Repos that exist, by union of two sources.

The deduped union (Path.resolve() collapses symlinks) of: store-derived roots (every repo with ≥1 persisted workspace) and config-declared roots (cfg.projects). The config arm is what keeps an empty project visible — a freshly-added repo, or one whose workspaces were all killed, has no store row but stays a known project.

This is the single place that decides "which repos exist", so every downstream consumer (GET /workspaces, ActivityService, the pickers) inherits empty-project visibility with no further change. Reads fresh from the store each call so newly created repos appear without a restart.

resolve_share(token: str) -> tuple[WorkspaceManager, WorkspaceState] | None

The workspace a public share token names, or None.

The ONE place a token becomes an identity, which is why it belongs here rather than on a Manager: a token is host-wide by construction (the public reader is handed a bare string and has no repo to scope it with), so resolution has to happen above the per-repo cache.

It reads the STORE directly rather than walking known_roots() and asking each Manager. One store read answers for every repo, where the walk would resolve every project's whole config cascade — and instantiate a Manager per repo — on a request that is unauthenticated by design. Only the single matching repo's Manager is then materialized, through the ordinary cache.

None covers every failure identically: no such token, an expired token, a token belonging to a workspace that has since been unshared, or an empty string. A caller must not distinguish them; the whole point of the capability is that a wrong guess learns nothing.

resolve_workspace(ref: str) -> tuple[WorkspaceManager, WorkspaceState]

The workspace ref names, and a Manager bound to ITS repo.

A workspace id is unique across the host, and the repo a workspace belongs to is recorded on the workspace — so naming one is enough to reach it, and asking the caller to also be standing in the right directory is a precondition the operation never had. Every id-addressed verb (attach, message, pause, kill, …) resolves here, which is why they work from anywhere: the record supplies the repo the Manager binds to.

Same shape and same reason as :meth:resolve_share — one store read answers for every repo, and only the single matching repo's Manager is materialized, through the ordinary cache. Walking known_roots() instead would resolve every project's whole config cascade to answer a question one record already answers.

Raises GroveError naming the candidates when the ref matches nothing or several. Ambiguity is judged HOST-wide, so a prefix that was unique inside one repo can now be ambiguous — which is the honest answer for a ref that no longer carries a repo to disambiguate it.

shared_in(repo_root: Path) -> list[WorkspaceState]

Every publicly-shared workspace in one repo, newest first.

What the public view's rail lists. Scoped by repo_root — the repo, not the nested Project — because "the same project" to somebody reading a shared link means the codebase, and a security-adjacent path is the wrong place to introduce a second notion of project identity.

The caller is trusted to already hold a token for ONE of these, and the consequence is deliberate and stated on the wire contract: sharing a workspace makes it discoverable from every other shared workspace in its repo. A private workspace never appears here.

subscribe_managers(callback: Callable[[Path, WorkspaceManager], None]) -> Callable[[], None]

Observe existing and newly materialized managers without rescanning readers.

workspace_states() -> list[WorkspaceState]

Every persisted workspace without status reconciliation or Manager setup.

Host-wide read indexes use this when they need workspace identity to annotate one filesystem edge. WorkspaceManager.list() additionally reaches tmux and lifecycle state, which is neither needed nor affordable for a path-local catalog update. The store supplies independent values from its signature-indexed snapshot.

grove.core.ActivityService

Aggregates workspaces, sessions, and activity across every repo.

Holds no workspace state of its own — it reads through the RepoRegistry and the agent adapters on demand. The only state it keeps is the subscriber list, the per-workspace change fingerprints (for poll_once delta detection), a monotonic seq, and a small GitRepo cache.

The timer lives at the edge: the daemon and the TUI own the tick and call poll_once(); the service keeps time-of-day out of its core, exactly like the manager keeps side effects at the boundary.

apply_reconcile(snapshot: DashboardSnapshot, *, emit: bool) -> None

Publish a prepared snapshot and report every row changed by recovery.

Collection runs off-loop. This owner-side half keeps persistence and event publication fenced to a live runtime generation, and makes a lost source edge visible to already-connected SSE clients rather than only to a later full snapshot reader.

apply_workspace_refresh(repo_root: str, workspace_id: str, row: WorkspaceActivity | None) -> None

Publish the owner-approved result of one prepared workspace read.

bootstrap() -> DashboardSnapshot

Build initial state once before accepting source events or readers.

close() -> None

Tear down all manager-bus bridges and subscribers (daemon shutdown).

next_seq() -> int

Next monotonic event id. Public so the daemon's SSE layer stamps its snapshot/heartbeat frames from the same sequence as the deltas, keeping Last-Event-ID replay coherent across both.

poll_once() -> None

Compatibility entry for explicit reconciliation, not recurring discovery.

Delegates to apply_reconcile(emit=True) rather than diffing itself. The earlier shape snapshotted _rows, called reconcile() — which REPLACES _rows on its way through apply_reconcile — and then compared against a baseline that had already been overwritten, so it emitted nothing and every notification trigger driven by an explicit poll went silent. apply_reconcile already owns the diff, the removal deltas and the cursor, so the second copy could only ever be the wrong one.

prepare_reconcile() -> DashboardSnapshot

Read the authoritative fleet snapshot without mutating the projection.

prepare_workspace_refresh(repo_root: str, workspace_id: str, *, domains: RefreshDomain = RefreshDomain.FULL) -> WorkspaceActivity | None

Read only invalidated parts of one maintained workspace row.

reconciled rather than get keeps the displayed status on every path. Unknown/lifecycle recovery keeps the default full refresh; an edge-specific caller reuses all retained immutable facts it did not invalidate.

reconcile() -> DashboardSnapshot

Synchronously recover the projection for non-runtime callers.

refresh_workspace(repo_root: str, workspace_id: str) -> None

Synchronously refresh one key for non-runtime callers.

sessions_for(mgr: WorkspaceManager, state: WorkspaceState) -> list[SessionActivity]

The workspace's agent session(s) with blended activity.

Public seam with two consumers — the daemon's poll (via _workspace_activity) and the TUI list screen's slow tick — so the blend + hook-sidecar policy stays in this single site.

Discovery is a read-only fs glob over state.transcript_scan_cwdsagent_cwd (worktree/subpath, where the agent runs) unioned with the worktree root (a session hand-started at the repo root of a nested project records its cwd), led by the transcript context's own recorded cwd when the workspace has one. It always runs, adapter-gated only (generic/shell discover nothing): cfg.hooks.enabled gates the sidecar push, never read-only discovery.

Every adapter read below is wrapped in mgr.transcript_scope(state) — an agent launched under a pinned CLAUDE_CONFIG_DIR/CODEX_HOME wrote its transcript where this process's ambient env does not point, and an unscoped read finds nothing and pins the card at STARTING. That is the manager's shared seam, not a local copy: this file once kept its own two-line version, which is exactly how the same invariant came to be forgotten at four other read sites.

Two paths, by whether Grove minted a deterministic id at create:

  • Minted id present (the happy path): that grove_launched session is the primary; only the discovered sessions this workspace adopts ride along as fs_discovered extras. Candidates are pre-filtered on CHEAP head metadata (birth) + the sidecar BEFORE any full parse (#F5), so per-tick cost is O(new sessions), not O(history) — a historical transcript predating the workspace is excluded from the card entirely (the "adopt only what's ours; the rest is noise" precedent). Exception: a minted id that is a dead pointer yields the primary slot to the newest adopted session; the minted entry rides behind and reclaims primary the moment it materializes.
  • No minted id — a workspace whose agent wasn't kind="claude_code" at create, one created before minting existed, or a purely hand-started run. With no minted session there is no reference pane, so adoption is birth-only (#F1); adopt the single most-recent session that passes. Nothing adopted (every candidate predates this workspace, e.g. a reused ROOT cwd) → honestly sessionless.

Adoption weighs transcript birth AND a hook sidecar proving the session was live in this workspace's own PANE after creation (ClaudeHook.adopts) — the sidecar arm lets a session resumed inside the pane (born before the workspace) be adopted, and the pane check keeps a shared cwd (ROOT placement) from adopting another live workspace's session (#F1).

snapshot() -> DashboardSnapshot

Read the maintained projection, bootstrapping it if nobody has yet.

ORDINARILY THIS IS A PURE READ: the runtime owner bootstraps at startup and every later change arrives as an event, so the projection is already there and this takes the lock and returns. The fallback exists because the alternative for an unbootstrapped projection is answering with an EMPTY FLEET — indistinguishable on the wire from a host that genuinely has no workspaces, which is the one answer a maintained projection must never invent.

Building it here rather than blocking startup is what keeps daemon readiness independent of fleet size: /healthz reads no projection, and a caller that does read one is the caller that should wait for it. bootstrap is idempotent under its own lock, so concurrent first readers do one scan between them, not one each.

snapshot_with_cursor() -> tuple[DashboardSnapshot, int]

Capture state and its applied watermark under the same publication lock.

THE BOOTSTRAP HAPPENS BEFORE THE LOCK, NEVER INSIDE IT. snapshot may now build the projection on demand, and building it takes _bootstrap_lock and then _projection_lock (through apply_reconcile). Calling it while already holding _projection_lock inverted that order, and two threads — one bootstrapping, one reading a cursor — deadlocked the whole daemon: every request including /healthz hung, because the loop thread was among them.

Bootstrapping first costs nothing in the steady state (the projection exists, so it is one uncontended lock round-trip) and the pair stays atomic, because the snapshot and the sequence are still read together under one acquisition below.

source_changed(repo_root: str, workspace_id: str) -> None

Invalidate content queries even when activity counters stayed equal.

subscribe(callback: Callable[[DashboardDelta], None]) -> Callable[[], None]

Register a delta callback. Returns an unsubscribe handle (idempotent).

workspace_row(repo_root: str, workspace_id: str) -> WorkspaceActivity | None

One workspace's activity row, without ever scanning the fleet.

The per-request seam behind GET /workspaces/{id}/activity. It exists because a page about ONE workspace was reading its session id off snapshot(), and snapshot BOOTSTRAPS when no projection exists yet — so the first reader after a daemon start paid for every workspace on the host before the page could paint. Measured on the reference host: a 40.6 s bootstrap, 22.4 s of it spent on two OFFLINE workspaces whose transcripts that page never renders.

Two paths, and which one runs is the whole design:

  • The projection already holds this row — the ordinary case, because the ~1 Hz poll maintains it. Then this is a dict lookup under the existing lock and costs nothing (measured: the maintained fleet read answers in ~2 ms for exactly this reason). The row is the same object the stream publishes, so a page cannot disagree with its own stream.
  • It does not (no projection yet, or a workspace created since the last tick). Then exactly ONE workspace is computed. This is the path that used to be a fleet bootstrap, and the whole point is that a page about one workspace now waits for one workspace.

Deliberately neither bootstraps nor publishes: a request must not build the shared projection (that is the poll's job, and the cost this exists to avoid) and must not advance a sequence the stream's consumers order on. None for an unknown workspace, so the route owns the 404.

grove.core.DashboardSnapshot dataclass

The whole cross-project picture for one render.

Sessions

grove.core.SessionExplorer

Aggregate, filter, and resolve agent sessions across a project's worktrees.

candidates_for(workspace_id: str) -> tuple[SessionListing, ...]

Every session recorded in one workspace's directories, newest-first, UNGATED — the remap-picker seam.

The ungated sibling of :meth:for_workspace: the same bounded one-cwd scan (cheap per request), but applying NO adopts_session birth/pane gate. So a session the auto-adoption heuristic rejects — a dead-minted-pointer's live successor born before the workspace, or a foreign session sharing a ROOT cwd — is still offered. This is exactly the set a human picks from to remap: the operator supplies the attribution the gate withholds (and manager.remap_session, the write it feeds, is likewise ungated). Provenance is still grove_launched for the minted id, fs_discovered otherwise. Raises :class:~grove.core.errors.WorkspaceNotFound for an unknown id.

A browse-everything counterpart to the ungated project-wide :meth:list, but scoped to one workspace's scan_cwds — so a picker pays one directory's parse, not the whole project's. Returns a tuple (the list-method shadowing trap, as in :meth:for_workspace).

enriched(listing: SessionListing) -> SessionListing

One row re-read by IDENTITY so its parse products are populated.

session_summary is the adapter seam that may full-parse; it resolves one known id through locate_transcripts rather than scanning, so this costs one parse per DISPLAYED row. Best-effort by contract: a row that cannot be re-read keeps its metadata-only form rather than raising out of a listing.

fleet_activity(workspace_id: str, session_id: str | None = None) -> tuple[str | None, bool, tuple[SessionActivity, ...]]

The selected root's child-session projection for the fleet reader.

A selected root is the explicit session_id when present, otherwise the workspace's own minted session — read directly off the record rather than through :meth:primary_for_workspace, which requires a materialized transcript and would misreport an honestly-supported root as unsupported during the STARTING window before its first transcript line lands (the same window the live hook roster already renders through). An explicit session_id must belong to this workspace's own scan — never a stranger's — so it is checked against :meth:for_workspace and refused (unsupported, no children) rather than resolved against a foreign workspace's cwd.

The adapter capability decides whether an empty tuple means "no children"; an adapter lacking it is honestly unsupported. This is a targeted, on-demand read, never an activity poll.

for_workspace(workspace_id: str, *, limit: int | None = None) -> tuple[SessionListing, ...]

Every session recorded for one workspace's directory, newest-first.

The bounded variant of :meth:list for per-request consumers (the daemon's GET /workspaces/{id}/sessions): scans only the workspace's own cwd instead of every worktree, so the transcript-parse cost stays one directory regardless of project size. Raises :class:~grove.core.errors.WorkspaceNotFound for an unknown id.

Scans the union of state.agent_cwd (worktree/subpath — where a nested project's agent runs) and the worktree root (a session hand-started at the repo root records its cwd, #F7), deduped for a flat workspace. Both are where filesystem adapters exact-match a transcript's recorded cwd.

A discovered (fs_discovered) listing is kept only when state.adopts_session accepts it — its transcript birth postdates this workspace's created_at, OR a hook sidecar proves it was live in this workspace's own PANE after creation (the same pane-verified evidence rule ActivityService.sessions_for uses, so a session resumed in the pane — born before the workspace — still attributes here, while a shared cwd can't steal another workspace's live session, #F1). Without the gate, a fresh workspace whose cwd already holds older transcripts (especially ROOT placement, whose cwd is the shared repo root) would present a stale, unrelated session as its own. A grove_launched listing is never gated — Grove minted it for this workspace regardless of birth. :meth:list and the project-scoped listing stay ungated by design: those are browse-everything history views, not workspace attribution.

Returns a tuple — in this class body a list[...] annotation would resolve to the :meth:list method, not the builtin (the documented mypy shadowing trap).

from_cwd(cwd: Path) -> SessionExplorer classmethod

Build an explorer for the project enclosing cwd.

Works from inside any worktree: the main worktree (first entry of git worktree list) is the root the workspace store is keyed by, so the explorer always binds its manager there — binding to the linked worktree's own root would find zero workspaces.

list(*, agent: str | None = None, workspace: str | None = None, since: datetime | None = None, limit: int | None = None, enrich: bool = False) -> list[SessionListing]

Every session across the project, newest-first, optionally filtered.

agent matches the adapter kind exactly; workspace matches a workspace id prefix or a case-insensitive title substring; since keeps sessions modified at/after that instant; limit caps the result after sorting.

enrich is the OPT-IN full parse, for the one consumer that renders parse products (the grove sessions table). It runs AFTER filtering and limiting, so the cost is one identity-keyed read per DISPLAYED row rather than one per transcript sharing the directory.

primary_for_workspace(workspace_id: str) -> SessionListing

The readable primary session for one workspace.

The recorded session id wins when it has materialized. Otherwise a Claude id rotated by /clear or a Codex workspace with no mint falls back to the newest adoption-gated session in the workspace's own scan. This is a request-time reader, not an activity-poll resolution: it may read the bounded transcript listing because an explicit recollection request earns that cost.

queries_from_messages(messages: Sequence[AgentMessage]) -> tuple[SessionQuery, ...] staticmethod

Project direct human messages from an adapter-filtered message spine.

The adapter has already applied its provider-specific real-turn filter while assigning the user role; this generic projection deliberately makes no second classification judgement.

recollect(ref: str, *, last: int | None = None) -> tuple[SessionQuery, ...]

Every direct user query in the uniquely resolved session, oldest first.

recollect_for(listing: SessionListing, *, last: int | None = None) -> tuple[SessionQuery, ...]

Every direct user query in one session, oldest first.

Reads the complete normalized message spine before applying last: a user query can follow any amount of agent work, and compaction only changes the agent's context, never the transcript. The adapter's existing real-turn filter is authoritative — it excludes provider and harness machinery while admitting human slash commands and messages delivered while the agent was busy. A slash command is included because it is an explicit direct instruction from the user, not an echo.

resolve(ref: str) -> SessionListing

The unique session whose id matches ref exactly or by prefix.

Raises :class:GroveError when nothing matches or the prefix is ambiguous (the message lists the candidates, so the user can extend the prefix without re-running list).

scan_roots() -> list[Path]

Every directory whose sessions belong to this project, de-duplicated.

Union of the live git worktree list (main first; covers hand-made worktrees Grove never managed), every workspace's persisted agent_cwd (worktree/subpath — where a nested project's agent actually runs and records its transcript's cwd), its worktree_path (covers paused workspaces whose directory is gone — their transcripts still live under the encoded-cwd projects folder), and, for a workspace with a transcript_context override, the recorded cwd it names instead — a container-launched agent's real cwd, which can never equal either host path. agent_cwd collapses to the worktree root for the common empty-subpath case, so the extra entries only matter for nested or overridden projects.

subagent_turns(workspace_id: str, thread_id: str, *, last: int | None = None) -> tuple[SessionListing, tuple[SessionTurn, ...]] | None

The resolution fallback for a fleet-child thread_id — one that for_workspace never lists.

A fleet row's session_id IS the Claude sub-agent thread id (agentId), and discover_paths deliberately skips subagents/ — so it never appears in any workspace's own session listing, and a direct id lookup always misses. Kind-scoped to claude_code exactly like ActivityService._fleet_entries (a direct :class:ClaudeCodeAdapter instantiation, never a widened AgentAdapter Protocol for one kind's capability — the in-session sidechain fleet is a Claude Code transcript concept). Tries thread_id against each of the workspace's own top-level sessions (its own :meth:for_workspace listing) via the adapter's subagent_turns/fleet_activity projections — both already read off the same memoized spine, so this pays no second parser — and returns the first match's turns plus a SessionListing synthesized from that SAME per-thread identity (title/current_task degrade exactly as fleet_activity already does: the .meta.json sidecar, else the truncated first task prompt). None when thread_id belongs to none of them, or the workspace isn't claude_code.

tool_call(workspace_id: str, session_id: str, tool_use_id: str) -> ToolCall | None

One of a workspace session's tool calls, resolved the way its turns are.

The listing whose id matches answers first. A session_id that names no listing is the fleet-child case the turns route already handles (subagent_turns): a sub-agent thread never appears in a workspace's own listing, but its records ride its PARENT session's spine, so the fallback searches the workspace's top-level sessions for the id. The search is by tool_use_id, which is unique across the spine, so the widened scan can only find the call the caller named.

tool_call_for(listing: SessionListing, tool_use_id: str) -> ToolCall | None

ONE tool call out of an already-resolved session, by its id.

The drill-in half of the head+drill-in pairing the windowed /turns read introduced: a turn list that withheld settled bodies (ToolCallView.body == "available") is the head, and this serves any one of those bodies whole. Built as a PROJECTION over :meth:AgentAdapter.read_messages — the same spine every other projection here reads, memoized per transcript — so an opened body costs a walk rather than a parse once the turn list is warm.

Deliberately a sibling of :meth:recollect_for rather than a new adapter capability: "which call carries this id" is answered identically for every provider off the normalized spine, so a per-adapter method would be one implementation duplicated per kind. Scoped to the owning workspace's config-dir override for the same reason :meth:turns_for is.

transcripts(listing: SessionListing) -> tuple[Path, ...]

Every transcript file for the session — main thread first, then sub-agent files — via the owning adapter's locator. Empty for a remote-backed session (no local files).

Scoped to the owning workspace's transcript_context.config_dir override, if any — _session_cwd already resolves to the session's own recorded cwd (a container path, when relevant), so only the adapter's config-dir env needs redirecting to find that host directory at all.

turns(ref: str, *, last: int | None = None) -> tuple[SessionTurn, ...]

The normalized conversation for the session matching ref.

turns_for(listing: SessionListing, *, last: int | None = None) -> tuple[SessionTurn, ...]

The normalized conversation for an already-resolved listing.

Split from :meth:turns so a caller holding a listing (the daemon's turns endpoint, fed by :meth:for_workspace) skips the full-project :meth:resolve scan.

Scoped to the owning workspace's config-dir override, if any — same reasoning as :meth:transcripts.

grove.core.SessionListing dataclass

One session row with its project context attached.

workspace_* fields are None for a session found in a directory Grove doesn't manage (a hand-made worktree, or the repo root with no ROOT workspace). provenance is grove_launched only when the id matches a workspace's minted agent_session_id.

duration is the wall clock and compute total (see :func:~grove.core.session_duration.duration_of) looked up from the SAME durable parse-fact cache the host catalog uses. None means not measured yet — never no work, which is a DurationView whose own fields are null. Listing requests never full-parse a transcript to fill this column.

Workspace state

grove.core.WorkspaceState dataclass

Persisted runtime record for one workspace.

agent_cwd: Path property

Absolute directory the agent session runs in: worktree / subpath.

The single source of truth for "where the agent starts", reused by every session-(re)creation path (resume/respawn) so they can't drift on the nested-cwd rule. project_subpath == "" collapses to the worktree root — the historical behavior.

diff_base: str property

The revision the COMMITTED "since created" reads measure from.

One definition for the three callers that share the question — the commit log, the line stats on the peek rail, and the same stats on the activity stream — so they cannot drift on which anchor they used.

The recorded base_commit when there is one, because "since the workspace was created" is anchored in TIME: the base branch moving on afterwards must not change what this workspace is credited with, and a range derived from a live branch name silently re-answers the question on every read. The fallback to base_branch is the honest degradation for a record written before the anchor existed — the historical behavior exactly, including its root-placement blind spot.

Deliberately NOT read by ahead_behind: "behind" asks how far the base BRANCH has moved since, which a frozen commit can only ever answer zero.

grove_owns_branch: bool property

Grove created this branch, so a Grove teardown may delete it.

The single definition of "whose branch is this", read by kill() (as the default for its delete_branch flag, which an explicit caller may still override) and by _rollback_create (as a hard gate — a rollback the user never asked for has no override). The rule lived only in kill once: rollback force-deleted whatever branch the record named, so a user who attached their own feature/x and hit a failing init script lost it to a create that never completed — the same damage an unvalidated branch name does, arriving through teardown instead of argv. ROOT placement is never Grove's: the branch there is the live checkout the workspace adopted.

init_env: dict[str, str] property

The GROVE_* variables an init script runs with.

Derived, not stored — and it replaces a persisted field of the same name that had neither producer nor consumer, round-tripping empty through every save. All four values are already on this record, so storing them was caching facts we hold, with a way to be wrong that the derivation does not have: a stored env survives a branch rename or a moved worktree and then reports the old one. A property cannot go stale.

A property rather than a builder at the call sites for the reason the asymmetry existed at all: create composed these inline while resume and respawn passed nothing, so $GROVE_BRANCH was silently empty on every run_on_resume run. One derivation on the record that owns the facts leaves no call site able to forget.

runtime_no_tmux: bool property

This container has no in-container tmux — the agent runs bare.

Derived, not stored — the fact already lives on container.tmux_command (empty string: the "no bundle for this arch and the image ships none" degradation). Storing a second field would let it drift from the one that produces it; a property cannot. runtime is HOST (including a fallback workspace) always reads False here — this is a CONTAINER-mode fact, distinct from runtime_fallback_reason (container unavailable at all) and runtime_default_config (no project devcontainer.json): a container can come up fine, on Grove's own packaged config or the project's, and still ship no tmux to hold the agent past a client detach.

scan_cwds: tuple[Path, ...] property

The cwds whose transcripts/sidecars may belong to this workspace (#F7).

The union of agent_cwd (worktree/subpath — where the agent is configured to run, and where a nested project's transcripts record their cwd) and the worktree ROOT (worktree_path — where a session hand-started at the repo/worktree root records its cwd). Re-keying discovery from the worktree root to agent_cwd alone silently dropped that root-recorded session for a nested project; scanning both recovers it. Deduped when the subpath is empty (the flat-workspace common case), so the second entry only exists for a genuinely nested project. agent_cwd first — the primary project cwd; callers that need newest-first across the union re-sort by mtime.

telemetry_session_id: str property

The id every emitter must key this run's traces by.

Grove and the agent trace independently and never share a trace id, so a Langfuse session is the only thing that reassembles one run — and it reassembles only what agrees on this value. The agent's half is stamped into OTEL_RESOURCE_ATTRIBUTES at launch and frozen there for the life of the process, so this is what that stamp said, not a better answer learned later: a spine that keys by anything else publishes a second, half-empty session beside the real one.

Prefer the harness's own session id, which is what its native exporters and its transcript exporter already use (claude_code takes a --session-id Grove mints, so all three agree). Fall back to the tmux session for a harness that mints its own id and offers no way to supply one (codex), where Grove's own identity is the only value that exists at the moment the stamp has to be written.

transcript_scan_cwds: tuple[Path, ...] property

The cwd(s) a transcript read should scan for this workspace.

The context's recorded cwd (first — it is the most specific answer) UNIONED with the ordinary scan_cwds, deduped in order. No override (the default) is byte-for-byte scan_cwds.

A deliberate widening of the original behavior, which had the override REPLACE the union on the reasoning that a container-recorded cwd can never equal a host path. That held while only a hypothetical container launch would set a context; an ordinary HOST launch now sets one too, and for a nested project (project_subpath) replacement would narrow the scan from {agent_cwd, worktree_root} to one entry — silently dropping the root-recorded session the union arm above exists to recover. The union cannot lose data in the container case either: a host path that holds no matching transcript simply globs empty, so the extra entry costs one empty scan, never a wrong answer.

adopts_session(born_at: datetime | None, *, live_here_at: datetime | None = None) -> bool

Whether a discovered (non-minted) session belongs to this workspace.

Two independent kinds of evidence, either sufficient — each measured against this workspace's own created_at (a session is ours only if it was alive here at/after we came into being):

  • born_at — the session's transcript BIRTH (first-record timestamp). Immutable, so a stale file merely being touched or re-read can't fake it. A workspace's cwd can hold transcripts written before it existed — most commonly ROOT placement, whose cwd is the shared repo root — and treating "newest transcript in the cwd" as "our session" (the pre-fix bug) presented a stale, unrelated conversation as a brand-new workspace's own. Birth, not recency, is the correct test.
  • live_here_at — the timestamp of a hook sidecar proving the session was live in this workspace (the caller attributes it by cwd/pane at the boundary; this predicate stays pure and never reads a sidecar). A session the user RESUMED inside this workspace's pane is born earlier than the workspace, so birth can never adopt it — but its post-create sidecar ts does. Birth alone silently drops every resumed session; the >= created_at guard still rejects a previous tenant of a reused cwd, whose sidecar predates this workspace.

Used by both discovery-adoption sites (ActivityService.sessions_for, SessionExplorer.for_workspace); a session Grove itself minted and launched never calls this — it is unconditionally ours, born or not.

Both timestamps are optional (unknown birth, no sidecar) and never adopt when absent — unproven evidence can't be shown to postdate creation. A tz-naive value is coerced to UTC defensively so a malformed timestamp degrades the comparison rather than raising on the poll path (the peek/best-effort discipline).

grove.core.WorkspaceStatus

Bases: StrEnum

Workspace status — split into persistent intents and computed views.

Three values are persistent intents: lifecycle methods write them to JSON and JsonWorkspaceStore rejects writes of any other value (defense in depth). The remaining four are derived at read time by WorkspaceManager._reconcile_status from the persistent intent + tmux session presence + worktree presence + tmux pane activity. list() and peek() always promote intents to displayed values, so callers reading through the manager see ACTIVE/IDLE/OFFLINE/PAUSED/ORPHANED/PROVISIONING/ ERROR — never the raw RUNNING intent.

PROVISIONING earns its place by CHANGING THE REMEDY, which is the bar this enum is held to. A container workspace is persisted the moment create starts, minutes before devcontainer up returns, and for that whole window the container legitimately does not exist yet — so the container dimension folded it onto OFFLINE, whose meaning is the runtime is gone, respawn is the remedy. Every word of that is wrong here: nothing is gone, and respawn is the one action that destroys the build in flight. The user is shown a dead-looking workspace and offered the button that kills it. The remedy for PROVISIONING is to WAIT, which no other status says, and it is the only status whose whole point is that it will end on its own.

grove.core.WorkspacePeek dataclass

Rich snapshot for the selected workspace, recomputed on demand.

Pure data. The TUI calls WorkspaceManager.peek(id) whenever it wants a fresh frame for the rail; nothing here is cached, polled, or animated. Failures in the underlying helpers degrade to zeros / empty rather than raise — peek must never break the render loop.

grove.core.Placement

Bases: StrEnum

Where a workspace's tmux session is rooted, and what Grove manages for it.

The dimension orthogonal to status: it never changes after create() and decides which side effects each lifecycle method may fire. Lives here (not in contracts/branch_plan.py) because branch_plan imports from this module; the enum has to sit on the depended-upon side to avoid a cycle. RootBranch.resolve() is the only producer of ROOT.

ROOT = 'root' class-attribute instance-attribute

The session runs in the repo root itself — no dedicated worktree, no Grove-created branch; it adopts whatever HEAD is checked out. Grove manages only the tmux session, so worktree add/remove and branch delete are all skipped, and pause/resume are refused (there is no worktree to free or rebuild). Recover a vanished session with respawn; stop it with kill.

WORKTREE = 'worktree' class-attribute instance-attribute

The default and historical shape: a dedicated git worktree under ${repo}/.worktrees, removable/recreatable, with its own branch. Every worktree git side effect (add on create/resume, remove on pause/kill) runs.

grove.core.BranchProvenance

Bases: StrEnum

Whether Grove created this workspace's branch or the user attached one.

Drives the kill-confirm default and the rollback-on-create-failure policy. Persisted on every WorkspaceState. The principle is that Grove manages the worktree always; the branch is the user's domain when they attached it, and Grove's only when Grove created it. The remote is never touched in either case — that is git push --delete territory and stays in the user's shell.

GROVE_CREATED = 'grove' class-attribute instance-attribute

Grove created the branch — Auto / NewNamed / TrackRemote (the local tracking side of the latter is fresh too). Default-delete on kill.

USER_ATTACHED = 'attached' class-attribute instance-attribute

User pointed Grove at a pre-existing local branch (ExistingLocal), or a root workspace adopting the live checkout. Default-keep on kill so a real feature branch is never lost to a tear-down.

grove.core.InitStatus

Bases: StrEnum

Outcome of the init script for one workspace, persisted on WorkspaceState.

grove.core.CommitSummary dataclass

One commit row, as the peek pane wants to render it.

committed_at is a timezone-aware datetime; humanizing to "2 minutes ago" is the client's job — keeping policy out of the engine.

scope says which question the list this row came from answered; it describes the RANGE, not the commit, and every row of one list carries the same value. It defaults to None (no anchoring question asked) so records and call sites written before it existed stay honest rather than inheriting a claim.

Branch-source contracts

grove.core.BranchPlan = Annotated[AutoBranch | NewNamedBranch | ExistingLocalBranch | TrackRemoteBranch | RootBranch, Field(discriminator='kind')]

Wire-level discriminated union. Clients send any of the five variants; Pydantic dispatches on kind with extra='forbid' rejecting typos. Four variants produce a worktree; RootBranch runs in the repo root.

grove.core.AutoBranch

Bases: BaseModel

Grove generates {branch_prefix}{slug(title)}-{ts} off base_ref.

The default create behavior — the same shape Grove ships today, now explicit. base_ref accepts any git revision (branch, tag, sha, HEAD, origin/main); validation that it actually exists happens in the engine when create() runs, so this Pydantic shape stays repo-agnostic and serializable.

grove.core.NewNamedBranch

Bases: BaseModel

User-supplied branch name, off base_ref.

Grove still owns the worktree path and tmux session names (they follow slug(title)); only the branch is the user's namespace. The pattern accepts alphanumerics, dot, dash, underscore, slash; rejects a leading dash so the value can never be parsed as a CLI flag downstream.

grove.core.ExistingLocalBranch

Bases: BaseModel

Check out an existing local branch into a new worktree.

No new branch is created. Provenance is USER_ATTACHED so kill defaults to keeping the branch — this is the user's pre-existing feature branch and a workspace tear-down must not lose it.

grove.core.TrackRemoteBranch

Bases: BaseModel

Track a remote branch by creating a fresh local tracking branch.

remote_ref is the full remote-qualified ref (e.g. origin/feature/x). local_name defaults to the part after the first / — strip the remote name and use whatever's left. Provenance is GROVE_CREATED because the local branch is fresh: if the user later kills the workspace and the local branch goes with it, the remote ref still has every commit (and they can re-track at any time).

effective_local_name: str property

The local branch this plan will actually create.

One definition, so the validator above and :meth:resolve cannot disagree about which string reaches git — a validator that checks a different value than the one used is worse than no validator.

grove.core.RootBranch

Bases: BaseModel

Run the workspace in the repo root itself — no worktree, current branch.

The fifth variant is a placement choice, not a branch choice: it carries no user fields because there is nothing to source. The session is rooted at the repo, adopting whatever branch HEAD already points to; Grove creates no worktree and no branch, so kill never deletes anything and pause/resume are refused. This is "work in place on what I've already got out" — the escape hatch for users who don't want an isolated worktree per task.

resolve() returns a sentinel with an empty name (the manager fills it from live HEAD) and provenance=USER_ATTACHED, so even an explicit delete_branch=True on kill is overridden to False: the user's working branch is never Grove's to delete.

grove.core.BranchInfo

Bases: BaseModel

One branch entry as seen by the engine.

A BranchInfo is a snapshot — it is correct only for the moment it was read. Callers that show it in a UI should re-read after every workspace lifecycle event so stale entries (a branch checked out elsewhere a moment ago, now free) don't mislead the user.

checked_out_in: Path | None = None class-attribute instance-attribute

Path of the worktree where this branch is currently checked out, or None if it isn't checked out anywhere. Drives the Existing branch dropdown's grayed-out rows and the BranchAlreadyCheckedOut error in WorkspaceManager.create().

is_current: bool = False class-attribute instance-attribute

True iff this is the local branch the repo's HEAD points to (the one a fresh git checkout would land on). Local-only — remote entries are always False.

kind: Literal['local', 'remote'] instance-attribute

Where this branch lives. remote entries leave is_current, upstream, and checked_out_in at their defaults.

name: str instance-attribute

The branch identifier as the user types it: feature/x for local, origin/feature/x for remote.

upstream: str | None = None class-attribute instance-attribute

Local-only: the upstream tracking ref, e.g. origin/feature/x. None when no upstream is configured.

grove.core.CreateWorkspaceRequest

Bases: BaseModel

Payload for WorkspaceManager.create().

branch_plan defaults to AutoBranch() so callers that don't care about branch semantics get the historical Grove behavior for free — Grove generates {prefix}{slug(title)}-{ts} off HEAD.

agent_name: str = Field(min_length=1) class-attribute instance-attribute

The agent to spawn in the workspace's tmux agent window. Must match an entry in the merged cfg.agents list at create time.

attachments: list[AttachmentUploadRequest] = Field(default_factory=list, max_length=20) class-attribute instance-attribute

Files to store in the new workspace and name in initial_prompt.

They ride the CREATE rather than a follow-up upload, and the reason is the race-free delivery above. A workspace composer can post to /workspaces/{id}/attachments because its workspace already exists; the landing composer's whole action is "here is a prompt, make me a workspace", so there is no id to upload against until the thing being described has been built. Creating first and steering afterwards would trade the launch-argv delivery for a post-boot type, which is the boot race initial_prompt exists to avoid — and would leave a partial failure as a live workspace whose prompt names files that never arrived.

The engine stores them once the worktree exists and appends the same Grove-fenced block send_message uses, so a create and a follow-up message put an agent in front of identical text. Capped at 20 because this is one request body; the per-file ceiling is AttachmentStore.MAX_BYTES.

branch_plan: BranchPlan = Field(default_factory=AutoBranch) class-attribute instance-attribute

How the workspace's branch and placement are sourced. See grove.core.contracts.branch_plan for the five variants — four produce a worktree, RootBranch runs in the repo root.

brief: bool | None = None class-attribute instance-attribute

Hand this workspace's agent Grove's first-turn brief — one short note pointing it at the working-in-grove skill. None — the default — takes the cascade's answer (GroveConfig.default_brief), exactly like runtime.

Persisted for the same reason runtime is: the delivery happens at every launch, so a workspace created while the default was on must keep being briefed after somebody flips the default off, and vice versa.

description: str | None = Field(default=None, max_length=2000) class-attribute instance-attribute

Optional free-form text the user attaches to the workspace. Persisted as-is on the resulting WorkspaceState. Empty string is treated equivalent to None by the engine; no separate "cleared" state on the wire.

initial_prompt: str | None = Field(default=None, max_length=10000) class-attribute instance-attribute

The agent's first task, delivered race-free as the session boots so the workspace starts working instead of idling at the prompt. Delivered through the launch invocation, never typed in post-boot (which races the agent's boot — the swallowed-Enter trap): claude_code appends it as a trailing positional arg to the launch argv (claude … "<prompt>" starts already working on it); mewbo re-engages the freshly-created session via its /message API (no boot race). A bare shell (generic) has no prompt concept and ignores it. Create-only — never re-applied on resume/respawn, like skip_init.

model: str | None = Field(default=None) class-attribute instance-attribute

Optional model id for this create only, forwarded to the agent tool as its model argument at launch (claude --model <id> / codex --model <id>). None (the default) lets the tool pick its own default — Grove never second-guesses the model, it only forwards the parameter (the provider boundary). Kinds with no launch-time model flag (mewbo, generic) ignore it. Create-time only, like skip_init — never persisted or re-applied.

native: bool | None = None class-attribute instance-attribute

Run this workspace's agent as a Grove-owned native session (Claude Code stream-json / Codex app-server) rather than its interactive terminal. None — the default — takes the roster entry's own AgentSpec.native, exactly like runtime takes the cascade's. True on an agent kind with no native protocol (generic/mewbo) is ignored: the kind, not the request, decides whether a control channel exists to own.

Persisted for the same reason runtime is: every launch and every steer verb reads the record to know whether a pane or a control channel is there, so a later roster edit must not re-decide for a running workspace.

project_cwd: Path | None = None class-attribute instance-attribute

Absolute path the agent session should start in — a nested project directory inside the repo. None (the default) starts the agent at the worktree root, the historical behavior. When set it must be the repo root or a subdirectory of it; the engine derives the subpath relative to the repo root and starts the agent in the matching subdir of the worktree. The git worktree and branch are always anchored at the repo root regardless — this field separates "where the agent works" from "where the worktree lives", letting several subdirs of one repo be distinct projects.

repo_root: Path | None = None class-attribute instance-attribute

Repository root for the workspace. None for in-process callers (the TUI knows its own repo). The HTTP daemon requires this set so it can dispatch to the right WorkspaceManager — its handler returns 422 when missing.

resume_session_id: str | None = Field(default=None, max_length=200) class-attribute instance-attribute

Adopt an EXISTING agent session as this workspace's primary instead of minting a fresh one. None (the default) mints as usual. When set, the id is persisted as agent_session_id (so the dashboard tracks the resumed session by construction — no discovery needed) and the agent is launched to CONTINUE it: claude --resume <id> (which keeps the same session id/file) or codex resume <id>. Only claude_code and codex agents can resume by id — a mewbo/generic agent rejects with a clear error (ResumeNotSupported, 422) before any side effect. Create-only, never re-applied on resume/respawn, like skip_init. Accepts a full id OR a unique id prefix scoped to the project (resolved through the same SessionExplorer the remap verb uses); an unknown/ambiguous ref, or one whose adapter kind mismatches the agent, fails with AgentSessionNotFound (404) before any side effect — never a fully-provisioned workspace stranded on a bogus id.

runtime: Runtime | None = None class-attribute instance-attribute

Where this workspace's agent runs: "container" or "host". None — the default — takes the cascade's answer (GroveConfig.default_runtime: the saved defaults.runtime if there is one, else container.enabled), so a caller that does not care never has to know the field exists.

Explicit "host" is the escape hatch, and it is a recorded choice, not a fallback: it is never warned about and a later respawn never auto-upgrades it. It is refused outright when the project's committed devcontainer config declares customizations.grove.requires_container.

Persisted (unlike skip_init / model): the answer selects a launch backend and every lifecycle verb needs it, so it lives on the record rather than being re-derived from a config default that may later flip.

skip_init: bool | None = None class-attribute instance-attribute

Skip the init script for this create only, regardless of init_script.enabled. A per-create override of a config default (mechanism, not policy): the init script is built for a fresh worktree, so it can be unwanted or unsafe in the repo root, and some worktrees simply don't need it. Records InitStatus.SKIPPED. Does not persist — it is a create-time decision, never re-applied on resume/respawn.

None rather than False because a bool cannot say "unspecified", and without that a saved defaults.skip_init was unreachable: every omitted field arrived as an explicit False the engine could not tell from a caller that meant it. False still parses and still means "run it", so no existing client changes.

ticket: TicketSelector | None = None class-attribute instance-attribute

Optional ticket to associate at create. When set alongside an AutoBranch plan, the generated branch becomes ticket-aware ({branch_prefix}{provider-formatted key + slug}), so the tracker links PRs/commits automatically. The provider must be enabled or create fails before any side effect. Regardless of this field, the final branch name is re-parsed through the providers to derive ticket_refs — so a non-auto branch that already carries a key is associated too.

title: str = Field(min_length=1, max_length=120) class-attribute instance-attribute

Human-readable workspace label. Drives the slug used by the worktree path and the tmux session name. Independent of the branch — the branch name comes from branch_plan.

grove.core.UpdateWorkspaceRequest

Bases: BaseModel

Payload for WorkspaceManager.update() — partial metadata edit.

Wire semantics: null (or field omitted) means "do not change". To clear an existing description, send "". Title cannot be cleared — workspaces are required to have a non-empty title at all times.

The model validator refuses an entirely-empty body so callers can't issue a no-op PATCH that bumps updated_at for free; the engine has its own no-op short-circuit for "values match current", but this catches the obvious "forgot to set anything" client bug at the request boundary.

description: str | None = Field(default=None, max_length=2000) class-attribute instance-attribute

New description. None / omitted leaves the description unchanged. Empty string clears the description. max_length is a soft cap that mirrors the engine's validation — clients should truncate for the textarea, the engine is the source of truth.

share: bool | None = None class-attribute instance-attribute

Whether this workspace is publicly readable. None / omitted leaves sharing exactly as it is — which is what makes an ordinary rename safe: a client PATCHing a title must never turn sharing off by not mentioning it.

true mints a public link (idempotent — an already-shared workspace keeps the token it has, so re-enabling never breaks a link somebody is holding). false revokes, permanently: the token is cleared rather than parked, and re-sharing later mints a fresh one. The token itself comes back on WorkspaceStateView.share_token; it is never accepted as input, because the engine is the only thing that may decide what a capability is.

share_session_id: str | None = Field(default=None, min_length=1) class-attribute instance-attribute

Which session transcript the public link shows. None / omitted keeps whatever the link is already pinned to.

Minting a link pins it automatically, to the workspace's session at that moment, so the ordinary path never sends this. It exists to RE-PIN a link already in circulation: enabling is idempotent, so share: true alone cannot move a live link's transcript, and that silence is deliberate — clicking share twice must not quietly change what a URL somebody already holds renders.

Requires share: true (a pin without a link is a claim about nothing) and accepts a unique id-prefix. An id this workspace's adapter could never read is rejected as agent_session_not_found rather than stored as a dead pointer that answers 200.

title: str | None = Field(default=None, min_length=1, max_length=120) class-attribute instance-attribute

New title. None / omitted leaves the title unchanged. Must be 1..120 characters when present.

Wire views

grove.core.WorkspaceStateView

Bases: BaseModel

Wire mirror of grove.core.workspace.WorkspaceState.

telemetry_session_id: str = '' class-attribute instance-attribute

The id every trace this workspace produces is keyed by — the Langfuse SESSION that reassembles Grove's replay and the harness's own spans into one run (:attr:WorkspaceState.telemetry_session_id).

On the wire because it is the ONE value a browser needs to build a link into the operator's Langfuse and cannot derive: it is the harness's own session id where the harness has one and the tmux session where it does not (codex mints nothing), and only the engine knows which. Empty string for a pre-field payload, never a fabricated id.

grove.core.WorkspacePeekView

Bases: BaseModel

Wire mirror of grove.core.workspace.WorkspacePeek.

grove.core.CommitSummaryView

Bases: BaseModel

Wire mirror of grove.core.workspace.CommitSummary.

scope describes the RANGE the list came from, not the commit, so every row of one response carries the same value — a bare-array response has nowhere else to put a property of the whole answer, and an envelope would be a breaking shape change for both shipped consumers. Defaults to None so a client that has not been taught the field decodes unchanged, and so that a list which asked no anchoring question makes no claim.

grove.core.AttachInstructionView = Annotated[HostAttachView | ContainerAttachView, Field(discriminator='kind')]

Errors

grove.core.GroveError

Bases: Exception

Base for every error raised by grove.core. Clients catch this.

grove.core.BranchError

Bases: GroveError

A BranchPlan could not be reconciled with live git state.

Subclasses pinpoint the specific failure so clients (TUI flash today, HTTP 422 in a future API server) can render a tailored message. Always raised before any worktree side effect, so create-failure cleanup is unnecessary on this path.

grove.core.BranchConflict

Bases: BranchError

A new branch name in the plan collides with an existing branch.

grove.core.BranchAlreadyCheckedOut

Bases: BranchError

The requested existing-local branch is already checked out at another worktree.

Carries structured context (name, worktree) so clients can render a useful message ("checked out at /path/to/wt") and a future API response can include them in the JSON payload.

grove.core.BranchNotFound

Bases: BranchError

A branch (local or remote) referenced by the plan does not exist.

Attach plumbing

grove.core.AttachInstruction = HostAttach | ContainerAttach