@oh-my-pi/pi-coding-agent 16.2.13 → 16.3.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 (166) hide show
  1. package/CHANGELOG.md +108 -7
  2. package/dist/cli.js +6033 -5982
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/settings-schema.d.ts +28 -5
  9. package/dist/types/edit/modes/patch.d.ts +11 -0
  10. package/dist/types/eval/agent-bridge.d.ts +5 -1
  11. package/dist/types/exec/bash-executor.d.ts +1 -0
  12. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  13. package/dist/types/extensibility/skills.d.ts +2 -1
  14. package/dist/types/mnemopi/state.d.ts +12 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  16. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  17. package/dist/types/modes/components/usage-row.d.ts +1 -1
  18. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  20. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  21. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  22. package/dist/types/modes/interactive-mode.d.ts +12 -7
  23. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  25. package/dist/types/modes/session-teardown.d.ts +63 -0
  26. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  27. package/dist/types/modes/theme/theme.d.ts +9 -4
  28. package/dist/types/modes/types.d.ts +2 -3
  29. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  30. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +9 -0
  32. package/dist/types/session/agent-session.d.ts +46 -13
  33. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  34. package/dist/types/session/session-loader.d.ts +5 -0
  35. package/dist/types/task/executor.d.ts +12 -5
  36. package/dist/types/task/parallel.d.ts +1 -0
  37. package/dist/types/task/render.test.d.ts +1 -0
  38. package/dist/types/thinking.d.ts +2 -0
  39. package/dist/types/tools/checkpoint.d.ts +8 -0
  40. package/dist/types/tools/eval-render.d.ts +1 -0
  41. package/dist/types/tools/fetch.d.ts +11 -1
  42. package/dist/types/tools/index.d.ts +3 -1
  43. package/dist/types/tools/irc.d.ts +1 -0
  44. package/dist/types/tools/output-meta.d.ts +7 -0
  45. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  46. package/dist/types/tools/path-utils.d.ts +16 -0
  47. package/dist/types/tools/render-utils.d.ts +4 -1
  48. package/dist/types/utils/git.d.ts +47 -2
  49. package/dist/types/web/search/provider.d.ts +7 -0
  50. package/dist/types/web/search/types.d.ts +1 -1
  51. package/package.json +12 -12
  52. package/src/advisor/config.ts +4 -2
  53. package/src/cli/bench-cli.ts +7 -2
  54. package/src/collab/guest.ts +94 -5
  55. package/src/collab/host.ts +80 -3
  56. package/src/collab/protocol.ts +9 -2
  57. package/src/commit/model-selection.ts +8 -2
  58. package/src/config/keybindings.ts +3 -1
  59. package/src/config/model-discovery.ts +66 -2
  60. package/src/config/model-registry.ts +7 -3
  61. package/src/config/model-resolver.ts +51 -23
  62. package/src/config/settings-schema.ts +44 -14
  63. package/src/edit/hashline/diff.ts +13 -2
  64. package/src/edit/index.ts +58 -8
  65. package/src/edit/modes/patch.ts +53 -18
  66. package/src/edit/streaming.ts +7 -6
  67. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  68. package/src/eval/agent-bridge.ts +15 -5
  69. package/src/exec/bash-executor.ts +7 -12
  70. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  71. package/src/extensibility/skills.ts +29 -6
  72. package/src/goals/guided-setup.ts +4 -3
  73. package/src/internal-urls/docs-index.generated.txt +1 -1
  74. package/src/lsp/client.ts +11 -14
  75. package/src/main.ts +38 -10
  76. package/src/mnemopi/state.ts +43 -7
  77. package/src/modes/acp/acp-agent.ts +1 -1
  78. package/src/modes/components/agent-hub.ts +10 -2
  79. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  80. package/src/modes/components/assistant-message.ts +16 -10
  81. package/src/modes/components/chat-transcript-builder.ts +11 -1
  82. package/src/modes/components/custom-editor.test.ts +20 -0
  83. package/src/modes/components/hook-selector.ts +44 -25
  84. package/src/modes/components/model-selector.ts +1 -1
  85. package/src/modes/components/status-line/component.test.ts +44 -0
  86. package/src/modes/components/status-line/component.ts +9 -1
  87. package/src/modes/components/status-line/segments.ts +3 -3
  88. package/src/modes/components/usage-row.ts +16 -2
  89. package/src/modes/controllers/command-controller.ts +6 -21
  90. package/src/modes/controllers/event-controller.ts +11 -9
  91. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  92. package/src/modes/controllers/input-controller.ts +16 -13
  93. package/src/modes/controllers/selector-controller.ts +3 -8
  94. package/src/modes/controllers/streaming-reveal.ts +75 -53
  95. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  96. package/src/modes/interactive-mode.ts +122 -79
  97. package/src/modes/rpc/rpc-client.ts +21 -0
  98. package/src/modes/rpc/rpc-mode.ts +197 -46
  99. package/src/modes/session-teardown.test.ts +219 -0
  100. package/src/modes/session-teardown.ts +82 -0
  101. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  102. package/src/modes/skill-command.ts +7 -20
  103. package/src/modes/theme/theme.ts +29 -15
  104. package/src/modes/types.ts +2 -3
  105. package/src/modes/utils/copy-targets.ts +12 -0
  106. package/src/modes/utils/ui-helpers.ts +19 -2
  107. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  108. package/src/prompts/advisor/system.md +1 -1
  109. package/src/prompts/agents/tester.md +6 -2
  110. package/src/prompts/skills/autoload.md +8 -0
  111. package/src/prompts/skills/user-invocation.md +11 -0
  112. package/src/prompts/steering/parent-irc.md +5 -0
  113. package/src/prompts/system/irc-incoming.md +2 -0
  114. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  115. package/src/prompts/system/plan-mode-approved.md +7 -10
  116. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  117. package/src/prompts/system/plan-mode-reference.md +3 -4
  118. package/src/prompts/system/rewind-report.md +6 -0
  119. package/src/prompts/system/subagent-system-prompt.md +3 -0
  120. package/src/prompts/tools/irc.md +2 -1
  121. package/src/prompts/tools/job.md +2 -2
  122. package/src/prompts/tools/rewind.md +3 -2
  123. package/src/prompts/tools/task.md +4 -0
  124. package/src/sdk.ts +18 -4
  125. package/src/session/agent-session.ts +660 -114
  126. package/src/session/exit-diagnostics.ts +202 -0
  127. package/src/session/session-context.ts +25 -13
  128. package/src/session/session-loader.ts +58 -25
  129. package/src/session/session-manager.ts +0 -1
  130. package/src/session/session-persistence.ts +30 -4
  131. package/src/session/settings-stream-fn.ts +14 -0
  132. package/src/slash-commands/builtin-registry.ts +35 -3
  133. package/src/system-prompt.ts +15 -1
  134. package/src/task/executor.ts +31 -8
  135. package/src/task/index.ts +31 -9
  136. package/src/task/isolation-runner.ts +18 -2
  137. package/src/task/parallel.ts +7 -2
  138. package/src/task/render.test.ts +121 -0
  139. package/src/task/render.ts +48 -2
  140. package/src/task/worktree.ts +12 -13
  141. package/src/task/yield-assembly.ts +8 -5
  142. package/src/thinking.ts +5 -0
  143. package/src/tools/ask.ts +188 -9
  144. package/src/tools/ast-edit.ts +7 -0
  145. package/src/tools/ast-grep.ts +11 -0
  146. package/src/tools/bash.ts +6 -30
  147. package/src/tools/checkpoint.ts +15 -1
  148. package/src/tools/eval-render.ts +15 -0
  149. package/src/tools/fetch.ts +82 -18
  150. package/src/tools/gh.ts +1 -1
  151. package/src/tools/grep.ts +45 -27
  152. package/src/tools/index.ts +3 -1
  153. package/src/tools/irc.ts +24 -9
  154. package/src/tools/output-meta.ts +50 -0
  155. package/src/tools/output-schema-validator.ts +152 -15
  156. package/src/tools/path-utils.ts +55 -10
  157. package/src/tools/read.ts +1 -1
  158. package/src/tools/render-utils.ts +5 -3
  159. package/src/tools/yield.ts +41 -1
  160. package/src/utils/commit-message-generator.ts +2 -2
  161. package/src/utils/git.ts +271 -29
  162. package/src/utils/thinking-display.ts +13 -0
  163. package/src/web/search/index.ts +9 -28
  164. package/src/web/search/provider.ts +26 -1
  165. package/src/web/search/providers/duckduckgo.ts +1 -1
  166. package/src/web/search/types.ts +5 -1
