pi-code 1.0.14 → 1.0.15
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/README.md +1 -1
- package/extensions/hooks/config.ts +15 -0
- package/extensions/hooks/index.ts +64 -7
- package/extensions/hooks/runners.ts +12 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
45
45
|
| Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`); `$ARGUMENTS` (with the `ARGUMENTS:` append when unused), 0-based `$ARGUMENTS[N]`/`$N`, named `arguments:` frontmatter, `${CLAUDE_SESSION_ID}`/`${CLAUDE_EFFORT}`/`${CLAUDE_SKILL_DIR}`/`${CLAUDE_PROJECT_DIR}` (in bodies and `allowed-tools` rules); `` !`cmd` `` and multi-line ```` ```! ```` bash (whitespace-bounded, merged stderr, 2-minute budget, a failure aborts the invocation with the documented exit-1 carveout), `@file` inlining; `allowed-tools` with `Bash(...)` and `Read`/`Edit`/`Write` path scopes enforced at call time (gitignore anchors, Edit governs writes), `disallowed-tools`, `argument-hint`, `model` (switches the session model for the command's turn, restored after), `effort` (raises reasoning for the turn, restored after), `shell: powershell` (injected spans run through PowerShell when a `pwsh` binary is present, else `/bin/sh`); the model can also run a command itself through the `SlashCommand` tool (Claude's `SlashCommand` in `allowed-tools`), steered by `when_to_use` and opted out per file with `disable-model-invocation` (`user-invocable: false` hides a command from the menu while still exposing it to the model); `disableSkillShellExecution` (managed and user always, project when trusted) replaces every `!` span with a policy-disabled placeholder; project commands gated on approval | `commands.ts` |
|
|
46
46
|
| `/init` | generates a project context file: detects an existing `AGENTS.md`/`CLAUDE.md` (proposes improvements) or none (creates `AGENTS.md`, pi's preferred name), ingesting `.cursor/rules`, `.cursorrules`, and `.github/copilot-instructions.md` when present; drives the main agent with full tools via a prompt (not a tool-less completion) so it analyzes the codebase and writes the file itself | `init.ts` |
|
|
47
47
|
| Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
|
|
48
|
-
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop; a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
|
|
48
|
+
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop; `async`/`asyncRewake` command hooks run in the background on every event (never blocking, no decision, no timeout enforced on `async` while asyncRewake keeps its own; an asyncRewake exit 2 wakes the model with the hook's stderr as a new turn, other completions deliver `systemMessage`/`additionalContext` to the model on the next turn, and hooks still running at session end are killed); a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
|
|
49
49
|
| Output styles | `.claude/output-styles` + active `outputStyle`; plus styles shipped by enabled plugins (manifest `outputStyles`, default `output-styles/`, ranked below the user's and project's own); Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
|
|
50
50
|
| CLAUDE.md `@imports` and rewriting | resolves `@path` imports pi's native loader skips (4-hop depth, budget-capped); loads the user `~/.claude/CLAUDE.md` and the project `.claude/CLAUDE.md` (approval-gated, deduped against the repo-root `CLAUDE.md`/`AGENTS.md` pi loads natively) that pi's own loader does not; loads every `CLAUDE.local.md` from the repo root down to cwd (approval-gated, root first); injects the managed `CLAUDE.md` (a per-OS file beside `managed-settings.json`) and the `claudeMd` string from `managed-settings.json` at the top of context (managed content is never excludable); honors `claudeMdExcludes` (glob/absolute-path skip list from user, approved-project, and managed settings, merged; managed content never excluded); strips block-level HTML comments from CLAUDE.md, rule, and imported bodies (fenced-code comments preserved), so a commented-out `@import` does not expand; with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` set, loads `CLAUDE.md`/`.claude/CLAUDE.md`/`.claude/rules/*.md`/`CLAUDE.local.md` from each `--add-dir` directory (comma-separated for several, since pi's flag is single-value) | `context-imports.ts` |
|
|
51
51
|
| Settings `env` | `env` blocks from `managed-settings.json`, `~/.claude/settings.json`, and the project `.claude/settings.json`/`settings.local.json` exported into the session (per-key `managed > user > project`); the project scope is approval-gated (a repo's env can redirect providers), a shell `export` outranks user and project but a managed key overrides even that, and a key an approved project set is unset once a later session no longer defines it | `env-settings.ts` |
|
|
@@ -17,6 +17,14 @@ export interface HookCommand {
|
|
|
17
17
|
* absent). $ARGUMENTS in each arg is replaced with the event JSON. */
|
|
18
18
|
args?: string[]
|
|
19
19
|
timeout?: number
|
|
20
|
+
/** Claude's background contract, honored on `type: "command"` hooks only: `async` runs
|
|
21
|
+
* without blocking its event and with no timeout enforced; `asyncRewake` also runs in
|
|
22
|
+
* the background but keeps its timeout, and wakes the model when the hook exits 2
|
|
23
|
+
* (stderr, or stdout when stderr is empty, feeds back as a new turn). Background hooks
|
|
24
|
+
* render no decision; their JSON `systemMessage`/`additionalContext` reach the model on
|
|
25
|
+
* the next turn, and any still running are killed at session end. */
|
|
26
|
+
async?: boolean
|
|
27
|
+
asyncRewake?: boolean
|
|
20
28
|
/** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
|
|
21
29
|
url?: string
|
|
22
30
|
headers?: Record<string, string>
|
|
@@ -35,6 +43,13 @@ export interface HookMatcher {
|
|
|
35
43
|
matcher?: string
|
|
36
44
|
hooks: HookCommand[]
|
|
37
45
|
}
|
|
46
|
+
|
|
47
|
+
/** Whether a hook runs in the background. Claude documents `async`/`asyncRewake` on
|
|
48
|
+
* `type: "command"` hooks only; on any other type the fields are inert and the hook
|
|
49
|
+
* blocks, exactly as Claude runs it. */
|
|
50
|
+
export function isBackgroundHook(hook: HookCommand): boolean {
|
|
51
|
+
return (hook.type === undefined || hook.type === 'command') && (hook.async === true || hook.asyncRewake === true)
|
|
52
|
+
}
|
|
38
53
|
export type HooksConfig = Record<string, HookMatcher[]>
|
|
39
54
|
|
|
40
55
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -38,6 +38,14 @@
|
|
|
38
38
|
* user-facing warning).
|
|
39
39
|
* `suppressOutput` is accepted and inert: pi never echoes hook stdout to the
|
|
40
40
|
* transcript in the first place.
|
|
41
|
+
* `async`/`asyncRewake` (command hooks only, as Claude documents) run in the
|
|
42
|
+
* background on every event: they never block or delay the event that fired them
|
|
43
|
+
* and render no decision. An asyncRewake hook exiting 2 wakes the model with its
|
|
44
|
+
* stderr (stdout when stderr is empty) as a new turn; any other background
|
|
45
|
+
* completion delivers the JSON response's systemMessage/additionalContext to the
|
|
46
|
+
* model on the next turn, shown to nobody else. No timeout is enforced on `async`
|
|
47
|
+
* (asyncRewake keeps its own), and hooks still running at session end are killed,
|
|
48
|
+
* as Claude does at teardown.
|
|
41
49
|
*
|
|
42
50
|
* SubagentStart/SubagentStop ride pi-code's own subagent extension, which publishes
|
|
43
51
|
* child-run lifecycle on the shared bus (notify-style: a child has already exited by
|
|
@@ -71,7 +79,7 @@ import { installedPlugins } from '../internal/plugins.js'
|
|
|
71
79
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
72
80
|
import { repoRoot } from '../internal/project-root.js'
|
|
73
81
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
74
|
-
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
|
|
82
|
+
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
|
|
75
83
|
import { blockedToolCall, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
76
84
|
import { matchingCommands } from './matcher.js'
|
|
77
85
|
import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, timeoutMs } from './runners.js'
|
|
@@ -150,17 +158,62 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
150
158
|
if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
|
|
151
159
|
return common
|
|
152
160
|
}
|
|
161
|
+
/** Kills for background hooks still running; Claude kills async hooks at teardown,
|
|
162
|
+
* so session_shutdown reaps anything left rather than let a hung hook pin the
|
|
163
|
+
* event loop past a one-shot run's end. */
|
|
164
|
+
const backgroundKills = new Set<() => void>()
|
|
165
|
+
/** Claude's background delivery: an asyncRewake exit 2 wakes the model with the
|
|
166
|
+
* hook's stderr (stdout when stderr is empty) as a new turn; any other completion
|
|
167
|
+
* feeds the JSON response's systemMessage/additionalContext to the model on the
|
|
168
|
+
* next turn, shown to nobody else. A timeout kill discards the output, like a
|
|
169
|
+
* canceled synchronous hook; it resolves with code 124, so it never reads as a wake. */
|
|
170
|
+
const deliverBackgroundResult = (hook: HookCommand, result: HookRunResult): void => {
|
|
171
|
+
if (result.timedOut) return
|
|
172
|
+
if (hook.asyncRewake === true && result.code === 2) {
|
|
173
|
+
const detail = result.stderr.trim() || result.stdout.trim()
|
|
174
|
+
const content = detail ? `Async hook requested attention (exit 2):\n${detail}` : 'Async hook requested attention (exit 2)'
|
|
175
|
+
pi.sendMessage({ customType: 'claude-async-hook', content, display: true }, { triggerTurn: true })
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
const parsed = tryParseJson(result.stdout)
|
|
179
|
+
// The typeof guard doubles as Claude's schema validation: a wrong-typed field is
|
|
180
|
+
// dropped rather than delivered.
|
|
181
|
+
const parts = [parsed?.systemMessage, parsed?.hookSpecificOutput?.additionalContext].filter((part): part is string => typeof part === 'string' && part.length > 0)
|
|
182
|
+
if (parts.length === 0) return
|
|
183
|
+
pi.sendMessage({ customType: 'claude-async-hook', content: parts.join('\n'), display: false }, { deliverAs: 'nextTurn' })
|
|
184
|
+
}
|
|
153
185
|
/** A runner bound to the firing context, filling the common fields into each
|
|
154
|
-
* payload and dispatching on the entry's type.
|
|
186
|
+
* payload and dispatching on the entry's type. A background hook (see
|
|
187
|
+
* isBackgroundHook) is fired and the caller immediately gets a no-verdict result,
|
|
188
|
+
* so it can neither block nor delay the event that fired it; its completion is
|
|
189
|
+
* delivered by deliverBackgroundResult whenever it lands. */
|
|
155
190
|
const boundRunner =
|
|
156
191
|
(ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
|
|
157
192
|
(hook, payload, ms) => {
|
|
158
193
|
const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
194
|
+
const dispatch = (onChild?: (kill: () => void) => void): Promise<HookRunResult> => {
|
|
195
|
+
if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
|
|
196
|
+
if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
|
|
197
|
+
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
198
|
+
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
199
|
+
return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
|
|
200
|
+
}
|
|
201
|
+
if (!isBackgroundHook(hook)) return dispatch()
|
|
202
|
+
let kill: (() => void) | undefined
|
|
203
|
+
void dispatch((registered) => {
|
|
204
|
+
kill = registered
|
|
205
|
+
backgroundKills.add(registered)
|
|
206
|
+
})
|
|
207
|
+
.then((result) => deliverBackgroundResult(hook, result))
|
|
208
|
+
.catch(() => {
|
|
209
|
+
// The hook may outlive the session (/new, shutdown): sendMessage asserts
|
|
210
|
+
// liveness, and nothing awaits this chain, so a throw would otherwise
|
|
211
|
+
// escape as an unhandled rejection.
|
|
212
|
+
})
|
|
213
|
+
.finally(() => {
|
|
214
|
+
if (kill) backgroundKills.delete(kill)
|
|
215
|
+
})
|
|
216
|
+
return Promise.resolve({ code: 0, stdout: '', stderr: '', timedOut: false })
|
|
164
217
|
}
|
|
165
218
|
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
166
219
|
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
@@ -422,6 +475,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
422
475
|
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
423
476
|
const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
|
|
424
477
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
478
|
+
// Claude kills async hooks still running at teardown; the session that spawned
|
|
479
|
+
// these is over, and their delivery would target a disposed context anyway.
|
|
480
|
+
for (const kill of backgroundKills) kill()
|
|
481
|
+
backgroundKills.clear()
|
|
425
482
|
})
|
|
426
483
|
|
|
427
484
|
// Claude's /hooks manages hook configuration; pi-code's is a viewer: hook failures
|
|
@@ -9,7 +9,7 @@ import type { Api, Model } from '@earendil-works/pi-ai'
|
|
|
9
9
|
import { runAgent } from '../internal/agent-run.js'
|
|
10
10
|
import { callMcpTool } from '../internal/mcp-call.js'
|
|
11
11
|
import { completeText } from '../internal/model-complete.js'
|
|
12
|
-
import { type HookCommand, httpUrlAllowed } from './config.js'
|
|
12
|
+
import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
|
|
13
13
|
|
|
14
14
|
// Claude defaults to 600s and lets a timed-out hook proceed; here a timed-out
|
|
15
15
|
// PreToolUse or UserPromptSubmit hook fails closed (pi has no permission prompt
|
|
@@ -30,13 +30,20 @@ export interface HookRunResult {
|
|
|
30
30
|
/** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
|
|
31
31
|
export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
32
32
|
/** The shell path specifically; the statusline reuses it for its own command. With an
|
|
33
|
-
* `args` array it becomes the exec path: `command` is spawned directly with those args.
|
|
34
|
-
|
|
33
|
+
* `args` array it becomes the exec path: `command` is spawned directly with those args.
|
|
34
|
+
* `onChild` hands the caller a kill for the spawned tree, so a background hook that is
|
|
35
|
+
* still running at session end can be reaped (Claude kills async hooks at teardown). */
|
|
36
|
+
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[], onChild?: (kill: () => void) => void) => Promise<HookRunResult>
|
|
35
37
|
|
|
36
38
|
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
37
39
|
const MAX_TIMEOUT_S = 2_147_483
|
|
38
40
|
|
|
39
41
|
export function timeoutMs(command: HookCommand): number {
|
|
42
|
+
// Claude does not enforce `timeout` on an `async` command hook (it does on
|
|
43
|
+
// `asyncRewake`), so the budget is the Node timer ceiling: the timer exists only
|
|
44
|
+
// so the delay never clamps, not as a deadline. Still-running background hooks
|
|
45
|
+
// are killed at session end instead.
|
|
46
|
+
if (isBackgroundHook(command) && command.asyncRewake !== true) return MAX_TIMEOUT_S * 1000
|
|
40
47
|
// Non-positive values fall back to the default: a 0ms timer would fire before the
|
|
41
48
|
// hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
|
|
42
49
|
const declared = command.timeout
|
|
@@ -67,7 +74,7 @@ function killTree(child: ChildProcess): void {
|
|
|
67
74
|
child.kill('SIGKILL')
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args) =>
|
|
77
|
+
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args, onChild) =>
|
|
71
78
|
new Promise((resolve) => {
|
|
72
79
|
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
73
80
|
// `detached` makes the shell its own process group leader so the timeout can kill
|
|
@@ -84,6 +91,7 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, p
|
|
|
84
91
|
const file = Array.isArray(args) ? command : '/bin/sh'
|
|
85
92
|
const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
|
|
86
93
|
const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
|
|
94
|
+
onChild?.(() => killTree(child))
|
|
87
95
|
let stdout = ''
|
|
88
96
|
let stderr = ''
|
|
89
97
|
let settled = false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|