@oh-my-pi/pi-coding-agent 16.2.9 → 16.2.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 (110) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3464 -3306
  3. package/dist/types/cli/bench-cli.d.ts +0 -5
  4. package/dist/types/cli/dry-balance-cli.d.ts +0 -5
  5. package/dist/types/cli/models-cli.d.ts +1 -1
  6. package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
  7. package/dist/types/config/model-registry.d.ts +1 -23
  8. package/dist/types/config/model-resolver.d.ts +10 -15
  9. package/dist/types/config/models-config-schema.d.ts +0 -6
  10. package/dist/types/config/models-config.d.ts +0 -6
  11. package/dist/types/config/settings-schema.d.ts +31 -1
  12. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +15 -0
  14. package/dist/types/extensibility/skills.d.ts +29 -0
  15. package/dist/types/lsp/client.d.ts +9 -3
  16. package/dist/types/modes/components/todo-reminder.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +0 -1
  18. package/dist/types/modes/skill-command.d.ts +1 -1
  19. package/dist/types/modes/types.d.ts +0 -1
  20. package/dist/types/stt/asr-client.d.ts +7 -3
  21. package/dist/types/task/spawn-policy.d.ts +17 -0
  22. package/dist/types/task/spawn-policy.test.d.ts +1 -0
  23. package/dist/types/task/types.d.ts +8 -2
  24. package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
  25. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
  26. package/dist/types/tools/browser/registry.d.ts +2 -0
  27. package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
  28. package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
  29. package/dist/types/tools/eval.d.ts +3 -6
  30. package/dist/types/tools/write.d.ts +1 -1
  31. package/dist/types/utils/image-vision-fallback.d.ts +1 -1
  32. package/package.json +14 -13
  33. package/scripts/bundle-dist.ts +23 -4
  34. package/scripts/generate-docs-index.ts +116 -24
  35. package/src/advisor/__tests__/advisor.test.ts +1 -1
  36. package/src/async/job-manager.ts +27 -3
  37. package/src/auto-thinking/classifier.ts +1 -1
  38. package/src/cli/bench-cli.ts +3 -12
  39. package/src/cli/dry-balance-cli.ts +1 -6
  40. package/src/cli/grep-cli.ts +1 -1
  41. package/src/cli/models-cli.ts +3 -81
  42. package/src/commands/models.ts +1 -2
  43. package/src/commit/model-selection.ts +4 -4
  44. package/src/config/inline-tool-descriptors-mode.ts +1 -2
  45. package/src/config/model-discovery.ts +150 -82
  46. package/src/config/model-registry.ts +11 -225
  47. package/src/config/model-resolver.ts +23 -135
  48. package/src/config/models-config-schema.ts +0 -22
  49. package/src/config/prompt-templates.ts +20 -0
  50. package/src/config/settings-schema.ts +35 -1
  51. package/src/config/settings.ts +60 -24
  52. package/src/eval/__tests__/agent-bridge.test.ts +17 -3
  53. package/src/eval/agent-bridge.ts +7 -9
  54. package/src/eval/completion-bridge.ts +1 -1
  55. package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
  56. package/src/eval/py/runner.py +169 -18
  57. package/src/extensibility/extensions/model-api.ts +1 -3
  58. package/src/extensibility/extensions/runner.ts +62 -5
  59. package/src/extensibility/skills.ts +77 -0
  60. package/src/internal-urls/docs-index.generated.txt +2 -2
  61. package/src/lsp/client.ts +162 -45
  62. package/src/lsp/config.ts +17 -3
  63. package/src/lsp/index.ts +31 -21
  64. package/src/main.ts +0 -1
  65. package/src/mcp/transports/http.ts +19 -17
  66. package/src/mcp/transports/stdio.test.ts +51 -1
  67. package/src/mcp/transports/stdio.ts +19 -9
  68. package/src/memories/index.ts +0 -1
  69. package/src/mnemopi/backend.ts +1 -1
  70. package/src/modes/acp/acp-agent.ts +6 -9
  71. package/src/modes/components/model-selector.ts +32 -186
  72. package/src/modes/components/todo-reminder.ts +5 -1
  73. package/src/modes/controllers/event-controller.ts +1 -16
  74. package/src/modes/controllers/selector-controller.ts +57 -36
  75. package/src/modes/interactive-mode.ts +0 -6
  76. package/src/modes/rpc/rpc-mode.ts +5 -8
  77. package/src/modes/skill-command.ts +8 -20
  78. package/src/modes/types.ts +0 -1
  79. package/src/prompts/system/subagent-system-prompt.md +2 -2
  80. package/src/prompts/system/tool-call-loop-redirect.md +8 -0
  81. package/src/prompts/tools/eval.md +2 -2
  82. package/src/prompts/tools/task.md +1 -1
  83. package/src/sdk.ts +2 -9
  84. package/src/session/agent-session.ts +89 -8
  85. package/src/session/session-context.ts +2 -1
  86. package/src/session/session-manager.ts +2 -1
  87. package/src/session/unexpected-stop-classifier.ts +1 -1
  88. package/src/stt/asr-client.ts +87 -27
  89. package/src/stt/downloader.ts +8 -2
  90. package/src/task/executor.ts +1 -3
  91. package/src/task/index.ts +37 -32
  92. package/src/task/spawn-policy.test.ts +63 -0
  93. package/src/task/spawn-policy.ts +58 -0
  94. package/src/task/types.ts +77 -6
  95. package/src/tools/__tests__/eval-description.test.ts +19 -0
  96. package/src/tools/ast-grep.ts +34 -12
  97. package/src/tools/browser/cmux/cmux-tab.ts +66 -24
  98. package/src/tools/browser/registry.ts +25 -0
  99. package/src/tools/browser/run-cancellation.ts +47 -0
  100. package/src/tools/browser/tab-supervisor.ts +120 -7
  101. package/src/tools/browser/tab-worker.ts +22 -10
  102. package/src/tools/browser.ts +1 -0
  103. package/src/tools/eval.ts +15 -10
  104. package/src/tools/grep.ts +11 -8
  105. package/src/tools/inspect-image.ts +1 -1
  106. package/src/tools/write.ts +49 -9
  107. package/src/utils/commit-message-generator.ts +0 -1
  108. package/src/utils/git.ts +22 -1
  109. package/src/utils/image-vision-fallback.ts +2 -3
  110. package/src/utils/title-generator.ts +1 -1
