@bermudi/pi-delegate 0.1.13 → 0.1.15
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 +14 -17
- package/delegate.ts +0 -8
- package/dispatch.ts +25 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +107 -21
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +559 -80
- package/manual.ts +1 -1
- package/model.ts +6 -1
- package/package.json +13 -9
- package/pool.ts +37 -6
- package/quiescence.ts +19 -6
- package/render-branches.ts +243 -85
- package/render-result.ts +116 -16
- package/runner.ts +361 -82
- package/session-quarantine.ts +266 -0
- package/spill.ts +93 -22
- package/task-resolution.ts +63 -50
- package/telemetry.ts +1 -1
- package/ticket-format.ts +15 -9
- package/tickets.ts +36 -14
- package/tools.ts +5 -4
- package/types.ts +92 -8
- package/utils.ts +123 -2
- package/workspace.ts +133 -0
- package/settings.ts +0 -418
package/ticket-format.ts
CHANGED
|
@@ -10,9 +10,10 @@ import {
|
|
|
10
10
|
relativeTouchedSummary,
|
|
11
11
|
findTouchedOverlaps,
|
|
12
12
|
formatTouchedOverlapWarning,
|
|
13
|
+
resumeMarker,
|
|
13
14
|
} from "./format.ts";
|
|
14
15
|
import { renderOutputForPoll } from "./spill.ts";
|
|
15
|
-
import { getOutputSpillTail } from "./config.ts";
|
|
16
|
+
import { getOutputSpillTail, getOutputSpillThreshold } from "./config.ts";
|
|
16
17
|
import type {
|
|
17
18
|
AsyncTicket,
|
|
18
19
|
DelegateDetails,
|
|
@@ -93,12 +94,12 @@ export function formatInFlightTaskLine(p: TaskProgress): string {
|
|
|
93
94
|
if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
|
|
94
95
|
const age = getActivityAge(p.lastActivityAt);
|
|
95
96
|
if (age) parts.push(age);
|
|
96
|
-
return `⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`;
|
|
97
|
+
return `⏳ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · ${parts.join(" · ")}`;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
/** Queued-task line shared by poll snapshots and cancel previews. */
|
|
100
101
|
export function formatQueuedTaskLine(p: TaskProgress): string {
|
|
101
|
-
return `○ ${p.agent}${formatTaskId(p.id)} · waiting…`;
|
|
102
|
+
return `○ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · waiting…`;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
function appendTouchedMeta(
|
|
@@ -120,23 +121,28 @@ function formatSettledPollLines(
|
|
|
120
121
|
const meta = taskMetaBase(result);
|
|
121
122
|
appendTouchedMeta(meta, result, task);
|
|
122
123
|
const tailChars = getOutputSpillTail(ticket.config);
|
|
124
|
+
const thresholdChars = getOutputSpillThreshold(ticket.config);
|
|
123
125
|
if (!failed) {
|
|
124
126
|
const lines = [
|
|
125
|
-
`✓ ${result.agent}${formatTaskId(result.id)} · ${meta.join(" · ")}`,
|
|
127
|
+
`✓ ${result.agent}${resumeMarker(result)}${formatTaskId(result.id)} · ${meta.join(" · ")}`,
|
|
126
128
|
];
|
|
127
129
|
if (result.output && result.output !== "(no output)") {
|
|
128
|
-
lines.push(
|
|
130
|
+
lines.push(
|
|
131
|
+
renderOutputForPoll(result.output, { tailChars, thresholdChars }),
|
|
132
|
+
);
|
|
129
133
|
}
|
|
130
134
|
return lines;
|
|
131
135
|
}
|
|
132
136
|
const errorText = result.error ?? "unknown error";
|
|
133
137
|
const lines = [
|
|
134
|
-
`✗ ${result.agent}${formatTaskId(result.id)} · ${errorText} · ${meta.join(" · ")}`,
|
|
138
|
+
`✗ ${result.agent}${resumeMarker(result)}${formatTaskId(result.id)} · ${errorText} · ${meta.join(" · ")}`,
|
|
135
139
|
];
|
|
136
140
|
if (result.sessionFile)
|
|
137
141
|
lines.push(` session: ${shortenPath(result.sessionFile)}`);
|
|
138
142
|
if (result.output && result.output !== "(no output)") {
|
|
139
|
-
lines.push(
|
|
143
|
+
lines.push(
|
|
144
|
+
renderOutputForPoll(result.output, { tailChars, thresholdChars }),
|
|
145
|
+
);
|
|
140
146
|
}
|
|
141
147
|
return lines;
|
|
142
148
|
}
|
|
@@ -268,9 +274,9 @@ export function formatCancelPreview(ticket: AsyncTicket): string {
|
|
|
268
274
|
|
|
269
275
|
for (const p of ticket.progress) {
|
|
270
276
|
if (p.status === "done") {
|
|
271
|
-
lines.push(`✓ ${p.agent}${formatTaskId(p.id)} · completed`);
|
|
277
|
+
lines.push(`✓ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · completed`);
|
|
272
278
|
} else if (p.status === "failed") {
|
|
273
|
-
lines.push(`✗ ${p.agent}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
|
|
279
|
+
lines.push(`✗ ${p.agent}${resumeMarker(p)}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
|
|
274
280
|
} else if (p.status === "running") {
|
|
275
281
|
lines.push(formatInFlightTaskLine(p));
|
|
276
282
|
} else {
|
package/tickets.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
fmtDuration,
|
|
12
12
|
formatCompletedTask,
|
|
13
13
|
formatTaskId,
|
|
14
|
+
resumeMarker,
|
|
14
15
|
trunc,
|
|
15
16
|
findTouchedOverlaps,
|
|
16
17
|
formatTouchedOverlapWarning,
|
|
@@ -94,8 +95,13 @@ export const ticketRegistry = new TicketRegistry();
|
|
|
94
95
|
|
|
95
96
|
/** Generate a short human-copyable identifier for an async ticket. */
|
|
96
97
|
export function generateTicketId(): string {
|
|
97
|
-
//
|
|
98
|
-
|
|
98
|
+
// Retry on the extremely unlikely collision rather than allowing Map.set()
|
|
99
|
+
// in dispatch to replace a still-retained ticket.
|
|
100
|
+
let id: string;
|
|
101
|
+
do {
|
|
102
|
+
id = Math.random().toString(36).slice(2, 10);
|
|
103
|
+
} while (!id || ticketRegistry.has(id));
|
|
104
|
+
return id;
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
/** Remove completed tickets after their retention TTL. Running tickets have no
|
|
@@ -220,13 +226,19 @@ export function resolveFinalTicketStatus(
|
|
|
220
226
|
export function formatCompletedTicket(
|
|
221
227
|
ticket: AsyncTicket,
|
|
222
228
|
): AgentToolResult<DelegateDetails> {
|
|
229
|
+
// Shutdown can make a ticket terminal while its workers are still unwinding.
|
|
230
|
+
// Do not freeze that partial projection: late TaskResults must appear in a
|
|
231
|
+
// later poll once the worker-settled barrier has resolved. Tickets created
|
|
232
|
+
// before this marker existed (including simple fixtures) are already safe to
|
|
233
|
+
// memoize because only live async dispatches explicitly set it false.
|
|
234
|
+
const canMemoize = ticket.workersSettled !== false;
|
|
235
|
+
if (canMemoize && ticket.formattedResult) return ticket.formattedResult;
|
|
236
|
+
|
|
223
237
|
const parts: string[] = [];
|
|
224
238
|
const succeeded = ticket.results.filter(
|
|
225
239
|
(r) => r && !("error" in r && r.error),
|
|
226
240
|
).length;
|
|
227
|
-
const elapsedTotal = ticket.completedAt
|
|
228
|
-
? ticket.completedAt - ticket.created
|
|
229
|
-
: 0;
|
|
241
|
+
const elapsedTotal = (ticket.completedAt ?? Date.now()) - ticket.created;
|
|
230
242
|
// Surface the overall ticket status so a failed/cancelled batch is not
|
|
231
243
|
// mistaken for success. "done" tickets keep the original header; others
|
|
232
244
|
// get an explicit status tag up front.
|
|
@@ -257,7 +269,7 @@ export function formatCompletedTicket(
|
|
|
257
269
|
const t = ticket.resolved[i]!;
|
|
258
270
|
if (!r) {
|
|
259
271
|
parts.push(
|
|
260
|
-
`=== ${t.agentName}${formatTaskId(t.id)}: ${trunc(t.prompt || "", 80)} ===`,
|
|
272
|
+
`=== ${t.agentName}${resumeMarker(ticket.progress[i]!)}${formatTaskId(t.id)}: ${trunc(t.prompt || "", 80)} ===`,
|
|
261
273
|
);
|
|
262
274
|
parts.push(`[${pendingLabelFor(i)}]`);
|
|
263
275
|
continue;
|
|
@@ -280,7 +292,7 @@ export function formatCompletedTicket(
|
|
|
280
292
|
);
|
|
281
293
|
}
|
|
282
294
|
|
|
283
|
-
|
|
295
|
+
const formatted: AgentToolResult<DelegateDetails> = {
|
|
284
296
|
content: [{ type: "text", text: parts.join("\n\n") }],
|
|
285
297
|
details: {
|
|
286
298
|
tasks: ticket.tasks,
|
|
@@ -297,10 +309,13 @@ export function formatCompletedTicket(
|
|
|
297
309
|
// the human sees which ticket they polled, even in the rich tree path.
|
|
298
310
|
ticketId: ticket.id,
|
|
299
311
|
status: ticket.status,
|
|
312
|
+
elapsedMs: elapsedTotal,
|
|
300
313
|
overlapWarning: overlapWarning || undefined,
|
|
301
314
|
dispatchWarning: ticket.dispatchWarning,
|
|
302
315
|
},
|
|
303
316
|
};
|
|
317
|
+
if (canMemoize) ticket.formattedResult = formatted;
|
|
318
|
+
return formatted;
|
|
304
319
|
}
|
|
305
320
|
|
|
306
321
|
// ── Waiter helpers ─────────────────────────────────────────────────────────
|
|
@@ -309,6 +324,7 @@ function pendingResultPlaceholder(task: ResolvedTask | undefined): TaskResult {
|
|
|
309
324
|
return {
|
|
310
325
|
id: task?.id,
|
|
311
326
|
agent: task?.agentName ?? "unknown",
|
|
327
|
+
resumedFrom: task?.resumeFromDisplay,
|
|
312
328
|
output: "",
|
|
313
329
|
durationMs: 0,
|
|
314
330
|
tokens: 0,
|
|
@@ -340,6 +356,7 @@ function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
|
|
|
340
356
|
parentModel: ticket.parentModelId,
|
|
341
357
|
ticketId: ticket.id,
|
|
342
358
|
status: ticket.status,
|
|
359
|
+
elapsedMs: (ticket.completedAt ?? Date.now()) - ticket.created,
|
|
343
360
|
overlapWarning: overlapWarning || undefined,
|
|
344
361
|
dispatchWarning: ticket.dispatchWarning,
|
|
345
362
|
};
|
|
@@ -427,6 +444,9 @@ function settleWaiter(
|
|
|
427
444
|
if (w.settled) return;
|
|
428
445
|
w.settled = true;
|
|
429
446
|
w.clearDeadline?.();
|
|
447
|
+
w.clearDeadline = undefined;
|
|
448
|
+
w.removeAbortListener?.();
|
|
449
|
+
w.removeAbortListener = undefined;
|
|
430
450
|
w.resolve(result);
|
|
431
451
|
}
|
|
432
452
|
|
|
@@ -573,6 +593,7 @@ export function deliverTicketResults(
|
|
|
573
593
|
...formatted.details,
|
|
574
594
|
ticketId: ticket.id,
|
|
575
595
|
status: ticket.status,
|
|
596
|
+
crossLeafDelivery: crossLeaf,
|
|
576
597
|
},
|
|
577
598
|
},
|
|
578
599
|
crossLeaf
|
|
@@ -626,6 +647,7 @@ export function handlePoll(
|
|
|
626
647
|
// (friction #2). The LLM-facing content still names the ticket id too.
|
|
627
648
|
ticketId: ticket.id,
|
|
628
649
|
status: ticket.status,
|
|
650
|
+
elapsedMs: Date.now() - ticket.created,
|
|
629
651
|
overlapWarning: snapshot.overlapWarning || undefined,
|
|
630
652
|
dispatchWarning: ticket.dispatchWarning,
|
|
631
653
|
},
|
|
@@ -747,13 +769,13 @@ export function handleWait(
|
|
|
747
769
|
};
|
|
748
770
|
|
|
749
771
|
if (signal) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
772
|
+
const onAbort = () => {
|
|
773
|
+
abortWaiter(waiter, ticket);
|
|
774
|
+
};
|
|
775
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
776
|
+
waiter.removeAbortListener = () => {
|
|
777
|
+
signal.removeEventListener("abort", onAbort);
|
|
778
|
+
};
|
|
757
779
|
}
|
|
758
780
|
|
|
759
781
|
if (
|
package/tools.ts
CHANGED
|
@@ -35,10 +35,11 @@ const PROVIDER_TOOLS: Readonly<Record<string, readonly string[]>> = {
|
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
export function availableToolNames(modelProvider?: string): string[] {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
const providerTools =
|
|
39
|
+
modelProvider && Object.hasOwn(PROVIDER_TOOLS, modelProvider)
|
|
40
|
+
? PROVIDER_TOOLS[modelProvider]
|
|
41
|
+
: undefined;
|
|
42
|
+
return [...Object.keys(TOOL_FACTORIES), ...(providerTools ?? [])];
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
|
package/types.ts
CHANGED
|
@@ -70,6 +70,7 @@ export interface TicketWaiter {
|
|
|
70
70
|
resolve: (result: AgentToolResult<DelegateDetails>) => void;
|
|
71
71
|
reject: (reason: unknown) => void;
|
|
72
72
|
clearDeadline?: () => void;
|
|
73
|
+
removeAbortListener?: () => void;
|
|
73
74
|
settled: boolean;
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -112,6 +113,10 @@ export interface AsyncTicket {
|
|
|
112
113
|
/** Immutable dispatch-scoped delegate.json snapshot used by async workers and
|
|
113
114
|
* later result formatting. */
|
|
114
115
|
config?: import("./config.ts").DelegateConfig;
|
|
116
|
+
/** Memoized terminal projection, populated only after workers settle. Besides
|
|
117
|
+
* avoiding repeated work, this keeps repeated poll/wait calls from creating
|
|
118
|
+
* duplicate output spill files without freezing a shutdown-time partial result. */
|
|
119
|
+
formattedResult?: AgentToolResult<DelegateDetails>;
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
/** Live parent settings captured when a delegate call starts. The built-in
|
|
@@ -143,6 +148,14 @@ export interface ResolvedTask {
|
|
|
143
148
|
sessionId?: string;
|
|
144
149
|
sessionAction?: SessionAction;
|
|
145
150
|
resumeFrom?: string;
|
|
151
|
+
/** Display tag (`formatResumeTag`) of the *caller's* `resumeFrom` path, frozen
|
|
152
|
+
* once at task resolution. `resumeFrom` itself is later replaced by the
|
|
153
|
+
* canonical transcript path for locking/acquisition/quarantine, which can
|
|
154
|
+
* have a different basename (symlink alias vs target). Settled-result and
|
|
155
|
+
* progress `resumedFrom` tags must read this field — never re-derive from the
|
|
156
|
+
* now-canonical `resumeFrom` — so the live and settled rows agree and
|
|
157
|
+
* `resumeMarker`'s no-duplication rule holds. */
|
|
158
|
+
resumeFromDisplay?: string;
|
|
146
159
|
/** Hard wall-clock budget in milliseconds, measured from task start. */
|
|
147
160
|
deadlineMs?: number;
|
|
148
161
|
agentName: string;
|
|
@@ -156,10 +169,45 @@ export interface ResolvedTask {
|
|
|
156
169
|
providerExtensionSources?: string;
|
|
157
170
|
}
|
|
158
171
|
|
|
172
|
+
export interface FileAttributionPathSignature {
|
|
173
|
+
/** Absolute component inspected while resolving the pre-execution target. */
|
|
174
|
+
path: string;
|
|
175
|
+
/** Filesystem identity. Strings avoid precision loss on platforms whose
|
|
176
|
+
* inode/device values exceed JavaScript's safe integer range. */
|
|
177
|
+
dev: string;
|
|
178
|
+
ino: string;
|
|
179
|
+
birthtimeMs: number;
|
|
180
|
+
kind: "directory" | "symlink" | "other";
|
|
181
|
+
/** Exact link text, present only for symlinks. */
|
|
182
|
+
symlinkTarget?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface FileAttribution {
|
|
186
|
+
/** Absolute path spelled by the tool call. This remains associated with the
|
|
187
|
+
* physical snapshot so a later symlink cannot change what the call targeted. */
|
|
188
|
+
lexicalPath: string;
|
|
189
|
+
/** Physical target captured synchronously at tool_execution_start. Consumers
|
|
190
|
+
* must not resolve this path again against the mutable post-execution tree. */
|
|
191
|
+
preExecutionPhysicalPath?: string;
|
|
192
|
+
/** Identity/signature chain used to obtain the physical snapshot. It covers
|
|
193
|
+
* every existing resolved component, including the leaf, plus the exact text
|
|
194
|
+
* of each followed symlink. In-place mutation preserves the leaf identity;
|
|
195
|
+
* replacement makes attribution uncertain. */
|
|
196
|
+
preExecutionPathSignatures?: FileAttributionPathSignature[];
|
|
197
|
+
/** Which explicit native tool supplied this evidence. */
|
|
198
|
+
provenance: "edit" | "write";
|
|
199
|
+
/** Canonicalization was incomplete, ambiguous, or its signature chain changed
|
|
200
|
+
* after execution. Such evidence is retained conservatively even when its
|
|
201
|
+
* lexical path is in a disposable workspace. */
|
|
202
|
+
uncertain: boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
159
205
|
export interface ToolActivity {
|
|
160
206
|
id: string;
|
|
161
207
|
name: string;
|
|
162
208
|
args: Record<string, unknown>;
|
|
209
|
+
/** Structured edit/write attribution captured before execution. */
|
|
210
|
+
fileAttribution?: FileAttribution;
|
|
163
211
|
result?: {
|
|
164
212
|
content: Array<{ type: string; text?: string }>;
|
|
165
213
|
isError: boolean;
|
|
@@ -171,6 +219,8 @@ export interface ToolActivity {
|
|
|
171
219
|
}
|
|
172
220
|
|
|
173
221
|
/** Stable machine-readable reason for a task failure.
|
|
222
|
+
* - `cancelled`: the caller or async-ticket controller requested cancellation.
|
|
223
|
+
* This must not be inferred from provider error text such as "Aborted".
|
|
174
224
|
* - `stalled`: inactivity watchdog fired; the prompt was cooperatively aborted.
|
|
175
225
|
* - `model_error`: the failure is attributable to the resolved model/provider
|
|
176
226
|
* (account usage limit, quota exhausted, auth lost) — not transient for that
|
|
@@ -179,12 +229,16 @@ export interface ToolActivity {
|
|
|
179
229
|
* - `deadline_exceeded`: the task's `deadlineMs` wall-clock budget expired
|
|
180
230
|
* (measured from when the task left the concurrency queue). The prompt was
|
|
181
231
|
* cooperatively aborted; completed side effects are not rolled back. */
|
|
182
|
-
export type TaskFailureKind =
|
|
232
|
+
export type TaskFailureKind =
|
|
233
|
+
"cancelled" | "stalled" | "model_error" | "deadline_exceeded";
|
|
183
234
|
|
|
184
235
|
export interface TaskProgress {
|
|
185
236
|
id?: string;
|
|
186
237
|
index: number;
|
|
187
238
|
agent: string;
|
|
239
|
+
/** Short tag (via `formatResumeTag`) of the transcript this task continued
|
|
240
|
+
* via `resumeFrom`, if any. Lets renderers mark the row as a revival. */
|
|
241
|
+
resumedFrom?: string;
|
|
188
242
|
task: string;
|
|
189
243
|
status: "pending" | "running" | "done" | "failed";
|
|
190
244
|
durationMs: number;
|
|
@@ -192,6 +246,8 @@ export interface TaskProgress {
|
|
|
192
246
|
toolUses: number;
|
|
193
247
|
error?: string;
|
|
194
248
|
failureKind?: TaskFailureKind;
|
|
249
|
+
/** Terminal lower-bound result returned after quiescence abandonment. */
|
|
250
|
+
incomplete?: "quiescence_abandoned";
|
|
195
251
|
model?: string;
|
|
196
252
|
lastActivityAt?: number;
|
|
197
253
|
activities: ToolActivity[];
|
|
@@ -208,6 +264,10 @@ export interface DelegateDetails {
|
|
|
208
264
|
ticketId?: string;
|
|
209
265
|
/** Terminal/live ticket status when this result comes from an async ticket. */
|
|
210
266
|
status?: AsyncTicket["status"];
|
|
267
|
+
/** Actual batch wall time for stable rendering outside the live tool context. */
|
|
268
|
+
elapsedMs?: number;
|
|
269
|
+
/** Async result arrived on a different session-tree leaf than it was spawned on. */
|
|
270
|
+
crossLeafDelivery?: boolean;
|
|
211
271
|
/** Global overlap warning derived from result.attributedFiles, surfaced in both
|
|
212
272
|
* the textual content and the custom TUI. */
|
|
213
273
|
overlapWarning?: string;
|
|
@@ -218,23 +278,32 @@ export interface DelegateDetails {
|
|
|
218
278
|
export interface TaskResult {
|
|
219
279
|
id?: string;
|
|
220
280
|
agent: string;
|
|
281
|
+
/** Short tag (via `formatResumeTag`) of the transcript this task continued
|
|
282
|
+
* via `resumeFrom`, if any. Lets settled-result renderers mark the row as
|
|
283
|
+
* a revival — mirrors `TaskProgress.resumedFrom`. */
|
|
284
|
+
resumedFrom?: string;
|
|
221
285
|
output: string;
|
|
222
286
|
error?: string;
|
|
223
287
|
/** Stable machine-readable failure reason; error remains human-facing. */
|
|
224
288
|
failureKind?: TaskFailureKind;
|
|
289
|
+
/** The task returned while its quarantined AgentSession could still run.
|
|
290
|
+
* Output, file evidence, token usage, and cost are lower bounds rather than
|
|
291
|
+
* final accounting. */
|
|
292
|
+
incomplete?: "quiescence_abandoned";
|
|
225
293
|
durationMs: number;
|
|
226
294
|
/** Display token count for the task, derived from the compaction-inclusive
|
|
227
295
|
* session-stat delta. This matches `usage.totalTokens`; the usage object
|
|
228
296
|
* additionally preserves the provider breakdown and cost. */
|
|
229
297
|
tokens: number;
|
|
230
|
-
/** Full provider Usage
|
|
231
|
-
* history. Always present (`emptyUsage()` on no-op/early-failure paths)
|
|
232
|
-
* sync delegate
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
298
|
+
/** Full provider Usage observed for this task, including compacted-away
|
|
299
|
+
* history. Always present (`emptyUsage()` on no-op/early-failure paths).
|
|
300
|
+
* When `incomplete` is absent, sync delegate can fold it into the parent and
|
|
301
|
+
* aggregate `cost.total` is final. For an abandoned task it is only a lower
|
|
302
|
+
* bound and dispatch omits top-level nested usage. Per-component cost fields
|
|
303
|
+
* stay 0 because `getSessionStats()` exposes only aggregate cost. */
|
|
236
304
|
usage: Usage;
|
|
237
|
-
/** Scratch
|
|
305
|
+
/** Scratch is never resumable. Its certain internal writes are excluded from
|
|
306
|
+
* shared-file conflict detection; external or uncertain evidence is retained. */
|
|
238
307
|
workspace?: WorkspaceMode;
|
|
239
308
|
sessionFile?: string;
|
|
240
309
|
/** All files the subagent is known to have touched, including bash mutations
|
|
@@ -245,6 +314,9 @@ export interface TaskResult {
|
|
|
245
314
|
* for overlap detection so concurrent tasks in the same repo do not
|
|
246
315
|
* fabricate false conflicts from shared git snapshots. */
|
|
247
316
|
attributedFiles?: string[];
|
|
317
|
+
/** Provenance-bearing evidence behind attributedFiles. Optional for legacy
|
|
318
|
+
* callers/tests that only provide the projected string list. */
|
|
319
|
+
fileAttributions?: FileAttribution[];
|
|
248
320
|
/** Git-native proposal/reconciliation outcome for workspace:"isolated". */
|
|
249
321
|
integration?: TaskIntegration;
|
|
250
322
|
}
|
|
@@ -259,6 +331,15 @@ export type TaskIntegrationStatus =
|
|
|
259
331
|
interface TaskIntegrationFiles {
|
|
260
332
|
proposedFiles: string[];
|
|
261
333
|
appliedFiles: string[];
|
|
334
|
+
/** Cleanup is separate from the integration outcome: a proposal can remain
|
|
335
|
+
* successfully applied even when a disposable worktree cannot be removed. */
|
|
336
|
+
cleanupIssue?: {
|
|
337
|
+
status: "deferred" | "failed";
|
|
338
|
+
reason: string;
|
|
339
|
+
/** Stable recovery marker. It may disappear after a deferred cleanup
|
|
340
|
+
* succeeds, but is retained when cleanup fails. */
|
|
341
|
+
recoveryPath?: string;
|
|
342
|
+
};
|
|
262
343
|
}
|
|
263
344
|
|
|
264
345
|
interface TaskIntegrationWithoutRecovery extends TaskIntegrationFiles {
|
|
@@ -278,6 +359,9 @@ export type TaskIntegration =
|
|
|
278
359
|
})
|
|
279
360
|
| (TaskIntegrationWithoutRecovery & {
|
|
280
361
|
status: "discarded";
|
|
362
|
+
/** Reporting-only issues encountered while classifying a failed task's
|
|
363
|
+
* paths. They do not turn the discarded proposal into an apply failure. */
|
|
364
|
+
classificationIssues?: Array<{ path: string; reason: string }>;
|
|
281
365
|
})
|
|
282
366
|
| (TaskIntegrationFiles & {
|
|
283
367
|
status: "conflict";
|
package/utils.ts
CHANGED
|
@@ -58,8 +58,129 @@ export function extractTextFromPartialResult(
|
|
|
58
58
|
|
|
59
59
|
/** Strip ANSI escape sequences from text. */
|
|
60
60
|
export function stripAnsi(text: string): string {
|
|
61
|
-
//
|
|
62
|
-
|
|
61
|
+
// A scanner keeps malformed, unterminated control strings linear-time.
|
|
62
|
+
// Regexes with lazy "anything until ST" branches become quadratic on input
|
|
63
|
+
// containing many unterminated OSC/DCS introducers.
|
|
64
|
+
let clean = "";
|
|
65
|
+
for (let index = 0; index < text.length;) {
|
|
66
|
+
const code = text.charCodeAt(index);
|
|
67
|
+
|
|
68
|
+
if (code === 0x1b) {
|
|
69
|
+
const next = text.charCodeAt(index + 1);
|
|
70
|
+
if (next === 0x5d) {
|
|
71
|
+
index = skipControlString(text, index + 2, true);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
|
|
75
|
+
index = skipControlString(text, index + 2, false);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (next === 0x5b) {
|
|
79
|
+
index = skipCsi(text, index + 2);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (next === 0x5c) {
|
|
83
|
+
// Preserve a boundary for a standalone 7-bit ST just as for C1 ST,
|
|
84
|
+
// so removing it cannot concatenate attacker-controlled words.
|
|
85
|
+
clean += " ";
|
|
86
|
+
index += 2;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Generic ESC sequence: intermediates followed by one final byte.
|
|
91
|
+
index++;
|
|
92
|
+
while (index < text.length) {
|
|
93
|
+
const value = text.charCodeAt(index);
|
|
94
|
+
if (value < 0x20 || value > 0x2f) break;
|
|
95
|
+
index++;
|
|
96
|
+
}
|
|
97
|
+
if (index < text.length) {
|
|
98
|
+
const final = text.charCodeAt(index);
|
|
99
|
+
if (final >= 0x30 && final <= 0x7e) index++;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (code === 0x9d) {
|
|
105
|
+
index = skipControlString(text, index + 1, true);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) {
|
|
109
|
+
index = skipControlString(text, index + 1, false);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (code === 0x9b) {
|
|
113
|
+
index = skipCsi(text, index + 1);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (code === 0x9c) {
|
|
117
|
+
// Preserve a boundary for a stray terminator so sanitization cannot
|
|
118
|
+
// concatenate attacker-controlled words around the removed control.
|
|
119
|
+
clean += " ";
|
|
120
|
+
index++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
clean += text[index]!;
|
|
125
|
+
index++;
|
|
126
|
+
}
|
|
127
|
+
return clean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function skipControlString(
|
|
131
|
+
text: string,
|
|
132
|
+
index: number,
|
|
133
|
+
bellTerminates: boolean,
|
|
134
|
+
): number {
|
|
135
|
+
while (index < text.length) {
|
|
136
|
+
const code = text.charCodeAt(index);
|
|
137
|
+
if ((bellTerminates && code === 0x07) || code === 0x9c) return index + 1;
|
|
138
|
+
if (
|
|
139
|
+
code === 0x1b &&
|
|
140
|
+
index + 1 < text.length &&
|
|
141
|
+
text.charCodeAt(index + 1) === 0x5c
|
|
142
|
+
) {
|
|
143
|
+
return index + 2;
|
|
144
|
+
}
|
|
145
|
+
index++;
|
|
146
|
+
}
|
|
147
|
+
return index;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function skipCsi(text: string, index: number): number {
|
|
151
|
+
while (index < text.length) {
|
|
152
|
+
const code = text.charCodeAt(index);
|
|
153
|
+
// A malformed CSI must not consume diagnostic layout while searching for
|
|
154
|
+
// a final byte. Leave common layout controls for the outer sanitizer.
|
|
155
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d) return index;
|
|
156
|
+
index++;
|
|
157
|
+
if (code >= 0x40 && code <= 0x7e) break;
|
|
158
|
+
}
|
|
159
|
+
return index;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Remove terminal controls from untrusted multiline text while preserving its
|
|
164
|
+
* line and ordinary whitespace structure for markdown/plain-text rendering.
|
|
165
|
+
*/
|
|
166
|
+
export function sanitizeTerminalText(text: string): string {
|
|
167
|
+
return (
|
|
168
|
+
stripAnsi(text)
|
|
169
|
+
.replace(/\r\n?|\u2028|\u2029/g, "\n")
|
|
170
|
+
.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]+/g, " ")
|
|
171
|
+
// Invisible bidi marks, embeddings/overrides, and isolates can reorder
|
|
172
|
+
// attacker-controlled terminal text without changing its stored spelling.
|
|
173
|
+
.replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "")
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Flatten untrusted text for one terminal row. ANSI sequences and terminal
|
|
179
|
+
* controls are removed before whitespace is normalized, so callers can safely
|
|
180
|
+
* truncate and store the result without preserving a partial escape sequence.
|
|
181
|
+
*/
|
|
182
|
+
export function sanitizeTerminalLine(text: string): string {
|
|
183
|
+
return sanitizeTerminalText(text).replace(/\s+/g, " ").trim();
|
|
63
184
|
}
|
|
64
185
|
|
|
65
186
|
/** Resolve carriage-return progress bars to their final line state. */
|