@zgeoff/atc 2.12.0 → 2.14.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 (75) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/boot-daemon.ts +47 -21
  23. package/src/client/format-protocol-mismatch.ts +37 -0
  24. package/src/client/index.ts +104 -2
  25. package/src/client/ui.ts +8 -0
  26. package/src/daemon/build-execution-targets.ts +49 -10
  27. package/src/daemon/build-imp-provider.ts +68 -0
  28. package/src/daemon/build-report-trail-entry.ts +6 -2
  29. package/src/daemon/build-report-view.ts +41 -0
  30. package/src/daemon/build-scoped-context.ts +9 -1
  31. package/src/daemon/build-session-lifecycle.ts +52 -0
  32. package/src/daemon/build-tar-archive.ts +85 -0
  33. package/src/daemon/daemon-connection.ts +134 -4
  34. package/src/daemon/daemon.ts +306 -42
  35. package/src/daemon/execution-provider.ts +109 -4
  36. package/src/daemon/hooks.ts +5 -17
  37. package/src/daemon/imp-client-port.ts +343 -0
  38. package/src/daemon/imp-harness.ts +618 -0
  39. package/src/daemon/imp-port-error.ts +18 -0
  40. package/src/daemon/imp-port.ts +246 -0
  41. package/src/daemon/imp-provider.ts +601 -0
  42. package/src/daemon/is-binding-current.ts +43 -0
  43. package/src/daemon/local-pty-provider.ts +62 -3
  44. package/src/daemon/materialize-workspace.ts +574 -0
  45. package/src/daemon/parse-hook-line.ts +31 -0
  46. package/src/daemon/pick-session-state.ts +15 -0
  47. package/src/daemon/restore-fleet.ts +41 -23
  48. package/src/daemon/screen-model.ts +7 -0
  49. package/src/daemon/sessions.ts +574 -61
  50. package/src/daemon/start-headless-turn.ts +1 -1
  51. package/src/daemon/start-session-bridge.ts +294 -0
  52. package/src/mcp/mcp-tools.ts +41 -3
  53. package/src/mcp/reconnecting-caller.ts +1 -0
  54. package/src/mcp/require-daemon-features.ts +4 -0
  55. package/src/mcp/run-tool.ts +32 -8
  56. package/src/protocol/daemon-features.ts +14 -0
  57. package/src/protocol/protocol.ts +23 -0
  58. package/src/protocol/request-param-schemas.ts +72 -0
  59. package/src/report.ts +37 -2
  60. package/src/run-bridge-tap.ts +241 -0
  61. package/src/shared/collect-clean-env.ts +9 -1
  62. package/src/shared/open-bridge-socket.ts +86 -0
  63. package/src/shared/send-bridge-request.ts +47 -0
  64. package/src/statusline.ts +34 -3
  65. package/src/store/fleet-entry.ts +19 -0
  66. package/src/store/run-migrations.ts +85 -0
  67. package/src/store/state-store.ts +226 -21
  68. package/src/store/trail-entry.ts +7 -0
  69. package/src/store/workspace-materialization.ts +70 -0
  70. package/src/tap.ts +11 -0
  71. package/src/workspace/check-url-credentials.ts +59 -0
  72. package/src/workspace/create-workspace-clone.ts +13 -1
  73. package/src/workspace/repository-env-vars.ts +22 -0
  74. package/src/workspace/run-git.ts +2 -18
  75. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -12,15 +12,64 @@ export interface ExecutionProvider {
12
12
  readonly kind: string;
13
13
  readonly capabilities: ExecutionCapabilities;
14
14
 
15
- // Starts a process in a pseudo-terminal of the given size.
15
+ // Whether the host is a machine other than the daemon's own. A remote
16
+ // harness's files, transcripts included, live on that machine, and its
17
+ // environment holds only what atc sets for it.
18
+ readonly remote: boolean;
19
+
20
+ // Where a remote host keeps atc's files: the folder each session's own
21
+ // files unpack under, and the atc binary inside the host, null when the
22
+ // host has none. Absent on the daemon's own machine.
23
+ readonly guest?: GuestLayout;
24
+
25
+ // Readies the host a harness is about to start on: a remote host is
26
+ // created when missing, woken when asleep, and held awake while its
27
+ // harnesses run. Rejects with the refusal before any harness starts.
28
+ readonly prepareHost: (request: HostRequest) => Promise<void>;
29
+
30
+ // Starts a process in a pseudo-terminal of the given size, on the host the
31
+ // spec holds, which a prepare readied first.
16
32
  readonly spawnHarness: (spec: HarnessSpec) => HarnessHandle;
17
33
 
18
- // Unpacks a tar archive into a directory, creating the directory first.
34
+ // Unpacks a tar archive into a directory on a host, creating the directory
35
+ // first. The daemon's own machine takes no host.
19
36
  // oxlint-disable-next-line prefer-readonly-parameter-types -- archive bytes have no readonly form
20
- readonly transferArchive: (archive: Uint8Array, dir: string) => Promise<void>;
37
+ readonly transferArchive: (archive: Uint8Array, dir: string, host?: string) => Promise<void>;
21
38
 
22
39
  // Runs a command to completion and returns what it printed.
23
40
  readonly runCommand: (spec: CommandSpec) => Promise<CommandResult>;
41
+
42
+ // Puts a host to sleep with every harness on it kept inside, so a revive
43
+ // finds each one as it was. Rejects with `host_leased` when another owner
44
+ // keeps the host awake, and leaves the host as it was then.
45
+ readonly suspendHost: (host: string) => Promise<void>;
46
+
47
+ // Deletes a host and everything on it, harnesses included. Nothing brings
48
+ // a destroyed host back.
49
+ readonly destroyHost: (host: string) => Promise<void>;
50
+
51
+ // Stops the provider's own background work when the daemon stops, and
52
+ // leaves every remote harness running for the next daemon to find.
53
+ readonly dispose: () => void;
54
+ }
55
+
56
+ /**
57
+ * The host a harness is about to start on, and the daemon that holds it:
58
+ * a remote host is held per daemon, so two daemons never release each
59
+ * other's hold.
60
+ */
61
+ export interface HostRequest {
62
+ readonly host: string;
63
+ readonly daemonID: string;
64
+
65
+ // Whether the harness about to start needs atc inside the host, which a
66
+ // provider that ships its own binary installs when missing.
67
+ readonly installATC?: boolean;
68
+ }
69
+
70
+ export interface GuestLayout {
71
+ readonly dir: string;
72
+ readonly atc: string | null;
24
73
  }
