Files
paseo/docs/agent-lifecycle.md
T
8ddeb79c8d Claude agents stay idle while background helpers work, and send or Stop no longer kills them (#6295)
* fix(claude): open autonomous turns only when Claude starts a turn

Paseo guessed from frame shape whether the main session had started work
on its own, and only a result could close the turn that guess opened. A
background subagent's task frames, its own background jobs'
task_notification and rate limit events all arrive with no parent tool
use id, so they looked like main-session work and opened a turn no result
would ever close. The agent then reported "running" while its main
session sat idle, and the app's message queue never drained.

Claude brackets every main-session turn itself: system/init opens it and
exactly one result closes it, whether a prompt, a wake-up after background
work, a slash command or an API error started it. Background subagents
never emit either on the main stream. An autonomous turn now opens only on
that init, and only when no foreground turn is open. The frame heuristic
is removed.

- Paseo launches the CLI with CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1,
  added as an overlay in buildSdkEnv so the SDK and the usage probe share
  one env object. An "idle" session state closes an autonomous turn as a
  safety net, never a foreground one. "running" opens nothing: Claude Code
  2.1.280 stays "running" for as long as any background subagent is
  alive. State frames never reach the timeline.
- An init clears a leftover pendingInterruptAbort. A new turn has begun,
  so the interrupted one is over, and a stale flag would swallow the new
  turn's result.

Tests that opened autonomous turns from bare frames now send the init
Claude sends. New coverage: helper frames never open a turn while the main
session is idle, a wake-up turn opens on init and closes on its result, a
folded steer keeps one turn while a turn Claude starts after the result
gets its own, an idle closes a turn no result closed, session state never
opens a turn, and the env flag reaches the SDK launch.

docs/agent-lifecycle.md records that only system/init and result bracket
a main-session turn, since the code cannot tell a reader why frame shape
fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(claude): interrupt Claude only while its main turn runs, and spare helpers

Every Stop, and every send that replaces a running turn (paseo send, MCP
send_agent_prompt, voice, the app's queue drain, Send now), ends in
query.interrupt(). Without the SDK's perTaskStopAffordance declaration
that call kills every running background subagent, even when the main
session is idle and there is nothing of ours to stop.

- Declare perTaskStopAffordance, so an interrupt aborts only the main
  turn, like Esc in Claude Code. Claude stops a helper itself with its
  TaskStop tool.
- Track whether a main-session turn is in flight from Claude's own
  brackets: set by system/init and by a pushed message's
  command_lifecycle "started" (/compact works for seconds before its
  init), cleared by the result or an idle session state.
- Call query.interrupt() and set pendingInterruptAbort only when that
  turn is in flight, or the cancelled turn's own message has already been
  started. Otherwise withdraw our not-yet-started messages with
  cancel_async_message instead: an idle interrupt reaches only helpers,
  and with no result to follow it the flag would linger.
- Track the foreground prompt with the queued steers until Claude starts
  it, so Stop withdraws a message still waiting in the queue. A turn
  stopped while the Claude process is still starting never sends it.
- Accept command_lifecycle "cancelled" as terminal, so a cancelled steer
  no longer waits to be withdrawn or keeps denying permission cards.

Interrupt tests now wait for the pump to read the init before stopping,
since that is when Paseo learns a turn is in flight. New coverage: a stop
before Claude starts the message withdraws it without interrupting, a
stop while only helpers run never reaches Claude, a stop mid-turn
interrupts once and drops the aborted result, a new turn clears a flag
no result will consume, the SDK option is passed, and a cancelled steer
is not withdrawn again.

docs/providers.md described the old unconditional interrupt; it now states
when the Claude adapter interrupts and why it declares the affordance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(claude): keep a helper spawned in the background out of a turn's cancel

When a turn is canceled, Paseo terminalizes the subagents that were
running in its foreground and skips the backgrounded ones, which outlive
it. It only learned a child was backgrounded from task_updated's
patch.is_backgrounded, which Claude sends when a foreground task moves to
the background later. A child spawned in the background carries the flag
on task_started instead, and a resumed subagent always does, so the
track marked those helpers "canceled" while they kept working.

Read is_backgrounded from task_started as well. docs/agent-lifecycle.md
said task_updated was the only signal; it now names both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): drain queued messages as steers

The composer queue drains when an agent goes idle. It sent each message
with no activeTurnBehavior, which the daemon treats as "interrupt": if
the agent had just started a turn of its own (a Claude wake-up after
background work finished), the drained message stopped that turn and
replaced it.

Send drained messages with activeTurnBehavior "steer". A drain that races
such a turn folds into it; with no turn running the daemon starts a
normal one, as before. "steer" is an existing protocol value, so older
daemons need nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): cover background helpers against real Claude

