@threadbase-sh/streamer 1.43.0 → 1.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -102,14 +102,14 @@ function createConversationWriter(opts) {
102
102
  }
103
103
  const file = join(baseDir, `${args.sessionId}.jsonl`);
104
104
  await mkdir(dirname(file), { recursive: true });
105
- const record = {
105
+ const record2 = {
106
106
  role: "assistant",
107
107
  turnId: args.turnId,
108
108
  content: args.content,
109
109
  timestamp: Date.now(),
110
110
  ...args.reviewerOverruled ? { reviewerOverruled: true } : {}
111
111
  };
112
- const line = `${JSON.stringify(record)}
112
+ const line = `${JSON.stringify(record2)}
113
113
  `;
114
114
  await appendFile(file, line, { encoding: "utf8" });
115
115
  }
@@ -416,6 +416,9 @@ var baseLogger = pino({
416
416
  censor: "[redacted]"
417
417
  }
418
418
  });
419
+ function defaultDest() {
420
+ return process.stdout.isTTY ? "console" : "pino";
421
+ }
419
422
  function emit(pinoChild, level, msg, fields, dest) {
420
423
  if (dest === "pino" || dest === "both") {
421
424
  if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
@@ -428,11 +431,11 @@ function emit(pinoChild, level, msg, fields, dest) {
428
431
  }
429
432
  function build(pinoChild) {
430
433
  return {
431
- debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
432
- info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
433
- warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
434
- error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
435
- log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
434
+ debug: (m, f, d = defaultDest()) => emit(pinoChild, "debug", m, f, d),
435
+ info: (m, f, d = defaultDest()) => emit(pinoChild, "info", m, f, d),
436
+ warn: (m, f, d = defaultDest()) => emit(pinoChild, "warn", m, f, d),
437
+ error: (m, f, d = defaultDest()) => emit(pinoChild, "error", m, f, d),
438
+ log: (lvl, m, f, d = defaultDest()) => emit(pinoChild, lvl, m, f, d),
436
439
  pino: pinoChild
437
440
  };
438
441
  }
@@ -454,6 +457,12 @@ var FEATURE_FLAGS = [
454
457
  description: "Seed the session list at boot with sessions a previous streamer run left behind, so a restart leaves them one tap from resuming instead of silently gone. On by default, with a kill switch: it changes what GET /api/sessions contains.",
455
458
  default: true,
456
459
  env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
460
+ },
461
+ {
462
+ id: "ptyHost",
463
+ description: "Keep live PTYs in a separate host process so a streamer restart can reconnect without restarting the agents. Off by default until cross-platform behavior is qualified.",
464
+ default: false,
465
+ env: "THREADBASE_FEATURE_PTY_HOST"
457
466
  }
458
467
  ];
459
468
  function findFeatureFlag(id) {
@@ -819,7 +828,16 @@ import { existsSync } from "fs";
819
828
  import { homedir as homedir2, platform } from "os";
820
829
  import { join as join4 } from "path";
821
830
  var isWindows = platform() === "win32";
831
+ var WINDOWS_EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([".exe", ".cmd", ".bat"]);
832
+ function isWindowsExecutablePath(path) {
833
+ const dot = path.lastIndexOf(".");
834
+ if (dot < 0) return false;
835
+ return WINDOWS_EXECUTABLE_EXTENSIONS.has(path.slice(dot).toLowerCase());
836
+ }
822
837
  var _claudeExe;
838
+ function clearClaudeExeCache() {
839
+ _claudeExe = void 0;
840
+ }
823
841
  function resolveClaudeExe() {
824
842
  if (_claudeExe !== void 0) return _claudeExe;
825
843
  if (isWindows) {
@@ -828,7 +846,7 @@ function resolveClaudeExe() {
828
846
  encoding: "utf-8",
829
847
  windowsHide: true,
830
848
  timeout: 3e3
831
- }).trim().split("\n")[0].trim();
849
+ }).trim().split("\n").map((line) => line.trim()).find(isWindowsExecutablePath);
832
850
  if (found) {
833
851
  _claudeExe = found;
834
852
  return _claudeExe;
@@ -878,6 +896,9 @@ function resolveClaudeExe() {
878
896
  return _claudeExe;
879
897
  }
880
898
  var _codexExe;
899
+ function clearCodexExeCache() {
900
+ _codexExe = void 0;
901
+ }
881
902
  function resolveCodexExe() {
882
903
  if (_codexExe !== void 0) return _codexExe;
883
904
  if (isWindows) {
@@ -886,7 +907,7 @@ function resolveCodexExe() {
886
907
  encoding: "utf-8",
887
908
  windowsHide: true,
888
909
  timeout: 3e3
889
- }).trim().split("\n")[0].trim();
910
+ }).trim().split("\n").map((line) => line.trim()).find(isWindowsExecutablePath);
890
911
  if (found) {
891
912
  _codexExe = found;
892
913
  return _codexExe;
@@ -1146,20 +1167,26 @@ var CodexPtyRunner = class {
1146
1167
  async doStart(sessionId, options) {
1147
1168
  const nodePty = await loadPty();
1148
1169
  const projectName = options.projectName ?? basename(options.projectPath);
1149
- const proc = nodePty.spawn(
1150
- resolveCodexExe(),
1151
- // `sessionId` stays the runner's map key — only argv carries the
1152
- // provider-side id, so a resumed Codex session keeps the placeholder id
1153
- // its client already navigated to.
1154
- ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1155
- {
1156
- name: "xterm-256color",
1157
- cols: PTY_COLS,
1158
- rows: PTY_ROWS,
1159
- cwd: options.projectPath,
1160
- env: process.env
1161
- }
1162
- );
1170
+ let proc;
1171
+ try {
1172
+ proc = nodePty.spawn(
1173
+ resolveCodexExe(),
1174
+ // `sessionId` stays the runner's map key — only argv carries the
1175
+ // provider-side id, so a resumed Codex session keeps the placeholder id
1176
+ // its client already navigated to.
1177
+ ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1178
+ {
1179
+ name: "xterm-256color",
1180
+ cols: PTY_COLS,
1181
+ rows: PTY_ROWS,
1182
+ cwd: options.projectPath,
1183
+ env: process.env
1184
+ }
1185
+ );
1186
+ } catch (err) {
1187
+ clearCodexExeCache();
1188
+ throw err;
1189
+ }
1163
1190
  const session = {
1164
1191
  id: sessionId,
1165
1192
  provider: CODEX_CLI_PROVIDER,
@@ -1202,13 +1229,19 @@ var CodexPtyRunner = class {
1202
1229
  if (options.systemPrompt) {
1203
1230
  args.push(options.systemPrompt);
1204
1231
  }
1205
- const proc = nodePty.spawn(resolveCodexExe(), args, {
1206
- name: "xterm-256color",
1207
- cols: PTY_COLS,
1208
- rows: PTY_ROWS,
1209
- cwd: options.projectPath,
1210
- env: process.env
1211
- });
1232
+ let proc;
1233
+ try {
1234
+ proc = nodePty.spawn(resolveCodexExe(), args, {
1235
+ name: "xterm-256color",
1236
+ cols: PTY_COLS,
1237
+ rows: PTY_ROWS,
1238
+ cwd: options.projectPath,
1239
+ env: process.env
1240
+ });
1241
+ } catch (err) {
1242
+ clearCodexExeCache();
1243
+ throw err;
1244
+ }
1212
1245
  const session = {
1213
1246
  id: sessionId,
1214
1247
  provider: CODEX_CLI_PROVIDER,
@@ -1683,6 +1716,381 @@ function stripAnsi(str) {
1683
1716
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
1684
1717
  }
1685
1718
 
1719
+ // src/pty-host/protocol.ts
1720
+ var PTY_HOST_PROTOCOL_VERSION = 2;
1721
+ function isHostEvent(message) {
1722
+ return "type" in message && message.type === "event";
1723
+ }
1724
+ var SESSION_DATE_FIELDS = [
1725
+ "startedAt",
1726
+ "completedAt",
1727
+ "statusUpdatedAt",
1728
+ "lastActivityAt",
1729
+ "firstMessageAt",
1730
+ "lastMessageAt"
1731
+ ];
1732
+ function reviveSession(raw) {
1733
+ const s = { ...raw };
1734
+ for (const field of SESSION_DATE_FIELDS) {
1735
+ const value = s[field];
1736
+ if (typeof value === "string") s[field] = new Date(value);
1737
+ }
1738
+ return s;
1739
+ }
1740
+ function encodeMessage(message) {
1741
+ return `${JSON.stringify(message)}
1742
+ `;
1743
+ }
1744
+ var LineDecoder = class {
1745
+ buffer = "";
1746
+ push(chunk) {
1747
+ this.buffer += chunk;
1748
+ const lines = this.buffer.split("\n");
1749
+ this.buffer = lines.pop() ?? "";
1750
+ return lines.filter((line) => line.length > 0);
1751
+ }
1752
+ };
1753
+
1754
+ // src/pty-host/remote-session-runner.ts
1755
+ var PtyHostProtocolMismatchError = class extends Error {
1756
+ constructor(hostVersion, streamerVersion) {
1757
+ super(
1758
+ `pty-host protocol ${hostVersion} is incompatible with streamer protocol ${streamerVersion}`
1759
+ );
1760
+ this.hostVersion = hostVersion;
1761
+ this.streamerVersion = streamerVersion;
1762
+ this.name = "PtyHostProtocolMismatchError";
1763
+ }
1764
+ hostVersion;
1765
+ streamerVersion;
1766
+ };
1767
+ var HOST_HEARTBEAT_INTERVAL_MS = 1e4;
1768
+ var HOST_HEARTBEAT_REQUEST_TIMEOUT_MS = 5e3;
1769
+ var HOST_SHUTDOWN_REQUEST_TIMEOUT_MS = 1e3;
1770
+ var RemoteSessionRunner = class _RemoteSessionRunner {
1771
+ transport;
1772
+ options;
1773
+ decoder = new LineDecoder();
1774
+ nextRequestId = 1;
1775
+ pending = /* @__PURE__ */ new Map();
1776
+ /** The mirror. Rebuilt wholesale by `status`, patched by events. */
1777
+ sessions = /* @__PURE__ */ new Map();
1778
+ /** Ring buffers, fed by `output` events so `getOutput` stays synchronous. */
1779
+ output = /* @__PURE__ */ new Map();
1780
+ inputHistory = /* @__PURE__ */ new Map();
1781
+ /** Fixed for a session's lifetime, so only spawn and status carry it. */
1782
+ pids = /* @__PURE__ */ new Map();
1783
+ closed = false;
1784
+ heartbeatTimer = null;
1785
+ heartbeatInFlight = false;
1786
+ /**
1787
+ * The only supported way to build one: a runner whose mirror has not been
1788
+ * seeded yet would answer `hasSession` with a confident, wrong `false` for
1789
+ * every session the host is holding — which reads as "the agent is gone" and
1790
+ * routes the user to start a new one.
1791
+ */
1792
+ static async connect(transport, options = {}) {
1793
+ const runner = new _RemoteSessionRunner(transport, options);
1794
+ const status = await runner.readStatus();
1795
+ if (status.protocolVersion !== PTY_HOST_PROTOCOL_VERSION) {
1796
+ try {
1797
+ await runner.request({ type: "shutdown-host" }, HOST_SHUTDOWN_REQUEST_TIMEOUT_MS);
1798
+ } catch (err) {
1799
+ options.logger?.warn("[pty-host] incompatible host did not acknowledge shutdown", {
1800
+ event: "pty_host.shutdown_failed",
1801
+ err
1802
+ });
1803
+ } finally {
1804
+ runner.dispose();
1805
+ }
1806
+ throw new PtyHostProtocolMismatchError(status.protocolVersion, PTY_HOST_PROTOCOL_VERSION);
1807
+ }
1808
+ await runner.request({ type: "subscribe" });
1809
+ runner.refreshMirror(status);
1810
+ return runner;
1811
+ }
1812
+ constructor(transport, options) {
1813
+ this.transport = transport;
1814
+ this.options = options;
1815
+ transport.onLine((line) => this.handleLine(line));
1816
+ transport.onClose(() => this.handleClose());
1817
+ }
1818
+ // ─── Transport plumbing ──────────────────────────────────────────
1819
+ handleLine(line) {
1820
+ for (const complete of this.decoder.push(line)) {
1821
+ let message;
1822
+ try {
1823
+ message = JSON.parse(complete);
1824
+ } catch {
1825
+ this.options.logger?.warn("[pty-host] dropped unparseable message", {
1826
+ event: "pty_host.bad_message"
1827
+ });
1828
+ continue;
1829
+ }
1830
+ if (isHostEvent(message)) {
1831
+ this.handleEvent(message);
1832
+ continue;
1833
+ }
1834
+ const waiter = this.pending.get(message.id);
1835
+ if (!waiter) continue;
1836
+ this.pending.delete(message.id);
1837
+ if (waiter.timeout) clearTimeout(waiter.timeout);
1838
+ if (message.ok) waiter.resolve(message.result);
1839
+ else waiter.reject(new Error(message.error));
1840
+ }
1841
+ }
1842
+ /**
1843
+ * Fail every in-flight request when the socket drops.
1844
+ *
1845
+ * Without this each one stays pending forever and the caller — a session
1846
+ * start, an input write — hangs rather than erroring. PR 9 adds reconnection;
1847
+ * until then a dropped host is a hard failure that says so.
1848
+ */
1849
+ handleClose() {
1850
+ if (this.closed) return;
1851
+ this.closed = true;
1852
+ this.stopHeartbeat();
1853
+ const err = new Error("pty-host connection closed");
1854
+ for (const waiter of this.pending.values()) {
1855
+ if (waiter.timeout) clearTimeout(waiter.timeout);
1856
+ waiter.reject(err);
1857
+ }
1858
+ this.pending.clear();
1859
+ }
1860
+ request(body, timeoutMs) {
1861
+ if (this.closed) return Promise.reject(new Error("pty-host connection closed"));
1862
+ const id = this.nextRequestId++;
1863
+ return new Promise((resolve2, reject) => {
1864
+ const timeout = timeoutMs === void 0 ? null : setTimeout(() => {
1865
+ if (!this.pending.delete(id)) return;
1866
+ reject(new Error(`${body.type} timed out after ${timeoutMs}ms`));
1867
+ }, timeoutMs);
1868
+ timeout?.unref?.();
1869
+ this.pending.set(id, { resolve: resolve2, reject, timeout });
1870
+ this.transport.send(encodeMessage({ ...body, id }));
1871
+ });
1872
+ }
1873
+ /**
1874
+ * Fire-and-forget for the synchronous parts of `SessionRunner`.
1875
+ *
1876
+ * `sendKeys`, `cancel`, `killPid` and `putOnHold` all return void, so there is
1877
+ * no channel to report a failure through even if we waited for one. The
1878
+ * response is still consumed — an unhandled rejection would take the process
1879
+ * down over a keystroke that failed to land.
1880
+ */
1881
+ fireAndForget(body) {
1882
+ this.request(body).catch((err) => {
1883
+ this.options.logger?.warn("[pty-host] request failed", {
1884
+ event: "pty_host.request_failed",
1885
+ type: body.type,
1886
+ err
1887
+ });
1888
+ });
1889
+ }
1890
+ async readStatus() {
1891
+ return await this.request({ type: "status" });
1892
+ }
1893
+ refreshMirror(status) {
1894
+ this.sessions = /* @__PURE__ */ new Map();
1895
+ this.pids = /* @__PURE__ */ new Map();
1896
+ for (const entry of status.sessions) {
1897
+ const session = reviveSession(entry.session);
1898
+ this.sessions.set(session.id, session);
1899
+ this.pids.set(session.id, entry.pid);
1900
+ }
1901
+ }
1902
+ async heartbeat(state, timeoutMs = HOST_HEARTBEAT_REQUEST_TIMEOUT_MS) {
1903
+ await this.request({ type: "heartbeat", ...state }, timeoutMs);
1904
+ }
1905
+ startHeartbeat(getState, intervalMs = HOST_HEARTBEAT_INTERVAL_MS) {
1906
+ this.stopHeartbeat();
1907
+ const send = () => {
1908
+ if (this.closed || this.heartbeatInFlight) return;
1909
+ this.heartbeatInFlight = true;
1910
+ void Promise.resolve().then(() => this.heartbeat(getState())).catch((err) => {
1911
+ if (this.closed) return;
1912
+ this.options.logger?.warn("[pty-host] heartbeat failed", {
1913
+ event: "pty_host.heartbeat_failed",
1914
+ err
1915
+ });
1916
+ }).finally(() => {
1917
+ this.heartbeatInFlight = false;
1918
+ });
1919
+ };
1920
+ send();
1921
+ this.heartbeatTimer = setInterval(send, intervalMs);
1922
+ this.heartbeatTimer.unref?.();
1923
+ }
1924
+ stopHeartbeat() {
1925
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
1926
+ this.heartbeatTimer = null;
1927
+ }
1928
+ // ─── Events ──────────────────────────────────────────────────────
1929
+ handleEvent(event) {
1930
+ switch (event.event) {
1931
+ case "output": {
1932
+ this.output.set(event.sessionId, (this.output.get(event.sessionId) ?? "") + event.data);
1933
+ this.options.onOutput?.(event.sessionId, event.data);
1934
+ break;
1935
+ }
1936
+ case "status-change": {
1937
+ const session = reviveSession(event.session);
1938
+ if (session.status === "idle" && session.completedAt != null) {
1939
+ this.sessions.delete(session.id);
1940
+ this.pids.delete(session.id);
1941
+ this.output.delete(session.id);
1942
+ this.inputHistory.delete(session.id);
1943
+ } else {
1944
+ this.sessions.set(session.id, session);
1945
+ }
1946
+ this.options.onStatusChange?.(session);
1947
+ break;
1948
+ }
1949
+ case "ready": {
1950
+ const session = reviveSession(event.session);
1951
+ this.sessions.set(session.id, session);
1952
+ this.options.onReady?.(session);
1953
+ break;
1954
+ }
1955
+ case "permission-change":
1956
+ this.options.onPermissionChange?.(event.sessionId, event.gate);
1957
+ break;
1958
+ case "live-question":
1959
+ this.options.onLiveQuestion?.(event.sessionId, event.questions);
1960
+ break;
1961
+ case "live-question-gone":
1962
+ this.options.onLiveQuestionGone?.(event.sessionId);
1963
+ break;
1964
+ case "user-message": {
1965
+ const history = this.inputHistory.get(event.sessionId) ?? [];
1966
+ history.push({ text: event.text, ts: event.ts });
1967
+ this.inputHistory.set(event.sessionId, history);
1968
+ this.options.onUserMessage?.(event.sessionId, event.text, event.ts);
1969
+ break;
1970
+ }
1971
+ case "exit": {
1972
+ this.sessions.delete(event.sessionId);
1973
+ this.pids.delete(event.sessionId);
1974
+ this.output.delete(event.sessionId);
1975
+ this.inputHistory.delete(event.sessionId);
1976
+ break;
1977
+ }
1978
+ }
1979
+ }
1980
+ // ─── SessionRunner ───────────────────────────────────────────────
1981
+ async start(sessionId, options) {
1982
+ const provider = options.provider ?? "claude-code";
1983
+ return this.adopt(await this.request({ type: "spawn", provider, sessionId, options }));
1984
+ }
1985
+ async startFresh(options) {
1986
+ const provider = options.provider ?? "claude-code";
1987
+ return this.adopt(await this.request({ type: "spawn", provider, sessionId: null, options }));
1988
+ }
1989
+ /**
1990
+ * Take a spawn answer into the mirror.
1991
+ *
1992
+ * The pid lands here and nowhere else in the live path: `recordSessionSpawn`
1993
+ * reads it immediately after start to write the durable registry row, and a
1994
+ * null there costs the next boot its ability to probe whether the agent
1995
+ * outlived us.
1996
+ */
1997
+ adopt(raw) {
1998
+ const entry = raw;
1999
+ const session = reviveSession(entry.session);
2000
+ this.sessions.set(session.id, session);
2001
+ this.pids.set(session.id, entry.pid);
2002
+ return session;
2003
+ }
2004
+ /**
2005
+ * Returns the mirror's promptCount, optimistically incremented.
2006
+ *
2007
+ * The interface is synchronous, so there is no way to return the host's
2008
+ * authoritative count. The increment matches what an in-process runner does
2009
+ * for the same call, and the next `status-change` event overwrites it — so a
2010
+ * mirror that guessed wrong is corrected within one round trip rather than
2011
+ * drifting.
2012
+ */
2013
+ sendInput(sessionId, input) {
2014
+ const session = this.requireSession(sessionId);
2015
+ this.fireAndForget({ type: "write", sessionId, input });
2016
+ session.promptCount += 1;
2017
+ return session.promptCount;
2018
+ }
2019
+ sendKeys(sessionId, keys) {
2020
+ this.requireSession(sessionId);
2021
+ this.fireAndForget({ type: "keys", sessionId, keys });
2022
+ }
2023
+ cancel(sessionId) {
2024
+ this.fireAndForget({ type: "cancel", sessionId });
2025
+ }
2026
+ killPid(pid) {
2027
+ this.fireAndForget({ type: "kill", pid });
2028
+ }
2029
+ putOnHold(sessionId) {
2030
+ this.fireAndForget({ type: "kill", sessionId, hold: true });
2031
+ this.sessions.delete(sessionId);
2032
+ this.pids.delete(sessionId);
2033
+ }
2034
+ getOutput(sessionId) {
2035
+ this.requireSession(sessionId);
2036
+ return this.output.get(sessionId) ?? "";
2037
+ }
2038
+ async getOutputLines(sessionId, maxLines) {
2039
+ const result = await this.request({ type: "replay", sessionId, maxLines });
2040
+ if (typeof result.output === "string") this.output.set(sessionId, result.output);
2041
+ return result.lines;
2042
+ }
2043
+ /**
2044
+ * Synchronous, so it answers from the mirror rather than the host.
2045
+ *
2046
+ * Seeded lazily: `user-message` events append as they arrive, and a session
2047
+ * this streamer did not start has none until `hydrateInputHistory` fetches
2048
+ * them. Empty is the same answer an in-process runner gives for an unknown
2049
+ * session, so a caller cannot tell "none yet" from "not fetched" — which is
2050
+ * why the fetch is explicit rather than hidden behind this getter.
2051
+ */
2052
+ getInputHistory(sessionId) {
2053
+ return this.inputHistory.get(sessionId) ?? [];
2054
+ }
2055
+ /** Pull a session's recorded messages from the host into the mirror. */
2056
+ async hydrateInputHistory(sessionId) {
2057
+ const result = await this.request({
2058
+ type: "input-history",
2059
+ sessionId
2060
+ });
2061
+ this.inputHistory.set(sessionId, result.history);
2062
+ return result.history;
2063
+ }
2064
+ getPid(sessionId) {
2065
+ return this.pids.get(sessionId) ?? null;
2066
+ }
2067
+ getSession(sessionId) {
2068
+ return this.sessions.get(sessionId) ?? null;
2069
+ }
2070
+ hasSession(sessionId) {
2071
+ return this.sessions.has(sessionId);
2072
+ }
2073
+ listSessions() {
2074
+ return [...this.sessions.values()];
2075
+ }
2076
+ /**
2077
+ * Drops this streamer's connection and nothing else.
2078
+ *
2079
+ * Emphatically NOT the in-process `dispose()`, which signals every child. The
2080
+ * entire point of the host is that its PTYs outlive the streamer, so tearing
2081
+ * them down here would spend the feature to implement a method name.
2082
+ */
2083
+ dispose() {
2084
+ this.handleClose();
2085
+ this.transport.close();
2086
+ }
2087
+ requireSession(sessionId) {
2088
+ const session = this.sessions.get(sessionId);
2089
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
2090
+ return session;
2091
+ }
2092
+ };
2093
+
1686
2094
  // src/pty-manager.ts
1687
2095
  import { Terminal as Terminal2 } from "@xterm/headless";
1688
2096
  import { randomUUID as randomUUID2 } from "crypto";
@@ -2054,13 +2462,19 @@ var PTYManager = class {
2054
2462
  sessionId
2055
2463
  ];
2056
2464
  args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
2057
- const proc = nodePty.spawn(resolveClaudeExe(), args, {
2058
- name: "xterm-256color",
2059
- cols: 120,
2060
- rows: 40,
2061
- cwd: options.projectPath,
2062
- env: buildSpawnEnv()
2063
- });
2465
+ let proc;
2466
+ try {
2467
+ proc = nodePty.spawn(resolveClaudeExe(), args, {
2468
+ name: "xterm-256color",
2469
+ cols: 120,
2470
+ rows: 40,
2471
+ cwd: options.projectPath,
2472
+ env: buildSpawnEnv()
2473
+ });
2474
+ } catch (err) {
2475
+ clearClaudeExeCache();
2476
+ throw err;
2477
+ }
2064
2478
  const session = {
2065
2479
  id: sessionId,
2066
2480
  provider: CLAUDE_CODE_PROVIDER,
@@ -2115,13 +2529,19 @@ var PTYManager = class {
2115
2529
  args.push("--system-prompt", options.systemPrompt);
2116
2530
  }
2117
2531
  args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
2118
- const proc = nodePty.spawn(resolveClaudeExe(), args, {
2119
- name: "xterm-256color",
2120
- cols: 120,
2121
- rows: 40,
2122
- cwd: options.projectPath,
2123
- env: buildSpawnEnv()
2124
- });
2532
+ let proc;
2533
+ try {
2534
+ proc = nodePty.spawn(resolveClaudeExe(), args, {
2535
+ name: "xterm-256color",
2536
+ cols: 120,
2537
+ rows: 40,
2538
+ cwd: options.projectPath,
2539
+ env: buildSpawnEnv()
2540
+ });
2541
+ } catch (err) {
2542
+ clearClaudeExeCache();
2543
+ throw err;
2544
+ }
2125
2545
  const session = {
2126
2546
  id: sessionId,
2127
2547
  provider: CLAUDE_CODE_PROVIDER,
@@ -2687,12 +3107,30 @@ function stripAnsi2(str) {
2687
3107
  // src/live-session-manager.ts
2688
3108
  var LiveSessionManager = class {
2689
3109
  runners;
3110
+ remoteRunner = null;
3111
+ options;
2690
3112
  constructor(options = {}) {
3113
+ this.options = options;
2691
3114
  this.runners = /* @__PURE__ */ new Map([
2692
3115
  [CLAUDE_CODE_PROVIDER, new PTYManager(options)],
2693
3116
  [CODEX_CLI_PROVIDER, new CodexPtyRunner(options)]
2694
3117
  ]);
2695
3118
  }
3119
+ async useRemoteRunner(transport) {
3120
+ const remote = await RemoteSessionRunner.connect(transport, this.options);
3121
+ await Promise.all(
3122
+ remote.listSessions().map((session) => remote.hydrateInputHistory(session.id))
3123
+ );
3124
+ for (const runner of this.runners.values()) runner.dispose();
3125
+ this.remoteRunner = remote;
3126
+ return remote.listSessions();
3127
+ }
3128
+ isRemote() {
3129
+ return this.remoteRunner !== null;
3130
+ }
3131
+ startRemoteHeartbeat(getState) {
3132
+ this.remoteRunner?.startHeartbeat(getState);
3133
+ }
2696
3134
  async start(sessionId, options) {
2697
3135
  const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
2698
3136
  const runner = this.assertSupportedProvider(provider, options.projectPath);
@@ -2713,7 +3151,7 @@ var LiveSessionManager = class {
2713
3151
  this.runnerFor(sessionId).cancel(sessionId);
2714
3152
  }
2715
3153
  killPid(pid) {
2716
- for (const runner of this.runners.values()) {
3154
+ for (const runner of this.activeRunners()) {
2717
3155
  runner.killPid(pid);
2718
3156
  }
2719
3157
  }
@@ -2723,13 +3161,13 @@ var LiveSessionManager = class {
2723
3161
  // every runner rather than throwing; this matches the pre-extraction
2724
3162
  // behavior of delegating straight through with no existence check.
2725
3163
  putOnHold(sessionId) {
2726
- for (const runner of this.runners.values()) {
3164
+ for (const runner of this.activeRunners()) {
2727
3165
  if (runner.hasSession(sessionId) || runner.getSession(sessionId)) {
2728
3166
  runner.putOnHold(sessionId);
2729
3167
  return;
2730
3168
  }
2731
3169
  }
2732
- for (const runner of this.runners.values()) {
3170
+ for (const runner of this.activeRunners()) {
2733
3171
  runner.putOnHold(sessionId);
2734
3172
  }
2735
3173
  }
@@ -2743,7 +3181,7 @@ var LiveSessionManager = class {
2743
3181
  return this.runnerFor(sessionId).getInputHistory(sessionId);
2744
3182
  }
2745
3183
  getSession(sessionId) {
2746
- for (const runner of this.runners.values()) {
3184
+ for (const runner of this.activeRunners()) {
2747
3185
  const session = runner.getSession(sessionId);
2748
3186
  if (session) return session;
2749
3187
  }
@@ -2753,23 +3191,23 @@ var LiveSessionManager = class {
2753
3191
  // best-effort basis, so an unknown session must return null rather than
2754
3192
  // throw the way the input-routing methods do.
2755
3193
  getPid(sessionId) {
2756
- for (const runner of this.runners.values()) {
3194
+ for (const runner of this.activeRunners()) {
2757
3195
  const pid = runner.getPid(sessionId);
2758
3196
  if (pid != null) return pid;
2759
3197
  }
2760
3198
  return null;
2761
3199
  }
2762
3200
  hasSession(sessionId) {
2763
- for (const runner of this.runners.values()) {
3201
+ for (const runner of this.activeRunners()) {
2764
3202
  if (runner.hasSession(sessionId)) return true;
2765
3203
  }
2766
3204
  return false;
2767
3205
  }
2768
3206
  listSessions() {
2769
- return Array.from(this.runners.values()).flatMap((runner) => runner.listSessions());
3207
+ return this.activeRunners().flatMap((runner) => runner.listSessions());
2770
3208
  }
2771
3209
  dispose() {
2772
- for (const runner of this.runners.values()) {
3210
+ for (const runner of this.activeRunners()) {
2773
3211
  runner.dispose();
2774
3212
  }
2775
3213
  }
@@ -2777,12 +3215,13 @@ var LiveSessionManager = class {
2777
3215
  // this is a linear scan across hasSession()/getSession() rather than a
2778
3216
  // separate session→provider index — see task-1-brief.md.
2779
3217
  runnerFor(sessionId) {
2780
- for (const runner of this.runners.values()) {
3218
+ for (const runner of this.activeRunners()) {
2781
3219
  if (runner.hasSession(sessionId) || runner.getSession(sessionId)) return runner;
2782
3220
  }
2783
3221
  throw new Error(`Session not found: ${sessionId}`);
2784
3222
  }
2785
3223
  assertSupportedProvider(provider, projectPath) {
3224
+ if (this.remoteRunner) return this.remoteRunner;
2786
3225
  const runner = this.runners.get(provider);
2787
3226
  if (runner) return runner;
2788
3227
  const err = new Error(
@@ -2791,6 +3230,9 @@ var LiveSessionManager = class {
2791
3230
  err.statusCode = 501;
2792
3231
  throw err;
2793
3232
  }
3233
+ activeRunners() {
3234
+ return this.remoteRunner ? [this.remoteRunner] : [...this.runners.values()];
3235
+ }
2794
3236
  };
2795
3237
 
2796
3238
  // src/process-discovery.ts
@@ -3080,9 +3522,9 @@ import {
3080
3522
  statSync as statSync9
3081
3523
  } from "fs";
3082
3524
  import { realpath as realpath2 } from "fs/promises";
3083
- import { createServer } from "http";
3084
- import { homedir as homedir9, hostname as hostname3 } from "os";
3085
- import { basename as basename5, dirname as dirname9, join as join18 } from "path";
3525
+ import { createServer as createServer2 } from "http";
3526
+ import { homedir as homedir10, hostname as hostname3 } from "os";
3527
+ import { basename as basename5, dirname as dirname9, join as join19 } from "path";
3086
3528
  import { createInterface } from "readline";
3087
3529
 
3088
3530
  // node_modules/nanoid/index.js
@@ -5205,21 +5647,53 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
5205
5647
  };
5206
5648
 
5207
5649
  // src/api/app.ts
5650
+ var ALREADY_HANDLED7 = 597;
5651
+ function summarizeQuery(query) {
5652
+ const keys = Object.keys(query).sort();
5653
+ if (keys.length === 0) return void 0;
5654
+ return keys.map((k) => `${k}=${/^-?\d+$/.test(query[k]) ? query[k] : "_"}`).join("&");
5655
+ }
5656
+ function countResponseBytes(res) {
5657
+ let bytes = 0;
5658
+ const add = (chunk) => {
5659
+ if (typeof chunk === "string") bytes += Buffer.byteLength(chunk);
5660
+ else if (chunk instanceof Uint8Array) bytes += chunk.byteLength;
5661
+ };
5662
+ const write = res.write;
5663
+ const end = res.end;
5664
+ res.write = function(...args) {
5665
+ add(args[0]);
5666
+ return write.apply(this, args);
5667
+ };
5668
+ res.end = function(...args) {
5669
+ add(args[0]);
5670
+ return end.apply(this, args);
5671
+ };
5672
+ return () => bytes;
5673
+ }
5208
5674
  var createHonoApp = (deps, upgradeWebSocket) => {
5209
5675
  const app = new Hono18();
5210
5676
  const httpLog = getLogger("http");
5211
5677
  app.use("*", async (c, next) => {
5212
5678
  const start = Date.now();
5213
5679
  const ua = c.req.header("user-agent") ?? "";
5680
+ const outgoing = c.env?.outgoing;
5681
+ const bytesWritten = outgoing && countResponseBytes(outgoing);
5214
5682
  await next();
5215
5683
  if (!deps.logMenubarRequests && c.req.header("x-client") === "menubar") return;
5216
5684
  const ms = Date.now() - start;
5217
- httpLog.info(`[req] ${c.req.method} ${c.req.path} \u2192 ${c.res.status} ${ms}ms`, {
5685
+ const handled = c.res.status === ALREADY_HANDLED7 && outgoing !== void 0;
5686
+ const status = handled ? outgoing.statusCode : c.res.status;
5687
+ const qs = summarizeQuery(c.req.query());
5688
+ const bytes = handled && bytesWritten ? bytesWritten() : void 0;
5689
+ httpLog.info(`[req] ${c.req.method} ${c.req.path} \u2192 ${status} ${ms}ms`, {
5218
5690
  method: c.req.method,
5219
5691
  path: c.req.path,
5220
- status: c.res.status,
5692
+ status,
5221
5693
  ms,
5222
5694
  ua,
5695
+ ...qs ? { qs } : {},
5696
+ ...bytes === void 0 ? {} : { bytes },
5223
5697
  event: "http.request"
5224
5698
  });
5225
5699
  });
@@ -5310,6 +5784,74 @@ import { open as openAsync } from "fs/promises";
5310
5784
  import { dirname as dirname7 } from "path";
5311
5785
  import { setImmediate as yieldToEventLoop } from "timers/promises";
5312
5786
 
5787
+ // src/db/query-timing.ts
5788
+ var log3 = getLogger("db");
5789
+ var DEFAULT_SLOW_QUERY_MS = 35;
5790
+ var LABEL = /* @__PURE__ */ Symbol("tbQueryLabel");
5791
+ function deriveLabel(sql) {
5792
+ const verb = /^\s*(\w+)/.exec(sql)?.[1]?.toLowerCase() ?? "sql";
5793
+ const table = /(?:from|into|update)\s+([A-Za-z_]\w*)/i.exec(sql)?.[1] ?? "?";
5794
+ return `${verb}:${table}`;
5795
+ }
5796
+ function resolveSlowMs() {
5797
+ const raw = process.env.THREADBASE_DB_SLOW_QUERY_MS;
5798
+ if (raw === void 0 || raw === "") return DEFAULT_SLOW_QUERY_MS;
5799
+ const parsed = Number(raw);
5800
+ return Number.isFinite(parsed) ? parsed : DEFAULT_SLOW_QUERY_MS;
5801
+ }
5802
+ function record(label, ms, rows, slowMs) {
5803
+ if (slowMs > 0 && ms >= slowMs) {
5804
+ log3.warn(
5805
+ `[db] slow query ${label} ${ms.toFixed(1)}ms rows=${rows}`,
5806
+ { event: "db.slow_query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
5807
+ "pino"
5808
+ );
5809
+ return;
5810
+ }
5811
+ if (log3.pino.isLevelEnabled("debug")) {
5812
+ log3.debug(
5813
+ `[db] ${label} ${ms.toFixed(2)}ms rows=${rows}`,
5814
+ { event: "db.query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
5815
+ "pino"
5816
+ );
5817
+ }
5818
+ }
5819
+ function rowsOf(method, result) {
5820
+ if (method === "all") return Array.isArray(result) ? result.length : 0;
5821
+ if (method === "run") return result?.changes ?? 0;
5822
+ return result === void 0 ? 0 : 1;
5823
+ }
5824
+ function instrumentDatabase(db, options = {}) {
5825
+ const slowMs = options.slowMs ?? resolveSlowMs();
5826
+ const prepare = db.prepare.bind(db);
5827
+ db.prepare = ((sql) => {
5828
+ const stmt = prepare(sql);
5829
+ const box = { label: deriveLabel(sql) };
5830
+ Object.defineProperty(stmt, LABEL, { value: box, configurable: true });
5831
+ for (const method of ["get", "all", "run"]) {
5832
+ const original = stmt[method].bind(stmt);
5833
+ Object.defineProperty(stmt, method, {
5834
+ configurable: true,
5835
+ writable: true,
5836
+ value: (...args) => {
5837
+ const started = performance.now();
5838
+ const result = original(...args);
5839
+ record(box.label, performance.now() - started, rowsOf(method, result), slowMs);
5840
+ return result;
5841
+ }
5842
+ });
5843
+ }
5844
+ return stmt;
5845
+ });
5846
+ return db;
5847
+ }
5848
+ function labelStatements(statements) {
5849
+ for (const [name, stmt] of Object.entries(statements)) {
5850
+ const box = stmt?.[LABEL];
5851
+ if (box) box.label = name;
5852
+ }
5853
+ }
5854
+
5313
5855
  // src/db/sqlite-migrate.ts
5314
5856
  import { readdirSync as readdirSync2, readFileSync as readFileSync7 } from "fs";
5315
5857
  import { dirname as dirname6, join as join12 } from "path";
@@ -5519,6 +6061,7 @@ CREATE TABLE IF NOT EXISTS session_names (
5519
6061
  updated_at INTEGER NOT NULL
5520
6062
  );
5521
6063
  `;
6064
+ var cacheLog = getLogger("cache");
5522
6065
  var ConversationCache = class _ConversationCache {
5523
6066
  db;
5524
6067
  tailSize;
@@ -5748,6 +6291,7 @@ var ConversationCache = class _ConversationCache {
5748
6291
  "SELECT COUNT(*) as cnt FROM conversation_message_index WHERE conversation_id = ?"
5749
6292
  )
5750
6293
  };
6294
+ labelStatements(this.stmts);
5751
6295
  }
