@platless/axom-cli 0.1.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 (90) hide show
  1. package/README.md +64 -0
  2. package/dist/args.js +29 -0
  3. package/dist/cli.js +76 -0
  4. package/dist/commands/auth.js +52 -0
  5. package/dist/commands/execute.js +40 -0
  6. package/dist/commands/guide.js +38 -0
  7. package/dist/commands/record.js +99 -0
  8. package/dist/commands/skills.js +69 -0
  9. package/dist/commands/struct.js +50 -0
  10. package/dist/commands/update.js +88 -0
  11. package/dist/commands/whoami.js +8 -0
  12. package/dist/commands/workstation.js +171 -0
  13. package/dist/config.js +131 -0
  14. package/dist/credentialBroker.js +340 -0
  15. package/dist/credentials.js +211 -0
  16. package/dist/install/agentsDir.js +35 -0
  17. package/dist/install/claude/commands.js +89 -0
  18. package/dist/install/claude/materialize.js +42 -0
  19. package/dist/install/installer.js +45 -0
  20. package/dist/install/targets/claude.js +154 -0
  21. package/dist/mcp.js +152 -0
  22. package/dist/output.js +37 -0
  23. package/dist/workstation/acp/acpAgents.js +1287 -0
  24. package/dist/workstation/acp/acpClient.js +691 -0
  25. package/dist/workstation/acp/acpSession.js +1047 -0
  26. package/dist/workstation/acp/adoption.js +396 -0
  27. package/dist/workstation/acp/auth.js +80 -0
  28. package/dist/workstation/acp/authUrlSniffer.js +52 -0
  29. package/dist/workstation/acp/binaryInstall.js +411 -0
  30. package/dist/workstation/acp/configOptions.js +257 -0
  31. package/dist/workstation/acp/contract.js +494 -0
  32. package/dist/workstation/acp/draftInterceptor.js +154 -0
  33. package/dist/workstation/acp/draftSubagents.js +38 -0
  34. package/dist/workstation/acp/installErrors.js +89 -0
  35. package/dist/workstation/acp/installProbe.js +363 -0
  36. package/dist/workstation/acp/permission.js +327 -0
  37. package/dist/workstation/acp/registry/catalog.js +29 -0
  38. package/dist/workstation/acp/registry/launch.js +128 -0
  39. package/dist/workstation/acp/registry/registryClient.js +160 -0
  40. package/dist/workstation/acp/registry/registryTypes.js +8 -0
  41. package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
  42. package/dist/workstation/acp/test/fakeAgent.js +435 -0
  43. package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
  44. package/dist/workstation/acp/timedCommand.js +47 -0
  45. package/dist/workstation/acp/uvxInstall.js +248 -0
  46. package/dist/workstation/acp/validate.js +134 -0
  47. package/dist/workstation/agentSessions.js +1628 -0
  48. package/dist/workstation/channels/agent.js +96 -0
  49. package/dist/workstation/channels/computer.js +91 -0
  50. package/dist/workstation/channels/management.js +231 -0
  51. package/dist/workstation/channels/url.js +42 -0
  52. package/dist/workstation/config.js +47 -0
  53. package/dist/workstation/connection/encryptedChannel.js +261 -0
  54. package/dist/workstation/connection/retryPolicy.js +14 -0
  55. package/dist/workstation/connection/supervisor.js +353 -0
  56. package/dist/workstation/crypto.js +63 -0
  57. package/dist/workstation/daemon.js +225 -0
  58. package/dist/workstation/daemonctl.js +95 -0
  59. package/dist/workstation/exec.js +112 -0
  60. package/dist/workstation/journal.js +257 -0
  61. package/dist/workstation/names.js +17 -0
  62. package/dist/workstation/oauth/browser.js +24 -0
  63. package/dist/workstation/oauth/deviceAuthorization.js +198 -0
  64. package/dist/workstation/osinfo.js +4 -0
  65. package/dist/workstation/protocol/agent.js +606 -0
  66. package/dist/workstation/protocol/common.js +136 -0
  67. package/dist/workstation/protocol/computer.js +31 -0
  68. package/dist/workstation/protocol/management.js +116 -0
  69. package/dist/workstation/register.js +42 -0
  70. package/dist/workstation/service/installer.js +362 -0
  71. package/dist/workstation/service/launchd.js +46 -0
  72. package/dist/workstation/service/loginShell.js +36 -0
  73. package/dist/workstation/service/systemd.js +48 -0
  74. package/dist/workstation/service/types.js +73 -0
  75. package/dist/workstation/supervision.js +52 -0
  76. package/dist/workstation/systemMetrics.js +271 -0
  77. package/dist/workstation/update/artifact.js +61 -0
  78. package/dist/workstation/update/coordinator.js +240 -0
  79. package/dist/workstation/update/npmInstaller.js +91 -0
  80. package/dist/workstation/update/redact.js +36 -0
  81. package/dist/workstation/update/runUpdate.js +215 -0
  82. package/dist/workstation/update/stateStore.js +87 -0
  83. package/dist/workstation/update/taskRegistry.js +142 -0
  84. package/dist/workstation/update/types.js +3 -0
  85. package/dist/workstation/update/watchdog.js +311 -0
  86. package/dist/workstation/version.js +12 -0
  87. package/package.json +39 -0
  88. package/skills/axom/SKILL.md +83 -0
  89. package/skills/axom-definition/SKILL.md +74 -0
  90. package/skills/axom-platform/SKILL.md +78 -0
