pi-jar 0.1.1 → 0.1.3

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/shells.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Key, matchesKey, Text, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import { safeLine } from "./diff-view.ts";
6
6
  import { cleanText } from "./status.ts";
7
- import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
8
7
 
9
8
  export const SHELL_TOOL = "jar_shell";
10
9
  export const SHELL_MESSAGE = "pi-jar.shell";
11
10
  export const MAX_RUNNING_SHELLS = 8;
12
11
  const MAX_KEPT_SHELLS = 20;
13
12
  const MAX_LINES = 2000;
13
+ const LINE_PRUNE_BATCH = 128;
14
14
  const MAX_LINE_CHARS = 2000;
15
+ const MAX_RETAINED_CHARS = 1024 * 1024;
16
+ const MAX_TOOL_OUTPUT_CHARS = 32 * 1024;
15
17
  const KILL_GRACE_MS = 3000;
16
18
 
17
19
  export type ShellStatus = "running" | "exited" | "killed" | "failed";
@@ -44,14 +46,25 @@ const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
44
46
  export class ShellManager {
45
47
  private jobs = new Map<string, { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> }>();
46
48
  private next = 1;
49
+ private lineChars = new Map<string, number>();
47
50
  private readonly onEvent: (event: ShellEvent) => void;
48
51
  private readonly spawnShell: typeof spawn;
49
52
  onChange?: () => void;
50
53
  constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn) { this.onEvent = onEvent; this.spawnShell = spawnShell; }
51
54
 
52
55
  list(): ShellJob[] { return [...this.jobs.values()].map(({ job }) => ({ ...job, lines: [...job.lines] })); }
56
+ summaries(): Array<Omit<ShellJob, "lines">> {
57
+ return [...this.jobs.values()].map(({ job }) => {
58
+ const { lines: _lines, ...summary } = job;
59
+ return { ...summary };
60
+ });
61
+ }
53
62
  get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
54
- running(): number { return [...this.jobs.values()].filter(({ job }) => job.status === "running").length; }
63
+ running(): number {
64
+ let count = 0;
65
+ for (const { job } of this.jobs.values()) if (job.status === "running") count++;
66
+ return count;
67
+ }
55
68
 
