@anton-kochev/pi-extensions 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,7 @@ pi install npm:@anton-kochev/pi-extensions@<version>
19
19
  ## Extensions
20
20
 
21
21
  - [`squiggle/`](./squiggle) — quietly polish grammar and spelling in user prompts.
22
+ - [`echo/`](./echo) — read-only side-channel question asker for pi sessions and project code.
22
23
 
23
24
  ## Local development
24
25
 
@@ -26,6 +27,7 @@ From a checkout of this repo:
26
27
 
27
28
  ```bash
28
29
  pi install -l ./squiggle
30
+ pi install -l ./echo
29
31
  ```
30
32
 
31
33
  Each subdirectory has its own `package.json` so individual extensions remain installable in isolation.
package/echo/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # echo
2
+
3
+ A read-only side-channel question asker for pi sessions and project code.
4
+
5
+ Echo adds a `/ask` command that spawns an isolated pi side-process with only read-only tools enabled. Answers are shown to you and stored in extension history, but they are not injected into the main agent context — so you can probe the session and project without polluting the conversation that's doing real work.
6
+
7
+ ## Install
8
+
9
+ This extension ships as part of the [`pi-extensions`](https://github.com/anton-kochev/pi-extensions) repository. The simplest install path is via the root pi-package — see the [repo README](../README.md).
10
+
11
+ For local development from a checkout of `pi-extensions`:
12
+
13
+ ```bash
14
+ pi install ./echo
15
+ ```
16
+
17
+ Project-local install:
18
+
19
+ ```bash
20
+ pi install ./echo -l
21
+ ```
22
+
23
+ Temporary test run:
24
+
25
+ ```bash
26
+ pi -e ./echo
27
+ ```
28
+
29
+ ## Pithos `.pithos` config
30
+
31
+ ```yaml
32
+ pi:
33
+ extensions:
34
+ echo: "git:https://github.com/anton-kochev/pi-extensions.git#main"
35
+ ```
36
+
37
+ Pin to a tag for reproducibility:
38
+
39
+ ```yaml
40
+ pi:
41
+ extensions:
42
+ echo: "git:https://github.com/anton-kochev/pi-extensions.git#v0.1.0"
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ Inside pi:
48
+
49
+ ```text
50
+ /ask [--model <provider/model>] [--] <question>
51
+ /asked
52
+ /ask-clear
53
+ ```
54
+
55
+ - `/ask` — ask Echo a question.
56
+ - `/asked` — browse previous Echo answers interactively with ↑/↓ and open one with Enter.
57
+ - `/ask-clear` — clears any stale Echo widget left by older versions.
58
+
59
+ ## What Echo can access
60
+
61
+ Echo launches an isolated Pi side process with only read-only tools enabled:
62
+
63
+ - `read`
64
+ - `grep`
65
+ - `find`
66
+ - `ls`
67
+
68
+ It does **not** receive `bash`, `edit`, or `write`. Echo runs from the current project directory, so it can inspect source code and project files read-only.
69
+
70
+ ## Session context strategy
71
+
72
+ Echo uses progressive disclosure to keep token usage low:
73
+
74
+ 1. A small recent-session excerpt is included in the side-agent prompt.
75
+ 2. The full current-session transcript is written to a temporary markdown file.
76
+ 3. The side agent is instructed to inspect that transcript only when needed, preferably with targeted `grep`/`read` calls.
77
+ 4. The temporary transcript is deleted after the side process exits.
78
+
79
+ ## Examples
80
+
81
+ ```text
82
+ /ask what is this session about?
83
+ /ask what files define the extension loading behavior?
84
+ /ask --model anthropic/claude-haiku-4-5 summarize the recent decisions
85
+ /asked
86
+ ```
87
+
88
+ ## Notes
89
+
90
+ This package imports pi runtime packages as peer dependencies:
91
+
92
+ - `@earendil-works/pi-coding-agent`
93
+ - `@earendil-works/pi-tui`
94
+ - `typebox`
95
+
96
+ Do not bundle those dependencies; pi provides them at runtime.
97
+
98
+ `extensions/index.ts` is a thin jiti trampoline that disables jiti's module cache for `src/echo.ts`, so editing the implementation and running `/reload` always evaluates the newest code.
@@ -0,0 +1,20 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createJiti } from "jiti";
3
+
4
+ /**
5
+ * Thin jiti trampoline.
6
+ *
7
+ * Pi already loads extensions through jiti, but this wrapper disables jiti's
8
+ * module cache for the implementation module so editing src/echo.ts and
9
+ * running /reload always evaluates the newest code.
10
+ */
11
+ export default async function echoHotReload(pi: ExtensionAPI) {
12
+ const jiti = createJiti(import.meta.url, {
13
+ moduleCache: false,
14
+ fsCache: false,
15
+ });
16
+
17
+ const mod = await jiti.import<typeof import("../src/echo")>("../src/echo.ts");
18
+ const factory = mod.default;
19
+ return factory(pi);
20
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "echo",
3
+ "version": "0.1.0",
4
+ "description": "Echo: a read-only side-channel question asker for pi sessions and project code.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "echo",
9
+ "ask",
10
+ "read-only"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "dependencies": {
15
+ "jiti": "^2.4.2"
16
+ },
17
+ "peerDependencies": {
18
+ "@earendil-works/pi-coding-agent": "*",
19
+ "@earendil-works/pi-tui": "*",
20
+ "typebox": "*"
21
+ },
22
+ "pi": {
23
+ "extensions": [
24
+ "./extensions"
25
+ ]
26
+ },
27
+ "files": [
28
+ "extensions",
29
+ "src",
30
+ "README.md"
31
+ ]
32
+ }
@@ -0,0 +1,722 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { BorderedLoader, DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
7
+ import { Container, Markdown, matchesKey, SelectList, Spacer, Text, type SelectItem } from "@earendil-works/pi-tui";
8
+
9
+ type QaUsage = {
10
+ input: number;
11
+ output: number;
12
+ cacheRead: number;
13
+ cacheWrite: number;
14
+ cost: number;
15
+ turns: number;
16
+ };
17
+
18
+ type AskOptions = {
19
+ question: string;
20
+ model?: string;
21
+ };
22
+
23
+ type QaResult = {
24
+ question: string;
25
+ answer: string;
26
+ exitCode: number;
27
+ stderr: string;
28
+ model?: string;
29
+ usage: QaUsage;
30
+ tools?: string[];
31
+ sessionSnapshot?: {
32
+ entries: number;
33
+ truncated: boolean;
34
+ };
35
+ options?: Omit<AskOptions, "question">;
36
+ stopReason?: string;
37
+ errorMessage?: string;
38
+ };
39
+
40
+ const HISTORY_CUSTOM_TYPE = "echo.history";
41
+ const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"];
42
+ const INLINE_RECENT_MESSAGES = 8;
43
+ const MAX_INLINE_SESSION_CHARS = 12_000;
44
+ const MAX_INLINE_MESSAGE_CHARS = 2_500;
45
+ const MAX_SNAPSHOT_FILE_CHARS = 2_000_000;
46
+ const MAX_SNAPSHOT_MESSAGE_CHARS = 100_000;
47
+
48
+ const ASK_HELP = `Usage: /ask [options] [--] question
49
+
50
+ Ask an isolated side-channel pi process. Echo receives progressive read-only access to the current session and only has read-only tools: read, grep, find, ls. The answer is shown to you and saved in Echo history, but it is not injected into the main agent context.
51
+
52
+ Options:
53
+ --model <model> Use a specific model (default: current model)
54
+ --help, -h Show this help
55
+
56
+ Examples:
57
+ /ask what did we decide about the API shape?
58
+ /ask what files have we touched so far?
59
+ /ask --model anthropic/claude-haiku-4-5 summarize the open questions`;
60
+
61
+ function formatCount(count: number): string {
62
+ if (count < 1000) return String(count);
63
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
64
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
65
+ return `${(count / 1_000_000).toFixed(1)}M`;
66
+ }
67
+
68
+ function formatUsage(result: Pick<QaResult, "usage" | "model">): string {
69
+ const parts: string[] = [];
70
+ if (result.usage.turns) parts.push(`${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"}`);
71
+ if (result.usage.input) parts.push(`↑${formatCount(result.usage.input)}`);
72
+ if (result.usage.output) parts.push(`↓${formatCount(result.usage.output)}`);
73
+ if (result.usage.cacheRead) parts.push(`R${formatCount(result.usage.cacheRead)}`);
74
+ if (result.usage.cacheWrite) parts.push(`W${formatCount(result.usage.cacheWrite)}`);
75
+ if (result.usage.cost) parts.push(`$${result.usage.cost.toFixed(4)}`);
76
+ if (result.model) parts.push(result.model);
77
+ return parts.join(" ");
78
+ }
79
+
80
+ function tokenizeArgs(input: string): string[] {
81
+ const tokens: string[] = [];
82
+ let current = "";
83
+ let quote: "'" | '"' | undefined;
84
+ let escaping = false;
85
+
86
+ for (const char of input) {
87
+ if (escaping) {
88
+ current += char;
89
+ escaping = false;
90
+ continue;
91
+ }
92
+ if (char === "\\") {
93
+ escaping = true;
94
+ continue;
95
+ }
96
+ if (quote) {
97
+ if (char === quote) quote = undefined;
98
+ else current += char;
99
+ continue;
100
+ }
101
+ if (char === "'" || char === '"') {
102
+ quote = char;
103
+ continue;
104
+ }
105
+ if (/\s/.test(char)) {
106
+ if (current) {
107
+ tokens.push(current);
108
+ current = "";
109
+ }
110
+ continue;
111
+ }
112
+ current += char;
113
+ }
114
+
115
+ if (escaping) current += "\\";
116
+ if (current) tokens.push(current);
117
+ return tokens;
118
+ }
119
+
120
+ type ParseResult =
121
+ | { type: "ok"; options: AskOptions }
122
+ | { type: "help" }
123
+ | { type: "error"; message: string };
124
+
125
+ function parseAskArgs(rawArgs: string): ParseResult {
126
+ const tokens = tokenizeArgs(rawArgs.trim());
127
+ let model: string | undefined;
128
+ let questionTokens: string[] = [];
129
+
130
+ for (let i = 0; i < tokens.length; i++) {
131
+ const token = tokens[i];
132
+ if (token === "--") {
133
+ questionTokens = tokens.slice(i + 1);
134
+ break;
135
+ }
136
+ if (token === "--help" || token === "-h") return { type: "help" };
137
+ if (token === "--model") {
138
+ const value = tokens[++i];
139
+ if (!value) return { type: "error", message: `${token} requires a value` };
140
+ model = value;
141
+ continue;
142
+ }
143
+ if (token.startsWith("--model=")) {
144
+ model = token.slice("--model=".length);
145
+ if (!model) return { type: "error", message: "--model requires a value" };
146
+ continue;
147
+ }
148
+ if (token.startsWith("--")) return { type: "error", message: `Unknown /ask option: ${token}` };
149
+
150
+ questionTokens = tokens.slice(i);
151
+ break;
152
+ }
153
+
154
+ return {
155
+ type: "ok",
156
+ options: {
157
+ question: questionTokens.join(" ").trim(),
158
+ model,
159
+ },
160
+ };
161
+ }
162
+
163
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
164
+ const currentScript = process.argv[1];
165
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
166
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
167
+ return { command: process.execPath, args: [currentScript, ...args] };
168
+ }
169
+
170
+ const execName = path.basename(process.execPath).toLowerCase();
171
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
172
+ if (!isGenericRuntime) return { command: process.execPath, args };
173
+ return { command: "pi", args };
174
+ }
175
+
176
+ function truncateText(text: string, maxChars: number): { text: string; truncated: boolean } {
177
+ if (text.length <= maxChars) return { text, truncated: false };
178
+ const head = Math.floor(maxChars * 0.35);
179
+ const tail = Math.max(0, maxChars - head - 80);
180
+ return {
181
+ text: `${text.slice(0, head)}\n\n[... ${text.length - head - tail} characters omitted ...]\n\n${text.slice(-tail)}`,
182
+ truncated: true,
183
+ };
184
+ }
185
+
186
+ function contentToText(content: any): string {
187
+ if (typeof content === "string") return content;
188
+ if (!Array.isArray(content)) return JSON.stringify(content ?? "");
189
+ return content
190
+ .map((part) => {
191
+ if (part?.type === "text") return part.text ?? "";
192
+ if (part?.type === "thinking") return "[thinking omitted]";
193
+ if (part?.type === "image") return `[image: ${part.mimeType ?? part.mediaType ?? "unknown"}]`;
194
+ if (part?.type === "toolCall") return `[tool call: ${part.name} ${JSON.stringify(part.arguments ?? {})}]`;
195
+ return JSON.stringify(part);
196
+ })
197
+ .filter(Boolean)
198
+ .join("\n");
199
+ }
200
+
201
+ function messageToTranscript(message: any): string {
202
+ switch (message?.role) {
203
+ case "user":
204
+ return `User:\n${contentToText(message.content)}`;
205
+ case "assistant":
206
+ return `Assistant${message.model ? ` (${message.model})` : ""}:\n${contentToText(message.content)}`;
207
+ case "toolResult":
208
+ return `Tool result (${message.toolName ?? "tool"}${message.isError ? ", error" : ""}):\n${contentToText(message.content)}`;
209
+ case "bashExecution":
210
+ return `User bash (${message.exitCode ?? "unknown"}): ${message.command}\n${message.output ?? ""}`;
211
+ case "custom":
212
+ return message.display ? `Custom (${message.customType ?? "extension"}):\n${contentToText(message.content)}` : "";
213
+ case "branchSummary":
214
+ return `Branch summary:\n${message.summary ?? ""}`;
215
+ case "compactionSummary":
216
+ return `Compaction summary:\n${message.summary ?? ""}`;
217
+ default:
218
+ return JSON.stringify(message ?? {});
219
+ }
220
+ }
221
+
222
+ function getSessionMessages(ctx: ExtensionCommandContext): any[] {
223
+ const manager = ctx.sessionManager as any;
224
+ try {
225
+ const built = manager.buildSessionContext?.();
226
+ if (built?.messages && Array.isArray(built.messages)) return built.messages;
227
+ } catch {
228
+ // Fall back to branch serialization below.
229
+ }
230
+ return manager
231
+ .getBranch()
232
+ .filter((entry: any) => entry.type === "message")
233
+ .map((entry: any) => entry.message);
234
+ }
235
+
236
+ type SessionSnapshot = {
237
+ fileText: string;
238
+ inlineText: string;
239
+ entries: number;
240
+ recentCount: number;
241
+ fileTruncated: boolean;
242
+ inlineTruncated: boolean;
243
+ };
244
+
245
+ function buildSessionSnapshot(ctx: ExtensionCommandContext): SessionSnapshot {
246
+ const messages = getSessionMessages(ctx);
247
+ let fileTruncated = false;
248
+ let inlineTruncated = false;
249
+
250
+ const renderSection = (message: any, index: number, maxChars: number) => {
251
+ const rendered = messageToTranscript(message).trim();
252
+ if (!rendered) return "";
253
+ const clipped = truncateText(rendered, maxChars);
254
+ return {
255
+ text: `### ${index + 1}. ${message?.role ?? "entry"}\n${clipped.text}`,
256
+ truncated: clipped.truncated,
257
+ };
258
+ };
259
+
260
+ const fullParts = messages
261
+ .map((message, index) => {
262
+ const section = renderSection(message, index, MAX_SNAPSHOT_MESSAGE_CHARS);
263
+ if (!section) return "";
264
+ if (section.truncated) fileTruncated = true;
265
+ return section.text;
266
+ })
267
+ .filter(Boolean);
268
+
269
+ let fileText = fullParts.join("\n\n---\n\n") || "(current session is empty)";
270
+ const clippedFile = truncateText(fileText, MAX_SNAPSHOT_FILE_CHARS);
271
+ if (clippedFile.truncated) fileTruncated = true;
272
+ fileText = clippedFile.text;
273
+
274
+ const recentStart = Math.max(0, messages.length - INLINE_RECENT_MESSAGES);
275
+ const recentMessages = messages.slice(recentStart);
276
+ const inlineParts = recentMessages
277
+ .map((message, offset) => {
278
+ const section = renderSection(message, recentStart + offset, MAX_INLINE_MESSAGE_CHARS);
279
+ if (!section) return "";
280
+ if (section.truncated) inlineTruncated = true;
281
+ return section.text;
282
+ })
283
+ .filter(Boolean);
284
+
285
+ let inlineText = inlineParts.join("\n\n---\n\n") || "(current session is empty)";
286
+ const clippedInline = truncateText(inlineText, MAX_INLINE_SESSION_CHARS);
287
+ if (clippedInline.truncated) inlineTruncated = true;
288
+ inlineText = clippedInline.text;
289
+
290
+ return {
291
+ fileText,
292
+ inlineText,
293
+ entries: messages.length,
294
+ recentCount: recentMessages.length,
295
+ fileTruncated,
296
+ inlineTruncated,
297
+ };
298
+ }
299
+
300
+ async function writeSessionSnapshotFile(snapshot: SessionSnapshot): Promise<{ dir: string; filePath: string }> {
301
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-ask-session-"));
302
+ const filePath = path.join(dir, "current-session.md");
303
+ const header = [
304
+ "# Current session snapshot for /ask",
305
+ "",
306
+ `- Messages: ${snapshot.entries}`,
307
+ `- Snapshot truncated: ${snapshot.fileTruncated ? "yes" : "no"}`,
308
+ `- Generated: ${new Date().toISOString()}`,
309
+ "",
310
+ "This temporary transcript is read-only context for the isolated /ask side agent.",
311
+ "",
312
+ ].join("\n");
313
+ await fs.promises.writeFile(filePath, header + snapshot.fileText, { encoding: "utf8", mode: 0o600 });
314
+ return { dir, filePath };
315
+ }
316
+
317
+ function buildPrompt(options: AskOptions, snapshot: SessionSnapshot, snapshotFilePath: string): string {
318
+ return [
319
+ "Answer this user question in an isolated side-channel session.",
320
+ "You have read-only access only. You may inspect project files with read, grep, find, and ls, but you must not modify files or run shell commands.",
321
+ "Use progressive disclosure for the main session context:",
322
+ "1. Start with the recent-session excerpt included below.",
323
+ `2. If the question needs older or more precise conversation history, inspect the full temporary session transcript at: ${snapshotFilePath}`,
324
+ "3. Prefer grep/find/read targeted ranges over reading the whole transcript when possible.",
325
+ "Do not mention the temporary transcript path unless the user asks for implementation details.",
326
+ "This side-channel answer will be shown to the user only; it will not be injected into the main agent context.",
327
+ "If you cannot answer confidently, say what information is missing rather than guessing.",
328
+ "",
329
+ `Recent session excerpt (${snapshot.recentCount} of ${snapshot.entries} messages${snapshot.inlineTruncated ? ", truncated" : ""}):`,
330
+ "<recent_session>",
331
+ snapshot.inlineText,
332
+ "</recent_session>",
333
+ "",
334
+ `Question: ${options.question}`,
335
+ ].join("\n");
336
+ }
337
+
338
+ async function runSideQuestion(
339
+ options: AskOptions,
340
+ ctx: ExtensionCommandContext,
341
+ signal?: AbortSignal,
342
+ ): Promise<QaResult> {
343
+ const args = [
344
+ "--mode",
345
+ "json",
346
+ "-p",
347
+ "--no-session",
348
+ "--no-extensions",
349
+ "--no-context-files",
350
+ "--no-skills",
351
+ "--no-prompt-templates",
352
+ "--tools",
353
+ READ_ONLY_TOOLS.join(","),
354
+ ];
355
+
356
+ const selectedModel = options.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
357
+ if (selectedModel) args.push("--model", selectedModel);
358
+
359
+ const sessionSnapshot = buildSessionSnapshot(ctx);
360
+ const snapshotFile = await writeSessionSnapshotFile(sessionSnapshot);
361
+ args.push(buildPrompt(options, sessionSnapshot, snapshotFile.filePath));
362
+
363
+ const result: QaResult = {
364
+ question: options.question,
365
+ answer: "",
366
+ exitCode: 0,
367
+ stderr: "",
368
+ model: selectedModel,
369
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
370
+ tools: [...READ_ONLY_TOOLS],
371
+ sessionSnapshot: { entries: sessionSnapshot.entries, truncated: sessionSnapshot.fileTruncated },
372
+ options: { model: options.model },
373
+ };
374
+
375
+ const invocation = getPiInvocation(args);
376
+ let wasAborted = false;
377
+
378
+ result.exitCode = await new Promise<number>((resolve) => {
379
+ const proc = spawn(invocation.command, invocation.args, {
380
+ cwd: ctx.cwd,
381
+ shell: false,
382
+ stdio: ["ignore", "pipe", "pipe"],
383
+ env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" },
384
+ });
385
+
386
+ let buffer = "";
387
+ let closed = false;
388
+ let killTimer: NodeJS.Timeout | undefined;
389
+ let abortHandler: (() => void) | undefined;
390
+
391
+ const cleanup = () => {
392
+ closed = true;
393
+ if (killTimer) clearTimeout(killTimer);
394
+ if (abortHandler) signal?.removeEventListener("abort", abortHandler);
395
+ };
396
+
397
+ const processLine = (line: string) => {
398
+ if (!line.trim()) return;
399
+ let event: any;
400
+ try {
401
+ event = JSON.parse(line);
402
+ } catch {
403
+ return;
404
+ }
405
+
406
+ if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
407
+ result.answer += event.assistantMessageEvent.delta ?? "";
408
+ }
409
+
410
+ if (event.type === "message_end" && event.message?.role === "assistant") {
411
+ result.usage.turns++;
412
+ result.model = event.message.model ?? result.model;
413
+ result.stopReason = event.message.stopReason ?? result.stopReason;
414
+ result.errorMessage = event.message.errorMessage ?? result.errorMessage;
415
+ const usage = event.message.usage;
416
+ if (usage) {
417
+ result.usage.input += usage.input || 0;
418
+ result.usage.output += usage.output || 0;
419
+ result.usage.cacheRead += usage.cacheRead || 0;
420
+ result.usage.cacheWrite += usage.cacheWrite || 0;
421
+ result.usage.cost += usage.cost?.total || 0;
422
+ }
423
+
424
+ if (!result.answer.trim() && Array.isArray(event.message.content)) {
425
+ result.answer = event.message.content
426
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
427
+ .map((part: any) => part.text)
428
+ .join("\n");
429
+ }
430
+ }
431
+
432
+ if (event.type === "error") {
433
+ result.errorMessage = event.error?.message ?? event.message ?? result.errorMessage;
434
+ }
435
+ };
436
+
437
+ proc.stdout.on("data", (data) => {
438
+ buffer += data.toString();
439
+ const lines = buffer.split("\n");
440
+ buffer = lines.pop() ?? "";
441
+ for (const line of lines) processLine(line);
442
+ });
443
+
444
+ proc.stderr.on("data", (data) => {
445
+ result.stderr += data.toString();
446
+ });
447
+
448
+ proc.on("close", (code) => {
449
+ cleanup();
450
+ if (buffer.trim()) processLine(buffer);
451
+ resolve(code ?? 0);
452
+ });
453
+
454
+ proc.on("error", (error) => {
455
+ cleanup();
456
+ result.stderr += String(error?.message ?? error);
457
+ resolve(1);
458
+ });
459
+
460
+ const kill = () => {
461
+ if (closed) return;
462
+ wasAborted = true;
463
+ proc.kill("SIGTERM");
464
+ killTimer = setTimeout(() => {
465
+ if (!closed) proc.kill("SIGKILL");
466
+ }, 3000);
467
+ killTimer.unref?.();
468
+ };
469
+
470
+ abortHandler = kill;
471
+ if (signal?.aborted) kill();
472
+ else signal?.addEventListener("abort", kill, { once: true });
473
+ });
474
+
475
+ try {
476
+ await fs.promises.rm(snapshotFile.dir, { recursive: true, force: true });
477
+ } catch {
478
+ // Best-effort cleanup of the temporary session transcript.
479
+ }
480
+
481
+ if (wasAborted) throw new Error("Echo was aborted");
482
+ result.answer = result.answer.trim();
483
+ if (result.exitCode !== 0 && !result.answer) {
484
+ throw new Error(
485
+ result.errorMessage || result.stderr.trim().split("\n").slice(-4).join("\n") || `Side agent exited with ${result.exitCode}`,
486
+ );
487
+ }
488
+ return result;
489
+ }
490
+
491
+ async function showMarkdown(title: string, markdown: string, ctx: ExtensionCommandContext) {
492
+ if (!ctx.hasUI) {
493
+ console.log(markdown);
494
+ return;
495
+ }
496
+
497
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
498
+ const container = new Container();
499
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
500
+ container.addChild(border);
501
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
502
+ container.addChild(new Spacer(1));
503
+ container.addChild(new Markdown(markdown, 1, 0, getMarkdownTheme()));
504
+ container.addChild(new Text(theme.fg("dim", "Press Enter or Esc to close"), 1, 0));
505
+ container.addChild(border);
506
+
507
+ return {
508
+ render: (width: number) => container.render(width),
509
+ invalidate: () => container.invalidate(),
510
+ handleInput: (data: string) => {
511
+ if (matchesKey(data, "enter") || matchesKey(data, "escape")) done(undefined);
512
+ return true;
513
+ },
514
+ };
515
+ });
516
+ }
517
+
518
+ async function showAnswer(result: QaResult, ctx: ExtensionCommandContext) {
519
+ if (!ctx.hasUI) {
520
+ console.log(result.answer);
521
+ return;
522
+ }
523
+
524
+ const usage = formatUsage(result);
525
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
526
+ const container = new Container();
527
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
528
+ container.addChild(border);
529
+ container.addChild(new Text(theme.fg("accent", theme.bold("Echo (isolated)")), 1, 0));
530
+ container.addChild(new Text(theme.fg("muted", `Q: ${result.question}`), 1, 0));
531
+ container.addChild(new Text(theme.fg("dim", `Tools: ${result.tools?.join(",") || "none"}`), 1, 0));
532
+ container.addChild(new Spacer(1));
533
+ container.addChild(new Markdown(result.answer || "(no answer)", 1, 0, getMarkdownTheme()));
534
+ if (result.errorMessage) {
535
+ container.addChild(new Spacer(1));
536
+ container.addChild(new Text(theme.fg("error", result.errorMessage), 1, 0));
537
+ }
538
+ if (result.stderr.trim()) {
539
+ container.addChild(new Spacer(1));
540
+ container.addChild(new Text(theme.fg("warning", result.stderr.trim().split("\n").slice(-4).join("\n")), 1, 0));
541
+ }
542
+ if (usage) container.addChild(new Text(theme.fg("dim", usage), 1, 0));
543
+ container.addChild(new Text(theme.fg("dim", "Press Enter or Esc to close"), 1, 0));
544
+ container.addChild(border);
545
+
546
+ return {
547
+ render: (width: number) => container.render(width),
548
+ invalidate: () => container.invalidate(),
549
+ handleInput: (data: string) => {
550
+ if (matchesKey(data, "enter") || matchesKey(data, "escape")) done(undefined);
551
+ return true;
552
+ },
553
+ };
554
+ });
555
+ }
556
+
557
+ async function askWithOptionalLoader(options: AskOptions, ctx: ExtensionCommandContext): Promise<QaResult | null> {
558
+ if (!ctx.hasUI) return runSideQuestion(options, ctx, ctx.signal);
559
+
560
+ type LoaderResult = QaResult | { error: string } | null;
561
+ const modelLabel = options.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "default model");
562
+ const toolsLabel = READ_ONLY_TOOLS.join(",");
563
+
564
+ const loaderResult = await ctx.ui.custom<LoaderResult>((tui, theme, _kb, done) => {
565
+ const loader = new BorderedLoader(tui, theme, `Asking isolated agent (${modelLabel}; ${toolsLabel}; current session)...`, {
566
+ cancellable: true,
567
+ });
568
+ let finished = false;
569
+ const finish = (value: LoaderResult) => {
570
+ if (finished) return;
571
+ finished = true;
572
+ done(value);
573
+ };
574
+
575
+ loader.onAbort = () => finish(null);
576
+ runSideQuestion(options, ctx, loader.signal)
577
+ .then((result) => finish(result))
578
+ .catch((error) => {
579
+ if (loader.signal.aborted) finish(null);
580
+ else finish({ error: error instanceof Error ? error.message : String(error) });
581
+ });
582
+
583
+ return loader;
584
+ });
585
+
586
+ if (loaderResult === null) return null;
587
+ if ("error" in loaderResult) throw new Error(loaderResult.error);
588
+ return loaderResult;
589
+ }
590
+
591
+ export default function echo(pi: ExtensionAPI) {
592
+ pi.registerCommand("ask", {
593
+ description: "Ask Echo, an isolated read-only side agent; answer is not added to the main LLM context",
594
+ handler: async (args, ctx) => {
595
+ const parsed = parseAskArgs(args);
596
+ if (parsed.type === "help") {
597
+ await showMarkdown("/ask help", ASK_HELP, ctx);
598
+ return;
599
+ }
600
+ if (parsed.type === "error") {
601
+ if (ctx.hasUI) ctx.ui.notify(`${parsed.message}. Use /ask --help for usage.`, "error");
602
+ else console.error(parsed.message);
603
+ return;
604
+ }
605
+
606
+ const options = parsed.options;
607
+ if (!options.question) {
608
+ const question = ctx.hasUI
609
+ ? await ctx.ui.input("Ask isolated question:", "What do you want to know?")
610
+ : undefined;
611
+ if (!question?.trim()) return;
612
+ options.question = question.trim();
613
+ }
614
+
615
+ ctx.ui.setStatus("echo", "asking…");
616
+ try {
617
+ const result = await askWithOptionalLoader(options, ctx);
618
+ if (!result) {
619
+ if (ctx.hasUI) ctx.ui.notify("Echo cancelled", "info");
620
+ return;
621
+ }
622
+
623
+ pi.appendEntry(HISTORY_CUSTOM_TYPE, { ...result, timestamp: Date.now() });
624
+ if (ctx.hasUI) {
625
+ ctx.ui.setWidget("echo", undefined);
626
+ ctx.ui.setWidget("session-qa", undefined);
627
+ }
628
+ await showAnswer(result, ctx);
629
+ } catch (error) {
630
+ const message = error instanceof Error ? error.message : String(error);
631
+ if (ctx.hasUI) ctx.ui.notify(`Echo failed: ${message}`, "error");
632
+ else console.error(`Echo failed: ${message}`);
633
+ } finally {
634
+ ctx.ui.setStatus("echo", undefined);
635
+ }
636
+ },
637
+ });
638
+
639
+ pi.registerCommand("ask-clear", {
640
+ description: "Hide any stale Echo answer widget",
641
+ handler: async (_args, ctx) => {
642
+ if (ctx.hasUI) {
643
+ ctx.ui.setWidget("echo", undefined);
644
+ ctx.ui.setWidget("session-qa", undefined);
645
+ }
646
+ },
647
+ });
648
+
649
+ const showAskedHistory = async (_args: string, ctx: ExtensionCommandContext) => {
650
+ const items = ctx.sessionManager
651
+ .getEntries()
652
+ .filter((entry: any) => entry.type === "custom" && entry.customType === HISTORY_CUSTOM_TYPE)
653
+ .map((entry: any) => entry.data as QaResult & { timestamp?: number });
654
+
655
+ if (items.length === 0) {
656
+ if (ctx.hasUI) ctx.ui.notify("No Echo history yet", "info");
657
+ return;
658
+ }
659
+
660
+ if (!ctx.hasUI) {
661
+ const markdown = [...items]
662
+ .reverse()
663
+ .map((item, index) => {
664
+ const when = item.timestamp ? new Date(item.timestamp).toLocaleString() : "";
665
+ const usage = item.usage ? formatUsage(item) : "";
666
+ const metadata = [when, usage].filter(Boolean).join(" · ");
667
+ return `## ${items.length - index}. ${item.question}${metadata ? `\n_${metadata}_` : ""}\n\n${item.answer}`;
668
+ })
669
+ .join("\n\n---\n\n");
670
+ console.log(markdown);
671
+ return;
672
+ }
673
+
674
+ const newestFirst = [...items].reverse();
675
+ const selectItems: SelectItem[] = newestFirst.map((item, index) => {
676
+ const when = item.timestamp ? new Date(item.timestamp).toLocaleString() : "";
677
+ const usage = item.usage ? formatUsage(item) : "";
678
+ return {
679
+ value: String(index),
680
+ label: item.question,
681
+ description: [when, usage].filter(Boolean).join(" · "),
682
+ };
683
+ });
684
+
685
+ const selectedIndex = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
686
+ const container = new Container();
687
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
688
+ container.addChild(border);
689
+ container.addChild(new Text(theme.fg("accent", theme.bold("Echo History")), 1, 0));
690
+ const selectList = new SelectList(selectItems, Math.min(selectItems.length, 12), {
691
+ selectedPrefix: (s: string) => theme.fg("accent", s),
692
+ selectedText: (s: string) => theme.fg("accent", s),
693
+ description: (s: string) => theme.fg("muted", s),
694
+ scrollInfo: (s: string) => theme.fg("dim", s),
695
+ noMatch: (s: string) => theme.fg("warning", s),
696
+ });
697
+ selectList.onSelect = (item) => done(Number(item.value));
698
+ selectList.onCancel = () => done(null);
699
+ container.addChild(selectList);
700
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • Enter open • Esc cancel"), 1, 0));
701
+ container.addChild(border);
702
+ return {
703
+ render: (width: number) => container.render(width),
704
+ invalidate: () => container.invalidate(),
705
+ handleInput: (data: string) => {
706
+ selectList.handleInput(data);
707
+ tui.requestRender();
708
+ return true;
709
+ },
710
+ };
711
+ });
712
+
713
+ if (selectedIndex === null || selectedIndex === undefined) return;
714
+ await showAnswer(newestFirst[selectedIndex], ctx);
715
+ };
716
+
717
+ pi.registerCommand("asked", {
718
+ description: "Interactively browse previous /ask answers",
719
+ handler: showAskedHistory,
720
+ });
721
+
722
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anton-kochev/pi-extensions",
3
- "version": "0.1.3",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extensions.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -13,18 +13,25 @@
13
13
  },