package/src/task/index.ts CHANGED
@@ -60,7 +60,7 @@ import {
60
60
  } from "./isolation-runner";
61
61
  import { generateTaskName } from "./name-generator";
62
62
  import { AgentOutputManager } from "./output-manager";
63
- import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
63
+ import { mapWithConcurrencyLimit, normalizeConcurrencyLimit, Semaphore } from "./parallel";
64
64
  import { renderResult, renderCall as renderTaskCall } from "./render";
65
65
  import { repairTaskParams } from "./repair-args";
66
66
  import { parseIsolationMode } from "./worktree";
@@ -208,7 +208,7 @@ function renderDescription(
208
208
  defaultAgent: spawnPolicy.defaultAgent,
209
209
  defaultAgentIsGeneric: spawnPolicy.defaultAgent === DEFAULT_SPAWN_AGENT,
210
210
  allowedAgentsText: spawnPolicy.allowedPromptText,
211
- MAX_CONCURRENCY: maxConcurrency,
211
+ MAX_CONCURRENCY: normalizeConcurrencyLimit(maxConcurrency),
212
212
  isolationEnabled,
213
213
  batchEnabled,
214
214
  asyncEnabled,
@@ -501,8 +501,10 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
501
501
  readonly #blockedAgent: string | undefined;
502
502
  /**
503
503
  * One semaphore per TaskTool instance (i.e. per session): bounds concurrent
504
- * subagents across parallel `task` calls within the session. Sized from
505
- * `task.maxConcurrency` at first use; later setting changes do not resize it.
504
+ * subagents across parallel `task` calls within the session. Resized in
505
+ * place from `task.maxConcurrency` before every acquire/release so a
506
+ * mid-session settings change (UI toggle, `/settings`) applies to both new
507
+ * spawns and work already parked in the semaphore queue.
506
508
  */
507
509
  #spawnSemaphore: Semaphore | undefined;
508
510
 
@@ -545,10 +547,19 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
545
547
  }
