agent-relay-runner 0.99.1 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.99.1",
3
+ "version": "0.101.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.80"
23
+ "agent-relay-sdk": "0.2.82"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.99.1",
4
+ "version": "0.101.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env bash
2
+ # Shared "fleet fratricide" kill-pattern guard (issue #753).
3
+ #
4
+ # Every managed agent's runner runs as
5
+ # bun run .../agent-relay-runner/src/index.ts <provider> ...
6
+ # so a broad process kill such as `pkill -f "src/index.ts"` (or `pkill bun`,
7
+ # `killall node`, ...) matches EVERY sibling agent host-wide and silently SIGTERMs
8
+ # the whole fleet. This is the structural guard so that can't happen again.
9
+ #
10
+ # It blocks broad process-kill commands (pkill / killall, and `kill` with a
11
+ # pattern that leaks a fleet token) whose target would match sibling runners,
12
+ # while still allowing the safe shapes: PID-scoped kills (kill 12345,
13
+ # kill "$SERVER_PID") and pkill -f on a token that is unique to the caller's own
14
+ # throwaway server (its exact test port, or "$PWD/public").
15
+ #
16
+ # DUAL MODE:
17
+ # - sourced: defines fratricide_deny_reason() / fratricide_raw_reason() plus the
18
+ # shell-parsing helpers (trim_shell_text / split_segments / shell_words). The
19
+ # Claude PreToolUse Bash guard sources this and is wired to it.
20
+ # - executed: `kill-guard.sh "<command text>"` prints the guidance + exits 0 when
21
+ # the command is a dangerous broad kill, and exits 1 (silent) when it is safe.
22
+ # This lets non-bash callers reuse the SAME matcher as a subprocess.
23
+ #
24
+ # TODO(#753 follow-up): wire the Codex runner/exec layer to invoke this guard.
25
+ # Codex managed agents have no Claude PreToolUse hook, so the same broad pkill can
26
+ # still fratricide the fleet from a Codex session until that wiring lands. The
27
+ # matcher is intentionally factored here so the Codex side can call it directly
28
+ # (e.g. `bash kill-guard.sh "$command"`) without duplicating the pattern list.
29
+
30
+ shopt -s extglob
31
+
32
+ # Guidance returned on a block — tells the caller how to clean up safely instead.
33
+ FRATRICIDE_PATTERN_GUIDANCE='Blocked a broad process-kill (#753 fleet-fratricide guard): this pattern would match sibling agents'\'' runners, because every managed agent runs "bun run .../agent-relay-runner/src/index.ts". Kill only a PID you own — capture it when you spawn the server (SERVER_PID=$!; kill "$SERVER_PID") — or pkill -f a token that is unique to your own process (your exact test port, or "$PWD/public"). Never pkill/killall on bun, node, src/index.ts, agent-relay, or agent-relay-runner.'
34
+
35
+ trim_shell_text() {
36
+ local value="$1"
37
+ value="${value##+([[:space:]])}"
38
+ value="${value%%+([[:space:]])}"
39
+ printf '%s' "$value"
40
+ }
41
+
42
+ split_segments() {
43
+ local text="$1"
44
+ local current="" i len ch next quote="" escaped=0
45
+ SEGMENTS=()
46
+ len=${#text}
47
+ for ((i = 0; i < len; i++)); do
48
+ ch="${text:i:1}"
49
+ if (( escaped )); then
50
+ current+="$ch"
51
+ escaped=0
52
+ continue
53
+ fi
54
+ if [ -n "$quote" ]; then
55
+ current+="$ch"
56
+ if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
57
+ escaped=1
58
+ continue
59
+ fi
60
+ if [ "$ch" = "$quote" ]; then
61
+ quote=""
62
+ fi
63
+ continue
64
+ fi
65
+
66
+ case "$ch" in
67
+ "'"|'"')
68
+ quote="$ch"
69
+ current+="$ch"
70
+ ;;
71
+ "\\")
72
+ escaped=1
73
+ current+="$ch"
74
+ ;;
75
+ $'\n'|";")
76
+ SEGMENTS+=("$current")
77
+ current=""
78
+ ;;
79
+ "&")
80
+ next="${text:i+1:1}"
81
+ if [ "$next" = "&" ]; then
82
+ ((i += 1))
83
+ fi
84
+ SEGMENTS+=("$current")
85
+ current=""
86
+ ;;
87
+ "|")
88
+ next="${text:i+1:1}"
89
+ if [ "$next" = "|" ]; then
90
+ ((i += 1))
91
+ fi
92
+ SEGMENTS+=("$current")
93
+ current=""
94
+ ;;
95
+ *)
96
+ current+="$ch"
97
+ ;;
98
+ esac
99
+ done
100
+
101
+ if [ -n "$quote" ] || (( escaped )); then
102
+ return 1
103
+ fi
104
+ SEGMENTS+=("$current")
105
+ return 0
106
+ }
107
+
108
+ shell_words() {
109
+ local text="$1"
110
+ local word="" i len ch quote="" escaped=0 in_word=0
111
+ WORDS=()
112
+ len=${#text}
113
+ for ((i = 0; i < len; i++)); do
114
+ ch="${text:i:1}"
115
+ if (( escaped )); then
116
+ word+="$ch"
117
+ in_word=1
118
+ escaped=0
119
+ continue
120
+ fi
121
+ if [ -n "$quote" ]; then
122
+ if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
123
+ escaped=1
124
+ continue
125
+ fi
126
+ if [ "$ch" = "$quote" ]; then
127
+ quote=""
128
+ else
129
+ word+="$ch"
130
+ fi
131
+ in_word=1
132
+ continue
133
+ fi
134
+
135
+ case "$ch" in
136
+ [[:space:]])
137
+ if (( in_word )); then
138
+ WORDS+=("$word")
139
+ word=""
140
+ in_word=0
141
+ fi
142
+ ;;
143
+ "'"|'"')
144
+ quote="$ch"
145
+ in_word=1
146
+ ;;
147
+ "\\")
148
+ escaped=1
149
+ in_word=1
150
+ ;;
151
+ *)
152
+ word+="$ch"
153
+ in_word=1
154
+ ;;
155
+ esac
156
+ done
157
+
158
+ if [ -n "$quote" ] || (( escaped )); then
159
+ return 1
160
+ fi
161
+ if (( in_word )); then
162
+ WORDS+=("$word")
163
+ fi
164
+ return 0
165
+ }
166
+
167
+ # True (0) when text mentions a process-kill verb (pkill / killall / kill).
168
+ _looks_like_kill() {
169
+ if [[ "${1,,}" =~ (^|[^a-z])(pkill|killall|kill)([^a-z]|$) ]]; then
170
+ return 0
171
+ fi
172
+ return 1
173
+ }
174
+
175
+ # True (0) when a kill pattern/token would match sibling runners across the fleet.
176
+ # Matches the runner path tokens, and the bare interpreter process names bun/node
177
+ # even when wrapped in pgrep/regex metacharacters such as [b]un, ^node$, bun.* .
178
+ _pattern_hits_fleet() {
179
+ local lower="${1,,}"
180
+ case "$lower" in
181
+ *index.ts*|*agent-relay*)
182
+ return 0
183
+ ;;
184
+ esac
185
+ local cleaned="$lower" mc
186
+ for mc in '[' ']' '(' ')' '{' '}' '^' '$' '*' '+' '?' '\' '|'; do
187
+ cleaned="${cleaned//"$mc"/}"
188
+ done
189
+ if [[ "$cleaned" =~ (^|[^a-z0-9])(bun|node)([^a-z0-9]|$) ]]; then
190
+ return 0
191
+ fi
192
+ return 1
193
+ }
194
+
195
+ # Inspect one shell segment. Prints guidance + returns 0 when it is a dangerous
196
+ # broad kill; returns 1 when the segment is safe or not a kill.
197
+ _fratricide_segment_reason() {
198
+ local raw="$1" segment base i=0 j arg
199
+ segment="$(trim_shell_text "$raw")"
200
+ if [ -z "$segment" ]; then
201
+ return 1
202
+ fi
203
+ if ! shell_words "$segment"; then
204
+ # Unparseable — only worry if it both looks like a kill and leaks a fleet token.
205
+ if _looks_like_kill "$segment" && _pattern_hits_fleet "$segment"; then
206
+ printf '%s' "$FRATRICIDE_PATTERN_GUIDANCE"
207
+ return 0
208
+ fi
209
+ return 1
210
+ fi
211
+ if (( ${#WORDS[@]} == 0 )); then
212
+ return 1
213
+ fi
214
+
215
+ # Skip any leading VAR=value assignments (e.g. SERVER_PID=$!; kill ...).
216
+ while (( i < ${#WORDS[@]} )) && [[ "${WORDS[i]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do
217
+ ((i += 1))
218
+ done
219
+ if (( i >= ${#WORDS[@]} )); then
220
+ return 1
221
+ fi
222
+
223
+ base="${WORDS[i]##*/}"
224
+ base="${base,,}"
225
+ case "$base" in
226
+ pkill|killall)
227
+ # Pattern-matching kills: block if ANY non-flag operand hits the fleet.
228
+ j=$((i + 1))
229
+ while (( j < ${#WORDS[@]} )); do
230
+ arg="${WORDS[j]}"
231
+ if [[ "$arg" == -* ]]; then
232
+ ((j += 1))
233
+ continue
234
+ fi
235
+ if _pattern_hits_fleet "$arg"; then
236
+ printf '%s' "$FRATRICIDE_PATTERN_GUIDANCE"
237
+ return 0
238
+ fi
239
+ ((j += 1))
240
+ done
241
+ return 1
242
+ ;;
243
+ kill)
244
+ # PID-scoped kills (kill 123, kill "$PID", kill -9 %1) are safe. Block only
245
+ # when an operand leaks a fleet token — e.g. kill $(pgrep -f src/index.ts).
246
+ j=$((i + 1))
247
+ while (( j < ${#WORDS[@]} )); do
248
+ arg="${WORDS[j]}"
249
+ if _pattern_hits_fleet "$arg"; then
250
+ printf '%s' "$FRATRICIDE_PATTERN_GUIDANCE"
251
+ return 0
252
+ fi
253
+ ((j += 1))
254
+ done
255
+ return 1
256
+ ;;
257
+ *)
258
+ return 1
259
+ ;;
260
+ esac
261
+ }
262
+
263
+ # Structured matcher: split a full command into segments and check each one.
264
+ # Prints guidance + returns 0 on the first dangerous broad kill; returns 1 if safe.
265
+ fratricide_deny_reason() {
266
+ local text="$1" segment reason
267
+ if ! split_segments "$text"; then
268
+ fratricide_raw_reason "$text"
269
+ return $?
270
+ fi
271
+ local -a segments=("${SEGMENTS[@]}")
272
+ for segment in "${segments[@]}"; do
273
+ if reason="$(_fratricide_segment_reason "$segment")"; then
274
+ printf '%s' "$reason"
275
+ return 0
276
+ fi
277
+ done
278
+ return 1
279
+ }
280
+
281
+ # Coarse raw-text fallback for when structured parsing is unavailable (no jq, or
282
+ # unparseable shell). Prints guidance + returns 0 only when the text both looks
283
+ # like a kill and leaks a fleet token.
284
+ fratricide_raw_reason() {
285
+ local text="$1"
286
+ if _looks_like_kill "$text" && _pattern_hits_fleet "$text"; then
287
+ printf '%s' "$FRATRICIDE_PATTERN_GUIDANCE"
288
+ return 0
289
+ fi
290
+ return 1
291
+ }
292
+
293
+ # Executed directly (not sourced): argv[1] is the command text to inspect.
294
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
295
+ if reason="$(fratricide_deny_reason "${1:-}")"; then
296
+ printf '%s\n' "$reason"
297
+ exit 0
298
+ fi
299
+ exit 1
300
+ fi
@@ -4,8 +4,12 @@
4
4
  # Real boundaries are Relay token scope, unavailable write tools, and future
5
5
  # OS-level enforcement.
6
6
  set -euo pipefail
7
- source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/hooks/relay-status.sh"
7
+ HOOK_DIR="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/hooks"
8
+ source "${HOOK_DIR}/relay-status.sh"
8
9
  relay_install_hook_guard read-only-bash-guard
10
+ # Shared fleet-fratricide kill-pattern matcher (#753): trim_shell_text,
11
+ # split_segments, shell_words and fratricide_deny_reason live here.
12
+ source "${HOOK_DIR}/kill-guard.sh"
9
13
 
10
14
  payload="$(cat)"
11
15
 
@@ -15,6 +19,38 @@ emit_deny() {
15
19
  "$(relay_json_escape "$reason")"
16
20
  }
17
21
 
22
+ shopt -s extglob
23
+
24
+ # Extract the Bash command once. jq is strongly preferred; both the fleet-kill
25
+ # guard (all modes) and the read-only allow-list (read-only mode) consume it.
26
+ jq_available=0
27
+ tool_name=""
28
+ command_text=""
29
+ if command -v jq >/dev/null 2>&1; then
30
+ jq_available=1
31
+ tool_name="$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null || true)"
32
+ command_text="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
33
+ fi
34
+
35
+ # --- Fleet-fratricide guard: ACTIVE IN ALL APPROVAL MODES (#753) ---
36
+ # A broad pkill/killall/kill can SIGTERM every sibling agent's runner host-wide,
37
+ # so this block runs before (and independent of) the read-only allow-list.
38
+ if (( jq_available )); then
39
+ if [ "${tool_name,,}" = "bash" ] && [ -n "$command_text" ]; then
40
+ if fratricide_reason="$(fratricide_deny_reason "$command_text")"; then
41
+ emit_deny "$fratricide_reason"
42
+ exit 0
43
+ fi
44
+ fi
45
+ else
46
+ # No jq: best-effort raw scan so the fleet guard still bites the obvious cases.
47
+ if fratricide_reason="$(fratricide_raw_reason "$payload")"; then
48
+ emit_deny "$fratricide_reason"
49
+ exit 0
50
+ fi
51
+ fi
52
+
53
+ # --- Read-only allow-list: only the read-only approval mode below this point ---
18
54
  case "${AGENT_RELAY_APPROVAL:-}" in
19
55
  open|guarded)
20
56
  exit 0
@@ -27,40 +63,27 @@ case "${AGENT_RELAY_APPROVAL:-}" in
27
63
  ;;
28
64
  esac
29
65
 
30
- if ! command -v jq >/dev/null 2>&1; then
66
+ if (( ! jq_available )); then
31
67
  emit_deny "Read-only Bash guard could not inspect the command because jq is unavailable."
32
68
  exit 0
33
69
  fi
34
70
 
35
- tool_name="$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null || true)"
36
71
  if [ "${tool_name,,}" != "bash" ]; then
37
72
  exit 0
38
73
  fi
39
74
 
40
- command_text="$(printf '%s' "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
41
75
  if [ -z "$command_text" ]; then
42
76
  emit_deny "Read-only Bash guard denied an empty Bash command."
43
77
  exit 0
44
78
  fi
45
79
 
46
- shopt -s extglob
47
-
48
80
  DENY_REASON=""
49
- SEGMENTS=()
50
- WORDS=()
51
81
 
52
82
  deny_segment() {
53
83
  DENY_REASON="$1"
54
84
  return 1
55
85
  }
56
86
 
57
- trim_shell_text() {
58
- local value="$1"
59
- value="${value##+([[:space:]])}"
60
- value="${value%%+([[:space:]])}"
61
- printf '%s' "$value"
62
- }
63
-
64
87
  hard_deny_reason() {
65
88
  local text="$1"
66
89
  case "$text" in
@@ -117,131 +140,6 @@ hard_deny_reason() {
117
140
  return 1
118
141
  }
119
142
 
120
- split_segments() {
121
- local text="$1"
122
- local current="" i len ch next quote="" escaped=0
123
- SEGMENTS=()
124
- len=${#text}
125
- for ((i = 0; i < len; i++)); do
126
- ch="${text:i:1}"
127
- if (( escaped )); then
128
- current+="$ch"
129
- escaped=0
130
- continue
131
- fi
132
- if [ -n "$quote" ]; then
133
- current+="$ch"
134
- if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
135
- escaped=1
136
- continue
137
- fi
138
- if [ "$ch" = "$quote" ]; then
139
- quote=""
140
- fi
141
- continue
142
- fi
143
-
144
- case "$ch" in
145
- "'"|'"')
146
- quote="$ch"
147
- current+="$ch"
148
- ;;
149
- "\\")
150
- escaped=1
151
- current+="$ch"
152
- ;;
153
- $'\n'|";")
154
- SEGMENTS+=("$current")
155
- current=""
156
- ;;
157
- "&")
158
- next="${text:i+1:1}"
159
- if [ "$next" = "&" ]; then
160
- ((i += 1))
161
- fi
162
- SEGMENTS+=("$current")
163
- current=""
164
- ;;
165
- "|")
166
- next="${text:i+1:1}"
167
- if [ "$next" = "|" ]; then
168
- ((i += 1))
169
- fi
170
- SEGMENTS+=("$current")
171
- current=""
172
- ;;
173
- *)
174
- current+="$ch"
175
- ;;
176
- esac
177
- done
178
-
179
- if [ -n "$quote" ] || (( escaped )); then
180
- return 1
181
- fi
182
- SEGMENTS+=("$current")
183
- return 0
184
- }
185
-
186
- shell_words() {
187
- local text="$1"
188
- local word="" i len ch quote="" escaped=0 in_word=0
189
- WORDS=()
190
- len=${#text}
191
- for ((i = 0; i < len; i++)); do
192
- ch="${text:i:1}"
193
- if (( escaped )); then
194
- word+="$ch"
195
- in_word=1
196
- escaped=0
197
- continue
198
- fi
199
- if [ -n "$quote" ]; then
200
- if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
201
- escaped=1
202
- continue
203
- fi
204
- if [ "$ch" = "$quote" ]; then
205
- quote=""
206
- else
207
- word+="$ch"
208
- fi
209
- in_word=1
210
- continue
211
- fi
212
-
213
- case "$ch" in
214
- [[:space:]])
215
- if (( in_word )); then
216
- WORDS+=("$word")
217
- word=""
218
- in_word=0
219
- fi
220
- ;;
221
- "'"|'"')
222
- quote="$ch"
223
- in_word=1
224
- ;;
225
- "\\")
226
- escaped=1
227
- in_word=1
228
- ;;
229
- *)
230
- word+="$ch"
231
- in_word=1
232
- ;;
233
- esac
234
- done
235
-
236
- if [ -n "$quote" ] || (( escaped )); then
237
- return 1
238
- fi
239
- if (( in_word )); then
240
- WORDS+=("$word")
241
- fi
242
- return 0
243
- }
244
-
245
143
  validate_git_branch() {
246
144
  local i="$1" arg
247
145
  while (( i < ${#WORDS[@]} )); do
package/src/adapter.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgentProfile, LivenessSignal, Message } from "agent-relay-sdk";
1
+ import type { AgentProfile, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
2
2
  import { isRecord } from "agent-relay-sdk";
3
3
  import type { LivenessInputs } from "./liveness";
4
4
  import type { SessionEvent } from "./session-insights";
@@ -30,7 +30,7 @@ export interface ProviderStatusEvent {
30
30
  // runner reconciles them against actual liveness and clears the busy marker if
31
31
  // every pid is dead — recovering from a tracked process killed out-of-band.
32
32
  pids?: number[];
33
- providerState?: Record<string, unknown>;
33
+ providerState?: ProviderState;
34
34
  metadata?: Record<string, unknown>;
35
35
  }
36
36
 
@@ -1,7 +1,7 @@
1
1
  import { accessSync, constants, existsSync, readFileSync, realpathSync, readdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, resolve } from "node:path";
4
- import type { ContextState, LivenessSignal, Message } from "agent-relay-sdk";
4
+ import type { ContextState, InteractivePrompt, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
5
5
  import { isRecord, stringValue } from "agent-relay-sdk";
6
6
  import { isPidAlive, killPid, processTreePids, processTreePidsFromTable, waitForPidsExit } from "agent-relay-sdk/process-utils";
7
7
  import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
@@ -29,7 +29,7 @@ type PendingCodexApproval = {
29
29
  requestId: string | number;
30
30
  method: string;
31
31
  params: Record<string, unknown>;
32
- view: Record<string, unknown>;
32
+ view: InteractivePrompt;
33
33
  };
34
34
 
35
35
  type PendingCodexCompact = {
@@ -420,6 +420,12 @@ export class CodexAdapter implements ProviderAdapter {
420
420
  if (event.type === "server-request" && process) {
421
421
  const approval = codexApprovalFromServerRequest(event.message);
422
422
  if (approval) {
423
+ // #435/#723 ordering invariant: mirror every reasoning/narrative step of the turn
424
+ // BEFORE the approval form surfaces — the same guarantee Claude makes by pre-flushing
425
+ // the transcript at its hook seam, here enforced from the Codex app-server mechanism.
426
+ // approval.view.reasoningSettled === true asserts this flush has run.
427
+ this.agentMessageCapture.closeSegment((body) => this.emitAgentNarration(body));
428
+ this.flushBufferedReasoning();
423
429
  this.pendingApprovals.set(approval.pending.id, approval.pending);
424
430
  this.statusCb({
425
431
  status: "busy",
@@ -728,7 +734,7 @@ export class CodexAdapter implements ProviderAdapter {
728
734
  pending.reject(error);
729
735
  }
730
736
 
731
- private providerStateFromThreadStatus(status: unknown, params?: Record<string, unknown>): Record<string, unknown> | undefined {
737
+ private providerStateFromThreadStatus(status: unknown, params?: Record<string, unknown>): ProviderState | undefined {
732
738
  const state = codexProviderStateFromThreadStatus(status, params);
733
739
  if (state?.state !== "blocked" || state.reason !== "waitingOnApproval" || state.pendingApproval) return state;
734
740
  const pending = [...this.pendingApprovals.values()].at(-1);
@@ -779,7 +785,7 @@ export function codexHeadlessElicitationResponse(): Record<string, unknown> {
779
785
  return { action: "accept", content: {}, _meta: null };
780
786
  }
781
787
 
782
- function codexApprovalFromServerRequest(message: { id: string | number; method: string; params?: unknown }): { pending: PendingCodexApproval; view: Record<string, unknown> } | null {
788
+ function codexApprovalFromServerRequest(message: { id: string | number; method: string; params?: unknown }): { pending: PendingCodexApproval; view: InteractivePrompt } | null {
783
789
  if (!isRecord(message.params)) return null;
784
790
  const method = message.method;
785
791
  if (!codexApprovalMethod(method)) return null;
@@ -882,7 +888,11 @@ function codexApprovalMethod(method: string): boolean {
882
888
  method === "item/permissions/requestApproval";
883
889
  }
884
890
 
885
- function codexApprovalView(id: string, method: string, params: Record<string, unknown>): Record<string, unknown> {
891
+ // Build the normalized InteractivePrompt for a Codex app-server approval request.
892
+ // reasoningSettled is stamped `true` because handleCodexEvent flushes buffered reasoning +
893
+ // any open narration segment BEFORE this view is emitted as pendingApproval (#435/#723). No
894
+ // `provider` tag — the server reads the typed contract, not provider identity.
895
+ function codexApprovalView(id: string, method: string, params: Record<string, unknown>): InteractivePrompt {
886
896
  const command = typeof params.command === "string" ? params.command
887
897
  : Array.isArray(params.command) ? params.command.filter((v): v is string => typeof v === "string").join(" ")
888
898
  : "";
@@ -902,7 +912,6 @@ function codexApprovalView(id: string, method: string, params: Record<string, un
902
912
  const isSessionCapable = method !== "item/permissions/requestApproval";
903
913
  return {
904
914
  id,
905
- provider: "codex",
906
915
  kind: method.includes("file") || method === "applyPatchApproval" ? "file-change" : method.includes("permission") ? "permission" : "command",
907
916
  title: method.includes("file") || method === "applyPatchApproval"
908
917
  ? "Approve file changes"
@@ -910,12 +919,12 @@ function codexApprovalView(id: string, method: string, params: Record<string, un
910
919
  ? "Approve extra permissions"
911
920
  : "Approve command",
912
921
  body: body || "Codex is requesting permission.",
913
- method,
914
922
  choices: [
915
923
  { id: "approve", label: method.includes("permission") ? "Allow this turn" : "Approve" },
916
924
  ...(isSessionCapable ? [{ id: "approve-session", label: "Approve session" }] : [{ id: "approve-session", label: "Allow session" }]),
917
925
  { id: "deny", label: "Deny" },
918
926
  ],
927
+ reasoningSettled: true,
919
928
  };
920
929
  }
921
930
 
@@ -962,7 +971,7 @@ export function codexDeliveryNotice(messages: Message[], threadId: string): stri
962
971
  return `[agent-relay] received ${messages.length} relay message${messages.length === 1 ? "" : "s"} ${ids} from ${senders}; injecting into Codex thread ${threadId}`;
963
972
  }
964
973
 
965
- export function codexProviderStateFromThreadStatus(status: unknown, params?: Record<string, unknown>, now = Date.now()): Record<string, unknown> | undefined {
974
+ export function codexProviderStateFromThreadStatus(status: unknown, params?: Record<string, unknown>, now = Date.now()): ProviderState | undefined {
966
975
  const type = statusType(status);
967
976
  const flags = activeFlags(status);
968
977
  const threadId = stringValue(params?.threadId) ?? (isRecord(params?.thread) ? stringValue(params.thread.id) : undefined);
@@ -1,5 +1,5 @@
1
1
  import { resolve } from "node:path";
2
- import { isRecord } from "agent-relay-sdk";
2
+ import { isRecord, type ProviderState } from "agent-relay-sdk";
3
3
  import type { ProviderStatusUpdate, RunnerSpawnConfig } from "./adapter";
4
4
  import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "./rate-limit";
5
5
 
@@ -24,7 +24,7 @@ interface ClaudePanePromptGate {
24
24
  keystroke: string;
25
25
  minDetections: number;
26
26
  match(text: string, nowMs?: number): PaneGateMatch | null;
27
- providerState?(match: PaneGateMatch, context: ClaudePromptGatePaneContext): Record<string, unknown>;
27
+ providerState?(match: PaneGateMatch, context: ClaudePromptGatePaneContext): ProviderState;
28
28
  }
29
29
 
30
30
  export interface ClaudePromptGatePaneState {
@@ -1,5 +1,5 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
- import type { Message, ReplyObligation } from "agent-relay-sdk";
2
+ import type { InteractivePrompt, Message, ReplyObligation } from "agent-relay-sdk";
3
3
  import { errMessage, isRecord } from "agent-relay-sdk";
4
4
  import type { ProviderPermissionDecisionInput, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
5
5
  import { obligationRequiresExplicitReply } from "./reply-obligation-cache";
@@ -302,7 +302,12 @@ async function handlePermissionRequest(
302
302
  return Response.json(claudePermissionHookResponse(decision, body));
303
303
  }
304
304
 
305
- function claudePermissionApprovalView(id: string, body: Record<string, unknown>): Record<string, unknown> {
305
+ // Build the normalized InteractivePrompt for a Claude PreToolUse permission/question hook.
306
+ // reasoningSettled is stamped `true` because handlePermissionRequest pre-flushes the
307
+ // transcript narrative (onSessionTurn isPreFlush) BEFORE this view is built and emitted, so
308
+ // the turn's reasoning is already mirrored ahead of the form (#435/#723). No `provider` tag —
309
+ // the server reads the typed contract, not provider identity.
310
+ function claudePermissionApprovalView(id: string, body: Record<string, unknown>): InteractivePrompt {
306
311
  const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
307
312
  const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
308
313
  // AskUserQuestion is not a yes/no gate — it asks the user to pick answers.
@@ -313,12 +318,12 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
313
318
  const count = toolInput.questions.length;
314
319
  return {
315
320
  id,
316
- provider: "claude",
317
321
  kind: "questions",
318
322
  title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
319
323
  body: "",
320
- questions: toolInput.questions,
324
+ questions: toolInput.questions as InteractivePrompt["questions"],
321
325
  choices: [],
326
+ reasoningSettled: true,
322
327
  };
323
328
  }
324
329
  // ExitPlanMode arrives through the generic PermissionRequest hook (it doesn't
@@ -332,7 +337,6 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
332
337
  : JSON.stringify(toolInput);
333
338
  return {
334
339
  id,
335
- provider: "claude",
336
340
  kind: "plan",
337
341
  title: "Claude is ready to code",
338
342
  body: plan,
@@ -340,6 +344,7 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
340
344
  { id: "approve", label: "Approve plan" },
341
345
  { id: "deny", label: "Keep planning" },
342
346
  ],
347
+ reasoningSettled: true,
343
348
  };
344
349
  }
345
350
  const command = typeof toolInput.command === "string" ? toolInput.command : "";
@@ -351,7 +356,6 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
351
356
  ].filter(Boolean).join("\n");
352
357
  return {
353
358
  id,
354
- provider: "claude",
355
359
  kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
356
360
  title: `Approve ${toolName}`,
357
361
  body: bodyText,
@@ -361,6 +365,7 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
361
365
  { id: "deny", label: "Deny" },
362
366
  { id: "abort", label: "Abort" },
363
367
  ],
368
+ reasoningSettled: true,
364
369
  };
365
370
  }
366
371
 
@@ -429,7 +434,7 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
429
434
  };
430
435
  }
431
436
 
432
- function claudeResolvedPermissionPrompt(view: Record<string, unknown>, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): ResolvedPermissionPrompt {
437
+ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): ResolvedPermissionPrompt {
433
438
  const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
434
439
  const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
435
440
  const kind = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
package/src/rate-limit.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  // Both build the hold here so the shape stays identical, and the relay's resume sweep
8
8
  // (services/rate-limit-resume.ts) lifts either once the window resets.
9
9
 
10
+ import type { ProviderState } from "agent-relay-sdk";
11
+
10
12
  export const RATE_LIMIT_BLOCK_REASON = "rate_limit";
11
13
 
12
14
  // Reject a parsed reset that's in the past or implausibly far out — a bad parse would
@@ -24,7 +26,7 @@ interface RateLimitHoldInput {
24
26
  }
25
27
 
26
28
  /** The blocked providerState a rate-limit hold carries. Shared by both detection arms. */
27
- export function buildRateLimitProviderState(input: RateLimitHoldInput): Record<string, unknown> {
29
+ export function buildRateLimitProviderState(input: RateLimitHoldInput): ProviderState {
28
30
  const now = Date.now();
29
31
  const resetAt = typeof input.resetAt === "number" && Number.isFinite(input.resetAt) ? input.resetAt : undefined;
30
32
  const kind = input.kind ?? rateLimitKindForError(input.errorType);
@@ -2,7 +2,7 @@ import { hostname } from "node:os";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
- import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
5
+ import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, ProviderState, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
6
6
  import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
7
7
  import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics } from "agent-relay-sdk/context-probe";
8
8
  import { isPidAlive } from "agent-relay-sdk/process-utils";
@@ -264,14 +264,14 @@ export class AgentRunner {
264
264
  // Tracks whether the provider is in a legitimate blocked/approval state, so the
265
265
  // busy reconciler doesn't mistake a permission prompt for a stuck-busy turn.
266
266
  private providerBlocked = false;
267
- private terminalFailure?: { reason: string; message: string; providerState?: Record<string, unknown> };
267
+ private terminalFailure?: { reason: string; message: string; providerState?: ProviderState };
268
268
  // #286: a usage/rate-limit hold. The turn truly ended (idle), but the agent is
269
269
  // held until the limit resets, so this can't ride an active-work claim like the
270
270
  // permission-blocked state does. publishStatus surfaces it as the agent's
271
271
  // providerState; it clears when a new turn starts (the relay's resume message
272
272
  // wakes one) so the blocked badge lifts on its own.
273
- private rateLimitHold?: Record<string, unknown>;
274
- private pendingRateLimitHold?: Record<string, unknown>;
273
+ private rateLimitHold?: ProviderState;
274
+ private pendingRateLimitHold?: ProviderState;
275
275
  // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
276
276
  // from the Claude transcript into chat as discreet session events.
277
277
  private reasoningTail?: ReasoningTailState;
@@ -1157,7 +1157,7 @@ export class AgentRunner {
1157
1157
  }
1158
1158
  let deferredRateLimitHold = false;
1159
1159
  if (typeof update !== "string" && update.providerState) {
1160
- const ps = update.providerState as { state?: unknown; reason?: unknown; source?: unknown };
1160
+ const ps = update.providerState;
1161
1161
  // A rate-limit hold persists across the idle the turn ends on; any other
1162
1162
  // providerState supersedes it (clears a stale hold).
1163
1163
  if (ps.state === "blocked" && ps.reason === "rate_limit") {
@@ -1858,8 +1858,8 @@ export class AgentRunner {
1858
1858
  // (NOT an inbound message) so it shows in the dashboard chat WITHOUT waking a turn
1859
1859
  // — waking a still-limited agent would just re-fail. The relay sends the waking
1860
1860
  // resume message later, once the window has reset.
1861
- private publishRateLimitNotice(providerState: Record<string, unknown>): void {
1862
- const label = typeof providerState.label === "string" && providerState.label ? providerState.label : "usage limit reached";
1861
+ private publishRateLimitNotice(providerState: ProviderState): void {
1862
+ const label = providerState.label || "usage limit reached";
1863
1863
  this.publishSessionEvent({
1864
1864
  from: this.agentId,
1865
1865
  to: "user",
@@ -1,5 +1,5 @@
1
1
  import { closeSync, openSync, readSync, statSync } from "node:fs";
2
- import type { AgentProfile, ContextState, Message, ProviderCapabilities, QuotaState } from "agent-relay-sdk";
2
+ import type { AgentProfile, ContextState, Message, ProviderCapabilities, ProviderState, QuotaState } from "agent-relay-sdk";
3
3
  import { errMessage } from "agent-relay-sdk";
4
4
  import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
5
5
  import type { ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus } from "./adapter";
@@ -377,10 +377,12 @@ function runtimeProviderQuotaCapabilities(options: RuntimeProviderOptions, quota
377
377
  };
378
378
  }
379
379
 
380
- export function providerStateFromActiveWork(activeWork: Array<{ kind: string; metadata?: Record<string, unknown> }>): Record<string, unknown> | null {
380
+ export function providerStateFromActiveWork(activeWork: Array<{ kind: string; metadata?: Record<string, unknown> }>): ProviderState | null {
381
381
  const providerTurn = activeWork.find((item) => item.kind === "provider-turn");
382
382
  const state = providerTurn?.metadata?.providerState;
383
- return state && typeof state === "object" && !Array.isArray(state) ? state as Record<string, unknown> : null;
383
+ return state && typeof state === "object" && !Array.isArray(state) && typeof (state as { state?: unknown }).state === "string"
384
+ ? state as ProviderState
385
+ : null;
384
386
  }
385
387
 
386
388
  export function isHttpAuthError(error: unknown): boolean {