@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.3

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 (163) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3855 -3798
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +20 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/discovery/helpers.d.ts +2 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  11. package/dist/types/edit/hashline/execute.d.ts +1 -1
  12. package/dist/types/edit/hashline/params.d.ts +3 -6
  13. package/dist/types/edit/renderer.d.ts +1 -0
  14. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  15. package/dist/types/hindsight/client.d.ts +15 -11
  16. package/dist/types/hindsight/client.test.d.ts +1 -0
  17. package/dist/types/irc/bus.d.ts +5 -4
  18. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  19. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  20. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  21. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  22. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  23. package/dist/types/modes/theme/theme.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +13 -13
  25. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  26. package/dist/types/session/session-storage.d.ts +31 -3
  27. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  28. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  29. package/dist/types/ssh/connection-manager.d.ts +19 -0
  30. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  31. package/dist/types/subprocess/worker-client.d.ts +20 -6
  32. package/dist/types/task/discovery.d.ts +5 -2
  33. package/dist/types/task/renderer.d.ts +1 -0
  34. package/dist/types/tools/ast-grep.d.ts +4 -2
  35. package/dist/types/tools/bash.d.ts +27 -0
  36. package/dist/types/tools/browser/render.d.ts +2 -0
  37. package/dist/types/tools/debug.d.ts +1 -0
  38. package/dist/types/tools/eval-render.d.ts +2 -0
  39. package/dist/types/tools/glob.d.ts +4 -2
  40. package/dist/types/tools/grep.d.ts +4 -3
  41. package/dist/types/tools/path-utils.d.ts +7 -0
  42. package/dist/types/tools/renderers.d.ts +24 -0
  43. package/dist/types/tools/ssh.d.ts +4 -1
  44. package/dist/types/tts/index.d.ts +2 -0
  45. package/dist/types/tts/speakable.d.ts +47 -0
  46. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  47. package/dist/types/tts/streaming-player.d.ts +1 -2
  48. package/dist/types/tts/tts-client.d.ts +11 -10
  49. package/dist/types/tts/tts-protocol.d.ts +7 -0
  50. package/dist/types/tts/vocalizer.d.ts +15 -8
  51. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  52. package/dist/types/utils/git.d.ts +2 -0
  53. package/dist/types/web/search/providers/base.d.ts +1 -0
  54. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  55. package/package.json +12 -12
  56. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  57. package/src/advisor/__tests__/advisor.test.ts +1 -1
  58. package/src/cli/gallery-fixtures/fs.ts +2 -2
  59. package/src/cli/gallery-fixtures/search.ts +2 -2
  60. package/src/cli/models-cli.ts +19 -0
  61. package/src/cli/update-cli.test.ts +28 -0
  62. package/src/cli/update-cli.ts +35 -8
  63. package/src/cli.ts +27 -5
  64. package/src/commands/update.ts +8 -2
  65. package/src/config/config-file.ts +6 -6
  66. package/src/config/model-resolver.ts +31 -10
  67. package/src/config/settings-schema.ts +21 -0
  68. package/src/cursor.ts +1 -1
  69. package/src/dap/client.ts +134 -36
  70. package/src/discovery/helpers.ts +8 -0
  71. package/src/edit/file-snapshot-store.ts +12 -1
  72. package/src/edit/hashline/diff.ts +4 -13
  73. package/src/edit/hashline/execute.ts +1 -1
  74. package/src/edit/hashline/params.ts +5 -12
  75. package/src/edit/renderer.ts +4 -2
  76. package/src/edit/streaming.ts +15 -5
  77. package/src/export/html/tool-views.generated.js +34 -34
  78. package/src/extensibility/custom-tools/loader.ts +3 -3
  79. package/src/extensibility/extensions/loader.ts +10 -3
  80. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  81. package/src/extensibility/plugins/installer.ts +12 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  83. package/src/extensibility/plugins/loader.ts +30 -1
  84. package/src/extensibility/plugins/manager.ts +32 -8
  85. package/src/extensibility/plugins/parser.ts +7 -5
  86. package/src/extensibility/tool-event-input.ts +1 -1
  87. package/src/hindsight/client.test.ts +33 -0
  88. package/src/hindsight/client.ts +42 -22
  89. package/src/internal-urls/docs-index.generated.txt +1 -1
  90. package/src/irc/bus.ts +5 -4
  91. package/src/main.ts +7 -1
  92. package/src/mcp/oauth-flow.ts +93 -4
  93. package/src/mcp/smithery-auth.ts +3 -0
  94. package/src/mcp/smithery-connect.ts +9 -0
  95. package/src/mcp/smithery-registry.test.ts +51 -0
  96. package/src/mcp/smithery-registry.ts +27 -4
  97. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  98. package/src/modes/components/advisor-message.ts +13 -10
  99. package/src/modes/components/assistant-message.ts +1 -1
  100. package/src/modes/components/move-overlay.ts +35 -23
  101. package/src/modes/components/status-line/component.ts +11 -4
  102. package/src/modes/components/tool-execution.ts +119 -20
  103. package/src/modes/components/transcript-container.ts +26 -0
  104. package/src/modes/components/tree-selector.ts +10 -3
  105. package/src/modes/controllers/event-controller.ts +23 -3
  106. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  107. package/src/modes/interactive-mode.ts +1 -0
  108. package/src/modes/rpc/rpc-client.ts +29 -16
  109. package/src/modes/theme/shimmer.ts +49 -15
  110. package/src/modes/theme/theme.ts +7 -0
  111. package/src/prompts/goals/goal-mode-context.md +4 -0
  112. package/src/prompts/goals/goal-todo-context.md +10 -2
  113. package/src/prompts/system/speech-rewrite.md +15 -0
  114. package/src/prompts/system/tiny-title-system.md +1 -1
  115. package/src/prompts/system/title-system-marker.md +2 -1
  116. package/src/prompts/system/title-system.md +2 -1
  117. package/src/prompts/tools/ast-grep.md +3 -3
  118. package/src/prompts/tools/glob.md +1 -1
  119. package/src/prompts/tools/grep.md +1 -1
  120. package/src/sdk.ts +8 -5
  121. package/src/session/agent-session.ts +329 -79
  122. package/src/session/indexed-session-storage.ts +40 -3
  123. package/src/session/session-history-format.ts +6 -2
  124. package/src/session/session-manager.ts +83 -14
  125. package/src/session/session-storage.ts +112 -26
  126. package/src/slash-commands/helpers/usage-report.ts +6 -1
  127. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  128. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  129. package/src/ssh/connection-manager.ts +44 -11
  130. package/src/ssh/sshfs-mount.ts +27 -4
  131. package/src/subprocess/worker-client.ts +161 -10
  132. package/src/task/discovery.ts +25 -2
  133. package/src/task/executor.ts +24 -4
  134. package/src/task/render.ts +26 -12
  135. package/src/task/renderer.ts +1 -0
  136. package/src/task/worktree.ts +144 -16
  137. package/src/tiny/text.ts +109 -17
  138. package/src/tools/ast-grep.ts +20 -17
  139. package/src/tools/bash.ts +46 -9
  140. package/src/tools/browser/render.ts +2 -0
  141. package/src/tools/debug.ts +1 -0
  142. package/src/tools/eval-render.ts +5 -2
  143. package/src/tools/gh-renderer.ts +3 -0
  144. package/src/tools/glob.ts +24 -20
  145. package/src/tools/grep.ts +39 -37
  146. package/src/tools/path-utils.ts +55 -10
  147. package/src/tools/read.ts +23 -6
  148. package/src/tools/renderers.ts +24 -0
  149. package/src/tools/ssh.ts +25 -7
  150. package/src/tts/index.ts +2 -0
  151. package/src/tts/speakable.ts +382 -0
  152. package/src/tts/speech-enhancer.ts +204 -0
  153. package/src/tts/streaming-player.ts +71 -16
  154. package/src/tts/tts-client.ts +11 -10
  155. package/src/tts/tts-protocol.ts +14 -5
  156. package/src/tts/tts-worker.ts +52 -49
  157. package/src/tts/vocalizer.ts +277 -46
  158. package/src/utils/clipboard.ts +49 -12
  159. package/src/utils/fetch-timeout.ts +10 -0
  160. package/src/utils/git.ts +8 -0
  161. package/src/web/search/index.ts +8 -0
  162. package/src/web/search/providers/base.ts +1 -0
  163. package/src/web/search/providers/gemini.ts +23 -6
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
1
3
  import * as path from "node:path";
