@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.
@@ -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. Every row is openable (has a session), so Enter
167
- * never dead-ends. Included: running/queued, plus the agent currently being
168
- * viewed, plus recently-finished ones (they linger briefly before dropping out).
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 => a.session && (a.status === "running" || a.status === "queued"
176
- || a.id === this.viewingAgentId
177
- || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)))
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
- if (!record.session) {
279
- this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
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.2",
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
+ }
@@ -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
- if (manager.abort(record.id)) {
1804
- ctx.ui.notify(`Stopped "${record.description}".`, "info");
1805
- }
1806
- }, keybindings, (message: string) => manager.steer(record.id, message));
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,
@@ -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
- try {
82
- appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
83
- } catch { /* ignore write errors */ }
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
  /**
@@ -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: AgentSession,
46
+ private session: ConversationSource,
38
47
  private record: AgentRecord,
39
48
  private activity: AgentActivity | undefined,
40
49
  private theme: Theme,
@@ -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. Every row is openable (has a session), so Enter
183
- * never dead-ends. Included: running/queued, plus the agent currently being
184
- * viewed, plus recently-finished ones (they linger briefly before dropping out).
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 => a.session && (
192
- a.status === "running" || a.status === "queued"
193
- || a.id === this.viewingAgentId
194
- || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
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
- if (!record.session) {
291
- this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
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
+ });