@jwilger/pi-development-system 0.81.0 → 0.83.0

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 CHANGED
@@ -22,8 +22,9 @@ what the current phase expects. When you say a slice is finished with no review
22
22
  it tells you to start one. The slash commands (`/devsys-start`, `/devsys-plan`, `/devsys-review`)
23
23
  are shortcuts to the same tools.
24
24
 
25
- With `codemode` enabled (`"defaultTools": ["+codemode"]` in pi settings), rarely used tools and
26
- the `judge_*` Jev wrappers are reached through scripts; without it they are declared directly.
25
+ With `codemode` enabled (`"defaultTools": ["+codemode"]` in pi settings), rarely used tools are
26
+ reached through scripts and the `judge_*` Jev wrappers exist for scripts only; without codemode
27
+ the rarely used tools are declared directly and the wrappers are absent.
27
28
 
28
29
  ## Development
29
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.81.0",
3
+ "version": "0.83.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -9,7 +9,7 @@ import { phaseGuide } from "./phase-guide.ts";
9
9
  * system prompt's state section; calling the tool returns the guidance for it.
10
10
  */
11
11
  export const DEVSYS_TOOL_DESCRIPTION =
12
- "What the development system expects in the current workflow phase (named in the system prompt's Current state), and which tools to use. Call it when unsure what comes next.";
12
+ "What the development system expects in the current workflow phase (the system prompt names it as of the start of the prompt; this call is live), and which tools to use. Call it when unsure what comes next.";
13
13
 
14
14
  /** `devsys`: a model-only tool that returns the current phase's guidance. */