14
14
  "files": [
15
15
  "squiggle",
16
+ "echo",
16
17
  "README.md"
17
18
  ],
18
19
  "publishConfig": {
19
20
  "access": "public"
20
21
  },
22
+ "dependencies": {
23
+ "jiti": "^2.4.2"
24
+ },
21
25
  "peerDependencies": {
22
26
  "@earendil-works/pi-ai": "*",
23
- "@earendil-works/pi-coding-agent": "*"
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*",
29
+ "typebox": "*"
24
30
  },
25
31
  "pi": {
26
32
  "extensions": [
27
- "./squiggle/extensions"
33
+ "./squiggle/extensions",
34
+ "./echo/extensions"
28
35
  ]
29
36
  }
30
37
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  Quietly polish grammar and spelling in your pi prompts.
4
4
 
5
- The extension intercepts user input, corrects spelling and grammar using a configured model, shows a colored diff, and submits the corrected prompt automatically without confirmation. Named after the red squiggle from your favorite spell-checker.
5
+ The extension intercepts user input, shows a `squiggling...` spinner while processing, corrects spelling and grammar using a configured model, shows a colored diff, and submits the corrected prompt automatically without confirmation. Named after the red squiggle from your favorite spell-checker.
6
6
 
7
7
  ## Install
8
8
 
