@oh-my-pi/pi-coding-agent 17.2.7 → 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.
- package/CHANGELOG.md +57 -1
- package/dist/{CHANGELOG-1p8yvde7.md → CHANGELOG-8yy8h3bc.md} +57 -1
- package/dist/cli.js +4974 -4968
- package/dist/types/async/job-manager.d.ts +12 -0
- package/dist/types/discovery/agents.d.ts +11 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
- package/dist/types/hindsight/state.d.ts +0 -1
- package/dist/types/lsp/utils.d.ts +8 -0
- package/dist/types/modes/session-observer-registry.d.ts +2 -0
- package/dist/types/registry/agent-lifecycle.d.ts +1 -1
- package/dist/types/session/agent-session-types.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +5 -3
- package/dist/types/session/turn-recovery.d.ts +6 -2
- package/dist/types/task/executor.d.ts +4 -0
- package/dist/types/tools/browser/launch.d.ts +2 -0
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tools/read.d.ts +2 -0
- package/dist/types/tools/shell-tokenize.d.ts +21 -3
- package/dist/types/utils/changelog.d.ts +3 -2
- package/dist/types/utils/late-cleanup.d.ts +2 -0
- package/dist/types/vibe/runtime.d.ts +2 -0
- package/package.json +13 -13
- package/scripts/omp +11 -2
- package/src/async/job-manager.ts +26 -0
- package/src/cli/stats-cli.ts +3 -3
- package/src/cli/update-cli.ts +2 -56
- package/src/config/config-file.ts +2 -2
- package/src/config/keybindings.ts +3 -3
- package/src/config/model-discovery.ts +36 -2
- package/src/config/model-registry.ts +4 -1
- package/src/discovery/agents.ts +71 -3
- package/src/discovery/claude.ts +8 -5
- package/src/discovery/cursor.ts +8 -5
- package/src/discovery/gemini.ts +11 -8
- package/src/discovery/vscode.ts +1 -0
- package/src/discovery/windsurf.ts +4 -2
- package/src/edit/hashline/filesystem.ts +7 -7
- package/src/eval/agent-bridge.ts +1 -3
- package/src/eval/jl/prelude.jl +4 -4
- package/src/eval/js/shared/prelude.txt +2 -2
- package/src/eval/py/prelude.py +0 -3
- package/src/eval/rb/prelude.rb +1 -2
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
- package/src/hindsight/state.ts +2 -22
- package/src/internal-urls/memory-protocol.ts +29 -0
- package/src/lsp/client.ts +2 -2
- package/src/lsp/utils.ts +29 -0
- package/src/modes/acp/acp-agent.ts +2 -1
- package/src/modes/components/agent-hub.ts +36 -20
- package/src/modes/components/agent-transcript-viewer.ts +1 -3
- package/src/modes/components/bordered-loader.ts +1 -1
- package/src/modes/components/custom-editor.ts +2 -2
- package/src/modes/components/hook-input.ts +1 -1
- package/src/modes/components/hook-selector.ts +2 -2
- package/src/modes/components/session-selector.ts +2 -2
- package/src/modes/controllers/event-controller.ts +159 -2
- package/src/modes/controllers/extension-ui-controller.ts +2 -1
- package/src/modes/print-mode.ts +29 -12
- package/src/modes/session-observer-registry.ts +5 -0
- package/src/registry/agent-lifecycle.ts +21 -3
- package/src/sdk.ts +11 -5
- package/src/session/agent-session-types.ts +8 -0
- package/src/session/agent-session.ts +156 -186
- package/src/session/session-paths.ts +68 -121
- package/src/session/settings-stream-fn.ts +7 -3
- package/src/session/turn-recovery.ts +234 -16
- package/src/session/unexpected-stop-classifier.ts +12 -3
- package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
- package/src/task/agents.ts +1 -1
- package/src/task/executor.ts +105 -29
- package/src/task/isolation-runner.ts +19 -1
- package/src/task/structured-subagent.ts +17 -2
- package/src/tiny/worker.ts +1 -1
- package/src/tools/bash-interceptor.ts +8 -4
- package/src/tools/browser/launch.ts +37 -9
- package/src/tools/glob.ts +4 -2
- package/src/tools/path-utils.ts +13 -3
- package/src/tools/read.ts +6 -0
- package/src/tools/shell-tokenize.ts +38 -9
- package/src/utils/changelog.ts +5 -4
- package/src/utils/late-cleanup.ts +17 -0
- package/src/vibe/runtime.ts +197 -53
- package/src/web/scrapers/hackage.ts +1 -12
- package/src/web/search/providers/duckduckgo.ts +1 -1
- package/src/web/search/providers/ecosia.ts +1 -1
- package/src/web/search/providers/exa.ts +1 -1
- package/src/web/search/providers/firecrawl.ts +1 -1
- package/src/web/search/providers/mojeek.ts +1 -1
- package/src/web/search/providers/perplexity.ts +1 -1
- package/src/web/search/providers/public.ts +1 -1
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
68
|
+
const url = `http://${activeStatsServer.hostname}:${activeStatsServer.port}`;
|
|
68
69
|
openUtils.openPath(url);
|
|
69
70
|
|
|
70
71
|
const serverLine = requestedPortIgnored
|
package/src/task/agents.ts
CHANGED
|
@@ -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)}`);
|
package/src/task/executor.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 && (
|
|
2154
|
+
runtimeLimitExceeded || Boolean(done.aborted) || abortedViaYield || (!hasYield && Boolean(signal?.aborted));
|
|
2144
2155
|
const finalAbortReason = wasAborted
|
|
2145
2156
|
? runtimeLimitExceeded
|
|
2146
2157
|
? monitor.resolveAbortReasonText()
|
|
2147
|
-
:
|
|
2148
|
-
?
|
|
2149
|
-
:
|
|
2150
|
-
|
|
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(
|
|
2356
|
-
} catch {
|
|
2357
|
-
|
|
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,
|
|
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(
|
|
3199
|
-
|
|
3200
|
-
|
|
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
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
643
|
-
|
|
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
|
}
|
package/src/tiny/worker.ts
CHANGED
|
@@ -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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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,
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
-
|
|
203
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|