@clastres/groundcontrol 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/daemon.mjs +1331 -22
  2. package/hook.mjs +109 -0
  3. package/package.json +1 -1
package/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.5",
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"