2
4
  import {
3
5
  $env,
@@ -64,7 +66,7 @@ export interface RefCountedWorkerHandle<Inbound, Outbound> extends WorkerHandle<
64
66
 
65
67
  /** The raw spawned subprocess plus the parent-side fan-out sets. */
66
68
  export interface SpawnedSubprocess<Outbound> {
67
- proc: Subprocess<"ignore", "ignore", "ignore">;
69
+ proc: Subprocess<"ignore", "ignore", number | "ignore">;
68
70
  inbound: Set<(message: Outbound) => void>;
69
71
  errors: Set<(error: Error) => void>;
70
72
  /**
@@ -74,8 +76,23 @@ export interface SpawnedSubprocess<Outbound> {
74
76
  * worker error so callers don't await forever.
75
77
  */
76
78
  intentionalExit: { value: boolean };
79
+ /**
80
+ * Resolves when the file-backed stderr capture has drained after worker
81
+ * exit. `onExit` waits on this before surfacing the crash so the exit-error
82
+ * carries the *whole* tail, not whatever happened to be flushed before the
83
+ * exit event fired. Tests can await it deterministically instead of racing
84
+ * wall-clock timers.
85
+ */
86
+ stderrDrained: Promise<void>;
77
87
  }
78
88
 
89
+ /**
90
+ * Bound on the tail of worker stderr surfaced with a crash. Sized to comfortably
91
+ * hold a full ONNX Runtime/glibc traceback (a few KiB) without letting a chatty
92
+ * native runtime OOM the parent on repeated warnings.
93
+ */
94
+ const STDERR_TAIL_LIMIT_BYTES = 16 * 1024;
95
+
79
96
  export interface WorkerSpawnCommand {
80
97
  cmd: string[];
81
98
  cwd?: string;
@@ -126,11 +143,17 @@ export function workerEnvFromParent(overlay?: Record<string, string>): Record<st
126
143
  }
127
144
 
128
145
  /**
129
- * Spawn an inference worker subprocess and wire its IPC fan-out. The child
130
- * inherits no stdio (native model runtimes may otherwise print progress or
131
- * decoded text and corrupt the chat scrollback) and is `unref`'d outside `bun
132
- * test` so an idle worker never blocks process exit. `exitLabel` prefixes the
133
- * worker-error message surfaced for an unexpected (non-intentional) exit.
146
+ * Spawn an inference worker subprocess and wire its IPC fan-out. Stdio is
147
+ * captured (stderr redirected to a temp file, stdout ignored) so native
148
+ * runtimes can't corrupt the chat scrollback while the crash reason still
149
+ * reaches the parent. The file-backed capture deliberately avoids Bun
150
+ * `ReadableStream` pipes: even an unref'd child with a piped stderr stream can
151
+ * keep the parent event loop alive. After the worker exits, the last
152
+ * {@link STDERR_TAIL_LIMIT_BYTES} are appended to the `onExit` error so
153
+ * `tts/mnemopi/…: worker error` lines carry the actual stack instead of a bare
154
+ * exit code (issue #4324). The child is `unref`'d outside `bun test` so an idle
155
+ * worker never blocks process exit. `exitLabel` prefixes the worker-error
156
+ * message surfaced for an unexpected (non-intentional) exit.
134
157
  */
135
158
  export function createWorkerSubprocess<Outbound>(options: {
136
159
  spawnCommand: WorkerSpawnCommand;
@@ -140,19 +163,29 @@ export function createWorkerSubprocess<Outbound>(options: {
140
163
  const inbound = new Set<(message: Outbound) => void>();
141
164
  const errors = new Set<(error: Error) => void>();
142
165
  const intentionalExit = { value: false };
166
+ const stderrTail = new StderrTail(STDERR_TAIL_LIMIT_BYTES);
167
+ const stderrDrained = Promise.withResolvers<void>();
168
+ const stderrCapture = createStderrCapture(options.exitLabel);
169
+ let stderrDrainStarted = false;
170
+ const startStderrDrain = (): void => {
171
+ if (stderrDrainStarted) return;
172
+ stderrDrainStarted = true;
173
+ void drainStderrCapture(stderrCapture, options.exitLabel, stderrTail).finally(() => stderrDrained.resolve());
174
+ };
143
175
  const proc = Bun.spawn({
144
176
  cmd: options.spawnCommand.cmd,
145
177
  cwd: options.spawnCommand.cwd,
146
178
  env: options.env,
147
179
  stdin: "ignore",
148
180
  stdout: "ignore",
149
- stderr: "ignore",
181
+ stderr: stderrCapture.target,
150
182
  serialization: "advanced",
151
183
  windowsHide: true,
152
184
  ipc(message) {
153
185
  for (const handler of inbound) handler(message as Outbound);
154
186
  },
155
187
  onExit(_proc, exitCode, signalCode) {
188
+ startStderrDrain();
156
189
  if (exitCode === 0) return;
157
190
  // Swallow only the expected SIGKILL from `terminate()`; every other
158
191
  // signal exit (SIGSEGV from a native fault, OOM SIGKILL, operator
@@ -160,15 +193,133 @@ export function createWorkerSubprocess<Outbound>(options: {
160
193
  // requests so callers don't await forever.
161
194
  if (exitCode === null && intentionalExit.value) return;
162
195
  const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
163
- const err = new Error(`${options.exitLabel} exited with ${reason}`);
164
- for (const handler of errors) handler(err);
196
+ // The stderr target is drained only after exit so idle unref'd
197
+ // workers do not keep the parent alive; wait for that drain before
198
+ // surfacing the error so the tail is complete.
199
+ void stderrDrained.promise.finally(() => {
200
+ const suffix = stderrTail.suffix();
201
+ const err = new Error(`${options.exitLabel} exited with ${reason}${suffix}`);
202
+ for (const handler of errors) handler(err);
203
+ });
165
204
  },
166
205
  });
167
206
  // Don't keep the parent event loop alive on an idle worker; the dispose
168
207
  // path calls `terminate()` explicitly. Bun's test runner starves IPC for
169
208
  // unref'd subprocesses, so keep it referenced only under tests.
170
209
  if (!isBunTestRuntime()) proc.unref();
171
- return { proc, inbound, errors, intentionalExit };
210
+ return { proc, inbound, errors, intentionalExit, stderrDrained: stderrDrained.promise };
211
+ }
212
+
213
+ /**
214
+ * Bounded buffer of the *tail* of a stderr stream. Appended chunks are
215
+ * concatenated and truncated from the front once they exceed `limit`, so the
216
+ * final `suffix()` always reflects the most recent output — where native
217
+ * crash tracebacks land.
218
+ */
219
+ class StderrTail {
220
+ #chunks: Uint8Array[] = [];
221
+ #bytes = 0;
222
+ constructor(readonly limit: number) {}
223
+
224
+ append(chunk: Uint8Array): void {
225
+ if (chunk.length === 0) return;
226
+ this.#chunks.push(chunk);
227
+ this.#bytes += chunk.length;
228
+ while (this.#bytes > this.limit && this.#chunks.length > 1) {
229
+ const head = this.#chunks.shift();
230
+ if (head) this.#bytes -= head.length;
231
+ }
232
+ if (this.#bytes > this.limit && this.#chunks.length === 1) {
233
+ const only = this.#chunks[0];
234
+ const start = only.length - this.limit;
235
+ this.#chunks[0] = only.subarray(start);
236
+ this.#bytes = this.limit;
237
+ }
238
+ }
239
+
240
+ /** Human-readable trailer for an exit error, or `""` when nothing was captured. */
241
+ suffix(): string {
242
+ if (this.#bytes === 0) return "";
243
+ const merged = new Uint8Array(this.#bytes);
244
+ let offset = 0;
245
+ for (const chunk of this.#chunks) {
246
+ merged.set(chunk, offset);
247
+ offset += chunk.length;
248
+ }
249
+ const text = new TextDecoder().decode(merged).replace(/\s+$/u, "");
250
+ if (text.length === 0) return "";
251
+ return `: ${text}`;
252
+ }
253
+ }
254
+
255
+ interface StderrCapture {
256
+ target: number | "ignore";
257
+ fd: number | null;
258
+ dir: string | null;
259
+ cleanupOnExit: (() => void) | null;
260
+ }
261
+
262
+ /** Create a file-backed stderr target that does not pin Bun's event loop. */
263
+ function createStderrCapture(exitLabel: string): StderrCapture {
264
+ try {
265
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-worker-stderr-"));
266
+ const fd = fs.openSync(path.join(dir, "stderr.log"), "w+");
267
+ const cleanupOnExit = (): void => cleanupStderrCapture({ target: fd, fd, dir, cleanupOnExit: null });
268
+ process.once("exit", cleanupOnExit);
269
+ return { target: fd, fd, dir, cleanupOnExit };
270
+ } catch (error) {
271
+ logger.debug(`${exitLabel} stderr capture unavailable`, {
272
+ error: error instanceof Error ? error.message : String(error),
273
+ });
274
+ return { target: "ignore", fd: null, dir: null, cleanupOnExit: null };
275
+ }
276
+ }
277
+
278
+ function cleanupStderrCapture(capture: StderrCapture): void {
279
+ if (capture.cleanupOnExit) process.off("exit", capture.cleanupOnExit);
280
+ if (capture.fd !== null) {
281
+ try {
282
+ fs.closeSync(capture.fd);
283
+ } catch {
284
+ // Already closed.
285
+ }
286
+ capture.fd = null;
287
+ }
288
+ if (capture.dir) {
289
+ try {
290
+ fs.rmSync(capture.dir, { recursive: true, force: true });
291
+ } catch {
292
+ // Best-effort temp cleanup.
293
+ }
294
+ capture.dir = null;
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Drain a worker's file-backed stderr target after it exits: forward each
300
+ * decoded tail line to `logger.debug`, and record the bytes in `tail` so the
301
+ * eventual exit error can carry the most recent output. Never rejects — cleanup
302
+ * failures must not fault the parent.
303
+ */
304
+ async function drainStderrCapture(capture: StderrCapture, exitLabel: string, tail: StderrTail): Promise<void> {
305
+ try {
306
+ if (capture.fd === null) return;
307
+ const size = fs.fstatSync(capture.fd).size;
308
+ if (size <= 0) return;
309
+ const length = Math.min(size, tail.limit);
310
+ const buffer = new Uint8Array(length);
311
+ fs.readSync(capture.fd, buffer, 0, length, size - length);
312
+ tail.append(buffer);
313
+ for (const rawLine of new TextDecoder().decode(buffer).split("\n")) {
314
+ const line = rawLine.replace(/\r$/u, "");
315
+ if (line.length > 0) logger.debug(`${exitLabel} stderr`, { line });
316
+ }
317
+ } catch {
318
+ // The worker may have exited while the parent is already tearing down,
319
+ // or the temp file may have been removed by process-exit cleanup.
320
+ } finally {
321
+ cleanupStderrCapture(capture);
322
+ }
172
323
  }
173
324
 
174
325
  /**
@@ -4,6 +4,11 @@
4
4
  * Discovers agent definitions from OMP-native task-agent roots:
5
5
  * - ~/.omp/agent/agents/*.md (user-level)
6
6
  * - .omp/agents/*.md (project-level)
7
+ * - <ext>/agents/*.md for every OMP extension package wired through
8
+ * `listOmpExtensionRoots` (CLI `--extension` roots, `extensions:` in
9
+ * settings, and enabled npm/link plugins under `<plugins>/node_modules/`).
10
+ * Mirrors the same sub-discovery convention applied to `skills/`,
11
+ * `hooks/`, `tools/`, etc. by `discovery/omp-plugins.ts`.
7
12
  *
8
13
  * Claude Code marketplace plugin agents are discovered separately via the
9
14
  * claude-plugins provider. Direct cross-harness roots such as .claude/agents
@@ -19,6 +24,7 @@ import { logger } from "@oh-my-pi/pi-utils";
19
24
  import { isProviderEnabled } from "../capability";
20
25
  import { findAllNearestProjectConfigDirs, getConfigDirs } from "../config";
21
26
  import { listClaudePluginRoots } from "../discovery/helpers";
27
+ import { listOmpExtensionRoots } from "../discovery/omp-extension-roots";
22
28
  import { loadBundledAgents, parseAgent } from "./agents";
23
29
  import type { AgentDefinition, AgentSource } from "./types";
24
30
 
@@ -54,8 +60,11 @@ async function loadAgentsFromDir(dir: string, source: AgentSource): Promise<Agen
54
60
 
55
61
  /**
56
62
  * Discover agents from filesystem and merge with bundled agents.
57
- *
58
- * Precedence (highest wins): project .omp, user .omp, Claude plugin agents, then bundled
63
+ * Precedence (highest wins): project `.omp/agents`, user `.omp/agents`,
64
+ * OMP extension-package agents in `listOmpExtensionRoots` source order
65
+ * (CLI roots > project `extensions:` settings > user `extensions:` settings >
66
+ * installed npm/link plugins), Claude marketplace plugin agents (project
67
+ * scope before user), then bundled.
59
68
  * @param cwd - Current working directory for project agent discovery
60
69
  */
61
70
  export async function discoverAgents(cwd: string, home: string = os.homedir()): Promise<DiscoveryResult> {
@@ -81,6 +90,20 @@ export async function discoverAgents(cwd: string, home: string = os.homedir()):
81
90
  const user = userDirs[0];
82
91
  if (user) orderedDirs.push({ dir: user.path, source: "user" });
83
92
 
93
+ // OMP extension-package agents/ dirs. `listOmpExtensionRoots` returns roots in
94
+ // source-precedence order (CLI > project `extensions:` settings > user
95
+ // `extensions:` settings > installed npm/link plugins, with marketplace
96
+ // installs already excluded by realpath) — consume that order verbatim so the
97
+ // `task` agent surface dedups identically to the sibling skills/hooks/tools
98
+ // surface in `discovery/omp-plugins.ts`. Gate on `omp-plugins` so
99
+ // disabledProviders suppresses the whole extension-package surface.
100
+ const extensionRoots = isProviderEnabled("omp-plugins")
101
+ ? await listOmpExtensionRoots({ cwd: resolvedCwd, home, repoRoot: null })
102
+ : [];
103
+ for (const root of extensionRoots) {
104
+ orderedDirs.push({ dir: path.join(root.path, "agents"), source: root.level });
105
+ }
106
+
84
107
  // Load agents from Claude Code marketplace plugins (respects disabledProviders)
85
108
  const { roots: pluginRoots } = isProviderEnabled("claude-plugins")
86
109
  ? await listClaudePluginRoots(home, resolvedCwd)
@@ -882,6 +882,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
882
882
  const finalOutputChunks: string[] = [];
883
883
  const RECENT_OUTPUT_TAIL_BYTES = 8 * 1024;
884
884
  let recentOutputTail = "";
885
+ let tailLastLineRepresentable = false;
885
886
  let resolved = false;
886
887
  let abortSent = false;
887
888
  let abortReason: AbortReason | undefined;
@@ -1060,17 +1061,35 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1060
1061
  };
1061
1062
 
1062
1063
  const updateRecentOutputLines = () => {
1063
- const lines = recentOutputTail.split("\n").filter(line => line.trim());
1064
- progress.recentOutput = lines.slice(-8).reverse();
1064
+ const lines = recentOutputTail.split("\n");
1065
+ const filtered = lines.filter(line => line.trim());
1066
+ progress.recentOutput = filtered.slice(-8).reverse();
1067
+ // The tail's last raw segment (after its final newline) is "represented"
1068
+ // in recentOutput only when it trims non-empty — an empty/whitespace-only
1069
+ // trailing segment is filtered out, so recentOutput[0] is then the line
1070
+ // before it, not the tail's true last line.
1071
+ tailLastLineRepresentable = lines[lines.length - 1].trim().length > 0;
1065
1072
  };
1066
1073
 
1067
1074
  const appendRecentOutputTail = (text: string) => {
1068
1075
  if (!text) return;
1069
1076
  recentOutputTail += text;
1070
- if (recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES) {
1077
+ const truncated = recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES;
1078
+ if (truncated) {
1071
1079
  recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
1072
1080
  }
1073
- updateRecentOutputLines();
1081
+ // Fast path: a token without a newline only extends the current last line.
1082
+ // This runs on every text_delta token (hundreds/thousands per second while
1083
+ // streaming), so skip re-splitting the whole (up to 8KB) tail unless the line
1084
+ // structure actually changed. Requires no truncation AND the tail's last line
1085
+ // already represented (trims non-empty) — otherwise boundaries shift and a
1086
+ // full recompute is required. Appending to a non-empty line keeps it non-empty,
1087
+ // so the flag stays valid across consecutive fast-path tokens.
1088
+ if (truncated || text.includes("\n") || !tailLastLineRepresentable || progress.recentOutput.length === 0) {
1089
+ updateRecentOutputLines();
1090
+ } else {
1091
+ progress.recentOutput = [progress.recentOutput[0] + text, ...progress.recentOutput.slice(1)];
1092
+ }
1074
1093
  };
1075
1094
 
1076
1095
  const replaceRecentOutputFromContent = (content: unknown[]) => {
@@ -1090,6 +1109,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1090
1109
 
1091
1110
  const resetRecentOutput = () => {
1092
1111
  recentOutputTail = "";
1112
+ tailLastLineRepresentable = false;
1093
1113
  progress.recentOutput = [];
1094
1114
  };
1095
1115
 
@@ -151,9 +151,9 @@ function normalizeReportFindings(value: unknown): ReportFindingDetails[] {
151
151
  const REVIEWER_ARRAY_LABELS: ReadonlySet<string> = new Set(["findings"]);
152
152
 
153
153
  function extractIncrementalReviewResult(
154
- value: unknown,
154
+ items: RenderYieldItem[],
155
155
  ): { summary: SubmitReviewDetails; findings: ReportFindingDetails[] } | undefined {
156
- const yieldItems: YieldItem[] = normalizeYieldData(value).map(item => ({
156
+ const yieldItems: YieldItem[] = items.map(item => ({
157
157
  data: item.data,
158
158
  type: item.type,
159
159
  status: item.status === "aborted" ? "aborted" : item.status === "success" ? "success" : undefined,
@@ -962,7 +962,7 @@ function renderAgentProgress(
962
962
  // yield sections. Fall back to the legacy `report_finding` side-channel.
963
963
  if (progress.status === "completed") {
964
964
  const completeData = normalizeYieldData(progress.extractedToolData.yield);
965
- const incrementalReview = extractIncrementalReviewResult(progress.extractedToolData.yield);
965
+ const incrementalReview = extractIncrementalReviewResult(completeData);
966
966
  const reportFindingData = normalizeReportFindings(progress.extractedToolData.report_finding);
967
967
  if (incrementalReview) {
968
968
  lines.push(
@@ -1253,7 +1253,7 @@ function renderAgentResult(
1253
1253
  // is not a function` when the slot is a plain object (see issue #1987).
1254
1254
  const completeData = normalizeYieldData(result.extractedToolData?.yield);
1255
1255
  const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding);
1256
- const incrementalReview = extractIncrementalReviewResult(result.extractedToolData?.yield);
1256
+ const incrementalReview = extractIncrementalReviewResult(completeData);
1257
1257
 
1258
1258
  if (incrementalReview) {
1259
1259
  lines.push(
@@ -1491,9 +1491,27 @@ export function renderResult(
1491
1491
  }
1492
1492
 
1493
1493
  const hasResults = Boolean(details.results && details.results.length > 0);
1494
- const aborted = hasResults && details.results.some(r => r.aborted);
1495
- const failed = hasResults && details.results.some(r => !r.aborted && r.exitCode !== 0);
1496
- const mergeFailed = hasResults && details.results.some(r => !r.aborted && r.exitCode === 0 && Boolean(r.error));
1494
+ // Single pass over details.results derives the header booleans AND the footer
1495
+ // counts/totals. This block re-runs ~30×/sec via the 33ms spinner render; the
1496
+ // previous form did `.some()` here plus `.filter()` + `.reduce()` again
1497
+ // inside the frame below (7+ full passes per tick).
1498
+ let abortedCount = 0;
1499
+ let failCount = 0;
1500
+ let mergeFailedCount = 0;
1501
+ let successCount = 0;
1502
+ let requestTotal = 0;
1503
+ if (hasResults) {
1504
+ for (const r of details.results) {
1505
+ requestTotal += r.requests ?? 0;
1506
+ if (r.aborted) abortedCount++;
1507
+ else if (r.exitCode !== 0) failCount++;
1508
+ else if (r.error) mergeFailedCount++;
1509
+ else successCount++;
1510
+ }
1511
+ }
1512
+ const aborted = abortedCount > 0;
1513
+ const failed = failCount > 0;
1514
+ const mergeFailed = mergeFailedCount > 0;
1497
1515
  const isError = aborted || failed;
1498
1516
  const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0);
1499
1517
  const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
@@ -1552,16 +1570,12 @@ export function renderResult(
1552
1570
  );
1553
1571
  }
1554
1572
 
1555
- const abortedCount = details.results.filter(r => r.aborted).length;
1556
- const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
1557
- const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
1558
- const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
1559
1573
  const summaryParts: string[] = [];
1560
1574
  if (abortedCount > 0) summaryParts.push(theme.fg("error", `${abortedCount} aborted`));
1561
1575
  if (successCount > 0) summaryParts.push(theme.fg("success", `${successCount} succeeded`));
1562
1576
  if (mergeFailedCount > 0) summaryParts.push(theme.fg("warning", `${mergeFailedCount} merge failed`));
1563
1577
  if (failCount > 0) summaryParts.push(theme.fg("error", `${failCount} failed`));
1564
- const totalRequests = details.results.reduce((sum, r) => sum + (r.requests ?? 0), 0);
1578
+ const totalRequests = requestTotal;
1565
1579
  if (totalRequests > 0) summaryParts.push(theme.fg("dim", `${formatNumber(totalRequests)} req`));
1566
1580
  summaryParts.push(theme.fg("dim", formatDuration(details.totalDurationMs)));
1567
1581
  // Wrap the run summary in the theme's bracket glyphs (dim chrome, colored
@@ -11,4 +11,5 @@ export const taskToolRenderer = {
11
11
  renderCall,
12
12
  renderResult,
13
13
  mergeCallAndResult: true,
14
+ animatedPartialResult: true,
14
15
  } as const;
@@ -481,41 +481,148 @@ function baselineHasRootWip(baseline: RepoBaseline): boolean {
481
481
  return !!(baseline.staged.trim() || baseline.unstaged.trim() || baseline.untrackedPatch.trim());
482
482
  }
483
483
 
484
+ /**
485
+ * Baseline WIP context needed to safely apply a delta patch whose hunks were
486
+ * captured against `HEAD + WIP` (see {@link captureRepoDeltaPatch}). Passed
487
+ * whenever {@link baselineHasRootWip} is true so
488
+ * {@link commitPatchToBranchWorktree} can replay the WIP into the temp
489
+ * worktree first, then rewind WIP-only files after applying the delta.
490
+ */
491
+ interface BaselineWipContext {
492
+ readonly staged: string;
493
+ readonly unstaged: string;
494
+ readonly untrackedPatch: string;
495
+ /** Untracked file paths present in the baseline (never in HEAD). */
496
+ readonly untracked: readonly string[];
497
+ }
498
+
499
+ function collectWipPatches(wip: BaselineWipContext | undefined): string[] {
500
+ if (!wip) return [];
501
+ return [wip.staged, wip.unstaged, wip.untrackedPatch].filter(p => p.trim());
502
+ }
503
+
484
504
  async function commitPatchToBranchWorktree(
485
505
  tmpDir: string,
486
506
  taskId: string,
487
507
  patchText: string,
488
508
  message: string,
489
509
  author?: git.CommitAuthor,
510
+ baselineWip?: BaselineWipContext,
490
511
  ): Promise<void> {
512
+ // Try the two clean paths first — they yield an agent-only commit and are
513
+ // the happy case when the temp worktree can resolve the patch against
514
+ // HEAD directly:
515
+ //
516
+ // 1. Plain apply — works when WIP context happens to match HEAD (e.g.
517
+ // WIP-touched files that the delta patch doesn't reference).
518
+ // 2. `--3way` — works when the WIP-side blob is tracked in HEAD and
519
+ // lives in the shared ODB (captureDeltaPatch seeded it while writing
520
+ // the synthetic baseline tree). The 3-way merge subtracts WIP,
521
+ // producing an agent-only commit even when WIP and agent modify the
522
+ // same tracked file at unrelated lines.
523
+ //
524
+ // If both fail (untracked WIP files, staged-new WIP files, or overlap that
525
+ // --3way can't resolve — see #4136), replay the WIP into the worktree
526
+ // first so the delta's context lines match, then rewind WIP-only files so
527
+ // they don't leak into the commit. Files touched by BOTH WIP and delta
528
+ // keep their combined state; the parent's stash-pop reconciles the WIP
529
+ // side via 3-way merge on merge-back.
530
+ let plainErr: git.GitCommandError | undefined;
491
531
  try {
492
532
  await git.patch.applyText(tmpDir, patchText);
493
533
  } catch (err) {
494
534
  if (!(err instanceof git.GitCommandError)) throw err;
495
- // Plain apply rejects when the parent checkout carries unrelated dirty
496
- // context near the task's edits; retry with --3way before giving up.
535
+ plainErr = err;
536
+ }
537
+ if (plainErr) {
538
+ let threeWayErr: git.GitCommandError | undefined;
497
539
  try {
498
540
  await git.patch.applyText(tmpDir, patchText, { threeWay: true });
499
- } catch (threeWayErr) {
500
- if (threeWayErr instanceof git.GitCommandError) {
541
+ } catch (err) {
542
+ if (!(err instanceof git.GitCommandError)) throw err;
543
+ threeWayErr = err;
544
+ }
545
+ if (threeWayErr) {
546
+ const wipPatches = collectWipPatches(baselineWip);
547
+ if (wipPatches.length === 0 || !baselineWip) {
501
548
  const stderr = threeWayErr.result.stderr.slice(0, 2000);
502
549
  logger.error("commitToBranch: git apply --3way failed", {
503
550
  taskId,
504
551
  exitCode: threeWayErr.result.exitCode,
505
552
  stderr,
506
- initialStderr: err.result.stderr.slice(0, 2000),
553
+ initialStderr: plainErr.result.stderr.slice(0, 2000),
507
554
  patchSize: patchText.length,
508
555
  patchHead: patchText.slice(0, 500),
509
556
  });
510
557
  throw new Error(`git apply --3way failed for task ${taskId}: ${stderr}`);
511
558
  }
512
- throw threeWayErr;
559
+ try {
560
+ // `git apply --3way` leaves conflict markers in `U` files when
561
+ // it can't resolve; reset the worktree so the WIP-seeded retry
562
+ // starts from a clean HEAD tree.
563
+ await git.reset(tmpDir, { hard: true, target: "HEAD" });
564
+ await applyDeltaOverBaselineWip(tmpDir, taskId, patchText, wipPatches, baselineWip);
565
+ } catch (wipErr) {
566
+ if (!(wipErr instanceof git.GitCommandError)) throw wipErr;
567
+ const stderr = wipErr.result.stderr.slice(0, 2000);
568
+ logger.error("commitToBranch: git apply with baseline WIP failed", {
569
+ taskId,
570
+ exitCode: wipErr.result.exitCode,
571
+ stderr,
572
+ threeWayStderr: threeWayErr.result.stderr.slice(0, 2000),
573
+ initialStderr: plainErr.result.stderr.slice(0, 2000),
574
+ patchSize: patchText.length,
575
+ patchHead: patchText.slice(0, 500),
576
+ });
577
+ throw new Error(`git apply with baseline WIP failed for task ${taskId}: ${stderr}`);
578
+ }
513
579
  }
514
580
  }
581
+
515
582
  await git.stage.files(tmpDir);
516
583
  await git.commit(tmpDir, message, author ? { author } : {});
517
584
  }
518
585
 
586
+ /**
587
+ * Replay baseline WIP into the temp worktree so the delta patch's HEAD+WIP
588
+ * context matches, apply the delta, then rewind files WIP touched but the
589
+ * delta didn't — HEAD-tracked files are restored via `git restore`, untracked
590
+ * or staged-new WIP files are removed from the worktree. The commit that
591
+ * follows reflects agent's delta plus any overlap with WIP; parent's
592
+ * stash-pop reconciles the WIP side on merge-back.
593
+ */
594
+ async function applyDeltaOverBaselineWip(
595
+ tmpDir: string,
596
+ _taskId: string,
597
+ patchText: string,
598
+ wipPatches: readonly string[],
599
+ baselineWip: BaselineWipContext,
600
+ ): Promise<void> {
601
+ for (const wip of wipPatches) {
602
+ await git.patch.applyText(tmpDir, wip);
603
+ }
604
+ await git.patch.applyText(tmpDir, patchText);
605
+
606
+ const wipFiles = new Set(wipPatches.flatMap(patchTouchedFiles));
607
+ const deltaFiles = new Set(patchTouchedFiles(patchText));
608
+ const wipOnly = [...wipFiles].filter(f => !deltaFiles.has(f));
609
+ if (wipOnly.length === 0) return;
610
+
611
+ // Any wipOnly file baselined as untracked cannot be in HEAD.
612
+ // Everything else may or may not — verify against HEAD's tree.
613
+ const untrackedSet = new Set(baselineWip.untracked);
614
+ const candidates = wipOnly.filter(f => !untrackedSet.has(f));
615
+ const inHead = candidates.length > 0 ? new Set(await git.ls.tree(tmpDir, "HEAD", candidates)) : new Set<string>();
616
+ const toRestore = candidates.filter(f => inHead.has(f));
617
+ const toRemove = wipOnly.filter(f => !toRestore.includes(f));
618
+ if (toRestore.length > 0) {
619
+ await git.restore(tmpDir, { source: "HEAD", staged: true, worktree: true, files: toRestore });
620
+ }
621
+ for (const rel of toRemove) {
622
+ await fs.rm(path.join(tmpDir, rel), { force: true });
623
+ }
624
+ }
625
+
519
626
  interface FilteredAgentReplayOptions {
520
627
  baseline: WorktreeBaseline;
521
628
  branchName: string;
@@ -542,6 +649,7 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
542
649
  opts.baseline.root.untrackedPatch,
543
650
  ]);
544
651
  let previousFilteredTree = baselineSha;
652
+ let filteredCommitsApplied = 0;
545
653
 
546
654
  for (const commitSha of agentCommits) {
547
655
  const taskStatePatch = await git.diff.tree(opts.isolationDir, dirtyBaselineTree, `${commitSha}^{tree}`, {
@@ -562,18 +670,37 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
562
670
  details.message || commitSha,
563
671
  details.author,
564
672
  );
673
+ filteredCommitsApplied++;
565
674
  }
566
675
  previousFilteredTree = currentFilteredTree;
567
676
  }
568
-
569
- const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
570
- const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
571
- allowFailure: true,
572
- binary: true,
573
- });
574
- if (leftoverPatch.trim()) {
575
- const msg = (opts.commitMessage && (await opts.commitMessage(leftoverPatch))) || opts.fallbackMessage;
576
- await commitPatchToBranchWorktree(tmpDir, opts.taskId, leftoverPatch, msg);
677
+ if (filteredCommitsApplied === 0) {
678
+ // No filtered commit landed — tmpDir is still pinned at baselineSha.
679
+ // The `finalFilteredTree = writeSyntheticTree(HEAD, [rootPatch])`
680
+ // path here fails hard whenever rootPatch's WIP-context can't be
681
+ // applied to a HEAD-only index (untracked WIP + agent modifies,
682
+ // staged-new WIP + agent modifies — see #4136). Bypass the synthesis
683
+ // entirely and collapse the isolation output onto a single commit
684
+ // with WIP seed, matching the no-agent-commit path in commitToBranch.
685
+ // This also handles the "agent committed only baseline WIP" corner
686
+ // case where every filtered patch collapsed to empty.
687
+ if (opts.rootPatch.trim()) {
688
+ const msg = (opts.commitMessage && (await opts.commitMessage(opts.rootPatch))) || opts.fallbackMessage;
689
+ await commitPatchToBranchWorktree(tmpDir, opts.taskId, opts.rootPatch, msg, undefined, opts.baseline.root);
690
+ }
691
+ } else {
692
+ // A filtered commit landed; tmpDir has advanced past baselineSha and
693
+ // previousFilteredTree is HEAD-derived, so writeSyntheticTree +
694
+ // leftoverPatch stay HEAD-based and no WIP seed is needed.
695
+ const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
696
+ const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
697
+ allowFailure: true,
698
+ binary: true,
699
+ });
700
+ if (leftoverPatch.trim()) {
701
+ const msg = (opts.commitMessage && (await opts.commitMessage(leftoverPatch))) || opts.fallbackMessage;
702
+ await commitPatchToBranchWorktree(tmpDir, opts.taskId, leftoverPatch, msg);
703
+ }
577
704
  }
578
705
  } finally {
579
706
  await git.worktree.tryRemove(opts.repoRoot, tmpDir);
@@ -674,7 +801,8 @@ export async function commitToBranch(
674
801
  await git.worktree.add(repoRoot, tmpDir, branchName);
675
802
 
676
803
  const msg = (commitMessage && (await commitMessage(rootPatch))) || fallbackMessage;
677
- await commitPatchToBranchWorktree(tmpDir, taskId, rootPatch, msg);
804
+ const wip = baselineHasRootWip(baseline.root) ? baseline.root : undefined;
805
+ await commitPatchToBranchWorktree(tmpDir, taskId, rootPatch, msg, undefined, wip);
678
806
  } finally {
679
807
  await git.worktree.tryRemove(repoRoot, tmpDir);
680
808
  await fs.rm(tmpDir, { recursive: true, force: true });