@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.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/src/settings.ts CHANGED
@@ -5,7 +5,44 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
- import type { JoinMode, ResultPreviewMode, WidgetMode } from "./types.js";
8
+ import { IMPLICIT_FALLBACK, NO_FALLBACK } from "./agent-types.js";
9
+ import type { WidgetMode } from "./types.js";
10
+
11
+ /**
12
+ * What the `fallbackSubagent` row shows and offers.
13
+ *
14
+ * The current value is always included in `values`. pi-tui's cycle handler
15
+ * computes `values.indexOf(currentValue)` and writes `values[0]` when that is
16
+ * -1, so a row whose current value is absent from its own list turns a single
17
+ * Enter into a silent rewrite to whatever happens to sort first — a custom
18
+ * agent, once the defaults are disabled. Including it makes Enter mean "cycle
19
+ * from what is shown", which is what every other row means.
20
+ *
21
+ * `unresolvable` reports an explicitly configured fallback that no longer names
22
+ * an enabled agent — deleted, disabled, or a default with `disableDefaultAgents`
23
+ * on. Dispatch refuses in that state, so the row must say so rather than
24
+ * presenting it as a working target. `general-purpose` is the exception in both
25
+ * directions: it resolves to the permissive hardcoded tier whether it is
26
+ * configured or unset, and dispatch accepts it either way, so an unset fallback
27
+ * is never unresolvable and neither is an explicit `general-purpose`.
28
+ */
29
+ export function fallbackRow(
30
+ configured: string | undefined,
31
+ available: readonly string[],
32
+ noneValue: string,
33
+ resolvable: (name: string) => boolean,
34
+ ): { value: string; values: string[]; unresolvable: boolean } {
35
+ const value = configured ?? IMPLICIT_FALLBACK;
36
+ // `resolvable` is `isFallbackResolvable`, i.e. the resolver's own policy. The
37
+ // uniqueness rule needs the full registry (a disabled agent still claims its
38
+ // name), which `available` alone cannot express — so the caller supplies the
39
+ // verdict rather than this function re-deriving a half-truth.
40
+ // Only a *configured* value can be unresolvable: an unset fallback deliberately
41
+ // targets the permissive hardcoded tier even when that agent is unregistered or
42
+ // disabled, so calling it broken would report a refusal dispatch never performs.
43
+ const unresolvable = configured !== undefined && value.trim().toLowerCase() !== noneValue && !resolvable(value);
44
+ return { value, values: [...new Set([value, ...available, noneValue])], unresolvable };
45
+ }
9
46
 
