pim-agent 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/bin/pim.ts +1 -1
  2. package/package.json +2 -2
  3. package/packages/core/src/extensions/CoreExtensions.ts +19 -16
  4. package/packages/core/src/extensions/apply-patch/render.ts +16 -55
  5. package/packages/core/src/extensions/system-prompt/index.ts +29 -21
  6. package/packages/core/src/extensions/system-prompt/prompt.ts +5 -1
  7. package/packages/core/src/picker/PickerService.ts +23 -1
  8. package/packages/core/src/picker/token.ts +12 -5
  9. package/packages/core/src/session/EventLog.ts +22 -79
  10. package/packages/core/src/session/SearchIndex.ts +752 -0
  11. package/packages/core/src/session/SearchTokens.ts +97 -0
  12. package/packages/core/src/session/SessionDigest.ts +161 -0
  13. package/packages/core/src/session/SessionHost.ts +17 -1
  14. package/packages/core/src/session/SessionMeta.ts +267 -0
  15. package/packages/core/src/session/SessionName.ts +18 -0
  16. package/packages/core/src/session/SessionRegistry.ts +89 -14
  17. package/packages/core/src/session/WriteMark.ts +15 -1
  18. package/packages/core/src/shared/DiffLines.ts +1 -1
  19. package/packages/core/src/shared/DiffPatch.ts +70 -0
  20. package/packages/core/src/shared/DiffRenderer.ts +1 -1
  21. package/packages/core/src/shared/FileWatch.ts +15 -1
  22. package/packages/core/src/shared/Git.ts +483 -42
  23. package/packages/core/src/shared/GitMonitor.ts +221 -0
  24. package/packages/core/src/shared/Levenshtein.ts +54 -1
  25. package/packages/core/src/shared/Proc.ts +21 -1
  26. package/packages/core/src/shared/RepoDiff.ts +741 -0
  27. package/packages/core/src/shared/Surface.ts +2 -0
  28. package/packages/core/src/shared/Updater.ts +21 -5
  29. package/packages/core/src/view/DiffExpand.ts +214 -0
  30. package/packages/core/src/view/DiffLayout.ts +7 -7
  31. package/packages/core/src/view/DiffPairs.ts +47 -0
  32. package/packages/core/src/view/MovePath.ts +70 -0
  33. package/packages/daemon/src/WebSurface.ts +1 -2
  34. package/packages/protocol/src/Command.ts +129 -2
  35. package/packages/protocol/src/Diff.ts +15 -0
  36. package/packages/protocol/src/ServerEvent.ts +157 -2
  37. package/packages/server/src/ClientConnection.ts +15 -3
  38. package/packages/server/src/ProbeClient.ts +90 -28
  39. package/packages/server/src/SessionCatalogue.ts +292 -38
  40. package/packages/server/src/SessionStream.ts +69 -43
  41. package/packages/server/src/StaticClient.ts +95 -12
  42. package/packages/server/src/WsGateway.ts +264 -29
  43. package/packages/server/src/probe.ts +61 -7
  44. package/packages/telegram/src/Session.ts +1 -0
  45. package/packages/tui/src/extensions/command-picker/index.ts +64 -32
  46. package/packages/tui/src/extensions/footer/index.ts +19 -43
  47. package/packages/tui/src/extensions/session-lease/index.ts +8 -3
  48. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  49. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  50. package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-CA0aSzPu.js} +1 -1
  51. package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
  52. package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
  53. package/packages/web/dist/client/index.html +2 -2
  54. package/packages/protocol/src/Protocol.ts +0 -6
  55. package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
  56. package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
@@ -1,9 +1,11 @@
1
1
  import type { DirectoryListing } from "#core/shared/Directories";
2
+ import type { CommitResult, GitBranch } from "#core/shared/Git";
2
3
  import type { PickerItem } from "#core/picker/PickerItem";
4
+ import type { SearchRange, SearchSnippet } from "#core/session/SearchIndex";
3
5
  import type { LeaseFrontend } from "#core/session/SessionLease";
4
6
  import type { UpdateSkip } from "#core/shared/Updater";
5
7
  import type { NoticeSeverity, ToolView } from "#core/view/ViewBlock";