Every other test in this file tells Claude "Do not use a background
task", so a message sent or a Stop pressed while a backgrounded subagent
runs was never exercised. Four real-Claude scenarios cover it:

- "a background subagent keeps running after a message is sent while it
  works": one helper, the main turn ends, the parent is not reported
  running, a message in that window is answered, and the helper is never
  read as canceled and completes.
- "a helper's own nested background job does not phantom-run the parent
  or kill the helper": a helper starts its own background Bash job, then
  keeps working in the foreground. The parent stays idle across the
  nested job's completion (sampled for 30 s around a 5 s job), a message
  sent in that window is answered, and the helper reports CHILD_DONE.
- "the app's default steer survives a backgrounded helper while the main
  session works": the app's default send behavior (steer) does not kill
  a helper when it interrupts the main session's own turn.
- "Stop cancels the main session's own turn but spares its background
  helper": the same for Stop.

The wake-up turn Claude starts after a helper finishes lands a moment
after the helper reads completed (0.1 to 5 s in recorded traces), and the
daemon announces it as a pushed "running" agent_update, not as a
turn_started stream event. The checks poll for it and read the final idle
once the wake-up settles. waitForFinish can return a stale "idle" right
after a send, and the push stream can miss a message's final text when it
resolves, so the tests read fetchAgentTimeline like the rest of the file.
Helper status comes from agent.provider_subagents.list, since agent_stream
carries no provider_subagent events.

The scenarios need the previous commits together. Run against the frame
heuristic this branch removes, the nested-job test fails: the parent
flips to running once the nested job finishes and stays there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(claude): interrupt a stopped prompt Claude has already taken

Stop before Claude announces a prompt started withdraws it with
cancel_async_message instead of interrupting. Claude can dequeue a
message for its next turn before announcing it, and withdrawing it is
then a no-op that reports cancelled: false, so the stopped prompt kept
running. Interrupt whenever a message could not be withdrawn.

Co-authored-by: Isak Persson <isak.a.persson@gmail.com>

* fix(claude): withdraw a stopped prompt only once

When the withdrawal of a stopped prompt failed, the interrupt path
withdrew a second time, which could cancel a prompt sent while the first
withdrawal was in flight. Withdraw once and interrupt on failure.

The real-Claude steer scenario now waits for the background helper to
exist before steering, so it proves the helper survives the steer.

Co-authored-by: Isak Persson <isak.a.persson@gmail.com>

---------

Co-authored-by: Isak Persson <isak.a.persson@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 14:08:48 +02:00

23 KiB

Agent lifecycle

How an agent is created, runs, becomes a subagent, gets archived, and disappears from the UI. The model spans the daemon (lifecycle, archive) and the client (tabs, the subagents track).

States

initializing → idle → running → idle (or error → closed)
                 ↑        │
                 └────────┘  (agent completes a turn, awaits next prompt)

Each live agent in AgentManager carries a lastStatus of initializing, idle, running, or error. closed is the persisted, resumable state for an agent record that has no live provider runtime. State transitions persist to disk and stream to subscribed clients via WebSocket.

Runtime residency

An unarchived agent may be closed without being deleted or archived. Closing releases its provider processes and subscriptions while retaining its Paseo identity, persistence handle, timeline, workspace, labels, title, usage, attention, timestamps, and parent relationship. Opening or prompting the agent runs through ensureAgentLoaded(), which resumes the durable provider session under the same Paseo agent ID. Provider history is not appended again when the canonical timeline is already primed.

Reload releases the old runtime before resuming its durable session: an idle provider process can still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.

Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle action such as archive, replacement, reload, workspace teardown, or daemon shutdown.

A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked inside that process dies with it: Claude Code's background Bash shells, Monitor watches, and workflows all live in the CLI process, and the completion notification that would have woken the agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but between turns nothing is watching, so the agent sits at idle looking healthy while its background work is gone. Report that exit as a turn failure so the agent lands in error with a timeline entry. Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt; the unfinished turn is lost.

Cancellation

Provider interruption is idempotent at the AgentSession boundary. It resolves when the prior foreground turn can no longer run, including when the provider reports that it is already idle. It rejects only when the provider may still own the turn. Provider adapters translate native errors into that contract; lifecycle callers do not interpret provider-specific errors.

After an acknowledged interrupt, the manager settles the captured run even when no terminal event arrives or the run was still waiting for its provider turn id. The captured run token prevents an older cancellation from settling a newer turn. If interruption is rejected or times out, the agent keeps its active foreground turn and replacement, reload, rewind, and Stop report the failure. Accepting new work after an ambiguous interruption would create a split-brain session.

Relationships

