@oh-my-pi/pi-coding-agent 14.9.8 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/package.json +7 -7
  3. package/scripts/build-binary.ts +11 -0
  4. package/scripts/format-prompts.ts +1 -1
  5. package/src/cli/args.ts +2 -2
  6. package/src/cli/stats-cli.ts +2 -0
  7. package/src/cli.ts +24 -1
  8. package/src/commands/acp.ts +24 -0
  9. package/src/commands/launch.ts +6 -4
  10. package/src/commit/agentic/prompts/system.md +1 -1
  11. package/src/config/model-resolver.ts +30 -0
  12. package/src/config/settings-schema.ts +61 -9
  13. package/src/config/settings.ts +18 -1
  14. package/src/edit/index.ts +22 -1
  15. package/src/edit/modes/patch.ts +10 -0
  16. package/src/edit/modes/replace.ts +3 -0
  17. package/src/edit/renderer.ts +10 -0
  18. package/src/edit/streaming.ts +1 -1
  19. package/src/eval/js/context-manager.ts +10 -9
  20. package/src/eval/js/shared/rewrite-imports.ts +120 -48
  21. package/src/eval/js/shared/runtime.ts +31 -4
  22. package/src/eval/js/tool-bridge.ts +43 -21
  23. package/src/extensibility/extensions/runner.ts +54 -1
  24. package/src/extensibility/extensions/types.ts +11 -0
  25. package/src/extensibility/skills.ts +33 -1
  26. package/src/hashline/grammar.lark +1 -1
  27. package/src/hashline/input.ts +11 -5
  28. package/src/internal-urls/docs-index.generated.ts +7 -7
  29. package/src/internal-urls/index.ts +1 -0
  30. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  31. package/src/internal-urls/router.ts +6 -3
  32. package/src/internal-urls/types.ts +22 -1
  33. package/src/main.ts +13 -9
  34. package/src/modes/acp/acp-agent.ts +361 -54
  35. package/src/modes/acp/acp-client-bridge.ts +152 -0
  36. package/src/modes/acp/acp-event-mapper.ts +180 -15
  37. package/src/modes/acp/terminal-auth.ts +37 -0
  38. package/src/modes/components/read-tool-group.ts +29 -1
  39. package/src/modes/controllers/command-controller.ts +14 -6
  40. package/src/modes/controllers/event-controller.ts +24 -11
  41. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  42. package/src/modes/controllers/input-controller.ts +72 -39
  43. package/src/modes/interactive-mode.ts +71 -7
  44. package/src/modes/rpc/rpc-mode.ts +17 -2
  45. package/src/modes/types.ts +6 -2
  46. package/src/modes/utils/ui-helpers.ts +15 -3
  47. package/src/prompts/agents/designer.md +5 -5
  48. package/src/prompts/agents/explore.md +7 -7
  49. package/src/prompts/agents/init.md +9 -9
  50. package/src/prompts/agents/librarian.md +14 -14
  51. package/src/prompts/agents/plan.md +4 -4
  52. package/src/prompts/agents/reviewer.md +5 -5
  53. package/src/prompts/agents/task.md +10 -10
  54. package/src/prompts/commands/orchestrate.md +2 -2
  55. package/src/prompts/compaction/branch-summary.md +3 -3
  56. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  57. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  58. package/src/prompts/compaction/compaction-summary.md +5 -5
  59. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  60. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  61. package/src/prompts/memories/consolidation.md +2 -2
  62. package/src/prompts/memories/read-path.md +1 -1
  63. package/src/prompts/memories/stage_one_input.md +1 -1
  64. package/src/prompts/memories/stage_one_system.md +5 -5
  65. package/src/prompts/review-request.md +4 -4
  66. package/src/prompts/system/agent-creation-architect.md +17 -17
  67. package/src/prompts/system/agent-creation-user.md +2 -2
  68. package/src/prompts/system/commit-message-system.md +2 -2
  69. package/src/prompts/system/custom-system-prompt.md +2 -2
  70. package/src/prompts/system/eager-todo.md +6 -6
  71. package/src/prompts/system/handoff-document.md +1 -1
  72. package/src/prompts/system/plan-mode-active.md +22 -21
  73. package/src/prompts/system/plan-mode-approved.md +4 -4
  74. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  75. package/src/prompts/system/plan-mode-reference.md +2 -2
  76. package/src/prompts/system/plan-mode-subagent.md +8 -8
  77. package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
  78. package/src/prompts/system/project-prompt.md +4 -4
  79. package/src/prompts/system/subagent-system-prompt.md +7 -7
  80. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  81. package/src/prompts/system/system-prompt.md +72 -71
  82. package/src/prompts/system/ttsr-interrupt.md +1 -1
  83. package/src/prompts/tools/apply-patch.md +1 -1
  84. package/src/prompts/tools/ast-edit.md +3 -3
  85. package/src/prompts/tools/ast-grep.md +3 -3
  86. package/src/prompts/tools/browser.md +3 -3
  87. package/src/prompts/tools/checkpoint.md +3 -3
  88. package/src/prompts/tools/exit-plan-mode.md +2 -2
  89. package/src/prompts/tools/find.md +3 -3
  90. package/src/prompts/tools/github.md +2 -5
  91. package/src/prompts/tools/hashline.md +20 -20
  92. package/src/prompts/tools/image-gen.md +3 -3
  93. package/src/prompts/tools/irc.md +1 -1
  94. package/src/prompts/tools/lsp.md +2 -2
  95. package/src/prompts/tools/patch.md +6 -6
  96. package/src/prompts/tools/read.md +7 -7
  97. package/src/prompts/tools/replace.md +5 -5
  98. package/src/prompts/tools/retain.md +1 -1
  99. package/src/prompts/tools/rewind.md +2 -2
  100. package/src/prompts/tools/search.md +2 -2
  101. package/src/prompts/tools/ssh.md +2 -2
  102. package/src/prompts/tools/task.md +12 -6
  103. package/src/prompts/tools/web-search.md +2 -2
  104. package/src/prompts/tools/write.md +3 -3
  105. package/src/sdk.ts +69 -12
  106. package/src/session/agent-session.ts +231 -22
  107. package/src/session/client-bridge.ts +81 -0
  108. package/src/session/compaction/errors.ts +31 -0
  109. package/src/session/compaction/index.ts +1 -0
  110. package/src/slash-commands/acp-builtins.ts +46 -0
  111. package/src/slash-commands/builtin-registry.ts +699 -116
  112. package/src/slash-commands/helpers/context-report.ts +39 -0
  113. package/src/slash-commands/helpers/format.ts +23 -0
  114. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  115. package/src/slash-commands/helpers/mcp.ts +532 -0
  116. package/src/slash-commands/helpers/parse.ts +85 -0
  117. package/src/slash-commands/helpers/ssh.ts +193 -0
  118. package/src/slash-commands/helpers/todo.ts +279 -0
  119. package/src/slash-commands/helpers/usage-report.ts +91 -0
  120. package/src/slash-commands/types.ts +126 -0
  121. package/src/task/executor.ts +10 -3
  122. package/src/task/index.ts +29 -51
  123. package/src/task/render.ts +6 -3
  124. package/src/task/worktree.ts +170 -239
  125. package/src/tools/bash.ts +176 -2
  126. package/src/tools/browser/tab-supervisor.ts +13 -13
  127. package/src/tools/conflict-detect.ts +6 -6
  128. package/src/tools/fetch.ts +15 -4
  129. package/src/tools/find.ts +19 -1
  130. package/src/tools/gh-renderer.ts +0 -12
  131. package/src/tools/gh.ts +682 -176
  132. package/src/tools/github-cache.ts +548 -0
  133. package/src/tools/index.ts +3 -0
  134. package/src/tools/read.ts +110 -27
  135. package/src/tools/write.ts +23 -1
  136. package/src/tui/code-cell.ts +70 -2
  137. package/src/utils/git.ts +5 -0
  138. package/src/task/isolation-backend.ts +0 -94
