@oh-my-pi/pi-coding-agent 17.3.4 → 17.3.5

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 (121) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/{CHANGELOG-trcc215s.md → CHANGELOG-tt9k4jpr.md} +68 -0
  3. package/dist/cli.js +3146 -3153
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/config/model-discovery.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/debug/report-bundle.d.ts +7 -2
  9. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  10. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +11 -0
  14. package/dist/types/lsp/client.d.ts +22 -0
  15. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  16. package/dist/types/mcp/config.d.ts +3 -1
  17. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  18. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  19. package/dist/types/registry/agent-registry.d.ts +4 -0
  20. package/dist/types/session/agent-session-types.d.ts +0 -2
  21. package/dist/types/session/agent-session.d.ts +20 -0
  22. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  23. package/dist/types/session/irc-bridge.d.ts +2 -0
  24. package/dist/types/session/messages.d.ts +4 -0
  25. package/dist/types/session/session-loader.d.ts +18 -11
  26. package/dist/types/session/session-maintenance.d.ts +6 -2
  27. package/dist/types/session/session-manager.d.ts +19 -3
  28. package/dist/types/session/session-tools.d.ts +0 -1
  29. package/dist/types/session/turn-recovery.d.ts +7 -7
  30. package/dist/types/tools/browser/attach.d.ts +16 -0
  31. package/dist/types/tools/browser/launch.d.ts +2 -2
  32. package/dist/types/tools/browser.d.ts +1 -1
  33. package/dist/types/tools/builtin-names.d.ts +1 -1
  34. package/dist/types/tools/hub/jobs.d.ts +6 -0
  35. package/dist/types/tools/hub/types.d.ts +6 -0
  36. package/dist/types/vibe/runtime.d.ts +3 -3
  37. package/package.json +13 -13
  38. package/src/auto-thinking/classifier.ts +37 -23
  39. package/src/cli/models-cli.ts +1 -1
  40. package/src/commit/analysis/conventional.ts +15 -9
  41. package/src/commit/analysis/summary.ts +15 -9
  42. package/src/commit/changelog/generate.ts +15 -9
  43. package/src/commit/map-reduce/map-phase.ts +3 -19
  44. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  45. package/src/config/model-discovery.ts +3 -3
  46. package/src/config/model-registry.ts +29 -16
  47. package/src/config/settings-schema.ts +13 -0
  48. package/src/debug/report-bundle.ts +25 -59
  49. package/src/discovery/claude-plugins.ts +2 -1
  50. package/src/discovery/claude.ts +4 -2
  51. package/src/discovery/helpers.ts +5 -0
  52. package/src/edit/renderer.ts +48 -19
  53. package/src/extensibility/extensions/loader.ts +9 -5
  54. package/src/extensibility/extensions/runner.ts +124 -21
  55. package/src/extensibility/extensions/types.ts +2 -0
  56. package/src/extensibility/extensions/wrapper.ts +16 -10
  57. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  58. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  59. package/src/irc/bus.ts +1 -1
  60. package/src/launch/broker.ts +8 -1
  61. package/src/launch/presence.ts +77 -1
  62. package/src/lsp/client.ts +35 -3
  63. package/src/lsp/clients/biome-client.ts +47 -93
  64. package/src/lsp/servers.ts +17 -11
  65. package/src/lsp/tool.ts +2 -4
  66. package/src/lsp/workspace-diagnostics.ts +24 -2
  67. package/src/mcp/config.ts +50 -6
  68. package/src/memories/index.ts +29 -25
  69. package/src/mnemopi/backend.ts +13 -11
  70. package/src/modes/components/ask-dialog.ts +25 -5
  71. package/src/modes/components/tool-execution.ts +10 -6
  72. package/src/modes/components/welcome.ts +4 -1
  73. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  74. package/src/modes/controllers/selector-controller.ts +6 -0
  75. package/src/modes/controllers/tan-command-controller.ts +1 -0
  76. package/src/modes/interactive-mode.ts +49 -10
  77. package/src/modes/theme/defaults/birch.json +2 -2
  78. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  79. package/src/prompts/system/date-cwd-reminder.md +3 -0
  80. package/src/prompts/system/project-prompt.md +0 -1
  81. package/src/prompts/system/rewind-report.md +1 -3
  82. package/src/prompts/tools/browser.md +1 -0
  83. package/src/prompts/tools/rewind.md +1 -13
  84. package/src/registry/agent-lifecycle.ts +34 -0
  85. package/src/registry/agent-registry.ts +27 -2
  86. package/src/registry/persisted-agents.ts +40 -20
  87. package/src/sdk.ts +32 -2
  88. package/src/session/agent-session-types.ts +0 -2
  89. package/src/session/agent-session.ts +178 -46
  90. package/src/session/date-cwd-reminder.ts +77 -0
  91. package/src/session/irc-bridge.ts +5 -0
  92. package/src/session/messages.ts +5 -1
  93. package/src/session/session-loader.ts +106 -64
  94. package/src/session/session-maintenance.ts +189 -115
  95. package/src/session/session-manager.ts +142 -35
  96. package/src/session/session-persistence.ts +4 -4
  97. package/src/session/session-storage.ts +18 -5
  98. package/src/session/session-tools.ts +5 -10
  99. package/src/session/turn-recovery.ts +102 -20
  100. package/src/session/unexpected-stop-classifier.ts +33 -21
  101. package/src/system-prompt.ts +0 -5
  102. package/src/task/executor.ts +4 -15
  103. package/src/task/persisted-revive.ts +3 -6
  104. package/src/tools/ask.ts +10 -3
  105. package/src/tools/browser/attach.ts +80 -25
  106. package/src/tools/browser/launch.ts +44 -15
  107. package/src/tools/browser/relay/daemon.ts +3 -7
  108. package/src/tools/browser/render.ts +1 -1
  109. package/src/tools/browser/shared-daemon.ts +3 -7
  110. package/src/tools/browser.ts +5 -5
  111. package/src/tools/builtin-names.ts +7 -3
  112. package/src/tools/checkpoint.ts +1 -7
  113. package/src/tools/hub/index.ts +1 -1
  114. package/src/tools/hub/jobs.ts +20 -3
  115. package/src/tools/hub/types.ts +6 -0
  116. package/src/tools/render-utils.ts +53 -21
  117. package/src/tools/think.ts +15 -3
  118. package/src/tts/speech-enhancer.ts +19 -14
  119. package/src/utils/commit-message-generator.ts +14 -12
  120. package/src/utils/title-generator.ts +23 -19
  121. package/src/vibe/runtime.ts +32 -17
