@oh-my-pi/pi-coding-agent 15.1.8 → 15.2.1

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 (71) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/types/cli/update-cli.d.ts +18 -0
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/eval/py/kernel.d.ts +6 -0
  5. package/dist/types/goals/state.d.ts +1 -1
  6. package/dist/types/goals/tools/goal-tool.d.ts +4 -0
  7. package/dist/types/hashline/parser.d.ts +6 -2
  8. package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
  9. package/dist/types/main.d.ts +25 -1
  10. package/dist/types/modes/theme/shimmer.d.ts +27 -0
  11. package/dist/types/slash-commands/helpers/format.d.ts +4 -1
  12. package/dist/types/tools/ast-edit.d.ts +3 -0
  13. package/dist/types/tools/ast-grep.d.ts +3 -0
  14. package/dist/types/tools/find.d.ts +3 -0
  15. package/dist/types/tools/search.d.ts +3 -0
  16. package/dist/types/tui/file-list.d.ts +6 -0
  17. package/dist/types/tui/hyperlink.d.ts +42 -0
  18. package/dist/types/tui/index.d.ts +1 -0
  19. package/dist/types/utils/tool-choice.d.ts +2 -1
  20. package/dist/types/web/search/providers/utils.d.ts +27 -1
  21. package/package.json +7 -7
  22. package/src/cli/update-cli.ts +78 -36
  23. package/src/config/model-registry.ts +23 -12
  24. package/src/config/settings-schema.ts +12 -0
  25. package/src/config/settings.ts +28 -5
  26. package/src/edit/renderer.ts +5 -3
  27. package/src/eval/py/executor.ts +12 -1
  28. package/src/eval/py/kernel.ts +24 -8
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  30. package/src/goals/runtime.ts +9 -3
  31. package/src/goals/state.ts +1 -1
  32. package/src/goals/tools/goal-tool.ts +12 -2
  33. package/src/hashline/diff.ts +1 -1
  34. package/src/hashline/execute.ts +2 -2
  35. package/src/hashline/parser.ts +87 -12
  36. package/src/internal-urls/memory-protocol.ts +1 -1
  37. package/src/main.ts +13 -2
  38. package/src/modes/interactive-mode.ts +29 -1
  39. package/src/modes/theme/shimmer.ts +79 -0
  40. package/src/prompts/agents/oracle.md +15 -16
  41. package/src/prompts/tools/goal.md +7 -2
  42. package/src/session/agent-session.ts +12 -75
  43. package/src/slash-commands/helpers/format.ts +23 -3
  44. package/src/task/executor.ts +115 -19
  45. package/src/tools/ast-edit.ts +39 -6
  46. package/src/tools/ast-grep.ts +38 -6
  47. package/src/tools/find.ts +13 -2
  48. package/src/tools/read.ts +46 -6
  49. package/src/tools/search.ts +447 -265
  50. package/src/tui/file-list.ts +10 -2
  51. package/src/tui/hyperlink.ts +126 -0
  52. package/src/tui/index.ts +1 -0
  53. package/src/utils/tool-choice.ts +7 -7
  54. package/src/web/kagi.ts +2 -2
  55. package/src/web/parallel.ts +3 -3
  56. package/src/web/search/index.ts +20 -9
  57. package/src/web/search/providers/anthropic.ts +4 -2
  58. package/src/web/search/providers/brave.ts +4 -2
  59. package/src/web/search/providers/codex.ts +4 -1
  60. package/src/web/search/providers/exa.ts +4 -1
  61. package/src/web/search/providers/gemini.ts +4 -1
  62. package/src/web/search/providers/jina.ts +4 -2
  63. package/src/web/search/providers/kagi.ts +5 -1
  64. package/src/web/search/providers/kimi.ts +4 -2
  65. package/src/web/search/providers/parallel.ts +5 -1
  66. package/src/web/search/providers/perplexity.ts +7 -2
  67. package/src/web/search/providers/searxng.ts +4 -1
  68. package/src/web/search/providers/synthetic.ts +4 -2
  69. package/src/web/search/providers/tavily.ts +4 -2
  70. package/src/web/search/providers/utils.ts +63 -1
  71. package/src/web/search/providers/zai.ts +4 -2
@@ -98,6 +98,7 @@ import {
98
98
  } from "./loop-limit";