6
- import type { ProtocolVersion } from "./Protocol";
8
+ import type { ChangeList, FileDiff, FileLines } from "./Diff";
7
9
 
8
10
  export type SessionStatus = "idle" | "thinking" | "streaming" | "tool";
9
11
 
@@ -92,11 +94,11 @@ export type UpdateStateEvent =
92
94
  export type EphemeralEvent =
93
95
  | {
94
96
  readonly type: "attached";
95
- readonly protocolVersion: ProtocolVersion;
96
97
  readonly sessionId: string;
97
98
  readonly cwd: string;
98
99
  /** Highest durable `seq` at attach time; replay follows immediately. */
99
100
  readonly head: number;
101
+ /** The server's build; a client built from another one is stale and should reload. */
100
102
  readonly pimVersion: string;
101
103
  readonly piVersion: string;
102
104
  }
@@ -160,6 +162,30 @@ export type EphemeralEvent =
160
162
  }
161
163
  /** Sent to every connection; the read cursor is one per session, not one per client. */
162
164
  | { readonly type: "session_read"; readonly sessionId: string }
165
+ /** Sent to every connection: one session's name or overrides changed, so a listing a client holds can be patched in place. */
166
+ | {
167
+ readonly type: "session_meta";
168
+ readonly sessionId: string;
169
+ /** The session's own name, `null` once it is cleared. */
170
+ readonly name?: string | null;
171
+ readonly archived?: boolean;
172
+ readonly unread?: boolean;
173
+ }
174
+ /**
175
+ * Sent to every connection; keyed by absolute working directory, not by
176
+ * session. A patch like `session_meta`: only what changed is said, so a
177
+ * fold carries no claim about the pin beside it.
178
+ */
179
+ | {
180
+ readonly type: "project_meta";
181
+ readonly cwd: string;
182
+ readonly pinned?: boolean;
183
+ readonly expanded?: boolean;
184
+ /** What the project is called in a listing, `null` once it is cleared. */
185
+ readonly label?: string | null;
186
+ }
187
+ /** Sent to every connection: the pinned projects, in the order they are shown. */
188
+ | { readonly type: "pins_changed"; readonly order: readonly string[] }
163
189
  /** Sent to every connection: the sessions on disk changed, so any listing a client holds is stale. */
164
190
  | { readonly type: "sessions_changed" }
165
191
  /** Sent to every connection; the restart it ends in drops every socket. */
@@ -175,6 +201,8 @@ export type EphemeralEvent =
175
201
  readonly status: SessionStatus;
176
202
  /** False while another process holds this session's turn lease. */
177
203
  readonly writable: boolean;
204
+ /** A session in the same working directory is mid-turn, so nothing may move the repository under it. */
205
+ readonly repoBusy?: boolean;
178
206
  /** Who holds it; absent when nothing does, or when their record is torn. */
179
207
  readonly heldBy?: {
180
208
  readonly frontend: LeaseFrontend;
@@ -192,6 +220,8 @@ export type EphemeralEvent =
192
220
  readonly dirtyCount?: number;
193
221
  readonly ahead?: number;
194
222
  readonly behind?: number;
223
+ /** Changes whenever the working copy does, content of a dirty file included. */
224
+ readonly repoRevision?: string;
195
225
  }
196
226
  /** A frame the server could not attribute to any command. */
197
227
  | { readonly type: "error"; readonly message: string };
@@ -205,12 +235,68 @@ export type SessionSummaryView = {
205
235
  readonly settledAt: number;
206
236
  /** The session's first user message, trimmed; absent when it has none. */
207
237
  readonly title?: string;
238
+ /** True when `title` is a name somebody wrote, rather than the first message. */
239
+ readonly named?: true;
240
+ readonly archived?: true;
208
241
  /** Has answered since anything last read it; absent means it has not. */
209
242
  readonly unread?: boolean;
210
243
  /** Absent when idle, and for a session this server does not hold open. */
211
244
  readonly status?: SessionStatus;
212
245
  };
213
246
 
