mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-01 09:24:28 +08:00
feat: write loop-status snapshots
This commit is contained in:
parent
bb40978e31
commit
20154ddb22
@ -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 <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:
|
||||
|
||||
@ -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 <n> Stop after n watch refreshes',
|
||||
' --watch-interval-seconds <n> Seconds between watch refreshes (default: 5)',
|
||||
' --write-dir <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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user