kimaki 0.4.77 → 0.4.78

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.
@@ -0,0 +1,314 @@
1
+ // Tracks tool-driven file changes and writes per-message/session .patch files
2
+ // to $KIMAKI_DATA_DIR/diffs/ (default ~/.kimaki/diffs/).
3
+ //
4
+ // The bot process reads these patch files in emitFooter() to upload them via
5
+ // critique and append [ses diff] / [msg diff] links to the footer message.
6
+ //
7
+ // Lifecycle:
8
+ // tool.execute.before → snapshot file contents before edit
9
+ // tool.execute.after → snapshot again, record the delta
10
+ // message.updated (completed) → write {sessionId}-{messageId}.patch
11
+ // session.idle → write {sessionId}.patch (cumulative, overwrites)
12
+ // session.deleted → cleanup in-memory state
13
+ //
14
+ // Pruning: caps the diffs folder at MAX_PATCH_FILES, deleting oldest by mtime.
15
+ import { createTwoFilesPatch } from 'diff';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { extractPatchFilePaths } from './patch-text-parser.js';
19
+ const TOOLS = new Set(['edit', 'write', 'apply_patch', 'multiedit']);
20
+ // Skip files larger than 512KB to avoid memory spikes
21
+ const MAX_FILE_BYTES = 512 * 1024;
22
+ // Max patch files before pruning oldest
23
+ const MAX_PATCH_FILES = 200;
24
+ // Pending tool calls older than 10 min are garbage collected
25
+ const PENDING_TTL_MS = 10 * 60 * 1000;
26
+ function extractPaths(tool, args) {
27
+ if (tool === 'edit' || tool === 'write' || tool === 'multiedit') {
28
+ return typeof args.filePath === 'string' ? [args.filePath] : [];
29
+ }
30
+ if (tool === 'apply_patch') {
31
+ const text = (args.patchText ?? args.patch ?? '');
32
+ return extractPatchFilePaths(text);
33
+ }
34
+ return [];
35
+ }
36
+ function buildPatch(files) {
37
+ const parts = [];
38
+ for (const [file, { first, last }] of files) {
39
+ if (first === last) {
40
+ continue;
41
+ }
42
+ parts.push(createTwoFilesPatch(file, file, first, last));
43
+ }
44
+ return parts.join('\n');
45
+ }
46
+ async function readSafe(file) {
47
+ const stat = await fs.promises.stat(file).catch(() => {
48
+ return undefined;
49
+ });
50
+ if (!stat) {
51
+ return '';
52
+ }
53
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) {
54
+ return undefined;
55
+ }
56
+ return fs.promises.readFile(file, 'utf8').catch(() => {
57
+ return '';
58
+ });
59
+ }
60
+ async function prunePatchDir(dir) {
61
+ const entries = await fs.promises.readdir(dir).catch(() => {
62
+ return [];
63
+ });
64
+ if (entries.length <= MAX_PATCH_FILES) {
65
+ return;
66
+ }
67
+ const files = (await Promise.all(entries.map(async (name) => {
68
+ const full = path.join(dir, name);
69
+ const stat = await fs.promises.stat(full).catch(() => {
70
+ return undefined;
71
+ });
72
+ return stat?.isFile() ? { full, mtime: stat.mtimeMs } : undefined;
73
+ }))).filter((f) => {
74
+ return f !== undefined;
75
+ });
76
+ files.sort((a, b) => {
77
+ return b.mtime - a.mtime;
78
+ });
79
+ await Promise.all(files.slice(MAX_PATCH_FILES).map((f) => {
80
+ return fs.promises.unlink(f.full).catch(() => { });
81
+ }));
82
+ }
83
+ // Atomic write: write to a temp file then rename to avoid torn reads
84
+ // from the bot process reading a partially-written patch.
85
+ // If content is empty, deletes any existing file so stale patches from
86
+ // previous turns aren't re-uploaded by the bot.
87
+ async function writePatch({ dir, name, content, }) {
88
+ const target = path.join(dir, name);
89
+ if (!content) {
90
+ await fs.promises.unlink(target).catch(() => { });
91
+ return;
92
+ }
93
+ await fs.promises.mkdir(dir, { recursive: true });
94
+ const tmp = `${target}.tmp.${process.pid}`;
95
+ await fs.promises.writeFile(tmp, content);
96
+ await fs.promises.rename(tmp, target);
97
+ await prunePatchDir(dir);
98
+ }
99
+ const diffPatchPlugin = async (input) => {
100
+ const dataDir = process.env.KIMAKI_DATA_DIR || path.join(process.env.HOME || '~', '.kimaki');
101
+ const dir = path.join(dataDir, 'diffs');
102
+ // session → file → { first snapshot, last snapshot }
103
+ const sessions = new Map();
104
+ // session → messageId → file → { first, last }
105
+ const messages = new Map();
106
+ // callID → pending tool info (for matching before/after)
107
+ const pending = new Map();
108
+ // session → current active assistant messageId
109
+ const activeMessage = new Map();
110
+ // dedup guard: "sessionID:messageID:completedTimestamp"
111
+ const completed = new Set();
112
+ // child session → root (top-level) parent session.
113
+ // When a task tool spawns a subtask, tools inside that subtask fire with
114
+ // the child sessionID. We resolve up to the root parent so file snapshots
115
+ // always roll up into the top-level session's cumulative patch.
116
+ const childToRoot = new Map();
117
+ /** Resolve up the parent chain to find the root (top-level) session. */
118
+ function resolveRoot(sessionID) {
119
+ return childToRoot.get(sessionID) ?? sessionID;
120
+ }
121
+ function ensureSession(sessionID) {
122
+ if (!sessions.has(sessionID)) {
123
+ sessions.set(sessionID, new Map());
124
+ }
125
+ if (!messages.has(sessionID)) {
126
+ messages.set(sessionID, new Map());
127
+ }
128
+ }
129
+ function ensureMessage(sessionID, messageID) {
130
+ const msgs = messages.get(sessionID);
131
+ if (!msgs.has(messageID)) {
132
+ msgs.set(messageID, new Map());
133
+ }
134
+ }
135
+ function setFirst(map, file, content) {
136
+ if (!map.has(file)) {
137
+ map.set(file, { first: content, last: content });
138
+ }
139
+ }
140
+ function prunePending() {
141
+ const now = Date.now();
142
+ for (const [id, item] of pending) {
143
+ if (now - item.createdAt > PENDING_TTL_MS) {
144
+ pending.delete(id);
145
+ }
146
+ }
147
+ }
148
+ function cleanupSession(sessionID) {
149
+ sessions.delete(sessionID);
150
+ messages.delete(sessionID);
151
+ activeMessage.delete(sessionID);
152
+ for (const [id, p] of pending) {
153
+ if (p.sessionID === sessionID) {
154
+ pending.delete(id);
155
+ }
156
+ }
157
+ for (const key of completed) {
158
+ if (key.startsWith(sessionID + ':')) {
159
+ completed.delete(key);
160
+ }
161
+ }
162
+ // Clean up child→root mappings pointing to or from this session
163
+ for (const [child, root] of childToRoot) {
164
+ if (child === sessionID || root === sessionID) {
165
+ childToRoot.delete(child);
166
+ }
167
+ }
168
+ }
169
+ return {
170
+ 'tool.execute.before': async ({ tool, sessionID, callID }, { args }) => {
171
+ if (!TOOLS.has(tool)) {
172
+ return;
173
+ }
174
+ const raw = extractPaths(tool, args);
175
+ if (!raw.length) {
176
+ return;
177
+ }
178
+ prunePending();
179
+ // Resolve root session so child edits roll up into the parent's patch.
180
+ const rootID = resolveRoot(sessionID);
181
+ ensureSession(rootID);
182
+ const rootFiles = sessions.get(rootID);
183
+ const msgID = activeMessage.get(rootID);
184
+ const captured = [];
185
+ for (const r of raw) {
186
+ const p = path.isAbsolute(r)
187
+ ? r
188
+ : path.resolve(input.directory, r);
189
+ const content = await readSafe(p);
190
+ if (content === undefined) {
191
+ continue;
192
+ }
193
+ setFirst(rootFiles, p, content);
194
+ if (msgID) {
195
+ ensureMessage(rootID, msgID);
196
+ setFirst(messages.get(rootID).get(msgID), p, content);
197
+ }
198
+ captured.push(p);
199
+ }
200
+ if (captured.length) {
201
+ pending.set(callID, {
202
+ // Store rootID so tool.execute.after updates the correct maps
203
+ sessionID: rootID,
204
+ messageID: msgID,
205
+ paths: captured,
206
+ createdAt: Date.now(),
207
+ });
208
+ }
209
+ },
210
+ 'tool.execute.after': async ({ callID }) => {
211
+ prunePending();
212
+ const call = pending.get(callID);
213
+ if (!call) {
214
+ return;
215
+ }
216
+ pending.delete(callID);
217
+ const sessionFiles = sessions.get(call.sessionID);
218
+ // Use messageID captured at before-time, not current activeMessage
219
+ const msgFiles = call.messageID
220
+ ? messages.get(call.sessionID)?.get(call.messageID)
221
+ : undefined;
222
+ for (const p of call.paths) {
223
+ const content = await readSafe(p);
224
+ if (content === undefined) {
225
+ continue;
226
+ }
227
+ const s = sessionFiles.get(p);
228
+ if (s) {
229
+ s.last = content;
230
+ }
231
+ if (msgFiles) {
232
+ const m = msgFiles.get(p);
233
+ if (m) {
234
+ m.last = content;
235
+ }
236
+ }
237
+ }
238
+ },
239
+ event: async ({ event }) => {
240
+ // Track child→root session mapping from session.updated events.
241
+ // OpenCode emits session.updated with parentID set when a task tool
242
+ // creates a child session. This fires BEFORE the child starts executing
243
+ // tools, so the mapping is ready when child tool.execute.before fires.
244
+ if (event.type === 'session.updated') {
245
+ const info = event.properties.info;
246
+ if (info.id && info.parentID) {
247
+ const rootID = resolveRoot(info.parentID);
248
+ childToRoot.set(info.id, rootID);
249
+ }
250
+ }
251
+ if (event.type === 'message.updated' &&
252
+ event.properties.info.role === 'assistant') {
253
+ const msg = event.properties.info;
254
+ activeMessage.set(msg.sessionID, msg.id);
255
+ if (msg.time.completed) {
256
+ const key = `${msg.sessionID}:${msg.id}:${msg.time.completed}`;
257
+ if (completed.has(key)) {
258
+ return;
259
+ }
260
+ completed.add(key);
261
+ const msgFiles = messages.get(msg.sessionID)?.get(msg.id);
262
+ if (msgFiles?.size) {
263
+ const patch = buildPatch(msgFiles);
264
+ await writePatch({
265
+ dir,
266
+ name: `${msg.sessionID}-${msg.id}.patch`,
267
+ content: patch,
268
+ });
269
+ }
270
+ messages.get(msg.sessionID)?.delete(msg.id);
271
+ }
272
+ return;
273
+ }
274
+ if (event.type === 'session.idle') {
275
+ const sessionID = event.properties.sessionID;
276
+ // Child sessions: edits already rolled up into root, skip patch write.
277
+ if (childToRoot.has(sessionID)) {
278
+ return;
279
+ }
280
+ const sessionFiles = sessions.get(sessionID);
281
+ if (sessionFiles?.size) {
282
+ const patch = buildPatch(sessionFiles);
283
+ await writePatch({
284
+ dir,
285
+ name: `${sessionID}.patch`,
286
+ content: patch,
287
+ });
288
+ // Reset first snapshots to current state so next turn is incremental
289
+ for (const state of sessionFiles.values()) {
290
+ state.first = state.last;
291
+ }
292
+ }
293
+ messages.get(sessionID)?.clear();
294
+ activeMessage.delete(sessionID);
295
+ for (const [id, p] of pending) {
296
+ if (p.sessionID === sessionID) {
297
+ pending.delete(id);
298
+ }
299
+ }
300
+ // Clear completed dedup keys for this session to bound memory growth
301
+ for (const key of completed) {
302
+ if (key.startsWith(sessionID + ':')) {
303
+ completed.delete(key);
304
+ }
305
+ }
306
+ return;
307
+ }
308
+ if (event.type === 'session.deleted') {
309
+ cleanupSession(event.properties.info.id);
310
+ }
311
+ },
312
+ };
313
+ };
314
+ export default diffPatchPlugin;
@@ -599,7 +599,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
599
599
  });
