@oh-my-pi/pi-coding-agent 17.1.6 → 17.1.7

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 (56) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/{CHANGELOG-x9zt79k8.md → CHANGELOG-5k4dq4g6.md} +23 -0
  3. package/dist/cli.js +3149 -3092
  4. package/dist/types/config/settings-schema.d.ts +16 -1
  5. package/dist/types/cursor.d.ts +2 -1
  6. package/dist/types/extensibility/extensions/runner.d.ts +4 -0
  7. package/dist/types/extensibility/shared-events.d.ts +18 -1
  8. package/dist/types/modes/components/custom-editor.d.ts +5 -0
  9. package/dist/types/session/agent-session-types.d.ts +5 -5
  10. package/dist/types/session/agent-session.d.ts +26 -1
  11. package/dist/types/session/model-controls.d.ts +1 -1
  12. package/dist/types/session/session-tools.d.ts +44 -6
  13. package/dist/types/session/streaming-output.d.ts +7 -2
  14. package/dist/types/session/turn-recovery.d.ts +1 -1
  15. package/dist/types/tools/index.d.ts +8 -3
  16. package/dist/types/tools/output-meta.d.ts +5 -0
  17. package/dist/types/tools/read.d.ts +9 -1
  18. package/dist/types/tools/xdev.d.ts +53 -67
  19. package/dist/types/utils/cpuprofile.d.ts +51 -0
  20. package/dist/types/utils/inspect-image-mode.d.ts +29 -0
  21. package/dist/types/utils/profile-tree.d.ts +47 -0
  22. package/dist/types/utils/sample-profile.d.ts +67 -0
  23. package/package.json +12 -12
  24. package/src/config/settings-schema.ts +15 -1
  25. package/src/config/settings.ts +35 -0
  26. package/src/cursor.ts +4 -3
  27. package/src/discovery/builtin-rules/index.ts +2 -0
  28. package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
  29. package/src/extensibility/extensions/runner.ts +26 -0
  30. package/src/extensibility/extensions/wrapper.ts +74 -42
  31. package/src/extensibility/hooks/tool-wrapper.ts +11 -4
  32. package/src/extensibility/shared-events.ts +18 -1
  33. package/src/modes/components/custom-editor.ts +39 -16
  34. package/src/modes/components/tips.txt +2 -1
  35. package/src/modes/components/tool-execution.ts +8 -7
  36. package/src/modes/controllers/extension-ui-controller.ts +4 -4
  37. package/src/modes/controllers/selector-controller.ts +7 -2
  38. package/src/sdk.ts +47 -50
  39. package/src/session/agent-session-types.ts +5 -5
  40. package/src/session/agent-session.ts +123 -7
  41. package/src/session/model-controls.ts +5 -5
  42. package/src/session/session-listing.ts +66 -4
  43. package/src/session/session-tools.ts +158 -52
  44. package/src/session/streaming-output.ts +18 -6
  45. package/src/session/turn-recovery.ts +4 -4
  46. package/src/slash-commands/builtin-registry.ts +69 -0
  47. package/src/tools/bash.ts +16 -9
  48. package/src/tools/index.ts +36 -16
  49. package/src/tools/output-meta.ts +20 -0
  50. package/src/tools/read.ts +87 -13
  51. package/src/tools/write.ts +16 -7
  52. package/src/tools/xdev.ts +198 -210
  53. package/src/utils/cpuprofile.ts +235 -0
  54. package/src/utils/inspect-image-mode.ts +39 -0
  55. package/src/utils/profile-tree.ts +111 -0
  56. package/src/utils/sample-profile.ts +437 -0