@@ -2,8 +2,8 @@ Reads the content at the specified path or URL.
2
2
 
3
3
  <instruction>
4
4
  The `read` tool is multi-purpose and more capable than it looks — inspects files, directories, archives, SQLite databases, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), **and URLs**.
5
- - You **MUST** parallelize reads when exploring related files
6
- - For URLs, `read` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You **SHOULD** reach for `read` — not a browser/puppeteer tool — for fetching and inspecting web content.
5
+ - You MUST parallelize reads when exploring related files
6
+ - For URLs, `read` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching and inspecting web content.
7
7
 
8
8
  ## Parameters
9
9
  - `path` — file path or URL (required). Append `:<sel>` for line ranges or raw mode (for example `src/foo.ts:50-200` or `src/foo.ts:raw`).
@@ -55,9 +55,9 @@ Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, R
55
55
  </instruction>
56
56
 
57
57
  <critical>
58
- - You **MUST** use `read` for every file, directory, archive, and URL read. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, and `wget` are **FORBIDDEN** for inspection — any such Bash call is a bug, regardless of how short or convenient it looks.
59
- - You **MUST** prefer `read` over a browser/puppeteer tool for fetching URL content; only use a browser if `read` fails to deliver reasonable content.
60
- - You **MUST** always include the `path` parameter — never call `read` with an empty argument object `{}`.
61
- - For specific line ranges, append the selector to `path` (e.g. `path="src/foo.ts:50-200"`, `path="src/foo.ts:50+150"`) — do **NOT** reach for `sed -n`, `awk NR`, or `head`/`tail` pipelines.
62
- - You **MAY** use path suffix selectors with URL reads; the tool paginates cached fetched output.
58
+ - You MUST use `read` for every file, directory, archive, and URL read. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, and `wget` are **FORBIDDEN** for inspection — any such Bash call is a bug, regardless of how short or convenient it looks.
59
+ - You MUST prefer `read` over a browser/puppeteer tool for fetching URL content; only use a browser if `read` fails to deliver reasonable content.
60
+ - You MUST always include the `path` parameter — never call `read` with an empty argument object `{}`.
61
+ - For specific line ranges, append the selector to `path` (e.g. `path="src/foo.ts:50-200"`, `path="src/foo.ts:50+150"`) — NEVER reach for `sed -n`, `awk NR`, or `head`/`tail` pipelines.
62
+ - You MAY use path suffix selectors with URL reads; the tool paginates cached fetched output.
63
63
  </critical>
