mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
Feedback sent from the canvas only reached an agent through a live /api/await long poll. When a turn ended with no await parked, queueFeedback wrote the message to sessions.json and nothing ever consumed it, so sending appeared to do nothing at all. The presence pill made it worse: workingKeys had no expiry and the feedback handler never broadcast presence, so it froze on "agent working" while nobody was listening. Delivery: - Add the stop:plan-canvas-pending hook. It drains undelivered feedback and blocks the Stop, handing the messages to the agent, so a canvas message lands even when no await is running. Scoped to sessions under cwd so parallel agents cannot swallow each other's feedback; set ECC_PLAN_CANVAS_STOP_SCOPE=all to widen. Honors stop_hook_active and fails open on every error path. - run-with-flags.js did not await a hook's run(), so any async hook silently degraded to pass-through. Fixed; plan-canvas-pending is the only async hook today. Presence and indicators: - Presence is now ended/typing/thinking/listening/queued/waiting. thinking and typing self-expire (90s/30s) and a 5s sweep pushes the decay to an idle browser, so the pill can no longer stick. - Broadcast presence when feedback is queued, and clear the activity state when an agent reply lands. - Add POST /api/session/:key/typing so agents can drive the indicator. - Chat shows an animated dots bubble for thinking and typing, plus an explicit note when a message is queued with nobody listening. Respects prefers-reduced-motion. - Send status reports what actually happened instead of always claiming the agent will pick it up. CLI and skill: - Add `ecc-plan-canvas pending` and `typing <file> --state ...`. - SKILL.md documents background await as the primary pattern and makes replying in the canvas mandatory. Tests: 6 new server cases covering queued presence, the typing endpoint, state expiry and the sweep, plus a new hook suite covering delivery, drain-once, stop_hook_active, cwd scoping and fail-open. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
276 lines
8.8 KiB
JavaScript
Executable File
276 lines
8.8 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Executes a hook script only when enabled by ECC hook profile flags.
|
|
*
|
|
* Usage:
|
|
* node run-with-flags.js <hookId> <scriptRelativePath> [profilesCsv]
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
const { isHookEnabled, isDryRun } = require('../lib/hook-flags');
|
|
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
|
|
|
|
const MAX_STDIN = 1024 * 1024;
|
|
|
|
function readStdinRaw() {
|
|
return new Promise(resolve => {
|
|
let raw = '';
|
|
let truncated = false;
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', chunk => {
|
|
if (raw.length < MAX_STDIN) {
|
|
const remaining = MAX_STDIN - raw.length;
|
|
raw += chunk.substring(0, remaining);
|
|
if (chunk.length > remaining) {
|
|
truncated = true;
|
|
}
|
|
} else {
|
|
truncated = true;
|
|
}
|
|
});
|
|
process.stdin.on('end', () => resolve({ raw, truncated }));
|
|
process.stdin.on('error', () => resolve({ raw, truncated }));
|
|
});
|
|
}
|
|
|
|
function writeStderr(stderr) {
|
|
if (typeof stderr !== 'string' || stderr.length === 0) {
|
|
return;
|
|
}
|
|
|
|
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
|
|
}
|
|
|
|
/**
|
|
* Exit only after stdout and any previously queued stderr have drained.
|
|
* `process.exit()` immediately after a stream write drops anything beyond
|
|
* the OS pipe buffer, which cut large hook output mid-payload and made the
|
|
* harness treat the hook as failed (#2222).
|
|
*/
|
|
function exitWithStdout(text, exitCode) {
|
|
process.exitCode = exitCode;
|
|
let pendingWrites = 1;
|
|
const exitWhenFlushed = () => {
|
|
pendingWrites -= 1;
|
|
if (pendingWrites === 0) {
|
|
process.exit(exitCode);
|
|
}
|
|
};
|
|
|
|
if (typeof text === 'string' && text.length > 0) {
|
|
pendingWrites += 1;
|
|
process.stdout.write(text, exitWhenFlushed);
|
|
}
|
|
process.stderr.write('', exitWhenFlushed);
|
|
}
|
|
|
|
function resolveHookResult(raw, output) {
|
|
if (typeof output === 'string' || Buffer.isBuffer(output)) {
|
|
return { stdout: String(output), exitCode: 0 };
|
|
}
|
|
|
|
if (output && typeof output === 'object') {
|
|
writeStderr(output.stderr);
|
|
const exitCode = Number.isInteger(output.exitCode) ? output.exitCode : 0;
|
|
|
|
if (Object.prototype.hasOwnProperty.call(output, 'additionalContext')) {
|
|
return { stdout: buildPreToolUseAdditionalContext(output.additionalContext), exitCode };
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
|
|
return { stdout: String(output.stdout ?? ''), exitCode };
|
|
}
|
|
return { stdout: exitCode === 0 ? raw : '', exitCode };
|
|
}
|
|
|
|
return { stdout: raw, exitCode: 0 };
|
|
}
|
|
|
|
function resolveLegacySpawnStdout(raw, result) {
|
|
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
if (stdout) {
|
|
return stdout;
|
|
}
|
|
|
|
if (Number.isInteger(result.status) && result.status === 0) {
|
|
return raw;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function getPluginRoot() {
|
|
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
|
|
return process.env.CLAUDE_PLUGIN_ROOT;
|
|
}
|
|
return path.resolve(__dirname, '..', '..');
|
|
}
|
|
|
|
//Safely extract target context from hook stdin JSON for dry-run preview.
|
|
|
|
function extractTargetContext(raw) {
|
|
const result = { tool: '', filePath: '', command: '' };
|
|
if (!raw || typeof raw !== 'string') return result;
|
|
|
|
try {
|
|
const payload = JSON.parse(raw);
|
|
if (payload && typeof payload === 'object') {
|
|
result.tool = String(payload.tool || '');
|
|
const input = payload.tool_input;
|
|
if (input && typeof input === 'object') {
|
|
result.filePath = String(input.file_path || input.path || '');
|
|
result.command = String(input.command || '');
|
|
}
|
|
}
|
|
} catch {
|
|
// best-effort field extraction; ignore malformed input
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Build the [DryRun] preview line for stderr.
|
|
|
|
function buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw) {
|
|
const ctx = extractTargetContext(raw);
|
|
const parts = [`[DryRun] Hook "${hookId}" would execute: ${relScriptPath}`, `(enabled=true, profiles=${profilesCsv || 'default'})`];
|
|
|
|
if (ctx.tool) {
|
|
parts.push(`tool=${ctx.tool}`);
|
|
}
|
|
if (ctx.filePath) {
|
|
parts.push(`target=${ctx.filePath}`);
|
|
}
|
|
if (ctx.command) {
|
|
parts.push(`command=${ctx.command}`);
|
|
}
|
|
|
|
return parts.join(' ') + '\n';
|
|
}
|
|
|
|
async function main() {
|
|
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
|
|
const { raw, truncated } = await readStdinRaw();
|
|
|
|
// Oversized payloads: never echo the truncated string — a JSON document
|
|
// cut mid-stream is treated by the harness as a hook failure, blocking the
|
|
// tool call (#2222). Empty stdout + exit 0 means "no opinion", so
|
|
// pass-through paths fail open. The hook itself still runs and receives
|
|
// the truncated flag (run() context / ECC_HOOK_INPUT_TRUNCATED), so
|
|
// security hooks like config-protection can still choose to block.
|
|
const sanitizeEcho = text => (truncated && text === raw ? '' : text);
|
|
if (truncated) {
|
|
process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for ${hookId || 'unknown'}; suppressing pass-through (fail-open unless the hook blocks)\n`);
|
|
}
|
|
|
|
if (!hookId || !relScriptPath) {
|
|
exitWithStdout(sanitizeEcho(raw), 0);
|
|
return;
|
|
}
|
|
|
|
if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
|
|
exitWithStdout(sanitizeEcho(raw), 0);
|
|
return;
|
|
}
|
|
|
|
if (isDryRun()) {
|
|
const preview = buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw);
|
|
process.stderr.write(preview);
|
|
exitWithStdout(sanitizeEcho(raw), 0);
|
|
return;
|
|
}
|
|
|
|
const pluginRoot = getPluginRoot();
|
|
const resolvedRoot = path.resolve(pluginRoot);
|
|
const scriptPath = path.resolve(pluginRoot, relScriptPath);
|
|
|
|
// Prevent path traversal outside the plugin root
|
|
if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
|
|
process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
|
|
exitWithStdout(sanitizeEcho(raw), 0);
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(scriptPath)) {
|
|
process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
|
|
exitWithStdout(sanitizeEcho(raw), 0);
|
|
return;
|
|
}
|
|
|
|
// Prefer direct require() when the hook exports a run(rawInput) function.
|
|
// This eliminates one Node.js process spawn (~50-100ms savings per hook).
|
|
//
|
|
// SAFETY: Only require() hooks that export run(). Legacy hooks execute
|
|
// side effects at module scope (stdin listeners, process.exit, main() calls)
|
|
// which would interfere with the parent process or cause double execution.
|
|
let hookModule;
|
|
const src = fs.readFileSync(scriptPath, 'utf8');
|
|
const hasRunExport = /\bmodule\.exports\b/.test(src) && /\brun\b/.test(src);
|
|
|
|
if (hasRunExport) {
|
|
try {
|
|
hookModule = require(scriptPath);
|
|
} catch (requireErr) {
|
|
process.stderr.write(`[Hook] require() failed for ${hookId}: ${requireErr.message}\n`);
|
|
// Fall through to legacy spawnSync path
|
|
}
|
|
}
|
|
|
|
if (hookModule && typeof hookModule.run === 'function') {
|
|
try {
|
|
// Awaited so a hook may export `async run()`. Without this an async hook
|
|
// hands back a pending Promise, which resolveHookResult reads as "no
|
|
// opinion" and silently degrades to pass-through. Synchronous hooks are
|
|
// unaffected: awaiting a plain value just costs a microtask.
|
|
const output = await hookModule.run(raw, {
|
|
hookId,
|
|
pluginRoot,
|
|
scriptPath,
|
|
truncated,
|
|
maxStdin: MAX_STDIN
|
|
});
|
|
const result = resolveHookResult(raw, output);
|
|
exitWithStdout(sanitizeEcho(result.stdout), result.exitCode);
|
|
} catch (runErr) {
|
|
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
|
|
exitWithStdout(sanitizeEcho(raw), 0);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Legacy path: spawn a child Node process for hooks without run() export
|
|
const result = spawnSync(process.execPath, [scriptPath], {
|
|
input: raw,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
|
ECC_PLUGIN_ROOT: pluginRoot,
|
|
ECC_HOOK_ID: hookId,
|
|
ECC_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0',
|
|
ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN)
|
|
},
|
|
cwd: process.cwd(),
|
|
timeout: 30000
|
|
});
|
|
|
|
const legacyStdout = sanitizeEcho(resolveLegacySpawnStdout(raw, result));
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
|
|
if (result.error || result.signal || result.status === null) {
|
|
const failureDetail = result.error ? result.error.message : result.signal ? `terminated by signal ${result.signal}` : 'missing exit status';
|
|
writeStderr(`[Hook] legacy hook execution failed for ${hookId}: ${failureDetail}`);
|
|
exitWithStdout(legacyStdout, 1);
|
|
return;
|
|
}
|
|
|
|
exitWithStdout(legacyStdout, Number.isInteger(result.status) ? result.status : 0);
|
|
}
|
|
|
|
main().catch(err => {
|
|
process.stderr.write(`[Hook] run-with-flags error: ${err.message}\n`);
|
|
process.exit(0);
|
|
});
|