@oh-my-pi/pi-coding-agent 16.2.4 → 16.2.6

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 (68) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli.js +3211 -3231
  3. package/dist/types/cli/bench-cli.d.ts +6 -1
  4. package/dist/types/commands/bench.d.ts +8 -0
  5. package/dist/types/config/model-discovery.d.ts +6 -1
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/edit/index.d.ts +1 -0
  8. package/dist/types/edit/renderer.d.ts +4 -0
  9. package/dist/types/edit/snapshot-details.d.ts +33 -0
  10. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  11. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  12. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  13. package/dist/types/modes/theme/theme.d.ts +2 -1
  14. package/dist/types/session/agent-session.d.ts +11 -0
  15. package/dist/types/session/session-manager.d.ts +3 -4
  16. package/dist/types/task/provider-concurrency.d.ts +40 -0
  17. package/dist/types/utils/git.d.ts +11 -0
  18. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  19. package/dist/types/web/search/types.d.ts +1 -1
  20. package/package.json +12 -12
  21. package/src/cli/args.ts +32 -1
  22. package/src/cli/bench-cli.ts +89 -21
  23. package/src/cli/web-search-cli.ts +6 -1
  24. package/src/cli/worktree-cli.ts +8 -5
  25. package/src/commands/bench.ts +10 -1
  26. package/src/config/mcp-schema.json +1 -1
  27. package/src/config/model-discovery.ts +98 -20
  28. package/src/config/model-registry.ts +13 -6
  29. package/src/config/settings-schema.ts +5 -2
  30. package/src/edit/hashline/execute.ts +15 -9
  31. package/src/edit/index.ts +19 -6
  32. package/src/edit/modes/patch.ts +3 -2
  33. package/src/edit/modes/replace.ts +3 -2
  34. package/src/edit/renderer.ts +4 -0
  35. package/src/edit/snapshot-details.ts +77 -0
  36. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  37. package/src/internal-urls/docs-index.generated.txt +1 -1
  38. package/src/mcp/oauth-flow.ts +10 -8
  39. package/src/mcp/transports/stdio.ts +9 -17
  40. package/src/modes/components/status-line/component.ts +29 -2
  41. package/src/modes/components/status-line/segments.ts +22 -8
  42. package/src/modes/components/status-line/types.ts +7 -0
  43. package/src/modes/controllers/event-controller.ts +17 -8
  44. package/src/modes/controllers/input-controller.ts +1 -1
  45. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  46. package/src/modes/theme/theme.ts +6 -0
  47. package/src/prompts/bench.md +4 -10
  48. package/src/prompts/tools/irc.md +19 -29
  49. package/src/prompts/tools/job.md +8 -14
  50. package/src/prompts/tools/lsp.md +19 -30
  51. package/src/prompts/tools/task.md +42 -62
  52. package/src/sdk.ts +14 -5
  53. package/src/session/agent-session.ts +107 -13
  54. package/src/session/session-listing.ts +9 -8
  55. package/src/session/session-loader.ts +98 -3
  56. package/src/session/session-manager.ts +34 -4
  57. package/src/subprocess/worker-client.ts +12 -4
  58. package/src/task/executor.ts +4 -62
  59. package/src/task/provider-concurrency.ts +100 -0
  60. package/src/task/worktree.ts +13 -4
  61. package/src/task/yield-assembly.ts +27 -39
  62. package/src/tools/path-utils.ts +4 -2
  63. package/src/tools/read.ts +11 -1
  64. package/src/utils/git.ts +13 -0
  65. package/src/web/search/index.ts +14 -8
  66. package/src/web/search/providers/duckduckgo.ts +136 -78
  67. package/src/web/search/providers/gemini.ts +268 -185
  68. package/src/web/search/types.ts +1 -1
@@ -1,39 +1,28 @@
1
- Language Server Protocol (LSP) servers for code intelligence.
1
+ Symbol-aware code intelligence from language servers — the accurate path for navigation, refactors, and diagnostics where text search or edits would miss callsites.
2
2
 
3
3
  <operations>
