pi-better-subagents 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/agent-inspection.ts +28 -0
- package/agents-catalog-tool.ts +2 -1
- package/batch.mjs +2 -2
- package/catalog-runtime.ts +52 -1
- package/child-incidents.ts +6 -4
- package/docs/agent-catalog-operations.md +2 -0
- package/docs/failure-observations.md +2 -0
- package/failures.ts +49 -3
- package/incident-model.ts +7 -7
- package/index.ts +19 -13
- package/output-payload.ts +2 -10
- package/package.json +1 -1
- package/shared-failure-observations.ts +22 -9
- package/shared-navigator.ts +57 -2
- package/shared-sandbox-core.ts +129 -21
- package/shared-task-files.ts +1 -1
- package/shared-task-sandbox.ts +1 -0
- package/task-policy.ts +1 -1
package/README.md
CHANGED
|
@@ -43,6 +43,13 @@ uses `agents_catalog` to discover current role descriptions and delegates every
|
|
|
43
43
|
nontrivial role-owned task, while the foreground coordinates, integrates, and
|
|
44
44
|
verifies. See [usage notes](docs/usage.md#delegation-mode).
|
|
45
45
|
|
|
46
|
+
`agents_catalog` shows each role's and named agent's default model and effort,
|
|
47
|
+
such as `default openai/gpt-6-sol@high`. To launch on that default, omit
|
|
48
|
+
`model` and `thinking` on a role or agent spawn; name one only for a stated
|
|
49
|
+
reason. When a launch's model or effort differs from the default, its launch
|
|
50
|
+
line says so, for example
|
|
51
|
+
`model openai/gpt-6-astra@high (role default openai/gpt-6-sol@high)`.
|
|
52
|
+
|
|
46
53
|
## When To Use
|
|
47
54
|
|
|
48
55
|
Use this package for independent coding, review, research, or verification work that can finish later. Do not use it for steps that need immediate foreground interaction or user clarification.
|
package/agent-inspection.ts
CHANGED
|
@@ -80,6 +80,23 @@ export interface OperationField {
|
|
|
80
80
|
inherited: boolean;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
export interface DefinitionDefaults {
|
|
84
|
+
model: string | null;
|
|
85
|
+
effort: string | null;
|
|
86
|
+
/** Compact `model@effort` form, as shown in the catalog list and launch notes. */
|
|
87
|
+
label: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `model@effort`, `model`, or `effort <level>` when only an effort is set. Undefined when neither is set. */
|
|
91
|
+
export function formatModelEffort(model: string | null | undefined, effort: string | null | undefined): string | undefined {
|
|
92
|
+
const m = typeof model === "string" && model.trim() !== "" ? model : undefined;
|
|
93
|
+
const e = typeof effort === "string" && effort.trim() !== "" ? effort : undefined;
|
|
94
|
+
if (m && e) return `${m}@${e}`;
|
|
95
|
+
if (m) return m;
|
|
96
|
+
if (e) return `effort ${e}`;
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
export interface OperationCapabilities {
|
|
84
101
|
grantedByCatalog: false;
|
|
85
102
|
sameAsLegacySpawn: true;
|
|
@@ -113,6 +130,8 @@ export interface OperationView {
|
|
|
113
130
|
instructionMode?: string;
|
|
114
131
|
instructions?: string;
|
|
115
132
|
fields?: { model: OperationField; effort: OperationField; tier: OperationField };
|
|
133
|
+
/** The definition's own default model and effort (role default, or the agent's override or inherited value). Null when neither is set. */
|
|
134
|
+
defaults: DefinitionDefaults | null;
|
|
116
135
|
requestedModel: string | null;
|
|
117
136
|
requestedEffort: string | null;
|
|
118
137
|
actualModel: string | null;
|
|
@@ -311,6 +330,7 @@ export function presentCatalogEntry(snapshot: CatalogSnapshot, id: string, enric
|
|
|
311
330
|
instructionMode: inspection.instructionMode,
|
|
312
331
|
instructions: inspection.instructions,
|
|
313
332
|
fields,
|
|
333
|
+
defaults: definitionDefaults(fields),
|
|
314
334
|
requestedModel: enrichment?.requestedModel ?? fields?.model.value ?? null,
|
|
315
335
|
requestedEffort: enrichment?.requestedEffort ?? (fields?.effort.value ?? null),
|
|
316
336
|
actualModel: enrichment?.actualModel ?? null,
|
|
@@ -350,6 +370,7 @@ export function renderOperationView(view: OperationView): string {
|
|
|
350
370
|
`definitionValid=${yesNo(view.definitionValid)}`,
|
|
351
371
|
`catalogLaunchable=${yesNo(view.catalogLaunchable)}`,
|
|
352
372
|
`launchable=${view.launchable === null ? "unknown" : yesNo(view.launchable)}`,
|
|
373
|
+
...(view.defaults ? [`default ${view.defaults.label}`] : []),
|
|
353
374
|
].join(" ");
|
|
354
375
|
const lines = [
|
|
355
376
|
header,
|
|
@@ -397,6 +418,13 @@ export function renderOperationView(view: OperationView): string {
|
|
|
397
418
|
return lines.join("\n");
|
|
398
419
|
}
|
|
399
420
|
|
|
421
|
+
function definitionDefaults(fields: OperationView["fields"]): DefinitionDefaults | null {
|
|
422
|
+
const model = fields?.model.value ?? null;
|
|
423
|
+
const effort = fields?.effort.value ?? null;
|
|
424
|
+
const label = formatModelEffort(model, effort);
|
|
425
|
+
return label ? { model, effort, label } : null;
|
|
426
|
+
}
|
|
427
|
+
|
|
400
428
|
function field(value: { value: string | null; source: OperationField["source"]; explicit: boolean }): OperationField {
|
|
401
429
|
return {
|
|
402
430
|
value: value.value,
|
package/agents-catalog-tool.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
|
|
|
37
37
|
return {
|
|
38
38
|
name: "agents_catalog" as const,
|
|
39
39
|
label: "Agents catalog",
|
|
40
|
-
description: "List or inspect role and named-agent definitions, including inheritance, diagnostics, restrictions, and whether launchability is actually known. This tool does not create, import, or edit definitions.",
|
|
40
|
+
description: "List or inspect role and named-agent definitions, including inheritance, default model and effort, diagnostics, restrictions, and whether launchability is actually known. This tool does not create, import, or edit definitions.",
|
|
41
41
|
promptSnippet: "List and inspect catalog roles and named agents, including inheritance and whether launchability is known.",
|
|
42
42
|
promptGuidelines: [
|
|
43
43
|
"Use agents_catalog to discover roles and named agents before subagent_spawn. It is read-only.",
|
|
@@ -46,6 +46,7 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
|
|
|
46
46
|
"Ask the user to run /agents create or /agents import-codex for writes. Those commands confirm role, scope, and import replacement.",
|
|
47
47
|
"Launch with the existing subagent_spawn or batch tool. Pass one agent or one role, not both. Two roles for one run need the user to choose one or split the work.",
|
|
48
48
|
"Put an authoritative model or effort on the structured invocation. Do not rely on copying a model name into the child prompt. Explicit invocation and workflow choices override definition defaults.",
|
|
49
|
+
"Each entry shows its default model@effort when set. Omit model and thinking on the spawn to use it; name one only for a stated reason.",
|
|
49
50
|
],
|
|
50
51
|
parameters: Type.Object({
|
|
51
52
|
action: Type.String({ description: "list or inspect. No other action is accepted." }),
|
package/batch.mjs
CHANGED
|
@@ -215,8 +215,8 @@ export function formatBatchLaunchResponse({ batchId, batchName, launched, skippe
|
|
|
215
215
|
lines.push(
|
|
216
216
|
`Batch ${label} launched ${launched.length} subagent(s):`,
|
|
217
217
|
);
|
|
218
|
-
for (const { name, id } of launched) {
|
|
219
|
-
lines.push(`• ${name} → ${id}`);
|
|
218
|
+
for (const { name, id, modelNote } of launched) {
|
|
219
|
+
lines.push(`• ${name} → ${id}${modelNote ? ` · ${modelNote}` : ""}`);
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
|
package/catalog-runtime.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `spawnSubagentRun`. This module does not grant tools, sandbox modes, or
|
|
5
5
|
* extensions, and it does not read task prose for model choices.
|
|
6
6
|
*/
|
|
7
|
-
import { describeDefaultLaunchCapabilities, LEGACY_CAPABILITY_CONTROLS, type LaunchEnricher, type LaunchEnrichment } from "./agent-inspection.ts";
|
|
7
|
+
import { describeDefaultLaunchCapabilities, formatModelEffort, LEGACY_CAPABILITY_CONTROLS, type LaunchEnricher, type LaunchEnrichment } from "./agent-inspection.ts";
|
|
8
8
|
import { resolveSelection, type EffectiveDefinition } from "./catalog-resolver.ts";
|
|
9
9
|
import { defaultUserRoot, loadCatalog, type CatalogSnapshot } from "./catalog-store.ts";
|
|
10
10
|
import { loadConfig, type SubagentConfig } from "./config.ts";
|
|
@@ -252,6 +252,57 @@ export async function clarifyCatalogRequest(
|
|
|
252
252
|
return { status: "resolved", jobs: ordered.map(stripBookkeeping) };
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Launch-line note for a catalog run whose effective model or effort differs
|
|
257
|
+
* from the role or agent default. The wording names the cause, so a fallback
|
|
258
|
+
* or a capped effort is not mistaken for a caller override:
|
|
259
|
+
* - override: `model openai/gpt-6-astra@high (role default openai/gpt-6-sol@high)`
|
|
260
|
+
* - fallback: `model xai/grok-4.7@high (role default openai/gpt-6-sol@high unavailable; foreground fallback)`
|
|
261
|
+
* - capped effort: `model openai/gpt-6-sol@medium (role default openai/gpt-6-sol@high; effort capped at medium by the model)`
|
|
262
|
+
* Undefined for a non-catalog run, a definition with no default, or a launch
|
|
263
|
+
* that matches the default. Only the fields the definition sets are compared.
|
|
264
|
+
*/
|
|
265
|
+
export function catalogDefaultNote(
|
|
266
|
+
record: Pick<CatalogRunRecord, "kind" | "effective" | "modelSelection" | "effortSelection"> | undefined,
|
|
267
|
+
model: string | undefined,
|
|
268
|
+
thinking: string | undefined,
|
|
269
|
+
): string | undefined {
|
|
270
|
+
if (!record) return undefined;
|
|
271
|
+
const defaultModel = record.effective.model.value;
|
|
272
|
+
const defaultEffort = record.effective.effort.value;
|
|
273
|
+
const defaultLabel = formatModelEffort(defaultModel, defaultEffort);
|
|
274
|
+
if (!defaultLabel) return undefined;
|
|
275
|
+
const modelSource = record.modelSelection?.source;
|
|
276
|
+
const modelFallback = defaultModel !== null && modelSource !== undefined && FALLBACK_SOURCES.has(modelSource);
|
|
277
|
+
const modelDiffers = defaultModel !== null && (modelFallback || !sameModel(defaultModel, model, modelSource));
|
|
278
|
+
const effortCapped = defaultEffort !== null && record.effortSelection?.adjusted === true && (thinking ?? null) !== defaultEffort;
|
|
279
|
+
const effortDiffers = defaultEffort !== null && (thinking ?? null) !== defaultEffort;
|
|
280
|
+
if (!modelDiffers && !effortDiffers) return undefined;
|
|
281
|
+
const actual = formatModelEffort(model ?? "Pi default", thinking) ?? "Pi default";
|
|
282
|
+
const causes = [
|
|
283
|
+
modelFallback ? `${record.kind} default ${defaultLabel} unavailable; ${FALLBACK_LABELS[modelSource!] ?? modelSource} fallback` : `${record.kind} default ${defaultLabel}`,
|
|
284
|
+
...(effortCapped ? [`effort capped at ${thinking ?? "the model default"} by the model`] : []),
|
|
285
|
+
];
|
|
286
|
+
return `model ${actual} (${causes.join("; ")})`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Model sources that mean the default could not be used, not that the caller chose another model. */
|
|
290
|
+
const FALLBACK_SOURCES: ReadonlySet<string> = new Set(["tier-candidate", "foreground", "configured-default"]);
|
|
291
|
+
const FALLBACK_LABELS: Record<string, string> = {
|
|
292
|
+
"tier-candidate": "same-tier",
|
|
293
|
+
foreground: "foreground",
|
|
294
|
+
"configured-default": "configured-default",
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
function sameModel(defaultModel: string, actual: string | undefined, source: string | undefined): boolean {
|
|
298
|
+
// The resolver launched the definition's own preference: same model, however it was spelled.
|
|
299
|
+
if (source === "role-default" || source === "agent-override") return true;
|
|
300
|
+
if (actual === undefined) return false;
|
|
301
|
+
if (defaultModel === actual) return true;
|
|
302
|
+
// A providerless default names the id; the resolved provider/id (the id may itself contain "/") still matches.
|
|
303
|
+
return !defaultModel.includes("/") ? actual.endsWith(`/${defaultModel}`) : false;
|
|
304
|
+
}
|
|
305
|
+
|
|
255
306
|
export async function prepareCatalogJob(snapshot: CatalogSnapshot, job: CatalogJobFields, host: CatalogHost): Promise<PreparedCatalogJob> {
|
|
256
307
|
const agent = agentIdOf(job);
|
|
257
308
|
const roles = roleIdsOf(job);
|
package/child-incidents.ts
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { BashOperations, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
|
|
14
|
+
// The intent validator is shared with background tasks through the vendored failure-observations module (#325).
|
|
15
|
+
import { emptyFailureState, reduceFailure, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent,
|
|
16
|
+
type FailureEvent, type FailureState } from "./shared-failure-observations.ts";
|
|
17
|
+
import { DISPOSITION_TOOL, describeOpenTargets, foldToolEnd, foldToolStart,
|
|
18
|
+
newIncidentModel, readDispositionRequest, resolveDisposition, type IncidentModel, type IncidentSink } from "./incident-model.ts";
|
|
17
19
|
|
|
18
20
|
const { createBashToolDefinition } = PiCodingAgent;
|
|
19
21
|
|
|
@@ -95,7 +97,7 @@ export function intentBashDefinition(cwd: string, operations: BashOperations) {
|
|
|
95
97
|
if (error) throw new Error(`Invalid command intent: ${error}. The command was not run.`);
|
|
96
98
|
if (intent.attemptId) {
|
|
97
99
|
const reused = sessionToolCalls(ctx).some((row) => row.type === "tool_execution_start" && row.toolName === "bash" &&
|
|
98
|
-
row.toolCallId !== toolCallId && (row.args as any)?.attemptId === intent.attemptId);
|
|
100
|
+
row.toolCallId !== toolCallId && (withoutAbsentIntent(row.args) as any)?.attemptId === intent.attemptId);
|
|
99
101
|
if (reused) throw new Error(`Invalid command intent: attemptId ${intent.attemptId} was already used. The command was not run.`);
|
|
100
102
|
}
|
|
101
103
|
const { operationId: _o, attemptId: _a, expectedExitCodes: _e, ...command } = params ?? {};
|
|
@@ -52,6 +52,8 @@ Codex applies the agent file's model and `model_reasoning_effort` ahead of the c
|
|
|
52
52
|
|
|
53
53
|
`agents_catalog` accepts `list` and `inspect` only. It uses the same inspection view as `/agents show`. It has no write path. Launch remains `subagent_spawn` or the batch tool, with at most one `agent` or `role` selector. Those selectors are lifecycle's wiring, not this tool.
|
|
54
54
|
|
|
55
|
+
Each list line and the inspect header end with the definition's default model and effort when one is set, such as `default openai/gpt-6-sol@high`: a role's own default, or a named agent's override or inherited role default. The structured view carries the same value as `defaults: { model, effort, label }`, or `null` when neither is set. A spawn that omits `model` and `thinking` uses it. When a catalog launch's model or effort differs from it, the launch line (and each batch job line) adds a note such as `model openai/gpt-6-astra@high (role default openai/gpt-6-sol@high)`. Only the fields the definition sets are compared. The note names the cause: a fallback from an unavailable default reads `(role default openai/gpt-6-sol@high unavailable; foreground fallback)` (or `same-tier` / `configured-default`), and an effort the model cannot run adds `effort capped at <level> by the model`.
|
|
56
|
+
|
|
55
57
|
## Lifecycle attachment
|
|
56
58
|
|
|
57
59
|
```ts
|
|
@@ -47,6 +47,8 @@ Automatic recovery still requires an exact retry: the same tool name, arguments,
|
|
|
47
47
|
|
|
48
48
|
Sandboxed children (the task runtime) get two structured additions. The parent honours them only for runs it launched on the task runtime: `meta.taskRuntime` and a parent-authored provenance record written before the child starts, outside the run directory, where the confined child cannot write. For any other run, intent-looking arguments are ordinary arguments and `failure_disposition` calls are ignored. If the metadata cannot be read for a moment, the parent waits and reads it again rather than falling back to the exact rule for the rest of the run. The wait is bounded: when the run ends, or after 30 seconds, the log is scanned under the exact-retry rule so real failures stay visible, with an `Observation incomplete` note that the metadata could not be read.
|
|
49
49
|
|
|
50
|
+
Trust is decided once per scan, when the scan begins. A running trusted run whose metadata later becomes unreadable (a corrupt `meta.json`, say) keeps its trusted scan for as long as the parent keeps that scan in memory. Only after a parent restart, a `/reload`, or an eviction from the scan cache does the bounded wait apply again, and the scan after it uses the exact-retry rule with the same note. When the metadata is readable again, a later trusted scan closes the note once the incidents that the exact-rule scan left unresolved are resolved. Failures from the trusted period never hold it open, however often the scan is rebuilt.
|
|
51
|
+
|
|
50
52
|
- `bash` accepts optional `operationId`, `attemptId`, and `expectedExitCodes`, validated before the command runs.
|
|
51
53
|
- `operationId` names one logical operation across modified retries. A later success with the same `operationId` (a changed scope, timeout, or flag) recovers the earlier failure automatically. Without it, the exact rule applies. `attemptId` names one execution for use as evidence and never changes operation identity.
|
|
52
54
|
- `expectedExitCodes` (distinct integers 1–255; a `0` is ignored, since exit 0 is already success) declares intentional non-zero exits, such as `[1]` for an `rg`/`grep` no-match or a `git diff --exit-code` probe. Only the final shell exit code is classified, taken from the tool's structured result, never from output text. Timeouts, aborts, and undeclared codes stay ordinary failures.
|
package/failures.ts
CHANGED
|
@@ -99,9 +99,55 @@ function settleMetadataGap(id: string, scan: Scan): void {
|
|
|
99
99
|
}
|
|
100
100
|
const held = [...intentBearing].some((incident) => findIncident(state, incident)?.status === "unresolved");
|
|
101
101
|
if (held) return;
|
|
102
|
-
observeFailures(failurePath(id), [{ id:
|
|
102
|
+
observeFailures(failurePath(id), [{ id: failureIdentity("run-metadata-readable", gap.id), operation: "run-metadata", kind: "recovered", incidents: [gap.id] }]);
|
|
103
103
|
scan.gap = undefined;
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Scan-period markers (#332). While a "run metadata could not be read" gap is open, each scan that
|
|
107
|
+
* starts under a different rule from the last marked one journals a no-op marker (a `delivered`
|
|
108
|
+
* event naming no incident) whose id records the rule: `run-metadata-scan:<rule>:<journal length>`.
|
|
109
|
+
* The ids are read back by prefix, so they are plain strings, not `failureIdentity` hashes.
|
|
110
|
+
*/
|
|
111
|
+
const SCAN_MARKER_PREFIX = "run-metadata-scan:";
|
|
112
|
+
type ScanRule = "exact" | "trusted";
|
|
113
|
+
function scanRuleAt(marker: string): ScanRule | undefined {
|
|
114
|
+
if (!marker.startsWith(SCAN_MARKER_PREFIX)) return undefined;
|
|
115
|
+
const rule = marker.slice(SCAN_MARKER_PREFIX.length).split(":")[0];
|
|
116
|
+
return rule === "exact" || rule === "trusted" ? rule : undefined;
|
|
117
|
+
}
|
|
118
|
+
/** Journal that a scan under `rule` begins, unless the last marked period already used it. */
|
|
119
|
+
function markScanPeriod(path: string, state: FailureState, rule: ScanRule): FailureState {
|
|
120
|
+
let last: ScanRule | undefined;
|
|
121
|
+
for (let i = state.seen.length - 1; i >= 0 && !last; i -= 1) last = scanRuleAt(state.seen[i]!);
|
|
122
|
+
if (last === rule) return state;
|
|
123
|
+
return observeFailures(path, [{ id: `${SCAN_MARKER_PREFIX}${rule}:${state.seen.length}`, operation: "run-metadata", kind: "delivered", incidents: [] }]);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The unresolved tool incidents first journaled by an exact-rule scan, which a trusted scan cannot
|
|
127
|
+
* re-key. Each incident belongs to the period of the last marker before it, so a failure from a
|
|
128
|
+
* trusted period is never mistaken for a leftover, however often the scan is rebuilt (a parent
|
|
129
|
+
* restart, `/reload`, or cache eviction), and a later exact-rule period's failures always are. An
|
|
130
|
+
* incident before every marker is a leftover only when the journal has no exact-rule marker at all
|
|
131
|
+
* (the first trusted scan of a gap, or a journal from before the markers existed).
|
|
132
|
+
*/
|
|
133
|
+
function exactRuleLeftovers(path: string, prior: FailureState): Set<string> {
|
|
134
|
+
const state = markScanPeriod(path, prior, "trusted");
|
|
135
|
+
const period = new Map<string, ScanRule | undefined>();
|
|
136
|
+
let current: ScanRule | undefined;
|
|
137
|
+
let sawExact = false;
|
|
138
|
+
for (const id of state.seen) {
|
|
139
|
+
const rule = scanRuleAt(id);
|
|
140
|
+
if (rule) { current = rule; sawExact ||= rule === "exact"; continue; }
|
|
141
|
+
period.set(id, current);
|
|
142
|
+
}
|
|
143
|
+
const leftover = (id: string) => {
|
|
144
|
+
if (!period.has(id)) return false;
|
|
145
|
+
const rule = period.get(id);
|
|
146
|
+
return rule === "exact" || (rule === undefined && !sawExact);
|
|
147
|
+
};
|
|
148
|
+
return new Set(Object.values(state.observations)
|
|
149
|
+
.filter((x) => x.category === "tool" && x.status === "unresolved" && leftover(x.id)).map((x) => x.id));
|
|
150
|
+
}
|
|
105
151
|
/** Scan complete source records, independent of the finite progress/transcript tail. */
|
|
106
152
|
export function collectRunFailures(id: string, cwd: string, terminal = false): FailureState {
|
|
107
153
|
const path = failurePath(id);
|
|
@@ -151,6 +197,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
151
197
|
summary: "Run metadata could not be read; tool failures are scanned under the exact-retry rule" }]);
|
|
152
198
|
}
|
|
153
199
|
if (!waited) return prior;
|
|
200
|
+
markScanPeriod(path, readRunFailures(id), "exact");
|
|
154
201
|
scan = { offset: 0, head, identity, model: newIncidentModel(false) };
|
|
155
202
|
} else {
|
|
156
203
|
trustUnknownSince.delete(id);
|
|
@@ -160,8 +207,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
160
207
|
// differently is still unresolved (#332).
|
|
161
208
|
const prior = readRunFailures(id);
|
|
162
209
|
const deferred = prior.observations[failureIdentity("run-metadata")];
|
|
163
|
-
if (deferred && deferred.status === "unresolved") scan.gap = { id: deferred.id,
|
|
164
|
-
before: new Set(Object.values(prior.observations).filter((x) => x.category === "tool" && x.status === "unresolved").map((x) => x.id)) };
|
|
210
|
+
if (deferred && deferred.status === "unresolved") scan.gap = { id: deferred.id, before: exactRuleLeftovers(path, prior) };
|
|
165
211
|
}
|
|
166
212
|
}
|
|
167
213
|
scan.head = head;
|
package/incident-model.ts
CHANGED
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
* `operationId` or the exact tool/arguments/cwd hash; expected exits require a code
|
|
9
9
|
* declared on the attempt before it ran and a structured exit code from the tool.
|
|
10
10
|
*/
|
|
11
|
-
import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS,
|
|
11
|
+
import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS,
|
|
12
12
|
REJECTED_INTENT_CATEGORY, readCommandIntent, withoutAbsentIntent, type CommandIntent, type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
|
|
13
13
|
|
|
14
14
|
export const DISPOSITION_TOOL = "failure_disposition";
|
|
15
|
-
// The intent validator is shared with background tasks through the vendored failure-observations module (#325).
|
|
16
|
-
export { INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent, type CommandIntent };
|
|
17
15
|
|
|
18
16
|
function stable(value: unknown): unknown {
|
|
19
17
|
if (Array.isArray(value)) return value.map(stable);
|
|
@@ -209,10 +207,12 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
|
|
|
209
207
|
}
|
|
210
208
|
const parsed = toolName === "bash" ? readCommandIntent(row.args) : { intent: {} as CommandIntent };
|
|
211
209
|
const intent = parsed.intent;
|
|
212
|
-
// Mirrors the child's check: any earlier bash start that named this attemptId, valid or not.
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
210
|
+
// Mirrors the child's check: any earlier bash start that named this attemptId, valid or not. The name
|
|
211
|
+
// is read after the same normalization the child applies, so `attemptId: ""` or `null` names nothing (#332).
|
|
212
|
+
const namedArgs = toolName === "bash" ? withoutAbsentIntent(row.args) : undefined;
|
|
213
|
+
const namedAttemptId = typeof namedArgs?.attemptId === "string" ? namedArgs.attemptId as string : undefined;
|
|
214
|
+
const reused = namedAttemptId !== undefined && model.namedAttempts.has(namedAttemptId);
|
|
215
|
+
if (namedAttemptId !== undefined) model.namedAttempts.add(namedAttemptId);
|
|
216
216
|
const predictedRejection = parsed.error ?? (reused ? `attemptId ${intent.attemptId} was already used` : undefined);
|
|
217
217
|
// A predicted refusal falls back to exact identity: if the child ran the command after all, it is
|
|
218
218
|
// an ordinary failure of exactly that command, never a failure of the operation it named.
|
package/index.ts
CHANGED
|
@@ -151,6 +151,7 @@ import {
|
|
|
151
151
|
loadLaunchSnapshot,
|
|
152
152
|
noteCatalogHost,
|
|
153
153
|
prepareCatalogJob,
|
|
154
|
+
catalogDefaultNote,
|
|
154
155
|
tiersForLaunch,
|
|
155
156
|
type CatalogHost,
|
|
156
157
|
type CatalogJobFields,
|
|
@@ -175,6 +176,7 @@ const projectConfigDirName = typeof (PiCodingAgent as { CONFIG_DIR_NAME?: unknow
|
|
|
175
176
|
: ".pi";
|
|
176
177
|
|
|
177
178
|
const CATALOG_GUIDELINES = [
|
|
179
|
+
"With an agent or role, omit model and thinking to launch on its default model and effort, which agents_catalog shows. Name a model or effort only for a stated reason, such as a task or workflow instruction, and never copy one from another role's runs.",
|
|
178
180
|
"When a task, workflow, or skill instruction names a model or effort, translate that authoritative choice into the structured model and thinking arguments before spawning. The runtime does not parse prose, quoted model names, or comparisons, and copying a model into the child prompt does not change the launch.",
|
|
179
181
|
"Optional agent, role, and alias select a catalog definition. Pass one agent id or one role id. Pass an array of role ids when one run was given more than one role: that call asks to choose one or split, and without a UI choice it returns clarification-needed and starts no child. Naming both an agent and a role does the same. One job's choice does not change another job. A named agent displays its defined name. A direct role displays the label allocated from the local run registry. Calls without agent or role keep the existing name and model chain.",
|
|
180
182
|
"Catalog model and effort are resolved before the child starts. An unavailable explicit model or unsupported explicit effort does not launch and does not fall back. The catalog grants no tools, sandbox modes, extensions, or permissions.",
|
|
@@ -1551,6 +1553,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1551
1553
|
runtime: string;
|
|
1552
1554
|
warn: string;
|
|
1553
1555
|
sandboxDir?: string;
|
|
1556
|
+
/** Set only when a catalog run's model or effort differs from its role or agent default. */
|
|
1557
|
+
modelNote?: string;
|
|
1554
1558
|
}> {
|
|
1555
1559
|
assertThinkingLevel(p.thinking);
|
|
1556
1560
|
const permissionPlan = resolveSubagentPermissions(pi, p.sandbox);
|
|
@@ -1766,7 +1770,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1766
1770
|
`${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
|
|
1767
1771
|
`Add a toolExtensions entry in config.json.\n`
|
|
1768
1772
|
: "");
|
|
1769
|
-
|
|
1773
|
+
const modelNote = catalogDefaultNote(p.catalog, model, thinking);
|
|
1774
|
+
return { id, meta, spawned, runtime: runtime + (meta.timing ? `${formatTimingLimits(meta.timing, startedAt)}\n` : ""), warn, sandboxDir, modelNote };
|
|
1770
1775
|
}
|
|
1771
1776
|
|
|
1772
1777
|
// ---- subagent_spawn -------------------------------------------------
|
|
@@ -1783,7 +1788,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1783
1788
|
"After subagent_spawn, do NOT call subagent_output or subagent_result in a loop to wait for the result, and do NOT sleep. The run completes on its own and reports back on the next turn.",
|
|
1784
1789
|
"Call subagent_result after a completion or attention callback, or when the user explicitly asks for the result. Use subagent_output only when the user explicitly asks how a run is progressing; never use either tool to poll.",
|
|
1785
1790
|
...SUBAGENT_ORCHESTRATION_GUIDELINES,
|
|
1786
|
-
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child.
|
|
1791
|
+
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Without an agent or role, pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
|
|
1787
1792
|
...CATALOG_GUIDELINES,
|
|
1788
1793
|
"By default the subagent is sandboxed. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Without published settings, legacy write confinement applies. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
|
|
1789
1794
|
"Every run is timed by the harness: a soft deadline (default 30 min) steers the child to wrap up and wakes you once, the run is stopped after grace_minutes (reason deadline), a hard ceiling (default 90 min) stops it without grace (reason ceiling), and no progress for stuck_minutes (default 10) wakes you once (reason stuck). Set deadline_minutes/max_minutes/stuck_minutes to fit the task instead of writing a time limit into the prompt; do not stop a slow child that is still making progress.",
|
|
@@ -1795,8 +1800,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1795
1800
|
agent: Type.Optional(Type.String({ description: "Named agent id (agent.<slug>). Mutually exclusive with role. The navigator shows the defined agent name." })),
|
|
1796
1801
|
role: catalogRoleSchema("direct role launch"),
|
|
1797
1802
|
alias: Type.Optional(Type.String({ description: "Per-run display alias for a direct role launch, such as checkout. Not a reusable agent. Colliding aliases gain a numeric suffix." })),
|
|
1798
|
-
model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort (for example openai/gpt-5.5@high). Providerless patterns are resolved by Pi.
|
|
1799
|
-
thinking: Type.Optional(Type.String({ description: "Reasoning effort for the child: off, minimal, low, medium, high, xhigh, or max
|
|
1803
|
+
model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort (for example openai/gpt-5.5@high). Providerless patterns are resolved by Pi. Omit to use the agent or role default; without one, inherit the foreground model. Put authoritative model choices here; do not rely on prompt text." })),
|
|
1804
|
+
thinking: Type.Optional(Type.String({ description: "Reasoning effort for the child: off, minimal, low, medium, high, xhigh, or max. Omit to use the agent or role default; without one, the Pi/model default." })),
|
|
1800
1805
|
tools: Type.Optional(Type.String({ description: "Tool allowlist: comma-separated names the child may use (e.g. 'read,bash,web_fetch'). This ALSO selects which extensions load — only packages backing a requested tool are loaded. Defaults to the configured safe set." })),
|
|
1801
1806
|
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist, applied on top of the allowlist." })),
|
|
1802
1807
|
clean: Type.Optional(Type.Boolean({ description: "Run a hermetic child with NO extensions at all (only built-ins: read, bash, edit, write). Default false — the extensions backing the requested tools load, so web_fetch and model auth (e.g. xai) work." })),
|
|
@@ -1841,21 +1846,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1841
1846
|
throw new Error(`Choosing split needs ${catalog.jobs.length} subagent slots, but only one was free. Nothing was launched.`);
|
|
1842
1847
|
}
|
|
1843
1848
|
reserved += extra;
|
|
1844
|
-
const launched: { name?: string; id: string }[] = [];
|
|
1849
|
+
const launched: { name?: string; id: string; modelNote?: string }[] = [];
|
|
1845
1850
|
for (const job of catalog.jobs) {
|
|
1846
|
-
const { id } = await spawnSubagentRun(ctx, job);
|
|
1851
|
+
const { id, modelNote } = await spawnSubagentRun(ctx, job);
|
|
1847
1852
|
gate.commit(1);
|
|
1848
1853
|
reserved -= 1;
|
|
1849
|
-
launched.push({ name: job.name, id });
|
|
1854
|
+
launched.push({ name: job.name, id, modelNote });
|
|
1850
1855
|
}
|
|
1851
|
-
return text(launched.map((item) => `Subagent launched: ${item.name ? `${item.name} ` : ""}id=${item.id}.`).join("\n"));
|
|
1856
|
+
return text(launched.map((item) => `Subagent launched: ${item.name ? `${item.name} ` : ""}id=${item.id}.${item.modelNote ? ` ${item.modelNote}.` : ""}`).join("\n"));
|
|
1852
1857
|
}
|
|
1853
1858
|
Object.assign(p, catalog.jobs[0]);
|
|
1854
|
-
const { id, spawned, runtime, warn, sandboxDir } = await spawnSubagentRun(ctx, p);
|
|
1859
|
+
const { id, spawned, runtime, warn, sandboxDir, modelNote } = await spawnSubagentRun(ctx, p);
|
|
1855
1860
|
gate.commit(1);
|
|
1856
1861
|
reserved = 0;
|
|
1857
1862
|
return text(
|
|
1858
1863
|
`Subagent launched: ${p.name ? `${p.name} ` : ""}id=${id} (pid ${spawned.pid}).\n` +
|
|
1864
|
+
(modelNote ? `${modelNote[0]!.toUpperCase()}${modelNote.slice(1)}.\n` : "") +
|
|
1859
1865
|
(p.callback === false
|
|
1860
1866
|
? `Running in the background; the foreground is free. It will finish quietly — read the result with subagent_result id=${id}.\n`
|
|
1861
1867
|
: `Running in the background; the foreground is free. Its result will be posted back here when it finishes.\n`) +
|
|
@@ -1898,7 +1904,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1898
1904
|
agent: Type.Optional(Type.String({ description: "Named agent id applied to jobs that do not select their own agent or role." })),
|
|
1899
1905
|
role: catalogRoleSchema("shared role selector"),
|
|
1900
1906
|
alias: Type.Optional(Type.String({ description: "Direct-role alias applied when a job does not set alias." })),
|
|
1901
|
-
model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort
|
|
1907
|
+
model: Type.Optional(Type.String({ description: "Pi model pattern, preferably provider/id, optionally suffixed with @effort. Omit to use the agent or role default; without one, inherit the foreground model. Put authoritative model choices here; prompt text is not parsed." })),
|
|
1902
1908
|
thinking: Type.Optional(Type.String({ description: "Reasoning effort applied to every job: off, minimal, low, medium, high, xhigh, or max." })),
|
|
1903
1909
|
tools: Type.Optional(Type.String({ description: "Tool allowlist applied to every job." })),
|
|
1904
1910
|
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist applied to every job." })),
|
|
@@ -1997,7 +2003,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1997
2003
|
|
|
1998
2004
|
const names = assignBatchJobNames(p.jobs);
|
|
1999
2005
|
const batchId = nextBatchId();
|
|
2000
|
-
const launched: { name: string; id: string }[] = [];
|
|
2006
|
+
const launched: { name: string; id: string; modelNote?: string }[] = [];
|
|
2001
2007
|
const failed: { name: string; reason: string }[] = [];
|
|
2002
2008
|
const skipped: { name: string }[] = [];
|
|
2003
2009
|
// How many reject-mode reserved slots are still held (not yet committed/released).
|
|
@@ -2035,10 +2041,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2035
2041
|
name = prepared.assign.name;
|
|
2036
2042
|
}
|
|
2037
2043
|
}
|
|
2038
|
-
const { id } = await spawnSubagentRun(ctx, { ...merged, name }, { batchId, batchName: p.batchName });
|
|
2044
|
+
const { id, modelNote } = await spawnSubagentRun(ctx, { ...merged, name }, { batchId, batchName: p.batchName });
|
|
2039
2045
|
gate.commit(1);
|
|
2040
2046
|
if (!launchAvailable) reservedRemaining -= 1;
|
|
2041
|
-
launched.push({ name, id });
|
|
2047
|
+
launched.push({ name, id, ...(modelNote ? { modelNote } : {}) });
|
|
2042
2048
|
} catch (err) {
|
|
2043
2049
|
gate.release(1);
|
|
2044
2050
|
if (!launchAvailable) reservedRemaining -= 1;
|
package/output-payload.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import {
|
|
49
49
|
actionableFailures,
|
|
50
50
|
failureJournalFingerprint,
|
|
51
|
+
fileChangeIdentity,
|
|
51
52
|
failureRevision,
|
|
52
53
|
formatIncidentSummary,
|
|
53
54
|
formatTerminalIncidentSummary,
|
|
@@ -777,16 +778,7 @@ const LIST_INCIDENT_CACHE_MAX = 4096;
|
|
|
777
778
|
let listIncidentComputations = 0;
|
|
778
779
|
|
|
779
780
|
function listIncidentKey(id: string, terminal: boolean): string {
|
|
780
|
-
return JSON.stringify([terminal,
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function logIdentityKey(path: string): string {
|
|
784
|
-
try {
|
|
785
|
-
const stats = statSync(path, { bigint: true });
|
|
786
|
-
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
|
|
787
|
-
} catch (error) {
|
|
788
|
-
return `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
|
|
789
|
-
}
|
|
781
|
+
return JSON.stringify([terminal, fileChangeIdentity(logPathFor(id)), failureJournalFingerprint(failurePath(id))]);
|
|
790
782
|
}
|
|
791
783
|
|
|
792
784
|
export function listIncidentCount(id: string, cwd: string, terminal: boolean): ListIncidentCount {
|
package/package.json
CHANGED
|
@@ -75,7 +75,8 @@ export function failureIdentity(...parts: unknown[]): string {
|
|
|
75
75
|
return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
|
|
76
76
|
}
|
|
77
77
|
function text(value: string | undefined, fallback: string): string {
|
|
78
|
-
|
|
78
|
+
// The 400-unit cut can split a surrogate pair; a lone half would render as U+FFFD (#332).
|
|
79
|
+
return dropLoneSurrogates((value || fallback).replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400));
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/** Agent tool attempts: the agent handles its own tool errors, so one failure is not yet actionable. */
|
|
@@ -517,6 +518,8 @@ export interface TerminalFailureParts {
|
|
|
517
518
|
rows: string[];
|
|
518
519
|
/** Counts and separate facts: earlier-reported, unclassified, expected, closed history, correctness. */
|
|
519
520
|
notes: string[];
|
|
521
|
+
/** The unclassified-count note, when it is among `notes`: a byte budget keeps it longer than the others. */
|
|
522
|
+
unclassifiedNote?: string;
|
|
520
523
|
}
|
|
521
524
|
/**
|
|
522
525
|
* Terminal/completion facts: actionable and incomplete incidents from `incidents` as rows,
|
|
@@ -530,12 +533,14 @@ export function terminalFailureParts(state: FailureState, incidents: readonly st
|
|
|
530
533
|
const earlier = activeFailures(state).filter((x) => !wanted.has(x.id) && reportable(x)).length;
|
|
531
534
|
if (earlier) notes.push(`${earlier} actionable incident${earlier === 1 ? " was" : "s were"} reported earlier; not repeated here.`);
|
|
532
535
|
const counts = failureCounts(state);
|
|
533
|
-
|
|
536
|
+
const unclassifiedNote = counts.unclassified
|
|
537
|
+
? `${counts.unclassified} earlier tool failure${counts.unclassified === 1 ? "" : "s"} remain${counts.unclassified === 1 ? "s" : ""} unclassified.` : undefined;
|
|
538
|
+
if (unclassifiedNote) notes.push(unclassifiedNote);
|
|
534
539
|
if (counts.expected) notes.push(`${counts.expected} expected failure${counts.expected === 1 ? "" : "s"} recorded.`);
|
|
535
540
|
const history = closedHistoryLine(state);
|
|
536
541
|
if (history) notes.push(history);
|
|
537
542
|
if (counts.unclassified || counts.actionRequired) notes.push(CORRECTNESS_NOTE);
|
|
538
|
-
return { rows, notes };
|
|
543
|
+
return { rows, notes, ...(unclassifiedNote ? { unclassifiedNote } : {}) };
|
|
539
544
|
}
|
|
540
545
|
export function formatTerminalFailureFacts(state: FailureState, incidents: readonly string[] = []): string {
|
|
541
546
|
const { rows, notes } = terminalFailureParts(state, incidents);
|
|
@@ -643,7 +648,10 @@ export interface IncidentPageRequest extends IncidentRowOptions {
|
|
|
643
648
|
* Caller-owned incident pages. Whole rows are preferred; a row larger than the
|
|
644
649
|
* page is split at a code-point boundary and resumes at that byte, so pages
|
|
645
650
|
* reconstruct formatFailureLines() exactly (join with "\n" except after a page
|
|
646
|
-
* that `endsPartial`). A page never exceeds `maxBytes
|
|
651
|
+
* that `endsPartial`). A page never exceeds `maxBytes`, so one smaller than the
|
|
652
|
+
* next code point (up to 4 bytes) returns no text, `hasMore`, and a `nextCursor`
|
|
653
|
+
* equal to `cursor`: the caller retries with a larger page, as with
|
|
654
|
+
* pageVerbatimText. Consumers never ask for fewer than 4 bytes.
|
|
647
655
|
*/
|
|
648
656
|
export function pageFailureIncidents(state: FailureState, request: IncidentPageRequest = {}): FailureIncidentPage {
|
|
649
657
|
// A cursor carries its view: following a history cursor stays in history without the flag.
|
|
@@ -761,14 +769,19 @@ export function incidentVerbatimPage(state: FailureState, request: IncidentPageR
|
|
|
761
769
|
* it, so a cache keyed by it never serves stale incident counts.
|
|
762
770
|
*/
|
|
763
771
|
export function failureJournalFingerprint(path: string): string {
|
|
764
|
-
|
|
772
|
+
return `${fileChangeIdentity(path)}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* A file's identity and change stamp (device, inode, size, and nanosecond mtime/ctime), or its read
|
|
776
|
+
* error. Any append, rewrite, replacement, or deletion changes it; for cache keys, never for trust.
|
|
777
|
+
*/
|
|
778
|
+
export function fileChangeIdentity(path: string): string {
|
|
765
779
|
try {
|
|
766
780
|
const stats = statSync(path, { bigint: true });
|
|
767
|
-
|
|
781
|
+
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
|
|
768
782
|
} catch (error) {
|
|
769
|
-
|
|
783
|
+
return `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
|
|
770
784
|
}
|
|
771
|
-
return `${file}|${existsSync(`${path}.observed`) ? 1 : 0}|${pendingWrites.get(path)?.length ?? 0}`;
|
|
772
785
|
}
|
|
773
786
|
|
|
774
787
|
export function incidentCursorAt(state: FailureState, offset: number, resource?: string, options: IncidentRowOptions = {}): string {
|
|
@@ -903,7 +916,7 @@ export function formatTerminalIncidentSummary(state: FailureState, options: Inci
|
|
|
903
916
|
// cursor's retrieval hint, the unclassified count, the cursor, and the count line. The
|
|
904
917
|
// correctness note goes last, and only when it alone does not fit.
|
|
905
918
|
const correctness: string[] = notes.filter((x) => x === CORRECTNESS_NOTE);
|
|
906
|
-
const unclassified = notes.filter((x) => x
|
|
919
|
+
const unclassified = notes.filter((x) => x === parts.unclassifiedNote);
|
|
907
920
|
const low = notes.filter((x) => !correctness.includes(x) && !unclassified.includes(x));
|
|
908
921
|
const ladder: Array<{ mode: Header | undefined; notes: string[] }> = [];
|
|
909
922
|
for (let keep = low.length - 1; keep >= 0; keep -= 1) ladder.push({ mode: "full", notes: [...low.slice(0, keep), ...unclassified, ...correctness] });
|
package/shared-navigator.ts
CHANGED
|
@@ -95,6 +95,13 @@ type NavigatorState = {
|
|
|
95
95
|
editorComponent?: Component;
|
|
96
96
|
/** The editor Pi currently has mounted (the wrapper the factory returned last). */
|
|
97
97
|
editorFocusTarget?: unknown;
|
|
98
|
+
/** How many times that wrapper has rendered, so an overlay can tell whether it is still on screen. */
|
|
99
|
+
editorRenders?: number;
|
|
100
|
+
/**
|
|
101
|
+
* Whether that wrapper rendered between the overlay's last two frames: true while it is on screen,
|
|
102
|
+
* including inside another extension's editor that wraps it. Undefined until two frames compare.
|
|
103
|
+
*/
|
|
104
|
+
editorRenderedLastFrame?: boolean;
|
|
98
105
|
detailOverlayRows?: number;
|
|
99
106
|
dispose?: () => void;
|
|
100
107
|
/** The mounted detail overlay, reused instead of stacking a second one. */
|
|
@@ -632,7 +639,7 @@ function installNavigatorEditor(ui: any, deps: HostDeps): unknown {
|
|
|
632
639
|
|
|
633
640
|
function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
634
641
|
if (inner && typeof inner.render === "function") state().editorComponent = inner as Component;
|
|
635
|
-
const proxy = new Proxy(inner, {
|
|
642
|
+
const proxy: any = new Proxy(inner, {
|
|
636
643
|
get(target, prop) {
|
|
637
644
|
if (prop === "handleInput") {
|
|
638
645
|
return (data: string) => {
|
|
@@ -642,6 +649,13 @@ function wrapEditor(inner: any, deps: HostDeps): unknown {
|
|
|
642
649
|
target.handleInput(data);
|
|
643
650
|
};
|
|
644
651
|
}
|
|
652
|
+
if (prop === "render" && typeof target.render === "function") {
|
|
653
|
+
return (...args: unknown[]) => {
|
|
654
|
+
const s = state();
|
|
655
|
+
if (s.editorFocusTarget === proxy) s.editorRenders = (s.editorRenders ?? 0) + 1;
|
|
656
|
+
return target.render(...args);
|
|
657
|
+
};
|
|
658
|
+
}
|
|
645
659
|
const value = Reflect.get(target, prop);
|
|
646
660
|
return typeof value === "function" ? value.bind(target) : value;
|
|
647
661
|
},
|
|
@@ -709,6 +723,19 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
|
|
|
709
723
|
return false;
|
|
710
724
|
}
|
|
711
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Whether pi-tui has the editor our wrapper returned mounted (#332). Pi mounts whatever the factory
|
|
728
|
+
* returns, so another extension's `setEditorComponent` (a foreign factory, or `undefined` for Pi's
|
|
729
|
+
* default editor) unmounts it, and no key reaches our input handler after that. `undefined` means
|
|
730
|
+
* the host cannot say (no `isComponentMounted`), and callers keep their earlier behaviour.
|
|
731
|
+
*/
|
|
732
|
+
function liveEditorMounted(tui: unknown): boolean | undefined {
|
|
733
|
+
const live = state().editorFocusTarget;
|
|
734
|
+
const check = (tui as { isComponentMounted?: (component: unknown) => boolean } | undefined)?.isComponentMounted;
|
|
735
|
+
if (!live || typeof check !== "function") return undefined;
|
|
736
|
+
try { return check.call(tui, live) === true; } catch { return undefined; }
|
|
737
|
+
}
|
|
738
|
+
|
|
712
739
|
/** Close a detail overlay that is still mounted while the editor has focus. */
|
|
713
740
|
function dismissOverlay(): void {
|
|
714
741
|
const s = state();
|
|
@@ -762,6 +789,7 @@ function openNavigator(): void {
|
|
|
762
789
|
try {
|
|
763
790
|
try { s.dispose?.(); } catch { /* ignore */ }
|
|
764
791
|
s.dispose = undefined;
|
|
792
|
+
s.editorRenderedLastFrame = undefined;
|
|
765
793
|
let disposeToken: (() => void) | undefined;
|
|
766
794
|
let overlayRef: NavigatorState["overlay"];
|
|
767
795
|
let handle: { focus?(): void; unfocus?(options?: { target: unknown }): void } | undefined;
|
|
@@ -774,8 +802,12 @@ function openNavigator(): void {
|
|
|
774
802
|
// Pi restores focus to the editor that was focused when the overlay opened. If an
|
|
775
803
|
// extension re-installed the editor since, that instance is unmounted and every key
|
|
776
804
|
// would be lost; hand focus to the live editor before closing.
|
|
805
|
+
// Only an editor on screen can take focus: after another extension replaced ours, Pi already
|
|
806
|
+
// focused its replacement, and handing focus to our unmounted wrapper would swallow every key.
|
|
807
|
+
// An extension that wraps ours mounts its own component, so pi-tui does not find ours, but ours
|
|
808
|
+
// still renders and handles every key through it: that is still the live editor (#332).
|
|
777
809
|
const liveEditor = s.editorFocusTarget;
|
|
778
|
-
if (liveEditor) {
|
|
810
|
+
if (liveEditor && (liveEditorMounted(tui) !== false || s.editorRenderedLastFrame !== false)) {
|
|
779
811
|
try { handle?.unfocus?.({ target: liveEditor }); } catch { /* ignore */ }
|
|
780
812
|
}
|
|
781
813
|
done(value);
|
|
@@ -952,8 +984,31 @@ function createOverlayComponent(
|
|
|
952
984
|
requestRender();
|
|
953
985
|
}
|
|
954
986
|
|
|
987
|
+
let editorRendersSeen: number | undefined;
|
|
988
|
+
/**
|
|
989
|
+
* Our editor wrapper was uninstalled: close, since no key of ours can reach this overlay again and
|
|
990
|
+
* it would cover the editor that replaced ours (#332). An extension that composes the editor inside
|
|
991
|
+
* its own non-container component still renders ours, so the wrapper also counts as live while it
|
|
992
|
+
* rendered since the previous overlay frame (Pi renders the editor before it composites overlays).
|
|
993
|
+
*/
|
|
994
|
+
function closeIfEditorReplaced(): void {
|
|
995
|
+
const rendersBefore = editorRendersSeen;
|
|
996
|
+
const renders = state().editorRenders ?? 0;
|
|
997
|
+
editorRendersSeen = renders;
|
|
998
|
+
state().editorRenderedLastFrame = rendersBefore === undefined ? undefined : renders !== rendersBefore;
|
|
999
|
+
if (closed || liveEditorMounted(tui) !== false || rendersBefore === undefined || renders !== rendersBefore) return;
|
|
1000
|
+
// Not from inside pi-tui's render pass: hiding an overlay there would change the stack it is drawing.
|
|
1001
|
+
queueMicrotask(() => {
|
|
1002
|
+
if (closed) return;
|
|
1003
|
+
unfocusMainList();
|
|
1004
|
+
close();
|
|
1005
|
+
requestRender();
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
955
1009
|
return {
|
|
956
1010
|
render(width: number) {
|
|
1011
|
+
closeIfEditorReplaced();
|
|
957
1012
|
refreshRows();
|
|
958
1013
|
const railLines = buildMainListLines(overlayState.rows, width, deps.truncate, fg, {
|
|
959
1014
|
selectedId: selectedRow()?.navigatorId,
|
package/shared-sandbox-core.ts
CHANGED
|
@@ -109,8 +109,12 @@ export type SandboxCommandArgs = SandboxTarget & {
|
|
|
109
109
|
internalHelperExecutable?: boolean;
|
|
110
110
|
};
|
|
111
111
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
112
|
+
/**
|
|
113
|
+
* The wrapper command to spawn: the backend executable and its full argv.
|
|
114
|
+
* `notices` are lines the caller shows with the launch output, such as a
|
|
115
|
+
* placeholder the Linux backend had to leave in the user's files.
|
|
116
|
+
*/
|
|
117
|
+
export type SandboxCommand = { file: string; fileArgs: string[]; notices?: string[] };
|
|
114
118
|
|
|
115
119
|
/** The caller's default-on / explicit-request / opt-out decision. */
|
|
116
120
|
export type SandboxRequest = {
|
|
@@ -131,7 +135,11 @@ export type SandboxSeams = {
|
|
|
131
135
|
platform?: () => string;
|
|
132
136
|
/** Defaults to a PATH scan that stats and access-checks without executing. */
|
|
133
137
|
lookupExecutable?: (name: string) => string | undefined;
|
|
134
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Defaults to `fs.realpathSync.native`, which (unlike the JS `realpathSync`)
|
|
140
|
+
* returns the on-disk case of every existing component on a case-insensitive
|
|
141
|
+
* volume, as the kernel sees it. Must throw when the path does not exist.
|
|
142
|
+
*/
|
|
135
143
|
canonicalize?: (path: string) => string;
|
|
136
144
|
/** Defaults to `fs.writeFileSync`. */
|
|
137
145
|
writeProfile?: (path: string, contents: string) => void;
|
|
@@ -325,7 +333,7 @@ function currentPlatform(seams: SandboxSeams): string {
|
|
|
325
333
|
* canonicalizes through its real parent chain.
|
|
326
334
|
*/
|
|
327
335
|
export function canonicalizePath(path: string, seams: SandboxSeams = {}): string {
|
|
328
|
-
const canonicalize = seams.canonicalize ?? realpathSync;
|
|
336
|
+
const canonicalize = seams.canonicalize ?? realpathSync.native;
|
|
329
337
|
const absolute = resolve(path);
|
|
330
338
|
try {
|
|
331
339
|
const resolved = canonicalize(absolute);
|
|
@@ -337,7 +345,7 @@ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string
|
|
|
337
345
|
try {
|
|
338
346
|
const source = statSync(resolved);
|
|
339
347
|
const alias = statSync(candidate);
|
|
340
|
-
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync(candidate);
|
|
348
|
+
if (source.dev === alias.dev && source.ino === alias.ino) return realpathSync.native(candidate);
|
|
341
349
|
} catch { /* An unrelated Data-volume path keeps its original identity. */ }
|
|
342
350
|
}
|
|
343
351
|
return resolved;
|
|
@@ -358,7 +366,7 @@ function compile(
|
|
|
358
366
|
// bind-mounts it; the macOS backend has always tolerated a not-yet-created
|
|
359
367
|
// one. Keep both behaviors rather than unifying them here.
|
|
360
368
|
const writableRoot = strictRoot
|
|
361
|
-
? (seams.canonicalize ?? realpathSync)(policy.writableRoot)
|
|
369
|
+
? (seams.canonicalize ?? realpathSync.native)(policy.writableRoot)
|
|
362
370
|
: canonicalizePath(policy.writableRoot, seams);
|
|
363
371
|
|
|
364
372
|
// The broad profile also keeps each literal entry: its removal grants would
|
|
@@ -544,6 +552,67 @@ function contains(root: string, target: string): boolean {
|
|
|
544
552
|
return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
545
553
|
}
|
|
546
554
|
|
|
555
|
+
/** Per-device answer to "does this volume fold case?" (APFS/HFS+ default). */
|
|
556
|
+
const caseFoldingByDevice = new Map<number, boolean>();
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Whether the volume holding `path` (its nearest existing ancestor) ignores
|
|
560
|
+
* case: an existing component whose case-swapped spelling names the same
|
|
561
|
+
* inode proves it. macOS only; false whenever it cannot be shown.
|
|
562
|
+
*/
|
|
563
|
+
function caseInsensitiveVolume(path: string): boolean {
|
|
564
|
+
if (process.platform !== "darwin") return false;
|
|
565
|
+
let current = path;
|
|
566
|
+
while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
|
|
567
|
+
let device: number;
|
|
568
|
+
try { device = statSync(current).dev; } catch { return false; }
|
|
569
|
+
const known = caseFoldingByDevice.get(device);
|
|
570
|
+
if (known !== undefined) return known;
|
|
571
|
+
for (let probe = current; dirname(probe) !== probe; probe = dirname(probe)) {
|
|
572
|
+
const name = basename(probe);
|
|
573
|
+
const swapped = [...name].map((c) => c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()).join("");
|
|
574
|
+
if (swapped === name) continue;
|
|
575
|
+
try {
|
|
576
|
+
const original = statSync(probe);
|
|
577
|
+
if (original.dev !== device) break;
|
|
578
|
+
let folded = false;
|
|
579
|
+
try {
|
|
580
|
+
const other = statSync(join(dirname(probe), swapped));
|
|
581
|
+
folded = other.dev === original.dev && other.ino === original.ino;
|
|
582
|
+
} catch { /* The swapped name is absent: case-sensitive. */ }
|
|
583
|
+
caseFoldingByDevice.set(device, folded);
|
|
584
|
+
return folded;
|
|
585
|
+
} catch { return false; }
|
|
586
|
+
}
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Spell `path` the way the policy spells it when they differ only in case on a
|
|
592
|
+
* case-insensitive volume. `canonicalizePath` fixes the case of every existing
|
|
593
|
+
* component, but a missing tail (`~/.NPMRC` while `~/.npmrc` is absent) keeps
|
|
594
|
+
* the caller's case, and the kernel still treats it as the protected name. The
|
|
595
|
+
* longest policy path that matches case-insensitively wins, so the pre-check
|
|
596
|
+
* decides as the kernel does (#354).
|
|
597
|
+
*/
|
|
598
|
+
function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): string {
|
|
599
|
+
const broad = policy.broad;
|
|
600
|
+
const candidates = [policy.writableRoot, ...policy.denyWrite, ...(policy.credentialPaths ?? []),
|
|
601
|
+
...(policy.runtimeWrite ?? []), ...(policy.compatibilityWrite ?? []),
|
|
602
|
+
...(broad ? [broad.home, ...broad.tempRoots, ...broad.deleteRoots, ...broad.codePaths] : [])];
|
|
603
|
+
const lower = path.toLowerCase();
|
|
604
|
+
if (lower.length !== path.length) return path;
|
|
605
|
+
let best: string | undefined;
|
|
606
|
+
for (const candidate of candidates) {
|
|
607
|
+
const folded = candidate.toLowerCase();
|
|
608
|
+
if (folded.length !== candidate.length || !contains(folded, lower)) continue;
|
|
609
|
+
if (!best || candidate.length > best.length) best = candidate;
|
|
610
|
+
}
|
|
611
|
+
// Already spelled as the policy spells it: nothing to probe.
|
|
612
|
+
if (!best || path.startsWith(best) || !caseInsensitiveVolume(path)) return path;
|
|
613
|
+
return `${best}${path.slice(best.length)}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
547
616
|
function isCredential(path: string, policy: CompiledSandboxWritePolicy): boolean {
|
|
548
617
|
return (policy.credentialPaths ?? []).some((credential) => contains(credential, path));
|
|
549
618
|
}
|
|
@@ -558,7 +627,7 @@ export function evaluateReadAccess(
|
|
|
558
627
|
policy: CompiledSandboxWritePolicy,
|
|
559
628
|
seams: SandboxSeams = {},
|
|
560
629
|
): ReadAccessDecision {
|
|
561
|
-
const path = canonicalizePath(target, seams);
|
|
630
|
+
const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
|
|
562
631
|
const permissions = policy.permissions;
|
|
563
632
|
if (!permissions) return { allowed: true, path };
|
|
564
633
|
if (policy.broad) {
|
|
@@ -585,7 +654,7 @@ export function evaluateWriteAccess(
|
|
|
585
654
|
policy: CompiledSandboxWritePolicy,
|
|
586
655
|
seams: SandboxSeams = {},
|
|
587
656
|
): WriteAccessDecision {
|
|
588
|
-
const path = canonicalizePath(target, seams);
|
|
657
|
+
const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
|
|
589
658
|
if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
|
|
590
659
|
if (!policy.permissions && !contains(policy.writableRoot, path)) {
|
|
591
660
|
return { allowed: false, path, reason: "outside-writable-root" };
|
|
@@ -1370,10 +1439,28 @@ function buildLinuxBroadCommand(
|
|
|
1370
1439
|
const readOnly = new Set<string>();
|
|
1371
1440
|
const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
|
|
1372
1441
|
.some((name) => canonicalizePath(join(broad.home, name), seams) === path);
|
|
1442
|
+
// The literal fixed-list entry (a dangling `~/.ssh` link) or its canonical path.
|
|
1443
|
+
const directoryEntry = (path: string) => directoryLocation(path) ||
|
|
1444
|
+
[...DIRECTORY_LOCATIONS].some((name) => join(broad.home, name) === path);
|
|
1373
1445
|
// Directories holding a protected symlink: read-only, so the link cannot be
|
|
1374
1446
|
// replaced, with their existing entries bound writable again below.
|
|
1375
1447
|
const linkParents = new Set<string>();
|
|
1376
|
-
|
|
1448
|
+
// Where dangling links would land. Only the first missing component of each
|
|
1449
|
+
// needs a guard: nothing can be created below a read-only mount or a locked
|
|
1450
|
+
// parent. It is a directory when more of the path lies below it, or when the
|
|
1451
|
+
// link is a directory-type entry (`~/.ssh -> ~/work/ssh`).
|
|
1452
|
+
const firstMissing = new Map<string, boolean>();
|
|
1453
|
+
const belowFirstMissing = new Set<string>();
|
|
1454
|
+
for (const path of protectedPaths) {
|
|
1455
|
+
const unresolved = walkSymlinks(path).unresolved;
|
|
1456
|
+
if (!unresolved.length) continue;
|
|
1457
|
+
const first = unresolved[0]!;
|
|
1458
|
+
const full = unresolved.at(-1)!;
|
|
1459
|
+
firstMissing.set(first, firstMissing.get(first) === true || first !== full || directoryEntry(path));
|
|
1460
|
+
if (full !== first) belowFirstMissing.add(full);
|
|
1461
|
+
}
|
|
1462
|
+
const created: string[] = [];
|
|
1463
|
+
const makeDirectory = seams.makeDirectory ?? ((target: string) => mkdirSync(target, { recursive: true, mode: 0o700 }));
|
|
1377
1464
|
const guard = (path: string, hide: boolean) => {
|
|
1378
1465
|
let directory: boolean | undefined;
|
|
1379
1466
|
let link = false;
|
|
@@ -1387,21 +1474,39 @@ function buildLinuxBroadCommand(
|
|
|
1387
1474
|
if (link && dirname(path) !== broad.home && writableAt(dirname(path))) linkParents.add(dirname(path));
|
|
1388
1475
|
return;
|
|
1389
1476
|
}
|
|
1390
|
-
if (
|
|
1477
|
+
if (belowFirstMissing.has(path) && !firstMissing.has(path)) return; // Its first missing component is guarded.
|
|
1478
|
+
if (firstMissing.has(path)) {
|
|
1391
1479
|
// Where a dangling link would land: nothing may be created there.
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
// is
|
|
1480
|
+
// A missing directory gets an empty read-only placeholder directory,
|
|
1481
|
+
// so only that one name is taken, not every new entry beside it
|
|
1482
|
+
// (`~/.cache/<tool>/…` would otherwise lock all of `~/.cache`). A
|
|
1483
|
+
// missing file is not planted in the user's files: its parent is
|
|
1484
|
+
// locked instead (entries stay writable, see below), except in the
|
|
1485
|
+
// workspace root and temp, which must keep accepting new entries,
|
|
1486
|
+
// so the file placeholder is bound there. Each placeholder made is
|
|
1487
|
+
// reported, and the workspace and temp fail closed without one.
|
|
1396
1488
|
if (!writableAt(path)) return;
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1489
|
+
const ancestor = dirname(path);
|
|
1490
|
+
if (ancestor === broad.home) return; // Home is read-only already.
|
|
1491
|
+
const failClosed = ancestor === project || broad.tempRoots.includes(ancestor);
|
|
1492
|
+
const directory = firstMissing.get(path)!;
|
|
1493
|
+
if (directory || failClosed) {
|
|
1494
|
+
const existed = exists(path);
|
|
1495
|
+
let made: boolean;
|
|
1496
|
+
if (directory) {
|
|
1497
|
+
try { makeDirectory(path); } catch { /* Re-checked below. */ }
|
|
1498
|
+
made = exists(path);
|
|
1499
|
+
} else {
|
|
1500
|
+
made = materialize(path);
|
|
1501
|
+
}
|
|
1502
|
+
if (made) {
|
|
1503
|
+
if (!existed) created.push(`${path}${directory ? "/" : ""}`);
|
|
1504
|
+
readOnly.add(path);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (failClosed) throw new Error(`Cannot protect denied path: ${path}`);
|
|
1404
1508
|
}
|
|
1509
|
+
linkParents.add(ancestor);
|
|
1405
1510
|
return;
|
|
1406
1511
|
}
|
|
1407
1512
|
if (!exists(path)) {
|
|
@@ -1476,6 +1581,9 @@ function buildLinuxBroadCommand(
|
|
|
1476
1581
|
file: bwrap,
|
|
1477
1582
|
fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
|
|
1478
1583
|
"--", args.execPath, ...args.execArgs],
|
|
1584
|
+
...(created.length && { notices: [
|
|
1585
|
+
`Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place: ${created.join(", ")}`,
|
|
1586
|
+
] }),
|
|
1479
1587
|
};
|
|
1480
1588
|
}
|
|
1481
1589
|
|
package/shared-task-files.ts
CHANGED
|
@@ -123,7 +123,7 @@ export function removeDecision(path: string, policy: CompiledSandboxWritePolicy)
|
|
|
123
123
|
const literalLeaf: SandboxSeams = {
|
|
124
124
|
canonicalize: (candidate) => {
|
|
125
125
|
if (candidate === entry) throw new Error("the leaf entry is judged literally");
|
|
126
|
-
return realpathSync(candidate);
|
|
126
|
+
return realpathSync.native(candidate);
|
|
127
127
|
},
|
|
128
128
|
};
|
|
129
129
|
return evaluateDeleteAccess(entry, policy, literalLeaf);
|
package/shared-task-sandbox.ts
CHANGED
|
@@ -124,6 +124,7 @@ export function createTaskBashOperations(
|
|
|
124
124
|
.map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
|
|
125
125
|
}, { sandboxEnabled: true, explicitSandbox: true });
|
|
126
126
|
if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
|
|
127
|
+
for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
|
|
127
128
|
if (options.signal?.aborted) throw new Error("aborted");
|
|
128
129
|
if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
|
|
129
130
|
throw new Error("Invalid timeout: must be a positive, supported number of seconds");
|
package/task-policy.ts
CHANGED
|
@@ -90,7 +90,7 @@ export function prepareTaskRuntime(options: {
|
|
|
90
90
|
}
|
|
91
91
|
const support = describeSandboxSupport();
|
|
92
92
|
if (!support.supported) throw new Error(`Task sandbox unavailable: ${support.reason}`);
|
|
93
|
-
const root = realpathSync(options.root);
|
|
93
|
+
const root = realpathSync.native(options.root);
|
|
94
94
|
const home = homedir();
|
|
95
95
|
if (root === dirname(root) || root === canonicalizePath(home)) throw new Error("Task sandbox requires a project directory, not the filesystem or home root.");
|
|
96
96
|
const permissions = options.permissions ?? DEFAULT_TASK_PERMISSIONS;
|