pi-jar 0.1.5 → 0.1.7

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/plan.ts CHANGED
@@ -75,7 +75,6 @@ export class PlanMode {
75
75
  private reminders = 0;
76
76
  private reviewing = false;
77
77
  private compacting = false;
78
- private contextDirty = true;
79
78
  private compactGeneration = 0;
80
79
  private executionRestore: (() => Promise<void>) | undefined;
81
80
  private executionRunning = false;
@@ -137,7 +136,6 @@ export class PlanMode {
137
136
  try { this.ensureDirectory(ctx); }
138
137
  catch (error) { ctx.ui.notify("Plan mode unavailable: cannot create plan directory: " + (error as Error).message, "error"); return; }
139
138
  this.enabled = true;
140
- this.contextDirty = true;
141
139
  this.pendingReview = false;
142
140
  this.reminders = 0;
143
141
  this.toolsBefore = this.pi.getActiveTools();
@@ -152,7 +150,6 @@ export class PlanMode {
152
150
  private async leave(ctx: ExtensionContext, persist = true): Promise<void> {
153
151
  const wasEnabled = this.enabled;
154
152
  this.enabled = false;
155
- this.contextDirty = true;
156
153
  this.compacting = false;
157
154
  this.pendingReview = false;
158
155
  this.compactGeneration++;
@@ -417,14 +414,13 @@ export class PlanMode {
417
414
 
418
415
  this.pi.on("before_agent_start", async () => {
419
416
  if (!this.enabled) return;
420
- this.contextDirty = true;
421
417
  return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
422
418
  });
423
419
 
424
420
  // Keep plan-only prompt messages bounded. When plan mode is active, retain only the newest
425
- // context and newest reminder; when it is off, remove all stale plan prompt messages.
421
+ // context and newest reminder; when it is off, remove all stale plan prompt messages. Context
422
+ // rewrites apply to a single LLM call, so this runs on every call.
426
423
  this.pi.on("context", async (event) => {
427
- if (!this.contextDirty) return;
428
424
  let latestContext = -1;
429
425
  let latestReminder = -1;
430
426
  let needsPrune = false;
@@ -438,7 +434,6 @@ export class PlanMode {
438
434
  else latestReminder = index;
439
435
  }
440
436
  }
441
- this.contextDirty = false;
442
437
  if (!needsPrune) return;
443
438
  return { messages: event.messages.filter((raw, index) => {
444
439
  const type = (raw as { customType?: string }).customType;
@@ -460,7 +455,6 @@ export class PlanMode {
460
455
  return;
461
456
  }
462
457
  this.reminders++;
463
- this.contextDirty = true;
464
458
  return {
465
459
  entries: [{ type: "custom_message", customType: "pi-jar.plan-reminder", display: false,
466
460
  content: `[PI-JAR PLAN MODE] You ended the turn without ${PLAN_SUBMIT_TOOL}. If the plan is ready, write it to the plan directory and call ${PLAN_SUBMIT_TOOL}. If you need a decision from the user, use jar_ask. Do not ask for approval in chat.` }],
@@ -480,8 +474,8 @@ export class PlanMode {
480
474
  if (this.enabled && this.pendingReview) await this.review(ctx);
481
475
  });
482
476
 
483
- this.pi.on("session_start", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
484
- this.pi.on("session_tree", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
477
+ this.pi.on("session_start", async (_event, ctx) => { await this.restore(sessionBranch(ctx), ctx); });
478
+ this.pi.on("session_tree", async (_event, ctx) => { await this.restore(sessionBranch(ctx), ctx); });
485
479
  this.pi.on("session_shutdown", async (_event, ctx) => { if (this.enabled) await this.leave(ctx, false); });
486
480
  }
487
481
  }
@@ -0,0 +1,104 @@
1
+ import type { ExtensionAPI, CustomMessageEntryDraft } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { cleanText } from "./status.ts";
4
+ import { describe, SHELL_MESSAGE, type ShellEvent, type ShellManager } from "./shells.ts";
5
+
6
+ export interface ShellNotificationDetails {
7
+ summary: string;
8
+ ids: string[];
9
+ events: Array<{ id: string; name: string; kind: ShellEvent["kind"]; status: string; exitCode?: number | null }>;
10
+ }
11
+
12
+ /** A compact transport payload; logs are fetched explicitly, never copied into wake-up messages. */
13
+ export function shellNotification(events: readonly ShellEvent[]): CustomMessageEntryDraft {
14
+ const failed = events.filter(({ kind, job }) => kind === "exit" && (job.status === "failed" || (job.status === "exited" && job.exitCode !== 0))).length;
15
+ const ready = events.filter(event => event.kind === "match").length;
16
+ const completed = events.length - ready;
17
+ const summary = `Background shells · ${completed} completed${failed ? ` · ${failed} failed` : ""}${ready ? ` · ${ready} ready` : ""}`;
18
+ const details: ShellNotificationDetails = {
19
+ summary, ids: events.map(event => event.job.id),
20
+ events: events.map(({ kind, job }) => ({ id: job.id, name: job.name, kind, status: job.status, exitCode: job.exitCode }))
21
+ };
22
+ return {
23
+ type: "custom_message", customType: SHELL_MESSAGE, display: true, details,
24
+ content: [summary, ...events.map(({ kind, job }) => kind === "match" ? `${job.id} · ${job.name} · ready: ${cleanText(job.matched ?? "", 120)}` : describe(job)),
25
+ "Use jar_shell output for relevant diagnostics only; observed completions do not replay."].join("\n")
26
+ };
27
+ }
28
+
29
+ /** Keep events in our own queue while the model runs, not Pi's irrevocable follow-up queue.
30
+ * Boundary entries are consumed before final settlement; output/peek/wait can acknowledge events
31
+ * before delivery. Idle events wake once after a debounce, and session replacement drops old work. */
32
+ export function registerShellNotifications(pi: ExtensionAPI, manager: () => ShellManager | undefined): { notify(): void; dispose(): void } {
33
+ let active = false;
34
+ let interrupted = false;
35
+ let deliveryFailures = 0;
36
+ let timer: ReturnType<typeof setTimeout> | undefined;
37
+ const warned = new Set<string>();
38
+ const cancelTimer = () => { clearTimeout(timer); timer = undefined; };
39
+ const take = () => {
40
+ cancelTimer();
41
+ const events = manager()?.takeNotifications() ?? [];
42
+ return events.length ? shellNotification(events) : undefined;
43
+ };
44
+ const notify = () => {
45
+ if (active || interrupted || timer || !manager()?.pendingNotifications()) return;
46
+ const owner = manager();
47
+ timer = setTimeout(() => {
48
+ timer = undefined;
49
+ if (active || interrupted || owner !== manager()) return;
50
+ const events = owner?.takeNotifications() ?? [];
51
+ if (!events.length) return;
52
+ const { type: _type, ...message } = shellNotification(events);
53
+ try { pi.sendMessage(message, { triggerTurn: true, deliverAs: "nextTurn" }); deliveryFailures = 0; }
54
+ catch (error) {
55
+ owner?.restoreNotifications(events);
56
+ console.error("pi-jar: could not deliver shell summary", error);
57
+ if (++deliveryFailures < 3) notify();
58
+ }
59
+ }, 300);
60
+ timer.unref?.();
61
+ };
62
+ const reset = () => { cancelTimer(); active = false; interrupted = false; deliveryFailures = 0; warned.clear(); };
63
+
64
+ pi.registerMessageRenderer?.<ShellNotificationDetails>(SHELL_MESSAGE, (message, { expanded }, theme) => {
65
+ const text = typeof message.content === "string" ? message.content : "Background shells";
66
+ const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Background shells";
67
+ return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for status"), 0, 0);
68
+ });
69
+ pi.on?.("session_start", reset);
70
+ pi.on?.("session_shutdown", reset);
71
+ pi.on?.("before_agent_start", () => {
72
+ active = true;
73
+ interrupted = false;
74
+ const entry = take();
75
+ if (!entry) return;
76
+ const { type: _type, ...message } = entry;
77
+ return { message };
78
+ });
79
+ pi.on?.("agent_start", () => { active = true; cancelTimer(); });
80
+ pi.on?.("turn_end", event => {
81
+ if (event.outcome !== "completed" || event.context?.canContinue === false) return;
82
+ const entry = take();
83
+ if (entry) return { entries: [entry], continue: true };
84
+ });
85
+ pi.on?.("agent_before_settle", event => {
86
+ interrupted = event.outcome !== "completed";
87
+ if (interrupted || event.context?.canContinue === false) return;
88
+ const entry = take();
89
+ if (entry) return { entries: [entry], continue: true };
90
+ // One reminder per outstanding finite job, never a self-repeating continuation loop.
91
+ const owner = manager();
92
+ const known = new Set(owner?.summaries().map(job => job.id) ?? []);
93
+ for (const id of warned) if (!known.has(id)) warned.delete(id);
94
+ const pending = (owner?.verificationPending() ?? []).filter(job => !warned.has(job.id));
95
+ if (!pending.length) return;
96
+ for (const job of pending) warned.add(job.id);
97
+ return { entries: [{ type: "custom_message" as const, customType: SHELL_MESSAGE, display: true,
98
+ content: `Verification still pending: ${pending.map(job => `${job.id} (${job.name})`).join(", ")}. Use jar_shell wait/peek for these ids before claiming success, or clearly report them as pending. Do not wait for long-lived services.`,
99
+ details: { summary: `${pending.length} background check(s) still pending`, ids: pending.map(job => job.id) }
100
+ }], continue: true };
101
+ });
102
+ pi.on?.("agent_settled", () => { active = false; notify(); });
103
+ return { notify, dispose: reset };
104
+ }
package/src/shells.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
+ import { signalProcessTree } from "./async-process.ts";
2
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
4
  import { Text } from "@earendil-works/pi-tui";
4
5
  import { Type } from "typebox";
@@ -30,6 +31,8 @@ export interface ShellJob {
30
31
  signal?: string | null;
31
32
  error?: string;
32
33
  watch?: string;
34
+ purpose?: "task" | "service";
35
+ complete?: boolean;
33
36
  matched?: string;
34
37
  /** Wake the agent when the pattern matches or the process ends. */
35
38
  notify: boolean;
@@ -38,14 +41,27 @@ export interface ShellJob {
38
41
  dropped: number;
39
42
  }
40
43
  export type ShellEvent = { kind: "match" | "exit"; job: ShellJob };
41
- export interface StartOptions { command: string; cwd: string; name?: string; watch?: string; notify?: boolean }
44
+ export interface StartOptions { command: string; cwd: string; name?: string; watch?: string; notify?: boolean; purpose?: "task" | "service" }
42
45
 
43
46
  const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
44
47
 
48
+ interface ShellEntry {
49
+ job: ShellJob;
50
+ child?: ChildProcess;
51
+ pattern?: RegExp;
52
+ partial: string;
53
+ timer?: ReturnType<typeof setTimeout>;
54
+ closeTimer?: ReturnType<typeof setTimeout>;
55
+ finished?: boolean;
56
+ }
57
+
45
58
  /** Long-running shell commands with bounded output and optional pattern watches. */
46
59
  export class ShellManager {
47
- private jobs = new Map<string, { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> }>();
60
+ private jobs = new Map<string, ShellEntry>();
48
61
  private next = 1;
62
+ private pending = new Map<string, ShellEvent>();
63
+ private listeners = new Set<() => void>();
64
+ private disposed = false;
49
65
  private lineChars = new Map<string, number>();
50
66
  private readonly onEvent: (event: ShellEvent) => void;
51
67
  private readonly spawnShell: typeof spawn;
@@ -61,6 +77,44 @@ export class ShellManager {
61
77
  });
62
78
  }
63
79
  get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
80
+ acknowledge(ids: readonly string[]): void { for (const id of ids) this.pending.delete(id); }
81
+ takeNotifications(): ShellEvent[] { const events = [...this.pending.values()]; this.pending.clear(); return events; }
82
+ restoreNotifications(events: readonly ShellEvent[]): void {
83
+ for (const event of events) if (this.jobs.has(event.job.id) && !this.pending.has(event.job.id)) this.pending.set(event.job.id, event);
84
+ }
85
+ pendingNotifications(): number { return this.pending.size; }
86
+ verificationPending(): Array<Omit<ShellJob, "lines">> {
87
+ return [...this.jobs.values()].filter(entry => !entry.finished && entry.job.purpose !== "service").map(({ job }) => {
88
+ const { lines: _lines, ...summary } = job;
89
+ return summary;
90
+ });
91
+ }
92
+ private emitEvent(kind: ShellEvent["kind"], job: ShellJob): void {
93
+ if (!this.jobs.has(job.id) || this.disposed) return;
94
+ // Terminal state supersedes readiness; queue metadata only, not duplicated log buffers.
95
+ if (job.notify) this.pending.set(job.id, { kind, job: { ...job, lines: [] } });
96
+ this.onEvent({ kind, job: { ...job, lines: job.lines.slice(-20) } });
97
+ }
98
+ async wait(ids: readonly string[], timeoutMs = 1000, signal?: AbortSignal): Promise<boolean> {
99
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0 || timeoutMs > 30_000) throw new Error("waitMs must be between 0 and 30000");
100
+ for (const id of ids) if (!this.jobs.has(id)) throw new Error("no shell " + id);
101
+ if (this.disposed) throw new Error("shell session disposed");
102
+ if (signal?.aborted) throw signal.reason ?? new Error("wait cancelled");
103
+ const ready = () => ids.every(id => this.jobs.get(id)?.finished === true);
104
+ if (ready() || timeoutMs === 0) return ready();
105
+ return new Promise<boolean>((resolve, reject) => {
106
+ const cleanup = () => { clearTimeout(timer); this.listeners.delete(check); signal?.removeEventListener("abort", abort); };
107
+ const check = () => {
108
+ if (this.disposed || ids.some(id => !this.jobs.has(id))) { cleanup(); reject(new Error("shell session disposed or result expired")); }
109
+ else if (ready()) { cleanup(); resolve(true); }
110
+ };
111
+ const abort = () => { cleanup(); reject(signal?.reason ?? new Error("wait cancelled")); };
112
+ const timer = setTimeout(() => { cleanup(); resolve(false); }, timeoutMs);
113
+ this.listeners.add(check);
114
+ signal?.addEventListener("abort", abort, { once: true });
115
+ if (signal?.aborted) abort(); else check();
116
+ });
117
+ }
64
118
  running(): number {
65
119
  let count = 0;
66
120
  for (const { job } of this.jobs.values()) if (job.status === "running") count++;
@@ -68,6 +122,7 @@ export class ShellManager {
68
122
  }
69
123
 
70
124
  start(options: StartOptions): ShellJob {
125
+ if (this.disposed) throw new Error("shell session disposed");
71
126
  const command = options.command.trim();
72
127
  if (!command) throw new Error("command is required");
73
128
  if (this.running() >= MAX_RUNNING_SHELLS) throw new Error(`at most ${MAX_RUNNING_SHELLS} shells can run at once; kill one first`);
@@ -79,15 +134,17 @@ export class ShellManager {
79
134
  this.prune();
80
135
  const id = "s" + this.next++;
81
136
  const job: ShellJob = { id, name: cleanText(options.name || command, 40), command, cwd: options.cwd, startedAt: Date.now(), status: "running",
82
- notify: options.notify ?? true, lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
83
- const entry: { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> } = { job, partial: "", ...(pattern ? { pattern } : {}) };
137
+ notify: options.notify ?? true, complete: false, purpose: options.purpose ?? (options.watch ? "service" : "task"), lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
138
+ const entry: ShellEntry = { job, partial: "", ...(pattern ? { pattern } : {}) };
84
139
  this.jobs.set(id, entry);
85
140
  this.lineChars.set(id, 0);
86
141
  // Own process group so kill() stops the whole tree (dev servers spawn children).
87
142
  const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
88
143
  entry.child = child;
89
144
  if (child.pid !== undefined) job.pid = child.pid;
90
- const receive = (chunk: Buffer | string) => this.receive(entry, String(chunk));
145
+ const receive = (chunk: Buffer | string) => { if (!entry.finished) this.receive(entry, String(chunk)); };
146
+ child.stdout?.setEncoding?.("utf8");
147
+ child.stderr?.setEncoding?.("utf8");
91
148
  child.stdout?.on("data", receive);
92
149
  child.stderr?.on("data", receive);
93
150
  child.on("error", (error) => {
@@ -97,13 +154,31 @@ export class ShellManager {
97
154
  job.endedAt = Date.now();
98
155
  this.finish(entry);
99
156
  });
100
- child.on("close", (code, signal) => {
157
+ const complete = (code: number | null, signal: NodeJS.Signals | null) => {
158
+ if (entry.finished) return;
101
159
  if (entry.partial) { this.push(entry, entry.partial); entry.partial = ""; }
102
160
  if (job.status === "running") job.status = "exited";
103
161
  job.exitCode = code;
104
162
  job.signal = signal;
105
163
  job.endedAt ??= Date.now();
106
164
  this.finish(entry);
165
+ };
166
+ child.once("close", complete);
167
+ child.once("exit", (code, signal) => {
168
+ if (entry.finished) return;
169
+ if (job.status === "running") job.status = "exited";
170
+ job.exitCode = code;
171
+ job.signal = signal;
172
+ job.endedAt ??= Date.now();
173
+ // Grandchildren may keep stdio open after the shell exits. Drain briefly, then reclaim
174
+ // the entire group and emit exactly one terminal event even if close never arrives.
175
+ entry.closeTimer = setTimeout(() => {
176
+ signalProcessTree(child);
177
+ child.stdout?.destroy?.();
178
+ child.stderr?.destroy?.();
179
+ complete(code, signal);
180
+ }, 250);
181
+ entry.closeTimer.unref();
107
182
  });
108
183
  this.onChange?.();
109
184
  return { ...job, lines: [] };
@@ -133,13 +208,22 @@ export class ShellManager {
133
208
  }
134
209
  if (entry.pattern && !job.matched && entry.pattern.test(line)) {
135
210
  job.matched = line;
136
- this.onEvent({ kind: "match", job: { ...job, lines: job.lines.slice(-20) } });
211
+ this.emitEvent("match", job);
137
212
  }
138
213
  }
139
214
 
140
- private finish(entry: { job: ShellJob; timer?: ReturnType<typeof setTimeout> }): void {
141
- if (entry.timer) clearTimeout(entry.timer);
142
- this.onEvent({ kind: "exit", job: { ...entry.job, lines: entry.job.lines.slice(-20) } });
215
+ private finish(entry: ShellEntry): void {
216
+ if (entry.finished) return;
217
+ entry.finished = true;
218
+ entry.job.complete = true;
219
+ entry.job.endedAt ??= Date.now();
220
+ clearTimeout(entry.timer);
221
+ clearTimeout(entry.closeTimer);
222
+ if (entry.child) signalProcessTree(entry.child);
223
+ entry.child?.stdout?.destroy?.();
224
+ entry.child?.stderr?.destroy?.();
225
+ this.emitEvent("exit", entry.job);
226
+ for (const listener of [...this.listeners]) listener();
143
227
  this.onChange?.();
144
228
  }
145
229
 
@@ -159,13 +243,13 @@ export class ShellManager {
159
243
  if (entry.job.status !== "running" || !entry.child) return false;
160
244
  entry.job.status = "killed";
161
245
  entry.job.endedAt = Date.now();
162
- const signal = (name: NodeJS.Signals) => {
163
- const pid = entry.child?.pid;
164
- try { if (pid && process.platform !== "win32") process.kill(-pid, name); else entry.child?.kill(name); }
165
- catch { entry.child?.kill(name); }
166
- };
167
- signal("SIGTERM");
168
- entry.timer = setTimeout(() => signal("SIGKILL"), KILL_GRACE_MS);
246
+ signalProcessTree(entry.child, "SIGTERM");
247
+ entry.timer = setTimeout(() => {
248
+ signalProcessTree(entry.child!);
249
+ entry.child?.stdout?.destroy?.();
250
+ entry.child?.stderr?.destroy?.();
251
+ this.finish(entry);
252
+ }, KILL_GRACE_MS);
169
253
  entry.timer.unref?.();
170
254
  this.onChange?.();
171
255
  return true;
@@ -177,10 +261,14 @@ export class ShellManager {
177
261
  for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) {
178
262
  this.jobs.delete(job.id);
179
263
  this.lineChars.delete(job.id);
264
+ this.pending.delete(job.id);
180
265
  }
181
266
  }
182
267
 
183
268
  dispose(): void {
269
+ this.disposed = true;
270
+ this.pending.clear();
271
+ for (const listener of [...this.listeners]) listener();
184
272
  for (const id of this.jobs.keys()) { try { this.kill(id); } catch { /* already gone */ } }
185
273
  this.jobs.clear();
186
274
  this.lineChars.clear();
@@ -194,7 +282,7 @@ export const elapsed = (span: { startedAt: number; endedAt?: number }, now = Dat
194
282
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
195
283
  };
196
284
  export const shellState = (job: ShellMeta | ShellJob) => job.status === "running" ? "running"
197
- : job.status === "exited" ? `exited ${job.exitCode ?? "?"}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
285
+ : job.status === "exited" ? `exited ${job.exitCode ?? "?"}${job.complete === false ? " (draining)" : ""}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
198
286
  export const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
199
287
  const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): string => {
200
288
  let size = 0;
@@ -222,12 +310,15 @@ export function shellEventMessage(event: ShellEvent): string {
222
310
  }
223
311
 
224
312
  const Parameters = Type.Object({
225
- action: Type.Unsafe<"start" | "list" | "output" | "kill">({ type: "string", enum: ["start", "list", "output", "kill"] }),
313
+ action: Type.Unsafe<"start" | "list" | "peek" | "wait" | "output" | "kill">({ type: "string", enum: ["start", "list", "peek", "wait", "output", "kill"] }),
226
314
  command: Type.Optional(Type.String({ description: "Shell command to run in the background (start)." })),
227
315
  name: Type.Optional(Type.String({ description: "Short label, e.g. \"dev server\"." })),
228
316
  watch: Type.Optional(Type.String({ description: "Regex; when a line matches you are woken once, e.g. \"ready on|error\"." })),
229
317
  notify: Type.Optional(Type.Boolean({ description: "Wake you when the pattern matches or the process ends (default true)." })),
230
- id: Type.Optional(Type.String({ description: "Shell id for output/kill, e.g. s1." })),
318
+ purpose: Type.Optional(Type.Union([Type.Literal("task"), Type.Literal("service")], { description: "task is finite and must be checked before claiming completion; service is intentionally long-lived (default with watch)." })),
319
+ id: Type.Optional(Type.String({ description: "Shell id for output/kill/wait/peek, e.g. s1." })),
320
+ ids: Type.Optional(Type.Array(Type.String(), { maxItems: 20, description: "Selected ids for wait/peek. Omit to inspect all jobs or wait for finite tasks only." })),
321
+ waitMs: Type.Optional(Type.Number({ minimum: 0, maximum: 30000, description: "Bounded event-driven wait (default 1000ms, maximum 30000). Esc cancels the wait, not the shell." })),
231
322
  lines: Type.Optional(Type.Number({ description: "How many trailing output lines to return (default 40, max 400)." }))
232
323
  });
233
324
 
@@ -236,36 +327,49 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
236
327
  pi.registerTool({
237
328
  name: SHELL_TOOL,
238
329
  label: "shell",
239
- description: "Run long-lived commands (dev servers, watchers, long test runs) in the background. start returns immediately; you are woken when an optional watch pattern matches or the process exits. Use output to read recent lines and kill to stop it.",
330
+ description: "Run background jobs. start returns immediately; notifications are compact and coalesced. peek reads status, wait checks selected tasks with a bounded event-driven wait, output loads logs and kill stops a process. Observed results are acknowledged so stale notifications do not replay. Verify relevant checks before claiming completion; do not wait for long-lived services.",
240
331
  promptSnippet: "Use jar_shell for long-running or never-ending commands instead of blocking bash.",
241
332
  promptGuidelines: [
242
333
  "Use jar_shell start for servers, watchers and slow commands; set watch to a regex for the line you are waiting for (e.g. \"listening|ready|error\").",
243
- "Do not poll: after starting, continue other work or end your turn; a message wakes you when the watch matches or the process ends. Kill shells you no longer need."
334
+ "Do not poll: work while commands run, then use wait with explicit ids for relevant checks before the final summary. A bounded wait returns pending jobs honestly; never claim success without their exit codes. Do not wait for services/watchers. output, peek, list and wait acknowledge observed events, preventing stale replay.",
335
+ "Notifications contain coalesced status, not log dumps. Inspect output only for relevant diagnostics. Mark never-ending jobs purpose: service and kill shells you no longer need."
244
336
  ],
245
337
  parameters: Parameters,
246
- async execute(_id, params, _signal, _update, ctx) {
338
+ async execute(_id, params, signal, _update, ctx) {
247
339
  const manager = shells();
248
340
  const reply = (text: string) => ({ content: [{ type: "text" as const, text: text.slice(0, MAX_TOOL_OUTPUT_CHARS) }], details: {
249
- jobs: (manager?.summaries() ?? []).map((job) => ({ id: job.id, name: job.name, status: job.status, pid: job.pid, exitCode: job.exitCode }))
341
+ jobs: (manager?.summaries() ?? []).map((job) => ({ id: job.id, name: job.name, status: job.status, purpose: job.purpose, complete: job.complete, pid: job.pid, exitCode: job.exitCode })),
342
+ pendingNotifications: manager?.pendingNotifications() ?? 0
250
343
  } });
251
344
  if (!manager) return reply("Background shells are unavailable before a Pi session starts.");
252
345
  try {
253
346
  switch (params.action) {
254
347
  case "start": {
255
348
  const job = manager.start({ command: params.command ?? "", cwd: ctx.cwd, ...(params.name ? { name: params.name } : {}), ...(params.watch ? { watch: params.watch } : {}),
256
- ...(params.notify !== undefined ? { notify: params.notify } : {}) });
349
+ ...(params.notify !== undefined ? { notify: params.notify } : {}), ...(params.purpose ? { purpose: params.purpose } : {}) });
257
350
  return reply(`Started ${describe(job)} (pid ${job.pid ?? "?"}). ${job.notify ? "You will be woken when it " + (job.watch ? "matches or " : "") + "exits." : "Notifications are off; check it with output."}`);
258
351
  }
259
352
  case "output": {
260
353
  if (!params.id) return reply("output needs an id.");
261
354
  const lines = manager.output(params.id, params.lines ?? 40);
355
+ manager.acknowledge([params.id]);
262
356
  return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? boundedTail(lines) : "(no output yet)"}`);
263
357
  }
264
358
  case "kill":
265
359
  if (!params.id) return reply("kill needs an id.");
266
360
  return reply(manager.kill(params.id) ? `Stopping ${params.id}.` : `${params.id} is not running.`);
361
+ case "wait": {
362
+ const ids = params.ids ?? (params.id ? [params.id] : manager.summaries().filter(job => job.purpose !== "service").map(job => job.id));
363
+ const completed = await manager.wait(ids, params.waitMs ?? 1000, signal);
364
+ const jobs = manager.summaries().filter(job => ids.includes(job.id));
365
+ manager.acknowledge(ids);
366
+ return reply((completed ? "Selected jobs completed." : "Wait timed out; jobs still pending. Do not claim they passed.") + (jobs.length ? "\n" + jobs.map(describe).join("\n") : " No finite tasks selected."));
367
+ }
267
368
  default: {
268
- const jobs = manager.summaries();
369
+ const ids = params.ids ?? (params.id ? [params.id] : undefined);
370
+ const jobs = manager.summaries().filter(job => !ids || ids.includes(job.id));
371
+ if (ids?.some(id => !jobs.some(job => job.id === id))) return reply("Unknown shell id in selection.");
372
+ manager.acknowledge(jobs.map(job => job.id));
269
373
  return reply(jobs.length ? jobs.map(describe).join("\n") : "No background shells.");
270
374
  }
271
375
  }
@@ -0,0 +1,45 @@
1
+ import { workspacePathAllowed } from "./delegate-worktree.ts";
2
+
3
+ export const CHILD_TOOLS_ENV = "PI_JAR_CHILD_TOOLS";
4
+ export const WEB_TOOLS = ["web_enable", "web_search", "fetch_content", "get_search_content", "source_check"] as const;
5
+ export const RECURSIVE_TOOLS = new Set(["jar_delegate", "jar_subagent", "jar_democracy"]);
6
+
7
+ /** Multi-file tools must validate each effective target, not just the top-level default path. */
8
+ export function worktreeToolViolation(root: string, cwd: string, name: string, args: unknown): string | undefined {
9
+ if (["bash", "powershell", "jar_shell"].includes(name)) return "Sandboxed worktree subagents cannot run shell tools.";
10
+ const pathTools = new Set(["read", "edit", "write", "grep", "find", "ls", "multi_file_edit"]);
11
+ if (!pathTools.has(name)) return;
12
+ if (!workspacePathAllowed(root, cwd, ".")) return "Sandboxed worktree subagent working directory is outside its workspace.";
13
+ const input = args && typeof args === "object" ? args as Record<string, unknown> : {};
14
+ const validate = (value: unknown): string | undefined => {
15
+ if (typeof value !== "string" || !value || !workspacePathAllowed(root, cwd, value, name === "read")) {
16
+ return "Sandboxed worktree subagent cannot access outside its workspace or use an invalid path: " + String(value);
17
+ }
18
+ };
19
+ for (const key of ["path", "file_path", "cwd"]) if (input[key] !== undefined) {
20
+ const violation = validate(input[key]);
21
+ if (violation) return violation;
22
+ }
23
+ if (name === "multi_file_edit") {
24
+ if (!Array.isArray(input.edits) || !input.edits.length) return "multi_file_edit needs a non-empty edits array.";
25
+ for (const raw of input.edits) {
26
+ if (!raw || typeof raw !== "object") return "Invalid multi_file_edit entry.";
27
+ const entry = raw as Record<string, unknown>;
28
+ for (const key of ["path", "file_path", "cwd"]) if (entry[key] !== undefined) {
29
+ const violation = validate(entry[key]);
30
+ if (violation) return violation;
31
+ }
32
+ const violation = validate(entry.path ?? entry.file_path ?? input.path ?? input.file_path);
33
+ if (violation) return violation;
34
+ }
35
+ }
36
+ }
37
+
38
+ export function childToolAllowlist(value: string | undefined): Set<string> | undefined {
39
+ if (value === undefined) return;
40
+ try {
41
+ const parsed: unknown = JSON.parse(value);
42
+ if (!Array.isArray(parsed) || parsed.length > 64 || parsed.some(name => typeof name !== "string" || !name || RECURSIVE_TOOLS.has(name))) return new Set();
43
+ return new Set(parsed as string[]);
44
+ } catch { return new Set(); } // invalid capability data fails closed
45
+ }
package/src/working.ts CHANGED
@@ -41,14 +41,12 @@ export class WorkingState {
41
41
  prompt(open: boolean): void { this.waiting = open; }
42
42
  toolStart(id: string, name: string): void { this.tools.set(id, cleanText(name, 24) || "tool"); }
43
43
  toolEnd(id: string): void { this.tools.delete(id); }
44
- /** `task` is the running jar_todo's active form; like Claude, it replaces the generic wording. */
45
- view(animations: boolean, fg: (color: WorkingColor, text: string) => string, now = Date.now(), effort?: string, task?: string): WorkingView {
44
+ /** Keep pi-jar's light-inspired activity phrases; task titles belong in the task widget. */
45
+ view(animations: boolean, fg: (color: WorkingColor, text: string) => string, now = Date.now(), effort?: string, _task?: string): WorkingView {
46
46
  const phase = this.phase;
47
- const activity = task ? cleanText(task, 60).replace(/[.…]+$/, "") : "";
48
47
  const elapsed = this.startedAt === undefined ? 0 : Math.max(0, Math.floor((now - this.startedAt) / 1000));
49
48
  const stage = STAGES.reduce<number>((index, threshold, next) => elapsed >= threshold ? next : index, 0);
50
- const word = activity && (phase === "tool" || phase === "generating") ? activity + "…"
51
- : phase === "tool" ? `Tracing light through ${[...this.tools.values()][0] ?? "tool"}…`
49
+ const word = phase === "tool" ? `Tracing light through ${[...this.tools.values()][0] ?? "tool"}…`
52
50
  : phase === "generating" ? PROGRESSION[Math.max(0, stage)]
53
51
  : phase === "waiting" ? "Holding the lantern for your answer…" : undefined;
54
52
  const color: WorkingColor = phase === "tool" ? "warning" : phase === "waiting" ? "muted" : "accent";