@@ -68,14 +68,17 @@ SQUIGGLE_MODEL=openai-codex/gpt-5.4-mini pi
68
68
  SQUIGGLE_MAX_CHARS=1000 pi
69
69
  ```
70
70
 
71
- ## Status
71
+ ## Commands
72
72
 
73
73
  Inside pi:
74
74
 
75
75
  ```text
76
- /squiggle-status
76
+ /squiggle toggle # switch between on/off
77
+ /squiggle-status # show status
77
78
  ```
78
79
 
80
+ The toggle state is saved in the current pi session and overrides `.pi/squiggle.json` and environment configuration for that session.
81
+
79
82
  ## Notes
80
83
 
81
84
  This package imports pi runtime packages as peer dependencies:
@@ -4,23 +4,44 @@ import { complete, type UserMessage } from "@earendil-works/pi-ai";
4
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  export default function squiggle(pi: ExtensionAPI) {
7
+ let runtimeMode: SquiggleConfig["mode"] | undefined;
8
+
9
+ pi.on("session_start", async (_event, ctx) => {
10
+ runtimeMode = restoreRuntimeMode(ctx);
11
+ });
12
+
13
+ pi.registerCommand("squiggle", {
14
+ description: "Toggle squiggle on/off",
15
+ handler: async (args, ctx) => {
16
+ const command = args.trim().toLowerCase();
17
+ if (command !== "toggle") {
18
+ ctx.ui.notify("Usage: /squiggle toggle", "warning");
19
+ return;
20
+ }
21
+
22
+ const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
23
+ runtimeMode = config.mode === "on" ? "off" : "on";
24
+ persistRuntimeMode(pi, runtimeMode);
25
+ ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
26
+ },
27
+ });
28
+
7
29
  pi.registerCommand("squiggle-status", {
8
30
  description: "Show whether squiggle is loaded",
9
31
  handler: async (_args, ctx) => {
10
- const config = loadConfig(ctx.cwd);
11
- const model = selectCorrectionModel(ctx, config);
12
- ctx.ui.notify(`squiggle is loaded (${config.mode}, ${formatModel(model)}).`, "info");
32
+ ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
13
33
  },
14
34
  });
15
35
 
16
36
  pi.on("input", async (event, ctx) => {
17
37
  if (event.source === "extension") return { action: "continue" };
18
38
 
19
- const config = loadConfig(ctx.cwd);
39
+ const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
20
40
  if (config.mode === "off") return { action: "continue" };
21
41
  if (!event.text.trim()) return { action: "continue" };
22
42
 
23
- const corrected = await correctWithModel(event.text, ctx, config);
43
+ const stopIndicator = startSquiggleIndicator(ctx);
44
+ const corrected = await correctWithModel(event.text, ctx, config).finally(stopIndicator);
24
45
  if (!corrected || corrected === event.text) return { action: "continue" };
25
46
 
26
47
  if (ctx.hasUI) ctx.ui.notify(formatColoredDiff(event.text, corrected), "info");
@@ -100,6 +121,28 @@ function loadConfig(cwd: string): SquiggleConfig {
100
121
  };
101
122
  }
102
123
 
124
+ function loadEffectiveConfig(cwd: string, runtimeMode: SquiggleConfig["mode"] | undefined): SquiggleConfig {
125
+ const config = loadConfig(cwd);
126
+ return { ...config, mode: runtimeMode ?? config.mode };
127
+ }
128
+
129
+ function restoreRuntimeMode(ctx: ExtensionContext): SquiggleConfig["mode"] | undefined {
130
+ for (const entry of [...ctx.sessionManager.getEntries()].reverse()) {
131
+ if (entry.type !== "custom" || entry.customType !== "squiggle-mode") continue;
132
+ const data = (entry as { data?: { mode?: unknown } }).data;
133
+ return normalizeMode(data?.mode);
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ function persistRuntimeMode(pi: ExtensionAPI, mode: SquiggleConfig["mode"]): void {
139
+ pi.appendEntry("squiggle-mode", { mode });
140
+ }
141
+
142
+ function formatStatus(ctx: ExtensionContext, config: SquiggleConfig): string {
143
+ return `squiggle is ${config.mode} (${formatModel(selectCorrectionModel(ctx, config))}).`;
144
+ }
145
+
103
146
  function readConfigFile(cwd: string): Partial<SquiggleConfig> {
104
147
  const path = join(cwd, ".pi", "squiggle.json");
105
148
  if (!existsSync(path)) return {};
@@ -143,27 +186,46 @@ function formatModel(model: ReturnType<typeof selectCorrectionModel>): string {
143
186
  return model ? `${model.provider}/${model.id}` : "no model";
144
187
  }
145
188
 
189
+ function startSquiggleIndicator(ctx: ExtensionContext): () => void {
190
+ if (!ctx.hasUI) return () => {};
191
+
192
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
193
+ let frame = 0;
194
+ let timer: ReturnType<typeof setInterval> | undefined;
195
+
196
+ const render = () => {
197
+ const theme = ctx.ui.theme;
198
+ ctx.ui.setStatus("squiggle", theme.fg("accent", frames[frame]!) + theme.fg("dim", " squiggling..."));
199
+ frame = (frame + 1) % frames.length;
200
+ };
201
+
202
+ render();
203
+ timer = setInterval(render, 120);
204
+
205
+ return () => {
206
+ if (timer) clearInterval(timer);
207
+ ctx.ui.setStatus("squiggle", undefined);
208
+ };
209
+ }
210
+
146
211
  type DiffOp = {
147
212
  type: "same" | "add" | "remove";
148
213
  text: string;
149
214
  };
150
215
 
151
216
  function formatColoredDiff(before: string, after: string): string {
152
- const dim = "\x1b[90;3m";
153
217
  const same = "\x1b[90;3m";
154
218
  const added = "\x1b[32;3m";
155
219
  const removed = "\x1b[31;3m";
156
220
  const reset = "\x1b[0m";
157
221
 
158
- const rendered = diffChars(before.trim(), after.trim())
222
+ return diffChars(before.trim(), after.trim())
159
223
  .map((op) => {
160
224
  if (op.type === "add") return `${added}${op.text}${reset}`;
161
225
  if (op.type === "remove") return `${removed}${op.text}${reset}`;
162
226
  return `${same}${op.text}${reset}`;
163
227
  })
164
228
  .join("");
165
-
166
- return `${dim}squiggle:${reset}\n${rendered}`;
167
229
  }
168
230
 
169
231
  function diffChars(before: string, after: string): DiffOp[] {