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.
Files changed (52) hide show
  1. package/AGENTS.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/docs/claude-parity.md +151 -0
  5. package/docs/field-notes-teams-setup.md +107 -0
  6. package/docs/smoke-test-plan.md +146 -0
  7. package/eslint.config.js +74 -0
  8. package/extensions/teams/README.md +23 -0
  9. package/extensions/teams/activity-tracker.ts +234 -0
  10. package/extensions/teams/cleanup.ts +31 -0
  11. package/extensions/teams/fs-lock.ts +87 -0
  12. package/extensions/teams/hooks.ts +363 -0
  13. package/extensions/teams/index.ts +18 -0
  14. package/extensions/teams/leader-attach-commands.ts +221 -0
  15. package/extensions/teams/leader-inbox.ts +214 -0
  16. package/extensions/teams/leader-info-commands.ts +140 -0
  17. package/extensions/teams/leader-lifecycle-commands.ts +559 -0
  18. package/extensions/teams/leader-messaging-commands.ts +148 -0
  19. package/extensions/teams/leader-plan-commands.ts +95 -0
  20. package/extensions/teams/leader-spawn-command.ts +149 -0
  21. package/extensions/teams/leader-task-commands.ts +435 -0
  22. package/extensions/teams/leader-team-command.ts +382 -0
  23. package/extensions/teams/leader-teams-tool.ts +1075 -0
  24. package/extensions/teams/leader.ts +925 -0
  25. package/extensions/teams/mailbox.ts +131 -0
  26. package/extensions/teams/model-policy.ts +142 -0
  27. package/extensions/teams/names.ts +121 -0
  28. package/extensions/teams/paths.ts +37 -0
  29. package/extensions/teams/protocol.ts +241 -0
  30. package/extensions/teams/spawn-types.ts +36 -0
  31. package/extensions/teams/task-store.ts +544 -0
  32. package/extensions/teams/team-attach-claim.ts +205 -0
  33. package/extensions/teams/team-config.ts +335 -0
  34. package/extensions/teams/team-discovery.ts +59 -0
  35. package/extensions/teams/teammate-rpc.ts +261 -0
  36. package/extensions/teams/teams-panel.ts +1186 -0
  37. package/extensions/teams/teams-style.ts +322 -0
  38. package/extensions/teams/teams-ui-shared.ts +89 -0
  39. package/extensions/teams/teams-widget.ts +212 -0
  40. package/extensions/teams/worker.ts +605 -0
  41. package/extensions/teams/worktree.ts +103 -0
  42. package/package.json +53 -0
  43. package/scripts/e2e-rpc-test.mjs +277 -0
  44. package/scripts/integration-claim-test.mts +157 -0
  45. package/scripts/integration-hooks-remediation-test.mts +382 -0
  46. package/scripts/integration-spawn-overrides-test.mts +398 -0
  47. package/scripts/integration-todo-test.mts +533 -0
  48. package/scripts/lib/pi-workers.ts +105 -0
  49. package/scripts/smoke-test.mts +764 -0
  50. package/scripts/start-tmux-team.sh +91 -0
  51. package/skills/agent-teams/SKILL.md +180 -0
  52. 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
+ }