247
+ /** One working directory the catalogue holds sessions for, counted before any per-project cut. */
248
+ export type ProjectView = {
249
+ readonly cwd: string;
250
+ /** Every session in it the listing's scope allows, including the ones the cut dropped. */
251
+ readonly count: number;
252
+ readonly pinned?: true;
253
+ /** Where it sorts among the pinned, 0 first; absent unless it is pinned. */
254
+ readonly pinRank?: number;
255
+ /** The sidebar group stands unfolded; absent is folded. */
256
+ readonly expanded?: true;
257
+ /** What to call it instead of its base name; absent when it goes by the directory. */
258
+ readonly label?: string;
259
+ };
260
+
261
+ /**
262
+ * One session a search matched. Deliberately not a `SessionSummaryView`: what
263
+ * a ranked list needs is why the row matched, and `unread` is the sidebar's
264
+ * triage while `status` resolves itself the moment the session opens.
265
+ */
266
+ export type SearchHitView = {
267
+ readonly sessionId: string;
268
+ readonly cwd: string;
269
+ /** Its name if it has one, else the clamped opening message; the digest's, so it agrees with the sidebar's. */
270
+ readonly title?: string;
271
+ /** Offsets into the clamped `title`, empty when the title did not match. */
272
+ readonly titleRanges: readonly SearchRange[];
273
+ /** The session's opening ask, for a row whose name is all that matched and so has no snippet to show. */
274
+ readonly opening?: string;
275
+ /** End of the last completed turn and the row's clock, never the file mtime; falls back to when the session started, as the sidebar's does. */
276
+ readonly settledAt: number;
277
+ /** Searched and found anyway: the badge that makes "archived are in scope" honest. */
278
+ readonly archived?: true;
279
+ /** What matched, in the words it was said in, each with its own ranges. */
280
+ readonly snippets: readonly SearchSnippet[];
281
+ /** Matching messages in this session, before the snippet cut. */
282
+ readonly total: number;
283
+ };
284
+
285
+ /** One answer to `list_sessions`: the page of rows, and every directory that had one, counted whole. */
286
+ export type SessionListing = {
287
+ readonly sessions: readonly SessionSummaryView[];
288
+ /** Counted before the per-project cut, so a group can say what a page of it leaves out. */
289
+ readonly projects: readonly ProjectView[];
290
+ };
291
+
292
+ /** One answer to `search_sessions`: the ranked rows, the words no session held, and the scope it read. */
293
+ export type SessionSearch = {
294
+ readonly hits: readonly SearchHitView[];
295
+ readonly dropped: readonly string[];
296
+ /** Sessions searched, whole: what the empty state and the result footer say out loud. */
297
+ readonly scanned: number;
298
+ };
299
+
214
300
  /** One model the server can be switched to, for the composer's model menu. */
