Skip to content

feat(crews): answer a Crew thread's provider approvals and questions from the Inbox - #309

Open
bryantderosier wants to merge 7 commits into
j5/crew-requests-serverfrom
j5/crew-requests-web
Open

bryantderosier wants to merge 7 commits into
j5/crew-requests-serverfrom
j5/crew-requests-web

Conversation

@bryantderosier

@bryantderosier bryantderosier commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Important

⚠️ Merge order: merge these in this exact order

#309 is based on #308, so #308 merges first. Neither has migrations or ordering against the other Crews PRs.

  1. feat(crews): a Crew thread's provider approvals and questions reach the Inbox API #308: A Crew thread's provider approvals and questions reach the Inbox API
  2. feat(crews): answer a Crew thread's provider approvals and questions from the Inbox #309: Answer a Crew thread's provider approvals and questions from the Inbox ⬅️ this PR

Problem

A Captain's or seat's provider approvals and questions render only inline in that thread's composer (ChatView feeds every pending request to ChatComposer), so I only see them if I open that thread, and the Crew stalls (#264). The Inbox and bell showed agent asks and Crew seat requests only.

What I changed

  • packages/client-runtime/src/j5/http.ts and state.ts: listCrewRuntimeRequests and respondCrewRuntimeRequest, plus a query atom family and a command, polled every 7.5 seconds like Crew gates.
  • apps/web/src/j5/state.ts: crewRuntimeRequestSourcesAtom, read on every connected environment.
  • apps/web/src/j5/crew/crewRuntimeRequests.logic.ts (new): mergeCrewRuntimeRequestSources, inboxRequestIdsForThread, withoutInboxRequests, and inboxBadgeCount.
  • apps/web/src/j5/crew/crewRuntimeRequestsClient.ts (new): the answer call, the forced refresh, the shared poll, and useJ5CrewRoutedRequests.
  • apps/web/src/j5/crew/CrewRuntimeRequestsSection.tsx (new): the Inbox's "Crew agent requests" section. Each item names its Crew and seat (or Captain) and links to the thread. Approvals show the provider's choices, or Approve and Decline by default; questions reuse upstream's pendingUserInput.ts helpers so answers have the composer's shape.
  • HumanInboxPage.tsx mounts the section and counts it; HumanInboxBell.tsx counts it through inboxBadgeCount.
  • apps/web/src/components/ChatView.tsx (upstream, FORK.md case 46): the derived memo is renamed providerPendingRequests and passed through useJ5CrewRoutedRequests, whose result keeps the name pendingRequests.
  • CrewRosterGate.tsx: shows "A request from this agent is waiting in your Inbox" with a link when the Inbox holds requests for the thread; the roster card is unchanged.
  • Docs: crews.md (the Definition's gate-placement rule, AC9, History) and the personas guide's Crews section, including that mobile still shows these prompts in the conversation.

Screenshots are from an isolated dev server on a copy of my data. The fixture Crew "Release Crew" has the Captain's thread holding a pending question and a builder seat holding a pending command approval. Every thread in the copy points at a provider instance that doesn't exist, so nothing could start a turn.

Seat thread, before (the approval inline) → after (the note):

Captain thread, before (the question inline) → after:

Inbox, before → after (bell: "2 open"):

Answering with no live provider session is refused, and the request stays listed:

The pass turned up two fixes, both in bcef3054c1: with no provider options, the Inbox offered only Approve and Decline while the composer offers four choices, so it now uses the composer's defaults; and the thread note sat on the composer's edge.

Why this shape

The thread hides exactly the request ids the Inbox currently lists for that thread on that environment. So a request the Inbox hasn't read yet, or can't read on a server without #263's route, stays inline. A prompt is never hidden from both places, and the thread doesn't need its own "is this a Crew thread" check. Putting the note in CrewRosterGate, which ChatView already mounts, keeps the upstream touch to one hook. The upstream approval and question panels are untouched.

Invariants

  • Non-Crew threads pass through unchanged (withoutInboxRequests returns the same object when no ids match).
  • The initial roster gate still renders inline; rosterGatesForThread is unchanged.
  • Ids are scoped by environment, so the same local thread id on two environments never hides another's prompt.
  • The bell counts asks, Crew seat requests, and Crew agent requests; an unread ask count still shows the Crew items.

Surfaces

Surface Decision
Entry points (chat, Settings, command palette, keybinding) Inbox page and bell; the thread composer's note. No new route or nav entry.
Clients (web, desktop, mobile) Web and desktop changed. Mobile keeps its inline cards (it has no J5 Inbox), which the personas guide states.
Providers Any provider whose requests reach the orchestrator; unaffected otherwise.
Contracts (packages/contracts) From #263; no further change.
Reverse states An answered or resolved request leaves the Inbox and the thread on the next read; the note disappears with it.
Connection modes (local, remote, tunnel) Read per connected environment; a server without the route fails its source alone and keeps its threads' inline panels.
Upstream files / FORK.md ChatView.tsx, recorded as FORK.md case 44.
Docs crews.md and docs/user/personas.md.

Out of scope

Upgrade and data

None.

Verification

  • crewRuntimeRequests.logic.test.ts covers:
    • a Crew thread's requests leaving the composer while unread ones stay inline;
    • a non-Crew thread keeping its panels;
    • environment scoping;
    • the bell count going up and back down.
  • vp test run apps/web/src/j5/crew apps/web/src/j5/a2a: 245 passed. ChatView.logic.test.ts: 125 passed.
  • Web typecheck clean. Lint clean apart from one existing set-state-in-effect warning in HumanInboxPage.tsx.
  • Seeded dev-server pass (screenshots above): Inbox items, bell count, notes on both threads, and inline panels gone on web; answering reached the orchestrator and was refused with 409 with no live session.
  • Not verified: a real provider turn resuming after an Inbox answer. The fixture had no live provider session by design.

Review focus

  • useJ5CrewRoutedRequests in ChatView: activeThreadId is passed even for drafts, which have no Inbox requests. Confirm nothing downstream depends on pendingRequests identity changing only with the projection.
  • CrewRuntimeRequestsSection handles multi-question requests all at once; check it against the composer panel's behavior.

Closes #264

Claude Opus 5.5 via Claude Code

🤖 Generated with Claude Code

@bryantderosier bryantderosier self-assigned this Sep 25, 2026
@bryantderosier
bryantderosier added this pull request to stack #310 September 25, 2026 01:00
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Repository: Jacksondr5/j5code/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: c551196c-c0d4-4c68-a303-c00e5e0430c9


Comment @coderabbitai help to get the list of available commands.

@bryantderosier
bryantderosier marked this pull request as draft September 25, 2026 01:00
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL 500-999 effective changed lines (test files excluded in mixed PRs). labels Sep 25, 2026
@bryantderosier
bryantderosier marked this pull request as ready for review September 25, 2026 01:15
bryantderosier and others added 2 commits September 24, 2026 21:25
…from the Inbox

A Captain's or seat's tool approvals and questions rendered inline in that
thread's composer, where the person only saw them by opening the thread, so
a Crew stalled unseen.

The Inbox gets a Crew agent requests section, read from #263's route on
every connected environment: each item names its Crew and seat (or the
Captain), links to the thread, and is answered in place with the provider's
approval choices or the question form. The bell counts them. On a live Crew
thread, one appended J5 hook in ChatView (FORK.md case 44) hands the
composer only the requests the Inbox does not hold, and CrewRosterGate shows
a note pointing to the Inbox; a request the Inbox has not read yet stays
inline, so nothing is hidden from both places. The roster gate still
renders inline, non-Crew threads are unchanged, and mobile keeps its inline
cards until it has an Inbox.

Closes #264

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ead note aligns

Found in the seeded dev-server pass: with no provider options the Inbox
offered only Approve and Decline while the composer offers Cancel, Decline,
Always allow this session, and Approve; the note sat on the composer's edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bryantderosier and others added 5 commits September 25, 2026 15:07
Conflicts in crews.md and personas.md: took the sync's handoff artifact wording and kept this branch's Inbox rule. FORK.md: this branch's case is renumbered 44 → 49, since the sync took 44 and 45 and the Captains stack takes 46–48; the inventory count line is left to the Captains stack so the two land without conflicting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The upstream sync's Badge lint (shadcn/no-restyle) refuses spacing and typography classes on <Badge>; the Crew agent request row's seat badge now uses size="sm" like the other Crew badges.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rest inline

With #308's selector matching the composer, a request can be listed that the
Inbox cannot answer: a question answered by sending a message, or one that is
no longer resumable. The Inbox now offers answers only for live requests and
points to the thread for the rest, and the composer keeps those requests
inline instead of handing them to an Inbox that cannot answer them.

FORK.md case 46 records the shared selector and the client-runtime test that
holds it to derivePendingThreadRequests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
});

