@esso0428/pi-subagents 0.17.34 → 0.17.36
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/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 +45 -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.36] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Legacy session history no longer lists records that cannot be opened**: recovering `subagents:record` entries from older sessions bypassed the `canOpenAgentHistory` check that the this-session list, the project-wide list, and the roster all apply, so entries with no durable transcript were offered and could only ever answer "No agent history." They now clear the same bar, which drops roughly half of what the scan recovers.
|
|
14
|
+
|
|
15
|
+
## [0.17.35] - 2026-10-05
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **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.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
10
27
|
## [0.17.34] - 2026-10-05
|
|
11
28
|
|
|
12
29
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esso0428/pi-subagents",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.36",
|
|
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,46 @@ 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
|
+
//
|
|
1935
|
+
// They must clear the same `canOpenAgentHistory` bar as everything else:
|
|
1936
|
+
// most carry no durable transcript, and listing an entry that can only ever
|
|
1937
|
+
// answer "No agent history." is a dead link, not history.
|
|
1938
|
+
const merged = scope === "this-session"
|
|
1939
|
+
? scoped
|
|
1940
|
+
: mergeLegacyRecords(
|
|
1941
|
+
scoped,
|
|
1942
|
+
(await readLegacySessionRecords(ctx.cwd)).filter(record => canOpenAgentHistory(record, ctx.cwd)),
|
|
1943
|
+
);
|
|
1944
|
+
const pairs = merged.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
|
|
1933
1945
|
makeUniqueAgentOptionLabels(pairs);
|
|
1934
|
-
const
|
|
1946
|
+
const title = scope === "this-session" ? "Agent history — this session" : "Agent history — all sessions";
|
|
1947
|
+
const record = await selectAgentFromReadOnlyList(ctx, title, pairs, historyAgentSelection);
|
|
1935
1948
|
if (!record) return;
|
|
1936
1949
|
|
|
1937
1950
|
await viewAgentConversation(ctx, record, "history");
|
|
1938
1951
|
// Back-navigation: re-show the list at the previously selected agent.
|
|
1939
|
-
await showAgentHistory(ctx);
|
|
1952
|
+
await showAgentHistory(ctx, scope);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/** In-memory records win; legacy session entries only fill genuine gaps. */
|
|
1956
|
+
function mergeLegacyRecords(current: readonly AgentRecord[], legacy: readonly AgentRecord[]): AgentRecord[] {
|
|
1957
|
+
const known = new Set(current.map((record) => record.id));
|
|
1958
|
+
return [...current, ...legacy.filter((record) => !known.has(record.id))]
|
|
1959
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
1940
1960
|
}
|
|
1941
1961
|
|
|
1942
1962
|
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
|
}
|