@oh-my-pi/pi-coding-agent 14.2.0 → 14.3.0

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 (54) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/package.json +19 -19
  3. package/src/cli/args.ts +10 -1
  4. package/src/cli/shell-cli.ts +15 -3
  5. package/src/config/settings-schema.ts +60 -1
  6. package/src/dap/session.ts +8 -2
  7. package/src/debug/system-info.ts +6 -2
  8. package/src/discovery/claude.ts +58 -36
  9. package/src/discovery/opencode.ts +20 -2
  10. package/src/edit/index.ts +3 -1
  11. package/src/edit/modes/chunk.ts +133 -53
  12. package/src/edit/modes/hashline.ts +36 -11
  13. package/src/edit/renderer.ts +98 -133
  14. package/src/edit/streaming.ts +351 -0
  15. package/src/exec/bash-executor.ts +60 -5
  16. package/src/internal-urls/docs-index.generated.ts +5 -5
  17. package/src/internal-urls/pi-protocol.ts +0 -2
  18. package/src/lsp/client.ts +22 -6
  19. package/src/lsp/defaults.json +2 -1
  20. package/src/lsp/index.ts +53 -10
  21. package/src/lsp/types.ts +2 -0
  22. package/src/modes/acp/acp-agent.ts +76 -2
  23. package/src/modes/components/assistant-message.ts +1 -34
  24. package/src/modes/components/hook-editor.ts +1 -1
  25. package/src/modes/components/tool-execution.ts +111 -101
  26. package/src/modes/controllers/input-controller.ts +1 -1
  27. package/src/modes/interactive-mode.ts +0 -2
  28. package/src/modes/theme/mermaid-cache.ts +13 -52
  29. package/src/modes/theme/theme.ts +2 -2
  30. package/src/prompts/system/system-prompt.md +1 -1
  31. package/src/prompts/tools/ast-grep.md +1 -0
  32. package/src/prompts/tools/browser.md +1 -0
  33. package/src/prompts/tools/chunk-edit.md +25 -22
  34. package/src/prompts/tools/gh-pr-push.md +2 -1
  35. package/src/prompts/tools/grep.md +4 -3
  36. package/src/prompts/tools/lsp.md +6 -0
  37. package/src/prompts/tools/read-chunk.md +46 -7
  38. package/src/prompts/tools/read.md +7 -4
  39. package/src/sdk.ts +8 -5
  40. package/src/session/agent-session.ts +36 -20
  41. package/src/session/session-manager.ts +228 -57
  42. package/src/session/streaming-output.ts +11 -0
  43. package/src/system-prompt.ts +7 -2
  44. package/src/task/executor.ts +1 -0
  45. package/src/tools/ast-edit.ts +37 -2
  46. package/src/tools/bash.ts +75 -12
  47. package/src/tools/find.ts +19 -26
  48. package/src/tools/gh.ts +6 -16
  49. package/src/tools/grep.ts +94 -37
  50. package/src/tools/path-utils.ts +31 -3
  51. package/src/tools/resolve.ts +12 -3
  52. package/src/tools/sqlite-reader.ts +116 -3
  53. package/src/tools/vim.ts +1 -1
  54. package/src/web/search/providers/codex.ts +129 -6
@@ -1,23 +1,54 @@
1
- Reads files using syntax-aware chunks.
1
+ Reads files using syntax-aware chunks. Also inspects directories, archives, SQLite databases, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), **and URLs**.
2
2
 
3
3
  <instruction>
4
- - `path` file path or URL; may include `:selector` suffix
5
- - `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD~`, `?`, `L50`, `L50-L120`, or `raw`
4
+ The chunk-aware `read` variant returns AST-scoped chunks with current checksum IDs for structural editing, and otherwise behaves like `open` for non-code content.
5
+
6
+ - You **MUST** parallelize calls when exploring related files
7
+ - For URLs, `read` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You **SHOULD** reach for `read` — not a browser/puppeteer tool — for fetching and inspecting web content.
8
+
9
+ ## Parameters
10
+ - `path` — file path or URL; may include `:selector` suffix (required)
11
+ - `sel` — optional selector for chunks, line ranges, listing, or raw mode
6
12
  - `timeout` — seconds, for URLs only
