@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.cjs CHANGED
@@ -154,14 +154,14 @@ function createConversationWriter(opts) {
154
154
  }
155
155
  const file = (0, import_node_path.join)(baseDir, `${args.sessionId}.jsonl`);
156
156
  await (0, import_promises.mkdir)((0, import_node_path.dirname)(file), { recursive: true });
157
- const record = {
157
+ const record2 = {
158
158
  role: "assistant",
159
159
  turnId: args.turnId,
160
160
  content: args.content,
161
161
  timestamp: Date.now(),
162
162
  ...args.reviewerOverruled ? { reviewerOverruled: true } : {}
163
163
  };
164
- const line = `${JSON.stringify(record)}
164
+ const line = `${JSON.stringify(record2)}
165
165
  `;
166
166
  await (0, import_promises.appendFile)(file, line, { encoding: "utf8" });
167
167
  }
@@ -468,6 +468,9 @@ var baseLogger = (0, import_pino.default)({
468
468
  censor: "[redacted]"
469
469
  }
470
470
  });
471
+ function defaultDest() {
472
+ return process.stdout.isTTY ? "console" : "pino";
473
+ }
471
474
  function emit(pinoChild, level, msg, fields, dest) {
472
475
  if (dest === "pino" || dest === "both") {
473
476
  if (fields && Object.keys(fields).length > 0) pinoChild[level](fields, msg);
@@ -480,11 +483,11 @@ function emit(pinoChild, level, msg, fields, dest) {
480
483
  }
481
484
  function build(pinoChild) {
482
485
  return {
483
- debug: (m, f, d = "both") => emit(pinoChild, "debug", m, f, d),
484
- info: (m, f, d = "both") => emit(pinoChild, "info", m, f, d),
485
- warn: (m, f, d = "both") => emit(pinoChild, "warn", m, f, d),
486
- error: (m, f, d = "both") => emit(pinoChild, "error", m, f, d),
487
- log: (lvl, m, f, d = "both") => emit(pinoChild, lvl, m, f, d),
486
+ debug: (m, f, d = defaultDest()) => emit(pinoChild, "debug", m, f, d),
487
+ info: (m, f, d = defaultDest()) => emit(pinoChild, "info", m, f, d),
488
+ warn: (m, f, d = defaultDest()) => emit(pinoChild, "warn", m, f, d),
489
+ error: (m, f, d = defaultDest()) => emit(pinoChild, "error", m, f, d),
490
+ log: (lvl, m, f, d = defaultDest()) => emit(pinoChild, lvl, m, f, d),
488
491
  pino: pinoChild
489
492
  };
490
493
  }
@@ -506,6 +509,12 @@ var FEATURE_FLAGS = [
506
509
  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.",
507
510
  default: true,
508
511
  env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
512
+ },
513
+ {
514
+ id: "ptyHost",
515
+ 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.",
516
+ default: false,
517
+ env: "THREADBASE_FEATURE_PTY_HOST"
509
518
  }
510
519
  ];
511
520
  function findFeatureFlag(id) {
@@ -872,7 +881,16 @@ var import_fs3 = require("fs");
872
881
  var import_os2 = require("os");
873
882
  var import_path3 = require("path");
874
883
  var isWindows = (0, import_os2.platform)() === "win32";
884
+ var WINDOWS_EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([".exe", ".cmd", ".bat"]);
885
+ function isWindowsExecutablePath(path) {
886
+ const dot = path.lastIndexOf(".");
887
+ if (dot < 0) return false;
888
+ return WINDOWS_EXECUTABLE_EXTENSIONS.has(path.slice(dot).toLowerCase());
889
+ }
875
890
  var _claudeExe;
891
+ function clearClaudeExeCache() {
892
+ _claudeExe = void 0;
893
+ }
876
894
  function resolveClaudeExe() {
877
895
  if (_claudeExe !== void 0) return _claudeExe;
878
896
  if (isWindows) {
@@ -881,7 +899,7 @@ function resolveClaudeExe() {
881
899
  encoding: "utf-8",
882
900
  windowsHide: true,
883
901
  timeout: 3e3
884
- }).trim().split("\n")[0].trim();
902
+ }).trim().split("\n").map((line) => line.trim()).find(isWindowsExecutablePath);
885
903
  if (found) {
886
904
  _claudeExe = found;
887
905
  return _claudeExe;
@@ -931,6 +949,9 @@ function resolveClaudeExe() {
931
949
  return _claudeExe;
932
950
  }
933
951
  var _codexExe;
952
+ function clearCodexExeCache() {
953
+ _codexExe = void 0;
954
+ }
934
955
  function resolveCodexExe() {
935
956
  if (_codexExe !== void 0) return _codexExe;
936
957
  if (isWindows) {
@@ -939,7 +960,7 @@ function resolveCodexExe() {
939
960
  encoding: "utf-8",
940
961
  windowsHide: true,
941
962
  timeout: 3e3
942
- }).trim().split("\n")[0].trim();
963
+ }).trim().split("\n").map((line) => line.trim()).find(isWindowsExecutablePath);
943
964
  if (found) {
944
965
  _codexExe = found;
945
966
  return _codexExe;
@@ -1199,20 +1220,26 @@ var CodexPtyRunner = class {
1199
1220
  async doStart(sessionId, options) {
1200
1221
  const nodePty = await loadPty();
1201
1222
  const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1202
- const proc = nodePty.spawn(
1203
- resolveCodexExe(),
1204
- // `sessionId` stays the runner's map key — only argv carries the
1205
- // provider-side id, so a resumed Codex session keeps the placeholder id
1206
- // its client already navigated to.
1207
- ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1208
- {
1209
- name: "xterm-256color",
1210
- cols: PTY_COLS,
1211
- rows: PTY_ROWS,
1212
- cwd: options.projectPath,
1213
- env: process.env
1214
- }
1215
- );
1223
+ let proc;
1224
+ try {
1225
+ proc = nodePty.spawn(
1226
+ resolveCodexExe(),
1227
+ // `sessionId` stays the runner's map key — only argv carries the
1228
+ // provider-side id, so a resumed Codex session keeps the placeholder id
1229
+ // its client already navigated to.
1230
+ ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1231
+ {
1232
+ name: "xterm-256color",
1233
+ cols: PTY_COLS,
1234
+ rows: PTY_ROWS,
1235
+ cwd: options.projectPath,
1236
+ env: process.env
1237
+ }
1238
+ );
1239
+ } catch (err) {
1240
+ clearCodexExeCache();
1241
+ throw err;
1242
+ }
1216
1243
  const session = {
1217
1244
  id: sessionId,
1218
1245
  provider: CODEX_CLI_PROVIDER,
@@ -1255,13 +1282,19 @@ var CodexPtyRunner = class {
1255
1282
  if (options.systemPrompt) {
1256
1283
  args.push(options.systemPrompt);
1257
1284
  }
1258
- const proc = nodePty.spawn(resolveCodexExe(), args, {
1259
- name: "xterm-256color",
1260
- cols: PTY_COLS,
1261
- rows: PTY_ROWS,
1262
- cwd: options.projectPath,
1263
- env: process.env
1264
- });
1285
+ let proc;
1286
+ try {
1287
+ proc = nodePty.spawn(resolveCodexExe(), args, {
1288
+ name: "xterm-256color",
1289
+ cols: PTY_COLS,
1290
+ rows: PTY_ROWS,
1291
+ cwd: options.projectPath,
1292
+ env: process.env
1293
+ });
1294
+ } catch (err) {
1295
+ clearCodexExeCache();
1296
+ throw err;
1297
+ }
1265
1298
  const session = {
1266
1299
  id: sessionId,
1267
1300
  provider: CODEX_CLI_PROVIDER,
@@ -1736,6 +1769,381 @@ function stripAnsi(str) {
1736
1769
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
1737
1770
  }
1738
1771
 
1772
+ // src/pty-host/protocol.ts
1773
+ var PTY_HOST_PROTOCOL_VERSION = 2;
1774
+ function isHostEvent(message) {
1775
+ return "type" in message && message.type === "event";
1776
+ }
1777
+ var SESSION_DATE_FIELDS = [
1778
+ "startedAt",
1779
+ "completedAt",
1780
+ "statusUpdatedAt",
1781
+ "lastActivityAt",
1782
+ "firstMessageAt",
1783
+ "lastMessageAt"
1784
+ ];
1785
+ function reviveSession(raw) {
1786
+ const s = { ...raw };
1787
+ for (const field of SESSION_DATE_FIELDS) {
1788
+ const value = s[field];
1789
+ if (typeof value === "string") s[field] = new Date(value);
1790
+ }
1791
+ return s;
1792
+ }
1793
+ function encodeMessage(message) {
1794
+ return `${JSON.stringify(message)}
1795
+ `;
1796
+ }
1797
+ var LineDecoder = class {
1798
+ buffer = "";
1799
+ push(chunk) {
1800
+ this.buffer += chunk;
1801
+ const lines = this.buffer.split("\n");
1802
+ this.buffer = lines.pop() ?? "";
1803
+ return lines.filter((line) => line.length > 0);
1804
+ }
1805
+ };
1806
+
1807
+ // src/pty-host/remote-session-runner.ts
1808
+ var PtyHostProtocolMismatchError = class extends Error {
1809
+ constructor(hostVersion, streamerVersion) {
1810
+ super(
1811
+ `pty-host protocol ${hostVersion} is incompatible with streamer protocol ${streamerVersion}`
1812
+ );
1813
+ this.hostVersion = hostVersion;
1814
+ this.streamerVersion = streamerVersion;
1815
+ this.name = "PtyHostProtocolMismatchError";
1816
+ }
1817
+ hostVersion;
1818
+ streamerVersion;
1819
+ };
1820
+ var HOST_HEARTBEAT_INTERVAL_MS = 1e4;
1821
+ var HOST_HEARTBEAT_REQUEST_TIMEOUT_MS = 5e3;
1822
+ var HOST_SHUTDOWN_REQUEST_TIMEOUT_MS = 1e3;
1823
+ var RemoteSessionRunner = class _RemoteSessionRunner {
1824
+ transport;
1825
+ options;
1826
+ decoder = new LineDecoder();
1827
+ nextRequestId = 1;
1828
+ pending = /* @__PURE__ */ new Map();
1829
+ /** The mirror. Rebuilt wholesale by `status`, patched by events. */
1830
+ sessions = /* @__PURE__ */ new Map();
1831
+ /** Ring buffers, fed by `output` events so `getOutput` stays synchronous. */
1832
+ output = /* @__PURE__ */ new Map();
1833
+ inputHistory = /* @__PURE__ */ new Map();
1834
+ /** Fixed for a session's lifetime, so only spawn and status carry it. */
1835
+ pids = /* @__PURE__ */ new Map();
1836
+ closed = false;
1837
+ heartbeatTimer = null;
1838
+ heartbeatInFlight = false;
1839
+ /**
1840
+ * The only supported way to build one: a runner whose mirror has not been
1841
+ * seeded yet would answer `hasSession` with a confident, wrong `false` for
1842
+ * every session the host is holding — which reads as "the agent is gone" and
1843
+ * routes the user to start a new one.
1844
+ */
1845
+ static async connect(transport, options = {}) {
1846
+ const runner = new _RemoteSessionRunner(transport, options);
1847
+ const status = await runner.readStatus();
1848
+ if (status.protocolVersion !== PTY_HOST_PROTOCOL_VERSION) {
1849
+ try {
1850
+ await runner.request({ type: "shutdown-host" }, HOST_SHUTDOWN_REQUEST_TIMEOUT_MS);
1851
+ } catch (err) {
1852
+ options.logger?.warn("[pty-host] incompatible host did not acknowledge shutdown", {
1853
+ event: "pty_host.shutdown_failed",
1854
+ err
1855
+ });
1856
+ } finally {
1857
+ runner.dispose();
1858
+ }
1859
+ throw new PtyHostProtocolMismatchError(status.protocolVersion, PTY_HOST_PROTOCOL_VERSION);
1860
+ }
1861
+ await runner.request({ type: "subscribe" });
1862
+ runner.refreshMirror(status);
1863
+ return runner;
1864
+ }
1865
+ constructor(transport, options) {
1866
+ this.transport = transport;
1867
+ this.options = options;
1868
+ transport.onLine((line) => this.handleLine(line));
1869
+ transport.onClose(() => this.handleClose());
1870
+ }
1871
+ // ─── Transport plumbing ──────────────────────────────────────────
1872
+ handleLine(line) {
1873
+ for (const complete of this.decoder.push(line)) {
1874
+ let message;
1875
+ try {
1876
+ message = JSON.parse(complete);
1877
+ } catch {
1878
+ this.options.logger?.warn("[pty-host] dropped unparseable message", {
1879
+ event: "pty_host.bad_message"
1880
+ });
1881
+ continue;
1882
+ }
1883
+ if (isHostEvent(message)) {
1884
+ this.handleEvent(message);
1885
+ continue;
1886
+ }
1887
+ const waiter = this.pending.get(message.id);
1888
+ if (!waiter) continue;
1889
+ this.pending.delete(message.id);
1890
+ if (waiter.timeout) clearTimeout(waiter.timeout);
1891
+ if (message.ok) waiter.resolve(message.result);
1892
+ else waiter.reject(new Error(message.error));
1893
+ }
1894
+ }
1895
+ /**
1896
+ * Fail every in-flight request when the socket drops.
1897
+ *
1898
+ * Without this each one stays pending forever and the caller — a session
1899
+ * start, an input write — hangs rather than erroring. PR 9 adds reconnection;
1900
+ * until then a dropped host is a hard failure that says so.
1901
+ */
1902
+ handleClose() {
1903
+ if (this.closed) return;
1904
+ this.closed = true;
1905
+ this.stopHeartbeat();
1906
+ const err = new Error("pty-host connection closed");
1907
+ for (const waiter of this.pending.values()) {
1908
+ if (waiter.timeout) clearTimeout(waiter.timeout);
1909
+ waiter.reject(err);
1910
+ }
1911
+ this.pending.clear();
1912
+ }
1913
+ request(body, timeoutMs) {
1914
+ if (this.closed) return Promise.reject(new Error("pty-host connection closed"));
1915
+ const id = this.nextRequestId++;
1916
+ return new Promise((resolve2, reject) => {
1917
+ const timeout = timeoutMs === void 0 ? null : setTimeout(() => {
1918
+ if (!this.pending.delete(id)) return;
1919
+ reject(new Error(`${body.type} timed out after ${timeoutMs}ms`));
1920
+ }, timeoutMs);
1921
+ timeout?.unref?.();
1922
+ this.pending.set(id, { resolve: resolve2, reject, timeout });
1923
+ this.transport.send(encodeMessage({ ...body, id }));
1924
+ });
1925
+ }
1926
+ /**
1927
+ * Fire-and-forget for the synchronous parts of `SessionRunner`.
1928
+ *
1929
+ * `sendKeys`, `cancel`, `killPid` and `putOnHold` all return void, so there is
1930
+ * no channel to report a failure through even if we waited for one. The
1931
+ * response is still consumed — an unhandled rejection would take the process
1932
+ * down over a keystroke that failed to land.
1933
+ */
1934
+ fireAndForget(body) {
1935
+ this.request(body).catch((err) => {
1936
+ this.options.logger?.warn("[pty-host] request failed", {
1937
+ event: "pty_host.request_failed",
1938
+ type: body.type,
1939
+ err
1940
+ });
1941
+ });
1942
+ }
1943
+ async readStatus() {
1944
+ return await this.request({ type: "status" });
1945
+ }
1946
+ refreshMirror(status) {
1947
+ this.sessions = /* @__PURE__ */ new Map();
1948
+ this.pids = /* @__PURE__ */ new Map();
1949
+ for (const entry of status.sessions) {
1950
+ const session = reviveSession(entry.session);
1951
+ this.sessions.set(session.id, session);
1952
+ this.pids.set(session.id, entry.pid);
1953
+ }
1954
+ }
1955
+ async heartbeat(state, timeoutMs = HOST_HEARTBEAT_REQUEST_TIMEOUT_MS) {
1956
+ await this.request({ type: "heartbeat", ...state }, timeoutMs);
1957
+ }
1958
+ startHeartbeat(getState, intervalMs = HOST_HEARTBEAT_INTERVAL_MS) {
1959
+ this.stopHeartbeat();
1960
+ const send = () => {
1961
+ if (this.closed || this.heartbeatInFlight) return;
1962
+ this.heartbeatInFlight = true;
1963
+ void Promise.resolve().then(() => this.heartbeat(getState())).catch((err) => {
1964
+ if (this.closed) return;
1965
+ this.options.logger?.warn("[pty-host] heartbeat failed", {
1966
+ event: "pty_host.heartbeat_failed",
1967
+ err
1968
+ });
1969
+ }).finally(() => {
1970
+ this.heartbeatInFlight = false;
1971
+ });
1972
+ };
1973
+ send();
1974
+ this.heartbeatTimer = setInterval(send, intervalMs);
1975
+ this.heartbeatTimer.unref?.();
1976
+ }
1977
+ stopHeartbeat() {
1978
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
1979
+ this.heartbeatTimer = null;
1980
+ }
1981
+ // ─── Events ──────────────────────────────────────────────────────
1982
+ handleEvent(event) {
1983
+ switch (event.event) {
1984
+ case "output": {
1985
+ this.output.set(event.sessionId, (this.output.get(event.sessionId) ?? "") + event.data);
1986
+ this.options.onOutput?.(event.sessionId, event.data);
1987
+ break;
1988
+ }
1989
+ case "status-change": {
1990
+ const session = reviveSession(event.session);
1991
+ if (session.status === "idle" && session.completedAt != null) {
1992
+ this.sessions.delete(session.id);
1993
+ this.pids.delete(session.id);
1994
+ this.output.delete(session.id);
1995
+ this.inputHistory.delete(session.id);
1996
+ } else {
1997
+ this.sessions.set(session.id, session);
1998
+ }
1999
+ this.options.onStatusChange?.(session);
2000
+ break;
2001
+ }
2002
+ case "ready": {
2003
+ const session = reviveSession(event.session);
2004
+ this.sessions.set(session.id, session);
2005
+ this.options.onReady?.(session);
2006
+ break;
2007
+ }
2008
+ case "permission-change":
2009
+ this.options.onPermissionChange?.(event.sessionId, event.gate);
2010
+ break;
2011
+ case "live-question":
2012
+ this.options.onLiveQuestion?.(event.sessionId, event.questions);
2013
+ break;
2014
+ case "live-question-gone":
2015
+ this.options.onLiveQuestionGone?.(event.sessionId);
2016
+ break;
2017
+ case "user-message": {
2018
+ const history = this.inputHistory.get(event.sessionId) ?? [];
2019
+ history.push({ text: event.text, ts: event.ts });
2020
+ this.inputHistory.set(event.sessionId, history);
2021
+ this.options.onUserMessage?.(event.sessionId, event.text, event.ts);
2022
+ break;
2023
+ }
2024
+ case "exit": {
2025
+ this.sessions.delete(event.sessionId);
2026
+ this.pids.delete(event.sessionId);
2027
+ this.output.delete(event.sessionId);
2028
+ this.inputHistory.delete(event.sessionId);
2029
+ break;
2030
+ }
2031
+ }
2032
+ }
2033
+ // ─── SessionRunner ───────────────────────────────────────────────
2034
+ async start(sessionId, options) {
2035
+ const provider = options.provider ?? "claude-code";
2036
+ return this.adopt(await this.request({ type: "spawn", provider, sessionId, options }));
2037
+ }
2038
+ async startFresh(options) {
2039
+ const provider = options.provider ?? "claude-code";
2040
+ return this.adopt(await this.request({ type: "spawn", provider, sessionId: null, options }));
2041
+ }
2042
+ /**
2043
+ * Take a spawn answer into the mirror.
2044
+ *
2045
+ * The pid lands here and nowhere else in the live path: `recordSessionSpawn`
2046
+ * reads it immediately after start to write the durable registry row, and a
2047
+ * null there costs the next boot its ability to probe whether the agent
2048
+ * outlived us.
2049
+ */
2050
+ adopt(raw) {
2051
+ const entry = raw;
2052
+ const session = reviveSession(entry.session);
2053
+ this.sessions.set(session.id, session);
2054
+ this.pids.set(session.id, entry.pid);
2055
+ return session;
2056
+ }
2057
+ /**
2058
+ * Returns the mirror's promptCount, optimistically incremented.
2059
+ *
2060
+ * The interface is synchronous, so there is no way to return the host's
2061
+ * authoritative count. The increment matches what an in-process runner does
2062
+ * for the same call, and the next `status-change` event overwrites it — so a
2063
+ * mirror that guessed wrong is corrected within one round trip rather than
2064
+ * drifting.
2065
+ */
2066
+ sendInput(sessionId, input) {
2067
+ const session = this.requireSession(sessionId);
2068
+ this.fireAndForget({ type: "write", sessionId, input });
2069
+ session.promptCount += 1;
2070
+ return session.promptCount;
2071
+ }
2072
+ sendKeys(sessionId, keys) {
2073
+ this.requireSession(sessionId);
2074
+ this.fireAndForget({ type: "keys", sessionId, keys });
2075
+ }
2076
+ cancel(sessionId) {
2077
+ this.fireAndForget({ type: "cancel", sessionId });
2078
+ }
2079
+ killPid(pid) {
2080
+ this.fireAndForget({ type: "kill", pid });
2081
+ }
2082
+ putOnHold(sessionId) {
2083
+ this.fireAndForget({ type: "kill", sessionId, hold: true });
2084
+ this.sessions.delete(sessionId);
2085
+ this.pids.delete(sessionId);
2086
+ }
2087
+ getOutput(sessionId) {
2088
+ this.requireSession(sessionId);
2089
+ return this.output.get(sessionId) ?? "";
2090
+ }
2091
+ async getOutputLines(sessionId, maxLines) {
2092
+ const result = await this.request({ type: "replay", sessionId, maxLines });
2093
+ if (typeof result.output === "string") this.output.set(sessionId, result.output);
2094
+ return result.lines;
2095
+ }
2096
+ /**
2097
+ * Synchronous, so it answers from the mirror rather than the host.
2098
+ *
2099
+ * Seeded lazily: `user-message` events append as they arrive, and a session
2100
+ * this streamer did not start has none until `hydrateInputHistory` fetches
2101
+ * them. Empty is the same answer an in-process runner gives for an unknown
2102
+ * session, so a caller cannot tell "none yet" from "not fetched" — which is
2103
+ * why the fetch is explicit rather than hidden behind this getter.
2104
+ */
2105
+ getInputHistory(sessionId) {
2106
+ return this.inputHistory.get(sessionId) ?? [];
2107
+ }
2108
+ /** Pull a session's recorded messages from the host into the mirror. */
2109
+ async hydrateInputHistory(sessionId) {
2110
+ const result = await this.request({
2111
+ type: "input-history",
2112
+ sessionId
2113
+ });
2114
+ this.inputHistory.set(sessionId, result.history);
2115
+ return result.history;
2116
+ }
2117
+ getPid(sessionId) {
2118
+ return this.pids.get(sessionId) ?? null;
2119
+ }
2120
+ getSession(sessionId) {
2121
+ return this.sessions.get(sessionId) ?? null;
2122
+ }
2123
+ hasSession(sessionId) {
2124
+ return this.sessions.has(sessionId);
2125
+ }
2126
+ listSessions() {
2127
+ return [...this.sessions.values()];
2128
+ }
2129
+ /**
2130
+ * Drops this streamer's connection and nothing else.
2131
+ *
2132
+ * Emphatically NOT the in-process `dispose()`, which signals every child. The
2133
+ * entire point of the host is that its PTYs outlive the streamer, so tearing
2134
+ * them down here would spend the feature to implement a method name.
2135
+ */
2136
+ dispose() {
2137
+ this.handleClose();
2138
+ this.transport.close();
2139
+ }
2140
+ requireSession(sessionId) {
2141
+ const session = this.sessions.get(sessionId);
2142
+ if (!session) throw new Error(`Session not found: ${sessionId}`);
2143
+ return session;
2144
+ }
2145
+ };
2146
+
1739
2147
  // src/pty-manager.ts
1740
2148
  var import_headless2 = require("@xterm/headless");
1741
2149
  var import_crypto3 = require("crypto");
@@ -2107,13 +2515,19 @@ var PTYManager = class {
2107
2515
  sessionId
2108
2516
  ];
2109
2517
  args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
2110
- const proc = nodePty.spawn(resolveClaudeExe(), args, {
2111
- name: "xterm-256color",
2112
- cols: 120,
2113
- rows: 40,
2114
- cwd: options.projectPath,
2115
- env: buildSpawnEnv()
2116
- });
2518
+ let proc;
2519
+ try {
2520
+ proc = nodePty.spawn(resolveClaudeExe(), args, {
2521
+ name: "xterm-256color",
2522
+ cols: 120,
2523
+ rows: 40,
2524
+ cwd: options.projectPath,
2525
+ env: buildSpawnEnv()
2526
+ });
2527
+ } catch (err) {
2528
+ clearClaudeExeCache();
2529
+ throw err;
2530
+ }
2117
2531
  const session = {
2118
2532
  id: sessionId,
2119
2533
  provider: CLAUDE_CODE_PROVIDER,
@@ -2168,13 +2582,19 @@ var PTYManager = class {
2168
2582
  args.push("--system-prompt", options.systemPrompt);
2169
2583
  }
2170
2584
  args.push(...buildFlagArgs(options.claudeFlags, options.claudeExtraArgs));
2171
- const proc = nodePty.spawn(resolveClaudeExe(), args, {
2172
- name: "xterm-256color",
2173
- cols: 120,
2174
- rows: 40,
2175
- cwd: options.projectPath,
2176
- env: buildSpawnEnv()
2177
- });
2585
+ let proc;
2586
+ try {
2587
+ proc = nodePty.spawn(resolveClaudeExe(), args, {
2588
+ name: "xterm-256color",
2589
+ cols: 120,
2590
+ rows: 40,
2591
+ cwd: options.projectPath,
2592
+ env: buildSpawnEnv()
2593
+ });
2594
+ } catch (err) {
2595
+ clearClaudeExeCache();
2596
+ throw err;
2597
+ }
2178
2598
  const session = {
2179
2599
  id: sessionId,
2180
2600
  provider: CLAUDE_CODE_PROVIDER,
@@ -2740,12 +3160,30 @@ function stripAnsi2(str) {
2740
3160
  // src/live-session-manager.ts
2741
3161
  var LiveSessionManager = class {
2742
3162
  runners;
3163
+ remoteRunner = null;
3164
+ options;
2743
3165
  constructor(options = {}) {
3166
+ this.options = options;
2744
3167
  this.runners = /* @__PURE__ */ new Map([
2745
3168
  [CLAUDE_CODE_PROVIDER, new PTYManager(options)],
2746
3169
  [CODEX_CLI_PROVIDER, new CodexPtyRunner(options)]
2747
3170
  ]);
2748
3171
  }
3172
+ async useRemoteRunner(transport) {
3173
+ const remote = await RemoteSessionRunner.connect(transport, this.options);
3174
+ await Promise.all(
3175
+ remote.listSessions().map((session) => remote.hydrateInputHistory(session.id))
3176
+ );
3177
+ for (const runner of this.runners.values()) runner.dispose();
3178
+ this.remoteRunner = remote;
3179
+ return remote.listSessions();
3180
+ }
3181
+ isRemote() {
3182
+ return this.remoteRunner !== null;
3183
+ }
3184
+ startRemoteHeartbeat(getState) {
3185
+ this.remoteRunner?.startHeartbeat(getState);
3186
+ }
2749
3187
  async start(sessionId, options) {
2750
3188
  const provider = options.provider ?? CLAUDE_CODE_PROVIDER;
2751
3189
  const runner = this.assertSupportedProvider(provider, options.projectPath);
@@ -2766,7 +3204,7 @@ var LiveSessionManager = class {
2766
3204
  this.runnerFor(sessionId).cancel(sessionId);
2767
3205
  }
2768
3206
  killPid(pid) {
2769
- for (const runner of this.runners.values()) {
3207
+ for (const runner of this.activeRunners()) {
2770
3208
  runner.killPid(pid);
2771
3209
  }
2772
3210
  }
@@ -2776,13 +3214,13 @@ var LiveSessionManager = class {
2776
3214
  // every runner rather than throwing; this matches the pre-extraction
2777
3215
  // behavior of delegating straight through with no existence check.
2778
3216
  putOnHold(sessionId) {
2779
- for (const runner of this.runners.values()) {
3217
+ for (const runner of this.activeRunners()) {
2780
3218
  if (runner.hasSession(sessionId) || runner.getSession(sessionId)) {
2781
3219
  runner.putOnHold(sessionId);
2782
3220
  return;
2783
3221
  }
2784
3222
  }
2785
- for (const runner of this.runners.values()) {
3223
+ for (const runner of this.activeRunners()) {
2786
3224
  runner.putOnHold(sessionId);
2787
3225
  }
2788
3226
  }
@@ -2796,7 +3234,7 @@ var LiveSessionManager = class {
2796
3234
  return this.runnerFor(sessionId).getInputHistory(sessionId);
2797
3235
  }
2798
3236
  getSession(sessionId) {
2799
- for (const runner of this.runners.values()) {
3237
+ for (const runner of this.activeRunners()) {
2800
3238
  const session = runner.getSession(sessionId);
2801
3239
  if (session) return session;
2802
3240
  }
@@ -2806,23 +3244,23 @@ var LiveSessionManager = class {
2806
3244
  // best-effort basis, so an unknown session must return null rather than
2807
3245
  // throw the way the input-routing methods do.
2808
3246
  getPid(sessionId) {
2809
- for (const runner of this.runners.values()) {
3247
+ for (const runner of this.activeRunners()) {
2810
3248
  const pid = runner.getPid(sessionId);
2811
3249
  if (pid != null) return pid;
2812
3250
  }
2813
3251
  return null;
2814
3252
  }
2815
3253
  hasSession(sessionId) {
2816
- for (const runner of this.runners.values()) {
3254
+ for (const runner of this.activeRunners()) {
2817
3255
  if (runner.hasSession(sessionId)) return true;
2818
3256
  }
2819
3257
  return false;
2820
3258
  }
2821
3259
  listSessions() {
2822
- return Array.from(this.runners.values()).flatMap((runner) => runner.listSessions());
3260
+ return this.activeRunners().flatMap((runner) => runner.listSessions());
2823
3261
  }
2824
3262
  dispose() {
2825
- for (const runner of this.runners.values()) {
3263
+ for (const runner of this.activeRunners()) {
2826
3264
  runner.dispose();
2827
3265
  }
2828
3266
  }
@@ -2830,12 +3268,13 @@ var LiveSessionManager = class {
2830
3268
  // this is a linear scan across hasSession()/getSession() rather than a
2831
3269
  // separate session→provider index — see task-1-brief.md.
2832
3270
  runnerFor(sessionId) {
2833
- for (const runner of this.runners.values()) {
3271
+ for (const runner of this.activeRunners()) {
2834
3272
  if (runner.hasSession(sessionId) || runner.getSession(sessionId)) return runner;
2835
3273
  }
2836
3274
  throw new Error(`Session not found: ${sessionId}`);
2837
3275
  }
2838
3276
  assertSupportedProvider(provider, projectPath) {
3277
+ if (this.remoteRunner) return this.remoteRunner;
2839
3278
  const runner = this.runners.get(provider);
2840
3279
  if (runner) return runner;
2841
3280
  const err = new Error(
@@ -2844,6 +3283,9 @@ var LiveSessionManager = class {
2844
3283
  err.statusCode = 501;
2845
3284
  throw err;
2846
3285
  }
3286
+ activeRunners() {
3287
+ return this.remoteRunner ? [this.remoteRunner] : [...this.runners.values()];
3288
+ }
2847
3289
  };
2848
3290
 
2849
3291
  // src/process-discovery.ts
@@ -5244,21 +5686,53 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
5244
5686
  };
5245
5687
 
5246
5688
  // src/api/app.ts
5689
+ var ALREADY_HANDLED7 = 597;
5690
+ function summarizeQuery(query) {
5691
+ const keys = Object.keys(query).sort();
5692
+ if (keys.length === 0) return void 0;
5693
+ return keys.map((k) => `${k}=${/^-?\d+$/.test(query[k]) ? query[k] : "_"}`).join("&");
5694
+ }
5695
+ function countResponseBytes(res) {
5696
+ let bytes = 0;
5697
+ const add = (chunk) => {
5698
+ if (typeof chunk === "string") bytes += Buffer.byteLength(chunk);
5699
+ else if (chunk instanceof Uint8Array) bytes += chunk.byteLength;
5700
+ };
5701
+ const write = res.write;
5702
+ const end = res.end;
5703
+ res.write = function(...args) {
5704
+ add(args[0]);
5705
+ return write.apply(this, args);
5706
+ };
5707
+ res.end = function(...args) {
5708
+ add(args[0]);
5709
+ return end.apply(this, args);
5710
+ };
5711
+ return () => bytes;
5712
+ }
5247
5713
  var createHonoApp = (deps, upgradeWebSocket) => {
5248
5714
  const app = new import_hono18.Hono();
5249
5715
  const httpLog = getLogger("http");
5250
5716
  app.use("*", async (c, next) => {
5251
5717
  const start = Date.now();
5252
5718
  const ua = c.req.header("user-agent") ?? "";
5719
+ const outgoing = c.env?.outgoing;
5720
+ const bytesWritten = outgoing && countResponseBytes(outgoing);
5253
5721
  await next();
5254
5722
  if (!deps.logMenubarRequests && c.req.header("x-client") === "menubar") return;
5255
5723
  const ms = Date.now() - start;
5256
- httpLog.info(`[req] ${c.req.method} ${c.req.path} \u2192 ${c.res.status} ${ms}ms`, {
5724
+ const handled = c.res.status === ALREADY_HANDLED7 && outgoing !== void 0;
5725
+ const status = handled ? outgoing.statusCode : c.res.status;
5726
+ const qs = summarizeQuery(c.req.query());
5727
+ const bytes = handled && bytesWritten ? bytesWritten() : void 0;
5728
+ httpLog.info(`[req] ${c.req.method} ${c.req.path} \u2192 ${status} ${ms}ms`, {
5257
5729
  method: c.req.method,
5258
5730
  path: c.req.path,
5259
- status: c.res.status,
5731
+ status,
5260
5732
  ms,
5261
5733
  ua,
5734
+ ...qs ? { qs } : {},
5735
+ ...bytes === void 0 ? {} : { bytes },
5262
5736
  event: "http.request"
5263
5737
  });
5264
5738
  });
@@ -5346,6 +5820,74 @@ var import_promises3 = require("fs/promises");
5346
5820
  var import_path12 = require("path");
5347
5821
  var import_promises4 = require("timers/promises");
5348
5822
 
5823
+ // src/db/query-timing.ts
5824
+ var log3 = getLogger("db");
5825
+ var DEFAULT_SLOW_QUERY_MS = 35;
5826
+ var LABEL = /* @__PURE__ */ Symbol("tbQueryLabel");
5827
+ function deriveLabel(sql) {
5828
+ const verb = /^\s*(\w+)/.exec(sql)?.[1]?.toLowerCase() ?? "sql";
5829
+ const table = /(?:from|into|update)\s+([A-Za-z_]\w*)/i.exec(sql)?.[1] ?? "?";
5830
+ return `${verb}:${table}`;
5831
+ }
5832
+ function resolveSlowMs() {
5833
+ const raw = process.env.THREADBASE_DB_SLOW_QUERY_MS;
5834
+ if (raw === void 0 || raw === "") return DEFAULT_SLOW_QUERY_MS;
5835
+ const parsed = Number(raw);
5836
+ return Number.isFinite(parsed) ? parsed : DEFAULT_SLOW_QUERY_MS;
5837
+ }
5838
+ function record(label, ms, rows, slowMs) {
5839
+ if (slowMs > 0 && ms >= slowMs) {
5840
+ log3.warn(
5841
+ `[db] slow query ${label} ${ms.toFixed(1)}ms rows=${rows}`,
5842
+ { event: "db.slow_query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
5843
+ "pino"
5844
+ );
5845
+ return;
5846
+ }
5847
+ if (log3.pino.isLevelEnabled("debug")) {
5848
+ log3.debug(
5849
+ `[db] ${label} ${ms.toFixed(2)}ms rows=${rows}`,
5850
+ { event: "db.query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
5851
+ "pino"
5852
+ );
5853
+ }
5854
+ }
5855
+ function rowsOf(method, result) {
5856
+ if (method === "all") return Array.isArray(result) ? result.length : 0;
5857
+ if (method === "run") return result?.changes ?? 0;
5858
+ return result === void 0 ? 0 : 1;
5859
+ }
5860
+ function instrumentDatabase(db, options = {}) {
5861
+ const slowMs = options.slowMs ?? resolveSlowMs();
5862
+ const prepare = db.prepare.bind(db);
5863
+ db.prepare = ((sql) => {
5864
+ const stmt = prepare(sql);
5865
+ const box = { label: deriveLabel(sql) };
5866
+ Object.defineProperty(stmt, LABEL, { value: box, configurable: true });
5867
+ for (const method of ["get", "all", "run"]) {
5868
+ const original = stmt[method].bind(stmt);
5869
+ Object.defineProperty(stmt, method, {
5870
+ configurable: true,
5871
+ writable: true,
5872
+ value: (...args) => {
5873
+ const started = performance.now();
5874
+ const result = original(...args);
5875
+ record(box.label, performance.now() - started, rowsOf(method, result), slowMs);
5876
+ return result;
5877
+ }
5878
+ });
5879
+ }
5880
+ return stmt;
5881
+ });
5882
+ return db;
5883
+ }
5884
+ function labelStatements(statements) {
5885
+ for (const [name, stmt] of Object.entries(statements)) {
5886
+ const box = stmt?.[LABEL];
5887
+ if (box) box.label = name;
5888
+ }
5889
+ }
5890
+
5349
5891
  // src/db/sqlite-migrate.ts
5350
5892
  var import_fs8 = require("fs");
5351
5893
  var import_path10 = require("path");
@@ -5556,6 +6098,7 @@ CREATE TABLE IF NOT EXISTS session_names (
5556
6098
  updated_at INTEGER NOT NULL
5557
6099
  );
5558
6100
  `;
6101
+ var cacheLog = getLogger("cache");
5559
6102
  var ConversationCache = class _ConversationCache {
5560
6103
  db;
5561
6104
  tailSize;
@@ -5785,6 +6328,7 @@ var ConversationCache = class _ConversationCache {
5785
6328
  "SELECT COUNT(*) as cnt FROM conversation_message_index WHERE conversation_id = ?"
5786
6329
  )
5787
6330
  };
6331
+ labelStatements(this.stmts);
5788
6332
  }
