@oh-my-pi/pi-coding-agent 6.8.1 → 6.8.2
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 +17 -0
- package/package.json +6 -6
- package/src/core/agent-session.ts +13 -4
- package/src/core/auth-storage.ts +5 -1
- package/src/core/bash-executor.ts +3 -2
- package/src/core/prompt-templates.ts +1 -1
- package/src/core/tools/lsp/client.ts +1 -1
- package/src/core/tools/patch/applicator.ts +38 -24
- package/src/core/tools/patch/diff.ts +7 -3
- package/src/core/tools/patch/fuzzy.ts +19 -1
- package/src/core/tools/patch/index.ts +4 -1
- package/src/core/tools/patch/types.ts +4 -0
- package/src/core/tools/task/executor.ts +21 -5
- package/src/core/tools/task/worker.ts +2 -1
- package/src/core/tools/web-scrapers/youtube.ts +6 -49
- package/src/lib/worktree/collapse.ts +3 -3
- package/src/lib/worktree/git.ts +6 -40
- package/src/lib/worktree/index.ts +1 -1
- package/src/utils/clipboard.ts +3 -5
- package/src/core/tools/task/model-resolver.ts +0 -206
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [6.8.2] - 2026-01-21
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Improved error messages when multiple text occurrences are found by showing line previews and context
|
|
10
|
+
- Enhanced patch application to better handle duplicate content in context lines
|
|
11
|
+
- Added occurrence previews to help users disambiguate between multiple matches
|
|
12
|
+
- Fixed cache invalidation for streaming edits to prevent stale data
|
|
13
|
+
- Fixed file existence check for prompt templates directory
|
|
14
|
+
- Fixed bash output streaming to prevent premature stream closure
|
|
15
|
+
- Fixed LSP client request handling when signal is already aborted
|
|
16
|
+
- Fixed git apply operations with stdin input handling
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
|
|
20
|
+
- Updated Anthropic authentication to handle manual code input securely
|
|
21
|
+
|
|
5
22
|
## [6.8.1] - 2026-01-20
|
|
6
23
|
|
|
7
24
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "6.8.
|
|
3
|
+
"version": "6.8.2",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"ompConfig": {
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"prepublishOnly": "bun run generate-template && bun run clean && bun run build"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@oh-my-pi/pi-agent-core": "6.8.
|
|
44
|
-
"@oh-my-pi/pi-ai": "6.8.
|
|
45
|
-
"@oh-my-pi/pi-git-tool": "6.8.
|
|
46
|
-
"@oh-my-pi/pi-tui": "6.8.
|
|
47
|
-
"@oh-my-pi/pi-utils": "6.8.
|
|
43
|
+
"@oh-my-pi/pi-agent-core": "6.8.2",
|
|
44
|
+
"@oh-my-pi/pi-ai": "6.8.2",
|
|
45
|
+
"@oh-my-pi/pi-git-tool": "6.8.2",
|
|
46
|
+
"@oh-my-pi/pi-tui": "6.8.2",
|
|
47
|
+
"@oh-my-pi/pi-utils": "6.8.2",
|
|
48
48
|
"@openai/agents": "^0.3.7",
|
|
49
49
|
"@sinclair/typebox": "^0.34.46",
|
|
50
50
|
"ajv": "^8.17.1",
|
|
@@ -454,15 +454,19 @@ export class AgentSession {
|
|
|
454
454
|
}
|
|
455
455
|
|
|
456
456
|
if (event.message.role === "toolResult") {
|
|
457
|
-
const { $normative, toolCallId } = event.message as {
|
|
457
|
+
const { toolName, $normative, toolCallId, details } = event.message as {
|
|
458
458
|
toolName?: string;
|
|
459
459
|
toolCallId?: string;
|
|
460
|
-
details?:
|
|
460
|
+
details?: { path?: string };
|
|
461
461
|
$normative?: Record<string, unknown>;
|
|
462
462
|
};
|
|
463
463
|
if ($normative && toolCallId && this.settingsManager.getNormativeRewrite()) {
|
|
464
464
|
await this._rewriteToolCallArgs(toolCallId, $normative);
|
|
465
465
|
}
|
|
466
|
+
// Invalidate streaming edit cache when edit tool completes to prevent stale data
|
|
467
|
+
if (toolName === "edit" && details?.path) {
|
|
468
|
+
this._invalidateFileCacheForPath(details.path);
|
|
469
|
+
}
|
|
466
470
|
}
|
|
467
471
|
}
|
|
468
472
|
|
|
@@ -579,11 +583,16 @@ export class AgentSession {
|
|
|
579
583
|
this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
|
|
580
584
|
}
|
|
581
585
|
} catch {
|
|
582
|
-
//
|
|
583
|
-
this._streamingEditFileCache.set(resolvedPath, "");
|
|
586
|
+
// Don't cache on read errors - let the edit tool handle them
|
|
584
587
|
}
|
|
585
588
|
}
|
|
586
589
|
|
|
590
|
+
/** Invalidate cache for a file after an edit completes to prevent stale data */
|
|
591
|
+
private _invalidateFileCacheForPath(path: string): void {
|
|
592
|
+
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
|
|
593
|
+
this._streamingEditFileCache.delete(resolvedPath);
|
|
594
|
+
}
|
|
595
|
+
|
|
587
596
|
private _maybeAbortStreamingEdit(event: AgentEvent): void {
|
|
588
597
|
if (!this.settingsManager.getEditStreamingAbort()) return;
|
|
589
598
|
if (this._streamingEditAbortTriggered) return;
|
package/src/core/auth-storage.ts
CHANGED
|
@@ -560,7 +560,11 @@ export class AuthStorage {
|
|
|
560
560
|
|
|
561
561
|
switch (provider) {
|
|
562
562
|
case "anthropic":
|
|
563
|
-
credentials = await loginAnthropic(
|
|
563
|
+
credentials = await loginAnthropic({
|
|
564
|
+
...ctrl,
|
|
565
|
+
onManualCodeInput: async () =>
|
|
566
|
+
ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" }),
|
|
567
|
+
});
|
|
564
568
|
break;
|
|
565
569
|
case "github-copilot":
|
|
566
570
|
credentials = await loginGitHubCopilot({
|
|
@@ -34,7 +34,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
34
34
|
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
|
|
35
35
|
const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
|
|
36
36
|
|
|
37
|
-
const stream = new OutputSink({
|
|
37
|
+
const stream = new OutputSink({ onChunk: options?.onChunk });
|
|
38
38
|
|
|
39
39
|
const child = cspawn([shell, ...args, finalCommand], {
|
|
40
40
|
cwd: options?.cwd,
|
|
@@ -44,6 +44,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
// Pump streams - errors during abort/timeout are expected
|
|
47
|
+
// Use preventClose to avoid closing the shared sink when either stream finishes
|
|
47
48
|
await Promise.allSettled([
|
|
48
49
|
child.stdout.pipeTo(stream.createWritable()),
|
|
49
50
|
child.stderr.pipeTo(stream.createWritable()),
|
|
@@ -92,7 +93,7 @@ export async function executeBashWithOperations(
|
|
|
92
93
|
operations: BashOperations,
|
|
93
94
|
options?: BashExecutorOptions,
|
|
94
95
|
): Promise<BashResult> {
|
|
95
|
-
const stream = new OutputSink({
|
|
96
|
+
const stream = new OutputSink({ onChunk: options?.onChunk });
|
|
96
97
|
const writable = stream.createWritable();
|
|
97
98
|
const writer = writable.getWriter();
|
|
98
99
|
|
|
@@ -432,7 +432,7 @@ async function loadTemplatesFromDir(
|
|
|
432
432
|
}
|
|
433
433
|
}
|
|
434
434
|
} catch (error) {
|
|
435
|
-
if (!Bun.file(dir).exists()) {
|
|
435
|
+
if (!(await Bun.file(dir).exists())) {
|
|
436
436
|
return [];
|
|
437
437
|
}
|
|
438
438
|
logger.warn("Failed to scan prompt templates directory", { dir, error: String(error) });
|
|
@@ -92,17 +92,17 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// Build a map from trimmed content to
|
|
96
|
-
// This
|
|
97
|
-
const
|
|
98
|
-
for (
|
|
99
|
-
const trimmed =
|
|
95
|
+
// Build a map from trimmed content to actual lines (by content, not position)
|
|
96
|
+
// This handles fuzzy matches where pattern and actual may not be positionally aligned
|
|
97
|
+
const contentToActualLines = new Map<string, string[]>();
|
|
98
|
+
for (const line of actualLines) {
|
|
99
|
+
const trimmed = line.trim();
|
|
100
100
|
if (trimmed.length === 0) continue;
|
|
101
|
-
const arr =
|
|
101
|
+
const arr = contentToActualLines.get(trimmed);
|
|
102
102
|
if (arr) {
|
|
103
|
-
arr.push(
|
|
103
|
+
arr.push(line);
|
|
104
104
|
} else {
|
|
105
|
-
|
|
105
|
+
contentToActualLines.set(trimmed, [line]);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -119,8 +119,8 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
|
|
|
119
119
|
}
|
|
120
120
|
const avgDelta = deltaCount > 0 ? Math.round(totalDelta / deltaCount) : 0;
|
|
121
121
|
|
|
122
|
-
// Track which
|
|
123
|
-
const
|
|
122
|
+
// Track which actual lines we've used to handle duplicate content correctly
|
|
123
|
+
const usedActualLines = new Map<string, number>(); // trimmed content -> count used
|
|
124
124
|
|
|
125
125
|
return newLines.map((newLine) => {
|
|
126
126
|
if (newLine.trim().length === 0) {
|
|
@@ -128,16 +128,15 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
const trimmed = newLine.trim();
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
// Check if this is a context line (same trimmed content exists in
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
131
|
+
const matchingActualLines = contentToActualLines.get(trimmed);
|
|
132
|
+
|
|
133
|
+
// Check if this is a context line (same trimmed content exists in actual)
|
|
134
|
+
if (matchingActualLines && matchingActualLines.length > 0) {
|
|
135
|
+
const usedCount = usedActualLines.get(trimmed) ?? 0;
|
|
136
|
+
if (usedCount < matchingActualLines.length) {
|
|
137
|
+
usedActualLines.set(trimmed, usedCount + 1);
|
|
138
|
+
// Use actual file content directly for context lines
|
|
139
|
+
return matchingActualLines[usedCount];
|
|
141
140
|
}
|
|
142
141
|
}
|
|
143
142
|
|
|
@@ -599,9 +598,11 @@ function applyCharacterMatch(
|
|
|
599
598
|
|
|
600
599
|
// Check for multiple exact occurrences
|
|
601
600
|
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
|
601
|
+
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
|
|
602
|
+
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
|
|
602
603
|
throw new ApplyPatchError(
|
|
603
|
-
`Found ${matchOutcome.occurrences} occurrences
|
|
604
|
-
`
|
|
604
|
+
`Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\n` +
|
|
605
|
+
`Add more context lines to disambiguate.`,
|
|
605
606
|
);
|
|
606
607
|
}
|
|
607
608
|
|
|
@@ -857,9 +858,22 @@ function computeReplacements(
|
|
|
857
858
|
if (hunk.changeContext === undefined && !hunk.hasContextLines && !hunk.isEndOfFile && lineHint === undefined) {
|
|
858
859
|
const secondMatch = seekSequence(originalLines, pattern, found + 1, false, { allowFuzzy });
|
|
859
860
|
if (secondMatch.index !== undefined) {
|
|
861
|
+
// Extract 3-line previews for each match
|
|
862
|
+
const formatPreview = (startIdx: number) => {
|
|
863
|
+
const lines = originalLines.slice(startIdx, startIdx + 3);
|
|
864
|
+
return lines
|
|
865
|
+
.map((line, i) => {
|
|
866
|
+
const num = startIdx + i + 1;
|
|
867
|
+
const truncated = line.length > 60 ? `${line.slice(0, 57)}...` : line;
|
|
868
|
+
return ` ${num} | ${truncated}`;
|
|
869
|
+
})
|
|
870
|
+
.join("\n");
|
|
871
|
+
};
|
|
872
|
+
const preview1 = formatPreview(found);
|
|
873
|
+
const preview2 = formatPreview(secondMatch.index);
|
|
860
874
|
throw new ApplyPatchError(
|
|
861
|
-
`Found 2 occurrences
|
|
862
|
-
`
|
|
875
|
+
`Found 2 occurrences in ${path}:\n\n${preview1}\n\n${preview2}\n\n` +
|
|
876
|
+
`Add more context lines to disambiguate.`,
|
|
863
877
|
);
|
|
864
878
|
}
|
|
865
879
|
}
|
|
@@ -228,9 +228,11 @@ export function replaceText(content: string, oldText: string, newText: string, o
|
|
|
228
228
|
});
|
|
229
229
|
|
|
230
230
|
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
|
231
|
+
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
|
|
232
|
+
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
|
|
231
233
|
throw new Error(
|
|
232
|
-
`Found ${matchOutcome.occurrences} occurrences
|
|
233
|
-
`
|
|
234
|
+
`Found ${matchOutcome.occurrences} occurrences${moreMsg}:\n\n${previews}\n\n` +
|
|
235
|
+
`Add more context lines to disambiguate.`,
|
|
234
236
|
);
|
|
235
237
|
}
|
|
236
238
|
|
|
@@ -307,8 +309,10 @@ export async function computeEditDiff(
|
|
|
307
309
|
});
|
|
308
310
|
|
|
309
311
|
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
|
312
|
+
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
|
|
313
|
+
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
|
|
310
314
|
return {
|
|
311
|
-
error: `Found ${matchOutcome.occurrences} occurrences
|
|
315
|
+
error: `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`,
|
|
312
316
|
};
|
|
313
317
|
}
|
|
314
318
|
|
|
@@ -215,7 +215,25 @@ export function findMatch(
|
|
|
215
215
|
if (exactIndex !== -1) {
|
|
216
216
|
const occurrences = content.split(target).length - 1;
|
|
217
217
|
if (occurrences > 1) {
|
|
218
|
-
|
|
218
|
+
// Find line numbers and previews for each occurrence (up to 5)
|
|
219
|
+
const contentLines = content.split("\n");
|
|
220
|
+
const occurrenceLines: number[] = [];
|
|
221
|
+
const occurrencePreviews: string[] = [];
|
|
222
|
+
let searchStart = 0;
|
|
223
|
+
for (let i = 0; i < 5; i++) {
|
|
224
|
+
const idx = content.indexOf(target, searchStart);
|
|
225
|
+
if (idx === -1) break;
|
|
226
|
+
const lineNumber = content.slice(0, idx).split("\n").length;
|
|
227
|
+
occurrenceLines.push(lineNumber);
|
|
228
|
+
// Extract 3 lines starting from match (0-indexed)
|
|
229
|
+
const previewLines = contentLines.slice(lineNumber - 1, lineNumber + 2);
|
|
230
|
+
const preview = previewLines
|
|
231
|
+
.map((line, i) => ` ${lineNumber + i} | ${line.length > 60 ? `${line.slice(0, 57)}...` : line}`)
|
|
232
|
+
.join("\n");
|
|
233
|
+
occurrencePreviews.push(preview);
|
|
234
|
+
searchStart = idx + 1;
|
|
235
|
+
}
|
|
236
|
+
return { occurrences, occurrenceLines, occurrencePreviews };
|
|
219
237
|
}
|
|
220
238
|
const startLine = content.slice(0, exactIndex).split("\n").length;
|
|
221
239
|
return {
|
|
@@ -390,8 +390,11 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
390
390
|
});
|
|
391
391
|
|
|
392
392
|
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
|
|
393
|
+
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
|
|
394
|
+
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
|
|
393
395
|
throw new Error(
|
|
394
|
-
`Found ${matchOutcome.occurrences} occurrences
|
|
396
|
+
`Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\n` +
|
|
397
|
+
`Add more context lines to disambiguate.`,
|
|
395
398
|
);
|
|
396
399
|
}
|
|
397
400
|
|
|
@@ -40,6 +40,10 @@ export interface MatchOutcome {
|
|
|
40
40
|
closest?: FuzzyMatch;
|
|
41
41
|
/** Number of occurrences if multiple exact matches found */
|
|
42
42
|
occurrences?: number;
|
|
43
|
+
/** Line numbers where occurrences were found (1-indexed) */
|
|
44
|
+
occurrenceLines?: number[];
|
|
45
|
+
/** Preview snippets for each occurrence (up to 5) */
|
|
46
|
+
occurrencePreviews?: string[];
|
|
43
47
|
/** Number of fuzzy matches above threshold */
|
|
44
48
|
fuzzyMatches?: number;
|
|
45
49
|
}
|
|
@@ -10,13 +10,13 @@ import type { EventBus } from "../../event-bus";
|
|
|
10
10
|
import { callTool } from "../../mcp/client";
|
|
11
11
|
import type { MCPManager } from "../../mcp/manager";
|
|
12
12
|
import type { ModelRegistry } from "../../model-registry";
|
|
13
|
+
import { formatModelString, parseModelPattern } from "../../model-resolver";
|
|
13
14
|
import { checkPythonKernelAvailability } from "../../python-kernel";
|
|
14
15
|
import type { ToolSession } from "..";
|
|
15
16
|
import { LspTool } from "../lsp/index";
|
|
16
17
|
import type { LspParams } from "../lsp/types";
|
|
17
18
|
import { PythonTool } from "../python";
|
|
18
19
|
import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
|
|
19
|
-
import { resolveModelPattern } from "./model-resolver";
|
|
20
20
|
import { subprocessToolRegistry } from "./subprocess-tool-registry";
|
|
21
21
|
import {
|
|
22
22
|
type AgentDefinition,
|
|
@@ -296,10 +296,26 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
const serializedSettings = options.settingsManager?.serialize();
|
|
299
|
-
const availableModels = options.modelRegistry?.getAvailable()
|
|
300
|
-
|
|
301
|
-
// Resolve
|
|
302
|
-
const
|
|
299
|
+
const availableModels = options.modelRegistry?.getAvailable() ?? [];
|
|
300
|
+
|
|
301
|
+
// Resolve model pattern to provider/modelId string
|
|
302
|
+
const modelPattern = modelOverride ?? agent.model;
|
|
303
|
+
let resolvedModel: string | undefined;
|
|
304
|
+
if (modelPattern) {
|
|
305
|
+
// Handle omp/<role> or pi/<role> aliases (e.g., "omp/slow", "pi/fast")
|
|
306
|
+
let effectivePattern = modelPattern;
|
|
307
|
+
const lower = modelPattern.toLowerCase();
|
|
308
|
+
if (lower.startsWith("omp/") || lower.startsWith("pi/")) {
|
|
309
|
+
const role = lower.startsWith("omp/") ? modelPattern.slice(4) : modelPattern.slice(3);
|
|
310
|
+
const roles = serializedSettings?.modelRoles as Record<string, string> | undefined;
|
|
311
|
+
const configured = roles?.[role] ?? roles?.[role.toLowerCase()];
|
|
312
|
+
if (configured) {
|
|
313
|
+
effectivePattern = configured;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const { model } = parseModelPattern(effectivePattern, availableModels);
|
|
317
|
+
resolvedModel = model ? formatModelString(model) : undefined;
|
|
318
|
+
}
|
|
303
319
|
const sessionFile = subtaskSessionFile ?? null;
|
|
304
320
|
const spawnsEnv = agent.spawns === undefined ? "" : agent.spawns === "*" ? "*" : agent.spawns.join(",");
|
|
305
321
|
|
|
@@ -799,10 +799,11 @@ const reportFatal = async (message: string): Promise<void> => {
|
|
|
799
799
|
} catch {
|
|
800
800
|
// Ignore cleanup errors
|
|
801
801
|
}
|
|
802
|
+
const error = new Error(message);
|
|
802
803
|
|
|
803
804
|
const runState = activeRun;
|
|
804
805
|
if (runState) {
|
|
805
|
-
runState.abortController.abort();
|
|
806
|
+
runState.abortController.abort(error);
|
|
806
807
|
if (runState.session) {
|
|
807
808
|
void runState.session.abort();
|
|
808
809
|
}
|
|
@@ -2,7 +2,6 @@ import { unlinkSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { cspawn } from "@oh-my-pi/pi-utils";
|
|
5
|
-
import type { FileSink } from "bun";
|
|
6
5
|
import { nanoid } from "nanoid";
|
|
7
6
|
import { ensureTool } from "../../../utils/tools-manager";
|
|
8
7
|
import type { RenderResult, SpecialHandler } from "./types";
|
|
@@ -16,59 +15,17 @@ async function exec(
|
|
|
16
15
|
args: string[],
|
|
17
16
|
options?: { timeout?: number; input?: string | Buffer; signal?: AbortSignal },
|
|
18
17
|
): Promise<{ stdout: string; stderr: string; ok: boolean; exitCode: number | null }> {
|
|
19
|
-
const controller = new AbortController();
|
|
20
|
-
const onAbort = () => controller.abort(options?.signal?.reason ?? new Error("Aborted"));
|
|
21
|
-
if (options?.signal) {
|
|
22
|
-
if (options.signal.aborted) {
|
|
23
|
-
onAbort();
|
|
24
|
-
} else {
|
|
25
|
-
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
const timeoutId =
|
|
29
|
-
options?.timeout && options.timeout > 0
|
|
30
|
-
? setTimeout(() => controller.abort(new Error("Timeout")), options.timeout)
|
|
31
|
-
: undefined;
|
|
32
18
|
const proc = cspawn([cmd, ...args], {
|
|
33
|
-
signal:
|
|
19
|
+
signal: options?.signal,
|
|
20
|
+
timeout: options?.timeout,
|
|
21
|
+
stdin: options?.input ? Buffer.from(options.input) : undefined,
|
|
34
22
|
});
|
|
35
23
|
|
|
36
|
-
if (options?.input && proc.stdin) {
|
|
37
|
-
const stdin = proc.stdin as FileSink;
|
|
38
|
-
const payload = typeof options.input === "string" ? new TextEncoder().encode(options.input) : options.input;
|
|
39
|
-
stdin.write(payload);
|
|
40
|
-
const flushed = stdin.flush();
|
|
41
|
-
if (flushed instanceof Promise) {
|
|
42
|
-
await flushed;
|
|
43
|
-
}
|
|
44
|
-
const ended = stdin.end();
|
|
45
|
-
if (ended instanceof Promise) {
|
|
46
|
-
await ended;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
24
|
const [stdout, stderr, exitResult] = await Promise.all([
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
(
|
|
54
|
-
try {
|
|
55
|
-
await proc.exited;
|
|
56
|
-
return proc.exitCode ?? 0;
|
|
57
|
-
} catch (err) {
|
|
58
|
-
if (err && typeof err === "object" && "exitCode" in err) {
|
|
59
|
-
const exitValue = (err as { exitCode?: number }).exitCode;
|
|
60
|
-
if (typeof exitValue === "number") {
|
|
61
|
-
return exitValue;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
throw err instanceof Error ? err : new Error(String(err));
|
|
65
|
-
}
|
|
66
|
-
})(),
|
|
25
|
+
proc.stdout.text(),
|
|
26
|
+
proc.stderr.text(),
|
|
27
|
+
proc.exited.then(() => proc.exitCode ?? 0),
|
|
67
28
|
]);
|
|
68
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
69
|
-
if (options?.signal) {
|
|
70
|
-
options.signal.removeEventListener("abort", onAbort);
|
|
71
|
-
}
|
|
72
29
|
|
|
73
30
|
return {
|
|
74
31
|
stdout,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { nanoid } from "nanoid";
|
|
2
2
|
import { WorktreeError, WorktreeErrorCode } from "./errors";
|
|
3
|
-
import { git,
|
|
3
|
+
import { git, gitWithInput } from "./git";
|
|
4
4
|
import { find, remove, type Worktree } from "./operations";
|
|
5
5
|
|
|
6
6
|
export type CollapseStrategy = "simple" | "merge-base" | "rebase";
|
|
@@ -121,10 +121,10 @@ async function collapseRebase(src: Worktree, dst: Worktree): Promise<string> {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
async function applyDiff(diff: string, targetPath: string): Promise<void> {
|
|
124
|
-
let result = await
|
|
124
|
+
let result = await gitWithInput(["apply"], diff, targetPath);
|
|
125
125
|
if (result.code === 0) return;
|
|
126
126
|
|
|
127
|
-
result = await
|
|
127
|
+
result = await gitWithInput(["apply", "--3way"], diff, targetPath);
|
|
128
128
|
if (result.code === 0) return;
|
|
129
129
|
|
|
130
130
|
throw new WorktreeError(
|
package/src/lib/worktree/git.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import
|
|
2
|
+
import { ptree } from "@oh-my-pi/pi-utils";
|
|
3
3
|
import { execCommand } from "../../core/exec";
|
|
4
4
|
import { WorktreeError, WorktreeErrorCode } from "./errors";
|
|
5
5
|
|
|
@@ -9,32 +9,6 @@ export interface GitResult {
|
|
|
9
9
|
stderr: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
type WritableLike = {
|
|
13
|
-
write: (chunk: string | Uint8Array) => unknown;
|
|
14
|
-
flush?: () => unknown;
|
|
15
|
-
end?: () => unknown;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const textEncoder = new TextEncoder();
|
|
19
|
-
|
|
20
|
-
async function writeStdin(handle: unknown, stdin: string): Promise<void> {
|
|
21
|
-
if (!handle || typeof handle === "number") return;
|
|
22
|
-
if (typeof (handle as WritableStream<Uint8Array>).getWriter === "function") {
|
|
23
|
-
const writer = (handle as WritableStream<Uint8Array>).getWriter();
|
|
24
|
-
try {
|
|
25
|
-
await writer.write(textEncoder.encode(stdin));
|
|
26
|
-
} finally {
|
|
27
|
-
await writer.close();
|
|
28
|
-
}
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const sink = handle as WritableLike;
|
|
33
|
-
sink.write(stdin);
|
|
34
|
-
if (sink.flush) sink.flush();
|
|
35
|
-
if (sink.end) sink.end();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
12
|
/**
|
|
39
13
|
* Execute a git command.
|
|
40
14
|
* @param args - Command arguments (excluding 'git')
|
|
@@ -50,23 +24,15 @@ export async function git(args: string[], cwd?: string): Promise<GitResult> {
|
|
|
50
24
|
* Execute git command with stdin input.
|
|
51
25
|
* Used for piping diffs to `git apply`.
|
|
52
26
|
*/
|
|
53
|
-
export async function
|
|
54
|
-
const proc
|
|
27
|
+
export async function gitWithInput(args: string[], stdin: string, cwd?: string): Promise<GitResult> {
|
|
28
|
+
const proc = ptree.cspawn(["git", ...args], {
|
|
55
29
|
cwd: cwd ?? process.cwd(),
|
|
56
|
-
stdin:
|
|
57
|
-
stdout: "pipe",
|
|
58
|
-
stderr: "pipe",
|
|
30
|
+
stdin: Buffer.from(stdin),
|
|
59
31
|
});
|
|
60
32
|
|
|
61
|
-
await
|
|
62
|
-
|
|
63
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
64
|
-
(proc.stdout as ReadableStream<Uint8Array>).text(),
|
|
65
|
-
(proc.stderr as ReadableStream<Uint8Array>).text(),
|
|
66
|
-
proc.exited,
|
|
67
|
-
]);
|
|
33
|
+
const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]);
|
|
68
34
|
|
|
69
|
-
return { code: exitCode ?? 0, stdout, stderr };
|
|
35
|
+
return { code: proc.exitCode ?? 0, stdout, stderr };
|
|
70
36
|
}
|
|
71
37
|
|
|
72
38
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { type CollapseOptions, type CollapseResult, type CollapseStrategy, collapse } from "./collapse";
|
|
2
2
|
export { WORKTREE_BASE } from "./constants";
|
|
3
3
|
export { WorktreeError, WorktreeErrorCode } from "./errors";
|
|
4
|
-
export { getRepoName, getRepoRoot, git, gitWithStdin } from "./git";
|
|
4
|
+
export { getRepoName, getRepoRoot, git, gitWithInput as gitWithStdin } from "./git";
|
|
5
5
|
export { create, find, list, prune, remove, type Worktree, which } from "./operations";
|
|
6
6
|
export {
|
|
7
7
|
cleanupSessions,
|
package/src/utils/clipboard.ts
CHANGED
|
@@ -32,8 +32,6 @@ function selectPreferredImageMimeType(mimeTypes: string[]): string | null {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export async function copyToClipboard(text: string): Promise<void> {
|
|
35
|
-
const timeout = Bun.sleep(3000).then(() => Promise.reject(new Error("Clipboard operation timed out")));
|
|
36
|
-
|
|
37
35
|
let promise: Promise<void>;
|
|
38
36
|
try {
|
|
39
37
|
switch (platform()) {
|
|
@@ -56,11 +54,11 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|
|
56
54
|
}
|
|
57
55
|
} catch (error) {
|
|
58
56
|
if (error instanceof Error) {
|
|
59
|
-
throw new Error(`Failed to copy to clipboard: ${error.message}
|
|
57
|
+
throw new Error(`Failed to copy to clipboard: ${error.message}`, { cause: error });
|
|
60
58
|
}
|
|
61
|
-
throw new Error(`Failed to copy to clipboard: ${String(error)}
|
|
59
|
+
throw new Error(`Failed to copy to clipboard: ${String(error)}`, { cause: error });
|
|
62
60
|
}
|
|
63
|
-
await Promise.race([promise,
|
|
61
|
+
await Promise.race([promise, Bun.sleep(3000)]);
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
export interface ClipboardImage {
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Model resolution with fuzzy pattern matching.
|
|
3
|
-
*
|
|
4
|
-
* Returns models in "provider/modelId" format for use with --model flag.
|
|
5
|
-
*
|
|
6
|
-
* Supports:
|
|
7
|
-
* - Exact match: "gpt-5.2" → "p-openai/gpt-5.2"
|
|
8
|
-
* - Fuzzy match: "opus" → "p-anthropic/claude-opus-4-5"
|
|
9
|
-
* - Comma fallback: "gpt, opus" → tries gpt first, then opus
|
|
10
|
-
* - "default" → undefined (use system default)
|
|
11
|
-
* - "omp/slow" or "pi/slow" → configured slow model from settings
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { $ } from "bun";
|
|
15
|
-
import { type Settings as SettingsFile, settingsCapability } from "../../../capability/settings";
|
|
16
|
-
import { loadCapability } from "../../../discovery";
|
|
17
|
-
import type { Settings as SettingsData } from "../../settings-manager";
|
|
18
|
-
import { resolveOmpCommand } from "./omp-command";
|
|
19
|
-
|
|
20
|
-
/** Cache for available models (provider/modelId format) */
|
|
21
|
-
let cachedModels: string[] | null = null;
|
|
22
|
-
|
|
23
|
-
/** Cache expiry time (5 minutes) */
|
|
24
|
-
let cacheExpiry = 0;
|
|
25
|
-
|
|
26
|
-
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Get available models from `omp --list-models`.
|
|
30
|
-
* Returns models in "provider/modelId" format.
|
|
31
|
-
* Caches the result for performance.
|
|
32
|
-
*/
|
|
33
|
-
export async function getAvailableModels(): Promise<string[]> {
|
|
34
|
-
const now = Date.now();
|
|
35
|
-
if (cachedModels !== null && now < cacheExpiry) {
|
|
36
|
-
return cachedModels;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const ompCommand = resolveOmpCommand();
|
|
41
|
-
const result = await $`${ompCommand.cmd} ${ompCommand.args} --list-models`.quiet().nothrow();
|
|
42
|
-
const stdout = result.stdout?.toString() ?? "";
|
|
43
|
-
|
|
44
|
-
if (result.exitCode !== 0 || !stdout.trim()) {
|
|
45
|
-
cachedModels = [];
|
|
46
|
-
cacheExpiry = now + CACHE_TTL_MS;
|
|
47
|
-
return cachedModels;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Parse output: skip header line, extract provider/model
|
|
51
|
-
const lines = stdout.trim().split("\n");
|
|
52
|
-
cachedModels = lines
|
|
53
|
-
.slice(1) // Skip header
|
|
54
|
-
.map((line) => {
|
|
55
|
-
const parts = line.trim().split(/\s+/);
|
|
56
|
-
// Format: provider/modelId
|
|
57
|
-
return parts[0] && parts[1] ? `${parts[0]}/${parts[1]}` : "";
|
|
58
|
-
})
|
|
59
|
-
.filter(Boolean);
|
|
60
|
-
|
|
61
|
-
cacheExpiry = now + CACHE_TTL_MS;
|
|
62
|
-
return cachedModels;
|
|
63
|
-
} catch {
|
|
64
|
-
cachedModels = [];
|
|
65
|
-
cacheExpiry = now + CACHE_TTL_MS;
|
|
66
|
-
return cachedModels;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Clear the model cache (for testing).
|
|
72
|
-
*/
|
|
73
|
-
export function clearModelCache(): void {
|
|
74
|
-
cachedModels = null;
|
|
75
|
-
cacheExpiry = 0;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Load model roles from settings files using capability API.
|
|
80
|
-
*/
|
|
81
|
-
async function loadModelRoles(): Promise<Record<string, string>> {
|
|
82
|
-
const result = await loadCapability<SettingsFile>(settingsCapability.id, { cwd: process.cwd() });
|
|
83
|
-
|
|
84
|
-
// Merge all settings, prioritizing first (highest priority)
|
|
85
|
-
let modelRoles: Record<string, string> = {};
|
|
86
|
-
for (const settings of result.items.reverse()) {
|
|
87
|
-
const roles = settings.data.modelRoles as Record<string, string> | undefined;
|
|
88
|
-
if (roles) {
|
|
89
|
-
modelRoles = { ...modelRoles, ...roles };
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return modelRoles;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Resolve an omp/<role> alias to a model string.
|
|
98
|
-
* Looks up the role in settings.modelRoles and returns the configured model.
|
|
99
|
-
* Returns undefined if the role isn't configured.
|
|
100
|
-
*/
|
|
101
|
-
async function resolveOmpAlias(
|
|
102
|
-
role: string,
|
|
103
|
-
availableModels: string[],
|
|
104
|
-
settings?: SettingsData,
|
|
105
|
-
): Promise<string | undefined> {
|
|
106
|
-
const roles = settings?.modelRoles ?? (await loadModelRoles());
|
|
107
|
-
|
|
108
|
-
// Look up role in settings (case-insensitive)
|
|
109
|
-
const configured = roles[role] || roles[role.toLowerCase()];
|
|
110
|
-
if (!configured) return undefined;
|
|
111
|
-
|
|
112
|
-
// configured is in "provider/modelId" format, find in available models
|
|
113
|
-
return availableModels.find((m) => m.toLowerCase() === configured.toLowerCase());
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Extract model ID from "provider/modelId" format.
|
|
118
|
-
*/
|
|
119
|
-
function getModelId(fullModel: string): string {
|
|
120
|
-
const slashIdx = fullModel.indexOf("/");
|
|
121
|
-
return slashIdx > 0 ? fullModel.slice(slashIdx + 1) : fullModel;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Extract provider from "provider/modelId" format.
|
|
126
|
-
* Returns undefined if no provider prefix.
|
|
127
|
-
*/
|
|
128
|
-
function getProvider(fullModel: string): string | undefined {
|
|
129
|
-
const slashIdx = fullModel.indexOf("/");
|
|
130
|
-
return slashIdx > 0 ? fullModel.slice(0, slashIdx) : undefined;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Resolve a fuzzy model pattern to "provider/modelId" format.
|
|
135
|
-
*
|
|
136
|
-
* Supports comma-separated patterns (e.g., "gpt, opus") - tries each in order.
|
|
137
|
-
* Returns undefined if pattern is "default", undefined, or no match found.
|
|
138
|
-
*
|
|
139
|
-
* @param pattern - Model pattern to resolve
|
|
140
|
-
* @param availableModels - Optional pre-fetched list of available models (in provider/modelId format)
|
|
141
|
-
* @param settings - Optional settings for role alias resolution (pi/..., omp/...)
|
|
142
|
-
*/
|
|
143
|
-
export async function resolveModelPattern(
|
|
144
|
-
pattern: string | undefined,
|
|
145
|
-
availableModels?: string[],
|
|
146
|
-
settings?: SettingsData,
|
|
147
|
-
): Promise<string | undefined> {
|
|
148
|
-
if (!pattern || pattern === "default") {
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const models = availableModels ?? (await getAvailableModels());
|
|
153
|
-
if (models.length === 0) {
|
|
154
|
-
// Fallback: return pattern as-is if we can't get available models
|
|
155
|
-
return pattern;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Split by comma, try each pattern in order
|
|
159
|
-
const patterns = pattern
|
|
160
|
-
.split(",")
|
|
161
|
-
.map((p) => p.trim())
|
|
162
|
-
.filter(Boolean);
|
|
163
|
-
|
|
164
|
-
for (const p of patterns) {
|
|
165
|
-
// Handle omp/<role> or pi/<role> aliases - looks up role in settings.modelRoles
|
|
166
|
-
const lower = p.toLowerCase();
|
|
167
|
-
if (lower.startsWith("omp/") || lower.startsWith("pi/")) {
|
|
168
|
-
const role = lower.startsWith("omp/") ? p.slice(4) : p.slice(3);
|
|
169
|
-
const resolved = await resolveOmpAlias(role, models, settings);
|
|
170
|
-
if (resolved) return resolved;
|
|
171
|
-
continue; // Role not configured, try next pattern
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Try exact match on full provider/modelId
|
|
175
|
-
const exactFull = models.find((m) => m.toLowerCase() === p.toLowerCase());
|
|
176
|
-
if (exactFull) return exactFull;
|
|
177
|
-
|
|
178
|
-
// Try exact match on model ID only
|
|
179
|
-
const exactId = models.find((m) => getModelId(m).toLowerCase() === p.toLowerCase());
|
|
180
|
-
if (exactId) return exactId;
|
|
181
|
-
|
|
182
|
-
// Check if pattern has provider prefix (e.g., "zai/glm-4.7")
|
|
183
|
-
const patternProvider = getProvider(p);
|
|
184
|
-
const patternModelId = getModelId(p);
|
|
185
|
-
|
|
186
|
-
// If pattern has provider prefix, fuzzy match must stay within that provider
|
|
187
|
-
// (don't cross provider boundaries when user explicitly specifies provider)
|
|
188
|
-
if (patternProvider) {
|
|
189
|
-
const providerFuzzyMatch = models.find(
|
|
190
|
-
(m) =>
|
|
191
|
-
getProvider(m)?.toLowerCase() === patternProvider.toLowerCase() &&
|
|
192
|
-
getModelId(m).toLowerCase().includes(patternModelId.toLowerCase()),
|
|
193
|
-
);
|
|
194
|
-
if (providerFuzzyMatch) return providerFuzzyMatch;
|
|
195
|
-
// No match in specified provider - don't fall through to other providers
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// No provider prefix - fall back to general fuzzy match on model ID (substring)
|
|
200
|
-
const fuzzyMatch = models.find((m) => getModelId(m).toLowerCase().includes(patternModelId.toLowerCase()));
|
|
201
|
-
if (fuzzyMatch) return fuzzyMatch;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// No match found - use default model
|
|
205
|
-
return undefined;
|
|
206
|
-
}
|