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,209 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { getTasksRoot } from "./task-store.ts";
|
|
12
|
+
|
|
13
|
+
const SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
function atomicWriteJSON(filePath: string, data: unknown): void {
|
|
16
|
+
const tmpPath = filePath + ".tmp";
|
|
17
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
|
|
18
|
+
renameSync(tmpPath, filePath);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// MARK: - Checkpoint
|
|
22
|
+
|
|
23
|
+
export interface Checkpoint {
|
|
24
|
+
id: string;
|
|
25
|
+
taskId: string;
|
|
26
|
+
title: string;
|
|
27
|
+
description: string;
|
|
28
|
+
status: "pending" | "approved" | "rejected";
|
|
29
|
+
createdBy: string;
|
|
30
|
+
reviewedBy: string | null;
|
|
31
|
+
created: string;
|
|
32
|
+
updated: string;
|
|
33
|
+
diff: string | null;
|
|
34
|
+
files: string[];
|
|
35
|
+
comments: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkpointsDir(dir: string): string {
|
|
39
|
+
return join(getTasksRoot(dir), "checkpoints");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ensureCheckpointsDir(dir: string): void {
|
|
43
|
+
const d = checkpointsDir(dir);
|
|
44
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function nextCheckpointId(dir: string): string {
|
|
48
|
+
const d = checkpointsDir(dir);
|
|
49
|
+
if (!existsSync(d)) return "001";
|
|
50
|
+
const files = readdirSync(d).filter((f) => f.endsWith(".json"));
|
|
51
|
+
if (files.length === 0) return "001";
|
|
52
|
+
const maxId = Math.max(...files.map((f) => parseInt(f.split("-")[0]!, 10) || 0));
|
|
53
|
+
return String(maxId + 1).padStart(3, "0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function slugify(text: string): string {
|
|
57
|
+
return text
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
60
|
+
.replace(/^-|-$/g, "")
|
|
61
|
+
.slice(0, 50);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function loadCheckpoints(dir: string): Checkpoint[] {
|
|
65
|
+
const d = checkpointsDir(dir);
|
|
66
|
+
if (!existsSync(d)) return [];
|
|
67
|
+
return readdirSync(d)
|
|
68
|
+
.filter((f) => f.endsWith(".json"))
|
|
69
|
+
.sort()
|
|
70
|
+
.map((f) => {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(join(d, f), "utf-8")) as Checkpoint;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
.filter((c): c is Checkpoint => c !== null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function loadCheckpoint(dir: string, id: string): Checkpoint | null {
|
|
81
|
+
const d = checkpointsDir(dir);
|
|
82
|
+
if (!existsSync(d)) return null;
|
|
83
|
+
const file = readdirSync(d).find((f) => f.startsWith(id + "-") || f === id + ".json");
|
|
84
|
+
if (!file) return null;
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(readFileSync(join(d, file), "utf-8")) as Checkpoint;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function loadCheckpointsForTask(dir: string, taskId: string): Checkpoint[] {
|
|
93
|
+
return loadCheckpoints(dir).filter((c) => c.taskId === taskId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function saveCheckpoint(dir: string, checkpoint: Checkpoint): void {
|
|
97
|
+
ensureCheckpointsDir(dir);
|
|
98
|
+
const d = checkpointsDir(dir);
|
|
99
|
+
const filename = `${checkpoint.id}-${slugify(checkpoint.title)}.json`;
|
|
100
|
+
const newPath = join(d, filename);
|
|
101
|
+
// Remove old file if slug changed
|
|
102
|
+
const existing = readdirSync(d).find(
|
|
103
|
+
(f) => f.startsWith(checkpoint.id + "-") || f === checkpoint.id + ".json",
|
|
104
|
+
);
|
|
105
|
+
atomicWriteJSON(newPath, { _version: SCHEMA_VERSION, ...checkpoint });
|
|
106
|
+
if (existing && join(d, existing) !== newPath) unlinkSync(join(d, existing));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function deleteCheckpoint(dir: string, id: string): boolean {
|
|
110
|
+
const d = checkpointsDir(dir);
|
|
111
|
+
if (!existsSync(d)) return false;
|
|
112
|
+
const file = readdirSync(d).find((f) => f.startsWith(id + "-") || f === id + ".json");
|
|
113
|
+
if (!file) return false;
|
|
114
|
+
unlinkSync(join(d, file));
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// MARK: - Review Request
|
|
119
|
+
|
|
120
|
+
export interface ReviewRequest {
|
|
121
|
+
id: string;
|
|
122
|
+
taskId: string;
|
|
123
|
+
checkpointId: string | null;
|
|
124
|
+
title: string;
|
|
125
|
+
description: string;
|
|
126
|
+
status: "open" | "approved" | "changes-requested" | "closed";
|
|
127
|
+
requestedBy: string;
|
|
128
|
+
reviewer: string | null;
|
|
129
|
+
created: string;
|
|
130
|
+
updated: string;
|
|
131
|
+
comments: ReviewComment[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ReviewComment {
|
|
135
|
+
author: string;
|
|
136
|
+
body: string;
|
|
137
|
+
created: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function reviewsDir(dir: string): string {
|
|
141
|
+
return join(getTasksRoot(dir), "reviews");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function ensureReviewsDir(dir: string): void {
|
|
145
|
+
const d = reviewsDir(dir);
|
|
146
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function nextReviewId(dir: string): string {
|
|
150
|
+
const d = reviewsDir(dir);
|
|
151
|
+
if (!existsSync(d)) return "001";
|
|
152
|
+
const files = readdirSync(d).filter((f) => f.endsWith(".json"));
|
|
153
|
+
if (files.length === 0) return "001";
|
|
154
|
+
const maxId = Math.max(...files.map((f) => parseInt(f.split("-")[0]!, 10) || 0));
|
|
155
|
+
return String(maxId + 1).padStart(3, "0");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function loadReviews(dir: string): ReviewRequest[] {
|
|
159
|
+
const d = reviewsDir(dir);
|
|
160
|
+
if (!existsSync(d)) return [];
|
|
161
|
+
return readdirSync(d)
|
|
162
|
+
.filter((f) => f.endsWith(".json"))
|
|
163
|
+
.sort()
|
|
164
|
+
.map((f) => {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(readFileSync(join(d, f), "utf-8")) as ReviewRequest;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
.filter((r): r is ReviewRequest => r !== null);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function loadReview(dir: string, id: string): ReviewRequest | null {
|
|
175
|
+
const d = reviewsDir(dir);
|
|
176
|
+
if (!existsSync(d)) return null;
|
|
177
|
+
const file = readdirSync(d).find((f) => f.startsWith(id + "-") || f === id + ".json");
|
|
178
|
+
if (!file) return null;
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(readFileSync(join(d, file), "utf-8")) as ReviewRequest;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function loadReviewsForTask(dir: string, taskId: string): ReviewRequest[] {
|
|
187
|
+
return loadReviews(dir).filter((r) => r.taskId === taskId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function saveReview(dir: string, review: ReviewRequest): void {
|
|
191
|
+
ensureReviewsDir(dir);
|
|
192
|
+
const d = reviewsDir(dir);
|
|
193
|
+
const filename = `${review.id}-${slugify(review.title)}.json`;
|
|
194
|
+
const newPath = join(d, filename);
|
|
195
|
+
const existing = readdirSync(d).find(
|
|
196
|
+
(f) => f.startsWith(review.id + "-") || f === review.id + ".json",
|
|
197
|
+
);
|
|
198
|
+
atomicWriteJSON(newPath, { _version: SCHEMA_VERSION, ...review });
|
|
199
|
+
if (existing && join(d, existing) !== newPath) unlinkSync(join(d, existing));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function deleteReview(dir: string, id: string): boolean {
|
|
203
|
+
const d = reviewsDir(dir);
|
|
204
|
+
if (!existsSync(d)) return false;
|
|
205
|
+
const file = readdirSync(d).find((f) => f.startsWith(id + "-") || f === id + ".json");
|
|
206
|
+
if (!file) return false;
|
|
207
|
+
unlinkSync(join(d, file));
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
WS_V3_VERSION,
|
|
4
|
+
WsV3MessageType,
|
|
5
|
+
encodeWsV3Frame,
|
|
6
|
+
decodeWsV3Frame,
|
|
7
|
+
encodeWsV3SubscribePayload,
|
|
8
|
+
decodeWsV3SubscribePayload,
|
|
9
|
+
encodeWsV3ResizePayload,
|
|
10
|
+
decodeWsV3ResizePayload,
|
|
11
|
+
} from "./protocol.ts";
|
|
12
|
+
|
|
13
|
+
const allMessageTypes = Object.values(WsV3MessageType).filter(
|
|
14
|
+
(v): v is WsV3MessageType => typeof v === "number",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe("ws-v3 protocol", () => {
|
|
18
|
+
it("encode/decode round-trip for each frame type with session + payload", () => {
|
|
19
|
+
const payload = new TextEncoder().encode("payload-bytes");
|
|
20
|
+
for (const type of allMessageTypes) {
|
|
21
|
+
const encoded = encodeWsV3Frame({
|
|
22
|
+
type,
|
|
23
|
+
sessionId: `sess-${type}`,
|
|
24
|
+
payload,
|
|
25
|
+
});
|
|
26
|
+
const decoded = decodeWsV3Frame(encoded);
|
|
27
|
+
expect(decoded).not.toBeNull();
|
|
28
|
+
expect(decoded!.type).toBe(type);
|
|
29
|
+
expect(decoded!.sessionId).toBe(`sess-${type}`);
|
|
30
|
+
expect(Buffer.from(decoded!.payload).equals(Buffer.from(payload))).toBe(true);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("round-trip with empty session id and empty payload", () => {
|
|
35
|
+
const encoded = encodeWsV3Frame({
|
|
36
|
+
type: WsV3MessageType.PING,
|
|
37
|
+
sessionId: "",
|
|
38
|
+
payload: new Uint8Array(),
|
|
39
|
+
});
|
|
40
|
+
const decoded = decodeWsV3Frame(encoded);
|
|
41
|
+
expect(decoded).toEqual({
|
|
42
|
+
type: WsV3MessageType.PING,
|
|
43
|
+
sessionId: "",
|
|
44
|
+
payload: new Uint8Array(),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("defaults sessionId and payload to empty", () => {
|
|
49
|
+
const encoded = encodeWsV3Frame({ type: WsV3MessageType.HELLO });
|
|
50
|
+
expect(decodeWsV3Frame(encoded)).toEqual({
|
|
51
|
+
type: WsV3MessageType.HELLO,
|
|
52
|
+
sessionId: "",
|
|
53
|
+
payload: new Uint8Array(),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null for invalid magic bytes", () => {
|
|
58
|
+
const buf = encodeWsV3Frame({ type: WsV3MessageType.HELLO });
|
|
59
|
+
buf[0] ^= 0xff;
|
|
60
|
+
expect(decodeWsV3Frame(buf)).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns null for wrong version", () => {
|
|
64
|
+
const buf = encodeWsV3Frame({ type: WsV3MessageType.HELLO });
|
|
65
|
+
buf[2] = WS_V3_VERSION + 1;
|
|
66
|
+
expect(decodeWsV3Frame(buf)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null when buffer is shorter than fixed header", () => {
|
|
70
|
+
expect(decodeWsV3Frame(new Uint8Array(10))).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns null on truncated frame (fragmented input): missing sessionId bytes", () => {
|
|
74
|
+
const full = encodeWsV3Frame({
|
|
75
|
+
type: WsV3MessageType.STDOUT,
|
|
76
|
+
sessionId: "abc",
|
|
77
|
+
payload: new Uint8Array([1, 2]),
|
|
78
|
+
});
|
|
79
|
+
for (let len = 0; len < full.byteLength; len++) {
|
|
80
|
+
const partial = full.subarray(0, len);
|
|
81
|
+
const d = decodeWsV3Frame(partial);
|
|
82
|
+
if (len < full.byteLength) {
|
|
83
|
+
expect(d).toBeNull();
|
|
84
|
+
} else {
|
|
85
|
+
expect(d).not.toBeNull();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("subscribe payload encode/decode round-trip", () => {
|
|
91
|
+
const p = encodeWsV3SubscribePayload({
|
|
92
|
+
flags: 7,
|
|
93
|
+
snapshotMinIntervalMs: 100,
|
|
94
|
+
snapshotMaxIntervalMs: 500,
|
|
95
|
+
});
|
|
96
|
+
expect(decodeWsV3SubscribePayload(p)).toEqual({
|
|
97
|
+
flags: 7,
|
|
98
|
+
snapshotMinIntervalMs: 100,
|
|
99
|
+
snapshotMaxIntervalMs: 500,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("subscribe decode returns null when payload too short", () => {
|
|
104
|
+
expect(decodeWsV3SubscribePayload(new Uint8Array(11))).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("resize payload encode/decode round-trip", () => {
|
|
108
|
+
const p = encodeWsV3ResizePayload(120, 40);
|
|
109
|
+
expect(decodeWsV3ResizePayload(p)).toEqual({ cols: 120, rows: 40 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("resize decode returns null when payload too short", () => {
|
|
113
|
+
expect(decodeWsV3ResizePayload(new Uint8Array(4))).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("decode uses byteOffset/byteLength (subarray of larger buffer)", () => {
|
|
117
|
+
const inner = encodeWsV3Frame({
|
|
118
|
+
type: WsV3MessageType.PONG,
|
|
119
|
+
sessionId: "x",
|
|
120
|
+
payload: new Uint8Array(),
|
|
121
|
+
});
|
|
122
|
+
const padded = new Uint8Array(inner.byteLength + 4);
|
|
123
|
+
padded.set(inner, 2);
|
|
124
|
+
const slice = padded.subarray(2, 2 + inner.byteLength);
|
|
125
|
+
expect(decodeWsV3Frame(slice)).not.toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Based on VibeTunnel (MIT) — github.com/amantus-ai/vibetunnel
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WebSocket v3 framing for terminal transport.
|
|
5
|
+
*
|
|
6
|
+
* Frame:
|
|
7
|
+
* u16 magic = 0x5654 ("VT") LE
|
|
8
|
+
* u8 version = 3
|
|
9
|
+
* u8 type
|
|
10
|
+
* u32 sessionIdLen LE
|
|
11
|
+
* u8[] sessionId UTF-8
|
|
12
|
+
* u32 payloadLen LE
|
|
13
|
+
* u8[] payload
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const WS_V3_MAGIC = 0x5654;
|
|
17
|
+
export const WS_V3_VERSION = 3;
|
|
18
|
+
|
|
19
|
+
export enum WsV3MessageType {
|
|
20
|
+
HELLO = 1,
|
|
21
|
+
WELCOME = 2,
|
|
22
|
+
|
|
23
|
+
SUBSCRIBE = 10,
|
|
24
|
+
UNSUBSCRIBE = 11,
|
|
25
|
+
|
|
26
|
+
STDOUT = 20,
|
|
27
|
+
SNAPSHOT_VT = 21,
|
|
28
|
+
EVENT = 22,
|
|
29
|
+
ERROR = 23,
|
|
30
|
+
|
|
31
|
+
INPUT_TEXT = 30,
|
|
32
|
+
INPUT_KEY = 31,
|
|
33
|
+
RESIZE = 32,
|
|
34
|
+
KILL = 33,
|
|
35
|
+
RESET_SIZE = 34,
|
|
36
|
+
|
|
37
|
+
PING = 40,
|
|
38
|
+
PONG = 41,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type WsV3DecodedFrame = {
|
|
42
|
+
type: WsV3MessageType;
|
|
43
|
+
sessionId: string;
|
|
44
|
+
payload: Uint8Array;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function encodeWsV3Frame(params: {
|
|
48
|
+
type: WsV3MessageType;
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
payload?: Uint8Array;
|
|
51
|
+
}): Uint8Array {
|
|
52
|
+
const sessionId = params.sessionId ?? "";
|
|
53
|
+
const encoder = new TextEncoder();
|
|
54
|
+
const sessionIdBytes = encoder.encode(sessionId);
|
|
55
|
+
const payload = params.payload ?? new Uint8Array();
|
|
56
|
+
|
|
57
|
+
const headerLen = 2 + 1 + 1 + 4 + sessionIdBytes.length + 4;
|
|
58
|
+
const out = new Uint8Array(headerLen + payload.length);
|
|
59
|
+
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
|
60
|
+
|
|
61
|
+
let offset = 0;
|
|
62
|
+
view.setUint16(offset, WS_V3_MAGIC, true);
|
|
63
|
+
offset += 2;
|
|
64
|
+
view.setUint8(offset, WS_V3_VERSION);
|
|
65
|
+
offset += 1;
|
|
66
|
+
view.setUint8(offset, params.type);
|
|
67
|
+
offset += 1;
|
|
68
|
+
|
|
69
|
+
view.setUint32(offset, sessionIdBytes.length, true);
|
|
70
|
+
offset += 4;
|
|
71
|
+
out.set(sessionIdBytes, offset);
|
|
72
|
+
offset += sessionIdBytes.length;
|
|
73
|
+
|
|
74
|
+
view.setUint32(offset, payload.length, true);
|
|
75
|
+
offset += 4;
|
|
76
|
+
out.set(payload, offset);
|
|
77
|
+
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function decodeWsV3Frame(data: Uint8Array): WsV3DecodedFrame | null {
|
|
82
|
+
if (data.byteLength < 2 + 1 + 1 + 4 + 4) return null;
|
|
83
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
84
|
+
let offset = 0;
|
|
85
|
+
|
|
86
|
+
const magic = view.getUint16(offset, true);
|
|
87
|
+
offset += 2;
|
|
88
|
+
if (magic !== WS_V3_MAGIC) return null;
|
|
89
|
+
|
|
90
|
+
const version = view.getUint8(offset);
|
|
91
|
+
offset += 1;
|
|
92
|
+
if (version !== WS_V3_VERSION) return null;
|
|
93
|
+
|
|
94
|
+
const type = view.getUint8(offset) as WsV3MessageType;
|
|
95
|
+
offset += 1;
|
|
96
|
+
|
|
97
|
+
const sessionIdLen = view.getUint32(offset, true);
|
|
98
|
+
offset += 4;
|
|
99
|
+
if (offset + sessionIdLen > data.byteLength) return null;
|
|
100
|
+
const sessionIdBytes = data.subarray(offset, offset + sessionIdLen);
|
|
101
|
+
const sessionId = new TextDecoder().decode(sessionIdBytes);
|
|
102
|
+
offset += sessionIdLen;
|
|
103
|
+
|
|
104
|
+
if (offset + 4 > data.byteLength) return null;
|
|
105
|
+
const payloadLen = view.getUint32(offset, true);
|
|
106
|
+
offset += 4;
|
|
107
|
+
if (offset + payloadLen > data.byteLength) return null;
|
|
108
|
+
|
|
109
|
+
const payload = data.subarray(offset, offset + payloadLen);
|
|
110
|
+
return { type, sessionId, payload };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export enum WsV3SubscribeFlags {
|
|
114
|
+
Stdout = 1 << 0,
|
|
115
|
+
Snapshots = 1 << 1,
|
|
116
|
+
Events = 1 << 2,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function encodeWsV3SubscribePayload(params: {
|
|
120
|
+
flags: number;
|
|
121
|
+
snapshotMinIntervalMs?: number;
|
|
122
|
+
snapshotMaxIntervalMs?: number;
|
|
123
|
+
}): Uint8Array {
|
|
124
|
+
const out = new Uint8Array(12);
|
|
125
|
+
const view = new DataView(out.buffer);
|
|
126
|
+
view.setUint32(0, params.flags >>> 0, true);
|
|
127
|
+
view.setUint32(4, (params.snapshotMinIntervalMs ?? 0) >>> 0, true);
|
|
128
|
+
view.setUint32(8, (params.snapshotMaxIntervalMs ?? 0) >>> 0, true);
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function decodeWsV3SubscribePayload(payload: Uint8Array): {
|
|
133
|
+
flags: number;
|
|
134
|
+
snapshotMinIntervalMs: number;
|
|
135
|
+
snapshotMaxIntervalMs: number;
|
|
136
|
+
} | null {
|
|
137
|
+
if (payload.byteLength < 12) return null;
|
|
138
|
+
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
139
|
+
return {
|
|
140
|
+
flags: view.getUint32(0, true),
|
|
141
|
+
snapshotMinIntervalMs: view.getUint32(4, true),
|
|
142
|
+
snapshotMaxIntervalMs: view.getUint32(8, true),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function encodeWsV3ResizePayload(cols: number, rows: number): Uint8Array {
|
|
147
|
+
const out = new Uint8Array(8);
|
|
148
|
+
const view = new DataView(out.buffer);
|
|
149
|
+
view.setUint32(0, cols >>> 0, true);
|
|
150
|
+
view.setUint32(4, rows >>> 0, true);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function decodeWsV3ResizePayload(
|
|
155
|
+
payload: Uint8Array,
|
|
156
|
+
): { cols: number; rows: number } | null {
|
|
157
|
+
if (payload.byteLength < 8) return null;
|
|
158
|
+
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
|
|
159
|
+
return { cols: view.getUint32(0, true), rows: view.getUint32(4, true) };
|
|
160
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, expect } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, basename } from "node:path";
|
|
5
|
+
import { readConfig, writeConfig, getSessionName } from "./yaml-io.ts";
|
|
6
|
+
import type { IdeConfig } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-yaml-test-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("writeConfig + readConfig", () => {
|
|
19
|
+
it("round-trips a config object", () => {
|
|
20
|
+
const config: IdeConfig = {
|
|
21
|
+
name: "test-project",
|
|
22
|
+
rows: [
|
|
23
|
+
{
|
|
24
|
+
size: "70%",
|
|
25
|
+
panes: [
|
|
26
|
+
{ title: "Claude", command: "claude", role: "lead", focus: true },
|
|
27
|
+
{ title: "Shell", command: "zsh" },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
panes: [{ title: "Dev", command: "pnpm dev" }],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
writeConfig(tmpDir, config);
|
|
37
|
+
const { config: loaded, configPath } = readConfig(tmpDir);
|
|
38
|
+
|
|
39
|
+
expect(loaded.name).toBe("test-project");
|
|
40
|
+
expect(loaded.rows.length).toBe(2);
|
|
41
|
+
expect(loaded.rows[0]!.size).toBe("70%");
|
|
42
|
+
expect(loaded.rows[0]!.panes.length).toBe(2);
|
|
43
|
+
expect(loaded.rows[0]!.panes[0]!.title).toBe("Claude");
|
|
44
|
+
expect(loaded.rows[0]!.panes[0]!.role).toBe("lead");
|
|
45
|
+
expect(loaded.rows[0]!.panes[0]!.focus).toBe(true);
|
|
46
|
+
expect(configPath.endsWith("ide.yml")).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("preserves theme config", () => {
|
|
50
|
+
const config: IdeConfig = {
|
|
51
|
+
rows: [{ panes: [{}] }],
|
|
52
|
+
theme: { accent: "colour75", border: "colour238" },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
writeConfig(tmpDir, config);
|
|
56
|
+
const { config: loaded } = readConfig(tmpDir);
|
|
57
|
+
expect(loaded.theme?.accent).toBe("colour75");
|
|
58
|
+
expect(loaded.theme?.border).toBe("colour238");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("preserves orchestrator config", () => {
|
|
62
|
+
const config: IdeConfig = {
|
|
63
|
+
rows: [{ panes: [{}] }],
|
|
64
|
+
orchestrator: {
|
|
65
|
+
enabled: true,
|
|
66
|
+
auto_dispatch: true,
|
|
67
|
+
stall_timeout: 300000,
|
|
68
|
+
dispatch_mode: "goals",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
writeConfig(tmpDir, config);
|
|
73
|
+
const { config: loaded } = readConfig(tmpDir);
|
|
74
|
+
expect(loaded.orchestrator?.enabled).toBe(true);
|
|
75
|
+
expect(loaded.orchestrator?.dispatch_mode).toBe("goals");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("preserves team config", () => {
|
|
79
|
+
const config: IdeConfig = {
|
|
80
|
+
rows: [{ panes: [{}] }],
|
|
81
|
+
team: { name: "my-team", model: "opus", permissions: ["read", "write"] },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
writeConfig(tmpDir, config);
|
|
85
|
+
const { config: loaded } = readConfig(tmpDir);
|
|
86
|
+
expect(loaded.team?.name).toBe("my-team");
|
|
87
|
+
expect(loaded.team?.model).toBe("opus");
|
|
88
|
+
expect(loaded.team?.permissions).toEqual(["read", "write"]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("readConfig", () => {
|
|
93
|
+
it("throws when no ide.yml exists", () => {
|
|
94
|
+
expect(() => readConfig(tmpDir)).toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("throws on malformed YAML", () => {
|
|
98
|
+
writeFileSync(join(tmpDir, "ide.yml"), ": invalid: yaml: [");
|
|
99
|
+
expect(() => readConfig(tmpDir)).toThrow();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("getSessionName", () => {
|
|
104
|
+
it("returns name from config", () => {
|
|
105
|
+
const config: IdeConfig = { name: "my-project", rows: [{ panes: [{}] }] };
|
|
106
|
+
writeConfig(tmpDir, config);
|
|
107
|
+
const result = getSessionName(tmpDir);
|
|
108
|
+
expect(result.name).toBe("my-project");
|
|
109
|
+
expect(result.source).toBe("config");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("falls back to directory basename when no name in config", () => {
|
|
113
|
+
const config: IdeConfig = { rows: [{ panes: [{}] }] };
|
|
114
|
+
writeConfig(tmpDir, config);
|
|
115
|
+
const result = getSessionName(tmpDir);
|
|
116
|
+
expect(result.name).toBe(basename(tmpDir));
|
|
117
|
+
expect(result.source).toBe("fallback");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("falls back to directory basename when no config file", () => {
|
|
121
|
+
const result = getSessionName(tmpDir);
|
|
122
|
+
expect(result.name).toBe(basename(tmpDir));
|
|
123
|
+
expect(result.source).toBe("fallback");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { resolve, basename } from "node:path";
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
|
+
import type { IdeConfig } from "../types.ts";
|
|
4
5
|
|
|
5
|
-
export function readConfig(dir) {
|
|
6
|
+
export function readConfig(dir: string): { config: IdeConfig; configPath: string } {
|
|
6
7
|
const configPath = resolve(dir, "ide.yml");
|
|
7
8
|
const raw = readFileSync(configPath, "utf-8");
|
|
8
|
-
const config = yaml.load(raw);
|
|
9
|
+
const config = yaml.load(raw) as IdeConfig;
|
|
9
10
|
return { config, configPath };
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export function writeConfig(dir, config) {
|
|
13
|
+
export function writeConfig(dir: string, config: IdeConfig): string {
|
|
13
14
|
const configPath = resolve(dir, "ide.yml");
|
|
14
15
|
const out = yaml.dump(config, { lineWidth: -1, noRefs: true, quotingType: '"' });
|
|
15
16
|
writeFileSync(configPath, out);
|
|
16
17
|
return configPath;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export function getSessionName(dir) {
|
|
20
|
+
export function getSessionName(dir: string): { name: string; source: "config" | "fallback" } {
|
|
20
21
|
try {
|
|
21
22
|
const { config } = readConfig(dir);
|
|
22
23
|
return { name: config.name ?? basename(dir), source: config.name ? "config" : "fallback" };
|