Agents can launch other agents via the agent-scoped create_agent MCP tool. Agent-scoped creation is always asynchronous and always stamps paseo.parent-agent-id, pointing back at the caller. Omit workspaceId to use the caller's workspace, or pass an existing workspace ID returned by create_workspace. Placement never changes parentage.

  • Subagents — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
  • Detached agents — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.

Parent archive detaches a subagent instead of archiving it when either condition holds:

  • The child belongs to another workspace.
  • The child is currently open in an agent tab.

All other children archive with the parent. After the workspace layout hydrates, the client marks every managed subagent present in its tabs with paseo.open-agent-tab.<client-id>=true through the generic agent metadata update. This includes background and restored tabs; navigation does not own the marker. Closing a tab sets that client's label to false. Any true client label keeps the child open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a normal root agent immediately, and closing its still-open tab archives it.

Runtime ownership is resolved from explicit workspace ID and caller context, never from cwd. Workspace creation is a separate operation with local | worktree isolation; agent creation only selects an existing workspace.

Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current cwd and workspaceId, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.

notifyOnFinish defaults to true for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to false only for truly fire-and-forget agents or prompts. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.

Provider-managed child agents

Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with child_session events; AgentManager imports the live provider handle, stamps paseo.parent-agent-id, and surfaces the result as a normal subagent in the parent's subagents track.

The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.

Archive

Archive is a soft delete: the agent record stays on disk with archivedAt set, the runtime is closed, and the agent disappears from active lists. Archive is global — it lives on the server and propagates to every connected client.

Archive sets archivedAt, invokes the provider's native archive hook, and cascades to managed children.

create_agent_request can opt an agent into autoArchive. In that mode the daemon archives the agent after the first terminal turn event (turn_completed, turn_failed, or turn_canceled). When the agent owns an isolated workspace, auto-archive archives that workspace too; the managed worktree is removed when its final workspace reference is gone.

Archiving runs through AgentManager.archiveAgent (packages/server/src/server/agent/agent-manager.ts):

  1. Snapshot the current session into the registry
  2. Set archivedAt and normalize lastStatus away from running/initializing
  3. Notify subscribers
  4. Close the runtime (kills the process if still running)
  5. Resolve children — detach cross-workspace and open-tab children; cascade-archive the rest recursively

Cascade is what keeps subagent fleets from outliving their orchestrator.

Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving agent record without setting the agent's archivedAt, while its workspaceId still points at the archived workspace. History navigation must not infer workspace lifecycle from agent.archivedAt or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace.

History navigation opens the selected agent without changing either archive state. Workspace Restore recovers only the workspace; the selected archived agent stays open with its callout. The agent's Unarchive runs the provider's native unarchive hook before interactive resume and history hydration. Other archived agents stay archived.

Opening an agent is a navigation choice, independent of whether its details are cached. The layout retains that choice across reload while the panel fetches the agent from the daemon. Once the daemon reports the agent active, its tab follows normal archive propagation again. An empty active list cannot cancel an explicit History selection. Agent-detail loading does not own selection or release the explicit open.

Persisted resume, native restore, and both live and stored-only archive enter the same per-agent lifecycle queue. Resume chooses its history or interactive purpose from the durable record after entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed close retains the runtime for cleanup and blocks replacement through that close operation.

Authoritative timeline catch-up can use a runtime-only history resume purpose. For Codex, that purpose initializes a temporary app-server, reads the persisted thread and child histories, and releases the process before returning. It never loads, resumes, or unarchives a native thread, including legacy records whose native archive failed. The retained history session contains only the read results. Interactive resume remains responsible for repairing a provider session archived outside Paseo while its Paseo agent is active.

Provider session connection owns every process it spawns until the session is registered with AgentManager. If initialization, persisted-session resume, or initial history hydration fails, connect() must dispose that process before rethrowing; the manager cannot clean up a session it never received.

Tabs vs archive

These are two distinct concepts that used to be conflated:

Concept Scope Triggers
Tab (workspace layout) Per-client User opens/closes a view
Archive (lifecycle) Global Explicit lifecycle gesture

Closing a tab on a root agent still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.

Closing a tab on a subagent (any agent with parentAgentId) is layout-only. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.

The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.

Workspace activity

Agent lifecycle status stays literal: a parent agent is idle when its own turn is idle, even if a child is running.

Workspace status is an aggregate activity signal computed per workspaceId. Ownership is never derived from cwd — many workspaces may share one directory, and same-cwd siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute running to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship.

Running provider-native subagents contribute running to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.

A finished workspace can be marked unread after it has been reviewed. The daemon restores finished attention on its newest eligible workspace-root agent without sending a new completion notification. Opening the workspace clears that attention through the normal focus flow.

