Agent Manager commited on
Commit
df112f9
·
1 Parent(s): 90c04f1

Surface native agent input requests

Browse files
Dockerfile CHANGED
@@ -55,6 +55,10 @@ RUN chmod 755 /etc/codex/hooks/am-codex-repin-hook.sh
55
  # Newer agents, best-effort so a publish hiccup can't break the image build;
56
  # the app marks any missing binary "unavailable" gracefully.
57
  RUN npm install -g @google/gemini-cli@latest || echo "gemini-cli install failed"
 
 
 
 
58
  RUN npm install -g opencode-ai@latest || echo "opencode install failed"
59
  RUN npm install -g openclaw@latest || echo "openclaw install failed"
60
  # ccusage powers the Usage page (token/cost aggregation across agents). Its
 
55
  # Newer agents, best-effort so a publish hiccup can't break the image build;
56
  # the app marks any missing binary "unavailable" gracefully.
57
  RUN npm install -g @google/gemini-cli@latest || echo "gemini-cli install failed"
58
+ # Gemini merges hook arrays across settings layers. The lowest-priority system
59
+ # defaults layer adds an observation-only ToolPermission hook without replacing
60
+ # any user or project hooks.
61
+ COPY gemini-system-defaults.json /etc/gemini-cli/system-defaults.json
62
  RUN npm install -g opencode-ai@latest || echo "opencode install failed"
63
  RUN npm install -g openclaw@latest || echo "openclaw install failed"
64
  # ccusage powers the Usage page (token/cost aggregation across agents). Its
docs/tui-input-required.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Interactive TUI dialogs in the reader
2
+
3
+ ## Decision
4
+
5
+ Agent Manager surfaces an interactive-dialog warning only when the CLI itself
6
+ emits a lifecycle event for an open permission, question or confirmation UI.
7
+ It does **not** infer this state from a quiet process, a static screen or words
8
+ such as `Allow` in terminal text. Those heuristics cannot distinguish a dialog
9
+ from thinking, a completed turn, documentation, or agent-produced output; a
10
+ false "blocked" badge would be worse than a missed prompt.
11
+
12
+ The pane reader and Overview card replace their reply composer with a **Needs
13
+ input** banner and an **open terminal** action. The session tile and sidebar
14
+ also identify the condition. The normal composer is hidden because submitting
15
+ text while a choice menu owns stdin can select the wrong option. The prompt and
16
+ attachment APIs refuse the same operation with HTTP 409 while the signal is
17
+ active; direct terminal input remains available.
18
+
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
+
26
+ Research covered the installed versions on 2026-08-15: Claude Code 2.1.232,
27
+ Codex 0.147.0, Gemini CLI 0.55.1, OpenCode 1.18.18, Hermes 0.20.1, and
28
+ OpenClaw 2026.7.1-2. The linked source revisions below are the evidence for
29
+ 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. |
37
+ | Hermes | None shipped. Hermes has `_approval_state` and clarify/approval callbacks inside the Python TUI, but no supported external lifecycle hook for the `hermes` process Agent Manager launches. | No reliable external signal found. | All Hermes dialogs. Reading process memory, patching its installed Python package, or matching rendered text was rejected. |
38
+ | OpenClaw | None shipped. OpenClaw's gateway has approval request/resolve events and can list open gateway approvals, but Agent Manager launches the local embedded TUI and has no stable, authenticated session-to-gateway approval stream to consume. | No reliable pane-scoped signal integrated. | All OpenClaw local-TUI dialogs. A future gateway adapter could support these without screen scraping. |
39
+
40
+ Shell, Files, Trace, and Remote panes are not local agent TUIs and do not run
41
+ these adapters.
42
+
43
+ ## Evidence
44
+
45
+ - Claude documents that `permission_prompt` and elicitation notifications start
46
+ from an actual displayed dialog, fire after about six seconds without input,
47
+ and still run when desktop notifications are disabled. It also documents that
48
+ `PermissionRequest` runs in non-interactive sessions that cannot show a
49
+ prompt, which is why this implementation does not use it as proof of a visible
50
+ dialog: [Claude Code hooks reference](https://code.claude.com/docs/en/hooks#notification).
51
+ - Codex calls its notifier in the handlers that push the approval and user-input
52
+ views: [tool request handlers](https://github.com/openai/codex/blob/c4941302c73c6322b153bba13ac0a9f4396301d6/codex-rs/tui/src/chatwidget/tool_requests.rs).
53
+ Its notification types distinguish `approval-requested` and
54
+ `plan-mode-prompt`: [notification model](https://github.com/openai/codex/blob/c4941302c73c6322b153bba13ac0a9f4396301d6/codex-rs/tui/src/chatwidget/notifications.rs).
55
+ - Gemini fires `ToolPermission` immediately before it places the call in
56
+ `AwaitingApproval`: [confirmation scheduler](https://github.com/google-gemini/gemini-cli/blob/2a87e7be103308b8734246097ba723cc7deb4122/packages/core/src/scheduler/confirmation.ts).
57
+ Its own attention selector enumerates tool/ask-user, command, authentication,
58
+ filesystem, extension and loop confirmations:
59
+ [pending attention state](https://github.com/google-gemini/gemini-cli/blob/2a87e7be103308b8734246097ba723cc7deb4122/packages/cli/src/ui/utils/pendingAttentionNotification.ts).
60
+ - OpenCode's own TUI notification plugin tracks the same paired permission and
61
+ question events used here: [OpenCode notifications](https://github.com/anomalyco/opencode/blob/4643e65ad6334de3e4e68dedc201d5fbb828c9fe/packages/tui/src/feature-plugins/system/notifications.ts).
62
+ Its session reducer documents and handles the missing-question-reply edge:
63
+ [session data recovery](https://github.com/anomalyco/opencode/blob/4643e65ad6334de3e4e68dedc201d5fbb828c9fe/packages/opencode/src/cli/cmd/run/session-data.ts).
64
+ - Hermes's approval wait is an in-process queue behind `_approval_state`:
65
+ [Hermes callbacks](https://github.com/NousResearch/hermes-agent/blob/56a41715dc3b8bf6f50a740ff9416c4036ef4259/hermes_cli/callbacks.py).
66
+ - OpenClaw's gateway-facing MCP adapter can list and resolve open approval
67
+ requests, but that stream is not the pane-scoped local TUI Agent Manager
68
+ launches: [OpenClaw MCP CLI](https://github.com/openclaw/openclaw/blob/0790d9f593ad30c940ed93b5872a8cf6d6f3cf8c/src/cli/mcp-cli.ts).
69
+
70
+ ## False-positive controls
71
+
72
+ Every file signal carries the Agent Manager pane id, CLI id, and random launch
73
+ id. The server accepts it only for the matching live PTY launch. Claude and
74
+ 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
+
88
+ Automated coverage includes stale/mismatched launch markers, unrelated terminal
89
+ notifications, OSC sequences split across PTY chunks, one-shot expiry and clear
90
+ events, OpenCode queues, reject/reply events, OpenCode's missing reply recovery,
91
+ 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.
gemini-system-defaults.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "general": {
3
+ "enableNotifications": true,
4
+ "notificationMethod": "osc9"
5
+ },
6
+ "hooks": {
7
+ "Notification": [
8
+ {
9
+ "matcher": "ToolPermission",
10
+ "hooks": [
11
+ {
12
+ "name": "agent-manager-input-required",
13
+ "type": "command",
14
+ "command": "/app/scripts/am-input-required-hook.sh",
15
+ "timeout": 5000
16
+ }
17
+ ]
18
+ }
19
+ ],
20
+ "AfterAgent": [
21
+ {
22
+ "matcher": "*",
23
+ "hooks": [
24
+ {
25
+ "name": "agent-manager-input-resolved",
26
+ "type": "command",
27
+ "command": "/app/scripts/am-input-required-hook.sh",
28
+ "timeout": 5000
29
+ }
30
+ ]
31
+ }
32
+ ],
33
+ "SessionEnd": [
34
+ {
35
+ "matcher": "*",
36
+ "hooks": [
37
+ {
38
+ "name": "agent-manager-input-session-end",
39
+ "type": "command",
40
+ "command": "/app/scripts/am-input-required-hook.sh",
41
+ "timeout": 5000
42
+ }
43
+ ]
44
+ }
45
+ ]
46
+ }
47
+ }
scripts/am-input-required-hook.sh ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # Observation-only hook for native CLI "this dialog is actually open" events.
3
+ # It never approves, rejects or otherwise participates in the decision.
4
+
5
+ [ -n "$AM_ID" ] || exit 0
6
+ case "$AM_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
7
+ [ -n "$AM_RUN_ID" ] || exit 0
8
+ case "$AM_RUN_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
9
+ case "$AM_PANE_PID" in '' | *[!0-9]*) exit 0 ;; esac
10
+ command -v jq >/dev/null 2>&1 || exit 0
11
+
12
+ payload=$(mktemp "${TMPDIR:-/tmp}/am-input-hook.XXXXXX") || exit 0
13
+ trap 'rm -f "$payload"' EXIT HUP INT TERM
14
+ cat > "$payload" || exit 0
15
+
16
+ kind=
17
+ source=
18
+ clear=
19
+ case "$AM_CLI" in
20
+ claude)
21
+ # A global Claude config is inherited by nested Claude processes. Its
22
+ # official entrypoint/pid fields let us keep only this pane's root CLI.
23
+ [ "$CLAUDE_CODE_ENTRYPOINT" = "cli" ] || exit 0
24
+ case "$CLAUDE_PID" in '' | *[!0-9]*) exit 0 ;; esac
25
+ if [ "$CLAUDE_PID" != "$AM_PANE_PID" ]; then
26
+ stat=$(cat "/proc/$CLAUDE_PID/stat" 2>/dev/null) || exit 0
27
+ rest=${stat##*) }
28
+ rest=${rest#* }
29
+ [ "${rest%% *}" = "$AM_PANE_PID" ] || exit 0
30
+ fi
31
+ case "$(jq -r '.hook_event_name // empty' "$payload")" in
32
+ Notification)
33
+ case "$(jq -r '.notification_type // empty' "$payload")" in
34
+ permission_prompt) kind=permission ;;
35
+ elicitation_dialog | elicitation_url_dialog | agent_needs_input) kind=question ;;
36
+ agent_completed) clear=1 ;;
37
+ *) exit 0 ;;
38
+ esac
39
+ ;;
40
+ PostToolBatch | Stop | SessionEnd | ElicitationResult | UserPromptSubmit) clear=1 ;;
41
+ *) exit 0 ;;
42
+ esac
43
+ source=claude-notification
44
+ ;;
45
+ gemini)
46
+ # Gemini spawns `bash -c` for command hooks. Bash normally execs this
47
+ # single-command script, but allowing that one runner process keeps the
48
+ # attribution correct if it forks instead. A Gemini started by an agent
49
+ # tool still has its own CLI and tool shell between here and the pane root.
50
+ stat=$(cat "/proc/$$/stat" 2>/dev/null) || exit 0
51
+ rest=${stat##*) }
52
+ rest=${rest#* }
53
+ parent=${rest%% *}
54
+ if [ "$parent" != "$AM_PANE_PID" ]; then
55
+ stat=$(cat "/proc/$parent/stat" 2>/dev/null) || exit 0
56
+ rest=${stat##*) }
57
+ rest=${rest#* }
58
+ [ "${rest%% *}" = "$AM_PANE_PID" ] || exit 0
59
+ fi
60
+ case "$(jq -r '.hook_event_name // empty' "$payload")" in
61
+ Notification)
62
+ [ "$(jq -r '.notification_type // empty' "$payload")" = "ToolPermission" ] || exit 0
63
+ if [ "$(jq -r '.details.type // empty' "$payload")" = "ask_user" ]; then kind=question; else kind=permission; fi
64
+ ;;
65
+ AfterAgent | SessionEnd) clear=1 ;;
66
+ *) exit 0 ;;
67
+ esac
68
+ source=gemini-notification
69
+ ;;
70
+ *) exit 0 ;;
71
+ esac
72
+
73
+ d=${AM_INPUT_REQUIRED_DIR:-/tmp/am-input-required}
74
+ mkdir -p "$d" 2>/dev/null || exit 0
75
+ if [ -n "$clear" ]; then
76
+ file="$d/$AM_ID.json"
77
+ [ "$(jq -r '.runId // empty' "$file" 2>/dev/null)" = "$AM_RUN_ID" ] || exit 0
78
+ [ "$(jq -r '.cli // empty' "$file" 2>/dev/null)" = "$AM_CLI" ] || exit 0
79
+ rm -f "$file"
80
+ exit 0
81
+ fi
82
+ at=$(( $(date +%s) * 1000 ))
83
+ tmp="$d/$AM_ID.json.$$.tmp"
84
+ jq -n --arg amId "$AM_ID" --arg runId "$AM_RUN_ID" --arg cli "$AM_CLI" \
85
+ --arg kind "$kind" --arg source "$source" --argjson at "$at" \
86
+ '{amId:$amId,runId:$runId,cli:$cli,kind:$kind,source:$source,at:$at}' > "$tmp" 2>/dev/null \
87
+ && mv -f "$tmp" "$d/$AM_ID.json"
88
+ exit 0
scripts/am-opencode-repin.js CHANGED
@@ -1,16 +1,21 @@
1
- import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
2
  import path from 'node:path';