5752
6296
  /**
5753
6297
  * Expose the underlying handle so projects/cache_metadata repositories can
@@ -5911,6 +6455,7 @@ var ConversationCache = class _ConversationCache {
5911
6455
  return walk;
5912
6456
  }
5913
6457
  async runBackfill(filePath) {
6458
+ const startedAt = performance.now();
5914
6459
  const convId = _ConversationCache.conversationIdForFile(filePath);
5915
6460
  this.deleteFileIndex(filePath, convId);
5916
6461
  this.indexParseState.delete(filePath);
@@ -5971,6 +6516,18 @@ var ConversationCache = class _ConversationCache {
5971
6516
  last_message_index: nextIndex - 1
5972
6517
  });
5973
6518
  this.indexParseState.set(filePath, state);
6519
+ const ms = Math.round(performance.now() - startedAt);
6520
+ cacheLog.info(
6521
+ `[cache] offset-index backfilled ${convId} ${ms}ms`,
6522
+ {
6523
+ event: "offset_index.backfill_ok",
6524
+ conversationId: convId,
6525
+ ms,
6526
+ rows: nextIndex,
6527
+ bytes: stat3.size
6528
+ },
6529
+ "pino"
6530
+ );
5974
6531
  }
5975
6532
  /**
5976
6533
  * Windowed detail read straight from the offset index — the hot path.
@@ -6056,7 +6613,7 @@ var ConversationCache = class _ConversationCache {
6056
6613
  }
6057
6614
  static open(dbPath, tailSize = 10, migrationsDir, options) {
6058
6615
  mkdirSync3(dirname7(dbPath), { recursive: true });
6059
- const db = new Database(dbPath);
6616
+ const db = instrumentDatabase(new Database(dbPath));
6060
6617
  db.pragma("journal_mode = WAL");
6061
6618
  db.pragma("foreign_keys = ON");
6062
6619
  return new _ConversationCache(db, tailSize, migrationsDir, options);
@@ -7068,7 +7625,7 @@ var RuntimeStore = class _RuntimeStore {
7068
7625
  }
7069
7626
  db;
7070
7627
  static open(dbPath, migrationsDir) {
7071
- const db = new Database2(dbPath);
7628
+ const db = instrumentDatabase(new Database2(dbPath));
7072
7629
  db.pragma("journal_mode = WAL");
7073
7630
  runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
7074
7631
  return new _RuntimeStore(db);
@@ -7200,14 +7757,14 @@ var PairTokenStore = class {
7200
7757
  };
7201
7758
  }
7202
7759
  consume(token) {
7203
- const record = this.current;
7204
- if (!record || record.token !== token) return { ok: false, reason: "unknown" };
7205
- if (Date.now() > record.expiresAt) {
7760
+ const record2 = this.current;
7761
+ if (!record2 || record2.token !== token) return { ok: false, reason: "unknown" };
7762
+ if (Date.now() > record2.expiresAt) {
7206
7763
  this.current = null;
7207
7764
  return { ok: false, reason: "expired" };
7208
7765
  }
7209
- if (record.used) return { ok: false, reason: "used" };
7210
- record.used = true;
7766
+ if (record2.used) return { ok: false, reason: "used" };
7767
+ record2.used = true;
7211
7768
  return { ok: true };
7212
7769
  }
7213
7770
  peek() {
@@ -7228,6 +7785,88 @@ var PairTokenStore = class {
7228
7785
  }
7229
7786
  };
7230
7787
 
7788
+ // src/pty-host/spawn-host.ts
7789
+ import { spawn as spawn2 } from "child_process";
7790
+
7791
+ // src/pty-host/socket.ts
7792
+ import { createConnection, createServer } from "net";
7793
+ import { homedir as homedir7 } from "os";
7794
+ import { join as join14 } from "path";
7795
+ function hostSocketPath(instanceId) {
7796
+ if (process.platform === "win32") {
7797
+ return `\\\\.\\pipe\\threadbase-pty-host-${instanceId}`;
7798
+ }
7799
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? join14(homedir7(), ".threadbase");
7800
+ return join14(dir, "run", `pty-host-${instanceId}.sock`);
7801
+ }
7802
+ function socketTransport(socket) {
7803
+ socket.setEncoding("utf8");
7804
+ return {
7805
+ send(line) {
7806
+ socket.write(line);
7807
+ },
7808
+ onLine(handler) {
7809
+ socket.on("data", (chunk) => handler(chunk));
7810
+ },
7811
+ onClose(handler) {
7812
+ let handled = false;
7813
+ const handleClose = () => {
7814
+ if (handled) return;
7815
+ handled = true;
7816
+ handler();
7817
+ };
7818
+ socket.once("close", handleClose);
7819
+ socket.once("error", handleClose);
7820
+ },
7821
+ close() {
7822
+ socket.destroy();
7823
+ }
7824
+ };
7825
+ }
7826
+ function connectToHost(socketPath) {
7827
+ return new Promise((resolve2, reject) => {
7828
+ const socket = createConnection(socketPath);
7829
+ socket.once("error", reject);
7830
+ socket.once("connect", () => {
7831
+ socket.removeListener("error", reject);
7832
+ resolve2(socketTransport(socket));
7833
+ });
7834
+ });
7835
+ }
7836
+
7837
+ // src/pty-host/spawn-host.ts
7838
+ var HOST_READY_TIMEOUT_MS = 5e3;
7839
+ var HOST_POLL_INTERVAL_MS = 50;
7840
+ async function connectOrSpawnHost(options) {
7841
+ const socketPath = hostSocketPath(options.instanceId);
7842
+ try {
7843
+ return await connectToHost(socketPath);
7844
+ } catch {
7845
+ }
7846
+ spawnDetachedHost(socketPath, options.entryPoint);
7847
+ const deadline = Date.now() + (options.timeoutMs ?? HOST_READY_TIMEOUT_MS);
7848
+ let lastError;
7849
+ while (Date.now() < deadline) {
7850
+ try {
7851
+ return await connectToHost(socketPath);
7852
+ } catch (err) {
7853
+ lastError = err;
7854
+ await new Promise((r) => setTimeout(r, HOST_POLL_INTERVAL_MS));
7855
+ }
7856
+ }
7857
+ throw new Error(
7858
+ `pty-host did not accept a connection on ${socketPath} within ${options.timeoutMs ?? HOST_READY_TIMEOUT_MS}ms` + (lastError instanceof Error ? `: ${lastError.message}` : "")
7859
+ );
7860
+ }
7861
+ function spawnDetachedHost(socketPath, entryPoint) {
7862
+ const child = spawn2(
7863
+ process.execPath,
7864
+ [entryPoint ?? process.argv[1], "pty-host", "--socket", socketPath],
7865
+ { detached: true, stdio: "ignore" }
7866
+ );
7867
+ child.unref();
7868
+ }
7869
+
7231
7870
  // src/seal.ts
7232
7871
  import nacl from "tweetnacl";
7233
7872
  import naclUtil from "tweetnacl-util";
@@ -7263,11 +7902,11 @@ import { existsSync as existsSync9 } from "fs";
7263
7902
 
7264
7903
  // src/services/cache-integrity/alertStore.ts
7265
7904
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
7266
- import { homedir as homedir7 } from "os";
7267
- import { dirname as dirname8, join as join14 } from "path";
7905
+ import { homedir as homedir8 } from "os";
7906
+ import { dirname as dirname8, join as join15 } from "path";
7268
7907
  function alertStatePath() {
7269
- const dir = process.env.THREADBASE_CONFIG_DIR ?? join14(homedir7(), ".threadbase");
7270
- return join14(dir, "cache-alert.json");
7908
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? join15(homedir8(), ".threadbase");
7909
+ return join15(dir, "cache-alert.json");
7271
7910
  }
7272
7911
  function loadAlertState() {
7273
7912
  try {
@@ -7286,7 +7925,7 @@ function saveAlertState(state) {
7286
7925
 
7287
7926
  // src/services/cache-integrity/backup.ts
7288
7927
  import { existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync4, statSync as statSync5, unlinkSync } from "fs";
7289
- import { join as join15 } from "path";
7928
+ import { join as join16 } from "path";
7290
7929
  var DEFAULT_RETAIN = 3;
7291
7930
  function retainCount() {
7292
7931
  const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
@@ -7297,13 +7936,13 @@ function timestamp(d) {
7297
7936
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
7298
7937
  }
7299
7938
  async function backupCacheDb(db, cacheDir) {
7300
- const backupsDir = join15(cacheDir, "backups");
7939
+ const backupsDir = join16(cacheDir, "backups");
7301
7940
  mkdirSync5(backupsDir, { recursive: true });
7302
- const destPath = join15(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
7941
+ const destPath = join16(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
7303
7942
  await db.backup(destPath);
7304
7943
  const retain = retainCount();
7305
7944
  const backups = readdirSync4(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
7306
- const full = join15(backupsDir, f);
7945
+ const full = join16(backupsDir, f);
7307
7946
  return { full, mtime: statSync5(full).mtimeMs };
7308
7947
  }).sort((a, b) => b.mtime - a.mtime);
7309
7948
  for (const stale of backups.slice(retain)) {
@@ -7326,10 +7965,10 @@ function fingerprintOf(ids) {
7326
7965
  return `sha256:${createHash3("sha256").update(sorted.join("\n")).digest("hex")}`;
7327
7966
  }
7328
7967
  var CacheIntegrityMonitor = class {
7329
- constructor(cache, wsHub, log7, cacheDir, rescan, runDuringReset) {
7968
+ constructor(cache, wsHub, log8, cacheDir, rescan, runDuringReset) {
7330
7969
  this.cache = cache;
7331
7970
  this.wsHub = wsHub;
7332
- this.log = log7;
7971
+ this.log = log8;
7333
7972
  this.cacheDir = cacheDir;
7334
7973
  this.rescan = rescan;
7335
7974
  this.runDuringReset = runDuringReset;
@@ -7887,9 +8526,9 @@ function refreshConversationCache(deps) {
7887
8526
 
7888
8527
  // src/services/conversations/shouldRefreshProjectsFromHdd.ts
7889
8528
  import { readdirSync as readdirSync5, statSync as statSync7 } from "fs";
7890
- import { homedir as homedir8 } from "os";
7891
- import { join as join16 } from "path";
7892
- var DEFAULT_PROJECTS_DIR = join16(homedir8(), ".claude", "projects");
8529
+ import { homedir as homedir9 } from "os";
8530
+ import { join as join17 } from "path";
8531
+ var DEFAULT_PROJECTS_DIR = join17(homedir9(), ".claude", "projects");
7893
8532
  function maxProjectsTreeMtimeMs(projectsDir) {
7894
8533
  let maxMs;
7895
8534
  try {
@@ -7901,7 +8540,7 @@ function maxProjectsTreeMtimeMs(projectsDir) {
7901
8540
  for (const ent of readdirSync5(projectsDir, { withFileTypes: true })) {
7902
8541
  if (!ent.isDirectory()) continue;
7903
8542
  try {
7904
- const childMs = statSync7(join16(projectsDir, ent.name)).mtimeMs;
8543
+ const childMs = statSync7(join17(projectsDir, ent.name)).mtimeMs;
7905
8544
  if (childMs > maxMs) maxMs = childMs;
7906
8545
  } catch {
7907
8546
  }
@@ -7945,7 +8584,7 @@ function deriveProjectChatTitle(input) {
7945
8584
  // src/services/push/apnsClient.ts
7946
8585
  import { createSign } from "crypto";
7947
8586
  import { connect, constants } from "http2";
7948
- var log3 = getLogger("apns");
8587
+ var log4 = getLogger("apns");
7949
8588
  var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
7950
8589
  var APNS_MAX_PAYLOAD_BYTES = 4096;
7951
8590
  var JWT_TTL_SECONDS = 3e3;
@@ -8029,7 +8668,7 @@ var ApnsClient = class {
8029
8668
  }
8030
8669
  const session = connect(`https://${this.creds.host}`);
8031
8670
  session.on("error", (err) => {
8032
- log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
8671
+ log4.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
8033
8672
  });
8034
8673
  this.session = session;
8035
8674
  return session;
@@ -8114,7 +8753,7 @@ function truncateLastOutput(raw) {
8114
8753
  }
8115
8754
 
8116
8755
  // src/services/push/liveActivityNotifier.ts
8117
- var log4 = getLogger("live-activity");
8756
+ var log5 = getLogger("live-activity");
8118
8757
  function contentStateForSession(args) {
8119
8758
  const status = toLiveActivityStatus(args.session.status);
8120
8759
  if (!status) return null;
@@ -8174,7 +8813,7 @@ var LiveActivityNotifier = class {
8174
8813
  }
8175
8814
  await this.maybeSendName(session);
8176
8815
  } catch (err) {
8177
- log4.error("live_activity.notify_failed", {
8816
+ log5.error("live_activity.notify_failed", {
8178
8817
  event: "live_activity.notify_failed",
8179
8818
  sessionId: session.id,
8180
8819
  status: session.status,
@@ -8196,7 +8835,7 @@ var LiveActivityNotifier = class {
8196
8835
  });
8197
8836
  this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
8198
8837
  if (outcome.attempted > 0) {
8199
- log4.info("live_activity.updated", {
8838
+ log5.info("live_activity.updated", {
8200
8839
  event: "live_activity.updated",
8201
8840
  sessionId: session.id,
8202
8841
  status: contentState.status,
@@ -8220,7 +8859,7 @@ var LiveActivityNotifier = class {
8220
8859
  });
8221
8860
  open2.sessionNameSent = true;
8222
8861
  if (outcome.attempted > 0) {
8223
- log4.info("live_activity.updated", {
8862
+ log5.info("live_activity.updated", {
8224
8863
  event: "live_activity.updated",
8225
8864
  sessionId: session.id,
8226
8865
  status: contentState.status,
@@ -8239,7 +8878,7 @@ var LiveActivityNotifier = class {
8239
8878
  if (!contentState) return;
8240
8879
  const outcome = await this.sender.end({ sessionId: session.id, contentState });
8241
8880
  if (outcome.attempted > 0) {
8242
- log4.info("live_activity.ended", {
8881
+ log5.info("live_activity.ended", {
8243
8882
  event: "live_activity.ended",
8244
8883
  sessionId: session.id,
8245
8884
  ...outcome
@@ -8253,7 +8892,7 @@ var LiveActivityNotifier = class {
8253
8892
  };
8254
8893
 
8255
8894
  // src/services/push/liveActivitySender.ts
8256
- var log5 = getLogger("live-activity");
8895
+ var log6 = getLogger("live-activity");
8257
8896
  var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
8258
8897
  function buildActivityKitPayload(args) {
8259
8898
  return {
@@ -8317,7 +8956,7 @@ var LiveActivitySender = class {
8317
8956
  );
8318
8957
  for (const { row, result, error } of results) {
8319
8958
  if (error) {
8320
- log5.error("live_activity.send_failed", {
8959
+ log6.error("live_activity.send_failed", {
8321
8960
  event: "live_activity.send_failed",
8322
8961
  sessionId: args.sessionId,
8323
8962
  activityId: row.activity_id,
@@ -8338,7 +8977,7 @@ var LiveActivitySender = class {
8338
8977
  this.repo.expire(row.token, now);
8339
8978
  outcome.retired += 1;
8340
8979
  }
8341
- log5.warn("live_activity.send_rejected", {
8980
+ log6.warn("live_activity.send_rejected", {
8342
8981
  event: "live_activity.send_rejected",
8343
8982
  sessionId: args.sessionId,
8344
8983
  activityId: row.activity_id,
@@ -8383,7 +9022,7 @@ var LiveActivitySender = class {
8383
9022
  };
8384
9023
 
8385
9024
  // src/services/push/liveActivityRenewal.ts
8386
- var log6 = getLogger("live-activity");
9025
+ var log7 = getLogger("live-activity");
8387
9026
  var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
8388
9027
  var MAX_TIMER_MS = 60 * 60 * 1e3;
8389
9028
  function renewalDueAt(row) {
@@ -8431,7 +9070,7 @@ var LiveActivityRenewalScheduler = class {
8431
9070
  await this.renew(row, now);
8432
9071
  }
8433
9072
  } catch (err) {
8434
- log6.error("live_activity.renewal_sweep_failed", {
9073
+ log7.error("live_activity.renewal_sweep_failed", {
8435
9074
  event: "live_activity.renewal_sweep_failed",
8436
9075
  err: String(err)
8437
9076
  });
@@ -8460,7 +9099,7 @@ var LiveActivityRenewalScheduler = class {
8460
9099
  if (!session || !status) {
8461
9100
  this.deps.repo.claimRenewal(row.token, now);
8462
9101
  this.deps.repo.expire(row.token, now);
8463
- log6.info("live_activity.renewal_skipped", {
9102
+ log7.info("live_activity.renewal_skipped", {
8464
9103
  event: "live_activity.renewal_skipped",
8465
9104
  sessionId: row.session_id,
8466
9105
  activityId: row.activity_id,
@@ -8495,7 +9134,7 @@ var LiveActivityRenewalScheduler = class {
8495
9134
  startedAt,
8496
9135
  now
8497
9136
  });
8498
- log6.info("live_activity.renewed", {
9137
+ log7.info("live_activity.renewed", {
8499
9138
  event: "live_activity.renewed",
8500
9139
  sessionId: session.id,
8501
9140
  activityId: row.activity_id,
@@ -8505,7 +9144,7 @@ var LiveActivityRenewalScheduler = class {
8505
9144
  replacementRequested: started
8506
9145
  });
8507
9146
  } catch (err) {
8508
- log6.error("live_activity.renewal_failed", {
9147
+ log7.error("live_activity.renewal_failed", {
8509
9148
  event: "live_activity.renewal_failed",
8510
9149
  sessionId: session.id,
8511
9150
  activityId: row.activity_id,
@@ -8996,6 +9635,12 @@ var SessionStore = class {
8996
9635
  removeManaged(sessionId) {
8997
9636
  return this.managed.delete(sessionId);
8998
9637
  }
9638
+ /**
9639
+ * The **live** stored record — mutating it mutates the store. Paired with
9640
+ * `get()`, which hands back a throwaway response copy. Prefer
9641
+ * `updateManaged()` for writes; this is for readers that need the internal
9642
+ * shape (Date fields, `rehydrated`, …) rather than the wire shape.
9643
+ */
8999
9644
  getManaged(sessionId) {
9000
9645
  return this.managed.get(sessionId) ?? null;
9001
9646
  }
