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.
- package/README.md +40 -0
- package/bin/setu.js +104 -0
- package/package.json +40 -0
- package/src/cli-adapters/README.md +74 -0
- package/src/cli-adapters/claude.js +304 -0
- package/src/cli-adapters/codex.js +261 -0
- package/src/cli-adapters/generic.js +32 -0
- package/src/cli-adapters/index.js +27 -0
- package/src/cli-adapters/shell.js +52 -0
- package/src/config.js +54 -0
- package/src/hooks.js +80 -0
- package/src/onPermissionRequest.js +89 -0
- package/src/pair.js +83 -0
- package/src/status.js +65 -0
- package/src/stop.js +51 -0
- package/src/supervisor.js +1264 -0
- package/src/update.js +73 -0
|
@@ -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
|
+
});
|