@oh-my-pi/pi-coding-agent 15.7.5 → 15.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +159 -182
  2. package/dist/types/async/job-manager.d.ts +3 -3
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli/claude-trace-cli.d.ts +54 -0
  5. package/dist/types/cli/session-picker.d.ts +10 -3
  6. package/dist/types/cli/update-cli.d.ts +17 -0
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/keybindings.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +11 -2
  10. package/dist/types/config/settings.d.ts +13 -0
  11. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  12. package/dist/types/eval/concurrency-bridge.d.ts +26 -0
  13. package/dist/types/eval/heartbeat.d.ts +45 -0
  14. package/dist/types/eval/js/tool-bridge.d.ts +2 -1
  15. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  16. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  18. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +11 -0
  19. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  20. package/dist/types/lsp/index.d.ts +2 -0
  21. package/dist/types/lsp/utils.d.ts +4 -0
  22. package/dist/types/main.d.ts +5 -0
  23. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  24. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  25. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  26. package/dist/types/modes/components/hook-selector.d.ts +10 -1
  27. package/dist/types/modes/components/session-selector.d.ts +32 -5
  28. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  29. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -3
  30. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  31. package/dist/types/modes/interactive-mode.d.ts +10 -3
  32. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  33. package/dist/types/modes/theme/theme.d.ts +1 -1
  34. package/dist/types/modes/types.d.ts +5 -3
  35. package/dist/types/registry/agent-registry.d.ts +1 -1
  36. package/dist/types/sdk.d.ts +2 -2
  37. package/dist/types/session/agent-session.d.ts +12 -3
  38. package/dist/types/session/history-storage.d.ts +16 -1
  39. package/dist/types/session/session-manager.d.ts +4 -0
  40. package/dist/types/task/output-manager.d.ts +6 -15
  41. package/dist/types/tools/ask.d.ts +8 -6
  42. package/dist/types/tools/eval-backends.d.ts +12 -0
  43. package/dist/types/tools/eval-render.d.ts +52 -0
  44. package/dist/types/tools/eval.d.ts +2 -35
  45. package/dist/types/tools/find.d.ts +0 -9
  46. package/dist/types/tools/index.d.ts +5 -12
  47. package/dist/types/tools/path-utils.d.ts +16 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +25 -8
  49. package/dist/types/tui/output-block.d.ts +4 -3
  50. package/dist/types/utils/clipboard.d.ts +4 -0
  51. package/dist/types/web/kagi.d.ts +76 -0
  52. package/dist/types/web/search/providers/exa.d.ts +7 -1
  53. package/dist/types/web/search/providers/kagi.d.ts +1 -0
  54. package/examples/extensions/README.md +1 -0
  55. package/examples/extensions/thinking-note.ts +13 -0
  56. package/package.json +9 -9
  57. package/src/async/job-manager.ts +3 -3
  58. package/src/cli/args.ts +6 -2
  59. package/src/cli/claude-trace-cli.ts +783 -0
  60. package/src/cli/session-picker.ts +36 -10
  61. package/src/cli/update-cli.ts +35 -2
  62. package/src/commands/launch.ts +3 -0
  63. package/src/config/keybindings.ts +6 -0
  64. package/src/config/model-registry.ts +33 -4
  65. package/src/config/settings-schema.ts +12 -2
  66. package/src/config/settings.ts +23 -0
  67. package/src/discovery/claude-plugins.ts +7 -9
  68. package/src/discovery/claude.ts +41 -22
  69. package/src/edit/index.ts +23 -3
  70. package/src/eval/__tests__/agent-bridge.test.ts +148 -4
  71. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  72. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  73. package/src/eval/agent-bridge.ts +44 -38
  74. package/src/eval/concurrency-bridge.ts +34 -0
  75. package/src/eval/heartbeat.ts +74 -0
  76. package/src/eval/js/executor.ts +13 -9
  77. package/src/eval/js/shared/prelude.txt +20 -17
  78. package/src/eval/js/tool-bridge.ts +5 -0
  79. package/src/eval/llm-bridge.ts +20 -14
  80. package/src/eval/py/executor.ts +14 -18
  81. package/src/eval/py/prelude.py +23 -15
  82. package/src/exec/bash-executor.ts +31 -5
  83. package/src/extensibility/extensions/loader.ts +16 -18
  84. package/src/extensibility/extensions/runner.ts +22 -17
  85. package/src/extensibility/extensions/types.ts +39 -5
  86. package/src/extensibility/plugins/legacy-pi-compat.ts +115 -131
  87. package/src/extensibility/skills.ts +0 -1
  88. package/src/internal-urls/docs-index.generated.ts +14 -13
  89. package/src/lsp/diagnostics-ledger.ts +51 -0
  90. package/src/lsp/index.ts +9 -22
  91. package/src/lsp/utils.ts +21 -0
  92. package/src/main.ts +92 -24
  93. package/src/modes/acp/acp-agent.ts +8 -4
  94. package/src/modes/acp/acp-event-mapper.ts +54 -4
  95. package/src/modes/components/assistant-message.ts +28 -1
  96. package/src/modes/components/custom-editor.ts +19 -7
  97. package/src/modes/components/hook-selector.ts +229 -44
  98. package/src/modes/components/oauth-selector.ts +12 -6
  99. package/src/modes/components/session-selector.ts +179 -24
  100. package/src/modes/components/tool-execution.ts +36 -7
  101. package/src/modes/controllers/command-controller.ts +2 -11
  102. package/src/modes/controllers/event-controller.ts +5 -2
  103. package/src/modes/controllers/extension-ui-controller.ts +6 -4
  104. package/src/modes/controllers/input-controller.ts +19 -16
  105. package/src/modes/controllers/selector-controller.ts +61 -21
  106. package/src/modes/interactive-mode.ts +127 -16
  107. package/src/modes/rpc/rpc-mode.ts +17 -6
  108. package/src/modes/theme/theme-schema.json +30 -0
  109. package/src/modes/theme/theme.ts +39 -2
  110. package/src/modes/types.ts +7 -3
  111. package/src/modes/utils/ui-helpers.ts +5 -2
  112. package/src/prompts/system/orchestrate-notice.md +5 -3
  113. package/src/prompts/system/workflow-notice.md +2 -2
  114. package/src/prompts/tools/ask.md +2 -1
  115. package/src/prompts/tools/eval.md +6 -6
  116. package/src/prompts/tools/find.md +1 -1
  117. package/src/prompts/tools/irc.md +6 -6
  118. package/src/prompts/tools/search.md +1 -1
  119. package/src/prompts/tools/task.md +1 -1
  120. package/src/registry/agent-registry.ts +1 -1
  121. package/src/sdk.ts +85 -31
  122. package/src/session/agent-session.ts +127 -57
  123. package/src/session/history-storage.ts +56 -12
  124. package/src/session/session-manager.ts +34 -0
  125. package/src/task/output-manager.ts +40 -48
  126. package/src/task/render.ts +3 -8
  127. package/src/tools/ask.ts +74 -32
  128. package/src/tools/browser/tab-worker.ts +8 -5
  129. package/src/tools/eval-backends.ts +38 -0
  130. package/src/tools/eval-render.ts +750 -0
  131. package/src/tools/eval.ts +27 -754
  132. package/src/tools/find.ts +5 -29
  133. package/src/tools/index.ts +8 -38
  134. package/src/tools/path-utils.ts +144 -1
  135. package/src/tools/read.ts +47 -0
  136. package/src/tools/renderers.ts +1 -1
  137. package/src/tools/search.ts +2 -27
  138. package/src/tools/sqlite-reader.ts +92 -9
  139. package/src/tools/write.ts +9 -1
  140. package/src/tui/output-block.ts +5 -4
  141. package/src/utils/clipboard.ts +38 -1
  142. package/src/utils/open.ts +37 -2
  143. package/src/web/kagi.ts +168 -49
  144. package/src/web/search/providers/anthropic.ts +1 -1
  145. package/src/web/search/providers/exa.ts +20 -86
  146. package/src/web/search/providers/kagi.ts +4 -0
