@esso0428/pi-subagents 0.15.1 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.3] - 2026-09-11
11
+
12
+ ### Added
13
+ - **Reload-safe transcript history.** Agents now keep a project-local copy under `.pi-subagents/agent-transcripts/`; terminal records persist a relative locator and `/agents`/FleetView can open a read-only history viewer after reload. The shared `.pi-subagents/.gitignore` is created with `*` to keep private transcripts out of git.
14
+
15
+ ## [0.15.2] - 2026-09-10
16
+
17
+ ### Added
18
+ - **Completed subagent records survive session reloads.** On `session_start`, the extension now reconstructs validated terminal records persisted as `subagents:record` entries. Restored records retain their result and status for `get_subagent_result`, but never pretend to have a live child session; running and queued records are ignored, and duplicate IDs use the newest branch entry.
19
+
10
20
  ## [0.15.1] - 2026-09-10
11
21
 
12
22
  ### Added
package/README.md CHANGED
@@ -140,7 +140,7 @@ Individual agent results render Claude Code-style in the conversation:
140
140
 
141
141
  Completed results can be expanded (ctrl+o in pi) to show the agent output inline with Markdown formatting. Expanded output is capped at 50 rendered lines (background notification results at 30 lines) and ends with a marker when more output is available; use `get_subagent_result` for the complete result.
142
142
 
