@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.
- package/CHANGELOG.md +91 -0
- package/dist/cli.js +3855 -3798
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/cli/update-cli.test.d.ts +1 -0
- package/dist/types/commands/update.d.ts +5 -0
- package/dist/types/config/config-file.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/dap/client.d.ts +9 -1
- package/dist/types/discovery/helpers.d.ts +2 -0
- package/dist/types/edit/file-snapshot-store.d.ts +9 -1
- package/dist/types/edit/hashline/execute.d.ts +1 -1
- package/dist/types/edit/hashline/params.d.ts +3 -6
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/extensibility/plugins/parser.d.ts +2 -1
- package/dist/types/hindsight/client.d.ts +15 -11
- package/dist/types/hindsight/client.test.d.ts +1 -0
- package/dist/types/irc/bus.d.ts +5 -4
- package/dist/types/mcp/smithery-registry.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
- package/dist/types/modes/components/advisor-message.d.ts +4 -2
- package/dist/types/modes/components/tool-execution.d.ts +9 -6
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +13 -13
- package/dist/types/session/indexed-session-storage.d.ts +2 -2
- package/dist/types/session/session-storage.d.ts +31 -3
- package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +19 -0
- package/dist/types/ssh/sshfs-mount.d.ts +10 -1
- package/dist/types/subprocess/worker-client.d.ts +20 -6
- package/dist/types/task/discovery.d.ts +5 -2
- package/dist/types/task/renderer.d.ts +1 -0
- package/dist/types/tools/ast-grep.d.ts +4 -2
- package/dist/types/tools/bash.d.ts +27 -0
- package/dist/types/tools/browser/render.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +1 -0
- package/dist/types/tools/eval-render.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -2
- package/dist/types/tools/grep.d.ts +4 -3
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tools/renderers.d.ts +24 -0
- package/dist/types/tools/ssh.d.ts +4 -1
- package/dist/types/tts/index.d.ts +2 -0
- package/dist/types/tts/speakable.d.ts +47 -0
- package/dist/types/tts/speech-enhancer.d.ts +46 -0
- package/dist/types/tts/streaming-player.d.ts +1 -2
- package/dist/types/tts/tts-client.d.ts +11 -10
- package/dist/types/tts/tts-protocol.d.ts +7 -0
- package/dist/types/tts/vocalizer.d.ts +15 -8
- package/dist/types/utils/fetch-timeout.d.ts +4 -0
- package/dist/types/utils/git.d.ts +2 -0
- package/dist/types/web/search/providers/base.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- package/src/cli/gallery-fixtures/fs.ts +2 -2
- package/src/cli/gallery-fixtures/search.ts +2 -2
- package/src/cli/models-cli.ts +19 -0
- package/src/cli/update-cli.test.ts +28 -0
- package/src/cli/update-cli.ts +35 -8
- package/src/cli.ts +27 -5
- package/src/commands/update.ts +8 -2
- package/src/config/config-file.ts +6 -6
- package/src/config/model-resolver.ts +31 -10
- package/src/config/settings-schema.ts +21 -0
- package/src/cursor.ts +1 -1
- package/src/dap/client.ts +134 -36
- package/src/discovery/helpers.ts +8 -0
- package/src/edit/file-snapshot-store.ts +12 -1
- package/src/edit/hashline/diff.ts +4 -13
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/hashline/params.ts +5 -12
- package/src/edit/renderer.ts +4 -2
- package/src/edit/streaming.ts +15 -5
- package/src/export/html/tool-views.generated.js +34 -34
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/extensions/loader.ts +10 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
- package/src/extensibility/plugins/installer.ts +12 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
- package/src/extensibility/plugins/loader.ts +30 -1
- package/src/extensibility/plugins/manager.ts +32 -8
- package/src/extensibility/plugins/parser.ts +7 -5
- package/src/extensibility/tool-event-input.ts +1 -1
- package/src/hindsight/client.test.ts +33 -0
- package/src/hindsight/client.ts +42 -22
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/irc/bus.ts +5 -4
- package/src/main.ts +7 -1
- package/src/mcp/oauth-flow.ts +93 -4
- package/src/mcp/smithery-auth.ts +3 -0
- package/src/mcp/smithery-connect.ts +9 -0
- package/src/mcp/smithery-registry.test.ts +51 -0
- package/src/mcp/smithery-registry.ts +27 -4
- package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
- package/src/modes/components/advisor-message.ts +13 -10
- package/src/modes/components/assistant-message.ts +1 -1
- package/src/modes/components/move-overlay.ts +35 -23
- package/src/modes/components/status-line/component.ts +11 -4
- package/src/modes/components/tool-execution.ts +119 -20
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/components/tree-selector.ts +10 -3
- package/src/modes/controllers/event-controller.ts +23 -3
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +29 -16
- package/src/modes/theme/shimmer.ts +49 -15
- package/src/modes/theme/theme.ts +7 -0
- package/src/prompts/goals/goal-mode-context.md +4 -0
- package/src/prompts/goals/goal-todo-context.md +10 -2
- package/src/prompts/system/speech-rewrite.md +15 -0
- package/src/prompts/system/tiny-title-system.md +1 -1
- package/src/prompts/system/title-system-marker.md +2 -1
- package/src/prompts/system/title-system.md +2 -1
- package/src/prompts/tools/ast-grep.md +3 -3
- package/src/prompts/tools/glob.md +1 -1
- package/src/prompts/tools/grep.md +1 -1
- package/src/sdk.ts +8 -5
- package/src/session/agent-session.ts +329 -79
- package/src/session/indexed-session-storage.ts +40 -3
- package/src/session/session-history-format.ts +6 -2
- package/src/session/session-manager.ts +83 -14
- package/src/session/session-storage.ts +112 -26
- package/src/slash-commands/helpers/usage-report.ts +6 -1
- package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
- package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
- package/src/ssh/connection-manager.ts +44 -11
- package/src/ssh/sshfs-mount.ts +27 -4
- package/src/subprocess/worker-client.ts +161 -10
- package/src/task/discovery.ts +25 -2
- package/src/task/executor.ts +24 -4
- package/src/task/render.ts +26 -12
- package/src/task/renderer.ts +1 -0
- package/src/task/worktree.ts +144 -16
- package/src/tiny/text.ts +109 -17
- package/src/tools/ast-grep.ts +20 -17
- package/src/tools/bash.ts +46 -9
- package/src/tools/browser/render.ts +2 -0
- package/src/tools/debug.ts +1 -0
- package/src/tools/eval-render.ts +5 -2
- package/src/tools/gh-renderer.ts +3 -0
- package/src/tools/glob.ts +24 -20
- package/src/tools/grep.ts +39 -37
- package/src/tools/path-utils.ts +55 -10
- package/src/tools/read.ts +23 -6
- package/src/tools/renderers.ts +24 -0
- package/src/tools/ssh.ts +25 -7
- package/src/tts/index.ts +2 -0
- package/src/tts/speakable.ts +382 -0
- package/src/tts/speech-enhancer.ts +204 -0
- package/src/tts/streaming-player.ts +71 -16
- package/src/tts/tts-client.ts +11 -10
- package/src/tts/tts-protocol.ts +14 -5
- package/src/tts/tts-worker.ts +52 -49
- package/src/tts/vocalizer.ts +277 -46
- package/src/utils/clipboard.ts +49 -12
- package/src/utils/fetch-timeout.ts +10 -0
- package/src/utils/git.ts +8 -0
- package/src/web/search/index.ts +8 -0
- package/src/web/search/providers/base.ts +1 -0
- 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.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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:
|
|
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
|
-
|
|
164
|
-
|
|
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
|
/**
|
package/src/task/discovery.ts
CHANGED
|
@@ -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
|
-
*
|
|
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)
|
package/src/task/executor.ts
CHANGED
|
@@ -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")
|
|
1064
|
-
|
|
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
|
-
|
|
1077
|
+
const truncated = recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES;
|
|
1078
|
+
if (truncated) {
|
|
1071
1079
|
recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
|
|
1072
1080
|
}
|
|
1073
|
-
|
|
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
|
|
package/src/task/render.ts
CHANGED
|
@@ -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
|
-
|
|
154
|
+
items: RenderYieldItem[],
|
|
155
155
|
): { summary: SubmitReviewDetails; findings: ReportFindingDetails[] } | undefined {
|
|
156
|
-
const yieldItems: YieldItem[] =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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 3× `.some()` here plus 3× `.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 =
|
|
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
|
package/src/task/renderer.ts
CHANGED
package/src/task/worktree.ts
CHANGED
|
@@ -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
|
-
|
|
496
|
-
|
|
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 (
|
|
500
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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 });
|