cli-setu 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.
@@ -0,0 +1,1264 @@
1
+ // QR pairing (unchanged) PLUS Phase 6 full terminal mirroring:
2
+ // - Device never paired before -> the QR both pairs it AND jumps the
3
+ // phone straight to watching THIS session (scan.html + auth-server.js's
4
+ // /device/pair/qr-confirm do both in one round trip).
5
+ // - Device already paired -> same QR mechanism, but auth-server.js skips
6
+ // re-pairing and the scan just means "watch this session on the phone."
7
+ // - EVERY byte the PTY produces is streamed live to Azure whenever a
8
+ // phone has terminal.html open and focused on this session (Section
9
+ // 11f) - not just detected confirmation prompts. Unfocused, nothing
10
+ // extra streams; the confirmation-request path is completely
11
+ // unaffected either way.
12
+ // - Remote input (typed on the phone) and remote resize both write
13
+ // straight into this PTY, same as a local keystroke or a dragged
14
+ // terminal window edge.
15
+ //
16
+ // Run with: node supervisor-qr.js <deviceName> <sessionId> [workingDir] [--cli <name>]
17
+ // e.g. node supervisor-qr.js Laptop-1 session-A
18
+ // node supervisor-qr.js Laptop-1 session-B ./session-b
19
+ // node supervisor-qr.js Laptop-1 session-C ./proj --cli codex
20
+ //
21
+ // --cli defaults to 'claude' (this script's original, only-ever-supported
22
+ // CLI). Any name not in cli-adapters/index.js's KNOWN_ADAPTERS still runs -
23
+ // it just gets the generic PTY-only adapter (mirrored terminal, no
24
+ // structured Confirmations card, no usage tracking) instead of refusing
25
+ // to start. See cli-adapters/README.md for the adapter interface this
26
+ // script now drives instead of hardcoding Claude Code's behavior directly.
27
+
28
+ const pty = require('node-pty');
29
+ const { Terminal: HeadlessTerminal } = require('@xterm/headless');
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const WebSocket = require('ws');
33
+ const { exec, execFile } = require('child_process');
34
+ const { promisify } = require('util');
35
+ const execAsync = promisify(exec);
36
+ // execFile (not exec) for anything that embeds a real filesystem path -
37
+ // exec runs through cmd.exe, string-interpolating a path into a shell
38
+ // command line is a real injection risk if that path ever contained
39
+ // shell metacharacters (an unlikely but not impossible filename the CLI
40
+ // itself created). execFile passes args as a real array, no shell
41
+ // involved, no interpolation - the correct tool whenever a path is
42
+ // data, not part of the command syntax itself.
43
+ const execFileAsync = promisify(execFile);
44
+ const { getAdapter } = require('./cli-adapters');
45
+
46
+ // Real bug found by testing: this script's own console.log/console.error
47
+ // calls write to the SAME process.stdout the PTY's raw output is echoed
48
+ // to (see ptyProcess.onData below). Claude Code's TUI does full-screen
49
+ // ANSI cursor positioning and assumes exclusive control of the terminal's
50
+ // screen buffer - any plain text interleaved into that stream mid-redraw
51
+ // (e.g. the "Azure decision..." log firing while a menu is on screen)
52
+ // corrupts its cursor-position math, visible as duplicated headers,
53
+ // garbled/overlapping lines, and log text bleeding into the TUI's own
54
+ // chrome. Once the PTY is running, ALL supervisor diagnostics go to a log
55
+ // file instead - stdout is reserved exclusively for the PTY passthrough
56
+ // from that point on.
57
+ const os = require('os');
58
+ const crypto = require('crypto');
59
+ const { readConfig, CONFIG_DIR } = require('./config');
60
+
61
+ // Real gap in the proof-of-concept this replaces: every run required
62
+ // typing <deviceName> <sessionId> as argv, and diagnostics went to a log
63
+ // file sitting in the install folder. A real global install (`npm install
64
+ // -g cli-setu`) has no single "install folder" a user would think to look
65
+ // in, and every session's log lived in ~/.cli-setu instead - one file per
66
+ // PAIRED DEVICE is still right (see the original comment this replaces:
67
+ // keeps a Claude Code session and a Codex session from interleaving into
68
+ // one indistinguishable file), just relocated to a real per-user config
69
+ // directory instead of wherever npm happened to install the package.
70
+ let LOG_FILE = path.join(CONFIG_DIR, 'supervisor.log');
71
+ function log(...args) {
72
+ const line = `[${new Date().toISOString()}] ${args.join(' ')}\n`;
73
+ fs.appendFileSync(LOG_FILE, line);
74
+ }
75
+
76
+ // Real config, from pairing (src/pair.js, either via bin/setu.js's own
77
+ // bare-`setu` pairing check or the explicit `setu pair` command) - not
78
+ // argv. bin/setu.js already guarantees this exists before requiring this
79
+ // file for a normal `setu` run; this is a defensive fallback for anyone
80
+ // requiring supervisor.js directly, so a null deviceToken fails with a
81
+ // clear message instead of a confusing crash deep in connectToAzure().
82
+ const config = readConfig();
83
+ if (!config || !config.deviceToken) {
84
+ console.error('[setu] This computer isn\'t paired yet. Run `setu` again, or run `setu pair` directly.');
85
+ process.exit(1);
86
+ }
87
+ const DEVICE_NAME = config.deviceName;
88
+
89
+ const AUTH_SERVER = process.env.CLI_SETU_SERVER || 'https://api.clisetu.com';
90
+
91
+ // --cli still supported, e.g. `setu --cli codex`, for the same reason it
92
+ // existed before: one paired device can run more than one kind of CLI
93
+ // across different sessions/folders. Real product decision (confirmed
94
+ // directly by the PM): bare `setu` with no --cli flag bridges the user's
95
+ // own plain terminal (see cli-adapters/shell.js), not Claude Code by
96
+ // default - the user decides what to run once it's open, same as
97
+ // getAdapter's own default (index.js) - kept in sync here rather than
98
+ // letting this default silently drift from that one.
99
+ const cliFlagIndex = process.argv.indexOf('--cli');
100
+ const CLI_NAME = cliFlagIndex !== -1 ? process.argv[cliFlagIndex + 1] : 'shell';
101
+
102
+ const WORKING_DIR = process.cwd();
103
+ // Real gap in the PoC this replaces: sessionId was a hand-typed argv, a
104
+ // real user shouldn't need to invent one. Derived from the device name
105
+ // plus the real working directory (not just the folder's basename, which
106
+ // collides the moment two different projects share a folder name like
107
+ // "app" on the same machine) plus a short random suffix so re-running
108
+ // `setu` in the same folder twice at once (e.g. two terminal tabs) never
109
+ // silently shares one session.
110
+ const dirHash = crypto.createHash('sha1').update(WORKING_DIR).digest('hex').slice(0, 8);
111
+ const SESSION_ID = `${os.hostname()}-${dirHash}-${crypto.randomBytes(3).toString('hex')}`;
112
+
113
+ // Per-device log file (see LOG_FILE's declaration comment above) - keeps
114
+ // two supervisors running under the same paired device (e.g. a Claude
115
+ // Code session and a Codex session) from interleaving into one
116
+ // indistinguishable file.
117
+ LOG_FILE = path.join(CONFIG_DIR, `supervisor.${DEVICE_NAME.replace(/[^a-zA-Z0-9-]/g, '_')}.log`);
118
+
119
+ const adapter = getAdapter(CLI_NAME);
120
+ console.log(`[supervisor] Using CLI adapter: ${adapter.displayName} (${adapter.id})`);
121
+ log(`Using CLI adapter: ${adapter.displayName} (${adapter.id})`);
122
+
123
+ // Real bug found by testing: when the spawned adapter is 'shell' (the
124
+ // bare-`setu` default - see cli-adapters/shell.js), the user is expected
125
+ // to freely run `claude` INSIDE that bridged shell whenever they want to.
126
+ // Gating hook registration on "is the spawn-time adapter Claude" (as this
127
+ // used to) meant Claude Code's hooks never got installed into
128
+ // WORKING_DIR's .claude/settings.json at all in that case, so no
129
+ // confirmation ever reached the phone once the user actually typed
130
+ // `claude` - the hook-queue mechanism had nothing writing to it. Fixed by
131
+ // always using Claude's own approvals object (queueFileFor,
132
+ // describePrompt, parseLiveOptions, etc.) for the hook-queue path,
133
+ // regardless of which adapter was spawned - the queue file is only ever
134
+ // written by Claude Code's own hooks in the first place, so there's
135
+ // nothing wrong with always registering them defensively. Harmless if the
136
+ // user never runs `claude` in this session: the hook stays installed but
137
+ // silent, since nothing ever calls it.
138
+ const claudeAdapter = require('./cli-adapters/claude');
139
+ const QUEUE_FILE = claudeAdapter.approvals.queueFileFor(CONFIG_DIR, WORKING_DIR);
140
+ const PROCESSED_FILE = path.join(CONFIG_DIR, `processed-requests.${SESSION_ID}.json`);
141
+ const POLL_INTERVAL_MS = 300;
142
+
143
+ // Always registered now (see QUEUE_FILE's comment above) - covers both
144
+ // `setu --cli claude` (adapter itself is Claude) and bare `setu` /
145
+ // `setu --cli shell` followed by the user manually running `claude`
146
+ // inside the bridged shell.
147
+ {
148
+ const { ensureHooksRegistered } = require('./hooks');
149
+ ensureHooksRegistered(WORKING_DIR);
150
+ }
151
+
152
+ // Real config, from `setu pair` (src/config.js) - not a per-device-name
153
+ // token file. Pairing is now a separate, one-time command (`setu pair`)
154
+ // that must already have succeeded before this ever runs (checked above,
155
+ // where the process exits early if config.deviceToken is missing) - so
156
+ // unlike the PoC's ensurePaired(), there is no QR-pairing fallback here at
157
+ // all. A session watcher's job is to watch a session, not to also be the
158
+ // pairing flow; keeping them separate is what makes `setu` (bare) not
159
+ // need a QR code/camera every single run.
160
+ let deviceToken = config.deviceToken;
161
+ let deviceId = null;
162
+ let ws = null;
163
+ let azureReady = false;
164
+ const pendingPrompts = new Map();
165
+
166
+ // Phase 6, Section 7: a rolling raw-byte scrollback so a phone that opens
167
+ // terminal.html AFTER the session has been running for a while gets caught
168
+ // up instantly via session.snapshot, instead of an empty screen. Capped so
169
+ // this can't grow unbounded on a long session.
170
+ const SCROLLBACK_MAX_BYTES = 200_000;
171
+ let scrollback = '';
172
+ function appendScrollback(chunk) {
173
+ scrollback += chunk;
174
+ if (scrollback.length > SCROLLBACK_MAX_BYTES) {
175
+ scrollback = scrollback.slice(scrollback.length - SCROLLBACK_MAX_BYTES);
176
+ }
177
+ }
178
+
179
+ let outputSeq = 0;
180
+
181
+ // Real gap found by testing: this used to also track isFocused, gating
182
+ // whether ptyProcess.onData's bytes were actually streamed live - removed
183
+ // (see that call site's own comment) since it could silently drop real
184
+ // conversation history from what's supposed to be a complete mirror.
185
+ // Still polled regardless, though: deviceId doubles as a lightweight
186
+ // heartbeat (see auth-server.js's /terminal/is-focused) - this call
187
+ // already happens every 3s, so it's the natural place for the Devices
188
+ // tab's "is this laptop actually alive right now" signal, and for
189
+ // /live-sessions' per-session online status, instead of adding a second
190
+ // poller just for those.
191
+ async function pollFocus() {
192
+ try {
193
+ await fetch(
194
+ `${AUTH_SERVER}/terminal/is-focused?sessionId=${encodeURIComponent(SESSION_ID)}${deviceId ? `&deviceId=${encodeURIComponent(deviceId)}` : ''}`
195
+ );
196
+ } catch {
197
+ // Network hiccup - next poll (3s later) tries again; no local state
198
+ // depends on this succeeding anymore.
199
+ }
200
+ }
201
+
202
+ async function connectToAzure() {
203
+ // cliType lets Devices/Profile show which CLI this paired laptop is
204
+ // actually running (see auth-server.js's /device/connect handler and
205
+ // db.js's devices.cli_type column) - sent on every connect (not just
206
+ // pairing) since the SAME device row can legitimately run a different
207
+ // CLI across restarts (e.g. re-launched with --cli codex next time).
208
+ const res = await fetch(`${AUTH_SERVER}/device/connect`, {
209
+ method: 'POST',
210
+ headers: { Authorization: `Bearer ${deviceToken}`, 'Content-Type': 'application/json' },
211
+ // sessionId here is what lets auth-server.js broadcast session.started
212
+ // immediately - see its own comment on why a session shouldn't have
213
+ // to wait for its first approval prompt to become visible on the
214
+ // phone. folderName (new) is the real product ask: the phone's own
215
+ // session lists only ever showed the raw sessionId (a hostname+hash+
216
+ // random string, meaningless to a real user) - just the WORKING_DIR's
217
+ // own folder name (not the full path - shorter, and doesn't expose
218
+ // this laptop's real directory structure to the phone/backend) lets
219
+ // someone actually tell which real project a session belongs to.
220
+ body: JSON.stringify({ cliType: adapter.id, sessionId: SESSION_ID, folderName: path.basename(WORKING_DIR) }),
221
+ });
222
+ if (!res.ok) {
223
+ const err = await res.json().catch(() => ({}));
224
+ // A 403 here means a real, durable plan-limit rejection (see
225
+ // auth-server.js's live-session-limit check) - retrying every 3s
226
+ // forever would just spam the same rejection, since nothing about
227
+ // THIS session changes by waiting (the fix is stopping an existing
228
+ // live session elsewhere, or upgrading, not a transient retry).
229
+ // Every other failure (network hiccup, a momentarily-down server)
230
+ // keeps the original retry behavior.
231
+ if (res.status === 403) {
232
+ console.error(`\n[supervisor] Connection rejected: ${err.error || 'the server declined this session.'}`);
233
+ log(`[supervisor] /device/connect rejected (403): ${err.error || 'no reason given'}`);
234
+ process.exit(1);
235
+ }
236
+ log(`[supervisor] /device/connect failed: ${err.error || res.status}`);
237
+ setTimeout(connectToAzure, 3000);
238
+ return;
239
+ }
240
+ const conn = await res.json();
241
+ deviceId = conn.deviceId;
242
+ log(`[supervisor] Connecting to Azure as device:${deviceId} (owner: ${conn.userId})...`);
243
+
244
+ ws = new WebSocket(conn.wssUrl);
245
+
246
+ ws.on('open', () => {
247
+ azureReady = true;
248
+ log('[supervisor] Connected to Azure. Ready to forward permission requests.');
249
+ });
250
+
251
+ ws.on('message', (raw) => {
252
+ let msg;
253
+ try {
254
+ msg = JSON.parse(raw.toString());
255
+ } catch {
256
+ return;
257
+ }
258
+ if (msg.type === 'confirmation.response') handleAzureDecision(msg);
259
+ if (msg.type === 'terminal.input' && msg.sessionId === SESSION_ID) {
260
+ // Section 11f Step 2: typed input from the phone, written to the PTY
261
+ // exactly like a local keystroke - this is the "arbitrary remote
262
+ // input" half of the DoD, not just approve/reject.
263
+ ptyProcess.write(msg.data);
264
+ }
265
+ if (msg.type === 'terminal.resize' && msg.sessionId === SESSION_ID) {
266
+ // Real bug found by testing: this used to call ptyProcess.resize()
267
+ // with the PHONE's dimensions, reflowing the one real, SHARED PTY to
268
+ // whatever narrow width the phone's screen happened to be - since a
269
+ // PTY has exactly one size, that visibly shrank and re-wrapped the
270
+ // laptop's own terminal the moment a phone connected/attached,
271
+ // corrupting the laptop-side view too, not just a phone-side
272
+ // cosmetic issue. Ignoring the phone's resize entirely is the
273
+ // correct fix: the laptop's real terminal keeps whatever size the
274
+ // person actually sitting at it set, and the phone's own
275
+ // xterm.js/WebView is responsible for wrapping/scrolling whatever
276
+ // width of output it receives to fit its own screen - the same
277
+ // model every real remote-terminal viewer (Termius, Blink) uses.
278
+ log(`[supervisor] Ignoring phone resize request (${msg.cols}x${msg.rows}) - the real PTY keeps its own size.`);
279
+ }
280
+ if (msg.type === 'session.attach' && msg.sessionId === SESSION_ID) {
281
+ // Section 7: a phone just opened/reopened the terminal view - hand it
282
+ // the scrollback immediately instead of leaving it blank until the
283
+ // next byte happens to be written.
284
+ sendSnapshot();
285
+ }
286
+ if (msg.type === 'git.diff.request' && msg.sessionId === SESSION_ID) {
287
+ // Roadmap #14 - git diff pane. Entirely independent of the running
288
+ // CLI adapter (Claude Code, Codex, generic) - this is a plain git
289
+ // operation against WORKING_DIR, not something any adapter needs to
290
+ // declare. Fire-and-forget from this handler's point of view; the
291
+ // real response goes back over its own relay endpoint (see
292
+ // sendGitDiff below), same pattern as sendSnapshot above.
293
+ sendGitDiff(msg.requestId);
294
+ }
295
+ if (msg.type === 'cross_session.inject' && msg.toSessionId === SESSION_ID) {
296
+ // Roadmap #3 - cross-session messaging. Note the field checked is
297
+ // toSessionId, not sessionId - this message genuinely has two
298
+ // different sessions in it (where it came FROM, where it's going
299
+ // TO), unlike every other message type in this file which only
300
+ // ever concerns THIS session.
301
+ //
302
+ // auth-server.js has ALREADY decided this is safe to inject
303
+ // (either nobody was watching, or the watching phone explicitly
304
+ // tapped Inject on the pending card) - this handler's only job is
305
+ // writing it in with a visible trace, never silently. A bracketed
306
+ // system-style line in the scrollback (not literally typed as if
307
+ // from the keyboard) is what makes an injected message
308
+ // distinguishable after the fact from something typed locally,
309
+ // even hours later when nobody remembers a message arrived.
310
+ const marker = `\r\n\x1b[38;2;155;185;235m[message from ${msg.fromDeviceName || 'another session'} / ${msg.fromSessionId}]\x1b[0m\r\n`;
311
+ ptyProcess.write(marker + msg.text + '\r');
312
+ log(`[supervisor] Injected cross-session message ${msg.messageId} from ${msg.fromSessionId}`);
313
+ }
314
+ if (msg.type === 'usage.refresh.request' && msg.sessionId === SESSION_ID) {
315
+ // Real feedback: the old 5-minute auto-timer (below, now removed)
316
+ // ran /cost in the user's own terminal on a schedule regardless of
317
+ // whether anyone was even looking at their phone - genuinely
318
+ // disruptive if someone's actively working locally and /cost's
319
+ // full-screen usage report suddenly takes over their session. This
320
+ // is the on-demand replacement: same triggerKeystrokes mechanism
321
+ // (still a real, visible command - that constraint hasn't changed,
322
+ // see cli-adapters/claude.js), but only ever fired when a phone
323
+ // explicitly asks for a fresh number, not on a timer nobody
324
+ // requested.
325
+ if (adapter.usage?.triggerKeystrokes && ptyProcess) {
326
+ ptyProcess.write(adapter.usage.triggerKeystrokes);
327
+ usageListenUntil = Date.now() + USAGE_LISTEN_WINDOW_MS;
328
+ log(`[supervisor] On-demand usage refresh requested for ${SESSION_ID}`);
329
+ }
330
+ }
331
+ });
332
+
333
+ ws.on('close', () => {
334
+ azureReady = false;
335
+ log('[supervisor] Azure connection closed. Reconnecting in 2s...');
336
+ setTimeout(connectToAzure, 2000);
337
+ });
338
+
339
+ ws.on('error', (err) => log('[supervisor] Azure socket error:', err.message));
340
+ }
341
+
342
+ async function sendSnapshot() {
343
+ try {
344
+ // preservedHistoryText() (see its own comment) is plain text, from
345
+ // BEFORE the current full-screen TUI took over - prepended ahead of
346
+ // the real, live raw-byte scrollback (still replayed as actual ANSI,
347
+ // unchanged), so a phone scrolling up sees the real startup banner/
348
+ // pre-TUI history too, not just whatever the current alt-screen
349
+ // buffer still happens to show.
350
+ const history = await preservedHistoryText();
351
+ const screen = history ? `${history}\n${scrollback}` : scrollback;
352
+ await fetch(`${AUTH_SERVER}/relay/terminal-snapshot`, {
353
+ method: 'POST',
354
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
355
+ // cols/rows: the real PTY's actual geometry, so a phone attaching
356
+ // can lock its terminal to match instead of guessing its own size -
357
+ // see auth-server.js's matching comment for why this matters.
358
+ body: JSON.stringify({ sessionId: SESSION_ID, screen, cols: PTY_COLS, rows: PTY_ROWS }),
359
+ });
360
+ } catch (err) {
361
+ log('[supervisor] Failed to send snapshot:', err.message);
362
+ }
363
+ }
364
+
365
+ // Roadmap #14 - git diff pane. Entirely CLI-agnostic: this runs plain git
366
+ // commands against WORKING_DIR regardless of which adapter (Claude Code,
367
+ // Codex, generic) is driving the PTY - no adapter interface changes
368
+ // needed, unlike the command palette (#6) which is genuinely adapter-
369
+ // specific. `git diff` alone only shows changes to ALREADY-TRACKED files -
370
+ // a brand-new file Claude/Codex just created shows up in `git status`
371
+ // but produces no `git diff` output at all, which is exactly the case
372
+ // most worth seeing remotely. `git diff --no-index /dev/null <file>`
373
+ // against each untracked file (Windows git still accepts the POSIX
374
+ // /dev/null form here) gets a real unified diff for those too, rather
375
+ // than just a bare filename with no content shown.
376
+ const GIT_TIMEOUT_MS = 15_000;
377
+ // Web PubSub has a real per-message size ceiling - a genuinely huge diff
378
+ // (a big refactor, an accidentally-modified generated/vendored file)
379
+ // could otherwise silently fail to relay at all. Capping per-file and
380
+ // truncating with a visible marker is safer than an unbounded payload -
381
+ // the phone still sees SOMETHING and knows it's incomplete, rather than
382
+ // the whole request quietly failing.
383
+ const MAX_DIFF_CHARS_PER_FILE = 20_000;
384
+
385
+ function capDiff(diffText) {
386
+ if (diffText.length <= MAX_DIFF_CHARS_PER_FILE) return { diff: diffText, truncated: false };
387
+ return {
388
+ diff: diffText.slice(0, MAX_DIFF_CHARS_PER_FILE) + '\n\n... (diff truncated - too large to send in full)',
389
+ truncated: true,
390
+ };
391
+ }
392
+
393
+ async function getGitDiff(cwd) {
394
+ // Not a git repo at all - a real, common, non-error state (the
395
+ // session's working directory might just never have been a repo) -
396
+ // distinguished from a real git failure so the phone can show an
397
+ // honest "not a git repository" message instead of a blank/broken pane.
398
+ try {
399
+ await execAsync('git rev-parse --is-inside-work-tree', { cwd, timeout: GIT_TIMEOUT_MS });
400
+ } catch {
401
+ return { isGitRepo: false, files: [] };
402
+ }
403
+
404
+ const files = [];
405
+
406
+ // --name-only first, then one `git diff` PER FILE - not a single
407
+ // combined `git diff` call. A combined diff mixes every modified
408
+ // file's text into one blob, which would make capDiff's per-file
409
+ // truncation (below) chop arbitrarily mid-file instead of cleanly
410
+ // per-file, and would collapse the phone's ability to show "which
411
+ // specific files changed" as a real list rather than one wall of text.
412
+ const { stdout: nameOnlyOut } = await execAsync('git diff --name-only', { cwd, timeout: GIT_TIMEOUT_MS });
413
+ const modifiedPaths = nameOnlyOut.split('\n').map((l) => l.trim()).filter(Boolean);
414
+
415
+ for (const relPath of modifiedPaths) {
416
+ try {
417
+ const { stdout } = await execFileAsync('git', ['diff', '--', relPath], { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
418
+ if (stdout) files.push({ status: 'modified', path: relPath, ...capDiff(stdout) });
419
+ } catch (err) {
420
+ log(`[supervisor] git diff failed for modified file "${relPath}":`, err.message);
421
+ }
422
+ }
423
+
424
+ // --porcelain's stable, machine-parseable format - '??' is git's own
425
+ // marker for untracked. Path may be quoted (e.g. contains a space) -
426
+ // stripping surrounding quotes is enough for this app's own diff
427
+ // request, not a full porcelain parser.
428
+ const { stdout: statusOut } = await execAsync('git status --porcelain', { cwd, timeout: GIT_TIMEOUT_MS });
429
+ const untrackedPaths = statusOut
430
+ .split('\n')
431
+ .filter((line) => line.startsWith('?? '))
432
+ .map((line) => line.slice(3).trim().replace(/^"|"$/g, ''));
433
+
434
+ for (const relPath of untrackedPaths) {
435
+ try {
436
+ // --no-index compares two paths outside git's own tracking - the
437
+ // ONLY way to get a real content diff for a file git has never
438
+ // seen before. Exits non-zero whenever the files differ (which is
439
+ // always true here, comparing against nothing) - execFileAsync
440
+ // treats that as a rejected promise even though stdout still has
441
+ // the real diff text, so it's read off the caught error instead.
442
+ // execFile, not exec - relPath is a real filesystem path (from git
443
+ // status output, ultimately from whatever the CLI wrote to disk),
444
+ // passed as a real argv entry with no shell involved, not
445
+ // interpolated into a command string.
446
+ await execFileAsync('git', ['diff', '--no-index', '--', '/dev/null', relPath], { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 5 * 1024 * 1024 });
447
+ } catch (err) {
448
+ if (err.stdout) files.push({ status: 'untracked', path: relPath, ...capDiff(err.stdout) });
449
+ }
450
+ }
451
+
452
+ return { isGitRepo: true, files, hasChanges: modifiedPaths.length > 0 || untrackedPaths.length > 0 };
453
+ }
454
+
455
+ async function sendGitDiff(requestId) {
456
+ try {
457
+ const result = await getGitDiff(WORKING_DIR);
458
+ await fetch(`${AUTH_SERVER}/relay/git-diff-response`, {
459
+ method: 'POST',
460
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
461
+ body: JSON.stringify({ sessionId: SESSION_ID, requestId, ...result }),
462
+ });
463
+ } catch (err) {
464
+ log('[supervisor] Failed to compute/send git diff:', err.message);
465
+ // Best-effort error reporting to the phone - without this, a failed
466
+ // diff (e.g. git not on PATH) would leave the phone's request hanging
467
+ // forever with no explanation, rather than a clear error state.
468
+ fetch(`${AUTH_SERVER}/relay/git-diff-response`, {
469
+ method: 'POST',
470
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
471
+ body: JSON.stringify({ sessionId: SESSION_ID, requestId, error: err.message }),
472
+ }).catch(() => {});
473
+ }
474
+ }
475
+
476
+ // Real bug #1, found by testing: ptyProcess.onData fires rapidly and
477
+ // synchronously for every PTY chunk, but each streamOutput() call did its
478
+ // own independent, un-awaited fetch(). Nothing guaranteed those HTTP
479
+ // requests RESOLVED in the order they were SENT - a later chunk's request
480
+ // could finish first under normal network jitter, and terminal.html just
481
+ // wrote whatever arrived with no reordering. Symptom: words/lines rendered
482
+ // interleaved, e.g. "Listingserver.js," instead of two separate lines.
483
+ //
484
+ // First fix was one fetch() per chunk, strictly serialized - correct
485
+ // order, but real bug #2: a single Claude Code response can produce MANY
486
+ // small PTY chunks, and awaiting a full HTTP round trip before sending the
487
+ // next one turned a live stream into a slow drip - observed as a ~2
488
+ // minute lag before anything appeared, for output that should stream in
489
+ // under a second.
490
+ //
491
+ // Actual fix: batch. Chunks accumulate in a buffer; a short timer (or the
492
+ // buffer growing large) triggers ONE combined send instead of one send
493
+ // per chunk - still exactly one in-flight request at a time (order
494
+ // preserved by construction, same as before), but far fewer round trips.
495
+ let outputBuffer = '';
496
+ let flushTimer = null;
497
+ let sendInFlight = Promise.resolve();
498
+ const BATCH_WINDOW_MS = 40;
499
+ const BATCH_MAX_BYTES = 8000; // flush early if a burst gets large, rather than waiting the full window
500
+
501
+ function streamOutput(chunk) {
502
+ outputBuffer += chunk;
503
+ if (outputBuffer.length >= BATCH_MAX_BYTES) {
504
+ scheduleFlush(0);
505
+ } else if (!flushTimer) {
506
+ scheduleFlush(BATCH_WINDOW_MS);
507
+ }
508
+ }
509
+
510
+ function scheduleFlush(delayMs) {
511
+ clearTimeout(flushTimer);
512
+ flushTimer = setTimeout(flushOutputBuffer, delayMs);
513
+ }
514
+
515
+ function flushOutputBuffer() {
516
+ flushTimer = null;
517
+ if (!outputBuffer) return;
518
+
519
+ const batch = outputBuffer;
520
+ outputBuffer = '';
521
+
522
+ // Still serialized against any send already in flight, so batches
523
+ // themselves can never arrive out of order relative to each other.
524
+ sendInFlight = sendInFlight.then(async () => {
525
+ try {
526
+ await fetch(`${AUTH_SERVER}/relay/terminal-output`, {
527
+ method: 'POST',
528
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
529
+ body: JSON.stringify({ sessionId: SESSION_ID, seq: ++outputSeq, data: batch }),
530
+ });
531
+ } catch (err) {
532
+ // A dropped terminal.output batch isn't fatal the way a dropped
533
+ // confirmation would be - the next batch (or a fresh session.attach
534
+ // snapshot) catches the view back up, so just log and move on.
535
+ log('[supervisor] Failed to stream output batch:', err.message);
536
+ }
537
+ });
538
+ }
539
+
540
+ function handleAzureDecision(msg) {
541
+ const pending = pendingPrompts.get(msg.promptId);
542
+ if (!pending) {
543
+ log(`[supervisor] Got a decision for unknown promptId ${msg.promptId} - already handled or unrecognized, ignoring.`);
544
+ return;
545
+ }
546
+
547
+ const chosen = pending.options?.[msg.optionIndex];
548
+ const keystroke = chosen ? chosen.keystroke : '\x1b';
549
+
550
+ // Real bug found by testing: "Type something" (the phone's own
551
+ // keepPending path - see ConfirmationsScreen.js) only opens Claude
552
+ // Code's freeform input, it isn't a real decision - but this used to
553
+ // unconditionally delete the promptId from pendingPrompts regardless of
554
+ // WHICH option was chosen. That meant tapping a REAL option (e.g.
555
+ // "Empty file") on the same still-visible card right after "Type
556
+ // something" found nothing left to act on - the keystroke silently
557
+ // never reached the terminal. Only delete when the actual decision was
558
+ // a real one, so the same card can still be resolved for real
559
+ // afterward.
560
+ if (chosen?.label !== 'Type something') {
561
+ pendingPrompts.delete(msg.promptId);
562
+ }
563
+
564
+ log(`[supervisor] Azure decision for ${msg.promptId}: "${chosen ? chosen.label : 'unknown'}". Injecting keystroke "${keystroke}"...`);
565
+ // Real bug found by testing: Claude Code's inline permission menu
566
+ // (1/2/3, or a bare Esc to reject) selects AND confirms on the single
567
+ // keypress alone - it's a hotkey menu, not a typed line. Appending '\r'
568
+ // unconditionally sent a second, separate Enter right after the
569
+ // selection, which landed in whatever input the UI advanced to next
570
+ // (the freeform chat box) - visible as a stray "1" typed into chat,
571
+ // sometimes racing with the terminal's own answer if you'd already
572
+ // resolved the same prompt locally before the phone's decision arrived.
573
+ ptyProcess.write(keystroke);
574
+ fs.appendFileSync(
575
+ PROCESSED_FILE,
576
+ JSON.stringify({ ...pending, chosenLabel: chosen?.label, injectedAt: new Date().toISOString() }) + '\n'
577
+ );
578
+ }
579
+
580
+ // Real bug found by testing: these used to be hardcoded to 120x30
581
+ // regardless of the user's REAL terminal window size - on any window
582
+ // bigger than that (the normal case for a maximized/large terminal),
583
+ // the PTY was spawned SMALLER than the window showing it, leaving real,
584
+ // visible blank space in the laptop's own terminal below and to the
585
+ // right of the actual PTY content - not a phone-side rendering bug at
586
+ // all, a genuine laptop-side geometry mismatch. process.stdout.columns/
587
+ // rows reflect the REAL terminal's actual current size at the moment
588
+ // `setu` starts (falling back to the old 120x30 only if those aren't
589
+ // available - e.g. output piped to a file, not a real TTY). Still a
590
+ // FIXED size after this point, same reasoning as before (not read back
591
+ // off ptyProcess after spawn, never resized again - see the
592
+ // terminal.resize handler above for why phone-side resize requests are
593
+ // still correctly ignored): just a better, size-aware fixed size instead
594
+ // of an arbitrary guessed one.
595
+ const PTY_COLS = process.stdout.columns || 120;
596
+ const PTY_ROWS = process.stdout.rows || 30;
597
+
598
+ // Real gap found by testing: Claude Code's full-screen TUI does its own
599
+ // absolute-cursor-positioning redraws, which means the raw scrollback
600
+ // STRING above (appendScrollback) is not a reliable transcript - earlier
601
+ // bytes get visually overwritten by later ones at the byte level (the
602
+ // same reason Claude Code's own startup banner disappeared from the
603
+ // phone's scrollback once real conversation started: the banner's bytes
604
+ // were still THERE in the string, but replaying them through a FRESH
605
+ // xterm instance just re-runs the same overwrites, landing on the same
606
+ // final picture). The real fix: @xterm/headless is xterm.js's own
607
+ // official Node.js package - running a real terminal emulator here,
608
+ // fed the IDENTICAL bytes the real PTY produces, maintains an actual,
609
+ // always-correct RENDERED buffer, not just a raw byte log. Critically,
610
+ // it tracks the NORMAL and ALTERNATE screen buffers separately (see
611
+ // node_modules/@xterm/headless/typings - IBuffer.type) - a full-screen
612
+ // TUI like Claude Code switches to the alternate buffer when it starts
613
+ // (standard terminal behavior every full-screen app relies on), which
614
+ // means whatever was in the NORMAL buffer before that (the real startup
615
+ // banner, or any plain shell output before Claude Code launched) is
616
+ // preserved completely untouched underneath, not overwritten - it's
617
+ // just hidden while the alt buffer is active. Reading the normal buffer
618
+ // independently (see preservedHistoryText below) means no "detect the
619
+ // redraw" logic is needed at all.
620
+ // allowProposedApi: true - buffer.normal/buffer.alternate are marked
621
+ // EXPERIMENTAL in xterm.js's own typings and throw at runtime without
622
+ // this explicit opt-in (confirmed directly: "You must set the
623
+ // allowProposedApi option to true to use proposed API").
624
+ const headlessTerm = new HeadlessTerminal({ cols: PTY_COLS, rows: PTY_ROWS, scrollback: SCROLLBACK_MAX_BYTES, allowProposedApi: true });
625
+
626
+ // Real bug found by testing: term.write() is ASYNCHRONOUS - reading the
627
+ // buffer immediately after calling it (no callback, no await) reads
628
+ // STALE/EMPTY content, confirmed directly against a real minimal
629
+ // reproduction. ptyProcess.onData can fire many times per second, so
630
+ // awaiting every single write here would be wasteful - instead, chain
631
+ // each write's completion onto the previous one (same pattern this file
632
+ // already uses for sendInFlight's own output batching), and have
633
+ // anything that needs to READ the buffer (preservedHistoryText, called
634
+ // from sendSnapshot) await this chain first, so it's guaranteed caught
635
+ // up by the time anyone actually reads it - without slowing down the hot
636
+ // onData path at all.
637
+ let headlessWriteChain = Promise.resolve();
638
+ function writeToHeadlessTerm(data) {
639
+ headlessWriteChain = headlessWriteChain.then(
640
+ () => new Promise((resolve) => headlessTerm.write(data, resolve))
641
+ );
642
+ }
643
+ async function awaitHeadlessTermCaughtUp() {
644
+ await headlessWriteChain;
645
+ }
646
+
647
+ // Renders a buffer's lines back to plain text - translateToString(true)
648
+ // trims trailing whitespace per line, same as how a real terminal looks
649
+ // when you select and copy its text.
650
+ function bufferToText(buffer) {
651
+ const lines = [];
652
+ for (let y = 0; y < buffer.length; y++) {
653
+ const line = buffer.getLine(y);
654
+ if (line) lines.push(line.translateToString(true));
655
+ }
656
+ return lines.join('\n');
657
+ }
658
+
659
+ // Real product ask: scrolling up on the phone should show EVERYTHING,
660
+ // including content from before the current alt-screen TUI took over.
661
+ // Deliberately only ever returns the PRESERVED NORMAL buffer (the
662
+ // banner/pre-TUI history) as plain text, to be PREPENDED before the
663
+ // existing raw-byte scrollback (see sendSnapshot) - never a replacement
664
+ // for it. The live/current content still replays as real ANSI bytes
665
+ // through the existing mechanism (term.write() of the raw scrollback
666
+ // string), which correctly handles Claude Code's own absolute cursor
667
+ // positioning; converting that part to plain text too would lose
668
+ // coloring and risk misaligning the cursor position live streaming
669
+ // continues from afterward. When the CLI is still on the plain normal
670
+ // buffer (hasn't switched to an alt screen at all - e.g. a plain shell
671
+ // with no full-screen TUI running), buffer.normal IS buffer.active, so
672
+ // this would just duplicate the live scrollback - returns '' in that
673
+ // case since there's nothing EXTRA to prepend.
674
+ async function preservedHistoryText() {
675
+ // Must wait for every queued write to actually apply first - see
676
+ // writeToHeadlessTerm's own comment on why reading right after a raw
677
+ // write would see stale/empty content.
678
+ await awaitHeadlessTermCaughtUp();
679
+ if (headlessTerm.buffer.active.type === 'normal') return '';
680
+ return bufferToText(headlessTerm.buffer.normal);
681
+ }
682
+
683
+ let processedLines = 0;
684
+ let ptyProcess = null;
685
+ let pollTimer = null;
686
+ let focusTimer = null;
687
+ // No longer an interval (see the on-demand usage.refresh.request handler
688
+ // below) - kept as a variable only so ptyProcess.onExit's cleanup has
689
+ // something harmless to clearInterval() on, in case a future change ever
690
+ // reintroduces a real timer here.
691
+ let usageTimer = null;
692
+ // Only actually parse PTY output for usage data while this is in the
693
+ // future - set a few seconds out each time triggerKeystrokes is sent
694
+ // (see below), cleared back to 0 the moment a match is found or the
695
+ // window lapses. See the onData handler's usage-polling comment for why
696
+ // this window exists at all instead of parsing every chunk forever.
697
+ let usageListenUntil = 0;
698
+ const USAGE_LISTEN_WINDOW_MS = 10_000;
699
+ // Codex-shape usage de-dup (no trigger keystrokes, and no reliable
700
+ // persistent status line to gate a check on - see cli-adapters/codex.js
701
+ // and the onData handler's comment on why every chunk gets checked) -
702
+ // only reports to the Backend when the parsed percentage actually
703
+ // CHANGES, not on every chunk that happens to still show the same
704
+ // still-rendered text.
705
+ let lastReportedCodexContextPct = null;
706
+ // Which onboarding gate (by `reason`) was last auto-answered - null once
707
+ // no gate is currently on-screen. Identity-based, not time-based (see the
708
+ // onData handler's comment for why a timer cooldown re-fired incorrectly
709
+ // in testing) - re-answers only when a DIFFERENT gate appears, or the
710
+ // same one reappears after genuinely clearing.
711
+ let lastOnboardingAnswered = null;
712
+ // De-dupes a Codex approval prompt that's still on-screen (and therefore
713
+ // still matching detectApproval) across many chunks, vs. a genuinely NEW
714
+ // prompt appearing - see cli-adapters/codex.js's detectApproval comment.
715
+ let lastApprovalFingerprint = null;
716
+
717
+ // Best-effort - a dropped usage report just means Profile/Devices show a
718
+ // slightly stale number until the next successful poll, same "core
719
+ // function survives an enhancement failing" principle used everywhere
720
+ // else in this app (push notifications, profile fetch, etc.).
721
+ async function reportUsage(parsed) {
722
+ if (!deviceId) return;
723
+ try {
724
+ await fetch(`${AUTH_SERVER}/device/usage`, {
725
+ method: 'POST',
726
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
727
+ body: JSON.stringify({
728
+ cliType: adapter.id,
729
+ totalTokens: parsed.totalTokens,
730
+ inputTokens: parsed.inputTokens,
731
+ outputTokens: parsed.outputTokens,
732
+ costUsd: parsed.costUsd,
733
+ contextUsedPct: parsed.sessionContextUsedPct,
734
+ weekUsedPct: parsed.weekUsedPct,
735
+ sessionResetsAt: parsed.sessionResetsAt,
736
+ weekResetsAt: parsed.weekResetsAt,
737
+ }),
738
+ });
739
+ log(`[supervisor] Reported usage: ${JSON.stringify(parsed)}`);
740
+ } catch (err) {
741
+ log('[supervisor] Failed to report usage:', err.message);
742
+ }
743
+ }
744
+
745
+ // Laptop battery-low warning - reads Windows' own Win32_Battery WMI
746
+ // class via a PowerShell one-liner (verified live: returns a real
747
+ // EstimatedChargeRemaining percentage on a real laptop; returns NOTHING
748
+ // at all on a desktop with no battery, which is exactly the "just skip
749
+ // this feature there" behavior wanted - no separate desktop-detection
750
+ // logic needed). Not a Node package - node's own APIs have no battery
751
+ // reading at all, and this needs zero new dependencies.
752
+ const BATTERY_LOW_THRESHOLD_PCT = 20;
753
+ const BATTERY_POLL_INTERVAL_MS = 5 * 60_000;
754
+ let batteryTimer = null;
755
+ // Tracks whether the LAST reading was already below the threshold, so
756
+ // the alert fires once per crossing (unplugged and draining past 20%)
757
+ // rather than every single 5-minute poll for as long as the battery
758
+ // stays low - repeating the same "your laptop is low" push every 5
759
+ // minutes while someone's already aware and mid-charge (or just hasn't
760
+ // gotten to a charger yet) would be pure noise, not a useful alert.
761
+ let wasBatteryLow = false;
762
+
763
+ function checkBatteryOnce() {
764
+ exec(
765
+ 'powershell -NoProfile -Command "Get-CimInstance -ClassName Win32_Battery | Select-Object -ExpandProperty EstimatedChargeRemaining"',
766
+ { timeout: 10_000 },
767
+ (err, stdout) => {
768
+ if (err) return; // no battery (desktop), PowerShell unavailable, etc. - silently skip, not an error worth logging every 5 minutes
769
+ const pct = Number(String(stdout).trim());
770
+ if (!Number.isFinite(pct)) return;
771
+
772
+ const isLow = pct < BATTERY_LOW_THRESHOLD_PCT;
773
+ if (isLow && !wasBatteryLow) {
774
+ log(`[supervisor] Battery low: ${pct}% - sending alert`);
775
+ if (deviceId) {
776
+ fetch(`${AUTH_SERVER}/device/battery-alert`, {
777
+ method: 'POST',
778
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
779
+ body: JSON.stringify({ batteryPct: pct }),
780
+ }).catch((fetchErr) => log('[supervisor] Failed to send battery alert:', fetchErr.message));
781
+ }
782
+ }
783
+ wasBatteryLow = isLow;
784
+ }
785
+ );
786
+ }
787
+
788
+ async function pollForRequests() {
789
+ // No queue file for this adapter (approvals: null - generic CLI or
790
+ // Codex's plain-PTY mode) means there is nothing to poll: this CLI has
791
+ // no known mechanism to report a prompt before it happens, so the human
792
+ // just answers it directly in the mirrored terminal, with no
793
+ // structured Confirmations card ever generated for it.
794
+ if (!azureReady || !QUEUE_FILE) return;
795
+
796
+ let raw;
797
+ try {
798
+ raw = fs.readFileSync(QUEUE_FILE, 'utf8');
799
+ } catch {
800
+ return;
801
+ }
802
+
803
+ const lines = raw.split('\n').filter(Boolean);
804
+ if (lines.length <= processedLines) return;
805
+
806
+ const newLines = lines.slice(processedLines);
807
+ processedLines = lines.length;
808
+
809
+ for (const line of newLines) {
810
+ let event;
811
+ try {
812
+ event = JSON.parse(line);
813
+ } catch {
814
+ continue;
815
+ }
816
+
817
+ if (event.hook_event_name === 'PostToolUse') {
818
+ resolveIfPending(event);
819
+ continue;
820
+ }
821
+
822
+ const options = await resolveRealOptions(event);
823
+ const text = claudeAdapter.approvals.describePrompt(event);
824
+ await relayApproval(event, options, text);
825
+ }
826
+ }
827
+
828
+ // Real bug found by testing: getDialogOptions' Write/Bash/PowerShell
829
+ // option sets are a hardcoded guess (the hook payload never carries the
830
+ // real menu for those tool types) - it drifted the moment Claude Code
831
+ // added a 4th choice ("switch to auto mode") to its own menu, showing the
832
+ // phone 3 buttons for a real 4-option prompt. adapter.approvals.
833
+ // parseLiveOptions reads the ACTUAL rendered menu from the terminal
834
+ // instead, but PermissionRequest fires BEFORE the prompt is drawn on
835
+ // screen (see this file's own top comment) - so a short retry loop here
836
+ // gives the PTY time to actually render it before ever falling back to
837
+ // the static guess. Skipped entirely for AskUserQuestion, whose real
838
+ // options DO come through the hook payload already (getDialogOptions
839
+ // reads tool_input.questions[0].options directly) - no need to wait on
840
+ // a screen render for a case that's already exact.
841
+ async function resolveRealOptions(event) {
842
+ if (!claudeAdapter.approvals.parseLiveOptions || event.tool_name === 'AskUserQuestion') {
843
+ return claudeAdapter.approvals.getDialogOptions(event);
844
+ }
845
+ for (let attempt = 0; attempt < 8; attempt++) {
846
+ const live = claudeAdapter.approvals.parseLiveOptions(scrollback.slice(-3000));
847
+ if (live) return live;
848
+ await new Promise((r) => setTimeout(r, 150));
849
+ }
850
+ log('[supervisor] Live menu did not render in time - falling back to the static guess for this prompt.');
851
+ return claudeAdapter.approvals.getDialogOptions(event);
852
+ }
853
+
854
+ // Real gap found by testing: a prompt answered directly at the laptop's
855
+ // own keyboard (never via the phone) left its card stuck "pending"
856
+ // forever on the phone, since nothing ever told the Backend the tool
857
+ // actually ran. PostToolUse only fires once a tool call actually
858
+ // executes - which can only happen after ITS permission prompt was
859
+ // answered somehow, locally or remotely - so a PostToolUse for the same
860
+ // tool_name + session_id we're still holding pending is real proof that
861
+ // prompt got resolved. Matched on tool_name + session_id rather than
862
+ // exact tool_input equality - Claude Code's PostToolUse payload doesn't
863
+ // echo back tool_input in a byte-identical shape to PermissionRequest's,
864
+ // and two DIFFERENT pending prompts for the exact same tool_name in the
865
+ // exact same session (e.g. two Bash calls in a row) resolve in the order
866
+ // they were opened either way, so ambiguity here is harmless.
867
+ function reportResolvedLocally(promptId, reason) {
868
+ log(`[supervisor] ${promptId} resolved locally (${reason}) - notifying Backend.`);
869
+ fetch(`${AUTH_SERVER}/relay/confirmation-resolved-locally`, {
870
+ method: 'POST',
871
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
872
+ body: JSON.stringify({ sessionId: SESSION_ID, promptId }),
873
+ }).catch((err) => log('[supervisor] Failed to report local resolution:', err.message));
874
+ }
875
+
876
+ // Extracts a real identity for one AskUserQuestion prompt - its actual
877
+ // question text, not just "this session asked SOMETHING". Undefined for
878
+ // every other tool_name, which is fine: those never fire two pending
879
+ // prompts back-to-back for the same session the way AskUserQuestion can
880
+ // (see resolveIfPending's own comment).
881
+ function askUserQuestionKey(event) {
882
+ return event.tool_input?.questions?.[0]?.question;
883
+ }
884
+
885
+ function resolveIfPending(postToolUseEvent) {
886
+ // Real bug found by testing: matching on tool_name + session_id alone
887
+ // resolved whichever pending prompt happened to be found FIRST in the
888
+ // Map, not necessarily the one that actually just got answered.
889
+ // AskUserQuestion specifically can have two genuinely different
890
+ // pending questions in flight for the SAME session at once - rejecting
891
+ // one often makes Claude Code immediately ask a related follow-up
892
+ // question before the first prompt's PostToolUse even fires - so this
893
+ // ambiguity isn't a rare edge case for it, it's a real, observed
894
+ // sequence. Matching on the actual question text (when available)
895
+ // instead of just tool_name+session_id resolves the CORRECT one even
896
+ // when two are pending at once; falls back to the old less-precise
897
+ // match for every other tool_name, where this ambiguity doesn't arise.
898
+ const postKey = askUserQuestionKey(postToolUseEvent);
899
+ for (const [promptId, pending] of pendingPrompts.entries()) {
900
+ if (pending.session_id !== postToolUseEvent.session_id) continue;
901
+ if (pending.tool_name !== postToolUseEvent.tool_name) continue;
902
+ if (postKey && askUserQuestionKey(pending) && askUserQuestionKey(pending) !== postKey) continue;
903
+ pendingPrompts.delete(promptId);
904
+ reportResolvedLocally(promptId, `PostToolUse for ${postToolUseEvent.tool_name}`);
905
+ return;
906
+ }
907
+ }
908
+
909
+ // Real gap found by testing: an APPROVED prompt gets a PostToolUse event
910
+ // (resolveIfPending above catches it), but a REJECTED/interrupted one
911
+ // never does - the tool call never ran, so there's no "it ran" signal to
912
+ // observe at all. The one thing that IS still true: Claude Code only ever
913
+ // has ONE open prompt per session at a time, so a genuinely NEW
914
+ // PermissionRequest arriving for a session is real proof any OLDER
915
+ // pending prompt in that same session is no longer live - it was
916
+ // rejected, interrupted, or otherwise handled, by definition, since
917
+ // Claude Code moved on. Called right before relaying the new prompt.
918
+ function resolveStaleForSession(sessionId, reason = 'a newer prompt arrived for the same session') {
919
+ for (const [promptId, pending] of pendingPrompts.entries()) {
920
+ if (pending.session_id === sessionId) {
921
+ pendingPrompts.delete(promptId);
922
+ reportResolvedLocally(promptId, reason);
923
+ }
924
+ }
925
+ }
926
+
927
+ // Shared by both approval-detection mechanisms (Claude Code's hook-queue
928
+ // polling above, and Codex's PTY-scrape detection below) - same
929
+ // pendingPrompts bookkeeping and /relay/confirmation-request call either
930
+ // way, since from the phone's perspective a confirmation card looks
931
+ // identical regardless of which CLI/detection method produced it.
932
+ async function relayApproval(event, options, text) {
933
+ // See resolveStaleForSession's own comment - a new prompt for this
934
+ // session means any older one we're still holding pending is stale.
935
+ resolveStaleForSession(event.session_id);
936
+
937
+ // Real bug found by testing: Date.now() alone collides when two
938
+ // separate supervisor processes (different laptops/sessions) detect a
939
+ // prompt in the same millisecond - the phone's seenPromptIds dedup then
940
+ // treats the second, genuinely different prompt as a duplicate of the
941
+ // first and drops it, or conflates their responses. DEVICE_NAME makes
942
+ // the id unique per-process even under a same-millisecond collision.
943
+ const promptId = `p-${DEVICE_NAME}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
944
+ pendingPrompts.set(promptId, { ...event, options });
945
+
946
+ log(`[supervisor] Detected approval prompt: ${text}`);
947
+ log(`[supervisor] Relaying to auth-server as promptId=${promptId}...`);
948
+
949
+ try {
950
+ const res = await fetch(`${AUTH_SERVER}/relay/confirmation-request`, {
951
+ method: 'POST',
952
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${deviceToken}` },
953
+ body: JSON.stringify({
954
+ sessionId: SESSION_ID,
955
+ promptId,
956
+ text,
957
+ options: options.map(({ label, style }) => ({ label, style })),
958
+ }),
959
+ });
960
+ if (!res.ok) {
961
+ const err = await res.json().catch(() => ({}));
962
+ log('[supervisor] Relay failed:', err.error || res.status);
963
+ }
964
+ } catch (err) {
965
+ log('[supervisor] Failed to reach auth-server:', err.message);
966
+ }
967
+
968
+ // Real gap found by testing: an APPROVED Bash/Write/PowerShell prompt
969
+ // gets a PostToolUse event (resolveIfPending), but a REJECTED one
970
+ // doesn't - the tool never ran, so there's nothing to hook. Several
971
+ // attempts at matching Claude Code's own rejection wording proved
972
+ // unreliable (see cli-adapters/claude.js's isMenuStillOnScreen
973
+ // comment). This instead polls whether the SAME menu is still
974
+ // physically on screen - once it isn't, a decision happened (approved,
975
+ // rejected, or interrupted, doesn't matter which) and the prompt is
976
+ // resolved. Skipped for AskUserQuestion, which already resolves
977
+ // reliably and precisely via PostToolUse's exact question-text match -
978
+ // this cruder "is ANY proceed-style menu gone" check would be a
979
+ // downgrade for that tool_name, not an improvement.
980
+ if (claudeAdapter.approvals.isMenuStillOnScreen && event.tool_name !== 'AskUserQuestion') {
981
+ watchForMenuDisappearance(promptId);
982
+ }
983
+ }
984
+
985
+ // Polls every 500ms for up to 5 minutes (a real user can genuinely take
986
+ // that long to decide) - stops early the moment isMenuStillOnScreen
987
+ // returns false, or the moment this promptId is no longer pending at all
988
+ // (already resolved through the phone, PostToolUse, or a newer prompt -
989
+ // no point continuing to poll something that's already been handled).
990
+ async function watchForMenuDisappearance(promptId) {
991
+ const deadline = Date.now() + 5 * 60_000;
992
+ while (Date.now() < deadline) {
993
+ await new Promise((r) => setTimeout(r, 500));
994
+ if (!pendingPrompts.has(promptId)) return; // already resolved some other way
995
+ if (!claudeAdapter.approvals.isMenuStillOnScreen(scrollback.slice(-3000))) {
996
+ pendingPrompts.delete(promptId);
997
+ reportResolvedLocally(promptId, 'the on-screen menu is gone - resolved locally (approved, rejected, or interrupted)');
998
+ return;
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ // Codex-specific entry point (cli-adapters/codex.js's sourceMode:
1004
+ // 'pty-scrape') - called from the onData handler once detectApproval
1005
+ // finds a NEW (not-yet-seen) prompt in the scrollback window.
1006
+ function handleDetectedApproval(detected) {
1007
+ const options = adapter.approvals.getDialogOptions(detected);
1008
+ const text = adapter.approvals.describePrompt(detected);
1009
+ relayApproval(detected, options, text);
1010
+ }
1011
+
1012
+ // Real gap found by testing: a laptop-side warning used to fire here any
1013
+ // time another setu was already running for the same folder - built
1014
+ // before the real enforcement below existed, to catch accidental
1015
+ // duplicates. Removed: running a 2nd session in the same folder is
1016
+ // explicitly an ALLOWED product rule (2 live sessions per device - see
1017
+ // auth-server.js's PLAN_LIMITS.maxLiveSessions, now scoped per-device),
1018
+ // so warning about it read alarmingly for something that's actually
1019
+ // fine, and the real safety net now exists at the backend: /device/
1020
+ // connect itself rejects a 3rd session for this device with a clear
1021
+ // error message, before it ever gets a usable connection - a stronger,
1022
+ // more reliable guarantee than a laptop-side warning a user could miss
1023
+ // or click past anyway.
1024
+ async function main() {
1025
+ if (QUEUE_FILE) fs.writeFileSync(QUEUE_FILE, '');
1026
+ fs.writeFileSync(PROCESSED_FILE, '');
1027
+
1028
+ console.log(`\n[supervisor] Spawning ${adapter.displayName} in a PTY (device=${DEVICE_NAME}, session=${SESSION_ID}, cwd=${WORKING_DIR})...`);
1029
+
1030
+ ptyProcess = pty.spawn(adapter.spawn.command, adapter.spawn.args(WORKING_DIR), {
1031
+ name: 'xterm-color',
1032
+ cols: PTY_COLS,
1033
+ rows: PTY_ROWS,
1034
+ cwd: WORKING_DIR,
1035
+ env: process.env,
1036
+ });
1037
+
1038
+ ptyProcess.onData((data) => {
1039
+ process.stdout.write(data);
1040
+
1041
+ // Section 4c call ① -> the scrollback (Section 7) is kept regardless
1042
+ // of whether anyone's watching, so a phone attaching later still gets
1043
+ // real history, not just whatever arrives after it connects.
1044
+ appendScrollback(data);
1045
+ // Fed the IDENTICAL bytes as the real PTY, in the same order - see
1046
+ // headlessTerm's own declaration comment for why this (not the raw
1047
+ // scrollback string above) is what makes a genuinely complete
1048
+ // transcript possible. writeToHeadlessTerm (not a direct .write()
1049
+ // call) queues this safely - see its own comment on why reading the
1050
+ // buffer right after a raw write would see stale/empty content.
1051
+ writeToHeadlessTerm(data);
1052
+
1053
+ // Real gap found by testing: gating live streaming on isFocused (only
1054
+ // spend a request if a phone is CURRENTLY looking at this session)
1055
+ // meant anything that happened while the user was on a different tab,
1056
+ // or had the app backgrounded, was never streamed live - only
1057
+ // recoverable later via the one-time scrollback snapshot on next
1058
+ // attach, which truncates past SCROLLBACK_MAX_BYTES and in practice
1059
+ // left real conversation history missing from what should be a
1060
+ // complete mirror. The whole point of this feature is "the phone's
1061
+ // terminal shows the exact same thing scrolling up as the real
1062
+ // laptop terminal does" - a cost optimization that can silently drop
1063
+ // real content contradicts that, so this now always streams
1064
+ // regardless of isFocused (kept only for the Devices tab's online
1065
+ // heartbeat - see pollFocus's own comment).
1066
+ streamOutput(data);
1067
+
1068
+ // Usage polling (see cli-adapters/claude.js) - only ever active for
1069
+ // an adapter that defines usage.parseFromOutput; every other adapter
1070
+ // leaves usageState untouched and Devices/Profile just show nothing
1071
+ // for token usage, same "optional richness" pattern as approvals.
1072
+ //
1073
+ // Real bug found by testing: parsing each raw `data` chunk in
1074
+ // isolation silently failed most of the time - a PTY streams output
1075
+ // in small, arbitrarily-split pieces (this is exactly why
1076
+ // TerminalScreen.js's own out-of-order chunk sequencing exists), and
1077
+ // `/cost`'s multi-line render (the "58% used" text, the "Resets ..."
1078
+ // line right after it) is not guaranteed to land in one chunk. A
1079
+ // regex against a single chunk that happens to end mid-render just
1080
+ // never matches, with no error - it looked like "auto-refresh isn't
1081
+ // working" from the phone side when the real gap was here. Matching
1082
+ // against a trailing window of the already-accumulated scrollback
1083
+ // instead guarantees the full rendered output is visible regardless
1084
+ // of how the PTY happened to chunk it.
1085
+ //
1086
+ // Gated to a short window after triggerKeystrokes was actually sent
1087
+ // (usageListenUntil) rather than running on every chunk for the
1088
+ // entire session - re-running 6 regexes against a 4KB window on
1089
+ // every single byte the CLI ever prints (a long response streams
1090
+ // MANY chunks) would be pure waste outside the brief moment right
1091
+ // after we actually asked for a fresh reading.
1092
+ if (adapter.usage?.parseFromOutput) {
1093
+ if (adapter.usage.triggerKeystrokes) {
1094
+ // Claude Code shape: only worth checking right after we actually
1095
+ // asked for a fresh reading (see usageListenUntil above).
1096
+ if (Date.now() < usageListenUntil) {
1097
+ const recentWindow = scrollback.slice(-4000);
1098
+ const parsed = adapter.usage.parseFromOutput(recentWindow);
1099
+ if (parsed) {
1100
+ reportUsage(parsed);
1101
+ usageListenUntil = 0; // got it - stop checking until the next trigger
1102
+ // Real feedback: /cost's full-screen usage report otherwise
1103
+ // sits visible in the person's own terminal after an
1104
+ // on-demand refresh, showing exactly what just happened
1105
+ // behind the scenes - not something a remote refresh should
1106
+ // leave lying around for whoever's physically at the laptop
1107
+ // to wonder about. Esc is the standard way Claude Code's own
1108
+ // full-screen views (Usage/Config/Status) are dismissed
1109
+ // elsewhere in this CLI - NOT independently re-verified
1110
+ // against this exact /cost screen in this session, so treat
1111
+ // this as the first real test of that assumption, not a
1112
+ // confirmed fact. Same mechanism as the approval
1113
+ // auto-injection above (ptyProcess.write with a real
1114
+ // keystroke), nothing new.
1115
+ ptyProcess.write('\x1b');
1116
+ }
1117
+ }
1118
+ } else {
1119
+ // Codex shape (cli-adapters/codex.js) - real bug found by live
1120
+ // testing: "% context left" is NOT a persistent, always-visible
1121
+ // status line the way the first version of this comment assumed.
1122
+ // It only renders for a brief moment right after a turn finishes,
1123
+ // while the input box is idle-focused ("tab to queue message ...
1124
+ // NN% context left") - gone again as soon as the next prompt is
1125
+ // typed or another turn starts. A 5-minute throttle (copied from
1126
+ // Claude Code's shape, where /cost is manually triggered and its
1127
+ // output persists on screen) missed this window almost every
1128
+ // time in testing - across several real approved turns, usage
1129
+ // was never once recorded, because the 5-minute cooldown was
1130
+ // still active during the one brief moment the text existed.
1131
+ // There's no reliable trigger to key a narrower check off (see
1132
+ // README.md), so the only option that actually works is checking
1133
+ // EVERY chunk - affordable here since this is one small regex,
1134
+ // not Claude Code's six-regex /cost parse.
1135
+ const recentWindow = scrollback.slice(-4000);
1136
+ const parsed = adapter.usage.parseFromOutput(recentWindow);
1137
+ // De-duped by the actual percentage, not a timer - the same
1138
+ // "100% context left" render can appear across several
1139
+ // consecutive chunks before Codex's next redraw changes it,
1140
+ // and reporting on every one of those would spam
1141
+ // /device/usage with identical, redundant POSTs.
1142
+ if (parsed && parsed.sessionContextUsedPct !== lastReportedCodexContextPct) {
1143
+ lastReportedCodexContextPct = parsed.sessionContextUsedPct;
1144
+ reportUsage(parsed);
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ // Approval detection for an adapter using PTY-text scraping instead
1150
+ // of a hook queue (see cli-adapters/codex.js's sourceMode) - Claude
1151
+ // Code's approvals flow through pollForRequests/QUEUE_FILE instead;
1152
+ // this branch is a no-op for any adapter without approvals.sourceMode
1153
+ // === 'pty-scrape' (Claude Code, generic, or an adapter with
1154
+ // approvals: null entirely).
1155
+ if (adapter.approvals?.sourceMode === 'pty-scrape' && azureReady) {
1156
+ const recentWindow = scrollback.slice(-4000);
1157
+
1158
+ const onboarding = adapter.approvals.autoAnswerOnboarding(recentWindow);
1159
+ if (onboarding && onboarding.reason !== lastOnboardingAnswered) {
1160
+ // Real bug found by testing: a time-based cooldown (originally
1161
+ // 2s) re-fired repeatedly on the SAME still-on-screen gate,
1162
+ // sometimes minutes apart - the screen can stay rendered in the
1163
+ // scrollback window far longer than any short cooldown, since
1164
+ // it's real elapsed wall-clock time until Codex actually
1165
+ // processes the keystroke and redraws, not a fixed delay.
1166
+ // Tracking WHICH gate (by reason) was last answered instead of a
1167
+ // timer means it's answered exactly once and never re-sent until
1168
+ // a genuinely DIFFERENT gate (or none) appears - each extra
1169
+ // stray keypress risked landing on the real chat input once the
1170
+ // gate actually cleared.
1171
+ lastOnboardingAnswered = onboarding.reason;
1172
+ log(`[supervisor] Auto-answering Codex onboarding gate: ${onboarding.reason}`);
1173
+ ptyProcess.write(onboarding.keystrokes);
1174
+ } else if (!onboarding) {
1175
+ lastOnboardingAnswered = null; // gate cleared - a future recurrence should be answered again
1176
+ const detected = adapter.approvals.detectApproval(recentWindow);
1177
+ if (detected && detected.fingerprint !== lastApprovalFingerprint) {
1178
+ lastApprovalFingerprint = detected.fingerprint;
1179
+ handleDetectedApproval(detected);
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ // Real gap found by testing: an APPROVED prompt gets a real
1185
+ // PostToolUse hook event to key off of (resolveIfPending above), but
1186
+ // a REJECTED/interrupted one produces no hook event at all - the tool
1187
+ // call never ran. A PTY-text-scan for Claude Code's own "Interrupted..."
1188
+ // wording was tried here and removed again - real captured terminal
1189
+ // output across several live rejection tests never actually contained
1190
+ // that literal text in a form the regex could reliably match (Claude
1191
+ // Code doesn't render it consistently enough to key off of). Left as
1192
+ // the reliable fallback instead: a rejected prompt clears once ANOTHER
1193
+ // prompt arrives for the same session (resolveStaleForSession, called
1194
+ // from relayApproval below) - not instant, but proven correct across
1195
+ // testing, unlike the PTY-scan attempt.
1196
+ });
1197
+
1198
+ ptyProcess.onExit(({ exitCode }) => {
1199
+ console.log(`\n[supervisor] ${adapter.displayName} exited with code ${exitCode}`);
1200
+ if (pollTimer) clearInterval(pollTimer);
1201
+ if (focusTimer) clearInterval(focusTimer);
1202
+ if (usageTimer) clearInterval(usageTimer);
1203
+ if (batteryTimer) clearInterval(batteryTimer);
1204
+ if (ws) ws.close();
1205
+ process.exit(exitCode || 0);
1206
+ });
1207
+
1208
+ // Real feedback: an automatic timer used to run here (every 5 minutes,
1209
+ // unconditionally) asking the session for a fresh /cost reading -
1210
+ // removed after live testing showed it genuinely disrupting someone
1211
+ // actively working at the laptop, taking over their visible terminal
1212
+ // with a full-screen usage report on a schedule nobody asked for. Usage
1213
+ // now only ever refreshes on an explicit request from the phone (see
1214
+ // this file's 'usage.refresh.request' handler above) - the SAME
1215
+ // triggerKeystrokes mechanism, just fired on demand instead of blind
1216
+ // polling. usageTimer is kept defined (see its own comment above) so
1217
+ // ptyProcess.onExit's cleanup still has a valid, harmless no-op target.
1218
+
1219
+ pollTimer = setInterval(pollForRequests, POLL_INTERVAL_MS);
1220
+ // Real gap found by testing: closing the shell directly (no graceful
1221
+ // Ctrl+C) leaves no explicit "I'm done" signal for the Backend at all
1222
+ // (see this function's onExit handler) - the ONLY way a dead session
1223
+ // ever stops showing live on the phone is this heartbeat simply going
1224
+ // quiet long enough for auth-server.js's own LIVE_SESSION_STALE_MS to
1225
+ // notice. 2000ms (was 3000ms) keeps a comfortable multiple of margin
1226
+ // under that window (now 7000ms) even if a poll or two is delayed,
1227
+ // while detecting a real disconnect meaningfully faster than before.
1228
+ focusTimer = setInterval(pollFocus, 2000);
1229
+ // CLI-agnostic - runs for every adapter (Claude Code, Codex, generic),
1230
+ // since battery is a property of the LAPTOP, not of whatever CLI it
1231
+ // happens to be running.
1232
+ checkBatteryOnce(); // one immediate reading at startup, not just 5 min later
1233
+ batteryTimer = setInterval(checkBatteryOnce, BATTERY_POLL_INTERVAL_MS);
1234
+
1235
+ process.stdin.setRawMode(true);
1236
+ process.stdin.resume();
1237
+ process.stdin.on('data', (data) => ptyProcess.write(data.toString()));
1238
+
1239
+ // Real gap in the PoC this replaces: there was no way for `setu status`
1240
+ // or `setu stop` (both new - see src/status.js, src/stop.js) to know
1241
+ // whether a session watcher was even running, or on which PID, since
1242
+ // supervisor-qr.js was always started and killed by hand in a visible
1243
+ // terminal. One small PID file per running session (keyed by
1244
+ // SESSION_ID, since one paired device can run several sessions at
1245
+ // once) - written here at startup, removed on every real exit path.
1246
+ const pidFile = path.join(CONFIG_DIR, `session.${SESSION_ID}.pid`);
1247
+ fs.writeFileSync(pidFile, JSON.stringify({ pid: process.pid, deviceName: DEVICE_NAME, sessionId: SESSION_ID, cwd: WORKING_DIR, startedAt: new Date().toISOString() }, null, 2));
1248
+ const removePidFile = () => { try { fs.unlinkSync(pidFile); } catch {} };
1249
+
1250
+ process.on('SIGINT', () => {
1251
+ ptyProcess.kill();
1252
+ if (ws) ws.close();
1253
+ removePidFile();
1254
+ process.exit(0);
1255
+ });
1256
+ process.on('exit', removePidFile);
1257
+
1258
+ connectToAzure();
1259
+ }
1260
+
1261
+ main().catch((err) => {
1262
+ console.error('[supervisor] fatal:', err.message);
1263
+ process.exit(1);
1264
+ });