3
  import os from 'node:os';
4
 
5
  const SAFE = /^[A-Za-z0-9_-]+$/;
6
 
7
- function report(sessionID, cwd, source) {
8
  const amId = process.env.AM_ID;
9
  const runId = process.env.AM_RUN_ID;
10
- if (process.env.AM_CLI !== 'opencode' || !SAFE.test(amId || '') || !SAFE.test(runId || '')) return;
11
- // The global plugin is also loaded by nested OpenCode processes. Only the
12
- // process that replaced the PTY's login shell owns this pane.
13
- if (String(process.pid) !== process.env.AM_PANE_PID) return;
 
 
 
 
 
14
  if (!/^ses_[A-Za-z0-9_-]+$/.test(sessionID || '') || typeof cwd !== 'string') return;
15
  const dir = process.env.AM_REPIN_DIR || path.join(os.tmpdir(), 'am-repin');
16
  const file = path.join(dir, `${amId}.opencode.json`);
@@ -31,26 +36,104 @@ function report(sessionID, cwd, source) {
31
  } catch { /* telemetry must never interfere with the user's prompt */ }
32
  }
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  // OpenCode creates a new root session for /new (alias /clear). chat.message
35
  // additionally follows an explicit switch to an existing session; runner.js
36
  // verifies that id against the database and rejects child/subagent sessions.
37
- export const AgentManagerRepin = async ({ directory }) => ({
38
- event: async ({ event }) => {
39
- if (event?.type !== 'session.created') return;
40
- const info = event.properties?.info;
41
- if (!info?.id || info.parentID) return;
42
- report(info.id, info.directory || directory, 'session.created');
43
- },
44
- 'chat.message': async ({ sessionID }) => {
45
- report(sessionID, directory, 'chat.message');
46
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  // Tool shells must not pass the pane's private attribution markers to an
48
  // agent launched inside them. Empty values override OpenCode's process.env
49
  // merge and make the nested plugin a no-op.
50
- 'shell.env': async (_input, output) => {
51
- output.env.AM_ID = '';
52
- output.env.AM_RUN_ID = '';
53
- output.env.AM_CLI = '';
54
- output.env.AM_PANE_PID = '';
55
- },
56
- });
 
 
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
  import path from 'node:path';
3
  import os from 'node:os';
4
 
5
  const SAFE = /^[A-Za-z0-9_-]+$/;
6
 
7
+ function paneIdentity() {
8
  const amId = process.env.AM_ID;
9
  const runId = process.env.AM_RUN_ID;
10
+ if (process.env.AM_CLI !== 'opencode' || !SAFE.test(amId || '') || !SAFE.test(runId || '')) return null;
11
+ if (String(process.pid) !== process.env.AM_PANE_PID) return null;
12
+ return { amId, runId };
13
+ }
14
+
15
+ function report(sessionID, cwd, source) {
16
+ const identity = paneIdentity();
17
+ if (!identity) return;
18
+ const { amId, runId } = identity;
19
  if (!/^ses_[A-Za-z0-9_-]+$/.test(sessionID || '') || typeof cwd !== 'string') return;
20
  const dir = process.env.AM_REPIN_DIR || path.join(os.tmpdir(), 'am-repin');
21
  const file = path.join(dir, `${amId}.opencode.json`);
 
36
  } catch { /* telemetry must never interfere with the user's prompt */ }
37
  }
38
 
39
+ function syncInputRequired(pending) {
40
+ const identity = paneIdentity();
41
+ if (!identity) return;
42
+ const { amId, runId } = identity;
43
+ const dir = process.env.AM_INPUT_REQUIRED_DIR || path.join(os.tmpdir(), 'am-input-required');
44
+ const file = path.join(dir, `${amId}.json`);
45
+ const item = pending.values().next().value;
46
+ if (!item) {
47
+ try {
48
+ const existing = JSON.parse(readFileSync(file, 'utf8'));
49
+ if (existing?.runId === runId && existing?.source === 'opencode-event') unlinkSync(file);
50
+ } catch { /* already absent or no longer ours */ }
51
+ return;
52
+ }
53
+ const tmp = `${file}.${process.pid}.tmp`;
54
+ try {
55
+ mkdirSync(dir, { recursive: true });
56
+ writeFileSync(tmp, JSON.stringify({
57
+ amId,
58
+ runId,
59
+ cli: 'opencode',
60
+ kind: item.kind,
61
+ source: 'opencode-event',
62
+ requestId: item.id,
63
+ at: item.at,
64
+ }));
65
+ renameSync(tmp, file);
66
+ } catch { /* an attention signal must never interfere with the TUI */ }
67
+ }
68
+
69
  // OpenCode creates a new root session for /new (alias /clear). chat.message
70
  // additionally follows an explicit switch to an existing session; runner.js
71
  // verifies that id against the database and rejects child/subagent sessions.
