pi-code 1.0.10 → 1.0.12

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
@@ -38,30 +38,36 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
38
38
  | Feature | Reads / provides | Extension |
39
39
  |---|---|---|
40
40
  | Global + project rules | `~/.claude/rules`, `.claude/rules` (nearest at or above cwd); unscoped rules inlined in full, `paths:`-scoped rules surfaced as pointers and auto-attached (the rule body is appended to a read/edit/write result when a matching file is touched, once per rule per session) | `claude-rules.ts` |
41
- | 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), `shell: powershell` (runs the command's injected spans through PowerShell when a pwsh binary is present, else falls back to /bin/sh); `disable-model-invocation` is parsed but not applied; project commands gated on approval | `commands.ts` |
41
+ | 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` |
42
42
  | `/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` |
43
43
  | 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` |
44
- | 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 | `hooks.ts` |
44
+ | 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` |
45
45
  | 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` |
46
- | CLAUDE.md `@imports` and rewriting | resolves `@path` imports pi's native loader skips (4-hop depth, budget-capped); loads every `CLAUDE.local.md` from the repo root down to cwd (approval-gated, root first); injects managed `claudeMd` from `managed-settings.json` at the top of context; 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` |
47
- | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE/WebSocket by `type` (WebSocket is url-only: any `headers`/`bearerToken`/`headersHelper` on a ws server is ignored with a warning); `${VAR:-default}` expansion; a `headersHelper` command whose stdout JSON merges into the transport headers (http/sse); managed `allowedMcpServers`/`deniedMcpServers` read from `managed-settings.json` only (`{serverName}` entries, applied globally across scopes, allow list exclusive with an empty array = lockdown, deny wins); `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`; tools refresh on `list_changed`; bearer tokens, or OAuth for remote servers (browser login on 401 after a confirm, tokens under `~/.pi/agent/mcp-oauth`, silent refresh later) | `mcp.ts` |
46
+ | 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` |
47
+ | 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` |
48
+ | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE/WebSocket by `type` (WebSocket is url-only: any `headers`/`bearerToken`/`headersHelper` on a ws server is ignored with a warning); `${VAR:-default}` expansion; a `headersHelper` command whose stdout JSON merges into the transport headers (http/sse); a `managed-mcp.json` beside `managed-settings.json` takes exclusive control (only its servers load, every other scope and the approval flow suppressed; an empty file disables MCP); managed `allowedMcpServers`/`deniedMcpServers` read from `managed-settings.json` only (`{serverName}` entries, applied globally across scopes, allow list exclusive with an empty array = lockdown, deny wins); connect and per-call budgets (`MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`) over a 4-hour wall default, plus a 5-minute idle timeout that a progress notification resets (`CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT`, `0` disables); a connected server's prompts register as `/mcp__server__prompt` commands and its resources are reachable through the `list_mcp_resources`/`read_mcp_resource` tools; tools refresh on `list_changed`; bearer tokens, or OAuth for remote servers (browser login on 401 after a confirm, CSRF-guarded loopback callback, tokens under `~/.pi/agent/mcp-oauth`, silent refresh later) | `mcp.ts` |
48
49
  | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles, commands, skills) that pi would otherwise trust silently | `internal/project-approval.ts` |
