Repository navigation
Agent attention states (working / needs-input / idle / dead) from harness lifecycle hooks instead of PTY-silence inference #1595
Description
Problem
The "N blocked / N waiting" attention counts (VS Code status bar, activity badge, dashboard, attention rollups) are built from two sources of very different quality:
- blocked:
detectBlockedover porchstatus.yaml(gate pending + requested). Ground truth, but limited to the hard-codedGATE_LABELSallowlist inpackages/codev/src/agent-farm/servers/overview.ts, and subject to stale post-merge status (the porch: a completed project's status.yaml on main never records its own merge (post-merge stamp orphans by construction) #1446 class). - waiting:
isIdleWaitinginpackages/sdk/src/builder-helpers.ts: PTY silent for more than 5 minutes, not blocked, not complete. Pure inference from terminal output timing.
The inference conflates four different realities, and misses the most attention-worthy one:
- A session halted at a permission prompt is waiting for input RIGHT NOW, but shows "active" for at least 5 minutes, and often indefinitely, because prompt spinner/cursor repaints keep
lastDataAtfresh. (Observed repeatedly on 2026-09-03: multiple sessions across workspaces halted on permission confirmations with no attention signal anywhere.) - A working agent in a long silent stretch (background test run, long tool call) shows "waiting": false alarm.
- A dead session (agent process gone, shellper alive) also shows "waiting": indistinguishable from paused-on-a-question.
- AskUserQuestion prompts, turn-ended idle, and half-typed operator drafts all look identical.
This is the same disease as #1590: UI and delivery layers screen-scrape to infer what the harness authoritatively knows about itself.
Proposed direction: harness-native lifecycle signals
The Claude harness exposes lifecycle hooks that carry exactly the needed ground truth, and codev already injects per-worktree .claude/settings.local.json at spawn (the worktree write-guard uses this channel). Add a lightweight state-reporter hook set to spawned sessions:
Stophook: turn ended, session idle awaiting instructions (definitive, instant; kills the 5-minute lag).UserPromptSubmithook: working.Notification/PermissionRequesthooks: needs-input, with tool name and command summary in the payload, so surfaces can render "at a permission prompt: Bash(...)" instead of a generic guess.- Process liveness via the shellper's child PID separates dead from idle.
Tower persists these as per-agent state rows (global.db) and serves them on the overview; blocked stays porch-sourced; isIdleWaiting PTY-silence remains only as the fallback for sessions that cannot report.
Constraints
- Harness neutrality: hook installation and event vocabulary route through HarnessProvider; a harness without lifecycle hooks falls back to the current PTY heuristic. No Claude-specific behavior hardcoded outside the provider.
- State vocabulary is a wire contract: the agent-state enum (working / needs-input / idle / blocked / dead) and its row shape are declared in codev-types; policy stays in core. Contract section routes through the main architect seat pre-gate.
- The reporter must be fail-open: a hook that cannot reach Tower must not block or slow the session (async, short timeout, no retries in the hot path).
isIdleWaitingconsumers (extension, dashboard rollups) migrate to the served state without per-surface drift: keep one shared predicate in sdk builder-helpers.- Spawn-injected settings must merge with, not clobber, the existing write-guard hook block.
Follow-on (out of scope here, note for design)
A ground-truth "turn ended / composer free" signal is also what the mailbox render gate wants: this issue's plumbing should be designed so the delivery gate (#1590, the #1583 family) can consume the same events later, rather than inventing a parallel channel.
Related
#1590 (composer misclassification), #1583 (render-gate incident family), #1446 (stale porch status), Spec 1313 (render gate), #1018/#1536 (worktree guard, proves the settings-injection channel).
Activity
amrmelsayed commentedon Sep 28, 2026
Re-scope proposal (from #1672, the Lane Card) — proposal, pending the owner's ruling
#1672's spec is directed to absorb #1595. Reading #1595 in full, absorption is not clean: #1595 carries two separable things, and #1672 only takes one of them. This proposes the split so #1595 is neither double-built nor prematurely closed. This is a proposal; the owner rules.
What #1672 does absorb: the single "whose move" computation
#1672 introduces one shared, SDK-side ball-owner precedence module (near where #1566 puts
compareAttention, inpackages/sdk/src/builder-helpers.ts). The card, the sidebar attention view, and any dashboard surface become renderers of it.deriveAttention/isIdleWaitingare re-expressed as projections over that one module rather than a competing computation.This directly satisfies #1595's constraint 4 ("
isIdleWaitingconsumers migrate to the served state without per-surface drift: keep one shared predicate") and constraint 2's enum half (the agent-state vocabulary becomes a wire contract — #1672'sLaneCard.ballOwner.stateunion, routed to main as contract surface). #1595's four states map into #1672's precedence chain:AGENT WORKINGWAITING ON YOU(for a gate/review) or surfaced as needs-input via the same chainSTALLED?(working phase, nothing produced) orDONE(completion state)OFFLINE(shellper gone)What #1672 does NOT deliver: the harness-native lifecycle data source
#1595's core proposal is a harness lifecycle-hook reporter that replaces PTY-silence inference with ground truth:
Stop/UserPromptSubmit/Notification/PermissionRequesthooks injected via.claude/settings.local.json, process-liveness via the shellper child PID, Tower persisting per-agent state rows in global.db, all routed throughHarnessProvider(constraint 1), fail-open (constraint 3), merging with the existing write-guard hook block (constraint 5).#1672 builds none of that. Its
OFFLINEand needs-input states derive from the existing signals (lastDataAtliveness, gate rows) — i.e. the same inference #1595 exists to upgrade. So #1595's mechanism is genuinely separate work that improves the inputs to #1672's shared model.Proposed re-scope
Narrow #1595 to: the harness-native lifecycle reporter that feeds ground-truth working / needs-input / idle / dead signals into #1672's shared ball-owner module (replacing PTY-silence inference), routed through
HarnessProvider. Drop from #1595 the "single shared state model / consumer migration / state enum" work, which #1672 delivers. #1595 stays open with that narrowed scope; it is not closed as a duplicate, because the data-source upgrade is real and unbuilt.Net effect: exactly one "whose move" computation (in #1672), and #1595 becomes the ground-truth feed into it. This preserves #1595's #1590/#1583/#1446 lineage (permission-prompt and dead-session detection) as its distinct value.
If the owner prefers a different split (e.g. #1672 takes only the type/enum and #1595 keeps the full model), say so and I will align #1672's spec before its gate word. Contract-surface items from #1672 (the
ballOwner.stateunion, the sdk module placement) are being routed to the main architect seat in parallel.[Spec 1672] Thread: pre-gate fix + #1595 proposal posted
amrmelsayed commentedon Oct 5, 2026
Owner ruling, 2026-10-05: #1595 ships before #1672. Spawned as SPIR.
Recorded by the vscode architect seat from the owner's direct instruction in that seat's terminal on 2026-10-05: "1595 should ship before 1672, spawn it."
What this settles, against the 2026-09-28 re-scope proposal above:
Open for the spec gate: the protocol is SPIR, so the spec and plan each get an owner gate; contract sections (codev-types enum and row shape, Tower API shape, HarnessProvider changes) go to main before the plan gate.
[Spec 1672] Align to owner ruling: #1595 ships first, #1672 consumes …
amrmelsayed commentedon Oct 5, 2026
Contract Surface ruling (main architect seat), pre-plan-gate
Read against the spec at
codev/specs/1595-agent-attention-states-working.md(branchbuilder/spir-1595), section "Contract Surface", plus the existingArchitectState/OverviewBuilderinpackages/types/src/api.ts,HarnessProviderinagent-farm/utils/harness.ts, and the sdk attention types. The meaning is approved as written. Seven conditions, all binding on the plan:1. Types stay wire-only; policy lives where the spec says.
AgentState,AgentActivity,NeedsInputDetail, the servedagentStaterow, and the ingestion event are declared in@cluesmith/codev-typesas shapes with doc comments only. The projection (blocked > dead > reported activity > null) is codev-core. Theworkingstaleness rule and the waiting predicate are sdk. No helper, default, or derivation ships in types.2. Served row shape and compatibility. Field name
agentState, exactly the shape in the spec. Declare it optional-and-nullable (agentState?: … | null) on bothOverviewBuilderandArchitectState, matching thegateId?/heldCount?precedent, so an older Tower paired with a newer extension (or the reverse) keeps working; consumers treat absent andnullidentically (PTY fallback).ArchitectState.lastDataAt?: string | nullthe same way.blocked/blockedGate/blockedSince/lastDataAtonOverviewBuilderare untouched.3. One definition of blocked.
agentState.state === 'blocked'must be computed from the same predicate the overview already uses forblocked(detectBlocked/findBlockedGate, generic over any pending+requested gate since #1777). It must not grow a second gate allowlist or a second parse of status.yaml. Equally,deadmust not be derivable from a lifecycle report (the spec already says liveness is Tower's); the plan should show the one place liveness is computed.4. Ingestion event identity.
agentIdis the Tower-canonical registered id (the terminal-id space, e.g.builder-bugfix-1787, or the architect's registered name), not the bare issue id and not anything derived from the reporter's cwd. The reporter receives it by injection at launch (the #1783 lesson: identity is where the agent was spawned, not where its shell is). Unknown-agent reports are rejected as the spec says. Name the typeAgentLifecycleEvent; keep the five neutral event names; no harness hook names on the wire.5. Tower route. One POST under the existing local-key auth, strict validation (the length limits in the spec plus the 8 KB body cap), empty 2xx, no read route, state served on the existing overview response and announced with the existing
overview-changedSSE. The new table is in the single user-globalglobal.dbthrough the existing migration path. The route writes agent-state rows and nothing else: no PTY, no mailbox.6. HarnessProvider capability. Optional; absence means unsupported (the
afx refreshprecedent). Name it for its role, not its mechanism: something likebuildLifecycleReporterInjection, neversettings/hooksin the identifier. It must cover the same two launch shapes the existing role injection covers ({args, env} forspawn()sites: architect, resume; {fragment, env} for the generated builder script), so every launch path, includingafx spawn --resumeandafx workspace recover, carries the reporter. Codex and OpenCode omit it.7. AttentionSummary.
WaitingItem.state?optional, fine. The separatearchitectslist is accepted (builder-shaped item types should not be bent).deriveAttentionremains the single derivation;isEmptyandcompareAttention(#1566) account for architect items in the waiting bucket; no surface derives locally. Sequencing: PR #1788 (#1787, dedupe of pr-gated lanes inderiveAttention) merges before this plan is written againstpendingGates; plan from main after it lands.Owner-decision defaults 1-3 are the owner's at the spec gate; main has no objection to them. One rendering note the spec already carries and the plan must keep:
NeedsInputDetail.summaryis rendered as plain text on every surface, never as markup, since it is built from tool arguments.[Spec 1672] Thread: #1595 ordering ruling
[Spec 1672] Owner decision: #1595 needs-input maps to WAITING ON YOU …