gsd-pi 2.36.0 → 2.37.0-dev.68605cd

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.
Files changed (71) hide show
  1. package/dist/resources/extensions/cmux/index.js +321 -0
  2. package/dist/resources/extensions/cmux/package.json +7 -0
  3. package/dist/resources/extensions/gsd/auto-dashboard.js +334 -104
  4. package/dist/resources/extensions/gsd/auto-loop.js +29 -4
  5. package/dist/resources/extensions/gsd/auto.js +58 -5
  6. package/dist/resources/extensions/gsd/commands-cmux.js +120 -0
  7. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  8. package/dist/resources/extensions/gsd/commands.js +131 -34
  9. package/dist/resources/extensions/gsd/docs/preferences-reference.md +25 -0
  10. package/dist/resources/extensions/gsd/git-service.js +9 -1
  11. package/dist/resources/extensions/gsd/history.js +2 -1
  12. package/dist/resources/extensions/gsd/index.js +5 -0
  13. package/dist/resources/extensions/gsd/metrics.js +4 -2
  14. package/dist/resources/extensions/gsd/notifications.js +10 -1
  15. package/dist/resources/extensions/gsd/preferences-types.js +2 -0
  16. package/dist/resources/extensions/gsd/preferences-validation.js +29 -0
  17. package/dist/resources/extensions/gsd/preferences.js +3 -0
  18. package/dist/resources/extensions/gsd/prompts/research-milestone.md +4 -3
  19. package/dist/resources/extensions/gsd/prompts/research-slice.md +3 -2
  20. package/dist/resources/extensions/gsd/session-lock.js +26 -6
  21. package/dist/resources/extensions/gsd/templates/preferences.md +6 -0
  22. package/dist/resources/extensions/search-the-web/native-search.js +45 -4
  23. package/dist/resources/extensions/shared/format-utils.js +5 -41
  24. package/dist/resources/extensions/shared/layout-utils.js +46 -0
  25. package/dist/resources/extensions/shared/mod.js +2 -1
  26. package/dist/resources/extensions/shared/terminal.js +5 -0
  27. package/dist/resources/extensions/subagent/index.js +180 -60
  28. package/package.json +1 -1
  29. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  30. package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -4
  31. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  32. package/packages/pi-coding-agent/package.json +1 -1
  33. package/packages/pi-coding-agent/src/core/extensions/loader.ts +8 -4
  34. package/packages/pi-tui/dist/terminal-image.d.ts.map +1 -1
  35. package/packages/pi-tui/dist/terminal-image.js +4 -0
  36. package/packages/pi-tui/dist/terminal-image.js.map +1 -1
  37. package/packages/pi-tui/src/terminal-image.ts +5 -0
  38. package/pkg/package.json +1 -1
  39. package/src/resources/extensions/cmux/index.ts +384 -0
  40. package/src/resources/extensions/cmux/package.json +7 -0
  41. package/src/resources/extensions/gsd/auto-dashboard.ts +363 -116
  42. package/src/resources/extensions/gsd/auto-loop.ts +66 -6
  43. package/src/resources/extensions/gsd/auto.ts +77 -5
  44. package/src/resources/extensions/gsd/commands-cmux.ts +143 -0
  45. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  46. package/src/resources/extensions/gsd/commands.ts +139 -32
  47. package/src/resources/extensions/gsd/docs/preferences-reference.md +25 -0
  48. package/src/resources/extensions/gsd/git-service.ts +12 -1
  49. package/src/resources/extensions/gsd/history.ts +2 -1
  50. package/src/resources/extensions/gsd/index.ts +8 -0
  51. package/src/resources/extensions/gsd/metrics.ts +4 -2
  52. package/src/resources/extensions/gsd/notifications.ts +10 -1
  53. package/src/resources/extensions/gsd/preferences-types.ts +13 -0
  54. package/src/resources/extensions/gsd/preferences-validation.ts +26 -0
  55. package/src/resources/extensions/gsd/preferences.ts +4 -0
  56. package/src/resources/extensions/gsd/prompts/research-milestone.md +4 -3
  57. package/src/resources/extensions/gsd/prompts/research-slice.md +3 -2
  58. package/src/resources/extensions/gsd/session-lock.ts +41 -6
  59. package/src/resources/extensions/gsd/templates/preferences.md +6 -0
  60. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +39 -1
  61. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +19 -0
  62. package/src/resources/extensions/gsd/tests/cmux.test.ts +122 -0
  63. package/src/resources/extensions/gsd/tests/preferences.test.ts +23 -0
  64. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +45 -0
  65. package/src/resources/extensions/search-the-web/native-search.ts +50 -4
  66. package/src/resources/extensions/shared/format-utils.ts +5 -44
  67. package/src/resources/extensions/shared/layout-utils.ts +49 -0
  68. package/src/resources/extensions/shared/mod.ts +7 -4
  69. package/src/resources/extensions/shared/terminal.ts +5 -0
  70. package/src/resources/extensions/shared/tests/format-utils.test.ts +5 -3
  71. package/src/resources/extensions/subagent/index.ts +236 -79