15
15
  export function createPhaseTool(deps: { state: SessionState }): ToolDefinition {
@@ -1,6 +1,6 @@
1
1
  import type { DevsysState } from "../core/types.ts";
2
2
  import type { Jev } from "../jev/client.ts";
3
- import { type Intent, judgeIntent } from "../jev/questions/intent.ts";
3
+ import { type Intent, judgeIntent, requestText } from "../jev/questions/intent.ts";
4
4
 
5
5
  /** Probability at or above which an intent is acted on. */
6
6
  export const INTENT_AT = 0.7;
@@ -35,7 +35,7 @@ export async function intentLineFor(
35
35
  state: DevsysState,
36
36
  jev: Jev,
37
37
  ): Promise<string | undefined> {
38
- if (state.phase !== "idle" || prompt.trim() === "" || jev.availability() === "offline") {
38
+ if (state.phase !== "idle" || requestText(prompt) === "" || jev.availability() === "offline") {
39
39
  return undefined;
40
40
  }
41
41
  const judged = await judgeIntent(jev, { prompt, phase: state.phase });
@@ -28,7 +28,7 @@ export function buildPrinciplesSection(nonNegotiables: string): string {
28
28
  */
29
29
  export function buildStateSection(state: DevsysState): string {
30
30
  const lines = [
31
- "## Current state",
31
+ "## Workflow state at the start of this prompt (call the devsys tool for the live phase)",
32
32
  `- phase: ${state.phase}`,
33
33
  `- sizing: ${state.sizing ?? "none"}`,
34
34
  `- active slice: ${state.activeSlice ?? "none"}`,
@@ -79,7 +79,7 @@ export async function judgeTestChange(
79
79
  ? { before: input.before ?? "", after: "" }
80
80
  : changeWindow(input.before ?? "", input.after);
81
81
  const state = {
82
- path: input.path,
82
+ path: clip(input.path).slice(0, 400),
83
83
  before: clip(windowed.before),
84
84
  after: input.after === undefined ? "" : clip(windowed.after),
85
85
  deleted: input.after === undefined,
@@ -41,3 +41,4 @@ SOFTWARE.
41
41
  6. Empty scope (I5 review): `orch/runtime.ts` `availableScopedModels` treats an empty `/scoped-models` as no restriction (pi's meaning) instead of "matches nothing", because every devsys agent declares `models:` and would otherwise be unspawnable by default. Pinned models are also re-applied on resume (`resolveInitialSettings`), since a restored session may hold only inherited history.
42
42
  7. Bundled agents outside `src/subagents`: `agents/researcher.md` (devsys `models:` family list) and `agents/reviewer.md` (devsys `models:` list and packet format; description rewritten; upstream `icon` and `modelSuggestions` removed) differ from upstream; `agents/{advisor,implementer,lens-*}.md` are new. The agent file format itself is unchanged.
43
43
  8. Thread cap (I7b.1): `orch/manager.ts` `archiveFinished` replaces the hard "Total thread limit reached" check. At `maxThreads` the oldest finished (completed/failed/stopped) thread without a retained descendant is dropped from the registry to make room; the limit is refused only when every kept thread is live, paused or a parent of one. Upstream kept finished threads forever, so long autonomous runs stalled on their own history. A dropped thread cannot be resumed with `agent_steer` (its session file stays on disk).
44
+ 9. Registry writes (OOM fix): `index.ts` `persist` signs the registry with `orch/persist-signature.ts` `registrySignature`, which leaves a starting or running thread's status line and partial output out of the change signature. Upstream wrote the whole registry (every thread, about 12 KB each) on every status change; pi never rewrites session entries, so one session grew to 2.3 GB (5,909 snapshots) and pi died loading it. Writes now happen when a thread is added or removed, starts, settles, pauses or is stopped.
@@ -1,21 +1,31 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
1
+ // biome-ignore-all lint/suspicious/noUnnecessaryConditions: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
2
+ // biome-ignore-all lint/suspicious/useAwait: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
3
+ // biome-ignore-all lint/complexity/noVoid: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
4
+ // biome-ignore-all lint/style/noNestedTernary: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
5
+ // biome-ignore-all lint/style/noNonNullAssertion: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
6
+ // biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: vendored upstream TUI/config code with no tests; rewriting it risks behaviour (see src/subagents/VENDORED.md)
2
7
  import { randomUUID } from "node:crypto";
3
- import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
4
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
5
10
  import { fuzzyFilter } from "@earendil-works/pi-tui";
6
- import { ConfigStore } from "./prefs/config.ts";
7
- import { IMPORT_REQUEST_PREFIX, markImportOffered, offerAgentImport } from "./prefs/agent-import.ts";
11
+ import { createInheritedToolSource } from "./orch/inherited-tools.ts";
8
12
  import { ThreadManager } from "./orch/manager.ts";
13
+ import { registrySignature } from "./orch/persist-signature.ts";
14
+ import { subagentPrompt } from "./orch/prompt.ts";
15
+ import { createDriverFactory } from "./orch/runtime.ts";
16
+ import { agentTools } from "./orch/tools.ts";
17
+ import {
18
+ IMPORT_REQUEST_PREFIX,
19
+ markImportOffered,
20
+ offerAgentImport,
21
+ } from "./prefs/agent-import.ts";
22
+ import { ConfigStore } from "./prefs/config.ts";
9
23
  import { loadManagerSettings } from "./prefs/settings.ts";
24
+ import type { SavedThread, ThreadEvent } from "./types.ts";
25
+ import { AgentNavigationEditor } from "./ui/agent-navigation-editor.ts";
10
26
  import { configureAgents } from "./ui/settings-ui.ts";
11
27
  import { AgentNavigationController, showAgentStatus } from "./ui/status-ui.ts";
12
- import { AgentNavigationEditor } from "./ui/agent-navigation-editor.ts";
13
- import { createDriverFactory } from "./orch/runtime.ts";
14
- import { createInheritedToolSource } from "./orch/inherited-tools.ts";
15
- import { subagentPrompt } from "./orch/prompt.ts";
16
- import { agentTools } from "./orch/tools.ts";
17
28
  import { editAgentTypes, updateWidget } from "./ui/ui.ts";
18
- import type { SavedThread, ThreadEvent } from "./types.ts";
19
29
 
20
30
  const REGISTRY_ENTRY = "pi-subagent:registry:v1";
21
31
  const ROOT_MAILBOX_ENTRY = "pi-subagent:root-mailbox:v1";
@@ -81,32 +91,55 @@ export default function piSubagent(pi: ExtensionAPI): void {
81
91
  if (existingFactory && existingFactory !== navigationEditorFactory) return;
82
92
  // The host only transfers raw text, not cursor/undo/expanded-paste state.
83
93
  if (ctx.ui.getEditorText?.()) return;
84
- const history = buildSessionContext(ctx.sessionManager.getBranch()).messages.flatMap((message) => {
85
- if (message.role !== "user") return [];
86
- const content = message.content;
87
- return [typeof content === "string" ? content : content
88
- .filter((block) => block.type === "text").map((block) => block.text).join("")];
89
- });
94
+ const history = buildSessionContext(ctx.sessionManager.getBranch()).messages.flatMap(
95
+ (message) => {
96
+ if (message.role !== "user") return [];
97
+ const content = message.content;
98
+ return [
99
+ typeof content === "string"
100
+ ? content
101
+ : content
102
+ .filter((block) => block.type === "text")
103
+ .map((block) => block.text)
104
+ .join(""),
105
+ ];
106
+ },
107
+ );
90
108
  navigationEditorFactory = (tui, theme, keys) => {
91
109
  const editor = new AgentNavigationEditor(tui, theme, keys, {
92
110
  // Initial startup hydrates after session_start; replacement editors do not.
93
- initialHistory: seedHistory ? history : undefined,
111
+ ...(seedHistory ? { initialHistory: history } : {}),
94
112
  generation: () => navigationGeneration,
95
- canOpen: () => manager !== undefined && context?.mode === "tui" &&
96
- limits.subagentMode !== "off" && !navigation.isOpen &&
97
- manager.list().some((thread) => thread.path !== "/root" &&
98
- (thread.state === "starting" || thread.state === "running")),
99
- openTree: () => manager && context
100
- ? navigation.open(context, manager.scope("/root"), undefined, limits.nerdFontIcons) : undefined,
101
- canCollapse: () => manager !== undefined && context?.mode === "tui" &&
102
- limits.subagentMode !== "off" && !navigation.isOpen && !widgetCollapsed &&
103
- limits.widgetMode === "full" && widgetThreads().some((thread) => thread.path !== "/root"),
113
+ canOpen: () =>
114
+ manager !== undefined &&
115
+ context?.mode === "tui" &&
116
+ limits.subagentMode !== "off" &&
117
+ !navigation.isOpen &&
118
+ manager
119
+ .list()
120
+ .some(
121
+ (thread) =>
122
+ thread.path !== "/root" &&
123
+ (thread.state === "starting" || thread.state === "running"),
124
+ ),
125
+ openTree: () =>
126
+ manager && context
127
+ ? navigation.open(context, manager.scope("/root"), undefined, limits.nerdFontIcons)
128
+ : undefined,
129
+ canCollapse: () =>
130
+ manager !== undefined &&
131
+ context?.mode === "tui" &&
132
+ limits.subagentMode !== "off" &&
133
+ !navigation.isOpen &&
134
+ !widgetCollapsed &&
135
+ limits.widgetMode === "full" &&
136
+ widgetThreads().some((thread) => thread.path !== "/root"),
104
137
  collapseWidget: () => {
105
138
  widgetCollapsed = true;
106
139
  if (context) refreshWidget(context);
107
140
  },
108
- onError: (error) => context?.ui.notify(
109
- error instanceof Error ? error.message : String(error), "error"),
141
+ onError: (error) =>
142
+ context?.ui.notify(error instanceof Error ? error.message : String(error), "error"),
110
143
  });
111
144
  navigationEditor = editor;
112
145
  return editor;
@@ -120,7 +153,10 @@ export default function piSubagent(pi: ExtensionAPI): void {
120
153
  // Migration requires the root's file tools and must not delegate source prompts.
121
154
  if (limits.subagentMode === "orchestration") {
122
155
  if (!firstRun)
123
- ctx.ui.notify("Agent import requires Opportunistic mode. Change Subagent Mode in /agents settings.", "info");
156
+ ctx.ui.notify(
157
+ "Agent import requires Opportunistic mode. Change Subagent Mode in /agents settings.",
158
+ "info",
159
+ );
124
160
  return;
125
161
  }
126
162
  if (importInProgress) return;
@@ -140,12 +176,15 @@ export default function piSubagent(pi: ExtensionAPI): void {
140
176
  if (!manager) throw new Error("Subagent threads are not initialized; start a Pi session first");
141
177
  return manager;
142
178
  };
143
- const widgetThreads = () => requireManager().list().filter((thread) => {
144
- // Resuming a retained session makes it visible for this task, even after it settles again.
145
- if (thread.state === "starting" || thread.state === "running")
146
- hiddenWidgetThreads.delete(thread.path);
147
- return !hiddenWidgetThreads.has(thread.path);
148
- });
179
+ const widgetThreads = () =>
180
+ requireManager()
181
+ .list()
182
+ .filter((thread) => {
183
+ // Resuming a retained session makes it visible for this task, even after it settles again.
184
+ if (thread.state === "starting" || thread.state === "running")
185
+ hiddenWidgetThreads.delete(thread.path);
186
+ return !hiddenWidgetThreads.has(thread.path);
187
+ });
149
188
  const requireContext = () => {
150
189
  if (!context) throw new Error("No active Pi session");
151
190
  return context;
@@ -153,7 +192,7 @@ export default function piSubagent(pi: ExtensionAPI): void {
153
192
  const persist = () => {
154
193
  if (!manager) return;
155
194
  const threads = manager.saved();
156
- const signature = JSON.stringify(threads);
195
+ const signature = registrySignature(threads);
157
196
  if (signature !== persistenceSignature) {
158
197
  pi.appendEntry(REGISTRY_ENTRY, {
159
198
  version: 1,
@@ -187,44 +226,72 @@ export default function piSubagent(pi: ExtensionAPI): void {
187
226
  };
188
227
  const pendingSummaries = (ctx: ExtensionContext) => {
189
228
  const entries = ctx.sessionManager.getBranch();
190
- const summarized = new Set(entries.flatMap((entry) => {
191
- if (entry.type !== "custom_message" || entry.customType !== ROOT_SUMMARY_MESSAGE) return [];
192
- const ids = (entry.details as { mailboxIds?: unknown } | undefined)?.mailboxIds;
193
- return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
194
- }));
229
+ const summarized = new Set(
230
+ entries.flatMap((entry) => {
231
+ if (entry.type !== "custom_message" || entry.customType !== ROOT_SUMMARY_MESSAGE) return [];
232
+ const ids = (entry.details as { mailboxIds?: unknown } | undefined)?.mailboxIds;
233
+ return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
234
+ }),
235
+ );
195
236
  return entries.flatMap((entry) => {
196
237
  if (entry.type !== "custom" || entry.customType !== ROOT_MAILBOX_ENTRY) return [];
197
238
  const notification = entry.data as RootNotification | undefined;
198
239
  return notification?.rootSessionId === ctx.sessionManager.getSessionId() &&
199
- notification.finalRecap === true && typeof notification.content === "string" &&
240
+ notification.finalRecap === true &&
241
+ typeof notification.content === "string" &&
200
242
  typeof notification.details?.mailboxId === "string" &&
201
- !summarized.has(notification.details.mailboxId) ? [notification] : [];
243
+ !summarized.has(notification.details.mailboxId)
244
+ ? [notification]
245
+ : [];
202
246
  });
203
247
  };
204
248
  const scheduleSummary = (ctx: ExtensionContext) => {
205
- if (!limits.finalRecap || limits.subagentMode === "off" || summaryRunning || suspendingSummaries ||
206
- summaryTimer !== undefined || ctx.isIdle() === false || !pendingSummaries(ctx).length) return;
249
+ if (
250
+ !limits.finalRecap ||
251
+ limits.subagentMode === "off" ||
252
+ summaryRunning ||
253
+ suspendingSummaries ||
254
+ summaryTimer !== undefined ||
255
+ ctx.isIdle() === false ||
256
+ pendingSummaries(ctx).length === 0
257
+ )
258
+ return;
207
259
  const currentGeneration = generation;
208
260
  // Coalesce siblings finishing together and leave the settlement boundary before
209
261
  // starting a new turn. Recheck settings/session/idle state after the deferral.
210
262
  summaryTimer = setTimeout(() => {
211
263
  summaryTimer = undefined;
212
- if (currentGeneration !== generation || context?.sessionManager !== ctx.sessionManager ||
213
- !limits.finalRecap || limits.subagentMode === "off" || suspendingSummaries || ctx.isIdle() === false) return;
264
+ if (
265
+ currentGeneration !== generation ||
266
+ context?.sessionManager !== ctx.sessionManager ||
267
+ !limits.finalRecap ||
268
+ limits.subagentMode === "off" ||
269
+ suspendingSummaries ||
270
+ ctx.isIdle() === false
271
+ )
272
+ return;
214
273
  const notifications = pendingSummaries(ctx);
215
- if (!notifications.length) return;
274
+ if (notifications.length === 0) return;
216
275
  summaryRunning = true;
217
276
  try {
218
- pi.sendMessage({
219
- customType: ROOT_SUMMARY_MESSAGE,
220
- content: "Summarize the newly finished asynchronous subagent results for the user. " +
221
- "Their results are in the preceding subagent notifications; use agent_output if more detail is needed. " +
222
- "Give a concise main-thread answer covering results and any failures. " +
223
- "Do not spawn or resume agents solely to summarize.\nAgents: " +
224
- [...new Set(notifications.map((notification) => notification.details.path))].join(", "),
225
- display: false,
226
- details: { mailboxIds: notifications.map((notification) => notification.details.mailboxId) },
227
- }, { triggerTurn: true });
277
+ pi.sendMessage(
278
+ {
279
+ customType: ROOT_SUMMARY_MESSAGE,
280
+ content:
281
+ "Summarize the newly finished asynchronous subagent results for the user. " +
282
+ "Their results are in the preceding subagent notifications; use agent_output if more detail is needed. " +
283
+ "Give a concise main-thread answer covering results and any failures. " +
284
+ "Do not spawn or resume agents solely to summarize.\nAgents: " +
285
+ [...new Set(notifications.map((notification) => notification.details.path))].join(
286
+ ", ",
287
+ ),
288
+ display: false,
289
+ details: {
290
+ mailboxIds: notifications.map((notification) => notification.details.mailboxId),
291
+ },
292
+ },
293
+ { triggerTurn: true },
294
+ );
228
295
  } catch (error) {
229
296
  summaryRunning = false;
230
297
  warnDelivery(error);
@@ -278,7 +345,10 @@ export default function piSubagent(pi: ExtensionAPI): void {
278
345
  content: message,
279
346
  // Foreground results already handled by a busy main thread do not need
280
347
  // another turn. Completions during a summary do need a follow-up summary.
281
- finalRecap: limits.finalRecap && !suspendingSummaries && event.kind === "settled" &&
348
+ finalRecap:
349
+ limits.finalRecap &&
350
+ !suspendingSummaries &&
351
+ event.kind === "settled" &&
282
352
  (thread.state === "completed" || thread.state === "failed") &&
283
353
  (requireContext().isIdle() || summaryRunning),
284
354
  details: {
@@ -335,8 +405,14 @@ export default function piSubagent(pi: ExtensionAPI): void {
335
405
  const refreshWidget = (ctx: ExtensionContext) => {
336
406
  if (limits.subagentMode === "off") {
337
407
  if (ctx.hasUI) ctx.ui.setWidget("pi-subagent", undefined);
338
- } else updateWidget(ctx, widgetThreads(), widgetCollapsed ? "minimal" : limits.widgetMode,
339
- limits.nerdFontIcons, rootTurnEnded);
408
+ } else
409
+ updateWidget(
410
+ ctx,
411
+ widgetThreads(),
412
+ widgetCollapsed ? "minimal" : limits.widgetMode,
413
+ limits.nerdFontIcons,
414
+ rootTurnEnded,
415
+ );
340
416
  };
341
417
  pi.on("agent_start", async (_event, ctx) => {
342
418
  // A new task must not resurrect the previous task's settled agents. Automatic
@@ -356,7 +432,7 @@ export default function piSubagent(pi: ExtensionAPI): void {
356
432
  // A migration can span clarification turns. Reload after each root turn, without converting files.
357
433
  if (limits.subagentMode === "off" || !migrationRequested) return;
358
434
  store.reload();
359
- if (store.diagnostics.length) context?.ui.notify(store.diagnostics.join("\n"), "warning");
435
+ if (store.diagnostics.length > 0) context?.ui.notify(store.diagnostics.join("\n"), "warning");
360
436
  });
361
437
  syncTools();
362
438
  pi.on("before_agent_start", async (event, ctx) => {
@@ -369,12 +445,17 @@ export default function piSubagent(pi: ExtensionAPI): void {
369
445
  ctx.ui.notify(`Could not save import onboarding state: ${String(error)}`, "warning");
370
446
  }
371
447
  }
372
- return {
373
- systemPrompt: `${event.systemPrompt}\n\n${prompt}`,
374
- };
448
+ // A section, not a returned `systemPrompt`: returning one makes pi force the whole prompt as
449
+ // the head of every request, so any change to our state section would miss the prompt cache.
450
+ event.systemPromptOptions.sections["pi-subagent"] = prompt;
451
+ return undefined;
375
452
  });
376
453
 
377
- const attachSession = async (ctx: ExtensionContext, installEditor = false, seedHistory = true) => {
454
+ const attachSession = async (
455
+ ctx: ExtensionContext,
456
+ installEditor = false,
457
+ seedHistory = true,
458
+ ) => {
378
459
  cancelSummary();
379
460
  const token = ++generation;
380
461
  closeNavigation(installEditor);
@@ -437,7 +518,7 @@ export default function piSubagent(pi: ExtensionAPI): void {
437
518
  restoreRootMailbox(ctx);
438
519
  refreshWidget(ctx);
439
520
  const diagnostics = [...store.diagnostics, ...settingsDiagnostics];
440
- if (limits.subagentMode !== "off" && diagnostics.length)
521
+ if (limits.subagentMode !== "off" && diagnostics.length > 0)
441
522
  ctx.ui.notify(diagnostics.join("\n"), "warning");
442
523
  };
443
524
  pi.on("session_start", async (event, ctx) => {
@@ -511,10 +592,11 @@ export default function piSubagent(pi: ExtensionAPI): void {
511
592
  refreshWidget(ctx);
512
593
  restoreRootMailbox(ctx);
513
594
  ctx.ui.notify(
514
- diagnostics.length
595
+ diagnostics.length > 0
515
596
  ? diagnostics.join("\n")
516
597
  : `Loaded ${store.list().length} agent types; maximum ${limits.maxLevels} levels`,
517
- diagnostics.length ? "warning" : "info",
598
+
599
+ diagnostics.length > 0 ? "warning" : "info",
518
600
  );
519
601
  } else if (!command || command === "settings") {
520
602
  await configureAgents(ctx, {
@@ -527,13 +609,23 @@ export default function piSubagent(pi: ExtensionAPI): void {
527
609
  syncTools();
528
610
  refreshWidget(ctx);
529
611
  restoreRootMailbox(ctx);
530
- if (diagnostics.length) ctx.ui.notify(diagnostics.join("\n"), "warning");
612
+ if (diagnostics.length > 0) ctx.ui.notify(diagnostics.join("\n"), "warning");
531
613
  },
532
614
  });
533
615
  } else if (command === "status") {
534
- await showAgentStatus(ctx, requireManager().scope("/root"), undefined, limits.nerdFontIcons);
616
+ await showAgentStatus(
617
+ ctx,
618
+ requireManager().scope("/root"),
619
+ undefined,
620
+ limits.nerdFontIcons,
621
+ );
535
622
  } else if (command === "tree") {
536
- await navigation.open(ctx, requireManager().scope("/root"), rest.join(" ") || undefined, limits.nerdFontIcons);
623
+ await navigation.open(
624
+ ctx,
625
+ requireManager().scope("/root"),
626
+ rest.join(" ") || undefined,
627
+ limits.nerdFontIcons,
628
+ );
537
629
  } else
538
630
  ctx.ui.notify(
539
631
  "Usage: /agents [tree [path] | status | settings | types | import | reload]",
@@ -0,0 +1,24 @@
1
+ import type { SavedThread } from "../types.ts";
2
+
3
+ const LIVE = new Set(["starting", "running"]);
4
+
5
+ /**
6
+ * What decides whether the registry needs writing again.
7
+ *
8
+ * pi appends every `appendEntry` to the session file and never rewrites it, and each
9
+ * registry entry holds every thread (about 12 KB each). A running thread changes its
10
+ * status line on every tool call, so signing the full snapshot wrote hundreds of
11
+ * kilobytes per tool call: one session reached 2.3 GB and pi ran out of memory loading
12
+ * it. A live thread's status and output are not needed to restore it (a reload marks it
13
+ * interrupted), so they stay out of the signature. Starting, settling, pausing and
14
+ * adding or removing a thread still write.
15
+ */
16
+ export function registrySignature(threads: readonly SavedThread[]): string {
17
+ return JSON.stringify(
18
+ threads.map((thread) =>
19
+ LIVE.has(thread.view.state)
20
+ ? { ...thread, view: { ...thread.view, status: "", output: undefined } }
21
+ : thread,
22
+ ),
23
+ );
24
+ }