Agent Manager commited on
Commit
126e54c
·
1 Parent(s): df112f9

Bound stale input request markers

Browse files
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; doing this later requires a typed response API from each adapter.
 
 
 
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 is authoritative until the final queued request closes.
79
- For CLIs without a paired close event, an actual operator key, a native
80
- completion event, process exit, or the 30-minute safety limit clears the signal.
81
- Automatic terminal query replies do not count as operator input. The safety
82
- limit can create a false negative for a dialog left open longer than 30 minutes;
83
- that is intentional because an indefinitely stale warning is the more damaging
84
- failure mode.
 
 
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 binary accepted the invocation-local notification settings,
96
- and the installed Gemini binary loaded the proposed system settings file. This
97
- branch was not deployed and did not drive authenticated live permission dialogs;
98
- the native event payloads and terminal sequences are exercised by automated
99
- fixtures. A deployment smoke test should deliberately trigger each covered
100
- dialog before release, especially after a CLI version update.
 
 
 
 
 
 
 
 
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
- const paired = marker.source === 'opencode-event';
80
- if (!paired && time - marker.at > ONE_SHOT_MAX_AGE_MS) {
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 may
132
- // clear it. The other CLIs expose an exact open signal but no exact close;
133
- // any operator key clears them conservatively (a false negative is safer).
 
134
  const marker = readMarker(id, runId, cli, now());
135
  if (current?.source === 'opencode-event' || marker?.source === 'opencode-event') return;
136
- removeMatchingMarker(id, runId, cli, { oneShotOnly: true });
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
- fs.unlinkSync(path.join(root, 'oc-pane.json'));
50
- assert.equal(opencode.get(), null, 'the paired reply/removal clears OpenCode');
 
 
 
 
 
 
 
 
 
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 });