@threadbase-sh/streamer 1.42.0 → 1.44.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.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,
@@ -8781,6 +9420,34 @@ function paginate(results, offset, limit) {
8781
9420
  };
8782
9421
  }
8783
9422
 
9423
+ // src/services/sessions/autoResumeOnBoot.ts
9424
+ var AUTO_RESUME_WINDOW_MS = 15 * 60 * 1e3;
9425
+ var AUTO_RESUME_MAX = 5;
9426
+ var AUTO_RESUME_CONCURRENCY = 2;
9427
+ var AUTO_RESUME_STAGGER_MS = 500;
9428
+ function autoResumeSkipReason(row, opts) {
9429
+ if (row.status_source !== "shutdown") return "not_shutdown";
9430
+ if (row.status !== "running" && row.status !== "waiting_input") return "not_interrupted";
9431
+ if (opts.now - row.status_updated_at > AUTO_RESUME_WINDOW_MS) return "too_old";
9432
+ if (!opts.projectExists(row.project_path)) return "project_missing";
9433
+ if (resumeIdForRow(row) == null) return "resume_identity_missing";
9434
+ return null;
9435
+ }
9436
+ function planAutoResume(rows, opts) {
9437
+ const eligible = [];
9438
+ const skipped = [];
9439
+ for (const row of rows) {
9440
+ const reason = autoResumeSkipReason(row, opts);
9441
+ if (reason) skipped.push({ row, reason });
9442
+ else eligible.push(row);
9443
+ }
9444
+ return {
9445
+ attempts: eligible.slice(0, AUTO_RESUME_MAX),
9446
+ skipped,
9447
+ overflow: eligible.slice(AUTO_RESUME_MAX)
9448
+ };
9449
+ }
9450
+
8784
9451
  // src/services/sessions/conversationBusy.ts
8785
9452
  import { statSync as statSync8 } from "fs";
8786
9453
  var RESUME_BUSY_WINDOW_MS = 12e4;