7
13
 
14
+ ## Selectors
15
+
16
+ |`sel` value|Behavior|
17
+ |---|---|
18
+ |*(omitted)*|Read full file as chunks (up to {{DEFAULT_LIMIT}} lines)|
19
+ |`class_Foo`|Read a specific chunk|
20
+ |`class_Foo.fn_bar#ABCD~`|Read a chunk region (body `~` / head `^`) by ID|
21
+ |`?`|List all chunk paths with IDs|
22
+ |`L50`|Read from line 50 onward (shorthand for L50 to EOF)|
23
+ |`L50-L120`|Read lines 50 through 120|
24
+ |`L20-L20`|Read exactly one line|
25
+ |`raw`|Raw content without transformations (for URLs: untouched HTML)|
26
+
27
+ Max {{DEFAULT_MAX_LINES}} lines per call.
28
+
29
+ # Chunks
8
30
  Each anchor `@full.chunk.path#CCCC` (with `-` prefixes for nesting depth) in the output identifies a chunk. Use `full.chunk.path#CCCC` as-is to read truncated chunks.
9
- If you need a canonical target list, run `read(path="file", sel="?")`. That listing shows chunk paths with IDs.
31
+ If you need a canonical target list, run `read(path="file", sel="?")`. That listing shows chunk paths with IDs and is the safest structural discovery mode. Summary lines in this listing are orientation hints; follow a selector with `read(path="file", sel="chunk#ID")` or use `raw` when you need exact source.
10
32
  Line numbers in the gutter are absolute file line numbers.
11
33
 
