Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions hooks/hook-hosts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,16 @@
* `--host <name>` so the script prints the output format that client reads.
*/

import { loadTelemetryAdapter } from "./telemetry-adapter.mjs";
import { TELEMETRY_ENABLED } from "./telemetry-config.mjs";

export const HOOK_TIMEOUT_SEC = 5;

/**
* One entry per hook script. The event is Claude Code's name for it; a client
* with an `events` map uses its own names and only gets the events it lists.
* Each client that runs plugin hooks. `runOnlyIfScriptExists` makes each
* command check for its script first. `telemetry` names the client's adapter
* in hooks/telemetry-adapters/ (see hooks/telemetry-adapter.mjs).
*/
export const HOOKS = [
{ script: "session-start.mjs", event: "SessionStart" },
{ script: "user-prompt-submit.mjs", event: "UserPromptSubmit" },
{ script: "subagent-start.mjs", event: "SubagentStart" },
];

export const HOSTS = {
"claude-code": {
// Not hooks/hooks.json: Cursor falls back to that default path and would
Expand Down Expand Up @@ -56,6 +54,39 @@ export const HOSTS = {
},
};

/**
* The telemetry hook entries of every client with a `telemetry` adapter,
* whether or not telemetry is enabled. Tests use this to check the wiring.
*/
export const telemetryHookRows = async () => {
const rows = [];
for (const [hostName, host] of Object.entries(HOSTS)) {
if (!host.telemetry) continue;
const adapter = await loadTelemetryAdapter(host.telemetry);
for (const row of adapter.hookRows) {
rows.push({ script: "telemetry.mjs", ...row, hosts: [hostName] });
}
}
return rows;
};

/**
* One entry per hook command. The event is Claude Code's name for it; a client
* with an `events` map uses its own names and only gets the events it lists.
* `hosts` limits an entry to some clients. `matcher` picks the tools a tool
* event runs for. `if` (a Claude Code permission rule such as "Bash(gh *)"
* that keeps the hook from starting for other commands) and `extraArgs`
* (added to the command) work only in the nested format.
*/
export const HOOKS = [
{ script: "session-start.mjs", event: "SessionStart" },
// Copilot CLI drops prompt-hook output, so only Claude Code runs this one.
{ script: "user-prompt-submit.mjs", event: "UserPromptSubmit", hosts: ["claude-code"] },
{ script: "subagent-start.mjs", event: "SubagentStart" },
// No telemetry hooks are written while telemetry is off (telemetry-config.mjs).
...(TELEMETRY_ENABLED ? await telemetryHookRows() : []),
];

/** The client named by `--host`, or null if it's missing or unknown. */
export const hostFromArgs = (argv) => {
const flag = argv.indexOf("--host");
Expand Down
112 changes: 112 additions & 0 deletions hooks/hook-scope.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @ts-check
/** Limits observations to app work in the current session and turn. */

import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { isConfirmation, isTaskNotification } from "./prompt-filters.mjs";
import { classifyPrompt } from "./telemetry-classify.mjs";

export const SCOPE_TTL_MS = 30 * 60 * 1000;
export const SCOPE_DIRECTORY = "prompt-scope";
export const MAX_SCOPE_FILES = 256;

const hash = (/** @type {string} */ value) => createHash("sha256").update(value).digest("hex");
const turnHash = (/** @type {Record<string, any>} */ input) =>
typeof input.prompt_id === "string" && input.prompt_id !== "" ? hash(input.prompt_id) : undefined;

/** @typedef {{ relevant: boolean, expiresAt: number, turn?: string }} ScopeState */

/**
* Pure prompt decision; confirmations keep the original app prompt's deadline.
* @param {unknown} prompt
* @param {ScopeState | null} previous
* @param {number} now
* @returns {ScopeState}
*/
export const classifyAppWork = (prompt, previous = null, now = Date.now()) => {
const direct = classifyPrompt(prompt).couldUseArcade
|| (typeof prompt === "string" && /\barcade\b/i.test(prompt));
if (direct) return { relevant: true, expiresAt: now + SCOPE_TTL_MS };
if (previous?.relevant && previous.expiresAt > now && isConfirmation(prompt)) {
return { relevant: true, expiresAt: previous.expiresAt };
}
return { relevant: false, expiresAt: now + SCOPE_TTL_MS };
};

const readState = (/** @type {string} */ file, /** @type {number} */ now) => {
try {
const state = JSON.parse(readFileSync(file, "utf8"));
if (Object.keys(state).some((key) => !["relevant", "expiresAt", "turn"].includes(key))
|| typeof state.relevant !== "boolean" || !Number.isFinite(state.expiresAt)
|| state.expiresAt <= now || state.expiresAt > now + SCOPE_TTL_MS
|| (state.turn !== undefined && (typeof state.turn !== "string" || !/^[a-f0-9]{64}$/.test(state.turn)))) return null;
return /** @type {ScopeState} */ (state);
} catch {
return null;
}
};

const writeState = (/** @type {string} */ file, /** @type {ScopeState} */ state, /** @type {number} */ now) => {
const dir = path.dirname(file);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const files = readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name));
const live = [];
for (const name of files) {
const entry = path.join(dir, name);
if (!readState(entry, now)) rmSync(entry, { force: true });
else live.push({ file: entry, modified: statSync(entry).mtimeMs });
}
live.sort((a, b) => a.modified - b.modified);
for (const entry of live.slice(0, Math.max(0, live.length - MAX_SCOPE_FILES + 1))) {
if (entry.file !== file) rmSync(entry.file, { force: true });
}
const temporary = `${file}.${randomUUID()}.tmp`;
try {
writeFileSync(temporary, JSON.stringify(state), { mode: 0o600 });
renameSync(temporary, file);
} finally {
rmSync(temporary, { force: true });
}
};

