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.
- package/extensions/index.ts +77 -5
- package/package.json +1 -1
- package/src/adapters/windows-adapter.test.ts +86 -155
- package/src/utils/paths.ts +4 -0
- package/src/utils/runtime.test.ts +171 -0
- package/src/utils/runtime.ts +168 -0
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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(
|
|
648
|
-
details
|
|
719
|
+
content: [{ type: "text", text: JSON.stringify(details, null, 2) }],
|
|
720
|
+
details,
|
|
649
721
|
};
|
|
650
722
|
},
|
|
651
723
|
});
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
144
|
-
expect(
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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: "
|
|
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
|
-
|
|
233
|
-
expect(
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
|
|
290
|
-
|
|
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
|
|
308
|
-
|
|
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
|
+
});
|
package/src/utils/paths.ts
CHANGED
|
@@ -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
|
+
}
|