72
+ export const AgentManagerRepin = async ({ directory }) => {
73
+ // OpenCode's own TUI uses the same asked/replied event pairs. Keep every
74
+ // queued request: resolving one must not hide another waiting behind it.
75
+ const pending = new Map();
76
+
77
+ const changed = () => syncInputRequired(pending);
78
+ const drop = (id) => {
79
+ if (!id || !pending.delete(id)) return;
80
+ changed();
81
+ };
82
+
83
+ return ({
84
+ event: async ({ event }) => {
85
+ const props = event?.properties || {};
86
+ if (event?.type === 'session.created') {
87
+ const info = props.info;
88
+ if (info?.id && !info.parentID) report(info.id, info.directory || directory, 'session.created');
89
+ return;
90
+ }
91
+ if (event?.type === 'permission.asked' || event?.type === 'question.asked') {
92
+ if (!props.id || pending.has(props.id)) return;
93
+ pending.set(props.id, {
94
+ id: props.id,
95
+ sessionID: props.sessionID,
96
+ kind: event.type === 'question.asked' ? 'question' : 'permission',
97
+ tool: props.tool || null,
98
+ at: Date.now(),
99
+ });
100
+ changed();
101
+ return;
102
+ }
103
+ if (event?.type === 'permission.replied'
104
+ || event?.type === 'question.replied'
105
+ || event?.type === 'question.rejected') {
106
+ drop(props.requestID);
107
+ return;
108
+ }
109
+ // A Question tool can complete without question.replied. OpenCode's TUI
110
+ // has this same recovery path; mirror it so the marker cannot stick.
111
+ if (event?.type === 'message.part.updated') {
112
+ const part = props.part;
113
+ if (part?.type !== 'tool' || part.tool !== 'question'
114
+ || (part.state?.status !== 'completed' && part.state?.status !== 'error')) return;
115
+ let dirty = false;
116
+ for (const [id, item] of pending) {
117
+ if (item.kind !== 'question' || !item.tool) continue;
118
+ if (item.tool.messageID === part.messageID && item.tool.callID === part.callID) {
119
+ pending.delete(id);
120
+ dirty = true;
121
+ }
122
+ }
123
+ if (dirty) changed();
124
+ }
125
+ },
126
+ 'chat.message': async ({ sessionID }) => {
127
+ report(sessionID, directory, 'chat.message');
128
+ },
129
  // Tool shells must not pass the pane's private attribution markers to an
130
  // agent launched inside them. Empty values override OpenCode's process.env
131
  // merge and make the nested plugin a no-op.
132
+ 'shell.env': async (_input, output) => {
133
+ output.env.AM_ID = '';
134
+ output.env.AM_RUN_ID = '';
135
+ output.env.AM_CLI = '';
136
+ output.env.AM_PANE_PID = '';
137
+ },
138
+ });
139
+ };
server/package.json CHANGED
@@ -16,7 +16,7 @@
16
  "test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs && node reader-info.test.mjs",
17
  "test:screenshots": "node screenshot-input.test.mjs",
18
  "test:mobile": "node mobile.test.mjs",
19
- "test": "node test/trace-download.test.mjs && node test/attachments.test.mjs && node state-checkpoint.test.mjs && node test/usage.test.mjs && node test/operations.test.mjs && node test/hidden.test.mjs && node test/slowfs.test.mjs && node test/spawn-group.test.mjs && node test/revive.test.mjs && node test/repin.test.mjs && node test/codex-repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node test/trace-window.test.mjs && node migration.test.mjs && node resize.test.mjs"
20
  },
21
  "engines": {
22
  "node": ">=20.19"
 
16
  "test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs && node reader-info.test.mjs",
17
  "test:screenshots": "node screenshot-input.test.mjs",
18
  "test:mobile": "node mobile.test.mjs",
19
+ "test": "node test/trace-download.test.mjs && node test/attachments.test.mjs && node state-checkpoint.test.mjs && node test/usage.test.mjs && node test/operations.test.mjs && node test/hidden.test.mjs && node test/slowfs.test.mjs && node test/spawn-group.test.mjs && node test/revive.test.mjs && node test/repin.test.mjs && node test/codex-repin.test.mjs && node test/opencode-resume.test.mjs && node test/input-required.test.mjs && node test/terminal-modes.test.mjs && node test/trace-tail.test.mjs && node test/trace-window.test.mjs && node migration.test.mjs && node resize.test.mjs"
20
  },