49
- | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents` (scanned recursively into subfolders) merged by default once the project is trusted (project wins on a name clash); frontmatter `tools`/`disallowedTools`/`model` (sonnet/opus/haiku/fable tier aliases or a concrete id)/`effort`/`skills` preload/`permissionMode: plan`/`maxTurns`/`memory` (`user`/`project`/`local` give the child its own persistent store under `.claude/agent-memory[-local]`, injected with Read/Write/Edit enabled, gated on auto memory; the parent conversation's memory is never loaded into a subagent, matching Claude); parallel and chain modes (pi extensions), one nesting level; background runs with cancel and resume | `subagent/` |
50
+ | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents` (scanned recursively into subfolders) merged by default once the project is trusted (project wins on a name clash); frontmatter `tools`/`disallowedTools`/`model` (sonnet/opus/haiku/fable tier aliases or a concrete id)/`effort`/`skills` preload/`permissionMode: plan`/`maxTurns`/`memory` (`user`/`project`/`local` give the child its own persistent store under `.claude/agent-memory[-local]`, injected with Read/Write/Edit enabled, gated on auto memory; the parent conversation's memory is never loaded into a subagent, matching Claude); parallel and chain modes (pi extensions), one nesting level; background runs with cancel and resume (`/tasks` lists them, `/agents` lists the discovered roster) | `subagent/` |
50
51
  | Plan mode | `plan_mode_complete` tool, tool snapshot/restore that survives `/reload` | `plan-mode/` |
51
52
  | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
52
53
  | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later; 100 per session, repos pruned after 30 days | `git-checkpoint.ts` |
53
54
  | Persistent memory | per-repo memories under `~/.pi/agent/memory` keyed on the repository root (subdirectory sessions share one store, as Claude does; pi's own store, separate from Claude's), index injected each session within Claude's 200-line/25KB bound (YAML frontmatter and block HTML comments stripped before it counts or loads); a save that would overflow it reports why; a memory written with frontmatter gets a `modified:` ISO timestamp; honors `autoMemoryEnabled` (settings) and `CLAUDE_CODE_DISABLE_AUTO_MEMORY` (env) to turn it off, and `autoMemoryDirectory` (absolute or `~/`) to relocate the store | `memory.ts` |
54
55
  | WebSearch / WebFetch | key-free DuckDuckGo search (with `allowed_domains`/`blocked_domains`); SSRF-guarded fetch that prefers markdown via `Accept` then converts HTML, with Claude's 15-minute per-URL cache and an optional `prompt` that runs the page through the model in-process and returns the answer (falls back to markdown when headless or on error) | `web.ts` |
55
56
  | AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header` and 2-4 options, single- or `multiSelect`, plus free-text | `question.ts` |
56
- | Statusline | Claude `statusLine` command contract (stdin JSON incl. `version`, `hook_event_name`, `session_name`, cost durations and line counters, context percentages; `padding`, `refreshInterval`, 300ms debounce); built-in turn state + session cost fallback | `status-line.ts` |
57
+ | Statusline | Claude `statusLine` command contract (stdin JSON incl. `version`, `hook_event_name`, `session_name`, a `cost` block with wall and API durations and lines added/removed, a `context_window` token breakdown, `exceeds_200k_tokens`, and `rate_limits` carrying the `five_hour`/`seven_day` utilization and reset from the provider's rate-limit headers; `padding`, `refreshInterval`, 300ms debounce); built-in turn state + session cost fallback | `status-line.ts` |
57
58
  | Notifications | terminal notification when a turn ends (OSC 777 / Kitty OSC 99 / Windows toast); honors `preferredNotifChannel` (`terminal_bell`, `notifications_disabled`, `iterm2_with_bell`, else desktop) from user settings; fires only when you "appear to be away" (approximated by turn duration, since pi exposes no terminal-focus signal) | `notify.ts` |
59
+ | Think keywords | raises reasoning for one turn when the prompt carries a keyword: `ultrathink` to the max, `think hard`/`think harder` to high, a bare `think` to medium; only ever raises, matched on word boundaries, and the prior level is restored once the turn settles | `thinking.ts` |
60
+ | Session title | names a new session from its first message with a single model call (shown in the session selector and the terminal title); never overwrites an existing name, best-effort, at most once per session | `session-title.ts` |
61
+ | `/context` | reports how much of the model's context window the session occupies (used, window, free, and percent), and reads pi's live usage so it reflects a compaction | `context-usage.ts` |
58
62
  | Claude plugins | installed marketplace plugins (`~/.claude/plugins/cache`), active per `enabledPlugins` in user settings only (a checked-out repo cannot flip which code-bearing plugins run, so project settings never toggle them): commands as `/plugin:name`, agents, hooks and MCP servers (tools aliased `mcp__plugin_<plugin>_<server>__<tool>`) and output styles with `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PLUGIN_DATA}` and `${user_config.KEY}` (from `pluginConfigs[id].options` in user settings) substituted; skill dirs contribute too, though pi's loader names them without the plugin prefix | `internal/plugins.ts` |
59
63
 
64
+ Slash commands: `/init`, `/context`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
65
+
60
66
  pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
61
67
 
62
- `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
68
+ `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base. Setting `CLAUDE_CONFIG_DIR` relocates the entire home config scope (settings, commands, agents, skills, plugins, output styles, memory, and the user `CLAUDE.md`); a project's own `.claude/` is a separate scope and is unaffected.
63
69
 
64
- `extensions/internal/` holds shared modules pi's loader must not treat as extensions: `output-guard.ts` (context-budget truncation), `web-transport.ts` (DNS-pinned fetch), `project-approval.ts` (the trust decision above), `command-file.ts` (slash-command parsing and dynamic content), and the shared-bus contracts `mcp-alias.ts`, `plan-mode-state.ts` and `subagent-events.ts`. The extensions use them; only `internal/` keeps them out of pi's extension scan.
70
+ `extensions/internal/` holds shared modules pi's loader must not treat as extensions: `output-guard.ts` (context-budget truncation), `web-transport.ts` (DNS-pinned fetch), `project-approval.ts` (the trust decision above), `command-file.ts` (slash-command parsing and dynamic content), `config-dir.ts` (the `CLAUDE_CONFIG_DIR` home-scope resolver), `managed-settings.ts` (the enterprise policy file), `model-complete.ts` (the in-process model calls behind session titling and prompt hooks), `mcp-oauth.ts` (the OAuth loopback), and the shared-bus contracts `mcp-alias.ts`, `plan-mode-state.ts` and `subagent-events.ts`. The extensions use them; only `internal/` keeps them out of pi's extension scan.
65
71
 
66
72
  Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
67
73
 
@@ -3,7 +3,14 @@
3
3
  *
4
4
  * Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
5
5
  * a project's existing hooks work under pi:
6
- * - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input)
6
+ * - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input), plus
7
+ * pi `user_bash` for a `!`/`!!` command the user runs directly (the
8
+ * model never issues these, so a deny-list guard would otherwise miss
9
+ * them). No pi tool call exists there, so the payload reports the
10
+ * Claude name "Bash"; a deny hands pi a synthetic failed result so
11
+ * the command never runs. UserBashEvent carries no execution result
12
+ * and fires only before the command runs, so it has no PostToolUse
13
+ * counterpart (pi never delivers the output to observe).
7
14
  * - PostToolUse -> pi `tool_result` (block reasons and additionalContext are
8
15
  * appended next to the tool result, as Claude documents)
9
16
  * - SessionStart -> pi `session_start` (stdout/additionalContext is injected as
@@ -1021,6 +1028,31 @@ export default function hooksExtension(pi: ExtensionAPI) {
1021
1028
  return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
1022
1029
  })
1023
1030
 
1031
+ // Claude's PreToolUse for Bash, extended to a command the user runs directly with the
1032
+ // `!`/`!!` prefix. pi fires user_bash before executing it, and the model never sees it,
1033
+ // so without this a guard that blocks `git push -f` from the model would not stop the
1034
+ // same command typed by hand. There is no pi tool call, so the matcher sees both pi's
1035
+ // "bash" and the Claude name "Bash" (exactly as an MCP alias is bridged) and the payload
1036
+ // reports "Bash", the tool_name a Claude-written PreToolUse Bash hook expects. The
1037
+ // payload carries no tool_use_id (no model tool call produced it). UserBashEventResult
1038
+ // exposes no block flag: a deny is enforced through `result` ("extension handled
1039
+ // execution, use this result"), a synthetic failed BashResult that stands in for the
1040
+ // command so it never runs and its deny reason shows as the output. The event delivers
1041
+ // no execution result and fires only before the command runs, so there is deliberately
1042
+ // no PostToolUse for it.
1043
+ pi.on('user_bash', async (event, ctx) => {
1044
+ const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'))
1045
+ if (!decision.block) return undefined
1046
+ // Claude's "ask": prompt before running and let the command through on approval; with
1047
+ // no UI (headless) the block stands, the same safe default as the tool_call path.
1048
+ if (decision.ask && ctx.hasUI) {
1049
+ const approved = await ctx.ui.confirm('Allow this command?', decision.reason ?? 'A hook asks you to confirm this command.')
1050
+ if (approved) return undefined
1051
+ }
1052
+ const reason = decision.reason ?? 'Command blocked by hook'
1053
+ return { result: { output: `Blocked by hook: ${reason}`, exitCode: 1, cancelled: false, truncated: false } }
1054
+ })
1055
+
1024
1056
  pi.on('input', async (event, ctx) => {
1025
1057
  // Only genuine user input; extension-injected messages (plan-mode, subagent) are not
1026
1058
  // prompts the user submitted.
@@ -61,7 +61,7 @@ export interface ApprovalContext {
61
61
  cwd: string
62
62
  hasUI: boolean
63
63
  isProjectTrusted?: () => boolean
64
- ui: { confirm: (title: string, body: string) => Promise<boolean> }
64
+ ui: { confirm: (title: string, body: string) => Promise<boolean>; notify?: (message: string, type?: 'info' | 'warning' | 'error') => void }
65
65
  }
66
66
 
67
67
  export interface ApprovalDeps {
@@ -80,6 +80,37 @@ const defaultDeps: ApprovalDeps = {
80
80
 
81
81
  const APPROVAL_BODY = 'It ships Claude Code configuration that pi-code loads. MCP servers, hooks and agents can run commands from this repository.'
82
82
 
83
+ /**
84
+ * Shown once when the pi runtime predates project-trust support. pi >= 0.79.1 hands
85
+ * extensions a `ctx.isProjectTrusted` callback; older runtimes omit it, and the trust
86
+ * guard below then reads every project as untrusted, so trust prompts, project hooks, MCP,
87
+ * commands, skills and rules all fail closed with nothing said. A live run on such a pi
88
+ * produced no error anywhere; this turns that silence into one line.
89
+ */
90
+ const RUNTIME_TOO_OLD = 'pi-code requires pi >= 0.79.1 for project configuration; project-scoped .claude config stays disabled on this pi version'
91
+
92
+ /**
93
+ * Whether the runtime lacks the `isProjectTrusted` capability, warning once when it does.
94
+ *
95
+ * The notice fires at most once per process and the guard is never reset: a pi binary
96
+ * cannot change version mid-run, so a single notice carries all the information a session
97
+ * can, and repeating it on every gated surface would be noise. Both the prompting and the
98
+ * silent callers report it. A missing runtime capability is not an approval question, so
99
+ * surfacing it from the silent variant is correct rather than a stray dialog. `ctx.ui` may
100
+ * be absent on a headless run, so the notify is fully optional-chained. Callers still fail
101
+ * closed on a true return, exactly as before.
102
+ */
103
+ let warnedRuntimeTooOld = false
104
+
105
+ function runtimeLacksProjectTrust(ctx: { isProjectTrusted?: () => boolean; ui?: { notify?: (message: string, type?: 'info' | 'warning' | 'error') => void } }): boolean {
106
+ if (typeof ctx.isProjectTrusted === 'function') return false
107
+ if (!warnedRuntimeTooOld) {
108
+ warnedRuntimeTooOld = true
109
+ ctx.ui?.notify?.(RUNTIME_TOO_OLD, 'warning')
110
+ }
111
+ return true
112
+ }
113
+
83
114
  /**
84
115
  * Whether project-controlled config may be acted on.
85
116
  *
@@ -90,7 +121,8 @@ const APPROVAL_BODY = 'It ships Claude Code configuration that pi-code loads. MC
90
121
  /** The same decision as isProjectApproved, but never prompts: an undecided project
91
122
  * reads as unapproved. For surfaces that only display project config, like the
92
123
  * subagent roster, where a mid-turn dialog would be wrong. */
93
- export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'>, deps: ApprovalDeps = defaultDeps): boolean {
124
+ export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'> & { ui?: ApprovalContext['ui'] }, deps: ApprovalDeps = defaultDeps): boolean {
125
+ if (runtimeLacksProjectTrust(ctx)) return false // pi predates project-trust support
94
126
  if (ctx.isProjectTrusted?.() !== true) return false
95
127
  if (!deps.hasClaudeShaped(ctx.cwd)) return true
96
128
  if (deps.piWouldAsk(ctx.cwd)) return true
@@ -98,7 +130,8 @@ export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'is
98
130
  }
99
131
 
100
132
  export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
101
- if (ctx.isProjectTrusted?.() !== true) return false // pi already declined, or never trusted
133
+ if (runtimeLacksProjectTrust(ctx)) return false // pi predates project-trust support
134
+ if (ctx.isProjectTrusted?.() !== true) return false // pi declined trust for this project
102
135
  if (!deps.hasClaudeShaped(ctx.cwd)) return true // nothing here pi's own check would miss
103
136
  if (deps.piWouldAsk(ctx.cwd)) return true // pi genuinely prompted for this project
104
137
 
package/extensions/mcp.ts CHANGED
@@ -1243,7 +1243,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1243
1243
  for (const [name, client] of Array.from(clients.entries())) {
1244
1244
  if (managedNames.has(name)) continue
1245
1245
  clients.delete(name)
1246
- await client.close().catch(() => {})
1246
+ // Bound the close like session_shutdown does: a hung server must not stall the new
1247
+ // session start, which awaits this eviction before connecting the managed set.
1248
+ await withTimeout(client.close(), 3000, 'close').catch(() => {})
1247
1249
  status.set(name, { state: 'disabled by managed policy', tools: 0 })
1248
1250
  }
1249
1251
  await connectServers(managedServers, authUi)
@@ -271,11 +271,17 @@ function readIndexQuietly(dir: string): string {
271
271
  }
272
272
  }
273
273
 
274
+ /** Write through a temp file and rename onto the target, so a crash mid-write cannot
275
+ * truncate it. The tmp name carries the pid so concurrent processes do not collide. */
276
+ function atomicWriteFile(filePath: string, content: string): void {
277
+ const tmp = `${filePath}.${process.pid}.tmp`
278
+ fs.writeFileSync(tmp, content)
279
+ fs.renameSync(tmp, filePath)
280
+ }
281
+
274
282
  /** Replace the index through a rename so a crash mid-write cannot truncate it. */
275
283
  function writeIndex(indexPath: string, content: string): void {
276
- const tmp = `${indexPath}.${process.pid}.tmp`
277
- fs.writeFileSync(tmp, content)
278
- fs.renameSync(tmp, indexPath)
284
+ atomicWriteFile(indexPath, content)
279
285
  }
280
286
 
281
287
  /** The settings chain that decides `autoMemoryEnabled` and `autoMemoryDirectory`:
@@ -337,7 +343,9 @@ export function setAutoMemoryEnabledSetting(home: string, value: boolean): { ok:
337
343
  }
338
344
  current.autoMemoryEnabled = value
339
345
  fs.mkdirSync(dir, { recursive: true })
340
- fs.writeFileSync(file, `${JSON.stringify(current, null, 2)}\n`)
346
+ // Atomic like writeIndex: a crash mid-write must not truncate the user's settings, which
347
+ // also hold their hooks, env and permissions config.
348
+ atomicWriteFile(file, `${JSON.stringify(current, null, 2)}\n`)
341
349
  return { ok: true }
342
350
  }
343
351
 
@@ -200,6 +200,13 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
200
200
 
201
201
  pi.registerCommand('output-style', {
202
202
  description: 'Choose the active Claude output style (or /output-style <name>)',
203
+ // /output-style <name> takes a style name, so complete the discovered names by the
204
+ // typed prefix (case-insensitive, like the handler's own name lookup). An empty
205
+ // prefix offers every style.
206
+ getArgumentCompletions: (argumentPrefix) => {
207
+ const prefix = argumentPrefix.trim().toLowerCase()
208
+ return styles.filter((style) => style.name.toLowerCase().startsWith(prefix)).map((style) => ({ value: style.name, label: style.name, ...(style.description ? { description: style.description } : {}) }))
209
+ },
203
210
  handler: async (args, ctx) => {
204
211
  const requested = args.trim()
205
212
  if (requested) {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Session Auto-Title Extension
3
+ *
4
+ * Claude auto-names a new conversation from its first message; this does the same for
5
+ * pi. After the first run of an unnamed session settles, it asks the current model for
6
+ * a short title based on the first user message and applies it two ways: setSessionName
7
+ * (the name shown in the session selector) and the terminal window/tab title.
8
+ *
9
+ * It runs in every mode, not just the TUI: naming a session is cheap and harmless, and a
10
+ * headless run that persists its session still benefits from a readable name later. The
11
+ * window-title update is the only terminal-specific part, so it is optional-called rather
12
+ * than gated on hasUI. Titling is best-effort throughout: a session that already has a
13
+ * name, a run with no user text (a slash-command-only turn), a headless run with no model,
14
+ * or any provider error leaves the session untitled and never throws.
15
+ *
16
+ * Cost: one model call per session at most. The guard is claimed before the completion so
17
+ * repeated settles cannot each fire a call, and a failed attempt is not retried until a
18
+ * new session resets the guard.
19
+ */
20
+
21
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
22
+
23
+ import { completeText } from './internal/model-complete.js'
24
+
25
+ const TITLE_SYSTEM = 'You name a coding session from its first user message. Reply with a terse 3 to 6 word title in Title Case that captures the task. No quotes, no surrounding punctuation, no trailing period. Output the title only, nothing else.'
26
+ /** A title is a few words; a tight cap keeps the extra call cheap and stops a runaway reply. */
27
+ const TITLE_MAX_TOKENS = 24
28
+ /** The first message can be huge; only its opening is needed to name the session, and a
29
+ * bounded prompt keeps the input cost of the extra call small. */
30
+ const MAX_PROMPT_CHARS = 1000
31
+
32
+ /** Join the text of a message's content, mirroring git-checkpoint's extraction: content is
33
+ * either a plain string or an array of parts, of which only text parts carry a title's worth. */
34
+ function extractText(content: unknown): string {
35
+ if (typeof content === 'string') return content
36
+ if (!Array.isArray(content)) return ''
37
+ return content
38
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
39
+ .map((part) => part.text)
40
+ .join(' ')
41
+ }
42
+
43
+ /** Text of the first user message in the branch, or empty when the run carried no user text
44
+ * (for example a slash-command-only turn), in which case there is nothing to title from. */
45
+ export function firstUserText(ctx: ExtensionContext): string {
46
+ for (const entry of ctx.sessionManager.getBranch()) {
47
+ if (entry?.type === 'message' && entry.message.role === 'user') {
48
+ return extractText(entry.message.content).trim()
49
+ }
50
+ }
51
+ return ''
52
+ }
53
+
54
+ /** Wrapping quotes (straight, smart, and backtick) and trailing sentence punctuation that
55
+ * cleanTitle peels, held as plain strings so the trims below can test membership by index
56
+ * rather than with an anchored regex. */
57
+ const WRAPPING_QUOTES = '"\'`“”‘’'
58
+ const TRAILING_PUNCTUATION = '.,;:!?'
59
+
60
+ /** Strip runs of `chars` from both ends of `value` in linear time. The equivalent
61
+ * /^[chars]+|[chars]+$/g backtracks super-linearly on a long run (S8786). */
62
+ function trimBothEnds(value: string, chars: string): string {
63
+ let start = 0
64
+ let end = value.length
65
+ while (start < end && chars.includes(value[start])) start++
66
+ while (end > start && chars.includes(value[end - 1])) end--
67
+ return value.slice(start, end)
68
+ }
69
+
70
+ /** Strip a trailing run of `chars` from `value` in linear time. The equivalent
71
+ * /[chars]+$/g backtracks super-linearly on a long trailing run (S8786). */
72
+ function trimTrailing(value: string, chars: string): string {
73
+ let end = value.length
74
+ while (end > 0 && chars.includes(value[end - 1])) end--
75
+ return value.slice(0, end)
76
+ }
77
+
78
+ /** Trim the model's reply to a bare title: collapse whitespace, then peel wrapping quotes
79
+ * and trailing punctuation until stable, so `"Fix The Parser."` and `Fix The Parser.` both
80
+ * land on the plain phrase. */
81
+ export function cleanTitle(raw: string): string {
82
+ let title = raw.trim().replace(/\s+/g, ' ')
83
+ let prev: string
84
+ do {
85
+ prev = title
86
+ title = trimTrailing(trimBothEnds(title, WRAPPING_QUOTES), TRAILING_PUNCTUATION).trim()
87
+ } while (title !== prev)
88
+ return title
89
+ }
90
+
91
+ export default function sessionTitleExtension(pi: ExtensionAPI) {
92
+ // One title per session, reset when a new session takes over so a resumed or forked
93
+ // session can still earn its own name.
94
+ let titled = false
95
+ // Bumped on every session_start. Captured before the model call so a title that resolves
96
+ // after a /new (which resets state to a different session) is recognized as stale and
97
+ // dropped, rather than renaming whoever holds the session slot now.
98
+ let generation = 0
99
+
100
+ pi.on('session_start', () => {
101
+ titled = false
102
+ generation++
103
+ })
104
+
105
+ pi.on('agent_settled', async (_event, ctx) => {
106
+ if (titled) return
107
+ // Never clobber an existing name: a user-chosen or resumed name wins.
108
+ if (pi.getSessionName?.()) return
109
+ const model = ctx.model
110
+ if (!model) return // headless with no model: nothing to name with, and the guard is left unspent
111
+ const prompt = firstUserText(ctx)
112
+ if (!prompt) return // no user text this run: leave the guard unspent for a later real message
113
+
114
+ // Claim the single attempt before the await, so overlapping or repeated settles cannot
115
+ // each fire a model call; a failed attempt below is not retried within this session.
116
+ titled = true
117
+ const startedGeneration = generation
118
+ let title: string
119
+ try {
120
+ const { text } = await completeText(model, `First user message of a new coding session:\n\n${prompt.slice(0, MAX_PROMPT_CHARS)}`, {
121
+ system: TITLE_SYSTEM,
122
+ maxTokens: TITLE_MAX_TOKENS,
123
+ })
124
+ title = cleanTitle(text)
125
+ } catch {
126
+ return // no model, provider error: leave the session untitled (best-effort)
127
+ }
128
+ if (!title) return
129
+ // A /new during the await moved us to a different session, or a name has since been set;
130
+ // applying this title now would rename the wrong session, so drop it.
131
+ if (generation !== startedGeneration || pi.getSessionName?.()) return
132
+ // Post-await ctx getters throw once the session is disposed, and an escaping rejection
133
+ // from this un-awaited settle can exit pi; apply the title best-effort.
134
+ try {
135
+ pi.setSessionName(title)
136
+ ctx.ui.setTitle?.(title)
137
+ } catch {
138
+ // disposed session or a setter failure: leave the session untitled.
139
+ }
140
+ })
141
+ }
@@ -64,6 +64,68 @@ function formatCost(cost: number): string {
64
64
  return cost >= 0.01 ? `$${cost.toFixed(2)}` : `$${cost.toFixed(4)}`
65
65
  }
