pi-extended-teams 2.1.16
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/README.md +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- package/src/utils/write-queue.ts +202 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import * as paths from "../../src/utils/paths";
|
|
6
|
+
import { hasRecentPrivateAgentSessions } from "./agent-session-files";
|
|
7
|
+
|
|
8
|
+
export function resolveSkillFile(skillName: string, cwd: string): string {
|
|
9
|
+
const safeName = paths.sanitizeName(skillName);
|
|
10
|
+
const candidates = [
|
|
11
|
+
path.join(cwd, "skills", `${safeName}.md`),
|
|
12
|
+
path.join(cwd, "skills", safeName, "SKILL.md"),
|
|
13
|
+
path.join(getAgentDir(), "skills", safeName, "SKILL.md"),
|
|
14
|
+
path.join(getAgentDir(), "skills", `${safeName}.md`),
|
|
15
|
+
];
|
|
16
|
+
const found = candidates.find(candidate => fs.existsSync(candidate));
|
|
17
|
+
if (!found) {
|
|
18
|
+
throw new Error(`Skill ${skillName} not found. Checked project and agent skill directories.`);
|
|
19
|
+
}
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getPiSessionId(ctx?: any): string | undefined {
|
|
24
|
+
const id = ctx?.sessionManager?.getSessionId?.();
|
|
25
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findLeadTeamForSession(sessionId?: string): string | null {
|
|
29
|
+
if (!sessionId) return null;
|
|
30
|
+
try {
|
|
31
|
+
const teamsDir = path.dirname(paths.teamDir("__probe__"));
|
|
32
|
+
if (!fs.existsSync(teamsDir)) return null;
|
|
33
|
+
|
|
34
|
+
for (const teamDir of fs.readdirSync(teamsDir)) {
|
|
35
|
+
try {
|
|
36
|
+
const sessionFile = paths.leadSessionPath(teamDir);
|
|
37
|
+
if (!fs.existsSync(sessionFile)) continue;
|
|
38
|
+
const session = JSON.parse(fs.readFileSync(sessionFile, "utf-8"));
|
|
39
|
+
if (session.sessionId === sessionId && session.pid === process.pid) {
|
|
40
|
+
return teamDir;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore entries that are not valid team names, and invalid session files
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore errors
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function registerLeadSession(teamName: string, sessionId?: string) {
|
|
53
|
+
const sessionFile = paths.leadSessionPath(teamName);
|
|
54
|
+
const dir = path.dirname(sessionFile);
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
fs.writeFileSync(sessionFile, JSON.stringify({
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
sessionId,
|
|
59
|
+
startedAt: Date.now(),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPidAlive(pid: number): boolean {
|
|
64
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
65
|
+
try {
|
|
66
|
+
process.kill(pid, 0);
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readJsonFile(filePath: string): any | null {
|
|
74
|
+
try {
|
|
75
|
+
if (!fs.existsSync(filePath)) return null;
|
|
76
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CleanupPidFileResult {
|
|
83
|
+
pid?: number;
|
|
84
|
+
killed: boolean;
|
|
85
|
+
unlinked: boolean;
|
|
86
|
+
killError?: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function unlinkPidFile(pidFile: string): boolean {
|
|
90
|
+
if (!fs.existsSync(pidFile)) return false;
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(pidFile);
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function cleanupPidFileProcess(
|
|
100
|
+
pidFile: string,
|
|
101
|
+
options: { signal?: NodeJS.Signals | number; skipPid?: number } = {}
|
|
102
|
+
): CleanupPidFileResult {
|
|
103
|
+
const result: CleanupPidFileResult = { killed: false, unlinked: false };
|
|
104
|
+
if (!fs.existsSync(pidFile)) return result;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const pid = Number.parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
|
|
108
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
109
|
+
result.pid = pid;
|
|
110
|
+
if (pid !== options.skipPid) {
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, options.signal ?? "SIGKILL");
|
|
113
|
+
result.killed = true;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
result.killError = error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Keep cleanup best-effort: unreadable/corrupt pid files should still be unlinked.
|
|
121
|
+
} finally {
|
|
122
|
+
result.unlinked = unlinkPidFile(pidFile);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function killTeamMemberProcesses(teamName: string, config: any, terminal: any): void {
|
|
129
|
+
for (const member of config?.members || []) {
|
|
130
|
+
if (member.name === "team-lead") continue;
|
|
131
|
+
|
|
132
|
+
const pidFile = path.join(paths.teamDir(teamName), `${member.name}.pid`);
|
|
133
|
+
cleanupPidFileProcess(pidFile, { skipPid: process.pid });
|
|
134
|
+
|
|
135
|
+
if (terminal && member.tmuxPaneId) {
|
|
136
|
+
try { terminal.kill(member.tmuxPaneId); } catch {}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function forceCleanupTeam(teamName: string, terminal: any): boolean {
|
|
142
|
+
const config = readJsonFile(paths.configPath(teamName));
|
|
143
|
+
killTeamMemberProcesses(teamName, config, terminal);
|
|
144
|
+
|
|
145
|
+
const teamDirectory = paths.teamDir(teamName);
|
|
146
|
+
const tasksDirectory = paths.taskDir(teamName);
|
|
147
|
+
let removed = false;
|
|
148
|
+
if (fs.existsSync(teamDirectory)) {
|
|
149
|
+
fs.rmSync(teamDirectory, { recursive: true, force: true });
|
|
150
|
+
removed = true;
|
|
151
|
+
}
|
|
152
|
+
if (fs.existsSync(tasksDirectory)) {
|
|
153
|
+
fs.rmSync(tasksDirectory, { recursive: true, force: true });
|
|
154
|
+
removed = true;
|
|
155
|
+
}
|
|
156
|
+
return removed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function teamLastTouchedAt(teamName: string, teamDirectory: string): number {
|
|
160
|
+
const session = readJsonFile(paths.leadSessionPath(teamName));
|
|
161
|
+
if (typeof session?.startedAt === "number") return session.startedAt;
|
|
162
|
+
|
|
163
|
+
const config = readJsonFile(paths.configPath(teamName));
|
|
164
|
+
if (typeof config?.createdAt === "number") return config.createdAt;
|
|
165
|
+
|
|
166
|
+
try { return fs.statSync(teamDirectory).mtimeMs; } catch { return 0; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function cleanupStaleTeam(teamName: string, terminal: any): boolean {
|
|
170
|
+
const session = readJsonFile(paths.leadSessionPath(teamName));
|
|
171
|
+
if (!session?.pid) return false;
|
|
172
|
+
return isPidAlive(Number(session.pid)) ? false : forceCleanupTeam(teamName, terminal);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface CleanupOrphanedTeamsOptions {
|
|
176
|
+
/** How old a team folder without a live lead must be before removal. */
|
|
177
|
+
maxAgeMs?: number;
|
|
178
|
+
/** Test seam; defaults to ~/.pi/teams. */
|
|
179
|
+
teamsRoot?: string;
|
|
180
|
+
now?: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function cleanupOrphanedTeams(
|
|
184
|
+
terminal: any,
|
|
185
|
+
options: CleanupOrphanedTeamsOptions = {}
|
|
186
|
+
): number {
|
|
187
|
+
const teamsRoot = options.teamsRoot || paths.TEAMS_DIR;
|
|
188
|
+
if (!fs.existsSync(teamsRoot)) return 0;
|
|
189
|
+
|
|
190
|
+
const now = options.now ?? Date.now();
|
|
191
|
+
const maxAgeMs = options.maxAgeMs ?? 24 * 60 * 60 * 1000;
|
|
192
|
+
let cleaned = 0;
|
|
193
|
+
|
|
194
|
+
for (const dir of fs.readdirSync(teamsRoot)) {
|
|
195
|
+
const teamDirectory = path.join(teamsRoot, dir);
|
|
196
|
+
try {
|
|
197
|
+
if (!fs.statSync(teamDirectory).isDirectory()) continue;
|
|
198
|
+
paths.sanitizeName(dir);
|
|
199
|
+
|
|
200
|
+
const session = readJsonFile(paths.leadSessionPath(dir));
|
|
201
|
+
if (session?.pid && isPidAlive(Number(session.pid))) continue;
|
|
202
|
+
|
|
203
|
+
const missingLiveLead = !session?.pid || !isPidAlive(Number(session.pid));
|
|
204
|
+
const lastTouchedAt = teamLastTouchedAt(dir, teamDirectory);
|
|
205
|
+
const oldEnough = maxAgeMs <= 0 || (lastTouchedAt > 0 && (now - lastTouchedAt) > maxAgeMs);
|
|
206
|
+
const deadLeadPid = !!session?.pid && !isPidAlive(Number(session.pid));
|
|
207
|
+
const quarantineDir = path.join(teamDirectory, "lifecycle", "quarantine");
|
|
208
|
+
const hasLifecycleFence = fs.existsSync(quarantineDir)
|
|
209
|
+
&& fs.readdirSync(quarantineDir).some(file => file.endsWith(".json"));
|
|
210
|
+
|
|
211
|
+
const shouldCleanOrphan = deadLeadPid || (missingLiveLead && oldEnough);
|
|
212
|
+
|
|
213
|
+
// Lifecycle tombstones have no TTL. Even corrupt files are occupied and
|
|
214
|
+
// must keep the team directory out of orphan cleanup, but they must not
|
|
215
|
+
// keep child processes alive after their lead is gone.
|
|
216
|
+
if (hasLifecycleFence) {
|
|
217
|
+
if (shouldCleanOrphan) {
|
|
218
|
+
killTeamMemberProcesses(dir, readJsonFile(paths.configPath(dir)), terminal);
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (shouldCleanOrphan) {
|
|
224
|
+
// Failed and recovery-relevant spawned sessions intentionally outlive
|
|
225
|
+
// their roster member. Stop orphan processes, but do not let dead-lead
|
|
226
|
+
// directory cleanup bypass the private transcript TTL.
|
|
227
|
+
const privateSessionRetention = hasRecentPrivateAgentSessions(teamDirectory, { now, maxAgeMs });
|
|
228
|
+
if (privateSessionRetention !== "none") {
|
|
229
|
+
killTeamMemberProcesses(dir, readJsonFile(paths.configPath(dir)), terminal);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (forceCleanupTeam(dir, terminal)) cleaned++;
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
// Ignore malformed team directories; cleanup should never disrupt startup.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return cleaned;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function cleanupAgentSessionFolders(maxAgeMs: number = 24 * 60 * 60 * 1000): number {
|
|
243
|
+
const agentTeamsDir = path.join(os.homedir(), ".pi", "agent", "teams");
|
|
244
|
+
if (!fs.existsSync(agentTeamsDir)) return 0;
|
|
245
|
+
|
|
246
|
+
let cleaned = 0;
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
|
|
249
|
+
for (const dir of fs.readdirSync(agentTeamsDir)) {
|
|
250
|
+
const sessionDir = path.join(agentTeamsDir, dir);
|
|
251
|
+
const configFile = path.join(sessionDir, "config.json");
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
// Check if this is a directory with a config.json
|
|
255
|
+
if (!fs.statSync(sessionDir).isDirectory()) continue;
|
|
256
|
+
if (!fs.existsSync(configFile)) continue;
|
|
257
|
+
|
|
258
|
+
// Read the config to check the creation time
|
|
259
|
+
const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
260
|
+
const createdAt = config.createdAt ? new Date(config.createdAt).getTime() : 0;
|
|
261
|
+
|
|
262
|
+
// If the folder is older than maxAgeMs, delete it
|
|
263
|
+
if (createdAt > 0 && (now - createdAt) > maxAgeMs) {
|
|
264
|
+
fs.rmSync(sessionDir, { recursive: true });
|
|
265
|
+
cleaned++;
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// Ignore errors for individual folders
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return cleaned;
|
|
273
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface SessionUsageSummary {
|
|
2
|
+
tokensUsed?: number;
|
|
3
|
+
costUsd?: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function numberOrZero(value: unknown): number {
|
|
7
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function assistantMessageKey(message: any): string | undefined {
|
|
11
|
+
if (!message || message.role !== "assistant") return undefined;
|
|
12
|
+
const timestamp = numberOrZero(message.timestamp);
|
|
13
|
+
const provider = typeof message.provider === "string" ? message.provider : "";
|
|
14
|
+
const model = typeof message.model === "string" ? message.model : "";
|
|
15
|
+
const stopReason = typeof message.stopReason === "string" ? message.stopReason : "";
|
|
16
|
+
return `${timestamp}:${provider}:${model}:${stopReason}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function usageTokens(usage: any): number {
|
|
20
|
+
return (
|
|
21
|
+
numberOrZero(usage?.input) +
|
|
22
|
+
numberOrZero(usage?.output) +
|
|
23
|
+
numberOrZero(usage?.cacheRead) +
|
|
24
|
+
numberOrZero(usage?.cacheWrite)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function addAssistantUsage(message: any, totals: { tokens: number; cost: number; seen: boolean }): void {
|
|
29
|
+
if (!message || message.role !== "assistant" || !message.usage) return;
|
|
30
|
+
totals.tokens += usageTokens(message.usage);
|
|
31
|
+
totals.cost += numberOrZero(message.usage.cost?.total);
|
|
32
|
+
totals.seen = true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function summarizeSessionUsage(ctx: any, currentAssistantMessage?: any): SessionUsageSummary {
|
|
36
|
+
const sessionManager = ctx?.sessionManager;
|
|
37
|
+
const entries = typeof sessionManager?.getBranch === "function"
|
|
38
|
+
? sessionManager.getBranch()
|
|
39
|
+
: typeof sessionManager?.getEntries === "function"
|
|
40
|
+
? sessionManager.getEntries()
|
|
41
|
+
: [];
|
|
42
|
+
|
|
43
|
+
const totals = { tokens: 0, cost: 0, seen: false };
|
|
44
|
+
const currentKey = assistantMessageKey(currentAssistantMessage);
|
|
45
|
+
let currentAlreadyIncluded = false;
|
|
46
|
+
|
|
47
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
48
|
+
const message = entry?.type === "message" ? entry.message : entry?.message;
|
|
49
|
+
if (!message || message.role !== "assistant") continue;
|
|
50
|
+
if (currentKey && assistantMessageKey(message) === currentKey) currentAlreadyIncluded = true;
|
|
51
|
+
addAssistantUsage(message, totals);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (currentAssistantMessage && !currentAlreadyIncluded) {
|
|
55
|
+
addAssistantUsage(currentAssistantMessage, totals);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return totals.seen ? { tokensUsed: totals.tokens, costUsd: totals.cost } : {};
|
|
59
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { DEFAULT_SETTINGS } from "../../src/utils/settings";
|
|
6
|
+
import {
|
|
7
|
+
createSpawnResourcePlan,
|
|
8
|
+
EXTENSIONS_COMMAND_DESCRIPTION,
|
|
9
|
+
parentProjectTrustForSpawn,
|
|
10
|
+
snapshotLeadExtensions,
|
|
11
|
+
type ExtensionSourceInfo,
|
|
12
|
+
} from "./spawn-resource-plan";
|
|
13
|
+
|
|
14
|
+
const roots: string[] = [];
|
|
15
|
+
|
|
16
|
+
function tempRoot(): string {
|
|
17
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pet-resource-plan-"));
|
|
18
|
+
roots.push(root);
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function touch(filePath: string): string {
|
|
23
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
24
|
+
fs.writeFileSync(filePath, "export default function () {}\n");
|
|
25
|
+
return filePath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sourceInfo(filePath: string, overrides: Partial<ExtensionSourceInfo> = {}): ExtensionSourceInfo {
|
|
29
|
+
return {
|
|
30
|
+
path: filePath,
|
|
31
|
+
source: "local",
|
|
32
|
+
scope: "user",
|
|
33
|
+
origin: "top-level",
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("lead extension snapshots", () => {
|
|
43
|
+
it("uses only public command/tool sourceInfo, dedupes canonical paths, and identifies self", () => {
|
|
44
|
+
const root = tempRoot();
|
|
45
|
+
const self = touch(path.join(root, "pi-extended-teams", "extensions", "index.ts"));
|
|
46
|
+
const selected = touch(path.join(root, "selected", "index.ts"));
|
|
47
|
+
const selectedLink = path.join(root, "selected-link.ts");
|
|
48
|
+
fs.symlinkSync(selected, selectedLink);
|
|
49
|
+
|
|
50
|
+
const snapshot = snapshotLeadExtensions({
|
|
51
|
+
getCommands: () => [
|
|
52
|
+
{ name: "agents-extensions", source: "extension", sourceInfo: sourceInfo(self) },
|
|
53
|
+
{ name: "selected-command", source: "extension", sourceInfo: sourceInfo(selected) },
|
|
54
|
+
{ name: "skill:anything", source: "skill", sourceInfo: sourceInfo("/skills/anything.md") },
|
|
55
|
+
],
|
|
56
|
+
getAllTools: () => [
|
|
57
|
+
{ name: "read", sourceInfo: sourceInfo("<builtin:read>", { source: "builtin", scope: "temporary" }) },
|
|
58
|
+
{ name: "selected-tool", sourceInfo: sourceInfo(selectedLink) },
|
|
59
|
+
{ name: "other", sourceInfo: sourceInfo(path.join(root, "other.ts"), { source: "npm:other", origin: "package" }) },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(snapshot.map((extension) => extension.path)).toEqual([self, selected, path.join(root, "other.ts")]);
|
|
64
|
+
expect(snapshot.map((extension) => extension.name)).toEqual(["pi-extended-teams", "selected", "other"]);
|
|
65
|
+
expect(snapshot.map((extension) => extension.isSelf)).toEqual([true, false, false]);
|
|
66
|
+
expect(Object.isFrozen(snapshot)).toBe(true);
|
|
67
|
+
expect(Object.isFrozen(snapshot[0]?.sourceInfo)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("identifies self by its public command description when invocation names collide", () => {
|
|
71
|
+
const root = tempRoot();
|
|
72
|
+
const other = touch(path.join(root, "other.ts"));
|
|
73
|
+
const self = touch(path.join(root, "pi-extended-teams", "extensions", "index.ts"));
|
|
74
|
+
const snapshot = snapshotLeadExtensions({
|
|
75
|
+
getCommands: () => [
|
|
76
|
+
{ name: "agents-extensions", description: "Another command", source: "extension", sourceInfo: sourceInfo(other) },
|
|
77
|
+
{ name: "agents-extensions:2", description: EXTENSIONS_COMMAND_DESCRIPTION, source: "extension", sourceInfo: sourceInfo(self) },
|
|
78
|
+
],
|
|
79
|
+
getAllTools: () => [],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(snapshot.map((extension) => extension.isSelf)).toEqual([false, true]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("cannot observe event-only or synthetic inline extensions through the public registration surface", () => {
|
|
86
|
+
const snapshot = snapshotLeadExtensions({
|
|
87
|
+
getCommands: () => [],
|
|
88
|
+
getAllTools: () => [
|
|
89
|
+
{ name: "sdk-tool", sourceInfo: sourceInfo("<sdk:sdk-tool>", { source: "sdk", scope: "temporary" }) },
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(snapshot).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("spawn resource planning", () => {
|
|
98
|
+
it("preserves null, empty, explicit, and block semantics over the lead snapshot", () => {
|
|
99
|
+
const root = tempRoot();
|
|
100
|
+
const self = touch(path.join(root, "pi-extended-teams", "extensions", "index.ts"));
|
|
101
|
+
const first = touch(path.join(root, "first", "index.ts"));
|
|
102
|
+
const second = touch(path.join(root, "second.ts"));
|
|
103
|
+
const blocked = touch(path.join(root, "blocked.ts"));
|
|
104
|
+
const leadExtensions = snapshotLeadExtensions({
|
|
105
|
+
getCommands: () => [
|
|
106
|
+
{ name: "agents-extensions", source: "extension", sourceInfo: sourceInfo(self) },
|
|
107
|
+
{ name: "first-command", source: "extension", sourceInfo: sourceInfo(first) },
|
|
108
|
+
],
|
|
109
|
+
getAllTools: () => [
|
|
110
|
+
{ name: "second-tool", sourceInfo: sourceInfo(second) },
|
|
111
|
+
{ name: "blocked-tool", sourceInfo: sourceInfo(blocked) },
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const defaultSettings = structuredClone(DEFAULT_SETTINGS);
|
|
116
|
+
defaultSettings.extensions.block = ["blocked"];
|
|
117
|
+
const defaultPlan = createSpawnResourcePlan({
|
|
118
|
+
cwd: root,
|
|
119
|
+
projectTrusted: true,
|
|
120
|
+
settings: defaultSettings,
|
|
121
|
+
leadExtensions,
|
|
122
|
+
});
|
|
123
|
+
expect(defaultPlan.extensionPaths).toEqual([first, second]);
|
|
124
|
+
expect(defaultPlan.selfExtensionPath).toBe(self);
|
|
125
|
+
expect(defaultPlan.extensions.map((extension) => extension.state)).toEqual([
|
|
126
|
+
"self",
|
|
127
|
+
"selected",
|
|
128
|
+
"selected",
|
|
129
|
+
"blocked",
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
const noneSettings = structuredClone(DEFAULT_SETTINGS);
|
|
133
|
+
noneSettings.extensions.allow = [];
|
|
134
|
+
const none = createSpawnResourcePlan({ cwd: root, projectTrusted: true, settings: noneSettings, leadExtensions });
|
|
135
|
+
expect(none.selectionMode).toBe("explicit");
|
|
136
|
+
expect(none.extensionPaths).toEqual([]);
|
|
137
|
+
|
|
138
|
+
const explicitSettings = structuredClone(DEFAULT_SETTINGS);
|
|
139
|
+
explicitSettings.extensions.allow = ["first", second, "blocked", "missing-extension", "pi-extended-teams"];
|
|
140
|
+
explicitSettings.extensions.block = ["blocked"];
|
|
141
|
+
const explicit = createSpawnResourcePlan({ cwd: root, projectTrusted: true, settings: explicitSettings, leadExtensions });
|
|
142
|
+
expect(explicit.extensionPaths).toEqual([first, second]);
|
|
143
|
+
expect(explicit.diagnostics).toEqual(expect.arrayContaining([
|
|
144
|
+
expect.objectContaining({ code: "blocked-selection", configuredEntry: "blocked" }),
|
|
145
|
+
expect.objectContaining({ code: "stale-selection", configuredEntry: "missing-extension" }),
|
|
146
|
+
expect.objectContaining({ code: "self-excluded", configuredEntry: "pi-extended-teams" }),
|
|
147
|
+
]));
|
|
148
|
+
expect(Object.isFrozen(explicit)).toBe(true);
|
|
149
|
+
expect(Object.isFrozen(explicit.extensionPaths)).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("uses names when unique and paths when names collide", () => {
|
|
153
|
+
const root = tempRoot();
|
|
154
|
+
const first = touch(path.join(root, "a", "same", "index.ts"));
|
|
155
|
+
const second = touch(path.join(root, "b", "same", "index.ts"));
|
|
156
|
+
const unique = touch(path.join(root, "unique.ts"));
|
|
157
|
+
const leadExtensions = snapshotLeadExtensions({
|
|
158
|
+
getCommands: () => [
|
|
159
|
+
{ name: "one", source: "extension", sourceInfo: sourceInfo(first) },
|
|
160
|
+
{ name: "two", source: "extension", sourceInfo: sourceInfo(second) },
|
|
161
|
+
{ name: "three", source: "extension", sourceInfo: sourceInfo(unique) },
|
|
162
|
+
],
|
|
163
|
+
getAllTools: () => [],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const plan = createSpawnResourcePlan({
|
|
167
|
+
cwd: root,
|
|
168
|
+
projectTrusted: false,
|
|
169
|
+
settings: structuredClone(DEFAULT_SETTINGS),
|
|
170
|
+
leadExtensions,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(plan.extensions.map((extension) => extension.selector)).toEqual([first, second, "unique"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("keeps canonical selections stable when a same-name extension is later observed", () => {
|
|
177
|
+
const root = tempRoot();
|
|
178
|
+
const original = touch(path.join(root, "first", "same.ts"));
|
|
179
|
+
const second = touch(path.join(root, "second", "same.ts"));
|
|
180
|
+
const oneExtension = snapshotLeadExtensions({
|
|
181
|
+
getCommands: () => [{ name: "first", source: "extension", sourceInfo: sourceInfo(original) }],
|
|
182
|
+
getAllTools: () => [],
|
|
183
|
+
});
|
|
184
|
+
const settings = structuredClone(DEFAULT_SETTINGS);
|
|
185
|
+
settings.extensions.allow = [oneExtension[0]!.identity];
|
|
186
|
+
|
|
187
|
+
const rebuilt = snapshotLeadExtensions({
|
|
188
|
+
getCommands: () => [
|
|
189
|
+
{ name: "first", source: "extension", sourceInfo: sourceInfo(original) },
|
|
190
|
+
{ name: "second", source: "extension", sourceInfo: sourceInfo(second) },
|
|
191
|
+
],
|
|
192
|
+
getAllTools: () => [],
|
|
193
|
+
});
|
|
194
|
+
const canonical = createSpawnResourcePlan({ cwd: root, projectTrusted: true, settings, leadExtensions: rebuilt });
|
|
195
|
+
expect(canonical.extensionPaths).toEqual([original]);
|
|
196
|
+
|
|
197
|
+
const legacySettings = structuredClone(DEFAULT_SETTINGS);
|
|
198
|
+
legacySettings.extensions.allow = ["same"];
|
|
199
|
+
const legacy = createSpawnResourcePlan({ cwd: root, projectTrusted: true, settings: legacySettings, leadExtensions: rebuilt });
|
|
200
|
+
expect(legacy.extensionPaths).toEqual([original, second]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("matches canonical and symlink entries while blocks always override allow", () => {
|
|
204
|
+
const root = tempRoot();
|
|
205
|
+
const extension = touch(path.join(root, "extension.ts"));
|
|
206
|
+
const extensionLink = path.join(root, "extension-link.ts");
|
|
207
|
+
fs.symlinkSync(extension, extensionLink);
|
|
208
|
+
const leadExtensions = snapshotLeadExtensions({
|
|
209
|
+
getCommands: () => [{ name: "extension", source: "extension", sourceInfo: sourceInfo(extension) }],
|
|
210
|
+
getAllTools: () => [],
|
|
211
|
+
});
|
|
212
|
+
const settings = structuredClone(DEFAULT_SETTINGS);
|
|
213
|
+
settings.extensions.allow = [leadExtensions[0]!.identity];
|
|
214
|
+
settings.extensions.block = [extensionLink];
|
|
215
|
+
|
|
216
|
+
const plan = createSpawnResourcePlan({ cwd: root, projectTrusted: true, settings, leadExtensions });
|
|
217
|
+
expect(plan.extensionPaths).toEqual([]);
|
|
218
|
+
expect(plan.extensions[0]?.state).toBe("blocked");
|
|
219
|
+
expect(plan.diagnostics).toEqual([
|
|
220
|
+
expect.objectContaining({ code: "blocked-selection", configuredEntry: leadExtensions[0]!.identity }),
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("never transfers project trust across cwd", () => {
|
|
225
|
+
const root = tempRoot();
|
|
226
|
+
const project = path.join(root, "project");
|
|
227
|
+
const ctx = { cwd: project, isProjectTrusted: () => true };
|
|
228
|
+
|
|
229
|
+
expect(parentProjectTrustForSpawn(ctx, project)).toBe(true);
|
|
230
|
+
expect(parentProjectTrustForSpawn(ctx, path.join(root, "other-project"))).toBe(false);
|
|
231
|
+
expect(parentProjectTrustForSpawn({ cwd: project }, project)).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
});
|