@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.12

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 (80) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +3119 -3028
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/settings-schema.d.ts +6 -0
  6. package/dist/types/config/settings.d.ts +2 -0
  7. package/dist/types/discovery/helpers.d.ts +9 -0
  8. package/dist/types/exec/bash-executor.d.ts +1 -0
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  11. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  12. package/dist/types/modes/components/model-selector.d.ts +2 -1
  13. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  14. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  15. package/dist/types/modes/workflow.d.ts +5 -1
  16. package/dist/types/session/agent-session.d.ts +5 -0
  17. package/dist/types/system-prompt.d.ts +1 -1
  18. package/dist/types/tools/bash-interactive.d.ts +1 -1
  19. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  20. package/dist/types/tools/bash.d.ts +2 -1
  21. package/dist/types/tools/browser/launch.d.ts +1 -0
  22. package/dist/types/tools/grep.d.ts +2 -0
  23. package/dist/types/tools/index.d.ts +4 -0
  24. package/dist/types/tools/path-utils.d.ts +24 -0
  25. package/dist/types/tools/read.d.ts +2 -0
  26. package/dist/types/utils/local-date.d.ts +2 -0
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +145 -0
  29. package/src/advisor/runtime.ts +19 -0
  30. package/src/config/api-key-resolver.ts +7 -2
  31. package/src/config/model-registry.ts +9 -0
  32. package/src/config/settings-schema.ts +11 -1
  33. package/src/config/settings.ts +11 -0
  34. package/src/discovery/builtin.ts +2 -1
  35. package/src/discovery/claude-plugins.ts +167 -46
  36. package/src/discovery/helpers.ts +16 -1
  37. package/src/edit/renderer.ts +20 -6
  38. package/src/eval/js/worker-core.ts +163 -6
  39. package/src/exec/bash-executor.ts +14 -9
  40. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  41. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  42. package/src/extensibility/shared-events.ts +2 -2
  43. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  44. package/src/internal-urls/docs-index.generated.txt +1 -1
  45. package/src/internal-urls/registry-helpers.ts +9 -6
  46. package/src/modes/components/model-selector.ts +30 -6
  47. package/src/modes/components/settings-defs.ts +1 -1
  48. package/src/modes/components/status-line/component.ts +14 -2
  49. package/src/modes/controllers/command-controller.ts +13 -23
  50. package/src/modes/controllers/event-controller.ts +12 -12
  51. package/src/modes/controllers/extension-ui-controller.ts +6 -35
  52. package/src/modes/controllers/input-controller.ts +18 -2
  53. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  54. package/src/modes/controllers/selector-controller.ts +16 -5
  55. package/src/modes/github-ref-autocomplete.ts +75 -0
  56. package/src/modes/interactive-mode.ts +54 -10
  57. package/src/modes/prompt-action-autocomplete.ts +35 -0
  58. package/src/modes/utils/context-usage.ts +58 -5
  59. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  60. package/src/modes/utils/ui-helpers.ts +2 -2
  61. package/src/modes/workflow.ts +14 -8
  62. package/src/prompts/system/plan-mode-active.md +5 -2
  63. package/src/prompts/system/system-prompt.md +1 -2
  64. package/src/prompts/system/workflow-notice.md +69 -50
  65. package/src/prompts/tools/bash.md +18 -7
  66. package/src/prompts/tools/grep.md +1 -1
  67. package/src/prompts/tools/read.md +4 -3
  68. package/src/sdk.ts +11 -0
  69. package/src/session/agent-session.ts +128 -14
  70. package/src/system-prompt.ts +3 -2
  71. package/src/tools/bash-interactive.ts +1 -1
  72. package/src/tools/bash-skill-urls.ts +39 -7
  73. package/src/tools/bash.ts +69 -39
  74. package/src/tools/browser/launch.ts +31 -4
  75. package/src/tools/grep.ts +53 -14
  76. package/src/tools/index.ts +11 -0
  77. package/src/tools/path-utils.ts +46 -1
  78. package/src/tools/read.ts +108 -50
  79. package/src/utils/local-date.ts +7 -0
  80. package/src/utils/open.ts +36 -10
@@ -6,14 +6,22 @@ The shell invokes **real binaries** with simple args. It is NOT full GNU Bash.
6
6
 
7
7
  Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a fact and does not depend on shell-specific regex/quoting (`wc -l`, `sort | uniq -c`, `comm`, `diff`, a checksum, `git status`).
8
8
 