Attention is set by the agent finishing or failing and cleared by the client's workspace.clear_attention, which fires when the user reads the chat. Loading an agent's runtime is neither, so resuming carries the stored attention and the stored last-activity time through untouched. Forging either makes a background resume look like the user read a workspace and like the agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted updatedAt is what workspace statusEnteredAt is re-derived from on the next daemon start.

The subagents track

The track is a pill at the foot of an agent's pane (packages/app/src/subagents/track.tsx): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; packages/app/src/panels/agent-tracks.tsx owns that placement, and the pill frame is shared with the task list in packages/app/src/composer/tracks.tsx.

The rows combine two kinds of children:

  • Paseo subagents are full managed agents. Their membership rule (packages/app/src/subagents/select.ts) is:
parentAgentId === thisAgent.id  AND  !archivedAt
  • Provider subagents are child executions owned by Claude, Codex, or OpenCode. They are not inserted into AgentManager as managed agents. Providers emit a separate descriptor and timeline stream through agent.provider_subagents.*; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional parentSubagentId identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.

Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use AgentStreamView, so message, reasoning, tool-call, and layout rendering stay identical.

Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.

Provider descriptors may include one compact subtitle. The provider owns its contents and formatting; clients display and truncate it without interpreting provider-specific model, thinking, or usage fields.

Claude provider subagents: the task protocol

Claude Code announces subagent lifecycle on the SDK stream (task_started / task_updated / task_notification / task_progress), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (subagents/live-source.ts) and the replay source (subagents/replay-source.ts) both translate into one observation vocabulary (subagents/observation.ts), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types:

  • Not every announced task belongs in the track. Task subagents announce as local_agent and workflows as local_workflow; a backgrounded shell announces as local_bash with the same tool_use_id shape, and ambient housekeeping sets skip_transcript. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled Workflow, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
  • A task that was never declared gets no descriptor, by any route. Filtered tasks still emit task_notifications carrying a tool_use_id, and still emit frames carrying parent_tool_use_id. Attributing either produces a descriptor with no identity and a defaulted running status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
  • Task ids are session-scoped, not turn-scoped. Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later task_notification is free to correct that guess. Backgrounded children are identified by is_backgrounded on task_started (set at spawn; a resumed subagent is always background) or, when moved there later, on task_updated.patch.
  • A resumed task can be announced again with a new tool_use_id. The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
  • Effort is only reachable through hooks. It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its agent_id, which is the same id task_started calls task_id.
  • Backgrounded subagents emit no frames carrying parent_tool_use_id at all. Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
  • Only system/init and result bracket a main-session turn. Background subagents emit neither on the main stream, while their task frames, their own background jobs' task_notification, and rate_limit_event arrive with no parent_tool_use_id, the same shape as main-session work. session_state_changed stays running for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its idle can close one.
  • Nested ownership comes from the launching sidechain, not spawn_depth alone. A sidechain's Agent or Bash tool call records the direct owner of that tool_use_id; the following task_started inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
  • On replay, <session>/subagents/ holds every descendant beside the root. Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by toolUseId. spawnDepth orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
  • Replay totalTokens is a context-size reading, not cumulative spend. Claude Code finalizes a subagent by summing the last assistant message's usage block and shipping that as usage.total_tokens. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.

Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the archive button on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.

The Archive finished row at the foot of the panel covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.

To keep the agent alive but remove it from the parent's track, use detach. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.

Why this shape

The decision was to decouple "close tab" from "archive" only for subagents, rather than universally:

  • Closing a tab on a root agent still archives — preserves the existing UX users are trained on
  • Closing a tab on a subagent is layout-only — fixes the lossy "click to read, close to dismiss view, lose the row" flow
  • Archive button on track rows — gives subagents an explicit lifecycle gesture in their home surface
  • Detach button on track rows — lets a subagent continue independently without killing its work
  • Cascade archive on parent — keeps subagents from leaking when the parent is archived

We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.

Limitations

Subagent accumulation under long-lived parents

A parent that spawns many subagents will see the panel's list grow; the pill only counts them. Managed Paseo subagents can be archived individually or with Archive finished. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.

Cross-client tab dismissal

Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup.

Storage

$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json

{cwd-with-dashes} is derived from the agent's filesystem cwd. It is not the workspace id; agent storage stays cwd-keyed while workspace identity is the opaque workspace id.

Each agent is a single JSON file. Fields relevant to this doc:

Field Type Meaning
id string Stable identifier
archivedAt string? Soft-delete timestamp (ISO 8601)
labels["paseo.parent-agent-id"] string? Parent agent ID, set automatically for agent-scoped creation and removed by detach
labels["paseo.open-agent-tab.<client-id>"] string? "true" protects an open tab on that client; detach clears every matching label
lastStatus AgentStatus initializing / idle / running / error / closed

See docs/data-model.md for the full agent record.