@@ -8968,6 +9635,12 @@ var SessionStore = class {
8968
9635
  removeManaged(sessionId) {
8969
9636
  return this.managed.delete(sessionId);
8970
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
+ */
8971
9644
  getManaged(sessionId) {
8972
9645
  return this.managed.get(sessionId) ?? null;
8973
9646
  }
@@ -8977,12 +9650,21 @@ var SessionStore = class {
8977
9650
  this.discovered.set(proc.pid, proc);
8978
9651
  }
8979
9652
  }
9653
+ /**
9654
+ * The **live** stored records — mutating an element mutates the store. Paired
9655
+ * with `list()`, which hands back throwaway response copies.
9656
+ */
8980
9657
  listManaged() {
8981
9658
  return Array.from(this.managed.values());
8982
9659
  }
8983
9660
  // Build the session list: live PTY sessions (managed) merged with externally
8984
9661
  // discovered Claude processes. Managed sessions keyed by JSONL UUID take
8985
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.
8986
9668
  list(ptyAttachedIds) {
8987
9669
  const results = [];
8988
9670
  const seenIds = /* @__PURE__ */ new Set();
@@ -8998,6 +9680,9 @@ var SessionStore = class {
8998
9680
  }
8999
9681
  return results;
9000
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.
9001
9686
  get(sessionId, ptyAttachedIds) {
9002
9687
  const managed = this.managed.get(sessionId);
9003
9688
  if (managed) return managedToResponse(managed, ptyAttachedIds.has(sessionId));
@@ -9089,8 +9774,8 @@ function managedToResponse(s, ptyAttached) {
9089
9774
  // behind it — `resumable`, and `historical` rather than `managed`
9090
9775
  // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
9091
9776
  lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
9092
- lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
9093
- // 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
9094
9779
  // `activity` is attached for managed sessions.
9095
9780
  ownership: s.rehydrated ? "historical" : "managed",
9096
9781
  projectPath: s.projectPath,
@@ -9165,7 +9850,7 @@ function discoveredToResponse(d, conversationId) {
9165
9850
  import { randomBytes as randomBytes4 } from "crypto";
9166
9851
  import { mkdir as mkdir3, writeFile } from "fs/promises";
9167
9852
  import heicConvert from "heic-convert";
9168
- import { join as join17 } from "path";
9853
+ import { join as join18 } from "path";
9169
9854
  var UPLOAD_DIR_NAME = ".threadbase-uploads";
9170
9855
  var MAX_BYTES = 25 * 1024 * 1024;
9171
9856
  var HEIC_MIMES = /* @__PURE__ */ new Set(["image/heic", "image/heif"]);
@@ -9198,9 +9883,9 @@ async function saveUploadFile(input) {
9198
9883
  }
9199
9884
  const id = `up_${randomBytes4(8).toString("hex")}`;
9200
9885
  const safeName = sanitizeFilename(originalName) || `file${MIME_TO_EXT[mimeType] ?? ""}`;
9201
- const dir = join17(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
9886
+ const dir = join18(input.projectPath, UPLOAD_DIR_NAME, input.sessionId);
9202
9887
  await mkdir3(dir, { recursive: true });
9203
- const filePath = join17(dir, `${Date.now()}-${id}-${safeName}`);
9888
+ const filePath = join18(dir, `${Date.now()}-${id}-${safeName}`);
9204
9889
  await writeFile(filePath, buffer);
9205
9890
  return {
9206
9891
  id,
@@ -9622,6 +10307,7 @@ var StreamerServer = class {
9622
10307
  disableDb = false;
9623
10308
  // Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
9624
10309
  skipStartupWarmup;
10310
+ autoResumeOnBoot;
9625
10311
  browseRoot = null;
9626
10312
  publicUrl = null;
9627
10313
  browserCors;
@@ -9738,9 +10424,10 @@ var StreamerServer = class {
9738
10424
  this.verbose = config.verbose ?? false;
9739
10425
  this.disableDb = config.disableDb ?? false;
9740
10426
  this.skipStartupWarmup = config.skipStartupWarmup ?? false;
10427
+ this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
9741
10428
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
9742
10429
  this.scanProfiles = config.scanProfiles;
9743
- this.codexRoots = config.codexRoots ?? [join18(homedir9(), ".codex", "sessions")];
10430
+ this.codexRoots = config.codexRoots ?? [join19(homedir10(), ".codex", "sessions")];
9744
10431
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
9745
10432
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
9746
10433
  this.featureFlags = resolveFeatureFlags({ cli: config.featureFlags, yaml: loadFeatureFlags() });
@@ -9754,8 +10441,8 @@ var StreamerServer = class {
9754
10441
  this.claudeFlagsPersistable = config.claudeFlags === void 0;
9755
10442
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
9756
10443
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
9757
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join18(homedir9(), ".threadbase", "cache");
9758
- 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");
9759
10446
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
9760
10447
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
9761
10448
  this.markScannerStaleDebounced = debounce(() => {
@@ -10011,7 +10698,7 @@ var StreamerServer = class {
10011
10698
  temporalClient,
10012
10699
  taskQueue: agentConfig.temporal.taskQueue
10013
10700
  });
10014
- const conversationsBaseDir = agentConfig.conversationsDir || join18(dirname9(this.cacheDir), "conversations");
10701
+ const conversationsBaseDir = agentConfig.conversationsDir || join19(dirname9(this.cacheDir), "conversations");
10015
10702
  conversationWriter = createConversationWriter({
10016
10703
  baseDir: conversationsBaseDir
10017
10704
  });
@@ -10164,7 +10851,7 @@ var StreamerServer = class {
10164
10851
  conversationWriter,
10165
10852
  agentConfig
10166
10853
  };
10167
- this.httpServer = createServer((req, res) => this.handleRequest(req, res));
10854
+ this.httpServer = createServer2((req, res) => this.handleRequest(req, res));
10168
10855
  this.httpServer.on("clientError", (_err, socket) => {
10169
10856
  try {
10170
10857
  socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
@@ -10218,16 +10905,19 @@ var StreamerServer = class {
10218
10905
  broadcastOrUnicastSessionList(req) {
10219
10906
  const clientId = req.headers["x-client-id"];
10220
10907
  const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
10221
- const payload = {
10222
- type: "session_list",
10223
- sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
10224
- };
10908
+ const payload = this.sessionListPayload();
10225
10909
  if (ws) {
10226
10910
  this.wsHub.unicast(ws, payload);
10227
10911
  } else {
10228
10912
  this.wsHub.broadcast(payload);
10229
10913
  }
10230
10914
  }
10915
+ sessionListPayload() {
10916
+ return {
10917
+ type: "session_list",
10918
+ sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
10919
+ };
10920
+ }
10231
10921
  /**
10232
10922
  * Overlay boot-reconciliation verdicts onto session responses.
10233
10923
  *
@@ -10315,7 +11005,7 @@ var StreamerServer = class {
10315
11005
  if (!this.managedSessionsRepo) return [];
10316
11006
  let verdicts = [];
10317
11007
  try {
10318
- const rows = this.managedSessionsRepo.listNonTerminal();
11008
+ const rows = this.managedSessionsRepo.listNonTerminal().filter((row) => !this.ptyManager.hasSession(row.session_id));
10319
11009
  if (rows.length === PROBE_SET_MAX) {
10320
11010
  this.log.warn(
10321
11011
  `[reconcile] probe set hit its cap of ${PROBE_SET_MAX} \u2014 older rows skipped`,
@@ -10401,7 +11091,7 @@ var StreamerServer = class {
10401
11091
  * by id rather than duplicating it.
10402
11092
  */
10403
11093
  rehydratePreviousSessions(verdicts) {
10404
- if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
11094
+ if (!this.managedSessionsRepo) return [];
10405
11095
  try {
10406
11096
  const now = Date.now();
10407
11097
  const rows = this.managedSessionsRepo.listRecoverable({
@@ -10410,7 +11100,7 @@ var StreamerServer = class {
10410
11100
  });
10411
11101
  const truncated = rows.length > REHYDRATE_MAX;
10412
11102
  const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
10413
- if (candidates.length === 0) return;
11103
+ if (!this.featureFlags.sessionRehydration || candidates.length === 0) return candidates;
10414
11104
  const verdictById = new Map(verdicts.map((v) => [v.sessionId, v]));
10415
11105
  let rehydrated = 0;
10416
11106
  const skippedBy = {};
@@ -10445,12 +11135,107 @@ var StreamerServer = class {
10445
11135
  skippedBy,
10446
11136
  truncated
10447
11137
  });
11138
+ return candidates;
10448
11139
  } catch (err) {
10449
11140
  this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
10450
11141
  event: "sessions.rehydrate_failed",
10451
11142
  err
10452
11143
  });
11144
+ return [];
11145
+ }
11146
+ }
11147
+ /** Resume only the recent sessions the user explicitly allowed us to start at boot. */
11148
+ async autoResumePreviousSessions(rows) {
11149
+ if (!this.autoResumeOnBoot) return;
11150
+ const plan = planAutoResume(rows, { now: Date.now(), projectExists: existsSync11 });
11151
+ const skippedBy = {};
11152
+ for (const { row, reason } of plan.skipped) {
11153
+ skippedBy[reason] = (skippedBy[reason] ?? 0) + 1;
11154
+ this.log.debug(`[auto-resume] skipped ${row.session_id}: ${reason}`, {
11155
+ event: "sessions.auto_resume_skipped",
11156
+ sessionId: row.session_id,
11157
+ reason
11158
+ });
11159
+ }
11160
+ if (plan.skipped.length > 0) {
11161
+ this.log.info(
11162
+ `[auto-resume] left ${plan.skipped.length} ineligible session(s) for manual resume`,
11163
+ {
11164
+ event: "sessions.auto_resume_skipped",
11165
+ skipped: plan.skipped.length,
11166
+ skippedBy
11167
+ }
11168
+ );
11169
+ }
11170
+ for (const row of plan.overflow) {
11171
+ this.log.info(`[auto-resume] left ${row.session_id} for manual resume: ceiling reached`, {
11172
+ event: "sessions.auto_resume_skipped",
11173
+ sessionId: row.session_id,
11174
+ reason: "ceiling_reached"
11175
+ });
10453
11176
  }
11177
+ let resumed = 0;
11178
+ let failed = 0;
11179
+ const inFlight = /* @__PURE__ */ new Set();
11180
+ let started = 0;
11181
+ const resume = async (row) => {
11182
+ try {
11183
+ const outcome = await this.resumeSession({
11184
+ sessionId: row.session_id,
11185
+ projectName: row.project_name,
11186
+ branch: row.branch
11187
+ });
11188
+ if (!outcome.ok) {
11189
+ failed++;
11190
+ this.log.info(`[auto-resume] skipped ${row.session_id}: ${outcome.reason}`, {
11191
+ event: "sessions.auto_resume_skipped",
11192
+ sessionId: row.session_id,
11193
+ reason: outcome.reason,
11194
+ ...outcome.reason === "conversation_busy" && {
11195
+ detectedBy: outcome.detectedBy,
11196
+ lastActivityMs: outcome.lastActivityMs,
11197
+ likelyOwner: outcome.likelyOwner
11198
+ }
11199
+ });
11200
+ return;
11201
+ }
11202
+ resumed++;
11203
+ this.log.info(`[auto-resume] resumed ${row.session_id}`, {
11204
+ event: "sessions.auto_resume_succeeded",
11205
+ sessionId: row.session_id,
11206
+ alreadyRunning: outcome.alreadyRunning
11207
+ });
11208
+ } catch (err) {
11209
+ failed++;
11210
+ this.log.warn(`[auto-resume] failed to resume ${row.session_id}`, {
11211
+ event: "sessions.auto_resume_failed",
11212
+ sessionId: row.session_id,
11213
+ err
11214
+ });
11215
+ }
11216
+ };
11217
+ for (const row of plan.attempts) {
11218
+ while (inFlight.size >= AUTO_RESUME_CONCURRENCY) {
11219
+ await Promise.race(inFlight);
11220
+ }
11221
+ if (started > 0) {
11222
+ await new Promise((resolve2) => setTimeout(resolve2, AUTO_RESUME_STAGGER_MS));
11223
+ }
11224
+ const task = resume(row);
11225
+ inFlight.add(task);
11226
+ void task.then(() => inFlight.delete(task));
11227
+ started++;
11228
+ }
11229
+ await Promise.all(inFlight);
11230
+ if (resumed > 0) this.wsHub.broadcast(this.sessionListPayload());
11231
+ this.log.info(`[auto-resume] completed boot recovery: ${resumed} resumed`, {
11232
+ event: "sessions.auto_resume_completed",
11233
+ attempted: plan.attempts.length,
11234
+ resumed,
11235
+ failed,
11236
+ ineligible: plan.skipped.length,
11237
+ overflow: plan.overflow.length
11238
+ });
10454
11239
  }
10455
11240
  /**
10456
11241
  * Pick a token guaranteed to appear in the spawned process's argv, for the
@@ -10467,6 +11252,26 @@ var StreamerServer = class {
10467
11252
  if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
10468
11253
  return session.boundConversationId ?? session.projectPath;
10469
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
+ }
10470
11275
  /**
10471
11276
  * Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
10472
11277
  *
@@ -10562,6 +11367,7 @@ var StreamerServer = class {
10562
11367
  * of waiting on the interval.
10563
11368
  */
10564
11369
  reapIdleSessions(now = Date.now()) {
11370
+ if (this.ptyManager.isRemote()) return [];
10565
11371
  const reaped = [];
10566
11372
  for (const session of this.ptyManager.listSessions()) {
10567
11373
  if (session.status === "running") continue;
@@ -10663,6 +11469,40 @@ var StreamerServer = class {
10663
11469
  return true;
10664
11470
  }
10665
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
+ }
10666
11506
  const dbConfig = this.disableDb ? null : getDbConfig();
10667
11507
  if (dbConfig) {
10668
11508
  this.dbPool = await createPool(dbConfig);
@@ -10677,8 +11517,10 @@ var StreamerServer = class {
10677
11517
  this.log.info("Database migrations applied", { event: "db.migrations_applied" });
10678
11518
  }
10679
11519
  await this.bindWithRetry(port);
10680
- this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
10681
- this.idleReaperTimer.unref?.();
11520
+ if (!this.ptyManager.isRemote()) {
11521
+ this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
11522
+ this.idleReaperTimer.unref?.();
11523
+ }
10682
11524
  const warmUp = new Promise((resolveWarm) => {
10683
11525
  {
10684
11526
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -10696,9 +11538,18 @@ var StreamerServer = class {
10696
11538
  { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
10697
11539
  );
10698
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
+ }
10699
11550
  try {
10700
11551
  this.cache = ConversationCache.open(
10701
- join18(this.cacheDir, "cache.db"),
11552
+ join19(this.cacheDir, "cache.db"),
10702
11553
  this.tailSize,
10703
11554
  void 0,
10704
11555
  {
@@ -10772,8 +11623,10 @@ var StreamerServer = class {
10772
11623
  );
10773
11624
  this.scannerPersistenceDisabled = true;
10774
11625
  }
10775
- void this.reconcilePreviousSessions().then((v) => {
10776
- this.rehydratePreviousSessions(v);
11626
+ if (this.ptyManager.isRemote()) this.refreshHostedSessionsFromRegistry();
11627
+ void this.reconcilePreviousSessions().then(async (v) => {
11628
+ const recoverableRows = this.rehydratePreviousSessions(v);
11629
+ await this.autoResumePreviousSessions(recoverableRows);
10777
11630
  this.pruneTerminalSessions();
10778
11631
  });
10779
11632
  if (this.skipStartupWarmup) {
@@ -10976,7 +11829,8 @@ var StreamerServer = class {
10976
11829
  }
10977
11830
  this.lastAgentChunkAt.clear();
10978
11831
  this.terminalSeq.clear();
10979
- this.recordShutdownState();
11832
+ if (this.ptyManager.isRemote()) this.ptyManager.dispose();
11833
+ else this.recordShutdownState();
10980
11834
  this.markScannerStaleDebounced.cancel();
10981
11835
  await Promise.all([...this.inFlightCacheWrites]);
10982
11836
  await Promise.all([...this.allScanners].map((s) => s.close()));
@@ -10984,7 +11838,7 @@ var StreamerServer = class {
10984
11838
  this.scanner = null;
10985
11839
  this.cache?.close();
10986
11840
  this.runtimeStore?.close();
10987
- this.ptyManager.dispose();
11841
+ if (!this.ptyManager.isRemote()) this.ptyManager.dispose();
10988
11842
  this.fileWatcher.dispose();
10989
11843
  this.externalTails.clear();
10990
11844
  this.wsHub.dispose();
@@ -11210,9 +12064,9 @@ var StreamerServer = class {
11210
12064
  /** Project roots watched for conversation JSONLs (profiles or ~/.claude/projects). */
11211
12065
  projectsDirsForFreshnessCheck() {
11212
12066
  if (this.scanProfiles && this.scanProfiles.length > 0) {
11213
- 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"));
11214
12068
  }
11215
- return [join18(homedir9(), ".claude", "projects")];
12069
+ return [join19(homedir10(), ".claude", "projects")];
11216
12070
  }
11217
12071
  /**
11218
12072
  * Full-glob scan + cache upsert/delete reconcile. Used by ?refresh=1 and by
@@ -11613,21 +12467,21 @@ var StreamerServer = class {
11613
12467
  */
11614
12468
  projectsDirs() {
11615
12469
  if (this.scanProfiles && this.scanProfiles.length > 0) {
11616
- 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"));
11617
12471
  }
11618
- return [join18(homedir9(), ".claude", "projects")];
12472
+ return [join19(homedir10(), ".claude", "projects")];
11619
12473
  }
11620
12474
  findJsonlPath(uuid) {
11621
12475
  const filename = `${uuid}.jsonl`;
11622
12476
  for (const projectsDir of this.projectsDirs()) {
11623
12477
  if (!existsSync11(projectsDir)) continue;
11624
12478
  for (const dir of readdirSync6(projectsDir)) {
11625
- const fp = join18(projectsDir, dir, filename);
12479
+ const fp = join19(projectsDir, dir, filename);
11626
12480
  if (existsSync11(fp)) return fp;
11627
- const projectDir = join18(projectsDir, dir);
12481
+ const projectDir = join19(projectsDir, dir);
11628
12482
  try {
11629
12483
  for (const sub of readdirSync6(projectDir)) {
11630
- const subagentPath = join18(projectDir, sub, "subagents", filename);
12484
+ const subagentPath = join19(projectDir, sub, "subagents", filename);
11631
12485
  if (existsSync11(subagentPath)) return subagentPath;
11632
12486
  }
11633
12487
  } catch {
@@ -12034,6 +12888,16 @@ var StreamerServer = class {
12034
12888
  const windowStart = Math.max(0, beforeIndex - scanLimit);
12035
12889
  const indexWindow = scanLimit > 0 && !hasAnchor && indexFilePath && this.cache ? this.cache.readMessageWindow(indexFilePath, windowStart, beforeIndex) : null;
12036
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
+ );
12037
12901
  this.trackCacheWrite(
12038
12902
  this.cache.backfillIndex(indexFilePath).catch((err) => {
12039
12903
  this.log.warn("offset-index.backfill_failed", {
@@ -12308,14 +13172,15 @@ var StreamerServer = class {
12308
13172
  }
12309
13173
  async handleGetSession(sessionId, res) {
12310
13174
  if (this.rejectIfWarmingUp(res)) return;
12311
- const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12312
- if (session) {
12313
- if (!existsSync11(session.projectPath)) {
12314
- session.failureReason = `Project directory not found: ${session.projectPath}`;
12315
- }
12316
- const reconciled = this.withReconciledLifecycle([session])[0];
12317
- session.lifecycle = reconciled.lifecycle;
12318
- 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
+ };
12319
13184
  if (this.ptyManager.hasSession(sessionId)) {
12320
13185
  try {
12321
13186
  const lines = await this.ptyManager.getOutputLines(sessionId, 10);
@@ -12478,31 +13343,37 @@ var StreamerServer = class {
12478
13343
  this.sessionStore.addManaged(session);
12479
13344
  this.recordSessionSpawn(session);
12480
13345
  void this.watchConversationFile(sessionId, historyId);
12481
- const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12482
13346
  this.enrichResumedSessionAsync(sessionId, projectPath, conv);
13347
+ const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12483
13348
  return { ok: true, alreadyRunning: false, session, response };
12484
13349
  }
12485
13350
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
12486
13351
  try {
12487
- const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12488
- if (!session) return;
13352
+ if (!this.sessionStore.getManaged(sessionId)) return;
12489
13353
  if (conv) {
12490
- session.sessionName = conv.sessionName ?? void 0;
12491
- session.messageCount = conv.messageCount ?? 0;
12492
- session.account = conv.account ?? void 0;
12493
- 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
+ });
12494
13360
  }
12495
13361
  if (!this.cache || !this.projectsRepo || !this.conversationsRepo) return;
12496
13362
  const cached3 = this.cache.getMetaById(sessionId);
12497
13363
  if (cached3) {
12498
- session.model = cached3.model ?? void 0;
12499
- session.preview = cached3.preview ?? void 0;
12500
13364
  const first = cached3.firstMessage ? JSON.parse(cached3.firstMessage) : null;
12501
13365
  const last = cached3.lastMessage ? JSON.parse(cached3.lastMessage) : null;
12502
- session.firstMessageText = first?.text ?? void 0;
12503
- session.firstMessageAt = first?.timestamp ? new Date(first.timestamp).toISOString() : void 0;
12504
- session.lastMessageText = last?.text ?? void 0;
12505
- 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
+ });
12506
13377
  }
12507
13378
  let resolvedProjectId = cached3?.projectId ?? null;
12508
13379
  if (!resolvedProjectId) {
@@ -12514,8 +13385,10 @@ var StreamerServer = class {
12514
13385
  });
12515
13386
  }
12516
13387
  if (resolvedProjectId) {
12517
- session.projectId = resolvedProjectId;
12518
- session.resumedFromConversationId = sessionId;
13388
+ this.sessionStore.updateManaged(sessionId, {
13389
+ projectId: resolvedProjectId,
13390
+ resumedFromConversationId: sessionId
13391
+ });
12519
13392
  }
12520
13393
  } catch (err) {
12521
13394
  console.error(`[enrichResumedSessionAsync] ${sessionId}:`, err);
@@ -12946,7 +13819,7 @@ var StreamerServer = class {
12946
13819
  sessionStore: this.sessionStore,
12947
13820
  // biome-ignore lint/style/noNonNullAssertion: agentClient is set when agentConfig.enabled is true
12948
13821
  agentClient: this.agentClient,
12949
- conversationsDir: this.cacheDir ? join18(dirname9(this.cacheDir), "conversations") : "",
13822
+ conversationsDir: this.cacheDir ? join19(dirname9(this.cacheDir), "conversations") : "",
12950
13823
  agentConfig: this.agentConfig
12951
13824
  });
12952
13825
  json(res, result.status, result.body);
@@ -13110,9 +13983,9 @@ var StreamerServer = class {
13110
13983
  // was passed to Claude via --session-id so the filename matches from the start.
13111
13984
  watchForJsonl(sessionId, projectPath) {
13112
13985
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
13113
- const projectsDir = join18(homedir9(), ".claude", "projects", encoded);
13986
+ const projectsDir = join19(homedir10(), ".claude", "projects", encoded);
13114
13987
  const expectedFile = `${sessionId}.jsonl`;
13115
- const filePath = join18(projectsDir, expectedFile);
13988
+ const filePath = join19(projectsDir, expectedFile);
13116
13989
  const deadline = Date.now() + 12e4;
13117
13990
  let watcher = null;
13118
13991
  const cleanup = () => {
@@ -13134,10 +14007,10 @@ var StreamerServer = class {
13134
14007
  if (!resolvedFilePath && existsSync11(projectsDir)) {
13135
14008
  try {
13136
14009
  const now = Date.now();
13137
- const match = readdirSync6(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync9(join18(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
13138
- ({ 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
13139
14012
  ).sort((a, b) => b.mtime - a.mtime)[0];
13140
- if (match) resolvedFilePath = join18(projectsDir, match.f);
14013
+ if (match) resolvedFilePath = join19(projectsDir, match.f);
13141
14014
  } catch {
13142
14015
  }
13143
14016
  }
@@ -13185,7 +14058,7 @@ var StreamerServer = class {
13185
14058
  watchForCodexRollout(sessionId, projectPath) {
13186
14059
  const deadline = Date.now() + 12e4;
13187
14060
  const now = /* @__PURE__ */ new Date();
13188
- const dateDir = join18(
14061
+ const dateDir = join19(
13189
14062
  String(now.getFullYear()),
13190
14063
  String(now.getMonth() + 1).padStart(2, "0"),
13191
14064
  String(now.getDate()).padStart(2, "0")
@@ -13226,7 +14099,7 @@ var StreamerServer = class {
13226
14099
  this.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
13227
14100
  );
13228
14101
  for (const root of this.codexRoots) {
13229
- const sessionsDir = join18(root, dateDir);
14102
+ const sessionsDir = join19(root, dateDir);
13230
14103
  if (!existsSync11(sessionsDir)) continue;
13231
14104
  let candidateFiles;
13232
14105
  try {
@@ -13235,9 +14108,9 @@ var StreamerServer = class {
13235
14108
  continue;
13236
14109
  }
13237
14110
  const nowMs = Date.now();
13238
- 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);
13239
14112
  for (const { f } of recentCandidates) {
13240
- const candidatePath = join18(sessionsDir, f);
14113
+ const candidatePath = join19(sessionsDir, f);
13241
14114
  const match = matchesProjectPath(candidatePath);
13242
14115
  if (!match) continue;
13243
14116
  if (boundElsewhere.has(match.id)) continue;