@dungle-scrubs/tallow 0.8.13 → 0.8.15

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.
@@ -3,6 +3,66 @@
3
3
  * Extracted for testability — no extension API dependencies.
4
4
  */
5
5
 
6
+ // ── Natural language plan intent detection ──────────────────────────────
7
+
8
+ /**
9
+ * Curated patterns that unambiguously express "enter planning mode".
10
+ * Each pattern uses word boundaries to avoid matching plan-as-noun usage
11
+ * (e.g. "make a plan for X") or questions about plan mode.
12
+ */
13
+ export const PLAN_INTENT_PATTERNS: readonly RegExp[] = [
14
+ // Composite patterns first — avoids partial stripping of overlapping phrases
15
+ /\bthis\s+is\s+plan(ning)?(\s+only)?\b/i,
16
+ /\bplan[\s-]only\b/i,
17
+ /\bjust\s+plan\b/i,
18
+ /\bonly\s+plan\b/i,
19
+ /\bplan\s+mode\b(?!\s*(\?|do|work|mean))/i,
20
+ /\bplanning\s+mode\b(?!\s*(\?|do|work|mean))/i,
21
+ /\bdon['\u2019]?t\s+(implement|code|execute|make\s+changes)\b/i,
22
+ /\bdo\s+not\s+(implement|code|execute|make\s+changes)\b/i,
23
+ /\bno\s+(implementation|changes|coding)\s+(yet|first|for\s+now)\b/i,
24
+ /\bread[\s-]only\s+mode\b/i,
25
+ /\bplan\s+(first|before)\b/i,
26
+ ];
27
+
28
+ /**
29
+ * Detects whether user input contains a strong planning-intent directive.
30
+ *
31
+ * Only matches unambiguous directives like "plan only" or "don't implement".
32
+ * Does NOT match noun usage ("the plan is…") or questions ("what does plan mode do?").
33
+ *
34
+ * @param text - Raw user input
35
+ * @returns true when any plan-intent pattern matches
36
+ */
37
+ export function detectPlanIntent(text: string): boolean {
38
+ return PLAN_INTENT_PATTERNS.some((pattern) => pattern.test(text));
39
+ }
40
+
41
+ /**
42
+ * Strips plan-intent phrases from user input, preserving the actual request.
43
+ *
44
+ * If stripping leaves an empty string (the entire message was just "plan only"),
45
+ * returns the original text so the model has something to work with.
46
+ *
47
+ * @param text - Raw user input
48
+ * @returns Cleaned text with plan-intent phrases removed, or original if nothing remains
49
+ */
50
+ export function stripPlanIntent(text: string): string {
51
+ let stripped = text;
52
+ for (const pattern of PLAN_INTENT_PATTERNS) {
53
+ stripped = stripped.replace(pattern, "");
54
+ }
55
+ // Clean up artifacts: leading/trailing punctuation, double spaces, dangling commas
56
+ stripped = stripped
57
+ .replace(/^[\s,.\-—;:]+/, "")
58
+ .replace(/[\s,.\-—;:]+$/, "")
59
+ .replace(/\s{2,}/g, " ")
60
+ .trim();
61
+ return stripped || text;
62
+ }
63
+
64
+ // ── Bash safety ─────────────────────────────────────────────────────────
65
+
6
66
  /** Patterns for destructive commands that are blocked in plan mode */
7
67
  const DESTRUCTIVE_PATTERNS = [
8
68
  /\brm\b/i,
@@ -0,0 +1,148 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
3
+ import {
4
+ CONTEXT_BUDGET_API_CHANNELS,
5
+ type ContextBudgetEnvelope,
6
+ } from "../../_shared/context-budget-interop.js";
7
+ import webFetchExtension, { type CapResolutionInput, resolveAdaptiveCap } from "../index.js";
8
+
9
+ /** Build a CapResolutionInput with test defaults. */
10
+ function makeInput(overrides: Partial<CapResolutionInput> = {}): CapResolutionInput {
11
+ return {
12
+ defaultMaxBytes: 32 * 1024,
13
+ envelope: undefined,
14
+ policyMax: 512 * 1024,
15
+ policyMin: 4 * 1024,
16
+ userMaxBytes: undefined,
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ /** Build a planner envelope. */
22
+ function makeEnvelope(maxBytes: number, batchSize = 1): ContextBudgetEnvelope {
23
+ return { batchSize, maxBytes };
24
+ }
25
+
26
+ const originalFetch = globalThis.fetch;
27
+
28
+ afterEach(() => {
29
+ globalThis.fetch = originalFetch;
30
+ });
31
+
32
+ describe("resolveAdaptiveCap", () => {
33
+ test("uses strict fallback when no envelope exists", () => {
34
+ const result = resolveAdaptiveCap(makeInput());
35
+ expect(result.effectiveMaxBytes).toBe(32 * 1024);
36
+ expect(result.budgetLimited).toBe(false);
37
+ expect(result.budgetReason).toContain("strict fallback");
38
+ expect(result.batchSize).toBe(1);
39
+ });
40
+
41
+ test("marks budgetLimited when envelope reduces cap", () => {
42
+ const result = resolveAdaptiveCap(makeInput({ envelope: makeEnvelope(8 * 1024, 3) }));
43
+ expect(result.effectiveMaxBytes).toBe(8 * 1024);
44
+ expect(result.budgetLimited).toBe(true);
45
+ expect(result.batchSize).toBe(3);
46
+ });
47
+
48
+ test("clamps envelope to policy max", () => {
49
+ const result = resolveAdaptiveCap(makeInput({ envelope: makeEnvelope(900 * 1024) }));
50
+ expect(result.effectiveMaxBytes).toBe(512 * 1024);
51
+ expect(result.budgetReason).toContain("policy max");
52
+ });
53
+
54
+ test("user maxBytes is a hard upper bound", () => {
55
+ const result = resolveAdaptiveCap(
56
+ makeInput({
57
+ envelope: makeEnvelope(20 * 1024),
58
+ userMaxBytes: 2 * 1024,
59
+ })
60
+ );
61
+ expect(result.effectiveMaxBytes).toBe(2 * 1024);
62
+ expect(result.budgetReason).toContain("user maxBytes");
63
+ });
64
+ });
65
+
66
+ describe("web_fetch planner handshake", () => {
67
+ test("requests planner API and consumes envelope by toolCallId", async () => {
68
+ const harness = ExtensionHarness.create();
69
+ const takeCalls: string[] = [];
70
+ const envelopes = new Map<string, ContextBudgetEnvelope>([["tc-1", makeEnvelope(7 * 1024, 2)]]);
71
+
72
+ harness.eventBus.on(CONTEXT_BUDGET_API_CHANNELS.budgetApiRequest, () => {
73
+ harness.eventBus.emit(CONTEXT_BUDGET_API_CHANNELS.budgetApi, {
74
+ api: {
75
+ take(toolCallId: string): ContextBudgetEnvelope | undefined {
76
+ takeCalls.push(toolCallId);
77
+ const envelope = envelopes.get(toolCallId);
78
+ envelopes.delete(toolCallId);
79
+ return envelope;
80
+ },
81
+ },
82
+ });
83
+ });
84
+
85
+ globalThis.fetch = async () =>
86
+ new Response("x".repeat(20 * 1024), {
87
+ headers: { "content-type": "text/html" },
88
+ status: 200,
89
+ });
90
+
91
+ await harness.loadExtension(webFetchExtension);
92
+ const tool = harness.tools.get("web_fetch");
93
+ if (!tool) throw new Error("web_fetch tool missing");
94
+
95
+ const result = await tool.execute("tc-1", { url: "https://example.com" }, undefined, () => {});
96
+ const details = result.details as {
97
+ effectiveMaxBytes?: number;
98
+ batchSize?: number;
99
+ budgetLimited?: boolean;
100
+ };
101
+
102
+ expect(takeCalls).toEqual(["tc-1"]);
103
+ expect(details.effectiveMaxBytes).toBe(7 * 1024);
104
+ expect(details.batchSize).toBe(2);
105
+ expect(details.budgetLimited).toBe(true);
106
+ });
107
+
108
+ test("consumed envelope falls back on next call", async () => {
109
+ const harness = ExtensionHarness.create();
110
+ const envelopes = new Map<string, ContextBudgetEnvelope>([["tc-1", makeEnvelope(6 * 1024, 2)]]);
111
+
112
+ harness.eventBus.on(CONTEXT_BUDGET_API_CHANNELS.budgetApiRequest, () => {
113
+ harness.eventBus.emit(CONTEXT_BUDGET_API_CHANNELS.budgetApi, {
114
+ api: {
115
+ take(toolCallId: string): ContextBudgetEnvelope | undefined {
116
+ const envelope = envelopes.get(toolCallId);
117
+ envelopes.delete(toolCallId);
118
+ return envelope;
119
+ },
120
+ },
121
+ });
122
+ });
123
+
124
+ globalThis.fetch = async () =>
125
+ new Response("x".repeat(80 * 1024), {
126
+ headers: { "content-type": "text/html" },
127
+ status: 200,
128
+ });
129
+
130
+ await harness.loadExtension(webFetchExtension);
131
+ const tool = harness.tools.get("web_fetch");
132
+ if (!tool) throw new Error("web_fetch tool missing");
133
+
134
+ const first = await tool.execute("tc-1", { url: "https://example.com/1" }, undefined, () => {});
135
+ const second = await tool.execute(
136
+ "tc-1",
137
+ { url: "https://example.com/2" },
138
+ undefined,
139
+ () => {}
140
+ );
141
+
142
+ const firstDetails = first.details as { effectiveMaxBytes?: number };
143
+ const secondDetails = second.details as { effectiveMaxBytes?: number; budgetReason?: string };
144
+ expect(firstDetails.effectiveMaxBytes).toBe(6 * 1024);
145
+ expect(secondDetails.effectiveMaxBytes).toBe(32 * 1024);
146
+ expect(secondDetails.budgetReason).toContain("strict fallback");
147
+ });
148
+ });
@@ -1,28 +1,135 @@
1
1
  /**
2
2
  * WebFetch Extension for Pi
3
3
  *
4
- * Fetches web content via plain HTTP. Returns page text truncated to 50KB.
4
+ * Fetches web content via plain HTTP. Returns page text truncated by context-budget caps.
5
5
  * For JS-rendered pages, full-page scraping, or structured extraction,
6
6
  * use a dedicated scraping tool (e.g. Firecrawl) instead.
7
+ *
8
+ * Supports adaptive context-budget caps when a planner extension publishes
9
+ * envelopes via the shared context-budget interop.
7
10
  */
8
11
 
9
12
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
13
  import { Text } from "@mariozechner/pi-tui";
11
14
  import { Type } from "@sinclair/typebox";
12
15
  import { getIcon } from "../_icons/index.js";
16
+ import {
17
+ CONTEXT_BUDGET_DEFAULTS,
18
+ type ContextBudgetEnvelope,
19
+ subscribeToBudgetApi,
20
+ } from "../_shared/context-budget-interop.js";
13
21
  import { renderLines } from "../tool-display/index.js";
14
22
 
15
- const DEFAULT_MAX_BYTES = 50_000;
23
+ /** Strict fallback cap when no planner envelope is available. */
24
+ const DEFAULT_MAX_BYTES = CONTEXT_BUDGET_DEFAULTS.unknownUsageFallbackCapBytes;
25
+
26
+ /** Policy floor — planner/tool logic should never allocate below this by default. */
27
+ const POLICY_MIN_BYTES = CONTEXT_BUDGET_DEFAULTS.minPerToolBytes;
28
+
29
+ /** Policy ceiling — never exceed this regardless of envelope. */
30
+ const POLICY_MAX_BYTES = CONTEXT_BUDGET_DEFAULTS.maxPerToolBytes;
31
+
32
+ // ── Adaptive cap resolution (pure, exported for tests) ──────────────
33
+
34
+ /** Input parameters for resolving the effective byte cap. */
35
+ export interface CapResolutionInput {
36
+ /** Explicit maxBytes from the user's tool-call parameters. */
37
+ userMaxBytes: number | undefined;
38
+ /** Budget envelope consumed for this tool call (undefined = no planner). */
39
+ envelope: ContextBudgetEnvelope | undefined;
40
+ /** Hard policy floor in bytes. */
41
+ policyMin: number;
42
+ /** Hard policy ceiling in bytes. */
43
+ policyMax: number;
44
+ /** Fallback cap when no envelope is present. */
45
+ defaultMaxBytes: number;
46
+ }
47
+
48
+ /** Resolved cap with diagnostics. */
49
+ export interface CapResolutionResult {
50
+ /** Final byte cap to apply to the fetch response. */
51
+ effectiveMaxBytes: number;
52
+ /** True when the planner envelope reduced the cap below what the user would have gotten. */
53
+ budgetLimited: boolean;
54
+ /** Human-readable explanation of how the cap was chosen. */
55
+ budgetReason: string;
56
+ /** Batch size from the envelope (1 when no envelope). */
57
+ batchSize: number;
58
+ }
59
+
60
+ /**
61
+ * Resolve the effective maxBytes cap from all inputs.
62
+ *
63
+ * Priority chain:
64
+ * 1. Start from envelope maxBytes (or defaultMaxBytes when absent).
65
+ * 2. User maxBytes is a hard upper bound — cap cannot exceed it.
66
+ * 3. Clamp into [policyMin, policyMax].
67
+ *
68
+ * @param input - Cap resolution parameters
69
+ * @returns Resolved cap with diagnostic metadata
70
+ */
71
+ export function resolveAdaptiveCap(input: CapResolutionInput): CapResolutionResult {
72
+ const { userMaxBytes, envelope, policyMin, policyMax, defaultMaxBytes } = input;
73
+
74
+ const batchSize = envelope?.batchSize ?? 1;
75
+ const userCap = userMaxBytes ?? Number.POSITIVE_INFINITY;
76
+
77
+ // Step 1: base cap comes from planner envelope or strict fallback.
78
+ const base = envelope?.maxBytes ?? defaultMaxBytes;
79
+ let reason = envelope
80
+ ? `planner envelope (${base} bytes, batch ${batchSize})`
81
+ : `strict fallback (${defaultMaxBytes} bytes)`;
82
+
83
+ // Step 2: clamp planner/fallback cap into policy bounds.
84
+ let effective = Math.min(policyMax, Math.max(policyMin, base));
85
+ if (effective !== base) {
86
+ reason +=
87
+ effective < base
88
+ ? ` → capped by policy max (${policyMax})`
89
+ : ` → raised to policy min (${policyMin})`;
90
+ }
91
+
92
+ // Step 3: explicit user maxBytes is a hard upper bound.
93
+ if (Number.isFinite(userCap) && effective > userCap) {
94
+ effective = userCap;
95
+ reason += ` → capped by user maxBytes (${userCap})`;
96
+ }
97
+
98
+ const withoutEnvelope = Number.isFinite(userCap)
99
+ ? Math.min(userCap, Math.min(policyMax, Math.max(policyMin, defaultMaxBytes)))
100
+ : Math.min(policyMax, Math.max(policyMin, defaultMaxBytes));
101
+ const budgetLimited = envelope !== undefined && effective < withoutEnvelope;
102
+
103
+ return { effectiveMaxBytes: effective, budgetLimited, budgetReason: reason, batchSize };
104
+ }
105
+
106
+ /**
107
+ * Truncate text to a maximum UTF-8 byte length.
108
+ *
109
+ * @param text - Source text
110
+ * @param maxBytes - Maximum number of UTF-8 bytes to keep
111
+ * @returns Truncated text at a valid character boundary
112
+ */
113
+ function truncateTextToBytes(text: string, maxBytes: number): string {
114
+ if (Buffer.byteLength(text, "utf-8") <= maxBytes) return text;
115
+ let end = Math.min(text.length, maxBytes);
116
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf-8") > maxBytes) {
117
+ end -= 1;
118
+ }
119
+ return text.slice(0, end);
120
+ }
16
121
 
17
122
  /**
18
123
  * Registers the web_fetch tool.
19
124
  * @param pi - Extension API for registering tools
20
125
  */
21
126
  export default function (pi: ExtensionAPI) {
127
+ const getBudgetApi = subscribeToBudgetApi(pi.events);
128
+
22
129
  pi.registerTool({
23
130
  name: "web_fetch",
24
131
  label: "web_fetch",
25
- description: `Fetch content from a URL. Returns the page text, truncated to 50KB by default.
132
+ description: `Fetch content from a URL. Returns page text truncated by context-budget policy (conservative fallback when budget is unknown).
26
133
 
27
134
  WHEN TO USE:
28
135
  - Need to read web page content
@@ -31,7 +138,10 @@ WHEN TO USE:
31
138
  parameters: Type.Object({
32
139
  url: Type.String({ description: "URL to fetch" }),
33
140
  maxBytes: Type.Optional(
34
- Type.Number({ description: "Max bytes before truncation (default 50KB)" })
141
+ Type.Number({
142
+ description:
143
+ "Max bytes before truncation (hard upper bound; may be reduced by context budget)",
144
+ })
35
145
  ),
36
146
  format: Type.Optional(
37
147
  Type.Union([Type.Literal("text"), Type.Literal("markdown"), Type.Literal("html")], {
@@ -40,8 +150,20 @@ WHEN TO USE:
40
150
  ),
41
151
  }),
42
152
 
43
- async execute(_toolCallId, params, signal, _onUpdate) {
44
- const maxBytes = params.maxBytes ?? DEFAULT_MAX_BYTES;
153
+ async execute(toolCallId, params, signal, _onUpdate) {
154
+ // ── Adaptive cap ────────────────────────────────────
155
+ const budgetApi = getBudgetApi();
156
+ const envelope = budgetApi?.take(toolCallId) ?? undefined;
157
+
158
+ const { effectiveMaxBytes, budgetLimited, budgetReason, batchSize } = resolveAdaptiveCap({
159
+ userMaxBytes: params.maxBytes,
160
+ envelope,
161
+ policyMin: POLICY_MIN_BYTES,
162
+ policyMax: POLICY_MAX_BYTES,
163
+ defaultMaxBytes: DEFAULT_MAX_BYTES,
164
+ });
165
+
166
+ const maxBytes = effectiveMaxBytes;
45
167
 
46
168
  try {
47
169
  const response = await fetch(params.url, {
@@ -62,10 +184,10 @@ WHEN TO USE:
62
184
 
63
185
  const contentType = response.headers.get("content-type") || "";
64
186
  const fullText = await response.text();
65
- const totalBytes = new TextEncoder().encode(fullText).length;
187
+ const totalBytes = Buffer.byteLength(fullText, "utf-8");
66
188
  const truncated = totalBytes > maxBytes;
67
189
 
68
- let content = truncated ? fullText.slice(0, maxBytes) : fullText;
190
+ let content = truncated ? truncateTextToBytes(fullText, maxBytes) : fullText;
69
191
 
70
192
  if (truncated) {
71
193
  content += `\n\n[Truncated: showing ${(maxBytes / 1024).toFixed(1)}KB of ${(totalBytes / 1024).toFixed(1)}KB]`;
@@ -79,6 +201,10 @@ WHEN TO USE:
79
201
  contentType,
80
202
  totalBytes,
81
203
  truncated,
204
+ effectiveMaxBytes,
205
+ budgetLimited,
206
+ budgetReason,
207
+ batchSize,
82
208
  },
83
209
  };
84
210
  } catch (error: unknown) {
@@ -106,6 +232,10 @@ WHEN TO USE:
106
232
  isError?: boolean;
107
233
  totalBytes?: number;
108
234
  truncated?: boolean;
235
+ effectiveMaxBytes?: number;
236
+ budgetLimited?: boolean;
237
+ budgetReason?: string;
238
+ batchSize?: number;
109
239
  }
110
240
  | undefined;
111
241
  if (!details) {
@@ -120,10 +250,11 @@ WHEN TO USE:
120
250
  } else {
121
251
  const size = details.totalBytes ? ` (${(details.totalBytes / 1024).toFixed(1)}KB)` : "";
122
252
  const truncNote = details.truncated ? " [truncated]" : "";
253
+ const budgetNote = details.budgetLimited ? " [budget-limited]" : "";
123
254
  footer =
124
255
  theme.fg("success", `${getIcon("success")} `) +
125
256
  theme.fg("dim", details.url ?? "") +
126
- theme.fg("muted", size + truncNote);
257
+ theme.fg("muted", size + truncNote + budgetNote);
127
258
  }
128
259
 
129
260
  // Expanded: content preview first, footer last
@@ -5,6 +5,8 @@ import weztermPaneControl, {
5
5
  buildWeztermPaneGuidance,
6
6
  executeWeztermAction,
7
7
  filterPanesToCurrentTab,
8
+ hasExplicitPaneRequest,
9
+ isPaneCreatingAction,
8
10
  type WeztermCliResult,
9
11
  type WeztermPaneInfo,
10
12
  } from "../index.js";
@@ -91,17 +93,36 @@ describe("wezterm-pane-control registration", () => {
91
93
  });
92
94
 
93
95
  describe("buildWeztermPaneGuidance", () => {
94
- it("includes privacy and manual-monitoring guidance", () => {
96
+ it("includes privacy and explicit-pane-request guidance", () => {
95
97
  const guidance = buildWeztermPaneGuidance(116);
96
98
  expect(guidance).toContain("Do not run or read commands likely to reveal private secrets");
99
+ expect(guidance).toContain("Do not split panes or spawn tabs unless the user explicitly asks");
100
+ expect(guidance).toContain("prefer bg_bash in the current session");
97
101
  expect(guidance).toContain("Default behavior: if you prefill a command");
98
102
  expect(guidance).toContain("Only leave a command unexecuted");
99
- expect(guidance).toContain("user wants to monitor output themselves");
103
+ expect(guidance).toContain("user explicitly asks to monitor output in another pane");
100
104
  expect(guidance).toContain("newline (\\n) via send_text");
101
105
  expect(guidance).toContain("WezTerm pane 116");
102
106
  });
103
107
  });
104
108
 
109
+ describe("explicit pane-request guards", () => {
110
+ it("detects explicit pane/tab intent in prompt text", () => {
111
+ expect(hasExplicitPaneRequest("split a pane to the right and run pnpm dev")).toBe(true);
112
+ expect(hasExplicitPaneRequest("open a new tab for logs")).toBe(true);
113
+ expect(hasExplicitPaneRequest("start dev server")).toBe(false);
114
+ expect(hasExplicitPaneRequest("run tests in background")).toBe(false);
115
+ });
116
+
117
+ it("identifies pane-creating actions", () => {
118
+ expect(isPaneCreatingAction("split")).toBe(true);
119
+ expect(isPaneCreatingAction("spawn_tab")).toBe(true);
120
+ expect(isPaneCreatingAction("move_to_tab")).toBe(true);
121
+ expect(isPaneCreatingAction("read_text")).toBe(false);
122
+ expect(isPaneCreatingAction(undefined)).toBe(false);
123
+ });
124
+ });
125
+
105
126
  describe("filterPanesToCurrentTab", () => {
106
127
  it("returns only panes from the active tab", () => {
107
128
  const panes = [
@@ -110,6 +110,50 @@ const DIRECTIONS: readonly WeztermDirection[] = [
110
110
 
111
111
  const ZOOM_STATES: readonly WeztermZoomState[] = ["zoom", "unzoom", "toggle"] as const;
112
112
 
113
+ const PANE_CREATING_ACTIONS: readonly WeztermAction[] = [
114
+ "split",
115
+ "spawn_tab",
116
+ "move_to_tab",
117
+ ] as const;
118
+
119
+ const EXPLICIT_PANE_REQUEST_PATTERNS: readonly RegExp[] = [
120
+ /\bwezterm\b/i,
121
+ /\bpane(?:s)?\b/i,
122
+ /\btab(?:s)?\b/i,
123
+ /\bsplit\b/i,
124
+ /\bspawn\b/i,
125
+ /\bwindow\b/i,
126
+ /\bleft\b/i,
127
+ /\bright\b/i,
128
+ /\btop\b/i,
129
+ /\bbottom\b/i,
130
+ ] as const;
131
+
132
+ /**
133
+ * Check whether an action creates or rehomes panes/tabs.
134
+ *
135
+ * @param action - Candidate action string
136
+ * @returns True when action can open/split/move panes or tabs
137
+ */
138
+ export function isPaneCreatingAction(action: unknown): action is WeztermAction {
139
+ if (typeof action !== "string") return false;
140
+ return PANE_CREATING_ACTIONS.includes(action as WeztermAction);
141
+ }
142
+
143
+ /**
144
+ * Determine whether the user explicitly requested pane/tab management.
145
+ *
146
+ * This acts as a conservative guardrail: opening/splitting panes should only
147
+ * happen when the current turn clearly references pane/tab/window controls.
148
+ *
149
+ * @param prompt - Current user prompt text
150
+ * @returns True when prompt contains explicit pane/tab intent
151
+ */
152
+ export function hasExplicitPaneRequest(prompt: string): boolean {
153
+ if (prompt.trim().length === 0) return false;
154
+ return EXPLICIT_PANE_REQUEST_PATTERNS.some((pattern) => pattern.test(prompt));
155
+ }
156
+
113
157
  /**
114
158
  * Parse WEZTERM_PANE to a valid pane ID.
115
159
  *
@@ -405,9 +449,11 @@ export function buildWeztermPaneGuidance(currentPaneId: number): string {
405
449
  "",
406
450
  "Use best judgment before controlling panes:",
407
451
  "- Do not run or read commands likely to reveal private secrets (keys, tokens, credentials) unless the user explicitly asks.",
452
+ "- Do not split panes or spawn tabs unless the user explicitly asks for pane/tab management.",
453
+ "- For long-running commands (dev servers, watchers), prefer bg_bash in the current session unless the user explicitly asks to run them in another pane/tab.",
408
454
  "- Default behavior: if you prefill a command for the user, execute it automatically by sending Enter (newline, \\n).",
409
455
  "- Only leave a command unexecuted when the user explicitly asks to review/edit it before running.",
410
- "- If the user wants to monitor output themselves, you can still execute the command and let them watch the pane output directly.",
456
+ "- If the user explicitly asks to monitor output in another pane, execute the command and let them watch there.",
411
457
  "- Enter can be pressed by sending a newline (\\n) via send_text (either appended to the command or as a second send_text call).",
412
458
  ].join("\n");
413
459
  }
@@ -597,6 +643,23 @@ export default function weztermPaneControl(pi: ExtensionAPI): void {
597
643
  }
598
644
 
599
645
  const runCli = createWeztermRunner(weztermExecutable);
646
+ let currentTurnPrompt = "";
647
+
648
+ pi.on("tool_call", async (event) => {
649
+ if (event.toolName !== "wezterm_pane") return;
650
+
651
+ const input = (event.input ?? {}) as Record<string, unknown>;
652
+ const action = input.action;
653
+ if (!isPaneCreatingAction(action)) return;
654
+ if (hasExplicitPaneRequest(currentTurnPrompt)) return;
655
+
656
+ return {
657
+ block: true,
658
+ reason:
659
+ "Opening/splitting WezTerm panes requires an explicit pane/tab request. " +
660
+ "Use bg_bash for dev servers/watchers unless the user asks for another pane.",
661
+ };
662
+ });
600
663
 
601
664
  pi.registerTool({
602
665
  name: "wezterm_pane",
@@ -648,6 +711,7 @@ export default function weztermPaneControl(pi: ExtensionAPI): void {
648
711
  });
649
712
 
650
713
  pi.on("before_agent_start", async (event) => {
714
+ currentTurnPrompt = event.prompt;
651
715
  return {
652
716
  systemPrompt: `${event.systemPrompt}\n\n${buildWeztermPaneGuidance(currentPaneId)}`,
653
717
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dungle-scrubs/tallow",
3
- "version": "0.8.13",
3
+ "version": "0.8.15",
4
4
  "description": "An opinionated coding agent. Built on pi.",
5
5
  "piConfig": {
6
6
  "name": "tallow",
@@ -67,7 +67,7 @@
67
67
  "dependencies": {
68
68
  "@clack/prompts": "^1.0.0",
69
69
  "@dungle-scrubs/synapse": "0.1.6",
70
- "@mariozechner/pi-coding-agent": "^0.55.1",
70
+ "@mariozechner/pi-coding-agent": "^0.55.4",
71
71
  "@sinclair/typebox": "0.34.48",
72
72
  "ai": "^6.0.86",
73
73
  "commander": "^14.0.3",
@@ -76,8 +76,8 @@
76
76
  },
77
77
  "devDependencies": {
78
78
  "@biomejs/biome": "2.4.2",
79
- "@mariozechner/pi-agent-core": "^0.55.1",
80
- "@mariozechner/pi-ai": "^0.55.1",
79
+ "@mariozechner/pi-agent-core": "^0.55.4",
80
+ "@mariozechner/pi-ai": "^0.55.4",
81
81
  "@mariozechner/pi-tui": "workspace:*",
82
82
  "@types/node": "25.2.3",
83
83
  "husky": "^9.1.7",
@@ -87,6 +87,7 @@ Extensions export a default function receiving `ExtensionAPI` (conventionally na
87
87
  - `setModel(model: Model<any>)` — Set the current model.
88
88
  - `getThinkingLevel()` — Get current thinking level.
89
89
  - `setThinkingLevel(level: ThinkingLevel)` — Set thinking level (clamped to model capabilities).
90
+ - `unregisterProvider(name: string)` — Unregister a previously registered provider.
90
91
  - `events` — Shared event bus for extension communication.
91
92
 
92
93
  ### Events (`pi.on(event, handler)`)