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 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
- if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
160
- if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
161
- if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
162
- if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
163
- return runHookCommand(hook.command, merged, ms, projectDir, hook.args)
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
- export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[]) => Promise<HookRunResult>
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.14",
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",