Skip to content

fix(send): submit multiline prompts + never nudge a session paused on a permission dialog#2357

Merged
illegalcall merged 3 commits into
AgentWrapper:mainfrom
axisrow:upstream-send-confirmation
Jul 11, 2026
Merged

fix(send): submit multiline prompts + never nudge a session paused on a permission dialog#2357
illegalcall merged 3 commits into
AgentWrapper:mainfrom
axisrow:upstream-send-confirmation

Conversation

@axisrow

@axisrow axisrow commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #2342.

Problem

ao send returns HTTP 200 the moment tmux send-keys exits 0, but for a large multiline prompt claude-code's TUI can absorb the trailing Enter and leave the text as an unsubmitted draft (#2342). UserPromptSubmit never fires, so the orchestrator can't tell the worker started and polls endlessly.

Fixing that surfaced a second, sharper problem: a session paused on a permission dialog looked identical to a session waiting at an idle prompt, so an automated re-send (or any reaction nudge) could paste Enter into the dialog and answer a decision on the user's behalf — an unrecoverable action.

What this does

Three commits, each a self-contained layer:

  1. feat(activity): reintroduce blocked state — splits the overloaded waiting_input into waiting_input (idle at the prompt, safe to message) and blocked (paused on a permission/approval decision, must not be auto-messaged). Zero DB migrations — the sessions CHECK constraint already permits blocked (it was removed as dead code without rebuilding the table). Adds a NeedsInput() predicate; both states map to the existing needs_input status, so the UI and telemetry are unchanged.

  2. fix(send): confirm prompt submission via a guarded pane-write primitive — after Send, a bounded confirm loop (3×2s) re-sends Enter until the session reports active, so a big multiline paste actually submits. All pane-writing paths (session_manager.Send/confirmActive, lifecycle.sendOnce) funnel through a new internal/sessionguard primitive that re-reads session state immediately before writing and refuses when the session is blocked/terminated (fail-closed). ao send into a blocked session now returns 409 SESSION_AWAITING_DECISION instead of pasting into the dialog. tmux gains a 300ms paste→Enter settle (matching conpty), on a detached context so a cancel mid-pause can't strand a half-submitted draft.

  3. feat(activity): clear stale blocked via the approved tool's own post — answering a permission dialog fires no hook, so the sticky blocked would otherwise persist until turn-end. claude-code installs PreToolUse/PostToolUse/PostToolUseFailure/PermissionRequest hooks; lifecycle keeps per-session tool-flight state and clears blocked only when the uniquely-identified approved tool's post arrives (or a turn boundary), correlating by the single in-flight tool of the blocking name — ambiguous same-name batches fail closed. Subagent/sibling tool traffic can never clear a live dialog. The activity signal gains optional {event, toolName, toolUseId} fields (additive; old daemons ignore them).

Compatibility

No DB migrations, no changes to ports.AgentMessenger, lifecycle.New/session_manager.Deps signatures, or the hooks wire protocol beyond three additive optional fields. Every adapter that doesn't tag its signals keeps last-writer-wins behavior. openapi.yaml/schema.ts regenerated.

Testing

  • go build ./... && go vet ./... && go test -race ./... — all packages green; new sessionguard and tool-flight suites pin the guard and precedence invariants.
  • Live-verified on both claude-code and codex workers: a 27-line multiline message submits fully (agent reads and replies, no draft left in the composer); a permission dialog drives the session to blocked, ao send returns 409, and approving in the terminal clears it to active before turn end via the approved tool's post.

This work went through three rounds of adversarial review (Claude + Codex) on my fork; the same-name-sibling clear, an ambiguity-reset leak, and a reaper flight-state leak were each caught and fixed there.

🤖 Generated with Claude Code

polymath-orchestrator added a commit to polymath-ventures/agent-orchestrator that referenced this pull request Jul 6, 2026
…-dialog nudge guard

