tmux-ide 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,154 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
3
+ import { HQClient } from "./client.ts";
4
+
5
+ // Capture fetch calls
6
+ let fetchCalls: { url: string; init: RequestInit }[] = [];
7
+ let fetchResponses: Array<{ ok: boolean; status: number; text: string }> = [];
8
+
9
+ const originalFetch = globalThis.fetch;
10
+
11
+ function mockFetch(url: string | URL | Request, init?: RequestInit): Promise<Response> {
12
+ const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
13
+ fetchCalls.push({ url: urlStr, init: init ?? {} });
14
+ const next = fetchResponses.shift() ?? { ok: true, status: 200, text: "{}" };
15
+ return Promise.resolve(
16
+ new Response(next.text, {
17
+ status: next.status,
18
+ headers: { "Content-Type": "application/json" },
19
+ }),
20
+ );
21
+ }
22
+
23
+ describe("HQClient", () => {
24
+ let client: HQClient;
25
+
26
+ beforeEach(() => {
27
+ fetchCalls = [];
28
+ fetchResponses = [];
29
+ globalThis.fetch = mockFetch as typeof fetch;
30
+
31
+ client = new HQClient({
32
+ hqUrl: "https://hq.example.com",
33
+ secret: "mysecret",
34
+ machineName: "dev-box",
35
+ remoteUrl: "https://remote.example.com",
36
+ bearerToken: "bearer-token-123",
37
+ heartbeatInterval: 100, // fast for tests
38
+ });
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await client.destroy();
43
+ globalThis.fetch = originalFetch;
44
+ });
45
+
46
+ describe("registration", () => {
47
+ it("sends POST to /api/hq/register with Basic Auth", async () => {
48
+ fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
49
+ await client.register();
50
+
51
+ expect(fetchCalls.length).toBeGreaterThanOrEqual(1);
52
+ const reg = fetchCalls[0]!;
53
+ expect(reg.url).toBe("https://hq.example.com/api/hq/register");
54
+ expect(reg.init.method).toBe("POST");
55
+
56
+ const authHeader = (reg.init.headers as Record<string, string>)["Authorization"];
57
+ expect(authHeader).toStartWith("Basic ");
58
+ const decoded = Buffer.from(authHeader!.slice(6), "base64").toString();
59
+ expect(decoded).toBe("mysecret");
60
+ });
61
+
62
+ it("sends correct payload with id, name, url, token", async () => {
63
+ fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
64
+ await client.register();
65
+
66
+ const body = JSON.parse(fetchCalls[0]!.init.body as string);
67
+ expect(body.name).toBe("dev-box");
68
+ expect(body.url).toBe("https://remote.example.com");
69
+ expect(body.token).toBe("bearer-token-123");
70
+ expect(body.id).toBeTruthy();
71
+ });
72
+
73
+ it("throws on registration failure", async () => {
74
+ fetchResponses.push({ ok: false, status: 401, text: "Unauthorized" });
75
+ await expect(client.register()).rejects.toThrow("Registration failed (401)");
76
+ });
77
+ });
78
+
79
+ describe("heartbeat", () => {
80
+ it("fires heartbeat after registration", async () => {
81
+ // Registration + at least 1 heartbeat
82
+ fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
83
+ fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
84
+ fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' });
85
+
86
+ await client.register();
87
+
88
+ // Wait for heartbeat interval to fire
89
+ await new Promise((r) => setTimeout(r, 250));
90
+
91
+ // At least 1 registration + 1 heartbeat
92
+ expect(fetchCalls.length).toBeGreaterThanOrEqual(2);
93
+
94
+ // All calls go to /api/hq/register
95
+ for (const call of fetchCalls) {
96
+ expect(call.url).toBe("https://hq.example.com/api/hq/register");
97
+ }
98
+ });
99
+ });
100
+
101
+ describe("backoff on failure", () => {
102
+ it("increases backoff when heartbeat fails", async () => {
103
+ fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' }); // register OK
104
+ fetchResponses.push({ ok: false, status: 500, text: "Server Error" }); // heartbeat fail
105
+ fetchResponses.push({ ok: false, status: 500, text: "Server Error" }); // heartbeat fail
106
+
107
+ await client.register();
108
+ await new Promise((r) => setTimeout(r, 250));
109
+
110
+ // Client should still be alive (backoff doesn't crash)
111
+ expect(client.getRemoteId()).toBeTruthy();
112
+ });
113
+ });
114
+
115
+ describe("deregister on shutdown", () => {
116
+ it("sends DELETE on destroy", async () => {
117
+ fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' }); // register
118
+ fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' }); // deregister DELETE
119
+
120
+ await client.register();
121
+ await client.destroy();
122
+
123
+ const deleteCalls = fetchCalls.filter((c) => c.init.method === "DELETE");
124
+ expect(deleteCalls.length).toBe(1);
125
+ expect(deleteCalls[0]!.url).toContain("/api/hq/machines/");
126
+ expect(deleteCalls[0]!.url).toContain(client.getRemoteId());
127
+ });
128
+
129
+ it("does not throw if deregister fails", async () => {
130
+ fetchResponses.push({ ok: true, status: 200, text: '{"ok":true}' }); // register
131
+ // No response for DELETE — will fail
132
+
133
+ await client.register();
134
+ // Should not throw
135
+ await client.destroy();
136
+ });
137
+ });
138
+
139
+ describe("accessors", () => {
140
+ it("returns remote ID", () => {
141
+ expect(client.getRemoteId()).toMatch(
142
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
143
+ );
144
+ });
145
+
146
+ it("returns bearer token", () => {
147
+ expect(client.getToken()).toBe("bearer-token-123");
148
+ });
149
+
150
+ it("returns machine name", () => {
151
+ expect(client.getName()).toBe("dev-box");
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,142 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import * as crypto from "node:crypto";
3
+ import { logger } from "../log.ts";
4
+ import type { RegistrationPayload } from "./types.ts";
5
+
6
+ const LOG = "hq-client";
7
+
8
+ export interface HQClientConfig {
9
+ hqUrl: string;
10
+ secret: string;
11
+ machineName: string;
12
+ remoteUrl: string;
13
+ bearerToken?: string;
14
+ heartbeatInterval?: number;
15
+ }
16
+
17
+ export class HQClient {
18
+ private readonly hqUrl: string;
19
+ private readonly remoteId: string;
20
+ private readonly machineName: string;
21
+ private readonly secret: string;
22
+ private readonly remoteUrl: string;
23
+ private readonly bearerToken: string;
24
+ private readonly heartbeatInterval: number;
25
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
26
+ private backoffMs = 1000;
27
+ private destroyed = false;
28
+
29
+ constructor(config: HQClientConfig) {
30
+ this.hqUrl = config.hqUrl;
31
+ this.remoteId = crypto.randomUUID();
32
+ this.machineName = config.machineName;
33
+ this.secret = config.secret;
34
+ this.remoteUrl = config.remoteUrl;
35
+ this.bearerToken = config.bearerToken ?? crypto.randomBytes(32).toString("hex");
36
+ this.heartbeatInterval = config.heartbeatInterval ?? 15_000;
37
+
38
+ logger.debug(LOG, "HQ client initialized", {
39
+ hqUrl: this.hqUrl,
40
+ machineName: this.machineName,
41
+ remoteId: this.remoteId,
42
+ });
43
+ }
44
+
45
+ private basicAuth(): string {
46
+ return `Basic ${Buffer.from(this.secret).toString("base64")}`;
47
+ }
48
+
49
+ async register(): Promise<void> {
50
+ logger.info(LOG, `Registering with HQ at ${this.hqUrl}`);
51
+
52
+ const payload: RegistrationPayload = {
53
+ id: this.remoteId,
54
+ name: this.machineName,
55
+ url: this.remoteUrl,
56
+ token: this.bearerToken,
57
+ };
58
+
59
+ const response = await fetch(`${this.hqUrl}/api/hq/register`, {
60
+ method: "POST",
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ Authorization: this.basicAuth(),
64
+ },
65
+ body: JSON.stringify(payload),
66
+ });
67
+
68
+ if (!response.ok) {
69
+ const text = await response.text();
70
+ throw new Error(`Registration failed (${response.status}): ${text}`);
71
+ }
72
+
73
+ logger.info(LOG, `Registered: ${this.machineName} (${this.remoteId})`);
74
+ this.backoffMs = 1000;
75
+ this.startHeartbeat();
76
+ }
77
+
78
+ private startHeartbeat(): void {
79
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
80
+ this.heartbeatTimer = setInterval(() => void this.sendHeartbeat(), this.heartbeatInterval);
81
+ }
82
+
83
+ private async sendHeartbeat(): Promise<void> {
84
+ if (this.destroyed) return;
85
+ try {
86
+ const res = await fetch(`${this.hqUrl}/api/hq/register`, {
87
+ method: "POST",
88
+ headers: {
89
+ "Content-Type": "application/json",
90
+ Authorization: this.basicAuth(),
91
+ },
92
+ body: JSON.stringify({
93
+ id: this.remoteId,
94
+ name: this.machineName,
95
+ url: this.remoteUrl,
96
+ token: this.bearerToken,
97
+ }),
98
+ });
99
+ if (res.ok) {
100
+ this.backoffMs = 1000;
101
+ logger.debug(LOG, "Heartbeat OK");
102
+ } else {
103
+ throw new Error(`HTTP ${res.status}`);
104
+ }
105
+ } catch (err) {
106
+ logger.warn(LOG, `Heartbeat failed, backoff ${this.backoffMs}ms`, {
107
+ error: String(err),
108
+ });
109
+ this.backoffMs = Math.min(this.backoffMs * 2, 60_000);
110
+ }
111
+ }
112
+
113
+ async destroy(): Promise<void> {
114
+ this.destroyed = true;
115
+ if (this.heartbeatTimer) {
116
+ clearInterval(this.heartbeatTimer);
117
+ this.heartbeatTimer = null;
118
+ }
119
+
120
+ logger.info(LOG, `Deregistering from HQ: ${this.machineName} (${this.remoteId})`);
121
+ try {
122
+ await fetch(`${this.hqUrl}/api/hq/machines/${this.remoteId}`, {
123
+ method: "DELETE",
124
+ headers: { Authorization: this.basicAuth() },
125
+ });
126
+ } catch {
127
+ // Best-effort during shutdown
128
+ }
129
+ }
130
+
131
+ getRemoteId(): string {
132
+ return this.remoteId;
133
+ }
134
+
135
+ getToken(): string {
136
+ return this.bearerToken;
137
+ }
138
+
139
+ getName(): string {
140
+ return this.machineName;
141
+ }
142
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, afterEach } from "bun:test";
2
+ import { MDNSService } from "./mdns.ts";
3
+
4
+ describe("MDNSService", () => {
5
+ let svc: MDNSService;
6
+
7
+ afterEach(async () => {
8
+ if (svc) await svc.stopAdvertising();
9
+ });
10
+
11
+ it("starts inactive", () => {
12
+ svc = new MDNSService();
13
+ expect(svc.isActive()).toBe(false);
14
+ });
15
+
16
+ it("stopAdvertising is a no-op when not active", async () => {
17
+ svc = new MDNSService();
18
+ await svc.stopAdvertising(); // should not throw
19
+ expect(svc.isActive()).toBe(false);
20
+ });
21
+
22
+ if (process.platform === "darwin") {
23
+ it("advertises via dns-sd on macOS", async () => {
24
+ svc = new MDNSService();
25
+ await svc.startAdvertising(19876, "test-tmux-ide");
26
+ expect(svc.isActive()).toBe(true);
27
+
28
+ await svc.stopAdvertising();
29
+ expect(svc.isActive()).toBe(false);
30
+ });
31
+
32
+ it("skips duplicate startAdvertising", async () => {
33
+ svc = new MDNSService();
34
+ await svc.startAdvertising(19877, "dup-test");
35
+ await svc.startAdvertising(19877, "dup-test"); // should warn but not throw
36
+ expect(svc.isActive()).toBe(true);
37
+ });
38
+
39
+ it("advertises _tmux-ide._tcp service type", async () => {
40
+ // The service type is hardcoded in the module. We just verify the
41
+ // service starts successfully (dns-sd logs the type on stdout).
42
+ svc = new MDNSService();
43
+ await svc.startAdvertising(19878);
44
+ expect(svc.isActive()).toBe(true);
45
+ });
46
+ } else {
47
+ it("skips advertisement on non-macOS", async () => {
48
+ svc = new MDNSService();
49
+ await svc.startAdvertising(19876, "test");
50
+ // On non-macOS the service doesn't actually start dns-sd
51
+ expect(svc.isActive()).toBe(false);
52
+ });
53
+ }
54
+ });
@@ -0,0 +1,88 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import { hostname } from "node:os";
4
+ import { logger } from "../log.ts";
5
+
6
+ const LOG = "mdns";
7
+ const SERVICE_TYPE = "_tmux-ide._tcp";
8
+
9
+ export class MDNSService {
10
+ private dnsSdProcess: ChildProcess | null = null;
11
+ private advertising = false;
12
+
13
+ async startAdvertising(port: number, instanceName?: string): Promise<void> {
14
+ if (this.advertising) {
15
+ logger.warn(LOG, "mDNS already advertising");
16
+ return;
17
+ }
18
+
19
+ const name = instanceName ?? hostname() ?? "tmux-ide";
20
+
21
+ if (process.platform === "darwin") {
22
+ await this.startDnsSd(name, port);
23
+ } else {
24
+ logger.info(LOG, "mDNS advertisement skipped (not macOS)");
25
+ }
26
+ }
27
+
28
+ async stopAdvertising(): Promise<void> {
29
+ if (!this.advertising) return;
30
+
31
+ if (this.dnsSdProcess) {
32
+ await new Promise<void>((resolve) => {
33
+ const proc = this.dnsSdProcess!;
34
+ this.dnsSdProcess = null;
35
+ proc.once("exit", () => resolve());
36
+ proc.kill();
37
+ setTimeout(resolve, 1000);
38
+ });
39
+ }
40
+
41
+ this.advertising = false;
42
+ logger.info(LOG, "Stopped mDNS advertisement");
43
+ }
44
+
45
+ isActive(): boolean {
46
+ return this.advertising;
47
+ }
48
+
49
+ private async startDnsSd(name: string, port: number): Promise<void> {
50
+ return new Promise((resolve, reject) => {
51
+ try {
52
+ const proc = spawn("dns-sd", ["-R", name, SERVICE_TYPE, "local.", String(port)], {
53
+ stdio: ["ignore", "pipe", "pipe"],
54
+ });
55
+
56
+ proc.on("error", (err) => {
57
+ logger.warn(LOG, `dns-sd error: ${err.message}`);
58
+ if (!this.advertising) reject(err);
59
+ });
60
+
61
+ proc.stdout?.on("data", (data: Buffer) => {
62
+ const msg = data.toString().trim();
63
+ if (msg) logger.debug(LOG, `dns-sd: ${msg}`);
64
+ });
65
+
66
+ proc.stderr?.on("data", (data: Buffer) => {
67
+ const msg = data.toString().trim();
68
+ if (msg) logger.warn(LOG, `dns-sd stderr: ${msg}`);
69
+ });
70
+
71
+ proc.on("close", () => {
72
+ this.advertising = false;
73
+ });
74
+
75
+ this.dnsSdProcess = proc;
76
+ this.advertising = true;
77
+ logger.info(LOG, `mDNS advertising: ${name} on port ${port} (${SERVICE_TYPE})`);
78
+
79
+ // dns-sd stays running, resolve immediately
80
+ resolve();
81
+ } catch (err) {
82
+ reject(err);
83
+ }
84
+ });
85
+ }
86
+ }
87
+
88
+ export const mdnsService = new MDNSService();
@@ -0,0 +1,237 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
3
+ import { RemoteRegistry } from "./registry.ts";
4
+
5
+ // Mock fetch for health checks (always succeed in tests to avoid pruning)
6
+ const originalFetch = globalThis.fetch;
7
+
8
+ describe("RemoteRegistry", () => {
9
+ let registry: RemoteRegistry;
10
+ let shuttingDown = false;
11
+
12
+ beforeEach(() => {
13
+ shuttingDown = false;
14
+ // Mock fetch so health checks succeed
15
+ globalThis.fetch = (() =>
16
+ Promise.resolve(new Response('{"ok":true}', { status: 200 }))) as typeof fetch;
17
+
18
+ registry = new RemoteRegistry({
19
+ healthInterval: 60_000, // long interval so health checks don't interfere
20
+ healthTimeout: 1000,
21
+ isShuttingDown: () => shuttingDown,
22
+ });
23
+ });
24
+
25
+ afterEach(() => {
26
+ registry.destroy();
27
+ globalThis.fetch = originalFetch;
28
+ });
29
+
30
+ describe("register", () => {
31
+ it("registers a machine and it appears in list", () => {
32
+ registry.register({
33
+ id: "m1",
34
+ name: "dev-box",
35
+ url: "https://dev.example.com",
36
+ token: "tok-1",
37
+ });
38
+
39
+ const machines = registry.getMachines();
40
+ expect(machines.length).toBe(1);
41
+ expect(machines[0]!.name).toBe("dev-box");
42
+ expect(machines[0]!.url).toBe("https://dev.example.com");
43
+ });
44
+
45
+ it("sets registeredAt and lastHeartbeat to now", () => {
46
+ const before = Date.now();
47
+ const m = registry.register({
48
+ id: "m2",
49
+ name: "prod-1",
50
+ url: "https://prod.example.com",
51
+ token: "tok-2",
52
+ });
53
+ const after = Date.now();
54
+
55
+ expect(m.registeredAt.getTime()).toBeGreaterThanOrEqual(before);
56
+ expect(m.registeredAt.getTime()).toBeLessThanOrEqual(after);
57
+ expect(m.lastHeartbeat.getTime()).toBeGreaterThanOrEqual(before);
58
+ });
59
+
60
+ it("initializes sessionIds as empty set", () => {
61
+ const m = registry.register({
62
+ id: "m3",
63
+ name: "staging",
64
+ url: "https://staging.example.com",
65
+ token: "tok-3",
66
+ });
67
+ expect(m.sessionIds.size).toBe(0);
68
+ });
69
+ });
70
+
71
+ describe("duplicate registration updates", () => {
72
+ it("re-registration with same ID updates fields", () => {
73
+ registry.register({
74
+ id: "m1",
75
+ name: "dev-box",
76
+ url: "https://old.example.com",
77
+ token: "tok-old",
78
+ });
79
+
80
+ const updated = registry.register({
81
+ id: "m1",
82
+ name: "dev-box",
83
+ url: "https://new.example.com",
84
+ token: "tok-new",
85
+ });
86
+
87
+ expect(registry.getMachines().length).toBe(1);
88
+ expect(updated.url).toBe("https://new.example.com");
89
+ expect(updated.token).toBe("tok-new");
90
+ });
91
+
92
+ it("rejects different ID with same name", () => {
93
+ registry.register({
94
+ id: "m1",
95
+ name: "dev-box",
96
+ url: "https://one.example.com",
97
+ token: "tok-1",
98
+ });
99
+
100
+ expect(() =>
101
+ registry.register({
102
+ id: "m2",
103
+ name: "dev-box",
104
+ url: "https://two.example.com",
105
+ token: "tok-2",
106
+ }),
107
+ ).toThrow("already registered");
108
+ });
109
+ });
110
+
111
+ describe("unregister", () => {
112
+ it("removes machine from list", () => {
113
+ registry.register({
114
+ id: "m1",
115
+ name: "dev-box",
116
+ url: "https://dev.example.com",
117
+ token: "tok-1",
118
+ });
119
+ expect(registry.getMachines().length).toBe(1);
120
+
121
+ const result = registry.unregister("m1");
122
+ expect(result).toBe(true);
123
+ expect(registry.getMachines().length).toBe(0);
124
+ });
125
+
126
+ it("returns false for unknown ID", () => {
127
+ expect(registry.unregister("nonexistent")).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe("session-to-remote mapping", () => {
132
+ it("maps sessions to machines", () => {
133
+ registry.register({
134
+ id: "m1",
135
+ name: "dev-box",
136
+ url: "https://dev.example.com",
137
+ token: "tok-1",
138
+ });
139
+
140
+ registry.updateSessions("m1", ["sess-a", "sess-b"]);
141
+
142
+ const found = registry.getMachineBySession("sess-a");
143
+ expect(found).toBeDefined();
144
+ expect(found!.id).toBe("m1");
145
+
146
+ const foundB = registry.getMachineBySession("sess-b");
147
+ expect(foundB).toBeDefined();
148
+ expect(foundB!.id).toBe("m1");
149
+ });
150
+
151
+ it("returns undefined for unmapped session", () => {
152
+ expect(registry.getMachineBySession("unknown")).toBeUndefined();
153
+ });
154
+
155
+ it("cleans up session mappings on unregister", () => {
156
+ registry.register({
157
+ id: "m1",
158
+ name: "dev-box",
159
+ url: "https://dev.example.com",
160
+ token: "tok-1",
161
+ });
162
+ registry.updateSessions("m1", ["sess-a"]);
163
+ expect(registry.getMachineBySession("sess-a")).toBeDefined();
164
+
165
+ registry.unregister("m1");
166
+ expect(registry.getMachineBySession("sess-a")).toBeUndefined();
167
+ });
168
+ });
169
+
170
+ describe("health check failure prunes", () => {
171
+ it("removes machine when health check fails", async () => {
172
+ // Make health check fail
173
+ globalThis.fetch = (() =>
174
+ Promise.resolve(new Response("Server Error", { status: 500 }))) as typeof fetch;
175
+
176
+ // Use short health interval
177
+ registry.destroy();
178
+ registry = new RemoteRegistry({
179
+ healthInterval: 50,
180
+ healthTimeout: 50,
181
+ isShuttingDown: () => shuttingDown,
182
+ });
183
+
184
+ registry.register({
185
+ id: "m1",
186
+ name: "unhealthy",
187
+ url: "https://dead.example.com",
188
+ token: "tok-1",
189
+ });
190
+
191
+ // Wait for health check to fire and prune
192
+ await new Promise((r) => setTimeout(r, 200));
193
+
194
+ expect(registry.getMachines().length).toBe(0);
195
+ });
196
+
197
+ it("does not prune during shutdown", async () => {
198
+ globalThis.fetch = (() =>
199
+ Promise.resolve(new Response("Server Error", { status: 500 }))) as typeof fetch;
200
+
201
+ registry.destroy();
202
+ registry = new RemoteRegistry({
203
+ healthInterval: 50,
204
+ healthTimeout: 50,
205
+ isShuttingDown: () => shuttingDown,
206
+ });
207
+
208
+ registry.register({
209
+ id: "m1",
210
+ name: "box",
211
+ url: "https://box.example.com",
212
+ token: "tok-1",
213
+ });
214
+
215
+ shuttingDown = true;
216
+ await new Promise((r) => setTimeout(r, 200));
217
+
218
+ // Should still be registered because shutdown flag prevents pruning
219
+ expect(registry.getMachines().length).toBe(1);
220
+ });
221
+ });
222
+
223
+ describe("getMachine", () => {
224
+ it("returns machine by ID", () => {
225
+ registry.register({
226
+ id: "m1",
227
+ name: "dev",
228
+ url: "https://dev.example.com",
229
+ token: "tok",
230
+ });
231
+
232
+ expect(registry.getMachine("m1")).toBeDefined();
233
+ expect(registry.getMachine("m1")!.name).toBe("dev");
234
+ expect(registry.getMachine("unknown")).toBeUndefined();
235
+ });
236
+ });
237
+ });