@a-t-h-i/bot-lobby 0.2.0 → 0.4.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/prompts/worker.md CHANGED
@@ -25,8 +25,41 @@ section of your output instead and continue with the rest of the work.
25
25
 
26
26
  ## Testing
27
27
 
28
- Run the project's existing test commands. New public behavior, endpoints, and
29
- bug fixes require appropriate tests before claiming completion.
28
+ Run the targeted tests for what you changed (the files and behavior you
29
+ touched); the QA gate runs the full suite afterwards. New public behavior,
30
+ endpoints, and bug fixes require appropriate tests before claiming completion.
31
+
32
+ - Always pass a bash `timeout` to tests and builds (for example 300 seconds).
33
+ - Never start dev servers, watch mode or other long-running processes, and
34
+ never leave background processes behind.
35
+ - Change files with `edit`/`write`, not through shell redirection or `sed -i`.
36
+
37
+ ## Time budget
38
+
39
+ Work efficiently: read what you need, make the change, verify, report. If the
40
+ engine asks you to wrap up, stop exploring, leave every file consistent, and
41
+ write your report with anything unfinished under Blockers or Notes.
42
+
43
+ ## Working alongside other workers (file desk)
44
+
45
+ When the `claim_file` tool is available, other workers are editing the same
46
+ repository at the same time, and files are checked out like physical documents:
47
+
48
+ - Before editing or writing a file, call `claim_file` with the path and a
49
+ one-line intent (what you are about to do to it). Reading never needs a claim.
50
+ - If another worker holds the file, you are queued: keep working on your other
51
+ files instead of waiting. You will be told when it is handed to you, together
52
+ with the previous holder's notes; re-read the file before editing it.
53
+ - You are always told who is queued behind you on files you hold, and what they
54
+ intend to do. As soon as you are finished with a file, call `handover_file`
55
+ with a short note written for the next worker's intent: what you changed,
56
+ what they should build on, and what to watch out for. The desk hands it to
57
+ whoever is next.
58
+ - Use `my_files` to see what you hold, what you are waiting for, and each
59
+ queue. Use `wait_for_files` only when nothing else is left to do, and hand
60
+ over every file someone is waiting for first.
61
+ - Files you still hold are handed over automatically when you finish, so
62
+ release them early whenever others are waiting.
30
63
 
31
64
  ## Before handoff