12
- `L20` (single line, no explicit end) is shorthand for `L20` to end-of-file. Use `L20-L20` for a one-line window.
13
-
14
34
  {{#if chunkAutoIndent}}
15
35
  Chunk reads normalize leading indentation so copied content round-trips cleanly into chunk edits.
16
36
  {{else}}
17
37
  Chunk reads preserve literal leading tabs/spaces from the file. When editing, keep the same whitespace characters you see here.
18
38
  {{/if}}
39
+ `raw` shows the file's literal whitespace. Structured chunk views may normalize or display indentation for edit round-tripping, so use `raw` when exact tabs/spaces matter, especially inside markdown fenced code blocks.
40
+
41
+ IDs change after every edit. Use the new IDs from the edit response or refresh with `sel="?"` before the next `write`/`delete`. `insert` selectors may omit IDs, but still prefer fresh paths after structural edits.
42
+
43
+ Parser boundaries vary by language: TypeScript/JavaScript decorators and JSDoc above decorated methods may appear as sibling `chunk#ID` entries, Python decorators are part of the function/class head, Python docstrings are body lines, and Python enum members or nested closures may remain opaque inside their parent chunk. Decorated Python `^` writes and Python `^` deletes are rejected for safety.
44
+ Markdown sections, lists, and tables are structural chunks. Recognized pipe tables expose `row_N` children for row-level edits; list items and table cells are not independently addressable. Fenced code blocks with a declared language are parsed again when possible, so functions inside a markdown fence can appear as addressable nested chunks.
19
45
 
20
46
  Chunk trees: JS, TS, TSX, Python, Rust, Go. Others use blank-line fallback.
47
+ # Inspection
48
+ Extracts text from PDF, Word, PowerPoint, Excel, RTF, EPUB, and Jupyter notebook files. Can inspect images.
49
+
50
+ # Directories & Archives
51
+ Directories and archive roots return a list of entries. Supports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read contents.
21
52
 
22
53
  # SQLite Databases
23
54
  When used against a SQLite database (`.sqlite`, `.sqlite3`, `.db`, `.db3`), returns structured database content.
@@ -27,9 +58,17 @@ When used against a SQLite database (`.sqlite`, `.sqlite3`, `.db`, `.db3`), retu
27
58
  - `file.db:table?limit=50&offset=100` — paginated rows
28
59
  - `file.db:table?where=status='active'&order=created:desc` — filtered rows
29
60
  - `file.db?q=SELECT …` — read-only SELECT query
61
+
62
+ # URLs
63
+ Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use `sel="raw"` for untouched HTML; `timeout` to override the default request timeout. You **SHOULD** prefer `read` over a browser/puppeteer tool for fetching URL content; only use a browser when the page requires JS execution, authentication, or interactive actions (clicks, forms, scrolling).
30
64
  </instruction>
31
65
 
32
66
  <critical>
33
- - **MUST** `read` before editing — never invent chunk names or IDs.
67
+ - You **MUST** `read` before editing — never invent chunk names or IDs.
34
68
  - Chunk names are truncated (e.g., `handleRequest` becomes `fn_handleRequ`). Always copy chunk paths from `read` or `?` output — never construct them from source identifiers.
69
+ - You **MUST** use `read` (never bash `cat`/`head`/`tail`/`less`/`more`/`ls`/`tar`/`unzip`/`curl`/`wget`) for all file, directory, archive, and URL reads.
70
+ - You **MUST NOT** reach for a browser/puppeteer tool to fetch static web content — `read` handles HTML, PDFs, JSON, feeds, and docs directly. Reserve browser tools for JS-heavy pages or interactive flows.
71
+ - You **MUST** always include the `path` parameter; never call with `{}`.
72
+ - For specific line ranges, use `sel`: `read(path="file", sel="L50-L150")` — not `cat -n file | sed`.
73
+ - You **MAY** use `sel` with URL reads; the tool paginates cached fetched output.
35
74
  </critical>
@@ -1,8 +1,9 @@
1
1
  Reads the content at the specified path or URL.
2
2
 
3
3
  <instruction>
4
- The `read` tool is multi-purpose — inspects files, directories, archives, SQLite databases, and URLs.
4
+ The `read` tool is multi-purpose and more capable than it looks — inspects files, directories, archives, SQLite databases, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), **and URLs**.
5
5
  - You **MUST** parallelize reads when exploring related files
6
+ - For URLs, `read` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You **SHOULD** reach for `read` — not a browser/puppeteer tool — for fetching and inspecting web content.
6
7
 
7
8
  ## Parameters
8
9
  - `path` — file path or URL (required)
@@ -14,8 +15,9 @@ The `read` tool is multi-purpose — inspects files, directories, archives, SQLi
14
15
  |`sel` value|Behavior|
15
16
  |---|---|
16
17
  |*(omitted)*|Read full file (up to {{DEFAULT_LIMIT}} lines)|
17
- |`L50`|Read from line 50 onward|
18
+ |`L50`|Read from line 50 onward (shorthand for L50 to EOF)|
18
19
  |`L50-L120`|Read lines 50 through 120|
20
+ |`L20-L20`|Read exactly one line|
19
21
  |`raw`|Raw content without transformations (for URLs: untouched HTML)|
20
22
 
21
23
  Max {{DEFAULT_MAX_LINES}} lines per call.
@@ -45,11 +47,12 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
45
47
  - `file.db?q=SELECT …` — read-only SELECT query
46
48
 
47
49
  # URLs
48
- Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, and similar text-based resources. Use `sel="raw"` for untouched HTML; `timeout` to override the default request timeout.
50
+ Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use `sel="raw"` for untouched HTML; `timeout` to override the default request timeout. You **SHOULD** prefer `read` over a browser/puppeteer tool for fetching URL content; only use a browser when the page requires JS execution, authentication, or interactive actions (clicks, forms, scrolling).
49
51
  </instruction>
50
52
 
51
53
  <critical>
52
- - You **MUST** use `read` (never bash `cat`/`head`/`tail`/`less`/`more`/`ls`/`tar`/`unzip`) for all file, directory, and archive reads.
54
+ - You **MUST** use `read` (never bash `cat`/`head`/`tail`/`less`/`more`/`ls`/`tar`/`unzip`/`curl`/`wget`) for all file, directory, archive, and URL reads.
55
+ - You **MUST NOT** reach for a browser/puppeteer tool to fetch static web content — `read` handles HTML, PDFs, JSON, feeds, and docs directly. Reserve browser tools for JS-heavy pages or interactive flows.
53
56
  - You **MUST** always include the `path` parameter; never call with `{}`.
54
57
  - For specific line ranges, use `sel`: `read(path="file", sel="L50-L150")` — not `cat -n file | sed`.
55
58
  - You **MAY** use `sel` with URL reads; the tool paginates cached fetched output.
package/src/sdk.ts CHANGED
@@ -194,6 +194,8 @@ export interface CreateAgentSessionOptions {
194
194
 
195
195
  /** Enable MCP server discovery from .mcp.json files. Default: true */
196
196
  enableMCP?: boolean;
197
+ /** Existing MCP manager to reuse (skips discovery, propagates to toolSession). */
198
+ mcpManager?: MCPManager;
197
199
 
198
200
  /** Enable LSP integration (tool, formatting, diagnostics, warmup). Default: true */
199
201
  enableLsp?: boolean;
@@ -1005,10 +1007,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1005
1007
  const builtinTools = await logger.time("createAllTools", createTools, toolSession, options.toolNames);
1006
1008
 
1007
1009
  // Discover MCP tools from .mcp.json files
1008
- let mcpManager: MCPManager | undefined;
1010
+ let mcpManager: MCPManager | undefined = options.mcpManager;
1009
1011
  const enableMCP = options.enableMCP ?? true;
1010
1012
  const customTools: CustomTool[] = [];
1011
- if (enableMCP) {
1013
+ if (enableMCP && !mcpManager) {
1012
1014
  const mcpResult = await logger.time("discoverAndLoadMCPTools", discoverAndLoadMCPTools, cwd, {
1013
1015
  onConnecting: serverNames => {
1014
1016
  if (options.hasUI && serverNames.length > 0) {
@@ -1024,7 +1026,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1024
1026
  authStorage,
1025
1027
  });
1026
1028
  mcpManager = mcpResult.manager;
1027
- toolSession.mcpManager = mcpManager;
1028
1029
 
1029
1030
  if (settings.get("mcp.notifications")) {
1030
1031
  mcpManager.setNotificationsEnabled(true);
@@ -1044,6 +1045,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1044
1045
  customTools.push(...mcpResult.tools.map(loaded => loaded.tool));
1045
1046
  }
1046
1047
  }
1048
+ toolSession.mcpManager = mcpManager;
1047
1049
 
1048
1050
  // Add Gemini image tools if GEMINI_API_KEY (or GOOGLE_API_KEY) is available
1049
1051
  const geminiImageTools = await logger.time("getGeminiImageTools", getGeminiImageTools);
@@ -1663,8 +1665,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1663
1665
  }),
1664
1666
  );
1665
1667
 
1666
- // Wire MCP manager callbacks to session for reactive tool updates
1667
- if (mcpManager) {
1668
+ // Wire MCP manager callbacks to session for reactive tool updates.
1669
+ // Skip when reusing a parent's manager — the parent owns the callbacks.
1670
+ if (mcpManager && !options.mcpManager) {
1668
1671
  mcpManager.setOnToolsChanged(tools => {
1669
1672
  void session.refreshMCPTools(tools);
1670
1673
  });
@@ -1179,6 +1179,29 @@ export class AgentSession {
1179
1179
  );
1180
1180
  }
1181
1181
 
1182
+ #scheduleAutoContinuePrompt(generation: number): void {
1183
+ const continuePrompt = async () => {
1184
+ await this.#promptWithMessage(
1185
+ {
1186
+ role: "developer",
1187
+ content: [{ type: "text", text: "Continue if you have next steps." }],
1188
+ attribution: "agent",
1189
+ timestamp: Date.now(),
1190
+ },
1191
+ "Continue if you have next steps.",
1192
+ { skipPostPromptRecoveryWait: true },
1193
+ );
1194
+ };
1195
+ this.#schedulePostPromptTask(
1196
+ async signal => {
1197
+ await Promise.resolve();
1198
+ if (signal.aborted) return;
1199
+ await continuePrompt();
1200
+ },
1201
+ { generation },
1202
+ );
1203
+ }
1204
+
1182
1205
  #cancelPostPromptTasks(): void {
