pi-warp 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Yi-An Lai
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,90 @@
1
+ # pi-warp
2
+
3
+ Real-time [pi](https://github.com/earendil-works/pi-coding-agent) notifications in the [Warp](https://www.warp.dev/) terminal.
4
+
5
+ pi-warp surfaces pi agent activity inline in Warp — so you always know what the agent is doing without switching context.
6
+
7
+ ![](static/demo.gif)
8
+
9
+ ## Features
10
+
11
+ - **Session tracking** — Warp knows when pi starts and stops a session.
12
+ - **Prompt notifications** — see when your prompt has been submitted and the agent begins working.
13
+ - **Tool result alerts** — get notified each time a tool finishes executing.
14
+ - **Completion signal** — Warp tells you when the agent has finished its work.
15
+ - **Animated terminal title** — an optional braille spinner in your terminal title while the agent is busy.
16
+
17
+ ## Requirements
18
+
19
+ - **[Warp](https://www.warp.dev/)** — build newer than `v0.2026.03.25.08.24.stable_05` (stable) or `v0.2026.03.25.08.24.preview_05` (preview). Dev channel builds are always supported.
20
+ - **[pi](https://github.com/earendil-works/pi-coding-agent)** coding agent.
21
+ - **Node.js** ≥ 20.
22
+
23
+ > pi-warp detects Warp automatically. If you're running an incompatible build or not inside Warp, the extension silently disables itself — nothing breaks.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pi install npm:pi-warp
29
+ ```
30
+
31
+ Or manually: clone this repository into your pi extensions directory (`~/.pi/agent/extensions/`).
32
+
33
+ ## Usage
34
+
35
+ No configuration needed — notifications start automatically when you launch pi inside Warp.
36
+
37
+ You'll see inline Warp notifications as the agent:
38
+
39
+ 1. **Starts a session** — confirms the extension is active.
40
+ 2. **Receives your prompt** — shows the agent is working.
41
+ 3. **Completes a tool call** — one notification per tool execution.
42
+ 4. **Finishes its work** — lets you know the agent is done.
43
+
44
+ ### Settings
45
+
46
+ Run the following command inside pi to open the settings panel:
47
+
48
+ ```
49
+ /pi-warp-settings
50
+ ```
51
+
52
+ | Setting | Default | Description |
53
+ |---|---|---|
54
+ | **Dynamic Terminal Titles** | on | Animate the terminal title with a braille spinner while the agent is working |
55
+
56
+ <details>
57
+ <summary>Editing settings directly</summary>
58
+
59
+ Settings are stored in pi's global config at `~/.pi/agent/settings.json` under the `piWarp` key:
60
+
61
+ ```json
62
+ {
63
+ "piWarp": {
64
+ "dynamicTitles": false
65
+ }
66
+ }
67
+ ```
68
+
69
+ </details>
70
+
71
+ ## Troubleshooting
72
+
73
+ **I don't see any notifications**
74
+
75
+ - Make sure you're running pi **inside Warp** (not another terminal emulator).
76
+ - Check your Warp version meets the minimum listed in [Requirements](#requirements).
77
+ - pi-warp prints a message on session start if Warp was not detected — look for it in your pi log.
78
+
79
+ **Notifications stopped after a Warp update**
80
+
81
+ - Warp may have changed its environment variables. Open a new terminal window and try again.
82
+ - If the issue persists, [file an issue](../../issues).
83
+
84
+ **The spinner in the terminal title is distracting**
85
+
86
+ - Run `/pi-warp-settings` in pi and set **Dynamic Terminal Titles** to `off`.
87
+
88
+ ## License
89
+
90
+ MIT
package/index.ts ADDED
@@ -0,0 +1,157 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
+ import { Container, type SettingItem, SettingsList } from "@earendil-works/pi-tui";
4
+ import { sendNotification } from "./src/osc.js";
5
+ import { shouldUseStructured } from "./src/version.js";
6
+ import { startSpinner, stopSpinner } from "./src/title.js";
7
+ import {
8
+ buildSessionStartPayload,
9
+ buildStopPayload,
10
+ buildPromptSubmitPayload,
11
+ buildToolCompletePayload,
12
+ } from "./src/events.js";
13
+ import { loadSettings, saveSetting, type WarpNotifySettings } from "./src/settings.js";
14
+ import { readFileSync } from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, join } from "node:path";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Plugin version from package.json
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const pkg = JSON.parse(
24
+ readFileSync(join(__dirname, "package.json"), "utf-8")
25
+ );
26
+ const PLUGIN_VERSION: string = pkg.version;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Extension entry point
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export default function (pi: ExtensionAPI): void {
33
+ // -----------------------------------------------------------------------
34
+ // /warp-settings command — toggle extension settings
35
+ // -----------------------------------------------------------------------
36
+ pi.registerCommand("pi-warp-settings", {
37
+ description: "Configure pi-warp extension settings",
38
+ handler: async (_args, ctx) => {
39
+ const current = loadSettings();
40
+
41
+ const items: SettingItem[] = [
42
+ {
43
+ id: "dynamicTitles",
44
+ label: "Dynamic Terminal Titles",
45
+ currentValue: current.dynamicTitles ? "on" : "off",
46
+ values: ["on", "off"],
47
+ },
48
+ ];
49
+
50
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
51
+ const container = new Container();
52
+ container.addChild({
53
+ render() {
54
+ return [theme.fg("accent", theme.bold("pi-warp Settings")), ""];
55
+ },
56
+ invalidate() {},
57
+ });
58
+
59
+ const settingsList = new SettingsList(
60
+ items,
61
+ Math.min(items.length + 2, 15),
62
+ getSettingsListTheme(),
63
+ (id: string, newValue: string) => {
64
+ const key = id as keyof WarpNotifySettings;
65
+ if (key === "dynamicTitles") {
66
+ const enabled = newValue === "on";
67
+ saveSetting(key, enabled);
68
+ ctx.ui.notify(
69
+ `Dynamic titles ${enabled ? "enabled" : "disabled"}`,
70
+ "info",
71
+ );
72
+ }
73
+ },
74
+ () => done(undefined),
75
+ );
76
+
77
+ container.addChild(settingsList);
78
+
79
+ return {
80
+ render: (w: number) => container.render(w),
81
+ invalidate: () => container.invalidate(),
82
+ handleInput: (data: string) => {
83
+ settingsList.handleInput?.(data);
84
+ },
85
+ };
86
+ });
87
+ },
88
+ });
89
+
90
+ // -----------------------------------------------------------------------
91
+ // Hook 1: Notify Warp when user submits a prompt and agent starts working
92
+ // -----------------------------------------------------------------------
93
+ pi.on("before_agent_start", async (event: { prompt?: string }, ctx: Parameters<typeof buildPromptSubmitPayload>[0]) => {
94
+ if (!shouldUseStructured()) return;
95
+
96
+ const payload = buildPromptSubmitPayload(ctx, event.prompt);
97
+ sendNotification(payload);
98
+
99
+ // Start animated terminal title spinner (respects setting)
100
+ if (loadSettings().dynamicTitles) {
101
+ startSpinner(ctx);
102
+ }
103
+ });
104
+
105
+ // -----------------------------------------------------------------------
106
+ // Hook 2: Notify Warp when the agent completes its work
107
+ // -----------------------------------------------------------------------
108
+ pi.on("agent_end", async (event: { messages: Parameters<typeof buildStopPayload>[1] }, ctx: Parameters<typeof buildStopPayload>[0]) => {
109
+ if (!shouldUseStructured()) return;
110
+
111
+ const payload = buildStopPayload(ctx, event.messages);
112
+ sendNotification(payload);
113
+
114
+ // Stop spinner and set static "ready" title
115
+ stopSpinner(ctx);
116
+ });
117
+
118
+ // -----------------------------------------------------------------------
119
+ // Hook 3: Session start — emit structured payload with plugin version,
120
+ // then after a short delay emit a "stop" event so Warp shows a ready
121
+ // (idle) state instead of staying in-progress.
122
+ // -----------------------------------------------------------------------
123
+ pi.on("session_start", async (_event: unknown, ctx: Parameters<typeof buildSessionStartPayload>[0] & { ui: { notify(message: string, level: string): void } }) => {
124
+ if (!shouldUseStructured()) {
125
+ console.log(
126
+ "[pi-warp] Warp not detected or structured notifications not supported."
127
+ );
128
+ return;
129
+ }
130
+
131
+ const payload = buildSessionStartPayload(ctx, PLUGIN_VERSION);
132
+ sendNotification(payload);
133
+ ctx.ui.notify("Warp notifications active ✓", "info");
134
+
135
+ // After a brief delay, tell Warp the agent is idle so it shows "ready"
136
+ setTimeout(() => {
137
+ const stopPayload = buildStopPayload(ctx, []);
138
+ sendNotification(stopPayload);
139
+ stopSpinner(ctx);
140
+ }, 500);
141
+ });
142
+
143
+ // -----------------------------------------------------------------------
144
+ // Hook 4: Tool execution end — emit tool_complete notification
145
+ // -----------------------------------------------------------------------
146
+ pi.on("tool_execution_end", async (event: { toolName: string }, ctx: Parameters<typeof buildToolCompletePayload>[0]) => {
147
+ if (!shouldUseStructured()) return;
148
+
149
+ const payload = buildToolCompletePayload(ctx, event.toolName);
150
+ sendNotification(payload);
151
+ });
152
+
153
+ // -----------------------------------------------------------------------
154
+ // Hook 5: DISABLED — permission_request is not wired to tool_call.
155
+ // See README for details on deferred permission_request support.
156
+ // -----------------------------------------------------------------------
157
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "pi-warp",
3
+ "version": "1.0.0",
4
+ "description": "Pi <> Warp terminal. Get notified when your agent is done working.",
5
+ "license": "MIT",
6
+ "author": "yianL",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/TeahouseHQ/pi-warp.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/TeahouseHQ/pi-warp/issues"
13
+ },
14
+ "homepage": "https://github.com/TeahouseHQ/pi-warp#readme",
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi",
18
+ "warp",
19
+ "terminal",
20
+ "notifications"
21
+ ],
22
+ "type": "module",
23
+ "exports": "./index.ts",
24
+ "files": [
25
+ "index.ts",
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "lint": "eslint .",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "vitest run",
34
+ "prepublishOnly": "npm run lint && npm run typecheck && npm test"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@eslint/js": "^10.0.1",
42
+ "@types/node": "^25.8.0",
43
+ "eslint": "^10.3.0",
44
+ "typescript": "^6.0.3",
45
+ "typescript-eslint": "^8.59.3",
46
+ "vitest": "^4.1.6"
47
+ },
48
+ "pi": {
49
+ "extensions": ["./index.ts"],
50
+ "image": "https://raw.githubusercontent.com/TeahouseHQ/pi-warp/refs/heads/main/static/demo.gif",
51
+ "video": "https://raw.githubusercontent.com/TeahouseHQ/pi-warp/refs/heads/main/static/piwarp.mp4"
52
+ }
53
+ }
package/src/events.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Notification event builders for Warp.
3
+ *
4
+ * Each function produces a complete payload ready for sendNotification().
5
+ */
6
+
7
+ import { buildBasePayload } from "./payload.js";
8
+
9
+ const MAX_FIELD_LENGTH = 200;
10
+ const MAX_PREVIEW_LENGTH = 120;
11
+
12
+ /**
13
+ * Truncate a string to maxLen characters, appending "..." when truncated.
14
+ */
15
+ export function truncate(str: string, maxLen: number = MAX_FIELD_LENGTH): string {
16
+ if (!str || str.length <= maxLen) return str || "";
17
+ return str.slice(0, maxLen - 3) + "...";
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Prompt Submit (before_agent_start)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Build a `prompt_submit` payload with the user's prompt text.
26
+ */
27
+ export function buildPromptSubmitPayload(
28
+ ctx: { cwd: string; sessionManager: { getSessionFile(): string | undefined } },
29
+ prompt: string | undefined
30
+ ): Record<string, unknown> {
31
+ return {
32
+ ...buildBasePayload("prompt_submit", ctx),
33
+ query: truncate(prompt ?? ""),
34
+ };
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Tool Complete (tool_execution_end)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Build a `tool_complete` payload with the tool name.
43
+ */
44
+ export function buildToolCompletePayload(
45
+ ctx: { cwd: string; sessionManager: { getSessionFile(): string | undefined } },
46
+ toolName: string
47
+ ): Record<string, unknown> {
48
+ return {
49
+ ...buildBasePayload("tool_complete", ctx),
50
+ tool_name: toolName,
51
+ };
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Session Start
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Build a `session_start` payload with the plugin version.
60
+ */
61
+ export function buildSessionStartPayload(
62
+ ctx: { cwd: string; sessionManager: { getSessionFile(): string | undefined } },
63
+ pluginVersion: string
64
+ ): Record<string, unknown> {
65
+ return {
66
+ ...buildBasePayload("session_start", ctx),
67
+ plugin_version: pluginVersion,
68
+ };
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Agent End / Stop
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Build a `stop` payload with the last user query and last assistant response.
77
+ */
78
+ export function buildStopPayload(
79
+ ctx: { cwd: string; sessionManager: { getSessionFile(): string | undefined } },
80
+ messages: Array<{ role: string; content: string | Array<{ type: string; text: string }> }>
81
+ ): Record<string, unknown> {
82
+ // Extract last user query
83
+ let query = "";
84
+ for (const msg of messages) {
85
+ if (msg.role === "user") {
86
+ if (typeof msg.content === "string") {
87
+ query = msg.content;
88
+ } else if (Array.isArray(msg.content)) {
89
+ query = msg.content
90
+ .filter((b) => b.type === "text")
91
+ .map((b) => b.text)
92
+ .join("");
93
+ }
94
+ }
95
+ }
96
+ query = truncate(query);
97
+
98
+ // Extract last assistant response (iterate backwards)
99
+ let response = "";
100
+ for (let i = messages.length - 1; i >= 0; i--) {
101
+ const msg = messages[i];
102
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
103
+ const textParts = msg.content
104
+ .filter((b) => b.type === "text")
105
+ .map((b) => b.text);
106
+ if (textParts.length > 0) {
107
+ response = textParts.join("");
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ response = truncate(response);
113
+
114
+ return {
115
+ ...buildBasePayload("stop", ctx),
116
+ query,
117
+ response,
118
+ };
119
+ }
120
+
121
+
122
+ /**
123
+ * Build a `permission_request` payload with a human-readable summary.
124
+ */
125
+ export function buildPermissionRequestPayload(
126
+ ctx: { cwd: string; sessionManager: { getSessionFile(): string | undefined } },
127
+ toolName: string,
128
+ input: Record<string, unknown>
129
+ ): Record<string, unknown> {
130
+ const preview = buildPreview(toolName, input);
131
+
132
+ return {
133
+ ...buildBasePayload("permission_request", ctx),
134
+ summary: `Wants to run ${toolName}: ${preview}`,
135
+ tool_name: toolName,
136
+ tool_input: input,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Build a short preview of the tool input for the summary string.
142
+ */
143
+ function buildPreview(
144
+ toolName: string,
145
+ input: Record<string, unknown>
146
+ ): string {
147
+ let raw: string;
148
+
149
+ if (typeof input.command === "string") {
150
+ raw = input.command;
151
+ } else if (typeof input.file_path === "string") {
152
+ raw = input.file_path;
153
+ } else if (typeof input.path === "string") {
154
+ raw = input.path;
155
+ } else {
156
+ raw = JSON.stringify(input).slice(0, MAX_PREVIEW_LENGTH);
157
+ }
158
+
159
+ return truncate(raw, MAX_PREVIEW_LENGTH);
160
+ }
package/src/osc.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * OSC 777 emitter for Warp notifications.
3
+ */
4
+
5
+ import { writeFileSync } from "node:fs";
6
+
7
+ /**
8
+ * Format an OSC 777 escape sequence for a Warp notification payload.
9
+ * Visible (testable) helper — the actual write goes through sendNotification().
10
+ */
11
+ export function formatOsc777(payload: Record<string, unknown>): string {
12
+ const body = JSON.stringify(payload);
13
+ return `\x1b]777;notify;warp://cli-agent;${body}\x07`;
14
+ }
15
+
16
+ /**
17
+ * Send a structured notification to Warp via OSC 777.
18
+ * Written to /dev/tty so it reaches the controlling terminal directly.
19
+ * Silently ignores write failures.
20
+ */
21
+ export function sendNotification(payload: Record<string, unknown>): void {
22
+ const seq = formatOsc777(payload);
23
+ try {
24
+ writeFileSync("/dev/tty", seq);
25
+ } catch {
26
+ // Silently ignore if /dev/tty is not available (e.g. piped mode)
27
+ }
28
+ }
package/src/payload.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Protocol version negotiation and payload builder.
3
+ */
4
+
5
+ export const PLUGIN_CURRENT_PROTOCOL_VERSION = 1;
6
+
7
+ /**
8
+ * Negotiate the protocol version with Warp.
9
+ * Uses min(plugin_current, warp_declared), falling back to 1.
10
+ */
11
+ export function negotiateProtocolVersion(): number {
12
+ const warpVersion = parseInt(
13
+ process.env.WARP_CLI_AGENT_PROTOCOL_VERSION ?? "1",
14
+ 10
15
+ );
16
+ if (isNaN(warpVersion)) return PLUGIN_CURRENT_PROTOCOL_VERSION;
17
+ return Math.min(warpVersion, PLUGIN_CURRENT_PROTOCOL_VERSION);
18
+ }
19
+
20
+ /**
21
+ * Build the common payload fields shared by all events.
22
+ */
23
+ export function buildBasePayload(
24
+ event: string,
25
+ ctx: { cwd: string; sessionManager: { getSessionFile(): string | undefined } }
26
+ ): Record<string, unknown> {
27
+ const sessionFile = ctx.sessionManager.getSessionFile();
28
+ const project = ctx.cwd ? ctx.cwd.split("/").pop() ?? "" : "";
29
+
30
+ return {
31
+ v: negotiateProtocolVersion(),
32
+ agent: "pi",
33
+ event,
34
+ session_id: sessionFile ?? "",
35
+ cwd: ctx.cwd,
36
+ project,
37
+ };
38
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Extension settings — persisted in pi global settings.
3
+ *
4
+ * Settings are stored under the `piWarp` key in
5
+ * `~/.pi/agent/settings.json` so they survive restarts.
6
+ *
7
+ * Schema:
8
+ * piWarp.dynamicTitles: boolean (default: true)
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Settings schema
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface WarpNotifySettings {
20
+ /** Animate terminal title while agent is working. Default: true */
21
+ dynamicTitles: boolean;
22
+ }
23
+
24
+ const DEFAULTS: WarpNotifySettings = {
25
+ dynamicTitles: true,
26
+ };
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Path helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** @internal Test override for the settings file path */
33
+ export let _settingsPathOverride: string | undefined;
34
+
35
+ /** @internal Set the override (used by tests) */
36
+ export function setSettingsPathOverride(value: string | undefined): void {
37
+ _settingsPathOverride = value;
38
+ }
39
+
40
+ function settingsPath(): string {
41
+ return _settingsPathOverride ?? join(homedir(), ".pi", "agent", "settings.json");
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Read / Write
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function readGlobalSettings(): Record<string, unknown> {
49
+ const path = settingsPath();
50
+ if (!existsSync(path)) return {};
51
+ try {
52
+ return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
53
+ } catch {
54
+ return {};
55
+ }
56
+ }
57
+
58
+ function writeGlobalSettings(settings: Record<string, unknown>): void {
59
+ const path = settingsPath();
60
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf-8");
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Public API
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Load the piWarp settings from pi global settings.
69
+ * Returns a full settings object with defaults applied.
70
+ */
71
+ export function loadSettings(): WarpNotifySettings {
72
+ const global = readGlobalSettings();
73
+ const ext = (global.piWarp ?? {}) as Partial<WarpNotifySettings>;
74
+
75
+ return {
76
+ dynamicTitles: ext.dynamicTitles ?? DEFAULTS.dynamicTitles,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Persist a single setting key under the `piWarp` namespace.
82
+ * Merges with existing piWarp settings so sibling keys are preserved.
83
+ */
84
+ export function saveSetting<K extends keyof WarpNotifySettings>(
85
+ key: K,
86
+ value: WarpNotifySettings[K],
87
+ ): void {
88
+ const global = readGlobalSettings();
89
+ const current = (global.piWarp ?? {}) as Record<string, unknown>;
90
+ current[key] = value;
91
+ global.piWarp = current;
92
+ writeGlobalSettings(global);
93
+ }
94
+
95
+ /**
96
+ * Convenience: check whether dynamic titles are enabled.
97
+ */
98
+ export function dynamicTitlesEnabled(): boolean {
99
+ return loadSettings().dynamicTitles;
100
+ }
package/src/title.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * OSC 0 — Dynamic Terminal Title.
3
+ *
4
+ * Working: ⠋ π session — project (animated braille spinner)
5
+ * Ready: π session — project (static, no spinner)
6
+ */
7
+
8
+ import { writeFileSync } from "node:fs";
9
+
10
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
+ const SPINNER_INTERVAL_MS = 120;
12
+
13
+ let timer: ReturnType<typeof setInterval> | null = null;
14
+ let frameIndex = 0;
15
+
16
+ /**
17
+ * Format an OSC 0 escape sequence to set the terminal window title.
18
+ */
19
+ export function formatOsc0(title: string): string {
20
+ return `\x1b]0;${title}\x07`;
21
+ }
22
+
23
+ /**
24
+ * Write an OSC 0 title sequence to /dev/tty.
25
+ * Silently ignores write failures.
26
+ */
27
+ function setTitle(title: string): void {
28
+ const seq = formatOsc0(title);
29
+ try {
30
+ writeFileSync("/dev/tty", seq);
31
+ } catch {
32
+ // Silently ignore if /dev/tty is not available
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Build the static title string.
38
+ *
39
+ * With user-named session: "π My Session — project"
40
+ * Without (auto-generated): "π — project"
41
+ *
42
+ * The session name is only included when the user has explicitly set one
43
+ * via `/name` — default auto-generated sessions are omitted.
44
+ */
45
+ export function buildTitle(
46
+ ctx: { cwd: string; sessionManager: { getSessionFile(): string | undefined; getSessionName?(): string | undefined } }
47
+ ): string {
48
+ const project = ctx.cwd ? ctx.cwd.split("/").pop() ?? "" : "";
49
+ const sessionName = ctx.sessionManager.getSessionName?.();
50
+ if (sessionName) {
51
+ return `π ${sessionName} — ${project}`;
52
+ }
53
+ return `π — ${project}`;
54
+ }
55
+
56
+ /**
57
+ * Start the animated spinner in the terminal title.
58
+ * Call this when the agent begins working (before_agent_start).
59
+ */
60
+ export function startSpinner(
61
+ ctx: { cwd: string; sessionManager: { getSessionFile(): string | undefined; getSessionName?(): string | undefined } }
62
+ ): void {
63
+ stopSpinner(); // clear any existing timer
64
+
65
+ const base = buildTitle(ctx);
66
+
67
+ timer = setInterval(() => {
68
+ const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length];
69
+ setTitle(`${frame} ${base}`);
70
+ frameIndex++;
71
+ }, SPINNER_INTERVAL_MS);
72
+ }
73
+
74
+ /**
75
+ * Stop the animated spinner and set the static "ready" title.
76
+ * Call this when the agent finishes (agent_end).
77
+ */
78
+ export function stopSpinner(
79
+ ctx?: { cwd: string; sessionManager: { getSessionFile(): string | undefined; getSessionName?(): string | undefined } }
80
+ ): void {
81
+ if (timer !== null) {
82
+ clearInterval(timer);
83
+ timer = null;
84
+ }
85
+ frameIndex = 0;
86
+
87
+ if (ctx) {
88
+ setTitle(buildTitle(ctx));
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Check whether the spinner is currently active.
94
+ */
95
+ export function isSpinnerActive(): boolean {
96
+ return timer !== null;
97
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Type declarations for the pi-coding-agent extension API.
3
+ * This is a peer dependency available at runtime but not installed locally.
4
+ */
5
+ declare module "@earendil-works/pi-coding-agent" {
6
+ export interface ExtensionAPI {
7
+ on(
8
+ event: string,
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ handler: (event: any, ctx: ExtensionContext) => void | Promise<void>
11
+ ): void;
12
+ registerCommand(
13
+ name: string,
14
+ options: {
15
+ description?: string;
16
+ handler: (args: string, ctx: ExtensionCommandContext) => void | Promise<void>;
17
+ }
18
+ ): void;
19
+ }
20
+
21
+ export function getSettingsListTheme(): Record<string, unknown>;
22
+
23
+ export interface ExtensionContext {
24
+ cwd: string;
25
+ sessionManager: {
26
+ getSessionFile(): string | undefined;
27
+ getSessionName(): string | undefined;
28
+ };
29
+ ui: {
30
+ notify(message: string, level: string): void;
31
+ custom<T>(
32
+ factory: (
33
+ tui: unknown,
34
+ theme: UiTheme,
35
+ keybindings: unknown,
36
+ done: (value: T) => void,
37
+ ) => CustomComponent,
38
+ ): Promise<T>;
39
+ };
40
+ }
41
+
42
+ // ExtensionCommandContext has the same members as ExtensionContext
43
+ // plus session control methods not needed for type checking here.
44
+ export type ExtensionCommandContext = ExtensionContext;
45
+
46
+ export interface UiTheme {
47
+ fg(color: string, text: string): string;
48
+ bold(text: string): string;
49
+ }
50
+
51
+ export interface CustomComponent {
52
+ render(width: number): string[];
53
+ invalidate(): void;
54
+ handleInput?(data: string): void;
55
+ }
56
+ }
57
+
58
+ declare module "@earendil-works/pi-tui" {
59
+ export interface SettingItem {
60
+ id: string;
61
+ label: string;
62
+ currentValue: string;
63
+ values: string[];
64
+ }
65
+
66
+ export class SettingsList {
67
+ constructor(
68
+ items: SettingItem[],
69
+ maxVisible: number,
70
+ theme: Record<string, unknown>,
71
+ onChange: (id: string, newValue: string) => void,
72
+ onClose: () => void,
73
+ options?: { enableSearch?: boolean },
74
+ );
75
+ handleInput?(data: string): void;
76
+ }
77
+
78
+ export class Container {
79
+ addChild(child: unknown): void;
80
+ render(width: number): string[];
81
+ invalidate(): void;
82
+ }
83
+ }
package/src/version.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Broken-version detection for Warp builds.
3
+ */
4
+
5
+ /**
6
+ * Known broken thresholds per channel — builds at or below these don't support
7
+ * structured CLI agent notifications.
8
+ */
9
+ const BROKEN_THRESHOLDS: Record<string, string> = {
10
+ stable: "v0.2026.03.25.08.24.stable_05",
11
+ preview: "v0.2026.03.25.08.24.preview_05",
12
+ };
13
+
14
+ /**
15
+ * Extract the channel from a Warp version string.
16
+ * Returns "dev", "stable", "preview", or undefined if unrecognized.
17
+ */
18
+ function extractChannel(version: string): string | undefined {
19
+ if (version.includes("dev")) return "dev";
20
+ if (version.includes("stable")) return "stable";
21
+ if (version.includes("preview")) return "preview";
22
+ return undefined;
23
+ }
24
+
25
+ /**
26
+ * Check if the current Warp build supports structured CLI agent notifications.
27
+ * Returns false if required env vars are missing or the client version is at or
28
+ * below the last known broken release for its channel.
29
+ */
30
+ export function shouldUseStructured(): boolean {
31
+ if (!process.env.WARP_CLI_AGENT_PROTOCOL_VERSION) return false;
32
+ if (!process.env.WARP_CLIENT_VERSION) return false;
33
+
34
+ const clientVersion = process.env.WARP_CLIENT_VERSION;
35
+ const channel = extractChannel(clientVersion);
36
+
37
+ // dev was never broken, unrecognized channels are assumed OK
38
+ if (!channel || channel === "dev") return true;
39
+
40
+ const threshold = BROKEN_THRESHOLDS[channel];
41
+ if (!threshold) return true;
42
+
43
+ // Compare lexicographically — Warp version strings sort correctly
44
+ return clientVersion > threshold;
45
+ }