@@ -1,10 +1,10 @@
1
1
  Performs string replacements in files with fuzzy whitespace matching.
2
2
 
3
3
  <instruction>
4
- - Params **MUST** be `{ path, edits }`; `path` is required at the top level and applies to every replacement
5
- - You **MUST** use the smallest `old_text` that uniquely identifies the change
6
- - If `old_text` is not unique, you **MUST** expand it with more context or use `all: true` to replace all occurrences
7
- - You **SHOULD** prefer editing existing files over creating new ones
4
+ - Params MUST be `{ path, edits }`; `path` is required at the top level and applies to every replacement
5
+ - You MUST use the smallest `old_text` that uniquely identifies the change
6
+ - If `old_text` is not unique, you MUST expand it with more context or use `all: true` to replace all occurrences
7
+ - You SHOULD prefer editing existing files over creating new ones
8
8
  </instruction>
9
9
 
10
10
  <output>
@@ -12,7 +12,7 @@ Returns success/failure status. On success, file modified in place with replacem
12
12
  </output>
13
13
 
14
14
  <critical>
15
- - You **MUST** read the file at least once in the conversation before editing. Tool errors if you attempt edit without reading file first.
15
+ - You MUST read the file at least once in the conversation before editing. Tool errors if you attempt edit without reading file first.
16
16
  </critical>
17
17
 
18
18
  <bash-alternatives>
@@ -3,4 +3,4 @@ Store one or more facts in long-term memory for future sessions.
3
3
  Use for durable, reusable knowledge: user preferences, project decisions, architectural choices, anything that improves future responses.
4
4
  Ephemeral task state does not belong here.
5
5
 
6
- Each item **MUST** be specific and self-contained — include who, what, when, and why. Batch related facts in a single call; they are deduplicated and consolidated.
6
+ Each item MUST be specific and self-contained — include who, what, when, and why. Batch related facts in a single call; they are deduplicated and consolidated.
@@ -3,10 +3,10 @@ End an active checkpoint. Rewind context to it, replacing intermediate explorati
3
3
  Call immediately after `checkpoint`-started investigative work.
4
4
 
5
5
  Requirements:
6
- - `report` is **REQUIRED** and must be concise, factual, and actionable.
6
+ - `report` is REQUIRED and must be concise, factual, and actionable.
7
7
  - Include key findings, decisions, and any unresolved risks.
8
8
  - Do not include raw scratch logs unless essential.
9
- - You **MUST** call this before yielding if a checkpoint is active.
9
+ - You MUST call this before yielding if a checkpoint is active.
10
10
 
11
11
  Behavior:
12
12
  - If no checkpoint is active, this tool errors.
@@ -17,8 +17,8 @@ Searches files using powerful regex matching.
17
17
  </output>
18
18
 
19
19
  <critical>
20
- - You **MUST** use the built-in `search` tool for any content search. Do **NOT** shell out to `grep`, `rg`, `ripgrep`, `ag`, `ack`, `git grep`, `awk`, `sed`-for-search, or any other CLI search via Bash — even for a single match, even "just to check quickly", even piped through other commands.
20
+ - You MUST use the built-in `search` tool for any content search. NEVER shell out to `grep`, `rg`, `ripgrep`, `ag`, `ack`, `git grep`, `awk`, `sed`-for-search, or any other CLI search via Bash — even for a single match, even "just to check quickly", even piped through other commands.
21
21
  - Bash `grep`/`rg` loses `.gitignore` semantics, bypasses result limits, and wastes tokens. The `search` tool is faster, structured, and already wired into the workspace — there is no scenario where Bash search is preferable.
22
22
  - If you catch yourself typing `grep`, `rg`, or `| grep` in a Bash command, stop and re-issue the lookup through the `search` tool instead.
23
- - If the search is open-ended, requiring multiple rounds, you **MUST** use the Task tool with the explore subagent instead of chaining `search` calls yourself.
23
+ - If the search is open-ended, requiring multiple rounds, you MUST use the Task tool with the explore subagent instead of chaining `search` calls yourself.
24
24
  </critical>
@@ -1,7 +1,7 @@
1
1
  Runs commands on remote hosts.
2
2
 
3
3
  <instruction>
4
- You **MUST** build commands from the reference below
4
+ You MUST build commands from the reference below
5
5
  </instruction>
6
6
 
7
7
  <commands>
@@ -22,7 +22,7 @@ You **MUST** build commands from the reference below
22
22
  </commands>
23
23
 
24
24
  <critical>
25
- You **MUST** verify the shell type from "Available hosts" and use matching commands.
25
+ You MUST verify the shell type from "Available hosts" and use matching commands.
26
26
  </critical>
27
27
 
28
28
  <examples>
@@ -2,14 +2,20 @@ Launches subagents to parallelize workflows.
2
2
 
