@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 +1000 -33
- package/hook.mjs +109 -0
- package/package.json +8 -2
- package/test/binaries.mjs +68 -0
- package/test/grokbot.mjs +86 -0
- package/test/run.mjs +23 -0
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(
|
|
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
|
|
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(
|
|
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.
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
353
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1160
|
-
kimicode: () => kimiCodeBin && { bin
|
|
1161
|
-
kimi: () => kimiLegacyBin && { bin
|
|
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"}') {
|
|
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
|
-
|
|
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,
|
|
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)');
|