@juicesharp/rpiv-warp 1.4.2 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to `@juicesharp/rpiv-warp` are documented here.
5
5
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
  Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.1] - 2026-05-13
9
+
10
+ ### Added
11
+ - Idle prompt emission after agent turns complete, carrying the assistant summary.
12
+ - Configurable heartbeat re-emitting `prompt_submit` during active work (default 15 s, set `heartbeatMs` in config to override, `0` to disable).
13
+ - `session_shutdown` handler clearing all timers and spinner state.
14
+ - Blocking tool input capture attached to `tool_complete` payloads.
15
+ - `before_agent_start` handler capturing the user's query for accurate `prompt_submit` events.
16
+
17
+ ## [1.5.0] - 2026-05-12
18
+
8
19
  ## [1.4.2] - 2026-05-11
9
20
 
10
21
  ## [1.4.1] - 2026-05-11
package/config.ts CHANGED
@@ -7,9 +7,11 @@ export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
7
7
 
8
8
  export interface RpivWarpConfig {
9
9
  readonly blockingTools?: readonly string[];
10
+ readonly heartbeatMs?: number;
10
11
  }
11
12
 
12
13
  export const DEFAULT_BLOCKING_TOOLS: readonly string[] = ["ask_user_question"];
14
+ export const DEFAULT_HEARTBEAT_MS = 15000;
13
15
 
14
16
  export function loadConfig(): RpivWarpConfig {
15
17
  if (!existsSync(CONFIG_PATH)) return {};
@@ -22,6 +24,14 @@ export function loadConfig(): RpivWarpConfig {
22
24
  }
23
25
  }
24
26
 
