@threadbase-sh/streamer 1.23.0 → 1.24.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 CHANGED
@@ -480,10 +480,50 @@ async function createPool(config) {
480
480
  return new Pool(poolConfig);
481
481
  }
482
482
 
483
- // src/process-discovery.ts
484
- import { execFile } from "child_process";
485
- import { platform as platform2 } from "os";
486
- import { basename, dirname as dirname3 } from "path";
483
+ // src/live-session-manager.ts
484
+ import { basename as basename3 } from "path";
485
+
486
+ // src/codex-pty-runner.ts
487
+ import { Terminal } from "@xterm/headless";
488
+ import { randomUUID } from "crypto";
489
+ import { existsSync as existsSync2 } from "fs";
490
+ import { basename } from "path";
491
+
492
+ // src/logger.ts
493
+ import pino from "pino";
494
+ var baseLogger = pino({
495
+ level: process.env.LOG_LEVEL ?? "info",
496
+ base: { service: "tb-streamer" },
497
+ timestamp: pino.stdTimeFunctions.isoTime,
498
+ redact: {
499
+ paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
500
+ censor: "[redacted]"
501
+ }
502
+ });
503
+ function emit(pinoChild, level, msg, fields, dest) {
504
+ if (dest === "pino" || dest === "both") {
505
+ if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
506
+ else pinoChild[level](msg);
507
+ }
508
+ if (dest === "console" || dest === "both") {
509
+ const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
510
+ console[consoleMethod](msg);
511
+ }
512
+ }
513
+ function build(pinoChild) {
514
+ return {
515
+ debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
516
+ info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
517
+ warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
518
+ error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
519
+ log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
520
+ pino: pinoChild
521
+ };
522
+ }
523
+ function getLogger(component) {
524
+ return build(component ? baseLogger.child({ component }) : baseLogger);
525
+ }
526
+ var logger = build(baseLogger);
487
527
 
488
528
  // src/platform.ts
489
529
  import { execFileSync } from "child_process";
@@ -549,188 +589,534 @@ function resolveClaudeExe() {
549
589
  _claudeExe = "claude";
550
590
  return _claudeExe;
551
591
  }
592
+ var _codexExe;
593
+ function resolveCodexExe() {
594
+ if (_codexExe !== void 0) return _codexExe;
595
+ if (isWindows) {
596
+ try {
597
+ const found = execFileSync("where.exe", ["codex"], {
598
+ encoding: "utf-8",
599
+ windowsHide: true,
600
+ timeout: 3e3
601
+ }).trim().split("\n")[0].trim();
602
+ if (found) {
603
+ _codexExe = found;
604
+ return _codexExe;
605
+ }
606
+ } catch {
607
+ }
608
+ const candidates = [
609
+ join4(homedir2(), ".local", "bin", "codex.exe"),
610
+ join4(
611
+ process.env.LOCALAPPDATA ?? join4(homedir2(), "AppData", "Local"),
612
+ "Microsoft",
613
+ "WindowsApps",
614
+ "codex.exe"
615
+ )
616
+ ];
617
+ for (const p of candidates) {
618
+ if (existsSync(p)) {
619
+ _codexExe = p;
620
+ return _codexExe;
621
+ }
622
+ }
623
+ } else {
624
+ try {
625
+ const found = execFileSync("/usr/bin/which", ["codex"], {
626
+ encoding: "utf-8",
627
+ timeout: 3e3
628
+ }).trim().split("\n")[0].trim();
629
+ if (found && existsSync(found)) {
630
+ _codexExe = found;
631
+ return _codexExe;
632
+ }
633
+ } catch {
634
+ }
635
+ const candidates = [
636
+ "/opt/homebrew/bin/codex",
637
+ "/usr/local/bin/codex",
638
+ join4(homedir2(), ".local", "bin", "codex")
639
+ ];
640
+ for (const p of candidates) {
641
+ if (existsSync(p)) {
642
+ _codexExe = p;
643
+ return _codexExe;
644
+ }
645
+ }
646
+ }
647
+ _codexExe = "codex";
648
+ return _codexExe;
649
+ }
552
650
 
