pi-better-btw-plus 1.0.2 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-btw-plus",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "pi extension: /btw side-chat overlay — maintained fork of @yceachan/pi-better-btw (+ right-click copy/paste, fork model switch, turn-level retry)",
5
5
  "type": "module",
6
6
  "keywords": [
package/srcs/config.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join } from "node:path";
4
4
  import type { PromptPackManifest } from "./prompt-pack.ts";
5
+ import { PROVIDER_RETRY_KEYS, type ProviderRetrySettings } from "./provider-retry.ts";
5
6
  import type { RetryPolicy } from "./retry.ts";
6
7
  /**
7
8
  * Layered config resolution for pi-better-btw.
@@ -70,8 +71,11 @@ export const AGENT_CONFIG_DIR = join(homedir(), ".pi", "agent");
70
71
  * with project <cwd>/.pi/settings.json (project wins per key, mirroring pi's
71
72
  * deepMergeSettings), then the `retry` block is extracted with pi's defaults
72
73
  * (settingsManager.getRetrySettings: enabled=true, maxRetries=3,
73
- * baseDelayMs=2000). Invalid/absent files contribute nothing; a present-but-
74
- * unreadable file warns instead of failing the fork.
74
+ * baseDelayMs=2000). The `retry.provider` block (spec #20 D4: timeoutMs /
75
+ * maxRetries / maxRetryDelayMs HTTP-layer retry knobs) is extracted too,
76
+ * with only defined number keys kept; it is consumed solely by the overlay's
77
+ * stream assembly, never by the turn loop. Invalid/absent files contribute
78
+ * nothing; a present-but-unreadable file warns instead of failing the fork.
75
79
  */
76
80
  export interface LoadRetryPolicyOptions {
77
81
  /** Agent config dir holding pi's global settings.json (~/.pi/agent). */
@@ -125,6 +129,15 @@ export function loadRetryPolicy(options: LoadRetryPolicyOptions = {}): RetryPoli
125
129
  : {},
126
130
  );
127
131
  const retry = isPlainRecord(merged.retry) ? merged.retry : {};
132
+ // D4 (spec #20): extract the provider block (HTTP-layer retry knobs)
133
+ // from the already-global+project-merged settings. Only defined number
134
+ // keys are kept; missing keys fall empty — no maxRetryDelayMs default
135
+ // here, because pi-ai's streamSimple defaults it to 60000 internally.
136
+ const provider = isPlainRecord(retry.provider) ? retry.provider : {};
137
+ const providerSettings: ProviderRetrySettings = {};
138
+ for (const key of PROVIDER_RETRY_KEYS) {
139
+ if (typeof provider[key] === "number") providerSettings[key] = provider[key];
140
+ }
128
141
  return {
129
142
  enabled:
130
143
  typeof retry.enabled === "boolean" ? retry.enabled : true,
@@ -132,6 +145,9 @@ export function loadRetryPolicy(options: LoadRetryPolicyOptions = {}): RetryPoli
132
145
  typeof retry.maxRetries === "number" ? retry.maxRetries : 3,
133
146
  baseDelayMs:
134
147
  typeof retry.baseDelayMs === "number" ? retry.baseDelayMs : 2000,
148
+ ...(Object.keys(providerSettings).length > 0
149
+ ? { provider: providerSettings }
150
+ : {}),
135
151
  };
136
152
  }
137
153
 
