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 +21 -0
- package/README.md +90 -0
- package/index.ts +157 -0
- package/package.json +53 -0
- package/src/events.ts +160 -0
- package/src/osc.ts +28 -0
- package/src/payload.ts +38 -0
- package/src/settings.ts +100 -0
- package/src/title.ts +97 -0
- package/src/types/pi-coding-agent.d.ts +83 -0
- package/src/version.ts +45 -0
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
|
+

|
|
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
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -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
|
+
}
|