@oh-my-pi/pi-coding-agent 16.2.5 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/cli.js +2991 -3011
  3. package/dist/types/cli/bench-cli.d.ts +6 -1
  4. package/dist/types/commands/bench.d.ts +8 -0
  5. package/dist/types/config/model-discovery.d.ts +6 -1
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/edit/index.d.ts +1 -0
  8. package/dist/types/edit/renderer.d.ts +4 -0
  9. package/dist/types/edit/snapshot-details.d.ts +33 -0
  10. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  11. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  12. package/dist/types/session/agent-session.d.ts +4 -0
  13. package/dist/types/session/session-manager.d.ts +3 -4
  14. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  15. package/dist/types/web/search/types.d.ts +1 -1
  16. package/package.json +12 -12
  17. package/src/cli/args.ts +32 -1
  18. package/src/cli/bench-cli.ts +89 -21
  19. package/src/cli/web-search-cli.ts +6 -1
  20. package/src/commands/bench.ts +10 -1
  21. package/src/config/mcp-schema.json +1 -1
  22. package/src/config/model-discovery.ts +66 -8
  23. package/src/config/model-registry.ts +13 -6
  24. package/src/edit/hashline/execute.ts +15 -9
  25. package/src/edit/index.ts +19 -6
  26. package/src/edit/modes/patch.ts +3 -2
  27. package/src/edit/modes/replace.ts +3 -2
  28. package/src/edit/renderer.ts +4 -0
  29. package/src/edit/snapshot-details.ts +77 -0
  30. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  31. package/src/internal-urls/docs-index.generated.txt +1 -1
  32. package/src/mcp/oauth-flow.ts +10 -8
  33. package/src/mcp/transports/stdio.ts +9 -17
  34. package/src/modes/controllers/event-controller.ts +17 -8
  35. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  36. package/src/prompts/bench.md +4 -10
  37. package/src/prompts/tools/irc.md +19 -29
  38. package/src/prompts/tools/job.md +8 -14
  39. package/src/prompts/tools/lsp.md +19 -30
  40. package/src/prompts/tools/task.md +42 -62
  41. package/src/sdk.ts +1 -0
  42. package/src/session/agent-session.ts +10 -0
  43. package/src/session/session-listing.ts +9 -8
  44. package/src/session/session-loader.ts +98 -3
  45. package/src/session/session-manager.ts +34 -4
  46. package/src/subprocess/worker-client.ts +12 -4
  47. package/src/tools/path-utils.ts +4 -2
  48. package/src/web/search/index.ts +14 -8
  49. package/src/web/search/providers/duckduckgo.ts +136 -78
  50. package/src/web/search/providers/gemini.ts +268 -185
  51. package/src/web/search/types.ts +1 -1
