@oh-my-pi/pi-coding-agent 14.5.8 → 14.5.10

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 (58) hide show
  1. package/CHANGELOG.md +56 -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 +14 -19
  7. package/src/discovery/claude-plugins.ts +28 -3
  8. package/src/edit/modes/atom.lark +7 -5
  9. package/src/edit/modes/atom.ts +510 -73
  10. package/src/edit/modes/hashline.ts +172 -91
  11. package/src/extensibility/extensions/runner.ts +34 -1
  12. package/src/extensibility/extensions/types.ts +8 -0
  13. package/src/lsp/client.ts +27 -35
  14. package/src/lsp/index.ts +2 -4
  15. package/src/lsp/render.ts +0 -3
  16. package/src/lsp/types.ts +1 -4
  17. package/src/lsp/utils.ts +18 -14
  18. package/src/memories/index.ts +5 -0
  19. package/src/modes/components/settings-defs.ts +1 -1
  20. package/src/modes/controllers/command-controller.ts +17 -0
  21. package/src/modes/controllers/input-controller.ts +7 -1
  22. package/src/modes/controllers/selector-controller.ts +2 -2
  23. package/src/modes/interactive-mode.ts +57 -26
  24. package/src/modes/theme/theme.ts +10 -1
  25. package/src/modes/types.ts +5 -3
  26. package/src/modes/utils/context-usage.ts +294 -0
  27. package/src/modes/utils/ui-helpers.ts +19 -6
  28. package/src/prompts/system/auto-continue.md +1 -0
  29. package/src/prompts/tools/atom.md +99 -44
  30. package/src/prompts/tools/exit-plan-mode.md +5 -39
  31. package/src/prompts/tools/github.md +3 -3
  32. package/src/prompts/tools/lsp.md +2 -3
  33. package/src/prompts/tools/{run-command.md → recipe.md} +1 -1
  34. package/src/prompts/tools/task.md +34 -147
  35. package/src/prompts/tools/todo-write.md +22 -64
  36. package/src/sdk.ts +13 -2
  37. package/src/session/agent-session.ts +175 -79
  38. package/src/session/compaction/compaction.ts +35 -22
  39. package/src/session/session-dump-format.ts +1 -0
  40. package/src/session/session-manager.ts +19 -2
  41. package/src/slash-commands/builtin-registry.ts +12 -5
  42. package/src/tools/bash.ts +9 -4
  43. package/src/tools/debug.ts +57 -70
  44. package/src/tools/gh.ts +267 -119
  45. package/src/tools/index.ts +7 -7
  46. package/src/tools/{run-command → recipe}/index.ts +19 -19
  47. package/src/tools/recipe/render.ts +19 -0
  48. package/src/tools/{run-command → recipe}/runner.ts +28 -7
  49. package/src/tools/{run-command → recipe}/runners/pkg.ts +23 -53
  50. package/src/tools/renderers.ts +2 -2
  51. package/src/utils/git.ts +61 -2
  52. package/src/web/search/providers/searxng.ts +71 -13
  53. package/src/tools/run-command/render.ts +0 -18
  54. /package/src/tools/{run-command → recipe}/runners/cargo.ts +0 -0
  55. /package/src/tools/{run-command → recipe}/runners/index.ts +0 -0
  56. /package/src/tools/{run-command → recipe}/runners/just.ts +0 -0
  57. /package/src/tools/{run-command → recipe}/runners/make.ts +0 -0
  58. /package/src/tools/{run-command → recipe}/runners/task.ts +0 -0
@@ -1,69 +1,33 @@
1
- Manages a phased task list through an `ops` array of flat operations.
2
- The next pending task is auto-promoted to `in_progress` after completing the current one.
1
+ Manages a phased task list. Pass `ops`: a flat array of operations.
2
+ The next pending task is auto-promoted to `in_progress` after each completion.
3
3
 
4
- <protocol>
5
- ## Shape
4
+ ## Operations
6
5
 
