prizmkit 1.1.82 → 1.1.84
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/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/lib/common.sh +83 -6
- package/bundled/dev-pipeline/lib/heartbeat.sh +35 -3
- package/bundled/dev-pipeline/run-bugfix.sh +10 -0
- package/bundled/dev-pipeline/run-feature.sh +10 -0
- package/bundled/dev-pipeline/run-refactor.sh +10 -0
- package/bundled/skills/_metadata.json +1 -1
- package/package.json +1 -1
- package/bundled/dev-pipeline-windows/lib/heartbeat.sh +0 -439
package/bundled/VERSION.json
CHANGED
|
@@ -403,6 +403,30 @@ prizm_start_ai_session() {
|
|
|
403
403
|
|
|
404
404
|
unset CLAUDECODE 2>/dev/null || true
|
|
405
405
|
|
|
406
|
+
# Create backup outside project tree. Claude Code subagents can unlink
|
|
407
|
+
# session.log inside the project, but cannot reach $HOME/.prizmkit/.
|
|
408
|
+
# tee writes to the backup file while >> redirects to the main log.
|
|
409
|
+
# If the main log gets severed (unlinked+recreated by subagent),
|
|
410
|
+
# prizm_recover_session_log copies the backup back when the session ends.
|
|
411
|
+
local backup_dir="$HOME/.prizmkit/session-backups"
|
|
412
|
+
mkdir -p "$backup_dir"
|
|
413
|
+
local session_dir
|
|
414
|
+
session_dir="$(dirname "$(dirname "$log_path")")"
|
|
415
|
+
local session_id
|
|
416
|
+
session_id="$(basename "$session_dir")"
|
|
417
|
+
local backup_log="$backup_dir/${session_id}.log"
|
|
418
|
+
PRIZM_BACKUP_LOG="$backup_log"
|
|
419
|
+
|
|
420
|
+
# Write preamble to both files
|
|
421
|
+
{
|
|
422
|
+
echo "=== PRIZMKIT SESSION START ==="
|
|
423
|
+
echo "Prompt: $prompt_path"
|
|
424
|
+
echo "Model: ${model:-default}"
|
|
425
|
+
echo "Started at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
426
|
+
echo "--- OUTPUT BELOW ---"
|
|
427
|
+
} > "$log_path"
|
|
428
|
+
cp "$log_path" "$backup_log"
|
|
429
|
+
|
|
406
430
|
local session_platform
|
|
407
431
|
session_platform="$(_prizm_session_platform)"
|
|
408
432
|
|
|
@@ -421,7 +445,7 @@ prizm_start_ai_session() {
|
|
|
421
445
|
if [[ -n "$model" ]]; then
|
|
422
446
|
claude_args+=(--model "$model")
|
|
423
447
|
fi
|
|
424
|
-
"$CLI_CMD" "${claude_args[@]}"
|
|
448
|
+
"$CLI_CMD" "${claude_args[@]}" 2>&1 | tee -a "$backup_log" >> "$log_path" &
|
|
425
449
|
;;
|
|
426
450
|
codex)
|
|
427
451
|
local codex_args=(--ask-for-approval never --sandbox danger-full-access)
|
|
@@ -440,7 +464,7 @@ prizm_start_ai_session() {
|
|
|
440
464
|
if [[ -n "${PRIZMKIT_EFFORT:-}" ]]; then
|
|
441
465
|
codex_args+=(--config "model_reasoning_effort=$PRIZMKIT_EFFORT")
|
|
442
466
|
fi
|
|
443
|
-
"$CLI_CMD" "${codex_args[@]}" - < "$prompt_path"
|
|
467
|
+
"$CLI_CMD" "${codex_args[@]}" - < "$prompt_path" 2>&1 | tee -a "$backup_log" >> "$log_path" &
|
|
444
468
|
;;
|
|
445
469
|
*)
|
|
446
470
|
local cb_args=(--print -y)
|
|
@@ -456,7 +480,7 @@ prizm_start_ai_session() {
|
|
|
456
480
|
if [[ -n "$model" ]]; then
|
|
457
481
|
cb_args+=(--model "$model")
|
|
458
482
|
fi
|
|
459
|
-
"$CLI_CMD" "${cb_args[@]}" < "$prompt_path"
|
|
483
|
+
"$CLI_CMD" "${cb_args[@]}" < "$prompt_path" 2>&1 | tee -a "$backup_log" >> "$log_path" &
|
|
460
484
|
;;
|
|
461
485
|
esac
|
|
462
486
|
PRIZM_AI_PID=$!
|
|
@@ -938,6 +962,26 @@ prizm_run_ai_session() {
|
|
|
938
962
|
|
|
939
963
|
unset CLAUDECODE 2>/dev/null || true
|
|
940
964
|
|
|
965
|
+
# Create backup outside project tree (same rationale as prizm_start_ai_session)
|
|
966
|
+
local backup_dir="$HOME/.prizmkit/session-backups"
|
|
967
|
+
mkdir -p "$backup_dir"
|
|
968
|
+
local session_dir
|
|
969
|
+
session_dir="$(dirname "$(dirname "$log_path")")"
|
|
970
|
+
local session_id
|
|
971
|
+
session_id="$(basename "$session_dir")"
|
|
972
|
+
local backup_log="$backup_dir/${session_id}.log"
|
|
973
|
+
PRIZM_BACKUP_LOG="$backup_log"
|
|
974
|
+
|
|
975
|
+
# Write preamble to both files
|
|
976
|
+
{
|
|
977
|
+
echo "=== PRIZMKIT SESSION START ==="
|
|
978
|
+
echo "Prompt: $prompt_path"
|
|
979
|
+
echo "Model: ${model:-default}"
|
|
980
|
+
echo "Started at: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
981
|
+
echo "--- OUTPUT BELOW ---"
|
|
982
|
+
} > "$log_path"
|
|
983
|
+
cp "$log_path" "$backup_log"
|
|
984
|
+
|
|
941
985
|
local session_platform
|
|
942
986
|
session_platform="$(_prizm_session_platform)"
|
|
943
987
|
|
|
@@ -950,7 +994,7 @@ prizm_run_ai_session() {
|
|
|
950
994
|
if [[ -n "$model" ]]; then
|
|
951
995
|
claude_args+=(--model "$model")
|
|
952
996
|
fi
|
|
953
|
-
"$CLI_CMD" "${claude_args[@]}"
|
|
997
|
+
"$CLI_CMD" "${claude_args[@]}" 2>&1 | tee -a "$backup_log" >> "$log_path"
|
|
954
998
|
;;
|
|
955
999
|
codex)
|
|
956
1000
|
local codex_args=(--ask-for-approval never --sandbox danger-full-access)
|
|
@@ -969,7 +1013,7 @@ prizm_run_ai_session() {
|
|
|
969
1013
|
if [[ -n "${PRIZMKIT_EFFORT:-}" ]]; then
|
|
970
1014
|
codex_args+=(--config "model_reasoning_effort=$PRIZMKIT_EFFORT")
|
|
971
1015
|
fi
|
|
972
|
-
"$CLI_CMD" "${codex_args[@]}" - < "$prompt_path"
|
|
1016
|
+
"$CLI_CMD" "${codex_args[@]}" - < "$prompt_path" 2>&1 | tee -a "$backup_log" >> "$log_path"
|
|
973
1017
|
;;
|
|
974
1018
|
*)
|
|
975
1019
|
local cb_args=(--print -y)
|
|
@@ -979,11 +1023,44 @@ prizm_run_ai_session() {
|
|
|
979
1023
|
if [[ -n "$model" ]]; then
|
|
980
1024
|
cb_args+=(--model "$model")
|
|
981
1025
|
fi
|
|
982
|
-
"$CLI_CMD" "${cb_args[@]}" < "$prompt_path"
|
|
1026
|
+
"$CLI_CMD" "${cb_args[@]}" < "$prompt_path" 2>&1 | tee -a "$backup_log" >> "$log_path"
|
|
983
1027
|
;;
|
|
984
1028
|
esac
|
|
985
1029
|
}
|
|
986
1030
|
|
|
1031
|
+
# Recover session log from backup if the main log was truncated during execution.
|
|
1032
|
+
# Claude Code subagents can unlink session.log at the filesystem level. The backup
|
|
1033
|
+
# lives in $HOME/.prizmkit/ — outside the project tree — so it survives.
|
|
1034
|
+
#
|
|
1035
|
+
# Must be called AFTER the AI CLI process exits and BEFORE the session summary
|
|
1036
|
+
# reads session.log. Uses PRIZM_BACKUP_LOG set by prizm_start_ai_session /
|
|
1037
|
+
# prizm_run_ai_session.
|
|
1038
|
+
prizm_recover_session_log() {
|
|
1039
|
+
local session_log="${1:-}"
|
|
1040
|
+
local backup_log="${PRIZM_BACKUP_LOG:-}"
|
|
1041
|
+
|
|
1042
|
+
if [[ -z "$backup_log" || ! -f "$backup_log" ]]; then
|
|
1043
|
+
return 0
|
|
1044
|
+
fi
|
|
1045
|
+
|
|
1046
|
+
local main_size=0
|
|
1047
|
+
if [[ -f "$session_log" ]]; then
|
|
1048
|
+
main_size=$(wc -c < "$session_log" 2>/dev/null | tr -d ' ')
|
|
1049
|
+
fi
|
|
1050
|
+
|
|
1051
|
+
local backup_size=0
|
|
1052
|
+
backup_size=$(wc -c < "$backup_log" 2>/dev/null | tr -d ' ')
|
|
1053
|
+
|
|
1054
|
+
if [[ "$main_size" -lt 1024 && "$backup_size" -gt "$main_size" ]]; then
|
|
1055
|
+
cp "$backup_log" "$session_log"
|
|
1056
|
+
log_info "Session log recovered from backup (${backup_size}B → $session_log)"
|
|
1057
|
+
fi
|
|
1058
|
+
|
|
1059
|
+
# Clean up backup either way
|
|
1060
|
+
rm -f "$backup_log"
|
|
1061
|
+
return 0
|
|
1062
|
+
}
|
|
1063
|
+
|
|
987
1064
|
# prizm_detect_subagents <session_log>
|
|
988
1065
|
#
|
|
989
1066
|
# Scan session log for subagent spawns, count them, and log the result.
|
|
@@ -41,6 +41,7 @@ start_heartbeat() {
|
|
|
41
41
|
(
|
|
42
42
|
local elapsed=0
|
|
43
43
|
local prev_size=0
|
|
44
|
+
local prev_max_size=0
|
|
44
45
|
local prev_child_activity_signature=""
|
|
45
46
|
local stale_seconds=0
|
|
46
47
|
while kill -0 "$cli_pid" 2>/dev/null; do
|
|
@@ -48,13 +49,28 @@ start_heartbeat() {
|
|
|
48
49
|
elapsed=$((elapsed + heartbeat_interval))
|
|
49
50
|
kill -0 "$cli_pid" 2>/dev/null || break
|
|
50
51
|
|
|
51
|
-
# Get log file size
|
|
52
|
+
# Get log file size. If session_log was truncated (e.g. by subagent
|
|
53
|
+
# stdout redirection severing the file descriptor), detect and flag it.
|
|
52
54
|
local cur_size=0
|
|
53
55
|
if [[ -f "$session_log" ]]; then
|
|
54
56
|
cur_size=$(wc -c < "$session_log" 2>/dev/null || echo 0)
|
|
55
57
|
cur_size=$(echo "$cur_size" | tr -d ' ')
|
|
56
58
|
fi
|
|
57
59
|
|
|
60
|
+
# Detect log truncation: size dropped to 0 or near-zero after previously
|
|
61
|
+
# accumulating significant content (>100KB). This indicates the file was
|
|
62
|
+
# unlinked/truncated by something outside our control while the AI CLI
|
|
63
|
+
# process is still running.
|
|
64
|
+
local log_truncated=false
|
|
65
|
+
if [[ $cur_size -lt 1024 && $prev_max_size -gt 102400 ]]; then
|
|
66
|
+
log_truncated=true
|
|
67
|
+
fi
|
|
68
|
+
# Track the maximum size we've seen for truncation detection
|
|
69
|
+
local prev_max_size=$prev_max_size
|
|
70
|
+
if [[ $cur_size -gt $prev_max_size ]]; then
|
|
71
|
+
prev_max_size=$cur_size
|
|
72
|
+
fi
|
|
73
|
+
|
|
58
74
|
local growth=$((cur_size - prev_size))
|
|
59
75
|
prev_size=$cur_size
|
|
60
76
|
|
|
@@ -183,8 +199,20 @@ PY
|
|
|
183
199
|
# tool while child transcripts keep growing, so child activity counts.
|
|
184
200
|
# Error loops bypass normal growth-as-progress because the log is only
|
|
185
201
|
# growing with repeated failed reads or wasted calls.
|
|
202
|
+
#
|
|
203
|
+
# When the main session log has been truncated (e.g. by subagent stdout
|
|
204
|
+
# redirection severing the file descriptor), use child activity as the
|
|
205
|
+
# primary progress signal — the parent process is still running and children
|
|
206
|
+
# are still producing output, so this is not a true stall.
|
|
186
207
|
if [[ "$error_loop_detected" == "true" ]]; then
|
|
187
208
|
stale_seconds=$effective_stale_kill_threshold
|
|
209
|
+
elif [[ "$log_truncated" == "true" ]]; then
|
|
210
|
+
# Log truncated: rely on child growth and CLI process liveness.
|
|
211
|
+
# Don't increment staleness — the process is still working.
|
|
212
|
+
if [[ $child_growth -gt 0 ]]; then
|
|
213
|
+
stale_seconds=0
|
|
214
|
+
fi
|
|
215
|
+
# If neither log nor children grow, this is a real stall
|
|
188
216
|
elif [[ $growth -le 0 && $child_growth -eq 0 ]]; then
|
|
189
217
|
stale_seconds=$((stale_seconds + heartbeat_interval))
|
|
190
218
|
else
|
|
@@ -192,7 +220,9 @@ PY
|
|
|
192
220
|
fi
|
|
193
221
|
|
|
194
222
|
local size_display
|
|
195
|
-
if
|
|
223
|
+
if $log_truncated; then
|
|
224
|
+
size_display="${cur_size}B/TRUNCATED(from ${prev_max_size}B)"
|
|
225
|
+
elif [[ $cur_size -gt 1048576 ]]; then
|
|
196
226
|
size_display="$((cur_size / 1048576))MB"
|
|
197
227
|
elif [[ $cur_size -gt 1024 ]]; then
|
|
198
228
|
size_display="$((cur_size / 1024))KB"
|
|
@@ -219,7 +249,9 @@ PY
|
|
|
219
249
|
local secs=$((elapsed % 60))
|
|
220
250
|
|
|
221
251
|
local status_icon
|
|
222
|
-
if
|
|
252
|
+
if $log_truncated; then
|
|
253
|
+
status_icon="${RED}⚠${NC}"
|
|
254
|
+
elif [[ $growth -gt 0 || $child_growth -gt 0 ]]; then
|
|
223
255
|
status_icon="${GREEN}▶${NC}"
|
|
224
256
|
else
|
|
225
257
|
status_icon="${YELLOW}⏸${NC}"
|
|
@@ -139,6 +139,9 @@ spawn_and_wait_session() {
|
|
|
139
139
|
stop_progress_parser "$parser_pid"
|
|
140
140
|
[[ -n "$watcher_pid" ]] && wait "$watcher_pid" 2>/dev/null || true
|
|
141
141
|
|
|
142
|
+
# Recover session log from backup if truncated by subagent stdout redirection
|
|
143
|
+
prizm_recover_session_log "$session_log"
|
|
144
|
+
|
|
142
145
|
[[ $exit_code -eq 143 ]] && exit_code=124
|
|
143
146
|
|
|
144
147
|
# Check for stale-kill marker (heartbeat killed the process due to no progress)
|
|
@@ -159,6 +162,10 @@ spawn_and_wait_session() {
|
|
|
159
162
|
local final_size=$(wc -c < "$session_log" 2>/dev/null | tr -d ' ')
|
|
160
163
|
local final_lines=$(wc -l < "$session_log" 2>/dev/null | tr -d ' ')
|
|
161
164
|
log_info "Session log: $final_lines lines, $((final_size / 1024))KB"
|
|
165
|
+
if [[ "$final_size" -lt 1024 && "$exit_code" -eq 0 ]]; then
|
|
166
|
+
log_warn "Session log was truncated during execution (subagent stdout redirection may have severed it)"
|
|
167
|
+
log_warn "Proceeding with exit-code-only outcome detection — child transcripts and git state are still valid"
|
|
168
|
+
fi
|
|
162
169
|
fi
|
|
163
170
|
log_info "exit_code=$exit_code"
|
|
164
171
|
|
|
@@ -967,6 +974,9 @@ DEPLOY_PROMPT_EOF
|
|
|
967
974
|
prizm_run_ai_session "$deploy_prompt" "$deploy_session_dir/logs/session.log"
|
|
968
975
|
local deploy_exit=$?
|
|
969
976
|
|
|
977
|
+
# Recover log from backup if truncated
|
|
978
|
+
prizm_recover_session_log "$deploy_session_dir/logs/session.log"
|
|
979
|
+
|
|
970
980
|
if [[ $deploy_exit -eq 0 ]]; then
|
|
971
981
|
log_success "Deploy session completed (exit 0)"
|
|
972
982
|
else
|
|
@@ -150,6 +150,9 @@ spawn_and_wait_session() {
|
|
|
150
150
|
stop_progress_parser "$parser_pid"
|
|
151
151
|
[[ -n "$watcher_pid" ]] && wait "$watcher_pid" 2>/dev/null || true
|
|
152
152
|
|
|
153
|
+
# Recover session log from backup if truncated by subagent stdout redirection
|
|
154
|
+
prizm_recover_session_log "$session_log"
|
|
155
|
+
|
|
153
156
|
# Map SIGTERM (143) to timeout code 124
|
|
154
157
|
if [[ $exit_code -eq 143 ]]; then
|
|
155
158
|
exit_code=124
|
|
@@ -174,6 +177,10 @@ spawn_and_wait_session() {
|
|
|
174
177
|
local final_size=$(wc -c < "$session_log" 2>/dev/null | tr -d ' ')
|
|
175
178
|
local final_lines=$(wc -l < "$session_log" 2>/dev/null | tr -d ' ')
|
|
176
179
|
log_info "Session log: $final_lines lines, $((final_size / 1024))KB"
|
|
180
|
+
if [[ "$final_size" -lt 1024 && "$exit_code" -eq 0 ]]; then
|
|
181
|
+
log_warn "Session log was truncated during execution (subagent stdout redirection may have severed it)"
|
|
182
|
+
log_warn "Proceeding with exit-code-only outcome detection — child transcripts and git state are still valid"
|
|
183
|
+
fi
|
|
177
184
|
fi
|
|
178
185
|
log_info "exit_code=$exit_code"
|
|
179
186
|
|
|
@@ -1260,6 +1267,9 @@ DEPLOY_PROMPT_EOF
|
|
|
1260
1267
|
prizm_run_ai_session "$deploy_prompt" "$deploy_session_dir/logs/session.log"
|
|
1261
1268
|
local deploy_exit=$?
|
|
1262
1269
|
|
|
1270
|
+
# Recover log from backup if truncated
|
|
1271
|
+
prizm_recover_session_log "$deploy_session_dir/logs/session.log"
|
|
1272
|
+
|
|
1263
1273
|
if [[ $deploy_exit -eq 0 ]]; then
|
|
1264
1274
|
log_success "Deploy session completed (exit 0)"
|
|
1265
1275
|
else
|
|
@@ -141,6 +141,9 @@ spawn_and_wait_session() {
|
|
|
141
141
|
stop_progress_parser "$parser_pid"
|
|
142
142
|
[[ -n "$watcher_pid" ]] && wait "$watcher_pid" 2>/dev/null || true
|
|
143
143
|
|
|
144
|
+
# Recover session log from backup if truncated by subagent stdout redirection
|
|
145
|
+
prizm_recover_session_log "$session_log"
|
|
146
|
+
|
|
144
147
|
[[ $exit_code -eq 143 ]] && exit_code=124
|
|
145
148
|
|
|
146
149
|
# Check for stale-kill marker (heartbeat killed the process due to no progress)
|
|
@@ -161,6 +164,10 @@ spawn_and_wait_session() {
|
|
|
161
164
|
local final_size=$(wc -c < "$session_log" 2>/dev/null | tr -d ' ')
|
|
162
165
|
local final_lines=$(wc -l < "$session_log" 2>/dev/null | tr -d ' ')
|
|
163
166
|
log_info "Session log: $final_lines lines, $((final_size / 1024))KB"
|
|
167
|
+
if [[ "$final_size" -lt 1024 && "$exit_code" -eq 0 ]]; then
|
|
168
|
+
log_warn "Session log was truncated during execution (subagent stdout redirection may have severed it)"
|
|
169
|
+
log_warn "Proceeding with exit-code-only outcome detection — child transcripts and git state are still valid"
|
|
170
|
+
fi
|
|
164
171
|
fi
|
|
165
172
|
log_info "exit_code=$exit_code"
|
|
166
173
|
|
|
@@ -1002,6 +1009,9 @@ DEPLOY_PROMPT_EOF
|
|
|
1002
1009
|
prizm_run_ai_session "$deploy_prompt" "$deploy_session_dir/logs/session.log"
|
|
1003
1010
|
local deploy_exit=$?
|
|
1004
1011
|
|
|
1012
|
+
# Recover log from backup if truncated
|
|
1013
|
+
prizm_recover_session_log "$deploy_session_dir/logs/session.log"
|
|
1014
|
+
|
|
1005
1015
|
if [[ $deploy_exit -eq 0 ]]; then
|
|
1006
1016
|
log_success "Deploy session completed (exit 0)"
|
|
1007
1017
|
else
|
package/package.json
CHANGED
|
@@ -1,439 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# ============================================================
|
|
3
|
-
# dev-pipeline/lib/heartbeat.sh - Shared heartbeat monitoring
|
|
4
|
-
#
|
|
5
|
-
# Provides start_heartbeat / stop_heartbeat functions that read
|
|
6
|
-
# structured progress from progress.json (written by
|
|
7
|
-
# parse-stream-progress.py) and fall back to tail-based monitoring.
|
|
8
|
-
#
|
|
9
|
-
# When stale_kill_threshold is set (>0), the heartbeat monitor will
|
|
10
|
-
# automatically kill the AI CLI process if it shows no progress for
|
|
11
|
-
# the specified duration. This prevents sessions from hanging forever
|
|
12
|
-
# when the AI CLI process doesn't exit after completing its work.
|
|
13
|
-
#
|
|
14
|
-
# Usage:
|
|
15
|
-
# source "$SCRIPT_DIR/lib/heartbeat.sh"
|
|
16
|
-
# start_heartbeat "$cli_pid" "$session_log" "$progress_json" "$interval" ["$stale_kill_threshold"]
|
|
17
|
-
# # ... wait for CLI to finish ...
|
|
18
|
-
# stop_heartbeat "$_HEARTBEAT_PID"
|
|
19
|
-
#
|
|
20
|
-
# Requires: colors (GREEN, YELLOW, BLUE, NC) and log functions
|
|
21
|
-
# to be defined before sourcing.
|
|
22
|
-
# ============================================================
|
|
23
|
-
|
|
24
|
-
# Start a heartbeat monitor in the background.
|
|
25
|
-
# Sets _HEARTBEAT_PID to the background process PID.
|
|
26
|
-
#
|
|
27
|
-
# Arguments:
|
|
28
|
-
# $1 - cli_pid PID of the AI CLI process to monitor
|
|
29
|
-
# $2 - session_log Path to session.log
|
|
30
|
-
# $3 - progress_json Path to progress.json (may not exist if stream-json disabled)
|
|
31
|
-
# $4 - interval Heartbeat interval in seconds
|
|
32
|
-
# $5 - stale_kill_threshold (optional) Seconds of no progress before auto-killing the process.
|
|
33
|
-
# 0 = disabled (default). Recommended: 900 (15 minutes).
|
|
34
|
-
start_heartbeat() {
|
|
35
|
-
local cli_pid="$1"
|
|
36
|
-
local session_log="$2"
|
|
37
|
-
local progress_json="$3"
|
|
38
|
-
local heartbeat_interval="$4"
|
|
39
|
-
local stale_kill_threshold="${5:-0}"
|
|
40
|
-
|
|
41
|
-
(
|
|
42
|
-
local elapsed=0
|
|
43
|
-
local prev_size=0
|
|
44
|
-
local prev_child_activity_signature=""
|
|
45
|
-
local stale_seconds=0
|
|
46
|
-
while kill -0 "$cli_pid" 2>/dev/null; do
|
|
47
|
-
sleep "$heartbeat_interval"
|
|
48
|
-
elapsed=$((elapsed + heartbeat_interval))
|
|
49
|
-
kill -0 "$cli_pid" 2>/dev/null || break
|
|
50
|
-
|
|
51
|
-
# Get log file size
|
|
52
|
-
local cur_size=0
|
|
53
|
-
if [[ -f "$session_log" ]]; then
|
|
54
|
-
cur_size=$(wc -c < "$session_log" 2>/dev/null || echo 0)
|
|
55
|
-
cur_size=$(echo "$cur_size" | tr -d ' ')
|
|
56
|
-
fi
|
|
57
|
-
|
|
58
|
-
local growth=$((cur_size - prev_size))
|
|
59
|
-
prev_size=$cur_size
|
|
60
|
-
|
|
61
|
-
local child_activity_signature=""
|
|
62
|
-
local child_total_bytes=0
|
|
63
|
-
local child_session_count=0
|
|
64
|
-
if [[ -f "$progress_json" ]]; then
|
|
65
|
-
local child_activity_data
|
|
66
|
-
child_activity_data=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
|
|
67
|
-
import json
|
|
68
|
-
import sys
|
|
69
|
-
|
|
70
|
-
try:
|
|
71
|
-
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
|
72
|
-
progress = json.load(fh)
|
|
73
|
-
except Exception:
|
|
74
|
-
sys.exit(0)
|
|
75
|
-
|
|
76
|
-
signature = str(progress.get("child_activity_signature") or "")
|
|
77
|
-
total_bytes = int(progress.get("child_total_bytes") or 0)
|
|
78
|
-
session_count = len(progress.get("child_session_files") or [])
|
|
79
|
-
print(f"{signature}\t{total_bytes}\t{session_count}")
|
|
80
|
-
PY
|
|
81
|
-
)
|
|
82
|
-
if [[ -n "$child_activity_data" ]]; then
|
|
83
|
-
IFS=$'\t' read -r child_activity_signature child_total_bytes child_session_count <<< "$child_activity_data"
|
|
84
|
-
fi
|
|
85
|
-
fi
|
|
86
|
-
|
|
87
|
-
local child_growth=0
|
|
88
|
-
if [[ -n "$child_activity_signature" && "$child_activity_signature" != "$prev_child_activity_signature" ]]; then
|
|
89
|
-
child_growth=1
|
|
90
|
-
fi
|
|
91
|
-
prev_child_activity_signature="$child_activity_signature"
|
|
92
|
-
|
|
93
|
-
local effective_stale_kill_threshold="$stale_kill_threshold"
|
|
94
|
-
if [[ $stale_kill_threshold -gt 0 && -f "$progress_json" ]]; then
|
|
95
|
-
local extended_threshold
|
|
96
|
-
extended_threshold=$(python3 - "$progress_json" "$stale_kill_threshold" <<'PY' 2>/dev/null || true
|
|
97
|
-
import json
|
|
98
|
-
import os
|
|
99
|
-
import sys
|
|
100
|
-
|
|
101
|
-
progress_path = sys.argv[1]
|
|
102
|
-
base_threshold = int(sys.argv[2])
|
|
103
|
-
|
|
104
|
-
with open(progress_path, "r", encoding="utf-8") as fh:
|
|
105
|
-
progress = json.load(fh)
|
|
106
|
-
|
|
107
|
-
spawn_count = 0
|
|
108
|
-
for tool in progress.get("tool_calls", []):
|
|
109
|
-
if isinstance(tool, dict) and tool.get("name") in ("spawn_agent", "Agent", "TaskCreate"):
|
|
110
|
-
try:
|
|
111
|
-
spawn_count += int(tool.get("count", 0))
|
|
112
|
-
except (TypeError, ValueError):
|
|
113
|
-
pass
|
|
114
|
-
|
|
115
|
-
# Also check the subagent_spawn_count field (set by _record_cb_agent_tool_call)
|
|
116
|
-
if not spawn_count:
|
|
117
|
-
spawn_count = int(progress.get("subagent_spawn_count", 0))
|
|
118
|
-
|
|
119
|
-
fmt = progress.get("event_format", "")
|
|
120
|
-
|
|
121
|
-
# Codex: current_tool == "wait" means parent is blocked on spawn_agent completion
|
|
122
|
-
if (
|
|
123
|
-
fmt == "codex-json"
|
|
124
|
-
and progress.get("current_tool") == "wait"
|
|
125
|
-
and spawn_count > 0
|
|
126
|
-
):
|
|
127
|
-
configured = os.environ.get("CODEX_WAIT_STALE_KILL_THRESHOLD", "")
|
|
128
|
-
try:
|
|
129
|
-
wait_threshold = int(configured)
|
|
130
|
-
except ValueError:
|
|
131
|
-
wait_threshold = max(base_threshold * 4, 3600)
|
|
132
|
-
if wait_threshold > base_threshold:
|
|
133
|
-
print(wait_threshold)
|
|
134
|
-
|
|
135
|
-
# CodeBuddy: Agent tool blocks synchronously; Task* tools imply bg agents.
|
|
136
|
-
# Extend the stale window when sub-agents have been spawned so the heartbeat
|
|
137
|
-
# doesn't kill the parent while children are still running.
|
|
138
|
-
if (
|
|
139
|
-
fmt == "stream-json"
|
|
140
|
-
and spawn_count > 0
|
|
141
|
-
and progress.get("cb_session_id", "")
|
|
142
|
-
):
|
|
143
|
-
configured = os.environ.get("CB_SUBAGENT_STALE_KILL_THRESHOLD", "")
|
|
144
|
-
try:
|
|
145
|
-
cb_threshold = int(configured)
|
|
146
|
-
except ValueError:
|
|
147
|
-
cb_threshold = max(base_threshold * 4, 3600)
|
|
148
|
-
if cb_threshold > base_threshold:
|
|
149
|
-
print(cb_threshold)
|
|
150
|
-
PY
|
|
151
|
-
)
|
|
152
|
-
if [[ "$extended_threshold" =~ ^[0-9]+$ && "$extended_threshold" -gt "$stale_kill_threshold" ]]; then
|
|
153
|
-
effective_stale_kill_threshold="$extended_threshold"
|
|
154
|
-
fi
|
|
155
|
-
fi
|
|
156
|
-
|
|
157
|
-
# Check for error-loop: agent is actively producing output but results are
|
|
158
|
-
# all read-offset errors or wasted calls. This is a stuck agent that appears
|
|
159
|
-
# "active" by log growth but is accomplishing nothing.
|
|
160
|
-
local error_loop_detected=false
|
|
161
|
-
if [[ $effective_stale_kill_threshold -gt 0 && $growth -gt 0 && -f "$progress_json" ]]; then
|
|
162
|
-
local error_loop_flag
|
|
163
|
-
error_loop_flag=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
|
|
164
|
-
import json, sys
|
|
165
|
-
try:
|
|
166
|
-
with open(sys.argv[1], encoding="utf-8") as fh:
|
|
167
|
-
progress = json.load(fh)
|
|
168
|
-
except Exception:
|
|
169
|
-
raise SystemExit(0)
|
|
170
|
-
errors = progress.get("errors", [])
|
|
171
|
-
if isinstance(errors, list) and len(errors) >= 5:
|
|
172
|
-
recent = errors[-5:]
|
|
173
|
-
if all(isinstance(e, dict) and e.get("type") in ("read_offset_overflow", "wasted_call") for e in recent):
|
|
174
|
-
print("error_loop")
|
|
175
|
-
PY
|
|
176
|
-
)
|
|
177
|
-
if [[ "$error_loop_flag" == "error_loop" ]]; then
|
|
178
|
-
error_loop_detected=true
|
|
179
|
-
fi
|
|
180
|
-
fi
|
|
181
|
-
|
|
182
|
-
# Track progress staleness. Parent sessions can sit in a wait/polling
|
|
183
|
-
# tool while child transcripts keep growing, so child activity counts.
|
|
184
|
-
# Error loops bypass normal growth-as-progress because the log is only
|
|
185
|
-
# growing with repeated failed reads or wasted calls.
|
|
186
|
-
if [[ "$error_loop_detected" == "true" ]]; then
|
|
187
|
-
stale_seconds=$effective_stale_kill_threshold
|
|
188
|
-
elif [[ $growth -eq 0 && $child_growth -eq 0 ]]; then
|
|
189
|
-
stale_seconds=$((stale_seconds + heartbeat_interval))
|
|
190
|
-
else
|
|
191
|
-
stale_seconds=0
|
|
192
|
-
fi
|
|
193
|
-
|
|
194
|
-
local size_display
|
|
195
|
-
if [[ $cur_size -gt 1048576 ]]; then
|
|
196
|
-
size_display="$((cur_size / 1048576))MB"
|
|
197
|
-
elif [[ $cur_size -gt 1024 ]]; then
|
|
198
|
-
size_display="$((cur_size / 1024))KB"
|
|
199
|
-
else
|
|
200
|
-
size_display="${cur_size}B"
|
|
201
|
-
fi
|
|
202
|
-
local child_display=""
|
|
203
|
-
if [[ ${child_total_bytes:-0} -gt 0 ]]; then
|
|
204
|
-
local child_size_display
|
|
205
|
-
if [[ $child_total_bytes -gt 1048576 ]]; then
|
|
206
|
-
child_size_display="$((child_total_bytes / 1048576))MB"
|
|
207
|
-
elif [[ $child_total_bytes -gt 1024 ]]; then
|
|
208
|
-
child_size_display="$((child_total_bytes / 1024))KB"
|
|
209
|
-
else
|
|
210
|
-
child_size_display="${child_total_bytes}B"
|
|
211
|
-
fi
|
|
212
|
-
child_display=" | child: ${child_size_display}"
|
|
213
|
-
if [[ ${child_session_count:-0} -gt 1 ]]; then
|
|
214
|
-
child_display="${child_display}/${child_session_count}"
|
|
215
|
-
fi
|
|
216
|
-
fi
|
|
217
|
-
|
|
218
|
-
local mins=$((elapsed / 60))
|
|
219
|
-
local secs=$((elapsed % 60))
|
|
220
|
-
|
|
221
|
-
local status_icon
|
|
222
|
-
if [[ $growth -gt 0 || $child_growth -gt 0 ]]; then
|
|
223
|
-
status_icon="${GREEN}▶${NC}"
|
|
224
|
-
else
|
|
225
|
-
status_icon="${YELLOW}⏸${NC}"
|
|
226
|
-
fi
|
|
227
|
-
|
|
228
|
-
# Fatal provider/runtime errors are terminal; do not wait for the
|
|
229
|
-
# stale window when progress.json already proves the model cannot
|
|
230
|
-
# continue (for example context_too_large).
|
|
231
|
-
if [[ -f "$progress_json" ]]; then
|
|
232
|
-
local fatal_error_code=""
|
|
233
|
-
fatal_error_code=$(python3 - "$progress_json" <<'PY' 2>/dev/null || true
|
|
234
|
-
import json
|
|
235
|
-
import sys
|
|
236
|
-
try:
|
|
237
|
-
with open(sys.argv[1], encoding="utf-8") as fh:
|
|
238
|
-
progress = json.load(fh)
|
|
239
|
-
except Exception:
|
|
240
|
-
raise SystemExit(0)
|
|
241
|
-
code = progress.get("fatal_error_code") or ""
|
|
242
|
-
if code:
|
|
243
|
-
print(code)
|
|
244
|
-
PY
|
|
245
|
-
)
|
|
246
|
-
if [[ -n "$fatal_error_code" ]]; then
|
|
247
|
-
echo -e " ${RED}[HEARTBEAT]${NC} ${mins}m${secs}s | log: ${size_display} | ${RED}FATAL: ${fatal_error_code}${NC}"
|
|
248
|
-
local _marker_dir
|
|
249
|
-
_marker_dir="$(dirname "$session_log")"
|
|
250
|
-
echo "{\"killed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"reason\": \"${fatal_error_code}\", \"fatal_error_code\": \"${fatal_error_code}\", \"stale_seconds\": $stale_seconds, \"threshold\": $effective_stale_kill_threshold}" > "$_marker_dir/fatal-error.json" 2>/dev/null || true
|
|
251
|
-
echo "{\"killed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"reason\": \"${fatal_error_code}\", \"fatal_error_code\": \"${fatal_error_code}\", \"stale_seconds\": $stale_seconds, \"threshold\": $effective_stale_kill_threshold}" > "$_marker_dir/stale-kill.json" 2>/dev/null || true
|
|
252
|
-
kill -TERM "$cli_pid" 2>/dev/null || true
|
|
253
|
-
local fatal_kill_grace_seconds="${STALE_KILL_GRACE_SECONDS:-10}"
|
|
254
|
-
if [[ $fatal_kill_grace_seconds -gt 0 ]]; then
|
|
255
|
-
sleep "$fatal_kill_grace_seconds"
|
|
256
|
-
fi
|
|
257
|
-
if kill -0 "$cli_pid" 2>/dev/null; then
|
|
258
|
-
kill -9 "$cli_pid" 2>/dev/null || true
|
|
259
|
-
fi
|
|
260
|
-
break
|
|
261
|
-
fi
|
|
262
|
-
fi
|
|
263
|
-
|
|
264
|
-
# Stale-kill: auto-terminate process if no progress for too long.
|
|
265
|
-
# Parent sessions can wait on spawned work; child transcript growth
|
|
266
|
-
# counts as progress above, while silent waits still use the active
|
|
267
|
-
# stale window to surface stuck agents promptly.
|
|
268
|
-
if [[ $effective_stale_kill_threshold -gt 0 && $stale_seconds -ge $effective_stale_kill_threshold ]]; then
|
|
269
|
-
local stale_mins=$((stale_seconds / 60))
|
|
270
|
-
echo -e " ${RED}[HEARTBEAT]${NC} ${mins}m${secs}s | log: ${size_display} | ${RED}STALE-KILL: no progress for ${stale_mins}m (threshold: ${effective_stale_kill_threshold}s)${NC}"
|
|
271
|
-
echo -e " ${RED}[HEARTBEAT]${NC} Killing AI CLI process $cli_pid (stale session)..."
|
|
272
|
-
# Write the marker before killing. Some CLIs exit quickly, and the
|
|
273
|
-
# parent runner may stop this heartbeat process immediately after
|
|
274
|
-
# wait(1) returns.
|
|
275
|
-
local _marker_dir
|
|
276
|
-
_marker_dir="$(dirname "$session_log")"
|
|
277
|
-
echo "{\"killed_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\", \"reason\": \"stale_session\", \"stale_seconds\": $stale_seconds, \"threshold\": $effective_stale_kill_threshold}" > "$_marker_dir/stale-kill.json" 2>/dev/null || true
|
|
278
|
-
kill -TERM "$cli_pid" 2>/dev/null || true
|
|
279
|
-
# Give process 10s to exit gracefully, then force kill
|
|
280
|
-
local stale_kill_grace_seconds="${STALE_KILL_GRACE_SECONDS:-10}"
|
|
281
|
-
if [[ $stale_kill_grace_seconds -gt 0 ]]; then
|
|
282
|
-
sleep "$stale_kill_grace_seconds"
|
|
283
|
-
fi
|
|
284
|
-
if kill -0 "$cli_pid" 2>/dev/null; then
|
|
285
|
-
echo -e " ${RED}[HEARTBEAT]${NC} Process still alive after SIGTERM, sending SIGKILL..."
|
|
286
|
-
kill -9 "$cli_pid" 2>/dev/null || true
|
|
287
|
-
fi
|
|
288
|
-
break
|
|
289
|
-
fi
|
|
290
|
-
|
|
291
|
-
# Build staleness hint for display
|
|
292
|
-
local stale_hint=""
|
|
293
|
-
if [[ $effective_stale_kill_threshold -gt 0 && $stale_seconds -gt 0 ]]; then
|
|
294
|
-
local stale_mins=$((stale_seconds / 60))
|
|
295
|
-
local threshold_mins=$((effective_stale_kill_threshold / 60))
|
|
296
|
-
stale_hint=" | stale: ${stale_mins}m/${threshold_mins}m"
|
|
297
|
-
fi
|
|
298
|
-
|
|
299
|
-
# Try structured progress from progress.json
|
|
300
|
-
if [[ -f "$progress_json" ]]; then
|
|
301
|
-
local phase tool msgs tools_total
|
|
302
|
-
phase=$(python3 -c "
|
|
303
|
-
import json, sys
|
|
304
|
-
try:
|
|
305
|
-
with open(sys.argv[1]) as f:
|
|
306
|
-
d = json.load(f)
|
|
307
|
-
parts = []
|
|
308
|
-
if d.get('current_phase'):
|
|
309
|
-
parts.append('phase: ' + d['current_phase'])
|
|
310
|
-
if d.get('current_tool'):
|
|
311
|
-
parts.append('tool: ' + d['current_tool'])
|
|
312
|
-
parts.append('msgs: ' + str(d.get('message_count', 0)))
|
|
313
|
-
parts.append(str(d.get('total_tool_calls', 0)) + ' tool calls')
|
|
314
|
-
print(' | '.join(parts))
|
|
315
|
-
except Exception:
|
|
316
|
-
sys.exit(1)
|
|
317
|
-
" "$progress_json" 2>/dev/null) && {
|
|
318
|
-
echo -e " ${status_icon} ${BLUE}[HEARTBEAT]${NC} ${mins}m${secs}s | log: ${size_display}${child_display} | ${phase}${stale_hint}"
|
|
319
|
-
continue
|
|
320
|
-
}
|
|
321
|
-
fi
|
|
322
|
-
|
|
323
|
-
# Fallback: tail-based activity detection
|
|
324
|
-
local last_activity=""
|
|
325
|
-
if [[ -f "$session_log" ]]; then
|
|
326
|
-
last_activity=$(tail -20 "$session_log" 2>/dev/null | grep -v '^$' | tail -1 | cut -c1-80 || echo "")
|
|
327
|
-
fi
|
|
328
|
-
|
|
329
|
-
echo -e " ${status_icon} ${BLUE}[HEARTBEAT]${NC} ${mins}m${secs}s elapsed | log: ${size_display}${child_display} (+${growth}B) | ${last_activity}${stale_hint}"
|
|
330
|
-
done
|
|
331
|
-
) &
|
|
332
|
-
_HEARTBEAT_PID=$!
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
# Stop a heartbeat monitor process.
|
|
336
|
-
#
|
|
337
|
-
# Arguments:
|
|
338
|
-
# $1 - heartbeat_pid PID returned by start_heartbeat
|
|
339
|
-
stop_heartbeat() {
|
|
340
|
-
local heartbeat_pid="$1"
|
|
341
|
-
if [[ -n "$heartbeat_pid" ]]; then
|
|
342
|
-
kill "$heartbeat_pid" 2>/dev/null || true
|
|
343
|
-
wait "$heartbeat_pid" 2>/dev/null || true
|
|
344
|
-
fi
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
# Start the stream-json progress parser as a background process.
|
|
348
|
-
# Sets _PARSER_PID to the background process PID.
|
|
349
|
-
# No-op if USE_STREAM_JSON is not "true".
|
|
350
|
-
#
|
|
351
|
-
# Arguments:
|
|
352
|
-
# $1 - session_log Path to session.log
|
|
353
|
-
# $2 - progress_json Path to write progress.json
|
|
354
|
-
# $3 - scripts_dir Path to scripts/ directory
|
|
355
|
-
start_progress_parser() {
|
|
356
|
-
local session_log="$1"
|
|
357
|
-
local progress_json="$2"
|
|
358
|
-
local scripts_dir="$3"
|
|
359
|
-
|
|
360
|
-
_PARSER_PID=""
|
|
361
|
-
|
|
362
|
-
if [[ "${USE_STREAM_JSON:-}" != "true" ]]; then
|
|
363
|
-
return 0
|
|
364
|
-
fi
|
|
365
|
-
|
|
366
|
-
local parser_script="$scripts_dir/parse-stream-progress.py"
|
|
367
|
-
if [[ ! -f "$parser_script" ]]; then
|
|
368
|
-
return 0
|
|
369
|
-
fi
|
|
370
|
-
|
|
371
|
-
python3 "$parser_script" \
|
|
372
|
-
--session-log "$session_log" \
|
|
373
|
-
--progress-file "$progress_json" &
|
|
374
|
-
_PARSER_PID=$!
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
# Stop the progress parser process.
|
|
378
|
-
#
|
|
379
|
-
# Arguments:
|
|
380
|
-
# $1 - parser_pid PID returned by start_progress_parser
|
|
381
|
-
stop_progress_parser() {
|
|
382
|
-
local parser_pid="$1"
|
|
383
|
-
if [[ -n "$parser_pid" ]]; then
|
|
384
|
-
kill "$parser_pid" 2>/dev/null || true
|
|
385
|
-
wait "$parser_pid" 2>/dev/null || true
|
|
386
|
-
fi
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
# Detect whether the AI CLI supports structured JSON progress.
|
|
390
|
-
# Sets USE_STREAM_JSON to "true" or "false".
|
|
391
|
-
#
|
|
392
|
-
# Arguments:
|
|
393
|
-
# $1 - cli_cmd The AI CLI command
|
|
394
|
-
detect_stream_json_support() {
|
|
395
|
-
local cli_cmd="$1"
|
|
396
|
-
USE_STREAM_JSON="false"
|
|
397
|
-
STREAM_JSON_FORMAT=""
|
|
398
|
-
|
|
399
|
-
local session_platform=""
|
|
400
|
-
session_platform="$(_prizm_known_platform_from_cli "$cli_cmd" 2>/dev/null || true)"
|
|
401
|
-
if [[ -z "$session_platform" ]]; then
|
|
402
|
-
case "$(_prizm_normalize_platform "${PRIZMKIT_PLATFORM:-${PLATFORM:-}}")" in
|
|
403
|
-
codex|claude|codebuddy)
|
|
404
|
-
session_platform="$(_prizm_normalize_platform "${PRIZMKIT_PLATFORM:-${PLATFORM:-}}")"
|
|
405
|
-
;;
|
|
406
|
-
esac
|
|
407
|
-
fi
|
|
408
|
-
|
|
409
|
-
# CodeBuddy (cbc) always supports stream-json
|
|
410
|
-
if [[ "$session_platform" == "codebuddy" || "$cli_cmd" == "cbc" ]]; then
|
|
411
|
-
USE_STREAM_JSON="true"
|
|
412
|
-
STREAM_JSON_FORMAT="stream-json"
|
|
413
|
-
export USE_STREAM_JSON STREAM_JSON_FORMAT
|
|
414
|
-
return 0
|
|
415
|
-
fi
|
|
416
|
-
|
|
417
|
-
# Codex uses `codex exec --json` JSONL, not `--output-format stream-json`.
|
|
418
|
-
if [[ "$session_platform" == "codex" ]]; then
|
|
419
|
-
local codex_help
|
|
420
|
-
codex_help=$("$cli_cmd" exec --help 2>&1) || true
|
|
421
|
-
if echo "$codex_help" | grep -q -- "--json"; then
|
|
422
|
-
USE_STREAM_JSON="true"
|
|
423
|
-
STREAM_JSON_FORMAT="codex-json"
|
|
424
|
-
fi
|
|
425
|
-
export USE_STREAM_JSON STREAM_JSON_FORMAT
|
|
426
|
-
return 0
|
|
427
|
-
fi
|
|
428
|
-
|
|
429
|
-
# For other CLIs, try to detect support via --help output
|
|
430
|
-
# Use explicit file descriptor to avoid issues in background processes
|
|
431
|
-
local help_output
|
|
432
|
-
help_output=$("$cli_cmd" --help 2>&1) || true
|
|
433
|
-
|
|
434
|
-
if echo "$help_output" | grep -q "stream-json"; then
|
|
435
|
-
USE_STREAM_JSON="true"
|
|
436
|
-
STREAM_JSON_FORMAT="stream-json"
|
|
437
|
-
fi
|
|
438
|
-
export USE_STREAM_JSON STREAM_JSON_FORMAT
|
|
439
|
-
}
|