4
- - `diagnostics`: errors/warnings for a file, glob, or workspace (`file: "*"`)
5
- - `definition`: symbol definition
6
- - `type_definition`: symbol's type definition
7
- - `implementation`: concrete implementations
8
- - `references`: all references
9
- - `hover`: type info / docs
10
- - `symbols`: list file symbols, or search workspace with `file: "*"` + `query`
11
- - `rename`: rename symbol codebase-wide
12
- - `rename_file`: rename/move a file/directory; updates import paths + other references
13
- - `code_actions`: list quick-fixes/refactors/import actions; apply one when `apply: true` + `query` matches title or index
14
- - `status`: active language servers
15
- - `capabilities`: per-server capabilities
16
- - `request`: raw LSP request — `query` = method name (e.g. `rust-analyzer/expandMacro`, `workspace/executeCommand`); `payload` = JSON params
17
- - `reload`: restart one server (via `file`) or all (`file: "*"`)
18
- </operations>
4
+ Position-based — pass `file` + `line` + `symbol` (substring on that line; append `#N` for the Nth match, e.g. `kind#2`):
5
+ - `definition`, `type_definition`, `implementation`, `references`, `hover` — standard LSP lookups
6
+ - `rename` — rename the symbol everywhere; **applies by default**, `apply: false` previews; needs `new_name`
7
+ - `code_actions` quick-fixes/refactors/imports at that position; lists by default (`query` filters by kind, e.g. `quickfix`, `source.organizeImports`), **applies one only with `apply: true` + `query`** (then `query` = action title substring or numeric index)
8
+
9
+ File / workspace:
10
+ - `diagnostics` errors/warnings for a path, a glob (`src/**/*.ts`), or the whole workspace (`file: "*"`)
11
+ - `symbols` `file` lists that file's symbols; `file: "*"` + `query` searches the workspace
12
+ - `rename_file` move `file` → `new_name` on disk AND rewrite imports/references through the server; applies by default
19
13
 
20
- <parameters>
21
- - `file`: path, glob (e.g. `src/**/*.ts`), or `"*"` for workspace scope
22
- - `line`: 1-indexed line for position-based actions
23
- - `symbol`: substring on the target line. Append `#N` for the Nth occurrence e.g. `foo#2` = second `foo`.
24
- - `query`: symbol search, code-action kind filter/selector (list/apply mode), or LSP method name when `action: request`
25
- - `new_name`: required for `rename` (new identifier) and `rename_file` (destination path)
26
- - `apply`: apply edits for rename/rename_file/code_actions (default true for rename/rename_file; code_actions list mode unless true)
27
- - `payload`: JSON params for `action: request`
28
- - `timeout`: seconds
29
- </parameters>
14
+ Servers:
15
+ - `status`, `capabilities` what's running / per-server capabilities (one via `file`, all via `*`)
16
+ - `reload` restart one server (`file`) or all (`*`); `reload *` also re-reads project LSP config
17
+ - `request` raw escape hatch: `query` = method (`rust-analyzer/expandMacro`, `workspace/executeCommand`), `payload` = JSON params (else auto-built from `file`/`line`/`symbol`)
18
+ </operations>
30
19
 
31
20
  <caution>
32
- - Missing `symbol` or out-of-bounds `#N` explicit error.
21
+ - `line` is 1-indexed. Project-aware `definition`/`references`/`rename` ERROR without `symbol` rather than guess the wrong identifier; a missing match or out-of-range `#N` is an explicit error, never a silent fallback.
33
22
  </caution>
34
23
 
35
24
  <critical>
36
- - You MUST use `lsp` for symbol-aware operations (rename, references, definition/implementation, code actions) whenever a language server is available — safer and more accurate than text-based alternatives.
37
- - You NEVER perform cross-file renames with `ast_edit`, `sed`, or manual edits when `lsp` `rename` can do it. Text-based renames miss shadowing, re-exports, and cross-file usages.
38
- - You SHOULD use `lsp` `code_actions` for imports, quick-fixes, and refactors the server already applies.
25
+ - Symbol-aware work (rename, references, definition/type/impl, code actions) MUST use `lsp` whenever a server is available — it follows shadowing, re-exports, and cross-file usages that text tools miss.
26
+ - NEVER do a cross-file rename with `ast_edit`, `sed`, or hand edits when `lsp` `rename`/`rename_file` can text renames silently drop callsites.
27
+ - Reach for `code_actions` on imports, quick-fixes, and server-known refactors before editing by hand.
39
28
  </critical>
