Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions .claude/scripts/cockpit-serve.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
# GET /api/worker/<role>/<task>
# Worker inspector (issue #70), backing the drawer that
# opens when a live-progress row is clicked. Returns
# JSON: { role, task, timeline, breadcrumbs, worktree }.
# JSON: { role, task, timeline, breadcrumbs, worktree,
# activity }.
# - timeline: every events.jsonl record matching
# (role,task), NEWEST FIRST.
# - breadcrumbs: the most recent non-empty --detail
Expand All @@ -51,6 +52,14 @@
# status, commits, diffstat, mergeBase, error }; if
# no worktree matches, found:false with a plain
# "no worktree found" error string (never a crash).
# - activity: (issue #84) up to the most recent 20
# records from the worker-tools mirror log
# (log-worker-tool.sh's JSONL, default
# <root>/.claude/state/worker-tools.jsonl, override
# via CLAUDE_WORKER_TOOLS_FILE) whose `path` falls
# under the worker's worktree path, NEWEST FIRST.
# Empty array if the log is missing/unreadable or no
# worktree was found — never a crash.
#
# Foreground process — SIGTERM/SIGINT close the server cleanly (via `exec`,
# below, node receives signals directly; no bash wrapper indirection).
Expand Down Expand Up @@ -93,6 +102,12 @@ gh_refresh="${COCKPIT_GH_REFRESH:-60}"
# the real one — no network/gh either way, just local git plumbing.
worktrees_root="${COCKPIT_SERVE_WORKTREES_ROOT:-$root}"

# Worker-tools mirror log (issue #84): log-worker-tool.sh's PostToolUse
# mirror JSONL, read by the worker inspector to populate the "activity"
# field. Same override seam as the hook script itself so tests can point
# 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}"

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
Expand All @@ -112,6 +127,7 @@ COCKPIT_SERVE_EVENTS_FILE="$events_file" \
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" \
exec node - <<'NODE_SERVE'
const http = require("http");
const fs = require("fs");
Expand All @@ -126,6 +142,11 @@ const GH_REFRESH_SECONDS = parseInt(process.env.COCKPIT_SERVE_GH_REFRESH, 10) ||
const GH_REFRESH_MS = GH_REFRESH_SECONDS * 1000;
const TMP_OUT = process.env.COCKPIT_SERVE_TMP_OUT;
const WORKTREES_ROOT = process.env.COCKPIT_SERVE_WORKTREES_ROOT || process.cwd();
// Worker-tools mirror log (issue #84): env override for testability, else
// the default log-worker-tool.sh itself writes to.
const WORKER_TOOLS_FILE =
process.env.CLAUDE_WORKER_TOOLS_FILE ||
path.join(process.cwd(), ".claude", "state", "worker-tools.jsonl");

// 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
Expand Down Expand Up @@ -318,6 +339,11 @@ function clientScript() {
var timelineList = document.createElement("ul");
timelineList.id = "drawer-timeline";

var activityHeading = document.createElement("h3");
activityHeading.textContent = "Live activity (newest first)";
var activityList = document.createElement("ul");
activityList.id = "drawer-activity";

drawer.appendChild(closeBtn);
drawer.appendChild(title);
drawer.appendChild(breadcrumbsHeading);
Expand All @@ -326,6 +352,8 @@ function clientScript() {
drawer.appendChild(forensicsBody);
drawer.appendChild(timelineHeading);
drawer.appendChild(timelineList);
drawer.appendChild(activityHeading);
drawer.appendChild(activityList);
document.body.appendChild(drawer);
return drawer;
}
Expand Down Expand Up @@ -386,6 +414,21 @@ function clientScript() {
});
}

