@juicesharp/rpiv-warp 1.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/CHANGELOG.md ADDED
@@ -0,0 +1,81 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@juicesharp/rpiv-warp` are documented here.
4
+
5
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.3.0] - 2026-05-08
9
+
10
+ ### Changed
11
+ - Package is now published to npm (previously `private: true`). The title-spinner module is included in the tarball.
12
+
13
+ ## [1.2.1] - 2026-05-07
14
+
15
+ ## [1.2.0] - 2026-05-07
16
+
17
+ ## [1.1.5] - 2026-05-05
18
+
19
+ ## [1.1.4] - 2026-05-03
20
+
21
+ ## [1.1.3] - 2026-05-03
22
+
23
+ ## [1.1.2] - 2026-05-03
24
+
25
+ ## [1.1.1] - 2026-05-03
26
+
27
+ ## [1.1.0] - 2026-05-03
28
+
29
+ ## [1.0.19] - 2026-05-03
30
+
31
+ ### Added
32
+ - README `Features` section listing customer-facing functionality: native OS toasts on Pi lifecycle events, live Warp tab badge, tab-title spinner, configurable blocking-tool allowlist, startup-only session notifications, silent-outside-Warp behavior, best-effort Windows support, and zero-tool footprint.
33
+
34
+ ### Changed
35
+ - **Now publicly published on npm.** `package.json` no longer carries `"private": true`, so workspace publishes pick `@juicesharp/rpiv-warp` up alongside the rest of the family. Install with `pi install npm:@juicesharp/rpiv-warp`. The package is still opt-in: it is intentionally absent from `siblings.ts` and is NOT auto-installed by `/rpiv-setup`.
36
+
37
+ ## [1.0.18] - 2026-05-02
38
+
39
+ ## [1.0.17] - 2026-05-02
40
+
41
+ ## [1.0.16] - 2026-05-02
42
+
43
+ ## [1.0.15] - 2026-05-02
44
+
45
+ ## [1.0.14] - 2026-05-01
46
+
47
+ ### Changed
48
+ - Cover redesigned as a macOS-style terminal-window screenshot demonstrating the extension's hero feature.
49
+
50
+ ## [1.0.13] - 2026-05-01
51
+
52
+ ### Added
53
+ - `docs/vertical-cover.{svg,png}` — portrait-orientation hero artwork (1280×800 canvas; PNG downscaled to 320×711).
54
+ - Best-effort Windows transport: `writeOSC777` writes the OSC 777 byte sequence to `process.stdout` (gated on `isTTY`) when `/dev/tty` is unavailable, relying on ConPTY to forward unrecognized OSCs to Warp.
55
+ - Warp tab-title spinner: animates the first character of the terminal window title with a 4-frame braille rotation at 160ms cadence during agent loops, wrapped in xterm `CSI 22;0t` / `CSI 23;0t` push/pop so Pi's `π - <repo>` title is restored verbatim on stop.
56
+ - `title-spinner.ts` module plus `writeOSC0`, `pushTitleStack`, `popTitleStack` emitters that share `writeOSC777`’s transport path (so they also flow through `process.stdout` on Windows).
57
+ - Test coverage for the title-spinner emitters on Windows transport.
58
+
59
+ ### Changed
60
+ - Cover canvas extended from 1280×640 to 1280×800 with refreshed crop marks/footer.
61
+ - README hero swapped from `docs/cover.png` to `docs/vertical-cover.png`, rendered at `width="160"`. The `<a>` wrapper around the `<picture>` was removed so the image is no longer a clickable link to the package directory.
62
+ - README edge-case table updated to flag the Windows transport as untested in the wild.
63
+ - Internal: renamed "OSC byte sequence" section to "Escape-sequence constants" to cover the new CSI additions; split formatters into their own section to mirror `payload.ts`’s Constants → Builders separation; restated the 160ms frame cadence in module headers.
64
+
65
+ ## [1.0.12] - 2026-05-01
66
+
67
+ ### Added
68
+ - `docs/cover.png` — package hero (rasterized from `docs/cover.svg` via `rsvg-convert`, 1280×640).
69
+ - `session_start` payload emission on `agent_start` so Warp learns the project context at agent boot, before any tool call.
70
+ - Client-side protocol-version negotiation: parse `WARP_CLI_AGENT_PROTOCOL_VERSION` and gate emission on the negotiated version, replacing the prior hard-coded broken-build check.
71
+ - Config-driven blocking-tool flow: subscribes to `question_asked` / `tool_complete`, drives the OSC 777 envelope from a per-tool config table instead of the inline `NOTIFY_TOOL_NAMES` allowlist. Adds `ask_user_question` as the initial blocking-tool entry.
72
+
73
+ ### Changed
74
+ - README now opens with a `<picture>`-wrapped `cover.png` hero so GitHub renders friendly artwork at the top of the package page.
75
+ - `package.json` now carries `"private": true` to gate npm publish — the package joins lockstep + shared CI infrastructure but does not publish until explicitly opted in.
76
+ - Agent-start emission switched from `session_start` to `prompt_submit` so Warp's UI cues fire on user-prompt cadence rather than session boot.
77
+
78
+ ## [1.0.11] - 2026-04-30
79
+
80
+ ### Added
81
+ - Initial release. New standalone Pi extension that subscribes to four Pi lifecycle events (`session_start`, `agent_end`, `tool_call`, `turn_end`) and emits Warp's structured `OSC 777` escape sequence to `/dev/tty` so Warp renders native OS-level toast notifications. Filters `session_start` to `reason === "startup"` only, and `tool_call` to a configurable `NOTIFY_TOOL_NAMES` set (initial entry: `ask_user_question`). Detects Warp via `TERM_PROGRAM === "WarpTerminal"` plus `WARP_CLI_AGENT_PROTOCOL_VERSION`; falls back to silent no-op outside Warp, on broken Warp builds (per-channel hard-coded thresholds), or when `/dev/tty` is unreachable. Standalone — not registered as a sibling, not auto-installed by `/rpiv-setup`. Install via `pi install npm:@juicesharp/rpiv-warp`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 juicesharp
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,87 @@
1
+ # rpiv-warp
2
+
3
+ <div align="center">
4
+ <a href="https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-warp">
5
+ <picture>
6
+ <img src="https://raw.githubusercontent.com/juicesharp/rpiv-mono/main/packages/rpiv-warp/docs/cover.png" alt="rpiv-warp cover" width="50%">
7
+ </picture>
8
+ </a>
9
+ </div>
10
+
11
+ [![npm version](https://img.shields.io/npm/v/@juicesharp/rpiv-warp.svg)](https://www.npmjs.com/package/@juicesharp/rpiv-warp)
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
13
+
14
+ Native [Warp terminal](https://www.warp.dev/) toasts for [Pi Agent](https://github.com/badlogic/pi-mono) lifecycle events. When Pi finishes a long task, asks for your input, or completes a turn, `rpiv-warp` emits Warp's `OSC 777` escape sequence and Warp surfaces a native OS notification. Outside Warp it does nothing - install it everywhere, it only fires where it's useful.
15
+
16
+ ## Features
17
+
18
+ - **Native OS toasts on Pi lifecycle events** - agent start, agent end, blocking-tool calls, and turn boundaries surface as native Warp / macOS / Windows notifications.
19
+ - **Live Warp tab badge** - the tab indicator transitions through **In progress → Success / Blocked** as Pi works.
20
+ - **Tab-title spinner** - animates the tab title while the agent loop is active and restores Pi's `π - <repo>` title on stop.
21
+ - **Configurable blocking-tool allowlist** - choose which tool calls flip the badge to **Blocked** via `~/.config/rpiv-warp/config.json`. Defaults to `ask_user_question`.
22
+ - **Startup-only session notifications** - `/new`, `/resume`, `/fork`, and `/reload` stay quiet; only fresh startups notify.
23
+ - **Silent outside Warp** - no-op when not running in Warp, on known-broken Warp builds, or when `/dev/tty` is unreachable.
24
+ - **Windows support** - best-effort delivery via `process.stdout` / ConPTY, gated on `isTTY`.
25
+ - **Zero-tool, zero-UI footprint** - no tools registered with the model, no commands, no widgets, no token cost.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pi install npm:@juicesharp/rpiv-warp
31
+ ```
32
+
33
+ `rpiv-warp` is **opt-in** - it is NOT auto-installed by `/rpiv-setup`. Install it explicitly only if you use Warp.
34
+
35
+ ## What you get
36
+
37
+ | Pi event | Warp wire event | Effect on the tab badge |
38
+ |---|---|---|
39
+ | `session_start` (startup only) | `session_start` | records plugin version |
40
+ | `agent_start` | `prompt_submit` | → **In progress** |
41
+ | `agent_end` | `stop` | → **Success** (checkmark) |
42
+ | `tool_call` (configured blocking tool) | `question_asked` | → **Blocked** ("Waiting for your answer") |
43
+ | `tool_execution_end` (configured blocking tool) | `tool_complete` | Blocked → **In progress** |
44
+
45
+ `session_start` is filtered to `reason === "startup"` only - `/new`, `/resume`, `/fork`, `/reload` do NOT emit (you're already looking at the terminal in those cases).
46
+
47
+ ## Config
48
+
49
+ Optional file at `~/.config/rpiv-warp/config.json`:
50
+
51
+ ```json
52
+ {
53
+ "blockingTools": ["ask_user_question", "my_custom_blocking_tool"]
54
+ }
55
+ ```
56
+
57
+ | Key | Default | Meaning |
58
+ |---|---|---|
59
+ | `blockingTools` | `["ask_user_question"]` | Tool names that put the Warp tab badge into the **Blocked** state when called and clear it when the tool finishes. Use for tools that genuinely block the agent loop on user input. |
60
+
61
+ Missing or malformed file falls back to defaults - no config required.
62
+
63
+ ## Detection
64
+
65
+ `rpiv-warp` reads three environment variables Warp sets automatically:
66
+
67
+ - `TERM_PROGRAM === "WarpTerminal"` - required. Outside Warp the extension is a no-op.
68
+ - `WARP_CLI_AGENT_PROTOCOL_VERSION` - required for structured emission. If unset, `rpiv-warp` does nothing rather than falling back to a less-rich legacy format.
69
+ - `WARP_CLIENT_VERSION` - used for broken-version gating. A short list of known-broken Warp builds (per release channel) suppresses emission until Warp ships a fix.
70
+
71
+ ## Edge cases
72
+
73
+ | Case | Behavior |
74
+ |---|---|
75
+ | Not in Warp (`TERM_PROGRAM !== "WarpTerminal"`) | Silent no-op - extension loads, every handler short-circuits |
76
+ | Pi in print mode (`pi -p "..."`) | **Toasts still fire** - print mode emits all four events at the agent layer |
77
+ | `/dev/tty` unreachable (cron, no-tty SSH) | Silent no-op - `try/catch` around `openSync` |
78
+ | Windows | Best-effort - writes OSC 777 to `process.stdout` so ConPTY forwards it to Warp ([Warp eng blog: "ConPTY will send even unrecognized OSCs to the shell"](https://www.warp.dev/blog/building-warp-on-windows)). Skipped when stdout is not a TTY (piped/redirected output). Untested in the wild - no Warp plugin currently ships a Windows transport |
79
+ | Known-broken Warp build | Silent no-op - broken-version table gates emission per channel |
80
+
81
+ ## Why standalone (not a sibling)
82
+
83
+ `rpiv-warp` is intentionally NOT registered in `rpiv-pi`'s sibling list. Not every Pi user uses Warp - auto-installing it everywhere would impose Warp-specific code on every install. If you don't use Warp, don't install it. If you do, install it explicitly. The package still joins the rpiv-mono lockstep version + shared release pipeline.
84
+
85
+ ## License
86
+
87
+ MIT - see [LICENSE](./LICENSE).
package/config.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export const CONFIG_DIR = join(homedir(), ".config", "rpiv-warp");
6
+ export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
7
+
8
+ export interface RpivWarpConfig {
9
+ readonly blockingTools?: readonly string[];
10
+ }
11
+
12
+ export const DEFAULT_BLOCKING_TOOLS: readonly string[] = ["ask_user_question"];
13
+
14
+ export function loadConfig(): RpivWarpConfig {
15
+ if (!existsSync(CONFIG_PATH)) return {};
16
+ try {
17
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as unknown;
18
+ if (parsed === null || typeof parsed !== "object") return {};
19
+ return parsed as RpivWarpConfig;
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ export function getBlockingTools(): ReadonlySet<string> {
26
+ const config = loadConfig();
27
+ const list = Array.isArray(config.blockingTools) ? config.blockingTools : DEFAULT_BLOCKING_TOOLS;
28
+ const filtered = list.filter((s): s is string => typeof s === "string" && s.length > 0);
29
+ return new Set(filtered);
30
+ }
package/index.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { basename } from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import { getBlockingTools } from "./config.js";
4
+ import {
5
+ buildPromptSubmitPayload,
6
+ buildQuestionAskedPayload,
7
+ buildSessionStartPayload,
8
+ buildStopPayload,
9
+ buildToolCompletePayload,
10
+ serializePayload,
11
+ type WarpPayload,
12
+ } from "./payload.js";
13
+ import { detectWarpEnvironment } from "./protocol.js";
14
+ import { startSpinner, stopSpinner } from "./title-spinner.js";
15
+ import { writeOSC777 } from "./warp-notify.js";
16
+
17
+ const TITLE = "warp://cli-agent";
18
+
19
+ function emit(payload: WarpPayload): void {
20
+ writeOSC777(TITLE, serializePayload(payload));
21
+ }
22
+
23
+ function readBranch(ctx: ExtensionContext): SessionEntry[] {
24
+ return ctx.sessionManager.getBranch() as SessionEntry[];
25
+ }
26
+
27
+ // Mirror Pi's startup tab title `<mascot> - <repo>`. We only own the first
28
+ // character (the spinner glyph during animation); push/pop restores Pi's
29
+ // mascot verbatim on stop.
30
+ function titleSuffix(ctx: ExtensionContext): string {
31
+ return ` - ${basename(ctx.cwd)}`;
32
+ }
33
+
34
+ export default function (pi: ExtensionAPI): void {
35
+ const warp = detectWarpEnvironment();
36
+ if (!warp.isWarp || !warp.supportsStructured) return;
37
+
38
+ const blockingTools = getBlockingTools();
39
+
40
+ pi.on("session_start", async (event, ctx) => {
41
+ if (event.reason !== "startup") return;
42
+ emit(buildSessionStartPayload(ctx));
43
+ });
44
+
45
+ pi.on("agent_start", async (_event, ctx) => {
46
+ emit(buildPromptSubmitPayload(ctx));
47
+ startSpinner(titleSuffix(ctx));
48
+ });
49
+
50
+ pi.on("agent_end", async (_event, ctx) => {
51
+ emit(buildStopPayload(ctx, readBranch(ctx)));
52
+ stopSpinner();
53
+ });
54
+
55
+ pi.on("tool_call", async (event, ctx) => {
56
+ if (!blockingTools.has(event.toolName)) return;
57
+ emit(buildQuestionAskedPayload(ctx));
58
+ stopSpinner();
59
+ });
60
+
61
+ pi.on("tool_execution_end", async (event, ctx) => {
62
+ if (!blockingTools.has(event.toolName)) return;
63
+ emit(buildToolCompletePayload(ctx, event.toolName));
64
+ startSpinner(titleSuffix(ctx));
65
+ });
66
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@juicesharp/rpiv-warp",
3
+ "version": "1.3.0",
4
+ "private": false,
5
+ "description": "Pi extension. Native Warp terminal notifications, dispatched via OSC 777 on Pi lifecycle events.",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "rpiv",
10
+ "warp",
11
+ "terminal",
12
+ "notifications",
13
+ "osc-777"
14
+ ],
15
+ "type": "module",
16
+ "license": "MIT",
17
+ "author": "juicesharp",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/juicesharp/rpiv-mono.git",
21
+ "directory": "packages/rpiv-warp"
22
+ },
23
+ "homepage": "https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-warp#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/juicesharp/rpiv-mono/issues"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "test": "vitest run"
32
+ },
33
+ "files": [
34
+ "index.ts",
35
+ "protocol.ts",
36
+ "warp-notify.ts",
37
+ "title-spinner.ts",
38
+ "payload.ts",
39
+ "config.ts",
40
+ "README.md",
41
+ "CHANGELOG.md",
42
+ "LICENSE"
43
+ ],
44
+ "pi": {
45
+ "extensions": [
46
+ "./index.ts"
47
+ ]
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*"
51
+ }
52
+ }
package/payload.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * rpiv-warp — Warp structured-payload composition.
3
+ *
4
+ * Pure data transforms: branch -> text extraction -> envelope -> JSON.
5
+ * No I/O. One small named function per concern; build* composers assemble
6
+ * them at the call sites consumed by `index.ts`.
7
+ */
8
+
9
+ import { basename } from "node:path";
10
+ import type { AssistantMessage, UserMessage } from "@earendil-works/pi-ai";
11
+ import type { ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
12
+ import { negotiateProtocolVersion, type WarpEvent } from "./protocol.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Constants — single definition site for tunables
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Warp's `CLIAgent` enum recognizes 12 IDs (warpdotdev/warp:
20
+ * `app/src/terminal/cli_agent.rs`), and `"pi"` is one of them — semantically
21
+ * correct identity for this extension. However, the session listener at
22
+ * `app/src/terminal/cli_agent_sessions/listener/mod.rs:48-57` currently routes
23
+ * only `Claude | OpenCode | Gemini | Auggie | Codex` to a notification handler
24
+ * and drops every other variant (including `Pi` and `Unknown`). So `"pi"`
25
+ * parses correctly but produces no toast in current Warp builds.
26
+ *
27
+ * Workaround options, all bad:
28
+ * - `agent: "claude"` → toasts render, but tab gets the Claude Code icon &
29
+ * "Claude" label via `SessionType::CliAgent(CLIAgent::Claude)`. Identity-
30
+ * misrepresenting; user-visibly wrong.
31
+ * - any non-allowlisted ID → no toast.
32
+ *
33
+ * Real fix is upstream: PR `warpdotdev/warp` moving `CLIAgent::Pi` into the
34
+ * `DefaultSessionListener` arm + adding `icon()` / `brand_color()` cases
35
+ * (template: `specs/APP-4067/TECH.md` did this for Gemini). Until that ships,
36
+ * we keep the correct identity and accept that Warp won't render the toast.
37
+ */
38
+ export const AGENT_ID = "pi";
39
+ export const TRUNCATE_LIMIT = 200;
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Types — base envelope + per-event extras
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export interface WarpPayloadBase {
46
+ readonly v: number;
47
+ readonly agent: string;
48
+ readonly event: WarpEvent;
49
+ readonly session_id: string;
50
+ readonly cwd: string;
51
+ readonly project: string;
52
+ }
53
+
54
+ export interface StopExtras {
55
+ readonly query: string;
56
+ readonly response: string;
57
+ }
58
+ export interface IdlePromptExtras {
59
+ readonly summary: string;
60
+ }
61
+ export interface ToolCompleteExtras {
62
+ readonly tool_name: string;
63
+ }
64
+
65
+ export type WarpPayload = WarpPayloadBase & Partial<StopExtras & IdlePromptExtras & ToolCompleteExtras>;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Text helpers — small, single-purpose, composable
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export function truncate(s: string, max: number = TRUNCATE_LIMIT): string {
72
+ if (s.length <= max) return s;
73
+ return `${s.slice(0, max - 3)}...`;
74
+ }
75
+
76
+ export function projectName(cwd: string): string {
77
+ return basename(cwd);
78
+ }
79
+
80
+ /**
81
+ * Extract plain text from a UserMessage.content (string | array) OR an
82
+ * AssistantMessage.content (always array). Filters to TextContent entries.
83
+ */
84
+ export function extractMessageText(content: UserMessage["content"] | AssistantMessage["content"]): string {
85
+ if (typeof content === "string") return content;
86
+ return content
87
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
88
+ .map((c) => c.text)
89
+ .join("\n");
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Branch traversal — reverse-scan filtered branch for last user/assistant text
94
+ // ---------------------------------------------------------------------------
95
+
96
+ function isMessageEntry(entry: SessionEntry): entry is SessionEntry & { type: "message" } {
97
+ return entry.type === "message";
98
+ }
99
+
100
+ function findLastMessageText(branch: SessionEntry[], role: "user" | "assistant"): string {
101
+ for (let i = branch.length - 1; i >= 0; i--) {
102
+ const entry = branch[i];
103
+ if (!isMessageEntry(entry)) continue;
104
+ const message = entry.message;
105
+ if (message.role !== role) continue;
106
+ const text = extractMessageText((message as UserMessage | AssistantMessage).content);
107
+ if (text.length > 0) return truncate(text);
108
+ }
109
+ return "";
110
+ }
111
+
112
+ export function lastUserText(branch: SessionEntry[]): string {
113
+ return findLastMessageText(branch, "user");
114
+ }
115
+
116
+ export function lastAssistantText(branch: SessionEntry[]): string {
117
+ return findLastMessageText(branch, "assistant");
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Envelope — common fields for every Warp event
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export function baseEnvelope(event: WarpEvent, ctx: ExtensionContext): WarpPayloadBase {
125
+ const cwd = ctx.cwd;
126
+ return {
127
+ v: negotiateProtocolVersion(),
128
+ agent: AGENT_ID,
129
+ event,
130
+ session_id: ctx.sessionManager.getSessionId(),
131
+ cwd,
132
+ project: projectName(cwd),
133
+ };
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Builders — one per Warp event; composition is linear and named
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export function buildSessionStartPayload(ctx: ExtensionContext): WarpPayload {
141
+ return baseEnvelope("session_start", ctx);
142
+ }
143
+
144
+ export function buildPromptSubmitPayload(ctx: ExtensionContext): WarpPayload {
145
+ return baseEnvelope("prompt_submit", ctx);
146
+ }
147
+
148
+ export function buildQuestionAskedPayload(ctx: ExtensionContext): WarpPayload {
149
+ return baseEnvelope("question_asked", ctx);
150
+ }
151
+
152
+ export function buildStopPayload(ctx: ExtensionContext, branch: SessionEntry[]): WarpPayload {
153
+ return {
154
+ ...baseEnvelope("stop", ctx),
155
+ query: lastUserText(branch),
156
+ response: lastAssistantText(branch),
157
+ };
158
+ }
159
+
160
+ export function buildIdlePromptPayload(ctx: ExtensionContext, summary: string): WarpPayload {
161
+ return {
162
+ ...baseEnvelope("idle_prompt", ctx),
163
+ summary,
164
+ };
165
+ }
166
+
167
+ export function buildToolCompletePayload(ctx: ExtensionContext, toolName: string): WarpPayload {
168
+ return {
169
+ ...baseEnvelope("tool_complete", ctx),
170
+ tool_name: toolName,
171
+ };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Serializer — single source of truth so tests can assert on JSON shape
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export function serializePayload(payload: WarpPayload): string {
179
+ return JSON.stringify(payload);
180
+ }
package/protocol.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * rpiv-warp — Warp terminal detection + protocol negotiation.
3
+ *
4
+ * Pure functions only. No module-level mutable state. Each function does
5
+ * one thing; `detectWarpEnvironment` is the composition site.
6
+ *
7
+ * Env vars consulted (read fresh on every call — no cache):
8
+ * TERM_PROGRAM — must be "WarpTerminal"
9
+ * WARP_CLI_AGENT_PROTOCOL_VERSION — required for structured emission
10
+ * WARP_CLIENT_VERSION — used for per-channel broken-version gating
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Types
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Structured event names emitted in the OSC 777 payload's `event` field. */
18
+ export type WarpEvent = "session_start" | "prompt_submit" | "stop" | "question_asked" | "tool_complete" | "idle_prompt";
19
+
20
+ /** Warp release channel — present in every `WARP_CLIENT_VERSION` literal. */
21
+ export type Channel = "stable" | "preview" | "dev";
22
+
23
+ /** Parsed version components: [year, month, day, hour, minute, rev, seq]. */
24
+ export type VersionTuple = readonly [number, number, number, number, number, number, number];
25
+
26
+ export interface ParsedWarpVersion {
27
+ readonly tuple: VersionTuple;
28
+ readonly channel: Channel;
29
+ }
30
+
31
+ export interface WarpEnvironment {
32
+ readonly isWarp: boolean;
33
+ readonly supportsStructured: boolean;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Constants
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Highest protocol version this plugin can speak. The plugin clamps
42
+ * client-side via `negotiateProtocolVersion()` and emits the agreed `v`
43
+ * in every payload, so an older Warp that only speaks v:1 keeps seeing v:1
44
+ * even after we bump this constant.
45
+ */
46
+ export const PLUGIN_MAX_PROTOCOL_VERSION = 1;
47
+
48
+ /**
49
+ * Last broken Warp build per channel. Builds at-or-below the threshold
50
+ * advertise structured-protocol support but render notifications behind a
51
+ * feature flag — gate them off until users upgrade.
52
+ */
53
+ export const BROKEN_VERSIONS: Record<Channel, VersionTuple | null> = {
54
+ stable: [2026, 3, 25, 8, 24, 5, 5],
55
+ preview: [2026, 3, 25, 8, 24, 5, 5],
56
+ dev: null,
57
+ };
58
+
59
+ const VERSION_RE = /^v0\.(\d{4})\.(\d{1,2})\.(\d{1,2})\.(\d{1,2})\.(\d{1,2})\.(stable|preview|dev)_(\d+)$/;
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Env-var primitives — each reads exactly one variable
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export function isWarpTerminal(): boolean {
66
+ return process.env.TERM_PROGRAM === "WarpTerminal";
67
+ }
68
+
69
+ export function hasStructuredProtocol(): boolean {
70
+ const v = process.env.WARP_CLI_AGENT_PROTOCOL_VERSION;
71
+ return typeof v === "string" && v.length > 0;
72
+ }
73
+
74
+ export function readClientVersion(): string | undefined {
75
+ const v = process.env.WARP_CLIENT_VERSION;
76
+ return typeof v === "string" && v.length > 0 ? v : undefined;
77
+ }
78
+
79
+ /**
80
+ * Client-side protocol-version clamp.
81
+ *
82
+ * Returns min(WARP_CLI_AGENT_PROTOCOL_VERSION, PLUGIN_MAX_PROTOCOL_VERSION).
83
+ * Falls back to PLUGIN_MAX_PROTOCOL_VERSION when the env var is missing,
84
+ * empty, or unparseable — matches reference (warpdotdev/opencode-warp
85
+ * src/payload.ts).
86
+ *
87
+ * Pure: env-var reads on every call, no caching, safe under env mutation
88
+ * in tests (research §Q5 contract).
89
+ */
90
+ export function negotiateProtocolVersion(): number {
91
+ const raw = process.env.WARP_CLI_AGENT_PROTOCOL_VERSION;
92
+ const warpVersion = raw ? Number.parseInt(raw, 10) : Number.NaN;
93
+ if (Number.isNaN(warpVersion)) return PLUGIN_MAX_PROTOCOL_VERSION;
94
+ return Math.min(warpVersion, PLUGIN_MAX_PROTOCOL_VERSION);
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Version parsing — pure, regex-driven
99
+ // ---------------------------------------------------------------------------
100
+
101
+ export function parseWarpVersion(raw: string | undefined): ParsedWarpVersion | null {
102
+ if (!raw) return null;
103
+ const m = VERSION_RE.exec(raw);
104
+ if (!m) return null;
105
+ const tuple: VersionTuple = [
106
+ Number(m[1]),
107
+ Number(m[2]),
108
+ Number(m[3]),
109
+ Number(m[4]),
110
+ Number(m[5]),
111
+ Number(m[7]),
112
+ Number(m[7]),
113
+ ];
114
+ return { tuple, channel: m[6] as Channel };
115
+ }
116
+
117
+ /** Element-wise `≤` over fixed-length tuples. Returns true on equal. */
118
+ export function tupleLeq(a: VersionTuple, b: VersionTuple): boolean {
119
+ for (let i = 0; i < a.length; i++) {
120
+ if (a[i] < b[i]) return true;
121
+ if (a[i] > b[i]) return false;
122
+ }
123
+ return true;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Broken-version gate
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export function isBrokenVersion(parsed: ParsedWarpVersion | null): boolean {
131
+ if (!parsed) return false;
132
+ const threshold = BROKEN_VERSIONS[parsed.channel];
133
+ if (threshold === null) return false;
134
+ return tupleLeq(parsed.tuple, threshold);
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Composition — one assembly site for the structured-mode predicate
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export function supportsStructured(): boolean {
142
+ if (!hasStructuredProtocol()) return false;
143
+ const parsed = parseWarpVersion(readClientVersion());
144
+ return !isBrokenVersion(parsed);
145
+ }
146
+
147
+ export function detectWarpEnvironment(): WarpEnvironment {
148
+ const isWarp = isWarpTerminal();
149
+ if (!isWarp) return { isWarp: false, supportsStructured: false };
150
+ return { isWarp: true, supportsStructured: supportsStructured() };
151
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * rpiv-warp — Tab-title activity spinner.
3
+ *
4
+ * Warp's per-tab "moving dots" animation is NOT part of the OSC 777
5
+ * cli-agent protocol (research: agent: "claude" payloads with the full
6
+ * OSC 777 lifecycle still produce no animation). It's a side effect of
7
+ * the foreground process continuously rewriting its terminal title via
8
+ * OSC 0 (`\x1b]0;<title>\x07`). Claude Code drives this by ticking
9
+ * braille glyphs through the title every ~80ms while a request is in
10
+ * flight (anthropics/claude-code#17887). Same mechanism animates
11
+ * activity indicators in iTerm2, Ghostty, tmux, Windows Terminal —
12
+ * terminal-side, not Warp-specific. Both reference plugins
13
+ * (warpdotdev/claude-code-warp, warpdotdev/opencode-warp) emit ONLY OSC 777
14
+ * — no spinner — confirming the dots originate in the agent process.
15
+ *
16
+ * Title-preservation strategy: the original Warp tab title (e.g.
17
+ * `π - rpiv-mono`, set by Pi at startup) MUST survive the animation
18
+ * round trip — and during the animation, only the FIRST character (the
19
+ * Pi mascot) is swapped for the rotating glyph; the suffix (` - <repo>`)
20
+ * stays put.
21
+ *
22
+ * on agent_start(suffix) → CSI 22;0t (push current title)
23
+ * while running, every 160ms → OSC 0 (write `<glyph><suffix>`)
24
+ * on agent_end → CSI 23;0t (pop — original restored)
25
+ *
26
+ * The suffix is supplied by callers in `index.ts` from `ctx.cwd`
27
+ * (` - ${basename(cwd)}`); on a stop, push/pop restores whatever the
28
+ * terminal had before — typically Pi's `π${suffix}`. Push/pop is
29
+ * supported by Warp, iTerm2, Ghostty, tmux, Linux console; terminals
30
+ * that don't implement it ignore the CSI silently.
31
+ *
32
+ * Module state: a single in-flight ticker. `startSpinner`/`stopSpinner`
33
+ * are idempotent — overlapping calls within one agent loop are safe.
34
+ * Timer is `unref()`d so a stray interval cannot block process exit.
35
+ * `__resetState` is the test-cleanup contract (timer-only; no I/O so
36
+ * the per-test fs mock isn't polluted with a stray pop sequence);
37
+ * `test/setup.ts` invokes it in `beforeEach`.
38
+ */
39
+
40
+ import { popTitleStack, pushTitleStack, writeOSC0 } from "./warp-notify.js";
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Constants — tunable at one site
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * 2×2-dot rotation, inverted: three of four dots in the cell's middle
48
+ * sub-grid (dots 2,5,3,6) stay lit, one rotates as a moving "gap"
49
+ * clockwise from top-left → top-right → bottom-right → bottom-left.
50
+ *
51
+ * ⠴ ⠦ ⠖ ⠲ (gap at TL, TR, BR, BL)
52
+ *
53
+ * Reads as a 3-dot cluster with a hole spinning around it. All four
54
+ * frames share the same monospace width, so the title suffix doesn't
55
+ * shimmer (vs Claude Code's variable-width `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`,
56
+ * anthropics/claude-code#17887).
57
+ *
58
+ * At FRAME_INTERVAL_MS = 160, the 4-frame cycle completes every ~640ms
59
+ * (~1.5 Hz) — relaxed pulse, deliberately slower than typical CLI
60
+ * spinners (80–100ms) so the tab indicator reads as ambient activity
61
+ * rather than urgency.
62
+ */
63
+ export const SPINNER_FRAMES: readonly string[] = ["⠴", "⠦", "⠖", "⠲"];
64
+
65
+ /** Tick rate — slower than typical CLI spinners (~80ms); reads as ambient. */
66
+ export const FRAME_INTERVAL_MS = 160;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Pure formatter — no I/O
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export function activeTitle(frameIndex: number, suffix: string = ""): string {
73
+ return `${SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]}${suffix}`;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Module state — one ticker at a time; idempotent start/stop
78
+ // ---------------------------------------------------------------------------
79
+
80
+ interface Ticker {
81
+ timer: ReturnType<typeof setInterval>;
82
+ frame: number;
83
+ suffix: string;
84
+ }
85
+
86
+ let active: Ticker | undefined;
87
+
88
+ function tick(): void {
89
+ if (!active) return;
90
+ writeOSC0(activeTitle(active.frame, active.suffix));
91
+ active.frame = (active.frame + 1) % SPINNER_FRAMES.length;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Public API — wired from index.ts agent-loop boundaries
96
+ // ---------------------------------------------------------------------------
97
+
98
+ export function startSpinner(suffix: string = ""): void {
99
+ if (active) return;
100
+ pushTitleStack();
101
+ const timer = setInterval(tick, FRAME_INTERVAL_MS);
102
+ if (typeof timer.unref === "function") timer.unref();
103
+ active = { timer, frame: 0, suffix };
104
+ }
105
+
106
+ export function stopSpinner(): void {
107
+ if (!active) return;
108
+ clearInterval(active.timer);
109
+ active = undefined;
110
+ popTitleStack();
111
+ }
112
+
113
+ export function __resetState(): void {
114
+ if (active) clearInterval(active.timer);
115
+ active = undefined;
116
+ }
package/warp-notify.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * rpiv-warp — OSC transport.
3
+ *
4
+ * Writes Warp's OSC escape sequences to the controlling terminal. On Unix
5
+ * this is `/dev/tty`; on Windows there is no `/dev/tty`, so we write the
6
+ * same OSC bytes to `process.stdout` and rely on ConPTY to forward them
7
+ * to Warp (per Warp's "Bringing Warp to Windows" eng blog: "ConPTY will
8
+ * send even unrecognized OSCs to the shell").
9
+ *
10
+ * Two OSC sequences and two CSI sequences are emitted from this module:
11
+ * - OSC 777 — Warp's structured cli-agent notification (badge state +
12
+ * toast). Single emission per lifecycle event; see `index.ts`.
13
+ * - OSC 0 — terminal title set. Driven from `title-spinner.ts` every
14
+ * 160ms to animate Warp's per-tab activity dots; same mechanism
15
+ * Claude Code uses (anthropics/claude-code#17887).
16
+ * - CSI 22;0t / CSI 23;0t — xterm window-title stack push/pop. Used
17
+ * by `title-spinner.ts` to snapshot Warp's existing tab title before
18
+ * the animation starts and restore it verbatim on stop, so the
19
+ * `π - <repo>` label Pi sets at startup survives the spinner round
20
+ * trip.
21
+ *
22
+ * Each call on Unix opens, writes, and closes the fd — no fd cache
23
+ * (matches bash precedent: warp-notify.sh:21). Windows writes go straight
24
+ * through `process.stdout.write` — best-effort, untested in the wild as
25
+ * no Warp plugin currently ships a Windows transport.
26
+ *
27
+ * Tests intercept fs calls via `vi.mock("node:fs", ...)` — the same
28
+ * pattern used in `packages/rpiv-pi/extensions/rpiv-core/pi-installer.test.ts:4`
29
+ * for `node:child_process`. Production uses `import * as fs from "node:fs"`
30
+ * for clarity (every fs call is namespace-prefixed).
31
+ */
32
+
33
+ import * as fs from "node:fs";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Escape-sequence constants — exported so tests assert against the same bytes
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export const OSC_INTRODUCER = "\x1b]";
40
+ export const OSC_TERMINATOR = "\x07";
41
+ export const OSC_777_PREFIX = "777;notify";
42
+ export const OSC_0_PREFIX = "0";
43
+
44
+ export const CSI_INTRODUCER = "\x1b[";
45
+ /** Push window+icon titles onto xterm's title stack (Ps=0 → both). */
46
+ export const CSI_PUSH_TITLE = "22;0t";
47
+ /** Pop and restore window+icon titles from xterm's title stack (Ps=0 → both). */
48
+ export const CSI_POP_TITLE = "23;0t";
49
+
50
+ const TTY_PATH = "/dev/tty";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Pure formatters — one per shape; no I/O
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export function formatOSC777(title: string, body: string): string {
57
+ return `${OSC_INTRODUCER}${OSC_777_PREFIX};${title};${body}${OSC_TERMINATOR}`;
58
+ }
59
+
60
+ export function formatOSC0(title: string): string {
61
+ return `${OSC_INTRODUCER}${OSC_0_PREFIX};${title}${OSC_TERMINATOR}`;
62
+ }
63
+
64
+ export function formatPushTitleStack(): string {
65
+ return `${CSI_INTRODUCER}${CSI_PUSH_TITLE}`;
66
+ }
67
+
68
+ export function formatPopTitleStack(): string {
69
+ return `${CSI_INTRODUCER}${CSI_POP_TITLE}`;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Platform / fs primitives — small wrappers so writeRaw reads as a sentence
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function isWindows(): boolean {
77
+ return process.platform === "win32";
78
+ }
79
+
80
+ function openTty(): number {
81
+ return fs.openSync(TTY_PATH, "w");
82
+ }
83
+
84
+ function writeBytes(fd: number, bytes: string): void {
85
+ fs.writeSync(fd, bytes);
86
+ }
87
+
88
+ function closeQuietly(fd: number): void {
89
+ try {
90
+ fs.closeSync(fd);
91
+ } catch {
92
+ /* fd already closed or invalid — ignore */
93
+ }
94
+ }
95
+
96
+ // Windows transport — write OSC bytes to stdout so ConPTY forwards them to
97
+ // Warp. Skipped when stdout isn't a TTY (piped/redirected output would either
98
+ // pollute downstream consumers or never reach the terminal).
99
+ function writeStdout(bytes: string): void {
100
+ if (!process.stdout.isTTY) return;
101
+ process.stdout.write(bytes);
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Raw transport — single platform-fork; shape-agnostic, swallows errors
106
+ // ---------------------------------------------------------------------------
107
+
108
+ function writeRaw(bytes: string): void {
109
+ if (isWindows()) {
110
+ try {
111
+ writeStdout(bytes);
112
+ } catch {
113
+ /* silent skip — best-effort on Windows */
114
+ }
115
+ return;
116
+ }
117
+ let fd: number | undefined;
118
+ try {
119
+ fd = openTty();
120
+ writeBytes(fd, bytes);
121
+ } catch {
122
+ /* silent skip — matches bash `warp-notify.sh:21` */
123
+ } finally {
124
+ if (fd !== undefined) closeQuietly(fd);
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Public emitters — one per shape; all silent-skip on any failure
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export function writeOSC777(title: string, body: string): void {
133
+ writeRaw(formatOSC777(title, body));
134
+ }
135
+
136
+ export function writeOSC0(title: string): void {
137
+ writeRaw(formatOSC0(title));
138
+ }
139
+
140
+ export function pushTitleStack(): void {
141
+ writeRaw(formatPushTitleStack());
142
+ }
143
+
144
+ export function popTitleStack(): void {
145
+ writeRaw(formatPopTitleStack());
146
+ }