@esso0428/pi-subagents 0.15.2 → 0.15.4
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 +10 -0
- package/README.md +3 -3
- package/dist/agent-history.d.ts +24 -0
- package/dist/agent-history.js +106 -0
- package/dist/agent-manager.js +9 -1
- package/dist/index.js +54 -49
- package/dist/output-file.d.ts +1 -1
- package/dist/output-file.js +7 -4
- package/dist/types.d.ts +4 -0
- package/dist/ui/agent-widget.d.ts +5 -1
- package/dist/ui/agent-widget.js +12 -2
- package/dist/ui/conversation-viewer.d.ts +4 -1
- package/dist/ui/conversation-viewer.js +4 -0
- package/dist/ui/fleet-list.d.ts +7 -4
- package/dist/ui/fleet-list.js +28 -12
- package/package.json +1 -1
- package/src/agent-history.ts +107 -0
- package/src/agent-manager.ts +11 -2
- package/src/index.ts +55 -13
- package/src/output-file.ts +7 -3
- package/src/types.ts +4 -0
- package/src/ui/agent-widget.ts +8 -1
- package/src/ui/conversation-viewer.ts +10 -1
- package/src/ui/fleet-list.ts +30 -15
- package/test/agent-history.test.ts +63 -0
- package/test/agent-manager-history.test.ts +4 -1
package/dist/ui/fleet-list.js
CHANGED
|
@@ -16,6 +16,7 @@ exports.FleetList = void 0;
|
|
|
16
16
|
exports.formatFleetElapsed = formatFleetElapsed;
|
|
17
17
|
exports.formatFleetTokens = formatFleetTokens;
|
|
18
18
|
const pi_tui_1 = require("@earendil-works/pi-tui");
|
|
19
|
+
const agent_history_js_1 = require("../agent-history.js");
|
|
19
20
|
const usage_js_1 = require("../usage.js");
|
|
20
21
|
const agent_widget_js_1 = require("./agent-widget.js");
|
|
21
22
|
const conversation_viewer_js_1 = require("./conversation-viewer.js");
|
|
@@ -57,6 +58,7 @@ function rightAlign(left, right, width) {
|
|
|
57
58
|
class FleetList {
|
|
58
59
|
manager;
|
|
59
60
|
agentActivity;
|
|
61
|
+
getCwd;
|
|
60
62
|
ui;
|
|
61
63
|
tui;
|
|
62
64
|
inputUnsub;
|
|
@@ -70,9 +72,10 @@ class FleetList {
|
|
|
70
72
|
/** Set while a conversation overlay is open; calling it closes the overlay. */
|
|
71
73
|
viewerClose;
|
|
72
74
|
viewingAgentId;
|
|
73
|
-
constructor(manager, agentActivity) {
|
|
75
|
+
constructor(manager, agentActivity, getCwd = () => undefined) {
|
|
74
76
|
this.manager = manager;
|
|
75
77
|
this.agentActivity = agentActivity;
|
|
78
|
+
this.getCwd = getCwd;
|
|
76
79
|
}
|
|
77
80
|
// ---- Lifecycle ----
|
|
78
81
|
setEnabled(enabled) {
|
|
@@ -163,18 +166,25 @@ class FleetList {
|
|
|
163
166
|
// ---- Roster ----
|
|
164
167
|
/**
|
|
165
168
|
* Agents shown in the list, ordered earliest-launched first so the ones you
|
|
166
|
-
* started sooner sit at the top.
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
+
* started sooner sit at the top. Live rows are openable through their session;
|
|
170
|
+
* terminal rows are included only when their durable transcript exists. This
|
|
171
|
+
* prevents Enter from dead-ending on stale rehydrated metadata.
|
|
172
|
+
* Included: running/queued, plus the agent currently being viewed, plus
|
|
173
|
+
* recently-finished ones (they linger briefly before dropping out).
|
|
169
174
|
* Pending agents with no session yet are hidden until they start.
|
|
170
175
|
* (`listAgents()` is newest-first, so we re-sort.)
|
|
171
176
|
*/
|
|
172
177
|
agentRecords() {
|
|
173
178
|
const now = Date.now();
|
|
174
179
|
return this.manager.listAgents()
|
|
175
|
-
.filter(a =>
|
|
176
|
-
|| a.
|
|
177
|
-
|
|
180
|
+
.filter(a => {
|
|
181
|
+
const live = a.session && (a.status === "running" || a.status === "queued"
|
|
182
|
+
|| a.id === this.viewingAgentId
|
|
183
|
+
|| (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS));
|
|
184
|
+
const cwd = this.getCwd();
|
|
185
|
+
const history = cwd !== undefined && (0, agent_history_js_1.hasAgentHistory)(cwd, a.transcriptPath);
|
|
186
|
+
return !!live || history;
|
|
187
|
+
})
|
|
178
188
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
179
189
|
}
|
|
180
190
|
roster() {
|
|
@@ -275,19 +285,25 @@ class FleetList {
|
|
|
275
285
|
const record = entry.record;
|
|
276
286
|
if (!this.ui)
|
|
277
287
|
return;
|
|
278
|
-
|
|
279
|
-
|
|
288
|
+
const session = record.session ?? (record.transcriptPath && this.getCwd()
|
|
289
|
+
? (() => {
|
|
290
|
+
const messages = (0, agent_history_js_1.readAgentHistory)(this.getCwd(), record.transcriptPath);
|
|
291
|
+
return messages ? (0, conversation_viewer_js_1.createStaticConversationSource)(messages) : undefined;
|
|
292
|
+
})()
|
|
293
|
+
: undefined);
|
|
294
|
+
if (!session) {
|
|
295
|
+
this.ui.notify(`Agent is ${record.status} — no history available.`, "info");
|
|
280
296
|
return;
|
|
281
297
|
}
|
|
282
|
-
const session = record.session;
|
|
283
298
|
const activity = this.agentActivity.get(record.id);
|
|
299
|
+
const isLive = record.session !== undefined;
|
|
284
300
|
this.viewingAgentId = record.id;
|
|
285
301
|
void this.ui.custom((tui, theme, keybindings, done) => {
|
|
286
302
|
this.viewerClose = () => done(undefined);
|
|
287
|
-
return new conversation_viewer_js_1.ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
303
|
+
return new conversation_viewer_js_1.ConversationViewer(tui, session, record, activity, theme, done, isLive ? () => {
|
|
288
304
|
if (this.manager.abort(record.id))
|
|
289
305
|
this.ui?.notify(`Stopped "${record.description}".`, "info");
|
|
290
|
-
}, keybindings, (message) => this.manager.steer(record.id, message));
|
|
306
|
+
} : undefined, keybindings, isLive ? (message) => this.manager.steer(record.id, message) : undefined);
|
|
291
307
|
}, {
|
|
292
308
|
overlay: true,
|
|
293
309
|
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${conversation_viewer_js_1.VIEWPORT_HEIGHT_PCT}%` },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esso0428/pi-subagents",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
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": {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Durable, project-local transcript storage for subagents. */
|
|
2
|
+
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
const SUBAGENTS_DIR = ".pi-subagents";
|
|
8
|
+
const TRANSCRIPTS_DIR = "agent-transcripts";
|
|
9
|
+
const MAX_HISTORY_BYTES = 20 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ensure project-local subagent artifacts are ignored by git.
|
|
13
|
+
*
|
|
14
|
+
* The rule is appended instead of rewriting the file. This preserves any
|
|
15
|
+
* existing user-owned rules and makes concurrent callers harmless (duplicate
|
|
16
|
+
* `*` rules are semantically equivalent).
|
|
17
|
+
*/
|
|
18
|
+
export function ensureSubagentsGitignore(cwd: string): string {
|
|
19
|
+
const directory = join(cwd, SUBAGENTS_DIR);
|
|
20
|
+
mkdirSync(directory, { recursive: true });
|
|
21
|
+
const path = join(directory, ".gitignore");
|
|
22
|
+
|
|
23
|
+
let content = "";
|
|
24
|
+
try {
|
|
25
|
+
content = readFileSync(path, "utf8");
|
|
26
|
+
} catch {
|
|
27
|
+
// The append below also creates a missing file.
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!content.split(/\r?\n/).some((line) => line.trim() === "*")) {
|
|
31
|
+
const separator = content.length > 0 && !/[\r\n]$/.test(content) ? "\n" : "";
|
|
32
|
+
appendFileSync(path, `${separator}*\n`, "utf8");
|
|
33
|
+
}
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Return the durable transcript path for an agent. */
|
|
38
|
+
export function createAgentHistoryPath(
|
|
39
|
+
cwd: string,
|
|
40
|
+
agentId: string,
|
|
41
|
+
agentType = "agent",
|
|
42
|
+
): string {
|
|
43
|
+
ensureSubagentsGitignore(cwd);
|
|
44
|
+
const directory = join(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
|
|
45
|
+
mkdirSync(directory, { recursive: true });
|
|
46
|
+
const safeId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
|
|
47
|
+
const safeType = agentType.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
|
|
48
|
+
return join(directory, `${safeId}_${safeType}_transcript.jsonl`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Return the project-relative path stored in the parent session record. */
|
|
52
|
+
export function agentHistoryLocator(cwd: string, historyPath: string): string {
|
|
53
|
+
return relative(cwd, historyPath).split(sep).join("/");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolve only paths in this package's project-local transcript namespace. */
|
|
57
|
+
export function resolveAgentHistoryPath(cwd: string, locator: string): string | undefined {
|
|
58
|
+
if (!locator || isAbsolute(locator)) return undefined;
|
|
59
|
+
const root = resolve(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
|
|
60
|
+
const candidate = resolve(cwd, locator);
|
|
61
|
+
if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) return undefined;
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Return true only when a valid project-local transcript file exists. */
|
|
66
|
+
export function hasAgentHistory(cwd: string, locator: string | undefined): boolean {
|
|
67
|
+
if (!locator) return false;
|
|
68
|
+
const path = resolveAgentHistoryPath(cwd, locator);
|
|
69
|
+
return path !== undefined && existsSync(path);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read persisted transcript entries into the message shape used by the live
|
|
74
|
+
* conversation viewer. Malformed lines and unknown records are skipped so one
|
|
75
|
+
* damaged entry cannot hide the rest of a history.
|
|
76
|
+
*/
|
|
77
|
+
export function readAgentHistory(
|
|
78
|
+
cwd: string,
|
|
79
|
+
locator: string,
|
|
80
|
+
): AgentSession["messages"] | undefined {
|
|
81
|
+
const path = resolveAgentHistoryPath(cwd, locator);
|
|
82
|
+
if (!path) return undefined;
|
|
83
|
+
|
|
84
|
+
let raw: string;
|
|
85
|
+
try {
|
|
86
|
+
raw = readFileSync(path, "utf8");
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (raw.length > MAX_HISTORY_BYTES) raw = raw.slice(0, MAX_HISTORY_BYTES);
|
|
91
|
+
|
|
92
|
+
const messages: AgentSession["messages"] = [];
|
|
93
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
94
|
+
if (!line.trim()) continue;
|
|
95
|
+
try {
|
|
96
|
+
const entry = JSON.parse(line) as { message?: unknown };
|
|
97
|
+
const message = entry.message;
|
|
98
|
+
if (!message || typeof message !== "object") continue;
|
|
99
|
+
const role = (message as { role?: unknown }).role;
|
|
100
|
+
if (typeof role !== "string") continue;
|
|
101
|
+
messages.push(message as AgentSession["messages"][number]);
|
|
102
|
+
} catch {
|
|
103
|
+
// Ignore malformed/truncated JSONL records.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return messages.length > 0 ? messages : undefined;
|
|
107
|
+
}
|
package/src/agent-manager.ts
CHANGED
|
@@ -114,12 +114,19 @@ const RESTORABLE_STATUSES = new Set<AgentRecord["status"]>([
|
|
|
114
114
|
|
|
115
115
|
type PersistedAgentRecord = Pick<
|
|
116
116
|
AgentRecord,
|
|
117
|
-
"id" | "type" | "description" | "status" | "result" | "error" | "startedAt" | "completedAt"
|
|
117
|
+
"id" | "type" | "description" | "status" | "result" | "error" | "startedAt" | "completedAt" | "transcriptPath"
|
|
118
118
|
>;
|
|
119
119
|
|
|
120
120
|
function isRestorableRecord(value: unknown): value is PersistedAgentRecord {
|
|
121
121
|
if (!value || typeof value !== "object") return false;
|
|
122
122
|
const record = value as Record<string, unknown>;
|
|
123
|
+
const transcriptPath = record.transcriptPath;
|
|
124
|
+
const validTranscriptPath =
|
|
125
|
+
transcriptPath === undefined ||
|
|
126
|
+
(typeof transcriptPath === "string" &&
|
|
127
|
+
transcriptPath.startsWith(".pi-subagents/agent-transcripts/") &&
|
|
128
|
+
!transcriptPath.includes("..") &&
|
|
129
|
+
!transcriptPath.includes("\\"));
|
|
123
130
|
return (
|
|
124
131
|
typeof record.id === "string" &&
|
|
125
132
|
record.id.length > 0 &&
|
|
@@ -131,7 +138,8 @@ function isRestorableRecord(value: unknown): value is PersistedAgentRecord {
|
|
|
131
138
|
typeof record.completedAt === "number" &&
|
|
132
139
|
Number.isFinite(record.completedAt) &&
|
|
133
140
|
(record.result === undefined || typeof record.result === "string") &&
|
|
134
|
-
(record.error === undefined || typeof record.error === "string")
|
|
141
|
+
(record.error === undefined || typeof record.error === "string") &&
|
|
142
|
+
validTranscriptPath
|
|
135
143
|
);
|
|
136
144
|
}
|
|
137
145
|
|
|
@@ -570,6 +578,7 @@ export class AgentManager {
|
|
|
570
578
|
status: value.status,
|
|
571
579
|
result: value.result,
|
|
572
580
|
error: value.error,
|
|
581
|
+
transcriptPath: value.transcriptPath,
|
|
573
582
|
toolUses: 0,
|
|
574
583
|
startedAt: value.startedAt,
|
|
575
584
|
completedAt: value.completedAt,
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabl
|
|
|
24
24
|
import { GroupJoinManager } from "./group-join.js";
|
|
25
25
|
import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
|
|
26
26
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
27
|
+
import { agentHistoryLocator, createAgentHistoryPath, ensureSubagentsGitignore, hasAgentHistory, readAgentHistory } from "./agent-history.js";
|
|
27
28
|
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
28
29
|
import { SubagentScheduler } from "./schedule.js";
|
|
29
30
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
@@ -462,11 +463,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
462
463
|
pi.events.emit("subagents:completed", eventData);
|
|
463
464
|
}
|
|
464
465
|
|
|
465
|
-
// Persist final record for cross-extension history reconstruction
|
|
466
|
+
// Persist final record for cross-extension history reconstruction. Keep
|
|
467
|
+
// the transcript body in a project-local file rather than inflating the
|
|
468
|
+
// parent session JSONL; only its project-relative locator is persisted.
|
|
466
469
|
pi.appendEntry("subagents:record", {
|
|
467
470
|
id: record.id, type: record.type, description: record.description,
|
|
468
471
|
status: record.status, result: record.result, error: record.error,
|
|
469
472
|
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
473
|
+
transcriptPath: record.transcriptPath,
|
|
470
474
|
});
|
|
471
475
|
|
|
472
476
|
// Skip notification if result was already consumed via get_subagent_result
|
|
@@ -570,12 +574,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
570
574
|
// bound session_start, so a filtered-out activation never advertises (#142).
|
|
571
575
|
pi.on("session_start", async (_event, ctx) => {
|
|
572
576
|
currentCtx = ctx;
|
|
577
|
+
try {
|
|
578
|
+
ensureSubagentsGitignore(ctx.cwd);
|
|
579
|
+
} catch (err) {
|
|
580
|
+
console.warn("[pi-subagents] Failed to protect project-local history:", err);
|
|
581
|
+
}
|
|
573
582
|
manager.clearCompleted(true);
|
|
574
583
|
const historicalRecords = ctx.sessionManager
|
|
575
584
|
.getBranch()
|
|
576
585
|
.filter((entry: any) => entry?.type === "custom" && entry.customType === "subagents:record")
|
|
577
586
|
.map((entry: any) => entry.data);
|
|
578
587
|
manager.restoreCompleted(historicalRecords);
|
|
588
|
+
// Bind the UI here as well as on tool execution so rehydrated terminal
|
|
589
|
+
// records are immediately visible and openable from FleetView after reload.
|
|
590
|
+
widget.setUICtx(ctx.ui as UICtx);
|
|
591
|
+
fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
|
|
592
|
+
fleet.update();
|
|
579
593
|
// Guard mirrors the `!scheduler.isActive()` pattern below: session_start
|
|
580
594
|
// fires once per activation, but a double-bind must not leak listeners.
|
|
581
595
|
if (!rpcHandle) {
|
|
@@ -626,11 +640,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
626
640
|
// everything else; "off" = hide the widget entirely. Read live at render time.
|
|
627
641
|
let widgetMode: WidgetMode = "background";
|
|
628
642
|
function getWidgetMode(): WidgetMode { return widgetMode; }
|
|
629
|
-
const widget = new AgentWidget(manager, agentActivity, getWidgetMode);
|
|
643
|
+
const widget = new AgentWidget(manager, agentActivity, getWidgetMode, () => currentCtx?.cwd);
|
|
630
644
|
function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
|
|
631
645
|
|
|
632
646
|
// Claude Code-style FleetView: navigable list of main + subagents below the editor.
|
|
633
|
-
const fleet = new FleetList(manager, agentActivity);
|
|
647
|
+
const fleet = new FleetList(manager, agentActivity, () => currentCtx?.cwd);
|
|
634
648
|
let fleetViewEnabled = true;
|
|
635
649
|
function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
|
|
636
650
|
function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
|
|
@@ -1161,6 +1175,19 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1161
1175
|
if (!rec || !outputTranscript) return;
|
|
1162
1176
|
rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
|
|
1163
1177
|
writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
|
|
1178
|
+
|
|
1179
|
+
// Keep the existing /tmp output for compatibility, while also writing a
|
|
1180
|
+
// durable project-local copy for reload-safe, read-only history viewing.
|
|
1181
|
+
try {
|
|
1182
|
+
rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId, subagentType);
|
|
1183
|
+
rec.transcriptPath = agentHistoryLocator(ctx.cwd, rec.historyFile);
|
|
1184
|
+
writeInitialEntry(rec.historyFile, agentId, params.prompt, ctx.cwd);
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
// History is best effort; never disable the existing output transcript.
|
|
1187
|
+
rec.historyFile = undefined;
|
|
1188
|
+
rec.transcriptPath = undefined;
|
|
1189
|
+
console.warn("[pi-subagents] Failed to create durable transcript:", err);
|
|
1190
|
+
}
|
|
1164
1191
|
};
|
|
1165
1192
|
|
|
1166
1193
|
const parentModelId = ctx.model?.id;
|
|
@@ -1270,7 +1297,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1270
1297
|
origBgOnSession(session);
|
|
1271
1298
|
const rec = manager.getRecord(id);
|
|
1272
1299
|
if (rec?.outputFile) {
|
|
1273
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
|
|
1300
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, rec.historyFile);
|
|
1274
1301
|
}
|
|
1275
1302
|
};
|
|
1276
1303
|
|
|
@@ -1386,7 +1413,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1386
1413
|
if (fgId) {
|
|
1387
1414
|
const rec = manager.getRecord(fgId);
|
|
1388
1415
|
if (rec?.outputFile) {
|
|
1389
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
|
|
1416
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, rec.historyFile);
|
|
1390
1417
|
}
|
|
1391
1418
|
}
|
|
1392
1419
|
};
|
|
@@ -1762,7 +1789,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1762
1789
|
}
|
|
1763
1790
|
|
|
1764
1791
|
async function showRunningAgents(ctx: ExtensionCommandContext) {
|
|
1765
|
-
const agents = manager.listAgents()
|
|
1792
|
+
const agents = manager.listAgents().filter((record) =>
|
|
1793
|
+
record.session !== undefined || hasAgentHistory(ctx.cwd, record.transcriptPath),
|
|
1794
|
+
);
|
|
1766
1795
|
if (agents.length === 0) {
|
|
1767
1796
|
ctx.ui.notify("No agents.", "info");
|
|
1768
1797
|
return;
|
|
@@ -1793,17 +1822,30 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1793
1822
|
return;
|
|
1794
1823
|
}
|
|
1795
1824
|
|
|
1796
|
-
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
|
|
1797
|
-
const session = record.session
|
|
1825
|
+
const { ConversationViewer, VIEWPORT_HEIGHT_PCT, createStaticConversationSource } = await import("./ui/conversation-viewer.js");
|
|
1826
|
+
const session = record.session ?? (record.transcriptPath
|
|
1827
|
+
? (() => {
|
|
1828
|
+
const messages = readAgentHistory(ctx.cwd, record.transcriptPath!);
|
|
1829
|
+
return messages ? createStaticConversationSource(messages) : undefined;
|
|
1830
|
+
})()
|
|
1831
|
+
: undefined);
|
|
1832
|
+
if (!session) {
|
|
1833
|
+
ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no history available.`, "info");
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1798
1836
|
const activity = agentActivity.get(record.id);
|
|
1837
|
+
const isLive = record.session !== undefined;
|
|
1799
1838
|
|
|
1800
1839
|
await ctx.ui.custom<undefined>(
|
|
1801
1840
|
(tui, theme, keybindings, done) => {
|
|
1802
|
-
return new ConversationViewer(tui, session, record, activity, theme, done,
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1841
|
+
return new ConversationViewer(tui, session, record, activity, theme, done,
|
|
1842
|
+
isLive ? () => {
|
|
1843
|
+
if (manager.abort(record.id)) {
|
|
1844
|
+
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
1845
|
+
}
|
|
1846
|
+
} : undefined,
|
|
1847
|
+
keybindings,
|
|
1848
|
+
isLive ? (message: string) => manager.steer(record.id, message) : undefined);
|
|
1807
1849
|
},
|
|
1808
1850
|
{
|
|
1809
1851
|
overlay: true,
|
package/src/output-file.ts
CHANGED
|
@@ -63,7 +63,9 @@ export function streamToOutputFile(
|
|
|
63
63
|
path: string,
|
|
64
64
|
agentId: string,
|
|
65
65
|
cwd: string,
|
|
66
|
+
historyPath?: string,
|
|
66
67
|
): () => void {
|
|
68
|
+
const outputPaths = historyPath && historyPath !== path ? [path, historyPath] : [path];
|
|
67
69
|
let writtenCount = 1; // initial user prompt already written
|
|
68
70
|
|
|
69
71
|
const flush = () => {
|
|
@@ -78,9 +80,11 @@ export function streamToOutputFile(
|
|
|
78
80
|
timestamp: new Date().toISOString(),
|
|
79
81
|
cwd,
|
|
80
82
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
for (const outputPath of outputPaths) {
|
|
84
|
+
try {
|
|
85
|
+
appendFileSync(outputPath, JSON.stringify(entry) + "\n", "utf-8");
|
|
86
|
+
} catch { /* ignore write errors */ }
|
|
87
|
+
}
|
|
84
88
|
writtenCount++;
|
|
85
89
|
}
|
|
86
90
|
};
|
package/src/types.ts
CHANGED
|
@@ -105,6 +105,10 @@ export interface AgentRecord {
|
|
|
105
105
|
toolCallId?: string;
|
|
106
106
|
/** Path to the streaming output transcript file. */
|
|
107
107
|
outputFile?: string;
|
|
108
|
+
/** Absolute path to the durable project-local transcript while live. */
|
|
109
|
+
historyFile?: string;
|
|
110
|
+
/** Project-relative durable transcript path persisted in the parent session. */
|
|
111
|
+
transcriptPath?: string;
|
|
108
112
|
/** Cleanup function for the output file stream subscription. */
|
|
109
113
|
outputCleanup?: () => void;
|
|
110
114
|
/**
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { hasAgentHistory } from "../agent-history.js";
|
|
9
10
|
import type { AgentManager } from "../agent-manager.js";
|
|
10
11
|
import { getConfig } from "../agent-types.js";
|
|
11
12
|
import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
|
|
@@ -237,6 +238,8 @@ export class AgentWidget {
|
|
|
237
238
|
* extension supplies one defaulting to `"background"`.
|
|
238
239
|
*/
|
|
239
240
|
private mode: () => WidgetMode = () => "all",
|
|
241
|
+
/** Current project cwd used to validate reload-restored transcripts. */
|
|
242
|
+
private getCwd: () => string | undefined = () => undefined,
|
|
240
243
|
) {}
|
|
241
244
|
|
|
242
245
|
/**
|
|
@@ -251,7 +254,11 @@ export class AgentWidget {
|
|
|
251
254
|
* - `all`: every agent.
|
|
252
255
|
*/
|
|
253
256
|
private widgetAgents() {
|
|
254
|
-
const all = this.manager.listAgents()
|
|
257
|
+
const all = this.manager.listAgents().filter((record) => {
|
|
258
|
+
if (record.status === "running" || record.status === "queued") return true;
|
|
259
|
+
const cwd = this.getCwd();
|
|
260
|
+
return cwd !== undefined && hasAgentHistory(cwd, record.transcriptPath);
|
|
261
|
+
});
|
|
255
262
|
switch (this.mode()) {
|
|
256
263
|
case "off": return [];
|
|
257
264
|
case "background": return all.filter(a => a.isBackground !== false);
|
|
@@ -20,6 +20,15 @@ const MIN_VIEWPORT = 3;
|
|
|
20
20
|
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
21
21
|
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
22
22
|
|
|
23
|
+
/** The live fields needed by the viewer; historical viewers use a static source. */
|
|
24
|
+
export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
|
|
25
|
+
|
|
26
|
+
export function createStaticConversationSource(
|
|
27
|
+
messages: AgentSession["messages"],
|
|
28
|
+
): ConversationSource {
|
|
29
|
+
return { messages, subscribe: () => () => {} };
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
export class ConversationViewer implements Component {
|
|
24
33
|
private scrollOffset = 0;
|
|
25
34
|
private autoScroll = true;
|
|
@@ -34,7 +43,7 @@ export class ConversationViewer implements Component {
|
|
|
34
43
|
|
|
35
44
|
constructor(
|
|
36
45
|
private tui: TUI,
|
|
37
|
-
private session:
|
|
46
|
+
private session: ConversationSource,
|
|
38
47
|
private record: AgentRecord,
|
|
39
48
|
private activity: AgentActivity | undefined,
|
|
40
49
|
private theme: Theme,
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
15
|
import type { AgentManager } from "../agent-manager.js";
|
|
16
|
+
import { hasAgentHistory, readAgentHistory } from "../agent-history.js";
|
|
16
17
|
import type { AgentRecord } from "../types.js";
|
|
17
18
|
import { getLifetimeTotal } from "../usage.js";
|
|
18
19
|
import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
|
|
19
|
-
import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
|
|
20
|
+
import { ConversationViewer, createStaticConversationSource, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
|
|
20
21
|
|
|
21
22
|
/** Widget key for the below-editor fleet list. */
|
|
22
23
|
const FLEET_KEY = "fleet";
|
|
@@ -93,6 +94,7 @@ export class FleetList {
|
|
|
93
94
|
constructor(
|
|
94
95
|
private manager: AgentManager,
|
|
95
96
|
private agentActivity: Map<string, AgentActivity>,
|
|
97
|
+
private getCwd: () => string | undefined = () => undefined,
|
|
96
98
|
) {}
|
|
97
99
|
|
|
98
100
|
// ---- Lifecycle ----
|
|
@@ -179,20 +181,27 @@ export class FleetList {
|
|
|
179
181
|
|
|
180
182
|
/**
|
|
181
183
|
* Agents shown in the list, ordered earliest-launched first so the ones you
|
|
182
|
-
* started sooner sit at the top.
|
|
183
|
-
*
|
|
184
|
-
*
|
|
184
|
+
* started sooner sit at the top. Live rows are openable through their session;
|
|
185
|
+
* terminal rows are included only when their durable transcript exists. This
|
|
186
|
+
* prevents Enter from dead-ending on stale rehydrated metadata.
|
|
187
|
+
* Included: running/queued, plus the agent currently being viewed, plus
|
|
188
|
+
* recently-finished ones (they linger briefly before dropping out).
|
|
185
189
|
* Pending agents with no session yet are hidden until they start.
|
|
186
190
|
* (`listAgents()` is newest-first, so we re-sort.)
|
|
187
191
|
*/
|
|
188
192
|
private agentRecords(): AgentRecord[] {
|
|
189
193
|
const now = Date.now();
|
|
190
194
|
return this.manager.listAgents()
|
|
191
|
-
.filter(a =>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
.filter(a => {
|
|
196
|
+
const live = a.session && (
|
|
197
|
+
a.status === "running" || a.status === "queued"
|
|
198
|
+
|| a.id === this.viewingAgentId
|
|
199
|
+
|| (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
|
|
200
|
+
);
|
|
201
|
+
const cwd = this.getCwd();
|
|
202
|
+
const history = cwd !== undefined && hasAgentHistory(cwd, a.transcriptPath);
|
|
203
|
+
return !!live || history;
|
|
204
|
+
})
|
|
196
205
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
197
206
|
}
|
|
198
207
|
|
|
@@ -287,12 +296,18 @@ export class FleetList {
|
|
|
287
296
|
}
|
|
288
297
|
const record = entry.record;
|
|
289
298
|
if (!this.ui) return;
|
|
290
|
-
|
|
291
|
-
|
|
299
|
+
const session = record.session ?? (record.transcriptPath && this.getCwd()
|
|
300
|
+
? (() => {
|
|
301
|
+
const messages = readAgentHistory(this.getCwd()!, record.transcriptPath!);
|
|
302
|
+
return messages ? createStaticConversationSource(messages) : undefined;
|
|
303
|
+
})()
|
|
304
|
+
: undefined);
|
|
305
|
+
if (!session) {
|
|
306
|
+
this.ui.notify(`Agent is ${record.status} — no history available.`, "info");
|
|
292
307
|
return;
|
|
293
308
|
}
|
|
294
|
-
const session = record.session;
|
|
295
309
|
const activity = this.agentActivity.get(record.id);
|
|
310
|
+
const isLive = record.session !== undefined;
|
|
296
311
|
this.viewingAgentId = record.id;
|
|
297
312
|
|
|
298
313
|
void this.ui.custom<undefined>(
|
|
@@ -305,11 +320,11 @@ export class FleetList {
|
|
|
305
320
|
activity,
|
|
306
321
|
theme,
|
|
307
322
|
done,
|
|
308
|
-
() => {
|
|
323
|
+
isLive ? () => {
|
|
309
324
|
if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
|
|
310
|
-
},
|
|
325
|
+
} : undefined,
|
|
311
326
|
keybindings,
|
|
312
|
-
(message: string) => this.manager.steer(record.id, message),
|
|
327
|
+
isLive ? (message: string) => this.manager.steer(record.id, message) : undefined,
|
|
313
328
|
);
|
|
314
329
|
},
|
|
315
330
|
{
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
agentHistoryLocator,
|
|
7
|
+
createAgentHistoryPath,
|
|
8
|
+
ensureSubagentsGitignore,
|
|
9
|
+
hasAgentHistory,
|
|
10
|
+
readAgentHistory,
|
|
11
|
+
resolveAgentHistoryPath,
|
|
12
|
+
} from "../src/agent-history.js";
|
|
13
|
+
import { writeInitialEntry } from "../src/output-file.js";
|
|
14
|
+
|
|
15
|
+
const tempDirectories: string[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
19
|
+
rmSync(directory, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function tempProject(): string {
|
|
24
|
+
const path = mkdtempSync(join(tmpdir(), "pi-subagents-history-test-"));
|
|
25
|
+
tempDirectories.push(path);
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("project-local subagent history", () => {
|
|
30
|
+
it("creates a gitignore rule and preserves existing rules", () => {
|
|
31
|
+
const cwd = tempProject();
|
|
32
|
+
const root = join(cwd, ".pi-subagents");
|
|
33
|
+
const gitignore = join(root, ".gitignore");
|
|
34
|
+
const path = ensureSubagentsGitignore(cwd);
|
|
35
|
+
|
|
36
|
+
expect(path).toBe(gitignore);
|
|
37
|
+
expect(readFileSync(gitignore, "utf8")).toBe("*\n");
|
|
38
|
+
|
|
39
|
+
writeFileSync(gitignore, "keep-this\n", "utf8");
|
|
40
|
+
ensureSubagentsGitignore(cwd);
|
|
41
|
+
ensureSubagentsGitignore(cwd);
|
|
42
|
+
expect(readFileSync(gitignore, "utf8")).toBe("keep-this\n*\n");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("creates a bounded project-relative transcript locator", () => {
|
|
46
|
+
const cwd = tempProject();
|
|
47
|
+
const historyPath = createAgentHistoryPath(cwd, "agent-1", "Explore");
|
|
48
|
+
const locator = agentHistoryLocator(cwd, historyPath);
|
|
49
|
+
|
|
50
|
+
expect(locator).toBe(".pi-subagents/agent-transcripts/agent-1_Explore_transcript.jsonl");
|
|
51
|
+
expect(resolveAgentHistoryPath(cwd, locator)).toBe(historyPath);
|
|
52
|
+
expect(hasAgentHistory(cwd, locator)).toBe(false);
|
|
53
|
+
expect(hasAgentHistory(cwd, ".pi-subagents/other.jsonl")).toBe(false);
|
|
54
|
+
expect(resolveAgentHistoryPath(cwd, ".pi-subagents/other.jsonl")).toBeUndefined();
|
|
55
|
+
expect(resolveAgentHistoryPath(cwd, ".pi-subagents/agent-transcripts/../other.jsonl")).toBeUndefined();
|
|
56
|
+
|
|
57
|
+
writeInitialEntry(historyPath, "agent-1", "hello", cwd);
|
|
58
|
+
expect(hasAgentHistory(cwd, locator)).toBe(true);
|
|
59
|
+
const messages = readAgentHistory(cwd, locator);
|
|
60
|
+
expect(messages).toHaveLength(1);
|
|
61
|
+
expect(messages?.[0]).toMatchObject({ role: "user", content: "hello" });
|
|
62
|
+
});
|
|
63
|
+
});
|