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,145 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import { logger } from "../log.ts";
3
+ import type { RemoteMachine } from "./types.ts";
4
+
5
+ const LOG = "remote-registry";
6
+
7
+ export class RemoteRegistry {
8
+ private machines = new Map<string, RemoteMachine>();
9
+ private machinesByName = new Map<string, RemoteMachine>();
10
+ private sessionToMachine = new Map<string, string>();
11
+ private healthCheckTimer: ReturnType<typeof setInterval> | null = null;
12
+ private readonly healthInterval: number;
13
+ private readonly healthTimeout: number;
14
+ private isShuttingDown: () => boolean;
15
+
16
+ constructor(opts?: {
17
+ healthInterval?: number;
18
+ healthTimeout?: number;
19
+ isShuttingDown?: () => boolean;
20
+ }) {
21
+ this.healthInterval = opts?.healthInterval ?? 15_000;
22
+ this.healthTimeout = opts?.healthTimeout ?? 5_000;
23
+ this.isShuttingDown = opts?.isShuttingDown ?? (() => false);
24
+ this.startHealthChecker();
25
+ }
26
+
27
+ register(
28
+ remote: Omit<RemoteMachine, "registeredAt" | "lastHeartbeat" | "sessionIds">,
29
+ ): RemoteMachine {
30
+ // If same ID already registered, update it (heartbeat / re-registration)
31
+ const existing = this.machines.get(remote.id);
32
+ if (existing) {
33
+ existing.name = remote.name;
34
+ existing.url = remote.url;
35
+ existing.token = remote.token;
36
+ existing.lastHeartbeat = new Date();
37
+ this.machinesByName.set(remote.name, existing);
38
+ logger.debug(LOG, `Machine re-registered: ${remote.name} (${remote.id})`);
39
+ return existing;
40
+ }
41
+
42
+ // Block duplicate names (different ID)
43
+ if (this.machinesByName.has(remote.name)) {
44
+ throw new Error(`Machine with name '${remote.name}' is already registered`);
45
+ }
46
+
47
+ const now = new Date();
48
+ const machine: RemoteMachine = {
49
+ ...remote,
50
+ registeredAt: now,
51
+ lastHeartbeat: now,
52
+ sessionIds: new Set(),
53
+ };
54
+
55
+ this.machines.set(remote.id, machine);
56
+ this.machinesByName.set(remote.name, machine);
57
+ logger.info(LOG, `Machine registered: ${remote.name} (${remote.id}) from ${remote.url}`);
58
+
59
+ void this.checkMachineHealth(machine);
60
+ return machine;
61
+ }
62
+
63
+ unregister(id: string): boolean {
64
+ const machine = this.machines.get(id);
65
+ if (!machine) return false;
66
+
67
+ for (const sid of machine.sessionIds) {
68
+ this.sessionToMachine.delete(sid);
69
+ }
70
+ this.machinesByName.delete(machine.name);
71
+ this.machines.delete(id);
72
+ logger.info(LOG, `Machine unregistered: ${machine.name} (${id})`);
73
+ return true;
74
+ }
75
+
76
+ getMachine(id: string): RemoteMachine | undefined {
77
+ return this.machines.get(id);
78
+ }
79
+
80
+ getMachines(): RemoteMachine[] {
81
+ return Array.from(this.machines.values());
82
+ }
83
+
84
+ getMachineBySession(sessionId: string): RemoteMachine | undefined {
85
+ const mid = this.sessionToMachine.get(sessionId);
86
+ return mid ? this.machines.get(mid) : undefined;
87
+ }
88
+
89
+ updateSessions(machineId: string, sessionIds: string[]): void {
90
+ const machine = this.machines.get(machineId);
91
+ if (!machine) return;
92
+
93
+ for (const old of machine.sessionIds) {
94
+ this.sessionToMachine.delete(old);
95
+ }
96
+ machine.sessionIds = new Set(sessionIds);
97
+ for (const sid of sessionIds) {
98
+ this.sessionToMachine.set(sid, machineId);
99
+ }
100
+ }
101
+
102
+ private async checkMachineHealth(machine: RemoteMachine): Promise<void> {
103
+ if (this.isShuttingDown()) return;
104
+
105
+ try {
106
+ const controller = new AbortController();
107
+ const tid = setTimeout(() => controller.abort(), this.healthTimeout);
108
+
109
+ const res = await fetch(`${machine.url}/health`, {
110
+ headers: { Authorization: `Bearer ${machine.token}` },
111
+ signal: controller.signal,
112
+ });
113
+
114
+ clearTimeout(tid);
115
+ if (res.ok) {
116
+ machine.lastHeartbeat = new Date();
117
+ logger.debug(LOG, `Health OK: ${machine.name}`);
118
+ } else {
119
+ throw new Error(`HTTP ${res.status}`);
120
+ }
121
+ } catch (err) {
122
+ if (this.isShuttingDown()) return;
123
+ logger.warn(LOG, `Health check failed, pruning: ${machine.name} (${machine.id})`, {
124
+ error: String(err),
125
+ });
126
+ this.unregister(machine.id);
127
+ }
128
+ }
129
+
130
+ private startHealthChecker(): void {
131
+ this.healthCheckTimer = setInterval(() => {
132
+ if (this.isShuttingDown()) return;
133
+ const checks = this.getMachines().map((m) => this.checkMachineHealth(m));
134
+ Promise.all(checks).catch(() => {});
135
+ }, this.healthInterval);
136
+ }
137
+
138
+ destroy(): void {
139
+ if (this.healthCheckTimer) {
140
+ clearInterval(this.healthCheckTimer);
141
+ this.healthCheckTimer = null;
142
+ }
143
+ logger.info(LOG, "Registry destroyed");
144
+ }
145
+ }
@@ -0,0 +1,44 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+ import { z } from "zod";
3
+
4
+ export interface RemoteMachine {
5
+ id: string;
6
+ name: string;
7
+ url: string;
8
+ token: string;
9
+ registeredAt: Date;
10
+ lastHeartbeat: Date;
11
+ sessionIds: Set<string>;
12
+ }
13
+
14
+ export interface HQConfig {
15
+ enabled: boolean;
16
+ role: "hq" | "remote";
17
+ hq_url?: string;
18
+ secret?: string;
19
+ heartbeat_interval?: number;
20
+ machine_name?: string;
21
+ }
22
+
23
+ export interface RegistrationPayload {
24
+ id: string;
25
+ name: string;
26
+ url: string;
27
+ token: string;
28
+ }
29
+
30
+ export const RegistrationPayloadSchema = z.object({
31
+ id: z.string().min(1),
32
+ name: z.string().min(1),
33
+ url: z.string().url(),
34
+ token: z.string().min(1),
35
+ });
36
+
37
+ export const HQConfigSchema = z.object({
38
+ enabled: z.boolean().default(false),
39
+ role: z.enum(["hq", "remote"]),
40
+ hq_url: z.string().url().optional(),
41
+ secret: z.string().optional(),
42
+ heartbeat_interval: z.number().min(1000).default(15000),
43
+ machine_name: z.string().optional(),
44
+ });
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { Buffer } from "node:buffer";
3
+ import {
4
+ MessageType,
5
+ frameMessage,
6
+ MessageParser,
7
+ MessageBuilder,
8
+ parsePayload,
9
+ } from "./socket-protocol.ts";
10
+
11
+ describe("socket-protocol", () => {
12
+ it("frameMessage round-trips through MessageParser for each JSON-backed type", () => {
13
+ const samples: { type: MessageType; payload: Buffer | string | object }[] = [
14
+ { type: MessageType.CONTROL_CMD, payload: { cmd: "resize", cols: 80, rows: 24 } },
15
+ { type: MessageType.STATUS_UPDATE, payload: { app: "vt", status: "ok" } },
16
+ { type: MessageType.ERROR, payload: { code: "E1", message: "m" } },
17
+ { type: MessageType.STATUS_REQUEST, payload: {} },
18
+ {
19
+ type: MessageType.STATUS_RESPONSE,
20
+ payload: { running: true, port: 4000 },
21
+ },
22
+ {
23
+ type: MessageType.GIT_FOLLOW_REQUEST,
24
+ payload: { enable: true },
25
+ },
26
+ {
27
+ type: MessageType.GIT_FOLLOW_RESPONSE,
28
+ payload: { success: true },
29
+ },
30
+ {
31
+ type: MessageType.GIT_EVENT_NOTIFY,
32
+ payload: { repoPath: "/r", type: "commit" },
33
+ },
34
+ { type: MessageType.GIT_EVENT_ACK, payload: { handled: true } },
35
+ ];
36
+
37
+ for (const { type, payload } of samples) {
38
+ const framed = frameMessage(type, payload);
39
+ const p = new MessageParser();
40
+ p.addData(framed);
41
+ const msgs = [...p.parseMessages()];
42
+ expect(msgs.length).toBe(1);
43
+ expect(msgs[0]!.type).toBe(type);
44
+ const decoded = JSON.parse(msgs[0]!.payload.toString("utf8")) as object;
45
+ expect(decoded).toEqual(payload as object);
46
+ }
47
+ });
48
+
49
+ it("frameMessage supports raw Buffer and string payloads", () => {
50
+ const buf = frameMessage(MessageType.STDIN_DATA, Buffer.from([0, 1, 2]));
51
+ const p = new MessageParser();
52
+ p.addData(buf);
53
+ const [m] = [...p.parseMessages()];
54
+ expect(m!.type).toBe(MessageType.STDIN_DATA);
55
+ expect(Buffer.from(m!.payload).equals(Buffer.from([0, 1, 2]))).toBe(true);
56
+ });
57
+
58
+ it("MessageParser handles fragmented input across chunks", () => {
59
+ const msg = MessageBuilder.resize(100, 50);
60
+ const a = msg.subarray(0, 3);
61
+ const b = msg.subarray(3);
62
+ const p = new MessageParser();
63
+ p.addData(a);
64
+ expect([...p.parseMessages()].length).toBe(0);
65
+ expect(p.pendingBytes).toBe(3);
66
+ p.addData(b);
67
+ const out = [...p.parseMessages()];
68
+ expect(out.length).toBe(1);
69
+ expect(out[0]!.type).toBe(MessageType.CONTROL_CMD);
70
+ expect(p.pendingBytes).toBe(0);
71
+ });
72
+
73
+ it("MessageParser yields multiple messages in one chunk", () => {
74
+ const m1 = MessageBuilder.heartbeat();
75
+ const m2 = MessageBuilder.stdin("hi");
76
+ const p = new MessageParser();
77
+ p.addData(Buffer.concat([m1, m2]));
78
+ const out = [...p.parseMessages()];
79
+ expect(out.length).toBe(2);
80
+ expect(out[0]!.type).toBe(MessageType.HEARTBEAT);
81
+ expect(out[1]!.type).toBe(MessageType.STDIN_DATA);
82
+ expect(out[1]!.payload.toString("utf8")).toBe("hi");
83
+ });
84
+
85
+ it("MessageParser waits when payload length exceeds available bytes", () => {
86
+ const framed = frameMessage(MessageType.HEARTBEAT, Buffer.alloc(0));
87
+ const partial = framed.subarray(0, framed.length - 1);
88
+ const p = new MessageParser();
89
+ p.addData(partial);
90
+ expect([...p.parseMessages()].length).toBe(0);
91
+ expect(p.pendingBytes).toBe(partial.length);
92
+ });
93
+
94
+ it("clear resets pending buffer", () => {
95
+ const p = new MessageParser();
96
+ p.addData(Buffer.from([1, 2, 3]));
97
+ p.clear();
98
+ expect(p.pendingBytes).toBe(0);
99
+ });
100
+
101
+ it("parsePayload maps types correctly", () => {
102
+ expect(parsePayload(MessageType.STDIN_DATA, Buffer.from("abc", "utf8"))).toBe("abc");
103
+ expect(parsePayload(MessageType.HEARTBEAT, Buffer.alloc(0))).toBeNull();
104
+ const ctrl = Buffer.from(JSON.stringify({ cmd: "resize", cols: 1, rows: 2 }), "utf8");
105
+ expect(parsePayload(MessageType.CONTROL_CMD, ctrl)).toEqual({
106
+ cmd: "resize",
107
+ cols: 1,
108
+ rows: 2,
109
+ });
110
+ });
111
+
112
+ it("parsePayload throws on invalid JSON for JSON message types", () => {
113
+ expect(() => parsePayload(MessageType.ERROR, Buffer.from("not-json", "utf8"))).toThrow(
114
+ /Failed to parse JSON payload/,
115
+ );
116
+ });
117
+
118
+ it("MessageBuilder helpers produce decodable frames", () => {
119
+ const parser = new MessageParser();
120
+ parser.addData(MessageBuilder.statusRequest());
121
+ parser.addData(MessageBuilder.statusResponse({ running: false }));
122
+ const [a, b] = [...parser.parseMessages()];
123
+ expect(parsePayload(a!.type, a!.payload)).toEqual({});
124
+ expect(parsePayload(b!.type, b!.payload)).toEqual({ running: false });
125
+ });
126
+ });
@@ -0,0 +1,324 @@
1
+ // Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
2
+
3
+ /**
4
+ * Unix socket protocol for VibeTunnel IPC
5
+ *
6
+ * Message format (binary):
7
+ * [1 byte: message type]
8
+ * [4 bytes: payload length (big-endian)]
9
+ * [N bytes: payload]
10
+ */
11
+
12
+ import { Buffer } from "node:buffer";
13
+
14
+ /**
15
+ * Message types for the socket protocol
16
+ */
17
+ export enum MessageType {
18
+ STDIN_DATA = 0x01, // Raw stdin data (keyboard input)
19
+ CONTROL_CMD = 0x02, // Control commands (resize, kill, etc)
20
+ STATUS_UPDATE = 0x03, // Legacy status updates (ignored)
21
+ HEARTBEAT = 0x04, // Keep-alive ping/pong
22
+ ERROR = 0x05, // Error messages
23
+ // Reserved for future use
24
+ STDOUT_SUBSCRIBE = 0x10,
25
+ METRICS = 0x11,
26
+ // Status operations
27
+ STATUS_REQUEST = 0x20, // Request server status
28
+ STATUS_RESPONSE = 0x21, // Server status response
29
+ // Git operations
30
+ GIT_FOLLOW_REQUEST = 0x30, // Enable/disable Git follow mode
31
+ GIT_FOLLOW_RESPONSE = 0x31, // Response to follow request
32
+ GIT_EVENT_NOTIFY = 0x32, // Git event notification
33
+ GIT_EVENT_ACK = 0x33, // Git event acknowledgment
34
+ }
35
+
36
+ /**
37
+ * Control command types
38
+ */
39
+ export interface ControlCommand {
40
+ cmd: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ export interface ResizeCommand extends ControlCommand {
45
+ cmd: "resize";
46
+ cols: number;
47
+ rows: number;
48
+ }
49
+
50
+ export interface KillCommand extends ControlCommand {
51
+ cmd: "kill";
52
+ signal?: string | number;
53
+ }
54
+
55
+ export interface ResetSizeCommand extends ControlCommand {
56
+ cmd: "reset-size";
57
+ }
58
+
59
+ export interface UpdateTitleCommand extends ControlCommand {
60
+ cmd: "update-title";
61
+ title: string;
62
+ }
63
+
64
+ /**
65
+ * Status update payload
66
+ */
67
+ export interface StatusUpdate {
68
+ app: string;
69
+ status: string;
70
+ timestamp?: number;
71
+ [key: string]: unknown;
72
+ }
73
+
74
+ /**
75
+ * Error message payload
76
+ */
77
+ export interface ErrorMessage {
78
+ code: string;
79
+ message: string;
80
+ details?: unknown;
81
+ }
82
+
83
+ /**
84
+ * Server status request (empty payload)
85
+ */
86
+ export type StatusRequest = Record<string, never>;
87
+
88
+ /**
89
+ * Server status response
90
+ */
91
+ export interface StatusResponse {
92
+ running: boolean;
93
+ port?: number;
94
+ url?: string;
95
+ version?: string;
96
+ buildDate?: string;
97
+ followMode?: {
98
+ enabled: boolean;
99
+ branch?: string;
100
+ repoPath?: string;
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Git follow mode request
106
+ */
107
+ export interface GitFollowRequest {
108
+ repoPath?: string; // Main repo path (for backward compatibility)
109
+ branch?: string; // Optional - branch name (for backward compatibility)
110
+ enable: boolean;
111
+ mainRepoPath?: string; // The main repository path
112
+ }
113
+
114
+ /**
115
+ * Git follow mode response
116
+ */
117
+ export interface GitFollowResponse {
118
+ success: boolean;
119
+ currentBranch?: string;
120
+ previousBranch?: string;
121
+ error?: string;
122
+ }
123
+
124
+ /**
125
+ * Git event notification
126
+ */
127
+ export interface GitEventNotify {
128
+ repoPath: string;
129
+ type: "checkout" | "commit" | "merge" | "rebase" | "other";
130
+ }
131
+
132
+ /**
133
+ * Git event acknowledgment
134
+ */
135
+ export interface GitEventAck {
136
+ handled: boolean;
137
+ }
138
+
139
+ /**
140
+ * Type-safe mapping of message types to their payload types
141
+ */
142
+ export type MessagePayloadMap = {
143
+ [MessageType.STDIN_DATA]: string;
144
+ [MessageType.CONTROL_CMD]: ControlCommand;
145
+ [MessageType.STATUS_UPDATE]: StatusUpdate;
146
+ [MessageType.HEARTBEAT]: Record<string, never>;
147
+ [MessageType.ERROR]: ErrorMessage;
148
+ [MessageType.STATUS_REQUEST]: StatusRequest;
149
+ [MessageType.STATUS_RESPONSE]: StatusResponse;
150
+ [MessageType.GIT_FOLLOW_REQUEST]: GitFollowRequest;
151
+ [MessageType.GIT_FOLLOW_RESPONSE]: GitFollowResponse;
152
+ [MessageType.GIT_EVENT_NOTIFY]: GitEventNotify;
153
+ [MessageType.GIT_EVENT_ACK]: GitEventAck;
154
+ };
155
+
156
+ /**
157
+ * Get the payload type for a given message type
158
+ */
159
+ export type MessagePayload<T extends MessageType> = T extends keyof MessagePayloadMap
160
+ ? MessagePayloadMap[T]
161
+ : never;
162
+
163
+ /**
164
+ * Frame a message for transmission
165
+ */
166
+ export function frameMessage(type: MessageType, payload: Buffer | string | object): Buffer {
167
+ const payloadBuffer = Buffer.isBuffer(payload)
168
+ ? payload
169
+ : Buffer.from(typeof payload === "string" ? payload : JSON.stringify(payload), "utf8");
170
+
171
+ const message = Buffer.allocUnsafe(5 + payloadBuffer.length);
172
+ message[0] = type;
173
+ message.writeUInt32BE(payloadBuffer.length, 1);
174
+ payloadBuffer.copy(message, 5);
175
+
176
+ return message;
177
+ }
178
+
179
+ /**
180
+ * Parse messages from a buffer
181
+ */
182
+ export class MessageParser {
183
+ private buffer = Buffer.alloc(0);
184
+
185
+ /**
186
+ * Add data to the parser
187
+ */
188
+ addData(chunk: Buffer | Uint8Array | string): void {
189
+ const bufferChunk = Buffer.isBuffer(chunk)
190
+ ? chunk
191
+ : Buffer.from(typeof chunk === "string" ? chunk : chunk);
192
+ this.buffer = Buffer.concat([this.buffer, bufferChunk]);
193
+ }
194
+
195
+ /**
196
+ * Parse complete messages from the buffer
197
+ */
198
+ *parseMessages(): Generator<{ type: MessageType; payload: Buffer }> {
199
+ while (this.buffer.length >= 5) {
200
+ const messageType = this.buffer[0] as MessageType;
201
+ const payloadLength = this.buffer.readUInt32BE(1);
202
+
203
+ // Check if we have the complete message
204
+ if (this.buffer.length < 5 + payloadLength) {
205
+ break;
206
+ }
207
+
208
+ // Extract the message
209
+ const payload = this.buffer.subarray(5, 5 + payloadLength);
210
+ this.buffer = this.buffer.subarray(5 + payloadLength);
211
+
212
+ yield { type: messageType, payload };
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Get the number of bytes waiting to be parsed
218
+ */
219
+ get pendingBytes(): number {
220
+ return this.buffer.length;
221
+ }
222
+
223
+ /**
224
+ * Clear the buffer
225
+ */
226
+ clear(): void {
227
+ this.buffer = Buffer.alloc(0);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * High-level message creation helpers
233
+ */
234
+ export const MessageBuilder = {
235
+ stdin(data: string): Buffer {
236
+ return frameMessage(MessageType.STDIN_DATA, data);
237
+ },
238
+
239
+ resize(cols: number, rows: number): Buffer {
240
+ return frameMessage(MessageType.CONTROL_CMD, { cmd: "resize", cols, rows });
241
+ },
242
+
243
+ kill(signal?: string | number): Buffer {
244
+ return frameMessage(MessageType.CONTROL_CMD, { cmd: "kill", signal });
245
+ },
246
+
247
+ resetSize(): Buffer {
248
+ return frameMessage(MessageType.CONTROL_CMD, { cmd: "reset-size" });
249
+ },
250
+
251
+ updateTitle(title: string): Buffer {
252
+ return frameMessage(MessageType.CONTROL_CMD, { cmd: "update-title", title });
253
+ },
254
+
255
+ status(app: string, status: string, extra?: Record<string, unknown>): Buffer {
256
+ return frameMessage(MessageType.STATUS_UPDATE, { app, status, ...extra });
257
+ },
258
+
259
+ heartbeat(): Buffer {
260
+ return frameMessage(MessageType.HEARTBEAT, Buffer.alloc(0));
261
+ },
262
+
263
+ error(code: string, message: string, details?: unknown): Buffer {
264
+ return frameMessage(MessageType.ERROR, { code, message, details });
265
+ },
266
+
267
+ gitFollowRequest(request: GitFollowRequest): Buffer {
268
+ return frameMessage(MessageType.GIT_FOLLOW_REQUEST, request);
269
+ },
270
+
271
+ gitFollowResponse(response: GitFollowResponse): Buffer {
272
+ return frameMessage(MessageType.GIT_FOLLOW_RESPONSE, response);
273
+ },
274
+
275
+ gitEventNotify(event: GitEventNotify): Buffer {
276
+ return frameMessage(MessageType.GIT_EVENT_NOTIFY, event);
277
+ },
278
+
279
+ gitEventAck(ack: GitEventAck): Buffer {
280
+ return frameMessage(MessageType.GIT_EVENT_ACK, ack);
281
+ },
282
+
283
+ statusRequest(): Buffer {
284
+ return frameMessage(MessageType.STATUS_REQUEST, {});
285
+ },
286
+
287
+ statusResponse(response: StatusResponse): Buffer {
288
+ return frameMessage(MessageType.STATUS_RESPONSE, response);
289
+ },
290
+ } as const;
291
+
292
+ /**
293
+ * Parse payload based on message type
294
+ */
295
+ export function parsePayload(type: MessageType, payload: Buffer): unknown {
296
+ switch (type) {
297
+ case MessageType.STDIN_DATA:
298
+ return payload.toString("utf8");
299
+
300
+ case MessageType.CONTROL_CMD:
301
+ case MessageType.STATUS_UPDATE:
302
+ case MessageType.ERROR:
303
+ case MessageType.STATUS_REQUEST:
304
+ case MessageType.STATUS_RESPONSE:
305
+ case MessageType.GIT_FOLLOW_REQUEST:
306
+ case MessageType.GIT_FOLLOW_RESPONSE:
307
+ case MessageType.GIT_EVENT_NOTIFY:
308
+ case MessageType.GIT_EVENT_ACK:
309
+ try {
310
+ return JSON.parse(payload.toString("utf8"));
311
+ } catch (e) {
312
+ throw new Error(
313
+ `Failed to parse JSON payload for message type ${type}: ${e instanceof Error ? e.message : String(e)}`,
314
+ { cause: e },
315
+ );
316
+ }
317
+
318
+ case MessageType.HEARTBEAT:
319
+ return null;
320
+
321
+ default:
322
+ return payload;
323
+ }
324
+ }