cli-setu 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/config.js +12 -0
- package/src/crypto.js +104 -0
- package/src/pair.js +22 -3
- package/src/supervisor.js +71 -13
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# cli-setu
|
|
2
2
|
|
|
3
|
-
Bridge your terminal to your phone. Run Claude Code, Codex, or any plain shell on your laptop, and approve prompts, type, and watch the live output from the [
|
|
3
|
+
Bridge your terminal to your phone. Run Claude Code, Codex, or any plain shell on your laptop, and approve prompts, type, and watch the live output from the [cli-setu](https://clisetu.com) app on your phone.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -16,7 +16,7 @@ cli-setu --cli claude # same, but runs Claude Code inside the bridged session
|
|
|
16
16
|
cli-setu --cli codex # or Codex
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
The first time you run it, `cli-setu` prints a QR code (and an 8-digit fallback code) - scan it with the
|
|
19
|
+
The first time you run it, `cli-setu` prints a QR code (and an 8-digit fallback code) - scan it with the cli-setu app to pair this computer and start watching the session live.
|
|
20
20
|
|
|
21
21
|
Once paired, running `cli-setu` in any folder just works - no QR code needed again, unless this is a device you haven't paired before.
|
|
22
22
|
|
|
@@ -33,7 +33,7 @@ cli-setu --help # show all commands
|
|
|
33
33
|
|
|
34
34
|
## How it works
|
|
35
35
|
|
|
36
|
-
`cli-setu` spawns your real terminal (or Claude Code/Codex) inside a PTY, same as any terminal emulator. Every byte it produces streams live to the
|
|
36
|
+
`cli-setu` spawns your real terminal (or Claude Code/Codex) inside a PTY, same as any terminal emulator. Every byte it produces streams live to the cli-setu app over a secure relay; anything you type on the phone writes back into that same PTY, exactly like a local keystroke. Permission prompts (file edits, shell commands, etc.) are detected and relayed as structured approve/reject cards instead of raw text, when the adapter for your CLI supports it.
|
|
37
37
|
|
|
38
38
|
## License
|
|
39
39
|
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -44,6 +44,17 @@ function loadOrCreateConfig() {
|
|
|
44
44
|
return { deviceName: defaultDeviceName(), deviceToken: null };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Section 1 E2E: this device's own X25519 keypair, generated once (first
|
|
48
|
+
// `cli-setu pair`) and reused for the lifetime of this pairing - same
|
|
49
|
+
// lifetime as deviceToken itself, so a re-pair naturally mints a fresh
|
|
50
|
+
// keypair too, same as it mints a fresh token. Private key stored
|
|
51
|
+
// base64-encoded in the same config.json deviceToken already lives in -
|
|
52
|
+
// no new file, no new permissions model; this machine's own user-profile
|
|
53
|
+
// access already protects deviceToken the same way.
|
|
54
|
+
function hasKeypair(config) {
|
|
55
|
+
return !!(config.devicePrivateKey && config.devicePublicKey);
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
module.exports = {
|
|
48
59
|
CONFIG_DIR,
|
|
49
60
|
CONFIG_FILE,
|
|
@@ -51,4 +62,5 @@ module.exports = {
|
|
|
51
62
|
readConfig,
|
|
52
63
|
writeConfig,
|
|
53
64
|
loadOrCreateConfig,
|
|
65
|
+
hasKeypair,
|
|
54
66
|
};
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Section 1 E2E: real end-to-end encryption for terminal.input/output,
|
|
2
|
+
// so the backend relay can forward packets without being able to read
|
|
3
|
+
// them (previously it could - see this file's own commit message / the
|
|
4
|
+
// privacy policy fix this exists to make true instead of false).
|
|
5
|
+
//
|
|
6
|
+
// Design: X25519 ECDH key exchange, one keypair per laptop, generated
|
|
7
|
+
// once at pair time and reused for the life of that pairing (same
|
|
8
|
+
// lifetime as the device token itself - a re-pair generates a fresh
|
|
9
|
+
// keypair, same as it mints a fresh token). The shared secret is
|
|
10
|
+
// derived once per phone public key via HKDF-SHA256 and cached; AES-
|
|
11
|
+
// 256-GCM encrypts each terminal payload with a fresh random IV.
|
|
12
|
+
//
|
|
13
|
+
// The backend only ever sees: this device's PUBLIC key (at pairing) and
|
|
14
|
+
// ciphertext (at relay time) - never the private key, never the derived
|
|
15
|
+
// secret, never plaintext. That's what makes this actually end-to-end,
|
|
16
|
+
// not just "encrypted somewhere."
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
|
|
19
|
+
// Node's 'x25519' curve via the generic Diffie-Hellman API - available
|
|
20
|
+
// since Node 13, well within this project's >=18 engines requirement.
|
|
21
|
+
function generateKeypair() {
|
|
22
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
|
23
|
+
return { publicKey, privateKey };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Node's KeyObject export format for a raw X25519 public key is a DER-
|
|
27
|
+
// wrapped SPKI blob, not the raw 32 bytes - stripping to raw bytes here
|
|
28
|
+
// so the wire format matches what @noble/curves on the app side produces
|
|
29
|
+
// and expects directly (no ASN.1 parsing needed on that end).
|
|
30
|
+
const RAW_PUBLIC_KEY_LEN = 32;
|
|
31
|
+
|
|
32
|
+
function publicKeyToRaw(publicKey) {
|
|
33
|
+
const der = publicKey.export({ type: 'spki', format: 'der' });
|
|
34
|
+
return der.subarray(der.length - RAW_PUBLIC_KEY_LEN);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function publicKeyFromRaw(rawBytes) {
|
|
38
|
+
// SPKI header for X25519 is fixed (12 bytes) - prepending it lets
|
|
39
|
+
// Node's crypto module import a raw 32-byte public key the same way
|
|
40
|
+
// it exports one above.
|
|
41
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
|
|
42
|
+
const der = Buffer.concat([SPKI_PREFIX, rawBytes]);
|
|
43
|
+
return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function publicKeyToBase64(publicKey) {
|
|
47
|
+
return publicKeyToRaw(publicKey).toString('base64');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Private key storage shape: Node's native PKCS8 DER export, base64-
|
|
51
|
+
// encoded - config.js stores it this way in config.json alongside
|
|
52
|
+
// deviceToken (same file, same OS-level file permissions already
|
|
53
|
+
// protecting that token, so no new secret-storage mechanism needed).
|
|
54
|
+
function privateKeyToBase64(privateKey) {
|
|
55
|
+
return privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function privateKeyFromBase64(base64) {
|
|
59
|
+
return crypto.createPrivateKey({ key: Buffer.from(base64, 'base64'), format: 'der', type: 'pkcs8' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function deriveSharedSecret(privateKey, peerPublicKeyBase64) {
|
|
63
|
+
const peerPublicKey = publicKeyFromRaw(Buffer.from(peerPublicKeyBase64, 'base64'));
|
|
64
|
+
const rawSecret = crypto.diffieHellman({ privateKey, publicKey: peerPublicKey });
|
|
65
|
+
// HKDF-SHA256, no salt (both sides derive deterministically from the
|
|
66
|
+
// same ECDH output - a salt would need its own exchange for no real
|
|
67
|
+
// benefit here), fixed info string scoping this key to this exact
|
|
68
|
+
// purpose so it can never collide with a key derived elsewhere for a
|
|
69
|
+
// different purpose from the same raw ECDH secret.
|
|
70
|
+
return Buffer.from(crypto.hkdfSync('sha256', rawSecret, Buffer.alloc(0), 'cli-setu-terminal-v1', 32));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// AES-256-GCM: a fresh random 12-byte IV per message (GCM's own
|
|
74
|
+
// requirement - reusing an IV with the same key breaks its security
|
|
75
|
+
// guarantees entirely), the 16-byte auth tag appended, both prepended/
|
|
76
|
+
// appended to the ciphertext so the wire format is one self-contained
|
|
77
|
+
// base64 string: IV (12) || ciphertext || authTag (16).
|
|
78
|
+
function encrypt(sharedSecret, plaintext) {
|
|
79
|
+
const iv = crypto.randomBytes(12);
|
|
80
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', sharedSecret, iv);
|
|
81
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
82
|
+
const authTag = cipher.getAuthTag();
|
|
83
|
+
return Buffer.concat([iv, ciphertext, authTag]).toString('base64');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function decrypt(sharedSecret, payloadBase64) {
|
|
87
|
+
const buf = Buffer.from(payloadBase64, 'base64');
|
|
88
|
+
const iv = buf.subarray(0, 12);
|
|
89
|
+
const authTag = buf.subarray(buf.length - 16);
|
|
90
|
+
const ciphertext = buf.subarray(12, buf.length - 16);
|
|
91
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', sharedSecret, iv);
|
|
92
|
+
decipher.setAuthTag(authTag);
|
|
93
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
generateKeypair,
|
|
98
|
+
publicKeyToBase64,
|
|
99
|
+
privateKeyToBase64,
|
|
100
|
+
privateKeyFromBase64,
|
|
101
|
+
deriveSharedSecret,
|
|
102
|
+
encrypt,
|
|
103
|
+
decrypt,
|
|
104
|
+
};
|
package/src/pair.js
CHANGED
|
@@ -5,11 +5,25 @@
|
|
|
5
5
|
// of a hand-typed argv, and the result is saved to ~/.cli-setu/config.json
|
|
6
6
|
// instead of a per-device-name file in the install folder.
|
|
7
7
|
const qrcode = require('qrcode-terminal');
|
|
8
|
-
const { loadOrCreateConfig, writeConfig } = require('./config');
|
|
8
|
+
const { loadOrCreateConfig, writeConfig, hasKeypair } = require('./config');
|
|
9
|
+
const { generateKeypair, publicKeyToBase64, privateKeyToBase64 } = require('./crypto');
|
|
9
10
|
|
|
10
11
|
async function pair(authServer, authServerPublic) {
|
|
11
12
|
const config = loadOrCreateConfig();
|
|
12
13
|
|
|
14
|
+
// Section 1 E2E: generate this device's own keypair up front, before
|
|
15
|
+
// either pairing path below, so both qr-start and pair/start can send
|
|
16
|
+
// the SAME public key no matter which path the user actually takes
|
|
17
|
+
// (scan vs typed code - both start by calling this function). A
|
|
18
|
+
// re-pair (config.deviceToken was cleared just below, or this is a
|
|
19
|
+
// genuinely fresh config) always gets a FRESH keypair - never reuses
|
|
20
|
+
// a stale one left over from a previous, now-dead pairing.
|
|
21
|
+
if (!hasKeypair(config)) {
|
|
22
|
+
const { privateKey, publicKey } = generateKeypair();
|
|
23
|
+
config.devicePrivateKey = privateKeyToBase64(privateKey);
|
|
24
|
+
config.devicePublicKey = publicKeyToBase64(publicKey);
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
if (config.deviceToken) {
|
|
14
28
|
// Real bug found by testing: unpairing from the phone deletes the
|
|
15
29
|
// device server-side, but the laptop's local config.json still has
|
|
@@ -55,7 +69,7 @@ async function pair(authServer, authServerPublic) {
|
|
|
55
69
|
const startRes = await fetch(`${authServer}/device/pair/qr-start`, {
|
|
56
70
|
method: 'POST',
|
|
57
71
|
headers: { 'Content-Type': 'application/json' },
|
|
58
|
-
body: JSON.stringify({ deviceName: config.deviceName, sessionId: 'pairing' }),
|
|
72
|
+
body: JSON.stringify({ deviceName: config.deviceName, sessionId: 'pairing', devicePublicKey: config.devicePublicKey }),
|
|
59
73
|
});
|
|
60
74
|
const { qrToken } = await startRes.json();
|
|
61
75
|
|
|
@@ -69,7 +83,7 @@ async function pair(authServer, authServerPublic) {
|
|
|
69
83
|
const codeRes = await fetch(`${authServer}/device/pair/start`, {
|
|
70
84
|
method: 'POST',
|
|
71
85
|
headers: { 'Content-Type': 'application/json' },
|
|
72
|
-
body: JSON.stringify({ deviceName: config.deviceName }),
|
|
86
|
+
body: JSON.stringify({ deviceName: config.deviceName, devicePublicKey: config.devicePublicKey }),
|
|
73
87
|
});
|
|
74
88
|
const { code: typedCode, pairId: typedPairId } = await codeRes.json();
|
|
75
89
|
console.log(`[cli-setu] Or type this code in the app's Devices tab: ${typedCode}`);
|
|
@@ -84,6 +98,10 @@ async function pair(authServer, authServerPublic) {
|
|
|
84
98
|
if (status.status === 'CONFIRMED') {
|
|
85
99
|
confirmed = true;
|
|
86
100
|
config.deviceToken = status.deviceToken;
|
|
101
|
+
// Section 1 E2E: the phone's public key, so this laptop can derive
|
|
102
|
+
// the shared secret for terminal.input/output - see crypto.js's
|
|
103
|
+
// deriveSharedSecret and supervisor.js's own use of it.
|
|
104
|
+
if (status.phonePublicKey) config.phonePublicKey = status.phonePublicKey;
|
|
87
105
|
writeConfig(config);
|
|
88
106
|
console.log(`[cli-setu] ✓ Paired via QR and saved to ~/.cli-setu/config.json`);
|
|
89
107
|
break;
|
|
@@ -100,6 +118,7 @@ async function pair(authServer, authServerPublic) {
|
|
|
100
118
|
if (typedStatus.status === 'CONFIRMED') {
|
|
101
119
|
confirmed = true;
|
|
102
120
|
config.deviceToken = typedStatus.deviceToken;
|
|
121
|
+
if (typedStatus.phonePublicKey) config.phonePublicKey = typedStatus.phonePublicKey;
|
|
103
122
|
writeConfig(config);
|
|
104
123
|
console.log(`[cli-setu] ✓ Paired via typed code and saved to ~/.cli-setu/config.json`);
|
|
105
124
|
break;
|
package/src/supervisor.js
CHANGED
|
@@ -42,6 +42,7 @@ const execAsync = promisify(exec);
|
|
|
42
42
|
// data, not part of the command syntax itself.
|
|
43
43
|
const execFileAsync = promisify(execFile);
|
|
44
44
|
const { getAdapter } = require('./cli-adapters');
|
|
45
|
+
const setuCrypto = require('./crypto');
|
|
45
46
|
|
|
46
47
|
// Real bug found by testing: this script's own console.log/console.error
|
|
47
48
|
// calls write to the SAME process.stdout the PTY's raw output is echoed
|
|
@@ -166,6 +167,25 @@ let ws = null;
|
|
|
166
167
|
let azureReady = false;
|
|
167
168
|
const pendingPrompts = new Map();
|
|
168
169
|
|
|
170
|
+
// Section 1 E2E: derived once at startup, same lifetime as this process.
|
|
171
|
+
// Null whenever either half is missing - a device paired before this
|
|
172
|
+
// feature existed has devicePrivateKey/phonePublicKey absent from its
|
|
173
|
+
// config.json, and terminal.input/output/snapshot below deliberately
|
|
174
|
+
// fall back to sending PLAINTEXT for exactly that case (see this
|
|
175
|
+
// project's own decision: no forced re-pair, upgrade happens naturally
|
|
176
|
+
// on the next real re-pair). Never silently "half encrypt" - either a
|
|
177
|
+
// real secret is available and used for every payload this session, or
|
|
178
|
+
// none is and nothing here pretends otherwise.
|
|
179
|
+
let sharedSecret = null;
|
|
180
|
+
if (config.devicePrivateKey && config.phonePublicKey) {
|
|
181
|
+
try {
|
|
182
|
+
const privateKey = setuCrypto.privateKeyFromBase64(config.devicePrivateKey);
|
|
183
|
+
sharedSecret = setuCrypto.deriveSharedSecret(privateKey, config.phonePublicKey);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error('[cli-setu] Could not derive E2E encryption key (falling back to unencrypted relay):', err.message);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
169
189
|
// Phase 6, Section 7: a rolling raw-byte scrollback so a phone that opens
|
|
170
190
|
// terminal.html AFTER the session has been running for a while gets caught
|
|
171
191
|
// up instantly via session.snapshot, instead of an empty screen. Capped so
|
|
@@ -255,6 +275,12 @@ async function connectToAzure() {
|
|
|
255
275
|
}
|
|
256
276
|
const conn = await res.json();
|
|
257
277
|
deviceId = conn.deviceId;
|
|
278
|
+
// See this function's own call site (main()'s former focusTimer
|
|
279
|
+
// comment) for why this starts HERE and not unconditionally at
|
|
280
|
+
// startup - deviceId must be real before pollFocus's heartbeat is
|
|
281
|
+
// worth sending at all. Guarded so a reconnect (connectToAzure called
|
|
282
|
+
// again after a dropped WebSocket) never stacks a second timer.
|
|
283
|
+
if (!focusTimer) focusTimer = setInterval(pollFocus, 2000);
|
|
258
284
|
log(`[supervisor] Connecting to Azure as device:${deviceId} (owner: ${conn.userId})...`);
|
|
259
285
|
// Diagnostic found by testing: a session not appearing in the phone's
|
|
260
286
|
// Terminal tab had no visible cause from the laptop's own side - this
|
|
@@ -283,7 +309,28 @@ async function connectToAzure() {
|
|
|
283
309
|
// Section 11f Step 2: typed input from the phone, written to the PTY
|
|
284
310
|
// exactly like a local keystroke - this is the "arbitrary remote
|
|
285
311
|
// input" half of the DoD, not just approve/reject.
|
|
286
|
-
|
|
312
|
+
//
|
|
313
|
+
// Section 1 E2E: msg.data is ciphertext whenever sharedSecret exists
|
|
314
|
+
// (the phone encrypts before sending - see TerminalScreen.js's own
|
|
315
|
+
// sendInput). msg.encrypted distinguishes the two wire shapes so a
|
|
316
|
+
// pre-upgrade phone build (no E2E yet) and a pre-upgrade laptop
|
|
317
|
+
// build talking to each other don't try to decrypt plaintext or
|
|
318
|
+
// vice versa - see this file's own sharedSecret comment for why
|
|
319
|
+
// that fallback has to be all-or-nothing, never silent guessing.
|
|
320
|
+
let data = msg.data;
|
|
321
|
+
if (msg.encrypted) {
|
|
322
|
+
if (!sharedSecret) {
|
|
323
|
+
log('[supervisor] Dropped an encrypted terminal.input - this device has no shared secret to decrypt it (re-pair to fix).');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
data = setuCrypto.decrypt(sharedSecret, msg.data);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
log('[supervisor] Failed to decrypt terminal.input, dropping:', err.message);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
ptyProcess.write(data);
|
|
287
334
|
}
|
|
288
335
|
if (msg.type === 'terminal.resize' && msg.sessionId === SESSION_ID) {
|
|
289
336
|
// Real bug found by testing: this used to call ptyProcess.resize()
|
|
@@ -371,14 +418,17 @@ async function sendSnapshot() {
|
|
|
371
418
|
// pre-TUI history too, not just whatever the current alt-screen
|
|
372
419
|
// buffer still happens to show.
|
|
373
420
|
const history = await preservedHistoryText();
|
|
374
|
-
const
|
|
421
|
+
const plainScreen = history ? `${history}\n${scrollback}` : scrollback;
|
|
422
|
+
// Section 1 E2E: see terminal.input's own comment on why this is
|
|
423
|
+
// all-or-nothing, never a silent per-message guess.
|
|
424
|
+
const screen = sharedSecret ? setuCrypto.encrypt(sharedSecret, plainScreen) : plainScreen;
|
|
375
425
|
await fetch(`${AUTH_SERVER}/relay/terminal-snapshot`, {
|
|
376
426
|
method: 'POST',
|
|
377
427
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
|
|
378
428
|
// cols/rows: the real PTY's actual geometry, so a phone attaching
|
|
379
429
|
// can lock its terminal to match instead of guessing its own size -
|
|
380
430
|
// see auth-server.js's matching comment for why this matters.
|
|
381
|
-
body: JSON.stringify({ sessionId: SESSION_ID, screen, cols: PTY_COLS, rows: PTY_ROWS }),
|
|
431
|
+
body: JSON.stringify({ sessionId: SESSION_ID, screen, encrypted: !!sharedSecret, cols: PTY_COLS, rows: PTY_ROWS }),
|
|
382
432
|
});
|
|
383
433
|
} catch (err) {
|
|
384
434
|
log('[supervisor] Failed to send snapshot:', err.message);
|
|
@@ -555,10 +605,13 @@ function flushOutputBuffer() {
|
|
|
555
605
|
// fetch() itself should resolve quickly regardless of Azure's own cost.
|
|
556
606
|
sendInFlight = sendInFlight.then(async () => {
|
|
557
607
|
try {
|
|
608
|
+
// Section 1 E2E: see terminal.input's own comment on why this is
|
|
609
|
+
// all-or-nothing, never a silent per-message guess.
|
|
610
|
+
const data = sharedSecret ? setuCrypto.encrypt(sharedSecret, batch) : batch;
|
|
558
611
|
await fetch(`${AUTH_SERVER}/relay/terminal-output`, {
|
|
559
612
|
method: 'POST',
|
|
560
613
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
|
|
561
|
-
body: JSON.stringify({ sessionId: SESSION_ID, seq: ++outputSeq, data:
|
|
614
|
+
body: JSON.stringify({ sessionId: SESSION_ID, seq: ++outputSeq, data, encrypted: !!sharedSecret }),
|
|
562
615
|
});
|
|
563
616
|
} catch (err) {
|
|
564
617
|
// A dropped terminal.output batch isn't fatal the way a dropped
|
|
@@ -1249,15 +1302,20 @@ async function main() {
|
|
|
1249
1302
|
// ptyProcess.onExit's cleanup still has a valid, harmless no-op target.
|
|
1250
1303
|
|
|
1251
1304
|
pollTimer = setInterval(pollForRequests, POLL_INTERVAL_MS);
|
|
1252
|
-
// Real
|
|
1253
|
-
//
|
|
1254
|
-
// (
|
|
1255
|
-
//
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
|
|
1305
|
+
// Real bug found by testing (PM report: "device shows offline after
|
|
1306
|
+
// connecting"): focusTimer used to start HERE, unconditionally, before
|
|
1307
|
+
// connectToAzure() (called at the bottom of this function, unawaited)
|
|
1308
|
+
// had any chance to resolve its /device/connect call and set the real
|
|
1309
|
+
// deviceId below. pollFocus's own deviceId-aware URL building silently
|
|
1310
|
+
// omits the heartbeat whenever deviceId is still null - harmless for a
|
|
1311
|
+
// couple of ticks on a normal fast connect, but genuinely persistent
|
|
1312
|
+
// (heartbeat omitted for the ENTIRE outage) whenever /device/connect is
|
|
1313
|
+
// stuck in its own 3s retry loop (network hiccup, backend restart) -
|
|
1314
|
+
// every tick silently did nothing, with no visible error, for as long
|
|
1315
|
+
// as that retry loop ran. Moved to start right where deviceId itself
|
|
1316
|
+
// gets set, inside connectToAzure() (see its own comment there) -
|
|
1317
|
+
// "Offline" on the phone now correctly means "genuinely not
|
|
1318
|
+
// connected," never "connected, but the heartbeat hasn't started yet."
|
|
1261
1319
|
// CLI-agnostic - runs for every adapter (Claude Code, Codex, generic),
|
|
1262
1320
|
// since battery is a property of the LAPTOP, not of whatever CLI it
|
|
1263
1321
|
// happens to be running.
|