21
  "engines": {
22
  "node": ">=20.19"
server/src/config.js CHANGED
@@ -70,6 +70,15 @@ export const PASSIVE_CLIS = ['files', 'trace'];
70
  // it" path has to route around it — see isRemote() callers.
71
  export const isRemote = (cli) => cli === 'remote';
72
 
 
 
 
 
 
 
 
 
 
73
  export const CLIS = [
74
  { id: 'shell', label: 'Shell', bin: 'bash', color: '#8aa0ad', run: 'exec bash -il', cont: null },
75
  { id: 'files', label: 'Files', bin: null, color: '#d99a2b', run: null, cont: null },
@@ -83,10 +92,11 @@ export const CLIS = [
83
  { id: 'claude', label: 'Claude Code', bin: 'claude', color: '#d97757', run: 'claude', cont: 'claude --continue', resizeMode: 'repaint',
84
  withPrompt: (q) => `claude ${q}`,
85
  setup: setupHint('ANTHROPIC_API_KEY') },
86
- { id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last', resizeMode: 'repaint',
 
87
  // `q` and image paths arrive shell-quoted from runner.commandFor(). Repeat
88
  // -i because Codex's variadic flag would otherwise consume the prompt.
89
- withPrompt: (q, images = []) => `codex${images.length ? ` ${images.map((image) => `-i ${image}`).join(' ')}` : ''} ${q}`,
90
  setup: setupHint('OPENAI_API_KEY') },
91
  { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null, resizeMode: 'repaint',
92
  withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
 
70
  // it" path has to route around it — see isRemote() callers.
71
  export const isRemote = (cli) => cli === 'remote';
72
 
73
+ // Codex's TUI emits OSC 9 only after it has installed one of these native
74
+ // interactive views. Pin the notification backend per invocation so Agent
75
+ // Manager can consume that control signal without changing the operator's
76
+ // durable Codex preferences or enabling ordinary turn-complete notifications.
77
+ const CODEX_INPUT_SIGNALS = '-c \'tui.notifications=["approval-requested","plan-mode-prompt"]\''
78
+ + ' -c \'tui.notification_method="osc9"\''
79
+ + ' -c \'tui.notification_condition="always"\'';
80
+ const codexCommand = (tail = '') => `codex ${CODEX_INPUT_SIGNALS}${tail ? ` ${tail}` : ''}`;
81
+
82
  export const CLIS = [
83
  { id: 'shell', label: 'Shell', bin: 'bash', color: '#8aa0ad', run: 'exec bash -il', cont: null },
84
  { id: 'files', label: 'Files', bin: null, color: '#d99a2b', run: null, cont: null },
 
92
  { id: 'claude', label: 'Claude Code', bin: 'claude', color: '#d97757', run: 'claude', cont: 'claude --continue', resizeMode: 'repaint',
93
  withPrompt: (q) => `claude ${q}`,
94
  setup: setupHint('ANTHROPIC_API_KEY') },
95
+ { id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: codexCommand(), cont: codexCommand('resume --last'), resizeMode: 'repaint',
96
+ resume: (id) => codexCommand(`resume ${id}`),
97
  // `q` and image paths arrive shell-quoted from runner.commandFor(). Repeat
98
  // -i because Codex's variadic flag would otherwise consume the prompt.
99
+ withPrompt: (q, images = []) => `${codexCommand()}${images.length ? ` ${images.map((image) => `-i ${image}`).join(' ')}` : ''} ${q}`,
100
  setup: setupHint('OPENAI_API_KEY') },
101
  { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null, resizeMode: 'repaint',
102
  withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
server/src/index.js CHANGED
@@ -548,6 +548,7 @@ function agentRow(s, act, d, selfId, mates) {
548
  state: deriveState(s, act),
549
  // Seconds since its screen last changed. Small = actively working.
550
  idleFor: act ? act.age : null,
 
551
  workdir: workspacePath(folder),
552
  path: folder,
553
  // Who else writes to this same folder — the actual collision hazard.
@@ -2026,7 +2027,7 @@ function sessionsWithState() {
2026
  const info = agentInfo();
2027
  return store.list().map((s) => {
2028
  const state = deriveState(s, info.get(s.id));
2029
- return { ...s, state, running: state !== 'stopped' };
2030
  });
2031
  }
2032
 
@@ -2624,11 +2625,12 @@ wss.on('connection', (ws, req) => {
2624
  let msg;
2625
  try { msg = JSON.parse(raw.toString()); } catch { return; }
2626
  if (msg.t === 'i') {
2627
- handle.write(msg.d);
 
2628
  // Not every frame on this channel is you: the emulator answers the TUI's
2629
  // device-attribute and cursor queries down the same path, instantly on
2630
  // attach. Opening a pane is not sending it something.
2631
- if (!runstate.isTerminalReply(msg.d)) touchInput(session.id);
2632
  }
2633
  else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
2634
  else if (msg.t === 'claim') handle.claim();
 
548
  state: deriveState(s, act),
549
  // Seconds since its screen last changed. Small = actively working.
550
  idleFor: act ? act.age : null,
551
+ inputRequired: act?.inputRequired || null,
552
  workdir: workspacePath(folder),
553
  path: folder,
554
  // Who else writes to this same folder — the actual collision hazard.
 
2027
  const info = agentInfo();
2028
  return store.list().map((s) => {
2029
  const state = deriveState(s, info.get(s.id));
2030
+ return { ...s, state, running: state !== 'stopped', inputRequired: info.get(s.id)?.inputRequired || null };
2031
  });
2032
  }
2033
 
 
2625
  let msg;
2626
  try { msg = JSON.parse(raw.toString()); } catch { return; }
2627
  if (msg.t === 'i') {
2628
+ const terminalReply = runstate.isTerminalReply(msg.d);
2629
+ handle.write(msg.d, { terminalReply });
2630
  // Not every frame on this channel is you: the emulator answers the TUI's
2631
  // device-attribute and cursor queries down the same path, instantly on
2632
  // attach. Opening a pane is not sending it something.
2633
+ if (!terminalReply) touchInput(session.id);
2634
  }
2635
  else if (msg.t === 'r') handle.resize(msg.cols, msg.rows);
2636
  else if (msg.t === 'claim') handle.claim();
server/src/input-required.js ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/;
6
+ const KINDS = new Set(['permission', 'question', 'confirmation']);
7
+ const MARKER_SOURCES = new Map([
8
+ ['claude', new Set(['claude-notification'])],
9
+ ['gemini', new Set(['gemini-notification'])],
10
+ ['opencode', new Set(['opencode-event'])],
11
+ ]);
12
+ const configuredMaxAge = Number(process.env.AM_INPUT_REQUIRED_MAX_AGE_MS);
13
+ const ONE_SHOT_MAX_AGE_MS = Number.isFinite(configuredMaxAge) && configuredMaxAge > 0
14
+ ? configuredMaxAge : 30 * 60_000;
15
+
16
+ function markerDirectory() {
17
+ return process.env.AM_INPUT_REQUIRED_DIR || path.join(os.tmpdir(), 'am-input-required');
18
+ }
19
+
20
+ function markerFile(id) {
21
+ return SAFE_ID.test(id || '') ? path.join(markerDirectory(), `${id}.json`) : null;
22
+ }
23
+
24
+ function readMarker(id, runId, cli, now) {
25
+ const file = markerFile(id);
26
+ if (!file) return null;
27
+ try {
28
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
29
+ const at = Number(value?.at);
30
+ const source = typeof value?.source === 'string' ? value.source : '';
31
+ if (value?.amId !== id || value?.runId !== runId || value?.cli !== cli
32
+ || !KINDS.has(value?.kind) || !MARKER_SOURCES.get(cli)?.has(source)
33
+ || !Number.isFinite(at) || at <= 0 || at > now + 60_000) return null;
34
+ return {
35
+ file,
36
+ at,
37
+ kind: value.kind,
38
+ source,
39
+ requestId: typeof value.requestId === 'string' ? value.requestId : '',
40
+ };
41
+ } catch { return null; }
42
+ }
43
+
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
+
50
+ function publicState(current) {
51
+ if (!current) return null;
52
+ return {
53
+ kind: current.kind,
54
+ cli: current.cli,
55
+ confidence: 'high',
56
+ detectedAt: new Date(current.at).toISOString(),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Native interactive-dialog signals for one PTY launch.
62
+ *
63
+ * This intentionally has no screen-text or process-idle fallback. An agent can
64
+ * print any prompt-looking text, and an event-loop TUI polls stdin while both
65
+ * thinking and waiting. Only CLI lifecycle events enter this tracker.
66
+ */
67
+ export function createInputRequiredTracker({ id, runId, cli, now = () => Date.now() }) {
68
+ let current = null;
69
+ let osc = '';
70
+
71
+ const set = (kind, source, at, transport, token = '') => {
72
+ current = { kind, source, at, transport, token, cli };
73
+ };
74
+
75
+ const get = () => {
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}`;
85
+ if (current?.transport !== 'marker' || current.token !== token) {
86
+ set(marker.kind, marker.source, marker.at, 'marker', token);
87
+ }
88
+ }
89
+ } else if (current?.transport === 'marker') {
90
+ current = null;
91
+ }
92
+
93
+ if (current?.transport === 'terminal' && time - current.at > ONE_SHOT_MAX_AGE_MS) current = null;
94
+ return publicState(current);
95
+ };
96
+
97
+ const observeOutput = (chunk) => {
98
+ if ((cli !== 'codex' && cli !== 'gemini') || !chunk) return;
99
+ osc += String(chunk);
100
+ // Codex emits these OSC 9 messages only after its TUI has installed the
101
+ // corresponding approval/question view. Invocation-local config forces
102
+ // the exact backend and enables only these two notification classes.
103
+ const re = /\x1b\](9;|777;notify;)([^\x07\x1b]{1,2048})(?:\x07|\x1b\\)/g;
104
+ let match;
105
+ let consumed = 0;
106
+ while ((match = re.exec(osc))) {
107
+ consumed = re.lastIndex;
108
+ const message = match[2];
109
+ if (cli === 'codex') {
110
+ if (message.startsWith('Plan mode prompt:')) {
111
+ set('question', 'codex-notification', now(), 'terminal');
112
+ } else if (message.startsWith('Approval requested:')
113
+ || message.startsWith('Codex wants to edit ')
114
+ || message.startsWith('Approval requested by ')) {
115
+ set('permission', 'codex-notification', now(), 'terminal');
116
+ }
117
+ } else if (message.startsWith('Gemini CLI needs your attention')) {
118
+ set(message.includes('Answer requested by agent') ? 'question' : 'confirmation',
119
+ 'gemini-notification', now(), 'terminal');
120
+ } else if (message.startsWith('Gemini CLI session complete')) {
121
+ removeMatchingMarker(id, runId, cli, { oneShotOnly: true });
122
+ current = null;
123
+ }
124
+ }
125
+ if (consumed) osc = osc.slice(consumed);
126
+ if (osc.length > 4096) osc = osc.slice(-4096);
127
+ };
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
+
140
+ const close = () => {
141
+ removeMatchingMarker(id, runId, cli);
142
+ current = null;
143
+ osc = '';
144
+ };
145
+
146
+ return { get, observeOutput, observeInput, close };
147
+ }
server/src/runner.js CHANGED
@@ -16,6 +16,7 @@ import {
16
  traceHistoryLines,
17
  } from './history-store.js';
18
  import { createTerminalModeTracker } from './terminal-modes.js';
 
19
 
20
  // libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
21
  // is guarded so a platform without a prebuilt still boots and says so, rather
@@ -153,7 +154,11 @@ export function agentInfo() {
153
  const now = Date.now();
154
  for (const [id, host] of hosts) {
155
  const changedAt = host.screenChangedAt || host.startedAt;
156
- map.set(id, { age: Math.round((now - changedAt) / 1000), bells: host.bells || 0 });
 
 
 
 
157
  }
158
  return map;
159
  }
@@ -1311,7 +1316,10 @@ export async function codexRolloutForId(id) {
1311
  // not parse is left alone (clobbering the user's settings to install a hook
1312
  // would be a terrible trade), and every failure is non-fatal: without the
1313
  // hook the watcher simply keeps today's behaviour.
1314
- export function installClaudeRepinHook(hookCmd = '/app/scripts/am-repin-hook.sh') {
 
 
 
1315
  const dir = process.env.CLAUDE_CONFIG_DIR;
1316
  if (!dir) return false;
1317
  const file = path.join(dir, 'settings.json');
@@ -1322,19 +1330,49 @@ export function installClaudeRepinHook(hookCmd = '/app/scripts/am-repin-hook.sh'
1322
  if (e.code !== 'ENOENT') { console.warn(`[claude] not installing repin hook: ${file} unreadable (${e.message})`); return false; }
1323
  }
1324
  if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) { console.warn(`[claude] not installing repin hook: ${file} is not an object`); return false; }
 
 
 
 
1325
  const entries = Array.isArray(cfg.hooks?.SessionStart) ? cfg.hooks.SessionStart : [];
1326
  const present = entries.some((m) => (m?.hooks || []).some((h) => String(h?.command || '').includes('am-repin-hook.sh')));
1327
- if (present) return true;
 
 
 
 
 
 
 
 
 
1328
  // No matcher: fire for every source. `startup` replaces the "--session-id
1329
  // not honoured" heuristic with a fact, `resume` is a proven no-op (same id),
1330
  // and `clear` is the case this exists for. Filtering happens server-side.
1331
  cfg.hooks = cfg.hooks || {};
1332
- cfg.hooks.SessionStart = [...entries, { hooks: [{ type: 'command', command: hookCmd, timeout: 5 }] }];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1333
  try {
1334
  const tmp = `${file}.am-tmp`;
1335
  fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n');
1336
  fs.renameSync(tmp, file);
1337
- console.warn(`[claude] repin hook installed in ${file}`);
1338
  return true;
1339
  } catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; }
1340
  }
@@ -1726,7 +1764,7 @@ export function commandFor(session) {
1726
  // pane instead of respawning. Unpinned sessions fall through to the generic
1727
  // `resume --last` below (correct while the agent has its folder to itself).
1728
  if (cli.id === 'codex' && session.codexSessionId && session.codexRollout) {
1729
- return `if [ -f '${session.codexRollout}' ]; then exec codex resume ${session.codexSessionId}; else exec codex; fi`;
1730
  }
1731
 
1732
  // opencode (seen on 1.17.13): at startup it creates a DIRECTORY named
@@ -1876,6 +1914,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1876
  screenChangedAt: Date.now(),
1877
  bells: 0,
1878
  };
 
1879
  host.historyCheckpoint = createTerminalHistoryCheckpoint({
1880
  directory: HISTORY_DIR,
1881
  id: host.id,
@@ -1900,6 +1939,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1900
  host.lastOutputAt = Date.now();
1901
  host.outputSeq++;
1902
  host.terminalModes.feed(chunk);
 
1903
  if (host.traceHistoryTimer) {
1904
  clearTimeout(host.traceHistoryTimer);
1905
  host.traceHistoryTimer = null;
@@ -1947,6 +1987,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
1947
  consumeBreadcrumb(session, host, workdir, true)
1948
  .catch((e) => console.warn(`[${session.cli}] ${session.id}: final breadcrumb read failed (${e && e.message})`));
1949
  hosts.delete(session.id);
 
1950
  stopping.delete(session.id);
1951
  if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
1952
  if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
@@ -2007,8 +2048,9 @@ export function attach(session, cols, rows) {
2007
  onExit: (cb) => { sub.onExit = () => { try { cb(); } catch {} }; },
2008
  onGrid: (cb) => { sub.onGrid = (c, r, controller, viewers, reset) => { try { cb(c, r, controller, viewers, reset); } catch {} }; },
2009
  // Input and terminal-query responses are accepted from one emulator only.
2010
- write: (d) => {
2011
  if (host.controller !== sub) return;
 
2012
  try { host.pty.write(d); } catch {}
2013
  },
2014
  // Every viewer remembers what it can display, but only the current
@@ -2053,6 +2095,12 @@ export function attach(session, cols, rows) {
2053
  export async function sendInput(id, text, { confirmEcho = false } = {}) {
2054
  const host = hosts.get(id);
2055
  if (!host || stopping.has(id)) throw new Error('session is not running');
 
 
 
 
 
 
2056
  // Multi-line prompts go in as a bracketed paste so the CLI's composer treats
2057
  // the inner newlines as soft line breaks instead of submitting early.
2058
  const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
@@ -2102,8 +2150,14 @@ export async function sendInput(id, text, { confirmEcho = false } = {}) {
2102
  export function pasteInput(id, text) {
2103
  const host = hosts.get(id);
2104
  if (!host || stopping.has(id)) throw new Error('session is not running');
 
 
 
 
 
2105
  const value = String(text || '');
2106
  if (!value) return;
 
2107
  const payload = value.includes('\n') ? `\x1b[200~${value}\x1b[201~` : value;
2108
  host.pty.write(payload);
2109
  }
 
16
  traceHistoryLines,
17
  } from './history-store.js';
18
  import { createTerminalModeTracker } from './terminal-modes.js';
19
+ import { createInputRequiredTracker } from './input-required.js';
20
 
21
  // libghostty-vt ships prebuilts for linux x64/arm64 and macOS arm64. Loading it
22
  // is guarded so a platform without a prebuilt still boots and says so, rather
 
154
  const now = Date.now();
155
  for (const [id, host] of hosts) {
156
  const changedAt = host.screenChangedAt || host.startedAt;
157
+ map.set(id, {
158
+ age: Math.round((now - changedAt) / 1000),
159
+ bells: host.bells || 0,
160
+ inputRequired: host.inputRequired.get(),
161
+ });
162
  }
163
  return map;
164
  }
 
1316
  // not parse is left alone (clobbering the user's settings to install a hook
1317
  // would be a terrible trade), and every failure is non-fatal: without the
1318
  // hook the watcher simply keeps today's behaviour.
1319
+ export function installClaudeRepinHook(
1320
+ hookCmd = '/app/scripts/am-repin-hook.sh',
1321
+ inputRequiredCmd = '/app/scripts/am-input-required-hook.sh',
1322
+ ) {
1323
  const dir = process.env.CLAUDE_CONFIG_DIR;
1324
  if (!dir) return false;
1325
  const file = path.join(dir, 'settings.json');
 
1330
  if (e.code !== 'ENOENT') { console.warn(`[claude] not installing repin hook: ${file} unreadable (${e.message})`); return false; }
1331
  }
1332
  if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) { console.warn(`[claude] not installing repin hook: ${file} is not an object`); return false; }
1333
+ if (cfg.hooks !== undefined && (typeof cfg.hooks !== 'object' || cfg.hooks === null || Array.isArray(cfg.hooks))) {
1334
+ console.warn(`[claude] not installing lifecycle hooks: ${file} hooks is not an object`);
1335
+ return false;
1336
+ }
1337
  const entries = Array.isArray(cfg.hooks?.SessionStart) ? cfg.hooks.SessionStart : [];
1338
  const present = entries.some((m) => (m?.hooks || []).some((h) => String(h?.command || '').includes('am-repin-hook.sh')));
1339
+ const notifications = Array.isArray(cfg.hooks?.Notification) ? cfg.hooks.Notification : [];
1340
+ const inputPresent = notifications.some((m) => (m?.hooks || [])
1341
+ .some((h) => String(h?.command || '').includes('am-input-required-hook.sh')));
1342
+ const clearEvents = ['PostToolBatch', 'Stop', 'SessionEnd', 'ElicitationResult', 'UserPromptSubmit'];
1343
+ const missingClear = clearEvents.some((event) => {
1344
+ const eventEntries = Array.isArray(cfg.hooks?.[event]) ? cfg.hooks[event] : [];
1345
+ return !eventEntries.some((m) => (m?.hooks || [])
1346
+ .some((h) => String(h?.command || '').includes('am-input-required-hook.sh')));
1347
+ });
1348
+ if (present && inputPresent && !missingClear) return true;
1349
  // No matcher: fire for every source. `startup` replaces the "--session-id
1350
  // not honoured" heuristic with a fact, `resume` is a proven no-op (same id),
1351
  // and `clear` is the case this exists for. Filtering happens server-side.
1352
  cfg.hooks = cfg.hooks || {};
1353
+ if (!present) cfg.hooks.SessionStart = [...entries, { hooks: [{ type: 'command', command: hookCmd, timeout: 5 }] }];
1354
+ // Notification is deliberately later than PermissionRequest: it fires only
1355
+ // after the actual permission/elicitation UI has remained unanswered for
1356
+ // about six seconds, and cannot be intercepted by another decision hook.
1357
+ if (!inputPresent) cfg.hooks.Notification = [...notifications, {
1358
+ matcher: 'permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|agent_completed',
1359
+ hooks: [{ type: 'command', command: inputRequiredCmd, timeout: 5 }],
1360
+ }];
1361
+ // These events prove the associated interaction has moved on. They only
1362
+ // remove a marker carrying this pane launch's nonce; hook output stays empty.
1363
+ for (const event of clearEvents) {
1364
+ const eventEntries = Array.isArray(cfg.hooks[event]) ? cfg.hooks[event] : [];
1365
+ const clearPresent = eventEntries.some((m) => (m?.hooks || [])
1366
+ .some((h) => String(h?.command || '').includes('am-input-required-hook.sh')));
1367
+ if (!clearPresent) cfg.hooks[event] = [...eventEntries, {
1368
+ hooks: [{ type: 'command', command: inputRequiredCmd, timeout: 5 }],
1369
+ }];
1370
+ }
1371
  try {
1372
  const tmp = `${file}.am-tmp`;
1373
  fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n');
1374
  fs.renameSync(tmp, file);
1375
+ console.warn(`[claude] lifecycle hooks installed in ${file}`);
1376
  return true;
1377
  } catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; }
1378
  }
 
1764
  // pane instead of respawning. Unpinned sessions fall through to the generic
1765
  // `resume --last` below (correct while the agent has its folder to itself).
1766
  if (cli.id === 'codex' && session.codexSessionId && session.codexRollout) {
1767
+ return `if [ -f '${session.codexRollout}' ]; then exec ${cli.resume(session.codexSessionId)}; else exec ${cli.run}; fi`;
1768
  }
1769
 
1770
  // opencode (seen on 1.17.13): at startup it creates a DIRECTORY named
 
1914
  screenChangedAt: Date.now(),
1915
  bells: 0,
1916
  };
1917
+ host.inputRequired = createInputRequiredTracker({ id: session.id, runId, cli: session.cli });
1918
  host.historyCheckpoint = createTerminalHistoryCheckpoint({
1919
  directory: HISTORY_DIR,
1920
  id: host.id,
 
1939
  host.lastOutputAt = Date.now();
1940
  host.outputSeq++;
1941
  host.terminalModes.feed(chunk);
1942
+ host.inputRequired.observeOutput(chunk);
1943
  if (host.traceHistoryTimer) {
1944
  clearTimeout(host.traceHistoryTimer);
1945
  host.traceHistoryTimer = null;
 
1987
  consumeBreadcrumb(session, host, workdir, true)
1988
  .catch((e) => console.warn(`[${session.cli}] ${session.id}: final breadcrumb read failed (${e && e.message})`));
1989
  hosts.delete(session.id);
1990
+ host.inputRequired.close();
1991
  stopping.delete(session.id);
1992
  if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
1993
  if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
 
2048
  onExit: (cb) => { sub.onExit = () => { try { cb(); } catch {} }; },
2049
  onGrid: (cb) => { sub.onGrid = (c, r, controller, viewers, reset) => { try { cb(c, r, controller, viewers, reset); } catch {} }; },
2050
  // Input and terminal-query responses are accepted from one emulator only.
2051
+ write: (d, { terminalReply = false } = {}) => {
2052
  if (host.controller !== sub) return;
2053
+ if (!terminalReply) host.inputRequired.observeInput();
2054
  try { host.pty.write(d); } catch {}
2055
  },
2056
  // Every viewer remembers what it can display, but only the current
 
2095
  export async function sendInput(id, text, { confirmEcho = false } = {}) {
2096
  const host = hosts.get(id);
2097
  if (!host || stopping.has(id)) throw new Error('session is not running');
2098
+ if (host.inputRequired.get()) {
2099
+ const error = new Error('session needs input in its terminal — a normal prompt was not sent into the open dialog');
2100
+ error.statusCode = 409;
2101
+ throw error;
2102
+ }
2103
+ host.inputRequired.observeInput();
2104
  // Multi-line prompts go in as a bracketed paste so the CLI's composer treats
2105
  // the inner newlines as soft line breaks instead of submitting early.
2106
  const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
 
2150
  export function pasteInput(id, text) {
2151
  const host = hosts.get(id);
2152
  if (!host || stopping.has(id)) throw new Error('session is not running');
2153
+ if (host.inputRequired.get()) {
2154
+ const error = new Error('session needs input in its terminal — text was not pasted into the open dialog');
2155
+ error.statusCode = 409;
2156
+ throw error;
2157
+ }
2158
  const value = String(text || '');
2159
  if (!value) return;
2160
+ host.inputRequired.observeInput();
2161
  const payload = value.includes('\n') ? `\x1b[200~${value}\x1b[201~` : value;
2162
  host.pty.write(payload);
2163
  }
server/test/attachments.test.mjs CHANGED
@@ -156,17 +156,18 @@ try {
156
  assert.deepEqual(formatAttachmentPrelude('hermes', [stored]), [`/image ${JSON.stringify(stored.path)}`]);
157
  assert.deepEqual(formatAttachmentPrelude('hermes', [docx, stored]), [`/image ${JSON.stringify(stored.path)}`]);
158
  assert.deepEqual(formatAttachmentPrelude('codex', [stored]), []);
159
- assert.equal(
160
- cliById('codex').withPrompt("'compare both'", ["'/tmp/first image.png'", "'/tmp/second.png'"]),
161
- "codex -i '/tmp/first image.png' -i '/tmp/second.png' 'compare both'",
162
- );
163
- assert.equal(
164
- commandFor({
165
- id: 'codex-first-image', cli: 'codex', everStarted: false,
166
- pendingPrompt: 'compare both', pendingImagePaths: ['/tmp/first image.png'],
167
- }),
168
- "exec codex -i '/tmp/first image.png' 'compare both'",
169
  );
 
 
 
 
 
 
 
 
 
170
 
171
  const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
172
  const oldPart = path.join(path.dirname(stored.path), '.crashed.part');
 
156
  assert.deepEqual(formatAttachmentPrelude('hermes', [stored]), [`/image ${JSON.stringify(stored.path)}`]);
157
  assert.deepEqual(formatAttachmentPrelude('hermes', [docx, stored]), [`/image ${JSON.stringify(stored.path)}`]);
158
  assert.deepEqual(formatAttachmentPrelude('codex', [stored]), []);
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({
166
+ id: 'codex-first-image', cli: 'codex', everStarted: false,
167
+ pendingPrompt: 'compare both', pendingImagePaths: ['/tmp/first image.png'],
168
+ });
169
+ assert.ok(codexFirst.startsWith('exec codex '));
170
+ assert.ok(codexFirst.endsWith("-i '/tmp/first image.png' 'compare both'"));
171
 
172
  const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
173
  const oldPart = path.join(path.dirname(stored.path), '.crashed.part');
server/test/input-required.test.mjs ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'am-input-required-'));
9
+ process.env.AM_INPUT_REQUIRED_DIR = root;
10
+ const { createInputRequiredTracker } = await import('../src/input-required.js');
11
+
12
+ const RUN = '11111111-2222-4333-8444-555555555555';
13
+ const marker = (id, cli, value = {}) => fs.writeFileSync(path.join(root, `${id}.json`), JSON.stringify({
14
+ amId: id,
15
+ runId: RUN,
16
+ cli,
17
+ kind: 'permission',
18
+ source: `${cli}-notification`,
19
+ at: Date.now(),
20
+ ...value,
21
+ }));
22
+
23
+ try {
24
+ const quiet = createInputRequiredTracker({ id: 'quiet', runId: RUN, cli: 'claude' });
25
+ assert.equal(quiet.get(), null, 'a quiet/static terminal is not evidence');
26
+
27
+ marker('wrong-run', 'claude', { runId: 'old-launch' });
28
+ const wrong = createInputRequiredTracker({ id: 'wrong-run', runId: RUN, cli: 'claude' });
29
+ assert.equal(wrong.get(), null, 'a stale launch marker is ignored');
30
+
31
+ marker('wrong-source', 'claude', { source: 'opencode-event' });
32
+ const wrongSource = createInputRequiredTracker({ id: 'wrong-source', runId: RUN, cli: 'claude' });
33
+ assert.equal(wrongSource.get(), null, 'a marker source must belong to the matching CLI adapter');
34
+
35
+ marker('claude-pane', 'claude');
36
+ const claude = createInputRequiredTracker({ id: 'claude-pane', runId: RUN, cli: 'claude' });
37
+ assert.equal(claude.get()?.kind, 'permission');
38
+ claude.observeInput();
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 });
54
+ codex.observeOutput('\x1b]9;ordinary turn complete\x07');
55
+ assert.equal(codex.get(), null, 'ordinary notifications are ignored');
56
+ codex.observeOutput('\x1b]9;Plan mode');
57
+ codex.observeOutput(' prompt: choose a path\x07');
58
+ assert.equal(codex.get()?.kind, 'question', 'a split native question signal is recognized');
59
+ codex.observeInput();
60
+ assert.equal(codex.get(), null);
61
+ codex.observeOutput('\x1b]9;Approval requested: run tests\x07');
62
+ assert.equal(codex.get()?.kind, 'permission');
63
+ now += 30 * 60_000 + 1;
64
+ assert.equal(codex.get(), null, 'an unpaired signal expires rather than sticking forever');
65
+
66
+ const geminiOsc = createInputRequiredTracker({ id: 'gemini-pane', runId: RUN, cli: 'gemini' });
67
+ geminiOsc.observeOutput('\x1b]9;Gemini CLI needs your attention | Answer requested by agent | choose one\x07');
68
+ assert.equal(geminiOsc.get()?.kind, 'question');
69
+ geminiOsc.observeInput();
70
+ geminiOsc.observeOutput('\x1b]777;notify;Gemini CLI needs your attention;Filesystem permission required\x07');
71
+ assert.equal(geminiOsc.get()?.kind, 'confirmation');
72
+ geminiOsc.observeOutput('\x1b]9;Gemini CLI session complete | Run finished\x07');
73
+ assert.equal(geminiOsc.get(), null);
74
+
75
+ const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts');
76
+ const hook = path.join(scripts, 'am-input-required-hook.sh');
77
+ const baseEnv = {
78
+ ...process.env,
79
+ AM_ID: 'hook-pane',
80
+ AM_RUN_ID: RUN,
81
+ AM_INPUT_REQUIRED_DIR: root,
82
+ AM_PANE_PID: String(process.pid),
83
+ };
84
+ let child = spawnSync('sh', [hook], {
85
+ input: JSON.stringify({ hook_event_name: 'Notification', notification_type: 'permission_prompt' }),
86
+ env: {
87
+ ...baseEnv,
88
+ AM_CLI: 'claude',
89
+ CLAUDE_CODE_ENTRYPOINT: 'cli',
90
+ CLAUDE_PID: String(process.pid),
91
+ },
92
+ });
93
+ assert.equal(child.status, 0, child.stderr?.toString());
94
+ assert.equal(JSON.parse(fs.readFileSync(path.join(root, 'hook-pane.json'))).kind, 'permission');
95
+
96
+ fs.unlinkSync(path.join(root, 'hook-pane.json'));
97
+ child = spawnSync('bash', ['-c', 'sh "$1"; :', 'am-gemini-hook-test', hook], {
98
+ input: JSON.stringify({ hook_event_name: 'Notification', notification_type: 'ToolPermission', details: { type: 'ask_user' } }),
99
+ env: { ...baseEnv, AM_CLI: 'gemini' },
100
+ });
101
+ assert.equal(child.status, 0, child.stderr?.toString());
102
+ const gemini = JSON.parse(fs.readFileSync(path.join(root, 'hook-pane.json')));
103
+ assert.equal(gemini.kind, 'question');
104
+ assert.equal(gemini.source, 'gemini-notification');
105
+
106
+ child = spawnSync('sh', [hook], {
107
+ input: JSON.stringify({ hook_event_name: 'AfterAgent' }),
108
+ env: { ...baseEnv, AM_CLI: 'gemini' },
109
+ });
110
+ assert.equal(child.status, 0, child.stderr?.toString());
111
+ assert.equal(fs.existsSync(path.join(root, 'hook-pane.json')), false, 'native close event removes the marker');
112
+
113
+ child = spawnSync('bash', ['-c', 'sh "$1"; :', 'am-nested-gemini-hook-test', hook], {
114
+ input: JSON.stringify({ hook_event_name: 'Notification', notification_type: 'ToolPermission' }),
115
+ env: { ...baseEnv, AM_CLI: 'gemini', AM_PANE_PID: '1' },
116
+ });
117
+ assert.equal(child.status, 0, child.stderr?.toString());
118
+ assert.equal(fs.existsSync(path.join(root, 'hook-pane.json')), false, 'a Gemini hook outside the pane process tree is ignored');
119
+
120
+ const geminiDefaults = JSON.parse(fs.readFileSync(path.resolve(scripts, '..', 'gemini-system-defaults.json')));
121
+ assert.equal(geminiDefaults.hooks.Notification[0].matcher, 'ToolPermission');
122
+ assert.equal(geminiDefaults.general.notificationMethod, 'osc9');
123
+
124
+ console.log('input-required: all assertions passed');
125
+ } finally {
126
+ fs.rmSync(root, { recursive: true, force: true });
127
+ }
server/test/opencode-resume.test.mjs CHANGED
@@ -14,6 +14,7 @@ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-resume-'));
14
  const XDG = path.join(TMP, 'xdg');
15
  const DATA = path.join(TMP, 'data');
16
  const REPIN = path.join(TMP, 'repin');
 
17
  fs.mkdirSync(path.join(XDG, 'opencode'), { recursive: true });
18
  fs.mkdirSync(DATA, { recursive: true });
19
 
@@ -21,6 +22,7 @@ process.env.XDG_DATA_HOME = XDG;
21
  process.env.XDG_CONFIG_HOME = path.join(TMP, 'config');
22
  process.env.DATA_DIR = DATA;
23
  process.env.AM_REPIN_DIR = REPIN;
 
24
  process.env.AM_ID = 'pane-1';
25
  process.env.AM_RUN_ID = '11111111-2222-4333-8444-555555555555';
26
  process.env.AM_CLI = 'opencode';
@@ -89,6 +91,37 @@ check('subagent create ignored', fs.existsSync(path.join(REPIN, 'pane-1.opencode
89
  await hooks['chat.message']({ sessionID: LIVE });
90
  crumb = JSON.parse(fs.readFileSync(path.join(REPIN, 'pane-1.opencode.json'), 'utf8'));
91
  check('message hook follows selected existing session', crumb.payload.session_id, LIVE);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  const shellOutput = { env: { KEEP: 'yes' } };
93
  await hooks['shell.env']({}, shellOutput);
94
  check('shell keeps unrelated environment', shellOutput.env.KEEP, 'yes');
 
14
  const XDG = path.join(TMP, 'xdg');
15
  const DATA = path.join(TMP, 'data');
16
  const REPIN = path.join(TMP, 'repin');
17
+ const INPUT_REQUIRED = path.join(TMP, 'input-required');
18
  fs.mkdirSync(path.join(XDG, 'opencode'), { recursive: true });
19
  fs.mkdirSync(DATA, { recursive: true });
20
 
 
22
  process.env.XDG_CONFIG_HOME = path.join(TMP, 'config');
23
  process.env.DATA_DIR = DATA;
24
  process.env.AM_REPIN_DIR = REPIN;
25
+ process.env.AM_INPUT_REQUIRED_DIR = INPUT_REQUIRED;
26
  process.env.AM_ID = 'pane-1';
27
  process.env.AM_RUN_ID = '11111111-2222-4333-8444-555555555555';
28
  process.env.AM_CLI = 'opencode';
 
91
  await hooks['chat.message']({ sessionID: LIVE });
92
  crumb = JSON.parse(fs.readFileSync(path.join(REPIN, 'pane-1.opencode.json'), 'utf8'));
93
  check('message hook follows selected existing session', crumb.payload.session_id, LIVE);
94
+
95
+ console.log('\nthe plugin tracks exact pending permissions and questions');
96
+ const attentionFile = path.join(INPUT_REQUIRED, 'pane-1.json');
97
+ await hooks.event({ event: { type: 'question.asked', properties: {
98
+ id: 'que_1', sessionID: LIVE, questions: [], tool: { messageID: 'msg_1', callID: 'call_1' },
99
+ } } });
100
+ let attention = JSON.parse(fs.readFileSync(attentionFile, 'utf8'));
101
+ check('question asked writes a question marker', attention.kind, 'question');
102
+ check('attention marker is tied to this launch', attention.runId, process.env.AM_RUN_ID);
103
+ await hooks.event({ event: { type: 'permission.asked', properties: {
104
+ id: 'per_1', sessionID: LIVE, permission: 'bash', patterns: ['*'],
105
+ } } });
106
+ await hooks.event({ event: { type: 'question.replied', properties: {
107
+ sessionID: LIVE, requestID: 'que_1', answers: [],
108
+ } } });
109
+ attention = JSON.parse(fs.readFileSync(attentionFile, 'utf8'));
110
+ check('resolving one queued item leaves the next one', attention.requestId, 'per_1');
111
+ await hooks.event({ event: { type: 'permission.replied', properties: {
112
+ sessionID: LIVE, requestID: 'per_1', reply: 'once',
113
+ } } });
114
+ check('last paired reply removes the marker', fs.existsSync(attentionFile), false);
115
+
116
+ await hooks.event({ event: { type: 'question.asked', properties: {
117
+ id: 'que_2', sessionID: LIVE, questions: [], tool: { messageID: 'msg_2', callID: 'call_2' },
118
+ } } });
119
+ await hooks.event({ event: { type: 'message.part.updated', properties: { part: {
120
+ id: 'part_2', type: 'tool', tool: 'question', messageID: 'msg_2', callID: 'call_2',
121
+ state: { status: 'completed' },
122
+ } } } });
123
+ check('completed Question tool clears a missing reply event', fs.existsSync(attentionFile), false);
124
+
125
  const shellOutput = { env: { KEEP: 'yes' } };
126
  await hooks['shell.env']({}, shellOutput);
127
  check('shell keeps unrelated environment', shellOutput.env.KEEP, 'yes');
server/test/repin.test.mjs CHANGED
@@ -312,9 +312,14 @@ check('installs into existing file', runner.installClaudeRepinHook('/app/scripts
312
  let s = JSON.parse(fs.readFileSync(settings, 'utf8'));
313
  check('other keys kept', s.model, 'opus');
314
  check('hook entry present', s.hooks.SessionStart.length, 1);
 
 
 
 
315
  check('second run is a no-op', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
316
  s = JSON.parse(fs.readFileSync(settings, 'utf8'));
317
  check('no duplicate entry', s.hooks.SessionStart.length, 1);
 
318
 
319
  console.log('\ninstaller refuses to clobber a corrupt settings file');
320
  fs.writeFileSync(settings, '{ not json');
 
312
  let s = JSON.parse(fs.readFileSync(settings, 'utf8'));
313
  check('other keys kept', s.model, 'opus');
314
  check('hook entry present', s.hooks.SessionStart.length, 1);
315
+ check('attention notification hook present', s.hooks.Notification.length, 1);
316
+ check('attention hook uses post-dialog events', s.hooks.Notification[0].matcher,
317
+ 'permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|agent_completed');
318
+ check('attention marker gets a native clear path', s.hooks.PostToolBatch.length, 1);
319
  check('second run is a no-op', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
320
  s = JSON.parse(fs.readFileSync(settings, 'utf8'));
321
  check('no duplicate entry', s.hooks.SessionStart.length, 1);
322
+ check('no duplicate attention entry', s.hooks.Notification.length, 1);
323
 
324
  console.log('\ninstaller refuses to clobber a corrupt settings file');
325
  fs.writeFileSync(settings, '{ not json');
web/src/components/Overview.tsx CHANGED
@@ -15,6 +15,7 @@ import type { PendingAttachment } from '../lib/attachments';
15
  import Attachments from './Attachments';
16
  import Logo from './Logo';
17
  import Composer from './conversation/Composer';
 
18
  import ExchangeView, { PendingExchange } from './conversation/Exchange';
19
  import { useDraft } from './conversation/useDraft';
20
  import { writePaneMode } from '../lib/paneMode';
@@ -366,23 +367,30 @@ export function Card({ s, color, group, pending, isMobile, onOpen, onClose }: {
366
  )}
367
  </div>
368
 
369
- <Composer
370
- draft={draft}
371
- sending={sending}
372
- isMobile={isMobile}
373
- inputRef={inputRef}
374
- canSend={!!draft.trim() || images.length > 0}
375
- above={<Attachments
376
- attachments={images}
377
- disabled={sending || !allowAttachments}
378
- disabledReason={!allowAttachments ? 'Files are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
379
- onFiles={addImages}
380
- onRemove={removeImage}
381
- />}
382
- onChange={setDraft}
383
- onSend={send}
384
- onPasteFiles={allowAttachments ? addImages : undefined}
385
- />
 
 
 
 
 
 
 
386
  {(imageError || failed) && <div className="ov-note" role="alert">{imageError || failed}</div>}
387
  </div>
388
  );
@@ -419,7 +427,9 @@ function Tile({ s, color, group, dim, pending, onOpen }: { s: MetaSession; color
419
  {d?.lastPromptText
420
  ? <div className="ovt-prompt" title={d.lastPromptText}>{d.lastPromptText}</div>
421
  : <div className="ovt-prompt none">no prompt yet</div>}
422
- {running
 
 
423
  ? <div className="ovt-state running mono">running</div>
424
  : s.state === 'stopped'
425
  ? <div className="ovt-state stopped mono">stopped</div>
 
15
  import Attachments from './Attachments';
16
  import Logo from './Logo';
17
  import Composer from './conversation/Composer';
18
+ import InputRequiredNotice from './conversation/InputRequiredNotice';
19
  import ExchangeView, { PendingExchange } from './conversation/Exchange';
20
  import { useDraft } from './conversation/useDraft';
21
  import { writePaneMode } from '../lib/paneMode';
 
367
  )}
368
  </div>
369
 
370
+ {s.inputRequired ? (
371
+ <InputRequiredNotice
372
+ input={s.inputRequired}
373
+ onOpenTerminal={() => { writePaneMode('terminal'); onOpen(s.id); }}
374
+ />
375
+ ) : (
376
+ <Composer
377
+ draft={draft}
378
+ sending={sending}
379
+ isMobile={isMobile}
380
+ inputRef={inputRef}
381
+ canSend={!!draft.trim() || images.length > 0}
382
+ above={<Attachments
383
+ attachments={images}
384
+ disabled={sending || !allowAttachments}
385
+ disabledReason={!allowAttachments ? 'Files are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
386
+ onFiles={addImages}
387
+ onRemove={removeImage}
388
+ />}
389
+ onChange={setDraft}
390
+ onSend={send}
391
+ onPasteFiles={allowAttachments ? addImages : undefined}
392
+ />
393
+ )}
394
  {(imageError || failed) && <div className="ov-note" role="alert">{imageError || failed}</div>}
395
  </div>
396
  );
 
427
  {d?.lastPromptText
428
  ? <div className="ovt-prompt" title={d.lastPromptText}>{d.lastPromptText}</div>
429
  : <div className="ovt-prompt none">no prompt yet</div>}
430
+ {s.inputRequired
431
+ ? <div className="ovt-state input mono">! needs input</div>
432
+ : running
433
  ? <div className="ovt-state running mono">running</div>
434
  : s.state === 'stopped'
435
  ? <div className="ovt-state stopped mono">stopped</div>
web/src/components/Sidebar.tsx CHANGED
@@ -342,11 +342,11 @@ export default function Sidebar({
342
  onDragStart={dnd.onDragStart} onDragEnd={dnd.onDragEnd} onDragOver={dnd.onDragOver} onDrop={dnd.onDrop}
343
  onClick={() => onOpenSession(s.id, groupId)}
344
  onDoubleClick={(e) => { e.stopPropagation(); startEdit(ref, s.name); }}
345
- title={s.path ? `${s.name} · ${s.path}` : s.name}
346
  >
347
  {/* The same three lights, but for a remote agent they mean connection,
348
  not process: working / listening / not connected. */}
349
- <span className={`status ${s.state}`} title={(isRemote(s.cli) ? REMOTE_STATE_LABEL : STATE_LABEL)[s.state]} />
350
  <Logo cli={s.cli} size={12} tint={colorOf[s.cli]} />
351
  {editing ? (
352
  <input
 
342
  onDragStart={dnd.onDragStart} onDragEnd={dnd.onDragEnd} onDragOver={dnd.onDragOver} onDrop={dnd.onDrop}
343
  onClick={() => onOpenSession(s.id, groupId)}
344
  onDoubleClick={(e) => { e.stopPropagation(); startEdit(ref, s.name); }}
345
+ title={s.inputRequired ? `${s.name} · needs input in terminal` : (s.path ? `${s.name} · ${s.path}` : s.name)}
346
  >
347
  {/* The same three lights, but for a remote agent they mean connection,
348
  not process: working / listening / not connected. */}
349
+ <span className={`status ${s.state}`} title={s.inputRequired ? 'needs input in terminal' : (isRemote(s.cli) ? REMOTE_STATE_LABEL : STATE_LABEL)[s.state]} />
350
  <Logo cli={s.cli} size={12} tint={colorOf[s.cli]} />
351
  {editing ? (
352
  <input
web/src/components/conversation/ConversationView.tsx CHANGED
@@ -29,6 +29,8 @@ import { fmtTok, splitExchanges } from './exchanges';
29
  import ExchangeView, { PendingExchange } from './Exchange';
30
  import Attachments from '../Attachments';
31
  import Composer from './Composer';
 
 
32
 
33
  const NEAR_TOP_PX = 300; // start fetching older turns before the reader arrives
34
 
@@ -554,7 +556,10 @@ export default function ConversationView({
554
  </div>
555
  </div>
556
 
557
- {!readOnly && (
 
 
 
558
  <Composer
559
  className="cxv-live"
560
  containerClassName="cxv-composer"
 
29
  import ExchangeView, { PendingExchange } from './Exchange';
30
  import Attachments from '../Attachments';
31
  import Composer from './Composer';
32
+ import InputRequiredNotice from './InputRequiredNotice';
33
+ import { writePaneMode } from '../../lib/paneMode';
34
 
35
  const NEAR_TOP_PX = 300; // start fetching older turns before the reader arrives
36
 
 
556
  </div>
557
  </div>
558
 
559
+ {!readOnly && session.inputRequired && (
560
+ <InputRequiredNotice input={session.inputRequired} onOpenTerminal={() => writePaneMode('terminal')} />
561
+ )}
562
+ {!readOnly && !session.inputRequired && (
563
  <Composer
564
  className="cxv-live"
565
  containerClassName="cxv-composer"
web/src/components/conversation/InputRequiredNotice.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { InputRequired } from '../../types';
2
+
3
+ const detail = (kind: InputRequired['kind']) => {
4
+ if (kind === 'permission') return 'A permission prompt is waiting in the terminal.';
5
+ if (kind === 'question') return 'A question or choice menu is waiting in the terminal.';
6
+ return 'A confirmation dialog is waiting in the terminal.';
7
+ };
8
+
9
+ export default function InputRequiredNotice({ input, onOpenTerminal }: {
10
+ input: InputRequired;
11
+ onOpenTerminal: () => void;
12
+ }) {
13
+ return (
14
+ <div className="input-required" role="status">
15
+ <span className="input-required-mark" aria-hidden="true">!</span>
16
+ <span className="input-required-copy">
17
+ <strong>Needs input</strong>
18
+ <span>{detail(input.kind)}</span>
19
+ </span>
20
+ <button type="button" onClick={onOpenTerminal}>open terminal</button>
21
+ </div>
22
+ );
23
+ }
web/src/conversation.css CHANGED
@@ -385,6 +385,22 @@ mark.cx-hit.on { background: var(--accent); color: var(--panel); }
385
  hardcoded 13px line and mis-centre the controls at every zoom but 100%. */
386
  .ov-composer.cxv-composer { --ov-first-line: calc(1.45em + 4px); }
387
  .cxv-note { flex: none; padding: 0 12px 6px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  @media (max-width: 720px) and (pointer: coarse) {
389
  /* Restating styles.css's iOS guard: 16px is what stops zoom-on-focus, and the
390
  rule above is later in the cascade, so it would otherwise win. A phone
 
385
  hardcoded 13px line and mis-centre the controls at every zoom but 100%. */
386
  .ov-composer.cxv-composer { --ov-first-line: calc(1.45em + 4px); }
387
  .cxv-note { flex: none; padding: 0 12px 6px; }
388
+ .input-required {
389
+ flex: none; display: flex; align-items: center; gap: 9px; min-width: 0;
390
+ padding: 8px 12px; border-top: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
391
+ background: color-mix(in srgb, var(--accent) 8%, var(--panel)); font-size: 0.88em;
392
+ }
393
+ .input-required-mark {
394
+ display: grid; place-items: center; flex: none; width: 18px; height: 18px;
395
+ border-radius: 50%; background: var(--accent); color: var(--panel); font-weight: 800;
396
+ }
397
+ .input-required-copy { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
398
+ .input-required-copy span { color: var(--muted); }
399
+ .input-required button {
400
+ flex: none; margin-left: auto; border: 1px solid var(--border); border-radius: 5px;
401
+ padding: 4px 8px; background: var(--panel-2); color: var(--text); font: inherit; cursor: pointer;
402
+ }
403
+ .input-required button:hover { border-color: var(--accent); }
404
  @media (max-width: 720px) and (pointer: coarse) {
405
  /* Restating styles.css's iOS guard: 16px is what stops zoom-on-focus, and the
406
  rule above is later in the cascade, so it would otherwise win. A phone
web/src/styles.css CHANGED
@@ -422,6 +422,7 @@ body {
422
  .ovt-prompt.none::before { color: var(--border-strong); }
423
  .ovt-state { font-size: 10.5px; color: var(--muted); }
424
  .ovt-state.running { color: var(--accent); }
 
425
  .ovt-state.running::before {
426
  content: '⠋'; display: inline-flex; align-items: center;
427
  width: var(--mark-w); height: var(--mark-h);
 
422
  .ovt-prompt.none::before { color: var(--border-strong); }
423
  .ovt-state { font-size: 10.5px; color: var(--muted); }
424
  .ovt-state.running { color: var(--accent); }
425
+ .ovt-state.input { color: var(--accent); font-weight: 650; }
426
  .ovt-state.running::before {
427
  content: '⠋'; display: inline-flex; align-items: center;
428
  width: var(--mark-w); height: var(--mark-h);
web/src/types.ts CHANGED
@@ -1,5 +1,12 @@
1
  export type SessionState = 'working' | 'waiting' | 'idle' | 'stopped';
2
 
 
 
 
 
 
 
 
3
  export interface Session {
4
  id: string;
5
  name: string;
@@ -11,6 +18,8 @@ export interface Session {
11
  everStarted: boolean;
12
  running: boolean;
13
  state: SessionState;
 
 
14
  // Only on `cli: 'trace'` panes: what the read-only trace view is pointed at.
15
  // A regular agent session needs no such record — it reads its own transcript.
16
  traceSource?: { kind: 'session' | 'bundle'; ref: string } | null;
 
1
  export type SessionState = 'working' | 'waiting' | 'idle' | 'stopped';
2
 
3
+ export interface InputRequired {
4
+ kind: 'permission' | 'question' | 'confirmation';
5
+ cli: string;
6
+ confidence: 'high';
7
+ detectedAt: string;
8
+ }
9
+
10
  export interface Session {
11
  id: string;
12
  name: string;
 
18
  everStarted: boolean;
19
  running: boolean;
20
  state: SessionState;
21
+ /** Native CLI event says an interactive TUI dialog is currently pending. */
22
+ inputRequired?: InputRequired | null;
23
  // Only on `cli: 'trace'` panes: what the read-only trace view is pointed at.
24
  // A regular agent session needs no such record — it reads its own transcript.
25
  traceSource?: { kind: 'session' | 'bundle'; ref: string } | null;