@@ -35,6 +35,8 @@ import {
35
35
  type AgentTurnEndContext,
36
36
  AppendOnlyContextManager,
37
37
  type AsideMessage,
38
+ type BeforeToolCallContext,
39
+ type BeforeToolCallResult,
38
40
  resolveTelemetry,
39
41
  type StreamFn,
40
42
  TERMINAL_TOOL_RESULT_ABORT_REASON,
@@ -70,6 +72,7 @@ import type {
70
72
  ToolChoice,
71
73
  ToolResultMessage,
72
74
  UsageReport,
75
+ UserMessage,
73
76
  } from "@oh-my-pi/pi-ai";
74
77
  import { type Effort, streamSimple } from "@oh-my-pi/pi-ai";
75
78
  import * as AIError from "@oh-my-pi/pi-ai/error";
@@ -133,6 +136,7 @@ import type { CompactOptions, ContextUsage } from "../extensibility/extensions/t
133
136
  import type { HookCommandContext } from "../extensibility/hooks/types";
134
137
  import type { Skill, SkillWarning } from "../extensibility/skills";
135
138
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
139
+ import { normalizeToolEventInput, resolveToolEventInput } from "../extensibility/tool-event-input";
136
140
  import { GoalRuntime } from "../goals/runtime";
137
141
  import type { GoalModeState } from "../goals/state";
138
142
  import type { HindsightSessionState } from "../hindsight/state";
@@ -176,6 +180,7 @@ import {
176
180
  toReasoningEffort,
177
181
  } from "../thinking";
178
182
  import { shutdownTinyTitleClient } from "../tiny/title-client";
183
+ import { resolveApproval } from "../tools/approval";
179
184
  import { type AskToolDetails, type AskToolInput, recoverAskQuestions } from "../tools/ask";
180
185
  import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
181
186
  import type { CheckpointState, CompletedRewindState } from "../tools/checkpoint";
@@ -196,6 +201,7 @@ import type { EditMode } from "../utils/edit-mode";
196
201
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
197
202
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
198
203
  import { normalizeModelContextImages } from "../utils/image-loading";
204
+ import type { InspectImageMode } from "../utils/inspect-image-mode";
199
205
  import { generateSessionTitle } from "../utils/title-generator";
200
206
  import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
201
207
  import type { VibeModeState } from "../vibe/state";
@@ -429,6 +435,8 @@ export class AgentSession {
429
435
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
430
436
  #queuedMessageDrainScheduled = false;
431
437
  #planModeState: PlanModeState | undefined;
438
+ /** Session-scoped `/vision` override; undefined = follow persisted `inspect_image.mode`. */
439
+ #inspectImageModeOverride: InspectImageMode | undefined;
432
440
  #vibeModeState: VibeModeState | undefined;
433
441
  #goalModeState: GoalModeState | undefined;
434
442
  #goalRuntime: GoalRuntime;
@@ -1081,20 +1089,24 @@ export class AgentSession {
1081
1089
  emitNotice: (level, message, source) => this.emitNotice(level, message, source),
1082
1090
  notifyCommandMetadataChanged: () => this.#notifyCommandMetadataChanged(),
1083
1091
  localProtocolOptions: () => this.#localProtocolOptions(),
1092
+ getInspectImageModeOverride: () => this.#inspectImageModeOverride,
1093
+ setInspectImageModeOverride: mode => {
1094
+ this.#inspectImageModeOverride = mode;
1095
+ },
1084
1096
  };
1085
1097
  this.#tools = new SessionTools(sessionToolsHost, {
1086
1098
  autoApprove: config.autoApprove,
1087
1099
  toolRegistry: config.toolRegistry,
1088
1100
  createVibeTools: config.createVibeTools,
1089
1101
  createComputerTool: config.createComputerTool,
1102
+ createInspectImageTool: config.createInspectImageTool,
1090
1103
  builtInToolNames: config.builtInToolNames,
1091
1104
  presentationPinnedToolNames: config.presentationPinnedToolNames,
1092
1105
  ensureWriteRegistered: config.ensureWriteRegistered,
1093
1106
  rebuildSystemPrompt: config.rebuildSystemPrompt,
1094
1107
  getLocalCalendarDate: config.getLocalCalendarDate,
1095
1108
  getMcpServerInstructions: config.getMcpServerInstructions,
1096
- xdevRegistry: config.xdevRegistry,
1097
- initialMountedXdevToolNames: config.initialMountedXdevToolNames,
1109
+ xdev: config.xdev,
1098
1110
  setActiveToolNames: config.setActiveToolNames,
1099
1111
  baseSystemPrompt: this.agent.state.systemPrompt,
1100
1112
  skills: config.skills,
@@ -1177,6 +1189,10 @@ export class AgentSession {
1177
1189
  });
1178
1190
  // Tool-result hook owns synchronous post-tool actions that must affect the current loop.
1179
1191
  this.agent.afterToolCall = ctx => this.#afterToolCall(ctx);
1192
+ // Pre-scheduling tool_call wiring: extension handlers run at arg-prep
1193
+ // time so a block/revision lands before concurrency resolution,
1194
+ // tool_execution_start, and the wrapper's approval gate.
1195
+ this.agent.beforeToolCall = ctx => this.#beforeToolCall(ctx);
1180
1196
  this.agent.providerSessionState = this.#providerSessionState;
1181
1197
  this.#syncAgentSessionId();
1182
1198
  this.#todo.syncFromBranch();
@@ -2961,6 +2977,50 @@ export class AgentSession {
2961
2977
  }
2962
2978
  return this.#ttsr.afterToolCall(ctx);
2963
2979
  }