99
99
  import { OAuthManualInputManager } from "./oauth-manual-input";
100
100
  import { SessionObserverRegistry } from "./session-observer-registry";
101
+ import { type ShimmerPalette, shimmerSegments, shimmerText } from "./theme/shimmer";
101
102
  import type { Theme } from "./theme/theme";
102
103
  import {
103
104
  getEditorTheme,
@@ -110,6 +111,20 @@ import {
110
111
  import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types";
111
112
  import { UiHelpers } from "./utils/ui-helpers";
112
113
 
114
+ const WORKING_INTERRUPT_HINT = " (esc to interrupt)";
115
+
116
+ const HINT_SHIMMER_PALETTE: ShimmerPalette = {
117
+ low: "dim",
118
+ mid: "muted",
119
+ high: "borderAccent",
120
+ };
121
+
122
+ function renderWorkingMessage(message: string): string {
123
+ if (!message.endsWith(WORKING_INTERRUPT_HINT)) return shimmerText(message, theme);
124
+ const header = message.slice(0, -WORKING_INTERRUPT_HINT.length);
125
+ return shimmerSegments([{ text: header }, { text: WORKING_INTERRUPT_HINT, palette: HINT_SHIMMER_PALETTE }], theme);
126
+ }
127
+
113
128
  const EDITOR_MAX_HEIGHT_MIN = 6;
114
129
  const EDITOR_MAX_HEIGHT_MAX = 18;
115
130
  const EDITOR_RESERVED_ROWS = 12;
@@ -1063,6 +1078,12 @@ export class InteractiveMode implements InteractiveModeContext {
1063
1078
  return;
1064
1079
  }
1065
1080
  if (event.type === "goal_updated") {
1081
+ // Handle drop before clearing goalModeEnabled so #exitGoalMode can
1082
+ // still restore the previous tool set while the flag is true.
1083
+ if (event.state?.goal?.status === "dropped") {
1084
+ await this.#exitGoalMode({ reason: "dropped", silent: true });
1085
+ return;
1086
+ }
1066
1087
  this.goalModeEnabled = event.state?.enabled === true;
1067
1088
  this.goalModePaused = event.state?.enabled !== true && event.state?.goal?.status === "paused";
1068
1089
  if (!event.state?.enabled) {
@@ -1150,6 +1171,13 @@ export class InteractiveMode implements InteractiveModeContext {
1150
1171
  const restored = await this.session.goalRuntime.onThreadResumed();
1151
1172
  this.goalModeEnabled = restored?.enabled === true;
1152
1173
  this.goalModePaused = restored?.enabled !== true && restored?.goal.status === "paused";
1174
+ // sdk.ts excludes "goal" from the initial active tool set unconditionally.
1175
+ // Re-add it now so the agent can call resume, complete, or drop on this goal.
1176
+ if (restored?.goal) {
1177
+ const previousTools = this.session.getActiveToolNames().filter(name => name !== "goal");
1178
+ this.#goalModePreviousTools = previousTools;
1179
+ await this.session.setActiveToolsByName([...new Set([...previousTools, "goal"])]);
1180
+ }
1153
1181
  this.#updateGoalModeStatus();
1154
1182
  return;
1155
1183
  }
@@ -2167,7 +2195,7 @@ export class InteractiveMode implements InteractiveModeContext {
2167
2195
  this.loadingAnimation = new Loader(
2168
2196
  this.ui,
2169
2197
  spinner => theme.fg("accent", spinner),
2170
- text => theme.fg("muted", text),
2198
+ renderWorkingMessage,
2171
2199
  this.#defaultWorkingMessage,
2172
2200
  getSymbolTheme().spinnerFrames,
2173
2201
  );
@@ -0,0 +1,79 @@
1
+ import type { Theme, ThemeColor } from "./theme";
2
+
3
+ const SHIMMER_PADDING = 10;
4
+ const SHIMMER_SWEEP_MS = 2000;
5
+ const SHIMMER_BAND_HALF_WIDTH = 5;
6
+
7
+ type ShimmerTheme = Pick<Theme, "bold" | "fg">;
8
+
9
+ /** Three-tier color stack a shimmer character cycles through as the band sweeps. */
10
+ export interface ShimmerPalette {
11
+ /** Color for chars outside / at the edge of the band (intensity < 0.2). */
12
+ low: ThemeColor;
13
+ /** Color for chars approaching the crest (0.2 <= intensity < 0.6). */
14
+ mid: ThemeColor;
15
+ /** Color at the band's crest (intensity >= 0.6). */
16
+ high: ThemeColor;
17
+ /** Whether to bold the crest tier. Default `false`. */
18
+ bold?: boolean;
19
+ }
20
+
21
+ /** One run of text that shares a palette inside a larger shimmer sweep. */
22
+ export interface ShimmerSegment {
23
+ text: string;
24
+ palette?: ShimmerPalette;
25
+ }
26
+
27
+ export const DEFAULT_SHIMMER_PALETTE: ShimmerPalette = {
28
+ low: "dim",
29
+ mid: "muted",
30
+ high: "accent",
31
+ bold: true,
32
+ };
33
+
34
+ function shimmerIntensity(index: number, length: number): number {
35
+ const period = length + SHIMMER_PADDING * 2;
36
+ const pos = Math.floor(((Date.now() % SHIMMER_SWEEP_MS) / SHIMMER_SWEEP_MS) * period);
37
+ const dist = Math.abs(index + SHIMMER_PADDING - pos);
38
+ if (dist > SHIMMER_BAND_HALF_WIDTH) return 0;
39
+
40
+ const x = Math.PI * (dist / SHIMMER_BAND_HALF_WIDTH);
41
+ return 0.5 * (1 + Math.cos(x));
42
+ }
43
+
44
+ function styleShimmerChar(ch: string, intensity: number, theme: ShimmerTheme, palette: ShimmerPalette): string {
45
+ if (intensity < 0.2) return theme.fg(palette.low, ch);
46
+ if (intensity < 0.6) return theme.fg(palette.mid, ch);
47
+ const styled = theme.fg(palette.high, ch);
48
+ return palette.bold ? theme.bold(styled) : styled;
49
+ }
50
+
51
+ /**
52
+ * Apply a shimmer sweep across one or more segments, treating them as a single
53
+ * continuous string for band positioning. Each segment can supply its own
54
+ * palette so the gradient stays in lockstep while the colors differ.
55
+ */
56
+ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: ShimmerTheme): string {
57
+ let total = 0;
58
+ const expanded: Array<{ chars: string[]; palette: ShimmerPalette }> = [];
59
+ for (const seg of segments) {
60
+ const chars = [...seg.text];
61
+ total += chars.length;
62
+ expanded.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
63
+ }
64
+ if (total === 0) return "";
65
+
66
+ const out: string[] = [];
67
+ let index = 0;
68
+ for (const { chars, palette } of expanded) {
69
+ for (const ch of chars) {
70
+ out.push(styleShimmerChar(ch, shimmerIntensity(index, total), theme, palette));
71
+ index++;
72
+ }
73
+ }
74
+ return out.join("");
75
+ }
76
+
77
+ export function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string {
78
+ return shimmerSegments([{ text, palette }], theme);
79
+ }
@@ -1,19 +1,17 @@
1
1
  ---
2
2
  name: oracle
3
- description: Deep reasoning advisor for debugging dead ends, architecture decisions, and second opinions. Read-only.
3
+ description: Wise senior engineer to consult or delegate work to — debugging, architecture, second opinions, and hands-on implementation when asked.
4
4
  spawns: explore
5
5
  model: pi/slow
6
6
  thinking-level: xhigh
7
7
  blocking: true
8
8
  ---
9
9
 
10
- You are a senior diagnostician and strategic technical advisor. You receive problems other agents are stuck on doom loops, mysterious failures, architectural tradeoffs, subtle bugs and return clear, actionable analysis.
10
+ You are the wise guy on the team a senior engineer with deep judgment that other agents consult when they are stuck, uncertain, or need a second opinion. You also take direct delegation: if the caller hands you work, you do it, including reads, writes, edits, and running commands.
11
11
 
12
- You diagnose, explain, and recommend. You do not implement. Others act on your findings.
13
-
14
- <critical>
15
- You MUST operate as read-only. You NEVER write, edit, or modify files, nor execute any state-changing commands.
16
- </critical>
12
+ You diagnose, decide, and execute. You match the mode to the ask:
13
+ - **Consult**: explain the root cause, lay out tradeoffs, recommend a path.
14
+ - **Delegate**: carry the work to completion — modify files, run verification, deliver a finished change.
17
15
 
18
16
  <directives>
19
17
  - You MUST reason from first principles. The caller already tried the obvious.
@@ -23,6 +21,7 @@ You MUST operate as read-only. You NEVER write, edit, or modify files, nor execu
23
21
  - You SHOULD consider at least two hypotheses before converging on one.
24
22
  - You SHOULD invoke tools in parallel when investigating multiple hypotheses.
25
23
  - When the problem is architectural, you MUST weigh tradeoffs explicitly: what does each option cost, what does it buy, what does it foreclose.
24
+ - When delegated implementation work, you MUST finish it: edit the files, run the relevant tests/checks, and report exactly what changed.
26
25
  </directives>
27
26
 
28
27
  <decision-framework>
@@ -35,22 +34,22 @@ Apply pragmatic minimalism:
35
34
  </decision-framework>
36
35
 
37
36
  <procedure>
38
- 1. Read the problem statement carefully. Identify what was already tried and why it failed.
39
- 2. Form 2-3 hypotheses for the root cause.
37
+ 1. Read the problem statement carefully. Identify what was already tried, what failed, and whether the caller wants advice or execution.
38
+ 2. Form 2-3 hypotheses for the root cause (for diagnosis) or 2-3 viable approaches (for design).
40
39
  3. Use tools to gather evidence — read relevant code, trace data flow, check types, grep for related patterns. Parallelize independent reads.
41
- 4. Eliminate hypotheses based on evidence. Narrow to the most likely cause.
42
- 5. If the problem is a decision (not a bug), lay out options with concrete tradeoffs.
43
- 6. Deliver a clear verdict with supporting evidence.
40
+ 4. Eliminate hypotheses based on evidence. Narrow to the most likely cause or best approach.
41
+ 5. If consulting: deliver verdict with supporting evidence and a concrete recommendation.
42
+ 6. If implementing: make the changes, verify them, and report the diff and verification result.
44
43
  </procedure>
44
+
45
45
  <scope-discipline>
46
- - Recommend ONLY what was asked. No unsolicited improvements.
46
+ - Do ONLY what was asked. No unsolicited refactors or improvements.
47
47
  - If you notice other issues, list at most 2 as "Optional future considerations" at the end.
48
48
  - You NEVER expand the problem surface beyond the original request.
49
49
  - Exhaust provided context before reaching for tools. External lookups fill genuine gaps, not curiosity.
50
50
  </scope-discipline>
51
51
 
52
52
  <critical>
53
- You MUST keep going until you have a clear answer or have exhausted available evidence.
54
- Before finalizing: re-scan for unstated assumptions, verify claims are grounded in code not invented, check for overly strong language not justified by evidence.
55
- This matters. The caller is stuck. Get it right.
53
+ You MUST keep going until the problem is solved or the work is finished. Before finalizing: re-scan for unstated assumptions, verify claims are grounded in code not invented, check for overly strong language not justified by evidence.
54
+ The caller came to you because they trust your judgment. Get it right.
56
55
  </critical>
@@ -1,13 +1,18 @@
1
1
  Manage the active goal-mode objective.
2
2
 
3
3
  Use a single `op` field:
4
- - `create` starts a goal. Requires `objective`; optional `token_budget` must be positive. Use only when no goal exists.
5
- - `get` returns the current goal and remaining token budget.
4
+ - `create` starts a goal. Requires `objective`; optional `token_budget` must be positive. Use only when no goal exists and no goal is paused.
5
+ - `get` returns the current goal (active or paused) and remaining token budget.
6
+ - `resume` re-activates a paused goal so work can continue.
6
7
  - `complete` marks the goal complete after you have verified every deliverable against current evidence.
8
+ - `drop` discards the current goal without completing it.
7
9
 
8
10
  Examples:
9
11
  - `goal({"op":"create","objective":"Implement feature X","token_budget":50000})`
10
12
  - `goal({"op":"get"})`
13
+ - `goal({"op":"resume"})`
11
14
  - `goal({"op":"complete"})`
15
+ - `goal({"op":"drop"})`
12
16
 
13
17
  Do not call `complete` because a budget is low or a turn is ending. Call it only when the goal is actually done and verified.
18
+ If `get` shows a paused goal, call `resume` before continuing work on it.
@@ -440,11 +440,6 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
440
440
  return `${selector.provider}/${selector.id}`;
441
441
  }
442
442
 
443
- /** Composite key for auto-clear timers, keyed by phase name + task content. */
444
- function todoClearKey(phaseName: string, taskContent: string): string {
445
- return `${phaseName}\u0000${taskContent}`;
446
- }
447
-
448
443
  const IRC_REPLY_MAX_BYTES = 4096;
449
444
 
450
445
  /**
@@ -796,7 +791,6 @@ export class AgentSession {
796
791
  // Todo completion reminder state
797
792
  #todoReminderCount = 0;
798
793
  #todoPhases: TodoPhase[] = [];
799
- #todoClearTimers = new Map<string, Timer>();
800
794
  #toolChoiceQueue = new ToolChoiceQueue();
801
795
 
802
796
  // Bash execution state
@@ -2734,7 +2728,6 @@ export class AgentSession {
2734
2728
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
2735
2729
  }
2736
2730
  await this.#cancelPostPromptTasks();
2737
- this.#clearTodoClearTimers();
2738
2731
  // Cancel jobs this agent registered so a subagent's teardown doesn't
2739
2732
  // leak its background bash/task work into the parent's manager. Only
2740
2733
  // the session that owns the manager goes on to dispose it (which itself
@@ -4628,13 +4621,12 @@ export class AgentSession {
4628
4621
 
4629
4622
  setTodoPhases(phases: TodoPhase[]): void {
4630
4623
  this.#todoPhases = this.#cloneTodoPhases(phases);
4631
- this.#scheduleTodoAutoClear(phases);
4632
4624
  }
4633
4625
 
4634
4626
  #syncTodoPhasesFromBranch(): void {
4635
4627
  const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
4636
4628
  // Strip completed/abandoned tasks — they were done in a previous run,
4637
- // so the auto-clear grace period has already elapsed.
4629
+ // so they have no bearing on progress tracking for the new turn.
4638
4630
  for (const phase of phases) {
4639
4631
  phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned");
4640
4632
  }
@@ -4652,72 +4644,11 @@ export class AgentSession {
4652
4644
  }));
4653
4645
  }
4654
4646
 
4655
- /** Schedule auto-removal of completed/abandoned tasks after a delay. */
4656
- #scheduleTodoAutoClear(phases: TodoPhase[]): void {
4657
- // Default bumped from 60s to 30 min: the prior 60s splice mutated canonical
4658
- // state mid-turn, so the model observed phase totals shrinking ("6 → 5")
4659
- // between tool calls. Surviving the turn matches user expectations; a
4660
- // render-time filter in the UI consumer would be cleaner but lives in a
4661
- // different package and is out of scope for this fix.
4662
- const delaySec = this.settings.get("tasks.todoClearDelay") ?? 1800;
4663
- if (delaySec < 0) return; // "Never" — no auto-clear
4664
- const delayMs = delaySec * 1000;
4665
- const doneKeys = new Set<string>();
4666
- for (const phase of phases) {
4667
- for (const task of phase.tasks) {
4668
- if (task.status === "completed" || task.status === "abandoned") {
4669
- doneKeys.add(todoClearKey(phase.name, task.content));
4670
- }
4671
- }
4672
- }
4673
-
4674
- // Cancel timers for tasks that are no longer done (e.g. status was reverted)
4675
- for (const [key, timer] of this.#todoClearTimers) {
4676
- if (!doneKeys.has(key)) {
4677
- clearTimeout(timer);
4678
- this.#todoClearTimers.delete(key);
4679
- }
4680
- }
4681
-
4682
- // Schedule new timers for newly-done tasks
4683
- for (const key of doneKeys) {
4684
- if (this.#todoClearTimers.has(key)) continue;
4685
- if (delayMs === 0) {
4686
- // Instant — run synchronously on next microtask to batch removals
4687
- const timer = setTimeout(() => this.#runTodoAutoClear(key), 0);
4688
- this.#todoClearTimers.set(key, timer);
4689
- } else {
4690
- const timer = setTimeout(() => this.#runTodoAutoClear(key), delayMs);
4691
- this.#todoClearTimers.set(key, timer);
4692
- }
4693
- }
4694
- }
4695
-
4696
- /** Remove a single completed task and notify the UI. */
4697
- #runTodoAutoClear(key: string): void {
4698
- this.#todoClearTimers.delete(key);
4699
- let removed = false;
4700
- for (const phase of this.#todoPhases) {
4701
- const idx = phase.tasks.findIndex(t => todoClearKey(phase.name, t.content) === key);
4702
- if (idx !== -1 && (phase.tasks[idx].status === "completed" || phase.tasks[idx].status === "abandoned")) {
4703
- phase.tasks.splice(idx, 1);
4704
- removed = true;
4705
- break;
4706
- }
4707
- }
4708
- if (!removed) return;
4709
-
4710
- // Remove empty phases
4711
- this.#todoPhases = this.#todoPhases.filter(p => p.tasks.length > 0);
4712
- this.#emit({ type: "todo_auto_clear" });
4713
- }
4714
-
4715
- #clearTodoClearTimers(): void {
4716
- for (const timer of this.#todoClearTimers.values()) {
4717
- clearTimeout(timer);
4718
- }
4719
- this.#todoClearTimers.clear();
4720
- }
4647
+ // Auto-clear of completed/abandoned tasks was removed: the timer-driven
4648
+ // splice mutated canonical `#todoPhases` between tool calls, so the model
4649
+ // observed phase totals shrinking ("5 4") after marking tasks done. The
4650
+ // `tasks.todoClearDelay` setting is now inert; completed tasks survive
4651
+ // until the next explicit `todo_write` call removes them via `rm`/`drop`.
4721
4652
 
4722
4653
  /**
4723
4654
  * Abort current operation and wait for agent to become idle.
@@ -6240,6 +6171,12 @@ export class AgentSession {
6240
6171
  };
6241
6172
 
6242
6173
  const currentModel = this.model;
6174
+ // Prefer the active session's model: it's what the user is actively using,
6175
+ // and routing compaction to a different provider (e.g. an OpenAI default
6176
+ // model while the chat is on Anthropic) changes provider-specific behavior
6177
+ // like remote compaction endpoints. Role-based candidates only kick in
6178
+ // as auth fallbacks when the current model has no usable credentials.
6179
+ addCandidate(currentModel);
6243
6180
  for (const role of MODEL_ROLE_IDS) {
6244
6181
  addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
6245
6182
  }
@@ -1,3 +1,6 @@
1
+ import { shimmerText } from "../../modes/theme/shimmer";
2
+ import { theme as currentTheme, type Theme } from "../../modes/theme/theme";
3
+
1
4
  /** Format a millisecond duration as a coarse-grained human label. */
2
5
  export function formatDuration(ms: number): string {
3
6
  const seconds = Math.max(0, Math.round(ms / 1000));
@@ -10,14 +13,31 @@ export function formatDuration(ms: number): string {
10
13
  return `${days}d`;
11
14
  }
12
15
 
16
+ type ProgressBarTheme = Pick<Theme, "bold" | "fg">;
17
+
18
+ const unstyledProgressBarTheme: ProgressBarTheme = {
19
+ fg(_color, text) {
20
+ return text;
21
+ },
22
+ bold(text) {
23
+ return text;
24
+ },
25
+ };
26
+
27
+ function resolveProgressBarTheme(uiTheme: ProgressBarTheme | undefined): ProgressBarTheme {
28
+ return uiTheme ?? currentTheme ?? unstyledProgressBarTheme;
29
+ }
30
+
13
31
  /**
14
32
  * Render an ASCII progress bar with a trailing percent label.
15
33
  * `fraction` is clamped to `[0, 1]`. `undefined` renders a dotted placeholder.
16
34
  */
17
- export function renderAsciiBar(fraction: number | undefined, width = 24): string {
18
- if (fraction === undefined) return `[${"·".repeat(width)}]`;
35
+ export function renderAsciiBar(fraction: number | undefined, width = 24, uiTheme?: ProgressBarTheme): string {
36
+ const progressBarTheme = resolveProgressBarTheme(uiTheme);
37
+ if (fraction === undefined) return `[${shimmerText("·".repeat(width), progressBarTheme)}]`;
19
38
  const clamped = Math.min(Math.max(fraction, 0), 1);
20
39
  const filled = Math.round(clamped * width);
21
40
  const pct = Math.round(clamped * 100);
22
- return `[${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}] ${pct}%`;
41
+ const bar = `${"█".repeat(filled)}${"░".repeat(Math.max(0, width - filled))}`;
42
+ return `[${shimmerText(bar, progressBarTheme)}] ${pct}%`;
23
43
  }
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
- import { isJsonSchemaValueValid } from "@oh-my-pi/pi-ai/utils/schema";
10
+ import { type JsonSchemaValidationIssue, validateJsonSchemaValue } from "@oh-my-pi/pi-ai/utils/schema";
11
11
  import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import { ModelRegistry } from "../config/model-registry";
13
13
  import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
@@ -204,12 +204,59 @@ function parseStringifiedJson(value: unknown): unknown {
204
204
  }
205
205
  }
206
206
 
207
- function buildOutputValidator(schema: unknown): { validate?: (value: unknown) => boolean; error?: string } {
207
+ interface OutputValidator {
208
+ validate: (value: unknown) => { ok: true } | { ok: false; message: string; missingRequired: string[] };
209
+ requiredFields: string[];
210
+ }
211
+
212
+ function buildOutputValidator(schema: unknown): { validator?: OutputValidator; error?: string } {
208
213
  const { normalized, error } = normalizeSchema(schema);
209
214
  if (error) return { error };
210
215
  if (normalized === undefined) return {};
211
216
  const jsonSchema = jtdToJsonSchema(normalized);
212
- return { validate: value => isJsonSchemaValueValid(jsonSchema, value) };
217
+ const required = extractRequiredFields(jsonSchema);
218
+ return {
219
+ validator: {
220
+ requiredFields: required,
221
+ validate: value => {
222
+ const result = validateJsonSchemaValue(jsonSchema, value);
223
+ if (result.success) return { ok: true };
224
+ const missing = computeMissingRequired(required, value);
225
+ const message = formatValidationIssue(result.issues[0]) ?? "schema validation failed";
226
+ return { ok: false, message, missingRequired: missing };
227
+ },
228
+ },
229
+ };
230
+ }
231
+
232
+ function extractRequiredFields(jsonSchema: unknown): string[] {
233
+ if (!jsonSchema || typeof jsonSchema !== "object") return [];
234
+ const required = (jsonSchema as { required?: unknown }).required;
235
+ return Array.isArray(required) ? required.filter((k): k is string => typeof k === "string") : [];
236
+ }
237
+
238
+ function computeMissingRequired(required: readonly string[], value: unknown): string[] {
239
+ if (required.length === 0) return [];
240
+ if (value === null || value === undefined) return [...required];
241
+ if (typeof value !== "object" || Array.isArray(value)) return [];
242
+ const record = value as Record<string, unknown>;
243
+ return required.filter(key => !(key in record) || record[key] === undefined);
244
+ }
245
+
246
+ function formatValidationIssue(issue: JsonSchemaValidationIssue | undefined): string | undefined {
247
+ if (!issue) return undefined;
248
+ const path = issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
249
+ return `${path}: ${issue.message}`;
250
+ }
251
+
252
+ function previewOffendingData(value: unknown, maxLength = 500): string {
253
+ let serialized: string;
254
+ try {
255
+ serialized = JSON.stringify(value) ?? "null";
256
+ } catch {
257
+ serialized = String(value);
258
+ }
259
+ return serialized.length > maxLength ? `${serialized.slice(0, maxLength)}…` : serialized;
213
260
  }
214
261
 
215
262
  function tryParseJsonOutput(text: string): unknown | undefined {
@@ -253,9 +300,9 @@ function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): {
253
300
  if (parsed === undefined) return null;
254
301
  const candidate = parseStringifiedJson(extractCompletionData(parsed));
255
302
  if (candidate === undefined) return null;
256
- const { validate, error } = buildOutputValidator(outputSchema);
303
+ const { validator, error } = buildOutputValidator(outputSchema);
257
304
  if (error) return null;
258
- if (validate && !validate(candidate)) return null;
305
+ if (validator && !validator.validate(candidate).ok) return null;
259
306
  return { data: candidate };
260
307
  }
261
308
 
@@ -288,6 +335,31 @@ export const SUBAGENT_WARNING_NULL_YIELD = "SYSTEM WARNING: Subagent called yiel
288
335
  export const SUBAGENT_WARNING_MISSING_YIELD =
289
336
  "SYSTEM WARNING: Subagent exited without calling yield tool after 3 reminders.";
290
337
 
338
+ /** Build a schema_violation outcome — surfaced as a non-zero exit so callers treat it as a failure. */
339
+ function buildSchemaViolationOutcome(
340
+ failure: { message: string; missingRequired: string[] },
341
+ data: unknown,
342
+ ): { rawOutput: string; stderr: string; exitCode: number } {
343
+ const missing = failure.missingRequired;
344
+ const headline =
345
+ missing.length > 0
346
+ ? `schema_violation: missing required fields: ${missing.join(", ")}`
347
+ : `schema_violation: ${failure.message}`;
348
+ const payload = {
349
+ error: "schema_violation",
350
+ message: failure.message,
351
+ missingRequired: missing,
352
+ data: previewOffendingData(data),
353
+ };
354
+ let rawOutput: string;
355
+ try {
356
+ rawOutput = JSON.stringify(payload, null, 2);
357
+ } catch {
358
+ rawOutput = `{"error":"schema_violation","message":${JSON.stringify(headline)}}`;
359
+ }
360
+ return { rawOutput, stderr: headline, exitCode: 1 };
361
+ }
362
+
291
363
  export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
292
364
  let { rawOutput, exitCode, stderr } = args;
293
365
  const { yieldItems, reportFindings, doneAborted, signalAborted, outputSchema } = args;
@@ -311,14 +383,29 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
311
383
  rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
312
384
  } else {
313
385
  const completeData = normalizeCompleteData(submitData, reportFindings);
314
- try {
315
- rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
316
- } catch (err) {
317
- const errorMessage = err instanceof Error ? err.message : String(err);
318
- rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
386
+ const { validator, error: schemaError } = buildOutputValidator(outputSchema);
387
+ if (schemaError) {
388
+ rawOutput = `{"error":"schema_violation","message":"invalid output schema: ${schemaError.replace(/"/g, '\\"')}"}`;
389
+ stderr = `schema_violation: invalid output schema: ${schemaError}`;
390
+ exitCode = 1;
391
+ } else {
392
+ const verdict = validator ? validator.validate(completeData) : { ok: true as const };
393
+ if (!verdict.ok) {
394
+ const outcome = buildSchemaViolationOutcome(verdict, completeData);
395
+ rawOutput = outcome.rawOutput;
396
+ stderr = outcome.stderr;
397
+ exitCode = outcome.exitCode;
398
+ } else {
399
+ try {
400
+ rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
401
+ } catch (err) {
402
+ const errorMessage = err instanceof Error ? err.message : String(err);
403
+ rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
404
+ }
405
+ exitCode = 0;
406
+ stderr = "";
407
+ }
319
408
  }
320
- exitCode = 0;
321
- stderr = "";
322
409
  }
323
410
  }
324
411
  } else {
@@ -328,14 +415,23 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
328
415
  const fallback = allowFallback ? resolveFallbackCompletion(rawOutput, outputSchema) : null;
329
416
  if (fallback) {
330
417
  const completeData = normalizeCompleteData(fallback.data, reportFindings);
331
- try {
332
- rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
333
- } catch (err) {
334
- const errorMessage = err instanceof Error ? err.message : String(err);
335
- rawOutput = `{"error":"Failed to serialize fallback completion: ${errorMessage}"}`;
418
+ const { validator } = buildOutputValidator(outputSchema);
419
+ const verdict = validator ? validator.validate(completeData) : { ok: true as const };
420
+ if (!verdict.ok) {
421
+ const outcome = buildSchemaViolationOutcome(verdict, completeData);
422
+ rawOutput = outcome.rawOutput;
423
+ stderr = outcome.stderr;
424
+ exitCode = outcome.exitCode;
425
+ } else {
426
+ try {
427
+ rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
428
+ } catch (err) {
429
+ const errorMessage = err instanceof Error ? err.message : String(err);
430
+ rawOutput = `{"error":"Failed to serialize fallback completion: ${errorMessage}"}`;
431
+ }
432
+ exitCode = 0;
433
+ stderr = "";
336
434
  }
337
- exitCode = 0;
338
- stderr = "";
339
435
  } else if (!hasOutputSchema && allowFallback && rawOutput.trim().length > 0) {
340
436
  exitCode = 0;
341
437
  stderr = "";