7
- Pass an object with an `ops` array:
8
-
9
- ```ts
10
- {
11
- ops: [
12
- { op: "replace", phases: [...] },
13
- { op: "start", task: "task-3" },
14
- { op: "done", phase: "Implementation" },
15
- { op: "rm" },
16
- { op: "drop", task: "task-9" },
17
- { op: "append", phase: "Implementation", items: [{ id: "task-10", label: "Run tests" }] },
18
- ],
19
- }
20
- ```
21
-
22
- ## Operation fields
23
-
24
- |Field|Type|When to use|
6
+ |`op`|Required fields|Effect|
25
7
  |---|---|---|
26
- |`op`|string|Required. One of `replace`, `start`, `done`, `rm`, `drop`, `append`, `note`|
27
- |`task`|string|Task id for `start`, or a task target for `done` / `rm` / `drop`|
28
- |`phase`|string|Phase target for `done` / `rm` / `drop`, or append destination for `append`|
29
- |`items`|{id, label}[]|Required for `append`. If the phase does not exist, it is created at the end|
30
- |`phases`|Phase[]|Only for `replace`. Keeps initial phased setup available for harness bootstrap and full restructures|
31
- |`text`|string|Required for `note`. The note text appended to `task.notes` (which is a list, joined with newlines on render)|
32
-
33
- ## Semantics
34
- - `start`: requires `task`; sets that task to `in_progress`
35
- - `done`: marks one task, one phase, or all tasks completed
36
- - `rm`: removes one task, one phase's tasks, or all tasks
37
- - `drop`: marks one task, one phase, or all tasks abandoned
38
- - `append`: appends `items` to `phase`; creates the phase if missing
39
- - `replace`: replaces the full todo list
40
- - `note`: append `text` as a new note attached to `task`. Notes are append-only context the user added; they only render to you when the task is `in_progress`. Other tasks display only a `+N` marker. Use this when you want to leave a follow-up reminder for yourself when you reach a later task.
41
-
42
- If `done`, `rm`, or `drop` omits both `task` and `phase`, it applies to all tasks.
43
-
44
- ## Task Anatomy
45
- - `label`: Short label (5-10 words). What is being done, not how.
46
- - `replace` task `content` should stay short and specific.
47
-
48
- ## Phase Anatomy
49
- - `name`: Short, human-readable noun phrase (1-3 words). Capitalize naturally.
50
- - Always prefix with a roman-numeral ordinal (`I.`, `II.`, `III.`, `IV.`, …) to convey ordering — e.g. `I. Foundation`, `II. Auth`, `III. Routing`. Single-phase plans use `I.` too.
51
- - You **MUST NOT** use snake_case, `Phase1_*`, arabic numerals (`1.`), or letter prefixes (`A.`) — they render as ugly identifiers.
8
+ |`replace`|`phases`|Replace the full list (initial setup, full restructure)|
9
+ |`start`|`task`|Set task to `in_progress`|
10
+ |`done`|`task` or `phase` (or neither = all)|Mark completed|
11
+ |`drop`|`task` or `phase` (or neither = all)|Mark abandoned|
12
+ |`rm`|`task` or `phase` (or neither = all)|Remove|
13
+ |`append`|`phase`, `items: {id, label}[]`|Append tasks; creates phase if missing|
14
+ |`note`|`task`, `text`|Append a note to `task.notes`. Only use to leave reminders for future-you.|
15
+
16
+ ## Anatomy
17
+ - **Task `label`**: 5–10 words, what is being done, not how.
18
+ - **Phase `name`**: short noun phrase prefixed with a roman numeral — `I. Foundation`, `II. Auth`, `III. Verification`. Single-phase plans still use `I.`. Never use snake_case, arabic numerals, or letter prefixes.
52
19
 
53
20
  ## Rules
54
21
  - Mark tasks done immediately after finishing — never defer.
55
- - Complete phases in order — do not skip ahead while earlier ones are pending.
56
- - On blockers, append a new task to the active phase.
22
+ - Complete phases in order.
23
+ - On blockers, `append` a new task to the active phase.
57
24
  - Keep ids stable once introduced.
