@aliou/pi-processes 0.10.0 → 0.10.2

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.
Files changed (28) hide show
  1. package/extensions/process-tabs.ts +2 -2
  2. package/extensions/processes/commands/kill.ts +12 -6
  3. package/extensions/processes/commands/overview.ts +2 -1
  4. package/extensions/processes/components/overview-component.ts +20 -8
  5. package/extensions/processes/components/overview-panel.ts +2 -1
  6. package/extensions/processes/config/migrations/002-stamp-v0-10-0-config-version.ts +1 -1
  7. package/extensions/processes/message-renderer.ts +8 -4
  8. package/extensions/processes/tools/list/render.ts +3 -2
  9. package/extensions/processes/tools/output/render.ts +4 -3
  10. package/extensions/processes/tools/update/render.ts +3 -2
  11. package/extensions/processes/tools/utils.ts +13 -6
  12. package/extensions/processes-dock/commands/pin.ts +6 -3
  13. package/extensions/processes-dock/components/log-dock-component.ts +16 -23
  14. package/extensions/processes-dock/widget/status.ts +7 -7
  15. package/extensions/processes-logs/commands/logs.ts +2 -1
  16. package/extensions/processes-logs/completions.ts +5 -2
  17. package/extensions/processes-logs/components/log-file-viewer.ts +27 -21
  18. package/extensions/processes-logs/components/log-overlay-component.ts +6 -3
  19. package/extensions/shared/display-text.ts +200 -0
  20. package/extensions/shared/log-line.ts +84 -0
  21. package/extensions/shared/ui.ts +12 -2
  22. package/package.json +2 -8
  23. package/src/manager/process-runtime-controller.ts +35 -15
  24. package/src/utils/ansi.ts +3 -1
  25. package/src/utils/format.ts +0 -8
  26. package/src/utils/index.ts +1 -6
  27. package/src/utils/process-group.ts +4 -0
  28. /package/extensions/{processes/utils → shared}/truncate.ts +0 -0
@@ -1,7 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { ProcessInfo } from "../src/types";
4
- import { truncateCmd } from "../src/utils/format";
4
+ import { truncateForDisplay } from "./shared/display-text";
5
5
  import { MAX_TAB_NAME, statusDot } from "./shared/ui";
6
6
 
7
7
  export { MAX_TAB_NAME, statusDot as renderProcessTabDot };