@@ -1,91 +1,71 @@
1
- {{#if asyncEnabled}}{{#if batchEnabled}}Spawns subagents to work in the background one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Spawns ONE subagent per call to work in the background.{{/if}}
1
+ {{#if asyncEnabled}}{{#if batchEnabled}}Delegate work to background subagents by passing multiple items in a single `tasks[]` batch.{{else}}Delegate work to ONE background subagent per call.{{/if}}
2
+ Execution does not block your turn: you receive agent and job IDs immediately, and the final results deliver themselves when the subagents finish.{{else}}{{#if batchEnabled}}Run subagents synchronously by passing items in a `tasks[]` batch.{{else}}Run ONE subagent synchronously per call.{{/if}}
3
+ Execution blocks your turn: the call only returns once the work is completely finished.{{/if}}
2
4
 
3
- - Spawning is non-blocking: the call returns immediately with the agent id{{#if batchEnabled}}s{{/if}} and job id{{#if batchEnabled}}s{{/if}}; each result is delivered automatically when that agent yields.
4
- - Parallelism = {{#if batchEnabled}}multiple `tasks[]` items in ONE call. MUST batch into one `tasks[]` (share `context` once). Separate `task` calls ONLY for a different `agent` type or unrelated `context`{{else}}multiple `task` calls in one assistant message{{/if}}.
5
- - If genuinely blocked on a result, wait with `job poll`; otherwise keep working. `job cancel` terminates a task and **cannot carry a message** only for stalled/abandoned work.
6
- {{else}}{{#if batchEnabled}}Runs subagents synchronously one per `tasks[]` item; a single spawn is a one-item batch.{{else}}Runs ONE subagent synchronously per call.{{/if}}
5
+ # Delegation Strategy
6
+ - **Maximize parallelism:** Break work into the widest possible {{#if batchEnabled}}array of `tasks[]`{{else}}set of parallel `task` calls{{/if}}. NEVER serialize work that can run concurrently. Tasks touching different files or independent refactors should run in parallel; agents resolve their own file collisions live.
7
+ - **Sequence only when necessary:** The only reason to run A before B is if B strictly requires A's output to function (e.g., a core API contract or schema migration). {{#if ircEnabled}}If the missing piece is small, run them in parallel and have B ask A via `irc`!{{/if}}
8
+ - **Role matching:** Assign each subagent a specific `role` (e.g. "Security Reviewer", "DB Migrator"). Do not spawn generic workers.
9
+ - **No overhead:** Each assignment MUST instruct its agent to skip formatters, linters, and project-wide test suites. You will run those once at the end.
10
+ - **Do your own thinking:** NEVER assign reasoning, architecture, or design to `quick_task` or `explore`. They are for mechanical lookups only. Keep hard decisions in your own context or use `task`, `plan`, or `oracle`.
11
+ - **One-pass agents:** Prefer agents that investigate **and** edit in a single pass; only spin a read-only discovery step (e.g. `explore`) when the affected files are genuinely unknown.
7
12
 
8
- - Spawning is blocking: the call returns only after the agent{{#if batchEnabled}}s{{/if}} finish; results arrive inline.
9
- - Parallelism = {{#if batchEnabled}}multiple `tasks[]` items in ONE call. MUST batch into one `tasks[]` (share `context` once). Separate `task` calls ONLY for a different `agent` type or unrelated `context`{{else}}multiple `task` calls in one assistant message{{/if}}.
10
- {{/if}}
11
- {{#if ircEnabled}}
12
- - Coordinate with agents via `irc` using their ids. Agents reach you and their siblings live the same way.
13
- {{/if}}
14
-
15
- <parameters>
16
- - `agent`: agent type to spawn
13
+ # Inputs
14
+ - `agent`: The base agent type to use (e.g., `task`, `explore`).
17
15
  {{#if batchEnabled}}
18
- - `context`: shared background prepended to every assignment goal, constraints, shared contract (see context-fmt); REQUIRED, session-specific only
19
- - `tasks`: tasks to spawn — one subagent per item, all in parallel:
20
- - `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED
21
- - `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted
22
- - `description`: UI label only subagent never sees it
23
- - `role`: specialist identity this subagent embodies (e.g. "Auth-flow security reviewer") — sets its system-prompt persona and roster display name; tailor every spawn rather than cloning a generic worker
16
+ - `context`: Shared project state, constraints, and contracts. Applies to the entire batch; do not duplicate this background into individual tasks.
17
+ - `tasks[]`: Array of subagents to spawn.
18
+ - `assignment`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED.
19
+ - `id`: A stable CamelCase identifier (≤32 chars). Generated automatically if omitted.
20
+ - `description`: A UI label only; the subagent NEVER sees it.
21
+ - `role`: The specialist this subagent embodies. Tailor per spawn; do not clone a generic worker.
24
22
  {{#if isolationEnabled}}
25
- - `isolated`: run this spawn in an isolated env; returns patches. Isolated agents are torn down at completion not addressable afterwards
23
+ - `isolated`: Run in a dedicated worktree and return patches. Isolated agents are destroyed upon completion and cannot be addressed afterward.
26
24
  {{/if}}
27
25
  {{else}}
28
- - `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted
29
- - `description`: UI label only subagent never sees it
30
- - `role`: specialist identity this subagent embodies (e.g. "Auth-flow security reviewer") — sets its system-prompt persona and roster display name; tailor every spawn rather than cloning a generic worker
31
- - `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED
26
+ - `assignment`: Complete, self-contained instructions. One-liners or missing acceptance criteria are PROHIBITED.
27
+ - `id`: A stable CamelCase identifier (≤32 chars). Generated automatically if omitted.
28
+ - `description`: A UI label only; the subagent NEVER sees it.
29
+ - `role`: The specialist this subagent embodies. Tailor per spawn; do not clone a generic worker.
32
30
  {{#if isolationEnabled}}
33
- - `isolated`: run in isolated env; returns patches. Isolated agents are torn down at completion not addressable afterwards
31
+ - `isolated`: Run in a dedicated worktree and return patches. Isolated agents are destroyed upon completion and cannot be addressed afterward.
34
32
  {{/if}}
35
33
  {{/if}}
36
- </parameters>
37
34
 
38
- <rules>
39
- - **Maximize fan-out.** Issue the widest {{#if batchEnabled}}`tasks[]` batch{{else}}set of parallel `task` calls{{/if}} the work decomposes into. NEVER serialize work that could run concurrently.
40
- - **Subagents do not verify, lint, or format.** Every assignment MUST instruct the subagent to skip all gates, formatters, and project-wide build/test/lint. You run them once at the end across the union of changed files.
41
- - No globs, no "update all", no package-wide scope. Fan out.
42
- - **Tailor every spawn with a `role`.** A role naming the specialist (e.g. "Parser edge-case tester", "SSE backpressure specialist") makes a sharper agent than a bare generic `task`/`quick_task` worker; decompose into named specialists, never clones of one generic worker. A role-less generic spawn is the exception.
43
- - NEVER slow down or serialize because tasks might overlap on some files. Agents resolve collisions among themselves in real time.
44
- - Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in {{#if batchEnabled}}`context` or the item's `assignment`{{else}}the `assignment`{{/if}}.
35
+ # Context and Communication
36
+ Subagents start blank. They have no access to your conversation history.
45
37
  {{#if batchEnabled}}
46
- - **Shared background** lives in `context` once — never duplicated across assignments. Pass large payloads via `local://<path>` URIs, not inline.
38
+ - Pass large payloads using `local://<path>` URIs, never inline text.
47
39
  {{else}}
48
- - **Shared background**: write it ONCE to a `local://` file (e.g. `local://ctx.md`) and reference that path in each assignment. Pass large payloads via `local://<path>` URIs, not inline.
40
+ - *Note: The single-spawn shape has no `context` field.* Write shared project state ONCE to a `local://` file (e.g., `local://ctx.md`) and reference that URL in your assignments. Pass large payloads using `local://<path>` URIs, never inline text.
49
41
  {{/if}}
50
- - Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown.
51
- - **Read-only agents**: Agents tagged READ-ONLY (e.g. `explore`) have no edit/write/command tools. NEVER hand them an assignment that requires changing files or running commands. Use them to investigate and report back; do the edits yourself or delegate to a writing agent (`task`, `oracle`, `designer`).
52
- - **No reasoning offload**: NEVER offload reasoning, analysis, design, or decision-making to `quick_task` or `explore` — they run minimal-effort / small models for mechanical lookups and data collection only. Keep judgment and synthesis in your own context; delegate hard thinking to `task`, `plan`, or `oracle`.
53
- </rules>
54
-
55
- <parallelization>
56
42
  {{#if ircEnabled}}
57
- Test: can task B run correctly without seeing A's output? If no, sequence A B **unless** B can reasonably ask A for the missing piece over `irc`. Live coordination beats a serial waterfall when the contract is small and easy to describe in a DM.
58
- Still sequence when one task produces a large, evolving contract (generated types, schema migration, core module API) the other consumes wholesale — IRC round-trips do not replace a finished artifact.
59
- Parallel when tasks touch disjoint files, are independent refactors/tests, or only need occasional clarification that can be resolved peer-to-peer.
60
- {{else}}
61
- Test: can task B run correctly without seeing A's output? If no, sequence A → B.
62
- Sequential when one task produces a contract (types, API, schema, core module) the other consumes.
63
- Parallel when tasks touch disjoint files or are independent refactors/tests.
43
+ - Once spawned, coordinate with live agents via `irc` using their IDs. If task B depends on task A, B SHOULD message A directly.
44
+ {{/if}}
45
+ {{#if asyncEnabled}}
46
+ - If you run out of things to do and are genuinely blocked waiting for a subagent, use `job poll`. Use `job cancel` only for stalled work.
64
47
  {{/if}}
65
- {{#if ircEnabled}}Sequenced follow-ups SHOULD message the agent that produced the prerequisite — it already holds the context.{{/if}}
66
- </parallelization>
67
48
 
49
+ # Format Contracts
68
50
  {{#if batchEnabled}}
69
- <context-fmt>
70
- # Goal ← one sentence: what the batch accomplishes
71
- # Constraints ← MUST/NEVER rules and session decisions
72
- # Contract ← exact types/signatures if tasks share an interface
73
- </context-fmt>
51
+ The `context` field MUST follow this format:
52
+ # Goal ← what the batch accomplishes
53
+ # Constraints ← rules and session decisions
54
+ # Contract ← shared interfaces
74
55
  {{/if}}
75
56
 
76
- <assignment-fmt>
57
+ The `assignment` field MUST follow this format:
77
58
  # Target ← exact files and symbols; explicit non-goals
78
59
  # Change ← step-by-step add/remove/rename; APIs and patterns
79
60
  # Acceptance ← observable result; no project-wide commands
80
- </assignment-fmt>
81
61
 
82
- <agents>
62
+ # Available Agents
83
63
  {{#if spawningDisabled}}
84
- Agent spawning is disabled for this context.
64
+ Agent spawning is currently disabled.
85
65
  {{else}}
86
66
  {{#list agents join="\n"}}
87
- # {{name}}{{#if readOnly}} READ-ONLY (no edit/write/exec tools){{/if}}
67
+ ### {{name}}{{#if readOnly}} (READ-ONLY: no edit/write/command tools){{/if}}
88
68
  {{description}}
69
+ {{#if readOnly}}Use ONLY for investigation and reporting; do the edits yourself or assign them to a writing agent.{{/if}}
89
70
  {{/list}}
90
71
  {{/if}}
91
- </agents>
package/src/sdk.ts CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  loadProjectContextFiles as loadContextFilesInternal,
131
131
  } from "./system-prompt";
132
132
  import { AgentOutputManager } from "./task/output-manager";
133
+ import { wrapStreamFnWithProviderConcurrency } from "./task/provider-concurrency";
133
134
  import {
134
135
  AUTO_THINKING,
135
136
  type ConfiguredThinkingLevel,
@@ -2535,11 +2536,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2535
2536
  // One-shot launch-latency marker: fired the first time the loop dispatches
2536
2537
  // a chat request to the provider transport. See onFirstChatDispatch.
2537
2538
  let notifyFirstChatDispatch = options.onFirstChatDispatch;
2538
- // Shared, settings-aware stream wrapper used by both the main agent and
2539
- // the advisor (via AgentSessionConfig.streamFn). Keeps OpenRouter
2540
- // sticky-routing variants, antigravity endpoint routing, in-flight caps,
2541
- // and the loop guard consistent across every agent the session drives.
2542
- const settingsAwareStreamFn = createSettingsAwareStreamFn(settings);
2539
+ // Shared, settings-aware stream wrapper used by the main agent, advisor,
2540
+ // and side-channel requests (`/btw`, `/omfg`, IRC auto-replies, handoff).
2541
+ // Keeps OpenRouter sticky-routing variants, antigravity endpoint routing,
2542
+ // in-flight caps, and the loop guard consistent across every provider call
2543
+ // the session drives. Wrapped in a per-provider concurrency limiter so
2544
+ // each LLM HTTP request — not the whole subagent lifecycle — holds the
2545
+ // slot, preventing the nested-spawn deadlock from issue #3749.
2546
+ const settingsAwareStreamFn = wrapStreamFnWithProviderConcurrency(
2547
+ settings,
2548
+ createSettingsAwareStreamFn(settings),
2549
+ );
2543
2550
  agent = new Agent({
2544
2551
  initialState: {
2545
2552
  systemPrompt,
@@ -2709,7 +2716,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2709
2716
  transformProviderContext,
2710
2717
  onPayload,
2711
2718
  onResponse,
2719
+ sideStreamFn: settingsAwareStreamFn,
2712
2720
  advisorStreamFn: settingsAwareStreamFn,
2721
+ preferWebsockets: preferOpenAICodexWebsockets,
2713
2722
  convertToLlm: convertToLlmFinal,
2714
2723
  rebuildSystemPrompt,
2715
2724
  reloadSshTool,
@@ -534,6 +534,13 @@ export interface AgentSessionConfig {
534
534
  * inherits this so its requests undergo the same shaping as the main turn.
535
535
  */
536
536
  transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
537
+ /**
538
+ * Stream wrapper passed to side-channel requests (`/btw`, `/omfg`, IRC
539
+ * auto-replies, and handoff generation) so they apply the same provider
540
+ * shaping and host-level request wrappers as normal agent turns. Defaults
541
+ * to plain `streamSimple` when omitted.
542
+ */
543
+ sideStreamFn?: StreamFn;
537
544
  /**
538
545
  * Stream wrapper passed to the advisor agent so its requests apply the
539
546
  * session's `providers.openrouterVariant`, `providers.antigravityEndpoint`,
@@ -542,6 +549,8 @@ export interface AgentSessionConfig {
542
549
  * main agent. Defaults to plain `streamSimple` when omitted.
543
550
  */
544
551
  advisorStreamFn?: StreamFn;
552
+ /** Hint that OpenAI Codex requests should prefer websocket transport when supported. */
553
+ preferWebsockets?: boolean;
545
554
  /** Provider payload hook used by the active session request path */
546
555
  onPayload?: SimpleStreamOptions["onPayload"];
547
556
  /** Provider response hook used by the active session request path */
@@ -1459,7 +1468,9 @@ export class AgentSession {
1459
1468
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
1460
1469
  #onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
1461
1470
  #transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
1471
+ #sideStreamFn: StreamFn;
1462
1472
  #advisorStreamFn: StreamFn | undefined;
1473
+ #preferWebsockets: boolean | undefined;
1463
1474
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
1464
1475
  #rebuildSystemPrompt:
1465
1476
  | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
@@ -1788,7 +1799,9 @@ export class AgentSession {
1788
1799
  this.#requestedToolNames = config.requestedToolNames;
1789
1800
  this.#transformContext = config.transformContext ?? (messages => messages);
1790
1801
  this.#transformProviderContext = config.transformProviderContext;
1802
+ this.#sideStreamFn = config.sideStreamFn ?? streamSimple;
1791
1803
  this.#advisorStreamFn = config.advisorStreamFn;
1804
+ this.#preferWebsockets = config.preferWebsockets;
1792
1805
  this.#onPayload = config.onPayload;
1793
1806
  this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
1794
1807
  // Avoid wrapping in an `async` closure when no user callback is configured: the
@@ -2130,6 +2143,7 @@ export class AgentSession {
2130
2143
  sessionId: advisorSessionId,
2131
2144
  promptCacheKey: advisorSessionId,
2132
2145
  providerSessionState: this.#providerSessionState,
2146
+ preferWebsockets: this.#preferWebsockets,
2133
2147
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
2134
2148
  streamFn: this.#advisorStreamFn,
2135
2149
  onPayload: this.#onPayload,
@@ -2615,6 +2629,11 @@ export class AgentSession {
2615
2629
  return this.#providerSessionState;
2616
2630
  }
2617
2631
 
2632
+ /** Hint forwarded to provider calls that support websocket transport. */
2633
+ get preferWebsockets(): boolean | undefined {
2634
+ return this.#preferWebsockets;
2635
+ }
2636
+
2618
2637
  getHindsightSessionState(): HindsightSessionState | undefined {
2619
2638
  return this.#hindsightSessionState;
2620
2639
  }
@@ -9195,6 +9214,10 @@ export class AgentSession {
9195
9214
  model,
9196
9215
  {
9197
9216
  streamOptions: handoffStreamOptions,
9217
+ completeImpl: async (requestModel, requestContext, requestOptions) => {
9218
+ const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions);
9219
+ return stream.result();
9220
+ },
9198
9221
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
9199
9222
  // Honor the user's /model thinking selection on the handoff path.
9200
9223
  // Clamped per-model inside generateHandoffFromContext via
@@ -9482,16 +9505,18 @@ export class AgentSession {
9482
9505
  const errorIsFromBeforeCompaction =
9483
9506
  compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
9484
9507
  if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
9485
- // Remove the error message from agent state (it IS saved to session for history,
9486
- // but we don't want it in context for the retry)
9487
- const messages = this.agent.state.messages;
9488
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
9489
- this.agent.replaceMessages(messages.slice(0, -1));
9490
- }
9508
+ // Clear the failed turn from active context so the retry (or the next
9509
+ // user prompt) does not replay it. The persisted branch entry stays
9510
+ // for now: when no recovery path runs, the user-facing transcript
9511
+ // MUST keep the only assistant message explaining why the turn
9512
+ // stopped. The branch entry is dropped further down, but only on the
9513
+ // paths that actually schedule a retry/compaction.
9514
+ this.#removeAssistantMessageFromActiveContext(assistantMessage);
9491
9515
 
9492
9516
  // Try context promotion first - switch to a larger model and retry without compacting
9493
9517
  const promoted = await this.#tryContextPromotion(assistantMessage);
9494
9518
  if (promoted) {
9519
+ await this.#dropPersistedAssistantTurn(assistantMessage);
9495
9520
  // Retry on the promoted (larger) model without compacting
9496
9521
  this.#scheduleAgentContinue({ delayMs: 100, generation });
9497
9522
  return COMPACTION_CHECK_CONTINUATION;
@@ -9500,7 +9525,9 @@ export class AgentSession {
9500
9525
  // No promotion target available fall through to compaction
9501
9526
  const compactionSettings = this.settings.getGroup("compaction");
9502
9527
  if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
9503
- return await this.#runAutoCompaction("overflow", true, false, allowDefer, { autoContinue });
9528
+ return await this.#runRecoveryCompactionWithRollback("overflow", assistantMessage, allowDefer, {
9529
+ autoContinue,
9530
+ });
9504
9531
  }
9505
9532
  return COMPACTION_CHECK_NONE;
9506
9533
  }
@@ -9512,13 +9539,14 @@ export class AgentSession {
9512
9539
  // otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
9513
9540
  // allow the handoff strategy to actually run.
9514
9541
  if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
9515
- const messages = this.agent.state.messages;
9516
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
9517
- this.agent.replaceMessages(messages.slice(0, -1));
9518
- }
9542
+ // Same active-context vs persisted-history split as the overflow path
9543
+ // above: clear the dead turn from agent state so it cannot be replayed,
9544
+ // but keep it on the branch unless promotion or compaction actually runs.
9545
+ this.#removeAssistantMessageFromActiveContext(assistantMessage);
9519
9546
 
9520
9547
  const promoted = await this.#tryContextPromotion(assistantMessage);
9521
9548
  if (promoted) {
9549
+ await this.#dropPersistedAssistantTurn(assistantMessage);
9522
9550
  logger.debug("Context promotion triggered by response.incomplete (length stop)", {
9523
9551
  from: `${assistantMessage.provider}/${assistantMessage.model}`,
9524
9552
  });
@@ -9532,7 +9560,7 @@ export class AgentSession {
9532
9560
  model: `${assistantMessage.provider}/${assistantMessage.model}`,
9533
9561
  strategy: incompleteCompactionSettings.strategy,
9534
9562
  });
9535
- return await this.#runAutoCompaction("incomplete", true, false, allowDefer, {
9563
+ return await this.#runRecoveryCompactionWithRollback("incomplete", assistantMessage, allowDefer, {
9536
9564
  autoContinue,
9537
9565
  triggerContextTokens: calculateContextTokens(assistantMessage.usage),
9538
9566
  });
@@ -9797,6 +9825,54 @@ export class AgentSession {
9797
9825
  }
9798
9826
  }
9799
9827
 
9828
+ /**
9829
+ * Drop a recoverable assistant turn from the persisted session branch once a
9830
+ * recovery path (context promotion or compaction) is committed. Waits for the
9831
+ * in-flight `message_end` persistence slot first so the branch entry exists
9832
+ * before we reparent past it. Active context removal is the caller's
9833
+ * responsibility — recovery paths clear it eagerly so the retry never
9834
+ * replays the failed turn, while no-recovery paths leave the persisted entry
9835
+ * (and the user-visible transcript line) in place.
9836
+ */
9837
+ async #dropPersistedAssistantTurn(assistantMessage: AssistantMessage): Promise<void> {
9838
+ await this.#waitForSessionMessagePersistence(assistantMessage);
9839
+ this.#discardAssistantTurn(assistantMessage);
9840
+ }
9841
+
9842
+ /**
9843
+ * Drop the failed assistant turn from persisted history, run
9844
+ * {@link #runAutoCompaction} for an `overflow` / `incomplete` recovery, and
9845
+ * restore the assistant entry if compaction did not actually commit
9846
+ * anything (no usable model/preparation, hook cancel, or compaction error).
9847
+ *
9848
+ * Compaction has to see a clean branch — otherwise its `prepareCompaction`
9849
+ * pass would keep the failed turn in the kept region and the retry would
9850
+ * replay it. But a NONE return that was not paired with a fresh compaction
9851
+ * summary means no recovery is in progress, and leaving the branch
9852
+ * reparented would erase the only user-visible explanation for why the turn
9853
+ * stopped. Reverting the drop in that case preserves the transcript while
9854
+ * still letting a real recovery path own the rewrite.
9855
+ */
9856
+ async #runRecoveryCompactionWithRollback(
9857
+ reason: "overflow" | "incomplete",
9858
+ assistantMessage: AssistantMessage,
9859
+ allowDefer: boolean,
9860
+ options: { autoContinue: boolean; triggerContextTokens?: number },
9861
+ ): Promise<CompactionCheckResult> {
9862
+ const compactionEntryBefore = getLatestCompactionEntry(this.sessionManager.getBranch());
9863
+ await this.#dropPersistedAssistantTurn(assistantMessage);
9864
+ const result = await this.#runAutoCompaction(reason, true, false, allowDefer, options);
9865
+ const recoveryCommitted =
9866
+ result.continuationScheduled || result.deferredHandoff || result.automaticContinuationBlocked === true;
9867
+ if (!recoveryCommitted) {
9868
+ const compactionEntryAfter = getLatestCompactionEntry(this.sessionManager.getBranch());
9869
+ if (compactionEntryAfter === compactionEntryBefore) {
9870
+ this.sessionManager.appendMessage(assistantMessage);
9871
+ }
9872
+ }
9873
+ return result;
9874
+ }
9875
+
9800
9876
  /**
9801
9877
  * Drop an assistant turn from BOTH the live agent context and the persisted
9802
9878
  * session branch (reparenting the leaf to the turn's parent), so a discarded
@@ -10694,6 +10770,18 @@ export class AgentSession {
10694
10770
  tools: this.agent.state.tools,
10695
10771
  sessionId: this.sessionId,
10696
10772
  promptCacheKey: this.sessionId,
10773
+ // Route every summarization HTTP request through the
10774
+ // session's side-stream transport so the provider
10775
+ // concurrency cap (e.g. providers.ollama-cloud.maxConcurrency)
10776
+ // brackets compaction the same way it brackets the live
10777
+ // agent turn — without this, multiple ollama-cloud
10778
+ // subagents auto/manually compacting issued uncapped
10779
+ // summary requests in parallel (chatgpt-codex review on
10780
+ // #3751).
10781
+ completeImpl: async (requestModel, requestContext, requestOptions) => {
10782
+ const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions);
10783
+ return stream.result();
10784
+ },
10697
10785
  },
10698
10786
  );
10699
10787
  } catch (error) {
@@ -12904,7 +12992,7 @@ export class AgentSession {
12904
12992
  let providerReplyText = "";
12905
12993
  let emittedReplyText = "";
12906
12994
  let assistantMessage: AssistantMessage | undefined;
12907
- const stream = streamSimple(model, obfuscateProviderContext(this.#obfuscator, context), options);
12995
+ const stream = await this.#sideStreamFn(model, obfuscateProviderContext(this.#obfuscator, context), options);
12908
12996
  for await (const event of stream) {
12909
12997
  if (event.type === "text_delta") {
12910
12998
  providerReplyText += event.delta;
@@ -13522,6 +13610,12 @@ export class AgentSession {
13522
13610
  metadata: this.agent.metadataForProvider(model.provider),
13523
13611
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
13524
13612
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
13613
+ // Same per-provider concurrency cap rationale as the compaction
13614
+ // path above (chatgpt-codex review on #3751).
13615
+ completeImpl: async (requestModel, requestContext, requestOptions) => {
13616
+ const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions);
13617
+ return stream.result();
13618
+ },
13525
13619
  });
13526
13620
  this.#branchSummaryAbortController = undefined;
13527
13621
  if (result.aborted) {
@@ -245,20 +245,21 @@ function countMessageMarkers(content: string): number {
245
245
  return count;
246
246
  }
247
247
 
248
- function extractFirstUserMessageFromPrefix(content: string): string | undefined {
249
- const roleIndex = content.indexOf('"role"');
250
- if (roleIndex === -1) return undefined;
248
+ function extractFirstDisplayMessageFromPrefix(content: string): string | undefined {
249
+ let fallback: string | undefined;
250
+ let index = content.indexOf('"role"');
251
251
 
252
- let index = roleIndex;
253
252
  while (index !== -1) {
254
253
  const role = extractStringProperty(content, "role", index);
255
- if (role === "user") {
256
- return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
254
+ const text = extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
255
+ if (text) {
256
+ if (role === "user") return text;
257
+ if (!fallback && (role === "developer" || role === "assistant")) fallback = text;
257
258
  }
258
259
  index = content.indexOf('"role"', index + 6);
259
260
  }
260
261
 
261
- return undefined;
262
+ return fallback;
262
263
  }
263
264
 
264
265
  interface SessionListHeader {
@@ -394,7 +395,7 @@ async function scanSessionFile(
394
395
  }
395
396
  }
396
397
 
397
- firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
398
+ firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? "";
398
399
  const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
399
400
  return {
400
401
  path: file,