3
3
  {{#if asyncEnabled}}
4
4
  - Results are delivered automatically when complete.
5
- - If genuinely blocked on task completion, wait with `job` using `poll`; otherwise continue with another task when possible.
6
- - Call `job` with `list: true` to snapshot manager state; pass `poll: [id]` to wait or `cancel: [id]` to stop \u2014 only when inspection or intervention is useful.
5
+ - The tool result lists the assigned task ids (e.g. `0-AuthLoader`) those are the live agent ids.
6
+ {{#if ircEnabled}}
7
+ - Coordinate with running tasks via `irc` using those ids. `job cancel` terminates a task and **cannot carry a message** — only use it for stalled/abandoned work.
8
+ - If genuinely blocked on completion, wait with `job poll`; otherwise keep working.
9
+ {{else}}
10
+ - If genuinely blocked on completion, wait with `job poll`; otherwise keep working.
11
+ - Use `job list` to snapshot manager state; `cancel: [id]` only to actually stop a stuck task.
12
+ {{/if}}
7
13
  {{/if}}
8
14
 
9
15
  {{#if ircEnabled}}
10
16
  Subagents have no conversation history, but they can reach you and their siblings live via the `irc` tool. Front-load every fact, file path, and direction they need in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
11
17
  {{else}}
12
- Subagents have no conversation history. Every fact, file path, and direction they need **MUST** be explicit in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
18
+ Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
13
19
  {{/if}}
14
20
 
15
21
  <parameters>
@@ -24,8 +30,8 @@ Subagents have no conversation history. Every fact, file path, and direction the
24
30
  </parameters>
25
31
 
26
32
  <rules>
27
- - **MUST NOT** assign tasks to run project-wide build/test/lint. Caller verifies after the batch.
28
- - **Subagents do not verify, lint, or format.** Every assignment **MUST** instruct the subagent to skip all gates and formatters. You run them once at the end across the union of changed files — avoids redundant runs and racing formatter passes.
33
+ - NEVER assign tasks to run project-wide build/test/lint. Caller verifies after the batch.
34
+ - **Subagents do not verify, lint, or format.** Every assignment MUST instruct the subagent to skip all gates and formatters. You run them once at the end across the union of changed files — avoids redundant runs and racing formatter passes.
29
35
  {{#if ircEnabled}}
30
36
  - Each task: ≤3–5 explicit files. Overlapping file sets are tolerable when peers can coordinate via `irc`, but still fan out to a cluster when the scopes are cleanly separable.
31
37
  - No globs, no "update all", no package-wide scope.
@@ -52,7 +58,7 @@ Parallel when tasks touch disjoint files or are independent refactors/tests.
52
58
  {{#if contextEnabled}}
53
59
  <context-fmt>
54
60
  # Goal ← one sentence: what the batch accomplishes
55
- # Constraints ← **MUST**/**MUST NOT** rules and session decisions
61
+ # Constraints ← MUST/NEVER rules and session decisions
56
62
  # Contract ← exact types/signatures if tasks share an interface
57
63
  </context-fmt>
58
64
  {{/if}}
@@ -1,8 +1,8 @@
1
1
  Searches the web for up-to-date information beyond Claude's knowledge cutoff.
2
2
 
3
3
  <instruction>
4
- - You **SHOULD** prefer primary sources (papers, official docs) and corroborate key claims with multiple sources
5
- - You **MUST** include links for cited sources in the final response
4
+ - You SHOULD prefer primary sources (papers, official docs) and corroborate key claims with multiple sources
5
+ - You MUST include links for cited sources in the final response
6
6
  </instruction>
7
7
 
8
8
  <caution>
@@ -8,7 +8,7 @@ Creates or overwrites file at specified path.
8
8
  </conditions>
9
9
 
10
10
  <critical>
11
- - You **SHOULD** use Edit tool for modifying existing files (more precise, preserves formatting)
12
- - You **MUST NOT** create documentation files (*.md, README) unless explicitly requested
13
- - You **MUST NOT** use emojis unless requested
11
+ - You SHOULD use Edit tool for modifying existing files (more precise, preserves formatting)
12
+ - You NEVER create documentation files (*.md, README) unless explicitly requested
13
+ - You NEVER use emojis unless requested
14
14
  </critical>
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, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
9
+ import type { CredentialDisabledEvent, Message, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
10
10
  import {
11
11
  getOpenAICodexTransportDetails,
12
12
  prewarmOpenAICodexResponses,
@@ -29,7 +29,13 @@ import { createAutoresearchExtension } from "./autoresearch";
29
29
  import { loadCapability } from "./capability";
30
30
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
31
31
  import { ModelRegistry } from "./config/model-registry";
32
- import { formatModelString, parseModelPattern, parseModelString, resolveModelRoleValue } from "./config/model-resolver";
32
+ import {
33
+ formatModelString,
34
+ parseModelPattern,
35
+ parseModelString,
36
+ resolveAllowedModels,
37
+ resolveModelRoleValue,
38
+ } from "./config/model-resolver";
33
39
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
34
40
  import { Settings, type SkillsSettings } from "./config/settings";
35
41
  import { CursorExecHandlers } from "./cursor";
@@ -670,10 +676,32 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
670
676
  registerSshCleanup();
671
677
  registerPythonCleanup();
672
678
 
673
- // Use provided or create AuthStorage and ModelRegistry
674
- const authStorage = options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir));
675
- const modelRegistry = options.modelRegistry ?? new ModelRegistry(authStorage);
676
-
679
+ // Pin authStorage to modelRegistry.authStorage: ModelRegistry.getApiKey() routes refresh
680
+ // failures through that instance, so any divergent storage handed to the bridge / mcpManager
681
+ // / session would silently miss credential_disabled events.
682
+ const modelRegistry =
683
+ options.modelRegistry ??
684
+ new ModelRegistry(options.authStorage ?? (await logger.time("discoverModels", discoverAuthStorage, agentDir)));
685
+ const authStorage = modelRegistry.authStorage;
686
+ if (options.authStorage && options.authStorage !== authStorage) {
687
+ throw new Error(
688
+ "options.authStorage and options.modelRegistry.authStorage must be the same instance when both are provided",
689
+ );
690
+ }
691
+ // Subscribe before any getApiKey() call so startup model probes can't fire a
692
+ // credential_disabled event past us. An embedder's constructor handler makes the
693
+ // listener set non-empty from construction, which defeats AuthStorage's no-listener
694
+ // buffer — so we can't rely on it to catch startup events for the extension runner.
695
+ const startupCredentialDisabledEvents: CredentialDisabledEvent[] = [];
696
+ let credentialDisabledTarget: ExtensionRunner | undefined;
697
+ let unsubscribeCredentialDisabled: (() => void) | undefined = authStorage.onCredentialDisabled(event => {
698
+ if (credentialDisabledTarget) {
699
+ // Discard return: any handler error is routed through runner.onError listeners.
700
+ void credentialDisabledTarget.emitCredentialDisabled(event);
701
+ } else {
702
+ startupCredentialDisabledEvents.push(event);
703
+ }
704
+ });
677
705
  const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
678
706
  logger.time("initializeWithSettings", initializeWithSettings, settings);
679
707
  if (!options.modelRegistry) {
@@ -778,8 +806,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
778
806
  const modelMatchPreferences = {
779
807
  usageOrder: settings.getStorage()?.getModelUsageOrder(),
780
808
  };
809
+ const allowedModels = await logger.time("resolveAllowedModels", () =>
810
+ resolveAllowedModels(modelRegistry, settings, modelMatchPreferences),
811
+ );
781
812
  const defaultRoleSpec = logger.time("resolveDefaultModelRole", () =>
782
- resolveModelRoleValue(settings.getModelRole("default"), modelRegistry.getAvailable(), {
813
+ resolveModelRoleValue(settings.getModelRole("default"), allowedModels, {
783
814
  settings,
784
815
  matchPreferences: modelMatchPreferences,
785
816
  modelRegistry,
@@ -1014,6 +1045,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1014
1045
  getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
1015
1046
  getActiveModelString,
1016
1047
  getPlanModeState: () => session.getPlanModeState(),
1048
+ getClientBridge: () => session?.clientBridge,
1017
1049
  getCompactContext: () => session.formatCompactContext(),
1018
1050
  getTodoPhases: () => session.getTodoPhases(),
1019
1051
  setTodoPhases: phases => session.setTodoPhases(phases),
@@ -1236,11 +1268,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1236
1268
  }
1237
1269
  }
1238
1270
 
1239
- // Fall back to first available model with a valid API key.
1240
- // Skip fallback if the user explicitly requested a model via --model that wasn't found.
1271
+ // Fall back to first available model with a valid API key, honoring the
1272
+ // path-scoped `enabledModels` allow-list when configured. Skip when the
1273
+ // user explicitly requested a model via --model that wasn't found.
1241
1274
  if (!model && !options.modelPattern) {
1242
- const allModels = modelRegistry.getAll();
1243
- for (const candidate of allModels) {
1275
+ // Re-resolve the allowed set: extension factories above may have
1276
+ // registered providers/models that weren't visible at startup.
1277
+ const fallbackCandidates = await resolveAllowedModels(modelRegistry, settings, modelMatchPreferences);
1278
+ for (const candidate of fallbackCandidates) {
1244
1279
  if (await hasModelApiKey(candidate)) {
1245
1280
  model = candidate;
1246
1281
  break;
@@ -1251,8 +1286,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1251
1286
  modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
1252
1287
  }
1253
1288
  } else {
1289
+ const patterns = settings.get("enabledModels");
1254
1290
  modelFallbackMessage =
1255
- "No models available. Use /login or set an API key environment variable. Then use /model to select a model.";
1291
+ patterns && patterns.length > 0
1292
+ ? `No model available matching enabledModels (${patterns.join(", ")}) with usable credentials. Configure auth for an allowed provider or adjust enabledModels.`
1293
+ : "No models available. Use /login or set an API key environment variable. Then use /model to select a model.";
1256
1294
  }
1257
1295
  }
1258
1296
 
@@ -1277,6 +1315,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1277
1315
  );
1278
1316
  }
1279
1317
 
1318
+ if (extensionRunner) {
1319
+ credentialDisabledTarget = extensionRunner;
1320
+ for (const event of startupCredentialDisabledEvents.splice(0)) {
1321
+ // Discard return: any handler error is routed through runner.onError listeners.
1322
+ void extensionRunner.emitCredentialDisabled(event);
1323
+ }
1324
+ } else {
1325
+ // No runner to forward to; release our subscription. The embedder's own
1326
+ // onCredentialDisabled (if any) keeps firing through its own subscription.
1327
+ startupCredentialDisabledEvents.length = 0;
1328
+ unsubscribeCredentialDisabled?.();
1329
+ unsubscribeCredentialDisabled = undefined;
1330
+ }
1331
+
1280
1332
  const getSessionContext = () => ({
1281
1333
  sessionManager,
1282
1334
  modelRegistry,
@@ -1766,6 +1818,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1766
1818
  await originalDispose();
1767
1819
  } finally {
1768
1820
  agentRegistry.unregister(resolvedAgentId);
1821
+ unsubscribeCredentialDisabled?.();
1769
1822
  }
1770
1823
  };
1771
1824
  }
@@ -1901,6 +1954,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1901
1954
  eventBus,
1902
1955
  };
1903
1956
  } catch (error) {
1957
+ // Release the subscription if the throw happened after install but before the
1958
+ // dispose-wrap took ownership. Idempotent with dispose() — Set.delete is a no-op
1959
+ // for already-removed listeners.
1960
+ unsubscribeCredentialDisabled?.();
1904
1961
  try {
1905
1962
  if (hasSession) {
1906
1963
  await session.dispose();
@@ -143,7 +143,7 @@ import { outputMeta } from "../tools/output-meta";
143
143
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
144
144
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
145
145
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
146
- import { ToolError } from "../tools/tool-errors";
146
+ import { ToolAbortError, ToolError } from "../tools/tool-errors";
147
147
  import { clampTimeout } from "../tools/tool-timeouts";
148
148
  import { parseCommandArgs } from "../utils/command-args";
149
149
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
@@ -151,7 +151,9 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
151
151
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
152
152
  import { buildNamedToolChoice } from "../utils/tool-choice";
153
153
  import type { AuthStorage } from "./auth-storage";
154
+ import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
154
155
  import {
156
+ CompactionCancelledError,
155
157
  type CompactionPreparation,
156
158
  type CompactionResult,
157
159
  calculateContextTokens,
@@ -498,6 +500,96 @@ const noOpUIContext: ExtensionUIContext = {
498
500
  setToolsExpanded: () => {},
499
501
  };
500
502
 
503
+ // ============================================================================
504
+ // ACP Permission Gate
505
+ // ============================================================================
506
+
507
+ /** Tools that require user permission before execution when an ACP client is connected. */
508
+ const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "write", "ast_edit", "delete", "move"]);
509
+
510
+ /** Permission options presented to the client on each gated tool call. */
511
+ const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
512
+ { optionId: "allow_once", name: "Allow once", kind: "allow_once" },
513
+ { optionId: "allow_always", name: "Always allow", kind: "allow_always" },
514
+ { optionId: "reject_once", name: "Reject", kind: "reject_once" },
515
+ { optionId: "reject_always", name: "Always reject", kind: "reject_always" },
516
+ ];
517
+
518
+ const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option]));
519
+
520
+ function derivePermissionTitle(toolName: string, args: unknown): string {
521
+ const a = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
522
+ if (toolName === "bash") {
523
+ const cmd = typeof a.command === "string" ? a.command.slice(0, 80) : undefined;
524
+ if (cmd) return cmd;
525
+ } else if (toolName === "edit" || toolName === "write" || toolName === "delete") {
526
+ const p = typeof a.path === "string" ? a.path : undefined;
527
+ if (p) {
528
+ const verb = toolName === "edit" ? "Edit" : toolName === "write" ? "Write" : "Delete";
529
+ return `${verb} ${p}`;
530
+ }
531
+ } else if (toolName === "move") {
532
+ const from =
533
+ typeof a.oldPath === "string"
534
+ ? a.oldPath
535
+ : typeof a.path === "string"
536
+ ? a.path
537
+ : typeof a.from === "string"
538
+ ? a.from
539
+ : undefined;
540
+ const to =
541
+ typeof a.newPath === "string"
542
+ ? a.newPath
543
+ : typeof a.to === "string"
544
+ ? a.to
545
+ : typeof a.destination === "string"
546
+ ? a.destination
547
+ : undefined;
548
+ if (from && to) return `Move ${from} to ${to}`;
549
+ if (from) return `Move ${from}`;
550
+ } else if (toolName === "ast_edit") {
551
+ const paths = Array.isArray(a.paths)
552
+ ? (a.paths as unknown[]).filter(x => typeof x === "string").join(", ")
553
+ : undefined;
554
+ if (paths) return `AST edit ${paths}`;
555
+ }
556
+ return toolName;
557
+ }
558
+
559
+ function extractPermissionLocations(args: unknown, cwd: string): { path: string; line?: number }[] {
560
+ if (!args || typeof args !== "object") return [];
561
+ const a = args as Record<string, unknown>;
562
+ const out: { path: string; line?: number }[] = [];
563
+ const pushPath = (value: unknown) => {
564
+ if (typeof value !== "string" || value.length === 0) return;
565
+ // ACP locations carry file paths that the editor host will open or focus;
566
+ // they must be absolute or the client cannot resolve them. Resolve raw
567
+ // tool args (often cwd-relative) against the session cwd before sending.
568
+ let resolved: string;
569
+ try {
570
+ resolved = resolveToCwd(value, cwd);
571
+ } catch {
572
+ return;
573
+ }
574
+ if (out.some(location => location.path === resolved)) return;
575
+ out.push({ path: resolved });
576
+ };
577
+ pushPath(a.path);
578
+ pushPath(a.file);
579
+ if (Array.isArray(a.paths)) {
580
+ for (const p of a.paths) {
581
+ pushPath(p);
582
+ }
583
+ }
584
+ pushPath(a.oldPath);
585
+ pushPath(a.newPath);
586
+ pushPath(a.from);
587
+ pushPath(a.to);
588
+ pushPath(a.source);
589
+ pushPath(a.destination);
590
+ return out;
591
+ }
592
+
501
593
  // ============================================================================
502
594
  // AgentSession Class
503
595
  // ============================================================================
@@ -530,6 +622,9 @@ export class AgentSession {
530
622
  #planModeState: PlanModeState | undefined;
531
623
  #planReferenceSent = false;
532
624
  #planReferencePath = "local://PLAN.md";
625
+ #clientBridge: ClientBridge | undefined;
626
+ /** Per-session memory of allow_always / reject_always decisions for gated tools. */
627
+ #acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
533
628
 
534
629
  // Compaction state
535
630
  #compactionAbortController: AbortController | undefined = undefined;
@@ -2547,6 +2642,85 @@ export class AgentSession {
2547
2642
  return [...new Set(activated)];
2548
2643
  }
2549
2644
 
2645
+ /**
2646
+ * Wrap a tool with a permission-gate proxy when an ACP client is connected.
2647
+ * Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when
2648
+ * the bridge exposes `requestPermission`. No-ops for all other cases.
2649
+ */
2650
+ #wrapToolForAcpPermission<T extends AgentTool>(tool: T): T {
2651
+ const bridge = this.#clientBridge;
2652
+ // Match the capability+method gating pattern used by read/write/bash.
2653
+ if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
2654
+ if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
2655
+ return new Proxy(tool, {
2656
+ get: (target, prop, receiver) => {
2657
+ if (prop !== "execute") return Reflect.get(target, prop, receiver);
2658
+ return async (
2659
+ toolCallId: string,
2660
+ args: unknown,
2661
+ signal: AbortSignal | undefined,
2662
+ onUpdate: never,
2663
+ ctx: never,
2664
+ ) => {
2665
+ // Short-circuit on persisted decisions.
2666
+ const persisted = this.#acpPermissionDecisions.get(target.name);
2667
+ if (persisted === "allow_always") {
2668
+ return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
2669
+ }
2670
+ if (persisted === "reject_always") {
2671
+ throw new ToolError(`Tool call rejected by user (preference)`);
2672
+ }
2673
+ if (signal?.aborted) {
2674
+ throw new ToolAbortError("Permission request cancelled");
2675
+ }
2676
+ type PermissionRaceResult =
2677
+ | { kind: "permission"; outcome: ClientBridgePermissionOutcome }
2678
+ | { kind: "aborted" };
2679
+ const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<PermissionRaceResult>();
2680
+ const onAbort = () => resolveAbort({ kind: "aborted" });
2681
+ signal?.addEventListener("abort", onAbort, { once: true });
2682
+ let raced: PermissionRaceResult;
2683
+ try {
2684
+ const permissionPromise = bridge.requestPermission!(
2685
+ {
2686
+ toolCallId,
2687
+ toolName: target.name,
2688
+ title: derivePermissionTitle(target.name, args),
2689
+ rawInput: args,
2690
+ locations: extractPermissionLocations(args, this.sessionManager.getCwd()),
2691
+ },
2692
+ PERMISSION_OPTIONS,
2693
+ signal,
2694
+ ).then(outcome => ({ kind: "permission" as const, outcome }));
2695
+ raced = await Promise.race([permissionPromise, abortPromise]);
2696
+ } finally {
2697
+ signal?.removeEventListener("abort", onAbort);
2698
+ }
2699
+ if (raced.kind === "aborted" || signal?.aborted) {
2700
+ throw new ToolAbortError("Permission request cancelled");
2701
+ }
2702
+ const outcome = raced.outcome;
2703
+ if (outcome.outcome === "cancelled") {
2704
+ throw new ToolAbortError("Permission request cancelled");
2705
+ }
2706
+ const selectedOption = PERMISSION_OPTIONS_BY_ID.get(outcome.optionId);
2707
+ if (!selectedOption) {
2708
+ throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
2709
+ }
2710
+ if (selectedOption.kind === "allow_always") {
2711
+ this.#acpPermissionDecisions.set(target.name, "allow_always");
2712
+ } else if (selectedOption.kind === "reject_always") {
2713
+ this.#acpPermissionDecisions.set(target.name, "reject_always");
2714
+ }
2715
+ if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
2716
+ throw new ToolError(`Tool call rejected by user (${target.name})`);
2717
+ }
2718
+ return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
2719
+ };
2720
+ },
2721
+ }) as T;
2722
+ }
2723
+
2550
2724
  async #applyActiveToolsByName(
2551
2725
  toolNames: string[],
2552
2726
  options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
@@ -2558,7 +2732,7 @@ export class AgentSession {
2558
2732
  for (const name of toolNames) {
2559
2733
  const tool = this.#toolRegistry.get(name);
2560
2734
  if (tool) {
2561
- tools.push(tool);
2735
+ tools.push(this.#wrapToolForAcpPermission(tool));
2562
2736
  validToolNames.push(name);
2563
2737
  }
2564
2738
  }
@@ -2566,7 +2740,7 @@ export class AgentSession {
2566
2740
  if (isAutoQaEnabled(this.settings) && !validToolNames.includes("report_tool_issue")) {
2567
2741
  const qaTool = this.#toolRegistry.get("report_tool_issue");
2568
2742
  if (qaTool) {
2569
- tools.push(qaTool);
2743
+ tools.push(this.#wrapToolForAcpPermission(qaTool));
2570
2744
  validToolNames.push("report_tool_issue");
2571
2745
  }
2572
2746
  }
@@ -2974,6 +3148,21 @@ export class AgentSession {
2974
3148
  this.#planReferencePath = path;
2975
3149
  }
2976
3150
 
3151
+ get clientBridge(): ClientBridge | undefined {
3152
+ return this.#clientBridge;
3153
+ }
3154
+
3155
+ setClientBridge(bridge: ClientBridge | undefined): void {
3156
+ this.#clientBridge = bridge;
3157
+ this.#acpPermissionDecisions.clear();
3158
+ const activeToolNames = this.getActiveToolNames();
3159
+ const activeTools = activeToolNames
3160
+ .map(name => this.#toolRegistry.get(name))
3161
+ .filter((tool): tool is AgentTool => tool !== undefined)
3162
+ .map(tool => this.#wrapToolForAcpPermission(tool));
3163
+ this.agent.setTools(activeTools);
3164
+ }
3165
+
2977
3166
  getCheckpointState(): CheckpointState | undefined {
2978
3167
  return this.#checkpointState;
2979
3168
  }
@@ -4503,7 +4692,7 @@ export class AgentSession {
4503
4692
  })) as SessionBeforeCompactResult | undefined;
4504
4693
 
4505
4694
  if (result?.cancel) {
4506
- throw new Error("Compaction cancelled");
4695
+ throw new CompactionCancelledError();
4507
4696
  }
4508
4697
 
4509
4698
  if (result?.compaction) {
@@ -4545,27 +4734,47 @@ export class AgentSession {
4545
4734
  details = hookCompaction.details;
4546
4735
  preserveData ??= hookCompaction.preserveData;
4547
4736
  } else {
4548
- // Generate compaction result
4549
- const result = await this.#compactWithFallbackModel(
4550
- preparation,
4551
- customInstructions,
4552
- compactionAbortController.signal,
4553
- {
4554
- promptOverride: hookPrompt,
4555
- extraContext: hookContext,
4556
- remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
4557
- },
4558
- );
4559
- summary = result.summary;
4560
- shortSummary = result.shortSummary;
4561
- firstKeptEntryId = result.firstKeptEntryId;
4562
- tokensBefore = result.tokensBefore;
4563
- details = result.details;
4564
- preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
4737
+ // Generate compaction result. Only convert known abort-shaped
4738
+ // rejections (AbortError raised while the abort signal is set,
4739
+ // or an already-typed sentinel) into `CompactionCancelledError`
4740
+ // so downstream callers can discriminate cancel from generic
4741
+ // failure via `instanceof` without inspecting message strings.
4742
+ // Real compaction bugs (network, server, parsing, etc.) keep
4743
+ // their original shape — they must not be silently relabeled
4744
+ // as cancellations even if the signal happens to be aborted
4745
+ // for an unrelated reason. Assignments live inside the try
4746
+ // block because every catch path throws — the post-try reads
4747
+ // of the result-derived locals are reachable only on success.
4748
+ try {
4749
+ const result = await this.#compactWithFallbackModel(
4750
+ preparation,
4751
+ customInstructions,
4752
+ compactionAbortController.signal,
4753
+ {
4754
+ promptOverride: hookPrompt,
4755
+ extraContext: hookContext,
4756
+ remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
4757
+ },
4758
+ );
4759
+ summary = result.summary;
4760
+ shortSummary = result.shortSummary;
4761
+ firstKeptEntryId = result.firstKeptEntryId;
4762
+ tokensBefore = result.tokensBefore;
4763
+ details = result.details;
4764
+ preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
4765
+ } catch (err) {
4766
+ if (err instanceof CompactionCancelledError) {
4767
+ throw err;
4768
+ }
4769
+ if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
4770
+ throw new CompactionCancelledError();
4771
+ }
4772
+ throw err;
4773
+ }
4565
4774
  }
4566
4775
 
4567
4776
  if (compactionAbortController.signal.aborted) {
4568
- throw new Error("Compaction cancelled");
4777
+ throw new CompactionCancelledError();
4569
4778
  }
4570
4779
 
4571
4780
  this.sessionManager.appendCompaction(