var activityList = d.querySelector("#drawer-activity");
activityList.textContent = "";
var activity = (data && data.activity) || [];
if (activity.length === 0) {
var noActivity = document.createElement("li");
noActivity.textContent = "(no activity yet)";
activityList.appendChild(noActivity);
} else {
activity.forEach(function (rec) {
var li = document.createElement("li");
li.textContent = (rec.ts || "") + " " + (rec.tool || "") + " " + (rec.summary || "");
activityList.appendChild(li);
});
}

d.style.display = "block";
}

Expand Down Expand Up @@ -529,6 +572,25 @@ function readEventsAll() {
return out;
}

// Worker-tools mirror log (issue #84) reader: same JSONL-tolerant pattern as
// readEventsAll() -- skip blank/malformed lines, exclude arrays. A
// missing/unreadable file yields [] (never throws), matching the hook
// script's own never-block contract.
function readWorkerToolsAll() {
let text = "";
try { text = fs.readFileSync(WORKER_TOOLS_FILE, "utf8"); } catch (e) { return []; }
const out = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed);
if (obj && typeof obj === "object" && !Array.isArray(obj)) out.push(obj);
} catch (e) { /* skip malformed line */ }
}
return out;
}

function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
Expand Down Expand Up @@ -671,7 +733,20 @@ function handleWorkerInspector(req, res, rawRole, rawTask) {
error: "no worktree found for task " + task,
};

const body = JSON.stringify({ role, task, timeline, breadcrumbs, worktree });
// Live activity (issue #84): worker-tools mirror records whose `path`
// falls under the matched worktree, NEWEST FIRST, capped to the most
// recent 20. No worktree found -> []; never throws.
let activity = [];
if (wtPath) {
const allTools = readWorkerToolsAll();
const matchingTools = allTools.filter((rec) => {
const recPath = rec && rec.path != null ? String(rec.path) : "";
return recPath === wtPath || recPath.startsWith(wtPath + "/");
});
activity = matchingTools.slice().reverse().slice(0, 20);
}

