@oh-my-pi/pi-coding-agent 17.2.8 → 17.2.9

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 (91) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/dist/{CHANGELOG-0b0w17se.md → CHANGELOG-8yy8h3bc.md} +51 -1
  3. package/dist/cli.js +4443 -4444
  4. package/dist/types/async/job-manager.d.ts +12 -0
  5. package/dist/types/discovery/agents.d.ts +11 -0
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
  7. package/dist/types/hindsight/state.d.ts +0 -1
  8. package/dist/types/lsp/utils.d.ts +8 -0
  9. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  10. package/dist/types/registry/agent-lifecycle.d.ts +1 -1
  11. package/dist/types/session/agent-session-types.d.ts +7 -0
  12. package/dist/types/session/agent-session.d.ts +5 -3
  13. package/dist/types/session/turn-recovery.d.ts +6 -2
  14. package/dist/types/task/executor.d.ts +4 -0
  15. package/dist/types/tools/browser/launch.d.ts +2 -0
  16. package/dist/types/tools/path-utils.d.ts +7 -0
  17. package/dist/types/tools/read.d.ts +2 -0
  18. package/dist/types/tools/shell-tokenize.d.ts +21 -3
  19. package/dist/types/utils/changelog.d.ts +3 -2
  20. package/dist/types/utils/late-cleanup.d.ts +2 -0
  21. package/dist/types/vibe/runtime.d.ts +2 -0
  22. package/package.json +13 -13
  23. package/scripts/omp +11 -2
  24. package/src/async/job-manager.ts +26 -0
  25. package/src/cli/stats-cli.ts +3 -3
  26. package/src/cli/update-cli.ts +2 -56
  27. package/src/config/config-file.ts +2 -2
  28. package/src/config/keybindings.ts +3 -3
  29. package/src/config/model-discovery.ts +36 -2
  30. package/src/config/model-registry.ts +4 -1
  31. package/src/discovery/agents.ts +71 -3
  32. package/src/discovery/claude.ts +8 -5
  33. package/src/discovery/cursor.ts +8 -5
  34. package/src/discovery/gemini.ts +11 -8
  35. package/src/discovery/vscode.ts +1 -0
  36. package/src/discovery/windsurf.ts +4 -2
  37. package/src/edit/hashline/filesystem.ts +7 -7
  38. package/src/eval/agent-bridge.ts +1 -3
  39. package/src/eval/jl/prelude.jl +4 -4
  40. package/src/eval/js/shared/prelude.txt +2 -2
  41. package/src/eval/py/prelude.py +0 -3
  42. package/src/eval/rb/prelude.rb +1 -2
  43. package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
  44. package/src/hindsight/state.ts +2 -22
  45. package/src/internal-urls/memory-protocol.ts +29 -0
  46. package/src/lsp/client.ts +2 -2
  47. package/src/lsp/utils.ts +29 -0
  48. package/src/modes/acp/acp-agent.ts +2 -1
  49. package/src/modes/components/agent-hub.ts +36 -20
  50. package/src/modes/components/agent-transcript-viewer.ts +1 -3
  51. package/src/modes/components/bordered-loader.ts +1 -1
  52. package/src/modes/components/custom-editor.ts +2 -2
  53. package/src/modes/components/hook-input.ts +1 -1
  54. package/src/modes/components/hook-selector.ts +2 -2
  55. package/src/modes/components/session-selector.ts +2 -2
  56. package/src/modes/controllers/event-controller.ts +159 -2
  57. package/src/modes/controllers/extension-ui-controller.ts +2 -1
  58. package/src/modes/print-mode.ts +29 -12
  59. package/src/modes/session-observer-registry.ts +5 -0
  60. package/src/registry/agent-lifecycle.ts +21 -3
  61. package/src/sdk.ts +11 -5
  62. package/src/session/agent-session-types.ts +8 -0
  63. package/src/session/agent-session.ts +156 -186
  64. package/src/session/session-paths.ts +68 -121
  65. package/src/session/settings-stream-fn.ts +7 -3
  66. package/src/session/turn-recovery.ts +234 -16
  67. package/src/session/unexpected-stop-classifier.ts +12 -3
  68. package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
  69. package/src/task/agents.ts +1 -1
  70. package/src/task/executor.ts +105 -29
  71. package/src/task/isolation-runner.ts +19 -1
  72. package/src/task/structured-subagent.ts +17 -2
  73. package/src/tiny/worker.ts +1 -1
  74. package/src/tools/bash-interceptor.ts +8 -4
  75. package/src/tools/browser/launch.ts +37 -9
  76. package/src/tools/glob.ts +4 -2
  77. package/src/tools/path-utils.ts +13 -3
  78. package/src/tools/read.ts +6 -0
  79. package/src/tools/shell-tokenize.ts +38 -9
  80. package/src/utils/changelog.ts +5 -4
  81. package/src/utils/late-cleanup.ts +17 -0
  82. package/src/vibe/runtime.ts +197 -53
  83. package/src/web/scrapers/hackage.ts +1 -12
  84. package/src/web/search/providers/duckduckgo.ts +1 -1
  85. package/src/web/search/providers/ecosia.ts +1 -1
  86. package/src/web/search/providers/exa.ts +1 -1
  87. package/src/web/search/providers/firecrawl.ts +1 -1
  88. package/src/web/search/providers/mojeek.ts +1 -1
  89. package/src/web/search/providers/perplexity.ts +1 -1
  90. package/src/web/search/providers/public.ts +1 -1
  91. package/src/web/search/providers/startpage.ts +1 -1
