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);
}