2980
+ /**
2981
+ * Emits the extension `tool_call` event for a loop-dispatched call at
2982
+ * arg-prep time — before concurrency scheduling, `tool_execution_start`,
2983
+ * and the wrapper's approval gate. A handler block becomes a blocked tool
2984
+ * result; a handler `input` revision becomes the arguments the loop
2985
+ * schedules, displays, persists, and executes, so approval resolves against
2986
+ * what actually runs. Marks the dispatch so `ExtensionToolWrapper` does not
2987
+ * emit a second event (nested xd:// device dispatches and direct non-loop
2988
+ * execution still emit there).
2989
+ */
2990
+ async #beforeToolCall(ctx: BeforeToolCallContext): Promise<BeforeToolCallResult | undefined> {
2991
+ const runner = this.#extensionRunner;
2992
+ if (!runner?.hasHandlers("tool_call")) return undefined;
2993
+ const metadata = ctx.toolCall.providerMetadata;
2994
+ const computer = metadata?.type === "computer" ? metadata : undefined;
2995
+ // Parity with the wrapper's pre-emit short-circuit: an already-denied
2996
+ // call never reaches extensions. Deny is mode-independent (tool decision
2997
+ // or user policy), so resolving under the most permissive mode is exact;
2998
+ // the wrapper still enforces the mode-accurate gate before execution.
2999
+ const userPolicies = (this.settings.get("tools.approval") ?? {}) as Record<string, unknown>;
3000
+ const approvalArgs = computer ? { actions: computer.actions } : ctx.args;
3001
+ if (resolveApproval(ctx.tool, approvalArgs, "yolo", userPolicies).policy === "deny") {
3002
+ return undefined;
3003
+ }
3004
+ const eventArgs = computer
3005
+ ? { actions: computer.actions, pendingSafetyChecks: computer.pendingSafetyChecks }
3006
+ : ctx.args;
3007
+ runner.markToolCallEmitted(ctx.toolCall.id, ctx.tool.name);
3008
+ const callResult = await runner.emitToolCall({
3009
+ type: "tool_call",
3010
+ toolName: ctx.tool.name,
3011
+ toolCallId: ctx.toolCall.id,
3012
+ input: normalizeToolEventInput(ctx.tool.name, resolveToolEventInput(ctx.tool, eventArgs)),
3013
+ });
3014
+ if (callResult?.block) {
3015
+ return { block: true, reason: callResult.reason || "Tool execution was blocked by an extension" };
3016
+ }
3017
+ // A computer call's event input is a synthetic {actions, pendingSafetyChecks}
3018
+ // view, not the execution params — a revision cannot map back onto them.
3019
+ if (callResult?.input !== undefined && !computer) {
3020
+ return { args: callResult.input };
3021
+ }
3022
+ return undefined;
3023
+ }
2964
3024
 
2965
3025
  /** Find the last assistant message in agent state (including aborted ones) */
2966
3026
  #findLastAssistantMessage(): AssistantMessage | undefined {
@@ -3950,6 +4010,34 @@ export class AgentSession {
3950
4010
  return this.#tools.setComputerToolEnabled(enabled);
3951
4011
  }
3952
4012
 
