@oh-my-pi/pi-coding-agent 14.5.9 → 14.5.11

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 (37) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -15
  3. package/scripts/build-binary.ts +1 -1
  4. package/src/cli/update-cli.ts +25 -1
  5. package/src/config/model-registry.ts +21 -19
  6. package/src/config/settings-schema.ts +11 -16
  7. package/src/discovery/claude-plugins.ts +28 -3
  8. package/src/edit/modes/atom.ts +50 -19
  9. package/src/edit/modes/hashline.ts +171 -110
  10. package/src/export/html/template.generated.ts +1 -1
  11. package/src/export/html/template.js +14 -2
  12. package/src/extensibility/extensions/runner.ts +34 -1
  13. package/src/extensibility/extensions/types.ts +8 -0
  14. package/src/internal-urls/docs-index.generated.ts +54 -54
  15. package/src/lsp/client.ts +27 -35
  16. package/src/memories/index.ts +5 -0
  17. package/src/modes/components/settings-defs.ts +1 -1
  18. package/src/modes/controllers/selector-controller.ts +2 -2
  19. package/src/modes/controllers/todo-command-controller.ts +22 -74
  20. package/src/modes/interactive-mode.ts +36 -9
  21. package/src/modes/theme/theme.ts +10 -1
  22. package/src/modes/types.ts +1 -3
  23. package/src/modes/utils/ui-helpers.ts +19 -6
  24. package/src/prompts/system/auto-continue.md +1 -0
  25. package/src/prompts/system/eager-todo.md +1 -1
  26. package/src/prompts/tools/github.md +3 -3
  27. package/src/prompts/tools/todo-write.md +19 -19
  28. package/src/sdk.ts +13 -2
  29. package/src/session/agent-session.ts +196 -96
  30. package/src/session/session-manager.ts +19 -2
  31. package/src/tools/bash.ts +9 -4
  32. package/src/tools/gh.ts +267 -119
  33. package/src/tools/todo-write.ts +157 -195
  34. package/src/utils/git.ts +61 -2
  35. package/src/web/search/providers/searxng.ts +71 -13
  36. package/examples/custom-tools/todo/index.ts +0 -211
  37. package/examples/extensions/todo.ts +0 -295
package/src/sdk.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  INTENT_FIELD,
7
7
  type ThinkingLevel,
8
8
  } from "@oh-my-pi/pi-agent-core";
9
- import type { Message, Model } from "@oh-my-pi/pi-ai";
9
+ import type { Message, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
10
10
  import {
11
11
  getOpenAICodexTransportDetails,
12
12
  prewarmOpenAICodexResponses,
@@ -793,7 +793,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
793
793
  thinkingLevel = defaultRoleSpec.thinkingLevel;
794
794
  }
795
795
 
796
- // Fall back to settings default
796
+ // Prefer the selected model's configured defaultLevel, otherwise fall back
797
+ // to the global settings default.
798
+ if (thinkingLevel === undefined && model?.thinking?.defaultLevel !== undefined) {
799
+ thinkingLevel = model.thinking.defaultLevel;
800
+ }
797
801
  if (thinkingLevel === undefined) {
798
802
  thinkingLevel = settings.get("defaultThinkingLevel");
799
803
  }
@@ -1498,6 +1502,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1498
1502
  return await extensionRunner.emitBeforeProviderRequest(payload);
1499
1503
  }
1500
1504
  : undefined;
1505
+ const onResponse: SimpleStreamOptions["onResponse"] | undefined = extensionRunner
1506
+ ? async (response, model) => {
1507
+ await extensionRunner.emitAfterProviderResponse(response, model);
1508
+ }
1509
+ : undefined;
1501
1510
 
1502
1511
  const setToolUIContext = (uiContext: ExtensionUIContext, hasUI: boolean) => {
1503
1512
  toolContextStore.setUIContext(uiContext, hasUI);
@@ -1527,6 +1536,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1527
1536
  },
1528
1537
  convertToLlm: convertToLlmFinal,
1529
1538
  onPayload,
1539
+ onResponse,
1530
1540
  sessionId: providerSessionId,
1531
1541
  transformContext,
1532
1542
  steeringMode: settings.get("steeringMode") ?? "one-at-a-time",
@@ -1599,6 +1609,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1599
1609
  toolRegistry,
1600
1610
  transformContext,
1601
1611
  onPayload,
