mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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>
This commit is contained in:
1 parent
beae377122
commit
a35ddd82ba
17 files changed
+557
-33
No files matched your search
+1
-1
@@ -914,7 +914,7 @@ export function registerAgentCommands(program: Command): Command {
|
||||
)
|
||||
.option(
|
||||
'-u, --until <signals>',
|
||||
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
|
||||
'Comma list: stop,idle,exit,working,blocked (stop needs hook signals for the session, blocked hooks or a dialog Codeman reads off the screen, as for codex; where there are none the server answers 400, passed through)'
|
||||
)
|
||||
.option(
|
||||
'-m, --match <marker>',
|
||||
|
||||
+2
-1
@@ -270,7 +270,8 @@ export function buildToolDefinitions(modes: readonly string[]): ToolDefinition[]
|
||||
'prompt, idle, working, exit) or prints a literal marker (match). Returns timed_out when timeout_ms passes: ' +
|
||||
'call again to keep waiting. Your own prompt is echoed into the output, so a marker written whole in the ' +
|
||||
'prompt matches at once: ask for it in two halves ("print DONE followed by _7f3a") and match the joined ' +
|
||||
'form (DONE_7f3a), one word without spaces. stop and blocked exist only for claude and deepseek sessions.',
|
||||
'form (DONE_7f3a), one word without spaces. stop exists only for claude and deepseek sessions; blocked ' +
|
||||
'also for codex, whose dialogs Codeman reads off its screen.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
* `Session` import, no IO; the server injects emit callbacks (`onPending`/
|
||||
* `onUpdated`/`onResolved`), which keeps this unit-testable and cycle-free.
|
||||
* - Items are in-memory only. A server restart drops them; the next prompt
|
||||
* re-fires the hook. Claude-mode sessions only (hooks fire for nothing else).
|
||||
* re-fires the hook. Answerable items are Claude's alone; the DeepSeek status
|
||||
* bridge and the screen-read dialogs of a hook-less CLI (codex, see
|
||||
* `web/screen-blocked.ts`) open alert-only items (`answerInTerminal`).
|
||||
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
|
||||
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
|
||||
* failed write unless a newer prompt arrived meanwhile.
|
||||
@@ -24,7 +26,8 @@
|
||||
*
|
||||
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
|
||||
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
|
||||
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
|
||||
* web/session-listener-wiring (working/exit resolution), web/screen-blocked (screen-read
|
||||
* dialogs), web/server (emit callbacks + stop)
|
||||
*
|
||||
* @module web/approval-inbox
|
||||
*/
|
||||
@@ -122,6 +125,13 @@ interface NotePromptArgs {
|
||||
answerInTerminal?: boolean;
|
||||
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
|
||||
capture?: () => string | null;
|
||||
/**
|
||||
* A frame the caller already read, kept as the card's context when there is no
|
||||
* `capture`. Never parsed for options and never re-read: an item raised from the
|
||||
* screen (`web/screen-blocked.ts`) belongs to the session that keeps reading it, and
|
||||
* the option parser and staleness sweep here are shaped for Claude's dialogs.
|
||||
*/
|
||||
context?: string;
|
||||
}
|
||||
|
||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||
@@ -279,6 +289,7 @@ export class ApprovalInbox {
|
||||
item.acknowledgedAt = item.createdAt;
|
||||
item.acknowledgedReason = `watching ${args.watching}`;
|
||||
}
|
||||
if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context);
|
||||
this.applyCapture(item, args.capture);
|
||||
this.items.set(args.sessionId, item);
|
||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||
|
||||
@@ -1287,6 +1287,9 @@
|
||||
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
|
||||
],
|
||||
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
|
||||
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
|
||||
// notification drawer shows it. The CLI's label passes through.
|
||||
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
|
||||
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
|
||||
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
|
||||
// and where the model came from when the CLI did not report it. The harness
|
||||
|
||||
@@ -35,7 +35,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.setPendingHook(data.sessionId, 'permission_prompt');
|
||||
}
|
||||
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
|
||||
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue');
|
||||
// A dialog read off a hook-less CLI's screen (codex; `source: 'screen'`, see
|
||||
// src/web/screen-blocked.ts) carries a server-built message naming that CLI, so the
|
||||
// notification does not claim it is Claude asking.
|
||||
const screenMessage = data.source === 'screen' && data.message ? data.message : '';
|
||||
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || screenMessage || 'Claude needs tool approval to continue');
|
||||
},
|
||||
|
||||
_onHookElicitationDialog(data) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ApprovalAnswerSchema } from '../schemas.js';
|
||||
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
|
||||
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
|
||||
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
/**
|
||||
@@ -99,20 +100,23 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
// Throws 404 (not 403) for sessions the caller does not own, same
|
||||
// no-existence-leak rule as every other session route.
|
||||
const session = findSessionOrFail(ctx, item.sessionId, req);
|
||||
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
||||
}
|
||||
// Alert-only items (supervised-hook CLIs such as DeepSeek Harness): the
|
||||
// dialog belongs to a third-party TUI whose keystroke contract Codeman has
|
||||
// not measured, so '1'/Esc would be a blind keystroke into a foreign
|
||||
// composer. The item still raises the red alert and clears on the harness's
|
||||
// own working/stop reports; answering happens in the terminal.
|
||||
// Alert-only items: the dialog belongs to a TUI whose keystroke contract Codeman
|
||||
// has not measured, so '1'/Esc would be a blind keystroke into a foreign composer.
|
||||
// Two sources today: a supervised-hook CLI (DeepSeek Harness), whose item clears on
|
||||
// the harness's own working/stop reports, and a dialog read off the screen of a
|
||||
// hook-less CLI (codex, web/screen-blocked.ts), which clears when it leaves the
|
||||
// screen and was read from text the agent writes, so it must never steer a key.
|
||||
// Checked BEFORE the hooks gate below: a codex session has no hooks, and "this mode
|
||||
// cannot have pending approvals" would be false about the item the caller holds.
|
||||
if (item.answerInTerminal) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
|
||||
`${getCli(session.mode)?.label ?? session.mode} approvals must be answered in the terminal: the dialog belongs to a TUI whose keystrokes Codeman cannot verify.`
|
||||
);
|
||||
}
|
||||
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
|
||||
}
|
||||
|
||||
// Re-capture the pane before aiming keystrokes at it: if the dialog was
|
||||
// answered in the terminal moments ago, the digit would land in whatever
|
||||
|
||||
@@ -678,6 +678,25 @@ function currentSignalFor(session: { pid: number | null; status: SessionStatus }
|
||||
return signalForStatus(session.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* `currentSignalFor`, answering `blocked` for a session whose pane is showing a dialog
|
||||
* its CLI is blocked on (`Session.screenBlocked`, read off the screen for a CLI with no
|
||||
* hook: codex) when the caller asked for it. A hook-raised `blocked` is an edge with no
|
||||
* history, but this one is a level the server is still looking at, so a caller that
|
||||
* asks after the dialog appeared is answered at once instead of waiting out its timeout
|
||||
* for an edge that already passed. Only when `blocked` was asked for: such a pane is also
|
||||
* `idle`, and a caller that asked for `idle` alone must keep getting `idle`.
|
||||
*/
|
||||
function currentSignalForWait(
|
||||
session: { pid: number | null; status: SessionStatus; screenBlocked?: boolean },
|
||||
dead: boolean,
|
||||
until: readonly WaitSignal[]
|
||||
): WaitSignal | null {
|
||||
const signal = currentSignalFor(session, dead);
|
||||
if (signal !== 'exit' && session.screenBlocked && until.includes('blocked')) return 'blocked';
|
||||
return signal;
|
||||
}
|
||||
|
||||
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
|
||||
//
|
||||
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
|
||||
@@ -2182,7 +2201,7 @@ export function registerSessionRoutes(
|
||||
abortSignal: abort.signal,
|
||||
requireTransition: query.fresh === '1' || query.fresh === 'true',
|
||||
// Read BEFORE awaiting: this is the state the caller is asking about.
|
||||
currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
|
||||
currentSignal: currentSignalForWait(session, workerIsDead(ctx.mux, session), until),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @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');
|
||||
}
|
||||
@@ -112,6 +112,7 @@ import { sessionWaits } from './session-wait-registry.js';
|
||||
import { intentStore } from '../intent-store.js';
|
||||
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
|
||||
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
@@ -2835,6 +2836,14 @@ export class WebServer extends EventEmitter {
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.source === SCREEN_BLOCKED_SOURCE && data.message) {
|
||||
// A dialog read off a hook-less CLI's screen (web/screen-blocked.ts), whose message
|
||||
// is built from the CLI's label, never from the screen. Keyed on the source so a
|
||||
// Claude hook's own `message` keeps the body it always had; capped like the error
|
||||
// text, since a hook poster holding the secret can claim the source too (as it can
|
||||
// already claim any `tool_name` above).
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.message).slice(0, 200);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { fileStreamManager } from '../file-stream-manager.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { raiseScreenBlocked, clearScreenBlocked } from './screen-blocked.js';
|
||||
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
|
||||
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
|
||||
|
||||
@@ -44,6 +45,7 @@ export interface SessionListenerRefs {
|
||||
working: () => void;
|
||||
idle: () => void;
|
||||
watchingChanged: () => void;
|
||||
blockedChanged: (blocked: boolean, frame: string | null) => void;
|
||||
displayModelChanged: () => void;
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
@@ -98,6 +100,8 @@ interface SessionListenerDeps {
|
||||
* Call `attachSessionListeners()` after to wire them to the session.
|
||||
*/
|
||||
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
|
||||
/** The Approvals Inbox item the session's last screen-read dialog opened (see blockedChanged). */
|
||||
let screenBlockedItemId: string | null = null;
|
||||
return {
|
||||
// ─── Terminal Output ─────────────────────────────────────
|
||||
|
||||
@@ -276,6 +280,21 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* The pane started or stopped showing a dialog its CLI is blocked on, read off the
|
||||
* screen for a CLI with no hook to report it (`capabilities.workDetect.blockedLine`:
|
||||
* codex). Raised as a Claude `permission_prompt` hook would be, alert-only, and
|
||||
* cleared by resolving the item it opened; web/screen-blocked.ts has the rules.
|
||||
*/
|
||||
blockedChanged: (blocked: boolean, frame: string | null) => {
|
||||
if (blocked) {
|
||||
screenBlockedItemId = raiseScreenBlocked(session, deps, frame);
|
||||
} else {
|
||||
clearScreenBlocked(session.id, screenBlockedItemId);
|
||||
screenBlockedItemId = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pushes the session state when the model the CLI reports changes (its statusline,
|
||||
* or its own footer), which the status does not reflect. Persisted too: a restart
|
||||
@@ -519,6 +538,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('working', refs.working);
|
||||
session.on('idle', refs.idle);
|
||||
session.on('watchingChanged', refs.watchingChanged);
|
||||
session.on('blockedChanged', refs.blockedChanged);
|
||||
session.on('displayModelChanged', refs.displayModelChanged);
|
||||
session.on('taskCreated', refs.taskCreated);
|
||||
session.on('taskUpdated', refs.taskUpdated);
|
||||
@@ -557,6 +577,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('working', refs.working);
|
||||
session.off('idle', refs.idle);
|
||||
session.off('watchingChanged', refs.watchingChanged);
|
||||
session.off('blockedChanged', refs.blockedChanged);
|
||||
session.off('displayModelChanged', refs.displayModelChanged);
|
||||
session.off('taskCreated', refs.taskCreated);
|
||||
session.off('taskUpdated', refs.taskUpdated);
|
||||
|
||||
@@ -28,11 +28,17 @@
|
||||
* `idle` / `working` / `exit` come from `Session`'s own events (heuristic: output
|
||||
* stabilization plus prompt detection), so `idle` can flap mid-turn when a spinner
|
||||
* pauses. `stop` and `blocked` come from Claude Code hooks (`POST /api/hook-event`)
|
||||
* and are definitive. Only `claude` mode installs those hooks, so `stop` and
|
||||
* `blocked` never fire for anything else: callers must be rejected up front rather
|
||||
* than left to hit the timeout. `hooksAvailableForMode()` keeps that rule in one
|
||||
* place, and it is keyed on the MODE rather than on `isExternalCliMode()` because
|
||||
* `shell` is not an external CLI yet installs no hooks either.
|
||||
* and are definitive. Only `claude` mode installs those hooks (and `deepseek`'s status
|
||||
* bridge reports the same two), so `stop` never fires for anything else: callers must
|
||||
* be rejected up front rather than left to hit the timeout. `hooksAvailableForMode()`
|
||||
* keeps that rule in one place, and it is keyed on the MODE rather than on
|
||||
* `isExternalCliMode()` because `shell` is not an external CLI yet installs no hooks
|
||||
* either.
|
||||
*
|
||||
* `blocked` has a second source: a CLI with no hook for it can declare the dialogs it
|
||||
* blocks on (`capabilities.workDetect.blockedLine`, codex), and `Session` reads them off
|
||||
* the screen (`web/screen-blocked.ts`). `blockedSignalAvailable()` is the rule for that
|
||||
* signal. `stop` has no such source, because nothing on a screen says a turn is over.
|
||||
*
|
||||
* ## Ordering contract for the wiring
|
||||
*
|
||||
@@ -152,7 +158,9 @@ export function parseWaitSignals(raw: unknown): ParsedWaitSignals {
|
||||
* `stopped` and `error` both map to `exit` because both mean "not running", so a
|
||||
* caller that does catch one of them does not block. `blocked` is deliberately
|
||||
* underivable here, it exists only as a hook event until it becomes a real
|
||||
* `SessionStatus` (see the deferred Part 3 in the plan).
|
||||
* `SessionStatus` (see the deferred Part 3 in the plan). A screen-read dialog IS a
|
||||
* level a later caller can find (`Session.screenBlocked`), and the wait route answers
|
||||
* that one itself.
|
||||
*/
|
||||
export function signalForStatus(status: SessionStatus): WaitSignal | null {
|
||||
switch (status) {
|
||||
@@ -168,9 +176,6 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
|
||||
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
|
||||
|
||||
/** Per-session facts that can turn a mode's hook capability OFF for one session. */
|
||||
export interface HookCapabilityOptions {
|
||||
/**
|
||||
@@ -266,6 +271,19 @@ export function sessionHookOptions(session: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `blocked` can ever fire for this session: its CLI reports it through hooks
|
||||
* (`hooksAvailableForMode()`), or Codeman reads the dialogs it blocks on off its screen
|
||||
* (`capabilities.workDetect.blockedLine`, codex). The screen half needs no per-session
|
||||
* facts: a remote or Docker pane is still a local tmux pane showing the CLI's screen
|
||||
* (their nested tmux runs with `status off`, so nothing is drawn below the CLI).
|
||||
*
|
||||
* ⚠️ Says nothing about `stop`. A screen shows a dialog; it never says a turn ended.
|
||||
*/
|
||||
export function blockedSignalAvailable(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
|
||||
return hooksAvailableForMode(mode, options) || !!getCli(mode)?.capabilities.workDetect?.blockedLine;
|
||||
}
|
||||
|
||||
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
|
||||
export interface ResolvedWaitSignals {
|
||||
/** Signals to actually wait on. Empty when `error` is set. */
|
||||
@@ -279,9 +297,9 @@ export interface ResolvedWaitSignals {
|
||||
* every wait endpoint shares:
|
||||
*
|
||||
* 1. An unknown token is an ERROR, never a silent fallback to the default.
|
||||
* 2. Hook-only signals are rejected when asked for EXPLICITLY on a mode that emits
|
||||
* no hooks, but merely dropped from the DEFAULT set: omitting the parameter must
|
||||
* never 400.
|
||||
* 2. A signal the session can never emit (`stop` without hooks, `blocked` without
|
||||
* hooks or a screen-read `blockedLine`) is rejected when asked for EXPLICITLY, but
|
||||
* merely dropped from the DEFAULT set: omitting the parameter must never 400.
|
||||
*
|
||||
* Shared by `GET .../wait` and the `wait` field on `POST .../input` so the two can
|
||||
* not drift; the second-guessing that produces is worse than the duplication.
|
||||
@@ -304,7 +322,9 @@ export function resolveWaitSignals(
|
||||
};
|
||||
}
|
||||
|
||||
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS);
|
||||
const unsupported = new Set<WaitSignal>();
|
||||
if (!hooksAvailableForMode(options.mode, options)) unsupported.add('stop');
|
||||
if (!blockedSignalAvailable(options.mode, options)) unsupported.add('blocked');
|
||||
|
||||
if (parsed.signals.length === 0) {
|
||||
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
|
||||
@@ -324,7 +344,8 @@ export function resolveWaitSignals(
|
||||
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
|
||||
: `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
|
||||
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
|
||||
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
|
||||
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). ` +
|
||||
`Use ${unsupported.has('blocked') ? 'idle or exit' : 'idle, blocked or exit'}.`,
|
||||
};
|
||||
}
|
||||
return { until: parsed.signals, error: null };
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
|
||||
* - **Image / attachments** (2): image:detected, attachment:detected
|
||||
* - **Hooks** (10): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed, prompt_submitted
|
||||
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
|
||||
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook;
|
||||
* permission_prompt also arrives with `source: 'screen'` for a codex dialog read off its screen)
|
||||
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
|
||||
* - **Custom Model Endpoint Profiles** (1): swapped-out (a session's model got evicted by another session on the same llama-swap endpoint)
|
||||
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
|
||||
@@ -365,7 +366,11 @@ export const AttachmentDetected = 'attachment:detected' as const;
|
||||
|
||||
/** Claude Code hook: session idle, waiting for input. */
|
||||
export const HookIdlePrompt = 'hook:idle_prompt' as const;
|
||||
/** Claude Code hook: tool requesting permission. */
|
||||
/**
|
||||
* Claude Code hook: tool requesting permission. Also raised by the server itself, with
|
||||
* `source: 'screen'`, for a dialog read off a hook-less CLI's screen (codex; see
|
||||
* `web/screen-blocked.ts`), which is cleared by `approval:resolved`.
|
||||
*/
|
||||
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
|
||||
/** Claude Code hook: elicitation dialog (Claude asking a question). */
|
||||
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;
|
||||
|
||||
@@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import { registerApprovalRoutes } from '../../src/web/routes/approval-routes.js';
|
||||
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
|
||||
import { approvalInbox } from '../../src/web/approval-inbox.js';
|
||||
import { raiseScreenBlocked } from '../../src/web/screen-blocked.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
|
||||
import { createMockRouteContext, type MockSession } from '../mocks/index.js';
|
||||
@@ -455,7 +456,6 @@ describe('approval routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let session: MockSession;
|
||||
@@ -505,6 +505,59 @@ describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('approval routes: dialogs read off a hook-less screen (codex) are alert-only', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let session: MockSession;
|
||||
const noopDeps = {
|
||||
broadcast: () => {},
|
||||
broadcastSessionStateDebounced: () => {},
|
||||
sendPushNotifications: () => {},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness();
|
||||
session = harness.ctx.sessions.get(SESSION_ID)!;
|
||||
session.mode = 'codex';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const item of approvalInbox.listPending()) {
|
||||
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
|
||||
}
|
||||
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('lists the item, and the page-load sweep keeps it', async () => {
|
||||
raiseScreenBlocked(session as never, noopDeps, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
|
||||
const approvals = await listApprovals(harness);
|
||||
expect(approvals).toHaveLength(1);
|
||||
expect(approvals[0]).toMatchObject({ sessionId: SESSION_ID, kind: 'permission', answerInTerminal: true });
|
||||
});
|
||||
|
||||
it('refuses an answer with the terminal-only error, not "cannot have pending approvals"', async () => {
|
||||
// The hooks gate would say a codex session cannot have an item at all, which is false
|
||||
// about the one the caller holds; the alert-only check runs first.
|
||||
raiseScreenBlocked(session as never, noopDeps, null);
|
||||
const [item] = await listApprovals(harness);
|
||||
for (const payload of [{ action: 'approve' }, { action: 'deny' }, { action: 'option', option: 1 }]) {
|
||||
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/answer`, payload });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/^Codex approvals must be answered in the terminal/);
|
||||
}
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
expect(await listApprovals(harness)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('can still be dismissed', async () => {
|
||||
raiseScreenBlocked(session as never, noopDeps, null);
|
||||
const [item] = await listApprovals(harness);
|
||||
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/dismiss`, payload: {} });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(await listApprovals(harness)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approval routes: multi-user scoping', () => {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { sessionWaits } from '../../src/web/session-wait-registry.js';
|
||||
import { approvalInbox } from '../../src/web/approval-inbox.js';
|
||||
import { MAX_WAIT_MS, MIN_WAIT_MS, MAX_WAITERS_TOTAL, MAX_WAITERS_PER_OWNER } from '../../src/config/agent-wait.js';
|
||||
|
||||
// Distinct per file on purpose: the three wait suites share the process-wide
|
||||
@@ -463,6 +464,80 @@ describe('GET /api/sessions/:id/wait: modes that install no hooks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait: blocked read off a codex screen', () => {
|
||||
/** The listener deps the screen-blocked raise reaches; nothing else matters here. */
|
||||
function stubDeps() {
|
||||
return {
|
||||
broadcast: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
sendPushNotifications: vi.fn(),
|
||||
getRunSummaryTracker: vi.fn(() => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
approvalInbox.resolveForSession(SESSION_ID, 'session_ended');
|
||||
});
|
||||
|
||||
it('accepts until=blocked for codex, whose dialogs are read off its screen', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=1000` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.wait.until).toEqual(['blocked']);
|
||||
});
|
||||
|
||||
it('answers blocked at once for a dialog already on screen, and only to a caller who asked', async () => {
|
||||
// A hook-raised blocked is an edge; this one is a level the server is still reading,
|
||||
// so a caller who asks after the dialog appeared does not wait out its timeout.
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
|
||||
mode: string;
|
||||
status: string;
|
||||
screenBlocked?: boolean;
|
||||
};
|
||||
session.mode = 'codex';
|
||||
session.status = 'idle';
|
||||
session.screenBlocked = true;
|
||||
|
||||
const asked = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle,blocked` });
|
||||
expect(asked.json().data.wait).toMatchObject({ signal: 'blocked', immediate: true });
|
||||
|
||||
// Such a pane is idle too, and a caller that asked for idle alone keeps getting idle.
|
||||
const idle = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
|
||||
});
|
||||
|
||||
it('wakes a parked until=blocked waiter when the session raises a screen-read dialog', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
session.mode = 'codex';
|
||||
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=600000` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
session.emit('blockedChanged', true, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: false, timedOut: false });
|
||||
expect(approvalInbox.getForSession(SESSION_ID)?.answerInTerminal).toBe(true);
|
||||
|
||||
// ...and the dialog leaving the screen resolves the item it opened.
|
||||
session.emit('blockedChanged', false, null);
|
||||
expect(approvalInbox.getForSession(SESSION_ID)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still refuses until=stop for codex: nothing on a screen says a turn ended', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop,blocked` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('Signal(s) stop never fire for codex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait: liveness beats the reported status', () => {
|
||||
it('answers exit for a session whose PTY is gone, not the idle its status claims', async () => {
|
||||
// Session parks a dead PTY at status 'idle' and the object survives in the map,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* What the server raises for a dialog read off a hook-less CLI's screen (codex), and what
|
||||
* clears it: `web/screen-blocked.ts`. The session side, and the codex captures it reads,
|
||||
* are in `codex-blocked-detection.test.ts`.
|
||||
*
|
||||
* The contract: exactly what a Claude `permission_prompt` hook raises (the `blocked` wait
|
||||
* signal, an Approvals Inbox item, the `hook:permission_prompt` broadcast, push and
|
||||
* webhook, a run-summary row), but ALERT-ONLY, since the dialog was read from text the
|
||||
* agent writes: the item is `answerInTerminal`, push carries no `approvalId`, and the
|
||||
* inbox never resolves or answers it on its own.
|
||||
*/
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { raiseScreenBlocked, clearScreenBlocked, screenBlockedMessage } from '../src/web/screen-blocked.js';
|
||||
import { approvalInbox } from '../src/web/approval-inbox.js';
|
||||
import { sessionWaits } from '../src/web/session-wait-registry.js';
|
||||
import { SseEvent } from '../src/web/sse-events.js';
|
||||
import type { SessionMode } from '../src/types.js';
|
||||
|
||||
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
|
||||
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
|
||||
|
||||
describe('raiseScreenBlocked / clearScreenBlocked', () => {
|
||||
const SESSION = { id: 'screen-blocked-test', name: 'w9-scratch', mode: 'codex' as SessionMode };
|
||||
|
||||
function deps() {
|
||||
const recordHookEvent = vi.fn();
|
||||
return {
|
||||
broadcast: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
sendPushNotifications: vi.fn(),
|
||||
getRunSummaryTracker: vi.fn(() => ({ recordHookEvent })),
|
||||
recordHookEvent,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
approvalInbox.resolveForSession(SESSION.id, 'session_ended');
|
||||
sessionWaits.cancelAll(SESSION.id);
|
||||
});
|
||||
|
||||
it('raises what a permission_prompt hook raises, alert-only', async () => {
|
||||
const d = deps();
|
||||
const waiting = sessionWaits.waitForSignal(SESSION.id, { until: ['blocked'], timeoutMs: 5000 });
|
||||
const itemId = raiseScreenBlocked(SESSION, d, fixture('exec-approval.txt'));
|
||||
|
||||
expect((await waiting).signal).toBe('blocked');
|
||||
|
||||
const item = approvalInbox.getForSession(SESSION.id)!;
|
||||
expect(item.id).toBe(itemId);
|
||||
expect(item.kind).toBe('permission');
|
||||
expect(item.answerInTerminal).toBe(true);
|
||||
expect(item.message).toBe('Codex is waiting for your answer in the terminal');
|
||||
// The frame is the card's context, and nothing parsed it into answer buttons.
|
||||
expect(item.context).toContain('Press enter to confirm or esc to cancel');
|
||||
expect(item.options).toBeUndefined();
|
||||
|
||||
expect(d.broadcast).toHaveBeenCalledWith(
|
||||
SseEvent.HookPermissionPrompt,
|
||||
expect.objectContaining({ sessionId: SESSION.id, source: 'screen', approvalId: itemId })
|
||||
);
|
||||
expect(d.broadcastSessionStateDebounced).toHaveBeenCalledWith(SESSION.id);
|
||||
// Push and webhook get the message and NO approvalId, so no Approve/Deny buttons.
|
||||
const [event, push] = d.sendPushNotifications.mock.calls[0] as [string, Record<string, unknown>];
|
||||
expect(event).toBe(SseEvent.HookPermissionPrompt);
|
||||
expect(push).toMatchObject({ sessionId: SESSION.id, sessionName: 'w9-scratch', source: 'screen' });
|
||||
expect(push.message).toBe(screenBlockedMessage('codex'));
|
||||
expect(push.approvalId).toBeUndefined();
|
||||
expect(d.recordHookEvent).toHaveBeenCalledWith('permission_prompt', expect.objectContaining({ source: 'screen' }));
|
||||
});
|
||||
|
||||
it('never steers a keystroke: the inbox item cannot be answered', () => {
|
||||
raiseScreenBlocked(SESSION, deps(), fixture('exec-approval.txt'));
|
||||
const item = approvalInbox.getForSession(SESSION.id)!;
|
||||
// No capture callback, so the inbox's Claude-shaped sweep never resolves it either:
|
||||
// the session owns its lifetime.
|
||||
expect(approvalInbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
approvalInbox.resolveIfDialogGone(SESSION.id);
|
||||
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(item.id);
|
||||
});
|
||||
|
||||
it('clears by resolving the item it opened, and only that one', () => {
|
||||
const resolved: string[] = [];
|
||||
const prev = approvalInbox.onResolved;
|
||||
approvalInbox.onResolved = (info) => resolved.push(`${info.id}:${info.resolution}`);
|
||||
try {
|
||||
const itemId = raiseScreenBlocked(SESSION, deps(), null);
|
||||
clearScreenBlocked(SESSION.id, itemId);
|
||||
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
|
||||
expect(resolved).toContain(`${itemId}:resolved_in_terminal`);
|
||||
|
||||
// An item it did not open (here a newer one) is left alone.
|
||||
const newer = raiseScreenBlocked(SESSION, deps(), null);
|
||||
clearScreenBlocked(SESSION.id, itemId);
|
||||
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
|
||||
clearScreenBlocked(SESSION.id, null);
|
||||
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
|
||||
} finally {
|
||||
approvalInbox.onResolved = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('raises nothing for a session whose CLI reports blocked through hooks', () => {
|
||||
const d = deps();
|
||||
expect(raiseScreenBlocked({ ...SESSION, mode: 'claude' }, d, null)).toBeNull();
|
||||
expect(d.broadcast).not.toHaveBeenCalled();
|
||||
expect(d.sendPushNotifications).not.toHaveBeenCalled();
|
||||
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SessionWaitRegistry,
|
||||
WaitCapacityError,
|
||||
hooksAvailableForMode,
|
||||
blockedSignalAvailable,
|
||||
parseWaitSignals,
|
||||
resolveWaitSignals,
|
||||
signalForStatus,
|
||||
@@ -100,6 +101,26 @@ describe('signalForStatus', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockedSignalAvailable', () => {
|
||||
it('is true where hooks report it, and for a CLI that declares a blockedLine (codex)', () => {
|
||||
expect(blockedSignalAvailable('claude')).toBe(true);
|
||||
expect(blockedSignalAvailable('codex')).toBe(true);
|
||||
// Not keyed on hooks: codex has none, and its screen-read dialogs are still a source.
|
||||
expect(hooksAvailableForMode('codex')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a CLI with neither (shell, gemini, opencode)', () => {
|
||||
for (const mode of ['shell', 'gemini', 'opencode'] as const) {
|
||||
expect(blockedSignalAvailable(mode)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('follows the per-session deepseek bridge, which is its only source', () => {
|
||||
expect(blockedSignalAvailable('deepseek')).toBe(true);
|
||||
expect(blockedSignalAvailable('deepseek', { deepSeekStatusReporting: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hooksAvailableForMode', () => {
|
||||
it('is true only for claude', () => {
|
||||
expect(hooksAvailableForMode('claude')).toBe(true);
|
||||
@@ -144,8 +165,22 @@ describe('resolveWaitSignals', () => {
|
||||
const result = resolveWaitSignals('stop', codex);
|
||||
expect(result.until).toEqual([]);
|
||||
expect(result.error).toContain('codex');
|
||||
expect(resolveWaitSignals('blocked', codex).error).toBeTruthy();
|
||||
expect(resolveWaitSignals('idle,stop', codex).error).toBeTruthy();
|
||||
// gemini declares no blockedLine, so nothing can ever raise `blocked` for it.
|
||||
expect(resolveWaitSignals('blocked', { mode: 'gemini' as SessionMode }).error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts `blocked` on codex, whose dialogs are read off the screen, but still not `stop`', () => {
|
||||
// capabilities.workDetect.blockedLine gives codex a source for `blocked`; nothing on a
|
||||
// screen says a turn ended, so `stop` keeps its 400.
|
||||
expect(resolveWaitSignals('blocked', codex)).toEqual({ until: ['blocked'], error: null });
|
||||
expect(resolveWaitSignals('idle,blocked,exit', codex)).toEqual({ until: ['idle', 'blocked', 'exit'], error: null });
|
||||
const stop = resolveWaitSignals('stop,blocked', codex);
|
||||
expect(stop.until).toEqual([]);
|
||||
expect(stop.error).toContain('stop');
|
||||
expect(stop.error).not.toContain('Signal(s) stop, blocked');
|
||||
// The suggestion names what does fire for codex.
|
||||
expect(stop.error).toContain('idle, blocked or exit');
|
||||
});
|
||||
|
||||
it('drops hook-only signals from the DEFAULT set instead of erroring', () => {
|
||||
|
||||
@@ -97,6 +97,24 @@ describe('sendPushNotifications -> webhook (the headless case)', () => {
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('carries a screen-read dialog (codex) with its own message, and a hook message stays out', async () => {
|
||||
// web/screen-blocked.ts sends the CLI's own label, built server-side, never the
|
||||
// dialog's text. A Claude hook's `message` keeps the body it always had.
|
||||
const { send } = await headlessServer({});
|
||||
await send('hook:permission_prompt', {
|
||||
sessionId: 's3',
|
||||
sessionName: 'w3-app',
|
||||
source: 'screen',
|
||||
message: 'Codex is waiting for your answer in the terminal',
|
||||
});
|
||||
await send('hook:permission_prompt', {
|
||||
sessionId: 's4',
|
||||
sessionName: 'w4-app',
|
||||
message: 'Claude needs your permission to use Bash',
|
||||
});
|
||||
expect(got.map((g) => g.body)).toEqual(['[w3-app] Codex is waiting for your answer in the terminal', '[w4-app]']);
|
||||
});
|
||||
|
||||
it('dedupes an immediate repeat of the same event for the same session', async () => {
|
||||
const { send } = await headlessServer({});
|
||||
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });
|
||||
|
||||
Reference in new issue
Block a user