@esso0428/pi-subagents 0.15.2 → 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,11 @@ 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
+
10
15
  ## [0.15.2] - 2026-09-10
11
16
 
12
17
  ### 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
+ }
@@ -57,6 +57,12 @@ function isRestorableRecord(value) {
57
57
  if (!value || typeof value !== "object")
58
58
  return false;
59
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("\\"));
60
66
  return (typeof record.id === "string" &&
61
67
  record.id.length > 0 &&
62
68
  typeof record.type === "string" &&
@@ -67,7 +73,8 @@ function isRestorableRecord(value) {
67
73
  typeof record.completedAt === "number" &&
68
74
  Number.isFinite(record.completedAt) &&
69
75
  (record.result === undefined || typeof record.result === "string") &&
70
- (record.error === undefined || typeof record.error === "string"));
76
+ (record.error === undefined || typeof record.error === "string") &&
77
+ validTranscriptPath);
71
78
  }
72
79
  class AgentManager {
73
80
  agents = new Map();
@@ -478,6 +485,7 @@ class AgentManager {
478
485
  status: value.status,
479
486
  result: value.result,
480
487
  error: value.error,
488
+ transcriptPath: value.transcriptPath,
481
489
  toolUses: 0,
482
490
  startedAt: value.startedAt,
483
491
  completedAt: value.completedAt,
package/dist/index.js CHANGED
@@ -10,39 +10,6 @@
10
10
  * Commands:
11
11
  * /agents — Interactive agent management menu
12
12
  */
13
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
- if (k2 === undefined) k2 = k;
15
- var desc = Object.getOwnPropertyDescriptor(m, k);
16
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
- desc = { enumerable: true, get: function() { return m[k]; } };
18
- }
19
- Object.defineProperty(o, k2, desc);
20
- }) : (function(o, m, k, k2) {
21
- if (k2 === undefined) k2 = k;
22
- o[k2] = m[k];
23
- }));
24
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
- Object.defineProperty(o, "default", { enumerable: true, value: v });
26
- }) : function(o, v) {
27
- o["default"] = v;
28
- });
29
- var __importStar = (this && this.__importStar) || (function () {
30
- var ownKeys = function(o) {
31
- ownKeys = Object.getOwnPropertyNames || function (o) {
32
- var ar = [];
33
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
- return ar;
35
- };
36
- return ownKeys(o);
37
- };
38
- return function (mod) {
39
- if (mod && mod.__esModule) return mod;
40
- var result = {};
41
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
- __setModuleDefault(result, mod);
43
- return result;
44
- };
45
- })();
46
13
  Object.defineProperty(exports, "__esModule", { value: true });
47
14
  exports.renderRunningAgentStatus = renderRunningAgentStatus;
48
15
  exports.default = default_1;
@@ -60,6 +27,7 @@ const enabled_models_js_1 = require("./enabled-models.js");
60
27
  const group_join_js_1 = require("./group-join.js");
61
28
  const invocation_config_js_1 = require("./invocation-config.js");
62
29
  const model_resolver_js_1 = require("./model-resolver.js");
30
+ const agent_history_js_1 = require("./agent-history.js");
63
31
  const output_file_js_1 = require("./output-file.js");
64
32
  const schedule_js_1 = require("./schedule.js");
65
33
  const schedule_store_js_1 = require("./schedule-store.js");
