codeam-cli 1.0.8 → 1.1.0
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/dist/index.js +147 -66
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -110,7 +110,7 @@ var import_picocolors = __toESM(require("picocolors"));
|
|
|
110
110
|
// package.json
|
|
111
111
|
var package_default = {
|
|
112
112
|
name: "codeam-cli",
|
|
113
|
-
version: "1.0
|
|
113
|
+
version: "1.1.0",
|
|
114
114
|
description: "Remote control Claude Code from your mobile device",
|
|
115
115
|
main: "dist/index.js",
|
|
116
116
|
bin: {
|
|
@@ -510,13 +510,70 @@ var CommandRelayService = class {
|
|
|
510
510
|
// src/services/claude.service.ts
|
|
511
511
|
var import_child_process = require("child_process");
|
|
512
512
|
var fs2 = __toESM(require("fs"));
|
|
513
|
+
var os3 = __toESM(require("os"));
|
|
513
514
|
var path2 = __toESM(require("path"));
|
|
515
|
+
var PYTHON_PTY_HELPER = `import os,pty,sys,select,signal,struct,fcntl,termios,errno
|
|
516
|
+
m,s=pty.openpty()
|
|
517
|
+
try:
|
|
518
|
+
cols=int(os.environ.get('COLUMNS','220'))
|
|
519
|
+
rows=int(os.environ.get('LINES','50'))
|
|
520
|
+
fcntl.ioctl(s,termios.TIOCSWINSZ,struct.pack('HHHH',rows,cols,0,0))
|
|
521
|
+
except Exception:pass
|
|
522
|
+
pid=os.fork()
|
|
523
|
+
if pid==0:
|
|
524
|
+
os.close(m)
|
|
525
|
+
os.setsid()
|
|
526
|
+
try:fcntl.ioctl(s,termios.TIOCSCTTY,0)
|
|
527
|
+
except Exception:pass
|
|
528
|
+
for fd in[0,1,2]:os.dup2(s,fd)
|
|
529
|
+
if s>2:os.close(s)
|
|
530
|
+
os.execvp(sys.argv[1],sys.argv[1:])
|
|
531
|
+
sys.exit(127)
|
|
532
|
+
os.close(s)
|
|
533
|
+
done=[False]
|
|
534
|
+
def onchld(n,f):
|
|
535
|
+
try:os.waitpid(pid,os.WNOHANG)
|
|
536
|
+
except Exception:pass
|
|
537
|
+
done[0]=True
|
|
538
|
+
def onwinch(n,f):
|
|
539
|
+
try:
|
|
540
|
+
sz=os.get_terminal_size(2)
|
|
541
|
+
fcntl.ioctl(m,termios.TIOCSWINSZ,struct.pack('HHHH',sz.lines,sz.columns,0,0))
|
|
542
|
+
except Exception:pass
|
|
543
|
+
signal.signal(signal.SIGCHLD,onchld)
|
|
544
|
+
signal.signal(signal.SIGWINCH,onwinch)
|
|
545
|
+
i=sys.stdin.fileno()
|
|
546
|
+
o=sys.stdout.fileno()
|
|
547
|
+
while not done[0]:
|
|
548
|
+
try:r,_,_=select.select([i,m],[],[],0.1)
|
|
549
|
+
except OSError as e:
|
|
550
|
+
if e.errno==errno.EINTR:continue
|
|
551
|
+
break
|
|
552
|
+
if i in r:
|
|
553
|
+
try:
|
|
554
|
+
d=os.read(i,4096)
|
|
555
|
+
if d:os.write(m,d)
|
|
556
|
+
else:break
|
|
557
|
+
except OSError:break
|
|
558
|
+
if m in r:
|
|
559
|
+
try:
|
|
560
|
+
d=os.read(m,4096)
|
|
561
|
+
if d:os.write(o,d)
|
|
562
|
+
except OSError:done[0]=True
|
|
563
|
+
try:os.kill(pid,signal.SIGTERM)
|
|
564
|
+
except Exception:pass
|
|
565
|
+
try:
|
|
566
|
+
_,st=os.waitpid(pid,0)
|
|
567
|
+
sys.exit((st>>8)&0xFF)
|
|
568
|
+
except Exception:sys.exit(0)
|
|
569
|
+
`;
|
|
514
570
|
var ClaudeService = class {
|
|
515
571
|
constructor(opts) {
|
|
516
572
|
this.opts = opts;
|
|
517
573
|
}
|
|
518
574
|
opts;
|
|
519
575
|
proc = null;
|
|
576
|
+
helperPath = null;
|
|
520
577
|
spawn() {
|
|
521
578
|
if (this.proc) {
|
|
522
579
|
this.cleanup();
|
|
@@ -537,20 +594,24 @@ var ClaudeService = class {
|
|
|
537
594
|
}
|
|
538
595
|
}
|
|
539
596
|
/**
|
|
540
|
-
* macOS / Linux:
|
|
541
|
-
*
|
|
542
|
-
* Why: Claude Code (React Ink) checks stdin.isTTY. When stdin is a pipe it
|
|
543
|
-
* shows a "no stdin data in 3s" warning and then stops reading stdin entirely.
|
|
544
|
-
* `script` creates a PTY for the child so Claude sees stdin as a terminal,
|
|
545
|
-
* while the parent (`codeam`) still controls script's stdin/stdout as pipes —
|
|
546
|
-
* enabling mobile command injection and output capture.
|
|
597
|
+
* macOS / Linux: use a Python PTY helper to give Claude a real TTY.
|
|
598
|
+
* Falls back to direct spawn if python3 is not available.
|
|
547
599
|
*/
|
|
548
600
|
spawnWithPty(claudeCmd) {
|
|
601
|
+
const python = findInPath("python3") ?? findInPath("python");
|
|
602
|
+
if (!python) {
|
|
603
|
+
console.error(
|
|
604
|
+
" \xB7 python3 not found; mobile command injection may be limited.\n"
|
|
605
|
+
);
|
|
606
|
+
this.spawnDirect(claudeCmd);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
549
609
|
const shell = process.env.SHELL || "/bin/sh";
|
|
550
610
|
const cols = process.stdout.columns || 220;
|
|
551
611
|
const rows = process.stdout.rows || 50;
|
|
552
|
-
|
|
553
|
-
|
|
612
|
+
this.helperPath = path2.join(os3.tmpdir(), "codeam-pty-helper.py");
|
|
613
|
+
fs2.writeFileSync(this.helperPath, PYTHON_PTY_HELPER, { mode: 420 });
|
|
614
|
+
this.proc = (0, import_child_process.spawn)(python, [this.helperPath, shell, "-c", `exec ${claudeCmd}`], {
|
|
554
615
|
stdio: ["pipe", "pipe", "inherit"],
|
|
555
616
|
cwd: this.opts.cwd,
|
|
556
617
|
env: {
|
|
@@ -578,14 +639,14 @@ var ClaudeService = class {
|
|
|
578
639
|
process.stdin.on("data", this.stdinHandler);
|
|
579
640
|
process.on("SIGWINCH", this.handleResize);
|
|
580
641
|
this.proc.on("exit", (code) => {
|
|
642
|
+
this.removeTempFile();
|
|
581
643
|
this.cleanup();
|
|
582
644
|
this.opts.onExit(code ?? 0);
|
|
583
645
|
});
|
|
584
646
|
}
|
|
585
647
|
/**
|
|
586
|
-
* Windows fallback: direct spawn without PTY.
|
|
587
|
-
* Mobile command injection is limited
|
|
588
|
-
* but the terminal session still works for manual use.
|
|
648
|
+
* Windows (or Python-unavailable) fallback: direct spawn without PTY.
|
|
649
|
+
* Mobile command injection is limited on Windows.
|
|
589
650
|
*/
|
|
590
651
|
spawnDirect(claudeCmd) {
|
|
591
652
|
this.proc = (0, import_child_process.spawn)(claudeCmd, [], {
|
|
@@ -623,6 +684,7 @@ var ClaudeService = class {
|
|
|
623
684
|
}
|
|
624
685
|
kill() {
|
|
625
686
|
this.proc?.kill();
|
|
687
|
+
this.removeTempFile();
|
|
626
688
|
this.cleanup();
|
|
627
689
|
}
|
|
628
690
|
stdinHandler = (chunk) => {
|
|
@@ -646,15 +708,16 @@ var ClaudeService = class {
|
|
|
646
708
|
}
|
|
647
709
|
}
|
|
648
710
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
711
|
+
removeTempFile() {
|
|
712
|
+
if (this.helperPath) {
|
|
713
|
+
try {
|
|
714
|
+
fs2.unlinkSync(this.helperPath);
|
|
715
|
+
} catch {
|
|
716
|
+
}
|
|
717
|
+
this.helperPath = null;
|
|
718
|
+
}
|
|
654
719
|
}
|
|
655
|
-
|
|
656
|
-
return ["-q", "-e", "-c", shellCmd, "/dev/null"];
|
|
657
|
-
}
|
|
720
|
+
};
|
|
658
721
|
function findInPath(name) {
|
|
659
722
|
const dirs = (process.env.PATH ?? "").split(path2.delimiter);
|
|
660
723
|
for (const dir of dirs) {
|
|
@@ -682,68 +745,87 @@ var OutputService = class _OutputService {
|
|
|
682
745
|
}
|
|
683
746
|
sessionId;
|
|
684
747
|
pluginId;
|
|
748
|
+
// Buffer that accumulates PTY text (ANSI stripped, growing during response)
|
|
685
749
|
buffer = "";
|
|
686
|
-
|
|
687
|
-
|
|
750
|
+
lastSentBuffer = "";
|
|
751
|
+
stableCount = 0;
|
|
752
|
+
pollTimer = null;
|
|
753
|
+
startTime = 0;
|
|
688
754
|
active = false;
|
|
689
|
-
|
|
690
|
-
static
|
|
755
|
+
// After this many stable polls (POLL_MS each) with content, mark done
|
|
756
|
+
static POLL_MS = 1e3;
|
|
757
|
+
static STABLE_THRESHOLD = 3;
|
|
758
|
+
// Give up after 2 minutes (absolute) or 30 seconds with no content at all
|
|
759
|
+
static MAX_MS = 12e4;
|
|
760
|
+
static EMPTY_TIMEOUT_MS = 3e4;
|
|
691
761
|
/**
|
|
692
762
|
* Call before sending a command from mobile.
|
|
693
|
-
* Clears previous output
|
|
694
|
-
* shows the typing indicator.
|
|
763
|
+
* Clears previous output, sends new_turn event, starts polling.
|
|
695
764
|
*/
|
|
696
|
-
|
|
697
|
-
this.
|
|
765
|
+
newTurn() {
|
|
766
|
+
this.stopPoll();
|
|
698
767
|
this.buffer = "";
|
|
768
|
+
this.lastSentBuffer = "";
|
|
769
|
+
this.stableCount = 0;
|
|
699
770
|
this.active = true;
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
771
|
+
this.startTime = Date.now();
|
|
772
|
+
this.postChunk({ clear: true }).then(() => this.postChunk({ type: "new_turn", content: "", done: false })).catch(() => {
|
|
773
|
+
});
|
|
774
|
+
this.pollTimer = setInterval(() => this.checkStability(), _OutputService.POLL_MS);
|
|
703
775
|
}
|
|
704
776
|
/** Feed raw terminal output from Claude (called on every stdout chunk). */
|
|
705
777
|
push(raw) {
|
|
706
778
|
if (!this.active) return;
|
|
707
779
|
const text = stripAnsi(raw).replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
|
708
|
-
if (
|
|
709
|
-
this.buffer += text;
|
|
710
|
-
this.resetDoneDebounce();
|
|
780
|
+
if (text) this.buffer += text;
|
|
711
781
|
}
|
|
712
782
|
dispose() {
|
|
713
|
-
this.
|
|
783
|
+
this.stopPoll();
|
|
714
784
|
this.active = false;
|
|
715
785
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
786
|
+
/**
|
|
787
|
+
* Stability check — runs every POLL_MS while active.
|
|
788
|
+
*
|
|
789
|
+
* Only finalises when we have content that has stopped changing.
|
|
790
|
+
* This avoids premature done signals while Claude is still thinking
|
|
791
|
+
* (which would set active=false and silently drop the real response).
|
|
792
|
+
*/
|
|
793
|
+
checkStability() {
|
|
794
|
+
if (!this.active) return;
|
|
795
|
+
if (Date.now() - this.startTime > _OutputService.MAX_MS) {
|
|
796
|
+
this.finalize();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const current = this.buffer;
|
|
800
|
+
if (!current.trim()) {
|
|
801
|
+
if (Date.now() - this.startTime > _OutputService.EMPTY_TIMEOUT_MS) {
|
|
802
|
+
this.finalize();
|
|
722
803
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
this.stopTimers();
|
|
730
|
-
const text = this.buffer;
|
|
731
|
-
this.buffer = "";
|
|
732
|
-
this.active = false;
|
|
733
|
-
if (text.trim()) {
|
|
734
|
-
this.postChunk({ type: "text", content: text, done: true }).catch(() => {
|
|
735
|
-
});
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (current === this.lastSentBuffer) {
|
|
807
|
+
this.stableCount++;
|
|
808
|
+
if (this.stableCount >= _OutputService.STABLE_THRESHOLD) {
|
|
809
|
+
this.finalize();
|
|
736
810
|
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
this.flushTimer = null;
|
|
811
|
+
} else {
|
|
812
|
+
this.stableCount = 0;
|
|
813
|
+
this.lastSentBuffer = current;
|
|
814
|
+
this.postChunk({ type: "text", content: current, done: false }).catch(() => {
|
|
815
|
+
});
|
|
743
816
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
817
|
+
}
|
|
818
|
+
finalize() {
|
|
819
|
+
const text = this.buffer;
|
|
820
|
+
this.stopPoll();
|
|
821
|
+
this.active = false;
|
|
822
|
+
this.postChunk({ type: "text", content: text, done: true }).catch(() => {
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
stopPoll() {
|
|
826
|
+
if (this.pollTimer) {
|
|
827
|
+
clearInterval(this.pollTimer);
|
|
828
|
+
this.pollTimer = null;
|
|
747
829
|
}
|
|
748
830
|
}
|
|
749
831
|
postChunk(body) {
|
|
@@ -799,8 +881,7 @@ async function start() {
|
|
|
799
881
|
const ws = new WebSocketService(session.id, pluginId);
|
|
800
882
|
const outputSvc = new OutputService(session.id, pluginId);
|
|
801
883
|
function sendPrompt(prompt) {
|
|
802
|
-
outputSvc.newTurn()
|
|
803
|
-
});
|
|
884
|
+
outputSvc.newTurn();
|
|
804
885
|
claude.sendCommand(prompt);
|
|
805
886
|
}
|
|
806
887
|
const relay = new CommandRelayService(pluginId, (cmd) => {
|