@esso0428/pi-subagents 0.17.33 → 0.17.35

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/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.35] - 2026-10-05
11
+
12
+ ### Added
13
+ - **Scope agent history and the roster to the current session**: agent records now carry the session that spawned them, so `/agents` offers `Agent history this session` alongside `Agent history all sessions`, and the above-editor roster shows only this session's agents. Records restored from a session branch are stamped with the current session — a fork continues its parent's context, so those agents count as present here — while records reloaded from project checkpoints keep their own stamp, because those files mix every session that ran in the directory.
14
+ - **Legacy session history**: `/agents` → `Agent history all sessions` also recovers `subagents:record` entries written by earlier builds, so history from before durable checkpoints stayed reachable. Nothing writes those entries any more, and the scan is scoped to the project's own session directory.
15
+
16
+ ### Changed
17
+ - **Stopped appending `subagents:record` to the session branch on completion**: durable checkpoints supersede it. They survive reboot, record the owning session, and do not grow the parent session file. The entry is still read for backward compatibility.
18
+
19
+ ### Fixed
20
+ - **Orphaned nested rows keep their place**: a nested child whose parent record was removed keeps its original indent and says its parent is gone, instead of being promoted to a top-level row.
21
+
22
+ ## [0.17.34] - 2026-10-05
23
+
24
+ ### Added
25
+ - **Documented nested subagents**: README now carries the feature entry, the `allowed_subagents` frontmatter row, and a `### Nested subagents` section covering the allowlist as a privilege boundary, depth cap, ownership scoping, pool behaviour, transcript durability, and how this fork diverges from upstream by rendering children as a navigable subtree. Also records that the mechanism is hand-authored and deliberately absent from the tool description, so a model never grants itself delegation.
26
+
10
27
  ## [0.17.33] - 2026-10-05
11
28
 
12
29
  ### Fixed
package/README.md CHANGED
@@ -17,6 +17,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
17
17
  - **Agents panel UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, colored status icons, and one focus-gated navigator. It shows every agent by default; press `↓` at an empty prompt to activate the panel, `↑`/`↓` to select, `Enter` to open a live or read-only history viewer, and `Esc` to return. Long rosters reserve a right-hand track/thumb scrollbar and show `↑ N more` or `↓ N more` at the clipped edge. Configure via `/agents → Settings → Widget`: `all`, `background`, or `off`
18
18
  - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). The main viewer reserves a right-hand scrollbar rail for the transcript and exposes a `[preview]` header action for the focused tool. Steer a running agent inline by pressing `e` to open a Pi-native composer, typing, then `Enter` to send (`Esc` or an empty submit returns); the follow-up appears as a muted/gray USER message and redirects the agent after its current tool. `Alt+Up` (`a-up`/`alt+up`) recalls submitted follow-up drafts. Read-only tool previews replace the viewer in place, use the larger viewport with the same right-hand track/thumb scrollbar and top/bottom hidden-line affordances, and return with `Esc`, `q`, or the close control. Stop a still-running agent by pressing `x` (then `x` again to confirm) — both work for background agents too
19
19
  - **Custom agent types** — define agents in `.pi/agents/<name>.md` or `.agents/agents/<name>.md` (project) or globally, with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
20
+ - **Nested subagents** — opt-in, default-off delegation: an agent whose frontmatter sets `allowed_subagents` gets its own ownership-scoped `Agent`, a blocking `wait_for_nested_agent`, and a scoped `steer_subagent`, depth-capped from the main session (default 2). Unlike upstream, nested children are **not** hidden — the agents roster renders them as an indented subtree you can navigate into. The allowlist is a privilege boundary, so it is set by hand in the agent file and never enabled automatically
20
21
  - **Mid-run steering** — inject messages into running agents to redirect their work without restarting
21
22
  - **Session resume** — pick up where an agent left off, preserving full conversation context
22
23
  - **Durable interruption recovery** — catchable shutdowns preserve running/queued metadata and partial transcripts so interrupted agents remain indexed after reload; abrupt `SIGKILL` termination cannot be checkpointed
@@ -246,6 +247,7 @@ All fields are optional — sensible defaults for everything.
246
247
  | `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload (see [Skill Preloading](#skill-preloading) for discovery locations) |
247
248
  | `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
