@clastres/groundcontrol 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/daemon.mjs CHANGED
@@ -11,8 +11,10 @@ import { WebSocket } from 'ws';
11
11
  import qrcode from 'qrcode-terminal';
12
12
  import crypto from 'node:crypto';
13
13
  import fs from 'node:fs';
14
+ import net from 'node:net';
14
15
  import os from 'node:os';
15
16
  import path from 'node:path';
17
+ import readline from 'node:readline';
16
18
  import { spawn, execFile, execSync } from 'node:child_process';
17
19
  import { promisify } from 'node:util';
18
20
  import { fileURLToPath } from 'node:url';
@@ -20,6 +22,9 @@ import { createRequire } from 'node:module';
20
22
 
21
23
  const execFileP = promisify(execFile);
22
24
 
25
+ const VERSION = '0.1.8';
26
+ const PACKAGE = '@clastres/groundcontrol';
27
+
23
28
  // ---------- config / pairing ----------
24
29
 
25
30
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
@@ -42,6 +47,12 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
42
47
  service status Check the background daemon
43
48
  service install Set up the background daemon without pairing output
44
49
  service uninstall Remove the background daemon
50
+ approvals status Check remote permission approvals
51
+ approvals away Send permission prompts to the phone only while you
52
+ are away from the Mac (the default)
53
+ approvals always Send them to the phone whenever it is connected
54
+ approvals off Keep every permission prompt on the Mac
55
+ approvals uninstall Remove the permission hook from Claude Code
45
56
 
46
57
  Options:
47
58
  --relay <url> Use a custom relay (default: wss://relay.groundcontrol.chat)
@@ -54,7 +65,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
54
65
  process.exit(0);
55
66
  }
56
67
  if (process.argv.includes('--version')) {
57
- console.log('0.1.6');
68
+ console.log(VERSION);
58
69
  process.exit(0);
59
70
  }
60
71
 
