@oh-my-pi/pi-coding-agent 14.2.1 → 14.4.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 (137) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/package.json +19 -19
  3. package/src/autoresearch/prompt.md +1 -1
  4. package/src/cli/args.ts +10 -1
  5. package/src/cli/shell-cli.ts +15 -3
  6. package/src/commit/agentic/prompts/analyze-file.md +1 -1
  7. package/src/config/model-registry.ts +67 -15
  8. package/src/config/prompt-templates.ts +5 -5
  9. package/src/config/settings-schema.ts +63 -4
  10. package/src/cursor.ts +3 -8
  11. package/src/debug/system-info.ts +6 -2
  12. package/src/discovery/claude.ts +58 -36
  13. package/src/discovery/helpers.ts +3 -3
  14. package/src/discovery/opencode.ts +20 -2
  15. package/src/edit/diff.ts +50 -47
  16. package/src/edit/index.ts +87 -57
  17. package/src/edit/line-hash.ts +735 -19
  18. package/src/edit/modes/apply-patch.ts +0 -9
  19. package/src/edit/modes/atom.ts +658 -0
  20. package/src/edit/modes/chunk.ts +144 -78
  21. package/src/edit/modes/hashline.ts +223 -146
  22. package/src/edit/modes/patch.ts +5 -9
  23. package/src/edit/modes/replace.ts +6 -11
  24. package/src/edit/renderer.ts +112 -143
  25. package/src/edit/streaming.ts +385 -0
  26. package/src/exec/bash-executor.ts +58 -5
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +4 -12
  29. package/src/extensibility/custom-tools/types.ts +2 -0
  30. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  31. package/src/internal-urls/docs-index.generated.ts +7 -7
  32. package/src/internal-urls/pi-protocol.ts +0 -2
  33. package/src/lsp/client.ts +8 -1
  34. package/src/lsp/defaults.json +2 -1
  35. package/src/lsp/index.ts +1 -1
  36. package/src/mcp/render.ts +1 -8
  37. package/src/modes/acp/acp-agent.ts +76 -2
  38. package/src/modes/components/assistant-message.ts +5 -34
  39. package/src/modes/components/diff.ts +23 -14
  40. package/src/modes/components/footer.ts +21 -16
  41. package/src/modes/components/hook-editor.ts +1 -1
  42. package/src/modes/components/settings-defs.ts +6 -1
  43. package/src/modes/components/todo-reminder.ts +1 -8
  44. package/src/modes/components/tool-execution.ts +112 -105
  45. package/src/modes/controllers/input-controller.ts +1 -1
  46. package/src/modes/controllers/selector-controller.ts +1 -1
  47. package/src/modes/interactive-mode.ts +0 -2
  48. package/src/modes/print-mode.ts +8 -0
  49. package/src/modes/theme/mermaid-cache.ts +13 -52
  50. package/src/modes/theme/theme.ts +2 -2
  51. package/src/prompts/agents/librarian.md +1 -1
  52. package/src/prompts/agents/reviewer.md +4 -4
  53. package/src/prompts/ci-green-request.md +1 -1
  54. package/src/prompts/review-request.md +1 -1
  55. package/src/prompts/system/subagent-system-prompt.md +3 -3
  56. package/src/prompts/system/subagent-yield-reminder.md +11 -0
  57. package/src/prompts/system/system-prompt.md +4 -1
  58. package/src/prompts/tools/ask.md +3 -2
  59. package/src/prompts/tools/ast-edit.md +15 -19
  60. package/src/prompts/tools/ast-grep.md +18 -24
  61. package/src/prompts/tools/atom.md +96 -0
  62. package/src/prompts/tools/browser.md +1 -0
  63. package/src/prompts/tools/chunk-edit.md +58 -179
  64. package/src/prompts/tools/debug.md +4 -5
  65. package/src/prompts/tools/exit-plan-mode.md +4 -5
  66. package/src/prompts/tools/find.md +4 -8
  67. package/src/prompts/tools/github.md +18 -0
  68. package/src/prompts/tools/grep.md +8 -8
  69. package/src/prompts/tools/hashline.md +22 -89
  70. package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
  71. package/src/prompts/tools/inspect-image.md +6 -6
  72. package/src/prompts/tools/lsp.md +6 -0
  73. package/src/prompts/tools/patch.md +12 -19
  74. package/src/prompts/tools/python.md +3 -2
  75. package/src/prompts/tools/read-chunk.md +46 -8
  76. package/src/prompts/tools/read.md +9 -6
  77. package/src/prompts/tools/ssh.md +8 -17
  78. package/src/prompts/tools/todo-write.md +54 -41
  79. package/src/sdk.ts +22 -14
  80. package/src/session/agent-session.ts +61 -22
  81. package/src/session/session-manager.ts +228 -57
  82. package/src/session/streaming-output.ts +11 -0
  83. package/src/system-prompt.ts +7 -2
  84. package/src/task/executor.ts +44 -48
  85. package/src/task/render.ts +11 -13
  86. package/src/tools/ask.ts +7 -7
  87. package/src/tools/ast-edit.ts +45 -41
  88. package/src/tools/ast-grep.ts +77 -85
  89. package/src/tools/bash.ts +21 -9
  90. package/src/tools/browser.ts +32 -30
  91. package/src/tools/calculator.ts +4 -4
  92. package/src/tools/cancel-job.ts +1 -1
  93. package/src/tools/checkpoint.ts +2 -2
  94. package/src/tools/debug.ts +41 -37
  95. package/src/tools/exit-plan-mode.ts +1 -1
  96. package/src/tools/find.ts +4 -4
  97. package/src/tools/gh-renderer.ts +12 -4
  98. package/src/tools/gh.ts +514 -712
  99. package/src/tools/grep.ts +115 -130
  100. package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
  101. package/src/tools/index.ts +14 -32
  102. package/src/tools/inspect-image.ts +3 -3
  103. package/src/tools/json-tree.ts +114 -114
  104. package/src/tools/match-line-format.ts +9 -8
  105. package/src/tools/notebook.ts +8 -7
  106. package/src/tools/poll-tool.ts +2 -1
  107. package/src/tools/python.ts +9 -23
  108. package/src/tools/read.ts +32 -21
  109. package/src/tools/render-mermaid.ts +1 -1
  110. package/src/tools/render-utils.ts +18 -0
  111. package/src/tools/renderers.ts +2 -2
  112. package/src/tools/report-tool-issue.ts +3 -2
  113. package/src/tools/resolve.ts +1 -1
  114. package/src/tools/review.ts +12 -10
  115. package/src/tools/search-tool-bm25.ts +2 -4
  116. package/src/tools/sqlite-reader.ts +116 -3
  117. package/src/tools/ssh.ts +4 -4
  118. package/src/tools/todo-write.ts +172 -147
  119. package/src/tools/vim.ts +14 -15
  120. package/src/tools/write.ts +4 -4
  121. package/src/tools/{submit-result.ts → yield.ts} +11 -13
  122. package/src/utils/edit-mode.ts +2 -1
  123. package/src/utils/file-display-mode.ts +10 -5
  124. package/src/utils/git.ts +9 -5
  125. package/src/utils/shell-snapshot.ts +2 -3
  126. package/src/vim/render.ts +4 -4
  127. package/src/web/search/providers/codex.ts +129 -6
  128. package/src/prompts/system/subagent-submit-reminder.md +0 -11
  129. package/src/prompts/tools/gh-issue-view.md +0 -11
  130. package/src/prompts/tools/gh-pr-checkout.md +0 -12
  131. package/src/prompts/tools/gh-pr-diff.md +0 -12
  132. package/src/prompts/tools/gh-pr-push.md +0 -11
  133. package/src/prompts/tools/gh-pr-view.md +0 -11
  134. package/src/prompts/tools/gh-repo-view.md +0 -11
  135. package/src/prompts/tools/gh-run-watch.md +0 -12
  136. package/src/prompts/tools/gh-search-issues.md +0 -11
  137. package/src/prompts/tools/gh-search-prs.md +0 -11