553
- // src/process-discovery.ts
554
- async function discoverClaudeProcesses() {
555
- if (platform2() === "win32") return discoverWindows();
556
- return discoverUnix();
651
+ // src/providers.ts
652
+ var CLAUDE_CODE_PROVIDER = "claude-code";
653
+ var CODEX_CLI_PROVIDER = "codex-cli";
654
+ function isProviderName(value) {
655
+ return value === CLAUDE_CODE_PROVIDER || value === CODEX_CLI_PROVIDER;
557
656
  }
558
- async function discoverUnix() {
559
- const pids = await getPidsUnix();
560
- const results = await Promise.all(
561
- pids.map(async (pid) => {
562
- try {
563
- const [cwd, args, startedAt] = await Promise.all([
564
- getProcessCwdUnix(pid),
565
- getProcessArgsUnix(pid),
566
- getProcessStartTimeUnix(pid)
567
- ]);
568
- const conversationId = extractResumeId(args);
569
- return {
570
- pid,
571
- projectPath: cwd,
572
- projectName: basename(cwd),
573
- branch: await readGitBranch(cwd),
574
- conversationId,
575
- startedAt
576
- };
577
- } catch {
578
- return null;
579
- }
580
- })
581
- );
582
- return results.filter((r) => r !== null);
657
+ function isProviderResumable(_provider, availabilityResumable) {
658
+ return availabilityResumable;
583
659
  }
584
- async function discoverWindows() {
585
- const pids = await getPidsWindows();
586
- const results = await Promise.all(
587
- pids.map(async (pid) => {
660
+
661
+ // src/codex-pty-runner.ts
662
+ var OUTPUT_BUFFER_MAX = 65536;
663
+ var PTY_COLS = 120;
664
+ var PTY_ROWS = 40;
665
+ var SCREEN_SCROLLBACK = 1e3;
666
+ var CODEX_PROMPT_READY_TEXT = "Ready";
667
+ var CODEX_TRUST_GATE_REGEX = /trust the contents/i;
668
+ var SUBMIT_BYTES = "\r";
669
+ var CODEX_SUBMIT_DELAY_MS = 16;
670
+ function digestBytes(s) {
671
+ const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
672
+ if (escaped.length <= 200) return escaped;
673
+ return `${escaped.slice(0, 100)}\u2026[${escaped.length - 200}B omitted]\u2026${escaped.slice(-100)}`;
674
+ }
675
+ var pty = null;
676
+ async function loadPty() {
677
+ if (pty) return pty;
678
+ try {
679
+ pty = await import("node-pty");
680
+ return pty;
681
+ } catch (err) {
682
+ throw new Error(
683
+ `node-pty is required for PTY management but failed to load. Ensure it is installed: npm install node-pty
684
+ Original error: ${err}`
685
+ );
686
+ }
687
+ }
688
+ function createScreen() {
689
+ return new Terminal({
690
+ cols: PTY_COLS,
691
+ rows: PTY_ROWS,
692
+ scrollback: SCREEN_SCROLLBACK,
693
+ allowProposedApi: true
694
+ });
695
+ }
696
+ var CodexPtyRunner = class {
697
+ sessions = /* @__PURE__ */ new Map();
698
+ onOutput;
699
+ onStatusChange;
700
+ onReady;
701
+ // Accepted for shape-compatibility with PTYManagerOptions; Codex has no
702
+ // detected equivalent yet (Phase 0) — never invoked.
703
+ onPermissionChange;
704
+ onLiveQuestion;
705
+ onLiveQuestionGone;
706
+ log;
707
+ // Tracks sessions whose PTY has spawned but Codex hasn't yet reached its
708
+ // "Ready" status bar — i.e. onReady hasn't fired.
709
+ pendingReady = /* @__PURE__ */ new Set();
710
+ // Inputs received via sendInput() while the session was still pendingReady.
711
+ // Flushed in arrival order once Codex reaches Ready.
712
+ queuedInputs = /* @__PURE__ */ new Map();
713
+ // Per-session debounce so the directory-trust gate's \r is only written once.
714
+ trustGateAnswered = /* @__PURE__ */ new Set();
715
+ // In-flight start()/startFresh() calls keyed by sessionId. A second
716
+ // concurrent resume for the same session (double-tap, client retry) awaits
717
+ // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
718
+ startPromises = /* @__PURE__ */ new Map();
719
+ constructor(options = {}) {
720
+ this.onOutput = options.onOutput;
721
+ this.onStatusChange = options.onStatusChange;
722
+ this.onReady = options.onReady;
723
+ this.onPermissionChange = options.onPermissionChange;
724
+ this.onLiveQuestion = options.onLiveQuestion;
725
+ this.onLiveQuestionGone = options.onLiveQuestionGone;
726
+ this.log = options.logger ?? getLogger("codex-pty");
727
+ }
728
+ // Resume an existing Codex session. sessionId is the Codex-persisted
729
+ // session_meta.payload.id (Phase 0, Section 8) — Codex has no fresh-session
730
+ // equivalent of --session-id, so start() always means "resume".
731
+ async start(sessionId, options) {
732
+ const existing = this.sessions.get(sessionId);
733
+ if (existing) return toPublicSession(existing);
734
+ const inFlight = this.startPromises.get(sessionId);
735
+ if (inFlight) return inFlight;
736
+ const promise = this.doStart(sessionId, options).finally(() => {
737
+ this.startPromises.delete(sessionId);
738
+ });
739
+ this.startPromises.set(sessionId, promise);
740
+ return promise;
741
+ }
742
+ async doStart(sessionId, options) {
743
+ const nodePty = await loadPty();
744
+ const projectName = options.projectName ?? basename(options.projectPath);
745
+ const proc = nodePty.spawn(
746
+ resolveCodexExe(),
747
+ ["resume", sessionId, "--cd", options.projectPath, "--no-alt-screen"],
748
+ {
749
+ name: "xterm-256color",
750
+ cols: PTY_COLS,
751
+ rows: PTY_ROWS,
752
+ cwd: options.projectPath,
753
+ env: process.env
754
+ }
755
+ );
756
+ const session = {
757
+ id: sessionId,
758
+ provider: CODEX_CLI_PROVIDER,
759
+ projectPath: options.projectPath,
760
+ projectName,
761
+ branch: options.branch ?? "",
762
+ status: "running",
763
+ startedAt: /* @__PURE__ */ new Date(),
764
+ completedAt: null,
765
+ promptCount: 0,
766
+ lastOutput: "",
767
+ process: proc,
768
+ outputBuffer: Buffer.alloc(0),
769
+ screen: createScreen()
770
+ };
771
+ this.sessions.set(sessionId, session);
772
+ this.pendingReady.add(sessionId);
773
+ proc.onData((data) => {
774
+ this.handleOutput(sessionId, data);
775
+ });
776
+ proc.onExit(({ exitCode }) => {
777
+ this.pendingReady.delete(sessionId);
778
+ this.handleExit(sessionId, exitCode);
779
+ });
780
+ return toPublicSession(session);
781
+ }
782
+ // Start a brand-new Codex session. Codex has no --session-id equivalent for
783
+ // a fresh launch — it assigns its own id, discovered later (Task 3's
784
+ // binding logic). This runner generates a local placeholder id for the
785
+ // ManagedSession handle only.
786
+ async startFresh(options) {
787
+ const nodePty = await loadPty();
788
+ const sessionId = randomUUID();
789
+ const projectName = options.projectName ?? basename(options.projectPath);
790
+ const args = ["--cd", options.projectPath, "--no-alt-screen"];
791
+ if (options.systemPrompt) {
792
+ args.push(options.systemPrompt);
793
+ }
794
+ const proc = nodePty.spawn(resolveCodexExe(), args, {
795
+ name: "xterm-256color",
796
+ cols: PTY_COLS,
797
+ rows: PTY_ROWS,
798
+ cwd: options.projectPath,
799
+ env: process.env
800
+ });
801
+ const session = {
802
+ id: sessionId,
803
+ provider: CODEX_CLI_PROVIDER,
804
+ projectPath: options.projectPath,
805
+ projectName,
806
+ branch: "",
807
+ status: "running",
808
+ startedAt: /* @__PURE__ */ new Date(),
809
+ completedAt: null,
810
+ promptCount: 0,
811
+ lastOutput: "",
812
+ process: proc,
813
+ outputBuffer: Buffer.alloc(0),
814
+ screen: createScreen()
815
+ };
816
+ this.sessions.set(sessionId, session);
817
+ this.pendingReady.add(sessionId);
818
+ proc.onData((data) => {
819
+ this.handleOutput(sessionId, data);
820
+ });
821
+ proc.onExit(({ exitCode }) => {
822
+ this.pendingReady.delete(sessionId);
823
+ this.handleExit(sessionId, exitCode);
824
+ });
825
+ return toPublicSession(session);
826
+ }
827
+ // Write raw key bytes directly to the PTY, same as PTYManager.sendKeys.
828
+ sendKeys(sessionId, keys) {
829
+ const session = this.sessions.get(sessionId);
830
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
831
+ if (session.status === "idle") {
832
+ throw new Error(`Session is idle (no active PTY): ${sessionId}`);
833
+ }
834
+ if (session.status === "waiting_input") {
835
+ session.status = "running";
836
+ this.onStatusChange?.(toPublicSession(session));
837
+ }
838
+ this.log.info(
839
+ `[codex.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes(keys)}`,
840
+ { event: "codex.keys_write", sessionId, byteLen: keys.length }
841
+ );
842
+ session.process.write(keys);
843
+ session.lastActivityAt = /* @__PURE__ */ new Date();
844
+ }
845
+ sendInput(sessionId, input) {
846
+ const session = this.sessions.get(sessionId);
847
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
848
+ if (session.status === "idle") {
849
+ throw new Error(`Session is idle (no active PTY): ${sessionId}`);
850
+ }
851
+ if (this.pendingReady.has(sessionId)) {
852
+ const queue = this.queuedInputs.get(sessionId) ?? [];
853
+ queue.push(input);
854
+ this.queuedInputs.set(sessionId, queue);
855
+ session.lastActivityAt = /* @__PURE__ */ new Date();
856
+ session.promptCount++;
857
+ this.log.warn(
858
+ `[codex.input.queued] ${sessionId.slice(0, 8)} promptCount=${session.promptCount} queueLen=${queue.length}`,
859
+ {
860
+ event: "codex.input_queued",
861
+ sessionId,
862
+ promptCount: session.promptCount,
863
+ queueLen: queue.length,
864
+ inputLen: input.length
865
+ }
866
+ );
867
+ return session.promptCount;
868
+ }
869
+ if (session.status === "waiting_input") {
870
+ session.status = "running";
871
+ this.onStatusChange?.(toPublicSession(session));
872
+ }
873
+ this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
874
+ session.lastActivityAt = /* @__PURE__ */ new Date();
875
+ session.promptCount++;
876
+ return session.promptCount;
877
+ }
878
+ // Write the input as plain bytes (no bracketed-paste wrap — Phase 0
879
+ // confirmed Codex accepts plain keystrokes), then submit \r after a short
880
+ // delay so Codex's TUI gets an event-loop tick to process the input first.
881
+ writeSubmit(sessionId, session, input, path, promptCount) {
882
+ this.log.info(
883
+ `[codex.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${input.length} digest=${digestBytes(input)}`,
884
+ {
885
+ event: "codex.input_write",
886
+ sessionId,
887
+ promptCount,
888
+ byteLen: input.length,
889
+ digest: digestBytes(input),
890
+ path,
891
+ phase: "input"
892
+ }
893
+ );
894
+ session.process.write(input);
895
+ setTimeout(() => {
896
+ const current = this.sessions.get(sessionId);
897
+ if (!current || current !== session) return;
898
+ this.log.info(
899
+ `[codex.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r`,
900
+ {
901
+ event: "codex.input_write",
902
+ sessionId,
903
+ promptCount,
904
+ byteLen: SUBMIT_BYTES.length,
905
+ digest: "\\r",
906
+ path,
907
+ phase: "submit"
908
+ }
909
+ );
910
+ current.process.write(SUBMIT_BYTES);
911
+ }, CODEX_SUBMIT_DELAY_MS);
912
+ }
913
+ // Drain any inputs sent while the session was still pendingReady, writing
914
+ // them in arrival order now that Codex is Ready.
915
+ flushQueuedInputs(sessionId) {
916
+ const queue = this.queuedInputs.get(sessionId);
917
+ if (!queue || queue.length === 0) return;
918
+ this.queuedInputs.delete(sessionId);
919
+ const session = this.sessions.get(sessionId);
920
+ if (!session) return;
921
+ this.log.info(
922
+ `[codex.flush] ${sessionId.slice(0, 8)} flushing ${queue.length} queued input(s)`,
923
+ {
924
+ event: "codex.flush_queued",
925
+ sessionId,
926
+ queueLen: queue.length
927
+ }
928
+ );
929
+ queue.forEach((input, i) => {
930
+ const writeAt = i * CODEX_SUBMIT_DELAY_MS * 2;
931
+ if (writeAt === 0) {
932
+ this.writeSubmit(sessionId, session, input, "flush", session.promptCount);
933
+ } else {
934
+ setTimeout(() => {
935
+ const current = this.sessions.get(sessionId);
936
+ if (!current || current !== session) return;
937
+ this.writeSubmit(sessionId, session, input, "flush", session.promptCount);
938
+ }, writeAt);
939
+ }
940
+ });
941
+ }
942
+ // SIGINT produces a clean exitCode=0 exit (Phase 0 — confirmed).
943
+ cancel(sessionId) {
944
+ const session = this.sessions.get(sessionId);
945
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
946
+ session.process.kill("SIGINT");
947
+ }
948
+ killPid(pid) {
949
+ try {
950
+ process.kill(pid, "SIGTERM");
951
+ } catch {
952
+ }
953
+ }
954
+ // Kill the PTY and mark the session idle. Mirrors PTYManager.putOnHold.
955
+ putOnHold(sessionId) {
956
+ const session = this.sessions.get(sessionId);
957
+ if (!session) return;
958
+ this.pendingReady.delete(sessionId);
959
+ this.queuedInputs.delete(sessionId);
960
+ this.trustGateAnswered.delete(sessionId);
961
+ try {
962
+ session.process.kill("SIGINT");
963
+ } catch {
964
+ }
965
+ session.status = "idle";
966
+ session.completedAt = /* @__PURE__ */ new Date();
967
+ session.screen.dispose();
968
+ this.sessions.delete(sessionId);
969
+ this.onStatusChange?.(toPublicSession(session));
970
+ }
971
+ getOutput(sessionId) {
972
+ const session = this.sessions.get(sessionId);
973
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
974
+ return session.outputBuffer.toString("utf-8");
975
+ }
976
+ // Render the last `maxLines` rows of the session's screen in true on-screen
977
+ // order — same flush-then-read technique as PTYManager.getOutputLines.
978
+ async getOutputLines(sessionId, maxLines) {
979
+ const session = this.sessions.get(sessionId);
980
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
981
+ await new Promise((resolve2) => session.screen.write("", () => resolve2()));
982
+ const buf = session.screen.buffer.active;
983
+ const lines = [];
984
+ for (let y = 0; y < buf.length; y++) {
985
+ lines.push(buf.getLine(y)?.translateToString(true) ?? "");
986
+ }
987
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
988
+ lines.pop();
989
+ }
990
+ return lines.slice(-maxLines);
991
+ }
992
+ getSession(sessionId) {
993
+ const session = this.sessions.get(sessionId);
994
+ return session ? toPublicSession(session) : null;
995
+ }
996
+ hasSession(sessionId) {
997
+ return this.sessions.has(sessionId);
998
+ }
999
+ listSessions() {
1000
+ return Array.from(this.sessions.values()).map(toPublicSession);
1001
+ }
1002
+ dispose() {
1003
+ for (const session of this.sessions.values()) {
588
1004
  try {
589
- const info = await getProcessInfoWindows(pid);
590
- if (!info) return null;
591
- return {
592
- pid,
593
- projectPath: info.cwd,
594
- projectName: basename(info.cwd),
595
- branch: await readGitBranch(info.cwd),
596
- conversationId: extractResumeId(info.args),
597
- startedAt: info.startedAt
598
- };
1005
+ session.process.kill();
599
1006
  } catch {
600
- return null;
601
- }
602
- })
603
- );
604
- return results.filter((r) => r !== null);
605
- }
606
- function run(cmd, args, opts = {}) {
607
- return new Promise((resolve2, reject) => {
608
- execFile(
609
- cmd,
610
- args,
611
- { windowsHide: isWindows, encoding: "utf-8", timeout: opts.timeout ?? 5e3, cwd: opts.cwd },
612
- (err, stdout) => {
613
- if (err) reject(err);
614
- else resolve2(stdout);
615
1007
  }
616
- );
617
- });
618
- }
619
- async function getPidsUnix() {
620
- try {
621
- const output = await run("pgrep", ["-x", "claude"]);
622
- return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
623
- } catch {
624
- return [];
625
- }
626
- }
627
- async function getProcessCwdUnix(pid) {
628
- const output = await run("lsof", ["-p", String(pid), "-a", "-d", "cwd", "-Fn"]);
629
- const match = output.match(/n(.+)/);
630
- return match?.[1] ?? "";
631
- }
632
- async function getProcessArgsUnix(pid) {
633
- return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
634
- }
635
- async function getProcessStartTimeUnix(pid) {
636
- const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
637
- const d = new Date(raw);
638
- return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
639
- }
640
- async function getPidsWindows() {
641
- try {
642
- const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
643
- return output.trim().split("\n").filter(Boolean).map((line) => {
644
- const parts = line.split(",");
645
- return Number.parseInt(parts[1]?.replace(/"/g, "") ?? "0", 10);
646
- }).filter((pid) => pid > 0);
647
- } catch {
648
- return [];
649
- }
650
- }
651
- async function getProcessInfoWindows(pid) {
652
- try {
653
- const output = await run("wmic", [
654
- "process",
655
- "where",
656
- `ProcessId=${pid}`,
657
- "get",
658
- "CommandLine,CreationDate,ExecutablePath",
659
- "/FORMAT:CSV"
660
- ]);
661
- const lines = output.trim().split(/\r?\n/).filter((l) => l.trim().length > 0);
662
- if (lines.length < 2) return null;
663
- const parts = lines[1].split(",");
664
- const args = parts[1] ?? "";
665
- const creationDate = parts[2] ?? "";
666
- const year = creationDate.slice(0, 4);
667
- const month = creationDate.slice(4, 6);
668
- const day = creationDate.slice(6, 8);
669
- const hour = creationDate.slice(8, 10);
670
- const min = creationDate.slice(10, 12);
671
- const sec = creationDate.slice(12, 14);
672
- const startedAt = /* @__PURE__ */ new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`);
673
- if (Number.isNaN(startedAt.getTime())) return null;
674
- const exePath = parts[3] ?? "";
675
- const cwd = exePath ? dirname3(exePath) : "";
676
- return { cwd, args, startedAt };
677
- } catch {
678
- return null;
1008
+ session.screen.dispose();
1009
+ }
1010
+ this.sessions.clear();
1011
+ this.pendingReady.clear();
1012
+ this.queuedInputs.clear();
1013
+ this.trustGateAnswered.clear();
679
1014
  }
680
- }
681
- function extractResumeId(args) {
682
- const match = args.match(/--resume\s+(\S+)/);
683
- return match?.[1] ?? null;
684
- }
685
- async function readGitBranch(dir) {
686
- try {
687
- return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
688
- } catch {
689
- return "";
1015
+ handleOutput(sessionId, data) {
1016
+ const session = this.sessions.get(sessionId);
1017
+ if (!session) return;
1018
+ const chunk = Buffer.from(data, "utf-8");
1019
+ session.outputBuffer = Buffer.concat([session.outputBuffer, chunk]);
1020
+ if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
1021
+ session.outputBuffer = session.outputBuffer.subarray(
1022
+ session.outputBuffer.length - OUTPUT_BUFFER_MAX
1023
+ );
1024
+ }
1025
+ session.screen.write(data);
1026
+ session.lastOutput = stripAnsi(data);
1027
+ this.onOutput?.(sessionId, data);
1028
+ this.detectReady(sessionId, session).catch((err) => {
1029
+ this.log.warn("[codex.ready_detect] failed", {
1030
+ event: "codex.ready_detect_failed",
1031
+ sessionId,
1032
+ err
1033
+ });
1034
+ });
690
1035
  }
691
- }
692
-
693
- // src/pty-manager.ts
694
- import { Terminal } from "@xterm/headless";
695
- import { randomUUID } from "crypto";
696
- import { existsSync as existsSync2 } from "fs";
697
- import { basename as basename2 } from "path";
698
-
699
- // src/logger.ts
700
- import pino from "pino";
701
- var baseLogger = pino({
702
- level: process.env.LOG_LEVEL ?? "info",
703
- base: { service: "tb-streamer" },
704
- timestamp: pino.stdTimeFunctions.isoTime,
705
- redact: {
706
- paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
707
- censor: "[redacted]"
1036
+ // Renders the session's headless screen and checks for the directory-trust
1037
+ // gate (answered once, debounced) and the "Ready" status-bar text. Only
1038
+ // transitions to waiting_input / fires onReady when the rendered status
1039
+ // line literally contains "Ready" — `›` alone (visible during "Starting")
1040
+ // is NOT a valid readiness signal (Phase 0).
1041
+ async detectReady(sessionId, session) {
1042
+ if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1043
+ const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1044
+ const screenText = lines.join("\n");
1045
+ if (CODEX_TRUST_GATE_REGEX.test(screenText)) {
1046
+ if (!this.trustGateAnswered.has(sessionId)) {
1047
+ this.trustGateAnswered.add(sessionId);
1048
+ this.log.info(`[codex.trust_gate] ${sessionId.slice(0, 8)} auto-answering`, {
1049
+ event: "codex.trust_gate",
1050
+ sessionId
1051
+ });
1052
+ session.process.write("\r");
1053
+ }
1054
+ return;
1055
+ }
1056
+ const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1057
+ if (!lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) return;
1058
+ this.markReady(sessionId, session);
708
1059
  }
709
- });
710
- function emit(pinoChild, level, msg, fields, dest) {
711
- if (dest === "pino" || dest === "both") {
712
- if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
713
- else pinoChild[level](msg);
1060
+ markReady(sessionId, session) {
1061
+ session.lastActivityAt = /* @__PURE__ */ new Date();
1062
+ session.status = "waiting_input";
1063
+ this.log.info(`[codex.ready] ${sessionId.slice(0, 8)}`, {
1064
+ event: "codex.ready",
1065
+ sessionId
1066
+ });
1067
+ this.onStatusChange?.(toPublicSession(session));
1068
+ if (this.pendingReady.has(sessionId)) {
1069
+ this.pendingReady.delete(sessionId);
1070
+ this.flushQueuedInputs(sessionId);
1071
+ this.onReady?.(toPublicSession(session));
1072
+ }
714
1073
  }
715
- if (dest === "console" || dest === "both") {
716
- const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
717
- console[consoleMethod](msg);
1074
+ handleExit(sessionId, exitCode) {
1075
+ const session = this.sessions.get(sessionId);
1076
+ if (!session) return;
1077
+ session.completedAt = /* @__PURE__ */ new Date();
1078
+ session.status = "idle";
1079
+ const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1080
+ if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1081
+ if (!existsSync2(session.projectPath)) {
1082
+ session.failureReason = `Project directory not found: ${session.projectPath}`;
1083
+ } else {
1084
+ session.failureReason = `Codex process exited immediately (code ${exitCode}).`;
1085
+ }
1086
+ }
1087
+ this.onStatusChange?.(toPublicSession(session));
1088
+ session.screen.dispose();
1089
+ this.sessions.delete(sessionId);
1090
+ this.queuedInputs.delete(sessionId);
1091
+ this.trustGateAnswered.delete(sessionId);
718
1092
  }
719
- }
720
- function build(pinoChild) {
1093
+ };
1094
+ function toPublicSession(s) {
721
1095
  return {
722
- debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
723
- info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
724
- warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
725
- error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
726
- log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
727
- pino: pinoChild
1096
+ id: s.id,
1097
+ provider: s.provider ?? CODEX_CLI_PROVIDER,
1098
+ projectPath: s.projectPath,
1099
+ projectName: s.projectName,
1100
+ branch: s.branch,
1101
+ status: s.status,
1102
+ startedAt: s.startedAt,
1103
+ completedAt: s.completedAt,
1104
+ promptCount: s.promptCount,
1105
+ lastOutput: s.lastOutput,
1106
+ ...s.failureReason != null && { failureReason: s.failureReason },
1107
+ ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
1108
+ ...s.filePath != null && { filePath: s.filePath }
728
1109
  };
729
1110
  }
730
- function getLogger(component) {
731
- return build(component ? baseLogger.child({ component }) : baseLogger);
1111
+ function stripAnsi(str) {
1112
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
732
1113
  }
733
- var logger = build(baseLogger);
1114
+
1115
+ // src/pty-manager.ts
1116
+ import { Terminal as Terminal2 } from "@xterm/headless";
1117
+ import { randomUUID as randomUUID2 } from "crypto";
1118
+ import { existsSync as existsSync3 } from "fs";
1119
+ import { basename as basename2 } from "path";
734
1120
 
735
1121
  // src/services/questions/detectPermissionGate.ts
736
1122
  var OSC_777_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*/;
@@ -889,28 +1275,28 @@ function detectShellPrompt(lines) {
889
1275
  }
890
1276
 
891
1277
  // src/pty-manager.ts
892
- var OUTPUT_BUFFER_MAX = 65536;
893
- var PTY_COLS = 120;
894
- var PTY_ROWS = 40;
895
- var SCREEN_SCROLLBACK = 1e3;
1278
+ var OUTPUT_BUFFER_MAX2 = 65536;
1279
+ var PTY_COLS2 = 120;
1280
+ var PTY_ROWS2 = 40;
1281
+ var SCREEN_SCROLLBACK2 = 1e3;
896
1282
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
897
1283
  var PROMPT_MARKER_FALLBACK_MS = 1e4;
898
1284
  function buildPasteBytes(input) {
899
1285
  return `\x1B[200~${input}\x1B[201~`;
900
1286
  }
901
- var SUBMIT_BYTES = "\r";
1287
+ var SUBMIT_BYTES2 = "\r";
902
1288
  var SUBMIT_DELAY_MS = 16;
903
- function digestBytes(s) {
1289
+ function digestBytes2(s) {
904
1290
  const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
905
1291
  if (escaped.length <= 200) return escaped;
906
1292
  return `${escaped.slice(0, 100)}\u2026[${escaped.length - 200}B omitted]\u2026${escaped.slice(-100)}`;
907
1293
  }
908
- var pty = null;
909
- async function loadPty() {
910
- if (pty) return pty;
1294
+ var pty2 = null;
1295
+ async function loadPty2() {
1296
+ if (pty2) return pty2;
911
1297
  try {
912
- pty = await import("node-pty");
913
- return pty;
1298
+ pty2 = await import("node-pty");
1299
+ return pty2;
914
1300
  } catch (err) {
915
1301
  throw new Error(
916
1302
  `node-pty is required for PTY management but failed to load. Ensure it is installed: npm install node-pty
@@ -918,11 +1304,11 @@ Original error: ${err}`
918
1304
  );
919
1305
  }
920
1306
  }
921
- function createScreen() {
922
- return new Terminal({
923
- cols: PTY_COLS,
924
- rows: PTY_ROWS,
925
- scrollback: SCREEN_SCROLLBACK,
1307
+ function createScreen2() {
1308
+ return new Terminal2({
1309
+ cols: PTY_COLS2,
1310
+ rows: PTY_ROWS2,
1311
+ scrollback: SCREEN_SCROLLBACK2,
926
1312
  allowProposedApi: true
927
1313
  });
928
1314
  }
@@ -971,6 +1357,10 @@ var PTYManager = class {
971
1357
  // to a given input or fell silent. Reset on dispose().
972
1358
  chunkIndex = /* @__PURE__ */ new Map();
973
1359
  lastChunkAt = /* @__PURE__ */ new Map();
1360
+ // In-flight start()/startFresh() calls keyed by sessionId. A second
1361
+ // concurrent resume for the same session (double-tap, client retry) awaits
1362
+ // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
1363
+ startPromises = /* @__PURE__ */ new Map();
974
1364
  constructor(options = {}) {
975
1365
  this.onOutput = options.onOutput;
976
1366
  this.onStatusChange = options.onStatusChange;
@@ -993,7 +1383,18 @@ var PTYManager = class {
993
1383
  // custom-API-key — are cleared by the seeded ~/.claude.json in
994
1384
  // docker/entrypoint.sh.) startFresh() uses the same flag for the same reason.
995
1385
  async start(sessionId, options) {
996
- const nodePty = await loadPty();
1386
+ const existing = this.sessions.get(sessionId);
1387
+ if (existing) return toPublicSession2(existing);
1388
+ const inFlight = this.startPromises.get(sessionId);
1389
+ if (inFlight) return inFlight;
1390
+ const promise = this.doStart(sessionId, options).finally(() => {
1391
+ this.startPromises.delete(sessionId);
1392
+ });
1393
+ this.startPromises.set(sessionId, promise);
1394
+ return promise;
1395
+ }
1396
+ async doStart(sessionId, options) {
1397
+ const nodePty = await loadPty2();
997
1398
  const projectName = options.projectName ?? basename2(options.projectPath);
998
1399
  const proc = nodePty.spawn(
999
1400
  resolveClaudeExe(),
@@ -1015,6 +1416,7 @@ var PTYManager = class {
1015
1416
  );
1016
1417
  const session = {
1017
1418
  id: sessionId,
1419
+ provider: CLAUDE_CODE_PROVIDER,
1018
1420
  projectPath: options.projectPath,
1019
1421
  projectName,
1020
1422
  branch: options.branch ?? "",
@@ -1025,7 +1427,7 @@ var PTYManager = class {
1025
1427
  lastOutput: "",
1026
1428
  process: proc,
1027
1429
  outputBuffer: Buffer.alloc(0),
1028
- screen: createScreen()
1430
+ screen: createScreen2()
1029
1431
  };
1030
1432
  this.sessions.set(sessionId, session);
1031
1433
  this.pendingReady.add(sessionId);
@@ -1036,14 +1438,14 @@ var PTYManager = class {
1036
1438
  this.pendingReady.delete(sessionId);
1037
1439
  this.handleExit(sessionId, exitCode);
1038
1440
  });
1039
- return toPublicSession(session);
1441
+ return toPublicSession2(session);
1040
1442
  }
1041
1443
  // Start a brand-new Claude session. A stable UUID is generated here and passed
1042
1444
  // to Claude via --session-id so the JSONL filename matches from the start.
1043
1445
  // onReady fires once Claude reaches its first prompt (waiting_input).
1044
1446
  async startFresh(options) {
1045
- const nodePty = await loadPty();
1046
- const sessionId = randomUUID();
1447
+ const nodePty = await loadPty2();
1448
+ const sessionId = randomUUID2();
1047
1449
  const projectName = options.projectName ?? basename2(options.projectPath);
1048
1450
  const args = [
1049
1451
  "--permission-mode",
@@ -1065,6 +1467,7 @@ var PTYManager = class {
1065
1467
  });
1066
1468
  const session = {
1067
1469
  id: sessionId,
1470
+ provider: CLAUDE_CODE_PROVIDER,
1068
1471
  projectPath: options.projectPath,
1069
1472
  projectName,
1070
1473
  branch: "",
@@ -1075,7 +1478,7 @@ var PTYManager = class {
1075
1478
  lastOutput: "",
1076
1479
  process: proc,
1077
1480
  outputBuffer: Buffer.alloc(0),
1078
- screen: createScreen()
1481
+ screen: createScreen2()
1079
1482
  };
1080
1483
  this.sessions.set(sessionId, session);
1081
1484
  this.pendingReady.add(sessionId);
@@ -1086,7 +1489,7 @@ var PTYManager = class {
1086
1489
  this.pendingReady.delete(sessionId);
1087
1490
  this.handleExit(sessionId, exitCode);
1088
1491
  });
1089
- return toPublicSession(session);
1492
+ return toPublicSession2(session);
1090
1493
  }
1091
1494
  // Write raw key bytes directly to the PTY without bracketed-paste wrapping.
1092
1495
  // Use for control sequences (arrow keys, Enter) that must not be quoted.
@@ -1098,10 +1501,10 @@ var PTYManager = class {
1098
1501
  }
1099
1502
  if (session.status === "waiting_input") {
1100
1503
  session.status = "running";
1101
- this.onStatusChange?.(toPublicSession(session));
1504
+ this.onStatusChange?.(toPublicSession2(session));
1102
1505
  }
1103
1506
  this.log.info(
1104
- `[pty.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes(keys)}`,
1507
+ `[pty.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes2(keys)}`,
1105
1508
  { event: "pty.keys_write", sessionId, byteLen: keys.length }
1106
1509
  );
1107
1510
  session.process.write(keys);
@@ -1133,7 +1536,7 @@ var PTYManager = class {
1133
1536
  }
1134
1537
  if (session.status === "waiting_input") {
1135
1538
  session.status = "running";
1136
- this.onStatusChange?.(toPublicSession(session));
1539
+ this.onStatusChange?.(toPublicSession2(session));
1137
1540
  }
1138
1541
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
1139
1542
  session.lastActivityAt = /* @__PURE__ */ new Date();
@@ -1146,13 +1549,13 @@ var PTYManager = class {
1146
1549
  writeSubmit(sessionId, session, input, path, promptCount) {
1147
1550
  const pasteBytes = buildPasteBytes(input);
1148
1551
  this.log.info(
1149
- `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes(pasteBytes)}`,
1552
+ `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes2(pasteBytes)}`,
1150
1553
  {
1151
1554
  event: "pty.input_write",
1152
1555
  sessionId,
1153
1556
  promptCount,
1154
1557
  byteLen: pasteBytes.length,
1155
- digest: digestBytes(pasteBytes),
1558
+ digest: digestBytes2(pasteBytes),
1156
1559
  path,
1157
1560
  phase: "paste"
1158
1561
  }
@@ -1167,13 +1570,13 @@ var PTYManager = class {
1167
1570
  event: "pty.input_write",
1168
1571
  sessionId,
1169
1572
  promptCount,
1170
- byteLen: SUBMIT_BYTES.length,
1573
+ byteLen: SUBMIT_BYTES2.length,
1171
1574
  digest: "\\r",
1172
1575
  path,
1173
1576
  phase: "submit"
1174
1577
  }
1175
1578
  );
1176
- current.process.write(SUBMIT_BYTES);
1579
+ current.process.write(SUBMIT_BYTES2);
1177
1580
  }, SUBMIT_DELAY_MS);
1178
1581
  }
1179
1582
  // Drain any inputs that were sent while the session was still pendingReady,
@@ -1231,7 +1634,7 @@ var PTYManager = class {
1231
1634
  session.completedAt = /* @__PURE__ */ new Date();
1232
1635
  session.screen.dispose();
1233
1636
  this.sessions.delete(sessionId);
1234
- this.onStatusChange?.(toPublicSession(session));
1637
+ this.onStatusChange?.(toPublicSession2(session));
1235
1638
  }
1236
1639
  getOutput(sessionId) {
1237
1640
  const session = this.sessions.get(sessionId);
@@ -1262,13 +1665,13 @@ var PTYManager = class {
1262
1665
  }
1263
1666
  getSession(sessionId) {
1264
1667
  const session = this.sessions.get(sessionId);
1265
- return session ? toPublicSession(session) : null;
1668
+ return session ? toPublicSession2(session) : null;
1266
1669
  }
1267
1670
  hasSession(sessionId) {
1268
1671
  return this.sessions.has(sessionId);
1269
1672
  }
1270
1673
  listSessions() {
1271
- return Array.from(this.sessions.values()).map(toPublicSession);
1674
+ return Array.from(this.sessions.values()).map(toPublicSession2);
1272
1675
  }
1273
1676
  dispose() {
1274
1677
  for (const session of this.sessions.values()) {
@@ -1300,7 +1703,7 @@ var PTYManager = class {
1300
1703
  this.lastChunkAt.set(sessionId, now);
1301
1704
  const gapMs = last == null ? 0 : now - last;
1302
1705
  this.log.info(
1303
- `[pty.chunk] ${sessionId.slice(0, 8)} #${idx} +${chunk.length}B gap=${gapMs}ms status=${session.status} digest=${digestBytes(data)}`,
1706
+ `[pty.chunk] ${sessionId.slice(0, 8)} #${idx} +${chunk.length}B gap=${gapMs}ms status=${session.status} digest=${digestBytes2(data)}`,
1304
1707
  {
1305
1708
  event: "pty.chunk",
1306
1709
  sessionId,
@@ -1309,17 +1712,17 @@ var PTYManager = class {
1309
1712
  gapMs,
1310
1713
  status: session.status,
1311
1714
  pendingReady: this.pendingReady.has(sessionId),
1312
- digest: digestBytes(data)
1715
+ digest: digestBytes2(data)
1313
1716
  }
1314
1717
  );
1315
1718
  session.outputBuffer = Buffer.concat([session.outputBuffer, chunk]);
1316
- if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
1719
+ if (session.outputBuffer.length > OUTPUT_BUFFER_MAX2) {
1317
1720
  session.outputBuffer = session.outputBuffer.subarray(
1318
- session.outputBuffer.length - OUTPUT_BUFFER_MAX
1721
+ session.outputBuffer.length - OUTPUT_BUFFER_MAX2
1319
1722
  );
1320
1723
  }
1321
1724
  session.screen.write(data);
1322
- const stripped = stripAnsi(data);
1725
+ const stripped = stripAnsi2(data);
1323
1726
  session.lastOutput = stripped;
1324
1727
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
1325
1728
  if (session.status === "running" && matchedMarker) {
@@ -1422,11 +1825,11 @@ var PTYManager = class {
1422
1825
  reason,
1423
1826
  elapsedMs
1424
1827
  });
1425
- this.onStatusChange?.(toPublicSession(session));
1828
+ this.onStatusChange?.(toPublicSession2(session));
1426
1829
  if (this.pendingReady.has(sessionId)) {
1427
1830
  this.pendingReady.delete(sessionId);
1428
1831
  this.flushQueuedInputs(sessionId);
1429
- this.onReady?.(toPublicSession(session));
1832
+ this.onReady?.(toPublicSession2(session));
1430
1833
  }
1431
1834
  }
1432
1835
  handleExit(sessionId, exitCode) {
@@ -1436,13 +1839,13 @@ var PTYManager = class {
1436
1839
  session.status = "idle";
1437
1840
  const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1438
1841
  if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1439
- if (!existsSync2(session.projectPath)) {
1842
+ if (!existsSync3(session.projectPath)) {
1440
1843
  session.failureReason = `Project directory not found: ${session.projectPath}`;
1441
1844
  } else {
1442
1845
  session.failureReason = `Process exited immediately (code ${exitCode}). Check that the Claude binary is installed and accessible.`;
1443
1846
  }
1444
1847
  }
1445
- this.onStatusChange?.(toPublicSession(session));
1848
+ this.onStatusChange?.(toPublicSession2(session));
1446
1849
  session.screen.dispose();
1447
1850
  this.sessions.delete(sessionId);
1448
1851
  this.queuedInputs.delete(sessionId);
@@ -1452,9 +1855,10 @@ var PTYManager = class {
1452
1855
  this.shellPromptOpen.delete(sessionId);
1453
1856
  }
1454
1857
  };
1455
- function toPublicSession(s) {
1858
+ function toPublicSession2(s) {
1456
1859
  return {
1457
1860
  id: s.id,
1861
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
1458
1862
  projectPath: s.projectPath,
1459
1863
  projectName: s.projectName,
1460
1864
  branch: s.branch,
@@ -1468,10 +1872,249 @@ function toPublicSession(s) {
1468
1872
  ...s.filePath != null && { filePath: s.filePath }
1469
1873
  };
1470
1874
  }
1471
- function stripAnsi(str) {
1875
+ function stripAnsi2(str) {
1472
1876
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
1473
1877
  }
1474
1878
 
1879
+ // src/live-session-manager.ts
1880
+ var LiveSessionManager = class {
1881
+ runners;
1882
+ constructor(options = {}) {
1883
+ this.runners = /* @__PURE__ */ new Map([
1884
+ [CLAUDE_CODE_PROVIDER, new PTYManager(options)],
1885
+ [CODEX_CLI_PROVIDER, new CodexPtyRunner(options)]
1886
+ ]);
1887
+ }
1888
+ async start(sessionId, options) {
1889
+ const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
1890
+ const runner = this.assertSupportedProvider(provider, options.projectPath);
1891
+ return runner.start(sessionId, options);
1892
+ }
1893
+ async startFresh(options) {
1894
+ const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
1895
+ const runner = this.assertSupportedProvider(provider, options.projectPath);
1896
+ return runner.startFresh(options);
1897
+ }
1898
+ sendInput(sessionId, input) {
1899
+ return this.runnerFor(sessionId).sendInput(sessionId, input);
1900
+ }
1901
+ sendKeys(sessionId, keys) {
1902
+ this.runnerFor(sessionId).sendKeys(sessionId, keys);
1903
+ }
1904
+ cancel(sessionId) {
1905
+ this.runnerFor(sessionId).cancel(sessionId);
1906
+ }
1907
+ killPid(pid) {
1908
+ for (const runner of this.runners.values()) {
1909
+ runner.killPid(pid);
1910
+ }
1911
+ }
1912
+ // putOnHold tolerates an unknown sessionId (PTYManager.putOnHold is a no-op
1913
+ // when the session isn't in its map), so — unlike the other session-keyed
1914
+ // methods — route to the owning runner when found, otherwise broadcast to
1915
+ // every runner rather than throwing; this matches the pre-extraction
1916
+ // behavior of delegating straight through with no existence check.
1917
+ putOnHold(sessionId) {
1918
+ for (const runner of this.runners.values()) {
1919
+ if (runner.hasSession(sessionId) || runner.getSession(sessionId)) {
1920
+ runner.putOnHold(sessionId);
1921
+ return;
1922
+ }
1923
+ }
1924
+ for (const runner of this.runners.values()) {
1925
+ runner.putOnHold(sessionId);
1926
+ }
1927
+ }
1928
+ getOutput(sessionId) {
1929
+ return this.runnerFor(sessionId).getOutput(sessionId);
1930
+ }
1931
+ getOutputLines(sessionId, maxLines) {
1932
+ return this.runnerFor(sessionId).getOutputLines(sessionId, maxLines);
1933
+ }
1934
+ getSession(sessionId) {
1935
+ for (const runner of this.runners.values()) {
1936
+ const session = runner.getSession(sessionId);
1937
+ if (session) return session;
1938
+ }
1939
+ return null;
1940
+ }
1941
+ hasSession(sessionId) {
1942
+ for (const runner of this.runners.values()) {
1943
+ if (runner.hasSession(sessionId)) return true;
1944
+ }
1945
+ return false;
1946
+ }
1947
+ listSessions() {
1948
+ return Array.from(this.runners.values()).flatMap((runner) => runner.listSessions());
1949
+ }
1950
+ dispose() {
1951
+ for (const runner of this.runners.values()) {
1952
+ runner.dispose();
1953
+ }
1954
+ }
1955
+ // Look up which runner owns a session. Only one runner exists today, so
1956
+ // this is a linear scan across hasSession()/getSession() rather than a
1957
+ // separate session→provider index — see task-1-brief.md.
1958
+ runnerFor(sessionId) {
1959
+ for (const runner of this.runners.values()) {
1960
+ if (runner.hasSession(sessionId) || runner.getSession(sessionId)) return runner;
1961
+ }
1962
+ throw new Error(`Session not found: ${sessionId}`);
1963
+ }
1964
+ assertSupportedProvider(provider, projectPath) {
1965
+ const runner = this.runners.get(provider);
1966
+ if (runner) return runner;
1967
+ const err = new Error(
1968
+ `Live ${provider} sessions are not implemented yet for ${basename3(projectPath)}`
1969
+ );
1970
+ err.statusCode = 501;
1971
+ throw err;
1972
+ }
1973
+ };
1974
+
1975
+ // src/process-discovery.ts
1976
+ import { execFile } from "child_process";
1977
+ import { platform as platform2 } from "os";
1978
+ import { basename as basename4, dirname as dirname3 } from "path";
1979
+ async function discoverClaudeProcesses() {
1980
+ if (platform2() === "win32") return discoverWindows();
1981
+ return discoverUnix();
1982
+ }
1983
+ async function discoverUnix() {
1984
+ const pids = await getPidsUnix();
1985
+ const results = await Promise.all(
1986
+ pids.map(async (pid) => {
1987
+ try {
1988
+ const [cwd, args, startedAt] = await Promise.all([
1989
+ getProcessCwdUnix(pid),
1990
+ getProcessArgsUnix(pid),
1991
+ getProcessStartTimeUnix(pid)
1992
+ ]);
1993
+ const conversationId = extractResumeId(args);
1994
+ return {
1995
+ pid,
1996
+ projectPath: cwd,
1997
+ projectName: basename4(cwd),
1998
+ branch: await readGitBranch(cwd),
1999
+ conversationId,
2000
+ startedAt
2001
+ };
2002
+ } catch {
2003
+ return null;
2004
+ }
2005
+ })
2006
+ );
2007
+ return results.filter((r) => r !== null);
2008
+ }
2009
+ async function discoverWindows() {
2010
+ const pids = await getPidsWindows();
2011
+ const results = await Promise.all(
2012
+ pids.map(async (pid) => {
2013
+ try {
2014
+ const info = await getProcessInfoWindows(pid);
2015
+ if (!info) return null;
2016
+ return {
2017
+ pid,
2018
+ projectPath: info.cwd,
2019
+ projectName: basename4(info.cwd),
2020
+ branch: await readGitBranch(info.cwd),
2021
+ conversationId: extractResumeId(info.args),
2022
+ startedAt: info.startedAt
2023
+ };
2024
+ } catch {
2025
+ return null;
2026
+ }
2027
+ })
2028
+ );
2029
+ return results.filter((r) => r !== null);
2030
+ }
2031
+ function run(cmd, args, opts = {}) {
2032
+ return new Promise((resolve2, reject) => {
2033
+ execFile(
2034
+ cmd,
2035
+ args,
2036
+ { windowsHide: isWindows, encoding: "utf-8", timeout: opts.timeout ?? 5e3, cwd: opts.cwd },
2037
+ (err, stdout) => {
2038
+ if (err) reject(err);
2039
+ else resolve2(stdout);
2040
+ }
2041
+ );
2042
+ });
2043
+ }
2044
+ async function getPidsUnix() {
2045
+ try {
2046
+ const output = await run("pgrep", ["-x", "claude"]);
2047
+ return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
2048
+ } catch {
2049
+ return [];
2050
+ }
2051
+ }
2052
+ async function getProcessCwdUnix(pid) {
2053
+ const output = await run("lsof", ["-p", String(pid), "-a", "-d", "cwd", "-Fn"]);
2054
+ const match = output.match(/n(.+)/);
2055
+ return match?.[1] ?? "";
2056
+ }
2057
+ async function getProcessArgsUnix(pid) {
2058
+ return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
2059
+ }
2060
+ async function getProcessStartTimeUnix(pid) {
2061
+ const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
2062
+ const d = new Date(raw);
2063
+ return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
2064
+ }
2065
+ async function getPidsWindows() {
2066
+ try {
2067
+ const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
2068
+ return output.trim().split("\n").filter(Boolean).map((line) => {
2069
+ const parts = line.split(",");
2070
+ return Number.parseInt(parts[1]?.replace(/"/g, "") ?? "0", 10);
2071
+ }).filter((pid) => pid > 0);
2072
+ } catch {
2073
+ return [];
2074
+ }
2075
+ }
2076
+ async function getProcessInfoWindows(pid) {
2077
+ try {
2078
+ const output = await run("wmic", [
2079
+ "process",
2080
+ "where",
2081
+ `ProcessId=${pid}`,
2082
+ "get",
2083
+ "CommandLine,CreationDate,ExecutablePath",
2084
+ "/FORMAT:CSV"
2085
+ ]);
2086
+ const lines = output.trim().split(/\r?\n/).filter((l) => l.trim().length > 0);
2087
+ if (lines.length < 2) return null;
2088
+ const parts = lines[1].split(",");
2089
+ const args = parts[1] ?? "";
2090
+ const creationDate = parts[2] ?? "";
2091
+ const year = creationDate.slice(0, 4);
2092
+ const month = creationDate.slice(4, 6);
2093
+ const day = creationDate.slice(6, 8);
2094
+ const hour = creationDate.slice(8, 10);
2095
+ const min = creationDate.slice(10, 12);
2096
+ const sec = creationDate.slice(12, 14);
2097
+ const startedAt = /* @__PURE__ */ new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`);
2098
+ if (Number.isNaN(startedAt.getTime())) return null;
2099
+ const exePath = parts[3] ?? "";
2100
+ const cwd = exePath ? dirname3(exePath) : "";
2101
+ return { cwd, args, startedAt };
2102
+ } catch {
2103
+ return null;
2104
+ }
2105
+ }
2106
+ function extractResumeId(args) {
2107
+ const match = args.match(/--resume\s+(\S+)/);
2108
+ return match?.[1] ?? null;
2109
+ }
2110
+ async function readGitBranch(dir) {
2111
+ try {
2112
+ return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
2113
+ } catch {
2114
+ return "";
2115
+ }
2116
+ }
2117
+
1475
2118
  // src/server.ts
1476
2119
  import { createNodeWebSocket } from "@hono/node-ws";
1477
2120
  import { Connection, Client as TemporalClient } from "@temporalio/client";
@@ -1486,7 +2129,7 @@ import {
1486
2129
  import { EventEmitter } from "events";
1487
2130
  import {
1488
2131
  createReadStream,
1489
- existsSync as existsSync6,
2132
+ existsSync as existsSync7,
1490
2133
  watch as fsWatch,
1491
2134
  readdirSync as readdirSync4,
1492
2135
  readFileSync as readFileSync6,
@@ -1678,7 +2321,7 @@ async function handleSendAgentInput(sessionId, body, deps) {
1678
2321
  }
1679
2322
 
1680
2323
  // src/agent/handle-start-agent-session.ts
1681
- import { existsSync as existsSync3 } from "fs";
2324
+ import { existsSync as existsSync4 } from "fs";
1682
2325
  import { join as join5 } from "path";
1683
2326
  function validateBody(body) {
1684
2327
  if (body === null || body === void 0 || typeof body !== "object") {
@@ -1710,7 +2353,7 @@ async function handleStartAgentSession(body, deps) {
1710
2353
  let conversationId = parsed.conversationId;
1711
2354
  if (conversationId) {
1712
2355
  const jsonlPath = join5(deps.conversationsDir, `${conversationId}.jsonl`);
1713
- if (!existsSync3(jsonlPath)) {
2356
+ if (!existsSync4(jsonlPath)) {
1714
2357
  return {
1715
2358
  status: 404,
1716
2359
  body: agentErrorResponse(
@@ -2300,7 +2943,7 @@ async function createDirectory(parentAbsolutePath, name) {
2300
2943
 
2301
2944
  // src/conversation-cache.ts
2302
2945
  import Database from "better-sqlite3";
2303
- import { closeSync as closeSync2, existsSync as existsSync4, mkdirSync as mkdirSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
2946
+ import { closeSync as closeSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
2304
2947
  import { dirname as dirname6 } from "path";
2305
2948
 
2306
2949
  // src/db/sqlite-migrate.ts
@@ -2344,14 +2987,6 @@ function runSqliteMigrations(db, migrationsDir) {
2344
2987
  return { applied, skipped };
2345
2988
  }
2346
2989
 
2347
- // src/providers.ts
2348
- var CLAUDE_CODE_PROVIDER = "claude-code";
2349
- var CODEX_CLI_PROVIDER = "codex-cli";
2350
- function isProviderResumable(provider, availabilityResumable) {
2351
- if (provider === CODEX_CLI_PROVIDER) return false;
2352
- return availabilityResumable;
2353
- }
2354
-
2355
2990
  // src/services/conversations/isAgentConversation.ts
2356
2991
  import { closeSync, openSync, readSync, statSync } from "fs";
2357
2992
  var DEFAULT_AGENT_ENTRYPOINTS = /* @__PURE__ */ new Set(["sdk-cli", "claude-vscode"]);
@@ -2532,7 +3167,10 @@ var ConversationCache = class _ConversationCache {
2532
3167
  model = excluded.model,
2533
3168
  account = excluded.account,
2534
3169
  branch = excluded.branch,
2535
- message_count = excluded.message_count,
3170
+ -- message_count is incremented by live tailing but recounted from
3171
+ -- scratch by a scanner rescan; a stale rescan must not carry a live
3172
+ -- session's count backwards, so take the max instead of overwriting.
3173
+ message_count = MAX(conversation_meta.message_count, excluded.message_count),
2536
3174
  last_activity = excluded.last_activity,
2537
3175
  first_message = excluded.first_message,
2538
3176
  last_message = excluded.last_message,
@@ -2809,6 +3447,7 @@ var ConversationCache = class _ConversationCache {
2809
3447
  fileSize = s.size;
2810
3448
  } catch {
2811
3449
  }
3450
+ const seq = ++this.tailSeq;
2812
3451
  this.stmts.upsertFull.run({
2813
3452
  id,
2814
3453
  file_path: m.filePath,
@@ -2823,7 +3462,7 @@ var ConversationCache = class _ConversationCache {
2823
3462
  first_message: m.firstMessage ? JSON.stringify(m.firstMessage) : null,
2824
3463
  last_message: m.lastMessage ? JSON.stringify(m.lastMessage) : null,
2825
3464
  preview: m.preview ?? null,
2826
- updated_at: 0,
3465
+ updated_at: seq,
2827
3466
  mtime_ms: mtimeMs,
2828
3467
  file_size: fileSize,
2829
3468
  provider: m.provider ?? CLAUDE_CODE_PROVIDER
@@ -3040,9 +3679,25 @@ var ConversationCache = class _ConversationCache {
3040
3679
  this.fileIndex.clear();
3041
3680
  }
3042
3681
  }
3043
- invalidateByFilePath(filePath) {
3682
+ /**
3683
+ * Drop the cached row for a file. Two callers with opposite intent:
3684
+ * - a directory-watch "change" event (the file was appended to) — pass
3685
+ * `skipIfTailed: true`. A cached tail means the row is being actively
3686
+ * maintained from the file's real content by the live-tail
3687
+ * (updateFromLines) or warm-up path, fresher than any scanner-derived
3688
+ * view. Both watchers fire on the same append with no ordering guarantee;
3689
+ * without this guard the invalidate can land after the tail write and wipe
3690
+ * the just-cached row, flickering the conversation out of
3691
+ * /api/conversations on nearly every message (CRITICAL #2). The debounced
3692
+ * rescan still re-derives metadata, so skipping the eager drop loses
3693
+ * nothing.
3694
+ * - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
3695
+ * row is always removed, otherwise a deleted session ghosts in the cache.
3696
+ */
3697
+ invalidateByFilePath(filePath, opts) {
3044
3698
  const row = this.stmts.getIdByFilePath.get(filePath);
3045
3699
  if (!row) return null;
3700
+ if (opts?.skipIfTailed && this.stmts.hasTail.get(row.id)) return null;
3046
3701
  this.invalidate(row.id);
3047
3702
  return row.id;
3048
3703
  }
@@ -3052,7 +3707,7 @@ var ConversationCache = class _ConversationCache {
3052
3707
  * `handleGetConversation` can still serve the cached tail even when the
3053
3708
  * JSONL has been deleted.
3054
3709
  */
3055
- pruneGhostFiles(exists = existsSync4) {
3710
+ pruneGhostFiles(exists = existsSync5) {
3056
3711
  const rows = this.stmts.allFilePaths.all();
3057
3712
  const ghosts = [];
3058
3713
  const prune = this.db.transaction((ids) => {
@@ -3132,7 +3787,7 @@ var ConversationsRepository = class {
3132
3787
  };
3133
3788
 
3134
3789
  // src/db/repositories/projects.repository.ts
3135
- import { randomUUID as randomUUID2 } from "crypto";
3790
+ import { randomUUID as randomUUID3 } from "crypto";
3136
3791
 
3137
3792
  // src/utils/canonicalizeProjectPath.ts
3138
3793
  function canonicalizeProjectPath(projectPath) {
@@ -3225,7 +3880,7 @@ var ProjectsRepository = class {
3225
3880
  });
3226
3881
  return rowToProject(this.getById.get(existing.id));
3227
3882
  }
3228
- const id = randomUUID2();
3883
+ const id = randomUUID3();
3229
3884
  this.insert.run({
3230
3885
  id,
3231
3886
  path,
@@ -3522,14 +4177,14 @@ var ConversationWatcher = class {
3522
4177
  };
3523
4178
 
3524
4179
  // src/services/conversations/pruneAgentConversations.ts
3525
- import { existsSync as existsSync5 } from "fs";
4180
+ import { existsSync as existsSync6 } from "fs";
3526
4181
  function pruneAgentConversations(cache) {
3527
4182
  const db = cache.getDatabase();
3528
4183
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
3529
4184
  let pruned = 0;
3530
4185
  let missing = 0;
3531
4186
  for (const row of rows) {
3532
- if (!existsSync5(row.file_path)) {
4187
+ if (!existsSync6(row.file_path)) {
3533
4188
  missing += 1;
3534
4189
  continue;
3535
4190
  }
@@ -3812,6 +4467,7 @@ function managedToResponse(s, ptyAttached) {
3812
4467
  return {
3813
4468
  id: s.id,
3814
4469
  conversationId: s.id,
4470
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
3815
4471
  status: s.status,
3816
4472
  projectPath: s.projectPath,
3817
4473
  projectName: s.projectName,
@@ -3837,13 +4493,15 @@ function managedToResponse(s, ptyAttached) {
3837
4493
  ...s.failureReason != null && { failureReason: s.failureReason },
3838
4494
  ...s.resumedFromConversationId != null && {
3839
4495
  resumedFromConversationId: s.resumedFromConversationId
3840
- }
4496
+ },
4497
+ ...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
3841
4498
  };
3842
4499
  }
3843
4500
  function discoveredToResponse(d, conversationId) {
3844
4501
  return {
3845
4502
  id: conversationId,
3846
4503
  conversationId,
4504
+ provider: CLAUDE_CODE_PROVIDER,
3847
4505
  status: "idle",
3848
4506
  projectPath: d.projectPath,
3849
4507
  projectName: d.projectName,
@@ -4268,7 +4926,7 @@ var StreamerServer = class {
4268
4926
  }
4269
4927
  },
4270
4928
  onConversationChanged: (filePath) => {
4271
- this.cache?.invalidateByFilePath(filePath);
4929
+ this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
4272
4930
  this.markScannerStaleDebounced();
4273
4931
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
4274
4932
  filePath,
@@ -4285,7 +4943,7 @@ var StreamerServer = class {
4285
4943
  });
4286
4944
  }
4287
4945
  });
4288
- this.ptyManager = new PTYManager({
4946
+ this.ptyManager = new LiveSessionManager({
4289
4947
  logger: getLogger("pty"),
4290
4948
  onOutput: (sessionId, data) => {
4291
4949
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
@@ -4622,10 +5280,11 @@ var StreamerServer = class {
4622
5280
  }
4623
5281
  } catch (err) {
4624
5282
  const message = err instanceof Error ? err.message : String(err);
4625
- this.log.warn(`ConversationCache failed to open (running without cache): ${message}`, {
4626
- error: message,
4627
- event: "cache.open_failed"
4628
- });
5283
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
5284
+ this.log.error(
5285
+ `ConversationCache failed to open \u2014 running WITHOUT cache; /api/conversations, /api/conversations/count and /project-chats will 500.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
5286
+ { error: message, abiMismatch, event: "cache.open_failed" }
5287
+ );
4629
5288
  }
4630
5289
  const warmupScanner = new ConversationScanner();
4631
5290
  this.allScanners.add(warmupScanner);
@@ -5113,16 +5772,16 @@ var StreamerServer = class {
5113
5772
  }
5114
5773
  findJsonlPath(uuid) {
5115
5774
  const projectsDir = join12(homedir5(), ".claude", "projects");
5116
- if (!existsSync6(projectsDir)) return null;
5775
+ if (!existsSync7(projectsDir)) return null;
5117
5776
  const filename = `${uuid}.jsonl`;
5118
5777
  for (const dir of readdirSync4(projectsDir)) {
5119
5778
  const fp = join12(projectsDir, dir, filename);
5120
- if (existsSync6(fp)) return fp;
5779
+ if (existsSync7(fp)) return fp;
5121
5780
  const projectDir = join12(projectsDir, dir);
5122
5781
  try {
5123
5782
  for (const sub of readdirSync4(projectDir)) {
5124
5783
  const subagentPath = join12(projectDir, sub, "subagents", filename);
5125
- if (existsSync6(subagentPath)) return subagentPath;
5784
+ if (existsSync7(subagentPath)) return subagentPath;
5126
5785
  }
5127
5786
  } catch {
5128
5787
  }
@@ -5431,7 +6090,7 @@ var StreamerServer = class {
5431
6090
  handleGetSession(sessionId, res) {
5432
6091
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
5433
6092
  if (session) {
5434
- if (!existsSync6(session.projectPath)) {
6093
+ if (!existsSync7(session.projectPath)) {
5435
6094
  session.failureReason = `Project directory not found: ${session.projectPath}`;
5436
6095
  }
5437
6096
  json(res, 200, session);
@@ -5471,7 +6130,10 @@ var StreamerServer = class {
5471
6130
  json(res, 400, { error: "Could not determine project path" });
5472
6131
  return;
5473
6132
  }
6133
+ const cachedConvMeta = this.cache?.getMetaById(sessionId);
6134
+ const provider = conv?.provider ?? cachedConvMeta?.provider ?? CLAUDE_CODE_PROVIDER;
5474
6135
  const session = await this.ptyManager.start(sessionId, {
6136
+ provider,
5475
6137
  projectPath,
5476
6138
  projectName: body.projectName,
5477
6139
  branch: body.branch
@@ -5830,6 +6492,13 @@ var StreamerServer = class {
5830
6492
  }
5831
6493
  return;
5832
6494
  }
6495
+ const body = await readBody(req);
6496
+ const { path: relativePath, provider: requestedProvider, systemPrompt: clientPrompt } = body;
6497
+ if (requestedProvider !== void 0 && !isProviderName(requestedProvider)) {
6498
+ json(res, 400, { error: "Invalid provider" });
6499
+ return;
6500
+ }
6501
+ const provider = requestedProvider ?? CLAUDE_CODE_PROVIDER;
5833
6502
  if (!this.browseRoot) {
5834
6503
  json(res, 403, {
5835
6504
  error: "File browsing not configured. Set browseRoot on the server.",
@@ -5837,8 +6506,6 @@ var StreamerServer = class {
5837
6506
  });
5838
6507
  return;
5839
6508
  }
5840
- const body = await readBody(req);
5841
- const { path: relativePath, systemPrompt: clientPrompt } = body;
5842
6509
  if (typeof relativePath !== "string") {
5843
6510
  json(res, 400, { error: "Missing path field" });
5844
6511
  return;
@@ -5859,21 +6526,27 @@ var StreamerServer = class {
5859
6526
  ].filter(Boolean);
5860
6527
  try {
5861
6528
  const session = await this.ptyManager.startFresh({
6529
+ provider,
5862
6530
  projectPath: resolvedPath,
5863
6531
  projectName: body.projectName,
5864
6532
  systemPrompt: systemPromptParts.join("\n")
5865
6533
  });
5866
6534
  this.sessionStore.addManaged(session);
5867
6535
  json(res, 202, { id: session.id, status: "pending" });
5868
- this.watchForJsonl(session.id, resolvedPath);
6536
+ if (provider === CODEX_CLI_PROVIDER) {
6537
+ this.watchForCodexRollout(session.id, resolvedPath);
6538
+ } else {
6539
+ this.watchForJsonl(session.id, resolvedPath);
6540
+ }
5869
6541
  this.broadcastOrUnicastSessionList(req);
5870
6542
  } catch (err) {
5871
6543
  const message = err instanceof Error ? err.message : "Failed to start session";
6544
+ const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
5872
6545
  this.log.error(`[start] failed to start session: ${message}`, {
5873
6546
  event: "session.start_failed",
5874
6547
  error: message
5875
6548
  });
5876
- json(res, 500, { error: message });
6549
+ json(res, statusCode, { error: message });
5877
6550
  }
5878
6551
  }
5879
6552
  // ─── Project linking ─────────────────────────────────────────────
@@ -5946,8 +6619,8 @@ var StreamerServer = class {
5946
6619
  cleanup();
5947
6620
  return;
5948
6621
  }
5949
- let resolvedFilePath = existsSync6(filePath) ? filePath : null;
5950
- if (!resolvedFilePath && existsSync6(projectsDir)) {
6622
+ let resolvedFilePath = existsSync7(filePath) ? filePath : null;
6623
+ if (!resolvedFilePath && existsSync7(projectsDir)) {
5951
6624
  try {
5952
6625
  const now = Date.now();
5953
6626
  const recent = readdirSync4(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync5(join12(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
@@ -5991,6 +6664,121 @@ var StreamerServer = class {
5991
6664
  } catch {
5992
6665
  }
5993
6666
  }
6667
+ // Codex-equivalent of watchForJsonl(). Differs because Codex has no
6668
+ // filename-encoded session id (it assigns its own persisted id) and its
6669
+ // rollout files live under a date-nested directory
6670
+ // (~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl) that Codex creates
6671
+ // itself — it may not exist yet when this function is first called, so we
6672
+ // poll rather than fs.watch a not-yet-existent directory. Per Phase 0
6673
+ // findings, the rollout file appears within ~1s of process spawn (after
6674
+ // any directory-trust gate is cleared), well before any user input.
6675
+ watchForCodexRollout(sessionId, projectPath) {
6676
+ const deadline = Date.now() + 12e4;
6677
+ const now = /* @__PURE__ */ new Date();
6678
+ const dateDir = join12(
6679
+ String(now.getFullYear()),
6680
+ String(now.getMonth() + 1).padStart(2, "0"),
6681
+ String(now.getDate()).padStart(2, "0")
6682
+ );
6683
+ const sessionStartedAtMs = (this.sessionStore.getManaged(sessionId)?.startedAt?.getTime() ?? Date.now()) - 5e3;
6684
+ let intervalHandle = null;
6685
+ const cleanup = () => {
6686
+ if (intervalHandle) clearInterval(intervalHandle);
6687
+ intervalHandle = null;
6688
+ };
6689
+ const matchesProjectPath = (candidatePath) => {
6690
+ try {
6691
+ const firstLine = readFileSync6(candidatePath, "utf8").split("\n", 1)[0];
6692
+ if (!firstLine) return null;
6693
+ const parsed = JSON.parse(firstLine);
6694
+ if (parsed?.type !== "session_meta") return null;
6695
+ const payload = parsed.payload ?? {};
6696
+ if (payload.cwd !== projectPath) return null;
6697
+ if (typeof payload.id !== "string") return null;
6698
+ const createdIso = payload.timestamp ?? parsed.timestamp;
6699
+ const createdAtMs = typeof createdIso === "string" ? Date.parse(createdIso) : Number.NaN;
6700
+ if (Number.isNaN(createdAtMs) || createdAtMs < sessionStartedAtMs) return null;
6701
+ return { id: payload.id, createdAtMs };
6702
+ } catch {
6703
+ return null;
6704
+ }
6705
+ };
6706
+ const tryWire = () => {
6707
+ if (!this.ptyManager.hasSession(sessionId)) {
6708
+ cleanup();
6709
+ return;
6710
+ }
6711
+ if (Date.now() > deadline) {
6712
+ cleanup();
6713
+ return;
6714
+ }
6715
+ const boundElsewhere = new Set(
6716
+ this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
6717
+ );
6718
+ for (const root of this.codexRoots) {
6719
+ const sessionsDir = join12(root, dateDir);
6720
+ if (!existsSync7(sessionsDir)) continue;
6721
+ let candidateFiles;
6722
+ try {
6723
+ candidateFiles = readdirSync4(sessionsDir).filter((f) => f.endsWith(".jsonl"));
6724
+ } catch {
6725
+ continue;
6726
+ }
6727
+ const nowMs = Date.now();
6728
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync5(join12(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
6729
+ for (const { f } of recentCandidates) {
6730
+ const candidatePath = join12(sessionsDir, f);
6731
+ const match = matchesProjectPath(candidatePath);
6732
+ if (!match) continue;
6733
+ if (boundElsewhere.has(match.id)) continue;
6734
+ const codexSessionId = match.id;
6735
+ cleanup();
6736
+ this.sessionStore.updateManaged(sessionId, { boundConversationId: codexSessionId });
6737
+ this.sessionFileMap.set(sessionId, candidatePath);
6738
+ this.fileWatcher.watch(candidatePath);
6739
+ try {
6740
+ const existing = readFileSync6(candidatePath, "utf8").split("\n").filter(Boolean);
6741
+ if (existing.length > 0) {
6742
+ this.wsHub.broadcast({ type: "conversation_events", sessionId, lines: existing });
6743
+ for (const line of existing) {
6744
+ this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
6745
+ }
6746
+ }
6747
+ } catch {
6748
+ }
6749
+ if (this.scannerReady) {
6750
+ this.scannerStale = true;
6751
+ } else {
6752
+ this.scanner = null;
6753
+ }
6754
+ this.linkSessionToProject(sessionId, projectPath, candidatePath);
6755
+ this.cache?.markAsStreamer(sessionId);
6756
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
6757
+ if (resp) {
6758
+ this.wsHub.broadcast({ type: "session_update", session: resp });
6759
+ }
6760
+ this.log.info(
6761
+ `[startFresh] bound Codex rollout for ${sessionId}`,
6762
+ {
6763
+ event: "session.codex_rollout_bound",
6764
+ sessionId,
6765
+ boundConversationId: codexSessionId,
6766
+ filePath: candidatePath
6767
+ },
6768
+ "pino"
6769
+ );
6770
+ return;
6771
+ }
6772
+ }
6773
+ };
6774
+ tryWire();
6775
+ if (!intervalHandle && Date.now() <= deadline) {
6776
+ const alreadyBound = this.sessionStore.getManaged(sessionId)?.boundConversationId != null;
6777
+ if (!alreadyBound) {
6778
+ intervalHandle = setInterval(tryWire, 250);
6779
+ }
6780
+ }
6781
+ }
5994
6782
  async handleBrowse(url, res) {
5995
6783
  if (!this.browseRoot) {
5996
6784
  json(res, 403, {
@@ -6074,7 +6862,7 @@ var StreamerServer = class {
6074
6862
  };
6075
6863
  function classifyResumability(cwd) {
6076
6864
  if (!cwd) return { resumable: true };
6077
- if (existsSync6(cwd)) return { resumable: true };
6865
+ if (existsSync7(cwd)) return { resumable: true };
6078
6866
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
6079
6867
  return {
6080
6868
  resumable: false,
@@ -6205,7 +6993,10 @@ function readBody(req) {
6205
6993
  });
6206
6994
  }
6207
6995
  export {
6996
+ CLAUDE_CODE_PROVIDER,
6997
+ CODEX_CLI_PROVIDER,
6208
6998
  ConversationWatcher,
6999
+ LiveSessionManager,
6209
7000
  PTYManager,
6210
7001
  SessionStore,
6211
7002
  StreamerServer,
@@ -6219,6 +7010,8 @@ export {
6219
7010
  generateApiKey,
6220
7011
  getDbConfig,
6221
7012
  isDbEnabled,
7013
+ isProviderName,
7014
+ isProviderResumable,
6222
7015
  loadOrCreateApiKey,
6223
7016
  maskConnectionString,
6224
7017
  readAgentConfig,