@justjoshn/pi-pstack 0.15.36-pi.13 → 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/src/index.ts CHANGED
@@ -5,16 +5,17 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
5
5
  import { aliasInputResult, aliasTable, packageSkillNames } from './aliases.ts';
6
6
  import { registerAskQuestion } from './ask.ts';
7
7
  import { aliasAutocomplete } from './autocomplete.ts';
8
+ import { registerBackgroundShell } from './background-shell.ts';
8
9
  import { childIdentity } from './child-env.ts';
9
10
  import { registerGenerateImage } from './generate-image.ts';
10
11
  import { registerLoop } from './loop.ts';
11
12
  import { registerMode } from './mode.ts';
12
13
  import { canAsk, isOneShot } from './modes.ts';
13
- import { artifactsDir, PACKAGE_ROOT, projectsDir, rootStoreDir, rulePath, SKILLS_DIR, storeDir, subagentDir } from './paths.ts';
14
+ import { artifactsDir, PACKAGE_ROOT, projectsDir, rootStoreDir, rulePath, SKILLS_DIR, storeDir, subagentDir, terminalsDir } from './paths.ts';
14
15
  import { registerPlanMode } from './plan.ts';
15
16
  import { registerReadonlyShell } from './readonly-shell.ts';
16
17
  import { migrateLegacyRule, modelsSection, readRule } from './rule.ts';
17
- import { alwaysAppliedRules, hostSection, subagentsSection } from './sections.ts';
18
+ import { alwaysAppliedRules, hostSection, subagentsSection, todayText } from './sections.ts';
18
19
  import { registerSubscriptions } from './subscriptions/index.ts';
19
20
  import { type AgentDefinition, loadAgentDefinitions } from './task/agents.ts';
20
21
  import { registerAgentStatus } from './task/status.ts';
@@ -39,6 +40,7 @@ function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly
39
40
  const activeTools = pi.getActiveTools();