@@ -19,7 +19,7 @@ export function renderProcessTab(
19
19
  theme: Theme,
20
20
  ): string {
21
21
  const dot = statusDot(process, active, theme);
22
- const label = truncateCmd(process.name, MAX_TAB_NAME);
22
+ const label = truncateForDisplay(process.name, MAX_TAB_NAME);
23
23
  return active
24
24
  ? theme.bg("selectedBg", ` ${dot} ${theme.fg("accent", label)} `)
25
25
  : ` ${dot} ${theme.fg("dim", label)} `;
@@ -10,7 +10,10 @@ import {
10
10
  SelectList,
11
11
  } from "@earendil-works/pi-tui";
12
12
  import { LIVE_STATUSES } from "../../../src/types";
13
- import { formatProcessSelectionDescription } from "../../shared/ui";
13
+ import {
14
+ formatProcessSelectionDescription,
15
+ formatProcessSelectionLabel,
16
+ } from "../../shared/ui";
14
17
  import { requestKill, requestProcess, requestProcessList } from "../client";
15
18
 
16
19
  /**
@@ -42,7 +45,10 @@ export function registerKillCommand(pi: ExtensionAPI): void {
42
45
  return;
43
46
  }
44
47
  if (!LIVE_STATUSES.has(proc.status)) {
45
- ctx.ui.notify(`${proc.name} (${proc.id}) is not running.`, "warning");
48
+ ctx.ui.notify(
49
+ `${formatProcessSelectionLabel(proc)} is not running.`,
50
+ "warning",
51
+ );
46
52
  return;
47
53
  }
48
54
  id = proc.id;
@@ -68,10 +74,10 @@ export function registerKillCommand(pi: ExtensionAPI): void {
68
74
  const timeoutMs = signal === "SIGKILL" ? 200 : 3000;
69
75
  const result = await requestKill(events, id, { signal, timeoutMs });
70
76
  if (result.ok) {
71
- ctx.ui.notify(`Killed ${proc.name} (${proc.id}).`, "info");
77
+ ctx.ui.notify(`Killed ${formatProcessSelectionLabel(proc)}.`, "info");
72
78
  } else {
73
79
  ctx.ui.notify(
74
- `Failed to kill ${proc.name} (${proc.id}): ${result.reason}.`,
80
+ `Failed to kill ${formatProcessSelectionLabel(proc)}: ${result.reason}.`,
75
81
  "warning",
76
82
  );
77
83
  }
@@ -92,7 +98,7 @@ async function pickTarget(
92
98
  const items: SelectItem[] = requestProcessList(events)
93
99
  .filter((process) => LIVE_STATUSES.has(process.status))
94
100
  .map((process) => ({
95
- label: `${process.name} (${process.id})`,
101
+ label: formatProcessSelectionLabel(process),
96
102
  value: process.id,
97
103
  description: formatProcessSelectionDescription(process),
98
104
  }));
@@ -145,7 +151,7 @@ function completions(
145
151
  )
146
152
  .map((process) => ({
147
153
  value: process.id,
148
- label: `${process.name} (${process.id})`,
154
+ label: formatProcessSelectionLabel(process),
149
155
  description: formatProcessSelectionDescription(process),
150
156
  }));
151
157
  return items.length > 0 ? items : null;
@@ -6,6 +6,7 @@ import type {
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import type { ProcessProtocolConfig } from "../../../src/protocol";
8
8
  import type { ProcessInfo } from "../../../src/types";
9
+ import { sanitizeForDisplay } from "../../shared/display-text";
9
10
  import { requestConfig, requestProcess, requestProcessList } from "../client";
10
11
  import { OverviewComponent } from "../components/overview-component";
11
12
 
@@ -96,7 +97,7 @@ function formatPlainProcessList(processes: ProcessInfo[]): string {
96
97
  return processes
97
98
  .map(
98
99
  (process) =>
99
- `${process.id}\t${process.name}\t${process.status}\t${process.command}`,
100
+ `${process.id}\t${sanitizeForDisplay(process.name)}\t${process.status}\t${sanitizeForDisplay(process.command)}`,
100
101
  )
101
102
  .join("\n");
102
103
  }
@@ -6,14 +6,19 @@ import {
6
6
  Key,
7
7
  matchesKey,
8
8
  type TUI,
9
- truncateToWidth,
10
9
  visibleWidth,
11
10
  } from "@earendil-works/pi-tui";
12
11
  import { CHANNELS, type ProcessProtocolConfig } from "../../../src/protocol";
13
12
  import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
14
- import { formatRuntime, truncateCmd } from "../../../src/utils/format";
13
+ import { formatRuntime } from "../../../src/utils/format";
14
+ import {
15
+ sanitizeForDisplay,
16
+ truncateForDisplay,
17
+ } from "../../shared/display-text";
15
18
  import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer";
19
+ import { renderLogLine } from "../../shared/log-line";
16
20
  import { isOutputChangedPayload } from "../../shared/output-payload";
21
+ import { truncateToWidth } from "../../shared/truncate";
17
22
  import { LineComponent, LinesComponent, statusColor } from "../../shared/ui";
18
23
  import {
19
24
  type ProcessLogLine,
@@ -573,7 +578,12 @@ export class OverviewComponent implements Component {
573
578
  width: number,
574
579
  ): string {
575
580
  const t = this.opts.theme;
576
- const name = truncateToWidth(process.name, MAX_NAME_WIDTH, "", true);
581
+ const name = truncateToWidth(
582
+ sanitizeForDisplay(process.name),
583
+ MAX_NAME_WIDTH,
584
+ "",
585
+ true,
586
+ );
577
587
  const id = truncateToWidth(process.id, MAX_ID_WIDTH, "", true);
578
588
  const status = truncateToWidth(
579
589
  formatColoredStatusShort(process, t),
@@ -596,7 +606,7 @@ export class OverviewComponent implements Component {
596
606
  const marker = this.pinnedId === process.id ? t.fg("accent", "◆") : " ";
597
607
  const left = `${marker} ${name}${sep}${dim(id)}${sep}${status}${sep}${runtime}`;
598
608
  const remaining = Math.max(0, width - visibleWidth(left) - 2);
599
- const command = dim(truncateCmd(process.command, remaining));
609
+ const command = dim(truncateForDisplay(process.command, remaining));
600
610
  const line = `${left}${sep}${command}`;
601
611
 
602
612
  if (selected) {
@@ -614,17 +624,19 @@ export class OverviewComponent implements Component {
614
624
  const dim = (value: string) => t.fg("dim", value);
615
625
  const accent = (value: string) => t.fg("accent", value);
616
626
 
617
- const header = `${dim(">")} ${accent(truncateCmd(selected.command, Math.max(1, width - 2)))}`;
627
+ const header = `${dim(">")} ${accent(truncateForDisplay(selected.command, Math.max(1, width - 2)))}`;
618
628
  const body: string[] = [header];
619
629
  const available = this.previewHeight();
620
630
  const total = this.previewLines.length;
621
631
  const start = this.previewOffset;
622
632
  const slice = this.previewLines.slice(start, start + available);
623
633
  for (const line of slice) {
624
- const text =
625
- line.type === "stderr" ? t.fg("warning", line.text) : line.text;
626
634
  body.push(
627
- truncateToWidth(`${dim(PREVIEW_LOG_PREFIX)}${text}`, width, "", true),
635
+ renderLogLine(line, {
636
+ theme: t,
637
+ width,
638
+ prefix: dim(PREVIEW_LOG_PREFIX),
639
+ }),
628
640
  );
629
641
  }
630
642
  while (body.length < available + 1) body.push("");
@@ -1,5 +1,6 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth } from "../../shared/truncate";
3
4
 
4
5
  export interface OverviewPanelOptions {
5
6
  title?: string;
@@ -17,5 +17,5 @@ export const configVersionStampMigration: Migration<ProcessConfig> = {
17
17
  shouldRun: needsConfigVersionStamp,
18
18
  run: (config) => stampConfigVersion(config),
19
19
  message:
20
- "Updated pi-processes settings for v0.10.0. This release rewrites the package into separate process, logs, and dock extensions while preserving your existing settings. Release notes will be published at https://github.com/aliou/pi-processes/releases/tag/v0.10.0.",
20
+ "Updated pi-processes settings for v0.10.0. This release rewrites the package into separate process, logs, and dock extensions while preserving your existing settings. Release notes are available at https://github.com/aliou/pi-processes/releases/tag/v0.10.0.",
21
21
  };
@@ -4,10 +4,9 @@ import type {
4
4
  Theme,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { type Component, Container, Text } from "@earendil-works/pi-tui";
7
-
7
+ import { truncateForDisplay } from "../shared/display-text";
8
8
  import { MESSAGE_TYPE_PROCESS_NOTIFICATION } from "./constants";
9
9
  import type { ProcessNotificationDetails } from "./notifications/types";
10
- import { truncateToWidth } from "./utils/truncate";
11
10
 
12
11
  export function registerProcessNotificationRenderer(pi: ExtensionAPI): void {
13
12
  pi.registerMessageRenderer<ProcessNotificationDetails>(
@@ -41,10 +40,15 @@ function formatSummary(
41
40
  theme: Theme,
42
41
  ): string {
43
42
  const prefix = theme.fg("accent", "[process]");
44
- const name = theme.fg("muted", `"${details.processName}"`);
43
+ const name = theme.fg(
44
+ "muted",
45
+ `"${truncateForDisplay(details.processName, 60)}"`,
46
+ );
45
47
 
46
48
  if (details.logMatch) {
47
- const line = truncateToWidth(details.logMatch.line, 160, "…");
49
+ // Matched text is raw process output: sanitize before it reaches the
50
+ // transcript, or an escape sequence in a log line hits the terminal.
51
+ const line = truncateForDisplay(details.logMatch.line, 160);
48
52
  return `${prefix} ${name} matched ${details.logMatch.stream}: ${line}`;
49
53
  }
50
54
 
@@ -1,8 +1,9 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { formatTimestamp, shortenPath } from "../../../../src/utils";
4
+ import { sanitizeForDisplay } from "../../../shared/display-text";
5
+ import { truncateToWidth } from "../../../shared/truncate";
4
6
  import { LinesComponent } from "../../../shared/ui";
5
- import { truncateToWidth } from "../../utils/truncate";
6
7
  import { formatPatternForDisplay, ProcessActionTitle } from "../components";
7
8
  import type {
8
9
  ProcessesParamsType,
@@ -75,7 +76,7 @@ export function buildCollapsed(details: ListDetails, theme: Theme): Container {
75
76
 
76
77
  for (const process of details.processes.slice(0, 2)) {
77
78
  const parts = [
78
- process.name,
79
+ sanitizeForDisplay(process.name),
79
80
  theme.fg("accent", process.id),
80
81
  `pid ${process.pid}`,
81
82
  formatColoredProcessStatus(process, theme),
@@ -1,6 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, Spacer, Text } from "@earendil-works/pi-tui";
3
3
 
4
+ import { sanitizeForDisplay } from "../../../shared/display-text";
4
5
  import { ProcessActionHeader } from "../components";
5
6
  import type { ProcessesParamsType } from "../schema";
6
7
  import { buildField } from "../utils";
@@ -32,7 +33,7 @@ export function buildExpanded(
32
33
  if (bodyLines.length > 0) {
33
34
  container.addChild(new Spacer(1));
34
35
  for (const line of bodyLines) {
35
- container.addChild(new Text(line, 0, 0));
36
+ container.addChild(new Text(sanitizeForDisplay(line), 0, 0));
36
37
  }
37
38
  } else {
38
39
  container.addChild(new Spacer(1));
@@ -61,7 +62,7 @@ export function buildCollapsed(
61
62
  container.addChild(
62
63
  new Text(
63
64
  [
64
- details.processName,
65
+ sanitizeForDisplay(details.processName),
65
66
  theme.fg("accent", details.id),
66
67
  theme.fg(getStatusTone(details), details.processStatus),
67
68
  ]
@@ -73,7 +74,7 @@ export function buildCollapsed(
73
74
  );
74
75
 
75
76
  const bodyLines = extractOutputBody(contentText, details);
76
- const preview = bodyLines.slice(-2).join("\n");
77
+ const preview = bodyLines.slice(-2).map(sanitizeForDisplay).join("\n");
77
78
 
78
79
  if (preview) {
79
80
  container.addChild(new Text(theme.fg("muted", preview), 0, 0));
@@ -1,5 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, Text } from "@earendil-works/pi-tui";
3
+ import { sanitizeForDisplay } from "../../../shared/display-text";
3
4
  import type { LogMatcherConfig } from "../../notifications/registry";
4
5
  import { buildMatcherLine, ProcessActionHeader } from "../components";
5
6
  import type { ProcessesParamsType } from "../schema";
@@ -31,7 +32,7 @@ export function buildExpanded(details: UpdateDetails, theme: Theme): Container {
31
32
  if (details.renamed && details.previousName && details.process) {
32
33
  container.addChild(
33
34
  new Text(
34
- `${theme.fg("muted", "renamed:")} ${details.previousName} -> ${theme.fg("accent", details.process.name)}`,
35
+ `${theme.fg("muted", "renamed:")} ${sanitizeForDisplay(details.previousName)} -> ${theme.fg("accent", sanitizeForDisplay(details.process.name))}`,
35
36
  0,
36
37
  0,
37
38
  ),
@@ -62,7 +63,7 @@ export function buildCollapsed(
62
63
  container.addChild(
63
64
  buildField(
64
65
  "renamed",
65
- `${details.previousName} -> ${details.process.name}`,
66
+ `${sanitizeForDisplay(details.previousName)} -> ${sanitizeForDisplay(details.process.name)}`,
66
67
  theme,
67
68
  ),
68
69
  );
@@ -8,8 +8,11 @@ import {
8
8
  formatTimestamp,
9
9
  shortenPath,
10
10
  } from "../../../src/utils";
11
+ import {
12
+ sanitizeForDisplay,
13
+ truncateForDisplay,
14
+ } from "../../shared/display-text";
11
15
  import { statusColor } from "../../shared/ui";
12
- import { truncateToWidth } from "../utils/truncate";
13
16
 
14
17
  export interface RenderOptions {
15
18
  expanded?: boolean;
@@ -33,8 +36,8 @@ export function buildCommandField(
33
36
  options?: { truncate?: boolean },
34
37
  ): Text {
35
38
  const value = options?.truncate
36
- ? truncateToWidth(command, 80, theme.fg("accent", "…"))
37
- : command;
39
+ ? truncateForDisplay(command, 80)
40
+ : sanitizeForDisplay(command);
38
41
  return buildField("command", theme.fg("accent", `\`${value}\``), theme);
39
42
  }
40
43
 
@@ -45,7 +48,9 @@ export function buildProcessDetails(
45
48
  ): Container {
46
49
  const container = new Container();
47
50
  container.addChild(buildField("id", process.id, theme));
48
- container.addChild(buildField("name", process.name, theme));
51
+ container.addChild(
52
+ buildField("name", sanitizeForDisplay(process.name), theme),
53
+ );
49
54
  container.addChild(
50
55
  buildField("status", formatColoredProcessStatus(process, theme), theme),
51
56
  );
@@ -58,7 +63,9 @@ export function buildProcessDetails(
58
63
  container.addChild(
59
64
  buildField("started", formatTimestamp(process.startTime), theme),
60
65
  );
61
- container.addChild(buildField("cwd", shortenPath(process.cwd), theme));
66
+ container.addChild(
67
+ buildField("cwd", sanitizeForDisplay(shortenPath(process.cwd)), theme),
68
+ );
62
69
  container.addChild(buildCommandField(process.command, theme));
63
70
  container.addChild(buildField("stdout", process.stdoutFile, theme));
64
71
  container.addChild(buildField("stderr", process.stderrFile, theme));
@@ -71,7 +78,7 @@ export function buildProcessSummaryRow(
71
78
  ): Text {
72
79
  return new Text(
73
80
  [
74
- process.name,
81
+ sanitizeForDisplay(process.name),
75
82
  theme.fg("accent", process.id),
76
83
  `pid ${process.pid}`,
77
84
  formatColoredProcessStatus(process, theme),
@@ -9,7 +9,10 @@ import {
9
9
  type SelectItem,
10
10
  SelectList,
11
11
  } from "@earendil-works/pi-tui";
12
- import { formatProcessSelectionDescription } from "../../shared/ui";
12
+ import {
13
+ formatProcessSelectionDescription,
14
+ formatProcessSelectionLabel,
15
+ } from "../../shared/ui";
13
16
  import { requestProcess, requestProcessList } from "../client";
14
17
  import type { DockController } from "../widget/setup";
15
18
 
@@ -83,7 +86,7 @@ async function pickPinTarget(
83
86
  ]
84
87
  : []),
85
88
  ...processes.map((process) => ({
86
- label: `${process.name} (${process.id})`,
89
+ label: formatProcessSelectionLabel(process),
87
90
  value: process.id,
88
91
  description: formatProcessSelectionDescription(
89
92
  process,
@@ -149,7 +152,7 @@ function completions(
149
152
  )
150
153
  .map((process) => ({
151
154
  value: process.id,
152
- label: `${process.name} (${process.id})`,
155
+ label: formatProcessSelectionLabel(process),
153
156
  description: formatProcessSelectionDescription(process),
154
157
  }));
155
158
  const items = [...clearItems, ...processItems];
@@ -1,14 +1,12 @@
1
1
  import { Stack } from "@aliou/pi-utils-ui";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
- import {
4
- type Component,
5
- truncateToWidth,
6
- visibleWidth,
7
- } from "@earendil-works/pi-tui";
3
+ import { type Component, visibleWidth } from "@earendil-works/pi-tui";
8
4
  import type { ProcessInfo } from "../../../src/types";
9
5
  import { LIVE_STATUSES } from "../../../src/types";
10
- import { stripAnsi } from "../../../src/utils/ansi";
11
6
  import { renderProcessTab } from "../../process-tabs";
7
+ import { sanitizeForDisplay } from "../../shared/display-text";
8
+ import { displayTextOf, renderLogLine } from "../../shared/log-line";
9
+ import { truncateToWidth } from "../../shared/truncate";
12
10
  import {
13
11
  clampNameColumn,
14
12
  LineComponent,
@@ -278,18 +276,11 @@ function renderAllRunningLogLines(
278
276
  );
279
277
  const nameCol = clampNameColumn(activeProcesses);
280
278
  const separator = theme.fg("dim", " │ ");
281
- const sepLen = visibleWidth(separator);
282
279
 
283
280
  return stream.slice(-height).map(({ processId, line }) => {
284
281
  const process = byId.get(processId);
285
282
  const label = theme.fg("dim", padName(process?.name ?? processId, nameCol));
286
- const text = renderLogText(
287
- line,
288
- snapshot,
289
- theme,
290
- Math.max(1, width - nameCol - sepLen),
291
- );
292
- return truncateToWidth(`${label}${separator}${text}`, width, "", true);
283
+ return renderLogText(line, snapshot, theme, width, `${label}${separator}`);
293
284
  });
294
285
  }
295
286
 
@@ -311,15 +302,17 @@ function renderLogText(
311
302
  snapshot: LogDockSnapshot,
312
303
  theme: Theme,
313
304
  width: number,
305
+ prefix = "",
314
306
  ): string {
315
- const text = truncateToWidth(stripAnsi(line.text), width, "", true);
316
- if (snapshot.notifyLines.has(line.text) || snapshot.notifyLines.has(text)) {
317
- return truncateToWidth(theme.underline(text), width);
318
- }
319
- if (line.type === "stderr") {
320
- return truncateToWidth(theme.fg("warning", text), width);
321
- }
322
- return truncateToWidth(text, width);
307
+ const notified =
308
+ snapshot.notifyLines.has(line.text) ||
309
+ snapshot.notifyLines.has(displayTextOf(line));
310
+ return renderLogLine(line, {
311
+ theme,
312
+ width,
313
+ prefix,
314
+ emphasis: notified ? "notify" : "none",
315
+ });
323
316
  }
324
317
 
325
318
  function renderProcessToken(
@@ -370,6 +363,6 @@ function centerLine(content: string, width: number, height: number): string[] {
370
363
  }
371
364
 
372
365
  function padName(value: string, width: number): string {
373
- const name = truncateToWidth(value, width, "", true);
366
+ const name = truncateToWidth(sanitizeForDisplay(value), width, "", true);
374
367
  return `${name}${" ".repeat(Math.max(0, width - visibleWidth(name)))}`;
375
368
  }
@@ -1,7 +1,8 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
-
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
3
  import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
4
+ import { truncateForDisplay } from "../../shared/display-text";
5
+ import { truncateToWidth } from "../../shared/truncate";
5
6
  import { statusColor, statusDot } from "../../shared/ui";
6
7
 
7
8
  const MAX_PROCESS_NAME = 20;
@@ -12,11 +13,10 @@ const DEFAULT_MAX_WIDTH = 200;
12
13
  * and the name always agree.
13
14
  */
14
15
  function formatProcessName(process: ProcessInfo, theme: Theme): string {
15
- const trimmed =
16
- process.name.length > MAX_PROCESS_NAME
17
- ? `${process.name.slice(0, MAX_PROCESS_NAME - 3)}...`
18
- : process.name;
19
- return theme.fg(statusColor(process), trimmed);
16
+ return theme.fg(
17
+ statusColor(process),
18
+ truncateForDisplay(process.name, MAX_PROCESS_NAME),
19
+ );
20
20
  }
21
21
 
22
22
  /**
@@ -6,6 +6,7 @@ import type {
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import type { ProcessProtocolConfig } from "../../../src/protocol";
8
8
  import type { ProcessInfo } from "../../../src/types";
9
+ import { sanitizeForDisplay } from "../../shared/display-text";
9
10
  import { requestConfig, requestProcess, requestProcessList } from "../client";
10
11
  import { allProcessCompletions } from "../completions";
11
12
  import { LogOverlayComponent } from "../components/log-overlay-component";
@@ -102,7 +103,7 @@ function formatPlainProcessList(processes: ProcessInfo[]): string {
102
103
  return processes
103
104
  .map(
104
105
  (process) =>
105
- `${process.id}\t${process.name}\t${process.status}\t${process.command}`,
106
+ `${process.id}\t${sanitizeForDisplay(process.name)}\t${process.status}\t${sanitizeForDisplay(process.command)}`,
106
107
  )
107
108
  .join("\n");
108
109
  }
@@ -1,6 +1,9 @@
1
1
  import type { EventBus } from "@earendil-works/pi-coding-agent";
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
- import { formatProcessSelectionDescription } from "../shared/ui";
3
+ import {
4
+ formatProcessSelectionDescription,
5
+ formatProcessSelectionLabel,
6
+ } from "../shared/ui";
4
7
  import { requestProcessList } from "./client";
5
8
 
6
9
  export function allProcessCompletions(
@@ -23,7 +26,7 @@ function buildCompletions(
23
26
  )
24
27
  .map((process) => ({
25
28
  value: process.id,
26
- label: `${process.name} (${process.id})`,
29
+ label: formatProcessSelectionLabel(process),
27
30
  description: formatProcessSelectionDescription(process),
28
31
  }));
29
32
 
@@ -1,6 +1,9 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { sanitizeForDisplay } from "../../shared/display-text";
3
4
  import { trimToBudget } from "../../shared/line-buffer";
5
+ import { type LogLineEmphasis, renderLogLine } from "../../shared/log-line";
6
+ import { truncateToWidth } from "../../shared/truncate";
4
7
  import type { ProcessLogLine } from "../logs-client";
5
8
 
6
9
  export type StreamFilter = "both" | "stdout" | "stderr";
@@ -29,7 +32,7 @@ export class LogFileViewer {
29
32
  ) {
30
33
  this.follow = options.followEnabled;
31
34
  this.lines = trimToBudget(
32
- initialLines,
35
+ initialLines.map(sanitizeLine),
33
36
  options.maxBufferLines,
34
37
  options.maxBufferBytes ?? Number.MAX_SAFE_INTEGER,
35
38
  );
@@ -38,7 +41,7 @@ export class LogFileViewer {
38
41
 
39
42
  appendLines(lines: ProcessLogLine[]): void {
40
43
  if (lines.length === 0) return;
41
- this.lines.push(...lines);
44
+ this.lines.push(...lines.map(sanitizeLine));
42
45
  this.lines = trimToBudget(
43
46
  this.lines,
44
47
  this.options.maxBufferLines,
@@ -119,7 +122,9 @@ export class LogFileViewer {
119
122
  * priority (search current match > search match > notify match > stream).
120
123
  */
121
124
  addNotifyMatch(match: { line: string }): void {
122
- if (match.line) this.notifyLines.add(match.line);
125
+ // Stored sanitized so it can be compared against sanitized buffer lines.
126
+ const line = sanitizeForDisplay(match.line);
127
+ if (line) this.notifyLines.add(line);
123
128
  }
124
129
 
125
130
  clearNotifyMatches(): void {
@@ -182,23 +187,15 @@ export class LogFileViewer {
182
187
 
183
188
  const rendered = visible.slice(start, end).map((line, index) => {
184
189
  const visibleIndex = start + index;
185
- const text = truncateToWidth(line.text, width, "", true);
186
- if (visibleIndex === currentMatchIndex) {
187
- return truncateToWidth(
188
- this.theme.bold(this.theme.inverse(text)),
189
- width,
190
- );
191
- }
192
- if (matchSet.has(visibleIndex)) {
193
- return truncateToWidth(this.theme.fg("warning", text), width);
194
- }
195
- if (this.notifyLines.has(line.text)) {
196
- return truncateToWidth(this.theme.underline(text), width);
197
- }
198
- if (line.type === "stderr") {
199
- return truncateToWidth(this.theme.fg("warning", text), width);
200
- }
201
- return truncateToWidth(text, width);
190
+ const emphasis: LogLineEmphasis =
191
+ visibleIndex === currentMatchIndex
192
+ ? "search-current"
193
+ : matchSet.has(visibleIndex)
194
+ ? "search"
195
+ : this.notifyLines.has(line.text)
196
+ ? "notify"
197
+ : "none";
198
+ return renderLogLine(line, { theme: this.theme, width, emphasis });
202
199
  });
203
200
 
204
201
  while (rendered.length < height) rendered.unshift("");
@@ -272,3 +269,12 @@ export class LogFileViewer {
272
269
  this.centerTarget = index;
273
270
  }
274
271
  }
272
+
273
+ /**
274
+ * Log lines are untrusted terminal output. Sanitize on ingest so search,
275
+ * notify-match comparison, and rendering all see the same safe text.
276
+ */
277
+ function sanitizeLine(line: ProcessLogLine): ProcessLogLine {
278
+ const text = sanitizeForDisplay(line.text);
279
+ return text === line.text ? line : { ...line, text };
280
+ }
@@ -6,7 +6,6 @@ import {
6
6
  Key,
7
7
  matchesKey,
8
8
  type TUI,
9
- truncateToWidth,
10
9
  visibleWidth,
11
10
  } from "@earendil-works/pi-tui";
12
11
  import {
@@ -16,9 +15,11 @@ import {
16
15
  type ProcessProtocolNotificationPayload,
17
16
  } from "../../../src/protocol";
18
17
  import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
19
- import { formatRuntime, truncateCmd } from "../../../src/utils/format";
18
+ import { formatRuntime } from "../../../src/utils/format";
20
19
  import { isRecord } from "../../../src/utils/is-record";
21
20
  import { renderProcessTab } from "../../process-tabs";
21
+ import { truncateForDisplay } from "../../shared/display-text";
22
+ import { truncateToWidth } from "../../shared/truncate";
22
23
  import { LineComponent, LinesComponent, RuleComponent } from "../../shared/ui";
23
24
  import { requestProcessList } from "../client";
24
25
  import {
@@ -559,7 +560,9 @@ export class LogOverlayComponent implements Component {
559
560
  4,
560
561
  width - visibleWidth(leftPrefix) - durationWidth - 1,
561
562
  );
562
- const command = dim(truncateCmd(process.command, availableCommandWidth));
563
+ const command = dim(
564
+ truncateForDisplay(process.command, availableCommandWidth),
565
+ );
563
566
  const left = `${leftPrefix}${command}`;
564
567
  const gap = Math.max(1, width - visibleWidth(left) - durationWidth);
565
568
  return truncateToWidth(`${left}${" ".repeat(gap)}${duration}`, width);
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Make untrusted process output safe to render in the TUI.
3
+ *
4
+ * This module lives outside pi-agnostic `src/` because tab expansion needs the
5
+ * Pi TUI width measurement to stay in sync with what the renderer computes.
6
+ */
7
+
8
+ import { visibleWidth } from "@earendil-works/pi-tui";
9
+
10
+ import { truncateToWidth } from "./truncate";
11
+
12
+ const ESC = String.fromCodePoint(0x001b);
13
+ const BEL = String.fromCodePoint(0x0007);
14
+ const ST = String.fromCodePoint(0x009c);
15
+ const RESET = `${ESC}[0m`;
16
+
17
+ // Control characters a single display row must never contain. Tabs are handled
18
+ // separately; newlines are dropped because they would shift the whole frame.
19
+ // C1 controls are included: a raw \u009b is an alias for CSI on some terminals.
20
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally targets terminal control characters.
21
+ const DISPLAY_CONTROL_CHARS = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f]/gu;
22
+
23
+ // SGR parameters we consider safe to keep: colors and attributes only.
24
+ const SGR_PARAMS = /^[0-9;:]*$/u;
25
+
26
+ // Terminals advance tabs to the next 8-column stop, but the TUI measures a tab
27
+ // as a fixed width. Expanding here keeps measured width and drawn width equal.
28
+ const TAB_WIDTH = 8;
29
+
30
+ /**
31
+ * Sanitize one line of terminal output for display while keeping its colors.
32
+ *
33
+ * Keeps SGR sequences (`ESC[...m`) and drops everything else: cursor
34
+ * movement, erase, scroll regions, alternate screen switches, OSC/DCS/APC
35
+ * strings (terminated or not), charset designators, lone escapes, newlines,
36
+ * and C0/C1 control characters. Tabs are expanded to spaces. Those are the
37
+ * inputs that corrupt the screen, because they either act on the real
38
+ * terminal or make width measurement disagree with what the terminal draws.
39
+ *
40
+ * A trailing reset is appended when any SGR survives so colors cannot bleed
41
+ * into the rest of the frame.
42
+ */
43
+ export function sanitizeForDisplay(text: string): string {
44
+ if (!text.includes(ESC)) return cleanPlainText(text, 0).text;
45
+
46
+ let out = "";
47
+ let cursor = 0;
48
+ let column = 0;
49
+ let keptSgr = false;
50
+
51
+ while (cursor < text.length) {
52
+ const escapeAt = text.indexOf(ESC, cursor);
53
+ if (escapeAt === -1) {
54
+ out += cleanPlainText(text.slice(cursor), column).text;
55
+ break;
56
+ }
57
+ const chunk = cleanPlainText(text.slice(cursor, escapeAt), column);
58
+ out += chunk.text;
59
+ column = chunk.column;
60
+
61
+ const sequence = readEscapeSequence(text, escapeAt);
62
+ if (sequence.isSgr) {
63
+ out += text.slice(escapeAt, sequence.end);
64
+ keptSgr = true;
65
+ }
66
+ cursor = sequence.end;
67
+ }
68
+
69
+ if (!keptSgr || out.endsWith(RESET)) return out;
70
+ return `${out}${RESET}`;
71
+ }
72
+
73
+ /**
74
+ * Fit a single-line label, such as a command or a process name, into `width`
75
+ * terminal cells. Sanitizes first, then truncates by display width so wide
76
+ * characters and emoji cannot over-run the row or get cut mid-grapheme.
77
+ */
78
+ export function truncateForDisplay(text: string, width: number): string {
79
+ return closeSgr(truncateToWidth(sanitizeForDisplay(text), width, "…"));
80
+ }
81
+
82
+ /**
83
+ * Re-close colors after truncation. `sanitizeForDisplay` ends a colored string
84
+ * with a reset, but truncating can cut that reset off and let the color bleed
85
+ * into the rest of the frame.
86
+ */
87
+ export function closeSgr(text: string): string {
88
+ if (!text.includes(ESC) || text.trimEnd().endsWith(RESET)) return text;
89
+ return `${text}${RESET}`;
90
+ }
91
+
92
+ /**
93
+ * Drop control characters and expand tabs, starting from `column` so tab stops
94
+ * line up across the chunks of a line split by escape sequences. Columns are
95
+ * measured in terminal cells, so wide and zero-width characters land right.
96
+ */
97
+ function cleanPlainText(
98
+ text: string,
99
+ column: number,
100
+ ): { text: string; column: number } {
101
+ const clean = text.replace(DISPLAY_CONTROL_CHARS, "");
102
+ if (!clean.includes("\t")) {
103
+ return { text: clean, column: column + visibleWidth(clean) };
104
+ }
105
+
106
+ const parts = clean.split("\t");
107
+ let out = parts[0] ?? "";
108
+ let col = column + visibleWidth(out);
109
+ for (const part of parts.slice(1)) {
110
+ const spaces = TAB_WIDTH - (col % TAB_WIDTH);
111
+ out += " ".repeat(spaces) + part;
112
+ col += spaces + visibleWidth(part);
113
+ }
114
+ return { text: out, column: col };
115
+ }
116
+
117
+ /**
118
+ * Find the end of the escape sequence starting at `start` (which must point at
119
+ * an ESC) and report whether it is a plain SGR sequence.
120
+ *
121
+ * Unterminated sequences swallow the rest of the string: a terminal would do
122
+ * the same, so dropping the tail is what keeps the screen intact.
123
+ */
124
+ function readEscapeSequence(
125
+ text: string,
126
+ start: number,
127
+ ): { end: number; isSgr: boolean } {
128
+ const next = text[start + 1];
129
+ if (next === undefined) return { end: text.length, isSgr: false };
130
+
131
+ if (next === "[") return readControlSequence(text, start);
132
+
133
+ // OSC (]) and APC (_): standard terminator is ST, but BEL is widely used
134
+ // for both in practice (pi's own cursor marker included), so accept it.
135
+ if (next === "]" || next === "_") {
136
+ return { end: findStringTerminator(text, start + 2, true), isSgr: false };
137
+ }
138
+
139
+ // DCS (P), SOS (X), PM (^): ST only. A BEL inside a sixel payload is data.
140
+ if (next === "P" || next === "X" || next === "^") {
141
+ return { end: findStringTerminator(text, start + 2, false), isSgr: false };
142
+ }
143
+
144
+ // Sequences with one intermediate byte: charset designators (ESC ( B),
145
+ // ESC % G, ESC # 8, and friends.
146
+ if (next >= "\u0020" && next <= "\u002f") {
147
+ return { end: Math.min(start + 3, text.length), isSgr: false };
148
+ }
149
+
150
+ // Everything else is a two-byte escape: ESC c (reset), ESC 7/8, ESC =, ...
151
+ return { end: start + 2, isSgr: false };
152
+ }
153
+
154
+ /** Index just past the terminator of a string sequence, else end of input. */
155
+ function findStringTerminator(
156
+ text: string,
157
+ from: number,
158
+ allowBel: boolean,
159
+ ): number {
160
+ for (let index = from; index < text.length; index++) {
161
+ if (allowBel && text[index] === BEL) return index + 1;
162
+ if (text[index] === ST) return index + 1;
163
+ if (text[index] === ESC && text[index + 1] === "\\") return index + 2;
164
+ }
165
+ return text.length;
166
+ }
167
+
168
+ function readControlSequence(
169
+ text: string,
170
+ start: number,
171
+ ): { end: number; isSgr: boolean } {
172
+ let index = start + 2;
173
+ const paramStart = index;
174
+ while (
175
+ index < text.length &&
176
+ text[index] >= "\u0030" &&
177
+ text[index] <= "\u003f"
178
+ ) {
179
+ index++;
180
+ }
181
+ const paramEnd = index;
182
+ while (
183
+ index < text.length &&
184
+ text[index] >= "\u0020" &&
185
+ text[index] <= "\u002f"
186
+ ) {
187
+ index++;
188
+ }
189
+ const hasIntermediates = index > paramEnd;
190
+ const final = text[index];
191
+ if (final === undefined || final < "\u0040" || final > "\u007e") {
192
+ return { end: text.length, isSgr: false };
193
+ }
194
+
195
+ const isSgr =
196
+ final === "m" &&
197
+ !hasIntermediates &&
198
+ SGR_PARAMS.test(text.slice(paramStart, paramEnd));
199
+ return { end: index + 1, isSgr };
200
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * One renderer for a single line of process output, shared by the `/ps`
3
+ * preview, the `/ps:logs` overlay, and the dock.
4
+ *
5
+ * Every log view sanitizes untrusted output, truncates it to the row width,
6
+ * and tones it by stream and match state. Keeping that in one place is what
7
+ * stops the three views from drifting apart again.
8
+ */
9
+
10
+ import type { Theme } from "@earendil-works/pi-coding-agent";
11
+ import { visibleWidth } from "@earendil-works/pi-tui";
12
+
13
+ import { closeSgr, sanitizeForDisplay } from "./display-text";
14
+ import { truncateToWidth } from "./truncate";
15
+
16
+ export interface DisplayLogLine {
17
+ type: "stdout" | "stderr";
18
+ text: string;
19
+ }
20
+
21
+ /**
22
+ * Why a line stands out, in priority order. `search-current` is the match the
23
+ * user is sitting on, `search` is any other hit, `notify` is a watch match.
24
+ */
25
+ export type LogLineEmphasis = "none" | "notify" | "search" | "search-current";
26
+
27
+ export interface RenderLogLineOptions {
28
+ theme: Theme;
29
+ /** Total row width, including `prefix`. */
30
+ width: number;
31
+ emphasis?: LogLineEmphasis;
32
+ /** Already-styled row prefix, such as the dock's process label. */
33
+ prefix?: string;
34
+ /** Drop colors from the log text itself. Match and stream tones still apply. */
35
+ plain?: boolean;
36
+ }
37
+
38
+ export function renderLogLine(
39
+ line: DisplayLogLine,
40
+ options: RenderLogLineOptions,
41
+ ): string {
42
+ const {
43
+ theme,
44
+ width,
45
+ emphasis = "none",
46
+ prefix = "",
47
+ plain = false,
48
+ } = options;
49
+ if (width <= 0) return "";
50
+
51
+ const prefixWidth = visibleWidth(prefix);
52
+ const textWidth = Math.max(1, width - prefixWidth);
53
+ const safe = sanitizeForDisplay(line.text);
54
+ const text = closeSgr(
55
+ truncateToWidth(plain ? stripSgr(safe) : safe, textWidth, "", true),
56
+ );
57
+
58
+ return `${prefix}${toneLogText(text, line.type, emphasis, theme)}`;
59
+ }
60
+
61
+ /** Text of a log line as the views display it, for match comparisons. */
62
+ export function displayTextOf(line: DisplayLogLine): string {
63
+ return sanitizeForDisplay(line.text);
64
+ }
65
+
66
+ function toneLogText(
67
+ text: string,
68
+ type: DisplayLogLine["type"],
69
+ emphasis: LogLineEmphasis,
70
+ theme: Theme,
71
+ ): string {
72
+ if (emphasis === "search-current") return theme.bold(theme.inverse(text));
73
+ if (emphasis === "search") return theme.fg("warning", text);
74
+ if (emphasis === "notify") return theme.underline(text);
75
+ if (type === "stderr") return theme.fg("warning", text);
76
+ return text;
77
+ }
78
+
79
+ const SGR = new RegExp(`${String.fromCodePoint(0x001b)}\\[[0-9;:]*m`, "gu");
80
+
81
+ /** Drop the SGR sequences `sanitizeForDisplay` kept. */
82
+ function stripSgr(text: string): string {
83
+ return text.replace(SGR, "");
84
+ }
@@ -13,6 +13,7 @@ import { type Component, visibleWidth } from "@earendil-works/pi-tui";
13
13
 
14
14
  import { LIVE_STATUSES, type ProcessInfo } from "../../src/types";
15
15
  import { formatStatus } from "../../src/utils/format";
16
+ import { sanitizeForDisplay } from "./display-text";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Render components
@@ -72,7 +73,7 @@ export function clampNameColumn(
72
73
  min: number = MIN_NAME_COLUMN,
73
74
  ): number {
74
75
  const longest = processes.reduce(
75
- (acc, p) => Math.max(acc, visibleWidth(p.name)),
76
+ (acc, p) => Math.max(acc, visibleWidth(sanitizeForDisplay(p.name))),
76
77
  0,
77
78
  );
78
79
  return Math.min(Math.max(longest, min), max);
@@ -132,6 +133,15 @@ export function statusDot(
132
133
  return theme.fg("dim", "■");
133
134
  }
134
135
 
136
+ /**
137
+ * Build the `label` string for process picker items and autocomplete
138
+ * completions: `"api (proc_1)"`. Names are untrusted display text, so they are
139
+ * sanitized here instead of at every call site.
140
+ */
141
+ export function formatProcessSelectionLabel(process: ProcessInfo): string {
142
+ return `${sanitizeForDisplay(process.name)} (${process.id})`;
143
+ }
144
+
135
145
  /**
136
146
  * Build the `description` string for process picker items and autocomplete
137
147
  * completions: `"running — pnpm dev"`. Shared so `/ps:kill`, `/ps:logs`,
@@ -141,6 +151,6 @@ export function formatProcessSelectionDescription(
141
151
  process: ProcessInfo,
142
152
  suffix = "",
143
153
  ): string {
144
- const base = `${formatStatus(process)} — ${process.command}`;
154
+ const base = `${formatStatus(process)} — ${sanitizeForDisplay(process.command)}`;
145
155
  return suffix ? `${base}${suffix}` : base;
146
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -68,12 +68,6 @@
68
68
  "typescript": "^5.9.3",
69
69
  "vitest": "^4.1.5"
70
70
  },
71
- "pnpm": {
72
- "overrides": {
73
- "@earendil-works/pi-ai": "$@earendil-works/pi-coding-agent",
74
- "@earendil-works/pi-tui": "$@earendil-works/pi-coding-agent"
75
- }
76
- },
77
71
  "scripts": {
78
72
  "typecheck": "tsc --noEmit",
79
73
  "lint": "biome check",
@@ -88,7 +82,7 @@
88
82
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
89
83
  "release": "pnpm changeset publish"
90
84
  },
91
- "packageManager": "pnpm@10.26.1",
85
+ "packageManager": "pnpm@11.5.1",
92
86
  "peerDependenciesMeta": {
93
87
  "@earendil-works/pi-ai": {
94
88
  "optional": true
@@ -83,6 +83,11 @@ export class ProcessRuntimeController {
83
83
  this.registry.add(managed);
84
84
 
85
85
  if (!child.pid) {
86
+ // No pid means the process never started. The async spawn `error`
87
+ // event may not have been delivered yet (Node emits it on a later
88
+ // turn of the event loop, and it cannot fire while start() runs),
89
+ // so attach a handler that finalizes the record with the real
90
+ // reason when it arrives.
86
91
  this.logs.appendErrorLine(managed.stderrFile, "Spawn error: missing pid");
87
92
  managed.exitCode = -1;
88
93
  managed.success = false;
@@ -91,6 +96,14 @@ export class ProcessRuntimeController {
91
96
  managed.endTime = Date.now();
92
97
  this.releaseRuntimeHandles(managed);
93
98
  this.transition(managed, "exited");
99
+ child.on("error", (err) => {
100
+ this.logs.appendErrorLine(
101
+ managed.stderrFile,
102
+ `Process error: ${err.message}`,
103
+ );
104
+ managed.endReason = "spawn_error";
105
+ managed.errorMessage = err.message;
106
+ });
94
107
  return managed;
95
108
  }
96
109
 
@@ -361,24 +374,31 @@ export class ProcessRuntimeController {
361
374
  });
362
375
 
363
376
  child.on("error", (err) => {
364
- this.logs.appendErrorLine(
365
- managed.stderrFile,
366
- `Process error: ${err.message}`,
367
- );
368
-
369
- if (!managed.endTime) {
370
- this.releaseRuntimeHandles(managed);
371
- managed.exitCode = -1;
372
- managed.success = false;
373
- managed.endReason = "spawn_error";
374
- managed.errorMessage = err.message;
375
- managed.endTime = Date.now();
376
- this.output.flush(managed);
377
- this.transition(managed, "exited");
378
- }
377
+ this.handleSpawnError(managed, err);
379
378
  });
380
379
  }
381
380
 
381
+ private handleSpawnError(
382
+ managed: ManagedProcessRecord,
383
+ err: NodeJS.ErrnoException,
384
+ ): void {
385
+ this.logs.appendErrorLine(
386
+ managed.stderrFile,
387
+ `Process error: ${err.message}`,
388
+ );
389
+
390
+ if (managed.endTime) return;
391
+
392
+ this.releaseRuntimeHandles(managed);
393
+ managed.exitCode = -1;
394
+ managed.success = false;
395
+ managed.endReason = "spawn_error";
396
+ managed.errorMessage = err.message;
397
+ managed.endTime = Date.now();
398
+ this.output.flush(managed);
399
+ this.transition(managed, "exited");
400
+ }
401
+
382
402
  private ensureWatcherRunning(): void {
383
403
  if (this.watcher) return;
384
404
  if (!this.registry.hasAliveishProcesses()) return;
package/src/utils/ansi.ts CHANGED
@@ -22,8 +22,10 @@ const ANSI_REPLACEMENTS: RegExp[] = [
22
22
  // Strip C0 terminal control characters that can corrupt TUI layout when
23
23
  // rendered back into pi, such as carriage return and backspace. Keep tabs and
24
24
  // newlines because logs use them as printable whitespace/line breaks.
25
+ // C1 controls are stripped too: a raw \u009b is an alias for CSI on some
26
+ // terminals, so leaving it in would reopen the escape-sequence hole.
25
27
  // biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally targets terminal control characters.
26
- const TERMINAL_CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f]/gu;
28
+ const TERMINAL_CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
27
29
 
28
30
  /** Check if a string contains ANSI escape codes. */
29
31
  export function hasAnsi(str: string): boolean {
@@ -49,14 +49,6 @@ export function formatStatus(proc: {
49
49
  }
50
50
  }
51
51
 
52
- /**
53
- * Truncate a command string to a maximum length.
54
- */
55
- export function truncateCmd(cmd: string, max = 40): string {
56
- if (cmd.length <= max) return cmd;
57
- return `${cmd.slice(0, max - 3)}...`;
58
- }
59
-
60
52
  /**
61
53
  * Format a timestamp as an ISO string or "-" if null.
62
54
  */
@@ -1,11 +1,6 @@
1
1
  export { hasAnsi, stripAnsi } from "./ansi";
2
2
  export { resolveShellExecutable, spawnCommand } from "./command-executor";
3
- export {
4
- formatRuntime,
5
- formatStatus,
6
- formatTimestamp,
7
- truncateCmd,
8
- } from "./format";
3
+ export { formatRuntime, formatStatus, formatTimestamp } from "./format";
9
4
  export type { LineMatchMode } from "./match-line";
10
5
  export { compileLineMatcher } from "./match-line";
11
6
  export { isProcessGroupAlive, killProcessGroup } from "./process-group";
@@ -3,6 +3,7 @@
3
3
  * Uses signal 0 to test existence without actually sending a signal.
4
4
  */
5
5
  export function isProcessGroupAlive(pgid: number): boolean {
6
+ if (!Number.isInteger(pgid) || pgid <= 0) return false;
6
7
  try {
7
8
  process.kill(-pgid, 0);
8
9
  return true;
@@ -18,5 +19,8 @@ export function isProcessGroupAlive(pgid: number): boolean {
18
19
  * Negative PID targets the process group.
19
20
  */
20
21
  export function killProcessGroup(pgid: number, signal: NodeJS.Signals): void {
22
+ if (!Number.isInteger(pgid) || pgid <= 0) {
23
+ throw new RangeError("Process group ID must be a positive integer");
24
+ }
21
25
  process.kill(-pgid, signal);
22
26
  }