@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 +17 -0
- package/README.md +65 -0
- package/ROADMAP.md +12 -0
- package/package.json +1 -1
- package/src/agent-history-list.ts +9 -1
- package/src/agent-manager.ts +17 -2
- package/src/agent-recovery.ts +8 -0
- package/src/index.ts +38 -25
- package/src/legacy-session-records.ts +159 -0
- package/src/types.ts +2 -0
- package/src/ui/agent-widget.ts +16 -2
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.
|
|
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
|
-
...(
|
|
96
|
+
...(inSession.length > 0 ? [`Agent history this session (${inSession.length})`] : []),
|
|
97
|
+
...(history.length > 0 ? [`Agent history all sessions (${history.length})`] : []),
|
|
90
98
|
];
|
|
91
99
|
}
|
package/src/agent-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/agent-recovery.ts
CHANGED
|
@@ -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
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
1928
|
-
|
|
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
|
-
|
|
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
|
|
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. */
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|