9
- Anything below → `eval` cell, not bash:
9
+ {{#if hasEval}}Anything below → `eval` cell, not bash:
10
10
  - Inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists for that language
11
11
  - Heredocs (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
12
12
  - `$(…)` command substitution nested inside another command
13
13
  - Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
14
14
  - Multiline commands, `&&`-chains mixing control flow
15
15
  - Quote/JSON escaping that fights the shell
16
- - GNU grep BRE extensions are not guaranteed in the embedded shell: use `grep -E 'json|tool'` for alternation instead of `grep 'json\|tool'`; use the built-in `grep` tool with `pattern: "json|tool"` (Rust regex, so `\bword\b` works there), or `eval` for exact text processing.
16
+ {{else}}Anything below means you are writing a shell program, not invoking one. Prefer a purpose-built tool, a checked-in script, or a single repo command instead:
17
+ - Inline interpreter scripts (`-e`/`-c`/`--eval`)
18
+ - Heredocs (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
19
+ - `$(…)` command substitution nested inside another command
20
+ - Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
21
+ - Multiline commands, `&&`-chains mixing control flow
22
+ - Quote/JSON escaping that fights the shell
23
+ {{/if}}
24
+ {{#if hasGrep}}- GNU grep BRE extensions are not guaranteed in the embedded shell: use `grep -E 'json|tool'` for alternation instead of `grep 'json\|tool'`; use the built-in `grep` tool with `pattern: "json|tool"` (Rust regex, so `\bword\b` works there){{#if hasEval}}, or `eval` for exact text processing{{/if}}.{{else}}- GNU grep BRE extensions are not guaranteed in the embedded shell: use `grep -E 'json|tool'` for alternation instead of `grep 'json\|tool'`{{#if hasEval}}, or use `eval` for exact text processing{{/if}}.{{/if}}
17
25
 
18
26
  <instruction>
19
27
  - `cwd` sets the working dir, not `cd dir && …`
@@ -23,14 +31,17 @@ Anything below → `eval` cell, not bash:
23
31
  - `;` only when later commands should run despite earlier failures
24
32
  - Multiple bash calls per message run concurrently. NEVER split order-dependent commands across parallel calls — chain with `&&` in one call.
25
33
  - Internal URIs (`skill://`, `agent://`, …) auto-resolve to FS paths
26
- - Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Prefer `eval` and process the stream directly.
34
+ {{#if hasEval}}- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Prefer `eval` and process the stream directly.{{else}}- Need exact pipeline semantics (`cmd | head`, multi-stage filtering) or output truncation? Use a checked-in script, purpose-built tool, or single command that owns the output shape.{{/if}}
27
35
  {{#if asyncEnabled}}
28
36
  - `async: true` for long-running commands when you don't need immediate output: returns a background job ID; result delivered as a follow-up.
29
37
  {{/if}}
30
38
  </instruction>
31
39
 
32
40
  <critical>
33
- - The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.
41
+ {{#if hasEval}}- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash and NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.{{else}}- The embedded shell invokes real binaries with simple args; it is NOT full GNU Bash and NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts, several piped stages, exact pipeline semantics, or quote/JSON escaping mean you're writing a shell program; use a purpose-built tool or checked-in script instead.{{/if}}
42
+ {{#if hasGrep}}- NEVER shell out to search content or files: `grep/rg` → `grep`.{{else}}- Avoid shelling out for broad content search; use an active search/read tool when one is available.{{/if}}
43
+ {{#if hasRead}}{{#if hasGlob}}- NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `glob` tool (globbing). This is non-negotiable, even for a single quick listing.{{else}}- Prefer `read` for known file and directory reads. Only use shell listing when no file-listing tool is active.{{/if}}{{else}}{{#if hasGlob}}- Prefer `glob` for file discovery; avoid `find` when `glob` is active.{{else}}- If no file read/listing tool is active, keep shell inspection narrow and state that limitation.{{/if}}{{/if}}
44
+ - Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
34
45
  </critical>
35
46
 
36
47
  <output>
@@ -41,9 +52,9 @@ Anything below → `eval` cell, not bash:
41
52
  {{#if asyncEnabled}}
42
53
  # Timeout and async
43
54
 
44
- - `timeout` is seconds, clamped to `1..3600`; the process is killed on elapse.
45
- - `async: true` defers only reporting — it does NOT extend the timeout; a daemon with `async: true` is still killed at the clamped timeout.
46
- - Need >3600s? Detach/manage lifecycle yourself (`cmd &`, supervisor, self-restarting script). The shell session persists across calls.
55
+ - `timeout` is seconds; nonzero values are clamped to `1..3600` and the process is killed on elapse. Set `timeout: 0` only for commands that must run until completion or explicit cancellation.
56
+ - `async: true` defers only reporting — it does NOT extend a nonzero timeout; use `timeout: 0` when a daemon or watcher must be cancellation-owned.
57
+ - Need a daemon or >3600s run? Use `async: true` with `timeout: 0` when the harness should keep it alive until cancellation, or detach/manage lifecycle yourself (`cmd &`, supervisor, self-restarting script). The shell session persists across calls.
47
58
  {{/if}}
48
59
  {{#if autoBackgroundEnabled}}
49
60
 
@@ -2,7 +2,7 @@ Greps files using regex.
2
2
 
3
3
  <instruction>
4
4
  - Rust regex (RE2-style): alternation is `foo|bar`, not GNU BRE-style `foo\|bar`; Rust word boundaries like `\bword\b` are supported. Use line anchors or post-filters instead of lookaround/backreferences.
5
- - `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`).
5
+ - `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Literal colon filename + line range? Use `selector` (e.g. `{"path":"test:1-2","selector":"1-2"}`), not recursive `path:"test:1-2:1-2"`.
6
6
  - Cross-line patterns detected from literal `\n` or `\\n` in `pattern`.
7
7
  </instruction>
8
8
 
@@ -1,4 +1,4 @@
1
- Read files, directories, archives, SQLite, images, documents, internal resources, and web URLs via one `path`.
1
+ Read files, directories, archives, SQLite, images, documents, internal resources, and web URLs via `path` plus optional `selector`.
2
2
 
3
3
  <instruction>
4
4
  - SHOULD parallelize independent reads.
@@ -7,7 +7,8 @@ Read files, directories, archives, SQLite, images, documents, internal resources
7
7
 
8
8
  ## Parameters
9
9
 
10
- - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`, `ssh://`), or URL. Append `:<sel>` for ranges/modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
10
+ - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`, `ssh://`), or URL. Inline `:<sel>` still works for ranges/modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
11
+ - `selector` — optional selector without leading `:` (e.g. `"50-200"`, `"raw"`, `"raw:50-100"`, `"conflicts"`). Use when `path` contains literal colons: `{"path":"test:1-2","selector":"1-2"}`.
11
12
 
12
13
  ## Selectors
13
14
 
@@ -72,6 +73,6 @@ All URI schemes take the same line selectors. `artifact://<id>` recovers spilled
72
73
  `ssh://host/<absolute-path>` reads a remote text file (UTF-8, ≤1 MiB) or lists a directory one level deep, on a pre-configured SSH host or `~/.ssh/config` alias; `ssh://host/` lists the remote root and bare `ssh://` lists the configured hosts. Files are also writable via `write` and searchable via `search`; a directory only lists (`search` refuses a directory, `write` refuses to overwrite one). A literal `:`, `?`, or `#` in the remote path must be percent-encoded (`%3A`/`%3F`/`%23`) — a trailing `:sel` is read as a line selector, and `?`/`#` start a URL query/fragment. Requires a POSIX login shell (`sh`/`bash`/`zsh`); a Windows host or a non-POSIX shell (fish, csh/tcsh) is rejected — use the `ssh` tool there.
73
74
 
74
75
  <critical>
75
- - Line ranges go in the selector: `path="src/foo.ts:50-200"`.
76
+ - Literal colon filename + selector? Use `selector`, not recursive `path:"file:sel:sel"`.
76
77
  - Summary footer names elided ranges? Re-issue ONLY those ranges. NEVER guess `..`/`…` content.
77
78
  </critical>
package/src/sdk.ts CHANGED
@@ -1525,10 +1525,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1525
1525
  // entries capture it at fetch time and are dropped at injection if a newer
1526
1526
  // mutation (any tool) bumped it in the meantime.
1527
1527
  const fileMutationVersions = new Map<string, number>();
1528
+ const activeToolNames = new Set<string>();
1529
+ const setActiveToolNames = (names: Iterable<string>): void => {
1530
+ activeToolNames.clear();
1531
+ for (const name of names) {
1532
+ activeToolNames.add(name);
1533
+ }
1534
+ };
1528
1535
  const toolSession: ToolSession = {
1529
1536
  get cwd() {
1530
1537
  return sessionManager.getCwd();
1531
1538
  },
1539
+ isToolActive: name => activeToolNames.has(name),
1540
+ setActiveToolNames,
1532
1541
  hasUI: options.hasUI ?? false,
1533
1542
  enableLsp,
1534
1543
  get hasEditTool() {
@@ -2558,6 +2567,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2558
2567
  });
2559
2568
  hasRegistered = true;
2560
2569
 
2570
+ setActiveToolNames(initialToolNames);
2561
2571
  const { systemPrompt } = await logger.time(
2562
2572
  "buildSystemPrompt",
2563
2573
  rebuildSystemPrompt,
@@ -2852,6 +2862,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2852
2862
  rebuildSystemPrompt,
2853
2863
  reloadSshTool,
2854
2864
  requestedToolNames: requestedToolNameSet,
2865
+ setActiveToolNames,
2855
2866
  getMcpServerInstructions: mcpManager
2856
2867
  ? () => {
2857
2868
  const raw = mcpManager.getServerInstructions();
@@ -35,6 +35,7 @@ import {
35
35
  type AsideMessage,
36
36
  type CompactionSummaryMessage,
37
37
  countTokens,
38
+ createToolScopedAbortReason,
38
39
  resolveTelemetry,
39
40
  type StreamFn,
40
41
  ThinkingLevel,
@@ -108,6 +109,7 @@ import {
108
109
  clearAnthropicFastModeFallback,
109
110
  deriveClaudeDeviceId,
110
111
  Effort,
112
+ isUsageLimitOutcome,
111
113
  parseRateLimitReason,
112
114
  realizesPriorityServiceTier,
113
115
  resolveModelServiceTier,
@@ -124,6 +126,7 @@ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
124
126
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
125
127
  import {
126
128
  escapeXmlText,
129
+ extractHttpStatusFromError,
127
130
  extractRetryHint,
128
131
  formatDuration,
129
132
  getAgentDbPath,
@@ -179,7 +182,12 @@ import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
179
182
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
180
183
  import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
181
184
  import type { Settings, SkillsSettings } from "../config/settings";
182
- import { getDefault, onAppendOnlyModeChanged, validateProviderMaxInFlightRequests } from "../config/settings";
185
+ import {
186
+ getDefault,
187
+ onAppendOnlyModeChanged,
188
+ onModelRolesChanged,
189
+ validateProviderMaxInFlightRequests,
190
+ } from "../config/settings";
183
191
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
184
192
  import { loadCapability } from "../discovery";
185
193
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
@@ -237,7 +245,7 @@ import { theme } from "../modes/theme/theme";
237
245
  import { parseTurnBudget } from "../modes/turn-budget";
238
246
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
239
247
  import { computeNonMessageBreakdown, computeNonMessageTokens } from "../modes/utils/context-usage";
240
- import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
248
+ import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow";
241
249
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
242
250
  import type { PlanModeState } from "../plan-mode/state";
243
251
  import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
@@ -314,6 +322,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
314
322
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
315
323
  import { normalizeModelContextImages } from "../utils/image-loading";
316
324
  import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallback";
325
+ import { formatLocalCalendarDate } from "../utils/local-date";
317
326
  import { generateSessionTitle } from "../utils/title-generator";
318
327
  import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
319
328
  import type { AuthStorage } from "./auth-storage";
@@ -686,6 +695,8 @@ export interface AgentSessionConfig {
686
695
  toolRegistry?: Map<string, AgentTool>;
687
696
  /** Tool names whose current registry entry is still the built-in implementation. */
688
697
  builtInToolNames?: Iterable<string>;
698
+ /** Update tool-session predicates that render guidance from the live active tool set. */
699
+ setActiveToolNames?: (names: Iterable<string>) => void;
689
700
  /** Current session pre-LLM message transform pipeline */
690
701
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
691
702
  /**
@@ -724,6 +735,8 @@ export interface AgentSessionConfig {
724
735
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
725
736
  /** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */
726
737
  rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
738
+ /** Local calendar date provider used by prompt-cache invalidation. Defaults to the host local date. */
739
+ getLocalCalendarDate?: () => string;
727
740
  /** Rebuild the SSH tool from current capability discovery results. */
728
741
  reloadSshTool?: () => Promise<AgentTool | null>;
729
742
  requestedToolNames?: ReadonlySet<string>;
@@ -870,6 +883,7 @@ export interface HandoffResult {
870
883
  export interface SessionHandoffOptions {
871
884
  autoTriggered?: boolean;
872
885
  signal?: AbortSignal;
886
+ onSwitchCancelled?: () => void;
873
887
  }
874
888
 
875
889
  /** Result from cycleModel() */
@@ -1555,6 +1569,7 @@ export class AgentSession {
1555
1569
  #cancelExitRecorder?: () => void;
1556
1570
  #exitRecorded = false;
1557
1571
  #unsubscribeAppendOnly?: () => void;
1572
+ #unsubscribeModelRoles?: () => void;
1558
1573
  /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
1559
1574
  #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
1560
1575
  #eventListeners: AgentSessionEventListener[] = [];
@@ -1716,8 +1731,10 @@ export class AgentSession {
1716
1731
  #rebuildSystemPrompt:
1717
1732
  | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
1718
1733
  | undefined;
1734
+ #getLocalCalendarDate: () => string;
1719
1735
  #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
1720
1736
  #reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
1737
+ #setActiveToolNames: ((names: Iterable<string>) => void) | undefined;
1721
1738
  #disconnectOwnedMcpManager: (() => Promise<void>) | undefined;
1722
1739
  #requestedToolNames: ReadonlySet<string> | undefined;
1723
1740
  #baseSystemPrompt: string[];
@@ -2161,8 +2178,10 @@ export class AgentSession {
2161
2178
  });
2162
2179
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
2163
2180
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
2181
+ this.#getLocalCalendarDate = config.getLocalCalendarDate ?? formatLocalCalendarDate;
2164
2182
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
2165
2183
  this.#reloadSshTool = config.reloadSshTool;
2184
+ this.#setActiveToolNames = config.setActiveToolNames;
2166
2185
  this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager;
2167
2186
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
2168
2187
  this.#promptModelKey = this.#currentPromptModelKey();
@@ -2259,6 +2278,11 @@ export class AgentSession {
2259
2278
  this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
2260
2279
  // Re-evaluate append-only context mode when the setting changes at runtime.
2261
2280
  this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
2281
+ this.#unsubscribeModelRoles = onModelRolesChanged(() => {
2282
+ if (!this.#advisorEnabled || this.#isDisposed) return;
2283
+ if (this.#advisors.length > 0 && !this.#advisorRuntimeMatchesCurrentConfig()) this.#stopAdvisorRuntime();
2284
+ this.#buildAdvisorRuntime(true);
2285
+ });
2262
2286
  }
2263
2287
  // -------------------------------------------------------------------------
2264
2288
  // Advisor runtime lifecycle
@@ -2391,7 +2415,9 @@ export class AgentSession {
2391
2415
  #advisorRuntimeSignature(config: AdvisorConfig, slug: string, model: Model, thinkingLevel: ThinkingLevel): string {
2392
2416
  const tools = config.tools?.length ? config.tools.join("\u001e") : "";
2393
2417
  const instructions = config.instructions?.trim() ?? "";
2394
- return [config.name, slug, model.provider, model.id, thinkingLevel, tools, instructions].join("\u001f");
2418
+ return [config.name, slug, formatModelStringWithRouting(model), thinkingLevel, tools, instructions].join(
2419
+ "\u001f",
2420
+ );
2395
2421
  }
2396
2422
 
2397
2423
  #advisorRuntimeMatchesCurrentConfig(): boolean {
@@ -2539,6 +2565,21 @@ export class AgentSession {
2539
2565
  maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
2540
2566
  obfuscator: this.#obfuscator,
2541
2567
  beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
2568
+ onTurnError: async error => {
2569
+ // Mirror the auth-gateway's usage-limit remedy: the in-stream a/b/c
2570
+ // auth retry rotates through siblings within one request but never
2571
+ // blocks the LAST failing credential, so without this the advisor
2572
+ // re-picks the same exhausted account every retry. Usage limits
2573
+ // only — other failures keep the plain retry/notify path (never
2574
+ // suspect-mark a credential on a transient advisor error).
2575
+ const message = error instanceof Error ? error.message : String(error);
2576
+ if (!isUsageLimitOutcome(extractHttpStatusFromError(error), message)) return;
2577
+ await this.#modelRegistry.authStorage.markUsageLimitReached(advisorModel.provider, advisorSessionId, {
2578
+ retryAfterMs: extractRetryHint(undefined, message),
2579
+ baseUrl: advisorModel.baseUrl,
2580
+ modelId: advisorModel.id,
2581
+ });
2582
+ },
2542
2583
  notifyFailure: error => {
2543
2584
  const message = error instanceof Error ? error.message : String(error);
2544
2585
  this.emitNotice(
@@ -4666,7 +4707,8 @@ export class AgentSession {
4666
4707
  // Decide first: a non-interrupting tool-source match attaches to the
4667
4708
  // specific tool call's result instead of driving a loop-wide follow-up.
4668
4709
  const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext);
4669
- const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext);
4710
+ const matchedToolId = this.#extractTtsrToolCallId(matchContext);
4711
+ const perToolId = shouldInterrupt ? undefined : matchedToolId;
4670
4712
  if (perToolId) {
4671
4713
  this.#addPerToolTtsrInjections(perToolId, matches);
4672
4714
  this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
@@ -4682,7 +4724,16 @@ export class AgentSession {
4682
4724
  // Abort the stream immediately — do not gate on extension callbacks
4683
4725
  this.#ttsrAbortPending = true;
4684
4726
  this.#ensureTtsrResumePromise();
4685
- this.agent.abort(this.#formatTtsrAbortReason(matches));
4727
+ const abortReason = this.#formatTtsrAbortReason(matches);
4728
+ this.agent.abort(
4729
+ matchedToolId
4730
+ ? createToolScopedAbortReason(
4731
+ abortReason,
4732
+ { [matchedToolId]: abortReason },
4733
+ "TTSR interrupt on another tool call",
4734
+ )
4735
+ : abortReason,
4736
+ );
4686
4737
  // Notify extensions (fire-and-forget, does not block abort)
4687
4738
  this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
4688
4739
  // Schedule retry after a short delay
@@ -5755,6 +5806,10 @@ export class AgentSession {
5755
5806
  this.#unsubscribeAppendOnly();
5756
5807
  this.#unsubscribeAppendOnly = undefined;
5757
5808
  }
5809
+ if (this.#unsubscribeModelRoles) {
5810
+ this.#unsubscribeModelRoles();
5811
+ this.#unsubscribeModelRoles = undefined;
5812
+ }
5758
5813
  this.#eventListeners = [];
5759
5814
  }
5760
5815
 
@@ -6298,6 +6353,7 @@ export class AgentSession {
6298
6353
  ),
6299
6354
  );
6300
6355
  }
6356
+ this.#setActiveToolNames?.(validToolNames);
6301
6357
  const activeNameSet = new Set(validToolNames);
6302
6358
  for (const name of Array.from(this.#selectedDiscoveredToolNames)) {
6303
6359
  if (!activeNameSet.has(name) || isMCPToolName(name) || !this.#toolRegistry.has(name)) {
@@ -6403,6 +6459,7 @@ export class AgentSession {
6403
6459
  async refreshBaseSystemPrompt(): Promise<void> {
6404
6460
  if (!this.#rebuildSystemPrompt) return;
6405
6461
  const activeToolNames = this.getActiveToolNames();
6462
+ this.#setActiveToolNames?.(activeToolNames);
6406
6463
  const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
6407
6464
  this.#baseSystemPrompt = built.systemPrompt;
6408
6465
  this.#baseSystemPromptBeforeMemoryPromotion = undefined;
@@ -6525,7 +6582,7 @@ export class AgentSession {
6525
6582
  entries.sort();
6526
6583
  instructionsSegment = entries.join("\u0006");
6527
6584
  }
6528
- const date = new Date().toISOString().slice(0, 10);
6585
+ const date = this.#getLocalCalendarDate();
6529
6586
  return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}|${date}`;
6530
6587
  }
6531
6588
 
@@ -7342,11 +7399,15 @@ export class AgentSession {
7342
7399
  timestamp,
7343
7400
  });
7344
7401
  }
7345
- if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
7402
+ if (
7403
+ this.#magicKeywordEnabled("workflow") &&
7404
+ containsWorkflow(text) &&
7405
+ this.getActiveToolNames().includes("task")
7406
+ ) {
7346
7407
  keywordNotices.push({
7347
7408
  role: "custom",
7348
7409
  customType: "workflow-notice",
7349
- content: WORKFLOW_NOTICE,
7410
+ content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }),
7350
7411
  display: false,
7351
7412
  attribution: "user",
7352
7413
  timestamp,
@@ -8787,6 +8848,7 @@ export class AgentSession {
8787
8848
 
8788
8849
  const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
8789
8850
 
8851
+ this.#modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
8790
8852
  this.#clearActiveRetryFallback();
8791
8853
  this.#setModelWithProviderSessionReset(targetModel);
8792
8854
  this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
@@ -8824,6 +8886,7 @@ export class AgentSession {
8824
8886
 
8825
8887
  const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
8826
8888
 
8889
+ this.#modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(targetModel));
8827
8890
  this.#clearActiveRetryFallback();
8828
8891
  this.#setModelWithProviderSessionReset(targetModel);
8829
8892
  this.sessionManager.appendModelChange(
@@ -8983,6 +9046,7 @@ export class AgentSession {
8983
9046
  const next = scopedModels[nextIndex];
8984
9047
 
8985
9048
  // Apply model
9049
+ this.#modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(next.model));
8986
9050
  this.#clearActiveRetryFallback();
8987
9051
  this.#setModelWithProviderSessionReset(next.model);
8988
9052
  this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
@@ -9013,6 +9077,7 @@ export class AgentSession {
9013
9077
  throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
9014
9078
  }
9015
9079
 
9080
+ this.#modelRegistry.clearSuppressedSelector(formatModelStringWithRouting(nextModel));
9016
9081
  this.#clearActiveRetryFallback();
9017
9082
  this.#setModelWithProviderSessionReset(nextModel);
9018
9083
  this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
@@ -10041,6 +10106,17 @@ export class AgentSession {
10041
10106
 
10042
10107
  // Start a new session
10043
10108
  const previousSessionFile = this.sessionFile;
10109
+ if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
10110
+ const result = (await this.#extensionRunner.emit({
10111
+ type: "session_before_switch",
10112
+ reason: "handoff",
10113
+ })) as SessionBeforeSwitchResult | undefined;
10114
+
10115
+ if (result?.cancel) {
10116
+ options?.onSwitchCancelled?.();
10117
+ return undefined;
10118
+ }
10119
+ }
10044
10120
  await this.sessionManager.flush();
10045
10121
  this.#cancelOwnAsyncJobs();
10046
10122
  await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
@@ -10097,6 +10173,13 @@ export class AgentSession {
10097
10173
  this.agent.replaceMessages(sessionContext.messages);
10098
10174
  this.#resetAllAdvisorRuntimes();
10099
10175
  this.#syncTodoPhasesFromBranch();
10176
+ if (this.#extensionRunner) {
10177
+ await this.#extensionRunner.emit({
10178
+ type: "session_switch",
10179
+ reason: "handoff",
10180
+ previousSessionFile,
10181
+ });
10182
+ }
10100
10183
 
10101
10184
  return { document: handoffText, savedPath };
10102
10185
  } catch (error) {
@@ -12313,13 +12396,17 @@ export class AgentSession {
12313
12396
  // queue, not the core steering queue (which handoff's agent.reset() would wipe).
12314
12397
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
12315
12398
  if (action === "handoff") {
12399
+ let handoffSwitchCancelled = false;
12316
12400
  const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
12317
12401
  const handoffResult = await this.handoff(handoffFocus, {
12318
12402
  autoTriggered: true,
12319
12403
  signal: autoCompactionSignal,
12404
+ onSwitchCancelled: () => {
12405
+ handoffSwitchCancelled = true;
12406
+ },
12320
12407
  });
12321
12408
  if (!handoffResult) {
12322
- const aborted = autoCompactionSignal.aborted;
12409
+ const aborted = autoCompactionSignal.aborted || handoffSwitchCancelled;
12323
12410
  if (aborted) {
12324
12411
  await this.#emitSessionEvent({
12325
12412
  type: "auto_compaction_end",
@@ -13220,6 +13307,7 @@ export class AgentSession {
13220
13307
  #resolveRetryFallbackRole(currentSelector: string): string | undefined {
13221
13308
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
13222
13309
  if (!parsedCurrent) return undefined;
13310
+ const chains = this.#getRetryFallbackChains();
13223
13311
  const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
13224
13312
  const currentPlainSelector = this.model
13225
13313
  ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
@@ -13229,11 +13317,11 @@ export class AgentSession {
13229
13317
  ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
13230
13318
  : undefined;
13231
13319
 
13232
- for (const role of Object.keys(this.#getRetryFallbackChains())) {
13320
+ for (const role of Object.keys(chains)) {
13233
13321
  const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13234
13322
  if (primarySelector?.raw === currentSelector) return role;
13235
13323
  }
13236
- for (const role of Object.keys(this.#getRetryFallbackChains())) {
13324
+ for (const role of Object.keys(chains)) {
13237
13325
  const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13238
13326
  if (!primarySelector) continue;
13239
13327
  if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role;
@@ -13241,6 +13329,14 @@ export class AgentSession {
13241
13329
  if (primaryBaseSelector === currentBaseSelector) return role;
13242
13330
  if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role;
13243
13331
  }
13332
+ const defaultChain = chains.default;
13333
+ if (
13334
+ Array.isArray(defaultChain) &&
13335
+ defaultChain.length > 0 &&
13336
+ this.#getRetryFallbackPrimarySelector("default") === undefined
13337
+ ) {
13338
+ return "default";
13339
+ }
13244
13340
  return undefined;
13245
13341
  }
13246
13342
 
@@ -13259,9 +13355,27 @@ export class AgentSession {
13259
13355
  }
13260
13356
 
13261
13357
  #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
13262
- const chain = this.#getRetryFallbackEffectiveChain(role);
13263
- if (chain.length <= 1) return [];
13358
+ let chain = this.#getRetryFallbackEffectiveChain(role);
13264
13359
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
13360
+ if (chain.length === 0 && role === "default" && parsedCurrent) {
13361
+ const chains = this.#getRetryFallbackChains();
13362
+ const defaultChain = chains.default;
13363
+ if (
13364
+ Array.isArray(defaultChain) &&
13365
+ defaultChain.length > 0 &&
13366
+ this.#getRetryFallbackPrimarySelector("default") === undefined
13367
+ ) {
13368
+ const seen = new Set<string>([parsedCurrent.raw]);
13369
+ chain = [parsedCurrent];
13370
+ for (const selector of defaultChain) {
13371
+ const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
13372
+ if (!parsed || seen.has(parsed.raw)) continue;
13373
+ seen.add(parsed.raw);
13374
+ chain.push(parsed);
13375
+ }
13376
+ }
13377
+ }
13378
+ if (chain.length <= 1) return [];
13265
13379
  const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
13266
13380
  const currentPlainSelector =
13267
13381
  this.model && parsedCurrent
@@ -15535,7 +15649,7 @@ export class AgentSession {
15535
15649
  lastAttemptAtByAccount: coordinator.lastAttemptAtByAccount,
15536
15650
  });
15537
15651
  if (!decision.redeem) {
15538
- logger.debug("codex-auto-reset: skipped", { reason: decision.reason });
15652
+ logger.debug("codex-auto-reset: skipped", { reason: decision.reason, account: accountKey });
15539
15653
  return false;
15540
15654
  }
15541
15655
  if (shouldPromptCodexAutoRedeem(cfg.autoRedeem) && !(await this.#confirmCodexAutoRedeem(decision))) {
@@ -24,6 +24,7 @@ import projectPromptTemplate from "./prompts/system/project-prompt.md" with { ty
24
24
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
25
25
  import { shortenPath } from "./tools/render-utils";
26
26
  import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
27
+ import { formatLocalCalendarDate } from "./utils/local-date";
27
28
  import { normalizePromptPath } from "./utils/prompt-path";
28
29
  import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
29
30
 
@@ -400,7 +401,7 @@ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {
400
401
  return userLevel?.content ?? null;
401
402
  }
402
403
 
403
- export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "eval", "edit", "write"] as const;
404
+ export const DEFAULT_SYSTEM_PROMPT_TOOL_NAMES = ["read", "bash", "edit", "write"] as const;
404
405
 
405
406
  export interface SystemPromptToolMetadata {
406
407
  label: string;
@@ -693,7 +694,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
693
694
  }
694
695
  }
695
696
 
696
- const date = new Date().toISOString().slice(0, 10);
697
+ const date = formatLocalCalendarDate();
697
698
  const dateTime = date;
698
699
  const promptCwd = shortenPath(normalizePromptPath(resolvedCwd));
699
700
  const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
@@ -300,7 +300,7 @@ export async function runInteractiveBashPty(
300
300
  options: {
301
301
  command: string;
302
302
  cwd: string;
303
- timeoutMs: number;
303
+ timeoutMs?: number;
304
304
  signal?: AbortSignal;
305
305
  env?: Record<string, string>;
306
306
  artifactPath?: string;
@@ -140,6 +140,30 @@ function unquoteToken(token: string): string {
140
140
  return token;
141
141
  }
142
142
 
143
+ function isInsideShellQuote(command: string, index: number): boolean {
144
+ let quote: "'" | '"' | undefined;
145
+ for (let i = 0; i < index; i++) {
146
+ const char = command[i];
147
+ if (char === "\\" && quote !== "'") {
148
+ i++;
149
+ continue;
150
+ }
151
+ if (char === "'" && quote !== '"') {
152
+ quote = quote === "'" ? undefined : "'";
153
+ continue;
154
+ }
155
+ if (char === '"' && quote !== "'") {
156
+ quote = quote === '"' ? undefined : '"';
157
+ }
158
+ }
159
+ return quote !== undefined;
160
+ }
161
+
162
+ function isEmbeddedInQuotedText(command: string, token: string, index: number): boolean {
163
+ if (token.startsWith("'") || token.startsWith('"')) return false;
164
+ return isInsideShellQuote(command, index);
165
+ }
166
+
143
167
  /** Shell-escape a path using single quotes. */
144
168
  function shellEscape(p: string): string {
145
169
  return `'${p.replace(/'/g, "'\\''")}'`;
@@ -216,6 +240,7 @@ export function expandSkillUrls(command: string, skills: readonly Skill[]): stri
216
240
 
217
241
  /**
218
242
  * Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
243
+ * Unresolvable URLs and literal mentions inside larger quoted text are left unchanged.
219
244
  * Supported schemes: skill://, agent://, artifact://, memory://, rule://, local://
220
245
  */
221
246
  export async function expandInternalUrls(command: string, options: InternalUrlExpansionOptions): Promise<string> {
@@ -231,15 +256,22 @@ export async function expandInternalUrls(command: string, options: InternalUrlEx
231
256
  const index = match.index;
232
257
  if (index === undefined) continue;
233
258
 
259
+ if (isEmbeddedInQuotedText(command, token, index)) continue;
260
+
234
261
  const rawUrl = unquoteToken(token);
235
262
  const url = normalizeLocalScheme(rawUrl);
236
- const resolvedPath = await resolveInternalUrlToPath(
237
- url,
238
- options.skills,
239
- options.internalRouter,
240
- options.localOptions,
241
- options.ensureLocalParentDirs,
242
- );
263
+ let resolvedPath: string;
264
+ try {
265
+ resolvedPath = await resolveInternalUrlToPath(
266
+ url,
267
+ options.skills,
268
+ options.internalRouter,
269
+ options.localOptions,
270
+ options.ensureLocalParentDirs,
271
+ );
272
+ } catch {
273
+ continue;
274
+ }
243
275
  const replacement = options.noEscape ? resolvedPath : shellEscape(resolvedPath);
244
276
  expanded = `${expanded.slice(0, index)}${replacement}${expanded.slice(index + token.length)}`;
245
277
  }