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.
- package/README.md +15 -12
- package/docs/DIFF-REVIEW.md +5 -2
- package/docs/DISCUSSION_BROKER_PLAN.md +333 -0
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/LONG_SESSION_PERFORMANCE_PLAN.md +731 -0
- package/docs/SUBAGENT-TOOLS.md +2 -0
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +153 -63
- package/package.json +1 -1
- package/src/activity-view.ts +10 -6
- package/src/changes.ts +147 -12
- package/src/delegate-worktree.ts +71 -9
- package/src/delegate.ts +734 -423
- package/src/democracy.ts +6 -4
- package/src/diff-inline.ts +80 -0
- package/src/diff-view.ts +196 -59
- package/src/discussion-hub.ts +418 -0
- package/src/discussion.ts +419 -149
- package/src/footer-settings.ts +3 -29
- package/src/perf.ts +419 -0
- package/src/quota.ts +77 -26
- package/src/settings.ts +2 -12
- package/src/shells.ts +134 -28
package/docs/SUBAGENT-TOOLS.md
CHANGED
|
@@ -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
|
|
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"`.
|
package/extensions/index.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
/**
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
*
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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) => {
|
|
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
|
-
|
|
376
|
-
const context = contextUsage?.
|
|
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(() =>
|
|
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;
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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 =
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/activity-view.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (!
|
|
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];
|