indus-swarms 0.1.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/AGENTS.md +14 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/claude-parity.md +151 -0
- package/docs/field-notes-teams-setup.md +107 -0
- package/docs/smoke-test-plan.md +146 -0
- package/eslint.config.js +74 -0
- package/extensions/teams/README.md +23 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/cleanup.ts +31 -0
- package/extensions/teams/fs-lock.ts +87 -0
- package/extensions/teams/hooks.ts +363 -0
- package/extensions/teams/index.ts +18 -0
- package/extensions/teams/leader-attach-commands.ts +221 -0
- package/extensions/teams/leader-inbox.ts +214 -0
- package/extensions/teams/leader-info-commands.ts +140 -0
- package/extensions/teams/leader-lifecycle-commands.ts +559 -0
- package/extensions/teams/leader-messaging-commands.ts +148 -0
- package/extensions/teams/leader-plan-commands.ts +95 -0
- package/extensions/teams/leader-spawn-command.ts +149 -0
- package/extensions/teams/leader-task-commands.ts +435 -0
- package/extensions/teams/leader-team-command.ts +382 -0
- package/extensions/teams/leader-teams-tool.ts +1075 -0
- package/extensions/teams/leader.ts +925 -0
- package/extensions/teams/mailbox.ts +131 -0
- package/extensions/teams/model-policy.ts +142 -0
- package/extensions/teams/names.ts +121 -0
- package/extensions/teams/paths.ts +37 -0
- package/extensions/teams/protocol.ts +241 -0
- package/extensions/teams/spawn-types.ts +36 -0
- package/extensions/teams/task-store.ts +544 -0
- package/extensions/teams/team-attach-claim.ts +205 -0
- package/extensions/teams/team-config.ts +335 -0
- package/extensions/teams/team-discovery.ts +59 -0
- package/extensions/teams/teammate-rpc.ts +261 -0
- package/extensions/teams/teams-panel.ts +1186 -0
- package/extensions/teams/teams-style.ts +322 -0
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +212 -0
- package/extensions/teams/worker.ts +605 -0
- package/extensions/teams/worktree.ts +103 -0
- package/package.json +53 -0
- package/scripts/e2e-rpc-test.mjs +277 -0
- package/scripts/integration-claim-test.mts +157 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +398 -0
- package/scripts/integration-todo-test.mts +533 -0
- package/scripts/lib/pi-workers.ts +105 -0
- package/scripts/smoke-test.mts +764 -0
- package/scripts/start-tmux-team.sh +91 -0
- package/skills/agent-teams/SKILL.md +180 -0
- package/tsconfig.strict.json +22 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { AgentEvent } from "indusagi/agent";
|
|
2
|
+
|
|
3
|
+
// ── Transcript types ──
|
|
4
|
+
|
|
5
|
+
export type TranscriptEntry =
|
|
6
|
+
| { kind: "text"; text: string; timestamp: number }
|
|
7
|
+
| { kind: "tool_start"; toolName: string; timestamp: number }
|
|
8
|
+
| { kind: "tool_end"; toolName: string; durationMs: number; timestamp: number }
|
|
9
|
+
| { kind: "turn_end"; turnNumber: number; tokens: number; timestamp: number };
|
|
10
|
+
|
|
11
|
+
const MAX_TRANSCRIPT = 200;
|
|
12
|
+
|
|
13
|
+
export class TranscriptLog {
|
|
14
|
+
private entries: TranscriptEntry[] = [];
|
|
15
|
+
|
|
16
|
+
push(entry: TranscriptEntry): void {
|
|
17
|
+
this.entries.push(entry);
|
|
18
|
+
if (this.entries.length > MAX_TRANSCRIPT) {
|
|
19
|
+
this.entries.splice(0, this.entries.length - MAX_TRANSCRIPT);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getEntries(): readonly TranscriptEntry[] {
|
|
24
|
+
return this.entries;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get length(): number {
|
|
28
|
+
return this.entries.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
reset(): void {
|
|
32
|
+
this.entries = [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TranscriptTracker {
|
|
37
|
+
private logs = new Map<string, TranscriptLog>();
|
|
38
|
+
private toolStarts = new Map<string, Map<string, number>>(); // name -> toolCallId -> startTimestamp
|
|
39
|
+
private pendingText = new Map<string, string>(); // name -> accumulated text
|
|
40
|
+
private turnCounts = new Map<string, number>();
|
|
41
|
+
private lastTokens = new Map<string, number>(); // name -> tokens from last message_end
|
|
42
|
+
|
|
43
|
+
handleEvent(name: string, ev: AgentEvent): void {
|
|
44
|
+
const log = this.getOrCreate(name);
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
|
|
47
|
+
if (ev.type === "message_update") {
|
|
48
|
+
const ame = ev.assistantMessageEvent;
|
|
49
|
+
if (ame.type === "text_delta") {
|
|
50
|
+
const cur = this.pendingText.get(name) ?? "";
|
|
51
|
+
this.pendingText.set(name, cur + ame.delta);
|
|
52
|
+
// Flush complete lines
|
|
53
|
+
this.flushText(name, log, now, false);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (ev.type === "tool_execution_start") {
|
|
59
|
+
// Flush any pending text before a tool starts
|
|
60
|
+
this.flushText(name, log, now, true);
|
|
61
|
+
const starts = this.toolStarts.get(name) ?? new Map<string, number>();
|
|
62
|
+
starts.set(ev.toolCallId, now);
|
|
63
|
+
this.toolStarts.set(name, starts);
|
|
64
|
+
log.push({ kind: "tool_start", toolName: ev.toolName, timestamp: now });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (ev.type === "tool_execution_end") {
|
|
69
|
+
const starts = this.toolStarts.get(name);
|
|
70
|
+
const startTs = starts?.get(ev.toolCallId);
|
|
71
|
+
const durationMs = startTs === undefined ? 0 : now - startTs;
|
|
72
|
+
starts?.delete(ev.toolCallId);
|
|
73
|
+
log.push({ kind: "tool_end", toolName: ev.toolName, durationMs, timestamp: now });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (ev.type === "message_end") {
|
|
78
|
+
// Capture tokens for use in the turn_end entry
|
|
79
|
+
const msg: unknown = ev.message;
|
|
80
|
+
if (isRecord(msg)) {
|
|
81
|
+
const usage = msg.usage;
|
|
82
|
+
if (isRecord(usage) && typeof usage.totalTokens === "number") {
|
|
83
|
+
this.lastTokens.set(name, (this.lastTokens.get(name) ?? 0) + usage.totalTokens);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (ev.type === "agent_end") {
|
|
90
|
+
// Flush remaining text
|
|
91
|
+
this.flushText(name, log, now, true);
|
|
92
|
+
const turn = (this.turnCounts.get(name) ?? 0) + 1;
|
|
93
|
+
this.turnCounts.set(name, turn);
|
|
94
|
+
const tokens = this.lastTokens.get(name) ?? 0;
|
|
95
|
+
log.push({ kind: "turn_end", turnNumber: turn, tokens, timestamp: now });
|
|
96
|
+
this.lastTokens.set(name, 0);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get(name: string): TranscriptLog {
|
|
102
|
+
return this.logs.get(name) ?? new TranscriptLog();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
reset(name: string): void {
|
|
106
|
+
this.logs.delete(name);
|
|
107
|
+
this.toolStarts.delete(name);
|
|
108
|
+
this.pendingText.delete(name);
|
|
109
|
+
this.turnCounts.delete(name);
|
|
110
|
+
this.lastTokens.delete(name);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private getOrCreate(name: string): TranscriptLog {
|
|
114
|
+
const existing = this.logs.get(name);
|
|
115
|
+
if (existing) return existing;
|
|
116
|
+
const created = new TranscriptLog();
|
|
117
|
+
this.logs.set(name, created);
|
|
118
|
+
return created;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private flushText(name: string, log: TranscriptLog, timestamp: number, force: boolean): void {
|
|
122
|
+
const buf = this.pendingText.get(name);
|
|
123
|
+
if (!buf) return;
|
|
124
|
+
|
|
125
|
+
// Split into lines; keep the last incomplete line unless forced
|
|
126
|
+
const parts = buf.split("\n");
|
|
127
|
+
if (force) {
|
|
128
|
+
// Flush everything
|
|
129
|
+
for (const part of parts) {
|
|
130
|
+
const trimmed = part.trimEnd();
|
|
131
|
+
if (trimmed) log.push({ kind: "text", text: trimmed, timestamp });
|
|
132
|
+
}
|
|
133
|
+
this.pendingText.delete(name);
|
|
134
|
+
} else if (parts.length > 1) {
|
|
135
|
+
// Flush all complete lines, keep the last (potentially incomplete) part
|
|
136
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
137
|
+
const part = parts[i];
|
|
138
|
+
if (part === undefined) continue;
|
|
139
|
+
const trimmed = part.trimEnd();
|
|
140
|
+
if (trimmed) log.push({ kind: "text", text: trimmed, timestamp });
|
|
141
|
+
}
|
|
142
|
+
this.pendingText.set(name, parts[parts.length - 1] ?? "");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Activity types ──
|
|
148
|
+
|
|
149
|
+
type TrackedEventType = "tool_execution_start" | "tool_execution_end" | "agent_end" | "message_end";
|
|
150
|
+
|
|
151
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
152
|
+
return typeof v === "object" && v !== null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface TeammateActivity {
|
|
156
|
+
toolUseCount: number;
|
|
157
|
+
currentToolName: string | null;
|
|
158
|
+
lastToolName: string | null;
|
|
159
|
+
turnCount: number;
|
|
160
|
+
totalTokens: number;
|
|
161
|
+
recentEvents: Array<{ type: TrackedEventType; toolName?: string; timestamp: number }>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const MAX_RECENT = 10;
|
|
165
|
+
|
|
166
|
+
function emptyActivity(): TeammateActivity {
|
|
167
|
+
return {
|
|
168
|
+
toolUseCount: 0,
|
|
169
|
+
currentToolName: null,
|
|
170
|
+
lastToolName: null,
|
|
171
|
+
turnCount: 0,
|
|
172
|
+
totalTokens: 0,
|
|
173
|
+
recentEvents: [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class ActivityTracker {
|
|
178
|
+
private data = new Map<string, TeammateActivity>();
|
|
179
|
+
|
|
180
|
+
handleEvent(name: string, ev: AgentEvent): void {
|
|
181
|
+
const a = this.getOrCreate(name);
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
|
|
184
|
+
if (ev.type === "tool_execution_start") {
|
|
185
|
+
a.currentToolName = ev.toolName;
|
|
186
|
+
a.recentEvents.push({ type: ev.type, toolName: ev.toolName, timestamp: now });
|
|
187
|
+
if (a.recentEvents.length > MAX_RECENT) a.recentEvents.shift();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (ev.type === "tool_execution_end") {
|
|
192
|
+
const toolName = a.currentToolName ?? ev.toolName;
|
|
193
|
+
a.toolUseCount++;
|
|
194
|
+
a.lastToolName = toolName;
|
|
195
|
+
a.currentToolName = null;
|
|
196
|
+
a.recentEvents.push({ type: ev.type, toolName, timestamp: now });
|
|
197
|
+
if (a.recentEvents.length > MAX_RECENT) a.recentEvents.shift();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (ev.type === "agent_end") {
|
|
202
|
+
a.turnCount++;
|
|
203
|
+
a.recentEvents.push({ type: ev.type, timestamp: now });
|
|
204
|
+
if (a.recentEvents.length > MAX_RECENT) a.recentEvents.shift();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (ev.type === "message_end") {
|
|
209
|
+
const msg: unknown = ev.message;
|
|
210
|
+
if (!isRecord(msg)) return;
|
|
211
|
+
const usage = msg.usage;
|
|
212
|
+
if (!isRecord(usage)) return;
|
|
213
|
+
const totalTokens = usage.totalTokens;
|
|
214
|
+
if (typeof totalTokens === "number") a.totalTokens += totalTokens;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
get(name: string): TeammateActivity {
|
|
219
|
+
return this.data.get(name) ?? emptyActivity();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
reset(name: string): void {
|
|
223
|
+
this.data.delete(name);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private getOrCreate(name: string): TeammateActivity {
|
|
227
|
+
const existing = this.data.get(name);
|
|
228
|
+
if (existing) return existing;
|
|
229
|
+
|
|
230
|
+
const created = emptyActivity();
|
|
231
|
+
this.data.set(name, created);
|
|
232
|
+
return created;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function assertTeamDirWithinTeamsRoot(teamsRootDir: string, teamDir: string): {
|
|
5
|
+
teamsRootAbs: string;
|
|
6
|
+
teamDirAbs: string;
|
|
7
|
+
} {
|
|
8
|
+
const teamsRootAbs = path.resolve(teamsRootDir);
|
|
9
|
+
const teamDirAbs = path.resolve(teamDir);
|
|
10
|
+
|
|
11
|
+
const rel = path.relative(teamsRootAbs, teamDirAbs);
|
|
12
|
+
// rel === "" => same path (would delete the whole root)
|
|
13
|
+
// rel starts with ".." or is absolute => outside root
|
|
14
|
+
if (!rel || rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Refusing to operate on path outside teams root. teamsRootDir=${teamsRootAbs} teamDir=${teamDirAbs}`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return { teamsRootAbs, teamDirAbs };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recursively delete the given teamDir, but only if it's safely inside teamsRootDir.
|
|
25
|
+
*
|
|
26
|
+
* Uses fs.rm({ recursive: true, force: true }) so it's idempotent.
|
|
27
|
+
*/
|
|
28
|
+
export async function cleanupTeamDir(teamsRootDir: string, teamDir: string): Promise<void> {
|
|
29
|
+
const { teamDirAbs } = assertTeamDirWithinTeamsRoot(teamsRootDir, teamDir);
|
|
30
|
+
await fs.promises.rm(teamDirAbs, { recursive: true, force: true });
|
|
31
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
function sleep(ms: number): Promise<void> {
|
|
4
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
8
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LockOptions {
|
|
12
|
+
/** How long to wait to acquire the lock before failing. */
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** If lock file is older than this, consider it stale and remove it. */
|
|
15
|
+
staleMs?: number;
|
|
16
|
+
/** Poll interval while waiting for lock. */
|
|
17
|
+
pollMs?: number;
|
|
18
|
+
/** Optional label to help debugging (written into lock file). */
|
|
19
|
+
label?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function withLock<T>(lockFilePath: string, fn: () => Promise<T>, opts: LockOptions = {}): Promise<T> {
|
|
23
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
24
|
+
const staleMs = opts.staleMs ?? 60_000;
|
|
25
|
+
const basePollMs = opts.pollMs ?? 50;
|
|
26
|
+
const maxPollMs = Math.max(basePollMs, 1_000);
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
|
|
29
|
+
let fd: number | null = null;
|
|
30
|
+
let attempt = 0;
|
|
31
|
+
|
|
32
|
+
while (fd === null) {
|
|
33
|
+
try {
|
|
34
|
+
fd = fs.openSync(lockFilePath, "wx");
|
|
35
|
+
const payload = {
|
|
36
|
+
pid: process.pid,
|
|
37
|
+
createdAt: new Date().toISOString(),
|
|
38
|
+
label: opts.label,
|
|
39
|
+
};
|
|
40
|
+
fs.writeFileSync(fd, JSON.stringify(payload));
|
|
41
|
+
} catch (err: unknown) {
|
|
42
|
+
if (!isErrnoException(err) || err.code !== "EEXIST") throw err;
|
|
43
|
+
|
|
44
|
+
// Stale lock handling
|
|
45
|
+
try {
|
|
46
|
+
const st = fs.statSync(lockFilePath);
|
|
47
|
+
const age = Date.now() - st.mtimeMs;
|
|
48
|
+
if (age > staleMs) {
|
|
49
|
+
fs.unlinkSync(lockFilePath);
|
|
50
|
+
attempt = 0;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore: stat/unlink failures fall through to wait
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const elapsedMs = Date.now() - start;
|
|
58
|
+
if (elapsedMs > timeoutMs) {
|
|
59
|
+
throw new Error(`Timeout acquiring lock: ${lockFilePath}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
attempt += 1;
|
|
63
|
+
const expBackoff = Math.min(maxPollMs, basePollMs * 2 ** Math.min(attempt, 6));
|
|
64
|
+
const jitterFactor = 0.5 + Math.random(); // [0.5, 1.5)
|
|
65
|
+
const jitteredBackoff = Math.min(maxPollMs, Math.round(expBackoff * jitterFactor));
|
|
66
|
+
|
|
67
|
+
const remainingMs = timeoutMs - elapsedMs;
|
|
68
|
+
const sleepMs = Math.max(1, Math.min(remainingMs, jitteredBackoff));
|
|
69
|
+
await sleep(sleepMs);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return await fn();
|
|
75
|
+
} finally {
|
|
76
|
+
try {
|
|
77
|
+
if (fd !== null) fs.closeSync(fd);
|
|
78
|
+
} catch {
|
|
79
|
+
// ignore
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
fs.unlinkSync(lockFilePath);
|
|
83
|
+
} catch {
|
|
84
|
+
// ignore
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { getTeamsHooksDir } from "./paths.js";
|
|
5
|
+
import type { TeamTask } from "./task-store.js";
|
|
6
|
+
|
|
7
|
+
export type TeamsHookEvent = "idle" | "task_completed" | "task_failed";
|
|
8
|
+
|
|
9
|
+
export type TeamsHookInvocation = {
|
|
10
|
+
event: TeamsHookEvent;
|
|
11
|
+
teamId: string;
|
|
12
|
+
teamDir: string;
|
|
13
|
+
taskListId: string;
|
|
14
|
+
style: string;
|
|
15
|
+
memberName?: string;
|
|
16
|
+
timestamp?: string;
|
|
17
|
+
completedTask?: TeamTask | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type TeamsHookRunResult = {
|
|
21
|
+
ran: boolean;
|
|
22
|
+
hookPath?: string;
|
|
23
|
+
command?: readonly string[];
|
|
24
|
+
exitCode: number | null;
|
|
25
|
+
timedOut: boolean;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
33
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isExecutable(st: fs.Stats): boolean {
|
|
37
|
+
// Owner/group/other execute bit.
|
|
38
|
+
return (st.mode & 0o111) !== 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function trimOutput(s: string, limit = 12_000): string {
|
|
42
|
+
if (s.length <= limit) return s;
|
|
43
|
+
return s.slice(0, limit) + `\n… (truncated, ${s.length - limit} bytes omitted)`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
47
|
+
const raw = env.PI_TEAMS_HOOK_TIMEOUT_MS;
|
|
48
|
+
if (!raw) return 60_000;
|
|
49
|
+
const n = Number.parseInt(raw, 10);
|
|
50
|
+
return Number.isFinite(n) && n > 0 ? n : 60_000;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function areTeamsHooksEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
54
|
+
return env.PI_TEAMS_HOOKS_ENABLED === "1";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type TeamsHookFailureAction = "warn" | "followup" | "reopen" | "reopen_followup";
|
|
58
|
+
export type TeamsHookFollowupOwnerPolicy = "member" | "lead" | "none";
|
|
59
|
+
|
|
60
|
+
export function isTeamsHookFailureAction(value: string): value is TeamsHookFailureAction {
|
|
61
|
+
return value === "warn" || value === "followup" || value === "reopen" || value === "reopen_followup";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getTeamsHookFailureAction(
|
|
65
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
66
|
+
override?: string,
|
|
67
|
+
): TeamsHookFailureAction {
|
|
68
|
+
const explicit = override?.trim().toLowerCase();
|
|
69
|
+
if (explicit && isTeamsHookFailureAction(explicit)) return explicit;
|
|
70
|
+
const raw = env.PI_TEAMS_HOOKS_FAILURE_ACTION?.trim().toLowerCase();
|
|
71
|
+
if (raw && isTeamsHookFailureAction(raw)) return raw;
|
|
72
|
+
if (env.PI_TEAMS_HOOKS_CREATE_TASK_ON_FAILURE === "1") return "followup";
|
|
73
|
+
return "warn";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function shouldCreateHookFollowupTask(action: TeamsHookFailureAction): boolean {
|
|
77
|
+
return action === "followup" || action === "reopen_followup";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function shouldReopenTaskOnHookFailure(action: TeamsHookFailureAction): boolean {
|
|
81
|
+
return action === "reopen" || action === "reopen_followup";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isTeamsHookFollowupOwnerPolicy(value: string): value is TeamsHookFollowupOwnerPolicy {
|
|
85
|
+
return value === "member" || value === "lead" || value === "none";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getTeamsHookFollowupOwnerPolicy(
|
|
89
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
90
|
+
override?: string,
|
|
91
|
+
): TeamsHookFollowupOwnerPolicy {
|
|
92
|
+
const explicit = override?.trim().toLowerCase();
|
|
93
|
+
if (explicit && isTeamsHookFollowupOwnerPolicy(explicit)) return explicit;
|
|
94
|
+
const raw = env.PI_TEAMS_HOOKS_FOLLOWUP_OWNER?.trim().toLowerCase();
|
|
95
|
+
if (raw && isTeamsHookFollowupOwnerPolicy(raw)) return raw;
|
|
96
|
+
return "member";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resolveTeamsHookFollowupOwner(opts: {
|
|
100
|
+
policy: TeamsHookFollowupOwnerPolicy;
|
|
101
|
+
memberName?: string;
|
|
102
|
+
leadName?: string;
|
|
103
|
+
}): string | undefined {
|
|
104
|
+
if (opts.policy === "none") return undefined;
|
|
105
|
+
if (opts.policy === "lead") {
|
|
106
|
+
const lead = opts.leadName?.trim();
|
|
107
|
+
return lead ? lead : undefined;
|
|
108
|
+
}
|
|
109
|
+
const member = opts.memberName?.trim();
|
|
110
|
+
if (member) return member;
|
|
111
|
+
const lead = opts.leadName?.trim();
|
|
112
|
+
return lead ? lead : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getTeamsHookMaxReopensPerTask(env: NodeJS.ProcessEnv = process.env, override?: number): number {
|
|
116
|
+
if (typeof override === "number" && Number.isFinite(override) && override >= 0) return Math.floor(override);
|
|
117
|
+
const raw = env.PI_TEAMS_HOOKS_MAX_REOPENS_PER_TASK?.trim();
|
|
118
|
+
if (!raw) return 3;
|
|
119
|
+
const parsed = Number.parseInt(raw, 10);
|
|
120
|
+
if (!Number.isFinite(parsed) || parsed < 0) return 3;
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function truncateField(value: string, max: number): string {
|
|
125
|
+
if (value.length <= max) return value;
|
|
126
|
+
return `${value.slice(0, Math.max(0, max - 1))}…`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getHookContextJson(invocation: TeamsHookInvocation): string {
|
|
130
|
+
const task = invocation.completedTask;
|
|
131
|
+
const payload = {
|
|
132
|
+
version: 1,
|
|
133
|
+
event: invocation.event,
|
|
134
|
+
team: {
|
|
135
|
+
id: invocation.teamId,
|
|
136
|
+
dir: invocation.teamDir,
|
|
137
|
+
taskListId: invocation.taskListId,
|
|
138
|
+
style: invocation.style,
|
|
139
|
+
},
|
|
140
|
+
member: invocation.memberName ?? null,
|
|
141
|
+
timestamp: invocation.timestamp ?? null,
|
|
142
|
+
task: task
|
|
143
|
+
? {
|
|
144
|
+
id: task.id,
|
|
145
|
+
subject: truncateField(task.subject, 1_000),
|
|
146
|
+
description: truncateField(task.description, 8_000),
|
|
147
|
+
owner: task.owner ?? null,
|
|
148
|
+
status: task.status,
|
|
149
|
+
blockedBy: task.blockedBy.slice(0, 200),
|
|
150
|
+
blocks: task.blocks.slice(0, 200),
|
|
151
|
+
metadata: task.metadata ?? {},
|
|
152
|
+
createdAt: task.createdAt,
|
|
153
|
+
updatedAt: task.updatedAt,
|
|
154
|
+
}
|
|
155
|
+
: null,
|
|
156
|
+
};
|
|
157
|
+
return JSON.stringify(payload);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function getHookBaseName(event: TeamsHookEvent): string {
|
|
161
|
+
switch (event) {
|
|
162
|
+
case "idle":
|
|
163
|
+
return "on_idle";
|
|
164
|
+
case "task_completed":
|
|
165
|
+
return "on_task_completed";
|
|
166
|
+
case "task_failed":
|
|
167
|
+
return "on_task_failed";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type HookCommand = { cmd: string; args: string[]; hookPath: string; display: readonly string[] };
|
|
172
|
+
|
|
173
|
+
function resolveHookCommand(hooksDir: string, event: TeamsHookEvent): HookCommand | null {
|
|
174
|
+
const base = getHookBaseName(event);
|
|
175
|
+
const candidates = [
|
|
176
|
+
path.join(hooksDir, base),
|
|
177
|
+
path.join(hooksDir, `${base}.sh`),
|
|
178
|
+
path.join(hooksDir, `${base}.js`),
|
|
179
|
+
path.join(hooksDir, `${base}.mjs`),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
for (const file of candidates) {
|
|
183
|
+
try {
|
|
184
|
+
if (!fs.existsSync(file)) continue;
|
|
185
|
+
const st = fs.statSync(file);
|
|
186
|
+
if (!st.isFile()) continue;
|
|
187
|
+
|
|
188
|
+
const ext = path.extname(file).toLowerCase();
|
|
189
|
+
if (ext === ".js" || ext === ".mjs") {
|
|
190
|
+
return { cmd: "node", args: [file], hookPath: file, display: ["node", file] };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (ext === ".sh") {
|
|
194
|
+
return { cmd: "bash", args: [file], hookPath: file, display: ["bash", file] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (isExecutable(st)) {
|
|
198
|
+
return { cmd: file, args: [], hookPath: file, display: [file] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Non-executable with unknown extension: ignore.
|
|
202
|
+
} catch {
|
|
203
|
+
// ignore
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runWithTimeout(opts: {
|
|
211
|
+
cmd: string;
|
|
212
|
+
args: readonly string[];
|
|
213
|
+
cwd: string;
|
|
214
|
+
env: NodeJS.ProcessEnv;
|
|
215
|
+
timeoutMs: number;
|
|
216
|
+
}): Promise<{ exitCode: number | null; timedOut: boolean; stdout: string; stderr: string; error?: string }> {
|
|
217
|
+
return await new Promise((resolve) => {
|
|
218
|
+
let stdout = "";
|
|
219
|
+
let stderr = "";
|
|
220
|
+
let timedOut = false;
|
|
221
|
+
|
|
222
|
+
const child = spawn(opts.cmd, [...opts.args], {
|
|
223
|
+
cwd: opts.cwd,
|
|
224
|
+
env: opts.env,
|
|
225
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
229
|
+
stdout += d.toString("utf8");
|
|
230
|
+
});
|
|
231
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
232
|
+
stderr += d.toString("utf8");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const timeout = setTimeout(() => {
|
|
236
|
+
timedOut = true;
|
|
237
|
+
try {
|
|
238
|
+
child.kill("SIGTERM");
|
|
239
|
+
} catch {
|
|
240
|
+
// ignore
|
|
241
|
+
}
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
try {
|
|
244
|
+
child.kill("SIGKILL");
|
|
245
|
+
} catch {
|
|
246
|
+
// ignore
|
|
247
|
+
}
|
|
248
|
+
}, 1000);
|
|
249
|
+
}, opts.timeoutMs);
|
|
250
|
+
|
|
251
|
+
child.on("close", (code) => {
|
|
252
|
+
clearTimeout(timeout);
|
|
253
|
+
resolve({
|
|
254
|
+
exitCode: code,
|
|
255
|
+
timedOut,
|
|
256
|
+
stdout: trimOutput(stdout),
|
|
257
|
+
stderr: trimOutput(stderr),
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
child.on("error", (err: unknown) => {
|
|
262
|
+
clearTimeout(timeout);
|
|
263
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
264
|
+
resolve({ exitCode: null, timedOut: false, stdout: trimOutput(stdout), stderr: trimOutput(stderr), error: msg });
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function runTeamsHook(opts: {
|
|
270
|
+
invocation: TeamsHookInvocation;
|
|
271
|
+
cwd: string;
|
|
272
|
+
env?: NodeJS.ProcessEnv;
|
|
273
|
+
}): Promise<TeamsHookRunResult> {
|
|
274
|
+
const env = opts.env ?? process.env;
|
|
275
|
+
if (!areTeamsHooksEnabled(env)) {
|
|
276
|
+
return {
|
|
277
|
+
ran: false,
|
|
278
|
+
exitCode: null,
|
|
279
|
+
timedOut: false,
|
|
280
|
+
durationMs: 0,
|
|
281
|
+
stdout: "",
|
|
282
|
+
stderr: "",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const hooksDir = getTeamsHooksDir();
|
|
287
|
+
const hook = resolveHookCommand(hooksDir, opts.invocation.event);
|
|
288
|
+
if (!hook) {
|
|
289
|
+
return {
|
|
290
|
+
ran: false,
|
|
291
|
+
exitCode: null,
|
|
292
|
+
timedOut: false,
|
|
293
|
+
durationMs: 0,
|
|
294
|
+
stdout: "",
|
|
295
|
+
stderr: "",
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const timeoutMs = parseTimeoutMs(env);
|
|
300
|
+
const start = Date.now();
|
|
301
|
+
|
|
302
|
+
const baseEnv: NodeJS.ProcessEnv = {
|
|
303
|
+
...env,
|
|
304
|
+
PI_TEAMS_HOOK_EVENT: opts.invocation.event,
|
|
305
|
+
PI_TEAMS_HOOK_CONTEXT_VERSION: "1",
|
|
306
|
+
PI_TEAMS_HOOK_CONTEXT_JSON: getHookContextJson(opts.invocation),
|
|
307
|
+
PI_TEAMS_TEAM_ID: opts.invocation.teamId,
|
|
308
|
+
PI_TEAMS_TEAM_DIR: opts.invocation.teamDir,
|
|
309
|
+
PI_TEAMS_TASK_LIST_ID: opts.invocation.taskListId,
|
|
310
|
+
PI_TEAMS_STYLE: opts.invocation.style,
|
|
311
|
+
...(opts.invocation.memberName ? { PI_TEAMS_MEMBER: opts.invocation.memberName } : {}),
|
|
312
|
+
...(opts.invocation.timestamp ? { PI_TEAMS_EVENT_TIMESTAMP: opts.invocation.timestamp } : {}),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const t = opts.invocation.completedTask;
|
|
316
|
+
const envWithTask: NodeJS.ProcessEnv = {
|
|
317
|
+
...baseEnv,
|
|
318
|
+
...(t?.id ? { PI_TEAMS_TASK_ID: t.id } : {}),
|
|
319
|
+
...(t?.subject ? { PI_TEAMS_TASK_SUBJECT: t.subject } : {}),
|
|
320
|
+
...(t?.owner ? { PI_TEAMS_TASK_OWNER: t.owner } : {}),
|
|
321
|
+
...(t?.status ? { PI_TEAMS_TASK_STATUS: t.status } : {}),
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
let res;
|
|
325
|
+
try {
|
|
326
|
+
res = await runWithTimeout({ cmd: hook.cmd, args: hook.args, cwd: opts.cwd, env: envWithTask, timeoutMs });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
329
|
+
return {
|
|
330
|
+
ran: true,
|
|
331
|
+
hookPath: hook.hookPath,
|
|
332
|
+
command: hook.display,
|
|
333
|
+
exitCode: null,
|
|
334
|
+
timedOut: false,
|
|
335
|
+
durationMs: Date.now() - start,
|
|
336
|
+
stdout: "",
|
|
337
|
+
stderr: "",
|
|
338
|
+
error: msg,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
ran: true,
|
|
344
|
+
hookPath: hook.hookPath,
|
|
345
|
+
command: hook.display,
|
|
346
|
+
exitCode: res.exitCode,
|
|
347
|
+
timedOut: res.timedOut,
|
|
348
|
+
durationMs: Date.now() - start,
|
|
349
|
+
stdout: res.stdout,
|
|
350
|
+
stderr: res.stderr,
|
|
351
|
+
error: res.error,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function ensureHooksDirExists(): Promise<void> {
|
|
356
|
+
const dir = getTeamsHooksDir();
|
|
357
|
+
try {
|
|
358
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
359
|
+
} catch (err) {
|
|
360
|
+
// ignore permission errors; caller can surface if needed
|
|
361
|
+
if (isErrnoException(err) && err.code === "EACCES") return;
|
|
362
|
+
}
|
|
363
|
+
}
|