215
301
  export type ModelView = {
216
302
  readonly id: string;
@@ -229,12 +315,30 @@ export type ResponseEvent = {
229
315
  readonly items?: readonly PickerItem[];
230
316
  /** The catalogue, for `list_sessions`. */
231
317
  readonly sessions?: readonly SessionSummaryView[];
318
+ /** The directories those sessions came from, for `list_sessions`. */
319
+ readonly projects?: readonly ProjectView[];
320
+ /** The ranked sessions, for `search_sessions`. */
321
+ readonly hits?: readonly SearchHitView[];
322
+ /** Query words no session held, dropped so the rest could match, for `search_sessions`. */
323
+ readonly dropped?: readonly string[];
324
+ /** Sessions the query actually searched, for `search_sessions`. */
325
+ readonly scanned?: number;
232
326
  /** The model catalogue, for `list_models`. */
233
327
  readonly models?: readonly ModelView[];
234
328
  /** What the *current* model supports, on the same answer. */
235
329
  readonly thinkingLevels?: readonly string[];
236
330
  /** One directory's subdirectories, for `list_dirs`. */
237
331
  readonly directory?: DirectoryListing;
332
+ /** The cwd's local branches, for `list_branches`. */
333
+ readonly branches?: readonly GitBranch[];
334
+ /** The commit that landed, for `commit`. */
335
+ readonly commit?: Pick<Extract<CommitResult, { readonly ok: true }>, "sha">;
336
+ /** The change list, for `list_changes`. */
337
+ readonly changes?: ChangeList;
338
+ /** One file's hunks, for `file_diff`. */
339
+ readonly fileDiff?: FileDiff;
340
+ /** The lines behind one gap, for `read_lines`. */
341
+ readonly fileLines?: FileLines;
238
342
  /** For `cancel` and `dequeue`: queued messages pi gave back, now owned by the client that asked. */
239
343
  readonly restored?: readonly string[];
240
344
  };
@@ -249,3 +353,54 @@ export type ServerEventType = ServerEvent["type"];
249
353
  export function isDurableEvent(event: ServerEvent): event is DurableEvent {
250
354
  return "seq" in event;
251
355
  }
356
+
357
+ /**
358
+ * Whether the frame speaks for the one session a connection is attached to,
359
+ * and so says nothing to a client whose attach is still in flight. The rest
360
+ * name their own subject — another session, a directory, or nothing at all —
361
+ * and gating those on the attach window drops a broadcast every time a client
362
+ * switches session.
363
+ *
364
+ * Exhaustive by construction: a new event is a type error here until it is
365
+ * classified, which is the only reason this lives beside the wire types
366
+ * rather than in the client that gates on it.
367
+ */
368
+ export function isAttachScoped(event: ServerEvent): boolean {
369
+ switch (event.type) {
370
+ case "attached":
371
+ case "replay":
372
+ case "message":
373
+ case "message_start":
374
+ case "message_retire":
375
+ case "text_delta":
376
+ case "thinking_delta":
377
+ case "tool_call":
378
+ case "tool_update":
379
+ case "tool_end":
380
+ case "tool_result":
381
+ case "notice":
382
+ case "subagent_events":
383
+ case "turn_end":
384
+ case "session_state":
385
+ // Names a cwd, but only ever reaches a client down the session stream it
386
+ // is attached to, so the old session's is the only one that can arrive
387
+ // mid-attach — and re-querying for it would warm the wrong cache.
388
+ case "picker_invalidate":
389
+ return true;
390
+ case "session_activity":
391
+ case "session_read":
392
+ case "session_meta":
393
+ case "project_meta":
394
+ case "pins_changed":
395
+ case "sessions_changed":
396
+ case "update_state":
397
+ // Sent for a frame the server could not read at all, which is likeliest
398
+ // before an attach has settled: gating it swallows the diagnostic.
399
+ case "error":
400
+ case "response":
401
+ return false;
402
+ default:
403
+ event satisfies never;
404
+ return false;
405
+ }
406
+ }
@@ -44,6 +44,7 @@ export class ClientConnection {
44
44
  private paused = false;
45
45
  private cursor = 0;
46
46
  private closed = false;
47
+ private reading = true;
47
48
  private watch: SubagentWatch | undefined;
48
49
 
49
50
  public constructor(ws: ServerWebSocket<undefined>) {
@@ -54,6 +55,11 @@ export class ClientConnection {
54
55
  return this.stream?.sessionId;
55
56
  }
56
57
 
58
+ /** Whether the reader behind this socket is looking at the session right now. */
59
+ public get attentive(): boolean {
60
+ return this.reading;
61
+ }
62
+
57
63
  /** Highest durable `seq` this client has been handed. */
58
64
  public get seq(): number {
59
65
  return this.cursor;
@@ -66,17 +72,23 @@ export class ClientConnection {
66
72
 
67
73
  public async attach(
68
74
  stream: AttachableStream,
69
- fromSeq: number
75
+ fromSeq: number,
76
+ attentive = true
70
77
  ): Promise<void> {
71
78
  this.detach();
72
79
  this.stream = stream;
73
80
  this.cursor = fromSeq;
81
+ this.reading = attentive;
74
82
  this.unsubscribe = stream.subscribe((event) => {
75
83
  this.onStreamEvent(event);
76
84
  });
77
85
  await this.sync();
78
86
  }
79
87
 
88
+ public setAttentive(attentive: boolean): void {
89
+ this.reading = attentive;
90
+ }
91
+
80
92
  /** Read a subagent's log alongside the attached session; at most one watch at a time. */
81
93
  public async watchSubagent(
82
94
  callId: string,
@@ -99,7 +111,7 @@ export class ClientConnection {
99
111
  if (this.closed) {
100
112
  return;
101
113
  }
102
- this.ws.send(frame(event));
114
+ this.ws.send(frame(event), true);
103
115
  }
104
116
 
105
117
  public onDrain(): void {
@@ -239,7 +251,7 @@ export class ClientConnection {
239
251
  if (this.closed || this.paused) {
240
252
  return false;
241
253
  }
242
- const status = this.ws.send(payload);
254
+ const status = this.ws.send(payload, true);
243
255
  if (status === 0) {
244
256
  this.paused = true;
245
257
  return false;
@@ -2,14 +2,19 @@ import { basename } from "node:path";
2
2
 
3
3
  import type { PickerItem } from "#core/picker/PickerItem";
4
4
  import { RemoteFilePickerSuggestionEngine } from "#core/picker/RemoteFilePickerSuggestionEngine";
5
- import type { AttachmentRef, CommandDraft } from "#protocol/Command";
6
- import { PROTOCOL_VERSION } from "#protocol/Protocol";
5
+ import type {
6
+ AttachmentRef,
7
+ CommandDraft,
8
+ SearchScope,
9
+ SessionScope,
10
+ } from "#protocol/Command";
7
11
  import {
8
12
  isDurableEvent,
9
13
  type ModelView,
10
14
  type ResponseEvent,
11
15
  type ServerEvent,
12
- type SessionSummaryView,
16
+ type SessionListing,
17
+ type SessionSearch,
13
18
  } from "#protocol/ServerEvent";
14
19
 
15
20
  export type ProbeOptions = {
@@ -18,10 +23,10 @@ export type ProbeOptions = {
18
23
  readonly sessionId?: string;
19
24
  readonly cwd?: string;
20
25
  readonly fromSeq?: number;
26
+ /** Sent on the attach frame; false is a client that is connected but not looking. */
27
+ readonly attentive?: boolean;
21
28
  /** Called for every frame, in arrival order. */
22
29
  readonly onEvent?: (event: ServerEvent) => void;
23
- /** Sent instead of the real one, to exercise version rejection. */
24
- readonly protocolVersion?: number;
25
30
  /** Keystroke debounce for `files`; 0 makes tests deterministic. */
26
31
  readonly debounceMs?: number;
27
32
  };
@@ -56,9 +61,6 @@ export class ProbeClient {
56
61
  }>();
57
62
  private socket: WebSocket | undefined;
58
63
  private nextId = 0;
59
- private closeInfo:
60
- | { readonly code: number; readonly reason: string }
61
- | undefined;
62
64
 
63
65
  public constructor(options: ProbeOptions) {
64
66
  this.options = options;
@@ -78,7 +80,6 @@ export class ProbeClient {
78
80
  this.receive(String(event.data));
79
81
  });
80
82
  socket.addEventListener("close", (event) => {
81
- this.closeInfo = { code: event.code, reason: event.reason };
82
83
  for (const { reject } of this.pending.values()) {
83
84
  reject(new Error(`socket closed: ${event.code} ${event.reason}`));
84
85
  }
@@ -94,14 +95,20 @@ export class ProbeClient {
94
95
  });
95
96
  return await this.send({
96
97
  type: "attach",
97
- protocolVersion: (this.options.protocolVersion ??
98
- PROTOCOL_VERSION) as typeof PROTOCOL_VERSION,
99
98
  ...(this.sessionId === undefined ? {} : { sessionId: this.sessionId }),
100
99
  ...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }),
100
+ ...(this.options.attentive === undefined
101
+ ? {}
102
+ : { attentive: this.options.attentive }),
101
103
  fromSeq: this.seq,
102
104
  });
103
105
  }
104
106
 
107
+ /** Says whether this client's reader is present; regaining it reads the session. */
108
+ public attention(value: boolean): Promise<ResponseEvent> {
109
+ return this.send({ type: "attention", value });
110
+ }
111
+
105
112
  public send(command: CommandDraft): Promise<ResponseEvent> {
106
113
  const socket = this.socket;
107
114
  if (!socket) {
@@ -165,16 +172,82 @@ export class ProbeClient {
165
172
 
166
173
  /** Pi's session catalogue; answers whether or not this probe is attached. */
167
174
  public async listSessions(
168
- cwd?: string
169
- ): Promise<readonly SessionSummaryView[]> {
175
+ scope: SessionScope = {}
176
+ ): Promise<SessionListing["sessions"]> {
177
+ return (await this.catalogue(scope)).sessions;
178
+ }
179
+
180
+ /** The listing whole: the rows the page kept and the projects they came from. */
181
+ public async catalogue(scope: SessionScope = {}): Promise<SessionListing> {
182
+ const response = await this.send({ type: "list_sessions", ...scope });
183
+ if (!response.success) {
184
+ throw new Error(response.error ?? "list_sessions failed");
185
+ }
186
+ return {
187
+ sessions: response.sessions ?? [],
188
+ projects: response.projects ?? [],
189
+ };
190
+ }
191
+
192
+ /** Searches every session on disk; an empty query warms the index and answers with no hits. */
193
+ public async search(
194
+ query: string,
195
+ scope: SearchScope = {}
196
+ ): Promise<SessionSearch> {
170
197
  const response = await this.send({
171
- type: "list_sessions",
172
- ...(cwd === undefined ? {} : { cwd }),
198
+ type: "search_sessions",
199
+ query,
200
+ ...scope,
173
201
  });
174
202
  if (!response.success) {
175
- throw new Error(response.error ?? "list_sessions failed");
203
+ throw new Error(response.error ?? "search_sessions failed");
176
204
  }
177
- return response.sessions ?? [];
205
+ return {
206
+ hits: response.hits ?? [],
207
+ dropped: response.dropped ?? [],
208
+ scanned: response.scanned ?? 0,
209
+ };
210
+ }
211
+
212
+ /** Names a session through pi's own name; `null` clears it back to its opening message. */
213
+ public rename(
214
+ sessionId: string,
215
+ value: string | null
216
+ ): Promise<ResponseEvent> {
217
+ return this.send({ type: "set_session_name", sessionId, value });
218
+ }
219
+
220
+ /** Puts a session out of the live listing, or brings it back. */
221
+ public setArchived(
222
+ sessionId: string,
223
+ value: boolean
224
+ ): Promise<ResponseEvent> {
225
+ return this.send({ type: "set_session_archived", sessionId, value });
226
+ }
227
+
228
+ /** Holds a session unread until something reads it; survives a listing and a re-attach. */
229
+ public markUnread(sessionId: string, value: boolean): Promise<ResponseEvent> {
230
+ return this.send({ type: "set_session_unread", sessionId, value });
231
+ }
232
+
233
+ /** Pins a working directory, not a session. */
234
+ public setPinned(cwd: string, value: boolean): Promise<ResponseEvent> {
235
+ return this.send({ type: "set_project_pinned", cwd, value });
236
+ }
237
+
238
+ /** Re-orders the pinned directories; the whole order, pinned ones only. */
239
+ public setPinOrder(order: readonly string[]): Promise<ResponseEvent> {
240
+ return this.send({ type: "set_pin_order", order });
241
+ }
242
+
243
+ /** Unfolds a working directory's sidebar group, or folds it. */
244
+ public setExpanded(cwd: string, value: boolean): Promise<ResponseEvent> {
245
+ return this.send({ type: "set_project_expanded", cwd, value });
246
+ }
247
+
248
+ /** Names a working directory for the listings; `null` puts it back to its base name. */
249
+ public setLabel(cwd: string, value: string | null): Promise<ResponseEvent> {
250
+ return this.send({ type: "set_project_label", cwd, value });
178
251
  }
179
252
 
180
253
  /** The model catalogue, plus this session's thinking levels; empty when unattached. */
@@ -249,17 +322,6 @@ export class ProbeClient {
249
322
  });
250
323
  }
251
324
 
252
- /** Resolves when the socket closes, e.g. after a protocol rejection. */
253
- public async closed(): Promise<{
254
- readonly code: number;
255
- readonly reason: string;
256
- }> {
257
- while (!this.closeInfo) {
258
- await Bun.sleep(5);
259
- }
260
- return this.closeInfo;
261
- }
262
-
263
325
  /** Drops the socket without a close frame, the way a killed client would. */
264
326
  public kill(): void {
265
327
  this.socket?.close(4000, "probe killed");