one-code-extension 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook command execution — the only file in extensions/hooks that touches
|
|
3
|
+
* child_process. Everything upstream (matching, payload, envelope, decision)
|
|
4
|
+
* is pure and tested with a fake executor; this file is tested against real
|
|
5
|
+
* /bin/sh children.
|
|
6
|
+
*
|
|
7
|
+
* pi.exec is deliberately not used: it hardcodes shell:false and gives the
|
|
8
|
+
* child no stdin, while Claude Code hooks are shell command strings that read
|
|
9
|
+
* a JSON payload from stdin. (Ported from pi-code's runHookCommand, MIT — see
|
|
10
|
+
* docs/decisions.md.)
|
|
11
|
+
*
|
|
12
|
+
* Hardening carried over:
|
|
13
|
+
* - absolute /bin/sh, so a repo-local `sh` on PATH can't hijack the hook
|
|
14
|
+
* - detached:true makes the shell a process-group leader; timeout SIGKILLs
|
|
15
|
+
* the negative pid so grandchildren holding the stdio pipes die too —
|
|
16
|
+
* otherwise `close` never fires and the promise hangs past the timeout
|
|
17
|
+
* - setEncoding("utf8") so multi-byte characters can't be split across chunks
|
|
18
|
+
* - output capped at 1MB per stream
|
|
19
|
+
* - stdin errors ignored (a hook that exits without reading stdin — `exit 2`
|
|
20
|
+
* — would otherwise EPIPE-crash the write)
|
|
21
|
+
* - timeout clamped under Node's 2^31-1 ms timer overflow, timer unref'd so a
|
|
22
|
+
* pending hook can't keep a one-shot `pi -p` process alive
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
|
|
27
|
+
export interface HookRunResult {
|
|
28
|
+
/** null when the process was killed (timeout) or never spawned. */
|
|
29
|
+
exitCode: number | null;
|
|
30
|
+
timedOut: boolean;
|
|
31
|
+
/** Set when the child could not be spawned at all. */
|
|
32
|
+
spawnError?: string;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HookRunOptions {
|
|
39
|
+
cwd: string;
|
|
40
|
+
/** Seconds, Claude Code convention. Clamped to [1, MAX_TIMEOUT_S]. */
|
|
41
|
+
timeoutSeconds?: number;
|
|
42
|
+
/** Exposed to the hook as CLAUDE_PROJECT_DIR; defaults to cwd. */
|
|
43
|
+
projectDir?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MAX_OUTPUT_BYTES = 1_000_000;
|
|
47
|
+
const DEFAULT_TIMEOUT_S = 60;
|
|
48
|
+
/** Node timers silently fire immediately above 2^31-1 ms. */
|
|
49
|
+
const MAX_TIMEOUT_S = 2_147_483;
|
|
50
|
+
|
|
51
|
+
export function runHookCommand(command: string, stdinJson: string, opts: HookRunOptions): Promise<HookRunResult> {
|
|
52
|
+
const timeoutMs = Math.min(Math.max(opts.timeoutSeconds ?? DEFAULT_TIMEOUT_S, 1), MAX_TIMEOUT_S) * 1000;
|
|
53
|
+
const started = Date.now();
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
let child: ReturnType<typeof spawn>;
|
|
57
|
+
try {
|
|
58
|
+
child = spawn("/bin/sh", ["-c", command], {
|
|
59
|
+
cwd: opts.cwd,
|
|
60
|
+
detached: true,
|
|
61
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
62
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: opts.projectDir ?? opts.cwd },
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
resolve({
|
|
66
|
+
exitCode: null,
|
|
67
|
+
timedOut: false,
|
|
68
|
+
spawnError: error instanceof Error ? error.message : String(error),
|
|
69
|
+
stdout: "",
|
|
70
|
+
stderr: "",
|
|
71
|
+
durationMs: Date.now() - started,
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
child.unref();
|
|
76
|
+
|
|
77
|
+
let stdout = "";
|
|
78
|
+
let stderr = "";
|
|
79
|
+
let timedOut = false;
|
|
80
|
+
let settled = false;
|
|
81
|
+
|
|
82
|
+
const capture = (sink: "stdout" | "stderr") => (chunk: string) => {
|
|
83
|
+
const current = sink === "stdout" ? stdout : stderr;
|
|
84
|
+
if (current.length >= MAX_OUTPUT_BYTES) return;
|
|
85
|
+
const next = current + chunk.slice(0, MAX_OUTPUT_BYTES - current.length);
|
|
86
|
+
if (sink === "stdout") stdout = next;
|
|
87
|
+
else stderr = next;
|
|
88
|
+
};
|
|
89
|
+
child.stdout?.setEncoding("utf8");
|
|
90
|
+
child.stderr?.setEncoding("utf8");
|
|
91
|
+
child.stdout?.on("data", capture("stdout"));
|
|
92
|
+
child.stderr?.on("data", capture("stderr"));
|
|
93
|
+
|
|
94
|
+
const killTree = () => {
|
|
95
|
+
// Negative pid = the whole process group the detached shell leads.
|
|
96
|
+
try {
|
|
97
|
+
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
98
|
+
} catch {
|
|
99
|
+
try {
|
|
100
|
+
child.kill("SIGKILL");
|
|
101
|
+
} catch {
|
|
102
|
+
// Already gone.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
timedOut = true;
|
|
108
|
+
killTree();
|
|
109
|
+
}, timeoutMs);
|
|
110
|
+
timer.unref();
|
|
111
|
+
|
|
112
|
+
const finish = (result: Omit<HookRunResult, "durationMs">) => {
|
|
113
|
+
if (settled) return;
|
|
114
|
+
settled = true;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
resolve({ ...result, durationMs: Date.now() - started });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
child.on("error", (error) => {
|
|
120
|
+
finish({ exitCode: null, timedOut, spawnError: error.message, stdout, stderr });
|
|
121
|
+
});
|
|
122
|
+
child.on("close", (code) => {
|
|
123
|
+
finish({ exitCode: code, timedOut, stdout, stderr });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// A hook that never reads stdin (e.g. plain `exit 2`) closes the pipe
|
|
127
|
+
// early; the resulting EPIPE must not take the extension down.
|
|
128
|
+
child.stdin?.on("error", () => {});
|
|
129
|
+
child.stdin?.write(stdinJson);
|
|
130
|
+
child.stdin?.end();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hooks extension — Claude Code-compatible command hooks.
|
|
3
|
+
*
|
|
4
|
+
* Reads CC hook config (user/managed .claude settings always; project/local
|
|
5
|
+
* after a once-per-config consent — trust.ts; plugin hooks/hooks.json as user
|
|
6
|
+
* scope), matches CC-style matchers against tool calls and lifecycle events,
|
|
7
|
+
* runs the commands with the CC stdin/stdout protocol (protocol.ts,
|
|
8
|
+
* executor.ts), and applies the verdicts.
|
|
9
|
+
*
|
|
10
|
+
* Position in package.json pi.extensions is load-bearing: this extension must
|
|
11
|
+
* run its tool_call handler BEFORE worktree/file-tracker/permissions, so hook
|
|
12
|
+
* matchers and updatedInput see the tool call as the model produced it, and
|
|
13
|
+
* everything downstream — including the permission gate, safety floor, and
|
|
14
|
+
* auto-mode classifier — evaluates the hook-rewritten input (CC semantics).
|
|
15
|
+
*
|
|
16
|
+
* A hook can block anything, loaders included (tool_search/skill/
|
|
17
|
+
* structured_output) — full CC fidelity, the user's config is the user's
|
|
18
|
+
* choice; see docs/findings.md for the foot-gun note. A hook can never
|
|
19
|
+
* pre-approve: "allow" is informational and the permission gate still runs.
|
|
20
|
+
*
|
|
21
|
+
* pi's emitToolCall has no try/catch around handlers, so every hook dispatch
|
|
22
|
+
* here is wrapped — a throwing hook fails open, never takes the turn down.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { claudeConfigDir } from "../lib/paths.ts";
|
|
27
|
+
import { appendHookLog, formatDebugLine, hooksDebugEnabled, hooksLogPath } from "./debug.ts";
|
|
28
|
+
import { runHookCommand } from "./executor.ts";
|
|
29
|
+
import { matcherApplies, ccToolName, toolMatchCandidates } from "./matcher.ts";
|
|
30
|
+
import { loadPluginHooks } from "./plugin-hooks.ts";
|
|
31
|
+
import {
|
|
32
|
+
type CcHookEvent,
|
|
33
|
+
type HookOutcome,
|
|
34
|
+
type HookStdinPayload,
|
|
35
|
+
interpretHookResult,
|
|
36
|
+
} from "./protocol.ts";
|
|
37
|
+
import { type HookCommand, type HooksSource, loadHookSettings } from "./settings.ts";
|
|
38
|
+
import { projectHooksApproved } from "./trust.ts";
|
|
39
|
+
|
|
40
|
+
interface MatchedHook {
|
|
41
|
+
source: HooksSource;
|
|
42
|
+
hook: HookCommand;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function hooksExtension(pi: ExtensionAPI) {
|
|
46
|
+
let stopHookActive = false;
|
|
47
|
+
let pendingPromptContext: string[] = [];
|
|
48
|
+
|
|
49
|
+
const notify = (ctx: ExtensionContext, message: string) => {
|
|
50
|
+
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
51
|
+
else process.stderr.write(`${message}\n`);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** All hooks for an event across trusted sources, matcher already applied. */
|
|
55
|
+
const collectHooks = async (
|
|
56
|
+
ctx: ExtensionContext,
|
|
57
|
+
event: CcHookEvent,
|
|
58
|
+
matchValue: { candidates?: string[]; ignoreMatcher?: boolean },
|
|
59
|
+
): Promise<MatchedHook[]> => {
|
|
60
|
+
const claudeDir = claudeConfigDir();
|
|
61
|
+
const loaded = loadHookSettings(claudeDir, ctx.cwd);
|
|
62
|
+
const pluginSources = loadPluginHooks(claudeDir, loaded.diagnostics);
|
|
63
|
+
if (hooksDebugEnabled()) {
|
|
64
|
+
for (const diagnostic of loaded.diagnostics) process.stderr.write(`[hooks] ${diagnostic}\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const projectSources = loaded.sources.filter((s) => s.scope === "project" || s.scope === "local");
|
|
68
|
+
const hasProjectHooks = projectSources.some((s) => s.config[event]?.length);
|
|
69
|
+
let projectAllowed = true;
|
|
70
|
+
if (hasProjectHooks) {
|
|
71
|
+
projectAllowed = await projectHooksApproved(ctx.cwd, projectSources, {
|
|
72
|
+
hasUI: ctx.hasUI,
|
|
73
|
+
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
74
|
+
notify: (message) => notify(ctx, message),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sources = [
|
|
79
|
+
...loaded.sources.filter((s) => s.scope === "user" || s.scope === "managed"),
|
|
80
|
+
...pluginSources,
|
|
81
|
+
...(projectAllowed ? projectSources : []),
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const matched: MatchedHook[] = [];
|
|
85
|
+
for (const source of sources) {
|
|
86
|
+
for (const entry of source.config[event] ?? []) {
|
|
87
|
+
const applies = matchValue.ignoreMatcher || matcherApplies(entry.matcher, matchValue.candidates ?? []);
|
|
88
|
+
if (!applies) continue;
|
|
89
|
+
for (const hook of entry.hooks) matched.push({ source, hook });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return matched;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const basePayload = (ctx: ExtensionContext, event: CcHookEvent): HookStdinPayload => ({
|
|
96
|
+
session_id: ctx.sessionManager.getSessionId(),
|
|
97
|
+
transcript_path: ctx.sessionManager.getSessionFile() ?? "",
|
|
98
|
+
cwd: ctx.cwd,
|
|
99
|
+
hook_event_name: event,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run every matched hook in parallel (CC runs matching hooks concurrently)
|
|
104
|
+
* and merge: first block wins, updatedInput merges in order, contexts and
|
|
105
|
+
* systemMessages concatenate. Any throw inside is caught → fail open.
|
|
106
|
+
*/
|
|
107
|
+
const dispatch = async (
|
|
108
|
+
ctx: ExtensionContext,
|
|
109
|
+
event: CcHookEvent,
|
|
110
|
+
matchValue: { candidates?: string[]; ignoreMatcher?: boolean },
|
|
111
|
+
payload: HookStdinPayload,
|
|
112
|
+
): Promise<HookOutcome> => {
|
|
113
|
+
const merged: HookOutcome = {};
|
|
114
|
+
try {
|
|
115
|
+
const hooks = await collectHooks(ctx, event, matchValue);
|
|
116
|
+
if (hooks.length === 0) return merged;
|
|
117
|
+
const stdin = JSON.stringify(payload);
|
|
118
|
+
const outcomes = await Promise.all(
|
|
119
|
+
hooks.map(async ({ source, hook }) => {
|
|
120
|
+
const run = await runHookCommand(hook.command, stdin, {
|
|
121
|
+
cwd: ctx.cwd,
|
|
122
|
+
timeoutSeconds: hook.timeout,
|
|
123
|
+
projectDir: ctx.cwd,
|
|
124
|
+
});
|
|
125
|
+
const outcome = interpretHookResult(event, run);
|
|
126
|
+
const decision = outcome.block ? "block" : outcome.additionalContext ? "context" : run.exitCode === 0 || run.exitCode === 2 ? "allow" : "error";
|
|
127
|
+
const logEntry = {
|
|
128
|
+
event,
|
|
129
|
+
scope: source.pluginName ? `plugin:${source.pluginName}` : source.scope,
|
|
130
|
+
command: hook.command,
|
|
131
|
+
decision: decision as "allow" | "block" | "context" | "error",
|
|
132
|
+
reason: outcome.block?.reason,
|
|
133
|
+
exitCode: run.exitCode,
|
|
134
|
+
durationMs: run.durationMs,
|
|
135
|
+
};
|
|
136
|
+
if (hooksDebugEnabled()) process.stderr.write(`${formatDebugLine(logEntry)}\n`);
|
|
137
|
+
if (process.env.CC_HOOKS_DEBUG === "2") {
|
|
138
|
+
appendHookLog({ ts: new Date().toISOString(), sessionId: payload.session_id, ...logEntry }, hooksLogPath());
|
|
139
|
+
}
|
|
140
|
+
return outcome;
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
for (const outcome of outcomes) {
|
|
144
|
+
merged.block ??= outcome.block;
|
|
145
|
+
if (outcome.updatedInput) merged.updatedInput = { ...merged.updatedInput, ...outcome.updatedInput };
|
|
146
|
+
if ("updatedToolResult" in outcome) merged.updatedToolResult = outcome.updatedToolResult;
|
|
147
|
+
if (outcome.additionalContext) {
|
|
148
|
+
merged.additionalContext = [merged.additionalContext, outcome.additionalContext].filter(Boolean).join("\n");
|
|
149
|
+
}
|
|
150
|
+
if (outcome.systemMessage) {
|
|
151
|
+
merged.systemMessage = [merged.systemMessage, outcome.systemMessage].filter(Boolean).join("\n");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// Fail open: a broken hook pipeline must never take the turn down.
|
|
156
|
+
if (hooksDebugEnabled()) {
|
|
157
|
+
process.stderr.write(`[hooks] dispatch error (${event}): ${error instanceof Error ? error.message : error}\n`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (merged.systemMessage) notify(ctx, merged.systemMessage);
|
|
161
|
+
return merged;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Hidden custom message the model sees as context, CC's additionalContext. */
|
|
165
|
+
const injectContext = (text: string, midLoop: boolean) => {
|
|
166
|
+
const message = { customType: "one-code:hook-context", content: `<hook-additional-context>\n${text}\n</hook-additional-context>`, display: false };
|
|
167
|
+
try {
|
|
168
|
+
if (midLoop) pi.sendMessage(message, { deliverAs: "steer" });
|
|
169
|
+
else pi.sendMessage(message);
|
|
170
|
+
} catch {
|
|
171
|
+
try {
|
|
172
|
+
pi.sendMessage(message);
|
|
173
|
+
} catch {
|
|
174
|
+
// No session to speak to; drop the context.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// ---- PreToolUse ---------------------------------------------------------
|
|
180
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
181
|
+
const payload: HookStdinPayload = {
|
|
182
|
+
...basePayload(ctx, "PreToolUse"),
|
|
183
|
+
tool_name: ccToolName(event.toolName),
|
|
184
|
+
tool_input: event.input as Record<string, unknown>,
|
|
185
|
+
};
|
|
186
|
+
const outcome = await dispatch(ctx, "PreToolUse", { candidates: toolMatchCandidates(event.toolName) }, payload);
|
|
187
|
+
if (outcome.block) return { block: true, reason: `PreToolUse hook: ${outcome.block.reason}` };
|
|
188
|
+
if (outcome.updatedInput) {
|
|
189
|
+
// In-place, so worktree/file-tracker/permissions (later in the
|
|
190
|
+
// extension order) all see the rewritten input — CC applies hook
|
|
191
|
+
// input updates before permission evaluation.
|
|
192
|
+
Object.assign(event.input as Record<string, unknown>, outcome.updatedInput);
|
|
193
|
+
}
|
|
194
|
+
if (outcome.additionalContext) injectContext(outcome.additionalContext, true);
|
|
195
|
+
return undefined;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---- PostToolUse --------------------------------------------------------
|
|
199
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
200
|
+
const payload: HookStdinPayload = {
|
|
201
|
+
...basePayload(ctx, "PostToolUse"),
|
|
202
|
+
tool_name: ccToolName(event.toolName),
|
|
203
|
+
tool_input: event.input,
|
|
204
|
+
tool_response: { content: event.content, is_error: event.isError },
|
|
205
|
+
};
|
|
206
|
+
const outcome = await dispatch(ctx, "PostToolUse", { candidates: toolMatchCandidates(event.toolName) }, payload);
|
|
207
|
+
if (!outcome.block && outcome.updatedToolResult === undefined && !outcome.additionalContext) return undefined;
|
|
208
|
+
|
|
209
|
+
let content = [...event.content];
|
|
210
|
+
let isError = event.isError;
|
|
211
|
+
if (outcome.updatedToolResult !== undefined) {
|
|
212
|
+
const replacement = outcome.updatedToolResult;
|
|
213
|
+
content = [{ type: "text", text: typeof replacement === "string" ? replacement : JSON.stringify(replacement) }];
|
|
214
|
+
}
|
|
215
|
+
if (outcome.block) {
|
|
216
|
+
// The tool already ran; the only channel left is the result the
|
|
217
|
+
// model reads, so the objection is delivered there (CC feeds
|
|
218
|
+
// PostToolUse block reasons back to the model the same way).
|
|
219
|
+
content = [{ type: "text" as const, text: `PostToolUse hook: ${outcome.block.reason}` }, ...content];
|
|
220
|
+
isError = true;
|
|
221
|
+
}
|
|
222
|
+
if (outcome.additionalContext) {
|
|
223
|
+
content = [...content, { type: "text" as const, text: `<hook-additional-context>\n${outcome.additionalContext}\n</hook-additional-context>` }];
|
|
224
|
+
}
|
|
225
|
+
return { content, isError };
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ---- UserPromptSubmit ---------------------------------------------------
|
|
229
|
+
pi.on("input", async (event, ctx) => {
|
|
230
|
+
if (event.source === "extension") return undefined;
|
|
231
|
+
stopHookActive = false;
|
|
232
|
+
const payload: HookStdinPayload = { ...basePayload(ctx, "UserPromptSubmit"), prompt: event.text };
|
|
233
|
+
const outcome = await dispatch(ctx, "UserPromptSubmit", { ignoreMatcher: true }, payload);
|
|
234
|
+
if (outcome.block) {
|
|
235
|
+
notify(ctx, `Prompt blocked by UserPromptSubmit hook: ${outcome.block.reason}`);
|
|
236
|
+
return { action: "handled" as const };
|
|
237
|
+
}
|
|
238
|
+
if (outcome.additionalContext) pendingPromptContext.push(outcome.additionalContext);
|
|
239
|
+
return undefined;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Prompt-hook context is injected right before the agent loop builds its
|
|
243
|
+
// context, so it lands on the same turn as the prompt that produced it.
|
|
244
|
+
pi.on("before_agent_start", () => {
|
|
245
|
+
if (pendingPromptContext.length === 0) return;
|
|
246
|
+
const texts = pendingPromptContext;
|
|
247
|
+
pendingPromptContext = [];
|
|
248
|
+
for (const text of texts) injectContext(text, false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ---- SessionStart / SessionEnd -----------------------------------------
|
|
252
|
+
const dispatchSessionStart = async (ctx: ExtensionContext, source: string) => {
|
|
253
|
+
const payload: HookStdinPayload = { ...basePayload(ctx, "SessionStart"), source };
|
|
254
|
+
const outcome = await dispatch(ctx, "SessionStart", { candidates: [source] }, payload);
|
|
255
|
+
if (outcome.additionalContext) injectContext(outcome.additionalContext, false);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
pi.on("session_start", async (event, ctx) => {
|
|
259
|
+
const reason = (event as { reason?: string }).reason ?? "startup";
|
|
260
|
+
if (reason === "reload" || reason === "fork") return;
|
|
261
|
+
await dispatchSessionStart(ctx, reason === "new" ? "startup" : reason);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
265
|
+
const payload: HookStdinPayload = { ...basePayload(ctx, "SessionEnd"), source: "other" };
|
|
266
|
+
// Fire and forget: the process is on its way out; nothing to apply.
|
|
267
|
+
void dispatch(ctx, "SessionEnd", { ignoreMatcher: true }, payload);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ---- Stop ---------------------------------------------------------------
|
|
271
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
272
|
+
const payload: HookStdinPayload = { ...basePayload(ctx, "Stop"), stop_hook_active: stopHookActive };
|
|
273
|
+
const outcome = await dispatch(ctx, "Stop", { ignoreMatcher: true }, payload);
|
|
274
|
+
if (!outcome.block) return;
|
|
275
|
+
stopHookActive = true;
|
|
276
|
+
try {
|
|
277
|
+
pi.sendMessage(
|
|
278
|
+
{
|
|
279
|
+
customType: "one-code:hook-stop",
|
|
280
|
+
content: `Stop hook blocked stopping: ${outcome.block.reason}`,
|
|
281
|
+
display: false,
|
|
282
|
+
},
|
|
283
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
284
|
+
);
|
|
285
|
+
} catch {
|
|
286
|
+
// Session is gone; nothing to continue.
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---- PreCompact / PostCompact ------------------------------------------
|
|
291
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
292
|
+
const reason = (event as { reason?: string }).reason;
|
|
293
|
+
const trigger = reason === "manual" ? "manual" : "auto";
|
|
294
|
+
const payload: HookStdinPayload = { ...basePayload(ctx, "PreCompact"), trigger };
|
|
295
|
+
const outcome = await dispatch(ctx, "PreCompact", { candidates: [trigger] }, payload);
|
|
296
|
+
if (outcome.block) {
|
|
297
|
+
notify(ctx, `Compaction cancelled by PreCompact hook: ${outcome.block.reason}`);
|
|
298
|
+
return { cancel: true };
|
|
299
|
+
}
|
|
300
|
+
return undefined;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
304
|
+
const payload: HookStdinPayload = { ...basePayload(ctx, "PostCompact"), trigger: "auto" };
|
|
305
|
+
const outcome = await dispatch(ctx, "PostCompact", { ignoreMatcher: true }, payload);
|
|
306
|
+
if (outcome.additionalContext) injectContext(outcome.additionalContext, false);
|
|
307
|
+
// CC fires SessionStart(source: "compact") after compaction.
|
|
308
|
+
await dispatchSessionStart(ctx, "compact");
|
|
309
|
+
});
|
|
310
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook matcher evaluation (pure). Claude Code matchers are anchored,
|
|
3
|
+
* case-insensitive regexes over CC tool names ("Bash", "Edit|Write",
|
|
4
|
+
* "mcp__server__.*"); One Code tools are snake_case, so every candidate
|
|
5
|
+
* spelling of a tool — native name, canonical CC name, and the alias table
|
|
6
|
+
* from permissions/matcher.ts — is tested.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ccAliasesForTool } from "../permissions/matcher.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Canonical Claude Code PascalCase name per One Code tool, for hook matchers
|
|
13
|
+
* and the `tool_name` stdin field (CC hook scripts compare exact strings like
|
|
14
|
+
* "Bash"). Tools with no CC counterpart pass through unchanged.
|
|
15
|
+
*/
|
|
16
|
+
const CC_CANONICAL: Record<string, string> = {
|
|
17
|
+
bash: "Bash",
|
|
18
|
+
read: "Read",
|
|
19
|
+
edit: "Edit",
|
|
20
|
+
write: "Write",
|
|
21
|
+
grep: "Grep",
|
|
22
|
+
find: "Glob",
|
|
23
|
+
ls: "LS",
|
|
24
|
+
notebook_edit: "NotebookEdit",
|
|
25
|
+
web_fetch: "WebFetch",
|
|
26
|
+
web_search: "WebSearch",
|
|
27
|
+
subagent: "Task",
|
|
28
|
+
todo_write: "TodoWrite",
|
|
29
|
+
skill: "Skill",
|
|
30
|
+
ask_user_question: "AskUserQuestion",
|
|
31
|
+
enter_plan_mode: "EnterPlanMode",
|
|
32
|
+
exit_plan_mode: "ExitPlanMode",
|
|
33
|
+
task_create: "TaskCreate",
|
|
34
|
+
task_get: "TaskGet",
|
|
35
|
+
task_list: "TaskList",
|
|
36
|
+
task_update: "TaskUpdate",
|
|
37
|
+
task_output: "TaskOutput",
|
|
38
|
+
task_stop: "TaskStop",
|
|
39
|
+
monitor: "Monitor",
|
|
40
|
+
schedule_wakeup: "ScheduleWakeup",
|
|
41
|
+
send_message: "SendMessage",
|
|
42
|
+
enter_worktree: "EnterWorktree",
|
|
43
|
+
exit_worktree: "ExitWorktree",
|
|
44
|
+
workflow: "Workflow",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function ccToolName(nativeName: string): string {
|
|
48
|
+
if (nativeName.startsWith("mcp__")) return nativeName;
|
|
49
|
+
return CC_CANONICAL[nativeName] ?? nativeName;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every spelling a matcher may reasonably target for this tool. */
|
|
53
|
+
export function toolMatchCandidates(nativeName: string): string[] {
|
|
54
|
+
return [...new Set([nativeName, ccToolName(nativeName), ...ccAliasesForTool(nativeName)])];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a CC matcher applies to any candidate spelling. Anchored and
|
|
59
|
+
* case-insensitive (`^(?:matcher)$`, the CC semantics); an invalid regex
|
|
60
|
+
* falls back to case-insensitive exact comparison rather than throwing.
|
|
61
|
+
*/
|
|
62
|
+
export function matcherApplies(matcher: string | undefined, candidates: string[]): boolean {
|
|
63
|
+
if (matcher === undefined || matcher === "" || matcher === "*") return true;
|
|
64
|
+
let regex: RegExp | undefined;
|
|
65
|
+
try {
|
|
66
|
+
regex = new RegExp(`^(?:${matcher})$`, "i");
|
|
67
|
+
} catch {
|
|
68
|
+
regex = undefined;
|
|
69
|
+
}
|
|
70
|
+
const lowered = matcher.toLowerCase();
|
|
71
|
+
return candidates.some((candidate) => (regex ? regex.test(candidate) : candidate.toLowerCase() === lowered));
|
|
72
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin hook discovery (pure): each enabled plugin's hooks/hooks.json,
|
|
3
|
+
* re-derived per call via lib/plugins.ts like every other plugin consumer
|
|
4
|
+
* (no cross-extension state under jiti).
|
|
5
|
+
*
|
|
6
|
+
* Plugin hooks are user scope — installing the plugin was the consent step —
|
|
7
|
+
* so they run without the project-trust prompt, matching how plugin commands,
|
|
8
|
+
* skills, and MCP servers already work.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { discoverPlugins, pluginResources } from "../lib/plugins.ts";
|
|
13
|
+
import { parseHooksBlock, type HooksSource } from "./settings.ts";
|
|
14
|
+
|
|
15
|
+
interface CacheEntry {
|
|
16
|
+
mtimeMs: number;
|
|
17
|
+
source: HooksSource | undefined;
|
|
18
|
+
diagnostics: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `loadPluginHooks` runs on every tool_call AND tool_result, so each plugin's
|
|
23
|
+
* hooks.json is mtime-cached like the settings files (settings.ts) instead of
|
|
24
|
+
* re-read and re-validated twice per tool invocation.
|
|
25
|
+
*/
|
|
26
|
+
const cache = new Map<string, CacheEntry>();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read one plugin hooks.json. CC's format nests events under a "hooks" key;
|
|
30
|
+
* a bare top-level events map is accepted too, defensively. Commands may
|
|
31
|
+
* reference ${CLAUDE_PLUGIN_ROOT}, expanded to the plugin's install path.
|
|
32
|
+
*/
|
|
33
|
+
function readPluginHooksFile(hooksConfig: string, pluginName: string, pluginPath: string, diagnostics: string[]): HooksSource | undefined {
|
|
34
|
+
let mtimeMs: number;
|
|
35
|
+
try {
|
|
36
|
+
mtimeMs = statSync(hooksConfig).mtimeMs;
|
|
37
|
+
} catch {
|
|
38
|
+
cache.delete(hooksConfig);
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const cached = cache.get(hooksConfig);
|
|
42
|
+
if (cached && cached.mtimeMs === mtimeMs) {
|
|
43
|
+
diagnostics.push(...cached.diagnostics);
|
|
44
|
+
return cached.source;
|
|
45
|
+
}
|
|
46
|
+
const fileDiagnostics: string[] = [];
|
|
47
|
+
let source: HooksSource | undefined;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(readFileSync(hooksConfig, "utf-8")) as Record<string, unknown>;
|
|
50
|
+
const block = typeof parsed.hooks === "object" && parsed.hooks !== null ? parsed.hooks : parsed;
|
|
51
|
+
const config = parseHooksBlock(block, hooksConfig, fileDiagnostics);
|
|
52
|
+
for (const entries of Object.values(config)) {
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
for (const hook of entry.hooks) {
|
|
55
|
+
hook.command = hook.command.replaceAll("${CLAUDE_PLUGIN_ROOT}", pluginPath);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (Object.keys(config).length > 0) {
|
|
60
|
+
source = { scope: "plugin", path: hooksConfig, pluginName, config };
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
fileDiagnostics.push(`${hooksConfig}: unreadable plugin hooks skipped (${error instanceof Error ? error.message : error})`);
|
|
64
|
+
}
|
|
65
|
+
cache.set(hooksConfig, { mtimeMs, source, diagnostics: fileDiagnostics });
|
|
66
|
+
diagnostics.push(...fileDiagnostics);
|
|
67
|
+
return source;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function loadPluginHooks(claudeDir: string, diagnostics: string[]): HooksSource[] {
|
|
71
|
+
const sources: HooksSource[] = [];
|
|
72
|
+
for (const plugin of discoverPlugins(claudeDir).plugins) {
|
|
73
|
+
const { hooksConfig } = pluginResources(plugin);
|
|
74
|
+
if (!hooksConfig) continue;
|
|
75
|
+
const source = readPluginHooksFile(hooksConfig, plugin.name, plugin.path, diagnostics);
|
|
76
|
+
if (source) sources.push(source);
|
|
77
|
+
}
|
|
78
|
+
return sources;
|
|
79
|
+
}
|