@@ -1,29 +1,28 @@
1
1
  /**
2
2
  * Session-scoped manager for agent output IDs.
3
3
  *
4
- * Ensures unique output IDs across task tool invocations within a session.
5
- * Prefixes each ID with a sequential number (e.g., "0-AuthProvider", "1-AuthApi").
6
- * If a parent prefix is provided, IDs are nested (e.g., "0-Auth.1-Subtask").
4
+ * Keeps every subagent output id unique within a session without polluting the
5
+ * common case with bookkeeping. A requested name is used verbatim the first
6
+ * time it appears; only a *repeated* name gets a numeric suffix to disambiguate
7
+ * it (e.g. "Anna", "Anna-2", "Anna-3"). When a parent prefix is configured, ids
8
+ * are nested under it (e.g. "Anna.Bob") so hierarchical outputs stay grouped.
7
9
  *
8
- * This enables reliable agent:// URL resolution and prevents artifact collisions.
10
+ * This enables reliable agent:// URL resolution and prevents artifact
11
+ * collisions across repeated or nested task invocations.
9
12
  */
10
13
  import * as fs from "node:fs/promises";
11
14
 
12
- function escapeRegExp(value: string): string {
13
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
- }
15
-
16
15
  /**
17
16
  * Manages agent output ID allocation to ensure uniqueness.
18
17
  *
19
- * Each allocated ID gets a numeric prefix based on allocation order.
20
- * If configured with a parent prefix, the numeric prefix is appended after
21
- * the parent (e.g., "0-Parent.0-Child").
22
- * On resume, scans existing files to find the next available index.
18
+ * The first allocation of a given name keeps the name as-is; subsequent
19
+ * allocations of the same name get a `-2`, `-3`, suffix. On resume, scans
20
+ * existing output files so previously written outputs are never overwritten.
23
21
  */
