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.
Files changed (2) hide show
  1. package/dist/index.js +147 -66
  2. 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.8",
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: wrap Claude in `script` to give it a real PTY.
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
- const scriptArgs = buildScriptArgs(shell, claudeCmd);
553
- this.proc = (0, import_child_process.spawn)("script", scriptArgs, {
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 (Claude may ignore pipe stdin after 3 s),
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
- function buildScriptArgs(shell, claudeCmd) {
651
- const innerCmd = `exec ${claudeCmd}`;
652
- if (process.platform === "darwin") {
653
- return ["-q", "/dev/null", shell, "-c", innerCmd];
711
+ removeTempFile() {
712
+ if (this.helperPath) {
713
+ try {
714
+ fs2.unlinkSync(this.helperPath);
715
+ } catch {
716
+ }
717
+ this.helperPath = null;
718
+ }
654
719
  }
655
- const shellCmd = `${shell} -c ${JSON.stringify(innerCmd)}`;
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
- flushTimer = null;
687
- streamTimer = null;
750
+ lastSentBuffer = "";
751
+ stableCount = 0;
752
+ pollTimer = null;
753
+ startTime = 0;
688
754
  active = false;
689
- static STREAM_INTERVAL_MS = 1e3;
690
- static DONE_DEBOUNCE_MS = 2500;
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 and pushes a new_turn event so the mobile app
694
- * shows the typing indicator.
763
+ * Clears previous output, sends new_turn event, starts polling.
695
764
  */
696
- async newTurn() {
697
- this.stopTimers();
765
+ newTurn() {
766
+ this.stopPoll();
698
767
  this.buffer = "";
768
+ this.lastSentBuffer = "";
769
+ this.stableCount = 0;
699
770
  this.active = true;
700
- await this.postChunk({ clear: true });
701
- await this.postChunk({ type: "new_turn", content: "", done: false });
702
- this.startStreamTimer();
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 (!text.trim()) return;
709
- this.buffer += text;
710
- this.resetDoneDebounce();
780
+ if (text) this.buffer += text;
711
781
  }
712
782
  dispose() {
713
- this.stopTimers();
783
+ this.stopPoll();
714
784
  this.active = false;
715
785
  }
716
- startStreamTimer() {
717
- this.streamTimer = setInterval(() => {
718
- const text = this.buffer;
719
- if (text.trim()) {
720
- this.postChunk({ type: "text", content: text, done: false }).catch(() => {
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
- }, _OutputService.STREAM_INTERVAL_MS);
724
- }
725
- resetDoneDebounce() {
726
- if (this.flushTimer) clearTimeout(this.flushTimer);
727
- this.flushTimer = setTimeout(() => {
728
- this.flushTimer = null;
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
- }, _OutputService.DONE_DEBOUNCE_MS);
738
- }
739
- stopTimers() {
740
- if (this.flushTimer) {
741
- clearTimeout(this.flushTimer);
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
- if (this.streamTimer) {
745
- clearInterval(this.streamTimer);
746
- this.streamTimer = null;
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().catch(() => {
803
- });
884
+ outputSvc.newTurn();
804
885
  claude.sendCommand(prompt);
805
886
  }
806
887
  const relay = new CommandRelayService(pluginId, (cmd) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeam-cli",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "description": "Remote control Claude Code from your mobile device",
5
5
  "main": "dist/index.js",
6
6
  "bin": {