pi-plus 0.1.8 → 0.1.11

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/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Fixed
6
+
7
+ - `pipi remove`/`pipi install` under a profile now update the source `~/.pi/agent/settings.json` `packages` list, not just the per-profile copy that the next materialization would discard. The wrapper registers a process-exit sync (`syncProfilePackagesToSource` from pi-hub) for profile launches, so the removed extension is not reinstalled on next launch.
8
+ - Context usage detection now sees the whole request, not just conversation messages: heuristic estimates (footer percentage before the first response and after `/compact`, zero-usage/error fallback, mid-session prompt/tool changes) include the folded system prompt and tool declarations, the usage anchor is invalidated when a newer-timestamp message is inserted into the prefix, Anthropic 1h cache writes (`cacheWrite1h`) are counted, and the footer shows a real estimate instead of `?` right after compaction. Wired via `estimateContextTokens`/`calculateContextTokens` shadows in the compaction wrapper plus a 9th module redirect for `coding-agent/src/core/agent-session.ts` (`getContextUsage` override).
9
+
10
+ ### Added
11
+
12
+ - Plan mode (openclaude/Claude Code style) via the `EnterPlanMode` / `ExitPlanMode` tools, a `/plan` command (`/plan`, `/plan show`, `/plan edit`), a `ctrl+alt+p` toggle, and a `--plan` launch flag. While active the model is read-only: `edit`/`write` are restricted to the session plan file (`<agent dir>/plans/<sessionId>.md`, the only writable target), `bash` is restricted to a read-only command allowlist, `powershell` and non-allowlisted custom tools (including the `subagent` tool unless the read-only `explore` agent type) are blocked via the `tool_call` event, and a `<plan_mode>` system-prompt section drives a research-then-plan workflow. `EnterPlanMode` asks the user to confirm; `ExitPlanMode` presents the plan with Approve / Stay / Edit options and, on approval, returns the approved plan with full tool access restored. The footer shows a `⏸ plan` indicator; state is per-session in memory (reset on new/resume/fork).
13
+ - `subagent` tool — delegate a task to a specialized sub-agent running in an isolated `pi -p --no-session` subprocess with its own context window (ported from the coding-agent subagent example, single mode only). Agent types are markdown files with frontmatter (`name`, `description`, `tools`, `model`) in `<agent dir>/agents/` (user) or the nearest `.pi/agents/` walking up from the project dir (project); two built-in types always exist: `worker` (full toolset) and `explore` (read-only). Sub-agents cannot spawn further sub-agents unless their definition lists the `subagent` tool; project-local agent definitions require a one-time trust confirmation in the TUI. The tool runs with `executionMode: "parallel"` (issue multiple calls in one turn for concurrency), inherits the session model/thinking by default (`model` param overrides), propagates aborts to the child process, reports child token usage/cost on the result, and truncates oversized final output.
14
+ - Task tools — `TaskCreate` / `TaskUpdate` / `TaskList` / `TaskGet` (openclaude V2 style), file-backed under `<agent dir>/tasks/<sessionId>/` with a monotonic `.highwatermark` id allocator, per-task `owner` and `blocks`/`blockedBy` dependencies, and atomic tmp+rename writes. A `/tasks` command and the `ctrl+shift+t` shortcut open a task-list overlay in the TUI.
15
+ - π+ welcome banner now shows the boxed banner on every launch (the first-run-per-project gate and its `pi-plus-banner.json` state are gone); the condensed tier remains the fallback only where the box cannot fit.
16
+ - `pipi completion <bash|zsh>` prints a shell completion script owned by pi-plus, covering the whole CLI: hub subcommands (dynamic profile names and per-profile models, providers, thinking levels) plus pi's native commands and flags. Moved here from the hub package, which no longer ships completion.
17
+ - `pipi --help` now ends with a "Profile commands (pi-plus)" section documenting `profile`, `use`/`unuse`, `completion`, and `--as` (via a `cli/args.ts` printHelp wrapper; 8th module redirect).
18
+ - Compiled `pi-plus` CLI artifact: `build.mjs` bundles `pipi` (pi + the plus override layer) into `dist/npm/`, a self-contained staging tree for `npm link` / `npm publish`. Exposes a single `pipi` bin — never `pi`. The CLI (command, help text, hub usage strings) is named `pipi`; the npm package stays `pi-plus`.
19
+ - The plus config wrapper now resolves package assets (themes, HTML export template, interactive assets) from the staged bundle layout.
20
+ - The version check against pi's release train (pi.dev) is skipped.
21
+ - Vim modal editing for the prompt input (core subset ported from [pi-vimmode](https://github.com/pekochan069/pi-vimmode), MIT, (c) 2026 pekochan069), registered as a hidden built-in extension. Modes: insert/normal/visual (`v`)/visual-line (`V`). Motions: `h j k l w b e 0 $ ^ f F t T` with counts. Operators/edits: `x s r J p P`, operator+motion `d/c/y`, linewise `dd cc yy`, insert entries `i a I A o O C`, `u`/Ctrl-r undo/redo, prompt search `/ ? n N`. Enabled with `"vim": true` in settings (project `.pi/settings.json` > agent dir > `~/.pi/settings.json`) or toggled with `/vim`, which persists the new state to the agent dir `settings.json` so it sticks across launches. Excluded: ex commands, macros, marks, named registers, easymotion, visual-block, JS config, cursor-shape escapes.
22
+
23
+ ### Removed
24
+
25
+ - The deprecated `pipi run` command. `pipi --as <name>` (one-off launch under a profile) and plain `pipi` (default profile, if set) replace it; hub dispatch is now management-only (`profile`, `use`, `unuse`).
package/README.md CHANGED
@@ -1,172 +1,93 @@
1
- # pi-plus
2
-
3
- Claude Code-exact context usage detection for [pi](https://github.com/earendil-works/pi).
4
-
5
- This extension measures pi's context usage with the **same arithmetic as Claude Code** —
6
- usage-based prefix counting plus rough tail estimation, CC's context-window resolution,
7
- and CC's warning thresholds. It replaces pi's built-in footer so its context field
8
- shows the plugin's CC-exact usage measured against the auto-compact threshold.
9
- Optionally, it can also take over auto-compaction and fire `ctx.compact()` at CC's
10
- auto-compact threshold.
11
-
12
- ## What it replicates (from Claude Code's source)
13
-
14
- | Behavior | CC source | Here |
15
- |---|---|---|
16
- | Context window resolution (`[1m]` suffix → capability ≥100k → 200k default) | `utils/context.ts` | `src/model-config.ts` |
17
- | Max output tokens per model + 8k slot-reservation cap | `utils/context.ts`, `services/api/claude.ts` | `src/model-config.ts` |
18
- | Effective window = window − min(maxOutput, 20k) | `services/compact/autoCompact.ts` | `src/model-config.ts` |
19
- | Auto-compact threshold = effective − 13,000 | `services/compact/autoCompact.ts` | `src/model-config.ts` |
20
- | Context measurement: last API usage (input+cacheWrite+cacheRead+output) + chars/4 tail estimate; images/PDFs flat 2000 | `utils/tokens.ts`, `services/tokenEstimation.ts` | `src/usage.ts` |
21
- | Statusline percentage excludes output tokens; raw window denominator | `utils/context.ts` | `src/percentages.ts` |
22
- | Warning/error/blocking thresholds (20k/20k/3k buffers) | `services/compact/autoCompact.ts` | `src/percentages.ts` |
23
- | Footer replication: cwd/branch line, token stats, model right side, extension-status line | `modes/interactive/components/footer.ts` | `src/footer-render.ts` |
24
-
25
- This plugin supports **no Claude Code environment variables**
26
- (`CLAUDE_CODE_*`, `DISABLE_COMPACT`, … are all ignored). All configuration is
27
- done through the settings system described below; the only env vars honored
28
- are the plugin's own kill-switches, `PI_PLUS_NO_AUTO_COMPACT` and
29
- `PI_PLUS_NO_LOG`.
30
-
31
- On a real session the plugin's token count matches pi's own `ctx.getContextUsage().tokens`
32
- exactly for the Anthropic-style usage shape (pi's `cacheWrite` already includes the 1h
33
- cache subset and `reasoning` ⊂ `output`, so the field-for-field sums coincide).
34
-
35
- ## Install
1
+ # plus
36
2
 
37
- ```bash
38
- # quick try
39
- pi -e ./src/index.ts
3
+ All override/custom logic lives here. Direct updates to any other folder under `packages/` are forbidden — upstream packages stay pristine so they can be synced/updated without merge conflicts.
40
4
 
41
- # permanent: symlink into pi's extension directory
42
- ln -s "$PWD/src" ~/.pi/agent/extensions/pi-plus
43
- # (or copy src/ there)
44
- ```
5
+ ## What this layer does
45
6
 
46
- ## Custom footer (default on)
7
+ Ports Claude Code semantics (reference: `../free-code/`, CC v2.1.87) onto pi's context detection, compaction, and reasoning — with pi-style env var names:
47
8
 
48
- By default the plugin takes over pi's footer via `ctx.ui.setFooter()` and
49
- replicates the built-in rendering — cwd/git-branch line, token stats
50
- (`↑ ↓ R W CH $`), right-aligned model/thinking level, and the extension-status
51
- line — with one substitution: **the context field shows the plugin's CC-exact
52
- usage measured against the auto-compact threshold**, e.g. `17%/187k (auto)`.
9
+ 1. **Context usage detection** (`src/context/detection.ts`) — effective context window = `contextWindow − min(maxTokens, 20000)`; auto-compact threshold = effective window − 13k; warning/error = threshold ∓ 20k; blocking limit = effective window − 3k; 3-failure circuit breaker.
10
+ 2. **Compaction** (`src/compaction/`) — single full-conversation summary using the CC 9-section prompt (`prompt.ts`); prompt-too-long retries drop the oldest turn (`compact.ts`); post-compact re-injection of up to 5 recently read files (50k token budget).
11
+ 3. **Reasoning effort** (`src/reasoning/effort.ts`) — CC effort levels (`low`/`medium`/`high`/`max`, `max` → `xhigh` → `high` downgrading), adaptive thinking default, `ultrathink` keyword → high effort for that turn, thinking budgets from env.
12
+ 4. **Hub profiles** (`src/coding-agent/main.ts` wrapper, backed by `@earendil-works/pi-hub` = `packages/hub`) — named pi profiles (provider/models/thinking/token/base URL) stored in `~/.pi/profiles.json`, materialized into isolated agent dirs under `~/.pi/pi-hub/profiles/<name>/`. Adds `pipi profile …`, `pipi use` / `pipi unuse`, and the `pipi --as <name>` flag. The wrapper resolves the profile, sets `PI_CODING_AGENT_DIR` in-process (read lazily by `getAgentDir()`), and delegates to the original `main`.
13
+ 5. **CLI surface** (`src/completion/`, `src/coding-agent/cli/args.ts` wrapper) — `pipi --help` gains a "Profile commands (pi-plus)" section, and `pipi completion <bash|zsh>` prints a shell completion script covering the whole CLI: hub subcommands (with dynamic profile/model names from `~/.pi/profiles.json`) plus pi's native commands and flags.
14
+ 6. **Welcome banner** (`src/coding-agent/ui/banner.ts`, registered as a hidden built-in extension from the `main.ts` wrapper) — pipi's TUI startup header becomes a CC-style banner (ported from better-claude-code-ui) with a block-character π+ mark: condensed logo by default, boxed variant with Extensions/Skills feeds on first run in a project or version change, compact/plain degradation on narrow terminals, `resumed <id> · <title>` on resume/fork. `setHeader` replaces pi's built-in header, so no settings changes are needed.
15
+ 7. **Vim modal editing** (`src/coding-agent/ui/vim/`, core subset ported from [pi-vimmode](https://github.com/pekochan069/pi-vimmode), MIT, (c) 2026 pekochan069) — vim-style modal editing for the prompt input, as a hidden built-in extension replacing the editor component. Supported: insert/normal/visual (`v`)/visual-line (`V`) modes; motions `h j k l w b e 0 $ ^ f F t T` with counts; operators `d c y` over motions, linewise `dd cc yy`; `x s r J p P i a I A o O C`; `u`/Ctrl-r undo/redo; prompt search `/ ? n N`. Excluded (vs full pi-vimmode): ex commands, macros, marks, named registers, easymotion, visual-block, JS config, cursor-shape escapes. Enable with `"vim": true` in settings — project `.pi/settings.json` wins over the agent dir, which wins over `~/.pi/settings.json` — or toggle with `/vim`, which applies immediately and persists the new state to the agent dir `settings.json` (project settings still override per project).
16
+ 8. **Subagent tool** (`src/extensions/subagent/`, ported from pi's subagent example, single mode only) — delegates a task to a specialized sub-agent in an isolated `pi -p --no-session` subprocess with its own context window. Agent types are markdown files with frontmatter (`name`, `description`, `tools`, `model`) in `<agent dir>/agents/` or the project's `.pi/agents/`; built-in types `worker` (full toolset) and `explore` (read-only) always exist. Parallel-safe (`executionMode: "parallel"`), abort propagates to the child, child usage/cost is reported, and sub-agents can't recurse unless their definition allows it.
17
+ 9. **Task tools** (`src/extensions/tasks/`, openclaude V2 style) — `TaskCreate`/`TaskUpdate`/`TaskList`/`TaskGet` backed by one JSON file per task under `<agent dir>/tasks/<sessionId>/` (monotonic ids via `.highwatermark`, atomic tmp+rename writes), with per-task `owner` and `blocks`/`blockedBy` dependencies. `/tasks` or `ctrl+shift+t` opens a task-list overlay in the TUI.
18
+ 10. **ask_user tool** (`src/extensions/ask-user/`, openclaude AskUserQuestion style) — the model can ask 1–4 structured questions per call (2–4 options with descriptions each, optional `multiSelect`, automatic "Other" free-text). In TUI mode a tabbed overlay dialog collects the answers; RPC mode falls back to sequential select/input dialogs; print/JSON mode makes the tool fail with an error result so the model proceeds with its own assumptions. Guardrails: unique question texts and option labels, no model-supplied "Other" option, header chips ≤ 12 chars.
19
+ 11. **User command hooks** (`src/extensions/hooks/`) — fires command hooks declared in `settings.json` under a `"hooks"` key (per-event matcher groups of `{type:"command", command, async}` entries; hooks from `~/.pi`, the agent dir, and the project `.pi` settings files are merged) at CC-analogue moments: `PermissionRequest` when any blocking prompt opens (ask_user dialog, plan-mode entry/approval), `PreToolUse` per tool call (`ask_user` matches `AskUserQuestion` matchers), `Stop` when the agent settles. Fire-and-forget detached spawns; never blocks or fails the agent.
53
20
 
54
- Semantics differ deliberately from pi's native `1.8%/1.0M` field:
21
+ The CLI identifies as **`pipi`** (pi-plus) via the `config.ts` wrapper (`APP_NAME`/`APP_TITLE` shadow). Scope is the display name only — config dir (`.pi`), `PI_`-prefixed env vars, and session layout stay pi's.
55
22
 
56
- - **Numerator** is the same measurement both sides already share (last valid
57
- API usage + chars/4 tail estimation).
58
- - **Denominator is the threshold** (effective window − 13k, settings-overridable),
59
- not the raw registry window — so the percentage reaches 100% exactly when
60
- the plugin's auto-compact fires. It rises as context fills, like pi's field.
61
- - ` (auto)` is appended when auto-compact is enabled; right after `/compact`
62
- the field shows `?/187k` until the next assistant response (pi's own
63
- convention for unknown post-compaction usage).
64
- - Colors follow the plugin's warning state: warning color inside the 20k
65
- warning buffer, error past the error threshold or blocking limit.
23
+ ## Mechanism
66
24
 
67
- Two built-in details are not reproducible from the extension API and are
68
- omitted: the `• xp` experimental-features marker, and the `(sub)` suffix for
69
- subscription providers other than `kimi-coding`.
25
+ - `loader/hooks.mjs` — Node module customization hook. Two jobs: (a) map `@earendil-works/*` specifiers to `packages/*/src` (mirrors root tsconfig paths), (b) redirect selected upstream modules to the wrapper modules below. Importers inside `packages/plus/` are never redirected, so wrappers can import the true original via relative path without a loop.
26
+ - `loader/run-plus.mjs` + `pipi` — entry point. Runs pi from TypeScript sources on Node's native type stripping (the repo is `erasableSyntaxOnly`). tsx is intentionally not used: its load hook silently produces empty modules when another customization hook is registered alongside it (Node 25).
27
+ - `src/<upstream-path>.ts` — wrapper modules. Each does `export * from "<relative path to original>"` and redefines selected exports; per ESM spec, explicit named exports shadow star exports, so everything not overridden passes through untouched.
28
+ - Redirect map: `loader/redirects.mjs` (9 modules: coding-agent compaction/agent-session/model-resolver/defaults/config/main/cli-args, agent agent.ts, agent harness compaction).
70
29
 
71
- **Conflict warning:** `setFooter` is last-caller-wins — pi-plus must be the
72
- only footer-taking extension. Another extension calling `setFooter` (e.g.
73
- `better-claude-code-ui`) replaces this footer entirely. Plain `setStatus`
74
- extensions are fine; they appear on the status line as usual.
30
+ ## Shell completion
75
31
 
76
- Set `"customFooter": "false"` (below) and `/reload` (or start a new session)
77
- to restore pi's built-in footer.
32
+ `pipi completion <bash|zsh>` prints a completion script for the whole CLI — hub subcommands
33
+ (dynamic profile names and per-profile models from `~/.pi/profiles.json`, providers, thinking
34
+ levels) plus pi's native commands and flags. Install with:
78
35
 
79
- ## Auto-compact takeover (optional)
36
+ ```bash
37
+ source <(pipi completion zsh) # add to ~/.zshrc
38
+ source <(pipi completion bash) # add to ~/.bashrc
39
+ ```
80
40
 
81
- pi's built-in auto-compaction fires at `contextWindow − 16,384` tokens, which is
82
- *earlier* than CC's threshold (`effective window − 13,000`, i.e. `window − 21,000`
83
- with the 8k output cap). For the CC threshold to actually govern, disable pi's
84
- built-in trigger in `~/.pi/agent/settings.json`:
41
+ ## Env vars (pi-style names)
85
42
 
86
- ```json
87
- { "compaction": { "enabled": false } }
88
- ```
43
+ | Var | Values | Effect |
44
+ | ----------------------------- | ----------------------------------- | --------------------------------------------------------------- |
45
+ | `PI_MAX_CONTEXT_TOKENS` | number | Overrides `model.contextWindow` after model resolution |
46
+ | `PI_AUTO_COMPACT_WINDOW` | number | Caps the window used for threshold math |
47
+ | `PI_AUTOCOMPACT_PCT_OVERRIDE` | 1–100 | Auto-compact threshold as % of effective window (capped at default) |
48
+ | `PI_BLOCKING_LIMIT_OVERRIDE` | number | Blocking limit override |
49
+ | `PI_DISABLE_COMPACT` | truthy | Disables all compaction (incl. manual) |
50
+ | `PI_DISABLE_AUTO_COMPACT` | truthy | Disables threshold-triggered auto-compaction |
51
+ | `PI_EFFORT_LEVEL` | `off`/`auto`/`low`/`medium`/`high`/`max` | Per-request reasoning effort override |
52
+ | `PI_MAX_THINKING_TOKENS` | number (`0` disables) | Thinking budget (enables thinking at `high` when > 0) |
53
+ | `PI_DISABLE_THINKING` | truthy | Disables thinking entirely |
54
+ | `PI_DISABLE_ADAPTIVE_THINKING`| truthy | Forces the budget-based thinking path |
89
55
 
90
- The plugin checks the overflow against CC's threshold on **every tool task
91
- finish** (`tool_execution_end`) and calls `ctx.compact()` the moment the
92
- threshold is crossed — pi performs the compaction between agent steps and
93
- continues the turn with the compacted context (the same window pi's own
94
- auto-compact and CC's autocompact use). The trigger is edge-triggered: it
95
- fires once per crossing and re-arms after post-compact usage drops below the
96
- threshold. Manual `/compact` keeps working.
97
-
98
- Set `PI_PLUS_NO_AUTO_COMPACT=1` to disable the plugin's trigger (footer field only).
99
-
100
- ## Configuration
101
-
102
- Settings are read through [pi-extension-settings](https://github.com/juanibiapina/pi-extension-settings)
103
- (the pi ecosystem's standard settings helpers — `getSetting()`), stored under a
104
- `pi-plus` section. Global values live in `~/.pi/agent/settings-extensions.json`,
105
- project values in `.pi/settings-extensions.json` (project wins per key). Values
106
- are strings; invalid or absent values fall back to the hardcoded CC-derived
107
- defaults. If `@juanibiapina/pi-extension-settings` is installed, the settings
108
- also appear in its `/extension-settings` UI.
109
-
110
- ```json
111
- {
112
- "pi-plus": {
113
- "autoCompact": "true",
114
- "contextWindow": "256000",
115
- "autoCompactThreshold": "235000",
116
- "autoCompactPercent": "50",
117
- "autoCompactWindowCap": "200000",
118
- "maxOutputTokens": "8192",
119
- "disableOutputTokenCap": "false",
120
- "blockingLimit": "190000",
121
- "customFooter": "true"
122
- }
123
- }
124
- ```
56
+ ## Conventions
125
57
 
126
- | Setting | Effect | Default |
127
- |---|---|---|
128
- | `autoCompact` | Master switch for the plugin's auto-compact trigger (`true`/`false`) | `true` |
129
- | `customFooter` | Replace pi's built-in footer with the plugin's (see above); takes effect on next session/`/reload` | `true` |
130
- | `contextWindow` | Override the model-derived context window (tokens) | model default (200k) |
131
- | `autoCompactThreshold` | Absolute token threshold for auto-compact; wins over everything below | effective window − 13k |
132
- | `autoCompactPercent` | Threshold as % of effective window (1–100, for testing) | off |
133
- | `autoCompactWindowCap` | Cap the window before threshold math (tokens) | off |
134
- | `maxOutputTokens` | Override max output tokens (bounded by the model's limit) | 8k-capped model default |
135
- | `disableOutputTokenCap` | Use the model's native default max output instead of the 8k slot cap | `false` |
136
- | `blockingLimit` | Absolute token usage that blocks further work | effective window − 3k |
137
-
138
- ## Usage-detection log
139
-
140
- Every usage check is appended as one JSON line to `pi-plus.log` inside pi's
141
- agent directory (`$PI_CODING_AGENT_DIR` or `~/.pi/agent`). Each line records
142
- the event (`usage_check` from the indicator or the auto-compact trigger,
143
- plus `auto_compact_fired` / `auto_compact_error`), the model, the measured
144
- token count, and the threshold/percent state:
145
-
146
- ```json
147
- {"ts":"2026-09-17T13:06:11.672Z","event":"usage_check","source":"indicator","model":"kimi-k2","tokens":9977,"percentLeft":94,"aboveWarning":false,"aboveError":false,"aboveAutoCompact":false,"atBlockingLimit":false}
58
+ - Mirror the upstream layout when overriding: e.g. logic overriding `packages/coding-agent/src/core/compaction/compaction.ts` lives in `src/coding-agent/core/compaction/compaction.ts`.
59
+ - Prefer extension points over patching: pi's extension API (see `packages/coding-agent/docs/extensions.md`), prompt templates, skills, and settings come first; reach for behavior overrides only when those cannot express the change.
60
+ - Each override module must re-export or wrap the upstream implementation instead of copying it, so upstream fixes propagate.
61
+ - Overrides are wired in only through the loader; nothing under `packages/plus` may be imported by upstream code.
62
+
63
+ ## Compiled artifact (npm)
64
+
65
+ `build.mjs` bundles `pipi` into `dist/npm/` — a self-contained staging tree that `npm link` /
66
+ `npm publish` operate on (this package itself stays private). The published artifact is
67
+ **`pi-plus`** on npmjs; it installs a single global command, **`pipi`** — deliberately
68
+ not `pi` (pi-plus must not own pi's command). The CLI identifies as `pipi` everywhere
69
+ (help text, hub commands).
70
+
71
+ ```bash
72
+ npm run build # in packages/plus; requires `npm run build:offline` at the repo root first
73
+ npm link # in packages/plus/dist/npm — global `pipi`
74
+ npm publish --access public --ignore-scripts # in packages/plus/dist/npm
148
75
  ```
149
76
 
150
- Logging is best-effort — a write failure never affects the host agent.
151
- Set `PI_PLUS_NO_LOG=1` to disable it.
77
+ The bundle applies `loader/redirects.mjs` at bundle time (esbuild plugin) and keeps each
78
+ module a singleton by funneling every `packages/*/src/**` import with a compiled `dist/`
79
+ counterpart to that dist file. `--no-env` is preserved via a banner scrub. Provider
80
+ credentials, config dir (`~/.pi`), and session layout are identical to source-mode `pipi`.
152
81
 
153
- ## Known limits
82
+ Known limitations of the compiled artifact:
154
83
 
155
- - With `customFooter` off (or replaced by another extension), pi's built-in
156
- footer keeps showing its own `percent%` (raw-window denominator — a much
157
- larger number for models like `kimi-for-coding` whose registry window is 1M);
158
- the plugin then has no on-screen display, only the log and the auto-compact
159
- trigger.
160
- - pi core owns the compaction cut point (keeps last ~20k tokens) — only the trigger
161
- threshold is CC-exact.
162
- - Exactness is guaranteed for Anthropic-style usage fields; other providers may map
163
- usage differently.
164
- - CC's `percentLeft` is not clamped to 100; neither is this port.
84
+ - `pipi server` / `pipi client` experimental subcommands (`PI_EXPERIMENTAL=1`) spawn sibling JS
85
+ files that are not emitted next to the bundle; use source-mode `./pipi` for those.
86
+ - `docs/` and `examples/` are not shipped, so `/docs`-style paths into them are absent.
165
87
 
166
- ## Development
88
+ ## Tests
167
89
 
168
- ```bash
169
- npm install
170
- npm test # vitest — pure-function ports against CC fixtures
171
- npm run typecheck
90
+ ```
91
+ npx vitest run # from packages/plus; unit tests under test/ (faux streamFn, no real APIs)
92
+ ./pipi --no-env # run pipi (pi-plus) from sources with the override layer active
172
93
  ```
package/anthropic.js ADDED
@@ -0,0 +1,82 @@
1
+ import { createRequire as __piCreateRequire } from "node:module"; const require = __piCreateRequire(import.meta.url);
2
+ {
3
+ const __noEnvIndex = process.argv.indexOf("--no-env");
4
+ if (__noEnvIndex !== -1) {
5
+ process.argv.splice(__noEnvIndex, 1);
6
+ for (const __key of ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "CEREBRAS_API_KEY", "XAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY", "MISTRAL_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY", "OPENCODE_API_KEY", "COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "HF_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "AWS_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_BEARER_TOKEN_BEDROCK", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_WEB_IDENTITY_TOKEN_FILE", "AZURE_OPENAI_API_KEY", "AZURE_OPENAI_BASE_URL", "AZURE_OPENAI_RESOURCE_NAME"]) delete process.env[__key];
7
+ }
8
+ }
9
+ var __require=(x=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(x,{get:(a,b)=>(typeof require<"u"?require:a)[b]}):x)(function(x){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+x+'" is not supported')});var procEnvCache=null;function getBunSandboxEnvValue(name){if(!(typeof process>"u"||!process.versions?.bun||Object.keys(process.env).length>0)){if(procEnvCache===null){procEnvCache=new Map;try{let{readFileSync}=__require("node:fs"),data=readFileSync("/proc/self/environ","utf-8");for(let entry of data.split("\0")){let idx=entry.indexOf("=");idx>0&&procEnvCache.set(entry.slice(0,idx),entry.slice(idx+1))}}catch{}}return procEnvCache.get(name)}}function getProviderEnvValue(name,env){return env?.[name]||(typeof process<"u"?process.env[name]:void 0)||getBunSandboxEnvValue(name)||void 0}var LOGO_SVG='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></svg>';function escapeHtml(value){return value.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&#39;")}function renderPage(options){let title=escapeHtml(options.title),heading=escapeHtml(options.heading),message=escapeHtml(options.message),details=options.details?escapeHtml(options.details):void 0;return`<!doctype html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
14
+ <title>${title}</title>
15
+ <style>
16
+ :root {
17
+ --text: #fafafa;
18
+ --text-dim: #a1a1aa;
19
+ --page-bg: #09090b;
20
+ --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
21
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
22
+ }
23
+ * { box-sizing: border-box; }
24
+ html { color-scheme: dark; }
25
+ body {
26
+ margin: 0;
27
+ min-height: 100vh;
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ padding: 24px;
32
+ background: var(--page-bg);
33
+ color: var(--text);
34
+ font-family: var(--font-sans);
35
+ text-align: center;
36
+ }
37
+ main {
38
+ width: 100%;
39
+ max-width: 560px;
40
+ display: flex;
41
+ flex-direction: column;
42
+ align-items: center;
43
+ justify-content: center;
44
+ }
45
+ .logo {
46
+ width: 72px;
47
+ height: 72px;
48
+ display: block;
49
+ margin-bottom: 24px;
50
+ }
51
+ h1 {
52
+ margin: 0 0 10px;
53
+ font-size: 28px;
54
+ line-height: 1.15;
55
+ font-weight: 650;
56
+ color: var(--text);
57
+ }
58
+ p {
59
+ margin: 0;
60
+ line-height: 1.7;
61
+ color: var(--text-dim);
62
+ font-size: 15px;
63
+ }
64
+ .details {
65
+ margin-top: 16px;
66
+ font-family: var(--font-mono);
67
+ font-size: 13px;
68
+ color: var(--text-dim);
69
+ white-space: pre-wrap;
70
+ word-break: break-word;
71
+ }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <main>
76
+ <div class="logo">${LOGO_SVG}</div>
77
+ <h1>${heading}</h1>
78
+ <p>${message}</p>
79
+ ${details?`<div class="details">${details}</div>`:""}
80
+ </main>
81
+ </body>
82
+ </html>`}function oauthSuccessHtml(message){return renderPage({title:"Authentication successful",heading:"Authentication successful",message})}function oauthErrorHtml(message,details){return renderPage({title:"Authentication failed",heading:"Authentication failed",message,details})}function base64urlEncode(bytes){let binary="";for(let byte of bytes)binary+=String.fromCharCode(byte);return btoa(binary).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}async function generatePKCE(){let verifierBytes=new Uint8Array(32);crypto.getRandomValues(verifierBytes);let verifier=base64urlEncode(verifierBytes),data=new TextEncoder().encode(verifier),hashBuffer=await crypto.subtle.digest("SHA-256",data),challenge=base64urlEncode(new Uint8Array(hashBuffer));return{verifier,challenge}}var nodeApis=null,nodeApisPromise=null,decode=s=>atob(s),CLIENT_ID=decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl"),AUTHORIZE_URL="https://claude.ai/oauth/authorize",TOKEN_URL="https://platform.claude.com/v1/oauth/token",CALLBACK_HOST=getProviderEnvValue("PI_OAUTH_CALLBACK_HOST")||"127.0.0.1",CALLBACK_PORT=53692,CALLBACK_PATH="/callback",REDIRECT_URI=`http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`,SCOPES="org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";async function getNodeApis(){if(nodeApis)return nodeApis;if(!nodeApisPromise){if(typeof process>"u"||!process.versions?.node&&!process.versions?.bun)throw new Error("Anthropic OAuth is only available in Node.js environments");nodeApisPromise=import("node:http").then(httpModule=>({createServer:httpModule.createServer}))}return nodeApis=await nodeApisPromise,nodeApis}function parseAuthorizationInput(input){let value=input.trim();if(!value)return{};try{let url=new URL(value);return{code:url.searchParams.get("code")??void 0,state:url.searchParams.get("state")??void 0}}catch{}if(value.includes("#")){let[code,state]=value.split("#",2);return{code,state}}if(value.includes("code=")){let params=new URLSearchParams(value);return{code:params.get("code")??void 0,state:params.get("state")??void 0}}return{code:value}}function formatErrorDetails(error){if(error instanceof Error){let details=[`${error.name}: ${error.message}`],errorWithCode=error;return errorWithCode.code&&details.push(`code=${errorWithCode.code}`),typeof errorWithCode.errno<"u"&&details.push(`errno=${String(errorWithCode.errno)}`),typeof error.cause<"u"&&details.push(`cause=${formatErrorDetails(error.cause)}`),error.stack&&details.push(`stack=${error.stack}`),details.join("; ")}return String(error)}async function startCallbackServer(expectedState){let{createServer}=await getNodeApis();return new Promise((resolve,reject)=>{let settleWait,waitForCodePromise=new Promise(resolveWait=>{let settled=!1;settleWait=value=>{settled||(settled=!0,resolveWait(value))}}),server=createServer((req,res)=>{try{let url=new URL(req.url||"","http://localhost");if(url.pathname!==CALLBACK_PATH){res.writeHead(404,{"Content-Type":"text/html; charset=utf-8"}),res.end(oauthErrorHtml("Callback route not found."));return}let code=url.searchParams.get("code"),state=url.searchParams.get("state"),error=url.searchParams.get("error");if(error){res.writeHead(400,{"Content-Type":"text/html; charset=utf-8"}),res.end(oauthErrorHtml("Anthropic authentication did not complete.",`Error: ${error}`));return}if(!code||!state){res.writeHead(400,{"Content-Type":"text/html; charset=utf-8"}),res.end(oauthErrorHtml("Missing code or state parameter."));return}if(state!==expectedState){res.writeHead(400,{"Content-Type":"text/html; charset=utf-8"}),res.end(oauthErrorHtml("State mismatch."));return}res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window.")),settleWait?.({code,state})}catch{res.writeHead(500,{"Content-Type":"text/plain; charset=utf-8"}),res.end("Internal error")}});server.on("error",err=>{reject(err)}),server.listen(CALLBACK_PORT,CALLBACK_HOST,()=>{resolve({server,redirectUri:REDIRECT_URI,cancelWait:()=>{settleWait?.(null)},waitForCode:()=>waitForCodePromise})})})}async function postJson(url,body,signal){let response=await fetch(url,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify(body),signal:AbortSignal.any([signal,AbortSignal.timeout(3e4)])}),responseBody=await response.text();if(!response.ok)throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`);return responseBody}async function exchangeAuthorizationCode(code,state,verifier,redirectUri,signal){let responseBody;try{responseBody=await postJson(TOKEN_URL,{grant_type:"authorization_code",client_id:CLIENT_ID,code,state,redirect_uri:redirectUri,code_verifier:verifier},signal)}catch(error){throw new Error(`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`)}let tokenData;try{tokenData=JSON.parse(responseBody)}catch(error){throw new Error(`Token exchange returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`)}return{type:"oauth",refresh:tokenData.refresh_token,access:tokenData.access_token,expires:Date.now()+tokenData.expires_in*1e3-300*1e3}}async function loginAnthropic(interaction){let{verifier,challenge}=await generatePKCE(),server=await startCallbackServer(verifier),manualAbort=new AbortController,onAbort=()=>server.cancelWait();interaction.signal.addEventListener("abort",onAbort,{once:!0}),interaction.signal.aborted&&onAbort();let code,state,manualInput,manualError;try{let authParams=new URLSearchParams({code:"true",client_id:CLIENT_ID,response_type:"code",redirect_uri:REDIRECT_URI,scope:SCOPES,code_challenge:challenge,code_challenge_method:"S256",state:verifier});interaction.notify({type:"auth_url",url:`${AUTHORIZE_URL}?${authParams.toString()}`,instructions:"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here."});let manualPromise=interaction.prompt({type:"manual_code",message:"Complete login in your browser, or paste the authorization code / redirect URL here:",placeholder:REDIRECT_URI,signal:manualAbort.signal}).then(input=>{manualInput=input,server.cancelWait()}).catch(error=>{manualError=error instanceof Error?error:new Error(String(error)),server.cancelWait()}),result=await server.waitForCode();if(manualError)throw manualError;if(result?.code)code=result.code,state=result.state;else if(manualInput){let parsed=parseAuthorizationInput(manualInput);if(parsed.state&&parsed.state!==verifier)throw new Error("OAuth state mismatch");code=parsed.code,state=parsed.state??verifier}if(!code){if(await manualPromise,manualError)throw manualError;if(manualInput){let parsed=parseAuthorizationInput(manualInput);if(parsed.state&&parsed.state!==verifier)throw new Error("OAuth state mismatch");code=parsed.code,state=parsed.state??verifier}}if(!code)throw new Error("Missing authorization code");if(!state)throw new Error("Missing OAuth state");return interaction.notify({type:"progress",message:"Exchanging authorization code for tokens..."}),exchangeAuthorizationCode(code,state,verifier,REDIRECT_URI,interaction.signal)}finally{interaction.signal.removeEventListener("abort",onAbort),manualAbort.abort(),server.server.close()}}async function refreshAnthropicToken(refreshToken,signal){let responseBody;try{responseBody=await postJson(TOKEN_URL,{grant_type:"refresh_token",client_id:CLIENT_ID,refresh_token:refreshToken},signal)}catch(error){throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`)}let data;try{data=JSON.parse(responseBody)}catch(error){throw new Error(`Anthropic token refresh returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`)}return{type:"oauth",refresh:data.refresh_token,access:data.access_token,expires:Date.now()+data.expires_in*1e3-300*1e3}}var anthropicOAuth={name:"Anthropic (Claude Pro/Max)",isSubscription:!0,login:loginAnthropic,refresh:(credential,signal)=>refreshAnthropicToken(credential.refresh,signal),async toAuth(credential){return{apiKey:credential.access}}};export{anthropicOAuth};