4013
+ /**
4014
+ * Session-scoped inspect_image mode (`/vision`). `auto` clears the override
4015
+ * and returns to the persisted `inspect_image.mode` setting; `on`/`off`
4016
+ * force the tool for this session only. See {@link SessionTools.setInspectImageMode}.
4017
+ */
4018
+ setInspectImageMode(mode: InspectImageMode): Promise<boolean> {
4019
+ return this.#tools.setInspectImageMode(mode);
4020
+ }
4021
+
4022
+ /** Effective inspect_image state for `/vision status`. */
4023
+ inspectImageState(): { mode: InspectImageMode; active: boolean; model: string | undefined } {
4024
+ return this.#tools.inspectImageState();
4025
+ }
4026
+
4027
+ /** Session-scoped `/vision` override; undefined means "follow the persisted setting". */
4028
+ getInspectImageModeOverride(): InspectImageMode | undefined {
4029
+ return this.#inspectImageModeOverride;
4030
+ }
4031
+
4032
+ /**
4033
+ * Reconciles the inspect_image tool set after the persisted
4034
+ * `inspect_image.mode` setting changed (e.g. via the settings selector), so
4035
+ * the new value takes effect immediately instead of on the next model switch.
4036
+ */
4037
+ applyInspectImageModeChange(): Promise<boolean> {
4038
+ return this.#tools.reconcileInspectImageTool();
4039
+ }
4040
+
3953
4041
  /** Cancels the local rollout-memory startup owned by this session. */
3954
4042
  cancelLocalMemoryStartup(): void {
3955
4043
  this.#memory.cancelLocalMemoryStartup();
@@ -6405,7 +6493,7 @@ export class AgentSession {
6405
6493
  return true;
6406
6494
  }
6407
6495
 
6408
- #setModelWithProviderSessionReset(model: Model): void {
6496
+ async #setModelWithProviderSessionReset(model: Model): Promise<void> {
6409
6497
  const currentModel = this.model;
6410
6498
  if (currentModel) {
6411
6499
  this.#closeProviderSessionsForModelSwitch(currentModel, model);
@@ -6417,6 +6505,16 @@ export class AgentSession {
6417
6505
 
6418
6506
  // Re-evaluate append-only context mode — provider or setting may have changed
6419
6507
  this.#syncAppendOnlyContext(model);
6508
+
6509
+ // inspect_image auto mode keys off model image capability. Reconcile
6510
+ // centrally here so retry-fallback model changes (turn-recovery.ts),
6511
+ // which bypass syncAfterModelChange, cannot leave the tool set stale —
6512
+ // callers await, so a scheduled retry never races the reconciled slate.
6513
+ try {
6514
+ await this.#tools.reconcileInspectImageAfterModelChange();
6515
+ } catch (error) {
6516
+ logger.warn("inspect_image reconcile after model change failed", { error: String(error) });
6517
+ }
6420
6518
  }
6421
6519
 
6422
6520
  #closeCodexProviderSessionsForHistoryRewrite(): void {
@@ -6981,7 +7079,7 @@ export class AgentSession {
6981
7079
  currentModel.id !== match.id ||
6982
7080
  currentModel.api !== match.api));
6983
7081
  if (shouldResetProviderState) {
6984
- this.#setModelWithProviderSessionReset(match);
7082
+ await this.#setModelWithProviderSessionReset(match);
6985
7083
  } else {
6986
7084
  this.agent.setModel(match);
6987
7085
  }
@@ -7108,10 +7206,12 @@ export class AgentSession {
7108
7206
  * @param entryId ID of the entry to branch from
7109
7207
  * @returns Object with:
7110
7208
  * - selectedText: The text of the selected user message (for editor pre-fill)
7209
+ * - selectedImages: Image attachments of the selected user message (for editor draft restore)
7111
7210
  * - cancelled: True if a hook cancelled the branch
7112
7211
  */
7113
7212
  async branch(entryId: string): Promise<{
7114
7213
  selectedText: string;
7214
+ selectedImages: ImageContent[];
7115
7215
  cancelled: boolean;
7116
7216
  }> {
7117
7217
  const previousSessionFile = this.sessionFile;
@@ -7122,6 +7222,7 @@ export class AgentSession {
7122
7222
  }
7123
7223
 
7124
7224
  const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
7225
+ const selectedImages = this.#extractUserMessageImages(selectedEntry.message.content);
7125
7226
 
7126
7227
  let skipConversationRestore = false;
7127
7228
 
@@ -7133,7 +7234,7 @@ export class AgentSession {
7133
7234
  })) as SessionBeforeBranchResult | undefined;
7134
7235
 
7135
7236
  if (result?.cancel) {
7136
- return { selectedText, cancelled: true };
7237
+ return { selectedText, selectedImages, cancelled: true };
7137
7238
  }
7138
7239
  skipConversationRestore = result?.skipConversationRestore ?? false;
7139
7240
  }
