feat: add loop-status watch mode

This commit is contained in:
Affaan Mustafa
2026-04-30 08:55:40 -04:00
committed by Affaan Mustafa
parent b1456bd954
commit 38f4265a1c
3 changed files with 119 additions and 14 deletions

View File

@@ -40,10 +40,15 @@ tool calls that have no matching `tool_result`.
directly. directly.
- `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash - `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash
threshold. threshold.
- `ecc loop-status --watch` refreshes status until interrupted.
- `ecc loop-status --watch --watch-count 3` emits a bounded watch stream for
scripts and handoffs.
## Watch Mode ## Watch Mode
When `--watch` is present, refresh status periodically and surface state changes. 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.
## Arguments ## Arguments

View File

@@ -8,12 +8,13 @@ const path = require('path');
const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60; const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60;
const DEFAULT_LIMIT = 10; const DEFAULT_LIMIT = 10;
const DEFAULT_WAKE_GRACE_MULTIPLIER = 2; const DEFAULT_WAKE_GRACE_MULTIPLIER = 2;
const DEFAULT_WATCH_INTERVAL_SECONDS = 5;
function usage() { function usage() {
console.log([ console.log([
'Usage:', 'Usage:',
' node scripts/loop-status.js [--json] [--home <dir>] [--limit <n>]', ' node scripts/loop-status.js [--json] [--home <dir>] [--limit <n>] [--watch]',
' node scripts/loop-status.js --transcript <session.jsonl> [--json]', ' node scripts/loop-status.js --transcript <session.jsonl> [--json] [--watch]',
'', '',
'Options:', 'Options:',
' --json Emit machine-readable status JSON', ' --json Emit machine-readable status JSON',
@@ -23,6 +24,9 @@ function usage() {
' --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")', ' --now <time> Override current time (ISO, epoch ms, or "now")',
' --watch Refresh status until interrupted',
' --watch-count <n> Stop after n watch refreshes',
' --watch-interval-seconds <n> Seconds between watch refreshes (default: 5)',
'', '',
'Examples:', 'Examples:',
' node scripts/loop-status.js --json', ' node scripts/loop-status.js --json',
@@ -64,7 +68,10 @@ function parseArgs(argv) {
now: null, now: null,
showHelp: false, showHelp: false,
transcriptPaths: [], transcriptPaths: [],
watch: false,
watchCount: null,
wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER, wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER,
watchIntervalSeconds: DEFAULT_WATCH_INTERVAL_SECONDS,
}; };
for (let index = 0; index < args.length; index += 1) { for (let index = 0; index < args.length; index += 1) {
@@ -92,6 +99,14 @@ function parseArgs(argv) {
} else if (arg === '--now') { } else if (arg === '--now') {
options.now = readValue(args, index, arg); options.now = readValue(args, index, arg);
index += 1; index += 1;
} else if (arg === '--watch') {
options.watch = true;
} else if (arg === '--watch-count') {
options.watchCount = readPositiveInteger(readValue(args, index, arg), arg);
index += 1;
} else if (arg === '--watch-interval-seconds') {
options.watchIntervalSeconds = readPositiveNumber(readValue(args, index, arg), arg);
index += 1;
} else { } else {
throw new Error(`Unknown option: ${arg}`); throw new Error(`Unknown option: ${arg}`);
} }
@@ -106,7 +121,10 @@ function normalizeOptions(options = {}) {
bashTimeoutSeconds: options.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS, bashTimeoutSeconds: options.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS,
limit: options.limit ?? DEFAULT_LIMIT, limit: options.limit ?? DEFAULT_LIMIT,
transcriptPaths: options.transcriptPaths || [], transcriptPaths: options.transcriptPaths || [],
watch: Boolean(options.watch),
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,
}; };
} }
@@ -576,28 +594,57 @@ function formatText(payload) {
return lines.join('\n'); return lines.join('\n');
} }
function main() { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function writeStatus(payload, options) {
if (options.json) {
console.log(options.watch ? JSON.stringify(payload) : JSON.stringify(payload, null, 2));
} else {
console.log(formatText(payload));
}
}
async function runWatch(options) {
const normalizedOptions = normalizeOptions(options);
let iteration = 0;
while (normalizedOptions.watchCount === null || iteration < normalizedOptions.watchCount) {
if (iteration > 0 && !normalizedOptions.json) {
console.log('');
}
writeStatus(buildStatus(normalizedOptions), normalizedOptions);
iteration += 1;
if (normalizedOptions.watchCount !== null && iteration >= normalizedOptions.watchCount) {
break;
}
await sleep(normalizedOptions.watchIntervalSeconds * 1000);
}
}
async function main() {
const options = parseArgs(process.argv); const options = parseArgs(process.argv);
if (options.showHelp) { if (options.showHelp) {
usage(); usage();
return; return;
} }
const payload = buildStatus(options); if (options.watch) {
if (options.json) { await runWatch(options);
console.log(JSON.stringify(payload, null, 2)); return;
} else {
console.log(formatText(payload));
} }
writeStatus(buildStatus(options), options);
} }
if (require.main === module) { if (require.main === module) {
try { main().catch(error => {
main();
} catch (error) {
console.error(`[loop-status] ${error.message}`); console.error(`[loop-status] ${error.message}`);
process.exit(1); process.exit(1);
} });
} }
module.exports = { module.exports = {
@@ -606,4 +653,5 @@ module.exports = {
extractToolResultIds, extractToolResultIds,
extractToolUses, extractToolUses,
parseArgs, parseArgs,
runWatch,
}; };

View File

@@ -9,7 +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, buildStatus } = require('../../scripts/loop-status'); const { analyzeTranscript, buildStatus, parseArgs } = 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 = {}) {
@@ -396,6 +396,58 @@ function runTests() {
assert.match(result.stderr, /--limit must be a positive integer/); assert.match(result.stderr, /--limit must be a positive integer/);
})) passed++; else failed++; })) passed++; else failed++;
if (test('parses watch mode controls', () => {
const options = parseArgs([
'node',
'scripts/loop-status.js',
'--watch',
'--watch-count',
'2',
'--watch-interval-seconds',
'0.01',
]);
assert.strictEqual(options.watch, true);
assert.strictEqual(options.watchCount, 2);
assert.strictEqual(options.watchIntervalSeconds, 0.01);
})) passed++; else failed++;
if (test('watch mode emits repeated JSON status frames', () => {
const homeDir = createTempHome();
try {
writeTranscript(homeDir, '-Users-affoon-project-watch', 'session-watch.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-watch', 'toolu_watch', 'ScheduleWakeup', {
delaySeconds: 300,
reason: 'Loop checkpoint',
}),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--watch',
'--watch-count',
'2',
'--watch-interval-seconds',
'0.01',
]);
assert.strictEqual(result.code, 0, result.stderr);
const frames = result.stdout.trim().split(/\r?\n/).map(line => JSON.parse(line));
assert.strictEqual(frames.length, 2);
assert.strictEqual(frames[0].schemaVersion, 'ecc.loop-status.v1');
assert.strictEqual(frames[1].schemaVersion, 'ecc.loop-status.v1');
assert.strictEqual(frames[0].sessions[0].sessionId, 'session-watch');
assert.strictEqual(frames[1].sessions[0].sessionId, 'session-watch');
} finally {
fs.rmSync(homeDir, { 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);
} }