5789
6333
  /**
5790
6334
  * Expose the underlying handle so projects/cache_metadata repositories can
@@ -5948,6 +6492,7 @@ var ConversationCache = class _ConversationCache {
5948
6492
  return walk;
5949
6493
  }
5950
6494
  async runBackfill(filePath) {
6495
+ const startedAt = performance.now();
5951
6496
  const convId = _ConversationCache.conversationIdForFile(filePath);
5952
6497
  this.deleteFileIndex(filePath, convId);
5953
6498
  this.indexParseState.delete(filePath);
@@ -6008,6 +6553,18 @@ var ConversationCache = class _ConversationCache {
6008
6553
  last_message_index: nextIndex - 1
6009
6554
  });
6010
6555
  this.indexParseState.set(filePath, state);
6556
+ const ms = Math.round(performance.now() - startedAt);
6557
+ cacheLog.info(
6558
+ `[cache] offset-index backfilled ${convId} ${ms}ms`,
6559
+ {
6560
+ event: "offset_index.backfill_ok",
6561
+ conversationId: convId,
6562
+ ms,
6563
+ rows: nextIndex,
6564
+ bytes: stat3.size
6565
+ },
6566
+ "pino"
6567
+ );
6011
6568
  }
6012
6569
  /**
6013
6570
  * Windowed detail read straight from the offset index — the hot path.
@@ -6093,7 +6650,7 @@ var ConversationCache = class _ConversationCache {
6093
6650
  }
6094
6651
  static open(dbPath, tailSize = 10, migrationsDir, options) {
6095
6652
  (0, import_fs10.mkdirSync)((0, import_path12.dirname)(dbPath), { recursive: true });
6096
- const db = new import_better_sqlite3.default(dbPath);
6653
+ const db = instrumentDatabase(new import_better_sqlite3.default(dbPath));
6097
6654
  db.pragma("journal_mode = WAL");
6098
6655
  db.pragma("foreign_keys = ON");
6099
6656
  return new _ConversationCache(db, tailSize, migrationsDir, options);
@@ -7105,7 +7662,7 @@ var RuntimeStore = class _RuntimeStore {
7105
7662
  }
7106
7663
  db;
7107
7664
  static open(dbPath, migrationsDir) {
7108
- const db = new import_better_sqlite32.default(dbPath);
7665
+ const db = instrumentDatabase(new import_better_sqlite32.default(dbPath));
7109
7666
  db.pragma("journal_mode = WAL");
7110
7667
  runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
7111
7668
  return new _RuntimeStore(db);
@@ -7237,14 +7794,14 @@ var PairTokenStore = class {
7237
7794
  };
7238
7795
  }
7239
7796
  consume(token) {
7240
- const record = this.current;
7241
- if (!record || record.token !== token) return { ok: false, reason: "unknown" };
7242
- if (Date.now() > record.expiresAt) {
7797
+ const record2 = this.current;
7798
+ if (!record2 || record2.token !== token) return { ok: false, reason: "unknown" };
7799
+ if (Date.now() > record2.expiresAt) {
7243
7800
  this.current = null;
7244
7801
  return { ok: false, reason: "expired" };
7245
7802
  }
7246
- if (record.used) return { ok: false, reason: "used" };
7247
- record.used = true;
7803
+ if (record2.used) return { ok: false, reason: "used" };
7804
+ record2.used = true;
7248
7805
  return { ok: true };
7249
7806
  }
7250
7807
  peek() {
@@ -7265,6 +7822,88 @@ var PairTokenStore = class {
7265
7822
  }
7266
7823
  };
7267
7824
 
7825
+ // src/pty-host/spawn-host.ts
7826
+ var import_node_child_process2 = require("child_process");
7827
+
7828
+ // src/pty-host/socket.ts
7829
+ var import_node_net = require("net");
7830
+ var import_node_os4 = require("os");
7831
+ var import_node_path7 = require("path");
7832
+ function hostSocketPath(instanceId) {
7833
+ if (process.platform === "win32") {
7834
+ return `\\\\.\\pipe\\threadbase-pty-host-${instanceId}`;
7835
+ }
7836
+ const dir = process.env.THREADBASE_CONFIG_DIR ?? (0, import_node_path7.join)((0, import_node_os4.homedir)(), ".threadbase");
7837
+ return (0, import_node_path7.join)(dir, "run", `pty-host-${instanceId}.sock`);
7838
+ }
7839
+ function socketTransport(socket) {
7840
+ socket.setEncoding("utf8");
7841
+ return {
7842
+ send(line) {
7843
+ socket.write(line);
7844
+ },
7845
+ onLine(handler) {
7846
+ socket.on("data", (chunk) => handler(chunk));
7847
+ },
7848
+ onClose(handler) {
7849
+ let handled = false;
7850
+ const handleClose = () => {
7851
+ if (handled) return;
7852
+ handled = true;
7853
+ handler();
7854
+ };
7855
+ socket.once("close", handleClose);
7856
+ socket.once("error", handleClose);
7857
+ },
7858
+ close() {
7859
+ socket.destroy();
7860
+ }
7861
+ };
7862
+ }
7863
+ function connectToHost(socketPath) {
7864
+ return new Promise((resolve2, reject) => {
7865
+ const socket = (0, import_node_net.createConnection)(socketPath);
7866
+ socket.once("error", reject);
7867
+ socket.once("connect", () => {
7868
+ socket.removeListener("error", reject);
7869
+ resolve2(socketTransport(socket));
7870
+ });
7871
+ });
7872
+ }
7873
+
7874
+ // src/pty-host/spawn-host.ts
7875
+ var HOST_READY_TIMEOUT_MS = 5e3;
7876
+ var HOST_POLL_INTERVAL_MS = 50;
7877
+ async function connectOrSpawnHost(options) {
7878
+ const socketPath = hostSocketPath(options.instanceId);
7879
+ try {
7880
+ return await connectToHost(socketPath);
7881
+ } catch {
7882
+ }
7883
+ spawnDetachedHost(socketPath, options.entryPoint);
7884
+ const deadline = Date.now() + (options.timeoutMs ?? HOST_READY_TIMEOUT_MS);
7885
+ let lastError;
7886
+ while (Date.now() < deadline) {
7887
+ try {
7888
+ return await connectToHost(socketPath);
7889
+ } catch (err) {
7890
+ lastError = err;
7891
+ await new Promise((r) => setTimeout(r, HOST_POLL_INTERVAL_MS));
7892
+ }
7893
+ }
7894
+ throw new Error(
7895
+ `pty-host did not accept a connection on ${socketPath} within ${options.timeoutMs ?? HOST_READY_TIMEOUT_MS}ms` + (lastError instanceof Error ? `: ${lastError.message}` : "")
7896
+ );
7897
+ }
7898
+ function spawnDetachedHost(socketPath, entryPoint) {
7899
+ const child = (0, import_node_child_process2.spawn)(
7900
+ process.execPath,
7901
+ [entryPoint ?? process.argv[1], "pty-host", "--socket", socketPath],
7902
+ { detached: true, stdio: "ignore" }
7903
+ );
7904
+ child.unref();
7905
+ }
7906
+
7268
7907
  // src/seal.ts
7269
7908
  var import_tweetnacl = __toESM(require("tweetnacl"), 1);
7270
7909
  var import_tweetnacl_util = __toESM(require("tweetnacl-util"), 1);
@@ -7363,10 +8002,10 @@ function fingerprintOf(ids) {
7363
8002
  return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
7364
8003
  }
7365
8004
  var CacheIntegrityMonitor = class {
7366
- constructor(cache, wsHub, log7, cacheDir, rescan, runDuringReset) {
8005
+ constructor(cache, wsHub, log8, cacheDir, rescan, runDuringReset) {
7367
8006
  this.cache = cache;
7368
8007
  this.wsHub = wsHub;
7369
- this.log = log7;
8008
+ this.log = log8;
7370
8009
  this.cacheDir = cacheDir;
7371
8010
  this.rescan = rescan;
7372
8011
  this.runDuringReset = runDuringReset;
@@ -7982,7 +8621,7 @@ function deriveProjectChatTitle(input) {
7982
8621
  // src/services/push/apnsClient.ts
7983
8622
  var import_node_crypto3 = require("crypto");
7984
8623
  var import_node_http2 = require("http2");
7985
- var log3 = getLogger("apns");
8624
+ var log4 = getLogger("apns");
7986
8625
  var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
7987
8626
  var APNS_MAX_PAYLOAD_BYTES = 4096;
7988
8627
  var JWT_TTL_SECONDS = 3e3;
@@ -8066,7 +8705,7 @@ var ApnsClient = class {
8066
8705
  }
8067
8706
  const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
8068
8707
  session.on("error", (err) => {
8069
- log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
8708
+ log4.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
8070
8709
  });
8071
8710
  this.session = session;
8072
8711
  return session;
@@ -8151,7 +8790,7 @@ function truncateLastOutput(raw) {
8151
8790
  }
8152
8791
 
8153
8792
  // src/services/push/liveActivityNotifier.ts
8154
- var log4 = getLogger("live-activity");
8793
+ var log5 = getLogger("live-activity");
8155
8794
  function contentStateForSession(args) {
8156
8795
  const status = toLiveActivityStatus(args.session.status);
8157
8796
  if (!status) return null;
@@ -8211,7 +8850,7 @@ var LiveActivityNotifier = class {
8211
8850
  }
8212
8851
  await this.maybeSendName(session);
8213
8852
  } catch (err) {
8214
- log4.error("live_activity.notify_failed", {
8853
+ log5.error("live_activity.notify_failed", {
8215
8854
  event: "live_activity.notify_failed",
8216
8855
  sessionId: session.id,
8217
8856
  status: session.status,
@@ -8233,7 +8872,7 @@ var LiveActivityNotifier = class {
8233
8872
  });
8234
8873
  this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
8235
8874
  if (outcome.attempted > 0) {
8236
- log4.info("live_activity.updated", {
8875
+ log5.info("live_activity.updated", {
8237
8876
  event: "live_activity.updated",
8238
8877
  sessionId: session.id,
8239
8878
  status: contentState.status,
@@ -8257,7 +8896,7 @@ var LiveActivityNotifier = class {
8257
8896
  });
8258
8897
  open2.sessionNameSent = true;
8259
8898
  if (outcome.attempted > 0) {
8260
- log4.info("live_activity.updated", {
8899
+ log5.info("live_activity.updated", {
8261
8900
  event: "live_activity.updated",
8262
8901
  sessionId: session.id,
8263
8902
  status: contentState.status,
@@ -8276,7 +8915,7 @@ var LiveActivityNotifier = class {
8276
8915
  if (!contentState) return;
8277
8916
  const outcome = await this.sender.end({ sessionId: session.id, contentState });
8278
8917
  if (outcome.attempted > 0) {
8279
- log4.info("live_activity.ended", {
8918
+ log5.info("live_activity.ended", {
8280
8919
  event: "live_activity.ended",
8281
8920
  sessionId: session.id,
8282
8921
  ...outcome
@@ -8290,7 +8929,7 @@ var LiveActivityNotifier = class {
8290
8929
  };
8291
8930
 
8292
8931
  // src/services/push/liveActivitySender.ts
8293
- var log5 = getLogger("live-activity");
8932
+ var log6 = getLogger("live-activity");
8294
8933
  var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
8295
8934
  function buildActivityKitPayload(args) {
8296
8935
  return {
@@ -8354,7 +8993,7 @@ var LiveActivitySender = class {
8354
8993
  );
8355
8994
  for (const { row, result, error } of results) {
8356
8995
  if (error) {
8357
- log5.error("live_activity.send_failed", {
8996
+ log6.error("live_activity.send_failed", {
8358
8997
  event: "live_activity.send_failed",
8359
8998
  sessionId: args.sessionId,
8360
8999
  activityId: row.activity_id,
@@ -8375,7 +9014,7 @@ var LiveActivitySender = class {
8375
9014
  this.repo.expire(row.token, now);
8376
9015
  outcome.retired += 1;
8377
9016
  }
8378
- log5.warn("live_activity.send_rejected", {
9017
+ log6.warn("live_activity.send_rejected", {
8379
9018
  event: "live_activity.send_rejected",
8380
9019
  sessionId: args.sessionId,
8381
9020
  activityId: row.activity_id,
@@ -8420,7 +9059,7 @@ var LiveActivitySender = class {
8420
9059
  };
8421
9060
 
8422
9061
  // src/services/push/liveActivityRenewal.ts
8423
- var log6 = getLogger("live-activity");
9062
+ var log7 = getLogger("live-activity");
8424
9063
  var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
8425
9064
  var MAX_TIMER_MS = 60 * 60 * 1e3;
8426
9065
  function renewalDueAt(row) {
@@ -8468,7 +9107,7 @@ var LiveActivityRenewalScheduler = class {
8468
9107
  await this.renew(row, now);
8469
9108
  }
8470
9109
  } catch (err) {
8471
- log6.error("live_activity.renewal_sweep_failed", {
9110
+ log7.error("live_activity.renewal_sweep_failed", {
8472
9111
  event: "live_activity.renewal_sweep_failed",
8473
9112
  err: String(err)
8474
9113
  });
@@ -8497,7 +9136,7 @@ var LiveActivityRenewalScheduler = class {
8497
9136
  if (!session || !status) {
8498
9137
  this.deps.repo.claimRenewal(row.token, now);
8499
9138
  this.deps.repo.expire(row.token, now);
8500
- log6.info("live_activity.renewal_skipped", {
9139
+ log7.info("live_activity.renewal_skipped", {
8501
9140
  event: "live_activity.renewal_skipped",
8502
9141
  sessionId: row.session_id,
8503
9142
  activityId: row.activity_id,
@@ -8532,7 +9171,7 @@ var LiveActivityRenewalScheduler = class {
8532
9171
  startedAt,
8533
9172
  now
8534
9173
  });
8535
- log6.info("live_activity.renewed", {
9174
+ log7.info("live_activity.renewed", {
8536
9175
  event: "live_activity.renewed",
8537
9176
  sessionId: session.id,
8538
9177
  activityId: row.activity_id,
@@ -8542,7 +9181,7 @@ var LiveActivityRenewalScheduler = class {
8542
9181
  replacementRequested: started
8543
9182
  });
8544
9183
  } catch (err) {
8545
- log6.error("live_activity.renewal_failed", {
9184
+ log7.error("live_activity.renewal_failed", {
8546
9185
  event: "live_activity.renewal_failed",
8547
9186
  sessionId: session.id,
8548
9187
  activityId: row.activity_id,
@@ -8818,6 +9457,34 @@ function paginate(results, offset, limit) {
8818
9457
  };
8819
9458
  }
8820
9459
 
9460
+ // src/services/sessions/autoResumeOnBoot.ts
9461
+ var AUTO_RESUME_WINDOW_MS = 15 * 60 * 1e3;
9462
+ var AUTO_RESUME_MAX = 5;
9463
+ var AUTO_RESUME_CONCURRENCY = 2;
9464
+ var AUTO_RESUME_STAGGER_MS = 500;
9465
+ function autoResumeSkipReason(row, opts) {
9466
+ if (row.status_source !== "shutdown") return "not_shutdown";
9467
+ if (row.status !== "running" && row.status !== "waiting_input") return "not_interrupted";
9468
+ if (opts.now - row.status_updated_at > AUTO_RESUME_WINDOW_MS) return "too_old";
9469
+ if (!opts.projectExists(row.project_path)) return "project_missing";
9470
+ if (resumeIdForRow(row) == null) return "resume_identity_missing";
9471
+ return null;
9472
+ }
9473
+ function planAutoResume(rows, opts) {
9474
+ const eligible = [];
9475
+ const skipped = [];
9476
+ for (const row of rows) {
9477
+ const reason = autoResumeSkipReason(row, opts);
9478
+ if (reason) skipped.push({ row, reason });
9479
+ else eligible.push(row);
9480
+ }
9481
+ return {
9482
+ attempts: eligible.slice(0, AUTO_RESUME_MAX),
9483
+ skipped,
9484
+ overflow: eligible.slice(AUTO_RESUME_MAX)
9485
+ };
9486
+ }
9487
+
8821
9488
  // src/services/sessions/conversationBusy.ts
8822
9489
  var import_fs18 = require("fs");
8823
9490
  var RESUME_BUSY_WINDOW_MS = 12e4;
@@ -9005,6 +9672,12 @@ var SessionStore = class {
9005
9672
  removeManaged(sessionId) {
9006
9673
  return this.managed.delete(sessionId);
9007
9674
  }
9675
+ /**
9676
+ * The **live** stored record — mutating it mutates the store. Paired with
9677
+ * `get()`, which hands back a throwaway response copy. Prefer
9678
+ * `updateManaged()` for writes; this is for readers that need the internal
9679
+ * shape (Date fields, `rehydrated`, …) rather than the wire shape.
9680
+ */
9008
9681
  getManaged(sessionId) {
9009
9682
  return this.managed.get(sessionId) ?? null;
9010
9683
  }
