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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.82",
3
- "bundledAt": "2026-06-30T03:07:55.258Z",
4
- "bundledFrom": "c12c40b"
2
+ "frameworkVersion": "1.1.84",
3
+ "bundledAt": "2026-06-30T08:08:52.840Z",
4
+ "bundledFrom": "3b93824"
5
5
  }
@@ -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[@]}" > "$log_path" 2>&1 &
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" > "$log_path" 2>&1 &
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" > "$log_path" 2>&1 &
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[@]}" > "$log_path" 2>&1
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" > "$log_path" 2>&1
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" > "$log_path" 2>&1
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 [[ $cur_size -gt 1048576 ]]; then
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 [[ $growth -gt 0 || $child_growth -gt 0 ]]; then
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.82",
2
+ "version": "1.1.84",
3
3
  "skills": {
4
4
  "prizm-kit": {
5
5
  "description": "Full-lifecycle dev toolkit. Covers spec-driven development, Prizm context docs, code quality, debugging, deployment, and knowledge management.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.82",
3
+ "version": "1.1.84",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- }