56
69
  start(options: StartOptions): ShellJob {
57
70
  const command = options.command.trim();
@@ -68,6 +81,7 @@ export class ShellManager {
68
81
  notify: options.notify ?? true, lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
69
82
  const entry: { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> } = { job, partial: "", ...(pattern ? { pattern } : {}) };
70
83
  this.jobs.set(id, entry);
84
+ this.lineChars.set(id, 0);
71
85
  // Own process group so kill() stops the whole tree (dev servers spawn children).
72
86
  const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
73
87
  entry.child = child;
@@ -105,16 +119,26 @@ export class ShellManager {
105
119
  const line = raw.replace(ANSI, "").replace(/\r/g, "").slice(0, MAX_LINE_CHARS);
106
120
  const { job } = entry;
107
121
  job.lines.push(line);
108
- if (job.lines.length > MAX_LINES) { job.lines.splice(0, job.lines.length - MAX_LINES); job.dropped++; }
122
+ this.lineChars.set(job.id, (this.lineChars.get(job.id) ?? 0) + line.length);
123
+ // Bound both line count and retained characters. A shell that prints very wide lines should
124
+ // not quietly reserve several MiB forever just because it has not reached MAX_LINES yet.
125
+ while (job.lines.length > MAX_LINES + LINE_PRUNE_BATCH || (this.lineChars.get(job.id) ?? 0) > MAX_RETAINED_CHARS) {
126
+ const removed = job.lines.splice(0, Math.min(LINE_PRUNE_BATCH, job.lines.length));
127
+ let removedChars = 0;
128
+ for (const item of removed) removedChars += item.length;
129
+ this.lineChars.set(job.id, Math.max(0, (this.lineChars.get(job.id) ?? 0) - removedChars));
130
+ job.dropped += removed.length;
131
+ if (!removed.length) break;
132
+ }
109
133
  if (entry.pattern && !job.matched && entry.pattern.test(line)) {
110
134
  job.matched = line;
111
- this.onEvent({ kind: "match", job: { ...job, lines: [...job.lines] } });
135
+ this.onEvent({ kind: "match", job: { ...job, lines: job.lines.slice(-20) } });
112
136
  }
113
137
  }
114
138
 
115
139
  private finish(entry: { job: ShellJob; timer?: ReturnType<typeof setTimeout> }): void {
116
140
  if (entry.timer) clearTimeout(entry.timer);
117
- this.onEvent({ kind: "exit", job: { ...entry.job, lines: [...entry.job.lines] } });
141
+ this.onEvent({ kind: "exit", job: { ...entry.job, lines: entry.job.lines.slice(-20) } });
118
142
  this.onChange?.();
119
143
  }
120
144
 
@@ -122,8 +146,10 @@ export class ShellManager {
122
146
  output(id: string, count = 40): string[] {
123
147
  const entry = this.jobs.get(id);
124
148
  if (!entry) throw new Error("no shell " + id);
125
- const lines = [...entry.job.lines, ...(entry.partial ? [entry.partial] : [])];
126
- return lines.slice(-Math.max(1, Math.min(400, Math.floor(count))));
149
+ const limit = Math.max(1, Math.min(400, Math.floor(count)));
150
+ if (!entry.partial) return entry.job.lines.slice(-limit);
151
+ if (limit === 1) return [entry.partial];
152
+ return [...entry.job.lines.slice(-(limit - 1)), entry.partial];
127
153
  }
128
154
 
129
155
  kill(id: string): boolean {
@@ -147,22 +173,42 @@ export class ShellManager {
147
173
  /** Forget finished jobs beyond the retention limit (oldest first). */
148
174
  private prune(): void {
149
175
  const finished = [...this.jobs.values()].filter(({ job }) => job.status !== "running");
150
- for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) this.jobs.delete(job.id);
176
+ for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) {
177
+ this.jobs.delete(job.id);
178
+ this.lineChars.delete(job.id);
179
+ }
151
180
  }
152
181
 
153
182
  dispose(): void {
154
183
  for (const id of this.jobs.keys()) { try { this.kill(id); } catch { /* already gone */ } }
155
184
  this.jobs.clear();
185
+ this.lineChars.clear();
156
186
  }
157
187
  }
158
188
 
159
- const elapsed = (job: ShellJob, now = Date.now()) => {
160
- const seconds = Math.max(0, Math.round(((job.endedAt ?? now) - job.startedAt) / 1000));
189
+ type ShellMeta = Omit<ShellJob, "lines">;
190
+ /** `12s` / `3m 4s` since `startedAt`, up to `endedAt` once finished. */
191
+ export const elapsed = (span: { startedAt: number; endedAt?: number }, now = Date.now()) => {
192
+ const seconds = Math.max(0, Math.round(((span.endedAt ?? now) - span.startedAt) / 1000));
161
193
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
162
194
  };
163
- export const shellState = (job: ShellJob) => job.status === "running" ? "running"
195
+ export const shellState = (job: ShellMeta | ShellJob) => job.status === "running" ? "running"
164
196
  : job.status === "exited" ? `exited ${job.exitCode ?? "?"}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
165
- const describe = (job: ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
197
+ export const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
198
+ const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): string => {
199
+ let size = 0;
200
+ const kept: string[] = [];
201
+ for (let index = lines.length - 1; index >= 0; index--) {
202
+ const line = lines[index]!;
203
+ const cost = line.length + (kept.length ? 1 : 0);
204
+ if (size + cost > limit) break;
205
+ kept.push(line);
206
+ size += cost;
207
+ }
208
+ kept.reverse();
209
+ const omitted = lines.length - kept.length;
210
+ return (omitted ? `… ${omitted} earlier line(s) omitted\n` : "") + kept.join("\n");
211
+ };
166
212
 
167
213
  /** The message that wakes the agent when a watched shell matches or ends. */
168
214
  export function shellEventMessage(event: ShellEvent): string {
@@ -198,7 +244,9 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
198
244
  parameters: Parameters,
199
245
  async execute(_id, params, _signal, _update, ctx) {
200
246
  const manager = shells();
201
- const reply = (text: string, jobs: ShellJob[] = manager?.list() ?? []) => ({ content: [{ type: "text" as const, text }], details: { jobs } });
247
+ const reply = (text: string) => ({ content: [{ type: "text" as const, text: text.slice(0, MAX_TOOL_OUTPUT_CHARS) }], details: {
248
+ jobs: (manager?.summaries() ?? []).map((job) => ({ id: job.id, name: job.name, status: job.status, pid: job.pid, exitCode: job.exitCode }))
249
+ } });
202
250
  if (!manager) return reply("Background shells are unavailable before a Pi session starts.");
203
251
  try {
204
252
  switch (params.action) {
@@ -210,14 +258,14 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
210
258
  case "output": {
211
259
  if (!params.id) return reply("output needs an id.");
212
260
  const lines = manager.output(params.id, params.lines ?? 40);
213
- return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? lines.join("\n") : "(no output yet)"}`);
261
+ return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? boundedTail(lines) : "(no output yet)"}`);
214
262
  }
215
263
  case "kill":
216
264
  if (!params.id) return reply("kill needs an id.");
217
265
  return reply(manager.kill(params.id) ? `Stopping ${params.id}.` : `${params.id} is not running.`);
218
266
  default: {
219
- const jobs = manager.list();
220
- return reply(jobs.length ? jobs.map(describe).join("\n") : "No background shells.", jobs);
267
+ const jobs = manager.summaries();
268
+ return reply(jobs.length ? jobs.map(describe).join("\n") : "No background shells.");
221
269
  }
222
270
  }
223
271
  } catch (error) {
@@ -232,88 +280,18 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
232
280
  return new Text(text, 0, 0);
233
281
  },
234
282
  renderResult(result, { expanded }, theme) {
235
- const text = result.content.map((part) => part.type === "text" ? part.text : "").join("\n");
236
- const lines = text.split("\n");
237
- const shown = expanded ? lines : lines.slice(0, 6);
283
+ const text = result.content.find((part) => part.type === "text")?.text ?? "";
284
+ if (expanded) {
285
+ const lines = text.split("\n");
286
+ return new Text(lines.map((line, index) => theme.fg(index ? "muted" : "accent", safeLine(line))).join("\n"), 0, 0);
287
+ }
288
+ // Historical shell cards are rebuilt on resume. Split only enough text to draw the collapsed
289
+ // card instead of counting every stored output line.
290
+ const sample = text.split("\n", 7);
291
+ const more = sample.length > 6;
292
+ const shown = sample.slice(0, 6);
238
293
  return new Text(shown.map((line, index) => theme.fg(index ? "muted" : "accent", safeLine(line))).join("\n")
239
- + (shown.length < lines.length ? "\n" + theme.fg("dim", `… +${lines.length - shown.length} lines (expand)`) : ""), 0, 0);
294
+ + (more ? "\n" + theme.fg("dim", "… more lines (expand)") : ""), 0, 0);
240
295
  }
241
296
  });
242
297
  }
243
-
244
- const SHELL_ACTIONS = [
245
- { key: "x", label: "Kill the selected shell" },
246
- { key: "f", label: "Follow the latest output" }
247
- ] as const;
248
-
249
- /** Overlay: shells on the left, the selected shell's live output on the right; k kills. */
250
- export async function openShellsView(ctx: ExtensionContext, manager: ShellManager): Promise<void> {
251
- if (!ctx.hasUI || ctx.mode !== "tui") return;
252
- if (!manager.list().length) { ctx.ui.notify("pi-jar: no background shells (the agent starts them with jar_shell)", "info"); return; }
253
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
254
- let selected = 0;
255
- let follow = true;
256
- let scroll = 0;
257
- let rows = 0;
258
- let width = 80;
259
- let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
260
- const fg = (color: string, text: string) => theme.fg(color as never, text);
261
- const previous = manager.onChange;
262
- const timer = setInterval(() => tui.requestRender(), 500);
263
- timer.unref?.();
264
- manager.onChange = () => { previous?.(); tui.requestRender(); };
265
- const close = () => { clearInterval(timer); manager.onChange = previous; done(); };
266
- const component = {
267
- invalidate() {},
268
- handleInput(data: string) {
269
- const jobs = manager.list();
270
- if (matchesKey(data, Key.escape) || data === "q") return close();
271
- if (matchesKey(data, Key.up) || data === "k") selected = Math.max(0, selected - 1);
272
- else if (matchesKey(data, Key.down) || data === "j") selected = Math.min(jobs.length - 1, selected + 1);
273
- else if (data === "x" || data === "K") { const job = jobs[selected]; if (job) { try { manager.kill(job.id); } catch (error) { ctx.ui.notify("pi-jar: " + String(error), "error"); } } }
274
- else if (matchesKey(data, Key.pageUp)) { follow = false; scroll = Math.max(0, scroll - Math.max(1, rows - 2)); }
275
- else if (matchesKey(data, Key.pageDown)) scroll += Math.max(1, rows - 2);
276
- else if (data === "f" || data === "G") follow = true;
277
- tui.requestRender();
278
- },
279
- handleMouse(event: TuiMouseEvent) {
280
- const row = event.y - layout.top;
281
- if (event.type === "wheel" && event.wheelDelta) { follow = false; scroll = Math.max(0, scroll + Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
282
- if (event.type !== "click" || event.button !== "left") return;
283
- if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
284
- if (row >= 0 && row < layout.rows && layout.leftWidth && event.x < layout.leftWidth + 3 && row < manager.list().length) { selected = row; follow = true; tui.requestRender(); return { handled: true, focus: true }; }
285
- const action = SHELL_ACTIONS[event.y - layout.footerTop];
286
- if (action) { component.handleInput(action.key); return { handled: true }; }
287
- },
288
- render(available: number): string[] {
289
- width = Math.max(24, available);
290
- rows = contentRows(6 + SHELL_ACTIONS.length, 6);
291
- const jobs = manager.list();
292
- selected = Math.min(selected, Math.max(0, jobs.length - 1));
293
- const job = jobs[selected];
294
- const listWidth = sidebarWidth(width, 20, 34);
295
- const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
296
- const output = job ? manager.output(job.id, 400) : [];
297
- const maxScroll = Math.max(0, output.length - rows);
298
- scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
299
- if (scroll >= maxScroll) follow = true;
300
- const list = jobs.slice(0, rows).map((item, index) => {
301
- const color = item.status === "running" ? "accent" : item.status === "exited" && item.exitCode === 0 ? "success" : item.status === "killed" ? "dim" : "error";
302
- const glyph = item.status === "running" ? "●" : item.status === "exited" && item.exitCode === 0 ? "✔" : item.status === "killed" ? "■" : "✖";
303
- return fg(index === selected ? "accent" : "muted", (index === selected ? "▌" : " ")) + fg(color, glyph + " ") + fg(index === selected ? "accent" : "muted", truncateToWidth(`${item.id} ${item.name}`, Math.max(4, listWidth - 4)));
304
- });
305
- const body = output.slice(scroll, scroll + rows).map((line) => fg("muted", truncateToWidth(safeLine(line), bodyWidth)));
306
- if (job && !output.length) body.push(fg("dim", "(no output yet)"));
307
- const title = `⚙ SHELLS · ${manager.running()} running` + (job ? ` · ${describe(job)}` : "");
308
- const footer = [
309
- ...optionList(theme, SHELL_ACTIONS.map((item) => item.label), -1, SHELL_ACTIONS.map((item) => item.key)),
310
- fg("dim", (job ? "$ " + truncateToWidth(cleanText(job.command, 400), Math.max(8, width - 60)) + " " : "") + "↑↓ shell · PgUp/PgDn scroll · Esc close")
311
- ];
312
- const split = splitFrame(theme, width, title, list, body, footer, rows, listWidth);
313
- layout = split.layout;
314
- return split.lines;
315
- }
316
- };
317
- return component;
318
- }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
319
- }
package/src/side-model.ts CHANGED
@@ -16,7 +16,10 @@ export interface SideCall {
16
16
  /** Session-scoped record of side calls, so /usage can show what they cost. */
17
17
  export class SideUsage {
18
18
  private calls: SideCall[] = [];
19
- add(call: SideCall): void { this.calls.push(call); if (this.calls.length > 500) this.calls.shift(); }
19
+ add(call: SideCall): void {
20
+ this.calls.push(call);
21
+ if (this.calls.length > 512) this.calls.splice(0, 64);
22
+ }
20
23
  all(): readonly SideCall[] { return this.calls; }
21
24
  clear(): void { this.calls = []; }
22
25
  }
@@ -42,7 +45,16 @@ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, us
42
45
  usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
43
46
  cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
44
47
  if (result.stopReason === "error" || result.stopReason === "aborted") throw new Error(result.errorMessage || `${name} ${result.stopReason}`);
45
- const text = result.content.filter((part) => part.type === "text").map((part) => (part as { text: string }).text).join("").trim();
48
+ const MAX_ANSWER_CHARS = 32 * 1024;
49
+ let text = "";
50
+ for (const part of result.content) {
51
+ if (part.type !== "text") continue;
52
+ const value = (part as { text: string }).text;
53
+ const room = MAX_ANSWER_CHARS - text.length;
54
+ if (room <= 0) break;
55
+ text += value.slice(0, room);
56
+ }
57
+ text = text.trim();
46
58
  if (!text) throw new Error(`${name} returned an empty answer`);
47
59
  return { text, model: name };
48
60
  }
package/src/status.ts CHANGED
@@ -24,6 +24,36 @@ export interface JarStatus {
24
24
  extras: string[];
25
25
  }
26
26
 
27
+ const ROLE_CACHE_LIMIT = 64;
28
+ const roleCache = new Map<string, JarRole | null>();
29
+
30
+ function cachedRole(id: string, rawValue: string): JarRole | undefined {
31
+ const cacheKey = id + "\0" + rawValue;
32
+ if (roleCache.has(cacheKey)) return roleCache.get(cacheKey) ?? undefined;
33
+ let parsed: JarRole | undefined;
34
+ try {
35
+ if (rawValue.length > 4096) throw new Error("oversized status");
36
+ const data: unknown = JSON.parse(rawValue);
37
+ if (!data || typeof data !== "object") throw new Error("invalid status");
38
+ const role = data as Record<string, unknown>;
39
+ if (typeof role.name !== "string" || typeof role.state !== "string" || !STATES.has(role.state as RoleState)) throw new Error("invalid role");
40
+ const expiresAt = role.expiresAt;
41
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) throw new Error("invalid expiry");
42
+ const name = cleanText(role.name, 32) || id;
43
+ parsed = {
44
+ id,
45
+ name,
46
+ label: cleanText(typeof role.label === "string" ? role.label : name, 12) || id,
47
+ state: role.state as RoleState,
48
+ task: typeof role.task === "string" ? cleanText(role.task, 48) : undefined,
49
+ expiresAt
50
+ };
51
+ } catch { parsed = undefined; }
52
+ roleCache.set(cacheKey, parsed ?? null);
53
+ if (roleCache.size > ROLE_CACHE_LIMIT) roleCache.delete(roleCache.keys().next().value!);
54
+ return parsed;
55
+ }
56
+
27
57
  /** Explicit opt-in contract; never guess teammate state from arbitrary status prose. */
28
58
  export function collectStatuses(statuses: ReadonlyMap<string, string>, now: number): JarStatus {
29
59
  const roles: JarRole[] = [];
@@ -32,6 +62,8 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
32
62
  const key = cleanText(rawKey, 48);
33
63
  if (!key || typeof rawValue !== "string") continue;
34
64
  if (rawKey.startsWith("pi-jar.quota.")) continue;
65
+ // SoL-Pi already announces each saving in its own notification; reprinting it here duplicates it.
66
+ if (rawKey.startsWith("sol-pi")) continue;
35
67
  if (!rawKey.startsWith(ROLE_PREFIX)) {
36
68
  const value = cleanText(rawValue);
37
69
  // pi-jar's own indicators (such as `role:default`) are already self-describing.
@@ -40,32 +72,12 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
40
72
  }
41
73
  const id = rawKey.slice(ROLE_PREFIX.length);
42
74
  if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) continue;
43
- try {
44
- if (rawValue.length > 4096) throw new Error("oversized status");
45
- const data: unknown = JSON.parse(rawValue);
46
- if (!data || typeof data !== "object") throw new Error("invalid status");
47
- const role = data as Record<string, unknown>;
48
- if (typeof role.name !== "string" || typeof role.state !== "string" || !STATES.has(role.state as RoleState)) {
49
- throw new Error("invalid role");
50
- }
51
- const state = role.state as RoleState;
52
- const expiresAt = role.expiresAt;
53
- if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= now || expiresAt > now + ROLE_TTL_MS) {
54
- extras.push(`${id}: unavailable`);
55
- continue;
56
- }
57
- const name = cleanText(role.name, 32) || id;
58
- roles.push({
59
- id,
60
- name,
61
- label: cleanText(typeof role.label === "string" ? role.label : name, 12) || id,
62
- state,
63
- task: typeof role.task === "string" ? cleanText(role.task, 48) : undefined,
64
- expiresAt: typeof expiresAt === "number" ? expiresAt : undefined
65
- });
66
- } catch {
75
+ const role = cachedRole(id, rawValue);
76
+ if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ROLE_TTL_MS) {
67
77
  extras.push(`${id}: unavailable`);
78
+ continue;
68
79
  }
80
+ roles.push(role);
69
81
  }
70
82
  return { roles: roles.slice(0, 16), extras: extras.slice(0, 16) };
71
83
  }
package/src/suggest.ts CHANGED
@@ -4,7 +4,6 @@ import { Type } from "typebox";
4
4
  import { cleanText } from "./status.ts";
5
5
 
6
6
  export const SUGGEST_TOOL = "jar_suggest";
7
- const REMINDER_TYPE = "pi-jar.suggest-reminder";
8
7
  const MAX_SUGGESTION = 160;
9
8
 
10
9
  /** The next-prompt suggestion shown as ghost text in an empty composer. Session-only, never persisted. */
@@ -25,27 +24,22 @@ export class SuggestionState {
25
24
 
26
25
  export interface SuggestionOptions {
27
26
  enabled: () => boolean;
28
- /** Skip enforcement while another workflow owns the next step (plan review, goal loop). */
29
- skip: () => boolean;
30
27
  }
31
28
 
32
29
  /** Ask the agent for one next-prompt suggestion per finished request, like an inline autocomplete. */
33
30
  export function registerSuggestions(pi: ExtensionAPI, state: SuggestionState, options: SuggestionOptions): { sync(): void } {
34
- let suggested = false;
35
- let reminded = false;
36
31
  pi.registerTool?.({
37
32
  name: SUGGEST_TOOL,
38
33
  label: "suggest",
39
- description: "Offer the user one short next prompt. It appears as dimmed ghost text in their input box; Tab accepts it for editing. Call it once, last, when you finish a request.",
40
- promptSnippet: "Finish each completed request by calling jar_suggest with the most useful next prompt for the user.",
34
+ description: "Optionally offer one useful next prompt as ghost text in the input box; Tab accepts it for editing.",
35
+ promptSnippet: "Optionally use jar_suggest at the end when one genuinely useful next prompt would help the user continue.",
41
36
  promptGuidelines: [
42
- "When you finish the user's request, call jar_suggest exactly once as your final action with the single most useful next prompt the user could send (imperative, one line, under 120 characters, written as the user would type it, e.g. \"Run the full test suite\").",
43
- "Do not mention jar_suggest or the suggestion in your reply, and do not call it mid-task or while waiting for an answer."
37
+ "jar_suggest is best-effort, not mandatory. Use it only when there is one clear next action worth surfacing; skip it for routine completions.",
38
+ "When used, call it once as the final action with one imperative line under 120 characters. Do not mention jar_suggest in your reply."
44
39
  ],
45
40
  parameters: Type.Object({ suggestion: Type.String({ description: "The user's likely next prompt, one line." }) }),
46
41
  execute: async (_id, params) => {
47
42
  const ok = state.set(params.suggestion ?? "");
48
- suggested ||= ok;
49
43
  return { content: [{ type: "text", text: ok ? "Suggestion shown to the user. End your turn now." : "Suggestion was empty; skipped." }], details: { suggestion: state.text }, terminate: true };
50
44
  },
51
45
  renderCall() { return new Text("", 0, 0); },
@@ -66,24 +60,11 @@ export function registerSuggestions(pi: ExtensionAPI, state: SuggestionState, op
66
60
 
67
61
  pi.on("input", (event) => {
68
62
  if (event.source !== "interactive") return;
69
- suggested = false;
70
- reminded = false;
71
63
  state.clear();
72
64
  });
73
65
  pi.on("agent_start", () => { state.clear(); });
74
- pi.on("session_start", () => { suggested = false; reminded = false; state.clear(); });
66
+ pi.on("session_start", () => { state.clear(); });
75
67
  pi.on("session_tree", () => { state.clear(); });
76
- pi.on("context", async (event) => ({ messages: event.messages.filter((raw) => (raw as { customType?: string }).customType !== REMINDER_TYPE) }));
77
-
78
- pi.on("agent_before_settle", (event) => {
79
- if (!options.enabled() || options.skip() || event.continue || event.outcome !== "completed" || suggested || reminded) return;
80
- if (typeof pi.getActiveTools === "function" && !pi.getActiveTools().includes(SUGGEST_TOOL)) return;
81
- reminded = true;
82
- return {
83
- entries: [{ type: "custom_message", customType: REMINDER_TYPE, display: false,
84
- content: "[PI-JAR] Call jar_suggest now with the single most useful next prompt for the user. Do not add any other text." }],
85
- continue: true
86
- };
87
- });
68
+ // No settle hook: suggestions must not add a provider turn or a hidden session entry.
88
69
  return { sync };
89
70
  }
package/src/task-tool.ts CHANGED
@@ -22,14 +22,27 @@ const Parameters = Type.Object({
22
22
 
23
23
  interface TaskToolDetails {
24
24
  action: string;
25
+ /** Bounded preview only; the canonical list lives in TodoStore. */
25
26
  items: Todo[];
27
+ total: number;
28
+ done: number;
29
+ current?: string;
26
30
  changed?: string;
31
+ truncated?: boolean;
27
32
  }
28
33
 
34
+ const stats = (items: readonly Todo[]) => {
35
+ let done = 0;
36
+ let current: Todo | undefined;
37
+ for (const item of items) {
38
+ if (item.done) done++;
39
+ if (!current && item.status === "in_progress") current = item;
40
+ }
41
+ return { total: items.length, done, current };
42
+ };
29
43
  const summary = (items: readonly Todo[]) => {
30
- const done = items.filter((item) => item.done).length;
31
- const current = items.find((item) => item.status === "in_progress");
32
- return `${done}/${items.length} done` + (current ? ` · now: ${current.title}` : "");
44
+ const value = stats(items);
45
+ return `${value.done}/${value.total} done` + (value.current ? ` · now: ${value.current.title}` : "");
33
46
  };
34
47
 
35
48
  const lines = (items: readonly Todo[]) => items.length
@@ -60,7 +73,7 @@ export function registerTaskTool(
60
73
  const current = store();
61
74
  const action = params.todos ? "write" : params.action ?? "list";
62
75
  if (!current) {
63
- return { content: [{ type: "text", text: "Task tracking is unavailable before a Pi session starts." }], details: { action, items: [] } satisfies TaskToolDetails };
76
+ return { content: [{ type: "text", text: "Task tracking is unavailable before a Pi session starts." }], details: { action, items: [], total: 0, done: 0 } satisfies TaskToolDetails };
64
77
  }
65
78
  let changedId: string | undefined;
66
79
  let error: string | undefined;
@@ -94,12 +107,22 @@ export function registerTaskTool(
94
107
  }
95
108
  if (action !== "list" && !error) changed(ctx);
96
109
  const items = current.all();
110
+ const state = stats(items);
111
+ const changedItem = changedId ? items.find((item) => item.id === changedId) : undefined;
97
112
  const message = error
98
- ? `Could not ${action} tasks: ${error}\n${lines(items)}`
99
- : `${action === "list" ? "Tasks" : "Task list updated"} (${summary(items)}):\n${lines(items)}${action === "list" ? "" : "\n" + REMINDER}`;
113
+ ? `Could not ${action} tasks: ${error} (${summary(items)}).`
114
+ : action === "list"
115
+ ? `Tasks (${summary(items)}):\n${lines(items)}`
116
+ : `Task list updated (${summary(items)}).${changedItem ? `\n${todoMark(changedItem)} [${changedItem.status}] ${changedItem.title} (${changedItem.id})` : ""}\n${REMINDER}`;
117
+ const preview = items.slice(0, 8);
100
118
  return {
101
119
  content: [{ type: "text", text: message }],
102
- details: { action, items, ...(changedId ? { changed: changedId } : {}) } satisfies TaskToolDetails
120
+ details: {
121
+ action, items: preview, total: state.total, done: state.done,
122
+ ...(state.current ? { current: state.current.title } : {}),
123
+ ...(preview.length < items.length ? { truncated: true } : {}),
124
+ ...(changedId ? { changed: changedId } : {})
125
+ } satisfies TaskToolDetails
103
126
  };
104
127
  },
105
128
  renderCall(args, theme) {
@@ -115,12 +138,13 @@ export function registerTaskTool(
115
138
  if (isPartial) return new Text(theme.fg("warning", "Updating tasks…"), 0, 0);
116
139
  const details = result.details as TaskToolDetails | undefined;
117
140
  const items = details?.items ?? [];
118
- if (!items.length) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
119
- // Like Claude: the checklist itself is the result. Collapsed shows up to 8 rows.
141
+ const total = details?.total ?? items.length;
142
+ const done = details?.done ?? items.filter((item) => item.done).length;
143
+ if (!total) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
120
144
  const shown = expanded ? items : items.slice(0, 8);
121
- let text = theme.fg("dim", summary(items));
145
+ let text = theme.fg("dim", `${done}/${total} done${details?.current ? " · now: " + details.current : ""}`);
122
146
  for (const item of shown) text += "\n" + todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value));
123
- if (shown.length < items.length) text += "\n" + theme.fg("dim", ` … +${items.length - shown.length} more (expand)`);
147
+ if (details?.truncated || shown.length < total) text += "\n" + theme.fg("dim", ` … +${Math.max(0, total - shown.length)} more · /jar tasks`);
124
148
  return new Text(text, 0, 0);
125
149
  }
126
150
  });
package/src/tasks.ts CHANGED
@@ -22,6 +22,18 @@ export type TodoEvent =
22
22
  const validId = (id: unknown): id is string => typeof id === "string" && /^[a-zA-Z0-9-]{1,64}$/.test(id);
23
23
  const validTitle = (title: unknown): title is string => typeof title === "string" && title.length <= 256 && !!cleanText(title, 120);
24
24
  const validStatus = (status: unknown): status is TodoStatus => TODO_STATUSES.includes(status as TodoStatus);
25
+ const validWrite = (event: Record<string, unknown>): boolean => {
26
+ if (event.v !== 1 || event.op !== "write" || !Array.isArray(event.items) || event.items.length > MAX_TODOS) return false;
27
+ const ids = new Set<string>();
28
+ let running = 0;
29
+ for (const raw of event.items as unknown[]) {
30
+ const item = raw as Record<string, unknown> | null;
31
+ if (!item || !validId(item.id) || !validTitle(item.title) || !validStatus(item.status) || ids.has(item.id)) return false;
32
+ ids.add(item.id);
33
+ if (item.status === "in_progress" && ++running > 1) return false;
34
+ }
35
+ return true;
36
+ };
25
37
  const make = (id: string, title: string, status: TodoStatus, activeForm?: unknown, details?: unknown): Todo => ({
26
38
  id, title: cleanText(title, 120), status, done: status === "completed",
27
39
  ...(typeof activeForm === "string" && cleanText(activeForm, 80) ? { activeForm: cleanText(activeForm, 80) } : {}),
@@ -50,14 +62,12 @@ export class TodoStore {
50
62
  const event = raw as Record<string, unknown>;
51
63
  if (event.v !== 1) return false;
52
64
  if (event.op === "write") {
53
- if (!Array.isArray(event.items) || event.items.length > MAX_TODOS) return false;
65
+ if (!validWrite(event)) return false;
54
66
  const next = new Map<string, Todo>();
55
67
  for (const raw of event.items as unknown[]) {
56
- const item = raw as Record<string, unknown> | null;
57
- if (!item || !validId(item.id) || !validTitle(item.title) || !validStatus(item.status) || next.has(item.id)) return false;
58
- next.set(item.id, make(item.id, item.title, item.status, item.activeForm, this.items.get(item.id)?.details));
68
+ const item = raw as Record<string, unknown>;
69
+ next.set(item.id as string, make(item.id as string, item.title as string, item.status as TodoStatus, item.activeForm, this.items.get(item.id as string)?.details));
59
70
  }
60
- if ([...next.values()].filter((item) => item.status === "in_progress").length > 1) return false;
61
71
  this.items = next;
62
72
  return true;
63
73
  }
@@ -86,11 +96,18 @@ export class TodoStore {
86
96
  }
87
97
  restore(branch: readonly unknown[]): void {
88
98
  this.items.clear();
89
- for (const raw of branch) {
99
+ // Walk the transcript once from newest to oldest, collecting only task events. A valid full
100
+ // write is a checkpoint, so everything before it is irrelevant.
101
+ const replay: unknown[] = [];
102
+ for (let index = branch.length - 1; index >= 0; index--) {
103
+ const raw = branch[index];
90
104
  if (!raw || typeof raw !== "object") continue;
91
105
  const entry = raw as Record<string, unknown>;
92
- if (entry.type === "custom" && entry.customType === TASK_ENTRY) this.apply(entry.data);
106
+ if (entry.type !== "custom" || entry.customType !== TASK_ENTRY) continue;
107
+ replay.push(entry.data);
108
+ if (entry.data && typeof entry.data === "object" && validWrite(entry.data as Record<string, unknown>)) break;
93
109
  }
110
+ for (let index = replay.length - 1; index >= 0; index--) this.apply(replay[index]);
94
111
  }
95
112
  private commit(event: TodoEvent): boolean {
96
113
  const before = new Map([...this.items].map(([id, item]) => [id, { ...item }]));
package/src/usage.ts CHANGED
@@ -1,9 +1,10 @@
1
+ import { sessionBranch } from "./session-branch.ts";
1
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
 
3
4
  /** Sum the active session branch, not all historical branches, using Pi's reported monetary cost. */
4
5
  export function sessionCost(ctx: Pick<ExtensionContext, "sessionManager">): number {
5
6
  let total = 0;
6
- for (const entry of ctx.sessionManager.getBranch()) {
7
+ for (const entry of sessionBranch(ctx) as ReturnType<typeof ctx.sessionManager.getBranch>) {
7
8
  if (entry.type !== "message" || entry.message.role !== "assistant") continue;
8
9
  const value = entry.message.usage.cost.total;
9
10
  if (Number.isFinite(value) && value >= 0) total += value;