25
74
 
26
75
  /**
@@ -63,12 +112,44 @@ export interface ExecutionCapabilities {
63
112
  export type ExecutionCapability = keyof ExecutionCapabilities;
64
113
 
65
114
  export interface HarnessSpec {
115
+ // The atc session the harness belongs to, and the host it runs on: the
116
+ // session's own id, or its parent's when the two share a host.
117
+ readonly session: string;
118
+ readonly host: string;
66
119
  readonly bin: string;
67
120
  readonly args: readonly string[];
68
121
  readonly cwd: string;
122
+
123
+ // The variables atc sets for the harness. A provider for the daemon's
124
+ // own machine adds the daemon's environment around them; a remote one
125
+ // passes only these and its own.
69
126
  readonly env: Readonly<Record<string, string>>;
127
+
128
+ // The variable names the harness goes without even when the daemon's
129
+ // environment holds them, such as a workspace's credential; none of the
130
+ // variables atc sets is withheld.
131
+ readonly withheldEnv?: readonly string[];
70
132
  readonly cols: number;
71
133
  readonly rows: number;
134
+
135
+ // Takes each connection a process of the harness opens to the daemon. A
136
+ // remote provider relays them from a socket inside the host that serves
137
+ // this harness alone, and points the harness's ATC_SOCKET at it.
138
+ readonly onRelay?: (relay: HarnessRelay) => void;
139
+ }
140
+
141
+ /**
142
+ * One connection from a process inside a harness's host to the daemon, in
143
+ * lines: each line the process writes arrives whole, and each line the
144
+ * daemon writes reaches the process in order.
145
+ */
146
+ export interface HarnessRelay {
147
+ readonly onLine: (listener: (line: string) => void) => void;
148
+ readonly onClose: (listener: () => void) => void;
149
+
150
+ // Settles once the relay has room for more.
151
+ readonly writeLine: (line: string) => Promise<void>;
152
+ readonly close: () => void;
72
153
  }
