Spaces:
Running
Running
Agent Manager commited on
Commit ·
126e54c
1
Parent(s): df112f9
Bound stale input request markers
Browse files- docs/tui-input-required.md +27 -15
- server/src/input-required.js +11 -7
- server/test/attachments.test.mjs +1 -1
- server/test/input-required.test.mjs +16 -4
docs/tui-input-required.md
CHANGED
|
@@ -19,7 +19,10 @@ active; direct terminal input remains available.
|
|
| 19 |
The reader does not answer the dialog in this version. The CLIs have different
|
| 20 |
choice identifiers, validation, queueing, "allow once/always" semantics, and
|
| 21 |
secret-handling rules. Translating a reader form to raw keystrokes would be
|
| 22 |
-
unsafe
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
## Detection coverage
|
| 25 |
|
|
@@ -30,7 +33,7 @@ which native states emit each signal.
|
|
| 30 |
|
| 31 |
| CLI | Signal used | Confidence | Deliberate misses |
|
| 32 |
| --- | --- | --- | --- |
|
| 33 |
-
| OpenCode | The plugin tracks `permission.asked`/`permission.replied` and `question.asked`/`question.replied`/`question.rejected`. It keeps the full pending queue and mirrors OpenCode's recovery when a Question tool completes without `question.replied`. | High, with paired open/close events. | Dialogs outside those two event families; a request already open before the plugin observes its event. |
|
| 34 |
| Claude Code | Observation-only `Notification` hooks for `permission_prompt`, MCP elicitation dialogs, and `agent_needs_input`. Unlike `PermissionRequest`, these fire after the actual UI has remained unanswered for about six seconds and cannot decide the permission. Native batch/stop/session/elicitation events and operator input clear the marker. | High once reported; intentionally delayed about six seconds. | The first six seconds; main-session choice UIs for which Claude exposes no matching notification (including versions where `AskUserQuestion` has no attention event); unlisted onboarding/configuration dialogs. |
|
| 35 |
| Codex | Each managed invocation enables only the TUI's `approval-requested` and `plan-mode-prompt` OSC 9 notifications. Codex emits them from the TUI handlers that install exec/edit/MCP approval and request-user-input views. | High for the listed views. | `RequestPermissionsEvent` and generic queued approval paths that do not call Codex's notifier; onboarding/configuration dialogs. Codex exposes no paired close event, so input clears the signal and a 30-minute safety expiry prevents a stale badge. |
|
| 36 |
| Gemini CLI | An observation-only `Notification` hook reports `ToolPermission`, including `ask_user`. Gemini's native attention notification additionally covers command, auth, filesystem, extension-update and loop-detection confirmations through OSC 9 when enabled. | High: both signals originate from Gemini's actual pending-confirmation state. | Non-tool attention notifications when a higher-precedence user/project setting disables notifications or Gemini suppresses them for terminal focus; other unlisted UI dialogs. Native completion/session events, input, and the safety expiry clear one-shot signals. |
|
|
@@ -75,13 +78,15 @@ Gemini hooks reject nested agent processes; OpenCode's plugin requires the pane
|
|
| 75 |
root process. Marker files live on local `/tmp`, not the durable bucket, and are
|
| 76 |
removed at process exit.
|
| 77 |
|
| 78 |
-
OpenCode's paired event
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
|
| 86 |
## Verification
|
| 87 |
|
|
@@ -92,9 +97,16 @@ nested-process rejection, preservation of existing Claude settings, reader/web
|
|
| 92 |
typechecking, and the full terminal migration/resize suite. No assertion relies
|
| 93 |
on screen text or process idleness.
|
| 94 |
|
| 95 |
-
The installed Codex
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
the
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
The reader does not answer the dialog in this version. The CLIs have different
|
| 20 |
choice identifiers, validation, queueing, "allow once/always" semantics, and
|
| 21 |
secret-handling rules. Translating a reader form to raw keystrokes would be
|
| 22 |
+
unsafe. OpenCode already exposes typed permission/question replies keyed by the
|
| 23 |
+
request id tracked here, but Agent Manager has no authenticated client to that
|
| 24 |
+
pane's OpenCode server. Other adapters would likewise need a typed, pane-scoped
|
| 25 |
+
response path before reader-side answers are safe.
|
| 26 |
|
| 27 |
## Detection coverage
|
| 28 |
|
|
|
|
| 33 |
|
| 34 |
| CLI | Signal used | Confidence | Deliberate misses |
|
| 35 |
| --- | --- | --- | --- |
|
| 36 |
+
| OpenCode | The plugin tracks `permission.asked`/`permission.replied` and `question.asked`/`question.replied`/`question.rejected`. It keeps the full pending queue and mirrors OpenCode's recovery when a Question tool completes without `question.replied`. | High, with paired open/close events. | Dialogs outside those two event families; a request already open before the plugin observes its event. A missing close event expires after 30 minutes, which can hide a genuinely long-lived dialog. |
|
| 37 |
| Claude Code | Observation-only `Notification` hooks for `permission_prompt`, MCP elicitation dialogs, and `agent_needs_input`. Unlike `PermissionRequest`, these fire after the actual UI has remained unanswered for about six seconds and cannot decide the permission. Native batch/stop/session/elicitation events and operator input clear the marker. | High once reported; intentionally delayed about six seconds. | The first six seconds; main-session choice UIs for which Claude exposes no matching notification (including versions where `AskUserQuestion` has no attention event); unlisted onboarding/configuration dialogs. |
|
| 38 |
| Codex | Each managed invocation enables only the TUI's `approval-requested` and `plan-mode-prompt` OSC 9 notifications. Codex emits them from the TUI handlers that install exec/edit/MCP approval and request-user-input views. | High for the listed views. | `RequestPermissionsEvent` and generic queued approval paths that do not call Codex's notifier; onboarding/configuration dialogs. Codex exposes no paired close event, so input clears the signal and a 30-minute safety expiry prevents a stale badge. |
|
| 39 |
| Gemini CLI | An observation-only `Notification` hook reports `ToolPermission`, including `ask_user`. Gemini's native attention notification additionally covers command, auth, filesystem, extension-update and loop-detection confirmations through OSC 9 when enabled. | High: both signals originate from Gemini's actual pending-confirmation state. | Non-tool attention notifications when a higher-precedence user/project setting disables notifications or Gemini suppresses them for terminal focus; other unlisted UI dialogs. Native completion/session events, input, and the safety expiry clear one-shot signals. |
|
|
|
|
| 78 |
root process. Marker files live on local `/tmp`, not the durable bucket, and are
|
| 79 |
removed at process exit.
|
| 80 |
|
| 81 |
+
OpenCode's paired event remains authoritative through menu-navigation input and
|
| 82 |
+
closes immediately when the final queued request closes. Every marker, including
|
| 83 |
+
OpenCode's, also has a 30-minute ceiling so a lost close event cannot disable
|
| 84 |
+
reader prompting indefinitely. For unpaired CLIs, an actual operator key or a
|
| 85 |
+
native completion event can clear the signal sooner; process exit clears every
|
| 86 |
+
kind. Automatic terminal query replies do not count as operator input. The
|
| 87 |
+
ceiling can create a false negative for a dialog left open longer than 30
|
| 88 |
+
minutes; that is intentional because an indefinitely stale warning is the more
|
| 89 |
+
damaging failure mode.
|
| 90 |
|
| 91 |
## Verification
|
| 92 |
|
|
|
|
| 97 |
typechecking, and the full terminal migration/resize suite. No assertion relies
|
| 98 |
on screen text or process idleness.
|
| 99 |
|
| 100 |
+
The installed Codex 0.147.0 TUI was driven to a real command approval with only
|
| 101 |
+
`approval-requested` enabled. Its raw PTY output contained
|
| 102 |
+
`ESC ] 9 ; Approval requested: ... BEL` immediately before the menu. Repeating
|
| 103 |
+
the approval with only `approval-request` reached the same menu without an OSC 9
|
| 104 |
+
notification, confirming the configured spelling rather than merely confirming
|
| 105 |
+
that Codex accepts arbitrary list values. The installed Gemini binary loaded the
|
| 106 |
+
proposed system settings file.
|
| 107 |
+
|
| 108 |
+
This branch was not deployed and did not drive authenticated live dialogs for
|
| 109 |
+
the other adapters; their native event payloads and terminal sequences are
|
| 110 |
+
exercised by automated fixtures. A deployment smoke test should deliberately
|
| 111 |
+
trigger each covered dialog before release, especially after a CLI version
|
| 112 |
+
update.
|
server/src/input-required.js
CHANGED
|
@@ -44,6 +44,10 @@ function readMarker(id, runId, cli, now) {
|
|
| 44 |
function removeMatchingMarker(id, runId, cli, { oneShotOnly = false } = {}) {
|
| 45 |
const marker = readMarker(id, runId, cli, Date.now());
|
| 46 |
if (!marker || (oneShotOnly && marker.source === 'opencode-event')) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
try { fs.unlinkSync(marker.file); } catch {}
|
| 48 |
}
|
| 49 |
|
|
@@ -76,9 +80,8 @@ export function createInputRequiredTracker({ id, runId, cli, now = () => Date.no
|
|
| 76 |
const time = now();
|
| 77 |
const marker = readMarker(id, runId, cli, time);
|
| 78 |
if (marker) {
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
removeMatchingMarker(id, runId, cli);
|
| 82 |
if (current?.transport === 'marker') current = null;
|
| 83 |
} else {
|
| 84 |
const token = `${marker.source}:${marker.at}:${marker.requestId}:${marker.kind}`;
|
|
@@ -128,12 +131,13 @@ export function createInputRequiredTracker({ id, runId, cli, now = () => Date.no
|
|
| 128 |
|
| 129 |
const observeInput = () => {
|
| 130 |
// OpenCode has paired asked/replied events. Cursor movement in its menu is
|
| 131 |
-
// still input but does not resolve the request, so only the paired event
|
| 132 |
-
//
|
| 133 |
-
// any operator key clears them conservatively (a
|
|
|
|
| 134 |
const marker = readMarker(id, runId, cli, now());
|
| 135 |
if (current?.source === 'opencode-event' || marker?.source === 'opencode-event') return;
|
| 136 |
-
|
| 137 |
current = null;
|
| 138 |
};
|
| 139 |
|
|
|
|
| 44 |
function removeMatchingMarker(id, runId, cli, { oneShotOnly = false } = {}) {
|
| 45 |
const marker = readMarker(id, runId, cli, Date.now());
|
| 46 |
if (!marker || (oneShotOnly && marker.source === 'opencode-event')) return;
|
| 47 |
+
removeMarker(marker);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function removeMarker(marker) {
|
| 51 |
try { fs.unlinkSync(marker.file); } catch {}
|
| 52 |
}
|
| 53 |
|
|
|
|
| 80 |
const time = now();
|
| 81 |
const marker = readMarker(id, runId, cli, time);
|
| 82 |
if (marker) {
|
| 83 |
+
if (time - marker.at > ONE_SHOT_MAX_AGE_MS) {
|
| 84 |
+
removeMarker(marker);
|
|
|
|
| 85 |
if (current?.transport === 'marker') current = null;
|
| 86 |
} else {
|
| 87 |
const token = `${marker.source}:${marker.at}:${marker.requestId}:${marker.kind}`;
|
|
|
|
| 131 |
|
| 132 |
const observeInput = () => {
|
| 133 |
// OpenCode has paired asked/replied events. Cursor movement in its menu is
|
| 134 |
+
// still input but does not resolve the request, so only the paired event or
|
| 135 |
+
// the finite safety ceiling clears it. The other CLIs expose an exact open
|
| 136 |
+
// signal but no exact close; any operator key clears them conservatively (a
|
| 137 |
+
// false negative is safer).
|
| 138 |
const marker = readMarker(id, runId, cli, now());
|
| 139 |
if (current?.source === 'opencode-event' || marker?.source === 'opencode-event') return;
|
| 140 |
+
if (marker) removeMarker(marker);
|
| 141 |
current = null;
|
| 142 |
};
|
| 143 |
|
server/test/attachments.test.mjs
CHANGED
|
@@ -159,7 +159,7 @@ try {
|
|
| 159 |
const codexQuickstart = cliById('codex').withPrompt(
|
| 160 |
"'compare both'", ["'/tmp/first image.png'", "'/tmp/second.png'"],
|
| 161 |
);
|
| 162 |
-
assert.match(codexQuickstart, /tui\.notifications=/);
|
| 163 |
assert.match(codexQuickstart, /tui\.notification_method="osc9"/);
|
| 164 |
assert.ok(codexQuickstart.endsWith("-i '/tmp/first image.png' -i '/tmp/second.png' 'compare both'"));
|
| 165 |
const codexFirst = commandFor({
|
|
|
|
| 159 |
const codexQuickstart = cliById('codex').withPrompt(
|
| 160 |
"'compare both'", ["'/tmp/first image.png'", "'/tmp/second.png'"],
|
| 161 |
);
|
| 162 |
+
assert.match(codexQuickstart, /tui\.notifications=\["approval-requested","plan-mode-prompt"\]/);
|
| 163 |
assert.match(codexQuickstart, /tui\.notification_method="osc9"/);
|
| 164 |
assert.ok(codexQuickstart.endsWith("-i '/tmp/first image.png' -i '/tmp/second.png' 'compare both'"));
|
| 165 |
const codexFirst = commandFor({
|
server/test/input-required.test.mjs
CHANGED
|
@@ -39,15 +39,27 @@ try {
|
|
| 39 |
assert.equal(claude.get(), null, 'one-shot signals clear conservatively on operator input');
|
| 40 |
assert.equal(fs.existsSync(path.join(root, 'claude-pane.json')), false);
|
| 41 |
|
|
|
|
| 42 |
marker('oc-pane', 'opencode', {
|
| 43 |
-
kind: 'question', source: 'opencode-event', requestId: 'que_1',
|
|
|
|
|
|
|
|
|
|
| 44 |
});
|
| 45 |
-
const opencode = createInputRequiredTracker({ id: 'oc-pane', runId: RUN, cli: 'opencode' });
|
| 46 |
assert.equal(opencode.get()?.kind, 'question');
|
| 47 |
opencode.observeInput();
|
| 48 |
assert.equal(opencode.get()?.kind, 'question', 'menu navigation cannot clear a paired OpenCode request');
|
| 49 |
-
|
| 50 |
-
assert.equal(opencode.get(), null, '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
let now = 1_800_000_000_000;
|
| 53 |
const codex = createInputRequiredTracker({ id: 'codex-pane', runId: RUN, cli: 'codex', now: () => now });
|
|
|
|
| 39 |
assert.equal(claude.get(), null, 'one-shot signals clear conservatively on operator input');
|
| 40 |
assert.equal(fs.existsSync(path.join(root, 'claude-pane.json')), false);
|
| 41 |
|
| 42 |
+
let opencodeNow = Date.now();
|
| 43 |
marker('oc-pane', 'opencode', {
|
| 44 |
+
kind: 'question', source: 'opencode-event', requestId: 'que_1', at: opencodeNow,
|
| 45 |
+
});
|
| 46 |
+
const opencode = createInputRequiredTracker({
|
| 47 |
+
id: 'oc-pane', runId: RUN, cli: 'opencode', now: () => opencodeNow,
|
| 48 |
});
|
|
|
|
| 49 |
assert.equal(opencode.get()?.kind, 'question');
|
| 50 |
opencode.observeInput();
|
| 51 |
assert.equal(opencode.get()?.kind, 'question', 'menu navigation cannot clear a paired OpenCode request');
|
| 52 |
+
opencodeNow += 30 * 60_000 + 1;
|
| 53 |
+
assert.equal(opencode.get(), null, 'a missing OpenCode close event cannot leave an unbounded marker');
|
| 54 |
+
assert.equal(fs.existsSync(path.join(root, 'oc-pane.json')), false, 'expiry removes the stale paired marker');
|
| 55 |
+
|
| 56 |
+
marker('oc-reply', 'opencode', {
|
| 57 |
+
kind: 'permission', source: 'opencode-event', requestId: 'per_1',
|
| 58 |
+
});
|
| 59 |
+
const opencodeReply = createInputRequiredTracker({ id: 'oc-reply', runId: RUN, cli: 'opencode' });
|
| 60 |
+
assert.equal(opencodeReply.get()?.kind, 'permission');
|
| 61 |
+
fs.unlinkSync(path.join(root, 'oc-reply.json'));
|
| 62 |
+
assert.equal(opencodeReply.get(), null, 'the paired reply/removal still clears OpenCode immediately');
|
| 63 |
|
| 64 |
let now = 1_800_000_000_000;
|
| 65 |
const codex = createInputRequiredTracker({ id: 'codex-pane', runId: RUN, cli: 'codex', now: () => now });
|