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