Files
Codeman/src/web/screen-blocked.ts
T
Codeman maintainer a35ddd82ba feat(codex): raise a screen-read dialog as BLOCKED, alert-only
A codex dialog the session now reads off its screen gets exactly what a
Claude `permission_prompt` hook gets, so every surface that already handles
that event needs no second path: `web/screen-blocked.ts` turns the session's
`blockedChanged` edge into the `blocked` wait signal, an Approvals Inbox
item, the `hook:permission_prompt` broadcast (with `source: 'screen'`), web
push and webhooks, and a run-summary row. That lights the red "needs you"
tab alert and desktop notification, the phone overview's NEEDS YOU row and
the TUI. When the dialog leaves the screen the item it opened is resolved,
and `approval:resolved` clears every client.

It is an ALERT and never a keystroke, following #610's shape for DeepSeek:
the item is `answerInTerminal`, push carries no `approvalId` (so no
Approve/Deny), and the answer route refuses it. The dialog was read from a
screen the agent writes most of, so a forged match must cost a false alert
and nothing more. The message every surface shows is built from the CLI's
registry label, never from the dialog's text. The item gets no `capture`
callback, only its frame as context, because the inbox's option parser and
staleness sweep are shaped for Claude's dialogs and the session owns this
item's lifetime. A CLI whose hooks report blocked gets nothing from the
screen, so declaring both could never alert twice.

The answer route now checks `answerInTerminal` BEFORE its hooks gate and
names the CLI: a codex session has no hooks, and "Session mode cannot have
pending approvals" would be false about the item the caller holds.

Wait signals: `blocked` is available where hooks report it OR a CLI declares
`blockedLine` (`blockedSignalAvailable()`); `stop` stays hooks-only, since
nothing on a screen says a turn ended, so `until=stop` on codex is still a
400 (now suggesting `idle, blocked or exit`). A screen-read dialog is a
level the server keeps reading, so a `GET .../wait` that asks for `blocked`
while one is up answers at once; a caller that asked for `idle` alone keeps
getting `idle`. The MCP and `codeman agent` help text say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:18:37 +02:00

136 lines
6.1 KiB
TypeScript

