@clastres/groundcontrol 0.1.13 → 0.1.14

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 CHANGED
@@ -22,7 +22,7 @@ import { createRequire } from 'node:module';
22
22
 
23
23
  const execFileP = promisify(execFile);
24
24
 
25
- const VERSION = '0.1.13';
25
+ const VERSION = '0.1.14';
26
26
  const PACKAGE = '@clastres/groundcontrol';
27
27
 
28
28
  // ---------- config / pairing ----------
@@ -821,7 +821,7 @@ function canInject(tty) {
821
821
  }
822
822
 
823
823
  // Returns the method used, or null if no terminal path was found.
824
- async function injectNow(tty, text) {
824
+ async function injectNow(tty, text, returns = 2) {
825
825
  const dev = `/dev/${tty}`;
826
826
  const apps = terminalApps();
827
827
  const clean = text.replace(/\r?\n/g, ' ').trim();
@@ -854,7 +854,41 @@ async function injectNow(tty, text) {
854
854
  end tell`);
855
855
  if (r === 'ok') {
856
856
  await sleep(150);
857
- await osascript(`tell application "iTerm2"
857
+ await pressEnter(tty, 'iterm');
858
+ return 'iterm';
859
+ }
860
+ }
861
+ if (apps.terminal) {
862
+ // do script always ends the text with its own newline, so the text and a
863
+ // Return arrive as one chunk and Claude Code takes them as a paste. Past
864
+ // about 40 characters that paste also swallows the next bare Return,
865
+ // whatever the delay: measured on 2.1.295, a 72 character prompt sat in
866
+ // the input box until a second Return. A Return on an empty box does
867
+ // nothing, so short prompts that already went through are safe. A menu
868
+ // is not an empty box, so the caller asks for fewer around one.
869
+ const r = await osascript(findTabScript('Terminal', dev, `do script "${escapeAS(clean)}" in tb`));
870
+ if (r === 'ok') {
871
+ for (let i = 0; i < returns; i++) {
872
+ await sleep(200);
873
+ await pressEnter(tty, 'terminal');
874
+ }
875
+ return 'terminal';
876
+ }
877
+ }
878
+ return null;
879
+ }
880
+
881
+ /// A bare Return into the terminal that hosts tty, through the same route the
882
+ /// prompt took.
883
+ async function pressEnter(tty, method) {
884
+ const dev = `/dev/${tty}`;
885
+ if (method === 'tmux') {
886
+ const pane = terminalApps().tmuxPanes.get(dev);
887
+ if (!pane) return false;
888
+ try { execFileSync('tmux', ['send-keys', '-t', pane, 'Enter'], { stdio: 'ignore' }); return true; } catch { return false; }
889
+ }
890
+ if (method === 'iterm') {
891
+ return await osascript(`tell application "iTerm2"
858
892
  repeat with w in windows
859
893
  repeat with tb in tabs of w
860
894
  repeat with s in sessions of tb
@@ -865,27 +899,28 @@ end tell`);
865
899
  end repeat
866
900
  end repeat
867
901
  end repeat
868
- end tell`);
869
- return 'iterm';
870
- }
871
- }
872
- if (apps.terminal) {
873
- const r = await osascript(findTabScript('Terminal', dev, `do script "${escapeAS(clean)}" in tb`));
874
- if (r === 'ok') {
875
- await sleep(200);
876
- await osascript(findTabScript('Terminal', dev, 'do script "" in tb'));
877
- return 'terminal';
878
- }
902
+ end tell`) === 'ok';
879
903
  }
880
- return null;
904
+ if (method === 'terminal') return await osascript(findTabScript('Terminal', dev, 'do script "" in tb')) === 'ok';
905
+ return false;
881
906
  }
882
907
 
883
908
  // One injection per terminal at a time. Each is two writes with a pause
884
909
  // between them (the text, then Enter), so two prompts landing together would
885
- // interleave into one run on prompt and a stray Enter.
910
+ // interleave into one run on prompt and a stray Enter. `watch` is called as
911
+ // the slot opens, so it sees the session as this prompt finds it rather than
912
+ // before the one queued ahead of it. It may return how many Returns to send
913
+ // and a check, which runs inside the slot, so a follow up Return cannot land
914
+ // on the next prompt's text.
886
915
  const injecting = new Map(); // tty -> tail of its queue
