mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-30 22:13:28 +08:00
610 lines
17 KiB
JavaScript
610 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60;
|
|
const DEFAULT_LIMIT = 10;
|
|
const DEFAULT_WAKE_GRACE_MULTIPLIER = 2;
|
|
|
|
function usage() {
|
|
console.log([
|
|
'Usage:',
|
|
' node scripts/loop-status.js [--json] [--home <dir>] [--limit <n>]',
|
|
' node scripts/loop-status.js --transcript <session.jsonl> [--json]',
|
|
'',
|
|
'Options:',
|
|
' --json Emit machine-readable status JSON',
|
|
' --home <dir> Override the home directory to scan',
|
|
' --transcript <session.jsonl> Inspect one transcript directly',
|
|
' --limit <n> Maximum recent transcripts to inspect (default: 10)',
|
|
' --bash-timeout-seconds <n> Age before a pending Bash call is stale (default: 1800)',
|
|
' --wake-grace-multiplier <n> ScheduleWakeup grace multiplier (default: 2)',
|
|
' --now <time> Override current time (ISO, epoch ms, or "now")',
|
|
'',
|
|
'Examples:',
|
|
' node scripts/loop-status.js --json',
|
|
' node scripts/loop-status.js --transcript ~/.claude/projects/-repo/session.jsonl'
|
|
].join('\n'));
|
|
}
|
|
|
|
function readValue(args, index, flagName) {
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`${flagName} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readPositiveNumber(value, flagName) {
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number) || number <= 0) {
|
|
throw new Error(`${flagName} must be a positive number`);
|
|
}
|
|
return number;
|
|
}
|
|
|
|
function readPositiveInteger(value, flagName) {
|
|
const number = readPositiveNumber(value, flagName);
|
|
if (!Number.isInteger(number)) {
|
|
throw new Error(`${flagName} must be a positive integer`);
|
|
}
|
|
return number;
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = argv.slice(2);
|
|
const options = {
|
|
bashTimeoutSeconds: DEFAULT_BASH_TIMEOUT_SECONDS,
|
|
home: null,
|
|
json: false,
|
|
limit: DEFAULT_LIMIT,
|
|
now: null,
|
|
showHelp: false,
|
|
transcriptPaths: [],
|
|
wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER,
|
|
};
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
|
|
if (arg === '--help' || arg === '-h') {
|
|
options.showHelp = true;
|
|
} else if (arg === '--json') {
|
|
options.json = true;
|
|
} else if (arg === '--home') {
|
|
options.home = readValue(args, index, arg);
|
|
index += 1;
|
|
} else if (arg === '--transcript') {
|
|
options.transcriptPaths.push(readValue(args, index, arg));
|
|
index += 1;
|
|
} else if (arg === '--limit') {
|
|
options.limit = readPositiveInteger(readValue(args, index, arg), arg);
|
|
index += 1;
|
|
} else if (arg === '--bash-timeout-seconds') {
|
|
options.bashTimeoutSeconds = readPositiveNumber(readValue(args, index, arg), arg);
|
|
index += 1;
|
|
} else if (arg === '--wake-grace-multiplier') {
|
|
options.wakeGraceMultiplier = readPositiveNumber(readValue(args, index, arg), arg);
|
|
index += 1;
|
|
} else if (arg === '--now') {
|
|
options.now = readValue(args, index, arg);
|
|
index += 1;
|
|
} else {
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function normalizeOptions(options = {}) {
|
|
return {
|
|
...options,
|
|
bashTimeoutSeconds: options.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS,
|
|
limit: options.limit ?? DEFAULT_LIMIT,
|
|
transcriptPaths: options.transcriptPaths || [],
|
|
wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER,
|
|
};
|
|
}
|
|
|
|
function getHomeDir(options = {}) {
|
|
if (options.home) {
|
|
return path.resolve(options.home);
|
|
}
|
|
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
}
|
|
|
|
function getNow(options = {}) {
|
|
if (!options.now) {
|
|
return new Date();
|
|
}
|
|
|
|
if (options.now === 'now') {
|
|
return new Date();
|
|
}
|
|
|
|
const now = /^\d+$/.test(String(options.now))
|
|
? new Date(Number(options.now))
|
|
: new Date(options.now);
|
|
if (Number.isNaN(now.getTime())) {
|
|
throw new Error('--now must be a valid timestamp');
|
|
}
|
|
return now;
|
|
}
|
|
|
|
function walkJsonlFiles(dir, result = { errors: [], files: [] }) {
|
|
if (!fs.existsSync(dir)) {
|
|
return result;
|
|
}
|
|
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch (error) {
|
|
result.errors.push({
|
|
code: error.code || null,
|
|
message: error.message,
|
|
transcriptPath: dir,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walkJsonlFiles(fullPath, result);
|
|
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
result.files.push(fullPath);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function findTranscriptPaths(options = {}) {
|
|
const normalizedOptions = normalizeOptions(options);
|
|
|
|
if (options.transcriptPaths && options.transcriptPaths.length > 0) {
|
|
return {
|
|
errors: [],
|
|
transcriptPaths: normalizedOptions.transcriptPaths.map(transcriptPath => path.resolve(transcriptPath)),
|
|
};
|
|
}
|
|
|
|
const homeDir = getHomeDir(normalizedOptions);
|
|
const transcriptRoot = path.join(homeDir, '.claude', 'projects');
|
|
const walkResult = walkJsonlFiles(transcriptRoot);
|
|
const errors = [...walkResult.errors];
|
|
const transcriptEntries = [];
|
|
|
|
for (const transcriptPath of walkResult.files) {
|
|
try {
|
|
transcriptEntries.push({
|
|
transcriptPath,
|
|
mtimeMs: fs.statSync(transcriptPath).mtimeMs,
|
|
});
|
|
} catch (error) {
|
|
errors.push({
|
|
code: error.code || null,
|
|
message: error.message,
|
|
transcriptPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
errors,
|
|
transcriptPaths: transcriptEntries
|
|
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
|
.slice(0, normalizedOptions.limit)
|
|
.map(entry => entry.transcriptPath),
|
|
};
|
|
}
|
|
|
|
function parseTimestamp(value) {
|
|
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
return null;
|
|
}
|
|
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return null;
|
|
}
|
|
return date;
|
|
}
|
|
|
|
function getEntryTimestamp(entry) {
|
|
return parseTimestamp(entry.timestamp)
|
|
|| parseTimestamp(entry.createdAt)
|
|
|| parseTimestamp(entry.created_at)
|
|
|| parseTimestamp(entry.message && entry.message.timestamp);
|
|
}
|
|
|
|
function getSessionId(entry, transcriptPath) {
|
|
return entry.sessionId
|
|
|| entry.session_id
|
|
|| (entry.session && entry.session.id)
|
|
|| (entry.message && entry.message.sessionId)
|
|
|| path.basename(transcriptPath, '.jsonl');
|
|
}
|
|
|
|
function getContentBlocks(entry) {
|
|
const blocks = [];
|
|
if (entry.message && Array.isArray(entry.message.content)) {
|
|
blocks.push(...entry.message.content);
|
|
}
|
|
if (Array.isArray(entry.content)) {
|
|
blocks.push(...entry.content);
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function extractToolUses(entry) {
|
|
const uses = [];
|
|
|
|
for (const block of getContentBlocks(entry)) {
|
|
if (block && block.type === 'tool_use' && block.id) {
|
|
uses.push({
|
|
id: block.id,
|
|
input: block.input || {},
|
|
name: block.name || 'unknown',
|
|
});
|
|
}
|
|
}
|
|
|
|
const topLevelUse = entry.tool_use || entry.toolUse;
|
|
if (topLevelUse && topLevelUse.id) {
|
|
uses.push({
|
|
id: topLevelUse.id,
|
|
input: topLevelUse.input || {},
|
|
name: topLevelUse.name || 'unknown',
|
|
});
|
|
}
|
|
|
|
if (entry.type === 'tool_use' && entry.id) {
|
|
uses.push({
|
|
id: entry.id,
|
|
input: entry.input || {},
|
|
name: entry.name || 'unknown',
|
|
});
|
|
}
|
|
|
|
return uses;
|
|
}
|
|
|
|
function extractToolResultIds(entry) {
|
|
const resultIds = [];
|
|
|
|
for (const block of getContentBlocks(entry)) {
|
|
if (block && block.type === 'tool_result') {
|
|
const toolUseId = block.tool_use_id || block.toolUseId || block.id;
|
|
if (toolUseId) {
|
|
resultIds.push(toolUseId);
|
|
}
|
|
}
|
|
}
|
|
|
|
const topLevelResult = entry.tool_result || entry.toolResult || entry.toolUseResult;
|
|
if (topLevelResult) {
|
|
const toolUseId = topLevelResult.tool_use_id || topLevelResult.toolUseId || topLevelResult.id;
|
|
if (toolUseId) {
|
|
resultIds.push(toolUseId);
|
|
}
|
|
}
|
|
|
|
if (entry.type === 'tool_result') {
|
|
const toolUseId = entry.tool_use_id || entry.toolUseId || entry.id;
|
|
if (toolUseId) {
|
|
resultIds.push(toolUseId);
|
|
}
|
|
}
|
|
|
|
return resultIds;
|
|
}
|
|
|
|
function isAssistantProgressEntry(entry) {
|
|
return entry.type === 'assistant'
|
|
|| (entry.message && entry.message.role === 'assistant')
|
|
|| extractToolUses(entry).length > 0;
|
|
}
|
|
|
|
function readJsonlEntries(transcriptPath) {
|
|
const raw = fs.readFileSync(transcriptPath, 'utf8');
|
|
const entries = [];
|
|
let parseErrors = 0;
|
|
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
entries.push(JSON.parse(line));
|
|
} catch (_error) {
|
|
parseErrors += 1;
|
|
}
|
|
}
|
|
|
|
return { entries, parseErrors };
|
|
}
|
|
|
|
function readDelaySeconds(input) {
|
|
const delay = input && (
|
|
input.delaySeconds
|
|
|| input.delay_seconds
|
|
|| input.seconds
|
|
|| input.delay
|
|
);
|
|
const number = Number(delay);
|
|
if (!Number.isFinite(number) || number <= 0) {
|
|
return null;
|
|
}
|
|
return number;
|
|
}
|
|
|
|
function toIso(date) {
|
|
return date ? date.toISOString() : null;
|
|
}
|
|
|
|
function buildRecommendation(signals) {
|
|
if (signals.some(signal => signal.type === 'pending_bash_tool_result')) {
|
|
return 'Open the transcript or interrupt the parked session; the Bash result appears stale.';
|
|
}
|
|
|
|
if (signals.some(signal => signal.type === 'schedule_wakeup_overdue')) {
|
|
return 'Open the transcript or interrupt the parked session; the scheduled wake is overdue.';
|
|
}
|
|
|
|
if (signals.some(signal => signal.type === 'transcript_parse_errors')) {
|
|
return 'Inspect the transcript; some JSONL lines could not be parsed.';
|
|
}
|
|
|
|
return 'No stale ScheduleWakeup or Bash waits detected.';
|
|
}
|
|
|
|
function analyzeTranscript(transcriptPath, options = {}) {
|
|
const normalizedOptions = normalizeOptions(options);
|
|
const absoluteTranscriptPath = path.resolve(transcriptPath);
|
|
const now = normalizedOptions.nowDate || getNow(normalizedOptions);
|
|
const nowMs = now.getTime();
|
|
const { entries, parseErrors } = readJsonlEntries(absoluteTranscriptPath);
|
|
const pendingTools = new Map();
|
|
let latestAssistantProgressAt = null;
|
|
let lastEventAt = null;
|
|
let latestWake = null;
|
|
let sessionId = path.basename(absoluteTranscriptPath, '.jsonl');
|
|
|
|
for (const entry of entries) {
|
|
sessionId = getSessionId(entry, absoluteTranscriptPath) || sessionId;
|
|
const timestamp = getEntryTimestamp(entry);
|
|
if (timestamp && (!lastEventAt || timestamp.getTime() > lastEventAt.getTime())) {
|
|
lastEventAt = timestamp;
|
|
}
|
|
if (
|
|
timestamp
|
|
&& isAssistantProgressEntry(entry)
|
|
&& (!latestAssistantProgressAt || timestamp.getTime() > latestAssistantProgressAt.getTime())
|
|
) {
|
|
latestAssistantProgressAt = timestamp;
|
|
}
|
|
|
|
for (const toolUse of extractToolUses(entry)) {
|
|
const startedAt = timestamp || lastEventAt;
|
|
pendingTools.set(toolUse.id, {
|
|
command: toolUse.input && toolUse.input.command ? String(toolUse.input.command) : null,
|
|
input: toolUse.input || {},
|
|
name: toolUse.name,
|
|
startedAt: toIso(startedAt),
|
|
toolUseId: toolUse.id,
|
|
});
|
|
|
|
if (toolUse.name === 'ScheduleWakeup') {
|
|
const delaySeconds = readDelaySeconds(toolUse.input);
|
|
if (delaySeconds && startedAt) {
|
|
const dueAt = new Date(startedAt.getTime() + delaySeconds * 1000);
|
|
latestWake = {
|
|
delaySeconds,
|
|
dueAt: dueAt.toISOString(),
|
|
reason: toolUse.input && toolUse.input.reason ? String(toolUse.input.reason) : null,
|
|
scheduledAt: startedAt.toISOString(),
|
|
toolUseId: toolUse.id,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const toolUseId of extractToolResultIds(entry)) {
|
|
pendingTools.delete(toolUseId);
|
|
}
|
|
}
|
|
|
|
const pendingToolList = Array.from(pendingTools.values()).map(tool => {
|
|
const startedAt = parseTimestamp(tool.startedAt);
|
|
return {
|
|
...tool,
|
|
ageSeconds: startedAt ? Math.max(0, Math.floor((nowMs - startedAt.getTime()) / 1000)) : null,
|
|
};
|
|
});
|
|
|
|
const signals = [];
|
|
if (latestWake) {
|
|
const scheduledAt = parseTimestamp(latestWake.scheduledAt);
|
|
const dueAt = parseTimestamp(latestWake.dueAt);
|
|
const thresholdMs = scheduledAt
|
|
? scheduledAt.getTime() + latestWake.delaySeconds * normalizedOptions.wakeGraceMultiplier * 1000
|
|
: null;
|
|
const hasAssistantProgressAfterDue = Boolean(
|
|
dueAt
|
|
&& latestAssistantProgressAt
|
|
&& latestAssistantProgressAt.getTime() >= dueAt.getTime()
|
|
);
|
|
|
|
if (thresholdMs && nowMs >= thresholdMs && !hasAssistantProgressAfterDue) {
|
|
signals.push({
|
|
delaySeconds: latestWake.delaySeconds,
|
|
dueAt: latestWake.dueAt,
|
|
overdueSeconds: dueAt ? Math.max(0, Math.floor((nowMs - dueAt.getTime()) / 1000)) : null,
|
|
scheduledAt: latestWake.scheduledAt,
|
|
toolUseId: latestWake.toolUseId,
|
|
type: 'schedule_wakeup_overdue',
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const tool of pendingToolList) {
|
|
if (
|
|
tool.name === 'Bash'
|
|
&& tool.ageSeconds !== null
|
|
&& tool.ageSeconds >= normalizedOptions.bashTimeoutSeconds
|
|
) {
|
|
signals.push({
|
|
ageSeconds: tool.ageSeconds,
|
|
command: tool.command,
|
|
startedAt: tool.startedAt,
|
|
thresholdSeconds: normalizedOptions.bashTimeoutSeconds,
|
|
toolUseId: tool.toolUseId,
|
|
type: 'pending_bash_tool_result',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (parseErrors > 0) {
|
|
signals.push({
|
|
count: parseErrors,
|
|
type: 'transcript_parse_errors',
|
|
});
|
|
}
|
|
|
|
return {
|
|
eventCount: entries.length,
|
|
lastEventAt: toIso(lastEventAt),
|
|
latestWake,
|
|
parseErrors,
|
|
pendingTools: pendingToolList,
|
|
projectSlug: path.basename(path.dirname(absoluteTranscriptPath)),
|
|
recommendedAction: buildRecommendation(signals),
|
|
sessionId,
|
|
signals,
|
|
state: signals.length > 0 ? 'attention' : 'ok',
|
|
transcriptPath: absoluteTranscriptPath,
|
|
};
|
|
}
|
|
|
|
function buildStatus(options = {}) {
|
|
const normalizedOptions = normalizeOptions(options);
|
|
const nowDate = getNow(normalizedOptions);
|
|
const mergedOptions = {
|
|
...normalizedOptions,
|
|
nowDate,
|
|
};
|
|
const homeDir = getHomeDir(normalizedOptions);
|
|
const { errors, transcriptPaths } = findTranscriptPaths(normalizedOptions);
|
|
const sessions = [];
|
|
|
|
for (const transcriptPath of transcriptPaths) {
|
|
try {
|
|
sessions.push(analyzeTranscript(transcriptPath, mergedOptions));
|
|
} catch (error) {
|
|
errors.push({
|
|
code: error.code || null,
|
|
message: error.message,
|
|
transcriptPath,
|
|
});
|
|
}
|
|
}
|
|
|
|
sessions.sort((left, right) => {
|
|
if (left.state !== right.state) {
|
|
return left.state === 'attention' ? -1 : 1;
|
|
}
|
|
return String(right.lastEventAt || '').localeCompare(String(left.lastEventAt || ''));
|
|
});
|
|
|
|
return {
|
|
generatedAt: nowDate.toISOString(),
|
|
errors,
|
|
schemaVersion: 'ecc.loop-status.v1',
|
|
sessions,
|
|
source: {
|
|
bashTimeoutSeconds: normalizedOptions.bashTimeoutSeconds,
|
|
homeDir,
|
|
limit: normalizedOptions.limit,
|
|
transcriptCount: transcriptPaths.length,
|
|
transcriptRoot: path.join(homeDir, '.claude', 'projects'),
|
|
wakeGraceMultiplier: normalizedOptions.wakeGraceMultiplier,
|
|
},
|
|
};
|
|
}
|
|
|
|
function formatSignals(signals) {
|
|
if (signals.length === 0) {
|
|
return 'none';
|
|
}
|
|
return signals.map(signal => signal.type).join(', ');
|
|
}
|
|
|
|
function formatText(payload) {
|
|
const skippedLines = payload.errors.map(error => ` - ${error.transcriptPath}: ${error.message}`);
|
|
|
|
if (payload.sessions.length === 0) {
|
|
const lines = [
|
|
`ECC loop status (${payload.generatedAt})`,
|
|
skippedLines.length > 0
|
|
? 'No readable Claude transcript JSONL files were found.'
|
|
: `No Claude transcript JSONL files found under ${payload.source.transcriptRoot}.`,
|
|
];
|
|
if (skippedLines.length > 0) {
|
|
lines.push('Skipped transcript errors:');
|
|
lines.push(...skippedLines);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
const lines = [`ECC loop status (${payload.generatedAt})`];
|
|
for (const session of payload.sessions) {
|
|
lines.push(`- ${session.sessionId} [${session.state}] ${session.transcriptPath}`);
|
|
lines.push(` last event: ${session.lastEventAt || 'unknown'}; events: ${session.eventCount}`);
|
|
lines.push(` signals: ${formatSignals(session.signals)}`);
|
|
lines.push(` action: ${session.recommendedAction}`);
|
|
}
|
|
if (skippedLines.length > 0) {
|
|
lines.push('Skipped transcript errors:');
|
|
lines.push(...skippedLines);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function main() {
|
|
const options = parseArgs(process.argv);
|
|
if (options.showHelp) {
|
|
usage();
|
|
return;
|
|
}
|
|
|
|
const payload = buildStatus(options);
|
|
if (options.json) {
|
|
console.log(JSON.stringify(payload, null, 2));
|
|
} else {
|
|
console.log(formatText(payload));
|
|
}
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(`[loop-status] ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
analyzeTranscript,
|
|
buildStatus,
|
|
extractToolResultIds,
|
|
extractToolUses,
|
|
parseArgs,
|
|
};
|