143
- By default, foreground and background agents each stream their full conversation to a per-subagent transcript a JSON-lines file at `<os-tmpdir>/pi-subagents-<uid>/<cwd>/<session>/tasks/<agent-id>.output` (owner-only `0700`, cleared on reboot). Set `output_transcript: false` on a custom agent to write no transcript path or file for it, or set `outputTranscript: false` in `subagents.json` to make transcripts opt-in for the whole project (frontmatter overrides the project default). This governs **only** the transcript: it is independent of `persist_session` (the pi session on disk), and it does not affect `isolation: worktree` (which commits the agent's work to a git branch) or `memory:` (durable files) — set those accordingly if the goal is to keep a run off disk entirely. Background agent completion notifications render as styled boxes:
143
+ By default, foreground and background agents stream their full conversation to two per-subagent transcripts: the existing temporary JSON-lines file at `<os-tmpdir>/pi-subagents-<uid>/<cwd>/<session>/tasks/<agent-id>.output` (owner-only `0700`, cleared on reboot), plus a durable copy at `<cwd>/.pi-subagents/agent-transcripts/<agent-id>_<agent-type>_transcript.jsonl`. The project-local `.pi-subagents/.gitignore` contains `*` so these private transcripts are not accidentally uploaded to git. After `/reload`, terminal records use the durable locator to open a read-only history viewer. Set `output_transcript: false` on a custom agent, or `outputTranscript: false` in `subagents.json`, to suppress both transcript files. This governs **only** the transcript: it is independent of `persist_session` (the pi session on disk), and it does not affect `isolation: worktree` or `memory:`. Background agent completion notifications render as styled boxes:
144
144
 
145
145
  ```
146
146
  ✓ Find auth files completed
@@ -222,7 +222,7 @@ All fields are optional — sensible defaults for everything.
222
222
  | `thinking` | inherit | off, minimal, low, medium, high, xhigh, max — actual availability depends on your pi version and model; pi clamps unsupported levels down |
223
223
  | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
224
224
  | `persist_session` | `false` | Persist this subagent as a normal pi session instead of keeping the session in memory only. The subagent's `.output` transcript is still written either way unless `output_transcript: false` |
225
- | `output_transcript` | `true` (or `subagents.json` `outputTranscript`) | Write this subagent's `.output` transcript; when set, overrides the `subagents.json` `outputTranscript` default. Set `false` to write no transcript file or path. Governs only the transcript — independent of `persist_session`, `isolation: worktree`, and `memory:` |
225
+ | `output_transcript` | `true` (or `subagents.json` `outputTranscript`) | Write the temporary `.output` transcript and durable `.pi-subagents/agent-transcripts/` copy; when set, overrides the `subagents.json` `outputTranscript` default. Set `false` to write neither. Governs only the transcript — independent of `persist_session`, `isolation: worktree`, and `memory:` |
226
226
  | `session_dir` | pi default | Optional session directory when `persist_session: true`; omitted uses pi's normal session location, and relative paths resolve from the agent cwd |
227
227
  | `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" — inherits parent's AGENTS.md / CLAUDE.md) |
228
228
  | `inherit_context` | `false` | Fork parent conversation into agent |
@@ -508,7 +508,7 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, default m
508
508
 
509
509
  **Disable defaults** (`disableDefaultAgents`, default `false`): when on, the three built-in agents (general-purpose, Explore, Plan) are not registered — only your project/global custom agents are advertised and spawnable. User-defined agents are unaffected, including ones that override a default by name. The Agent tool's type list updates on the next pi session (the tool schema is registered at startup).
510
510
 
511
- **Output transcript** (`outputTranscript`, default `true`): the project/global default for writing each subagent's `.output` transcript. Toggle via `/agents → Settings → Output transcript`, or set `false` in `subagents.json` to make transcripts opt-in project-wide — useful when run transcripts shouldn't sit on disk for backup or DLP tooling to pick up. A custom agent's `output_transcript` frontmatter overrides this per agent. Applied live at spawn time. Governs only the transcript, not `persist_session`, worktree commits, or memory files.
511
+ **Output transcript** (`outputTranscript`, default `true`): the project/global default for writing each subagent's temporary `.output` transcript and durable project-local history copy under `.pi-subagents/agent-transcripts/`. Toggle via `/agents → Settings → Output transcript`, or set `false` in `subagents.json` to make both transcripts opt-in project-wide. A custom agent's `output_transcript` frontmatter overrides this per agent. Applied live at spawn time. Governs only the transcript, not `persist_session`, worktree commits, or memory files.
512
512
 
513
513
  **Tool description** (`toolDescriptionMode`, default `"full"`): which Agent tool description the LLM sees. `"full"` is the rich Claude Code-style prompt (~1,400 tokens with the default agents); `"compact"` is ~75% smaller — one-line agent type list, terse usage notes — for small/local models where tool-spec tokens are expensive. Per-option details stay in the parameter descriptions in every mode (the parameter schema is never customizable). Applies on the next pi session.
514
514
 
@@ -0,0 +1,22 @@
1
+ /** Durable, project-local transcript storage for subagents. */
2
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
3
+ /**
4
+ * Ensure project-local subagent artifacts are ignored by git.
5
+ *
6
+ * The rule is appended instead of rewriting the file. This preserves any
7
+ * existing user-owned rules and makes concurrent callers harmless (duplicate
8
+ * `*` rules are semantically equivalent).
9
+ */
10
+ export declare function ensureSubagentsGitignore(cwd: string): string;
11
+ /** Return the durable transcript path for an agent. */
12
+ export declare function createAgentHistoryPath(cwd: string, agentId: string, agentType?: string): string;
13
+ /** Return the project-relative path stored in the parent session record. */
14
+ export declare function agentHistoryLocator(cwd: string, historyPath: string): string;
15
+ /** Resolve only paths in this package's project-local transcript namespace. */
16
+ export declare function resolveAgentHistoryPath(cwd: string, locator: string): string | undefined;
17
+ /**
18
+ * Read persisted transcript entries into the message shape used by the live
19
+ * conversation viewer. Malformed lines and unknown records are skipped so one
20
+ * damaged entry cannot hide the rest of a history.
21
+ */
22
+ export declare function readAgentHistory(cwd: string, locator: string): AgentSession["messages"] | undefined;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ /** Durable, project-local transcript storage for subagents. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ensureSubagentsGitignore = ensureSubagentsGitignore;
5
+ exports.createAgentHistoryPath = createAgentHistoryPath;
6
+ exports.agentHistoryLocator = agentHistoryLocator;
7
+ exports.resolveAgentHistoryPath = resolveAgentHistoryPath;
8
+ exports.readAgentHistory = readAgentHistory;
9
+ const node_fs_1 = require("node:fs");
10
+ const node_path_1 = require("node:path");
11
+ const SUBAGENTS_DIR = ".pi-subagents";
12
+ const TRANSCRIPTS_DIR = "agent-transcripts";
13
+ const MAX_HISTORY_BYTES = 20 * 1024 * 1024;
14
+ /**
15
+ * Ensure project-local subagent artifacts are ignored by git.
16
+ *
17
+ * The rule is appended instead of rewriting the file. This preserves any
18
+ * existing user-owned rules and makes concurrent callers harmless (duplicate
19
+ * `*` rules are semantically equivalent).
20
+ */
21
+ function ensureSubagentsGitignore(cwd) {
22
+ const directory = (0, node_path_1.join)(cwd, SUBAGENTS_DIR);
23
+ (0, node_fs_1.mkdirSync)(directory, { recursive: true });
24
+ const path = (0, node_path_1.join)(directory, ".gitignore");
25
+ let content = "";
26
+ try {
27
+ content = (0, node_fs_1.readFileSync)(path, "utf8");
28
+ }
29
+ catch {
30
+ // The append below also creates a missing file.
31
+ }
32
+ if (!content.split(/\r?\n/).some((line) => line.trim() === "*")) {
33
+ const separator = content.length > 0 && !/[\r\n]$/.test(content) ? "\n" : "";
34
+ (0, node_fs_1.appendFileSync)(path, `${separator}*\n`, "utf8");
35
+ }
36
+ return path;
37
+ }
38
+ /** Return the durable transcript path for an agent. */
39
+ function createAgentHistoryPath(cwd, agentId, agentType = "agent") {
40
+ ensureSubagentsGitignore(cwd);
41
+ const directory = (0, node_path_1.join)(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
42
+ (0, node_fs_1.mkdirSync)(directory, { recursive: true });
43
+ const safeId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
44
+ const safeType = agentType.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
45
+ return (0, node_path_1.join)(directory, `${safeId}_${safeType}_transcript.jsonl`);
46
+ }
47
+ /** Return the project-relative path stored in the parent session record. */
48
+ function agentHistoryLocator(cwd, historyPath) {
49
+ return (0, node_path_1.relative)(cwd, historyPath).split(node_path_1.sep).join("/");
50
+ }
51
+ /** Resolve only paths in this package's project-local transcript namespace. */
52
+ function resolveAgentHistoryPath(cwd, locator) {
53
+ if (!locator || (0, node_path_1.isAbsolute)(locator))
54
+ return undefined;
55
+ const root = (0, node_path_1.resolve)(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
56
+ const candidate = (0, node_path_1.resolve)(cwd, locator);
57
+ if (candidate !== root && !candidate.startsWith(`${root}${node_path_1.sep}`))
58
+ return undefined;
59
+ return candidate;
60
+ }
61
+ /**
62
+ * Read persisted transcript entries into the message shape used by the live
63
+ * conversation viewer. Malformed lines and unknown records are skipped so one
64
+ * damaged entry cannot hide the rest of a history.
65
+ */
66
+ function readAgentHistory(cwd, locator) {
67
+ const path = resolveAgentHistoryPath(cwd, locator);
68
+ if (!path)
69
+ return undefined;
70
+ let raw;
71
+ try {
72
+ raw = (0, node_fs_1.readFileSync)(path, "utf8");
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ if (raw.length > MAX_HISTORY_BYTES)
78
+ raw = raw.slice(0, MAX_HISTORY_BYTES);
79
+ const messages = [];
80
+ for (const line of raw.split(/\r?\n/)) {
81
+ if (!line.trim())
82
+ continue;
83
+ try {
84
+ const entry = JSON.parse(line);
85
+ const message = entry.message;
86
+ if (!message || typeof message !== "object")
87
+ continue;
88
+ const role = message.role;
89
+ if (typeof role !== "string")
90
+ continue;
91
+ messages.push(message);
92
+ }
93
+ catch {
94
+ // Ignore malformed/truncated JSONL records.
95
+ }
96
+ }
97
+ return messages.length > 0 ? messages : undefined;
98
+ }
@@ -121,6 +121,14 @@ export declare class AgentManager {
121
121
  */
122
122
  steer(id: string, message: string): boolean;
123
123
  getRecord(id: string): AgentRecord | undefined;
124
+ /**
125
+ * Restore terminal records persisted in a parent session.
126
+ *
127
+ * Restored records deliberately have no live session, promise, or abort
128
+ * controller. Invalid data is ignored because session entries are persisted
129
+ * extension data and may have been written by an older version.
130
+ */
131
+ restoreCompleted(records: readonly unknown[]): void;
124
132
  listAgents(): AgentRecord[];
125
133
  abort(id: string): boolean;
126
134
  /** Dispose a record's session and remove it from the map. */
@@ -16,6 +16,13 @@ const usage_js_1 = require("./usage.js");
16
16
  const worktree_js_1 = require("./worktree.js");
17
17
  /** Default max concurrent background agents. */
18
18
  const DEFAULT_MAX_CONCURRENT = 4;
19
+ const TERMINAL_STATUSES = new Set([
20
+ "completed",
21
+ "steered",
22
+ "aborted",
23
+ "stopped",
24
+ "error",
25
+ ]);
19
26
  /**
20
27
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
21
28
  * (parent cwd). Anything else must be an absolute path to an existing
@@ -39,6 +46,36 @@ function assertValidSpawnCwd(cwd) {
39
46
  throw new Error(`SpawnOptions.cwd is not a directory: "${cwd}"`);
40
47
  }
41
48
  }
49
+ const RESTORABLE_STATUSES = new Set([
50
+ "completed",
51
+ "steered",
52
+ "aborted",
53
+ "stopped",
54
+ "error",
55
+ ]);
56
+ function isRestorableRecord(value) {
57
+ if (!value || typeof value !== "object")
58
+ return false;
59
+ const record = value;
60
+ const transcriptPath = record.transcriptPath;
61
+ const validTranscriptPath = transcriptPath === undefined ||
62
+ (typeof transcriptPath === "string" &&
63
+ transcriptPath.startsWith(".pi-subagents/agent-transcripts/") &&
64
+ !transcriptPath.includes("..") &&
65
+ !transcriptPath.includes("\\"));
66
+ return (typeof record.id === "string" &&
67
+ record.id.length > 0 &&
68
+ typeof record.type === "string" &&
69
+ typeof record.description === "string" &&
70
+ RESTORABLE_STATUSES.has(record.status) &&
71
+ typeof record.startedAt === "number" &&
72
+ Number.isFinite(record.startedAt) &&
73
+ typeof record.completedAt === "number" &&
74
+ Number.isFinite(record.completedAt) &&
75
+ (record.result === undefined || typeof record.result === "string") &&
76
+ (record.error === undefined || typeof record.error === "string") &&
77
+ validTranscriptPath);
78
+ }
42
79
  class AgentManager {
43
80
  agents = new Map();
44
81
  cleanupInterval;
@@ -425,6 +462,41 @@ class AgentManager {
425
462
  getRecord(id) {
426
463
  return this.agents.get(id);
427
464
  }
465
+ /**
466
+ * Restore terminal records persisted in a parent session.
467
+ *
468
+ * Restored records deliberately have no live session, promise, or abort
469
+ * controller. Invalid data is ignored because session entries are persisted
470
+ * extension data and may have been written by an older version.
471
+ */
472
+ restoreCompleted(records) {
473
+ const restoredIds = new Set();
474
+ // getBranch() is chronological; newest persisted state wins on duplicate IDs.
475
+ for (const value of [...records].reverse()) {
476
+ if (!isRestorableRecord(value))
477
+ continue;
478
+ if (restoredIds.has(value.id) || this.agents.has(value.id))
479
+ continue;
480
+ restoredIds.add(value.id);
481
+ this.agents.set(value.id, {
482
+ id: value.id,
483
+ type: value.type,
484
+ description: value.description,
485
+ status: value.status,
486
+ result: value.result,
487
+ error: value.error,
488
+ transcriptPath: value.transcriptPath,
489
+ toolUses: 0,
490
+ startedAt: value.startedAt,
491
+ completedAt: value.completedAt,
492
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
493
+ compactionCount: 0,
494
+ // Historical records have no inline tool surface and should remain
495
+ // visible in the background widget.
496
+ isBackground: true,
497
+ });
498
+ }
499
+ }
428
500
  listAgents() {
429
501
  return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
430
502
  }
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ const enabled_models_js_1 = require("./enabled-models.js");
27
27
  const group_join_js_1 = require("./group-join.js");
28
28
  const invocation_config_js_1 = require("./invocation-config.js");
29
29
  const model_resolver_js_1 = require("./model-resolver.js");
30
+ const agent_history_js_1 = require("./agent-history.js");
30
31
  const output_file_js_1 = require("./output-file.js");
31
32
  const schedule_js_1 = require("./schedule.js");
32
33
  const schedule_store_js_1 = require("./schedule-store.js");
@@ -410,11 +411,14 @@ function default_1(pi) {
410
411
  else {
411
412
  pi.events.emit("subagents:completed", eventData);
412
413
  }
413
- // Persist final record for cross-extension history reconstruction
414
+ // Persist final record for cross-extension history reconstruction. Keep
415
+ // the transcript body in a project-local file rather than inflating the
416
+ // parent session JSONL; only its project-relative locator is persisted.
414
417
  pi.appendEntry("subagents:record", {
415
418
  id: record.id, type: record.type, description: record.description,
416
419
  status: record.status, result: record.result, error: record.error,
417
420
  startedAt: record.startedAt, completedAt: record.completedAt,
421
+ transcriptPath: record.transcriptPath,
418
422
  });
419
423
  // Skip notification if result was already consumed via get_subagent_result
420
424
  if (record.resultConsumed) {
@@ -511,7 +515,23 @@ function default_1(pi) {
511
515
  // bound session_start, so a filtered-out activation never advertises (#142).
512
516
  pi.on("session_start", async (_event, ctx) => {
513
517
  currentCtx = ctx;
518
+ try {
519
+ (0, agent_history_js_1.ensureSubagentsGitignore)(ctx.cwd);
520
+ }
521
+ catch (err) {
522
+ console.warn("[pi-subagents] Failed to protect project-local history:", err);
523
+ }
514
524
  manager.clearCompleted(true);
525
+ const historicalRecords = ctx.sessionManager
526
+ .getBranch()
527
+ .filter((entry) => entry?.type === "custom" && entry.customType === "subagents:record")
528
+ .map((entry) => entry.data);
529
+ manager.restoreCompleted(historicalRecords);
530
+ // Bind the UI here as well as on tool execution so rehydrated terminal
531
+ // records are immediately visible and openable from FleetView after reload.
532
+ widget.setUICtx(ctx.ui);
533
+ fleet.setUICtx(ctx.ui);
534
+ fleet.update();
515
535
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
516
536
  // fires once per activation, but a double-bind must not leak listeners.
517
537
  if (!rpcHandle) {
@@ -564,7 +584,7 @@ function default_1(pi) {
564
584
  const widget = new agent_widget_js_1.AgentWidget(manager, agentActivity, getWidgetMode);
565
585
  function setWidgetMode(m) { widgetMode = m; widget.update(); }
566
586
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
567
- const fleet = new fleet_list_js_1.FleetList(manager, agentActivity);
587
+ const fleet = new fleet_list_js_1.FleetList(manager, agentActivity, () => currentCtx?.cwd);
568
588
  let fleetViewEnabled = true;
569
589
  function isFleetViewEnabled() { return fleetViewEnabled; }
570
590
  function setFleetViewEnabled(b) { fleetViewEnabled = b; fleet.setEnabled(b); }
@@ -1034,6 +1054,19 @@ Terse command-style prompts produce shallow, generic work.
1034
1054
  return;
1035
1055
  rec.outputFile = (0, output_file_js_1.createOutputFilePath)(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
1036
1056
  (0, output_file_js_1.writeInitialEntry)(rec.outputFile, agentId, params.prompt, ctx.cwd);
1057
+ // Keep the existing /tmp output for compatibility, while also writing a
1058
+ // durable project-local copy for reload-safe, read-only history viewing.
1059
+ try {
1060
+ rec.historyFile = (0, agent_history_js_1.createAgentHistoryPath)(ctx.cwd, agentId, subagentType);
1061
+ rec.transcriptPath = (0, agent_history_js_1.agentHistoryLocator)(ctx.cwd, rec.historyFile);
1062
+ (0, output_file_js_1.writeInitialEntry)(rec.historyFile, agentId, params.prompt, ctx.cwd);
1063
+ }
1064
+ catch (err) {
1065
+ // History is best effort; never disable the existing output transcript.
1066
+ rec.historyFile = undefined;
1067
+ rec.transcriptPath = undefined;
1068
+ console.warn("[pi-subagents] Failed to create durable transcript:", err);
1069
+ }
1037
1070
  };
1038
1071
  const parentModelId = ctx.model?.id;
1039
1072
  const effectiveModelId = model?.id;
@@ -1134,7 +1167,7 @@ Terse command-style prompts produce shallow, generic work.
1134
1167
  origBgOnSession(session);
1135
1168
  const rec = manager.getRecord(id);
1136
1169
  if (rec?.outputFile) {
1137
- rec.outputCleanup = (0, output_file_js_1.streamToOutputFile)(session, rec.outputFile, id, ctx.cwd);
1170
+ rec.outputCleanup = (0, output_file_js_1.streamToOutputFile)(session, rec.outputFile, id, ctx.cwd, rec.historyFile);
1138
1171
  }
1139
1172
  };
1140
1173
  try {
@@ -1240,7 +1273,7 @@ Terse command-style prompts produce shallow, generic work.
1240
1273
  if (fgId) {
1241
1274
  const rec = manager.getRecord(fgId);
1242
1275
  if (rec?.outputFile) {
1243
- rec.outputCleanup = (0, output_file_js_1.streamToOutputFile)(session, rec.outputFile, fgId, ctx.cwd);
1276
+ rec.outputCleanup = (0, output_file_js_1.streamToOutputFile)(session, rec.outputFile, fgId, ctx.cwd, rec.historyFile);
1244
1277
  }
1245
1278
  }
1246
1279
  };
@@ -1611,15 +1644,25 @@ Terse command-style prompts produce shallow, generic work.
1611
1644
  ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1612
1645
  return;
1613
1646
  }
1614
- const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1615
- const session = record.session;
1647
+ const { ConversationViewer, VIEWPORT_HEIGHT_PCT, createStaticConversationSource } = await import("./ui/conversation-viewer.js");
1648
+ const session = record.session ?? (record.transcriptPath
1649
+ ? (() => {
1650
+ const messages = (0, agent_history_js_1.readAgentHistory)(ctx.cwd, record.transcriptPath);
1651
+ return messages ? createStaticConversationSource(messages) : undefined;
1652
+ })()
1653
+ : undefined);
1654
+ if (!session) {
1655
+ ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no history available.`, "info");
1656
+ return;
1657
+ }
1616
1658
  const activity = agentActivity.get(record.id);
1659
+ const isLive = record.session !== undefined;
1617
1660
  await ctx.ui.custom((tui, theme, keybindings, done) => {
1618
- return new ConversationViewer(tui, session, record, activity, theme, done, () => {
1661
+ return new ConversationViewer(tui, session, record, activity, theme, done, isLive ? () => {
1619
1662
  if (manager.abort(record.id)) {
1620
1663
  ctx.ui.notify(`Stopped "${record.description}".`, "info");
1621
1664
  }
1622
- }, keybindings, (message) => manager.steer(record.id, message));
1665
+ } : undefined, keybindings, isLive ? (message) => manager.steer(record.id, message) : undefined);
1623
1666
  }, {
1624
1667
  overlay: true,
1625
1668
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
@@ -21,4 +21,4 @@ export declare function writeInitialEntry(path: string, agentId: string, prompt:
21
21
  * Subscribe to session events and flush new messages to the output file on each turn_end.
22
22
  * Returns a cleanup function that does a final flush and unsubscribes.
23
23
  */
24
- export declare function streamToOutputFile(session: AgentSession, path: string, agentId: string, cwd: string): () => void;
24
+ export declare function streamToOutputFile(session: AgentSession, path: string, agentId: string, cwd: string, historyPath?: string): () => void;
@@ -60,7 +60,8 @@ function writeInitialEntry(path, agentId, prompt, cwd) {
60
60
  * Subscribe to session events and flush new messages to the output file on each turn_end.
61
61
  * Returns a cleanup function that does a final flush and unsubscribes.
62
62
  */
63
- function streamToOutputFile(session, path, agentId, cwd) {
63
+ function streamToOutputFile(session, path, agentId, cwd, historyPath) {
64
+ const outputPaths = historyPath && historyPath !== path ? [path, historyPath] : [path];
64
65
  let writtenCount = 1; // initial user prompt already written
65
66
  const flush = () => {
66
67
  const messages = session.messages;
@@ -74,10 +75,12 @@ function streamToOutputFile(session, path, agentId, cwd) {
74
75
  timestamp: new Date().toISOString(),
75
76
  cwd,
76
77
  };
77
- try {
78
- (0, node_fs_1.appendFileSync)(path, JSON.stringify(entry) + "\n", "utf-8");
78
+ for (const outputPath of outputPaths) {
79
+ try {
80
+ (0, node_fs_1.appendFileSync)(outputPath, JSON.stringify(entry) + "\n", "utf-8");
81
+ }
82
+ catch { /* ignore write errors */ }
79
83
  }
80
- catch { /* ignore write errors */ }
81
84
  writtenCount++;
82
85
  }
83
86
  };
package/dist/types.d.ts CHANGED
@@ -103,6 +103,10 @@ export interface AgentRecord {
103
103
  toolCallId?: string;
104
104
  /** Path to the streaming output transcript file. */
105
105
  outputFile?: string;
106
+ /** Absolute path to the durable project-local transcript while live. */
107
+ historyFile?: string;
108
+ /** Project-relative durable transcript path persisted in the parent session. */
109
+ transcriptPath?: string;
106
110
  /** Cleanup function for the output file stream subscription. */
107
111
  outputCleanup?: () => void;
108
112
  /**
@@ -12,6 +12,9 @@ import { type AgentActivity } from "./agent-widget.js";
12
12
  import { type ViewerKeybindings } from "./viewer-keys.js";
13
13
  /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
14
14
  export declare const VIEWPORT_HEIGHT_PCT = 70;
15
+ /** The live fields needed by the viewer; historical viewers use a static source. */
16
+ export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
17
+ export declare function createStaticConversationSource(messages: AgentSession["messages"]): ConversationSource;
15
18
  export declare class ConversationViewer implements Component {
16
19
  private tui;
17
20
  private session;
@@ -33,7 +36,7 @@ export declare class ConversationViewer implements Component {
33
36
  private keys;
34
37
  /** Steering composer — present while the user is typing a message to the agent. */
35
38
  private composer;
36
- constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void,
39
+ constructor(tui: TUI, session: ConversationSource, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void,
37
40
  /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
38
41
  onStop?: (() => void) | undefined,
39
42
  /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
@@ -7,6 +7,7 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.ConversationViewer = exports.VIEWPORT_HEIGHT_PCT = void 0;
10
+ exports.createStaticConversationSource = createStaticConversationSource;
10
11
  const pi_tui_1 = require("@earendil-works/pi-tui");
11
12
  const context_js_1 = require("../context.js");
12
13
  const usage_js_1 = require("../usage.js");
@@ -17,6 +18,9 @@ const CHROME_LINES_BASE = 6;
17
18
  const MIN_VIEWPORT = 3;
18
19
  /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
19
20
  exports.VIEWPORT_HEIGHT_PCT = 70;
21
+ function createStaticConversationSource(messages) {
22
+ return { messages, subscribe: () => () => { } };
23
+ }
20
24
  class ConversationViewer {
21
25
  tui;
22
26
  session;
@@ -44,6 +44,7 @@ export declare function formatFleetTokens(count: number): string;
44
44
  export declare class FleetList {
45
45
  private manager;
46
46
  private agentActivity;
47
+ private getCwd;
47
48
  private ui;
48
49
  private tui;
49
50
  private inputUnsub;
@@ -57,7 +58,7 @@ export declare class FleetList {
57
58
  /** Set while a conversation overlay is open; calling it closes the overlay. */
58
59
  private viewerClose;
59
60
  private viewingAgentId;
60
- constructor(manager: AgentManager, agentActivity: Map<string, AgentActivity>);
61
+ constructor(manager: AgentManager, agentActivity: Map<string, AgentActivity>, getCwd?: () => string | undefined);
61
62
  setEnabled(enabled: boolean): void;
62
63
  /** Capture the UI context and (re)register the global input handler. */
63
64
  setUICtx(ui: FleetUICtx): void;
@@ -15,7 +15,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.FleetList = void 0;
16
16
  exports.formatFleetElapsed = formatFleetElapsed;
17
17
  exports.formatFleetTokens = formatFleetTokens;
18
+ const node_fs_1 = require("node:fs");
18
19
  const pi_tui_1 = require("@earendil-works/pi-tui");
20
+ const agent_history_js_1 = require("../agent-history.js");
19
21
  const usage_js_1 = require("../usage.js");
20
22
  const agent_widget_js_1 = require("./agent-widget.js");
21
23
  const conversation_viewer_js_1 = require("./conversation-viewer.js");
@@ -57,6 +59,7 @@ function rightAlign(left, right, width) {
57
59
  class FleetList {
58
60
  manager;
59
61
  agentActivity;
62
+ getCwd;
60
63
  ui;
61
64
  tui;
62
65
  inputUnsub;
@@ -70,9 +73,10 @@ class FleetList {
70
73
  /** Set while a conversation overlay is open; calling it closes the overlay. */
71
74
  viewerClose;
72
75
  viewingAgentId;
73
- constructor(manager, agentActivity) {
76
+ constructor(manager, agentActivity, getCwd = () => undefined) {
74
77
  this.manager = manager;
75
78
  this.agentActivity = agentActivity;
79
+ this.getCwd = getCwd;
76
80
  }
77
81
  // ---- Lifecycle ----
78
82
  setEnabled(enabled) {
@@ -172,9 +176,17 @@ class FleetList {
172
176
  agentRecords() {
173
177
  const now = Date.now();
174
178
  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)))
179
+ .filter(a => {
180
+ const live = a.session && (a.status === "running" || a.status === "queued"
181
+ || a.id === this.viewingAgentId
182
+ || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS));
183
+ const cwd = this.getCwd();
184
+ const historyPath = cwd && a.transcriptPath
185
+ ? (0, agent_history_js_1.resolveAgentHistoryPath)(cwd, a.transcriptPath)
186
+ : undefined;
187
+ const history = !!historyPath && (0, node_fs_1.existsSync)(historyPath);
188
+ return !!live || history;
189
+ })
178
190
  .sort((a, b) => a.startedAt - b.startedAt);
179
191
  }
180
192
  roster() {
@@ -275,19 +287,25 @@ class FleetList {
275
287
  const record = entry.record;
276
288
  if (!this.ui)
277
289
  return;
278
- if (!record.session) {
279
- this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
290
+ const session = record.session ?? (record.transcriptPath && this.getCwd()
291
+ ? (() => {
292
+ const messages = (0, agent_history_js_1.readAgentHistory)(this.getCwd(), record.transcriptPath);
293
+ return messages ? (0, conversation_viewer_js_1.createStaticConversationSource)(messages) : undefined;
294
+ })()
295
+ : undefined);
296
+ if (!session) {
297
+ this.ui.notify(`Agent is ${record.status} — no history available.`, "info");
280
298
  return;
281
299
  }
282
- const session = record.session;
283
300
  const activity = this.agentActivity.get(record.id);
301
+ const isLive = record.session !== undefined;
284
302
  this.viewingAgentId = record.id;
285
303
  void this.ui.custom((tui, theme, keybindings, done) => {
286
304
  this.viewerClose = () => done(undefined);
287
- return new conversation_viewer_js_1.ConversationViewer(tui, session, record, activity, theme, done, () => {
305
+ return new conversation_viewer_js_1.ConversationViewer(tui, session, record, activity, theme, done, isLive ? () => {
288
306
  if (this.manager.abort(record.id))
289
307
  this.ui?.notify(`Stopped "${record.description}".`, "info");
290
- }, keybindings, (message) => this.manager.steer(record.id, message));
308
+ } : undefined, keybindings, isLive ? (message) => this.manager.steer(record.id, message) : undefined);
291
309
  }, {
292
310
  overlay: true,
293
311
  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.1",
3
+ "version": "0.15.3",
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,100 @@
1
+ /** Durable, project-local transcript storage for subagents. */
2
+
3
+ import { appendFileSync, 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
+ /**
66
+ * Read persisted transcript entries into the message shape used by the live
67
+ * conversation viewer. Malformed lines and unknown records are skipped so one
68
+ * damaged entry cannot hide the rest of a history.
69
+ */
70
+ export function readAgentHistory(
71
+ cwd: string,
72
+ locator: string,
73
+ ): AgentSession["messages"] | undefined {
74
+ const path = resolveAgentHistoryPath(cwd, locator);
75
+ if (!path) return undefined;
76
+
77
+ let raw: string;
78
+ try {
79
+ raw = readFileSync(path, "utf8");
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ if (raw.length > MAX_HISTORY_BYTES) raw = raw.slice(0, MAX_HISTORY_BYTES);
84
+
85
+ const messages: AgentSession["messages"] = [];
86
+ for (const line of raw.split(/\r?\n/)) {
87
+ if (!line.trim()) continue;
88
+ try {
89
+ const entry = JSON.parse(line) as { message?: unknown };
90
+ const message = entry.message;
91
+ if (!message || typeof message !== "object") continue;
92
+ const role = (message as { role?: unknown }).role;
93
+ if (typeof role !== "string") continue;
94
+ messages.push(message as AgentSession["messages"][number]);
95
+ } catch {
96
+ // Ignore malformed/truncated JSONL records.
97
+ }
98
+ }
99
+ return messages.length > 0 ? messages : undefined;
100
+ }
@@ -23,6 +23,13 @@ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; toke
23
23
 
24
24
  /** Default max concurrent background agents. */
25
25
  const DEFAULT_MAX_CONCURRENT = 4;
26
+ const TERMINAL_STATUSES = new Set<AgentRecord["status"]>([
27
+ "completed",
28
+ "steered",
29
+ "aborted",
30
+ "stopped",
31
+ "error",
32
+ ]);
26
33
 
27
34
  /**
28
35
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
@@ -97,6 +104,45 @@ interface SpawnOptions {
97
104
  onCompaction?: (info: CompactionInfo) => void;
98
105
  }
99
106
 
107
+ const RESTORABLE_STATUSES = new Set<AgentRecord["status"]>([
108
+ "completed",
109
+ "steered",
110
+ "aborted",
111
+ "stopped",
112
+ "error",
113
+ ]);
114
+
115
+ type PersistedAgentRecord = Pick<
116
+ AgentRecord,
117
+ "id" | "type" | "description" | "status" | "result" | "error" | "startedAt" | "completedAt" | "transcriptPath"
118
+ >;
119
+
120
+ function isRestorableRecord(value: unknown): value is PersistedAgentRecord {
121
+ if (!value || typeof value !== "object") return false;
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("\\"));
130
+ return (
131
+ typeof record.id === "string" &&
132
+ record.id.length > 0 &&
133
+ typeof record.type === "string" &&
134
+ typeof record.description === "string" &&
135
+ RESTORABLE_STATUSES.has(record.status as AgentRecord["status"]) &&
136
+ typeof record.startedAt === "number" &&
137
+ Number.isFinite(record.startedAt) &&
138
+ typeof record.completedAt === "number" &&
139
+ Number.isFinite(record.completedAt) &&
140
+ (record.result === undefined || typeof record.result === "string") &&
141
+ (record.error === undefined || typeof record.error === "string") &&
142
+ validTranscriptPath
143
+ );
144
+ }
145
+
100
146
  export class AgentManager {
101
147
  private agents = new Map<string, AgentRecord>();
102
148
  private cleanupInterval: ReturnType<typeof setInterval>;
@@ -510,6 +556,41 @@ export class AgentManager {
510
556
  return this.agents.get(id);
511
557
  }
512
558
 
559
+ /**
560
+ * Restore terminal records persisted in a parent session.
561
+ *
562
+ * Restored records deliberately have no live session, promise, or abort
563
+ * controller. Invalid data is ignored because session entries are persisted
564
+ * extension data and may have been written by an older version.
565
+ */
566
+ restoreCompleted(records: readonly unknown[]): void {
567
+ const restoredIds = new Set<string>();
568
+ // getBranch() is chronological; newest persisted state wins on duplicate IDs.
569
+ for (const value of [...records].reverse()) {
570
+ if (!isRestorableRecord(value)) continue;
571
+ if (restoredIds.has(value.id) || this.agents.has(value.id)) continue;
572
+ restoredIds.add(value.id);
573
+
574
+ this.agents.set(value.id, {
575
+ id: value.id,
576
+ type: value.type,
577
+ description: value.description,
578
+ status: value.status,
579
+ result: value.result,
580
+ error: value.error,
581
+ transcriptPath: value.transcriptPath,
582
+ toolUses: 0,
583
+ startedAt: value.startedAt,
584
+ completedAt: value.completedAt,
585
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
586
+ compactionCount: 0,
587
+ // Historical records have no inline tool surface and should remain
588
+ // visible in the background widget.
589
+ isBackground: true,
590
+ });
591
+ }
592
+ }
593
+
513
594
  listAgents(): AgentRecord[] {
514
595
  return [...this.agents.values()].sort(
515
596
  (a, b) => b.startedAt - a.startedAt,
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, readAgentHistory, ensureSubagentsGitignore } 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,7 +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);
583
+ const historicalRecords = ctx.sessionManager
584
+ .getBranch()
585
+ .filter((entry: any) => entry?.type === "custom" && entry.customType === "subagents:record")
586
+ .map((entry: any) => entry.data);
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();
574
593
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
575
594
  // fires once per activation, but a double-bind must not leak listeners.
576
595
  if (!rpcHandle) {
@@ -625,7 +644,7 @@ export default function (pi: ExtensionAPI) {
625
644
  function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
626
645
 
627
646
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
628
- const fleet = new FleetList(manager, agentActivity);
647
+ const fleet = new FleetList(manager, agentActivity, () => currentCtx?.cwd);
629
648
  let fleetViewEnabled = true;
630
649
  function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
631
650
  function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
@@ -1156,6 +1175,19 @@ Terse command-style prompts produce shallow, generic work.
1156
1175
  if (!rec || !outputTranscript) return;
1157
1176
  rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
1158
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
+ }
1159
1191
  };
1160
1192
 
1161
1193
  const parentModelId = ctx.model?.id;
@@ -1265,7 +1297,7 @@ Terse command-style prompts produce shallow, generic work.
1265
1297
  origBgOnSession(session);
1266
1298
  const rec = manager.getRecord(id);
1267
1299
  if (rec?.outputFile) {
1268
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
1300
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, rec.historyFile);
1269
1301
  }
1270
1302
  };
1271
1303
 
@@ -1381,7 +1413,7 @@ Terse command-style prompts produce shallow, generic work.
1381
1413
  if (fgId) {
1382
1414
  const rec = manager.getRecord(fgId);
1383
1415
  if (rec?.outputFile) {
1384
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
1416
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, rec.historyFile);
1385
1417
  }
1386
1418
  }
1387
1419
  };
@@ -1788,17 +1820,30 @@ Terse command-style prompts produce shallow, generic work.
1788
1820
  return;
1789
1821
  }
1790
1822
 
1791
- const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1792
- const session = record.session;
1823
+ const { ConversationViewer, VIEWPORT_HEIGHT_PCT, createStaticConversationSource } = await import("./ui/conversation-viewer.js");
1824
+ const session = record.session ?? (record.transcriptPath
1825
+ ? (() => {
1826
+ const messages = readAgentHistory(ctx.cwd, record.transcriptPath!);
1827
+ return messages ? createStaticConversationSource(messages) : undefined;
1828
+ })()
1829
+ : undefined);
1830
+ if (!session) {
1831
+ ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no history available.`, "info");
1832
+ return;
1833
+ }
1793
1834
  const activity = agentActivity.get(record.id);
1835
+ const isLive = record.session !== undefined;
1794
1836
 
1795
1837
  await ctx.ui.custom<undefined>(
1796
1838
  (tui, theme, keybindings, done) => {
1797
- return new ConversationViewer(tui, session, record, activity, theme, done, () => {
1798
- if (manager.abort(record.id)) {
1799
- ctx.ui.notify(`Stopped "${record.description}".`, "info");
1800
- }
1801
- }, keybindings, (message: string) => manager.steer(record.id, message));
1839
+ return new ConversationViewer(tui, session, record, activity, theme, done,
1840
+ isLive ? () => {
1841
+ if (manager.abort(record.id)) {
1842
+ ctx.ui.notify(`Stopped "${record.description}".`, "info");
1843
+ }
1844
+ } : undefined,
1845
+ keybindings,
1846
+ isLive ? (message: string) => manager.steer(record.id, message) : undefined);
1802
1847
  },
1803
1848
  {
1804
1849
  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
  /**
@@ -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,
@@ -11,12 +11,14 @@
11
11
  * can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
12
12
  */
13
13
 
14
+ import { existsSync } from "node:fs";
14
15
  import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
16
  import type { AgentManager } from "../agent-manager.js";
17
+ import { readAgentHistory, resolveAgentHistoryPath } from "../agent-history.js";
16
18
  import type { AgentRecord } from "../types.js";
17
19
  import { getLifetimeTotal } from "../usage.js";
18
20
  import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
19
- import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
21
+ import { ConversationViewer, createStaticConversationSource, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
20
22
 
21
23
  /** Widget key for the below-editor fleet list. */
22
24
  const FLEET_KEY = "fleet";
@@ -93,6 +95,7 @@ export class FleetList {
93
95
  constructor(
94
96
  private manager: AgentManager,
95
97
  private agentActivity: Map<string, AgentActivity>,
98
+ private getCwd: () => string | undefined = () => undefined,
96
99
  ) {}
97
100
 
98
101
  // ---- Lifecycle ----
@@ -188,11 +191,19 @@ export class FleetList {
188
191
  private agentRecords(): AgentRecord[] {
189
192
  const now = Date.now();
190
193
  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
- ))
194
+ .filter(a => {
195
+ const live = a.session && (
196
+ a.status === "running" || a.status === "queued"
197
+ || a.id === this.viewingAgentId
198
+ || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
199
+ );
200
+ const cwd = this.getCwd();
201
+ const historyPath = cwd && a.transcriptPath
202
+ ? resolveAgentHistoryPath(cwd, a.transcriptPath)
203
+ : undefined;
204
+ const history = !!historyPath && existsSync(historyPath);
205
+ return !!live || history;
206
+ })
196
207
  .sort((a, b) => a.startedAt - b.startedAt);
197
208
  }
198
209
 
@@ -287,12 +298,18 @@ export class FleetList {
287
298
  }
288
299
  const record = entry.record;
289
300
  if (!this.ui) return;
290
- if (!record.session) {
291
- this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
301
+ const session = record.session ?? (record.transcriptPath && this.getCwd()
302
+ ? (() => {
303
+ const messages = readAgentHistory(this.getCwd()!, record.transcriptPath!);
304
+ return messages ? createStaticConversationSource(messages) : undefined;
305
+ })()
306
+ : undefined);
307
+ if (!session) {
308
+ this.ui.notify(`Agent is ${record.status} — no history available.`, "info");
292
309
  return;
293
310
  }
294
- const session = record.session;
295
311
  const activity = this.agentActivity.get(record.id);
312
+ const isLive = record.session !== undefined;
296
313
  this.viewingAgentId = record.id;
297
314
 
298
315
  void this.ui.custom<undefined>(
@@ -305,11 +322,11 @@ export class FleetList {
305
322
  activity,
306
323
  theme,
307
324
  done,
308
- () => {
325
+ isLive ? () => {
309
326
  if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
310
- },
327
+ } : undefined,
311
328
  keybindings,
312
- (message: string) => this.manager.steer(record.id, message),
329
+ isLive ? (message: string) => this.manager.steer(record.id, message) : undefined,
313
330
  );
314
331
  },
315
332
  {
@@ -0,0 +1,59 @@
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
+ readAgentHistory,
10
+ resolveAgentHistoryPath,
11
+ } from "../src/agent-history.js";
12
+ import { writeInitialEntry } from "../src/output-file.js";
13
+
14
+ const tempDirectories: string[] = [];
15
+
16
+ afterEach(() => {
17
+ for (const directory of tempDirectories.splice(0)) {
18
+ rmSync(directory, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ function tempProject(): string {
23
+ const path = mkdtempSync(join(tmpdir(), "pi-subagents-history-test-"));
24
+ tempDirectories.push(path);
25
+ return path;
26
+ }
27
+
28
+ describe("project-local subagent history", () => {
29
+ it("creates a gitignore rule and preserves existing rules", () => {
30
+ const cwd = tempProject();
31
+ const root = join(cwd, ".pi-subagents");
32
+ const gitignore = join(root, ".gitignore");
33
+ const path = ensureSubagentsGitignore(cwd);
34
+
35
+ expect(path).toBe(gitignore);
36
+ expect(readFileSync(gitignore, "utf8")).toBe("*\n");
37
+
38
+ writeFileSync(gitignore, "keep-this\n", "utf8");
39
+ ensureSubagentsGitignore(cwd);
40
+ ensureSubagentsGitignore(cwd);
41
+ expect(readFileSync(gitignore, "utf8")).toBe("keep-this\n*\n");
42
+ });
43
+
44
+ it("creates a bounded project-relative transcript locator", () => {
45
+ const cwd = tempProject();
46
+ const historyPath = createAgentHistoryPath(cwd, "agent-1", "Explore");
47
+ const locator = agentHistoryLocator(cwd, historyPath);
48
+
49
+ expect(locator).toBe(".pi-subagents/agent-transcripts/agent-1_Explore_transcript.jsonl");
50
+ expect(resolveAgentHistoryPath(cwd, locator)).toBe(historyPath);
51
+ expect(resolveAgentHistoryPath(cwd, ".pi-subagents/other.jsonl")).toBeUndefined();
52
+ expect(resolveAgentHistoryPath(cwd, ".pi-subagents/agent-transcripts/../other.jsonl")).toBeUndefined();
53
+
54
+ writeInitialEntry(historyPath, "agent-1", "hello", cwd);
55
+ const messages = readAgentHistory(cwd, locator);
56
+ expect(messages).toHaveLength(1);
57
+ expect(messages?.[0]).toMatchObject({ role: "user", content: "hello" });
58
+ });
59
+ });
@@ -0,0 +1,87 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { AgentManager } from "../src/agent-manager.js";
3
+
4
+ type PersistedRecord = {
5
+ id: string;
6
+ type: string;
7
+ description: string;
8
+ status: string;
9
+ result?: unknown;
10
+ error?: unknown;
11
+ startedAt: number;
12
+ completedAt: number;
13
+ transcriptPath?: string;
14
+ };
15
+
16
+ function persisted(overrides: Partial<PersistedRecord> = {}): PersistedRecord {
17
+ return {
18
+ id: "agent-1",
19
+ type: "general-purpose",
20
+ description: "history test",
21
+ status: "completed",
22
+ result: "restored result",
23
+ startedAt: 100,
24
+ completedAt: 200,
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ describe("AgentManager.restoreCompleted", () => {
30
+ let manager: AgentManager | undefined;
31
+
32
+ afterEach(() => {
33
+ manager?.dispose();
34
+ manager = undefined;
35
+ });
36
+
37
+ it("restores terminal records without creating live runtime state", () => {
38
+ manager = new AgentManager();
39
+
40
+ manager.restoreCompleted([
41
+ persisted(),
42
+ persisted({ id: "agent-error", status: "error", error: "failed", transcriptPath: ".pi-subagents/agent-transcripts/agent-error.jsonl" }),
43
+ persisted({ id: "agent-running", status: "running" }),
44
+ persisted({ id: "agent-queued", status: "queued" }),
45
+ persisted({ id: "agent-invalid", completedAt: Number.NaN }),
46
+ persisted({ id: "agent-unsafe", transcriptPath: ".pi-subagents/../private.jsonl" }),
47
+ ]);
48
+
49
+ expect(manager.listAgents().map((record) => record.id).sort()).toEqual([
50
+ "agent-1",
51
+ "agent-error",
52
+ ]);
53
+ const restored = manager.getRecord("agent-1");
54
+ expect(restored).toMatchObject({
55
+ status: "completed",
56
+ result: "restored result",
57
+ toolUses: 0,
58
+ lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
59
+ compactionCount: 0,
60
+ isBackground: true,
61
+ transcriptPath: undefined,
62
+ });
63
+ expect(restored?.session).toBeUndefined();
64
+ expect(restored?.promise).toBeUndefined();
65
+ expect(restored?.abortController).toBeUndefined();
66
+ });
67
+
68
+ it("uses the newest branch entry when an id appears more than once", () => {
69
+ manager = new AgentManager();
70
+
71
+ manager.restoreCompleted([
72
+ persisted({ result: "old", completedAt: 200 }),
73
+ persisted({ result: "new", completedAt: 300 }),
74
+ ]);
75
+
76
+ expect(manager.getRecord("agent-1")?.result).toBe("new");
77
+ });
78
+
79
+ it("does not overwrite a record already held by the manager", () => {
80
+ manager = new AgentManager();
81
+
82
+ manager.restoreCompleted([persisted({ result: "first" })]);
83
+ manager.restoreCompleted([persisted({ result: "second" })]);
84
+
85
+ expect(manager.getRecord("agent-1")?.result).toBe("first");
86
+ });
87
+ });