@@ -173,6 +173,13 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
173
173
  - `on_milestone`: boolean — notify when a milestone finishes. Default: `true`.
174
174
  - `on_attention`: boolean — notify when manual attention is needed. Default: `true`.
175
175
 
176
+ - `cmux`: configures cmux terminal integration when GSD is running inside a cmux workspace. Keys:
177
+ - `enabled`: boolean — master toggle for cmux integration. Default: `false`.
178
+ - `notifications`: boolean — route desktop notifications through cmux. Default: `true` when enabled.
179
+ - `sidebar`: boolean — publish status, progress, and log metadata to the cmux sidebar. Default: `true` when enabled.
180
+ - `splits`: boolean — run supported subagent work in visible cmux splits. Default: `false`.
181
+ - `browser`: boolean — reserve the future browser integration flag. Default: `false`.
182
+
176
183
  - `dynamic_routing`: configures the dynamic model router that adjusts model selection based on task complexity. Keys:
177
184
  - `enabled`: boolean — enable dynamic routing. Default: `false`.
178
185
  - `tier_models`: object — model overrides per complexity tier. Keys: `light`, `standard`, `heavy`. Values are model ID strings.
@@ -477,6 +484,24 @@ Disables per-unit completion notifications (noisy in long runs) while keeping er
477
484
 
478
485
  ---
479
486
 
487
+ ## cmux Example
488
+
489
+ ```yaml
490
+ ---
491
+ version: 1
492
+ cmux:
493
+ enabled: true
494
+ notifications: true
495
+ sidebar: true
496
+ splits: true
497
+ browser: false
498
+ ---
499
+ ```
500
+
501
+ Enables cmux-aware notifications, sidebar metadata, and visible subagent splits when GSD is running inside a cmux terminal.
502
+
503
+ ---
504
+
480
505
  ## Post-Unit Hooks Example
481
506
 