package/src/sdk.ts CHANGED
@@ -128,7 +128,7 @@ import {
128
128
  warmupLspServers,
129
129
  } from "./tools";
130
130
  import { ToolContextStore } from "./tools/context";
131
- import { getGeminiImageTools } from "./tools/gemini-image";
131
+ import { getImageGenTools } from "./tools/image-gen";
132
132
  import { wrapToolWithMetaNotice } from "./tools/output-meta";
133
133
  import { queueResolveHandler } from "./tools/resolve";
134
134
  import { EventBus } from "./utils/event-bus";
@@ -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;
@@ -207,8 +209,8 @@ export interface CreateAgentSessionOptions {
207
209
 
208
210
  /** Output schema for structured completion (subagents) */
209
211
  outputSchema?: unknown;
210
- /** Whether to include the submit_result tool by default */
211
- requireSubmitResultTool?: boolean;
212
+ /** Whether to include the yield tool by default */
213
+ requireYieldTool?: boolean;
212
214
  /** Task recursion depth (for subagent sessions). Default: 0 */
213
215
  taskDepth?: number;
214
216
  /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
@@ -671,7 +673,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
671
673
  }
672
674
 
673
675
  const imageProvider = settings.get("providers.image");
674
- if (imageProvider === "auto" || imageProvider === "gemini" || imageProvider === "openrouter") {
676
+ if (
677
+ imageProvider === "auto" ||
678
+ imageProvider === "openai" ||
679
+ imageProvider === "gemini" ||
680
+ imageProvider === "openrouter"
681
+ ) {
675
682
  setPreferredImageProvider(imageProvider);
676
683
  }
677
684
 
@@ -914,7 +921,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
914
921
  skills,
915
922
  eventBus,
916
923
  outputSchema: options.outputSchema,
917
- requireSubmitResultTool: options.requireSubmitResultTool,
924
+ requireYieldTool: options.requireYieldTool,
918
925
  taskDepth: options.taskDepth ?? 0,
919
926
  getSessionFile: () => sessionManager.getSessionFile() ?? null,
920
927
  getPythonKernelOwnerId: () => pythonKernelOwnerId,
@@ -1005,10 +1012,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1005
1012
  const builtinTools = await logger.time("createAllTools", createTools, toolSession, options.toolNames);
1006
1013
 
1007
1014
  // Discover MCP tools from .mcp.json files
1008
- let mcpManager: MCPManager | undefined;
1015
+ let mcpManager: MCPManager | undefined = options.mcpManager;
1009
1016
  const enableMCP = options.enableMCP ?? true;
1010
1017
  const customTools: CustomTool[] = [];
1011
- if (enableMCP) {
1018
+ if (enableMCP && !mcpManager) {
1012
1019
  const mcpResult = await logger.time("discoverAndLoadMCPTools", discoverAndLoadMCPTools, cwd, {
1013
1020
  onConnecting: serverNames => {
1014
1021
  if (options.hasUI && serverNames.length > 0) {
@@ -1024,7 +1031,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1024
1031
  authStorage,
1025
1032
  });
1026
1033
  mcpManager = mcpResult.manager;
1027
- toolSession.mcpManager = mcpManager;
1028
1034
 
1029
1035
  if (settings.get("mcp.notifications")) {
1030
1036
  mcpManager.setNotificationsEnabled(true);
@@ -1044,11 +1050,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1044
1050
  customTools.push(...mcpResult.tools.map(loaded => loaded.tool));
1045
1051
  }
1046
1052
  }
1053
+ toolSession.mcpManager = mcpManager;
1047
1054
 
1048
- // Add Gemini image tools if GEMINI_API_KEY (or GOOGLE_API_KEY) is available
1049
- const geminiImageTools = await logger.time("getGeminiImageTools", getGeminiImageTools);
1050
- if (geminiImageTools.length > 0) {
1051
- customTools.push(...(geminiImageTools as unknown as CustomTool[]));
1055
+ // Add image tools when the active model or configured image providers can generate images.
1056
+ const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
1057
+ if (imageGenTools.length > 0) {
1058
+ customTools.push(...(imageGenTools as unknown as CustomTool[]));
1052
1059
  }
1053
1060
 
1054
1061
  // Add web search tools
@@ -1663,8 +1670,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1663
1670
  }),
1664
1671
  );
1665
1672
 
1666
- // Wire MCP manager callbacks to session for reactive tool updates
1667
- if (mcpManager) {
1673
+ // Wire MCP manager callbacks to session for reactive tool updates.
1674
+ // Skip when reusing a parent's manager — the parent owns the callbacks.
1675
+ if (mcpManager && !options.mcpManager) {
1668
1676
  mcpManager.setOnToolsChanged(tools => {
1669
1677
  void session.refreshMCPTools(tools);
1670
1678
  });
@@ -525,6 +525,7 @@ export class AgentSession {
525
525
  #obfuscator: SecretObfuscator | undefined;
526
526
  #checkpointState: CheckpointState | undefined = undefined;
527
527
  #pendingRewindReport: string | undefined = undefined;
528
+ #lastSuccessfulYieldToolCallId: string | undefined = undefined;
528
529
  #promptGeneration = 0;
529
530
  #providerSessionState = new Map<string, ProviderSessionState>();
530
531
 
@@ -789,6 +790,9 @@ export class AgentSession {
789
790
  this.#toolChoiceQueue.resolve();
790
791
  }
791
792
  }
793
+ if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
794
+ this.#lastSuccessfulYieldToolCallId = event.toolCallId;
795
+ }
792
796
  if (event.type === "turn_end" && this.#pendingRewindReport) {
793
797
  const report = this.#pendingRewindReport;
794
798
  this.#pendingRewindReport = undefined;
@@ -1026,7 +1030,10 @@ export class AgentSession {
1026
1030
  .find((message): message is AssistantMessage => message.role === "assistant");
1027
1031
  const msg = this.#lastAssistantMessage ?? fallbackAssistant;
1028
1032
  this.#lastAssistantMessage = undefined;
1029
- if (!msg) return;
1033
+ if (!msg) {
1034
+ this.#lastSuccessfulYieldToolCallId = undefined;
1035
+ return;
1036
+ }
1030
1037
 
1031
1038
  // Invalidate GitHub Copilot credentials on auth failure so stale tokens
1032
1039
  // aren't reused on the next request
@@ -1040,9 +1047,16 @@ export class AgentSession {
1040
1047
 
1041
1048
  if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
1042
1049
  this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
1050
+ this.#lastSuccessfulYieldToolCallId = undefined;
1043
1051
  return;
1044
1052
  }
1045
1053
 
1054
+ if (this.#assistantEndedWithSuccessfulYield(msg)) {
1055
+ this.#lastSuccessfulYieldToolCallId = undefined;
1056
+ return;
1057
+ }
1058
+ this.#lastSuccessfulYieldToolCallId = undefined;
1059
+
1046
1060
  // Check for retryable errors first (overloaded, rate limit, server errors)
1047
1061
  if (this.#isRetryableError(msg)) {
1048
1062
  const didRetry = await this.#handleRetryableError(msg);
@@ -1179,6 +1193,29 @@ export class AgentSession {
1179
1193
  );
1180
1194
  }
1181
1195
 
1196
+ #scheduleAutoContinuePrompt(generation: number): void {
1197
+ const continuePrompt = async () => {
1198
+ await this.#promptWithMessage(
1199
+ {
1200
+ role: "developer",
1201
+ content: [{ type: "text", text: "Continue if you have next steps." }],
1202
+ attribution: "agent",
1203
+ timestamp: Date.now(),
1204
+ },
1205
+ "Continue if you have next steps.",
1206
+ { skipPostPromptRecoveryWait: true },
1207
+ );
1208
+ };
1209
+ this.#schedulePostPromptTask(
1210
+ async signal => {
1211
+ await Promise.resolve();
1212
+ if (signal.aborted) return;
1213
+ await continuePrompt();
1214
+ },
1215
+ { generation },
1216
+ );
1217
+ }
1218
+
1182
1219
  #cancelPostPromptTasks(): void {
1183
1220
  this.#postPromptTasksAbortController.abort();
1184
1221
  this.#postPromptTasksAbortController = new AbortController();
@@ -3204,7 +3241,6 @@ export class AgentSession {
3204
3241
  id: task.id,
3205
3242
  content: task.content,
3206
3243
  status: task.status,
3207
- notes: task.notes,
3208
3244
  })),