@@ -34,14 +34,23 @@ export interface ClassifyUnexpectedStopDeps {
34
34
 
35
35
  export function isUnexpectedStopCandidate(message: AssistantMessage): boolean {
36
36
  if (message.stopReason !== "stop") return false;
37
- let hasText = false;
37
+ let hasContent = false;
38
38
  for (const content of message.content) {
39
39
  if (content.type === "toolCall") return false;
40
40
  if (content.type === "text" && /\S/.test(content.text)) {
41
- hasText = true;
41
+ hasContent = true;
42
+ }
43
+ // A signed thinking-only stop is still a candidate: reasoning models can
44
+ // trap the intended response (or a truncated fragment) in a thinking block
45
+ // with no text. #isEmptyAssistantStop treats a non-whitespace signature as
46
+ // terminal (not empty), so such stops bypass the empty-stop path entirely.
47
+ // Match that predicate here — unsigned thinking-only stops stay with the
48
+ // empty-stop retry path (and its cap) rather than being re-handled here.
49
+ if (content.type === "thinking" && /\S/.test(content.thinking) && /\S/.test(content.thinkingSignature ?? "")) {
50
+ hasContent = true;
42
51
  }
43
52
  }
44
- return hasText;
53
+ return hasContent;
45
54
  }
46
55
 
47
56
  export async function classifyUnexpectedStop(
@@ -4,6 +4,7 @@ import * as openUtils from "../../utils/open";
4
4
  export const DEFAULT_STATS_DASHBOARD_PORT = 3847;
5
5
 
6
6
  interface StatsDashboardServer {
7
+ hostname: string;
7
8
  port: number;
8
9
  stop: () => void;
9
10
  }
@@ -64,7 +65,7 @@ export async function launchStatsDashboard(args: StatsDashboardArgs): Promise<St
64
65
  requestedPortIgnored = true;
65
66
  }
66
67
 
67
- const url = `http://localhost:${activeStatsServer.port}`;
68
+ const url = `http://${activeStatsServer.hostname}:${activeStatsServer.port}`;
68
69
  openUtils.openPath(url);
69
70
 
70
71
  const serverLine = requestedPortIgnored
@@ -85,7 +85,7 @@ export class AgentParsingError extends Error {
85
85
  this.name = "AgentParsingError";
86
86
  }
87
87
 
88
- toString(): string {
88
+ override toString(): string {
89
89
  const details: string[] = [this.message];
90
90
  if (this.source !== undefined) {
91
91
  details.push(`Source: ${JSON.stringify(this.source)}`);
@@ -54,6 +54,7 @@ import { normalizeSchema } from "../tools/jtd-to-json-schema";
54
54
  import { buildOutputValidator, summarizeValidationFailure } from "../tools/output-schema-validator";
55
55
  import { ToolAbortError } from "../tools/tool-errors";
56
56
  import type { EventBus } from "../utils/event-bus";
57
+ import { trackLateCleanup } from "../utils/late-cleanup";
57
58
  import { buildNamedToolChoice } from "../utils/tool-choice";
58
59
  import type { WorkspaceTree } from "../workspace-tree";
59
60
  import { generateTaskLabel } from "./label";
@@ -79,6 +80,7 @@ import { arrayValuedLabels, assembleYieldResult } from "./yield-assembly";
79
80
  export type { YieldItem } from "./types";
80
81
 
81
82
  const MCP_CALL_TIMEOUT_MS = 60_000;
83
+ const TASK_ABORT_CLEANUP_GRACE_MS = 10_000;
82
84
 
83
85
  /**
84
86
  * Soft per-agent request budgets (assistant requests per run). Crossing the
@@ -166,9 +168,11 @@ function resolveSubagentRetryFallbackCandidates(
166
168
  ): SubagentRetryFallbackCandidate[] {
167
169
  const candidates: SubagentRetryFallbackCandidate[] = [];
168
170
  const seen = new Set<string>();
171
+ const disabledProviders = new Set(settings.get("disabledProviders"));
169
172
  for (const pattern of modelPatterns) {
170
173
  const resolved = resolveModelOverride([pattern], modelRegistry, settings);
171
174
  if (!resolved.model) continue;
175
+ if (disabledProviders.has(resolved.model.provider)) continue;
172
176
  const selector = resolved.explicitThinkingLevel
173
177
  ? formatModelSelectorValue(formatModelStringWithRouting(resolved.model), resolved.thinkingLevel)
174
178
  : formatModelStringWithRouting(resolved.model);
@@ -179,7 +183,10 @@ function resolveSubagentRetryFallbackCandidates(
179
183
  return candidates;
180
184
  }
181
185
 
182
- function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[] | undefined {
186
+ function resolveSubagentDefaultRetryFallbackChain(
187
+ settings: Settings,
188
+ modelRegistry: ModelRegistry,
189
+ ): string[] | undefined {
183
190
  const fallbackChain = settings.get("retry.fallbackChains")?.default;
184
191
  if (
185
192
  !Array.isArray(fallbackChain) ||
@@ -188,7 +195,11 @@ function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[]
188
195
  ) {
189
196
  return undefined;
190
197
  }
191
- return fallbackChain;
198
+ const disabledProviders = new Set(settings.get("disabledProviders"));
199
+ return fallbackChain.filter(entry => {
200
+ const resolved = resolveModelOverride([entry], modelRegistry, settings);
201
+ return !resolved.model || !disabledProviders.has(resolved.model.provider);
202
+ });
192
203
  }
193
204
 
194
205
  function installSubagentRetryFallbackChain(args: {
@@ -455,6 +466,8 @@ export interface ExecutorOptions {
455
466
  * set this false so disposal unregisters them instead of leaving idle peers.
456
467
  */
457
468
  keepAlive?: boolean;
469
+ /** Internal ownership handoff for cleanup that outlives the visible Task result. */
470
+ onCleanupDeferred?: (completion: Promise<void>) => void;
458
471
  }
459
472
 
460
473
  function parseStringifiedJson(value: unknown): unknown {
@@ -1964,9 +1977,7 @@ async function driveSessionToYield(
1964
1977
  // yield: the next iteration's ladder demands a fresh one.
1965
1978
  }
1966
1979
 
1967
- if (monitor.yieldCalled()) {
1968
- await session.waitForIdle();
1969
- } else {
1980
+ if (!monitor.yieldCalled()) {
1970
1981
  await awaitAbortable(session.waitForIdle());
1971
1982
  }
1972
1983
 
@@ -2140,14 +2151,17 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
2140
2151
  exitCode = 1;
2141
2152
  }
2142
2153
  const wasAborted =
2143
- runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
2154
+ runtimeLimitExceeded || Boolean(done.aborted) || abortedViaYield || (!hasYield && Boolean(signal?.aborted));
2144
2155
  const finalAbortReason = wasAborted
2145
2156
  ? runtimeLimitExceeded
2146
2157
  ? monitor.resolveAbortReasonText()
2147
- : abortedViaYield
2148
- ? yieldAbortReason
2149
- : (done.abortReason ??
2150
- (signal?.aborted ? monitor.resolveSignalAbortReason() : monitor.resolveAbortReasonText()))
2158
+ : done.aborted
2159
+ ? (done.abortReason ?? monitor.resolveAbortReasonText())
2160
+ : abortedViaYield
2161
+ ? yieldAbortReason
2162
+ : signal?.aborted
2163
+ ? monitor.resolveSignalAbortReason()
2164
+ : monitor.resolveAbortReasonText()
2151
2165
  : undefined;
2152
2166
  progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
2153
2167
  monitor.scheduleProgress(true);
@@ -2346,15 +2360,27 @@ export async function finalizeSubagentLifecycle(args: {
2346
2360
  isolated: boolean;
2347
2361
  agentIdleTtlMs: number;
2348
2362
  reviveSession: AgentReviver | null;
2363
+ cleanupDeadlineAt?: number;
2364
+ onCleanupDeferred?: (completion: Promise<void>) => void;
2349
2365
  }): Promise<void> {
2350
2366
  const registry = AgentRegistry.global();
2351
2367
  const ref = registry.get(args.id);
2352
2368
  const ownsRef = Boolean(ref && ref.session === args.session);
2369
+ const cleanupDeadlineAt = args.cleanupDeadlineAt ?? Date.now() + 5000;
2353
2370
  const disposeSession = async (): Promise<void> => {
2371
+ const disposal = args.session.dispose();
2372
+ const remainingMs = Math.max(0, cleanupDeadlineAt - Date.now());
2354
2373
  try {
2355
- await untilAborted(AbortSignal.timeout(5000), () => args.session.dispose());
2356
- } catch {
2357
- // Ignore cleanup errors
2374
+ await untilAborted(AbortSignal.timeout(remainingMs), () => disposal);
2375
+ } catch (error) {
2376
+ if (Date.now() >= cleanupDeadlineAt) {
2377
+ args.onCleanupDeferred?.(disposal);
2378
+ return;
2379
+ }
2380
+ logger.warn("Subagent session cleanup failed", {
2381
+ id: args.id,
2382
+ error: error instanceof Error ? error.message : String(error),
2383
+ });
2358
2384
  }
2359
2385
  };
2360
2386
 
@@ -2766,7 +2792,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2766
2792
  const configuredModelPatterns = resolveConfiguredModelPatterns(modelPatterns, settings);
2767
2793
  const defaultRetryFallbackChain =
2768
2794
  configuredModelPatterns.length === 1
2769
- ? resolveSubagentDefaultRetryFallbackChain(subagentSettings)
2795
+ ? resolveSubagentDefaultRetryFallbackChain(subagentSettings, modelRegistry)
2770
2796
  : undefined;
2771
2797
  const {
2772
2798
  model,
@@ -2800,7 +2826,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2800
2826
  const retryFallbackRole = installSubagentRetryFallbackChain({
2801
2827
  settings: subagentSettings,
2802
2828
  id,
2803
- candidates: resolveSubagentRetryFallbackCandidates(modelPatterns, modelRegistry, settings),
2829
+ candidates: resolveSubagentRetryFallbackCandidates(modelPatterns, modelRegistry, subagentSettings),
2804
2830
  defaultFallbackChain: defaultRetryFallbackChain,
2805
2831
  model,
2806
2832
  authFallbackUsed,
@@ -3186,6 +3212,20 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3186
3212
  error = err instanceof Error ? err.stack || err.message : String(err);
3187
3213
  }
3188
3214
  } finally {
3215
+ const cleanupDeadlineAt = Date.now() + TASK_ABORT_CLEANUP_GRACE_MS;
3216
+ const cleanupChangeStatus =
3217
+ worktree === undefined
3218
+ ? "This task was not isolated, so its changes may remain in the working directory."
3219
+ : "No isolated changes were applied.";
3220
+ const lateCleanups: Promise<void>[] = [];
3221
+ let deferredSessionShutdown: Promise<void> | undefined;
3222
+ const deferCleanup = (completion: Promise<void>): void => {
3223
+ lateCleanups.push(completion);
3224
+ exitCode = 1;
3225
+ aborted = true;
3226
+ abortReasonText = `cleanup exceeded ${TASK_ABORT_CLEANUP_GRACE_MS} ms`;
3227
+ error ??= `Task aborted. Cleanup did not finish within ${TASK_ABORT_CLEANUP_GRACE_MS} ms. ${cleanupChangeStatus}`;
3228
+ };
3189
3229
  if (abortSignal.aborted) {
3190
3230
  aborted = monitor.isAbortedRun();
3191
3231
  if (aborted) {
@@ -3194,10 +3234,21 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3194
3234
  if (exitCode === 0) exitCode = 1;
3195
3235
  }
3196
3236
  sessionAbortController.abort();
3237
+ const activeSessionAbort = monitor.waitForActiveSessionAbort();
3197
3238
  try {
3198
- await untilAborted(AbortSignal.timeout(5000), () => monitor.waitForActiveSessionAbort());
3199
- } catch {
3200
- // Ignore abort cleanup timeouts/errors; terminal disposal below is still best-effort.
3239
+ await untilAborted(
3240
+ AbortSignal.timeout(Math.max(0, cleanupDeadlineAt - Date.now())),
3241
+ () => activeSessionAbort,
3242
+ );
3243
+ } catch (cleanupError) {
3244
+ if (Date.now() >= cleanupDeadlineAt) {
3245
+ deferCleanup(activeSessionAbort);
3246
+ } else {
3247
+ logger.warn("Subagent abort cleanup failed", {
3248
+ id,
3249
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
3250
+ });
3251
+ }
3201
3252
  }
3202
3253
  if (unsubscribe) {
3203
3254
  try {
@@ -3207,6 +3258,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3207
3258
  }
3208
3259
  unsubscribe = null;
3209
3260
  }
3261
+ const jobManager = AsyncJobManager.instance();
3262
+ if (jobManager) {
3263
+ const reap = await jobManager.cancelAndReapOwnerJobs(id, cleanupDeadlineAt);
3264
+ if (!reap.settled) {
3265
+ deferCleanup(reap.completion);
3266
+ logger.warn("Subagent async job cleanup exceeded its deadline", {
3267
+ id,
3268
+ pendingJobIds: reap.pendingJobIds,
3269
+ });
3270
+ }
3271
+ }
3210
3272
  const session = monitor.takeActiveSession();
3211
3273
  if (session) {
3212
3274
  monitor.captureSalvage(session);
@@ -3222,22 +3284,36 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3222
3284
  isolated: worktree !== undefined,
3223
3285
  agentIdleTtlMs,
3224
3286
  reviveSession,
3287
+ cleanupDeadlineAt,
3288
+ onCleanupDeferred: completion => {
3289
+ deferredSessionShutdown = completion;
3290
+ deferCleanup(completion);
3291
+ },
3225
3292
  });
3226
3293
  }
3227
- // Structured-concurrency reap: cancel and await ALL surviving owner
3228
- // jobs (abort paths; suppressed/watched jobs the model left behind)
3229
- // so isolation capture/cleanup never races a live process writing
3230
- // into the worktree. This never proceeds while an owner process is
3231
- // live: cancellation SIGKILL-escalates, so settlement is expected
3232
- // within one interval — an unkillable process blocks here visibly
3233
- // (with periodic warnings) instead of silently racing teardown.
3234
- const jobManager = AsyncJobManager.instance();
3235
3294
  if (jobManager) {
3236
- jobManager.cancelAll({ ownerId: id });
3237
- while (!(await jobManager.waitForOwnerJobs(id, { timeoutMs: 10_000 }))) {
3238
- logger.warn("Subagent async jobs still settling; delaying teardown until process exit", { id });
3295
+ if (deferredSessionShutdown) {
3296
+ const finalReap = Promise.allSettled([deferredSessionShutdown]).then(async () => {
3297
+ const reap = await jobManager.cancelAndReapOwnerJobs(id, Date.now());
3298
+ await reap.completion;
3299
+ });
3300
+ lateCleanups.push(finalReap);
3301
+ } else {
3302
+ const reap = await jobManager.cancelAndReapOwnerJobs(id, cleanupDeadlineAt);
3303
+ if (!reap.settled) {
3304
+ deferCleanup(reap.completion);
3305
+ logger.warn("Subagent async job cleanup exceeded its deadline after session shutdown", {
3306
+ id,
3307
+ pendingJobIds: reap.pendingJobIds,
3308
+ });
3309
+ }
3239
3310
  }
3240
3311
  }
3312
+ if (lateCleanups.length > 0) {
3313
+ const completion = Promise.allSettled(lateCleanups).then(() => {});
3314
+ trackLateCleanup(completion, { id, resource: "subagent" });
3315
+ options.onCleanupDeferred?.(completion);
3316
+ }
3241
3317
  }
3242
3318
 
3243
3319
  // Launch-latency breakdown (subagent invocation → first chat dispatch).
@@ -23,6 +23,7 @@ import type * as natives from "@oh-my-pi/pi-natives";
23
23
  import type { ToolSession } from "../tools";
24
24
  import { generateCommitMessage } from "../utils/commit-message-generator";
25
25
  import * as git from "../utils/git";
26
+ import { trackLateCleanup } from "../utils/late-cleanup";
26
27
  import type { ExecutorOptions } from "./executor";
27
28
  import { runSubprocess } from "./executor";
28
29
  import type { SingleResult } from "./types";
@@ -146,6 +147,7 @@ async function writeIsolationPatch(
146
147
  */
147
148
  export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<SingleResult> {
148
149
  let handle: IsolationHandle | undefined;
150
+ let deferredCleanup: Promise<void> | undefined;
149
151
  try {
150
152
  const taskBaseline = structuredClone(opts.context.baseline);
151
153
  handle = await ensureIsolation(opts.context.repoRoot, opts.agentId, opts.preferredBackend);
@@ -155,7 +157,12 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<S
155
157
  worktree: isolationDir,
156
158
  preloadedExtensionPaths: undefined,
157
159
  preloadedCustomToolPaths: undefined,
160
+ onCleanupDeferred: completion => {
161
+ deferredCleanup = completion;
162
+ opts.baseOptions.onCleanupDeferred?.(completion);
163
+ },
158
164
  });
165
+ if (deferredCleanup) return result;
159
166
  if (opts.mergeMode === "branch" && result.exitCode === 0) {
160
167
  try {
161
168
  const commitResult = await commitToBranch(
@@ -213,7 +220,18 @@ export async function runIsolatedSubprocess(opts: IsolatedRunOptions): Promise<S
213
220
  return opts.buildFailureResult(err);
214
221
  } finally {
215
222
  if (handle) {
216
- await cleanupIsolation(handle);
223
+ const isolationHandle = handle;
224
+ if (deferredCleanup) {
225
+ trackLateCleanup(
226
+ deferredCleanup.then(() => cleanupIsolation(isolationHandle)),
227
+ {
228
+ agentId: opts.agentId,
229
+ resource: "isolation",
230
+ },
231
+ );
232
+ } else {
233
+ await cleanupIsolation(isolationHandle);
234
+ }
217
235
  }
218
236
  }
219
237
  }
@@ -20,6 +20,7 @@ import type { TaskEffort } from "../thinking";
20
20
  import type { ToolSession } from "../tools";
21
21
  import { isIrcEnabled } from "../tools/hub";
22
22
  import { buildOutputValidator } from "../tools/output-schema-validator";
23
+ import { trackLateCleanup } from "../utils/late-cleanup";
23
24
  import { type DiscoveryResult, discoverAgents, getAgent } from "./discovery";
24
25
  import { type ExecutorOptions, runSubprocess } from "./executor";
25
26
  import {
@@ -540,12 +541,16 @@ export async function runStructuredSubagent(request: StructuredSubagentRequest):
540
541
  let mergeSummary = "";
541
542
  let requiresRecoveryArtifacts = false;
542
543
  let completedSuccessfully = false;
544
+ let deferredCleanup: Promise<void> | undefined;
543
545
  try {
544
546
  const id = await reserveStructuredSubagentId(request.session, {
545
547
  ...request.identity,
546
548
  label: request.identity?.label ?? (request.invocationKind === "eval" ? "EvalAgent" : undefined),
547
549
  });
548
550
  const baseOptions = buildExecutorOptions(request, policy, lease, id);
551
+ baseOptions.onCleanupDeferred = completion => {
552
+ deferredCleanup = completion;
553
+ };
549
554
  baseOptions.planReference = await loadPlanReference(request, policy);
550
555
  let isolationContext: IsolationContext | null = null;
551
556
  if (policy.isIsolated) {
@@ -639,8 +644,18 @@ export async function runStructuredSubagent(request: StructuredSubagentRequest):
639
644
  (policy.isIsolated && (!policy.applyChanges || changesApplied === false || requiresRecoveryArtifacts));
640
645
  const shouldCleanup = lease.temporary && !shouldRetainArtifacts;
641
646
  if (shouldCleanup) {
642
- await fs.rm(lease.artifactsDir, { recursive: true, force: true });
643
- lease.unregister?.();
647
+ const cleanupArtifacts = async (): Promise<void> => {
648
+ await fs.rm(lease.artifactsDir, { recursive: true, force: true });
649
+ lease.unregister?.();
650
+ };
651
+ if (deferredCleanup) {
652
+ trackLateCleanup(deferredCleanup.then(cleanupArtifacts), {
653
+ resource: "artifacts",
654
+ artifactsDir: lease.artifactsDir,
655
+ });
656
+ } else {
657
+ await cleanupArtifacts();
658
+ }
644
659
  }
645
660
  }
646
661
  }
@@ -94,7 +94,7 @@ function createStopOnTextCriteria(
94
94
  this.#text = text;
95
95
  }
96
96
 
97
- _call(inputIds: number[][]): boolean[] {
97
+ override _call(inputIds: number[][]): boolean[] {
98
98
  return inputIds.map(ids => {
99
99
  const tail = ids.slice(-STOP_DECODE_WINDOW_TOKENS);
100
100
  const decoded = this.#tokenizer.decode(tail, {
@@ -96,10 +96,14 @@ function withoutLeadingEnvironmentAssignments(command: string): string | null {
96
96
 
97
97
  function interceptionCandidates(command: string): string[] {
98
98
  const candidates = [command.trim()];
99
- const segments = extractFlatShellCommandSegments(command);
100
- candidates.push(...segments.map(segment => segment.trim()));
101
- for (const segment of segments) {
102
- const withoutAssignments = withoutLeadingEnvironmentAssignments(segment);
99
+ for (const segment of extractFlatShellCommandSegments(command)) {
100
+ // A segment that consumes the previous stage's stdout via `|` reads piped
101
+ // stdin, which no path-based dedicated tool (read/grep/glob) — nor any
102
+ // other dedicated tool — can replace, so it is not an interception
103
+ // candidate. Standalone and first-stage commands still match.
104
+ if (segment.pipedStdin) continue;
105
+ candidates.push(segment.text);
106
+ const withoutAssignments = withoutLeadingEnvironmentAssignments(segment.text);
103
107
  if (withoutAssignments) candidates.push(withoutAssignments);
104
108
  }
105
109
  return candidates;
@@ -121,9 +121,9 @@ async function loadBrowsers(): Promise<typeof BrowsersNs> {
121
121
  }
122
122
 
123
123
  /**
124
- * Resolve the Chromium executable puppeteer will launch, lazily downloading it
125
- * on first use via @puppeteer/browsers. Skipped when a system Chromium (NixOS)
126
- * or PUPPETEER_EXECUTABLE_PATH is set. The browser is cached under
124
+ * Resolve the Chromium executable puppeteer will launch, honoring
125
+ * PUPPETEER_EXECUTABLE_PATH before system browser detection and lazily
126
+ * downloading Chromium otherwise. The browser is cached under
127
127
  * ~/.omp/puppeteer (getPuppeteerDir). Returns undefined when platform
128
128
  * detection fails (puppeteer default resolution takes over). Exported so
129
129
  * real-browser tests can probe launchability and skip on hosts missing
@@ -131,10 +131,10 @@ async function loadBrowsers(): Promise<typeof BrowsersNs> {
131
131
  */
132
132
  let chromiumExecutablePromise: Promise<string | undefined> | undefined;
133
133
  export async function ensureChromiumExecutable(): Promise<string | undefined> {
134
- const sysChrome = resolveSystemChromium();
135
- if (sysChrome) return sysChrome;
136
134
  const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
137
135
  if (envPath) return envPath;
136
+ const sysChrome = resolveSystemChromium();
137
+ if (sysChrome) return sysChrome;
138
138
  if (chromiumExecutablePromise) return chromiumExecutablePromise;
139
139
 
140
140
  chromiumExecutablePromise = (async () => {
@@ -199,10 +199,16 @@ function isExecutableFile(p: string): boolean {
199
199
  }
200
200
  }
201
201
 
202
- function systemChromiumCandidates(): string[] {
203
- const home = os.homedir();
202
+ /** Flatpak application id published by the Ungoogled Chromium project. */
203
+ const UNGOOGLED_CHROMIUM_FLATPAK_ID = "io.github.ungoogled_software.ungoogled_chromium";
204
+
205
+ function systemChromiumCandidates(
206
+ platform: NodeJS.Platform = process.platform,
207
+ home = os.homedir(),
208
+ which: (name: string) => string | null | undefined = $which,
209
+ ): string[] {
204
210
  const candidates: string[] = [];
205
- switch (process.platform) {
211
+ switch (platform) {
206
212
  case "darwin": {
207
213
  for (const root of ["/Applications", path.join(home, "Applications")]) {
208
214
  candidates.push(
@@ -219,7 +225,7 @@ function systemChromiumCandidates(): string[] {
219
225
  case "linux": {
220
226
  const names = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"];
221
227
  for (const name of names) {
222
- const found = $which(name);
228
+ const found = which(name);
223
229
  if (found) candidates.push(found);
224
230
  }
225
231
  candidates.push(
@@ -238,6 +244,19 @@ function systemChromiumCandidates(): string[] {
238
244
  if (onNixos) {
239
245
  candidates.push(path.join(home, ".nix-profile/bin/chromium"), "/run/current-system/sw/bin/chromium");
240
246
  }
247
+ for (const name of ["ungoogled-chromium", "ungoogled-chromium-browser"]) {
248
+ const found = which(name);
249
+ if (found) candidates.push(found);
250
+ }
251
+ candidates.push(
252
+ // Ungoogled Chromium. Distro and AUR packages that keep the plain
253
+ // `chromium` name are already covered above; these are the paths
254
+ // unique to it, including the system and per-user Flatpak shims.
255
+ "/usr/bin/ungoogled-chromium",
256
+ "/usr/bin/ungoogled-chromium-browser",
257
+ `/var/lib/flatpak/exports/bin/${UNGOOGLED_CHROMIUM_FLATPAK_ID}`,
258
+ path.join(home, ".local/share/flatpak/exports/bin", UNGOOGLED_CHROMIUM_FLATPAK_ID),
259
+ );
241
260
  break;
242
261
  }
243
262
  case "win32": {
@@ -866,6 +885,15 @@ export async function applyStealthPatches(
866
885
  await injectStealthScripts(page);
867
886
  }
868
887
 
888
+ /** Exposes executable candidates for detection tests. */
889
+ export function systemChromiumCandidatesForTest(
890
+ platform: NodeJS.Platform = process.platform,
891
+ home?: string,
892
+ which?: (name: string) => string | undefined,
893
+ ): string[] {
894
+ return systemChromiumCandidates(platform, home, which);
895
+ }
896
+
869
897
  export function stealthIgnoreDefaultArgsForTest(executablePath: string | undefined): string[] {
870
898
  return stealthIgnoreDefaultArgs(executablePath);
871
899
  }
package/src/tools/glob.ts CHANGED
@@ -6,7 +6,7 @@ import type { ToolExample } from "@oh-my-pi/pi-ai";
6
6
  import * as natives from "@oh-my-pi/pi-natives";
7
7
  import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { Text } from "@oh-my-pi/pi-tui";
9
- import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
9
+ import { formatGroupedPaths, hasFsCode, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import { InternalUrlRouter } from "../internal-urls";
12
12
  import { splitMemoryGlobPattern } from "../internal-urls/memory-protocol";
@@ -415,7 +415,9 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
415
415
  try {
416
416
  stat = await fs.promises.stat(target.searchPath);
417
417
  } catch (err) {
418
- if (isEnoent(err)) {
418
+ // ENAMETOOLONG can never name a real target; surface a clean
419
+ // "Path not found" instead of leaking the raw errno (issue #7597).
420
+ if (isEnoent(err) || hasFsCode(err, "ENAMETOOLONG")) {
419
421
  if (isSingle) throw new ToolError(`Path not found: ${scopePath}`);
420
422
  return [];
421
423
  }
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
  import { glob } from "@oh-my-pi/pi-natives";
6
- import { isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix, untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { hasFsCode, isEnoent, isEnotdir, stripWindowsExtendedLengthPathPrefix, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import type { Skill } from "../extensibility/skills";
8
8
  import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
9
9
  import { ToolAbortError, ToolError } from "./tool-errors";
@@ -337,6 +337,13 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
337
337
  * plus selector `1-2` (issue #4618). `lstat` inspects the entry itself, so a
338
338
  * dangling symlink is still detected as present; ambiguous errors resolve to
339
339
  * `"unknown"` so callers keep the raw path instead of guessing.
340
+ *
341
+ * `ENAMETOOLONG` resolves to `"missing"` rather than `"unknown"`: a path whose
342
+ * component or whole length exceeds the OS limit can never name a real single
343
+ * entry, so it is strictly stronger evidence of non-existence than `ENOENT`.
344
+ * Without this, a semicolon-joined `path` list long enough to trip the limit
345
+ * (bare filenames past `NAME_MAX`, or a total past `PATH_MAX`) was read as one
346
+ * literal path and the delimited split was suppressed (issue #7597).
340
347
  */
341
348
  export async function probeLiteralPathExists(filePath: string, cwd: string): Promise<"exists" | "missing" | "unknown"> {
342
349
  const resolved = resolveReadPath(filePath, cwd);
@@ -344,7 +351,7 @@ export async function probeLiteralPathExists(filePath: string, cwd: string): Pro
344
351
  await fs.promises.lstat(resolved);
345
352
  return "exists";
346
353
  } catch (err) {
347
- if (isEnoent(err) || isEnotdir(err)) return "missing";
354
+ if (isEnoent(err) || isEnotdir(err) || hasFsCode(err, "ENAMETOOLONG")) return "missing";
348
355
  return "unknown";
349
356
  }
350
357
  }
@@ -762,7 +769,10 @@ async function delimitedPathPartResolves(entry: string, cwd: string, splitter: P
762
769
  await fs.promises.stat(absoluteBasePath);
763
770
  return true;
764
771
  } catch (err) {
765
- if (isEnoent(err)) return false;
772
+ // ENOENT and ENAMETOOLONG both mean this string cannot name an existing
773
+ // path, so the whole entry does not resolve and the delimited split may
774
+ // proceed (issue #7597). Other errors (EACCES, transient I/O) stay fatal.
775
+ if (isEnoent(err) || hasFsCode(err, "ENAMETOOLONG")) return false;
766
776
  throw err;
767
777
  }
768
778
  }
package/src/tools/read.ts CHANGED
@@ -737,6 +737,8 @@ export interface ReadToolDetails {
737
737
  meta?: OutputMeta;
738
738
  /** Full on-disk byte size recorded before applying a file range. */
739
739
  fileSize?: number;
740
+ /** Full source line count when the read reached EOF and the count is exact. */
741
+ totalLines?: number;
740
742
  /** Raw text + start line for user-visible TUI rendering, set when content is text-like.
741
743
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
742
744
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
@@ -1397,6 +1399,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1397
1399
  const details = options.details ?? {};
1398
1400
  const allLines = text.split("\n");
1399
1401
  const totalLines = allLines.length;
1402
+ details.totalLines = totalLines;
1400
1403
  // User-requested 0-indexed range start. Lines BEFORE this are leading
1401
1404
  // context (added below if offset is explicit).
1402
1405
  const requestedStart = offset ? Math.max(0, offset - 1) : 0;
@@ -1592,6 +1595,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1592
1595
  const details = options.details ?? {};
1593
1596
  const allLines = text.split("\n");
1594
1597
  const totalLines = allLines.length;
1598
+ details.totalLines = totalLines;
1595
1599
  const shouldAddHashLines = displayMode.hashLines;
1596
1600
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
1597
1601
  const hashContext =
@@ -2908,6 +2912,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2908
2912
  details = {};
2909
2913
  sourcePath = absolutePath;
2910
2914
  }
2915
+ if (reachedEof) details.totalLines = totalFileLines;
2911
2916
 
2912
2917
  if (hashContext?.tag) {
2913
2918
  recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
@@ -3239,6 +3244,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
3239
3244
  if (!rawSelector && artifact.size > MAX_ARTIFACT_RAW_INLINE_BYTES) {
3240
3245
  outputText += `\n\n[${this.#formatArtifactWorkflowNotice(artifact, artifactUrl)}]`;
3241
3246
  }
3247
+ if (reachedEof) details.totalLines = totalFileLines;
3242
3248
  if (displayContent) details.displayContent = displayContent;
3243
3249
  if (truncationInfo) details.truncation = truncationInfo.result;
3244
3250
  const resultBuilder = toolResult<ReadToolDetails>(details)