@@ -1,91 +1,71 @@
1
- {{#if asyncEnabled}}{{#if batchEnabled}}Spawns subagents to work in the background one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Spawns ONE subagent per call to work in the background.{{/if}}
1
+ {{#if asyncEnabled}}{{#if batchEnabled}}Delegate work to background subagents by passing multiple items in a single `tasks[]` batch.{{else}}Delegate work to ONE background subagent per call.{{/if}}
2
+ Execution does not block your turn: you receive agent and job IDs immediately, and the final results deliver themselves when the subagents finish.{{else}}{{#if batchEnabled}}Run subagents synchronously by passing items in a `tasks[]` batch.{{else}}Run ONE subagent synchronously per call.{{/if}}
3
+ Execution blocks your turn: the call only returns once the work is completely finished.{{/if}}
2
4
 
3
- - Spawning is non-blocking: the call returns immediately with the agent id{{#if batchEnabled}}s{{/if}} and job id{{#if batchEnabled}}s{{/if}}; each result is delivered automatically when that agent yields.
4
- - Parallelism = {{#if batchEnabled}}multiple `tasks[]` items in ONE call. MUST batch into one `tasks[]` (share `context` once). Separate `task` calls ONLY for a different `agent` type or unrelated `context`{{else}}multiple `task` calls in one assistant message{{/if}}.
5
- - If genuinely blocked on a result, wait with `job poll`; otherwise keep working. `job cancel` terminates a task and **cannot carry a message** only for stalled/abandoned work.
6
- {{else}}{{#if batchEnabled}}Runs subagents synchronously one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Runs ONE subagent synchronously per call.{{/if}}
5
+ # Delegation Strategy
6
+ - **Maximize parallelism:** Break work into the widest possible {{#if batchEnabled}}array of `tasks[]`{{else}}set of parallel `task` calls{{/if}}. NEVER serialize work that can run concurrently. Tasks touching different files or independent refactors should run in parallel; agents resolve their own file collisions live.
7
+ - **Sequence only when necessary:** The only reason to run A before B is if B strictly requires A's output to function (e.g., a core API contract or schema migration). {{#if ircEnabled}}If the missing piece is small, run them in parallel and have B ask A via `irc`!{{/if}}
8
+ - **Role matching:** Assign each subagent a specific `role` (e.g. "Security Reviewer", "DB Migrator"). Do not spawn generic workers.
9
+ - **No overhead:** Each assignment MUST instruct its agent to skip formatters, linters, and project-wide test suites. You will run those once at the end.
10
+ - **Do your own thinking:** NEVER assign reasoning, architecture, or design to `quick_task` or `explore`. They are for mechanical lookups only. Keep hard decisions in your own context or use `task`, `plan`, or `oracle`.
11
+ - **One-pass agents:** Prefer agents that investigate **and** edit in a single pass; only spin a read-only discovery step (e.g. `explore`) when the affected files are genuinely unknown.
7
12
 
8
- - Spawning is blocking: the call returns only after the agent{{#if batchEnabled}}s{{/if}} finish; results arrive inline.
9
- - Parallelism = {{#if batchEnabled}}multiple `tasks[]` items in ONE call. MUST batch into one `tasks[]` (share `context` once). Separate `task` calls ONLY for a different `agent` type or unrelated `context`{{else}}multiple `task` calls in one assistant message{{/if}}.
10
- {{/if}}
11
- {{#if ircEnabled}}
12
- - Coordinate with agents via `irc` using their ids. Agents reach you and their siblings live the same way.
13
- {{/if}}
14
-
15
- <parameters>
16
- - `agent`: agent type to spawn
13
+ # Inputs
14
+ - `agent`: The base agent type to use (e.g., `task`, `explore`).
17
15
  {{#if batchEnabled}}
18
- - `context`: shared background prepended to every assignment goal, constraints, shared contract (see context-fmt); REQUIRED, session-specific only
19
- - `tasks`: tasks to spawn — one subagent per item, all in parallel:
20
- - `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED
21
- - `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted
22
- - `description`: UI label only subagent never sees it
23
- - `role`: specialist identity this subagent embodies (e.g. "Auth-flow security reviewer") — sets its system-prompt persona and roster display name; tailor every spawn rather than cloning a generic worker
16
+ - `context`: Shared project state, constraints, and contracts. Applies to the entire batch; do not duplicate this background into individual tasks.
17
+ - `tasks[]`: Array of subagents to spawn.
18
+ - `assignment`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED.
19
+ - `id`: A stable CamelCase identifier (≤32 chars). Generated automatically if omitted.
20
+ - `description`: A UI label only; the subagent NEVER sees it.
21
+ - `role`: The specialist this subagent embodies. Tailor per spawn; do not clone a generic worker.
24
22
  {{#if isolationEnabled}}
25
- - `isolated`: run this spawn in an isolated env; returns patches. Isolated agents are torn down at completion not addressable afterwards
23
+ - `isolated`: Run in a dedicated worktree and return patches. Isolated agents are destroyed upon completion and cannot be addressed afterward.
26
24
  {{/if}}
27
25
  {{else}}
28
- - `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted
29
- - `description`: UI label only subagent never sees it
30
- - `role`: specialist identity this subagent embodies (e.g. "Auth-flow security reviewer") — sets its system-prompt persona and roster display name; tailor every spawn rather than cloning a generic worker
31
- - `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED
26
+ - `assignment`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED.
27
+ - `id`: A stable CamelCase identifier (≤32 chars). Generated automatically if omitted.
28
+ - `description`: A UI label only; the subagent NEVER sees it.
29
+ - `role`: The specialist this subagent embodies. Tailor per spawn; do not clone a generic worker.
32
30
  {{#if isolationEnabled}}
33
- - `isolated`: run in isolated env; returns patches. Isolated agents are torn down at completion not addressable afterwards
31
+ - `isolated`: Run in a dedicated worktree and return patches. Isolated agents are destroyed upon completion and cannot be addressed afterward.
34
32
  {{/if}}
35
33
  {{/if}}
36
- </parameters>
37
34
 
38
- <rules>
39
- - **Maximize fan-out.** Issue the widest {{#if batchEnabled}}`tasks[]` batch{{else}}set of parallel `task` calls{{/if}} the work decomposes into. NEVER serialize work that could run concurrently.
40
- - **Subagents do not verify, lint, or format.** Every assignment MUST instruct the subagent to skip all gates, formatters, and project-wide build/test/lint. You run them once at the end across the union of changed files.
41
- - No globs, no "update all", no package-wide scope. Fan out.
42
- - **Tailor every spawn with a `role`.** A role naming the specialist (e.g. "Parser edge-case tester", "SSE backpressure specialist") makes a sharper agent than a bare generic `task`/`quick_task` worker; decompose into named specialists, never clones of one generic worker. A role-less generic spawn is the exception.
43
- - NEVER slow down or serialize because tasks might overlap on some files. Agents resolve collisions among themselves in real time.
44
- - Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in {{#if batchEnabled}}`context` or the item's `assignment`{{else}}the `assignment`{{/if}}.
35
+ # Context and Communication
36
+ Subagents start blank. They have no access to your conversation history.
45
37
  {{#if batchEnabled}}
46
- - **Shared background** lives in `context` once — never duplicated across assignments. Pass large payloads via `local://<path>` URIs, not inline.
38
+ - Pass large payloads using `local://<path>` URIs, never inline text.
47
39
  {{else}}
48
- - **Shared background**: write it ONCE to a `local://` file (e.g. `local://ctx.md`) and reference that path in each assignment. Pass large payloads via `local://<path>` URIs, not inline.
40
+ - *Note: The single-spawn shape has no `context` field.* Write shared project state ONCE to a `local://` file (e.g., `local://ctx.md`) and reference that URL in your assignments. Pass large payloads using `local://<path>` URIs, never inline text.
49
41
  {{/if}}
50
- - Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown.
51
- - **Read-only agents**: Agents tagged READ-ONLY (e.g. `explore`) have no edit/write/command tools. NEVER hand them an assignment that requires changing files or running commands. Use them to investigate and report back; do the edits yourself or delegate to a writing agent (`task`, `oracle`, `designer`).
52
- - **No reasoning offload**: NEVER offload reasoning, analysis, design, or decision-making to `quick_task` or `explore` — they run minimal-effort / small models for mechanical lookups and data collection only. Keep judgment and synthesis in your own context; delegate hard thinking to `task`, `plan`, or `oracle`.
53
- </rules>
54
-
55
- <parallelization>
56
42
  {{#if ircEnabled}}
57
- Test: can task B run correctly without seeing A's output? If no, sequence A B **unless** B can reasonably ask A for the missing piece over `irc`. Live coordination beats a serial waterfall when the contract is small and easy to describe in a DM.
58
- Still sequence when one task produces a large, evolving contract (generated types, schema migration, core module API) the other consumes wholesale — IRC round-trips do not replace a finished artifact.
59
- Parallel when tasks touch disjoint files, are independent refactors/tests, or only need occasional clarification that can be resolved peer-to-peer.
60
- {{else}}
61
- Test: can task B run correctly without seeing A's output? If no, sequence A → B.
62
- Sequential when one task produces a contract (types, API, schema, core module) the other consumes.
63
- Parallel when tasks touch disjoint files or are independent refactors/tests.
43
+ - Once spawned, coordinate with live agents via `irc` using their IDs. If task B depends on task A, B SHOULD message A directly.
44
+ {{/if}}
45
+ {{#if asyncEnabled}}
46
+ - If you run out of things to do and are genuinely blocked waiting for a subagent, use `job poll`. Use `job cancel` only for stalled work.
64
47
  {{/if}}
65
- {{#if ircEnabled}}Sequenced follow-ups SHOULD message the agent that produced the prerequisite — it already holds the context.{{/if}}
66
- </parallelization>
67
48
 
49
+ # Format Contracts
68
50
  {{#if batchEnabled}}
69
- <context-fmt>
70
- # Goal ← one sentence: what the batch accomplishes
71
- # Constraints ← MUST/NEVER rules and session decisions
72
- # Contract ← exact types/signatures if tasks share an interface
73
- </context-fmt>
51
+ The `context` field MUST follow this format:
52
+ # Goal ← what the batch accomplishes
53
+ # Constraints ← rules and session decisions
54
+ # Contract ← shared interfaces
74
55
  {{/if}}
75
56
 
76
- <assignment-fmt>
57
+ The `assignment` field MUST follow this format:
77
58
  # Target ← exact files and symbols; explicit non-goals
78
59
  # Change ← step-by-step add/remove/rename; APIs and patterns
79
60
  # Acceptance ← observable result; no project-wide commands
80
- </assignment-fmt>
81
61
 
82
- <agents>
62
+ # Available Agents
83
63
  {{#if spawningDisabled}}
84
- Agent spawning is disabled for this context.
64
+ Agent spawning is currently disabled.
85
65
  {{else}}
86
66
  {{#list agents join="\n"}}
87
- # {{name}}{{#if readOnly}} READ-ONLY (no edit/write/exec tools){{/if}}
67
+ ### {{name}}{{#if readOnly}} (READ-ONLY: no edit/write/command tools){{/if}}
88
68
  {{description}}
69
+ {{#if readOnly}}Use ONLY for investigation and reporting; do the edits yourself or assign them to a writing agent.{{/if}}
89
70
  {{/list}}
90
71
  {{/if}}
91
- </agents>
package/src/sdk.ts CHANGED
@@ -2718,6 +2718,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2718
2718
  onResponse,
2719
2719
  sideStreamFn: settingsAwareStreamFn,
2720
2720
  advisorStreamFn: settingsAwareStreamFn,
2721
+ preferWebsockets: preferOpenAICodexWebsockets,
2721
2722
  convertToLlm: convertToLlmFinal,
2722
2723
  rebuildSystemPrompt,
2723
2724
  reloadSshTool,
@@ -549,6 +549,8 @@ export interface AgentSessionConfig {
549
549
  * main agent. Defaults to plain `streamSimple` when omitted.
550
550
  */
551
551
  advisorStreamFn?: StreamFn;
552
+ /** Hint that OpenAI Codex requests should prefer websocket transport when supported. */
553
+ preferWebsockets?: boolean;
552
554
  /** Provider payload hook used by the active session request path */
553
555
  onPayload?: SimpleStreamOptions["onPayload"];
554
556
  /** Provider response hook used by the active session request path */
@@ -1468,6 +1470,7 @@ export class AgentSession {
1468
1470
  #transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
1469
1471
  #sideStreamFn: StreamFn;
1470
1472
  #advisorStreamFn: StreamFn | undefined;
1473
+ #preferWebsockets: boolean | undefined;
1471
1474
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
1472
1475
  #rebuildSystemPrompt:
1473
1476
  | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
@@ -1798,6 +1801,7 @@ export class AgentSession {
1798
1801
  this.#transformProviderContext = config.transformProviderContext;
1799
1802
  this.#sideStreamFn = config.sideStreamFn ?? streamSimple;
1800
1803
  this.#advisorStreamFn = config.advisorStreamFn;
1804
+ this.#preferWebsockets = config.preferWebsockets;
1801
1805
  this.#onPayload = config.onPayload;
1802
1806
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
1803
1807
  // Avoid wrapping in an `async` closure when no user callback is configured: the
@@ -2139,6 +2143,7 @@ export class AgentSession {
2139
2143
  sessionId: advisorSessionId,
2140
2144
  promptCacheKey: advisorSessionId,
2141
2145
  providerSessionState: this.#providerSessionState,
2146
+ preferWebsockets: this.#preferWebsockets,
2142
2147
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
2143
2148
  streamFn: this.#advisorStreamFn,
2144
2149
  onPayload: this.#onPayload,
@@ -2624,6 +2629,11 @@ export class AgentSession {
2624
2629
  return this.#providerSessionState;
2625
2630
  }
2626
2631
 
2632
+ /** Hint forwarded to provider calls that support websocket transport. */
2633
+ get preferWebsockets(): boolean | undefined {
2634
+ return this.#preferWebsockets;
2635
+ }
2636
+
2627
2637
  getHindsightSessionState(): HindsightSessionState | undefined {
2628
2638
  return this.#hindsightSessionState;
2629
2639
  }
@@ -245,20 +245,21 @@ function countMessageMarkers(content: string): number {
245
245
  return count;
246
246
  }
247
247
 
248
- function extractFirstUserMessageFromPrefix(content: string): string | undefined {
249
- const roleIndex = content.indexOf('"role"');
250
- if (roleIndex === -1) return undefined;
248
+ function extractFirstDisplayMessageFromPrefix(content: string): string | undefined {
249
+ let fallback: string | undefined;
250
+ let index = content.indexOf('"role"');
251
251
 
252
- let index = roleIndex;
253
252
  while (index !== -1) {
254
253
  const role = extractStringProperty(content, "role", index);
255
- if (role === "user") {
256
- return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
254
+ const text = extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
255
+ if (text) {
256
+ if (role === "user") return text;
257
+ if (!fallback && (role === "developer" || role === "assistant")) fallback = text;
257
258
  }
258
259
  index = content.indexOf('"role"', index + 6);
259
260
  }
260
261
 
261
- return undefined;
262
+ return fallback;
262
263
  }
263
264
 
264
265
  interface SessionListHeader {
@@ -394,7 +395,7 @@ async function scanSessionFile(
394
395
  }
395
396
  }
396
397
 
397
- firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
398
+ firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? "";
398
399
  const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
399
400
  return {
400
401
  path: file,
@@ -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 content: string;
173
+ let loaded: { entries: FileEntry[]; titleSlot: SessionTitleUpdate | undefined };
84
174
  try {
85
- content = await storage.readText(filePath);
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 } = parseSessionContent(content);
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 flush all in-memory entries to disk. Use when the process may
1028
- * exit before an async flush settles (Ctrl+C in the TUI). Software-crash
1029
- * durable; not atomic and not power-loss safe a same-process crash never
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 { $env, isBunTestRuntime, isCompiledBinary, logger, workerHostEntry } from "@oh-my-pi/pi-utils";
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
- if (isCompiledBinary()) return { cmd: [process.execPath, workerArg] };
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: [process.execPath, path.basename(hostEntry), workerArg], cwd: path.dirname(hostEntry) };
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: [process.execPath, "src/cli.ts", workerArg], cwd: packageRoot };
107
+ return { cmd: [executable, "src/cli.ts", workerArg], cwd: packageRoot };
100
108
  }
101
109
 
102
110
  /**
@@ -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 = stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath)));
150
+ const normalized = stripWindowsExtendedLengthPathPrefix(
151
+ stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath))),
152
+ );
151
153
  return expandTilde(normalized);
152
154
  }
153
155
 
@@ -138,14 +138,20 @@ async function executeSearch(
138
138
  options: ExecuteSearchOptions,
139
139
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
140
140
  const { authStorage, sessionId, signal } = options;
141
- const providers =
142
- params.provider && params.provider !== "auto"
143
- ? await getSearchProvider(params.provider).then(async provider =>
144
- (await provider.isExplicitlyAvailable(authStorage))
145
- ? [provider]
146
- : resolveProviderChain(authStorage, "auto"),
147
- )
148
- : await resolveProviderChain(authStorage);
141
+ const explicitProvider = params.provider;
142
+ let providers: SearchProvider[];
143
+ if (explicitProvider && explicitProvider !== "auto") {
144
+ const provider = await getSearchProvider(explicitProvider);
145
+ providers = (await provider.isExplicitlyAvailable(authStorage))
146
+ ? [provider]
147
+ : await resolveProviderChain(authStorage, "auto");
148
+ } else if (explicitProvider === "auto") {
149
+ // Explicit `--provider auto` bypasses the configured preferred provider
150
+ // for this invocation; exclusions still apply.
151
+ providers = await resolveProviderChain(authStorage, "auto");
152
+ } else {
153
+ providers = await resolveProviderChain(authStorage);
154
+ }
149
155
  if (providers.length === 0) {
150
156
  const message = "No web search provider configured.";
151
157
  return {