@oh-my-pi/pi-coding-agent 16.2.4 → 16.2.6
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 +37 -0
- package/dist/cli.js +3211 -3231
- package/dist/types/cli/bench-cli.d.ts +6 -1
- package/dist/types/commands/bench.d.ts +8 -0
- package/dist/types/config/model-discovery.d.ts +6 -1
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/edit/index.d.ts +1 -0
- package/dist/types/edit/renderer.d.ts +4 -0
- package/dist/types/edit/snapshot-details.d.ts +33 -0
- package/dist/types/mcp/oauth-flow.d.ts +4 -6
- package/dist/types/modes/components/status-line/types.d.ts +10 -0
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +11 -0
- package/dist/types/session/session-manager.d.ts +3 -4
- package/dist/types/task/provider-concurrency.d.ts +40 -0
- package/dist/types/utils/git.d.ts +11 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +12 -12
- package/src/cli/args.ts +32 -1
- package/src/cli/bench-cli.ts +89 -21
- package/src/cli/web-search-cli.ts +6 -1
- package/src/cli/worktree-cli.ts +8 -5
- package/src/commands/bench.ts +10 -1
- package/src/config/mcp-schema.json +1 -1
- package/src/config/model-discovery.ts +98 -20
- package/src/config/model-registry.ts +13 -6
- package/src/config/settings-schema.ts +5 -2
- package/src/edit/hashline/execute.ts +15 -9
- package/src/edit/index.ts +19 -6
- package/src/edit/modes/patch.ts +3 -2
- package/src/edit/modes/replace.ts +3 -2
- package/src/edit/renderer.ts +4 -0
- package/src/edit/snapshot-details.ts +77 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/oauth-flow.ts +10 -8
- package/src/mcp/transports/stdio.ts +9 -17
- package/src/modes/components/status-line/component.ts +29 -2
- package/src/modes/components/status-line/segments.ts +22 -8
- package/src/modes/components/status-line/types.ts +7 -0
- package/src/modes/controllers/event-controller.ts +17 -8
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/controllers/tool-args-reveal.ts +100 -22
- package/src/modes/theme/theme.ts +6 -0
- package/src/prompts/bench.md +4 -10
- package/src/prompts/tools/irc.md +19 -29
- package/src/prompts/tools/job.md +8 -14
- package/src/prompts/tools/lsp.md +19 -30
- package/src/prompts/tools/task.md +42 -62
- package/src/sdk.ts +14 -5
- package/src/session/agent-session.ts +107 -13
- package/src/session/session-listing.ts +9 -8
- package/src/session/session-loader.ts +98 -3
- package/src/session/session-manager.ts +34 -4
- package/src/subprocess/worker-client.ts +12 -4
- package/src/task/executor.ts +4 -62
- package/src/task/provider-concurrency.ts +100 -0
- package/src/task/worktree.ts +13 -4
- package/src/task/yield-assembly.ts +27 -39
- package/src/tools/path-utils.ts +4 -2
- package/src/tools/read.ts +11 -1
- package/src/utils/git.ts +13 -0
- package/src/web/search/index.ts +14 -8
- package/src/web/search/providers/duckduckgo.ts +136 -78
- package/src/web/search/providers/gemini.ts +268 -185
- package/src/web/search/types.ts +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as readline from "node:readline";
|
|
1
3
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
4
|
import { getBlobsDir, isEnoent, parseJsonlLenient } from "@oh-my-pi/pi-utils";
|
|
3
5
|
import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store";
|
|
4
6
|
import { buildSessionContext } from "./session-context";
|
|
5
7
|
import {
|
|
8
|
+
type CompactionEntry,
|
|
6
9
|
type FileEntry,
|
|
7
10
|
type RawFileEntry,
|
|
8
11
|
SESSION_TITLE_SLOT_BYTES,
|
|
@@ -20,6 +23,10 @@ import {
|
|
|
20
23
|
titleUpdateFromSlot,
|
|
21
24
|
} from "./session-title-slot";
|
|
22
25
|
|
|
26
|
+
const STREAM_LOAD_THRESHOLD_BYTES = 8 * 1024 * 1024;
|
|
27
|
+
const ELIDED_COMPACTION_SUMMARY = "[Superseded compaction summary elided during session load]";
|
|
28
|
+
const ELIDED_COMPACTION_SHORT_SUMMARY = "Superseded compaction elided";
|
|
29
|
+
|
|
23
30
|
function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpdate | undefined } {
|
|
24
31
|
const slot = titleUpdateFromSlot(parseTitleSlotFromContent(content));
|
|
25
32
|
if (!slot) return { body: content, slot: undefined };
|
|
@@ -54,6 +61,89 @@ export function parseSessionContent(content: string): {
|
|
|
54
61
|
return { entries: foldTitleSlot(entries, slot), titleSlot: slot };
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
function elideCompactionSummary(entry: CompactionEntry | undefined): boolean {
|
|
65
|
+
if (!entry) return false;
|
|
66
|
+
if (
|
|
67
|
+
entry.summary === ELIDED_COMPACTION_SUMMARY &&
|
|
68
|
+
entry.shortSummary === ELIDED_COMPACTION_SHORT_SUMMARY &&
|
|
69
|
+
entry.preserveData === undefined
|
|
70
|
+
) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
entry.summary = ELIDED_COMPACTION_SUMMARY;
|
|
74
|
+
entry.shortSummary = ELIDED_COMPACTION_SHORT_SUMMARY;
|
|
75
|
+
entry.preserveData = undefined;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collectActiveBranchIds(entries: FileEntry[]): Set<string> {
|
|
80
|
+
const byId = new Map<string, SessionEntry>();
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const id = (entry as SessionEntry).id;
|
|
83
|
+
if (typeof id === "string") byId.set(id, entry as SessionEntry);
|
|
84
|
+
}
|
|
85
|
+
const branchIds = new Set<string>();
|
|
86
|
+
let cursor = entries[entries.length - 1] as SessionEntry | undefined;
|
|
87
|
+
while (cursor && typeof cursor.id === "string" && !branchIds.has(cursor.id)) {
|
|
88
|
+
branchIds.add(cursor.id);
|
|
89
|
+
const parentId = cursor.parentId;
|
|
90
|
+
cursor = parentId ? byId.get(parentId) : undefined;
|
|
91
|
+
}
|
|
92
|
+
return branchIds;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function elideSupersededCompactionEntries(entries: FileEntry[]): void {
|
|
96
|
+
const branchIds = collectActiveBranchIds(entries);
|
|
97
|
+
let previousCompaction: CompactionEntry | undefined;
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (entry.type !== "compaction") continue;
|
|
100
|
+
if (!branchIds.has(entry.id)) continue;
|
|
101
|
+
elideCompactionSummary(previousCompaction);
|
|
102
|
+
previousCompaction = entry;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function loadEntriesFromFileStream(filePath: string): Promise<{
|
|
107
|
+
entries: FileEntry[];
|
|
108
|
+
titleSlot: SessionTitleUpdate | undefined;
|
|
109
|
+
}> {
|
|
110
|
+
const entries: FileEntry[] = [];
|
|
111
|
+
let titleSlot: SessionTitleUpdate | undefined;
|
|
112
|
+
let sawBodyLine = false;
|
|
113
|
+
const input = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
114
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
for await (const rawLine of lines) {
|
|
118
|
+
const line = rawLine.trim();
|
|
119
|
+
if (!line) continue;
|
|
120
|
+
if (!sawBodyLine) {
|
|
121
|
+
const slot = parseTitleSlotLine(line);
|
|
122
|
+
if (slot) {
|
|
123
|
+
titleSlot = titleUpdateFromSlot(slot);
|
|
124
|
+
sawBodyLine = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
sawBodyLine = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let entry: FileEntry;
|
|
131
|
+
try {
|
|
132
|
+
entry = JSON.parse(line) as FileEntry;
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
entries.push(entry);
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
input.destroy();
|
|
140
|
+
if (isEnoent(err)) return { entries: [], titleSlot: undefined };
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { entries: foldTitleSlot(entries, titleSlot), titleSlot };
|
|
145
|
+
}
|
|
146
|
+
|
|
57
147
|
/** Read only the fixed-size head window to detect a physical title slot. */
|
|
58
148
|
export async function readTitleSlotFromFile(
|
|
59
149
|
filePath: string,
|
|
@@ -80,14 +170,19 @@ export async function loadEntriesFromFile(
|
|
|
80
170
|
filePath: string,
|
|
81
171
|
storage: SessionStorage = new FileSessionStorage(),
|
|
82
172
|
): Promise<FileEntry[]> {
|
|
83
|
-
let
|
|
173
|
+
let loaded: { entries: FileEntry[]; titleSlot: SessionTitleUpdate | undefined };
|
|
84
174
|
try {
|
|
85
|
-
|
|
175
|
+
const stat = storage.statSync(filePath);
|
|
176
|
+
loaded =
|
|
177
|
+
storage instanceof FileSessionStorage && stat.size >= STREAM_LOAD_THRESHOLD_BYTES
|
|
178
|
+
? await loadEntriesFromFileStream(filePath)
|
|
179
|
+
: parseSessionContent(await storage.readText(filePath));
|
|
86
180
|
} catch (err) {
|
|
87
181
|
if (isEnoent(err)) return [];
|
|
88
182
|
throw err;
|
|
89
183
|
}
|
|
90
|
-
const { entries } =
|
|
184
|
+
const { entries } = loaded;
|
|
185
|
+
elideSupersededCompactionEntries(entries);
|
|
91
186
|
|
|
92
187
|
// Validate session header
|
|
93
188
|
if (entries.length === 0) return entries;
|
|
@@ -66,6 +66,8 @@ import {
|
|
|
66
66
|
import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
|
|
67
67
|
|
|
68
68
|
const JSONL_SUFFIX_LENGTH = ".jsonl".length;
|
|
69
|
+
const SUPERSEDED_COMPACTION_SUMMARY = "[Superseded compaction summary elided after a newer compaction]";
|
|
70
|
+
const SUPERSEDED_COMPACTION_SHORT_SUMMARY = "Superseded compaction elided";
|
|
69
71
|
|
|
70
72
|
function mintSessionId(): string {
|
|
71
73
|
return Bun.randomUUIDv7();
|
|
@@ -502,6 +504,26 @@ export class SessionManager {
|
|
|
502
504
|
return this.#forceFileCreation || this.#fileIsCurrent || this.#historyContainsAssistantMessage();
|
|
503
505
|
}
|
|
504
506
|
|
|
507
|
+
#elideSupersededCompactionsOnBranch(leafId: string | null): boolean {
|
|
508
|
+
if (!leafId) return false;
|
|
509
|
+
let changed = false;
|
|
510
|
+
for (const entry of this.#index.pathTo(leafId)) {
|
|
511
|
+
if (entry.type !== "compaction") continue;
|
|
512
|
+
if (
|
|
513
|
+
entry.summary === SUPERSEDED_COMPACTION_SUMMARY &&
|
|
514
|
+
entry.shortSummary === SUPERSEDED_COMPACTION_SHORT_SUMMARY &&
|
|
515
|
+
entry.preserveData === undefined
|
|
516
|
+
) {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
entry.summary = SUPERSEDED_COMPACTION_SUMMARY;
|
|
520
|
+
entry.shortSummary = SUPERSEDED_COMPACTION_SHORT_SUMMARY;
|
|
521
|
+
entry.preserveData = undefined;
|
|
522
|
+
changed = true;
|
|
523
|
+
}
|
|
524
|
+
return changed;
|
|
525
|
+
}
|
|
526
|
+
|
|
505
527
|
/**
|
|
506
528
|
* Synchronously rewrite the whole file (header + entries) and keep no open
|
|
507
529
|
* writer; the next append re-opens one. `writeTextSync` returns with the
|
|
@@ -1024,14 +1046,18 @@ export class SessionManager {
|
|
|
1024
1046
|
}
|
|
1025
1047
|
|
|
1026
1048
|
/**
|
|
1027
|
-
* Synchronously
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
* lands mid-`writeFileSync`.
|
|
1049
|
+
* Synchronously makes the current append-only session durable. Avoid rewriting
|
|
1050
|
+
* an already-current file: large restored sessions can contain GiB of compacted
|
|
1051
|
+
* history, and Ctrl+C must not rebuild the whole JSONL string just to flush.
|
|
1031
1052
|
*/
|
|
1032
1053
|
flushSync(): void {
|
|
1033
1054
|
if (!this.#persist || !this.#sessionFile) return;
|
|
1034
1055
|
if (this.#diskFailure) throw this.#diskFailure;
|
|
1056
|
+
if (this.#fileIsCurrent && !this.#rewriteRequired) {
|
|
1057
|
+
const writerError = this.#writer?.getError();
|
|
1058
|
+
if (writerError) throw writerError;
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1035
1061
|
this.#rewriteSynchronously();
|
|
1036
1062
|
if (this.#diskFailure) throw this.#diskFailure;
|
|
1037
1063
|
}
|
|
@@ -1305,6 +1331,7 @@ export class SessionManager {
|
|
|
1305
1331
|
fromExtension?: boolean,
|
|
1306
1332
|
preserveData?: Record<string, unknown>,
|
|
1307
1333
|
): string {
|
|
1334
|
+
const elidedSupersededCompactions = this.#elideSupersededCompactionsOnBranch(this.#index.leafId());
|
|
1308
1335
|
const entry: CompactionEntry<T> = {
|
|
1309
1336
|
type: "compaction",
|
|
1310
1337
|
...this.#freshEntryFields(),
|
|
@@ -1317,6 +1344,9 @@ export class SessionManager {
|
|
|
1317
1344
|
preserveData,
|
|
1318
1345
|
};
|
|
1319
1346
|
this.#recordEntry(entry);
|
|
1347
|
+
if (elidedSupersededCompactions) {
|
|
1348
|
+
void this.#rewriteAtomically().catch(err => this.#noteDiskFailure(err));
|
|
1349
|
+
}
|
|
1320
1350
|
return entry.id;
|
|
1321
1351
|
}
|
|
1322
1352
|
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
$env,
|
|
4
|
+
isBunTestRuntime,
|
|
5
|
+
isCompiledBinary,
|
|
6
|
+
logger,
|
|
7
|
+
stripWindowsExtendedLengthPathPrefix,
|
|
8
|
+
workerHostEntry,
|
|
9
|
+
} from "@oh-my-pi/pi-utils";
|
|
3
10
|
import type { Subprocess } from "bun";
|
|
4
11
|
|
|
5
12
|
/**
|
|
@@ -90,13 +97,14 @@ export const SMOKE_TEST_TIMEOUT_MS = 30_000;
|
|
|
90
97
|
* embedding).
|
|
91
98
|
*/
|
|
92
99
|
export function resolveWorkerSpawnCmd(workerArg: string): WorkerSpawnCommand {
|
|
93
|
-
|
|
100
|
+
const executable = stripWindowsExtendedLengthPathPrefix(process.execPath);
|
|
101
|
+
if (isCompiledBinary()) return { cmd: [executable, workerArg] };
|
|
94
102
|
const hostEntry = workerHostEntry();
|
|
95
103
|
if (hostEntry) {
|
|
96
|
-
return { cmd: [
|
|
104
|
+
return { cmd: [executable, path.basename(hostEntry), workerArg], cwd: path.dirname(hostEntry) };
|
|
97
105
|
}
|
|
98
106
|
const packageRoot = path.resolve(import.meta.dir, "..", "..");
|
|
99
|
-
return { cmd: [
|
|
107
|
+
return { cmd: [executable, "src/cli.ts", workerArg], cwd: packageRoot };
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
/**
|
package/src/task/executor.ts
CHANGED
|
@@ -56,7 +56,6 @@ import { ToolAbortError } from "../tools/tool-errors";
|
|
|
56
56
|
import type { EventBus } from "../utils/event-bus";
|
|
57
57
|
import { buildNamedToolChoice } from "../utils/tool-choice";
|
|
58
58
|
import type { WorkspaceTree } from "../workspace-tree";
|
|
59
|
-
import { Semaphore } from "./parallel";
|
|
60
59
|
import { subprocessToolRegistry } from "./subprocess-tool-registry";
|
|
61
60
|
import {
|
|
62
61
|
type AgentDefinition,
|
|
@@ -199,51 +198,6 @@ function installSubagentRetryFallbackChain(args: {
|
|
|
199
198
|
return role;
|
|
200
199
|
}
|
|
201
200
|
|
|
202
|
-
const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
|
|
203
|
-
"ollama-cloud": "providers.ollama-cloud.maxConcurrency",
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
interface ProviderSemaphoreEntry {
|
|
207
|
-
limit: number;
|
|
208
|
-
semaphore: Semaphore;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Resolve the configured concurrency ceiling for a provider, or `undefined`
|
|
215
|
-
* when the provider has no cap concept at all. A configured value `<= 0` means
|
|
216
|
-
* "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
|
|
217
|
-
* holds a slot and a later finite resize counts work started while unlimited.
|
|
218
|
-
*/
|
|
219
|
-
function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
|
|
220
|
-
const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
|
|
221
|
-
if (!settingPath) return undefined;
|
|
222
|
-
const raw = settings.get(settingPath);
|
|
223
|
-
const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
|
224
|
-
return limit > 0 ? limit : Number.POSITIVE_INFINITY;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
|
|
228
|
-
const limit = getProviderConcurrencyLimit(settings, provider);
|
|
229
|
-
if (limit === undefined) return undefined;
|
|
230
|
-
// Always hand out (and acquire on) the single shared limiter, even when
|
|
231
|
-
// unlimited (Infinity). Resizing it in place — rather than replacing it —
|
|
232
|
-
// keeps every in-flight slot counted, so a runtime or mixed limit change can
|
|
233
|
-
// never push concurrency past the cap (issue #3464 review feedback).
|
|
234
|
-
const existing = providerSemaphores.get(provider);
|
|
235
|
-
if (existing) {
|
|
236
|
-
if (existing.limit !== limit) {
|
|
237
|
-
existing.limit = limit;
|
|
238
|
-
existing.semaphore.resize(limit);
|
|
239
|
-
}
|
|
240
|
-
return existing.semaphore;
|
|
241
|
-
}
|
|
242
|
-
const semaphore = new Semaphore(limit);
|
|
243
|
-
providerSemaphores.set(provider, { limit, semaphore });
|
|
244
|
-
return semaphore;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
201
|
function renderIrcPeerRoster(selfId: string): string {
|
|
248
202
|
const peers = AgentRegistry.global()
|
|
249
203
|
.list()
|
|
@@ -2028,8 +1982,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2028
1982
|
let sessionOpenedAt: number | undefined;
|
|
2029
1983
|
let sessionCreatedAt: number | undefined;
|
|
2030
1984
|
let readyAt: number | undefined;
|
|
2031
|
-
let providerSemaphore: Semaphore | undefined;
|
|
2032
|
-
let providerSemaphoreAcquired = false;
|
|
2033
1985
|
|
|
2034
1986
|
try {
|
|
2035
1987
|
checkAbort();
|
|
@@ -2098,13 +2050,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2098
2050
|
? resolvedThinkingLevel
|
|
2099
2051
|
: (thinkingLevel ?? resolvedThinkingLevel);
|
|
2100
2052
|
resolvedAt = performance.now();
|
|
2101
|
-
if (model) {
|
|
2102
|
-
providerSemaphore = getProviderSemaphore(settings, model.provider);
|
|
2103
|
-
if (providerSemaphore) {
|
|
2104
|
-
await providerSemaphore.acquire(abortSignal);
|
|
2105
|
-
providerSemaphoreAcquired = true;
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
2053
|
|
|
2109
2054
|
const effectiveCwd = worktree ?? cwd;
|
|
2110
2055
|
const sessionManager = sessionFile
|
|
@@ -2395,10 +2340,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2395
2340
|
}
|
|
2396
2341
|
if (exitCode === 0) exitCode = 1;
|
|
2397
2342
|
}
|
|
2398
|
-
if (providerSemaphoreAcquired) {
|
|
2399
|
-
providerSemaphore?.release();
|
|
2400
|
-
providerSemaphoreAcquired = false;
|
|
2401
|
-
}
|
|
2402
2343
|
sessionAbortController.abort();
|
|
2403
2344
|
try {
|
|
2404
2345
|
await untilAborted(AbortSignal.timeout(5000), () => monitor.waitForActiveSessionAbort());
|
|
@@ -2429,9 +2370,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2429
2370
|
}
|
|
2430
2371
|
|
|
2431
2372
|
// Launch-latency breakdown (subagent invocation → first chat dispatch).
|
|
2432
|
-
// Phase deltas are performance.now() spans; the
|
|
2433
|
-
// Date.now epochs captured by the spawn site
|
|
2434
|
-
// acquiredAt after) so queue wait and
|
|
2373
|
+
// Phase deltas are performance.now() spans; the task-tool concurrency
|
|
2374
|
+
// brackets use the Date.now epochs captured by the spawn site
|
|
2375
|
+
// (invokedAt before acquire, acquiredAt after) so queue wait and
|
|
2376
|
+
// pre-run setup are reported apart.
|
|
2435
2377
|
const span = (from: number | undefined, to: number | undefined): number | undefined =>
|
|
2436
2378
|
from !== undefined && to !== undefined ? Math.round(to - from) : undefined;
|
|
2437
2379
|
const queueMs =
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider LLM concurrency cap, applied around each provider HTTP request.
|
|
3
|
+
*
|
|
4
|
+
* The semaphore brackets only the streaming request itself, not the whole
|
|
5
|
+
* agent lifetime: a parent subagent releases its slot the moment its LLM
|
|
6
|
+
* stream finishes producing, so children spawned during tool execution can
|
|
7
|
+
* acquire slots for their own turns. Holding the slot across the parent's
|
|
8
|
+
* full conversation deadlocks any spawn tree whose width exceeds
|
|
9
|
+
* `maxConcurrency` because the parents wait for children that wait for
|
|
10
|
+
* slots the parents are holding (issue
|
|
11
|
+
* [#3749](https://github.com/can1357/oh-my-pi/issues/3749)).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { StreamFn } from "@oh-my-pi/pi-agent-core";
|
|
15
|
+
import type { Settings } from "../config/settings";
|
|
16
|
+
import type { SettingPath } from "../config/settings-schema";
|
|
17
|
+
import { Semaphore } from "./parallel";
|
|
18
|
+
|
|
19
|
+
const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
|
|
20
|
+
"ollama-cloud": "providers.ollama-cloud.maxConcurrency",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface ProviderSemaphoreEntry {
|
|
24
|
+
limit: number;
|
|
25
|
+
semaphore: Semaphore;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the configured concurrency ceiling for a provider, or `undefined`
|
|
32
|
+
* when the provider has no cap concept at all. A configured value `<= 0` means
|
|
33
|
+
* "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
|
|
34
|
+
* holds a slot and a later finite resize counts work started while unlimited.
|
|
35
|
+
*/
|
|
36
|
+
export function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
|
|
37
|
+
const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
|
|
38
|
+
if (!settingPath) return undefined;
|
|
39
|
+
const raw = settings.get(settingPath);
|
|
40
|
+
const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
|
41
|
+
return limit > 0 ? limit : Number.POSITIVE_INFINITY;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Hand out the single shared limiter for `provider` (creating one lazily) and
|
|
46
|
+
* resize it in place when the configured limit changes. Replacing the
|
|
47
|
+
* semaphore would orphan in-flight slots on the old instance and let a
|
|
48
|
+
* runtime or mixed limit value exceed the cap (issue #3464 review feedback).
|
|
49
|
+
*/
|
|
50
|
+
export function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
|
|
51
|
+
const limit = getProviderConcurrencyLimit(settings, provider);
|
|
52
|
+
if (limit === undefined) return undefined;
|
|
53
|
+
const existing = providerSemaphores.get(provider);
|
|
54
|
+
if (existing) {
|
|
55
|
+
if (existing.limit !== limit) {
|
|
56
|
+
existing.limit = limit;
|
|
57
|
+
existing.semaphore.resize(limit);
|
|
58
|
+
}
|
|
59
|
+
return existing.semaphore;
|
|
60
|
+
}
|
|
61
|
+
const semaphore = new Semaphore(limit);
|
|
62
|
+
providerSemaphores.set(provider, { limit, semaphore });
|
|
63
|
+
return semaphore;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap a {@link StreamFn} so every LLM HTTP request acquires the provider's
|
|
68
|
+
* concurrency slot before the request goes out and releases it when the
|
|
69
|
+
* stream finishes producing (success, error, or abort). Providers without a
|
|
70
|
+
* configured cap pass straight through.
|
|
71
|
+
*
|
|
72
|
+
* The acquire bracket is intentionally narrow (one slot per LLM call), so
|
|
73
|
+
* spawn trees deeper than `maxConcurrency` no longer deadlock on themselves —
|
|
74
|
+
* see the module-level comment for the failure mode this fixes.
|
|
75
|
+
*/
|
|
76
|
+
export function wrapStreamFnWithProviderConcurrency(settings: Settings, base: StreamFn): StreamFn {
|
|
77
|
+
return async (model, context, options) => {
|
|
78
|
+
const semaphore = getProviderSemaphore(settings, model.provider);
|
|
79
|
+
if (!semaphore) return base(model, context, options);
|
|
80
|
+
await semaphore.acquire(options?.signal);
|
|
81
|
+
let released = false;
|
|
82
|
+
const release = () => {
|
|
83
|
+
if (released) return;
|
|
84
|
+
released = true;
|
|
85
|
+
semaphore.release();
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
const stream = await base(model, context, options);
|
|
89
|
+
// EventStream.result() settles when the producer pushes 'done'/'error'
|
|
90
|
+
// or calls fail() — i.e. once the provider has finished producing.
|
|
91
|
+
// Releasing here keeps the slot held for the network request and
|
|
92
|
+
// nothing else.
|
|
93
|
+
stream.result().then(release, release);
|
|
94
|
+
return stream;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
release();
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/task/worktree.ts
CHANGED
|
@@ -3,12 +3,16 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import * as natives from "@oh-my-pi/pi-natives";
|
|
6
|
-
import { getWorktreeDir,
|
|
6
|
+
import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import * as git from "../utils/git";
|
|
8
8
|
import * as jj from "../utils/jj";
|
|
9
9
|
import { mapWithConcurrencyLimit } from "./parallel";
|
|
10
10
|
|
|
11
11
|
const { IsoBackendKind } = natives;
|
|
12
|
+
|
|
13
|
+
const TASK_ISOLATION_DIR_PREFIX = "t";
|
|
14
|
+
const TASK_ISOLATION_DIR_DIGEST_CHARS = 9;
|
|
15
|
+
const TASK_ISOLATION_MOUNT_DIR = "m";
|
|
12
16
|
type IsoBackendKind = natives.IsoBackendKind;
|
|
13
17
|
|
|
14
18
|
/** Baseline state for a single git repository. */
|
|
@@ -389,15 +393,20 @@ function errorMessage(err: unknown): string {
|
|
|
389
393
|
return err instanceof Error ? err.message : String(err);
|
|
390
394
|
}
|
|
391
395
|
|
|
396
|
+
function getTaskIsolationSegment(repoRoot: string, id: string): string {
|
|
397
|
+
const key = `${path.resolve(repoRoot)}\0${id}`;
|
|
398
|
+
const digest = Bun.hash(key).toString(16).padStart(16, "0").slice(-TASK_ISOLATION_DIR_DIGEST_CHARS);
|
|
399
|
+
return `${TASK_ISOLATION_DIR_PREFIX}${digest}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
392
402
|
export async function ensureIsolation(
|
|
393
403
|
baseCwd: string,
|
|
394
404
|
id: string,
|
|
395
405
|
preferred?: IsoBackendKind,
|
|
396
406
|
): Promise<IsolationHandle> {
|
|
397
407
|
const repoRoot = await getRepoRoot(baseCwd);
|
|
398
|
-
const baseDir = getWorktreeDir(
|
|
399
|
-
const mergedDir = path.join(baseDir,
|
|
400
|
-
|
|
408
|
+
const baseDir = getWorktreeDir(getTaskIsolationSegment(repoRoot, id));
|
|
409
|
+
const mergedDir = path.join(baseDir, TASK_ISOLATION_MOUNT_DIR);
|
|
401
410
|
const resolution = natives.isoResolve(preferred ?? null);
|
|
402
411
|
const candidates = resolution.candidates.length > 0 ? resolution.candidates : [resolution.kind];
|
|
403
412
|
let fallbackReason = resolution.reason ?? null;
|
|
@@ -130,74 +130,62 @@ export function assembleYieldResult(
|
|
|
130
130
|
arrayLabels?: ReadonlySet<string>,
|
|
131
131
|
): AssembledYieldResult | undefined {
|
|
132
132
|
if (yieldItems.length === 0) return undefined;
|
|
133
|
+
|
|
134
|
+
// Terminal = the last non-incremental yield (untyped, or string-typed like
|
|
135
|
+
// `type: "result"`). Array-typed yields are incremental sections and never
|
|
136
|
+
// terminate on their own.
|
|
133
137
|
let terminalItem: YieldItem | undefined;
|
|
134
138
|
for (let index = yieldItems.length - 1; index >= 0; index--) {
|
|
135
139
|
const item = yieldItems[index];
|
|
136
|
-
if (!item)
|
|
137
|
-
if (!isIncrementalYieldType(item.type)) {
|
|
140
|
+
if (item && !isIncrementalYieldType(item.type)) {
|
|
138
141
|
terminalItem = item;
|
|
139
142
|
break;
|
|
140
143
|
}
|
|
141
144
|
}
|
|
142
|
-
let hasTypedSections = false;
|
|
143
|
-
for (const item of yieldItems) {
|
|
144
|
-
if (getYieldLabels(item.type).length > 0) {
|
|
145
|
-
hasTypedSections = true;
|
|
146
|
-
break;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
if (terminalItem && typeof terminalItem.type === "string" && terminalItem.data === undefined) {
|
|
150
|
-
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, getYieldLabels(terminalItem.type));
|
|
151
|
-
return {
|
|
152
|
-
data: resolved.value,
|
|
153
|
-
schemaOverridden: terminalItem.schemaOverridden === true,
|
|
154
|
-
rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
|
|
155
|
-
missingData: resolved.missingData,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
if (!hasTypedSections && terminalItem) {
|
|
159
|
-
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []);
|
|
160
|
-
return {
|
|
161
|
-
data: resolved.value,
|
|
162
|
-
schemaOverridden: terminalItem.schemaOverridden === true,
|
|
163
|
-
rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
|
|
164
|
-
missingData: resolved.missingData,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
145
|
|
|
146
|
+
// Sections come ONLY from incremental (array-typed) yields. A string `type`
|
|
147
|
+
// is a terminal marker, never a section label: folding its data under the
|
|
148
|
+
// label is what nested a finalize payload (`type: "result"`, `data: {…}`) one
|
|
149
|
+
// level deep and made output-schema validation report every field missing.
|
|
168
150
|
const sections: Record<string, unknown> = {};
|
|
169
151
|
const sectionCounts = new Map<string, number>();
|
|
170
152
|
let schemaOverridden = false;
|
|
171
153
|
let missingData = false;
|
|
172
154
|
let hasSections = false;
|
|
173
|
-
|
|
174
155
|
for (const item of yieldItems) {
|
|
175
156
|
if (item.status === "aborted") continue;
|
|
157
|
+
if (!isIncrementalYieldType(item.type)) continue;
|
|
176
158
|
schemaOverridden ||= item.schemaOverridden === true;
|
|
177
159
|
const labels = getYieldLabels(item.type);
|
|
178
|
-
if (labels.length === 0) continue;
|
|
179
160
|
const resolved = resolveYieldPayload(item, lastAssistantText, labels);
|
|
180
161
|
missingData ||= resolved.missingData;
|
|
181
|
-
const incremental = isIncrementalYieldType(item.type);
|
|
182
162
|
for (const label of labels) {
|
|
183
|
-
appendYieldSection(
|
|
184
|
-
sections,
|
|
185
|
-
sectionCounts,
|
|
186
|
-
label,
|
|
187
|
-
resolved.value,
|
|
188
|
-
incremental && (arrayLabels?.has(label) ?? false),
|
|
189
|
-
);
|
|
163
|
+
appendYieldSection(sections, sectionCounts, label, resolved.value, arrayLabels?.has(label) ?? false);
|
|
190
164
|
hasSections = true;
|
|
191
165
|
}
|
|
192
|
-
if (!isIncrementalYieldType(item.type)) break;
|
|
193
166
|
}
|
|
194
167
|
|
|
168
|
+
// An explicit terminal payload wins: an untyped final result or a
|
|
169
|
+
// `type: "result"` finalize that carries `data` is the complete result, used
|
|
170
|
+
// verbatim — never wrapped in a section.
|
|
171
|
+
if (terminalItem && terminalItem.data !== undefined) {
|
|
172
|
+
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []);
|
|
173
|
+
return {
|
|
174
|
+
data: resolved.value,
|
|
175
|
+
schemaOverridden: terminalItem.schemaOverridden === true,
|
|
176
|
+
rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
|
|
177
|
+
missingData: resolved.missingData,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A data-less terminal finalize keeps accumulated sections; only when none
|
|
182
|
+
// exist does the last assistant turn become the raw result.
|
|
195
183
|
if (hasSections) {
|
|
196
184
|
return { data: sections, schemaOverridden, rawText: false, missingData };
|
|
197
185
|
}
|
|
198
186
|
|
|
199
187
|
if (!terminalItem) return undefined;
|
|
200
|
-
const resolved = resolveYieldPayload(terminalItem, lastAssistantText,
|
|
188
|
+
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, getYieldLabels(terminalItem.type));
|
|
201
189
|
return {
|
|
202
190
|
data: resolved.value,
|
|
203
191
|
schemaOverridden: terminalItem.schemaOverridden === true,
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as url from "node:url";
|
|
5
|
-
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { isEnoent, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
|
|
6
6
|
import type { Skill } from "../extensibility/skills";
|
|
7
7
|
import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
|
|
8
8
|
import { ToolError } from "./tool-errors";
|
|
@@ -147,7 +147,9 @@ export function expandTilde(filePath: string, home?: string): string {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
export function expandPath(filePath: string): string {
|
|
150
|
-
const normalized =
|
|
150
|
+
const normalized = stripWindowsExtendedLengthPathPrefix(
|
|
151
|
+
stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath))),
|
|
152
|
+
);
|
|
151
153
|
return expandTilde(normalized);
|
|
152
154
|
}
|
|
153
155
|
|
package/src/tools/read.ts
CHANGED
|
@@ -178,7 +178,17 @@ interface HashlineHeaderContext {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
function formatReadHashlineHeader(displayPath: string, tag: string): string {
|
|
181
|
-
|
|
181
|
+
// In-workspace reads collapse to the bare filename for brevity: the edit
|
|
182
|
+
// tool's snapshot-tag recovery rebinds a bare `[name#tag]` onto the in-tree
|
|
183
|
+
// file it uniquely names. Out-of-workspace reads can't lean on that —
|
|
184
|
+
// recovery refuses to redirect a write outside the cwd/sandbox
|
|
185
|
+
// (HashlineFilesystem.allowTagPathRecovery) — so an absolute displayPath
|
|
186
|
+
// must stay directly resolvable, otherwise the basename resolves against
|
|
187
|
+
// cwd, misses, and the edit fails with "File not found" (e.g. ~/.claude/*).
|
|
188
|
+
// `shortenPath` keeps `~/.claude/...` (round-trips through resolveToCwd's ~
|
|
189
|
+
// expansion) instead of leaking the full home path into the read output.
|
|
190
|
+
const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : path.basename(displayPath);
|
|
191
|
+
return formatHashlineHeader(anchor, tag);
|
|
182
192
|
}
|
|
183
193
|
|
|
184
194
|
function recordFullHashlineContext(
|
package/src/utils/git.ts
CHANGED
|
@@ -1712,6 +1712,19 @@ export const repo = {
|
|
|
1712
1712
|
return primaryRootFromRepositorySync(repository);
|
|
1713
1713
|
},
|
|
1714
1714
|
|
|
1715
|
+
/**
|
|
1716
|
+
* Linked-worktree metadata for `cwd`, or `null` when `cwd` is the primary
|
|
1717
|
+
* checkout (or outside a repository). `root` is the worktree's own checkout
|
|
1718
|
+
* root; `primaryRoot` is the shared main checkout that names the project.
|
|
1719
|
+
* Resolves purely via on-disk `.git`/`commondir` walking — no subprocess —
|
|
1720
|
+
* so the status line may call it on every render.
|
|
1721
|
+
*/
|
|
1722
|
+
linkedWorktreeSync(cwd: string): { root: string; primaryRoot: string } | null {
|
|
1723
|
+
const repository = resolveRepositorySync(cwd);
|
|
1724
|
+
if (!repository || !isLinkedWorktree(repository)) return null;
|
|
1725
|
+
return { root: repository.repoRoot, primaryRoot: primaryRootFromRepositorySync(repository) };
|
|
1726
|
+
},
|
|
1727
|
+
|
|
1715
1728
|
/** Full GitRepository metadata (sync). */
|
|
1716
1729
|
resolveSync(cwd: string): GitRepository | null {
|
|
1717
1730
|
return resolveRepositorySync(cwd);
|