@@ -443,11 +411,14 @@ function default_1(pi) {
443
411
  else {
444
412
  pi.events.emit("subagents:completed", eventData);
445
413
  }
446
- // 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.
447
417
  pi.appendEntry("subagents:record", {
448
418
  id: record.id, type: record.type, description: record.description,
449
419
  status: record.status, result: record.result, error: record.error,
450
420
  startedAt: record.startedAt, completedAt: record.completedAt,
421
+ transcriptPath: record.transcriptPath,
451
422
  });
452
423
  // Skip notification if result was already consumed via get_subagent_result
453
424
  if (record.resultConsumed) {
@@ -544,12 +515,23 @@ function default_1(pi) {
544
515
  // bound session_start, so a filtered-out activation never advertises (#142).
545
516
  pi.on("session_start", async (_event, ctx) => {
546
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
+ }
547
524
  manager.clearCompleted(true);
548
525
  const historicalRecords = ctx.sessionManager
549
526
  .getBranch()
550
527
  .filter((entry) => entry?.type === "custom" && entry.customType === "subagents:record")
551
528
  .map((entry) => entry.data);
552
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();
553
535
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
554
536
  // fires once per activation, but a double-bind must not leak listeners.
555
537
  if (!rpcHandle) {
@@ -602,7 +584,7 @@ function default_1(pi) {
602
584
  const widget = new agent_widget_js_1.AgentWidget(manager, agentActivity, getWidgetMode);
603
585
  function setWidgetMode(m) { widgetMode = m; widget.update(); }
604
586
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
605
- const fleet = new fleet_list_js_1.FleetList(manager, agentActivity);
587
+ const fleet = new fleet_list_js_1.FleetList(manager, agentActivity, () => currentCtx?.cwd);
606
588
  let fleetViewEnabled = true;
607
589
  function isFleetViewEnabled() { return fleetViewEnabled; }
608
590
  function setFleetViewEnabled(b) { fleetViewEnabled = b; fleet.setEnabled(b); }
@@ -1072,6 +1054,19 @@ Terse command-style prompts produce shallow, generic work.
1072
1054
  return;
1073
1055
  rec.outputFile = (0, output_file_js_1.createOutputFilePath)(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
1074
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
+ }
1075
1070
  };
1076
1071
  const parentModelId = ctx.model?.id;
1077
1072
  const effectiveModelId = model?.id;
@@ -1172,7 +1167,7 @@ Terse command-style prompts produce shallow, generic work.
1172
1167
  origBgOnSession(session);
1173
1168
  const rec = manager.getRecord(id);
1174
1169
  if (rec?.outputFile) {
1175
- 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);
1176
1171
  }
1177
1172
  };
1178
1173
  try {
@@ -1278,7 +1273,7 @@ Terse command-style prompts produce shallow, generic work.
1278
1273
  if (fgId) {
1279
1274
  const rec = manager.getRecord(fgId);
1280
1275
  if (rec?.outputFile) {
1281
- 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);
1282
1277
  }
1283
1278
  }
1284
1279
  };
@@ -1649,15 +1644,25 @@ Terse command-style prompts produce shallow, generic work.
1649
1644
  ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1650
1645
  return;
1651
1646
  }
1652
- const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await Promise.resolve().then(() => __importStar(require("./ui/conversation-viewer.js")));
1653
- 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
+ }
1654
1658
  const activity = agentActivity.get(record.id);