@@ -17,6 +17,7 @@ import { daemonClientForGlobal } from "../../../launch/client";
17
17
  import { describeQuietly, stopQuietly, waitReady } from "../../../launch/ensure";
18
18
  import { resolveWorkerSpawnCmd } from "../../../subprocess/worker-client";
19
19
  import { throwIfAborted } from "../../tool-errors";
20
+ import { probeCdpStatus } from "../attach";
20
21
 
21
22
  /** Stable broker daemon name for the relay server. */
22
23
  export const RELAY_DAEMON_NAME = "omp.browser.relay";
@@ -30,13 +31,8 @@ const ENSURE_ATTEMPTS = 3;
30
31
 
31
32
  /** True when the relay HTTP server answers /json/version at all (200 = extension connected, 503 = waiting for it). */
32
33
  export async function probeRelayServer(cdpUrl: string): Promise<boolean> {
33
- try {
34
- const res = await fetch(`${cdpUrl}/json/version`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
35
- await res.body?.cancel();
36
- return res.ok || res.status === 503;
37
- } catch {
38
- return false;
39
- }
34
+ const status = await probeCdpStatus(`${cdpUrl}/json/version`, { timeoutMs: PROBE_TIMEOUT_MS });
35
+ return status === 503 || (status !== null && status >= 200 && status < 300);
40
36
  }
41
37
 
42
38
  /** Auto-start is only safe for endpoints this machine can own. */
@@ -162,7 +162,7 @@ function renderOpenOrCloseLine(
162
162
  let title: string;
163
163
  if (action === "close") {
164
164
  const all = args.all === true || (args.name === undefined && details?.name === undefined);
165
- title = all ? "Close all tabs" : `Close ${tabLabel(args, details)}`;
165
+ title = all ? "Release all tabs" : `Release ${tabLabel(args, details)}`;
166
166
  if (args.kill) title += " (kill)";
167
167
  } else {
168
168
  title = `Open ${tabLabel(args, details)}`;
@@ -16,6 +16,7 @@ import { describeQuietly, stopQuietly, waitReady } from "../../launch/ensure";
16
16
  import { daemonRuntimeDir } from "../../launch/paths";
17
17
  import type { DaemonSnapshot } from "../../launch/protocol";
18
18
  import { throwIfAborted } from "../tool-errors";
19
+ import { probeCdpStatus } from "./attach";
19
20
  import { resolveSharedBrowserLaunchSpec } from "./launch";
20
21
 
21
22
  /** Chrome prints this on stderr once the CDP listener is up; the broker's ready probe captures the line. */
@@ -50,13 +51,8 @@ async function probeEndpoint(wsEndpoint: string): Promise<boolean> {
50
51
  } catch {
51
52
  return false;
52
53
  }
53
- try {
54
- const res = await fetch(`http://${host}/json/version`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
55
- await res.body?.cancel();
56
- return res.ok;
57
- } catch {
58
- return false;
59
- }
54
+ const status = await probeCdpStatus(`http://${host}/json/version`, { timeoutMs: PROBE_TIMEOUT_MS });
55
+ return status !== null && status >= 200 && status < 300;
60
56
  }
61
57
 
62
58
  /**
@@ -69,7 +69,7 @@ const browserSchema = type({
69
69
  "dialogs?": type("'accept' | 'dismiss'").describe("auto-handle dialogs"),
70
70
  "code?": type("string").describe("js body to run in tab"),
71
71
  "timeout?": type("number").describe("timeout in seconds"),
72
- "all?": type("boolean").describe("close every tab"),
72
+ "all?": type("boolean").describe("release every managed tab"),
73
73
  "kill?": type("boolean").describe("also kill spawned-app browsers"),
74
74
  });
75
75
 
@@ -133,7 +133,7 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
133
133
  /**
134
134
  * Browser tool: stateful, multi-tab. Three actions:
135
135
  * - `open` → acquire/create a named tab on a browser kind (headless | spawned | connected) and optionally goto a url.
136
- * - `close` → release a named tab (or all tabs); dispose browser when refcount hits 0.
136
+ * - `close` → release a named tab handle (or all handles); attached/relay pages remain open, and spawned pages remain unless killed.
137
137
  * - `run` → execute JS code against an existing tab with `page`/`browser`/`tab` helpers in scope.
138
138
  */
139
139
  export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
@@ -204,7 +204,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
204
204
  },
205
205
  },
206
206
  {
207
- caption: "Close every tab and kill spawned-app processes",
207
+ caption: "Release every managed tab and kill spawned-app processes",
208
208
  call: { action: "close", all: true, kill: true },
209
209
  },
210
210
  ];