@@ -9014,12 +9687,21 @@ var SessionStore = class {
9014
9687
  this.discovered.set(proc.pid, proc);
9015
9688
  }
9016
9689
  }
9690
+ /**
9691
+ * The **live** stored records — mutating an element mutates the store. Paired
9692
+ * with `list()`, which hands back throwaway response copies.
9693
+ */
9017
9694
  listManaged() {
9018
9695
  return Array.from(this.managed.values());
9019
9696
  }
9020
9697
  // Build the session list: live PTY sessions (managed) merged with externally
9021
9698
  // discovered Claude processes. Managed sessions keyed by JSONL UUID take
9022
9699
  // priority — discovered processes with the same UUID are skipped.
9700
+ //
9701
+ // Returns freshly constructed response objects, NOT references into the
9702
+ // store — hence `Readonly`: writing to one changes nothing, so the compiler
9703
+ // refuses it. Persist state with `updateManaged()`; to decorate a response,
9704
+ // build a new object (`{ ...s, … }`) as `withReconciledLifecycle` does.
9023
9705
  list(ptyAttachedIds) {
9024
9706
  const results = [];
9025
9707
  const seenIds = /* @__PURE__ */ new Set();
@@ -9035,6 +9717,9 @@ var SessionStore = class {
9035
9717
  }
9036
9718
  return results;
9037
9719
  }