const body = JSON.stringify({ role, task, timeline, breadcrumbs, worktree, activity });
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
res.end(body);
} catch (e) {
Expand Down
79 changes: 78 additions & 1 deletion .claude/scripts/cockpit.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,14 @@ insp_port="$(node -e '
')"
insp_serve_log="$work/serve-inspector.log"
: > "$insp_serve_log"
COCKPIT_SERVE_WORKTREES_ROOT="$insp_root" bash "$cockpit_serve" "$insp_port" --fixtures "$work/fixtures-inspector" >"$insp_serve_log" 2>&1 &
# CLAUDE_WORKER_TOOLS_FILE (issue #84) deliberately points at a file that
# does not exist, so this primary server instance also covers the
# "no worker-tools log present" case below -- activity must come back as an
# empty array, never missing/an error, and never accidentally pick up this
# checkout's own real (gitignored) worker-tools.jsonl.
COCKPIT_SERVE_WORKTREES_ROOT="$insp_root" \
CLAUDE_WORKER_TOOLS_FILE="$work/no-such-worker-tools.jsonl" \
bash "$cockpit_serve" "$insp_port" --fixtures "$work/fixtures-inspector" >"$insp_serve_log" 2>&1 &
server_pid=$!

insp_ready=0
Expand All @@ -405,6 +412,18 @@ check "worker-inspector 70a: timeline has both events, NEWEST FIRST, plus breadc
if (typeof wt.diffstat !== "string") throw new Error("diffstat field missing/wrong type");
' "$resp_70a"

# Activity (issue #84), no-log-file case: CLAUDE_WORKER_TOOLS_FILE for this
# server points at a nonexistent file -- activity must be an empty array
# (not missing, not an error), and the rest of the response (timeline,
# worktree) must still be intact.
check "worker-inspector 70a: no worker-tools log present -> activity is [] (not missing/error), timeline/worktree intact" node -e '
const got = JSON.parse(process.argv[1]);
if (!Array.isArray(got.activity)) throw new Error("expected activity to be an array, got " + JSON.stringify(got.activity));
if (got.activity.length !== 0) throw new Error("expected empty activity with no log file, got " + JSON.stringify(got.activity));
if (!Array.isArray(got.timeline) || got.timeline.length !== 2) throw new Error("timeline should still be intact");
if (!got.worktree || got.worktree.found !== true) throw new Error("worktree should still be intact");
' "$resp_70a"

resp_70b="$(curl -s "http://127.0.0.1:$insp_port/api/worker/reviewer/70b" 2>/dev/null)"
check "worker-inspector 70b: worktree found via BRANCH-NAME fallback (not conventional dir name)" node -e '
const got = JSON.parse(process.argv[1]);
Expand Down Expand Up @@ -449,6 +468,64 @@ check "worker-inspector: server still serves a valid request after malformed/tra
kill "$server_pid" >/dev/null 2>&1 || true
wait "$server_pid" 2>/dev/null || true
server_pid=""

# ---------------------------------------------------------------------------
# 6b. Worker inspector "activity" field (issue #84): a populated worker-tools
# mirror log (log-worker-tool.sh's JSONL), pointed at via
# CLAUDE_WORKER_TOOLS_FILE. Records under 70a's worktree path must come
# back NEWEST FIRST; a record under a DIFFERENT path (70b's worktree)
# must be excluded.
# ---------------------------------------------------------------------------
insp_wtools_file="$work/inspector-worker-tools.jsonl"
insp_wt_70a="$insp_root/.claude/worktrees/issue-70a"
cat >"$insp_wtools_file" <<EOF
{"ts":"2026-01-01T02:00:00Z","tool":"Bash","summary":"git status","path":"$insp_wt_70a"}
{"ts":"2026-01-01T02:01:00Z","tool":"Edit","summary":"$insp_wt_70a/src/foo.js","path":"$insp_wt_70a/src/foo.js"}
{"ts":"2026-01-01T02:02:00Z","tool":"Bash","summary":"git status","path":"$insp_wt_70b"}
{"ts":"2026-01-01T02:03:00Z","tool":"Write","summary":"$insp_wt_70a/src/bar.js","path":"$insp_wt_70a/src/bar.js"}
EOF

insp_port2="$(node -e '
const s = require("net").createServer();
s.listen(0, "127.0.0.1", () => { console.log(s.address().port); s.close(); });
')"
insp_serve_log2="$work/serve-inspector-activity.log"
: > "$insp_serve_log2"
COCKPIT_SERVE_WORKTREES_ROOT="$insp_root" \
CLAUDE_WORKER_TOOLS_FILE="$insp_wtools_file" \
bash "$cockpit_serve" "$insp_port2" --fixtures "$work/fixtures-inspector" >"$insp_serve_log2" 2>&1 &
server_pid2=$!

insp_ready2=0
for _ in $(seq 1 50); do
grep -q "cockpit serving" "$insp_serve_log2" 2>/dev/null && { insp_ready2=1; break; }
kill -0 "$server_pid2" 2>/dev/null || break
sleep 0.2
done
check "worker-inspector (activity) server starts within the readiness timeout" [ "$insp_ready2" -eq 1 ]

resp_70a_activity="$(curl -s "http://127.0.0.1:$insp_port2/api/worker/implementer/70a" 2>/dev/null)"
check "worker-inspector 70a: activity present, is an array, NEWEST FIRST, excludes records under a different worktree path" node -e '
const got = JSON.parse(process.argv[1]);
if (!Array.isArray(got.activity)) throw new Error("expected activity to be an array, got " + JSON.stringify(got.activity));
if (got.activity.length !== 3) throw new Error("expected 3 activity records (70a-scoped only), got " + JSON.stringify(got.activity));
const summaries = got.activity.map((r) => r.summary);
const wantOrder = [got.activity[0], got.activity[1], got.activity[2]].map((r) => r.ts);
if (wantOrder[0] !== "2026-01-01T02:03:00Z") throw new Error("expected newest (bar.js write) first, got " + JSON.stringify(got.activity));
if (wantOrder[1] !== "2026-01-01T02:01:00Z") throw new Error("expected foo.js edit second, got " + JSON.stringify(got.activity));
if (wantOrder[2] !== "2026-01-01T02:00:00Z") throw new Error("expected git status last (oldest), got " + JSON.stringify(got.activity));
if (summaries.some((s) => String(s).indexOf("bar.js") === -1 && String(s).indexOf("foo.js") === -1 && s !== "git status")) {
throw new Error("unexpected summary content: " + JSON.stringify(summaries));
}
if (got.activity.some((r) => r.path && r.path.indexOf("issue-70a") === -1)) {
throw new Error("activity leaked a record outside 70a worktree: " + JSON.stringify(got.activity));
}
' "$resp_70a_activity"

kill "$server_pid2" >/dev/null 2>&1 || true
wait "$server_pid2" 2>/dev/null || true
server_pid2=""

IG worktree remove --force "$insp_root/.claude/worktrees/issue-70a" >/dev/null 2>&1 || true
IG worktree remove --force "$insp_wt_70b" >/dev/null 2>&1 || true

Expand Down
21 changes: 14 additions & 7 deletions .claude/scripts/log-worker-tool.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
#!/usr/bin/env bash
# log-worker-tool.sh — PostToolUse mirror hook (issue #71, Cockpit 3c SPIKE).
# log-worker-tool.sh — PostToolUse mirror hook (issue #71, Cockpit 3c SPIKE;
# enabled by default as of issue #84).
#
# NOT ENABLED BY DEFAULT — see the "Wiring (disabled by default)" section
# below for the copy-paste snippet that turns it on. This is a spike to
# gather evidence on signal/noise and log growth before committing to always-
# on tool mirroring.
# ENABLED BY DEFAULT — wired into .claude/settings.json's hooks.PostToolUse
# (see the "Wiring" section below, kept here as reference for what's live).
# The issue #71 spike gathered evidence on signal/noise and log growth
# (readable, truncated/collapsed Bash summaries; rotation via atomic mv keeps
# the JSONL valid; manageable lines/hour for a typical fan-out) and issue #84
# concluded: enable it. Cockpit's worker inspector (cockpit-serve.sh) reads
# this log to power the "Live activity" drawer section.
#
# Reads the PostToolUse hook event JSON on STDIN and mirrors ONLY the tools
# Bash, Edit, Write (every other tool — Read, Grep, Glob, ... — is silently
Expand Down Expand Up @@ -42,7 +46,8 @@
# agent never sees this happen and it costs zero agent tokens.
#
# ---------------------------------------------------------------------------
# Wiring (DISABLED by default — copy into .claude/settings.json to enable):
# Wiring — this is the wiring now live in .claude/settings.json's
# hooks.PostToolUse array (kept here as reference, not a to-do):
#
# "PostToolUse": [
# {
Expand All @@ -56,7 +61,9 @@
# }
# ]
#
# A "go" decision is this one array entry — nothing else to wire up.
# The evaluation (issue #84) concluded: enable — this is a single array
# entry alongside the existing Edit|Write -> lint entry, nothing else to
# wire up.
# ---------------------------------------------------------------------------
set -u

Expand Down
2 changes: 1 addition & 1 deletion .claude/scripts/log-worker-tool.test.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# log-worker-tool.test.sh — offline smoke test for log-worker-tool.sh
# (issue #71, Cockpit 3c SPIKE).
# (issue #71, Cockpit 3c SPIKE; enabled by default as of issue #84).
#
# Asserts: Bash/Edit/Write tool calls yield exactly one valid-JSON record
# each with the expected tool/summary/path fields; non-mirrored tools (Read,
Expand Down
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/gate.sh\" lint"
}
]
},
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/log-worker-tool.sh\""
}
]
}
],
"Stop": [
Expand Down
Loading