@oh-my-pi/pi-coding-agent 16.0.3 → 16.0.5

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 (75) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +697 -337
  3. package/dist/types/advisor/advise-tool.d.ts +9 -0
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/bench-cli.d.ts +6 -0
  6. package/dist/types/commands/launch.d.ts +6 -0
  7. package/dist/types/config/settings-schema.d.ts +92 -3
  8. package/dist/types/edit/file-snapshot-store.d.ts +2 -0
  9. package/dist/types/extensibility/extensions/runner.d.ts +5 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -7
  11. package/dist/types/extensibility/shared-events.d.ts +22 -1
  12. package/dist/types/main.d.ts +1 -0
  13. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  14. package/dist/types/modes/components/status-line/context-thresholds.d.ts +0 -1
  15. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  16. package/dist/types/modes/utils/context-usage.d.ts +12 -0
  17. package/dist/types/sdk.d.ts +3 -1
  18. package/dist/types/session/agent-session.d.ts +20 -0
  19. package/dist/types/session/session-persistence.d.ts +4 -0
  20. package/dist/types/tools/read.d.ts +1 -0
  21. package/dist/types/tui/code-cell.d.ts +2 -0
  22. package/dist/types/utils/image-vision-fallback.d.ts +28 -0
  23. package/dist/types/web/search/providers/base.d.ts +1 -0
  24. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  25. package/package.json +12 -12
  26. package/src/advisor/__tests__/advisor.test.ts +59 -0
  27. package/src/advisor/advise-tool.ts +13 -0
  28. package/src/cli/args.ts +4 -0
  29. package/src/cli/bench-cli.ts +30 -7
  30. package/src/cli/flag-tables.ts +9 -0
  31. package/src/collab/host.ts +2 -2
  32. package/src/commands/launch.ts +6 -0
  33. package/src/config/settings-schema.ts +85 -3
  34. package/src/edit/file-snapshot-store.ts +12 -3
  35. package/src/eval/py/runner.py +44 -0
  36. package/src/extensibility/extensions/runner.ts +20 -2
  37. package/src/extensibility/extensions/types.ts +16 -5
  38. package/src/extensibility/shared-events.ts +24 -0
  39. package/src/internal-urls/docs-index.generated.ts +81 -81
  40. package/src/main.ts +18 -9
  41. package/src/modes/components/branch-summary-message.ts +1 -0
  42. package/src/modes/components/collab-prompt-message.ts +9 -7
  43. package/src/modes/components/compaction-summary-message.ts +1 -0
  44. package/src/modes/components/custom-message.ts +1 -0
  45. package/src/modes/components/footer.ts +6 -5
  46. package/src/modes/components/hook-message.ts +1 -0
  47. package/src/modes/components/read-tool-group.ts +9 -3
  48. package/src/modes/components/skill-message.ts +1 -0
  49. package/src/modes/components/status-line/component.ts +131 -14
  50. package/src/modes/components/status-line/context-thresholds.ts +0 -1
  51. package/src/modes/components/tips.txt +2 -1
  52. package/src/modes/components/todo-reminder.ts +1 -0
  53. package/src/modes/components/ttsr-notification.ts +1 -0
  54. package/src/modes/components/user-message.ts +6 -6
  55. package/src/modes/controllers/event-controller.ts +2 -7
  56. package/src/modes/controllers/selector-controller.ts +10 -3
  57. package/src/modes/interactive-mode.ts +4 -2
  58. package/src/modes/rpc/rpc-types.ts +1 -1
  59. package/src/modes/utils/context-usage.ts +28 -15
  60. package/src/prompts/system/system-prompt.md +2 -0
  61. package/src/prompts/tools/image-attachment-describe-system.md +8 -0
  62. package/src/prompts/tools/image-attachment-describe.md +10 -0
  63. package/src/sdk.ts +14 -18
  64. package/src/session/agent-session.ts +571 -235
  65. package/src/session/session-loader.ts +19 -32
  66. package/src/session/session-persistence.ts +27 -11
  67. package/src/ssh/connection-manager.ts +3 -2
  68. package/src/task/executor.ts +1 -1
  69. package/src/tools/image-gen.ts +67 -25
  70. package/src/tools/read.ts +54 -6
  71. package/src/tui/code-cell.ts +44 -3
  72. package/src/utils/image-vision-fallback.ts +197 -0
  73. package/src/web/search/index.ts +12 -0
  74. package/src/web/search/providers/base.ts +1 -0
  75. package/src/web/search/providers/gemini.ts +56 -18