58
- </protocol>
59
25
 
60
- <conditions>
61
- Create a todo list when:
62
- 1. Task requires 3+ distinct steps
63
- 2. User explicitly requests one
64
- 3. User provides a set of tasks to complete
65
- 4. New instructions arrive mid-task — capture before proceeding
66
- </conditions>
26
+ ## When to create a list
27
+ - Task requires 3+ distinct steps
28
+ - User explicitly requests one
29
+ - User provides a set of tasks to complete
30
+ - New instructions arrive mid-task capture before proceeding
67
31
 
68
32
  <examples>
69
33
  # Initial setup (multi-phase)
@@ -81,9 +45,3 @@ Create a todo list when:
81
45
  # Append tasks to a phase
82
46
  `{"ops":[{"op":"append","phase":"II. Auth","items":[{"id":"task-8","label":"Handle retries"},{"id":"task-9","label":"Run tests"}]}]}`
83
47
  </examples>
84
-
85
- <avoid>
86
- - Single-step tasks — act directly
87
- - Conversational or informational requests
88
- - Tasks completable in under 3 trivial steps
89
- </avoid>
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 */
@@ -469,7 +473,7 @@ export class AgentSession {
469
473
  #toolChoiceQueue = new ToolChoiceQueue();
470
474
 
471
475
  // Bash execution state
472
- #bashAbortController: AbortController | undefined = undefined;
476
+ #bashAbortControllers = new Set<AbortController>();
473
477
  #pendingBashMessages: BashExecutionMessage[] = [];
474
478
 
475
479
  // Python execution state
@@ -507,6 +511,7 @@ export class AgentSession {
507
511
  #toolRegistry: Map<string, AgentTool>;
508
512
  #transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
509
513
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
514
+ #onResponse: SimpleStreamOptions["onResponse"] | undefined;
510
515
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
511
516
  #rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
512
517
  #baseSystemPrompt: string;
@@ -526,8 +531,7 @@ export class AgentSession {
526
531
  #ttsrRetryToken = 0;
527
532
  #ttsrResumePromise: Promise<void> | undefined = undefined;
528
533
  #ttsrResumeResolve: (() => void) | undefined = undefined;
529
- #postPromptTaskCounter = 0;
530
- #postPromptTaskIds = new Set<number>();
534
+ #postPromptTasks = new Set<Promise<void>>();
531
535
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
532
536
  #postPromptTasksResolve: (() => void) | undefined = undefined;
533
537
  #postPromptTasksAbortController = new AbortController();
@@ -593,6 +597,7 @@ export class AgentSession {
593
597
  this.#toolRegistry = config.toolRegistry ?? new Map();
594
598
  this.#transformContext = config.transformContext ?? (messages => messages);
595
599
  this.#onPayload = config.onPayload;
600
+ this.#onResponse = config.onResponse;
596
601
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
597
602
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
598
603
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
@@ -1144,14 +1149,13 @@ export class AgentSession {
1144
1149
  }
1145
1150
 
1146
1151
  #trackPostPromptTask(task: Promise<void>): void {
1147
- const taskId = ++this.#postPromptTaskCounter;
1148
- this.#postPromptTaskIds.add(taskId);
1152
+ this.#postPromptTasks.add(task);
1149
1153
  this.#ensurePostPromptTasksPromise();
1150
1154
  void task
1151
1155
  .catch(() => {})
1152
1156
  .finally(() => {
1153
- this.#postPromptTaskIds.delete(taskId);
1154
- if (this.#postPromptTaskIds.size === 0) {
1157
+ this.#postPromptTasks.delete(task);
1158
+ if (this.#postPromptTasks.size === 0) {
1155
1159
  this.#resolvePostPromptTasks();
1156
1160
  }
1157
1161
  });
@@ -1217,11 +1221,11 @@ export class AgentSession {
1217
1221
  await this.#promptWithMessage(
1218
1222
  {
1219
1223
  role: "developer",
1220
- content: [{ type: "text", text: "Continue if you have next steps." }],
1224
+ content: [{ type: "text", text: autoContinuePrompt }],
1221
1225
  attribution: "agent",
1222
1226
  timestamp: Date.now(),
1223
1227
  },
1224
- "Continue if you have next steps.",
1228
+ autoContinuePrompt,
1225
1229
  { skipPostPromptRecoveryWait: true },
1226
1230
  );
1227
1231
  };
@@ -1235,11 +1239,21 @@ export class AgentSession {
1235
1239
  );
1236
1240
  }
1237
1241
 
1238
- #cancelPostPromptTasks(): void {
1242
+ async #cancelPostPromptTasks(): Promise<void> {
1239
1243
  this.#postPromptTasksAbortController.abort();
1240
1244
  this.#postPromptTasksAbortController = new AbortController();
1241
- this.#postPromptTaskIds.clear();
1242
- this.#resolvePostPromptTasks();
1245
+ this.#resolveTtsrResume();
1246
+
1247
+ const pendingTasks = Array.from(this.#postPromptTasks);
1248
+ if (pendingTasks.length === 0) {
1249
+ this.#resolvePostPromptTasks();
1250
+ return;
1251
+ }
1252
+
1253
+ await Promise.allSettled(pendingTasks);
1254
+ if (this.#postPromptTasks.size === 0) {
1255
+ this.#resolvePostPromptTasks();
1256
+ }
1243
1257
  }
1244
1258
  /**
1245
1259
  * Wait for retry, TTSR resume, and any background continuation to settle.
@@ -1523,10 +1537,19 @@ export class AgentSession {
1523
1537
  const path = typeof args.path === "string" ? args.path : undefined;
1524
1538
  if (!path) return undefined;
1525
1539
 
1540
+ // `local://` URLs (e.g. local://PLAN.md for plan-mode) resolve to a real
1541
+ // on-disk artifacts path; pre-caching works as long as we ask the
1542
+ // local-protocol handler. Other internal-scheme URLs (agent://, skill://,
1543
+ // rule://, mcp://, artifact://) have no stable filesystem representation;
1544
+ // skip pre-cache entirely for those — the edit tool itself will reject
1545
+ // them through its normal dispatch path.
1546
+ const resolvedPath = this.#resolveSessionFsPath(path);
1547
+ if (resolvedPath === undefined) return undefined;
1548
+
1526
1549
  return {
1527
1550
  toolCall,
1528
1551
  path,
1529
- resolvedPath: resolveToCwd(path, this.sessionManager.getCwd()),
1552
+ resolvedPath,
1530
1553
  diff: typeof args.diff === "string" ? args.diff : undefined,
1531
1554
  op: typeof args.op === "string" ? args.op : undefined,
1532
1555
  rename: typeof args.rename === "string" ? args.rename : undefined,
@@ -1600,11 +1623,47 @@ export class AgentSession {
1600
1623
  }
1601
1624
 
1602
1625
  /** 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());
1626
+ #invalidateFileCacheForPath(filePath: string): void {
1627
+ const resolvedPath = this.#resolveSessionFsPath(filePath);
1628
+ if (resolvedPath === undefined) return;
1605
1629
  this.#streamingEditFileCache.delete(resolvedPath);
1606
1630
  }
1607
1631
 
1632
+ /**
1633
+ * Resolve a path supplied to a tool to a real filesystem path.
1634
+ *
1635
+ * - `local://` URLs route through the local-protocol handler so they map
1636
+ * onto the session's on-disk artifacts directory; pre-caching, ENOENT
1637
+ * handling, and post-edit invalidation all work normally.
1638
+ * - Other internal-scheme URLs (agent://, skill://, rule://, mcp://,
1639
+ * artifact://) have no stable filesystem path; this returns `undefined`
1640
+ * so callers skip filesystem-only operations.
1641
+ * - Cwd-relative and absolute paths resolve via `resolveToCwd`.
1642
+ */
1643
+ #resolveSessionFsPath(filePath: string): string | undefined {
1644
+ const normalized = normalizeLocalScheme(filePath);
1645
+ if (normalized.startsWith("local:")) {
1646
+ return resolveLocalUrlToPath(normalized, this.#localProtocolOptions());
1647
+ }
1648
+ if (
1649
+ normalized.startsWith("agent://") ||
1650
+ normalized.startsWith("skill://") ||
1651
+ normalized.startsWith("rule://") ||
1652
+ normalized.startsWith("mcp://") ||
1653
+ normalized.startsWith("artifact://")
1654
+ ) {
1655
+ return undefined;
1656
+ }
1657
+ return resolveToCwd(normalized, this.sessionManager.getCwd());
1658
+ }
1659
+
1660
+ #localProtocolOptions(): LocalProtocolOptions {
1661
+ return {
1662
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1663
+ getSessionId: () => this.sessionManager.getSessionId(),
1664
+ };
1665
+ }
1666
+
1608
1667
  #maybeAbortStreamingEdit(event: AgentEvent): void {
1609
1668
  if (!this.settings.get("edit.streamingAbort")) return;
1610
1669
  if (this.#streamingEditAbortTriggered) return;
@@ -1892,7 +1951,7 @@ export class AgentSession {
1892
1951
  } catch (error) {
1893
1952
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
1894
1953
  }
1895
- this.#cancelPostPromptTasks();
1954
+ await this.#cancelPostPromptTasks();
1896
1955
  this.#clearTodoClearTimers();
1897
1956
  const drained = await this.#asyncJobManager?.dispose({ timeoutMs: 3_000 });
1898
1957
  const deliveryState = this.#asyncJobManager?.getDeliveryState();
@@ -2318,21 +2377,39 @@ export class AgentSession {
2318
2377
 
2319
2378
  /** Apply session-level stream hooks to a direct side request. */
2320
2379
  prepareSimpleStreamOptions(options: SimpleStreamOptions): SimpleStreamOptions {
2321
- if (!this.#onPayload) return options;
2322
- if (!options.onPayload) {
2323
- return { ...options, onPayload: this.#onPayload };
2324
- }
2325
2380
  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
- };
2381
+ const sessionOnResponse = this.#onResponse;
2382
+ if (!sessionOnPayload && !sessionOnResponse) return options;
2383
+
2384
+ const preparedOptions: SimpleStreamOptions = { ...options };
2385
+
2386
+ if (sessionOnPayload) {
2387
+ if (!options.onPayload) {
2388
+ preparedOptions.onPayload = sessionOnPayload;
2389
+ } else {
2390
+ const requestOnPayload = options.onPayload;
2391
+ preparedOptions.onPayload = async (payload, model) => {
2392
+ const sessionPayload = await sessionOnPayload(payload, model);
2393
+ const sessionResolvedPayload = sessionPayload ?? payload;
2394
+ const requestPayload = await requestOnPayload(sessionResolvedPayload, model);
2395
+ return requestPayload ?? sessionResolvedPayload;
2396
+ };
2397
+ }
2398
+ }
2399
+
2400
+ if (sessionOnResponse) {
2401
+ if (!options.onResponse) {
2402
+ preparedOptions.onResponse = sessionOnResponse;
2403
+ } else {
2404
+ const requestOnResponse = options.onResponse;
2405
+ preparedOptions.onResponse = async (response, model) => {
2406
+ await sessionOnResponse(response, model);
2407
+ await requestOnResponse(response, model);
2408
+ };
2409
+ }
2410
+ }
2411
+
2412
+ return preparedOptions;
2336
2413
  }
2337
2414
 
2338
2415
  /** Current steering mode */
@@ -2466,10 +2543,7 @@ export class AgentSession {
2466
2543
  if (this.#planReferenceSent) return null;
2467
2544
 
2468
2545
  const planFilePath = this.#planReferencePath;
2469
- const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, {
2470
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2471
- getSessionId: () => this.sessionManager.getSessionId(),
2472
- });
2546
+ const resolvedPlanPath = resolveLocalUrlToPath(planFilePath, this.#localProtocolOptions());
2473
2547
  let planContent: string;
2474
2548
  try {
2475
2549
  planContent = await Bun.file(resolvedPlanPath).text();
@@ -2502,15 +2576,9 @@ export class AgentSession {
2502
2576
  if (!state?.enabled) return null;
2503
2577
  const sessionPlanUrl = "local://PLAN.md";
2504
2578
  const resolvedPlanPath = state.planFilePath.startsWith("local:")
2505
- ? resolveLocalUrlToPath(normalizeLocalScheme(state.planFilePath), {
2506
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2507
- getSessionId: () => this.sessionManager.getSessionId(),
2508
- })
2579
+ ? resolveLocalUrlToPath(normalizeLocalScheme(state.planFilePath), this.#localProtocolOptions())
2509
2580
  : resolveToCwd(state.planFilePath, this.sessionManager.getCwd());
2510
- const resolvedSessionPlan = resolveLocalUrlToPath(sessionPlanUrl, {
2511
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2512
- getSessionId: () => this.sessionManager.getSessionId(),
2513
- });
2581
+ const resolvedSessionPlan = resolveLocalUrlToPath(sessionPlanUrl, this.#localProtocolOptions());
2514
2582
  const displayPlanPath =
2515
2583
  state.planFilePath.startsWith("local:") || resolvedPlanPath !== resolvedSessionPlan
2516
2584
  ? state.planFilePath
@@ -3358,9 +3426,13 @@ export class AgentSession {
3358
3426
  this.abortRetry();
3359
3427
  this.#promptGeneration++;
3360
3428
  this.#scheduledHiddenNextTurnGeneration = undefined;
3361
- this.#resolveTtsrResume();
3362
- this.#cancelPostPromptTasks();
3429
+ this.abortCompaction();
3430
+ this.abortHandoff();
3431
+ this.abortBash();
3432
+ this.abortPython();
3433
+ const postPromptDrain = this.#cancelPostPromptTasks();
3363
3434
  this.agent.abort();
3435
+ await postPromptDrain;
3364
3436
  await this.agent.waitForIdle();
3365
3437
  // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
3366
3438
  // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
@@ -3555,8 +3627,9 @@ export class AgentSession {
3555
3627
  );
3556
3628
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
3557
3629
 
3558
- // Re-apply the current thinking level for the newly selected model
3559
- this.setThinkingLevel(this.thinkingLevel);
3630
+ // Re-apply thinking for the newly selected model. Prefer the model's
3631
+ // configured defaultLevel; otherwise preserve the current level.
3632
+ this.setThinkingLevel(model.thinking?.defaultLevel ?? this.thinkingLevel);
3560
3633
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
3561
3634
  }
3562
3635
 
@@ -3577,8 +3650,9 @@ export class AgentSession {
3577
3650
  this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
3578
3651
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
3579
3652
 
3580
- // Apply explicit thinking level, or re-clamp current level to new model's capabilities
3581
- this.setThinkingLevel(thinkingLevel ?? this.thinkingLevel);
3653
+ // Apply explicit thinking level if given; otherwise prefer the model's
3654
+ // configured defaultLevel; otherwise re-clamp the current level.
3655
+ this.setThinkingLevel(thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel);
3582
3656
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
3583
3657
  }
3584
3658
 
@@ -3876,9 +3950,13 @@ export class AgentSession {
3876
3950
  * @param options Optional callbacks for completion/error handling
3877
3951
  */
3878
3952
  async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
3953
+ if (this.#compactionAbortController) {
3954
+ throw new Error("Compaction already in progress");
3955
+ }
3879
3956
  this.#disconnectFromAgent();
3880
3957
  await this.abort();
3881
- this.#compactionAbortController = new AbortController();
3958
+ const compactionAbortController = new AbortController();
3959
+ this.#compactionAbortController = compactionAbortController;
3882
3960
 
3883
3961
  try {
3884
3962
  if (!this.model) {
@@ -3916,7 +3994,7 @@ export class AgentSession {
3916
3994
  preparation,
3917
3995
  branchEntries: pathEntries,
3918
3996
  customInstructions,
3919
- signal: this.#compactionAbortController.signal,
3997
+ signal: compactionAbortController.signal,
3920
3998
  })) as SessionBeforeCompactResult | undefined;
3921
3999
 
3922
4000
  if (result?.cancel) {
@@ -3963,7 +4041,7 @@ export class AgentSession {
3963
4041
  compactionModel,
3964
4042
  apiKey,
3965
4043
  customInstructions,
3966
- this.#compactionAbortController.signal,
4044
+ compactionAbortController.signal,
3967
4045
  { promptOverride: hookPrompt, extraContext: hookContext, remoteInstructions: this.#baseSystemPrompt },
3968
4046
  );
3969
4047
  summary = result.summary;
@@ -3974,7 +4052,7 @@ export class AgentSession {
3974
4052
  preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
3975
4053
  }
3976
4054
 
3977
- if (this.#compactionAbortController.signal.aborted) {
4055
+ if (compactionAbortController.signal.aborted) {
3978
4056
  throw new Error("Compaction cancelled");
3979
4057
  }
3980
4058
 
@@ -4021,7 +4099,9 @@ export class AgentSession {
4021
4099
  options?.onError?.(err);
4022
4100
  throw error;
4023
4101
  } finally {
4024
- this.#compactionAbortController = undefined;
4102
+ if (this.#compactionAbortController === compactionAbortController) {
4103
+ this.#compactionAbortController = undefined;
4104
+ }
4025
4105
  this.#reconnectToAgent();
4026
4106
  }
4027
4107
  }
@@ -5263,9 +5343,12 @@ export class AgentSession {
5263
5343
 
5264
5344
  #isTransientTransportErrorMessage(errorMessage: string): boolean {
5265
5345
  // 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,
5346
+ // service unavailable, network/connection/socket errors, fetch failed, terminated, retry delay exceeded
5347
+ return (
5348
+ isUnexpectedSocketCloseMessage(errorMessage) ||
5349
+ /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(
5350
+ errorMessage,
5351
+ )
5269
5352
  );
5270
5353
  }
5271
5354
 
@@ -5608,15 +5691,16 @@ export class AgentSession {
5608
5691
  this.agent.replaceMessages(messages.slice(0, -1));
5609
5692
  }
5610
5693
 
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();
5694
+ // Wait with exponential backoff (abortable).
5695
+ const retryAbortController = new AbortController();
5696
+ this.#retryAbortController?.abort();
5697
+ this.#retryAbortController = retryAbortController;
5617
5698
  try {
5618
- await abortableSleep(delayMs, this.#retryAbortController.signal);
5699
+ await abortableSleep(delayMs, retryAbortController.signal);
5619
5700
  } catch {
5701
+ if (this.#retryAbortController !== retryAbortController) {
5702
+ return false;
5703
+ }
5620
5704
  // Aborted during sleep - emit end event so UI can clean up
5621
5705
  const attempt = this.#retryAttempt;
5622
5706
  this.#retryAttempt = 0;
@@ -5630,7 +5714,9 @@ export class AgentSession {
5630
5714
  this.#resolveRetry();
5631
5715
  return false;
5632
5716
  }
5633
- this.#retryAbortController = undefined;
5717
+ if (this.#retryAbortController === retryAbortController) {
5718
+ this.#retryAbortController = undefined;
5719
+ }
5634
5720
 
5635
5721
  // Retry via continue() outside the agent_end event callback chain.
5636
5722
  this.#scheduleAgentContinue({ delayMs: 1, generation });
@@ -5722,12 +5808,13 @@ export class AgentSession {
5722
5808
  }
5723
5809
  }
5724
5810
 
5725
- this.#bashAbortController = new AbortController();
5811
+ const abortController = new AbortController();
5812
+ this.#bashAbortControllers.add(abortController);
5726
5813
 
5727
5814
  try {
5728
5815
  const result = await executeBashCommand(command, {
5729
5816
  onChunk,
5730
- signal: this.#bashAbortController.signal,
5817
+ signal: abortController.signal,
5731
5818
  sessionKey: this.sessionId,
5732
5819
  timeout: clampTimeout("bash") * 1000,
5733
5820
  onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
@@ -5736,7 +5823,7 @@ export class AgentSession {
5736
5823
  this.recordBashResult(command, result, options);
5737
5824
  return result;
5738
5825
  } finally {
5739
- this.#bashAbortController = undefined;
5826
+ this.#bashAbortControllers.delete(abortController);
5740
5827
  }
5741
5828
  }
5742
5829
 
@@ -5775,12 +5862,14 @@ export class AgentSession {
5775
5862
  * Cancel running bash command.
5776
5863
  */
5777
5864
  abortBash(): void {
5778
- this.#bashAbortController?.abort();
5865
+ for (const abortController of this.#bashAbortControllers) {
5866
+ abortController.abort();
5867
+ }
5779
5868
  }
5780
5869
 
5781
5870
  /** Whether a bash command is currently running */
5782
5871
  get isBashRunning(): boolean {
5783
- return this.#bashAbortController !== undefined;
5872
+ return this.#bashAbortControllers.size > 0;
5784
5873
  }
5785
5874
 
5786
5875
  /** Whether there are pending bash messages waiting to be flushed */
@@ -6518,6 +6607,8 @@ export class AgentSession {
6518
6607
  cancelled: boolean;
6519
6608
  aborted?: boolean;
6520
6609
  summaryEntry?: BranchSummaryEntry;
6610
+ /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
6611
+ sessionContext?: SessionContext;
6521
6612
  }> {
6522
6613
  const oldLeafId = this.sessionManager.getLeafId();
6523
6614
 
@@ -6647,15 +6738,20 @@ export class AgentSession {
6647
6738
  this.sessionManager.branch(newLeafId);
6648
6739
  }
6649
6740
 
6650
- // Update agent state
6651
- const sessionContext = this.buildDisplaySessionContext();
6652
- await this.#restoreMCPSelectionsForSessionContext(sessionContext);
6653
- this.agent.replaceMessages(sessionContext.messages);
6741
+ // Update agent state — build display context to populate agent messages.
6742
+ const stateContext = this.sessionManager.buildSessionContext();
6743
+ const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
6744
+ await this.#restoreMCPSelectionsForSessionContext(displayContext);
6745
+ this.agent.replaceMessages(displayContext.messages);
6654
6746
  this.#syncTodoPhasesFromBranch();
6655
6747
  this.#closeCodexProviderSessionsForHistoryRewrite();
6656
6748
 
6657
- // Emit session_tree event
6658
- if (this.#extensionRunner) {
6749
+ this.#branchSummaryAbortController = undefined;
6750
+
6751
+ // Emit session_tree event; only handlers can mutate session entries, so skip
6752
+ // the emit and the context rebuild when no handlers are registered (mirrors
6753
+ // the session_before_tree guard above).
6754
+ if (this.#extensionRunner?.hasHandlers("session_tree")) {
6659
6755
  await this.#extensionRunner.emit({
6660
6756
  type: "session_tree",
6661
6757
  newLeafId: this.sessionManager.getLeafId(),
@@ -6663,10 +6759,10 @@ export class AgentSession {
6663
6759
  summaryEntry,
6664
6760
  fromExtension: summaryText ? fromExtension : undefined,
6665
6761
  });
6762
+ const rawContext = this.sessionManager.buildSessionContext();
6763
+ return { editorText, cancelled: false, summaryEntry, sessionContext: rawContext };
6666
6764
  }
6667
-
6668
- this.#branchSummaryAbortController = undefined;
6669
- return { editorText, cancelled: false, summaryEntry };
6765
+ return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext };
6670
6766
  }
6671
6767
 
6672
6768
  /**