@aaroncql/pim-agent 0.0.1

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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AaronCQL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,212 @@
1
+ <!-- omit in toc -->
2
+ # PIM - Pi IMproved
3
+
4
+ _**Pim is to Pi what Vim is to Vi.**_
5
+
6
+ An opinionated yet minimal, Bun-native extension pack for [Pi](https://pi.dev/): revamped tools, ANSI-compatible themes, fzf-style autocompletions, Telegram bot, and more. Scores up to [41.6% on Terminal-Bench 2.0](#terminal-bench-20) with locally hosted Qwen3.6-35B, matching Claude Code + Sonnet 4.5.
7
+
8
+ - [Quick Start](#quick-start)
9
+ - [API Keys (Optional)](#api-keys-optional)
10
+ - [Recommended Settings (Optional)](#recommended-settings-optional)
11
+ - [Agent Tools](#agent-tools)
12
+ - [Terminal UI](#terminal-ui)
13
+ - [Telegram Bot](#telegram-bot)
14
+ - [Setup](#setup)
15
+ - [Commands](#commands)
16
+ - [Features](#features)
17
+ - [Why Pim?](#why-pim)
18
+ - [Harness Design](#harness-design)
19
+ - [Terminal-Bench 2.0](#terminal-bench-20)
20
+ - [Developing](#developing)
21
+
22
+ ## Quick Start
23
+
24
+ > [!IMPORTANT]
25
+ > The following instructions assume you have [Pi](https://pi.dev/docs/latest/quickstart) and [Bun](https://bun.com/docs/installation) already installed. If not, install them first (_or ask your agent to do it for you_). For all things related to Pi, refer to [Pi's comprehensive docs](https://pi.dev/docs/latest).
26
+
27
+ ```sh
28
+ # First, install Pim as a Pi extension:
29
+ pi install npm:@aaroncql/pim-agent
30
+
31
+ # Then, install the Bun-native `pim` launcher:
32
+ bun install -g @aaroncql/pim-agent
33
+
34
+ # Finally, launch pim:
35
+ pim
36
+ ```
37
+
38
+ The `pim` command is a [thin Bun launcher](./bin/pim.ts) that wraps around `pi` so that Bun-specific tooling and APIs can be used. Other Pi extensions should continue to work normally.
39
+
40
+ ### API Keys (Optional)
41
+
42
+ Pim's web tools use [Exa](https://exa.ai) for searching the web and [Jina](https://jina.ai/reader/) for fetching websites as Markdown. Without API keys, the tools are subject to the following rate limits (as of May 2026):
43
+
44
+ - Exa - 1,000 requests per month
45
+ - Jina - 20 requests per minute
46
+
47
+ For heavier usage, add API keys to `~/.pim/settings.json`:
48
+
49
+ ```json
50
+ {
51
+ "exa": {
52
+ "apiKey": "api_key_here"
53
+ },
54
+ "jina": {
55
+ "apiKey": "api_key_here"
56
+ }
57
+ }
58
+ ```
59
+
60
+ Environment variables override `settings.json` when present:
61
+
62
+ ```sh
63
+ EXA_API_KEY='api_key_here' JINA_API_KEY='api_key_here' pim
64
+ ```
65
+
66
+ ### Recommended Settings (Optional)
67
+
68
+ Add the following settings to your `~/.pi/agent/settings.json` for the best experience with Pim:
69
+
70
+ ```json
71
+ {
72
+ "quietStartup": true,
73
+ "editorPaddingX": 1,
74
+ "markdown": {
75
+ "codeBlockIndent": ""
76
+ }
77
+ }
78
+ ```
79
+
80
+ ## Agent Tools
81
+
82
+ Pim revamps Pi's default tools (`bash`, `read`, `write`, `edit`) and adds the following:
83
+
84
+ - **`glob`** - file enumeration by glob pattern, sorted newest-first, respects `.gitignore`
85
+ - **`grep`** - regex search across files with context lines, multiline matching, respects `.gitignore`
86
+ - **`web_search`** - search the web via [Exa](https://exa.ai) with ranked results and snippets
87
+ - **`web_fetch`** - fetch websites as Markdown via [Jina](https://jina.ai/reader/), with browser-rendered fallback via [`Bun.WebView`](https://bun.com/docs/runtime/webview)
88
+ - **`subagent`** - delegate complex work to isolated sub-sessions with full tool access
89
+ - **`todo`** - in-session task list with a live widget in the UI footer
90
+
91
+ ## Terminal UI
92
+
93
+ Pim also ships with quality of life improvements for the TUI:
94
+
95
+ - **ANSI-compatible themes** - `pim-light` and `pim-dark` themes that match your terminal's colour scheme
96
+ - **fzf-style autocomplete** - `@path` file picker and `/command` picker with fuzzy search
97
+ - **Git-aware powerline footer** - current dir, git branch and states, context usage, model and session cost (toggle with `/powerline`)
98
+ - **TPS reporting** - per-cycle decode/prefill rate, TTFT, and cache read tokens (toggle with `/tps`)
99
+ - **Concise tool headers** - minimal one-liner title across all tool calls, `Ctrl+O` to toggle full details
100
+
101
+ ## Telegram Bot
102
+
103
+ Run Pim as a Telegram bot with full agent capabilities in your DMs or group chats (supports threads).
104
+
105
+ ### Setup
106
+
107
+ Create `~/.pim/telegram/config.json` with your bot token (from [@BotFather](https://t.me/BotFather)) and an allowlist of chat IDs the bot will respond to:
108
+
109
+ ```json
110
+ {
111
+ "token": "YOUR_TELEGRAM_BOT_TOKEN",
112
+ "allow": [123456789, 987654321]
113
+ }
114
+ ```
115
+
116
+ Then, install and run as a persistent daemon (_recommended_):
117
+
118
+ ```sh
119
+ # Supports Linux (systemd) and macOS (launchd)
120
+ pim --mode telegram --install
121
+
122
+ # Tear down
123
+ pim --mode telegram --uninstall
124
+ ```
125
+
126
+ The daemon auto-restarts on failure and supports the `/update` command for in-chat updates.
127
+
128
+ For development, run standalone with `pim --mode telegram` instead.
129
+
130
+ ### Commands
131
+
132
+ > [!TIP]
133
+ > Use `/commands` on your bot for all commands to show up on your Telegram UI.
134
+
135
+ | Command | Description |
136
+ | ------------ | -------------------------------------------------- |
137
+ | `/cancel` | Cancel the current turn |
138
+ | `/cd` | Show or change the working directory |
139
+ | `/chatid` | Show this chat's numeric ID |
140
+ | `/clear` | Reset chat history and context window |
141
+ | `/commands` | Register all commands with Telegram |
142
+ | `/compact` | Compact the current session context |
143
+ | `/effort` | Show or change thinking effort level |
144
+ | `/logs` | Show or change log verbosity |
145
+ | `/model` | Show or change the AI model |
146
+ | `/temporary` | Toggle temporary chat (fresh session each message) |
147
+ | `/update` | Update the bot to the latest version |
148
+ | `/usage` | Show context window and session cost |
149
+
150
+ ### Features
151
+
152
+ - **Scheduled tasks** - your bot can create one-time, interval, or cron-based tasks that fire automatically; ask your bot to schedule something.
153
+ - **Rich media** - send photos, documents, videos, audio, and voice messages directly in chat; your bot can also send files back to you.
154
+ - **Thread-specific prompts** - each chat (or thread) gets its own session and instructions; ask your bot to modify its instructions.
155
+
156
+ ## Why Pim?
157
+
158
+ ### Harness Design
159
+
160
+ Pim overrides Pi's default tools (`bash`, `read`, `write`, `edit`) so that all tools produce consistent, structured output for the model, cross-reference each other where useful, and render uniformly in the TUI.
161
+
162
+ The system prompt is also kept as minimal as possible: at just ~3K tokens despite having 10+ tools (vs OpenCode's ~10K, Hermes' ~16K), with tool descriptions focusing on _how_ to use each tool instead of prescribing _when_. The rationale is that models already appear to internally encode when tools are needed, and prompting them to call tools can [suppress both necessary and unnecessary calls](https://arxiv.org/abs/2605.09252).
163
+
164
+ ### Terminal-Bench 2.0
165
+
166
+ Preliminary results from two full runs:
167
+
168
+ | ID | Pim Version | LLM / Model | Results |
169
+ | --- | --- | --- | --- |
170
+ | [r1](./benchmarks/terminal_bench_2/results/r1/) | [`21d084d1`](https://github.com/AaronCQL/pim-agent/tree/21d084d1) | `Qwen3.6-35B-A3B-UD-Q6_K_XL.gguf` | **41.6%** (37/89) |
171
+ | [r2](./benchmarks/terminal_bench_2/results/r2/) | [`bfd792cf`](https://github.com/AaronCQL/pim-agent/tree/bfd792cf) | `Qwen3.6-35B-A3B-UD-Q6_K_XL.gguf` | **36.0%** (32/89) |
172
+
173
+ Comparing against the same Qwen3.6-35B model, Pim solves up to **70% more tasks** than [little-coder](https://github.com/itayinbarr/little-coder) (41.6% vs 24.6%). This puts Pim, with a locally hosted model, in a similar tier to Claude Code + Sonnet 4.5 (40.1%) and above Codex + GPT-5-Mini (31.9%).
174
+
175
+ The Qwen3.6-35B model is hosted via llama.cpp on an M4 Pro 48GB MacBook, with the following config:
176
+
177
+ ```sh
178
+ llama-server \
179
+ -c 131072 \
180
+ -ngl 99 \
181
+ --slot-save-path /tmp/llama-slots \
182
+ --flash-attn on \
183
+ --cache-type-k q8_0 \
184
+ --cache-type-v q8_0 \
185
+ --jinja \
186
+ --temp 0.6 \
187
+ --top-p 0.95 \
188
+ --top-k 20 \
189
+ --min-p 0.0 \
190
+ --presence-penalty 0.0 \
191
+ --repeat-penalty 1.0 \
192
+ --reasoning-budget 16384 \
193
+ --reasoning-budget-message "Alright, I've thought enough. Let me take the next concrete step now — either a tool call or a final answer — and refine based on what I learn." \
194
+ -np 1
195
+ ```
196
+
197
+ _Note 1_: results are preliminary as only 2 independent full runs were conducted; Terminal-Bench 2.0 requires 5 independent full runs for an official score.
198
+
199
+ _Note 2_: the gap with little-coder may be partly explained by different inference configs (128K context vs 32K, Q6_K_XL vs Q4_K_M, higher thinking budget, etc.).
200
+
201
+ _Note 3_: in r1, the recorded result is actually 1 higher at 38/89. However, the `code-from-image` trial was excluded because Qwen autonomously searched for the answer online after 27 legitimate turns (see line 822 in [trajectory.json](benchmarks/terminal_bench_2/results/r1/code-from-image/trajectory.json)).
202
+
203
+ _Note 4_: see the [`benchmarks/terminal_bench_2`](./benchmarks/terminal_bench_2/) dir for breakdown of results and reproduction steps.
204
+
205
+ ## Developing
206
+
207
+ ```sh
208
+ # Link locally and launch:
209
+ bun dev
210
+ ```
211
+
212
+ Pim is registered as a project-local Pi package via `.pi/settings.json` and auto-loads when launched from within this repo. Use the built-in `/reload` command to reload after edits without restarting.
package/bin/pim.ts ADDED
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env bun
2
+ import { dirname, join } from "node:path";
3
+
4
+ const PI_PACKAGE = "@earendil-works/pi-coding-agent";
5
+
6
+ function findPiCli(): string {
7
+ const globalCli = resolveGlobalPiCli();
8
+ if (globalCli) {
9
+ return globalCli;
10
+ }
11
+
12
+ try {
13
+ const pkgUrl = import.meta.resolve(`${PI_PACKAGE}/package.json`);
14
+ return join(dirname(Bun.fileURLToPath(pkgUrl)), "dist/cli.js");
15
+ } catch {
16
+ throw new Error(
17
+ `Pim could not locate ${PI_PACKAGE}.\n` +
18
+ `Install it globally under Bun: bun install -g ${PI_PACKAGE}`
19
+ );
20
+ }
21
+ }
22
+
23
+ function resolveGlobalPiCli(): string | null {
24
+ const result = Bun.spawnSync({ cmd: ["bun", "pm", "-g", "bin"] });
25
+ if (result.exitCode !== 0) {
26
+ return null;
27
+ }
28
+ const binDir = result.stdout.toString().trim();
29
+ if (!binDir) {
30
+ return null;
31
+ }
32
+ const cliPath = join(
33
+ binDir,
34
+ "..",
35
+ "install",
36
+ "global",
37
+ "node_modules",
38
+ PI_PACKAGE,
39
+ "dist",
40
+ "cli.js"
41
+ );
42
+ return Bun.file(cliPath).size > 0 ? cliPath : null;
43
+ }
44
+
45
+ const cliArgs = process.argv.slice(2);
46
+
47
+ // Pi's argparse rejects prompts beginning with `-` and doesn't honour `--`
48
+ // itself; do the split here and forward the prompt via pi's stdin instead.
49
+ const dashDashIdx = cliArgs.indexOf("--");
50
+ let promptViaStdin: string | undefined;
51
+ if (dashDashIdx >= 0) {
52
+ promptViaStdin = cliArgs.slice(dashDashIdx + 1).join(" ");
53
+ cliArgs.length = dashDashIdx;
54
+ }
55
+
56
+ const modeIdx = cliArgs.findIndex(
57
+ (a) => a === "--mode" || a.startsWith("--mode=")
58
+ );
59
+ const mode =
60
+ modeIdx >= 0
61
+ ? cliArgs[modeIdx]!.includes("=")
62
+ ? cliArgs[modeIdx]!.split("=")[1]
63
+ : cliArgs[modeIdx + 1]
64
+ : undefined;
65
+ if (mode === "telegram") {
66
+ if (cliArgs.includes("--install")) {
67
+ const { Supervisor } = await import("../src/telegram/Supervisor.ts");
68
+ await Supervisor.install();
69
+ process.exit(0);
70
+ }
71
+ if (cliArgs.includes("--uninstall")) {
72
+ const { Supervisor } = await import("../src/telegram/Supervisor.ts");
73
+ await Supervisor.uninstall();
74
+ process.exit(0);
75
+ }
76
+ const { start } = await import("../src/telegram/index.ts");
77
+ await start(cliArgs);
78
+ process.exit(0);
79
+ }
80
+
81
+ const piCli = findPiCli();
82
+ const proc = Bun.spawn({
83
+ cmd: [process.execPath, piCli, ...cliArgs],
84
+ stdio: [
85
+ promptViaStdin === undefined ? "inherit" : "pipe",
86
+ "inherit",
87
+ "inherit",
88
+ ],
89
+ env: process.env,
90
+ });
91
+ if (promptViaStdin !== undefined && proc.stdin) {
92
+ proc.stdin.write(promptViaStdin);
93
+ proc.stdin.end();
94
+ }
95
+ // Forward shutdown signals so pi's bash subtrees aren't orphaned on the host.
96
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
97
+ process.once(sig, () => {
98
+ try {
99
+ proc.kill(sig);
100
+ } catch {}
101
+ });
102
+ }
103
+ const exitCode = await proc.exited;
104
+ const signalCode = proc.signalCode as NodeJS.Signals | null;
105
+ if (signalCode) {
106
+ process.kill(process.pid, signalCode);
107
+ } else {
108
+ process.exit(exitCode ?? 0);
109
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@aaroncql/pim-agent",
3
+ "version": "0.0.1",
4
+ "description": "Pim is to Pi what Vim is to Vi.",
5
+ "type": "module",
6
+ "bin": {
7
+ "pim": "bin/pim.ts"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/"
12
+ ],
13
+ "engines": {
14
+ "bun": ">=1.2.7"
15
+ },
16
+ "pi": {
17
+ "extensions": [
18
+ "./src/extensions"
19
+ ],
20
+ "themes": [
21
+ "./src/themes"
22
+ ]
23
+ },
24
+ "scripts": {
25
+ "dev": "bun link && pim",
26
+ "typecheck": "tsgo --noEmit",
27
+ "test": "bun test src --only-failures",
28
+ "lint": "oxlint . --fix",
29
+ "format": "prettier --write --list-different package.json tsconfig.json .oxlintrc.json .prettierrc.json \"src/**/*.ts\" \"bin/**/*.ts\" \"**/*.md\"",
30
+ "check": "bun run typecheck && bun run test && bun run lint && bun run format"
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": "*"
34
+ },
35
+ "devDependencies": {
36
+ "@earendil-works/pi-coding-agent": "0.76.0",
37
+ "@types/bun": "latest",
38
+ "@typescript/native-preview": "^7.0.0-dev.20260505.1",
39
+ "oxlint": "^1.62.0",
40
+ "prettier": "^3.8.3"
41
+ },
42
+ "dependencies": {
43
+ "diff": "^9.0.0",
44
+ "fzf": "^0.5.2",
45
+ "grammy": "^1.42.0",
46
+ "ignore": "^7.0.5",
47
+ "ky": "^2.0.2"
48
+ }
49
+ }
@@ -0,0 +1,109 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ const SPLASH_ID = "pim-splash";
7
+
8
+ const shortcuts = [
9
+ ["Ctrl+C", "Clear editor (first) / exit (second)"],
10
+ ["Escape", "Cancel autocomplete / abort streaming"],
11
+ ["/<command>", "Slash commands", "<command>"],
12
+ ["/hotkeys", "Show all keyboard shortcuts"],
13
+ ["/settings", "Open settings menu"],
14
+ ["/powerline", "Toggle Pim powerline footer"],
15
+ ["@<path>", "Attach files", "<path>"],
16
+ ["!<command>", "Run bash command", "<command>"],
17
+ ["!!<command>", "Run bash command (excluded from context)", "<command>"],
18
+ ] as const;
19
+
20
+ export default async function (pi: ExtensionAPI): Promise<void> {
21
+ if (typeof Bun === "undefined") {
22
+ throw new Error(
23
+ "Pim requires the Bun runtime.\n" +
24
+ "Install pi via bun: bun install -g @earendil-works/pi-coding-agent\n" +
25
+ "Then run: pim"
26
+ );
27
+ }
28
+
29
+ const pkgPath = `${import.meta.dir}/../../../package.json`;
30
+ const { version } = (await Bun.file(pkgPath).json()) as { version: string };
31
+
32
+ const keyCol = Math.max(...shortcuts.map(([k]) => k.length)) + 2;
33
+
34
+ let splashShown = false;
35
+
36
+ pi.on("session_start", (event, ctx) => {
37
+ if (event.reason !== "startup" && event.reason !== "new") {
38
+ return;
39
+ }
40
+
41
+ const theme = ctx.ui.theme;
42
+ const renderKey = (key: string, muted: string | undefined): string => {
43
+ const padding = " ".repeat(Math.max(0, keyCol - key.length));
44
+ if (!muted) {
45
+ return theme.fg("mdCode", key + padding);
46
+ }
47
+ const idx = key.indexOf(muted);
48
+ if (idx === -1) {
49
+ return theme.fg("mdCode", key + padding);
50
+ }
51
+ return (
52
+ theme.fg("mdCode", key.slice(0, idx)) +
53
+ theme.fg("muted", muted) +
54
+ key.slice(idx + muted.length) +
55
+ padding
56
+ );
57
+ };
58
+
59
+ const title =
60
+ theme.bold(theme.fg("accent", "PIM - Pi IMproved")) +
61
+ " " +
62
+ theme.italic(theme.fg("muted", `v${version}`));
63
+ ctx.ui.setWidget(SPLASH_ID, [
64
+ title,
65
+ ...shortcuts.map(
66
+ ([k, d, muted]) => renderKey(k, muted) + theme.fg("dim", d)
67
+ ),
68
+ ]);
69
+ splashShown = true;
70
+ });
71
+
72
+ const clearSplash = (ctx: ExtensionContext) => {
73
+ if (splashShown) {
74
+ ctx.ui.setWidget(SPLASH_ID, undefined);
75
+ splashShown = false;
76
+ }
77
+ };
78
+
79
+ pi.on("input", (_event, ctx) => {
80
+ clearSplash(ctx);
81
+ return { action: "continue" };
82
+ });
83
+ pi.on("user_bash", (_event, ctx) => {
84
+ clearSplash(ctx);
85
+ });
86
+ pi.on("model_select", (_event, ctx) => {
87
+ clearSplash(ctx);
88
+ });
89
+ pi.on("thinking_level_select", (_event, ctx) => {
90
+ clearSplash(ctx);
91
+ });
92
+ pi.on("session_before_fork", (_event, ctx) => {
93
+ clearSplash(ctx);
94
+ });
95
+ pi.on("session_before_tree", (_event, ctx) => {
96
+ clearSplash(ctx);
97
+ });
98
+ pi.on("session_before_compact", (_event, ctx) => {
99
+ clearSplash(ctx);
100
+ });
101
+
102
+ pi.registerCommand("clear", {
103
+ description: "Start a new session (alias: /new)",
104
+ handler: async (_args, ctx) => {
105
+ await ctx.waitForIdle();
106
+ await ctx.newSession();
107
+ },
108
+ });
109
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { concat, StreamCapture } from "./capture";
3
+ import { STREAM_HEAD_BYTES, STREAM_TAIL_BYTES } from "./schema";
4
+
5
+ const enc = new TextEncoder();
6
+ const u8 = (s: string) => enc.encode(s);
7
+
8
+ describe("concat", () => {
9
+ test("merges multiple chunks in order", () => {
10
+ const out = concat([u8("foo"), u8("bar")], 6);
11
+ expect(new TextDecoder().decode(out)).toBe("foobar");
12
+ });
13
+
14
+ test("returns empty array when total is 0", () => {
15
+ expect(concat([], 0).byteLength).toBe(0);
16
+ });
17
+ });
18
+
19
+ describe("StreamCapture", () => {
20
+ test("empty capture", () => {
21
+ const c = new StreamCapture();
22
+ expect(c.snapshot()).toEqual({
23
+ text: "",
24
+ totalBytes: 0,
25
+ truncated: false,
26
+ path: null,
27
+ nextStart: null,
28
+ });
29
+ });
30
+
31
+ test("ignores zero-byte chunks", () => {
32
+ const c = new StreamCapture();
33
+ c.push(new Uint8Array(0));
34
+ c.push(u8("hi"));
35
+ expect(c.snapshot()).toEqual({
36
+ text: "hi",
37
+ totalBytes: 2,
38
+ truncated: false,
39
+ path: null,
40
+ nextStart: null,
41
+ });
42
+ });
43
+
44
+ test("does not truncate when within head+tail budget", () => {
45
+ const c = new StreamCapture();
46
+ c.push(u8("hello"));
47
+ c.push(u8(" world"));
48
+ expect(c.snapshot()).toEqual({
49
+ text: "hello world",
50
+ totalBytes: 11,
51
+ truncated: false,
52
+ path: null,
53
+ nextStart: null,
54
+ });
55
+ });
56
+
57
+ test("truncates middle when over budget", () => {
58
+ const c = new StreamCapture();
59
+ const headChunk = "A".repeat(STREAM_HEAD_BYTES);
60
+ const middleChunk = "X".repeat(1000);
61
+ const tailChunk = "B".repeat(STREAM_TAIL_BYTES);
62
+ c.push(u8(headChunk));
63
+ c.push(u8(middleChunk));
64
+ c.push(u8(tailChunk));
65
+
66
+ const snap = c.snapshot();
67
+ expect(snap.truncated).toBe(true);
68
+ expect(snap.totalBytes).toBe(STREAM_HEAD_BYTES + 1000 + STREAM_TAIL_BYTES);
69
+ expect(snap.text.startsWith(headChunk)).toBe(true);
70
+ expect(snap.text.endsWith(tailChunk)).toBe(true);
71
+ expect(snap.text).toContain(`... ${1000} bytes truncated ...`);
72
+ expect(snap.nextStart).toBe(1);
73
+ });
74
+
75
+ test("reports the resume line at the end of the head when truncated", () => {
76
+ const c = new StreamCapture();
77
+ const lineBytes = STREAM_HEAD_BYTES / 4;
78
+ const headChunk = `${"A".repeat(lineBytes - 1)}\n`.repeat(4);
79
+ c.push(u8(headChunk));
80
+ c.push(u8("X".repeat(1000)));
81
+ c.push(u8("B".repeat(STREAM_TAIL_BYTES)));
82
+
83
+ const snap = c.snapshot();
84
+ expect(snap.truncated).toBe(true);
85
+ // Head holds exactly 4 newline-terminated lines, so reading resumes at 5.
86
+ expect(snap.nextStart).toBe(5);
87
+ });
88
+
89
+ test("splits a single chunk between head and tail when needed", () => {
90
+ const c = new StreamCapture();
91
+ const big = "Z".repeat(STREAM_HEAD_BYTES + STREAM_TAIL_BYTES + 500);
92
+ c.push(u8(big));
93
+
94
+ const snap = c.snapshot();
95
+ expect(snap.truncated).toBe(true);
96
+ expect(snap.totalBytes).toBe(big.length);
97
+ expect(snap.text.startsWith("Z".repeat(STREAM_HEAD_BYTES))).toBe(true);
98
+ expect(snap.text.endsWith("Z".repeat(STREAM_TAIL_BYTES))).toBe(true);
99
+ expect(snap.text).toContain(`... ${500} bytes truncated ...`);
100
+ });
101
+
102
+ test("keeps head and final tail when many middle chunks arrive", () => {
103
+ const c = new StreamCapture();
104
+ const HEAD_FILL = "A".repeat(STREAM_HEAD_BYTES);
105
+ c.push(u8(HEAD_FILL));
106
+ for (let i = 0; i < 100; i++) {
107
+ c.push(u8("M".repeat(STREAM_TAIL_BYTES)));
108
+ }
109
+ const finalTail = "B".repeat(STREAM_TAIL_BYTES);
110
+ c.push(u8(finalTail));
111
+
112
+ const snap = c.snapshot();
113
+ expect(snap.truncated).toBe(true);
114
+ expect(snap.text.startsWith(HEAD_FILL)).toBe(true);
115
+ expect(snap.text.endsWith(finalTail)).toBe(true);
116
+ });
117
+
118
+ test("at exact head+tail boundary is not truncated", () => {
119
+ const c = new StreamCapture();
120
+ c.push(u8("A".repeat(STREAM_HEAD_BYTES)));
121
+ c.push(u8("B".repeat(STREAM_TAIL_BYTES)));
122
+ const snap = c.snapshot();
123
+ expect(snap.truncated).toBe(false);
124
+ expect(snap.totalBytes).toBe(STREAM_HEAD_BYTES + STREAM_TAIL_BYTES);
125
+ });
126
+ });