@@ -7189,7 +7290,7 @@ export class AgentSession {
7189
7290
  this.#closeCodexProviderSessionsForHistoryRewrite();
7190
7291
  }
7191
7292
 
7192
- return { selectedText, cancelled: false };
7293
+ return { selectedText, selectedImages, cancelled: false };
7193
7294
  }
7194
7295
 
7195
7296
  async branchFromBtw(
@@ -7304,7 +7405,7 @@ export class AgentSession {
7304
7405
  * @param targetId The entry ID to navigate to
7305
7406
  * @param options.summarize Whether user wants to summarize abandoned branch
7306
7407
  * @param options.customInstructions Custom instructions for summarizer
7307
- * @returns Result with editorText (if user message) and cancelled status
7408
+ * @returns Result with editorText/editorImages (if user message) and cancelled status
7308
7409
  */
7309
7410
  async navigateTree(
7310
7411
  targetId: string,
@@ -7334,6 +7435,8 @@ export class AgentSession {
7334
7435
  } = {},
7335
7436
  ): Promise<{
7336
7437
  editorText?: string;
7438
+ /** Image attachments of the target user message, parallel to the positional `[Image #N]` markers in {@link editorText}. */
7439
+ editorImages?: ImageContent[];
7337
7440
  cancelled: boolean;
7338
7441
  aborted?: boolean;
7339
7442
  summaryEntry?: BranchSummaryEntry;
@@ -7506,6 +7609,7 @@ export class AgentSession {
7506
7609
  // Determine the new leaf position based on target type
7507
7610
  let newLeafId: string | null;
7508
7611
  let editorText: string | undefined;
7612
+ let editorImages: ImageContent[] | undefined;
7509
7613
  // Set when the second-pass `ask` re-answer branch below actually commits a
7510
7614
  // new sibling answer — the trigger for resuming the agent afterwards so the
7511
7615
  // model consumes it, mirroring a live `ask` completion (issue #6483).
@@ -7515,6 +7619,8 @@ export class AgentSession {
7515
7619
  // User message: leaf = parent (null if root), text goes to editor
7516
7620
  newLeafId = targetEntry.parentId;
7517
7621
  editorText = this.#extractUserMessageText(targetEntry.message.content);
7622
+ const targetImages = this.#extractUserMessageImages(targetEntry.message.content);
7623
+ if (targetImages.length > 0) editorImages = targetImages;
7518
7624
  } else if (targetEntry.type === "custom_message" && targetEntry.customType !== SKILL_PROMPT_MESSAGE_TYPE) {
7519
7625
  // Custom message: leaf = parent (null if root), text goes to editor
7520
7626
  newLeafId = targetEntry.parentId;
@@ -7614,6 +7720,7 @@ export class AgentSession {
7614
7720
  const rawContext = this.sessionManager.buildSessionContext();
7615
7721
  return {
7616
7722
  editorText,
7723
+ editorImages,
7617
7724
  cancelled: false,
7618
7725
  summaryEntry,
7619
7726
  sessionContext: rawContext,
@@ -7622,6 +7729,7 @@ export class AgentSession {
7622
7729
  }
7623
7730
  return {
7624
7731
  editorText,
7732
+ editorImages,
7625
7733
  cancelled: false,
7626
7734
  summaryEntry,
7627
7735
  sessionContext: stateContext,
@@ -7740,6 +7848,14 @@ export class AgentSession {
7740
7848
  return "";
7741
7849
  }
7742
7850
 
7851
+ /** Image parts of a stored user message, in submission order — index N-1 backs the
7852
+ * `[Image #N]` marker in the message text, so restoring them alongside the text keeps
7853
+ * positional markers resolvable on resubmit. */
7854
+ #extractUserMessageImages(content: UserMessage["content"]): ImageContent[] {
7855
+ if (!Array.isArray(content)) return [];
7856
+ return content.filter((c): c is ImageContent => c.type === "image");
7857
+ }
7858
+
7743
7859
  /**
7744
7860
  * Get session statistics.
7745
7861
  */
@@ -52,7 +52,7 @@ export interface ModelControlsHost {
52
52
  promptGeneration(): number;
53
53
  resolveActiveEditMode(): EditMode;
54
54
  syncAfterModelChange(previousEditMode: EditMode): Promise<void>;
55
- setModelWithProviderSessionReset(model: Model): void;
55
+ setModelWithProviderSessionReset(model: Model): Promise<void>;
56
56
  clearActiveRetryFallback(): void;
57
57
  clearInheritedProviderPromptCacheKey(): void;
58
58
  magicKeywordEnabled(keyword: "orchestrate" | "ultrathink" | "workflow"): boolean;
@@ -220,7 +220,7 @@ export class ModelControls {
220
220
 
221
221
  this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
222
222
  this.#host.clearActiveRetryFallback();
223
- this.#host.setModelWithProviderSessionReset(targetModel);
223
+ await this.#host.setModelWithProviderSessionReset(targetModel);
224
224
  this.#host.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
225
225
  if (options?.persist) {
226
226
  this.#host.settings.setModelRole(
@@ -265,7 +265,7 @@ export class ModelControls {
265
265
 
266
266
  this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
267
267
  this.#host.clearActiveRetryFallback();
268
- this.#host.setModelWithProviderSessionReset(targetModel);
268
+ await this.#host.setModelWithProviderSessionReset(targetModel);
269
269
  this.#host.sessionManager.appendModelChange(
270
270
  `${targetModel.provider}/${targetModel.id}`,
271
271
  options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
@@ -425,7 +425,7 @@ export class ModelControls {
425
425
  // Apply model
426
426
  this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(next.model));
427
427
  this.#host.clearActiveRetryFallback();
428
- this.#host.setModelWithProviderSessionReset(next.model);
428
+ await this.#host.setModelWithProviderSessionReset(next.model);
429
429
  this.#host.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
430
430
  this.#host.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
431
431
 
@@ -456,7 +456,7 @@ export class ModelControls {
456
456
 
457
457
  this.#host.modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(nextModel));
458
458
  this.#host.clearActiveRetryFallback();
459
- this.#host.setModelWithProviderSessionReset(nextModel);
459
+ await this.#host.setModelWithProviderSessionReset(nextModel);
460
460
  this.#host.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
461
461
  this.#host.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
462
462
  // Re-apply the current thinking level (or auto) for the newly selected model
@@ -2,8 +2,9 @@ import * as os from "node:os";
2
2
  import * as path from "node:path";
3
3
  import type { Message } from "@oh-my-pi/pi-ai";
4
4
  import { getAgentDir as getDefaultAgentDir, logger, parseJsonlLenient, toError } from "@oh-my-pi/pi-utils";
5
+ import { LRUCache } from "lru-cache/raw";
5
6
  import { computeDefaultSessionDir } from "./session-paths";
6
- import { FileSessionStorage, type SessionStorage } from "./session-storage";
7
+ import { FileSessionStorage, type SessionStorage, type SessionStorageStat } from "./session-storage";
7
8
 
8
9
  /**
9
10
  * Coarse lifecycle status of a session, derived from its last persisted message.
@@ -65,6 +66,44 @@ const SESSION_LIST_SUFFIX_BYTES = 32_768;
65
66
  const SESSION_LIST_PARALLEL_THRESHOLD = 64;
66
67
  const SESSION_LIST_MAX_WORKERS = 16;
67
68
 
69
+ /**
70
+ * Memoizes {@link scanSessionFile} results keyed by stat identity so listing
71
+ * refreshes (resume picker opens, startup recent-sessions, cross-project
72
+ * scans) skip the open+read+parse for unchanged files. The `statSync` still
73
+ * runs on every scan — it IS the invalidation check: a hit requires both
74
+ * `mtimeMs` and `size` to match. This covers the two mutation paths:
75
+ * - streaming appends grow `size` (and bump `mtimeMs`);
76
+ * - `updateSessionTitle` rewrites the fixed-width title slot in place via
77
+ * `writeSync`, which leaves `size` unchanged but updates `mtimeMs`.
78
+ * Negative results (unparseable files) are cached too, as `undefined` info.
79
+ * Entries are small header objects, so a generous cap is cheap.
80
+ */
81
+ const SESSION_SCAN_CACHE_MAX = 4096;
82
+
83
+ interface SessionScanCacheEntry {
84
+ mtimeMs: number;
85
+ size: number;
86
+ info: SessionInfo | undefined;
87
+ }
88
+
89
+ type SessionScanCache = LRUCache<string, SessionScanCacheEntry>;
90
+
91
+ /** All {@link FileSessionStorage} instances view the same real filesystem, so they share one cache. */
92
+ const fileSessionScanCache: SessionScanCache = new LRUCache({ max: SESSION_SCAN_CACHE_MAX });
93
+ /** Other storages (in-memory test doubles) each carry their own cache to avoid cross-instance path collisions. */
94
+ const kScanCache = Symbol("session-listing.scanCache");
95
+
96
+ interface StorageWithScanCache extends SessionStorage {
97
+ [kScanCache]?: SessionScanCache;
98
+ }
99
+
100
+ function getSessionScanCache(storage: SessionStorage): SessionScanCache {
101
+ if (storage instanceof FileSessionStorage) return fileSessionScanCache;
102
+ const holder = storage as StorageWithScanCache;
103
+ if (!holder[kScanCache]) holder[kScanCache] = new LRUCache({ max: SESSION_SCAN_CACHE_MAX });
104
+ return holder[kScanCache];
105
+ }
106
+
68
107
  function sanitizeSessionName(value: string | undefined): string | undefined {
69
108
  if (!value) return undefined;
70
109
  const firstLine = value.split(/\r?\n/)[0] ?? "";
@@ -355,8 +394,22 @@ async function scanSessionFile(
355
394
  storage: SessionStorage,
356
395
  withStatus: boolean,
357
396
  ): Promise<SessionInfo | undefined> {
397
+ let stat: SessionStorageStat;
398
+ try {
399
+ stat = storage.statSync(file);
400
+ } catch {
401
+ // Missing/unstatable file: no stat identity to cache under.
402
+ return undefined;
403
+ }
404
+ const cache = getSessionScanCache(storage);
405
+ // `withStatus` changes what a scan reads (tail window) and returns, so the
406
+ // two variants are cached under distinct keys.
407
+ const cacheKey = withStatus ? `s\0${file}` : `h\0${file}`;
408
+ const cached = cache.get(cacheKey);
409
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
410
+ return cached.info ? { ...cached.info } : undefined;
411
+ }
358
412
  try {
359
- const stat = storage.statSync(file);
360
413
  const [content, suffix] = await storage.readTextSlices(
361
414
  file,
362
415
  SESSION_LIST_PREFIX_BYTES,
@@ -365,7 +418,12 @@ async function scanSessionFile(
365
418
  const { size, mtime } = stat;
366
419
  const entries = parseJsonlLenient<Record<string, unknown>>(content);
367
420
  const header = parseSessionListHeader(content, entries);
368
- if (!header) return undefined;
421
+ if (!header) {
422
+ // Cache the negative result too: an unparseable file stays unparseable
423
+ // until its stat identity changes.
424
+ cache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, info: undefined });
425
+ return undefined;
426
+ }
369
427
 
370
428
  let parsedMessageCount = 0;
371
429
  let firstMessage = "";
@@ -398,7 +456,7 @@ async function scanSessionFile(
398
456
 
399
457
  firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? "";
400
458
  const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
401
- return {
459
+ const info: SessionInfo = {
402
460
  path: file,
403
461
  id: header.id,
404
462
  cwd: header.cwd ?? "",
@@ -412,6 +470,10 @@ async function scanSessionFile(
412
470
  allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
413
471
  status: withStatus ? deriveSessionStatus(suffix) : undefined,
414
472
  };
473
+ // The cache keeps its own shallow copy; hits also hand out copies, so
474
+ // callers can never mutate the shared cached object.
475
+ cache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, info: { ...info } });
476
+ return info;
415
477
  } catch {
416
478
  return undefined;
417
479
  }