@@ -362,11 +362,11 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
362
362
  const kill = !!params.kill;
363
363
  if (params.all) {
364
364
  const count = await untilAborted(signal, () => releaseAllTabs({ kill, timeoutMs }));
365
- details.result = `Closed ${count} tab(s)`;
365
+ details.result = `Released ${count} managed tab${count === 1 ? "" : "s"}`;
366
366
  return toolResult(details).text(details.result).done();
367
367
  }
368
368
  const closed = await untilAborted(signal, () => releaseTab(name, { kill, timeoutMs }));
369
- details.result = closed ? `Closed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`;
369
+ details.result = closed ? `Released managed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`;
370
370
  return toolResult(details).text(details.result).done();
371
371
  }
372
372
 
@@ -41,10 +41,14 @@ const LEGACY_BUILTIN_TOOL_NAME_ALIASES: ReadonlyMap<string, BuiltinToolName> = n
41
41
  ["find", "glob"],
42
42
  ]);
43
43
 
44
- /** Return the canonical tool name for current and legacy built-in tool IDs. */
44
+ const CANONICAL_TOOL_NAMES: Record<string, true> = Object.fromEntries(
45
+ [...BUILTIN_TOOL_NAMES, ...HIDDEN_TOOL_NAMES].map(name => [name, true]),
46
+ );
47
+
48
+ /** Canonicalize built-in IDs and legacy aliases. Leave plugin names unchanged. */
45
49
  export function normalizeToolName(name: string): string {
46
- const normalized = name.toLowerCase();
47
- return LEGACY_BUILTIN_TOOL_NAME_ALIASES.get(normalized) ?? normalized;
50
+ const lower = name.toLowerCase();
51
+ return LEGACY_BUILTIN_TOOL_NAME_ALIASES.get(lower) ?? (Object.hasOwn(CANONICAL_TOOL_NAMES, lower) ? lower : name);
48
52
  }
49
53
 
50
54
  /** Normalize and deduplicate tool names while preserving first-seen order. */
@@ -81,13 +81,7 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
81
81
  }
82
82
  const startedAt = new Date().toISOString();
83
83
  return toolResult<CheckpointToolDetails>({ goal: params.goal, startedAt })
84
- .text(
85
- [
86
- "Checkpoint created.",
87
- `Goal: ${params.goal}`,
88
- "Run your investigation, then call rewind with a concise report.",
89
- ].join("\n"),
90
- )
84
+ .text([`Checkpoint: ${params.goal}`, "Finish exploration and formulate findings."].join("\n"))
91
85
  .done();
92
86
  }
93
87
  }