@@ -66,9 +77,10 @@ if (process.argv.includes('--version')) {
66
77
 
67
78
  const subcommand = process.argv[2];
68
79
  const LABEL = 'chat.groundcontrol.daemon';
69
- const SERVICE_DIR = path.join(os.homedir(), '.groundcontrol', 'service');
80
+ const CONF_DIR = path.join(os.homedir(), '.groundcontrol');
81
+ const SERVICE_DIR = path.join(CONF_DIR, 'service');
70
82
  const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
71
- const LOG_FILE = path.join(os.homedir(), '.groundcontrol', 'daemon.log');
83
+ const LOG_FILE = path.join(CONF_DIR, 'daemon.log');
72
84
  const THIS_FILE = fs.realpathSync(fileURLToPath(import.meta.url));
73
85
  const runsFromServiceDir = THIS_FILE.startsWith(SERVICE_DIR + path.sep);
74
86
 
@@ -123,17 +135,36 @@ function explainStoppedService(printOut) {
123
135
  /// Returns true when launchd reports the job running.
124
136
  function installService() {
125
137
  const uid = process.getuid();
138
+
139
+ // Stage into a scratch directory first and only disturb the running service
140
+ // once every file is in place. The old order (bootout, delete, then copy)
141
+ // meant any failure mid copy left the user with no daemon at all, which is
142
+ // unacceptable now that self update runs this unattended.
143
+ const staging = `${SERVICE_DIR}.staging`;
144
+ try {
145
+ fs.rmSync(staging, { recursive: true, force: true });
146
+ fs.mkdirSync(path.join(staging, 'node_modules'), { recursive: true });
147
+ fs.copyFileSync(THIS_FILE, path.join(staging, 'daemon.mjs'));
148
+ // The permission hook ships beside the daemon and must travel with it, so a
149
+ // service copy can re-stage it after an upgrade without the npx cache.
150
+ fs.copyFileSync(hookSourceFile(), path.join(staging, 'hook.mjs'));
151
+ fs.writeFileSync(
152
+ path.join(staging, 'package.json'),
153
+ JSON.stringify({ name: 'groundcontrol-service', private: true, type: 'module' }, null, 2),
154
+ );
155
+ for (const dep of ['ws', 'qrcode-terminal']) {
156
+ fs.cpSync(packageDirOf(dep), path.join(staging, 'node_modules', dep), { recursive: true });
157
+ }
158
+ } catch (e) {
159
+ // Nothing has been touched yet, so the existing service keeps running.
160
+ console.log(`[daemon] could not stage the service, leaving the current one alone: ${e.message}`);
161
+ fs.rmSync(staging, { recursive: true, force: true });
162
+ return false;
163
+ }
164
+
126
165
  launchctl(`bootout gui/${uid}/${LABEL}`);
127
166
  fs.rmSync(SERVICE_DIR, { recursive: true, force: true });
128
- fs.mkdirSync(path.join(SERVICE_DIR, 'node_modules'), { recursive: true });
129
- fs.copyFileSync(THIS_FILE, path.join(SERVICE_DIR, 'daemon.mjs'));
130
- fs.writeFileSync(
131
- path.join(SERVICE_DIR, 'package.json'),
132
- JSON.stringify({ name: 'groundcontrol-service', private: true, type: 'module' }, null, 2),
133
- );
134
- for (const dep of ['ws', 'qrcode-terminal']) {
135
- fs.cpSync(packageDirOf(dep), path.join(SERVICE_DIR, 'node_modules', dep), { recursive: true });
136
- }
167
+ fs.renameSync(staging, SERVICE_DIR);
137
168
 
138
169
  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
139
170
  const nodeBin = stableNodePath();
@@ -184,6 +215,181 @@ function installService() {
184
215
  return true;
185
216
  }
186
217
 
218
+ // ---------- remote approvals: Claude Code hook installation ----------
219
+ // Claude Code fires a PermissionRequest hook whenever a tool call needs a
220
+ // decision. Our hook hands the request to this daemon, which either holds it
221
+ // for the phone or passes immediately so the terminal prompts as usual.
222
+
223
+ const CLAUDE_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
224
+ const HOOK_FILE = path.join(CONF_DIR, 'hook.mjs');
225
+ // Generous, because the daemon settles every request itself. This only caps
226
+ // the case where the daemon wedges while holding.
227
+ const HOOK_TIMEOUT_SECONDS = 12 * 60 * 60;
228
+
229
+ const hookSourceFile = () => path.join(path.dirname(THIS_FILE), 'hook.mjs');
230
+
231
+ /// Copy the hook next to the key material so it survives npx cache eviction.
232
+ function stageHookFile() {
233
+ fs.mkdirSync(CONF_DIR, { recursive: true });
234
+ fs.copyFileSync(hookSourceFile(), HOOK_FILE);
235
+ }
236
+
237
+ const hookCommand = () => `${JSON.stringify(stableNodePath())} ${JSON.stringify(HOOK_FILE)}`;
238
+
239
+ /// Every command string an entry can carry, flattened, so a hook can be
240
+ /// recognised whether it uses the nested or the shorthand form.
241
+ const commandsOf = (entry) => [entry?.command, entry?.bash, ...(Array.isArray(entry?.hooks) ? entry.hooks.map((h) => h?.command) : [])]
242
+ .filter((c) => typeof c === 'string').join(' ');
243
+
244
+ const isOurHook = (cmd) => cmd.includes('.groundcontrol') && cmd.includes('hook.mjs');
245
+
246
+ /// Read settings.json. Returns null when it exists but cannot be parsed, which
247
+ /// must never be treated as "empty": overwriting it would destroy real config.
248
+ function readClaudeSettings() {
249
+ if (!fs.existsSync(CLAUDE_SETTINGS)) return {};
250
+ try {
251
+ const parsed = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, 'utf8'));
252
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
253
+ } catch { return null; }
254
+ }
255
+
256
+ function backupClaudeSettings() {
257
+ if (!fs.existsSync(CLAUDE_SETTINGS)) return null;
258
+ const dest = `${CLAUDE_SETTINGS}.groundcontrol-backup`;
259
+ try { fs.copyFileSync(CLAUDE_SETTINGS, dest); return dest; } catch { return null; }
260
+ }
261
+
262
+ /// Add our hook to settings.json. Strictly additive: the only entry it ever
263
+ /// removes is our own previous one, so an upgrade replaces it instead of
264
+ /// stacking duplicates. Another tool's hooks are reported, never touched.
265
+ /// Writes only when something actually changes, so restarts are inert.
266
+ ///
267
+ /// Nothing is written unless `consent` is true. Editing another tool's config
268
+ /// is not something to do behind someone's back, so the caller has to have
269
+ /// asked first, or the user has to have run `groundcontrol approvals on`.
270
+ function installApprovalHook({ consent = false } = {}) {
271
+ try { stageHookFile(); } catch (e) { return { ok: false, reason: `could not stage the hook file: ${e.message}` }; }
272
+ if (!fs.existsSync(path.dirname(CLAUDE_SETTINGS))) return { ok: false, reason: 'no ~/.claude directory on this Mac' };
273
+
274
+ const settings = readClaudeSettings();
275
+ if (settings === null) return { ok: false, reason: '~/.claude/settings.json is not valid JSON, leaving it untouched' };
276
+
277
+ const before = JSON.stringify(settings.hooks?.PermissionRequest ?? null);
278
+ const existing = Array.isArray(settings.hooks?.PermissionRequest) ? settings.hooks.PermissionRequest : [];
279
+ const foreign = [];
280
+ const kept = existing.filter((entry) => {
281
+ const cmd = commandsOf(entry);
282
+ if (isOurHook(cmd)) return false; // ours, re-added fresh below
283
+ if (cmd) foreign.push(cmd.trim().slice(0, 70));
284
+ return true; // everyone else stays, always
285
+ });
286
+ const next = [...kept, {
287
+ matcher: '*',
288
+ hooks: [{ type: 'command', command: hookCommand(), timeout: HOOK_TIMEOUT_SECONDS }],
289
+ }];
290
+ if (JSON.stringify(next) === before) return { ok: true, unchanged: true, foreign };
291
+ if (!consent) return { ok: false, needsConsent: true, foreign, reason: 'the permission hook is not installed yet' };
292
+
293
+ const raw = fs.existsSync(CLAUDE_SETTINGS) ? fs.readFileSync(CLAUDE_SETTINGS, 'utf8') : '';
294
+ // A rewrite goes through JSON.parse/stringify, so any // comments in the
295
+ // file are lost. Worth saying out loud rather than quietly dropping them.
296
+ const hadComments = /^\s*\/\//m.test(raw);
297
+ const backup = backupClaudeSettings();
298
+ settings.hooks = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
299
+ settings.hooks.PermissionRequest = next;
300
+ try { fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2) + '\n'); }
301
+ catch (e) { return { ok: false, reason: `could not write settings.json: ${e.message}` }; }
302
+ return { ok: true, installed: true, foreign, backup, hadComments };
303
+ }
304
+
305
+ /// Remove only our own entry, leaving anything else in place.
306
+ function uninstallApprovalHook() {
307
+ const settings = readClaudeSettings();
308
+ if (settings === null) return { ok: false, reason: '~/.claude/settings.json is not valid JSON, leaving it untouched' };
309
+ const existing = Array.isArray(settings.hooks?.PermissionRequest) ? settings.hooks.PermissionRequest : [];
310
+ const kept = existing.filter((entry) => !isOurHook(commandsOf(entry)));
311
+ if (kept.length === existing.length) return { ok: true, unchanged: true };
312
+ backupClaudeSettings();
313
+ if (kept.length) settings.hooks.PermissionRequest = kept;
314
+ else delete settings.hooks.PermissionRequest;
315
+ if (settings.hooks && !Object.keys(settings.hooks).length) delete settings.hooks;
316
+ try { fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2) + '\n'); }
317
+ catch (e) { return { ok: false, reason: `could not write settings.json: ${e.message}` }; }
318
+ return { ok: true };
319
+ }
320
+
321
+ /// Another tool answering the same event is worth knowing about: Claude Code
322
+ /// runs every PermissionRequest hook and any deny wins, so a second one can
323
+ /// overrule your tap. Ground Control says so and leaves it alone. Removing it
324
+ /// is your call, not ours.
325
+ function reportForeignHooks(foreign) {
326
+ if (!foreign?.length) return;
327
+ console.log('\n Another app also answers permission requests on this Mac:');
328
+ for (const cmd of foreign) console.log(` ${cmd}`);
329
+ console.log(' Both will run. If either denies, the tool call is denied, so a');
330
+ console.log(' tap on your phone can be overruled. Ground Control leaves it in');
331
+ console.log(' place; remove it yourself if you want only one app deciding.');
332
+ }
333
+
334
+ /// Ask before editing ~/.claude/settings.json. Anything but an interactive
335
+ /// terminal answers no: a background or piped run must never decide this.
336
+ async function askYesNo(question) {
337
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
338
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
339
+ const answer = await new Promise((resolve) => rl.question(question, resolve));
340
+ rl.close();
341
+ return /^y(es)?$/i.test(answer.trim()) || answer.trim() === '';
342
+ }
343
+
344
+ /// Offer remote approvals during pairing, then do exactly what was answered.
345
+ async function offerApprovals() {
346
+ const state = installApprovalHook();
347
+ if (state.ok && state.unchanged) {
348
+ console.log(' Permission prompts already reach your phone.');
349
+ reportForeignHooks(state.foreign);
350
+ return;
351
+ }
352
+ if (!state.ok && !state.needsConsent) {
353
+ console.log(` Remote approvals unavailable: ${state.reason}`);
354
+ return;
355
+ }
356
+ console.log(' One more thing. Claude Code stops and asks before it runs');
357
+ console.log(' risky commands. Ground Control can send those to your phone');
358
+ console.log(' while you are away from the Mac, so a session does not sit');
359
+ console.log(' blocked until you get back. Sitting at your Mac, prompts stay');
360
+ console.log(' in the terminal exactly as they do now.');
361
+ console.log('');
362
+ console.log(' This adds one hook to ~/.claude/settings.json. Nothing else in');
363
+ console.log(' that file is changed, and a backup is written first.');
364
+ reportForeignHooks(state.foreign);
365
+ console.log('');
366
+ if (!await askYesNo(' Turn on remote approvals? [Y/n] ')) {
367
+ console.log('\n Left alone. Turn it on later with: groundcontrol approvals on\n');
368
+ return;
369
+ }
370
+ const done = installApprovalHook({ consent: true });
371
+ console.log('');
372
+ reportHookInstall(done);
373
+ console.log(' Change it anytime: groundcontrol approvals <away|always|off>\n');
374
+ }
375
+
376
+ function reportHookInstall(result) {
377
+ if (result.needsConsent) {
378
+ console.log(' Remote approvals are not set up yet. Turn them on with:');
379
+ console.log(' groundcontrol approvals on');
380
+ reportForeignHooks(result.foreign);
381
+ return;
382
+ }
383
+ if (!result.ok) {
384
+ console.log(` Remote approvals are off: ${result.reason}`);
385
+ return;
386
+ }
387
+ if (result.installed) console.log(' Permission prompts can now reach your phone.');
388
+ if (result.backup) console.log(` Backed up your settings to ${result.backup}`);
389
+ if (result.hadComments) console.log(' Heads up: comments in settings.json were dropped by the rewrite.');
390
+ reportForeignHooks(result.foreign);
391
+ }
392
+
187
393
  if (subcommand === 'service') {
188
394
  const action = process.argv[3];
189
395
  if (process.platform !== 'darwin') {
@@ -196,7 +402,10 @@ if (subcommand === 'service') {
196
402
  if (installService()) {
197
403
  console.log('\n Ground Control daemon is now running in the background.');
198
404
  console.log(' It starts at login and restarts automatically if it stops.\n');
405
+ // Deliberately not installing the hook here: this path is the scripted,
406
+ // non-interactive one, so there is nobody to ask.
199
407
  console.log(` Log: ${LOG_FILE}`);
408
+ console.log(' Approvals: groundcontrol approvals on');
200
409
  console.log(' Pair: groundcontrol qr');
201
410
  console.log(' Check: groundcontrol service status');
202
411
  console.log(' Remove: groundcontrol service uninstall\n');
@@ -234,12 +443,91 @@ if (subcommand === 'service') {
234
443
  process.exit(1);
235
444
  }
236
445
 
446
+ // ---------- remote approvals: policy config ----------
447
+
448
+ const APPROVALS_CONF = path.join(CONF_DIR, 'approvals.json');
449
+ const APPROVAL_DEFAULTS = {
450
+ // away: hold for the phone only while you are not using the Mac
451
+ // always: hold whenever a phone is connected
452
+ // off: never hold; the terminal prompts exactly as it always has
453
+ mode: 'away',
454
+ awayAfterSeconds: 90,
455
+ holdMinutes: 720,
456
+ };
457
+
458
+ let approvalConfCache = { at: 0, value: APPROVAL_DEFAULTS };
459
+ function approvalConfig() {
460
+ if (Date.now() - approvalConfCache.at < 5000) return approvalConfCache.value;
461
+ let value = APPROVAL_DEFAULTS;
462
+ try {
463
+ const parsed = JSON.parse(fs.readFileSync(APPROVALS_CONF, 'utf8'));
464
+ if (parsed && typeof parsed === 'object') value = { ...APPROVAL_DEFAULTS, ...parsed };
465
+ } catch {}
466
+ if (!['away', 'always', 'off'].includes(value.mode)) value = { ...value, mode: 'away' };
467
+ approvalConfCache = { at: Date.now(), value };
468
+ return value;
469
+ }
470
+
471
+ function writeApprovalConfig(patch) {
472
+ fs.mkdirSync(CONF_DIR, { recursive: true });
473
+ let current = {};
474
+ try { current = JSON.parse(fs.readFileSync(APPROVALS_CONF, 'utf8')) || {}; } catch {}
475
+ const next = { ...APPROVAL_DEFAULTS, ...current, ...patch };
476
+ fs.writeFileSync(APPROVALS_CONF, JSON.stringify(next, null, 2) + '\n');
477
+ approvalConfCache = { at: 0, value: APPROVAL_DEFAULTS };
478
+ return next;
479
+ }
480
+
481
+ if (subcommand === 'approvals') {
482
+ const action = process.argv[3] || 'status';
483
+ if (action === 'on' || action === 'always' || action === 'away') {
484
+ const mode = action === 'on' ? 'away' : action;
485
+ console.log(`\n Remote approvals: ${writeApprovalConfig({ mode }).mode}`);
486
+ reportHookInstall(installApprovalHook({ consent: true }));
487
+ console.log('');
488
+ process.exit(0);
489
+ }
490
+ if (action === 'off') {
491
+ writeApprovalConfig({ mode: 'off' });
492
+ console.log('\n Remote approvals are off. Permission prompts stay on the Mac.');
493
+ console.log(' The hook stays installed and does nothing. Remove it fully with:');
494
+ console.log(' groundcontrol approvals uninstall\n');
495
+ process.exit(0);
496
+ }
497
+ if (action === 'install') {
498
+ reportHookInstall(installApprovalHook({ consent: true }));
499
+ console.log('');
500
+ process.exit(0);
501
+ }
502
+ if (action === 'uninstall') {
503
+ const r = uninstallApprovalHook();
504
+ console.log(r.ok ? '\n Permission hook removed.\n' : `\n ${r.reason}\n`);
505
+ process.exit(0);
506
+ }
507
+ if (action === 'status') {
508
+ const cfg = approvalConfig();
509
+ const settings = readClaudeSettings();
510
+ const entries = Array.isArray(settings?.hooks?.PermissionRequest) ? settings.hooks.PermissionRequest : [];
511
+ const installed = entries.some((e) => isOurHook(commandsOf(e)));
512
+ const others = entries.filter((e) => !isOurHook(commandsOf(e))).map((e) => commandsOf(e).trim().slice(0, 70));
513
+ console.log(`\n Mode: ${cfg.mode}${cfg.mode === 'away' ? ` (after ${cfg.awayAfterSeconds}s idle)` : ''}`);
514
+ console.log(` Hook: ${installed ? 'installed' : 'NOT installed'}`);
515
+ console.log(` Hook file: ${fs.existsSync(HOOK_FILE) ? HOOK_FILE : 'missing'}`);
516
+ console.log(` Hold limit: ${cfg.holdMinutes} minutes, then the Mac prompts instead`);
517
+ if (settings === null) console.log(' Settings: ~/.claude/settings.json is not valid JSON');
518
+ for (const o of others) console.log(` Also runs: ${o}`);
519
+ console.log(` Config: ${APPROVALS_CONF}\n`);
520
+ process.exit(0);
521
+ }
522
+ console.log('\n Usage: groundcontrol approvals <status|on|off|always|install|uninstall>\n');
523
+ process.exit(1);
524
+ }
525
+
237
526
  const argRelay = (() => {
238
527
  const i = process.argv.indexOf('--relay');
239
528
  return i > -1 ? process.argv[i + 1] : null;
240
529
  })();
241
530
 
242
- const CONF_DIR = path.join(os.homedir(), '.groundcontrol');
243
531
  const KEY_FILE = path.join(CONF_DIR, 'key');
244
532
  const RELAY_FILE = path.join(CONF_DIR, 'relay');
245
533
  fs.mkdirSync(CONF_DIR, { recursive: true });
@@ -283,6 +571,7 @@ if (subcommand !== 'run' && !runsFromServiceDir && process.platform === 'darwin'
283
571
  if (installService()) {
284
572
  console.log('\n Done. The daemon now runs in the background: it starts at');
285
573
  console.log(' login and restarts by itself. You can close this window.\n');
574
+ await offerApprovals();
286
575
  console.log(' If macOS asks to allow "node" to control Terminal or iTerm2,');
287
576
  console.log(' click Allow. That is how phone messages reach your sessions.\n');
288
577
  console.log(' Scan the QR above with the Ground Control app to pair.');
@@ -349,23 +638,40 @@ widenPath();
349
638
  const which = (cmd) => { try { return execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch { return null; } };
350
639
  const executable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { return null; } };
351
640
 
352
- const claudeBin = which('claude');
353
- console.log(`[daemon] claude binary: ${claudeBin || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
641
+ // Every agent binary is resolved on use rather than at startup. launchd starts
642
+ // this at login and it then runs for weeks, while CLIs get installed
643
+ // underneath it. Session discovery already rescans the disk every few seconds,
644
+ // so a `const` resolved at boot produces the worst possible split: the new
645
+ // session appears on the phone, its transcript streams, and sending fails with
646
+ // "binary not found" until someone restarts the daemon. Cached for a minute so
647
+ // a prompt does not pay for a PATH scan.
648
+ const binMemo = new Map(); // label -> { at, path }
649
+ function resolveBin(label, find) {
650
+ const c = binMemo.get(label);
651
+ if (c && Date.now() - c.at < 60_000) return c.path;
652
+ let p = null;
653
+ try { p = find() || null; } catch {}
654
+ binMemo.set(label, { at: Date.now(), path: p });
655
+ return p;
656
+ }
657
+
658
+ const claudeBin = () => resolveBin('claude', () => which('claude'));
659
+ console.log(`[daemon] claude binary: ${claudeBin() || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
354
660
 
355
661
  // Codex ships a full CLI inside the ChatGPT desktop app, so Codex Desktop
356
662
  // users can receive phone prompts even without a standalone install.
357
- const codexBin = which('codex') || executable('/Applications/ChatGPT.app/Contents/Resources/codex');
663
+ const codexBin = () => resolveBin('codex', () => which('codex') || executable('/Applications/ChatGPT.app/Contents/Resources/codex'));
358
664
 
359
665
  // Kimi Code renames a legacy Python `kimi` shim to `kimi-legacy` on install,
360
666
  // so when the Kimi Code store (~/.kimi-code) exists, `kimi` on PATH belongs
361
667
  // to Kimi Code and `kimi-legacy` is the old CLI. Without the store, `kimi` is
362
668
  // the legacy one. The two binaries cannot resume each other's sessions, so
363
669
  // neither ever doubles for the other.
364
- const kimiCodeInstalled = fs.existsSync(path.join(os.homedir(), '.kimi-code'));
365
- const kimiCodeBin = kimiCodeInstalled
670
+ const kimiCodeInstalled = () => fs.existsSync(path.join(os.homedir(), '.kimi-code'));
671
+ const kimiCodeBin = () => resolveBin('kimicode', () => (kimiCodeInstalled()
366
672
  ? which('kimi') || executable(path.join(os.homedir(), '.kimi-code', 'bin', 'kimi'))
367
- : null;
368
- const kimiLegacyBin = which('kimi-legacy') || (kimiCodeInstalled ? null : which('kimi'));
673
+ : null));
674
+ const kimiLegacyBin = () => resolveBin('kimi', () => which('kimi-legacy') || (kimiCodeInstalled() ? null : which('kimi')));
369
675
 
370
676
  // ---------- Claude Code session discovery ----------
371
677
 
@@ -561,6 +867,7 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
561
867
  // path.sep keeps the prefixes unambiguous: without it ~/.kimi would also
562
868
  // match every ~/.kimi-code path and routing would depend on check order.
563
869
  if (file.startsWith(CODEX_SESS_DIR + path.sep)) return readCodexMessages(file, maxBytes, limit);
870
+ if (file.startsWith(GROKBOT_DIR + path.sep)) return readGrokBotMessages(file, maxBytes, limit);
564
871
  if (file.startsWith(KIMICODE_DIR + path.sep)) return readKimiCodeMessages(file, maxBytes, limit);
565
872
  if (file.startsWith(KIMI_DIR + path.sep)) return readKimiLegacyMessages(file, maxBytes, limit);
566
873
  let raw;
@@ -606,6 +913,10 @@ function readClaudeSessions() {
606
913
  cwd: s.cwd || '',
607
914
  project: path.basename(s.cwd || '?'),
608
915
  status: headlessActive.has(s.sessionId) ? 'busy' : (s.status || 'idle'),
916
+ // Claude Code's own view, kept separate because readSessions overwrites
917
+ // status to 'waiting' for a held request. Answering on the Mac is only
918
+ // detectable by watching this drop back out of 'waiting'.
919
+ rawStatus: s.status || 'idle',
609
920
  updatedAt: s.statusUpdatedAt || s.updatedAt || 0,
610
921
  startedAt: s.startedAt || 0,
611
922
  lastMessage: msgs.length ? msgs[msgs.length - 1] : null,
@@ -1019,12 +1330,568 @@ function readKimiSessions() {
1019
1330
  return kimiCache.sessions;
1020
1331
  }
1021
1332
 
1333
+ // ---------- Grok Bot session discovery ----------
1334
+ // Grok Bot's agents run on a cloud VM, so unlike every other provider there is
1335
+ // nothing local to attach to: no process, no tty, no working directory, and no
1336
+ // way to send. What the desktop app does keep is a replica of what it has
1337
+ // synced, under ~/Library/Application Support/Grok Bot/sand-client-persistence,
1338
+ // one JSON blob per store key with the key base32 encoded into the filename.
1339
+ // Two keys matter:
1340
+ // <account>.roster.last-roster one row per Bot
1341
+ // <account>.transcript.replicas.<id> that Bot's conversation
1342
+ //
1343
+ // Read only, deliberately. The replica is the app's cache and not the source of
1344
+ // truth (that lives on the VM at /home/box/sand-data/agents/<id>/store.db), so
1345
+ // a message written in here is a message the app overwrites rather than sends.
1346
+ //
1347
+ // It also only advances while the desktop app is running. Bots keep working
1348
+ // with the app closed and the island cannot see it, so a Bot left mid turn is
1349
+ // the app having quit, not the Bot being stuck: busy expires on staleness the
1350
+ // same way the other journal-freshness providers do.
1351
+
1352
+ const GROKBOT_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'Grok Bot', 'sand-client-persistence');
1353
+ const GROKBOT_BUSY_STALE_MS = 5 * 60 * 1000;
1354
+
1355
+ // RFC 4648 base32, lowercase and unpadded, which is how the app names blobs.
1356
+ // A name that is not one of ours decodes to junk and simply matches nothing,
1357
+ // so guessing wrong costs a string compare.
1358
+ const B32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
1359
+ function b32decode(name) {
1360
+ let bits = 0;
1361
+ let value = 0;
1362
+ const out = [];
1363
+ for (const ch of name.toLowerCase()) {
1364
+ if (ch === '=') break;
1365
+ const i = B32_ALPHABET.indexOf(ch);
1366
+ if (i < 0) return '';
1367
+ value = (value << 5) | i;
1368
+ bits += 5;
1369
+ if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
1370
+ }
1371
+ return Buffer.from(out).toString('utf8');
1372
+ }
1373
+
1374
+ // The names are long and never change, so decode each one once.
1375
+ const grokbotKeys = new Map(); // filename -> decoded store key
1376
+ const grokbotKeyOf = (name) => {
1377
+ if (!grokbotKeys.has(name)) grokbotKeys.set(name, b32decode(name.replace(/\.blob$/, '')));
1378
+ return grokbotKeys.get(name);
1379
+ };
1380
+
1381
+ const grokbotEntriesOf = (file) => {
1382
+ const entries = readJson(file)?.value?.entries;
1383
+ return Array.isArray(entries) ? entries : [];
1384
+ };
1385
+
1386
+ // Two entry kinds carry a turn: a typed one is {kind:'message', role, content}
1387
+ // and a Bot one is {kind:'send-message', message:{type,content}}. Everything
1388
+ // else in the stream (tool calls, approvals, status) has no place in a phone
1389
+ // transcript.
1390
+ function grokbotMessages(entries, limit = 80) {
1391
+ const out = [];
1392
+ for (const e of entries) {
1393
+ let role = null;
1394
+ let text = '';
1395
+ if (e?.kind === 'message' && (e.role === 'user' || e.role === 'assistant')) {
1396
+ role = e.role;
1397
+ text = typeof e.content === 'string' ? e.content : '';
1398
+ } else if (e?.kind === 'send-message' && e.message?.type === 'text') {
1399
+ role = 'assistant';
1400
+ text = typeof e.message.content === 'string' ? e.message.content : '';
1401
+ }
1402
+ text = text.trim();
1403
+ if (!role || !text) continue;
1404
+ out.push({ role, text: text.slice(0, 4000), ts: isoOf(e.timestampMs) });
1405
+ }
1406
+ return out.slice(-limit);
1407
+ }
1408
+
1409
+ // One JSON object rather than a journal, so the tail slice every other provider
1410
+ // reads with would just be a syntax error. Whole file, with a ceiling that only
1411
+ // exists so a pathological blob cannot stall the daemon.
1412
+ function readGrokBotMessages(file, maxBytes = 512 * 1024, limit = 80) {
1413
+ try { if (fs.statSync(file).size > 8 * 1024 * 1024) return []; } catch { return []; }
1414
+ return grokbotMessages(grokbotEntriesOf(file), limit);
1415
+ }
1416
+
1417
+ function scanGrokBotSessions() {
1418
+ const sessions = [];
1419
+ let files = [];
1420
+ try { files = fs.readdirSync(GROKBOT_DIR); } catch { return sessions; }
1421
+
1422
+ let rosterFile = null;
1423
+ const transcripts = new Map(); // botId -> blob path
1424
+ for (const name of files) {
1425
+ if (!name.endsWith('.blob')) continue;
1426
+ const key = grokbotKeyOf(name);
1427
+ if (key.endsWith('.roster.last-roster')) rosterFile = path.join(GROKBOT_DIR, name);
1428
+ else {
1429
+ const m = key.match(/\.transcript\.replicas\.([0-9a-f-]{36})$/);
1430
+ if (m) transcripts.set(m[1], path.join(GROKBOT_DIR, name));
1431
+ }
1432
+ }
1433
+ const rows = rosterFile ? readJson(rosterFile)?.value?.rows : null;
1434
+ if (!Array.isArray(rows)) return sessions;
1435
+
1436
+ for (const row of rows) {
1437
+ // A Bot is a standing teammate rather than a session that ends, so the
1438
+ // roster is mirrored as it stands: what the Grok Bot sidebar lists, the
1439
+ // island shows.
1440
+ if (!row?.id || row.isHiddenFromSidebar) continue;
1441
+ const file = transcripts.get(row.id) || null;
1442
+ const scan = file
1443
+ ? memoScan(`grokbot:${file}`, file, () => {
1444
+ const entries = grokbotEntriesOf(file);
1445
+ return { messages: grokbotMessages(entries, 1), streaming: entries.some((e) => e?.isStreaming === true) };
1446
+ })
1447
+ : { messages: [], streaming: false };
1448
+ let updatedAt = row.lastActivityAt || row.updatedAt || 0;
1449
+ if (file) { try { updatedAt = Math.max(updatedAt, fs.statSync(file).mtimeMs); } catch {} }
1450
+ const busy = scan.streaming && Date.now() - updatedAt < GROKBOT_BUSY_STALE_MS;
1451
+ if (file) transcriptCache.set(row.id, file);
1452
+ sessions.push({
1453
+ id: row.id,
1454
+ provider: 'grokbot',
1455
+ name: row.name || 'Bot',
1456
+ // Its working directory is on a machine that is not this one, and
1457
+ // claiming otherwise would put a path on the island you cannot open.
1458
+ cwd: '',
1459
+ project: 'Grok Bot',
1460
+ // awaitingUserResponse is how the app marks a Bot stopped on you, which
1461
+ // is the same pink invader as a held permission request.
1462
+ status: row.awaitingUserResponse ? 'waiting' : (busy ? 'busy' : 'idle'),
1463
+ updatedAt,
1464
+ startedAt: row.createdAt || 0,
1465
+ lastMessage: scan.messages.length ? scan.messages[scan.messages.length - 1] : null,
1466
+ tty: '',
1467
+ live: false,
1468
+ // Nothing reads this yet. It is what a build that greys out the compose
1469
+ // box will key on, instead of re-deriving read-only from the provider.
1470
+ readOnly: true,
1471
+ });
1472
+ }
1473
+ return sessions;
1474
+ }
1475
+
1476
+ let grokbotCache = { at: 0, sessions: [] };
1477
+ function readGrokBotSessions() {
1478
+ if (Date.now() - grokbotCache.at < 10_000) return grokbotCache.sessions;
1479
+ grokbotCache.at = Date.now();
1480
+ grokbotCache.sessions = scanGrokBotSessions();
1481
+ return grokbotCache.sessions;
1482
+ }
1483
+
1484
+ /// The phone-facing shape of a held request.
1485
+ const askPayload = (ask, queued) => ({
1486
+ id: ask.id,
1487
+ tool: ask.tool,
1488
+ line: ask.line,
1489
+ detail: ask.detail,
1490
+ at: ask.at,
1491
+ queued,
1492
+ });
1493
+
1022
1494
  function readSessions() {
1023
- const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
1495
+ const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions(), ...readGrokBotSessions()];
1496
+ const known = new Set(sessions.map((s) => s.id));
1497
+ for (const s of sessions) {
1498
+ const queue = askQueue.get(s.id);
1499
+ const ask = queue?.length ? pendingAsks.get(queue[0]) : null;
1500
+ if (!ask) continue;
1501
+ // A held permission outranks busy: the session is stopped on a human, and
1502
+ // that is the whole reason the invader turns pink on the island.
1503
+ s.status = 'waiting';
1504
+ s.ask = askPayload(ask, queue.length);
1505
+ }
1506
+
1507
+ // A held request whose session is not in the fleet would never reach the
1508
+ // phone, and one that cannot be seen cannot be answered, which strands the
1509
+ // session until the hold expires. It happens for real: the session file is
1510
+ // written slightly after the first tool call, a scan races a partial write,
1511
+ // or the request comes from a session kind we do not discover. Surface the
1512
+ // request on its own rather than dropping it.
1513
+ for (const [sessionId, queue] of askQueue) {
1514
+ if (known.has(sessionId) || !queue.length) continue;
1515
+ const ask = pendingAsks.get(queue[0]);
1516
+ if (!ask) continue;
1517
+ sessions.push({
1518
+ id: sessionId,
1519
+ provider: 'claude',
1520
+ name: path.basename(ask.cwd || '') || 'session',
1521
+ cwd: ask.cwd || '',
1522
+ project: path.basename(ask.cwd || '') || '?',
1523
+ status: 'waiting',
1524
+ updatedAt: ask.at,
1525
+ startedAt: ask.at,
1526
+ lastMessage: null,
1527
+ tty: '',
1528
+ live: false,
1529
+ ask: askPayload(ask, queue.length),
1530
+ });
1531
+ }
1532
+
1024
1533
  sessions.sort((a, b) => a.startedAt - b.startedAt);
1025
1534
  return sessions;
1026
1535
  }
1027
1536
 
1537
+ // ---------- remote approvals: runtime ----------
1538
+ // The hook connects here over a unix socket and blocks until this daemon
1539
+ // answers. Answering null means "no decision", which puts the prompt back on
1540
+ // the Mac exactly as it behaves without Ground Control installed.
1541
+
1542
+ const ASK_SOCKET = path.join(CONF_DIR, 'approvals.sock');
1543
+
1544
+ /// What the phone needs to render the approvals control without knowing
1545
+ /// anything about hooks or settings files. Cached: a snapshot goes out every
1546
+ /// 1.5s and this reads and parses settings.json.
1547
+ let hostCache = { at: 0, value: null };
1548
+ function hostState() {
1549
+ if (Date.now() - hostCache.at < 5000 && hostCache.value) return hostCache.value;
1550
+ const settings = readClaudeSettings();
1551
+ const entries = Array.isArray(settings?.hooks?.PermissionRequest) ? settings.hooks.PermissionRequest : [];
1552
+ const value = {
1553
+ version: VERSION,
1554
+ approvals: {
1555
+ // Present at all means this daemon understands approvals; an older one
1556
+ // simply omits the whole field and the app can say so.
1557
+ installed: entries.some((e) => isOurHook(commandsOf(e))),
1558
+ mode: approvalConfig().mode,
1559
+ // Another app answering the same event can overrule a tap, so the phone
1560
+ // should be able to say that out loud.
1561
+ others: entries.filter((e) => !isOurHook(commandsOf(e))).length,
1562
+ // A settings.json we cannot parse must not be silently overwritten, and
1563
+ // the phone should explain why the toggle will not work.
1564
+ settingsOk: settings !== null,
1565
+ socketOk: approvalServerUp,
1566
+ },
1567
+ updateAvailable: availableUpdate,
1568
+ };
1569
+ hostCache = { at: Date.now(), value };
1570
+ return value;
1571
+ }
1572
+ const bustHostCache = () => { hostCache.at = 0; };
1573
+
1574
+ let approvalServerUp = false;
1575
+ let availableUpdate = null; // version string once a newer release is seen
1576
+
1577
+ const pendingAsks = new Map(); // askId -> ask record
1578
+ const askQueue = new Map(); // sessionId -> [askId], oldest first
1579
+ let askSeq = 0;
1580
+
1581
+ /// Seconds since the last keyboard or mouse event on this Mac. A failure reads
1582
+ /// as 0 (you are here), which keeps prompts on the Mac rather than silently
1583
+ /// routing them to a phone that may not be watching.
1584
+ let idleCache = { at: 0, seconds: 0 };
1585
+ function macIdleSeconds() {
1586
+ if (process.platform !== 'darwin') return Infinity; // no signal; treat a headless box as away
1587
+ if (Date.now() - idleCache.at < 2000) return idleCache.seconds;
1588
+ let seconds = 0;
1589
+ try {
1590
+ const out = execSync("ioreg -c IOHIDSystem -d 1 | awk '/HIDIdleTime/ {print $NF; exit}'", {
1591
+ timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
1592
+ }).toString().trim();
1593
+ const ns = Number(out);
1594
+ if (Number.isFinite(ns) && ns >= 0) seconds = ns / 1e9;
1595
+ } catch {}
1596
+ idleCache = { at: Date.now(), seconds };
1597
+ return seconds;
1598
+ }
1599
+
1600
+ // Deliberately not a live presence check. iOS suspends the websocket the
1601
+ // moment the app is backgrounded, so a phone in a pocket looks offline, which
1602
+ // is exactly the situation this feature exists for. What matters is whether
1603
+ // this Mac belongs to someone who actually uses the app, so a request is worth
1604
+ // holding until they pick the phone up. Walking back to the Mac releases it
1605
+ // within seconds either way, so a hold costs a paired user nothing.
1606
+ const PHONE_SEEN_FILE = path.join(CONF_DIR, 'phone-seen');
1607
+ const PHONE_KNOWN_MS = 30 * 24 * 60 * 60 * 1000;
1608
+
1609
+ let lastPhoneWrite = 0;
1610
+ function notePhoneSeen() {
1611
+ if (Date.now() - lastPhoneWrite < 60_000) return; // the phone heartbeats; do not churn the disk
1612
+ lastPhoneWrite = Date.now();
1613
+ try { fs.writeFileSync(PHONE_SEEN_FILE, String(Date.now())); } catch {}
1614
+ }
1615
+
1616
+ function phoneKnown() {
1617
+ try { return Date.now() - Number(fs.readFileSync(PHONE_SEEN_FILE, 'utf8').trim()) < PHONE_KNOWN_MS; }
1618
+ catch { return false; }
1619
+ }
1620
+
1621
+ function holdPolicy() {
1622
+ const cfg = approvalConfig();
1623
+ if (cfg.mode === 'off') return { hold: false, why: 'remote approvals are off' };
1624
+ if (!phoneKnown()) return { hold: false, why: 'no phone has paired with this Mac' };
1625
+ if (cfg.mode === 'always') return { hold: true };
1626
+ const idle = macIdleSeconds();
1627
+ if (idle < cfg.awayAfterSeconds) return { hold: false, why: `you are at the Mac (idle ${Math.round(idle)}s)` };
1628
+ return { hold: true };
1629
+ }
1630
+
1631
+ const clip = (v, n) => {
1632
+ const s = typeof v === 'string' ? v : (v == null ? '' : JSON.stringify(v));
1633
+ return s.length > n ? `${s.slice(0, n)}\n...` : s;
1634
+ };
1635
+
1636
+ /// Reduce a tool call to the one line that identifies it plus a longer preview.
1637
+ /// Tool inputs can carry whole file contents, and every byte here is encrypted,
1638
+ /// relayed, and rendered on a phone.
1639
+ function summarizeTool(tool, input) {
1640
+ const i = input && typeof input === 'object' ? input : {};
1641
+ switch (tool) {
1642
+ case 'Bash':
1643
+ case 'BashOutput':
1644
+ return { line: clip(i.command, 400), detail: clip(i.description, 300) };
1645
+ case 'Write':
1646
+ return { line: clip(i.file_path, 300), detail: clip(i.content, 1000) };
1647
+ case 'Edit':
1648
+ return {
1649
+ line: clip(i.file_path, 300),
1650
+ detail: `${clip(i.old_string, 500)}\n\n->\n\n${clip(i.new_string, 500)}`,
1651
+ };
1652
+ case 'Read':
1653
+ return { line: clip(i.file_path, 300), detail: '' };
1654
+ case 'WebFetch':
1655
+ return { line: clip(i.url, 300), detail: clip(i.prompt, 300) };
1656
+ case 'WebSearch':
1657
+ return { line: clip(i.query, 300), detail: '' };
1658
+ default: {
1659
+ // Unknown or MCP tools: show whichever field reads most like a target,
1660
+ // and fall back to the whole input.
1661
+ const pick = i.command || i.file_path || i.path || i.url || i.query || i.pattern;
1662
+ return { line: clip(pick || tool, 300), detail: clip(i, 1000) };
1663
+ }
1664
+ }
1665
+ }
1666
+
1667
+ function dropAsk(ask) {
1668
+ pendingAsks.delete(ask.id);
1669
+ const queue = askQueue.get(ask.sessionId);
1670
+ if (!queue) return;
1671
+ const at = queue.indexOf(ask.id);
1672
+ if (at > -1) queue.splice(at, 1);
1673
+ if (!queue.length) askQueue.delete(ask.sessionId);
1674
+ }
1675
+
1676
+ /// Answer a held request exactly once. A null decision releases it back to the
1677
+ /// Mac; the hook prints nothing and Claude Code prompts in the terminal.
1678
+ function settleAsk(ask, decision, outcome, note) {
1679
+ if (ask.done) return;
1680
+ ask.done = true;
1681
+ clearTimeout(ask.holdTimer);
1682
+ dropAsk(ask);
1683
+ try { ask.socket.write(JSON.stringify({ decision: decision || null }) + '\n'); } catch {}
1684
+ try { ask.socket.end(); } catch {}
1685
+ console.log(`[daemon] permission ${ask.tool} ${outcome}${note ? `: ${note}` : ''}`);
1686
+ if (ws?.readyState === 1) {
1687
+ ws.send(seal({ type: 'askResolved', askId: ask.id, sessionId: ask.sessionId, outcome, note: note || '' }));
1688
+ }
1689
+ sendSnapshot(true);
1690
+ }
1691
+
1692
+ function onPermissionRequest(req, socket) {
1693
+ const gate = holdPolicy();
1694
+ if (!gate.hold) {
1695
+ console.log(`[daemon] permission ${req.toolName} left on the Mac: ${gate.why}`);
1696
+ try { socket.write('{"decision":null}\n'); } catch {}
1697
+ try { socket.end(); } catch {}
1698
+ return;
1699
+ }
1700
+ const { line, detail } = summarizeTool(req.toolName, req.toolInput);
1701
+ const ask = {
1702
+ id: `${Date.now().toString(36)}-${++askSeq}`,
1703
+ sessionId: req.sessionId,
1704
+ tool: req.toolName || 'tool',
1705
+ line,
1706
+ detail,
1707
+ cwd: req.cwd || '',
1708
+ at: Date.now(),
1709
+ socket,
1710
+ done: false,
1711
+ };
1712
+ ask.holdTimer = setTimeout(
1713
+ () => settleAsk(ask, null, 'released', 'held too long, prompting on the Mac instead'),
1714
+ Math.max(1, approvalConfig().holdMinutes) * 60_000,
1715
+ );
1716
+ // The hook dying (session quit, Ctrl+C, Claude restarted) closes the socket.
1717
+ // Without this the card would linger on the phone with nothing behind it.
1718
+ socket.on('close', () => settleAsk(ask, null, 'gone', 'the session stopped waiting'));
1719
+ socket.on('error', () => {});
1720
+ pendingAsks.set(ask.id, ask);
1721
+ askQueue.set(ask.sessionId, [...(askQueue.get(ask.sessionId) || []), ask.id]);
1722
+ console.log(`[daemon] permission held for the phone: ${ask.tool} ${ask.line.slice(0, 60)}`);
1723
+ sendSnapshot(true);
1724
+ }
1725
+
1726
+ function startApprovalServer(attempt = 0) {
1727
+ // sun_path is 104 bytes on macOS. Over that, the bind fails in ways that
1728
+ // read as EADDRINUSE rather than "path too long", so say it plainly.
1729
+ if (Buffer.byteLength(ASK_SOCKET) > 100) {
1730
+ console.log(`[daemon] home path too long for the approvals socket, permission prompts stay on the Mac: ${ASK_SOCKET}`);
1731
+ return;
1732
+ }
1733
+ // A hard killed daemon leaves the socket file behind and bind fails with
1734
+ // EADDRINUSE, so clear it first. If the bind still fails, another daemon is
1735
+ // genuinely listening; it is about to be displaced on the relay and exit, so
1736
+ // retry a few times rather than losing approvals until the next restart.
1737
+ try { fs.unlinkSync(ASK_SOCKET); } catch {}
1738
+ const server = net.createServer((socket) => {
1739
+ socket.setEncoding('utf8');
1740
+ let buf = '';
1741
+ let handled = false;
1742
+ socket.on('data', (chunk) => {
1743
+ if (handled) return;
1744
+ buf += chunk;
1745
+ const nl = buf.indexOf('\n');
1746
+ if (nl === -1) {
1747
+ if (buf.length > 2_000_000) socket.destroy(); // a tool input this big is not real
1748
+ return;
1749
+ }
1750
+ handled = true;
1751
+ let req;
1752
+ try { req = JSON.parse(buf.slice(0, nl)); } catch { req = null; }
1753
+ if (!req || !req.sessionId) {
1754
+ try { socket.write('{"decision":null}\n'); } catch {}
1755
+ try { socket.end(); } catch {}
1756
+ return;
1757
+ }
1758
+ onPermissionRequest(req, socket);
1759
+ });
1760
+ socket.on('error', () => {});
1761
+ });
1762
+ server.on('error', (e) => {
1763
+ if (e.code === 'EADDRINUSE' && attempt < 5) {
1764
+ setTimeout(() => startApprovalServer(attempt + 1), 1000);
1765
+ return;
1766
+ }
1767
+ console.log(`[daemon] approvals socket unavailable, permission prompts stay on the Mac: ${e.message}`);
1768
+ });
1769
+ server.listen(ASK_SOCKET, () => {
1770
+ try { fs.chmodSync(ASK_SOCKET, 0o600); } catch {}
1771
+ approvalServerUp = true;
1772
+ bustHostCache();
1773
+ console.log(`[daemon] remote approvals ready (mode: ${approvalConfig().mode})`);
1774
+ });
1775
+ }
1776
+
1777
+ // Claude Code's own status for each session, as last seen on disk.
1778
+ const lastRawStatus = new Map(); // sessionId -> 'busy' | 'idle' | 'waiting' | ...
1779
+
1780
+ /// Clear a held request that was answered on the Mac.
1781
+ ///
1782
+ /// The socket closing is the fast path, but it cannot be relied on: Claude
1783
+ /// Code does not always kill the hook process when the human answers in the
1784
+ /// terminal, and a hook nobody killed keeps its socket open, so the phone
1785
+ /// would keep showing a card for a question that is already settled.
1786
+ ///
1787
+ /// Claude Code writes "waiting" into its own session file while a prompt is
1788
+ /// on screen, so that dropping back to busy or idle is the real signal that
1789
+ /// the prompt is gone. The grace period covers the gap between our hook being
1790
+ /// called and Claude getting round to writing the status.
1791
+ const ANSWERED_GRACE_MS = 6000;
1792
+ function releaseAnsweredOnMac() {
1793
+ if (!pendingAsks.size) return;
1794
+ for (const ask of [...pendingAsks.values()]) {
1795
+ if (Date.now() - ask.at < ANSWERED_GRACE_MS) continue;
1796
+ const status = lastRawStatus.get(ask.sessionId);
1797
+ // Unknown session: nothing to conclude, leave it to the socket or the cap.
1798
+ if (!status || status === 'waiting') continue;
1799
+ settleAsk(ask, null, 'gone', 'answered on the Mac');
1800
+ }
1801
+ }
1802
+
1803
+ /// Sitting back down at the Mac clears the phone card. Not because the Mac
1804
+ /// needs it back: Claude Code shows its own terminal prompt the whole time a
1805
+ /// hook is running (verified on 2.1.220), so the Mac is never blocked and can
1806
+ /// always answer. This just stops the phone showing a request you are about
1807
+ /// to deal with in front of you.
1808
+ function releaseAsksIfBack() {
1809
+ if (!pendingAsks.size) return;
1810
+ const cfg = approvalConfig();
1811
+ if (cfg.mode !== 'away') return;
1812
+ if (macIdleSeconds() >= cfg.awayAfterSeconds) return;
1813
+ for (const ask of [...pendingAsks.values()]) {
1814
+ settleAsk(ask, null, 'released', 'you came back to the Mac');
1815
+ }
1816
+ }
1817
+
1818
+ // ---------- self update ----------
1819
+ // The service runs from a frozen copy under ~/.groundcontrol/service, so
1820
+ // without this a user who installed once never sees another release. Checked
1821
+ // daily, always announced in the log and surfaced in the app.
1822
+
1823
+ const UPDATE_CHECK_MS = 24 * 60 * 60 * 1000;
1824
+ const UPDATE_STAMP = path.join(CONF_DIR, 'last-update-check');
1825
+
1826
+ /// Compare dotted numeric versions. Anything unparseable sorts as older, so a
1827
+ /// junk value from the registry can never trigger an update.
1828
+ function isNewer(candidate, current) {
1829
+ const parse = (v) => String(v).split('.').map((n) => Number(n));
1830
+ const a = parse(candidate), b = parse(current);
1831
+ if (a.length !== 3 || a.some((n) => !Number.isInteger(n) || n < 0)) return false;
1832
+ for (let i = 0; i < 3; i++) {
1833
+ if ((a[i] || 0) > (b[i] || 0)) return true;
1834
+ if ((a[i] || 0) < (b[i] || 0)) return false;
1835
+ }
1836
+ return false;
1837
+ }
1838
+
1839
+ async function latestPublishedVersion() {
1840
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
1841
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
1842
+ signal: AbortSignal.timeout(15_000),
1843
+ });
1844
+ if (!res.ok) throw new Error(`registry ${res.status}`);
1845
+ const body = await res.json();
1846
+ if (!body?.version) throw new Error('registry response had no version');
1847
+ return body.version;
1848
+ }
1849
+
1850
+ /// Re-install the service from the newly published version. Runs detached
1851
+ /// because `service install` boots this very process out partway through;
1852
+ /// launchd then starts the replacement. installService stages everything
1853
+ /// before it touches the running service, so a failure here leaves the
1854
+ /// current daemon in place rather than nothing at all.
1855
+ function applyUpdate(version) {
1856
+ console.log(`[daemon] updating to ${version}`);
1857
+ const child = spawn('npx', ['-y', `${PACKAGE}@${version}`, 'service', 'install'], {
1858
+ detached: true,
1859
+ stdio: ['ignore', fs.openSync(LOG_FILE, 'a'), fs.openSync(LOG_FILE, 'a')],
1860
+ env: process.env,
1861
+ });
1862
+ child.unref();
1863
+ }
1864
+
1865
+ async function checkForUpdate({ force = false } = {}) {
1866
+ // Only the launchd copy updates itself. A daemon someone is running by hand
1867
+ // in a terminal must never swap itself out from under them.
1868
+ if (!runsFromServiceDir && !force) return;
1869
+ try {
1870
+ const last = Number(fs.readFileSync(UPDATE_STAMP, 'utf8').trim());
1871
+ if (!force && Number.isFinite(last) && Date.now() - last < UPDATE_CHECK_MS) return;
1872
+ } catch {}
1873
+ try { fs.writeFileSync(UPDATE_STAMP, String(Date.now())); } catch {}
1874
+ let latest;
1875
+ try { latest = await latestPublishedVersion(); }
1876
+ catch (e) { console.log(`[daemon] update check failed: ${e.message}`); return; }
1877
+ if (!isNewer(latest, VERSION)) return;
1878
+ availableUpdate = latest;
1879
+ bustHostCache();
1880
+ sendSnapshot(true);
1881
+ console.log(`[daemon] a newer version is available: ${latest} (running ${VERSION})`);
1882
+ // Held requests belong to sessions that are about to lose their socket.
1883
+ releaseAllAsks();
1884
+ applyUpdate(latest);
1885
+ }
1886
+
1887
+ /// A daemon shutting down must hand every held request back rather than leave
1888
+ /// sessions blocked on a socket that will never answer.
1889
+ function releaseAllAsks() {
1890
+ for (const ask of [...pendingAsks.values()]) {
1891
+ settleAsk(ask, null, 'released', 'the daemon stopped');
1892
+ }
1893
+ }
1894
+
1028
1895
  // ---------- relay connection ----------
1029
1896
 
1030
1897
  let ws = null;
@@ -1038,11 +1905,15 @@ function sendSnapshot(force = false) {
1038
1905
  console.log(`[daemon] snapshot failed: ${e.message}`);
1039
1906
  return;
1040
1907
  }
1041
- const json = JSON.stringify(sessions.map(({ lastMessage, ...s }) => ({ ...s, last: lastMessage?.text?.slice(0, 120) })));
1908
+ for (const s of sessions) if (s.rawStatus) lastRawStatus.set(s.id, s.rawStatus);
1909
+ const host = hostState();
1910
+ // Host state joins the dedupe key, otherwise flipping approvals on would not
1911
+ // reach a phone whose session list happened not to change.
1912
+ const json = JSON.stringify([sessions.map(({ lastMessage, ...s }) => ({ ...s, last: lastMessage?.text?.slice(0, 120) })), host]);
1042
1913
  if (!force && json === lastSnapshotJson) return;
1043
1914
  lastSnapshotJson = json;
1044
1915
  if (ws?.readyState === 1) {
1045
- ws.send(seal({ type: 'snapshot', at: Date.now(), sessions }));
1916
+ ws.send(seal({ type: 'snapshot', at: Date.now(), sessions, host }));
1046
1917
  console.log(`[daemon] snapshot -> ${sessions.length} session(s): ${sessions.map((s) => `${s.name}:${s.status}`).join(', ') || 'none'}`);
1047
1918
  }
1048
1919
  }
@@ -1056,7 +1927,14 @@ function watchTranscript(sessionId) {
1056
1927
  if (!file) return;
1057
1928
  let debounce;
1058
1929
  try {
1059
- const w = fs.watch(file, () => {
1930
+ // Journals are appended to, so watching the file is enough. Grok Bot
1931
+ // rewrites its replica blob wholesale, and a rename leaves a file watch
1932
+ // bound to an inode nothing writes to again: watch the directory instead
1933
+ // and filter, which survives the swap.
1934
+ const inGrokBot = file.startsWith(GROKBOT_DIR + path.sep);
1935
+ const target = inGrokBot ? GROKBOT_DIR : file;
1936
+ const w = fs.watch(target, (_ev, changed) => {
1937
+ if (inGrokBot && changed && changed !== path.basename(file)) return;
1060
1938
  clearTimeout(debounce);
1061
1939
  debounce = setTimeout(() => {
1062
1940
  ws?.send(seal({ type: 'transcript', sessionId, messages: readMessages(sessionId) }));
@@ -1079,6 +1957,46 @@ async function handleCommand(cmd) {
1079
1957
  ws?.send(seal({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) }));
1080
1958
  } else if (cmd.type === 'unwatch' && cmd.sessionId) {
1081
1959
  unwatchTranscript(cmd.sessionId);
1960
+ } else if (cmd.type === 'setApprovals') {
1961
+ // A deliberate tap in the app, with the app explaining what it adds, is
1962
+ // the consent for this. A backup is still written before any change.
1963
+ let result;
1964
+ if (cmd.enabled === false) {
1965
+ writeApprovalConfig({ mode: 'off' });
1966
+ result = uninstallApprovalHook();
1967
+ console.log('[daemon] remote approvals turned off from the phone');
1968
+ } else {
1969
+ const mode = ['away', 'always'].includes(cmd.mode) ? cmd.mode : 'away';
1970
+ writeApprovalConfig({ mode });
1971
+ result = installApprovalHook({ consent: true });
1972
+ console.log(`[daemon] remote approvals turned on from the phone (mode: ${mode})`);
1973
+ }
1974
+ bustHostCache();
1975
+ sendSnapshot(true);
1976
+ ws?.send(seal({
1977
+ type: 'approvalsResult',
1978
+ ok: !!result.ok,
1979
+ error: result.ok ? '' : (result.reason || 'could not change the setting'),
1980
+ host: hostState(),
1981
+ }));
1982
+ } else if (cmd.type === 'approve' || cmd.type === 'deny') {
1983
+ const ask = cmd.askId ? pendingAsks.get(cmd.askId) : null;
1984
+ if (!ask) {
1985
+ // Answered on the Mac, expired, or the session moved on while the tap
1986
+ // was in flight. Tell the phone so the card clears instead of hanging.
1987
+ return ws?.send(seal({
1988
+ type: 'askResolved', askId: cmd.askId || '', sessionId: cmd.sessionId || '',
1989
+ outcome: 'gone', note: 'that request is no longer waiting',
1990
+ }));
1991
+ }
1992
+ if (cmd.type === 'approve') {
1993
+ settleAsk(ask, { behavior: 'allow', ...(cmd.always ? { alwaysAllow: true } : {}) },
1994
+ 'allowed', cmd.always ? 'from the phone, and always from now on' : 'from the phone');
1995
+ } else {
1996
+ const reason = (cmd.text || '').trim();
1997
+ settleAsk(ask, { behavior: 'deny', message: reason || 'Denied from the Ground Control app.' },
1998
+ 'denied', reason ? `from the phone: ${reason.slice(0, 60)}` : 'from the phone');
1999
+ }
1082
2000
  } else if (cmd.type === 'ping' && cmd.sessionId) {
1083
2001
  const s = readSessions().find((x) => x.id === cmd.sessionId);
1084
2002
  ws?.send(seal({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() }));
@@ -1086,6 +2004,15 @@ async function handleCommand(cmd) {
1086
2004
  const session = readSessions().find((s) => s.id === cmd.sessionId);
1087
2005
  if (!session) return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' }));
1088
2006
 
2007
+ // A Bot lives on a cloud VM this daemon has no channel to. Saying so beats
2008
+ // a send that looks accepted and arrives nowhere.
2009
+ if (session.provider === 'grokbot') {
2010
+ return ws?.send(seal({
2011
+ type: 'promptResult', sessionId: cmd.sessionId, ok: false,
2012
+ error: 'Grok Bot runs in the cloud, so Ground Control can only watch it. Open the Grok Bot app to reply.',
2013
+ }));
2014
+ }
2015
+
1089
2016
  if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
1090
2017
 
1091
2018
  // Preferred path: type into the real terminal so the desktop updates live.
@@ -1107,7 +2034,8 @@ async function handleCommand(cmd) {
1107
2034
  }
1108
2035
  }
1109
2036
  if (session.status === 'busy') return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session is busy, wait for it to go idle' }));
1110
- if (!claudeBin) {
2037
+ const bin = claudeBin();
2038
+ if (!bin) {
1111
2039
  console.log('[daemon] prompt failed: claude binary not found on PATH');
1112
2040
  return ws?.send(seal({
1113
2041
  type: 'promptResult', sessionId: cmd.sessionId, ok: false,
@@ -1117,7 +2045,7 @@ async function handleCommand(cmd) {
1117
2045
  console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
1118
2046
  headlessActive.add(cmd.sessionId);
1119
2047
  sendSnapshot(true);
1120
- const child = spawn(claudeBin, ['--resume', cmd.sessionId, '-p', cmd.text], {
2048
+ const child = spawn(bin, ['--resume', cmd.sessionId, '-p', cmd.text], {
1121
2049
  cwd: fs.existsSync(session.cwd || '') ? session.cwd : os.homedir(),
1122
2050
  env: process.env,
1123
2051
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1156,14 +2084,15 @@ async function handleCommand(cmd) {
1156
2084
  // dash there (the models do not care about leading whitespace).
1157
2085
  const defuse = (text) => (/^\s*-/.test(text) ? ` ${text}` : text);
1158
2086
  const headlessPromptSpec = {
1159
- codex: () => codexBin && { bin: codexBin, args: (id, text) => ['exec', 'resume', id, '--skip-git-repo-check', '--', text] },
1160
- kimicode: () => kimiCodeBin && { bin: kimiCodeBin, args: (id, text) => ['--session', id, '-p', defuse(text)] },
1161
- kimi: () => kimiLegacyBin && { bin: kimiLegacyBin, args: (id, text) => ['--print', '--session', id, '-p', defuse(text)] },
2087
+ codex: () => { const bin = codexBin(); return bin && { bin, args: (id, text) => ['exec', 'resume', id, '--skip-git-repo-check', '--', text] }; },
2088
+ kimicode: () => { const bin = kimiCodeBin(); return bin && { bin, args: (id, text) => ['--session', id, '-p', defuse(text)] }; },
2089
+ kimi: () => { const bin = kimiLegacyBin(); return bin && { bin, args: (id, text) => ['--print', '--session', id, '-p', defuse(text)] }; },
1162
2090
  };
1163
2091
 
1164
2092
  function bustProviderCaches() {
1165
2093
  codexCache.at = 0;
1166
2094
  kimiCache.at = 0;
2095
+ grokbotCache.at = 0;
1167
2096
  }
1168
2097
 
1169
2098
  function promptHeadless(session, text) {
@@ -1176,7 +2105,7 @@ function promptHeadless(session, text) {
1176
2105
  console.log(`[daemon] ${session.provider} prompt failed: binary not found`);
1177
2106
  return ws?.send(seal({
1178
2107
  type: 'promptResult', sessionId: session.id, ok: false,
1179
- error: `The daemon could not find the ${session.provider === 'codex' ? 'codex' : 'kimi'} command on this Mac, so it cannot reach this session.`,
2108
+ error: `The daemon could not find the ${session.provider === 'codex' ? 'codex' : 'kimi'} command on this Mac, so it cannot reach this session. If you just installed it, try again in a minute.`,
1180
2109
  }));
1181
2110
  }
1182
2111
  console.log(`[daemon] prompt -> ${session.provider} ${session.name}: ${text.slice(0, 60)}`);
@@ -1231,9 +2160,20 @@ function connect() {
1231
2160
  ws.on('pong', () => { lastPongAt = Date.now(); });
1232
2161
  ws.on('message', (data) => {
1233
2162
  const str = data.toString();
1234
- if (str === '{"sys":"client-joined"}') { console.log('[daemon] phone connected'); sendSnapshot(true); return; }
2163
+ if (str === '{"sys":"client-joined"}') {
2164
+ notePhoneSeen();
2165
+ console.log('[daemon] phone connected');
2166
+ sendSnapshot(true);
2167
+ return;
2168
+ }
1235
2169
  if (str.startsWith('{"sys"')) return;
1236
- try { handleCommand(open(str))?.catch?.(() => {}); } catch (e) { console.log('[daemon] bad message (wrong key?)'); }
2170
+ // A command that decrypts proves a real paired phone is on the other end,
2171
+ // which is what the approval policy checks before holding a prompt.
2172
+ try {
2173
+ const cmd = open(str);
2174
+ notePhoneSeen();
2175
+ handleCommand(cmd)?.catch?.(() => {});
2176
+ } catch (e) { console.log('[daemon] bad message (wrong key?)'); }
1237
2177
  });
1238
2178
  ws.on('close', (code) => {
1239
2179
  if (code === 4001) {
@@ -1248,6 +2188,32 @@ function connect() {
1248
2188
 
1249
2189
  connect();
1250
2190
  if (process.platform === 'darwin') requestAutomationGrant();
2191
+
2192
+ // Remote approvals. The daemon refreshes the hook script itself, which is our
2193
+ // own file, but never edits ~/.claude/settings.json: a background process is
2194
+ // the last thing that should rewrite another tool's config unasked. Turning it
2195
+ // on is an explicit `groundcontrol approvals on`.
2196
+ try { stageHookFile(); } catch (e) { console.log(`[daemon] could not refresh the permission hook: ${e.message}`); }
2197
+ const hookState = installApprovalHook(); // consent defaults to false: reports, never writes
2198
+ if (hookState.unchanged) console.log('[daemon] permission hook installed, remote approvals available');
2199
+ else if (hookState.needsConsent) console.log('[daemon] permission hook not installed (run: groundcontrol approvals on)');
2200
+ else if (!hookState.ok) console.log(`[daemon] permission hook unavailable: ${hookState.reason}`);
2201
+ for (const cmd of hookState.foreign || []) console.log(`[daemon] another app also answers permission requests: ${cmd}`);
2202
+ startApprovalServer();
2203
+ setInterval(releaseAsksIfBack, 3000);
2204
+ setInterval(releaseAnsweredOnMac, 2000);
2205
+
2206
+ // Self update. Staggered off startup so a login storm does not hit the
2207
+ // registry at once, then re-checked hourly (the daily stamp does the gating).
2208
+ setTimeout(() => { checkForUpdate().catch(() => {}); }, 60_000);
2209
+ setInterval(() => { checkForUpdate().catch(() => {}); }, 60 * 60 * 1000);
2210
+
2211
+ // Held requests are sockets a Claude session is blocked on. Hand them back
2212
+ // before going away, or those sessions sit frozen until the hook times out.
2213
+ for (const sig of ['SIGINT', 'SIGTERM']) {
2214
+ process.on(sig, () => { releaseAllAsks(); process.exit(0); });
2215
+ }
2216
+
1251
2217
  setInterval(sendSnapshot, 1500);
1252
2218
  // Heartbeat traffic keeps the Cloudflare socket alive in both directions and
1253
2219
  // lets the phone detect a dead link; missing pongs force a reconnect here.
@@ -1281,6 +2247,7 @@ function watchProviderDir(dir, cache, label) {
1281
2247
  console.log(`[daemon] ${label}: watching ${dir}`);
1282
2248
  } catch {}
1283
2249
  }
1284
- watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin || 'not found, sessions will be read only'})`);
2250
+ watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin() || 'not found yet, rechecked on each prompt'})`);
1285
2251
  watchProviderDir(path.join(KIMI_DIR, 'sessions'), kimiCache, 'kimi sessions');
1286
2252
  watchProviderDir(path.join(KIMICODE_DIR, 'sessions'), kimiCache, 'kimi code sessions');
2253
+ watchProviderDir(GROKBOT_DIR, grokbotCache, 'grok bot roster (read only, replies happen in the Grok Bot app)');