Documentation source
Work queue + agent comms command strip (DRI-004)
Unified "what needs me right now" surface — needs-me / running / delegatable / failed / done — aggregating waiting human tasks, pending tool approvals, blocked agent runs, and next actions.
## Problem
The operator's "what needs me right now" surface is fragmented across at least four UIs and one external system:
1. **Waiting human tasks** live under `/tasks` and on entity-detail action lists — discoverable only if you remember to go look. The `sessions` table flips to `status = "waiting_human"` (see `features/sessions/server/session-reaper.ts:47`) and stays there silently.
2. **Pending tool-call approvals** were just made first-class on session detail pages by DRI-028-b (`features/sessions/components/pending-tool-calls-strip.tsx`) — but only visible if you happen to be looking at that session.
3. **Blocked agent runs** (claim failures, awaiting_tool, abandoned, expired) surface in Sentry breadcrumbs and `_completed.md` postmortems, not in-product. The agent's heartbeat continues; the operator has no idea anything stalled.
4. **Next actions** — cron actions firing within the hour, scheduled tasks I own, queue actions with reserved slots — are scattered between `/tasks/planner`, the action registry, and `actions.next_run_at`.
Result: there is no single "command center" surface answering "what should I look at next?" The operator's mental model is "I'll just check Today and hope I see everything," which fails the moment a tool call sits awaiting approval inside a session detail page nobody navigated to.
The design exploration `Sprinter Work Queue Agent Comms Redesign` (PKG-036/037, at `~/SprinterVault/20-Ventures/Amble/03-Product/design-references/claude-design-docs-20260519/extracted/Sprinter Work Queue Agent Comms Redesign/`) demonstrates the target: a five-bucket horizontal strip — `Needs me · Running · Delegatable · Failed · Done today` — reusable across Today, Tasks, Inbox, and the dashboard, with one-glance counts and click-to-filter behavior. Today none of that exists; the closest in-product surface is the sidebar count badges (which only count by route, not by bucket).
## Solution
A **Work Queue Command Strip** — a horizontal five-bucket surface that aggregates everything the operator might need to act on, with one-click drill-down. The strip is a reusable component first; its v1 mount is at the top of `/today`.
The five buckets follow the design reference exactly:
1. **Needs me** — `sessions.status = "waiting_human"` assigned to `auth.uid()` OR `session_events` of type `tool_call_requested` with status `awaiting_approval` on a session the user can act on. Surfaces approvals, human tasks, and diff confirmations.
2. **Running** — `sessions.status IN ("running", "pending")` where the user owns the parent action OR the session was spawned for an entity the user follows. Surfaces what's in flight right now.
3. **Delegatable** — `actions` rows with `agent_slug IS NOT NULL` AND `status = "active"` AND no live run for the current scope (no `sessions` row in `running` / `pending` for this `(action_id, scope)`). These are "auto-runnable, awaiting nudge."
4. **Failed** — `sessions.status IN ("failed", "expired", "abandoned")` within a rolling 24h window, parent action owned by user OR session is for entity user follows. Surfaces tool errors, timeouts, retries.
5. **Done today** — `entity_responses` promoted to canonical today (`source IN ('agent', 'manual')`, `promoted_at >= start_of_day(tz)`) OR `sessions` that hit `completed` since start-of-day. Surfaces "we shipped work."
Each bucket is a clickable card with `(label, icon, count, sub-meta)`. Clicking filters the host surface (Today, Tasks, Inbox) to just that bucket. The strip ships as a single reusable component with **no embedded routing assumptions** — it accepts `onPick(bucket)` and lets the host decide what filter means.
The strip reuses existing primitives:
- **`DelegationBadge`** (DRI-026, `components/ui/delegation-badge.tsx`) for the "by whom" identity chip on the bucket-expanded list view.
- **`PendingToolCallsStrip`** (DRI-028, `features/sessions/components/pending-tool-calls-strip.tsx`) as the in-context surface when "Needs me" expands to show tool approvals.
- **`session_events`** stream (already canonical, append-only) for everything in Needs me / Running / Failed.
- **`actions.next_run_at`** + `queue_config` (ADR-0016) for Delegatable / scheduled.
No new database tables. No new event types. One new server action (`getWorkQueueCounts`) and one new client component (`CommandStrip`).
## Design
### Architecture
```
┌─────────────────────────────────────────────┐
│ /today page (v1 host) │
│ │
│ ┌───────── CommandStrip ───────────────┐ │
│ │ [Needs me 7] [Run 4] [Delg 11] [Fail 2]│ │
│ │ [Done 23] │ │
│ └────────┬───────────────────────────────┘ │
│ │ onPick(bucket) │
│ ▼ │
│ ┌───────── Focus area ─────────────────┐ │
│ │ filtered list view for picked bucket │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
│
│ React Query: useWorkQueueCounts()
▼
┌─────────────────────────────────────────────┐
│ features/work-queue/server/aggregate.ts │
│ getWorkQueueCounts({tenantId, userId, tz}) │
│ → 5 parallel SQL queries (Promise.all) │
│ → returns { needs, running, delegatable, │
│ failed, done, deltaSinceLast } │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ sessions · session_events · actions · │
│ entity_responses │
└─────────────────────────────────────────────┘
```
### Data sources (aggregator queries)
`features/work-queue/server/aggregate.ts` exports one server action returning all five counts in a single round-trip via `Promise.all`. Each query is tenant-scoped (RLS handles it) and bounded to the current user's reach:
```ts
type WorkQueueCounts = {
needs: number // waiting_human + pending tool approvals
running: number // sessions running/pending
delegatable: number // active actions with no live run
failed: number // failed/expired/abandoned within 24h
done: number // promoted today
runRate?: string // optional "~3/min" for Running detail
generatedAt: string // ISO; for "fresh as of N seconds ago"
}
getWorkQueueCounts(input: {
tenantId: string
userId: string
tz: string
}): Promise<WorkQueueCounts>
```
Queries (all parallel):
1. **Needs me** — UNION ALL of:
- `SELECT count(*) FROM sessions WHERE status = 'waiting_human' AND assignee_user_id = $userId AND tenant_id = $tenantId`
- `SELECT count(*) FROM session_events e JOIN sessions s ON s.id = e.session_id WHERE e.event_type = 'tool_call_requested' AND e.payload->>'status' = 'awaiting_approval' AND s.tenant_id = $tenantId AND (s.assignee_user_id = $userId OR s.owner_user_id = $userId)`
2. **Running** — `SELECT count(*) FROM sessions WHERE status IN ('running', 'pending') AND tenant_id = $tenantId AND (owner_user_id = $userId OR exists user follows entity)`. The "follows entity" join is opt-in; v1 may scope strictly to `owner_user_id` and revisit.
3. **Delegatable** — `SELECT count(*) FROM actions a WHERE a.status = 'active' AND a.agent_slug IS NOT NULL AND a.tenant_id = $tenantId AND a.owner_user_id = $userId AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.action_id = a.id AND s.status IN ('running','pending'))`.
4. **Failed** — `SELECT count(*) FROM sessions WHERE status IN ('failed','expired','abandoned') AND tenant_id = $tenantId AND owner_user_id = $userId AND updated_at >= now() - interval '24 hours'`.
5. **Done today** — `SELECT count(*) FROM entity_responses WHERE tenant_id = $tenantId AND promoted_at >= start_of_day($userId tz) AND source IN ('agent','manual')`. The day boundary uses the tenant timezone or user override.
Existing indexes cover all five (RLS leads with `tenant_id`; `sessions.status`, `sessions.updated_at`, `actions.next_run_at` are partial-indexed). No new migration needed.
### Component
`features/work-queue/components/command-strip.tsx`:
```tsx
export type CommandStripBucket =
| "needs" | "running" | "delegatable" | "failed" | "done"
export interface CommandStripProps {
counts: WorkQueueCounts
active?: CommandStripBucket
onPick?: (bucket: CommandStripBucket) => void
compact?: boolean // dense variant for sidebar mount in v2
className?: string
}
export function CommandStrip(props: CommandStripProps) { ... }
```
Five buckets via `CommandStripBucket` const array — label / icon / status-kind / sub-meta. Renders as a CSS grid with 5 equal columns on desktop, horizontal scroll on mobile (`<= 640px`). Each bucket is a `<button>` with shadcn `Card`-style chrome, the tabular-numeric count, and a one-line description on the non-compact variant.
`features/work-queue/components/command-strip-bucket.tsx` is the individual button — extracted to keep the parent under the 200-line limit.
States rendered:
- **Loading** — skeleton placeholders (5 cells, animated). Use shadcn `Skeleton`.
- **Empty** — zero count renders as muted `0` with no sub-meta. Failed bucket renders as muted (not red) when count is 0.
- **Active** — clicked bucket gets `border-ink-1 + shadow-sm`. Other buckets remain default.
- **Delta** — Failed shows `↑` if delta-since-last-load > 0 (regression signal). Done shows nothing — promotions are net-positive.
- **Run rate** — Running shows `~3/min` next to the count when `counts.runRate` is set (server computes from `sessions.updated_at` events in last 5 min).
### Hook
`features/work-queue/hooks/use-work-queue-counts.ts`:
```ts
useWorkQueueCounts(opts?: { staleSeconds?: number }):
UseQueryResult<WorkQueueCounts>
```
React Query under the hood. `staleTime: 15_000` (15s) by default. Realtime subscribes to a tenant-scoped channel `tenant:{tenantId}:work-queue` that fires INSERT/UPDATE on `sessions` and `session_events` filtered by tenant — invalidates the query rather than managing state directly (per `.claude/rules/performance.md`).
### Host mount: `/today` (v1)
The strip mounts at the top of `app/(app)/today/page.tsx`, above whatever Today renders currently. Click handling sets local state `activeBucket`; the page can then filter its sections to match. v1 wires picks to anchor scrolls (`#needs-me`, `#running`, etc.) inside the existing Today layout. Deeper "filter the whole page" UX is deferred.
### v2: shell-attached variant (deferred)
A second mount lives in the global app shell (above the breadcrumb, below the titlebar) as a compact 5-cell variant. Decision deferred to a follow-up spec because:
- Shell real-estate is contested (workspace switcher, search, command palette already there).
- Shell-attached means N realtime subscriptions for every logged-in user on every page — cost amortization questions.
- The reusable component can be dropped into Tasks, Inbox, Dashboard, or `/today` independently. v2 explores which surface earns shell-attached treatment, not whether to ship the component.
### View-based variant (future)
A `surface_type: "command-strip"` could be added to the views system so tenants customize bucket definitions per workspace. Deferred to v3; the component's API (`counts`, `onPick`) is already shaped for a future view-based driver to call into.
## Trade-offs
### Why shell-attached vs route-attached
- **Route-attached (chosen for v1)**: cheap to ship, low risk, easy to remove. The host (`/today`) owns the realtime subscription; only users on that page pay the cost.
- **Shell-attached (deferred)**: globally available, but every authenticated user pays for the realtime channel + query on every page. Cost amortization unclear until we measure. Also contests shell real estate with existing affordances (workspace switcher, search, command palette).
- Decision: ship route-attached v1, decide shell-attached in v2 once we have usage data.
### Why realtime vs poll-on-focus
- **Realtime (chosen)**: matches the operator's mental model of "what needs me right now." A pending approval that arrives 30 seconds after the page loads should bump the count without a refresh.
- **Poll-on-focus**: cheaper, but the strip becomes a stale snapshot the moment the user looks away. Defeats the point.
- Cost: one tenant-scoped realtime channel + one React Query invalidation handler per active user-page. Existing pattern (see chat history hook); within budget.
### Cost of N realtime subscriptions
- v1 (route-attached on `/today`): only users currently looking at `/today` subscribe. Bounded to a small fraction of authenticated users.
- v2 (shell-attached): every authenticated user on every page subscribes. Order-of-magnitude jump. Mitigations: (1) single tenant channel reused across all users in that tenant; (2) coalesce session_events INSERTs into the same channel as sessions UPDATEs; (3) backpressure via stale-time on the React Query layer.
- Decision: defer the shell-attached cost question to v2 once v1 ships and we have a baseline.
### Interaction depth — one-click acknowledge vs open detail
- **v1 (chosen)**: click bucket → host filters its content area to that bucket → user clicks individual item to drill in. Two clicks to action.
- **One-click acknowledge**: each bucket exposes a top-line action ("approve all 3 tool calls"). Tempting but dangerous — the approval flow needs per-item context (which tool, which args, which session). v1 surfaces the bucket; the per-item approval lives in `PendingToolCallsStrip` (already shipped) and human-task UIs (already shipped).
- v2 explores hover-preview + keyboard chord shortcuts (`G N` for "Go Needs me") once the v1 flow proves out.
### Where state lives — counts query vs entity-resolved list
- The strip queries **counts only** — five integers + run-rate string + delta. Resolving the full list of items per bucket is the host's job.
- Trade-off: a slightly chattier set of round-trips on bucket-pick (counts already loaded; list re-fetched). Benefit: the strip stays cheap regardless of how many items a bucket holds, and the host owns presentation.
- Anti-pattern avoided: bundling the first-N items per bucket into `WorkQueueCounts` would put kilobytes of session metadata into a query that's polled aggressively.
### Why not extend the existing sidebar count badges
- Sidebar shows `Inbox: 4`, `Tasks: 41` — route-keyed, not bucket-keyed. Counts answer "how many items in this section" not "what needs me right now."
- The strip's semantic is orthogonal to navigation. A "Failed" bucket spans Tasks + Inbox + Today + agent runs — fundamentally not a per-route count.
### Scope control
- v1 ships the component + the hook + the mount on `/today`. No shell mount. No view-based driver. No keyboard shortcuts.
- No persona-customized buckets, no per-tenant bucket definitions, no admin UI to toggle which buckets appear.
- No long-poll-with-toast notifications for off-page events (cross-tab notify is a separate surface).
- No mobile-specific layout beyond horizontal scroll — bucket count stays at 5 on all viewports.
## Acceptance criteria
**Server aggregator**
- [ ] `features/work-queue/server/aggregate.ts` exports `getWorkQueueCounts({tenantId, userId, tz})` returning `WorkQueueCounts`.
- [ ] Five queries run in parallel via `Promise.all`; each one is tenant-scoped via RLS, not by code filter alone.
- [ ] Co-located `aggregate.test.ts` with mocks covers happy path, zero-count case, day-boundary edge case, and 24h-rolling-window edge case for Failed.
- [ ] Failed query respects 24h rolling window from `sessions.updated_at`, not `created_at`.
- [ ] Done query respects user's tenant timezone for "today" boundary; falls back to UTC if unset.
- [ ] Performance: full call returns in under 200ms p50 on a tenant with 10k sessions + 1k actions (measured via `measure()` from `lib/performance/measure.ts`).
**Hook**
- [ ] `useWorkQueueCounts()` returns a React Query result keyed on `(tenantId, userId)`.
- [ ] Default `staleTime: 15_000`; configurable per caller.
- [ ] Realtime subscription on `tenant:{tenantId}:work-queue` invalidates the query on `sessions` INSERT/UPDATE or `session_events` INSERT.
- [ ] Cleanup on unmount removes the channel.
- [ ] Co-located test asserts subscription lifecycle and invalidation.
**Component**
- [ ] `CommandStrip` renders 5 buckets in a CSS grid (1fr × 5) at default density, horizontal scroll on viewports `< 640px`.
- [ ] Each bucket renders `(icon, label, count, sub-meta)` per the design reference; count uses tabular-nums + mono font.
- [ ] Loading state renders 5 shadcn `Skeleton` placeholders.
- [ ] Active bucket has visible border + shadow treatment distinct from inactive.
- [ ] Failed bucket shows `↑` indicator when `deltaSinceLast > 0`; otherwise no indicator.
- [ ] Running bucket shows `runRate` string next to count when present.
- [ ] All buckets are `<button>` elements with `aria-label`, focusable via tab, activatable via Space/Enter.
- [ ] Compact variant (`compact={true}`) renders without the sub-meta line; sized for shell mounting.
- [ ] No hardcoded Tailwind colors — uses CSS variables (`--ink-1`, `--bg-surface`, `--st-failed-ink`, etc.).
- [ ] Co-located test for: render with all-zero counts, render with non-zero counts, click handler fires `onPick(bucket)` with correct argument, active state visual diff.
- [ ] Component file under 200 lines (bucket sub-component extracted).
**`/today` host mount**
- [ ] Strip mounts above the existing Today layout.
- [ ] Bucket pick scrolls to corresponding anchor in the page (`#needs-me`, etc.) and sets `activeBucket` state.
- [ ] Initial render reads `getWorkQueueCounts` SSR via React.cache and passes as `initialData` to the hook — no loading flash.
- [ ] Mobile viewport (375px) keeps strip usable via horizontal scroll.
**Documentation**
- [ ] `/docs/features/sessions` gets a "Work Queue Command Strip" section linking the component and the aggregator.
- [ ] Sidebar `meta.json` unchanged (no new doc file needed; reuse `sessions.mdx`).
- [ ] Spec status flipped to `completed` and PR linked.
**Quality gates**
- [ ] `pnpm lint` clean
- [ ] `pnpm typecheck` clean
- [ ] `pnpm test` passes for new files (full suite at pre-push)
- [ ] `pnpm build` succeeds
- [ ] Visual verification at 1280x720 and 375x667 — strip renders correctly on `/today`
- [ ] No console errors on affected pages
- [ ] No regressions in `pnpm check:boundary-risk` (server action passes tenant-scope tests)
## Files
### New
```
features/work-queue/types.ts — WorkQueueCounts, CommandStripBucket
features/work-queue/server/aggregate.ts — getWorkQueueCounts server action
features/work-queue/server/aggregate.test.ts — co-located
features/work-queue/hooks/use-work-queue-counts.ts — React Query hook + realtime
features/work-queue/hooks/use-work-queue-counts.test.ts
features/work-queue/components/command-strip.tsx — main component
features/work-queue/components/command-strip.test.tsx
features/work-queue/components/command-strip-bucket.tsx — sub-component (one bucket button)
features/work-queue/components/command-strip-bucket.test.tsx
features/work-queue/index.ts — barrel re-exports for the component + hook + types
```
### Modified
```
app/(app)/today/page.tsx — mount CommandStrip + wire SSR initialData
app/(app)/today/loading.tsx — extend skeleton to include strip placeholder (optional)
content/docs/features/sessions.mdx — add "Work Queue Command Strip" section
```
### Not modified (referenced reuse, no edits required)
```
components/ui/delegation-badge.tsx — DRI-026 (reused in bucket expand)
features/sessions/components/pending-tool-calls-strip.tsx — DRI-028 (reused in Needs-me expand)
features/sessions/server/session-reaper.ts — read-only reference for waiting_human semantics
features/actions/server/dispatch.ts — read-only reference for delegatable definition
```
## References
- **Design source:** `~/SprinterVault/20-Ventures/Amble/03-Product/design-references/claude-design-docs-20260519/extracted/Sprinter Work Queue Agent Comms Redesign/` (PKG-036) and `Sprinter Work Queue Agent Comms Redesign (1)/` (PKG-037). Canonical artifact: `command-strip.jsx` for the five-bucket structure; `screen-today.jsx` for the host mount; `app-shell.jsx` for the future shell-attached variant.
- **Related work folders:**
- `documents/work/2026-05-20-design-reference-implementation/` — parent program, this is lane DRI-004.
- `documents/work/.../DRI-026-*` — `DelegationBadge` (`components/ui/delegation-badge.tsx`).
- `documents/work/.../DRI-028-*` — `PendingToolCallsStrip` (`features/sessions/components/pending-tool-calls-strip.tsx`).
- `documents/work/.../DRI-036` — Today route refactor (parallel lane).
- **ADRs:**
- **ADR-0016** — Unified `action-tick` worker + `queue_config` on cron actions. Source of truth for Delegatable bucket semantics.
- **ADR-0023** — Session executor lives in `features/sessions/server/executor.ts`. Source of truth for `sessions.status` lifecycle that drives Needs me / Running / Failed.
- **ADR-0024** — Cross-scope authorization invariants. Aggregator must filter by `getActiveTenantId()` from the URL, not a caller-supplied tenantId.
- **Target doc:** `/docs/features/sessions` — extended with a "Work Queue Command Strip" section on completion.