600
600
  }
601
601
  else {
602
- discordLogger.log(`Channel type ${channel.type} is not supported`);
602
+ // discordLogger.log(`Channel type ${channel.type} is not supported`)
603
603
  }
604
604
  }
605
605
  catch (error) {
@@ -37,6 +37,7 @@ import { handleContextUsageCommand } from './commands/context-usage.js';
37
37
  import { handleSessionIdCommand } from './commands/session-id.js';
38
38
  import { handleUpgradeAndRestartCommand } from './commands/upgrade.js';
39
39
  import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
40
+ import { handleScreenshareCommand, handleScreenshareStopCommand, } from './commands/screenshare.js';
40
41
  import { handleModelVariantSelectMenu } from './commands/model.js';
41
42
  import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
42
43
  import { hasKimakiBotPermission } from './discord-utils.js';
@@ -211,6 +212,15 @@ export function registerInteractionHandler({ discordClient, appId, }) {
211
212
  case 'mcp':
212
213
  await handleMcpCommand({ command: interaction, appId });
213
214
  return;
215
+ case 'screenshare':
216
+ await handleScreenshareCommand({ command: interaction, appId });
217
+ return;
218
+ case 'screenshare-stop':
219
+ await handleScreenshareStopCommand({
220
+ command: interaction,
221
+ appId,
222
+ });
223
+ return;
214
224
  }
215
225
  // Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
216
226
  if (interaction.commandName.endsWith('-agent') &&
@@ -5,6 +5,7 @@ import * as errore from 'errore';
5
5
  import { createLogger, LogPrefix } from './logger.js';
6
6
  import { FetchError } from './errors.js';
7
7
  import { processImage } from './image-utils.js';
8
+ import { parsePatchFileCounts } from './patch-text-parser.js';
8
9
  const logger = createLogger(LogPrefix.FORMATTING);
9
10
  /**
10
11
  * Resolves Discord mentions in message content to human-readable names.
@@ -36,67 +37,7 @@ export function resolveMentions(message) {
36
37
  function escapeInlineMarkdown(text) {
37
38
  return text.replace(/([*_~|`\\])/g, '\\$1');
38
39
  }
39
- /**
40
- * Parses a patchText string (apply_patch format) and counts additions/deletions per file.
41
- * Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
42
- * with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
43
- */
44
- function parsePatchCounts(patchText) {
45
- const counts = new Map();
46
- const lines = patchText.split('\n');
47
- let currentFile = '';
48
- let currentType = '';
49
- let inHunk = false;
50
- for (const line of lines) {
51
- const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/);
52
- const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/);
53
- const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/);
54
- if (addMatch || updateMatch || deleteMatch) {
55
- const match = addMatch || updateMatch || deleteMatch;
56
- currentFile = (match?.[1] ?? '').trim();
57
- currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete';
58
- counts.set(currentFile, { additions: 0, deletions: 0 });
59
- inHunk = false;
60
- continue;
61
- }
62
- if (line.startsWith('@@')) {
63
- inHunk = true;
64
- continue;
65
- }
66
- if (line.startsWith('*** ')) {
67
- inHunk = false;
68
- continue;
69
- }
70
- if (!currentFile) {
71
- continue;
72
- }
73
- const entry = counts.get(currentFile);
74
- if (!entry) {
75
- continue;
76
- }
77
- if (currentType === 'add') {
78
- // all content lines in Add File are additions
79
- if (line.length > 0 && !line.startsWith('*** ')) {
80
- entry.additions++;
81
- }
82
- }
83
- else if (currentType === 'delete') {
84
- // all content lines in Delete File are deletions
85
- if (line.length > 0 && !line.startsWith('*** ')) {
86
- entry.deletions++;
87
- }
88
- }
89
- else if (inHunk) {
90
- if (line.startsWith('+')) {
91
- entry.additions++;
92
- }
93
- else if (line.startsWith('-')) {
94
- entry.deletions++;
95
- }
96
- }
97
- }
98
- return counts;
99
- }
40
+ // parsePatchCounts → imported from patch-text-parser.ts as parsePatchFileCounts
100
41
  /**
101
42
  * Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
102
43
  */
