@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 +10 -0
- package/README.md +3 -3
- package/dist/agent-history.d.ts +22 -0
- package/dist/agent-history.js +98 -0
- package/dist/agent-manager.d.ts +8 -0
- package/dist/agent-manager.js +72 -0
- package/dist/index.js +51 -8
- package/dist/output-file.d.ts +1 -1
- package/dist/output-file.js +7 -4
- package/dist/types.d.ts +4 -0
- package/dist/ui/conversation-viewer.d.ts +4 -1
- package/dist/ui/conversation-viewer.js +4 -0
- package/dist/ui/fleet-list.d.ts +2 -1
- package/dist/ui/fleet-list.js +27 -9
- package/package.json +1 -1
- package/src/agent-history.ts +100 -0
- package/src/agent-manager.ts +81 -0
- package/src/index.ts +56 -11
- package/src/output-file.ts +7 -3
- package/src/types.ts +4 -0
- package/src/ui/conversation-viewer.ts +10 -1
- package/src/ui/fleet-list.ts +29 -12
- package/test/agent-history.test.ts +59 -0
- package/test/agent-manager-history.test.ts +87 -0
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/agent-manager.d.ts
CHANGED
|
@@ -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. */
|
package/dist/agent-manager.js
CHANGED
|
@@ -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}%` },
|
package/dist/output-file.d.ts
CHANGED
|
@@ -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;
|
package/dist/output-file.js
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
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:
|
|
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;
|
package/dist/ui/fleet-list.d.ts
CHANGED
|
@@ -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;
|
package/dist/ui/fleet-list.js
CHANGED
|
@@ -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 =>
|
|
176
|
-
|| a.
|
|
177
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/agent-manager.ts
CHANGED
|
@@ -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
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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,
|
package/src/output-file.ts
CHANGED
|
@@ -63,7 +63,9 @@ export function streamToOutputFile(
|
|
|
63
63
|
path: string,
|
|
64
64
|
agentId: string,
|
|
65
65
|
cwd: string,
|
|
66
|
+
historyPath?: string,
|
|
66
67
|
): () => void {
|
|
68
|
+
const outputPaths = historyPath && historyPath !== path ? [path, historyPath] : [path];
|
|
67
69
|
let writtenCount = 1; // initial user prompt already written
|
|
68
70
|
|
|
69
71
|
const flush = () => {
|
|
@@ -78,9 +80,11 @@ export function streamToOutputFile(
|
|
|
78
80
|
timestamp: new Date().toISOString(),
|
|
79
81
|
cwd,
|
|
80
82
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
for (const outputPath of outputPaths) {
|
|
84
|
+
try {
|
|
85
|
+
appendFileSync(outputPath, JSON.stringify(entry) + "\n", "utf-8");
|
|
86
|
+
} catch { /* ignore write errors */ }
|
|
87
|
+
}
|
|
84
88
|
writtenCount++;
|
|
85
89
|
}
|
|
86
90
|
};
|
package/src/types.ts
CHANGED
|
@@ -105,6 +105,10 @@ export interface AgentRecord {
|
|
|
105
105
|
toolCallId?: string;
|
|
106
106
|
/** Path to the streaming output transcript file. */
|
|
107
107
|
outputFile?: string;
|
|
108
|
+
/** Absolute path to the durable project-local transcript while live. */
|
|
109
|
+
historyFile?: string;
|
|
110
|
+
/** Project-relative durable transcript path persisted in the parent session. */
|
|
111
|
+
transcriptPath?: string;
|
|
108
112
|
/** Cleanup function for the output file stream subscription. */
|
|
109
113
|
outputCleanup?: () => void;
|
|
110
114
|
/**
|
|
@@ -20,6 +20,15 @@ const MIN_VIEWPORT = 3;
|
|
|
20
20
|
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
21
21
|
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
22
22
|
|
|
23
|
+
/** The live fields needed by the viewer; historical viewers use a static source. */
|
|
24
|
+
export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
|
|
25
|
+
|
|
26
|
+
export function createStaticConversationSource(
|
|
27
|
+
messages: AgentSession["messages"],
|
|
28
|
+
): ConversationSource {
|
|
29
|
+
return { messages, subscribe: () => () => {} };
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
export class ConversationViewer implements Component {
|
|
24
33
|
private scrollOffset = 0;
|
|
25
34
|
private autoScroll = true;
|
|
@@ -34,7 +43,7 @@ export class ConversationViewer implements Component {
|
|
|
34
43
|
|
|
35
44
|
constructor(
|
|
36
45
|
private tui: TUI,
|
|
37
|
-
private session:
|
|
46
|
+
private session: ConversationSource,
|
|
38
47
|
private record: AgentRecord,
|
|
39
48
|
private activity: AgentActivity | undefined,
|
|
40
49
|
private theme: Theme,
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -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 =>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
+
});
|