@clastres/groundcontrol 0.1.5 → 0.1.6

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 (2) hide show
  1. package/daemon.mjs +536 -6
  2. package/package.json +1 -1
package/daemon.mjs CHANGED
@@ -54,7 +54,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
54
54
  process.exit(0);
55
55
  }
56
56
  if (process.argv.includes('--version')) {
57
- console.log('0.1.5');
57
+ console.log('0.1.6');
58
58
  process.exit(0);
59
59
  }
60
60
 
@@ -346,10 +346,27 @@ function widenPath() {
346
346
  }
347
347
  widenPath();
348
348
 
349
- let claudeBin = null;
350
- try { claudeBin = execSync('command -v claude', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch {}
349
+ const which = (cmd) => { try { return execSync(`command -v ${cmd}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch { return null; } };
350
+ const executable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { return null; } };
351
+
352
+ const claudeBin = which('claude');
351
353
  console.log(`[daemon] claude binary: ${claudeBin || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
352
354
 
355
+ // Codex ships a full CLI inside the ChatGPT desktop app, so Codex Desktop
356
+ // users can receive phone prompts even without a standalone install.
357
+ const codexBin = which('codex') || executable('/Applications/ChatGPT.app/Contents/Resources/codex');
358
+
359
+ // Kimi Code renames a legacy Python `kimi` shim to `kimi-legacy` on install,
360
+ // so when the Kimi Code store (~/.kimi-code) exists, `kimi` on PATH belongs
361
+ // to Kimi Code and `kimi-legacy` is the old CLI. Without the store, `kimi` is
362
+ // the legacy one. The two binaries cannot resume each other's sessions, so
363
+ // neither ever doubles for the other.
364
+ const kimiCodeInstalled = fs.existsSync(path.join(os.homedir(), '.kimi-code'));
365
+ const kimiCodeBin = kimiCodeInstalled
366
+ ? which('kimi') || executable(path.join(os.homedir(), '.kimi-code', 'bin', 'kimi'))
367
+ : null;
368
+ const kimiLegacyBin = which('kimi-legacy') || (kimiCodeInstalled ? null : which('kimi'));
369
+
353
370
  // ---------- Claude Code session discovery ----------
354
371
 
355
372
  const CLAUDE = path.join(os.homedir(), '.claude');
@@ -541,6 +558,11 @@ function extractText(message) {
541
558
  function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
542
559
  const file = findTranscript(sessionId);
543
560
  if (!file) return [];
561
+ // path.sep keeps the prefixes unambiguous: without it ~/.kimi would also
562
+ // match every ~/.kimi-code path and routing would depend on check order.
563
+ if (file.startsWith(CODEX_SESS_DIR + path.sep)) return readCodexMessages(file, maxBytes, limit);
564
+ if (file.startsWith(KIMICODE_DIR + path.sep)) return readKimiCodeMessages(file, maxBytes, limit);
565
+ if (file.startsWith(KIMI_DIR + path.sep)) return readKimiLegacyMessages(file, maxBytes, limit);
544
566
  let raw;
545
567
  try {
546
568
  const size = fs.statSync(file).size;
@@ -566,7 +588,7 @@ function readMessages(sessionId, maxBytes = 512 * 1024, limit = 80) {
566
588
  return out.slice(-limit);
567
589
  }
568
590
 
569
- function readSessions() {
591
+ function readClaudeSessions() {
570
592
  const sessions = [];
571
593
  let files = [];
572
594
  try { files = fs.readdirSync(SESSIONS_DIR); } catch {}
@@ -579,6 +601,7 @@ function readSessions() {
579
601
  const tty = ttyOf(s.pid);
580
602
  sessions.push({
581
603
  id: s.sessionId,
604
+ provider: 'claude',
582
605
  name: s.name || path.basename(s.cwd || '?'),
583
606
  cwd: s.cwd || '',
584
607
  project: path.basename(s.cwd || '?'),
@@ -590,6 +613,414 @@ function readSessions() {
590
613
  live: canInject(tty),
591
614
  });
592
615
  }
616
+ return sessions;
617
+ }
618
+
619
+ // ---------- Codex session discovery ----------
620
+ // Codex (CLI, VS Code extension, and the Codex Desktop app) all write the same
621
+ // rollout JSONL files under ~/.codex/sessions/YYYY/MM/DD/. There is no pid or
622
+ // tty on disk, so a session counts as present while its rollout stays fresh,
623
+ // and busy while its last task_started has no matching end event. Prompts go
624
+ // through `codex exec resume`, which appends to the same rollout file.
625
+
626
+ const CODEX_DIR = path.join(os.homedir(), '.codex');
627
+ const CODEX_SESS_DIR = path.join(CODEX_DIR, 'sessions');
628
+ const CODEX_INDEX = path.join(CODEX_DIR, 'session_index.jsonl');
629
+ const CODEX_ACTIVE_WINDOW_MS = 2 * 60 * 60 * 1000; // rollout idle this long = off the island
630
+ const CODEX_BUSY_STALE_MS = 10 * 60 * 1000; // a working turn keeps writing; silence means it died
631
+
632
+ // Codex Desktop names threads ("Polish Slack message"); the index maps id -> name.
633
+ // The index is append-only and grows for as long as Codex is used, so it is
634
+ // only re-parsed when its size or mtime actually moved.
635
+ let codexNames = new Map();
636
+ let codexNamesAt = 0;
637
+ let codexNamesStat = '';
638
+ function codexThreadNames() {
639
+ if (Date.now() - codexNamesAt < 30_000) return codexNames;
640
+ codexNamesAt = Date.now();
641
+ try {
642
+ const st = fs.statSync(CODEX_INDEX);
643
+ const sig = `${st.size}:${st.mtimeMs}`;
644
+ if (sig === codexNamesStat) return codexNames;
645
+ codexNamesStat = sig;
646
+ for (const line of fs.readFileSync(CODEX_INDEX, 'utf8').split('\n')) {
647
+ if (!line.trim()) continue;
648
+ try {
649
+ const e = JSON.parse(line);
650
+ if (e.id && e.thread_name) codexNames.set(e.id, e.thread_name);
651
+ } catch {}
652
+ }
653
+ } catch {}
654
+ return codexNames;
655
+ }
656
+
657
+ const codexMetaCache = new Map(); // rollout path -> {size, meta} from the first line
658
+
659
+ function codexMetaOf(file) {
660
+ // A parse failure usually means the scan raced Codex mid-write of the first
661
+ // line, so a failed result is only trusted while the file has not grown;
662
+ // caching null forever would hide the session for the daemon's lifetime.
663
+ let size = 0;
664
+ try { size = fs.statSync(file).size; } catch { return null; }
665
+ const cached = codexMetaCache.get(file);
666
+ if (cached && (cached.meta || cached.size === size)) return cached.meta;
667
+ let meta = null;
668
+ try {
669
+ // The first line nests the full system prompt, so it can run past 100KB;
670
+ // keep reading until the line actually ends.
671
+ const fd = fs.openSync(file, 'r');
672
+ const chunks = [];
673
+ const buf = Buffer.alloc(64 * 1024);
674
+ for (let pos = 0; pos < 1024 * 1024; ) {
675
+ const n = fs.readSync(fd, buf, 0, buf.length, pos);
676
+ if (n === 0) break;
677
+ chunks.push(Buffer.from(buf.subarray(0, n)));
678
+ pos += n;
679
+ if (buf.subarray(0, n).includes(0x0a)) break;
680
+ }
681
+ fs.closeSync(fd);
682
+ const j = JSON.parse(Buffer.concat(chunks).toString('utf8').split('\n')[0]);
683
+ if (j.type === 'session_meta' && j.payload?.session_id) {
684
+ meta = {
685
+ id: j.payload.session_id,
686
+ cwd: j.payload.cwd || '',
687
+ startedAt: Date.parse(j.payload.timestamp || j.timestamp) || 0,
688
+ };
689
+ }
690
+ } catch {}
691
+ codexMetaCache.set(file, { size, meta });
692
+ return meta;
693
+ }
694
+
695
+ function tailLines(file, maxBytes) {
696
+ const size = fs.statSync(file).size;
697
+ const fd = fs.openSync(file, 'r');
698
+ const start = Math.max(0, size - maxBytes);
699
+ const buf = Buffer.alloc(size - start);
700
+ fs.readSync(fd, buf, 0, buf.length, start);
701
+ fs.closeSync(fd);
702
+ return buf.toString('utf8').split('\n');
703
+ }
704
+
705
+ // Journal scans re-run on every cache refresh; keying results on (mtime, size)
706
+ // makes the rescan free for every file that did not change since last time.
707
+ const scanMemo = new Map(); // key -> {m, s, v}
708
+ function memoScan(key, file, fn) {
709
+ let st;
710
+ try { st = fs.statSync(file); } catch { return fn(); }
711
+ const c = scanMemo.get(key);
712
+ if (c && c.m === st.mtimeMs && c.s === st.size) return c.v;
713
+ const v = fn();
714
+ scanMemo.set(key, { m: st.mtimeMs, s: st.size, v });
715
+ return v;
716
+ }
717
+
718
+ // The user_message / agent_message events carry only genuinely typed and
719
+ // spoken turns; injected context blocks only appear as response_items.
720
+ function readCodexMessages(file, maxBytes = 512 * 1024, limit = 80) {
721
+ let lines;
722
+ try { lines = tailLines(file, maxBytes); } catch { return []; }
723
+ const out = [];
724
+ for (const line of lines) {
725
+ if (!line.trim()) continue;
726
+ let e; try { e = JSON.parse(line); } catch { continue; }
727
+ if (e.type !== 'event_msg') continue;
728
+ const p = e.payload || {};
729
+ if (p.type === 'user_message' && p.message) {
730
+ out.push({ role: 'user', text: String(p.message).trim().slice(0, 4000), ts: e.timestamp || null });
731
+ } else if (p.type === 'agent_message' && p.message) {
732
+ out.push({ role: 'assistant', text: String(p.message).trim().slice(0, 4000), ts: e.timestamp || null });
733
+ }
734
+ }
735
+ return out.slice(-limit);
736
+ }
737
+
738
+ function codexActivityOf(file) {
739
+ let lines;
740
+ try { lines = tailLines(file, 64 * 1024); } catch { return { busy: false, sawMarkers: true, lastMessage: null }; }
741
+ let lastStart = 0, lastEnd = 0, lastMessage = null;
742
+ for (const line of lines) {
743
+ if (!line.trim()) continue;
744
+ let e; try { e = JSON.parse(line); } catch { continue; }
745
+ if (e.type !== 'event_msg') continue;
746
+ const p = e.payload || {};
747
+ const ts = Date.parse(e.timestamp) || 0;
748
+ if (p.type === 'task_started') lastStart = Math.max(lastStart, ts);
749
+ else if (p.type === 'task_complete' || p.type === 'task_aborted' || p.type === 'turn_aborted' || p.type === 'error') lastEnd = Math.max(lastEnd, ts);
750
+ else if (p.type === 'user_message' && p.message) lastMessage = { role: 'user', text: String(p.message).trim().slice(0, 4000), ts: e.timestamp || null };
751
+ else if (p.type === 'agent_message' && p.message) lastMessage = { role: 'assistant', text: String(p.message).trim().slice(0, 4000), ts: e.timestamp || null };
752
+ }
753
+ // No turn markers in the tail means a long streaming turn pushed them out
754
+ // (a finished turn always ends with its completion event in the tail), so
755
+ // the caller must treat freshness as the busy signal, not assume idle.
756
+ return { busy: lastStart > lastEnd, sawMarkers: lastStart > 0 || lastEnd > 0, lastMessage };
757
+ }
758
+
759
+ // Walking every date dir stays cheap at this scale, and a resumed months-old
760
+ // thread appends to its original file, so no date cutoff during the walk.
761
+ function codexRolloutFiles() {
762
+ const files = [];
763
+ const walk = (dir, depth) => {
764
+ let entries = [];
765
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
766
+ for (const e of entries) {
767
+ const p = path.join(dir, e.name);
768
+ if (e.isDirectory() && depth < 3) walk(p, depth + 1);
769
+ else if (e.isFile() && e.name.endsWith('.jsonl')) files.push(p);
770
+ }
771
+ };
772
+ walk(CODEX_SESS_DIR, 0);
773
+ return files;
774
+ }
775
+
776
+ let codexCache = { at: 0, sessions: [] };
777
+ function readCodexSessions() {
778
+ if (Date.now() - codexCache.at < 10_000) return codexCache.sessions;
779
+ codexCache.at = Date.now();
780
+ const names = codexThreadNames();
781
+ const sessions = [];
782
+ for (const file of codexRolloutFiles()) {
783
+ let mtime;
784
+ try { mtime = fs.statSync(file).mtimeMs; } catch { continue; }
785
+ if (Date.now() - mtime > CODEX_ACTIVE_WINDOW_MS) continue;
786
+ const meta = codexMetaOf(file);
787
+ if (!meta) continue;
788
+ const act = memoScan(`codex:${file}`, file, () => codexActivityOf(file));
789
+ const busy = (act.busy || (!act.sawMarkers && Date.now() - mtime < 60_000))
790
+ && Date.now() - mtime < CODEX_BUSY_STALE_MS;
791
+ transcriptCache.set(meta.id, file);
792
+ sessions.push({
793
+ id: meta.id,
794
+ provider: 'codex',
795
+ name: names.get(meta.id) || path.basename(meta.cwd || '?'),
796
+ cwd: meta.cwd,
797
+ project: path.basename(meta.cwd || '?'),
798
+ status: headlessActive.has(meta.id) || busy ? 'busy' : 'idle',
799
+ updatedAt: mtime,
800
+ startedAt: meta.startedAt,
801
+ lastMessage: act.lastMessage,
802
+ tty: '',
803
+ live: false,
804
+ });
805
+ }
806
+ codexCache.sessions = sessions;
807
+ return sessions;
808
+ }
809
+
810
+ // ---------- Kimi session discovery ----------
811
+ // Two on-disk generations exist. The legacy Python CLI keeps sessions in
812
+ // ~/.kimi/sessions/<md5(workdir)>/<uuid>/ with a context.jsonl history and a
813
+ // wire.jsonl event log (TurnBegin/TurnEnd bracket a working turn). The new
814
+ // Kimi Code CLI keeps ~/.kimi-code/sessions/<wdKey>/<sessionId>/ with
815
+ // state.json metadata and a per-agent journal in agents/main/wire.jsonl whose
816
+ // records are {type, time, ...payload}. Neither writes a pid, so presence and
817
+ // busyness come from journal freshness, like Codex.
818
+
819
+ const KIMI_DIR = path.join(os.homedir(), '.kimi');
820
+ const KIMICODE_DIR = path.join(os.homedir(), '.kimi-code');
821
+ const KIMI_ACTIVE_WINDOW_MS = CODEX_ACTIVE_WINDOW_MS;
822
+ // Wide enough that a turn quietly grinding through a long tool run (build,
823
+ // test suite) does not flip to idle and admit a second writer.
824
+ const KIMI_BUSY_STALE_MS = 5 * 60 * 1000;
825
+
826
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
827
+
828
+ const kosongText = (content) => {
829
+ if (typeof content === 'string') return content;
830
+ if (Array.isArray(content)) return content.filter((p) => p && (p.type === 'text' || p.text)).map((p) => p.text || '').join('\n');
831
+ return '';
832
+ };
833
+
834
+ // Legacy ~/.kimi: kimi.json maps workdirs to their md5 bucket names.
835
+ function kimiWorkdirByBucket() {
836
+ const map = new Map();
837
+ const meta = readJson(path.join(KIMI_DIR, 'kimi.json'));
838
+ for (const wd of meta?.work_dirs || []) {
839
+ if (wd?.path) map.set(crypto.createHash('md5').update(wd.path).digest('hex'), wd.path);
840
+ }
841
+ return map;
842
+ }
843
+
844
+ function readKimiLegacyMessages(file, maxBytes = 512 * 1024, limit = 80) {
845
+ let lines;
846
+ try { lines = tailLines(file, maxBytes); } catch { return []; }
847
+ const out = [];
848
+ for (const line of lines) {
849
+ if (!line.trim()) continue;
850
+ let e; try { e = JSON.parse(line); } catch { continue; }
851
+ if (e.partial || (e.role !== 'user' && e.role !== 'assistant')) continue;
852
+ const text = kosongText(e.content).trim();
853
+ if (!text) continue;
854
+ out.push({ role: e.role, text: text.slice(0, 4000), ts: null });
855
+ }
856
+ return out.slice(-limit);
857
+ }
858
+
859
+ function kimiLegacyBusy(wireFile) {
860
+ let lines;
861
+ try { lines = tailLines(wireFile, 64 * 1024); } catch { return { busy: false, sawMarkers: true }; }
862
+ let busy = false, sawMarkers = false;
863
+ for (const line of lines) {
864
+ if (!line.trim()) continue;
865
+ let e; try { e = JSON.parse(line); } catch { continue; }
866
+ const t = e.message?.type;
867
+ if (t === 'TurnBegin') { busy = true; sawMarkers = true; }
868
+ else if (t === 'TurnEnd' || t === 'StepInterrupted') { busy = false; sawMarkers = true; }
869
+ }
870
+ return { busy, sawMarkers };
871
+ }
872
+
873
+ function readKimiLegacySessions() {
874
+ const sessions = [];
875
+ const root = path.join(KIMI_DIR, 'sessions');
876
+ let buckets = [];
877
+ try { buckets = fs.readdirSync(root); } catch { return sessions; }
878
+ const workdirs = kimiWorkdirByBucket();
879
+ for (const bucket of buckets) {
880
+ let ids = [];
881
+ try { ids = fs.readdirSync(path.join(root, bucket)); } catch { continue; }
882
+ for (const id of ids) {
883
+ const dir = path.join(root, bucket, id);
884
+ const context = path.join(dir, 'context.jsonl');
885
+ const wire = path.join(dir, 'wire.jsonl');
886
+ let mtime = 0, started = 0;
887
+ try {
888
+ const st = fs.statSync(context);
889
+ mtime = st.mtimeMs;
890
+ started = st.birthtimeMs || st.ctimeMs;
891
+ } catch { continue; }
892
+ try { mtime = Math.max(mtime, fs.statSync(wire).mtimeMs); } catch {}
893
+ if (Date.now() - mtime > KIMI_ACTIVE_WINDOW_MS) continue;
894
+ const cwd = workdirs.get(bucket) || '';
895
+ const state = readJson(path.join(dir, 'state.json'));
896
+ if (state?.archived) continue;
897
+ const msgs = memoScan(`kimi:${context}`, context, () => readKimiLegacyMessages(context, 64 * 1024, 1));
898
+ const act = memoScan(`kimi:${wire}`, wire, () => kimiLegacyBusy(wire));
899
+ const busy = (act.busy || (!act.sawMarkers && Date.now() - mtime < 60_000))
900
+ && Date.now() - mtime < KIMI_BUSY_STALE_MS;
901
+ transcriptCache.set(id, context);
902
+ sessions.push({
903
+ id,
904
+ provider: 'kimi',
905
+ name: state?.custom_title || path.basename(cwd || '?'),
906
+ cwd,
907
+ project: path.basename(cwd || '?'),
908
+ status: headlessActive.has(id) || busy ? 'busy' : 'idle',
909
+ updatedAt: mtime,
910
+ startedAt: started,
911
+ lastMessage: msgs.length ? msgs[msgs.length - 1] : null,
912
+ tty: '',
913
+ live: false,
914
+ });
915
+ }
916
+ }
917
+ return sessions;
918
+ }
919
+
920
+ // A truthy but unparseable time value must not throw: this runs inside the
921
+ // snapshot interval, where an exception would crash-loop the whole daemon.
922
+ const isoOf = (t) => { try { return t ? new Date(t).toISOString() : null; } catch { return null; } };
923
+
924
+ // Kimi Code journal: turn.prompt opens a turn; a text-only assistant message
925
+ // (no tool calls), a turn.cancel, or an Interrupt closes it.
926
+ function kimiCodeScan(wireFile, maxBytes = 512 * 1024, limit = 80) {
927
+ let lines;
928
+ try { lines = tailLines(wireFile, maxBytes); } catch { return { messages: [], busy: false, sawMarkers: true, waiting: false }; }
929
+ const messages = [];
930
+ let lastPrompt = 0, lastEnd = 0;
931
+ const pendingInteractions = new Set();
932
+ for (const line of lines) {
933
+ if (!line.trim()) continue;
934
+ let e; try { e = JSON.parse(line); } catch { continue; }
935
+ const ts = isoOf(e.time);
936
+ if (e.type === 'turn.prompt' || e.type === 'turn.steer') {
937
+ lastPrompt = Math.max(lastPrompt, e.time || 0);
938
+ const text = kosongText(e.input).trim();
939
+ if (text) messages.push({ role: 'user', text: text.slice(0, 4000), ts });
940
+ } else if (e.type === 'turn.cancel') {
941
+ lastEnd = Math.max(lastEnd, e.time || 0, lastPrompt);
942
+ } else if (e.type === 'context.append_message') {
943
+ const m = e.message || {};
944
+ const text = kosongText(m.content).trim();
945
+ const toolCalls = m.toolCalls || m.tool_calls;
946
+ if (m.role === 'assistant' && !(toolCalls && toolCalls.length)) {
947
+ if (text) messages.push({ role: 'assistant', text: text.slice(0, 4000), ts });
948
+ lastEnd = Math.max(lastEnd, e.time || 0);
949
+ }
950
+ } else if (e.type === 'interaction.request') {
951
+ const key = e.id ?? e.interaction_id;
952
+ if (key) pendingInteractions.add(key);
953
+ } else if (e.type === 'interaction.resolved') {
954
+ const key = e.id ?? e.interaction_id;
955
+ if (key) pendingInteractions.delete(key); else pendingInteractions.clear();
956
+ }
957
+ }
958
+ return {
959
+ messages: messages.slice(-limit),
960
+ busy: lastPrompt > lastEnd,
961
+ // Same tail-window caveat as Codex: no markers seen means a long turn
962
+ // pushed them out, so freshness decides.
963
+ sawMarkers: lastPrompt > 0 || lastEnd > 0,
964
+ waiting: pendingInteractions.size > 0,
965
+ };
966
+ }
967
+
968
+ function readKimiCodeMessages(file, maxBytes = 512 * 1024, limit = 80) {
969
+ return kimiCodeScan(file, maxBytes, limit).messages;
970
+ }
971
+
972
+ function readKimiCodeSessions() {
973
+ const sessions = [];
974
+ const root = path.join(KIMICODE_DIR, 'sessions');
975
+ let wdKeys = [];
976
+ try { wdKeys = fs.readdirSync(root); } catch { return sessions; }
977
+ for (const wdKey of wdKeys) {
978
+ let ids = [];
979
+ try { ids = fs.readdirSync(path.join(root, wdKey)); } catch { continue; }
980
+ for (const id of ids) {
981
+ const dir = path.join(root, wdKey, id);
982
+ const state = readJson(path.join(dir, 'state.json'));
983
+ if (!state?.id || state.archived) continue;
984
+ const wire = path.join(dir, 'agents', 'main', 'wire.jsonl');
985
+ // A brand new session has state.json before its first journal write;
986
+ // state.updatedAt keeps it visible during that gap.
987
+ let mtime = state.updatedAt || 0;
988
+ try { mtime = Math.max(mtime, fs.statSync(wire).mtimeMs); } catch {}
989
+ if (Date.now() - mtime > KIMI_ACTIVE_WINDOW_MS) continue;
990
+ const scan = memoScan(`kimicode:${wire}`, wire, () => kimiCodeScan(wire, 64 * 1024, 1));
991
+ const busy = (scan.busy || (!scan.sawMarkers && Date.now() - mtime < 60_000))
992
+ && Date.now() - mtime < KIMI_BUSY_STALE_MS;
993
+ // A pending approval outranks busy: the agent is stopped on a human.
994
+ const status = scan.waiting ? 'waiting' : (headlessActive.has(state.id) || busy ? 'busy' : 'idle');
995
+ transcriptCache.set(state.id, wire);
996
+ sessions.push({
997
+ id: state.id,
998
+ provider: 'kimicode',
999
+ name: state.title || (state.lastPrompt || '').slice(0, 40) || path.basename(state.cwd || '?'),
1000
+ cwd: state.cwd || '',
1001
+ project: path.basename(state.cwd || '?'),
1002
+ status,
1003
+ updatedAt: mtime,
1004
+ startedAt: state.createdAt || 0,
1005
+ lastMessage: scan.messages.length ? scan.messages[scan.messages.length - 1] : null,
1006
+ tty: '',
1007
+ live: false,
1008
+ });
1009
+ }
1010
+ }
1011
+ return sessions;
1012
+ }
1013
+
1014
+ let kimiCache = { at: 0, sessions: [] };
1015
+ function readKimiSessions() {
1016
+ if (Date.now() - kimiCache.at < 10_000) return kimiCache.sessions;
1017
+ kimiCache.at = Date.now();
1018
+ kimiCache.sessions = [...readKimiLegacySessions(), ...readKimiCodeSessions()];
1019
+ return kimiCache.sessions;
1020
+ }
1021
+
1022
+ function readSessions() {
1023
+ const sessions = [...readClaudeSessions(), ...readCodexSessions(), ...readKimiSessions()];
593
1024
  sessions.sort((a, b) => a.startedAt - b.startedAt);
594
1025
  return sessions;
595
1026
  }
@@ -600,7 +1031,13 @@ let ws = null;
600
1031
  let lastSnapshotJson = '';
601
1032
 
602
1033
  function sendSnapshot(force = false) {
603
- const sessions = readSessions();
1034
+ // A parser tripping on a malformed journal must never take the daemon down:
1035
+ // this runs on a bare interval with nothing above it to catch a throw.
1036
+ let sessions;
1037
+ try { sessions = readSessions(); } catch (e) {
1038
+ console.log(`[daemon] snapshot failed: ${e.message}`);
1039
+ return;
1040
+ }
604
1041
  const json = JSON.stringify(sessions.map(({ lastMessage, ...s }) => ({ ...s, last: lastMessage?.text?.slice(0, 120) })));
605
1042
  if (!force && json === lastSnapshotJson) return;
606
1043
  lastSnapshotJson = json;
@@ -649,6 +1086,8 @@ async function handleCommand(cmd) {
649
1086
  const session = readSessions().find((s) => s.id === cmd.sessionId);
650
1087
  if (!session) return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session not found' }));
651
1088
 
1089
+ if (session.provider && session.provider !== 'claude') return promptHeadless(session, cmd.text);
1090
+
652
1091
  // Preferred path: type into the real terminal so the desktop updates live.
653
1092
  if (session.live) {
654
1093
  const method = await injectPrompt(session.tty, cmd.text);
@@ -679,7 +1118,7 @@ async function handleCommand(cmd) {
679
1118
  headlessActive.add(cmd.sessionId);
680
1119
  sendSnapshot(true);
681
1120
  const child = spawn(claudeBin, ['--resume', cmd.sessionId, '-p', cmd.text], {
682
- cwd: session.cwd || os.homedir(),
1121
+ cwd: fs.existsSync(session.cwd || '') ? session.cwd : os.homedir(),
683
1122
  env: process.env,
684
1123
  stdio: ['ignore', 'pipe', 'pipe'],
685
1124
  });
@@ -707,6 +1146,80 @@ async function handleCommand(cmd) {
707
1146
  }
708
1147
  }
709
1148
 
1149
+ // Sends a phone prompt to a Codex or Kimi session. There is no tty path for
1150
+ // these (their session files record no pid), so every prompt goes through the
1151
+ // provider's headless resume command, which appends the turn to the same
1152
+ // journal the transcript watcher follows — desktop app threads included.
1153
+ // Codex takes the prompt as a positional, so `--` stops phone text that
1154
+ // begins with a dash from being read as CLI flags. The kimi CLIs take it as
1155
+ // the -p option value where `--` cannot help, so a leading space defuses a
1156
+ // dash there (the models do not care about leading whitespace).
1157
+ const defuse = (text) => (/^\s*-/.test(text) ? ` ${text}` : text);
1158
+ const headlessPromptSpec = {
1159
+ codex: () => codexBin && { bin: codexBin, args: (id, text) => ['exec', 'resume', id, '--skip-git-repo-check', '--', text] },
1160
+ kimicode: () => kimiCodeBin && { bin: kimiCodeBin, args: (id, text) => ['--session', id, '-p', defuse(text)] },
1161
+ kimi: () => kimiLegacyBin && { bin: kimiLegacyBin, args: (id, text) => ['--print', '--session', id, '-p', defuse(text)] },
1162
+ };
1163
+
1164
+ function bustProviderCaches() {
1165
+ codexCache.at = 0;
1166
+ kimiCache.at = 0;
1167
+ }
1168
+
1169
+ function promptHeadless(session, text) {
1170
+ // Anything but idle means another writer owns the session right now: busy
1171
+ // is a running turn, waiting is a human approval the phone cannot answer.
1172
+ 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' }));
1173
+ 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' }));
1174
+ const spec = headlessPromptSpec[session.provider]?.();
1175
+ if (!spec) {
1176
+ console.log(`[daemon] ${session.provider} prompt failed: binary not found`);
1177
+ return ws?.send(seal({
1178
+ type: 'promptResult', sessionId: session.id, ok: false,
1179
+ error: `The daemon could not find the ${session.provider === 'codex' ? 'codex' : 'kimi'} command on this Mac, so it cannot reach this session.`,
1180
+ }));
1181
+ }
1182
+ console.log(`[daemon] prompt -> ${session.provider} ${session.name}: ${text.slice(0, 60)}`);
1183
+ const promptedAt = Date.now();
1184
+ headlessActive.add(session.id);
1185
+ bustProviderCaches();
1186
+ sendSnapshot(true);
1187
+ const child = spawn(spec.bin, spec.args(session.id, text), {
1188
+ cwd: fs.existsSync(session.cwd || '') ? session.cwd : os.homedir(),
1189
+ env: process.env,
1190
+ stdio: ['ignore', 'pipe', 'pipe'],
1191
+ });
1192
+ let err = '';
1193
+ let responded = false;
1194
+ const respond = (payload) => {
1195
+ if (responded) return;
1196
+ responded = true;
1197
+ headlessActive.delete(session.id);
1198
+ bustProviderCaches();
1199
+ ws?.send(seal(payload));
1200
+ sendSnapshot(true);
1201
+ };
1202
+ child.stderr.on('data', (d) => { err += d; });
1203
+ child.stdout.on('data', () => {});
1204
+ child.on('error', (e) => {
1205
+ console.log(`[daemon] ${session.provider} spawn failed: ${e.message}`);
1206
+ respond({ type: 'promptResult', sessionId: session.id, ok: false, error: `could not run ${session.provider}: ${e.message}` });
1207
+ });
1208
+ child.on('close', (code) => {
1209
+ // The reply is already in the journal; surface the last assistant turn,
1210
+ // but only one written after this prompt. An older message here would
1211
+ // present a stale answer as the reply (the live transcript stream shows
1212
+ // the real one either way, so an empty text is fine).
1213
+ const msgs = readMessages(session.id, 64 * 1024, 10);
1214
+ const reply = [...msgs].reverse().find((m) => m.role === 'assistant' && Date.parse(m.ts || '') > promptedAt);
1215
+ respond({
1216
+ type: 'promptResult', sessionId: session.id, ok: code === 0,
1217
+ text: code === 0 ? (reply?.text || '').slice(0, 4000) : err.trim().slice(0, 4000),
1218
+ });
1219
+ });
1220
+ watchTranscript(session.id);
1221
+ }
1222
+
710
1223
  let lastPongAt = Date.now();
711
1224
 
712
1225
  function connect() {
@@ -754,3 +1267,20 @@ setInterval(() => {
754
1267
  }
755
1268
  }, 20_000);
756
1269
  try { fs.watch(SESSIONS_DIR, () => sendSnapshot()); } catch {}
1270
+ // Recursive watches (macOS supports them) so a fresh Codex or Kimi turn busts
1271
+ // the scan cache immediately instead of waiting out the 10s refresh. Debounced:
1272
+ // a streaming turn appends to its journal many times a second, and every
1273
+ // uncached snapshot is a full rescan.
1274
+ function watchProviderDir(dir, cache, label) {
1275
+ let debounce;
1276
+ try {
1277
+ fs.watch(dir, { recursive: true }, () => {
1278
+ clearTimeout(debounce);
1279
+ debounce = setTimeout(() => { cache.at = 0; sendSnapshot(); }, 500);
1280
+ });
1281
+ console.log(`[daemon] ${label}: watching ${dir}`);
1282
+ } catch {}
1283
+ }
1284
+ watchProviderDir(CODEX_SESS_DIR, codexCache, `codex sessions (codex binary: ${codexBin || 'not found, sessions will be read only'})`);
1285
+ watchProviderDir(path.join(KIMI_DIR, 'sessions'), kimiCache, 'kimi sessions');
1286
+ watchProviderDir(path.join(KIMICODE_DIR, 'sessions'), kimiCache, 'kimi code sessions');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clastres/groundcontrol",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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"