66
66
 
67
+ interface RateLimitWindow {
68
+ used_percentage: number
69
+ resets_at?: string
70
+ }
71
+ interface RateLimitSnapshot {
72
+ five_hour?: RateLimitWindow
73
+ seven_day?: RateLimitWindow
74
+ }
75
+
76
+ /** Lowercase every header name (HTTP names are case-insensitive) and drop null
77
+ * values, so a single lookup shape works regardless of how the provider cased
78
+ * them. ProviderHeaders values are string | null. */
79
+ function normalizeHeaders(raw: Record<string, string | null> | undefined): Record<string, string> {
80
+ const out: Record<string, string> = {}
81
+ for (const [key, value] of Object.entries(raw ?? {})) {
82
+ if (typeof value === 'string') out[key.toLowerCase()] = value
83
+ }
84
+ return out
85
+ }
86
+
87
+ function toNumber(value: string | undefined): number | undefined {
88
+ if (value === undefined || value.trim() === '') return undefined
89
+ const parsed = Number(value)
90
+ return Number.isFinite(parsed) ? parsed : undefined
91
+ }
92
+
93
+ /** One rate-limit window from the `anthropic-ratelimit-<prefix>-*` header family,
94
+ * taking a direct `-utilization` percentage when present, else computing it from
95
+ * `-limit` and `-remaining`. resets_at comes from `-reset` when the header is set.
96
+ * Header names vary, so only what is present is read and a bare number is enough. */
97
+ function readRateLimitWindow(headers: Record<string, string>, prefix: string): RateLimitWindow | undefined {
98
+ const base = `anthropic-ratelimit-${prefix}`
99
+ let usedPercentage = toNumber(headers[`${base}-utilization`])
100
+ if (usedPercentage === undefined) {
101
+ const limit = toNumber(headers[`${base}-limit`])
102
+ const remaining = toNumber(headers[`${base}-remaining`])
103
+ if (limit !== undefined && limit > 0 && remaining !== undefined) {
104
+ usedPercentage = ((limit - remaining) / limit) * 100
105
+ }
106
+ }
107
+ if (usedPercentage === undefined) return undefined
108
+ // A provider can report a utilization above 100 (or a remaining above the limit, making
109
+ // the computed value negative); clamp so the payload never carries a nonsense percentage.
110
+ const window: RateLimitWindow = { used_percentage: Math.max(0, Math.min(100, usedPercentage)) }
111
+ const resetsAt = headers[`${base}-reset`] ?? headers[`${base}-resets-at`]
112
+ if (resetsAt) window.resets_at = resetsAt
113
+ return window
114
+ }
115
+
116
+ /** The five-hour and seven-day utilization windows Claude's statusline reports,
117
+ * from the unified rate-limit response headers. Undefined when neither is present
118
+ * so a response without them never clobbers an earlier snapshot. */
119
+ function parseRateLimits(headers: Record<string, string>): RateLimitSnapshot | undefined {
120
+ const fiveHour = readRateLimitWindow(headers, 'unified-5h')
121
+ const sevenDay = readRateLimitWindow(headers, 'unified-7d')
122
+ if (!fiveHour && !sevenDay) return undefined
123
+ const snapshot: RateLimitSnapshot = {}
124
+ if (fiveHour) snapshot.five_hour = fiveHour
125
+ if (sevenDay) snapshot.seven_day = sevenDay
126
+ return snapshot
127
+ }
128
+
67
129
  export interface StatusLineConfig {
68
130
  command: string
69
131
  padding: number
@@ -120,6 +182,10 @@ export default function statusLine(pi: ExtensionAPI) {
120
182
  let apiDurationMs = 0
121
183
  let requestStartMs: number | undefined
122
184
  let lastUsage: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number } | undefined
185
+ // The most recent rate-limit snapshot parsed from provider response headers, and
186
+ // a once-per-session guard so a 429 warns the user only the first time it lands.
187
+ let rateLimits: RateLimitSnapshot | undefined
188
+ let rateLimitWarned = false
123
189
  let refreshTimer: ReturnType<typeof setInterval> | undefined
124
190
  let debounceTimer: ReturnType<typeof setTimeout> | undefined
125
191
  let running = false
@@ -193,6 +259,9 @@ export default function statusLine(pi: ExtensionAPI) {
193
259
  payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
194
260
  }
195
261
  if (styleName) payload.output_style = { name: styleName }
262
+ // The current utilization of the account's rate-limit windows, when the
263
+ // provider reported them; omitted entirely until a response has carried them.
264
+ if (rateLimits) payload.rate_limits = rateLimits
196
265
  return payload
197
266
  }