/**
* Whether this hook input is part of app work. Prompt hooks record the
* decision; other hooks read it.
* @param {Record<string, any>} input
* @param {{ host: string, requiresTurn: boolean, dir?: string, now?: number }} options
*/
export const scopeForInput = (input, { host, requiresTurn, dir, now = Date.now() }) => {
const fallback = { appWork: false };
const promptHook = input.hook_event_name === "UserPromptSubmit";
if (promptHook && isTaskNotification(input.prompt)) return fallback;
const session = typeof input.session_id === "string" && input.session_id !== "" ? input.session_id : null;
const file = dir && path.isAbsolute(dir) && session
? path.join(dir, SCOPE_DIRECTORY, `${hash(`${host}:${session}`)}.json`) : null;
if (input.hook_event_name === "SessionStart") {
// Claude Code also sends SessionStart after compacting a conversation. The
// session and turn continue, so their scope does too.
if (file && input.source !== "compact") rmSync(file, { force: true });
return fallback;
}
const previous = file ? readState(file, now) : null;
const turn = turnHash(input);
if (!promptHook) {
const matchesTurn = requiresTurn
? turn !== undefined && previous?.turn === turn
: turn === undefined || previous?.turn === turn;
const appWork = previous?.relevant === true && matchesTurn;
return { appWork };
}
const state = turn && previous?.turn === turn
? previous : { ...classifyAppWork(input.prompt, previous, now), ...(turn ? { turn } : {}) };
if (file) {
try {
writeState(file, state, now);
} catch {
// Remove stale relevance when a new prompt cannot be stored.
try { rmSync(file, { force: true }); } catch {}
}
}
return { appWork: state.relevant };
};
10 changes: 9 additions & 1 deletion hooks/prompt-filters.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const CONTINUATION_WORDS = new Set([

const MAX_CONTINUATION_WORDS = 2;

const isBareContinuation = (prompt) => {
export const isBareContinuation = (prompt) => {
const words = prompt
.toLowerCase()
.replace(/[^a-z\s]/g, " ")
Expand All @@ -25,6 +25,14 @@ const isBareContinuation = (prompt) => {
export const isTaskNotification = (prompt) =>
typeof prompt === "string" && prompt.trimStart().startsWith("<task-notification>");

export const isConfirmation = (prompt) => {
if (typeof prompt !== "string") return false;
if (isBareContinuation(prompt)) return true;
const text = prompt.toLowerCase().replace(/[^a-z\s]/g, " ").replace(/\s+/g, " ").trim();
return /^(?:(?:yes|yeah|yep|ok|okay|sure|please) )?(?:go ahead(?: and)? )?(?:send|post|create|schedule|book|reply|submit|do)(?: it| that| them| the draft| the message)(?: please)?$/.test(text)
|| text === "go ahead";
};

export const shouldRemind = (prompt) =>
typeof prompt === "string" &&
prompt.trim() !== "" &&
Expand Down
7 changes: 7 additions & 0 deletions hooks/routing-guidance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,10 @@ export const SUBAGENT_CONTEXT = join(
line("Routing", "For external service tasks, use try-arcade."),
line("Privacy", PRIVACY),
);

const OPERATOR_AGENT = "arcade-operator";

/** True for arcade-operator, bare or plugin-scoped (e.g. "arcade:arcade-operator"). */
export const isOperatorAgentType = (agentType) =>
typeof agentType === "string" &&
(agentType === OPERATOR_AGENT || agentType.endsWith(`:${OPERATOR_AGENT}`));
19 changes: 18 additions & 1 deletion hooks/session-start.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
#!/usr/bin/env node
// Adds the Arcade routing rules at session start. Always exits 0.

import { hostFromArgs, printContext } from "./hook-hosts.mjs";
import { hostFromArgs, printContext, readInput } from "./hook-hosts.mjs";
import { TELEMETRY_ENABLED } from "./telemetry-config.mjs";
import { loadTelemetryAdapter } from "./telemetry-adapter.mjs";
import { clearSessionScope } from "./telemetry-run.mjs";
import { SESSION_CONTEXT } from "./routing-guidance.mjs";

const host = hostFromArgs(process.argv);
try {
if (TELEMETRY_ENABLED && host?.telemetry) {
try {
const adapter = await loadTelemetryAdapter(host.telemetry);
const input = await readInput();
clearSessionScope({
adapter,
env: process.env,
input,
enabled: TELEMETRY_ENABLED,
});
} catch {
// Clearing local scope must not suppress the routing context.
}
}
if (host) printContext(host, "SessionStart", SESSION_CONTEXT);
} catch {
// Never block session startup.
Expand Down
11 changes: 3 additions & 8 deletions hooks/subagent-start.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,13 @@
// own instructions, so it is skipped. Always exits 0.

import { hostFromArgs, printContext, readInput } from "./hook-hosts.mjs";
import { SUBAGENT_CONTEXT } from "./routing-guidance.mjs";

// Plugin agents can arrive scoped, e.g. "arcade:arcade-operator".
// Claude Code sends the name as agent_type, Copilot CLI as agentName.
const isOperator = (name) =>
typeof name === "string" &&
(name === "arcade-operator" || name.endsWith(":arcade-operator"));
import { isOperatorAgentType, SUBAGENT_CONTEXT } from "./routing-guidance.mjs";

const host = hostFromArgs(process.argv);
try {
const input = await readInput();
if (host && !isOperator(input.agent_type ?? input.agentName)) {
// Claude Code sends the name as agent_type, Copilot CLI as agentName.
if (host && !isOperatorAgentType(input.agent_type ?? input.agentName)) {
printContext(host, "SubagentStart", SUBAGENT_CONTEXT);
}
} catch {
Expand Down
86 changes: 86 additions & 0 deletions hooks/telemetry-adapter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @ts-check
/**
* The interface between the shared telemetry code and each client. A client
* runs telemetry when its HOSTS entry in hook-hosts.mjs names an adapter in
* `telemetry`; the adapter lives in hooks/telemetry-adapters/<host>.mjs and
* default-exports a TelemetryAdapter. Removing that file and that HOSTS field
* removes the client without touching the shared code or other clients.
*/

import { TELEMETRY_HOSTS } from "./telemetry-contract.mjs";

/**
* Hook input in Claude Code's field names. Adapters translate their client's
* input into this shape; the shared code reads nothing else.
* @typedef {object} HookInput
* @property {string} [hook_event_name] Claude Code's name for the hook.
* @property {string} [session_id]
* @property {string} [prompt_id]
* @property {string} [source] SessionStart source, e.g. "compact".
* @property {unknown} [prompt]
* @property {unknown} [tool_name]
* @property {Record<string, any>} [tool_input]
* @property {unknown} [tool_response] The tool's result, read only to classify sign-in answers.
* @property {unknown} [error]
* @property {unknown} [is_interrupt]
* @property {unknown} [agent_type]
* @property {unknown} [agent_id]
* @property {unknown} [last_assistant_message]
*/

/**
* One telemetry hook entry the generator writes for this client. `event` is
* Claude Code's name; the client's `events` map in hook-hosts.mjs translates
* it. `if` and `extraArgs` work only in the nested (Claude Code) format.
* @typedef {object} HookRow
* @property {string} event
* @property {string} [matcher]
* @property {string} [if]
* @property {string[]} [extraArgs]
*/

/**
* @typedef {{ name: string, anyValue: boolean }} OptOutSwitch
* A client's own off switch. `anyValue: true`: any non-empty value turns
* telemetry off. `anyValue: false`: any value except empty, 0, false, off,
* or no does.
*/

/**
* @typedef {Record<string, string>} ToolProperties
* Contract properties for an MCP tool event (`server`, `tool`, `service`), or
* for a built-in tool event (`tool`, `cli`, `service`).
*/

/**
* @typedef {object} TelemetryAdapter
* @property {typeof TELEMETRY_HOSTS[number]} host The `host` property on every event.
* @property {string} dataVariable Environment variable naming the plugin's data folder.
* @property {OptOutSwitch[]} optOutSwitches
* @property {boolean} requiresTurn Whether tool events count as app work only
* with the same prompt ID as the prompt that opened scope. Without it, a
* tool event inherits its session's scope.
* @property {boolean} promptReminder Whether the client runs user-prompt-submit.mjs.
* @property {boolean} subagentSession Whether SubagentStop events carry `subagent_session`.
* @property {HookRow[]} hookRows
* @property {(raw: Record<string, any>) => HookInput} normalize
* @property {(toolName: unknown, toolInput: Record<string, any> | undefined) => ToolProperties | null} toolProperties
* @property {(toolName: unknown, toolInput: Record<string, any> | undefined) => ToolProperties | null} [attemptProperties]
* Only for clients with a PreToolUse row.
* @property {(toolName: unknown, toolInput: Record<string, any> | undefined, cli: string | undefined) => ToolProperties | null} [builtinToolProperties]
* Only for clients that report built-in CLI and web tools.
*/

/**
* Loads the adapter for a contract host. Throws for any other name.
* @param {string} host
* @returns {Promise<TelemetryAdapter>}
*/
export const loadTelemetryAdapter = async (host) => {
if (!(/** @type {readonly string[]} */ (TELEMETRY_HOSTS)).includes(host)) {
throw new Error(`${host} is not a telemetry host in hooks/telemetry-contract.mjs`);
}
const adapter = (await import(new URL(`./telemetry-adapters/${host}.mjs`, import.meta.url).href)).default;
if (adapter?.host !== host) throw new Error(`hooks/telemetry-adapters/${host}.mjs does not export the ${host} adapter`);
return adapter;
};
Loading
Loading