1183
1206
  this.#postPromptTasksAbortController.abort();
1184
1207
  this.#postPromptTasksAbortController = new AbortController();
@@ -4813,6 +4836,9 @@ export class AgentSession {
4813
4836
  aborted: false,
4814
4837
  willRetry: false,
4815
4838
  });
4839
+ if (!autoCompactionSignal.aborted && reason !== "idle" && compactionSettings.autoContinue !== false) {
4840
+ this.#scheduleAutoContinuePrompt(generation);
4841
+ }
4816
4842
  return;
4817
4843
  }
4818
4844
  }
@@ -5064,26 +5090,7 @@ export class AgentSession {
5064
5090
  await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
5065
5091
 
5066
5092
  if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
5067
- const continuePrompt = async () => {
5068
- await this.#promptWithMessage(
5069
- {
5070
- role: "developer",
5071
- content: [{ type: "text", text: "Continue if you have next steps." }],
5072
- attribution: "agent",
5073
- timestamp: Date.now(),
5074
- },
5075
- "Continue if you have next steps.",
5076
- { skipPostPromptRecoveryWait: true },
5077
- );
5078
- };
5079
- this.#schedulePostPromptTask(
5080
- async signal => {
5081
- await Promise.resolve();
5082
- if (signal.aborted) return;
5083
- await continuePrompt();
5084
- },
5085
- { generation },
5086
- );
5093
+ this.#scheduleAutoContinuePrompt(generation);
5087
5094
  }
