@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.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 (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  14. package/src/commit/analysis/conventional.ts +8 -66
  15. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  16. package/src/commit/pipeline.ts +2 -2
  17. package/src/commit/shared-llm.ts +89 -0
  18. package/src/config/config-file.ts +210 -0
  19. package/src/config/model-equivalence.ts +8 -11
  20. package/src/config/model-registry.ts +13 -2
  21. package/src/config/model-resolver.ts +1 -4
  22. package/src/config/settings-schema.ts +71 -1
  23. package/src/config/settings.ts +1 -1
  24. package/src/config.ts +3 -219
  25. package/src/edit/renderer.ts +7 -1
  26. package/src/eval/js/executor.ts +3 -0
  27. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  28. package/src/eval/py/executor.ts +5 -0
  29. package/src/exa/factory.ts +2 -2
  30. package/src/exa/mcp-client.ts +74 -1
  31. package/src/exec/bash-executor.ts +5 -1
  32. package/src/export/html/template.generated.ts +1 -1
  33. package/src/export/html/template.js +0 -11
  34. package/src/extensibility/extensions/runner.ts +1 -1
  35. package/src/extensibility/extensions/types.ts +89 -223
  36. package/src/extensibility/hooks/types.ts +89 -314
  37. package/src/extensibility/shared-events.ts +343 -0
  38. package/src/extensibility/skills.ts +9 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/runtime.ts +500 -0
  41. package/src/goals/state.ts +37 -0
  42. package/src/goals/tools/goal-tool.ts +237 -0
  43. package/src/hashline/anchors.ts +2 -2
  44. package/src/hindsight/mental-models.ts +1 -1
  45. package/src/internal-urls/agent-protocol.ts +1 -20
  46. package/src/internal-urls/artifact-protocol.ts +1 -19
  47. package/src/internal-urls/docs-index.generated.ts +5 -6
  48. package/src/internal-urls/registry-helpers.ts +25 -0
  49. package/src/main.ts +11 -2
  50. package/src/mcp/oauth-flow.ts +20 -0
  51. package/src/modes/acp/acp-agent.ts +79 -45
  52. package/src/modes/components/assistant-message.ts +14 -8
  53. package/src/modes/components/bash-execution.ts +24 -63
  54. package/src/modes/components/custom-message.ts +14 -40
  55. package/src/modes/components/eval-execution.ts +27 -57
  56. package/src/modes/components/execution-shared.ts +102 -0
  57. package/src/modes/components/hook-message.ts +17 -49
  58. package/src/modes/components/mcp-add-wizard.ts +26 -5
  59. package/src/modes/components/message-frame.ts +88 -0
  60. package/src/modes/components/model-selector.ts +1 -1
  61. package/src/modes/components/session-observer-overlay.ts +6 -2
  62. package/src/modes/components/session-selector.ts +1 -1
  63. package/src/modes/components/status-line/segments.ts +55 -4
  64. package/src/modes/components/status-line/types.ts +4 -0
  65. package/src/modes/components/status-line.ts +28 -10
  66. package/src/modes/components/tool-execution.ts +7 -8
  67. package/src/modes/controllers/command-controller-shared.ts +108 -0
  68. package/src/modes/controllers/command-controller.ts +13 -4
  69. package/src/modes/controllers/event-controller.ts +36 -7
  70. package/src/modes/controllers/input-controller.ts +13 -0
  71. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  72. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  73. package/src/modes/interactive-mode.ts +624 -52
  74. package/src/modes/print-mode.ts +16 -86
  75. package/src/modes/rpc/rpc-mode.ts +14 -87
  76. package/src/modes/runtime-init.ts +115 -0
  77. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  78. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  79. package/src/modes/theme/theme.ts +18 -6
  80. package/src/modes/types.ts +14 -3
  81. package/src/modes/utils/context-usage.ts +13 -13
  82. package/src/modes/utils/ui-helpers.ts +10 -3
  83. package/src/plan-mode/approved-plan.ts +35 -1
  84. package/src/prompts/goals/goal-budget-limit.md +16 -0
  85. package/src/prompts/goals/goal-continuation.md +28 -0
  86. package/src/prompts/goals/goal-mode-active.md +23 -0
  87. package/src/prompts/system/plan-mode-active.md +5 -5
  88. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  89. package/src/prompts/tools/bash.md +6 -0
  90. package/src/prompts/tools/goal.md +13 -0
  91. package/src/prompts/tools/hashline.md +102 -114
  92. package/src/prompts/tools/read.md +1 -0
  93. package/src/prompts/tools/resolve.md +6 -5
  94. package/src/sdk.ts +12 -5
  95. package/src/session/agent-session.ts +428 -106
  96. package/src/session/blob-store.ts +36 -3
  97. package/src/session/messages.ts +67 -2
  98. package/src/session/session-manager.ts +131 -12
  99. package/src/session/session-storage.ts +33 -15
  100. package/src/session/streaming-output.ts +309 -13
  101. package/src/slash-commands/builtin-registry.ts +18 -0
  102. package/src/ssh/ssh-executor.ts +5 -0
  103. package/src/system-prompt.ts +4 -2
  104. package/src/task/executor.ts +17 -7
  105. package/src/task/index.ts +3 -0
  106. package/src/task/render.ts +21 -15
  107. package/src/task/types.ts +4 -0
  108. package/src/tools/ast-edit.ts +21 -120
  109. package/src/tools/ast-grep.ts +21 -119
  110. package/src/tools/bash-interactive.ts +9 -1
  111. package/src/tools/bash.ts +27 -4
  112. package/src/tools/browser/attach.ts +3 -3
  113. package/src/tools/browser/launch.ts +81 -18
  114. package/src/tools/browser/registry.ts +1 -5
  115. package/src/tools/browser/tab-supervisor.ts +51 -14
  116. package/src/tools/conflict-detect.ts +15 -4
  117. package/src/tools/eval.ts +3 -1
  118. package/src/tools/find.ts +20 -38
  119. package/src/tools/gh.ts +7 -6
  120. package/src/tools/index.ts +22 -11
  121. package/src/tools/inspect-image.ts +3 -10
  122. package/src/tools/output-meta.ts +176 -37
  123. package/src/tools/path-utils.ts +125 -2
  124. package/src/tools/read.ts +516 -233
  125. package/src/tools/render-utils.ts +92 -0
  126. package/src/tools/renderers.ts +2 -0
  127. package/src/tools/resolve.ts +72 -44
  128. package/src/tools/search.ts +120 -186
  129. package/src/tools/write.ts +44 -9
  130. package/src/utils/file-mentions.ts +1 -1
  131. package/src/utils/image-loading.ts +7 -3
  132. package/src/utils/image-resize.ts +32 -43
  133. package/src/vim/parser.ts +0 -17
  134. package/src/vim/render.ts +1 -1
  135. package/src/vim/types.ts +1 -1
  136. package/src/web/search/providers/gemini.ts +35 -95
  137. package/src/prompts/tools/exit-plan-mode.md +0 -6
  138. package/src/tools/exit-plan-mode.ts +0 -97
  139. package/src/utils/fuzzy.ts +0 -108
  140. package/src/utils/image-convert.ts +0 -27
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { $which, getPuppeteerDir, logger } from "@oh-my-pi/pi-utils";
5
5
  import * as browsers from "@puppeteer/browsers";
6
- import type { Browser, CDPSession, Page, default as Puppeteer } from "puppeteer-core";
6
+ import type { Browser, CDPSession, Page, default as Puppeteer, Target } from "puppeteer-core";
7
7
  import { PUPPETEER_REVISIONS } from "puppeteer-core/internal/revisions.js";
8
8
  import stealthTamperingScript from "../puppeteer/00_stealth_tampering.txt" with { type: "text" };
9
9
  import stealthActivityScript from "../puppeteer/01_stealth_activity.txt" with { type: "text" };
@@ -30,13 +30,15 @@ export const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1
30
30
  * connection dropped, etc.).
