@ryan_nookpi/pi-extension-claude-hooks-bridge 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.
Files changed (3) hide show
  1. package/README.md +27 -0
  2. package/index.ts +745 -0
  3. package/package.json +34 -0
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # claude-hooks-bridge
2
+
3
+ Bridge [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) (`.claude/settings.json`) into pi extension lifecycle events.
4
+
5
+ ## What it does
6
+
7
+ - Reads `.claude/settings.json` hooks configuration from the project root
8
+ - Executes hooks at matching lifecycle events:
9
+ - **SessionStart** → `session_start`
10
+ - **UserPromptSubmit** → `before_agent_start`
11
+ - **PreToolUse** → `tool_call` (can block / ask for confirmation)
12
+ - **PostToolUse** → `tool_result`
13
+ - **Stop** → `agent_end` (can queue follow-up messages)
14
+ - Supports matcher patterns (regex or pipe-separated tool names)
15
+ - Maps pi tool names to Claude Code equivalents (`bash` → `Bash`, etc.)
16
+ - Handles hook JSON output with `permissionDecision` / exit code 2 for blocking
17
+ - Provides transcript files for Stop hooks
18
+
19
+ ## Requirements
20
+
21
+ - **bash** is required — hooks are executed via `bash -lc`. Works on macOS and Linux. Not natively supported on Windows.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pi install npm:@ryan_nookpi/pi-extension-claude-hooks-bridge
27
+ ```
package/index.ts ADDED
@@ -0,0 +1,745 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type {
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ ToolCallEvent,
9
+ ToolCallEventResult,
10
+ ToolResultEvent,
11
+ } from "@mariozechner/pi-coding-agent";
12
+
13
+ export type ClaudeHookEventName = "SessionStart" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "Stop";
14
+
15
+ export type JsonRecord = Record<string, unknown>;
16
+
17
+ export interface ClaudeCommandHook {
18
+ type?: string;
19
+ command?: string;
20
+ timeout?: number;
21
+ }
22
+
23
+ export interface ClaudeHookGroup {
24
+ matcher?: string;
25
+ hooks?: ClaudeCommandHook[];
26
+ }
27
+
28
+ export interface ClaudeSettings {
29
+ hooks?: Record<string, ClaudeHookGroup[]>;
30
+ }
31
+
32
+ interface LoadedSettings {
33
+ path: string;
34
+ settings: ClaudeSettings | null;
35
+ parseError?: string;
36
+ }
37
+
38
+ interface SettingsCacheEntry {
39
+ mtimeMs: number;
40
+ loaded: LoadedSettings;
41
+ }
42
+
43
+ export interface HookExecResult {
44
+ command: string;
45
+ code: number;
46
+ stdout: string;
47
+ stderr: string;
48
+ timedOut: boolean;
49
+ json: unknown | null;
50
+ }
51
+
52
+ export interface HookDecision {
53
+ action: "none" | "allow" | "ask" | "block";
54
+ reason?: string;
55
+ }
56
+
57
+ const SETTINGS_REL_PATH = path.join(".claude", "settings.json");
58
+ const TRANSCRIPT_TMP_DIR = path.join(os.tmpdir(), "pi-claude-hooks-bridge");
59
+ export const DEFAULT_HOOK_TIMEOUT_MS = 600_000;
60
+
61
+ /**
62
+ * Convert Claude Code hook timeout (seconds) to milliseconds.
63
+ * Official docs: "Seconds before canceling. Defaults: 600 for command"
64
+ */
65
+ export function convertHookTimeoutToMs(timeoutSeconds: number | undefined): number {
66
+ if (typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0) {
67
+ return timeoutSeconds * 1000;
68
+ }
69
+ return DEFAULT_HOOK_TIMEOUT_MS;
70
+ }
71
+
72
+ export const BUILTIN_TOOL_ALIASES: Record<string, string> = {
73
+ bash: "Bash",
74
+ read: "Read",
75
+ edit: "Edit",
76
+ write: "Write",
77
+ grep: "Grep",
78
+ find: "Find",
79
+ ls: "LS",
80
+ };
81
+
82
+ const settingsCache = new Map<string, SettingsCacheEntry>();
83
+ const parseErrorNotified = new Set<string>();
84
+ const stopHookActiveBySession = new Map<string, boolean>();
85
+
86
+ function getSessionId(ctx: ExtensionContext): string {
87
+ try {
88
+ const id = ctx.sessionManager.getSessionId();
89
+ return id || "unknown";
90
+ } catch {
91
+ return "unknown";
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Pinned session ID for CC hook payloads.
97
+ *
98
+ * Pi's sessionManager.getSessionId() can change mid-session (e.g. fork,
99
+ * navigateTree, or internal resets). CC hooks use session_id as the filename,
100
+ * so an unstable ID creates a new file per turn instead of accumulating
101
+ * within a single session file.
102
+ *
103
+ * We pin the ID on the first event and only reset on session_start
104
+ * or session_shutdown.
105
+ */
106
+ let pinnedHookSessionId: string | null = null;
107
+
108
+ function getHookSessionId(ctx: ExtensionContext): string {
109
+ if (!pinnedHookSessionId) {
110
+ pinnedHookSessionId = getSessionId(ctx);
111
+ }
112
+ return pinnedHookSessionId;
113
+ }
114
+
115
+ function getSettingsPath(cwd: string): string {
116
+ return path.join(cwd, SETTINGS_REL_PATH);
117
+ }
118
+
119
+ function loadSettings(cwd: string): LoadedSettings {
120
+ const settingsPath = getSettingsPath(cwd);
121
+
122
+ if (!existsSync(settingsPath)) {
123
+ return { path: settingsPath, settings: null };
124
+ }
125
+
126
+ let mtimeMs = 0;
127
+ try {
128
+ mtimeMs = statSync(settingsPath).mtimeMs;
129
+ } catch {
130
+ return { path: settingsPath, settings: null, parseError: "settings 파일 상태를 읽을 수 없습니다." };
131
+ }
132
+
133
+ const cached = settingsCache.get(settingsPath);
134
+ if (cached && cached.mtimeMs === mtimeMs) {
135
+ return cached.loaded;
136
+ }
137
+
138
+ try {
139
+ const raw = readFileSync(settingsPath, "utf8");
140
+ const parsed = JSON.parse(raw);
141
+ const settings = typeof parsed === "object" && parsed ? (parsed as ClaudeSettings) : null;
142
+ const loaded: LoadedSettings = { path: settingsPath, settings };
143
+ settingsCache.set(settingsPath, { mtimeMs, loaded });
144
+ return loaded;
145
+ } catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ const loaded: LoadedSettings = {
148
+ path: settingsPath,
149
+ settings: null,
150
+ parseError: `.claude/settings.json 파싱 실패: ${message}`,
151
+ };
152
+ settingsCache.set(settingsPath, { mtimeMs, loaded });
153
+ return loaded;
154
+ }
155
+ }
156
+
157
+ export function getHookGroups(settings: ClaudeSettings | null, eventName: ClaudeHookEventName): ClaudeHookGroup[] {
158
+ if (!settings?.hooks) return [];
159
+ const groups = settings.hooks[eventName];
160
+ if (!Array.isArray(groups)) return [];
161
+ return groups;
162
+ }
163
+
164
+ export function getClaudeToolName(toolName: string): string {
165
+ return BUILTIN_TOOL_ALIASES[toolName] || toolName;
166
+ }
167
+
168
+ export function getMatcherCandidates(toolName: string): string[] {
169
+ const canonical = getClaudeToolName(toolName);
170
+ const set = new Set<string>([toolName, toolName.toLowerCase(), canonical, canonical.toLowerCase()]);
171
+ return Array.from(set);
172
+ }
173
+
174
+ export function matcherMatches(matcher: string | undefined, toolName: string): boolean {
175
+ if (!matcher || matcher.trim() === "") return true;
176
+
177
+ const candidates = getMatcherCandidates(toolName);
178
+
179
+ try {
180
+ const re = new RegExp(`^(?:${matcher})$`);
181
+ if (candidates.some((name) => re.test(name))) return true;
182
+ } catch {
183
+ // matcher가 정규식으로 유효하지 않아도 fallback 비교를 시도한다.
184
+ }
185
+
186
+ const tokens = matcher
187
+ .split("|")
188
+ .map((token) => token.trim())
189
+ .filter(Boolean);
190
+
191
+ if (tokens.length === 0) return false;
192
+ return tokens.some((token) =>
193
+ candidates.some((name) => name === token || name.toLowerCase() === token.toLowerCase()),
194
+ );
195
+ }
196
+
197
+ export function getCommandHooks(
198
+ settings: ClaudeSettings | null,
199
+ eventName: ClaudeHookEventName,
200
+ toolName?: string,
201
+ ): ClaudeCommandHook[] {
202
+ const groups = getHookGroups(settings, eventName);
203
+ const hooks: ClaudeCommandHook[] = [];
204
+
205
+ for (const group of groups) {
206
+ if (toolName && !matcherMatches(group.matcher, toolName)) continue;
207
+ if (!Array.isArray(group.hooks)) continue;
208
+
209
+ for (const hook of group.hooks) {
210
+ if (!hook || typeof hook !== "object") continue;
211
+ if (hook.type !== "command") continue;
212
+ if (typeof hook.command !== "string" || hook.command.trim() === "") continue;
213
+ hooks.push(hook);
214
+ }
215
+ }
216
+
217
+ return hooks;
218
+ }
219
+
220
+ function resolveMaybePath(inputPath: string, cwd: string): string {
221
+ if (path.isAbsolute(inputPath)) return path.normalize(inputPath);
222
+ return path.resolve(cwd, inputPath);
223
+ }
224
+
225
+ export function normalizeToolInput(toolName: string, rawInput: unknown, cwd: string): JsonRecord {
226
+ const input: JsonRecord = rawInput && typeof rawInput === "object" ? { ...(rawInput as JsonRecord) } : {};
227
+
228
+ const pathCandidate =
229
+ typeof input.path === "string"
230
+ ? input.path
231
+ : typeof input.file_path === "string"
232
+ ? input.file_path
233
+ : typeof input.filePath === "string"
234
+ ? input.filePath
235
+ : undefined;
236
+
237
+ if (pathCandidate) {
238
+ const absolute = resolveMaybePath(pathCandidate, cwd);
239
+ input.path = absolute;
240
+ input.file_path = absolute;
241
+ input.filePath = absolute;
242
+ }
243
+
244
+ if (toolName === "bash" && typeof input.command !== "string") {
245
+ input.command = "";
246
+ }
247
+
248
+ return input;
249
+ }
250
+
251
+ export function extractTextFromBlocks(content: unknown): string {
252
+ if (typeof content === "string") return content;
253
+ if (!Array.isArray(content)) return "";
254
+
255
+ const lines: string[] = [];
256
+ for (const block of content) {
257
+ if (!block || typeof block !== "object") continue;
258
+ const text = (block as JsonRecord).text;
259
+ if (typeof text === "string") lines.push(text);
260
+ }
261
+ return lines.join("");
262
+ }
263
+
264
+ function getLastAssistantMessage(ctx: ExtensionContext): string | undefined {
265
+ const entries = ctx.sessionManager.getEntries();
266
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
267
+ const entry = entries[i];
268
+ if (!entry || entry.type !== "message") continue;
269
+ if (entry.message.role !== "assistant") continue;
270
+ const text = extractTextFromBlocks(entry.message.content);
271
+ if (text) return text;
272
+ }
273
+ return undefined;
274
+ }
275
+
276
+ function mapAssistantTranscriptContent(content: Array<JsonRecord>): JsonRecord[] {
277
+ const mapped: JsonRecord[] = [];
278
+ for (const block of content) {
279
+ if (block.type === "text") {
280
+ mapped.push({ type: "text", text: block.text });
281
+ continue;
282
+ }
283
+ if (block.type === "toolCall") {
284
+ mapped.push({
285
+ type: "tool_use",
286
+ id: block.id,
287
+ name: block.name,
288
+ input: block.arguments,
289
+ });
290
+ }
291
+ }
292
+ return mapped;
293
+ }
294
+
295
+ function mapUserTranscriptContent(content: unknown): JsonRecord[] {
296
+ if (!Array.isArray(content)) return [];
297
+ const mapped: JsonRecord[] = [];
298
+ for (const block of content) {
299
+ if (block?.type === "text") {
300
+ mapped.push({ type: "text", text: block.text });
301
+ }
302
+ }
303
+ return mapped;
304
+ }
305
+
306
+ function mapTranscriptLine(message: { role: string; content: unknown; toolCallId?: string }): string | null {
307
+ if (message.role === "assistant") {
308
+ const mapped = Array.isArray(message.content)
309
+ ? mapAssistantTranscriptContent(message.content as Array<JsonRecord>)
310
+ : [];
311
+ return mapped.length > 0 ? JSON.stringify({ type: "assistant", message: { content: mapped } }) : null;
312
+ }
313
+
314
+ if (message.role === "user") {
315
+ const mapped = mapUserTranscriptContent(message.content);
316
+ return mapped.length > 0 ? JSON.stringify({ type: "user", message: { content: mapped } }) : null;
317
+ }
318
+
319
+ if (message.role !== "toolResult") {
320
+ return null;
321
+ }
322
+
323
+ const text = extractTextFromBlocks(message.content);
324
+ return JSON.stringify({
325
+ type: "user",
326
+ message: {
327
+ content: [
328
+ {
329
+ type: "tool_result",
330
+ tool_use_id: message.toolCallId,
331
+ content: [{ type: "text", text }],
332
+ },
333
+ ],
334
+ },
335
+ });
336
+ }
337
+
338
+ function toClaudeTranscriptLines(ctx: ExtensionContext): string[] {
339
+ const lines: string[] = [];
340
+ const entries = ctx.sessionManager.getEntries();
341
+
342
+ for (const entry of entries) {
343
+ if (!entry || entry.type !== "message") continue;
344
+ const line = mapTranscriptLine(entry.message as { role: string; content: unknown; toolCallId?: string });
345
+ if (line) lines.push(line);
346
+ }
347
+
348
+ return lines;
349
+ }
350
+
351
+ function createTranscriptFile(ctx: ExtensionContext, sessionId: string): string | undefined {
352
+ try {
353
+ const lines = toClaudeTranscriptLines(ctx);
354
+ mkdirSync(TRANSCRIPT_TMP_DIR, { recursive: true });
355
+ const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
356
+ const transcriptPath = path.join(TRANSCRIPT_TMP_DIR, `${safeSessionId}.jsonl`);
357
+ const content = lines.length > 0 ? `${lines.join("\n")}\n` : "";
358
+ writeFileSync(transcriptPath, content, "utf8");
359
+ return transcriptPath;
360
+ } catch {
361
+ return undefined;
362
+ }
363
+ }
364
+
365
+ export function parseJsonFromStdout(stdout: string): unknown | null {
366
+ const trimmed = stdout.trim();
367
+ if (!trimmed) return null;
368
+
369
+ try {
370
+ return JSON.parse(trimmed);
371
+ } catch {
372
+ // pass
373
+ }
374
+
375
+ const lines = trimmed
376
+ .split("\n")
377
+ .map((line) => line.trim())
378
+ .filter(Boolean);
379
+
380
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
381
+ try {
382
+ return JSON.parse(lines[i]);
383
+ } catch {
384
+ // pass
385
+ }
386
+ }
387
+
388
+ return null;
389
+ }
390
+
391
+ export function fallbackReason(stderr: string, stdout: string): string | undefined {
392
+ const text = stderr.trim() || stdout.trim();
393
+ if (!text) return undefined;
394
+ return text.length > 2000 ? `${text.slice(0, 2000)}...` : text;
395
+ }
396
+
397
+ export function extractDecision(result: HookExecResult): HookDecision {
398
+ const payload = result.json;
399
+ const asObj = payload && typeof payload === "object" ? (payload as JsonRecord) : undefined;
400
+ const hookSpecific = asObj?.hookSpecificOutput;
401
+ const hookSpecificObj = hookSpecific && typeof hookSpecific === "object" ? (hookSpecific as JsonRecord) : undefined;
402
+
403
+ const decisionRaw =
404
+ (typeof hookSpecificObj?.permissionDecision === "string" && hookSpecificObj.permissionDecision) ||
405
+ (typeof asObj?.permissionDecision === "string" && asObj.permissionDecision) ||
406
+ (typeof hookSpecificObj?.decision === "string" && hookSpecificObj.decision) ||
407
+ (typeof asObj?.decision === "string" && asObj.decision) ||
408
+ "";
409
+
410
+ const reason =
411
+ (typeof hookSpecificObj?.permissionDecisionReason === "string" && hookSpecificObj.permissionDecisionReason) ||
412
+ (typeof asObj?.permissionDecisionReason === "string" && asObj.permissionDecisionReason) ||
413
+ (typeof hookSpecificObj?.reason === "string" && hookSpecificObj.reason) ||
414
+ (typeof asObj?.reason === "string" && asObj.reason) ||
415
+ fallbackReason(result.stderr, result.stdout);
416
+
417
+ const decision = decisionRaw.toLowerCase();
418
+ if (decision === "allow") return { action: "allow", reason };
419
+ if (decision === "ask") return { action: "ask", reason };
420
+ if (decision === "deny" || decision === "block") return { action: "block", reason };
421
+
422
+ if (result.code === 2) {
423
+ return { action: "block", reason: reason || "Hook requested block (exit code 2)." };
424
+ }
425
+
426
+ return { action: "none", reason };
427
+ }
428
+
429
+ async function execCommandHook(
430
+ command: string,
431
+ cwd: string,
432
+ payload: JsonRecord,
433
+ timeoutMs: number,
434
+ ): Promise<HookExecResult> {
435
+ return new Promise((resolve) => {
436
+ const child = spawn("bash", ["-lc", command], {
437
+ cwd,
438
+ env: {
439
+ ...process.env,
440
+ CLAUDE_PROJECT_DIR: cwd,
441
+ PWD: cwd,
442
+ },
443
+ stdio: ["pipe", "pipe", "pipe"],
444
+ });
445
+
446
+ let stdout = "";
447
+ let stderr = "";
448
+ let settled = false;
449
+ let timedOut = false;
450
+
451
+ const finalize = (code: number) => {
452
+ if (settled) return;
453
+ settled = true;
454
+ const json = parseJsonFromStdout(stdout);
455
+ resolve({ command, code, stdout, stderr, timedOut, json });
456
+ };
457
+
458
+ let timeout: NodeJS.Timeout | undefined;
459
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
460
+ timeout = setTimeout(() => {
461
+ timedOut = true;
462
+ child.kill("SIGTERM");
463
+ setTimeout(() => child.kill("SIGKILL"), 1000);
464
+ }, timeoutMs);
465
+ }
466
+
467
+ child.stdout.on("data", (chunk: Buffer | string) => {
468
+ stdout += chunk.toString();
469
+ });
470
+
471
+ child.stderr.on("data", (chunk: Buffer | string) => {
472
+ stderr += chunk.toString();
473
+ });
474
+
475
+ child.on("error", (error) => {
476
+ if (timeout) clearTimeout(timeout);
477
+ stderr += `\n${error instanceof Error ? error.message : String(error)}`;
478
+ finalize(1);
479
+ });
480
+
481
+ child.on("close", (code) => {
482
+ if (timeout) clearTimeout(timeout);
483
+ finalize(typeof code === "number" ? code : 1);
484
+ });
485
+
486
+ try {
487
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
488
+ child.stdin.end();
489
+ } catch (error) {
490
+ stderr += `\nstdin write failed: ${error instanceof Error ? error.message : String(error)}`;
491
+ finalize(1);
492
+ }
493
+ });
494
+ }
495
+
496
+ function makeBasePayload(eventName: ClaudeHookEventName, ctx: ExtensionContext): JsonRecord {
497
+ return {
498
+ hook_event_name: eventName,
499
+ session_id: getHookSessionId(ctx),
500
+ cwd: ctx.cwd,
501
+ };
502
+ }
503
+
504
+ function buildPreToolUsePayload(event: ToolCallEvent, ctx: ExtensionContext): JsonRecord {
505
+ const toolInput = normalizeToolInput(event.toolName, event.input as unknown, ctx.cwd);
506
+ return {
507
+ ...makeBasePayload("PreToolUse", ctx),
508
+ tool_name: getClaudeToolName(event.toolName),
509
+ tool_input: toolInput,
510
+ tool_use_id: event.toolCallId,
511
+ };
512
+ }
513
+
514
+ function buildPostToolUsePayload(event: ToolResultEvent, ctx: ExtensionContext): JsonRecord {
515
+ const toolInput = normalizeToolInput(event.toolName, event.input as unknown, ctx.cwd);
516
+ return {
517
+ ...makeBasePayload("PostToolUse", ctx),
518
+ tool_name: getClaudeToolName(event.toolName),
519
+ tool_input: toolInput,
520
+ tool_response: {
521
+ is_error: Boolean(event.isError),
522
+ content: event.content,
523
+ details: event.details,
524
+ },
525
+ tool_use_id: event.toolCallId,
526
+ };
527
+ }
528
+
529
+ function notifyOnceForParseError(ctx: ExtensionContext, loaded: LoadedSettings): void {
530
+ if (!loaded.parseError) return;
531
+ if (!ctx.hasUI) return;
532
+ if (parseErrorNotified.has(loaded.path)) return;
533
+ parseErrorNotified.add(loaded.path);
534
+ ctx.ui.notify(`[claude-hooks-bridge] ${loaded.parseError}`, "warning");
535
+ }
536
+
537
+ export function countHooks(settings: ClaudeSettings): number {
538
+ if (!settings.hooks) return 0;
539
+ let total = 0;
540
+ for (const groups of Object.values(settings.hooks)) {
541
+ if (!Array.isArray(groups)) continue;
542
+ for (const group of groups) {
543
+ if (!Array.isArray(group.hooks)) continue;
544
+ total += group.hooks.filter((hook) => hook?.type === "command" && typeof hook.command === "string").length;
545
+ }
546
+ }
547
+ return total;
548
+ }
549
+
550
+ export function toBlockReason(reason: string | undefined, fallback: string): string {
551
+ const text = (reason || "").trim();
552
+ if (!text) return fallback;
553
+ if (text.length <= 2000) return text;
554
+ return `${text.slice(0, 2000)}...`;
555
+ }
556
+
557
+ async function runHooks(
558
+ settings: ClaudeSettings | null,
559
+ eventName: ClaudeHookEventName,
560
+ ctx: ExtensionContext,
561
+ payload: JsonRecord,
562
+ toolNameForMatcher?: string,
563
+ ): Promise<HookExecResult[]> {
564
+ const hooks = getCommandHooks(settings, eventName, toolNameForMatcher);
565
+ if (hooks.length === 0) return [];
566
+
567
+ const results: HookExecResult[] = [];
568
+
569
+ for (const hook of hooks) {
570
+ const timeoutMs = convertHookTimeoutToMs(hook.timeout);
571
+ const result = await execCommandHook(hook.command as string, ctx.cwd, payload, timeoutMs);
572
+ results.push(result);
573
+ }
574
+
575
+ return results;
576
+ }
577
+
578
+ function notifyHookCount(ctx: ExtensionContext, settings: ClaudeSettings | null): void {
579
+ if (!settings || !ctx.hasUI) return;
580
+ const total = countHooks(settings);
581
+ if (total > 0) {
582
+ ctx.ui.notify(`[claude-hooks-bridge] loaded ${total} hook(s) from ${SETTINGS_REL_PATH}`, "info");
583
+ }
584
+ }
585
+
586
+ function trimHookOutput(text: string): string {
587
+ return text.length > 1200 ? `${text.slice(0, 1200)}...` : text;
588
+ }
589
+
590
+ function notifySessionStartHookResult(ctx: ExtensionContext, result: HookExecResult): void {
591
+ if (!ctx.hasUI) return;
592
+ const out = result.stdout.trim();
593
+ const err = result.stderr.trim();
594
+ if (out) {
595
+ ctx.ui.notify(`[claude-hooks-bridge:SessionStart]\n${trimHookOutput(out)}`, "info");
596
+ }
597
+ if (err) {
598
+ ctx.ui.notify(`[claude-hooks-bridge:SessionStart stderr]\n${trimHookOutput(err)}`, "warning");
599
+ }
600
+ }
601
+
602
+ async function handleSessionStart(event: { reason?: string }, ctx: ExtensionContext): Promise<void> {
603
+ pinnedHookSessionId = getSessionId(ctx);
604
+ const sessionId = pinnedHookSessionId;
605
+ stopHookActiveBySession.set(sessionId, false);
606
+ if (event.reason === "resume" || event.reason === "fork") return;
607
+
608
+ const loaded = loadSettings(ctx.cwd);
609
+ notifyOnceForParseError(ctx, loaded);
610
+ const settings = loaded.settings;
611
+ notifyHookCount(ctx, settings);
612
+ if (!settings) return;
613
+
614
+ const results = await runHooks(settings, "SessionStart", ctx, makeBasePayload("SessionStart", ctx));
615
+ for (const result of results) {
616
+ notifySessionStartHookResult(ctx, result);
617
+ }
618
+ }
619
+
620
+ export default function claudeHooksBridge(pi: ExtensionAPI) {
621
+ pi.on("session_start", async (event, ctx) => {
622
+ await handleSessionStart(event, ctx);
623
+ });
624
+
625
+ pi.on("session_shutdown", async () => {
626
+ pinnedHookSessionId = null;
627
+ stopHookActiveBySession.clear();
628
+ });
629
+
630
+ pi.on("before_agent_start", async (event, ctx) => {
631
+ const loaded = loadSettings(ctx.cwd);
632
+ notifyOnceForParseError(ctx, loaded);
633
+ const settings = loaded.settings;
634
+ if (!settings) return;
635
+
636
+ const payload: JsonRecord = {
637
+ ...makeBasePayload("UserPromptSubmit", ctx),
638
+ prompt: event.prompt,
639
+ };
640
+ await runHooks(settings, "UserPromptSubmit", ctx, payload);
641
+ });
642
+
643
+ pi.on("tool_call", async (event, ctx): Promise<ToolCallEventResult | undefined> => {
644
+ const loaded = loadSettings(ctx.cwd);
645
+ notifyOnceForParseError(ctx, loaded);
646
+ const settings = loaded.settings;
647
+ if (!settings) return;
648
+
649
+ const payload = buildPreToolUsePayload(event, ctx);
650
+ const results = await runHooks(settings, "PreToolUse", ctx, payload, event.toolName);
651
+
652
+ for (const result of results) {
653
+ const decision = extractDecision(result);
654
+
655
+ if (decision.action === "ask") {
656
+ const reason = toBlockReason(decision.reason, "Hook requested permission.");
657
+
658
+ if (!ctx.hasUI) {
659
+ return { block: true, reason: `Blocked (no UI): ${reason}` };
660
+ }
661
+
662
+ const ok = await ctx.ui.confirm("Claude hook permission", reason, { timeout: 30_000 });
663
+ if (!ok) {
664
+ return {
665
+ block: true,
666
+ reason: toBlockReason(decision.reason, "Blocked by user confirmation from .claude hook."),
667
+ };
668
+ }
669
+ continue;
670
+ }
671
+
672
+ if (decision.action === "block") {
673
+ return {
674
+ block: true,
675
+ reason: toBlockReason(decision.reason, "Blocked by .claude PreToolUse hook."),
676
+ };
677
+ }
678
+ }
679
+
680
+ return undefined;
681
+ });
682
+
683
+ pi.on("tool_result", async (event, ctx) => {
684
+ const loaded = loadSettings(ctx.cwd);
685
+ notifyOnceForParseError(ctx, loaded);
686
+ const settings = loaded.settings;
687
+ if (!settings) return;
688
+
689
+ const payload = buildPostToolUsePayload(event, ctx);
690
+ await runHooks(settings, "PostToolUse", ctx, payload, event.toolName);
691
+ });
692
+
693
+ pi.on("agent_end", async (_event, ctx) => {
694
+ const loaded = loadSettings(ctx.cwd);
695
+ notifyOnceForParseError(ctx, loaded);
696
+ const settings = loaded.settings;
697
+ if (!settings) return;
698
+
699
+ const sessionId = getHookSessionId(ctx);
700
+ const stopHookActive = stopHookActiveBySession.get(sessionId) || false;
701
+ const transcriptPath = createTranscriptFile(ctx, sessionId);
702
+
703
+ const lastAssistantMessage = getLastAssistantMessage(ctx);
704
+ const payload: JsonRecord = {
705
+ ...makeBasePayload("Stop", ctx),
706
+ stop_hook_active: stopHookActive,
707
+ };
708
+ if (transcriptPath) payload.transcript_path = transcriptPath;
709
+ if (lastAssistantMessage) payload.last_assistant_message = lastAssistantMessage;
710
+
711
+ const results = await runHooks(settings, "Stop", ctx, payload);
712
+
713
+ let blockedReason: string | undefined;
714
+ for (const result of results) {
715
+ const decision = extractDecision(result);
716
+ if (decision.action === "block") {
717
+ blockedReason = toBlockReason(
718
+ decision.reason,
719
+ "Stop hook blocked completion. Continue the remaining work before finishing.",
720
+ );
721
+ break;
722
+ }
723
+ }
724
+
725
+ if (!blockedReason) {
726
+ stopHookActiveBySession.set(sessionId, false);
727
+ return;
728
+ }
729
+
730
+ if (!stopHookActive) {
731
+ stopHookActiveBySession.set(sessionId, true);
732
+ pi.sendUserMessage(blockedReason, { deliverAs: "followUp" });
733
+ if (ctx.hasUI) {
734
+ ctx.ui.notify("[claude-hooks-bridge] Stop hook blocked end and queued follow-up.", "info");
735
+ }
736
+ return;
737
+ }
738
+
739
+ // 무한 루프 보호: 이미 stop_hook_active=true 인 상태에서 다시 block이면 자동 재시도하지 않는다.
740
+ stopHookActiveBySession.set(sessionId, false);
741
+ if (ctx.hasUI) {
742
+ ctx.ui.notify(`[claude-hooks-bridge] Stop hook blocked again (loop guard): ${blockedReason}`, "warning");
743
+ }
744
+ });
745
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@ryan_nookpi/pi-extension-claude-hooks-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Bridge Claude Code hooks (.claude/settings.json) into pi extension lifecycle events.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Jonghakseo/pi-extension.git",
9
+ "directory": "packages/claude-hooks-bridge"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Jonghakseo/pi-extension/issues"
13
+ },
14
+ "homepage": "https://github.com/Jonghakseo/pi-extension/tree/main/packages/claude-hooks-bridge#readme",
15
+ "type": "module",
16
+ "keywords": [
17
+ "pi-package"
18
+ ],
19
+ "files": [
20
+ "index.ts",
21
+ "README.md"
22
+ ],
23
+ "pi": {
24
+ "extensions": [
25
+ "./index.ts"
26
+ ]
27
+ },
28
+ "peerDependencies": {
29
+ "@mariozechner/pi-coding-agent": "*"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }