diff --git a/commands/loop-status.md b/commands/loop-status.md index 4a8bccd5..7a05020c 100644 --- a/commands/loop-status.md +++ b/commands/loop-status.md @@ -49,6 +49,9 @@ tool calls that have no matching `tool_result`. number of times, then exits with the highest status seen. - `ecc loop-status --watch --watch-count 3` emits a bounded watch stream for 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 @@ -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 consume the stream. +## Snapshot Files + +Use `--write-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. +- `.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: diff --git a/scripts/loop-status.js b/scripts/loop-status.js index a52dc0ff..1b0912c2 100644 --- a/scripts/loop-status.js +++ b/scripts/loop-status.js @@ -4,6 +4,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const crypto = require('crypto'); const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60; const DEFAULT_LIMIT = 10; @@ -28,6 +29,7 @@ function usage() { ' --watch Refresh status until interrupted', ' --watch-count Stop after n watch refreshes', ' --watch-interval-seconds Seconds between watch refreshes (default: 5)', + ' --write-dir Write index.json and per-session status snapshots', '', 'Examples:', ' node scripts/loop-status.js --json', @@ -74,6 +76,7 @@ function parseArgs(argv) { watchCount: null, wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER, watchIntervalSeconds: DEFAULT_WATCH_INTERVAL_SECONDS, + writeDir: null, }; for (let index = 0; index < args.length; index += 1) { @@ -111,6 +114,9 @@ function parseArgs(argv) { } else if (arg === '--watch-interval-seconds') { options.watchIntervalSeconds = readPositiveNumber(readValue(args, index, arg), arg); index += 1; + } else if (arg === '--write-dir') { + options.writeDir = readValue(args, index, arg); + index += 1; } else { throw new Error(`Unknown option: ${arg}`); } @@ -134,6 +140,7 @@ function normalizeOptions(options = {}) { watchCount: options.watchCount ?? null, wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER, watchIntervalSeconds: options.watchIntervalSeconds ?? DEFAULT_WATCH_INTERVAL_SECONDS, + writeDir: options.writeDir || null, }; } @@ -603,6 +610,81 @@ function formatText(payload) { 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) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -635,6 +717,7 @@ async function runWatch(options) { console.log(''); } const payload = buildStatus(normalizedOptions); + writeStatusSnapshots(payload, normalizedOptions.writeDir); writeStatus(payload, normalizedOptions); exitCode = Math.max(exitCode, getStatusExitCode(payload)); iteration += 1; @@ -665,6 +748,7 @@ async function main() { } const payload = buildStatus(options); + writeStatusSnapshots(payload, options.writeDir); writeStatus(payload, options); if (options.exitCode) { process.exitCode = getStatusExitCode(payload); @@ -686,4 +770,5 @@ module.exports = { getStatusExitCode, parseArgs, runWatch, + writeStatusSnapshots, }; diff --git a/tests/scripts/loop-status.test.js b/tests/scripts/loop-status.test.js index 304db7c1..c4d7f56f 100644 --- a/tests/scripts/loop-status.test.js +++ b/tests/scripts/loop-status.test.js @@ -414,6 +414,17 @@ function runTests() { assert.strictEqual(options.watchIntervalSeconds, 0.01); })) 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', () => { const homeDir = createTempHome(); @@ -534,6 +545,55 @@ function runTests() { } })) 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}`); process.exit(failed > 0 ? 1 : 0); }