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.
- package/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- 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
|
+
}
|