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,164 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as paths from "../../src/utils/paths.js";
|
|
6
|
+
import {
|
|
7
|
+
PRIVATE_AGENT_SESSION_RETENTION_MS,
|
|
8
|
+
cleanupPrivateAgentSessionDirectory,
|
|
9
|
+
cleanupStalePrivateAgentSessions,
|
|
10
|
+
preparePrivateAgentSessionDirectory,
|
|
11
|
+
} from "./agent-session-files.js";
|
|
12
|
+
|
|
13
|
+
let root: string;
|
|
14
|
+
let teamsRoot: string;
|
|
15
|
+
|
|
16
|
+
function privateRunDirectory(teamName: string, agentName: string, runId: string): string {
|
|
17
|
+
return path.join(teamsRoot, teamName, "agent-sessions", agentName, runId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeTeamConfig(
|
|
21
|
+
teamName: string,
|
|
22
|
+
members: Array<{ name: string; lifecycleRunId?: string; isActive?: boolean }> = [],
|
|
23
|
+
): void {
|
|
24
|
+
const teamDirectory = path.join(teamsRoot, teamName);
|
|
25
|
+
fs.mkdirSync(teamDirectory, { recursive: true });
|
|
26
|
+
fs.writeFileSync(path.join(teamDirectory, "config.json"), JSON.stringify({
|
|
27
|
+
name: teamName,
|
|
28
|
+
members: [
|
|
29
|
+
{ name: "team-lead", agentType: "lead" },
|
|
30
|
+
...members.map(member => ({ ...member, agentType: "teammate" })),
|
|
31
|
+
],
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createRunDirectory(teamName: string, agentName: string, runId: string): string {
|
|
36
|
+
const directory = privateRunDirectory(teamName, agentName, runId);
|
|
37
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
38
|
+
fs.writeFileSync(path.join(directory, "session.jsonl"), "private transcript\n");
|
|
39
|
+
return directory;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("private spawned-agent session files", () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-private-agent-sessions-"));
|
|
45
|
+
teamsRoot = path.join(root, "teams");
|
|
46
|
+
fs.mkdirSync(teamsRoot, { recursive: true });
|
|
47
|
+
vi.spyOn(paths, "teamDir").mockImplementation(teamName => {
|
|
48
|
+
return path.join(teamsRoot, paths.sanitizeName(String(teamName)));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.restoreAllMocks();
|
|
54
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("creates a private per-run directory outside Pi's resumable session tree", () => {
|
|
58
|
+
writeTeamConfig("team");
|
|
59
|
+
|
|
60
|
+
const directory = preparePrivateAgentSessionDirectory("team", "reader", "run-one");
|
|
61
|
+
|
|
62
|
+
expect(directory).toBe(privateRunDirectory("team", "reader", "run-one"));
|
|
63
|
+
expect(directory).not.toContain(path.join(".pi", "agent", "sessions"));
|
|
64
|
+
expect(fs.statSync(directory).isDirectory()).toBe(true);
|
|
65
|
+
if (process.platform !== "win32") {
|
|
66
|
+
expect(fs.statSync(directory).mode & 0o777).toBe(0o700);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("removes only the exact completed run and preserves sibling and unrelated files", () => {
|
|
71
|
+
writeTeamConfig("team");
|
|
72
|
+
const completed = createRunDirectory("team", "reader", "run-completed");
|
|
73
|
+
const retained = createRunDirectory("team", "reader", "run-retained");
|
|
74
|
+
const unrelated = path.join(teamsRoot, "team", "notes.txt");
|
|
75
|
+
fs.writeFileSync(unrelated, "keep");
|
|
76
|
+
|
|
77
|
+
expect(cleanupPrivateAgentSessionDirectory("team", "reader", "run-completed")).toBe(true);
|
|
78
|
+
|
|
79
|
+
expect(fs.existsSync(completed)).toBe(false);
|
|
80
|
+
expect(fs.existsSync(retained)).toBe(true);
|
|
81
|
+
expect(fs.readFileSync(unrelated, "utf8")).toBe("keep");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("removes only stale inactive runs while preserving active and recent recovery runs", () => {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
writeTeamConfig("team", [{ name: "reader", lifecycleRunId: "run-active", isActive: false }]);
|
|
87
|
+
const active = createRunDirectory("team", "reader", "run-active");
|
|
88
|
+
const stale = createRunDirectory("team", "reader", "run-stale");
|
|
89
|
+
const recent = createRunDirectory("team", "reader", "run-recent");
|
|
90
|
+
const old = new Date(now - PRIVATE_AGENT_SESSION_RETENTION_MS - 1_000);
|
|
91
|
+
fs.utimesSync(path.join(active, "session.jsonl"), old, old);
|
|
92
|
+
fs.utimesSync(active, old, old);
|
|
93
|
+
fs.utimesSync(path.join(stale, "session.jsonl"), old, old);
|
|
94
|
+
fs.utimesSync(stale, old, old);
|
|
95
|
+
// The directory was admitted long ago, but its transcript was written recently.
|
|
96
|
+
fs.utimesSync(recent, old, old);
|
|
97
|
+
|
|
98
|
+
const cleaned = cleanupStalePrivateAgentSessions({ teamsRoot, now });
|
|
99
|
+
|
|
100
|
+
expect(cleaned).toBe(1);
|
|
101
|
+
expect(fs.existsSync(active)).toBe(true);
|
|
102
|
+
expect(fs.existsSync(stale)).toBe(false);
|
|
103
|
+
expect(fs.existsSync(recent)).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("continues cleaning later stale runs when one private-session removal fails", () => {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
writeTeamConfig("team");
|
|
109
|
+
const failed = createRunDirectory("team", "reader", "run-failed");
|
|
110
|
+
const cleaned = createRunDirectory("team", "reader", "run-cleaned");
|
|
111
|
+
const old = new Date(now - PRIVATE_AGENT_SESSION_RETENTION_MS - 1_000);
|
|
112
|
+
fs.utimesSync(path.join(failed, "session.jsonl"), old, old);
|
|
113
|
+
fs.utimesSync(failed, old, old);
|
|
114
|
+
fs.utimesSync(path.join(cleaned, "session.jsonl"), old, old);
|
|
115
|
+
fs.utimesSync(cleaned, old, old);
|
|
116
|
+
const originalRemove = fs.rmSync.bind(fs);
|
|
117
|
+
const remove = vi.spyOn(fs, "rmSync").mockImplementation((target: any, options?: any) => {
|
|
118
|
+
if (target === failed) throw new Error("simulated removal failure");
|
|
119
|
+
return originalRemove(target, options);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(cleanupStalePrivateAgentSessions({ teamsRoot, now })).toBe(1);
|
|
123
|
+
expect(remove).toHaveBeenCalledWith(failed, { recursive: true, force: false });
|
|
124
|
+
expect(fs.existsSync(failed)).toBe(true);
|
|
125
|
+
expect(fs.existsSync(cleaned)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("retains an uninspectable stale run while cleaning separately inspectable stale runs", () => {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
writeTeamConfig("team");
|
|
131
|
+
const uninspectable = createRunDirectory("team", "reader", "run-uninspectable");
|
|
132
|
+
const inspectable = createRunDirectory("team", "reader", "run-inspectable");
|
|
133
|
+
const uninspectableTranscript = path.join(uninspectable, "session.jsonl");
|
|
134
|
+
const old = new Date(now - PRIVATE_AGENT_SESSION_RETENTION_MS - 1_000);
|
|
135
|
+
fs.utimesSync(uninspectableTranscript, old, old);
|
|
136
|
+
fs.utimesSync(uninspectable, old, old);
|
|
137
|
+
fs.utimesSync(path.join(inspectable, "session.jsonl"), old, old);
|
|
138
|
+
fs.utimesSync(inspectable, old, old);
|
|
139
|
+
|
|
140
|
+
const originalLstat = fs.lstatSync.bind(fs) as any;
|
|
141
|
+
vi.spyOn(fs, "lstatSync").mockImplementation(((target: any, ...args: any[]) => {
|
|
142
|
+
if (String(target) === uninspectableTranscript) {
|
|
143
|
+
throw Object.assign(new Error("simulated inspection failure"), { code: "EACCES" });
|
|
144
|
+
}
|
|
145
|
+
return originalLstat(target, ...args);
|
|
146
|
+
}) as any);
|
|
147
|
+
|
|
148
|
+
expect(cleanupStalePrivateAgentSessions({ teamsRoot, now })).toBe(1);
|
|
149
|
+
expect(fs.existsSync(uninspectable)).toBe(true);
|
|
150
|
+
expect(fs.existsSync(inspectable)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it.runIf(process.platform !== "win32")("refuses symlinked private-session directories", () => {
|
|
154
|
+
writeTeamConfig("team");
|
|
155
|
+
const external = path.join(root, "external");
|
|
156
|
+
fs.mkdirSync(external);
|
|
157
|
+
const privateRoot = path.join(teamsRoot, "team", "agent-sessions");
|
|
158
|
+
fs.symlinkSync(external, privateRoot, "dir");
|
|
159
|
+
|
|
160
|
+
expect(() => preparePrivateAgentSessionDirectory("team", "reader", "run-one"))
|
|
161
|
+
.toThrow(/private agent session directory/i);
|
|
162
|
+
expect(fs.readdirSync(external)).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as paths from "../../src/utils/paths";
|
|
4
|
+
|
|
5
|
+
export const PRIVATE_AGENT_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export interface CleanupStalePrivateAgentSessionsOptions {
|
|
8
|
+
teamsRoot?: string;
|
|
9
|
+
now?: number;
|
|
10
|
+
maxAgeMs?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PrivateAgentSessionRetentionOptions {
|
|
14
|
+
now?: number;
|
|
15
|
+
maxAgeMs?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type PrivateAgentSessionRetentionResult = "recent" | "none" | "unknown";
|
|
19
|
+
|
|
20
|
+
type DirectoryInspection =
|
|
21
|
+
| { kind: "directory"; stat: fs.Stats }
|
|
22
|
+
| { kind: "missing" }
|
|
23
|
+
| { kind: "unsafe" }
|
|
24
|
+
| { kind: "error" };
|
|
25
|
+
|
|
26
|
+
type DirectoryEntriesInspection =
|
|
27
|
+
| { kind: "entries"; entries: fs.Dirent[] }
|
|
28
|
+
| { kind: "error" };
|
|
29
|
+
|
|
30
|
+
function errorCode(error: unknown): string | undefined {
|
|
31
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
|
|
32
|
+
const code = error.code;
|
|
33
|
+
return typeof code === "string" ? code : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function inspectDirectory(directory: string): DirectoryInspection {
|
|
37
|
+
try {
|
|
38
|
+
const stat = fs.lstatSync(directory);
|
|
39
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return { kind: "unsafe" };
|
|
40
|
+
return { kind: "directory", stat };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR"
|
|
43
|
+
? { kind: "missing" }
|
|
44
|
+
: { kind: "error" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function inspectDirectoryEntries(directory: string): DirectoryEntriesInspection {
|
|
49
|
+
try {
|
|
50
|
+
return { kind: "entries", entries: fs.readdirSync(directory, { withFileTypes: true }) };
|
|
51
|
+
} catch {
|
|
52
|
+
return { kind: "error" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function safeComponent(value: string): string {
|
|
57
|
+
return paths.sanitizeName(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function directoryStat(directory: string): fs.Stats | null {
|
|
61
|
+
try {
|
|
62
|
+
const stat = fs.lstatSync(directory);
|
|
63
|
+
return stat.isDirectory() && !stat.isSymbolicLink() ? stat : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ensurePrivateDirectory(directory: string): void {
|
|
70
|
+
const existing = directoryStat(directory);
|
|
71
|
+
if (!existing) {
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
74
|
+
} catch (error: any) {
|
|
75
|
+
if (error?.code !== "EEXIST") throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const verified = directoryStat(directory);
|
|
79
|
+
if (!verified) {
|
|
80
|
+
throw new Error(`Refusing untrusted private agent session directory: ${directory}`);
|
|
81
|
+
}
|
|
82
|
+
if (process.platform !== "win32" && (verified.mode & 0o777) !== 0o700) {
|
|
83
|
+
fs.chmodSync(directory, 0o700);
|
|
84
|
+
const secured = directoryStat(directory);
|
|
85
|
+
if (!secured || (secured.mode & 0o777) !== 0o700) {
|
|
86
|
+
throw new Error(`Could not secure private agent session directory: ${directory}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function privateSessionRoot(teamName: string): string {
|
|
92
|
+
return path.join(paths.teamDir(safeComponent(teamName)), "agent-sessions");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function privateAgentDirectory(teamName: string, agentName: string): string {
|
|
96
|
+
return path.join(privateSessionRoot(teamName), safeComponent(agentName));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function privateRunDirectory(teamName: string, agentName: string, lifecycleRunId: string): string {
|
|
100
|
+
return path.join(privateAgentDirectory(teamName, agentName), safeComponent(lifecycleRunId));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pruneEmptyDirectory(directory: string): void {
|
|
104
|
+
try {
|
|
105
|
+
if (directoryStat(directory)) fs.rmdirSync(directory);
|
|
106
|
+
} catch {
|
|
107
|
+
// Siblings or unknown entries keep their parent directory in place.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function preparePrivateAgentSessionDirectory(
|
|
112
|
+
teamName: string,
|
|
113
|
+
agentName: string,
|
|
114
|
+
lifecycleRunId: string,
|
|
115
|
+
): string {
|
|
116
|
+
const teamDirectory = paths.teamDir(safeComponent(teamName));
|
|
117
|
+
if (!directoryStat(teamDirectory)) {
|
|
118
|
+
throw new Error(`Refusing private agent session storage without a trusted team directory: ${teamDirectory}`);
|
|
119
|
+
}
|
|
120
|
+
const root = privateSessionRoot(teamName);
|
|
121
|
+
const agentDirectory = privateAgentDirectory(teamName, agentName);
|
|
122
|
+
const runDirectory = privateRunDirectory(teamName, agentName, lifecycleRunId);
|
|
123
|
+
ensurePrivateDirectory(root);
|
|
124
|
+
ensurePrivateDirectory(agentDirectory);
|
|
125
|
+
ensurePrivateDirectory(runDirectory);
|
|
126
|
+
return runDirectory;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function cleanupPrivateAgentSessionDirectory(
|
|
130
|
+
teamName: string,
|
|
131
|
+
agentName: string,
|
|
132
|
+
lifecycleRunId: string,
|
|
133
|
+
): boolean {
|
|
134
|
+
const root = privateSessionRoot(teamName);
|
|
135
|
+
const agentDirectory = privateAgentDirectory(teamName, agentName);
|
|
136
|
+
const runDirectory = privateRunDirectory(teamName, agentName, lifecycleRunId);
|
|
137
|
+
const runInspection = inspectDirectory(runDirectory);
|
|
138
|
+
if (runInspection.kind === "missing") return false;
|
|
139
|
+
const inspections = [
|
|
140
|
+
{ directory: root, inspection: inspectDirectory(root) },
|
|
141
|
+
{ directory: agentDirectory, inspection: inspectDirectory(agentDirectory) },
|
|
142
|
+
{ directory: runDirectory, inspection: runInspection },
|
|
143
|
+
];
|
|
144
|
+
const failedInspection = inspections.find(({ inspection }) => inspection.kind !== "directory");
|
|
145
|
+
if (failedInspection) {
|
|
146
|
+
if (failedInspection.inspection.kind === "error") {
|
|
147
|
+
throw new Error(`Could not inspect private agent session directory: ${failedInspection.directory}`);
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`Refusing to remove an untrusted private agent session directory: ${failedInspection.directory}`);
|
|
150
|
+
}
|
|
151
|
+
fs.rmSync(runDirectory, { recursive: true, force: false });
|
|
152
|
+
pruneEmptyDirectory(agentDirectory);
|
|
153
|
+
pruneEmptyDirectory(root);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readActiveRuns(teamDirectory: string): Set<string> | null {
|
|
158
|
+
try {
|
|
159
|
+
const configPath = path.join(teamDirectory, "config.json");
|
|
160
|
+
const stat = fs.lstatSync(configPath);
|
|
161
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
|
162
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
163
|
+
if (!Array.isArray(config?.members)) return null;
|
|
164
|
+
const active = new Set<string>();
|
|
165
|
+
for (const member of config.members) {
|
|
166
|
+
// A report fence marks the member inactive before trailing session events
|
|
167
|
+
// and final teardown settle. Roster presence, not isActive, owns retention.
|
|
168
|
+
if (member?.agentType !== "teammate") continue;
|
|
169
|
+
if (typeof member.name !== "string" || typeof member.lifecycleRunId !== "string") return null;
|
|
170
|
+
active.add(`${safeComponent(member.name)}\0${safeComponent(member.lifecycleRunId)}`);
|
|
171
|
+
}
|
|
172
|
+
return active;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function safeDirectoryEntries(directory: string): fs.Dirent[] {
|
|
179
|
+
try {
|
|
180
|
+
return fs.readdirSync(directory, { withFileTypes: true });
|
|
181
|
+
} catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function latestPrivateRunActivityMs(runDirectory: string, runStat: fs.Stats): number | null {
|
|
187
|
+
const entries = inspectDirectoryEntries(runDirectory);
|
|
188
|
+
if (entries.kind === "error") return null;
|
|
189
|
+
|
|
190
|
+
let latest = runStat.mtimeMs;
|
|
191
|
+
for (const entry of entries.entries) {
|
|
192
|
+
const entryPath = path.join(runDirectory, entry.name);
|
|
193
|
+
try {
|
|
194
|
+
const stat = fs.lstatSync(entryPath);
|
|
195
|
+
if (stat.isSymbolicLink()) continue;
|
|
196
|
+
latest = Math.max(latest, stat.mtimeMs);
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return latest;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function hasRecentPrivateAgentSessions(
|
|
205
|
+
teamDirectory: string,
|
|
206
|
+
options: PrivateAgentSessionRetentionOptions = {},
|
|
207
|
+
): PrivateAgentSessionRetentionResult {
|
|
208
|
+
const now = options.now ?? Date.now();
|
|
209
|
+
const maxAgeMs = options.maxAgeMs ?? PRIVATE_AGENT_SESSION_RETENTION_MS;
|
|
210
|
+
if (maxAgeMs <= 0) return "none";
|
|
211
|
+
|
|
212
|
+
const teamInspection = inspectDirectory(teamDirectory);
|
|
213
|
+
if (teamInspection.kind === "missing" || teamInspection.kind === "unsafe") return "none";
|
|
214
|
+
if (teamInspection.kind === "error") return "unknown";
|
|
215
|
+
|
|
216
|
+
const sessionRoot = path.join(teamDirectory, "agent-sessions");
|
|
217
|
+
const rootInspection = inspectDirectory(sessionRoot);
|
|
218
|
+
if (rootInspection.kind === "missing" || rootInspection.kind === "unsafe") return "none";
|
|
219
|
+
if (rootInspection.kind === "error") return "unknown";
|
|
220
|
+
|
|
221
|
+
const agentEntries = inspectDirectoryEntries(sessionRoot);
|
|
222
|
+
if (agentEntries.kind === "error") return "unknown";
|
|
223
|
+
for (const agentEntry of agentEntries.entries) {
|
|
224
|
+
if (!agentEntry.isDirectory()) continue;
|
|
225
|
+
try {
|
|
226
|
+
safeComponent(agentEntry.name);
|
|
227
|
+
} catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const agentDirectory = path.join(sessionRoot, agentEntry.name);
|
|
231
|
+
const agentInspection = inspectDirectory(agentDirectory);
|
|
232
|
+
if (agentInspection.kind === "error" || agentInspection.kind === "missing") return "unknown";
|
|
233
|
+
if (agentInspection.kind === "unsafe") continue;
|
|
234
|
+
|
|
235
|
+
const runEntries = inspectDirectoryEntries(agentDirectory);
|
|
236
|
+
if (runEntries.kind === "error") return "unknown";
|
|
237
|
+
for (const runEntry of runEntries.entries) {
|
|
238
|
+
if (!runEntry.isDirectory()) continue;
|
|
239
|
+
try {
|
|
240
|
+
safeComponent(runEntry.name);
|
|
241
|
+
} catch {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const runDirectory = path.join(agentDirectory, runEntry.name);
|
|
245
|
+
const runInspection = inspectDirectory(runDirectory);
|
|
246
|
+
if (runInspection.kind === "error" || runInspection.kind === "missing") return "unknown";
|
|
247
|
+
if (runInspection.kind === "unsafe") continue;
|
|
248
|
+
|
|
249
|
+
const latestActivity = latestPrivateRunActivityMs(runDirectory, runInspection.stat);
|
|
250
|
+
if (latestActivity === null) return "unknown";
|
|
251
|
+
if (now - latestActivity <= maxAgeMs) return "recent";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return "none";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function cleanupStalePrivateAgentSessions(
|
|
259
|
+
options: CleanupStalePrivateAgentSessionsOptions = {},
|
|
260
|
+
): number {
|
|
261
|
+
const teamsRoot = options.teamsRoot ?? path.dirname(paths.teamDir("__probe__"));
|
|
262
|
+
const now = options.now ?? Date.now();
|
|
263
|
+
const maxAgeMs = options.maxAgeMs ?? PRIVATE_AGENT_SESSION_RETENTION_MS;
|
|
264
|
+
if (!directoryStat(teamsRoot)) return 0;
|
|
265
|
+
let cleaned = 0;
|
|
266
|
+
|
|
267
|
+
for (const teamEntry of safeDirectoryEntries(teamsRoot)) {
|
|
268
|
+
if (!teamEntry.isDirectory()) continue;
|
|
269
|
+
let teamName: string;
|
|
270
|
+
try {
|
|
271
|
+
teamName = safeComponent(teamEntry.name);
|
|
272
|
+
} catch {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const teamDirectory = path.join(teamsRoot, teamName);
|
|
276
|
+
if (!directoryStat(teamDirectory)) continue;
|
|
277
|
+
const activeRuns = readActiveRuns(teamDirectory);
|
|
278
|
+
if (!activeRuns) continue;
|
|
279
|
+
const sessionRoot = path.join(teamDirectory, "agent-sessions");
|
|
280
|
+
if (!directoryStat(sessionRoot)) continue;
|
|
281
|
+
|
|
282
|
+
for (const agentEntry of safeDirectoryEntries(sessionRoot)) {
|
|
283
|
+
if (!agentEntry.isDirectory()) continue;
|
|
284
|
+
let agentName: string;
|
|
285
|
+
try {
|
|
286
|
+
agentName = safeComponent(agentEntry.name);
|
|
287
|
+
} catch {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const agentDirectory = path.join(sessionRoot, agentName);
|
|
291
|
+
if (!directoryStat(agentDirectory)) continue;
|
|
292
|
+
|
|
293
|
+
for (const runEntry of safeDirectoryEntries(agentDirectory)) {
|
|
294
|
+
if (!runEntry.isDirectory()) continue;
|
|
295
|
+
let runId: string;
|
|
296
|
+
try {
|
|
297
|
+
runId = safeComponent(runEntry.name);
|
|
298
|
+
} catch {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (activeRuns.has(`${agentName}\0${runId}`)) continue;
|
|
302
|
+
const runDirectory = path.join(agentDirectory, runId);
|
|
303
|
+
const stat = directoryStat(runDirectory);
|
|
304
|
+
if (!stat) continue;
|
|
305
|
+
const latestActivity = latestPrivateRunActivityMs(runDirectory, stat);
|
|
306
|
+
if (latestActivity === null || now - latestActivity <= maxAgeMs) continue;
|
|
307
|
+
try {
|
|
308
|
+
fs.rmSync(runDirectory, { recursive: true, force: false });
|
|
309
|
+
cleaned += 1;
|
|
310
|
+
} catch {
|
|
311
|
+
// One failed removal must not prevent later eligible runs from being cleaned.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
pruneEmptyDirectory(agentDirectory);
|
|
315
|
+
}
|
|
316
|
+
pruneEmptyDirectory(sessionRoot);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return cleaned;
|
|
320
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as paths from "../../src/utils/paths";
|
|
4
|
+
import type { PiExtendedTeamsSettings } from "../../src/utils/settings";
|
|
5
|
+
|
|
6
|
+
const DISABLED_VALUES = new Set(["", "0", "false", "no", "off"]);
|
|
7
|
+
|
|
8
|
+
export function isTeamsDebugEnabled(settings?: Pick<PiExtendedTeamsSettings, "debug">): boolean {
|
|
9
|
+
const envValue = process.env.PI_EXTENDED_TEAMS_DEBUG ?? process.env.PI_TEAMS_DEBUG;
|
|
10
|
+
if (envValue !== undefined) {
|
|
11
|
+
return !DISABLED_VALUES.has(envValue.trim().toLowerCase());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return settings?.debug.enabled ?? false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function teamDebugLogPath(teamName: string): string {
|
|
18
|
+
return path.join(paths.teamDir(teamName), "debug.log");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function writeTeamsDebugEvent(
|
|
22
|
+
teamName: string,
|
|
23
|
+
event: string,
|
|
24
|
+
details: Record<string, unknown>,
|
|
25
|
+
settings?: Pick<PiExtendedTeamsSettings, "debug">
|
|
26
|
+
): Promise<string | null> {
|
|
27
|
+
if (!isTeamsDebugEnabled(settings)) return null;
|
|
28
|
+
|
|
29
|
+
const logPath = teamDebugLogPath(teamName);
|
|
30
|
+
const entry = {
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
pid: process.pid,
|
|
33
|
+
event,
|
|
34
|
+
...details,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await fs.mkdir(path.dirname(logPath), { recursive: true });
|
|
39
|
+
await fs.appendFile(logPath, `${JSON.stringify(entry)}\n`, "utf-8");
|
|
40
|
+
return logPath;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isKnownQualifiedModel,
|
|
3
|
+
listPreferredQualifiedModels,
|
|
4
|
+
loadModelResolutionConfig,
|
|
5
|
+
loadPiModelSettings,
|
|
6
|
+
normalizeQualifiedModel,
|
|
7
|
+
sortAvailableModels,
|
|
8
|
+
type AvailableModel,
|
|
9
|
+
} from "../../src/utils/model-resolution";
|
|
10
|
+
import type { ThinkingCapableModel } from "../../src/utils/thinking-levels";
|
|
11
|
+
|
|
12
|
+
export interface AvailableRegisteredModel extends AvailableModel, ThinkingCapableModel {}
|
|
13
|
+
|
|
14
|
+
export async function getAvailableModels(ctx: any): Promise<AvailableRegisteredModel[]> {
|
|
15
|
+
try {
|
|
16
|
+
const available = await ctx.modelRegistry.getAvailable();
|
|
17
|
+
return available.map((model: any) => ({
|
|
18
|
+
provider: model.provider,
|
|
19
|
+
model: model.id,
|
|
20
|
+
reasoning: model.reasoning,
|
|
21
|
+
thinkingLevelMap: model.thinkingLevelMap,
|
|
22
|
+
}));
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getModelSelectionState(ctx: any, projectDir: string, preferredModels: string[] = []) {
|
|
29
|
+
const availableModels = await getAvailableModels(ctx);
|
|
30
|
+
const piSettings = loadPiModelSettings({ projectDir });
|
|
31
|
+
const config = loadModelResolutionConfig({ projectDir });
|
|
32
|
+
const preferredQualifiedModels = listPreferredQualifiedModels(availableModels, {
|
|
33
|
+
projectDir,
|
|
34
|
+
preferredModels,
|
|
35
|
+
});
|
|
36
|
+
const sortedModels = sortAvailableModels(availableModels, {
|
|
37
|
+
preferredModels: preferredQualifiedModels,
|
|
38
|
+
providerPriority: config.providerPriority,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
availableModels,
|
|
43
|
+
piSettings,
|
|
44
|
+
providerPriority: config.providerPriority,
|
|
45
|
+
preferredQualifiedModels,
|
|
46
|
+
sortedModels,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getCurrentQualifiedModel(ctx: any): string | undefined {
|
|
51
|
+
if (!ctx.model?.provider || !ctx.model?.id) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return `${ctx.model.provider}/${ctx.model.id}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function requireQualifiedKnownModel(
|
|
58
|
+
model: string | undefined,
|
|
59
|
+
availableModels: Array<{ provider: string; model: string }>,
|
|
60
|
+
fieldName: string
|
|
61
|
+
): string | undefined {
|
|
62
|
+
if (!model) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const normalized = normalizeQualifiedModel(model);
|
|
67
|
+
if (!normalized) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`${fieldName} must be a fully qualified provider/model string. ` +
|
|
70
|
+
`Use list_available_models to choose a valid model.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!isKnownQualifiedModel(normalized, availableModels)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`${fieldName} \"${normalized}\" is not available. ` +
|
|
77
|
+
`Use list_available_models to choose a valid model.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return normalized;
|
|
82
|
+
}
|