1659
+ const isLive = record.session !== undefined;
1655
1660
  await ctx.ui.custom((tui, theme, keybindings, done) => {
1656
- return new ConversationViewer(tui, session, record, activity, theme, done, () => {
1661
+ return new ConversationViewer(tui, session, record, activity, theme, done, isLive ? () => {
1657
1662
  if (manager.abort(record.id)) {
1658
1663
  ctx.ui.notify(`Stopped "${record.description}".`, "info");
1659
1664
  }
1660
- }, keybindings, (message) => manager.steer(record.id, message));
1665
+ } : undefined, keybindings, isLive ? (message) => manager.steer(record.id, message) : undefined);
1661
1666
  }, {
1662
1667
  overlay: true,
1663
1668
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
@@ -1698,7 +1703,7 @@ Terse command-style prompts produce shallow, generic work.
1698
1703
  const content = (0, node_fs_1.readFileSync)(file.path, "utf-8");
1699
1704
  const edited = await ctx.ui.editor(`Edit ${name}`, content);
1700
1705
  if (edited !== undefined && edited !== content) {
1701
- const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1706
+ const { writeFileSync } = await import("node:fs");
1702
1707
  writeFileSync(file.path, edited, "utf-8");
1703
1708
  reloadCustomAgents();
1704
1709
  ctx.ui.notify(`Updated ${file.path}`, "info");
@@ -1786,7 +1791,7 @@ Terse command-style prompts produce shallow, generic work.
1786
1791
  if (cfg.isolation)
1787
1792
  fmFields.push(`isolation: ${cfg.isolation}`);
1788
1793
  const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
1789
- const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1794
+ const { writeFileSync } = await import("node:fs");
1790
1795
  writeFileSync(targetPath, content, "utf-8");
1791
1796
  reloadCustomAgents();
1792
1797
  ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
@@ -1802,7 +1807,7 @@ Terse command-style prompts produce shallow, generic work.
1802
1807
  return;
1803
1808
  }
1804
1809
  const updated = content.replace(/^---\n/, "---\nenabled: false\n");
1805
- const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1810
+ const { writeFileSync } = await import("node:fs");
1806
1811
  writeFileSync(file.path, updated, "utf-8");
1807
1812
  reloadCustomAgents();
1808
1813
  ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
@@ -1818,7 +1823,7 @@ Terse command-style prompts produce shallow, generic work.
1818
1823
  const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
1819
1824
  (0, node_fs_1.mkdirSync)(targetDir, { recursive: true });
1820
1825
  const targetPath = (0, node_path_1.join)(targetDir, `${name}.md`);
1821
- const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1826
+ const { writeFileSync } = await import("node:fs");
1822
1827
  writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
1823
1828
  reloadCustomAgents();
1824
1829
  ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
@@ -1830,7 +1835,7 @@ Terse command-style prompts produce shallow, generic work.
1830
1835
  return;
1831
1836
  const content = (0, node_fs_1.readFileSync)(file.path, "utf-8");
1832
1837
  const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
1833
- const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
1838
+ const { writeFileSync } = await import("node:fs");
1834
1839
  // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
1835
1840
  if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
1836
1841
  (0, node_fs_1.unlinkSync)(file.path);
@@ -2013,7 +2018,7 @@ ${systemPrompt}
2013
2018
  if (!overwrite)
2014
2019
  return;
2015
2020
  }
2016
- const { writeFileSync } = await Promise.resolve().then(() => __importStar(require("node:fs")));
2021
+ const { writeFileSync } = await import("node:fs");
2017
2022
  writeFileSync(targetPath, content, "utf-8");
2018
2023
  reloadCustomAgents();
2019
2024
  ctx.ui.notify(`Created ${targetPath}`, "info");
@@ -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.2",
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
+ }
@@ -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, 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,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) {
@@ -630,7 +644,7 @@ export default function (pi: ExtensionAPI) {
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
  };
@@ -1793,17 +1820,30 @@ Terse command-style prompts produce shallow, generic work.
1793
1820
  return;
1794
1821
  }
1795
1822
 
1796
- const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1797
- 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
+ }
1798
1834
  const activity = agentActivity.get(record.id);
1835
+ const isLive = record.session !== undefined;
1799
1836
 
1800
1837
  await ctx.ui.custom<undefined>(
1801
1838
  (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));
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);
1807
1847
  },
1808
1848
  {
1809
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
+ });
@@ -10,6 +10,7 @@ type PersistedRecord = {
10
10
  error?: unknown;
11
11
  startedAt: number;
12
12
  completedAt: number;
13
+ transcriptPath?: string;
13
14
  };
14
15
 
15
16
  function persisted(overrides: Partial<PersistedRecord> = {}): PersistedRecord {
@@ -38,10 +39,11 @@ describe("AgentManager.restoreCompleted", () => {
38
39
 
39
40
  manager.restoreCompleted([
40
41
  persisted(),
41
- persisted({ id: "agent-error", status: "error", error: "failed" }),
42
+ persisted({ id: "agent-error", status: "error", error: "failed", transcriptPath: ".pi-subagents/agent-transcripts/agent-error.jsonl" }),
42
43
  persisted({ id: "agent-running", status: "running" }),
43
44
  persisted({ id: "agent-queued", status: "queued" }),
44
45
  persisted({ id: "agent-invalid", completedAt: Number.NaN }),
46
+ persisted({ id: "agent-unsafe", transcriptPath: ".pi-subagents/../private.jsonl" }),
45
47
  ]);
46
48
 
47
49
  expect(manager.listAgents().map((record) => record.id).sort()).toEqual([
@@ -56,6 +58,7 @@ describe("AgentManager.restoreCompleted", () => {
56
58
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
57
59
  compactionCount: 0,
58
60
  isBackground: true,
61
+ transcriptPath: undefined,
59
62
  });
60
63
  expect(restored?.session).toBeUndefined();
61
64
  expect(restored?.promise).toBeUndefined();