@@ -377,7 +377,7 @@ export class HubTool implements AgentTool<typeof hubSchema, HubDetails> {
377
377
  // instead of blocking a full message-timeout window.
378
378
  const hasRunningPeer = messaging.registry
379
379
  .listVisibleTo(messaging.senderId)
380
- .some(ref => ref.status === "running");
380
+ .some(ref => messaging.registry.isRunning(ref));
381
381
  if (!hasRunningPeer) return nothingToWaitForResult(this.session);
382
382
  }
383
383
  return executeMessageWait(messaging, { from, timeoutMs: params.timeoutMs }, signal);
@@ -87,6 +87,12 @@ export function visibleJobs(manager: AsyncJobManager, ids: string[], ownerId: st
87
87
  * that activity instead of implying the system is quiet. Existence is
88
88
  * already public via the peer roster, so listing ids here leaks nothing new;
89
89
  * job *control* stays owner-scoped.
90
+ *
91
+ * Reporting deliberately uses the claimed `status`, not the session-corroborated
92
+ * `registry.isRunning` used by the wait-sustaining gates: a ref that claims
93
+ * `running` with no live turn is exactly the stale entry an operator must see
94
+ * here to cancel it (#8634). Hiding it would match the badge count to nothing
95
+ * and remove the only discovery path for the id.
90
96
  */
91
97
  export function runningAgentsOutsideJobs(session: ToolSession): AgentActivitySnapshot[] {
92
98
  const registry = session.agentRegistry;
@@ -113,6 +119,7 @@ export function runningAgentsOutsideJobs(session: ToolSession): AgentActivitySna
113
119
  ...(ref.parentId ? { parentId: ref.parentId } : {}),
114
120
  ...(ref.activity ? { activity: ref.activity } : {}),
115
121
  ageMs: Math.max(0, now - ref.createdAt),
122
+ live: registry.isRunning(ref),
116
123
  });
117
124
  }
118
125
  return out;
@@ -124,9 +131,15 @@ function describeAgents(agents: AgentActivitySnapshot[]): string[] {
124
131
  for (const agent of agents) {
125
132
  const parent = agent.parentId ? ` (spawned by \`${agent.parentId}\`)` : "";
126
133
  const activity = agent.activity ? ` — ${agent.activity}` : "";
127
- lines.push(`- \`${agent.id}\`${parent}up ${formatDuration(agent.ageMs)}${activity}`);
134
+ const stale = agent.live ? "" : " no turn in flight (stale registration?)";
135
+ lines.push(`- \`${agent.id}\`${parent} — up ${formatDuration(agent.ageMs)}${activity}${stale}`);
128
136
  }
129
137
  lines.push("", "These agents have no job entry; message them via `hub` send, transcripts at `history://<id>`.");
138
+ if (agents.some(agent => !agent.live)) {
139
+ lines.push(
140
+ "An agent with no turn in flight cannot answer a message and never satisfies a bare `wait`; clear it with `hub` cancel.",
141
+ );
142
+ }
130
143
  return lines;
131
144
  }
132
145
 