31
31
  */
32
32
  export const BROWSER_PROTOCOL_TIMEOUT_MS = 60_000;
33
- export const STEALTH_IGNORE_DEFAULT_ARGS = [
33
+ const STEALTH_IGNORE_DEFAULT_ARGS = [
34
34
  "--disable-extensions",
35
35
  "--disable-default-apps",
36
36
  "--disable-component-extensions-with-background-pages",
37
37
  ];
38
- export const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
38
+ const STEALTH_ACCEPT_LANGUAGE = "en-US,en";
39
39
 
40
+ const USER_AGENT_TARGET_TIMEOUT_MS = 5_000;
41
+ const USER_AGENT_TARGET_TYPES = new Set(["page", "webview", "background_page"]);
40
42
  const PUPPETEER_SOURCE_URL_SUFFIX = "//# sourceURL=__puppeteer_evaluation_script__";
41
43
 
42
44
  /**
@@ -82,7 +84,7 @@ export async function loadPuppeteerInWorker(safeDir: string): Promise<typeof Pup
82
84
  * The browser is cached under ~/.omp/puppeteer (getPuppeteerDir).
83
85
  */
84
86
  let chromiumExecutablePromise: Promise<string | undefined> | undefined;
85
- export async function ensureChromiumExecutable(): Promise<string | undefined> {
87
+ async function ensureChromiumExecutable(): Promise<string | undefined> {
86
88
  const sysChrome = resolveSystemChromium();
87
89
  if (sysChrome) return sysChrome;
88
90
  const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
@@ -138,7 +140,7 @@ export async function ensureChromiumExecutable(): Promise<string | undefined> {
138
140
  return chromiumExecutablePromise;
139
141
  }
140
142
 
141
- let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
143
+ let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
142
144
 
143
145
  function isExecutableFile(p: string): boolean {
144
146
  try {
@@ -209,19 +211,19 @@ function systemChromiumCandidates(): string[] {
209
211
  return candidates;
210
212
  }
211
213
 
212
- export function resolveSystemChromium(): string | undefined {
213
- if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
214
+ function resolveSystemChromium(): string | undefined {
215
+ if (resolvedChromium !== undefined) return resolvedChromium ?? undefined;
214
216
  const seen = new Set<string>();
215
217
  for (const candidate of systemChromiumCandidates()) {
216
218
  if (!candidate || seen.has(candidate)) continue;
217
219
  seen.add(candidate);
218
220
  if (isExecutableFile(candidate)) {
219
- _resolvedChromium = candidate;
221
+ resolvedChromium = candidate;
220
222
  logger.debug("Using system Chrome/Chromium", { path: candidate });
221
223
  return candidate;
222
224
  }
223
225
  }
224
- _resolvedChromium = null;
226
+ resolvedChromium = null;
225
227
  return undefined;
226
228
  }
227
229
 
@@ -463,6 +465,7 @@ export interface UserAgentSession {
463
465
  async function configureUserAgentTargets(
464
466
  browser: Browser,
465
467
  state: { browserSession: CDPSession | null; override: UserAgentOverride },
468
+ targetTimeoutMs = USER_AGENT_TARGET_TIMEOUT_MS,
466
469
  ): Promise<void> {
467
470
  if (!state.browserSession) {
468
471
  state.browserSession = await browser.target().createCDPSession();
@@ -471,23 +474,72 @@ async function configureUserAgentTargets(
471
474
  waitForDebuggerOnStart: false,
472
475
  flatten: true,
473
476
  });
474
- state.browserSession.on("Target.attachedToTarget", async (event: { sessionId: string }) => {
475
- const connection = state.browserSession?.connection();
476
- const session = connection?.session(event.sessionId);
477
- if (!session) return;
478
- await sendUserAgentOverride(wrapSession(session), state.override);
479
- });
477
+ state.browserSession.on(
478
+ "Target.attachedToTarget",
479
+ async (event: { sessionId: string; targetInfo?: { type?: string } }) => {
480
+ if (!targetInfoSupportsUserAgentOverride(event.targetInfo)) return;
481
+ const connection = state.browserSession?.connection();
482
+ const session = connection?.session(event.sessionId);
483
+ if (!session) return;
484
+ await withSoftTimeout(
485
+ sendUserAgentOverride(wrapSession(session), state.override),
486
+ targetTimeoutMs,
487
+ "new target user-agent override",
488
+ );
489
+ },
490
+ );
480
491
  }
481
492
 
482
- const targets = browser.targets();
493
+ const targets = browser.targets().filter(targetSupportsUserAgentOverride);
483
494
  await Promise.all(
484
495
  targets.map(async target => {
485
- const session = await target.createCDPSession();
486
- await sendUserAgentOverride(wrapSession(session), state.override);
496
+ await withSoftTimeout(
497
+ applyTargetUserAgentOverride(target, state.override),
498
+ targetTimeoutMs,
499
+ "target user-agent override",
500
+ );
487
501
  }),
488
502
  );
489
503
  }
490
504
 
505
+ function targetSupportsUserAgentOverride(target: Target): boolean {
506
+ return targetInfoSupportsUserAgentOverride({ type: target.type() });
507
+ }
508
+
509
+ function targetInfoSupportsUserAgentOverride(targetInfo: { type?: string } | undefined): boolean {
510
+ return Boolean(targetInfo?.type && USER_AGENT_TARGET_TYPES.has(targetInfo.type));
511
+ }
512
+
513
+ async function applyTargetUserAgentOverride(target: Target, override: UserAgentOverride): Promise<void> {
514
+ const session = await target.createCDPSession();
515
+ try {
516
+ await sendUserAgentOverride(wrapSession(session), override);
517
+ } finally {
518
+ await session.detach().catch(() => undefined);
519
+ }
520
+ }
521
+
522
+ async function withSoftTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T | undefined> {
523
+ let timeout: NodeJS.Timeout | undefined;
524
+ const timeoutPromise = new Promise<undefined>(resolve => {
525
+ timeout = setTimeout(() => {
526
+ logger.debug(`Timed out applying ${label}`);
527
+ resolve(undefined);
528
+ }, timeoutMs);
529
+ });
530
+ try {
531
+ return await Promise.race([
532
+ promise.catch(error => {
533
+ logger.debug(`Failed to apply ${label}`, { error: error instanceof Error ? error.message : String(error) });
534
+ return undefined;
535
+ }),
536
+ timeoutPromise,
537
+ ]);
538
+ } finally {
539
+ if (timeout) clearTimeout(timeout);
540
+ }
541
+ }
542
+
491
543
  async function injectStealthScripts(page: Page): Promise<void> {
492
544
  const scripts = [
493
545
  stealthTamperingScript,
@@ -574,3 +626,14 @@ export async function applyStealthPatches(
574
626
  state.browserSession = targetState.browserSession;
575
627
  await injectStealthScripts(page);
576
628
  }
629
+
630
+ export function targetSupportsUserAgentOverrideForTest(target: Target): boolean {
631
+ return targetSupportsUserAgentOverride(target);
632
+ }
633
+ export async function configureUserAgentTargetsForTest(
634
+ browser: Browser,
635
+ state: { browserSession: CDPSession | null; override: UserAgentOverride },
636
+ targetTimeoutMs?: number,
637
+ ): Promise<void> {
638
+ await configureUserAgentTargets(browser, state, targetTimeoutMs);
639
+ }
@@ -26,10 +26,6 @@ export interface BrowserHandle {
26
26
 
27
27
  const browsers = new Map<string, BrowserHandle>();
28
28
 
29
- export function listBrowsers(): BrowserHandle[] {
30
- return [...browsers.values()];
31
- }
32
-
33
29
  function browserKey(kind: BrowserKind): string {
34
30
  switch (kind.kind) {
35
31
  case "headless":
@@ -166,7 +162,7 @@ export async function releaseBrowser(handle: BrowserHandle, opts: { kill: boolea
166
162
  }
167
163
  }
168
164
 
169
- export async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
165
+ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean }): Promise<void> {
170
166
  if (handle.kind.kind === "headless") {
171
167
  if (handle.browser.connected) {
172
168
  try {
@@ -30,6 +30,7 @@ import type {
30
30
  interface WorkerHandle {
31
31
  send(msg: WorkerInbound, transferList?: Transferable[]): void;
32
32
  onMessage(handler: (msg: WorkerOutbound) => void): () => void;
33
+ onError(handler: (error: Error) => void): () => void;
33
34
  terminate(): Promise<void>;
34
35
  readonly mode: "worker" | "inline";
35
36
  }
@@ -89,10 +90,6 @@ export function getTab(name: string): TabSession | undefined {
89
90
  return tabs.get(name);
90
91
  }
91
92
 
92
- export function listTabs(): TabSession[] {
93
- return [...tabs.values()];
94
- }
95
-
96
93
  export async function acquireTab(
97
94
  name: string,
98
95
  browser: BrowserHandle,
@@ -124,23 +121,14 @@ export async function acquireTab(
124
121
 
125
122
  const initPayload = await buildInitPayload(browser, opts);
126
123
  const worker = await spawnTabWorker();
127
- const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
128
- const unlisten = worker.onMessage(msg => {
129
- if (msg.type === "ready") resolve(msg.info);
130
- else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
131
- else if (msg.type === "log") logWorkerMessage(msg);
132
- });
133
124
  let info: ReadyInfo;
134
125
  try {
135
- worker.send({ type: "init", payload: initPayload });
136
- info = await raceWithTimeout(promise, opts.timeoutMs + GRACE_MS, "Timed out initializing browser tab worker");
126
+ info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
137
127
  } catch (error) {
138
- unlisten();
139
128
  await worker.terminate().catch(() => undefined);
140
129
  if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
141
130
  throw error;
142
131
  }
143
- unlisten();
144
132
 
145
133
  holdBrowser(browser);
146
134
  const tab: TabSession = {
@@ -477,6 +465,17 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
477
465
  worker.addEventListener("message", wrap);
478
466
  return () => worker.removeEventListener("message", wrap);
479
467
  },
468
+ onError(handler) {
469
+ const onError = (event: ErrorEvent): void => handler(errorFromWorkerEvent(event));
470
+ const onMessageError = (event: MessageEvent): void =>
471
+ handler(new ToolError(`Tab worker message error: ${String(event.data)}`));
472
+ worker.addEventListener("error", onError);
473
+ worker.addEventListener("messageerror", onMessageError);
474
+ return () => {
475
+ worker.removeEventListener("error", onError);
476
+ worker.removeEventListener("messageerror", onMessageError);
477
+ };
478
+ },
480
479
  async terminate() {
481
480
  worker.terminate();
482
481
  },
@@ -515,6 +514,44 @@ async function spawnInlineWorker(): Promise<WorkerHandle> {
515
514
  hostListeners.add(handler);
516
515
  return () => hostListeners.delete(handler);
517
516
  },
517
+ onError: () => () => {},
518
518
  async terminate() {},
519
519
  };
520
520
  }
521
+
522
+ async function initializeTabWorker(
523
+ worker: WorkerHandle,
524
+ payload: WorkerInitPayload,
525
+ timeoutMs: number,
526
+ ): Promise<ReadyInfo> {
527
+ const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
528
+ const unlisten = worker.onMessage(msg => {
529
+ if (msg.type === "ready") resolve(msg.info);
530
+ else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
531
+ else if (msg.type === "log") logWorkerMessage(msg);
532
+ });
533
+ const unlistenError = worker.onError(error => {
534
+ reject(new ToolError(`Tab worker failed during startup: ${error.message}`));
535
+ });
536
+ try {
537
+ worker.send({ type: "init", payload });
538
+ return await raceWithTimeout(promise, timeoutMs, "Timed out initializing browser tab worker");
539
+ } finally {
540
+ unlisten();
541
+ unlistenError();
542
+ }
543
+ }
544
+
545
+ export function initializeTabWorkerForTest(
546
+ worker: WorkerHandle,
547
+ payload: WorkerInitPayload,
548
+ timeoutMs: number,
549
+ ): Promise<ReadyInfo> {
550
+ return initializeTabWorker(worker, payload, timeoutMs);
551
+ }
552
+
553
+ function errorFromWorkerEvent(event: ErrorEvent): Error {
554
+ if (event.error instanceof Error) return event.error;
555
+ if (event.message) return new Error(event.message);
556
+ return new Error("Unknown tab worker error");
557
+ }
@@ -250,9 +250,19 @@ export interface ParsedConflictUri {
250
250
  /** `"*"` selects every currently-registered conflict (bulk write only). */
251
251
  id: number | "*";
252
252
  scope?: ConflictScope;
253
+ /**
254
+ * When `raw` was a malformed `<file-prefix>:conflict://…` path, the
255
+ * stripped prefix is preserved here so callers can surface a gentle
256
+ * "you don't need the file path" note. `undefined` for clean URIs.
257
+ */
258
+ recoveredPrefix?: string;
253
259
  }
254
260
 
255
- const CONFLICT_URI_RE = /^conflict:\/\/(.+)$/;
261
+ // Accept an optional `<prefix>:` before the scheme so paths like
262
+ // `path/to/file.ts:conflict://3` (where the agent mixed the `:conflicts`
263
+ // read selector with the `conflict://` scheme) still resolve. The prefix
264
+ // is greedy so the LAST `:conflict://` wins for multi-colon inputs.
265
+ const CONFLICT_URI_RE = /^(?:(.+):)?conflict:\/\/(.+)$/;
256
266
 
257
267
  /**
258
268
  * Parse a `conflict://<N>`, `conflict://<N>/<scope>`, or `conflict://*` URI.
@@ -269,7 +279,8 @@ const CONFLICT_URI_RE = /^conflict:\/\/(.+)$/;
269
279
  export function parseConflictUri(raw: string): ParsedConflictUri | null {
270
280
  const match = raw.match(CONFLICT_URI_RE);
271
281
  if (!match) return null;
272
- const tail = match[1];
282
+ const recoveredPrefix = match[1];
283
+ const tail = match[2];
273
284
  const slashIdx = tail.indexOf("/");
274
285
  const idPart = slashIdx === -1 ? tail : tail.slice(0, slashIdx);
275
286
  const scopePart = slashIdx === -1 ? undefined : tail.slice(slashIdx + 1);
@@ -280,7 +291,7 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
280
291
  `Invalid conflict URI '${raw}': wildcard 'conflict://*' does not accept a scope segment. Drop '/${scopePart}' or use a numeric id.`,
281
292
  );
282
293
  }
283
- return { id: "*" };
294
+ return recoveredPrefix !== undefined ? { id: "*", recoveredPrefix } : { id: "*" };
284
295
  }
285
296
 
286
297
  if (!/^\d+$/.test(idPart)) {
@@ -303,7 +314,7 @@ export function parseConflictUri(raw: string): ParsedConflictUri | null {
303
314
  scope = scopePart as ConflictScope;
304
315
  }
305
316
 
306
- return { id, scope };
317
+ return recoveredPrefix !== undefined ? { id, scope, recoveredPrefix } : { id, scope };
307
318
  }
308
319
 
309
320
  /**
package/src/tools/eval.ts CHANGED
@@ -16,7 +16,7 @@ import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
16
16
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
17
17
  import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
18
18
  import { resolveEvalBackends, type ToolSession } from ".";
19
- import { formatStyledTruncationWarning } from "./output-meta";
19
+ import { formatStyledTruncationWarning, resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta";
20
20
  import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils";
21
21
  import { ToolAbortError, ToolError } from "./tool-errors";
22
22
  import { toolResult } from "./tool-result";
@@ -358,6 +358,8 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
358
358
  outputSink = new OutputSink({
359
359
  artifactPath,
360
360
  artifactId,
361
+ headBytes: resolveOutputSinkHeadBytes(session.settings),
362
+ maxColumns: resolveOutputMaxColumns(session.settings),
361
363
  onChunk: chunk => {
362
364
  appendTail(chunk);
363
365
  pushUpdate();
package/src/tools/find.ts CHANGED
@@ -12,15 +12,7 @@ import { InternalUrlRouter } from "../internal-urls";
12
12
  import type { Theme } from "../modes/theme/theme";
13
13
  import findDescription from "../prompts/tools/find.md" with { type: "text" };
14
14
  import { type TruncationResult, truncateHead } from "../session/streaming-output";
15
- import {
16
- Ellipsis,
17
- Hasher,
18
- type RenderCache,
19
- renderFileList,
20
- renderStatusLine,
21
- renderTreeList,
22
- truncateToWidth,
23
- } from "../tui";
15
+ import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
24
16
  import type { ToolSession } from ".";
25
17
  import { applyListLimit } from "./list-limit";
26
18
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
@@ -33,7 +25,13 @@ import {
33
25
  resolveExplicitFindPatterns,
34
26
  resolveToCwd,
35
27
  } from "./path-utils";
36
- import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
28
+ import {
29
+ createCachedComponent,
30
+ formatCount,
31
+ formatEmptyMessage,
32
+ formatErrorMessage,
33
+ PREVIEW_LIMITS,
34
+ } from "./render-utils";
37
35
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
38
36
  import { toolResult } from "./tool-result";
39
37
 
@@ -401,30 +399,22 @@ export const findToolRenderer = {
401
399
  },
402
400
  uiTheme,
403
401
  );
404
- let cached: RenderCache | undefined;
405
- return {
406
- render(width: number): string[] {
407
- const { expanded } = options;
408
- const key = new Hasher().bool(expanded).u32(width).digest();
409
- if (cached?.key === key) return cached.lines;
402
+ return createCachedComponent(
403
+ () => options.expanded,
404
+ width => {
410
405
  const listLines = renderTreeList(
411
406
  {
412
407
  items: lines,
413
- expanded,
408
+ expanded: options.expanded,
414
409
  maxCollapsed: COLLAPSED_LIST_LIMIT,
415
410
  itemType: "file",
416
411
  renderItem: line => uiTheme.fg("accent", line),
417
412
  },
418
413
  uiTheme,
419
414
  );
420
- const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
421
- cached = { key, lines: result };
422
- return result;
415
+ return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
423
416
  },
424
- invalidate() {
425
- cached = undefined;
426
- },
427
- };
417
+ );
428
418
  }
429
419
 
430
420
  const fileCount = details?.fileCount ?? 0;
@@ -467,28 +457,20 @@ export const findToolRenderer = {
467
457
  }
468
458
  if (missingNote) extraLines.push(missingNote);
469
459
 
470
- let cached: RenderCache | undefined;
471
- return {
472
- render(width: number): string[] {
473
- const { expanded } = options;
474
- const key = new Hasher().bool(expanded).u32(width).digest();
475
- if (cached?.key === key) return cached.lines;
460
+ return createCachedComponent(
461
+ () => options.expanded,
462
+ width => {
476
463
  const fileLines = renderFileList(
477
464
  {
478
465
  files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
479
- expanded,
466
+ expanded: options.expanded,
480
467
  maxCollapsed: COLLAPSED_LIST_LIMIT,
481
468
  },
482
469
  uiTheme,
483
470
  );
484
- const result = [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
485
- cached = { key, lines: result };
486
- return result;
487
- },
488
- invalidate() {
489
- cached = undefined;
471
+ return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
490
472
  },
491
- };
473
+ );
492
474
  },
493
475
  mergeCallAndResult: true,
494
476
  };
package/src/tools/gh.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { scheduler } from "node:timers/promises";
4
5
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
6
  import { StringEnum } from "@oh-my-pi/pi-ai";
6
- import { abortableSleep, getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
+ import { getWorktreesDir, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
8
  import { type Static, Type } from "@sinclair/typebox";
8
9
  import type { Settings } from "../config/settings";
9
10
  import githubDescription from "../prompts/tools/github.md" with { type: "text" };
@@ -3400,7 +3401,7 @@ async function executeRunWatch(
3400
3401
  note,
3401
3402
  }),
3402
3403
  });
3403
- await abortableSleep(graceSeconds * 1000, signal);
3404
+ await scheduler.wait(graceSeconds * 1000, { signal });
3404
3405
  run = await fetchRunSnapshot(session.cwd, repo, runId, signal);
3405
3406
  }
3406
3407
 
@@ -3435,7 +3436,7 @@ async function executeRunWatch(
3435
3436
  return buildTextResult(formatRunWatchResult(repo, run, [], tail), run.url, finalDetails);
3436
3437
  }
3437
3438
 
3438
- await abortableSleep(intervalSeconds * 1000, signal);
3439
+ await scheduler.wait(intervalSeconds * 1000, { signal });
3439
3440
  }
3440
3441
  }
3441
3442
 
@@ -3477,7 +3478,7 @@ async function executeRunWatch(
3477
3478
  note,
3478
3479
  }),
3479
3480
  });
3480
- await abortableSleep(graceSeconds * 1000, signal);
3481
+ await scheduler.wait(graceSeconds * 1000, { signal });
3481
3482
  runs = await fetchRunsForCommit(session.cwd, repo, headSha, branch, signal);
3482
3483
  }
3483
3484
 
@@ -3533,11 +3534,11 @@ async function executeRunWatch(
3533
3534
  note,
3534
3535
  }),
3535
3536
  });
3536
- await abortableSleep(intervalSeconds * 1000, signal);
3537
+ await scheduler.wait(intervalSeconds * 1000, { signal });
3537
3538
  continue;
3538
3539
  }
3539
3540
 
3540
3541
  settledSuccessSignature = undefined;
3541
- await abortableSleep(intervalSeconds * 1000, signal);
3542
+ await scheduler.wait(intervalSeconds * 1000, { signal });
3542
3543
  }
3543
3544
  }
@@ -6,6 +6,8 @@ import type { Settings } from "../config/settings";
6
6
  import { EditTool } from "../edit";
7
7
  import { checkPythonKernelAvailability } from "../eval/py/kernel";
8
8
  import type { Skill } from "../extensibility/skills";
9
+ import type { GoalModeState, GoalRuntime } from "../goals";
10
+ import { GoalTool } from "../goals/tools/goal-tool";
9
11
  import type { HindsightSessionState } from "../hindsight/state";
10
12
  import { LspTool } from "../lsp";
11
13
  import type { PlanModeState } from "../plan-mode/state";
@@ -29,7 +31,6 @@ import { CalculatorTool } from "./calculator";
29
31
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
30
32
  import { DebugTool } from "./debug";
31
33
  import { EvalTool } from "./eval";
32
- import { ExitPlanModeTool } from "./exit-plan-mode";
33
34
  import { FindTool } from "./find";
34
35
  import { GithubTool } from "./gh";
35
36
  import { HindsightRecallTool } from "./hindsight-recall";
@@ -57,6 +58,7 @@ import { YieldTool } from "./yield";
57
58
  export * from "../edit";
58
59
  export * from "../exa";
59
60
  export type * from "../exa/types";
61
+ export * from "../goals";
60
62
  export * from "../lsp";
61
63
  export * from "../session/streaming-output";
62
64
  export * from "../task";
@@ -70,7 +72,6 @@ export * from "./calculator";
70
72
  export * from "./checkpoint";
71
73
  export * from "./debug";
72
74
  export * from "./eval";
73
- export * from "./exit-plan-mode";
74
75
  export * from "./find";
75
76
  export * from "./gh";
76
77
  export * from "./hindsight-recall";
@@ -179,6 +180,10 @@ export interface ToolSession {
179
180
  settings: Settings;
180
181
  /** Plan mode state (if active) */
181
182
  getPlanModeState?: () => PlanModeState | undefined;
183
+ /** Goal mode state (if active or paused) */
184
+ getGoalModeState?: () => GoalModeState | undefined;
185
+ /** Goal runtime for the active agent session. */
186
+ getGoalRuntime?: () => GoalRuntime | undefined;
182
187
  /** Bridge to the connected client (e.g. ACP editor host). Tools should route fs/terminal/permission requests through this when available. */
183
188
  getClientBridge?: () => ClientBridge | undefined;
184
189
  /** Get compact conversation context for subagents (excludes tool results, system prompts) */
@@ -220,6 +225,12 @@ export interface ToolSession {
220
225
  steer?(message: { customType: string; content: string; details?: unknown }): void;
221
226
  /** Peek the currently in-flight tool-choice queue directive's invocation handler. Used by the `resolve` tool to dispatch to the pending action. */
222
227
  peekQueueInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
228
+ /** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode).
229
+ * Consulted by the `resolve` tool as a fallback when no queue invoker is in flight,
230
+ * letting modes accept `resolve` invocations without forcing the tool choice every turn. */
231
+ peekStandingResolveHandler?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
232
+ /** Register or clear the standing resolve handler. Passing `null` clears it. */
233
+ setStandingResolveHandler?(handler: ((input: unknown) => Promise<unknown> | unknown) | null): void;
223
234
  /** Get active checkpoint state if any. */
224
235
  getCheckpointState?: () => CheckpointState | undefined;
225
236
  /** Set or clear active checkpoint state. */
@@ -303,8 +314,8 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
303
314
  yield: s => new YieldTool(s),
304
315
  report_finding: () => reportFindingTool,
305
316
  report_tool_issue: s => createReportToolIssueTool(s),
306
- exit_plan_mode: s => new ExitPlanModeTool(s),
307
317
  resolve: s => new ResolveTool(s),
318
+ goal: s => new GoalTool(s),
308
319
  };
309
320
 
310
321
  export type ToolName = keyof typeof BUILTIN_TOOLS;
@@ -351,11 +362,12 @@ export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance
351
362
  export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
352
363
  const includeYield = session.requireYieldTool === true;
353
364
  const enableLsp = session.enableLsp ?? true;
354
- const requestedTools =
365
+ let requestedTools =
355
366
  toolNames && toolNames.length > 0 ? [...new Set(toolNames.map(name => name.toLowerCase()))] : undefined;
356
- const planEnabled = session.settings.get("plan.enabled");
357
- if (planEnabled && requestedTools && !requestedTools.includes("exit_plan_mode")) {
358
- requestedTools.push("exit_plan_mode");
367
+ const goalEnabled = session.settings.get("goal.enabled");
368
+ const goalModeActive = goalEnabled && session.getGoalModeState?.()?.enabled === true;
369
+ if (goalModeActive && requestedTools && !requestedTools.includes("goal")) {
370
+ requestedTools = [...requestedTools, "goal"];
359
371
  }
360
372
  const backends = resolveEvalBackends(session);
361
373
  const allowPython = backends.python;
@@ -428,7 +440,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
428
440
 
429
441
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
430
442
  const isToolAllowed = (name: string) => {
431
- if (name === "exit_plan_mode") return planEnabled;
443
+ if (name === "goal") return goalEnabled && goalModeActive;
432
444
  if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
433
445
  if (name === "bash") return true;
434
446
  if (name === "eval") return allowEval;
@@ -478,7 +490,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
478
490
  .filter(([name]) => isToolAllowed(name))
479
491
  .map(([name, factory]) => [name, factory] as const),
480
492
  ...(includeYield ? ([["yield", HIDDEN_TOOLS.yield]] as const) : []),
481
- ...(planEnabled ? ([["exit_plan_mode", HIDDEN_TOOLS.exit_plan_mode]] as const) : []),
493
+ ...(goalModeActive ? ([["goal", HIDDEN_TOOLS.goal]] as const) : []),
482
494
  ];
483
495
 
484
496
  const baseResults = await Promise.all(
@@ -488,8 +500,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
488
500
  }),
489
501
  );
490
502
  const tools = baseResults.filter((r): r is Tool => r !== null);
491
- const hasDeferrableTools = tools.some(tool => tool.deferrable === true);
492
- if (hasDeferrableTools && !tools.some(tool => tool.name === "resolve")) {
503
+ if (!tools.some(tool => tool.name === "resolve")) {
493
504
  const resolveTool = await logger.time("createTools:resolve", HIDDEN_TOOLS.resolve, session);
494
505
  if (resolveTool) {
495
506
  tools.push(wrapToolWithMetaNotice(resolveTool));
@@ -1,7 +1,8 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
- import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai";
2
+ import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
3
3
  import { prompt } from "@oh-my-pi/pi-utils";
4
4
  import { type Static, Type } from "@sinclair/typebox";
5
+ import { extractTextContent } from "../commit/utils";
5
6
  import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
6
7
  import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
7
8
  import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
@@ -30,14 +31,6 @@ export interface InspectImageToolDetails {
30
31
  mimeType: string;
31
32
  }
32
33
 
33
- function extractResponseText(message: AssistantMessage): string {
34
- return message.content
35
- .filter(content => content.type === "text")
36
- .map(content => content.text)
37
- .join("")
38
- .trim();
39
- }
40
-
41
34
  export class InspectImageTool implements AgentTool<typeof inspectImageSchema, InspectImageToolDetails> {
42
35
  readonly name = "inspect_image";
43
36
  readonly label = "InspectImage";
@@ -151,7 +144,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
151
144
  throw new ToolError("inspect_image request aborted.");
152
145
  }
153
146
 
154
- const text = extractResponseText(response);
147
+ const text = extractTextContent(response);
155
148
  if (!text) {
156
149
  throw new ToolError("inspect_image model returned no text output.");
157
150
  }