feat: write loop-status snapshots

This commit is contained in:
Affaan Mustafa
2026-04-30 11:41:11 -04:00
committed by Affaan Mustafa
parent bb40978e31
commit 20154ddb22
3 changed files with 160 additions and 0 deletions

View File

@@ -49,6 +49,9 @@ tool calls that have no matching `tool_result`.
number of times, then exits with the highest status seen. number of times, then exits with the highest status seen.
- `ecc loop-status --watch --watch-count 3` emits a bounded watch stream for - `ecc loop-status --watch --watch-count 3` emits a bounded watch stream for
scripts and handoffs. scripts and handoffs.
- `ecc loop-status --watch --write-dir ~/.claude/loops` maintains
`index.json` and per-session JSON snapshots for sibling terminals or
watchdog scripts.
## Watch Mode ## Watch Mode
@@ -56,6 +59,18 @@ When `--watch` is present, refresh status periodically. With `--json`, each
refresh is emitted as one JSON object per line so another terminal or script can refresh is emitted as one JSON object per line so another terminal or script can
consume the stream. consume the stream.
## Snapshot Files
Use `--write-dir <dir>` when a separate process needs to inspect loop state
without waiting for the current Claude session to dequeue `/loop-status`. The
CLI writes:
- `index.json` with one row per inspected session.
- `<session-id>.json` with the full status payload for that session.
These files are snapshots of local transcript analysis. They do not control or
timeout Claude Code runtime tool calls.
## Arguments ## Arguments
$ARGUMENTS: $ARGUMENTS:

View File

