@threadbase-sh/streamer 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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) {
@@ -2566,7 +3206,10 @@ var ConversationCache = class _ConversationCache {
2566
3206
  model = excluded.model,
2567
3207
  account = excluded.account,
2568
3208
  branch = excluded.branch,
2569
- message_count = excluded.message_count,
3209
+ -- message_count is incremented by live tailing but recounted from
3210
+ -- scratch by a scanner rescan; a stale rescan must not carry a live
3211
+ -- session's count backwards, so take the max instead of overwriting.
3212
+ message_count = MAX(conversation_meta.message_count, excluded.message_count),
2570
3213
  last_activity = excluded.last_activity,
2571
3214
  first_message = excluded.first_message,
2572
3215
  last_message = excluded.last_message,
@@ -2655,7 +3298,7 @@ var ConversationCache = class _ConversationCache {
2655
3298
  return this.agentEntrypoints;
2656
3299
  }
2657
3300
  static open(dbPath, tailSize = 10, migrationsDir, options) {
2658
- (0, import_fs7.mkdirSync)((0, import_path8.dirname)(dbPath), { recursive: true });
3301
+ (0, import_fs8.mkdirSync)((0, import_path10.dirname)(dbPath), { recursive: true });
2659
3302
  const db = new import_better_sqlite3.default(dbPath);
2660
3303
  db.pragma("journal_mode = WAL");
2661
3304
  db.pragma("foreign_keys = ON");
@@ -2838,11 +3481,12 @@ var ConversationCache = class _ConversationCache {
2838
3481
  let mtimeMs = null;
2839
3482
  let fileSize = null;
2840
3483
  try {
2841
- const s = (0, import_fs7.statSync)(m.filePath);
3484
+ const s = (0, import_fs8.statSync)(m.filePath);
2842
3485
  mtimeMs = s.mtimeMs;
2843
3486
  fileSize = s.size;
2844
3487
  } catch {
2845
3488
  }
3489
+ const seq = ++this.tailSeq;
2846
3490
  this.stmts.upsertFull.run({
2847
3491
  id,
2848
3492
  file_path: m.filePath,
@@ -2857,7 +3501,7 @@ var ConversationCache = class _ConversationCache {
2857
3501
  first_message: m.firstMessage ? JSON.stringify(m.firstMessage) : null,
2858
3502
  last_message: m.lastMessage ? JSON.stringify(m.lastMessage) : null,
2859
3503
  preview: m.preview ?? null,
2860
- updated_at: 0,
3504
+ updated_at: seq,
2861
3505
  mtime_ms: mtimeMs,
2862
3506
  file_size: fileSize,
2863
3507
  provider: m.provider ?? CLAUDE_CODE_PROVIDER
@@ -2890,8 +3534,8 @@ var ConversationCache = class _ConversationCache {
2890
3534
  let fileSize;
2891
3535
  let fd;
2892
3536
  try {
2893
- fileSize = (0, import_fs7.statSync)(filePath).size;
2894
- fd = (0, import_fs7.openSync)(filePath, "r");
3537
+ fileSize = (0, import_fs8.statSync)(filePath).size;
3538
+ fd = (0, import_fs8.openSync)(filePath, "r");
2895
3539
  } catch {
2896
3540
  return false;
2897
3541
  }
@@ -2904,7 +3548,7 @@ var ConversationCache = class _ConversationCache {
2904
3548
  while (pos > 0 && lines.length < this.tailSize * 4) {
2905
3549
  const toRead = Math.min(CHUNK, pos);
2906
3550
  pos -= toRead;
2907
- (0, import_fs7.readSync)(fd, buf, 0, toRead, pos);
3551
+ (0, import_fs8.readSync)(fd, buf, 0, toRead, pos);
2908
3552
  const chunk = buf.subarray(0, toRead).toString("utf8");
2909
3553
  const combined = chunk + partial;
2910
3554
  const parts = combined.split("\n");
@@ -2915,7 +3559,7 @@ var ConversationCache = class _ConversationCache {
2915
3559
  }
2916
3560
  if (partial) lines.push(partial);
2917
3561
  } finally {
2918
- (0, import_fs7.closeSync)(fd);
3562
+ (0, import_fs8.closeSync)(fd);
2919
3563
  }
2920
3564
  const msgs = [];
2921
3565
  for (let i = 0; i < lines.length && msgs.length < this.tailSize; i++) {
@@ -3074,9 +3718,25 @@ var ConversationCache = class _ConversationCache {
3074
3718
  this.fileIndex.clear();
3075
3719
  }
3076
3720
  }
3077
- invalidateByFilePath(filePath) {
3721
+ /**
3722
+ * Drop the cached row for a file. Two callers with opposite intent:
3723
+ * - a directory-watch "change" event (the file was appended to) — pass
3724
+ * `skipIfTailed: true`. A cached tail means the row is being actively
3725
+ * maintained from the file's real content by the live-tail
3726
+ * (updateFromLines) or warm-up path, fresher than any scanner-derived
3727
+ * view. Both watchers fire on the same append with no ordering guarantee;
3728
+ * without this guard the invalidate can land after the tail write and wipe
3729
+ * the just-cached row, flickering the conversation out of
3730
+ * /api/conversations on nearly every message (CRITICAL #2). The debounced
3731
+ * rescan still re-derives metadata, so skipping the eager drop loses
3732
+ * nothing.
3733
+ * - a genuine unlink (the file is gone) — leave `skipIfTailed` false so the
3734
+ * row is always removed, otherwise a deleted session ghosts in the cache.
3735
+ */
3736
+ invalidateByFilePath(filePath, opts) {
3078
3737
  const row = this.stmts.getIdByFilePath.get(filePath);
3079
3738
  if (!row) return null;
3739
+ if (opts?.skipIfTailed && this.stmts.hasTail.get(row.id)) return null;
3080
3740
  this.invalidate(row.id);
3081
3741
  return row.id;
3082
3742
  }
@@ -3086,7 +3746,7 @@ var ConversationCache = class _ConversationCache {
3086
3746
  * `handleGetConversation` can still serve the cached tail even when the
3087
3747
  * JSONL has been deleted.
3088
3748
  */
3089
- pruneGhostFiles(exists = import_fs7.existsSync) {
3749
+ pruneGhostFiles(exists = import_fs8.existsSync) {
3090
3750
  const rows = this.stmts.allFilePaths.all();
3091
3751
  const ghosts = [];
3092
3752
  const prune = this.db.transaction((ids) => {
@@ -3166,7 +3826,7 @@ var ConversationsRepository = class {
3166
3826
  };
3167
3827
 
3168
3828
  // src/db/repositories/projects.repository.ts
3169
- var import_crypto4 = require("crypto");
3829
+ var import_crypto5 = require("crypto");
3170
3830
 
3171
3831
  // src/utils/canonicalizeProjectPath.ts
3172
3832
  function canonicalizeProjectPath(projectPath) {
@@ -3259,7 +3919,7 @@ var ProjectsRepository = class {
3259
3919
  });
3260
3920
  return rowToProject(this.getById.get(existing.id));
3261
3921
  }
3262
- const id = (0, import_crypto4.randomUUID)();
3922
+ const id = (0, import_crypto5.randomUUID)();
3263
3923
  this.insert.run({
3264
3924
  id,
3265
3925
  path,
@@ -3315,23 +3975,23 @@ async function recordUpload(pool2, instanceId, row) {
3315
3975
  }
3316
3976
 
3317
3977
  // src/handlers/handleListProjects.ts
3318
- var import_fs8 = require("fs");
3978
+ var import_fs9 = require("fs");
3319
3979
  var import_os5 = require("os");
3320
- var import_path9 = require("path");
3980
+ var import_path11 = require("path");
3321
3981
  function decodeProjectPath(dirName) {
3322
3982
  return dirName.replace(/-/g, "/");
3323
3983
  }
3324
3984
  function handleListProjects(url, res) {
3325
3985
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
3326
3986
  const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
3327
- 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");
3328
3988
  let entries;
3329
3989
  try {
3330
- entries = (0, import_fs8.readdirSync)(projectsDir).map((dirName) => {
3331
- 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);
3332
3992
  let mtime = 0;
3333
3993
  try {
3334
- mtime = (0, import_fs8.statSync)(fullPath).mtimeMs;
3994
+ mtime = (0, import_fs9.statSync)(fullPath).mtimeMs;
3335
3995
  } catch {
3336
3996
  }
3337
3997
  const path = decodeProjectPath(dirName);
@@ -3350,7 +4010,7 @@ function handleListProjects(url, res) {
3350
4010
  }
3351
4011
 
3352
4012
  // src/pair-store.ts
3353
- var import_crypto5 = require("crypto");
4013
+ var import_crypto6 = require("crypto");
3354
4014
  var DEFAULT_TTL_SECONDS = 180;
3355
4015
  var SWEEP_INTERVAL_MS = 6e4;
3356
4016
  var PairTokenStore = class {
@@ -3365,7 +4025,7 @@ var PairTokenStore = class {
3365
4025
  }
3366
4026
  }
3367
4027
  mint() {
3368
- const token = `pt_${(0, import_crypto5.randomBytes)(16).toString("hex")}`;
4028
+ const token = `pt_${(0, import_crypto6.randomBytes)(16).toString("hex")}`;
3369
4029
  const expiresAt = Date.now() + this.ttlMs;
3370
4030
  this.current = { token, expiresAt, used: false };
3371
4031
  return {
@@ -3426,7 +4086,7 @@ function seal(plaintext, recipientPublicKeyBase64) {
3426
4086
 
3427
4087
  // src/services/conversations/conversationWatcher.ts
3428
4088
  var import_chokidar = __toESM(require("chokidar"), 1);
3429
- var import_fs9 = require("fs");
4089
+ var import_fs10 = require("fs");
3430
4090
  var import_promises3 = require("fs/promises");
3431
4091
  var ConversationWatcher = class {
3432
4092
  files = /* @__PURE__ */ new Map();
@@ -3447,7 +4107,7 @@ var ConversationWatcher = class {
3447
4107
  if (this.files.has(filePath)) return;
3448
4108
  let offset;
3449
4109
  try {
3450
- offset = (0, import_fs9.statSync)(filePath).size;
4110
+ offset = (0, import_fs10.statSync)(filePath).size;
3451
4111
  } catch {
3452
4112
  offset = 0;
3453
4113
  }
@@ -3556,14 +4216,14 @@ var ConversationWatcher = class {
3556
4216
  };
3557
4217
 
3558
4218
  // src/services/conversations/pruneAgentConversations.ts
3559
- var import_fs10 = require("fs");
4219
+ var import_fs11 = require("fs");
3560
4220
  function pruneAgentConversations(cache) {
3561
4221
  const db = cache.getDatabase();
3562
4222
  const rows = db.prepare("SELECT id, file_path FROM conversation_meta").all();
3563
4223
  let pruned = 0;
3564
4224
  let missing = 0;
3565
4225
  for (const row of rows) {
3566
- if (!(0, import_fs10.existsSync)(row.file_path)) {
4226
+ if (!(0, import_fs11.existsSync)(row.file_path)) {
3567
4227
  missing += 1;
3568
4228
  continue;
3569
4229
  }
@@ -3846,6 +4506,7 @@ function managedToResponse(s, ptyAttached) {
3846
4506
  return {
3847
4507
  id: s.id,
3848
4508
  conversationId: s.id,
4509
+ provider: s.provider ?? CLAUDE_CODE_PROVIDER,
3849
4510
  status: s.status,
3850
4511
  projectPath: s.projectPath,
3851
4512
  projectName: s.projectName,
@@ -3871,13 +4532,15 @@ function managedToResponse(s, ptyAttached) {
3871
4532
  ...s.failureReason != null && { failureReason: s.failureReason },
3872
4533
  ...s.resumedFromConversationId != null && {
3873
4534
  resumedFromConversationId: s.resumedFromConversationId
3874
- }
4535
+ },
4536
+ ...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
3875
4537
  };
3876
4538
  }
3877
4539
  function discoveredToResponse(d, conversationId) {
3878
4540
  return {
3879
4541
  id: conversationId,
3880
4542
  conversationId,
4543
+ provider: CLAUDE_CODE_PROVIDER,
3881
4544
  status: "idle",
3882
4545
  projectPath: d.projectPath,
3883
4546
  projectName: d.projectName,
@@ -3893,10 +4556,10 @@ function discoveredToResponse(d, conversationId) {
3893
4556
  }
3894
4557
 
3895
4558
  // src/uploads.ts
3896
- var import_crypto6 = require("crypto");
4559
+ var import_crypto7 = require("crypto");
3897
4560
  var import_promises4 = require("fs/promises");
3898
4561
  var import_heic_convert = __toESM(require("heic-convert"), 1);
3899
- var import_path10 = require("path");
4562
+ var import_path12 = require("path");
3900
4563
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
3901
4564
  var MAX_BYTES = 25 * 1024 * 1024;
3902
4565
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -3927,11 +4590,11 @@ async function saveUploadFile(input) {
3927
4590
  mimeType = "image/jpeg";
3928
4591
  originalName = originalName.replace(/\.(heic|heif)$/i, ".jpg");
3929
4592
  }
3930
- const id = `up_${(0, import_crypto6.randomBytes)(8).toString("hex")}`;
4593
+ const id = `up_${(0, import_crypto7.randomBytes)(8).toString("hex")}`;
3931
4594
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
3932
- 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);
3933
4596
  await (0, import_promises4.mkdir)(dir, { recursive: true });
3934
- const filePath = (0, import_path10.join)(dir, `${Date.now()}-${id}-${safeName}`);
4597
+ const filePath = (0, import_path12.join)(dir, `${Date.now()}-${id}-${safeName}`);
3935
4598
  await (0, import_promises4.writeFile)(filePath, buffer);
3936
4599
  return {
3937
4600
  id,
@@ -4232,10 +4895,10 @@ var StreamerServer = class {
4232
4895
  this.verbose = config.verbose ?? false;
4233
4896
  this.disableDb = config.disableDb ?? false;
4234
4897
  this.scanProfiles = config.scanProfiles;
4235
- 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")];
4236
4899
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
4237
4900
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
4238
- 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");
4239
4902
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
4240
4903
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
4241
4904
  this.markScannerStaleDebounced = debounce(() => {
@@ -4302,7 +4965,7 @@ var StreamerServer = class {
4302
4965
  }
4303
4966
  },
4304
4967
  onConversationChanged: (filePath) => {
4305
- this.cache?.invalidateByFilePath(filePath);
4968
+ this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
4306
4969
  this.markScannerStaleDebounced();
4307
4970
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
4308
4971
  filePath,
@@ -4319,7 +4982,7 @@ var StreamerServer = class {
4319
4982
  });
4320
4983
  }
4321
4984
  });
4322
- this.ptyManager = new PTYManager({
4985
+ this.ptyManager = new LiveSessionManager({
4323
4986
  logger: getLogger("pty"),
4324
4987
  onOutput: (sessionId, data) => {
4325
4988
  this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
@@ -4400,7 +5063,7 @@ var StreamerServer = class {
4400
5063
  temporalClient,
4401
5064
  taskQueue: agentConfig.temporal.taskQueue
4402
5065
  });
4403
- 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");
4404
5067
  conversationWriter = createConversationWriter({
4405
5068
  baseDir: conversationsBaseDir
4406
5069
  });
@@ -4622,7 +5285,7 @@ var StreamerServer = class {
4622
5285
  });
4623
5286
  try {
4624
5287
  this.cache = ConversationCache.open(
4625
- (0, import_path11.join)(this.cacheDir, "cache.db"),
5288
+ (0, import_path13.join)(this.cacheDir, "cache.db"),
4626
5289
  this.tailSize,
4627
5290
  void 0,
4628
5291
  {
@@ -4648,18 +5311,19 @@ var StreamerServer = class {
4648
5311
  if (this.scanProfiles && this.scanProfiles.length > 0) {
4649
5312
  for (const profile of this.scanProfiles) {
4650
5313
  if (profile.enabled) {
4651
- this.fileWatcher.watchDirectory((0, import_path11.join)(profile.configDir, "projects"));
5314
+ this.fileWatcher.watchDirectory((0, import_path13.join)(profile.configDir, "projects"));
4652
5315
  }
4653
5316
  }
4654
5317
  } else {
4655
- 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"));
4656
5319
  }
4657
5320
  } catch (err) {
4658
5321
  const message = err instanceof Error ? err.message : String(err);
4659
- this.log.warn(`ConversationCache failed to open (running without cache): ${message}`, {
4660
- error: message,
4661
- event: "cache.open_failed"
4662
- });
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
+ );
4663
5327
  }
4664
5328
  const warmupScanner = new import_scanner2.ConversationScanner();
4665
5329
  this.allScanners.add(warmupScanner);
@@ -5146,17 +5810,17 @@ var StreamerServer = class {
5146
5810
  return this.getScanner();
5147
5811
  }
5148
5812
  findJsonlPath(uuid) {
5149
- const projectsDir = (0, import_path11.join)((0, import_os6.homedir)(), ".claude", "projects");
5150
- 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;
5151
5815
  const filename = `${uuid}.jsonl`;
5152
- for (const dir of (0, import_fs11.readdirSync)(projectsDir)) {
5153
- const fp = (0, import_path11.join)(projectsDir, dir, filename);
5154
- if ((0, import_fs11.existsSync)(fp)) return fp;
5155
- 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);
5156
5820
  try {
5157
- for (const sub of (0, import_fs11.readdirSync)(projectDir)) {
5158
- const subagentPath = (0, import_path11.join)(projectDir, sub, "subagents", filename);
5159
- 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;
5160
5824
  }
5161
5825
  } catch {
5162
5826
  }
@@ -5165,7 +5829,7 @@ var StreamerServer = class {
5165
5829
  }
5166
5830
  async readCwdFromJsonl(filePath) {
5167
5831
  return new Promise((resolve2) => {
5168
- 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 });
5169
5833
  let found = false;
5170
5834
  rl.on("line", (line) => {
5171
5835
  if (found) return;
@@ -5222,7 +5886,7 @@ var StreamerServer = class {
5222
5886
  if (!conv.filePath) return false;
5223
5887
  let mtimeMs = null;
5224
5888
  try {
5225
- mtimeMs = (0, import_fs11.statSync)(conv.filePath).mtimeMs;
5889
+ mtimeMs = (0, import_fs12.statSync)(conv.filePath).mtimeMs;
5226
5890
  } catch {
5227
5891
  return false;
5228
5892
  }
@@ -5465,7 +6129,7 @@ var StreamerServer = class {
5465
6129
  handleGetSession(sessionId, res) {
5466
6130
  const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
5467
6131
  if (session) {
5468
- if (!(0, import_fs11.existsSync)(session.projectPath)) {
6132
+ if (!(0, import_fs12.existsSync)(session.projectPath)) {
5469
6133
  session.failureReason = `Project directory not found: ${session.projectPath}`;
5470
6134
  }
5471
6135
  json(res, 200, session);
@@ -5505,7 +6169,10 @@ var StreamerServer = class {
5505
6169
  json(res, 400, { error: "Could not determine project path" });
5506
6170
  return;
5507
6171
  }
6172
+ const cachedConvMeta = this.cache?.getMetaById(sessionId);
6173
+ const provider = conv?.provider ?? cachedConvMeta?.provider ?? CLAUDE_CODE_PROVIDER;
5508
6174
  const session = await this.ptyManager.start(sessionId, {
6175
+ provider,
5509
6176
  projectPath,
5510
6177
  projectName: body.projectName,
5511
6178
  branch: body.branch
@@ -5855,7 +6522,7 @@ var StreamerServer = class {
5855
6522
  sessionStore: this.sessionStore,
5856
6523
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
5857
6524
  agentClient: this.agentClient,
5858
- 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") : "",
5859
6526
  agentConfig: this.agentConfig
5860
6527
  });
5861
6528
  json(res, result.status, result.body);
@@ -5864,6 +6531,13 @@ var StreamerServer = class {
5864
6531
  }
5865
6532
  return;
5866
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;
5867
6541
  if (!this.browseRoot) {
5868
6542
  json(res, 403, {
5869
6543
  error: "File browsing not configured. Set browseRoot on the server.",
@@ -5871,8 +6545,6 @@ var StreamerServer = class {
5871
6545
  });
5872
6546
  return;
5873
6547
  }
5874
- const body = await readBody(req);
5875
- const { path: relativePath, systemPrompt: clientPrompt } = body;
5876
6548
  if (typeof relativePath !== "string") {
5877
6549
  json(res, 400, { error: "Missing path field" });
5878
6550
  return;
@@ -5893,21 +6565,27 @@ var StreamerServer = class {
5893
6565
  ].filter(Boolean);
5894
6566
  try {
5895
6567
  const session = await this.ptyManager.startFresh({
6568
+ provider,
5896
6569
  projectPath: resolvedPath,
5897
6570
  projectName: body.projectName,
5898
6571
  systemPrompt: systemPromptParts.join("\n")
5899
6572
  });
5900
6573
  this.sessionStore.addManaged(session);
5901
6574
  json(res, 202, { id: session.id, status: "pending" });
5902
- 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
+ }
5903
6580
  this.broadcastOrUnicastSessionList(req);
5904
6581
  } catch (err) {
5905
6582
  const message = err instanceof Error ? err.message : "Failed to start session";
6583
+ const statusCode = typeof err.statusCode === "number" ? err.statusCode : 500;
5906
6584
  this.log.error(`[start] failed to start session: ${message}`, {
5907
6585
  event: "session.start_failed",
5908
6586
  error: message
5909
6587
  });
5910
- json(res, 500, { error: message });
6588
+ json(res, statusCode, { error: message });
5911
6589
  }
5912
6590
  }
5913
6591
  // ─── Project linking ─────────────────────────────────────────────
@@ -5960,9 +6638,9 @@ var StreamerServer = class {
5960
6638
  // was passed to Claude via --session-id so the filename matches from the start.
5961
6639
  watchForJsonl(sessionId, projectPath) {
5962
6640
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
5963
- 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);
5964
6642
  const expectedFile = `${sessionId}.jsonl`;
5965
- const filePath = (0, import_path11.join)(projectsDir, expectedFile);
6643
+ const filePath = (0, import_path13.join)(projectsDir, expectedFile);
5966
6644
  const deadline = Date.now() + 12e4;
5967
6645
  let watcher = null;
5968
6646
  const cleanup = () => {
@@ -5980,12 +6658,12 @@ var StreamerServer = class {
5980
6658
  cleanup();
5981
6659
  return;
5982
6660
  }
5983
- let resolvedFilePath = (0, import_fs11.existsSync)(filePath) ? filePath : null;
5984
- 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)) {
5985
6663
  try {
5986
6664
  const now = Date.now();
5987
- 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];
5988
- 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);
5989
6667
  } catch {
5990
6668
  }
5991
6669
  }
@@ -5994,7 +6672,7 @@ var StreamerServer = class {
5994
6672
  this.sessionFileMap.set(sessionId, resolvedFilePath);
5995
6673
  this.fileWatcher.watch(resolvedFilePath);
5996
6674
  try {
5997
- 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);
5998
6676
  if (existing.length > 0) {
5999
6677
  this.wsHub.broadcast({ type: "conversation_events", sessionId, lines: existing });
6000
6678
  for (const line of existing) {
@@ -6020,11 +6698,126 @@ var StreamerServer = class {
6020
6698
  if (this.sessionFileMap.has(sessionId)) return;
6021
6699
  try {
6022
6700
  require("fs").mkdirSync(projectsDir, { recursive: true });
6023
- watcher = (0, import_fs11.watch)(projectsDir, tryWire);
6701
+ watcher = (0, import_fs12.watch)(projectsDir, tryWire);
6024
6702
  watcher.on("error", cleanup);
6025
6703
  } catch {
6026
6704
  }
6027
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
+ }
6028
6821
  async handleBrowse(url, res) {
6029
6822
  if (!this.browseRoot) {
6030
6823
  json(res, 403, {
@@ -6108,7 +6901,7 @@ var StreamerServer = class {
6108
6901
  };
6109
6902
  function classifyResumability(cwd) {
6110
6903
  if (!cwd) return { resumable: true };
6111
- if ((0, import_fs11.existsSync)(cwd)) return { resumable: true };
6904
+ if ((0, import_fs12.existsSync)(cwd)) return { resumable: true };
6112
6905
  const ranInWorktree = /\/\.worktrees\//.test(cwd) || /\/\.claude\/worktrees\//.test(cwd);
6113
6906
  return {
6114
6907
  resumable: false,
@@ -6240,7 +7033,10 @@ function readBody(req) {
6240
7033
  }
6241
7034
  // Annotate the CommonJS export names for ESM import in node:
6242
7035
  0 && (module.exports = {
7036
+ CLAUDE_CODE_PROVIDER,
7037
+ CODEX_CLI_PROVIDER,
6243
7038
  ConversationWatcher,
7039
+ LiveSessionManager,
6244
7040
  PTYManager,
6245
7041
  SessionStore,
6246
7042
  StreamerServer,
@@ -6254,6 +7050,8 @@ function readBody(req) {
6254
7050
  generateApiKey,
6255
7051
  getDbConfig,
6256
7052
  isDbEnabled,
7053
+ isProviderName,
7054
+ isProviderResumable,
6257
7055
  loadOrCreateApiKey,
6258
7056
  maskConnectionString,
6259
7057
  readAgentConfig,