@threadbase-sh/streamer 1.43.0 → 1.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,
@@ -9033,6 +9672,12 @@ var SessionStore = class {
9033
9672
  removeManaged(sessionId) {
9034
9673
  return this.managed.delete(sessionId);
9035
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
+ */
9036
9681
  getManaged(sessionId) {
9037
9682
  return this.managed.get(sessionId) ?? null;
9038
9683
  }
@@ -9042,12 +9687,21 @@ var SessionStore = class {
9042
9687
  this.discovered.set(proc.pid, proc);
9043
9688
  }
9044
9689
  }
9690
+ /**
9691
+ * The **live** stored records — mutating an element mutates the store. Paired
9692
+ * with `list()`, which hands back throwaway response copies.
9693
+ */
9045
9694
  listManaged() {
9046
9695
  return Array.from(this.managed.values());
9047
9696
  }
9048
9697
  // Build the session list: live PTY sessions (managed) merged with externally
9049
9698
  // discovered Claude processes. Managed sessions keyed by JSONL UUID take
9050
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.
9051
9705
  list(ptyAttachedIds) {
9052
9706
  const results = [];
9053
9707
  const seenIds = /* @__PURE__ */ new Set();
@@ -9063,6 +9717,9 @@ var SessionStore = class {
9063
9717
  }
9064
9718
  return results;
9065
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.
9066
9723
  get(sessionId, ptyAttachedIds) {
9067
9724
  const managed = this.managed.get(sessionId);
9068
9725
  if (managed) return managedToResponse(managed, ptyAttachedIds.has(sessionId));
@@ -9154,8 +9811,8 @@ function managedToResponse(s, ptyAttached) {
9154
9811
  // behind it — `resumable`, and `historical` rather than `managed`
9155
9812
  // (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
9156
9813
  lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
9157
- lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
9158
- // 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
9159
9816
  // `activity` is attached for managed sessions.
9160
9817
  ownership: s.rehydrated ? "historical" : "managed",
9161
9818
  projectPath: s.projectPath,
@@ -10385,7 +11042,7 @@ var StreamerServer = class {
10385
11042
  if (!this.managedSessionsRepo) return [];
10386
11043
  let verdicts = [];
10387
11044
  try {
10388
- const rows = this.managedSessionsRepo.listNonTerminal();
11045
+ const rows = this.managedSessionsRepo.listNonTerminal().filter((row) => !this.ptyManager.hasSession(row.session_id));
10389
11046
  if (rows.length === PROBE_SET_MAX) {
10390
11047
  this.log.warn(
10391
11048
  `[reconcile] probe set hit its cap of ${PROBE_SET_MAX} \u2014 older rows skipped`,
@@ -10632,6 +11289,26 @@ var StreamerServer = class {
10632
11289
  if (session.provider !== CODEX_CLI_PROVIDER) return session.id;
10633
11290
  return session.boundConversationId ?? session.projectPath;
10634
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
+ }
10635
11312
  /**
10636
11313
  * Mirror a freshly-spawned session into the durable registry (C1 Phase 2).
10637
11314
  *
@@ -10727,6 +11404,7 @@ var StreamerServer = class {
10727
11404
  * of waiting on the interval.
10728
11405
  */
10729
11406
  reapIdleSessions(now = Date.now()) {
11407
+ if (this.ptyManager.isRemote()) return [];
10730
11408
  const reaped = [];
10731
11409
  for (const session of this.ptyManager.listSessions()) {
10732
11410
  if (session.status === "running") continue;
@@ -10828,6 +11506,40 @@ var StreamerServer = class {
10828
11506
  return true;
10829
11507
  }
10830
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
+ }
10831
11543
  const dbConfig = this.disableDb ? null : getDbConfig();
10832
11544
  if (dbConfig) {
10833
11545
  this.dbPool = await createPool(dbConfig);
@@ -10842,8 +11554,10 @@ var StreamerServer = class {
10842
11554
  this.log.info("Database migrations applied", { event: "db.migrations_applied" });
10843
11555
  }
10844
11556
  await this.bindWithRetry(port);
10845
- this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
10846
- this.idleReaperTimer.unref?.();
11557
+ if (!this.ptyManager.isRemote()) {
11558
+ this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
11559
+ this.idleReaperTimer.unref?.();
11560
+ }
10847
11561
  const warmUp = new Promise((resolveWarm) => {
10848
11562
  {
10849
11563
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -10861,6 +11575,15 @@ var StreamerServer = class {
10861
11575
  { error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
10862
11576
  );
10863
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
+ }
10864
11587
  try {
10865
11588
  this.cache = ConversationCache.open(
10866
11589
  (0, import_path18.join)(this.cacheDir, "cache.db"),
@@ -10937,6 +11660,7 @@ var StreamerServer = class {
10937
11660
  );
10938
11661
  this.scannerPersistenceDisabled = true;
10939
11662
  }
11663
+ if (this.ptyManager.isRemote()) this.refreshHostedSessionsFromRegistry();
10940
11664
  void this.reconcilePreviousSessions().then(async (v) => {
10941
11665
  const recoverableRows = this.rehydratePreviousSessions(v);
10942
11666
  await this.autoResumePreviousSessions(recoverableRows);
@@ -11142,7 +11866,8 @@ var StreamerServer = class {
11142
11866
  }
11143
11867
  this.lastAgentChunkAt.clear();
11144
11868
  this.terminalSeq.clear();
11145
- this.recordShutdownState();
11869
+ if (this.ptyManager.isRemote()) this.ptyManager.dispose();
11870
+ else this.recordShutdownState();
11146
11871
  this.markScannerStaleDebounced.cancel();
11147
11872
  await Promise.all([...this.inFlightCacheWrites]);
11148
11873
  await Promise.all([...this.allScanners].map((s) => s.close()));
@@ -11150,7 +11875,7 @@ var StreamerServer = class {
11150
11875
  this.scanner = null;
11151
11876
  this.cache?.close();
11152
11877
  this.runtimeStore?.close();
11153
- this.ptyManager.dispose();
11878
+ if (!this.ptyManager.isRemote()) this.ptyManager.dispose();
11154
11879
  this.fileWatcher.dispose();
11155
11880
  this.externalTails.clear();
11156
11881
  this.wsHub.dispose();
@@ -12200,6 +12925,16 @@ var StreamerServer = class {
12200
12925
  const windowStart = Math.max(0, beforeIndex - scanLimit);
12201
12926
  const indexWindow = scanLimit > 0 && !hasAnchor && indexFilePath && this.cache ? this.cache.readMessageWindow(indexFilePath, windowStart, beforeIndex) : null;
12202
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
+ );
12203
12938
  this.trackCacheWrite(
12204
12939
  this.cache.backfillIndex(indexFilePath).catch((err) => {
12205
12940
  this.log.warn("offset-index.backfill_failed", {
@@ -12474,14 +13209,15 @@ var StreamerServer = class {
12474
13209
  }
12475
13210
  async handleGetSession(sessionId, res) {
12476
13211
  if (this.rejectIfWarmingUp(res)) return;
12477
- const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12478
- if (session) {
12479
- if (!(0, import_fs19.existsSync)(session.projectPath)) {
12480
- session.failureReason = `Project directory not found: ${session.projectPath}`;
12481
- }
12482
- const reconciled = this.withReconciledLifecycle([session])[0];
12483
- session.lifecycle = reconciled.lifecycle;
12484
- 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
+ };
12485
13221
  if (this.ptyManager.hasSession(sessionId)) {
12486
13222
  try {
12487
13223
  const lines = await this.ptyManager.getOutputLines(sessionId, 10);
@@ -12644,31 +13380,37 @@ var StreamerServer = class {
12644
13380
  this.sessionStore.addManaged(session);
12645
13381
  this.recordSessionSpawn(session);
12646
13382
  void this.watchConversationFile(sessionId, historyId);
12647
- const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12648
13383
  this.enrichResumedSessionAsync(sessionId, projectPath, conv);
13384
+ const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12649
13385
  return { ok: true, alreadyRunning: false, session, response };
12650
13386
  }
12651
13387
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
12652
13388
  try {
12653
- const session = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12654
- if (!session) return;
13389
+ if (!this.sessionStore.getManaged(sessionId)) return;
12655
13390
  if (conv) {
12656
- session.sessionName = conv.sessionName ?? void 0;
12657
- session.messageCount = conv.messageCount ?? 0;
12658
- session.account = conv.account ?? void 0;
12659
- 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
+ });
12660
13397
  }
12661
13398
  if (!this.cache || !this.projectsRepo || !this.conversationsRepo) return;
12662
13399
  const cached3 = this.cache.getMetaById(sessionId);
12663
13400
  if (cached3) {
12664
- session.model = cached3.model ?? void 0;
12665
- session.preview = cached3.preview ?? void 0;
12666
13401
  const first = cached3.firstMessage ? JSON.parse(cached3.firstMessage) : null;
12667
13402
  const last = cached3.lastMessage ? JSON.parse(cached3.lastMessage) : null;
12668
- session.firstMessageText = first?.text ?? void 0;
12669
- session.firstMessageAt = first?.timestamp ? new Date(first.timestamp).toISOString() : void 0;
12670
- session.lastMessageText = last?.text ?? void 0;
12671
- 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
+ });
12672
13414
  }
12673
13415
  let resolvedProjectId = cached3?.projectId ?? null;
12674
13416
  if (!resolvedProjectId) {
@@ -12680,8 +13422,10 @@ var StreamerServer = class {
12680
13422
  });
12681
13423
  }
12682
13424
  if (resolvedProjectId) {
12683
- session.projectId = resolvedProjectId;
12684
- session.resumedFromConversationId = sessionId;
13425
+ this.sessionStore.updateManaged(sessionId, {
13426
+ projectId: resolvedProjectId,
13427
+ resumedFromConversationId: sessionId
13428
+ });
12685
13429
  }
12686
13430
  } catch (err) {
12687
13431
  console.error(`[enrichResumedSessionAsync] ${sessionId}:`, err);