@clastres/groundcontrol 0.1.6 → 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.
- package/daemon.mjs +796 -17
- package/hook.mjs +109 -0
- 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(
|
|
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.');
|
|
@@ -606,6 +895,10 @@ function readClaudeSessions() {
|
|
|
606
895
|
cwd: s.cwd || '',
|
|
607
896
|
project: path.basename(s.cwd || '?'),
|
|
608
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',
|
|
609
902
|
updatedAt: s.statusUpdatedAt || s.updatedAt || 0,
|
|
610
903
|
startedAt: s.startedAt || 0,
|
|
611
904
|
lastMessage: msgs.length ? msgs[msgs.length - 1] : null,
|
|
@@ -1019,12 +1312,417 @@ function readKimiSessions() {
|
|
|
1019
1312
|
return kimiCache.sessions;
|
|
1020
1313
|
}
|
|
1021
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
|
+
|
|
1022
1325
|
function readSessions() {
|
|
1023
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
|
+
|
|
1024
1364
|
sessions.sort((a, b) => a.startedAt - b.startedAt);
|
|
1025
1365
|
return sessions;
|
|
1026
1366
|
}
|
|
1027
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
|
+
|
|
1028
1726
|
// ---------- relay connection ----------
|
|
1029
1727
|
|
|
1030
1728
|
let ws = null;
|
|
@@ -1038,11 +1736,15 @@ function sendSnapshot(force = false) {
|
|
|
1038
1736
|
console.log(`[daemon] snapshot failed: ${e.message}`);
|
|
1039
1737
|
return;
|
|
1040
1738
|
}
|
|
1041
|
-
const
|
|
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]);
|
|
1042
1744
|
if (!force && json === lastSnapshotJson) return;
|
|
1043
1745
|
lastSnapshotJson = json;
|
|
1044
1746
|
if (ws?.readyState === 1) {
|
|
1045
|
-
ws.send(seal({ type: 'snapshot', at: Date.now(), sessions }));
|
|
1747
|
+
ws.send(seal({ type: 'snapshot', at: Date.now(), sessions, host }));
|
|
1046
1748
|
console.log(`[daemon] snapshot -> ${sessions.length} session(s): ${sessions.map((s) => `${s.name}:${s.status}`).join(', ') || 'none'}`);
|
|
1047
1749
|
}
|
|
1048
1750
|
}
|
|
@@ -1079,6 +1781,46 @@ async function handleCommand(cmd) {
|
|
|
1079
1781
|
ws?.send(seal({ type: 'transcript', sessionId: cmd.sessionId, messages: readMessages(cmd.sessionId) }));
|
|
1080
1782
|
} else if (cmd.type === 'unwatch' && cmd.sessionId) {
|
|
1081
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
|
+
}
|
|
1082
1824
|
} else if (cmd.type === 'ping' && cmd.sessionId) {
|
|
1083
1825
|
const s = readSessions().find((x) => x.id === cmd.sessionId);
|
|
1084
1826
|
ws?.send(seal({ type: 'pong', sessionId: cmd.sessionId, alive: !!s, status: s?.status || 'gone', at: Date.now() }));
|
|
@@ -1231,9 +1973,20 @@ function connect() {
|
|
|
1231
1973
|
ws.on('pong', () => { lastPongAt = Date.now(); });
|
|
1232
1974
|
ws.on('message', (data) => {
|
|
1233
1975
|
const str = data.toString();
|
|
1234
|
-
if (str === '{"sys":"client-joined"}') {
|
|
1976
|
+
if (str === '{"sys":"client-joined"}') {
|
|
1977
|
+
notePhoneSeen();
|
|
1978
|
+
console.log('[daemon] phone connected');
|
|
1979
|
+
sendSnapshot(true);
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1235
1982
|
if (str.startsWith('{"sys"')) return;
|
|
1236
|
-
|
|
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?)'); }
|
|
1237
1990
|
});
|
|
1238
1991
|
ws.on('close', (code) => {
|
|
1239
1992
|
if (code === 4001) {
|
|
@@ -1248,6 +2001,32 @@ function connect() {
|
|
|
1248
2001
|
|
|
1249
2002
|
connect();
|
|
1250
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
|
+
|
|
1251
2030
|
setInterval(sendSnapshot, 1500);
|
|
1252
2031
|
// Heartbeat traffic keeps the Cloudflare socket alive in both directions and
|
|
1253
2032
|
// lets the phone detect a dead link; missing pongs force a reconnect here.
|
package/hook.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Ground Control PermissionRequest hook — runs inside Claude Code, not the daemon.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code invokes this whenever a tool call needs a permission decision. It
|
|
4
|
+
// hands the request to the local daemon over a unix socket; the daemon decides
|
|
5
|
+
// whether to hold it for the phone or let it fall straight through.
|
|
6
|
+
//
|
|
7
|
+
// The one rule that matters here: when anything at all goes wrong (no daemon,
|
|
8
|
+
// no answer, malformed reply, broken pipe) this exits 0 printing nothing, which
|
|
9
|
+
// Claude Code reads as "no decision" and shows its normal terminal prompt. A
|
|
10
|
+
// user who has never opened the app must not be able to tell this is installed.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const SOCKET = path.join(os.homedir(), '.groundcontrol', 'approvals.sock');
|
|
18
|
+
// Backstop only. The daemon settles every request itself, either with a
|
|
19
|
+
// decision or with an explicit pass, so reaching this means the daemon wedged.
|
|
20
|
+
const HARD_CAP_MS = 12 * 60 * 60 * 1000;
|
|
21
|
+
const STDIN_MS = 5000;
|
|
22
|
+
|
|
23
|
+
/// Exit without a decision. Claude Code prompts in the terminal as usual.
|
|
24
|
+
function pass() {
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function decide(decision) {
|
|
29
|
+
// An unrecognised behaviour would be a silent deny in some Claude versions,
|
|
30
|
+
// so anything that is not a clean allow/deny falls back to the prompt.
|
|
31
|
+
if (!decision || (decision.behavior !== 'allow' && decision.behavior !== 'deny')) pass();
|
|
32
|
+
const out = JSON.stringify({
|
|
33
|
+
hookSpecificOutput: { hookEventName: 'PermissionRequest', decision },
|
|
34
|
+
});
|
|
35
|
+
// writeSync, not process.stdout.write: stdout is a pipe here, so an async
|
|
36
|
+
// write followed by process.exit can truncate. Half a JSON object would be
|
|
37
|
+
// unparseable, which is safe (Claude prompts) but silently loses the answer.
|
|
38
|
+
try { fs.writeSync(1, out); } catch { /* nothing left to do but pass */ }
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readStdin() {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let buf = '';
|
|
45
|
+
const done = (v) => { clearTimeout(timer); resolve(v); };
|
|
46
|
+
const timer = setTimeout(() => done(buf), STDIN_MS);
|
|
47
|
+
process.stdin.setEncoding('utf8');
|
|
48
|
+
process.stdin.on('data', (d) => { buf += d; });
|
|
49
|
+
process.stdin.on('end', () => done(buf));
|
|
50
|
+
process.stdin.on('error', () => done(''));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const raw = await readStdin();
|
|
55
|
+
let payload;
|
|
56
|
+
try { payload = JSON.parse(raw); } catch { pass(); }
|
|
57
|
+
if (!payload || typeof payload !== 'object') pass();
|
|
58
|
+
|
|
59
|
+
// Only session_id is genuinely required: it is what ties the request to a
|
|
60
|
+
// session on the island. Claude Code 2.1.220 sends no tool_use_id on this
|
|
61
|
+
// event (verified against a live session), and the daemon mints its own
|
|
62
|
+
// request id anyway, so requiring one here would drop every real request.
|
|
63
|
+
if (!payload.session_id) pass();
|
|
64
|
+
|
|
65
|
+
const sock = net.connect(SOCKET);
|
|
66
|
+
sock.setEncoding('utf8');
|
|
67
|
+
|
|
68
|
+
let settled = false;
|
|
69
|
+
const finish = (fn, arg) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
settled = true;
|
|
72
|
+
try { sock.destroy(); } catch {}
|
|
73
|
+
fn(arg);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const cap = setTimeout(() => finish(pass), HARD_CAP_MS);
|
|
77
|
+
cap.unref?.();
|
|
78
|
+
|
|
79
|
+
// No daemon, stale socket file, wrong permissions: all of these land here and
|
|
80
|
+
// all of them mean "behave exactly like Ground Control is not installed".
|
|
81
|
+
sock.on('error', () => finish(pass));
|
|
82
|
+
// A close before any reply means the daemon died mid-decision.
|
|
83
|
+
sock.on('close', () => finish(pass));
|
|
84
|
+
|
|
85
|
+
sock.on('connect', () => {
|
|
86
|
+
sock.write(JSON.stringify({
|
|
87
|
+
v: 1,
|
|
88
|
+
sessionId: payload.session_id,
|
|
89
|
+
toolUseId: payload.tool_use_id || '',
|
|
90
|
+
toolName: payload.tool_name || 'tool',
|
|
91
|
+
toolInput: payload.tool_input || {},
|
|
92
|
+
cwd: payload.cwd || '',
|
|
93
|
+
permissionMode: payload.permission_mode || '',
|
|
94
|
+
permissionContext: payload.permission_context || null,
|
|
95
|
+
}) + '\n');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
let inbox = '';
|
|
99
|
+
sock.on('data', (chunk) => {
|
|
100
|
+
inbox += chunk;
|
|
101
|
+
const nl = inbox.indexOf('\n');
|
|
102
|
+
if (nl === -1) return;
|
|
103
|
+
let reply;
|
|
104
|
+
try { reply = JSON.parse(inbox.slice(0, nl)); } catch { return finish(pass); }
|
|
105
|
+
// decision null is the daemon explicitly passing (you are at the Mac, the
|
|
106
|
+
// hold expired, or the phone never answered).
|
|
107
|
+
if (!reply || !reply.decision) return finish(pass);
|
|
108
|
+
finish(decide, reply.decision);
|
|
109
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "See and command your Claude Code sessions from your iPhone. Pairs the Ground Control app with the agents running on this Mac, end to end encrypted.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"groundcontrol": "bin/groundcontrol.mjs"
|