pi-teams 0.5.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -71
- package/extensions/index.ts +161 -144
- package/package.json +1 -1
- package/src/adapters/iterm2-adapter.ts +158 -0
- package/src/adapters/terminal-registry.ts +92 -0
- package/src/adapters/tmux-adapter.ts +77 -0
- package/src/adapters/zellij-adapter.ts +62 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lock.test.ts +1 -0
- package/src/utils/lock.ts +4 -2
- package/src/utils/messaging.test.ts +36 -1
- package/src/utils/messaging.ts +35 -0
- package/src/utils/models.ts +4 -1
- package/src/utils/tasks.test.ts +66 -1
- package/src/utils/tasks.ts +78 -6
- package/src/utils/terminal-adapter.ts +85 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iTerm2 Terminal Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the TerminalAdapter interface for iTerm2 terminal emulator.
|
|
5
|
+
* Uses AppleScript for all operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Context needed for iTerm2 spawning (tracks last pane for layout)
|
|
12
|
+
*/
|
|
13
|
+
export interface Iterm2SpawnContext {
|
|
14
|
+
/** ID of the last spawned session, used for layout decisions */
|
|
15
|
+
lastSessionId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class Iterm2Adapter implements TerminalAdapter {
|
|
19
|
+
readonly name = "iTerm2";
|
|
20
|
+
private spawnContext: Iterm2SpawnContext = {};
|
|
21
|
+
|
|
22
|
+
detect(): boolean {
|
|
23
|
+
// iTerm2 is available if TERM_PROGRAM is iTerm.app and not in tmux/zellij
|
|
24
|
+
return process.env.TERM_PROGRAM === "iTerm.app" && !process.env.TMUX && !process.env.ZELLIJ;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
spawn(options: SpawnOptions): string {
|
|
28
|
+
const envStr = Object.entries(options.env)
|
|
29
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
30
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
31
|
+
.join(" ");
|
|
32
|
+
|
|
33
|
+
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
|
|
34
|
+
const escapedCmd = itermCmd.replace(/"/g, '\\"');
|
|
35
|
+
|
|
36
|
+
let script: string;
|
|
37
|
+
|
|
38
|
+
if (!this.spawnContext.lastSessionId) {
|
|
39
|
+
// First teammate: split current session vertically (side-by-side)
|
|
40
|
+
script = `tell application "iTerm2"
|
|
41
|
+
tell current session of current window
|
|
42
|
+
set newSession to split vertically with default profile
|
|
43
|
+
tell newSession
|
|
44
|
+
write text "${escapedCmd}"
|
|
45
|
+
return id
|
|
46
|
+
end tell
|
|
47
|
+
end tell
|
|
48
|
+
end tell`;
|
|
49
|
+
} else {
|
|
50
|
+
// Subsequent teammate: split the last teammate's session horizontally (stacking)
|
|
51
|
+
script = `tell application "iTerm2"
|
|
52
|
+
repeat with aWindow in windows
|
|
53
|
+
repeat with aTab in tabs of aWindow
|
|
54
|
+
repeat with aSession in sessions of aTab
|
|
55
|
+
if id of aSession is "${this.spawnContext.lastSessionId}" then
|
|
56
|
+
tell aSession
|
|
57
|
+
set newSession to split horizontally with default profile
|
|
58
|
+
tell newSession
|
|
59
|
+
write text "${escapedCmd}"
|
|
60
|
+
return id
|
|
61
|
+
end tell
|
|
62
|
+
end tell
|
|
63
|
+
end if
|
|
64
|
+
end repeat
|
|
65
|
+
end repeat
|
|
66
|
+
end repeat
|
|
67
|
+
end tell`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = execCommand("osascript", ["-e", script]);
|
|
71
|
+
|
|
72
|
+
if (result.status !== 0) {
|
|
73
|
+
throw new Error(`osascript failed with status ${result.status}: ${result.stderr}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sessionId = result.stdout.toString().trim();
|
|
77
|
+
this.spawnContext.lastSessionId = sessionId;
|
|
78
|
+
|
|
79
|
+
return `iterm_${sessionId}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
kill(paneId: string): void {
|
|
83
|
+
if (!paneId || !paneId.startsWith("iterm_")) {
|
|
84
|
+
return; // Not an iTerm2 pane
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const itermId = paneId.replace("iterm_", "");
|
|
88
|
+
const script = `tell application "iTerm2"
|
|
89
|
+
repeat with aWindow in windows
|
|
90
|
+
repeat with aTab in tabs of aWindow
|
|
91
|
+
repeat with aSession in sessions of aTab
|
|
92
|
+
if id of aSession is "${itermId}" then
|
|
93
|
+
close aSession
|
|
94
|
+
return "Closed"
|
|
95
|
+
end if
|
|
96
|
+
end repeat
|
|
97
|
+
end repeat
|
|
98
|
+
end repeat
|
|
99
|
+
end tell`;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
execCommand("osascript", ["-e", script]);
|
|
103
|
+
} catch {
|
|
104
|
+
// Ignore errors - session may already be closed
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
isAlive(paneId: string): boolean {
|
|
109
|
+
if (!paneId || !paneId.startsWith("iterm_")) {
|
|
110
|
+
return false; // Not an iTerm2 pane
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const itermId = paneId.replace("iterm_", "");
|
|
114
|
+
const script = `tell application "iTerm2"
|
|
115
|
+
repeat with aWindow in windows
|
|
116
|
+
repeat with aTab in tabs of aWindow
|
|
117
|
+
repeat with aSession in sessions of aTab
|
|
118
|
+
if id of aSession is "${itermId}" then
|
|
119
|
+
return "Alive"
|
|
120
|
+
end if
|
|
121
|
+
end repeat
|
|
122
|
+
end repeat
|
|
123
|
+
end repeat
|
|
124
|
+
end tell`;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const result = execCommand("osascript", ["-e", script]);
|
|
128
|
+
return result.stdout.includes("Alive");
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setTitle(title: string): void {
|
|
135
|
+
try {
|
|
136
|
+
execCommand("osascript", [
|
|
137
|
+
"-e",
|
|
138
|
+
`tell application "iTerm2" to tell current session of current window to set name to "${title}"`
|
|
139
|
+
]);
|
|
140
|
+
} catch {
|
|
141
|
+
// Ignore errors
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Set the spawn context (used to restore state when needed)
|
|
147
|
+
*/
|
|
148
|
+
setSpawnContext(context: Iterm2SpawnContext): void {
|
|
149
|
+
this.spawnContext = context;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get current spawn context (useful for persisting state)
|
|
154
|
+
*/
|
|
155
|
+
getSpawnContext(): Iterm2SpawnContext {
|
|
156
|
+
return { ...this.spawnContext };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal Registry
|
|
3
|
+
*
|
|
4
|
+
* Manages terminal adapters and provides automatic selection based on
|
|
5
|
+
* the current environment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TerminalAdapter } from "../utils/terminal-adapter";
|
|
9
|
+
import { TmuxAdapter } from "./tmux-adapter";
|
|
10
|
+
import { Iterm2Adapter } from "./iterm2-adapter";
|
|
11
|
+
import { ZellijAdapter } from "./zellij-adapter";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Available terminal adapters, ordered by priority
|
|
15
|
+
*/
|
|
16
|
+
const adapters: TerminalAdapter[] = [
|
|
17
|
+
new TmuxAdapter(),
|
|
18
|
+
new Iterm2Adapter(),
|
|
19
|
+
new ZellijAdapter(),
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cached detected adapter
|
|
24
|
+
*/
|
|
25
|
+
let cachedAdapter: TerminalAdapter | null = null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect and return the appropriate terminal adapter for the current environment.
|
|
29
|
+
*
|
|
30
|
+
* Detection order (first match wins):
|
|
31
|
+
* 1. tmux - if TMUX env is set
|
|
32
|
+
* 2. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
|
|
33
|
+
* 3. Zellij - if ZELLIJ env is set and not in tmux
|
|
34
|
+
*
|
|
35
|
+
* @returns The detected terminal adapter, or null if none detected
|
|
36
|
+
*/
|
|
37
|
+
export function getTerminalAdapter(): TerminalAdapter | null {
|
|
38
|
+
if (cachedAdapter) {
|
|
39
|
+
return cachedAdapter;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const adapter of adapters) {
|
|
43
|
+
if (adapter.detect()) {
|
|
44
|
+
cachedAdapter = adapter;
|
|
45
|
+
return adapter;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get a specific terminal adapter by name.
|
|
54
|
+
*
|
|
55
|
+
* @param name - The adapter name (e.g., "tmux", "iTerm2", "zellij")
|
|
56
|
+
* @returns The adapter instance, or undefined if not found
|
|
57
|
+
*/
|
|
58
|
+
export function getAdapterByName(name: string): TerminalAdapter | undefined {
|
|
59
|
+
return adapters.find(a => a.name === name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get all available adapters.
|
|
64
|
+
*
|
|
65
|
+
* @returns Array of all registered adapters
|
|
66
|
+
*/
|
|
67
|
+
export function getAllAdapters(): TerminalAdapter[] {
|
|
68
|
+
return [...adapters];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Clear the cached adapter (useful for testing or environment changes)
|
|
73
|
+
*/
|
|
74
|
+
export function clearAdapterCache(): void {
|
|
75
|
+
cachedAdapter = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Set a specific adapter (useful for testing or forced selection)
|
|
80
|
+
*/
|
|
81
|
+
export function setAdapter(adapter: TerminalAdapter): void {
|
|
82
|
+
cachedAdapter = adapter;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if any terminal adapter is available.
|
|
87
|
+
*
|
|
88
|
+
* @returns true if a terminal adapter was detected
|
|
89
|
+
*/
|
|
90
|
+
export function hasTerminalAdapter(): boolean {
|
|
91
|
+
return getTerminalAdapter() !== null;
|
|
92
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tmux Terminal Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the TerminalAdapter interface for tmux terminal multiplexer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
|
|
9
|
+
|
|
10
|
+
export class TmuxAdapter implements TerminalAdapter {
|
|
11
|
+
readonly name = "tmux";
|
|
12
|
+
|
|
13
|
+
detect(): boolean {
|
|
14
|
+
// tmux is available if TMUX environment variable is set
|
|
15
|
+
return !!process.env.TMUX;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
spawn(options: SpawnOptions): string {
|
|
19
|
+
const envArgs = Object.entries(options.env)
|
|
20
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
21
|
+
.map(([k, v]) => `${k}=${v}`);
|
|
22
|
+
|
|
23
|
+
const tmuxArgs = [
|
|
24
|
+
"split-window",
|
|
25
|
+
"-h", "-dP",
|
|
26
|
+
"-F", "#{pane_id}",
|
|
27
|
+
"-c", options.cwd,
|
|
28
|
+
"env", ...envArgs,
|
|
29
|
+
"sh", "-c", options.command
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const result = execCommand("tmux", tmuxArgs);
|
|
33
|
+
|
|
34
|
+
if (result.status !== 0) {
|
|
35
|
+
throw new Error(`tmux spawn failed with status ${result.status}: ${result.stderr}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Apply layout after spawning
|
|
39
|
+
execCommand("tmux", ["set-window-option", "main-pane-width", "60%"]);
|
|
40
|
+
execCommand("tmux", ["select-layout", "main-vertical"]);
|
|
41
|
+
|
|
42
|
+
return result.stdout.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
kill(paneId: string): void {
|
|
46
|
+
if (!paneId || paneId.startsWith("iterm_") || paneId.startsWith("zellij_")) {
|
|
47
|
+
return; // Not a tmux pane
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
execCommand("tmux", ["kill-pane", "-t", paneId.trim()]);
|
|
52
|
+
} catch {
|
|
53
|
+
// Ignore errors - pane may already be dead
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
isAlive(paneId: string): boolean {
|
|
58
|
+
if (!paneId || paneId.startsWith("iterm_") || paneId.startsWith("zellij_")) {
|
|
59
|
+
return false; // Not a tmux pane
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
execSync(`tmux has-session -t ${paneId}`);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setTitle(title: string): void {
|
|
71
|
+
try {
|
|
72
|
+
execCommand("tmux", ["select-pane", "-T", title]);
|
|
73
|
+
} catch {
|
|
74
|
+
// Ignore errors
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zellij Terminal Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the TerminalAdapter interface for Zellij terminal multiplexer.
|
|
5
|
+
* Note: Zellij uses --close-on-exit, so explicit kill is not needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TerminalAdapter, SpawnOptions, execCommand } from "../utils/terminal-adapter";
|
|
9
|
+
|
|
10
|
+
export class ZellijAdapter implements TerminalAdapter {
|
|
11
|
+
readonly name = "zellij";
|
|
12
|
+
|
|
13
|
+
detect(): boolean {
|
|
14
|
+
// Zellij is available if ZELLIJ env is set and not in tmux
|
|
15
|
+
return !!process.env.ZELLIJ && !process.env.TMUX;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
spawn(options: SpawnOptions): string {
|
|
19
|
+
const zellijArgs = [
|
|
20
|
+
"run",
|
|
21
|
+
"--name", options.name,
|
|
22
|
+
"--cwd", options.cwd,
|
|
23
|
+
"--close-on-exit",
|
|
24
|
+
"--",
|
|
25
|
+
"env",
|
|
26
|
+
...Object.entries(options.env)
|
|
27
|
+
.filter(([k]) => k.startsWith("PI_"))
|
|
28
|
+
.map(([k, v]) => `${k}=${v}`),
|
|
29
|
+
"sh", "-c", options.command
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const result = execCommand("zellij", zellijArgs);
|
|
33
|
+
|
|
34
|
+
if (result.status !== 0) {
|
|
35
|
+
throw new Error(`zellij spawn failed with status ${result.status}: ${result.stderr}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Zellij doesn't return a pane ID, so we create a synthetic one
|
|
39
|
+
return `zellij_${options.name}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
kill(_paneId: string): void {
|
|
43
|
+
// Zellij uses --close-on-exit, so panes close automatically
|
|
44
|
+
// when the process exits. No explicit kill needed.
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
isAlive(paneId: string): boolean {
|
|
48
|
+
// Zellij doesn't have a straightforward way to check if a pane is alive
|
|
49
|
+
// For now, we assume alive if it's a zellij pane ID
|
|
50
|
+
if (!paneId || !paneId.startsWith("zellij_")) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Could potentially use `zellij list-sessions` or similar in the future
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setTitle(_title: string): void {
|
|
59
|
+
// Zellij pane titles are set via --name at spawn time
|
|
60
|
+
// No runtime title changing supported
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { runHook } from "./hooks";
|
|
4
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
describe("runHook", () => {
|
|
7
|
+
const hooksDir = path.join(process.cwd(), ".pi", "team-hooks");
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
if (!fs.existsSync(hooksDir)) {
|
|
11
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
// Optional: Clean up created scripts
|
|
17
|
+
const files = ["success_hook.sh", "fail_hook.sh"];
|
|
18
|
+
files.forEach(f => {
|
|
19
|
+
const p = path.join(hooksDir, f);
|
|
20
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should return true if hook script does not exist", async () => {
|
|
25
|
+
const result = await runHook("test_team", "non_existent_hook", { data: "test" });
|
|
26
|
+
expect(result).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should return true if hook script succeeds", async () => {
|
|
30
|
+
const hookName = "success_hook";
|
|
31
|
+
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
|
|
32
|
+
|
|
33
|
+
// Create a simple script that exits with 0
|
|
34
|
+
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 0", { mode: 0o755 });
|
|
35
|
+
|
|
36
|
+
const result = await runHook("test_team", hookName, { data: "test" });
|
|
37
|
+
expect(result).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return false if hook script fails", async () => {
|
|
41
|
+
const hookName = "fail_hook";
|
|
42
|
+
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
|
|
43
|
+
|
|
44
|
+
// Create a simple script that exits with 1
|
|
45
|
+
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 1", { mode: 0o755 });
|
|
46
|
+
|
|
47
|
+
// Mock console.error to avoid noise in test output
|
|
48
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
49
|
+
|
|
50
|
+
const result = await runHook("test_team", hookName, { data: "test" });
|
|
51
|
+
expect(result).toBe(false);
|
|
52
|
+
|
|
53
|
+
consoleSpy.mockRestore();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should pass the payload to the hook script", async () => {
|
|
57
|
+
const hookName = "payload_hook";
|
|
58
|
+
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
|
|
59
|
+
const outputFile = path.join(hooksDir, "payload_output.txt");
|
|
60
|
+
|
|
61
|
+
// Create a script that writes its first argument to a file
|
|
62
|
+
fs.writeFileSync(scriptPath, `#!/bin/bash\necho "$1" > "${outputFile}"`, { mode: 0o755 });
|
|
63
|
+
|
|
64
|
+
const payload = { key: "value", "special'char": true };
|
|
65
|
+
const result = await runHook("test_team", hookName, payload);
|
|
66
|
+
|
|
67
|
+
expect(result).toBe(true);
|
|
68
|
+
const output = fs.readFileSync(outputFile, "utf-8").trim();
|
|
69
|
+
expect(JSON.parse(output)).toEqual(payload);
|
|
70
|
+
|
|
71
|
+
// Clean up
|
|
72
|
+
fs.unlinkSync(scriptPath);
|
|
73
|
+
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runs a hook script asynchronously if it exists.
|
|
10
|
+
* Hooks are located in .pi/team-hooks/{hookName}.sh relative to the CWD.
|
|
11
|
+
*
|
|
12
|
+
* @param teamName The name of the team.
|
|
13
|
+
* @param hookName The name of the hook to run (e.g., 'task_completed').
|
|
14
|
+
* @param payload The payload to pass to the hook script as the first argument.
|
|
15
|
+
* @returns true if the hook doesn't exist or executes successfully; false otherwise.
|
|
16
|
+
*/
|
|
17
|
+
export async function runHook(teamName: string, hookName: string, payload: any): Promise<boolean> {
|
|
18
|
+
const hookPath = path.join(process.cwd(), ".pi", "team-hooks", `${hookName}.sh`);
|
|
19
|
+
|
|
20
|
+
if (!fs.existsSync(hookPath)) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const payloadStr = JSON.stringify(payload);
|
|
26
|
+
// Use execFile: More secure (no shell interpolation) and asynchronous
|
|
27
|
+
await execFileAsync(hookPath, [payloadStr], {
|
|
28
|
+
env: { ...process.env, PI_TEAM: teamName },
|
|
29
|
+
});
|
|
30
|
+
return true;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(`Hook ${hookName} failed:`, error);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/utils/lock.test.ts
CHANGED
package/src/utils/lock.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
// Project: pi-teams
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
5
|
+
const LOCK_TIMEOUT = 5000; // 5 seconds of retrying
|
|
6
|
+
const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale
|
|
7
|
+
|
|
4
8
|
export async function withLock<T>(lockPath: string, fn: () => Promise<T>, retries: number = 50): Promise<T> {
|
|
5
9
|
const lockFile = `${lockPath}.lock`;
|
|
6
|
-
const LOCK_TIMEOUT = 5000; // 5 seconds of retrying
|
|
7
|
-
const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale
|
|
8
10
|
|
|
9
11
|
while (retries > 0) {
|
|
10
12
|
try {
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
5
|
-
import { appendMessage, readInbox, sendPlainMessage } from "./messaging";
|
|
5
|
+
import { appendMessage, readInbox, sendPlainMessage, broadcastMessage } from "./messaging";
|
|
6
6
|
import * as paths from "./paths";
|
|
7
7
|
|
|
8
8
|
// Mock the paths to use a temporary directory
|
|
@@ -18,6 +18,9 @@ describe("Messaging Utilities", () => {
|
|
|
18
18
|
return path.join(testDir, "inboxes", `${agentName}.json`);
|
|
19
19
|
});
|
|
20
20
|
vi.spyOn(paths, "teamDir").mockReturnValue(testDir);
|
|
21
|
+
vi.spyOn(paths, "configPath").mockImplementation((teamName) => {
|
|
22
|
+
return path.join(testDir, "config.json");
|
|
23
|
+
});
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
afterEach(() => {
|
|
@@ -66,4 +69,36 @@ describe("Messaging Utilities", () => {
|
|
|
66
69
|
expect(all.length).toBe(2);
|
|
67
70
|
expect(all.every(m => m.read)).toBe(true);
|
|
68
71
|
});
|
|
72
|
+
|
|
73
|
+
it("should broadcast message to all members except the sender", async () => {
|
|
74
|
+
// Setup team config
|
|
75
|
+
const config = {
|
|
76
|
+
name: "test-team",
|
|
77
|
+
members: [
|
|
78
|
+
{ name: "sender" },
|
|
79
|
+
{ name: "member1" },
|
|
80
|
+
{ name: "member2" }
|
|
81
|
+
]
|
|
82
|
+
};
|
|
83
|
+
const configFilePath = path.join(testDir, "config.json");
|
|
84
|
+
fs.writeFileSync(configFilePath, JSON.stringify(config));
|
|
85
|
+
|
|
86
|
+
await broadcastMessage("test-team", "sender", "broadcast text", "summary");
|
|
87
|
+
|
|
88
|
+
// Check member1's inbox
|
|
89
|
+
const inbox1 = await readInbox("test-team", "member1", false, false);
|
|
90
|
+
expect(inbox1.length).toBe(1);
|
|
91
|
+
expect(inbox1[0].text).toBe("broadcast text");
|
|
92
|
+
expect(inbox1[0].from).toBe("sender");
|
|
93
|
+
|
|
94
|
+
// Check member2's inbox
|
|
95
|
+
const inbox2 = await readInbox("test-team", "member2", false, false);
|
|
96
|
+
expect(inbox2.length).toBe(1);
|
|
97
|
+
expect(inbox2[0].text).toBe("broadcast text");
|
|
98
|
+
expect(inbox2[0].from).toBe("sender");
|
|
99
|
+
|
|
100
|
+
// Check sender's inbox (should be empty)
|
|
101
|
+
const inboxSender = await readInbox("test-team", "sender", false, false);
|
|
102
|
+
expect(inboxSender.length).toBe(0);
|
|
103
|
+
});
|
|
69
104
|
});
|
package/src/utils/messaging.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { InboxMessage } from "./models";
|
|
4
4
|
import { withLock } from "./lock";
|
|
5
5
|
import { inboxPath } from "./paths";
|
|
6
|
+
import { readConfig } from "./teams";
|
|
6
7
|
|
|
7
8
|
export function nowIso(): string {
|
|
8
9
|
return new Date().toISOString();
|
|
@@ -71,3 +72,37 @@ export async function sendPlainMessage(
|
|
|
71
72
|
};
|
|
72
73
|
await appendMessage(teamName, toName, msg);
|
|
73
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Broadcasts a message to all team members except the sender.
|
|
78
|
+
* @param teamName The name of the team
|
|
79
|
+
* @param fromName The name of the sender
|
|
80
|
+
* @param text The message text
|
|
81
|
+
* @param summary A short summary of the message
|
|
82
|
+
* @param color An optional color for the message
|
|
83
|
+
*/
|
|
84
|
+
export async function broadcastMessage(
|
|
85
|
+
teamName: string,
|
|
86
|
+
fromName: string,
|
|
87
|
+
text: string,
|
|
88
|
+
summary: string,
|
|
89
|
+
color?: string
|
|
90
|
+
) {
|
|
91
|
+
const config = await readConfig(teamName);
|
|
92
|
+
|
|
93
|
+
// Create an array of delivery promises for all members except the sender
|
|
94
|
+
const deliveryPromises = config.members
|
|
95
|
+
.filter((member) => member.name !== fromName)
|
|
96
|
+
.map((member) => sendPlainMessage(teamName, fromName, member.name, text, summary, color));
|
|
97
|
+
|
|
98
|
+
// Execute deliveries in parallel and wait for all to settle
|
|
99
|
+
const results = await Promise.allSettled(deliveryPromises);
|
|
100
|
+
|
|
101
|
+
// Log failures for diagnostics
|
|
102
|
+
const failures = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
|
|
103
|
+
if (failures.length > 0) {
|
|
104
|
+
console.error(`Broadcast partially failed: ${failures.length} messages could not be delivered.`);
|
|
105
|
+
// Optionally log individual errors
|
|
106
|
+
failures.forEach((f) => console.error(`- Delivery error:`, f.reason));
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/utils/models.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface Member {
|
|
|
9
9
|
subscriptions: any[];
|
|
10
10
|
prompt?: string;
|
|
11
11
|
color?: string;
|
|
12
|
+
thinking?: "off" | "minimal" | "low" | "medium" | "high";
|
|
12
13
|
planModeRequired?: boolean;
|
|
13
14
|
backendType?: string;
|
|
14
15
|
isActive?: boolean;
|
|
@@ -29,7 +30,9 @@ export interface TaskFile {
|
|
|
29
30
|
subject: string;
|
|
30
31
|
description: string;
|
|
31
32
|
activeForm?: string;
|
|
32
|
-
status: "pending" | "in_progress" | "completed" | "deleted";
|
|
33
|
+
status: "pending" | "planning" | "in_progress" | "completed" | "deleted";
|
|
34
|
+
plan?: string;
|
|
35
|
+
planFeedback?: string;
|
|
33
36
|
blocks: string[];
|
|
34
37
|
blockedBy: string[];
|
|
35
38
|
owner?: string;
|