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