546
548
 
547
549
  #getSpawnSemaphore(): Semaphore {
548
- this.#spawnSemaphore ??= new Semaphore(this.session.settings.get("task.maxConcurrency"));
550
+ const max = this.session.settings.get("task.maxConcurrency");
551
+ if (this.#spawnSemaphore) {
552
+ this.#spawnSemaphore.resize(max);
553
+ } else {
554
+ this.#spawnSemaphore = new Semaphore(max);
555
+ }
549
556
  return this.#spawnSemaphore;
550
557
  }
551
558
 
559
+ #releaseSpawnSemaphore(): void {
560
+ this.#getSpawnSemaphore().release();
561
+ }
562
+
552
563
  /**
553
564
  * Create a TaskTool instance with async agent discovery.
554
565
  */
@@ -803,6 +814,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
803
814
  const startedAt = Date.now();
804
815
  const semaphore = this.#getSpawnSemaphore();
805
816
  let semaphoreHeld = false;
817
+ // Every release funnels through here: the flag flips before the
818
+ // release so no path — acquire-time abort, executor failure, or a
819
+ // future refactor that reorders the branches — can return a permit
820
+ // twice. Releasing a permit this job never acquired would steal one
821
+ // from a running job and let a later spawn start past
822
+ // task.maxConcurrency.
823
+ const releasePermit = () => {
824
+ if (!semaphoreHeld) return;
825
+ semaphoreHeld = false;
826
+ this.#releaseSpawnSemaphore();
827
+ };
806
828
  try {
807
829
  await semaphore.acquire(runSignal);
808
830
  semaphoreHeld = true;
@@ -814,7 +836,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
814
836
  }
815
837
  const acquiredAt = Date.now();
816
838
  if (!semaphoreHeld || runSignal.aborted) {
817
- if (semaphoreHeld) semaphore.release();
839
+ releasePermit();
818
840
  progress.status = "aborted";
819
841
  onSettled?.(true);
820
842
  throw new Error("Aborted before execution");
@@ -886,7 +908,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
886
908
  const hint = AgentRegistry.global().get(agentId) ? buildFollowUpHint(false) : "";
887
909
  throw new TaskJobError(`${message}${hint}`);
888
910
  } finally {
889
- semaphore.release();
911
+ releasePermit();
890
912
  }
891
913
  },
892
914
  {
@@ -931,7 +953,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
931
953
  { invokedAt, acquiredAt },
932
954
  );
933
955
  } finally {
934
- semaphore.release();
956
+ this.#releaseSpawnSemaphore();
935
957
  }
936
958
  }
937
959
 
@@ -979,7 +1001,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
979
1001
  { invokedAt, acquiredAt },
980
1002
  );
981
1003
  } finally {
982
- semaphore.release();
1004
+ this.#releaseSpawnSemaphore();
983
1005
  }
984
1006
  },
985
1007
  signal,
@@ -291,15 +291,31 @@ export async function mergeIsolatedChanges(opts: IsolationMergeOptions): Promise
291
291
  hadAnyChanges = false;
292
292
  } else {
293
293
  const normalized = patchText.endsWith("\n") ? patchText : `${patchText}\n`;
294
- changesApplied = await git.patch.canApplyText(repoRoot, normalized);
294
+ // Idempotence: declare a no-op only when the reverse patch applies AND
295
+ // the forward patch does not. `--reverse --check` alone can theoretically
296
+ // succeed if the file happens to carry the postimage at another location
297
+ // via git-apply's fuzz factor; requiring the forward check to fail
298
+ // removes that ambiguity while still catching true already-applied
299
+ // runs. Reads only — neither call touches the worktree, unlike
300
+ // `--3way --check`, which exits 0 even when the real apply would
301
+ // leave conflict markers and unmerged index entries.
302
+ const [alreadyApplied, forwardApplies] = await Promise.all([
303
+ git.patch.canApplyText(repoRoot, normalized, { reverse: true }),
304
+ git.patch.canApplyText(repoRoot, normalized),
305
+ ]);
295
306
  hadAnyChanges = false;
296
- if (changesApplied) {
307
+ if (alreadyApplied && !forwardApplies) {
308
+ changesApplied = true;
309
+ } else if (forwardApplies) {
310
+ changesApplied = true;
297
311
  try {
298
312
  await git.patch.applyText(repoRoot, normalized);
299
313
  hadAnyChanges = true;
300
314
  } catch {
301
315
  changesApplied = false;
302
316
  }
317
+ } else {
318
+ changesApplied = false;
303
319
  }
304
320
  }
305
321
  }
