pi-jar 0.1.7 → 0.1.9

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.
@@ -21,4 +21,6 @@ Subagents use Pi's standard `--tools` allowlist. Each `jar_delegate` task can cu
21
21
  - `jar_delegate`, `jar_subagent`, and `jar_democracy` are always removed from inherited tools and rejected in explicit lists. A child cannot recursively spawn more subagents.
22
22
  - Tool capabilities are per task; a batch can mix modes and allowlists. A fork still inherits the parent's conversation context, independently of its tool list.
23
23
 
24
+ Retained scout/fork/worktree children hibernate after 30 seconds idle: their Pi process exits, but the private child session and worktree remain for `jar_subagent resume` or `ask`. Hibernated records count against Max subagents. Unresolved worktree recovery records do not occupy a pool slot, but at most four active/recovery worktrees are admitted. `jar_subagent stop` retries reconciliation; `discard` explicitly removes an unresolved recovery workspace without applying it. On session shutdown pi-jar reports paths for worktrees it preserves on disk, including still-active worktrees that might contain edits.
25
+
24
26
  If configurable tools are unavailable on a Pi host, use mode defaults or upgrade to a host exposing its active tool list. Avoid inheriting every tool for convenience; grant the narrowest capability needed for the assignment.
package/docs/WORKFLOWS.md CHANGED
@@ -120,7 +120,7 @@ Manual choices win: a model or effort you select yourself (model picker, cycling
120
120
  ## Change review
121
121
 
122
122
  - A `tool_call` hook records a file's content right before the agent's **first** `edit` or `write` to it (project files only, up to 200 files, 1 MB each, text only). Later edits keep that baseline, so the review always shows everything since the agent started on the file.
123
- - `/diff` (or <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd>) compares the baseline with the file on disk using a line diff with 3 lines of context. Files that are back to their baseline drop out.
123
+ - `/diff` (or <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd>) compares the baseline with the file on disk using a line diff with 3 lines of context by default (`←`/`→` change it, up to the whole file). Files that are back to their baseline drop out.
124
124
  - **Accept** forgets the baseline. **Revert** writes the baseline back (or removes a file the agent created) after a second key press to confirm. Errors are reported and the file stays listed.
125
125
  - Baselines live in memory for the session; changes made through `bash` or by you are not tracked.
126
126
  - **Subagent edits** join the same review in two ways. Retained `worktree` writers remain isolated while working, idle or paused. Only `jar_subagent stop` finalizes them: pi-jar verifies that each parent target still equals the private baseline snapshot, copies reviewable safe results back, then captures/marks those parent files in the normal `ChangeTracker`. The deprecated one-shot `write: true` shared-workspace mode keeps its older `PI_JAR_BASELINE_DIR` bridge for compatibility.
@@ -146,7 +146,7 @@ watch matches or process exits → pi-jar.shell message → agent wakes (o
146
146
  - The moderator never has to poll: when a turn started by `jar_subagent resume` ends, pi-jar coalesces the settled agents into one visible `pi-jar.subagent` message (their `peek` progress plus the turn's report, clipped to 1200 characters) that triggers or queues a moderator turn. `pause`, `ask` and `stop` cancel that wake-up because the moderator is handling the agent directly.
147
147
  - Nothing in the moderator path waits unbounded. `jar_subagent` returns as soon as the tool call is aborted (the child keeps its state); `pause` reports "not settled yet" after 15 s if the child ignores abort and still pauses when it settles; a working child that sends no RPC event for 10 minutes is killed as hung. After a child exits, its stdio gets 2 s to drain: a grandchild (MCP server, shell) that inherited the pipes cannot keep `stop` or session shutdown waiting for `close`.
148
148
  - Worktree setup snapshots the exact starting workspace: `HEAD` plus tracked dirty changes and non-ignored untracked files are committed only inside the detached worktree. The worktree persists across idle/pause/resume. On stop, the parent target must still byte/mode-match the snapshot and the change must be trackable by `/diff`; otherwise nothing is overwritten and the workspace/error is reported. Conflicted workspaces survive report-history trimming; after resolving parent drift, retry `stop`. Finalization waits for RPC stdio closure. Snapshot blobs preserve filesystem bytes without executing clean/smudge filters or hooks. The path guard handles Pi path normalization, rejects lexical/symlink/dangling-link escapes and Git control metadata, checks read-tool filename fallbacks (NFD, curly apostrophes, AM/PM spaces), and rechecks both source and destination ancestors during reconciliation. All replacements/rollback backups are prepared before parent mutation; failed multi-file application restores earlier files. Exceptional rollback failures explicitly report actual applied paths for recovery. Worktree children receive only `read,edit,write,grep,find,ls,jar_todo,jar_discuss`; shell tools are absent. This is filesystem-write isolation, not a network/process sandbox.
149
- - `jar_discuss` gives moderator and children a session-scoped structured paper. `ask` appends a question with stable id/from/to fields and `answer` references the question id (validated under the same lock as the write). `list` returns the newest entries within a 4000-character reply, each clipped to 400 characters, and says how many older entries it omitted; `since: "d12"` returns only newer entries (a no-change reread costs one line) and `questionId` reads one thread in full. Text is capped at 1600 characters per entry, at most 64 entries are retained, and the compact UTF-8 JSON paper is strictly bounded to 64 KiB with oldest-entry eviction and atomic rewrites. The file lock waits by yielding to the event loop (at most ~1 s), never blocking the UI. It is intentionally for terse inter-agent Q/A rather than another transcript; agents are told not to poll it.
149
+ - `jar_discuss` gives moderator and children a session-scoped structured paper. `ask` appends a question with stable id/from/to fields and `answer` references the question id (validated under the same lock as the write); both reply with the new id only, never echoing the text. Each process keeps an in-memory read cursor (the highest entry id it has been shown, reset when the paper path changes). `list` returns only unseen entries written by others — the newest that fit a 4000-character reply, each clipped to 400 characters, stating how many older entries it omitted — and advances the cursor; with nothing new it replies `No new entries (latest dN).` `since: "d12"` lists everything newer than that id instead (`since: "d0"` rereads the whole paper) and `questionId` reads one thread in full without moving the cursor. An `ask`/`answer` reply attaches unseen entries (≤1000 characters) only when all of them are relevant to the caller — answers to its questions or questions addressed to it — otherwise it adds `(N new entries; list to read)` and leaves the cursor alone, so nothing is skipped. Text is capped at 1600 characters per entry, at most 64 entries are retained, and the compact UTF-8 JSON paper is strictly bounded to 64 KiB with oldest-entry eviction and atomic rewrites. The file lock waits by yielding to the event loop (at most ~1 s), never blocking the UI. It is intentionally for terse inter-agent Q/A rather than another transcript; agents are told not to poll it.
150
150
  - Fork/worktree children inherit the parent model conversation but reset pi-jar goal/plan/todo automation. All child modes inherit explicit extension flags, set `PI_JAR_CHILD=1` to prevent recursive delegation, and share the discussion-paper path/name through environment variables.
151
151
  - Progress (current tool, streaming text, tools used, turns, cost) stays in the activity view. Retained `idle` and `paused` agents remain visible in the footer/activity fleet; the newest eight ordinary retired reports remain for review, while unresolved private workspaces remain recoverable until explicit finalization/session cleanup. `s` in the activity view still steers a working subagent and `x` stops the selected agent through the same registry.
152
152
  - The deprecated `write: true` mode remains a one-shot shared-workspace compatibility path. New delegated implementation should use `mode: "worktree"`.
@@ -16,7 +16,7 @@ import { openJarHistory } from "../src/history-ui.ts";
16
16
  import { GOAL_ENTRY, GoalStore } from "../src/goals.ts";
17
17
  import { GoalLoop } from "../src/goal-loop.ts";
18
18
  import { FOOTER_FIELDS } from "../src/footer-settings.ts";
19
- import { defaultVisualSettings, loadVisualSettings, migrateLegacySettings, saveVisualSettings, type JarVisualSettings } from "../src/settings.ts";
19
+ import { defaultVisualSettings, loadVisualSettings, saveVisualSettings, type JarVisualSettings } from "../src/settings.ts";
20
20
  import { openJarSettings, type PiPreferences } from "../src/settings-ui.ts";
21
21
  import { pickSession } from "../src/session-ui.ts";
22
22
  import { fetchQuota, QuotaCache, type QuotaProvider } from "../src/quota.ts";
@@ -38,12 +38,13 @@ import { WorkingState } from "../src/working.ts";
38
38
  import { ChangeTracker } from "../src/changes.ts";
39
39
  import { CHILD_ENV, DelegateRegistry, registerDelegate, SUBAGENT_MESSAGE, type DelegateRun } from "../src/delegate.ts";
40
40
  import { CHILD_WORKTREE_ENV, workspacePathAllowed } from "../src/delegate-worktree.ts";
41
- import { createDiscussionPaper, DISCUSSION_FILE_ENV, disposeDiscussionPaper, registerDiscussionTool, SUBAGENT_NAME_ENV } from "../src/discussion.ts";
41
+ import { DiscussionBroker, DISCUSSION_NOTICE, discussionClientFromEnv, registerDiscussionTool } from "../src/discussion.ts";
42
42
  import { openActivityView, type ActivityTarget } from "../src/activity-view.ts";
43
43
  import { ICON_SETS, setIconSet, withIcon } from "../src/icons.ts";
44
44
  import { collectPrompts, openPromptSearch } from "../src/prompt-search.ts";
45
45
  import { ago, recentSessions, sessionDetails, type RecentSession } from "../src/session-gallery.ts";
46
46
  import { clearSessionBranchCache, sessionBranch } from "../src/session-branch.ts";
47
+ import { ContextSampler, formatPerf, LARGE_SESSION_ENTRIES, processRss, RenderScheduler, type RenderKind } from "../src/perf.ts";
47
48
  import { registerChangeReview } from "../src/diff-view.ts";
48
49
  import { registerShells, ShellManager, type ShellEvent, type ShellJob } from "../src/shells.ts";
49
50
  import { registerShellNotifications } from "../src/shell-notifications.ts";
@@ -57,7 +58,6 @@ const VERSION = (() => {
57
58
  })();
58
59
  /** Ignore the click Pi may synthesize right after a press we already acted on. */
59
60
  const WELCOME_CLICK_DEDUPE_MS = 400;
60
- const LARGE_SESSION_ENTRIES = 800;
61
61
  /** Entries Pi writes when a fresh session starts; a branch holding only these has no transcript yet. */
62
62
  const SESSION_SETUP_ENTRIES = new Set(["thinking_level_change", "model_change", "session_info"]);
63
63
  export default function piJar(pi: ExtensionAPI): void {
@@ -68,7 +68,7 @@ export default function piJar(pi: ExtensionAPI): void {
68
68
  // and handlers are disabled. Remove only parent automation, not task/user context.
69
69
  const parentAutomation = new Set([
70
70
  "pi-jar.goal-context", "pi-jar.goal-continuation",
71
- "pi-jar.plan-context", "pi-jar.plan-reminder", "pi-jar.moderator-context", SUBAGENT_MESSAGE
71
+ "pi-jar.plan-context", "pi-jar.plan-reminder", "pi-jar.moderator-context", SUBAGENT_MESSAGE, DISCUSSION_NOTICE
72
72
  ]);
73
73
  pi.on("context", (event) => {
74
74
  const messages = event.messages.filter((message) => !parentAutomation.has((message as { customType?: string }).customType ?? ""));
@@ -101,18 +101,30 @@ export default function piJar(pi: ExtensionAPI): void {
101
101
  let enabled = visualSettings.ui;
102
102
  let disposeFooter: (() => void) | undefined;
103
103
  let footerTui: { requestRender(): void } | undefined;
104
- /** Footer facts that walk the session to compute; refreshed on the events that change them, never per frame. */
105
- let footerContext = "ctx ?";
104
+ /**
105
+ * Per-session render budget and context-usage cache (src/perf.ts): created on session_start and
106
+ * disposed on shutdown, so no repaint or sampling timer outlives its session.
107
+ */
108
+ let renders: RenderScheduler | undefined;
109
+ let contextUsage: ContextSampler | undefined;
110
+ /** Only an installed footer in a live session is repainted; user actions and transitions stay prompt. */
111
+ const repaint = (source: string, kind: RenderKind = "foreground") => { if (footerTui) renders?.request(source, kind); };
106
112
  let footerSessionName: string | undefined;
113
+ /** Boundaries refresh the footer facts that walk the session; message ends only mark context dirty. */
107
114
  const sampleFooter = (ctx: ExtensionContext) => {
108
- try {
109
- const usage = ctx.getContextUsage();
110
- footerContext = usage?.percent == null || !Number.isFinite(usage.percent) ? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
111
- } catch { footerContext = "ctx ?"; }
115
+ contextUsage?.refresh(() => ctx.getContextUsage());
112
116
  try { footerSessionName = ctx.sessionManager?.getSessionName?.(); } catch { footerSessionName = undefined; }
113
- footerTui?.requestRender();
117
+ repaint("session", "transition");
114
118
  };
115
119
  let quotaCache: QuotaCache | undefined;
120
+ let enableQuota: NodeJS.Timeout | undefined;
121
+ /**
122
+ * Lookups start on session boundaries, never from a render, and only while an installed footer can
123
+ * show them (never in RPC children or with the UI off); the cache bounds them to one per TTL.
124
+ */
125
+ const refreshQuota = (ctx: ExtensionContext) => {
126
+ if (footerTui && footerSettings.quota) quotaCache?.refresh(ctx.model?.provider, welcomeStatuses(), Date.now());
127
+ };
116
128
  let cost = 0;
117
129
  let welcomeInterval: ReturnType<typeof setInterval> | undefined;
118
130
  let welcomeFrame = 0;
@@ -133,8 +145,9 @@ export default function piJar(pi: ExtensionAPI): void {
133
145
  let workingIndicatorKey = "";
134
146
  let openSettings: (ctx: ExtensionContext) => Promise<void> = async () => {};
135
147
  let settingsOpen = false;
136
- let discussionFile: string | undefined = process.env[DISCUSSION_FILE_ENV];
137
- registerDiscussionTool(pi, () => discussionFile, () => process.env[SUBAGENT_NAME_ENV] ?? "moderator");
148
+ const discussionClient = delegatedChild ? discussionClientFromEnv() : undefined;
149
+ let discussion: DiscussionBroker | undefined;
150
+ registerDiscussionTool(pi, () => delegatedChild ? discussionClient : discussion?.local());
138
151
 
139
152
  // A finished list stays visible (all struck through) until the user's next prompt, like Claude.
140
153
  let todosAcknowledged = false;
@@ -182,38 +195,47 @@ export default function piJar(pi: ExtensionAPI): void {
182
195
  let changes: ChangeTracker | undefined;
183
196
  let changeCount = 0;
184
197
  const refreshChanges = () => {
198
+ const before = changeCount;
185
199
  try { changeCount = changes?.count() ?? 0; } catch { changeCount = 0; }
186
- footerTui?.requestRender();
200
+ if (changeCount !== before) repaint("changes", "transition");
187
201
  };
188
202
  registerChangeReview(pi, () => changes, refreshChanges);
189
203
  let shells: ShellManager | undefined;
190
204
  registerShells(pi, () => shells);
191
205
  const shellNotifications = registerShellNotifications(pi, () => shells);
206
+ /** Shell rows show status and watch matches: changing those is a transition, anything else a background delta. */
207
+ let shellSignature = "";
208
+ const noteShells = () => {
209
+ if (!footerTui) return;
210
+ const signature = (shells?.summaries() ?? []).map((job) => `${job.id}:${job.status}:${job.matched ? 1 : 0}`).join(" ");
211
+ repaint("shells", signature === shellSignature ? "background" : "transition");
212
+ shellSignature = signature;
213
+ };
192
214
  const onShellEvent = (event: ShellEvent) => {
193
- footerTui?.requestRender();
215
+ noteShells();
194
216
  if (!event.job.notify) return;
195
217
  // Queue compact metadata until a model boundary; observed completions are acknowledged.
196
218
  shellNotifications.notify();
197
219
  };
198
220
  /** Footer indicators owned by pi-jar; live subagents and shells get their own rows. */
199
221
  const footerChips = () => changeCount ? [withIcon("changes", `${changeCount} file${changeCount === 1 ? "" : "s"} · /diff`)] : [];
222
+ const subagents = new DelegateRegistry();
200
223
  /**
201
- * Subagent streams and shell output change the footer many times a second; every requested frame
202
- * re-renders the whole transcript, so background sources repaint it at most this often.
224
+ * Subagent streams notify many times a second, but footer rows only change state rarely: a changed
225
+ * key/state set is a transition, anything else (tool counts, activity text) a background delta.
203
226
  */
204
- const BACKGROUND_REPAINT_MS = 250;
205
- let backgroundRepaint: NodeJS.Timeout | undefined;
206
- const repaintFooterSoon = () => {
207
- if (backgroundRepaint) return;
208
- backgroundRepaint = setTimeout(() => { backgroundRepaint = undefined; footerTui?.requestRender(); }, BACKGROUND_REPAINT_MS);
209
- backgroundRepaint.unref?.();
210
- };
211
- const subagents = new DelegateRegistry();
212
- subagents.subscribe(repaintFooterSoon);
227
+ let subagentSignature = "";
228
+ subagents.subscribe(() => {
229
+ if (!footerTui) return;
230
+ const signature = subagents.records().map(({ key, run }) => `${key}:${run.state}`).join(" ");
231
+ repaint("subagents", signature === subagentSignature ? "background" : "transition");
232
+ subagentSignature = signature;
233
+ });
213
234
  /** Finished work stays visible briefly so a quick run is not a flicker. */
214
235
  const ACTIVITY_LINGER_MS = 5000;
236
+ // A hibernated agent has no live process but resumes on demand: the footer shows it as idle.
215
237
  const subagentState = (run: DelegateRun): ActivityState => run.state === "working" ? "running" : run.state === "queued" ? "queued"
216
- : run.state === "idle" ? "idle" : run.state === "paused" ? "paused" : run.state === "stopped" ? "stopped"
238
+ : run.state === "idle" || run.state === "hibernated" ? "idle" : run.state === "paused" ? "paused" : run.state === "stopped" ? "stopped"
217
239
  : run.state === "done" ? "done" : "failed";
218
240
  const shellActivityState = (job: Omit<ShellJob, "lines">): ActivityState => job.status === "running" ? "running"
219
241
  : job.status === "killed" ? "stopped" : job.status === "exited" && job.exitCode === 0 ? "done" : "failed";
@@ -235,17 +257,23 @@ export default function piJar(pi: ExtensionAPI): void {
235
257
  return rows;
236
258
  };
237
259
  let overlayOpen = false;
238
- /** Subagents, shells and other extensions' roles in one live split view. */
260
+ /**
261
+ * Subagents, shells and other extensions' roles in one live split view. While it is open it
262
+ * repaints live updates itself (on the tier's focused cadence), so footer background deltas wait.
263
+ */
239
264
  const openActivity = async (ctx: ExtensionContext, initial?: ActivityTarget) => {
240
265
  if (!ctx.hasUI || ctx.mode !== "tui" || overlayOpen) return;
241
266
  overlayOpen = true;
267
+ const budget = renders;
268
+ budget?.setFocused(true);
242
269
  try {
243
270
  await openActivityView(ctx, {
244
271
  subagents, ...(shells ? { shells } : {}),
245
272
  roles: () => collectStatuses(welcomeStatuses(), Date.now()).roles.filter((role) => !role.id.startsWith("delegate-"))
246
- .map((role) => ({ id: role.id, name: role.name, state: role.state, ...(role.task ? { task: role.task } : {}) }))
273
+ .map((role) => ({ id: role.id, name: role.name, state: role.state, ...(role.task ? { task: role.task } : {}) })),
274
+ repaintMs: () => renders?.focusedMs() ?? 100
247
275
  }, initial);
248
- } finally { overlayOpen = false; }
276
+ } finally { overlayOpen = false; budget?.setFocused(false); }
249
277
  };
250
278
  /** Searchable project sessions with a details pane; Enter resumes the selected one. */
251
279
  const openSessions = async (ctx: ExtensionContext | ExtensionCommandContext, query = "") => {
@@ -280,7 +308,10 @@ export default function piJar(pi: ExtensionAPI): void {
280
308
  quota: (ctx) => quotaCache?.get(ctx.model?.provider, welcomeStatuses(), Date.now()) });
281
309
  modelRoles.register((ctx) => openRolesUi(ctx, modelRoles), { activateDefault: () => !delegatedChild });
282
310
  let delegateController: DelegateController | undefined;
283
- registerDelegate(pi, modelRoles, subagents, { changes: () => changes, changed: refreshChanges, discussionFile: () => discussionFile,
311
+ registerDelegate(pi, modelRoles, subagents, { changes: () => changes, changed: refreshChanges,
312
+ discussionEnv: (key, name) => discussion?.childEnv(key, name),
313
+ discussionRetire: (key) => discussion?.retire(key),
314
+ discussionUnread: (key) => discussion?.unreadFor(key) ?? 0,
284
315
  getMaxSubagents: () => visualSettings.maxSubagents, onController: (controller) => { delegateController = controller; } });
285
316
  if (!delegatedChild) registerDemocracy(pi, () => delegateController, () => visualSettings.maxSubagents);
286
317
  const planMode = new PlanMode(pi, () => todos, modelRoles, updateTaskWidget);
@@ -289,7 +320,7 @@ export default function piJar(pi: ExtensionAPI): void {
289
320
  goals: () => goals, todos: () => todos, roles: modelRoles, planActive: () => planMode.isEnabled(),
290
321
  maxRounds: () => visualSettings.goalRounds,
291
322
  subagentMode: (agent) => subagents.resolve(agent)?.run.mode,
292
- changed: (ctx) => { footerTui?.requestRender(); welcomeTui?.requestRender(); updateTaskWidget(ctx); },
323
+ changed: (ctx) => { repaint("goal", "transition"); welcomeTui?.requestRender(); updateTaskWidget(ctx); },
293
324
  completed: () => composer.flash("complete", 4000)
294
325
  });
295
326
  if (!delegatedChild) goalLoop.register();
@@ -372,9 +403,8 @@ export default function piJar(pi: ExtensionAPI): void {
372
403
  welcomeFrame = 0;
373
404
  welcomeDismiss = 0;
374
405
  try {
375
- const contextUsage = ctx.getContextUsage();
376
- const context = contextUsage?.percent != null && Number.isFinite(contextUsage.percent)
377
- ? `ctx ${Math.round(contextUsage.percent)}%` : "ctx ?";
406
+ // The cached sample: the welcome never forces its own walk of the session.
407
+ const context = contextUsage?.label ?? "ctx ?";
378
408
  const commands = pi.getCommands().filter((item) => item.source === "extension");
379
409
  const managers: ("tasks" | "subagents")[] = [];
380
410
  if (commands.some((item) => item.name === "tasks")) managers.push("tasks");
@@ -498,7 +528,7 @@ export default function piJar(pi: ExtensionAPI): void {
498
528
  let disposed = false;
499
529
  let hits: FooterHit[] = [];
500
530
  let pressed: { target: FooterTarget; at: number } | undefined;
501
- const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
531
+ const unsubscribe = footerData.onBranchChange(() => repaint("branch", "transition"));
502
532
  const dispose = () => {
503
533
  if (disposed) return;
504
534
  disposed = true;
@@ -527,6 +557,8 @@ export default function piJar(pi: ExtensionAPI): void {
527
557
  },
528
558
  render(width: number): string[] {
529
559
  try {
560
+ // Whatever background state was waiting for a repaint is drawn by this frame, ours or Pi's.
561
+ renders?.painted();
530
562
  const now = Date.now();
531
563
  const statuses = footerData.getExtensionStatuses();
532
564
  const live = collectStatuses(statuses, now);
@@ -539,7 +571,7 @@ export default function piJar(pi: ExtensionAPI): void {
539
571
  expiryTimer = undefined;
540
572
  expiryAt = nearest;
541
573
  if (nearest != null) {
542
- expiryTimer = setTimeout(() => { expiryTimer = undefined; expiryAt = undefined; tui.requestRender(); }, Math.max(1, nearest - now));
574
+ expiryTimer = setTimeout(() => { expiryTimer = undefined; expiryAt = undefined; repaint("roles", "transition"); }, Math.max(1, nearest - now));
543
575
  expiryTimer.unref?.();
544
576
  }
545
577
  }
@@ -557,7 +589,7 @@ export default function piJar(pi: ExtensionAPI): void {
557
589
  model: ctx.model?.id ?? "no-model", effort: ctx.model?.reasoning === false ? "off" : (pi.getThinkingLevel?.() ?? "off"),
558
590
  sessionName: footerSessionName,
559
591
  cwd: ctx.cwd, settings: footerSettings, branch: footerData.getGitBranch(),
560
- context: footerContext, goal: goalLoop.progress(), chips: footerChips(), activity: footerActivity(now),
592
+ context: contextUsage?.label ?? "ctx ?", goal: goalLoop.progress(), chips: footerChips(), activity: footerActivity(now),
561
593
  memory, cost: formatCost(cost), quota, roles, extras: live.extras,
562
594
  demo, animations, frame, motionBudget: ctx.isIdle() ? 2 : 1
563
595
  }, width, ctx.ui.theme ?? theme);
@@ -583,6 +615,7 @@ export default function piJar(pi: ExtensionAPI): void {
583
615
  const applyVisualSettings = (next: JarVisualSettings, ctx: ExtensionContext) => {
584
616
  const wasEnabled = enabled;
585
617
  const hadMotion = animations;
618
+ const quotaShown = !!footerTui && footerSettings.quota;
586
619
  if (next.accent !== visualSettings.accent && next.accent !== "follow" && !selectAccent(ctx, next.accent)) {
587
620
  ctx.ui.notify(`Accent ${next.accent} is not loaded`, "warning");
588
621
  return;
@@ -609,8 +642,10 @@ export default function piJar(pi: ExtensionAPI): void {
609
642
  else if (!hadMotion && animations && welcomeTui && !welcomeInterval) {
610
643
  { welcomeInterval = setInterval(() => { welcomeFrame++; welcomeTui?.requestRender(); }, WELCOME_INTERVAL_MS); welcomeInterval.unref?.(); }
611
644
  }
612
- footerTui?.requestRender();
645
+ repaint("settings");
613
646
  welcomeTui?.requestRender();
647
+ // Quota shown again (field or UI back on): hiding it stopped lookups and dropped the cache.
648
+ if (!quotaShown) refreshQuota(ctx);
614
649
  try { saveVisualSettings(getAgentDir(), next); }
615
650
  catch { ctx.ui.notify("Visual preferences changed for this session but could not be saved", "warning"); }
616
651
  };
@@ -623,9 +658,9 @@ export default function piJar(pi: ExtensionAPI): void {
623
658
  finally { settingsOpen = false; }
624
659
  };
625
660
 
661
+ /** Callers are lifecycle handlers that repaint once through sampleFooter. */
626
662
  const updateCost = (ctx: ExtensionContext) => {
627
663
  try { cost = sessionCost(ctx); } catch { cost = 0; }
628
- footerTui?.requestRender();
629
664
  };
630
665
  const addMessageCost = (message: { usage?: { cost?: { total?: number } } }) => {
631
666
  const value = message.usage?.cost?.total;
@@ -637,11 +672,9 @@ export default function piJar(pi: ExtensionAPI): void {
637
672
  };
638
673
  const restoreGoal = (ctx: ExtensionContext, branch?: readonly unknown[]) => {
639
674
  try { goals?.restore(branch ?? sessionBranch(ctx)); } catch { goals?.restore([]); }
640
- footerTui?.requestRender();
641
675
  };
642
676
  pi.on("session_start", (_event, ctx) => {
643
677
  sideUsage.clear();
644
- migrateLegacySettings(getAgentDir());
645
678
  visualSettings = loadVisualSettings(getAgentDir());
646
679
  setIconSet(visualSettings.icons);
647
680
  animations = visualSettings.animations;
@@ -652,20 +685,37 @@ export default function piJar(pi: ExtensionAPI): void {
652
685
  workingIndicatorKey = "";
653
686
  working.end();
654
687
  quotaCache?.stop();
688
+ clearTimeout(enableQuota);
689
+ // Render and sampling counters are session-scoped; nothing from a previous session may fire.
690
+ renders?.dispose();
691
+ contextUsage?.dispose();
655
692
  todos = new TodoStore((entry) => pi.appendEntry(TASK_ENTRY, entry));
656
693
  changes = new ChangeTracker(() => ctx.cwd);
657
694
  changeCount = 0;
658
695
  shells?.dispose();
659
- subagents.clear();
696
+ const preserved = subagents.clear();
697
+ if (preserved.length) ctx.ui.notify(`Unresolved subagent worktrees preserved on disk:\n${preserved.join("\n")}`, "warning");
698
+ const previousDiscussion = discussion;
660
699
  if (!delegatedChild) {
661
- disposeDiscussionPaper(discussionFile);
662
- discussionFile = createDiscussionPaper();
700
+ discussion = new DiscussionBroker();
701
+ discussion.onChange = () => repaint("discussion", "background");
663
702
  }
703
+ renders = new RenderScheduler(() => {
704
+ const tui = footerTui;
705
+ if (!tui) return false;
706
+ tui.requestRender();
707
+ return true;
708
+ });
709
+ contextUsage = new ContextSampler(() => repaint("context", "background"));
710
+ subagentSignature = "";
711
+ shellSignature = "";
664
712
  shells = new ShellManager(onShellEvent);
665
- shells.onChange = repaintFooterSoon;
713
+ shells.onChange = noteShells;
666
714
  goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
667
715
  let branch: readonly unknown[] = [];
668
716
  try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
717
+ // Counted from here on per message_end: no per-event branch walk.
718
+ renders.setEntries(branch.length);
669
719
  if (delegatedChild) {
670
720
  todos.restore([]);
671
721
  goals.restore([]);
@@ -676,12 +726,15 @@ export default function piJar(pi: ExtensionAPI): void {
676
726
  // Quota is enabled per session; no credentials or consent are persisted.
677
727
  quotaCache = new QuotaCache(
678
728
  (provider: QuotaProvider, signal) => fetchQuota(provider, (id) => ctx.modelRegistry.getProviderAuth(id), signal),
679
- () => footerTui?.requestRender()
729
+ () => repaint("quota", "transition")
680
730
  );
681
731
  // Hold the network lookup until startup settles; the footer shows it once it arrives.
682
732
  quotaCache.enabled = false;
683
733
  const cache = quotaCache;
684
- const enableQuota = setTimeout(() => { if (quotaCache === cache && !quotaDisabledByUser) { cache.enabled = true; footerTui?.requestRender(); } }, STARTUP_GRACE_MS);
734
+ enableQuota = setTimeout(() => {
735
+ enableQuota = undefined;
736
+ if (quotaCache === cache && !quotaDisabledByUser) { cache.enabled = true; refreshQuota(ctx); }
737
+ }, STARTUP_GRACE_MS);
685
738
  enableQuota.unref?.();
686
739
  quotaDisabledByUser = false;
687
740
  updateCost(ctx);
@@ -705,6 +758,11 @@ export default function piJar(pi: ExtensionAPI): void {
705
758
  const resumed = branch.some((entry) => !(typeof entry === "object" && entry !== null && "type" in entry
706
759
  && typeof entry.type === "string" && SESSION_SETUP_ENTRIES.has(entry.type)));
707
760
  if (!delegatedChild && !resumed) showWelcome(ctx);
761
+ if (discussion) {
762
+ const current = discussion;
763
+ return (previousDiscussion ? previousDiscussion.close().then(() => current.start()) : current.start())
764
+ .catch((error) => { ctx.ui.notify("Discussion broker unavailable: " + String(error), "warning"); });
765
+ }
708
766
  });
709
767
  // Any real prompt dismisses the welcome. Slash commands bypass `input`, so `agent_start` covers them.
710
768
  const dismissWelcome = (ctx: ExtensionContext) => {
@@ -724,15 +782,18 @@ export default function piJar(pi: ExtensionAPI): void {
724
782
  dismissWelcome(ctx);
725
783
  if (!todosAcknowledged && todos?.all().every((item) => item.done)) { todosAcknowledged = true; updateTaskWidget(ctx); }
726
784
  });
727
- pi.on("agent_start", (_event, ctx) => { dismissWelcome(ctx); working.start(); applyWorking(ctx); });
785
+ pi.on("agent_start", (_event, ctx) => { dismissWelcome(ctx); working.start(); applyWorking(ctx); refreshQuota(ctx); });
728
786
  pi.on("turn_start", (_event, ctx) => { working.start(); applyWorking(ctx); });
729
787
  pi.on("message_end", (event, ctx) => {
730
- // Every message (user, assistant, tool result) moves the context window.
731
- sampleFooter(ctx);
788
+ // Every message (user, assistant, tool result) moves the context window, and a tool-heavy turn
789
+ // ends many: count the entry and let the sampler recompute once per window, not per message.
790
+ renders?.addEntries(1);
791
+ contextUsage?.markDirty(() => ctx.getContextUsage());
732
792
  if (event.message.role === "assistant") {
733
793
  working.reportOutputTokens(event.message.usage?.output ?? 0);
734
794
  addMessageCost(event.message);
735
795
  applyWorking(ctx);
796
+ repaint("cost", "background");
736
797
  }
737
798
  });
738
799
  pi.on("tool_execution_start", (event, ctx) => { working.toolStart(event.toolCallId, event.toolName); applyWorking(ctx); });
@@ -740,13 +801,15 @@ export default function piJar(pi: ExtensionAPI): void {
740
801
  pi.on("ui_prompt_start", (_event, ctx) => { working.prompt(true); applyWorking(ctx); });
741
802
  pi.on("ui_prompt_end", (_event, ctx) => { working.prompt(false); applyWorking(ctx); });
742
803
  // A request may span several tool turns; keep its elapsed time and reported tokens until agent_end.
743
- pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); });
744
- pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); });
804
+ pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); refreshQuota(ctx); });
805
+ pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); sampleFooter(ctx); });
745
806
  pi.on("agent_before_settle", (event) => { if (event.outcome === "error") composer.flash("error"); });
746
- pi.on("agent_settled", (_event, ctx) => { working.end(); applyWorking(ctx); });
807
+ // Retries or compaction after agent_end may have added messages; otherwise the agent_end sample stands.
808
+ pi.on("agent_settled", (_event, ctx) => { working.end(); applyWorking(ctx); if (contextUsage?.dirty) sampleFooter(ctx); refreshQuota(ctx); });
747
809
  pi.on("session_tree", (_event, ctx) => {
748
810
  let branch: readonly unknown[] = [];
749
811
  try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
812
+ renders?.setEntries(branch.length);
750
813
  if (!delegatedChild) {
751
814
  restoreTodos(ctx, branch);
752
815
  restoreGoal(ctx, branch);
@@ -756,12 +819,15 @@ export default function piJar(pi: ExtensionAPI): void {
756
819
  sampleFooter(ctx);
757
820
  });
758
821
  pi.on("session_compact", (_event, ctx) => {
759
- if (!delegatedChild) { restoreTodos(ctx); restoreGoal(ctx); }
822
+ let branch: readonly unknown[] = [];
823
+ try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
824
+ renders?.setEntries(branch.length);
825
+ if (!delegatedChild) { restoreTodos(ctx, branch); restoreGoal(ctx, branch); }
760
826
  updateCost(ctx);
761
827
  sampleFooter(ctx);
762
828
  });
763
- pi.on("model_select", (_event, ctx) => sampleFooter(ctx));
764
- pi.on("thinking_level_select", (_event, _ctx) => footerTui?.requestRender());
829
+ pi.on("model_select", (_event, ctx) => { sampleFooter(ctx); refreshQuota(ctx); });
830
+ pi.on("thinking_level_select", (_event, _ctx) => repaint("effort"));
765
831
  pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); sampleFooter(ctx); });
766
832
  pi.on("session_shutdown", (_event, ctx) => {
767
833
  clearSessionBranchCache(ctx);
@@ -772,17 +838,27 @@ export default function piJar(pi: ExtensionAPI): void {
772
838
  try { if (ctx.hasUI && ctx.mode === "tui") { ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); ctx.ui.setWidget("pi-jar.todos", undefined); } } catch {}
773
839
  quotaCache?.stop();
774
840
  quotaCache = undefined;
775
- if (!delegatedChild) { disposeDiscussionPaper(discussionFile); discussionFile = undefined; }
841
+ clearTimeout(enableQuota);
842
+ enableQuota = undefined;
843
+ // Late notifications (draining children, shell disposal) find no scheduler and start no timer.
844
+ renders?.dispose();
845
+ renders = undefined;
846
+ contextUsage?.dispose();
847
+ contextUsage = undefined;
776
848
  todos = undefined;
777
849
  changes = undefined;
778
850
  changeCount = 0;
779
851
  shells?.dispose();
780
852
  shells = undefined;
781
- subagents.clear();
853
+ const preserved = subagents.clear();
854
+ if (preserved.length) ctx.ui.notify(`Unresolved subagent worktrees preserved on disk:\n${preserved.join("\n")}`, "warning");
855
+ const closingDiscussion = discussion;
856
+ discussion = undefined;
782
857
  goals = undefined;
783
858
  disposeFooter?.();
784
859
  disposeFooter = undefined;
785
860
  demo = false;
861
+ return closingDiscussion?.close();
786
862
  });
787
863
 
788
864
  pi.registerShortcut?.(Key.ctrlAlt("r"), {
@@ -808,7 +884,7 @@ export default function piJar(pi: ExtensionAPI): void {
808
884
  });
809
885
  pi.registerShortcut?.(Key.ctrlAlt("m"), {
810
886
  description: "Cycle pi-jar model roles",
811
- handler: async (ctx) => { await modelRoles.cycle(ctx); footerTui?.requestRender(); }
887
+ handler: async (ctx) => { await modelRoles.cycle(ctx); repaint("roles"); }
812
888
  });
813
889
  pi.registerShortcut?.(Key.ctrlAlt("a"), {
814
890
  description: "Subagents and background shells (pi-jar)",
@@ -835,6 +911,20 @@ export default function piJar(pi: ExtensionAPI): void {
835
911
  ctx.ui.notify(`pi-jar: UI ${enabled ? "on" : "off"}; animations ${animations ? "on" : "off"}; quota ${quotaCache?.enabled ? "on" : "off"} (session-only); composer ${composer.enabled ? "on" : "off"}; mascot ${visualSettings.mascot ? "on" : "off"}; suggestions ${visualSettings.suggestions ? "on" : "off"}; ${todos?.all().length ?? 0} to-dos; demo ${demo ? "on" : "off"}`, "info");
836
912
  return;
837
913
  }
914
+ if (command === "perf") {
915
+ // On demand only: one `ps` call for live children; everything else is an in-memory counter.
916
+ const subagentStats = subagents.stats();
917
+ const childRssBytes = await processRss(subagentStats.pids);
918
+ ctx.ui.notify(formatPerf({
919
+ entries: renders?.entries ?? 0, context: contextUsage?.label ?? "ctx ?", rssBytes: process.memoryUsage.rss(),
920
+ ...(renders ? { render: renders.stats() } : {}), ...(contextUsage ? { sampling: contextUsage.stats() } : {}),
921
+ subagents: subagentStats, ...(childRssBytes !== undefined ? { childRssBytes } : {}),
922
+ ...(shells ? { shells: shells.stats() } : {}), ...(discussion ? { discussion: discussion.stats() } : {}),
923
+ ...(quotaCache ? { quota: quotaCache.stats() } : {}),
924
+ sideCalls: sideUsage.all().length, now: Date.now()
925
+ }), "info");
926
+ return;
927
+ }
838
928
  // `/jar resume` without a number opens the same searchable picker.
839
929
  if (command === "sessions" || command.startsWith("sessions ") || command === "resume") {
840
930
  await openSessions(ctx, command === "resume" ? "" : args.trim().slice(8).trim());
@@ -879,7 +969,7 @@ export default function piJar(pi: ExtensionAPI): void {
879
969
  return;
880
970
  }
881
971
  footerSettings = visualSettings.footer;
882
- footerTui?.requestRender();
972
+ repaint("settings");
883
973
  const labels: Record<(typeof FOOTER_FIELDS)[number], string> = {
884
974
  model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory", context: "Context",
885
975
  memory: "Process RAM (RSS)", cost: "Session cost", quota: "Quota", roles: "Roles", extras: "Extension statuses", branch: "Git branch"
@@ -974,10 +1064,10 @@ export default function piJar(pi: ExtensionAPI): void {
974
1064
  else if (command === "animations off") applyVisualSettings({ ...visualSettings, animations: false }, ctx);
975
1065
  else if (command === "ui on") applyVisualSettings({ ...visualSettings, ui: true }, ctx);
976
1066
  else if (command === "ui off") applyVisualSettings({ ...visualSettings, ui: false }, ctx);
977
- else if (command === "quota on" && quotaCache) { quotaCache.enabled = true; quotaDisabledByUser = false; }
1067
+ else if (command === "quota on" && quotaCache) { quotaCache.enabled = true; quotaDisabledByUser = false; refreshQuota(ctx); }
978
1068
  else if (command === "quota off" && quotaCache) { quotaCache.enabled = false; quotaDisabledByUser = true; quotaCache.stop(); }
979
1069
  else {
980
- ctx.ui.notify("Usage: /jar [status|settings|activity|shells|sessions [search]|icons [unicode|nerd|ascii]|commit [note]|name <title>|history|footer|tasks|ask|composer on/off|accent [preset]|hub|welcome|demo|reset|animations on/off|ui on/off|quota on/off]", "error");
1070
+ ctx.ui.notify("Usage: /jar [status|perf|settings|activity|shells|sessions [search]|icons [unicode|nerd|ascii]|commit [note]|name <title>|history|footer|tasks|ask|composer on/off|accent [preset]|hub|welcome|demo|reset|animations on/off|ui on/off|quota on/off]", "error");
981
1071
  return;
982
1072
  }
983
1073
  if (!["animations on", "animations off", "ui on", "ui off"].includes(command)) installUi(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "A warm, role-aware Pi terminal workspace with animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,8 @@ export interface ActivitySources {
16
16
  subagents: DelegateRegistry;
17
17
  shells?: ShellManager;
18
18
  roles?: () => ActivityRole[];
19
+ /** Live-update coalescing in ms (default 100); the host widens it as the transcript under the overlay grows. */
20
+ repaintMs?: () => number;
19
21
  }
20
22
 
21
23
  /** Doubles as the icon key for the row glyph. */
@@ -46,7 +48,7 @@ function collect(sources: ActivitySources): Item[] {
46
48
  if (records.length) items.push({ kind: "header", label: withIcon("subagents", "SUBAGENTS") });
47
49
  for (const record of records) {
48
50
  const { run } = record;
49
- const status: Status = run.state === "working" ? "running" : run.state === "queued" || run.state === "idle" ? "pending"
51
+ const status: Status = run.state === "working" ? "running" : run.state === "queued" || run.state === "idle" || run.state === "hibernated" ? "pending"
50
52
  : run.state === "paused" || run.state === "stopped" ? "stopped" : run.state === "done" ? "success" : "error";
51
53
  items.push({ kind: "subagent", id: record.key, status, label: run.name, record });
52
54
  }
@@ -275,11 +277,12 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
275
277
  const fg: Fg = (color, text) => theme.fg(color as never, text);
276
278
  const shells = sources.shells;
277
279
  const previous = shells?.onChange;
278
- // Live streams notify far faster than a transcript view needs to repaint; ~10 frames a second reads as live.
280
+ // Live streams notify far faster than a transcript view needs to repaint; ~10 frames a second reads
281
+ // as live. Each frame also re-renders the transcript beneath the overlay, so long sessions go slower.
279
282
  let repaint: NodeJS.Timeout | undefined;
280
283
  const repaintSoon = () => {
281
284
  if (repaint || closed) return;
282
- repaint = setTimeout(() => { repaint = undefined; if (!closed) tui.requestRender(); }, 100);
285
+ repaint = setTimeout(() => { repaint = undefined; if (!closed) tui.requestRender(); }, sources.repaintMs?.() ?? 100);
283
286
  repaint.unref?.();
284
287
  };
285
288
  const unsubscribe = sources.subagents.subscribe(repaintSoon);
@@ -359,9 +362,10 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
359
362
  const stopSelected = () => {
360
363
  const item = selected();
361
364
  if (!item || item.kind === "role") return;
362
- const subagentLive = item.kind === "subagent" && !["done", "failed", "stopped"].includes(item.record.run.state);
365
+ // An unresolved recovery workspace stays stoppable: stop retries its reconciliation.
366
+ const subagentStoppable = item.kind === "subagent" && (!["done", "failed", "stopped"].includes(item.record.run.state) || !!item.record.run.workspace);
363
367
  const shellLive = item.kind === "shell" && item.status === "running";
364
- if (!subagentLive && !shellLive) return;
368
+ if (!subagentStoppable && !shellLive) return;
365
369
  try {
366
370
  if (item.kind === "subagent") void sources.subagents.stop(item.id).catch((error: unknown) => {
367
371
  ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error");
@@ -460,7 +464,7 @@ ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(erro
460
464
  // Footer: the two actions, the steering input for subagents, then key hints.
461
465
  const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
462
466
  "Follow the latest output"], -1, ACTIONS.map((action) => action.key));
463
- const stoppable = item?.kind === "subagent" ? !["done", "failed", "stopped"].includes(item.record.run.state)
467
+ const stoppable = item?.kind === "subagent" ? !["done", "failed", "stopped"].includes(item.record.run.state) || !!item.record.run.workspace
464
468
  : item?.kind === "shell" && item.status === "running";
465
469
  if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
466
470
  const footer = [...actions];