@clastres/groundcontrol 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/daemon.mjs +1331 -22
  2. package/hook.mjs +109 -0
  3. package/package.json +1 -1
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.7';
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.5');
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.');
@@ -346,10 +635,27 @@ function widenPath() {
346
635
  }
347
636
  widenPath();
348
637
 
349
- let claudeBin = null;
350
- try { claudeBin = execSync('command -v claude', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch {}
638
+ const which = (cmd) => { try { return execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch { return null; } };
639
+ const executable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { return null; } };
640
+
641
+ const claudeBin = which('claude');
351
642
  console.log(`[daemon] claude binary: ${claudeBin || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
352
643
 
644
+ // Codex ships a full CLI inside the ChatGPT desktop app, so Codex Desktop
645
+ // users can receive phone prompts even without a standalone install.
646
+ const codexBin = which('codex') || executable('/Applications/ChatGPT.app/Contents/Resources/codex');
647
+
648
+ // Kimi Code renames a legacy Python `kimi` shim to `kimi-legacy` on install,
649
+ // so when the Kimi Code store (~/.kimi-code) exists, `kimi` on PATH belongs
650
+ // to Kimi Code and `kimi-legacy` is the old CLI. Without the store, `kimi` is
651
+ // the legacy one. The two binaries cannot resume each other's sessions, so
652
+ // neither ever doubles for the other.
653
+ const kimiCodeInstalled = fs.existsSync(path.join(os.homedir(), '.kimi-code'));
654
+ const kimiCodeBin = kimiCodeInstalled
655
+ ? which('kimi') || executable(path.join(os.homedir(), '.kimi-code', 'bin', 'kimi'))
656
+ : null;
657
+ const kimiLegacyBin = which('kimi-legacy') || (kimiCodeInstalled ? null : which('kimi'));
658
+
353
659
  // ---------- Claude Code session discovery ----------
354
660
 
355
661
  const CLAUDE = path.join(os.homedir(), '.claude');
@@ -541,6 +847,11 @@ function extractText(message) {
541
847
  function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
542
848
  const file = findTranscript(sessionId);
543
849
  if (!file) return [];
850
+ // path.sep keeps the prefixes unambiguous: without it ~/.kimi would also
851
+ // match every ~/.kimi-code path and routing would depend on check order.
852
+ if (file.startsWith(CODEX_SESS_DIR + path.sep)) return readCodexMessages(file, maxBytes, limit);
853
+ if (file.startsWith(KIMICODE_DIR + path.sep)) return readKimiCodeMessages(file, maxBytes, limit);
854
+ if (file.startsWith(KIMI_DIR + path.sep)) return readKimiLegacyMessages(file, maxBytes, limit);
544
855
  let raw;
545
856
  try {
546
857
  const size = fs.statSync(file).size;
@@ -566,7 +877,7 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
566
877
  return out.slice(-limit);
567
878
  }
568
879
 
569
- function readSessions() {
880
+ function readClaudeSessions() {
570
881
  const sessions = [];
571
882
  let files = [];
572
883
  try { files = fs.readdirSync(SESSIONS_DIR); } catch {}
@@ -579,10 +890,15 @@ function readSessions() {
579
890
  const tty = ttyOf(s.pid);
580
891
  sessions.push({
581
892
  id: s.sessionId,
893
+ provider: 'claude',
582
894
  name: s.name || path.basename(s.cwd || '?'),
583
895
  cwd: s.cwd || '',
584
896
  project: path.basename(s.cwd || '?'),
585
897
  status: headlessActive.has(s.sessionId) ? 'busy' : (s.status || 'idle'),
898
+ // Claude Code's own view, kept separate because readSessions overwrites
899
+ // status to 'waiting' for a held request. Answering on the Mac is only
900
+ // detectable by watching this drop back out of 'waiting'.
901
+ rawStatus: s.status || 'idle',
586
902
  updatedAt: s.statusUpdatedAt || s.updatedAt || 0,
587
903
  startedAt: s.startedAt || 0,
588
904
  lastMessage: msgs.length ? msgs[msgs.length - 1] : null,
@@ -590,22 +906,845 @@ function readSessions() {
590
906
  live: canInject(tty),
591
907
  });
592
908
  }
909
+ return sessions;
910
+ }
911
+
912
+ // ---------- Codex session discovery ----------
913
+ // Codex (CLI, VS Code extension, and the Codex Desktop app) all write the same
914
+ // rollout JSONL files under ~/.codex/sessions/YYYY/MM/DD/. There is no pid or
915
+ // tty on disk, so a session counts as present while its rollout stays fresh,
916
+ // and busy while its last task_started has no matching end event. Prompts go
917
+ // through `codex exec resume`, which appends to the same rollout file.
918
+
919
+ const CODEX_DIR = path.join(os.homedir(), '.codex');
920
+ const CODEX_SESS_DIR = path.join(CODEX_DIR, 'sessions');
921
+ const CODEX_INDEX = path.join(CODEX_DIR, 'session_index.jsonl');
922
+ const CODEX_ACTIVE_WINDOW_MS = 2 * 60 * 60 * 1000; // rollout idle this long = off the island
923
+ const CODEX_BUSY_STALE_MS = 10 * 60 * 1000; // a working turn keeps writing; silence means it died
924
+
925
+ // Codex Desktop names threads ("Polish Slack message"); the index maps id -> name.
926
+ // The index is append-only and grows for as long as Codex is used, so it is
927
+ // only re-parsed when its size or mtime actually moved.
928
+ let codexNames = new Map();
929
+ let codexNamesAt = 0;
930
+ let codexNamesStat = '';
931
+ function codexThreadNames() {
932
+ if (Date.now() - codexNamesAt < 30_000) return codexNames;
933
+ codexNamesAt = Date.now();
934
+ try {
935
+ const st = fs.statSync(CODEX_INDEX);
936
+ const sig = `${st.size}:${st.mtimeMs}`;
937
+ if (sig === codexNamesStat) return codexNames;
938
+ codexNamesStat = sig;
939
+ for (const line of fs.readFileSync(CODEX_INDEX, 'utf8').split('\n')) {
940
+ if (!line.trim()) continue;
941
+ try {
942
+ const e = JSON.parse(line);
943
+ if (e.id && e.thread_name) codexNames.set(e.id, e.thread_name);
944
+ } catch {}
945
+ }
946
+ } catch {}
947
+ return codexNames;
948
+ }
949
+
950
+ const codexMetaCache = new Map(); // rollout path -> {size, meta} from the first line
951
+
952
+ function codexMetaOf(file) {
953
+ // A parse failure usually means the scan raced Codex mid-write of the first
954
+ // line, so a failed result is only trusted while the file has not grown;
955
+ // caching null forever would hide the session for the daemon's lifetime.
956
+ let size = 0;
957
+ try { size = fs.statSync(file).size; } catch { return null; }
958
+ const cached = codexMetaCache.get(file);
959
+ if (cached && (cached.meta || cached.size === size)) return cached.meta;
960
+ let meta = null;
961
+ try {
962
+ // The first line nests the full system prompt, so it can run past 100KB;
963
+ // keep reading until the line actually ends.
964
+ const fd = fs.openSync(file, 'r');
965
+ const chunks = [];
966
+ const buf = Buffer.alloc(64 * 1024);
967
+ for (let pos = 0; pos < 1024 * 1024; ) {
968
+ const n = fs.readSync(fd, buf, 0, buf.length, pos);
969
+ if (n === 0) break;
970
+ chunks.push(Buffer.from(buf.subarray(0, n)));
971
+ pos += n;
972
+ if (buf.subarray(0, n).includes(0x0a)) break;
973
+ }
974
+ fs.closeSync(fd);
975
+ const j = JSON.parse(Buffer.concat(chunks).toString('utf8').split('\n')[0]);
976
+ if (j.type === 'session_meta' && j.payload?.session_id) {
977
+ meta = {
978
+ id: j.payload.session_id,
979
+ cwd: j.payload.cwd || '',
980
+ startedAt: Date.parse(j.payload.timestamp || j.timestamp) || 0,
981
+ };
982
+ }
983
+ } catch {}
984
+ codexMetaCache.set(file, { size, meta });
985
+ return meta;
986
+ }
987
+
988
+ function tailLines(file, maxBytes) {
989
+ const size = fs.statSync(file).size;
990
+ const fd = fs.openSync(file, 'r');
991
+ const start = Math.max(0, size - maxBytes);
992
+ const buf = Buffer.alloc(size - start);
993
+ fs.readSync(fd, buf, 0, buf.length, start);
994
+ fs.closeSync(fd);
995
+ return buf.toString('utf8').split('\n');
996
+ }
997
+
998
+ // Journal scans re-run on every cache refresh; keying results on (mtime, size)
999
+ // makes the rescan free for every file that did not change since last time.
1000
+ const scanMemo = new Map(); // key -> {m, s, v}
1001
+ function memoScan(key, file, fn) {
1002
+ let st;
1003
+ try { st = fs.statSync(file); } catch { return fn(); }
1004
+ const c = scanMemo.get(key);
1005
+ if (c && c.m === st.mtimeMs && c.s === st.size) return c.v;
1006
+ const v = fn();
1007
+ scanMemo.set(key, { m: st.mtimeMs, s: st.size, v });
1008
+ return v;
1009
+ }
1010
+
1011
+ // The user_message / agent_message events carry only genuinely typed and
1012
+ // spoken turns; injected context blocks only appear as response_items.
1013
+ function readCodexMessages(file, maxBytes = 512 * 1024, limit = 80) {
1014
+ let lines;
1015
+ try { lines = tailLines(file, maxBytes); } catch { return []; }
1016
+ const out = [];
1017
+ for (const line of lines) {
1018
+ if (!line.trim()) continue;
1019
+ let e; try { e = JSON.parse(line); } catch { continue; }
1020
+ if (e.type !== 'event_msg') continue;
1021
+ const p = e.payload || {};
1022
+ if (p.type === 'user_message' && p.message) {
1023
+ out.push({ role: 'user', text: String(p.message).trim().slice(0, 4000), ts: e.timestamp || null });
1024
+ } else if (p.type === 'agent_message' && p.message) {
1025
+ out.push({ role: 'assistant', text: String(p.message).trim().slice(0, 4000), ts: e.timestamp || null });
1026
+ }
1027
+ }
1028
+ return out.slice(-limit);
1029
+ }
1030
+
1031
+ function codexActivityOf(file) {
1032
+ let lines;
1033
+ try { lines = tailLines(file, 64 * 1024); } catch { return { busy: false, sawMarkers: true, lastMessage: null }; }
1034
+ let lastStart = 0, lastEnd = 0, lastMessage = null;
1035
+ for (const line of lines) {
1036
+ if (!line.trim()) continue;
1037
+ let e; try { e = JSON.parse(line); } catch { continue; }
1038
+ if (e.type !== 'event_msg') continue;
1039
+ const p = e.payload || {};
1040
+ const ts = Date.parse(e.timestamp) || 0;
1041
+ if (p.type === 'task_started') lastStart = Math.max(lastStart, ts);
1042
+ else if (p.type === 'task_complete' || p.type === 'task_aborted' || p.type === 'turn_aborted' || p.type === 'error') lastEnd = Math.max(lastEnd, ts);
1043
+ else if (p.type === 'user_message' && p.message) lastMessage = { role: 'user', text: String(p.message).trim().slice(0, 4000), ts: e.timestamp || null };
1044
+ else if (p.type === 'agent_message' && p.message) lastMessage = { role: 'assistant', text: String(p.message).trim().slice(0, 4000), ts: e.timestamp || null };
1045
+ }
1046
+ // No turn markers in the tail means a long streaming turn pushed them out
1047
+ // (a finished turn always ends with its completion event in the tail), so
1048
+ // the caller must treat freshness as the busy signal, not assume idle.
1049
+ return { busy: lastStart > lastEnd, sawMarkers: lastStart > 0 || lastEnd > 0, lastMessage };
1050
+ }
1051
+
1052
+ // Walking every date dir stays cheap at this scale, and a resumed months-old
1053
+ // thread appends to its original file, so no date cutoff during the walk.
1054
+ function codexRolloutFiles() {
1055
+ const files = [];
1056
+ const walk = (dir, depth) => {
1057
+ let entries = [];
1058
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
1059
+ for (const e of entries) {
1060
+ const p = path.join(dir, e.name);
1061
+ if (e.isDirectory() && depth < 3) walk(p, depth + 1);
1062
+ else if (e.isFile() && e.name.endsWith('.jsonl')) files.push(p);
1063
+ }
1064
+ };
1065
+ walk(CODEX_SESS_DIR, 0);
1066
+ return files;
1067
+ }
1068
+
1069
+ let codexCache = { at: 0, sessions: [] };
1070
+ function readCodexSessions() {
1071
+ if (Date.now() - codexCache.at < 10_000) return codexCache.sessions;
1072
+ codexCache.at = Date.now();
1073
+ const names = codexThreadNames();
1074
+ const sessions = [];
1075
+ for (const file of codexRolloutFiles()) {
1076
+ let mtime;
1077
+ try { mtime = fs.statSync(file).mtimeMs; } catch { continue; }
1078
+ if (Date.now() - mtime > CODEX_ACTIVE_WINDOW_MS) continue;
1079
+ const meta = codexMetaOf(file);
1080
+ if (!meta) continue;
1081
+ const act = memoScan(`codex:${file}`, file, () => codexActivityOf(file));
1082
+ const busy = (act.busy || (!act.sawMarkers && Date.now() - mtime < 60_000))
1083
+ && Date.now() - mtime < CODEX_BUSY_STALE_MS;
1084
+ transcriptCache.set(meta.id, file);
1085
+ sessions.push({
1086
+ id: meta.id,
1087
+ provider: 'codex',
1088
+ name: names.get(meta.id) || path.basename(meta.cwd || '?'),
1089
+ cwd: meta.cwd,
1090
+ project: path.basename(meta.cwd || '?'),
1091
+ status: headlessActive.has(meta.id) || busy ? 'busy' : 'idle',
1092
+ updatedAt: mtime,
1093
+ startedAt: meta.startedAt,
1094
+ lastMessage: act.lastMessage,
1095
+ tty: '',
1096
+ live: false,
1097
+ });
1098
+ }
1099
+ codexCache.sessions = sessions;
1100
+ return sessions;
1101
+ }
1102
+
1103
+ // ---------- Kimi session discovery ----------
1104
+ // Two on-disk generations exist. The legacy Python CLI keeps sessions in
1105
+ // ~/.kimi/sessions/<md5(workdir)>/<uuid>/ with a context.jsonl history and a
1106
+ // wire.jsonl event log (TurnBegin/TurnEnd bracket a working turn). The new
1107
+ // Kimi Code CLI keeps ~/.kimi-code/sessions/<wdKey>/<sessionId>/ with
1108
+ // state.json metadata and a per-agent journal in agents/main/wire.jsonl whose
1109
+ // records are {type, time, ...payload}. Neither writes a pid, so presence and
1110
+ // busyness come from journal freshness, like Codex.
1111
+
1112
+ const KIMI_DIR = path.join(os.homedir(), '.kimi');
1113
+ const KIMICODE_DIR = path.join(os.homedir(), '.kimi-code');
1114
+ const KIMI_ACTIVE_WINDOW_MS = CODEX_ACTIVE_WINDOW_MS;
1115
+ // Wide enough that a turn quietly grinding through a long tool run (build,
1116
+ // test suite) does not flip to idle and admit a second writer.
1117
+ const KIMI_BUSY_STALE_MS = 5 * 60 * 1000;
1118
+
1119
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
1120
+
1121
+ const kosongText = (content) => {
1122
+ if (typeof content === 'string') return content;
1123
+ if (Array.isArray(content)) return content.filter((p) => p && (p.type === 'text' || p.text)).map((p) => p.text || '').join('\n');
1124
+ return '';
1125
+ };
1126
+
1127
+ // Legacy ~/.kimi: kimi.json maps workdirs to their md5 bucket names.
1128
+ function kimiWorkdirByBucket() {
1129
+ const map = new Map();
1130
+ const meta = readJson(path.join(KIMI_DIR, 'kimi.json'));
1131
+ for (const wd of meta?.work_dirs || []) {
1132
+ if (wd?.path) map.set(crypto.createHash('md5').update(wd.path).digest('hex'), wd.path);
1133
+ }
1134
+ return map;
1135
+ }
1136
+
1137
+ function readKimiLegacyMessages(file, maxBytes = 512 * 1024, limit = 80) {
1138
+ let lines;
1139
+ try { lines = tailLines(file, maxBytes); } catch { return []; }
1140
+ const out = [];
1141
+ for (const line of lines) {
1142
+ if (!line.trim()) continue;
1143
+ let e; try { e = JSON.parse(line); } catch { continue; }
1144
+ if (e.partial || (e.role !== 'user' && e.role !== 'assistant')) continue;
1145
+ const text = kosongText(e.content).trim();
1146
+ if (!text) continue;
1147
+ out.push({ role: e.role, text: text.slice(0, 4000), ts: null });
1148
+ }
1149
+ return out.slice(-limit);
1150
+ }
1151
+
1152
+ function kimiLegacyBusy(wireFile) {
1153
+ let lines;
1154
+ try { lines = tailLines(wireFile, 64 * 1024); } catch { return { busy: false, sawMarkers: true }; }
1155
+ let busy = false, sawMarkers = false;
1156
+ for (const line of lines) {
1157
+ if (!line.trim()) continue;
1158
+ let e; try { e = JSON.parse(line); } catch { continue; }
1159
+ const t = e.message?.type;
1160
+ if (t === 'TurnBegin') { busy = true; sawMarkers = true; }
1161
+ else if (t === 'TurnEnd' || t === 'StepInterrupted') { busy = false; sawMarkers = true; }
1162
+ }
1163
+ return { busy, sawMarkers };
1164
+ }
1165
+
1166
+ function readKimiLegacySessions() {
1167
+ const sessions = [];
1168
+ const root = path.join(KIMI_DIR, 'sessions');
1169
+ let buckets = [];
1170
+ try { buckets = fs.readdirSync(root); } catch { return sessions; }
1171
+ const workdirs = kimiWorkdirByBucket();
1172
+ for (const bucket of buckets) {
1173
+ let ids = [];
1174
+ try { ids = fs.readdirSync(path.join(root, bucket)); } catch { continue; }
1175
+ for (const id of ids) {
1176
+ const dir = path.join(root, bucket, id);
1177
+ const context = path.join(dir, 'context.jsonl');
1178
+ const wire = path.join(dir, 'wire.jsonl');
1179
+ let mtime = 0, started = 0;
1180
+ try {
1181
+ const st = fs.statSync(context);
1182
+ mtime = st.mtimeMs;
1183
+ started = st.birthtimeMs || st.ctimeMs;
1184
+ } catch { continue; }
1185
+ try { mtime = Math.max(mtime, fs.statSync(wire).mtimeMs); } catch {}
1186
+ if (Date.now() - mtime > KIMI_ACTIVE_WINDOW_MS) continue;
1187
+ const cwd = workdirs.get(bucket) || '';
1188
+ const state = readJson(path.join(dir, 'state.json'));
1189
+ if (state?.archived) continue;
1190
+ const msgs = memoScan(`kimi:${context}`, context, () => readKimiLegacyMessages(context, 64 * 1024, 1));
1191
+ const act = memoScan(`kimi:${wire}`, wire, () => kimiLegacyBusy(wire));
1192
+ const busy = (act.busy || (!act.sawMarkers && Date.now() - mtime < 60_000))
1193
+ && Date.now() - mtime < KIMI_BUSY_STALE_MS;
1194
+ transcriptCache.set(id, context);
1195
+ sessions.push({
1196
+ id,
1197
+ provider: 'kimi',
1198
+ name: state?.custom_title || path.basename(cwd || '?'),
1199
+ cwd,
1200
+ project: path.basename(cwd || '?'),
1201
+ status: headlessActive.has(id) || busy ? 'busy' : 'idle',
1202
+ updatedAt: mtime,
1203
+ startedAt: started,
1204
+ lastMessage: msgs.length ? msgs[msgs.length - 1] : null,
1205
+ tty: '',
1206
+ live: false,
1207
+ });
1208
+ }
1209
+ }
1210
+ return sessions;
1211
+ }
1212
+
1213
+ // A truthy but unparseable time value must not throw: this runs inside the
1214
+ // snapshot interval, where an exception would crash-loop the whole daemon.
1215
+ const isoOf = (t) => { try { return t ? new Date(t).toISOString() : null; } catch { return null; } };
1216
+
1217
+ // Kimi Code journal: turn.prompt opens a turn; a text-only assistant message
1218
+ // (no tool calls), a turn.cancel, or an Interrupt closes it.
1219
+ function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
1220
+ let lines;
1221
+ try { lines = tailLines(wireFile, maxBytes); } catch { return { messages: [], busy: false, sawMarkers: true, waiting: false }; }
1222
+ const messages = [];
1223
+ let lastPrompt = 0, lastEnd = 0;
1224
+ const pendingInteractions = new Set();
1225
+ for (const line of lines) {
1226
+ if (!line.trim()) continue;
1227
+ let e; try { e = JSON.parse(line); } catch { continue; }
1228
+ const ts = isoOf(e.time);
1229
+ if (e.type === 'turn.prompt' || e.type === 'turn.steer') {
1230
+ lastPrompt = Math.max(lastPrompt, e.time || 0);
1231
+ const text = kosongText(e.input).trim();
1232
+ if (text) messages.push({ role: 'user', text: text.slice(0, 4000), ts });
1233
+ } else if (e.type === 'turn.cancel') {
1234
+ lastEnd = Math.max(lastEnd, e.time || 0, lastPrompt);
1235
+ } else if (e.type === 'context.append_message') {
1236
+ const m = e.message || {};
1237
+ const text = kosongText(m.content).trim();
1238
+ const toolCalls = m.toolCalls || m.tool_calls;
1239
+ if (m.role === 'assistant' && !(toolCalls && toolCalls.length)) {
1240
+ if (text) messages.push({ role: 'assistant', text: text.slice(0, 4000), ts });
1241
+ lastEnd = Math.max(lastEnd, e.time || 0);
1242
+ }
1243
+ } else if (e.type === 'interaction.request') {
1244
+ const key = e.id ?? e.interaction_id;
1245
+ if (key) pendingInteractions.add(key);
1246
+ } else if (e.type === 'interaction.resolved') {
1247
+ const key = e.id ?? e.interaction_id;
1248
+ if (key) pendingInteractions.delete(key); else pendingInteractions.clear();
1249
+ }
1250
+ }
1251
+ return {
1252
+ messages: messages.slice(-limit),
1253
+ busy: lastPrompt > lastEnd,
1254
+ // Same tail-window caveat as Codex: no markers seen means a long turn
1255
+ // pushed them out, so freshness decides.
1256
+ sawMarkers: lastPrompt > 0 || lastEnd > 0,
1257
+ waiting: pendingInteractions.size > 0,
1258
+ };
1259
+ }
1260
+
1261
+ function readKimiCodeMessages(file, maxBytes = 512 * 1024, limit = 80) {
1262
+ return kimiCodeScan(file, maxBytes, limit).messages;
1263
+ }
1264
+
1265
+ function readKimiCodeSessions() {
1266
+ const sessions = [];
1267
+ const root = path.join(KIMICODE_DIR, 'sessions');
1268
+ let wdKeys = [];
1269
+ try { wdKeys = fs.readdirSync(root); } catch { return sessions; }
1270
+ for (const wdKey of wdKeys) {
1271
+ let ids = [];
1272
+ try { ids = fs.readdirSync(path.join(root, wdKey)); } catch { continue; }
1273
+ for (const id of ids) {
1274
+ const dir = path.join(root, wdKey, id);
1275
+ const state = readJson(path.join(dir, 'state.json'));
1276
+ if (!state?.id || state.archived) continue;
1277
+ const wire = path.join(dir, 'agents', 'main', 'wire.jsonl');
1278
+ // A brand new session has state.json before its first journal write;
1279
+ // state.updatedAt keeps it visible during that gap.
1280
+ let mtime = state.updatedAt || 0;
1281
+ try { mtime = Math.max(mtime, fs.statSync(wire).mtimeMs); } catch {}
1282
+ if (Date.now() - mtime > KIMI_ACTIVE_WINDOW_MS) continue;
1283
+ const scan = memoScan(`kimicode:${wire}`, wire, () => kimiCodeScan(wire, 64 * 1024, 1));
1284
+ const busy = (scan.busy || (!scan.sawMarkers && Date.now() - mtime < 60_000))
1285
+ && Date.now() - mtime < KIMI_BUSY_STALE_MS;
1286
+ // A pending approval outranks busy: the agent is stopped on a human.
1287
+ const status = scan.waiting ? 'waiting' : (headlessActive.has(state.id) || busy ? 'busy' : 'idle');
1288
+ transcriptCache.set(state.id, wire);
1289
+ sessions.push({
1290
+ id: state.id,
1291
+ provider: 'kimicode',
1292
+ name: state.title || (state.lastPrompt || '').slice(0, 40) || path.basename(state.cwd || '?'),
1293
+ cwd: state.cwd || '',
1294
+ project: path.basename(state.cwd || '?'),
1295
+ status,
1296
+ updatedAt: mtime,
1297
+ startedAt: state.createdAt || 0,
1298
+ lastMessage: scan.messages.length ? scan.messages[scan.messages.length - 1] : null,
1299
+ tty: '',
1300
+ live: false,
1301
+ });
1302
+ }
1303
+ }
1304
+ return sessions;
1305
+ }
1306
+
1307
+ let kimiCache = { at: 0, sessions: [] };
1308
+ function readKimiSessions() {
1309
+ if (Date.now() - kimiCache.at < 10_000) return kimiCache.sessions;
1310
+ kimiCache.at = Date.now();
1311
+ kimiCache.sessions = [...readKimiLegacySessions(), ...readKimiCodeSessions()];
1312
+ return kimiCache.sessions;
1313
+ }
1314
+
1315
+ /// The phone-facing shape of a held request.
1316
+ const askPayload = (ask, queued) => ({
1317
+ id: ask.id,
1318
+ tool: ask.tool,
1319
+ line: ask.line,
1320
+ detail: ask.detail,
1321
+ at: ask.at,
1322
+ queued,
1323
+ });
1324
+
1325
+ function readSessions() {
1326
+ const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
1327
+ const known = new Set(sessions.map((s) => s.id));
1328
+ for (const s of sessions) {
1329
+ const queue = askQueue.get(s.id);
1330
+ const ask = queue?.length ? pendingAsks.get(queue[0]) : null;
1331
+ if (!ask) continue;
1332
+ // A held permission outranks busy: the session is stopped on a human, and
1333
+ // that is the whole reason the invader turns pink on the island.
1334
+ s.status = 'waiting';
1335
+ s.ask = askPayload(ask, queue.length);
1336
+ }
1337
+
1338
+ // A held request whose session is not in the fleet would never reach the
1339
+ // phone, and one that cannot be seen cannot be answered, which strands the
1340
+ // session until the hold expires. It happens for real: the session file is
1341
+ // written slightly after the first tool call, a scan races a partial write,
1342
+ // or the request comes from a session kind we do not discover. Surface the
1343
+ // request on its own rather than dropping it.
1344
+ for (const [sessionId, queue] of askQueue) {
1345
+ if (known.has(sessionId) || !queue.length) continue;
1346
+ const ask = pendingAsks.get(queue[0]);
1347
+ if (!ask) continue;
1348
+ sessions.push({
1349
+ id: sessionId,
1350
+ provider: 'claude',
1351
+ name: path.basename(ask.cwd || '') || 'session',
1352
+ cwd: ask.cwd || '',
1353
+ project: path.basename(ask.cwd || '') || '?',
1354
+ status: 'waiting',
1355
+ updatedAt: ask.at,
1356
+ startedAt: ask.at,
1357
+ lastMessage: null,
1358
+ tty: '',
1359
+ live: false,
1360
+ ask: askPayload(ask, queue.length),
1361
+ });
1362
+ }
1363
+
593
1364
  sessions.sort((a, b) => a.startedAt - b.startedAt);
594
1365
  return sessions;
595
1366
  }
596
1367
 
1368
+ // ---------- remote approvals: runtime ----------
1369
+ // The hook connects here over a unix socket and blocks until this daemon
1370
+ // answers. Answering null means "no decision", which puts the prompt back on
1371
+ // the Mac exactly as it behaves without Ground Control installed.
1372
+
1373
+ const ASK_SOCKET = path.join(CONF_DIR, 'approvals.sock');
1374
+
1375
+ /// What the phone needs to render the approvals control without knowing
1376
+ /// anything about hooks or settings files. Cached: a snapshot goes out every
1377
+ /// 1.5s and this reads and parses settings.json.
1378
+ let hostCache = { at: 0, value: null };
1379
+ function hostState() {
1380
+ if (Date.now() - hostCache.at < 5000 && hostCache.value) return hostCache.value;
1381
+ const settings = readClaudeSettings();
1382
+ const entries = Array.isArray(settings?.hooks?.PermissionRequest) ? settings.hooks.PermissionRequest : [];
1383
+ const value = {
1384
+ version: VERSION,
1385
+ approvals: {
1386
+ // Present at all means this daemon understands approvals; an older one
1387
+ // simply omits the whole field and the app can say so.
1388
+ installed: entries.some((e) => isOurHook(commandsOf(e))),
1389
+ mode: approvalConfig().mode,
1390
+ // Another app answering the same event can overrule a tap, so the phone
1391
+ // should be able to say that out loud.
1392
+ others: entries.filter((e) => !isOurHook(commandsOf(e))).length,
1393
+ // A settings.json we cannot parse must not be silently overwritten, and
1394
+ // the phone should explain why the toggle will not work.
1395
+ settingsOk: settings !== null,
1396
+ socketOk: approvalServerUp,
1397
+ },
1398
+ updateAvailable: availableUpdate,
1399
+ };
1400
+ hostCache = { at: Date.now(), value };
1401
+ return value;
1402
+ }
1403
+ const bustHostCache = () => { hostCache.at = 0; };
1404
+
1405
+ let approvalServerUp = false;
1406
+ let availableUpdate = null; // version string once a newer release is seen
1407
+
1408
+ const pendingAsks = new Map(); // askId -> ask record
1409
+ const askQueue = new Map(); // sessionId -> [askId], oldest first
1410
+ let askSeq = 0;
1411
+
1412
+ /// Seconds since the last keyboard or mouse event on this Mac. A failure reads
1413
+ /// as 0 (you are here), which keeps prompts on the Mac rather than silently
1414
+ /// routing them to a phone that may not be watching.
1415
+ let idleCache = { at: 0, seconds: 0 };
1416
+ function macIdleSeconds() {
1417
+ if (process.platform !== 'darwin') return Infinity; // no signal; treat a headless box as away
1418
+ if (Date.now() - idleCache.at < 2000) return idleCache.seconds;
1419
+ let seconds = 0;
1420
+ try {
1421
+ const out = execSync("ioreg -c IOHIDSystem -d 1 | awk '/HIDIdleTime/ {print $NF; exit}'", {
1422
+ timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
1423
+ }).toString().trim();
1424
+ const ns = Number(out);
1425
+ if (Number.isFinite(ns) && ns >= 0) seconds = ns / 1e9;
1426
+ } catch {}
1427
+ idleCache = { at: Date.now(), seconds };
1428
+ return seconds;
1429
+ }
1430
+
1431
+ // Deliberately not a live presence check. iOS suspends the websocket the
1432
+ // moment the app is backgrounded, so a phone in a pocket looks offline, which
1433
+ // is exactly the situation this feature exists for. What matters is whether
1434
+ // this Mac belongs to someone who actually uses the app, so a request is worth
1435
+ // holding until they pick the phone up. Walking back to the Mac releases it
1436
+ // within seconds either way, so a hold costs a paired user nothing.
1437
+ const PHONE_SEEN_FILE = path.join(CONF_DIR, 'phone-seen');
1438
+ const PHONE_KNOWN_MS = 30 * 24 * 60 * 60 * 1000;
1439
+
1440
+ let lastPhoneWrite = 0;
1441
+ function notePhoneSeen() {
1442
+ if (Date.now() - lastPhoneWrite < 60_000) return; // the phone heartbeats; do not churn the disk
1443
+ lastPhoneWrite = Date.now();
1444
+ try { fs.writeFileSync(PHONE_SEEN_FILE, String(Date.now())); } catch {}
1445
+ }
1446
+
1447
+ function phoneKnown() {
1448
+ try { return Date.now() - Number(fs.readFileSync(PHONE_SEEN_FILE, 'utf8').trim()) < PHONE_KNOWN_MS; }
1449
+ catch { return false; }
1450
+ }
1451
+
1452
+ function holdPolicy() {
1453
+ const cfg = approvalConfig();
1454
+ if (cfg.mode === 'off') return { hold: false, why: 'remote approvals are off' };
1455
+ if (!phoneKnown()) return { hold: false, why: 'no phone has paired with this Mac' };
1456
+ if (cfg.mode === 'always') return { hold: true };
1457
+ const idle = macIdleSeconds();
1458
+ if (idle < cfg.awayAfterSeconds) return { hold: false, why: `you are at the Mac (idle ${Math.round(idle)}s)` };
1459
+ return { hold: true };
1460
+ }
1461
+
1462
+ const clip = (v, n) => {
1463
+ const s = typeof v === 'string' ? v : (v == null ? '' : JSON.stringify(v));
1464
+ return s.length > n ? `${s.slice(0, n)}\n...` : s;
1465
+ };
1466
+
1467
+ /// Reduce a tool call to the one line that identifies it plus a longer preview.
1468
+ /// Tool inputs can carry whole file contents, and every byte here is encrypted,
1469
+ /// relayed, and rendered on a phone.
1470
+ function summarizeTool(tool, input) {
1471
+ const i = input && typeof input === 'object' ? input : {};
1472
+ switch (tool) {
1473
+ case 'Bash':
1474
+ case 'BashOutput':
1475
+ return { line: clip(i.command, 400), detail: clip(i.description, 300) };
1476
+ case 'Write':
1477
+ return { line: clip(i.file_path, 300), detail: clip(i.content, 1000) };
1478
+ case 'Edit':
1479
+ return {
1480
+ line: clip(i.file_path, 300),
1481
+ detail: `${clip(i.old_string, 500)}\n\n->\n\n${clip(i.new_string, 500)}`,
1482
+ };
1483
+ case 'Read':
1484
+ return { line: clip(i.file_path, 300), detail: '' };
1485
+ case 'WebFetch':
1486
+ return { line: clip(i.url, 300), detail: clip(i.prompt, 300) };
1487
+ case 'WebSearch':
1488
+ return { line: clip(i.query, 300), detail: '' };
1489
+ default: {
1490
+ // Unknown or MCP tools: show whichever field reads most like a target,
1491
+ // and fall back to the whole input.
1492
+ const pick = i.command || i.file_path || i.path || i.url || i.query || i.pattern;
1493
+ return { line: clip(pick || tool, 300), detail: clip(i, 1000) };
1494
+ }
1495
+ }
1496
+ }
1497
+
1498
+ function dropAsk(ask) {
1499
+ pendingAsks.delete(ask.id);
1500
+ const queue = askQueue.get(ask.sessionId);
1501
+ if (!queue) return;
1502
+ const at = queue.indexOf(ask.id);
1503
+ if (at > -1) queue.splice(at, 1);
1504
+ if (!queue.length) askQueue.delete(ask.sessionId);
1505
+ }
1506
+
1507
+ /// Answer a held request exactly once. A null decision releases it back to the
1508
+ /// Mac; the hook prints nothing and Claude Code prompts in the terminal.
1509
+ function settleAsk(ask, decision, outcome, note) {
1510
+ if (ask.done) return;
1511
+ ask.done = true;
1512
+ clearTimeout(ask.holdTimer);
1513
+ dropAsk(ask);
1514
+ try { ask.socket.write(JSON.stringify({ decision: decision || null }) + '\n'); } catch {}
1515
+ try { ask.socket.end(); } catch {}
1516
+ console.log(`[daemon] permission ${ask.tool} ${outcome}${note ? `: ${note}` : ''}`);
1517
+ if (ws?.readyState === 1) {
1518
+ ws.send(seal({ type: 'askResolved', askId: ask.id, sessionId: ask.sessionId, outcome, note: note || '' }));
1519
+ }
1520
+ sendSnapshot(true);
1521
+ }
1522
+
1523
+ function onPermissionRequest(req, socket) {
1524
+ const gate = holdPolicy();
1525
+ if (!gate.hold) {
1526
+ console.log(`[daemon] permission ${req.toolName} left on the Mac: ${gate.why}`);
1527
+ try { socket.write('{"decision":null}\n'); } catch {}
1528
+ try { socket.end(); } catch {}
1529
+ return;
1530
+ }
1531
+ const { line, detail } = summarizeTool(req.toolName, req.toolInput);
1532
+ const ask = {
1533
+ id: `${Date.now().toString(36)}-${++askSeq}`,
1534
+ sessionId: req.sessionId,
1535
+ tool: req.toolName || 'tool',
1536
+ line,
1537
+ detail,
1538
+ cwd: req.cwd || '',
1539
+ at: Date.now(),
1540
+ socket,
1541
+ done: false,
1542
+ };
1543
+ ask.holdTimer = setTimeout(
1544
+ () => settleAsk(ask, null, 'released', 'held too long, prompting on the Mac instead'),
1545
+ Math.max(1, approvalConfig().holdMinutes) * 60_000,
1546
+ );
1547
+ // The hook dying (session quit, Ctrl+C, Claude restarted) closes the socket.
1548
+ // Without this the card would linger on the phone with nothing behind it.
1549
+ socket.on('close', () => settleAsk(ask, null, 'gone', 'the session stopped waiting'));
1550
+ socket.on('error', () => {});
1551
+ pendingAsks.set(ask.id, ask);
1552
+ askQueue.set(ask.sessionId, [...(askQueue.get(ask.sessionId) || []), ask.id]);
1553
+ console.log(`[daemon] permission held for the phone: ${ask.tool} ${ask.line.slice(0, 60)}`);
1554
+ sendSnapshot(true);
1555
+ }
1556
+
1557
+ function startApprovalServer(attempt = 0) {
1558
+ // sun_path is 104 bytes on macOS. Over that, the bind fails in ways that
1559
+ // read as EADDRINUSE rather than "path too long", so say it plainly.
1560
+ if (Buffer.byteLength(ASK_SOCKET) > 100) {
1561
+ console.log(`[daemon] home path too long for the approvals socket, permission prompts stay on the Mac: ${ASK_SOCKET}`);
1562
+ return;
1563
+ }
1564
+ // A hard killed daemon leaves the socket file behind and bind fails with
1565
+ // EADDRINUSE, so clear it first. If the bind still fails, another daemon is
1566
+ // genuinely listening; it is about to be displaced on the relay and exit, so
1567
+ // retry a few times rather than losing approvals until the next restart.
1568
+ try { fs.unlinkSync(ASK_SOCKET); } catch {}
1569
+ const server = net.createServer((socket) => {
1570
+ socket.setEncoding('utf8');
1571
+ let buf = '';
1572
+ let handled = false;
1573
+ socket.on('data', (chunk) => {
1574
+ if (handled) return;
1575
+ buf += chunk;
1576
+ const nl = buf.indexOf('\n');
1577
+ if (nl === -1) {
1578
+ if (buf.length > 2_000_000) socket.destroy(); // a tool input this big is not real
1579
+ return;
1580
+ }
1581
+ handled = true;
1582
+ let req;
1583
+ try { req = JSON.parse(buf.slice(0, nl)); } catch { req = null; }
1584
+ if (!req || !req.sessionId) {
1585
+ try { socket.write('{"decision":null}\n'); } catch {}
1586
+ try { socket.end(); } catch {}
1587
+ return;
1588
+ }
1589
+ onPermissionRequest(req, socket);
1590
+ });
1591
+ socket.on('error', () => {});
1592
+ });
1593
+ server.on('error', (e) => {
1594
+ if (e.code === 'EADDRINUSE' && attempt < 5) {
1595
+ setTimeout(() => startApprovalServer(attempt + 1), 1000);
1596
+ return;
1597
+ }
1598
+ console.log(`[daemon] approvals socket unavailable, permission prompts stay on the Mac: ${e.message}`);
1599
+ });
1600
+ server.listen(ASK_SOCKET, () => {
1601
+ try { fs.chmodSync(ASK_SOCKET, 0o600); } catch {}
1602
+ approvalServerUp = true;
1603
+ bustHostCache();
1604
+ console.log(`[daemon] remote approvals ready (mode: ${approvalConfig().mode})`);
1605
+ });
1606
+ }
1607
+
1608
+ // Claude Code's own status for each session, as last seen on disk.
1609
+ const lastRawStatus = new Map(); // sessionId -> 'busy' | 'idle' | 'waiting' | ...
1610
+
1611
+ /// Clear a held request that was answered on the Mac.
1612
+ ///
1613
+ /// The socket closing is the fast path, but it cannot be relied on: Claude
1614
+ /// Code does not always kill the hook process when the human answers in the
1615
+ /// terminal, and a hook nobody killed keeps its socket open, so the phone
1616
+ /// would keep showing a card for a question that is already settled.
1617
+ ///
1618
+ /// Claude Code writes "waiting" into its own session file while a prompt is
1619
+ /// on screen, so that dropping back to busy or idle is the real signal that
1620
+ /// the prompt is gone. The grace period covers the gap between our hook being
1621
+ /// called and Claude getting round to writing the status.
1622
+ const ANSWERED_GRACE_MS = 6000;
1623
+ function releaseAnsweredOnMac() {
1624
+ if (!pendingAsks.size) return;
1625
+ for (const ask of [...pendingAsks.values()]) {
1626
+ if (Date.now() - ask.at < ANSWERED_GRACE_MS) continue;
1627
+ const status = lastRawStatus.get(ask.sessionId);
1628
+ // Unknown session: nothing to conclude, leave it to the socket or the cap.
1629
+ if (!status || status === 'waiting') continue;
1630
+ settleAsk(ask, null, 'gone', 'answered on the Mac');
1631
+ }
1632
+ }
1633
+
1634
+ /// Sitting back down at the Mac clears the phone card. Not because the Mac
1635
+ /// needs it back: Claude Code shows its own terminal prompt the whole time a
1636
+ /// hook is running (verified on 2.1.220), so the Mac is never blocked and can
1637
+ /// always answer. This just stops the phone showing a request you are about
1638
+ /// to deal with in front of you.
1639
+ function releaseAsksIfBack() {
1640
+ if (!pendingAsks.size) return;
1641
+ const cfg = approvalConfig();
1642
+ if (cfg.mode !== 'away') return;
1643
+ if (macIdleSeconds() >= cfg.awayAfterSeconds) return;
1644
+ for (const ask of [...pendingAsks.values()]) {
1645
+ settleAsk(ask, null, 'released', 'you came back to the Mac');
1646
+ }
1647
+ }
1648
+
1649
+ // ---------- self update ----------
1650
+ // The service runs from a frozen copy under ~/.groundcontrol/service, so
1651
+ // without this a user who installed once never sees another release. Checked
1652
+ // daily, always announced in the log and surfaced in the app.
1653
+
1654
+ const UPDATE_CHECK_MS = 24 * 60 * 60 * 1000;
1655
+ const UPDATE_STAMP = path.join(CONF_DIR, 'last-update-check');
1656
+
1657
+ /// Compare dotted numeric versions. Anything unparseable sorts as older, so a
1658
+ /// junk value from the registry can never trigger an update.
1659
+ function isNewer(candidate, current) {
1660
+ const parse = (v) => String(v).split('.').map((n) => Number(n));
1661
+ const a = parse(candidate), b = parse(current);
1662
+ if (a.length !== 3 || a.some((n) => !Number.isInteger(n) || n < 0)) return false;
1663
+ for (let i = 0; i < 3; i++) {
1664
+ if ((a[i] || 0) > (b[i] || 0)) return true;
1665
+ if ((a[i] || 0) < (b[i] || 0)) return false;
1666
+ }
1667
+ return false;
1668
+ }
1669
+
1670
+ async function latestPublishedVersion() {
1671
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
1672
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
1673
+ signal: AbortSignal.timeout(15_000),
1674
+ });
1675
+ if (!res.ok) throw new Error(`registry ${res.status}`);
1676
+ const body = await res.json();
1677
+ if (!body?.version) throw new Error('registry response had no version');
1678
+ return body.version;
1679
+ }
1680
+
1681
+ /// Re-install the service from the newly published version. Runs detached
1682
+ /// because `service install` boots this very process out partway through;
1683
+ /// launchd then starts the replacement. installService stages everything
1684
+ /// before it touches the running service, so a failure here leaves the
1685
+ /// current daemon in place rather than nothing at all.
1686
+ function applyUpdate(version) {
1687
+ console.log(`[daemon] updating to ${version}`);
1688
+ const child = spawn('npx', ['-y', `${PACKAGE}@${version}`, 'service', 'install'], {
1689
+ detached: true,
1690
+ stdio: ['ignore', fs.openSync(LOG_FILE, 'a'), fs.openSync(LOG_FILE, 'a')],
1691
+ env: process.env,
1692
+ });
1693
+ child.unref();
1694
+ }
1695
+
1696
+ async function checkForUpdate({ force = false } = {}) {
1697
+ // Only the launchd copy updates itself. A daemon someone is running by hand
1698
+ // in a terminal must never swap itself out from under them.
1699
+ if (!runsFromServiceDir && !force) return;
1700
+ try {
1701
+ const last = Number(fs.readFileSync(UPDATE_STAMP, 'utf8').trim());
1702
+ if (!force && Number.isFinite(last) && Date.now() - last < UPDATE_CHECK_MS) return;
1703
+ } catch {}
1704
+ try { fs.writeFileSync(UPDATE_STAMP, String(Date.now())); } catch {}
1705
+ let latest;
1706
+ try { latest = await latestPublishedVersion(); }
1707
+ catch (e) { console.log(`[daemon] update check failed: ${e.message}`); return; }
1708
+ if (!isNewer(latest, VERSION)) return;
1709
+ availableUpdate = latest;
1710
+ bustHostCache();
1711
+ sendSnapshot(true);
1712
+ console.log(`[daemon] a newer version is available: ${latest} (running ${VERSION})`);
1713
+ // Held requests belong to sessions that are about to lose their socket.
1714
+ releaseAllAsks();
1715
+ applyUpdate(latest);
1716
+ }
1717
+
1718
+ /// A daemon shutting down must hand every held request back rather than leave
1719
+ /// sessions blocked on a socket that will never answer.
1720
+ function releaseAllAsks() {
1721
+ for (const ask of [...pendingAsks.values()]) {
1722
+ settleAsk(ask, null, 'released', 'the daemon stopped');
1723
+ }
1724
+ }
1725
+
597
1726
  // ---------- relay connection ----------
598
1727
 
599
1728
  let ws = null;
600
1729
  let lastSnapshotJson = '';
601
1730
 
602
1731
  function sendSnapshot(force = false) {
603
- const sessions = readSessions();
604
- const json = JSON.stringify(sessions.map(({ lastMessage, ...s }) => ({ ...s, last: lastMessage?.text?.slice(0, 120) })));
1732
+ // A parser tripping on a malformed journal must never take the daemon down:
1733
+ // this runs on a bare interval with nothing above it to catch a throw.
1734
+ let sessions;
1735
+ try { sessions = readSessions(); } catch (e) {
1736
+ console.log(`[daemon] snapshot failed: ${e.message}`);
1737
+ return;
1738
+ }
1739
+ for (const s of sessions) if (s.rawStatus) lastRawStatus.set(s.id, s.rawStatus);
1740
+ const host = hostState();
1741
+ // Host state joins the dedupe key, otherwise flipping approvals on would not
1742
+ // reach a phone whose session list happened not to change.
1743
+ const json = JSON.stringify([sessions.map(({ lastMessage, ...s }) => ({ ...s, last: lastMessage?.text?.slice(0, 120) })), host]);
605
1744
  if (!force && json === lastSnapshotJson) return;
606
1745
  lastSnapshotJson = json;
607
1746
  if (ws?.readyState === 1) {
608
- ws.send(seal({ type: 'snapshot', at: Date.now(), sessions }));
1747
+ ws.send(seal({ type: 'snapshot', at: Date.now(), sessions, host }));
609
1748
  console.log(`[daemon] snapshot -> ${sessions.length} session(s): ${sessions.map((s) => `${s.name}:${s.status}`).join(', ') || 'none'}`);
610
1749
  }
611
1750
  }
@@ -642,6 +1781,46 @@ async function handleCommand(cmd) {
642
1781
  ws?.send(seal({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) }));
643
1782
  } else if (cmd.type === 'unwatch' && cmd.sessionId) {
644
1783
  unwatchTranscript(cmd.sessionId);
1784
+ } else if (cmd.type === 'setApprovals') {
1785
+ // A deliberate tap in the app, with the app explaining what it adds, is
1786
+ // the consent for this. A backup is still written before any change.
1787
+ let result;
1788
+ if (cmd.enabled === false) {
1789
+ writeApprovalConfig({ mode: 'off' });
1790
+ result = uninstallApprovalHook();
1791
+ console.log('[daemon] remote approvals turned off from the phone');
1792
+ } else {
1793
+ const mode = ['away', 'always'].includes(cmd.mode) ? cmd.mode : 'away';
1794
+ writeApprovalConfig({ mode });
1795
+ result = installApprovalHook({ consent: true });
1796
+ console.log(`[daemon] remote approvals turned on from the phone (mode: ${mode})`);
1797
+ }
1798
+ bustHostCache();
1799
+ sendSnapshot(true);
1800
+ ws?.send(seal({
1801
+ type: 'approvalsResult',
1802
+ ok: !!result.ok,
1803
+ error: result.ok ? '' : (result.reason || 'could not change the setting'),
1804
+ host: hostState(),
1805
+ }));
1806
+ } else if (cmd.type === 'approve' || cmd.type === 'deny') {
1807
+ const ask = cmd.askId ? pendingAsks.get(cmd.askId) : null;
1808
+ if (!ask) {
1809
+ // Answered on the Mac, expired, or the session moved on while the tap
1810
+ // was in flight. Tell the phone so the card clears instead of hanging.
1811
+ return ws?.send(seal({
1812
+ type: 'askResolved', askId: cmd.askId || '', sessionId: cmd.sessionId || '',
1813
+ outcome: 'gone', note: 'that request is no longer waiting',
1814
+ }));
1815
+ }
1816
+ if (cmd.type === 'approve') {
1817
+ settleAsk(ask, { behavior: 'allow', ...(cmd.always ? { alwaysAllow: true } : {}) },
1818
+ 'allowed', cmd.always ? 'from the phone, and always from now on' : 'from the phone');
1819
+ } else {
1820
+ const reason = (cmd.text || '').trim();
1821
+ settleAsk(ask, { behavior: 'deny', message: reason || 'Denied from the Ground Control app.' },
1822
+ 'denied', reason ? `from the phone: ${reason.slice(0, 60)}` : 'from the phone');
1823
+ }
645
1824
  } else if (cmd.type === 'ping' && cmd.sessionId) {
646
1825
  const s = readSessions().find((x) => x.id === cmd.sessionId);
647
1826
  ws?.send(seal({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() }));
@@ -649,6 +1828,8 @@ async function handleCommand(cmd) {
649
1828
  const session = readSessions().find((s) => s.id === cmd.sessionId);
650
1829
  if (!session) return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' }));
651
1830
 
1831
+ if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
1832
+
652
1833
  // Preferred path: type into the real terminal so the desktop updates live.
653
1834
  if (session.live) {
654
1835
  const method = await injectPrompt(session.tty, cmd.text);
@@ -679,7 +1860,7 @@ async function handleCommand(cmd) {
679
1860
  headlessActive.add(cmd.sessionId);
680
1861
  sendSnapshot(true);
681
1862
  const child = spawn(claudeBin, ['--resume', cmd.sessionId, '-p', cmd.text], {
682
- cwd: session.cwd || os.homedir(),
1863
+ cwd: fs.existsSync(session.cwd || '') ? session.cwd : os.homedir(),
683
1864
  env: process.env,
684
1865
  stdio: ['ignore', 'pipe', 'pipe'],
685
1866
  });
@@ -707,6 +1888,80 @@ async function handleCommand(cmd) {
707
1888
  }
708
1889
  }
709
1890
 
1891
+ // Sends a phone prompt to a Codex or Kimi session. There is no tty path for
1892
+ // these (their session files record no pid), so every prompt goes through the
1893
+ // provider's headless resume command, which appends the turn to the same
1894
+ // journal the transcript watcher follows — desktop app threads included.
1895
+ // Codex takes the prompt as a positional, so `--` stops phone text that
1896
+ // begins with a dash from being read as CLI flags. The kimi CLIs take it as
1897
+ // the -p option value where `--` cannot help, so a leading space defuses a
1898
+ // dash there (the models do not care about leading whitespace).
1899
+ const defuse = (text) => (/^\s*-/.test(text) ? ` ${text}` : text);
1900
+ const headlessPromptSpec = {
1901
+ codex: () => codexBin && { bin: codexBin, args: (id, text) => ['exec', 'resume', id, '--skip-git-repo-check', '--', text] },
1902
+ kimicode: () => kimiCodeBin && { bin: kimiCodeBin, args: (id, text) => ['--session', id, '-p', defuse(text)] },
1903
+ kimi: () => kimiLegacyBin && { bin: kimiLegacyBin, args: (id, text) => ['--print', '--session', id, '-p', defuse(text)] },
1904
+ };
1905
+
1906
+ function bustProviderCaches() {
1907
+ codexCache.at = 0;
1908
+ kimiCache.at = 0;
1909
+ }
1910
+
1911
+ function promptHeadless(session, text) {
1912
+ // Anything but idle means another writer owns the session right now: busy
1913
+ // is a running turn, waiting is a human approval the phone cannot answer.
1914
+ if (session.status === 'busy') return ws?.send(seal({ type: 'promptResult', sessionId: session.id, ok: false, error: 'session is busy, wait for it to go idle' }));
1915
+ if (session.status !== 'idle') return ws?.send(seal({ type: 'promptResult', sessionId: session.id, ok: false, error: 'session is waiting for an approval, answer it in the app or terminal first' }));
1916
+ const spec = headlessPromptSpec[session.provider]?.();
1917
+ if (!spec) {
1918
+ console.log(`[daemon] ${session.provider} prompt failed: binary not found`);
1919
+ return ws?.send(seal({
1920
+ type: 'promptResult', sessionId: session.id, ok: false,
1921
+ error: `The daemon could not find the ${session.provider === 'codex' ? 'codex' : 'kimi'} command on this Mac, so it cannot reach this session.`,
1922
+ }));
1923
+ }
1924
+ console.log(`[daemon] prompt -> ${session.provider} ${session.name}: ${text.slice(0, 60)}`);
1925
+ const promptedAt = Date.now();
1926
+ headlessActive.add(session.id);
1927
+ bustProviderCaches();
1928
+ sendSnapshot(true);
1929
+ const child = spawn(spec.bin, spec.args(session.id, text), {
1930
+ cwd: fs.existsSync(session.cwd || '') ? session.cwd : os.homedir(),
1931
+ env: process.env,
1932
+ stdio: ['ignore', 'pipe', 'pipe'],
1933
+ });
1934
+ let err = '';
1935
+ let responded = false;
1936
+ const respond = (payload) => {
1937
+ if (responded) return;
1938
+ responded = true;
1939
+ headlessActive.delete(session.id);
1940
+ bustProviderCaches();
1941
+ ws?.send(seal(payload));
1942
+ sendSnapshot(true);
1943
+ };
1944
+ child.stderr.on('data', (d) => { err += d; });
1945
+ child.stdout.on('data', () => {});
1946
+ child.on('error', (e) => {
1947
+ console.log(`[daemon] ${session.provider} spawn failed: ${e.message}`);
1948
+ respond({ type: 'promptResult', sessionId: session.id, ok: false, error: `could not run ${session.provider}: ${e.message}` });
1949
+ });
1950
+ child.on('close', (code) => {
1951
+ // The reply is already in the journal; surface the last assistant turn,
1952
+ // but only one written after this prompt. An older message here would
1953
+ // present a stale answer as the reply (the live transcript stream shows
1954
+ // the real one either way, so an empty text is fine).
1955
+ const msgs = readMessages(session.id, 64 * 1024, 10);
1956
+ const reply = [...msgs].reverse().find((m) => m.role === 'assistant' && Date.parse(m.ts || '') > promptedAt);
1957
+ respond({
1958
+ type: 'promptResult', sessionId: session.id, ok: code === 0,
1959
+ text: code === 0 ? (reply?.text || '').slice(0, 4000) : err.trim().slice(0, 4000),
1960
+ });
1961
+ });
1962
+ watchTranscript(session.id);
1963
+ }
1964
+
710
1965
  let lastPongAt = Date.now();
711
1966
 
712
1967
  function connect() {
@@ -718,9 +1973,20 @@ function connect() {
718
1973
  ws.on('pong', () => { lastPongAt = Date.now(); });
719
1974
  ws.on('message', (data) => {
720
1975
  const str = data.toString();
721
- if (str === '{"sys":"client-joined"}') { console.log('[daemon] phone connected'); sendSnapshot(true); return; }
1976
+ if (str === '{"sys":"client-joined"}') {
1977
+ notePhoneSeen();
1978
+ console.log('[daemon] phone connected');
1979
+ sendSnapshot(true);
1980
+ return;
1981
+ }
722
1982
  if (str.startsWith('{"sys"')) return;
723
- try { handleCommand(open(str))?.catch?.(() => {}); } catch (e) { console.log('[daemon] bad message (wrong key?)'); }
1983
+ // A command that decrypts proves a real paired phone is on the other end,
1984
+ // which is what the approval policy checks before holding a prompt.
1985
+ try {
1986
+ const cmd = open(str);
1987
+ notePhoneSeen();
1988
+ handleCommand(cmd)?.catch?.(() => {});
1989
+ } catch (e) { console.log('[daemon] bad message (wrong key?)'); }
724
1990
  });
725
1991
  ws.on('close', (code) => {
726
1992
  if (code === 4001) {
@@ -735,6 +2001,32 @@ function connect() {
735
2001
 
736
2002
  connect();
737
2003
  if (process.platform === 'darwin') requestAutomationGrant();
2004
+
2005
+ // Remote approvals. The daemon refreshes the hook script itself, which is our
2006
+ // own file, but never edits ~/.claude/settings.json: a background process is
2007
+ // the last thing that should rewrite another tool's config unasked. Turning it
2008
+ // on is an explicit `groundcontrol approvals on`.
2009
+ try { stageHookFile(); } catch (e) { console.log(`[daemon] could not refresh the permission hook: ${e.message}`); }
2010
+ const hookState = installApprovalHook(); // consent defaults to false: reports, never writes
2011
+ if (hookState.unchanged) console.log('[daemon] permission hook installed, remote approvals available');
2012
+ else if (hookState.needsConsent) console.log('[daemon] permission hook not installed (run: groundcontrol approvals on)');
2013
+ else if (!hookState.ok) console.log(`[daemon] permission hook unavailable: ${hookState.reason}`);
2014
+ for (const cmd of hookState.foreign || []) console.log(`[daemon] another app also answers permission requests: ${cmd}`);
2015
+ startApprovalServer();
2016
+ setInterval(releaseAsksIfBack, 3000);
2017
+ setInterval(releaseAnsweredOnMac, 2000);
2018
+
2019
+ // Self update. Staggered off startup so a login storm does not hit the
2020
+ // registry at once, then re-checked hourly (the daily stamp does the gating).
2021
+ setTimeout(() => { checkForUpdate().catch(() => {}); }, 60_000);
2022
+ setInterval(() => { checkForUpdate().catch(() => {}); }, 60 * 60 * 1000);
2023
+
2024
+ // Held requests are sockets a Claude session is blocked on. Hand them back
2025
+ // before going away, or those sessions sit frozen until the hook times out.
2026
+ for (const sig of ['SIGINT', 'SIGTERM']) {
2027
+ process.on(sig, () => { releaseAllAsks(); process.exit(0); });
2028
+ }
2029
+
738
2030
  setInterval(sendSnapshot, 1500);
739
2031
  // Heartbeat traffic keeps the Cloudflare socket alive in both directions and
740
2032
  // lets the phone detect a dead link; missing pongs force a reconnect here.
@@ -754,3 +2046,20 @@ setInterval(() => {
754
2046
  }
755
2047
  }, 20_000);
756
2048
  try { fs.watch(SESSIONS_DIR, () => sendSnapshot()); } catch {}
2049
+ // Recursive watches (macOS supports them) so a fresh Codex or Kimi turn busts
2050
+ // the scan cache immediately instead of waiting out the 10s refresh. Debounced:
2051
+ // a streaming turn appends to its journal many times a second, and every
2052
+ // uncached snapshot is a full rescan.
2053
+ function watchProviderDir(dir, cache, label) {
2054
+ let debounce;
2055
+ try {
2056
+ fs.watch(dir, { recursive: true }, () => {
2057
+ clearTimeout(debounce);
2058
+ debounce = setTimeout(() => { cache.at = 0; sendSnapshot(); }, 500);
2059
+ });
2060
+ console.log(`[daemon] ${label}: watching ${dir}`);
2061
+ } catch {}
2062
+ }
2063
+ watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin || 'not found, sessions will be read only'})`);
2064
+ watchProviderDir(path.join(KIMI_DIR, 'sessions'), kimiCache, 'kimi sessions');
2065
+ watchProviderDir(path.join(KIMICODE_DIR, 'sessions'), kimiCache, 'kimi code sessions');