@@ -690,8 +703,12 @@ export function jobsRenderResult(
690
703
  maxCollapsed: COLLAPSED_LIST_LIMIT,
691
704
  itemType: "agent",
692
705
  renderItem: agent => {
693
- const icon = formatStatusIcon("running", uiTheme, options.spinnerFrame);
694
- const badge = formatBadge("agent", "accent", uiTheme);
706
+ const icon = agent.live
707
+ ? formatStatusIcon("running", uiTheme, options.spinnerFrame)
708
+ : formatStatusIcon("warning", uiTheme);
709
+ const badge = agent.live
710
+ ? formatBadge("agent", "accent", uiTheme)
711
+ : formatBadge("agent · no turn", "warning", uiTheme);
695
712
  const gist = agent.activity
696
713
  ? ` ${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(agent.activity), LABEL_MAX_WIDTH, Ellipsis.Unicode))}`
697
714
  : "";
@@ -74,6 +74,12 @@ export interface AgentActivitySnapshot {
74
74
  activity?: string;
75
75
  /** Time since the agent was registered. */
76
76
  ageMs: number;
77
+ /**
78
+ * Whether an attached session corroborates the `running` claim. False marks
79
+ * a ref that says `running` with no turn in flight — either a spawn still
80
+ * wiring up or a stale registration that `hub cancel <id>` clears (#8634).
81
+ */
82
+ live: boolean;
77
83
  }
78
84
 
79
85
  /** Result details for messaging and job ops; fields are disjoint per op. */
@@ -599,79 +599,111 @@ export function truncateDiffByHunk(
599
599
  let keptHunks = 0;
600
600
 
601
601
  for (const seg of segments) {
602
+ if (kept.length >= maxLines) break;
602
603
  if (seg.isChange) {
604
+ if (keptHunks >= maxHunks) break;
603
605
  keptHunks++;
604
- if (keptHunks > maxHunks) break;
605
606
  }
606
- kept.push(...seg.lines);
607
- if (kept.length >= maxLines) break;
607
+ const take = Math.min(seg.lines.length, maxLines - kept.length);
608
+ for (let i = 0; i < take; i++) {
609
+ kept.push(seg.lines[i]!);
610
+ }
608
611
  }
609
612
 
610
- const keptStats = getDiffStats(kept.join("\n"));
611
613
  return {
612
614
  text: kept.join("\n"),
613
- hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
615
+ hiddenHunks: Math.max(0, totalStats.hunks - keptHunks),
614
616
  hiddenLines: Math.max(0, lines.length - kept.length),
615
617
  };
616
618
  }
617
619
 
618
620
  const contextBudget = maxLines - changeLineCount;
619
- const contextSegments = segments.filter(s => !s.isChange && !s.isEllipsis);
621
+ const contextSegments = segments.filter(s => !s.isChange);
620
622
  const totalContextLines = contextSegments.reduce((sum, s) => sum + s.lines.length, 0);
621
623
 
622
624
  const kept: string[] = [];
623
625
  let keptHunks = 0;
626
+ let keptSourceLines = 0;
624
627
 
625
628
  if (totalContextLines <= contextBudget) {
626
629
  for (const seg of segments) {
627
630
  if (seg.isChange) {
631
+ if (keptHunks >= maxHunks) break;
628
632
  keptHunks++;
629
- if (keptHunks > maxHunks) break;
630
633
  }
631
634
  kept.push(...seg.lines);
635
+ keptSourceLines += seg.lines.length;
632
636
  }
633
637
  } else {
634
- const contextRatio = contextSegments.length > 0 ? contextBudget / totalContextLines : 0;
638
+ const contextRatio = totalContextLines > 0 ? contextBudget / totalContextLines : 0;
639
+ let remainingContextBudget = contextBudget;
635
640
 
636
641
  for (let i = 0; i < segments.length; i++) {
637
642
  const seg = segments[i];
638
643
 
639
644
  if (seg.isChange) {
645
+ if (keptHunks >= maxHunks) break;
640
646
  keptHunks++;
641
- if (keptHunks > maxHunks) break;
642
- kept.push(...seg.lines);
643
- } else if (seg.isEllipsis) {
644
647
  kept.push(...seg.lines);
648
+ keptSourceLines += seg.lines.length;
649
+ continue;
650
+ }
651
+ if (remainingContextBudget <= 0) continue;
652
+
653
+ const allowedLines = Math.min(
654
+ remainingContextBudget,
655
+ Math.max(1, Math.floor(seg.lines.length * contextRatio)),
656
+ );
657
+ const outputStart = kept.length;
658
+ let sourceLinesAdded = 0;
659
+
660
+ if (seg.isEllipsis || seg.lines.length <= allowedLines) {
661
+ for (let j = 0; j < allowedLines; j++) {
662
+ kept.push(seg.lines[j]!);
663
+ }
664
+ sourceLinesAdded = allowedLines;
645
665
  } else {
646
- const allowedLines = Math.max(1, Math.floor(seg.lines.length * contextRatio));
647
666
  const isBeforeChange = segments[i + 1]?.isChange;
648
667
  const isAfterChange = segments[i - 1]?.isChange;
649
668
 
650
669
  if (isBeforeChange && isAfterChange) {
651
- const half = Math.ceil(allowedLines / 2);
652
- if (seg.lines.length > allowedLines) {
653
- kept.push(...seg.lines.slice(0, half));
670
+ if (allowedLines >= 3) {
671
+ const sourceBudget = allowedLines - 1;
672
+ const firstCount = Math.ceil(sourceBudget / 2);
673
+ const lastCount = sourceBudget - firstCount;
674
+ kept.push(...seg.lines.slice(0, firstCount));
654
675
  kept.push("");
655
- kept.push(...seg.lines.slice(-half));
676
+ if (lastCount > 0) kept.push(...seg.lines.slice(-lastCount));
677
+ sourceLinesAdded = sourceBudget;
656
678
  } else {
657
- kept.push(...seg.lines);
679
+ const firstCount = Math.ceil(allowedLines / 2);
680
+ const lastCount = allowedLines - firstCount;
681
+ kept.push(...seg.lines.slice(0, firstCount));
682
+ if (lastCount > 0) kept.push(...seg.lines.slice(-lastCount));
683
+ sourceLinesAdded = allowedLines;
658
684
  }
659
685
  } else if (isBeforeChange) {
660
686
  kept.push(...seg.lines.slice(-allowedLines));
687
+ sourceLinesAdded = allowedLines;
661
688
  } else if (isAfterChange) {
662
689
  kept.push(...seg.lines.slice(0, allowedLines));
690
+ sourceLinesAdded = allowedLines;
663
691
  } else {
664
- kept.push(...seg.lines.slice(0, Math.min(allowedLines, 2)));
692
+ const take = Math.min(allowedLines, 2);
693
+ kept.push(...seg.lines.slice(0, take));
694
+ sourceLinesAdded = take;
665
695
  }
666
696
  }
697
+
698
+ keptSourceLines += sourceLinesAdded;
699
+ remainingContextBudget -= kept.length - outputStart;
667
700
  }
668
701
  }
669
702
 
670
- const keptStats = getDiffStats(kept.join("\n"));
671
703
  return {
672
704
  text: kept.join("\n"),
673
- hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
674
- hiddenLines: Math.max(0, lines.length - kept.length),
705
+ hiddenHunks: Math.max(0, totalStats.hunks - keptHunks),
706
+ hiddenLines: Math.max(0, lines.length - keptSourceLines),
675
707
  };
676
708
  }
677
709
 
@@ -8,14 +8,26 @@ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
8
8
  /** Whether a model transport can suppress native reasoning while private scratchpad thoughts are active. */
9
9
  export function supportsExternalThinking(model: Model | null | undefined): boolean {
10
10
  if (!model) return false;
11
+ const compat = model.compat;
11
12
  const requiresThinking =
12
13
  model.api === "anthropic-messages" &&
13
- model.compat !== undefined &&
14
- "requiresThinkingEnabled" in model.compat &&
15
- model.compat.requiresThinkingEnabled === true;
14
+ compat !== undefined &&
15
+ "requiresThinkingEnabled" in compat &&
16
+ compat.requiresThinkingEnabled === true;
16
17
  if (model.reasoning && (requiresThinking || (model.thinking?.requiresEffort && !model.thinking.suppressWhenOff))) {
17
18
  return false;
18
19
  }
20
+ // Transports that reject `reasoning.effort` (xAI Grok 4 and the other
21
+ // reasoning-only Responses models) cannot honour `forceReasoningOff`, so the
22
+ // scratchpad would run alongside native reasoning instead of replacing it.
23
+ if (
24
+ model.reasoning &&
25
+ compat !== undefined &&
26
+ (("omitReasoningEffort" in compat && compat.omitReasoningEffort === true) ||
27
+ ("supportsReasoningEffort" in compat && compat.supportsReasoningEffort === false))
28
+ ) {
29
+ return false;
30
+ }
19
31
  if (model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex") {
20
32
  return !model.reasoning || model.thinking?.mode === "budget" || model.thinking?.suppressWhenOff === true;
21
33
  }
@@ -15,7 +15,7 @@
15
15
  * mechanical {@link SpeakableStream} cleanup — speech never blocks on the
16
16
  * model.
17
17
  */
18
- import { type AssistantMessage, completeSimple } from "@oh-my-pi/pi-ai";
18
+ import { type AssistantMessage, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
19
19
  import { logger, prompt } from "@oh-my-pi/pi-utils";
20
20
  import type { ModelRegistry } from "../config/model-registry";
21
21
  import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
@@ -82,20 +82,25 @@ export class SpeechEnhancer {
82
82
  if (!apiKey) return null;
83
83
  // Resolve metadata after getApiKey so the session-sticky credential is recorded first.
84
84
  const metadata = this.#deps.metadataResolver?.(model.provider);
85
- const timeout = AbortSignal.timeout(REWRITE_TIMEOUT_MS);
86
- const response = await completeSimple(
87
- model,
88
- {
89
- systemPrompt: [SYSTEM_PROMPT],
90
- messages: [{ role: "user", content: boundBlock(block), timestamp: Date.now() }],
91
- },
92
- {
93
- apiKey: registry.resolver(model, sessionId),
94
- maxTokens: ANSWER_MAX_TOKENS,
95
- disableReasoning: true,
96
- metadata,
97
- signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
85
+ const response = await retryTransientCompletion(
86
+ () => {
87
+ const timeout = AbortSignal.timeout(REWRITE_TIMEOUT_MS);
88
+ return completeSimple(
89
+ model,
90
+ {
91
+ systemPrompt: [SYSTEM_PROMPT],
92
+ messages: [{ role: "user", content: boundBlock(block), timestamp: Date.now() }],
93
+ },
94
+ {
95
+ apiKey: registry.resolver(model, sessionId),
96
+ maxTokens: ANSWER_MAX_TOKENS,
97
+ disableReasoning: true,
98
+ metadata,
99
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
100
+ },
101
+ );
98
102
  },
103
+ { signal },
99
104
  );
100
105
  if (response.stopReason === "error") {
101
106
  logger.debug("speech-enhancer: rewrite errored", { error: response.errorMessage });
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
6
6
  import type { Api, Model } from "@oh-my-pi/pi-ai";
7
- import { completeSimple } from "@oh-my-pi/pi-ai";
7
+ import { completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
8
8
  import { logger, prompt } from "@oh-my-pi/pi-utils";
9
9
 
10
10
  import type { ModelRegistry } from "../config/model-registry";
@@ -106,17 +106,19 @@ export async function generateCommitMessage(
106
106
 
107
107
  try {
108
108
  const maxTokens = COMMIT_MAX_TOKENS;
109
- const response = await completeSimple(
110
- candidate.model,
111
- {
112
- systemPrompt: [COMMIT_SYSTEM_PROMPT],
113
- messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
114
- },
115
- {
116
- apiKey: registry.resolver(candidate.model, sessionId),
117
- maxTokens,
118
- reasoning: toReasoningEffort(candidate.thinkingLevel),
119
- },
109
+ const response = await retryTransientCompletion(() =>
110
+ completeSimple(
111
+ candidate.model,
112
+ {
113
+ systemPrompt: [COMMIT_SYSTEM_PROMPT],
114
+ messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
115
+ },
116
+ {
117
+ apiKey: registry.resolver(candidate.model, sessionId),
118
+ maxTokens,
119
+ reasoning: toReasoningEffort(candidate.thinkingLevel),
120
+ },
121
+ ),
120
122
  );
121
123
 
122
124
  if (response.stopReason === "error") {
@@ -4,7 +4,7 @@
4
4
  import { dlopen, FFIType, ptr } from "bun:ffi";
5
5
  import * as path from "node:path";
6
6
 
7
- import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai";
7
+ import { type Api, type AssistantMessage, completeSimple, type Model, retryTransientCompletion } from "@oh-my-pi/pi-ai";
8
8
  import { StreamMarkupHealing } from "@oh-my-pi/pi-ai/utils/stream-markup-healing";
9
9
  import { isConPTYHosted } from "@oh-my-pi/pi-tui";
10
10
  import { isTerminalHeadless, logger, prompt } from "@oh-my-pi/pi-utils";
@@ -254,24 +254,28 @@ export async function generateTitleOnline(
254
254
  const maxTokens = TITLE_MAX_TOKENS;
255
255
  logger.debug("title-generator: request", { ...modelContext, maxTokens });
256
256
 
257
- const response = await completeSimple(
258
- model,
259
- {
260
- systemPrompt,
261
- messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
262
- },
263
- {
264
- apiKey: registry.resolver(model, sessionId),
265
- maxTokens,
266
- disableReasoning: true,
267
- // Greedy decode: titling is extraction, not generation. Backends that
268
- // default temperature high (e.g. Ollama's 0.8) otherwise garble names
269
- // from the message ("hashline" "HasHroshi"). Providers whose models
270
- // reject sampling params drop this via `supportsSamplingParams`.
271
- temperature: 0,
272
- metadata,
273
- signal,
274
- },
257
+ const response = await retryTransientCompletion(
258
+ () =>
259
+ completeSimple(
260
+ model,
261
+ {
262
+ systemPrompt,
263
+ messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
264
+ },
265
+ {
266
+ apiKey: registry.resolver(model, sessionId),
267
+ maxTokens,
268
+ disableReasoning: true,
269
+ // Greedy decode: titling is extraction, not generation. Backends that
270
+ // default temperature high (e.g. Ollama's 0.8) otherwise garble names
271
+ // from the message ("hashline" → "HasHroshi"). Providers whose models
272
+ // reject sampling params drop this via `supportsSamplingParams`.
273
+ temperature: 0,
274
+ metadata,
275
+ signal,
276
+ },
277
+ ),
278
+ { signal },
275
279
  );
276
280
 
277
281
  if (response.stopReason === "error") {
@@ -421,11 +421,17 @@ export class VibeSessionRegistry {
421
421
  }
422
422
 
423
423
  /**
424
- * Insert a bare worker record without the spawn/job machinery. Test-only —
425
- * lets {@link aggregateVibeWorkerTokensPerSecond} be exercised against a
426
- * fake roster + AgentRegistry session without driving a real turn.
424
+ * Insert a bare worker record without the spawn machinery. Test-only —
425
+ * lets focused runtime tests attach an optional synthetic in-flight job.
427
426
  */
428
- registerRecordForTests(record: { id: string; cli?: VibeCli; ownerId: string; state?: VibeSessionState }): void {
427
+ registerRecordForTests(record: {
428
+ id: string;
429
+ cli?: VibeCli;
430
+ ownerId: string;
431
+ state?: VibeSessionState;
432
+ jobId?: string;
433
+ }): void {
434
+ const now = Date.now();
429
435
  this.#records.set(record.id, {
430
436
  id: record.id,
431
437
  cli: record.cli ?? "fast",
@@ -434,8 +440,11 @@ export class VibeSessionRegistry {
434
440
  parentSessionFile: null,
435
441
  agent: getBundledAgent("sonic")!,
436
442
  state: record.state ?? "running",
437
- createdAt: Date.now(),
438
- lastActivityAt: Date.now(),
443
+ createdAt: now,
444
+ lastActivityAt: now,
445
+ turn: record.jobId
446
+ ? { jobId: record.jobId, message: "test turn", startedAt: now, trace: [], toolCount: 0 }
447
+ : undefined,
439
448
  queue: [],
440
449
  turnCount: 0,
441
450
  killed: false,
@@ -1113,25 +1122,31 @@ export class VibeSessionRegistry {
1113
1122
  if (job?.status === "running") runningJobs.push(job);
1114
1123
  }
1115
1124
 
1116
- let waited = false;
1125
+ let waitEndedByTimeout = false;
1117
1126
  if (runningJobs.length > 0 && collectSettled().length === 0) {
1118
- waited = true;
1119
1127
  const timeoutMs = Math.max(1, Math.trunc(args.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS));
1120
1128
  const watchedJobIds = runningJobs.map(job => job.id);
1121
1129
  manager.watchJobs(watchedJobIds);
1122
- const { promise: timeoutPromise, resolve: timeoutResolve } = Promise.withResolvers<void>();
1123
- const timeoutHandle = setTimeout(() => timeoutResolve(), timeoutMs);
1124
- const racePromises: Promise<unknown>[] = [...runningJobs.map(job => job.promise), timeoutPromise];
1130
+ const { promise: timeoutPromise, resolve: timeoutResolve } = Promise.withResolvers<"timeout">();
1131
+ const timeoutHandle = setTimeout(() => timeoutResolve("timeout"), timeoutMs);
1132
+ const racePromises: Array<Promise<"settled" | "timeout" | "aborted">> = [
1133
+ ...runningJobs.map(job => job.promise.then(() => "settled" as const)),
1134
+ timeoutPromise,
1135
+ ];
1125
1136
  let abortCleanup: (() => void) | undefined;
1126
1137
  if (args.signal) {
1127
- const { promise: abortPromise, resolve: abortResolve } = Promise.withResolvers<void>();
1128
- const onAbort = () => abortResolve();
1129
- args.signal.addEventListener("abort", onAbort, { once: true });
1130
- abortCleanup = () => args.signal?.removeEventListener("abort", onAbort);
1138
+ const { promise: abortPromise, resolve: abortResolve } = Promise.withResolvers<"aborted">();
1139
+ const onAbort = () => abortResolve("aborted");
1140
+ if (args.signal.aborted) {
1141
+ onAbort();
1142
+ } else {
1143
+ args.signal.addEventListener("abort", onAbort, { once: true });
1144
+ abortCleanup = () => args.signal?.removeEventListener("abort", onAbort);
1145
+ }
1131
1146
  racePromises.push(abortPromise);
1132
1147
  }
1133
1148
  try {
1134
- await Promise.race(racePromises);
1149
+ waitEndedByTimeout = (await Promise.race(racePromises)) === "timeout";
1135
1150
  } finally {
1136
1151
  manager.unwatchJobs(watchedJobIds);
1137
1152
  clearTimeout(timeoutHandle);
@@ -1144,7 +1159,7 @@ export class VibeSessionRegistry {
1144
1159
  // Current in-flight state, independent of the snapshot: a session whose
1145
1160
  // watched turn settled may already be mid queued follow-up.
1146
1161
  const stillRunning = watched.filter(record => record.turn !== undefined).map(record => record.id);
1147
- return { settled, stillRunning, timedOut: waited && settled.length === 0 };
1162
+ return { settled, stillRunning, timedOut: waitEndedByTimeout && settled.length === 0 };
1148
1163
  }
1149
1164
 
1150
1165
  /** Detach one parent's process-local workers without tombstoning their persisted conversations. */