@justjoshn/pi-pstack 0.15.36-pi.14 → 0.15.36-pi.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.15.36-pi.15] - 2026-11-04
4
+
5
+ - Match watched shell output before a newline or process exit. Count every occurrence within a chunk and retain matches across chunk boundaries.
6
+ - Match the captured observer's batching, minimum debounce, completion flush, and 100-occurrence limit notice.
7
+ - Decode stdout and stderr independently so split UTF-8 characters reach the output file and monitor intact.
8
+ - Pin `re2js` 1.2.2 for output-watch patterns. Invalid patterns warn without stopping the command. A missing dependency leaves unrelated tools available but rejects a valid requested watch before the command runs.
9
+ - Record the pinned reference, original-component comparisons, and open requirements in `parity/pstack-full-parity-2026-11-04/`. Whole-plugin parity remains `NOT VERIFIED`.
10
+
3
11
  ## [0.15.36-pi.14] - 2026-11-04
4
12
 
5
13
  Each item below was checked against Cursor CLI 2026.10.22 with pstack and cursor-team-kit loaded, through live captures. `parity/pstack-native-parity-2026-11-04/` in the repository records the evidence and the decisions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justjoshn/pi-pstack",
3
- "version": "0.15.36-pi.14",
3
+ "version": "0.15.36-pi.15",
4
4
  "description": "pstack 0.15.36 for Pi with its soft-dependency team-kit skills vendored in: rigorous agent workflows and principles as Pi skills, with the host features they use: subagents, todos, modes, plan mode, the model rule, /loop, subscriptions and routines, AskQuestion, SwitchMode, image generation, and visualize",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,6 +65,7 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@anthropic-ai/sandbox-runtime": "0.0.79",
68
+ "re2js": "1.2.2",
68
69
  "yaml": "2.9.0"
69
70
  },
70
71
  "peerDependencies": {
@@ -5,12 +5,14 @@ import { type ChildProcess, spawn } from 'node:child_process';
5
5
  import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, statSync, writeSync } from 'node:fs';
6
6
  import { constants } from 'node:os';
7
7
  import { join, resolve } from 'node:path';
8
+ import { StringDecoder } from 'node:string_decoder';
8
9
 
9
10
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
10
11
  import { getShellConfig, truncateTail } from '@earendil-works/pi-coding-agent';
11
12
  import { Text } from '@earendil-works/pi-tui';
12
13
  import { Type } from 'typebox';
13
14
  import { isOneShot } from './modes.ts';
15
+ import { OutputWatchDependencyError, type OutputWatchRequest, ShellOutputWatch } from './shell-output-watch.ts';
14
16
 
15
17
  export const SHELL_NOTICE_TYPE = 'pstack/shell';
16
18
  const BACKGROUND_TOOL = 'bash_background';
@@ -18,7 +20,6 @@ const AWAIT_TOOL = 'AwaitShell';
18
20
 
19
21
  const FIELD_WIDTH = 9;
20
22
  const HEADER_REFRESH_MS = 5_000;
21
- const MIN_DEBOUNCE_MS = 5_000;
22
23
  const DEFAULT_AWAIT_MS = 30_000;
23
24
  const MAX_AWAIT_MS = 7_140_000;
24
25
  const KILL_GRACE_MS = 2_000;
@@ -27,20 +28,6 @@ const FOLLOW_UP = 'Briefly inform the user about the task result and perform any
27
28
  type JobStatus = 'running' | 'succeeded' | 'failed' | 'aborted';
28
29
  type NoticeStatus = 'success' | 'error' | 'aborted';
29
30
 
