@threadbase-sh/streamer 1.23.1 → 1.24.1

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
  }
@@ -931,6 +1317,11 @@ function buildSpawnEnv() {
931
1317
  if (env.CLAUDE_API_KEY) {
932
1318
  env.ANTHROPIC_API_KEY = env.CLAUDE_API_KEY;
933
1319
  }
1320
+ for (const key of Object.keys(env)) {
1321
+ if (key === "CLAUDECODE" || key.startsWith("CLAUDE_CODE_")) {
1322
+ delete env[key];
1323
+ }
1324
+ }
934
1325
  return env;
935
1326
  }
936
1327
  var PTYManager = class {
@@ -971,6 +1362,10 @@ var PTYManager = class {
971
1362
  // to a given input or fell silent. Reset on dispose().
972
1363
  chunkIndex = /* @__PURE__ */ new Map();
973
1364
  lastChunkAt = /* @__PURE__ */ new Map();
1365
+ // In-flight start()/startFresh() calls keyed by sessionId. A second
1366
+ // concurrent resume for the same session (double-tap, client retry) awaits
1367
+ // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
1368
+ startPromises = /* @__PURE__ */ new Map();
974
1369
  constructor(options = {}) {
975
1370
  this.onOutput = options.onOutput;
976
1371
  this.onStatusChange = options.onStatusChange;
@@ -993,7 +1388,18 @@ var PTYManager = class {
993
1388
  // custom-API-key — are cleared by the seeded ~/.claude.json in
994
1389
  // docker/entrypoint.sh.) startFresh() uses the same flag for the same reason.
995
1390
  async start(sessionId, options) {
996
- const nodePty = await loadPty();
1391
+ const existing = this.sessions.get(sessionId);
1392
+ if (existing) return toPublicSession2(existing);
1393
+ const inFlight = this.startPromises.get(sessionId);
1394
+ if (inFlight) return inFlight;
1395
+ const promise = this.doStart(sessionId, options).finally(() => {
1396
+ this.startPromises.delete(sessionId);
1397
+ });
1398
+ this.startPromises.set(sessionId, promise);
1399
+ return promise;
1400
+ }
1401
+ async doStart(sessionId, options) {
1402
+ const nodePty = await loadPty2();
997
1403
  const projectName = options.projectName ?? basename2(options.projectPath);
998
1404
  const proc = nodePty.spawn(
999
1405
  resolveClaudeExe(),
@@ -1015,6 +1421,7 @@ var PTYManager = class {
1015
1421
  );
1016
1422
  const session = {
1017
1423
  id: sessionId,
1424
+ provider: CLAUDE_CODE_PROVIDER,
1018
1425
  projectPath: options.projectPath,
1019
1426
  projectName,
1020
1427
  branch: options.branch ?? "",
@@ -1025,7 +1432,7 @@ var PTYManager = class {
1025
1432
  lastOutput: "",
1026
1433
  process: proc,
1027
1434
  outputBuffer: Buffer.alloc(0),
1028
- screen: createScreen()
1435
+ screen: createScreen2()
1029
1436
  };
1030
1437
  this.sessions.set(sessionId, session);
1031
1438
  this.pendingReady.add(sessionId);
@@ -1036,14 +1443,14 @@ var PTYManager = class {
1036
1443
  this.pendingReady.delete(sessionId);
1037
1444
  this.handleExit(sessionId, exitCode);
1038
1445
  });
1039
- return toPublicSession(session);
1446
+ return toPublicSession2(session);
1040
1447
  }
1041
1448
  // Start a brand-new Claude session. A stable UUID is generated here and passed
1042
1449
  // to Claude via --session-id so the JSONL filename matches from the start.
1043
1450
  // onReady fires once Claude reaches its first prompt (waiting_input).
1044
1451
  async startFresh(options) {
1045
- const nodePty = await loadPty();
1046
- const sessionId = randomUUID();
1452
+ const nodePty = await loadPty2();
1453
+ const sessionId = randomUUID2();
1047
1454
  const projectName = options.projectName ?? basename2(options.projectPath);
1048
1455
  const args = [
1049
1456
  "--permission-mode",
@@ -1065,6 +1472,7 @@ var PTYManager = class {
1065
1472
  });
1066
1473
  const session = {
1067
1474
  id: sessionId,
1475
+ provider: CLAUDE_CODE_PROVIDER,
1068
1476
  projectPath: options.projectPath,
1069
1477
  projectName,
1070
1478
  branch: "",
@@ -1075,7 +1483,7 @@ var PTYManager = class {
1075
1483
  lastOutput: "",
1076
1484
  process: proc,
1077
1485
  outputBuffer: Buffer.alloc(0),
1078
- screen: createScreen()
1486
+ screen: createScreen2()
1079
1487
  };
1080
1488
  this.sessions.set(sessionId, session);
1081
1489
  this.pendingReady.add(sessionId);
@@ -1086,7 +1494,7 @@ var PTYManager = class {
1086
1494
  this.pendingReady.delete(sessionId);
1087
1495
  this.handleExit(sessionId, exitCode);
1088
1496
  });
1089
- return toPublicSession(session);
1497
+ return toPublicSession2(session);
1090
1498
  }
1091
1499
  // Write raw key bytes directly to the PTY without bracketed-paste wrapping.
1092
1500
  // Use for control sequences (arrow keys, Enter) that must not be quoted.
@@ -1098,10 +1506,10 @@ var PTYManager = class {
1098
1506
  }
1099
1507
  if (session.status === "waiting_input") {
1100
1508
  session.status = "running";
1101
- this.onStatusChange?.(toPublicSession(session));
1509
+ this.onStatusChange?.(toPublicSession2(session));
1102
1510
  }
1103
1511
  this.log.info(
1104
- `[pty.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes(keys)}`,
1512
+ `[pty.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes2(keys)}`,
1105
1513
  { event: "pty.keys_write", sessionId, byteLen: keys.length }
1106
1514
  );
1107
1515
  session.process.write(keys);
@@ -1133,7 +1541,7 @@ var PTYManager = class {
1133
1541
  }
1134
1542
  if (session.status === "waiting_input") {
1135
1543
  session.status = "running";
1136
- this.onStatusChange?.(toPublicSession(session));
1544
+ this.onStatusChange?.(toPublicSession2(session));
1137
1545
  }
1138
1546
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
1139
1547
  session.lastActivityAt = /* @__PURE__ */ new Date();
@@ -1146,13 +1554,13 @@ var PTYManager = class {
1146
1554
  writeSubmit(sessionId, session, input, path, promptCount) {
1147
1555
  const pasteBytes = buildPasteBytes(input);
1148
1556
  this.log.info(
1149
- `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes(pasteBytes)}`,
1557
+ `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes2(pasteBytes)}`,
1150
1558
  {
1151
1559
  event: "pty.input_write",
1152
1560
  sessionId,
1153
1561
  promptCount,
1154
1562
  byteLen: pasteBytes.length,
1155
- digest: digestBytes(pasteBytes),
1563
+ digest: digestBytes2(pasteBytes),
1156
1564
  path,
1157
1565
  phase: "paste"
1158
1566
  }
@@ -1167,13 +1575,13 @@ var PTYManager = class {
1167
1575
  event: "pty.input_write",
1168
1576
  sessionId,
1169
1577
  promptCount,
1170
- byteLen: SUBMIT_BYTES.length,
1578
+ byteLen: SUBMIT_BYTES2.length,
1171
1579
  digest: "\\r",
1172
1580
  path,
1173
1581
  phase: "submit"
1174
1582
  }
1175
1583
  );
1176
- current.process.write(SUBMIT_BYTES);
1584
+ current.process.write(SUBMIT_BYTES2);
1177
1585
  }, SUBMIT_DELAY_MS);
1178
1586
  }
1179
1587
  // Drain any inputs that were sent while the session was still pendingReady,
@@ -1231,7 +1639,7 @@ var PTYManager = class {
1231
1639
  session.completedAt = /* @__PURE__ */ new Date();
1232
1640
  session.screen.dispose();
1233
1641
  this.sessions.delete(sessionId);
1234
- this.onStatusChange?.(toPublicSession(session));
1642
+ this.onStatusChange?.(toPublicSession2(session));
1235
1643
  }
1236
1644
  getOutput(sessionId) {
1237
1645
  const session = this.sessions.get(sessionId);
@@ -1262,13 +1670,13 @@ var PTYManager = class {
1262
1670
  }
1263
1671
  getSession(sessionId) {
1264
1672
  const session = this.sessions.get(sessionId);
1265
- return session ? toPublicSession(session) : null;
1673
+ return session ? toPublicSession2(session) : null;
1266
1674
  }
1267
1675
  hasSession(sessionId) {
1268
1676
  return this.sessions.has(sessionId);
1269
1677
  }
1270
1678
  listSessions() {
1271
- return Array.from(this.sessions.values()).map(toPublicSession);
1679
+ return Array.from(this.sessions.values()).map(toPublicSession2);
1272
1680
  }
1273
1681
  dispose() {
1274
1682
  for (const session of this.sessions.values()) {
@@ -1300,7 +1708,7 @@ var PTYManager = class {
1300
1708
  this.lastChunkAt.set(sessionId, now);
1301
1709
  const gapMs = last == null ? 0 : now - last;
1302
1710
  this.log.info(
1303
- `[pty.chunk] ${sessionId.slice(0, 8)} #${idx} +${chunk.length}B gap=${gapMs}ms status=${session.status} digest=${digestBytes(data)}`,
1711
+ `[pty.chunk] ${sessionId.slice(0, 8)} #${idx} +${chunk.length}B gap=${gapMs}ms status=${session.status} digest=${digestBytes2(data)}`,
1304
1712
  {
1305
1713
  event: "pty.chunk",
1306
1714
  sessionId,
@@ -1309,17 +1717,17 @@ var PTYManager = class {
1309
1717
  gapMs,
1310
1718
  status: session.status,
1311
1719
  pendingReady: this.pendingReady.has(sessionId),
1312
- digest: digestBytes(data)
1720
+ digest: digestBytes2(data)
1313
1721
  }
1314
1722
  );
1315
1723
  session.outputBuffer = Buffer.concat([session.outputBuffer, chunk]);
1316
- if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
1724
+ if (session.outputBuffer.length > OUTPUT_BUFFER_MAX2) {
1317
1725
  session.outputBuffer = session.outputBuffer.subarray(
1318
- session.outputBuffer.length - OUTPUT_BUFFER_MAX
1726
+ session.outputBuffer.length - OUTPUT_BUFFER_MAX2
1319
1727
  );
1320
1728
  }
1321
1729
  session.screen.write(data);
1322
- const stripped = stripAnsi(data);
1730
+ const stripped = stripAnsi2(data);
1323
1731
  session.lastOutput = stripped;
1324
1732
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
1325
1733
  if (session.status === "running" && matchedMarker) {
@@ -1422,11 +1830,11 @@ var PTYManager = class {
1422
1830
  reason,
1423
1831
  elapsedMs
1424
1832
  });
1425
- this.onStatusChange?.(toPublicSession(session));
1833
+ this.onStatusChange?.(toPublicSession2(session));
1426
1834
  if (this.pendingReady.has(sessionId)) {
1427
1835
  this.pendingReady.delete(sessionId);
1428
1836
  this.flushQueuedInputs(sessionId);
1429
- this.onReady?.(toPublicSession(session));
1837
+ this.onReady?.(toPublicSession2(session));
1430
1838
  }
1431
1839
  }
1432
1840
  handleExit(sessionId, exitCode) {
@@ -1436,13 +1844,13 @@ var PTYManager = class {
1436
1844
  session.status = "idle";
1437
1845
  const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1438
1846
  if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1439
- if (!existsSync2(session.projectPath)) {
1847
+ if (!existsSync3(session.projectPath)) {
1440
1848
  session.failureReason = `Project directory not found: ${session.projectPath}`;
1441
1849
  } else {
1442
1850
  session.failureReason = `Process exited immediately (code ${exitCode}). Check that the Claude binary is installed and accessible.`;
1443
1851
  }
1444
1852
  }
1445
- this.onStatusChange?.(toPublicSession(session));
1853
+ this.onStatusChange?.(toPublicSession2(session));
1446
1854
  session.screen.dispose();
1447
1855
  this.sessions.delete(sessionId);
1448
1856
  this.queuedInputs.delete(sessionId);
@@ -1452,9 +1860,10 @@ var PTYManager = class {
1452
1860
  this.shellPromptOpen.delete(sessionId);
1453
1861
  }
1454
1862
  };
1455
- function toPublicSession(s) {
1863
+ function toPublicSession2(s) {
1456
1864
  return {
1457
1865
  id: s.id,
1866
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
1458
1867
  projectPath: s.projectPath,
1459
1868
  projectName: s.projectName,
1460
1869
  branch: s.branch,
@@ -1468,10 +1877,249 @@ function toPublicSession(s) {
1468
1877
  ...s.filePath != null && { filePath: s.filePath }
1469
1878
  };
1470
1879
  }
1471
- function stripAnsi(str) {
1880
+ function stripAnsi2(str) {
1472
1881
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
1473
1882
  }
1474
1883
 
1884
+ // src/live-session-manager.ts
1885
+ var LiveSessionManager = class {
1886
+ runners;
1887
+ constructor(options = {}) {
1888
+ this.runners = /* @__PURE__ */ new Map([
1889
+ [CLAUDE_CODE_PROVIDER, new PTYManager(options)],
1890
+ [CODEX_CLI_PROVIDER, new CodexPtyRunner(options)]
1891
+ ]);
1892
+ }
1893
+ async start(sessionId, options) {
1894
+ const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
1895
+ const runner = this.assertSupportedProvider(provider, options.projectPath);
1896
+ return runner.start(sessionId, options);
1897
+ }
1898
+ async startFresh(options) {
1899
+ const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
1900
+ const runner = this.assertSupportedProvider(provider, options.projectPath);
1901
+ return runner.startFresh(options);
1902
+ }
1903
+ sendInput(sessionId, input) {
1904
+ return this.runnerFor(sessionId).sendInput(sessionId, input);
1905
+ }
1906
+ sendKeys(sessionId, keys) {
1907
+ this.runnerFor(sessionId).sendKeys(sessionId, keys);
1908
+ }
1909
+ cancel(sessionId) {
1910
+ this.runnerFor(sessionId).cancel(sessionId);
1911
+ }
1912
+ killPid(pid) {
1913
+ for (const runner of this.runners.values()) {
1914
+ runner.killPid(pid);
1915
+ }
1916
+ }
1917
+ // putOnHold tolerates an unknown sessionId (PTYManager.putOnHold is a no-op
1918
+ // when the session isn't in its map), so — unlike the other session-keyed
1919
+ // methods — route to the owning runner when found, otherwise broadcast to
1920
+ // every runner rather than throwing; this matches the pre-extraction
1921
+ // behavior of delegating straight through with no existence check.
1922
+ putOnHold(sessionId) {
1923
+ for (const runner of this.runners.values()) {
1924
+ if (runner.hasSession(sessionId) || runner.getSession(sessionId)) {
1925
+ runner.putOnHold(sessionId);
1926
+ return;
1927
+ }
1928
+ }
1929
+ for (const runner of this.runners.values()) {
1930
+ runner.putOnHold(sessionId);
1931
+ }
1932
+ }
1933
+ getOutput(sessionId) {
1934
+ return this.runnerFor(sessionId).getOutput(sessionId);
1935
+ }
1936
+ getOutputLines(sessionId, maxLines) {
1937
+ return this.runnerFor(sessionId).getOutputLines(sessionId, maxLines);
1938
+ }
1939
+ getSession(sessionId) {
1940
+ for (const runner of this.runners.values()) {
1941
+ const session = runner.getSession(sessionId);
1942
+ if (session) return session;
1943
+ }
1944
+ return null;
1945
+ }
1946
+ hasSession(sessionId) {
1947
+ for (const runner of this.runners.values()) {
1948
+ if (runner.hasSession(sessionId)) return true;
1949
+ }
1950
+ return false;
1951
+ }
1952
+ listSessions() {
1953
+ return Array.from(this.runners.values()).flatMap((runner) => runner.listSessions());
1954
+ }
1955
+ dispose() {
1956
+ for (const runner of this.runners.values()) {
1957
+ runner.dispose();
1958
+ }
1959
+ }
1960
+ // Look up which runner owns a session. Only one runner exists today, so
1961
+ // this is a linear scan across hasSession()/getSession() rather than a
1962
+ // separate session→provider index — see task-1-brief.md.
1963
+ runnerFor(sessionId) {
1964
+ for (const runner of this.runners.values()) {
1965
+ if (runner.hasSession(sessionId) || runner.getSession(sessionId)) return runner;
1966
+ }
1967
+ throw new Error(`Session not found: ${sessionId}`);
1968
+ }
1969
+ assertSupportedProvider(provider, projectPath) {
1970
+ const runner = this.runners.get(provider);
1971
+ if (runner) return runner;
1972
+ const err = new Error(
1973
+ `Live ${provider} sessions are not implemented yet for ${basename3(projectPath)}`
1974
+ );
1975
+ err.statusCode = 501;
1976
+ throw err;
1977
+ }
1978
+ };
1979
+
1980
+ // src/process-discovery.ts
1981
+ import { execFile } from "child_process";
1982
+ import { platform as platform2 } from "os";
1983
+ import { basename as basename4, dirname as dirname3 } from "path";
1984
+ async function discoverClaudeProcesses() {
1985
+ if (platform2() === "win32") return discoverWindows();
1986
+ return discoverUnix();
1987
+ }
1988
+ async function discoverUnix() {
1989
+ const pids = await getPidsUnix();
1990
+ const results = await Promise.all(
1991
+ pids.map(async (pid) => {
1992
+ try {
1993
+ const [cwd, args, startedAt] = await Promise.all([
1994
+ getProcessCwdUnix(pid),
1995
+ getProcessArgsUnix(pid),
1996
+ getProcessStartTimeUnix(pid)
1997
+ ]);
1998
+ const conversationId = extractResumeId(args);
1999
+ return {
2000
+ pid,
2001
+ projectPath: cwd,
2002
+ projectName: basename4(cwd),
2003
+ branch: await readGitBranch(cwd),
2004
+ conversationId,
2005
+ startedAt
2006
+ };
2007
+ } catch {
2008
+ return null;
2009
+ }
2010
+ })
2011
+ );
2012
+ return results.filter((r) => r !== null);
2013
+ }
2014
+ async function discoverWindows() {
2015
+ const pids = await getPidsWindows();
2016
+ const results = await Promise.all(
2017
+ pids.map(async (pid) => {
2018
+ try {
2019
+ const info = await getProcessInfoWindows(pid);
2020
+ if (!info) return null;
2021
+ return {
2022
+ pid,
2023
+ projectPath: info.cwd,
2024
+ projectName: basename4(info.cwd),
2025
+ branch: await readGitBranch(info.cwd),
2026
+ conversationId: extractResumeId(info.args),
2027
+ startedAt: info.startedAt
2028
+ };
2029
+ } catch {
2030
+ return null;
2031
+ }
2032
+ })
2033
+ );
2034
+ return results.filter((r) => r !== null);
2035
+ }
2036
+ function run(cmd, args, opts = {}) {
2037
+ return new Promise((resolve2, reject) => {
2038
+ execFile(
2039
+ cmd,
2040
+ args,
2041
+ { windowsHide: isWindows, encoding: "utf-8", timeout: opts.timeout ?? 5e3, cwd: opts.cwd },
2042
+ (err, stdout) => {
2043
+ if (err) reject(err);
2044
+ else resolve2(stdout);
2045
+ }
2046
+ );
2047
+ });
2048
+ }
2049
+ async function getPidsUnix() {
2050
+ try {
2051
+ const output = await run("pgrep", ["-x", "claude"]);
2052
+ return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
2053
+ } catch {
2054
+ return [];
2055
+ }
2056
+ }
2057
+ async function getProcessCwdUnix(pid) {
2058
+ const output = await run("lsof", ["-p", String(pid), "-a", "-d", "cwd", "-Fn"]);
2059
+ const match = output.match(/n(.+)/);
2060
+ return match?.[1] ?? "";
2061
+ }
2062
+ async function getProcessArgsUnix(pid) {
2063
+ return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
2064
+ }
2065
+ async function getProcessStartTimeUnix(pid) {
2066
+ const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
2067
+ const d = new Date(raw);
2068
+ return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
2069
+ }
2070
+ async function getPidsWindows() {
2071
+ try {
2072
+ const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
2073
+ return output.trim().split("\n").filter(Boolean).map((line) => {
2074
+ const parts = line.split(",");
2075
+ return Number.parseInt(parts[1]?.replace(/"/g, "") ?? "0", 10);
2076
+ }).filter((pid) => pid > 0);
2077
+ } catch {
2078
+ return [];
2079
+ }
2080
+ }
2081
+ async function getProcessInfoWindows(pid) {
2082
+ try {
2083
+ const output = await run("wmic", [
2084
+ "process",
2085
+ "where",
2086
+ `ProcessId=${pid}`,
2087
+ "get",
2088
+ "CommandLine,CreationDate,ExecutablePath",
2089
+ "/FORMAT:CSV"
2090
+ ]);
2091
+ const lines = output.trim().split(/\r?\n/).filter((l) => l.trim().length > 0);
2092
+ if (lines.length < 2) return null;
2093
+ const parts = lines[1].split(",");
2094
+ const args = parts[1] ?? "";
2095
+ const creationDate = parts[2] ?? "";
2096
+ const year = creationDate.slice(0, 4);
2097
+ const month = creationDate.slice(4, 6);
2098
+ const day = creationDate.slice(6, 8);
2099
+ const hour = creationDate.slice(8, 10);
2100
+ const min = creationDate.slice(10, 12);
2101
+ const sec = creationDate.slice(12, 14);
2102
+ const startedAt = /* @__PURE__ */ new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`);
2103
+ if (Number.isNaN(startedAt.getTime())) return null;
2104
+ const exePath = parts[3] ?? "";
2105
+ const cwd = exePath ? dirname3(exePath) : "";
2106
+ return { cwd, args, startedAt };
2107
+ } catch {
2108
+ return null;
2109
+ }
2110
+ }
2111
+ function extractResumeId(args) {
2112
+ const match = args.match(/--resume\s+(\S+)/);
2113
+ return match?.[1] ?? null;
2114
+ }
2115
+ async function readGitBranch(dir) {
2116
+ try {
2117
+ return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
2118
+ } catch {
2119
+ return "";
2120
+ }
2121
+ }
2122
+
1475
2123
  // src/server.ts
1476
2124
  import { createNodeWebSocket } from "@hono/node-ws";
1477
2125
  import { Connection, Client as TemporalClient } from "@temporalio/client";
@@ -1486,7 +2134,7 @@ import {
1486
2134
  import { EventEmitter } from "events";
1487
2135
  import {
1488
2136
  createReadStream,
1489
- existsSync as existsSync6,
2137
+ existsSync as existsSync7,
1490
2138
  watch as fsWatch,
1491
2139
  readdirSync as readdirSync4,
1492
2140
  readFileSync as readFileSync6,
@@ -1678,7 +2326,7 @@ async function handleSendAgentInput(sessionId, body, deps) {
1678
2326
  }
1679
2327
 
1680
2328
  // src/agent/handle-start-agent-session.ts
1681
- import { existsSync as existsSync3 } from "fs";
2329
+ import { existsSync as existsSync4 } from "fs";
1682
2330
  import { join as join5 } from "path";
1683
2331
  function validateBody(body) {
1684
2332
  if (body === null || body === void 0 || typeof body !== "object") {
@@ -1710,7 +2358,7 @@ async function handleStartAgentSession(body, deps) {
1710
2358
  let conversationId = parsed.conversationId;
1711
2359
  if (conversationId) {
1712
2360
  const jsonlPath = join5(deps.conversationsDir, `${conversationId}.jsonl`);
1713
- if (!existsSync3(jsonlPath)) {
2361
+ if (!existsSync4(jsonlPath)) {
1714
2362
  return {
1715
2363
  status: 404,
1716
2364
  body: agentErrorResponse(
@@ -2300,7 +2948,7 @@ async function createDirectory(parentAbsolutePath, name) {
2300
2948
 
2301
2949
  // src/conversation-cache.ts
2302
2950
  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";
2951
+ import { closeSync as closeSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, openSync as openSync2, readSync as readSync2, statSync as statSync2 } from "fs";
2304
2952
  import { dirname as dirname6 } from "path";
2305
2953
 
2306
2954
  // src/db/sqlite-migrate.ts
@@ -2344,14 +2992,6 @@ function runSqliteMigrations(db, migrationsDir) {
2344
2992
  return { applied, skipped };
2345
2993
  }
2346
2994
 
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
2995
  // src/services/conversations/isAgentConversation.ts
2356
2996
  import { closeSync, openSync, readSync, statSync } from "fs";
2357
2997
  var DEFAULT_AGENT_ENTRYPOINTS = /* @__PURE__ */ new Set(["sdk-cli", "claude-vscode"]);
@@ -3072,7 +3712,7 @@ var ConversationCache = class _ConversationCache {
3072
3712
  * `handleGetConversation` can still serve the cached tail even when the
3073
3713
  * JSONL has been deleted.
3074
3714
  */
3075
- pruneGhostFiles(exists = existsSync4) {
3715
+ pruneGhostFiles(exists = existsSync5) {
3076
3716
  const rows = this.stmts.allFilePaths.all();
3077
3717
  const ghosts = [];
3078
3718
  const prune = this.db.transaction((ids) => {
@@ -3152,7 +3792,7 @@ var ConversationsRepository = class {
3152
3792
  };
3153
3793
 
3154
3794
  // src/db/repositories/projects.repository.ts
3155
- import { randomUUID as randomUUID2 } from "crypto";
3795
+ import { randomUUID as randomUUID3 } from "crypto";
3156
3796
 
3157
3797
  // src/utils/canonicalizeProjectPath.ts
3158
3798
  function canonicalizeProjectPath(projectPath) {
@@ -3245,7 +3885,7 @@ var ProjectsRepository = class {
3245
3885
  });
3246
3886
  return rowToProject(this.getById.get(existing.id));
3247
3887
  }
3248
- const id = randomUUID2();
3888
+ const id = randomUUID3();
3249
3889
  this.insert.run({
3250
3890
  id,
3251
3891
  path,
@@ -3542,14 +4182,14 @@ var ConversationWatcher = class {
3542
4182
  };
3543
4183
 
3544
4184
  // src/services/conversations/pruneAgentConversations.ts
3545
- import { existsSync as existsSync5 } from "fs";
4185
+ import { existsSync as existsSync6 } from "fs";
3546
4186
  function pruneAgentConversations(cache) {
3547
4187
  const db = cache.getDatabase();
3548
4188
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
3549
4189
  let pruned = 0;
3550
4190
  let missing = 0;
3551
4191
  for (const row of rows) {
3552
- if (!existsSync5(row.file_path)) {
4192
+ if (!existsSync6(row.file_path)) {
3553
4193
  missing += 1;
3554
4194
  continue;
3555
4195
  }
@@ -3832,6 +4472,7 @@ function managedToResponse(s, ptyAttached) {
3832
4472
  return {
3833
4473
  id: s.id,
3834
4474
  conversationId: s.id,
4475
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
3835
4476
  status: s.status,
3836
4477
  projectPath: s.projectPath,
3837
4478
  projectName: s.projectName,
@@ -3857,13 +4498,15 @@ function managedToResponse(s, ptyAttached) {
3857
4498
  ...s.failureReason != null && { failureReason: s.failureReason },
3858
4499
  ...s.resumedFromConversationId != null && {
3859
4500
  resumedFromConversationId: s.resumedFromConversationId
3860
- }
4501
+ },
4502
+ ...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
3861
4503
  };
3862
4504
  }
3863
4505
  function discoveredToResponse(d, conversationId) {
3864
4506
  return {
3865
4507
  id: conversationId,
3866
4508
  conversationId,
4509
+ provider: CLAUDE_CODE_PROVIDER,
3867
4510
  status: "idle",
3868
4511
  projectPath: d.projectPath,
3869
4512
  projectName: d.projectName,
@@ -4305,7 +4948,7 @@ var StreamerServer = class {
4305
4948
  });
4306
4949
  }
4307
4950
  });
4308
- this.ptyManager = new PTYManager({
4951
+ this.ptyManager = new LiveSessionManager({
4309
4952
  logger: getLogger("pty"),
4310
4953
  onOutput: (sessionId, data) => {
4311
4954
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
@@ -4642,10 +5285,11 @@ var StreamerServer = class {
4642
5285
  }
4643
5286
  } catch (err) {
4644
5287
  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
- });
5288
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
5289
+ this.log.error(
5290
+ `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})`,
5291
+ { error: message, abiMismatch, event: "cache.open_failed" }
5292
+ );
4649
5293
  }
4650
5294
  const warmupScanner = new ConversationScanner();
4651
5295
  this.allScanners.add(warmupScanner);
@@ -5133,16 +5777,16 @@ var StreamerServer = class {
5133
5777
  }
5134
5778
  findJsonlPath(uuid) {
5135
5779
  const projectsDir = join12(homedir5(), ".claude", "projects");
5136
- if (!existsSync6(projectsDir)) return null;
5780
+ if (!existsSync7(projectsDir)) return null;
5137
5781
  const filename = `${uuid}.jsonl`;
5138
5782
  for (const dir of readdirSync4(projectsDir)) {
5139
5783
  const fp = join12(projectsDir, dir, filename);
5140
- if (existsSync6(fp)) return fp;
5784
+ if (existsSync7(fp)) return fp;
5141
5785
  const projectDir = join12(projectsDir, dir);
5142
5786
  try {
5143
5787
  for (const sub of readdirSync4(projectDir)) {
5144
5788
  const subagentPath = join12(projectDir, sub, "subagents", filename);
5145
- if (existsSync6(subagentPath)) return subagentPath;
5789
+ if (existsSync7(subagentPath)) return subagentPath;
5146
5790
  }
5147
5791
  } catch {
5148
5792
  }
@@ -5451,7 +6095,7 @@ var StreamerServer = class {
5451
6095
  handleGetSession(sessionId, res) {
5452
6096
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
5453
6097
  if (session) {
5454
- if (!existsSync6(session.projectPath)) {
6098
+ if (!existsSync7(session.projectPath)) {
5455
6099
  session.failureReason = `Project directory not found: ${session.projectPath}`;
5456
6100
  }
5457
6101
  json(res, 200, session);
@@ -5491,7 +6135,10 @@ var StreamerServer = class {
5491
6135
  json(res, 400, { error: "Could not determine project path" });
5492
6136
  return;
5493
6137
  }
6138
+ const cachedConvMeta = this.cache?.getMetaById(sessionId);
6139
+ const provider = conv?.provider ?? cachedConvMeta?.provider ?? CLAUDE_CODE_PROVIDER;
5494
6140
  const session = await this.ptyManager.start(sessionId, {
6141
+ provider,
5495
6142
  projectPath,
5496
6143
  projectName: body.projectName,
5497
6144
  branch: body.branch
@@ -5850,6 +6497,13 @@ var StreamerServer = class {
5850
6497
  }
5851
6498
  return;
5852
6499
  }
6500
+ const body = await readBody(req);
6501
+ const { path: relativePath, provider: requestedProvider, systemPrompt: clientPrompt } = body;
6502
+ if (requestedProvider !== void 0 && !isProviderName(requestedProvider)) {
6503
+ json(res, 400, { error: "Invalid provider" });
6504
+ return;
6505
+ }
6506
+ const provider = requestedProvider ?? CLAUDE_CODE_PROVIDER;
5853
6507
  if (!this.browseRoot) {
5854
6508
  json(res, 403, {
5855
6509
  error: "File browsing not configured. Set browseRoot on the server.",
@@ -5857,8 +6511,6 @@ var StreamerServer = class {
5857
6511
  });
5858
6512
  return;
5859
6513
  }
5860
- const body = await readBody(req);
5861
- const { path: relativePath, systemPrompt: clientPrompt } = body;
5862
6514
  if (typeof relativePath !== "string") {
5863
6515
  json(res, 400, { error: "Missing path field" });
5864
6516
  return;
@@ -5879,21 +6531,27 @@ var StreamerServer = class {
5879
6531
  ].filter(Boolean);
5880
6532
  try {
5881
6533
  const session = await this.ptyManager.startFresh({
6534
+ provider,
5882
6535
  projectPath: resolvedPath,
5883
6536
  projectName: body.projectName,
5884
6537
  systemPrompt: systemPromptParts.join("\n")
5885
6538
  });
5886
6539
  this.sessionStore.addManaged(session);
5887
6540
  json(res, 202, { id: session.id, status: "pending" });
5888
- this.watchForJsonl(session.id, resolvedPath);
6541
+ if (provider === CODEX_CLI_PROVIDER) {
6542
+ this.watchForCodexRollout(session.id, resolvedPath);
6543
+ } else {
6544
+ this.watchForJsonl(session.id, resolvedPath);
6545
+ }
5889
6546
  this.broadcastOrUnicastSessionList(req);
5890
6547
  } catch (err) {
5891
6548
  const message = err instanceof Error ? err.message : "Failed to start session";
6549
+ const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
5892
6550
  this.log.error(`[start] failed to start session: ${message}`, {
5893
6551
  event: "session.start_failed",
5894
6552
  error: message
5895
6553
  });
5896
- json(res, 500, { error: message });
6554
+ json(res, statusCode, { error: message });
5897
6555
  }
5898
6556
  }
5899
6557
  // ─── Project linking ─────────────────────────────────────────────
@@ -5966,8 +6624,8 @@ var StreamerServer = class {
5966
6624
  cleanup();
5967
6625
  return;
5968
6626
  }
5969
- let resolvedFilePath = existsSync6(filePath) ? filePath : null;
5970
- if (!resolvedFilePath && existsSync6(projectsDir)) {
6627
+ let resolvedFilePath = existsSync7(filePath) ? filePath : null;
6628
+ if (!resolvedFilePath && existsSync7(projectsDir)) {
5971
6629
  try {
5972
6630
  const now = Date.now();
5973
6631
  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 +6669,121 @@ var StreamerServer = class {
6011
6669
  } catch {
6012
6670
  }
6013
6671
  }
6672
+ // Codex-equivalent of watchForJsonl(). Differs because Codex has no
6673
+ // filename-encoded session id (it assigns its own persisted id) and its
6674
+ // rollout files live under a date-nested directory
6675
+ // (~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl) that Codex creates
6676
+ // itself — it may not exist yet when this function is first called, so we
6677
+ // poll rather than fs.watch a not-yet-existent directory. Per Phase 0
6678
+ // findings, the rollout file appears within ~1s of process spawn (after
6679
+ // any directory-trust gate is cleared), well before any user input.
6680
+ watchForCodexRollout(sessionId, projectPath) {
6681
+ const deadline = Date.now() + 12e4;
6682
+ const now = /* @__PURE__ */ new Date();
6683
+ const dateDir = join12(
6684
+ String(now.getFullYear()),
6685
+ String(now.getMonth() + 1).padStart(2, "0"),
6686
+ String(now.getDate()).padStart(2, "0")
6687
+ );
6688
+ const sessionStartedAtMs = (this.sessionStore.getManaged(sessionId)?.startedAt?.getTime() ?? Date.now()) - 5e3;
6689
+ let intervalHandle = null;
6690
+ const cleanup = () => {
6691
+ if (intervalHandle) clearInterval(intervalHandle);
6692
+ intervalHandle = null;
6693
+ };
6694
+ const matchesProjectPath = (candidatePath) => {
6695
+ try {
6696
+ const firstLine = readFileSync6(candidatePath, "utf8").split("\n", 1)[0];
6697
+ if (!firstLine) return null;
6698
+ const parsed = JSON.parse(firstLine);
6699
+ if (parsed?.type !== "session_meta") return null;
6700
+ const payload = parsed.payload ?? {};
6701
+ if (payload.cwd !== projectPath) return null;
6702
+ if (typeof payload.id !== "string") return null;
6703
+ const createdIso = payload.timestamp ?? parsed.timestamp;
6704
+ const createdAtMs = typeof createdIso === "string" ? Date.parse(createdIso) : Number.NaN;
6705
+ if (Number.isNaN(createdAtMs) || createdAtMs < sessionStartedAtMs) return null;
6706
+ return { id: payload.id, createdAtMs };
6707
+ } catch {
6708
+ return null;
6709
+ }
6710
+ };
6711
+ const tryWire = () => {
6712
+ if (!this.ptyManager.hasSession(sessionId)) {
6713
+ cleanup();
6714
+ return;
6715
+ }
6716
+ if (Date.now() > deadline) {
6717
+ cleanup();
6718
+ return;
6719
+ }
6720
+ const boundElsewhere = new Set(
6721
+ this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
6722
+ );
6723
+ for (const root of this.codexRoots) {
6724
+ const sessionsDir = join12(root, dateDir);
6725
+ if (!existsSync7(sessionsDir)) continue;
6726
+ let candidateFiles;
6727
+ try {
6728
+ candidateFiles = readdirSync4(sessionsDir).filter((f) => f.endsWith(".jsonl"));
6729
+ } catch {
6730
+ continue;
6731
+ }
6732
+ const nowMs = Date.now();
6733
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync5(join12(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
6734
+ for (const { f } of recentCandidates) {
6735
+ const candidatePath = join12(sessionsDir, f);
6736
+ const match = matchesProjectPath(candidatePath);
6737
+ if (!match) continue;
6738
+ if (boundElsewhere.has(match.id)) continue;
6739
+ const codexSessionId = match.id;
6740
+ cleanup();
6741
+ this.sessionStore.updateManaged(sessionId, { boundConversationId: codexSessionId });
6742
+ this.sessionFileMap.set(sessionId, candidatePath);
6743
+ this.fileWatcher.watch(candidatePath);
6744
+ try {
6745
+ const existing = readFileSync6(candidatePath, "utf8").split("\n").filter(Boolean);
6746
+ if (existing.length > 0) {
6747
+ this.wsHub.broadcast({ type: "conversation_events", sessionId, lines: existing });
6748
+ for (const line of existing) {
6749
+ this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
6750
+ }
6751
+ }
6752
+ } catch {
6753
+ }
6754
+ if (this.scannerReady) {
6755
+ this.scannerStale = true;
6756
+ } else {
6757
+ this.scanner = null;
6758
+ }
6759
+ this.linkSessionToProject(sessionId, projectPath, candidatePath);
6760
+ this.cache?.markAsStreamer(sessionId);
6761
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
6762
+ if (resp) {
6763
+ this.wsHub.broadcast({ type: "session_update", session: resp });
6764
+ }
6765
+ this.log.info(
6766
+ `[startFresh] bound Codex rollout for ${sessionId}`,
6767
+ {
6768
+ event: "session.codex_rollout_bound",
6769
+ sessionId,
6770
+ boundConversationId: codexSessionId,
6771
+ filePath: candidatePath
6772
+ },
6773
+ "pino"
6774
+ );
6775
+ return;
6776
+ }
6777
+ }
6778
+ };
6779
+ tryWire();
6780
+ if (!intervalHandle && Date.now() <= deadline) {
6781
+ const alreadyBound = this.sessionStore.getManaged(sessionId)?.boundConversationId != null;
6782
+ if (!alreadyBound) {
6783
+ intervalHandle = setInterval(tryWire, 250);
6784
+ }
6785
+ }
6786
+ }
6014
6787
  async handleBrowse(url, res) {
6015
6788
  if (!this.browseRoot) {
6016
6789
  json(res, 403, {
@@ -6094,7 +6867,7 @@ var StreamerServer = class {
6094
6867
  };
6095
6868
  function classifyResumability(cwd) {
6096
6869
  if (!cwd) return { resumable: true };
6097
- if (existsSync6(cwd)) return { resumable: true };
6870
+ if (existsSync7(cwd)) return { resumable: true };
6098
6871
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
6099
6872
  return {
6100
6873
  resumable: false,
@@ -6225,7 +6998,10 @@ function readBody(req) {
6225
6998
  });
6226
6999
  }
6227
7000
  export {
7001
+ CLAUDE_CODE_PROVIDER,
7002
+ CODEX_CLI_PROVIDER,
6228
7003
  ConversationWatcher,
7004
+ LiveSessionManager,
6229
7005
  PTYManager,
6230
7006
  SessionStore,
6231
7007
  StreamerServer,
@@ -6239,6 +7015,8 @@ export {
6239
7015
  generateApiKey,
6240
7016
  getDbConfig,
6241
7017
  isDbEnabled,
7018
+ isProviderName,
7019
+ isProviderResumable,
6242
7020
  loadOrCreateApiKey,
6243
7021
  maskConnectionString,
6244
7022
  readAgentConfig,