diff --git a/.claude/scripts/cockpit-serve.sh b/.claude/scripts/cockpit-serve.sh
index 6b7759b..9fdd017 100755
--- a/.claude/scripts/cockpit-serve.sh
+++ b/.claude/scripts/cockpit-serve.sh
@@ -108,6 +108,23 @@ worktrees_root="${COCKPIT_SERVE_WORKTREES_ROOT:-$root}"
# this at a synthetic temp file instead of the real, gitignored state dir.
worker_tools_file="${CLAUDE_WORKER_TOOLS_FILE:-$root/.claude/state/worker-tools.jsonl}"
+# Loop-driver timeout budget + "still running" signal (issue #116): same data
+# sources cockpit.sh's own bash prelude gathers for its static render (see
+# cockpit.sh's comment block on the same feature) — duplicated here (rather
+# than shelling into cockpit.sh per request) because this value must be
+# recomputed fresh on every page load, not just once at process startup.
+# Fixtures mode points at the SAME
/loop-driver-running.json seam
+# cockpit.sh reads; live mode globs the real (gitignored) state dir at
+# request time, honoring CLAUDE_DRIVER_OUT_DIR for parity.
+if [ -n "$fixtures" ]; then
+ driver_running_fixture="$fixtures/loop-driver-running.json"
+ driver_out_dir=""
+else
+ driver_running_fixture=""
+ driver_out_dir="${CLAUDE_DRIVER_OUT_DIR:-$root/.claude/state}"
+fi
+loop_driver_timeout="${LOOP_DRIVER_TIMEOUT:-90m}"
+
tmp_out="$(mktemp "${TMPDIR:-/tmp}/cockpit-serve.XXXXXX.html")"
# NOTE: deliberately NO bash `trap ... EXIT` here. `exec` below REPLACES this
# shell process image with node (same PID) — a bash-level EXIT trap
@@ -128,6 +145,10 @@ COCKPIT_SERVE_GH_REFRESH="$gh_refresh" \
COCKPIT_SERVE_TMP_OUT="$tmp_out" \
COCKPIT_SERVE_WORKTREES_ROOT="$worktrees_root" \
CLAUDE_WORKER_TOOLS_FILE="$worker_tools_file" \
+COCKPIT_SERVE_DRIVER_RUNNING_FIXTURE="$driver_running_fixture" \
+COCKPIT_SERVE_DRIVER_OUT_DIR="$driver_out_dir" \
+COCKPIT_SERVE_LOOP_DRIVER_TIMEOUT="$loop_driver_timeout" \
+COCKPIT_SERVE_TIMEOUT_WARN_FRACTION="${COCKPIT_TIMEOUT_WARN_FRACTION:-0.8}" \
exec node - <<'NODE_SERVE'
const http = require("http");
const fs = require("fs");
@@ -148,6 +169,58 @@ const WORKER_TOOLS_FILE =
process.env.CLAUDE_WORKER_TOOLS_FILE ||
path.join(process.cwd(), ".claude", "state", "worker-tools.jsonl");
+// ---------------------------------------------------------------------------
+// Live-progress age/timeout-budget badge (issue #116): the SSE upsert path
+// below (upsertRow, inside clientScript()) applies the SAME formula/badge
+// thresholds cockpit.sh's own renderLiveProgress()/timeoutBadge() use for the
+// server-rendered rows, so a row upserted live never disagrees with a row
+// that survives into the next full-page re-render. Duplicated rather than
+// shared (these are two separate inline scripts with no shared module), same
+// as every other per-row field this file already re-derives from raw event
+// JSON independently of cockpit.sh (role/task/model/phase/lens/ts).
+const DRIVER_RUNNING_FIXTURE = process.env.COCKPIT_SERVE_DRIVER_RUNNING_FIXTURE || "";
+const DRIVER_OUT_DIR = process.env.COCKPIT_SERVE_DRIVER_OUT_DIR || "";
+const LOOP_DRIVER_TIMEOUT_ENV = process.env.COCKPIT_SERVE_LOOP_DRIVER_TIMEOUT || "90m";
+const TIMEOUT_WARN_FRACTION = (() => {
+ const n = parseFloat(process.env.COCKPIT_SERVE_TIMEOUT_WARN_FRACTION);
+ return Number.isFinite(n) && n > 0 ? n : 0.8;
+})();
+
+// Mirrors cockpit.sh's own parseDurationToSeconds(): GNU-`timeout`-style
+// duration grammar ("90m", "45s", "2h", "1.5d", or a bare number of seconds).
+function parseDurationToSeconds(str, fallbackSeconds) {
+ const s = String(str == null ? "" : str).trim();
+ const m = s.match(/^([0-9]*\.?[0-9]+)([smhd]?)$/);
+ if (!m) return fallbackSeconds;
+ const n = parseFloat(m[1]);
+ if (!Number.isFinite(n)) return fallbackSeconds;
+ const mult = { s: 1, m: 60, h: 3600, d: 86400 }[m[2] || "s"];
+ return n * mult;
+}
+const DRIVER_BUDGET_SECONDS = parseDurationToSeconds(LOOP_DRIVER_TIMEOUT_ENV, 90 * 60);
+
+// "Still running" signal, re-checked on every full-page render (see
+// clientScript() below, called fresh from handleIndex() on every request):
+// fixtures mode reads the same /loop-driver-running.json seam cockpit.sh
+// reads; live mode checks for any .loop-driver-out.*.json file in the state
+// dir (loop-daemon.sh deletes it right after its driver exits).
+function isDriverRunning() {
+ if (DRIVER_RUNNING_FIXTURE) {
+ try {
+ const j = JSON.parse(fs.readFileSync(DRIVER_RUNNING_FIXTURE, "utf8"));
+ return !!j.running;
+ } catch (e) {
+ return false;
+ }
+ }
+ if (!DRIVER_OUT_DIR) return false;
+ try {
+ return fs.readdirSync(DRIVER_OUT_DIR).some((f) => /^\.loop-driver-out\..*\.json$/.test(f));
+ } catch (e) {
+ return false;
+ }
+}
+
// Fix (issue #70, 3a-followup): TMP_OUT cleanup moved here from the now-dead
// bash EXIT trap (see the shell comment above `exec node`, above) — this
// fires on every node exit path, so the temp HTML file cockpit.sh renders
@@ -196,10 +269,35 @@ function getHtml(force) {
// via textContent, never innerHTML.
// ---------------------------------------------------------------------------
function clientScript() {
+ // Fresh per call (see injectClientScript(), invoked on every handleIndex()
+ // request): DRIVER_RUNNING is re-checked right now, so a page reload always
+ // reflects the CURRENT driver state, same freshness as the cached HTML
+ // itself gets on its own refresh cadence.
+ const driverRunning = isDriverRunning();
return `