887
- function injectPrompt(tty, text) {
888
- const run = (injecting.get(tty) || Promise.resolve()).then(() => injectNow(tty, text));
916
+ function injectPrompt(tty, text, watch) {
917
+ const run = (injecting.get(tty) || Promise.resolve()).then(async () => {
918
+ let plan = {};
919
+ try { plan = watch?.() || {}; } catch {}
920
+ const method = await injectNow(tty, text, plan.returns);
921
+ if (method && plan.check) { try { await plan.check(method); } catch {} }
922
+ return method;
923
+ });
889
924
  const tail = run.catch(() => null);
890
925
  injecting.set(tty, tail);
891
926
  tail.then(() => { if (injecting.get(tty) === tail) injecting.delete(tty); });
@@ -955,6 +990,41 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
955
990
  return out.slice(-limit);
956
991
  }
957
992
 
993
+ // The terminal taking the keys is not Claude taking the prompt: a Return
994
+ // swallowed by a paste leaves the text in the input box, and the phone used
995
+ // to hear ok for it. An idle session writes a prompt to its journal the moment
996
+ // it is submitted, so look for it there, press Return once more if it is
997
+ // missing, and report through `result.landed` if it still is. Leaving idle
998
+ // counts too: a queued prompt or a slow prompt hook journals nothing yet, and
999
+ // a status that has not caught up with the prompt ahead must not read as a
1000
+ // lost one. Claude's own dialog and a slash command's picker take every
1001
+ // Return as a choice, so those keep the one Return they always got.
1002
+ const claudeStatus = (sessionId) => readClaudeSessions().find((s) => s.id === sessionId)?.rawStatus || null;
1003
+ const lastUserId = (sessionId) => readMessages(sessionId).findLast((m) => m.role === 'user')?.id || null;
1004
+ async function promptLanded(sessionId, before, ms = 2500) {
1005
+ for (let waited = 0; waited < ms; waited += 250) {
1006
+ await sleep(250);
1007
+ if (lastUserId(sessionId) !== before || claudeStatus(sessionId) !== 'idle') return true;
1008
+ }
1009
+ return false;
1010
+ }
1011
+ function promptWatch(session, text, result) {
1012
+ return () => {
1013
+ const status = claudeStatus(session.id);
1014
+ if (text.trim().startsWith('/') || status === 'waiting') return { returns: 1 };
1015
+ if (status !== 'idle') return {};
1016
+ const before = lastUserId(session.id);
1017
+ return {
1018
+ check: async (how) => {
1019
+ if (await promptLanded(session.id, before)) return;
1020
+ console.log(`[daemon] ${session.name} did not take the prompt, pressing Return again`);
1021
+ await pressEnter(session.tty, how);
1022
+ result.landed = await promptLanded(session.id, before);
1023
+ },
1024
+ };
1025
+ };
1026
+ }
1027
+
958
1028
  function readClaudeSessions() {
959
1029
  const sessions = [];
960
1030
  let files = [];
@@ -2333,7 +2403,19 @@ async function handleCommand(cmd) {
2333
2403
 
2334
2404
  // Preferred path: type into the real terminal so the desktop updates live.
2335
2405
  if (session.live) {
2336
- const method = await injectPrompt(session.tty, cmd.text);
2406
+ const result = { landed: true };
2407
+ const method = await injectPrompt(session.tty, cmd.text, promptWatch(session, cmd.text, result));
2408
+ if (method && !result.landed) {
2409
+ console.log(`[daemon] prompt typed into ${session.name} via ${method} but never submitted`);
2410
+ toPhone({
2411
+ type: 'promptResult', sessionId: cmd.sessionId, ok: false,
2412
+ error: 'Typed into the terminal on your Mac, but Claude did not take it. It may be waiting in the input box there.',
2413
+ });
2414
+ // Pressed by hand on the Mac, it still reaches the phone.
2415
+ watchTranscript(cmd.sessionId, 'prompt');
2416
+ sendSnapshot(true);
2417
+ return;
2418
+ }
2337
2419
  if (method) {
2338
2420
  console.log(`[daemon] injected prompt into ${session.name} via ${method}`);
2339
2421
  toPhone({ type: 'promptResult', sessionId: cmd.sessionId, ok: true, text: '' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clastres/groundcontrol",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "See and command your coding agents from your iPhone. Pairs the Ground Control app with Claude Code, Codex and Kimi sessions running on this Mac, end to end encrypted.",
5
5
  "bin": {
6
6
  "groundcontrol": "bin/groundcontrol.mjs"
@@ -0,0 +1,138 @@
1
+ // A prompt typed into Terminal.app has to be submitted, not left in the input
2
+ // box. On 2026-10-11 a 72 character prompt from the phone sat there unsent:
3
+ // do script sends the text with its own newline, Claude Code takes that as a
4
+ // paste, and the paste swallows the one Return that followed. Lift the real
5
+ // injection code out of daemon.mjs and run it against a fake Terminal.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'daemon.mjs');
11
+ const src = fs.readFileSync(DAEMON, 'utf8');
12
+ const from = '// Returns the method used, or null if no terminal path was found.';
13
+ const start = src.indexOf(from);
14
+ const end = src.indexOf('const headlessActive = new Set();', start);
15
+ if (start < 0 || end < 0) throw new Error('could not locate the injection code in daemon.mjs');
16
+
17
+ let failures = 0;
18
+ const check = (label, cond, detail = '') => {
19
+ console.log(`${cond ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`);
20
+ if (!cond) failures++;
21
+ };
22
+
23
+ const load = (scripts) => new Function(
24
+ 'terminalApps', 'execFileSync', 'execSync', 'sleep', 'osascript', 'escapeAS', 'findTabScript',
25
+ `${src.slice(start, end)}
26
+ return { injectPrompt, pressEnter };`,
27
+ )(
28
+ () => ({ tmuxPanes: new Map(), terminal: true, iterm: false }),
29
+ () => { throw new Error('no tmux here'); },
30
+ () => { throw new Error('no shell'); },
31
+ async () => {},
32
+ async (script) => { scripts.push(script.match(/do script "(.*)" in tb/)?.[1] ?? script); return 'ok'; },
33
+ (s) => s,
34
+ (app, dev, action) => action,
35
+ );
36
+
37
+ {
38
+ const scripts = [];
39
+ const { injectPrompt } = load(scripts);
40
+ const method = await injectPrompt('ttys005', 'Do we have pstack installed? Do you need it or do you have enough tools?');
41
+ check('a Terminal.app session is typed into through Terminal', method === 'terminal', `got ${method}`);
42
+ check('the text is followed by two bare Returns, enough to get past the paste',
43
+ scripts.length === 3 && scripts[1] === '' && scripts[2] === '', JSON.stringify(scripts));
44
+ }
45
+
46
+ {
47
+ // The session is read as this prompt's slot opens, after the prompt ahead
48
+ // of it went in, and the follow up check runs inside the slot: its extra
49
+ // Return must land before the next prompt is typed, not on top of it.
50
+ const scripts = [];
51
+ const { injectPrompt, pressEnter } = load(scripts);
52
+ const watch = (label) => () => { scripts.push(`WATCH ${label}`); return { check: async (how) => { scripts.push('CHECK'); await pressEnter('ttys005', how); } }; };
53
+ await Promise.all([injectPrompt('ttys005', 'FIRST', watch('1')), injectPrompt('ttys005', 'SECOND', watch('2'))]);
54
+ check('each prompt is watched from its own slot, with the extra Return before the next prompt',
55
+ scripts.join(',') === 'WATCH 1,FIRST,,,CHECK,,WATCH 2,SECOND,,,CHECK,', scripts.join(','));
56
+ let survived = true;
57
+ try {
58
+ await injectPrompt('ttys005', 'THIRD', () => ({ check: async () => { throw new Error('boom'); } }));
59
+ await injectPrompt('ttys005', 'FOURTH', () => { throw new Error('boom'); });
60
+ } catch { survived = false; }
61
+ check('a failing check does not turn a typed prompt into an error', survived);
62
+ }
63
+
64
+ {
65
+ // Fewer Returns where a Return is a choice: Claude's own dialog, a picker.
66
+ const scripts = [];
67
+ const { injectPrompt } = load(scripts);
68
+ await injectPrompt('ttys005', '/model', () => ({ returns: 1 }));
69
+ check('a slash command or an open dialog gets the one Return it always got',
70
+ scripts.length === 2 && scripts[1] === '', JSON.stringify(scripts));
71
+ }
72
+
73
+ // ---------- the handler's watch: is the prompt really in? ----------
74
+ {
75
+ const wStart = src.indexOf('// The terminal taking the keys is not Claude taking the prompt');
76
+ const wEnd = src.indexOf('function readClaudeSessions()', wStart);
77
+ if (wStart < 0 || wEnd < 0) throw new Error('could not locate the prompt watch in daemon.mjs');
78
+ // A fake Claude: its status and journal change on cue as Returns are pressed.
79
+ const rig = ({ status = 'idle', onEnter = () => {} } = {}) => {
80
+ const st = { status, ids: ['u1'], enters: 0 };
81
+ const api = new Function(
82
+ 'readClaudeSessions', 'readMessages', 'sleep', 'pressEnter', 'console',
83
+ `${src.slice(wStart, wEnd)}
84
+ return { promptWatch };`,
85
+ )(
86
+ () => [{ id: 's1', rawStatus: st.status }],
87
+ () => st.ids.map((id) => ({ role: 'user', id })),
88
+ async () => {},
89
+ async () => { st.enters++; onEnter(st); return true; },
90
+ { log() {} },
91
+ );
92
+ return { st, watch: (text = 'Do we have pstack installed? Do you need it or do you have enough tools?') => {
93
+ const result = { landed: true };
94
+ return { result, plan: api.promptWatch({ id: 's1', name: 'test', tty: 'ttys005' }, text, result)() };
95
+ } };
96
+ };
97
+
98
+ {
99
+ const { st, watch } = rig();
100
+ const { result, plan } = watch();
101
+ await plan.check('terminal');
102
+ check('a prompt stuck in the box gets one more Return, then is reported as not taken',
103
+ st.enters === 1 && result.landed === false, `enters ${st.enters}, landed ${result.landed}`);
104
+ }
105
+ {
106
+ const { st, watch } = rig({ onEnter: (x) => x.ids.push('u2') });
107
+ const { result, plan } = watch();
108
+ await plan.check('terminal');
109
+ check('one that the extra Return submits counts as landed', st.enters === 1 && result.landed === true);
110
+ }
111
+ {
112
+ const { st, watch } = rig();
113
+ const { result, plan } = watch();
114
+ st.ids.push('u2');
115
+ await plan.check('terminal');
116
+ check('one that went straight in gets no extra Return', st.enters === 0 && result.landed === true);
117
+ }
118
+ {
119
+ // The status file lags the prompt ahead by about a tenth of a second,
120
+ // so this one was read as idle and then got queued: nothing journaled.
121
+ const { st, watch } = rig();
122
+ const { result, plan } = watch();
123
+ st.status = 'busy';
124
+ await plan.check('terminal');
125
+ check('one queued behind a turn that just started is not called lost', st.enters === 0 && result.landed === true);
126
+ }
127
+ const plans = {
128
+ busy: rig({ status: 'busy' }).watch().plan,
129
+ waiting: rig({ status: 'waiting' }).watch().plan,
130
+ slash: rig().watch('/model').plan,
131
+ };
132
+ check('a busy session is typed into as usual with nothing to check', !plans.busy.check && plans.busy.returns === undefined);
133
+ check('Claude\'s own dialog gets one Return and no check', plans.waiting.returns === 1 && !plans.waiting.check);
134
+ check('a slash command gets one Return and no check', plans.slash.returns === 1 && !plans.slash.check);
135
+ }
136
+
137
+ console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
138
+ process.exit(failures ? 1 : 0);