5088
5095
 
5089
5096
  if (willRetry) {
@@ -5604,6 +5611,14 @@ export class AgentSession {
5604
5611
  // Bash Execution
5605
5612
  // =========================================================================
5606
5613
 
5614
+ async #saveBashOriginalArtifact(originalText: string): Promise<string | undefined> {
5615
+ try {
5616
+ return await this.sessionManager.saveArtifact(originalText, "bash-original");
5617
+ } catch {
5618
+ return undefined;
5619
+ }
5620
+ }
5621
+
5607
5622
  /**
5608
5623
  * Execute a bash command.
5609
5624
  * Adds result to agent context and session.
@@ -5640,6 +5655,7 @@ export class AgentSession {
5640
5655
  signal: this.#bashAbortController.signal,
5641
5656
  sessionKey: this.sessionId,
5642
5657
  timeout: clampTimeout("bash") * 1000,
5658
+ onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
5643
5659
  });
5644
5660
 
5645
5661
  this.recordBashResult(command, result, options);
@@ -1264,74 +1264,245 @@ function extractTextFromContent(content: Message["content"]): string {
1264
1264
  .join(" ");
1265
1265
  }
1266
1266
 
1267
- async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise<SessionInfo[]> {
1268
- const sessions: SessionInfo[] = [];
1267
+ const SESSION_LIST_PREFIX_BYTES = 1024;
1268
+ const SESSION_LIST_PARALLEL_THRESHOLD = 64;
1269
+ const SESSION_LIST_MAX_WORKERS = 16;
1270
+ const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
1269
1271
 
1270
- // Collect session info for all files in parallel
1271
- await Promise.all(
1272
- files.map(async file => {
1273
- try {
1274
- const content = await storage.readText(file);
1275
- const entries = parseJsonlLenient<Record<string, unknown>>(content);
1276
- if (entries.length === 0) return;
1277
-
1278
- // Check first entry for valid session header
1279
- type SessionHeaderShape = {
1280
- type: string;
1281
- id: string;
1282
- cwd?: string;
1283
- title?: string;
1284
- titleSource?: "auto" | "user";
1285
- timestamp: string;
1286
- };
1287
- const header = entries[0] as SessionHeaderShape;
1288
- if (header.type !== "session" || !header.id) return;
1272
+ async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise<string> {
1273
+ if (!(storage instanceof FileSessionStorage)) {
1274
+ return storage.readTextPrefix(file, buffer.byteLength);
1275
+ }
1276
+
1277
+ const handle = await fs.promises.open(file, "r");
1278
+ try {
1279
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
1280
+ return sessionListPrefixDecoder.decode(buffer.subarray(0, bytesRead));
1281
+ } finally {
1282
+ await handle.close();
1283
+ }
1284
+ }
1285
+
1286
+ function decodeJsonStringFragment(value: string): string {
1287
+ const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
1288
+ try {
1289
+ return JSON.parse(`"${safeValue}"`) as string;
1290
+ } catch {
1291
+ return safeValue
1292
+ .replace(/\\n/g, "\n")
1293
+ .replace(/\\r/g, "\r")
1294
+ .replace(/\\t/g, "\t")
1295
+ .replace(/\\"/g, '"')
1296
+ .replace(/\\\\/g, "\\");
1297
+ }
1298
+ }
1289
1299
 
1290
- let messageCount = 0;
1291
- let firstMessage = "";
1292
- const allMessages: string[] = [];
1293
- let shortSummary: string | undefined;
1300
+ function extractStringProperty(source: string, name: string, startIndex = 0): string | undefined {
1301
+ const propertyIndex = source.indexOf(`"${name}"`, startIndex);
1302
+ if (propertyIndex === -1) return undefined;
1294
1303
 
1295
- for (let i = 1; i < entries.length; i++) {
1296
- const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string };
1304
+ const colonIndex = source.indexOf(":", propertyIndex + name.length + 2);
1305
+ if (colonIndex === -1) return undefined;
1297
1306
 
1298
- if (entry.type === "compaction" && typeof entry.shortSummary === "string") {
1299
- shortSummary = entry.shortSummary;
1300
- }
1307
+ let valueIndex = colonIndex + 1;
1308
+ while (valueIndex < source.length) {
1309
+ const char = source.charCodeAt(valueIndex);
1310
+ if (char !== 32 && char !== 9 && char !== 10 && char !== 13) break;
1311
+ valueIndex++;
1312
+ }
1313
+ if (source.charCodeAt(valueIndex) !== 34) return undefined;
1314
+
1315
+ const valueStart = valueIndex + 1;
1316
+ let escaped = false;
1317
+ for (let i = valueStart; i < source.length; i++) {
1318
+ const char = source.charCodeAt(i);
1319
+ if (escaped) {
1320
+ escaped = false;
1321
+ continue;
1322
+ }
1323
+ if (char === 92) {
1324
+ escaped = true;
1325
+ continue;
1326
+ }
1327
+ if (char === 34) {
1328
+ return decodeJsonStringFragment(source.slice(valueStart, i));
1329
+ }
1330
+ }
1331
+
1332
+ return decodeJsonStringFragment(source.slice(valueStart));
1333
+ }
1334
+
1335
+ function countMessageMarkers(content: string): number {
1336
+ let count = 0;
1337
+ let index = 0;
1338
+ while (index < content.length) {
1339
+ const typeIndex = content.indexOf('"type"', index);
1340
+ if (typeIndex === -1) break;
1341
+ const colonIndex = content.indexOf(":", typeIndex + 6);
1342
+ if (colonIndex === -1) break;
1343
+ const type = extractStringProperty(content, "type", typeIndex);
1344
+ if (type === "message") count++;
1345
+ index = colonIndex + 1;
1346
+ }
1347
+ return count;
1348
+ }
1349
+
1350
+ function extractFirstUserMessageFromPrefix(content: string): string | undefined {
1351
+ const roleIndex = content.indexOf('"role"');
1352
+ if (roleIndex === -1) return undefined;
1353
+
1354
+ let index = roleIndex;
1355
+ while (index !== -1) {
1356
+ const role = extractStringProperty(content, "role", index);
1357
+ if (role === "user") {
1358
+ return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
1359
+ }
1360
+ index = content.indexOf('"role"', index + 6);
1361
+ }
1362
+
1363
+ return undefined;
1364
+ }
1365
+
1366
+ interface SessionListHeader {
1367
+ type: "session";
1368
+ id: string;
1369
+ cwd?: string;
1370
+ title?: string;
1371
+ parentSession?: string;
1372
+ timestamp?: string;
1373
+ }
1301
1374
 
1302
- if (entry.type === "message" && entry.message) {
1303
- messageCount++;
1375
+ function parseSessionListHeader(
1376
+ content: string,
1377
+ entries: Array<Record<string, unknown>>,
1378
+ ): SessionListHeader | undefined {
1379
+ const parsedHeader = entries[0];
1380
+ if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
1381
+ return {
1382
+ type: "session",
1383
+ id: parsedHeader.id,
1384
+ cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
1385
+ title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
1386
+ parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
1387
+ timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
1388
+ };
1389
+ }
1390
+
1391
+ const firstLineEnd = content.indexOf("\n");
1392
+ const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd);
1393
+ if (extractStringProperty(firstLine, "type") !== "session") return undefined;
1394
+
1395
+ const id = extractStringProperty(firstLine, "id");
1396
+ if (!id) return undefined;
1397
+
1398
+ return {
1399
+ type: "session",
1400
+ id,
1401
+ cwd: extractStringProperty(firstLine, "cwd"),
1402
+ title: extractStringProperty(firstLine, "title"),
1403
+ parentSession: extractStringProperty(firstLine, "parentSession"),
1404
+ timestamp: extractStringProperty(firstLine, "timestamp"),
1405
+ };
1406
+ }
1407
+
1408
+ function getSessionListWorkerCount(fileCount: number): number {
1409
+ if (fileCount <= SESSION_LIST_PARALLEL_THRESHOLD) return 1;
1410
+ return Math.min(
1411
+ SESSION_LIST_MAX_WORKERS,
1412
+ os.availableParallelism(),
1413
+ Math.ceil(fileCount / SESSION_LIST_PARALLEL_THRESHOLD),
1414
+ );
1415
+ }
1416
+
1417
+ async function collectSessionFromFile(
1418
+ file: string,
1419
+ storage: SessionStorage,
1420
+ buffer: Buffer,
1421
+ ): Promise<SessionInfo | undefined> {
1422
+ try {
1423
+ const content = await readSessionListPrefix(file, storage, buffer);
1424
+ const entries = parseJsonlLenient<Record<string, unknown>>(content);
1425
+ const header = parseSessionListHeader(content, entries);
1426
+ if (!header) return undefined;
1427
+
1428
+ let parsedMessageCount = 0;
1429
+ let firstMessage = "";
1430
+ const allMessages: string[] = [];
1431
+ let shortSummary: string | undefined;
1432
+
1433
+ for (let i = 1; i < entries.length; i++) {
1434
+ const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string };
1435
+
1436
+ if (entry.type === "compaction" && typeof entry.shortSummary === "string") {
1437
+ shortSummary = entry.shortSummary;
1438
+ }
1304
1439
 
1305
- if (entry.message.role === "user" || entry.message.role === "assistant") {
1306
- const textContent = extractTextFromContent(entry.message.content);
1440
+ if (entry.type === "message" && entry.message) {
1441
+ parsedMessageCount++;
1307
1442
 
1308
- if (textContent) {
1309
- allMessages.push(textContent);
1443
+ if (entry.message.role === "user" || entry.message.role === "assistant") {
1444
+ const textContent = extractTextFromContent(entry.message.content);
1310
1445
 
1311
- if (!firstMessage && entry.message.role === "user") {
1312
- firstMessage = textContent;
1313
- }
1314
- }
1446
+ if (textContent) {
1447
+ allMessages.push(textContent);
1448
+
1449
+ if (!firstMessage && entry.message.role === "user") {
1450
+ firstMessage = textContent;
1315
1451
  }
1316
1452
  }
1317
1453
  }
1454
+ }
1455
+ }
1318
1456
 
1319
- const stats = storage.statSync(file);
1320
- sessions.push({
1321
- path: file,
1322
- id: header.id,
1323
- cwd: typeof header.cwd === "string" ? header.cwd : "",
1324
- title: header.title ?? shortSummary,
1325
- parentSessionPath: (header as SessionHeader).parentSession,
1326
- created: new Date(header.timestamp),
1327
- modified: stats.mtime,
1328
- messageCount,
1329
- firstMessage: firstMessage || "(no messages)",
1330
- allMessagesText: allMessages.join(" "),
1331
- });
1332
- } catch {}
1333
- }),
1334
- );
1457
+ firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
1458
+ const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
1459
+ const stats = storage.statSync(file);
1460
+ return {
1461
+ path: file,
1462
+ id: header.id,
1463
+ cwd: header.cwd ?? "",
1464
+ title: header.title ?? shortSummary,
1465
+ parentSessionPath: header.parentSession,
1466
+ created: new Date(header.timestamp ?? ""),
1467
+ modified: stats.mtime,
1468
+ messageCount,
1469
+ firstMessage: firstMessage || "(no messages)",
1470
+ allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
1471
+ };
1472
+ } catch {
1473
+ return undefined;
1474
+ }
1475
+ }
1476
+
1477
+ async function collectSessionsFromFileStride(
1478
+ files: string[],
1479
+ storage: SessionStorage,
1480
+ startIndex: number,
1481
+ stride: number,
1482
+ ): Promise<SessionInfo[]> {
1483
+ const sessions: SessionInfo[] = [];
1484
+ const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
1485
+
1486
+ for (let i = startIndex; i < files.length; i += stride) {
1487
+ const session = await collectSessionFromFile(files[i], storage, buffer);
1488
+ if (session) sessions.push(session);
1489
+ }
1490
+
1491
+ return sessions;
1492
+ }
1493
+
1494
+ async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise<SessionInfo[]> {
1495
+ const workerCount = getSessionListWorkerCount(files.length);
1496
+ const sessions =
1497
+ workerCount === 1
1498
+ ? await collectSessionsFromFileStride(files, storage, 0, 1)
1499
+ : (
1500
+ await Promise.all(
1501
+ Array.from({ length: workerCount }, (_, workerIndex) =>
1502
+ collectSessionsFromFileStride(files, storage, workerIndex, workerCount),
1503
+ ),
1504
+ )
1505
+ ).flat();
1335
1506
 
1336
1507
  sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1337
1508
  return sessions;
@@ -2771,7 +2942,7 @@ export class SessionManager {
2771
2942
  static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
2772
2943
  const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
2773
2944
  try {
2774
- const files = Array.from(new Bun.Glob("**/*.jsonl").scanSync(sessionsRoot)).map(name =>
2945
+ const files = await Array.fromAsync(new Bun.Glob("*/*.jsonl").scan(sessionsRoot), name =>
2775
2946
  path.join(sessionsRoot, name),
2776
2947
  );
2777
2948
  return await collectSessionsFromFiles(files, storage);
@@ -680,6 +680,17 @@ export class OutputSink {
680
680
  });