@@ -9005,12 +9650,21 @@ var SessionStore = class {
9005
9650
  this.discovered.set(proc.pid, proc);
9006
9651
  }
9007
9652
  }
9653
+ /**
9654
+ * The **live** stored records — mutating an element mutates the store. Paired
9655
+ * with `list()`, which hands back throwaway response copies.
9656
+ */
9008
9657
  listManaged() {
9009
9658
  return Array.from(this.managed.values());
9010
9659
  }
9011
9660
  // Build the session list: live PTY sessions (managed) merged with externally
9012
9661
  // discovered Claude processes. Managed sessions keyed by JSONL UUID take
9013
9662
  // priority — discovered processes with the same UUID are skipped.
9663
+ //
9664
+ // Returns freshly constructed response objects, NOT references into the
9665
+ // store — hence `Readonly`: writing to one changes nothing, so the compiler
9666
+ // refuses it. Persist state with `updateManaged()`; to decorate a response,
9667
+ // build a new object (`{ ...s, … }`) as `withReconciledLifecycle` does.
9014
9668
  list(ptyAttachedIds) {
9015
9669
  const results = [];
9016
9670
  const seenIds = /* @__PURE__ */ new Set();
@@ -9026,6 +9680,9 @@ var SessionStore = class {
9026
9680
  }
9027
9681
  return results;
9028
9682
  }
