@esso0428/pi-subagents 0.15.5 → 0.15.6
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 +4 -22
- package/README.md +5 -5
- package/dist/agent-history-list.d.ts +18 -0
- package/dist/agent-history-list.js +68 -0
- package/dist/agent-history.d.ts +1 -1
- package/dist/agent-history.js +31 -34
- package/dist/agent-manager.d.ts +22 -8
- package/dist/agent-manager.js +100 -93
- package/dist/agent-runner.js +67 -86
- package/dist/agent-types.js +27 -45
- package/dist/context.js +2 -6
- package/dist/cross-extension-rpc.js +5 -9
- package/dist/custom-agents.js +15 -18
- package/dist/default-agents.js +1 -4
- package/dist/enabled-models.js +11 -16
- package/dist/env.js +1 -4
- package/dist/group-join.js +1 -5
- package/dist/index.js +273 -286
- package/dist/invocation-config.js +2 -6
- package/dist/memory.js +24 -34
- package/dist/model-resolver.js +1 -4
- package/dist/nico-overrides.js +14 -20
- package/dist/output-file.js +16 -22
- package/dist/prompts.js +1 -4
- package/dist/schedule-store.js +16 -21
- package/dist/schedule.js +8 -12
- package/dist/settings.js +15 -23
- package/dist/skill-loader.js +20 -23
- package/dist/status-note.js +1 -4
- package/dist/types.d.ts +0 -1
- package/dist/types.js +1 -4
- package/dist/ui/agent-widget.d.ts +1 -5
- package/dist/ui/agent-widget.js +25 -49
- package/dist/ui/conversation-blocks.d.ts +18 -0
- package/dist/ui/conversation-blocks.js +214 -0
- package/dist/ui/conversation-nvim.d.ts +7 -0
- package/dist/ui/conversation-nvim.js +116 -0
- package/dist/ui/conversation-search.d.ts +38 -0
- package/dist/ui/conversation-search.js +123 -0
- package/dist/ui/conversation-viewer.d.ts +55 -19
- package/dist/ui/conversation-viewer.js +398 -215
- package/dist/ui/fleet-list.d.ts +23 -11
- package/dist/ui/fleet-list.js +85 -78
- package/dist/ui/schedule-menu.js +1 -4
- package/dist/ui/viewer-keys.js +7 -10
- package/dist/usage.js +4 -10
- package/dist/worktree.js +26 -31
- package/package.json +1 -1
- package/src/agent-history-list.ts +91 -0
- package/src/agent-history.ts +12 -17
- package/src/agent-manager.ts +108 -81
- package/src/agent-runner.ts +5 -13
- package/src/index.ts +84 -89
- package/src/output-file.ts +1 -1
- package/src/types.ts +0 -1
- package/src/ui/agent-widget.ts +1 -8
- package/src/ui/conversation-blocks.ts +270 -0
- package/src/ui/conversation-nvim.ts +127 -0
- package/src/ui/conversation-search.ts +161 -0
- package/src/ui/conversation-viewer.ts +376 -205
- package/src/ui/fleet-list.ts +73 -52
- package/test/agent-history-list.test.ts +121 -0
- package/test/agent-manager-history.test.ts +59 -58
- package/test/agent-menu-history-navigation.test.ts +86 -0
- package/test/ui/conversation-blocks.test.ts +72 -0
- package/test/ui/conversation-search.test.ts +55 -0
- package/test/ui/conversation-viewer.test.ts +132 -0
- package/test/ui/fleet-list.test.ts +131 -0
- package/dist/ui/markdown-result.d.ts +0 -3
- package/dist/ui/markdown-result.js +0 -53
- package/src/ui/markdown-result.ts +0 -56
- package/test/agent-history.test.ts +0 -63
- package/test/ui/markdown-result.test.ts +0 -45
package/dist/worktree.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
2
|
* worktree.ts — Git worktree isolation for agents.
|
|
4
3
|
*
|
|
@@ -6,51 +5,47 @@
|
|
|
6
5
|
* On completion, if no changes were made, the worktree is cleaned up.
|
|
7
6
|
* If changes exist, a branch is created and returned in the result.
|
|
8
7
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const node_crypto_1 = require("node:crypto");
|
|
15
|
-
const node_fs_1 = require("node:fs");
|
|
16
|
-
const node_os_1 = require("node:os");
|
|
17
|
-
const node_path_1 = require("node:path");
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join, relative } from "node:path";
|
|
18
13
|
/**
|
|
19
14
|
* Create a temporary git worktree for an agent.
|
|
20
15
|
* Returns the worktree path, or undefined if not in a git repo.
|
|
21
16
|
*/
|
|
22
|
-
function createWorktree(cwd, agentId) {
|
|
17
|
+
export function createWorktree(cwd, agentId) {
|
|
23
18
|
// Verify we're in a git repo with at least one commit (HEAD must exist)
|
|
24
19
|
let baseSha;
|
|
25
20
|
let subdir;
|
|
26
21
|
try {
|
|
27
|
-
|
|
28
|
-
baseSha =
|
|
22
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
23
|
+
baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
|
|
29
24
|
.toString()
|
|
30
25
|
.trim();
|
|
31
26
|
// Where cwd sits inside the repo ("" at the root): the agent must work at
|
|
32
27
|
// the same subdirectory inside the copy, or a monorepo-package cwd would
|
|
33
28
|
// silently widen to the whole repo. realpath both sides — git emits
|
|
34
29
|
// resolved paths while cwd may arrive through a symlink (macOS /tmp).
|
|
35
|
-
const topLevel =
|
|
30
|
+
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
|
|
36
31
|
.toString()
|
|
37
32
|
.trim();
|
|
38
|
-
subdir =
|
|
33
|
+
subdir = relative(realpathSync(topLevel), realpathSync(cwd));
|
|
39
34
|
}
|
|
40
35
|
catch {
|
|
41
36
|
return undefined;
|
|
42
37
|
}
|
|
43
38
|
const branch = `pi-agent-${agentId}`;
|
|
44
|
-
const suffix =
|
|
45
|
-
const worktreePath =
|
|
39
|
+
const suffix = randomUUID().slice(0, 8);
|
|
40
|
+
const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
|
|
46
41
|
try {
|
|
47
42
|
// Create detached worktree at HEAD
|
|
48
|
-
|
|
43
|
+
execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
|
|
49
44
|
cwd,
|
|
50
45
|
stdio: "pipe",
|
|
51
46
|
timeout: 30000,
|
|
52
47
|
});
|
|
53
|
-
return { path: worktreePath, branch, baseSha, workPath: subdir ?
|
|
48
|
+
return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath };
|
|
54
49
|
}
|
|
55
50
|
catch {
|
|
56
51
|
// If worktree creation fails, return undefined (agent runs in normal cwd)
|
|
@@ -62,31 +57,31 @@ function createWorktree(cwd, agentId) {
|
|
|
62
57
|
* - If no changes: remove worktree entirely.
|
|
63
58
|
* - If changes exist: create a branch, commit changes, return branch info.
|
|
64
59
|
*/
|
|
65
|
-
function cleanupWorktree(cwd, worktree, agentDescription) {
|
|
66
|
-
if (!
|
|
60
|
+
export function cleanupWorktree(cwd, worktree, agentDescription) {
|
|
61
|
+
if (!existsSync(worktree.path)) {
|
|
67
62
|
return { hasChanges: false };
|
|
68
63
|
}
|
|
69
64
|
try {
|
|
70
65
|
// Check for uncommitted changes in the worktree
|
|
71
|
-
const status =
|
|
66
|
+
const status = execFileSync("git", ["status", "--porcelain"], {
|
|
72
67
|
cwd: worktree.path,
|
|
73
68
|
stdio: "pipe",
|
|
74
69
|
timeout: 10000,
|
|
75
70
|
}).toString().trim();
|
|
76
71
|
if (status) {
|
|
77
72
|
// Changes exist — stage, commit, and create a branch
|
|
78
|
-
|
|
73
|
+
execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
|
|
79
74
|
// Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
|
|
80
75
|
const safeDesc = agentDescription.slice(0, 200);
|
|
81
76
|
const commitMsg = `pi-agent: ${safeDesc}`;
|
|
82
|
-
|
|
77
|
+
execFileSync("git", ["commit", "--no-verify", "-m", commitMsg], {
|
|
83
78
|
cwd: worktree.path,
|
|
84
79
|
stdio: "pipe",
|
|
85
80
|
timeout: 10000,
|
|
86
81
|
});
|
|
87
82
|
}
|
|
88
83
|
else {
|
|
89
|
-
const currentSha =
|
|
84
|
+
const currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
90
85
|
cwd: worktree.path,
|
|
91
86
|
stdio: "pipe",
|
|
92
87
|
timeout: 5000,
|
|
@@ -101,7 +96,7 @@ function cleanupWorktree(cwd, worktree, agentDescription) {
|
|
|
101
96
|
// If the branch already exists, append a suffix to avoid overwriting previous work.
|
|
102
97
|
let branchName = worktree.branch;
|
|
103
98
|
try {
|
|
104
|
-
|
|
99
|
+
execFileSync("git", ["branch", branchName], {
|
|
105
100
|
cwd: worktree.path,
|
|
106
101
|
stdio: "pipe",
|
|
107
102
|
timeout: 5000,
|
|
@@ -110,7 +105,7 @@ function cleanupWorktree(cwd, worktree, agentDescription) {
|
|
|
110
105
|
catch {
|
|
111
106
|
// Branch already exists — use a unique suffix
|
|
112
107
|
branchName = `${worktree.branch}-${Date.now()}`;
|
|
113
|
-
|
|
108
|
+
execFileSync("git", ["branch", branchName], {
|
|
114
109
|
cwd: worktree.path,
|
|
115
110
|
stdio: "pipe",
|
|
116
111
|
timeout: 5000,
|
|
@@ -140,7 +135,7 @@ function cleanupWorktree(cwd, worktree, agentDescription) {
|
|
|
140
135
|
*/
|
|
141
136
|
function removeWorktree(cwd, worktreePath) {
|
|
142
137
|
try {
|
|
143
|
-
|
|
138
|
+
execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
|
|
144
139
|
cwd,
|
|
145
140
|
stdio: "pipe",
|
|
146
141
|
timeout: 10000,
|
|
@@ -149,7 +144,7 @@ function removeWorktree(cwd, worktreePath) {
|
|
|
149
144
|
catch {
|
|
150
145
|
// If git worktree remove fails, try pruning
|
|
151
146
|
try {
|
|
152
|
-
|
|
147
|
+
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
153
148
|
}
|
|
154
149
|
catch { /* ignore */ }
|
|
155
150
|
}
|
|
@@ -157,9 +152,9 @@ function removeWorktree(cwd, worktreePath) {
|
|
|
157
152
|
/**
|
|
158
153
|
* Prune any orphaned worktrees (crash recovery).
|
|
159
154
|
*/
|
|
160
|
-
function pruneWorktrees(cwd) {
|
|
155
|
+
export function pruneWorktrees(cwd) {
|
|
161
156
|
try {
|
|
162
|
-
|
|
157
|
+
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
|
|
163
158
|
}
|
|
164
159
|
catch { /* ignore */ }
|
|
165
160
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esso0428/pi-subagents",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.6",
|
|
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,91 @@
|
|
|
1
|
+
/** Helpers for separating openable active agents from terminal history. */
|
|
2
|
+
|
|
3
|
+
import { hasAgentHistory } from "./agent-history.js";
|
|
4
|
+
import type { AgentRecord } from "./types.js";
|
|
5
|
+
import { getAgentConfig } from "./agent-types.js";
|
|
6
|
+
import { formatDuration, getDisplayName } from "./ui/agent-widget.js";
|
|
7
|
+
|
|
8
|
+
export type AgentHistoryStatus =
|
|
9
|
+
| "completed"
|
|
10
|
+
| "steered"
|
|
11
|
+
| "stopped"
|
|
12
|
+
| "aborted"
|
|
13
|
+
| "error";
|
|
14
|
+
|
|
15
|
+
const TERMINAL_STATUSES: ReadonlySet<string> = new Set<AgentHistoryStatus>([
|
|
16
|
+
"completed",
|
|
17
|
+
"steered",
|
|
18
|
+
"stopped",
|
|
19
|
+
"aborted",
|
|
20
|
+
"error",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/** Return whether a record status represents a terminal agent run. */
|
|
24
|
+
export function isTerminalAgentStatus(status: string): status is AgentHistoryStatus {
|
|
25
|
+
return TERMINAL_STATUSES.has(status);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Return whether an active record has a live session that can be opened. */
|
|
29
|
+
export function canOpenActiveAgent(record: AgentRecord): boolean {
|
|
30
|
+
return (record.status === "running" || record.status === "queued")
|
|
31
|
+
&& record.session !== undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Return whether a terminal record has an in-memory or durable conversation. */
|
|
35
|
+
export function canOpenAgentHistory(record: AgentRecord, cwd: string | undefined): boolean {
|
|
36
|
+
if (!isTerminalAgentStatus(record.status)) return false;
|
|
37
|
+
return record.session !== undefined
|
|
38
|
+
|| (cwd !== undefined && hasAgentHistory(cwd, record.transcriptPath));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Split records into openable active and terminal-history buckets. */
|
|
42
|
+
export function splitAgentRecords(
|
|
43
|
+
records: readonly AgentRecord[],
|
|
44
|
+
cwd: string | undefined,
|
|
45
|
+
): { active: AgentRecord[]; history: AgentRecord[] } {
|
|
46
|
+
const active: AgentRecord[] = [];
|
|
47
|
+
const history: AgentRecord[] = [];
|
|
48
|
+
|
|
49
|
+
for (const record of records) {
|
|
50
|
+
if (canOpenActiveAgent(record)) {
|
|
51
|
+
active.push(record);
|
|
52
|
+
} else if (canOpenAgentHistory(record, cwd)) {
|
|
53
|
+
history.push(record);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { active, history };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DESCRIPTION_LIMIT = 72;
|
|
61
|
+
const LABEL_LIMIT = 140;
|
|
62
|
+
|
|
63
|
+
function boundedDescription(description: string): string {
|
|
64
|
+
const line = description.split("\n").find((part) => part.trim())?.trim() ?? "";
|
|
65
|
+
if (line.length <= DESCRIPTION_LIMIT) return line;
|
|
66
|
+
return `${line.slice(0, DESCRIPTION_LIMIT - 1)}…`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Format a bounded, single-line history menu option. */
|
|
70
|
+
export function formatAgentHistoryOption(record: AgentRecord, now: number): string {
|
|
71
|
+
const duration = formatDuration(record.startedAt, record.completedAt ?? now);
|
|
72
|
+
// Unit consumers may format persisted records before the registry has been
|
|
73
|
+
// initialized; preserve the persisted type instead of falling back to
|
|
74
|
+
// general-purpose's generic display name in that case.
|
|
75
|
+
const displayName = getAgentConfig(record.type)?.displayName
|
|
76
|
+
?? (record.type === "general-purpose" ? getDisplayName(record.type) : record.type);
|
|
77
|
+
const option = `[${record.status}] ${displayName} · ${boundedDescription(record.description)} · ${duration}`;
|
|
78
|
+
return option.length <= LABEL_LIMIT ? option : `${option.slice(0, LABEL_LIMIT - 1)}…`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build the visible status-section labels for the agents menu. */
|
|
82
|
+
export function buildAgentStatusMenuEntries(
|
|
83
|
+
records: readonly AgentRecord[],
|
|
84
|
+
cwd: string | undefined,
|
|
85
|
+
): string[] {
|
|
86
|
+
const { active, history } = splitAgentRecords(records, cwd);
|
|
87
|
+
return [
|
|
88
|
+
...(active.length > 0 ? [`Running agents (${active.length})`] : []),
|
|
89
|
+
...(history.length > 0 ? [`Agent history (${history.length})`] : []),
|
|
90
|
+
];
|
|
91
|
+
}
|
package/src/agent-history.ts
CHANGED
|
@@ -19,14 +19,12 @@ export function ensureSubagentsGitignore(cwd: string): string {
|
|
|
19
19
|
const directory = join(cwd, SUBAGENTS_DIR);
|
|
20
20
|
mkdirSync(directory, { recursive: true });
|
|
21
21
|
const path = join(directory, ".gitignore");
|
|
22
|
-
|
|
23
22
|
let content = "";
|
|
24
23
|
try {
|
|
25
24
|
content = readFileSync(path, "utf8");
|
|
26
25
|
} catch {
|
|
27
26
|
// The append below also creates a missing file.
|
|
28
27
|
}
|
|
29
|
-
|
|
30
28
|
if (!content.split(/\r?\n/).some((line) => line.trim() === "*")) {
|
|
31
29
|
const separator = content.length > 0 && !/[\r\n]$/.test(content) ? "\n" : "";
|
|
32
30
|
appendFileSync(path, `${separator}*\n`, "utf8");
|
|
@@ -35,17 +33,12 @@ export function ensureSubagentsGitignore(cwd: string): string {
|
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
/** Return the durable transcript path for an agent. */
|
|
38
|
-
export function createAgentHistoryPath(
|
|
39
|
-
cwd: string,
|
|
40
|
-
agentId: string,
|
|
41
|
-
agentType = "agent",
|
|
42
|
-
): string {
|
|
36
|
+
export function createAgentHistoryPath(cwd: string, agentId: string): string {
|
|
43
37
|
ensureSubagentsGitignore(cwd);
|
|
44
38
|
const directory = join(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
|
|
45
39
|
mkdirSync(directory, { recursive: true });
|
|
46
40
|
const safeId = agentId.replace(/[^A-Za-z0-9._-]+/g, "-") || "agent";
|
|
47
|
-
|
|
48
|
-
return join(directory, `${safeId}_${safeType}_transcript.jsonl`);
|
|
41
|
+
return join(directory, `${safeId}.jsonl`);
|
|
49
42
|
}
|
|
50
43
|
|
|
51
44
|
/** Return the project-relative path stored in the parent session record. */
|
|
@@ -55,7 +48,12 @@ export function agentHistoryLocator(cwd: string, historyPath: string): string {
|
|
|
55
48
|
|
|
56
49
|
/** Resolve only paths in this package's project-local transcript namespace. */
|
|
57
50
|
export function resolveAgentHistoryPath(cwd: string, locator: string): string | undefined {
|
|
58
|
-
|
|
51
|
+
// Locators are persisted data, not arbitrary paths. Reject traversal syntax
|
|
52
|
+
// before resolving so a path which normalizes back inside the namespace is
|
|
53
|
+
// still not accepted as an unsafe locator.
|
|
54
|
+
if (!locator || isAbsolute(locator) || locator.includes("\0") || locator.includes("\\")) return undefined;
|
|
55
|
+
const segments = locator.split("/");
|
|
56
|
+
if (segments.some((segment) => segment === ".." || segment === "")) return undefined;
|
|
59
57
|
const root = resolve(cwd, SUBAGENTS_DIR, TRANSCRIPTS_DIR);
|
|
60
58
|
const candidate = resolve(cwd, locator);
|
|
61
59
|
if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) return undefined;
|
|
@@ -74,12 +72,9 @@ export function hasAgentHistory(cwd: string, locator: string | undefined): boole
|
|
|
74
72
|
* conversation viewer. Malformed lines and unknown records are skipped so one
|
|
75
73
|
* damaged entry cannot hide the rest of a history.
|
|
76
74
|
*/
|
|
77
|
-
export function readAgentHistory(
|
|
78
|
-
cwd: string,
|
|
79
|
-
locator: string,
|
|
80
|
-
): AgentSession["messages"] | undefined {
|
|
75
|
+
export function readAgentHistory(cwd: string, locator: string): AgentSession["messages"] | undefined {
|
|
81
76
|
const path = resolveAgentHistoryPath(cwd, locator);
|
|
82
|
-
if (!path) return undefined;
|
|
77
|
+
if (!path || !existsSync(path)) return undefined;
|
|
83
78
|
|
|
84
79
|
let raw: string;
|
|
85
80
|
try {
|
|
@@ -93,8 +88,8 @@ export function readAgentHistory(
|
|
|
93
88
|
for (const line of raw.split(/\r?\n/)) {
|
|
94
89
|
if (!line.trim()) continue;
|
|
95
90
|
try {
|
|
96
|
-
const entry = JSON.parse(line)
|
|
97
|
-
const message = entry
|
|
91
|
+
const entry: unknown = JSON.parse(line);
|
|
92
|
+
const message = (entry as { message?: unknown } | null)?.message;
|
|
98
93
|
if (!message || typeof message !== "object") continue;
|
|
99
94
|
const role = (message as { role?: unknown }).role;
|
|
100
95
|
if (typeof role !== "string") continue;
|
package/src/agent-manager.ts
CHANGED
|
@@ -23,13 +23,67 @@ 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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
]);
|
|
26
|
+
const RESTORABLE_STATUSES = new Set(["completed", "steered", "stopped", "aborted", "error"] as const);
|
|
27
|
+
|
|
28
|
+
type RestorableAgentStatus = "completed" | "steered" | "stopped" | "aborted" | "error";
|
|
29
|
+
|
|
30
|
+
/** Narrow persisted strings before putting them into runtime/UI state. */
|
|
31
|
+
function isSafePersistedString(value: unknown, maxLength: number): value is string {
|
|
32
|
+
return typeof value === "string" && value.length > 0 && value.length <= maxLength && !/[\0\r\n]/.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isFiniteTimestamp(value: unknown): value is number {
|
|
36
|
+
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isValidUsage(value: unknown): value is { input: number; output: number; cacheWrite: number } {
|
|
40
|
+
if (!value || typeof value !== "object") return false;
|
|
41
|
+
const usage = value as Record<string, unknown>;
|
|
42
|
+
return ["input", "output", "cacheWrite"].every((key) => {
|
|
43
|
+
const n = usage[key];
|
|
44
|
+
return typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isSafeTranscriptLocator(value: unknown): value is string {
|
|
49
|
+
return typeof value === "string"
|
|
50
|
+
&& /^\.pi-subagents\/agent-transcripts\/[^/]+\.jsonl$/.test(value)
|
|
51
|
+
&& !value.includes("..")
|
|
52
|
+
&& !value.includes("\\")
|
|
53
|
+
&& !value.includes("\0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validate one persisted terminal record without constructing runtime handles. */
|
|
57
|
+
export function isRestorableAgentRecord(value: unknown): value is {
|
|
58
|
+
id: string;
|
|
59
|
+
type: string;
|
|
60
|
+
description: string;
|
|
61
|
+
status: RestorableAgentStatus;
|
|
62
|
+
startedAt: number;
|
|
63
|
+
completedAt: number;
|
|
64
|
+
result?: string;
|
|
65
|
+
error?: string;
|
|
66
|
+
toolUses?: number;
|
|
67
|
+
lifetimeUsage?: { input: number; output: number; cacheWrite: number };
|
|
68
|
+
transcriptPath?: string;
|
|
69
|
+
} {
|
|
70
|
+
if (!value || typeof value !== "object") return false;
|
|
71
|
+
const record = value as Record<string, unknown>;
|
|
72
|
+
if (!isSafePersistedString(record.id, 256)
|
|
73
|
+
|| !isSafePersistedString(record.type, 256)
|
|
74
|
+
|| !isSafePersistedString(record.description, 4096)
|
|
75
|
+
|| typeof record.status !== "string"
|
|
76
|
+
|| !RESTORABLE_STATUSES.has(record.status as RestorableAgentStatus)
|
|
77
|
+
|| !isFiniteTimestamp(record.startedAt)
|
|
78
|
+
|| !isFiniteTimestamp(record.completedAt)
|
|
79
|
+
|| record.completedAt < record.startedAt) return false;
|
|
80
|
+
if (record.result !== undefined && !isSafePersistedString(record.result, 2_000_000)) return false;
|
|
81
|
+
if (record.error !== undefined && !isSafePersistedString(record.error, 64_000)) return false;
|
|
82
|
+
if (record.toolUses !== undefined && (!Number.isInteger(record.toolUses) || (record.toolUses as number) < 0)) return false;
|
|
83
|
+
if (record.lifetimeUsage !== undefined && !isValidUsage(record.lifetimeUsage)) return false;
|
|
84
|
+
if (record.transcriptPath !== undefined && !isSafeTranscriptLocator(record.transcriptPath)) return false;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
33
87
|
|
|
34
88
|
/**
|
|
35
89
|
* Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
|
|
@@ -104,45 +158,6 @@ interface SpawnOptions {
|
|
|
104
158
|
onCompaction?: (info: CompactionInfo) => void;
|
|
105
159
|
}
|
|
106
160
|
|
|
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
|
-
|
|
146
161
|
export class AgentManager {
|
|
147
162
|
private agents = new Map<string, AgentRecord>();
|
|
148
163
|
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
@@ -556,47 +571,59 @@ export class AgentManager {
|
|
|
556
571
|
return this.agents.get(id);
|
|
557
572
|
}
|
|
558
573
|
|
|
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
|
-
|
|
594
574
|
listAgents(): AgentRecord[] {
|
|
595
575
|
return [...this.agents.values()].sort(
|
|
596
576
|
(a, b) => b.startedAt - a.startedAt,
|
|
597
577
|
);
|
|
598
578
|
}
|
|
599
579
|
|
|
580
|
+
/** Restore terminal records persisted by a parent branch without runtime handles. */
|
|
581
|
+
restoreCompleted(records: readonly unknown[]): void {
|
|
582
|
+
const latest = new Map<string, ReturnType<typeof this.createRestoredRecord>>();
|
|
583
|
+
for (const value of records) {
|
|
584
|
+
if (isRestorableAgentRecord(value)) {
|
|
585
|
+
latest.set(value.id, this.createRestoredRecord(value));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
for (const [id, restored] of latest) {
|
|
590
|
+
const existing = this.agents.get(id);
|
|
591
|
+
if (existing?.status === "running" || existing?.status === "queued") continue;
|
|
592
|
+
this.agents.set(id, restored);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private createRestoredRecord(record: {
|
|
597
|
+
id: string;
|
|
598
|
+
type: string;
|
|
599
|
+
description: string;
|
|
600
|
+
status: RestorableAgentStatus;
|
|
601
|
+
startedAt: number;
|
|
602
|
+
completedAt: number;
|
|
603
|
+
result?: string;
|
|
604
|
+
error?: string;
|
|
605
|
+
toolUses?: number;
|
|
606
|
+
lifetimeUsage?: { input: number; output: number; cacheWrite: number };
|
|
607
|
+
transcriptPath?: string;
|
|
608
|
+
}): AgentRecord {
|
|
609
|
+
return {
|
|
610
|
+
id: record.id,
|
|
611
|
+
type: record.type,
|
|
612
|
+
description: record.description,
|
|
613
|
+
status: record.status,
|
|
614
|
+
result: record.result,
|
|
615
|
+
error: record.error,
|
|
616
|
+
toolUses: record.toolUses ?? 0,
|
|
617
|
+
startedAt: record.startedAt,
|
|
618
|
+
completedAt: record.completedAt,
|
|
619
|
+
transcriptPath: record.transcriptPath,
|
|
620
|
+
lifetimeUsage: record.lifetimeUsage
|
|
621
|
+
? { ...record.lifetimeUsage }
|
|
622
|
+
: { input: 0, output: 0, cacheWrite: 0 },
|
|
623
|
+
compactionCount: 0,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
600
627
|
abort(id: string): boolean {
|
|
601
628
|
const record = this.agents.get(id);
|
|
602
629
|
if (!record) return false;
|
package/src/agent-runner.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { readFileSync } from "node:fs";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
8
|
import type { Model } from "@earendil-works/pi-ai";
|
|
9
|
-
import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { ExtensionContext, LoadExtensionsResult, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import {
|
|
11
11
|
type AgentSession,
|
|
12
12
|
type AgentSessionEvent,
|
|
@@ -785,25 +785,17 @@ export async function runAgent(
|
|
|
785
785
|
// Pi 0.80.8 replaced createAgentSession's modelRegistry option with
|
|
786
786
|
// modelRuntime, but ExtensionContext still exposes only the registry facade.
|
|
787
787
|
// Pass both so the full supported Pi range retains the parent's providers.
|
|
788
|
-
const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?:
|
|
789
|
-
|
|
790
|
-
// `modelRuntime` was added in Pi 0.80.8. Infer it when available, while
|
|
791
|
-
// falling back to unknown for older Pi declarations.
|
|
792
|
-
type CompatibleModelRuntime = CreateAgentSessionOptions extends {
|
|
793
|
-
modelRuntime?: infer Runtime;
|
|
794
|
-
} ? Runtime : unknown;
|
|
795
|
-
const sessionOpts: CreateAgentSessionOptions & {
|
|
788
|
+
const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: ModelRuntime | null }).runtime ?? undefined;
|
|
789
|
+
const sessionOpts: Parameters<typeof createAgentSession>[0] & {
|
|
796
790
|
modelRegistry: ExtensionContext["modelRegistry"];
|
|
797
|
-
modelRuntime?:
|
|
791
|
+
modelRuntime?: ModelRuntime;
|
|
798
792
|
} = {
|
|
799
793
|
cwd: effectiveCwd,
|
|
800
794
|
agentDir,
|
|
801
795
|
sessionManager,
|
|
802
796
|
settingsManager,
|
|
803
797
|
modelRegistry: ctx.modelRegistry,
|
|
804
|
-
...(parentModelRuntime
|
|
805
|
-
modelRuntime: parentModelRuntime as CompatibleModelRuntime,
|
|
806
|
-
}),
|
|
798
|
+
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime }),
|
|
807
799
|
model,
|
|
808
800
|
tools: sessionTools,
|
|
809
801
|
resourceLoader: loader,
|