198
267
 
@@ -260,9 +329,24 @@ export default function statusLine(pi: ExtensionAPI) {
260
329
  pi.on('before_provider_request', async () => {
261
330
  requestStartMs = Date.now()
262
331
  })
263
- pi.on('after_provider_response', async () => {
332
+ pi.on('after_provider_response', async (event, ctx) => {
264
333
  if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
265
334
  requestStartMs = undefined
335
+ // Rate-limit windows and 429 handling ride on the same response event. Header
336
+ // names and presence vary, so parse only what is there and never throw.
337
+ const headers = normalizeHeaders(event.headers)
338
+ const snapshot = parseRateLimits(headers)
339
+ if (snapshot) rateLimits = snapshot
340
+ if (event.status === 429 && !rateLimitWarned) {
341
+ rateLimitWarned = true
342
+ const retryAfter = headers['retry-after']
343
+ const detail = retryAfter ? `; retry after ${retryAfter}s` : ''
344
+ try {
345
+ ctx.ui.notify(`Provider rate limit reached (429)${detail}`, 'warning')
346
+ } catch {
347
+ // The session may be gone by the time a late response lands; nothing to warn.
348
+ }
349
+ }
266
350
  })
267
351
  // The last message's token usage, for the breakdown getContextUsage() omits,
268
352
  // and the running cost total, so renders never re-walk the branch.
@@ -284,6 +368,8 @@ export default function statusLine(pi: ExtensionAPI) {
284
368
  apiDurationMs = 0
285
369
  requestStartMs = undefined
286
370
  lastUsage = undefined
371
+ rateLimits = undefined
372
+ rateLimitWarned = false
287
373
  clearInterval(refreshTimer)
288
374
  // Seed the running cost from the branch: a resumed or forked session starts
289
375
  // with history, and message_end only accumulates from here on.
@@ -327,6 +413,15 @@ export default function statusLine(pi: ExtensionAPI) {
327
413
  scheduleRefresh()
328
414
  })
329
415
 
416
+ // The model and effort segments of the payload go stale between turns; a switch
417
+ // fires these events, so refresh at once instead of waiting for the next tick.
418
+ pi.on('model_select', async () => {
419
+ scheduleRefresh()
420
+ })
421
+ pi.on('thinking_level_select', async () => {
422
+ scheduleRefresh()
423
+ })
424
+
330
425
  pi.on('session_compact', async (_event, ctx) => {
331
426
  // Compaction replaces the branch entries; reseed the total from what remains.
332
427
  costTotal = sessionCost(ctx)
@@ -51,6 +51,16 @@ export default function thinkingExtension(pi: ExtensionAPI) {
51
51
  // source 'extension' (a subagent prompt, a command body replayed through it); a
52
52
  // think keyword the user did not type must not escalate, mirroring hooks.ts's guard.
53
53
  if (event.source === 'extension') return
54
+ // A prompt that escalated but was then BLOCKED by a hook runs no turn, so no
55
+ // agent_settled ever fires to restore the level. The arrival of a new input is the
56
+ // signal that the prior prompt is gone: if this extension still owns the level (it is
57
+ // exactly our escalation target), restore before handling this input. In the normal
58
+ // path a settle already cleared pending, so this fires only for the blocked case.
59
+ if (pendingRestore !== undefined && (pi.getThinkingLevel?.() ?? ctx.thinkingLevel) === pendingTarget) {
60
+ pi.setThinkingLevel?.(pendingRestore)
61
+ pendingRestore = undefined
62
+ pendingTarget = undefined
63
+ }
54
64
  const target = requestedThinkingLevel(event.text)
55
65
  if (!target) return
56
66
  const current = pi.getThinkingLevel?.() ?? ctx.thinkingLevel ?? 'off'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
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",