9683
+ // A freshly constructed response object, NOT a reference into the store —
9684
+ // hence `Readonly`, for the same reason as `list()` above. Use `getManaged()`
9685
+ // when you want the live record.
9029
9686
  get(sessionId, ptyAttachedIds) {
9030
9687
  const managed = this.managed.get(sessionId);
9031
9688
  if (managed) return managedToResponse(managed, ptyAttachedIds.has(sessionId));
@@ -9117,8 +9774,8 @@ function managedToResponse(s, ptyAttached) {
9117
9774
  // behind it — `resumable`, and `historical` rather than `managed`
9118
9775
  // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
9119
9776
  lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
9120
- lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
9121
- // We spawned it, so `status` is the authoritative signal — no inferred
9777
+ lifecycleSource: ptyAttached ? s.reconciled ? "reconcile" : "spawn" : s.rehydrated ? "reconcile" : "exit",
9778
+ // We own its PTY, so `status` is the authoritative signal — no inferred
9122
9779
  // `activity` is attached for managed sessions.
9123
9780
  ownership: s.rehydrated ? "historical" : "managed",
9124
9781
  projectPath: s.projectPath,
@@ -9193,7 +9850,7 @@ function discoveredToResponse(d, conversationId) {
9193
9850
  import { randomBytes as randomBytes4 } from "crypto";
9194
9851
  import { mkdir as mkdir3, writeFile } from "fs/promises";
9195
9852
  import heicConvert from "heic-convert";
9196
- import { join as join17 } from "path";
9853
+ import { join as join18 } from "path";
9197
9854
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
9198
9855
  var MAX_BYTES = 25 * 1024 * 1024;
9199
9856
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -9226,9 +9883,9 @@ async function saveUploadFile(input) {
9226
9883
  }
9227
9884
  const id = `up_${randomBytes4(8).toString("hex")}`;
9228
9885
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
9229
- const dir = join17(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
9886
+ const dir = join18(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
9230
9887
  await mkdir3(dir, { recursive: true });
9231
- const filePath = join17(dir, `${Date.now()}-${id}-${safeName}`);
9888
+ const filePath = join18(dir, `${Date.now()}-${id}-${safeName}`);
9232
9889
  await writeFile(filePath, buffer);
9233
9890
  return {
9234
9891
  id,
@@ -9770,7 +10427,7 @@ var StreamerServer = class {
9770
10427
  this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
9771
10428
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
9772
10429
  this.scanProfiles = config.scanProfiles;
9773
- this.codexRoots = config.codexRoots ?? [join18(homedir9(), ".codex", "sessions")];
10430
+ this.codexRoots = config.codexRoots ?? [join19(homedir10(), ".codex", "sessions")];
9774
10431
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
9775
10432
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
9776
10433
  this.featureFlags = resolveFeatureFlags({ cli: config.featureFlags, yaml: loadFeatureFlags() });
@@ -9784,8 +10441,8 @@ var StreamerServer = class {
9784
10441
  this.claudeFlagsPersistable = config.claudeFlags === void 0;
9785
10442
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
9786
10443
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
9787
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join18(homedir9(), ".threadbase", "cache");
9788
- this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? join18(process.env.THREADBASE_CONFIG_DIR ?? join18(homedir9(), ".threadbase"), "runtime.db");
10444
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join19(homedir10(), ".threadbase", "cache");
10445
+ this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? join19(process.env.THREADBASE_CONFIG_DIR ?? join19(homedir10(), ".threadbase"), "runtime.db");
9789
10446
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
9790
10447
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
9791
10448
  this.markScannerStaleDebounced = debounce(() => {
@@ -10041,7 +10698,7 @@ var StreamerServer = class {
10041
10698
  temporalClient,
10042
10699
  taskQueue: agentConfig.temporal.taskQueue
10043
10700
  });
10044
- const conversationsBaseDir = agentConfig.conversationsDir || join18(dirname9(this.cacheDir), "conversations");
10701
+ const conversationsBaseDir = agentConfig.conversationsDir || join19(dirname9(this.cacheDir), "conversations");
10045
10702
  conversationWriter = createConversationWriter({
10046
10703
  baseDir: conversationsBaseDir
10047
10704
  });
@@ -10194,7 +10851,7 @@ var StreamerServer = class {
10194
10851
  conversationWriter,
10195
10852
  agentConfig
10196
10853
  };
10197
- this.httpServer = createServer((req, res) => this.handleRequest(req, res));
10854
+ this.httpServer = createServer2((req, res) => this.handleRequest(req, res));
10198
10855
  this.httpServer.on("clientError", (_err, socket) => {
10199
10856
  try {
10200
10857
  socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
@@ -10348,7 +11005,7 @@ var StreamerServer = class {
10348
11005
  if (!this.managedSessionsRepo) return [];
10349
11006
  let verdicts = [];
10350
11007
  try {
10351
- const rows = this.managedSessionsRepo.listNonTerminal();
11008
+ const rows = this.managedSessionsRepo.listNonTerminal().filter((row) => !this.ptyManager.hasSession(row.session_id));
10352
11009
  if (rows.length === PROBE_SET_MAX) {
10353
11010
  this.log.warn(
10354
11011
  `[reconcile] probe set hit its cap of ${PROBE_SET_MAX} \u2014 older rows skipped`,
@@ -10595,6 +11252,26 @@ var StreamerServer = class {
10595
11252
  if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
10596
11253
  return session.boundConversationId ?? session.projectPath;
10597
11254
  }
11255
+ /** Restore registry-only metadata after the host mirror has been adopted. */
11256
+ refreshHostedSessionsFromRegistry() {
11257
+ for (const session of this.ptyManager.listSessions()) {
11258
+ const row = this.managedSessionsRepo?.get(session.id);
11259
+ const merged = {
11260
+ ...session,
11261
+ reconciled: true,
11262
+ ...row?.project_id != null && { projectId: row.project_id },
11263
+ ...row?.session_name != null && { sessionName: row.session_name },
11264
+ ...row?.bound_conversation_id != null && {
11265
+ boundConversationId: row.bound_conversation_id
11266
+ },
11267
+ ...row?.resumed_from_conversation_id != null && {
11268
+ resumedFromConversationId: row.resumed_from_conversation_id
11269
+ }
11270
+ };
11271
+ this.sessionStore.addManaged(merged);
11272
+ void this.watchConversationFile(session.id, merged.boundConversationId ?? session.id);
11273
+ }
11274
+ }
10598
11275
  /**
10599
11276
  * Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
10600
11277
  *
@@ -10690,6 +11367,7 @@ var StreamerServer = class {
10690
11367
  * of waiting on the interval.
10691
11368
  */
10692
11369
  reapIdleSessions(now = Date.now()) {
11370
+ if (this.ptyManager.isRemote()) return [];
10693
11371
  const reaped = [];
10694
11372
  for (const session of this.ptyManager.listSessions()) {
10695
11373
  if (session.status === "running") continue;
@@ -10791,6 +11469,40 @@ var StreamerServer = class {
10791
11469
  return true;
10792
11470
  }
10793
11471
  async listen(port, opts) {
11472
+ if (this.featureFlags.ptyHost) {
11473
+ try {
11474
+ let sessions = null;
11475
+ for (let attempt = 0; attempt < 2; attempt += 1) {
11476
+ const transport = await connectOrSpawnHost({
11477
+ instanceId: process.env.THREADBASE_INSTANCE_ID ?? hostname3()
11478
+ });
11479
+ try {
11480
+ sessions = await this.ptyManager.useRemoteRunner(transport);
11481
+ break;
11482
+ } catch (err) {
11483
+ if (!(err instanceof PtyHostProtocolMismatchError) || attempt > 0) throw err;
11484
+ this.log.info(`[pty-host] replaced incompatible protocol ${err.hostVersion}`, {
11485
+ event: "pty_host.protocol_replaced",
11486
+ hostVersion: err.hostVersion,
11487
+ streamerVersion: err.streamerVersion
11488
+ });
11489
+ }
11490
+ }
11491
+ if (!sessions) throw new Error("pty-host replacement did not produce a compatible host");
11492
+ for (const session of sessions) {
11493
+ this.sessionStore.addManaged({ ...session, reconciled: true });
11494
+ }
11495
+ this.log.info(`[pty-host] re-adopted ${sessions.length} live session(s)`, {
11496
+ event: "pty_host.sessions_adopted",
11497
+ sessions: sessions.length
11498
+ });
11499
+ } catch (err) {
11500
+ this.log.error(
11501
+ "[pty-host] could not attach; falling back to in-process PTYs for this run",
11502
+ { event: "pty_host.attach_failed", err }
11503
+ );
11504
+ }
11505
+ }
10794
11506
  const dbConfig = this.disableDb ? null : getDbConfig();
10795
11507
  if (dbConfig) {
10796
11508
  this.dbPool = await createPool(dbConfig);
@@ -10805,8 +11517,10 @@ var StreamerServer = class {
10805
11517
  this.log.info("Database migrations applied", { event: "db.migrations_applied" });
10806
11518
  }
10807
11519
  await this.bindWithRetry(port);
10808
- this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
10809
- this.idleReaperTimer.unref?.();
11520
+ if (!this.ptyManager.isRemote()) {
11521
+ this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
11522
+ this.idleReaperTimer.unref?.();
11523
+ }
10810
11524
  const warmUp = new Promise((resolveWarm) => {
10811
11525
  {
10812
11526
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -10824,9 +11538,18 @@ var StreamerServer = class {
10824
11538
  { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
10825
11539
  );
10826
11540
  }
11541
+ if (this.ptyManager.isRemote()) {
11542
+ this.ptyManager.startRemoteHeartbeat(() => {
11543
+ if (!this.managedSessionsRepo) {
11544
+ return { registryState: "unknown", referencedSessionIds: [] };
11545
+ }
11546
+ const referencedSessionIds = this.ptyManager.listSessions().filter((session) => this.managedSessionsRepo?.get(session.id)?.completed_at == null).map((session) => session.id);
11547
+ return { registryState: "known", referencedSessionIds };
11548
+ });
11549
+ }
10827
11550
  try {
10828
11551
  this.cache = ConversationCache.open(
10829
- join18(this.cacheDir, "cache.db"),
11552
+ join19(this.cacheDir, "cache.db"),
10830
11553
  this.tailSize,
10831
11554
  void 0,
10832
11555
  {
@@ -10900,6 +11623,7 @@ var StreamerServer = class {
10900
11623
  );
10901
11624
  this.scannerPersistenceDisabled = true;
10902
11625
  }
11626
+ if (this.ptyManager.isRemote()) this.refreshHostedSessionsFromRegistry();
10903
11627
  void this.reconcilePreviousSessions().then(async (v) => {
10904
11628
  const recoverableRows = this.rehydratePreviousSessions(v);
10905
11629
  await this.autoResumePreviousSessions(recoverableRows);
@@ -11105,7 +11829,8 @@ var StreamerServer = class {
11105
11829
  }
11106
11830
  this.lastAgentChunkAt.clear();
11107
11831
  this.terminalSeq.clear();
11108
- this.recordShutdownState();
11832
+ if (this.ptyManager.isRemote()) this.ptyManager.dispose();
11833
+ else this.recordShutdownState();
11109
11834
  this.markScannerStaleDebounced.cancel();
11110
11835
  await Promise.all([...this.inFlightCacheWrites]);
11111
11836
  await Promise.all([...this.allScanners].map((s) => s.close()));
@@ -11113,7 +11838,7 @@ var StreamerServer = class {
11113
11838
  this.scanner = null;
11114
11839
  this.cache?.close();
11115
11840
  this.runtimeStore?.close();
11116
- this.ptyManager.dispose();
11841
+ if (!this.ptyManager.isRemote()) this.ptyManager.dispose();
11117
11842
  this.fileWatcher.dispose();
11118
11843
  this.externalTails.clear();
11119
11844
  this.wsHub.dispose();
@@ -11339,9 +12064,9 @@ var StreamerServer = class {
11339
12064
  /** Project roots watched for conversation JSONLs (profiles or ~/.claude/projects). */
11340
12065
  projectsDirsForFreshnessCheck() {
11341
12066
  if (this.scanProfiles && this.scanProfiles.length > 0) {
11342
- return this.scanProfiles.filter((p) => p.enabled).map((p) => join18(p.configDir, "projects"));
12067
+ return this.scanProfiles.filter((p) => p.enabled).map((p) => join19(p.configDir, "projects"));
11343
12068
  }
11344
- return [join18(homedir9(), ".claude", "projects")];
12069
+ return [join19(homedir10(), ".claude", "projects")];
11345
12070
  }
11346
12071
  /**
11347
12072
  * Full-glob scan + cache upsert/delete reconcile. Used by ?refresh=1 and by
@@ -11742,21 +12467,21 @@ var StreamerServer = class {
11742
12467
  */
11743
12468
  projectsDirs() {
11744
12469
  if (this.scanProfiles && this.scanProfiles.length > 0) {
11745
- return this.scanProfiles.filter((p) => p.enabled).map((p) => join18(p.configDir, "projects"));
12470
+ return this.scanProfiles.filter((p) => p.enabled).map((p) => join19(p.configDir, "projects"));
11746
12471
  }
11747
- return [join18(homedir9(), ".claude", "projects")];
12472
+ return [join19(homedir10(), ".claude", "projects")];
11748
12473
  }
11749
12474
  findJsonlPath(uuid) {
11750
12475
  const filename = `${uuid}.jsonl`;
11751
12476
  for (const projectsDir of this.projectsDirs()) {
11752
12477
  if (!existsSync11(projectsDir)) continue;
11753
12478
  for (const dir of readdirSync6(projectsDir)) {
11754
- const fp = join18(projectsDir, dir, filename);
12479
+ const fp = join19(projectsDir, dir, filename);
11755
12480
  if (existsSync11(fp)) return fp;
11756
- const projectDir = join18(projectsDir, dir);
12481
+ const projectDir = join19(projectsDir, dir);
11757
12482
  try {
11758
12483
  for (const sub of readdirSync6(projectDir)) {
11759
- const subagentPath = join18(projectDir, sub, "subagents", filename);
12484
+ const subagentPath = join19(projectDir, sub, "subagents", filename);
11760
12485
  if (existsSync11(subagentPath)) return subagentPath;
11761
12486
  }
11762
12487
  } catch {
@@ -12163,6 +12888,16 @@ var StreamerServer = class {
12163
12888
  const windowStart = Math.max(0, beforeIndex - scanLimit);
12164
12889
  const indexWindow = scanLimit > 0 && !hasAnchor && indexFilePath && this.cache ? this.cache.readMessageWindow(indexFilePath, windowStart, beforeIndex) : null;
12165
12890
  if (!indexWindow && indexFilePath && this.cache && !hasAnchor) {
12891
+ this.log.info(
12892
+ `[server] offset-index miss ${id} \u2192 scanner fallback`,
12893
+ {
12894
+ event: "offset_index.miss",
12895
+ conversationId: id,
12896
+ fromIndex: windowStart,
12897
+ toIndex: beforeIndex
12898
+ },
12899
+ "pino"
12900
+ );
12166
12901
  this.trackCacheWrite(
12167
12902
  this.cache.backfillIndex(indexFilePath).catch((err) => {
12168
12903
  this.log.warn("offset-index.backfill_failed", {
@@ -12437,14 +13172,15 @@ var StreamerServer = class {
12437
13172
  }
12438
13173
  async handleGetSession(sessionId, res) {
12439
13174
  if (this.rejectIfWarmingUp(res)) return;
12440
- const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12441
- if (session) {
12442
- if (!existsSync11(session.projectPath)) {
12443
- session.failureReason = `Project directory not found: ${session.projectPath}`;
12444
- }
12445
- const reconciled = this.withReconciledLifecycle([session])[0];
12446
- session.lifecycle = reconciled.lifecycle;
12447
- session.lifecycleSource = reconciled.lifecycleSource;
13175
+ const base = this.sessionStore.get(sessionId, this.ptyAttachedIds());
13176
+ if (base) {
13177
+ const reconciled = this.withReconciledLifecycle([base])[0];
13178
+ const session = {
13179
+ ...base,
13180
+ ...existsSync11(base.projectPath) ? {} : { failureReason: `Project directory not found: ${base.projectPath}` },
13181
+ lifecycle: reconciled.lifecycle,
13182
+ lifecycleSource: reconciled.lifecycleSource
13183
+ };
12448
13184
  if (this.ptyManager.hasSession(sessionId)) {
12449
13185
  try {
12450
13186
  const lines = await this.ptyManager.getOutputLines(sessionId, 10);
@@ -12607,31 +13343,37 @@ var StreamerServer = class {
12607
13343
  this.sessionStore.addManaged(session);
12608
13344
  this.recordSessionSpawn(session);
12609
13345
  void this.watchConversationFile(sessionId, historyId);
12610
- const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12611
13346
  this.enrichResumedSessionAsync(sessionId, projectPath, conv);
13347
+ const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12612
13348
  return { ok: true, alreadyRunning: false, session, response };
12613
13349
  }
12614
13350
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
12615
13351
  try {
12616
- const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12617
- if (!session) return;
13352
+ if (!this.sessionStore.getManaged(sessionId)) return;
12618
13353
  if (conv) {
12619
- session.sessionName = conv.sessionName ?? void 0;
12620
- session.messageCount = conv.messageCount ?? 0;
12621
- session.account = conv.account ?? void 0;
12622
- session.filePath = conv.filePath ?? void 0;
13354
+ this.sessionStore.updateManaged(sessionId, {
13355
+ sessionName: conv.sessionName ?? void 0,
13356
+ messageCount: conv.messageCount ?? 0,
13357
+ account: conv.account ?? void 0,
13358
+ filePath: conv.filePath ?? void 0
13359
+ });
12623
13360
  }
12624
13361
  if (!this.cache || !this.projectsRepo || !this.conversationsRepo) return;
12625
13362
  const cached3 = this.cache.getMetaById(sessionId);
12626
13363
  if (cached3) {
12627
- session.model = cached3.model ?? void 0;
12628
- session.preview = cached3.preview ?? void 0;
12629
13364
  const first = cached3.firstMessage ? JSON.parse(cached3.firstMessage) : null;
12630
13365
  const last = cached3.lastMessage ? JSON.parse(cached3.lastMessage) : null;
12631
- session.firstMessageText = first?.text ?? void 0;
12632
- session.firstMessageAt = first?.timestamp ? new Date(first.timestamp).toISOString() : void 0;
12633
- session.lastMessageText = last?.text ?? void 0;
12634
- session.lastMessageAt = last?.timestamp ? new Date(last.timestamp).toISOString() : void 0;
13366
+ this.sessionStore.updateManaged(sessionId, {
13367
+ model: cached3.model ?? void 0,
13368
+ preview: cached3.preview ?? void 0,
13369
+ firstMessageText: first?.text ?? void 0,
13370
+ // parseIsoDateOrNull, not `new Date()`: an unparseable cached
13371
+ // timestamp must land as absent, not as an Invalid Date that
13372
+ // managedToResponse would throw on when it calls .toISOString().
13373
+ firstMessageAt: parseIsoDateOrNull(first?.timestamp) ?? void 0,
13374
+ lastMessageText: last?.text ?? void 0,
13375
+ lastMessageAt: parseIsoDateOrNull(last?.timestamp) ?? void 0
13376
+ });
12635
13377
  }
12636
13378
  let resolvedProjectId = cached3?.projectId ?? null;
12637
13379
  if (!resolvedProjectId) {
@@ -12643,8 +13385,10 @@ var StreamerServer = class {
12643
13385
  });
12644
13386
  }
12645
13387
  if (resolvedProjectId) {
12646
- session.projectId = resolvedProjectId;
12647
- session.resumedFromConversationId = sessionId;
13388
+ this.sessionStore.updateManaged(sessionId, {
13389
+ projectId: resolvedProjectId,
13390
+ resumedFromConversationId: sessionId
13391
+ });
12648
13392
  }
12649
13393
  } catch (err) {
12650
13394
  console.error(`[enrichResumedSessionAsync] ${sessionId}:`, err);
@@ -13075,7 +13819,7 @@ var StreamerServer = class {
13075
13819
  sessionStore: this.sessionStore,
13076
13820
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
13077
13821
  agentClient: this.agentClient,
13078
- conversationsDir: this.cacheDir ? join18(dirname9(this.cacheDir), "conversations") : "",
13822
+ conversationsDir: this.cacheDir ? join19(dirname9(this.cacheDir), "conversations") : "",
13079
13823
  agentConfig: this.agentConfig
13080
13824
  });
13081
13825
  json(res, result.status, result.body);
@@ -13239,9 +13983,9 @@ var StreamerServer = class {
13239
13983
  // was passed to Claude via --session-id so the filename matches from the start.
13240
13984
  watchForJsonl(sessionId, projectPath) {
13241
13985
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
13242
- const projectsDir = join18(homedir9(), ".claude", "projects", encoded);
13986
+ const projectsDir = join19(homedir10(), ".claude", "projects", encoded);
13243
13987
  const expectedFile = `${sessionId}.jsonl`;
13244
- const filePath = join18(projectsDir, expectedFile);
13988
+ const filePath = join19(projectsDir, expectedFile);
13245
13989
  const deadline = Date.now() + 12e4;
13246
13990
  let watcher = null;
13247
13991
  const cleanup = () => {
@@ -13263,10 +14007,10 @@ var StreamerServer = class {
13263
14007
  if (!resolvedFilePath && existsSync11(projectsDir)) {
13264
14008
  try {
13265
14009
  const now = Date.now();
13266
- const match = readdirSync6(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync9(join18(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
13267
- ({ f }) => basename5(f, ".jsonl") === sessionId || this.readFirstLineSessionId(join18(projectsDir, f)) === sessionId
14010
+ const match = readdirSync6(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync9(join19(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
14011
+ ({ f }) => basename5(f, ".jsonl") === sessionId || this.readFirstLineSessionId(join19(projectsDir, f)) === sessionId
13268
14012
  ).sort((a, b) => b.mtime - a.mtime)[0];
13269
- if (match) resolvedFilePath = join18(projectsDir, match.f);
14013
+ if (match) resolvedFilePath = join19(projectsDir, match.f);
13270
14014
  } catch {
13271
14015
  }
13272
14016
  }
@@ -13314,7 +14058,7 @@ var StreamerServer = class {
13314
14058
  watchForCodexRollout(sessionId, projectPath) {
13315
14059
  const deadline = Date.now() + 12e4;
13316
14060
  const now = /* @__PURE__ */ new Date();
13317
- const dateDir = join18(
14061
+ const dateDir = join19(
13318
14062
  String(now.getFullYear()),
13319
14063
  String(now.getMonth() + 1).padStart(2, "0"),
13320
14064
  String(now.getDate()).padStart(2, "0")
@@ -13355,7 +14099,7 @@ var StreamerServer = class {
13355
14099
  this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
13356
14100
  );
13357
14101
  for (const root of this.codexRoots) {
13358
- const sessionsDir = join18(root, dateDir);
14102
+ const sessionsDir = join19(root, dateDir);
13359
14103
  if (!existsSync11(sessionsDir)) continue;
13360
14104
  let candidateFiles;
13361
14105
  try {
@@ -13364,9 +14108,9 @@ var StreamerServer = class {
13364
14108
  continue;
13365
14109
  }
13366
14110
  const nowMs = Date.now();
13367
- const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync9(join18(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
14111
+ const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync9(join19(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
13368
14112
  for (const { f } of recentCandidates) {
13369
- const candidatePath = join18(sessionsDir, f);
14113
+ const candidatePath = join19(sessionsDir, f);
13370
14114
  const match = matchesProjectPath(candidatePath);
13371
14115
  if (!match) continue;
13372
14116
  if (boundElsewhere.has(match.id)) continue;