1612
+ onResponse,
1602
1613
  convertToLlm: convertToLlmFinal,
1603
1614
  rebuildSystemPrompt,
1604
1615
  mcpDiscoveryEnabled,
@@ -46,6 +46,7 @@ import {
46
46
  calculateRateLimitBackoffMs,
47
47
  getSupportedEfforts,
48
48
  isContextOverflow,
49
+ isUnexpectedSocketCloseMessage,
49
50
  isUsageLimitError,
50
51
  modelsAreEqual,
51
52
  parseRateLimitReason,
@@ -104,7 +105,7 @@ import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
104
105
  import type { HookCommandContext } from "../extensibility/hooks/types";
105
106
  import type { Skill, SkillWarning } from "../extensibility/skills";
106
107
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
107
- import { resolveLocalUrlToPath } from "../internal-urls";
108
+ import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
108
109
  import {
109
110
  disposeKernelSessionsByOwner,
110
111
  executePython as executePythonCommand,
@@ -120,6 +121,7 @@ import {
120
121
  } from "../mcp/discoverable-tool-metadata";
121
122
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
122
123
  import type { PlanModeState } from "../plan-mode/state";
124
+ import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
123
125
  import autoHandoffThresholdFocusPrompt from "../prompts/system/auto-handoff-threshold-focus.md" with { type: "text" };
124
126
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
125
127
  import handoffDocumentPrompt from "../prompts/system/handoff-document.md" with { type: "text" };
@@ -244,6 +246,8 @@ export interface AgentSessionConfig {
244
246
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
245
247
  /** Provider payload hook used by the active session request path */
246
248
  onPayload?: SimpleStreamOptions["onPayload"];
249
+ /** Provider response hook used by the active session request path */
250
+ onResponse?: SimpleStreamOptions["onResponse"];
247
251
  /** Current session message-to-LLM conversion pipeline */
248
252
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
249
253
  /** System prompt builder that can consider tool availability */
@@ -383,6 +387,11 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
383
387
  return `${selector.provider}/${selector.id}`;
384
388
  }
385
389
 
390
+ /** Composite key for auto-clear timers, keyed by phase name + task content. */
391
+ function todoClearKey(phaseName: string, taskContent: string): string {
392
+ return `${phaseName}\u0000${taskContent}`;
393
+ }
394
+
386
395
  const noOpUIContext: ExtensionUIContext = {
387
396
  select: async (_title, _options, _dialogOptions) => undefined,
388
397
  confirm: async (_title, _message, _dialogOptions) => false,
@@ -469,7 +478,7 @@ export class AgentSession {
469
478
  #toolChoiceQueue = new ToolChoiceQueue();
470
479
 
471
480
  // Bash execution state
472
- #bashAbortController: AbortController | undefined = undefined;
481
+ #bashAbortControllers = new Set<AbortController>();
473
482
  #pendingBashMessages: BashExecutionMessage[] = [];
474
483
 
475
484
  // Python execution state
@@ -507,6 +516,7 @@ export class AgentSession {
507
516
  #toolRegistry: Map<string, AgentTool>;
508
517
  #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
509
518
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
519
+ #onResponse: SimpleStreamOptions["onResponse"] | undefined;
510
520
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
511
521
  #rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
512
522
  #baseSystemPrompt: string;
@@ -526,8 +536,7 @@ export class AgentSession {
526
536
  #ttsrRetryToken = 0;
527
537
  #ttsrResumePromise: Promise<void> | undefined = undefined;
528
538
  #ttsrResumeResolve: (() => void) | undefined = undefined;
529
- #postPromptTaskCounter = 0;
530
- #postPromptTaskIds = new Set<number>();
539
+ #postPromptTasks = new Set<Promise<void>>();
531
540
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
532
541
  #postPromptTasksResolve: (() => void) | undefined = undefined;
533
542
  #postPromptTasksAbortController = new AbortController();
@@ -593,6 +602,7 @@ export class AgentSession {
593
602
  this.#toolRegistry = config.toolRegistry ?? new Map();
594
603
  this.#transformContext = config.transformContext ?? (messages => messages);
595
604
  this.#onPayload = config.onPayload;
605
+ this.#onResponse = config.onResponse;
596
606
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
597
607
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
598
608
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
@@ -1144,14 +1154,13 @@ export class AgentSession {
1144
1154
  }
1145
1155
 
1146
1156
  #trackPostPromptTask(task: Promise<void>): void {
1147
- const taskId = ++this.#postPromptTaskCounter;
1148
- this.#postPromptTaskIds.add(taskId);
1157
+ this.#postPromptTasks.add(task);
1149
1158
  this.#ensurePostPromptTasksPromise();
1150
1159
  void task
1151
1160
  .catch(() => {})
1152
1161
  .finally(() => {
1153
- this.#postPromptTaskIds.delete(taskId);
1154
- if (this.#postPromptTaskIds.size === 0) {
1162
+ this.#postPromptTasks.delete(task);
1163
+ if (this.#postPromptTasks.size === 0) {
1155
1164
  this.#resolvePostPromptTasks();
1156
1165
  }
1157
1166
  });
@@ -1217,11 +1226,11 @@ export class AgentSession {
1217
1226
  await this.#promptWithMessage(
1218
1227
  {
1219
1228
  role: "developer",
1220
- content: [{ type: "text", text: "Continue if you have next steps." }],
1229
+ content: [{ type: "text", text: autoContinuePrompt }],
1221
1230
  attribution: "agent",
1222
1231
  timestamp: Date.now(),
1223
1232
  },
1224
- "Continue if you have next steps.",
1233
+ autoContinuePrompt,
1225
1234
  { skipPostPromptRecoveryWait: true },
1226
1235
  );
1227
1236
  };
@@ -1235,11 +1244,21 @@ export class AgentSession {
1235
1244
  );
1236
1245
  }
1237
1246
 
1238
- #cancelPostPromptTasks(): void {
1247
+ async #cancelPostPromptTasks(): Promise<void> {
1239
1248
  this.#postPromptTasksAbortController.abort();
1240
1249
  this.#postPromptTasksAbortController = new AbortController();
1241
- this.#postPromptTaskIds.clear();
1242
- this.#resolvePostPromptTasks();
1250
+ this.#resolveTtsrResume();
1251
+
1252
+ const pendingTasks = Array.from(this.#postPromptTasks);
1253
+ if (pendingTasks.length === 0) {
1254
+ this.#resolvePostPromptTasks();
1255
+ return;
1256
+ }
1257
+
1258
+ await Promise.allSettled(pendingTasks);
1259
+ if (this.#postPromptTasks.size === 0) {
1260
+ this.#resolvePostPromptTasks();
1261
+ }
1243
1262
  }
1244
1263
  /**
1245
1264
  * Wait for retry, TTSR resume, and any background continuation to settle.
@@ -1523,10 +1542,19 @@ export class AgentSession {
1523
1542
  const path = typeof args.path === "string" ? args.path : undefined;
1524
1543
  if (!path) return undefined;
1525
1544
 
1545
+ // `local://` URLs (e.g. local://PLAN.md for plan-mode) resolve to a real
1546
+ // on-disk artifacts path; pre-caching works as long as we ask the
1547
+ // local-protocol handler. Other internal-scheme URLs (agent://, skill://,
1548
+ // rule://, mcp://, artifact://) have no stable filesystem representation;
1549
+ // skip pre-cache entirely for those — the edit tool itself will reject
1550
+ // them through its normal dispatch path.
1551
+ const resolvedPath = this.#resolveSessionFsPath(path);
1552
+ if (resolvedPath === undefined) return undefined;
1553
+
1526
1554
  return {
1527
1555
  toolCall,
1528
1556
  path,
1529
- resolvedPath: resolveToCwd(path, this.sessionManager.getCwd()),
1557
+ resolvedPath,
1530
1558
  diff: typeof args.diff === "string" ? args.diff : undefined,
1531
1559
  op: typeof args.op === "string" ? args.op : undefined,
1532
1560
  rename: typeof args.rename === "string" ? args.rename : undefined,
@@ -1600,11 +1628,47 @@ export class AgentSession {
1600
1628
  }
1601
1629
 
1602
1630
  /** Invalidate cache for a file after an edit completes to prevent stale data */
1603
- #invalidateFileCacheForPath(path: string): void {
1604
- const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
1631
+ #invalidateFileCacheForPath(filePath: string): void {
1632
+ const resolvedPath = this.#resolveSessionFsPath(filePath);
1633
+ if (resolvedPath === undefined) return;
1605
1634
  this.#streamingEditFileCache.delete(resolvedPath);
1606
1635
  }
1607
1636
 
1637
+ /**
1638
+ * Resolve a path supplied to a tool to a real filesystem path.
1639
+ *
1640
+ * - `local://` URLs route through the local-protocol handler so they map
1641
+ * onto the session's on-disk artifacts directory; pre-caching, ENOENT
1642
+ * handling, and post-edit invalidation all work normally.
1643
+ * - Other internal-scheme URLs (agent://, skill://, rule://, mcp://,
1644
+ * artifact://) have no stable filesystem path; this returns `undefined`
1645
+ * so callers skip filesystem-only operations.
1646
+ * - Cwd-relative and absolute paths resolve via `resolveToCwd`.
1647
+ */
1648
+ #resolveSessionFsPath(filePath: string): string | undefined {
1649
+ const normalized = normalizeLocalScheme(filePath);
1650
+ if (normalized.startsWith("local:")) {
1651
+ return resolveLocalUrlToPath(normalized, this.#localProtocolOptions());
1652
+ }
1653
+ if (
1654
+ normalized.startsWith("agent://") ||
1655
+ normalized.startsWith("skill://") ||
1656
+ normalized.startsWith("rule://") ||
1657
+ normalized.startsWith("mcp://") ||
1658
+ normalized.startsWith("artifact://")
1659
+ ) {
1660
+ return undefined;
1661
+ }
1662
+ return resolveToCwd(normalized, this.sessionManager.getCwd());
1663
+ }
1664
+
1665
+ #localProtocolOptions(): LocalProtocolOptions {
1666
+ return {
1667
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1668
+ getSessionId: () => this.sessionManager.getSessionId(),
1669
+ };
1670
+ }
1671
+
1608
1672
  #maybeAbortStreamingEdit(event: AgentEvent): void {
1609
1673
  if (!this.settings.get("edit.streamingAbort")) return;
1610
1674
  if (this.#streamingEditAbortTriggered) return;
@@ -1892,7 +1956,7 @@ export class AgentSession {
1892
1956
  } catch (error) {
1893
1957
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
1894
1958
  }
1895
- this.#cancelPostPromptTasks();
1959
+ await this.#cancelPostPromptTasks();
1896
1960
  this.#clearTodoClearTimers();
1897
1961
  const drained = await this.#asyncJobManager?.dispose({ timeoutMs: 3_000 });
1898
1962
  const deliveryState = this.#asyncJobManager?.getDeliveryState();
@@ -2318,21 +2382,39 @@ export class AgentSession {
2318
2382
 
2319
2383
  /** Apply session-level stream hooks to a direct side request. */
2320
2384
  prepareSimpleStreamOptions(options: SimpleStreamOptions): SimpleStreamOptions {
2321
- if (!this.#onPayload) return options;
2322
- if (!options.onPayload) {
2323
- return { ...options, onPayload: this.#onPayload };
2324
- }
2325
2385
  const sessionOnPayload = this.#onPayload;
2326
- const requestOnPayload = options.onPayload;
2327
- return {
2328
- ...options,
2329
- onPayload: async (payload, model) => {
2330
- const sessionPayload = await sessionOnPayload(payload, model);
2331
- const sessionResolvedPayload = sessionPayload ?? payload;
2332
- const requestPayload = await requestOnPayload(sessionResolvedPayload, model);
2333
- return requestPayload ?? sessionResolvedPayload;
2334
- },
2335
- };
2386
+ const sessionOnResponse = this.#onResponse;
2387
+ if (!sessionOnPayload && !sessionOnResponse) return options;
2388
+
2389
+ const preparedOptions: SimpleStreamOptions = { ...options };
2390
+
2391
+ if (sessionOnPayload) {
2392
+ if (!options.onPayload) {
2393
+ preparedOptions.onPayload = sessionOnPayload;
2394
+ } else {
2395
+ const requestOnPayload = options.onPayload;
2396
+ preparedOptions.onPayload = async (payload, model) => {
2397
+ const sessionPayload = await sessionOnPayload(payload, model);
2398
+ const sessionResolvedPayload = sessionPayload ?? payload;
2399
+ const requestPayload = await requestOnPayload(sessionResolvedPayload, model);
2400
+ return requestPayload ?? sessionResolvedPayload;
2401
+ };
2402
+ }
2403
+ }
2404
+
2405
+ if (sessionOnResponse) {
2406
+ if (!options.onResponse) {
2407
+ preparedOptions.onResponse = sessionOnResponse;
2408
+ } else {
2409
+ const requestOnResponse = options.onResponse;
2410
+ preparedOptions.onResponse = async (response, model) => {
2411
+ await sessionOnResponse(response, model);
2412
+ await requestOnResponse(response, model);
2413
+ };
2414
+ }
2415
+ }
2416
+
2417
+ return preparedOptions;
2336
2418
  }
2337
2419
 
2338
2420
  /** Current steering mode */
@@ -2466,10 +2548,7 @@ export class AgentSession {
2466
2548
  if (this.#planReferenceSent) return null;
2467
2549
 
2468
2550
  const planFilePath = this.#planReferencePath;
2469
- const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, {
2470
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2471
- getSessionId: () => this.sessionManager.getSessionId(),
2472
- });
2551
+ const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, this.#localProtocolOptions());
2473
2552
  let planContent: string;
2474
2553
  try {
2475
2554
  planContent = await Bun.file(resolvedPlanPath).text();
@@ -2502,15 +2581,9 @@ export class AgentSession {
2502
2581
  if (!state?.enabled) return null;
2503
2582
  const sessionPlanUrl = "local://PLAN.md";
2504
2583
  const resolvedPlanPath = state.planFilePath.startsWith("local:")
2505
- ? resolveLocalUrlToPath(normalizeLocalScheme(state.planFilePath), {
2506
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2507
- getSessionId: () => this.sessionManager.getSessionId(),
2508
- })
2584
+ ? resolveLocalUrlToPath(normalizeLocalScheme(state.planFilePath), this.#localProtocolOptions())
2509
2585
  : resolveToCwd(state.planFilePath, this.sessionManager.getCwd());
2510
- const resolvedSessionPlan = resolveLocalUrlToPath(sessionPlanUrl, {
2511
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2512
- getSessionId: () => this.sessionManager.getSessionId(),
2513
- });
2586
+ const resolvedSessionPlan = resolveLocalUrlToPath(sessionPlanUrl, this.#localProtocolOptions());
2514
2587
  const displayPlanPath =
2515
2588
  state.planFilePath.startsWith("local:") || resolvedPlanPath !== resolvedSessionPlan
2516
2589
  ? state.planFilePath
@@ -3279,10 +3352,9 @@ export class AgentSession {
3279
3352
 
3280
3353
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
3281
3354
  return phases.map(phase => ({
3282
- id: phase.id,
3283
3355
  name: phase.name,
3284
3356
  tasks: phase.tasks.map(task => {
3285
- const out: TodoItem = { id: task.id, content: task.content, status: task.status };
3357
+ const out: TodoItem = { content: task.content, status: task.status };
3286
3358
  if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
3287
3359
  return out;
3288
3360
  }),
@@ -3294,43 +3366,43 @@ export class AgentSession {
3294
3366
  const delaySec = this.settings.get("tasks.todoClearDelay") ?? 60;
3295
3367
  if (delaySec < 0) return; // "Never" — no auto-clear
3296
3368
  const delayMs = delaySec * 1000;
3297
- const doneTaskIds = new Set<string>();
3369
+ const doneKeys = new Set<string>();
3298
3370
  for (const phase of phases) {
3299
3371
  for (const task of phase.tasks) {
3300
3372
  if (task.status === "completed" || task.status === "abandoned") {
3301
- doneTaskIds.add(task.id);
3373
+ doneKeys.add(todoClearKey(phase.name, task.content));
3302
3374
  }
3303
3375
  }
3304
3376
  }
3305
3377
 
3306
3378
  // Cancel timers for tasks that are no longer done (e.g. status was reverted)
3307
- for (const [id, timer] of this.#todoClearTimers) {
3308
- if (!doneTaskIds.has(id)) {
3379
+ for (const [key, timer] of this.#todoClearTimers) {
3380
+ if (!doneKeys.has(key)) {
3309
3381
  clearTimeout(timer);
3310
- this.#todoClearTimers.delete(id);
3382
+ this.#todoClearTimers.delete(key);
3311
3383
  }
3312
3384
  }
3313
3385
 
3314
3386
  // Schedule new timers for newly-done tasks
3315
- for (const id of doneTaskIds) {
3316
- if (this.#todoClearTimers.has(id)) continue;
3387
+ for (const key of doneKeys) {
3388
+ if (this.#todoClearTimers.has(key)) continue;
3317
3389
  if (delayMs === 0) {
3318
3390
  // Instant — run synchronously on next microtask to batch removals
3319
- const timer = setTimeout(() => this.#runTodoAutoClear(id), 0);
3320
- this.#todoClearTimers.set(id, timer);
3391
+ const timer = setTimeout(() => this.#runTodoAutoClear(key), 0);
3392
+ this.#todoClearTimers.set(key, timer);
3321
3393
  } else {
3322
- const timer = setTimeout(() => this.#runTodoAutoClear(id), delayMs);
3323
- this.#todoClearTimers.set(id, timer);
3394
+ const timer = setTimeout(() => this.#runTodoAutoClear(key), delayMs);
3395
+ this.#todoClearTimers.set(key, timer);
3324
3396
  }
3325
3397
  }
3326
3398
  }
3327
3399
 
3328
3400
  /** Remove a single completed task and notify the UI. */
3329
- #runTodoAutoClear(taskId: string): void {
3330
- this.#todoClearTimers.delete(taskId);
3401
+ #runTodoAutoClear(key: string): void {
3402
+ this.#todoClearTimers.delete(key);
3331
3403
  let removed = false;
3332
3404
  for (const phase of this.#todoPhases) {
3333
- const idx = phase.tasks.findIndex(t => t.id === taskId);
3405
+ const idx = phase.tasks.findIndex(t => todoClearKey(phase.name, t.content) === key);
3334
3406
  if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) {
3335
3407
  phase.tasks.splice(idx, 1);
3336
3408
  removed = true;
@@ -3358,9 +3430,13 @@ export class AgentSession {
3358
3430
  this.abortRetry();
3359
3431
  this.#promptGeneration++;
3360
3432
  this.#scheduledHiddenNextTurnGeneration = undefined;
3361
- this.#resolveTtsrResume();
3362
- this.#cancelPostPromptTasks();
3433
+ this.abortCompaction();
3434
+ this.abortHandoff();
3435
+ this.abortBash();
3436
+ this.abortPython();
3437
+ const postPromptDrain = this.#cancelPostPromptTasks();
3363
3438
  this.agent.abort();
3439
+ await postPromptDrain;
3364
3440
  await this.agent.waitForIdle();
3365
3441
  // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
3366
3442
  // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
@@ -3555,8 +3631,9 @@ export class AgentSession {
3555
3631
  );
3556
3632
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
3557
3633
 
3558
- // Re-apply the current thinking level for the newly selected model
3559
- this.setThinkingLevel(this.thinkingLevel);
3634
+ // Re-apply thinking for the newly selected model. Prefer the model's
3635
+ // configured defaultLevel; otherwise preserve the current level.
3636
+ this.setThinkingLevel(model.thinking?.defaultLevel ?? this.thinkingLevel);
3560
3637
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
3561
3638
  }
3562
3639
 
@@ -3577,8 +3654,9 @@ export class AgentSession {
3577
3654
  this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
3578
3655
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
3579
3656
 
3580
- // Apply explicit thinking level, or re-clamp current level to new model's capabilities
3581
- this.setThinkingLevel(thinkingLevel ?? this.thinkingLevel);
3657
+ // Apply explicit thinking level if given; otherwise prefer the model's
3658
+ // configured defaultLevel; otherwise re-clamp the current level.
3659
+ this.setThinkingLevel(thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel);
3582
3660
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
3583
3661
  }
3584
3662
 
@@ -3876,9 +3954,13 @@ export class AgentSession {
3876
3954
  * @param options Optional callbacks for completion/error handling
3877
3955
  */
3878
3956
  async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
3957
+ if (this.#compactionAbortController) {
3958
+ throw new Error("Compaction already in progress");
3959
+ }
3879
3960
  this.#disconnectFromAgent();
3880
3961
  await this.abort();
3881
- this.#compactionAbortController = new AbortController();
3962
+ const compactionAbortController = new AbortController();
3963
+ this.#compactionAbortController = compactionAbortController;
3882
3964
 
3883
3965
  try {
3884
3966
  if (!this.model) {
@@ -3916,7 +3998,7 @@ export class AgentSession {
3916
3998
  preparation,
3917
3999
  branchEntries: pathEntries,
3918
4000
  customInstructions,
3919
- signal: this.#compactionAbortController.signal,
4001
+ signal: compactionAbortController.signal,
3920
4002
  })) as SessionBeforeCompactResult | undefined;
3921
4003
 
3922
4004
  if (result?.cancel) {
@@ -3963,7 +4045,7 @@ export class AgentSession {
3963
4045
  compactionModel,
3964
4046
  apiKey,
3965
4047
  customInstructions,
3966
- this.#compactionAbortController.signal,
4048
+ compactionAbortController.signal,
3967
4049
  { promptOverride: hookPrompt, extraContext: hookContext, remoteInstructions: this.#baseSystemPrompt },
3968
4050
  );
3969
4051
  summary = result.summary;
@@ -3974,7 +4056,7 @@ export class AgentSession {
3974
4056
  preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
3975
4057
  }
3976
4058
 
3977
- if (this.#compactionAbortController.signal.aborted) {
4059
+ if (compactionAbortController.signal.aborted) {
3978
4060
  throw new Error("Compaction cancelled");
3979
4061
  }
3980
4062
 
@@ -4021,7 +4103,9 @@ export class AgentSession {
4021
4103
  options?.onError?.(err);
4022
4104
  throw error;
4023
4105
  } finally {
4024
- this.#compactionAbortController = undefined;
4106
+ if (this.#compactionAbortController === compactionAbortController) {
4107
+ this.#compactionAbortController = undefined;
4108
+ }
4025
4109
  this.#reconnectToAgent();
4026
4110
  }
4027
4111
  }
@@ -4488,7 +4572,7 @@ export class AgentSession {
4488
4572
  (task): task is TodoItem & { status: "pending" | "in_progress" } =>
4489
4573
  task.status === "pending" || task.status === "in_progress",
4490
4574
  )
4491
- .map(task => ({ id: task.id, content: task.content, status: task.status })),
4575
+ .map(task => ({ content: task.content, status: task.status })),
4492
4576
  }))
4493
4577
  .filter(phase => phase.tasks.length > 0);
4494
4578
  const incomplete = incompleteByPhase.flatMap(phase => phase.tasks);
@@ -5263,9 +5347,12 @@ export class AgentSession {
5263
5347
 
5264
5348
  #isTransientTransportErrorMessage(errorMessage: string): boolean {
5265
5349
  // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
5266
- // service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded
5267
- return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test(
5268
- errorMessage,
5350
+ // service unavailable, network/connection/socket errors, fetch failed, terminated, retry delay exceeded
5351
+ return (
5352
+ isUnexpectedSocketCloseMessage(errorMessage) ||
5353
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test(
5354
+ errorMessage,
5355
+ )
5269
5356
  );
5270
5357
  }
5271
5358
 
@@ -5608,15 +5695,16 @@ export class AgentSession {
5608
5695
  this.agent.replaceMessages(messages.slice(0, -1));
5609
5696
  }
5610
5697
 
5611
- // Wait with exponential backoff (abortable)
5612
- // Properly abort and null existing controller before replacing
5613
- if (this.#retryAbortController) {
5614
- this.#retryAbortController.abort();
5615
- }
5616
- this.#retryAbortController = new AbortController();
5698
+ // Wait with exponential backoff (abortable).
5699
+ const retryAbortController = new AbortController();
5700
+ this.#retryAbortController?.abort();
5701
+ this.#retryAbortController = retryAbortController;
5617
5702
  try {
5618
- await abortableSleep(delayMs, this.#retryAbortController.signal);
5703
+ await abortableSleep(delayMs, retryAbortController.signal);
5619
5704
  } catch {
5705
+ if (this.#retryAbortController !== retryAbortController) {
5706
+ return false;
5707
+ }
5620
5708
  // Aborted during sleep - emit end event so UI can clean up
5621
5709
  const attempt = this.#retryAttempt;
5622
5710
  this.#retryAttempt = 0;
@@ -5630,7 +5718,9 @@ export class AgentSession {
5630
5718
  this.#resolveRetry();
5631
5719
  return false;
5632
5720
  }
5633
- this.#retryAbortController = undefined;
5721
+ if (this.#retryAbortController === retryAbortController) {
5722
+ this.#retryAbortController = undefined;
5723
+ }
5634
5724
 
5635
5725
  // Retry via continue() outside the agent_end event callback chain.
5636
5726
  this.#scheduleAgentContinue({ delayMs: 1, generation });
@@ -5722,12 +5812,13 @@ export class AgentSession {
5722
5812
  }
5723
5813
  }
5724
5814
 
5725
- this.#bashAbortController = new AbortController();
5815
+ const abortController = new AbortController();
5816
+ this.#bashAbortControllers.add(abortController);
5726
5817
 
5727
5818
  try {
5728
5819
  const result = await executeBashCommand(command, {
5729
5820
  onChunk,
5730
- signal: this.#bashAbortController.signal,
5821
+ signal: abortController.signal,
5731
5822
  sessionKey: this.sessionId,
5732
5823
  timeout: clampTimeout("bash") * 1000,
5733
5824
  onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
@@ -5736,7 +5827,7 @@ export class AgentSession {
5736
5827
  this.recordBashResult(command, result, options);
5737
5828
  return result;
5738
5829
  } finally {
5739
- this.#bashAbortController = undefined;
5830
+ this.#bashAbortControllers.delete(abortController);
5740
5831
  }
5741
5832
  }
5742
5833
 
@@ -5775,12 +5866,14 @@ export class AgentSession {
5775
5866
  * Cancel running bash command.
5776
5867
  */
5777
5868
  abortBash(): void {
5778
- this.#bashAbortController?.abort();
5869
+ for (const abortController of this.#bashAbortControllers) {
5870
+ abortController.abort();
5871
+ }
5779
5872
  }
5780
5873
 
5781
5874
  /** Whether a bash command is currently running */
5782
5875
  get isBashRunning(): boolean {
5783
- return this.#bashAbortController !== undefined;
5876
+ return this.#bashAbortControllers.size > 0;
5784
5877
  }
5785
5878
 
5786
5879
  /** Whether there are pending bash messages waiting to be flushed */
@@ -6518,6 +6611,8 @@ export class AgentSession {
6518
6611
  cancelled: boolean;
6519
6612
  aborted?: boolean;
6520
6613
  summaryEntry?: BranchSummaryEntry;
6614
+ /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
6615
+ sessionContext?: SessionContext;
6521
6616
  }> {
6522
6617
  const oldLeafId = this.sessionManager.getLeafId();
6523
6618
 
@@ -6647,15 +6742,20 @@ export class AgentSession {
6647
6742
  this.sessionManager.branch(newLeafId);
6648
6743
  }
6649
6744
 
6650
- // Update agent state
6651
- const sessionContext = this.buildDisplaySessionContext();
6652
- await this.#restoreMCPSelectionsForSessionContext(sessionContext);
6653
- this.agent.replaceMessages(sessionContext.messages);
6745
+ // Update agent state — build display context to populate agent messages.
6746
+ const stateContext = this.sessionManager.buildSessionContext();
6747
+ const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
6748
+ await this.#restoreMCPSelectionsForSessionContext(displayContext);
6749
+ this.agent.replaceMessages(displayContext.messages);
6654
6750
  this.#syncTodoPhasesFromBranch();
6655
6751
  this.#closeCodexProviderSessionsForHistoryRewrite();
6656
6752
 
6657
- // Emit session_tree event
6658
- if (this.#extensionRunner) {
6753
+ this.#branchSummaryAbortController = undefined;
6754
+
6755
+ // Emit session_tree event; only handlers can mutate session entries, so skip
6756
+ // the emit and the context rebuild when no handlers are registered (mirrors
6757
+ // the session_before_tree guard above).
6758
+ if (this.#extensionRunner?.hasHandlers("session_tree")) {
6659
6759
  await this.#extensionRunner.emit({
6660
6760
  type: "session_tree",
6661
6761
  newLeafId: this.sessionManager.getLeafId(),
@@ -6663,10 +6763,10 @@ export class AgentSession {
6663
6763
  summaryEntry,
6664
6764
  fromExtension: summaryText ? fromExtension : undefined,
6665
6765
  });
6766
+ const rawContext = this.sessionManager.buildSessionContext();
6767
+ return { editorText, cancelled: false, summaryEntry, sessionContext: rawContext };
6666
6768
  }
6667
-
6668
- this.#branchSummaryAbortController = undefined;
6669
- return { editorText, cancelled: false, summaryEntry };
6769
+ return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext };
6670
6770
  }
6671
6771
 
6672
6772
  /**