27
+ export function getHeartbeatMs(): number {
28
+ const config = loadConfig();
29
+ const ms = config.heartbeatMs;
30
+ if (ms === 0) return 0; // explicitly disabled
31
+ if (typeof ms !== "number" || ms <= 0) return DEFAULT_HEARTBEAT_MS;
32
+ return ms;
33
+ }
34
+
25
35
  export function getBlockingTools(): ReadonlySet<string> {
26
36
  const config = loadConfig();
27
37
  const list = Array.isArray(config.blockingTools) ? config.blockingTools : DEFAULT_BLOCKING_TOOLS;
package/index.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { basename } from "node:path";
2
2
  import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
3
- import { getBlockingTools } from "./config.js";
3
+ import { DEFAULT_HEARTBEAT_MS, getBlockingTools, getHeartbeatMs } from "./config.js";
4
4
  import {
5
+ buildIdlePromptPayload,
5
6
  buildPromptSubmitPayload,
6
7
  buildQuestionAskedPayload,
7
8
  buildSessionStartPayload,
8
9
  buildStopPayload,
9
10
  buildToolCompletePayload,
11
+ lastAssistantText,
10
12
  serializePayload,
11
13
  type WarpPayload,
12
14
  } from "./payload.js";
@@ -14,12 +16,85 @@ import { detectWarpEnvironment } from "./protocol.js";
14
16
  import { startSpinner, stopSpinner } from "./title-spinner.js";
15
17
  import { writeOSC777 } from "./warp-notify.js";
16
18
 
19
+ // ---------------------------------------------------------------------------
20
+ // Module-level timer state — __resetState() clears for test isolation
21
+ // ---------------------------------------------------------------------------
22
+
23
+ let pendingQuery = "";
24
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
25
+ let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
26
+ let heartbeatMs = DEFAULT_HEARTBEAT_MS;
27
+ const toolInputCapture = new Map<string, Record<string, unknown>>();
28
+
29
+ export function __resetState(): void {
30
+ pendingQuery = "";
31
+ if (idleTimer !== undefined) {
32
+ clearTimeout(idleTimer);
33
+ idleTimer = undefined;
34
+ }
35
+ if (heartbeatInterval !== undefined) {
36
+ clearInterval(heartbeatInterval);
37
+ heartbeatInterval = undefined;
38
+ }
39
+ heartbeatMs = DEFAULT_HEARTBEAT_MS;
40
+ toolInputCapture.clear();
41
+ }
42
+
17
43
  const TITLE = "warp://cli-agent";
18
44
 
19
45
  function emit(payload: WarpPayload): void {
20
46
  writeOSC777(TITLE, serializePayload(payload));
21
47
  }
22
48
 
49
+ function cancelIdleTimer(): void {
50
+ if (idleTimer !== undefined) {
51
+ clearTimeout(idleTimer);
52
+ idleTimer = undefined;
53
+ }
54
+ }
55
+
56
+ function startIdleTimer(ctx: ExtensionContext, branch: SessionEntry[]): void {
57
+ cancelIdleTimer();
58
+ idleTimer = setTimeout(() => {
59
+ idleTimer = undefined;
60
+ const summary = lastAssistantText(branch);
61
+ emit(buildIdlePromptPayload(ctx, summary));
62
+ }, 300);
63
+ if (typeof (idleTimer as ReturnType<typeof setTimeout>).unref === "function") {
64
+ (idleTimer as ReturnType<typeof setTimeout>).unref();
65
+ }
66
+ }
67
+
68
+ function startHeartbeat(ctx: ExtensionContext, ms: number): void {
69
+ stopHeartbeat();
70
+ if (ms <= 0) return; // disabled
71
+ heartbeatInterval = setInterval(() => {
72
+ emit(buildPromptSubmitPayload(ctx, pendingQuery));
73
+ }, ms);
74
+ if (typeof heartbeatInterval.unref === "function") {
75
+ heartbeatInterval.unref();
76
+ }
77
+ }
78
+
79
+ function stopHeartbeat(): void {
80
+ if (heartbeatInterval !== undefined) {
81
+ clearInterval(heartbeatInterval);
82
+ heartbeatInterval = undefined;
83
+ }
84
+ }
85
+
86
+ function captureToolInput(toolCallId: string, input: unknown): void {
87
+ if (typeof input === "object" && input !== null) {
88
+ toolInputCapture.set(toolCallId, input as Record<string, unknown>);
89
+ }
90
+ }
91
+
92
+ function consumeToolInput(toolCallId: string): Record<string, unknown> | undefined {
93
+ const input = toolInputCapture.get(toolCallId);
94
+ toolInputCapture.delete(toolCallId);
95
+ return input;
96
+ }
97
+
23
98
  function readBranch(ctx: ExtensionContext): SessionEntry[] {
24
99
  return ctx.sessionManager.getBranch() as SessionEntry[];
25
100
  }
@@ -36,31 +111,53 @@ export default function (pi: ExtensionAPI): void {
36
111
  if (!warp.isWarp || !warp.supportsStructured) return;
37
112
 
38
113
  const blockingTools = getBlockingTools();
114
+ heartbeatMs = getHeartbeatMs();
39
115
 
40
116
  pi.on("session_start", async (event, ctx) => {
41
117
  if (event.reason !== "startup") return;
42
118
  emit(buildSessionStartPayload(ctx));
43
119
  });
44
120
 
121
+ pi.on("before_agent_start", async (event) => {
122
+ pendingQuery = event.prompt ?? "";
123
+ });
124
+
45
125
  pi.on("agent_start", async (_event, ctx) => {
46
- emit(buildPromptSubmitPayload(ctx));
126
+ emit(buildSessionStartPayload(ctx)); // Item 2: defensive re-announce
127
+ emit(buildPromptSubmitPayload(ctx, pendingQuery));
47
128
  startSpinner(titleSuffix(ctx));
129
+ cancelIdleTimer(); // Item 3: cancel pending idle from previous turn
130
+ startHeartbeat(ctx, heartbeatMs); // Item 4: heartbeat
48
131
  });
49
132
 
50
133
  pi.on("agent_end", async (_event, ctx) => {
51
134
  emit(buildStopPayload(ctx, readBranch(ctx)));
52
135
  stopSpinner();
136
+ stopHeartbeat(); // Item 4: stop heartbeat
137
+ startIdleTimer(ctx, readBranch(ctx)); // Item 3: schedule idle_prompt after 300ms
53
138
  });
54
139
 
55
140
  pi.on("tool_call", async (event, ctx) => {
56
141
  if (!blockingTools.has(event.toolName)) return;
142
+ captureToolInput(event.toolCallId, event.input); // Item 6: capture input
57
143
  emit(buildQuestionAskedPayload(ctx));
58
144
  stopSpinner();
145
+ stopHeartbeat(); // Item 4: pause heartbeat
59
146
  });
60
147
 
61
148
  pi.on("tool_execution_end", async (event, ctx) => {
62
149
  if (!blockingTools.has(event.toolName)) return;
63
- emit(buildToolCompletePayload(ctx, event.toolName));
150
+ const toolInput = consumeToolInput(event.toolCallId); // Item 6: consume input
151
+ emit(buildToolCompletePayload(ctx, event.toolName, toolInput));
64
152
  startSpinner(titleSuffix(ctx));
153
+ startHeartbeat(ctx, heartbeatMs); // Item 4: resume heartbeat
154
+ });
155
+
156
+ pi.on("session_shutdown", async () => {
157
+ cancelIdleTimer();
158
+ stopHeartbeat();
159
+ pendingQuery = "";
160
+ toolInputCapture.clear();
161
+ stopSpinner();
65
162
  });
66
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicesharp/rpiv-warp",
3
- "version": "1.4.2",
3
+ "version": "1.5.1",
4
4
  "private": false,
5
5
  "description": "Pi extension. Native Warp terminal notifications, dispatched via OSC 777 on Pi lifecycle events.",
6
6
  "keywords": [
package/payload.ts CHANGED
@@ -16,24 +16,8 @@ import { negotiateProtocolVersion, type WarpEvent } from "./protocol.js";
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
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.
19
+ * Warp's `CLIAgent::Pi` is routed to `DefaultSessionListener` since Warp's
20
+ * open-source release (warpdotdev/warp `listener/mod.rs:93-99`).
37
21
  */
38
22
  export const AGENT_ID = "pi";
39
23
  export const TRUNCATE_LIMIT = 200;
@@ -58,11 +42,16 @@ export interface StopExtras {
58
42
  export interface IdlePromptExtras {
59
43
  readonly summary: string;
60
44
  }
45
+ export interface PromptSubmitExtras {
46
+ readonly query: string;
47
+ }
61
48
  export interface ToolCompleteExtras {
62
49
  readonly tool_name: string;
50
+ readonly tool_input?: Record<string, unknown>;
63
51
  }
64
52
 
65
- export type WarpPayload = WarpPayloadBase & Partial<StopExtras & IdlePromptExtras & ToolCompleteExtras>;
53
+ export type WarpPayload = WarpPayloadBase &
54
+ Partial<StopExtras & IdlePromptExtras & PromptSubmitExtras & ToolCompleteExtras>;
66
55
 
67
56
  // ---------------------------------------------------------------------------
68
57
  // Text helpers — small, single-purpose, composable
@@ -141,8 +130,11 @@ export function buildSessionStartPayload(ctx: ExtensionContext): WarpPayload {
141
130
  return baseEnvelope("session_start", ctx);
142
131
  }
143
132
 
144
- export function buildPromptSubmitPayload(ctx: ExtensionContext): WarpPayload {
145
- return baseEnvelope("prompt_submit", ctx);
133
+ export function buildPromptSubmitPayload(ctx: ExtensionContext, query: string = ""): WarpPayload {
134
+ return {
135
+ ...baseEnvelope("prompt_submit", ctx),
136
+ query,
137
+ };
146
138
  }
147
139
 
148
140
  export function buildQuestionAskedPayload(ctx: ExtensionContext): WarpPayload {
@@ -164,10 +156,15 @@ export function buildIdlePromptPayload(ctx: ExtensionContext, summary: string):
164
156
  };
165
157
  }
166
158
 
167
- export function buildToolCompletePayload(ctx: ExtensionContext, toolName: string): WarpPayload {
159
+ export function buildToolCompletePayload(
160
+ ctx: ExtensionContext,
161
+ toolName: string,
162
+ toolInput?: Record<string, unknown>,
163
+ ): WarpPayload {
168
164
  return {
169
165
  ...baseEnvelope("tool_complete", ctx),
170
166
  tool_name: toolName,
167
+ ...(toolInput !== undefined ? { tool_input: toolInput } : {}),
171
168
  };
172
169
  }
173
170