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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/hooks/kill-guard.sh +300 -0
- package/plugins/claude/hooks/read-only-bash-guard.sh +38 -140
- package/src/adapter.ts +2 -2
- package/src/adapters/codex.ts +17 -8
- package/src/claude-prompt-gates.ts +2 -2
- package/src/control-server.ts +12 -7
- package/src/rate-limit.ts +3 -1
- package/src/runner-core.ts +7 -7
- package/src/runner-helpers.ts +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
23
|
+
"agent-relay-sdk": "0.2.82"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
|
@@ -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
|
-
|
|
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 !
|
|
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?:
|
|
33
|
+
providerState?: ProviderState;
|
|
34
34
|
metadata?: Record<string, unknown>;
|
|
35
35
|
}
|
|
36
36
|
|
package/src/adapters/codex.ts
CHANGED
|
@@ -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:
|
|
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>):
|
|
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:
|
|
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
|
-
|
|
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()):
|
|
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):
|
|
27
|
+
providerState?(match: PaneGateMatch, context: ClaudePromptGatePaneContext): ProviderState;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export interface ClaudePromptGatePaneState {
|
package/src/control-server.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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):
|
|
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);
|
package/src/runner-core.ts
CHANGED
|
@@ -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?:
|
|
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?:
|
|
274
|
-
private pendingRateLimitHold?:
|
|
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
|
|
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:
|
|
1862
|
-
const label =
|
|
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",
|
package/src/runner-helpers.ts
CHANGED
|
@@ -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> }>):
|
|
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)
|
|
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 {
|