pi-teams 0.8.7 → 0.9.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.
@@ -5,6 +5,7 @@ import * as paths from "../src/utils/paths";
5
5
  import * as teams from "../src/utils/teams";
6
6
  import * as tasks from "../src/utils/tasks";
7
7
  import * as messaging from "../src/utils/messaging";
8
+ import * as runtime from "../src/utils/runtime";
8
9
  import { Member } from "../src/utils/models";
9
10
  import { getTerminalAdapter } from "../src/adapters/terminal-registry";
10
11
  import { Iterm2Adapter } from "../src/adapters/iterm2-adapter";
@@ -154,6 +155,13 @@ export default function (pi: ExtensionAPI) {
154
155
  if (teamName) {
155
156
  const pidFile = path.join(paths.teamDir(teamName), `${agentName}.pid`);
156
157
  fs.writeFileSync(pidFile, process.pid.toString());
158
+ await runtime.writeRuntimeStatus(teamName, agentName, {
159
+ pid: process.pid,
160
+ startedAt: Date.now(),
161
+ lastHeartbeatAt: Date.now(),
162
+ ready: false,
163
+ lastError: undefined,
164
+ });
157
165
  }
158
166
  ctx.ui.notify(`Teammate: ${agentName} (Team: ${teamName})`, "info");
159
167
  ctx.ui.setStatus("00-pi-teams", `[${agentName.toUpperCase()}]`);
@@ -176,9 +184,19 @@ export default function (pi: ExtensionAPI) {
176
184
 
177
185
  setInterval(async () => {
178
186
  if (ctx.isIdle() && teamName) {
179
- const unread = await messaging.readInbox(teamName, agentName, true, false);
180
- if (unread.length > 0) {
181
- pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
187
+ try {
188
+ const unread = await messaging.readInbox(teamName, agentName, true, false);
189
+ await runtime.writeRuntimeStatus(teamName, agentName, {
190
+ lastHeartbeatAt: Date.now(),
191
+ });
192
+ if (unread.length > 0) {
193
+ pi.sendUserMessage(`I have ${unread.length} new message(s) in my inbox. Reading them now...`);
194
+ }
195
+ } catch (e) {
196
+ await runtime.writeRuntimeStatus(teamName, agentName, {
197
+ lastHeartbeatAt: Date.now(),
198
+ lastError: runtime.createRuntimeError(e),
199
+ });
182
200
  }
183
201
  }
184
202
  }, 30000);
@@ -192,6 +210,11 @@ export default function (pi: ExtensionAPI) {
192
210
  const fullTitle = teamName ? `${teamName}: ${agentName}` : agentName;
193
211
  if ((ctx.ui as any).setTitle) (ctx.ui as any).setTitle(fullTitle);
194
212
  if (terminal) terminal.setTitle(fullTitle);
213
+ if (teamName) {
214
+ await runtime.writeRuntimeStatus(teamName, agentName, {
215
+ lastHeartbeatAt: Date.now(),
216
+ });
217
+ }
195
218
  }
196
219
  });
197
220
 
@@ -200,6 +223,12 @@ export default function (pi: ExtensionAPI) {
200
223
  if (isTeammate && firstTurn) {
201
224
  firstTurn = false;
202
225
 
226
+ if (teamName) {
227
+ await runtime.writeRuntimeStatus(teamName, agentName, {
228
+ lastHeartbeatAt: Date.now(),
229
+ });
230
+ }
231
+
203
232
  let modelInfo = "";
204
233
  if (teamName) {
205
234
  try {
@@ -244,6 +273,8 @@ export default function (pi: ExtensionAPI) {
244
273
  if (member.tmuxPaneId && terminal) {
245
274
  terminal.kill(member.tmuxPaneId);
246
275
  }
276
+
277
+ await runtime.deleteRuntimeStatus(teamName, member.name);
247
278
  }
248
279
 
249
280
  // Tools
@@ -480,6 +511,16 @@ export default function (pi: ExtensionAPI) {
480
511
  async execute(toolCallId, params: any, signal, onUpdate, ctx) {
481
512
  const targetAgent = params.agent_name || agentName;
482
513
  const msgs = await messaging.readInbox(params.team_name, targetAgent, params.unread_only);
514
+
515
+ if (isTeammate && teamName && params.team_name === teamName && targetAgent === agentName) {
516
+ await runtime.writeRuntimeStatus(teamName, agentName, {
517
+ lastHeartbeatAt: Date.now(),
518
+ lastInboxReadAt: Date.now(),
519
+ ready: true,
520
+ lastError: undefined,
521
+ });
522
+ }
523
+
483
524
  return {
484
525
  content: [{ type: "text", text: JSON.stringify(msgs, null, 2) }],
485
526
  details: { messages: msgs },
@@ -643,9 +684,40 @@ export default function (pi: ExtensionAPI) {
643
684
  }
644
685
 
645
686
  const unreadCount = (await messaging.readInbox(params.team_name, params.agent_name, true, false)).length;
687
+ const runtimeStatus = await runtime.readRuntimeStatus(params.team_name, params.agent_name);
688
+ const now = Date.now();
689
+ const hasRecentHeartbeat = !!runtimeStatus?.lastHeartbeatAt
690
+ && (now - runtimeStatus.lastHeartbeatAt) <= runtime.HEARTBEAT_STALE_MS;
691
+ const startupStalled = alive
692
+ && unreadCount > 0
693
+ && (now - member.joinedAt) > runtime.STARTUP_STALL_MS
694
+ && !(runtimeStatus?.ready);
695
+ const health = !alive
696
+ ? "dead"
697
+ : startupStalled
698
+ ? "stalled"
699
+ : runtimeStatus?.ready
700
+ ? (hasRecentHeartbeat ? "healthy" : "idle")
701
+ : "starting";
702
+
703
+ const details = {
704
+ alive,
705
+ unreadCount,
706
+ health,
707
+ agentLoopReady: !!runtimeStatus?.ready,
708
+ hasRecentHeartbeat,
709
+ startupStalled,
710
+ runtime: runtimeStatus,
711
+ };
712
+
713
+ // Clean up runtime status for dead teammates
714
+ if (!alive && runtimeStatus) {
715
+ await runtime.deleteRuntimeStatus(params.team_name, params.agent_name);
716
+ }
717
+
646
718
  return {
647
- content: [{ type: "text", text: JSON.stringify({ alive, unreadCount }, null, 2) }],
648
- details: { alive, unreadCount },
719
+ content: [{ type: "text", text: JSON.stringify(details, null, 2) }],
720
+ details,
649
721
  };
650
722
  },
651
723
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-teams",
3
- "version": "0.8.7",
3
+ "version": "0.9.0",
4
4
  "description": "Agent teams for pi, ported from claude-code-teams-mcp",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,22 +2,44 @@
2
2
  * Windows Adapter Tests
3
3
  */
4
4
 
5
- import { describe, it, expect, beforeEach, vi } from "vitest";
5
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
6
+
7
+ // Mock at module level - this is hoisted by Vitest
8
+ vi.mock("../utils/terminal-adapter", () => ({
9
+ execCommand: vi.fn(),
10
+ TerminalAdapter: class {},
11
+ }));
12
+
13
+ // Import after mock
14
+ import { execCommand } from "../utils/terminal-adapter";
6
15
  import { WindowsAdapter } from "./windows-adapter";
7
16
 
8
- // Mock process.platform for Windows tests
17
+ const mockExecCommand = vi.mocked(execCommand);
18
+
9
19
  const originalPlatform = process.platform;
10
20
 
11
21
  describe("WindowsAdapter", () => {
12
22
  let adapter: WindowsAdapter;
13
- let mockExecCommand: ReturnType<typeof vi.fn>;
14
23
 
15
24
  beforeEach(() => {
25
+ // Create a fresh adapter for each test
16
26
  adapter = new WindowsAdapter();
17
27
  vi.resetAllMocks();
18
28
  vi.clearAllMocks();
19
29
 
20
- // Save original process.platform
30
+ // Reset environment
31
+ delete process.env.TMUX;
32
+ delete process.env.ZELLIJ;
33
+ delete process.env.WEZTERM_PANE;
34
+
35
+ Object.defineProperty(process, "platform", {
36
+ value: originalPlatform,
37
+ writable: true,
38
+ configurable: true,
39
+ });
40
+ });
41
+
42
+ afterEach(() => {
21
43
  Object.defineProperty(process, "platform", {
22
44
  value: originalPlatform,
23
45
  writable: true,
@@ -34,22 +56,7 @@ describe("WindowsAdapter", () => {
34
56
  describe("detect()", () => {
35
57
  it("should detect when on Windows and wt is available", () => {
36
58
  Object.defineProperty(process, "platform", { value: "win32" });
37
- delete process.env.TMUX;
38
- delete process.env.ZELLIJ;
39
- delete process.env.WEZTERM_PANE;
40
-
41
- // Mock successful wt --version
42
- vi.mock("../utils/terminal-adapter", async () => {
43
- const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
44
- return {
45
- ...actual,
46
- execCommand: vi.fn().mockReturnValue({ stdout: "Windows Terminal", status: 0 }),
47
- };
48
- });
49
-
50
- const { execCommand } = require("../utils/terminal-adapter");
51
- // Create new adapter to use mocked execCommand
52
- adapter = new WindowsAdapter();
59
+ mockExecCommand.mockReturnValue({ stdout: "Windows Terminal", status: 0 });
53
60
 
54
61
  expect(adapter.detect()).toBe(true);
55
62
  });
@@ -85,17 +92,12 @@ describe("WindowsAdapter", () => {
85
92
  describe("spawn()", () => {
86
93
  it("should spawn first pane on Windows", () => {
87
94
  Object.defineProperty(process, "platform", { value: "win32" });
88
-
89
- vi.mock("../utils/terminal-adapter", async () => {
90
- const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
91
- return {
92
- ...actual,
93
- execCommand: vi.fn().mockReturnValue({ stdout: "pane-id", status: 0 }),
94
- };
95
- });
96
-
97
- const { execCommand } = require("../utils/terminal-adapter");
98
- adapter = new WindowsAdapter();
95
+ // Mock findWtBinary check
96
+ mockExecCommand.mockReturnValueOnce({ stdout: "wt", status: 0 });
97
+ // Mock getPanes - no existing panes
98
+ mockExecCommand.mockReturnValueOnce({ stdout: "[]", status: 0 });
99
+ // Mock actual spawn
100
+ mockExecCommand.mockReturnValueOnce({ stdout: "", status: 0 });
99
101
 
100
102
  const paneId = adapter.spawn({
101
103
  name: "test-agent",
@@ -104,210 +106,139 @@ describe("WindowsAdapter", () => {
104
106
  env: { PI_TEAM_NAME: "team1", PI_AGENT_NAME: "agent1" },
105
107
  });
106
108
 
109
+ // Returns synthetic ID: windows_<timestamp>_<name>
107
110
  expect(paneId).toMatch(/^windows_\d+_test-agent$/);
108
- expect(execCommand).toHaveBeenCalledWith(
109
- "wt",
110
- expect.arrayContaining([
111
- "split-pane",
112
- "--vertical",
113
- "--size", "50%",
114
- "--", "pwsh", "-NoExit", "-Command",
115
- ])
116
- );
117
111
  });
118
112
 
119
113
  it("should spawn subsequent pane on Windows", () => {
120
114
  Object.defineProperty(process, "platform", { value: "win32" });
121
-
122
- vi.mock("../utils/terminal-adapter", async () => {
123
- const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
124
- return {
125
- ...actual,
126
- execCommand: vi.fn()
127
- .mockReturnValueOnce({ stdout: "pane-id", status: 0 }) // wt --version
128
- .mockReturnValueOnce({ stdout: '[{"window":1,"pane":1},{"window":1,"pane":2}]', status: 0 }) // wt list
129
- .mockReturnValue({ stdout: "pane-id", status: 0 }), // split-pane
130
- };
131
- });
132
-
133
- const { execCommand } = require("../utils/terminal-adapter");
134
- adapter = new WindowsAdapter();
115
+ // Mock findWtBinary check
116
+ mockExecCommand.mockReturnValueOnce({ stdout: "wt", status: 0 });
117
+ // Mock getPanes - existing panes
118
+ mockExecCommand.mockReturnValueOnce({ stdout: '[{"window":1}]', status: 0 });
119
+ // Mock actual spawn
120
+ mockExecCommand.mockReturnValueOnce({ stdout: "", status: 0 });
135
121
 
136
122
  const paneId = adapter.spawn({
137
- name: "test-agent",
123
+ name: "test-agent-2",
138
124
  cwd: "/test/path",
139
125
  command: "pi --model gpt-4",
140
126
  env: { PI_TEAM_NAME: "team1", PI_AGENT_NAME: "agent1" },
141
127
  });
142
128
 
143
- expect(paneId).toMatch(/^windows_\d+_test-agent$/);
144
- expect(execCommand).toHaveBeenCalledWith(
145
- "wt",
146
- expect.arrayContaining(["split-pane", "--horizontal"])
147
- );
129
+ // Returns synthetic ID: windows_<timestamp>_<name>
130
+ expect(paneId).toMatch(/^windows_\d+_test-agent-2$/);
148
131
  });
149
132
 
150
133
  it("should throw error when wt binary not found", () => {
151
134
  Object.defineProperty(process, "platform", { value: "win32" });
152
-
153
- vi.mock("../utils/terminal-adapter", async () => {
154
- const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
155
- return {
156
- ...actual,
157
- execCommand: vi.fn().mockReturnValue({ stdout: "", status: 1 }),
158
- };
159
- });
160
-
161
- const { execCommand } = require("../utils/terminal-adapter");
162
- adapter = new WindowsAdapter();
163
-
164
- expect(() => adapter.spawn({
165
- name: "test-agent",
166
- cwd: "/test/path",
167
- command: "pi",
168
- env: {},
169
- })).toThrow("Windows Terminal (wt) CLI binary not found");
135
+ // Mock findWtBinary - not found
136
+ mockExecCommand.mockReturnValue({ stdout: "", status: 1 });
137
+
138
+ expect(() =>
139
+ adapter.spawn({
140
+ name: "test-agent",
141
+ cwd: "/test/path",
142
+ command: "pi",
143
+ env: {},
144
+ })
145
+ ).toThrow();
170
146
  });
171
147
  });
172
148
 
173
149
  describe("supportsWindows()", () => {
174
150
  it("should return true when wt is available", () => {
175
151
  Object.defineProperty(process, "platform", { value: "win32" });
176
-
177
- vi.mock("../utils/terminal-adapter", async () => {
178
- const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
179
- return {
180
- ...actual,
181
- execCommand: vi.fn().mockReturnValue({ stdout: "version", status: 0 }),
182
- };
183
- });
184
-
185
- const { execCommand } = require("../utils/terminal-adapter");
186
- adapter = new WindowsAdapter();
152
+ mockExecCommand.mockReturnValue({ stdout: "wt found", status: 0 });
187
153
 
188
154
  expect(adapter.supportsWindows()).toBe(true);
189
155
  });
190
156
 
191
- it("should return false when wt not available", () => {
157
+ it("should return true on Windows even if wt check fails (fallback)", () => {
192
158
  Object.defineProperty(process, "platform", { value: "win32" });
159
+ // The Windows adapter has a fallback that assumes wt exists on Windows
160
+ mockExecCommand.mockReturnValue({ stdout: "", status: 1 });
193
161
 
194
- vi.mock("../utils/terminal-adapter", async () => {
195
- const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
196
- return {
197
- ...actual,
198
- execCommand: vi.fn().mockReturnValue({ stdout: "", status: 1 }),
199
- };
200
- });
201
-
202
- const { execCommand } = require("../utils/terminal-adapter");
203
- adapter = new WindowsAdapter();
204
-
205
- expect(adapter.supportsWindows()).toBe(false);
162
+ // On Windows, the adapter falls back to assuming wt exists
163
+ expect(adapter.supportsWindows()).toBe(true);
206
164
  });
207
165
  });
208
166
 
209
167
  describe("spawnWindow()", () => {
210
168
  it("should spawn a new window", () => {
211
169
  Object.defineProperty(process, "platform", { value: "win32" });
212
-
213
- vi.mock("../utils/terminal-adapter", async () => {
214
- const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
215
- return {
216
- ...actual,
217
- execCommand: vi.fn().mockReturnValue({ stdout: "window-id", status: 0 }),
218
- };
219
- });
220
-
221
- const { execCommand } = require("../utils/terminal-adapter");
222
- adapter = new WindowsAdapter();
170
+ // Mock findWtBinary check
171
+ mockExecCommand.mockReturnValueOnce({ stdout: "wt", status: 0 });
172
+ // Mock actual spawn
173
+ mockExecCommand.mockReturnValueOnce({ stdout: "", status: 0 });
223
174
 
224
175
  const windowId = adapter.spawnWindow({
225
- name: "agent",
226
- cwd: "/test",
176
+ name: "team-lead",
177
+ cwd: "/test/path",
227
178
  command: "pi",
228
- env: {},
179
+ env: { PI_TEAM_NAME: "team1", PI_AGENT_NAME: "team-lead" },
229
180
  teamName: "team1",
230
181
  });
231
182
 
232
- expect(windowId).toMatch(/^windows_win_\d+_agent$/);
233
- expect(execCommand).toHaveBeenCalledWith(
234
- "wt",
235
- expect.arrayContaining([
236
- "new-window",
237
- "--title", "team1: agent",
238
- ])
239
- );
183
+ // Returns synthetic ID: windows_win_<timestamp>_<name>
184
+ expect(windowId).toMatch(/^windows_win_\d+_team-lead$/);
240
185
  });
241
186
  });
242
187
 
243
188
  describe("kill()", () => {
244
189
  it("should handle kill gracefully for windows pane", () => {
245
- adapter.kill("windows_123_agent");
246
- // Should not throw, just silently do nothing
247
- expect(true).toBe(true);
190
+ // Should not throw
191
+ adapter.kill("windows_123_test-pane");
248
192
  });
249
193
 
250
194
  it("should ignore non-windows pane IDs", () => {
251
- adapter.kill("tmux_123");
252
- // Should not throw, just silently do nothing
253
- expect(true).toBe(true);
195
+ // Should not throw for non-windows pane IDs
196
+ adapter.kill("tmux_pane-123");
197
+ adapter.kill("iterm_tab-456");
254
198
  });
255
199
  });
256
200
 
257
201
  describe("killWindow()", () => {
258
202
  it("should handle killWindow gracefully", () => {
259
- adapter.killWindow("windows_win_123_agent");
260
- // Should not throw, just silently do nothing
261
- expect(true).toBe(true);
203
+ // Should not throw
204
+ adapter.killWindow("windows_win_123_test-window");
262
205
  });
263
206
  });
264
207
 
265
208
  describe("isAlive()", () => {
266
209
  it("should return true for windows pane ID", () => {
267
- expect(adapter.isAlive("windows_123_agent")).toBe(true);
210
+ expect(adapter.isAlive("windows_123_test")).toBe(true);
268
211
  });
269
212
 
270
213
  it("should return false for non-windows pane ID", () => {
271
- expect(adapter.isAlive("tmux_123")).toBe(false);
214
+ expect(adapter.isAlive("tmux_pane-123")).toBe(false);
272
215
  });
273
216
  });
274
217
 
275
218
  describe("isWindowAlive()", () => {
276
219
  it("should return true for windows window ID", () => {
277
- expect(adapter.isWindowAlive("windows_win_123_agent")).toBe(true);
220
+ expect(adapter.isWindowAlive("windows_win_123_test")).toBe(true);
278
221
  });
279
222
 
280
223
  it("should return false for non-windows window ID", () => {
281
- expect(adapter.isWindowAlive("other_123")).toBe(false);
224
+ expect(adapter.isWindowAlive("iterm_window-123")).toBe(false);
282
225
  });
283
226
  });
284
227
 
285
228
  describe("setTitle()", () => {
286
229
  it("should set tab title gracefully", () => {
287
230
  Object.defineProperty(process, "platform", { value: "win32" });
231
+ mockExecCommand.mockReturnValue({ stdout: "", status: 0 });
288
232
 
289
- vi.mock("../utils/terminal-adapter", async () => {
290
- const actual = await vi.importActual<typeof import("../utils/terminal-adapter")>("../utils/terminal-adapter");
291
- return {
292
- ...actual,
293
- execCommand: vi.fn().mockReturnValue({ stdout: "", status: 0 }),
294
- };
295
- });
296
-
297
- const { execCommand } = require("../utils/terminal-adapter");
298
- adapter = new WindowsAdapter();
299
-
300
- adapter.setTitle("Test Title");
301
- expect(execCommand).toHaveBeenCalledWith("wt", ["set-tab-title", "Test Title"]);
233
+ // Should not throw
234
+ adapter.setTitle("windows_123_test", "New Title");
302
235
  });
303
236
  });
304
237
 
305
238
  describe("setWindowTitle()", () => {
306
239
  it("should gracefully handle setWindowTitle limitation", () => {
307
- adapter.setWindowTitle("windows_win_123", "Test Title");
308
- // Windows Terminal limitation - titles are set at spawn time
309
- // Should silently do nothing without throwing
310
- expect(true).toBe(true);
240
+ // Windows adapter doesn't support setWindowTitle, should not throw
241
+ adapter.setWindowTitle("windows_win_123_test", "New Title");
311
242
  });
312
243
  });
313
- });
244
+ });
@@ -32,6 +32,10 @@ export function inboxPath(teamName: string, agentName: string) {
32
32
  return path.join(teamDir(teamName), "inboxes", `${sanitizeName(agentName)}.json`);
33
33
  }
34
34
 
35
+ export function runtimeStatusPath(teamName: string, agentName: string) {
36
+ return path.join(teamDir(teamName), "runtime", `${sanitizeName(agentName)}.json`);
37
+ }
38
+
35
39
  export function configPath(teamName: string) {
36
40
  return path.join(teamDir(teamName), "config.json");
37
41
  }
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ writeRuntimeStatus,
6
+ readRuntimeStatus,
7
+ deleteRuntimeStatus,
8
+ cleanupStaleRuntimeFiles,
9
+ createRuntimeError,
10
+ HEARTBEAT_STALE_MS,
11
+ STARTUP_STALL_MS,
12
+ RUNTIME_STALE_MS,
13
+ } from "./runtime";
14
+ import { runtimeStatusPath, teamDir } from "./paths";
15
+
16
+ describe("runtime status", () => {
17
+ const teamName = `runtime-test-${Date.now()}`;
18
+ const agentName = "worker-1";
19
+
20
+ beforeEach(() => {
21
+ const dir = teamDir(teamName);
22
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
23
+ });
24
+
25
+ afterEach(() => {
26
+ const dir = teamDir(teamName);
27
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ it("writes and reads status", async () => {
31
+ await writeRuntimeStatus(teamName, agentName, {
32
+ pid: 123,
33
+ startedAt: 1000,
34
+ ready: false,
35
+ });
36
+
37
+ const runtime = await readRuntimeStatus(teamName, agentName);
38
+ expect(runtime).not.toBeNull();
39
+ expect(runtime?.teamName).toBe(teamName);
40
+ expect(runtime?.agentName).toBe(agentName);
41
+ expect(runtime?.pid).toBe(123);
42
+ expect(runtime?.ready).toBe(false);
43
+ });
44
+
45
+ it("merges updates instead of overwriting status", async () => {
46
+ await writeRuntimeStatus(teamName, agentName, {
47
+ pid: 123,
48
+ startedAt: 1000,
49
+ ready: false,
50
+ });
51
+
52
+ await writeRuntimeStatus(teamName, agentName, {
53
+ lastHeartbeatAt: 2000,
54
+ ready: true,
55
+ });
56
+
57
+ const runtime = await readRuntimeStatus(teamName, agentName);
58
+ expect(runtime?.pid).toBe(123);
59
+ expect(runtime?.startedAt).toBe(1000);
60
+ expect(runtime?.lastHeartbeatAt).toBe(2000);
61
+ expect(runtime?.ready).toBe(true);
62
+ });
63
+
64
+ it("returns null when status does not exist", async () => {
65
+ const missing = await readRuntimeStatus(teamName, "missing-agent");
66
+ expect(missing).toBeNull();
67
+ });
68
+
69
+ it("stores status in team runtime directory", async () => {
70
+ await writeRuntimeStatus(teamName, agentName, { ready: true });
71
+ const p = runtimeStatusPath(teamName, agentName);
72
+ expect(path.basename(path.dirname(p))).toBe("runtime");
73
+ expect(fs.existsSync(p)).toBe(true);
74
+ });
75
+
76
+ describe("deleteRuntimeStatus", () => {
77
+ it("deletes existing runtime status", async () => {
78
+ await writeRuntimeStatus(teamName, agentName, { ready: true });
79
+ const deleted = await deleteRuntimeStatus(teamName, agentName);
80
+ expect(deleted).toBe(true);
81
+
82
+ const runtime = await readRuntimeStatus(teamName, agentName);
83
+ expect(runtime).toBeNull();
84
+ });
85
+
86
+ it("returns false when status does not exist", async () => {
87
+ const deleted = await deleteRuntimeStatus(teamName, "nonexistent");
88
+ expect(deleted).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe("cleanupStaleRuntimeFiles", () => {
93
+ it("removes stale runtime files with old heartbeats", async () => {
94
+ const staleTime = Date.now() - RUNTIME_STALE_MS - 1000;
95
+ await writeRuntimeStatus(teamName, "stale-agent", {
96
+ startedAt: staleTime,
97
+ lastHeartbeatAt: staleTime,
98
+ ready: true,
99
+ });
100
+
101
+ const cleaned = await cleanupStaleRuntimeFiles(teamName);
102
+ expect(cleaned).toBe(1);
103
+
104
+ const runtime = await readRuntimeStatus(teamName, "stale-agent");
105
+ expect(runtime).toBeNull();
106
+ });
107
+
108
+ it("preserves runtime files with recent heartbeats", async () => {
109
+ await writeRuntimeStatus(teamName, agentName, {
110
+ startedAt: Date.now() - RUNTIME_STALE_MS - 1000,
111
+ lastHeartbeatAt: Date.now(),
112
+ ready: true,
113
+ });
114
+
115
+ const cleaned = await cleanupStaleRuntimeFiles(teamName);
116
+ expect(cleaned).toBe(0);
117
+
118
+ const runtime = await readRuntimeStatus(teamName, agentName);
119
+ expect(runtime).not.toBeNull();
120
+ });
121
+
122
+ it("removes corrupted files", async () => {
123
+ const runtimeDir = path.join(teamDir(teamName), "runtime");
124
+ if (!fs.existsSync(runtimeDir)) fs.mkdirSync(runtimeDir, { recursive: true });
125
+ fs.writeFileSync(path.join(runtimeDir, "corrupted.json"), "not valid json");
126
+
127
+ const cleaned = await cleanupStaleRuntimeFiles(teamName);
128
+ expect(cleaned).toBe(1);
129
+ });
130
+
131
+ it("returns 0 when no runtime directory exists", async () => {
132
+ const cleaned = await cleanupStaleRuntimeFiles("nonexistent-team");
133
+ expect(cleaned).toBe(0);
134
+ });
135
+ });
136
+
137
+ describe("createRuntimeError", () => {
138
+ it("creates structured error from Error object", () => {
139
+ const error = new Error("Test error");
140
+ const runtimeError = createRuntimeError(error);
141
+ expect(runtimeError.message).toBe("Test error");
142
+ expect(runtimeError.timestamp).toBeGreaterThan(0);
143
+ });
144
+
145
+ it("creates structured error from string", () => {
146
+ const runtimeError = createRuntimeError("String error");
147
+ expect(runtimeError.message).toBe("String error");
148
+ expect(runtimeError.timestamp).toBeGreaterThan(0);
149
+ });
150
+
151
+ it("creates structured error from unknown type", () => {
152
+ const runtimeError = createRuntimeError({ weird: "object" });
153
+ expect(runtimeError.message).toBe("[object Object]");
154
+ expect(runtimeError.timestamp).toBeGreaterThan(0);
155
+ });
156
+ });
157
+
158
+ describe("constants", () => {
159
+ it("exports HEARTBEAT_STALE_MS with correct value", () => {
160
+ expect(HEARTBEAT_STALE_MS).toBe(90000);
161
+ });
162
+
163
+ it("exports STARTUP_STALL_MS with correct value", () => {
164
+ expect(STARTUP_STALL_MS).toBe(60000);
165
+ });
166
+
167
+ it("exports RUNTIME_STALE_MS with correct value", () => {
168
+ expect(RUNTIME_STALE_MS).toBe(300000);
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,168 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { withLock } from "./lock";
4
+ import { runtimeStatusPath, teamDir } from "./paths";
5
+
6
+ /**
7
+ * Runtime constants for health checking.
8
+ * Exported for configurability and testing.
9
+ */
10
+ export const HEARTBEAT_STALE_MS = 90000; // 90 seconds
11
+ export const STARTUP_STALL_MS = 60000; // 60 seconds
12
+ export const RUNTIME_STALE_MS = 300000; // 5 minutes - files older than this are considered stale
13
+
14
+ /**
15
+ * Structured error information for better diagnostics.
16
+ */
17
+ export interface RuntimeError {
18
+ message: string;
19
+ timestamp: number;
20
+ }
21
+
22
+ export interface AgentRuntimeStatus {
23
+ teamName: string;
24
+ agentName: string;
25
+ pid?: number;
26
+ startedAt?: number;
27
+ lastHeartbeatAt?: number;
28
+ lastInboxReadAt?: number;
29
+ ready?: boolean;
30
+ lastError?: RuntimeError;
31
+ }
32
+
33
+ /**
34
+ * Write runtime status for an agent. Merges with existing status.
35
+ */
36
+ export async function writeRuntimeStatus(
37
+ teamName: string,
38
+ agentName: string,
39
+ updates: Partial<AgentRuntimeStatus>
40
+ ): Promise<AgentRuntimeStatus> {
41
+ const p = runtimeStatusPath(teamName, agentName);
42
+ const dir = path.dirname(p);
43
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
44
+
45
+ return await withLock(p, async () => {
46
+ let current: AgentRuntimeStatus = {
47
+ teamName,
48
+ agentName,
49
+ };
50
+
51
+ if (fs.existsSync(p)) {
52
+ try {
53
+ current = JSON.parse(fs.readFileSync(p, "utf-8")) as AgentRuntimeStatus;
54
+ } catch {
55
+ // Corrupted file, start fresh
56
+ current = { teamName, agentName };
57
+ }
58
+ }
59
+
60
+ const next: AgentRuntimeStatus = {
61
+ ...current,
62
+ ...updates,
63
+ teamName,
64
+ agentName,
65
+ };
66
+
67
+ fs.writeFileSync(p, JSON.stringify(next, null, 2));
68
+ return next;
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Read runtime status for an agent. Returns null if not found.
74
+ */
75
+ export async function readRuntimeStatus(
76
+ teamName: string,
77
+ agentName: string
78
+ ): Promise<AgentRuntimeStatus | null> {
79
+ const p = runtimeStatusPath(teamName, agentName);
80
+ if (!fs.existsSync(p)) return null;
81
+
82
+ return await withLock(p, async () => {
83
+ if (!fs.existsSync(p)) return null;
84
+ try {
85
+ return JSON.parse(fs.readFileSync(p, "utf-8")) as AgentRuntimeStatus;
86
+ } catch {
87
+ // Corrupted file
88
+ return null;
89
+ }
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Delete runtime status for an agent. Called during shutdown.
95
+ */
96
+ export async function deleteRuntimeStatus(
97
+ teamName: string,
98
+ agentName: string
99
+ ): Promise<boolean> {
100
+ const p = runtimeStatusPath(teamName, agentName);
101
+ if (!fs.existsSync(p)) return false;
102
+
103
+ return await withLock(p, async () => {
104
+ if (!fs.existsSync(p)) return false;
105
+ try {
106
+ fs.unlinkSync(p);
107
+ return true;
108
+ } catch {
109
+ return false;
110
+ }
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Clean up stale runtime files for a team.
116
+ * Removes files older than RUNTIME_STALE_MS that have no recent heartbeat.
117
+ * Returns the number of files cleaned up.
118
+ */
119
+ export async function cleanupStaleRuntimeFiles(
120
+ teamName: string,
121
+ now: number = Date.now()
122
+ ): Promise<number> {
123
+ const runtimeDir = path.join(teamDir(teamName), "runtime");
124
+ if (!fs.existsSync(runtimeDir)) return 0;
125
+
126
+ let cleaned = 0;
127
+ const files = fs.readdirSync(runtimeDir).filter(f => f.endsWith(".json"));
128
+
129
+ for (const file of files) {
130
+ const p = path.join(runtimeDir, file);
131
+ try {
132
+ const status = JSON.parse(fs.readFileSync(p, "utf-8")) as AgentRuntimeStatus;
133
+
134
+ // Check if the file is stale
135
+ const lastActivity = status.lastHeartbeatAt || status.startedAt || 0;
136
+ const isStale = (now - lastActivity) > RUNTIME_STALE_MS;
137
+
138
+ if (isStale) {
139
+ await withLock(p, async () => {
140
+ if (fs.existsSync(p)) {
141
+ fs.unlinkSync(p);
142
+ cleaned++;
143
+ }
144
+ });
145
+ }
146
+ } catch {
147
+ // Corrupted file, remove it
148
+ try {
149
+ fs.unlinkSync(p);
150
+ cleaned++;
151
+ } catch {
152
+ // Ignore removal errors
153
+ }
154
+ }
155
+ }
156
+
157
+ return cleaned;
158
+ }
159
+
160
+ /**
161
+ * Create a structured error object from an error.
162
+ */
163
+ export function createRuntimeError(error: unknown): RuntimeError {
164
+ return {
165
+ message: error instanceof Error ? error.message : String(error),
166
+ timestamp: Date.now(),
167
+ };
168
+ }