248
249
  | `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
250
+ | `allowed_subagents` | none | Opt in to scoped nested `Agent`, `wait_for_nested_agent`, and `steer_subagent` tools. Omitted / empty / `none` / `false` = no nesting; `all` (or `"*"` / `true`) = any enabled agent; comma-separated list = only those agent types |
249
251
  | `isolation` | — | Set to `worktree` to run in an isolated git worktree |
250
252
  | `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`). Resolved tolerantly (`.`/`-` and a trailing date stamp are interchangeable) and falls back to the same model under another provider if the named one doesn't have it |
251
253
  | `thinking` | inherit | off, minimal, low, medium, high, xhigh, max — actual availability depends on your pi version and model; pi clamps unsupported levels down |
@@ -300,6 +302,69 @@ A few rules the examples don't make obvious:
300
302
  - `exclude_extensions:` is **not a sandbox**: excluded extensions' factory code still executes once during loading. Exclusion suppresses their tools and their bound lifecycle hooks (`pi.on` handlers like `session_start` only fire for extensions bound to the session), but not other load-time side effects — a factory that subscribes directly to the shared `pi.events` bus stays live. Don't rely on it to contain an untrusted extension.
301
303
  - Array and string forms are equivalent: `[a, b]` == `"a, b"`.
302
304
 
305
+ ### Nested subagents
306
+
307
+ Nested delegation is default-off and hand-authored. An agent that owns a real
308
+ fan-out responsibility opts in by setting `allowed_subagents` in its own
309
+ frontmatter:
310
+
311
+ ```yaml
312
+ # .pi/agents/support-coordinator.md
313
+ ---
314
+ name: support-coordinator
315
+ description: Coordinates support-triage work across several areas
316
+ allowed_subagents: support-file-finder, support-callsite-tracer
317
+ tools: read, grep, find, ls
318
+ ---
319
+ ```
320
+
321
+ Omitted, empty, `none`, or `false` means no nested tools are injected at all.
322
+ `all` (or `"*"` / `true`) allows any enabled agent; a comma-separated list
323
+ restricts nesting to exactly those types. Unknown, disabled, and out-of-list
324
+ types are **rejected**, never fallen back to — a configured fallback agent
325
+ cannot hand a nested caller something outside its allowlist.
326
+
327
+ **The allowlist is a privilege boundary.** A nested child runs with its own tool
328
+ set, so choose it as carefully as you would `tools:`. It is set by hand in the
329
+ agent file and is never enabled automatically; no skill or tool description
330
+ teaches a model to grant itself delegation.
331
+
332
+ A nested child receives an ownership-scoped `Agent`, a `wait_for_nested_agent`
333
+ that always blocks until that child finishes, and a `steer_subagent` scoped to
334
+ its own children. Result, resume, and steer are ownership-checked, so a parent
335
+ cannot read, steer, or resume a foreign child. `maxSubagentDepth` caps how deep
336
+ nesting goes (default 2: main session 0, its subagents 1, nested children 2);
337
+ an agent already at the cap receives no nested tools at all — not even
338
+ `wait_for_nested_agent` — since it can never own a child. Change it project-wide
339
+ via `maxSubagentDepth` in `subagents.json` or `/agents` → Settings → Nested
340
+ depth. Nested children occupy no concurrency slot: their parent already holds
341
+ one, and queueing a child behind its own parent would deadlock.
342
+
343
+ Nested children consume **no** concurrency slot and are never reported to the
344
+ main session as top-level agents — their completion surfaces inside the parent,
345
+ and their token usage folds into every ancestor's total. Each still writes its
346
+ own durable transcript.
347
+
348
+ ### How nested children are shown
349
+
350
+ Upstream hides nested children from every surface. This fork keeps the same
351
+ reporting semantics but makes the hierarchy visible: the agents roster renders
352
+ children as an indented subtree, each with its own spinner and activity line,
353
+ token counts marked `(in parent)` so totals are not double-counted, and a
354
+ `nested blocked: …` note when the allowlist or depth cap refuses a dispatch.
355
+ `↑`/`↓` move across every visible row regardless of depth and `Enter` opens that
356
+ level's conversation viewer. Because the lookup is case-insensitive, an override
357
+ named `Explore` also changes what a lowercase `explore` request resolves to.
358
+
359
+ The `parentAgentId` and `depth` of a nested child are recorded in its recovery
360
+ checkpoint, so a reload rebuilds the same subtree rather than flattening it. If
361
+ a parent record is ever removed before its child, the child keeps its indent and
362
+ says its parent is gone.
363
+
364
+ `subagents.agentOverrides` applies to nested dispatch exactly as it does at
365
+ top level, including the project-over-global precedence described in
366
+ [Priority Chain](#priority-chain).
367
+
303
368
  ## `npm:pi-subagents`-Style JSON Agent Overrides
304
369
 
305
370
  This fork adds the ability to configure — and even create — agents entirely through JSON,
package/ROADMAP.md CHANGED
@@ -12,6 +12,18 @@
12
12
  - v0.17.6 的 ConversationViewer baseline(scrollbar rail、`[preview]`/`[Esc]`、`w` preview、in-place read-only Tool Output、鍵盤與 wheel scroll)不可被改寫
13
13
  - 資源安全政策不因功能恢復而撤回:Vitest / E2E / build 在此裝置需明確授權
14
14
 
15
+ ## 未來:讓模型知道 nested 怎麼用
16
+
17
+ `allowed_subagents` 目前只能由人手寫進 agent 檔案。上游刻意如此 — 它的 `Agent` 工具描述、`promptGuidelines`、`skills/`、範例 agent 檔全都沒有提 nested,所以模型不會自己開自己的權限。這是對的安全立場,不是缺陷。
18
+
19
+ 若要改善可用性,安全作法是**只教模型怎麼「建議」**,不給它自己開的權限:
20
+
21
+ - 在建立/編輯 agent 檔案的流程裡提示 `allowed_subagents` 的存在與語意(`all` / 逗號清單 / 省略 = 不開)
22
+ - 明確標示這是授權邊界,該由人確認,不接受模型自行套用
23
+ - 不在 `Agent` 工具描述或 `promptGuidelines` 中指示模型對既有 agent 開啟巢狀
24
+
25
+ 設計前提未定:這段提示該放哪(工具描述、skill、`/agents` 建立精靈的表單、還是 agent 檔的模板註解),以及如何避免模型把「建議」當成「已授權」。
26
+
15
27
  ## 交付 B:Workflow
16
28
 
17
29
  上游規模約 6,200 行:`src/workflow/**` 11 檔約 4,458 行(`runtime.ts` 1,219 + `worker-source.ts` 781 為骨幹)、`ui/workflow-*.ts` 3 檔約 1,778 行(`workflow-dialog.ts` 1,115 最大)。含 `node:vm` sandbox、worker thread、journal,是新的執行模型而非 UI,風險等級與 nested 不同,不與其他交付合併。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esso0428/pi-subagents",
3
- "version": "0.17.33",
3
+ "version": "0.17.35",
4
4
  "description": "A pi extension that brings smart Claude Code-style autonomous sub-agents to pi, with npm:pi-subagents-style JSON agent overrides.",
5
5
  "author": "ESSO0428",
6
6
  "repository": {
@@ -82,10 +82,18 @@ export function formatAgentHistoryOption(record: AgentRecord, now: number): stri
82
82
  export function buildAgentStatusMenuEntries(
83
83
  records: readonly AgentRecord[],
84
84
  cwd: string | undefined,
85
+ sessionId?: string,
85
86
  ): string[] {
86
87
  const { active, history } = splitAgentRecords(records, cwd);
88
+ // This-session history is what you almost always want, so it is the default
89
+ // entry. Project-wide history stays one keystroke away for the occasional
90
+ // "which agent was that, two sessions ago" lookup. Records restored from a
91
+ // branch or checkpoint are stamped with the current session, so an inherited
92
+ // agent counts as present here rather than disappearing into the other bucket.
93
+ const inSession = sessionId ? history.filter((record) => record.sessionId === sessionId) : [];
87
94
  return [
88
95
  ...(active.length > 0 ? [`Running agents (${active.length})`] : []),
89
- ...(history.length > 0 ? [`Agent history (${history.length})`] : []),
96
+ ...(inSession.length > 0 ? [`Agent history this session (${inSession.length})`] : []),
97
+ ...(history.length > 0 ? [`Agent history all sessions (${history.length})`] : []),
90
98
  ];
91
99
  }
@@ -159,6 +159,8 @@ interface SpawnArgs {
159
159
  }
160
160
 
161
161
  export interface SpawnOptions extends Partial<Omit<NestedSpawnOptions, "description" | "signal" | "onAssistantUsage" | "onSessionCreated">> {
162
+ /** Overrides the session stamp applied at spawn (used by restore paths). */
163
+ sessionId?: string;
162
164
  description: string;
