pi-code 1.0.11 → 1.0.13
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 +18 -8
- package/extensions/commands.ts +10 -0
- package/extensions/git-checkpoint.ts +7 -0
- package/extensions/hooks.ts +15 -3
- package/extensions/internal/project-approval.ts +36 -3
- package/extensions/mcp.ts +28 -4
- package/extensions/memory.ts +12 -4
- package/extensions/session-title.ts +27 -9
- package/extensions/status-line.ts +3 -1
- package/extensions/subagent/background.ts +15 -2
- package/extensions/subagent/index.ts +18 -1
- package/extensions/thinking.ts +19 -0
- package/package.json +10 -9
package/README.md
CHANGED
|
@@ -17,6 +17,10 @@ What a repository ships is treated as untrusted until you approve it: project MC
|
|
|
17
17
|
|
|
18
18
|

|
|
19
19
|
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
pi `>=0.79.1` (0.84.x recommended) and Node `>=22.19` for current pi.
|
|
23
|
+
|
|
20
24
|
## Install
|
|
21
25
|
|
|
22
26
|
```bash
|
|
@@ -38,30 +42,36 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
38
42
|
| Feature | Reads / provides | Extension |
|
|
39
43
|
|---|---|---|
|
|
40
44
|
| 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` (
|
|
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` |
|
|
42
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` |
|
|
43
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` |
|
|
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` |
|
|
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` |
|
|
45
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` |
|
|
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
|
-
|
|
|
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
|
+
| 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` |
|
|
52
|
+
| 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
53
|
| 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/` |
|
|
54
|
+
| 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
55
|
| Plan mode | `plan_mode_complete` tool, tool snapshot/restore that survives `/reload` | `plan-mode/` |
|
|
51
56
|
| Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
|
|
52
57
|
| 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
58
|
| 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
59
|
| 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
60
|
| 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
|
|
61
|
+
| 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
62
|
| 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` |
|
|
63
|
+
| 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` |
|
|
64
|
+
| 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` |
|
|
65
|
+
| `/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
66
|
| 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
67
|
|
|
68
|
+
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.
|
|
69
|
+
|
|
60
70
|
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
71
|
|
|
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.
|
|
72
|
+
`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
73
|
|
|
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.
|
|
74
|
+
`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
75
|
|
|
66
76
|
Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
|
|
67
77
|
|
package/extensions/commands.ts
CHANGED
|
@@ -466,6 +466,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
466
466
|
}
|
|
467
467
|
|
|
468
468
|
pi.on('session_start', async (_event, ctx) => {
|
|
469
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
470
|
+
// the same instance while a command's per-run scoping is still pending (its agent_settled
|
|
471
|
+
// never came). Carrying that into the next session would restore an unrelated tool set,
|
|
472
|
+
// bash/path scope, model, or effort onto it, so drop the pending state here. Drop only:
|
|
473
|
+
// no setActiveTools/setModel/setThinkingLevel, since the new session owns its own state.
|
|
474
|
+
pendingRestore = undefined
|
|
475
|
+
pendingBashRules = undefined
|
|
476
|
+
pendingPathRules = undefined
|
|
477
|
+
pendingModelRestore = undefined
|
|
478
|
+
pendingEffortRestore = undefined
|
|
469
479
|
const trusted = await isProjectApproved(ctx)
|
|
470
480
|
projectApproved = trusted
|
|
471
481
|
// A resume/fork/new session can switch projects in-process. pi cannot unregister a
|
|
@@ -253,6 +253,13 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
pi.on('session_start', async (_event, ctx) => {
|
|
256
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
257
|
+
// the same instance after turn_start took the pre-run snapshot but before turn_end saved
|
|
258
|
+
// it; that pending ref belongs to the previous session and must not attach to the next
|
|
259
|
+
// session's first turn_end. Re-arm runNeedsSnapshot too, so the next run snapshots its
|
|
260
|
+
// own tree even though the prior run left it false.
|
|
261
|
+
pending = undefined
|
|
262
|
+
runNeedsSnapshot = true
|
|
256
263
|
await ensureShadow(ctx)
|
|
257
264
|
checkpoints.clear()
|
|
258
265
|
for (const entry of ctx.sessionManager.getEntries()) {
|
package/extensions/hooks.ts
CHANGED
|
@@ -67,7 +67,7 @@ import * as fs from 'node:fs'
|
|
|
67
67
|
import * as os from 'node:os'
|
|
68
68
|
import * as path from 'node:path'
|
|
69
69
|
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
70
|
-
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
70
|
+
import type { ExtensionAPI, ExtensionContext, ToolCallEventResult } from '@earendil-works/pi-coding-agent'
|
|
71
71
|
import { runAgent } from './internal/agent-run.js'
|
|
72
72
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
73
73
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
|
|
@@ -859,6 +859,13 @@ function postToolFeedback(result: HookRunResult, eventName: string, isError: boo
|
|
|
859
859
|
return lines
|
|
860
860
|
}
|
|
861
861
|
|
|
862
|
+
/** A blocked tool_call verdict carrying pi's `terminate` flag (#7715): with it set on
|
|
863
|
+
* an all-terminating tool batch, pi skips the automatic follow-up model call that a plain
|
|
864
|
+
* block would otherwise pay for. */
|
|
865
|
+
function blockedToolCall(reason: string | undefined): ToolCallEventResult {
|
|
866
|
+
return { block: true, reason, terminate: true }
|
|
867
|
+
}
|
|
868
|
+
|
|
862
869
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
863
870
|
let config: HooksConfig = {}
|
|
864
871
|
let projectDir = ''
|
|
@@ -951,6 +958,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
951
958
|
|
|
952
959
|
pi.on('session_start', async (event, ctx) => {
|
|
953
960
|
sessionCtx = ctx
|
|
961
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
962
|
+
// the same instance while a Stop-hook continuation streak is in flight; it must not
|
|
963
|
+
// carry into the next session, so reset before any early return (disableAllHooks below).
|
|
964
|
+
stopHookActive = false
|
|
965
|
+
stopHookBlockCount = 0
|
|
954
966
|
const trusted = await isProjectApproved(ctx)
|
|
955
967
|
// Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
|
|
956
968
|
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
@@ -1002,9 +1014,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
1002
1014
|
// With no UI (headless) the block stands, which is the safe default.
|
|
1003
1015
|
if (decision.ask && ctx.hasUI) {
|
|
1004
1016
|
const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
|
|
1005
|
-
return approved ? undefined :
|
|
1017
|
+
return approved ? undefined : blockedToolCall(decision.reason)
|
|
1006
1018
|
}
|
|
1007
|
-
return
|
|
1019
|
+
return blockedToolCall(decision.reason)
|
|
1008
1020
|
})
|
|
1009
1021
|
|
|
1010
1022
|
// Claude's PostToolUse (success) and PostToolUseFailure (error) both feed their
|
|
@@ -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'
|
|
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
|
|
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
|
@@ -943,6 +943,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
943
943
|
// Original server/tool names per registered pi name, for Claude-style hook matchers.
|
|
944
944
|
const aliases: McpToolAlias[] = []
|
|
945
945
|
|
|
946
|
+
/** How many tools a server actually has registered. Counted from `registered` (the
|
|
947
|
+
* durable owner map) rather than registerTools' return, so a reconnect on a second
|
|
948
|
+
* session, where every tool is already registered and registerTools adds 0, still
|
|
949
|
+
* reports the true count in /mcp and the startup banner instead of zero. */
|
|
950
|
+
const serverToolCount = (name: string): number => [...registered.values()].filter((owner) => owner === name).length
|
|
951
|
+
|
|
946
952
|
/** Register every not-yet-registered tool of a server; returns how many were added. */
|
|
947
953
|
function registerTools(name: string, config: ServerConfig, tools: McpToolInfo[]): number {
|
|
948
954
|
let count = 0
|
|
@@ -1154,7 +1160,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1154
1160
|
const added = registerTools(name, config, refreshed)
|
|
1155
1161
|
if (added === 0) return
|
|
1156
1162
|
const current = status.get(name)
|
|
1157
|
-
status.set(name, { state: current?.state ?? 'connected', tools: (
|
|
1163
|
+
status.set(name, { state: current?.state ?? 'connected', tools: serverToolCount(name) })
|
|
1158
1164
|
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
1159
1165
|
} catch (error) {
|
|
1160
1166
|
console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -1187,7 +1193,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1187
1193
|
const client = await connect(name, config, authUi)
|
|
1188
1194
|
clients.set(name, client)
|
|
1189
1195
|
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
1190
|
-
|
|
1196
|
+
registerTools(name, config, tools)
|
|
1191
1197
|
subscribeToToolChanges(name, config, client)
|
|
1192
1198
|
// Prompts and resources are additive surfaces: their failures warn (inside
|
|
1193
1199
|
// connectPrompts) rather than flipping a tool-serving server to failed.
|
|
@@ -1195,7 +1201,10 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1195
1201
|
subscribeToPromptChanges(name, client)
|
|
1196
1202
|
ensureResourceTools()
|
|
1197
1203
|
subscribeToResourceChanges(client)
|
|
1198
|
-
|
|
1204
|
+
// Count from `registered`, not registerTools' return: a reconnect re-lists tools
|
|
1205
|
+
// that are already registered (return 0) but still serves them, so the banner
|
|
1206
|
+
// must reflect the true count.
|
|
1207
|
+
status.set(name, { state: 'connected', tools: serverToolCount(name) })
|
|
1199
1208
|
// A server that dies mid-session would otherwise stay "connected" in /mcp
|
|
1200
1209
|
// while every call fails with the SDK's bare "Not connected"; flip the
|
|
1201
1210
|
// status and free the name so a later session start can reconnect it.
|
|
@@ -1243,7 +1252,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1243
1252
|
for (const [name, client] of Array.from(clients.entries())) {
|
|
1244
1253
|
if (managedNames.has(name)) continue
|
|
1245
1254
|
clients.delete(name)
|
|
1246
|
-
|
|
1255
|
+
// Bound the close like session_shutdown does: a hung server must not stall the new
|
|
1256
|
+
// session start, which awaits this eviction before connecting the managed set.
|
|
1257
|
+
await withTimeout(client.close(), 3000, 'close').catch(() => {})
|
|
1247
1258
|
status.set(name, { state: 'disabled by managed policy', tools: 0 })
|
|
1248
1259
|
}
|
|
1249
1260
|
await connectServers(managedServers, authUi)
|
|
@@ -1289,6 +1300,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1289
1300
|
}
|
|
1290
1301
|
|
|
1291
1302
|
pi.on('session_start', async (_event, ctx) => {
|
|
1303
|
+
// Reset the status map so /mcp and the banner reflect only this session's config: a
|
|
1304
|
+
// server present last session but not this one must not linger as "connected". The
|
|
1305
|
+
// registered tools, aliases, and prompt commands stay: pi has no unregister (a
|
|
1306
|
+
// withdrawn tool keeps its registration and surfaces the server's own error), which
|
|
1307
|
+
// is why serverToolCount reads from `registered` to recover the true count here.
|
|
1308
|
+
status.clear()
|
|
1292
1309
|
const authUi = authUiFor(ctx)
|
|
1293
1310
|
// The managed allow/deny lists filter every scope, including a managed-mcp.json set.
|
|
1294
1311
|
const { allowed, denied } = mcpAllowDeny()
|
|
@@ -1318,6 +1335,13 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1318
1335
|
pi.on('session_shutdown', async () => {
|
|
1319
1336
|
// Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
|
|
1320
1337
|
await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
|
|
1338
|
+
// Drop the closed clients and their status now rather than waiting on each client's
|
|
1339
|
+
// onclose, which the SDK fires late: a same-process session switch (/new, /resume,
|
|
1340
|
+
// /fork) runs the next session_start right after this, and a lingering dead client
|
|
1341
|
+
// there would make connectServers skip reconnecting the name, stranding every tool
|
|
1342
|
+
// closure on a closed client. session_start resets status too, so a switch rebuilds it.
|
|
1343
|
+
clients.clear()
|
|
1344
|
+
status.clear()
|
|
1321
1345
|
})
|
|
1322
1346
|
|
|
1323
1347
|
pi.registerCommand('mcp', {
|
package/extensions/memory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Claude auto-names a new conversation from its first message; this does the same for
|
|
5
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
|
|
7
|
-
*
|
|
6
|
+
* a short title based on the first user message and applies it with setSessionName, which
|
|
7
|
+
* names the session in the selector and refreshes the terminal window/tab title natively
|
|
8
|
+
* (pi changelog); a separate ctx.ui.setTitle call would only duplicate that, so there is none.
|
|
8
9
|
*
|
|
9
10
|
* 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.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* or any provider error leaves the session untitled and never throws.
|
|
11
|
+
* headless run that persists its session still benefits from a readable name later. Titling
|
|
12
|
+
* is best-effort throughout: a session that already has a name, a run with no user text (a
|
|
13
|
+
* slash-command-only turn), a headless run with no model, or any provider error leaves the
|
|
14
|
+
* session untitled and never throws.
|
|
15
15
|
*
|
|
16
16
|
* Cost: one model call per session at most. The guard is claimed before the completion so
|
|
17
17
|
* repeated settles cannot each fire a call, and a failed attempt is not retried until a
|
|
@@ -92,9 +92,14 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
|
|
|
92
92
|
// One title per session, reset when a new session takes over so a resumed or forked
|
|
93
93
|
// session can still earn its own name.
|
|
94
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
|
|
95
99
|
|
|
96
100
|
pi.on('session_start', () => {
|
|
97
101
|
titled = false
|
|
102
|
+
generation++
|
|
98
103
|
})
|
|
99
104
|
|
|
100
105
|
pi.on('agent_settled', async (_event, ctx) => {
|
|
@@ -109,6 +114,7 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
|
|
|
109
114
|
// Claim the single attempt before the await, so overlapping or repeated settles cannot
|
|
110
115
|
// each fire a model call; a failed attempt below is not retried within this session.
|
|
111
116
|
titled = true
|
|
117
|
+
const startedGeneration = generation
|
|
112
118
|
let title: string
|
|
113
119
|
try {
|
|
114
120
|
const { text } = await completeText(model, `First user message of a new coding session:\n\n${prompt.slice(0, MAX_PROMPT_CHARS)}`, {
|
|
@@ -120,7 +126,19 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
|
|
|
120
126
|
return // no model, provider error: leave the session untitled (best-effort)
|
|
121
127
|
}
|
|
122
128
|
if (!title) return
|
|
123
|
-
|
|
124
|
-
|
|
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 refreshes the terminal/tab title natively (pi changelog), so a
|
|
136
|
+
// separate ctx.ui.setTitle call would only duplicate that. The guard stays: a
|
|
137
|
+
// disposed session throws from setSessionName post-await, and an escaping rejection
|
|
138
|
+
// from this un-awaited settle can exit pi.
|
|
139
|
+
pi.setSessionName(title)
|
|
140
|
+
} catch {
|
|
141
|
+
// disposed session or a setter failure: leave the session untitled.
|
|
142
|
+
}
|
|
125
143
|
})
|
|
126
144
|
}
|
|
@@ -105,7 +105,9 @@ function readRateLimitWindow(headers: Record<string, string>, prefix: string): R
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
if (usedPercentage === undefined) return undefined
|
|
108
|
-
|
|
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)) }
|
|
109
111
|
const resetsAt = headers[`${base}-reset`] ?? headers[`${base}-resets-at`]
|
|
110
112
|
if (resetsAt) window.resets_at = resetsAt
|
|
111
113
|
return window
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Background subagent runs: fire-and-forget children whose completion wakes
|
|
3
3
|
* the parent agent via a notification message. State lives in an in-memory
|
|
4
|
-
* registry queried via {status: true}; it is lost on restart
|
|
5
|
-
*
|
|
4
|
+
* registry queried via {status: true}; it is lost on restart. A child still
|
|
5
|
+
* running when pi quits is SIGTERMed (cancelAllBackgroundRuns); one still running
|
|
6
|
+
* across a same-process session switch keeps going under the new session.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { spawn } from 'node:child_process'
|
|
@@ -141,6 +142,18 @@ export function cancelBackgroundRun(id: string): 'cancelled' | 'not-running' | '
|
|
|
141
142
|
return 'cancelled'
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** SIGTERM every live background child, killing each process group the way a single
|
|
146
|
+
* cancel does. Called on quit: a detached child would otherwise keep running (and
|
|
147
|
+
* spending tokens) after pi exits, its completion swallowed. Returns how many were
|
|
148
|
+
* signalled; each cancelled child still holds its slot until it actually dies. */
|
|
149
|
+
export function cancelAllBackgroundRuns(): number {
|
|
150
|
+
let count = 0
|
|
151
|
+
for (const id of Array.from(runs.keys())) {
|
|
152
|
+
if (cancelBackgroundRun(id) === 'cancelled') count++
|
|
153
|
+
}
|
|
154
|
+
return count
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
export function backgroundStatusText(): string {
|
|
145
158
|
return formatStatus(runs.values())
|
|
146
159
|
}
|
|
@@ -32,7 +32,7 @@ import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
|
32
32
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
33
33
|
import { skillDirs } from '../skills.js'
|
|
34
34
|
import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
35
|
-
import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
35
|
+
import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
36
36
|
|
|
37
37
|
const MAX_PARALLEL_TASKS = 8
|
|
38
38
|
const MAX_CONCURRENCY = 4
|
|
@@ -1387,6 +1387,23 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1387
1387
|
})
|
|
1388
1388
|
})
|
|
1389
1389
|
|
|
1390
|
+
pi.on('session_shutdown', (event, ctx) => {
|
|
1391
|
+
// On quit pi is exiting, so a detached background child would keep running (and
|
|
1392
|
+
// spending tokens) with its completion swallowed: SIGTERM every live run, killing the
|
|
1393
|
+
// process group the way a cancel does. On a same-process session switch
|
|
1394
|
+
// (new/resume/fork) the children keep running under the new session, so leave them be
|
|
1395
|
+
// and warn once that they are still spending; /tasks inspects them. reload re-imports
|
|
1396
|
+
// this module (losing the registry), so it neither kills nor warns.
|
|
1397
|
+
if (event.reason === 'quit') {
|
|
1398
|
+
cancelAllBackgroundRuns()
|
|
1399
|
+
return
|
|
1400
|
+
}
|
|
1401
|
+
if (event.reason === 'new' || event.reason === 'resume' || event.reason === 'fork') {
|
|
1402
|
+
const active = activeBackgroundRuns()
|
|
1403
|
+
if (active > 0) ctx.ui?.notify(`${active} background run${active === 1 ? '' : 's'} still active; /tasks to inspect`, 'warning')
|
|
1404
|
+
}
|
|
1405
|
+
})
|
|
1406
|
+
|
|
1390
1407
|
// Claude surfaces each agent's description so the model can pick one autonomously.
|
|
1391
1408
|
// Served from the session-level cache above (keyed on cwd and scope, so an approval
|
|
1392
1409
|
// granted mid-session still widens it); project agents are included only when the
|
package/extensions/thinking.ts
CHANGED
|
@@ -46,11 +46,30 @@ export default function thinkingExtension(pi: ExtensionAPI) {
|
|
|
46
46
|
// already moved the level, thinking stands down instead of clobbering it.
|
|
47
47
|
let pendingTarget: ThinkingLevel | undefined
|
|
48
48
|
|
|
49
|
+
pi.on('session_start', () => {
|
|
50
|
+
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
51
|
+
// the same instance while an escalation is still pending (its agent_settled never came),
|
|
52
|
+
// and that stale restore must be dropped rather than fired into the next session, whose
|
|
53
|
+
// level the new session owns. Drop only: do NOT setThinkingLevel here.
|
|
54
|
+
pendingRestore = undefined
|
|
55
|
+
pendingTarget = undefined
|
|
56
|
+
})
|
|
57
|
+
|
|
49
58
|
pi.on('input', (event, ctx) => {
|
|
50
59
|
// Only genuine user input escalates. sendUserMessage emits an input event with
|
|
51
60
|
// source 'extension' (a subagent prompt, a command body replayed through it); a
|
|
52
61
|
// think keyword the user did not type must not escalate, mirroring hooks.ts's guard.
|
|
53
62
|
if (event.source === 'extension') return
|
|
63
|
+
// A prompt that escalated but was then BLOCKED by a hook runs no turn, so no
|
|
64
|
+
// agent_settled ever fires to restore the level. The arrival of a new input is the
|
|
65
|
+
// signal that the prior prompt is gone: if this extension still owns the level (it is
|
|
66
|
+
// exactly our escalation target), restore before handling this input. In the normal
|
|
67
|
+
// path a settle already cleared pending, so this fires only for the blocked case.
|
|
68
|
+
if (pendingRestore !== undefined && (pi.getThinkingLevel?.() ?? ctx.thinkingLevel) === pendingTarget) {
|
|
69
|
+
pi.setThinkingLevel?.(pendingRestore)
|
|
70
|
+
pendingRestore = undefined
|
|
71
|
+
pendingTarget = undefined
|
|
72
|
+
}
|
|
54
73
|
const target = requestedThinkingLevel(event.text)
|
|
55
74
|
if (!target) return
|
|
56
75
|
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.
|
|
3
|
+
"version": "1.0.13",
|
|
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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"todo"
|
|
21
21
|
],
|
|
22
22
|
"license": "MIT",
|
|
23
|
+
"author": "ilovepixelart",
|
|
23
24
|
"type": "module",
|
|
24
25
|
"files": [
|
|
25
26
|
"extensions"
|
|
@@ -51,20 +52,20 @@
|
|
|
51
52
|
"provenance": true
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
54
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
55
56
|
"typebox": "^1.3.6"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
|
-
"@earendil-works/pi-ai": "
|
|
59
|
-
"@earendil-works/pi-coding-agent": "
|
|
60
|
-
"@earendil-works/pi-tui": "
|
|
59
|
+
"@earendil-works/pi-ai": ">=0.79.1",
|
|
60
|
+
"@earendil-works/pi-coding-agent": ">=0.79.1",
|
|
61
|
+
"@earendil-works/pi-tui": ">=0.79.1"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
64
|
"@biomejs/biome": "^2.5.4",
|
|
64
|
-
"@earendil-works/pi-agent-core": "^0.84.
|
|
65
|
-
"@earendil-works/pi-ai": "^0.84.
|
|
66
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
67
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
65
|
+
"@earendil-works/pi-agent-core": "^0.84.2",
|
|
66
|
+
"@earendil-works/pi-ai": "^0.84.2",
|
|
67
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
68
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
68
69
|
"@types/node": "^26.1.1",
|
|
69
70
|
"@vitest/coverage-v8": "^4.1.10",
|
|
70
71
|
"typescript": "^7.0.2",
|