androdex 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,554 @@
1
+ // FILE: host-runtime.js
2
+ // Purpose: Keeps a durable relay presence alive and activates the local Codex workspace on demand.
3
+ // Layer: CLI service
4
+ // Exports: HostRuntime
5
+ // Depends on: fs, ws, ./codex-desktop-refresher, ./codex-transport, ./session-state, ./secure-device-state, ./secure-transport
6
+
7
+ const fs = require("fs");
8
+ const WebSocket = require("ws");
9
+ const {
10
+ CodexDesktopRefresher,
11
+ readBridgeConfig,
12
+ } = require("./codex-desktop-refresher");
13
+ const { createCodexTransport } = require("./codex-transport");
14
+ const { rememberActiveThread } = require("./session-state");
15
+ const { handleGitRequest } = require("./git-handler");
16
+ const { handleWorkspaceRequest } = require("./workspace-handler");
17
+ const { loadOrCreateBridgeDeviceState } = require("./secure-device-state");
18
+ const { createBridgeSecureTransport } = require("./secure-transport");
19
+ const { readDaemonRuntimeState, writeDaemonRuntimeState } = require("./daemon-store");
20
+
21
+ class HostRuntime {
22
+ constructor({ env = process.env, platform = process.platform } = {}) {
23
+ this.config = readBridgeConfig({ env, platform });
24
+ this.relayBaseUrl = this.config.relayUrl.replace(/\/+$/, "");
25
+ this.deviceState = loadOrCreateBridgeDeviceState();
26
+ this.hostId = this.deviceState.hostId;
27
+ this.relayHostUrl = `${this.relayBaseUrl}/${this.hostId}`;
28
+ this.desktopRefresher = new CodexDesktopRefresher({
29
+ enabled: this.config.refreshEnabled,
30
+ debounceMs: this.config.refreshDebounceMs,
31
+ refreshCommand: this.config.refreshCommand,
32
+ bundleId: this.config.codexBundleId,
33
+ appPath: this.config.codexAppPath,
34
+ });
35
+ this.secureTransport = createBridgeSecureTransport({
36
+ hostId: this.hostId,
37
+ relayUrl: this.relayBaseUrl,
38
+ deviceState: this.deviceState,
39
+ });
40
+ this.socket = null;
41
+ this.codex = null;
42
+ this.currentCwd = "";
43
+ this.reconnectAttempt = 0;
44
+ this.reconnectTimer = null;
45
+ this.activationQueue = Promise.resolve();
46
+ this.activationSequence = 0;
47
+ this.relayStatus = "disconnected";
48
+ this.codexHandshakeState = "cold";
49
+ this.forwardedInitializeRequestIds = new Set();
50
+ this.cachedInitializeParams = null;
51
+ this.cachedLegacyInitializeParams = null;
52
+ this.cachedInitializedNotification = false;
53
+ this.syntheticInitializeRequest = null;
54
+ this.syntheticInitializeCounter = 0;
55
+ this.isStopping = false;
56
+ this.runtimeState = readDaemonRuntimeState();
57
+ }
58
+
59
+ start() {
60
+ this.connectRelay();
61
+ const rememberedCwd = normalizeNonEmptyString(this.runtimeState.lastActiveCwd);
62
+ if (rememberedCwd && isExistingDirectory(rememberedCwd)) {
63
+ void this.activateWorkspace({ cwd: rememberedCwd }).catch((error) => {
64
+ console.error(`[androdex] Failed to restore workspace ${rememberedCwd}: ${error.message}`);
65
+ });
66
+ }
67
+ }
68
+
69
+ async stop() {
70
+ this.isStopping = true;
71
+ this.clearReconnectTimer();
72
+ this.clearCachedBridgeHandshakeState();
73
+ this.desktopRefresher.handleTransportReset();
74
+ if (this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) {
75
+ this.socket.close();
76
+ }
77
+ this.socket = null;
78
+ await this.shutdownCodex();
79
+ }
80
+
81
+ async activateWorkspace({ cwd = "" } = {}) {
82
+ const nextCwd = normalizeNonEmptyString(cwd) || process.cwd();
83
+ if (!isExistingDirectory(nextCwd)) {
84
+ throw new Error(`Workspace directory not found: ${nextCwd}`);
85
+ }
86
+
87
+ const activationId = ++this.activationSequence;
88
+ const activation = this.activationQueue.then(() => this.performWorkspaceActivation({
89
+ cwd: nextCwd,
90
+ activationId,
91
+ }));
92
+
93
+ this.activationQueue = activation.then(
94
+ () => undefined,
95
+ () => undefined
96
+ );
97
+
98
+ return activation;
99
+ }
100
+
101
+ getPairingPayload() {
102
+ return this.secureTransport.createPairingPayload();
103
+ }
104
+
105
+ getStatus() {
106
+ const currentDeviceState = this.secureTransport.getCurrentDeviceState();
107
+ return {
108
+ hostId: this.hostId,
109
+ macDeviceId: currentDeviceState.macDeviceId,
110
+ relayUrl: this.relayBaseUrl,
111
+ relayStatus: this.relayStatus,
112
+ currentCwd: this.currentCwd || null,
113
+ workspaceActive: Boolean(this.codex),
114
+ hasTrustedPhone: Object.keys(currentDeviceState.trustedPhones || {}).length > 0,
115
+ };
116
+ }
117
+
118
+ clearReconnectTimer() {
119
+ if (!this.reconnectTimer) {
120
+ return;
121
+ }
122
+ clearTimeout(this.reconnectTimer);
123
+ this.reconnectTimer = null;
124
+ }
125
+
126
+ scheduleRelayReconnect(closeCode) {
127
+ if (this.isStopping) {
128
+ return;
129
+ }
130
+ if (closeCode === 4000 || closeCode === 4001) {
131
+ this.relayStatus = "disconnected";
132
+ return;
133
+ }
134
+ if (this.reconnectTimer) {
135
+ return;
136
+ }
137
+
138
+ this.reconnectAttempt += 1;
139
+ const delayMs = Math.min(1_000 * this.reconnectAttempt, 5_000);
140
+ this.relayStatus = "connecting";
141
+ this.reconnectTimer = setTimeout(() => {
142
+ this.reconnectTimer = null;
143
+ this.connectRelay();
144
+ }, delayMs);
145
+ }
146
+
147
+ connectRelay() {
148
+ if (this.isStopping) {
149
+ return;
150
+ }
151
+
152
+ this.relayStatus = "connecting";
153
+ const nextSocket = new WebSocket(this.relayHostUrl, {
154
+ headers: { "x-role": "mac" },
155
+ });
156
+ this.socket = nextSocket;
157
+
158
+ nextSocket.on("open", () => {
159
+ this.clearReconnectTimer();
160
+ this.reconnectAttempt = 0;
161
+ this.relayStatus = "connected";
162
+ this.secureTransport.bindLiveSendWireMessage((wireMessage) => {
163
+ if (nextSocket.readyState === WebSocket.OPEN) {
164
+ nextSocket.send(wireMessage);
165
+ }
166
+ });
167
+ });
168
+
169
+ nextSocket.on("message", (data) => {
170
+ const message = typeof data === "string" ? data : data.toString("utf8");
171
+ if (this.secureTransport.handleIncomingWireMessage(message, {
172
+ sendControlMessage: (controlMessage) => {
173
+ if (nextSocket.readyState === WebSocket.OPEN) {
174
+ nextSocket.send(JSON.stringify(controlMessage));
175
+ }
176
+ },
177
+ onApplicationMessage: (plaintextMessage) => {
178
+ this.handleApplicationMessage(plaintextMessage);
179
+ },
180
+ })) {
181
+ return;
182
+ }
183
+ });
184
+
185
+ nextSocket.on("close", (code) => {
186
+ this.relayStatus = "disconnected";
187
+ if (this.socket === nextSocket) {
188
+ this.socket = null;
189
+ }
190
+ this.clearCachedBridgeHandshakeState();
191
+ this.desktopRefresher.handleTransportReset();
192
+ this.scheduleRelayReconnect(code);
193
+ });
194
+
195
+ nextSocket.on("error", () => {
196
+ this.relayStatus = "disconnected";
197
+ });
198
+ }
199
+
200
+ async shutdownCodex() {
201
+ const activeCodex = this.codex;
202
+ this.syntheticInitializeRequest = null;
203
+ if (!activeCodex) {
204
+ this.codex = null;
205
+ return;
206
+ }
207
+
208
+ this.codex = null;
209
+ this.codexHandshakeState = "cold";
210
+ try {
211
+ activeCodex.shutdown();
212
+ } catch {
213
+ // Ignore shutdown failures for stale transports.
214
+ }
215
+ }
216
+
217
+ async performWorkspaceActivation({ cwd, activationId }) {
218
+ if (activationId !== this.activationSequence) {
219
+ return this.getStatus();
220
+ }
221
+
222
+ if (this.currentCwd === cwd && this.codex) {
223
+ return this.getStatus();
224
+ }
225
+
226
+ await this.shutdownCodex();
227
+ this.currentCwd = cwd;
228
+ writeDaemonRuntimeState({ lastActiveCwd: cwd });
229
+ this.codexHandshakeState = this.config.codexEndpoint ? "warm" : "cold";
230
+ this.forwardedInitializeRequestIds.clear();
231
+
232
+ const codex = createCodexTransport({
233
+ endpoint: this.config.codexEndpoint,
234
+ env: process.env,
235
+ cwd,
236
+ });
237
+ this.codex = codex;
238
+
239
+ codex.onError((error) => {
240
+ if (this.codex !== codex) {
241
+ return;
242
+ }
243
+ if (this.config.codexEndpoint) {
244
+ console.error(`[androdex] Failed to connect to Codex endpoint: ${this.config.codexEndpoint}`);
245
+ } else {
246
+ console.error("[androdex] Failed to start `codex app-server` for the active workspace.");
247
+ console.error(`[androdex] Launch command: ${codex.describe()}`);
248
+ }
249
+ console.error(error.message);
250
+ this.codex = null;
251
+ this.codexHandshakeState = "cold";
252
+ });
253
+
254
+ codex.onMessage((message) => {
255
+ if (this.codex !== codex) {
256
+ return;
257
+ }
258
+ if (this.handleSyntheticInitializeMessage(message)) {
259
+ return;
260
+ }
261
+ this.trackCodexHandshakeState(message);
262
+ this.desktopRefresher.handleOutbound(message);
263
+ this.rememberThreadFromMessage("codex", message);
264
+ this.secureTransport.queueOutboundApplicationMessage(message, (wireMessage) => {
265
+ if (this.socket?.readyState === WebSocket.OPEN) {
266
+ this.socket.send(wireMessage);
267
+ }
268
+ });
269
+ });
270
+
271
+ codex.onClose(() => {
272
+ if (this.codex !== codex) {
273
+ return;
274
+ }
275
+ this.desktopRefresher.handleTransportReset();
276
+ this.codex = null;
277
+ this.codexHandshakeState = "cold";
278
+ });
279
+
280
+ this.primeCodexHandshake();
281
+ return this.getStatus();
282
+ }
283
+
284
+ handleApplicationMessage(rawMessage) {
285
+ if (this.handleBridgeManagedHandshakeMessage(rawMessage)) {
286
+ return;
287
+ }
288
+ if (handleWorkspaceRequest(rawMessage, this.sendApplicationResponse.bind(this))) {
289
+ return;
290
+ }
291
+ if (handleGitRequest(rawMessage, this.sendApplicationResponse.bind(this))) {
292
+ return;
293
+ }
294
+
295
+ if (!this.codex) {
296
+ this.respondWorkspaceNotActive(rawMessage);
297
+ return;
298
+ }
299
+
300
+ this.desktopRefresher.handleInbound(rawMessage);
301
+ this.rememberThreadFromMessage("phone", rawMessage);
302
+ this.codex.send(rawMessage);
303
+ }
304
+
305
+ sendApplicationResponse(rawMessage) {
306
+ this.secureTransport.queueOutboundApplicationMessage(rawMessage, (wireMessage) => {
307
+ if (this.socket?.readyState === WebSocket.OPEN) {
308
+ this.socket.send(wireMessage);
309
+ }
310
+ });
311
+ }
312
+
313
+ rememberThreadFromMessage(source, rawMessage) {
314
+ const threadId = extractThreadId(rawMessage);
315
+ if (!threadId) {
316
+ return;
317
+ }
318
+ rememberActiveThread(threadId, source);
319
+ }
320
+
321
+ handleBridgeManagedHandshakeMessage(rawMessage) {
322
+ const parsed = safeParseJSON(rawMessage);
323
+ if (!parsed) {
324
+ return false;
325
+ }
326
+
327
+ const method = typeof parsed?.method === "string" ? parsed.method.trim() : "";
328
+ if (!method) {
329
+ return false;
330
+ }
331
+
332
+ if (method === "initialize" && parsed.id != null) {
333
+ this.cacheBridgeInitialize(parsed);
334
+ if (!this.codex) {
335
+ this.sendApplicationResponse(JSON.stringify({
336
+ id: parsed.id,
337
+ result: {
338
+ bridgeManaged: true,
339
+ workspaceActive: false,
340
+ },
341
+ }));
342
+ return true;
343
+ }
344
+
345
+ if (this.codexHandshakeState !== "warm") {
346
+ this.forwardedInitializeRequestIds.add(String(parsed.id));
347
+ return false;
348
+ }
349
+
350
+ this.sendApplicationResponse(JSON.stringify({
351
+ id: parsed.id,
352
+ result: {
353
+ bridgeManaged: true,
354
+ workspaceActive: true,
355
+ },
356
+ }));
357
+ return true;
358
+ }
359
+
360
+ if (method === "initialized") {
361
+ this.cachedInitializedNotification = true;
362
+ return this.codexHandshakeState === "warm" || !this.codex;
363
+ }
364
+
365
+ return false;
366
+ }
367
+
368
+ respondWorkspaceNotActive(rawMessage) {
369
+ const parsed = safeParseJSON(rawMessage);
370
+ if (!parsed || parsed.id == null) {
371
+ return;
372
+ }
373
+
374
+ this.sendApplicationResponse(JSON.stringify({
375
+ id: parsed.id,
376
+ error: {
377
+ code: -32000,
378
+ message: "No active workspace on the host. Run `androdex up` in the project you want to use.",
379
+ },
380
+ }));
381
+ }
382
+
383
+ trackCodexHandshakeState(rawMessage) {
384
+ const parsed = safeParseJSON(rawMessage);
385
+ const responseId = parsed?.id;
386
+ if (responseId == null) {
387
+ return;
388
+ }
389
+
390
+ const responseKey = String(responseId);
391
+ if (!this.forwardedInitializeRequestIds.has(responseKey)) {
392
+ return;
393
+ }
394
+
395
+ this.forwardedInitializeRequestIds.delete(responseKey);
396
+ if (parsed?.result != null) {
397
+ this.codexHandshakeState = "warm";
398
+ return;
399
+ }
400
+
401
+ const errorMessage = typeof parsed?.error?.message === "string"
402
+ ? parsed.error.message.toLowerCase()
403
+ : "";
404
+ if (errorMessage.includes("already initialized")) {
405
+ this.codexHandshakeState = "warm";
406
+ }
407
+ }
408
+
409
+ cacheBridgeInitialize(parsedMessage) {
410
+ const params = parsedMessage?.params && typeof parsedMessage.params === "object"
411
+ ? parsedMessage.params
412
+ : {};
413
+ this.cachedInitializeParams = params;
414
+ const clientInfo = params?.clientInfo && typeof params.clientInfo === "object"
415
+ ? params.clientInfo
416
+ : null;
417
+ this.cachedLegacyInitializeParams = clientInfo ? { clientInfo } : null;
418
+ }
419
+
420
+ primeCodexHandshake() {
421
+ if (!this.codex || this.codexHandshakeState === "warm" || !this.cachedInitializeParams) {
422
+ return;
423
+ }
424
+ this.sendSyntheticInitialize(this.cachedInitializeParams, false);
425
+ }
426
+
427
+ sendSyntheticInitialize(params, usingLegacyParams) {
428
+ if (!this.codex) {
429
+ return;
430
+ }
431
+ const requestId = `androdex-initialize-${++this.syntheticInitializeCounter}`;
432
+ this.syntheticInitializeRequest = {
433
+ id: requestId,
434
+ usingLegacyParams,
435
+ };
436
+ this.codex.send(JSON.stringify({
437
+ id: requestId,
438
+ method: "initialize",
439
+ params,
440
+ }));
441
+ }
442
+
443
+ handleSyntheticInitializeMessage(rawMessage) {
444
+ const pendingRequest = this.syntheticInitializeRequest;
445
+ if (!pendingRequest) {
446
+ return false;
447
+ }
448
+
449
+ const parsed = safeParseJSON(rawMessage);
450
+ if (!parsed || parsed.id !== pendingRequest.id) {
451
+ return false;
452
+ }
453
+
454
+ this.syntheticInitializeRequest = null;
455
+ if (parsed?.result != null || isAlreadyInitializedError(parsed?.error?.message)) {
456
+ this.codexHandshakeState = "warm";
457
+ if (this.cachedInitializedNotification && this.codex) {
458
+ this.codex.send(JSON.stringify({ method: "initialized" }));
459
+ }
460
+ return true;
461
+ }
462
+
463
+ if (
464
+ !pendingRequest.usingLegacyParams
465
+ && this.cachedLegacyInitializeParams
466
+ && isCapabilitiesMismatchError(parsed?.error?.message)
467
+ ) {
468
+ this.sendSyntheticInitialize(this.cachedLegacyInitializeParams, true);
469
+ return true;
470
+ }
471
+
472
+ const errorMessage = parsed?.error?.message;
473
+ if (typeof errorMessage === "string" && errorMessage.trim()) {
474
+ console.error(`[androdex] Failed to initialize the active Codex workspace: ${errorMessage}`);
475
+ }
476
+ return true;
477
+ }
478
+
479
+ clearCachedBridgeHandshakeState() {
480
+ this.forwardedInitializeRequestIds.clear();
481
+ this.cachedInitializeParams = null;
482
+ this.cachedLegacyInitializeParams = null;
483
+ this.cachedInitializedNotification = false;
484
+ this.syntheticInitializeRequest = null;
485
+ }
486
+ }
487
+
488
+ function extractThreadId(rawMessage) {
489
+ const parsed = safeParseJSON(rawMessage);
490
+ if (!parsed) {
491
+ return null;
492
+ }
493
+
494
+ const method = parsed?.method;
495
+ const params = parsed?.params;
496
+ if (method === "turn/start") {
497
+ return readString(params?.threadId) || readString(params?.thread_id);
498
+ }
499
+ if (method === "thread/start" || method === "thread/started") {
500
+ return (
501
+ readString(params?.threadId)
502
+ || readString(params?.thread_id)
503
+ || readString(params?.thread?.id)
504
+ || readString(params?.thread?.threadId)
505
+ || readString(params?.thread?.thread_id)
506
+ );
507
+ }
508
+ if (method === "turn/completed") {
509
+ return (
510
+ readString(params?.threadId)
511
+ || readString(params?.thread_id)
512
+ || readString(params?.turn?.threadId)
513
+ || readString(params?.turn?.thread_id)
514
+ );
515
+ }
516
+ return null;
517
+ }
518
+
519
+ function safeParseJSON(value) {
520
+ try {
521
+ return JSON.parse(value);
522
+ } catch {
523
+ return null;
524
+ }
525
+ }
526
+
527
+ function readString(value) {
528
+ return typeof value === "string" && value ? value : null;
529
+ }
530
+
531
+ function normalizeNonEmptyString(value) {
532
+ return typeof value === "string" ? value.trim() : "";
533
+ }
534
+
535
+ function isAlreadyInitializedError(message) {
536
+ return normalizeNonEmptyString(message).toLowerCase().includes("already initialized");
537
+ }
538
+
539
+ function isCapabilitiesMismatchError(message) {
540
+ const normalized = normalizeNonEmptyString(message).toLowerCase();
541
+ return normalized.includes("capabilities") || normalized.includes("experimentalapi");
542
+ }
543
+
544
+ function isExistingDirectory(targetPath) {
545
+ try {
546
+ return fs.statSync(targetPath).isDirectory();
547
+ } catch {
548
+ return false;
549
+ }
550
+ }
551
+
552
+ module.exports = {
553
+ HostRuntime,
554
+ };
package/src/index.js ADDED
@@ -0,0 +1,38 @@
1
+ // FILE: index.js
2
+ // Purpose: Small entrypoint wrapper for the daemon-backed bridge runtime.
3
+ // Layer: CLI entry
4
+ // Exports: daemon-backed CLI actions and thread helpers.
5
+ // Depends on: ./daemon-control, ./daemon-runtime, ./secure-device-state, ./session-state, ./rollout-watch
6
+
7
+ const {
8
+ createPairing,
9
+ getDaemonStatus,
10
+ startBridge,
11
+ startDaemonCli,
12
+ stopDaemonCli,
13
+ } = require("./daemon-control");
14
+ const { runDaemonProcess } = require("./daemon-runtime");
15
+ const { resetBridgeDeviceState } = require("./secure-device-state");
16
+ const { openLastActiveThread } = require("./session-state");
17
+ const { watchThreadRollout } = require("./rollout-watch");
18
+
19
+ async function resetBridgePairing() {
20
+ try {
21
+ await stopDaemonCli();
22
+ } catch {
23
+ // Reset should still proceed if the daemon is already down or stale.
24
+ }
25
+ return resetBridgeDeviceState();
26
+ }
27
+
28
+ module.exports = {
29
+ createPairing,
30
+ getDaemonStatus,
31
+ openLastActiveThread,
32
+ resetBridgePairing,
33
+ runDaemonProcess,
34
+ startBridge,
35
+ startDaemonCli,
36
+ stopDaemonCli,
37
+ watchThreadRollout,
38
+ };
package/src/qr.js ADDED
@@ -0,0 +1,26 @@
1
+ // FILE: qr.js
2
+ // Purpose: Prints the pairing QR payload that the mobile clients expect.
3
+ // Layer: CLI helper
4
+ // Exports: printQR
5
+ // Depends on: qrcode-terminal
6
+
7
+ const qrcode = require("qrcode-terminal");
8
+
9
+ function printQR(pairingPayload) {
10
+ const payload = JSON.stringify(pairingPayload);
11
+ const pairingTarget = pairingPayload.hostId || pairingPayload.sessionId || "unknown";
12
+
13
+ console.log("\nScan this QR with the mobile client:\n");
14
+ qrcode.generate(payload, { small: true });
15
+ console.log(`\nHost ID: ${pairingTarget}`);
16
+ console.log(`Relay: ${pairingPayload.relay}`);
17
+ console.log(`Device ID: ${pairingPayload.macDeviceId}`);
18
+ if (pairingPayload.bootstrapToken) {
19
+ console.log(`Bootstrap Token: ${String(pairingPayload.bootstrapToken).slice(0, 8)}...`);
20
+ }
21
+ console.log(`Expires: ${new Date(pairingPayload.expiresAt).toISOString()}\n`);
22
+ console.log("Pairing payload (copy into the Android app if you are not scanning):");
23
+ console.log(`${payload}\n`);
24
+ }
25
+
26
+ module.exports = { printQR };