mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-30 22:13:28 +08:00
fix: harden loop status transcript scanning
This commit is contained in:
committed by
Affaan Mustafa
parent
b8452dc108
commit
fb6cc8548b
@@ -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
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
transcriptPath,
|
|
||||||
mtimeMs: fs.statSync(transcriptPath).mtimeMs,
|
for (const transcriptPath of walkJsonlFiles(transcriptRoot)) {
|
||||||
}))
|
try {
|
||||||
|
transcriptEntries.push({
|
||||||
|
transcriptPath,
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user