@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/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(renderOutputForPoll(result.output, { tailChars }));
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(renderOutputForPoll(result.output, { tailChars }));
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
- // 8-char alphanumeric, no lookalikes
98
- return Math.random().toString(36).slice(2, 10);
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
- return {
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
- signal.addEventListener(
751
- "abort",
752
- () => {
753
- abortWaiter(waiter, ticket);
754
- },
755
- { once: true },
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
- return [
39
- ...Object.keys(TOOL_FACTORIES),
40
- ...(modelProvider ? (PROVIDER_TOOLS[modelProvider] ?? []) : []),
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 = "stalled" | "model_error" | "deadline_exceeded";
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 consumed by this task, including compacted-away
231
- * history. Always present (`emptyUsage()` on no-op/early-failure paths) so a
232
- * sync delegate call can fold subagent spend into the parent's session
233
- * total. Aggregate `cost.total` is accurate; the per-component cost fields
234
- * stay 0 because `getSessionStats()` exposes only the aggregate cost — and
235
- * Pi sums `cost.total` for nested usage anyway. */
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 results are excluded from shared-file conflict detection and never resumable. */
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
- // eslint-disable-next-line no-control-regex
62
- return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
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. */