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.
Files changed (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { appendTeamReportEvent, listTeamReportEvents } from "./report-events";
6
+ import * as paths from "./paths";
7
+ import type { TeamReportEvent } from "./models";
8
+
9
+ let root: string;
10
+
11
+ function reportsPath(teamName = "team"): string {
12
+ return path.join(root, "teams", paths.sanitizeName(teamName), "reports.json");
13
+ }
14
+
15
+ function event(overrides: Partial<TeamReportEvent>): TeamReportEvent {
16
+ return {
17
+ id: overrides.id || `event-${overrides.createdAt}`,
18
+ teamName: overrides.teamName || "team",
19
+ agentName: overrides.agentName || "reader",
20
+ status: overrides.status || "completed",
21
+ report: overrides.report || "done",
22
+ createdAt: overrides.createdAt || Date.now(),
23
+ source: overrides.source || "read-agent",
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe("report events", () => {
29
+ beforeEach(() => {
30
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-reports-"));
31
+ vi.spyOn(paths, "teamDir").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName))));
32
+ vi.spyOn(paths, "reportFilesDir").mockReturnValue(path.join(root, "agent", "reports"));
33
+ });
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
38
+ });
39
+
40
+ it("filters before applying latest-limit pagination", async () => {
41
+ await appendTeamReportEvent("team", event({ id: "a-100", agentName: "agent-a", createdAt: 100, summary: "a100" }));
42
+ await appendTeamReportEvent("team", event({ id: "b-200", agentName: "agent-b", createdAt: 200, summary: "b200" }));
43
+ await appendTeamReportEvent("team", event({ id: "a-300", agentName: "agent-a", createdAt: 300, summary: "a300" }));
44
+ await appendTeamReportEvent("team", event({ id: "b-400", agentName: "agent-b", createdAt: 400, summary: "b400" }));
45
+ await appendTeamReportEvent("team", event({ id: "a-500", agentName: "agent-a", createdAt: 500, summary: "a500" }));
46
+
47
+ const agentReports = await listTeamReportEvents("team", { agentName: "agent-a", limit: 2 });
48
+ expect(agentReports.map(report => report.summary)).toEqual(["a300", "a500"]);
49
+
50
+ const sinceReports = await listTeamReportEvents("team", { since: 250, limit: 2 });
51
+ expect(sinceReports.map(report => report.summary)).toEqual(["b400", "a500"]);
52
+
53
+ await expect(listTeamReportEvents("team", { limit: 0 })).resolves.toEqual([]);
54
+ });
55
+
56
+ it("sorts externally-written files before slicing latest events", async () => {
57
+ const p = reportsPath();
58
+ fs.mkdirSync(path.dirname(p), { recursive: true });
59
+ fs.writeFileSync(p, JSON.stringify([
60
+ event({ id: "300", createdAt: 300, summary: "third" }),
61
+ event({ id: "100", createdAt: 100, summary: "first" }),
62
+ event({ id: "200", createdAt: 200, summary: "second" }),
63
+ ]));
64
+
65
+ const reports = await listTeamReportEvents("team", { limit: 2 });
66
+
67
+ expect(reports.map(report => report.summary)).toEqual(["second", "third"]);
68
+ });
69
+
70
+ it("reuses the in-process index until the report file changes", async () => {
71
+ await appendTeamReportEvent("team", event({ id: "one", createdAt: 100, summary: "one" }));
72
+
73
+ const readSpy = vi.spyOn(fs, "readFileSync");
74
+ const first = await listTeamReportEvents("team");
75
+ const second = await listTeamReportEvents("team");
76
+
77
+ expect(first.map(report => report.id)).toEqual(["one"]);
78
+ expect(second.map(report => report.id)).toEqual(["one"]);
79
+ expect(readSpy.mock.calls.filter(call => String(call[0]).endsWith("reports.json"))).toHaveLength(0);
80
+
81
+ const p = reportsPath();
82
+ fs.writeFileSync(p, JSON.stringify([
83
+ ...first,
84
+ event({ id: "two", createdAt: 200, summary: "two" }),
85
+ ]));
86
+
87
+ const afterExternalWrite = await listTeamReportEvents("team");
88
+ expect(afterExternalWrite.map(report => report.id)).toEqual(["one", "two"]);
89
+ expect(readSpy.mock.calls.filter(call => String(call[0]).endsWith("reports.json"))).toHaveLength(1);
90
+ });
91
+
92
+ it("isolates cached report objects from caller mutation", async () => {
93
+ const appended = await appendTeamReportEvent("team", event({
94
+ id: "immutable",
95
+ createdAt: 100,
96
+ summary: "original",
97
+ metadata: { nested: { value: "original" } },
98
+ }));
99
+
100
+ appended.summary = "mutated append result";
101
+ appended.metadata!.nested.value = "mutated append result";
102
+
103
+ const firstList = await listTeamReportEvents("team");
104
+ expect(firstList[0].summary).toBe("original");
105
+ expect(firstList[0].metadata).toEqual({ nested: { value: "original" } });
106
+
107
+ firstList[0].summary = "mutated list result";
108
+ firstList[0].metadata!.nested.value = "mutated list result";
109
+
110
+ const secondList = await listTeamReportEvents("team");
111
+ expect(secondList[0].summary).toBe("original");
112
+ expect(secondList[0].metadata).toEqual({ nested: { value: "original" } });
113
+ });
114
+
115
+ it("does not resort when inserting into an already sorted cache", async () => {
116
+ await appendTeamReportEvent("team", event({ id: "one", createdAt: 100, summary: "one" }));
117
+
118
+ const sortSpy = vi.spyOn(Array.prototype, "sort");
119
+ await appendTeamReportEvent("team", event({ id: "two", createdAt: 200, summary: "two" }));
120
+
121
+ expect(sortSpy).not.toHaveBeenCalled();
122
+ sortSpy.mockRestore();
123
+ await expect(listTeamReportEvents("team")).resolves.toMatchObject([{ id: "one" }, { id: "two" }]);
124
+ });
125
+
126
+ it("writes plain-text reports under the session and versions repeated agent names", async () => {
127
+ const reportFilesDir = path.join(root, "agent", "reports");
128
+ const getReportFilesDir = (paths as any).reportFilesDir;
129
+ if (typeof getReportFilesDir !== "function") throw new Error("reportFilesDir is not implemented");
130
+ vi.spyOn(paths as any, "reportFilesDir").mockReturnValue(reportFilesDir);
131
+
132
+ const first = await appendTeamReportEvent("team", event({ id: "one", createdAt: 100, report: "first report", summary: "First" }));
133
+ const second = await appendTeamReportEvent("team", event({ id: "two", createdAt: 200, report: "second report", summary: "Second" }));
134
+ const sessionDir = path.join(reportFilesDir, "team");
135
+
136
+ expect(path.dirname((first as any).reportPath)).toBe(sessionDir);
137
+ expect(path.basename((first as any).reportPath)).toBe("reader.md");
138
+ expect(path.basename((second as any).reportPath)).toBe("reader-v2.md");
139
+ expect(fs.readFileSync((first as any).reportPath, "utf-8")).toBe("first report");
140
+ expect(fs.readFileSync((second as any).reportPath, "utf-8")).toBe("second report");
141
+ await expect(listTeamReportEvents("team")).resolves.toMatchObject([
142
+ { id: "one", report: "first report", summary: "First", reportPath: (first as any).reportPath },
143
+ { id: "two", report: "second report", summary: "Second", reportPath: (second as any).reportPath },
144
+ ]);
145
+ });
146
+
147
+ it("returns the original event for duplicate ids", async () => {
148
+ const first = await appendTeamReportEvent("team", event({ id: "same", createdAt: 100, summary: "first" }));
149
+ const second = await appendTeamReportEvent("team", event({ id: "same", createdAt: 200, summary: "second" }));
150
+
151
+ expect(second).toEqual(first);
152
+ await expect(listTeamReportEvents("team")).resolves.toMatchObject([{ id: "same", summary: "first" }]);
153
+ });
154
+ });
@@ -0,0 +1,222 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { TeamReportEvent } from "./models";
5
+ import * as paths from "./paths";
6
+ import { withLock } from "./lock";
7
+
8
+ export type NewTeamReportEvent = Omit<TeamReportEvent, "id" | "teamName" | "createdAt"> & Partial<Pick<TeamReportEvent, "id" | "teamName" | "createdAt">>;
9
+
10
+ export interface ListTeamReportEventsOptions {
11
+ since?: number;
12
+ agentName?: string;
13
+ limit?: number;
14
+ }
15
+
16
+ interface ReportEventsCache {
17
+ statKey: string;
18
+ events: TeamReportEvent[];
19
+ byId: Map<string, TeamReportEvent>;
20
+ byAgent: Map<string, TeamReportEvent[]>;
21
+ }
22
+
23
+ const MAX_REPORT_EVENTS_CACHE_ENTRIES = 128;
24
+
25
+ const eventCache = new Map<string, ReportEventsCache>();
26
+
27
+ function rememberReportEventsCache(p: string, cache: ReportEventsCache): ReportEventsCache {
28
+ if (eventCache.has(p)) eventCache.delete(p);
29
+ eventCache.set(p, cache);
30
+
31
+ while (eventCache.size > MAX_REPORT_EVENTS_CACHE_ENTRIES) {
32
+ const oldestKey = eventCache.keys().next().value;
33
+ if (oldestKey === undefined) break;
34
+ eventCache.delete(oldestKey);
35
+ }
36
+
37
+ return cache;
38
+ }
39
+
40
+ function getFreshReportEventsCache(p: string, statKey: string): ReportEventsCache | undefined {
41
+ const cached = eventCache.get(p);
42
+ if (!cached) return undefined;
43
+
44
+ if (cached.statKey !== statKey) {
45
+ eventCache.delete(p);
46
+ return undefined;
47
+ }
48
+
49
+ return rememberReportEventsCache(p, cached);
50
+ }
51
+
52
+ function ensureReportEventsFile(teamName: string): string {
53
+ const p = path.join(paths.teamDir(teamName), "reports.json");
54
+ const dir = path.dirname(p);
55
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
56
+ return p;
57
+ }
58
+
59
+ function reportEventsStatKey(p: string): string {
60
+ if (!fs.existsSync(p)) return "missing";
61
+ const stat = fs.statSync(p, { bigint: true });
62
+ return `${stat.size}:${stat.mtimeNs}`;
63
+ }
64
+
65
+ function compareCreatedAt(a: TeamReportEvent, b: TeamReportEvent): number {
66
+ return a.createdAt - b.createdAt;
67
+ }
68
+
69
+ function cloneTeamReportEvent(event: TeamReportEvent): TeamReportEvent {
70
+ const cloned = { ...event };
71
+ if (event.metadata !== undefined) cloned.metadata = structuredClone(event.metadata);
72
+ return cloned;
73
+ }
74
+
75
+ function cloneTeamReportEvents(events: readonly TeamReportEvent[]): TeamReportEvent[] {
76
+ return events.map(cloneTeamReportEvent);
77
+ }
78
+
79
+ function buildCache(
80
+ p: string,
81
+ events: TeamReportEvent[],
82
+ statKey: string,
83
+ options: { sorted?: boolean } = {}
84
+ ): ReportEventsCache {
85
+ const sortedEvents = cloneTeamReportEvents(events);
86
+ if (!options.sorted) sortedEvents.sort(compareCreatedAt);
87
+
88
+ const byId = new Map<string, TeamReportEvent>();
89
+ const byAgent = new Map<string, TeamReportEvent[]>();
90
+
91
+ for (const event of sortedEvents) {
92
+ if (!byId.has(event.id)) byId.set(event.id, event);
93
+
94
+ const agentEvents = byAgent.get(event.agentName);
95
+ if (agentEvents) agentEvents.push(event);
96
+ else byAgent.set(event.agentName, [event]);
97
+ }
98
+
99
+ return rememberReportEventsCache(p, { statKey, events: sortedEvents, byId, byAgent });
100
+ }
101
+
102
+ function readEventsRaw(p: string): TeamReportEvent[] {
103
+ if (!fs.existsSync(p)) return [];
104
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
105
+ return Array.isArray(parsed) ? parsed : [];
106
+ }
107
+
108
+ function readEventsCache(p: string): ReportEventsCache {
109
+ const statKey = reportEventsStatKey(p);
110
+ const cached = getFreshReportEventsCache(p, statKey);
111
+ if (cached) return cached;
112
+
113
+ const events = readEventsRaw(p);
114
+ return buildCache(p, events, reportEventsStatKey(p));
115
+ }
116
+
117
+ function writeEventsRaw(p: string, events: TeamReportEvent[], options: { sorted?: boolean } = {}): void {
118
+ fs.writeFileSync(p, JSON.stringify(events, null, 2));
119
+ buildCache(p, events, reportEventsStatKey(p), options);
120
+ }
121
+
122
+ function defaultEventId(teamName: string, event: NewTeamReportEvent): string {
123
+ if (event.operationId) {
124
+ return ["report", teamName, event.agentName, event.operationId, event.workflowRunId || ""].join(":");
125
+ }
126
+ return crypto.randomUUID();
127
+ }
128
+
129
+ function writeStandaloneReport(teamName: string, agentName: string, report: string): string {
130
+ const dir = path.join(paths.reportFilesDir(), paths.sanitizeName(teamName));
131
+ const baseName = paths.sanitizeName(agentName);
132
+ fs.mkdirSync(dir, { recursive: true });
133
+
134
+ for (let version = 1; ; version += 1) {
135
+ const suffix = version === 1 ? "" : `-v${version}`;
136
+ const reportPath = path.join(dir, `${baseName}${suffix}.md`);
137
+ try {
138
+ fs.writeFileSync(reportPath, report, { encoding: "utf-8", flag: "wx" });
139
+ return reportPath;
140
+ } catch (error) {
141
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
142
+ }
143
+ }
144
+ }
145
+
146
+ function lowerBoundCreatedAt(events: TeamReportEvent[], since: number): number {
147
+ let low = 0;
148
+ let high = events.length;
149
+
150
+ while (low < high) {
151
+ const mid = Math.floor((low + high) / 2);
152
+ if (events[mid].createdAt < since) low = mid + 1;
153
+ else high = mid;
154
+ }
155
+
156
+ return low;
157
+ }
158
+
159
+ function upperBoundCreatedAt(events: TeamReportEvent[], createdAt: number): number {
160
+ let low = 0;
161
+ let high = events.length;
162
+
163
+ while (low < high) {
164
+ const mid = Math.floor((low + high) / 2);
165
+ if (events[mid].createdAt <= createdAt) low = mid + 1;
166
+ else high = mid;
167
+ }
168
+
169
+ return low;
170
+ }
171
+
172
+ function applyLimit(events: TeamReportEvent[], limit?: number): TeamReportEvent[] {
173
+ if (limit !== undefined && limit >= 0) return events.slice(Math.max(0, events.length - limit));
174
+ return events.slice();
175
+ }
176
+
177
+ function selectEvents(cache: ReportEventsCache, options: ListTeamReportEventsOptions): TeamReportEvent[] {
178
+ if (options.since !== undefined && Number.isNaN(options.since)) return [];
179
+
180
+ const source = options.agentName ? cache.byAgent.get(options.agentName) || [] : cache.events;
181
+ const start = options.since === undefined ? 0 : lowerBoundCreatedAt(source, options.since);
182
+ const filtered = start === 0 ? source : source.slice(start);
183
+ return cloneTeamReportEvents(applyLimit(filtered, options.limit));
184
+ }
185
+
186
+ function insertEvent(events: TeamReportEvent[], event: TeamReportEvent): TeamReportEvent[] {
187
+ const next = events.slice();
188
+ next.splice(upperBoundCreatedAt(next, event.createdAt), 0, event);
189
+ return next;
190
+ }
191
+
192
+ export async function appendTeamReportEvent(teamName: string, event: NewTeamReportEvent): Promise<TeamReportEvent> {
193
+ const p = ensureReportEventsFile(teamName);
194
+
195
+ return await withLock(p, async () => {
196
+ const cache = readEventsCache(p);
197
+ const normalized: TeamReportEvent = cloneTeamReportEvent({
198
+ ...event,
199
+ id: event.id || defaultEventId(teamName, event),
200
+ teamName,
201
+ createdAt: event.createdAt || Date.now(),
202
+ });
203
+
204
+ const existing = cache.byId.get(normalized.id);
205
+ if (existing) return cloneTeamReportEvent(existing);
206
+
207
+ normalized.reportPath = writeStandaloneReport(teamName, normalized.agentName, normalized.report);
208
+ writeEventsRaw(p, insertEvent(cache.events, normalized), { sorted: true });
209
+ return cloneTeamReportEvent(normalized);
210
+ });
211
+ }
212
+
213
+ export async function listTeamReportEvents(
214
+ teamName: string,
215
+ options: ListTeamReportEventsOptions = {}
216
+ ): Promise<TeamReportEvent[]> {
217
+ const p = ensureReportEventsFile(teamName);
218
+
219
+ return await withLock(p, async () => {
220
+ return selectEvents(readEventsCache(p), options);
221
+ });
222
+ }
@@ -0,0 +1,314 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ writeRuntimeStatus,
7
+ readRuntimeStatus,
8
+ deleteRuntimeStatus,
9
+ cleanupStaleRuntimeFiles,
10
+ createRuntimeError,
11
+ HEARTBEAT_STALE_MS,
12
+ STARTUP_STALL_MS,
13
+ RUNTIME_STALE_MS,
14
+ } from "./runtime";
15
+ import * as paths from "./paths";
16
+
17
+ describe("runtime status", () => {
18
+ const teamName = `runtime-test-${Date.now()}`;
19
+ const agentName = "worker-1";
20
+ const runId = "worker-run";
21
+ let root: string;
22
+
23
+ function writeRoster(members: Array<{ name: string; lifecycleRunId: string; isActive?: boolean }>): void {
24
+ fs.writeFileSync(paths.configPath(teamName), JSON.stringify({ name: teamName, members }, null, 2));
25
+ }
26
+
27
+ beforeEach(() => {
28
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-runtime-"));
29
+ const teamPath = (teamName: string) => path.join(root, "teams", paths.sanitizeName(teamName));
30
+ vi.spyOn(paths, "teamDir").mockImplementation(teamPath);
31
+ vi.spyOn(paths, "configPath").mockImplementation(teamName => path.join(teamPath(teamName), "config.json"));
32
+ vi.spyOn(paths, "runtimeStatusPath").mockImplementation((teamName, agentName) => {
33
+ return path.join(teamPath(teamName), "runtime", `${paths.sanitizeName(agentName)}.json`);
34
+ });
35
+ vi.spyOn(paths, "lifecycleTombstonePath").mockImplementation((teamName, agentName) => {
36
+ return path.join(teamPath(teamName), "lifecycle", "quarantine", `${paths.sanitizeName(agentName)}.json`);
37
+ });
38
+ fs.mkdirSync(paths.teamDir(teamName), { recursive: true });
39
+ writeRoster([{ name: agentName, lifecycleRunId: runId, isActive: true }]);
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.restoreAllMocks();
44
+ if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
45
+ });
46
+
47
+ it("writes and reads status", async () => {
48
+ await writeRuntimeStatus(teamName, agentName, runId, {
49
+ pid: 123,
50
+ startedAt: 1000,
51
+ ready: false,
52
+ });
53
+
54
+ const runtime = await readRuntimeStatus(teamName, agentName);
55
+ expect(runtime).not.toBeNull();
56
+ expect(runtime?.teamName).toBe(teamName);
57
+ expect(runtime?.agentName).toBe(agentName);
58
+ expect(runtime?.pid).toBe(123);
59
+ expect(runtime?.ready).toBe(false);
60
+ });
61
+
62
+ it("merges updates instead of overwriting status", async () => {
63
+ await writeRuntimeStatus(teamName, agentName, runId, {
64
+ pid: 123,
65
+ startedAt: 1000,
66
+ ready: false,
67
+ latestProgress: "Inspecting runtime state",
68
+ progressUpdatedAt: 1500,
69
+ });
70
+
71
+ await writeRuntimeStatus(teamName, agentName, runId, {
72
+ lastHeartbeatAt: 2000,
73
+ ready: true,
74
+ currentAction: "working",
75
+ activeToolName: "read",
76
+ });
77
+
78
+ const runtime = await readRuntimeStatus(teamName, agentName);
79
+ expect(runtime?.pid).toBe(123);
80
+ expect(runtime?.startedAt).toBe(1000);
81
+ expect(runtime?.lastHeartbeatAt).toBe(2000);
82
+ expect(runtime?.ready).toBe(true);
83
+ expect(runtime?.currentAction).toBe("working");
84
+ expect(runtime?.activeToolName).toBe("read");
85
+ expect(runtime?.latestProgress).toBe("Inspecting runtime state");
86
+ expect(runtime?.progressUpdatedAt).toBe(1500);
87
+ });
88
+
89
+ it("returns null when status does not exist", async () => {
90
+ const missing = await readRuntimeStatus(teamName, "missing-agent");
91
+ expect(missing).toBeNull();
92
+ });
93
+
94
+ it("stores status in team runtime directory", async () => {
95
+ await writeRuntimeStatus(teamName, agentName, runId, { ready: true });
96
+ const p = paths.runtimeStatusPath(teamName, agentName);
97
+ expect(path.basename(path.dirname(p))).toBe("runtime");
98
+ expect(fs.existsSync(p)).toBe(true);
99
+ });
100
+
101
+ describe("deleteRuntimeStatus", () => {
102
+ it("deletes existing runtime status", async () => {
103
+ await writeRuntimeStatus(teamName, agentName, runId, { ready: true });
104
+ const deleted = await deleteRuntimeStatus(teamName, agentName);
105
+ expect(deleted).toBe(true);
106
+
107
+ const runtime = await readRuntimeStatus(teamName, agentName);
108
+ expect(runtime).toBeNull();
109
+ });
110
+
111
+ it("returns false when status does not exist", async () => {
112
+ const deleted = await deleteRuntimeStatus(teamName, "nonexistent");
113
+ expect(deleted).toBe(false);
114
+ });
115
+
116
+ it("refuses wrong-run overwrite and deletion", async () => {
117
+ await writeRuntimeStatus(teamName, agentName, runId, { ready: true });
118
+
119
+ await expect(writeRuntimeStatus(teamName, agentName, "replacement-run", {
120
+ ready: false,
121
+ })).rejects.toThrow("not replacement-run");
122
+ await expect(deleteRuntimeStatus(teamName, agentName, "replacement-run")).resolves.toBe(false);
123
+ await expect(readRuntimeStatus(teamName, agentName)).resolves.toMatchObject({
124
+ lifecycleRunId: runId,
125
+ ready: true,
126
+ });
127
+ await expect(deleteRuntimeStatus(teamName, agentName, runId)).resolves.toBe(true);
128
+ });
129
+ });
130
+
131
+ describe("cleanupStaleRuntimeFiles", () => {
132
+ it("removes stale runtime files with old heartbeats", async () => {
133
+ const staleTime = Date.now() - RUNTIME_STALE_MS - 1000;
134
+ writeRoster([
135
+ { name: agentName, lifecycleRunId: runId, isActive: true },
136
+ { name: "stale-agent", lifecycleRunId: "stale-run", isActive: true },
137
+ ]);
138
+ await writeRuntimeStatus(teamName, "stale-agent", "stale-run", {
139
+ startedAt: staleTime,
140
+ lastHeartbeatAt: staleTime,
141
+ ready: true,
142
+ });
143
+
144
+ const cleaned = await cleanupStaleRuntimeFiles(teamName);
145
+ expect(cleaned).toBe(1);
146
+
147
+ const runtime = await readRuntimeStatus(teamName, "stale-agent");
148
+ expect(runtime).toBeNull();
149
+ });
150
+
151
+ it("preserves runtime files with recent heartbeats", async () => {
152
+ await writeRuntimeStatus(teamName, agentName, runId, {
153
+ startedAt: Date.now() - RUNTIME_STALE_MS - 1000,
154
+ lastHeartbeatAt: Date.now(),
155
+ ready: true,
156
+ });
157
+
158
+ const cleaned = await cleanupStaleRuntimeFiles(teamName);
159
+ expect(cleaned).toBe(0);
160
+
161
+ const runtime = await readRuntimeStatus(teamName, agentName);
162
+ expect(runtime).not.toBeNull();
163
+ });
164
+
165
+ it("removes corrupted files while holding the lifecycle and runtime locks", async () => {
166
+ const runtimeDir = path.join(paths.teamDir(teamName), "runtime");
167
+ if (!fs.existsSync(runtimeDir)) fs.mkdirSync(runtimeDir, { recursive: true });
168
+ fs.writeFileSync(path.join(runtimeDir, "corrupted.json"), "not valid json");
169
+
170
+ const cleaned = await cleanupStaleRuntimeFiles(teamName);
171
+ expect(cleaned).toBe(1);
172
+ });
173
+
174
+ it("re-reads after taking the lifecycle lock and preserves a concurrent fresh replacement", async () => {
175
+ const staleTime = Date.now() - RUNTIME_STALE_MS - 1000;
176
+ await writeRuntimeStatus(teamName, agentName, runId, {
177
+ startedAt: staleTime,
178
+ lastHeartbeatAt: staleTime,
179
+ ready: true,
180
+ });
181
+ let releaseLifecycleBarrier!: () => void;
182
+ const lifecycleBarrier = new Promise<void>(resolve => { releaseLifecycleBarrier = resolve; });
183
+ let notifyLifecycleLocked!: () => void;
184
+ const lifecycleLocked = new Promise<void>(resolve => { notifyLifecycleLocked = resolve; });
185
+
186
+ const cleanup = cleanupStaleRuntimeFiles(teamName, Date.now(), {
187
+ afterLifecycleLock: async name => {
188
+ if (name !== agentName) return;
189
+ notifyLifecycleLocked();
190
+ await lifecycleBarrier;
191
+ },
192
+ });
193
+ await lifecycleLocked;
194
+ fs.writeFileSync(paths.runtimeStatusPath(teamName, agentName), JSON.stringify({
195
+ teamName,
196
+ agentName,
197
+ lifecycleRunId: runId,
198
+ startedAt: staleTime,
199
+ lastHeartbeatAt: Date.now(),
200
+ ready: true,
201
+ }, null, 2));
202
+ releaseLifecycleBarrier();
203
+
204
+ await expect(cleanup).resolves.toBe(0);
205
+ await expect(readRuntimeStatus(teamName, agentName)).resolves.toMatchObject({
206
+ lifecycleRunId: runId,
207
+ ready: true,
208
+ });
209
+ });
210
+
211
+ it("serializes a fresh heartbeat behind stale deletion without losing the new runtime", async () => {
212
+ const staleTime = Date.now() - RUNTIME_STALE_MS - 1000;
213
+ await writeRuntimeStatus(teamName, agentName, runId, {
214
+ startedAt: staleTime,
215
+ lastHeartbeatAt: staleTime,
216
+ ready: true,
217
+ });
218
+ let releaseDeleteBarrier!: () => void;
219
+ const deleteBarrier = new Promise<void>(resolve => { releaseDeleteBarrier = resolve; });
220
+ let notifyBeforeDelete!: () => void;
221
+ const beforeDelete = new Promise<void>(resolve => { notifyBeforeDelete = resolve; });
222
+
223
+ const cleanup = cleanupStaleRuntimeFiles(teamName, Date.now(), {
224
+ beforeDelete: async name => {
225
+ if (name !== agentName) return;
226
+ notifyBeforeDelete();
227
+ await deleteBarrier;
228
+ },
229
+ });
230
+ await beforeDelete;
231
+ let heartbeatSettled = false;
232
+ const heartbeat = writeRuntimeStatus(teamName, agentName, runId, {
233
+ lastHeartbeatAt: Date.now(),
234
+ ready: true,
235
+ }).then(result => {
236
+ heartbeatSettled = true;
237
+ return result;
238
+ });
239
+ await Promise.resolve();
240
+ expect(heartbeatSettled).toBe(false);
241
+ releaseDeleteBarrier();
242
+
243
+ await expect(cleanup).resolves.toBe(1);
244
+ await expect(heartbeat).resolves.toMatchObject({ lifecycleRunId: runId, ready: true });
245
+ await expect(readRuntimeStatus(teamName, agentName)).resolves.toMatchObject({
246
+ lifecycleRunId: runId,
247
+ ready: true,
248
+ });
249
+ });
250
+
251
+ it("rejects writes while an occupied or corrupt tombstone fences the active roster member", async () => {
252
+ const tombstonePath = paths.lifecycleTombstonePath(teamName, agentName);
253
+ fs.mkdirSync(path.dirname(tombstonePath), { recursive: true });
254
+ fs.writeFileSync(tombstonePath, JSON.stringify({
255
+ version: 1,
256
+ team: teamName,
257
+ agent: agentName,
258
+ runId,
259
+ role: "write",
260
+ reason: "quit",
261
+ phase: "closing",
262
+ ownerPid: process.pid,
263
+ extensionInstanceId: "test-instance",
264
+ timestamps: { createdAt: Date.now(), updatedAt: Date.now() },
265
+ }));
266
+ await expect(writeRuntimeStatus(teamName, agentName, runId, { ready: false }))
267
+ .rejects.toThrow("lifecycle run worker-run is closing");
268
+
269
+ fs.writeFileSync(tombstonePath, "not json");
270
+ await expect(writeRuntimeStatus(teamName, agentName, runId, { ready: false }))
271
+ .rejects.toThrow("lifecycle tombstone is corrupt");
272
+ });
273
+
274
+ it("returns 0 when no runtime directory exists", async () => {
275
+ const cleaned = await cleanupStaleRuntimeFiles("nonexistent-team");
276
+ expect(cleaned).toBe(0);
277
+ });
278
+ });
279
+
280
+ describe("createRuntimeError", () => {
281
+ it("creates structured error from Error object", () => {
282
+ const error = new Error("Test error");
283
+ const runtimeError = createRuntimeError(error);
284
+ expect(runtimeError.message).toBe("Test error");
285
+ expect(runtimeError.timestamp).toBeGreaterThan(0);
286
+ });
287
+
288
+ it("creates structured error from string", () => {
289
+ const runtimeError = createRuntimeError("String error");
290
+ expect(runtimeError.message).toBe("String error");
291
+ expect(runtimeError.timestamp).toBeGreaterThan(0);
292
+ });
293
+
294
+ it("creates structured error from unknown type", () => {
295
+ const runtimeError = createRuntimeError({ weird: "object" });
296
+ expect(runtimeError.message).toBe("[object Object]");
297
+ expect(runtimeError.timestamp).toBeGreaterThan(0);
298
+ });
299
+ });
300
+
301
+ describe("constants", () => {
302
+ it("exports HEARTBEAT_STALE_MS with correct value", () => {
303
+ expect(HEARTBEAT_STALE_MS).toBe(90000);
304
+ });
305
+
306
+ it("exports STARTUP_STALL_MS with correct value", () => {
307
+ expect(STARTUP_STALL_MS).toBe(60000);
308
+ });
309
+
310
+ it("exports RUNTIME_STALE_MS with correct value", () => {
311
+ expect(RUNTIME_STALE_MS).toBe(300000);
312
+ });
313
+ });
314
+ });