73
154
 
74
155
  /**
@@ -78,22 +159,46 @@ export interface HarnessSpec {
78
159
  export interface HarnessHandle {
79
160
  readonly onData: (listener: (data: string) => void) => HarnessSubscription;
80
161
  readonly onExit: (listener: (exit: HarnessExit) => void) => HarnessSubscription;
162
+
163
+ // Follows a remote harness's connection: lost and being restored, or
164
+ // restored. A harness on the daemon's own machine has no connection.
165
+ readonly onAttachment?: (
166
+ listener: (attachment: HarnessAttachment) => void,
167
+ ) => HarnessSubscription;
81
168
  readonly write: (data: string) => void;
82
169
  readonly resize: (cols: number, rows: number) => void;
83
170
  readonly kill: () => void;
171
+
172
+ // Stops following the harness and leaves its process running, for a host
173
+ // that keeps it after the daemon lets go; on a host that cannot keep it,
174
+ // the process ends as a kill ends it. No listener fires after a detach.
175
+ readonly detach: () => void;
84
176
  }
85
177
 
178
+ export type HarnessAttachment = 'attached' | 'reattaching';
179
+
86
180
  interface HarnessSubscription {
87
181
  readonly dispose: () => void;
88
182
  }
89
183
 
90
- interface HarnessExit {
184
+ export interface HarnessExit {
91
185
  readonly exitCode: number;
186
+
187
+ // Why the harness stopped: its process exited, its host went to sleep
188
+ // with the process inside, or the host lost it without an exit, such as
189
+ // a cold boot. Absent is an exit.
190
+ readonly reason?: 'exited' | 'suspended' | 'ended';
191
+
192
+ // What ended it, for a reason other than an exit.
193
+ readonly detail?: string;
92
194
  }
93
195
 
94
196
  export interface CommandSpec {
95
197
  readonly argv: readonly string[];
96
198
  readonly cwd: string;
199
+
200
+ // The host the command runs on; the daemon's own machine takes none.
201
+ readonly host?: string;
97
202
  }
98
203
 
99
204
  export interface CommandResult {
@@ -1,8 +1,7 @@
1
1
  import { unlinkSync } from 'node:fs';
2
2
  import { socketPath } from '../shared/config';
3
- import { isRecord } from '../shared/report';
4
3
  import type { SessionID } from '../shared/session-id';
5
- import { toSessionID } from '../shared/to-session-id';
4
+ import { parseHookLine } from './parse-hook-line';
6
5
 
7
6
  export interface HookEvent {
8
7
  atcId: SessionID;
@@ -38,22 +37,11 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
38
37
  continue;
39
38
  }
40
39
 
41
- try {
42
- const parsed: unknown = JSON.parse(line);
40
+ const event = parseHookLine(line);
43
41
 
44
- if (
45
- isRecord(parsed) &&
46
- typeof parsed['atcId'] === 'string' &&
47
- typeof parsed['event'] === 'string' &&
48
- isRecord(parsed['payload'])
49
- ) {
50
- onEvent({
51
- atcId: toSessionID(parsed['atcId']),
52
- event: parsed['event'],
53
- payload: parsed['payload'],
54
- });
55
- }
56
- } catch {}
42
+ if (event !== null) {
43
+ onEvent(event);
44
+ }
57
45
  }
58
46
  },
59
47
  open(socket) {
@@ -0,0 +1,343 @@
1
+ import { createImpClient, openExecSession, openReverseForward } from '@zgeoff/imp-client';
2
+ import type { ExecOutcome, ImpClient } from '@zgeoff/imp-client';
3
+ import type {
4
+ ImpCommand,
5
+ ImpCommandResult,
6
+ ImpCreateSpec,
7
+ ImpFeatures,
8
+ ImpLease,
9
+ ImpPort,
10
+ ImpRelayConnection,
11
+ ImpReverseForward,
12
+ ImpSessionConnection,
13
+ ImpSessionHandlers,
14
+ ImpSessionOutcome,
15
+ ImpSessionRequest,
16
+ ImpView,
17
+ } from './imp-port';
18
+ import { ImpPortError } from './imp-port-error';
19
+
20
+ /**
21
+ * Where impd listens and the token the daemon calls it with. The token
22
+ * comes from the daemon's environment, never from a config file.
23
+ */
24
+ export interface ImpClientPortOptions {
25
+ readonly url: string;
26
+ readonly token: string | null;
27
+ }
28
+
29
+ /**
30
+ * The imp port over `@zgeoff/imp-client`: RPC calls for imps and leases,
31
+ * one exec WebSocket per session connection, and one reverse forward per
32
+ * guest socket. Every refusal impd sends becomes an `ImpPortError` with its
33
+ * code and data, and a call that never reaches impd carries `UNREACHABLE`.
34
+ */
35
+ export class ImpClientPort implements ImpPort {
36
+ private readonly url: string;
37
+
38
+ private readonly token: string | null;
39
+
40
+ private readonly client: ImpClient;
41
+
42
+ constructor(options: ImpClientPortOptions) {
43
+ this.url = options.url;
44
+ this.token = options.token;
45
+
46
+ this.client = createImpClient({
47
+ url: options.url,
48
+ ...(options.token === null ? {} : { token: options.token }),
49
+ });
50
+ }
51
+
52
+ // An impd from before the flags has neither.
53
+ readonly readFeatures = async (): Promise<ImpFeatures> => {
54
+ const info = await this.tryCall(() => this.client.system.info());
55
+
56
+ return info.features ?? { sessionOffsets: false, leases: false };
57
+ };
58
+
59
+ readonly readImp = async (name: string): Promise<ImpView | null> => {
60
+ try {
61
+ const imp = await this.tryCall(() => this.client.imps.get({ name }));
62
+
63
+ return {
64
+ name: imp.name,
65
+ state: imp.state,
66
+ leases: (imp.leases?.leases ?? []).map((lease) => toLease(lease)),
67
+ otherLeaseCount: imp.leases?.otherCount ?? 0,
68
+ };
69
+ } catch (error) {
70
+ if (error instanceof ImpPortError && error.code === 'NOT_FOUND') {
71
+ return null;
72
+ }
73
+
74
+ throw error;
75
+ }
76
+ };
77
+
78
+ readonly createImp = async (spec: ImpCreateSpec): Promise<ImpView> => {
79
+ const imp = await this.tryCall(() =>
80
+ this.client.imps.create({
81
+ name: spec.name,
82
+ ...(spec.image === undefined ? {} : { image: spec.image }),
83
+ ...(spec.memoryMib === undefined ? {} : { memoryMib: spec.memoryMib }),
84
+ }),
85
+ );
86
+
87
+ return { name: imp.name, state: imp.state, leases: [], otherLeaseCount: 0 };
88
+ };
89
+
90
+ readonly acquireLease = async (
91
+ name: string,
92
+ label: string,
93
+ ttlSeconds: number,
94
+ ): Promise<ImpLease> => {
95
+ const lease = await this.tryCall(() => this.client.leases.acquire({ name, label, ttlSeconds }));
96
+
97
+ return toLease(lease);
98
+ };
99
+
100
+ readonly renewLease = async (
101
+ name: string,
102
+ label: string,
103
+ ttlSeconds: number,
104
+ ): Promise<ImpLease> => {
105
+ const lease = await this.tryCall(() => this.client.leases.renew({ name, label, ttlSeconds }));
106
+
107
+ return toLease(lease);
108
+ };
109
+
110
+ readonly releaseLease = async (name: string, label: string): Promise<boolean> => {
111
+ const result = await this.tryCall(() => this.client.leases.release({ name, label }));
112
+
113
+ return result.released;
114
+ };
115
+
116
+ readonly suspendImp = async (name: string): Promise<void> => {
117
+ await this.tryCall(() => this.client.imps.sleep({ name }));
118
+ };
119
+
120
+ readonly destroyImp = async (name: string): Promise<void> => {
121
+ await this.tryCall(() => this.client.imps.destroy({ name }));
122
+ };
123
+
124
+ // The exec WebSocket answers only after it opens, so nothing reaches the
125
+ // handlers before this returns.
126
+ readonly openSession = (
127
+ request: ImpSessionRequest,
128
+ handlers: ImpSessionHandlers,
129
+ ): ImpSessionConnection => {
130
+ const session = openExecSession({
131
+ baseUrl: this.url,
132
+ token: this.token,
133
+ start:
134
+ request.kind === 'start'
135
+ ? {
136
+ name: request.name,
137
+ session: request.session,
138
+ argv: request.argv,
139
+ tty: true,
140
+ env: request.env,
141
+ cwd: request.cwd,
142
+ cols: request.cols,
143
+ rows: request.rows,
144
+ ...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
145
+ }
146
+ : {
147
+ name: request.name,
148
+ session: request.session,
149
+ cols: request.cols,
150
+ rows: request.rows,
151
+ wake: request.wake,
152
+ ...(request.resumeFrom === undefined ? {} : { resumeFrom: request.resumeFrom }),
153
+ },
154
+ onStarted: (started) => {
155
+ handlers.onStarted({ created: started.created, output: started.output });
156
+ },
157
+ onOutput: (_channel, data) => {
158
+ handlers.onOutput(data);
159
+ },
160
+ connect: (url, headers) => new WebSocket(url, { headers }),
161
+ });
162
+
163
+ return {
164
+ outcome: waitForSessionOutcome(session.outcome),
165
+ write: (data) => {
166
+ session.sendStdin(data);
167
+ },
168
+ resize: (cols, rows) => {
169
+ session.resize(cols, rows);
170
+ },
171
+ sendSignal: (signal) => {
172
+ session.sendSignal(signal);
173
+ },
174
+ close: () => {
175
+ session.stop();
176
+ },
177
+ };
178
+ };
179
+
180
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the command's input bytes have no readonly form
181
+ readonly runCommand = async (name: string, command: ImpCommand): Promise<ImpCommandResult> => {
182
+ const result = await this.tryCall(() =>
183
+ this.client.run(name, command.argv, {
184
+ ...(command.cwd === undefined ? {} : { cwd: command.cwd }),
185
+ ...(command.stdin === undefined ? {} : { stdin: command.stdin }),
186
+ }),
187
+ );
188
+
189
+ return { code: result.code, stdout: result.stdout, stderr: result.stderr };
190
+ };
191
+
192
+ readonly openReverseForward = (
193
+ name: string,
194
+ guestPath: string,
195
+ onConnection: (connection: ImpRelayConnection) => void,
196
+ ): ImpReverseForward => {
197
+ const forward = openReverseForward({
198
+ baseUrl: this.url,
199
+ token: this.token,
200
+ name,
201
+ guest: { network: 'unix', path: guestPath },
202
+ connect: (url, headers) => new WebSocket(url, { headers }),
203
+ onConnection: (accept) => {
204
+ onConnection(openRelay(accept));
205
+ },
206
+ });
207
+
208
+ return {
209
+ listening: waitForListening(forward.listening),
210
+ stop: () => {
211
+ forward.stop();
212
+ },
213
+ };
214
+ };
215
+
216
+ // Runs one client call, turning impd's refusals and transport failures
217
+ // into port errors.
218
+ private async tryCall<T>(call: () => Promise<T>): Promise<T> {
219
+ try {
220
+ return await call();
221
+ } catch (error) {
222
+ throw toPortError(error);
223
+ }
224
+ }
225
+ }
226
+
227
+ // A lease as the client returns it.
228
+ interface ClientLease {
229
+ readonly name: string;
230
+ readonly owner: Readonly<{ principal: string; display: string; label: string }>;
231
+ readonly until: Date | null;
232
+ }
233
+
234
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a lease's end is a Date
235
+ function toLease(lease: ClientLease): ImpLease {
236
+ return {
237
+ name: lease.name,
238
+ owner: {
239
+ principal: lease.owner.principal,
240
+ display: lease.owner.display,
241
+ label: lease.owner.label,
242
+ },
243
+ until: lease.until?.getTime() ?? null,
244
+ };
245
+ }
246
+
247
+ function toPortError(error: unknown): ImpPortError {
248
+ if (error instanceof ImpPortError) {
249
+ return error;
250
+ }
251
+
252
+ // impd's refusals arrive as errors carrying impd's code and data.
253
+ if (error instanceof Error && 'code' in error && typeof error.code === 'string') {
254
+ const data: unknown = 'data' in error ? error.data : undefined;
255
+
256
+ return new ImpPortError(error.code, error.message, data);
257
+ }
258
+
259
+ const message = error instanceof Error ? error.message : String(error);
260
+
261
+ return new ImpPortError('UNREACHABLE', message);
262
+ }
263
+
264
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
265
+ async function waitForSessionOutcome(outcome: Promise<ExecOutcome>): Promise<ImpSessionOutcome> {
266
+ const ended = await outcome;
267
+
268
+ if (ended.kind === 'local_error') {
269
+ return {
270
+ kind: 'local_error',
271
+ detail: ended.error instanceof Error ? ended.error.message : String(ended.error),
272
+ };
273
+ }
274
+
275
+ if (ended.kind === 'unauthorized') {
276
+ return { kind: 'unauthorized' };
277
+ }
278
+
279
+ return ended;
280
+ }
281
+
282
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a promise is a live handle
283
+ async function waitForListening(listening: Promise<unknown>): Promise<void> {
284
+ await listening;
285
+ }
286
+
287
+ // How the client hands over a guest connection: the daemon accepts it with
288
+ // its handlers and gets the relay back.
289
+ type RelayAccept = (
290
+ handlers: Readonly<{
291
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
292
+ onData: (data: Uint8Array) => void;
293
+ onEof: () => void;
294
+ onClose: (lost: boolean) => void;
295
+ }>,
296
+ ) => Readonly<{
297
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
298
+ send: (data: Uint8Array) => boolean;
299
+ waitForRoom: () => Promise<void>;
300
+ close: () => void;
301
+ }>;
302
+
303
+ // Takes a guest connection at once, handing its bytes and its close to the
304
+ // listeners the relay's owner adds.
305
+ function openRelay(accept: RelayAccept): ImpRelayConnection {
306
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
307
+ const dataListeners: ((data: Uint8Array) => void)[] = [];
308
+ const closeListeners: (() => void)[] = [];
309
+
310
+ const relay = accept({
311
+ onData: (data) => {
312
+ for (const listener of dataListeners) {
313
+ listener(data);
314
+ }
315
+ },
316
+ onEof: () => {},
317
+ onClose: () => {
318
+ for (const listener of closeListeners) {
319
+ listener();
320
+ }
321
+ },
322
+ });
323
+
324
+ return {
325
+ onData: (listener) => {
326
+ dataListeners.push(listener);
327
+ },
328
+ onClose: (listener) => {
329
+ closeListeners.push(listener);
330
+ },
331
+
332
+ // A send past the relay's window still queues the bytes, so only the
333
+ // next write waits; a closed relay takes bytes and drops them.
334
+ write: async (data) => {
335
+ if (!relay.send(data)) {
336
+ await relay.waitForRoom();
337
+ }
338
+ },
339
+ close: () => {
340
+ relay.close();
341
+ },
342
+ };
343
+ }