482
507
  ```yaml
@@ -349,10 +349,18 @@ export class GitServiceImpl {
349
349
  }
350
350
  const wtName = detectWorktreeName(this.basePath);
351
351
  if (wtName) {
352
+ // Auto-mode worktrees use milestone/<MID> branches (wtName = milestone ID)
353
+ const milestoneBranch = `milestone/${wtName}`;
354
+ const currentBranch = nativeGetCurrentBranch(this.basePath);
355
+ // If we're on a milestone/<MID> branch, use it (auto-mode case)
356
+ if (currentBranch.startsWith("milestone/")) {
357
+ return currentBranch;
358
+ }
359
+ // Otherwise check for manual worktree branch (worktree/<name>)
352
360
  const wtBranch = `worktree/${wtName}`;
353
361
  if (nativeBranchExists(this.basePath, wtBranch))
354
362
  return wtBranch;
355
- return nativeGetCurrentBranch(this.basePath);
363
+ return currentBranch;
356
364
  }
357
365
  // Repo-level default detection: origin/HEAD → main → master → current branch.
358
366
  // Native path uses libgit2 (single call), fallback spawns multiple git processes.
@@ -1,6 +1,7 @@
1
1
  // GSD Extension — Session History View
2
2
  // Human-readable display of past auto-mode unit executions.
3
- import { formatDuration, padRight, truncateWithEllipsis } from "../shared/format-utils.js";
3
+ import { formatDuration, truncateWithEllipsis } from "../shared/format-utils.js";
4
+ import { padRight } from "../shared/layout-utils.js";
4
5
  import { getLedger, getProjectTotals, formatCost, formatTokenCount, aggregateBySlice, aggregateByPhase, aggregateByModel, loadLedgerFromDisk, } from "./metrics.js";
5
6
  /**
6
7
  * Show recent unit execution history with cost, tokens, and duration.
@@ -46,6 +46,7 @@ import { pauseAutoForProviderError, classifyProviderError } from "./provider-err
46
46
  import { toPosixPath } from "../shared/mod.js";
47
47
  import { isParallelActive, shutdownParallel } from "./parallel-orchestrator.js";
48
48
  import { DEFAULT_BASH_TIMEOUT_SECS } from "./constants.js";
49
+ import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../cmux/index.js";
49
50
  // ── Agent Instructions (DEPRECATED) ──────────────────────────────────────
50
51
  // agent-instructions.md is deprecated. Use AGENTS.md or CLAUDE.md instead.
51
52
  // Pi core natively supports AGENTS.md (with CLAUDE.md fallback) per directory.
@@ -532,6 +533,10 @@ export default function (pi) {
532
533
  const stopContextTimer = debugTime("context-inject");
533
534
  const systemContent = loadPrompt("system");
534
535
  const loadedPreferences = loadEffectiveGSDPreferences();
536
+ if (shouldPromptToEnableCmux(loadedPreferences?.preferences)) {
537
+ markCmuxPromptShown();
538
+ ctx.ui.notify("cmux detected. Run /gsd cmux on to enable sidebar metadata, notifications, and visual subagent splits for this project.", "info");
539
+ }
535
540
  let preferenceBlock = "";
536
541
  if (loadedPreferences) {
537
542
  const cwd = process.cwd();
@@ -17,8 +17,10 @@ import { gsdRoot } from "./paths.js";
17
17
  import { getAndClearSkills } from "./skill-telemetry.js";
18
18
  import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
19
19
  import { parseUnitId } from "./unit-id.js";
20
- // Re-export from shared — canonical implementation lives in format-utils.
21
- export { formatTokenCount } from "../shared/mod.js";
20
+ // Re-export from shared — import directly from format-utils to avoid pulling
21
+ // in the full barrel (mod.js → ui.js → @gsd/pi-tui) which breaks when loaded
22
+ // outside jiti's alias resolution (e.g. dynamic import in auto-loop reports).
23
+ export { formatTokenCount } from "../shared/format-utils.js";
22
24
  export function classifyUnitPhase(unitType) {
23
25
  switch (unitType) {
24
26
  case "research-milestone":
@@ -2,13 +2,22 @@
2
2
  // Cross-platform desktop notifications for auto-mode events.
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { loadEffectiveGSDPreferences } from "./preferences.js";
5
+ import { CmuxClient, emitOsc777Notification, resolveCmuxConfig } from "../cmux/index.js";
5
6
  /**
6
7
  * Send a native desktop notification. Non-blocking, non-fatal.
7
8
  * macOS: osascript, Linux: notify-send, Windows: skipped.
8
9
  */
9
10
  export function sendDesktopNotification(title, message, level = "info", kind = "complete") {
10
- if (!shouldSendDesktopNotification(kind))
11
+ const loaded = loadEffectiveGSDPreferences()?.preferences;
12
+ if (!shouldSendDesktopNotification(kind, loaded?.notifications))
11
13
  return;
14
+ const cmux = resolveCmuxConfig(loaded);
15
+ if (cmux.notifications) {
16
+ const delivered = CmuxClient.fromPreferences(loaded).notify(title, message);
17
+ if (delivered)
18
+ return;
19
+ emitOsc777Notification(title, message);
20
+ }
12
21
  try {
13
22
  const command = buildDesktopNotificationCommand(process.platform, title, message, level);
14
23
  if (!command)
@@ -47,6 +47,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([
47
47
  "budget_enforcement",
48
48
  "context_pause_threshold",
49
49
  "notifications",
50
+ "cmux",
50
51
  "remote_questions",
51
52
  "git",
52
53
  "post_unit_hooks",
@@ -63,6 +64,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([
63
64
  "search_provider",
64
65
  "compression_strategy",
65
66
  "context_selection",
67
+ "widget_mode",
66
68
  ]);
67
69
  /** Canonical list of all dispatch unit types. */
68
70
  export const KNOWN_UNIT_TYPES = [
@@ -225,6 +225,35 @@ export function validatePreferences(preferences) {
225
225
  errors.push("notifications must be an object");
226
226
  }
227
227
  }
228
+ // ─── Cmux ───────────────────────────────────────────────────────────────
229
+ if (preferences.cmux !== undefined) {
230
+ if (preferences.cmux && typeof preferences.cmux === "object") {
231
+ const cmux = preferences.cmux;
232
+ const validatedCmux = {};
233
+ if (cmux.enabled !== undefined)
234
+ validatedCmux.enabled = !!cmux.enabled;
235
+ if (cmux.notifications !== undefined)
236
+ validatedCmux.notifications = !!cmux.notifications;
237
+ if (cmux.sidebar !== undefined)
238
+ validatedCmux.sidebar = !!cmux.sidebar;
239
+ if (cmux.splits !== undefined)
240
+ validatedCmux.splits = !!cmux.splits;
241
+ if (cmux.browser !== undefined)
242
+ validatedCmux.browser = !!cmux.browser;
243
+ const knownCmuxKeys = new Set(["enabled", "notifications", "sidebar", "splits", "browser"]);
244
+ for (const key of Object.keys(cmux)) {
245
+ if (!knownCmuxKeys.has(key)) {
246
+ warnings.push(`unknown cmux key "${key}" — ignored`);
247
+ }
248
+ }
249
+ if (Object.keys(validatedCmux).length > 0) {
250
+ validated.cmux = validatedCmux;
251
+ }
252
+ }
253
+ else {
254
+ errors.push("cmux must be an object");
255
+ }
256
+ }
228
257
  // ─── Remote Questions ───────────────────────────────────────────────
229
258
  if (preferences.remote_questions !== undefined) {
230
259
  if (preferences.remote_questions && typeof preferences.remote_questions === "object") {
@@ -174,6 +174,9 @@ function mergePreferences(base, override) {
174
174
  notifications: (base.notifications || override.notifications)
175
175
  ? { ...(base.notifications ?? {}), ...(override.notifications ?? {}) }
176
176
  : undefined,
177
+ cmux: (base.cmux || override.cmux)
178
+ ? { ...(base.cmux ?? {}), ...(override.cmux ?? {}) }
179
+ : undefined,
177
180
  remote_questions: override.remote_questions
178
181
  ? { ...(base.remote_questions ?? {}), ...override.remote_questions }
179
182
  : base.remote_questions,
@@ -25,9 +25,10 @@ Then research the codebase and relevant technologies. Narrate key findings and s
25
25
  2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}}
26
26
  3. Explore relevant code. For small/familiar codebases, use `rg`, `find`, and targeted reads. For large or unfamiliar codebases, use `scout` to build a broad map efficiently before diving in.
27
27
  4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries — skip this for libraries already used in the codebase
28
- 5. Use the **Research** output template from the inlined context above include only sections that have real content
29
- 6. If `.gsd/REQUIREMENTS.md` exists, research against it. Identify which Active requirements are table stakes, likely omissions, overbuilt risks, or domain-standard behaviors the user may or may not want.
30
- 7. Write `{{outputPath}}`
28
+ 5. **Web search budget:** You have a limited budget of web searches (max ~15 per session). Use them strategically prefer `resolve_library` / `get_library_docs` for library documentation. Do NOT repeat the same or similar queries. If a search didn't find what you need, rephrase once or move on. Target 3-5 total web searches for a typical research unit.
29
+ 6. Use the **Research** output template from the inlined context above include only sections that have real content
30
+ 7. If `.gsd/REQUIREMENTS.md` exists, research against it. Identify which Active requirements are table stakes, likely omissions, overbuilt risks, or domain-standard behaviors the user may or may not want.
31
+ 8. Write `{{outputPath}}`
31
32
 
32
33
  ## Strategic Questions to Answer
33
34
 
@@ -46,8 +46,9 @@ Research what this slice needs. Narrate key findings and surprises as you go —
46
46
  2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}}
47
47
  3. Explore relevant code for this slice's scope. For targeted exploration, use `rg`, `find`, and reads. For broad or unfamiliar subsystems, use `scout` to map the relevant area first.
48
48
  4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries — skip this for libraries already used in the codebase
49
- 5. Use the **Research** output template from the inlined context aboveinclude only sections that have real content. The template is already inlined above; do NOT attempt to read any template file from disk (there is no `templates/SLICE-RESEARCH.md` the correct template is already present in this prompt).
50
- 6. Write `{{outputPath}}`
49
+ 5. **Web search budget:** You have a limited budget of web searches (max ~15 per session). Use them strategically prefer `resolve_library` / `get_library_docs` for library documentation. Do NOT repeat the same or similar queries. If a search didn't find what you need, rephrase once or move on. Target 3-5 total web searches for a typical research unit.
50
+ 6. Use the **Research** output template from the inlined context above — include only sections that have real content. The template is already inlined above; do NOT attempt to read any template file from disk (there is no `templates/SLICE-RESEARCH.md` — the correct template is already present in this prompt).
51
+ 7. Write `{{outputPath}}`
51
52
 
52
53
  The slice directory already exists at `{{slicePath}}/`. Do NOT mkdir — just write the file.
53
54
 
@@ -320,7 +320,7 @@ export function updateSessionLock(basePath, unitType, unitId, completedUnits, se
320
320
  *
321
321
  * This is called periodically during the dispatch loop.
322
322
  */
323
- export function validateSessionLock(basePath) {
323
+ export function getSessionLockStatus(basePath) {
324
324
  // Lock was compromised by proper-lockfile (mtime drift from sleep, stall, etc.)
325
325
  if (_lockCompromised) {
326
326
  // Recovery gate (#1512): Before declaring the lock lost, check if the lock
@@ -335,27 +335,47 @@ export function validateSessionLock(basePath) {
335
335
  const result = acquireSessionLock(basePath);
336
336
  if (result.acquired) {
337
337
  process.stderr.write(`[gsd] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`);
338
- return true;
338
+ return { valid: true, recovered: true };
339
339
  }
340
340
  }
341
341
  catch {
342
342
  // Re-acquisition failed — fall through to return false
343
343
  }
344
344
  }
345
- return false;
345
+ return {
346
+ valid: false,
347
+ failureReason: "compromised",
348
+ existingPid: existing?.pid,
349
+ expectedPid: process.pid,
350
+ };
346
351
  }
347
352
  // If we have an OS-level lock, we're still the owner
348
353
  if (_releaseFunction && _lockedPath === basePath) {
349
- return true;
354
+ return { valid: true };
350
355
  }
351
356
  // Fallback: check the lock file PID
352
357
  const lp = lockPath(basePath);
353
358
  const existing = readExistingLockData(lp);
354
359
  if (!existing) {
355
360
  // Lock file was deleted — we lost ownership
356
- return false;
361
+ return {
362
+ valid: false,
363
+ failureReason: "missing-metadata",
364
+ expectedPid: process.pid,
365
+ };
366
+ }
367
+ if (existing.pid !== process.pid) {
368
+ return {
369
+ valid: false,
370
+ failureReason: "pid-mismatch",
371
+ existingPid: existing.pid,
372
+ expectedPid: process.pid,
373
+ };
357
374
  }
358
- return existing.pid === process.pid;
375
+ return { valid: true };
376
+ }
377
+ export function validateSessionLock(basePath) {
378
+ return getSessionLockStatus(basePath).valid;
359
379
  }
360
380
  /**
361
381
  * Release the session lock. Called on clean stop/pause.
@@ -57,6 +57,12 @@ notifications:
57
57
  on_budget:
58
58
  on_milestone:
59
59
  on_attention:
60
+ cmux:
61
+ enabled:
62
+ notifications:
63
+ sidebar:
64
+ splits:
65
+ browser:
60
66
  remote_questions:
61
67
  channel:
62
68
  channel_id:
@@ -11,6 +11,15 @@ export const BRAVE_TOOL_NAMES = ["search-the-web", "search_and_read"];
11
11
  export const CUSTOM_SEARCH_TOOL_NAMES = ["search-the-web", "search_and_read", "google_search"];
12
12
  /** Thinking block types that require signature validation by the API */
13
13
  const THINKING_TYPES = new Set(["thinking", "redacted_thinking"]);
14
+ /**
15
+ * Maximum number of native web searches allowed per session (agent unit).
16
+ * The Anthropic API's `max_uses` is per-request — it resets on each API call.
17
+ * When `pause_turn` triggers a resubmit, the model gets a fresh budget.
18
+ * This session-level cap prevents unbounded search accumulation (#1309).
19
+ *
20
+ * 15 = 3 full turns of 5 searches each — generous for research, but bounded.
21
+ */
22
+ export const MAX_NATIVE_SEARCHES_PER_SESSION = 15;
14
23
  /** When true, skip native web search injection and keep Brave/custom tools active on Anthropic. */
15
24
  export function preferBraveSearch() {
16
25
  // preferences.md takes priority over env var
@@ -57,6 +66,10 @@ export function stripThinkingFromHistory(messages) {
57
66
  export function registerNativeSearchHooks(pi) {
58
67
  let isAnthropicProvider = false;
59
68
  let modelSelectFired = false;
69
+ // Session-level native search counter (#1309).
70
+ // Tracks cumulative web_search_tool_result blocks across all turns in a session.
71
+ // Reset on session_start. Used to compute remaining budget for max_uses.
72
+ let sessionSearchCount = 0;
60
73
  // Track provider changes via model selection — also handles diagnostics
61
74
  // since model_select fires AFTER session_start and knows the provider.
62
75
  pi.on("model_select", async (event, ctx) => {
@@ -135,18 +148,46 @@ export function registerNativeSearchHooks(pi) {
135
148
  // the model and causes it to pick custom tools which can fail with network errors.
136
149
  tools = tools.filter((t) => !CUSTOM_SEARCH_TOOL_NAMES.includes(t.name));
137
150
  payload.tools = tools;
151
+ // ── Session-level search budget (#1309) ──────────────────────────────
152
+ // Count web_search_tool_result blocks in the conversation history to
153
+ // determine how many native searches have already been used this session.
154
+ // The Anthropic API's max_uses resets per request, so without this guard,
155
+ // pause_turn → resubmit cycles allow unlimited total searches.
156
+ if (Array.isArray(messages)) {
157
+ let historySearchCount = 0;
158
+ for (const msg of messages) {
159
+ const content = msg.content;
160
+ if (!Array.isArray(content))
161
+ continue;
162
+ for (const block of content) {
163
+ if (block?.type === "web_search_tool_result") {
164
+ historySearchCount++;
165
+ }
166
+ }
167
+ }
168
+ // Sync counter from history (handles session restore / context replay)
169
+ sessionSearchCount = historySearchCount;
170
+ }
171
+ const remaining = Math.max(0, MAX_NATIVE_SEARCHES_PER_SESSION - sessionSearchCount);
172
+ if (remaining <= 0) {
173
+ // Budget exhausted — don't inject the search tool at all.
174
+ // The model will proceed without web search capability.
175
+ return payload;
176
+ }
138
177
  tools.push({
139
178
  type: "web_search_20250305",
140
179
  name: "web_search",
141
- // Cap server-side searches per response to prevent the model from
142
- // looping on web_search without synthesizing results (#817).
143
- // 5 searches is generous most queries need 1-2.
144
- max_uses: 5,
180
+ // Cap per-request searches to the lesser of 5 (per-turn cap) or the
181
+ // remaining session budget (#1309). This prevents the model from
182
+ // consuming unlimited searches via pause_turn resubmit cycles.
183
+ max_uses: Math.min(5, remaining),
145
184
  });
146
185
  return payload;
147
186
  });
148
187
  // Basic startup diagnostics — provider-specific info comes from model_select
149
188
  pi.on("session_start", async (_event, ctx) => {
189
+ // Reset session-level search budget (#1309)
190
+ sessionSearchCount = 0;
150
191
  const hasBrave = !!process.env.BRAVE_API_KEY;
151
192
  const hasJina = !!process.env.JINA_API_KEY;
152
193
  const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY;
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Shared formatting and layout utilities for TUI dashboard components.
2
+ * Shared pure formatting utilities no @gsd/pi-tui dependency.
3
3
  *
4
- * Consolidates helpers that were previously duplicated across
5
- * auto-dashboard.ts, dashboard-overlay.ts, and visualizer-views.ts.
4
+ * ANSI-aware layout helpers (padRight, joinColumns, centerLine, fitColumns)
5
+ * live in layout-utils.ts to avoid pulling @gsd/pi-tui into modules that
6
+ * run outside jiti's alias resolution (e.g. HTML report generation via
7
+ * dynamic import in auto-loop).
6
8
  */
7
- import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
8
9
  // ─── Duration Formatting ──────────────────────────────────────────────────────
9
10
  /** Format a millisecond duration as a compact human-readable string. */
10
11
  export function formatDuration(ms) {
@@ -30,43 +31,6 @@ export function formatTokenCount(count) {
30
31
  return `${(count / 1000).toFixed(1)}k`;
31
32
  return `${(count / 1_000_000).toFixed(2)}M`;
32
33
  }
33
- // ─── Layout Helpers ───────────────────────────────────────────────────────────
34
- /** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
35
- export function padRight(content, width) {
36
- const vis = visibleWidth(content);
37
- return content + " ".repeat(Math.max(0, width - vis));
38
- }
39
- /** Build a line with left-aligned and right-aligned content. */
40
- export function joinColumns(left, right, width) {
41
- const leftW = visibleWidth(left);
42
- const rightW = visibleWidth(right);
43
- if (leftW + rightW + 2 > width) {
44
- return truncateToWidth(`${left} ${right}`, width);
45
- }
46
- return left + " ".repeat(width - leftW - rightW) + right;
47
- }
48
- /** Center content within `width` (ANSI-aware). */
49
- export function centerLine(content, width) {
50
- const vis = visibleWidth(content);
51
- if (vis >= width)
52
- return truncateToWidth(content, width);
53
- const leftPad = Math.floor((width - vis) / 2);
54
- return " ".repeat(leftPad) + content;
55
- }
56
- /** Join as many parts as fit within `width`, separated by `separator`. */
57
- export function fitColumns(parts, width, separator = " ") {
58
- const filtered = parts.filter(Boolean);
59
- if (filtered.length === 0)
60
- return "";
61
- let result = filtered[0];
62
- for (let i = 1; i < filtered.length; i++) {
63
- const candidate = `${result}${separator}${filtered[i]}`;
64
- if (visibleWidth(candidate) > width)
65
- break;
66
- result = candidate;
67
- }
68
- return truncateToWidth(result, width);
69
- }
70
34
  // ─── Text Truncation ─────────────────────────────────────────────────────────
71
35
  /** Truncate a string to `maxLength` characters, replacing the last character with an ellipsis if needed. */
72
36
  export function truncateWithEllipsis(text, maxLength) {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * ANSI-aware TUI layout utilities that depend on @gsd/pi-tui.
3
+ *
4
+ * Separated from format-utils.ts so that modules needing only pure
5
+ * formatting (e.g. HTML report generation) can import format-utils
6
+ * without pulling in the @gsd/pi-tui dependency — which fails when
7
+ * loaded outside jiti's alias resolution context.
8
+ */
9
+ import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
10
+ // ─── Layout Helpers ───────────────────────────────────────────────────────────
11
+ /** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
12
+ export function padRight(content, width) {
13
+ const vis = visibleWidth(content);
14
+ return content + " ".repeat(Math.max(0, width - vis));
15
+ }
16
+ /** Build a line with left-aligned and right-aligned content. */
17
+ export function joinColumns(left, right, width) {
18
+ const leftW = visibleWidth(left);
19
+ const rightW = visibleWidth(right);
20
+ if (leftW + rightW + 2 > width) {
21
+ return truncateToWidth(`${left} ${right}`, width);
22
+ }
23
+ return left + " ".repeat(width - leftW - rightW) + right;
24
+ }
25
+ /** Center content within `width` (ANSI-aware). */
26
+ export function centerLine(content, width) {
27
+ const vis = visibleWidth(content);
28
+ if (vis >= width)
29
+ return truncateToWidth(content, width);
30
+ const leftPad = Math.floor((width - vis) / 2);
31
+ return " ".repeat(leftPad) + content;
32
+ }
33
+ /** Join as many parts as fit within `width`, separated by `separator`. */
34
+ export function fitColumns(parts, width, separator = " ") {
35
+ const filtered = parts.filter(Boolean);
36
+ if (filtered.length === 0)
37
+ return "";
38
+ let result = filtered[0];
39
+ for (let i = 1; i < filtered.length; i++) {
40
+ const candidate = `${result}${separator}${filtered[i]}`;
41
+ if (visibleWidth(candidate) > width)
42
+ break;
43
+ result = candidate;
44
+ }
45
+ return truncateToWidth(result, width);
46
+ }
@@ -1,6 +1,7 @@
1
1
  // Barrel file — re-exports consumed by external modules
2
2
  export { makeUI, GLYPH, INDENT, STATUS_GLYPH, STATUS_COLOR, } from "./ui.js";
3
- export { stripAnsi, formatTokenCount, formatDuration, padRight, joinColumns, centerLine, fitColumns, sparkline, normalizeStringArray, fileLink, } from "./format-utils.js";
3
+ export { stripAnsi, formatTokenCount, formatDuration, sparkline, normalizeStringArray, fileLink, } from "./format-utils.js";
4
+ export { padRight, joinColumns, centerLine, fitColumns, } from "./layout-utils.js";
4
5
  export { shortcutDesc } from "./terminal.js";
5
6
  export { toPosixPath } from "./path-display.js";
6
7
  export { showInterviewRound } from "./interview-ui.js";
@@ -5,9 +5,14 @@
5
5
  * Terminals that lack this support silently swallow the key combos.
6
6
  */
7
7
  const UNSUPPORTED_TERMS = ["apple_terminal", "warpterm"];
8
+ export function isCmuxTerminal(env = process.env) {
9
+ return Boolean(env.CMUX_WORKSPACE_ID && env.CMUX_SURFACE_ID);
10
+ }
8
11
  export function supportsCtrlAltShortcuts() {
9
12
  const term = (process.env.TERM_PROGRAM || "").toLowerCase();
10
13
  const jetbrains = (process.env.TERMINAL_EMULATOR || "").toLowerCase().includes("jetbrains");
14
+ if (isCmuxTerminal())
15
+ return true;
11
16
  return !UNSUPPORTED_TERMS.some((t) => term.includes(t)) && !jetbrains;
12
17
  }
13
18
  /**