@@ -12,6 +12,7 @@ import {
12
12
  AdvisorRuntime,
13
13
  type AdvisorRuntimeHost,
14
14
  formatAdvisorBatchContent,
15
+ isAdvisorInterruptImmuneTurnActive,
15
16
  isInterruptingSeverity,
16
17
  resolveAdvisorDeliveryChannel,
17
18
  } from "..";
@@ -124,6 +125,44 @@ describe("advisor", () => {
124
125
  expect(isInterruptingSeverity(undefined)).toBe(false);
125
126
  });
126
127
 
128
+ it("keeps the interrupt-immune turn fence half-open for the configured window", () => {
129
+ expect(
130
+ isAdvisorInterruptImmuneTurnActive({
131
+ completedTurns: 4,
132
+ immuneTurnStart: undefined,
133
+ immuneTurns: 2,
134
+ }),
135
+ ).toBe(false);
136
+ expect(
137
+ isAdvisorInterruptImmuneTurnActive({
138
+ completedTurns: 4,
139
+ immuneTurnStart: 5,
140
+ immuneTurns: 0,
141
+ }),
142
+ ).toBe(false);
143
+ expect(
144
+ isAdvisorInterruptImmuneTurnActive({
145
+ completedTurns: 4,
146
+ immuneTurnStart: 5,
147
+ immuneTurns: 2,
148
+ }),
149
+ ).toBe(true);
150
+ expect(
151
+ isAdvisorInterruptImmuneTurnActive({
152
+ completedTurns: 6,
153
+ immuneTurnStart: 5,
154
+ immuneTurns: 2,
155
+ }),
156
+ ).toBe(true);
157
+ expect(
158
+ isAdvisorInterruptImmuneTurnActive({
159
+ completedTurns: 7,
160
+ immuneTurnStart: 5,
161
+ immuneTurns: 2,
162
+ }),
163
+ ).toBe(false);
164
+ });
165
+
127
166
  it("wraps each note in an advisory tag with severity as an attribute and escapes the body", () => {
128
167
  const content = formatAdvisorBatchContent([
129
168
  { note: "first note" },
@@ -688,6 +727,26 @@ describe("advisor", () => {
688
727
  }
689
728
  });
690
729
 
730
+ it("routes interrupting notes to the aside queue during immune turns without overriding preservation", () => {
731
+ expect(
732
+ resolveAdvisorDeliveryChannel({
733
+ severity: "concern",
734
+ autoResumeSuppressed: false,
735
+ streaming: true,
736
+ aborting: false,
737
+ interruptImmuneTurnActive: true,
738
+ }),
739
+ ).toBe("aside");
740
+ expect(
741
+ resolveAdvisorDeliveryChannel({
742
+ severity: "blocker",
743
+ autoResumeSuppressed: true,
744
+ streaming: false,
745
+ aborting: false,
746
+ interruptImmuneTurnActive: true,
747
+ }),
748
+ ).toBe("preserve");
749
+ });
691
750
  it("preserves an interrupting note while suppressed AND idle (no auto-resume of a stopped run)", () => {
692
751
  for (const severity of ["concern", "blocker"] as const) {
693
752
  expect(
@@ -68,6 +68,15 @@ export function isInterruptingSeverity(severity: AdvisorSeverity | undefined): b
68
68
 
69
69
  /** How an advisor note is routed to the primary. */
70
70
  export type AdvisorDeliveryChannel = "aside" | "steer" | "preserve";
71
+ /** Half-open turn-count fence for the post-interrupt cooldown. */
72
+ export function isAdvisorInterruptImmuneTurnActive(opts: {
73
+ completedTurns: number;
74
+ immuneTurnStart: number | undefined;
75
+ immuneTurns: number;
76
+ }): boolean {
77
+ if (opts.immuneTurnStart === undefined || opts.immuneTurns <= 0) return false;
78
+ return opts.completedTurns < opts.immuneTurnStart + opts.immuneTurns;
79
+ }
71
80
 
72
81
  /**
73
82
  * Decide how one advisor note reaches the primary agent.
@@ -84,15 +93,19 @@ export type AdvisorDeliveryChannel = "aside" | "steer" | "preserve";
84
93
  * auto-resume anything, so it is delivered live. Parking it during an active
85
94
  * run instead strands it (it never reaches the running agent) and the withheld
86
95
  * notes dump as one burst at the next user prompt — the bug this guards.
96
+ * - During the post-interrupt immune-turn window, further `concern`/`blocker`
97
+ * notes are downgraded to asides; suppression preservation still wins.
87
98
  */
88
99
  export function resolveAdvisorDeliveryChannel(opts: {
89
100
  severity: AdvisorSeverity | undefined;
90
101
  autoResumeSuppressed: boolean;
91
102
  streaming: boolean;
92
103
  aborting: boolean;
104
+ interruptImmuneTurnActive?: boolean;
93
105
  }): AdvisorDeliveryChannel {
94
106
  if (!isInterruptingSeverity(opts.severity)) return "aside";
95
107
  if (opts.autoResumeSuppressed && (opts.aborting || !opts.streaming)) return "preserve";
108
+ if (opts.interruptImmuneTurnActive) return "aside";
96
109
  return "steer";
97
110
  }
98
111
 
package/src/cli/args.ts CHANGED
@@ -28,11 +28,13 @@ export interface Args {
28
28
  smol?: string;
29
29
  slow?: string;
30
30
  plan?: string;
31
+ maxTime?: number;
31
32
  apiKey?: string;
32
33
  systemPrompt?: string;
33
34
  appendSystemPrompt?: string;
34
35
  thinking?: Effort;
35
36
  hideThinking?: boolean;
37
+ advisor?: boolean;
36
38
  continue?: boolean;
37
39
  resume?: string | true;
38
40
  help?: boolean;
@@ -194,6 +196,8 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
194
196
  result.noPty = true;
195
197
  } else if (arg === "--hide-thinking") {
196
198
  result.hideThinking = true;
199
+ } else if (arg === "--advisor") {
200
+ result.advisor = true;
197
201
  } else if (arg === "--print" || arg === "-p") {
198
202
  result.print = true;
199
203
  } else if (arg === "--no-extensions") {
@@ -17,7 +17,12 @@ import { formatDuration, getProjectDir } from "@oh-my-pi/pi-utils";
17
17
  import chalk from "chalk";
18
18
  import type { ApiKeyResolverModel } from "../config/api-key-resolver";
19
19
  import { type CanonicalModelQueryOptions, ModelRegistry } from "../config/model-registry";
20
- import { formatModelString, getModelMatchPreferences, resolveCliModel } from "../config/model-resolver";
20
+ import {
21
+ formatModelSelectorValue,
22
+ formatModelString,
23
+ getModelMatchPreferences,
24
+ resolveCliModel,
25
+ } from "../config/model-resolver";
21
26
  import { Settings } from "../config/settings";
22
27
  import benchPrompt from "../prompts/bench.md" with { type: "text" };
23
28
  import { discoverAuthStorage } from "../sdk";
@@ -144,9 +149,15 @@ function isFirstTokenEvent(event: AssistantMessageEvent): boolean {
144
149
  * latency does not dilute throughput. Falls back to total duration when the
145
150
  * response arrived as a single chunk (TTFT ~ duration).
146
151
  */
147
- function computeTokensPerSecond(outputTokens: number, durationMs: number, ttftMs: number): number {
152
+ export function computeTokensPerSecond(
153
+ outputTokens: number,
154
+ durationMs: number,
155
+ ttftMs: number,
156
+ deltaChunkCount: number,
157
+ ): number {
148
158
  const decodeMs = durationMs - ttftMs;
149
- const windowMs = decodeMs > 0 ? decodeMs : durationMs;
159
+ // Fall back to total duration when the response arrived as a single chunk/non-streaming.
160
+ const windowMs = decodeMs > 0 && deltaChunkCount >= 2 ? decodeMs : durationMs;
150
161
  return windowMs > 0 ? (outputTokens * 1000) / windowMs : 0;
151
162
  }
152
163
 
@@ -193,10 +204,17 @@ async function runBenchRequest(
193
204
  headers: model.provider === "openrouter" ? { "X-OpenRouter-Cache": "false" } : undefined,
194
205
  });
195
206
  let message: AssistantMessage | undefined;
207
+ let deltaChunkCount = 0;
196
208
  for await (const event of stream) {
197
209
  if (firstTokenAt === undefined && isFirstTokenEvent(event)) {
198
210
  firstTokenAt = now();
199
211
  }
212
+ if (
213
+ (event.type === "text_delta" || event.type === "thinking_delta" || event.type === "toolcall_delta") &&
214
+ event.delta.length > 0
215
+ ) {
216
+ deltaChunkCount++;
217
+ }
200
218
  if (event.type === "error") {
201
219
  return { ok: false, error: event.error.errorMessage ?? "request failed" };
202
220
  }
@@ -218,7 +236,7 @@ async function runBenchRequest(
218
236
  ttftMs,
219
237
  durationMs,
220
238
  outputTokens,
221
- tokensPerSecond: computeTokensPerSecond(outputTokens, durationMs, ttftMs),
239
+ tokensPerSecond: computeTokensPerSecond(outputTokens, durationMs, ttftMs, deltaChunkCount),
222
240
  };
223
241
  } catch (error) {
224
242
  return { ok: false, error: getErrorMessage(error) };
@@ -244,6 +262,10 @@ function buildModelReport(
244
262
  return { selector, model: formatModelString(model), thinking, results, average };
245
263
  }
246
264
 
265
+ function formatBenchModelLabel(report: BenchModelReport): string {
266
+ return formatModelSelectorValue(report.model, report.thinking);
267
+ }
268
+
247
269
  function formatMs(ms: number): string {
248
270
  return formatDuration(Math.max(0, Math.round(ms)));
249
271
  }
@@ -264,7 +286,7 @@ export function formatBenchTable(summary: BenchSummary): string {
264
286
  return b.average.tokensPerSecond - a.average.tokensPerSecond;
265
287
  });
266
288
  const rows = ranked.map(report => ({
267
- model: report.model,
289
+ model: formatBenchModelLabel(report),
268
290
  ttft: report.average ? formatMs(report.average.ttftMs) : "-",
269
291
  tps: report.average ? `${report.average.tokensPerSecond.toFixed(1)}/s` : "-",
270
292
  tokens: report.average ? String(Math.round(report.average.outputTokens)) : "-",
@@ -382,8 +404,9 @@ export async function runBenchCommand(command: BenchCommandArgs, deps: BenchDepe
382
404
  const reports: BenchModelReport[] = [];
383
405
  for (const { selector, model, thinking } of targets) {
384
406
  if (!json) {
385
- const resolvedNote = selector === formatModelString(model) ? "" : chalk.dim(` (${selector})`);
386
- writeStdout(`${chalk.bold(formatModelString(model))}${resolvedNote}\n`);
407
+ const resolvedModel = formatModelSelectorValue(formatModelString(model), thinking);
408
+ const resolvedNote = selector === resolvedModel ? "" : chalk.dim(` (${selector})`);
409
+ writeStdout(`${chalk.bold(resolvedModel)}${resolvedNote}\n`);
387
410
  }
388
411
  const results: BenchRunResult[] = [];
389
412
  for (let index = 0; index < runs; index++) {
@@ -120,6 +120,14 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
120
120
  "--plan": (result, value) => {
121
121
  result.plan = value;
122
122
  },
123
+ "--max-time": (result, value, deps) => {
124
+ const seconds = Number(value);
125
+ if (Number.isFinite(seconds) && seconds > 0) {
126
+ result.maxTime = seconds;
127
+ } else {
128
+ deps.logger.warn("Invalid seconds passed to --max-time", { value });
129
+ }
130
+ },
123
131
  "--api-key": (result, value) => {
124
132
  result.apiKey = value;
125
133
  },
@@ -260,6 +268,7 @@ export const VALUELESS_FLAGS: ReadonlySet<string> = new Set([
260
268
  "--no-lsp",
261
269
  "--no-pty",
262
270
  "--hide-thinking",
271
+ "--advisor",
263
272
  "--print",
264
273
  "--no-extensions",
265
274
  "--no-skills",
@@ -415,7 +415,7 @@ export class CollabHost {
415
415
  // render exactly the same anchored, provider-real count the host's own
416
416
  // status line shows.
417
417
  const breakdown = this.#ctx.statusLine.getCachedContextBreakdown();
418
- const tokens = breakdown.usedTokens;
418
+ const tokens = breakdown.usedTokens ?? 0;
419
419
  return {
420
420
  isStreaming: session.isStreaming,
421
421
  isAborting: session.isAborting,
@@ -427,7 +427,7 @@ export class CollabHost {
427
427
  contextUsage: {
428
428
  tokens,
429
429
  contextWindow: breakdown.contextWindow,
430
- percent: tokens !== null && breakdown.contextWindow > 0 ? (tokens / breakdown.contextWindow) * 100 : null,
430
+ percent: breakdown.contextWindow > 0 ? (tokens / breakdown.contextWindow) * 100 : 0,
431
431
  },
432
432
  participants: this.participants,
433
433
  };
@@ -106,6 +106,9 @@ export default class Index extends Command {
106
106
  "hide-thinking": Flags.boolean({
107
107
  description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)",
108
108
  }),
109
+ advisor: Flags.boolean({
110
+ description: "Enable the advisor runtime (passively reviews each turn and injects notes)",
111
+ }),
109
112
  hook: Flags.string({
110
113
  description: "Load a hook/extension file (can be used multiple times)",
111
114
  multiple: true,
@@ -133,6 +136,9 @@ export default class Index extends Command {
133
136
  "no-title": Flags.boolean({
134
137
  description: "Disable title auto-generation",
135
138
  }),
139
+ "max-time": Flags.string({
140
+ description: "Stop the session after this many seconds",
141
+ }),
136
142
  // `--auto-approve` / `--yolo`: declared here so oclif's auto-generated `--help` lists it.
137
143
  // Runtime parsing happens in `cli/args.ts parseArgs` (line 176 in that file) — `runRootCommand`
138
144
  // consumes the manual-parser output, not these oclif flag values. If you rename or remove
@@ -106,7 +106,7 @@ export const TAB_METADATA: Record<SettingTab, { label: string; icon: `tab.${stri
106
106
  */
107
107
  export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
108
108
  appearance: ["Theme", "Status Line", "Display", "Images"],
109
- model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback", "Advisor"],
109
+ model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback", "Advisor", "Vision"],
110
110
  interaction: [
111
111
  "Input",
112
112
  "Approvals",
@@ -117,6 +117,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
117
117
  "Startup & Updates",
118
118
  "Power (macOS)",
119
119
  "Agent",
120
+ "Git",
120
121
  ],
121
122
  context: ["General", "Compaction", "Rules (TTSR)", "Experimental"],
122
123
  memory: ["General", "Auto-Learn", "Mnemopi", "Hindsight"],
@@ -414,7 +415,36 @@ export const SETTINGS_SCHEMA = {
414
415
  "Pause the main agent for up to 30 seconds if the advisor falls behind by this many turns. Off disables catch-up delays.",
415
416
  },
416
417
  },
418
+ "advisor.immuneTurns": {
419
+ type: "number",
420
+ default: 1,
421
+ ui: {
422
+ tab: "model",
423
+ group: "Advisor",
424
+ label: "Advisor Immune Turns",
425
+ description:
426
+ "After an advisor concern or blocker interrupts, route further concerns/blockers non-interruptingly for this many primary turns.",
427
+ options: [
428
+ { value: "0", label: "0 turns", description: "Allow every concern/blocker to interrupt." },
429
+ { value: "1", label: "1 turn", description: "Default." },
430
+ { value: "2", label: "2 turns" },
431
+ { value: "3", label: "3 turns" },
432
+ { value: "4", label: "4 turns" },
433
+ { value: "5", label: "5 turns" },
434
+ ],
435
+ },
436
+ },
417
437
  shellPath: { type: "string", default: undefined },
438
+ "git.enabled": {
439
+ type: "boolean",
440
+ default: true,
441
+ ui: {
442
+ tab: "interaction",
443
+ group: "Git",
444
+ label: "Enable Git Integration",
445
+ description: "Show git branch, status, and PR information in the TUI and watch repository metadata.",
446
+ },
447
+ },
418
448
 
419
449
  extensions: { type: "array", default: EMPTY_STRING_ARRAY },
420
450
 
@@ -712,6 +742,18 @@ export const SETTINGS_SCHEMA = {
712
742
  },
713
743
  },
714
744
 
745
+ "images.describeForTextModels": {
746
+ type: "boolean",
747
+ default: true,
748
+ ui: {
749
+ tab: "model",
750
+ group: "Vision",
751
+ label: "Describe Images for Text Models",
752
+ description:
753
+ "When an image is attached to a model without vision support, save it under local:// and inject a description from a vision-capable model instead of dropping it",
754
+ },
755
+ },
756
+
715
757
  "tui.maxInlineImageColumns": {
716
758
  type: "number",
717
759
  default: 100,
@@ -757,6 +799,16 @@ export const SETTINGS_SCHEMA = {
757
799
  "Wrap paths and URLs in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
758
800
  },
759
801
  },
802
+ "tui.tight": {
803
+ type: "boolean",
804
+ default: false,
805
+ ui: {
806
+ tab: "appearance",
807
+ group: "Display",
808
+ label: "Tight Layout",
809
+ description: "Remove the 1-character horizontal padding from the left and right of the terminal output",
810
+ },
811
+ },
760
812
  // Display rendering
761
813
  "display.tabWidth": {
762
814
  type: "number",
@@ -1505,7 +1557,7 @@ export const SETTINGS_SCHEMA = {
1505
1557
  // Context promotion
1506
1558
  "contextPromotion.enabled": {
1507
1559
  type: "boolean",
1508
- default: true,
1560
+ default: false,
1509
1561
  ui: {
1510
1562
  tab: "context",
1511
1563
  group: "General",
@@ -1769,6 +1821,7 @@ export const SETTINGS_SCHEMA = {
1769
1821
  "qwen3",
1770
1822
  "gemini",
1771
1823
  "gemma",
1824
+ "minimax",
1772
1825
  ] as const,
1773
1826
  default: "auto",
1774
1827
  ui: {
@@ -1795,6 +1848,7 @@ export const SETTINGS_SCHEMA = {
1795
1848
  { value: "qwen3", label: "Qwen3", description: "Use the Qwen3 owned dialect." },
1796
1849
  { value: "gemini", label: "Gemini", description: "Use the Gemini owned dialect." },
1797
1850
  { value: "gemma", label: "Gemma", description: "Use the Gemma owned dialect." },
1851
+ { value: "minimax", label: "MiniMax", description: "Use the MiniMax owned dialect." },
1798
1852
  ],
1799
1853
  },
1800
1854
  },
@@ -2658,7 +2712,7 @@ export const SETTINGS_SCHEMA = {
2658
2712
  group: "Read Summaries",
2659
2713
  label: "Read Summary Unfold Ceiling",
2660
2714
  description:
2661
- "Hard ceiling on summary size while BFS-unfolding. An unfold that would exceed this is reverted and unfolding stops.",
2715
+ "Hard ceiling on summary size while BFS-unfolding. An unfold whose revealed lines would exceed this is skipped (that span stays folded) and unfolding continues with the remaining spans.",
2662
2716
  },
2663
2717
  },
2664
2718
 
@@ -3857,6 +3911,34 @@ export const SETTINGS_SCHEMA = {
3857
3911
  description: "Providers that web_search should never use, even as fallbacks",
3858
3912
  },
3859
3913
  },
3914
+ "providers.antigravityEndpoint": {
3915
+ type: "enum",
3916
+ values: ["auto", "production", "sandbox"] as const,
3917
+ default: "auto",
3918
+ ui: {
3919
+ tab: "providers",
3920
+ group: "Services",
3921
+ label: "Antigravity Endpoint Mode",
3922
+ description: "Endpoint routing strategy for google-antigravity providers (chat, search, image, discovery)",
3923
+ options: [
3924
+ {
3925
+ value: "auto",
3926
+ label: "Auto",
3927
+ description: "Try production endpoint, fail over to sandbox on 5xx/429",
3928
+ },
3929
+ {
3930
+ value: "production",
3931
+ label: "Production Only",
3932
+ description: "Force production endpoint only",
3933
+ },
3934
+ {
3935
+ value: "sandbox",
3936
+ label: "Sandbox Only",
3937
+ description: "Force sandbox endpoint only",
3938
+ },
3939
+ ],
3940
+ },
3941
+ },
3860
3942
  "providers.image": {
3861
3943
  type: "enum",
3862
3944
  values: ["auto", "openai", "antigravity", "xai", "gemini", "openrouter"] as const,
@@ -116,6 +116,17 @@ export function parseSeenLinesFromHashlineBody(body: string): number[] {
116
116
  return seen;
117
117
  }
118
118
 
119
+ /** Merge explicit 1-indexed displayed lines into a recorded hashline snapshot. */
120
+ export function recordSeenLines(
121
+ session: FileSnapshotStoreOwner,
122
+ absolutePath: string,
123
+ tag: string,
124
+ lines: readonly number[],
125
+ ): void {
126
+ if (lines.length === 0) return;
127
+ getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, lines);
128
+ }
129
+
119
130
  /**
120
131
  * Attach the lines a read displayed to the snapshot it minted, so the patcher
121
132
  * can reject edits anchored on lines the model never saw. Best-effort: a no-op
@@ -128,7 +139,5 @@ export function recordSeenLinesFromBody(
128
139
  tag: string,
129
140
  body: string,
130
141
  ): void {
131
- const seen = parseSeenLinesFromHashlineBody(body);
132
- if (seen.length === 0) return;
133
- getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, seen);
142
+ recordSeenLines(session, absolutePath, tag, parseSeenLinesFromHashlineBody(body));
134
143
  }
@@ -190,6 +190,11 @@ class _RunnerState:
190
190
 
191
191
 
192
192
  _CURRENT_RID: contextvars.ContextVar[str | None] = contextvars.ContextVar("omp_current_rid", default=None)
193
+ _CURRENT_DISPLAYED_MATPLOTLIB_FIGURE_IDS: contextvars.ContextVar[set[int] | None] = contextvars.ContextVar(
194
+ "omp_displayed_matplotlib_figure_ids",
195
+ default=None,
196
+ )
197
+
193
198
 
194
199
  _STATE = _RunnerState()
195
200
 
@@ -670,6 +675,36 @@ _REPR_MIMES = [
670
675
  ]
671
676
 
672
677
 
678
+ def _is_matplotlib_figure(value: Any) -> bool:
679
+ figure_module = sys.modules.get("matplotlib.figure")
680
+ figure_cls = getattr(figure_module, "Figure", None)
681
+ if isinstance(figure_cls, type) and isinstance(value, figure_cls):
682
+ return True
683
+
684
+ value_type = type(value)
685
+ return value_type.__module__ == "matplotlib.figure" and value_type.__name__ == "Figure"
686
+
687
+
688
+ def _matplotlib_figure_png(value: Any) -> str | None:
689
+ if not _is_matplotlib_figure(value):
690
+ return None
691
+
692
+ savefig = getattr(value, "savefig", None)
693
+ if not callable(savefig):
694
+ return None
695
+
696
+ try:
697
+ buf = io.BytesIO()
698
+ savefig(buf, format="png", bbox_inches="tight")
699
+ except Exception:
700
+ return None
701
+
702
+ displayed_ids = _CURRENT_DISPLAYED_MATPLOTLIB_FIGURE_IDS.get()
703
+ if displayed_ids is not None:
704
+ displayed_ids.add(id(value))
705
+ return base64.b64encode(buf.getvalue()).decode("ascii")
706
+
707
+
673
708
  def _coerce_image_bytes(value: Any) -> str:
674
709
  if isinstance(value, (bytes, bytearray)):
675
710
  return base64.b64encode(bytes(value)).decode("ascii")
@@ -685,6 +720,10 @@ def _mime_bundle(value: Any) -> dict:
685
720
  accessors, and always provides ``text/plain``.
686
721
  """
687
722
  bundle: dict[str, Any] = {}
723
+ matplotlib_png = _matplotlib_figure_png(value)
724
+ if matplotlib_png is not None:
725
+ bundle["image/png"] = matplotlib_png
726
+
688
727
 
689
728
  mimebundle = getattr(value, "_repr_mimebundle_", None)
690
729
  if callable(mimebundle):
@@ -758,6 +797,9 @@ def _flush_matplotlib_figures() -> None:
758
797
  for num in fignums:
759
798
  try:
760
799
  fig = plt.figure(num)
800
+ if id(fig) in (_CURRENT_DISPLAYED_MATPLOTLIB_FIGURE_IDS.get() or set()):
801
+ plt.close(fig)
802
+ continue
761
803
  buf = io.BytesIO()
762
804
  fig.savefig(buf, format="png", bbox_inches="tight")
763
805
  data = base64.b64encode(buf.getvalue()).decode("ascii")
@@ -978,6 +1020,7 @@ def _start_parent_watchdog() -> None:
978
1020
  async def _handle_request_async(req: dict) -> None:
979
1021
  rid = str(req.get("id"))
980
1022
  token = _CURRENT_RID.set(rid)
1023
+ displayed_matplotlib_token = _CURRENT_DISPLAYED_MATPLOTLIB_FIGURE_IDS.set(set())
981
1024
  _STATE.capture_rid = rid
982
1025
  _STATE.user_ns["__omp_run_id__"] = rid
983
1026
  _STATE.cancel_requested = False
@@ -1046,6 +1089,7 @@ async def _handle_request_async(req: dict) -> None:
1046
1089
  _STATE.capture_rid = None
1047
1090
  _flush_stream_proxies(rid)
1048
1091
  _CURRENT_RID.reset(token)
1092
+ _CURRENT_DISPLAYED_MATPLOTLIB_FIGURE_IDS.reset(displayed_matplotlib_token)
1049
1093
 
1050
1094
 
1051
1095
  def _emit_error(rid: str, exc: BaseException) -> None:
@@ -46,6 +46,8 @@ import type {
46
46
  SessionBeforeSwitchResult,
47
47
  SessionBeforeTreeResult,
48
48
  SessionCompactingResult,
49
+ SessionStopEvent,
50
+ SessionStopEventResult,
49
51
  ToolCallEvent,
50
52
  ToolCallEventResult,
51
53
  ToolResultEvent,
@@ -135,7 +137,9 @@ type RunnerEmitResult<TEvent extends RunnerEmitEvent> = TEvent extends { type: "
135
137
  ? SessionBeforeTreeResult | undefined
136
138
  : TEvent extends { type: "session.compacting" }
137
139
  ? SessionCompactingResult | undefined
138
- : undefined;
140
+ : TEvent extends { type: "session_stop" }
141
+ ? SessionStopEventResult | undefined
142
+ : undefined;
139
143
 
140
144
  export type NewSessionHandler = (options?: {
141
145
  parentSession?: string;
@@ -322,6 +326,10 @@ export class ExtensionRunner {
322
326
  await this.emit({ type: "credential_disabled", ...event });
323
327
  }
324
328
 
329
+ async emitSessionStop(event: Omit<SessionStopEvent, "type">): Promise<SessionStopEventResult | undefined> {
330
+ return await this.emit({ type: "session_stop", ...event });
331
+ }
332
+
325
333
  getUIContext(): ExtensionUIContext {
326
334
  return this.#uiContext;
327
335
  }
@@ -588,7 +596,7 @@ export class ExtensionRunner {
588
596
 
589
597
  async emit<TEvent extends RunnerEmitEvent>(event: TEvent): Promise<RunnerEmitResult<TEvent>> {
590
598
  const ctx = this.createContext();
591
- let result: SessionBeforeEventResult | SessionCompactingResult | undefined;
599
+ let result: SessionBeforeEventResult | SessionCompactingResult | SessionStopEventResult | undefined;
592
600
 
593
601
  if (this.#isSessionShutdownEvent(event)) {
594
602
  const timeoutMs = handlerTimeoutForEvent(event.type);
@@ -627,6 +635,16 @@ export class ExtensionRunner {
627
635
  if (event.type === "session.compacting" && handlerResult) {
628
636
  result = handlerResult as SessionCompactingResult;
629
637
  }
638
+
639
+ if (event.type === "session_stop" && handlerResult) {
640
+ result = handlerResult as SessionStopEventResult;
641
+ const hasContinuationContext =
642
+ (typeof result.additionalContext === "string" && result.additionalContext.length > 0) ||
643
+ (typeof result.reason === "string" && result.reason.length > 0);
644
+ if ((result.continue === true || result.decision === "block") && hasContinuationContext) {
645
+ return result as RunnerEmitResult<TEvent>;
646
+ }
647
+ }
630
648
  }
631
649
  }
632
650
 
@@ -82,6 +82,8 @@ import type {
82
82
  SessionEvent,
83
83
  SessionShutdownEvent,
84
84
  SessionStartEvent,
85
+ SessionStopEvent,
86
+ SessionStopEventResult,
85
87
  SessionSwitchEvent,
86
88
  SessionTreeEvent,
87
89
  TodoReminderEvent,
@@ -274,11 +276,11 @@ export interface ExtensionUIContext {
274
276
  // ============================================================================
275
277
 
276
278
  export interface ContextUsage {
277
- /** Estimated context tokens, or null if unknown (e.g. right after compaction, before next LLM response). */
278
- tokens: number | null;
279
+ /** Estimated context tokens. */
280
+ tokens: number;
279
281
  contextWindow: number;
280
- /** Context usage as percentage of context window, or null if tokens is unknown. */
281
- percent: number | null;
282
+ /** Context usage as percentage of context window. */
283
+ percent: number;
282
284
  }
283
285
 
284
286
  export interface CompactOptions {
@@ -525,7 +527,14 @@ export interface BeforeAgentStartEvent {
525
527
  systemPrompt: string[];
526
528
  }
527
529
 
528
- export type { AgentEndEvent, AgentStartEvent, TurnEndEvent, TurnStartEvent } from "../shared-events";
530
+ export type {
531
+ AgentEndEvent,
532
+ AgentStartEvent,
533
+ SessionStopEvent,
534
+ SessionStopEventResult,
535
+ TurnEndEvent,
536
+ TurnStartEvent,
537
+ } from "../shared-events";
529
538
 
530
539
  /** Fired when a message starts (user, assistant, or toolResult) */
531
540
  export interface MessageStartEvent {
@@ -802,6 +811,7 @@ export type ExtensionEvent =
802
811
  | BeforeAgentStartEvent
803
812
  | AgentStartEvent
804
813
  | AgentEndEvent
814
+ | SessionStopEvent
805
815
  | TurnStartEvent
806
816
  | TurnEndEvent
807
817
  | MessageStartEvent
@@ -978,6 +988,7 @@ export interface ExtensionAPI {
978
988
  on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult>): void;
979
989
  on(event: "agent_start", handler: ExtensionHandler<AgentStartEvent>): void;
980
990
  on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
991
+ on(event: "session_stop", handler: ExtensionHandler<SessionStopEvent, SessionStopEventResult>): void;
981
992
  on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
982
993
  on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
983
994
  on(event: "message_start", handler: ExtensionHandler<MessageStartEvent>): void;