30
- interface OutputWatch {
31
- readonly regex: RegExp;
32
- readonly source: string;
33
- readonly reason: string;
34
- readonly debounceMs: number;
35
- occurrences: number;
36
- /** Matches since the last notification, sent when the debounce window closes. */
37
- unreported: number;
38
- latest: string;
39
- notices: number;
40
- window: ReturnType<typeof setTimeout> | undefined;
41
- partial: string;
42
- }
43
-
44
31
  interface ShellJob {
45
32
  readonly id: string;
46
33
  readonly pid: number;
@@ -55,7 +42,7 @@ interface ShellJob {
55
42
  readonly statusOffset: number;
56
43
  readonly runningOffset: number;
57
44
  readonly child: ChildProcess;
58
- readonly watch: OutputWatch | undefined;
45
+ readonly watch: ShellOutputWatch | undefined;
59
46
  readonly listeners: Set<() => void>;
60
47
  readonly done: Promise<void>;
61
48
  /** Where the next output byte goes; the header is rewritten in place, so the file is never opened for append. */
@@ -145,9 +132,6 @@ export function noticeText(notice: ShellNotice): string {
145
132
  ].join('\n');
146
133
  }
147
134
 
148
- const firstMatchDetail = (watch: OutputWatch) => `Shell output has pattern /${watch.source}/. This is occurrence number ${watch.occurrences}. Matched text: ${watch.latest}. The title message is being displayed to the user.`;
149
- const laterMatchesDetail = (watch: OutputWatch) => `Shell output matched /${watch.source}/ ${watch.unreported} more times since the previous notification. The title message is being displayed to the user. Latest match: ${watch.latest}.`;
150
-
151
135
  function exitStatus(code: number | null, signal: NodeJS.Signals | null): { status: JobStatus; exitCode: number } {
152
136
  if (signal !== null) return { status: 'aborted', exitCode: 128 + (constants.signals[signal] ?? 0) };
153
137
  const exitCode = code ?? 1;
@@ -215,7 +199,7 @@ export interface StartRequest {
215
199
  cwd: string;
216
200
  title: string;
217
201
  toolCallId: string;
218
- watch?: { pattern: string; reason: string; debounceMs?: number };
202
+ watch?: OutputWatchRequest;
219
203
  }
220
204
 
221
205
  /** Every background shell this runtime started, and the notifications they owe the agent. */
@@ -245,50 +229,6 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
245
229
  for (const listener of [...job.listeners]) listener();
246
230
  };
247
231
 
248
- const sendProgress = (job: ShellJob, watch: OutputWatch, detail: string) => {
249
- watch.notices += 1;
250
- deliver({ kind: 'progress', status: 'success', taskId: `${job.id}:task_progress:${watch.notices}`, shellId: job.id, title: watch.reason, toolCallId: job.toolCallId, detail, outputPath: job.path });
251
- };
252
- /** Trailing-edge debounce: the first match notifies at once, later ones in the window are counted and sent when it closes. */
253
- const openWindow = (job: ShellJob, watch: OutputWatch) => {
254
- watch.window = setTimeout(() => {
255
- watch.window = undefined;
256
- if (watch.unreported === 0) return;
257
- sendProgress(job, watch, laterMatchesDetail(watch));
258
- watch.unreported = 0;
259
- openWindow(job, watch);
260
- }, watch.debounceMs);
261
- watch.window.unref();
262
- };
263
- const matchLine = (job: ShellJob, watch: OutputWatch, line: string) => {
264
- const match = watch.regex.exec(line);
265
- if (match === null) return;
266
- watch.occurrences += 1;
267
- watch.latest = match[0];
268
- if (watch.window !== undefined) {
269
- watch.unreported += 1;
270
- return;
271
- }
272
- sendProgress(job, watch, firstMatchDetail(watch));
273
- openWindow(job, watch);
274
- };
275
- const scan = (job: ShellJob, chunk: string, final: boolean) => {
276
- const watch = job.watch;
277
- if (watch === undefined) return;
278
- const lines = (watch.partial + chunk).split('\n');
279
- watch.partial = final ? '' : (lines.pop() ?? '');
280
- for (const line of lines) if (line !== '' || final) matchLine(job, watch, line.replace(/\r$/, ''));
281
- };
282
- const flushWatch = (job: ShellJob) => {
283
- const watch = job.watch;
284
- if (watch === undefined) return;
285
- scan(job, '', true);
286
- if (watch.window !== undefined) clearTimeout(watch.window);
287
- watch.window = undefined;
288
- if (watch.unreported > 0) sendProgress(job, watch, laterMatchesDetail(watch));
289
- watch.unreported = 0;
290
- };
291
-
292
232
  const finish = (job: ShellJob, status: JobStatus, exitCode: number, announce: boolean) => {
293
233
  if (job.status !== 'running') return;
294
234
  const endedAt = Date.now();
@@ -299,8 +239,8 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
299
239
  job.bodyEnd = job.size;
300
240
  append(job, terminalFooter(exitCode, endedAt - job.startedAt, endedAt));
301
241
  closeSync(job.fd);
242
+ job.watch?.finish(announce);
302
243
  if (announce) {
303
- flushWatch(job);
304
244
  if (!job.observed) {
305
245
  const noticeStatus: NoticeStatus = status === 'succeeded' ? 'success' : status === 'failed' ? 'error' : 'aborted';
306
246
  deliver({
@@ -313,17 +253,27 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
313
253
  ...(noticeStatus === 'error' ? { detail: `exit_code=${exitCode}` } : {}),
314
254
  });
315
255
  }
316
- } else if (job.watch?.window !== undefined) clearTimeout(job.watch.window);
256
+ }
317
257
  notify(job);
318
258
  };
319
259
 