@@ -90,13 +90,18 @@ export async function mapWithConcurrencyLimit<T, R>(
90
90
  * immediately — matching `task.maxConcurrency = 0`'s "Unlimited" semantics in the
91
91
  * settings UI ([#3305](https://github.com/can1357/oh-my-pi/issues/3305)).
92
92
  */
93
+ export function normalizeConcurrencyLimit(max: number): number {
94
+ const normalizedMax = Number.isFinite(max) ? Math.trunc(max) : 0;
95
+ return normalizedMax > 0 ? normalizedMax : 0;
96
+ }
97
+
93
98
  export class Semaphore {
94
99
  #max: number;
95
100
  #current = 0;
96
101
  #queue: Array<() => void> = [];
97
102
 
98
103
  constructor(max: number) {
99
- const normalizedMax = Number.isFinite(max) ? Math.trunc(max) : 0;
104
+ const normalizedMax = normalizeConcurrencyLimit(max);
100
105
  this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
101
106
  }
102
107
 
@@ -155,7 +160,7 @@ export class Semaphore {
155
160
  * never push concurrency past the cap (issue #3464 review feedback).
156
161
  */
157
162
  resize(max: number): void {
158
- const normalizedMax = Number.isFinite(max) ? Math.trunc(max) : 0;
163
+ const normalizedMax = normalizeConcurrencyLimit(max);
159
164
  this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
160
165
  while (this.#current < this.#max) {
161
166
  const next = this.#queue.shift();
@@ -0,0 +1,121 @@
1
+ import { afterEach, beforeAll, describe, expect, it } from "bun:test";
2
+ import { Settings } from "../config/settings";
3
+ import { getThemeByName, setThemeInstance, type Theme } from "../modes/theme/theme";
4
+ import { renderResult } from "./render";
5
+ import type { AgentProgress, TaskToolDetails } from "./types";
6
+
7
+ const strip = (lines: readonly string[]): string =>
8
+ lines
9
+ .join("\n")
10
+ .replace(/\x1b\]8;[^\x1b\x07]*(?:\x07|\x1b\\)/g, "")
11
+ .replace(/\x1b\[[0-9;]*m/g, "");
12
+
13
+ const originalRowsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "rows");
14
+
15
+ function setViewportRows(rows: number): void {
16
+ Object.defineProperty(process.stdout, "rows", { configurable: true, value: rows });
17
+ }
18
+
19
+ function restoreViewportRows(): void {
20
+ if (originalRowsDescriptor) {
21
+ Object.defineProperty(process.stdout, "rows", originalRowsDescriptor);
22
+ return;
23
+ }
24
+ Reflect.deleteProperty(process.stdout, "rows");
25
+ }
26
+
27
+ function makeProgress(recentOutput: string[]): AgentProgress {
28
+ return {
29
+ index: 0,
30
+ id: "NoisySubagent",
31
+ agent: "task",
32
+ agentSource: "bundled",
33
+ status: "running",
34
+ task: "produce noisy output",
35
+ recentTools: [],
36
+ recentOutput,
37
+ toolCount: 1,
38
+ requests: 1,
39
+ tokens: 0,
40
+ cost: 0,
41
+ durationMs: 0,
42
+ };
43
+ }
44
+
45
+ function renderProgressText(progress: AgentProgress, expanded: boolean, uiTheme: Theme): string {
46
+ const details: TaskToolDetails = {
47
+ projectAgentsDir: null,
48
+ results: [],
49
+ totalDurationMs: 1,
50
+ progress: [progress],
51
+ };
52
+ const component = renderResult(
53
+ { content: [{ type: "text", text: "Running 1 agent..." }], details },
54
+ { expanded, isPartial: true },
55
+ uiTheme,
56
+ );
57
+ return strip(component.render(120));
58
+ }
59
+
60
+ describe("task live progress rendering", () => {
61
+ let uiTheme: Theme;
62
+
63
+ beforeAll(async () => {
64
+ await Settings.init({ inMemory: true });
65
+ const loaded = await getThemeByName("dark");
66
+ if (!loaded) throw new Error("theme unavailable");
67
+ uiTheme = loaded;
68
+ setThemeInstance(uiTheme);
69
+ });
70
+
71
+ afterEach(() => {
72
+ restoreViewportRows();
73
+ });
74
+
75
+ it("caps subagent recent output with the viewport budget instead of a fixed six lines", () => {
76
+ setViewportRows(40);
77
+ const chronological = Array.from({ length: 8 }, (_, index) => `line ${index + 1}`);
78
+ const text = renderProgressText(makeProgress([...chronological].reverse()), true, uiTheme);
79
+
80
+ expect(text).toContain("line 1");
81
+ expect(text).toContain("line 8");
82
+ expect(text).not.toContain("more lines");
83
+ });
84
+
85
+ it("keeps the newest subagent output when the viewport cap truncates", () => {
86
+ setViewportRows(24);
87
+ const chronological = Array.from({ length: 8 }, (_, index) => `line ${index + 1}`);
88
+ const text = renderProgressText(makeProgress([...chronological].reverse()), true, uiTheme);
89
+
90
+ expect(text).toContain("… 3 earlier lines");
91
+ expect(text).not.toContain("line 1");
92
+ expect(text).not.toContain("line 3");
93
+ expect(text).toContain("line 4");
94
+ expect(text).toContain("line 8");
95
+ });
96
+
97
+ it("strips bash footer notices from expanded subagent recent output", () => {
98
+ setViewportRows(40);
99
+ const chronological = [
100
+ "line 1",
101
+ "line 2",
102
+ "line 3",
103
+ "line 4",
104
+ "line 5",
105
+ "line 6",
106
+ "Wall time: 0.03 seconds",
107
+ "[raw output: artifact://123]",
108
+ ];
109
+ const progress = makeProgress([...chronological].reverse());
110
+
111
+ const expandedText = renderProgressText(progress, true, uiTheme);
112
+ expect(expandedText).toContain("line 1");
113
+ expect(expandedText).toContain("line 6");
114
+ expect(expandedText).not.toContain("Wall time:");
115
+ expect(expandedText).not.toContain("raw output:");
116
+
117
+ const collapsedText = renderProgressText(progress, false, uiTheme);
118
+ expect(collapsedText).not.toContain("line 1");
119
+ expect(collapsedText).not.toContain("raw output:");
120
+ });
121
+ });
@@ -12,13 +12,16 @@ import { settings } from "../config/settings";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
14
14
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
15
+ import { stripGeneratedOutputNotice, stripRawOutputArtifactNotice } from "../tools/output-meta";
15
16
  import {
17
+ capPreviewLines,
16
18
  formatBadge,
17
19
  formatDuration,
18
20
  formatExpandHint,
19
21
  formatMoreItems,
20
22
  formatStatusIcon,
21
23
  previewLine,
24
+ previewWindowRows,
22
25
  replaceTabs,
23
26
  type ToolUIStatus,
24
27
  truncateToWidth,
@@ -450,6 +453,41 @@ function renderJsonTreeLines(
450
453
  return { lines, truncated };
451
454
  }
452
455
 
456
+ const BASH_WALL_TIME_NOTICE_RE = /^Wall time: \d+(?:\.\d+)? seconds$/u;
457
+ const BASH_EXIT_CODE_NOTICE_RE = /^Command exited with code -?\d+$/u;
458
+
459
+ function stripRecentOutputNoticeLine(text: string): string {
460
+ const trimmed = text.trimEnd();
461
+ const lineStart = trimmed.lastIndexOf("\n");
462
+ const candidateStart = lineStart === -1 ? 0 : lineStart + 1;
463
+ const line = trimmed.slice(candidateStart);
464
+ if (!BASH_WALL_TIME_NOTICE_RE.test(line) && !BASH_EXIT_CODE_NOTICE_RE.test(line)) return text;
465
+ return trimmed.slice(0, lineStart === -1 ? 0 : lineStart).trimEnd();
466
+ }
467
+
468
+ function sanitizeRecentOutput(output: string): string {
469
+ let text = output.trimEnd();
470
+ while (text) {
471
+ const withoutArtifactNotice = stripRawOutputArtifactNotice(text).text;
472
+ if (withoutArtifactNotice !== text) {
473
+ text = withoutArtifactNotice;
474
+ continue;
475
+ }
476
+ const withoutOutputNotice = stripGeneratedOutputNotice(text);
477
+ if (withoutOutputNotice !== text) {
478
+ text = withoutOutputNotice;
479
+ continue;
480
+ }
481
+ const withoutRuntimeNotice = stripRecentOutputNoticeLine(text);
482
+ if (withoutRuntimeNotice !== text) {
483
+ text = withoutRuntimeNotice;
484
+ continue;
485
+ }
486
+ break;
487
+ }
488
+ return text;
489
+ }
490
+
453
491
  function renderOutputSection(
454
492
  output: string,
455
493
  continuePrefix: string,
@@ -1016,8 +1054,16 @@ function renderAgentProgress(
1016
1054
 
1017
1055
  // Expanded view: recent output and tools
1018
1056
  if (expanded && progress.status === "running") {
1019
- const output = progress.recentOutput.join("\n");
1020
- lines.push(...renderOutputSection(output, continuePrefix, true, theme, 2, 6));
1057
+ const previewRows = previewWindowRows();
1058
+ const output = capPreviewLines(
1059
+ sanitizeRecentOutput([...progress.recentOutput].reverse().join("\n")).split("\n"),
1060
+ theme,
1061
+ {
1062
+ max: previewRows,
1063
+ expandHint: false,
1064
+ },
1065
+ ).join("\n");
1066
+ lines.push(...renderOutputSection(output, continuePrefix, expanded, theme, 2, previewRows));
1021
1067
  }
1022
1068
 
1023
1069
  return lines;
@@ -305,16 +305,13 @@ export async function applyNestedPatches(
305
305
  }
306
306
  } finally {
307
307
  if (stashed) {
308
- try {
309
- await git.stash.pop(nestedDir, { index: true });
310
- } catch (popErr) {
311
- const message = popErr instanceof Error ? popErr.message : String(popErr);
308
+ const restored = await git.stash.tryPop(nestedDir, { index: true });
309
+ if (!restored) {
312
310
  logger.warn("Pre-existing nested-repo dirty state could not be auto-restored", {
313
311
  nestedDir,
314
- error: message,
315
312
  });
316
313
  warnings.push(
317
- `Pre-existing dirty state in nested repo \`${relativePath}\` could not be auto-restored after the agent commit; stash entry preserved (${message}).`,
314
+ `Pre-existing dirty state in nested repo \`${relativePath}\` could not be auto-restored after the agent commit; stash entry preserved.`,
318
315
  );
319
316
  }
320
317
  }
@@ -755,13 +752,15 @@ export async function mergeTaskBranches(
755
752
  }
756
753
  } finally {
757
754
  if (didStash) {
758
- try {
759
- await git.stash.pop(repoRoot, { index: true });
760
- } catch {
761
- // Stash-pop conflicts mean the replayed changes clash with the user's
762
- // uncommitted edits. The cherry-picked commits are already on HEAD, so
763
- // the merged branches DID land report them as merged and surface the
764
- // stash conflict separately instead of claiming they are unmerged.
755
+ const restored = await git.stash.tryPop(repoRoot, { index: true });
756
+ if (!restored) {
757
+ // Stash pop would leave stage 1/2/3 unmerged entries in `.git/index`
758
+ // that overlay-isolated subsequent tasks inherit through the lower
759
+ // layer, corrupting every downstream `captureRepoDeltaPatch`. `tryPop`
760
+ // short-circuits the pop when the WIP would conflict with the
761
+ // cherry-picked HEAD (and reset-cleans up if a rarer conflict slips
762
+ // past). The merged branches DID land — surface a stash-restore
763
+ // warning without claiming the merge failed.
765
764
  logger.warn("Failed to restore stashed changes after task merge; stash entry preserved");
766
765
  const stashConflict =
767
766
  "stash pop: cherry-picked changes conflict with uncommitted edits. The merged commits are on HEAD; run `git stash pop` and resolve manually.";
@@ -8,6 +8,8 @@
8
8
  * `session-manager`, the TUI tool renderers). It has no side effects and
9
9
  * depends only on the yield type and the output-schema validator.
10
10
  */
11
+ import { dereferenceJsonSchema } from "@oh-my-pi/pi-ai/utils/schema";
12
+ import { isRecord } from "@oh-my-pi/pi-utils";
11
13
  import { buildOutputValidator } from "../tools/output-schema-validator";
12
14
  import type { YieldItem } from "./types";
13
15
 
@@ -105,11 +107,12 @@ export function arrayValuedLabels(outputSchema: unknown): ReadonlySet<string> {
105
107
  // with `type: "array"`, which raw `normalizeSchema` would not expose.
106
108
  const { jsonSchema } = buildOutputValidator(outputSchema);
107
109
  if (jsonSchema === undefined) return labels;
108
- const properties = jsonSchema.properties;
109
- if (properties === null || typeof properties !== "object") return labels;
110
- const propRecord = properties as Record<string, unknown>;
111
- for (const key in propRecord) {
112
- if (isArrayTypedSchema(propRecord[key])) labels.add(key);
110
+ const dereferenced = dereferenceJsonSchema(jsonSchema);
111
+ const labelSchema = isRecord(dereferenced) ? dereferenced : jsonSchema;
112
+ const properties = labelSchema.properties;
113
+ if (!isRecord(properties)) return labels;
114
+ for (const key in properties) {
115
+ if (isArrayTypedSchema(properties[key])) labels.add(key);
113
116
  }
114
117
  return labels;
115
118
  }
package/src/thinking.ts CHANGED
@@ -124,6 +124,11 @@ export const AUTO_THINKING = "auto" as const;
124
124
  /** A thinking selector as configured by the user — a concrete level or `auto`. */
125
125
  export type ConfiguredThinkingLevel = ThinkingLevel | typeof AUTO_THINKING;
126
126
 
127
+ /** Maps the session-level `auto` sentinel to `undefined`; concrete levels pass through. */
128
+ export function concreteThinkingLevel(level: ConfiguredThinkingLevel | undefined): ThinkingLevel | undefined {
129
+ return level === AUTO_THINKING ? undefined : level;
130
+ }
131
+
127
132
  /** Metadata used to render the `auto` selector value alongside concrete levels. */
128
133
  export interface ConfiguredThinkingLevelMetadata {
129
134
  value: ConfiguredThinkingLevel;
package/src/tools/ask.ts CHANGED
@@ -17,7 +17,17 @@
17
17
 
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import type { ToolExample } from "@oh-my-pi/pi-ai";
20
- import { type Component, Markdown, type MarkdownTheme, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui";
20
+ import {
21
+ type Component,
22
+ Ellipsis,
23
+ Markdown,
24
+ type MarkdownTheme,
25
+ renderInlineMarkdown,
26
+ TERMINAL,
27
+ Text,
28
+ truncateToWidth,
29
+ visibleWidth,
30
+ } from "@oh-my-pi/pi-tui";
21
31
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
22
32
  import { type as arkType } from "arktype";
23
33
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -142,19 +152,146 @@ interface CustomInputContext {
142
152
  markableCount: number;
143
153
  }
144
154
 
155
+ /** Hard caps for the editor title rendered while the user types an `Other`
156
+ * custom answer. {@link HookEditorComponent} renders the title via a single
157
+ * `Text` child stacked above the prompt editor with no `maxVisible` windowing,
158
+ * so the title MUST fit a normal terminal:
159
+ * - {@link MAX_CUSTOM_INPUT_OPTION_ROWS}: at most this many option-row entries
160
+ * survive {@link pickCustomInputOptionWindow}, regardless of total options.
161
+ * - {@link MAX_CUSTOM_INPUT_TITLE_ROWS}: hard cap on rendered title rows after
162
+ * every line is pre-truncated to one row at the live terminal width. Sized
163
+ * so a 24-row terminal still has space for the input row, hint, and chrome.
164
+ */
165
+ const MAX_CUSTOM_INPUT_OPTION_ROWS = 8;
166
+ const MAX_CUSTOM_INPUT_TITLE_ROWS = 16;
167
+ const MIN_CUSTOM_INPUT_CONTENT_WIDTH = 20;
168
+ /** Subtracted from the terminal width to leave room for the surrounding
169
+ * `Text(... padX=1)` padding + DynamicBorder vertical chrome. */
170
+ const CUSTOM_INPUT_CHROME_COLUMNS = 4;
171
+ const CUSTOM_INPUT_DESCRIPTION_INDENT = " ";
172
+
173
+ function customInputContentWidth(): number {
174
+ const cols = process.stdout.columns ?? 80;
175
+ return Math.max(MIN_CUSTOM_INPUT_CONTENT_WIDTH, cols - CUSTOM_INPUT_CHROME_COLUMNS);
176
+ }
177
+
178
+ function clampLineToWidth(line: string, width: number): string {
179
+ if (visibleWidth(line) <= width) return line;
180
+ return truncateToWidth(line, width, Ellipsis.Unicode);
181
+ }
182
+
183
+ function flattenDescription(text: string): string {
184
+ return text.replace(/\s+/g, " ").trim();
185
+ }
186
+
145
187
  function getSelectOptionDescription(option: ExtensionUISelectItem): string | undefined {
146
188
  return typeof option === "string" ? undefined : option.description;
147
189
  }
148
190
 
149
- function formatCustomInputTitle(
191
+ interface CustomInputOptionGap {
192
+ total: number;
193
+ checked: number;
194
+ }
195
+
196
+ interface CustomInputOptionWindow {
197
+ indices: number[];
198
+ gapBefore: Map<number, CustomInputOptionGap>;
199
+ }
200
+
201
+ /** Window the option list so the title stays bounded. Required rows are the
202
+ * selected `Other` row and the first option as an anchor; checked rows fill
203
+ * the remaining budget before unselected leading rows. Hidden checked options
204
+ * are summarized in gap markers so the rendered option-row count still never
205
+ * exceeds {@link MAX_CUSTOM_INPUT_OPTION_ROWS}. */
206
+ function pickCustomInputOptionWindow(
207
+ total: number,
208
+ selectedIndex: number,
209
+ checked: ReadonlySet<number>,
210
+ ): CustomInputOptionWindow {
211
+ if (total === 0) return { indices: [], gapBefore: new Map() };
212
+ if (total <= MAX_CUSTOM_INPUT_OPTION_ROWS) {
213
+ return {
214
+ indices: Array.from({ length: total }, (_, i) => i),
215
+ gapBefore: new Map(),
216
+ };
217
+ }
218
+ const keep = new Set<number>();
219
+ const addIfRoom = (index: number) => {
220
+ if (index >= 0 && index < total && keep.size < MAX_CUSTOM_INPUT_OPTION_ROWS) {
221
+ keep.add(index);
222
+ }
223
+ };
224
+ addIfRoom(selectedIndex);
225
+ addIfRoom(0);
226
+ for (const i of [...checked].sort((a, b) => a - b)) {
227
+ addIfRoom(i);
228
+ }
229
+ for (let i = 0; i < total && keep.size < MAX_CUSTOM_INPUT_OPTION_ROWS; i++) {
230
+ addIfRoom(i);
231
+ }
232
+ const indices = [...keep].sort((a, b) => a - b);
233
+ const gapBefore = new Map<number, CustomInputOptionGap>();
234
+ const countCheckedBetween = (startInclusive: number, endExclusive: number): number => {
235
+ let count = 0;
236
+ for (const i of checked) {
237
+ if (i >= startInclusive && i < endExclusive) count++;
238
+ }
239
+ return count;
240
+ };
241
+ let prev = -1;
242
+ for (const idx of indices) {
243
+ if (idx > prev + 1) {
244
+ gapBefore.set(idx, {
245
+ total: idx - prev - 1,
246
+ checked: countCheckedBetween(prev + 1, idx),
247
+ });
248
+ }
249
+ prev = idx;
250
+ }
251
+ if (prev < total - 1) {
252
+ gapBefore.set(total, {
253
+ total: total - 1 - prev,
254
+ checked: countCheckedBetween(prev + 1, total),
255
+ });
256
+ }
257
+ return { indices, gapBefore };
258
+ }
259
+
260
+ interface CustomInputRow {
261
+ text: string;
262
+ /** Lower priority drops first when over budget; negative values are pinned.
263
+ * Gap markers are budgeted rows too so sparse checked selections cannot
264
+ * push the editor input off-screen. */
265
+ priority: number;
266
+ }
267
+
268
+ function buildCustomInputRows(
150
269
  question: string,
151
270
  options: ExtensionUISelectItem[],
152
271
  context: CustomInputContext,
153
- ): string {
272
+ contentWidth: number,
273
+ ): CustomInputRow[] {
154
274
  const selectedIndex = options.findIndex(option => getSelectOptionLabel(option) === OTHER_OPTION);
155
- const lines = [question, ""];
156
275
  const checked = new Set(context.checkedIndices ?? []);
157
- for (let index = 0; index < options.length; index++) {
276
+ const window = pickCustomInputOptionWindow(options.length, selectedIndex, checked);
277
+ const rows: CustomInputRow[] = [];
278
+ rows.push({ text: clampLineToWidth(question, contentWidth), priority: -1 });
279
+ rows.push({ text: "", priority: -1 });
280
+
281
+ const emitGap = (gap: CustomInputOptionGap) => {
282
+ const checkedSuffix = gap.checked > 0 ? `, ${gap.checked} checked` : "";
283
+ rows.push({
284
+ text: clampLineToWidth(
285
+ ` … ${gap.total} more option${gap.total === 1 ? "" : "s"}${checkedSuffix} …`,
286
+ contentWidth,
287
+ ),
288
+ priority: 2,
289
+ });
290
+ };
291
+
292
+ for (const index of window.indices) {
293
+ const gap = window.gapBefore.get(index);
294
+ if (gap !== undefined) emitGap(gap);
158
295
  const option = options[index]!;
159
296
  const label = getSelectOptionLabel(option);
160
297
  const isSelected = index === selectedIndex;
@@ -167,12 +304,54 @@ function formatCustomInputTitle(
167
304
  : isSelected
168
305
  ? `${theme.nav.cursor} `
169
306
  : " ";
170
- lines.push(prefix + label);
307
+ rows.push({ text: clampLineToWidth(prefix + label, contentWidth), priority: -1 });
171
308
  const description = getSelectOptionDescription(option);
172
- if (description) lines.push(` ${description}`);
309
+ if (description) {
310
+ const flat = flattenDescription(description);
311
+ if (flat) {
312
+ rows.push({
313
+ text: clampLineToWidth(`${CUSTOM_INPUT_DESCRIPTION_INDENT}${flat}`, contentWidth),
314
+ // Selected (Other) carries no description; favor checked rows
315
+ // when budget pressure forces description rows to be dropped.
316
+ priority: isSelected ? 2 : checked.has(index) ? 1 : 0,
317
+ });
318
+ }
319
+ }
173
320
  }
174
- lines.push("", "Enter your response:");
175
- return lines.join("\n");
321
+
322
+ const trailingGap = window.gapBefore.get(options.length);
323
+ if (trailingGap !== undefined) emitGap(trailingGap);
324
+ rows.push({ text: "", priority: -1 });
325
+ rows.push({ text: "Enter your response:", priority: -1 });
326
+ return rows;
327
+ }
328
+
329
+ function applyCustomInputRowBudget(rows: CustomInputRow[], budget: number): CustomInputRow[] {
330
+ if (rows.length <= budget) return rows;
331
+ // Drop droppable rows lowest priority first; on ties, drop later rows first
332
+ // so the user still sees the earliest options' descriptions.
333
+ const droppable = rows
334
+ .map((row, index) => ({ row, index }))
335
+ .filter(entry => entry.row.priority >= 0)
336
+ .sort((a, b) => a.row.priority - b.row.priority || b.index - a.index);
337
+ const removed = new Set<number>();
338
+ for (const { index } of droppable) {
339
+ if (rows.length - removed.size <= budget) break;
340
+ removed.add(index);
341
+ }
342
+ return rows.filter((_, i) => !removed.has(i));
343
+ }
344
+
345
+ function formatCustomInputTitle(
346
+ question: string,
347
+ options: ExtensionUISelectItem[],
348
+ context: CustomInputContext,
349
+ ): string {
350
+ const contentWidth = customInputContentWidth();
351
+ const rows = buildCustomInputRows(question, options, context, contentWidth);
352
+ return applyCustomInputRowBudget(rows, MAX_CUSTOM_INPUT_TITLE_ROWS)
353
+ .map(row => row.text)
354
+ .join("\n");
176
355
  }
177
356
 
178
357
  // =============================================================================