@@ -0,0 +1,346 @@
1
+ /**
2
+ * fork-turn (issue #10, T1): the side chat's turn lifecycle as a deep module.
3
+ *
4
+ * `ForkTurnRunner` owns one fork `Agent`: it constructs the agent itself (via
5
+ * an injectable `agentFactory`), binds the lane-enforcement hooks
6
+ * (`transformContext` / `beforeToolCall` / `afterToolCall`) internally, and
7
+ * subscribes to agent events, translating them into semantic `TurnPhase`
8
+ * events for the overlay to render. The module is free of TUI / pi-runtime
9
+ * dependencies — it depends only on pi-agent-core types, the retry engine and
10
+ * the prompt-pack types — so tests drive it directly with a fake agent and a
11
+ * phase log.
12
+ *
13
+ * The turn semantics mirror the overlay's current wiring verbatim (ADR-0001):
14
+ * prompt → strip-failed-assistant-message → continue through `runWithRetry`,
15
+ * with `classifyRetryable` bound to the live model context window, per-turn
16
+ * lane state reset at the top of `run()`, and `cancel()` aborting both the
17
+ * backoff wait and the agent run. Budget exhaustion and Esc-cancel errors
18
+ * surface via the final `messages` + `turn-end` phases; genuine thrown attempt
19
+ * errors propagate out of `run()` for the caller to surface.
20
+ */
21
+ import {
22
+ Agent,
23
+ type AgentEvent,
24
+ type AgentMessage,
25
+ type AgentOptions,
26
+ type AfterToolCallContext,
27
+ type AfterToolCallResult,
28
+ type BeforeToolCallContext,
29
+ type BeforeToolCallResult,
30
+ } from "@earendil-works/pi-agent-core";
31
+ import { substituteTemplate, type PromptPack } from "./prompt-pack.ts";
32
+ import {
33
+ classifyRetryable,
34
+ runWithRetry,
35
+ type RetryableFailure,
36
+ type RetryableInput,
37
+ type RetryPolicy,
38
+ } from "./retry.ts";
39
+
40
+ /**
41
+ * Semantic turn lifecycle events emitted by the runner (frozen contract,
42
+ * issue #9). The overlay maps phases to rendering (spinner, countdown ticker,
43
+ * status line, message batches); tests read the union like a log.
44
+ */
45
+ export type TurnPhase =
46
+ | { kind: "stream"; delta: string }
47
+ | { kind: "messages"; messages: AgentMessage[] }
48
+ | { kind: "tool"; name: string; state: "start" | "end" }
49
+ | {
50
+ kind: "retry-wait";
51
+ attempt: number;
52
+ maxAttempts: number;
53
+ delayMs: number;
54
+ errorMessage: string;
55
+ }
56
+ | { kind: "lane"; count: number; escalated: boolean; tool: string }
57
+ | { kind: "turn-end" };
58
+
59
+ export interface ForkTurnRunnerOptions {
60
+ /**
61
+ * Agent construction config without the three lane hooks — the runner
62
+ * overrides `transformContext` / `beforeToolCall` / `afterToolCall` with its
63
+ * own implementations. `initialState` is assembled by the overlay (fork
64
+ * surgery, framing message, peek tool, read-only tool list).
65
+ */
66
+ agentOptions: AgentOptions;
67
+ /**
68
+ * Retry budget/backoff. Already ANDed with `features.retry` by the overlay
69
+ * (D11) — a disabled feature arrives as `enabled: false`.
70
+ */
71
+ retryPolicy: RetryPolicy;
72
+ /** Resolved prompt texts (focus anchor + lane reminders). */
73
+ promptPack: PromptPack;
74
+ /** Live lane predicate (tool-mode closure; live after Ctrl+T). */
75
+ isReadOnlyLane: () => boolean;
76
+ /** Read-only tool-set membership (tool-set closure). */
77
+ isReadOnlyTool: (name: string) => boolean;
78
+ /** Receives every semantic phase; the overlay maps phases to rendering. */
79
+ onPhase: (phase: TurnPhase) => void;
80
+ /** Test seam; defaults to constructing the real Agent. */
81
+ agentFactory?: (options: AgentOptions) => Agent;
82
+ }
83
+
84
+ export class ForkTurnRunner {
85
+ /** The owned fork agent; the overlay reaches it for model picker / export / Ctrl+T. */
86
+ readonly agent: Agent;
87
+
88
+ /** Out-of-lane attempts in the current turn (reset at the top of run()). */
89
+ private laneViolations = 0;
90
+ /** Reminder queued for injection by transformContext before the next LLM call. */
91
+ private pendingReminder: string | null = null;
92
+ /** When true, the turn is aborted right after the escalated reminder is injected. */
93
+ private abortAfterInject = false;
94
+ /** Per-turn retry cancellation (D9): cancel() aborts this so the backoff wait stops. */
95
+ private retryAbortController: AbortController | null = null;
96
+ /** True while a turn is in flight — streaming and backoff wait included. */
97
+ private running = false;
98
+
99
+ constructor(private readonly options: ForkTurnRunnerOptions) {
100
+ const agentFactory =
101
+ options.agentFactory ?? ((agentOptions: AgentOptions) => new Agent(agentOptions));
102
+ this.agent = agentFactory({
103
+ ...options.agentOptions,
104
+ transformContext: (messages) => this.transformContext(messages),
105
+ beforeToolCall: (context) => this.beforeToolCall(context),
106
+ afterToolCall: (context) => this.afterToolCall(context),
107
+ });
108
+ this.agent.subscribe((event) => this.handleAgentEvent(event));
109
+ }
110
+
111
+ /** True during both streaming and the retry backoff wait. */
112
+ get isRunning(): boolean {
113
+ return this.running;
114
+ }
115
+
116
+ /**
117
+ * Run one turn (user submit): prompt → strip-failed-assistant-message →
118
+ * continue through `runWithRetry` (ADR-0001 mirror). A thrown attempt error
119
+ * propagates out of `run()` for the caller to surface; budget exhaustion and
120
+ * Esc-cancel errors surface via the final `messages` + `turn-end` phases.
121
+ * A submit while a turn is in flight is ignored.
122
+ */
123
+ async run(text: string): Promise<void> {
124
+ const trimmed = text.trim();
125
+ if (!trimmed || this.running) return;
126
+
127
+ // Per-turn lane state resets at the top of run().
128
+ this.laneViolations = 0;
129
+ this.pendingReminder = null;
130
+ this.abortAfterInject = false;
131
+
132
+ this.running = true;
133
+ this.retryAbortController = new AbortController();
134
+ const signal = this.retryAbortController.signal;
135
+
136
+ try {
137
+ let firstAttempt = true;
138
+ const attempt = async (): Promise<RetryableFailure | undefined> => {
139
+ if (!firstAttempt) this.removeTrailingAssistantError();
140
+ if (firstAttempt) {
141
+ firstAttempt = false;
142
+ await this.agent.prompt(trimmed);
143
+ } else {
144
+ await this.agent.continue();
145
+ }
146
+ return this.lastAssistantMessage();
147
+ };
148
+
149
+ await runWithRetry({
150
+ attempt,
151
+ signal,
152
+ // ADR-0001 mirror: classify against the live model context window so
153
+ // silent overflow (pi isContextOverflow cases 2/3) is also excluded.
154
+ classify: (result) =>
155
+ classifyRetryable(
156
+ result as RetryableInput,
157
+ this.agent.state.model?.contextWindow ?? 0,
158
+ ),
159
+ onAttempt: (info) => {
160
+ this.emit({ kind: "retry-wait", ...info });
161
+ },
162
+ policy: this.options.retryPolicy,
163
+ });
164
+ } finally {
165
+ this.running = false;
166
+ this.retryAbortController = null;
167
+ // Final transcript snapshot + turn-end: the overlay clears its statuses
168
+ // and re-renders here (budget-exhaustion / Esc-cancel errors included).
169
+ this.emit({ kind: "messages", messages: [...this.agent.state.messages] });
170
+ this.emit({ kind: "turn-end" });
171
+ }
172
+ }
173
+
174
+ /** Esc / overlay dispose: abort the backoff wait and the agent run. */
175
+ cancel(): void {
176
+ this.retryAbortController?.abort();
177
+ this.agent.abort();
178
+ }
179
+
180
+ // --- Agent event → phase translation --------------------------------------
181
+
182
+ private handleAgentEvent(event: AgentEvent): void {
183
+ if (
184
+ event.type === "message_update" &&
185
+ event.assistantMessageEvent?.type === "text_delta"
186
+ ) {
187
+ this.emit({ kind: "stream", delta: event.assistantMessageEvent.delta });
188
+ return;
189
+ }
190
+ if (event.type === "message_end") {
191
+ this.emit({ kind: "messages", messages: [...this.agent.state.messages] });
192
+ return;
193
+ }
194
+ if (event.type === "tool_execution_start") {
195
+ this.emit({ kind: "tool", name: event.toolName, state: "start" });
196
+ return;
197
+ }
198
+ if (event.type === "tool_execution_end") {
199
+ this.emit({ kind: "tool", name: event.toolName, state: "end" });
200
+ // Detection signal: an error result for a tool that is not in the
201
+ // read-only lane (absent tools produce "Tool X not found" errors).
202
+ if (
203
+ this.options.isReadOnlyLane() &&
204
+ event.isError &&
205
+ !this.options.isReadOnlyTool(event.toolName)
206
+ ) {
207
+ this.registerLaneViolation(event.toolName);
208
+ }
209
+ }
210
+ }
211
+
212
+ private emit(phase: TurnPhase): void {
213
+ this.options.onPhase(phase);
214
+ }
215
+
216
+ // --- Lane enforcement ------------------------------------------------------
217
+
218
+ /**
219
+ * 1st violation → base reminder; 2nd → escalated wording + abort-after-inject
220
+ * (the reminder is injected by transformContext before the next LLM call).
221
+ * Texts come from the prompt pack (#13); UI copy stays in the overlay.
222
+ */
223
+ private registerLaneViolation(toolName: string): void {
224
+ this.laneViolations += 1;
225
+ const escalated = this.laneViolations >= 2;
226
+ if (escalated) {
227
+ this.pendingReminder = substituteTemplate(
228
+ this.options.promptPack.laneReminders.escalated,
229
+ { tool: toolName, count: this.laneViolations },
230
+ );
231
+ this.abortAfterInject = true;
232
+ } else {
233
+ this.pendingReminder = substituteTemplate(
234
+ this.options.promptPack.laneReminders.base,
235
+ { tool: toolName },
236
+ );
237
+ }
238
+ this.emit({ kind: "lane", count: this.laneViolations, escalated, tool: toolName });
239
+ }
240
+
241
+ // --- Lane hooks (bound by the runner, never by the overlay) ----------------
242
+
243
+ /**
244
+ * Transient tail injections (present in the LLM request only, never stored in
245
+ * the transcript), texts from the prompt pack: focus anchor (every turn, both
246
+ * modes), lane preamble (read-only lane only), pending lane reminder (after
247
+ * an out-of-lane attempt; escalated violations abort the turn right after the
248
+ * reminder is queued).
249
+ */
250
+ private async transformContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
251
+ const additions: AgentMessage[] = [
252
+ {
253
+ role: "user",
254
+ content: this.options.promptPack.focusAnchor,
255
+ timestamp: Date.now(),
256
+ },
257
+ ];
258
+ if (this.options.isReadOnlyLane()) {
259
+ additions.push({
260
+ role: "user",
261
+ content: this.options.promptPack.laneReminders.preamble,
262
+ timestamp: Date.now(),
263
+ });
264
+ }
265
+ if (this.pendingReminder) {
266
+ const reminder = this.pendingReminder;
267
+ this.pendingReminder = null;
268
+ if (this.abortAfterInject) {
269
+ this.abortAfterInject = false;
270
+ setTimeout(() => this.agent.abort(), 0);
271
+ }
272
+ additions.push({
273
+ role: "user",
274
+ content: reminder,
275
+ timestamp: Date.now(),
276
+ });
277
+ }
278
+ return [...messages, ...additions];
279
+ }
280
+
281
+ /**
282
+ * Belt-and-braces: block any residual present-but-disallowed tool with the
283
+ * base reminder as the reason (blocked calls never reach afterToolCall).
284
+ */
285
+ private async beforeToolCall(
286
+ context: BeforeToolCallContext,
287
+ ): Promise<BeforeToolCallResult | undefined> {
288
+ if (!this.options.isReadOnlyLane()) return undefined;
289
+ if (this.options.isReadOnlyTool(context.toolCall.name)) return undefined;
290
+ return {
291
+ block: true,
292
+ reason: substituteTemplate(this.options.promptPack.laneReminders.base, {
293
+ tool: context.toolCall.name,
294
+ }),
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Layer 2: re-ground executed-but-failed read-only calls (never fires for
300
+ * blocked/absent tools). Not a violation — no escalation count.
301
+ */
302
+ private async afterToolCall(
303
+ context: AfterToolCallContext,
304
+ ): Promise<AfterToolCallResult | undefined> {
305
+ if (!this.options.isReadOnlyLane() || !context.isError) return undefined;
306
+ const content = [...context.result.content];
307
+ if (!content.some((c) => c.type === "text" && c.text.includes("🚧"))) {
308
+ content.push({
309
+ type: "text",
310
+ text: this.options.promptPack.laneReminders.failedNote,
311
+ });
312
+ }
313
+ return { content };
314
+ }
315
+
316
+ // --- Transcript helpers (pi _findLastAssistantMessage / _prepareRetry mirrors) --
317
+
318
+ /**
319
+ * Last assistant message in the fork transcript (pi's
320
+ * `_findLastAssistantMessage` semantics: includes aborted/error ones). The
321
+ * retry loop classifies this result after each attempt.
322
+ */
323
+ private lastAssistantMessage(): RetryableFailure | undefined {
324
+ const messages = this.agent.state.messages;
325
+ for (let i = messages.length - 1; i >= 0; i--) {
326
+ const msg = messages[i];
327
+ if (msg.role === "assistant") return msg as RetryableFailure;
328
+ }
329
+ return undefined;
330
+ }
331
+
332
+ /**
333
+ * pi's `_prepareRetry` cleanup, mirrored: before a retry the failed assistant
334
+ * message is stripped from the transcript so the error never re-enters the
335
+ * next request (and `agent.continue()` can run — it requires a trailing
336
+ * user/toolResult message). Only an error-stop trailing message is removed;
337
+ * a successful prior turn's assistant message is left untouched.
338
+ */
339
+ private removeTrailingAssistantError(): void {
340
+ const messages = this.agent.state.messages;
341
+ const last = messages[messages.length - 1];
342
+ if (last?.role === "assistant" && last.stopReason === "error") {
343
+ this.agent.state.messages = messages.slice(0, -1);
344
+ }
345
+ }
346
+ }
package/srcs/index.ts CHANGED
@@ -14,17 +14,17 @@ import { loadConfig, loadRetryPolicy } from "./config.ts";
14
14
  import { getExtensionDir, loadPromptPack } from "./prompt-pack.ts";
15
15
  import {
16
16
  SideChatOverlay,
17
- SIDE_CHAT_OVERLAY_MARGIN_TOP,
18
- SIDE_CHAT_OVERLAY_MAX_HEIGHT,
19
17
  type ForkContext,
20
18
  } from "./side-chat-overlay.ts";
19
+ import { LAYOUT } from "./overlay-layout.ts";
21
20
  import { SIDE_CHAT_SHORTCUT } from "./shortcuts.ts";
22
21
  import {
23
22
  disableMouseReporting,
24
23
  enableMouseReporting,
24
+ isLeftPress,
25
25
  parseSgrMouseEvent,
26
26
  } from "./side-chat-mouse.ts";
27
- import { extractWritePaths } from "./tool-wrapper.ts";
27
+ import { extractWritePaths } from "./write-paths.ts";
28
28
  // Patch to capture the runner instance for extension tool access in side chat.
29
29
  let capturedRunner: ExtensionRunner | null = null;
30
30
  // Patch once (module reloads re-execute this file; re-patching would nest the
@@ -116,11 +116,9 @@ export default function sideChatExtension(pi: ExtensionAPI) {
116
116
  // A press on the chat focuses the overlay, so the subsequent
117
117
  // Ctrl+C / Ctrl+Shift+C lands in the overlay (not the main editor)
118
118
  // and re-copies the selection.
119
- if (
120
- !event.isRelease &&
121
- (event.button & 3) === 0 &&
122
- (event.button & 32) === 0
123
- ) {
119
+ // A press on the chat focuses the overlay (single classification
120
+ // source: isLeftPress, shared with the gesture module).
121
+ if (isLeftPress(event)) {
124
122
  overlayHandle?.focus();
125
123
  }
126
124
  overlay.handleMouseEvent(event);
@@ -328,13 +326,7 @@ export default function sideChatExtension(pi: ExtensionAPI) {
328
326
  },
329
327
  {
330
328
  overlay: true,
331
- overlayOptions: {
332
- width: "85%",
333
- maxHeight: SIDE_CHAT_OVERLAY_MAX_HEIGHT,
334
- anchor: "top-center",
335
- margin: { top: SIDE_CHAT_OVERLAY_MARGIN_TOP, left: 2, right: 2 },
336
- nonCapturing: true,
337
- },
329
+ overlayOptions: { ...LAYOUT },
338
330
  onHandle: (handle) => {
339
331
  overlayHandle = handle;
340
332
  handle.focus();