@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/cli.cjs +1379 -483
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +853 -109
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +44 -4
- package/dist/index.d.ts +44 -4
- package/dist/index.js +894 -150
- package/dist/index.js.map +1 -1
- package/dist/launchd-entry.cjs +8 -5
- package/dist/launchd-entry.cjs.map +1 -1
- package/package.json +5 -5
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
|
|
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(
|
|
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 =
|
|
484
|
-
info: (m, f, d =
|
|
485
|
-
warn: (m, f, d =
|
|
486
|
-
error: (m, f, d =
|
|
487
|
-
log: (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")
|
|
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")
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
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
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
3260
|
+
return this.activeRunners().flatMap((runner) => runner.listSessions());
|
|
2823
3261
|
}
|
|
2824
3262
|
dispose() {
|
|
2825
|
-
for (const runner of this.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
7241
|
-
if (!
|
|
7242
|
-
if (Date.now() >
|
|
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 (
|
|
7247
|
-
|
|
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,
|
|
8005
|
+
constructor(cache, wsHub, log8, cacheDir, rescan, runDuringReset) {
|
|
7367
8006
|
this.cache = cache;
|
|
7368
8007
|
this.wsHub = wsHub;
|
|
7369
|
-
this.log =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
10846
|
-
|
|
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.
|
|
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
|
|
12478
|
-
if (
|
|
12479
|
-
|
|
12480
|
-
|
|
12481
|
-
|
|
12482
|
-
|
|
12483
|
-
|
|
12484
|
-
|
|
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
|
-
|
|
12654
|
-
if (!session) return;
|
|
13389
|
+
if (!this.sessionStore.getManaged(sessionId)) return;
|
|
12655
13390
|
if (conv) {
|
|
12656
|
-
|
|
12657
|
-
|
|
12658
|
-
|
|
12659
|
-
|
|
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
|
-
|
|
12669
|
-
|
|
12670
|
-
|
|
12671
|
-
|
|
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
|
-
|
|
12684
|
-
|
|
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);
|