681
681
  }
682
682
 
683
+ /**
684
+ * Replace the in-memory buffer with the given text while preserving the
685
+ * streaming counters (totalLines/totalBytes reflect the raw chunks that
686
+ * already reached the sink). Used when an upstream minimizer rewrites the
687
+ * captured output after the raw bytes have already been streamed.
688
+ */
689
+ replace(text: string): void {
690
+ this.#buffer = text;
691
+ this.#bufferBytes = Buffer.byteLength(text, "utf-8");
692
+ }
693
+
683
694
  async dump(notice?: string): Promise<OutputSummary> {
684
695
  const noticeLine = notice ? `[${notice}]\n` : "";
685
696
  const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0;
@@ -275,13 +275,18 @@ async function getCachedGpu(): Promise<string | undefined> {
275
275
  }
276
276
  async function getEnvironmentInfo(): Promise<Array<{ label: string; value: string }>> {
277
277
  const gpu = await getCachedGpu();
278
- const cpus = os.cpus();
278
+ let cpuModel: string | undefined;
279
+ try {
280
+ cpuModel = os.cpus()[0]?.model;
281
+ } catch {
282
+ cpuModel = undefined;
283
+ }
279
284
  const entries: Array<{ label: string; value: string | undefined }> = [
280
285
  { label: "OS", value: `${os.platform()} ${os.release()}` },
281
286
  { label: "Distro", value: os.type() },
282
287
  { label: "Kernel", value: os.version() },
283
288
  { label: "Arch", value: os.arch() },
284
- { label: "CPU", value: `${cpus[0]?.model}` },
289
+ { label: "CPU", value: cpuModel },
285
290
  { label: "GPU", value: gpu },
286
291
  { label: "Terminal", value: getTerminalName() },
287
292
  ];
@@ -975,6 +975,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
975
975
  enableLsp: lspEnabled,
976
976
  skipPythonPreflight,
977
977
  enableMCP,
978
+ mcpManager: options.mcpManager,
978
979
  customTools: mcpProxyTools.length > 0 ? mcpProxyTools : undefined,
979
980
  });
980
981