Files
ECC/scripts/hooks/run-with-flags.js
T
ae303fb6c1 fix(plan-canvas): deliver browser chat to the agent every time (#2739)
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>
2026-08-09 18:15:25 -04:00

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