fix: harden loop status transcript scanning

This commit is contained in:
Affaan Mustafa
2026-04-30 05:48:31 -04:00
committed by Affaan Mustafa
parent b8452dc108
commit fb6cc8548b
3 changed files with 146 additions and 26 deletions

View File

@@ -34,6 +34,8 @@ tool calls that have no matching `tool_result`.
- `ecc loop-status --json` emits machine-readable status for recent local - `ecc loop-status --json` emits machine-readable status for recent local
Claude transcripts. Claude transcripts.
- `ecc loop-status --home <dir>` scans a different home directory when
inspecting another local profile or mounted workspace.
- `ecc loop-status --transcript <session.jsonl>` inspects one transcript - `ecc loop-status --transcript <session.jsonl>` inspects one transcript
directly. directly.
- `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash - `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash

View File

@@ -22,6 +22,7 @@ function usage() {
' --limit <n> Maximum recent transcripts to inspect (default: 10)', ' --limit <n> Maximum recent transcripts to inspect (default: 10)',
' --bash-timeout-seconds <n> Age before a pending Bash call is stale (default: 1800)', ' --bash-timeout-seconds <n> Age before a pending Bash call is stale (default: 1800)',
' --wake-grace-multiplier <n> ScheduleWakeup grace multiplier (default: 2)', ' --wake-grace-multiplier <n> ScheduleWakeup grace multiplier (default: 2)',
' --now <time> Override current time (ISO, epoch ms, or "now")',
'', '',
'Examples:', 'Examples:',
' node scripts/loop-status.js --json', ' node scripts/loop-status.js --json',
@@ -45,6 +46,14 @@ function readPositiveNumber(value, flagName) {
return number; return number;
} }
function readPositiveInteger(value, flagName) {
const number = readPositiveNumber(value, flagName);
if (!Number.isInteger(number)) {
throw new Error(`${flagName} must be a positive integer`);
}
return number;
}
function parseArgs(argv) { function parseArgs(argv) {
const args = argv.slice(2); const args = argv.slice(2);
const options = { const options = {
@@ -72,7 +81,7 @@ function parseArgs(argv) {
options.transcriptPaths.push(readValue(args, index, arg)); options.transcriptPaths.push(readValue(args, index, arg));
index += 1; index += 1;
} else if (arg === '--limit') { } else if (arg === '--limit') {
options.limit = readPositiveNumber(readValue(args, index, arg), arg); options.limit = readPositiveInteger(readValue(args, index, arg), arg);
index += 1; index += 1;
} else if (arg === '--bash-timeout-seconds') { } else if (arg === '--bash-timeout-seconds') {
options.bashTimeoutSeconds = readPositiveNumber(readValue(args, index, arg), arg); options.bashTimeoutSeconds = readPositiveNumber(readValue(args, index, arg), arg);
@@ -91,6 +100,16 @@ function parseArgs(argv) {
return options; return options;
} }
function normalizeOptions(options = {}) {
return {
...options,
bashTimeoutSeconds: options.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS,
limit: options.limit ?? DEFAULT_LIMIT,
transcriptPaths: options.transcriptPaths || [],
wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER,
};
}
function getHomeDir(options = {}) { function getHomeDir(options = {}) {
if (options.home) { if (options.home) {
return path.resolve(options.home); return path.resolve(options.home);
@@ -103,7 +122,13 @@ function getNow(options = {}) {
return new Date(); return new Date();
} }
const now = new Date(options.now); if (options.now === 'now') {
return new Date();
}
const now = /^\d+$/.test(String(options.now))
? new Date(Number(options.now))
: new Date(options.now);
if (Number.isNaN(now.getTime())) { if (Number.isNaN(now.getTime())) {
throw new Error('--now must be a valid timestamp'); throw new Error('--now must be a valid timestamp');
} }
@@ -128,20 +153,42 @@ function walkJsonlFiles(dir, files = []) {
} }
function findTranscriptPaths(options = {}) { function findTranscriptPaths(options = {}) {
const normalizedOptions = normalizeOptions(options);
if (options.transcriptPaths && options.transcriptPaths.length > 0) { if (options.transcriptPaths && options.transcriptPaths.length > 0) {
return options.transcriptPaths.map(transcriptPath => path.resolve(transcriptPath)); return {
errors: [],
transcriptPaths: normalizedOptions.transcriptPaths.map(transcriptPath => path.resolve(transcriptPath)),
};
} }
const homeDir = getHomeDir(options); const homeDir = getHomeDir(normalizedOptions);
const transcriptRoot = path.join(homeDir, '.claude', 'projects'); const transcriptRoot = path.join(homeDir, '.claude', 'projects');
return walkJsonlFiles(transcriptRoot) const errors = [];
.map(transcriptPath => ({ const transcriptEntries = [];
for (const transcriptPath of walkJsonlFiles(transcriptRoot)) {
try {
transcriptEntries.push({
transcriptPath, transcriptPath,
mtimeMs: fs.statSync(transcriptPath).mtimeMs, mtimeMs: fs.statSync(transcriptPath).mtimeMs,
})) });
} catch (error) {
errors.push({
code: error.code || null,
message: error.message,
transcriptPath,
});
}
}
return {
errors,
transcriptPaths: transcriptEntries
.sort((left, right) => right.mtimeMs - left.mtimeMs) .sort((left, right) => right.mtimeMs - left.mtimeMs)
.slice(0, options.limit) .slice(0, normalizedOptions.limit)
.map(entry => entry.transcriptPath); .map(entry => entry.transcriptPath),
};
} }
function parseTimestamp(value) { function parseTimestamp(value) {
@@ -302,8 +349,9 @@ function buildRecommendation(signals) {
} }
function analyzeTranscript(transcriptPath, options = {}) { function analyzeTranscript(transcriptPath, options = {}) {
const normalizedOptions = normalizeOptions(options);
const absoluteTranscriptPath = path.resolve(transcriptPath); const absoluteTranscriptPath = path.resolve(transcriptPath);
const now = options.nowDate || getNow(options); const now = normalizedOptions.nowDate || getNow(normalizedOptions);
const nowMs = now.getTime(); const nowMs = now.getTime();
const { entries, parseErrors } = readJsonlEntries(absoluteTranscriptPath); const { entries, parseErrors } = readJsonlEntries(absoluteTranscriptPath);
const pendingTools = new Map(); const pendingTools = new Map();
@@ -369,12 +417,12 @@ function analyzeTranscript(transcriptPath, options = {}) {
const scheduledAt = parseTimestamp(latestWake.scheduledAt); const scheduledAt = parseTimestamp(latestWake.scheduledAt);
const dueAt = parseTimestamp(latestWake.dueAt); const dueAt = parseTimestamp(latestWake.dueAt);
const thresholdMs = scheduledAt const thresholdMs = scheduledAt
? scheduledAt.getTime() + latestWake.delaySeconds * options.wakeGraceMultiplier * 1000 ? scheduledAt.getTime() + latestWake.delaySeconds * normalizedOptions.wakeGraceMultiplier * 1000
: null; : null;
const hasAssistantProgressAfterDue = Boolean( const hasAssistantProgressAfterDue = Boolean(
dueAt dueAt
&& latestAssistantProgressAt && latestAssistantProgressAt
&& latestAssistantProgressAt.getTime() > dueAt.getTime() && latestAssistantProgressAt.getTime() >= dueAt.getTime()
); );
if (thresholdMs && nowMs >= thresholdMs && !hasAssistantProgressAfterDue) { if (thresholdMs && nowMs >= thresholdMs && !hasAssistantProgressAfterDue) {
@@ -390,12 +438,16 @@ function analyzeTranscript(transcriptPath, options = {}) {
} }
for (const tool of pendingToolList) { for (const tool of pendingToolList) {
if (tool.name === 'Bash' && tool.ageSeconds !== null && tool.ageSeconds >= options.bashTimeoutSeconds) { if (
tool.name === 'Bash'
&& tool.ageSeconds !== null
&& tool.ageSeconds >= normalizedOptions.bashTimeoutSeconds
) {
signals.push({ signals.push({
ageSeconds: tool.ageSeconds, ageSeconds: tool.ageSeconds,
command: tool.command, command: tool.command,
startedAt: tool.startedAt, startedAt: tool.startedAt,
thresholdSeconds: options.bashTimeoutSeconds, thresholdSeconds: normalizedOptions.bashTimeoutSeconds,
toolUseId: tool.toolUseId, toolUseId: tool.toolUseId,
type: 'pending_bash_tool_result', type: 'pending_bash_tool_result',
}); });
@@ -418,14 +470,28 @@ function analyzeTranscript(transcriptPath, options = {}) {
} }
function buildStatus(options = {}) { function buildStatus(options = {}) {
const nowDate = getNow(options); const normalizedOptions = normalizeOptions(options);
const nowDate = getNow(normalizedOptions);
const mergedOptions = { const mergedOptions = {
...options, ...normalizedOptions,
nowDate, nowDate,
}; };
const homeDir = getHomeDir(options); const homeDir = getHomeDir(normalizedOptions);
const transcriptPaths = findTranscriptPaths(options); const { errors, transcriptPaths } = findTranscriptPaths(normalizedOptions);
const sessions = transcriptPaths.map(transcriptPath => analyzeTranscript(transcriptPath, mergedOptions)); const sessions = [];
for (const transcriptPath of transcriptPaths) {
try {
sessions.push(analyzeTranscript(transcriptPath, mergedOptions));
} catch (error) {
errors.push({
code: error.code || null,
message: error.message,
transcriptPath,
});
}
}
sessions.sort((left, right) => { sessions.sort((left, right) => {
if (left.state !== right.state) { if (left.state !== right.state) {
return left.state === 'attention' ? -1 : 1; return left.state === 'attention' ? -1 : 1;
@@ -435,15 +501,16 @@ function buildStatus(options = {}) {
return { return {
generatedAt: nowDate.toISOString(), generatedAt: nowDate.toISOString(),
errors,
schemaVersion: 'ecc.loop-status.v1', schemaVersion: 'ecc.loop-status.v1',
sessions, sessions,
source: { source: {
bashTimeoutSeconds: options.bashTimeoutSeconds, bashTimeoutSeconds: normalizedOptions.bashTimeoutSeconds,
homeDir, homeDir,
limit: options.limit, limit: normalizedOptions.limit,
transcriptCount: transcriptPaths.length, transcriptCount: transcriptPaths.length,
transcriptRoot: path.join(homeDir, '.claude', 'projects'), transcriptRoot: path.join(homeDir, '.claude', 'projects'),
wakeGraceMultiplier: options.wakeGraceMultiplier, wakeGraceMultiplier: normalizedOptions.wakeGraceMultiplier,
}, },
}; };
} }
@@ -456,11 +523,18 @@ function formatSignals(signals) {
} }
function formatText(payload) { function formatText(payload) {
const skippedLines = payload.errors.map(error => ` - ${error.transcriptPath}: ${error.message}`);
if (payload.sessions.length === 0) { if (payload.sessions.length === 0) {
return [ const lines = [
`ECC loop status (${payload.generatedAt})`, `ECC loop status (${payload.generatedAt})`,
`No Claude transcript JSONL files found under ${payload.source.transcriptRoot}.`, `No Claude transcript JSONL files found under ${payload.source.transcriptRoot}.`,
].join('\n'); ];
if (skippedLines.length > 0) {
lines.push('Skipped transcript errors:');
lines.push(...skippedLines);
}
return lines.join('\n');
} }
const lines = [`ECC loop status (${payload.generatedAt})`]; const lines = [`ECC loop status (${payload.generatedAt})`];
@@ -470,6 +544,10 @@ function formatText(payload) {
lines.push(` signals: ${formatSignals(session.signals)}`); lines.push(` signals: ${formatSignals(session.signals)}`);
lines.push(` action: ${session.recommendedAction}`); lines.push(` action: ${session.recommendedAction}`);
} }
if (skippedLines.length > 0) {
lines.push('Skipped transcript errors:');
lines.push(...skippedLines);
}
return lines.join('\n'); return lines.join('\n');
} }

View File

@@ -9,6 +9,7 @@ const path = require('path');
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js');
const { analyzeTranscript } = require('../../scripts/loop-status');
const NOW = '2026-04-30T10:00:00.000Z'; const NOW = '2026-04-30T10:00:00.000Z';
function run(args = [], options = {}) { function run(args = [], options = {}) {
@@ -164,6 +165,26 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('analyzeTranscript applies default thresholds when called directly', () => {
const homeDir = createTempHome();
try {
const transcriptPath = writeTranscript(homeDir, '-Users-affoon-project-direct', 'session-direct.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-direct', 'toolu_direct_wake', 'ScheduleWakeup', {
delaySeconds: 300,
reason: 'Direct API default threshold check',
}),
]);
const session = analyzeTranscript(transcriptPath, { now: NOW });
assert.strictEqual(session.state, 'attention');
assert.ok(session.signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('reports stale Bash tool_use entries without matching tool_result', () => { if (test('reports stale Bash tool_use entries without matching tool_result', () => {
const homeDir = createTempHome(); const homeDir = createTempHome();
@@ -280,6 +301,25 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('continues when an explicit transcript path cannot be read', () => {
const missingTranscript = path.join(os.tmpdir(), `missing-loop-status-${Date.now()}.jsonl`);
const result = run(['--transcript', missingTranscript, '--now', NOW, '--json']);
assert.strictEqual(result.code, 0, result.stderr);
const payload = parsePayload(result.stdout);
assert.deepStrictEqual(payload.sessions, []);
assert.strictEqual(payload.errors.length, 1);
assert.strictEqual(payload.errors[0].transcriptPath, missingTranscript);
})) passed++; else failed++;
if (test('rejects non-integer limit values', () => {
const result = run(['--limit', '1.5']);
assert.strictEqual(result.code, 1);
assert.match(result.stderr, /--limit must be a positive integer/);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0);
} }