killeros 2.0.13 → 2.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +3 -3
- package/killeros/goals.ts +34 -9
- package/killeros/hooks.ts +16 -5
- package/killeros/question.ts +24 -14
- package/killeros/runtime.ts +5 -0
- package/killeros/safe-terminal-text.ts +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ All notable changes to KillerOS are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.0.14] - 2026-08-22
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Prevented direct tag pushes from publishing commits that have not passed CI on `main`.
|
|
12
|
+
- Kept oversized hook payloads valid JSON and marked their bounded preview as truncated.
|
|
13
|
+
- Stripped terminal escape sequences and unsafe controls from model-controlled question and goal text.
|
|
14
|
+
- Aligned hook timeout validation and execution on the documented five-minute maximum.
|
|
15
|
+
- Required file-backed goals to create or change their deliverable after the goal starts, including after session restore.
|
|
16
|
+
- Removed a CI test dependency on an intentionally untracked internal document.
|
|
17
|
+
|
|
7
18
|
## [2.0.13] - 2026-08-21
|
|
8
19
|
|
|
9
20
|
### Added
|
package/README.md
CHANGED
|
@@ -24,10 +24,10 @@ Install from GitHub:
|
|
|
24
24
|
pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Pin an install to version `v2.0.
|
|
27
|
+
Pin an install to version `v2.0.14`:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.
|
|
30
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.14
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
Add `-l` to either command to install only for the current project. Restart Pi after installing.
|
|
@@ -175,7 +175,7 @@ For a normal release:
|
|
|
175
175
|
|
|
176
176
|
After the full CI workflow passes on `main`, the release workflow validates the commit and changelog, publishes the package to npm through trusted publishing, and creates the matching tag and GitHub release. The [`pi-package` keyword](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) makes the npm package visible in Pi's package catalog.
|
|
177
177
|
|
|
178
|
-
Do not
|
|
178
|
+
Do not push version tags manually. Tag pushes cannot publish; every published commit must pass the full `main` CI workflow.
|
|
179
179
|
|
|
180
180
|
## Security
|
|
181
181
|
|
package/killeros/goals.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { BoundedText } from "./bounded-text.ts";
|
|
|
9
9
|
import { formatTime, formatTokens } from "./display.ts";
|
|
10
10
|
import { reportError } from "./errors.ts";
|
|
11
11
|
import { resolvePersonalInstructions } from "./personal-instructions.ts";
|
|
12
|
-
import type { GoalBlockerAudit, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
12
|
+
import type { GoalBlockerAudit, GoalFileBaseline, GoalFileVerification, GoalRuntime, GoalState, GoalStatus, InitRuntime } from "./runtime.ts";
|
|
13
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
13
14
|
|
|
14
15
|
const GOAL_ENTRY_TYPE = "killeros-goal";
|
|
15
16
|
const GOAL_CONTINUATION_TYPE = "killeros-goal-continuation";
|
|
@@ -67,13 +68,21 @@ function finiteNonNegative(value: unknown): value is number {
|
|
|
67
68
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
function isGoalFileBaseline(value: unknown): value is GoalFileBaseline {
|
|
72
|
+
if (!value || typeof value !== "object") return false;
|
|
73
|
+
const candidate = value as { exists?: unknown; size?: unknown; mtimeMs?: unknown };
|
|
74
|
+
if (candidate.exists === false) return candidate.size === undefined && candidate.mtimeMs === undefined;
|
|
75
|
+
return candidate.exists === true && finiteNonNegative(candidate.size) && finiteNonNegative(candidate.mtimeMs);
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
function isGoalFileVerification(value: unknown): value is GoalFileVerification {
|
|
71
79
|
if (!value || typeof value !== "object") return false;
|
|
72
80
|
const candidate = value as Partial<GoalFileVerification>;
|
|
73
81
|
return candidate.kind === "file"
|
|
74
82
|
&& typeof candidate.path === "string"
|
|
75
83
|
&& candidate.path === candidate.path.trim()
|
|
76
|
-
&& isAbsoluteFilePath(candidate.path)
|
|
84
|
+
&& isAbsoluteFilePath(candidate.path)
|
|
85
|
+
&& isGoalFileBaseline(candidate.baseline);
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
function isAbsoluteFilePath(value: string): boolean {
|
|
@@ -81,13 +90,23 @@ function isAbsoluteFilePath(value: string): boolean {
|
|
|
81
90
|
return path.isAbsolute(value) || path.win32.isAbsolute(value);
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
function captureGoalFileBaseline(filePath: string): GoalFileBaseline {
|
|
94
|
+
try {
|
|
95
|
+
const artifact = lstatSync(filePath);
|
|
96
|
+
return { exists: true, size: artifact.size, mtimeMs: artifact.mtimeMs };
|
|
97
|
+
} catch {
|
|
98
|
+
return { exists: false };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
function inferGoalVerification(objective: string): GoalFileVerification | undefined {
|
|
85
103
|
const destination = /\b(?:create|write|save|generate)\b[^\r\n]{0,160}?\b(?:file|document|markdown|report|spreadsheet|presentation|image)\b\s+(?:to|at|as|destination(?:\s+is)?|output(?:\s+(?:to|at))?)\b\s*(?:`([^`\r\n]+)`|"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z]:\\[^\s,;]+|\/[^\s,;]+))/giu;
|
|
86
104
|
const paths = [...objective.matchAll(destination)]
|
|
87
105
|
.map((match) => (match[1] ?? match[2] ?? match[3] ?? match[4] ?? "").trim())
|
|
88
106
|
.filter(isAbsoluteFilePath);
|
|
89
107
|
const unique = [...new Set(paths)];
|
|
90
|
-
|
|
108
|
+
const filePath = unique.length === 1 ? unique[0] : undefined;
|
|
109
|
+
return filePath ? { kind: "file", path: filePath, baseline: captureGoalFileBaseline(filePath) } : undefined;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
function verifyGoalDeliverable(verification: GoalFileVerification): void {
|
|
@@ -100,6 +119,11 @@ function verifyGoalDeliverable(verification: GoalFileVerification): void {
|
|
|
100
119
|
if (!artifact.isFile()) {
|
|
101
120
|
throw new Error(`Goal deliverable is not a regular file at the required path: ${verification.path}`);
|
|
102
121
|
}
|
|
122
|
+
if (verification.baseline.exists
|
|
123
|
+
&& artifact.size === verification.baseline.size
|
|
124
|
+
&& artifact.mtimeMs === verification.baseline.mtimeMs) {
|
|
125
|
+
throw new Error(`Goal deliverable has not changed since the goal started: ${verification.path}`);
|
|
126
|
+
}
|
|
103
127
|
}
|
|
104
128
|
|
|
105
129
|
function isGoalBlockerAudit(value: unknown, turns: number, status: GoalStatus): value is GoalBlockerAudit {
|
|
@@ -555,9 +579,10 @@ export function registerGoal(
|
|
|
555
579
|
const icon = state.status === "active" ? "✻" : state.status === "paused" ? "Ⅱ" : state.status === "blocked" ? "!" : "✓";
|
|
556
580
|
const color: ThemeColor = state.status === "active" ? "accent" : state.status === "paused" ? "warning" : state.status === "blocked" ? "error" : "success";
|
|
557
581
|
const status = theme.fg(color, `${icon} Goal ${state.status}`);
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
582
|
+
const objective = safeTerminalText(state.objective);
|
|
583
|
+
if (!options.expanded) return new BoundedText(`${status}${theme.fg("dim", ` · ${objective}`)}`, 3);
|
|
584
|
+
const lines = [status, theme.fg("dim", objective)];
|
|
585
|
+
if (state.result) lines.push(theme.fg("muted", safeTerminalText(state.result)));
|
|
561
586
|
return new BoundedText(lines.join("\n"));
|
|
562
587
|
});
|
|
563
588
|
|
|
@@ -615,18 +640,18 @@ export function registerGoal(
|
|
|
615
640
|
};
|
|
616
641
|
},
|
|
617
642
|
renderCall(args, theme) {
|
|
618
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", args.status)}`, 0, 0);
|
|
643
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("goal "))}${theme.fg("muted", safeTerminalText(args.status))}`, 0, 0);
|
|
619
644
|
},
|
|
620
645
|
renderResult(result, options, theme, context) {
|
|
621
646
|
if (context?.isError) {
|
|
622
647
|
const first = result.content[0];
|
|
623
|
-
const message = first?.type === "text" ? first.text : "Goal update failed";
|
|
648
|
+
const message = first?.type === "text" ? safeTerminalText(first.text) : "Goal update failed";
|
|
624
649
|
return new BoundedText(theme.fg("error", message), options.expanded ? undefined : 3);
|
|
625
650
|
}
|
|
626
651
|
const details = result.details;
|
|
627
652
|
if (!details) return new BoundedText(theme.fg("dim", "Goal updated"));
|
|
628
653
|
const label = details.status === "complete" ? "✓ Complete" : details.status === "blocked" ? "! Blocked" : `! Blocker audit ${details.streak}/3`;
|
|
629
|
-
const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${details.evidence}`)}`;
|
|
654
|
+
const text = `${theme.fg(details.status === "complete" ? "success" : "warning", label)}${theme.fg("dim", ` · ${safeTerminalText(details.evidence)}`)}`;
|
|
630
655
|
return new BoundedText(text, options.expanded ? undefined : 3);
|
|
631
656
|
},
|
|
632
657
|
});
|
package/killeros/hooks.ts
CHANGED
|
@@ -4,7 +4,6 @@ import path from "node:path";
|
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
5
|
import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { reportError } from "./errors.ts";
|
|
7
|
-
import { MAX_NODE_TIMER_MS } from "./limits.ts";
|
|
8
7
|
|
|
9
8
|
type KillerosHookEvent = "tool_call" | "tool_result" | "agent_settled";
|
|
10
9
|
|
|
@@ -29,6 +28,8 @@ interface HookExecutionResult {
|
|
|
29
28
|
|
|
30
29
|
const HOOK_EVENTS: readonly KillerosHookEvent[] = ["tool_call", "tool_result", "agent_settled"];
|
|
31
30
|
const HOOK_OUTPUT_LIMIT = 16 * 1024;
|
|
31
|
+
const HOOK_PAYLOAD_LIMIT = 8_000;
|
|
32
|
+
const HOOK_TIMEOUT_MAX_MS = 300_000;
|
|
32
33
|
|
|
33
34
|
function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
34
35
|
const configPath = path.join(ctx.cwd, CONFIG_DIR_NAME, "killeros-hooks.json");
|
|
@@ -49,11 +50,14 @@ function loadKillerosHooks(ctx: ExtensionContext): KillerosHookConfig {
|
|
|
49
50
|
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: matchers are only valid for tool events`, "warning");
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
53
|
+
if (hook?.timeoutMs !== undefined && (!Number.isSafeInteger(hook.timeoutMs) || hook.timeoutMs <= 0 || hook.timeoutMs > HOOK_TIMEOUT_MAX_MS)) {
|
|
54
|
+
ctx.ui.notify(`Ignored ${event} hook ${index + 1}: timeoutMs must be an integer from 1 to ${HOOK_TIMEOUT_MAX_MS}`, "warning");
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
52
57
|
const valid = hook
|
|
53
58
|
&& typeof hook.command === "string"
|
|
54
59
|
&& hook.command.trim().length > 0
|
|
55
|
-
&& (hook.matcher === undefined || typeof hook.matcher === "string")
|
|
56
|
-
&& (hook.timeoutMs === undefined || Number.isSafeInteger(hook.timeoutMs) && hook.timeoutMs > 0 && hook.timeoutMs <= MAX_NODE_TIMER_MS);
|
|
60
|
+
&& (hook.matcher === undefined || typeof hook.matcher === "string");
|
|
57
61
|
if (!valid) {
|
|
58
62
|
ctx.ui.notify(`Ignored invalid ${event} hook ${index + 1} in ${configPath}`, "warning");
|
|
59
63
|
return false;
|
|
@@ -189,15 +193,22 @@ export function executeHook(
|
|
|
189
193
|
finish(termination ? terminationCode() : 1);
|
|
190
194
|
});
|
|
191
195
|
child.once("close", (code) => finish(termination ? terminationCode() : code ?? 1));
|
|
192
|
-
timer = setTimeout(() => beginTermination("timeout"), Math.max(
|
|
196
|
+
timer = setTimeout(() => beginTermination("timeout"), Math.max(1, Math.min(timeoutMs, HOOK_TIMEOUT_MAX_MS)));
|
|
193
197
|
});
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
function serializeHookPayload(payload: unknown): string {
|
|
201
|
+
const serialized = JSON.stringify(payload) ?? "null";
|
|
202
|
+
if (serialized.length <= HOOK_PAYLOAD_LIMIT) return serialized;
|
|
203
|
+
const previewLength = Math.floor((HOOK_PAYLOAD_LIMIT - 64) / 2);
|
|
204
|
+
return JSON.stringify({ truncated: true, preview: serialized.slice(0, previewLength) });
|
|
205
|
+
}
|
|
206
|
+
|
|
196
207
|
function hookEnvironment(event: KillerosHookEvent, toolName = "", payload: unknown = {}): Record<string, string> {
|
|
197
208
|
return {
|
|
198
209
|
KILLEROS_EVENT: event,
|
|
199
210
|
KILLEROS_TOOL: toolName,
|
|
200
|
-
KILLEROS_PAYLOAD:
|
|
211
|
+
KILLEROS_PAYLOAD: serializeHookPayload(payload),
|
|
201
212
|
};
|
|
202
213
|
}
|
|
203
214
|
|
package/killeros/question.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@earendil-works/pi-tui";
|
|
17
17
|
import { Type, type Static } from "typebox";
|
|
18
18
|
import { BoundedText } from "./bounded-text.ts";
|
|
19
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
19
20
|
|
|
20
21
|
const OptionSchema = Type.Object({
|
|
21
22
|
label: Type.String({ minLength: 1, maxLength: 200, description: "Display label for the option" }),
|
|
@@ -264,11 +265,12 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
264
265
|
if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
|
|
265
266
|
if (signal?.aborted) throw new Error("Question cancelled before it opened");
|
|
266
267
|
|
|
268
|
+
const question = safeTerminalText(params.question);
|
|
267
269
|
const options: DisplayOption[] = [
|
|
268
270
|
...params.options.map((option, index) => ({
|
|
269
|
-
label: option.label,
|
|
270
|
-
description: option.description,
|
|
271
|
-
preview: option.preview,
|
|
271
|
+
label: safeTerminalText(option.label),
|
|
272
|
+
description: option.description === undefined ? undefined : safeTerminalText(option.description),
|
|
273
|
+
preview: option.preview === undefined ? undefined : safeTerminalText(option.preview),
|
|
272
274
|
originalIndex: index + 1,
|
|
273
275
|
isOther: false,
|
|
274
276
|
})),
|
|
@@ -599,7 +601,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
599
601
|
} else lines = [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
|
|
600
602
|
} else if (rowBudget <= 5) {
|
|
601
603
|
lines = [
|
|
602
|
-
...boundedQuestionLines(
|
|
604
|
+
...boundedQuestionLines(question, width, Math.max(1, rowBudget - 3)),
|
|
603
605
|
editMode !== "none"
|
|
604
606
|
? `${editMode === "filter" ? "Filter" : "Answer"} ${editMode === "filter" ? filterCount : editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
|
|
605
607
|
: selected ? optionLabel(selected, optionIndex) : "No matching options",
|
|
@@ -607,7 +609,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
607
609
|
editMode !== "none" ? editHint : browseHint,
|
|
608
610
|
];
|
|
609
611
|
} else {
|
|
610
|
-
const questionLines = boundedQuestionLines(
|
|
612
|
+
const questionLines = boundedQuestionLines(question, width, Math.max(1, rowBudget - 5));
|
|
611
613
|
const contentRows = rowBudget - questionLines.length - 4;
|
|
612
614
|
const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
|
|
613
615
|
const detailCapacity = Math.max(0, contentRows - optionCapacity);
|
|
@@ -723,20 +725,26 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
723
725
|
renderCall(args, theme, context) {
|
|
724
726
|
const { mode, minSelections: minimum, maxSelections: maximum } = normalizeQuestionSelection(args);
|
|
725
727
|
const multiple = mode === "multiple";
|
|
728
|
+
const question = safeTerminalText(args.question);
|
|
729
|
+
const options = args.options.map((option) => ({
|
|
730
|
+
label: safeTerminalText(option.label),
|
|
731
|
+
description: option.description === undefined ? undefined : safeTerminalText(option.description),
|
|
732
|
+
preview: option.preview === undefined ? undefined : safeTerminalText(option.preview),
|
|
733
|
+
}));
|
|
726
734
|
if (!context.expanded) {
|
|
727
735
|
const title = multiple ? "question (multi-select) " : "question ";
|
|
728
|
-
const detail = multiple ? `${
|
|
729
|
-
return new BoundedText(`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", oneLine(
|
|
736
|
+
const detail = multiple ? `${options.length} options · choose ${minimum}–${maximum}` : `${options.length} option${options.length === 1 ? "" : "s"}`;
|
|
737
|
+
return new BoundedText(`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", oneLine(question))}\n${theme.fg("dim", ` ${detail}`)}`, 3);
|
|
730
738
|
}
|
|
731
739
|
const title = multiple ? "question (multi-select) " : "question ";
|
|
732
|
-
const lines = [`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted",
|
|
733
|
-
if (multiple) lines.push(theme.fg("dim", `${
|
|
734
|
-
|
|
740
|
+
const lines = [`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", question)}`];
|
|
741
|
+
if (multiple) lines.push(theme.fg("dim", `${options.length} options · choose ${minimum}–${maximum}`));
|
|
742
|
+
options.forEach((option, index) => {
|
|
735
743
|
lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${index + 1}. ${option.label}`));
|
|
736
744
|
if (option.description) lines.push(theme.fg("muted", ` ${option.description}`));
|
|
737
745
|
if (option.preview) lines.push(theme.fg("dim", option.preview));
|
|
738
746
|
});
|
|
739
|
-
lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${
|
|
747
|
+
lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${options.length + 1}. Type a custom answer`));
|
|
740
748
|
return new BoundedText(lines.join("\n"));
|
|
741
749
|
},
|
|
742
750
|
|
|
@@ -744,14 +752,16 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
744
752
|
const details = result.details;
|
|
745
753
|
if (!details) {
|
|
746
754
|
const first = result.content[0];
|
|
747
|
-
return new BoundedText(first?.type === "text" ? first.text : "", options.expanded ? undefined : 3);
|
|
755
|
+
return new BoundedText(first?.type === "text" ? safeTerminalText(first.text) : "", options.expanded ? undefined : 3);
|
|
748
756
|
}
|
|
749
757
|
if (details.cancelled || ("answer" in details && details.answer === null)) return new BoundedText(theme.fg("warning", "Cancelled"));
|
|
750
758
|
if ("mode" in details && details.mode === "multiple") {
|
|
751
|
-
|
|
759
|
+
const answers = details.answers.map(safeTerminalText);
|
|
760
|
+
const customAnswer = details.customAnswer === undefined ? undefined : safeTerminalText(details.customAnswer);
|
|
761
|
+
return new MultipleResultText(answers, options.expanded, customAnswer, theme.fg.bind(theme));
|
|
752
762
|
}
|
|
753
763
|
if (!("answer" in details) || details.answer === null) return new BoundedText("");
|
|
754
|
-
const answer = details.answer;
|
|
764
|
+
const answer = safeTerminalText(details.answer);
|
|
755
765
|
if (details.wasCustom) {
|
|
756
766
|
return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("muted", "(wrote) ")}${theme.fg("accent", answer)}`, options.expanded ? undefined : 3);
|
|
757
767
|
}
|
package/killeros/runtime.ts
CHANGED
|
@@ -28,9 +28,14 @@ export interface GoalBlockerAudit {
|
|
|
28
28
|
lastTurn: number;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export type GoalFileBaseline =
|
|
32
|
+
| { exists: false }
|
|
33
|
+
| { exists: true; size: number; mtimeMs: number };
|
|
34
|
+
|
|
31
35
|
export interface GoalFileVerification {
|
|
32
36
|
kind: "file";
|
|
33
37
|
path: string;
|
|
38
|
+
baseline: GoalFileBaseline;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
export interface GoalState {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/** Remove terminal commands and unsafe controls while preserving line feeds. */
|
|
4
|
+
export function safeTerminalText(value: string): string {
|
|
5
|
+
return stripTerminalSequences(value).replace(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/gu, "");
|
|
6
|
+
}
|