@aliou/pi-processes 0.10.2 → 0.10.4

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.
@@ -60,10 +60,11 @@ export function registerNotificationDelivery(
60
60
  summary: `Suppressed ${count} log-match notifications because output was too fast.`,
61
61
  attention: "context",
62
62
  };
63
- sendProcessNotificationMessage(pi, details, {
64
- triggerTurn: false,
65
- deliverAs: "steer",
66
- });
63
+ sendProcessNotificationMessage(
64
+ pi,
65
+ details,
66
+ attentionToSendOptions(details.attention),
67
+ );
67
68
  };
68
69
 
69
70
  const flushSuppressedSummary = () => {
@@ -1,4 +1,5 @@
1
1
  import { compileLineMatcher } from "../../../src/utils/match-line";
2
+ import { plainTextForDisplay } from "../../shared/display-text";
2
3
  import type { NotifyConfig } from "./registry";
3
4
  import type { Attention } from "./types";
4
5
 
@@ -130,8 +131,12 @@ function splitAppendedIntoLines(
130
131
  for (const entry of appended) {
131
132
  const split = entry.text.split("\n");
132
133
  for (const line of split) {
133
- if (line.length === 0) continue;
134
- lines.push({ type: entry.type, text: line });
134
+ // Match the same per-line visible text the log views render. This keeps
135
+ // CR-overwritten progress and invisible escape bytes from firing
136
+ // watches, without making watches depend on viewport width.
137
+ const displayLine = plainTextForDisplay(line);
138
+ if (displayLine.length === 0) continue;
139
+ lines.push({ type: entry.type, text: displayLine });
135
140
  }
136
141
  }
137
142
 
@@ -25,7 +25,10 @@ const DEFAULT_ATTENTION: Record<
25
25
  "onSuccess" | "onFailure" | "onKilled",
26
26
  Attention
27
27
  > = {
28
- onSuccess: "context",
28
+ // Keep in sync with DEFAULT_NOTIFY_CONFIG in tools/notify.ts. Success
29
+ // defaults to a turn because "context" is only seen by an agent that is
30
+ // still streaming when the process ends.
31
+ onSuccess: "turn",
29
32
  onFailure: "turn",
30
33
  // External kills surface as context by default (see DEFAULT_NOTIFY_CONFIG
31
34
  // in tools/notify.ts for rationale). Intentional stops are classified
@@ -54,7 +54,8 @@ export function registerProcessTool(
54
54
  "Manage long-running background processes: start, list, stop, write to stdin, update watches, clear finished entries, and inspect recent output. After starting a process, do not wait - notifications bring you back on exit and on log matches.",
55
55
  promptGuidelines: [
56
56
  "process tool: use process start for long-running commands (dev servers, watchers, builds) instead of shell background patterns like &, nohup, or setsid; give each process a specific name and check process list first when a duplicate would be noisy.",
57
- "process tool: after process start, do not sleep, poll, or hold your turn. End your turn or move on. The process notifies you on exit (success, failure, killed) and on notify.logMatches matches, which brings you back.",
57
+ "process tool: after process start, do not sleep, poll, or hold your turn. End your turn or move on. Exits and notify.logMatches matches bring you back.",
58
+ "process tool: attention turn wakes you when idle; context only reaches you if you are still working; ignore never notifies. Keep turn for anything whose result you need.",
58
59
  "process tool: use notify.logMatches to get brought back on readiness or error signals instead of polling process output. If a watch is too noisy, use process update (watches.mode append/replace/remove/clear) to fix it without restarting.",
59
60
  "process tool: for the full lifecycle (start, list, output, update, write, stop, clear), notify options, use cases, and noisy-watch handling, read the pi-processes skill.",
60
61
  ],
@@ -14,7 +14,13 @@ export const MAX_NOTIFY_LOG_MATCHERS = MAX_LOG_MATCHERS_PER_PROCESS;
14
14
  export const MAX_NOTIFY_PATTERN_LENGTH = MAX_LOG_MATCH_PATTERN_LENGTH;
15
15
 
16
16
  const DEFAULT_NOTIFY_CONFIG = {
17
- onSuccess: "context",
17
+ // A backgrounded process usually outlives the turn that started it, and
18
+ // "context" only reaches the agent if it happens to still be streaming when
19
+ // the process ends. Builds, tests, and other one-shot commands are started
20
+ // precisely because the agent needs the result, so success defaults to a
21
+ // turn. Long-running servers rarely exit 0, and callers that do not want the
22
+ // interruption can pass onSuccess: "context".
23
+ onSuccess: "turn",
18
24
  onFailure: "turn",
19
25
  // External kills (outside this manager) surface as context by default so
20
26
  // the agent and user learn that a managed process disappeared. Intentional
@@ -36,11 +42,14 @@ export function normalizeNotifyConfig(input: unknown): NotifyConfig {
36
42
 
37
43
  return {
38
44
  onSuccess:
39
- normalizeAttention(input.onSuccess, "notify.onSuccess") ?? "context",
45
+ normalizeAttention(input.onSuccess, "notify.onSuccess") ??
46
+ DEFAULT_NOTIFY_CONFIG.onSuccess,
40
47
  onFailure:
41
- normalizeAttention(input.onFailure, "notify.onFailure") ?? "turn",
48
+ normalizeAttention(input.onFailure, "notify.onFailure") ??
49
+ DEFAULT_NOTIFY_CONFIG.onFailure,
42
50
  onKilled:
43
- normalizeAttention(input.onKilled, "notify.onKilled") ?? "context",
51
+ normalizeAttention(input.onKilled, "notify.onKilled") ??
52
+ DEFAULT_NOTIFY_CONFIG.onKilled,
44
53
  logMatches: normalizeLogMatches(logMatches),
45
54
  };
46
55
  }
@@ -110,28 +110,25 @@ const WatchUpdateItemParams = Type.Object({
110
110
  ),
111
111
  on: Type.Optional(
112
112
  StringEnum(PROCESS_NOTIFY_ATTENTIONS, {
113
- description: "Agent attention for this log match. Defaults to turn.",
113
+ description: "Attention for this match. Defaults to turn.",
114
114
  }),
115
115
  ),
116
116
  });
117
117
 
118
- const NotifyParams = Type.Object({
118
+ const NotifyProperties = {
119
119
  onSuccess: Type.Optional(
120
120
  StringEnum(PROCESS_NOTIFY_ATTENTIONS, {
121
- description:
122
- "Agent attention when the process exits successfully. Defaults to context.",
121
+ description: "Attention on clean exit. Defaults to turn.",
123
122
  }),
124
123
  ),
125
124
  onFailure: Type.Optional(
126
125
  StringEnum(PROCESS_NOTIFY_ATTENTIONS, {
127
- description:
128
- "Agent attention when the process fails or crashes. Defaults to turn.",
126
+ description: "Attention on failure or crash. Defaults to turn.",
129
127
  }),
130
128
  ),
131
129
  onKilled: Type.Optional(
132
130
  StringEnum(PROCESS_NOTIFY_ATTENTIONS, {
133
- description:
134
- "Agent attention when the process is killed. Defaults to context.",
131
+ description: "Attention on external kill. Defaults to context.",
135
132
  }),
136
133
  ),
137
134
  logMatches: Type.Optional(
@@ -141,6 +138,11 @@ const NotifyParams = Type.Object({
141
138
  "Log match notifications. Supports at most 20 matchers, with each pattern limited to 500 characters.",
142
139
  }),
143
140
  ),
141
+ };
142
+
143
+ const NotifyParams = Type.Object(NotifyProperties, {
144
+ description:
145
+ "Notify settings. Attention: turn wakes an idle agent, context only reaches an agent still working, ignore never notifies.",
144
146
  });
145
147
 
146
148
  export const ProcessesParams = Type.Object({
@@ -1,8 +1,15 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { visibleWidth } from "@earendil-works/pi-tui";
3
- import { sanitizeForDisplay } from "../../shared/display-text";
3
+ import {
4
+ plainTextForDisplay,
5
+ sanitizeForDisplay,
6
+ } from "../../shared/display-text";
4
7
  import { trimToBudget } from "../../shared/line-buffer";
5
- import { type LogLineEmphasis, renderLogLine } from "../../shared/log-line";
8
+ import {
9
+ displayTextOf,
10
+ type LogLineEmphasis,
11
+ renderLogLine,
12
+ } from "../../shared/log-line";
6
13
  import { truncateToWidth } from "../../shared/truncate";
7
14
  import type { ProcessLogLine } from "../logs-client";
8
15
 
@@ -122,8 +129,9 @@ export class LogFileViewer {
122
129
  * priority (search current match > search match > notify match > stream).
123
130
  */
124
131
  addNotifyMatch(match: { line: string }): void {
125
- // Stored sanitized so it can be compared against sanitized buffer lines.
126
- const line = sanitizeForDisplay(match.line);
132
+ // Stored as plain visible text so invisible escape bytes cannot be what
133
+ // makes a notification marker match.
134
+ const line = plainTextForDisplay(match.line);
127
135
  if (line) this.notifyLines.add(line);
128
136
  }
129
137
 
@@ -192,7 +200,8 @@ export class LogFileViewer {
192
200
  ? "search-current"
193
201
  : matchSet.has(visibleIndex)
194
202
  ? "search"
195
- : this.notifyLines.has(line.text)
203
+ : this.notifyLines.has(line.text) ||
204
+ this.notifyLines.has(displayTextOf(line))
196
205
  ? "notify"
197
206
  : "none";
198
207
  return renderLogLine(line, { theme: this.theme, width, emphasis });
@@ -13,10 +13,17 @@ const ESC = String.fromCodePoint(0x001b);
13
13
  const BEL = String.fromCodePoint(0x0007);
14
14
  const ST = String.fromCodePoint(0x009c);
15
15
  const RESET = `${ESC}[0m`;
16
+ const C1_DCS = String.fromCodePoint(0x0090);
17
+ const C1_SOS = String.fromCodePoint(0x0098);
18
+ const C1_OSC = String.fromCodePoint(0x009d);
19
+ const C1_PM = String.fromCodePoint(0x009e);
20
+ const C1_APC = String.fromCodePoint(0x009f);
21
+ const C1_STRING_INTRODUCERS = [C1_DCS, C1_SOS, C1_OSC, C1_PM, C1_APC];
16
22
 
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.
23
+ // Control characters a single display row must never contain. Tabs and
24
+ // carriage returns are handled separately; newlines are dropped because they
25
+ // would shift the whole frame. C1 controls are included: a raw \u009b is an
26
+ // alias for CSI on some terminals.
20
27
  // biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally targets terminal control characters.
21
28
  const DISPLAY_CONTROL_CHARS = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f]/gu;
22
29
 
@@ -41,7 +48,9 @@ const TAB_WIDTH = 8;
41
48
  * into the rest of the frame.
42
49
  */
43
50
  export function sanitizeForDisplay(text: string): string {
44
- if (!text.includes(ESC)) return cleanPlainText(text, 0).text;
51
+ if (!text.includes(ESC) && !hasC1StringIntroducer(text)) {
52
+ return cleanPlainText(text, 0).text;
53
+ }
45
54
 
46
55
  let out = "";
47
56
  let cursor = 0;
@@ -49,21 +58,34 @@ export function sanitizeForDisplay(text: string): string {
49
58
  let keptSgr = false;
50
59
 
51
60
  while (cursor < text.length) {
52
- const escapeAt = text.indexOf(ESC, cursor);
53
- if (escapeAt === -1) {
54
- out += cleanPlainText(text.slice(cursor), column).text;
61
+ const sequenceAt = findNextSequenceStart(text, cursor);
62
+ if (sequenceAt === -1) {
63
+ const chunk = cleanPlainText(text.slice(cursor), column);
64
+ if (chunk.resetsRow) {
65
+ out = "";
66
+ keptSgr = false;
67
+ }
68
+ out += chunk.text;
55
69
  break;
56
70
  }
57
- const chunk = cleanPlainText(text.slice(cursor, escapeAt), column);
71
+ const chunk = cleanPlainText(text.slice(cursor, sequenceAt), column);
72
+ if (chunk.resetsRow) {
73
+ out = "";
74
+ keptSgr = false;
75
+ }
58
76
  out += chunk.text;
59
77
  column = chunk.column;
60
78
 
61
- const sequence = readEscapeSequence(text, escapeAt);
62
- if (sequence.isSgr) {
63
- out += text.slice(escapeAt, sequence.end);
64
- keptSgr = true;
79
+ if (text[sequenceAt] === ESC) {
80
+ const sequence = readEscapeSequence(text, sequenceAt);
81
+ if (sequence.isSgr) {
82
+ out += text.slice(sequenceAt, sequence.end);
83
+ keptSgr = true;
84
+ }
85
+ cursor = sequence.end;
86
+ } else {
87
+ cursor = readC1StringSequence(text, sequenceAt).end;
65
88
  }
66
- cursor = sequence.end;
67
89
  }
68
90
 
69
91
  if (!keptSgr || out.endsWith(RESET)) return out;
@@ -79,6 +101,22 @@ export function truncateForDisplay(text: string, width: number): string {
79
101
  return closeSgr(truncateToWidth(sanitizeForDisplay(text), width, "…"));
80
102
  }
81
103
 
104
+ /**
105
+ * Return the unstyled text a user can actually read after terminal controls
106
+ * have been interpreted/dropped. Use this for comparisons such as search,
107
+ * notify markers, and log watches so invisible escape bytes cannot match.
108
+ */
109
+ export function plainTextForDisplay(text: string): string {
110
+ return stripSgr(sanitizeForDisplay(text));
111
+ }
112
+
113
+ const SGR = new RegExp(`${ESC}\\[[0-9;:]*m`, "gu");
114
+
115
+ /** Drop the SGR sequences `sanitizeForDisplay` kept for rendering colors. */
116
+ export function stripSgr(text: string): string {
117
+ return text.replace(SGR, "");
118
+ }
119
+
82
120
  /**
83
121
  * Re-close colors after truncation. `sanitizeForDisplay` ends a colored string
84
122
  * with a reset, but truncating can cut that reset off and let the color bleed
@@ -97,21 +135,42 @@ export function closeSgr(text: string): string {
97
135
  function cleanPlainText(
98
136
  text: string,
99
137
  column: number,
100
- ): { text: string; column: number } {
101
- const clean = text.replace(DISPLAY_CONTROL_CHARS, "");
138
+ ): { text: string; column: number; resetsRow: boolean } {
139
+ const carriageReturn = text.lastIndexOf("\r");
140
+ const resetsRow = carriageReturn !== -1;
141
+ const visibleText = resetsRow ? text.slice(carriageReturn + 1) : text;
142
+ const startColumn = resetsRow ? 0 : column;
143
+ const clean = visibleText.replace(DISPLAY_CONTROL_CHARS, "");
102
144
  if (!clean.includes("\t")) {
103
- return { text: clean, column: column + visibleWidth(clean) };
145
+ return {
146
+ text: clean,
147
+ column: startColumn + visibleWidth(clean),
148
+ resetsRow,
149
+ };
104
150
  }
105
151
 
106
152
  const parts = clean.split("\t");
107
153
  let out = parts[0] ?? "";
108
- let col = column + visibleWidth(out);
154
+ let col = startColumn + visibleWidth(out);
109
155
  for (const part of parts.slice(1)) {
110
156
  const spaces = TAB_WIDTH - (col % TAB_WIDTH);
111
157
  out += " ".repeat(spaces) + part;
112
158
  col += spaces + visibleWidth(part);
113
159
  }
114
- return { text: out, column: col };
160
+ return { text: out, column: col, resetsRow };
161
+ }
162
+
163
+ function findNextSequenceStart(text: string, cursor: number): number {
164
+ let next = text.indexOf(ESC, cursor);
165
+ for (const introducer of C1_STRING_INTRODUCERS) {
166
+ const index = text.indexOf(introducer, cursor);
167
+ if (index !== -1 && (next === -1 || index < next)) next = index;
168
+ }
169
+ return next;
170
+ }
171
+
172
+ function hasC1StringIntroducer(text: string): boolean {
173
+ return C1_STRING_INTRODUCERS.some((introducer) => text.includes(introducer));
115
174
  }
116
175
 
117
176
  /**
@@ -151,6 +210,12 @@ function readEscapeSequence(
151
210
  return { end: start + 2, isSgr: false };
152
211
  }
153
212
 
213
+ function readC1StringSequence(text: string, start: number): { end: number } {
214
+ const introducer = text[start];
215
+ const allowBel = introducer === C1_OSC || introducer === C1_APC;
216
+ return { end: findStringTerminator(text, start + 1, allowBel) };
217
+ }
218
+
154
219
  /** Index just past the terminator of a string sequence, else end of input. */
155
220
  function findStringTerminator(
156
221
  text: string,
@@ -10,7 +10,12 @@
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
11
  import { visibleWidth } from "@earendil-works/pi-tui";
12
12
 
13
- import { closeSgr, sanitizeForDisplay } from "./display-text";
13
+ import {
14
+ closeSgr,
15
+ plainTextForDisplay,
16
+ sanitizeForDisplay,
17
+ stripSgr,
18
+ } from "./display-text";
14
19
  import { truncateToWidth } from "./truncate";
15
20
 
16
21
  export interface DisplayLogLine {
@@ -60,7 +65,7 @@ export function renderLogLine(
60
65
 
61
66
  /** Text of a log line as the views display it, for match comparisons. */
62
67
  export function displayTextOf(line: DisplayLogLine): string {
63
- return sanitizeForDisplay(line.text);
68
+ return plainTextForDisplay(line.text);
64
69
  }
65
70
 
66
71
  function toneLogText(
@@ -75,10 +80,3 @@ function toneLogText(
75
80
  if (type === "stderr") return theme.fg("warning", text);
76
81
  return text;
77
82
  }
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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -15,9 +15,11 @@ A started process runs in the background and the manager brings you back when so
15
15
  2. End your turn or move on to other work. Do not call `process output` in a loop waiting for "ready".
16
16
  3. The process notifies you when:
17
17
  - a `logMatches` pattern hits (readiness, error, progress),
18
- - the process exits successfully (`onSuccess`, default `context`),
18
+ - the process exits successfully (`onSuccess`, default `turn`),
19
19
  - the process fails or crashes (`onFailure`, default `turn`),
20
- - the process is killed, by you or externally (`onKilled`, default `context`).
20
+ - the process is killed externally (`onKilled`, default `context`, which does not wake an idle agent).
21
+
22
+ Stopping a process yourself never notifies.
21
23
  4. When a watch is too noisy or wrong, fix it with `process update` — do not restart the process just to change watches.
22
24
  5. `process stop` obsolete live processes and `process clear` finished entries when they are no longer useful.
23
25
 
@@ -50,6 +52,8 @@ Good:
50
52
  }
51
53
  ```
52
54
 
55
+ `onSuccess: "context"` here because a dev server exiting cleanly needs no reaction. Keep the default `turn` for builds, tests, and other one-shot commands whose result you need.
56
+
53
57
  Optional `cwd` sets the working directory for the spawned command. Omit it to inherit the agent's current working directory.
54
58
 
55
59
  Empty `logMatches` patterns (literal or regex) are rejected at start and update time. Use `mode: "regex"` only when literal matching is not enough, scope by `stream` to cut noise, and use `repeat: true` when a matcher should fire more than once.
@@ -214,9 +218,9 @@ Good:
214
218
 
215
219
  Exit attention:
216
220
 
217
- - `notify.onSuccess` — when the process exits successfully. Defaults to `context`.
218
- - `notify.onFailure` — when the process fails or crashes. Defaults to `turn`.
219
- - `notify.onKilled` — when the process is killed, by you or externally. Defaults to `context`.
221
+ - `notify.onSuccess` — clean exit. Defaults to `turn`.
222
+ - `notify.onFailure` — failure or crash. Defaults to `turn`.
223
+ - `notify.onKilled` — killed from outside the tool. Defaults to `context`. Stopping a process yourself never notifies.
220
224
 
221
225
  Log match watches (`notify.logMatches`, up to 20, each pattern up to 500 chars):
222
226
 
@@ -226,7 +230,13 @@ Log match watches (`notify.logMatches`, up to 20, each pattern up to 500 chars):
226
230
  - `repeat` — `false` (default) fires once; `true` fires on every match.
227
231
  - `on` — `turn`, `context`, or `ignore`. Overrides the default attention for that watch. Defaults to `turn`.
228
232
 
229
- `turn` interrupts with an agent message. `context` adds the notice as context without interrupting. `ignore` records the match silently.
233
+ Attention levels:
234
+
235
+ - `turn` — starts an agent turn. Reaches you even when you are idle.
236
+ - `context` — recorded in the transcript, no turn. It reaches you only if you are still working when the event fires; an idle agent is not woken and sees it on the next user message.
237
+ - `ignore` — recorded, never notifies.
238
+
239
+ Use `context` only when nothing needs to happen in response.
230
240
 
231
241
  ## Use cases
232
242