@@ -0,0 +1,261 @@
1
+ import { WebSocket } from 'ws';
2
+ import { open as openFrame, seal as sealFrame } from '../crypto.js';
3
+ class AsyncMessageQueue {
4
+ values = [];
5
+ waiters = [];
6
+ ended = false;
7
+ [Symbol.asyncIterator]() { return this; }
8
+ next() {
9
+ const value = this.values.shift();
10
+ if (value !== undefined)
11
+ return Promise.resolve({ done: false, value });
12
+ if (this.ended)
13
+ return Promise.resolve({ done: true, value: undefined });
14
+ return new Promise((resolve) => this.waiters.push(resolve));
15
+ }
16
+ push(value) {
17
+ if (this.ended)
18
+ return;
19
+ const waiter = this.waiters.shift();
20
+ if (waiter)
21
+ waiter({ done: false, value });
22
+ else
23
+ this.values.push(value);
24
+ }
25
+ end() {
26
+ if (this.ended)
27
+ return;
28
+ this.ended = true;
29
+ for (const waiter of this.waiters.splice(0))
30
+ waiter({ done: true, value: undefined });
31
+ }
32
+ }
33
+ const defaultTimers = {
34
+ now: () => Date.now(),
35
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
36
+ clearTimeout: (handle) => clearTimeout(handle),
37
+ };
38
+ const defaultCrypto = { seal: sealFrame, open: openFrame };
39
+ /**
40
+ * Opens a raw WebSocket whose payloads are encrypted with the caller-provided session key.
41
+ *
42
+ * Authentication and domain handshakes deliberately remain outside this primitive. Management
43
+ * derives a long-term-key session after its nonce challenge; computer and agent derive a ticket
44
+ * key. Once a key exists, all three use this identical framing and Client-originated liveness.
45
+ */
46
+ export function connectEncryptedChannel(options) {
47
+ const heartbeatMs = options.heartbeatMs ?? 10_000;
48
+ const missedAcks = options.missedAcks ?? 3;
49
+ // Comfortably past a connect, an upgrade and one handshake round trip on any link this daemon is
50
+ // expected to work over, and well inside the supervisor's own per-attempt bound, so a channel
51
+ // that is merely slow is never mistaken for one that is wedged.
52
+ const openTimeoutMs = options.openTimeoutMs ?? 15_000;
53
+ if (heartbeatMs <= 0 || missedAcks <= 0)
54
+ throw new RangeError('heartbeat settings must be positive');
55
+ if (openTimeoutMs <= 0)
56
+ throw new RangeError('openTimeoutMs must be positive');
57
+ const socket = (options.createWebSocket ?? ((url) => new WebSocket(url)))(options.url);
58
+ const crypto = options.crypto ?? defaultCrypto;
59
+ const timers = options.timers ?? defaultTimers;
60
+ const messages = new AsyncMessageQueue();
61
+ const rawMessages = new AsyncMessageQueue();
62
+ let sessionKey = options.sessionKey;
63
+ let state = 'OPEN';
64
+ let opened = false;
65
+ let settled = false;
66
+ let heartbeatTimer;
67
+ let openTimer;
68
+ let nextSequence = 1;
69
+ const outstandingAcks = new Set();
70
+ let resolveClosed;
71
+ let removeAbortListener = () => { };
72
+ const closed = new Promise((resolve) => { resolveClosed = resolve; });
73
+ const disarmOpenTimer = () => {
74
+ if (openTimer === undefined)
75
+ return;
76
+ timers.clearTimeout(openTimer);
77
+ openTimer = undefined;
78
+ };
79
+ const finish = () => {
80
+ if (settled)
81
+ return;
82
+ settled = true;
83
+ state = 'CLOSED';
84
+ if (heartbeatTimer !== undefined)
85
+ timers.clearTimeout(heartbeatTimer);
86
+ disarmOpenTimer();
87
+ messages.end();
88
+ rawMessages.end();
89
+ outstandingAcks.clear();
90
+ removeAbortListener();
91
+ resolveClosed();
92
+ };
93
+ const close = () => {
94
+ if (settled)
95
+ return;
96
+ // Finish before calling close: test doubles and some ws error paths synchronously emit close.
97
+ finish();
98
+ socket.close();
99
+ };
100
+ const send = (frame) => {
101
+ if (state !== 'OPEN' || !opened)
102
+ throw new Error('Encrypted channel is not open');
103
+ socket.send(crypto.seal(sessionKey, Buffer.from(options.encode(frame))));
104
+ };
105
+ const scheduleHeartbeat = () => {
106
+ heartbeatTimer = timers.setTimeout(() => {
107
+ if (settled)
108
+ return;
109
+ const sequence = nextSequence++;
110
+ // Register before send: a synchronous server acknowledgement/work frame emitted from
111
+ // `send` must observe and satisfy this precise liveness period.
112
+ outstandingAcks.add(sequence);
113
+ try {
114
+ send(options.heartbeat(sequence, timers.now()));
115
+ }
116
+ catch {
117
+ outstandingAcks.delete(sequence);
118
+ close();
119
+ return;
120
+ }
121
+ // `ws.send` can synchronously deliver close/error through a test double or a transport
122
+ // implementation. Never resurrect liveness state after that terminal callback.
123
+ if (settled)
124
+ return;
125
+ if (outstandingAcks.size >= missedAcks) {
126
+ close();
127
+ return;
128
+ }
129
+ scheduleHeartbeat();
130
+ }, heartbeatMs);
131
+ };
132
+ return new Promise((resolve, reject) => {
133
+ let connectionSettled = false;
134
+ const failConnection = (error) => {
135
+ if (connectionSettled)
136
+ return;
137
+ connectionSettled = true;
138
+ finish();
139
+ socket.close();
140
+ reject(error);
141
+ };
142
+ const abort = () => {
143
+ if (settled)
144
+ return;
145
+ if (!connectionSettled) {
146
+ connectionSettled = true;
147
+ finish();
148
+ socket.close();
149
+ reject(new Error(`Encrypted channel aborted before opening: ${options.url}`));
150
+ return;
151
+ }
152
+ close();
153
+ };
154
+ removeAbortListener = () => options.signal?.removeEventListener('abort', abort);
155
+ if (options.signal?.aborted)
156
+ abort();
157
+ else
158
+ options.signal?.addEventListener('abort', abort, { once: true });
159
+ // Armed before the socket can report anything, and disarmed by `finish` on every terminal path
160
+ // as well as by the success path below -- so it bounds the opening and nothing more.
161
+ openTimer = timers.setTimeout(() => {
162
+ failConnection(new Error(`Encrypted channel timed out opening ${options.url} after ${openTimeoutMs}ms`));
163
+ }, openTimeoutMs);
164
+ socket.on('open', () => {
165
+ if (settled || opened || connectionSettled)
166
+ return;
167
+ void (async () => {
168
+ try {
169
+ sessionKey = options.handshake ? await options.handshake({
170
+ sendText: (text) => socket.send(text),
171
+ receive: async () => {
172
+ const item = await rawMessages.next();
173
+ if (item.done)
174
+ throw new Error('Socket closed during encrypted-channel handshake');
175
+ return item.value;
176
+ },
177
+ }) : sessionKey;
178
+ // A close/error may have happened while an asynchronous plaintext handshake was waiting
179
+ // for its challenge response. The connection promise is already terminal in that case.
180
+ if (settled || connectionSettled)
181
+ return;
182
+ if (!sessionKey)
183
+ throw new Error('Encrypted channel needs a session key or handshake');
184
+ opened = true;
185
+ if (options.initialFrame)
186
+ send(options.initialFrame());
187
+ // An initial proof may synchronously trigger a socket close/error. `opened` permits the
188
+ // proof frame itself, but the public connection is not published until this guard passes.
189
+ if (settled || connectionSettled)
190
+ return;
191
+ scheduleHeartbeat();
192
+ connectionSettled = true;
193
+ disarmOpenTimer();
194
+ resolve({
195
+ send,
196
+ messages,
197
+ close,
198
+ closed,
199
+ get state() { return state; },
200
+ });
201
+ }
202
+ catch (error) {
203
+ failConnection(error instanceof Error ? error : new Error(String(error)));
204
+ }
205
+ })();
206
+ });
207
+ socket.on('message', (data, isBinary) => {
208
+ if (settled)
209
+ return;
210
+ const bytes = Array.isArray(data)
211
+ ? Buffer.concat(data)
212
+ : Buffer.isBuffer(data)
213
+ ? data
214
+ : Buffer.from(new Uint8Array(data));
215
+ if (!opened) {
216
+ rawMessages.push({ data: bytes, isBinary });
217
+ return;
218
+ }
219
+ if (!isBinary)
220
+ return;
221
+ try {
222
+ const frame = options.decode(crypto.open(sessionKey, bytes).toString());
223
+ const acknowledged = [...outstandingAcks].some((sequence) => options.isHeartbeatAck(frame, sequence));
224
+ // A correctly encrypted and decoded frame proves the peer is still reachable. Heartbeat
225
+ // acknowledgements are one such frame, but computer streaming/agent events may legitimately
226
+ // be the only server traffic while a task runs. Invalid frames take the catch path and never
227
+ // get to satisfy liveness.
228
+ outstandingAcks.clear();
229
+ if (!acknowledged)
230
+ messages.push(frame);
231
+ }
232
+ catch {
233
+ close();
234
+ }
235
+ });
236
+ socket.on('close', () => {
237
+ if (!connectionSettled) {
238
+ connectionSettled = true;
239
+ finish();
240
+ reject(new Error(`Encrypted channel closed before opening: ${options.url}`));
241
+ return;
242
+ }
243
+ finish();
244
+ });
245
+ socket.on('error', () => {
246
+ if (settled)
247
+ return;
248
+ const rejectOpening = !connectionSettled;
249
+ // Mark this before close(): ws and deterministic fakes can emit close synchronously.
250
+ if (rejectOpening)
251
+ connectionSettled = true;
252
+ finish();
253
+ // The error event alone does not guarantee ws has released its network resources. Closing is
254
+ // safe and idempotent, and it also gives callers one uniform terminal path.
255
+ socket.close();
256
+ if (rejectOpening) {
257
+ reject(new Error(`Unable to open encrypted channel to ${options.url}`));
258
+ }
259
+ });
260
+ });
261
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The reconnect cadence is intentionally bounded, not exponential: an offline workstation first
3
+ * retries promptly, then stays observable and recoverable for days without a terminal state.
4
+ */
5
+ export class RetryPolicy {
6
+ failures = 0;
7
+ recordFailure() {
8
+ this.failures += 1;
9
+ return this.failures <= 3 ? 10_000 : 30_000;
10
+ }
11
+ recordSuccess() {
12
+ this.failures = 0;
13
+ }
14
+ }
@@ -0,0 +1,353 @@
1
+ import { RetryPolicy } from './retryPolicy.js';
2
+ const waitFor = (milliseconds, signal) => new Promise((resolve) => {
3
+ if (signal?.aborted) {
4
+ resolve();
5
+ return;
6
+ }
7
+ const timer = setTimeout(done, milliseconds);
8
+ const abort = () => { clearTimeout(timer); done(); };
9
+ function done() { signal?.removeEventListener('abort', abort); resolve(); }
10
+ signal?.addEventListener('abort', abort, { once: true });
11
+ });
12
+ /**
13
+ * Twice the encrypted channel's own opening deadline, so this outer bound never pre-empts the inner
14
+ * one and turn a diagnosable "timed out opening the socket" into a vaguer attempt timeout. What it
15
+ * covers is everything else a connect call does around that socket -- refreshing git, waiting for
16
+ * `management_ready`, the readiness hook, the data channels' ticket round trip -- and, more to the
17
+ * point, whatever is added to that path later. The loop's guarantee is meant to be structural: it
18
+ * keeps retrying because no attempt can outlast this, not because every function it calls happens
19
+ * to have been audited.
20
+ */
21
+ const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000;
22
+ /**
23
+ * How long a management session has to survive before the reconnect that follows it is treated as
24
+ * recovery rather than churn.
25
+ *
26
+ * The distinction this draws is not "did the connection drop" -- every outage does that, and every
27
+ * outage must still recover as fast as it always has. It is "did the reconnect *hold*". A dropped
28
+ * network, a crashed server, a server down for a day: those fail at `connect`, are counted by
29
+ * [managementRetry], and none of this touches them. A server restart closes a session that had been
30
+ * up for hours, which is well past this line, so that reconnect stays immediate too.
31
+ *
32
+ * What lands under it is a session that connects and is then taken away within seconds, over and
33
+ * over -- the shape two daemons sharing one workstation identity make, because each new management
34
+ * generation evicts the other's. Reconnecting instantly is exactly wrong there: it is what turns a
35
+ * pair of duplicates into ~6 evictions a second (8648 of them in minutes on 2026-09-10) instead of
36
+ * two processes taking turns while somebody notices.
37
+ */
38
+ const HEALTHY_SESSION_MS = 5_000;
39
+ /**
40
+ * Delay before reconnecting after the Nth consecutive session that died younger than
41
+ * [HEALTHY_SESSION_MS]. The first two are still immediate, so a real flap -- a server bouncing, a
42
+ * laptop's Wi-Fi returning mid-handshake -- recovers exactly as promptly as it used to; only a
43
+ * pattern that has already repeated gets slowed, and only to five seconds. One session that
44
+ * survives resets the whole ladder.
45
+ */
46
+ const CHURN_BACKOFF_MS = [0, 0, 250, 1_000, 2_000, 5_000];
47
+ /** Owns one authenticated management generation and its independently reconnecting data channels. */
48
+ export class ConnectionSupervisor {
49
+ options;
50
+ states = { management: 'DISCONNECTED', computer: 'DISCONNECTED', agent: 'DISCONNECTED' };
51
+ managementRetry = new RetryPolicy();
52
+ computerRetry = new RetryPolicy();
53
+ agentRetry = new RetryPolicy();
54
+ running = false;
55
+ management = null;
56
+ computer = null;
57
+ agent = null;
58
+ lifecycleAbort = null;
59
+ dataAbort = null;
60
+ generation = 0;
61
+ /** Consecutive management sessions that died younger than {@link HEALTHY_SESSION_MS}. */
62
+ churn = 0;
63
+ startPromise = null;
64
+ constructor(options) {
65
+ this.options = options;
66
+ }
67
+ get attemptTimeoutMs() { return this.options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS; }
68
+ log(message) { this.options.log?.(message); }
69
+ get now() { return (this.options.now ?? Date.now)(); }
70
+ /**
71
+ * Closes one management session and decides how soon to open the next.
72
+ *
73
+ * A session that lived at least {@link HEALTHY_SESSION_MS} clears the ladder and reconnects with
74
+ * no delay -- the behaviour every outage has always had, and the one this must not slow down.
75
+ * Only a run of sessions that keep dying young is paced, and only up to five seconds.
76
+ *
77
+ * Closing [connected] here is the other half. The two paths that abandon a session because its
78
+ * data channels never attached used to leave the management socket open: `closeData()` covers
79
+ * computer and agent, nothing covered management, so every lap leaked a live socket that the
80
+ * server kept bound until a newer generation evicted it. That is why the loop could not settle --
81
+ * the abandoned sockets were still there to be evicted, and each eviction fed another lap.
82
+ */
83
+ async endSession(connected, connectedAt, reason, signal) {
84
+ connected.close?.();
85
+ if (this.management === connected)
86
+ this.management = null;
87
+ this.closeData();
88
+ if (!this.running || signal.aborted)
89
+ return;
90
+ this.states.management = 'RECONNECTING';
91
+ if (this.now - connectedAt >= HEALTHY_SESSION_MS) {
92
+ this.churn = 0;
93
+ this.log(`[connection] ${reason}; reconnecting immediately`);
94
+ return;
95
+ }
96
+ const delayMs = CHURN_BACKOFF_MS[Math.min(this.churn, CHURN_BACKOFF_MS.length - 1)];
97
+ this.churn += 1;
98
+ if (delayMs === 0) {
99
+ this.log(`[connection] ${reason}; reconnecting immediately`);
100
+ return;
101
+ }
102
+ this.log(`[connection] ${reason}; reconnecting in ${delayMs}ms (${this.churn} short-lived sessions in a row -- another daemon may hold this workstation)`);
103
+ await this.wait(delayMs, signal);
104
+ }
105
+ /**
106
+ * Runs one connect attempt under a deadline, and hands back the cleanup that unlinks it from the
107
+ * supervisor's lifecycle.
108
+ *
109
+ * The attempt gets its own AbortController rather than the lifecycle signal directly, because the
110
+ * deadline has to be able to cancel *this* attempt without tearing down the supervisor. The
111
+ * controller still cascades from the lifecycle signal, so stopping the supervisor closes an
112
+ * in-flight opening exactly as it did before.
113
+ *
114
+ * A timed-out attempt is aborted, not merely dropped: the socket underneath it would otherwise
115
+ * stay open, and a peer that is silent rather than gone would cost one file descriptor per
116
+ * reconnect for as long as it stayed that way.
117
+ */
118
+ async attempt(lifecycle, connect) {
119
+ const attempt = new AbortController();
120
+ const cascade = () => attempt.abort();
121
+ if (lifecycle.aborted)
122
+ attempt.abort();
123
+ else
124
+ lifecycle.addEventListener('abort', cascade, { once: true });
125
+ const release = () => lifecycle.removeEventListener('abort', cascade);
126
+ const armed = new AbortController();
127
+ try {
128
+ const connection = await Promise.race([
129
+ connect(attempt.signal),
130
+ // Disarming (the `finally` below) also ends this wait, so this can throw after the race is
131
+ // already decided. That is harmless and deliberately not guarded against: `Promise.race`
132
+ // subscribes to both inputs, so it absorbs the late rejection instead of leaving it
133
+ // unhandled. What the disarm is actually for is the timer itself -- an attempt that
134
+ // succeeded in a second must not hold a 30-second timer for the other 29.
135
+ this.deadline(this.attemptTimeoutMs, armed.signal).then(() => {
136
+ throw new Error(`Connect attempt exceeded ${this.attemptTimeoutMs}ms`);
137
+ }),
138
+ ]);
139
+ return { connection, release };
140
+ }
141
+ catch (error) {
142
+ attempt.abort();
143
+ release();
144
+ throw error;
145
+ }
146
+ finally {
147
+ armed.abort();
148
+ }
149
+ }
150
+ channelSnapshot() { return { ...this.states }; }
151
+ async start() {
152
+ if (this.startPromise)
153
+ return this.startPromise;
154
+ this.running = true;
155
+ const lifecycleAbort = new AbortController();
156
+ this.lifecycleAbort = lifecycleAbort;
157
+ let ready;
158
+ this.startPromise = new Promise((resolve) => { ready = resolve; });
159
+ void this.managementLoop(ready, lifecycleAbort.signal);
160
+ return this.startPromise;
161
+ }
162
+ async run(signal) {
163
+ const abortError = new Error('Connection supervisor stopped');
164
+ if (signal.aborted) {
165
+ this.stop();
166
+ throw abortError;
167
+ }
168
+ let rejectAbort;
169
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
170
+ const abort = () => {
171
+ this.stop();
172
+ rejectAbort(abortError);
173
+ };
174
+ signal.addEventListener('abort', abort, { once: true });
175
+ try {
176
+ await Promise.race([this.start(), aborted]);
177
+ await aborted;
178
+ }
179
+ finally {
180
+ signal.removeEventListener('abort', abort);
181
+ this.stop();
182
+ }
183
+ throw new Error('Connection supervisor stopped');
184
+ }
185
+ stop() {
186
+ if (!this.running)
187
+ return;
188
+ this.running = false;
189
+ this.startPromise = null;
190
+ this.lifecycleAbort?.abort();
191
+ this.lifecycleAbort = null;
192
+ this.generation += 1;
193
+ this.management?.close?.();
194
+ this.management = null;
195
+ this.closeData();
196
+ this.states.management = 'DISCONNECTED';
197
+ }
198
+ async managementLoop(onFirstReady, signal) {
199
+ let firstReady = true;
200
+ while (this.running && !signal.aborted) {
201
+ this.states.management = firstReady ? 'CONNECTING' : 'RECONNECTING';
202
+ let failure = 'unknown error';
203
+ try {
204
+ const attempt = await this.attempt(signal, (attemptSignal) => this.options.management.connect(attemptSignal));
205
+ const connected = attempt.connection;
206
+ try {
207
+ if (!this.running) {
208
+ connected.close?.();
209
+ return;
210
+ }
211
+ this.managementRetry.recordSuccess();
212
+ this.management = connected;
213
+ const connectedAt = this.now;
214
+ this.states.management = 'CONNECTED';
215
+ this.log(`[connection] management connected (generation ${connected.generation})`);
216
+ const epoch = ++this.generation;
217
+ this.closeData();
218
+ const dataAbort = new AbortController();
219
+ this.dataAbort = dataAbort;
220
+ const computerLoop = this.dataLoop('COMPUTER', connected, epoch, dataAbort.signal);
221
+ // Initial attachment is deliberately ordered: only an authenticated management generation
222
+ // can mint tickets, and computer finishes its first join before agent asks for its ticket.
223
+ if (!await this.waitForDataOrManagementClose('computer', connected)) {
224
+ void computerLoop;
225
+ dataAbort.abort();
226
+ await this.endSession(connected, connectedAt, 'management closed before the computer channel attached', signal);
227
+ continue;
228
+ }
229
+ const agentLoop = this.dataLoop('AGENT', connected, epoch, dataAbort.signal);
230
+ if (firstReady) {
231
+ // The first successful attachment is the useful completion point for daemon startup.
232
+ if (!await this.waitForDataOrManagementClose('agent', connected)) {
233
+ void computerLoop;
234
+ void agentLoop;
235
+ dataAbort.abort();
236
+ await this.endSession(connected, connectedAt, 'management closed before the agent channel attached', signal);
237
+ continue;
238
+ }
239
+ firstReady = false;
240
+ onFirstReady();
241
+ }
242
+ await connected.closed;
243
+ void computerLoop;
244
+ void agentLoop;
245
+ if (this.running) {
246
+ // Immediate reconnect after a clean closure is still the rule: a server that explicitly
247
+ // closed the channel (restart, heartbeat timeout) should be rejoined at once, and a
248
+ // session that had been up for any real length of time takes that path unchanged.
249
+ // `endSession` only paces a *run* of sessions that die within seconds of connecting,
250
+ // which is the duplicate-daemon shape rather than an outage.
251
+ await this.endSession(connected, connectedAt, 'management channel closed', signal);
252
+ continue;
253
+ }
254
+ if (this.management === connected)
255
+ this.management = null;
256
+ }
257
+ finally {
258
+ attempt.release();
259
+ }
260
+ }
261
+ catch (error) {
262
+ if (!this.running)
263
+ return;
264
+ failure = error instanceof Error ? error.message : String(error);
265
+ // Network errors (failed connect, handshake error) get backoff via retryPolicy.
266
+ }
267
+ if (!this.running || signal.aborted)
268
+ return;
269
+ const delayMs = this.managementRetry.recordFailure();
270
+ // The one line that made the 2026-09-03 outage a forensic exercise instead of a `tail`: every
271
+ // failure on this path used to be swallowed silently, so a loop that had stopped trying and a
272
+ // loop that was trying and failing looked identical from the log.
273
+ this.log(`[connection] management connect failed: ${failure}; retrying in ${delayMs}ms`);
274
+ await this.wait(delayMs, signal);
275
+ }
276
+ }
277
+ async dataLoop(kind, management, epoch, signal) {
278
+ const name = kind === 'COMPUTER' ? 'computer' : 'agent';
279
+ const retry = kind === 'COMPUTER' ? this.computerRetry : this.agentRetry;
280
+ let connectedOnce = false;
281
+ while (this.running && this.generation === epoch && this.management === management) {
282
+ this.states[name] = connectedOnce ? 'RECONNECTING' : 'CONNECTING';
283
+ let failure = 'unknown error';
284
+ let release = () => { };
285
+ try {
286
+ // Ticket and join are bounded as one attempt: the ticket is a request/response over the
287
+ // management channel, so it is one more place a silent peer can park this loop.
288
+ const attempt = await this.attempt(signal, async (attemptSignal) => {
289
+ const ticket = await management.ticket(kind);
290
+ return (kind === 'COMPUTER' ? this.options.computer : this.options.agent).connect(ticket, attemptSignal);
291
+ });
292
+ release = attempt.release;
293
+ const connected = attempt.connection;
294
+ if (!this.running || this.generation !== epoch || this.management !== management) {
295
+ connected.close?.();
296
+ return;
297
+ }
298
+ retry.recordSuccess();
299
+ if (kind === 'COMPUTER')
300
+ this.computer = connected;
301
+ else
302
+ this.agent = connected;
303
+ this.states[name] = 'CONNECTED';
304
+ connectedOnce = true;
305
+ await connected.closed;
306
+ // Assigned here, not left at the initial value: reaching this line means the channel opened
307
+ // and was then closed, which is a different event from the open having thrown. Both used to
308
+ // print "unknown error", because `failure` was only ever written in the `catch` -- so the
309
+ // log said the same thing whether the peer had rejected the ticket or had simply hung up,
310
+ // and a duplicate-daemon eviction (a perfectly clean close) read as a transport fault. Two
311
+ // hours of 2026-09-10 went into a reverse proxy that was never involved.
312
+ failure = 'closed by the peer';
313
+ if (kind === 'COMPUTER' && this.computer === connected)
314
+ this.computer = null;
315
+ if (kind === 'AGENT' && this.agent === connected)
316
+ this.agent = null;
317
+ }
318
+ catch (error) {
319
+ failure = error instanceof Error ? error.message : String(error);
320
+ // The persistent retry policy below owns all failed opens and unexpected closes.
321
+ }
322
+ finally {
323
+ release();
324
+ }
325
+ if (!this.running || this.generation !== epoch || this.management !== management)
326
+ return;
327
+ this.states[name] = 'RECONNECTING';
328
+ const delayMs = retry.recordFailure();
329
+ this.log(`[connection] ${name} channel dropped: ${failure}; retrying in ${delayMs}ms`);
330
+ await this.wait(delayMs, signal);
331
+ }
332
+ }
333
+ closeData() {
334
+ this.dataAbort?.abort();
335
+ this.dataAbort = null;
336
+ this.computer?.close?.();
337
+ this.agent?.close?.();
338
+ this.computer = this.agent = null;
339
+ this.states.computer = this.states.agent = 'DISCONNECTED';
340
+ }
341
+ async waitForDataState(name) {
342
+ while (this.running && this.states[name] !== 'CONNECTED')
343
+ await waitFor(1);
344
+ }
345
+ async waitForDataOrManagementClose(name, management) {
346
+ return Promise.race([
347
+ this.waitForDataState(name).then(() => this.running && this.management === management),
348
+ management.closed.then(() => false),
349
+ ]);
350
+ }
351
+ wait(milliseconds, signal) { return (this.options.wait ?? waitFor)(milliseconds, signal); }
352
+ deadline(milliseconds, signal) { return (this.options.deadline ?? waitFor)(milliseconds, signal); }
353
+ }