@tmustier/pi-agent-teams 0.4.0-beta.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +72 -9
- package/WORKFLOW.md +110 -0
- package/docs/claude-parity.md +18 -13
- package/docs/hook-contract.md +183 -0
- package/docs/smoke-test-plan.md +26 -7
- package/extensions/teams/activity-tracker.ts +296 -8
- package/extensions/teams/cleanup.ts +216 -3
- package/extensions/teams/hooks.ts +57 -5
- package/extensions/teams/leader-attach-commands.ts +8 -4
- package/extensions/teams/leader-inbox.ts +162 -4
- package/extensions/teams/leader-info-commands.ts +105 -3
- package/extensions/teams/leader-lifecycle-commands.ts +205 -3
- package/extensions/teams/leader-messaging-commands.ts +19 -7
- package/extensions/teams/leader-spawn-command.ts +5 -1
- package/extensions/teams/leader-team-command.ts +51 -2
- package/extensions/teams/leader-teams-tool.ts +387 -11
- package/extensions/teams/leader.ts +126 -52
- package/extensions/teams/mailbox.ts +6 -1
- package/extensions/teams/model-policy.ts +117 -0
- package/extensions/teams/spawn-types.ts +4 -0
- package/extensions/teams/teammate-rpc.ts +14 -0
- package/extensions/teams/teams-panel.ts +117 -19
- package/extensions/teams/teams-ui-shared.ts +205 -2
- package/extensions/teams/teams-widget.ts +67 -14
- package/extensions/teams/worker.ts +18 -6
- package/extensions/teams/worktree.ts +143 -0
- package/package.json +4 -2
- package/scripts/integration-cleanup-test.mts +419 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +10 -0
- package/scripts/smoke-test.mts +701 -3
- package/skills/agent-teams/SKILL.md +28 -7
|
@@ -1,14 +1,148 @@
|
|
|
1
1
|
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
|
2
2
|
|
|
3
|
+
// ── Helpers ──
|
|
4
|
+
|
|
5
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
6
|
+
return typeof v === "object" && v !== null;
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
// ── Transcript types ──
|
|
4
10
|
|
|
5
11
|
export type TranscriptEntry =
|
|
6
12
|
| { kind: "text"; text: string; timestamp: number }
|
|
7
|
-
| { kind: "tool_start"; toolName: string; timestamp: number }
|
|
8
|
-
| { kind: "tool_end"; toolName: string; durationMs: number; timestamp: number }
|
|
13
|
+
| { kind: "tool_start"; toolName: string; content: string | null; summary: string | null; timestamp: number }
|
|
14
|
+
| { kind: "tool_end"; toolName: string; content: string | null; summary: string | null; isError: boolean; durationMs: number; timestamp: number }
|
|
9
15
|
| { kind: "turn_end"; turnNumber: number; tokens: number; timestamp: number };
|
|
10
16
|
|
|
11
17
|
const MAX_TRANSCRIPT = 200;
|
|
18
|
+
const MAX_SUMMARY_LENGTH = 120;
|
|
19
|
+
|
|
20
|
+
// ── Tool content summarization ──
|
|
21
|
+
|
|
22
|
+
function truncateSummary(text: string): string {
|
|
23
|
+
if (text.length <= MAX_SUMMARY_LENGTH) return text;
|
|
24
|
+
return `${text.slice(0, MAX_SUMMARY_LENGTH - 1)}…`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function summarizeToolArgs(toolName: string, args: unknown): string {
|
|
28
|
+
if (!isRecord(args)) return "";
|
|
29
|
+
const key = toolName.toLowerCase();
|
|
30
|
+
|
|
31
|
+
if (key === "read" || key === "write") {
|
|
32
|
+
const path = typeof args.path === "string" ? args.path : null;
|
|
33
|
+
if (!path) return "";
|
|
34
|
+
return truncateSummary(path);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (key === "edit") {
|
|
38
|
+
const path = typeof args.path === "string" ? args.path : null;
|
|
39
|
+
if (!path) return "";
|
|
40
|
+
return truncateSummary(path);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (key === "bash") {
|
|
44
|
+
const cmd = typeof args.command === "string" ? args.command : null;
|
|
45
|
+
if (!cmd) return "";
|
|
46
|
+
return truncateSummary(cmd.replace(/\s+/g, " ").trim());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (key === "grep" || key === "glob") {
|
|
50
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : null;
|
|
51
|
+
const path = typeof args.path === "string" ? args.path : null;
|
|
52
|
+
const parts: string[] = [];
|
|
53
|
+
if (pattern) parts.push(pattern);
|
|
54
|
+
if (path) parts.push(`in ${path}`);
|
|
55
|
+
return truncateSummary(parts.join(" "));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (key === "webfetch" || key === "websearch") {
|
|
59
|
+
const url = typeof args.url === "string" ? args.url : null;
|
|
60
|
+
const query = typeof args.query === "string" ? args.query : null;
|
|
61
|
+
return truncateSummary(url ?? query ?? "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (key === "team_message") {
|
|
65
|
+
const recipient = typeof args.recipient === "string" ? args.recipient : null;
|
|
66
|
+
const message = typeof args.message === "string" ? args.message : null;
|
|
67
|
+
if (recipient && message) return truncateSummary(`→ ${recipient}: ${message.replace(/\s+/g, " ").trim()}`);
|
|
68
|
+
if (message) return truncateSummary(message.replace(/\s+/g, " ").trim());
|
|
69
|
+
return recipient ? truncateSummary(`→ ${recipient}`) : "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (key === "task" || key === "teams") {
|
|
73
|
+
const action = typeof args.action === "string" ? args.action : null;
|
|
74
|
+
return action ? truncateSummary(action) : "";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fallback: try to find a meaningful first-string arg
|
|
78
|
+
for (const v of Object.values(args)) {
|
|
79
|
+
if (typeof v === "string" && v.length > 0) return truncateSummary(v);
|
|
80
|
+
}
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract the first text content from a ToolResultMessage-shaped result.
|
|
86
|
+
* The agent-loop emits `{ content: [{type: "text", text: "..."}], ... }`.
|
|
87
|
+
*/
|
|
88
|
+
function extractContentText(result: Record<string, unknown>): string | null {
|
|
89
|
+
const content = result.content;
|
|
90
|
+
if (!Array.isArray(content)) return null;
|
|
91
|
+
for (const item of content) {
|
|
92
|
+
if (isRecord(item) && item.type === "text" && typeof item.text === "string") {
|
|
93
|
+
return item.text;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function summarizeToolResult(toolName: string, result: unknown, isError: boolean): string {
|
|
100
|
+
if (isError) {
|
|
101
|
+
if (typeof result === "string") return truncateSummary(result.replace(/\s+/g, " ").trim());
|
|
102
|
+
if (isRecord(result)) {
|
|
103
|
+
// Try content array first (ToolResultMessage shape)
|
|
104
|
+
const contentText = extractContentText(result);
|
|
105
|
+
if (contentText !== null) {
|
|
106
|
+
const trimmed = contentText.replace(/\s+/g, " ").trim();
|
|
107
|
+
return trimmed.length > 0 ? truncateSummary(trimmed) : "error";
|
|
108
|
+
}
|
|
109
|
+
const msg = typeof result.message === "string"
|
|
110
|
+
? result.message
|
|
111
|
+
: typeof result.error === "string"
|
|
112
|
+
? result.error
|
|
113
|
+
: null;
|
|
114
|
+
if (msg) return truncateSummary(msg.replace(/\s+/g, " ").trim());
|
|
115
|
+
}
|
|
116
|
+
return "error";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (typeof result === "string") {
|
|
120
|
+
const compact = result.replace(/\s+/g, " ").trim();
|
|
121
|
+
if (compact.length === 0) return "(empty)";
|
|
122
|
+
return truncateSummary(compact);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isRecord(result)) {
|
|
126
|
+
// Try content array first (ToolResultMessage shape from agent-loop)
|
|
127
|
+
const contentText = extractContentText(result);
|
|
128
|
+
if (contentText !== null) {
|
|
129
|
+
const compact = contentText.replace(/\s+/g, " ").trim();
|
|
130
|
+
if (compact.length === 0) return "(empty)";
|
|
131
|
+
return truncateSummary(compact);
|
|
132
|
+
}
|
|
133
|
+
// Fallback: check for common structured result shapes
|
|
134
|
+
const status = typeof result.status === "string" ? result.status : null;
|
|
135
|
+
if (status) return truncateSummary(status);
|
|
136
|
+
const output = typeof result.output === "string" ? result.output : null;
|
|
137
|
+
if (output) return truncateSummary(output.replace(/\s+/g, " ").trim());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(result)) {
|
|
141
|
+
return `${String(result.length)} items`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
12
146
|
|
|
13
147
|
export class TranscriptLog {
|
|
14
148
|
private entries: TranscriptEntry[] = [];
|
|
@@ -33,6 +167,160 @@ export class TranscriptLog {
|
|
|
33
167
|
}
|
|
34
168
|
}
|
|
35
169
|
|
|
170
|
+
// ── Tool content extraction ──
|
|
171
|
+
// Extracts a compact, human-readable summary from tool args/results.
|
|
172
|
+
// Budget: ≤120 chars to fit one terminal line minus timestamp prefix.
|
|
173
|
+
|
|
174
|
+
const MAX_CONTENT_LEN = 120;
|
|
175
|
+
|
|
176
|
+
function truncateContent(s: string, maxLen: number = MAX_CONTENT_LEN): string {
|
|
177
|
+
const oneLine = s.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
|
178
|
+
if (oneLine.length <= maxLen) return oneLine;
|
|
179
|
+
return oneLine.slice(0, maxLen - 1) + "\u2026";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract a display-friendly summary from tool start args.
|
|
184
|
+
*
|
|
185
|
+
* Known tool shapes:
|
|
186
|
+
* read → { path }
|
|
187
|
+
* edit → { path }
|
|
188
|
+
* write → { path }
|
|
189
|
+
* bash → { command }
|
|
190
|
+
* grep → { pattern, path? }
|
|
191
|
+
* glob → { pattern }
|
|
192
|
+
*
|
|
193
|
+
* Falls back to the first short string value for unknown tools.
|
|
194
|
+
*/
|
|
195
|
+
function extractStartContent(toolName: string, args: unknown): string | null {
|
|
196
|
+
if (!isRecord(args)) return null;
|
|
197
|
+
const key = toolName.toLowerCase();
|
|
198
|
+
|
|
199
|
+
if (key === "read" || key === "edit" || key === "write") {
|
|
200
|
+
const p = args.path;
|
|
201
|
+
return typeof p === "string" ? truncateContent(p) : null;
|
|
202
|
+
}
|
|
203
|
+
if (key === "bash") {
|
|
204
|
+
const cmd = args.command;
|
|
205
|
+
return typeof cmd === "string" ? truncateContent(cmd) : null;
|
|
206
|
+
}
|
|
207
|
+
if (key === "grep") {
|
|
208
|
+
const pattern = args.pattern;
|
|
209
|
+
const path = args.path;
|
|
210
|
+
if (typeof pattern !== "string") return null;
|
|
211
|
+
const suffix = typeof path === "string" ? ` in ${path}` : "";
|
|
212
|
+
return truncateContent(`/${pattern}/${suffix}`);
|
|
213
|
+
}
|
|
214
|
+
if (key === "glob" || key === "find") {
|
|
215
|
+
const pattern = args.pattern;
|
|
216
|
+
return typeof pattern === "string" ? truncateContent(pattern) : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Unknown tool: show the first short string arg value (if any).
|
|
220
|
+
for (const v of Object.values(args)) {
|
|
221
|
+
if (typeof v === "string" && v.length > 0 && v.length <= MAX_CONTENT_LEN) {
|
|
222
|
+
return truncateContent(v);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Extract a cleaner summary for display (may differ from content for some tools).
|
|
230
|
+
*
|
|
231
|
+
* grep: strips regex slashes → "TODO in /src" instead of "/TODO/ in /src"
|
|
232
|
+
* team_message: "→ bob: please rebase onto main" instead of just "bob"
|
|
233
|
+
* bash: normalizes whitespace
|
|
234
|
+
* Others: same as extractStartContent
|
|
235
|
+
*/
|
|
236
|
+
function extractStartSummary(toolName: string, args: unknown): string | null {
|
|
237
|
+
if (!isRecord(args)) return null;
|
|
238
|
+
const key = toolName.toLowerCase();
|
|
239
|
+
|
|
240
|
+
if (key === "grep") {
|
|
241
|
+
const pattern = args.pattern;
|
|
242
|
+
const path = args.path;
|
|
243
|
+
if (typeof pattern !== "string") return null;
|
|
244
|
+
const suffix = typeof path === "string" ? ` in ${path}` : "";
|
|
245
|
+
return truncateContent(`${pattern}${suffix}`);
|
|
246
|
+
}
|
|
247
|
+
if (key === "team_message" || key === "message_dm" || key === "message_broadcast") {
|
|
248
|
+
const recipient = args.recipient ?? args.to ?? args.name;
|
|
249
|
+
const message = args.message;
|
|
250
|
+
if (typeof recipient === "string" && typeof message === "string") {
|
|
251
|
+
return truncateContent(`→ ${recipient}: ${message}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (key === "bash") {
|
|
255
|
+
const cmd = args.command;
|
|
256
|
+
if (typeof cmd === "string") {
|
|
257
|
+
return truncateContent(cmd.replace(/\s+/g, " ").trim());
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// Fall through to default extraction
|
|
261
|
+
return extractStartContent(toolName, args);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Extract a display-friendly summary from tool end result.
|
|
266
|
+
*
|
|
267
|
+
* For errors: first line of error text.
|
|
268
|
+
* For success: null (the tool_end line already shows tool name + duration;
|
|
269
|
+
* adding full output would be noisy).
|
|
270
|
+
*/
|
|
271
|
+
function extractEndContent(isError: boolean, result: unknown): string | null {
|
|
272
|
+
if (!isError) return null;
|
|
273
|
+
if (typeof result === "string") return truncateContent(result);
|
|
274
|
+
if (isRecord(result)) {
|
|
275
|
+
const msg = result.error ?? result.message ?? result.stderr ?? result.output;
|
|
276
|
+
if (typeof msg === "string") return truncateContent(msg);
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Extract a display-friendly summary from tool end result (for both success and error).
|
|
283
|
+
*
|
|
284
|
+
* Unlike extractEndContent (which only extracts for errors), this extracts
|
|
285
|
+
* a compact summary from any result for display in the transcript.
|
|
286
|
+
*/
|
|
287
|
+
function extractEndSummary(isError: boolean, result: unknown): string | null {
|
|
288
|
+
// For errors, try existing logic first, then fall through to content array extraction
|
|
289
|
+
if (isError) {
|
|
290
|
+
const fromContent = extractEndContent(isError, result);
|
|
291
|
+
if (fromContent !== null) return fromContent;
|
|
292
|
+
// Fall through to handle { content: [{ type: "text", text: "..." }] } shape
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// For success: try to extract text from common result shapes
|
|
296
|
+
if (typeof result === "string") {
|
|
297
|
+
return result.length === 0 ? "(empty)" : truncateContent(result.replace(/\n/g, " "));
|
|
298
|
+
}
|
|
299
|
+
if (isRecord(result)) {
|
|
300
|
+
// Handle { content: [{ type: "text", text: "..." }] } shape (common in pi tool results)
|
|
301
|
+
const content = result.content;
|
|
302
|
+
if (Array.isArray(content)) {
|
|
303
|
+
const texts: string[] = [];
|
|
304
|
+
for (const item of content) {
|
|
305
|
+
if (isRecord(item) && typeof item.text === "string") {
|
|
306
|
+
texts.push(item.text);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (texts.length > 0) {
|
|
310
|
+
const joined = texts.join(" ").replace(/\n/g, " ");
|
|
311
|
+
return joined.length === 0 ? "(empty)" : truncateContent(joined);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Handle { output: "..." } or { message: "..." }
|
|
315
|
+
const msg = result.output ?? result.message ?? result.text;
|
|
316
|
+
if (typeof msg === "string") {
|
|
317
|
+
return msg.length === 0 ? "(empty)" : truncateContent(msg.replace(/\n/g, " "));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// No extractable content
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
36
324
|
export class TranscriptTracker {
|
|
37
325
|
private logs = new Map<string, TranscriptLog>();
|
|
38
326
|
private toolStarts = new Map<string, Map<string, number>>(); // name -> toolCallId -> startTimestamp
|
|
@@ -61,7 +349,9 @@ export class TranscriptTracker {
|
|
|
61
349
|
const starts = this.toolStarts.get(name) ?? new Map<string, number>();
|
|
62
350
|
starts.set(ev.toolCallId, now);
|
|
63
351
|
this.toolStarts.set(name, starts);
|
|
64
|
-
|
|
352
|
+
const content = extractStartContent(ev.toolName, ev.args);
|
|
353
|
+
const summary = extractStartSummary(ev.toolName, ev.args);
|
|
354
|
+
log.push({ kind: "tool_start", toolName: ev.toolName, content, summary, timestamp: now });
|
|
65
355
|
return;
|
|
66
356
|
}
|
|
67
357
|
|
|
@@ -70,7 +360,9 @@ export class TranscriptTracker {
|
|
|
70
360
|
const startTs = starts?.get(ev.toolCallId);
|
|
71
361
|
const durationMs = startTs === undefined ? 0 : now - startTs;
|
|
72
362
|
starts?.delete(ev.toolCallId);
|
|
73
|
-
|
|
363
|
+
const content = extractEndContent(ev.isError, ev.result);
|
|
364
|
+
const summary = extractEndSummary(ev.isError, ev.result);
|
|
365
|
+
log.push({ kind: "tool_end", toolName: ev.toolName, content, summary, isError: ev.isError, durationMs, timestamp: now });
|
|
74
366
|
return;
|
|
75
367
|
}
|
|
76
368
|
|
|
@@ -148,10 +440,6 @@ export class TranscriptTracker {
|
|
|
148
440
|
|
|
149
441
|
type TrackedEventType = "tool_execution_start" | "tool_execution_end" | "agent_end" | "message_end";
|
|
150
442
|
|
|
151
|
-
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
152
|
-
return typeof v === "object" && v !== null;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
443
|
export interface TeammateActivity {
|
|
156
444
|
toolUseCount: number;
|
|
157
445
|
currentToolName: string | null;
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { cleanupWorktrees, type WorktreeCleanupResult } from "./worktree.js";
|
|
4
|
+
|
|
5
|
+
export type CleanupResult = {
|
|
6
|
+
teamDir: string;
|
|
7
|
+
worktreeResult: WorktreeCleanupResult;
|
|
8
|
+
warnings: string[];
|
|
9
|
+
};
|
|
3
10
|
|
|
4
11
|
export function assertTeamDirWithinTeamsRoot(teamsRootDir: string, teamDir: string): {
|
|
5
12
|
teamsRootAbs: string;
|
|
@@ -21,11 +28,217 @@ export function assertTeamDirWithinTeamsRoot(teamsRootDir: string, teamDir: stri
|
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/**
|
|
24
|
-
* Recursively delete the given teamDir,
|
|
31
|
+
* Recursively delete the given teamDir, including proper git worktree + branch removal.
|
|
32
|
+
*
|
|
33
|
+
* Steps:
|
|
34
|
+
* 1. Remove git worktrees and branches via `cleanupWorktrees`
|
|
35
|
+
* 2. Delete the team directory recursively
|
|
25
36
|
*
|
|
26
|
-
*
|
|
37
|
+
* Idempotent — safe to call multiple times.
|
|
27
38
|
*/
|
|
28
|
-
export async function cleanupTeamDir(
|
|
39
|
+
export async function cleanupTeamDir(
|
|
40
|
+
teamsRootDir: string,
|
|
41
|
+
teamDir: string,
|
|
42
|
+
opts?: { teamId?: string; repoCwd?: string },
|
|
43
|
+
): Promise<CleanupResult> {
|
|
29
44
|
const { teamDirAbs } = assertTeamDirWithinTeamsRoot(teamsRootDir, teamDir);
|
|
45
|
+
const warnings: string[] = [];
|
|
46
|
+
|
|
47
|
+
// Infer teamId from directory name if not provided.
|
|
48
|
+
const teamId = opts?.teamId ?? path.basename(teamDirAbs);
|
|
49
|
+
|
|
50
|
+
// 1. Clean up git worktrees and branches before deleting the directory.
|
|
51
|
+
let worktreeResult: WorktreeCleanupResult = { removedWorktrees: [], removedBranches: [], warnings: [] };
|
|
52
|
+
try {
|
|
53
|
+
worktreeResult = await cleanupWorktrees({ teamDir: teamDirAbs, teamId, repoCwd: opts?.repoCwd });
|
|
54
|
+
warnings.push(...worktreeResult.warnings);
|
|
55
|
+
} catch (err: unknown) {
|
|
56
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
57
|
+
warnings.push(`Worktree cleanup failed (non-fatal): ${msg}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Delete the team directory.
|
|
30
61
|
await fs.promises.rm(teamDirAbs, { recursive: true, force: true });
|
|
62
|
+
|
|
63
|
+
return { teamDir: teamDirAbs, worktreeResult, warnings };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Garbage-collect stale team directories that have no active workers and are older
|
|
68
|
+
* than the given threshold.
|
|
69
|
+
*
|
|
70
|
+
* A team directory is considered stale when:
|
|
71
|
+
* - Its config.json `createdAt` (or directory mtime) is older than `maxAgeMs`
|
|
72
|
+
* - It has no in_progress tasks
|
|
73
|
+
* - It has no online members
|
|
74
|
+
*
|
|
75
|
+
* Returns a summary of what was cleaned up.
|
|
76
|
+
*/
|
|
77
|
+
export async function gcStaleTeamDirs(opts: {
|
|
78
|
+
teamsRootDir: string;
|
|
79
|
+
maxAgeMs: number;
|
|
80
|
+
repoCwd?: string;
|
|
81
|
+
dryRun?: boolean;
|
|
82
|
+
}): Promise<{
|
|
83
|
+
scanned: number;
|
|
84
|
+
removed: string[];
|
|
85
|
+
skipped: Array<{ teamId: string; reason: string }>;
|
|
86
|
+
warnings: string[];
|
|
87
|
+
}> {
|
|
88
|
+
const { teamsRootDir, maxAgeMs, repoCwd, dryRun } = opts;
|
|
89
|
+
const teamsRootAbs = path.resolve(teamsRootDir);
|
|
90
|
+
const removed: string[] = [];
|
|
91
|
+
const skipped: Array<{ teamId: string; reason: string }> = [];
|
|
92
|
+
const warnings: string[] = [];
|
|
93
|
+
|
|
94
|
+
let entries: string[];
|
|
95
|
+
try {
|
|
96
|
+
entries = await fs.promises.readdir(teamsRootAbs);
|
|
97
|
+
} catch {
|
|
98
|
+
return { scanned: 0, removed, skipped, warnings: ["teams root directory not found"] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Filter out non-team entries (like _styles, _hooks).
|
|
102
|
+
const teamEntries = entries.filter((e) => !e.startsWith("_"));
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
|
|
105
|
+
for (const teamId of teamEntries) {
|
|
106
|
+
const teamDir = path.join(teamsRootAbs, teamId);
|
|
107
|
+
let stat: fs.Stats;
|
|
108
|
+
try {
|
|
109
|
+
stat = await fs.promises.stat(teamDir);
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!stat.isDirectory()) continue;
|
|
114
|
+
|
|
115
|
+
// Check age: prefer config.json createdAt, fall back to directory mtime.
|
|
116
|
+
let ageMs: number;
|
|
117
|
+
try {
|
|
118
|
+
const configPath = path.join(teamDir, "config.json");
|
|
119
|
+
const configRaw = await fs.promises.readFile(configPath, "utf8");
|
|
120
|
+
const config: unknown = JSON.parse(configRaw);
|
|
121
|
+
const createdAt = typeof config === "object" && config !== null && "createdAt" in config
|
|
122
|
+
? (config as Record<string, unknown>).createdAt
|
|
123
|
+
: undefined;
|
|
124
|
+
if (typeof createdAt === "string") {
|
|
125
|
+
const ts = Date.parse(createdAt);
|
|
126
|
+
ageMs = Number.isFinite(ts) ? now - ts : now - stat.mtimeMs;
|
|
127
|
+
} else {
|
|
128
|
+
ageMs = now - stat.mtimeMs;
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
ageMs = now - stat.mtimeMs;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (ageMs < maxAgeMs) {
|
|
135
|
+
skipped.push({ teamId, reason: "too recent" });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check for active work: in_progress tasks, online workers, or live attach claims.
|
|
140
|
+
let hasActiveWork = false;
|
|
141
|
+
try {
|
|
142
|
+
const configPath = path.join(teamDir, "config.json");
|
|
143
|
+
const configRaw = await fs.promises.readFile(configPath, "utf8");
|
|
144
|
+
const config: unknown = JSON.parse(configRaw);
|
|
145
|
+
if (typeof config === "object" && config !== null) {
|
|
146
|
+
const members = (config as Record<string, unknown>).members;
|
|
147
|
+
if (Array.isArray(members)) {
|
|
148
|
+
for (const m of members) {
|
|
149
|
+
if (typeof m !== "object" || m === null) continue;
|
|
150
|
+
const rec = m as Record<string, unknown>;
|
|
151
|
+
// Ignore the lead — it stays "online" forever and is not a signal of activity.
|
|
152
|
+
if (rec.role === "lead") continue;
|
|
153
|
+
if (rec.status === "online") {
|
|
154
|
+
hasActiveWork = true;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// No config — probably safe to remove.
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check for a live attach claim (another session is using this team).
|
|
165
|
+
if (!hasActiveWork) {
|
|
166
|
+
try {
|
|
167
|
+
const claimPath = path.join(teamDir, ".attach-claim.json");
|
|
168
|
+
const claimRaw = await fs.promises.readFile(claimPath, "utf8");
|
|
169
|
+
const claim: unknown = JSON.parse(claimRaw);
|
|
170
|
+
if (typeof claim === "object" && claim !== null) {
|
|
171
|
+
const heartbeatAt = (claim as Record<string, unknown>).heartbeatAt;
|
|
172
|
+
if (typeof heartbeatAt === "string") {
|
|
173
|
+
const hbTs = Date.parse(heartbeatAt);
|
|
174
|
+
// Consider claims fresh if heartbeat is within 5 minutes.
|
|
175
|
+
const claimFreshnessMs = 5 * 60 * 1000;
|
|
176
|
+
if (Number.isFinite(hbTs) && now - hbTs < claimFreshnessMs) {
|
|
177
|
+
hasActiveWork = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// No claim file or invalid — not actively attached.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!hasActiveWork) {
|
|
187
|
+
// Check task files for in_progress tasks.
|
|
188
|
+
// Tasks live at tasks/<taskListId>/<id>.json — scan all subdirectories.
|
|
189
|
+
try {
|
|
190
|
+
const tasksDir = path.join(teamDir, "tasks");
|
|
191
|
+
const taskListDirs = await fs.promises.readdir(tasksDir);
|
|
192
|
+
for (const listDir of taskListDirs) {
|
|
193
|
+
if (hasActiveWork) break;
|
|
194
|
+
const listPath = path.join(tasksDir, listDir);
|
|
195
|
+
let listStat: fs.Stats;
|
|
196
|
+
try {
|
|
197
|
+
listStat = await fs.promises.stat(listPath);
|
|
198
|
+
} catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (!listStat.isDirectory()) continue;
|
|
202
|
+
|
|
203
|
+
const taskFiles = await fs.promises.readdir(listPath);
|
|
204
|
+
for (const tf of taskFiles) {
|
|
205
|
+
if (!tf.endsWith(".json")) continue;
|
|
206
|
+
try {
|
|
207
|
+
const raw = await fs.promises.readFile(path.join(listPath, tf), "utf8");
|
|
208
|
+
const parsed: unknown = JSON.parse(raw);
|
|
209
|
+
if (typeof parsed === "object" && parsed !== null && (parsed as Record<string, unknown>).status === "in_progress") {
|
|
210
|
+
hasActiveWork = true;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// ignore individual task read errors
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// No tasks dir — fine.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (hasActiveWork) {
|
|
224
|
+
skipped.push({ teamId, reason: "has active work" });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (dryRun) {
|
|
229
|
+
removed.push(teamId);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const result = await cleanupTeamDir(teamsRootAbs, teamDir, { teamId, repoCwd });
|
|
235
|
+
warnings.push(...result.warnings);
|
|
236
|
+
removed.push(teamId);
|
|
237
|
+
} catch (err: unknown) {
|
|
238
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
239
|
+
warnings.push(`Failed to remove ${teamId}: ${msg}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { scanned: teamEntries.length, removed, skipped, warnings };
|
|
31
244
|
}
|
|
@@ -4,6 +4,12 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { getTeamsHooksDir } from "./paths.js";
|
|
5
5
|
import type { TeamTask } from "./task-store.js";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Hook contract version. Increment on breaking changes only.
|
|
9
|
+
* See docs/hook-contract.md for the full compatibility policy.
|
|
10
|
+
*/
|
|
11
|
+
export const HOOK_CONTRACT_VERSION = 1;
|
|
12
|
+
|
|
7
13
|
export type TeamsHookEvent = "idle" | "task_completed" | "task_failed";
|
|
8
14
|
|
|
9
15
|
export type TeamsHookInvocation = {
|
|
@@ -17,6 +23,43 @@ export type TeamsHookInvocation = {
|
|
|
17
23
|
completedTask?: TeamTask | null;
|
|
18
24
|
};
|
|
19
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Structured context payload passed to hooks via PI_TEAMS_HOOK_CONTEXT_JSON.
|
|
28
|
+
*
|
|
29
|
+
* `task` is null for "idle" events and may also be null for task_completed /
|
|
30
|
+
* task_failed events when the task was cleared before the leader processed the
|
|
31
|
+
* worker's idle notification (race condition). Hook authors must guard access.
|
|
32
|
+
*
|
|
33
|
+
* For task_failed events, `task.status` is typically "pending" — the worker
|
|
34
|
+
* resets the task status before emitting the idle notification.
|
|
35
|
+
*
|
|
36
|
+
* See docs/hook-contract.md for the full schema and compatibility policy.
|
|
37
|
+
*/
|
|
38
|
+
export interface HookContextPayload {
|
|
39
|
+
version: typeof HOOK_CONTRACT_VERSION;
|
|
40
|
+
event: TeamsHookEvent;
|
|
41
|
+
team: {
|
|
42
|
+
id: string;
|
|
43
|
+
dir: string;
|
|
44
|
+
taskListId: string;
|
|
45
|
+
style: string;
|
|
46
|
+
};
|
|
47
|
+
member: string | null;
|
|
48
|
+
timestamp: string | null;
|
|
49
|
+
task: {
|
|
50
|
+
id: string;
|
|
51
|
+
subject: string;
|
|
52
|
+
description: string;
|
|
53
|
+
owner: string | null;
|
|
54
|
+
status: string;
|
|
55
|
+
blockedBy: string[];
|
|
56
|
+
blocks: string[];
|
|
57
|
+
metadata: Record<string, unknown>;
|
|
58
|
+
createdAt: string;
|
|
59
|
+
updatedAt: string;
|
|
60
|
+
} | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
20
63
|
export type TeamsHookRunResult = {
|
|
21
64
|
ran: boolean;
|
|
22
65
|
hookPath?: string;
|
|
@@ -27,6 +70,8 @@ export type TeamsHookRunResult = {
|
|
|
27
70
|
stdout: string;
|
|
28
71
|
stderr: string;
|
|
29
72
|
error?: string;
|
|
73
|
+
/** The contract version used for this invocation. */
|
|
74
|
+
contractVersion: typeof HOOK_CONTRACT_VERSION;
|
|
30
75
|
};
|
|
31
76
|
|
|
32
77
|
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
@@ -126,10 +171,10 @@ function truncateField(value: string, max: number): string {
|
|
|
126
171
|
return `${value.slice(0, Math.max(0, max - 1))}…`;
|
|
127
172
|
}
|
|
128
173
|
|
|
129
|
-
function
|
|
174
|
+
export function buildHookContextPayload(invocation: TeamsHookInvocation): HookContextPayload {
|
|
130
175
|
const task = invocation.completedTask;
|
|
131
|
-
|
|
132
|
-
version:
|
|
176
|
+
return {
|
|
177
|
+
version: HOOK_CONTRACT_VERSION,
|
|
133
178
|
event: invocation.event,
|
|
134
179
|
team: {
|
|
135
180
|
id: invocation.teamId,
|
|
@@ -154,7 +199,10 @@ function getHookContextJson(invocation: TeamsHookInvocation): string {
|
|
|
154
199
|
}
|
|
155
200
|
: null,
|
|
156
201
|
};
|
|
157
|
-
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getHookContextJson(invocation: TeamsHookInvocation): string {
|
|
205
|
+
return JSON.stringify(buildHookContextPayload(invocation));
|
|
158
206
|
}
|
|
159
207
|
|
|
160
208
|
export function getHookBaseName(event: TeamsHookEvent): string {
|
|
@@ -280,6 +328,7 @@ export async function runTeamsHook(opts: {
|
|
|
280
328
|
durationMs: 0,
|
|
281
329
|
stdout: "",
|
|
282
330
|
stderr: "",
|
|
331
|
+
contractVersion: HOOK_CONTRACT_VERSION,
|
|
283
332
|
};
|
|
284
333
|
}
|
|
285
334
|
|
|
@@ -293,6 +342,7 @@ export async function runTeamsHook(opts: {
|
|
|
293
342
|
durationMs: 0,
|
|
294
343
|
stdout: "",
|
|
295
344
|
stderr: "",
|
|
345
|
+
contractVersion: HOOK_CONTRACT_VERSION,
|
|
296
346
|
};
|
|
297
347
|
}
|
|
298
348
|
|
|
@@ -302,7 +352,7 @@ export async function runTeamsHook(opts: {
|
|
|
302
352
|
const baseEnv: NodeJS.ProcessEnv = {
|
|
303
353
|
...env,
|
|
304
354
|
PI_TEAMS_HOOK_EVENT: opts.invocation.event,
|
|
305
|
-
PI_TEAMS_HOOK_CONTEXT_VERSION:
|
|
355
|
+
PI_TEAMS_HOOK_CONTEXT_VERSION: String(HOOK_CONTRACT_VERSION),
|
|
306
356
|
PI_TEAMS_HOOK_CONTEXT_JSON: getHookContextJson(opts.invocation),
|
|
307
357
|
PI_TEAMS_TEAM_ID: opts.invocation.teamId,
|
|
308
358
|
PI_TEAMS_TEAM_DIR: opts.invocation.teamDir,
|
|
@@ -336,6 +386,7 @@ export async function runTeamsHook(opts: {
|
|
|
336
386
|
stdout: "",
|
|
337
387
|
stderr: "",
|
|
338
388
|
error: msg,
|
|
389
|
+
contractVersion: HOOK_CONTRACT_VERSION,
|
|
339
390
|
};
|
|
340
391
|
}
|
|
341
392
|
|
|
@@ -349,6 +400,7 @@ export async function runTeamsHook(opts: {
|
|
|
349
400
|
stdout: res.stdout,
|
|
350
401
|
stderr: res.stderr,
|
|
351
402
|
error: res.error,
|
|
403
|
+
contractVersion: HOOK_CONTRACT_VERSION,
|
|
352
404
|
};
|
|
353
405
|
}
|
|
354
406
|
|