@@ -220,7 +161,7 @@ export function getToolSummaryText(part) {
220
161
  if (!patchText) {
221
162
  return '';
222
163
  }
223
- const patchCounts = parsePatchCounts(patchText);
164
+ const patchCounts = parsePatchFileCounts(patchText);
224
165
  return [...patchCounts.entries()]
225
166
  .map(([filePath, { additions, deletions }]) => {
226
167
  const fileName = filePath.split('/').pop() || '';
@@ -42,7 +42,7 @@ const onboardingTutorialPlugin = async () => {
42
42
  return;
43
43
  }
44
44
  output.parts.push({
45
- id: crypto.randomUUID(),
45
+ id: `prt_${crypto.randomUUID()}`,
46
46
  sessionID,
47
47
  messageID: firstText.messageID,
48
48
  type: 'text',
@@ -267,7 +267,7 @@ const kimakiPlugin = async ({ directory }) => {
267
267
  if (memoryContent) {
268
268
  const condensed = condenseMemoryMd(memoryContent);
269
269
  output.parts.push({
270
- id: crypto.randomUUID(),
270
+ id: `prt_${crypto.randomUUID()}`,
271
271
  sessionID,
272
272
  messageID,
273
273
  type: 'text',
@@ -304,7 +304,7 @@ const kimakiPlugin = async ({ directory }) => {
304
304
  hour12: false,
305
305
  });
306
306
  output.parts.push({
307
- id: crypto.randomUUID(),
307
+ id: `prt_${crypto.randomUUID()}`,
308
308
  sessionID,
309
309
  messageID,
310
310
  type: 'text',
@@ -315,7 +315,7 @@ const kimakiPlugin = async ({ directory }) => {
315
315
  // When the user comes back after a long break, remind the model
316
316
  // to save any important context from the previous conversation.
317
317
  output.parts.push({
318
- id: crypto.randomUUID(),
318
+ id: `prt_${crypto.randomUUID()}`,
319
319
  sessionID,
320
320
  messageID,
321
321
  type: 'text',
@@ -340,7 +340,7 @@ const kimakiPlugin = async ({ directory }) => {
340
340
  })();
341
341
  sessionGitStates.set(sessionID, gitState);
342
342
  output.parts.push({
343
- id: crypto.randomUUID(),
343
+ id: `prt_${crypto.randomUUID()}`,
344
344
  sessionID,
345
345
  messageID,
346
346
  type: 'text',
@@ -0,0 +1,97 @@
1
+ // Shared apply_patch text parsing utilities.
2
+ // Used by diff-patch-plugin.ts (file path extraction for snapshots) and
3
+ // message-formatting.ts (per-file addition/deletion counts for Discord display).
4
+ //
5
+ // The apply_patch tool uses three path header formats:
6
+ // *** Add File: path — new file
7
+ // *** Update File: path — existing file edit
8
+ // *** Delete File: path — file removal
9
+ // *** Move to: path — rename destination
10
+ // --- a/path / +++ b/path — unified diff headers (fallback)
11
+ /**
12
+ * Extract all file paths referenced in a patchText string.
13
+ * Handles custom apply_patch headers, move targets, and unified diff headers.
14
+ * Returns deduplicated paths.
15
+ */
16
+ export function extractPatchFilePaths(patchText) {
17
+ const custom = [
18
+ ...patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File:\s+(.+)$/gm),
19
+ ].map((m) => {
20
+ return (m[1] ?? '').trim();
21
+ });
22
+ const moved = [
23
+ ...patchText.matchAll(/^\*\*\* Move to:\s+(.+)$/gm),
24
+ ].map((m) => {
25
+ return (m[1] ?? '').trim();
26
+ });
27
+ const unified = [
28
+ ...patchText.matchAll(/^(?:---|\+\+\+) [ab]\/(.+)$/gm),
29
+ ].map((m) => {
30
+ return (m[1] ?? '').trim();
31
+ });
32
+ const all = [...custom, ...moved, ...unified].filter(Boolean);
33
+ return all.filter((v, i, a) => {
34
+ return a.indexOf(v) === i;
35
+ });
36
+ }
37
+ /**
38
+ * Parse a patchText string and count additions/deletions per file.
39
+ * Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
40
+ * with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
41
+ */
42
+ export function parsePatchFileCounts(patchText) {
43
+ const counts = new Map();
44
+ const lines = patchText.split('\n');
45
+ let currentFile = '';
46
+ let currentType = '';
47
+ let inHunk = false;
48
+ for (const line of lines) {
49
+ const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/);
50
+ const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/);
51
+ const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/);
52
+ if (addMatch || updateMatch || deleteMatch) {
53
+ const match = addMatch || updateMatch || deleteMatch;
54
+ currentFile = (match?.[1] ?? '').trim();
55
+ currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete';
56
+ counts.set(currentFile, { additions: 0, deletions: 0 });
57
+ inHunk = false;
58
+ continue;
59
+ }
60
+ if (line.startsWith('@@')) {
61
+ inHunk = true;
62
+ continue;
63
+ }
64
+ if (line.startsWith('*** ')) {
65
+ inHunk = false;
66
+ continue;
67
+ }
68
+ if (!currentFile) {
69
+ continue;
70
+ }
71
+ const entry = counts.get(currentFile);
72
+ if (!entry) {
73
+ continue;
74
+ }
75
+ if (currentType === 'add') {
76
+ // all content lines in Add File are additions
77
+ if (line.length > 0 && !line.startsWith('*** ')) {
78
+ entry.additions++;
79
+ }
80
+ }
81
+ else if (currentType === 'delete') {
82
+ // all content lines in Delete File are deletions
83
+ if (line.length > 0 && !line.startsWith('*** ')) {
84
+ entry.deletions++;
85
+ }
86
+ }
87
+ else if (inHunk) {
88
+ if (line.startsWith('+')) {
89
+ entry.additions++;
90
+ }
91
+ else if (line.startsWith('-')) {
92
+ entry.deletions++;
93
+ }
94
+ }
95
+ }
96
+ return counts;
97
+ }
@@ -2686,7 +2686,7 @@ export class ThreadSessionRuntime {
2686
2686
  let contextInfo = '';
2687
2687
  const folderName = path.basename(this.sdkDirectory);
2688
2688
  const client = getOpencodeClient(this.projectDirectory);
2689
- // Run git branch, token fetch, and provider list in parallel
2689
+ // Run git branch and token fetch in parallel (fast, no external CLI)
2690
2690
  const [branchResult, contextResult] = await Promise.all([
2691
2691
  errore.tryAsync(() => {
2692
2692
  return execAsync('git symbolic-ref --short HEAD', {
@@ -0,0 +1,69 @@
1
+ // In-process WebSocket-to-TCP bridge (websockify replacement).
2
+ // Accepts WebSocket connections and pipes raw bytes to/from a TCP target.
3
+ // Used by /screenshare to bridge noVNC (WebSocket) to a VNC server (TCP).
4
+ // Supports the 'binary' subprotocol required by noVNC.
5
+ import { WebSocketServer, WebSocket } from 'ws';
6
+ import net from 'node:net';
7
+ import { createLogger } from './logger.js';
8
+ const logger = createLogger('SCREEN');
9
+ export function startWebsockify({ wsPort, tcpHost, tcpPort, }) {
10
+ return new Promise((resolve, reject) => {
11
+ const wss = new WebSocketServer({
12
+ port: wsPort,
13
+ // noVNC negotiates the 'binary' subprotocol
14
+ handleProtocols: (protocols) => {
15
+ if (protocols.has('binary')) {
16
+ return 'binary';
17
+ }
18
+ return false;
19
+ },
20
+ });
21
+ wss.on('listening', () => {
22
+ const addr = wss.address();
23
+ const port = typeof addr === 'object' && addr ? addr.port : wsPort;
24
+ logger.log(`Websockify listening on port ${port} → ${tcpHost}:${tcpPort}`);
25
+ resolve({
26
+ wss,
27
+ port,
28
+ close: () => {
29
+ for (const client of wss.clients) {
30
+ client.close();
31
+ }
32
+ wss.close();
33
+ },
34
+ });
35
+ });
36
+ wss.on('error', (err) => {
37
+ reject(new Error('Websockify failed to start', { cause: err }));
38
+ });
39
+ wss.on('connection', (ws) => {
40
+ const tcp = net.createConnection(tcpPort, tcpHost, () => {
41
+ logger.log(`TCP connection established to ${tcpHost}:${tcpPort}`);
42
+ });
43
+ tcp.on('data', (data) => {
44
+ if (ws.readyState === WebSocket.OPEN) {
45
+ ws.send(data);
46
+ }
47
+ });
48
+ ws.on('message', (data) => {
49
+ if (!tcp.destroyed) {
50
+ tcp.write(data);
51
+ }
52
+ });
53
+ ws.on('close', () => {
54
+ tcp.destroy();
55
+ });
56
+ ws.on('error', (err) => {
57
+ logger.error('WebSocket error:', err);
58
+ tcp.destroy();
59
+ });
60
+ tcp.on('close', () => {
61
+ ws.close();
62
+ });
63
+ tcp.on('error', (err) => {
64
+ logger.error('TCP connection error:', err);
65
+ ws.close();
66
+ });
67
+ });
68
+ });
69
+ }