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.
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ // Project: pi-teams
1
2
  import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
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
  });
@@ -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
+ }
@@ -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;