163
165
  model?: Model<any>;
164
166
  maxTurns?: number;
@@ -284,6 +286,7 @@ export class AgentManager {
284
286
  // flattens, because nothing on disk records the nesting.
285
287
  ...(record.parentAgentId !== undefined && { parentAgentId: record.parentAgentId }),
286
288
  ...(record.depth !== undefined && { depth: record.depth }),
289
+ ...(record.sessionId !== undefined && { sessionId: record.sessionId }),
287
290
  };
288
291
  return checkpoint;
289
292
  }
@@ -341,6 +344,10 @@ export class AgentManager {
341
344
  this.recoveryCwds.set(checkpoint.id, cwd);
342
345
  continue;
343
346
  }
347
+ // No session stamp here: these files are project-level and mix every
348
+ // session that ever ran in this directory. Re-stamping them would make
349
+ // "this session" mean "everything", which is the thing the scope exists to
350
+ // avoid. Records with no stamp stay reachable under the all-sessions entry.
344
351
  this.agents.set(checkpoint.id, this.createRestoredRecord({
345
352
  ...checkpoint,
346
353
  status,
@@ -391,6 +398,7 @@ export class AgentManager {
391
398
  parentDescription: options.parentAgentId ? this.agents.get(options.parentAgentId)?.description : undefined,
392
399
  maxSubagentDepth: options.maxSubagentDepth,
393
400
  rootSessionId: options.rootSessionId ?? ctx.sessionManager?.getSessionId?.(),
401
+ sessionId: options.sessionId ?? ctx.sessionManager?.getSessionId?.(),
394
402
  liveActivity: {
395
403
  activeTools: new Map(),
396
404
  responseText: "",
@@ -780,11 +788,16 @@ export class AgentManager {
780
788
  }
781
789
 
782
790
  /** Restore terminal records persisted by a parent branch without runtime handles. */
783
- restoreCompleted(records: readonly unknown[]): void {
791
+ /**
792
+ * Restore terminal records carried by a parent branch. A fork continues its
793
+ * parent's context, so those agents count as present in this session and are
794
+ * stamped with the current session id rather than the one that spawned them.
795
+ */
796
+ restoreCompleted(records: readonly unknown[], sessionId?: string): void {
784
797
  const latest = new Map<string, ReturnType<typeof this.createRestoredRecord>>();
785
798
  for (const value of records) {
786
799
  if (isRestorableAgentRecord(value)) {
787
- latest.set(value.id, this.createRestoredRecord(value));
800
+ latest.set(value.id, this.createRestoredRecord({ ...value, sessionId }));
788
801
  }
789
802
  }
790
803
 
@@ -811,6 +824,7 @@ export class AgentManager {
811
824
  compactionCount?: number;
812
825
  parentAgentId?: string;
813
826
  depth?: number;
827
+ sessionId?: string;
814
828
  }): AgentRecord {
815
829
  return {
816
830
  id: record.id,
@@ -830,6 +844,7 @@ export class AgentManager {
830
844
  compactionCount: record.compactionCount ?? 0,
831
845
  parentAgentId: record.parentAgentId,
832
846
  depth: record.depth,
847
+ sessionId: record.sessionId,
833
848
  };
834
849
  }
835
850
 
@@ -36,6 +36,13 @@ export interface AgentRecoveryCheckpoint {
36
36
  parentAgentId?: string;
37
37
  /** Nesting depth of a nested child; absent on top-level agents. */
38
38
  depth?: number;
39
+ /**
40
+ * Session this record was spawned in. Absent on older checkpoints, and
41
+ * re-stamped with the *current* session when a branch or checkpoint restore
42
+ * brings the record forward, so "this session" means "present in this session"
43
+ * rather than "originally spawned here".
44
+ */
45
+ sessionId?: string;
39
46
  }
40
47
 
41
48
  function isSafeString(value: unknown, maxLength: number): value is string {
@@ -104,6 +111,7 @@ export function isAgentRecoveryCheckpoint(value: unknown): value is AgentRecover
104
111
  || (checkpoint.compactionCount as number) < 0) return false;
105
112
 
106
113
  if (checkpoint.parentAgentId !== undefined && !isSafeString(checkpoint.parentAgentId, 256)) return false;
114
+ if (checkpoint.sessionId !== undefined && !isSafeString(checkpoint.sessionId, 256)) return false;
107
115
  if (checkpoint.depth !== undefined && (!Number.isInteger(checkpoint.depth) || (checkpoint.depth as number) < 0)) return false;
108
116
 
109
117
  const status = checkpoint.status as AgentRecoveryStatus;
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { loadCustomAgents } from "./custom-agents.js";
26
26
  import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
27
27
  import { GroupJoinManager } from "./group-join.js";
28
28
  import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
29
+ import { readLegacySessionRecords } from "./legacy-session-records.js";
29
30
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
30
31
  import { isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
31
32
  import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
@@ -443,21 +444,10 @@ export default function (pi: ExtensionAPI) {
443
444
  pi.events.emit("subagents:completed", eventData);
444
445
  }
445
446
 
446
- // Persist final record for cross-extension history reconstruction
447
- pi.appendEntry("subagents:record", {
448
- id: record.id, type: record.type, description: record.description,
449
- status: record.status,
450
- // Durable transcripts are the source of truth for full output. Avoid
451
- // copying a potentially large result into the parent session branch;
452
- // get_subagent_result reloads it on demand after cleanup/restart.
453
- result: record.transcriptPath ? undefined : record.result,
454
- error: record.error,
455
- startedAt: record.startedAt, completedAt: record.completedAt,
456
- toolUses: record.toolUses,
457
- lifetimeUsage: record.lifetimeUsage,
458
- invocation: record.invocation,
459
- transcriptPath: record.transcriptPath,
460
- });
447
+ // Durable checkpoints replaced this entry: they survive reboot, carry the
448
+ // owning session, and do not bloat the parent session branch. It is still
449
+ // read for sessions recorded before that change, so history written by an
450
+ // older build stays reachable.
461
451
 
462
452
  // Explicit wait-group members never emit individual notifications. Result
463
453
  // consumption does not remove membership; the sealed group still delivers
@@ -590,10 +580,11 @@ export default function (pi: ExtensionAPI) {
590
580
  resetAgentMenuSelections();
591
581
  currentCtx = ctx;
592
582
  manager.clearCompleted(true);
583
+ const sessionId = ctx.sessionManager?.getSessionId?.();
593
584
  const branch = ctx.sessionManager?.getBranch?.() ?? [];
594
585
  manager.restoreCompleted(branch
595
586
  .filter((entry: any) => entry?.type === "custom" && entry?.customType === "subagents:record")
596
- .map((entry: any) => entry.data));
587
+ .map((entry: any) => entry.data), sessionId);
597
588
  // Checkpoint files cover agents whose parent session never got a terminal
598
589
  // branch entry (shutdown, session switch, or a process restart).
599
590
  manager.restoreRecovered(ctx.cwd);
@@ -666,6 +657,7 @@ export default function (pi: ExtensionAPI) {
666
657
  const ctx = currentCtx;
667
658
  if (ctx) void viewAgentConversation(ctx as ExtensionCommandContext, record, mode);
668
659
  },
660
+ getSessionId: () => currentCtx?.sessionManager?.getSessionId?.(),
669
661
  },
670
662
  );
671
663
 
@@ -1709,7 +1701,7 @@ Terse command-style prompts produce shallow, generic work.
1709
1701
  // Keep active agents and terminal history in separate menu entries.
1710
1702
  const records = manager.listAgents();
1711
1703
  const { active, history } = splitAgentRecords(records, ctx.cwd);
1712
- options.push(...buildAgentStatusMenuEntries(records, ctx.cwd));
1704
+ options.push(...buildAgentStatusMenuEntries(records, ctx.cwd, ctx.sessionManager?.getSessionId?.()));
1713
1705
 
1714
1706
  // Agent types list
1715
1707
  if (allNames.length > 0) {
@@ -1742,8 +1734,11 @@ Terse command-style prompts produce shallow, generic work.
1742
1734
  if (choice.startsWith("Running agents (")) {
1743
1735
  await showRunningAgents(ctx);
1744
1736
  await showAgentsMenu(ctx);
1745
- } else if (choice.startsWith("Agent history (")) {
1746
- await showAgentHistory(ctx);
1737
+ } else if (choice.startsWith("Agent history this session (")) {
1738
+ await showAgentHistory(ctx, "this-session");
1739
+ await showAgentsMenu(ctx);
1740
+ } else if (choice.startsWith("Agent history all sessions (")) {
1741
+ await showAgentHistory(ctx, "all-sessions");
1747
1742
  await showAgentsMenu(ctx);
1748
1743
  } else if (choice.startsWith("Agent types (")) {
1749
1744
  await showAllAgentsList(ctx);
@@ -1922,21 +1917,39 @@ Terse command-style prompts produce shallow, generic work.
1922
1917
  await showRunningAgents(ctx);
1923
1918
  }
1924
1919
 
1925
- async function showAgentHistory(ctx: ExtensionCommandContext) {
1920
+ async function showAgentHistory(ctx: ExtensionCommandContext, scope: "this-session" | "all-sessions" = "all-sessions") {
1926
1921
  const { history } = splitAgentRecords(manager.listAgents(), ctx.cwd);
1927
- if (history.length === 0) {
1928
- ctx.ui.notify("No agent history.", "info");
1922
+ const sessionId = ctx.sessionManager?.getSessionId?.();
1923
+ const scoped = scope === "this-session" && sessionId
1924
+ ? history.filter((record) => record.sessionId === sessionId)
1925
+ : history;
1926
+ if (scoped.length === 0) {
1927
+ ctx.ui.notify(scope === "this-session" ? "No agent history in this session." : "No agent history.", "info");
1929
1928
  return;
1930
1929
  }
1931
1930
 
1932
- const pairs = history.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
1931
+ // Sessions recorded before durable checkpoints only exist as
1932
+ // `subagents:record` entries in their session file. They are merged here for
1933
+ // the project-wide view; the this-session view never needs the scan.
1934
+ const merged = scope === "this-session"
1935
+ ? scoped
1936
+ : mergeLegacyRecords(scoped, await readLegacySessionRecords(ctx.cwd));
1937
+ const pairs = merged.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
1933
1938
  makeUniqueAgentOptionLabels(pairs);
1934
- const record = await selectAgentFromReadOnlyList(ctx, "Agent history", pairs, historyAgentSelection);
1939
+ const title = scope === "this-session" ? "Agent history — this session" : "Agent history — all sessions";
1940
+ const record = await selectAgentFromReadOnlyList(ctx, title, pairs, historyAgentSelection);
1935
1941
  if (!record) return;
1936
1942
 
1937
1943
  await viewAgentConversation(ctx, record, "history");
1938
1944
  // Back-navigation: re-show the list at the previously selected agent.
1939
- await showAgentHistory(ctx);
1945
+ await showAgentHistory(ctx, scope);
1946
+ }
1947
+
1948
+ /** In-memory records win; legacy session entries only fill genuine gaps. */
1949
+ function mergeLegacyRecords(current: readonly AgentRecord[], legacy: readonly AgentRecord[]): AgentRecord[] {
1950
+ const known = new Set(current.map((record) => record.id));
1951
+ return [...current, ...legacy.filter((record) => !known.has(record.id))]
1952
+ .sort((a, b) => b.startedAt - a.startedAt);
1940
1953
  }
1941
1954
 
1942
1955
  async function viewAgentConversation(
@@ -0,0 +1,159 @@
1
+ import { createReadStream, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { encodeCwd } from "./output-file.js";
5
+ import type { AgentInvocation, AgentRecord } from "./types.js";
6
+
7
+ /**
8
+ * Reads `subagents:record` entries out of session files.
9
+ *
10
+ * Older builds appended one such entry per finished agent to the parent session
11
+ * branch. Durable checkpoints superseded that: they survive reboot, carry the
12
+ * owning session, and do not bloat the session file. This reader exists purely
13
+ * so history written before that change stays reachable from `/agents`; nothing
14
+ * writes these entries any more.
15
+ *
16
+ * Session files can be very large, so each is streamed line by line and only
17
+ * lines mentioning the entry type are parsed.
18
+ */
19
+
20
+ const RECORD_MARKER = '"subagents:record"';
21
+ const SESSION_EXTENSION = ".jsonl";
22
+
23
+ /** `<timestamp>_<sessionId>.jsonl` — the sessionId is the part after the first underscore. */
24
+ function sessionIdFromFile(name: string): string | undefined {
25
+ if (!name.endsWith(SESSION_EXTENSION)) return undefined;
26
+ const base = name.slice(0, -SESSION_EXTENSION.length);
27
+ const separator = base.indexOf("_");
28
+ if (separator < 0) return undefined;
29
+ const id = base.slice(separator + 1);
30
+ return /^[0-9a-f-]{8,}$/i.test(id) ? id : undefined;
31
+ }
32
+
33
+ function isRecordLike(value: unknown): value is Record<string, unknown> {
34
+ if (!value || typeof value !== "object") return false;
35
+ const entry = value as Record<string, unknown>;
36
+ if (typeof entry.id !== "string" || entry.id.length === 0) return false;
37
+ return typeof entry.type === "string" && typeof entry.description === "string";
38
+ }
39
+
40
+ function toRecord(entry: Record<string, unknown>, sessionId: string): AgentRecord {
41
+ const startedAt = typeof entry.startedAt === "number" ? entry.startedAt : 0;
42
+ const transcriptPath = typeof entry.transcriptPath === "string" ? entry.transcriptPath : undefined;
43
+ return {
44
+ id: entry.id as string,
45
+ type: entry.type as string,
46
+ description: entry.description as string,
47
+ status: (typeof entry.status === "string" ? entry.status : "completed") as AgentRecord["status"],
48
+ startedAt,
49
+ completedAt: typeof entry.completedAt === "number" ? entry.completedAt : startedAt,
50
+ result: typeof entry.result === "string" ? entry.result : undefined,
51
+ error: typeof entry.error === "string" ? entry.error : undefined,
52
+ toolUses: typeof entry.toolUses === "number" ? entry.toolUses : 0,
53
+ compactionCount: 0,
54
+ lifetimeUsage: (entry.lifetimeUsage as AgentRecord["lifetimeUsage"]) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
55
+ invocation: entry.invocation as AgentInvocation | undefined,
56
+ transcriptPath,
57
+ historyFile: transcriptPath,
58
+ sessionId,
59
+ };
60
+ }
61
+
62
+ /** Yield the parsed JSON value of each line that carries the marker. */
63
+ async function* matchingLines(path: string): AsyncGenerator<unknown> {
64
+ const stream = createReadStream(path, { encoding: "utf-8" });
65
+ let buffer = "";
66
+ for await (const chunk of stream) {
67
+ buffer += chunk;
68
+ let newline = buffer.indexOf("\n");
69
+ while (newline >= 0) {
70
+ const line = buffer.slice(0, newline);
71
+ buffer = buffer.slice(newline + 1);
72
+ if (line.includes(RECORD_MARKER)) {
73
+ try {
74
+ yield JSON.parse(line);
75
+ } catch { /* a malformed line must not hide the rest of the session */ }
76
+ }
77
+ newline = buffer.indexOf("\n");
78
+ }
79
+ }
80
+ if (buffer.includes(RECORD_MARKER)) {
81
+ try {
82
+ yield JSON.parse(buffer);
83
+ } catch { /* ignore trailing partial line */ }
84
+ }
85
+ }
86
+
87
+ function collectEntries(parsed: unknown, out: Record<string, unknown>[]): void {
88
+ if (Array.isArray(parsed)) {
89
+ for (const item of parsed) collectEntries(item, out);
90
+ return;
91
+ }
92
+ if (!parsed || typeof parsed !== "object") return;
93
+ const node = parsed as Record<string, unknown>;
94
+ if (node.customType === "subagents:record" && isRecordLike(node.data)) {
95
+ out.push(node.data);
96
+ }
97
+ for (const value of Object.values(node)) {
98
+ if (value && typeof value === "object") collectEntries(value, out);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Agent records this extension wrote into session files before durable
104
+ * checkpoints. Sessions whose directory does not exist simply contribute none.
105
+ */
106
+ export async function readLegacySessionRecords(cwd: string, agentDir: string = getAgentDir()): Promise<AgentRecord[]> {
107
+ const directory = join(agentDir, "sessions");
108
+ let files: string[];
109
+ try {
110
+ files = readdirSync(directory).filter((name) => name.endsWith(SESSION_EXTENSION));
111
+ } catch {
112
+ return [];
113
+ }
114
+
115
+ const records: AgentRecord[] = [];
116
+ const seen = new Set<string>();
117
+ void files;
118
+
119
+ // pi scopes sessions by project: <sessions>/--<encoded-cwd>--/<file>. Without
120
+ // this filter every project's agents would show up in every other project.
121
+ const projectDir = `--${encodeCwd(cwd)}--`;
122
+ const entriesIn: string[] = [];
123
+ try {
124
+ for (const name of readdirSync(directory, { withFileTypes: true })) {
125
+ if (name.isDirectory() && name.name === projectDir) entriesIn.push(name.name);
126
+ }
127
+ } catch {
128
+ return [];
129
+ }
130
+ if (entriesIn.length === 0) return records;
131
+
132
+ // Session files live one directory deeper: <sessions>/<encoded-cwd>/<file>.
133
+ for (const name of entriesIn.map((entryName) => ({ isDirectory: () => true, name: entryName }))) {
134
+ let sessionFiles: string[];
135
+ try {
136
+ sessionFiles = readdirSync(join(directory, name.name));
137
+ } catch {
138
+ continue;
139
+ }
140
+ for (const sessionFile of sessionFiles) {
141
+ const sessionId = sessionIdFromFile(sessionFile);
142
+ if (!sessionId) continue;
143
+ const entries: Record<string, unknown>[] = [];
144
+ try {
145
+ for await (const parsed of matchingLines(join(directory, name.name, sessionFile))) {
146
+ collectEntries(parsed, entries);
147
+ }
148
+ } catch {
149
+ continue;
150
+ }
151
+ for (const entry of entries) {
152
+ if (seen.has(entry.id as string)) continue;
153
+ seen.add(entry.id as string);
154
+ records.push(toRecord(entry, sessionId));
155
+ }
156
+ }
157
+ }
158
+ return records;
159
+ }
package/src/types.ts CHANGED
@@ -140,6 +140,8 @@ export interface AgentRecord {
140
140
  depth?: number;
141
141
  /** Parent agent ID for ownership-scoped nested controls. */
142
142
  parentAgentId?: string;
143
+ /** Session this record belongs to; re-stamped on restore. */
144
+ sessionId?: string;
143
145
  /** Parent description shown in a nested conversation viewer. */
144
146
  parentDescription?: string;
145
147
  /** Effective inherited nesting cap for this branch. */
@@ -57,6 +57,13 @@ export type AgentWidgetOpenCallback = (record: AgentRecord, mode: AgentWidgetOpe
57
57
  export type AgentWidgetOptions = {
58
58
  canOpenHistory: (record: AgentRecord) => boolean;
59
59
  onOpen: AgentWidgetOpenCallback;
60
+ /**
61
+ * Current session id. The roster shows only agents belonging to it, so a
62
+ * long-lived project directory does not fill the widget with work from other
63
+ * sessions. Read live — the session id is only known once pi has started.
64
+ * Nested children carry their parent's session id, so trees stay intact.
65
+ */
66
+ getSessionId?: () => string | undefined;
60
67
  };
61
68
  /** @deprecated Use AgentWidgetOpenMode. */
62
69
  export type AgentOpenMode = AgentWidgetOpenMode;
@@ -307,6 +314,7 @@ export class AgentWidget {
307
314
  private options: AgentWidgetOptions = {
308
315
  canOpenHistory: (record) => record.session !== undefined || record.completedAt !== undefined,
309
316
  onOpen: () => {},
317
+ getSessionId: () => undefined,
310
318
  },
311
319
  ) {}
312
320
 
@@ -322,9 +330,15 @@ export class AgentWidget {
322
330
  * - `all`: every agent.
323
331
  */
324
332
  private widgetAgents() {
325
- const all = this.manager.listAgents();
333
+ if (this.mode() === "off") return [];
334
+ // Scope to this session before the mode filter. Restored records from other
335
+ // sessions are still openable through `/agents` history; they just do not
336
+ // clutter the roster sitting under the editor.
337
+ const sessionId = this.options.getSessionId?.();
338
+ const all = sessionId
339
+ ? this.manager.listAgents().filter(record => record.sessionId === sessionId)
340
+ : this.manager.listAgents();
326
341
  switch (this.mode()) {
327
- case "off": return [];
328
342
  case "background": return all.filter(a => a.parentAgentId !== undefined || a.isBackground !== false);
329
343
  default: return all;
330
344
  }