@@ -248,7 +248,6 @@ export class SelectorController {
248
248
  const advisorRoleSel = resolveAdvisorRoleSelection(
249
249
  this.ctx.settings,
250
250
  this.ctx.session.modelRegistry.getAvailable(),
251
- this.ctx.session.modelRegistry,
252
251
  );
253
252
  const defaultAdvisorModel = advisorRoleSel?.model;
254
253
  const deps: AdvisorConfigDeps = {
@@ -962,43 +961,65 @@ export class SelectorController {
962
961
  // every project's history when the cwd has nothing to resume. See #3099.
963
962
  const historyStorage = this.ctx.historyStorage;
964
963
  const historyMatcher = historyStorage ? (query: string) => historyStorage.matchingSessionIds(query) : undefined;
965
- this.showSelector(done => {
966
- const selector = new SessionSelectorComponent(
967
- sessions,
968
- async (session: SessionInfo) => {
969
- done();
970
- await this.handleResumeSession(session.path);
971
- },
972
- () => {
973
- done();
974
- this.ctx.ui.requestRender();
975
- },
976
- () => {
977
- void this.ctx.shutdown();
978
- },
979
- {
980
- onDelete: async (session: SessionInfo) => {
981
- if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) {
982
- return false;
983
- }
984
- const storage = new FileSessionStorage();
985
- try {
986
- await storage.deleteSessionWithArtifacts(session.path);
987
- return true;
988
- } catch (err) {
989
- throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, {
990
- cause: err,
991
- });
992
- }
993
- },
994
- historyMatcher,
995
- loadAllSessions: () => SessionManager.listAll(),
996
- getTerminalRows: () => this.ctx.ui.terminal.rows,
964
+ // Fullscreen session picker on the alternate screen (the /settings idiom):
965
+ // the overlay borrows the alt buffer and enables mouse tracking (wheel
966
+ // scroll + click-to-resume) for its lifetime, leaving the transcript
967
+ // untouched underneath. Anchored top-left at full size so a mouse row maps
968
+ // directly to a rendered line (the overlay paints from screen row 0), and
969
+ // `fillHeight` pads the body so the footer pins to the screen bottom.
970
+ let overlayHandle: OverlayHandle | undefined;
971
+ const done = () => {
972
+ overlayHandle?.hide();
973
+ this.focusActiveEditorArea();
974
+ this.ctx.ui.requestRender();
975
+ };
976
+ const selector = new SessionSelectorComponent(
977
+ sessions,
978
+ async (session: SessionInfo) => {
979
+ done();
980
+ await this.handleResumeSession(session.path);
981
+ },
982
+ () => {
983
+ done();
984
+ },
985
+ () => {
986
+ // Release the alt buffer before teardown: shutdown() awaits flush/save/
987
+ // dispose/drain before stop() leaves the alt screen, so without this the
988
+ // fullscreen picker would freeze on screen for that window on Ctrl+C.
989
+ done();
990
+ void this.ctx.shutdown();
991
+ },
992
+ {
993
+ onDelete: async (session: SessionInfo) => {
994
+ if (!(await this.#detachActiveSessionBeforeDeletion(session.path))) {
995
+ return false;
996
+ }
997
+ const storage = new FileSessionStorage();
998
+ try {
999
+ await storage.deleteSessionWithArtifacts(session.path);
1000
+ return true;
1001
+ } catch (err) {
1002
+ throw new Error(`Failed to delete session: ${err instanceof Error ? err.message : String(err)}`, {
1003
+ cause: err,
1004
+ });
1005
+ }
997
1006
  },
998
- );
999
- selector.setOnRequestRender(() => this.ctx.ui.requestRender());
1000
- return { component: selector, focus: selector };
1007
+ historyMatcher,
1008
+ loadAllSessions: () => SessionManager.listAll(),
1009
+ getTerminalRows: () => this.ctx.ui.terminal.rows,
1010
+ fillHeight: true,
1011
+ },
1012
+ );
1013
+ selector.setOnRequestRender(() => this.ctx.ui.requestRender());
1014
+ overlayHandle = this.ctx.ui.showOverlay(selector, {
1015
+ anchor: "top-left",
1016
+ width: "100%",
1017
+ maxHeight: "100%",
1018
+ margin: 0,
1019
+ fullscreen: true,
1001
1020
  });
1021
+ this.ctx.ui.setFocus(selector);
1022
+ this.ctx.ui.requestRender();
1002
1023
  }
1003
1024
 
1004
1025
  #refreshSessionTerminalTitle(): void {
@@ -379,7 +379,6 @@ export class InteractiveMode implements InteractiveModeContext {
379
379
  chatContainer: TranscriptContainer;
380
380
  pendingMessagesContainer: Container;
381
381
  statusContainer: Container;
382
- todoReminderContainer: Container;
383
382
  todoContainer: Container;
384
383
  subagentContainer: Container;
385
384
  btwContainer: Container;
@@ -548,7 +547,6 @@ export class InteractiveMode implements InteractiveModeContext {
548
547
  this.retryLoader = undefined;
549
548
  }
550
549
  this.statusContainer.clear();
551
- this.todoReminderContainer.clear();
552
550
  this.pendingMessagesContainer.clear();
553
551
  this.#cancelModelCycleClearTimer();
554
552
  this.modelCycleContainer.clear();
@@ -628,7 +626,6 @@ export class InteractiveMode implements InteractiveModeContext {
628
626
  this.chatContainer = new TranscriptContainer();
629
627
  this.pendingMessagesContainer = new Container();
630
628
  this.statusContainer = new AnchoredLiveContainer();
631
- this.todoReminderContainer = new AnchoredLiveContainer();
632
629
  this.todoContainer = new AnchoredLiveContainer();
633
630
  this.subagentContainer = new AnchoredLiveContainer();
634
631
  this.btwContainer = new AnchoredLiveContainer();
@@ -848,7 +845,6 @@ export class InteractiveMode implements InteractiveModeContext {
848
845
 
849
846
  this.ui.addChild(this.chatContainer);
850
847
  this.ui.addChild(this.pendingMessagesContainer);
851
- this.ui.addChild(this.todoReminderContainer);
852
848
  this.ui.addChild(this.todoContainer);
853
849
  this.ui.addChild(this.subagentContainer);
854
850
  this.ui.addChild(this.btwContainer);
@@ -4077,14 +4073,12 @@ export class InteractiveMode implements InteractiveModeContext {
4077
4073
  },
4078
4074
  ];
4079
4075
  }
4080
- this.todoReminderContainer.clear();
4081
4076
  this.#syncTodoAutoClearTimer();
4082
4077
  this.#renderTodoList();
4083
4078
  this.ui.requestRender();
4084
4079
  }
4085
4080
 
4086
4081
  async reloadTodos(): Promise<void> {
4087
- this.todoReminderContainer.clear();
4088
4082
  await this.#loadTodoList();
4089
4083
  this.ui.requestRender();
4090
4084
  }
@@ -22,7 +22,7 @@ import {
22
22
  type ExtensionWidgetOptions,
23
23
  getExtensionUISelectOptionLabel,
24
24
  } from "../../extensibility/extensions";
25
- import { buildSkillPromptMessage } from "../../extensibility/skills";
25
+ import { buildSkillPromptMessage, parseSkillInvocation } from "../../extensibility/skills";
26
26
  import { loadSlashCommands } from "../../extensibility/slash-commands";
27
27
  import { type Theme, theme } from "../../modes/theme/theme";
28
28
  import type { AgentSession } from "../../session/agent-session";
@@ -86,15 +86,12 @@ export async function tryRunRpcSkillCommand(
86
86
  session: RpcSkillCommandSession,
87
87
  text: string,
88
88
  ): Promise<RpcSkillCommandResult | false> {
89
- if (!text.startsWith("/skill:")) return false;
90
89
  if (!session.skillsSettings?.enableSkillCommands) return false;
91
- const spaceIndex = text.indexOf(" ");
92
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
93
- const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
94
- const skillName = commandName.slice("skill:".length);
95
- const skill = session.skills.find(candidate => candidate.name === skillName);
90
+ const parsed = parseSkillInvocation(text);
91
+ if (!parsed) return false;
92
+ const skill = session.skills.find(candidate => candidate.name === parsed.name);
96
93
  if (!skill) return false;
97
- const built = await buildSkillPromptMessage(skill, args);
94
+ const built = await buildSkillPromptMessage(skill, parsed.args);
98
95
  await session.promptCustomMessage({
99
96
  customType: SKILL_PROMPT_MESSAGE_TYPE,
100
97
  content: built.message,
@@ -1,4 +1,5 @@
1
1
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
2
+ import { getSkillSlashCommandName, parseSkillInvocation } from "../extensibility/skills";
2
3
  import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../session/messages";
3
4
  import type { InteractiveModeContext } from "./types";
4
5
 
@@ -20,11 +21,6 @@ type SkillPromptOptions = {
20
21
  queueChipText: string;
21
22
  };
22
23
 
23
- interface ParsedSkillCommand {
24
- commandName: string;
25
- args: string;
26
- }
27
-
28
24
  interface InvokeSkillCommandOptions {
29
25
  propagateErrors?: boolean;
30
26
  queueOnly?: boolean;
@@ -37,19 +33,11 @@ export interface BuiltSkillCommandPrompt {
37
33
  options: SkillPromptOptions;
38
34
  }
39
35
 
40
- function parseSkillCommand(text: string): ParsedSkillCommand | undefined {
41
- if (!text.startsWith("/skill:")) return undefined;
42
- const spaceIndex = text.indexOf(" ");
43
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
44
- const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
45
- return { commandName, args };
46
- }
47
-
48
- /** Return true when `text` names a registered `/skill:<name>` command. */
36
+ /** Return true when `text` invokes a registered `/skill:<name>` command. */
49
37
  export function isKnownSkillCommand(ctx: SkillCommandHost, text: string): boolean {
50
- const parsed = parseSkillCommand(text);
38
+ const parsed = parseSkillInvocation(text);
51
39
  if (!parsed) return false;
52
- return ctx.skillCommands.has(parsed.commandName);
40
+ return ctx.skillCommands.has(getSkillSlashCommandName({ name: parsed.name }));
53
41
  }
54
42
 
55
43
  /** Build the user-attributed custom message for a registered `/skill:<name>` command. */
@@ -59,9 +47,10 @@ export async function buildSkillCommandPrompt(
59
47
  streamingBehavior: "steer" | "followUp",
60
48
  images?: ImageContent[],
61
49
  ): Promise<BuiltSkillCommandPrompt | undefined> {
62
- const parsed = parseSkillCommand(text);
50
+ const parsed = parseSkillInvocation(text);
63
51
  if (!parsed) return undefined;
64
- const skillPath = ctx.skillCommands.get(parsed.commandName);
52
+ const commandName = getSkillSlashCommandName({ name: parsed.name });
53
+ const skillPath = ctx.skillCommands.get(commandName);
65
54
  if (!skillPath) return undefined;
66
55
 
67
56
  const content = await Bun.file(skillPath).text();
@@ -73,9 +62,8 @@ export async function buildSkillCommandPrompt(
73
62
  const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
74
63
  const textBlock: TextContent = { type: "text", text: message };
75
64
  const promptContent = images && images.length > 0 ? [textBlock, ...images] : message;
76
- const skillName = parsed.commandName.slice("skill:".length);
77
65
  const details: SkillPromptDetails = {
78
- name: skillName || parsed.commandName,
66
+ name: parsed.name,
79
67
  path: skillPath,
80
68
  args: parsed.args || undefined,
81
69
  lineCount: body ? body.split("\n").length : 0,
@@ -96,7 +96,6 @@ export interface InteractiveModeContext {
96
96
  chatContainer: TranscriptContainer;
97
97
  pendingMessagesContainer: Container;
98
98
  statusContainer: Container;
99
- todoReminderContainer: Container;
100
99
  todoContainer: Container;
101
100
  subagentContainer: Container;
102
101
  btwContainer: Container;
@@ -62,9 +62,9 @@ Yield protocol:
62
62
  This is your only way to return a final result. For structured results, you NEVER put JSON in plain text or substitute a text summary for `result.data`.
63
63
 
64
64
  {{#if outputSchema}}
65
- Your result MUST match this TypeScript interface:
65
+ Your terminal `yield` MUST use exactly this shape — the schema fields go inside `result.data`, NEVER at the top level and NEVER as a stringified summary:
66
66
  ```ts
67
- {{jtdToTypeScript outputSchema}}
67
+ {{renderYieldSchema outputSchema}}
68
68
  ```
69
69
  {{/if}}
70
70
 
@@ -0,0 +1,8 @@
1
+ <system-interrupt reason="tool_call_loop_detected">
2
+ You called `{{tool_name}}` {{count}} consecutive times with identical arguments:
3
+ `{{arguments_summary}}`
4
+
5
+ Last result (truncated): `{{result_summary}}`
6
+
7
+ NEVER call `{{tool_name}}` with those arguments again this turn. Use different arguments, choose another tool, or summarize findings and yield if complete.
8
+ </system-interrupt>
@@ -39,8 +39,8 @@ tool.<name>(args) → unknown
39
39
  Invoke any session tool; `args` = its parameter object.
40
40
  completion(prompt, model?="default", system?=None, schema?=None) → str | dict
41
41
  Oneshot, stateless (no history/tools). `model`: "smol" fast | "default" session | "slow" most capable. `schema` (JSON-Schema) → structured output, parsed object.
42
- {{#if spawns}}agent(prompt, agent?="task", model?=None, label?=None, schema?=None, handle?=False) → str | dict
43
- Run a subagent → final output. `agent` picks another discovered agent; `schema` as in completion(). Background via `local://` files named in the prompt. `handle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
42
+ {{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, handle?=False) → str | dict
43
+ Run a subagent → final output. `agent` picks another discovered agent; omit it to use `{{spawnDefaultAgent}}`.{{#if spawnAllowedAgentsText}} Allowed agents: {{spawnAllowedAgentsText}}.{{/if}} `schema` as in completion(). Background via `local://` files named in the prompt. `handle` → DAG node dict { text, output, handle: "agent://<id>", id, agent } (parsed under `data` when `schema` set).
44
44
  {{#if js}} JS: options are ONE trailing object — agent(prompt, { agent, schema, handle }).
45
45
  {{/if}}
46
46
  {{/if}}
@@ -10,7 +10,7 @@ Execution blocks your turn: the call only returns once the work is completely fi
10
10
  - **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.
11
11
 
12
12
  # Inputs
13
- - `agent` (optional): The base agent type to use (e.g., `explore`, `reviewer`). Defaults to `task` (the general-purpose worker) — omit it for the default worker instead of passing `agent: "task"`.
13
+ - `agent` (optional): The base agent type to use (e.g., `explore`, `reviewer`). Defaults to `{{defaultAgent}}`{{#if defaultAgentIsGeneric}} (the general-purpose worker){{/if}} — omit it for the default worker instead of passing `agent: "{{defaultAgent}}"`.{{#if allowedAgentsText}} Current spawn policy allows: {{allowedAgentsText}}.{{/if}}
14
14
  {{#if batchEnabled}}
15
15
  - `context`: Shared project state, constraints, and contracts. Applies to the entire batch; do not duplicate this background into individual tasks.
16
16
  - `tasks[]`: Array of subagents to spawn.
package/src/sdk.ts CHANGED
@@ -1244,7 +1244,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1244
1244
  resolveModelRoleValue(settings.getModelRole("default"), allowedModels, {
1245
1245
  settings,
1246
1246
  matchPreferences: modelMatchPreferences,
1247
- modelRegistry,
1248
1247
  }),
1249
1248
  );
1250
1249
  let model = options.model;
@@ -1946,9 +1945,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1946
1945
  if (!model && options.modelPattern) {
1947
1946
  const availableModels = modelRegistry.getAll();
1948
1947
  const matchPreferences = getModelMatchPreferences(settings);
1949
- const { model: resolved } = parseModelPattern(options.modelPattern, availableModels, matchPreferences, {
1950
- modelRegistry,
1951
- });
1948
+ const { model: resolved } = parseModelPattern(options.modelPattern, availableModels, matchPreferences);
1952
1949
  if (resolved) {
1953
1950
  model = resolved;
1954
1951
  modelFallbackMessage = undefined;
@@ -1978,7 +1975,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1978
1975
  const reResolvedRoleSpec = resolveModelRoleValue(settings.getModelRole("default"), fallbackCandidates, {
1979
1976
  settings,
1980
1977
  matchPreferences: modelMatchPreferences,
1981
- modelRegistry,
1982
1978
  });
1983
1979
  if (reResolvedRoleSpec.model) {
1984
1980
  defaultRoleSpec = reResolvedRoleSpec;
@@ -2179,10 +2175,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2179
2175
  // `auto` enforces the per-model policy (inline for Gemini, off otherwise);
2180
2176
  // like the rest of the prune machinery this is fixed for the session, so a
2181
2177
  // mid-session model switch keeps the start-time decision.
2182
- const inlineToolDescriptors = shouldInlineToolDescriptors(
2183
- settings.get("inlineToolDescriptors"),
2184
- model ? (modelRegistry.getCanonicalId(model) ?? model.id) : undefined,
2185
- );
2178
+ const inlineToolDescriptors = shouldInlineToolDescriptors(settings.get("inlineToolDescriptors"), model?.id);
2186
2179
  const eagerTasks = settings.get("task.eager") !== "default";
2187
2180
  const eagerTasksAlways = settings.get("task.eager") === "always";
2188
2181
  const intentField = $flag("PI_INTENT_TRACING", settings.get("tools.intentTracing")) ? INTENT_FIELD : undefined;
@@ -115,6 +115,7 @@ import {
115
115
  import * as AIError from "@oh-my-pi/pi-ai/error";
116
116
  import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
117
117
  import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
118
+ import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard";
118
119
  import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
119
120
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
120
121
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
@@ -251,6 +252,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
251
252
  };
252
253
  import sideChannelNoToolsReminder from "../prompts/system/side-channel-no-tools.md" with { type: "text" };
253
254
  import thinkingLoopRedirectTemplate from "../prompts/system/thinking-loop-redirect.md" with { type: "text" };
255
+ import toolCallLoopRedirectTemplate from "../prompts/system/tool-call-loop-redirect.md" with { type: "text" };
254
256
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
255
257
  import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
256
258
  import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
@@ -285,6 +287,7 @@ import {
285
287
  selectDiscoverableToolNamesByServer,
286
288
  } from "../tool-discovery/tool-index";
287
289
  import { assertEditableFile } from "../tools/auto-generated-guard";
290
+ import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
288
291
  import { normalizeToolNames } from "../tools/builtin-names";
289
292
  import type { CheckpointState } from "../tools/checkpoint";
290
293
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
@@ -350,6 +353,7 @@ const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
350
353
  /** `customType` for the hidden redirect notice injected into a turn retried after a
351
354
  * thinking/response loop. Steers the model off the repeated content; never displayed. */
352
355
  const THINKING_LOOP_REDIRECT_TYPE = "thinking-loop-redirect";
356
+ const TOOL_CALL_LOOP_REDIRECT_TYPE = "tool-call-loop-redirect";
353
357
 
354
358
  // A side-channel assistant response is signed for the hidden prompt/history that
355
359
  // produced it. If we persist that response under a different user turn, native
@@ -1554,6 +1558,8 @@ export class AgentSession {
1554
1558
  * `#geminiHeaderGuardActive`); undefined for non-Gemini models or when the
1555
1559
  * guard is off. Fed thinking deltas in the assistant-message interceptor. */
1556
1560
  #geminiHeaderDetector: GeminiHeaderRunDetector | undefined;
1561
+ #toolCallLoopGuard: ToolCallLoopGuard | undefined;
1562
+ #toolCallLoopGuardSettingsKey: string | undefined;
1557
1563
  #promptInFlightCount = 0;
1558
1564
  #abortInProgress = false;
1559
1565
  // Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
@@ -1848,6 +1854,13 @@ export class AgentSession {
1848
1854
  this.#pendingRewindReport = undefined;
1849
1855
  await this.#applyRewind(rewindReport, messages);
1850
1856
  }
1857
+ if (context?.message.role === "assistant") {
1858
+ const detection = this.#activeToolCallLoopGuard()?.recordTurn({
1859
+ message: context.message,
1860
+ toolResults: context.toolResults,
1861
+ });
1862
+ if (detection) this.#maybeInjectToolCallLoopRedirect(messages, detection);
1863
+ }
1851
1864
  this.#advisorPrimaryTurnsCompleted++;
1852
1865
  if (this.#advisors.length > 0) {
1853
1866
  for (const a of this.#advisors) {
@@ -2095,11 +2108,7 @@ export class AgentSession {
2095
2108
  continue;
2096
2109
  }
2097
2110
  } else {
2098
- const sel = resolveAdvisorRoleSelection(
2099
- this.settings,
2100
- this.#modelRegistry.getAvailable(),
2101
- this.#modelRegistry,
2102
- );
2111
+ const sel = resolveAdvisorRoleSelection(this.settings, this.#modelRegistry.getAvailable());
2103
2112
  if (!sel) {
2104
2113
  logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2105
2114
  advisor: config.name,
@@ -4299,6 +4308,58 @@ export class AgentSession {
4299
4308
  this.#streamingEditFileCache.clear();
4300
4309
  }
4301
4310
 
4311
+ #activeToolCallLoopGuard(): ToolCallLoopGuard | undefined {
4312
+ if (this.settings.get("model.toolCallLoopGuard.enabled") !== true) {
4313
+ this.#toolCallLoopGuard = undefined;
4314
+ this.#toolCallLoopGuardSettingsKey = undefined;
4315
+ return undefined;
4316
+ }
4317
+
4318
+ const threshold = this.settings.get("model.toolCallLoopGuard.threshold");
4319
+ const exemptTools = this.settings
4320
+ .get("model.toolCallLoopGuard.exemptTools")
4321
+ .filter((tool): tool is string => typeof tool === "string" && tool.length > 0);
4322
+ const settingsKey = `${threshold}:${JSON.stringify(exemptTools)}`;
4323
+ if (!this.#toolCallLoopGuard || this.#toolCallLoopGuardSettingsKey !== settingsKey) {
4324
+ this.#toolCallLoopGuard = new ToolCallLoopGuard({ threshold, exemptTools });
4325
+ this.#toolCallLoopGuardSettingsKey = settingsKey;
4326
+ }
4327
+ return this.#toolCallLoopGuard;
4328
+ }
4329
+
4330
+ #maybeInjectToolCallLoopRedirect(messages: AgentMessage[], detection: RepeatedToolCallDetection): void {
4331
+ const content = prompt.render(toolCallLoopRedirectTemplate, {
4332
+ tool_name: detection.toolName,
4333
+ count: detection.count,
4334
+ arguments_summary: detection.argumentsSummary,
4335
+ result_summary: detection.resultSummary || "(no text result)",
4336
+ });
4337
+ const details = {
4338
+ toolName: detection.toolName,
4339
+ count: detection.count,
4340
+ argumentsSummary: detection.argumentsSummary,
4341
+ resultSummary: detection.resultSummary,
4342
+ };
4343
+ logger.warn("cross-turn tool-call loop detected", {
4344
+ toolName: detection.toolName,
4345
+ count: detection.count,
4346
+ });
4347
+ const redirectMessage: CustomMessage = {
4348
+ role: "custom",
4349
+ customType: TOOL_CALL_LOOP_REDIRECT_TYPE,
4350
+ content,
4351
+ display: false,
4352
+ details,
4353
+ attribution: "agent",
4354
+ timestamp: Date.now(),
4355
+ };
4356
+ messages.push(redirectMessage);
4357
+ if (this.agent.state.messages !== messages) {
4358
+ this.agent.appendMessage(redirectMessage);
4359
+ }
4360
+ this.sessionManager.appendCustomMessageEntry(TOOL_CALL_LOOP_REDIRECT_TYPE, content, false, details, "agent");
4361
+ }
4362
+
4302
4363
  /**
4303
4364
  * Whether the Gemini header-runaway guard applies to the current model: the loop
4304
4365
  * guard is on (settings + `PI_NO_THINKING_LOOP_GUARD`), the tool-call reminder is
@@ -5093,6 +5154,28 @@ export class AgentSession {
5093
5154
  await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
5094
5155
  await disposeRubyKernelSessionsByOwner(this.#evalKernelOwnerId);
5095
5156
  await disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId);
5157
+ // Release headless / spawned Chromium and worker tabs this session
5158
+ // opened via the browser tool. The tool's `tabs`/`browsers` maps are
5159
+ // module-global — subagents and future sessions share them — so we
5160
+ // walk by `ownerSessionId` (assigned at `acquireTab` creation, never on
5161
+ // reuse) and touch only what THIS session created. Bounded so a broken
5162
+ // CDP close cannot stall `/exit`; mirrors the async-job/MCP pattern.
5163
+ // (Issue #3963.)
5164
+ const browserOwnerId = this.sessionManager.getSessionId();
5165
+ if (browserOwnerId) {
5166
+ try {
5167
+ const released = await withTimeout(
5168
+ releaseTabsForOwner(browserOwnerId, { kill: true }),
5169
+ 3_000,
5170
+ "Timed out releasing owned browser tabs during dispose",
5171
+ );
5172
+ if (released > 0) {
5173
+ logger.debug("Released owned browser tabs during dispose", { ownerId: browserOwnerId, released });
5174
+ }
5175
+ } catch (error) {
5176
+ logger.warn("Failed to release owned browser tabs during dispose", { error: String(error) });
5177
+ }
5178
+ }
5096
5179
  await shutdownTinyTitleClient();
5097
5180
  this.#releasePowerAssertion();
5098
5181
  // Clean up an empty session created by this session's /move so it doesn't accumulate.
@@ -8158,7 +8241,6 @@ export class AgentSession {
8158
8241
  const resolved = resolveModelRoleValue(roleModelStr, availableModels, {
8159
8242
  settings: this.settings,
8160
8243
  matchPreferences,
8161
- modelRegistry: this.#modelRegistry,
8162
8244
  });
8163
8245
  if (!resolved.model) continue;
8164
8246
 
@@ -8299,7 +8381,7 @@ export class AgentSession {
8299
8381
  const all = this.#modelRegistry.getAvailable();
8300
8382
  const patterns = this.settings.get("enabledModels");
8301
8383
  if (!patterns || patterns.length === 0) return all;
8302
- return filterAvailableModelsByEnabledPatterns(all, patterns, this.#modelRegistry);
8384
+ return filterAvailableModelsByEnabledPatterns(all, patterns);
8303
8385
  }
8304
8386
 
8305
8387
  // =========================================================================
@@ -10713,7 +10795,6 @@ export class AgentSession {
10713
10795
  return resolveModelRoleValue(roleModelStr, availableModels, {
10714
10796
  settings: this.settings,
10715
10797
  matchPreferences: getModelMatchPreferences(this.settings),
10716
- modelRegistry: this.#modelRegistry,
10717
10798
  });
10718
10799
  }
10719
10800
 
@@ -142,9 +142,10 @@ export function buildSessionContext(
142
142
  const path: SessionEntry[] = [];
143
143
  let current: SessionEntry | undefined = leaf;
144
144
  while (current) {
145
- path.unshift(current);
145
+ path.push(current);
146
146
  current = current.parentId ? byId.get(current.parentId) : undefined;
147
147
  }
148
+ path.reverse();
148
149
 
149
150
  // Extract settings and find compaction
150
151
  let thinkingLevel: string | undefined = "off";
@@ -242,9 +242,10 @@ class SessionEntryIndex {
242
242
 
243
243
  while (cursor && !seen.has(cursor.id)) {
244
244
  seen.add(cursor.id);
245
- branch.unshift(cursor);
245
+ branch.push(cursor);
246
246
  cursor = cursor.parentId ? this.#entriesById.get(cursor.parentId) : undefined;
247
247
  }
248
+ branch.reverse();
248
249
 
249
250
  return branch;
250
251
  }
@@ -64,7 +64,7 @@ export async function classifyUnexpectedStop(
64
64
  }
65
65
 
66
66
  async function classifyOnline(text: string, deps: ClassifyUnexpectedStopDeps): Promise<boolean | undefined> {
67
- const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
67
+ const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable());
68
68
  const model = resolved?.model;
69
69
  if (!model) {
70
70
  throw new Error("unexpected-stop: no tiny/smol model available for classification");