10
47
  export interface SubagentsSettings {
11
48
  maxConcurrent?: number;
@@ -16,7 +53,6 @@ export interface SubagentsSettings {
16
53
  */
17
54
  defaultMaxTurns?: number;
18
55
  graceTurns?: number;
19
- defaultJoinMode?: JoinMode;
20
56
  /**
21
57
  * Master switch for the schedule subagent feature. Defaults to `true`.
22
58
  * When `false`: the `Agent` tool's `schedule` param + its guideline are
@@ -44,12 +80,20 @@ export interface SubagentsSettings {
44
80
  * against. Defaults to false: subagents may use any model.
45
81
  */
46
82
  scopeModels?: boolean;
47
- /** Result preview rendering mode. Defaults to "markdown". */
48
- resultPreviewMode?: ResultPreviewMode;
49
- /** Always show expanded result preview, ignoring pi's expanded flag. Defaults to true. */
50
- resultPreviewExpanded?: boolean;
51
83
  /** Max chars for failure preview before truncation. Defaults to 65536 (64 KiB). */
52
84
  failurePreviewMaxChars?: number;
85
+ /**
86
+ * When true, an unreadable or unparseable agent `.md` aborts extension load
87
+ * instead of being skipped with a warning — pi exits, naming the file.
88
+ *
89
+ * Startup only, by design. Mid-session reloads (one per `Agent` call) keep
90
+ * warning: a bad edit at 3pm should not kill the session on the next
91
+ * unrelated spawn, where the failure would look disconnected from its cause.
92
+ * For a checked-in `.pi/agents/`, failing at startup is the point — the
93
+ * alternative is running a *different* agent than the file names.
94
+ * Defaults to false.
95
+ */
96
+ strictAgentFiles?: boolean;
53
97
  /**
54
98
  * When true, the three built-in default agents (general-purpose, Explore, Plan)
55
99
  * are not registered at startup. User-defined agents from project/global custom
@@ -76,13 +120,9 @@ export interface SubagentsSettings {
76
120
  fleetView?: boolean;
77
121
  /**
78
122
  * Display mode for the persistent above-editor agent widget:
79
- * - `all`: show every agent (foreground + background).
80
- * - `background`: hide foreground agents — they already render inline as the
81
- * Agent tool result, so the widget would otherwise double-render them
82
- * (#118); everything else (background, queued, scheduled, RPC) stays.
123
+ * - `all`: show every agent.
83
124
  * - `off`: hide the widget entirely.
84
- * Defaults to `background`. Pure-UI and applied live (toggling refreshes the
85
- * widget).
125
+ * Defaults to `all`. Pure-UI and applied live (toggling refreshes the widget).
86
126
  */
87
127
  widgetMode?: WidgetMode;
88
128
  /**
@@ -92,10 +132,106 @@ export interface SubagentsSettings {
92
132
  * project (e.g. a repo that shouldn't leave run transcripts on disk for backup
93
133
  * or DLP tooling to ingest). A custom agent's `output_transcript` frontmatter
94
134
  * overrides this per agent. This governs only the transcript — it does NOT
95
- * affect the persisted pi session (`persist_session`), worktree commits
96
- * (`isolation: worktree`), or memory files.
135
+ * affect whether the run's pi session file is retained (`persist_session`),
136
+ * worktree commits (`isolation: worktree`), or memory files.
97
137
  */
98
138
  outputTranscript?: boolean;
139
+ /**
140
+ * Hard ceiling on nested subagent delegation, counted from the main session:
141
+ * main = 0, its subagents = 1, their children = 2. Defaults to `2`; `0` or `1`
142
+ * disables nesting project-wide. Read when a subagent session is built, so a
143
+ * change applies to agents started after it. A per-agent `max_subagent_depth`
144
+ * can only tighten this, never raise it.
145
+ */
146
+ maxSubagentDepth?: number;
147
+ /**
148
+ * Whether `isolation: "worktree"` may create a worktree at all. Defaults to
149
+ * `true`. Set `false` on a repo where worktrees are too slow or too large to
150
+ * be worth it: a requested worktree is then dropped and the agent runs in the
151
+ * main checkout.
152
+ *
153
+ * The drop is deliberately silent — there is no per-result note, because the
154
+ * setting exists for projects whose model asks for a worktree on every call,
155
+ * where a note would be noise on every result. What keeps the orchestrator
156
+ * from claiming a `pi-agent-*` branch anyway is that it is never told the
157
+ * capability exists: `isolationParam` (invocation-config.ts) drops the field
158
+ * from the tool schema, and `isolationGuideline` (index.ts) drops the
159
+ * matching prose from the full and compact descriptions — a custom one opts
160
+ * in via the `{{isolationGuideline}}` placeholder. Anything that
161
+ * reintroduces the prose has to reintroduce a note with it.
162
+ *
163
+ * Deliberately a downgrade rather than an error. The fail-loud rule covers
164
+ * worktrees that *cannot* be created; this is the user declining one, and
165
+ * throwing would reject exactly the calls that the `isolation: "off"` value
166
+ * exists to tolerate. Enforced below the tool boundary, so it also covers the
167
+ * scheduler and the unvalidated cross-extension RPC path.
168
+ */
169
+ worktreeIsolation?: boolean;
170
+ /**
171
+ * Agent type substituted when a caller-supplied `subagent_type` doesn't
172
+ * resolve to exactly one enabled agent (unknown, disabled, or ambiguous by
173
+ * case). Omitted keeps the historical `general-purpose` fallback; a type name
174
+ * routes those calls to that agent instead; `"none"` disables the fallback so
175
+ * dispatch fails closed with an error naming the available types.
176
+ *
177
+ * The boolean `false` is accepted as a spelling of `"none"`, because a boolean
178
+ * would otherwise be dropped as the wrong type and silently leave the
179
+ * PERMISSIVE default in place while the author believes strict dispatch is on
180
+ * — the wrong direction to fail for this setting. Every other value is an
181
+ * agent name, so a mistaken `"off"` fails loudly at dispatch rather than
182
+ * meaning one thing here and another in the resolver.
183
+ */
184
+ fallbackSubagent?: string;
185
+ /**
186
+ * Whether this extension's tool results carry a `usage` field, so subagent
187
+ * spend reaches the parent session's own accounting. Defaults to `true`.
188
+ *
189
+ * Subagents run in their own pi sessions, so without this the parent's footer,
190
+ * statusline and `/cost` show only what the main model spent — a session that
191
+ * delegated most of its work reads as nearly free. Pi folds
192
+ * `toolResult.usage` into `getSessionStats()`, so attaching it makes those
193
+ * surfaces count subagents too, under `/cost`'s "Tools/summaries" bucket.
194
+ *
195
+ * On by default because the alternative is a total that is quietly wrong about
196
+ * what the session cost; upstream defaults it off, this fork does not. The
197
+ * honest caveat stands: turning it on makes numbers move that a statusline may
198
+ * already have been tracking, which is why it remains a setting.
199
+ *
200
+ * Three properties of what gets reported:
201
+ * - `cacheRead` IS included, because pi counts the parent's own messages
202
+ * that way and the prefix really is re-billed on every call. The display
203
+ * total (`getLifetimeTotal`) keeps excluding it — see `usage.ts`.
204
+ * - Cost is pi's own per-message `usage.cost.total`; we price nothing, and
205
+ * a model pi has no rates for contributes 0.
206
+ * - The context-window percentage is untouched. Pi derives it from assistant
207
+ * messages alone (`getContextUsage`), so a delegating session's context
208
+ * does not appear to fill up faster.
209
+ */
210
+ reportUsage?: boolean;
211
+ /**
212
+ * Whether the subagent surfaces show an estimated dollar cost next to their
213
+ * token counts (widget, FleetView, conversation viewer, `get_subagent_result`,
214
+ * completion notifications). Defaults to `true`. Applied live.
215
+ *
216
+ * Rendered as `~$0.0042` — the tilde marks it as pi's reported estimate rather
217
+ * than a billed figure, and it is omitted entirely when the model has no
218
+ * pricing data, so a local model shows tokens and no dollars.
219
+ *
220
+ * Independent of `reportUsage`: this one is what a human reads, that one is
221
+ * what the parent session counts.
222
+ */
223
+ showCost?: boolean;
224
+ /**
225
+ * Whether the widget's running rows name the model driving each agent and the
226
+ * thinking level it is running at. Defaults to `false`.
227
+ *
228
+ * Off by default because the widget row already carries the description,
229
+ * turns, tool uses, tokens and elapsed time, and every character it gains is
230
+ * one the description loses on a narrow terminal. The conversation viewer and
231
+ * the `get_subagent_result` header show the pair either way — they have a line
232
+ * to themselves.
233
+ */
234
+ showModel?: boolean;
99
235
  }
100
236
 
101
237
  export type ToolDescriptionMode = "full" | "compact" | "custom";
@@ -105,26 +241,28 @@ export interface SettingsAppliers {
105
241
  setMaxConcurrent: (n: number) => void;
106
242
  setDefaultMaxTurns: (n: number) => void;
107
243
  setGraceTurns: (n: number) => void;
108
- setDefaultJoinMode: (mode: JoinMode) => void;
109
244
  setSchedulingEnabled: (b: boolean) => void;
110
245
  setScopeModels: (enabled: boolean) => void;
111
- setResultPreviewMode: (mode: ResultPreviewMode) => void;
112
- setResultPreviewExpanded: (expanded: boolean) => void;
246
+ setStrictAgentFiles: (b: boolean) => void;
113
247
  setFailurePreviewMaxChars: (chars: number) => void;
114
248
  setDisableDefaultAgents: (b: boolean) => void;
115
249
  setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
116
250
  setFleetView: (b: boolean) => void;
117
251
  setWidgetMode: (mode: WidgetMode) => void;
118
252
  setOutputTranscript: (b: boolean) => void;
253
+ setMaxSubagentDepth: (n: number) => void;
254
+ setWorktreeIsolation: (b: boolean) => void;
255
+ setFallbackSubagent: (v: string | undefined) => void;
256
+ setReportUsage: (b: boolean) => void;
257
+ setShowCost: (b: boolean) => void;
258
+ setShowModel: (b: boolean) => void;
119
259
  }
120
260
 
121
261
  /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
122
262
  export type SettingsEmit = (event: string, payload: unknown) => void;
123
263
 
124
- const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
125
- const VALID_RESULT_PREVIEW_MODES: ReadonlySet<string> = new Set<ResultPreviewMode>(["plain", "markdown"]);
126
264
  const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
127
- const VALID_WIDGET_MODES: ReadonlySet<string> = new Set<WidgetMode>(["all", "background", "off"]);
265
+ const VALID_WIDGET_MODES: ReadonlySet<string> = new Set<WidgetMode>(["all", "off"]);
128
266
 
129
267
 
130
268
  // Sanity ceilings — prevent hand-edited configs from asking for values that
@@ -133,7 +271,16 @@ const VALID_WIDGET_MODES: ReadonlySet<string> = new Set<WidgetMode>(["all", "bac
133
271
  const MAX_CONCURRENT_CEILING = 1024;
134
272
  const MAX_TURNS_CEILING = 10_000;
135
273
  const GRACE_TURNS_CEILING = 1_000;
136
- export const RESULT_PREVIEW_MAX_CHARS_CEILING = 1_048_576;
274
+ const SUBAGENT_DEPTH_CEILING = 16;
275
+ export const FAILURE_PREVIEW_MAX_CHARS_CEILING = 1_048_576;
276
+
277
+ /**
278
+ * Shipped default for `showCost`. Named because the report renderer keeps its
279
+ * own copy of the flag (see `result-report.ts`): a file that omits the key
280
+ * applies nothing, so both copies must start from the same value or the widget
281
+ * and the result report disagree about showing a cost.
282
+ */
283
+ export const DEFAULT_SHOW_COST = true;
137
284
 
138
285
  /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
139
286
  function sanitize(raw: unknown): SubagentsSettings {
@@ -161,8 +308,12 @@ function sanitize(raw: unknown): SubagentsSettings {
161
308
  ) {
162
309
  out.graceTurns = r.graceTurns as number;
163
310
  }
164
- if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
165
- out.defaultJoinMode = r.defaultJoinMode as JoinMode;
311
+ if (
312
+ Number.isInteger(r.maxSubagentDepth) &&
313
+ (r.maxSubagentDepth as number) >= 0 &&
314
+ (r.maxSubagentDepth as number) <= SUBAGENT_DEPTH_CEILING
315
+ ) {
316
+ out.maxSubagentDepth = r.maxSubagentDepth as number;
166
317
  }
167
318
  if (typeof r.schedulingEnabled === "boolean") {
168
319
  out.schedulingEnabled = r.schedulingEnabled;
@@ -170,16 +321,13 @@ function sanitize(raw: unknown): SubagentsSettings {
170
321
  if (typeof r.scopeModels === "boolean") {
171
322
  out.scopeModels = r.scopeModels;
172
323
  }
173
- if (typeof r.resultPreviewMode === "string" && VALID_RESULT_PREVIEW_MODES.has(r.resultPreviewMode)) {
174
- out.resultPreviewMode = r.resultPreviewMode as ResultPreviewMode;
175
- }
176
- if (typeof r.resultPreviewExpanded === "boolean") {
177
- out.resultPreviewExpanded = r.resultPreviewExpanded;
324
+ if (typeof r.strictAgentFiles === "boolean") {
325
+ out.strictAgentFiles = r.strictAgentFiles;
178
326
  }
179
327
  if (
180
328
  Number.isInteger(r.failurePreviewMaxChars) &&
181
329
  (r.failurePreviewMaxChars as number) >= 1 &&
182
- (r.failurePreviewMaxChars as number) <= RESULT_PREVIEW_MAX_CHARS_CEILING
330
+ (r.failurePreviewMaxChars as number) <= FAILURE_PREVIEW_MAX_CHARS_CEILING
183
331
  ) {
184
332
  out.failurePreviewMaxChars = r.failurePreviewMaxChars as number;
185
333
  }
@@ -198,6 +346,28 @@ function sanitize(raw: unknown): SubagentsSettings {
198
346
  if (typeof r.outputTranscript === "boolean") {
199
347
  out.outputTranscript = r.outputTranscript;
200
348
  }
349
+ if (typeof r.worktreeIsolation === "boolean") {
350
+ out.worktreeIsolation = r.worktreeIsolation;
351
+ }
352
+ if (typeof r.reportUsage === "boolean") {
353
+ out.reportUsage = r.reportUsage;
354
+ }
355
+ if (typeof r.showCost === "boolean") {
356
+ out.showCost = r.showCost;
357
+ }
358
+ if (typeof r.showModel === "boolean") {
359
+ out.showModel = r.showModel;
360
+ }
361
+ if (r.fallbackSubagent === false) {
362
+ // The only non-string spelling worth accepting: a boolean would otherwise be
363
+ // dropped, silently leaving the PERMISSIVE default in place. Every string is
364
+ // an agent name except the `none` sentinel, which the resolver recognizes —
365
+ // so a mistaken "off" fails loudly at dispatch instead of meaning something
366
+ // different here than it does there.
367
+ out.fallbackSubagent = NO_FALLBACK;
368
+ } else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
369
+ out.fallbackSubagent = r.fallbackSubagent.trim();
370
+ }
201
371
  return out;
202
372
  }
203
373
 
@@ -251,17 +421,21 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
251
421
  if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
252
422
  if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
253
423
  if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
254
- if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
424
+ if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
255
425
  if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
256
426
  if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
257
- if (s.resultPreviewMode) appliers.setResultPreviewMode(s.resultPreviewMode);
258
- if (typeof s.resultPreviewExpanded === "boolean") appliers.setResultPreviewExpanded(s.resultPreviewExpanded);
427
+ if (typeof s.strictAgentFiles === "boolean") appliers.setStrictAgentFiles(s.strictAgentFiles);
259
428
  if (typeof s.failurePreviewMaxChars === "number") appliers.setFailurePreviewMaxChars(s.failurePreviewMaxChars);
260
429
  if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents);
261
430
  if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode);
262
431
  if (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView);
263
432
  if (s.widgetMode) appliers.setWidgetMode(s.widgetMode);
264
433
  if (typeof s.outputTranscript === "boolean") appliers.setOutputTranscript(s.outputTranscript);
434
+ if (typeof s.worktreeIsolation === "boolean") appliers.setWorktreeIsolation(s.worktreeIsolation);
435
+ if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
436
+ if (typeof s.reportUsage === "boolean") appliers.setReportUsage(s.reportUsage);
437
+ if (typeof s.showCost === "boolean") appliers.setShowCost(s.showCost);
438
+ if (typeof s.showModel === "boolean") appliers.setShowModel(s.showModel);
265
439
  }
266
440
 
267
441
  /**
@@ -1,7 +1,14 @@
1
1
  /**
2
- * status-note.ts — Parenthetical status note appended to agent result text.
2
+ * status-note.ts — Honest framing for an agent result: the parenthetical status
3
+ * note for a non-normal outcome, and the salvaged partial output of a failure.
4
+ *
5
+ * Lives here rather than in an index.ts closure because both entry points need
6
+ * it — the top-level tools and the nested delegation tools, which cannot import
7
+ * the extension entry point.
3
8
  */
4
9
 
10
+ import type { AgentRecord } from "./types.js";
11
+
5
12
  /**
6
13
  * Explicit parenthetical note for a non-normal terminal outcome, so the parent
7
14
  * agent can't mistake partial output for a completed result. Empty string for a
@@ -23,3 +30,13 @@ export function getStatusNote(status: string): string {
23
30
  return "";
24
31
  }
25
32
  }
33
+
34
+ /**
35
+ * Salvaged partial output of a failed run, as a labeled suffix for the error
36
+ * surfaces (or "" if the run produced nothing). `record.result` is bounded to
37
+ * the run's own turns, so this is never a stale earlier answer (#144).
38
+ */
39
+ export function partialOutputSuffix(record: AgentRecord): string {
40
+ const partial = record.result?.trim();
41
+ return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
42
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * text-safety.ts — shared sanitizer for untrusted text that reaches the
3
+ * terminal or the model context (child output, tool arguments, settings).
4
+ */
5
+
6
+ /**
7
+ * Strip terminal control sequences and invisible/forging characters, keeping
8
+ * tab, LF, and printable non-ASCII. Complete OSC and CSI sequences are consumed
9
+ * whole, including the 8-bit C1 form of CSI (U+009B with no `[`); a dangling
10
+ * introducer goes with its ESC/C1 byte, while an unterminated payload (an
11
+ * OSC/DCS sequence missing its terminator) may leave that payload as visible
12
+ * text. CR is dropped, so CRLF collapses to LF and a
13
+ * lone CR cannot overwrite the rendered line. A guard against terminal control
14
+ * and invisible text, not a content filter: markdown and XML-ish characters
15
+ * pass through untouched. The result is always well-formed UTF-16: an
16
+ * unpaired surrogate becomes U+FFFD, or the paired character when a removal
17
+ * above brings a high and a low surrogate together. Leaving one in place
18
+ * would let a padded or clamped row overflow — a terminal renders it as
19
+ * U+FFFD one column wide, while width helpers count it as zero columns.
20
+ *
21
+ * The invisible/format set includes the Unicode Tag block (U+E0001,
22
+ * U+E0020–U+E007F), which encodes invisible ASCII payloads, plus CGJ (U+034F),
23
+ * the Hangul fillers (U+115F/U+1160/U+3164/U+FFA0), the variation-selector
24
+ * supplement (U+E0100–U+E01EF), the zero-width/directional mark range
25
+ * (U+200B–U+200F, including ZWJ U+200D), the line/paragraph separators
26
+ * (U+2028/U+2029), the bidi embedding/override controls (U+202A–U+202E), the
27
+ * U+2060–U+2069 block (word joiner, invisible operators, bidi isolates), soft
28
+ * hyphen (U+00AD), ALM (U+061C), the Mongolian vowel separator (U+180E), and
29
+ * BOM/ZWNBSP (U+FEFF). Stripping those families is a deliberate over-strip for
30
+ * Trojan-Source/covert-channel defense even though they have legitimate roles —
31
+ * CGJ blocks Arabic/Indic ligatures, the VS supplement selects CJK ideographic
32
+ * glyph variants, the Hangul fillers appear in old-Hangul text, ZWJ joins emoji
33
+ * and Indic sequences, and the bidi controls/isolates steer RTL layout.
34
+ * U+FE0E/U+FE0F are deliberately kept: they carry emoji vs text presentation.
35
+ */
36
+ export function stripControlChars(s: string): string {
37
+ return s
38
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "") // complete OSC: ESC ] … BEL | ST
39
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "") // complete CSI: ESC [ … final byte
40
+ .replace(/\u009b[0-?]*[ -/]*[@-~]/g, "") // complete C1 CSI: U+009B … final byte
41
+ .replace(/[\u001b\u009b][[\]()#;?]*/g, "") // dangling ESC/C1 plus its introducer
42
+ .replace(
43
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
44
+ "\uFFFD",
45
+ ) // coerce unpaired surrogates
46
+ .replace(
47
+ /[\u{e0100}-\u{e01ef}\u034f\x00-\x08\x0b-\x0d\x0e-\x1f\x7f-\x9f\u00ad\u061c\u115f\u1160\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2069\u3164\ufeff\uffa0\u{e0001}\u{e0020}-\u{e007f}]/gu,
48
+ "",
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Collapse newlines/CRs/tabs to spaces and strip control/invisible characters,
54
+ * for untrusted text composed into a single terminal line (report headers and
55
+ * metadata, tool-call lines, widget rows). Use `stripControlChars` for body
56
+ * and display paths that preserve layout. A display sanitizer, not a type
57
+ * guard: non-string input renders as the empty string.
58
+ */
59
+ export function toSingleLine(s: unknown): string {
60
+ if (typeof s !== "string") return "";
61
+ return stripControlChars(s.replace(/\r\n?|\n/g, " ").replace(/\t/g, " ")).trim();
62
+ }
63
+
64
+ /** Truncate to maxChars UTF-16 code units, never splitting a surrogate pair. */
65
+ export function safeTruncate(s: string, maxChars: number): string {
66
+ if (s.length <= maxChars) return s;
67
+ const high = s.charCodeAt(maxChars - 1);
68
+ return high >= 0xD800 && high <= 0xDBFF ? s.slice(0, maxChars - 1) : s.slice(0, maxChars);
69
+ }