40
41
  return {
41
42
  pstack_host: hostSection({
43
+ today: todayText(new Date()),
42
44
  packageRoot: PACKAGE_ROOT,
43
45
  skillsDir: SKILLS_DIR,
44
46
  skillNames: packageSkillNames(pi.getCommands()),
@@ -51,6 +53,7 @@ function hostSections(pi: ExtensionAPI, ctx: ExtensionContext, agents: readonly
51
53
  rootStoreDir: rootStoreDir(ctx.cwd, ctx.sessionManager.getSessionId(), childIdentity.rootStore),
52
54
  projectsDir: projectsDir(ctx.cwd),
53
55
  artifactsDir: artifactsDir(ctx.cwd, ctx.sessionManager.getSessionId()),
56
+ terminalsDir: terminalsDir(ctx.cwd),
54
57
  loopAvailable: !isOneShot(ctx.mode),
55
58
  questionsAvailable: canAsk(ctx),
56
59
  activeTools,
@@ -122,7 +125,15 @@ export default function piPstack(pi: ExtensionAPI): void {
122
125
  return undefined;
123
126
  },
124
127
  });
125
- const loop = registerLoop(pi, { hasPendingWork: task.hasBackgroundWork });
128
+ // A readonly agent's shell is the sandboxed one, and a background job would run outside that sandbox.
129
+ const shells = childIdentity.readonly
130
+ ? undefined
131
+ : registerBackgroundShell(pi, {
132
+ terminalsDir: (ctx) => terminalsDir(ctx.cwd),
133
+ waitsForJobs: (ctx) => childIdentity.depth === 0 && (ctx.mode === 'print' || ctx.mode === 'json'),
134
+ endsWithTurn: childIdentity.depth > 0,
135
+ });
136
+ const loop = registerLoop(pi, { hasPendingWork: () => task.hasBackgroundWork() || shells?.hasRunningJobs() === true });
126
137
  const todos = registerTodoWrite(pi, childIdentity.readonly ? {} : { onWrite: plan.syncTodos });
127
138
  registerAskQuestion(pi);
128
139
  if (childIdentity.parentPid !== undefined) watchParent(pi, childIdentity.parentPid);
@@ -0,0 +1,121 @@
1
+ // The Linux read-only sandbox: bubblewrap with every namespace unshared, the whole filesystem bound read-only, and a
2
+ // seccomp filter that refuses Unix sockets. bubblewrap installs the filter itself, so this needs no nested user
3
+ // namespace, which hosts with kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 and later) refuse.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { accessSync, closeSync, constants, existsSync, mkdtempSync, openSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { delimiter, join } from 'node:path';
8
+
9
+ const LD_ABS_W = 0x20;
10
+ const JEQ_K = 0x15;
11
+ const JGE_K = 0x35;
12
+ const RET_K = 0x06;
13
+ const SECCOMP_RET_ALLOW = 0x7fff_0000;
14
+ const SECCOMP_RET_ERRNO_EPERM = 0x0005_0001;
15
+ const SECCOMP_RET_KILL_PROCESS = 0x8000_0000;
16
+ const X32_SYSCALL_BIT = 0x4000_0000;
17
+ const AF_UNIX = 1;
18
+
19
+ const ARCHES: Record<string, { audit: number; socket: number; ioUringSetup: number }> = {
20
+ x64: { audit: 0xc000_003e, socket: 41, ioUringSetup: 425 },
21
+ arm64: { audit: 0xc000_00b7, socket: 198, ioUringSetup: 425 },
22
+ };
23
+
24
+ const instruction = (code: number, jt: number, jf: number, k: number): Buffer => {
25
+ const bytes = Buffer.alloc(8);
26
+ bytes.writeUInt16LE(code, 0);
27
+ bytes.writeUInt8(jt, 2);
28
+ bytes.writeUInt8(jf, 3);
29
+ bytes.writeUInt32LE(k >>> 0, 4);
30
+ return bytes;
31
+ };
32
+
33
+ /**
34
+ * A classic BPF seccomp program: socket(AF_UNIX, ...) and io_uring_setup fail with EPERM, any other architecture or
35
+ * the x32 ABI kills the process, and everything else is allowed. Jumps are relative to the next instruction.
36
+ */
37
+ export function seccompFilter(arch: string): Buffer {
38
+ const target = ARCHES[arch];
39
+ if (target === undefined) throw new Error(`the read-only shell has no seccomp filter for ${arch}`);
40
+ return Buffer.concat([
41
+ instruction(LD_ABS_W, 0, 0, 4), // 0: architecture
42
+ instruction(JEQ_K, 0, 8, target.audit), // 1: native → 2, otherwise → 10
43
+ instruction(LD_ABS_W, 0, 0, 0), // 2: syscall number
44
+ instruction(JGE_K, 6, 0, X32_SYSCALL_BIT), // 3: x32 → 10
45
+ instruction(JEQ_K, 0, 2, target.socket), // 4: socket → 5, otherwise → 7
46
+ instruction(LD_ABS_W, 0, 0, 16), // 5: low word of the first argument, the domain
47
+ instruction(JEQ_K, 2, 1, AF_UNIX), // 6: AF_UNIX → 9, otherwise → 8
48
+ instruction(JEQ_K, 1, 0, target.ioUringSetup), // 7: io_uring_setup → 9, otherwise → 8
49
+ instruction(RET_K, 0, 0, SECCOMP_RET_ALLOW), // 8
50
+ instruction(RET_K, 0, 0, SECCOMP_RET_ERRNO_EPERM), // 9
51
+ instruction(RET_K, 0, 0, SECCOMP_RET_KILL_PROCESS), // 10
52
+ ]);
53
+ }
54
+
55
+ const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
56
+
57
+ /** bubblewrap's arguments before the command: nothing is writable and nothing outside is reachable. */
58
+ export function bwrapArgs(cwd: string): string[] {
59
+ return ['--new-session', '--die-with-parent', '--unshare-all', '--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--cap-drop', 'ALL', '--chdir', cwd, '--seccomp', '3'];
60
+ }
61
+
62
+ interface Sandbox {
63
+ bwrap: string;
64
+ filterPath: string;
65
+ shell: string;
66
+ }
67
+
68
+ /** The first executable `name` on PATH, so every command runs the bubblewrap the check ran. */
69
+ function onPath(name: string): string | undefined {
70
+ for (const dir of (process.env['PATH'] ?? '').split(delimiter).filter((entry) => entry !== '')) {
71
+ const candidate = join(dir, name);
72
+ try {
73
+ accessSync(candidate, constants.X_OK);
74
+ return candidate;
75
+ } catch {
76
+ // Not here; keep looking.
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ let verified: { sandbox: Sandbox } | { error: Error } | undefined;
83
+
84
+ /** The checked sandbox, or the error that stopped it; either is kept, so a failed host never retries into a weaker state. */
85
+ function verifiedSandbox(): Sandbox {
86
+ verified ??= (() => {
87
+ try {
88
+ return { sandbox: checkSandbox() };
89
+ } catch (error) {
90
+ return { error: error instanceof Error ? error : new Error(String(error)) };
91
+ }
92
+ })();
93
+ if ('error' in verified) throw verified.error;
94
+ return verified.sandbox;
95
+ }
96
+
97
+ /** Writes the filter into a private directory and checks that bubblewrap can load it here. */
98
+ function checkSandbox(): Sandbox {
99
+ const filter = seccompFilter(process.arch);
100
+ const bwrap = onPath('bwrap');
101
+ if (bwrap === undefined) throw new Error('bubblewrap (bwrap) is not available: no bwrap on PATH');
102
+ const dir = mkdtempSync(join(tmpdir(), 'pi-pstack-ro-'));
103
+ const filterPath = join(dir, 'unix-block.bpf');
104
+ writeFileSync(filterPath, filter, { mode: 0o400 });
105
+ const shell = existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh';
106
+ const fd = openSync(filterPath, 'r');
107
+ try {
108
+ const probe = spawnSync(bwrap, [...bwrapArgs('/'), '--', shell, '-c', 'exit 0'], { stdio: ['ignore', 'ignore', 'pipe', fd], encoding: 'utf8', timeout: 15_000 });
109
+ if (probe.error !== undefined) throw new Error(`bubblewrap (bwrap) could not run: ${probe.error.message}`);
110
+ if (probe.status !== 0) throw new Error(`bubblewrap could not start the read-only sandbox: ${probe.stderr.trim() || `exit ${probe.status}`}`);
111
+ } finally {
112
+ closeSync(fd);
113
+ }
114
+ return { bwrap, filterPath, shell };
115
+ }
116
+
117
+ /** The shell command that runs `command` inside the read-only sandbox, or an error when no sandbox can start. */
118
+ export function linuxReadonlyCommand(command: string, cwd: string): string {
119
+ const { bwrap, filterPath, shell } = verifiedSandbox();
120
+ return `exec ${quote(bwrap)} ${bwrapArgs(cwd).map(quote).join(' ')} -- ${quote(shell)} -c ${quote(command)} 3< ${quote(filterPath)}`;
121
+ }
package/src/loop.ts CHANGED
@@ -258,7 +258,7 @@ function registerLoopTool(pi: ExtensionAPI, registry: LoopRegistry): void {
258
258
  name: 'Loop',
259
259
  label: 'Loop',
260
260
  description:
261
- '/loop for you: start an independent loop that re-sends a prompt at a fixed interval or at wakes you pick, schedule its next wake, or stop it once its goal is met. Use it when a playbook says to arm /loop. Each result names the loop ID. With several loops running, pass loop_id to wake or stop the intended loop.',
261
+ '/loop for you: start an independent loop that re-sends a prompt at a fixed interval or at wakes you pick, schedule its next wake, or stop it once its goal is met. Use it when a playbook says to arm /loop. Run the prompt once yourself in the turn that arms the loop; the first scheduled run comes after the interval or wake_in. Then confirm the interval, that the prompt already ran once, and when the first run comes. Each result names the loop ID. With several loops running, pass loop_id to wake or stop the intended loop.',
262
262
  promptSnippet: 'Start a /loop, schedule its next wake, or stop it',
263
263
  parameters: LoopParams,
264
264
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
package/src/paths.ts CHANGED
@@ -75,6 +75,11 @@ export function artifactsDir(cwd: string, sessionId: string): string {
75
75
  return join(workspaceStateDir(cwd), 'artifacts', sessionId);
76
76
  }
77
77
 
78
+ /** Background shell jobs' output files, `<shell id>.txt`, as Cursor keeps them in a workspace's terminals folder. */
79
+ export function terminalsDir(cwd: string): string {
80
+ return join(workspaceStateDir(cwd), 'terminals');
81
+ }
82
+
78
83
  export function worktreesDir(cwd: string): string {
79
84
  return join(workspaceStateDir(cwd), 'worktrees');
80
85
  }
@@ -2,12 +2,14 @@ import { existsSync } from 'node:fs';
2
2
 
3
3
  import type { SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime';
4
4
  import { type BashSpawnContext, createBashTool, type ExtensionAPI, type ExtensionToolContext, type ToolDefinition } from '@earendil-works/pi-coding-agent';
5
+ import { linuxReadonlyCommand } from './linux-readonly-sandbox.ts';
5
6
 
6
7
  // Cursor's readonly subagent: "no file edits, no state-changing shell commands". The OS sandbox enforces it:
7
8
  // nothing may be written and no host may be reached, so a pipe, substitution, or pager cannot get around it.
9
+ // Linux uses bubblewrap directly (linux-readonly-sandbox.ts); macOS uses sandbox-runtime's Seatbelt policy below.
8
10
  // denyWrite names the paths the sandbox makes writable for itself (/tmp/claude is a dependency default), and
9
11
  // denyRead drops the dependency's writable home defaults (~/.npm/_logs, ~/.claude/debug). Seatbelt is
10
- // last-match-wins with deny emitted after allow; on Linux bubblewrap binds deny last the same way.
12
+ // last-match-wins with deny emitted after allow.
11
13
  // Unix sockets must stay blocked too: a host service can mutate files on a command's behalf.
12
14
  const READ_ONLY_POLICY: SandboxRuntimeConfig = {
13
15
  network: { allowedDomains: [], deniedDomains: [], allowAllUnixSockets: false },
@@ -29,7 +31,7 @@ type Sandbox = typeof import('@anthropic-ai/sandbox-runtime')['SandboxManager'];
29
31
  let ready: Promise<Sandbox> | undefined;
30
32
 
31
33
  /**
32
- * Starts the sandbox once per process, loading it only in a readonly subagent: a local package install gets no
34
+ * Starts sandbox-runtime once per process, loading it only in a readonly subagent: a local package install gets no
33
35
  * dependency install, and everything else in the package works without it. A failure is kept, so no command ever
34
36
  * runs outside the sandbox.
35
37
  */
@@ -73,11 +75,7 @@ export function createReadonlyBashTool(name = 'bash'): ToolDefinition<BashParame
73
75
  description: `${base.description}${NOTE}`,
74
76
  async execute(toolCallId, params, signal, onUpdate, ctx: ExtensionToolContext) {
75
77
  // Fail closed: when the sandbox cannot start, the call fails here and nothing spawns.
76
- const sandbox = await sandboxReady();
77
- // The per-command policy re-states READ_ONLY_POLICY, so the command never inherits a policy another
78
- // consumer left on the shared manager. Wrapping honors abort, and Pi's own shell operations then enforce
79
- // the pre-spawn abort check, timeouts, and process-tree cleanup.
80
- const command = await sandbox.wrapWithSandbox(params.command, undefined, READ_ONLY_POLICY, signal);
78
+ const command = process.platform === 'linux' ? linuxCommand(params.command, ctx.cwd) : await wrapped(params.command, signal);
81
79
  const bash = createBashTool(ctx.cwd, {
82
80
  ...(existsSync('/bin/bash') ? { shellPath: '/bin/bash' } : {}),
83
81
  spawnHook: (spawn: BashSpawnContext) => ({ ...spawn, command, env: strippedEnv(spawn.env) }),
@@ -87,10 +85,25 @@ export function createReadonlyBashTool(name = 'bash'): ToolDefinition<BashParame
87
85
  };
88
86
  }
89
87
 
88
+ function linuxCommand(command: string, cwd: string): string {
89
+ try {
90
+ return linuxReadonlyCommand(command, cwd);
91
+ } catch (error) {
92
+ throw new Error(`no verified OS sandbox is available: ${error instanceof Error ? error.message : String(error)}`);
93
+ }
94
+ }
95
+
90
96
  /**
91
- * Releases the sandbox if it started. On Linux the sandbox's socat bridges keep the event loop alive, so a process
92
- * that used the shell never exits unless the sandbox is released.
97
+ * The per-command policy re-states READ_ONLY_POLICY, so the command never inherits a policy another consumer left on
98
+ * the shared manager. Wrapping honors abort, and Pi's own shell operations then enforce the pre-spawn abort check,
99
+ * timeouts, and process-tree cleanup.
93
100
  */
101
+ async function wrapped(command: string, signal: AbortSignal | undefined): Promise<string> {
102
+ const sandbox = await sandboxReady();
103
+ return sandbox.wrapWithSandbox(command, undefined, READ_ONLY_POLICY, signal);
104
+ }
105
+
106
+ /** Releases sandbox-runtime if it started, so a process that used the shell can exit. */
94
107
  export async function releaseReadonlySandbox(): Promise<void> {
95
108
  const started = ready;
96
109
  ready = undefined;
package/src/sections.ts CHANGED
@@ -6,6 +6,8 @@ import type { AgentDefinition } from './task/agents.ts';
6
6
  import { type AvailableModel, type ModelSpec, modelLabel } from './task/models.ts';
7
7
 
8
8
  export interface HostFacts {
9
+ /** Today's date as Cursor states it in its user info, such as "Wednesday Nov 4, 2026". */
10
+ today: string;
9
11
  packageRoot: string;
10
12
  skillsDir: string;
11
13
  skillNames: readonly string[];
@@ -18,6 +20,8 @@ export interface HostFacts {
18
20
  rootStoreDir: string;
19
21
  projectsDir: string;
20
22
  artifactsDir: string;
23
+ /** Background shell jobs' output files. */
24
+ terminalsDir: string;
21
25
  /** False in print and JSON runs, including subagents, which exit when they settle. */
22
26
  loopAvailable: boolean;
23
27
  /** False when nobody can answer a dialog (print and JSON runs, including subagents). */
@@ -29,7 +33,9 @@ export interface HostFacts {
29
33
  }
30
34
 
31
35
  const LOOP_LINE =
32
- '- /loop: the user starts `/loop [interval] <prompt>`. When a playbook says to arm /loop, call the Loop tool with prompt plus interval (fixed cadence) or wake_in (agent-paced). Inside a loop, Loop with wake_in schedules the next run and stop: true ends it.';
36
+ '- /loop: the user starts `/loop [interval] <prompt>`. When a playbook says to arm /loop, run the prompt once now, in this turn, then call the Loop tool with prompt plus interval (fixed cadence) or wake_in (agent-paced); its first scheduled run comes after that interval or wake. Inside a loop, Loop with wake_in schedules the next run and stop: true ends it.';
37
+ const EVENT_LOOP_SENTENCE =
38
+ " When the next run waits on an event (CI finishing, a ref advancing, a log line), start a watcher with bash_background that exits, or prints a line its notify_on_output pattern matches, when the event fires, and keep a long wake_in as the fallback heartbeat: the watcher's notification wakes you.";
33
39
  const NO_LOOP_LINE =
34
40
  '- /loop: this run ends when it settles (a subagent, or a print or JSON run), so it has no Loop tool and cannot arm a loop. Keep working until the goal is met, and name in the final reply anything the parent chat should loop on.';
35
41
 
@@ -52,6 +58,14 @@ export function alwaysAppliedRules(skillsDir: string): string[] {
52
58
  return bodies;
53
59
  }
54
60
 
61
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
62
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
63
+
64
+ /** The local date in Cursor's user-info form: weekday, short month, day, year. */
65
+ export function todayText(date: Date): string {
66
+ return `${WEEKDAYS[date.getDay()]} ${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
67
+ }
68
+
55
69
  const TASK_PARAMS = 'description, prompt, subagent_type, model, readonly, run_in_background, resume, fork, fork_turns, environment, cloud_base_branch, file_attachments';
56
70
 
57
71
  function todoLine(facts: HostFacts): string {
@@ -79,7 +93,7 @@ function agentToolsLine(tools: readonly string[]): string[] {
79
93
  export function hostSection(facts: HostFacts): string {
80
94
  const transcript = facts.sessionFile === undefined ? 'This chat is not persisted (--no-session), so it has no transcript file.' : `This chat's transcript is ${facts.sessionFile}.`;
81
95
  return [
82
- `pstack runs here through the pi-pstack package at ${facts.packageRoot}.`,
96
+ `pstack runs here through the pi-pstack package at ${facts.packageRoot}. Today's date: ${facts.today}.`,
83
97
  `- <SKILLS> = ${facts.skillsDir}. "the X skill", "/X", and "principle-X" mean <SKILLS>/X/SKILL.md, even for skills hidden from automatic invocation; a delegate that needs poteto-mode reads <SKILLS>/poteto-mode/SKILL.md. Installed skills (${facts.skillNames.length}): ${facts.skillNames.join(', ')}.`,
84
98
  '- A <skill> block in a user message means the user attached that skill to this request. Follow its workflow from its first step, including the steps it says to take before answering, instead of answering directly.',
85
99
  `- Skill folders: project \`.pi/skills/\`, personal \`${join(facts.agentDir, 'skills')}/\`.`,
@@ -95,7 +109,12 @@ export function hostSection(facts: HostFacts): string {
95
109
  `- Files a tool makes for the user, such as visualize charts, go in ${facts.artifactsDir}.`,
96
110
  facts.loopAvailable ? SUBSCRIPTIONS_LINE : NO_SUBSCRIPTIONS_LINE,
97
111
  ...agentToolsLine(facts.activeTools),
98
- facts.loopAvailable ? LOOP_LINE : NO_LOOP_LINE,
112
+ facts.loopAvailable ? `${LOOP_LINE}${facts.activeTools.includes('bash_background') ? EVENT_LOOP_SENTENCE : ''}` : NO_LOOP_LINE,
113
+ ...(facts.activeTools.includes('bash_background')
114
+ ? [
115
+ `- Background shells: bash_background runs a command in the background, or moves it there after block_until_ms, and writes its output to ${facts.terminalsDir}/<shell id>.txt. The first lines of that file hold pid, cwd, command, title, status, and running_for_ms (\`head -n 10 *.txt\` there lists every job), and a footer with exit_code appears when the command ends. A notification wakes you after your turn when a job finishes and on each notify_on_output match; AwaitShell polls or waits. Stop a job with \`kill <pid>\`. bash waits for its command to finish and never moves it to the background, so start anything that can run for minutes (a watcher, a polling script such as watch-pr without --status-only, a dev server) with bash_background.`,
116
+ ]
117
+ : []),
99
118
  facts.activeTools.includes('GenerateImage')
100
119
  ? '- Image generation: the GenerateImage tool, which saves each image to the artifacts folder below and shows it in its result.'
101
120
  : '- Image generation: no image model is configured, so this session has no GenerateImage tool. Say so and use a mermaid diagram or a table instead.',
@@ -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
+ }