@threadbase-sh/streamer 1.23.1 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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
  }
@@ -1018,6 +1409,10 @@ var PTYManager = class {
1018
1409
  // to a given input or fell silent. Reset on dispose().
1019
1410
  chunkIndex = /* @__PURE__ */ new Map();
1020
1411
  lastChunkAt = /* @__PURE__ */ new Map();
1412
+ // In-flight start()/startFresh() calls keyed by sessionId. A second
1413
+ // concurrent resume for the same session (double-tap, client retry) awaits
1414
+ // the first call's promise instead of spawning a duplicate PTY (CRITICAL #3).
1415
+ startPromises = /* @__PURE__ */ new Map();
1021
1416
  constructor(options = {}) {
1022
1417
  this.onOutput = options.onOutput;
1023
1418
  this.onStatusChange = options.onStatusChange;
@@ -1040,7 +1435,18 @@ var PTYManager = class {
1040
1435
  // custom-API-key — are cleared by the seeded ~/.claude.json in
1041
1436
  // docker/entrypoint.sh.) startFresh() uses the same flag for the same reason.
1042
1437
  async start(sessionId, options) {
1043
- const nodePty = await loadPty();
1438
+ const existing = this.sessions.get(sessionId);
1439
+ if (existing) return toPublicSession2(existing);
1440
+ const inFlight = this.startPromises.get(sessionId);
1441
+ if (inFlight) return inFlight;
1442
+ const promise = this.doStart(sessionId, options).finally(() => {
1443
+ this.startPromises.delete(sessionId);
1444
+ });
1445
+ this.startPromises.set(sessionId, promise);
1446
+ return promise;
1447
+ }
1448
+ async doStart(sessionId, options) {
1449
+ const nodePty = await loadPty2();
1044
1450
  const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1045
1451
  const proc = nodePty.spawn(
1046
1452
  resolveClaudeExe(),
@@ -1062,6 +1468,7 @@ var PTYManager = class {
1062
1468
  );
1063
1469
  const session = {
1064
1470
  id: sessionId,
1471
+ provider: CLAUDE_CODE_PROVIDER,
1065
1472
  projectPath: options.projectPath,
1066
1473
  projectName,
1067
1474
  branch: options.branch ?? "",
@@ -1072,7 +1479,7 @@ var PTYManager = class {
1072
1479
  lastOutput: "",
1073
1480
  process: proc,
1074
1481
  outputBuffer: Buffer.alloc(0),
1075
- screen: createScreen()
1482
+ screen: createScreen2()
1076
1483
  };
1077
1484
  this.sessions.set(sessionId, session);
1078
1485
  this.pendingReady.add(sessionId);
@@ -1083,14 +1490,14 @@ var PTYManager = class {
1083
1490
  this.pendingReady.delete(sessionId);
1084
1491
  this.handleExit(sessionId, exitCode);
1085
1492
  });
1086
- return toPublicSession(session);
1493
+ return toPublicSession2(session);
1087
1494
  }
1088
1495
  // Start a brand-new Claude session. A stable UUID is generated here and passed
1089
1496
  // to Claude via --session-id so the JSONL filename matches from the start.
1090
1497
  // onReady fires once Claude reaches its first prompt (waiting_input).
1091
1498
  async startFresh(options) {
1092
- const nodePty = await loadPty();
1093
- const sessionId = (0, import_crypto2.randomUUID)();
1499
+ const nodePty = await loadPty2();
1500
+ const sessionId = (0, import_crypto3.randomUUID)();
1094
1501
  const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1095
1502
  const args = [
1096
1503
  "--permission-mode",
@@ -1112,6 +1519,7 @@ var PTYManager = class {
1112
1519
  });
1113
1520
  const session = {
1114
1521
  id: sessionId,
1522
+ provider: CLAUDE_CODE_PROVIDER,
1115
1523
  projectPath: options.projectPath,
1116
1524
  projectName,
1117
1525
  branch: "",
@@ -1122,7 +1530,7 @@ var PTYManager = class {
1122
1530
  lastOutput: "",
1123
1531
  process: proc,
1124
1532
  outputBuffer: Buffer.alloc(0),
1125
- screen: createScreen()
1533
+ screen: createScreen2()
1126
1534
  };
1127
1535
  this.sessions.set(sessionId, session);
1128
1536
  this.pendingReady.add(sessionId);
@@ -1133,7 +1541,7 @@ var PTYManager = class {
1133
1541
  this.pendingReady.delete(sessionId);
1134
1542
  this.handleExit(sessionId, exitCode);
1135
1543
  });
1136
- return toPublicSession(session);
1544
+ return toPublicSession2(session);
1137
1545
  }
1138
1546
  // Write raw key bytes directly to the PTY without bracketed-paste wrapping.
1139
1547
  // Use for control sequences (arrow keys, Enter) that must not be quoted.
@@ -1145,10 +1553,10 @@ var PTYManager = class {
1145
1553
  }
1146
1554
  if (session.status === "waiting_input") {
1147
1555
  session.status = "running";
1148
- this.onStatusChange?.(toPublicSession(session));
1556
+ this.onStatusChange?.(toPublicSession2(session));
1149
1557
  }
1150
1558
  this.log.info(
1151
- `[pty.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes(keys)}`,
1559
+ `[pty.keys.write] ${sessionId.slice(0, 8)} bytes=${keys.length} digest=${digestBytes2(keys)}`,
1152
1560
  { event: "pty.keys_write", sessionId, byteLen: keys.length }
1153
1561
  );
1154
1562
  session.process.write(keys);
@@ -1180,7 +1588,7 @@ var PTYManager = class {
1180
1588
  }
1181
1589
  if (session.status === "waiting_input") {
1182
1590
  session.status = "running";
1183
- this.onStatusChange?.(toPublicSession(session));
1591
+ this.onStatusChange?.(toPublicSession2(session));
1184
1592
  }
1185
1593
  this.writeSubmit(sessionId, session, input, "direct", session.promptCount + 1);
1186
1594
  session.lastActivityAt = /* @__PURE__ */ new Date();
@@ -1193,13 +1601,13 @@ var PTYManager = class {
1193
1601
  writeSubmit(sessionId, session, input, path, promptCount) {
1194
1602
  const pasteBytes = buildPasteBytes(input);
1195
1603
  this.log.info(
1196
- `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes(pasteBytes)}`,
1604
+ `[pty.input.write] ${sessionId.slice(0, 8)} promptCount=${promptCount} bytes=${pasteBytes.length} digest=${digestBytes2(pasteBytes)}`,
1197
1605
  {
1198
1606
  event: "pty.input_write",
1199
1607
  sessionId,
1200
1608
  promptCount,
1201
1609
  byteLen: pasteBytes.length,
1202
- digest: digestBytes(pasteBytes),
1610
+ digest: digestBytes2(pasteBytes),
1203
1611
  path,
1204
1612
  phase: "paste"
1205
1613
  }
@@ -1214,13 +1622,13 @@ var PTYManager = class {
1214
1622
  event: "pty.input_write",
1215
1623
  sessionId,
1216
1624
  promptCount,
1217
- byteLen: SUBMIT_BYTES.length,
1625
+ byteLen: SUBMIT_BYTES2.length,
1218
1626
  digest: "\\r",
1219
1627
  path,
1220
1628
  phase: "submit"
1221
1629
  }
1222
1630
  );
1223
- current.process.write(SUBMIT_BYTES);
1631
+ current.process.write(SUBMIT_BYTES2);
1224
1632
  }, SUBMIT_DELAY_MS);
1225
1633
  }
1226
1634
  // Drain any inputs that were sent while the session was still pendingReady,
@@ -1278,7 +1686,7 @@ var PTYManager = class {
1278
1686
  session.completedAt = /* @__PURE__ */ new Date();
1279
1687
  session.screen.dispose();
1280
1688
  this.sessions.delete(sessionId);
1281
- this.onStatusChange?.(toPublicSession(session));
1689
+ this.onStatusChange?.(toPublicSession2(session));
1282
1690
  }
1283
1691
  getOutput(sessionId) {
1284
1692
  const session = this.sessions.get(sessionId);
@@ -1309,13 +1717,13 @@ var PTYManager = class {
1309
1717
  }
1310
1718
  getSession(sessionId) {
1311
1719
  const session = this.sessions.get(sessionId);
1312
- return session ? toPublicSession(session) : null;
1720
+ return session ? toPublicSession2(session) : null;
1313
1721
  }
1314
1722
  hasSession(sessionId) {
1315
1723
  return this.sessions.has(sessionId);
1316
1724
  }
1317
1725
  listSessions() {
1318
- return Array.from(this.sessions.values()).map(toPublicSession);
1726
+ return Array.from(this.sessions.values()).map(toPublicSession2);
1319
1727
  }
1320
1728
  dispose() {
1321
1729
  for (const session of this.sessions.values()) {
@@ -1347,7 +1755,7 @@ var PTYManager = class {
1347
1755
  this.lastChunkAt.set(sessionId, now);
1348
1756
  const gapMs = last == null ? 0 : now - last;
1349
1757
  this.log.info(
1350
- `[pty.chunk] ${sessionId.slice(0, 8)} #${idx} +${chunk.length}B gap=${gapMs}ms status=${session.status} digest=${digestBytes(data)}`,
1758
+ `[pty.chunk] ${sessionId.slice(0, 8)} #${idx} +${chunk.length}B gap=${gapMs}ms status=${session.status} digest=${digestBytes2(data)}`,
1351
1759
  {
1352
1760
  event: "pty.chunk",
1353
1761
  sessionId,
@@ -1356,17 +1764,17 @@ var PTYManager = class {
1356
1764
  gapMs,
1357
1765
  status: session.status,
1358
1766
  pendingReady: this.pendingReady.has(sessionId),
1359
- digest: digestBytes(data)
1767
+ digest: digestBytes2(data)
1360
1768
  }
1361
1769
  );
1362
1770
  session.outputBuffer = Buffer.concat([session.outputBuffer, chunk]);
1363
- if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
1771
+ if (session.outputBuffer.length > OUTPUT_BUFFER_MAX2) {
1364
1772
  session.outputBuffer = session.outputBuffer.subarray(
1365
- session.outputBuffer.length - OUTPUT_BUFFER_MAX
1773
+ session.outputBuffer.length - OUTPUT_BUFFER_MAX2
1366
1774
  );
1367
1775
  }
1368
1776
  session.screen.write(data);
1369
- const stripped = stripAnsi(data);
1777
+ const stripped = stripAnsi2(data);
1370
1778
  session.lastOutput = stripped;
1371
1779
  const matchedMarker = CLAUDE_PROMPT_MARKERS.find((m) => stripped.includes(m));
1372
1780
  if (session.status === "running" && matchedMarker) {
@@ -1436,87 +1844,327 @@ var PTYManager = class {
1436
1844
  this.onLiveQuestion?.(sessionId, detected.questions);
1437
1845
  }
1438
1846
  }
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);
1847
+ } else if (this.lastScreenQuestionKey.has(sessionId) && hasPromptMarker) {
1848
+ this.lastScreenQuestionKey.delete(sessionId);
1849
+ this.onLiveQuestionGone?.(sessionId);
1850
+ }
1851
+ if (!oscPermission && !askFooterOnScreen && !this.permissionOpen.has(sessionId)) {
1852
+ const shell = detectShellPrompt(lines);
1853
+ if (shell) {
1854
+ const key = `${shell.prompt}\0${shell.options.map((o) => o.label).join("\0")}`;
1855
+ if (this.shellPromptOpen.get(sessionId) !== key) {
1856
+ this.shellPromptOpen.set(sessionId, key);
1857
+ this.onPermissionChange?.(sessionId, {
1858
+ prompt: shell.prompt,
1859
+ options: shell.options
1860
+ });
1861
+ }
1862
+ } else if (this.shellPromptOpen.has(sessionId) && hasPromptMarker) {
1863
+ this.shellPromptOpen.delete(sessionId);
1864
+ this.onPermissionChange?.(sessionId, null);
1865
+ }
1866
+ }
1867
+ }
1868
+ // Transition a session from "running" to "waiting_input", clear pendingReady,
1869
+ // and flush any queued input. Idempotent: callers can invoke at any chunk.
1870
+ markReady(sessionId, session, reason) {
1871
+ session.lastActivityAt = /* @__PURE__ */ new Date();
1872
+ session.status = "waiting_input";
1873
+ const elapsedMs = Date.now() - (this.firstChunkAt.get(sessionId) ?? Date.now());
1874
+ this.log.info(`[pty.ready] ${sessionId.slice(0, 8)} ${reason} (elapsed=${elapsedMs}ms)`, {
1875
+ event: "pty.ready",
1876
+ sessionId,
1877
+ reason,
1878
+ elapsedMs
1879
+ });
1880
+ this.onStatusChange?.(toPublicSession2(session));
1881
+ if (this.pendingReady.has(sessionId)) {
1882
+ this.pendingReady.delete(sessionId);
1883
+ this.flushQueuedInputs(sessionId);
1884
+ this.onReady?.(toPublicSession2(session));
1885
+ }
1886
+ }
1887
+ handleExit(sessionId, exitCode) {
1888
+ const session = this.sessions.get(sessionId);
1889
+ if (!session) return;
1890
+ session.completedAt = /* @__PURE__ */ new Date();
1891
+ session.status = "idle";
1892
+ const elapsedMs = session.completedAt.getTime() - session.startedAt.getTime();
1893
+ if (exitCode !== 0 && elapsedMs < 2e3 && session.lastOutput === "") {
1894
+ if (!(0, import_fs5.existsSync)(session.projectPath)) {
1895
+ session.failureReason = `Project directory not found: ${session.projectPath}`;
1896
+ } else {
1897
+ session.failureReason = `Process exited immediately (code ${exitCode}). Check that the Claude binary is installed and accessible.`;
1898
+ }
1899
+ }
1900
+ this.onStatusChange?.(toPublicSession2(session));
1901
+ session.screen.dispose();
1902
+ this.sessions.delete(sessionId);
1903
+ this.queuedInputs.delete(sessionId);
1904
+ this.firstChunkAt.delete(sessionId);
1905
+ this.permissionOpen.delete(sessionId);
1906
+ this.lastScreenQuestionKey.delete(sessionId);
1907
+ this.shellPromptOpen.delete(sessionId);
1908
+ }
1909
+ };
1910
+ function toPublicSession2(s) {
1911
+ return {
1912
+ id: s.id,
1913
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
1914
+ projectPath: s.projectPath,
1915
+ projectName: s.projectName,
1916
+ branch: s.branch,
1917
+ status: s.status,
1918
+ startedAt: s.startedAt,
1919
+ completedAt: s.completedAt,
1920
+ promptCount: s.promptCount,
1921
+ lastOutput: s.lastOutput,
1922
+ ...s.failureReason != null && { failureReason: s.failureReason },
1923
+ ...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
1924
+ ...s.filePath != null && { filePath: s.filePath }
1925
+ };
1926
+ }
1927
+ function stripAnsi2(str) {
1928
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
1929
+ }
1930
+
1931
+ // src/live-session-manager.ts
1932
+ var LiveSessionManager = class {
1933
+ runners;
1934
+ constructor(options = {}) {
1935
+ this.runners = /* @__PURE__ */ new Map([
1936
+ [CLAUDE_CODE_PROVIDER, new PTYManager(options)],
1937
+ [CODEX_CLI_PROVIDER, new CodexPtyRunner(options)]
1938
+ ]);
1939
+ }
1940
+ async start(sessionId, options) {
1941
+ const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
1942
+ const runner = this.assertSupportedProvider(provider, options.projectPath);
1943
+ return runner.start(sessionId, options);
1944
+ }
1945
+ async startFresh(options) {
1946
+ const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
1947
+ const runner = this.assertSupportedProvider(provider, options.projectPath);
1948
+ return runner.startFresh(options);
1949
+ }
1950
+ sendInput(sessionId, input) {
1951
+ return this.runnerFor(sessionId).sendInput(sessionId, input);
1952
+ }
1953
+ sendKeys(sessionId, keys) {
1954
+ this.runnerFor(sessionId).sendKeys(sessionId, keys);
1955
+ }
1956
+ cancel(sessionId) {
1957
+ this.runnerFor(sessionId).cancel(sessionId);
1958
+ }
1959
+ killPid(pid) {
1960
+ for (const runner of this.runners.values()) {
1961
+ runner.killPid(pid);
1962
+ }
1963
+ }
1964
+ // putOnHold tolerates an unknown sessionId (PTYManager.putOnHold is a no-op
1965
+ // when the session isn't in its map), so — unlike the other session-keyed
1966
+ // methods — route to the owning runner when found, otherwise broadcast to
1967
+ // every runner rather than throwing; this matches the pre-extraction
1968
+ // behavior of delegating straight through with no existence check.
1969
+ putOnHold(sessionId) {
1970
+ for (const runner of this.runners.values()) {
1971
+ if (runner.hasSession(sessionId) || runner.getSession(sessionId)) {
1972
+ runner.putOnHold(sessionId);
1973
+ return;
1974
+ }
1975
+ }
1976
+ for (const runner of this.runners.values()) {
1977
+ runner.putOnHold(sessionId);
1978
+ }
1979
+ }
1980
+ getOutput(sessionId) {
1981
+ return this.runnerFor(sessionId).getOutput(sessionId);
1982
+ }
1983
+ getOutputLines(sessionId, maxLines) {
1984
+ return this.runnerFor(sessionId).getOutputLines(sessionId, maxLines);
1985
+ }
1986
+ getSession(sessionId) {
1987
+ for (const runner of this.runners.values()) {
1988
+ const session = runner.getSession(sessionId);
1989
+ if (session) return session;
1990
+ }
1991
+ return null;
1992
+ }
1993
+ hasSession(sessionId) {
1994
+ for (const runner of this.runners.values()) {
1995
+ if (runner.hasSession(sessionId)) return true;
1996
+ }
1997
+ return false;
1998
+ }
1999
+ listSessions() {
2000
+ return Array.from(this.runners.values()).flatMap((runner) => runner.listSessions());
2001
+ }
2002
+ dispose() {
2003
+ for (const runner of this.runners.values()) {
2004
+ runner.dispose();
2005
+ }
2006
+ }
2007
+ // Look up which runner owns a session. Only one runner exists today, so
2008
+ // this is a linear scan across hasSession()/getSession() rather than a
2009
+ // separate session→provider index — see task-1-brief.md.
2010
+ runnerFor(sessionId) {
2011
+ for (const runner of this.runners.values()) {
2012
+ if (runner.hasSession(sessionId) || runner.getSession(sessionId)) return runner;
2013
+ }
2014
+ throw new Error(`Session not found: ${sessionId}`);
2015
+ }
2016
+ assertSupportedProvider(provider, projectPath) {
2017
+ const runner = this.runners.get(provider);
2018
+ if (runner) return runner;
2019
+ const err = new Error(
2020
+ `Live ${provider} sessions are not implemented yet for ${(0, import_path6.basename)(projectPath)}`
2021
+ );
2022
+ err.statusCode = 501;
2023
+ throw err;
2024
+ }
2025
+ };
2026
+
2027
+ // src/process-discovery.ts
2028
+ var import_child_process2 = require("child_process");
2029
+ var import_os3 = require("os");
2030
+ var import_path7 = require("path");
2031
+ async function discoverClaudeProcesses() {
2032
+ if ((0, import_os3.platform)() === "win32") return discoverWindows();
2033
+ return discoverUnix();
2034
+ }
2035
+ async function discoverUnix() {
2036
+ const pids = await getPidsUnix();
2037
+ const results = await Promise.all(
2038
+ pids.map(async (pid) => {
2039
+ try {
2040
+ const [cwd, args, startedAt] = await Promise.all([
2041
+ getProcessCwdUnix(pid),
2042
+ getProcessArgsUnix(pid),
2043
+ getProcessStartTimeUnix(pid)
2044
+ ]);
2045
+ const conversationId = extractResumeId(args);
2046
+ return {
2047
+ pid,
2048
+ projectPath: cwd,
2049
+ projectName: (0, import_path7.basename)(cwd),
2050
+ branch: await readGitBranch(cwd),
2051
+ conversationId,
2052
+ startedAt
2053
+ };
2054
+ } catch {
2055
+ return null;
2056
+ }
2057
+ })
2058
+ );
2059
+ return results.filter((r) => r !== null);
2060
+ }
2061
+ async function discoverWindows() {
2062
+ const pids = await getPidsWindows();
2063
+ const results = await Promise.all(
2064
+ pids.map(async (pid) => {
2065
+ try {
2066
+ const info = await getProcessInfoWindows(pid);
2067
+ if (!info) return null;
2068
+ return {
2069
+ pid,
2070
+ projectPath: info.cwd,
2071
+ projectName: (0, import_path7.basename)(info.cwd),
2072
+ branch: await readGitBranch(info.cwd),
2073
+ conversationId: extractResumeId(info.args),
2074
+ startedAt: info.startedAt
2075
+ };
2076
+ } catch {
2077
+ return null;
2078
+ }
2079
+ })
2080
+ );
2081
+ return results.filter((r) => r !== null);
2082
+ }
2083
+ function run(cmd, args, opts = {}) {
2084
+ return new Promise((resolve2, reject) => {
2085
+ (0, import_child_process2.execFile)(
2086
+ cmd,
2087
+ args,
2088
+ { windowsHide: isWindows, encoding: "utf-8", timeout: opts.timeout ?? 5e3, cwd: opts.cwd },
2089
+ (err, stdout) => {
2090
+ if (err) reject(err);
2091
+ else resolve2(stdout);
1457
2092
  }
1458
- }
2093
+ );
2094
+ });
2095
+ }
2096
+ async function getPidsUnix() {
2097
+ try {
2098
+ const output = await run("pgrep", ["-x", "claude"]);
2099
+ return output.trim().split("\n").filter(Boolean).map((s) => Number.parseInt(s, 10));
2100
+ } catch {
2101
+ return [];
1459
2102
  }
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
- }
2103
+ }
2104
+ async function getProcessCwdUnix(pid) {
2105
+ const output = await run("lsof", ["-p", String(pid), "-a", "-d", "cwd", "-Fn"]);
2106
+ const match = output.match(/n(.+)/);
2107
+ return match?.[1] ?? "";
2108
+ }
2109
+ async function getProcessArgsUnix(pid) {
2110
+ return (await run("ps", ["-p", String(pid), "-o", "args="])).trim();
2111
+ }
2112
+ async function getProcessStartTimeUnix(pid) {
2113
+ const raw = (await run("ps", ["-p", String(pid), "-o", "lstart="])).trim();
2114
+ const d = new Date(raw);
2115
+ return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
2116
+ }
2117
+ async function getPidsWindows() {
2118
+ try {
2119
+ const output = await run("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV", "/NH"]);
2120
+ return output.trim().split("\n").filter(Boolean).map((line) => {
2121
+ const parts = line.split(",");
2122
+ return Number.parseInt(parts[1]?.replace(/"/g, "") ?? "0", 10);
2123
+ }).filter((pid) => pid > 0);
2124
+ } catch {
2125
+ return [];
1478
2126
  }
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);
2127
+ }
2128
+ async function getProcessInfoWindows(pid) {
2129
+ try {
2130
+ const output = await run("wmic", [
2131
+ "process",
2132
+ "where",
2133
+ `ProcessId=${pid}`,
2134
+ "get",
2135
+ "CommandLine,CreationDate,ExecutablePath",
2136
+ "/FORMAT:CSV"
2137
+ ]);
2138
+ const lines = output.trim().split(/\r?\n/).filter((l) => l.trim().length > 0);
2139
+ if (lines.length < 2) return null;
2140
+ const parts = lines[1].split(",");
2141
+ const args = parts[1] ?? "";
2142
+ const creationDate = parts[2] ?? "";
2143
+ const year = creationDate.slice(0, 4);
2144
+ const month = creationDate.slice(4, 6);
2145
+ const day = creationDate.slice(6, 8);
2146
+ const hour = creationDate.slice(8, 10);
2147
+ const min = creationDate.slice(10, 12);
2148
+ const sec = creationDate.slice(12, 14);
2149
+ const startedAt = /* @__PURE__ */ new Date(`${year}-${month}-${day}T${hour}:${min}:${sec}`);
2150
+ if (Number.isNaN(startedAt.getTime())) return null;
2151
+ const exePath = parts[3] ?? "";
2152
+ const cwd = exePath ? (0, import_path7.dirname)(exePath) : "";
2153
+ return { cwd, args, startedAt };
2154
+ } catch {
2155
+ return null;
1500
2156
  }
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
2157
  }
1518
- function stripAnsi(str) {
1519
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
2158
+ function extractResumeId(args) {
2159
+ const match = args.match(/--resume\s+(\S+)/);
2160
+ return match?.[1] ?? null;
2161
+ }
2162
+ async function readGitBranch(dir) {
2163
+ try {
2164
+ return (await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 3e3 })).trim();
2165
+ } catch {
2166
+ return "";
2167
+ }
1520
2168
  }
1521
2169
 
1522
2170
  // src/server.ts
@@ -1524,15 +2172,15 @@ var import_node_ws = require("@hono/node-ws");
1524
2172
  var import_client = require("@temporalio/client");
1525
2173
  var import_scanner2 = require("@threadbase-sh/scanner");
1526
2174
  var import_events = require("events");
1527
- var import_fs11 = require("fs");
2175
+ var import_fs12 = require("fs");
1528
2176
  var import_promises5 = require("fs/promises");
1529
2177
  var import_http = require("http");
1530
2178
  var import_os6 = require("os");
1531
- var import_path11 = require("path");
2179
+ var import_path13 = require("path");
1532
2180
  var import_readline = require("readline");
1533
2181
 
1534
2182
  // node_modules/nanoid/index.js
1535
- var import_crypto3 = __toESM(require("crypto"), 1);
2183
+ var import_crypto4 = __toESM(require("crypto"), 1);
1536
2184
 
1537
2185
  // node_modules/nanoid/url-alphabet/index.js
1538
2186
  var urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
@@ -1546,10 +2194,10 @@ var fillPool = (bytes) => {
1546
2194
  try {
1547
2195
  if (!pool || pool.length < bytes) {
1548
2196
  pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER);
1549
- import_crypto3.default.randomFillSync(pool);
2197
+ import_crypto4.default.randomFillSync(pool);
1550
2198
  poolOffset = 0;
1551
2199
  } else if (poolOffset + bytes > pool.length) {
1552
- import_crypto3.default.randomFillSync(pool);
2200
+ import_crypto4.default.randomFillSync(pool);
1553
2201
  poolOffset = 0;
1554
2202
  }
1555
2203
  } catch (e) {
@@ -2282,7 +2930,7 @@ var createHonoApp = (deps, upgradeWebSocket) => {
2282
2930
 
2283
2931
  // src/browse.ts
2284
2932
  var import_promises2 = require("fs/promises");
2285
- var import_path6 = require("path");
2933
+ var import_path8 = require("path");
2286
2934
  var BrowsePathNotFoundError = class extends Error {
2287
2935
  constructor(message) {
2288
2936
  super(message);
@@ -2290,15 +2938,15 @@ var BrowsePathNotFoundError = class extends Error {
2290
2938
  }
2291
2939
  };
2292
2940
  async function resolveBrowsePath(browseRoot, relativePath) {
2293
- const normalizedRoot = (0, import_path6.resolve)(browseRoot);
2941
+ const normalizedRoot = (0, import_path8.resolve)(browseRoot);
2294
2942
  let sanitized;
2295
2943
  if (process.platform !== "win32" && relativePath.startsWith("/") && relativePath.length > 1 && relativePath.includes("/", 1)) {
2296
2944
  sanitized = relativePath;
2297
2945
  } else {
2298
2946
  sanitized = relativePath.replace(/^[/\\]+/, "");
2299
2947
  }
2300
- const target = sanitized ? (0, import_path6.resolve)(normalizedRoot, sanitized) : normalizedRoot;
2301
- const rootPrefix = normalizedRoot.endsWith(import_path6.sep) ? normalizedRoot : `${normalizedRoot}${import_path6.sep}`;
2948
+ const target = sanitized ? (0, import_path8.resolve)(normalizedRoot, sanitized) : normalizedRoot;
2949
+ const rootPrefix = normalizedRoot.endsWith(import_path8.sep) ? normalizedRoot : `${normalizedRoot}${import_path8.sep}`;
2302
2950
  if (!target.startsWith(rootPrefix) && target !== normalizedRoot) {
2303
2951
  throw new Error("Path outside browse root");
2304
2952
  }
@@ -2320,7 +2968,7 @@ async function createDirectory(parentAbsolutePath, name) {
2320
2968
  if (name.includes("/") || name.includes("\\") || name === ".." || name === ".") {
2321
2969
  throw new Error("Invalid directory name");
2322
2970
  }
2323
- const target = (0, import_path6.join)(parentAbsolutePath, name);
2971
+ const target = (0, import_path8.join)(parentAbsolutePath, name);
2324
2972
  try {
2325
2973
  const s = await (0, import_promises2.stat)(target);
2326
2974
  if (s.isDirectory()) throw new Error("Directory already exists");
@@ -2333,17 +2981,17 @@ async function createDirectory(parentAbsolutePath, name) {
2333
2981
 
2334
2982
  // src/conversation-cache.ts
2335
2983
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
2336
- var import_fs7 = require("fs");
2337
- var import_path8 = require("path");
2984
+ var import_fs8 = require("fs");
2985
+ var import_path10 = require("path");
2338
2986
 
2339
2987
  // src/db/sqlite-migrate.ts
2340
- var import_fs5 = require("fs");
2341
- var import_path7 = require("path");
2988
+ var import_fs6 = require("fs");
2989
+ var import_path9 = require("path");
2342
2990
  var import_url2 = require("url");
2343
2991
  var import_meta2 = {};
2344
2992
  function getMigrationsDir2() {
2345
2993
  if (typeof import_meta2 !== "undefined" && import_meta2.url) {
2346
- return (0, import_path7.dirname)((0, import_url2.fileURLToPath)(import_meta2.url));
2994
+ return (0, import_path9.dirname)((0, import_url2.fileURLToPath)(import_meta2.url));
2347
2995
  }
2348
2996
  return __dirname;
2349
2997
  }
@@ -2355,8 +3003,8 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
2355
3003
  `;
2356
3004
  function runSqliteMigrations(db, migrationsDir) {
2357
3005
  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();
3006
+ const dir = migrationsDir ?? (0, import_path9.join)(getMigrationsDir2(), "migrations");
3007
+ const files = (0, import_fs6.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
2360
3008
  const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
2361
3009
  const appliedSet = new Set(appliedRows.map((r) => r.id));
2362
3010
  const recordApplied = db.prepare("INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)");
@@ -2367,7 +3015,7 @@ function runSqliteMigrations(db, migrationsDir) {
2367
3015
  skipped.push(file);
2368
3016
  continue;
2369
3017
  }
2370
- const sql = (0, import_fs5.readFileSync)((0, import_path7.join)(dir, file), "utf-8");
3018
+ const sql = (0, import_fs6.readFileSync)((0, import_path9.join)(dir, file), "utf-8");
2371
3019
  const tx = db.transaction(() => {
2372
3020
  db.exec(sql);
2373
3021
  recordApplied.run(file, (/* @__PURE__ */ new Date()).toISOString());
@@ -2378,16 +3026,8 @@ function runSqliteMigrations(db, migrationsDir) {
2378
3026
  return { applied, skipped };
2379
3027
  }
2380
3028
 
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
3029
  // src/services/conversations/isAgentConversation.ts
2390
- var import_fs6 = require("fs");
3030
+ var import_fs7 = require("fs");
2391
3031
  var DEFAULT_AGENT_ENTRYPOINTS = /* @__PURE__ */ new Set(["sdk-cli", "claude-vscode"]);
2392
3032
  var CHUNK_BYTES = 64 * 1024;
2393
3033
  var ENTRYPOINT_PROBE = `"entrypoint":`;
@@ -2409,12 +3049,12 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
2409
3049
  if (cached2 !== void 0) return cached2;
2410
3050
  let fd;
2411
3051
  try {
2412
- fd = (0, import_fs6.openSync)(filePath, "r");
3052
+ fd = (0, import_fs7.openSync)(filePath, "r");
2413
3053
  } catch {
2414
3054
  return false;
2415
3055
  }
2416
3056
  try {
2417
- const fileSize = (0, import_fs6.statSync)(filePath).size;
3057
+ const fileSize = (0, import_fs7.statSync)(filePath).size;
2418
3058
  if (fileSize === 0) {
2419
3059
  fileDecisionCache.set(key, false);
2420
3060
  return false;
@@ -2425,7 +3065,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
2425
3065
  let carry = "";
2426
3066
  while (offset < fileSize) {
2427
3067
  const toRead = Math.min(CHUNK_BYTES, fileSize - offset);
2428
- const got = (0, import_fs6.readSync)(fd, buf, 0, toRead, offset);
3068
+ const got = (0, import_fs7.readSync)(fd, buf, 0, toRead, offset);
2429
3069
  if (got <= 0) break;
2430
3070
  const chunk = carry + buf.toString("utf8", 0, got);
2431
3071
  for (const marker of markers) {
@@ -2446,7 +3086,7 @@ function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
2446
3086
  } catch {
2447
3087
  return false;
2448
3088
  } finally {
2449
- (0, import_fs6.closeSync)(fd);
3089
+ (0, import_fs7.closeSync)(fd);
2450
3090
  }
2451
3091
  }
2452
3092
  function parseAgentEntrypointsEnv(raw) {
@@ -2658,7 +3298,7 @@ var ConversationCache = class _ConversationCache {
2658
3298
  return this.agentEntrypoints;
2659
3299
  }
2660
3300
  static open(dbPath, tailSize = 10, migrationsDir, options) {
2661
- (0, import_fs7.mkdirSync)((0, import_path8.dirname)(dbPath), { recursive: true });
3301
+ (0, import_fs8.mkdirSync)((0, import_path10.dirname)(dbPath), { recursive: true });
2662
3302
  const db = new import_better_sqlite3.default(dbPath);
2663
3303
  db.pragma("journal_mode = WAL");
2664
3304
  db.pragma("foreign_keys = ON");
@@ -2841,7 +3481,7 @@ var ConversationCache = class _ConversationCache {
2841
3481
  let mtimeMs = null;
2842
3482
  let fileSize = null;
2843
3483
  try {
2844
- const s = (0, import_fs7.statSync)(m.filePath);
3484
+ const s = (0, import_fs8.statSync)(m.filePath);
2845
3485
  mtimeMs = s.mtimeMs;
2846
3486
  fileSize = s.size;
2847
3487
  } catch {
@@ -2894,8 +3534,8 @@ var ConversationCache = class _ConversationCache {
2894
3534
  let fileSize;
2895
3535
  let fd;
2896
3536
  try {
2897
- fileSize = (0, import_fs7.statSync)(filePath).size;
2898
- fd = (0, import_fs7.openSync)(filePath, "r");
3537
+ fileSize = (0, import_fs8.statSync)(filePath).size;
3538
+ fd = (0, import_fs8.openSync)(filePath, "r");
2899
3539
  } catch {
2900
3540
  return false;
2901
3541
  }
@@ -2908,7 +3548,7 @@ var ConversationCache = class _ConversationCache {
2908
3548
  while (pos > 0 && lines.length < this.tailSize * 4) {
2909
3549
  const toRead = Math.min(CHUNK, pos);
2910
3550
  pos -= toRead;
2911
- (0, import_fs7.readSync)(fd, buf, 0, toRead, pos);
3551
+ (0, import_fs8.readSync)(fd, buf, 0, toRead, pos);
2912
3552
  const chunk = buf.subarray(0, toRead).toString("utf8");
2913
3553
  const combined = chunk + partial;
2914
3554
  const parts = combined.split("\n");
@@ -2919,7 +3559,7 @@ var ConversationCache = class _ConversationCache {
2919
3559
  }
2920
3560
  if (partial) lines.push(partial);
2921
3561
  } finally {
2922
- (0, import_fs7.closeSync)(fd);
3562
+ (0, import_fs8.closeSync)(fd);
2923
3563
  }
2924
3564
  const msgs = [];
2925
3565
  for (let i = 0; i < lines.length && msgs.length < this.tailSize; i++) {
@@ -3106,7 +3746,7 @@ var ConversationCache = class _ConversationCache {
3106
3746
  * `handleGetConversation` can still serve the cached tail even when the
3107
3747
  * JSONL has been deleted.
3108
3748
  */
3109
- pruneGhostFiles(exists = import_fs7.existsSync) {
3749
+ pruneGhostFiles(exists = import_fs8.existsSync) {
3110
3750
  const rows = this.stmts.allFilePaths.all();
3111
3751
  const ghosts = [];
3112
3752
  const prune = this.db.transaction((ids) => {
@@ -3186,7 +3826,7 @@ var ConversationsRepository = class {
3186
3826
  };
3187
3827
 
3188
3828
  // src/db/repositories/projects.repository.ts
3189
- var import_crypto4 = require("crypto");
3829
+ var import_crypto5 = require("crypto");
3190
3830
 
3191
3831
  // src/utils/canonicalizeProjectPath.ts
3192
3832
  function canonicalizeProjectPath(projectPath) {
@@ -3279,7 +3919,7 @@ var ProjectsRepository = class {
3279
3919
  });
3280
3920
  return rowToProject(this.getById.get(existing.id));
3281
3921
  }
3282
- const id = (0, import_crypto4.randomUUID)();
3922
+ const id = (0, import_crypto5.randomUUID)();
3283
3923
  this.insert.run({
3284
3924
  id,
3285
3925
  path,
@@ -3335,23 +3975,23 @@ async function recordUpload(pool2, instanceId, row) {
3335
3975
  }
3336
3976
 
3337
3977
  // src/handlers/handleListProjects.ts
3338
- var import_fs8 = require("fs");
3978
+ var import_fs9 = require("fs");
3339
3979
  var import_os5 = require("os");
3340
- var import_path9 = require("path");
3980
+ var import_path11 = require("path");
3341
3981
  function decodeProjectPath(dirName) {
3342
3982
  return dirName.replace(/-/g, "/");
3343
3983
  }
3344
3984
  function handleListProjects(url, res) {
3345
3985
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
3346
3986
  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");
3987
+ const projectsDir = (0, import_path11.join)((0, import_os5.homedir)(), ".claude", "projects");
3348
3988
  let entries;
3349
3989
  try {
3350
- entries = (0, import_fs8.readdirSync)(projectsDir).map((dirName) => {
3351
- const fullPath = (0, import_path9.join)(projectsDir, dirName);
3990
+ entries = (0, import_fs9.readdirSync)(projectsDir).map((dirName) => {
3991
+ const fullPath = (0, import_path11.join)(projectsDir, dirName);
3352
3992
  let mtime = 0;
3353
3993
  try {
3354
- mtime = (0, import_fs8.statSync)(fullPath).mtimeMs;
3994
+ mtime = (0, import_fs9.statSync)(fullPath).mtimeMs;
3355
3995
  } catch {
3356
3996
  }
3357
3997
  const path = decodeProjectPath(dirName);
@@ -3370,7 +4010,7 @@ function handleListProjects(url, res) {
3370
4010
  }
3371
4011
 
3372
4012
  // src/pair-store.ts
3373
- var import_crypto5 = require("crypto");
4013
+ var import_crypto6 = require("crypto");
3374
4014
  var DEFAULT_TTL_SECONDS = 180;
3375
4015
  var SWEEP_INTERVAL_MS = 6e4;
3376
4016
  var PairTokenStore = class {
@@ -3385,7 +4025,7 @@ var PairTokenStore = class {
3385
4025
  }
3386
4026
  }
3387
4027
  mint() {
3388
- const token = `pt_${(0, import_crypto5.randomBytes)(16).toString("hex")}`;
4028
+ const token = `pt_${(0, import_crypto6.randomBytes)(16).toString("hex")}`;
3389
4029
  const expiresAt = Date.now() + this.ttlMs;
3390
4030
  this.current = { token, expiresAt, used: false };
3391
4031
  return {
@@ -3446,7 +4086,7 @@ function seal(plaintext, recipientPublicKeyBase64) {
3446
4086
 
3447
4087
  // src/services/conversations/conversationWatcher.ts
3448
4088
  var import_chokidar = __toESM(require("chokidar"), 1);
3449
- var import_fs9 = require("fs");
4089
+ var import_fs10 = require("fs");
3450
4090
  var import_promises3 = require("fs/promises");
3451
4091
  var ConversationWatcher = class {
3452
4092
  files = /* @__PURE__ */ new Map();
@@ -3467,7 +4107,7 @@ var ConversationWatcher = class {
3467
4107
  if (this.files.has(filePath)) return;
3468
4108
  let offset;
3469
4109
  try {
3470
- offset = (0, import_fs9.statSync)(filePath).size;
4110
+ offset = (0, import_fs10.statSync)(filePath).size;
3471
4111
  } catch {
3472
4112
  offset = 0;
3473
4113
  }
@@ -3576,14 +4216,14 @@ var ConversationWatcher = class {
3576
4216
  };
3577
4217
 
3578
4218
  // src/services/conversations/pruneAgentConversations.ts
3579
- var import_fs10 = require("fs");
4219
+ var import_fs11 = require("fs");
3580
4220
  function pruneAgentConversations(cache) {
3581
4221
  const db = cache.getDatabase();
3582
4222
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
3583
4223
  let pruned = 0;
3584
4224
  let missing = 0;
3585
4225
  for (const row of rows) {
3586
- if (!(0, import_fs10.existsSync)(row.file_path)) {
4226
+ if (!(0, import_fs11.existsSync)(row.file_path)) {
3587
4227
  missing += 1;
3588
4228
  continue;
3589
4229
  }
@@ -3866,6 +4506,7 @@ function managedToResponse(s, ptyAttached) {
3866
4506
  return {
3867
4507
  id: s.id,
3868
4508
  conversationId: s.id,
4509
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
3869
4510
  status: s.status,
3870
4511
  projectPath: s.projectPath,
3871
4512
  projectName: s.projectName,
@@ -3891,13 +4532,15 @@ function managedToResponse(s, ptyAttached) {
3891
4532
  ...s.failureReason != null && { failureReason: s.failureReason },
3892
4533
  ...s.resumedFromConversationId != null && {
3893
4534
  resumedFromConversationId: s.resumedFromConversationId
3894
- }
4535
+ },
4536
+ ...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
3895
4537
  };
3896
4538
  }
3897
4539
  function discoveredToResponse(d, conversationId) {
3898
4540
  return {
3899
4541
  id: conversationId,
3900
4542
  conversationId,
4543
+ provider: CLAUDE_CODE_PROVIDER,
3901
4544
  status: "idle",
3902
4545
  projectPath: d.projectPath,
3903
4546
  projectName: d.projectName,
@@ -3913,10 +4556,10 @@ function discoveredToResponse(d, conversationId) {
3913
4556
  }
3914
4557
 
3915
4558
  // src/uploads.ts
3916
- var import_crypto6 = require("crypto");
4559
+ var import_crypto7 = require("crypto");
3917
4560
  var import_promises4 = require("fs/promises");
3918
4561
  var import_heic_convert = __toESM(require("heic-convert"), 1);
3919
- var import_path10 = require("path");
4562
+ var import_path12 = require("path");
3920
4563
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
3921
4564
  var MAX_BYTES = 25 * 1024 * 1024;
3922
4565
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -3947,11 +4590,11 @@ async function saveUploadFile(input) {
3947
4590
  mimeType = "image/jpeg";
3948
4591
  originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
3949
4592
  }
3950
- const id = `up_${(0, import_crypto6.randomBytes)(8).toString("hex")}`;
4593
+ const id = `up_${(0, import_crypto7.randomBytes)(8).toString("hex")}`;
3951
4594
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
3952
- const dir = (0, import_path10.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
4595
+ const dir = (0, import_path12.join)(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
3953
4596
  await (0, import_promises4.mkdir)(dir, { recursive: true });
3954
- const filePath = (0, import_path10.join)(dir, `${Date.now()}-${id}-${safeName}`);
4597
+ const filePath = (0, import_path12.join)(dir, `${Date.now()}-${id}-${safeName}`);
3955
4598
  await (0, import_promises4.writeFile)(filePath, buffer);
3956
4599
  return {
3957
4600
  id,
@@ -4252,10 +4895,10 @@ var StreamerServer = class {
4252
4895
  this.verbose = config.verbose ?? false;
4253
4896
  this.disableDb = config.disableDb ?? false;
4254
4897
  this.scanProfiles = config.scanProfiles;
4255
- this.codexRoots = config.codexRoots ?? [(0, import_path11.join)((0, import_os6.homedir)(), ".codex", "sessions")];
4898
+ this.codexRoots = config.codexRoots ?? [(0, import_path13.join)((0, import_os6.homedir)(), ".codex", "sessions")];
4256
4899
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
4257
4900
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
4258
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path11.join)((0, import_os6.homedir)(), ".threadbase", "cache");
4901
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path13.join)((0, import_os6.homedir)(), ".threadbase", "cache");
4259
4902
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
4260
4903
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
4261
4904
  this.markScannerStaleDebounced = debounce(() => {
@@ -4339,7 +4982,7 @@ var StreamerServer = class {
4339
4982
  });
4340
4983
  }
4341
4984
  });
4342
- this.ptyManager = new PTYManager({
4985
+ this.ptyManager = new LiveSessionManager({
4343
4986
  logger: getLogger("pty"),
4344
4987
  onOutput: (sessionId, data) => {
4345
4988
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
@@ -4420,7 +5063,7 @@ var StreamerServer = class {
4420
5063
  temporalClient,
4421
5064
  taskQueue: agentConfig.temporal.taskQueue
4422
5065
  });
4423
- const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path11.join)((0, import_path11.dirname)(this.cacheDir), "conversations");
5066
+ const conversationsBaseDir = agentConfig.conversationsDir || (0, import_path13.join)((0, import_path13.dirname)(this.cacheDir), "conversations");
4424
5067
  conversationWriter = createConversationWriter({
4425
5068
  baseDir: conversationsBaseDir
4426
5069
  });
@@ -4642,7 +5285,7 @@ var StreamerServer = class {
4642
5285
  });
4643
5286
  try {
4644
5287
  this.cache = ConversationCache.open(
4645
- (0, import_path11.join)(this.cacheDir, "cache.db"),
5288
+ (0, import_path13.join)(this.cacheDir, "cache.db"),
4646
5289
  this.tailSize,
4647
5290
  void 0,
4648
5291
  {
@@ -4668,18 +5311,19 @@ var StreamerServer = class {
4668
5311
  if (this.scanProfiles && this.scanProfiles.length > 0) {
4669
5312
  for (const profile of this.scanProfiles) {
4670
5313
  if (profile.enabled) {
4671
- this.fileWatcher.watchDirectory((0, import_path11.join)(profile.configDir, "projects"));
5314
+ this.fileWatcher.watchDirectory((0, import_path13.join)(profile.configDir, "projects"));
4672
5315
  }
4673
5316
  }
4674
5317
  } else {
4675
- this.fileWatcher.watchDirectory((0, import_path11.join)((0, import_os6.homedir)(), ".claude", "projects"));
5318
+ this.fileWatcher.watchDirectory((0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects"));
4676
5319
  }
4677
5320
  } catch (err) {
4678
5321
  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
- });
5322
+ const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
5323
+ this.log.error(
5324
+ `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})`,
5325
+ { error: message, abiMismatch, event: "cache.open_failed" }
5326
+ );
4683
5327
  }
4684
5328
  const warmupScanner = new import_scanner2.ConversationScanner();
4685
5329
  this.allScanners.add(warmupScanner);
@@ -5166,17 +5810,17 @@ var StreamerServer = class {
5166
5810
  return this.getScanner();
5167
5811
  }
5168
5812
  findJsonlPath(uuid) {
5169
- const projectsDir = (0, import_path11.join)((0, import_os6.homedir)(), ".claude", "projects");
5170
- if (!(0, import_fs11.existsSync)(projectsDir)) return null;
5813
+ const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects");
5814
+ if (!(0, import_fs12.existsSync)(projectsDir)) return null;
5171
5815
  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);
5816
+ for (const dir of (0, import_fs12.readdirSync)(projectsDir)) {
5817
+ const fp = (0, import_path13.join)(projectsDir, dir, filename);
5818
+ if ((0, import_fs12.existsSync)(fp)) return fp;
5819
+ const projectDir = (0, import_path13.join)(projectsDir, dir);
5176
5820
  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;
5821
+ for (const sub of (0, import_fs12.readdirSync)(projectDir)) {
5822
+ const subagentPath = (0, import_path13.join)(projectDir, sub, "subagents", filename);
5823
+ if ((0, import_fs12.existsSync)(subagentPath)) return subagentPath;
5180
5824
  }
5181
5825
  } catch {
5182
5826
  }
@@ -5185,7 +5829,7 @@ var StreamerServer = class {
5185
5829
  }
5186
5830
  async readCwdFromJsonl(filePath) {
5187
5831
  return new Promise((resolve2) => {
5188
- const rl = (0, import_readline.createInterface)({ input: (0, import_fs11.createReadStream)(filePath), crlfDelay: Infinity });
5832
+ const rl = (0, import_readline.createInterface)({ input: (0, import_fs12.createReadStream)(filePath), crlfDelay: Infinity });
5189
5833
  let found = false;
5190
5834
  rl.on("line", (line) => {
5191
5835
  if (found) return;
@@ -5242,7 +5886,7 @@ var StreamerServer = class {
5242
5886
  if (!conv.filePath) return false;
5243
5887
  let mtimeMs = null;
5244
5888
  try {
5245
- mtimeMs = (0, import_fs11.statSync)(conv.filePath).mtimeMs;
5889
+ mtimeMs = (0, import_fs12.statSync)(conv.filePath).mtimeMs;
5246
5890
  } catch {
5247
5891
  return false;
5248
5892
  }
@@ -5485,7 +6129,7 @@ var StreamerServer = class {
5485
6129
  handleGetSession(sessionId, res) {
5486
6130
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
5487
6131
  if (session) {
5488
- if (!(0, import_fs11.existsSync)(session.projectPath)) {
6132
+ if (!(0, import_fs12.existsSync)(session.projectPath)) {
5489
6133
  session.failureReason = `Project directory not found: ${session.projectPath}`;
5490
6134
  }
5491
6135
  json(res, 200, session);
@@ -5525,7 +6169,10 @@ var StreamerServer = class {
5525
6169
  json(res, 400, { error: "Could not determine project path" });
5526
6170
  return;
5527
6171
  }
6172
+ const cachedConvMeta = this.cache?.getMetaById(sessionId);
6173
+ const provider = conv?.provider ?? cachedConvMeta?.provider ?? CLAUDE_CODE_PROVIDER;
5528
6174
  const session = await this.ptyManager.start(sessionId, {
6175
+ provider,
5529
6176
  projectPath,
5530
6177
  projectName: body.projectName,
5531
6178
  branch: body.branch
@@ -5875,7 +6522,7 @@ var StreamerServer = class {
5875
6522
  sessionStore: this.sessionStore,
5876
6523
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
5877
6524
  agentClient: this.agentClient,
5878
- conversationsDir: this.cacheDir ? (0, import_path11.join)((0, import_path11.dirname)(this.cacheDir), "conversations") : "",
6525
+ conversationsDir: this.cacheDir ? (0, import_path13.join)((0, import_path13.dirname)(this.cacheDir), "conversations") : "",
5879
6526
  agentConfig: this.agentConfig
5880
6527
  });
5881
6528
  json(res, result.status, result.body);
@@ -5884,6 +6531,13 @@ var StreamerServer = class {
5884
6531
  }
5885
6532
  return;
5886
6533
  }
6534
+ const body = await readBody(req);
6535
+ const { path: relativePath, provider: requestedProvider, systemPrompt: clientPrompt } = body;
6536
+ if (requestedProvider !== void 0 && !isProviderName(requestedProvider)) {
6537
+ json(res, 400, { error: "Invalid provider" });
6538
+ return;
6539
+ }
6540
+ const provider = requestedProvider ?? CLAUDE_CODE_PROVIDER;
5887
6541
  if (!this.browseRoot) {
5888
6542
  json(res, 403, {
5889
6543
  error: "File browsing not configured. Set browseRoot on the server.",
@@ -5891,8 +6545,6 @@ var StreamerServer = class {
5891
6545
  });
5892
6546
  return;
5893
6547
  }
5894
- const body = await readBody(req);
5895
- const { path: relativePath, systemPrompt: clientPrompt } = body;
5896
6548
  if (typeof relativePath !== "string") {
5897
6549
  json(res, 400, { error: "Missing path field" });
5898
6550
  return;
@@ -5913,21 +6565,27 @@ var StreamerServer = class {
5913
6565
  ].filter(Boolean);
5914
6566
  try {
5915
6567
  const session = await this.ptyManager.startFresh({
6568
+ provider,
5916
6569
  projectPath: resolvedPath,
5917
6570
  projectName: body.projectName,
5918
6571
  systemPrompt: systemPromptParts.join("\n")
5919
6572
  });
5920
6573
  this.sessionStore.addManaged(session);
5921
6574
  json(res, 202, { id: session.id, status: "pending" });
5922
- this.watchForJsonl(session.id, resolvedPath);
6575
+ if (provider === CODEX_CLI_PROVIDER) {
6576
+ this.watchForCodexRollout(session.id, resolvedPath);
6577
+ } else {
6578
+ this.watchForJsonl(session.id, resolvedPath);
6579
+ }
5923
6580
  this.broadcastOrUnicastSessionList(req);
5924
6581
  } catch (err) {
5925
6582
  const message = err instanceof Error ? err.message : "Failed to start session";
6583
+ const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
5926
6584
  this.log.error(`[start] failed to start session: ${message}`, {
5927
6585
  event: "session.start_failed",
5928
6586
  error: message
5929
6587
  });
5930
- json(res, 500, { error: message });
6588
+ json(res, statusCode, { error: message });
5931
6589
  }
5932
6590
  }
5933
6591
  // ─── Project linking ─────────────────────────────────────────────
@@ -5980,9 +6638,9 @@ var StreamerServer = class {
5980
6638
  // was passed to Claude via --session-id so the filename matches from the start.
5981
6639
  watchForJsonl(sessionId, projectPath) {
5982
6640
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
5983
- const projectsDir = (0, import_path11.join)((0, import_os6.homedir)(), ".claude", "projects", encoded);
6641
+ const projectsDir = (0, import_path13.join)((0, import_os6.homedir)(), ".claude", "projects", encoded);
5984
6642
  const expectedFile = `${sessionId}.jsonl`;
5985
- const filePath = (0, import_path11.join)(projectsDir, expectedFile);
6643
+ const filePath = (0, import_path13.join)(projectsDir, expectedFile);
5986
6644
  const deadline = Date.now() + 12e4;
5987
6645
  let watcher = null;
5988
6646
  const cleanup = () => {
@@ -6000,12 +6658,12 @@ var StreamerServer = class {
6000
6658
  cleanup();
6001
6659
  return;
6002
6660
  }
6003
- let resolvedFilePath = (0, import_fs11.existsSync)(filePath) ? filePath : null;
6004
- if (!resolvedFilePath && (0, import_fs11.existsSync)(projectsDir)) {
6661
+ let resolvedFilePath = (0, import_fs12.existsSync)(filePath) ? filePath : null;
6662
+ if (!resolvedFilePath && (0, import_fs12.existsSync)(projectsDir)) {
6005
6663
  try {
6006
6664
  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);
6665
+ 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];
6666
+ if (recent) resolvedFilePath = (0, import_path13.join)(projectsDir, recent.f);
6009
6667
  } catch {
6010
6668
  }
6011
6669
  }
@@ -6014,7 +6672,7 @@ var StreamerServer = class {
6014
6672
  this.sessionFileMap.set(sessionId, resolvedFilePath);
6015
6673
  this.fileWatcher.watch(resolvedFilePath);
6016
6674
  try {
6017
- const existing = (0, import_fs11.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
6675
+ const existing = (0, import_fs12.readFileSync)(resolvedFilePath, "utf8").split("\n").filter(Boolean);
6018
6676
  if (existing.length > 0) {
6019
6677
  this.wsHub.broadcast({ type: "conversation_events", sessionId, lines: existing });
6020
6678
  for (const line of existing) {
@@ -6040,11 +6698,126 @@ var StreamerServer = class {
6040
6698
  if (this.sessionFileMap.has(sessionId)) return;
6041
6699
  try {
6042
6700
  require("fs").mkdirSync(projectsDir, { recursive: true });
6043
- watcher = (0, import_fs11.watch)(projectsDir, tryWire);
6701
+ watcher = (0, import_fs12.watch)(projectsDir, tryWire);
6044
6702
  watcher.on("error", cleanup);
6045
6703
  } catch {
6046
6704
  }
6047
6705
  }
6706
+ // Codex-equivalent of watchForJsonl(). Differs because Codex has no
6707
+ // filename-encoded session id (it assigns its own persisted id) and its
6708
+ // rollout files live under a date-nested directory
6709
+ // (~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl) that Codex creates
6710
+ // itself — it may not exist yet when this function is first called, so we
6711
+ // poll rather than fs.watch a not-yet-existent directory. Per Phase 0
6712
+ // findings, the rollout file appears within ~1s of process spawn (after
6713
+ // any directory-trust gate is cleared), well before any user input.
6714
+ watchForCodexRollout(sessionId, projectPath) {
6715
+ const deadline = Date.now() + 12e4;
6716
+ const now = /* @__PURE__ */ new Date();
6717
+ const dateDir = (0, import_path13.join)(
6718
+ String(now.getFullYear()),
6719
+ String(now.getMonth() + 1).padStart(2, "0"),
6720
+ String(now.getDate()).padStart(2, "0")
6721
+ );
6722
+ const sessionStartedAtMs = (this.sessionStore.getManaged(sessionId)?.startedAt?.getTime() ?? Date.now()) - 5e3;
6723
+ let intervalHandle = null;
6724
+ const cleanup = () => {
6725
+ if (intervalHandle) clearInterval(intervalHandle);
6726
+ intervalHandle = null;
6727
+ };
6728
+ const matchesProjectPath = (candidatePath) => {
6729
+ try {
6730
+ const firstLine = (0, import_fs12.readFileSync)(candidatePath, "utf8").split("\n", 1)[0];
6731
+ if (!firstLine) return null;
6732
+ const parsed = JSON.parse(firstLine);
6733
+ if (parsed?.type !== "session_meta") return null;
6734
+ const payload = parsed.payload ?? {};
6735
+ if (payload.cwd !== projectPath) return null;
6736
+ if (typeof payload.id !== "string") return null;
6737
+ const createdIso = payload.timestamp ?? parsed.timestamp;
6738
+ const createdAtMs = typeof createdIso === "string" ? Date.parse(createdIso) : Number.NaN;
6739
+ if (Number.isNaN(createdAtMs) || createdAtMs < sessionStartedAtMs) return null;
6740
+ return { id: payload.id, createdAtMs };
6741
+ } catch {
6742
+ return null;
6743
+ }
6744
+ };
6745
+ const tryWire = () => {
6746
+ if (!this.ptyManager.hasSession(sessionId)) {
6747
+ cleanup();
6748
+ return;
6749
+ }
6750
+ if (Date.now() > deadline) {
6751
+ cleanup();
6752
+ return;
6753
+ }
6754
+ const boundElsewhere = new Set(
6755
+ this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
6756
+ );
6757
+ for (const root of this.codexRoots) {
6758
+ const sessionsDir = (0, import_path13.join)(root, dateDir);
6759
+ if (!(0, import_fs12.existsSync)(sessionsDir)) continue;
6760
+ let candidateFiles;
6761
+ try {
6762
+ candidateFiles = (0, import_fs12.readdirSync)(sessionsDir).filter((f) => f.endsWith(".jsonl"));
6763
+ } catch {
6764
+ continue;
6765
+ }
6766
+ const nowMs = Date.now();
6767
+ 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);
6768
+ for (const { f } of recentCandidates) {
6769
+ const candidatePath = (0, import_path13.join)(sessionsDir, f);
6770
+ const match = matchesProjectPath(candidatePath);
6771
+ if (!match) continue;
6772
+ if (boundElsewhere.has(match.id)) continue;
6773
+ const codexSessionId = match.id;
6774
+ cleanup();
6775
+ this.sessionStore.updateManaged(sessionId, { boundConversationId: codexSessionId });
6776
+ this.sessionFileMap.set(sessionId, candidatePath);
6777
+ this.fileWatcher.watch(candidatePath);
6778
+ try {
6779
+ const existing = (0, import_fs12.readFileSync)(candidatePath, "utf8").split("\n").filter(Boolean);
6780
+ if (existing.length > 0) {
6781
+ this.wsHub.broadcast({ type: "conversation_events", sessionId, lines: existing });
6782
+ for (const line of existing) {
6783
+ this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
6784
+ }
6785
+ }
6786
+ } catch {
6787
+ }
6788
+ if (this.scannerReady) {
6789
+ this.scannerStale = true;
6790
+ } else {
6791
+ this.scanner = null;
6792
+ }
6793
+ this.linkSessionToProject(sessionId, projectPath, candidatePath);
6794
+ this.cache?.markAsStreamer(sessionId);
6795
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
6796
+ if (resp) {
6797
+ this.wsHub.broadcast({ type: "session_update", session: resp });
6798
+ }
6799
+ this.log.info(
6800
+ `[startFresh] bound Codex rollout for ${sessionId}`,
6801
+ {
6802
+ event: "session.codex_rollout_bound",
6803
+ sessionId,
6804
+ boundConversationId: codexSessionId,
6805
+ filePath: candidatePath
6806
+ },
6807
+ "pino"
6808
+ );
6809
+ return;
6810
+ }
6811
+ }
6812
+ };
6813
+ tryWire();
6814
+ if (!intervalHandle && Date.now() <= deadline) {
6815
+ const alreadyBound = this.sessionStore.getManaged(sessionId)?.boundConversationId != null;
6816
+ if (!alreadyBound) {
6817
+ intervalHandle = setInterval(tryWire, 250);
6818
+ }
6819
+ }
6820
+ }
6048
6821
  async handleBrowse(url, res) {
6049
6822
  if (!this.browseRoot) {
6050
6823
  json(res, 403, {
@@ -6128,7 +6901,7 @@ var StreamerServer = class {
6128
6901
  };
6129
6902
  function classifyResumability(cwd) {
6130
6903
  if (!cwd) return { resumable: true };
6131
- if ((0, import_fs11.existsSync)(cwd)) return { resumable: true };
6904
+ if ((0, import_fs12.existsSync)(cwd)) return { resumable: true };
6132
6905
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
6133
6906
  return {
6134
6907
  resumable: false,
@@ -6260,7 +7033,10 @@ function readBody(req) {
6260
7033
  }
6261
7034
  // Annotate the CommonJS export names for ESM import in node:
6262
7035
  0 && (module.exports = {
7036
+ CLAUDE_CODE_PROVIDER,
7037
+ CODEX_CLI_PROVIDER,
6263
7038
  ConversationWatcher,
7039
+ LiveSessionManager,
6264
7040
  PTYManager,
6265
7041
  SessionStore,
6266
7042
  StreamerServer,
@@ -6274,6 +7050,8 @@ function readBody(req) {
6274
7050
  generateApiKey,
6275
7051
  getDbConfig,
6276
7052
  isDbEnabled,
7053
+ isProviderName,
7054
+ isProviderResumable,
6277
7055
  loadOrCreateApiKey,
6278
7056
  maskConnectionString,
6279
7057
  readAgentConfig,