one-code-extension 0.1.4 → 0.3.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 +20 -0
- package/README.md +200 -172
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +126 -0
- package/docs/guide/configuration.md +171 -0
- package/docs/guide/differences-from-claude-code.md +150 -0
- package/docs/guide/doctor.md +86 -0
- package/docs/guide/hooks.md +109 -0
- package/docs/guide/installation.md +124 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
- package/docs/guide/providers-and-models.md +194 -0
- package/docs/guide/reference.md +224 -0
- package/docs/guide/sessions-and-context.md +190 -0
- package/docs/guide/skills-plugins-and-mcp.md +176 -0
- package/docs/guide/subagents-and-workflows.md +193 -0
- package/docs/guide/tasks-and-background-work.md +111 -0
- package/docs/guide/terminal-interface.md +155 -0
- package/docs/guide/tools.md +175 -0
- package/docs/guide/troubleshooting.md +175 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -217
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -20,46 +20,118 @@
|
|
|
20
20
|
*
|
|
21
21
|
* pi's emitToolCall has no try/catch around handlers, so every hook dispatch
|
|
22
22
|
* here is wrapped — a throwing hook fails open, never takes the turn down.
|
|
23
|
+
*
|
|
24
|
+
* Hook context reaches the model in Claude Code's `hook_additional_context`
|
|
25
|
+
* shape — a `<system-reminder>` reading "<Event> hook additional context: …" —
|
|
26
|
+
* never as a message of its own: PreToolUse context is queued as a one-shot on
|
|
27
|
+
* the reminder channel, so it lands INSIDE the tool result it belongs to,
|
|
28
|
+
* persisted, at no extra turn; PostToolUse context is appended to the result
|
|
29
|
+
* the same way; prompt/session/compaction context is returned from
|
|
30
|
+
* `before_agent_start` as a custom message right AFTER the user's prompt. Until
|
|
31
|
+
* 2026-09-05 each was a custom steer (one extra LLM turn per hook per tool,
|
|
32
|
+
* pi draining one steer per call) and prompt context landed BEFORE the prompt
|
|
33
|
+
* (STEERING-REVIEW-2026-09-05 M6).
|
|
34
|
+
*
|
|
35
|
+
* Subagents: the tool hooks also run for a child's tool calls, through the
|
|
36
|
+
* bridge published on `SUBAGENT_HOOK_CHANNEL` (subagent-bridge.ts): the child
|
|
37
|
+
* calls back into this extension, which dispatches with the parent's context
|
|
38
|
+
* (settings, consent, logging) and a payload naming the child.
|
|
23
39
|
*/
|
|
24
40
|
|
|
25
|
-
import type
|
|
41
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
42
|
+
import { RunOutcomeLatch } from "../lib/interrupt.ts";
|
|
26
43
|
import { claudeConfigDir } from "../lib/paths.ts";
|
|
44
|
+
import { defaultDiscoverRoots } from "../lib/plugins.ts";
|
|
27
45
|
import { appendHookLog, formatDebugLine, hooksDebugEnabled, hooksLogPath } from "./debug.ts";
|
|
28
46
|
import { runHookCommand } from "./executor.ts";
|
|
29
|
-
import {
|
|
47
|
+
import { changedSince, FORMATTER_NOTICE, type FileSnapshot, fileToolTarget, snapshotFile } from "./formatter-notice.ts";
|
|
48
|
+
import { matcherApplies, ccToolName, toolMatchCandidates, ccToolInput, nativeToolInput } from "./matcher.ts";
|
|
30
49
|
import { loadPluginHooks } from "./plugin-hooks.ts";
|
|
31
50
|
import {
|
|
51
|
+
applyPostToolUseOutcome,
|
|
32
52
|
type CcHookEvent,
|
|
33
53
|
type HookOutcome,
|
|
34
54
|
type HookStdinPayload,
|
|
35
55
|
interpretHookResult,
|
|
36
56
|
} from "./protocol.ts";
|
|
37
57
|
import { type HookCommand, type HooksSource, loadHookSettings } from "./settings.ts";
|
|
58
|
+
import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
|
|
59
|
+
import { REMINDER_CHANNEL, wrapReminder } from "../lib/reminders.ts";
|
|
60
|
+
import { type ChildHookCall, type ChildHookResult, type HookBridge, SUBAGENT_HOOK_CHANNEL, type SubagentHookPayload } from "./subagent-bridge.ts";
|
|
38
61
|
import { projectHooksApproved } from "./trust.ts";
|
|
39
62
|
|
|
63
|
+
/** Claude Code's `hook_additional_context` attachment text (utils/messages.ts). */
|
|
64
|
+
export function hookContextText(event: CcHookEvent, text: string): string {
|
|
65
|
+
return `${event} hook additional context: ${text}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every hook text that reaches the model is bounded through this: additional
|
|
70
|
+
* context, a string or (JSON-encoded) object updatedToolResult, a block reason,
|
|
71
|
+
* a Stop stopReason. The executor allows 1 MB per stream, and a runaway hook
|
|
72
|
+
* would otherwise dump all of it into context (reviews T5, M2). Past the cap the
|
|
73
|
+
* full text is PERSISTED under the session's tool-results (Claude Code's
|
|
74
|
+
* convention) and the model gets a preview plus the path — nothing is cut away.
|
|
75
|
+
*/
|
|
76
|
+
const HOOK_MODEL_TEXT_CAP = 20_000;
|
|
77
|
+
// A monotonic counter, not Date.now(), so two hooks finishing in the same
|
|
78
|
+
// millisecond do not persist to the same file and clobber each other (review M2).
|
|
79
|
+
let hookPersistSeq = 0;
|
|
80
|
+
function capHookText(text: string, ctx: HookDispatchCtx, label: string): string {
|
|
81
|
+
return persistIfLarge(text, { dir: sessionResultsDir(ctx), id: `hook-${label}-${hookPersistSeq++}`, maxBytes: HOOK_MODEL_TEXT_CAP });
|
|
82
|
+
}
|
|
83
|
+
|
|
40
84
|
interface MatchedHook {
|
|
41
85
|
source: HooksSource;
|
|
42
86
|
hook: HookCommand;
|
|
43
87
|
}
|
|
44
88
|
|
|
89
|
+
/**
|
|
90
|
+
* What the hook pipeline reads off a context — the live `ExtensionContext` is
|
|
91
|
+
* assignable, and a SessionEnd dispatch runs on a snapshot of the same shape
|
|
92
|
+
* (see `shutdownCtx`) after the real one has been disposed.
|
|
93
|
+
*/
|
|
94
|
+
interface HookDispatchCtx {
|
|
95
|
+
cwd: string;
|
|
96
|
+
hasUI: boolean;
|
|
97
|
+
sessionManager: { getSessionId(): string; getSessionFile(): string | undefined; getSessionDir?(): string | undefined };
|
|
98
|
+
ui: { confirm(title: string, message: string): Promise<boolean | undefined>; notify(message: string, type: "info"): void };
|
|
99
|
+
/** Set on the shutdown snapshot: no consent prompt, no notices — the session is gone. */
|
|
100
|
+
sessionEnded?: true;
|
|
101
|
+
}
|
|
102
|
+
|
|
45
103
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
46
104
|
let stopHookActive = false;
|
|
47
|
-
|
|
105
|
+
/** Context from UserPromptSubmit / SessionStart / PostCompact hooks, delivered with the next prompt. */
|
|
106
|
+
let pendingPromptContext: Array<{ event: CcHookEvent; text: string }> = [];
|
|
107
|
+
/** The last context seen, for bridged child calls (dispatched parent-side). */
|
|
108
|
+
let lastCtx: ExtensionContext | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* The in-flight SessionStart hook dispatch. It runs OFF the `session_start`
|
|
111
|
+
* path so the editor opens immediately (a slow hook used to hold the prompt
|
|
112
|
+
* for its whole run — TUI-REVIEW M2); the first `before_agent_start` awaits it
|
|
113
|
+
* so the first turn still carries the hook's additionalContext, and the
|
|
114
|
+
* first-run approval dialog still gates execution.
|
|
115
|
+
*/
|
|
116
|
+
let sessionStartPending: Promise<void> | undefined;
|
|
117
|
+
/** Bumped on every session_start; a superseded dispatch checks it before writing context. */
|
|
118
|
+
let sessionGen = 0;
|
|
48
119
|
|
|
49
|
-
const notify = (ctx:
|
|
120
|
+
const notify = (ctx: HookDispatchCtx, message: string) => {
|
|
121
|
+
if (ctx.sessionEnded) return; // session gone; nowhere to show it
|
|
50
122
|
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
51
123
|
else process.stderr.write(`${message}\n`);
|
|
52
124
|
};
|
|
53
125
|
|
|
54
126
|
/** All hooks for an event across trusted sources, matcher already applied. */
|
|
55
127
|
const collectHooks = async (
|
|
56
|
-
ctx:
|
|
128
|
+
ctx: HookDispatchCtx,
|
|
57
129
|
event: CcHookEvent,
|
|
58
130
|
matchValue: { candidates?: string[]; ignoreMatcher?: boolean },
|
|
59
131
|
): Promise<MatchedHook[]> => {
|
|
60
132
|
const claudeDir = claudeConfigDir();
|
|
61
133
|
const loaded = loadHookSettings(claudeDir, ctx.cwd);
|
|
62
|
-
const pluginSources = loadPluginHooks(
|
|
134
|
+
const pluginSources = loadPluginHooks(defaultDiscoverRoots(getAgentDir(), ctx.cwd), loaded.diagnostics);
|
|
63
135
|
if (hooksDebugEnabled()) {
|
|
64
136
|
for (const diagnostic of loaded.diagnostics) process.stderr.write(`[hooks] ${diagnostic}\n`);
|
|
65
137
|
}
|
|
@@ -70,6 +142,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
70
142
|
if (hasProjectHooks) {
|
|
71
143
|
projectAllowed = await projectHooksApproved(ctx.cwd, projectSources, {
|
|
72
144
|
hasUI: ctx.hasUI,
|
|
145
|
+
noPrompt: ctx.sessionEnded,
|
|
73
146
|
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
74
147
|
notify: (message) => notify(ctx, message),
|
|
75
148
|
});
|
|
@@ -92,7 +165,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
92
165
|
return matched;
|
|
93
166
|
};
|
|
94
167
|
|
|
95
|
-
const basePayload = (ctx:
|
|
168
|
+
const basePayload = (ctx: HookDispatchCtx, event: CcHookEvent): HookStdinPayload => ({
|
|
96
169
|
session_id: ctx.sessionManager.getSessionId(),
|
|
97
170
|
transcript_path: ctx.sessionManager.getSessionFile() ?? "",
|
|
98
171
|
cwd: ctx.cwd,
|
|
@@ -105,15 +178,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
105
178
|
* systemMessages concatenate. Any throw inside is caught → fail open.
|
|
106
179
|
*/
|
|
107
180
|
const dispatch = async (
|
|
108
|
-
ctx:
|
|
181
|
+
ctx: HookDispatchCtx,
|
|
109
182
|
event: CcHookEvent,
|
|
110
183
|
matchValue: { candidates?: string[]; ignoreMatcher?: boolean },
|
|
111
184
|
payload: HookStdinPayload,
|
|
185
|
+
/** Runs once, only when hooks will actually run — the point where a caller can snapshot state the hooks may change. */
|
|
186
|
+
willRun?: () => void,
|
|
112
187
|
): Promise<HookOutcome> => {
|
|
113
188
|
const merged: HookOutcome = {};
|
|
114
189
|
try {
|
|
115
190
|
const hooks = await collectHooks(ctx, event, matchValue);
|
|
116
191
|
if (hooks.length === 0) return merged;
|
|
192
|
+
willRun?.();
|
|
117
193
|
const stdin = JSON.stringify(payload);
|
|
118
194
|
const outcomes = await Promise.all(
|
|
119
195
|
hooks.map(async ({ source, hook }) => {
|
|
@@ -121,6 +197,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
121
197
|
cwd: ctx.cwd,
|
|
122
198
|
timeoutSeconds: hook.timeout,
|
|
123
199
|
projectDir: ctx.cwd,
|
|
200
|
+
// SessionEnd is fire-and-forget at shutdown: it must not hold a
|
|
201
|
+
// one-shot process open. Every other hook is awaited work that
|
|
202
|
+
// must (executor.ts, the event-loop drain).
|
|
203
|
+
detached: event === "SessionEnd",
|
|
124
204
|
});
|
|
125
205
|
const outcome = interpretHookResult(event, run);
|
|
126
206
|
const decision = outcome.block ? "block" : outcome.additionalContext ? "context" : run.exitCode === 0 || run.exitCode === 2 ? "allow" : "error";
|
|
@@ -141,11 +221,26 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
141
221
|
}),
|
|
142
222
|
);
|
|
143
223
|
for (const outcome of outcomes) {
|
|
144
|
-
|
|
224
|
+
// A block reason becomes model-facing text (the tool result, or a
|
|
225
|
+
// Stop follow-up), so it is bounded like the other hook texts —
|
|
226
|
+
// stderr on an exit-2 hook is easily a full stack trace (review M2).
|
|
227
|
+
if (!merged.block && outcome.block) {
|
|
228
|
+
merged.block = { reason: capHookText(outcome.block.reason, ctx, `${event}-block`) };
|
|
229
|
+
}
|
|
145
230
|
if (outcome.updatedInput) merged.updatedInput = { ...merged.updatedInput, ...outcome.updatedInput };
|
|
146
|
-
if ("updatedToolResult" in outcome)
|
|
231
|
+
if ("updatedToolResult" in outcome) {
|
|
232
|
+
// A non-string replacement is JSON-encoded before it reaches the
|
|
233
|
+
// model, so stringify then cap it too — not just the string case.
|
|
234
|
+
const text =
|
|
235
|
+
typeof outcome.updatedToolResult === "string" ? outcome.updatedToolResult : JSON.stringify(outcome.updatedToolResult);
|
|
236
|
+
merged.updatedToolResult = capHookText(text, ctx, `${event}-result`);
|
|
237
|
+
}
|
|
147
238
|
if (outcome.additionalContext) {
|
|
148
|
-
merged.additionalContext =
|
|
239
|
+
merged.additionalContext = capHookText(
|
|
240
|
+
[merged.additionalContext, outcome.additionalContext].filter(Boolean).join("\n"),
|
|
241
|
+
ctx,
|
|
242
|
+
`${event}-context`,
|
|
243
|
+
);
|
|
149
244
|
}
|
|
150
245
|
if (outcome.systemMessage) {
|
|
151
246
|
merged.systemMessage = [merged.systemMessage, outcome.systemMessage].filter(Boolean).join("\n");
|
|
@@ -161,37 +256,25 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
161
256
|
return merged;
|
|
162
257
|
};
|
|
163
258
|
|
|
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
259
|
// ---- PreToolUse ---------------------------------------------------------
|
|
180
260
|
pi.on("tool_call", async (event, ctx) => {
|
|
181
261
|
const payload: HookStdinPayload = {
|
|
182
262
|
...basePayload(ctx, "PreToolUse"),
|
|
183
263
|
tool_name: ccToolName(event.toolName),
|
|
184
|
-
tool_input: event.input as Record<string, unknown
|
|
264
|
+
tool_input: ccToolInput(event.toolName, event.input as Record<string, unknown>),
|
|
185
265
|
};
|
|
186
266
|
const outcome = await dispatch(ctx, "PreToolUse", { candidates: toolMatchCandidates(event.toolName) }, payload);
|
|
187
267
|
if (outcome.block) return { block: true, reason: `PreToolUse hook: ${outcome.block.reason}` };
|
|
188
268
|
if (outcome.updatedInput) {
|
|
189
269
|
// In-place, so worktree/file-tracker/permissions (later in the
|
|
190
270
|
// extension order) all see the rewritten input — CC applies hook
|
|
191
|
-
// input updates before permission evaluation.
|
|
192
|
-
|
|
271
|
+
// input updates before permission evaluation. The hook answered in
|
|
272
|
+
// CC's parameter names; translate back to the tool's own.
|
|
273
|
+
Object.assign(event.input as Record<string, unknown>, nativeToolInput(event.toolName, outcome.updatedInput));
|
|
193
274
|
}
|
|
194
|
-
|
|
275
|
+
// A one-shot pending when this call's result is stored is written into
|
|
276
|
+
// that result by the system-reminder extension — CC's shape, no extra turn.
|
|
277
|
+
if (outcome.additionalContext) pi.events.emit(REMINDER_CHANNEL, { text: hookContextText("PreToolUse", outcome.additionalContext) });
|
|
195
278
|
return undefined;
|
|
196
279
|
});
|
|
197
280
|
|
|
@@ -200,75 +283,219 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
200
283
|
const payload: HookStdinPayload = {
|
|
201
284
|
...basePayload(ctx, "PostToolUse"),
|
|
202
285
|
tool_name: ccToolName(event.toolName),
|
|
203
|
-
tool_input: event.input,
|
|
286
|
+
tool_input: ccToolInput(event.toolName, event.input as Record<string, unknown>),
|
|
204
287
|
tool_response: { content: event.content, is_error: event.isError },
|
|
205
288
|
};
|
|
206
|
-
|
|
207
|
-
|
|
289
|
+
// A hook that rewrites the file the model just edited is otherwise invisible
|
|
290
|
+
// to it: this extension awaits the hook and loads before file-tracker, so
|
|
291
|
+
// the tracker records the hook's version as the model's own write
|
|
292
|
+
// (formatter-notice.ts, review M1). Snapshot only when hooks will run.
|
|
293
|
+
const target = event.isError ? undefined : fileToolTarget(event.toolName, event.input, ctx.cwd);
|
|
294
|
+
let before: FileSnapshot | undefined;
|
|
295
|
+
const outcome = await dispatch(ctx, "PostToolUse", { candidates: toolMatchCandidates(event.toolName) }, payload, () => {
|
|
296
|
+
if (target) before = snapshotFile(target);
|
|
297
|
+
});
|
|
298
|
+
const formatterNotice = target && before && changedSince(target, before) ? wrapReminder(FORMATTER_NOTICE(target)) : undefined;
|
|
299
|
+
// CC feeds PostToolUse block reasons back to the model in the result the
|
|
300
|
+
// same way; the assembly rules live in applyPostToolUseOutcome.
|
|
301
|
+
const content = applyPostToolUseOutcome(event.content, {
|
|
302
|
+
...outcome,
|
|
303
|
+
additionalContext:
|
|
304
|
+
[outcome.additionalContext && wrapReminder(hookContextText("PostToolUse", outcome.additionalContext)), formatterNotice]
|
|
305
|
+
.filter(Boolean)
|
|
306
|
+
.join("\n") || undefined,
|
|
307
|
+
});
|
|
308
|
+
if (!content) return undefined;
|
|
309
|
+
return { content, isError: event.isError };
|
|
310
|
+
});
|
|
208
311
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
//
|
|
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>` }];
|
|
312
|
+
/** Await the backgrounded SessionStart dispatch once, if one is pending. */
|
|
313
|
+
const drainSessionStart = async () => {
|
|
314
|
+
if (!sessionStartPending) return;
|
|
315
|
+
const pending = sessionStartPending;
|
|
316
|
+
sessionStartPending = undefined;
|
|
317
|
+
try {
|
|
318
|
+
await pending;
|
|
319
|
+
} catch {
|
|
320
|
+
// dispatch already reports its own failures; never block the turn on one.
|
|
224
321
|
}
|
|
225
|
-
|
|
226
|
-
});
|
|
322
|
+
};
|
|
227
323
|
|
|
228
324
|
// ---- UserPromptSubmit ---------------------------------------------------
|
|
229
325
|
pi.on("input", async (event, ctx) => {
|
|
230
326
|
if (event.source === "extension") return undefined;
|
|
231
327
|
stopHookActive = false;
|
|
328
|
+
// Drain the backgrounded SessionStart dispatch before this prompt's own
|
|
329
|
+
// UserPromptSubmit context, so their order in the first turn is unchanged
|
|
330
|
+
// (SessionStart context precedes UserPromptSubmit). before_agent_start is
|
|
331
|
+
// the backstop for turns with no input event (one-shot, programmatic).
|
|
332
|
+
await drainSessionStart();
|
|
232
333
|
const payload: HookStdinPayload = { ...basePayload(ctx, "UserPromptSubmit"), prompt: event.text };
|
|
233
334
|
const outcome = await dispatch(ctx, "UserPromptSubmit", { ignoreMatcher: true }, payload);
|
|
234
335
|
if (outcome.block) {
|
|
235
336
|
notify(ctx, `Prompt blocked by UserPromptSubmit hook: ${outcome.block.reason}`);
|
|
236
337
|
return { action: "handled" as const };
|
|
237
338
|
}
|
|
238
|
-
if (outcome.additionalContext) pendingPromptContext.push(outcome.additionalContext);
|
|
339
|
+
if (outcome.additionalContext) pendingPromptContext.push({ event: "UserPromptSubmit", text: outcome.additionalContext });
|
|
239
340
|
return undefined;
|
|
240
341
|
});
|
|
241
342
|
|
|
242
|
-
// Prompt
|
|
243
|
-
//
|
|
244
|
-
|
|
343
|
+
// Prompt/session/compaction hook context rides the turn it belongs to as a
|
|
344
|
+
// custom message pi appends right AFTER the user's prompt (the
|
|
345
|
+
// before_agent_start `message` return). An idle `sendMessage` from here
|
|
346
|
+
// landed it BEFORE the prompt (pi appends first, then adds the user message).
|
|
347
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
348
|
+
lastCtx = ctx;
|
|
349
|
+
// The SessionStart dispatch runs in the background from session_start; the
|
|
350
|
+
// first turn must wait for it so its additionalContext is present (and, on
|
|
351
|
+
// first run, so its approval dialog gates execution before the model runs).
|
|
352
|
+
// The `input` handler usually drained it already; this is the backstop for
|
|
353
|
+
// turns with no input event.
|
|
354
|
+
await drainSessionStart();
|
|
245
355
|
if (pendingPromptContext.length === 0) return;
|
|
246
356
|
const texts = pendingPromptContext;
|
|
247
357
|
pendingPromptContext = [];
|
|
248
|
-
|
|
358
|
+
return {
|
|
359
|
+
message: {
|
|
360
|
+
customType: "one-code:hook-context",
|
|
361
|
+
content: texts.map(({ event, text }) => wrapReminder(hookContextText(event, text))).join("\n"),
|
|
362
|
+
display: false,
|
|
363
|
+
},
|
|
364
|
+
};
|
|
249
365
|
});
|
|
250
366
|
|
|
251
367
|
// ---- SessionStart / SessionEnd -----------------------------------------
|
|
252
|
-
const dispatchSessionStart = async (ctx: ExtensionContext, source: string) => {
|
|
368
|
+
const dispatchSessionStart = async (ctx: ExtensionContext, source: string, gen: number) => {
|
|
253
369
|
const payload: HookStdinPayload = { ...basePayload(ctx, "SessionStart"), source };
|
|
254
370
|
const outcome = await dispatch(ctx, "SessionStart", { candidates: [source] }, payload);
|
|
255
|
-
|
|
371
|
+
// A later session_start (rapid /clear, resume) may have superseded this one
|
|
372
|
+
// while its hook ran; discard the stale result instead of pushing it into
|
|
373
|
+
// the new session's context (`pendingPromptContext` is shared and reset).
|
|
374
|
+
if (gen === sessionGen && outcome.additionalContext) {
|
|
375
|
+
pendingPromptContext.push({ event: "SessionStart", text: outcome.additionalContext });
|
|
376
|
+
}
|
|
256
377
|
};
|
|
257
378
|
|
|
258
|
-
pi.on("session_start",
|
|
379
|
+
pi.on("session_start", (event, ctx) => {
|
|
380
|
+
lastCtx = ctx;
|
|
381
|
+
const gen = ++sessionGen;
|
|
382
|
+
pendingPromptContext = [];
|
|
383
|
+
sessionStartPending = undefined;
|
|
384
|
+
// Publish the child hook bridge (subagent-bridge.ts). The closures read
|
|
385
|
+
// live parent state per call, so once per session start is enough.
|
|
386
|
+
pi.events.emit(SUBAGENT_HOOK_CHANNEL, { bridge: childHookBridge } satisfies SubagentHookPayload);
|
|
387
|
+
// Claude Code's sources are startup | resume | clear | compact; pi's
|
|
388
|
+
// `new` (/clear, /new) is a clear — the process-launch case arrives as
|
|
389
|
+
// `startup` already. `compact` is dispatched from session_compact below.
|
|
259
390
|
const reason = (event as { reason?: string }).reason ?? "startup";
|
|
260
391
|
if (reason === "reload" || reason === "fork") return;
|
|
261
|
-
|
|
392
|
+
// Run the dispatch OFF this handler so the prompt editor opens immediately;
|
|
393
|
+
// the first before_agent_start awaits the promise (TUI-REVIEW M2). The
|
|
394
|
+
// detached `.catch` only suppresses an unhandled rejection before that
|
|
395
|
+
// await attaches — the real error still surfaces there.
|
|
396
|
+
sessionStartPending = dispatchSessionStart(ctx, reason === "new" ? "clear" : reason, gen);
|
|
397
|
+
sessionStartPending.catch(() => {});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ---- Subagent bridge ----------------------------------------------------
|
|
401
|
+
/** A child's payload: the child's identity, the parent's config and consent. */
|
|
402
|
+
const childPayload = (call: ChildHookCall, event: CcHookEvent): HookStdinPayload => ({
|
|
403
|
+
session_id: call.sessionId ?? "",
|
|
404
|
+
transcript_path: call.transcriptPath ?? "",
|
|
405
|
+
cwd: call.cwd,
|
|
406
|
+
hook_event_name: event,
|
|
407
|
+
tool_name: ccToolName(call.toolName),
|
|
408
|
+
tool_input: ccToolInput(call.toolName, call.input),
|
|
409
|
+
agent_id: call.sessionId,
|
|
410
|
+
agent_type: call.agentType,
|
|
262
411
|
});
|
|
412
|
+
const childHookBridge: HookBridge = {
|
|
413
|
+
async preToolUse(call) {
|
|
414
|
+
const ctx = lastCtx;
|
|
415
|
+
if (!ctx) return {};
|
|
416
|
+
const outcome = await dispatch(ctx, "PreToolUse", { candidates: toolMatchCandidates(call.toolName) }, childPayload(call, "PreToolUse"));
|
|
417
|
+
return {
|
|
418
|
+
block: outcome.block,
|
|
419
|
+
updatedInput: outcome.updatedInput ? nativeToolInput(call.toolName, outcome.updatedInput) : undefined,
|
|
420
|
+
additionalContext: outcome.additionalContext ? hookContextText("PreToolUse", outcome.additionalContext) : undefined,
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
async postToolUse(result: ChildHookResult) {
|
|
424
|
+
const ctx = lastCtx;
|
|
425
|
+
if (!ctx) return {};
|
|
426
|
+
const payload: HookStdinPayload = {
|
|
427
|
+
...childPayload(result, "PostToolUse"),
|
|
428
|
+
tool_response: { content: result.content, is_error: result.isError },
|
|
429
|
+
};
|
|
430
|
+
// The CHILD's cwd, not the parent's: a worktree-isolated subagent edits
|
|
431
|
+
// its own copy, and resolving against ctx.cwd would snapshot the main
|
|
432
|
+
// checkout — missing the hook's rewrite, or reporting an unrelated change.
|
|
433
|
+
const target = result.isError ? undefined : fileToolTarget(result.toolName, result.input, result.cwd);
|
|
434
|
+
let before: FileSnapshot | undefined;
|
|
435
|
+
const outcome = await dispatch(ctx, "PostToolUse", { candidates: toolMatchCandidates(result.toolName) }, payload, () => {
|
|
436
|
+
if (target) before = snapshotFile(target);
|
|
437
|
+
});
|
|
438
|
+
const formatterNotice = target && before && changedSince(target, before) ? wrapReminder(FORMATTER_NOTICE(target)) : undefined;
|
|
439
|
+
return {
|
|
440
|
+
block: outcome.block,
|
|
441
|
+
updatedToolResult: outcome.updatedToolResult,
|
|
442
|
+
additionalContext:
|
|
443
|
+
[outcome.additionalContext && wrapReminder(hookContextText("PostToolUse", outcome.additionalContext)), formatterNotice]
|
|
444
|
+
.filter(Boolean)
|
|
445
|
+
.join("\n") || undefined,
|
|
446
|
+
};
|
|
447
|
+
},
|
|
448
|
+
};
|
|
263
449
|
|
|
264
|
-
pi.on("session_shutdown",
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
450
|
+
pi.on("session_shutdown", (event, ctx) => {
|
|
451
|
+
// A reload re-runs the extensions but keeps the conversation (pi's
|
|
452
|
+
// AgentSession.reload): not a session end, Claude Code has no such event.
|
|
453
|
+
if (event.reason === "reload") return;
|
|
454
|
+
// Claude Code's SessionEnd carries `reason`: clear | logout | prompt_input_exit
|
|
455
|
+
// | other. pi's `new` (/clear, /new) is a clear; a `quit` from the TUI is the
|
|
456
|
+
// user leaving the prompt; a headless run's end, a resume and a fork are
|
|
457
|
+
// `other` (LIFECYCLE-REVIEW-2026-09-06 M4).
|
|
458
|
+
const reason = event.reason === "new" ? "clear" : event.reason === "quit" && ctx.hasUI ? "prompt_input_exit" : "other";
|
|
459
|
+
const payload: HookStdinPayload = { ...basePayload(ctx, "SessionEnd"), reason };
|
|
460
|
+
// Fire and forget: the process is on its way out; nothing to apply. The ctx
|
|
461
|
+
// is frozen first: after this handler returns pi disposes the session and
|
|
462
|
+
// every getter on the real ctx throws, so a dispatch that awaited (the
|
|
463
|
+
// project-hooks consent read) would then fail inside its own try and the
|
|
464
|
+
// hook silently would not run. Consent is never PROMPTED at shutdown: a
|
|
465
|
+
// project config that was never approved is skipped, as in headless runs.
|
|
466
|
+
void dispatch(shutdownCtx(ctx), "SessionEnd", { ignoreMatcher: true }, payload);
|
|
268
467
|
});
|
|
269
468
|
|
|
469
|
+
/** The subset of ctx `dispatch` reads, snapshotted so it survives the session's disposal. */
|
|
470
|
+
const shutdownCtx = (ctx: ExtensionContext): HookDispatchCtx => {
|
|
471
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
472
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
473
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
474
|
+
return {
|
|
475
|
+
sessionEnded: true,
|
|
476
|
+
cwd: ctx.cwd,
|
|
477
|
+
hasUI: false,
|
|
478
|
+
sessionManager: { getSessionId: () => sessionId, getSessionFile: () => sessionFile, getSessionDir: () => sessionDir },
|
|
479
|
+
ui: { confirm: async () => false, notify: () => {} },
|
|
480
|
+
};
|
|
481
|
+
};
|
|
482
|
+
|
|
270
483
|
// ---- Stop ---------------------------------------------------------------
|
|
271
|
-
|
|
484
|
+
// agent_settled, not agent_end: CC fires Stop once, when the main agent has
|
|
485
|
+
// finished responding, and a turn can hold several runs (`lib/interrupt.ts`).
|
|
486
|
+
// Dispatching per run would run the user's Stop hook several times per turn and
|
|
487
|
+
// let a blocking hook inject its follow-up mid-retry.
|
|
488
|
+
const stopOutcome = new RunOutcomeLatch();
|
|
489
|
+
pi.on("agent_end", (event, ctx) => {
|
|
490
|
+
stopOutcome.record(event.messages, ctx.signal?.aborted);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
494
|
+
// CC skips Stop when the turn ended on an API error (a blocking hook would
|
|
495
|
+
// spiral: error → block → retry → error) or on a user interrupt (its query
|
|
496
|
+
// loop returns on the abort signal before the stop-hook step). An empty latch
|
|
497
|
+
// means no run ended in this turn, which is not a response to stop on either.
|
|
498
|
+
if (stopOutcome.take() !== "ok") return;
|
|
272
499
|
const payload: HookStdinPayload = { ...basePayload(ctx, "Stop"), stop_hook_active: stopHookActive };
|
|
273
500
|
const outcome = await dispatch(ctx, "Stop", { ignoreMatcher: true }, payload);
|
|
274
501
|
if (!outcome.block) return;
|
|
@@ -288,10 +515,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
288
515
|
});
|
|
289
516
|
|
|
290
517
|
// ---- PreCompact / PostCompact ------------------------------------------
|
|
518
|
+
// Claude Code's trigger is manual | auto; pi's threshold and overflow
|
|
519
|
+
// reasons are both `auto`. Both payloads mirror CC's PreCompactHookInput /
|
|
520
|
+
// PostCompactHookInput field for field.
|
|
521
|
+
const compactTrigger = (event: { reason?: string }): "manual" | "auto" => (event.reason === "manual" ? "manual" : "auto");
|
|
522
|
+
|
|
291
523
|
pi.on("session_before_compact", async (event, ctx) => {
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
|
|
524
|
+
const trigger = compactTrigger(event);
|
|
525
|
+
const payload: HookStdinPayload = {
|
|
526
|
+
...basePayload(ctx, "PreCompact"),
|
|
527
|
+
trigger,
|
|
528
|
+
custom_instructions: event.customInstructions ?? "",
|
|
529
|
+
};
|
|
295
530
|
const outcome = await dispatch(ctx, "PreCompact", { candidates: [trigger] }, payload);
|
|
296
531
|
if (outcome.block) {
|
|
297
532
|
notify(ctx, `Compaction cancelled by PreCompact hook: ${outcome.block.reason}`);
|
|
@@ -300,11 +535,17 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
300
535
|
return undefined;
|
|
301
536
|
});
|
|
302
537
|
|
|
303
|
-
pi.on("session_compact", async (
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
538
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
539
|
+
const trigger = compactTrigger(event);
|
|
540
|
+
const payload: HookStdinPayload = {
|
|
541
|
+
...basePayload(ctx, "PostCompact"),
|
|
542
|
+
trigger,
|
|
543
|
+
compact_summary: event.compactionEntry.summary,
|
|
544
|
+
};
|
|
545
|
+
const outcome = await dispatch(ctx, "PostCompact", { candidates: [trigger] }, payload);
|
|
546
|
+
if (outcome.additionalContext) pendingPromptContext.push({ event: "PostCompact", text: outcome.additionalContext });
|
|
547
|
+
// CC fires SessionStart(source: "compact") after compaction. Awaited inline
|
|
548
|
+
// (not backgrounded), so the current generation is still in force.
|
|
549
|
+
await dispatchSessionStart(ctx, "compact", sessionGen);
|
|
309
550
|
});
|
|
310
551
|
}
|
|
@@ -25,7 +25,6 @@ const CC_CANONICAL: Record<string, string> = {
|
|
|
25
25
|
web_fetch: "WebFetch",
|
|
26
26
|
web_search: "WebSearch",
|
|
27
27
|
subagent: "Task",
|
|
28
|
-
todo_write: "TodoWrite",
|
|
29
28
|
skill: "Skill",
|
|
30
29
|
ask_user_question: "AskUserQuestion",
|
|
31
30
|
enter_plan_mode: "EnterPlanMode",
|
|
@@ -49,6 +48,41 @@ export function ccToolName(nativeName: string): string {
|
|
|
49
48
|
return CC_CANONICAL[nativeName] ?? nativeName;
|
|
50
49
|
}
|
|
51
50
|
|
|
51
|
+
/**
|
|
52
|
+
* pi parameter name → Claude Code parameter name, per tool, for the hook stdin
|
|
53
|
+
* payload's `tool_input`. A CC hook reads `.tool_input.file_path`; pi's tools
|
|
54
|
+
* call it `path` (review T4). Tools not listed pass their input through
|
|
55
|
+
* unchanged (bash's `command`/`timeout`/`description`/`run_in_background`
|
|
56
|
+
* already match CC's Bash; MCP tools are their own schema).
|
|
57
|
+
*/
|
|
58
|
+
const CC_INPUT_NAMES: Record<string, Record<string, string>> = {
|
|
59
|
+
read: { path: "file_path" },
|
|
60
|
+
edit: { path: "file_path", oldText: "old_string", newText: "new_string" },
|
|
61
|
+
write: { path: "file_path" },
|
|
62
|
+
grep: { ignoreCase: "-i", context: "-C", limit: "head_limit" },
|
|
63
|
+
notebook_edit: { path: "notebook_path" },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function renameKeys(input: Record<string, unknown>, names: Record<string, string>): Record<string, unknown> {
|
|
67
|
+
const out: Record<string, unknown> = {};
|
|
68
|
+
for (const [key, value] of Object.entries(input)) out[names[key] ?? key] = value;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A pi tool's input spelled with Claude Code's parameter names. */
|
|
73
|
+
export function ccToolInput(nativeName: string, input: Record<string, unknown>): Record<string, unknown> {
|
|
74
|
+
const names = CC_INPUT_NAMES[nativeName];
|
|
75
|
+
return names ? renameKeys(input, names) : input;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The inverse: a hook's `updatedInput` (CC names) back to the pi tool's names. */
|
|
79
|
+
export function nativeToolInput(nativeName: string, input: Record<string, unknown>): Record<string, unknown> {
|
|
80
|
+
const names = CC_INPUT_NAMES[nativeName];
|
|
81
|
+
if (!names) return input;
|
|
82
|
+
const inverse = Object.fromEntries(Object.entries(names).map(([pi, cc]) => [cc, pi]));
|
|
83
|
+
return renameKeys(input, inverse);
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
/** Every spelling a matcher may reasonably target for this tool. */
|
|
53
87
|
export function toolMatchCandidates(nativeName: string): string[] {
|
|
54
88
|
return [...new Set([nativeName, ccToolName(nativeName), ...ccAliasesForTool(nativeName)])];
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { readFileSync, statSync } from "node:fs";
|
|
12
|
-
import { discoverPlugins, pluginResources } from "../lib/plugins.ts";
|
|
12
|
+
import { discoverPlugins, pluginResources, type DiscoverRoots } from "../lib/plugins.ts";
|
|
13
13
|
import { parseHooksBlock, type HooksSource } from "./settings.ts";
|
|
14
14
|
|
|
15
15
|
interface CacheEntry {
|
|
@@ -67,9 +67,9 @@ function readPluginHooksFile(hooksConfig: string, pluginName: string, pluginPath
|
|
|
67
67
|
return source;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export function loadPluginHooks(
|
|
70
|
+
export function loadPluginHooks(roots: DiscoverRoots, diagnostics: string[]): HooksSource[] {
|
|
71
71
|
const sources: HooksSource[] = [];
|
|
72
|
-
for (const plugin of discoverPlugins(
|
|
72
|
+
for (const plugin of discoverPlugins(roots).enabledPlugins) {
|
|
73
73
|
const { hooksConfig } = pluginResources(plugin);
|
|
74
74
|
if (!hooksConfig) continue;
|
|
75
75
|
const source = readPluginHooksFile(hooksConfig, plugin.name, plugin.path, diagnostics);
|