/** The bell, the Inbox page, and every open thread share this one foreground poll. */
export const useCrewRuntimeRequestsRefresh = createVisibleRefreshHook(() => {

@Jacksondr5 Jacksondr5 Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Review panel: Opus 5.5 + Astra] performance: this poll runs all the time, even when nothing is pending, and every hit makes the server load a full projection for each live Crew thread.

HumanInboxBell is always mounted and calls useCrewRuntimeRequests(). So every visible web or desktop window sends POST /crews/runtime-requests to every connected environment every 7.5s, including when no Crew exists or nothing is pending, which is nearly always. On the server, #308's list runs listLive and then a full getThreadProjection (all turn items, messages, and runs) for each Captain and seat thread. Two Crews of five seats each means 12 full projection loads every 7.5s per open window, on top of the existing 7.5s crew-proposal poll. #324 narrows the server read but leaves the request count unchanged.

Thread shells already push hasPendingApprovals / hasPendingUserInput for every thread (packages/client-runtime/src/state/models.ts:245). Suggest gating the poll on them:

  • Poll an environment only while at least one of its thread shells has either flag set.
  • Refresh as soon as a flag turns on or off, plus the existing forced refresh after an answer.

Keep the interval while the environment has pending requests: one pending request can be replaced by another while the flag stays true, so reacting to flag changes alone would miss the replacement. In the steady state, with nothing pending, the result is zero requests.

): ReadonlyArray<{ readonly decision: ProviderApprovalDecision; readonly label: string }> =>
options ?? DEFAULT_APPROVAL_CHOICES;

const REQUEST_KIND_LABEL: Record<string, string> = {

@Jacksondr5 Jacksondr5 Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Review panel: Opus 5.5 + Astra] The Inbox approval buttons drop the provider's risk warning. Fix it by reusing the upstream approval components, which also lets this file lose its copied approval UI.

Bug: The approval buttons read only decision and label from each option (approvalChoices also narrows the type to those two fields), so a ProviderApprovalOption.warning never reaches the screen. That field is the provider's caution for an option, such as a prompt-injection warning (packages/contracts/src/providerPolicy.ts:62). The composer's ComposerPendingApprovalActions shows it as a warning icon, a tooltip, and an aria-description (:54-64, :84-96). A Crew approval can only be answered in the Inbox, so the user approves without the risk explanation the composer would have shown. A second, smaller bug: REQUEST_KIND_LABEL has no "permission" entry, so a permission approval is labelled plain "Approval" where the composer says "App permission approval".

Fix, which is also a simplification: mount the upstream components directly. Neither one uses hooks or context, and both take plain props:

  • ComposerPendingApprovalActions takes { requestId, isResponding, canRespond, options, onRespondToApproval }. It brings the warnings, the default choices, and the overflow menu. Its only composer-specific piece is composerFloatingLayerProps, a plain data attribute.
  • ComposerPendingApprovalPanel takes { approval: PendingApproval, pendingCount }. PendingApproval is ThreadPendingApproval, the shape the selector already produces. The panel brings the kind label, the detail formatting, and the not-resumable text.