/**
* @fileoverview Screen-detected blocked dialogs: what the server does with them.
*
* A CLI that has no hook to say "I stopped to ask you something" (codex: `hooks: 'none'`,
* and the one CLI Codeman launches with its approvals on by default) declares
* `capabilities.workDetect.blockedLine`, and `Session` reads its dialogs off the pane and
* emits `blockedChanged` (see `_noteBlockedReading()` in session.ts). This module turns
* that edge into exactly what a Claude `permission_prompt` hook produces in
* `hook-event-routes.ts`, so every surface that already knows that event (the red tab
* alert and desktop notification, web push, webhooks, the phone overview's NEEDS YOU, the
* Approvals Inbox, `codeman tui`, the run summary and the `blocked` wait signal) needs no
* second code path:
*
* - `raiseScreenBlocked()`: the `blocked` wait signal, an Approvals Inbox item, the
* `hook:permission_prompt` broadcast and the push/webhook.
* - `clearScreenBlocked()`: resolve that item, which broadcasts `approval:resolved`, and
* every client clears the alert from it (`_onApprovalResolved`, approvals-ui.js).
*
* Rules it keeps:
* - ⚠️ ALERT ONLY. The item is `answerInTerminal` (the #610 shape the DeepSeek Harness
* items use): the answer route refuses it, push carries no Approve/Deny, and the inbox
* card and the overview strip say "answer in the terminal". The dialog is read
* off a screen the agent writes most of, so a forged match must cost a false alert and
* never a keystroke. Codeman does not answer these dialogs.
* - The item gets no `capture` callback, only the frame it was raised from as context:
* the inbox's option parser and staleness sweep are shaped for Claude's dialogs, and the
* SESSION owns this item's lifetime (it re-reads the screen while the dialog is up).
* - Hooks win. A session whose CLI reports blocked itself (`hooksAvailableForMode()`)
* gets nothing here, so a CLI that declared both could never alert twice for one dialog.
* - Only the item this module opened is resolved on clear, never a hook-raised one.
*
* @dependencies approval-inbox, session-wait-registry, sse-events, cli-registry
* @consumedby web/session-listener-wiring (blockedChanged)
* @module web/screen-blocked
*/
import { getCli } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { approvalInbox } from './approval-inbox.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from './session-wait-registry.js';
import { SseEvent } from './sse-events.js';
/** Marks a screen-read event, so a consumer can tell it from a hook-raised one. */
export const SCREEN_BLOCKED_SOURCE = 'screen';
/** The facts about a session this module reads (structural, so tests need no `Session`). */
export interface ScreenBlockedSession {
id: string;
name: string;
mode: SessionMode;
deepSeekStatusReporting?: boolean;
docker?: unknown;
remote?: unknown;
}
/** What the server lends it: the same channels the hook route uses. */
export interface ScreenBlockedDeps {
broadcast(event: string, data: unknown): void;
broadcastSessionStateDebounced(sessionId: string): void;
sendPushNotifications(event: string, data: Record<string, unknown>): void;
getRunSummaryTracker?(
sessionId: string
): { recordHookEvent(event: string, data?: Record<string, unknown>): void } | undefined;
}
/**
* The line every surface shows for such a dialog. Built from the CLI's own label (data,
* never an id branch), and from nothing on the screen: the dialog's wording is the
* agent's to choose (a question's text, a command preview), so none of it reaches a push
* or a webhook. The approval card shows the frame itself, escaped.
*/
export function screenBlockedMessage(mode: SessionMode): string {
return `${getCli(mode)?.label ?? mode} is waiting for your answer in the terminal`;
}
/**
* The pane now shows a dialog its CLI is blocked on: raise everything a Claude
* `permission_prompt` hook would.
*
* @param frame the capture the dialog was read from, kept as the card's context
* @returns the id of the Approvals Inbox item opened, or null when nothing was raised
*/
export function raiseScreenBlocked(
session: ScreenBlockedSession,
deps: ScreenBlockedDeps,
frame?: string | null
): string | null {
if (hooksAvailableForMode(session.mode, sessionHookOptions(session))) return null;
const message = screenBlockedMessage(session.mode);
// Before anything that can throw, like the hook route: a caller parked on `blocked`
// is the reason this exists for agents.
sessionWaits.notifySignal(session.id, 'blocked');
const item = approvalInbox.notePrompt({
sessionId: session.id,
sessionName: session.name,
kind: 'permission',
message,
answerInTerminal: true,
context: frame ?? undefined,
});
const data = { source: SCREEN_BLOCKED_SOURCE, message };
deps.broadcast(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
timestamp: Date.now(),
...data,
approvalId: item.id,
});
// Same ride-along as the hook route: the home screens rank the blocked group on the
// session's stamps, so the row moves without waiting for some unrelated event.
deps.broadcastSessionStateDebounced(session.id);
// No approvalId: push would offer Approve/Deny for a dialog nobody here may answer.
deps.sendPushNotifications(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
sessionName: session.name,
...data,
});
deps.getRunSummaryTracker?.(session.id)?.recordHookEvent('permission_prompt', data);
return item.id;
}
/**
* The dialog left the screen (answered, cancelled, or the pane went away): resolve the
* item `raiseScreenBlocked()` opened, which clears the alert on every client. An item
* that is no longer that one (dismissed, expired, or superseded by a hook) is left alone.
*
* @param itemId what `raiseScreenBlocked()` returned
*/
export function clearScreenBlocked(sessionId: string, itemId: string | null): void {
if (!itemId) return;
if (approvalInbox.getForSession(sessionId)?.id !== itemId) return;
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}