@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 +8 -0
- package/package.json +2 -1
- package/src/background-shell.ts +36 -90
- package/src/shell-output-watch.ts +126 -0
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.
|
|
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": {
|
package/src/background-shell.ts
CHANGED
|
@@ -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:
|
|
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?:
|
|
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
|
-
}
|
|
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
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
+
}
|