@threadbase-sh/streamer 1.23.1 → 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
1007
  }
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
- }
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"]);
@@ -3072,7 +3707,7 @@ var ConversationCache = class _ConversationCache {
3072
3707
  * `handleGetConversation` can still serve the cached tail even when the
3073
3708
  * JSONL has been deleted.
3074
3709
  */
3075
- pruneGhostFiles(exists = existsSync4) {
3710
+ pruneGhostFiles(exists = existsSync5) {
3076
3711
  const rows = this.stmts.allFilePaths.all();
3077
3712
  const ghosts = [];
3078
3713
  const prune = this.db.transaction((ids) => {
@@ -3152,7 +3787,7 @@ var ConversationsRepository = class {
3152
3787
  };
3153
3788
 
3154
3789
  // src/db/repositories/projects.repository.ts
3155
- import { randomUUID as randomUUID2 } from "crypto";
3790
+ import { randomUUID as randomUUID3 } from "crypto";
3156
3791
 
3157
3792
  // src/utils/canonicalizeProjectPath.ts
3158
3793
  function canonicalizeProjectPath(projectPath) {
@@ -3245,7 +3880,7 @@ var ProjectsRepository = class {
3245
3880
  });
3246
3881
  return rowToProject(this.getById.get(existing.id));
3247
3882
  }
3248
- const id = randomUUID2();
3883
+ const id = randomUUID3();
3249
3884
  this.insert.run({
3250
3885
  id,
3251
3886
  path,
@@ -3542,14 +4177,14 @@ var ConversationWatcher = class {
3542
4177
  };
3543
4178
 
3544
4179
  // src/services/conversations/pruneAgentConversations.ts
3545
- import { existsSync as existsSync5 } from "fs";
4180
+ import { existsSync as existsSync6 } from "fs";
3546
4181
  function pruneAgentConversations(cache) {
3547
4182
  const db = cache.getDatabase();
3548
4183
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
3549
4184
  let pruned = 0;
3550
4185
  let missing = 0;
3551
4186
  for (const row of rows) {
3552
- if (!existsSync5(row.file_path)) {
4187
+ if (!existsSync6(row.file_path)) {
3553
4188
  missing += 1;
3554
4189
  continue;
3555
4190
  }
@@ -3832,6 +4467,7 @@ function managedToResponse(s, ptyAttached) {
3832
4467
  return {
3833
4468
  id: s.id,
3834
4469
  conversationId: s.id,
4470
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
3835
4471
  status: s.status,
3836
4472
  projectPath: s.projectPath,
3837
4473
  projectName: s.projectName,
@@ -3857,13 +4493,15 @@ function managedToResponse(s, ptyAttached) {
3857
4493
  ...s.failureReason != null && { failureReason: s.failureReason },
3858
4494
  ...s.resumedFromConversationId != null && {
3859
4495
  resumedFromConversationId: s.resumedFromConversationId
3860
- }
4496
+ },
4497
+ ...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
3861
4498
  };
3862
4499
  }
3863
4500
  function discoveredToResponse(d, conversationId) {
3864
4501
  return {
3865
4502
  id: conversationId,
3866
4503
  conversationId,
4504
+ provider: CLAUDE_CODE_PROVIDER,
3867
4505
  status: "idle",
3868
4506
  projectPath: d.projectPath,
3869
4507
  projectName: d.projectName,
@@ -4305,7 +4943,7 @@ var StreamerServer = class {
4305
4943
  });
4306
4944
  }
4307
4945
  });
4308
- this.ptyManager = new PTYManager({
4946
+ this.ptyManager = new LiveSessionManager({
4309
4947
  logger: getLogger("pty"),
4310
4948
  onOutput: (sessionId, data) => {
4311
4949
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
@@ -4642,10 +5280,11 @@ var StreamerServer = class {
4642
5280
  }
4643
5281
  } catch (err) {
4644
5282
  const message = err instanceof Error ? err.message : String(err);
4645
- this.log.warn(`ConversationCache failed to open (running without cache): ${message}`, {
4646
- error: message,
4647
- event: "cache.open_failed"
4648
- });
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
+ );
4649
5288
  }
4650
5289
  const warmupScanner = new ConversationScanner();
4651
5290
  this.allScanners.add(warmupScanner);
@@ -5133,16 +5772,16 @@ var StreamerServer = class {
5133
5772
  }
5134
5773
  findJsonlPath(uuid) {
5135
5774
  const projectsDir = join12(homedir5(), ".claude", "projects");
5136
- if (!existsSync6(projectsDir)) return null;
5775
+ if (!existsSync7(projectsDir)) return null;
5137
5776
  const filename = `${uuid}.jsonl`;
5138
5777
  for (const dir of readdirSync4(projectsDir)) {
5139
5778
  const fp = join12(projectsDir, dir, filename);
5140
- if (existsSync6(fp)) return fp;
5779
+ if (existsSync7(fp)) return fp;
5141
5780
  const projectDir = join12(projectsDir, dir);
5142
5781
  try {
5143
5782
  for (const sub of readdirSync4(projectDir)) {
5144
5783
  const subagentPath = join12(projectDir, sub, "subagents", filename);
5145
- if (existsSync6(subagentPath)) return subagentPath;
5784
+ if (existsSync7(subagentPath)) return subagentPath;
5146
5785
  }
5147
5786
  } catch {
5148
5787
  }
@@ -5451,7 +6090,7 @@ var StreamerServer = class {
5451
6090
  handleGetSession(sessionId, res) {
5452
6091
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
5453
6092
  if (session) {
5454
- if (!existsSync6(session.projectPath)) {
6093
+ if (!existsSync7(session.projectPath)) {
5455
6094
  session.failureReason = `Project directory not found: ${session.projectPath}`;
5456
6095
  }
5457
6096
  json(res, 200, session);
@@ -5491,7 +6130,10 @@ var StreamerServer = class {
5491
6130
  json(res, 400, { error: "Could not determine project path" });
5492
6131
  return;
5493
6132
  }
6133
+ const cachedConvMeta = this.cache?.getMetaById(sessionId);
6134
+ const provider = conv?.provider ?? cachedConvMeta?.provider ?? CLAUDE_CODE_PROVIDER;
5494
6135
  const session = await this.ptyManager.start(sessionId, {
6136
+ provider,
5495
6137
  projectPath,
5496
6138
  projectName: body.projectName,
5497
6139
  branch: body.branch
@@ -5850,6 +6492,13 @@ var StreamerServer = class {
5850
6492
  }
5851
6493
  return;
5852
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;
5853
6502
  if (!this.browseRoot) {
5854
6503
  json(res, 403, {
5855
6504
  error: "File browsing not configured. Set browseRoot on the server.",
@@ -5857,8 +6506,6 @@ var StreamerServer = class {
5857
6506
  });
5858
6507
  return;
5859
6508
  }
5860
- const body = await readBody(req);
5861
- const { path: relativePath, systemPrompt: clientPrompt } = body;
5862
6509
  if (typeof relativePath !== "string") {
5863
6510
  json(res, 400, { error: "Missing path field" });
5864
6511
  return;
@@ -5879,21 +6526,27 @@ var StreamerServer = class {
5879
6526
  ].filter(Boolean);
5880
6527
  try {
5881
6528
  const session = await this.ptyManager.startFresh({
6529
+ provider,
5882
6530
  projectPath: resolvedPath,
5883
6531
  projectName: body.projectName,
5884
6532
  systemPrompt: systemPromptParts.join("\n")
5885
6533
  });
5886
6534
  this.sessionStore.addManaged(session);
5887
6535
  json(res, 202, { id: session.id, status: "pending" });
5888
- 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
+ }
5889
6541
  this.broadcastOrUnicastSessionList(req);
5890
6542
  } catch (err) {
5891
6543
  const message = err instanceof Error ? err.message : "Failed to start session";
6544
+ const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
5892
6545
  this.log.error(`[start] failed to start session: ${message}`, {
5893
6546
  event: "session.start_failed",
5894
6547
  error: message
5895
6548
  });
5896
- json(res, 500, { error: message });
6549
+ json(res, statusCode, { error: message });
5897
6550
  }
5898
6551
  }
5899
6552
  // ─── Project linking ─────────────────────────────────────────────
@@ -5966,8 +6619,8 @@ var StreamerServer = class {
5966
6619
  cleanup();
5967
6620
  return;
5968
6621
  }
5969
- let resolvedFilePath = existsSync6(filePath) ? filePath : null;
5970
- if (!resolvedFilePath && existsSync6(projectsDir)) {
6622
+ let resolvedFilePath = existsSync7(filePath) ? filePath : null;
6623
+ if (!resolvedFilePath && existsSync7(projectsDir)) {
5971
6624
  try {
5972
6625
  const now = Date.now();
5973
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];
@@ -6011,6 +6664,121 @@ var StreamerServer = class {
6011
6664
  } catch {
6012
6665
  }
6013
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
+ }
6014
6782
  async handleBrowse(url, res) {
6015
6783
  if (!this.browseRoot) {
6016
6784
  json(res, 403, {
@@ -6094,7 +6862,7 @@ var StreamerServer = class {
6094
6862
  };
6095
6863
  function classifyResumability(cwd) {
6096
6864
  if (!cwd) return { resumable: true };
6097
- if (existsSync6(cwd)) return { resumable: true };
6865
+ if (existsSync7(cwd)) return { resumable: true };
6098
6866
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
6099
6867
  return {
6100
6868
  resumable: false,
@@ -6225,7 +6993,10 @@ function readBody(req) {
6225
6993
  });
6226
6994
  }
6227
6995
  export {
6996
+ CLAUDE_CODE_PROVIDER,
6997
+ CODEX_CLI_PROVIDER,
6228
6998
  ConversationWatcher,
6999
+ LiveSessionManager,
6229
7000
  PTYManager,
6230
7001
  SessionStore,
6231
7002
  StreamerServer,
@@ -6239,6 +7010,8 @@ export {
6239
7010
  generateApiKey,
6240
7011
  getDbConfig,
6241
7012
  isDbEnabled,
7013
+ isProviderName,
7014
+ isProviderResumable,
6242
7015
  loadOrCreateApiKey,
6243
7016
  maskConnectionString,
6244
7017
  readAgentConfig,