9720
+ // A freshly constructed response object, NOT a reference into the store —
9721
+ // hence `Readonly`, for the same reason as `list()` above. Use `getManaged()`
9722
+ // when you want the live record.
9038
9723
  get(sessionId, ptyAttachedIds) {
9039
9724
  const managed = this.managed.get(sessionId);
9040
9725
  if (managed) return managedToResponse(managed, ptyAttachedIds.has(sessionId));
@@ -9126,8 +9811,8 @@ function managedToResponse(s, ptyAttached) {
9126
9811
  // behind it — `resumable`, and `historical` rather than `managed`
9127
9812
  // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
9128
9813
  lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
9129
- lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
9130
- // We spawned it, so `status` is the authoritative signal — no inferred
9814
+ lifecycleSource: ptyAttached ? s.reconciled ? "reconcile" : "spawn" : s.rehydrated ? "reconcile" : "exit",
9815
+ // We own its PTY, so `status` is the authoritative signal — no inferred
9131
9816
  // `activity` is attached for managed sessions.
9132
9817
  ownership: s.rehydrated ? "historical" : "managed",
9133
9818
  projectPath: s.projectPath,
@@ -9659,6 +10344,7 @@ var StreamerServer = class {
9659
10344
  disableDb = false;
9660
10345
  // Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
9661
10346
  skipStartupWarmup;
10347
+ autoResumeOnBoot;
9662
10348
  browseRoot = null;
9663
10349
  publicUrl = null;
9664
10350
  browserCors;
@@ -9775,6 +10461,7 @@ var StreamerServer = class {
9775
10461
  this.verbose = config.verbose ?? false;
9776
10462
  this.disableDb = config.disableDb ?? false;
9777
10463
  this.skipStartupWarmup = config.skipStartupWarmup ?? false;
10464
+ this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
9778
10465
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
9779
10466
  this.scanProfiles = config.scanProfiles;
9780
10467
  this.codexRoots = config.codexRoots ?? [(0, import_path18.join)((0, import_os10.homedir)(), ".codex", "sessions")];
@@ -10255,16 +10942,19 @@ var StreamerServer = class {
10255
10942
  broadcastOrUnicastSessionList(req) {
10256
10943
  const clientId = req.headers["x-client-id"];
10257
10944
  const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
10258
- const payload = {
10259
- type: "session_list",
10260
- sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
10261
- };
10945
+ const payload = this.sessionListPayload();
10262
10946
  if (ws) {
10263
10947
  this.wsHub.unicast(ws, payload);
10264
10948
  } else {
10265
10949
  this.wsHub.broadcast(payload);
10266
10950
  }
10267
10951
  }
10952
+ sessionListPayload() {
10953
+ return {
10954
+ type: "session_list",
10955
+ sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
10956
+ };
10957
+ }
10268
10958
  /**
10269
10959
  * Overlay boot-reconciliation verdicts onto session responses.
10270
10960
  *
@@ -10352,7 +11042,7 @@ var StreamerServer = class {
10352
11042
  if (!this.managedSessionsRepo) return [];
10353
11043
  let verdicts = [];
10354
11044
  try {
10355
- const rows = this.managedSessionsRepo.listNonTerminal();
11045
+ const rows = this.managedSessionsRepo.listNonTerminal().filter((row) => !this.ptyManager.hasSession(row.session_id));
10356
11046
  if (rows.length === PROBE_SET_MAX) {
10357
11047
  this.log.warn(
10358
11048
  `[reconcile] probe set hit its cap of ${PROBE_SET_MAX} \u2014 older rows skipped`,
@@ -10438,7 +11128,7 @@ var StreamerServer = class {
10438
11128
  * by id rather than duplicating it.
10439
11129
  */
10440
11130
  rehydratePreviousSessions(verdicts) {
10441
- if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
11131
+ if (!this.managedSessionsRepo) return [];
10442
11132
  try {
10443
11133
  const now = Date.now();
10444
11134
  const rows = this.managedSessionsRepo.listRecoverable({
@@ -10447,7 +11137,7 @@ var StreamerServer = class {
10447
11137
  });
10448
11138
  const truncated = rows.length > REHYDRATE_MAX;
10449
11139
  const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
10450
- if (candidates.length === 0) return;
11140
+ if (!this.featureFlags.sessionRehydration || candidates.length === 0) return candidates;
10451
11141
  const verdictById = new Map(verdicts.map((v) => [v.sessionId, v]));
10452
11142
  let rehydrated = 0;
10453
11143
  const skippedBy = {};
@@ -10482,12 +11172,107 @@ var StreamerServer = class {
10482
11172
  skippedBy,
10483
11173
  truncated
10484
11174
  });
11175
+ return candidates;
10485
11176
  } catch (err) {
10486
11177
  this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
10487
11178
  event: "sessions.rehydrate_failed",
10488
11179
  err
10489
11180
  });
11181
+ return [];
11182
+ }
11183
+ }
11184
+ /** Resume only the recent sessions the user explicitly allowed us to start at boot. */
11185
+ async autoResumePreviousSessions(rows) {
11186
+ if (!this.autoResumeOnBoot) return;
11187
+ const plan = planAutoResume(rows, { now: Date.now(), projectExists: import_fs19.existsSync });
11188
+ const skippedBy = {};
11189
+ for (const { row, reason } of plan.skipped) {
11190
+ skippedBy[reason] = (skippedBy[reason] ?? 0) + 1;
11191
+ this.log.debug(`[auto-resume] skipped ${row.session_id}: ${reason}`, {
11192
+ event: "sessions.auto_resume_skipped",
11193
+ sessionId: row.session_id,
11194
+ reason
11195
+ });
10490
11196
  }
11197
+ if (plan.skipped.length > 0) {
11198
+ this.log.info(
11199
+ `[auto-resume] left ${plan.skipped.length} ineligible session(s) for manual resume`,
11200
+ {
11201
+ event: "sessions.auto_resume_skipped",
11202
+ skipped: plan.skipped.length,
11203
+ skippedBy
11204
+ }
11205
+ );
11206
+ }
11207
+ for (const row of plan.overflow) {
11208
+ this.log.info(`[auto-resume] left ${row.session_id} for manual resume: ceiling reached`, {
11209
+ event: "sessions.auto_resume_skipped",
11210
+ sessionId: row.session_id,
11211
+ reason: "ceiling_reached"
11212
+ });
11213
+ }
11214
+ let resumed = 0;
11215
+ let failed = 0;
11216
+ const inFlight = /* @__PURE__ */ new Set();
11217
+ let started = 0;
11218
+ const resume = async (row) => {
11219
+ try {
11220
+ const outcome = await this.resumeSession({
11221
+ sessionId: row.session_id,
11222
+ projectName: row.project_name,
11223
+ branch: row.branch
11224
+ });
11225
+ if (!outcome.ok) {
11226
+ failed++;
11227
+ this.log.info(`[auto-resume] skipped ${row.session_id}: ${outcome.reason}`, {
11228
+ event: "sessions.auto_resume_skipped",
11229
+ sessionId: row.session_id,
11230
+ reason: outcome.reason,
11231
+ ...outcome.reason === "conversation_busy" && {
11232
+ detectedBy: outcome.detectedBy,
11233
+ lastActivityMs: outcome.lastActivityMs,
11234
+ likelyOwner: outcome.likelyOwner
11235
+ }
11236
+ });
11237
+ return;
11238
+ }
11239
+ resumed++;
11240
+ this.log.info(`[auto-resume] resumed ${row.session_id}`, {
11241
+ event: "sessions.auto_resume_succeeded",
11242
+ sessionId: row.session_id,
11243
+ alreadyRunning: outcome.alreadyRunning
11244
+ });
11245
+ } catch (err) {
11246
+ failed++;
11247
+ this.log.warn(`[auto-resume] failed to resume ${row.session_id}`, {
11248
+ event: "sessions.auto_resume_failed",
11249
+ sessionId: row.session_id,
11250
+ err
11251
+ });
11252
+ }
11253
+ };
11254
+ for (const row of plan.attempts) {
11255
+ while (inFlight.size >= AUTO_RESUME_CONCURRENCY) {
11256
+ await Promise.race(inFlight);
11257
+ }
11258
+ if (started > 0) {
11259
+ await new Promise((resolve2) => setTimeout(resolve2, AUTO_RESUME_STAGGER_MS));
11260
+ }
11261
+ const task = resume(row);
11262
+ inFlight.add(task);
11263
+ void task.then(() => inFlight.delete(task));
11264
+ started++;
11265
+ }
11266
+ await Promise.all(inFlight);
11267
+ if (resumed > 0) this.wsHub.broadcast(this.sessionListPayload());
11268
+ this.log.info(`[auto-resume] completed boot recovery: ${resumed} resumed`, {
11269
+ event: "sessions.auto_resume_completed",
11270
+ attempted: plan.attempts.length,
11271
+ resumed,
11272
+ failed,
11273
+ ineligible: plan.skipped.length,
11274
+ overflow: plan.overflow.length
11275
+ });
10491
11276
  }
10492
11277
  /**
10493
11278
  * Pick a token guaranteed to appear in the spawned process's argv, for the
@@ -10504,6 +11289,26 @@ var StreamerServer = class {
10504
11289
  if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
10505
11290
  return session.boundConversationId ?? session.projectPath;
10506
11291
  }
11292
+ /** Restore registry-only metadata after the host mirror has been adopted. */
11293
+ refreshHostedSessionsFromRegistry() {
11294
+ for (const session of this.ptyManager.listSessions()) {
11295
+ const row = this.managedSessionsRepo?.get(session.id);
11296
+ const merged = {
11297
+ ...session,
11298
+ reconciled: true,
11299
+ ...row?.project_id != null && { projectId: row.project_id },
11300
+ ...row?.session_name != null && { sessionName: row.session_name },
11301
+ ...row?.bound_conversation_id != null && {
11302
+ boundConversationId: row.bound_conversation_id
11303
+ },
11304
+ ...row?.resumed_from_conversation_id != null && {
11305
+ resumedFromConversationId: row.resumed_from_conversation_id
11306
+ }
11307
+ };
11308
+ this.sessionStore.addManaged(merged);
11309
+ void this.watchConversationFile(session.id, merged.boundConversationId ?? session.id);
11310
+ }
11311
+ }
10507
11312
  /**
10508
11313
  * Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
10509
11314
  *
@@ -10599,6 +11404,7 @@ var StreamerServer = class {
10599
11404
  * of waiting on the interval.
10600
11405
  */
10601
11406
  reapIdleSessions(now = Date.now()) {
11407
+ if (this.ptyManager.isRemote()) return [];
10602
11408
  const reaped = [];
10603
11409
  for (const session of this.ptyManager.listSessions()) {
10604
11410
  if (session.status === "running") continue;
@@ -10700,6 +11506,40 @@ var StreamerServer = class {
10700
11506
  return true;
10701
11507
  }
10702
11508
  async listen(port, opts) {
11509
+ if (this.featureFlags.ptyHost) {
11510
+ try {
11511
+ let sessions = null;
11512
+ for (let attempt = 0; attempt < 2; attempt += 1) {
11513
+ const transport = await connectOrSpawnHost({
11514
+ instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os10.hostname)()
11515
+ });
11516
+ try {
11517
+ sessions = await this.ptyManager.useRemoteRunner(transport);
11518
+ break;
11519
+ } catch (err) {
11520
+ if (!(err instanceof PtyHostProtocolMismatchError) || attempt > 0) throw err;
11521
+ this.log.info(`[pty-host] replaced incompatible protocol ${err.hostVersion}`, {
11522
+ event: "pty_host.protocol_replaced",
11523
+ hostVersion: err.hostVersion,
11524
+ streamerVersion: err.streamerVersion
11525
+ });
11526
+ }
11527
+ }
11528
+ if (!sessions) throw new Error("pty-host replacement did not produce a compatible host");
11529
+ for (const session of sessions) {
11530
+ this.sessionStore.addManaged({ ...session, reconciled: true });
11531
+ }
11532
+ this.log.info(`[pty-host] re-adopted ${sessions.length} live session(s)`, {
11533
+ event: "pty_host.sessions_adopted",
11534
+ sessions: sessions.length
11535
+ });
11536
+ } catch (err) {
11537
+ this.log.error(
11538
+ "[pty-host] could not attach; falling back to in-process PTYs for this run",
11539
+ { event: "pty_host.attach_failed", err }
11540
+ );
11541
+ }
11542
+ }
10703
11543
  const dbConfig = this.disableDb ? null : getDbConfig();
10704
11544
  if (dbConfig) {
10705
11545
  this.dbPool = await createPool(dbConfig);
@@ -10714,8 +11554,10 @@ var StreamerServer = class {
10714
11554
  this.log.info("Database migrations applied", { event: "db.migrations_applied" });
10715
11555
  }
10716
11556
  await this.bindWithRetry(port);
10717
- this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
10718
- this.idleReaperTimer.unref?.();
11557
+ if (!this.ptyManager.isRemote()) {
11558
+ this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
11559
+ this.idleReaperTimer.unref?.();
11560
+ }
10719
11561
  const warmUp = new Promise((resolveWarm) => {
10720
11562
  {
10721
11563
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -10733,6 +11575,15 @@ var StreamerServer = class {
10733
11575
  { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
10734
11576
  );
10735
11577
  }
11578
+ if (this.ptyManager.isRemote()) {
11579
+ this.ptyManager.startRemoteHeartbeat(() => {
11580
+ if (!this.managedSessionsRepo) {
11581
+ return { registryState: "unknown", referencedSessionIds: [] };
11582
+ }
11583
+ const referencedSessionIds = this.ptyManager.listSessions().filter((session) => this.managedSessionsRepo?.get(session.id)?.completed_at == null).map((session) => session.id);
11584
+ return { registryState: "known", referencedSessionIds };
11585
+ });
11586
+ }
10736
11587
  try {
10737
11588
  this.cache = ConversationCache.open(
10738
11589
  (0, import_path18.join)(this.cacheDir, "cache.db"),
@@ -10809,8 +11660,10 @@ var StreamerServer = class {
10809
11660
  );
10810
11661
  this.scannerPersistenceDisabled = true;
10811
11662
  }
10812
- void this.reconcilePreviousSessions().then((v) => {
10813
- this.rehydratePreviousSessions(v);
11663
+ if (this.ptyManager.isRemote()) this.refreshHostedSessionsFromRegistry();
11664
+ void this.reconcilePreviousSessions().then(async (v) => {
11665
+ const recoverableRows = this.rehydratePreviousSessions(v);
11666
+ await this.autoResumePreviousSessions(recoverableRows);
10814
11667
  this.pruneTerminalSessions();
10815
11668
  });
10816
11669
  if (this.skipStartupWarmup) {
@@ -11013,7 +11866,8 @@ var StreamerServer = class {
11013
11866
  }
11014
11867
  this.lastAgentChunkAt.clear();
11015
11868
  this.terminalSeq.clear();
11016
- this.recordShutdownState();
11869
+ if (this.ptyManager.isRemote()) this.ptyManager.dispose();
11870
+ else this.recordShutdownState();
11017
11871
  this.markScannerStaleDebounced.cancel();
11018
11872
  await Promise.all([...this.inFlightCacheWrites]);
11019
11873
  await Promise.all([...this.allScanners].map((s) => s.close()));
@@ -11021,7 +11875,7 @@ var StreamerServer = class {
11021
11875
  this.scanner = null;
11022
11876
  this.cache?.close();
11023
11877
  this.runtimeStore?.close();
11024
- this.ptyManager.dispose();
11878
+ if (!this.ptyManager.isRemote()) this.ptyManager.dispose();
11025
11879
  this.fileWatcher.dispose();
11026
11880
  this.externalTails.clear();
11027
11881
  this.wsHub.dispose();
@@ -12071,6 +12925,16 @@ var StreamerServer = class {
12071
12925
  const windowStart = Math.max(0, beforeIndex - scanLimit);
12072
12926
  const indexWindow = scanLimit > 0 && !hasAnchor && indexFilePath && this.cache ? this.cache.readMessageWindow(indexFilePath, windowStart, beforeIndex) : null;
12073
12927
  if (!indexWindow && indexFilePath && this.cache && !hasAnchor) {
12928
+ this.log.info(
12929
+ `[server] offset-index miss ${id} \u2192 scanner fallback`,
12930
+ {
12931
+ event: "offset_index.miss",
12932
+ conversationId: id,
12933
+ fromIndex: windowStart,
12934
+ toIndex: beforeIndex
12935
+ },
12936
+ "pino"
12937
+ );
12074
12938
  this.trackCacheWrite(
12075
12939
  this.cache.backfillIndex(indexFilePath).catch((err) => {
12076
12940
  this.log.warn("offset-index.backfill_failed", {
@@ -12345,14 +13209,15 @@ var StreamerServer = class {
12345
13209
  }
12346
13210
  async handleGetSession(sessionId, res) {
12347
13211
  if (this.rejectIfWarmingUp(res)) return;
12348
- const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12349
- if (session) {
12350
- if (!(0, import_fs19.existsSync)(session.projectPath)) {
12351
- session.failureReason = `Project directory not found: ${session.projectPath}`;
12352
- }
12353
- const reconciled = this.withReconciledLifecycle([session])[0];
12354
- session.lifecycle = reconciled.lifecycle;
12355
- session.lifecycleSource = reconciled.lifecycleSource;
13212
+ const base = this.sessionStore.get(sessionId, this.ptyAttachedIds());
13213
+ if (base) {
13214
+ const reconciled = this.withReconciledLifecycle([base])[0];
13215
+ const session = {
13216
+ ...base,
13217
+ ...(0, import_fs19.existsSync)(base.projectPath) ? {} : { failureReason: `Project directory not found: ${base.projectPath}` },
13218
+ lifecycle: reconciled.lifecycle,
13219
+ lifecycleSource: reconciled.lifecycleSource
13220
+ };
12356
13221
  if (this.ptyManager.hasSession(sessionId)) {
12357
13222
  try {
12358
13223
  const lines = await this.ptyManager.getOutputLines(sessionId, 10);
@@ -12515,31 +13380,37 @@ var StreamerServer = class {
12515
13380
  this.sessionStore.addManaged(session);
12516
13381
  this.recordSessionSpawn(session);
12517
13382
  void this.watchConversationFile(sessionId, historyId);
12518
- const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12519
13383
  this.enrichResumedSessionAsync(sessionId, projectPath, conv);
13384
+ const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12520
13385
  return { ok: true, alreadyRunning: false, session, response };
12521
13386
  }
12522
13387
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
12523
13388
  try {
12524
- const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12525
- if (!session) return;
13389
+ if (!this.sessionStore.getManaged(sessionId)) return;
12526
13390
  if (conv) {
12527
- session.sessionName = conv.sessionName ?? void 0;
12528
- session.messageCount = conv.messageCount ?? 0;
12529
- session.account = conv.account ?? void 0;
12530
- session.filePath = conv.filePath ?? void 0;
13391
+ this.sessionStore.updateManaged(sessionId, {
13392
+ sessionName: conv.sessionName ?? void 0,
13393
+ messageCount: conv.messageCount ?? 0,
13394
+ account: conv.account ?? void 0,
13395
+ filePath: conv.filePath ?? void 0
13396
+ });
12531
13397
  }
12532
13398
  if (!this.cache || !this.projectsRepo || !this.conversationsRepo) return;
12533
13399
  const cached3 = this.cache.getMetaById(sessionId);
12534
13400
  if (cached3) {
12535
- session.model = cached3.model ?? void 0;
12536
- session.preview = cached3.preview ?? void 0;
12537
13401
  const first = cached3.firstMessage ? JSON.parse(cached3.firstMessage) : null;
12538
13402
  const last = cached3.lastMessage ? JSON.parse(cached3.lastMessage) : null;
12539
- session.firstMessageText = first?.text ?? void 0;
12540
- session.firstMessageAt = first?.timestamp ? new Date(first.timestamp).toISOString() : void 0;
12541
- session.lastMessageText = last?.text ?? void 0;
12542
- session.lastMessageAt = last?.timestamp ? new Date(last.timestamp).toISOString() : void 0;
13403
+ this.sessionStore.updateManaged(sessionId, {
13404
+ model: cached3.model ?? void 0,
13405
+ preview: cached3.preview ?? void 0,
13406
+ firstMessageText: first?.text ?? void 0,
13407
+ // parseIsoDateOrNull, not `new Date()`: an unparseable cached
13408
+ // timestamp must land as absent, not as an Invalid Date that
13409
+ // managedToResponse would throw on when it calls .toISOString().
13410
+ firstMessageAt: parseIsoDateOrNull(first?.timestamp) ?? void 0,
13411
+ lastMessageText: last?.text ?? void 0,
13412
+ lastMessageAt: parseIsoDateOrNull(last?.timestamp) ?? void 0
13413
+ });
12543
13414
  }
12544
13415
  let resolvedProjectId = cached3?.projectId ?? null;
12545
13416
  if (!resolvedProjectId) {
@@ -12551,8 +13422,10 @@ var StreamerServer = class {
12551
13422
  });
12552
13423
  }
12553
13424
  if (resolvedProjectId) {
12554
- session.projectId = resolvedProjectId;
12555
- session.resumedFromConversationId = sessionId;
13425
+ this.sessionStore.updateManaged(sessionId, {
13426
+ projectId: resolvedProjectId,
13427
+ resumedFromConversationId: sessionId
13428
+ });
12556
13429
  }
12557
13430
  } catch (err) {
12558
13431
  console.error(`[enrichResumedSessionAsync] ${sessionId}:`, err);