The imports come from this J5-owned file, so no upstream file changes. You can then delete DEFAULT_APPROVAL_CHOICES, approvalChoices, REQUEST_KIND_LABEL, and the approval JSX. The question path is tied more closely to the composer, so keeping its own UI with the shared pendingUserInput.ts helpers is reasonable.

@Jacksondr5

Copy link
Copy Markdown
Owner

[Review panel: Opus 5.5 + Astra] Live test evidence for #308 and #309, run by Astra against real provider turns on an isolated dev server. This fills the PR's "Not verified: a real provider turn resuming after an Inbox answer".

Scenario Result
A real Claude Captain's AskUserQuestion and a real seat's Bash approval pending at the same time Pass. The Inbox listed both and the bell showed 2.
Both answered from the Inbox UI Pass. Both POSTs returned 200. The Captain then printed CAPTAIN_RESUMED_BLUE and the seat printed SEAT_RESUMED_AFTER_APPROVAL, and the seat's harmless /tmp proof file had the expected content.
Answered inline in a second browser, then answered again from the stale Inbox card Pass. The second answer got a 409 ("already resolved") and the card left on the next poll. The event log shows one pending and one resolved runtime-request event, with only the inline command's id, so no second response was sent to the provider.
Approval on a thread that isn't in a Crew Pass. It stayed inline, the bell stayed at 0, the Inbox didn't show it, and answering it inline resumed the provider.
Crew retired while a Captain question was pending Pass. The next poll removed it from the Inbox, the "waiting in your Inbox" note went away, the question showed inline again, and answering it inline worked.
Poll traffic, 60s, no live Crews, Inbox closed 8 POSTs, 120 response-body bytes in total (15 each), request bodies 2 bytes each (headers excluded). With two pending requests, one response is 2,154 bytes, about 17.2 KB/min. The data had 171 threads and one small test Crew, so these numbers say nothing about server cost with large Crews.

Not tested: the "waiting in your Inbox" note on a seat thread (only the Captain's note was checked), the native desktop shell, mobile, and a disconnect in the middle of a flow. Bryant's before-images in the PR description cover the baseline.

Inbox with both requests:

Note on the Captain's thread:

Captain resumed after the Inbox answer:

Seat resumed after the Inbox approval:

Stale Inbox answer refused with 409:

Approval on a non-Crew thread stays inline:

After the Crew retired, the question is inline again:

@Jacksondr5

Copy link
Copy Markdown
Owner

Decision (Jackson, 2026-09-26): slim #308/#309 to seat approvals only. See the matching comment on #308 for why.

For #309 that means:

  • Don't hide anything in the thread. A seat thread isn't the one the person watches, so showing a prompt both inline and in the Inbox is harmless: answering in either place resolves it, and a stale Inbox answer gets a clean 409. Remove:
    • the upstream ChatView.tsx change and its FORK.md case;
    • useJ5CrewRoutedRequests / withoutInboxRequests;
    • the "waiting in your Inbox" note in CrewRosterGate.
  • Approvals only, from seats only. Drop the question UI in CrewRuntimeRequestsSection.
  • The panel's fixes that still apply:
    • reuse upstream's ComposerPendingApprovalPanel/Actions, so provider warnings and the "permission" label show;
    • poll only while a seat thread has a pending approval, instead of an always-on 7.5s poll.
  • The docs (crews.md, personas.md) describe the slimmed behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 effective changed lines (test files excluded in mixed PRs). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A Crew thread's provider approvals and questions are answered from the Inbox, not inline (web)

2 participants