3209
3245
  }));
3210
3246
  }
@@ -4207,6 +4243,16 @@ export class AgentSession {
4207
4243
  }
4208
4244
  }
4209
4245
  }
4246
+ #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
4247
+ const toolCallId = this.#lastSuccessfulYieldToolCallId;
4248
+ if (!toolCallId) return false;
4249
+ const lastToolCall = assistantMessage.content
4250
+ .slice()
4251
+ .reverse()
4252
+ .find((content): content is ToolCall => content.type === "toolCall");
4253
+ return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
4254
+ }
4255
+
4210
4256
  #enforceRewindBeforeYield(): boolean {
4211
4257
  if (!this.#checkpointState || this.#pendingRewindReport) {
4212
4258
  return false;
@@ -4813,6 +4859,9 @@ export class AgentSession {
4813
4859
  aborted: false,
4814
4860
  willRetry: false,
4815
4861
  });
4862
+ if (!autoCompactionSignal.aborted && reason !== "idle" && compactionSettings.autoContinue !== false) {
4863
+ this.#scheduleAutoContinuePrompt(generation);
4864
+ }
4816
4865
  return;
4817
4866
  }
4818
4867
  }
@@ -5064,26 +5113,7 @@ export class AgentSession {
5064
5113
  await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
5065
5114
 
5066
5115
  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
- );
5116
+ this.#scheduleAutoContinuePrompt(generation);
5087
5117
  }
5088
5118
 
5089
5119
  if (willRetry) {
@@ -5604,6 +5634,14 @@ export class AgentSession {
5604
5634
  // Bash Execution
5605
5635
  // =========================================================================
5606
5636
 
5637
+ async #saveBashOriginalArtifact(originalText: string): Promise<string | undefined> {
5638
+ try {
5639
+ return await this.sessionManager.saveArtifact(originalText, "bash-original");
5640
+ } catch {
5641
+ return undefined;
5642
+ }
5643
+ }
5644
+
5607
5645
  /**
5608
5646
  * Execute a bash command.
5609
5647
  * Adds result to agent context and session.
@@ -5640,6 +5678,7 @@ export class AgentSession {
5640
5678
  signal: this.#bashAbortController.signal,
5641
5679
  sessionKey: this.sessionId,
5642
5680
  timeout: clampTimeout("bash") * 1000,
5681
+ onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
5643
5682
  });
5644
5683
 
5645
5684
  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
  ];