@clastres/groundcontrol 0.1.4 → 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.
- package/daemon.mjs +545 -7
- package/package.json +3 -3
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.
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
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
|
-
|
|
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,10 +1146,87 @@ 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() {
|
|
713
|
-
|
|
1226
|
+
// handshakeTimeout: a dial that never completes would otherwise sit in
|
|
1227
|
+
// CONNECTING forever without firing close, freezing the retry loop.
|
|
1228
|
+
ws = new WebSocket(`${RELAY}/ch/${CHANNEL}?role=host`, { handshakeTimeout: 15_000 });
|
|
1229
|
+
lastPongAt = Date.now();
|
|
714
1230
|
ws.on('open', () => { lastPongAt = Date.now(); console.log('[daemon] connected to relay'); sendSnapshot(true); });
|
|
715
1231
|
ws.on('pong', () => { lastPongAt = Date.now(); });
|
|
716
1232
|
ws.on('message', (data) => {
|
|
@@ -743,6 +1259,28 @@ setInterval(() => {
|
|
|
743
1259
|
console.log('[daemon] stale relay link, forcing reconnect');
|
|
744
1260
|
ws.terminate();
|
|
745
1261
|
}
|
|
1262
|
+
} else if (ws && ws.readyState === 0 && Date.now() - lastPongAt > 65_000) {
|
|
1263
|
+
// Backstop for a handshake stuck past its timeout; terminate fires the
|
|
1264
|
+
// close handler, which schedules the next retry.
|
|
1265
|
+
console.log('[daemon] connect attempt hung, forcing reconnect');
|
|
1266
|
+
ws.terminate();
|
|
746
1267
|
}
|
|
747
1268
|
}, 20_000);
|
|
748
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,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"groundcontrol": "
|
|
6
|
+
"groundcontrol": "bin/groundcontrol.mjs"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"engines": {
|
|
@@ -26,4 +26,4 @@
|
|
|
26
26
|
"homepage": "https://groundcontrol.chat",
|
|
27
27
|
"author": "Carlos Lastres",
|
|
28
28
|
"license": "MIT"
|
|
29
|
-
}
|
|
29
|
+
}
|