24
22
  export class AgentOutputManager {
25
- #nextId = 0;
26
23
  #initialized = false;
24
+ /** Final ids already handed out, relative to this manager's scope. */
25
+ readonly #taken = new Set<string>();
27
26
  readonly #getArtifactsDir: () => string | null;
28
27
  readonly #parentPrefix: string | undefined;
29
28
 
@@ -33,8 +32,8 @@ export class AgentOutputManager {
33
32
  }
34
33
 
35
34
  /**
36
- * Scan existing agent output files to find the next available ID.
37
- * This ensures we don't overwrite outputs when resuming a session.
35
+ * Seed the taken-id set from output files already on disk so a resumed
36
+ * session never reuses a name that would clobber a prior subagent's output.
38
37
  */
39
38
  async #ensureInitialized(): Promise<void> {
40
39
  if (this.#initialized) return;
@@ -50,31 +49,41 @@ export class AgentOutputManager {
50
49
  return; // Directory doesn't exist yet
51
50
  }
52
51
 
53
- const pattern = this.#parentPrefix
54
- ? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.md$`)
55
- : /^(\d+)-.*\.md$/;
56
-
57
- let maxId = -1;
52
+ const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
58
53
  for (const file of files) {
59
- const match = file.match(pattern);
60
- if (match) {
61
- const id = Number.parseInt(match[1], 10);
62
- if (id > maxId) maxId = id;
54
+ if (!file.endsWith(".md")) continue;
55
+ let rest = file.slice(0, -3); // drop ".md"
56
+ if (prefix) {
57
+ if (!rest.startsWith(prefix)) continue;
58
+ rest = rest.slice(prefix.length);
63
59
  }
60
+ // Requested ids never contain "."; a dot marks a nested child, so this
61
+ // manager only owns the first segment of whatever remains.
62
+ const dot = rest.indexOf(".");
63
+ const segment = dot === -1 ? rest : rest.slice(0, dot);
64
+ if (segment) this.#taken.add(segment);
64
65
  }
65
- this.#nextId = maxId + 1;
66
+ }
67
+
68
+ /** Pick the first free name (base, then `base-2`, `base-3`, …) and reserve it. */
69
+ #allocateUnique(id: string): string {
70
+ let candidate = id;
71
+ for (let n = 2; this.#taken.has(candidate); n++) {
72
+ candidate = `${id}-${n}`;
73
+ }
74
+ this.#taken.add(candidate);
75
+ return this.#parentPrefix ? `${this.#parentPrefix}.${candidate}` : candidate;
66
76
  }
67
77
 
68
78
  /**
69
- * Allocate a unique ID with numeric prefix.
79
+ * Allocate a unique ID.
70
80
  *
71
- * @param id Requested ID (e.g., "AuthProvider")
72
- * @returns Unique ID with prefix (e.g., "0-AuthProvider")
81
+ * @param id Requested ID (e.g., "Anna")
82
+ * @returns Unique ID ("Anna" first, then "Anna-2", "Anna-3", …)
73
83
  */
74
84
  async allocate(id: string): Promise<string> {
75
85
  await this.#ensureInitialized();
76
- const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
77
- return `${prefix}${this.#nextId++}-${id}`;
86
+ return this.#allocateUnique(id);
78
87
  }
79
88
 
80
89
  /**
@@ -85,23 +94,6 @@ export class AgentOutputManager {
85
94
  */
86
95
  async allocateBatch(ids: string[]): Promise<string[]> {
87
96
  await this.#ensureInitialized();
88
- const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
89
- return ids.map(id => `${prefix}${this.#nextId++}-${id}`);
90
- }
91
-
92
- /**
93
- * Get the next ID that would be allocated (without allocating).
94
- */
95
- async peekNextIndex(): Promise<number> {
96
- await this.#ensureInitialized();
97
- return this.#nextId;
98
- }
99
-
100
- /**
101
- * Reset state (primarily for testing).
102
- */
103
- reset(): void {
104
- this.#nextId = 0;
105
- this.#initialized = false;
97
+ return ids.map(id => this.#allocateUnique(id));
106
98
  }
107
99
  }
@@ -128,15 +128,10 @@ function formatJsonScalar(value: unknown, _theme: Theme): string {
128
128
  }
129
129
 
130
130
  function formatTaskId(id: string): string {
131
+ // Ids are name-based (e.g. "Anna", "Anna-2"); a "." separates nesting levels
132
+ // (e.g. "Anna.Bob"). Render the hierarchy with a ">" breadcrumb.
131
133
  const segments = id.split(".");
132
- if (segments.length < 2) return id;
133
-
134
- const parsed = segments.map(segment => segment.match(/^(\d+)-(.+)$/));
135
- if (parsed.some(match => !match)) return id;
136
-
137
- const indices = parsed.map(match => match![1]).join(".");
138
- const labels = parsed.map(match => match![2]).join(">");
139
- return `${indices} ${labels}`;
134
+ return segments.length < 2 ? id : segments.join(">");
140
135
  }
141
136
 
142
137
  const MISSING_YIELD_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling yield tool";
package/src/tools/ask.ts CHANGED
@@ -20,6 +20,7 @@ import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Te
20
20
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
21
21
  import * as z from "zod/v4";
22
22
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
23
+ import type { ExtensionUISelectItem } from "../extensibility/extensions";
23
24
  import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
24
25
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
25
26
  import { renderStatusLine } from "../tui";
@@ -33,6 +34,7 @@ import { ToolAbortError } from "./tool-errors";
33
34
 
34
35
  const OptionItem = z.object({
35
36
  label: z.string().describe("display label"),
37
+ description: z.string().describe("optional explanatory text displayed below the label").optional(),
36
38
  });
37
39
 
38
40
  const QuestionItem = z.object({
@@ -69,6 +71,23 @@ export interface AskToolDetails {
69
71
  results?: QuestionResult[];
70
72
  }
71
73
 
74
+ interface AskOption {
75
+ label: string;
76
+ description?: string;
77
+ }
78
+
79
+ function getAskOptionLabel(option: AskOption): string {
80
+ return option.label;
81
+ }
82
+
83
+ function getSelectOptionLabel(option: ExtensionUISelectItem): string {
84
+ return typeof option === "string" ? option : option.label;
85
+ }
86
+
87
+ function toSelectOption(option: AskOption, label = option.label): ExtensionUISelectItem {
88
+ return option.description ? { label, description: option.description } : label;
89
+ }
90
+
72
91
  // =============================================================================
73
92
  // Constants
74
93
  // =============================================================================
@@ -81,24 +100,25 @@ function getDoneOptionLabel(): string {
81
100
  }
82
101
 
83
102
  /** Add "(Recommended)" suffix to the option at the given index if not already present */
84
- function addRecommendedSuffix(labels: string[], recommendedIndex?: number): string[] {
85
- if (recommendedIndex === undefined || recommendedIndex < 0 || recommendedIndex >= labels.length) {
86
- return labels;
103
+ function addRecommendedSuffix(options: AskOption[], recommendedIndex?: number): ExtensionUISelectItem[] {
104
+ if (recommendedIndex === undefined || recommendedIndex < 0 || recommendedIndex >= options.length) {
105
+ return options.map(option => toSelectOption(option));
87
106
  }
88
- return labels.map((label, i) => {
89
- if (i === recommendedIndex && !label.endsWith(RECOMMENDED_SUFFIX)) {
90
- return label + RECOMMENDED_SUFFIX;
91
- }
92
- return label;
107
+ return options.map((option, i) => {
108
+ const label =
109
+ i === recommendedIndex && !option.label.endsWith(RECOMMENDED_SUFFIX)
110
+ ? option.label + RECOMMENDED_SUFFIX
111
+ : option.label;
112
+ return toSelectOption(option, label);
93
113
  });
94
114
  }
95
115
 
96
- function getAutoSelectionOnTimeout(optionLabels: string[], recommended?: number): string[] {
97
- if (optionLabels.length === 0) return [];
98
- if (typeof recommended === "number" && recommended >= 0 && recommended < optionLabels.length) {
99
- return [optionLabels[recommended]];
116
+ function getAutoSelectionOnTimeout(options: AskOption[], recommended?: number): string[] {
117
+ if (options.length === 0) return [];
118
+ if (typeof recommended === "number" && recommended >= 0 && recommended < options.length) {
119
+ return [options[recommended]!.label];
100
120
  }
101
- return [optionLabels[0]];
121
+ return [options[0]!.label];
102
122
  }
103
123
 
104
124
  /** Strip "(Recommended)" suffix from a label */
@@ -134,7 +154,7 @@ interface AskSingleQuestionOptions {
134
154
  interface UIContext {
135
155
  select(
136
156
  prompt: string,
137
- options: string[],
157
+ options: ExtensionUISelectItem[],
138
158
  options_?: {
139
159
  initialIndex?: number;
140
160
  timeout?: number;
@@ -157,7 +177,7 @@ interface UIContext {
157
177
  async function askSingleQuestion(
158
178
  ui: UIContext,
159
179
  question: string,
160
- optionLabels: string[],
180
+ questionOptions: AskOption[],
161
181
  multi: boolean,
162
182
  options: AskSingleQuestionOptions = {},
163
183
  ): Promise<SelectionResult> {
@@ -169,7 +189,7 @@ async function askSingleQuestion(
169
189
 
170
190
  const selectOption = async (
171
191
  prompt: string,
172
- optionsToShow: string[],
192
+ optionsToShow: ExtensionUISelectItem[],
173
193
  initialIndex?: number,
174
194
  ): Promise<{ choice: string | undefined; timedOut: boolean; navigation?: "back" | "forward" }> => {
175
195
  let timeoutTriggered = false;
@@ -218,18 +238,19 @@ async function askSingleQuestion(
218
238
  const promptWithProgress = navigation?.progressText ? `${question} (${navigation.progressText})` : question;
219
239
  if (multi) {
220
240
  const selected = new Set<string>(selectedOptions);
221
- let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), Math.max(optionLabels.length - 1, 0));
241
+ let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), Math.max(questionOptions.length - 1, 0));
222
242
  const firstSelected = selectedOptions[0];
223
243
  if (firstSelected) {
224
- const selectedIndex = optionLabels.indexOf(firstSelected);
244
+ const selectedIndex = questionOptions.findIndex(option => option.label === firstSelected);
225
245
  if (selectedIndex >= 0) cursorIndex = selectedIndex;
226
246
  }
227
247
  while (true) {
228
- const opts: string[] = [];
248
+ const opts: ExtensionUISelectItem[] = [];
229
249
 
230
- for (const opt of optionLabels) {
231
- const checkbox = selected.has(opt) ? theme.checkbox.checked : theme.checkbox.unchecked;
232
- opts.push(`${checkbox} ${opt}`);
250
+ for (const opt of questionOptions) {
251
+ const checkbox = selected.has(opt.label) ? theme.checkbox.checked : theme.checkbox.unchecked;
252
+ const displayLabel = `${checkbox} ${opt.label}`;
253
+ opts.push(toSelectOption(opt, displayLabel));
233
254
  }
234
255
 
235
256
  if (!navigation?.allowForward && selected.size > 0) {
@@ -269,7 +290,7 @@ async function askSingleQuestion(
269
290
  break;
270
291
  }
271
292
 
272
- const selectedIdx = opts.indexOf(choice);
293
+ const selectedIdx = opts.findIndex(opt => getSelectOptionLabel(opt) === choice);
273
294
  if (selectedIdx >= 0) {
274
295
  cursorIndex = selectedIdx;
275
296
  }
@@ -297,16 +318,16 @@ async function askSingleQuestion(
297
318
  }
298
319
  selectedOptions = Array.from(selected);
299
320
  } else {
300
- const displayLabels = addRecommendedSuffix(optionLabels, recommended);
301
- const optionsWithNavigation = [...displayLabels, OTHER_OPTION];
321
+ const displayOptions = addRecommendedSuffix(questionOptions, recommended);
322
+ const optionsWithNavigation: ExtensionUISelectItem[] = [...displayOptions, OTHER_OPTION];
302
323
 
303
324
  let initialIndex = recommended;
304
325
  const previouslySelected = selectedOptions[0];
305
326
  if (previouslySelected) {
306
- const selectedIndex = optionLabels.indexOf(previouslySelected);
327
+ const selectedIndex = questionOptions.findIndex(option => option.label === previouslySelected);
307
328
  if (selectedIndex >= 0) initialIndex = selectedIndex;
308
329
  } else if (customInput !== undefined) {
309
- initialIndex = displayLabels.length;
330
+ initialIndex = displayOptions.length;
310
331
  }
311
332
  if (initialIndex !== undefined) {
312
333
  const maxIndex = Math.max(optionsWithNavigation.length - 1, 0);
@@ -346,7 +367,7 @@ async function askSingleQuestion(
346
367
  }
347
368
 
348
369
  if (timedOut && selectedOptions.length === 0 && customInput === undefined) {
349
- selectedOptions = getAutoSelectionOnTimeout(optionLabels, recommended);
370
+ selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended);
350
371
  }
351
372
 
352
373
  return { selectedOptions, customInput, timedOut };
@@ -442,12 +463,16 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
442
463
  q: AskParams["questions"][number],
443
464
  options?: { previous?: QuestionResult; navigation?: NavigationControls },
444
465
  ) => {
445
- const optionLabels = q.options.map(o => o.label);
466
+ const questionOptions = q.options.map(option => ({
467
+ label: option.label,
468
+ ...(option.description?.trim() ? { description: option.description.trim() } : {}),
469
+ }));
470
+ const optionLabels = questionOptions.map(getAskOptionLabel);
446
471
  try {
447
472
  const { selectedOptions, customInput, navigation, cancelled, timedOut } = await askSingleQuestion(
448
473
  ui,
449
474
  q.question,
450
- optionLabels,
475
+ questionOptions,
451
476
  q.multi ?? false,
452
477
  {
453
478
  recommended: q.recommended,
@@ -568,14 +593,19 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
568
593
  // TUI Renderer
569
594
  // =============================================================================
570
595
 
596
+ interface AskRenderOption {
597
+ label: string;
598
+ description?: string;
599
+ }
600
+
571
601
  interface AskRenderArgs {
572
602
  question?: string;
573
- options?: Array<{ label: string }>;
603
+ options?: AskRenderOption[];
574
604
  multi?: boolean;
575
605
  questions?: Array<{
576
606
  id: string;
577
607
  question: string;
578
- options: Array<{ label: string }>;
608
+ options: AskRenderOption[];
579
609
  multi?: boolean;
580
610
  }>;
581
611
  }
@@ -634,6 +664,13 @@ export const askToolRenderer = {
634
664
  const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
635
665
  const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
636
666
  optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
667
+ if (opt.description?.trim()) {
668
+ const optContinuation = isLastOpt ? " " : uiTheme.tree.vertical;
669
+ const description = renderInlineMarkdown(opt.description.trim(), mdTheme, t =>
670
+ uiTheme.fg("dim", t),
671
+ );
672
+ optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optContinuation)} ${uiTheme.fg("dim", "↳")} ${description}`;
673
+ }
637
674
  }
638
675
  container.addChild(new Text(optText, 0, 0));
639
676
  }
@@ -661,6 +698,11 @@ export const askToolRenderer = {
661
698
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
662
699
  const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
663
700
  optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
701
+ if (opt.description?.trim()) {
702
+ const continuation = isLast ? " " : uiTheme.tree.vertical;
703
+ const description = renderInlineMarkdown(opt.description.trim(), mdTheme, t => uiTheme.fg("dim", t));
704
+ optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", "↳")} ${description}`;
705
+ }
664
706
  }
665
707
  container.addChild(new Text(optText, 0, 0));
666
708
  }
@@ -815,18 +815,21 @@ export class WorkerCore {
815
815
  { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
816
816
  );
817
817
  const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
818
+ const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
819
+ const savedBuffer = saveFullRes ? buffer : resized.buffer;
820
+ const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
821
+ // Auto-generated names must match the bytes we actually write: full-res is always
822
+ // PNG, but the resized buffer is whichever of PNG/JPEG/WebP encoded smallest.
823
+ const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png";
818
824
  const dest =
819
825
  explicitPath ??
820
826
  (session.browserScreenshotDir
821
827
  ? path.join(
822
828
  session.browserScreenshotDir,
823
- `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.png`,
829
+ `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
824
830
  )
825
- : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.png`));
831
+ : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`));
826
832
  await fs.promises.mkdir(path.dirname(dest), { recursive: true });
827
- const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
828
- const savedBuffer = saveFullRes ? buffer : resized.buffer;
829
- const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
830
833
  await Bun.write(dest, savedBuffer);
831
834
  const info: ScreenshotResult = {
832
835
  dest,
@@ -0,0 +1,38 @@
1
+ import { $env, $flag } from "@oh-my-pi/pi-utils";
2
+ import type { ToolSession } from ".";
3
+
4
+ export interface EvalBackendsAllowance {
5
+ python: boolean;
6
+ js: boolean;
7
+ }
8
+
9
+ /**
10
+ * Parse PI_PY / PI_JS environment variables. Each is a boolean flag; unset
11
+ * means "not specified, defer to settings". Returns null when neither is set
12
+ * so the caller can fall through to `readEvalBackendsAllowance` per key.
13
+ */
14
+ function getEvalBackendsFromEnv(): EvalBackendsAllowance | null {
15
+ const pyEnv = $env.PI_PY;
16
+ const jsEnv = $env.PI_JS;
17
+ if (pyEnv === undefined && jsEnv === undefined) return null;
18
+ return {
19
+ python: pyEnv === undefined ? true : $flag("PI_PY"),
20
+ js: jsEnv === undefined ? true : $flag("PI_JS"),
21
+ };
22
+ }
23
+
24
+ /** Read per-backend allowance from settings (defaults true). */
25
+ export function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAllowance {
26
+ return {
27
+ python: session.settings.get("eval.py") ?? true,
28
+ js: session.settings.get("eval.js") ?? true,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Materialize the active eval backend allowance: PI_PY / PI_JS env flags
34
+ * override the per-key settings; otherwise settings (defaults true) win.
35
+ */
36
+ export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance {
37
+ return getEvalBackendsFromEnv() ?? readEvalBackendsAllowance(session);
38
+ }