@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.cjs CHANGED
@@ -30,7 +30,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ CLAUDE_CODE_PROVIDER: () => CLAUDE_CODE_PROVIDER,
34
+ CODEX_CLI_PROVIDER: () => CODEX_CLI_PROVIDER,
33
35
  ConversationWatcher: () => ConversationWatcher,
36
+ LiveSessionManager: () => LiveSessionManager,
34
37
  PTYManager: () => PTYManager,
35
38
  SessionStore: () => SessionStore,
36
39
  StreamerServer: () => StreamerServer,
@@ -44,6 +47,8 @@ __export(index_exports, {
44
47
  generateApiKey: () => generateApiKey,
45
48
  getDbConfig: () => getDbConfig,
46
49
  isDbEnabled: () => isDbEnabled,
50
+ isProviderName: () => isProviderName,
51
+ isProviderResumable: () => isProviderResumable,
47
52
  loadOrCreateApiKey: () => loadOrCreateApiKey,
48
53
  maskConnectionString: () => maskConnectionString,
49
54
  readAgentConfig: () => readAgentConfig,
@@ -527,11 +532,51 @@ async function createPool(config) {
527
532
  return new Pool(poolConfig);
528
533
  }
529
534
 
530
- // src/process-discovery.ts
531
- var import_child_process2 = require("child_process");
532
- var import_os3 = require("os");
535
+ // src/live-session-manager.ts
536
+ var import_path6 = require("path");
537
+
538
+ // src/codex-pty-runner.ts
539
+ var import_headless = require("@xterm/headless");
540
+ var import_crypto2 = require("crypto");
541
+ var import_fs4 = require("fs");
533
542
  var import_path4 = require("path");
534
543
 
544
+ // src/logger.ts
545
+ var import_pino = __toESM(require("pino"), 1);
546
+ var baseLogger = (0, import_pino.default)({
547
+ level: process.env.LOG_LEVEL ?? "info",
548
+ base: { service: "tb-streamer" },
549
+ timestamp: import_pino.default.stdTimeFunctions.isoTime,
550
+ redact: {
551
+ paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
552
+ censor: "[redacted]"
553
+ }
554
+ });
555
+ function emit(pinoChild, level, msg, fields, dest) {
556
+ if (dest === "pino" || dest === "both") {
557
+ if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
558
+ else pinoChild[level](msg);
559
+ }
560
+ if (dest === "console" || dest === "both") {
561
+ const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
562
+ console[consoleMethod](msg);
563
+ }
564
+ }
565
+ function build(pinoChild) {
566
+ return {
567
+ debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
568
+ info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
569
+ warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
570
+ error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
571
+ log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
572
+ pino: pinoChild
573
+ };
574
+ }
575
+ function getLogger(component) {
576
+ return build(component ? baseLogger.child({ component }) : baseLogger);
577
+ }
578
+ var logger = build(baseLogger);
579
+
535
580
  // src/platform.ts
536
581
  var import_child_process = require("child_process");
537
582
  var import_fs3 = require("fs");
@@ -596,188 +641,534 @@ function resolveClaudeExe() {
596
641
  _claudeExe = "claude";
597
642
  return _claudeExe;
598
643
  }
599
-
600
- // src/process-discovery.ts
601
- async function discoverClaudeProcesses() {
602
- if ((0, import_os3.platform)() === "win32") return discoverWindows();
603
- return discoverUnix();
604
- }
605
- async function discoverUnix() {
606
- const pids = await getPidsUnix();
607
- const results = await Promise.all(
608
- pids.map(async (pid) => {
609
- try {
610
- const [cwd, args, startedAt] = await Promise.all([
611
- getProcessCwdUnix(pid),
612
- getProcessArgsUnix(pid),
613
- getProcessStartTimeUnix(pid)
614
- ]);
615
- const conversationId = extractResumeId(args);
616
- return {
617
- pid,
618
- projectPath: cwd,
619
- projectName: (0, import_path4.basename)(cwd),
620
- branch: await readGitBranch(cwd),
621
- conversationId,
622
- startedAt
623
- };
624
- } catch {
625
- return null;
644
+ var _codexExe;
645
+ function resolveCodexExe() {
646
+ if (_codexExe !== void 0) return _codexExe;
647
+ if (isWindows) {
648
+ try {
649
+ const found = (0, import_child_process.execFileSync)("where.exe", ["codex"], {
650
+ encoding: "utf-8",
651
+ windowsHide: true,
652
+ timeout: 3e3
653
+ }).trim().split("\n")[0].trim();
654
+ if (found) {
655
+ _codexExe = found;
656
+ return _codexExe;
626
657
  }
627
- })
628
- );
629
- return results.filter((r) => r !== null);
630
- }
631
- async function discoverWindows() {
632
- const pids = await getPidsWindows();
633
- const results = await Promise.all(
634
- pids.map(async (pid) => {
635
- try {
636
- const info = await getProcessInfoWindows(pid);
637
- if (!info) return null;
638
- return {
639
- pid,
640
- projectPath: info.cwd,
641
- projectName: (0, import_path4.basename)(info.cwd),
642
- branch: await readGitBranch(info.cwd),
643
- conversationId: extractResumeId(info.args),
644
- startedAt: info.startedAt
645
- };
646
- } catch {
647
- return null;
658
+ } catch {
659
+ }
660
+ const candidates = [
661
+ (0, import_path3.join)((0, import_os2.homedir)(), ".local", "bin", "codex.exe"),
662
+ (0, import_path3.join)(
663
+ process.env.LOCALAPPDATA ?? (0, import_path3.join)((0, import_os2.homedir)(), "AppData", "Local"),
664
+ "Microsoft",
665
+ "WindowsApps",
666
+ "codex.exe"
667
+ )
668
+ ];
669
+ for (const p of candidates) {
670
+ if ((0, import_fs3.existsSync)(p)) {
671
+ _codexExe = p;
672
+ return _codexExe;
648
673
  }
649
- })
650
- );
651
- return results.filter((r) => r !== null);
652
- }
653
- function run(cmd, args, opts = {}) {
654
- return new Promise((resolve2, reject) => {
655
- (0, import_child_process2.execFile)(
656
- cmd,
657
- args,
658
- { windowsHide: isWindows, encoding: "utf-8", timeout: opts.timeout ?? 5e3, cwd: opts.cwd },
659
- (err, stdout) => {
660
- if (err) reject(err);
661
- else resolve2(stdout);
674
+ }
675
+ } else {
676
+ try {
677
+ const found = (0, import_child_process.execFileSync)("/usr/bin/which", ["codex"], {
678
+ encoding: "utf-8",
679
+ timeout: 3e3
680
+ }).trim().split("\n")[0].trim();
681
+ if (found && (0, import_fs3.existsSync)(found)) {
682
+ _codexExe = found;
683
+ return _codexExe;
662
684
  }
663
- );
664
- });
665
- }
666
- async function getPidsUnix() {
667
- try {
668
- const output = await run("pgrep", ["-x", "claude"]);
669
- return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
670
- } catch {
671
- return [];
685
+ } catch {
686
+ }
687
+ const candidates = [
688
+ "/opt/homebrew/bin/codex",
689
+ "/usr/local/bin/codex",
690
+ (0, import_path3.join)((0, import_os2.homedir)(), ".local", "bin", "codex")
691
+ ];
692
+ for (const p of candidates) {
693
+ if ((0, import_fs3.existsSync)(p)) {
694
+ _codexExe = p;
695
+ return _codexExe;
696
+ }
697
+ }
672
698
  }
699
+ _codexExe = "codex";
700
+ return _codexExe;
673
701
  }
674
- async function getProcessCwdUnix(pid) {
675
- const output = await run("lsof", ["-p", String(pid), "-a", "-d", "cwd", "-Fn"]);
676
- const match = output.match(/n(.+)/);
677
- return match?.[1] ?? "";
678
- }
679
- async function getProcessArgsUnix(pid) {
680
- return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
702
+
703
+ // src/providers.ts
704
+ var CLAUDE_CODE_PROVIDER = "claude-code";
705
+ var CODEX_CLI_PROVIDER = "codex-cli";
706
+ function isProviderName(value) {
707
+ return value === CLAUDE_CODE_PROVIDER || value === CODEX_CLI_PROVIDER;
681
708
  }
682
- async function getProcessStartTimeUnix(pid) {
683
- const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
684
- const d = new Date(raw);
685
- return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
709
+ function isProviderResumable(_provider, availabilityResumable) {
710
+ return availabilityResumable;
686
711
  }
687
- async function getPidsWindows() {
688
- try {
689
- const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
690
- return output.trim().split("\n").filter(Boolean).map((line) => {
691
- const parts = line.split(",");
692
- return Number.parseInt(parts[1]?.replace(/"/g, "") ?? "0", 10);
693
- }).filter((pid) => pid > 0);
694
- } catch {
695
- return [];
696
- }
712
+
713
+ // src/codex-pty-runner.ts
714
+ var OUTPUT_BUFFER_MAX = 65536;
715
+ var PTY_COLS = 120;
716
+ var PTY_ROWS = 40;
717
+ var SCREEN_SCROLLBACK = 1e3;
718
+ var CODEX_PROMPT_READY_TEXT = "Ready";
719
+ var CODEX_TRUST_GATE_REGEX = /trust the contents/i;
720
+ var SUBMIT_BYTES = "\r";
721
+ var CODEX_SUBMIT_DELAY_MS = 16;
722
+ function digestBytes(s) {
723
+ const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
724
+ if (escaped.length <= 200) return escaped;
725
+ return `${escaped.slice(0, 100)}\u2026[${escaped.length - 200}B omitted]\u2026${escaped.slice(-100)}`;
697
726
  }
698
- async function getProcessInfoWindows(pid) {
727
+ var pty = null;
728
+ async function loadPty() {
729
+ if (pty) return pty;
699
730
  try {
700
- const output = await run("wmic", [
701
- "process",
702
- "where",
703
- `ProcessId=${pid}`,
704
- "get",
705
- "CommandLine,CreationDate,ExecutablePath",
706
- "/FORMAT:CSV"
707
- ]);
708
- const lines = output.trim().split(/\r?\n/).filter((l) => l.trim().length > 0);
709
- if (lines.length < 2) return null;
710
- const parts = lines[1].split(",");
711
- const args = parts[1] ?? "";
712
- const creationDate = parts[2] ?? "";
713
- const year = creationDate.slice(0, 4);
714
- const month = creationDate.slice(4, 6);
715
- const day = creationDate.slice(6, 8);
716
- const hour = creationDate.slice(8, 10);
717
- const min = creationDate.slice(10, 12);
718
- const sec = creationDate.slice(12, 14);
719
- const startedAt = /* @__PURE__ */ new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`);
720
- if (Number.isNaN(startedAt.getTime())) return null;
721
- const exePath = parts[3] ?? "";
722
- const cwd = exePath ? (0, import_path4.dirname)(exePath) : "";
723
- return { cwd, args, startedAt };
724
- } catch {
725
- return null;
731
+ pty = await import("node-pty");
732
+ return pty;
733
+ } catch (err) {
734
+ throw new Error(
735
+ `node-pty is required for PTY management but failed to load. Ensure it is installed: npm install node-pty
736
+ Original error: ${err}`
737
+ );
726
738
  }
727
739
  }
728
- function extractResumeId(args) {
729
- const match = args.match(/--resume\s+(\S+)/);
730
- return match?.[1] ?? null;
740
+ function createScreen() {
741
+ return new import_headless.Terminal({
742
+ cols: PTY_COLS,
743
+ rows: PTY_ROWS,
744
+ scrollback: SCREEN_SCROLLBACK,
745
+ allowProposedApi: true
746
+ });
731
747
  }
732
- async function readGitBranch(dir) {
733
- try {
734
- return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
735
- } catch {
736
- return "";
748
+ var CodexPtyRunner = class {
749
+ sessions = /* @__PURE__ */ new Map();
750
+ onOutput;
751
+ onStatusChange;
752
+ onReady;
753
+ // Accepted for shape-compatibility with PTYManagerOptions; Codex has no
754
+ // detected equivalent yet (Phase 0) — never invoked.
755
+ onPermissionChange;
756
+ onLiveQuestion;
757
+ onLiveQuestionGone;
758
+ log;
759
+ // Tracks sessions whose PTY has spawned but Codex hasn't yet reached its
760
+ // "Ready" status bar — i.e. onReady hasn't fired.
761
+ pendingReady = /* @__PURE__ */ new Set();
762
+ // Inputs received via sendInput() while the session was still pendingReady.
763
+ // Flushed in arrival order once Codex reaches Ready.
764
+ queuedInputs = /* @__PURE__ */ new Map();
765
+ // Per-session debounce so the directory-trust gate's \r is only written once.
766
+ trustGateAnswered = /* @__PURE__ */ new Set();
767
+ // In-flight start()/startFresh() calls keyed by sessionId. A second
768
+ // concurrent resume for the same session (double-tap, client retry) awaits
769
+ // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
770
+ startPromises = /* @__PURE__ */ new Map();
771
+ constructor(options = {}) {
772
+ this.onOutput = options.onOutput;
773
+ this.onStatusChange = options.onStatusChange;
774
+ this.onReady = options.onReady;
775
+ this.onPermissionChange = options.onPermissionChange;
776
+ this.onLiveQuestion = options.onLiveQuestion;
777
+ this.onLiveQuestionGone = options.onLiveQuestionGone;
778
+ this.log = options.logger ?? getLogger("codex-pty");
737
779
  }
738
- }
739
-
740
- // src/pty-manager.ts
741
- var import_headless = require("@xterm/headless");
742
- var import_crypto2 = require("crypto");
743
- var import_fs4 = require("fs");
744
- var import_path5 = require("path");
745
-
746
- // src/logger.ts
747
- var import_pino = __toESM(require("pino"), 1);
748
- var baseLogger = (0, import_pino.default)({
749
- level: process.env.LOG_LEVEL ?? "info",
750
- base: { service: "tb-streamer" },
751
- timestamp: import_pino.default.stdTimeFunctions.isoTime,
752
- redact: {
753
- paths: ["req.headers.authorization", "req.headers.cookie", 'req.headers["x-api-key"]'],
754
- censor: "[redacted]"
780
+ // Resume an existing Codex session. sessionId is the Codex-persisted
781
+ // session_meta.payload.id (Phase 0, Section 8) — Codex has no fresh-session
782
+ // equivalent of --session-id, so start() always means "resume".
783
+ async start(sessionId, options) {
784
+ const existing = this.sessions.get(sessionId);
785
+ if (existing) return toPublicSession(existing);
786
+ const inFlight = this.startPromises.get(sessionId);
787
+ if (inFlight) return inFlight;
788
+ const promise = this.doStart(sessionId, options).finally(() => {
789
+ this.startPromises.delete(sessionId);
790
+ });
791
+ this.startPromises.set(sessionId, promise);
792
+ return promise;
755
793
  }
756
- });
757
- function emit(pinoChild, level, msg, fields, dest) {
758
- if (dest === "pino" || dest === "both") {
759
- if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
760
- else pinoChild[level](msg);
794
+ async doStart(sessionId, options) {
795
+ const nodePty = await loadPty();
796
+ const projectName = options.projectName ?? (0, import_path4.basename)(options.projectPath);
797
+ const proc = nodePty.spawn(
798
+ resolveCodexExe(),
799
+ ["resume", sessionId, "--cd", options.projectPath, "--no-alt-screen"],
800
+ {
801
+ name: "xterm-256color",
802
+ cols: PTY_COLS,
803
+ rows: PTY_ROWS,
804
+ cwd: options.projectPath,
805
+ env: process.env
806
+ }
807
+ );
808
+ const session = {
809
+ id: sessionId,
810
+ provider: CODEX_CLI_PROVIDER,
811
+ projectPath: options.projectPath,
812
+ projectName,
813
+ branch: options.branch ?? "",
814
+ status: "running",
815
+ startedAt: /* @__PURE__ */ new Date(),
816
+ completedAt: null,
817
+ promptCount: 0,
818
+ lastOutput: "",
819
+ process: proc,
820
+ outputBuffer: Buffer.alloc(0),
821
+ screen: createScreen()
822
+ };
823
+ this.sessions.set(sessionId, session);
824
+ this.pendingReady.add(sessionId);
825
+ proc.onData((data) => {
826
+ this.handleOutput(sessionId, data);
827
+ });
828
+ proc.onExit(({ exitCode }) => {
829
+ this.pendingReady.delete(sessionId);
830
+ this.handleExit(sessionId, exitCode);
831
+ });
832
+ return toPublicSession(session);
833
+ }
834
+ // Start a brand-new Codex session. Codex has no --session-id equivalent for
835
+ // a fresh launch — it assigns its own id, discovered later (Task 3's
836
+ // binding logic). This runner generates a local placeholder id for the
837
+ // ManagedSession handle only.
838
+ async startFresh(options) {
839
+ const nodePty = await loadPty();
840
+ const sessionId = (0, import_crypto2.randomUUID)();
841
+ const projectName = options.projectName ?? (0, import_path4.basename)(options.projectPath);
842
+ const args = ["--cd", options.projectPath, "--no-alt-screen"];
843
+ if (options.systemPrompt) {
844
+ args.push(options.systemPrompt);
845
+ }
846
+ const proc = nodePty.spawn(resolveCodexExe(), args, {
847
+ name: "xterm-256color",
848
+ cols: PTY_COLS,
849
+ rows: PTY_ROWS,
850
+ cwd: options.projectPath,
851
+ env: process.env
852
+ });
853
+ const session = {
854
+ id: sessionId,
855
+ provider: CODEX_CLI_PROVIDER,
856
+ projectPath: options.projectPath,
857
+ projectName,
858
+ branch: "",
859
+ status: "running",
860
+ startedAt: /* @__PURE__ */ new Date(),
861
+ completedAt: null,
862
+ promptCount: 0,
863
+ lastOutput: "",
864
+ process: proc,
865
+ outputBuffer: Buffer.alloc(0),
866
+ screen: createScreen()
867
+ };
868
+ this.sessions.set(sessionId, session);
869
+ this.pendingReady.add(sessionId);
870
+ proc.onData((data) => {
871
+ this.handleOutput(sessionId, data);
872
+ });
873
+ proc.onExit(({ exitCode }) => {
874
+ this.pendingReady.delete(sessionId);
875
+ this.handleExit(sessionId, exitCode);
876
+ });
877
+ return toPublicSession(session);
878
+ }
879
+ // Write raw key bytes directly to the PTY, same as PTYManager.sendKeys.
880
+ sendKeys(sessionId, keys) {
881
+ const session = this.sessions.get(sessionId);
882
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
883
+ if (session.status === "idle") {
884
+ throw new Error(`Session is idle (no active PTY): ${sessionId}`);
885
+ }
886
+ if (session.status === "waiting_input") {
887
+ session.status = "running";
888
+ this.onStatusChange?.(toPublicSession(session));
889
+ }
890
+ this.log.info(
891
+ `[codex.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes(keys)}`,
892
+ { event: "codex.keys_write", sessionId, byteLen: keys.length }
893
+ );
894
+ session.process.write(keys);
895
+ session.lastActivityAt = /* @__PURE__ */ new Date();
896
+ }
897
+ sendInput(sessionId, input) {
898
+ const session = this.sessions.get(sessionId);
899
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
900
+ if (session.status === "idle") {
901
+ throw new Error(`Session is idle (no active PTY): ${sessionId}`);
902
+ }
903
+ if (this.pendingReady.has(sessionId)) {
904
+ const queue = this.queuedInputs.get(sessionId) ?? [];
905
+ queue.push(input);
906
+ this.queuedInputs.set(sessionId, queue);
907
+ session.lastActivityAt = /* @__PURE__ */ new Date();
908
+ session.promptCount++;
909
+ this.log.warn(
910
+ `[codex.input.queued] ${sessionId.slice(0, 8)} promptCount=${session.promptCount} queueLen=${queue.length}`,
911
+ {
912
+ event: "codex.input_queued",
913
+ sessionId,
914
+ promptCount: session.promptCount,
915
+ queueLen: queue.length,
916
+ inputLen: input.length
917
+ }
918
+ );
919
+ return session.promptCount;
920
+ }
921
+ if (session.status === "waiting_input") {
922
+ session.status = "running";
923
+ this.onStatusChange?.(toPublicSession(session));
924
+ }
925
+ this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
926
+ session.lastActivityAt = /* @__PURE__ */ new Date();
927
+ session.promptCount++;
928
+ return session.promptCount;
929
+ }
930
+ // Write the input as plain bytes (no bracketed-paste wrap — Phase 0
931
+ // confirmed Codex accepts plain keystrokes), then submit \r after a short
932
+ // delay so Codex's TUI gets an event-loop tick to process the input first.
933
+ writeSubmit(sessionId, session, input, path, promptCount) {
934
+ this.log.info(
935
+ `[codex.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${input.length} digest=${digestBytes(input)}`,
936
+ {
937
+ event: "codex.input_write",
938
+ sessionId,
939
+ promptCount,
940
+ byteLen: input.length,
941
+ digest: digestBytes(input),
942
+ path,
943
+ phase: "input"
944
+ }
945
+ );
946
+ session.process.write(input);
947
+ setTimeout(() => {
948
+ const current = this.sessions.get(sessionId);
949
+ if (!current || current !== session) return;
950
+ this.log.info(
951
+ `[codex.input.submit] ${sessionId.slice(0, 8)} promptCount=${promptCount} digest=\\r`,
952
+ {
953
+ event: "codex.input_write",
954
+ sessionId,
955
+ promptCount,
956
+ byteLen: SUBMIT_BYTES.length,
957
+ digest: "\\r",
958
+ path,
959
+ phase: "submit"
960
+ }
961
+ );
962
+ current.process.write(SUBMIT_BYTES);
963
+ }, CODEX_SUBMIT_DELAY_MS);
964
+ }
965
+ // Drain any inputs sent while the session was still pendingReady, writing
966
+ // them in arrival order now that Codex is Ready.
967
+ flushQueuedInputs(sessionId) {
968
+ const queue = this.queuedInputs.get(sessionId);
969
+ if (!queue || queue.length === 0) return;
970
+ this.queuedInputs.delete(sessionId);
971
+ const session = this.sessions.get(sessionId);
972
+ if (!session) return;
973
+ this.log.info(
974
+ `[codex.flush] ${sessionId.slice(0, 8)} flushing ${queue.length} queued input(s)`,
975
+ {
976
+ event: "codex.flush_queued",
977
+ sessionId,
978
+ queueLen: queue.length
979
+ }
980
+ );
981
+ queue.forEach((input, i) => {
982
+ const writeAt = i * CODEX_SUBMIT_DELAY_MS * 2;
983
+ if (writeAt === 0) {
984
+ this.writeSubmit(sessionId, session, input, "flush", session.promptCount);
985
+ } else {
986
+ setTimeout(() => {
987
+ const current = this.sessions.get(sessionId);
988
+ if (!current || current !== session) return;
989
+ this.writeSubmit(sessionId, session, input, "flush", session.promptCount);
990
+ }, writeAt);
991
+ }
992
+ });
993
+ }
994
+ // SIGINT produces a clean exitCode=0 exit (Phase 0 — confirmed).
995
+ cancel(sessionId) {
996
+ const session = this.sessions.get(sessionId);
997
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
998
+ session.process.kill("SIGINT");
999
+ }
1000
+ killPid(pid) {
1001
+ try {
1002
+ process.kill(pid, "SIGTERM");
1003
+ } catch {
1004
+ }
1005
+ }
1006
+ // Kill the PTY and mark the session idle. Mirrors PTYManager.putOnHold.
1007
+ putOnHold(sessionId) {
1008
+ const session = this.sessions.get(sessionId);
1009
+ if (!session) return;
1010
+ this.pendingReady.delete(sessionId);
1011
+ this.queuedInputs.delete(sessionId);
1012
+ this.trustGateAnswered.delete(sessionId);
1013
+ try {
1014
+ session.process.kill("SIGINT");
1015
+ } catch {
1016
+ }
1017
+ session.status = "idle";
1018
+ session.completedAt = /* @__PURE__ */ new Date();
1019
+ session.screen.dispose();
1020
+ this.sessions.delete(sessionId);
1021
+ this.onStatusChange?.(toPublicSession(session));
1022
+ }
1023
+ getOutput(sessionId) {
1024
+ const session = this.sessions.get(sessionId);
1025
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
1026
+ return session.outputBuffer.toString("utf-8");
1027
+ }
1028
+ // Render the last `maxLines` rows of the session's screen in true on-screen
1029
+ // order — same flush-then-read technique as PTYManager.getOutputLines.
1030
+ async getOutputLines(sessionId, maxLines) {
1031
+ const session = this.sessions.get(sessionId);
1032
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
1033
+ await new Promise((resolve2) => session.screen.write("", () => resolve2()));
1034
+ const buf = session.screen.buffer.active;
1035
+ const lines = [];
1036
+ for (let y = 0; y < buf.length; y++) {
1037
+ lines.push(buf.getLine(y)?.translateToString(true) ?? "");
1038
+ }
1039
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
1040
+ lines.pop();
1041
+ }
1042
+ return lines.slice(-maxLines);
1043
+ }
1044
+ getSession(sessionId) {
1045
+ const session = this.sessions.get(sessionId);
1046
+ return session ? toPublicSession(session) : null;
1047
+ }
1048
+ hasSession(sessionId) {
1049
+ return this.sessions.has(sessionId);
1050
+ }
1051
+ listSessions() {
1052
+ return Array.from(this.sessions.values()).map(toPublicSession);
1053
+ }
1054
+ dispose() {
1055
+ for (const session of this.sessions.values()) {
1056
+ try {
1057
+ session.process.kill();
1058
+ } catch {
1059
+ }
1060
+ session.screen.dispose();
1061
+ }
1062
+ this.sessions.clear();
1063
+ this.pendingReady.clear();
1064
+ this.queuedInputs.clear();
1065
+ this.trustGateAnswered.clear();
1066
+ }
1067
+ handleOutput(sessionId, data) {
1068
+ const session = this.sessions.get(sessionId);
1069
+ if (!session) return;
1070
+ const chunk = Buffer.from(data, "utf-8");
1071
+ session.outputBuffer = Buffer.concat([session.outputBuffer, chunk]);
1072
+ if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
1073
+ session.outputBuffer = session.outputBuffer.subarray(
1074
+ session.outputBuffer.length - OUTPUT_BUFFER_MAX
1075
+ );
1076
+ }
1077
+ session.screen.write(data);
1078
+ session.lastOutput = stripAnsi(data);
1079
+ this.onOutput?.(sessionId, data);
1080
+ this.detectReady(sessionId, session).catch((err) => {
1081
+ this.log.warn("[codex.ready_detect] failed", {
1082
+ event: "codex.ready_detect_failed",
1083
+ sessionId,
1084
+ err
1085
+ });
1086
+ });
1087
+ }
1088
+ // Renders the session's headless screen and checks for the directory-trust
1089
+ // gate (answered once, debounced) and the "Ready" status-bar text. Only
1090
+ // transitions to waiting_input / fires onReady when the rendered status
1091
+ // line literally contains "Ready" — `›` alone (visible during "Starting")
1092
+ // is NOT a valid readiness signal (Phase 0).
1093
+ async detectReady(sessionId, session) {
1094
+ if (session.status !== "running" || !this.pendingReady.has(sessionId)) return;
1095
+ const lines = await this.getOutputLines(sessionId, PTY_ROWS);
1096
+ const screenText = lines.join("\n");
1097
+ if (CODEX_TRUST_GATE_REGEX.test(screenText)) {
1098
+ if (!this.trustGateAnswered.has(sessionId)) {
1099
+ this.trustGateAnswered.add(sessionId);
1100
+ this.log.info(`[codex.trust_gate] ${sessionId.slice(0, 8)} auto-answering`, {
1101
+ event: "codex.trust_gate",
1102
+ sessionId
1103
+ });
1104
+ session.process.write("\r");
1105
+ }
1106
+ return;
1107
+ }
1108
+ const lastNonBlank2 = [...lines].reverse().find((l) => l.trim() !== "") ?? "";
1109
+ if (!lastNonBlank2.includes(CODEX_PROMPT_READY_TEXT)) return;
1110
+ this.markReady(sessionId, session);
1111
+ }
1112
+ markReady(sessionId, session) {
1113
+ session.lastActivityAt = /* @__PURE__ */ new Date();
1114
+ session.status = "waiting_input";
1115
+ this.log.info(`[codex.ready] ${sessionId.slice(0, 8)}`, {
1116
+ event: "codex.ready",
1117
+ sessionId
1118
+ });
1119
+ this.onStatusChange?.(toPublicSession(session));
1120
+ if (this.pendingReady.has(sessionId)) {
1121
+ this.pendingReady.delete(sessionId);
1122
+ this.flushQueuedInputs(sessionId);
1123
+ this.onReady?.(toPublicSession(session));
1124
+ }
761
1125
  }
762
- if (dest === "console" || dest === "both") {
763
- const consoleMethod = level === "error" ? "error" : level === "warn" ? "warn" : "log";
764
- console[consoleMethod](msg);
1126
+ handleExit(sessionId, exitCode) {
1127
+ const session = this.sessions.get(sessionId);
1128
+ if (!session) return;
1129
+ session.completedAt = /* @__PURE__ */ new Date();
1130
+ session.status = "idle";
1131
+ const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1132
+ if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1133
+ if (!(0, import_fs4.existsSync)(session.projectPath)) {
1134
+ session.failureReason = `Project directory not found: ${session.projectPath}`;
1135
+ } else {
1136
+ session.failureReason = `Codex process exited immediately (code ${exitCode}).`;
1137
+ }
1138
+ }
1139
+ this.onStatusChange?.(toPublicSession(session));
1140
+ session.screen.dispose();
1141
+ this.sessions.delete(sessionId);
1142
+ this.queuedInputs.delete(sessionId);
1143
+ this.trustGateAnswered.delete(sessionId);
765
1144
  }
766
- }
767
- function build(pinoChild) {
1145
+ };
1146
+ function toPublicSession(s) {
768
1147
  return {
769
- debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
770
- info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
771
- warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
772
- error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
773
- log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
774
- pino: pinoChild
1148
+ id: s.id,
1149
+ provider: s.provider ?? CODEX_CLI_PROVIDER,
1150
+ projectPath: s.projectPath,
1151
+ projectName: s.projectName,
1152
+ branch: s.branch,
1153
+ status: s.status,
1154
+ startedAt: s.startedAt,
1155
+ completedAt: s.completedAt,
1156
+ promptCount: s.promptCount,
1157
+ lastOutput: s.lastOutput,
1158
+ ...s.failureReason != null && { failureReason: s.failureReason },
1159
+ ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
1160
+ ...s.filePath != null && { filePath: s.filePath }
775
1161
  };
776
1162
  }
777
- function getLogger(component) {
778
- return build(component ? baseLogger.child({ component }) : baseLogger);
1163
+ function stripAnsi(str) {
1164
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
779
1165
  }
780
- var logger = build(baseLogger);
1166
+
1167
+ // src/pty-manager.ts
1168
+ var import_headless2 = require("@xterm/headless");
1169
+ var import_crypto3 = require("crypto");
1170
+ var import_fs5 = require("fs");
1171
+ var import_path5 = require("path");
781
1172
 
782
1173
  // src/services/questions/detectPermissionGate.ts
783
1174
  var OSC_777_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*/;
@@ -936,28 +1327,28 @@ function detectShellPrompt(lines) {
936
1327
  }
937
1328
 
938
1329
  // src/pty-manager.ts
939
- var OUTPUT_BUFFER_MAX = 65536;
940
- var PTY_COLS = 120;
941
- var PTY_ROWS = 40;
942
- var SCREEN_SCROLLBACK = 1e3;
1330
+ var OUTPUT_BUFFER_MAX2 = 65536;
1331
+ var PTY_COLS2 = 120;
1332
+ var PTY_ROWS2 = 40;
1333
+ var SCREEN_SCROLLBACK2 = 1e3;
943
1334
  var CLAUDE_PROMPT_MARKERS = ["\u256D", "\u276F"];
944
1335
  var PROMPT_MARKER_FALLBACK_MS = 1e4;
945
1336
  function buildPasteBytes(input) {
946
1337
  return `\x1B[200~${input}\x1B[201~`;
947
1338
  }
948
- var SUBMIT_BYTES = "\r";
1339
+ var SUBMIT_BYTES2 = "\r";
949
1340
  var SUBMIT_DELAY_MS = 16;
950
- function digestBytes(s) {
1341
+ function digestBytes2(s) {
951
1342
  const escaped = s.replace(new RegExp(String.fromCharCode(27), "g"), "\\x1b").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
952
1343
  if (escaped.length <= 200) return escaped;
953
1344
  return `${escaped.slice(0, 100)}\u2026[${escaped.length - 200}B omitted]\u2026${escaped.slice(-100)}`;
954
1345
  }
955
- var pty = null;
956
- async function loadPty() {
957
- if (pty) return pty;
1346
+ var pty2 = null;
1347
+ async function loadPty2() {
1348
+ if (pty2) return pty2;
958
1349
  try {
959
- pty = await import("node-pty");
960
- return pty;
1350
+ pty2 = await import("node-pty");
1351
+ return pty2;
961
1352
  } catch (err) {
962
1353
  throw new Error(
963
1354
  `node-pty is required for PTY management but failed to load. Ensure it is installed: npm install node-pty
@@ -965,11 +1356,11 @@ Original error: ${err}`
965
1356
  );
966
1357
  }
967
1358
  }
968
- function createScreen() {
969
- return new import_headless.Terminal({
970
- cols: PTY_COLS,
971
- rows: PTY_ROWS,
972
- scrollback: SCREEN_SCROLLBACK,
1359
+ function createScreen2() {
1360
+ return new import_headless2.Terminal({
1361
+ cols: PTY_COLS2,
1362
+ rows: PTY_ROWS2,
1363
+ scrollback: SCREEN_SCROLLBACK2,
973
1364
  allowProposedApi: true
974
1365
  });
975
1366
  }
@@ -978,6 +1369,11 @@ function buildSpawnEnv() {
978
1369
  if (env.CLAUDE_API_KEY) {
979
1370
  env.ANTHROPIC_API_KEY = env.CLAUDE_API_KEY;
980
1371
  }
1372
+ for (const key of Object.keys(env)) {
1373
+ if (key === "CLAUDECODE" || key.startsWith("CLAUDE_CODE_")) {
1374
+ delete env[key];
1375
+ }
1376
+ }
981
1377
  return env;
982
1378
  }
983
1379
  var PTYManager = class {
@@ -1018,6 +1414,10 @@ var PTYManager = class {
1018
1414
  // to a given input or fell silent. Reset on dispose().
1019
1415
  chunkIndex = /* @__PURE__ */ new Map();
1020
1416
  lastChunkAt = /* @__PURE__ */ new Map();
1417
+ // In-flight start()/startFresh() calls keyed by sessionId. A second
1418
+ // concurrent resume for the same session (double-tap, client retry) awaits
1419
+ // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
1420
+ startPromises = /* @__PURE__ */ new Map();
1021
1421
  constructor(options = {}) {
1022
1422
  this.onOutput = options.onOutput;
1023
1423
  this.onStatusChange = options.onStatusChange;
@@ -1040,7 +1440,18 @@ var PTYManager = class {
1040
1440
  // custom-API-key — are cleared by the seeded ~/.claude.json in
1041
1441
  // docker/entrypoint.sh.) startFresh() uses the same flag for the same reason.
1042
1442
  async start(sessionId, options) {
1043
- const nodePty = await loadPty();
1443
+ const existing = this.sessions.get(sessionId);
1444
+ if (existing) return toPublicSession2(existing);
1445
+ const inFlight = this.startPromises.get(sessionId);
1446
+ if (inFlight) return inFlight;
1447
+ const promise = this.doStart(sessionId, options).finally(() => {
1448
+ this.startPromises.delete(sessionId);
1449
+ });
1450
+ this.startPromises.set(sessionId, promise);
1451
+ return promise;
1452
+ }
1453
+ async doStart(sessionId, options) {
1454
+ const nodePty = await loadPty2();
1044
1455
  const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1045
1456
  const proc = nodePty.spawn(
1046
1457
  resolveClaudeExe(),
@@ -1062,6 +1473,7 @@ var PTYManager = class {
1062
1473
  );
1063
1474
  const session = {
1064
1475
  id: sessionId,
1476
+ provider: CLAUDE_CODE_PROVIDER,
1065
1477
  projectPath: options.projectPath,
1066
1478
  projectName,
1067
1479
  branch: options.branch ?? "",
@@ -1072,7 +1484,7 @@ var PTYManager = class {
1072
1484
  lastOutput: "",
1073
1485
  process: proc,
1074
1486
  outputBuffer: Buffer.alloc(0),
1075
- screen: createScreen()
1487
+ screen: createScreen2()
1076
1488
  };
1077
1489
  this.sessions.set(sessionId, session);
1078
1490
  this.pendingReady.add(sessionId);
@@ -1083,14 +1495,14 @@ var PTYManager = class {
1083
1495
  this.pendingReady.delete(sessionId);
1084
1496
  this.handleExit(sessionId, exitCode);
1085
1497
  });
1086
- return toPublicSession(session);
1498
+ return toPublicSession2(session);
1087
1499
  }
1088
1500
  // Start a brand-new Claude session. A stable UUID is generated here and passed
1089
1501
  // to Claude via --session-id so the JSONL filename matches from the start.
1090
1502
  // onReady fires once Claude reaches its first prompt (waiting_input).
1091
1503
  async startFresh(options) {
1092
- const nodePty = await loadPty();
1093
- const sessionId = (0, import_crypto2.randomUUID)();
1504
+ const nodePty = await loadPty2();
1505
+ const sessionId = (0, import_crypto3.randomUUID)();
1094
1506
  const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1095
1507
  const args = [
1096
1508
  "--permission-mode",
@@ -1112,6 +1524,7 @@ var PTYManager = class {
1112
1524
  });
1113
1525
  const session = {
1114
1526
  id: sessionId,
1527
+ provider: CLAUDE_CODE_PROVIDER,
1115
1528
  projectPath: options.projectPath,
1116
1529
  projectName,
1117
1530
  branch: "",
@@ -1122,7 +1535,7 @@ var PTYManager = class {
1122
1535
  lastOutput: "",
1123
1536
  process: proc,
1124
1537
  outputBuffer: Buffer.alloc(0),
1125
- screen: createScreen()
1538
+ screen: createScreen2()
1126
1539
  };
1127
1540
  this.sessions.set(sessionId, session);
1128
1541
  this.pendingReady.add(sessionId);
@@ -1133,7 +1546,7 @@ var PTYManager = class {
1133
1546
  this.pendingReady.delete(sessionId);
1134
1547
  this.handleExit(sessionId, exitCode);
1135
1548
  });
1136
- return toPublicSession(session);
1549
+ return toPublicSession2(session);
1137
1550
  }
1138
1551
  // Write raw key bytes directly to the PTY without bracketed-paste wrapping.
1139
1552
  // Use for control sequences (arrow keys, Enter) that must not be quoted.
@@ -1145,10 +1558,10 @@ var PTYManager = class {
1145
1558
  }
1146
1559
  if (session.status === "waiting_input") {
1147
1560
  session.status = "running";
1148
- this.onStatusChange?.(toPublicSession(session));
1561
+ this.onStatusChange?.(toPublicSession2(session));
1149
1562
  }
1150
1563
  this.log.info(
1151
- `[pty.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes(keys)}`,
1564
+ `[pty.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes2(keys)}`,
1152
1565
  { event: "pty.keys_write", sessionId, byteLen: keys.length }
1153
1566
  );
1154
1567
  session.process.write(keys);
@@ -1180,7 +1593,7 @@ var PTYManager = class {
1180
1593
  }
1181
1594
  if (session.status === "waiting_input") {
1182
1595
  session.status = "running";
1183
- this.onStatusChange?.(toPublicSession(session));
1596
+ this.onStatusChange?.(toPublicSession2(session));
1184
1597
  }
1185
1598
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
1186
1599
  session.lastActivityAt = /* @__PURE__ */ new Date();
@@ -1193,13 +1606,13 @@ var PTYManager = class {
1193
1606
  writeSubmit(sessionId, session, input, path, promptCount) {
1194
1607
  const pasteBytes = buildPasteBytes(input);
1195
1608
  this.log.info(
1196
- `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes(pasteBytes)}`,
1609
+ `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes2(pasteBytes)}`,
1197
1610
  {
1198
1611
  event: "pty.input_write",
1199
1612
  sessionId,
1200
1613
  promptCount,
1201
1614
  byteLen: pasteBytes.length,
1202
- digest: digestBytes(pasteBytes),
1615
+ digest: digestBytes2(pasteBytes),
1203
1616
  path,
1204
1617
  phase: "paste"
1205
1618
  }
@@ -1214,13 +1627,13 @@ var PTYManager = class {
1214
1627
  event: "pty.input_write",
1215
1628
  sessionId,
1216
1629
  promptCount,
1217
- byteLen: SUBMIT_BYTES.length,
1630
+ byteLen: SUBMIT_BYTES2.length,
1218
1631
  digest: "\\r",
1219
1632
  path,
1220
1633
  phase: "submit"
1221
1634
  }
1222
1635
  );
1223
- current.process.write(SUBMIT_BYTES);
1636
+ current.process.write(SUBMIT_BYTES2);
1224
1637
  }, SUBMIT_DELAY_MS);
1225
1638
  }
1226
1639
  // Drain any inputs that were sent while the session was still pendingReady,
@@ -1278,7 +1691,7 @@ var PTYManager = class {
1278
1691
  session.completedAt = /* @__PURE__ */ new Date();
1279
1692
  session.screen.dispose();
1280
1693
  this.sessions.delete(sessionId);
1281
- this.onStatusChange?.(toPublicSession(session));
1694
+ this.onStatusChange?.(toPublicSession2(session));
1282
1695
  }
1283
1696
  getOutput(sessionId) {
1284
1697
  const session = this.sessions.get(sessionId);
@@ -1309,13 +1722,13 @@ var PTYManager = class {
1309
1722
  }
1310
1723
  getSession(sessionId) {
1311
1724
  const session = this.sessions.get(sessionId);
1312
- return session ? toPublicSession(session) : null;
1725
+ return session ? toPublicSession2(session) : null;
1313
1726
  }
1314
1727
  hasSession(sessionId) {
1315
1728
  return this.sessions.has(sessionId);
1316
1729
  }
1317
1730
  listSessions() {
1318
- return Array.from(this.sessions.values()).map(toPublicSession);
1731
+ return Array.from(this.sessions.values()).map(toPublicSession2);
1319
1732
  }
1320
1733
  dispose() {
1321
1734
  for (const session of this.sessions.values()) {
@@ -1347,7 +1760,7 @@ var PTYManager = class {
1347
1760
  this.lastChunkAt.set(sessionId, now);
1348
1761
  const gapMs = last == null ? 0 : now - last;
1349
1762
  this.log.info(
1350
- `[pty.chunk] ${sessionId.slice(0, 8)} #${idx} +${chunk.length}B gap=${gapMs}ms status=${session.status} digest=${digestBytes(data)}`,
1763
+ `[pty.chunk] ${sessionId.slice(0, 8)} #${idx} +${chunk.length}B gap=${gapMs}ms status=${session.status} digest=${digestBytes2(data)}`,
1351
1764
  {
1352
1765
  event: "pty.chunk",
1353
1766
  sessionId,
@@ -1356,17 +1769,17 @@ var PTYManager = class {
1356
1769
  gapMs,
1357
1770
  status: session.status,
1358
1771
  pendingReady: this.pendingReady.has(sessionId),
1359
- digest: digestBytes(data)
1772
+ digest: digestBytes2(data)
1360
1773
  }
1361
1774
  );
1362
1775
  session.outputBuffer = Buffer.concat([session.outputBuffer, chunk]);
1363
- if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
1776
+ if (session.outputBuffer.length > OUTPUT_BUFFER_MAX2) {
1364
1777
  session.outputBuffer = session.outputBuffer.subarray(
1365
- session.outputBuffer.length - OUTPUT_BUFFER_MAX
1778
+ session.outputBuffer.length - OUTPUT_BUFFER_MAX2
1366
1779
  );
1367
1780
  }
1368
1781
  session.screen.write(data);
1369
- const stripped = stripAnsi(data);
1782
+ const stripped = stripAnsi2(data);
1370
1783
  session.lastOutput = stripped;
1371
1784
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
1372
1785
  if (session.status === "running" && matchedMarker) {
@@ -1436,87 +1849,327 @@ var PTYManager = class {
1436
1849
  this.onLiveQuestion?.(sessionId, detected.questions);
1437
1850
  }
1438
1851
  }
1439
- } else if (this.lastScreenQuestionKey.has(sessionId) && hasPromptMarker) {
1440
- this.lastScreenQuestionKey.delete(sessionId);
1441
- this.onLiveQuestionGone?.(sessionId);
1442
- }
1443
- if (!oscPermission && !askFooterOnScreen && !this.permissionOpen.has(sessionId)) {
1444
- const shell = detectShellPrompt(lines);
1445
- if (shell) {
1446
- const key = `${shell.prompt}\0${shell.options.map((o) => o.label).join("\0")}`;
1447
- if (this.shellPromptOpen.get(sessionId) !== key) {
1448
- this.shellPromptOpen.set(sessionId, key);
1449
- this.onPermissionChange?.(sessionId, {
1450
- prompt: shell.prompt,
1451
- options: shell.options
1452
- });
1453
- }
1454
- } else if (this.shellPromptOpen.has(sessionId) && hasPromptMarker) {
1455
- this.shellPromptOpen.delete(sessionId);
1456
- this.onPermissionChange?.(sessionId, null);
1852
+ } else if (this.lastScreenQuestionKey.has(sessionId) && hasPromptMarker) {
1853
+ this.lastScreenQuestionKey.delete(sessionId);
1854
+ this.onLiveQuestionGone?.(sessionId);
1855
+ }
1856
+ if (!oscPermission && !askFooterOnScreen && !this.permissionOpen.has(sessionId)) {
1857
+ const shell = detectShellPrompt(lines);
1858
+ if (shell) {
1859
+ const key = `${shell.prompt}\0${shell.options.map((o) => o.label).join("\0")}`;
1860
+ if (this.shellPromptOpen.get(sessionId) !== key) {
1861
+ this.shellPromptOpen.set(sessionId, key);
1862
+ this.onPermissionChange?.(sessionId, {
1863
+ prompt: shell.prompt,
1864
+ options: shell.options
1865
+ });
1866
+ }
1867
+ } else if (this.shellPromptOpen.has(sessionId) && hasPromptMarker) {
1868
+ this.shellPromptOpen.delete(sessionId);
1869
+ this.onPermissionChange?.(sessionId, null);
1870
+ }
1871
+ }
1872
+ }
1873
+ // Transition a session from "running" to "waiting_input", clear pendingReady,
1874
+ // and flush any queued input. Idempotent: callers can invoke at any chunk.
1875
+ markReady(sessionId, session, reason) {
1876
+ session.lastActivityAt = /* @__PURE__ */ new Date();
1877
+ session.status = "waiting_input";
1878
+ const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
1879
+ this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
1880
+ event: "pty.ready",
1881
+ sessionId,
1882
+ reason,
1883
+ elapsedMs
1884
+ });
1885
+ this.onStatusChange?.(toPublicSession2(session));
1886
+ if (this.pendingReady.has(sessionId)) {
1887
+ this.pendingReady.delete(sessionId);
1888
+ this.flushQueuedInputs(sessionId);
1889
+ this.onReady?.(toPublicSession2(session));
1890
+ }
1891
+ }
1892
+ handleExit(sessionId, exitCode) {
1893
+ const session = this.sessions.get(sessionId);
1894
+ if (!session) return;
1895
+ session.completedAt = /* @__PURE__ */ new Date();
1896
+ session.status = "idle";
1897
+ const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1898
+ if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1899
+ if (!(0, import_fs5.existsSync)(session.projectPath)) {
1900
+ session.failureReason = `Project directory not found: ${session.projectPath}`;
1901
+ } else {
1902
+ session.failureReason = `Process exited immediately (code ${exitCode}). Check that the Claude binary is installed and accessible.`;
1903
+ }
1904
+ }
1905
+ this.onStatusChange?.(toPublicSession2(session));
1906
+ session.screen.dispose();
1907
+ this.sessions.delete(sessionId);
1908
+ this.queuedInputs.delete(sessionId);
1909
+ this.firstChunkAt.delete(sessionId);
1910
+ this.permissionOpen.delete(sessionId);
1911
+ this.lastScreenQuestionKey.delete(sessionId);
1912
+ this.shellPromptOpen.delete(sessionId);
1913
+ }
1914
+ };
1915
+ function toPublicSession2(s) {
1916
+ return {
1917
+ id: s.id,
1918
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
1919
+ projectPath: s.projectPath,
1920
+ projectName: s.projectName,
1921
+ branch: s.branch,
1922
+ status: s.status,
1923
+ startedAt: s.startedAt,
1924
+ completedAt: s.completedAt,
1925
+ promptCount: s.promptCount,
1926
+ lastOutput: s.lastOutput,
1927
+ ...s.failureReason != null && { failureReason: s.failureReason },
1928
+ ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
1929
+ ...s.filePath != null && { filePath: s.filePath }
1930
+ };
1931
+ }
1932
+ function stripAnsi2(str) {
1933
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
1934
+ }
1935
+
1936
+ // src/live-session-manager.ts
1937
+ var LiveSessionManager = class {
1938
+ runners;
1939
+ constructor(options = {}) {
1940
+ this.runners = /* @__PURE__ */ new Map([
1941
+ [CLAUDE_CODE_PROVIDER, new PTYManager(options)],
1942
+ [CODEX_CLI_PROVIDER, new CodexPtyRunner(options)]
1943
+ ]);
1944
+ }
1945
+ async start(sessionId, options) {
1946
+ const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
1947
+ const runner = this.assertSupportedProvider(provider, options.projectPath);
1948
+ return runner.start(sessionId, options);
1949
+ }
1950
+ async startFresh(options) {
1951
+ const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
1952
+ const runner = this.assertSupportedProvider(provider, options.projectPath);
1953
+ return runner.startFresh(options);
1954
+ }
1955
+ sendInput(sessionId, input) {
1956
+ return this.runnerFor(sessionId).sendInput(sessionId, input);
1957
+ }
1958
+ sendKeys(sessionId, keys) {
1959
+ this.runnerFor(sessionId).sendKeys(sessionId, keys);
1960
+ }
1961
+ cancel(sessionId) {
1962
+ this.runnerFor(sessionId).cancel(sessionId);
1963
+ }
1964
+ killPid(pid) {
1965
+ for (const runner of this.runners.values()) {
1966
+ runner.killPid(pid);
1967
+ }
1968
+ }
1969
+ // putOnHold tolerates an unknown sessionId (PTYManager.putOnHold is a no-op
1970
+ // when the session isn't in its map), so — unlike the other session-keyed
1971
+ // methods — route to the owning runner when found, otherwise broadcast to
1972
+ // every runner rather than throwing; this matches the pre-extraction
1973
+ // behavior of delegating straight through with no existence check.
1974
+ putOnHold(sessionId) {
1975
+ for (const runner of this.runners.values()) {
1976
+ if (runner.hasSession(sessionId) || runner.getSession(sessionId)) {
1977
+ runner.putOnHold(sessionId);
1978
+ return;
1979
+ }
1980
+ }
1981
+ for (const runner of this.runners.values()) {
1982
+ runner.putOnHold(sessionId);
1983
+ }
1984
+ }
1985
+ getOutput(sessionId) {
1986
+ return this.runnerFor(sessionId).getOutput(sessionId);
1987
+ }
1988
+ getOutputLines(sessionId, maxLines) {
1989
+ return this.runnerFor(sessionId).getOutputLines(sessionId, maxLines);
1990
+ }
1991
+ getSession(sessionId) {
1992
+ for (const runner of this.runners.values()) {
1993
+ const session = runner.getSession(sessionId);
1994
+ if (session) return session;
1995
+ }
1996
+ return null;
1997
+ }
1998
+ hasSession(sessionId) {
1999
+ for (const runner of this.runners.values()) {
2000
+ if (runner.hasSession(sessionId)) return true;
2001
+ }
2002
+ return false;
2003
+ }
2004
+ listSessions() {
2005
+ return Array.from(this.runners.values()).flatMap((runner) => runner.listSessions());
2006
+ }
2007
+ dispose() {
2008
+ for (const runner of this.runners.values()) {
2009
+ runner.dispose();
2010
+ }
2011
+ }
2012
+ // Look up which runner owns a session. Only one runner exists today, so
2013
+ // this is a linear scan across hasSession()/getSession() rather than a
2014
+ // separate session→provider index — see task-1-brief.md.
2015
+ runnerFor(sessionId) {
2016
+ for (const runner of this.runners.values()) {
2017
+ if (runner.hasSession(sessionId) || runner.getSession(sessionId)) return runner;
2018
+ }
2019
+ throw new Error(`Session not found: ${sessionId}`);
2020
+ }
2021
+ assertSupportedProvider(provider, projectPath) {
2022
+ const runner = this.runners.get(provider);
2023
+ if (runner) return runner;
2024
+ const err = new Error(
2025
+ `Live ${provider} sessions are not implemented yet for ${(0, import_path6.basename)(projectPath)}`
2026
+ );
2027
+ err.statusCode = 501;
2028
+ throw err;
2029
+ }
2030
+ };
2031
+
2032
+ // src/process-discovery.ts
2033
+ var import_child_process2 = require("child_process");
2034
+ var import_os3 = require("os");
2035
+ var import_path7 = require("path");
2036
+ async function discoverClaudeProcesses() {
2037
+ if ((0, import_os3.platform)() === "win32") return discoverWindows();
2038
+ return discoverUnix();
2039
+ }
2040
+ async function discoverUnix() {
2041
+ const pids = await getPidsUnix();
2042
+ const results = await Promise.all(
2043
+ pids.map(async (pid) => {
2044
+ try {
2045
+ const [cwd, args, startedAt] = await Promise.all([
2046
+ getProcessCwdUnix(pid),
2047
+ getProcessArgsUnix(pid),
2048
+ getProcessStartTimeUnix(pid)
2049
+ ]);
2050
+ const conversationId = extractResumeId(args);
2051
+ return {
2052
+ pid,
2053
+ projectPath: cwd,
2054
+ projectName: (0, import_path7.basename)(cwd),
2055
+ branch: await readGitBranch(cwd),
2056
+ conversationId,
2057
+ startedAt
2058
+ };
2059
+ } catch {
2060
+ return null;
2061
+ }
2062
+ })
2063
+ );
2064
+ return results.filter((r) => r !== null);
2065
+ }
2066
+ async function discoverWindows() {
2067
+ const pids = await getPidsWindows();
2068
+ const results = await Promise.all(
2069
+ pids.map(async (pid) => {
2070
+ try {
2071
+ const info = await getProcessInfoWindows(pid);
2072
+ if (!info) return null;
2073
+ return {
2074
+ pid,
2075
+ projectPath: info.cwd,
2076
+ projectName: (0, import_path7.basename)(info.cwd),
2077
+ branch: await readGitBranch(info.cwd),
2078
+ conversationId: extractResumeId(info.args),
2079
+ startedAt: info.startedAt
2080
+ };
2081
+ } catch {
2082
+ return null;
2083
+ }
2084
+ })
2085
+ );
2086
+ return results.filter((r) => r !== null);
2087
+ }
2088
+ function run(cmd, args, opts = {}) {
2089
+ return new Promise((resolve2, reject) => {
2090
+ (0, import_child_process2.execFile)(
2091
+ cmd,
2092
+ args,
2093
+ { windowsHide: isWindows, encoding: "utf-8", timeout: opts.timeout ?? 5e3, cwd: opts.cwd },
2094
+ (err, stdout) => {
2095
+ if (err) reject(err);
2096
+ else resolve2(stdout);
1457
2097
  }
1458
- }
2098
+ );
2099
+ });
2100
+ }
2101
+ async function getPidsUnix() {
2102
+ try {
2103
+ const output = await run("pgrep", ["-x", "claude"]);
2104
+ return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
2105
+ } catch {
2106
+ return [];
1459
2107
  }
1460
- // Transition a session from "running" to "waiting_input", clear pendingReady,
1461
- // and flush any queued input. Idempotent: callers can invoke at any chunk.
1462
- markReady(sessionId, session, reason) {
1463
- session.lastActivityAt = /* @__PURE__ */ new Date();
1464
- session.status = "waiting_input";
1465
- const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
1466
- this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
1467
- event: "pty.ready",
1468
- sessionId,
1469
- reason,
1470
- elapsedMs
1471
- });
1472
- this.onStatusChange?.(toPublicSession(session));
1473
- if (this.pendingReady.has(sessionId)) {
1474
- this.pendingReady.delete(sessionId);
1475
- this.flushQueuedInputs(sessionId);
1476
- this.onReady?.(toPublicSession(session));
1477
- }
2108
+ }
2109
+ async function getProcessCwdUnix(pid) {
2110
+ const output = await run("lsof", ["-p", String(pid), "-a", "-d", "cwd", "-Fn"]);
2111
+ const match = output.match(/n(.+)/);
2112
+ return match?.[1] ?? "";
2113
+ }
2114
+ async function getProcessArgsUnix(pid) {
2115
+ return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
2116
+ }
2117
+ async function getProcessStartTimeUnix(pid) {
2118
+ const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
2119
+ const d = new Date(raw);
2120
+ return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
2121
+ }
2122
+ async function getPidsWindows() {
2123
+ try {
2124
+ const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
2125
+ return output.trim().split("\n").filter(Boolean).map((line) => {
2126
+ const parts = line.split(",");
2127
+ return Number.parseInt(parts[1]?.replace(/"/g, "") ?? "0", 10);
2128
+ }).filter((pid) => pid > 0);
2129
+ } catch {
2130
+ return [];
1478
2131
  }
1479
- handleExit(sessionId, exitCode) {
1480
- const session = this.sessions.get(sessionId);
1481
- if (!session) return;
1482
- session.completedAt = /* @__PURE__ */ new Date();
1483
- session.status = "idle";
1484
- const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1485
- if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1486
- if (!(0, import_fs4.existsSync)(session.projectPath)) {
1487
- session.failureReason = `Project directory not found: ${session.projectPath}`;
1488
- } else {
1489
- session.failureReason = `Process exited immediately (code ${exitCode}). Check that the Claude binary is installed and accessible.`;
1490
- }
1491
- }
1492
- this.onStatusChange?.(toPublicSession(session));
1493
- session.screen.dispose();
1494
- this.sessions.delete(sessionId);
1495
- this.queuedInputs.delete(sessionId);
1496
- this.firstChunkAt.delete(sessionId);
1497
- this.permissionOpen.delete(sessionId);
1498
- this.lastScreenQuestionKey.delete(sessionId);
1499
- this.shellPromptOpen.delete(sessionId);
2132
+ }
2133
+ async function getProcessInfoWindows(pid) {
2134
+ try {
2135
+ const output = await run("wmic", [
2136
+ "process",
2137
+ "where",
2138
+ `ProcessId=${pid}`,
2139
+ "get",
2140
+ "CommandLine,CreationDate,ExecutablePath",
2141
+ "/FORMAT:CSV"
2142
+ ]);
2143
+ const lines = output.trim().split(/\r?\n/).filter((l) => l.trim().length > 0);
2144
+ if (lines.length < 2) return null;
2145
+ const parts = lines[1].split(",");
2146
+ const args = parts[1] ?? "";
2147
+ const creationDate = parts[2] ?? "";
2148
+ const year = creationDate.slice(0, 4);
2149
+ const month = creationDate.slice(4, 6);
2150
+ const day = creationDate.slice(6, 8);
2151
+ const hour = creationDate.slice(8, 10);
2152
+ const min = creationDate.slice(10, 12);
2153
+ const sec = creationDate.slice(12, 14);
2154
+ const startedAt = /* @__PURE__ */ new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`);
2155
+ if (Number.isNaN(startedAt.getTime())) return null;
2156
+ const exePath = parts[3] ?? "";
2157
+ const cwd = exePath ? (0, import_path7.dirname)(exePath) : "";
2158
+ return { cwd, args, startedAt };
2159
+ } catch {
2160
+ return null;
1500
2161
  }
1501
- };
1502
- function toPublicSession(s) {
1503
- return {
1504
- id: s.id,
1505
- projectPath: s.projectPath,
1506
- projectName: s.projectName,
1507
- branch: s.branch,
1508
- status: s.status,
1509
- startedAt: s.startedAt,
1510
- completedAt: s.completedAt,
1511
- promptCount: s.promptCount,
1512
- lastOutput: s.lastOutput,
1513
- ...s.failureReason != null && { failureReason: s.failureReason },
1514
- ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
1515
- ...s.filePath != null && { filePath: s.filePath }
1516
- };
1517
2162
  }
1518
- function stripAnsi(str) {
1519
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
2163
+ function extractResumeId(args) {
2164
+ const match = args.match(/--resume\s+(\S+)/);
2165
+ return match?.[1] ?? null;
2166
+ }
2167
+ async function readGitBranch(dir) {
2168
+ try {
2169
+ return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
2170
+ } catch {
2171
+ return "";
2172
+ }
1520
2173
  }
1521
2174
 
1522
2175
  // src/server.ts
@@ -1524,15 +2177,15 @@ var import_node_ws = require("@hono/node-ws");
1524
2177
  var import_client = require("@temporalio/client");
1525
2178
  var import_scanner2 = require("@threadbase-sh/scanner");
1526
2179
  var import_events = require("events");
1527
- var import_fs11 = require("fs");
2180
+ var import_fs12 = require("fs");
1528
2181
  var import_promises5 = require("fs/promises");
1529
2182
  var import_http = require("http");
1530
2183
  var import_os6 = require("os");
1531
- var import_path11 = require("path");
2184
+ var import_path13 = require("path");
1532
2185
  var import_readline = require("readline");
1533
2186
 
1534
2187
  // node_modules/nanoid/index.js
1535
- var import_crypto3 = __toESM(require("crypto"), 1);
2188
+ var import_crypto4 = __toESM(require("crypto"), 1);
1536
2189
 
1537
2190
  // node_modules/nanoid/url-alphabet/index.js
1538
2191
  var urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
@@ -1546,10 +2199,10 @@ var fillPool = (bytes) => {
1546
2199
  try {
1547
2200
  if (!pool || pool.length < bytes) {
1548
2201
  pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER);
1549
- import_crypto3.default.randomFillSync(pool);
2202
+ import_crypto4.default.randomFillSync(pool);
1550
2203
  poolOffset = 0;
1551
2204
  } else if (poolOffset + bytes > pool.length) {
1552
- import_crypto3.default.randomFillSync(pool);
2205
+ import_crypto4.default.randomFillSync(pool);
1553
2206
  poolOffset = 0;
1554
2207
  }
1555
2208
  } catch (e) {
@@ -2282,7 +2935,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
2282
2935
 
2283
2936
  // src/browse.ts
2284
2937
  var import_promises2 = require("fs/promises");
2285
- var import_path6 = require("path");
2938
+ var import_path8 = require("path");
2286
2939
  var BrowsePathNotFoundError = class extends Error {
2287
2940
  constructor(message) {
2288
2941
  super(message);
@@ -2290,15 +2943,15 @@ var BrowsePathNotFoundError = class extends Error {
2290
2943
  }
2291
2944
  };
2292
2945
  async function resolveBrowsePath(browseRoot, relativePath) {
2293
- const normalizedRoot = (0, import_path6.resolve)(browseRoot);
2946
+ const normalizedRoot = (0, import_path8.resolve)(browseRoot);
2294
2947
  let sanitized;
2295
2948
  if (process.platform !== "win32" && relativePath.startsWith("/") && relativePath.length > 1 && relativePath.includes("/", 1)) {
2296
2949
  sanitized = relativePath;
2297
2950
  } else {
2298
2951
  sanitized = relativePath.replace(/^[/\\]+/, "");
2299
2952
  }
2300
- const target = sanitized ? (0, import_path6.resolve)(normalizedRoot, sanitized) : normalizedRoot;
2301
- const rootPrefix = normalizedRoot.endsWith(import_path6.sep) ? normalizedRoot : `${normalizedRoot}${import_path6.sep}`;
2953
+ const target = sanitized ? (0, import_path8.resolve)(normalizedRoot, sanitized) : normalizedRoot;
2954
+ const rootPrefix = normalizedRoot.endsWith(import_path8.sep) ? normalizedRoot : `${normalizedRoot}${import_path8.sep}`;
2302
2955
  if (!target.startsWith(rootPrefix) && target !== normalizedRoot) {
2303
2956
  throw new Error("Path outside browse root");
2304
2957
  }
@@ -2320,7 +2973,7 @@ async function createDirectory(parentAbsolutePath, name) {
2320
2973
  if (name.includes("/") || name.includes("\\") || name === ".." || name === ".") {
2321
2974
  throw new Error("Invalid directory name");
2322
2975
  }
2323
- const target = (0, import_path6.join)(parentAbsolutePath, name);
2976
+ const target = (0, import_path8.join)(parentAbsolutePath, name);
2324
2977
  try {
2325
2978
  const s = await (0, import_promises2.stat)(target);
2326
2979
  if (s.isDirectory()) throw new Error("Directory already exists");
@@ -2333,17 +2986,17 @@ async function createDirectory(parentAbsolutePath, name) {
2333
2986
 
2334
2987
  // src/conversation-cache.ts
2335
2988
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
2336
- var import_fs7 = require("fs");
2337
- var import_path8 = require("path");
2989
+ var import_fs8 = require("fs");
2990
+ var import_path10 = require("path");
2338
2991
 
2339
2992
  // src/db/sqlite-migrate.ts
2340
- var import_fs5 = require("fs");
2341
- var import_path7 = require("path");
2993
+ var import_fs6 = require("fs");
2994
+ var import_path9 = require("path");
2342
2995
  var import_url2 = require("url");
2343
2996
  var import_meta2 = {};
2344
2997
  function getMigrationsDir2() {
2345
2998
  if (typeof import_meta2 !== "undefined" && import_meta2.url) {
2346
- return (0, import_path7.dirname)((0, import_url2.fileURLToPath)(import_meta2.url));
2999
+ return (0, import_path9.dirname)((0, import_url2.fileURLToPath)(import_meta2.url));
2347
3000
  }
2348
3001
  return __dirname;
2349
3002
  }
@@ -2355,8 +3008,8 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
2355
3008
  `;
2356
3009
  function runSqliteMigrations(db, migrationsDir) {
2357
3010
  db.exec(SCHEMA_MIGRATIONS_SQL);
2358
- const dir = migrationsDir ?? (0, import_path7.join)(getMigrationsDir2(), "migrations");
2359
- const files = (0, import_fs5.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
3011
+ const dir = migrationsDir ?? (0, import_path9.join)(getMigrationsDir2(), "migrations");
3012
+ const files = (0, import_fs6.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
2360
3013
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
2361
3014
  const appliedSet = new Set(appliedRows.map((r) => r.id));
2362
3015
  const recordApplied = db.prepare("INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)");
@@ -2367,7 +3020,7 @@ function runSqliteMigrations(db, migrationsDir) {
2367
3020
  skipped.push(file);
2368
3021
  continue;
2369
3022
  }
2370
- const sql = (0, import_fs5.readFileSync)((0, import_path7.join)(dir, file), "utf-8");
3023
+ const sql = (0, import_fs6.readFileSync)((0, import_path9.join)(dir, file), "utf-8");
2371
3024
  const tx = db.transaction(() => {
2372
3025
  db.exec(sql);
2373
3026
  recordApplied.run(file, (/* @__PURE__ */ new Date()).toISOString());
@@ -2378,16 +3031,8 @@ function runSqliteMigrations(db, migrationsDir) {
2378
3031
  return { applied, skipped };
2379
3032
  }
2380
3033
 
2381
- // src/providers.ts
2382
- var CLAUDE_CODE_PROVIDER = "claude-code";
2383
- var CODEX_CLI_PROVIDER = "codex-cli";
2384
- function isProviderResumable(provider, availabilityResumable) {
2385
- if (provider === CODEX_CLI_PROVIDER) return false;
2386
- return availabilityResumable;
2387
- }
2388
-
2389
3034
  // src/services/conversations/isAgentConversation.ts
2390
- var import_fs6 = require("fs");
3035
+ var import_fs7 = require("fs");
2391
3036
  var DEFAULT_AGENT_ENTRYPOINTS = /* @__PURE__ */ new Set(["sdk-cli", "claude-vscode"]);
2392
3037
  var CHUNK_BYTES = 64 * 1024;
2393
3038
  var ENTRYPOINT_PROBE = `"entrypoint":`;
@@ -2409,12 +3054,12 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
2409
3054
  if (cached2 !== void 0) return cached2;
2410
3055
  let fd;
2411
3056
  try {
2412
- fd = (0, import_fs6.openSync)(filePath, "r");
3057
+ fd = (0, import_fs7.openSync)(filePath, "r");
2413
3058
  } catch {
2414
3059
  return false;
2415
3060
  }
2416
3061
  try {
2417
- const fileSize = (0, import_fs6.statSync)(filePath).size;
3062
+ const fileSize = (0, import_fs7.statSync)(filePath).size;
2418
3063
  if (fileSize === 0) {
2419
3064
  fileDecisionCache.set(key, false);
2420
3065
  return false;
@@ -2425,7 +3070,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
2425
3070
  let carry = "";
2426
3071
  while (offset < fileSize) {
2427
3072
  const toRead = Math.min(CHUNK_BYTES, fileSize - offset);
2428
- const got = (0, import_fs6.readSync)(fd, buf, 0, toRead, offset);
3073
+ const got = (0, import_fs7.readSync)(fd, buf, 0, toRead, offset);
2429
3074
  if (got <= 0) break;
2430
3075
  const chunk = carry + buf.toString("utf8", 0, got);
2431
3076
  for (const marker of markers) {
@@ -2446,7 +3091,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
2446
3091
  } catch {
2447
3092
  return false;
2448
3093
  } finally {
2449
- (0, import_fs6.closeSync)(fd);
3094
+ (0, import_fs7.closeSync)(fd);
2450
3095
  }
2451
3096
  }
2452
3097
  function parseAgentEntrypointsEnv(raw) {
@@ -2658,7 +3303,7 @@ var ConversationCache = class _ConversationCache {
2658
3303
  return this.agentEntrypoints;
2659
3304
  }
2660
3305
  static open(dbPath, tailSize = 10, migrationsDir, options) {
2661
- (0, import_fs7.mkdirSync)((0, import_path8.dirname)(dbPath), { recursive: true });
3306
+ (0, import_fs8.mkdirSync)((0, import_path10.dirname)(dbPath), { recursive: true });
2662
3307
  const db = new import_better_sqlite3.default(dbPath);
2663
3308
  db.pragma("journal_mode = WAL");
2664
3309
  db.pragma("foreign_keys = ON");
@@ -2841,7 +3486,7 @@ var ConversationCache = class _ConversationCache {
2841
3486
  let mtimeMs = null;
2842
3487
  let fileSize = null;
2843
3488
  try {
2844
- const s = (0, import_fs7.statSync)(m.filePath);
3489
+ const s = (0, import_fs8.statSync)(m.filePath);
2845
3490
  mtimeMs = s.mtimeMs;
2846
3491
  fileSize = s.size;
2847
3492
  } catch {
@@ -2894,8 +3539,8 @@ var ConversationCache = class _ConversationCache {
2894
3539
  let fileSize;
2895
3540
  let fd;
2896
3541
  try {
2897
- fileSize = (0, import_fs7.statSync)(filePath).size;
2898
- fd = (0, import_fs7.openSync)(filePath, "r");
3542
+ fileSize = (0, import_fs8.statSync)(filePath).size;
3543
+ fd = (0, import_fs8.openSync)(filePath, "r");
2899
3544
  } catch {
2900
3545
  return false;
2901
3546
  }
@@ -2908,7 +3553,7 @@ var ConversationCache = class _ConversationCache {
2908
3553
  while (pos > 0 && lines.length < this.tailSize * 4) {
2909
3554
  const toRead = Math.min(CHUNK, pos);
2910
3555
  pos -= toRead;
2911
- (0, import_fs7.readSync)(fd, buf, 0, toRead, pos);
3556
+ (0, import_fs8.readSync)(fd, buf, 0, toRead, pos);
2912
3557
  const chunk = buf.subarray(0, toRead).toString("utf8");
2913
3558
  const combined = chunk + partial;
2914
3559
  const parts = combined.split("\n");
@@ -2919,7 +3564,7 @@ var ConversationCache = class _ConversationCache {
2919
3564
  }
2920
3565
  if (partial) lines.push(partial);
2921
3566
  } finally {
2922
- (0, import_fs7.closeSync)(fd);
3567
+ (0, import_fs8.closeSync)(fd);
2923
3568
  }
2924
3569
  const msgs = [];
2925
3570
  for (let i = 0; i < lines.length && msgs.length < this.tailSize; i++) {
@@ -3106,7 +3751,7 @@ var ConversationCache = class _ConversationCache {
3106
3751
  * `handleGetConversation` can still serve the cached tail even when the
3107
3752
  * JSONL has been deleted.
3108
3753
  */
3109
- pruneGhostFiles(exists = import_fs7.existsSync) {
3754
+ pruneGhostFiles(exists = import_fs8.existsSync) {
3110
3755
  const rows = this.stmts.allFilePaths.all();
3111
3756
  const ghosts = [];
3112
3757
  const prune = this.db.transaction((ids) => {
@@ -3186,7 +3831,7 @@ var ConversationsRepository = class {
3186
3831
  };
3187
3832
 
3188
3833
  // src/db/repositories/projects.repository.ts
3189
- var import_crypto4 = require("crypto");
3834
+ var import_crypto5 = require("crypto");
3190
3835
 
3191
3836
  // src/utils/canonicalizeProjectPath.ts
3192
3837
  function canonicalizeProjectPath(projectPath) {
@@ -3279,7 +3924,7 @@ var ProjectsRepository = class {
3279
3924
  });
3280
3925
  return rowToProject(this.getById.get(existing.id));
3281
3926
  }
3282
- const id = (0, import_crypto4.randomUUID)();
3927
+ const id = (0, import_crypto5.randomUUID)();
3283
3928
  this.insert.run({
3284
3929
  id,
3285
3930
  path,
@@ -3335,23 +3980,23 @@ async function recordUpload(pool2, instanceId, row) {
3335
3980
  }
3336
3981
 
3337
3982
  // src/handlers/handleListProjects.ts
3338
- var import_fs8 = require("fs");
3983
+ var import_fs9 = require("fs");
3339
3984
  var import_os5 = require("os");
3340
- var import_path9 = require("path");
3985
+ var import_path11 = require("path");
3341
3986
  function decodeProjectPath(dirName) {
3342
3987
  return dirName.replace(/-/g, "/");
3343
3988
  }
3344
3989
  function handleListProjects(url, res) {
3345
3990
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
3346
3991
  const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
3347
- const projectsDir = (0, import_path9.join)((0, import_os5.homedir)(), ".claude", "projects");
3992
+ const projectsDir = (0, import_path11.join)((0, import_os5.homedir)(), ".claude", "projects");
3348
3993
  let entries;
3349
3994
  try {
3350
- entries = (0, import_fs8.readdirSync)(projectsDir).map((dirName) => {
3351
- const fullPath = (0, import_path9.join)(projectsDir, dirName);
3995
+ entries = (0, import_fs9.readdirSync)(projectsDir).map((dirName) => {
3996
+ const fullPath = (0, import_path11.join)(projectsDir, dirName);
3352
3997
  let mtime = 0;
3353
3998
  try {
3354
- mtime = (0, import_fs8.statSync)(fullPath).mtimeMs;
3999
+ mtime = (0, import_fs9.statSync)(fullPath).mtimeMs;
3355
4000
  } catch {
3356
4001
  }
3357
4002
  const path = decodeProjectPath(dirName);
@@ -3370,7 +4015,7 @@ function handleListProjects(url, res) {
3370
4015
  }
3371
4016
 
3372
4017
  // src/pair-store.ts
3373
- var import_crypto5 = require("crypto");
4018
+ var import_crypto6 = require("crypto");
3374
4019
  var DEFAULT_TTL_SECONDS = 180;
3375
4020
  var SWEEP_INTERVAL_MS = 6e4;
3376
4021
  var PairTokenStore = class {
@@ -3385,7 +4030,7 @@ var PairTokenStore = class {
3385
4030
  }
3386
4031
  }
3387
4032
  mint() {
3388
- const token = `pt_${(0, import_crypto5.randomBytes)(16).toString("hex")}`;
4033
+ const token = `pt_${(0, import_crypto6.randomBytes)(16).toString("hex")}`;
3389
4034
  const expiresAt = Date.now() + this.ttlMs;
3390
4035
  this.current = { token, expiresAt, used: false };
3391
4036
  return {
@@ -3446,7 +4091,7 @@ function seal(plaintext, recipientPublicKeyBase64) {
3446
4091
 
3447
4092
  // src/services/conversations/conversationWatcher.ts
3448
4093
  var import_chokidar = __toESM(require("chokidar"), 1);
3449
- var import_fs9 = require("fs");
4094
+ var import_fs10 = require("fs");
3450
4095
  var import_promises3 = require("fs/promises");
3451
4096
  var ConversationWatcher = class {
3452
4097
  files = /* @__PURE__ */ new Map();
@@ -3467,7 +4112,7 @@ var ConversationWatcher = class {
3467
4112
  if (this.files.has(filePath)) return;
3468
4113
  let offset;
3469
4114
  try {
3470
- offset = (0, import_fs9.statSync)(filePath).size;
4115
+ offset = (0, import_fs10.statSync)(filePath).size;
3471
4116
  } catch {
3472
4117
  offset = 0;
3473
4118
  }
@@ -3576,14 +4221,14 @@ var ConversationWatcher = class {
3576
4221
  };
3577
4222
 
3578
4223
  // src/services/conversations/pruneAgentConversations.ts
3579
- var import_fs10 = require("fs");
4224
+ var import_fs11 = require("fs");
3580
4225
  function pruneAgentConversations(cache) {
3581
4226
  const db = cache.getDatabase();
3582
4227
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
3583
4228
  let pruned = 0;
3584
4229
  let missing = 0;
3585
4230
  for (const row of rows) {
3586
- if (!(0, import_fs10.existsSync)(row.file_path)) {
4231
+ if (!(0, import_fs11.existsSync)(row.file_path)) {
3587
4232
  missing += 1;
3588
4233
  continue;
3589
4234
  }
@@ -3866,6 +4511,7 @@ function managedToResponse(s, ptyAttached) {
3866
4511
  return {
3867
4512
  id: s.id,
3868
4513
  conversationId: s.id,
4514
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
3869
4515
  status: s.status,
3870
4516
  projectPath: s.projectPath,
3871
4517
  projectName: s.projectName,
@@ -3891,13 +4537,15 @@ function managedToResponse(s, ptyAttached) {
3891
4537
  ...s.failureReason != null && { failureReason: s.failureReason },
3892
4538
  ...s.resumedFromConversationId != null && {
3893
4539
  resumedFromConversationId: s.resumedFromConversationId
3894
- }
4540
+ },
4541
+ ...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
3895
4542
  };
3896
4543
  }
3897
4544
  function discoveredToResponse(d, conversationId) {
3898
4545
  return {
3899
4546
  id: conversationId,
3900
4547
  conversationId,
4548
+ provider: CLAUDE_CODE_PROVIDER,
3901
4549
  status: "idle",
3902
4550
  projectPath: d.projectPath,
3903
4551
  projectName: d.projectName,
@@ -3913,10 +4561,10 @@ function discoveredToResponse(d, conversationId) {
3913
4561
  }
3914
4562
 
3915
4563
  // src/uploads.ts
3916
- var import_crypto6 = require("crypto");
4564
+ var import_crypto7 = require("crypto");
3917
4565
  var import_promises4 = require("fs/promises");
3918
4566
  var import_heic_convert = __toESM(require("heic-convert"), 1);
3919
- var import_path10 = require("path");
4567
+ var import_path12 = require("path");
3920
4568
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
3921
4569
  var MAX_BYTES = 25 * 1024 * 1024;
3922
4570
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -3947,11 +4595,11 @@ async function saveUploadFile(input) {
3947
4595
  mimeType = "image/jpeg";
3948
4596
  originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
3949
4597
  }
3950
- const id = `up_${(0, import_crypto6.randomBytes)(8).toString("hex")}`;
4598
+ const id = `up_${(0, import_crypto7.randomBytes)(8).toString("hex")}`;
3951
4599
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
3952
- const dir = (0, import_path10.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
4600
+ const dir = (0, import_path12.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
3953
4601
  await (0, import_promises4.mkdir)(dir, { recursive: true });
3954
- const filePath = (0, import_path10.join)(dir, `${Date.now()}-${id}-${safeName}`);
4602
+ const filePath = (0, import_path12.join)(dir, `${Date.now()}-${id}-${safeName}`);
3955
4603
  await (0, import_promises4.writeFile)(filePath, buffer);
3956
4604
  return {
3957
4605
  id,
@@ -4252,10 +4900,10 @@ var StreamerServer = class {
4252
4900
  this.verbose = config.verbose ?? false;
4253
4901
  this.disableDb = config.disableDb ?? false;
4254
4902
  this.scanProfiles = config.scanProfiles;
4255
- this.codexRoots = config.codexRoots ?? [(0, import_path11.join)((0, import_os6.homedir)(), ".codex", "sessions")];
4903
+ this.codexRoots = config.codexRoots ?? [(0, import_path13.join)((0, import_os6.homedir)(), ".codex", "sessions")];
4256
4904
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
4257
4905
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
4258
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path11.join)((0, import_os6.homedir)(), ".threadbase", "cache");
4906
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path13.join)((0, import_os6.homedir)(), ".threadbase", "cache");
4259
4907
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
4260
4908
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
4261
4909
  this.markScannerStaleDebounced = debounce(() => {
@@ -4339,7 +4987,7 @@ var StreamerServer = class {
4339
4987
  });
4340
4988
  }
4341
4989
  });
4342
- this.ptyManager = new PTYManager({
4990
+ this.ptyManager = new LiveSessionManager({
4343
4991
  logger: getLogger("pty"),
4344
4992
  onOutput: (sessionId, data) => {
4345
4993
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
@@ -4420,7 +5068,7 @@ var StreamerServer = class {
4420
5068
  temporalClient,
4421
5069
  taskQueue: agentConfig.temporal.taskQueue
4422
5070
  });
4423
- const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path11.join)((0, import_path11.dirname)(this.cacheDir), "conversations");
5071
+ const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path13.join)((0, import_path13.dirname)(this.cacheDir), "conversations");
4424
5072
  conversationWriter = createConversationWriter({
4425
5073
  baseDir: conversationsBaseDir
4426
5074
  });
@@ -4642,7 +5290,7 @@ var StreamerServer = class {
4642
5290
  });
4643
5291
  try {
4644
5292
  this.cache = ConversationCache.open(
4645
- (0, import_path11.join)(this.cacheDir, "cache.db"),
5293
+ (0, import_path13.join)(this.cacheDir, "cache.db"),
4646
5294
  this.tailSize,
4647
5295
  void 0,
4648
5296
  {
@@ -4668,18 +5316,19 @@ var StreamerServer = class {
4668
5316
  if (this.scanProfiles && this.scanProfiles.length > 0) {
4669
5317
  for (const profile of this.scanProfiles) {
4670
5318
  if (profile.enabled) {
4671
- this.fileWatcher.watchDirectory((0, import_path11.join)(profile.configDir, "projects"));
5319
+ this.fileWatcher.watchDirectory((0, import_path13.join)(profile.configDir, "projects"));
4672
5320
  }
4673
5321
  }
4674
5322
  } else {
4675
- this.fileWatcher.watchDirectory((0, import_path11.join)((0, import_os6.homedir)(), ".claude", "projects"));
5323
+ this.fileWatcher.watchDirectory((0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects"));
4676
5324
  }
4677
5325
  } catch (err) {
4678
5326
  const message = err instanceof Error ? err.message : String(err);
4679
- this.log.warn(`ConversationCache failed to open (running without cache): ${message}`, {
4680
- error: message,
4681
- event: "cache.open_failed"
4682
- });
5327
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
5328
+ this.log.error(
5329
+ `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})`,
5330
+ { error: message, abiMismatch, event: "cache.open_failed" }
5331
+ );
4683
5332
  }
4684
5333
  const warmupScanner = new import_scanner2.ConversationScanner();
4685
5334
  this.allScanners.add(warmupScanner);
@@ -5166,17 +5815,17 @@ var StreamerServer = class {
5166
5815
  return this.getScanner();
5167
5816
  }
5168
5817
  findJsonlPath(uuid) {
5169
- const projectsDir = (0, import_path11.join)((0, import_os6.homedir)(), ".claude", "projects");
5170
- if (!(0, import_fs11.existsSync)(projectsDir)) return null;
5818
+ const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects");
5819
+ if (!(0, import_fs12.existsSync)(projectsDir)) return null;
5171
5820
  const filename = `${uuid}.jsonl`;
5172
- for (const dir of (0, import_fs11.readdirSync)(projectsDir)) {
5173
- const fp = (0, import_path11.join)(projectsDir, dir, filename);
5174
- if ((0, import_fs11.existsSync)(fp)) return fp;
5175
- const projectDir = (0, import_path11.join)(projectsDir, dir);
5821
+ for (const dir of (0, import_fs12.readdirSync)(projectsDir)) {
5822
+ const fp = (0, import_path13.join)(projectsDir, dir, filename);
5823
+ if ((0, import_fs12.existsSync)(fp)) return fp;
5824
+ const projectDir = (0, import_path13.join)(projectsDir, dir);
5176
5825
  try {
5177
- for (const sub of (0, import_fs11.readdirSync)(projectDir)) {
5178
- const subagentPath = (0, import_path11.join)(projectDir, sub, "subagents", filename);
5179
- if ((0, import_fs11.existsSync)(subagentPath)) return subagentPath;
5826
+ for (const sub of (0, import_fs12.readdirSync)(projectDir)) {
5827
+ const subagentPath = (0, import_path13.join)(projectDir, sub, "subagents", filename);
5828
+ if ((0, import_fs12.existsSync)(subagentPath)) return subagentPath;
5180
5829
  }
5181
5830
  } catch {
5182
5831
  }
@@ -5185,7 +5834,7 @@ var StreamerServer = class {
5185
5834
  }
5186
5835
  async readCwdFromJsonl(filePath) {
5187
5836
  return new Promise((resolve2) => {
5188
- const rl = (0, import_readline.createInterface)({ input: (0, import_fs11.createReadStream)(filePath), crlfDelay: Infinity });
5837
+ const rl = (0, import_readline.createInterface)({ input: (0, import_fs12.createReadStream)(filePath), crlfDelay: Infinity });
5189
5838
  let found = false;
5190
5839
  rl.on("line", (line) => {
5191
5840
  if (found) return;
@@ -5242,7 +5891,7 @@ var StreamerServer = class {
5242
5891
  if (!conv.filePath) return false;
5243
5892
  let mtimeMs = null;
5244
5893
  try {
5245
- mtimeMs = (0, import_fs11.statSync)(conv.filePath).mtimeMs;
5894
+ mtimeMs = (0, import_fs12.statSync)(conv.filePath).mtimeMs;
5246
5895
  } catch {
5247
5896
  return false;
5248
5897
  }
@@ -5485,7 +6134,7 @@ var StreamerServer = class {
5485
6134
  handleGetSession(sessionId, res) {
5486
6135
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
5487
6136
  if (session) {
5488
- if (!(0, import_fs11.existsSync)(session.projectPath)) {
6137
+ if (!(0, import_fs12.existsSync)(session.projectPath)) {
5489
6138
  session.failureReason = `Project directory not found: ${session.projectPath}`;
5490
6139
  }
5491
6140
  json(res, 200, session);
@@ -5525,7 +6174,10 @@ var StreamerServer = class {
5525
6174
  json(res, 400, { error: "Could not determine project path" });
5526
6175
  return;
5527
6176
  }
6177
+ const cachedConvMeta = this.cache?.getMetaById(sessionId);
6178
+ const provider = conv?.provider ?? cachedConvMeta?.provider ?? CLAUDE_CODE_PROVIDER;
5528
6179
  const session = await this.ptyManager.start(sessionId, {
6180
+ provider,
5529
6181
  projectPath,
5530
6182
  projectName: body.projectName,
5531
6183
  branch: body.branch
@@ -5875,7 +6527,7 @@ var StreamerServer = class {
5875
6527
  sessionStore: this.sessionStore,
5876
6528
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
5877
6529
  agentClient: this.agentClient,
5878
- conversationsDir: this.cacheDir ? (0, import_path11.join)((0, import_path11.dirname)(this.cacheDir), "conversations") : "",
6530
+ conversationsDir: this.cacheDir ? (0, import_path13.join)((0, import_path13.dirname)(this.cacheDir), "conversations") : "",
5879
6531
  agentConfig: this.agentConfig
5880
6532
  });
5881
6533
  json(res, result.status, result.body);
@@ -5884,6 +6536,13 @@ var StreamerServer = class {
5884
6536
  }
5885
6537
  return;
5886
6538
  }
6539
+ const body = await readBody(req);
6540
+ const { path: relativePath, provider: requestedProvider, systemPrompt: clientPrompt } = body;
6541
+ if (requestedProvider !== void 0 && !isProviderName(requestedProvider)) {
6542
+ json(res, 400, { error: "Invalid provider" });
6543
+ return;
6544
+ }
6545
+ const provider = requestedProvider ?? CLAUDE_CODE_PROVIDER;
5887
6546
  if (!this.browseRoot) {
5888
6547
  json(res, 403, {
5889
6548
  error: "File browsing not configured. Set browseRoot on the server.",
@@ -5891,8 +6550,6 @@ var StreamerServer = class {
5891
6550
  });
5892
6551
  return;
5893
6552
  }
5894
- const body = await readBody(req);
5895
- const { path: relativePath, systemPrompt: clientPrompt } = body;
5896
6553
  if (typeof relativePath !== "string") {
5897
6554
  json(res, 400, { error: "Missing path field" });
5898
6555
  return;
@@ -5913,21 +6570,27 @@ var StreamerServer = class {
5913
6570
  ].filter(Boolean);
5914
6571
  try {
5915
6572
  const session = await this.ptyManager.startFresh({
6573
+ provider,
5916
6574
  projectPath: resolvedPath,
5917
6575
  projectName: body.projectName,
5918
6576
  systemPrompt: systemPromptParts.join("\n")
5919
6577
  });
5920
6578
  this.sessionStore.addManaged(session);
5921
6579
  json(res, 202, { id: session.id, status: "pending" });
5922
- this.watchForJsonl(session.id, resolvedPath);
6580
+ if (provider === CODEX_CLI_PROVIDER) {
6581
+ this.watchForCodexRollout(session.id, resolvedPath);
6582
+ } else {
6583
+ this.watchForJsonl(session.id, resolvedPath);
6584
+ }
5923
6585
  this.broadcastOrUnicastSessionList(req);
5924
6586
  } catch (err) {
5925
6587
  const message = err instanceof Error ? err.message : "Failed to start session";
6588
+ const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
5926
6589
  this.log.error(`[start] failed to start session: ${message}`, {
5927
6590
  event: "session.start_failed",
5928
6591
  error: message
5929
6592
  });
5930
- json(res, 500, { error: message });
6593
+ json(res, statusCode, { error: message });
5931
6594
  }
5932
6595
  }
5933
6596
  // ─── Project linking ─────────────────────────────────────────────
@@ -5980,9 +6643,9 @@ var StreamerServer = class {
5980
6643
  // was passed to Claude via --session-id so the filename matches from the start.
5981
6644
  watchForJsonl(sessionId, projectPath) {
5982
6645
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
5983
- const projectsDir = (0, import_path11.join)((0, import_os6.homedir)(), ".claude", "projects", encoded);
6646
+ const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects", encoded);
5984
6647
  const expectedFile = `${sessionId}.jsonl`;
5985
- const filePath = (0, import_path11.join)(projectsDir, expectedFile);
6648
+ const filePath = (0, import_path13.join)(projectsDir, expectedFile);
5986
6649
  const deadline = Date.now() + 12e4;
5987
6650
  let watcher = null;
5988
6651
  const cleanup = () => {
@@ -6000,12 +6663,12 @@ var StreamerServer = class {
6000
6663
  cleanup();
6001
6664
  return;
6002
6665
  }
6003
- let resolvedFilePath = (0, import_fs11.existsSync)(filePath) ? filePath : null;
6004
- if (!resolvedFilePath && (0, import_fs11.existsSync)(projectsDir)) {
6666
+ let resolvedFilePath = (0, import_fs12.existsSync)(filePath) ? filePath : null;
6667
+ if (!resolvedFilePath && (0, import_fs12.existsSync)(projectsDir)) {
6005
6668
  try {
6006
6669
  const now = Date.now();
6007
- const recent = (0, import_fs11.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs11.statSync)((0, import_path11.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
6008
- if (recent) resolvedFilePath = (0, import_path11.join)(projectsDir, recent.f);
6670
+ const recent = (0, import_fs12.readdirSync)(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: (0, import_fs12.statSync)((0, import_path13.join)(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
6671
+ if (recent) resolvedFilePath = (0, import_path13.join)(projectsDir, recent.f);
6009
6672
  } catch {
6010
6673
  }
6011
6674
  }
@@ -6014,7 +6677,7 @@ var StreamerServer = class {
6014
6677
  this.sessionFileMap.set(sessionId, resolvedFilePath);
6015
6678
  this.fileWatcher.watch(resolvedFilePath);
6016
6679
  try {
6017
- const existing = (0, import_fs11.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
6680
+ const existing = (0, import_fs12.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
6018
6681
  if (existing.length > 0) {
6019
6682
  this.wsHub.broadcast({ type: "conversation_events", sessionId, lines: existing });
6020
6683
  for (const line of existing) {
@@ -6040,11 +6703,126 @@ var StreamerServer = class {
6040
6703
  if (this.sessionFileMap.has(sessionId)) return;
6041
6704
  try {
6042
6705
  require("fs").mkdirSync(projectsDir, { recursive: true });
6043
- watcher = (0, import_fs11.watch)(projectsDir, tryWire);
6706
+ watcher = (0, import_fs12.watch)(projectsDir, tryWire);
6044
6707
  watcher.on("error", cleanup);
6045
6708
  } catch {
6046
6709
  }
6047
6710
  }
6711
+ // Codex-equivalent of watchForJsonl(). Differs because Codex has no
6712
+ // filename-encoded session id (it assigns its own persisted id) and its
6713
+ // rollout files live under a date-nested directory
6714
+ // (~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl) that Codex creates
6715
+ // itself — it may not exist yet when this function is first called, so we
6716
+ // poll rather than fs.watch a not-yet-existent directory. Per Phase 0
6717
+ // findings, the rollout file appears within ~1s of process spawn (after
6718
+ // any directory-trust gate is cleared), well before any user input.
6719
+ watchForCodexRollout(sessionId, projectPath) {
6720
+ const deadline = Date.now() + 12e4;
6721
+ const now = /* @__PURE__ */ new Date();
6722
+ const dateDir = (0, import_path13.join)(
6723
+ String(now.getFullYear()),
6724
+ String(now.getMonth() + 1).padStart(2, "0"),
6725
+ String(now.getDate()).padStart(2, "0")
6726
+ );
6727
+ const sessionStartedAtMs = (this.sessionStore.getManaged(sessionId)?.startedAt?.getTime() ?? Date.now()) - 5e3;
6728
+ let intervalHandle = null;
6729
+ const cleanup = () => {
6730
+ if (intervalHandle) clearInterval(intervalHandle);
6731
+ intervalHandle = null;
6732
+ };
6733
+ const matchesProjectPath = (candidatePath) => {
6734
+ try {
6735
+ const firstLine = (0, import_fs12.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
6736
+ if (!firstLine) return null;
6737
+ const parsed = JSON.parse(firstLine);
6738
+ if (parsed?.type !== "session_meta") return null;
6739
+ const payload = parsed.payload ?? {};
6740
+ if (payload.cwd !== projectPath) return null;
6741
+ if (typeof payload.id !== "string") return null;
6742
+ const createdIso = payload.timestamp ?? parsed.timestamp;
6743
+ const createdAtMs = typeof createdIso === "string" ? Date.parse(createdIso) : Number.NaN;
6744
+ if (Number.isNaN(createdAtMs) || createdAtMs < sessionStartedAtMs) return null;
6745
+ return { id: payload.id, createdAtMs };
6746
+ } catch {
6747
+ return null;
6748
+ }
6749
+ };
6750
+ const tryWire = () => {
6751
+ if (!this.ptyManager.hasSession(sessionId)) {
6752
+ cleanup();
6753
+ return;
6754
+ }
6755
+ if (Date.now() > deadline) {
6756
+ cleanup();
6757
+ return;
6758
+ }
6759
+ const boundElsewhere = new Set(
6760
+ this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
6761
+ );
6762
+ for (const root of this.codexRoots) {
6763
+ const sessionsDir = (0, import_path13.join)(root, dateDir);
6764
+ if (!(0, import_fs12.existsSync)(sessionsDir)) continue;
6765
+ let candidateFiles;
6766
+ try {
6767
+ candidateFiles = (0, import_fs12.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
6768
+ } catch {
6769
+ continue;
6770
+ }
6771
+ const nowMs = Date.now();
6772
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: (0, import_fs12.statSync)((0, import_path13.join)(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
6773
+ for (const { f } of recentCandidates) {
6774
+ const candidatePath = (0, import_path13.join)(sessionsDir, f);
6775
+ const match = matchesProjectPath(candidatePath);
6776
+ if (!match) continue;
6777
+ if (boundElsewhere.has(match.id)) continue;
6778
+ const codexSessionId = match.id;
6779
+ cleanup();
6780
+ this.sessionStore.updateManaged(sessionId, { boundConversationId: codexSessionId });
6781
+ this.sessionFileMap.set(sessionId, candidatePath);
6782
+ this.fileWatcher.watch(candidatePath);
6783
+ try {
6784
+ const existing = (0, import_fs12.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
6785
+ if (existing.length > 0) {
6786
+ this.wsHub.broadcast({ type: "conversation_events", sessionId, lines: existing });
6787
+ for (const line of existing) {
6788
+ this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
6789
+ }
6790
+ }
6791
+ } catch {
6792
+ }
6793
+ if (this.scannerReady) {
6794
+ this.scannerStale = true;
6795
+ } else {
6796
+ this.scanner = null;
6797
+ }
6798
+ this.linkSessionToProject(sessionId, projectPath, candidatePath);
6799
+ this.cache?.markAsStreamer(sessionId);
6800
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
6801
+ if (resp) {
6802
+ this.wsHub.broadcast({ type: "session_update", session: resp });
6803
+ }
6804
+ this.log.info(
6805
+ `[startFresh] bound Codex rollout for ${sessionId}`,
6806
+ {
6807
+ event: "session.codex_rollout_bound",
6808
+ sessionId,
6809
+ boundConversationId: codexSessionId,
6810
+ filePath: candidatePath
6811
+ },
6812
+ "pino"
6813
+ );
6814
+ return;
6815
+ }
6816
+ }
6817
+ };
6818
+ tryWire();
6819
+ if (!intervalHandle && Date.now() <= deadline) {
6820
+ const alreadyBound = this.sessionStore.getManaged(sessionId)?.boundConversationId != null;
6821
+ if (!alreadyBound) {
6822
+ intervalHandle = setInterval(tryWire, 250);
6823
+ }
6824
+ }
6825
+ }
6048
6826
  async handleBrowse(url, res) {
6049
6827
  if (!this.browseRoot) {
6050
6828
  json(res, 403, {
@@ -6128,7 +6906,7 @@ var StreamerServer = class {
6128
6906
  };
6129
6907
  function classifyResumability(cwd) {
6130
6908
  if (!cwd) return { resumable: true };
6131
- if ((0, import_fs11.existsSync)(cwd)) return { resumable: true };
6909
+ if ((0, import_fs12.existsSync)(cwd)) return { resumable: true };
6132
6910
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
6133
6911
  return {
6134
6912
  resumable: false,
@@ -6260,7 +7038,10 @@ function readBody(req) {
6260
7038
  }
6261
7039
  // Annotate the CommonJS export names for ESM import in node:
6262
7040
  0 && (module.exports = {
7041
+ CLAUDE_CODE_PROVIDER,
7042
+ CODEX_CLI_PROVIDER,
6263
7043
  ConversationWatcher,
7044
+ LiveSessionManager,
6264
7045
  PTYManager,
6265
7046
  SessionStore,
6266
7047
  StreamerServer,
@@ -6274,6 +7055,8 @@ function readBody(req) {
6274
7055
  generateApiKey,
6275
7056
  getDbConfig,
6276
7057
  isDbEnabled,
7058
+ isProviderName,
7059
+ isProviderResumable,
6277
7060
  loadOrCreateApiKey,
6278
7061
  maskConnectionString,
6279
7062
  readAgentConfig,