Spaces:
Running
Running
Agent Manager commited on
Commit ·
df112f9
1
Parent(s): 90c04f1
Surface native agent input requests
Browse files- Dockerfile +4 -0
- docs/tui-input-required.md +100 -0
- gemini-system-defaults.json +47 -0
- scripts/am-input-required-hook.sh +88 -0
- scripts/am-opencode-repin.js +106 -23
- server/package.json +1 -1
- server/src/config.js +12 -2
- server/src/index.js +5 -3
- server/src/input-required.js +147 -0
- server/src/runner.js +61 -7
- server/test/attachments.test.mjs +11 -10
- server/test/input-required.test.mjs +127 -0
- server/test/opencode-resume.test.mjs +33 -0
- server/test/repin.test.mjs +5 -0
- web/src/components/Overview.tsx +28 -18
- web/src/components/Sidebar.tsx +2 -2
- web/src/components/conversation/ConversationView.tsx +6 -1
- web/src/components/conversation/InputRequiredNotice.tsx +23 -0
- web/src/conversation.css +16 -0
- web/src/styles.css +1 -0
- web/src/types.ts +9 -0
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
|
| 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 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 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 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 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:
|
|
|
|
| 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 = []) => `
|
| 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 |
-
|
|
|
|
| 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 (!
|
| 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, {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]
|
| 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
|
| 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 |
-
|
| 160 |
-
|
| 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 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 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 |
-
{
|
|
|
|
|
|
|
| 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;
|