32
65
 
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The file desk inside a parallel worker's pi process. Registered only when
3
+ * bot-lobby runs as a subagent with a desk address in its environment: it
4
+ * refuses `edit`/`write` on files the worker has not checked out, and gives
5
+ * the worker the tools to claim, hand over and wait for files.
6
+ */
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "typebox";
9
+ import { createDeskClient, type DeskClient } from "./ipc.ts";
10
+ import { DESK_ENV, isEditTool, MAX_WAIT_MS } from "./session.ts";
11
+
12
+ function text(value: string) {
13
+ return { content: [{ type: "text" as const, text: value }], details: undefined };
14
+ }
15
+
16
+ function editPath(input: unknown): string | undefined {
17
+ if (!input || typeof input !== "object") return undefined;
18
+ const record = input as Record<string, unknown>;
19
+ const path = record.path ?? record.file_path;
20
+ return typeof path === "string" ? path : undefined;
21
+ }
22
+
23
+ /** True when this process is a worker in a parallel batch. */
24
+ export function deskAddress(env: NodeJS.ProcessEnv = process.env): { address: string; worker: string } | undefined {
25
+ const address = env[DESK_ENV.address];
26
+ const worker = env[DESK_ENV.worker];
27
+ return address && worker ? { address, worker } : undefined;
28
+ }
29
+
30
+ export function registerDeskClient(pi: ExtensionAPI, client?: DeskClient): void {
31
+ const target = deskAddress();
32
+ if (!target && !client) return;
33
+ const desk = client ?? createDeskClient(target!.address, target!.worker);
34
+
35
+ pi.on("session_start", () => {
36
+ void desk.request({ op: "hello" });
37
+ });
38
+ pi.on("session_shutdown", () => desk.close());
39
+
40
+ pi.on("tool_call", async (event) => {
41
+ if (!isEditTool(event.toolName)) return undefined;
42
+ const path = editPath(event.input);
43
+ if (!path) return undefined;
44
+ const answer = await desk.request({ op: "check", path });
45
+ // If the desk is gone the batch is over; never wedge the worker on it.
46
+ if (!answer.ok || answer.allowed) return undefined;
47
+ return { block: true, reason: answer.text };
48
+ });
49
+
50
+ pi.registerTool({
51
+ name: "claim_file",
52
+ label: "Claim file",
53
+ description:
54
+ "Check a repository file out from the file desk before editing it. Give the path and a one-line intent (what you are about to change). A free file is yours at once; a busy one queues you behind its holder, who is told your intent.",
55
+ parameters: Type.Object({
56
+ path: Type.String({ description: "File path, relative to the repository root" }),
57
+ intent: Type.String({ description: "One line: what you will change in this file" }),
58
+ }),
59
+ async execute(_id, params) {
60
+ return text((await desk.request({ op: "claim", path: params.path, intent: params.intent })).text);
61
+ },
62
+ });
63
+
64
+ pi.registerTool({
65
+ name: "handover_file",
66
+ label: "Hand over file",
67
+ description:
68
+ "Release a file you hold to the next worker in its queue. Write the note for their stated intent: what you changed, what to build on, and what to watch out for.",
69
+ parameters: Type.Object({
70
+ path: Type.String({ description: "File path you hold" }),
71
+ note: Type.String({ description: "Handover note for the next worker" }),
72
+ }),
73
+ async execute(_id, params) {
74
+ return text((await desk.request({ op: "handover", path: params.path, note: params.note })).text);
75
+ },
76
+ });
77
+
78
+ pi.registerTool({
79
+ name: "my_files",
80
+ label: "My files",
81
+ description: "List the files you hold (with who is queued behind you and why) and the files you are queued for.",
82
+ parameters: Type.Object({}),
83
+ async execute() {
84
+ return text((await desk.request({ op: "mine" })).text);
85
+ },
86
+ });
87
+
88
+ pi.registerTool({
89
+ name: "wait_for_files",
90
+ label: "Wait for files",
91
+ description:
92
+ "Wait until a file you are queued for is handed to you (up to 2 minutes). Use only when nothing else is left to do; hand over files others wait for first.",
93
+ parameters: Type.Object({
94
+ seconds: Type.Optional(Type.Number({ description: "How long to wait, at most 120 seconds" })),
95
+ }),
96
+ async execute(_id, params) {
97
+ const timeoutMs = Math.min(MAX_WAIT_MS, Math.max(1, params.seconds ?? 120) * 1000);
98
+ return text((await desk.request({ op: "wait", timeoutMs }, timeoutMs + 5000)).text);
99
+ },
100
+ });
101
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The file desk: checkout and handover of repository files between workers
3
+ * that run in parallel, the way people share a physical document.
4
+ *
5
+ * A worker claims a file with a one-line intent before editing it. A free
6
+ * file is granted at once; a held file queues the claimant behind the holder,
7
+ * and the holder is told who is waiting and what they intend. When the holder
8
+ * hands the file over (with a note written for the next worker's intent), or
9
+ * finishes, the file passes to the head of the queue together with that note.
10
+ * Reads never need a claim. Everything lives in the master process, so a
11
+ * crashed worker can never leave a stale lock behind.
12
+ */
13
+ import { isAbsolute, relative, resolve } from "node:path";
14
+
15
+ export type WorkerId = string;
16
+
17
+ export interface Claim {
18
+ worker: WorkerId;
19
+ intent: string;
20
+ }
21
+
22
+ /** One file's checkout state as a worker sees it. */
23
+ export interface FileView {
24
+ path: string;
25
+ holder: Claim;
26
+ queue: Claim[];
27
+ }
28
+
29
+ export interface Handover {
30
+ path: string;
31
+ from: WorkerId;
32
+ to: WorkerId;
33
+ note: string;
34
+ auto: boolean;
35
+ at: number;
36
+ }
37
+
38
+ export type ClaimResult =
39
+ | { status: "granted"; path: string; queue: Claim[] }
40
+ | { status: "held"; path: string; queue: Claim[] }
41
+ | { status: "queued"; path: string; holder: Claim; position: number };
42
+
43
+ export type HandoverResult =
44
+ | { ok: true; path: string; to?: Claim; queue: Claim[] }
45
+ | { ok: false; path: string; error: string };
46
+
47
+ /** What the desk tells workers; the session turns these into steering messages. */
48
+ export type DeskEvent =
49
+ /** `to` holds `path` and its queue changed (someone joined or left). */
50
+ | { type: "queue"; to: WorkerId; path: string; queue: Claim[] }
51
+ /** `to` now holds `path`, handed over by `from` with `note`; `queue` is who waits behind it. */
52
+ | { type: "granted"; to: WorkerId; path: string; from: WorkerId; note: string; intent: string; queue: Claim[] }
53
+ /** `worker` joined the queue for `path` behind `holder`. */
54
+ | { type: "queued"; worker: WorkerId; path: string; holder: WorkerId; position: number };
55
+
56
+ interface Entry {
57
+ holder: Claim;
58
+ queue: Claim[];
59
+ }
60
+
61
+ interface Waiter {
62
+ resolve: (grants: DeskEvent[]) => void;
63
+ timer: ReturnType<typeof setTimeout>;
64
+ }
65
+
66
+ const MAX_INTENT = 160;
67
+ const MAX_NOTE = 1200;
68
+
69
+ function clean(text: string, max: number): string {
70
+ const flat = text.replace(/\s+/g, " ").trim();
71
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
72
+ }
73
+
74
+ export class FileDesk {
75
+ private readonly files = new Map<string, Entry>();
76
+ private readonly waiters = new Map<WorkerId, Waiter>();
77
+ private readonly pendingGrants = new Map<WorkerId, DeskEvent[]>();
78
+ private readonly log: Handover[] = [];
79
+ private readonly cwd: string;
80
+ private readonly onEvent: (event: DeskEvent) => void;
81
+ private readonly now: () => number;
82
+
83
+ constructor(cwd: string, onEvent: (event: DeskEvent) => void = () => {}, now: () => number = Date.now) {
84
+ this.cwd = resolve(cwd);
85
+ this.onEvent = onEvent;
86
+ this.now = now;
87
+ }
88
+
89
+ /** A stable key and display path for a file, relative to the repository root. */
90
+ normalize(path: string): string {
91
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(this.cwd, path);
92
+ const rel = relative(this.cwd, absolute);
93
+ const display = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : absolute;
94
+ return display.replace(/\\/g, "/");
95
+ }
96
+
97
+ /** Claim `path` for `worker` with a short intent; grants a free file, queues behind a holder. */
98
+ claim(worker: WorkerId, path: string, intent: string): ClaimResult {
99
+ const key = this.normalize(path);
100
+ const claim = { worker, intent: clean(intent || "edit", MAX_INTENT) };
101
+ const entry = this.files.get(key);
102
+ if (!entry) {
103
+ this.files.set(key, { holder: claim, queue: [] });
104
+ return { status: "granted", path: key, queue: [] };
105
+ }
106
+ if (entry.holder.worker === worker) {
107
+ entry.holder = claim;
108
+ return { status: "held", path: key, queue: [...entry.queue] };
109
+ }
110
+ const existing = entry.queue.findIndex((queued) => queued.worker === worker);
111
+ if (existing >= 0) entry.queue[existing] = claim;
112
+ else entry.queue.push(claim);
113
+ const position = entry.queue.findIndex((queued) => queued.worker === worker) + 1;
114
+ if (existing < 0) {
115
+ this.onEvent({ type: "queued", worker, path: key, holder: entry.holder.worker, position });
116
+ this.onEvent({ type: "queue", to: entry.holder.worker, path: key, queue: [...entry.queue] });
117
+ }
118
+ return { status: "queued", path: key, holder: entry.holder, position };
119
+ }
120
+
121
+ /** True when `worker` currently holds `path`. */
122
+ holds(worker: WorkerId, path: string): boolean {
123
+ return this.files.get(this.normalize(path))?.holder.worker === worker;
124
+ }
125
+
126
+ /** Who holds `path`, if anyone. */
127
+ holderOf(path: string): Claim | undefined {
128
+ return this.files.get(this.normalize(path))?.holder;
129
+ }
130
+
131
+ /** Hand `path` to whoever is next in its queue, with a note for them; free it when nobody waits. */
132
+ handover(worker: WorkerId, path: string, note: string): HandoverResult {
133
+ const key = this.normalize(path);
134
+ const entry = this.files.get(key);
135
+ if (!entry || entry.holder.worker !== worker) {
136
+ return { ok: false, path: key, error: `you do not hold ${key}${entry ? ` (${entry.holder.worker} does)` : ""}` };
137
+ }
138
+ const next = this.pass(key, entry, worker, clean(note || "no note", MAX_NOTE), false);
139
+ return { ok: true, path: key, ...(next ? { to: next } : {}), queue: [...(this.files.get(key)?.queue ?? [])] };
140
+ }
141
+
142
+ /**
143
+ * Release everything `worker` holds (handing each file to its next worker
144
+ * with `note(path, next)`) and drop it from every queue. Used when a worker
145
+ * finishes, crashes or retries.
146
+ */
147
+ release(worker: WorkerId, note: (path: string, next: Claim) => string): Handover[] {
148
+ const passed: Handover[] = [];
149
+ for (const [key, entry] of [...this.files]) {
150
+ const before = entry.queue.length;
151
+ entry.queue = entry.queue.filter((queued) => queued.worker !== worker);
152
+ if (entry.holder.worker === worker) {
153
+ const head = entry.queue[0];
154
+ const next = this.pass(key, entry, worker, head ? clean(note(key, head), MAX_NOTE) : "", true);
155
+ if (next) passed.push(this.log.at(-1)!);
156
+ } else if (entry.queue.length !== before) {
157
+ this.onEvent({ type: "queue", to: entry.holder.worker, path: key, queue: [...entry.queue] });
158
+ }
159
+ }
160
+ const waiter = this.waiters.get(worker);
161
+ if (waiter) {
162
+ clearTimeout(waiter.timer);
163
+ this.waiters.delete(worker);
164
+ waiter.resolve([]);
165
+ }
166
+ this.pendingGrants.delete(worker);
167
+ return passed;
168
+ }
169
+
170
+ private pass(key: string, entry: Entry, from: WorkerId, note: string, auto: boolean): Claim | undefined {
171
+ const next = entry.queue.shift();
172
+ if (!next) {
173
+ this.files.delete(key);
174
+ return undefined;
175
+ }
176
+ entry.holder = next;
177
+ this.log.push({ path: key, from, to: next.worker, note, auto, at: this.now() });
178
+ const event: DeskEvent = { type: "granted", to: next.worker, path: key, from, note, intent: next.intent, queue: [...entry.queue] };
179
+ this.onEvent(event);
180
+ this.deliver(next.worker, event);
181
+ return next;
182
+ }
183
+
184
+ /** Wake a worker blocked in `wait` with its grant, or keep it for its next wait. */
185
+ private deliver(worker: WorkerId, event: DeskEvent): void {
186
+ const waiter = this.waiters.get(worker);
187
+ if (waiter) {
188
+ clearTimeout(waiter.timer);
189
+ this.waiters.delete(worker);
190
+ waiter.resolve([event]);
191
+ return;
192
+ }
193
+ this.pendingGrants.set(worker, [...(this.pendingGrants.get(worker) ?? []), event]);
194
+ }
195
+
196
+ /** Files `worker` holds that someone is waiting for; it must hand these over before it may wait. */
197
+ owed(worker: WorkerId): FileView[] {
198
+ return this.heldBy(worker).filter((file) => file.queue.length > 0);
199
+ }
200
+
201
+ heldBy(worker: WorkerId): FileView[] {
202
+ return [...this.files].filter(([, entry]) => entry.holder.worker === worker).map(([path, entry]) => ({ path, holder: entry.holder, queue: [...entry.queue] }));
203
+ }
204
+
205
+ /** Files `worker` is queued for, with its position. */
206
+ waitingFor(worker: WorkerId): Array<{ path: string; holder: Claim; position: number }> {
207
+ return [...this.files].flatMap(([path, entry]) => {
208
+ const index = entry.queue.findIndex((queued) => queued.worker === worker);
209
+ return index >= 0 ? [{ path, holder: entry.holder, position: index + 1 }] : [];
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Wait up to `timeoutMs` for a file to be handed to `worker`. Refuses while
215
+ * the worker holds files others are waiting for, which breaks deadlock
216
+ * cycles: whoever waits first hands over first.
217
+ */
218
+ wait(worker: WorkerId, timeoutMs: number): Promise<{ refused?: FileView[]; grants: DeskEvent[] }> {
219
+ const owed = this.owed(worker);
220
+ if (owed.length > 0) return Promise.resolve({ refused: owed, grants: [] });
221
+ const pending = this.pendingGrants.get(worker);
222
+ if (pending?.length) {
223
+ this.pendingGrants.delete(worker);
224
+ return Promise.resolve({ grants: pending });
225
+ }
226
+ if (this.waitingFor(worker).length === 0) return Promise.resolve({ grants: [] });
227
+ return new Promise((done) => {
228
+ const timer = setTimeout(() => {
229
+ this.waiters.delete(worker);
230
+ done({ grants: [] });
231
+ }, Math.max(0, timeoutMs));
232
+ this.waiters.set(worker, { resolve: (grants) => done({ grants }), timer });
233
+ });
234
+ }
235
+
236
+ /** Every handover so far, oldest first. */
237
+ handovers(): Handover[] {
238
+ return [...this.log];
239
+ }
240
+
241
+ /** Wake every waiter; called when the batch ends. */
242
+ close(): void {
243
+ for (const [worker, waiter] of this.waiters) {
244
+ clearTimeout(waiter.timer);
245
+ waiter.resolve([]);
246
+ this.waiters.delete(worker);
247
+ }
248
+ }
249
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Transport between the file desk (master process) and the workers' pi
3
+ * processes: newline-delimited JSON requests and responses over a private
4
+ * Unix socket (a named pipe on Windows). One server per parallel batch.
5
+ */
6
+ import { randomBytes } from "node:crypto";
7
+ import { chmodSync, rmSync } from "node:fs";
8
+ import { createConnection, createServer, type Socket } from "node:net";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ export type DeskOp = "hello" | "check" | "claim" | "handover" | "mine" | "wait";
13
+
14
+ export interface DeskRequest {
15
+ id: number;
16
+ worker: string;
17
+ op: DeskOp;
18
+ path?: string;
19
+ intent?: string;
20
+ note?: string;
21
+ timeoutMs?: number;
22
+ }
23
+
24
+ export interface DeskResponse {
25
+ id: number;
26
+ ok: boolean;
27
+ /** Human-readable answer for the worker's tool result or block reason. */
28
+ text: string;
29
+ /** `check` only: whether the worker may edit the file. */
30
+ allowed?: boolean;
31
+ }
32
+
33
+ export type DeskHandler = (request: DeskRequest) => Promise<Omit<DeskResponse, "id">>;
34
+
35
+ export interface DeskServer {
36
+ address: string;
37
+ close(): Promise<void>;
38
+ }
39
+
40
+ function socketAddress(): string {
41
+ const name = `bl-desk-${process.pid}-${randomBytes(4).toString("hex")}`;
42
+ return process.platform === "win32" ? `\\\\.\\pipe\\${name}` : join(tmpdir(), `${name}.sock`);
43
+ }
44
+
45
+ /** Split a byte stream into complete lines, keeping any partial tail. */
46
+ function lineReader(onLine: (line: string) => void): (chunk: string) => void {
47
+ let buffer = "";
48
+ return (chunk) => {
49
+ buffer += chunk;
50
+ let at = buffer.indexOf("\n");
51
+ while (at >= 0) {
52
+ const line = buffer.slice(0, at).trim();
53
+ buffer = buffer.slice(at + 1);
54
+ if (line) onLine(line);
55
+ at = buffer.indexOf("\n");
56
+ }
57
+ };
58
+ }
59
+
60
+ function serve(socket: Socket, handler: DeskHandler): void {
61
+ socket.setEncoding("utf8");
62
+ socket.on("error", () => socket.destroy());
63
+ socket.on(
64
+ "data",
65
+ lineReader((line) => {
66
+ let request: DeskRequest;
67
+ try {
68
+ request = JSON.parse(line) as DeskRequest;
69
+ } catch {
70
+ return;
71
+ }
72
+ handler(request)
73
+ .catch((error: Error) => ({ ok: false, text: `desk error: ${error.message}` }))
74
+ .then((response) => {
75
+ if (!socket.destroyed) socket.write(`${JSON.stringify({ ...response, id: request.id })}\n`);
76
+ });
77
+ }),
78
+ );
79
+ }
80
+
81
+ /** Start a desk server on a fresh private socket. */
82
+ export async function startDeskServer(handler: DeskHandler): Promise<DeskServer> {
83
+ const address = socketAddress();
84
+ const sockets = new Set<Socket>();
85
+ const server = createServer((socket) => {
86
+ sockets.add(socket);
87
+ socket.on("close", () => sockets.delete(socket));
88
+ serve(socket, handler);
89
+ });
90
+ await new Promise<void>((done, fail) => {
91
+ server.once("error", fail);
92
+ server.listen(address, () => done());
93
+ });
94
+ if (process.platform !== "win32") {
95
+ try {
96
+ chmodSync(address, 0o600);
97
+ } catch {
98
+ // Best effort: the socket already lives in the private temp dir.
99
+ }
100
+ }
101
+ return {
102
+ address,
103
+ close: () =>
104
+ new Promise((done) => {
105
+ for (const socket of sockets) socket.destroy();
106
+ server.close(() => {
107
+ if (process.platform !== "win32") rmSync(address, { force: true });
108
+ done();
109
+ });
110
+ }),
111
+ };
112
+ }
113
+
114
+ export interface DeskClient {
115
+ request(request: Omit<DeskRequest, "id" | "worker">, timeoutMs?: number): Promise<DeskResponse>;
116
+ close(): void;
117
+ }
118
+
119
+ /** A persistent connection from one worker to the desk; requests fail (never hang) when it is gone. */
120
+ export function createDeskClient(address: string, worker: string): DeskClient {
121
+ let socket: Socket | undefined;
122
+ let nextId = 1;
123
+ const pending = new Map<number, { done: (response: DeskResponse) => void; timer: ReturnType<typeof setTimeout> }>();
124
+ const failAll = (text: string) => {
125
+ for (const [id, entry] of pending) {
126
+ clearTimeout(entry.timer);
127
+ entry.done({ id, ok: false, text });
128
+ }
129
+ pending.clear();
130
+ };
131
+ const connect = (): Socket => {
132
+ if (socket && !socket.destroyed) return socket;
133
+ const next = createConnection(address);
134
+ next.setEncoding("utf8");
135
+ next.on(
136
+ "data",
137
+ lineReader((line) => {
138
+ let response: DeskResponse;
139
+ try {
140
+ response = JSON.parse(line) as DeskResponse;
141
+ } catch {
142
+ return;
143
+ }
144
+ const entry = pending.get(response.id);
145
+ if (!entry) return;
146
+ pending.delete(response.id);
147
+ clearTimeout(entry.timer);
148
+ entry.done(response);
149
+ }),
150
+ );
151
+ next.on("error", () => failAll("the file desk is unavailable"));
152
+ next.on("close", () => failAll("the file desk closed"));
153
+ next.unref();
154
+ socket = next;
155
+ return next;
156
+ };
157
+ return {
158
+ request(request, timeoutMs = 10_000) {
159
+ const id = nextId++;
160
+ return new Promise((done) => {
161
+ const timer = setTimeout(() => {
162
+ pending.delete(id);
163
+ done({ id, ok: false, text: "the file desk did not answer" });
164
+ }, timeoutMs);
165
+ pending.set(id, { done, timer });
166
+ try {
167
+ connect().write(`${JSON.stringify({ ...request, id, worker })}\n`);
168
+ } catch {
169
+ failAll("the file desk is unavailable");
170
+ }
171
+ });
172
+ },
173
+ close() {
174
+ socket?.destroy();
175
+ socket = undefined;
176
+ },
177
+ };
178
+ }