@@ -4,6 +4,7 @@
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60; const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60;
const DEFAULT_LIMIT = 10; const DEFAULT_LIMIT = 10;
@@ -28,6 +29,7 @@ function usage() {
' --watch Refresh status until interrupted', ' --watch Refresh status until interrupted',
' --watch-count <n> Stop after n watch refreshes', ' --watch-count <n> Stop after n watch refreshes',
' --watch-interval-seconds <n> Seconds between watch refreshes (default: 5)', ' --watch-interval-seconds <n> Seconds between watch refreshes (default: 5)',
' --write-dir <dir> Write index.json and per-session status snapshots',
'', '',
'Examples:', 'Examples:',
' node scripts/loop-status.js --json', ' node scripts/loop-status.js --json',
@@ -74,6 +76,7 @@ function parseArgs(argv) {
watchCount: null, watchCount: null,
wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER, wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER,
watchIntervalSeconds: DEFAULT_WATCH_INTERVAL_SECONDS, watchIntervalSeconds: DEFAULT_WATCH_INTERVAL_SECONDS,
writeDir: null,
}; };
for (let index = 0; index < args.length; index += 1) { for (let index = 0; index < args.length; index += 1) {
@@ -111,6 +114,9 @@ function parseArgs(argv) {
} else if (arg === '--watch-interval-seconds') { } else if (arg === '--watch-interval-seconds') {
options.watchIntervalSeconds = readPositiveNumber(readValue(args, index, arg), arg); options.watchIntervalSeconds = readPositiveNumber(readValue(args, index, arg), arg);
index += 1; index += 1;
} else if (arg === '--write-dir') {
options.writeDir = readValue(args, index, arg);
index += 1;
} else { } else {
throw new Error(`Unknown option: ${arg}`); throw new Error(`Unknown option: ${arg}`);
} }
@@ -134,6 +140,7 @@ function normalizeOptions(options = {}) {
watchCount: options.watchCount ?? null, watchCount: options.watchCount ?? null,
wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER, wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER,
watchIntervalSeconds: options.watchIntervalSeconds ?? DEFAULT_WATCH_INTERVAL_SECONDS, watchIntervalSeconds: options.watchIntervalSeconds ?? DEFAULT_WATCH_INTERVAL_SECONDS,
writeDir: options.writeDir || null,
}; };
} }
@@ -603,6 +610,81 @@ function formatText(payload) {
return lines.join('\n'); return lines.join('\n');
} }
function hashString(value) {
return crypto.createHash('sha256').update(String(value)).digest('hex');
}
function sanitizeSnapshotName(value, fallback = 'session') {
const raw = String(value || '').trim() || fallback;
const sanitized = raw.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^_+|_+$/g, '');
if (sanitized && sanitized.length <= 96) {
return sanitized;
}
const prefix = sanitized ? sanitized.slice(0, 48).replace(/[._-]+$/g, '') : fallback;
return `${prefix || fallback}-${hashString(raw).slice(0, 12)}`;
}
function atomicWriteJson(filePath, payload) {
const data = JSON.stringify(payload, null, 2) + '\n';
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
fs.writeFileSync(tempPath, data, 'utf8');
fs.renameSync(tempPath, filePath);
}
function getSnapshotPath(outputDir, session, usedNames) {
const baseName = sanitizeSnapshotName(session.sessionId);
let fileName = `${baseName}.json`;
if (usedNames.has(fileName)) {
fileName = `${baseName}-${hashString(session.transcriptPath || session.sessionId).slice(0, 8)}.json`;
}
usedNames.add(fileName);
return path.join(outputDir, fileName);
}
function writeStatusSnapshots(payload, writeDir) {
if (!writeDir) {
return null;
}
const outputDir = path.resolve(writeDir);
fs.mkdirSync(outputDir, { recursive: true });
const usedNames = new Set();
const sessions = payload.sessions.map(session => {
const snapshotPath = getSnapshotPath(outputDir, session, usedNames);
atomicWriteJson(snapshotPath, {
generatedAt: payload.generatedAt,
schemaVersion: 'ecc.loop-status.session.v1',
session,
});
return {
lastEventAt: session.lastEventAt,
sessionId: session.sessionId,
signalTypes: session.signals.map(signal => signal.type),
snapshotPath,
state: session.state,
transcriptPath: session.transcriptPath,
};
});
const indexPath = path.join(outputDir, 'index.json');
atomicWriteJson(indexPath, {
errors: payload.errors,
generatedAt: payload.generatedAt,
schemaVersion: 'ecc.loop-status.index.v1',
sessionCount: payload.sessions.length,
sessions,
source: payload.source,
});
return {
indexPath,
sessionCount: payload.sessions.length,
};
}
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@@ -635,6 +717,7 @@ async function runWatch(options) {
console.log(''); console.log('');
} }
const payload = buildStatus(normalizedOptions); const payload = buildStatus(normalizedOptions);
writeStatusSnapshots(payload, normalizedOptions.writeDir);
writeStatus(payload, normalizedOptions); writeStatus(payload, normalizedOptions);
exitCode = Math.max(exitCode, getStatusExitCode(payload)); exitCode = Math.max(exitCode, getStatusExitCode(payload));
iteration += 1; iteration += 1;
@@ -665,6 +748,7 @@ async function main() {
} }
const payload = buildStatus(options); const payload = buildStatus(options);
writeStatusSnapshots(payload, options.writeDir);
writeStatus(payload, options); writeStatus(payload, options);
if (options.exitCode) { if (options.exitCode) {
process.exitCode = getStatusExitCode(payload); process.exitCode = getStatusExitCode(payload);
@@ -686,4 +770,5 @@ module.exports = {
getStatusExitCode, getStatusExitCode,
parseArgs, parseArgs,
runWatch, runWatch,
writeStatusSnapshots,
}; };

View File

@@ -414,6 +414,17 @@ function runTests() {
assert.strictEqual(options.watchIntervalSeconds, 0.01); assert.strictEqual(options.watchIntervalSeconds, 0.01);
})) passed++; else failed++; })) passed++; else failed++;
if (test('parses write-dir snapshot option', () => {
const options = parseArgs([
'node',
'scripts/loop-status.js',
'--write-dir',
'/tmp/ecc-loop-snapshots',
]);
assert.strictEqual(options.writeDir, '/tmp/ecc-loop-snapshots');
})) passed++; else failed++;
if (test('exit-code mode returns 2 when attention signals are present', () => { if (test('exit-code mode returns 2 when attention signals are present', () => {
const homeDir = createTempHome(); const homeDir = createTempHome();
@@ -534,6 +545,55 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('writes per-session status snapshots and index when write-dir is set', () => {
const homeDir = createTempHome();
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-snapshots-'));
try {
writeTranscript(homeDir, '-Users-affoon-project-snapshot', 'session-snapshot.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-snapshot', 'toolu_snapshot', 'ScheduleWakeup', {
delaySeconds: 300,
reason: 'Loop checkpoint',
}),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
snapshotDir,
]);
assert.strictEqual(result.code, 0, result.stderr);
const stdoutPayload = parsePayload(result.stdout);
assert.strictEqual(stdoutPayload.schemaVersion, 'ecc.loop-status.v1');
const indexPath = path.join(snapshotDir, 'index.json');
const snapshotPath = path.join(snapshotDir, 'session-snapshot.json');
assert.ok(fs.existsSync(indexPath), 'write-dir should include an index.json file');
assert.ok(fs.existsSync(snapshotPath), 'write-dir should include a per-session snapshot');
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
assert.strictEqual(indexPayload.schemaVersion, 'ecc.loop-status.index.v1');
assert.strictEqual(indexPayload.sessions.length, 1);
assert.strictEqual(indexPayload.sessions[0].sessionId, 'session-snapshot');
assert.strictEqual(indexPayload.sessions[0].state, 'attention');
assert.strictEqual(indexPayload.sessions[0].snapshotPath, snapshotPath);
const snapshotPayload = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
assert.strictEqual(snapshotPayload.generatedAt, NOW);
assert.strictEqual(snapshotPayload.session.sessionId, 'session-snapshot');
assert.ok(snapshotPayload.session.signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) 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);
} }