Ports AgentWrapper#2357 (fixes upstream AgentWrapper#2342) onto our
fork's post-AgentWrapper#2349 consolidated adapter architecture. Upstream AgentWrapper#2357 was never
rebased onto the adapter-layer consolidation (AgentWrapper#2349), so the per-agent
activity.go edits it carries do not apply here; this reconstructs the same
semantics against the consolidated layout.

Three concerns, matching the upstream sub-PRs squashed into AgentWrapper#2357:

A. Reintroduce ActivityBlocked (upstream #2) — a decision-pause distinct from
   waiting_input. permission-request now derives blocked in the shared
   activitystate deriver (the one consolidated file replacing the 8 per-agent
   copies); claudecode splits notifications (idle_prompt→waiting_input,
   permission_prompt/agent_needs_input→blocked) and adds permission-request →
   blocked; codex/droid derive the conservative blocked. Both states render
   needs_input, so the dashboard/frontend vocabulary is unchanged. Ingress
   accepts "blocked"; status uses the NeedsInput family.

B. Send confirmation + sessionguard (upstream #5) — tmux pauses 300ms between a
   non-empty paste and the trailing Enter (detached from caller cancellation),
   fixing the multiline unsubmitted-draft race at its source; the new
   sessionguard package centralizes the just-in-time pre-paste blocked re-read;
   session_manager delivers through Guard.Deliver (409 SESSION_AWAITING_DECISION
   on a blocked session) and confirms via Guard.Nudge; lifecycle sendOnce routes
   through the guard. The Enter nudge is gated on ports.ActivitySignaler
   (EmitsSubmit + EmitsBlocked); goose/opencode/agy opt out of the nudge,
   copilot opts out entirely.

C. Correlated stale-blocked clearing (upstream #7) — the activity signal carries
   {event, toolName, toolUseId}; lifecycle keeps per-session tool-flight state
   and clears a sticky blocked only when the exact approved tool's post fires or
   at a turn boundary, failing closed on ambiguity/restart.

Gates: backend go build/vet/test green (the 4 pre-existing cli spawn tests fail
on clean main too — sandbox daemon 404, unrelated); openapi.yaml + frontend
schema.ts regenerated; frontend typecheck green. Canonical change is upstream
AgentWrapper#2357; we carry the delta until it merges.

Refs: AgentWrapper#2357, AgentWrapper#2342
Closes #9

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113G9d2yYNGxX37JyJPXFeZ
polymath-orchestrator added a commit to polymath-ventures/agent-orchestrator that referenced this pull request Jul 6, 2026
…-dialog nudge guard

Ports AgentWrapper#2357 (fixes upstream AgentWrapper#2342) onto our
fork's post-AgentWrapper#2349 consolidated adapter architecture. Upstream AgentWrapper#2357 was never
rebased onto the adapter-layer consolidation (AgentWrapper#2349), so the per-agent
activity.go edits it carries do not apply here; this reconstructs the same
semantics against the consolidated layout.

Three concerns, matching the upstream sub-PRs squashed into AgentWrapper#2357:

A. Reintroduce ActivityBlocked (upstream #2) — a decision-pause distinct from
   waiting_input. permission-request now derives blocked in the shared
   activitystate deriver (the one consolidated file replacing the 8 per-agent
   copies); claudecode splits notifications (idle_prompt→waiting_input,
   permission_prompt/agent_needs_input→blocked) and adds permission-request →
   blocked; codex/droid derive the conservative blocked. Both states render
   needs_input, so the dashboard/frontend vocabulary is unchanged. Ingress
   accepts "blocked"; status uses the NeedsInput family.

B. Send confirmation + sessionguard (upstream #5) — tmux pauses 300ms between a
   non-empty paste and the trailing Enter (detached from caller cancellation),
   fixing the multiline unsubmitted-draft race at its source; the new
   sessionguard package centralizes the just-in-time pre-paste blocked re-read;
   session_manager delivers through Guard.Deliver (409 SESSION_AWAITING_DECISION
   on a blocked session) and confirms via Guard.Nudge; lifecycle sendOnce routes
   through the guard. The Enter nudge is gated on ports.ActivitySignaler
   (EmitsSubmit + EmitsBlocked); goose/opencode/agy opt out of the nudge,
   copilot opts out entirely.

C. Correlated stale-blocked clearing (upstream #7) — the activity signal carries
   {event, toolName, toolUseId}; lifecycle keeps per-session tool-flight state
   and clears a sticky blocked only when the exact approved tool's post fires or
   at a turn boundary, failing closed on ambiguity/restart.

Gates: backend go build/vet/test green (the 4 pre-existing cli spawn tests fail
on clean main too — sandbox daemon 404, unrelated); openapi.yaml + frontend
schema.ts regenerated; frontend typecheck green. Canonical change is upstream
AgentWrapper#2357; we carry the delta until it merges.

Refs: AgentWrapper#2357, AgentWrapper#2342
Closes #9

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113G9d2yYNGxX37JyJPXFeZ
polymath-orchestrator added a commit to polymath-ventures/agent-orchestrator that referenced this pull request Jul 7, 2026
…nly genuine ones (#93)

* docs(orchestrator): triage needs_input — answer simple Qs, escalate only genuine ones

Encode the orchestrator standing duty to triage every worker in
needs_input and answer the self-resolvable ones directly instead of
leaving them stuck for Nick. Expands the thin supervision-list item into
a dedicated `needs_input triage` protocol:

- inspect the pane first
- answer via `ao send` when resolvable from issue/spec, task context,
  repo conventions, or reasonable engineering judgment (clarifications,
  yes/no, which-approach, proceed?, defaults)
- escalate to Nick (@mention, the #87 escalation path) ONLY for
  product/judgment calls, unresolvable ambiguity, destructive actions,
  external credentials, or a genuine blocker
- default is unblock, not escalate — a self-answerable stuck worker is a
  supervision defect

Permission/destructive prompts always escalate and are never
self-answered, mirroring the send/permission-dialog guard (AgentWrapper#2357); also
added as a Hard line for prominence.

Closes #90

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxPmZdfsrGTVfbCK5an3MA

* docs(orchestrator): disambiguate merge go-ahead and engineering vs product judgment

Address Codex review of PR #93:
- A worker's "should I merge?" is not a self-answerable "proceed?" — merge
  go-ahead is governed by CLAUDE.md rule 6, never on the orchestrator's own
  initiative.
- Split "judgment": engineering judgment (which-approach, defaults,
  conventions) is self-answerable; only product/business-judgment calls
  escalate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxPmZdfsrGTVfbCK5an3MA

* docs(orchestrator): include step 4 in the conservative-escalation rule

Address Codex re-review of PR #93: the closing "bias toward escalation
ONLY for the categories in step 3" excluded step 4's always-escalate
permission/destructive class. Widen to "steps 3 and 4" so permission
prompts are unambiguously on the escalate side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxPmZdfsrGTVfbCK5an3MA

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@axisrow axisrow force-pushed the upstream-send-confirmation branch from b05f433 to c292d84 Compare July 9, 2026 11:26
@axisrow

axisrow commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed: rebased onto current `main` and squashed the three original commits (#2 blocked state, #5 guarded send-confirmation, #7 correlated clearing) plus the later Guard decorator refactor into one commit.

One behavior change worth flagging for review: `permission-request` now maps to `blocked` only for claude-code (and its hook-delegators grok/continue/devin). Those are the only harnesses that install the pre/post-tool-use trio, so they are the only ones where lifecycle can correlate the approved tool's post with the dialog and clear `blocked` mid-turn. Every other harness that mapped `permission-request`/`notification` to `blocked` could enter the state but never clear it before the turn boundary, so `ao send` returned 409 for the rest of the turn and automated nudges were suppressed — copilot/cline/kiro/cursor on every tool call (their pre-tool hook maps to permission-request), codex/qwen/autohand after a real dialog, droid on a 60s-idle notification. Those now map to `waiting_input` (still suppresses automated nudges via NeedsInput, still deliverable by user sends), and they opt out of the Enter nudge via `EmitsBlockedActivity()`.

@axisrow axisrow force-pushed the upstream-send-confirmation branch from c292d84 to a17870d Compare July 10, 2026 07:00
@axisrow

axisrow commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (8 upstream commits) and accepted upstream PR #2541idle_prompt now maps to idle (a finished turn at the prompt is Idle, not Input Needed), while permission_prompt stays blocked for the sessionguard 409.

Scope narrowed to claude-code only — it is the one harness with the pre/post-tool-use trio that lets lifecycle correlate the approved tool's post and clear blocked before the turn ends. codex keeps its submit signal but opts out of blocked (no tool trio). Every other harness maps its permission signal to waiting_input via the shared deriver and does not implement ActivitySignaler, so it gets the paste-settle delay but no confirm loop. The 13-harness mapping set removed from this branch is preserved on fork/archive/blocked-mappings for restoration when a harness is adopted.

@neversettle17-101 neversettle17-101 self-requested a review July 11, 2026 04:16
@axisrow axisrow force-pushed the upstream-send-confirmation branch 2 times, most recently from 37a0c96 to f6d750e Compare July 11, 2026 08:03
Comment thread backend/internal/sessionguard/guard.go
axisrow and others added 2 commits July 11, 2026 16:50
…e-code)

`ao send` returned 200 the moment the runtime paste + Enter exited 0, but a
large multiline prompt's trailing Enter could be absorbed by the TUI —
claude-code kept it as an unsubmitted draft, user-prompt-submit never fired,
and the orchestrator could not tell a hung message from an idle worker (AgentWrapper#2342).

Fix (claude-code): a 300ms paste-settle delay before Enter (mirrors conpty),
then a bounded post-send confirm loop that re-sends a bare Enter until the
durable Activity.State flips to active (3×2s). A new sessionguard package
guards every pane write: Deliver refuses a blocked/missing/terminated session
and reports why via a typed Outcome, so `ao send` to a session paused on a
permission dialog returns 409 SESSION_AWAITING_DECISION instead of pasting
Enter into the dialog. blocked is cleared by correlating the approved tool's
post-tool-use with the dialog that blocked the session (lifecycle tool-flight).

Scoped to claude-code — the only harness that installs the pre/post-tool-use
trio enabling correlated clearing. codex keeps the submit signal but opts out
of blocked (no tool trio). Every other harness maps its permission signal to
waiting_input via the shared deriver and does not implement ActivitySignaler,
so it gets the paste settle but no confirm loop. The prior 13-harness mapping
set is preserved on fork/archive/blocked-mappings for restoration when a
harness is adopted.

Notification semantics (merged with upstream PR AgentWrapper#2541): idle_prompt → idle
(finished turn, awaiting instruction); agent_needs_input → waiting_input;
permission_prompt → blocked; agent_completed → idle.

Zero migrations: the sessions CHECK already permits blocked; NeedsInput()
covers both sticky states so the derived needs_input status is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019NxfHmtjXz1oVDwnF4R22t
Fixes the 12 golangci-lint findings + gofmt that failed the build-test
and lint checks on this branch:

- goimports/gofmt: order sessionguard before skillassets in
  session_manager/manager.go imports.
- nilerr: Send's best-effort confirm no longer returns nil on a store
  error; it logs a warning instead (confirmation never fails the send).
- revive: add the missing doc comment for EmitsBlockedActivity; rewrite
  the five sessionguard.Outcome const comments in the standard
  "Name ..." form.
- staticcheck QF1008: drop the redundant embedded fakeStore selector in
  blockOnNthGetStore / blockAfterFirstReadStore (lifecycle +
  session_manager tests).

gofmt clean, golangci-lint v2: 0 issues, affected tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
@axisrow axisrow force-pushed the upstream-send-confirmation branch from f6d750e to 92166a0 Compare July 11, 2026 08:52
@axisrow

axisrow commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the latest main (662405cd) — it had advanced past the old base, so the PR was showing an inflated diff and mergeable: false. Now mergeable: true, 2 commits, scoped to the send fix.

Conflict with the token-based design-system migration (#2535): my blocked activity-pill in ShellTopbar.tsx / SessionInspector.tsx collided with the new CSS tokens. Resolved by adopting the new tokens (var(--color-working) / var(--color-text-muted) / var(--color-warning)) and keeping the blocked → "Awaiting Decision" row, using the same warning tone as waiting_input.

The earlier CI failures (build-test gofmt, lint golangci-lint) are fixed in the second commit (import ordering, a nilerr in Send, revive doc comments, two staticcheck QF1008). Verified locally: gofmt clean, golangci-lint v2 0 issues, all 534 frontend tests + the Go suite pass. CI here is waiting on "Approve and run workflows" for the new push from a fork.

Address review on AgentWrapper#2357 (sessionguard.Guard.Send): Send folded a
suppressed guard outcome into nil, so after-start prompt delivery
(Spawn/Restore when the agent requests PromptDeliveryAfterStart) reported
success even when the write was actually refused — a session that
terminated or hit a permission dialog between readiness and injection was
reported as a successful spawn whose prompt was never delivered.

deliverAfterStartPrompt now calls Deliver directly and maps non-Sent
outcomes to the existing ErrNotFound/ErrTerminated/ErrAwaitingDecision
sentinels (same mapping the user-facing Send uses), so suppression fails
the spawn and triggers the existing cleanup path.

Guard.Send stays (it satisfies ports.AgentMessenger) but its comment no
longer claims suppression is impossible at after-start delivery; it now
warns that callers whose success contract depends on the write landing
must call Deliver.

Test: TestSpawn_AfterStartPromptSuppressedTerminationFailsSpawn covers the
terminated-before-injection case (prompt not delivered, spawn fails).

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

@illegalcall illegalcall left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the latest push. The after-start prompt suppression issue is addressed by using Deliver directly, and the conflict with the frontend design tokens is resolved. Backend test suite passes locally.

@illegalcall illegalcall merged commit e867496 into AgentWrapper:main Jul 11, 2026
10 checks passed
axisrow added a commit to axisrow/agent-orchestrator that referenced this pull request Jul 11, 2026
Apply the same golangci-lint + gofmt fixes that unblocked PR AgentWrapper#2357, so
this branch passes CI when it opens:

- gofmt: manager.go imports (sessionguard before skillassets), project.go.
- nilerr: Send's best-effort confirm logs the store error instead of
  returning nil on it.
- revive: doc comment for EmitsBlockedActivity; standard "Name ..." form
  for the five sessionguard.Outcome consts.
- staticcheck QF1008: drop redundant embedded fakeStore selector in
  blockOnNthGetStore / blockAfterFirstReadStore.

gofmt clean, golangci-lint v2: 0 issues, full affected test suite passes.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(send): ao send hangs on multiline — message sits unsubmitted in worker input

2 participants