320
260
  const start = (ctx: ExtensionContext, request: StartRequest): ShellJob => {
321
261
  if (!existsSync(request.cwd) || !statSync(request.cwd).isDirectory()) throw new Error(`Working directory ${request.cwd} does not exist.`);
322
- const watchRegex = request.watch === undefined ? undefined : compile(request.watch.pattern);
323
262
  const dir = options.terminalsDir(ctx);
324
263
  mkdirSync(dir, { recursive: true });
325
264
  const id = allocateId(dir);
326
265
  const path = join(dir, `${id}.txt`);
266
+ let watch: ShellOutputWatch | undefined;
267
+ if (request.watch !== undefined && request.watch.pattern.trim() !== '') {
268
+ try {
269
+ watch = new ShellOutputWatch(request.watch, (notice) => {
270
+ deliver({ kind: 'progress', status: 'success', taskId: `${id}:task_progress:${notice.sequence}`, shellId: id, title: notice.title, toolCallId: request.toolCallId, detail: notice.detail, outputPath: path });
271
+ });
272
+ } catch (error) {
273
+ if (error instanceof OutputWatchDependencyError) throw error;
274
+ console.warn('Invalid shell output notification pattern', { shellId: id, error: error instanceof Error ? error.message : String(error) });
275
+ }
276
+ }
327
277
  const config = getShellConfig(options.shellPath());
328
278
  const viaStdin = config.commandTransport === 'stdin';
329
279
  const child = spawn(config.shell, viaStdin ? config.args : [...config.args, request.command], {
@@ -357,21 +307,7 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
357
307
  statusOffset: header.statusOffset,
358
308
  runningOffset: header.runningOffset,
359
309
  child,
360
- watch:
361
- request.watch === undefined || watchRegex === undefined
362
- ? undefined
363
- : {
364
- regex: watchRegex,
365
- source: request.watch.pattern,
366
- reason: request.watch.reason,
367
- debounceMs: Math.max(MIN_DEBOUNCE_MS, request.watch.debounceMs ?? MIN_DEBOUNCE_MS),
368
- occurrences: 0,
369
- unreported: 0,
370
- latest: '',
371
- notices: 0,
372
- window: undefined,
373
- partial: '',
374
- },
310
+ watch,
375
311
  listeners: new Set(),
376
312
  done: new Promise<void>((resolveDone) => {
377
313
  release = resolveDone;
@@ -388,14 +324,24 @@ export function createShellJobs(options: ShellJobsOptions, deliver: (notice: She
388
324
  append(job, header.text);
389
325
  job.refresh = setInterval(() => refreshRunning(job), HEADER_REFRESH_MS);
390
326
  job.refresh.unref();
391
- const onData = (chunk: Buffer) => {
392
- if (job.status !== 'running') return;
393
- append(job, chunk);
394
- scan(job, chunk.toString('utf8'), false);
395
- notify(job);
396
- };
397
- child.stdout?.on('data', onData);
398
- child.stderr?.on('data', onData);
327
+ for (const stream of [child.stdout, child.stderr]) {
328
+ if (stream === null) continue;
329
+ const decoder = new StringDecoder('utf8');
330
+ stream.on('data', (chunk: Buffer) => {
331
+ if (job.status !== 'running') return;
332
+ const text = decoder.write(chunk);
333
+ append(job, text);
334
+ job.watch?.observe(text);
335
+ notify(job);
336
+ });
337
+ stream.on('end', () => {
338
+ if (job.status !== 'running') return;
339
+ const text = decoder.end();
340
+ append(job, text);
341
+ job.watch?.observe(text);
342
+ notify(job);
343
+ });
344
+ }
399
345
  child.on('error', (error) => {
400
346
  if (job.status === 'running') append(job, `${error.message}\n`);
401
347
  finish(job, 'failed', 127, true);
@@ -0,0 +1,126 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ import type { RE2JS } from 're2js';
4
+
5
+ const require = createRequire(import.meta.url);
6
+ let regexEngine: typeof RE2JS | undefined;
7
+ try {
8
+ regexEngine = (require('re2js') as { RE2JS: typeof RE2JS }).RE2JS;
9
+ } catch {}
10
+
11
+ export class OutputWatchDependencyError extends Error {}
12
+
13
+ export interface OutputWatchRequest {
14
+ pattern: string;
15
+ reason: string;
16
+ debounceMs?: number;
17
+ }
18
+
19
+ export interface OutputMatchNotice {
20
+ sequence: number;
21
+ title: string;
22
+ detail: string;
23
+ }
24
+
25
+ interface MatchBatch {
26
+ start: number;
27
+ count: number;
28
+ matchedText: string;
29
+ }
30
+
31
+ const MATCH_LIMIT = 100;
32
+ const MAX_CHUNK = 65_536;
33
+ const MIN_DEBOUNCE_MS = 5_000;
34
+
35
+ export class ShellOutputWatch {
36
+ private readonly request: OutputWatchRequest;
37
+ private readonly notify: (notice: OutputMatchNotice) => void;
38
+ private readonly regex: RE2JS;
39
+ private readonly title: string;
40
+ private readonly debounceMs: number;
41
+ private state: 'watching' | 'limited' | 'finished' = 'watching';
42
+ private carryover = '';
43
+ private occurrences = 0;
44
+ private notices = 0;
45
+ private batch: MatchBatch | undefined;
46
+ private timer: ReturnType<typeof setTimeout> | undefined;
47
+
48
+ constructor(request: OutputWatchRequest, notify: (notice: OutputMatchNotice) => void) {
49
+ this.request = request;
50
+ this.notify = notify;
51
+ if (request.pattern.length > 500) throw new Error('Output notification pattern exceeds 500 characters');
52
+ if (regexEngine === undefined) throw new OutputWatchDependencyError('Shell output monitoring needs the re2js dependency. Run bun install in the pi-pstack package directory, then reload Pi.');
53
+ this.regex = regexEngine.compile(request.pattern, regexEngine.MULTILINE);
54
+ const title = request.reason.trim() || `Shell output contains /${request.pattern}/`;
55
+ this.title = title.length <= 500 ? title : `${title.slice(0, 500)}...`;
56
+ this.debounceMs = Math.max(request.debounceMs ?? MIN_DEBOUNCE_MS, MIN_DEBOUNCE_MS);
57
+ }
58
+
59
+ observe(chunk: string): void {
60
+ if (this.state !== 'watching' || chunk.length === 0) return;
61
+ for (let offset = 0; offset < chunk.length && this.state === 'watching'; offset += MAX_CHUNK) {
62
+ const previous = this.carryover.length;
63
+ const text = this.carryover + chunk.slice(offset, offset + MAX_CHUNK);
64
+ const matcher = this.regex.matcher(text);
65
+ while (this.state === 'watching' && matcher.find()) {
66
+ if (matcher.end() > previous) this.match(matcher.group() ?? '');
67
+ }
68
+ this.carryover = text.slice(-1_024);
69
+ }
70
+ }
71
+
72
+ finish(announce = true): void {
73
+ if (this.state === 'finished') return;
74
+ this.state = 'finished';
75
+ if (announce) this.flush();
76
+ this.batch = undefined;
77
+ this.clearTimer();
78
+ }
79
+
80
+ private match(text: string): void {
81
+ this.occurrences += 1;
82
+ const label = this.occurrences === 1 ? 'Matched text' : 'Latest match';
83
+ const matchedText = text.length <= 5_000 ? `${label}: ${text}` : `${label} is ${text.length} characters; omitted from notification detail.`;
84
+ if (this.occurrences === 1) {
85
+ this.emit({ start: 1, count: 1, matchedText });
86
+ } else {
87
+ this.batch = this.batch === undefined ? { start: this.occurrences, count: 1, matchedText } : { start: this.batch.start, count: this.batch.count + 1, matchedText };
88
+ if (this.occurrences < MATCH_LIMIT && this.timer === undefined) {
89
+ this.timer = setTimeout(() => this.flush(), this.debounceMs);
90
+ this.timer.unref();
91
+ }
92
+ }
93
+ if (this.occurrences === MATCH_LIMIT) {
94
+ this.state = 'limited';
95
+ this.flush();
96
+ this.notices += 1;
97
+ this.notify({
98
+ sequence: this.notices,
99
+ title: `Notification limit reached (${MATCH_LIMIT})`,
100
+ detail: `Shell output matched /${this.request.pattern}/ ${MATCH_LIMIT} times and has reached the notification limit. No further notifications will be sent for this pattern. To continue monitoring, read the output file directly at the output path.`,
101
+ });
102
+ }
103
+ }
104
+
105
+ private clearTimer(): void {
106
+ if (this.timer !== undefined) clearTimeout(this.timer);
107
+ this.timer = undefined;
108
+ }
109
+
110
+ private flush(): void {
111
+ const batch = this.batch;
112
+ this.batch = undefined;
113
+ this.clearTimer();
114
+ if (batch !== undefined) this.emit(batch);
115
+ }
116
+
117
+ private emit(batch: MatchBatch): void {
118
+ this.notices += 1;
119
+ const pattern = this.request.pattern;
120
+ const detail =
121
+ batch.count === 1
122
+ ? `Shell output has pattern /${pattern}/. This is occurrence number ${batch.start}. ${batch.matchedText}. The title message is being displayed to the user.`
123
+ : `Shell output matched /${pattern}/ ${batch.count} more times since the previous notification. The title message is being displayed to the user. ${batch.matchedText}.`;
124
+ this.notify({ sequence: this.notices, title: this.title, detail });
125
+ }
126
+ }