@threadbase-sh/streamer 1.52.5 → 1.53.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 +754 -467
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +228 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -6
- package/dist/index.d.ts +66 -6
- package/dist/index.js +225 -59
- package/dist/index.js.map +1 -1
- package/dist/migrations/011_create_devices.sql +15 -0
- package/dist/runtime-migrations/003_create_devices.sql +52 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -555,7 +555,8 @@ function loadOrCreateApiKey() {
|
|
|
555
555
|
const key = generateApiKey();
|
|
556
556
|
mkdirSync(configDir(), { recursive: true });
|
|
557
557
|
writeFileSync(configFile(), `api_key: ${key}
|
|
558
|
-
`, "utf-8");
|
|
558
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
559
|
+
chmodSync(configFile(), 384);
|
|
559
560
|
return key;
|
|
560
561
|
}
|
|
561
562
|
function loadBrowseRoot() {
|
|
@@ -3863,8 +3864,8 @@ import { EventEmitter } from "events";
|
|
|
3863
3864
|
import { existsSync as existsSync16 } from "fs";
|
|
3864
3865
|
import { realpath as realpath2 } from "fs/promises";
|
|
3865
3866
|
import { createServer as createServer2 } from "http";
|
|
3866
|
-
import { homedir as
|
|
3867
|
-
import { dirname as dirname10, join as
|
|
3867
|
+
import { homedir as homedir13, hostname as hostname3 } from "os";
|
|
3868
|
+
import { dirname as dirname10, join as join24 } from "path";
|
|
3868
3869
|
|
|
3869
3870
|
// src/api/app.ts
|
|
3870
3871
|
import { Hono as Hono18 } from "hono";
|
|
@@ -4000,6 +4001,8 @@ var DevicesRepository = class {
|
|
|
4000
4001
|
listStmt;
|
|
4001
4002
|
revokeStmt;
|
|
4002
4003
|
touchStmt;
|
|
4004
|
+
deleteStmt;
|
|
4005
|
+
deleteRevokedStmt;
|
|
4003
4006
|
constructor(db) {
|
|
4004
4007
|
this.insertStmt = db.prepare(`
|
|
4005
4008
|
INSERT INTO devices (
|
|
@@ -4013,6 +4016,8 @@ var DevicesRepository = class {
|
|
|
4013
4016
|
this.listStmt = db.prepare("SELECT * FROM devices ORDER BY created_at DESC");
|
|
4014
4017
|
this.revokeStmt = db.prepare("UPDATE devices SET revoked_at = ? WHERE device_id = ?");
|
|
4015
4018
|
this.touchStmt = db.prepare("UPDATE devices SET last_seen_at = ? WHERE device_id = ?");
|
|
4019
|
+
this.deleteStmt = db.prepare("DELETE FROM devices WHERE device_id = ?");
|
|
4020
|
+
this.deleteRevokedStmt = db.prepare("DELETE FROM devices WHERE revoked_at IS NOT NULL");
|
|
4016
4021
|
}
|
|
4017
4022
|
/**
|
|
4018
4023
|
* Record a newly paired device and mint its token.
|
|
@@ -4059,6 +4064,33 @@ var DevicesRepository = class {
|
|
|
4059
4064
|
revoke(deviceId, now = Date.now()) {
|
|
4060
4065
|
return this.revokeStmt.run(now, deviceId).changes > 0;
|
|
4061
4066
|
}
|
|
4067
|
+
/**
|
|
4068
|
+
* Erase one device's record outright.
|
|
4069
|
+
*
|
|
4070
|
+
* Deliberately separate from `revoke`, which is a soft delete that keeps the
|
|
4071
|
+
* row so `list()` can show what happened. That audit trail is the right
|
|
4072
|
+
* default — but it meant a `devices` row, including the user-supplied `name`
|
|
4073
|
+
* ("Ronen's iPhone"), had no removal path at all once the registry moved to
|
|
4074
|
+
* runtime.db, which no command deletes. This is that path.
|
|
4075
|
+
*
|
|
4076
|
+
* Erasure is NOT revocation: deleting a row frees its `token_hash`, so a
|
|
4077
|
+
* device whose token is still on a phone somewhere stops being *known* rather
|
|
4078
|
+
* than being *refused*. Revoke first, delete second, is the safe order, and
|
|
4079
|
+
* `deleteRevoked()` exists so that is the easy thing to do.
|
|
4080
|
+
*/
|
|
4081
|
+
delete(deviceId) {
|
|
4082
|
+
return this.deleteStmt.run(deviceId).changes > 0;
|
|
4083
|
+
}
|
|
4084
|
+
/**
|
|
4085
|
+
* Erase every already-revoked device. The bulk companion to `delete`, and the
|
|
4086
|
+
* one that is safe by construction: a revoked device is already refused, so
|
|
4087
|
+
* removing its row cannot restore access to anything.
|
|
4088
|
+
*
|
|
4089
|
+
* Returns the number of rows removed.
|
|
4090
|
+
*/
|
|
4091
|
+
deleteRevoked() {
|
|
4092
|
+
return this.deleteRevokedStmt.run().changes;
|
|
4093
|
+
}
|
|
4062
4094
|
touch(deviceId, now = Date.now()) {
|
|
4063
4095
|
this.touchStmt.run(now, deviceId);
|
|
4064
4096
|
}
|
|
@@ -4585,6 +4617,34 @@ var createDeviceRoutes = (deps) => {
|
|
|
4585
4617
|
repo.revoke(id);
|
|
4586
4618
|
return c.json({ ok: true, alreadyRevoked: false });
|
|
4587
4619
|
});
|
|
4620
|
+
app.delete("/:id", (c) => {
|
|
4621
|
+
const repo = deps.devicesRepo();
|
|
4622
|
+
if (!repo) {
|
|
4623
|
+
return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
4624
|
+
}
|
|
4625
|
+
const id = c.req.param("id");
|
|
4626
|
+
const existing = repo.get(id);
|
|
4627
|
+
if (!existing) return c.json({ ok: true, alreadyDeleted: true });
|
|
4628
|
+
const force = c.req.query("force") === "1" || c.req.query("force") === "true";
|
|
4629
|
+
if (existing.revoked_at == null && !force) {
|
|
4630
|
+
return c.json(
|
|
4631
|
+
{
|
|
4632
|
+
error: "Revoke the device before deleting it, or pass ?force=1",
|
|
4633
|
+
code: "DEVICE_ACTIVE"
|
|
4634
|
+
},
|
|
4635
|
+
409
|
|
4636
|
+
);
|
|
4637
|
+
}
|
|
4638
|
+
repo.delete(id);
|
|
4639
|
+
return c.json({ ok: true, alreadyDeleted: false });
|
|
4640
|
+
});
|
|
4641
|
+
app.delete("/", (c) => {
|
|
4642
|
+
const repo = deps.devicesRepo();
|
|
4643
|
+
if (!repo) {
|
|
4644
|
+
return c.json({ error: "Device registry is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
4645
|
+
}
|
|
4646
|
+
return c.json({ ok: true, deleted: repo.deleteRevoked() });
|
|
4647
|
+
});
|
|
4588
4648
|
return app;
|
|
4589
4649
|
};
|
|
4590
4650
|
|
|
@@ -5501,6 +5561,13 @@ var createMiscRoutes = (deps) => {
|
|
|
5501
5561
|
// Same contract: this server serves GET /api/projects/summary, which the
|
|
5502
5562
|
// Hub's grouped views need before they can draw a tree.
|
|
5503
5563
|
projectSummary: true,
|
|
5564
|
+
// The paired-device registry lives in runtime.db, so it survives
|
|
5565
|
+
// `tb-streamer cache clear` and the integrity monitor's reset-and-rescan.
|
|
5566
|
+
// A client may only prefer its scoped device token over the shared API
|
|
5567
|
+
// key when this is true: on an older server the registry is inside
|
|
5568
|
+
// cache.db, where a documented troubleshooting step deletes it and every
|
|
5569
|
+
// device token with it. Absent means "old server, assume not durable".
|
|
5570
|
+
devicesDurable: true,
|
|
5504
5571
|
// Delivery capability, not endpoint support: whether this server can
|
|
5505
5572
|
// actually send a push, so mobile can hide an affordance instead of
|
|
5506
5573
|
// registering tokens nothing will ever send to. Absent on older servers,
|
|
@@ -5911,7 +5978,13 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
5911
5978
|
const app = new Hono17();
|
|
5912
5979
|
app.get(
|
|
5913
5980
|
"/ws",
|
|
5914
|
-
|
|
5981
|
+
// The principal is read here, at the upgrade, and captured for the life of
|
|
5982
|
+
// the socket. authMiddleware sets it because /ws is classified
|
|
5983
|
+
// `history:read`, but it only ever reaches the HTTP request — without
|
|
5984
|
+
// capturing it the socket has no principal at all, so every frame after
|
|
5985
|
+
// the upgrade is unauthorized-by-omission.
|
|
5986
|
+
upgradeWebSocket((c) => {
|
|
5987
|
+
const principal = c.get("principal") ?? null;
|
|
5915
5988
|
let openWs = null;
|
|
5916
5989
|
return {
|
|
5917
5990
|
onOpen(_evt, ws) {
|
|
@@ -5921,7 +5994,7 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
|
|
|
5921
5994
|
deps.handleWsOpen(raw);
|
|
5922
5995
|
},
|
|
5923
5996
|
onMessage(evt, _ws) {
|
|
5924
|
-
if (openWs) deps.handleWsMessage(openWs, evt.data);
|
|
5997
|
+
if (openWs) deps.handleWsMessage(openWs, evt.data, principal);
|
|
5925
5998
|
},
|
|
5926
5999
|
onClose(_evt, _ws) {
|
|
5927
6000
|
if (openWs) deps.handleWsClose(openWs);
|
|
@@ -10651,6 +10724,11 @@ var SessionsRepository = class {
|
|
|
10651
10724
|
|
|
10652
10725
|
// src/db/runtime-store.ts
|
|
10653
10726
|
import Database2 from "better-sqlite3";
|
|
10727
|
+
import { homedir as homedir6 } from "os";
|
|
10728
|
+
import { join as join16 } from "path";
|
|
10729
|
+
function resolveRuntimeDbPath(override) {
|
|
10730
|
+
return override ?? process.env.THREADBASE_RUNTIME_DB ?? join16(process.env.THREADBASE_CONFIG_DIR ?? join16(homedir6(), ".threadbase"), "runtime.db");
|
|
10731
|
+
}
|
|
10654
10732
|
var RuntimeStore = class _RuntimeStore {
|
|
10655
10733
|
constructor(db) {
|
|
10656
10734
|
this.db = db;
|
|
@@ -10668,23 +10746,64 @@ var RuntimeStore = class _RuntimeStore {
|
|
|
10668
10746
|
/**
|
|
10669
10747
|
* One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
|
|
10670
10748
|
*
|
|
10671
|
-
* Non-destructive by design: the source table is left in place so an older
|
|
10672
|
-
* streamer rolled back onto the same machine still finds its registry. Runs
|
|
10673
|
-
* only when this file's table is empty, so a second boot is a no-op rather
|
|
10674
|
-
* than a re-copy that would resurrect rows deleted since.
|
|
10675
|
-
*
|
|
10676
10749
|
* Returns the number of rows copied.
|
|
10677
10750
|
*/
|
|
10678
10751
|
importLegacyManagedSessions(source) {
|
|
10679
|
-
|
|
10752
|
+
return this.importLegacyTable(source, "managed_sessions");
|
|
10753
|
+
}
|
|
10754
|
+
/**
|
|
10755
|
+
* One-time move of `devices` rows out of `cache.db`, where the registry used
|
|
10756
|
+
* to live (migration `011_create_devices.sql`).
|
|
10757
|
+
*
|
|
10758
|
+
* Losing this table invalidates every device token ever issued, and cache.db
|
|
10759
|
+
* is the file `tb-streamer cache clear` deletes and the integrity monitor
|
|
10760
|
+
* rebuilds — see `runtime-migrations/003_create_devices.sql`.
|
|
10761
|
+
*
|
|
10762
|
+
* Unlike `managed_sessions`, this one MOVES rather than copies: the source
|
|
10763
|
+
* rows are deleted once the copy is verified. A `devices` row carries a
|
|
10764
|
+
* user-supplied label ("Ronen's iPhone"), and leaving a second copy of that
|
|
10765
|
+
* on disk indefinitely — in the one file the user is told to delete when
|
|
10766
|
+
* something goes wrong — is more retained personal data than the rollback
|
|
10767
|
+
* path is worth. Recovering from a rollback is re-scanning a pairing QR.
|
|
10768
|
+
*
|
|
10769
|
+
* The delete is conditional on the copy being complete: `INSERT OR IGNORE`
|
|
10770
|
+
* can silently skip a row, so the destination count must match what was read
|
|
10771
|
+
* before anything is removed. A mismatch keeps the source and reports
|
|
10772
|
+
* `purged: false` rather than throwing — the import itself still succeeded,
|
|
10773
|
+
* and keeping data is the safe direction to fail in.
|
|
10774
|
+
*/
|
|
10775
|
+
importLegacyDevices(source) {
|
|
10776
|
+
const copied = this.importLegacyTable(source, "devices");
|
|
10777
|
+
if (copied === 0) return { copied: 0, purged: false };
|
|
10778
|
+
const landed = this.db.prepare("SELECT COUNT(*) AS n FROM devices").get().n;
|
|
10779
|
+
if (landed !== copied) return { copied, purged: false };
|
|
10780
|
+
source.prepare("DELETE FROM devices").run();
|
|
10781
|
+
return { copied, purged: true };
|
|
10782
|
+
}
|
|
10783
|
+
/**
|
|
10784
|
+
* Copy a whole table out of a pre-split `cache.db` into this file.
|
|
10785
|
+
*
|
|
10786
|
+
* The copy itself is non-destructive — the source is left in place, so an
|
|
10787
|
+
* older streamer rolled back onto the same machine still finds its data.
|
|
10788
|
+
* `importLegacyDevices` deletes the source afterwards for its own reasons;
|
|
10789
|
+
* `managed_sessions` does not. Runs only when this file's table is empty, so
|
|
10790
|
+
* a second boot is a no-op rather than a re-copy that would resurrect rows
|
|
10791
|
+
* deleted since.
|
|
10792
|
+
*
|
|
10793
|
+
* The table name is interpolated into SQL, so it is typed as a closed union
|
|
10794
|
+
* rather than `string` — the set of tables that can ever be lifted is known
|
|
10795
|
+
* at compile time, and that is what keeps a caller from making this a hole.
|
|
10796
|
+
*/
|
|
10797
|
+
importLegacyTable(source, table) {
|
|
10798
|
+
const existing = this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
|
|
10680
10799
|
if (existing.n > 0) return 0;
|
|
10681
|
-
const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name =
|
|
10800
|
+
const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
10682
10801
|
if (!hasTable) return 0;
|
|
10683
|
-
const rows = source.prepare(
|
|
10802
|
+
const rows = source.prepare(`SELECT * FROM ${table}`).all();
|
|
10684
10803
|
if (rows.length === 0) return 0;
|
|
10685
10804
|
const columns = Object.keys(rows[0]);
|
|
10686
10805
|
const insert = this.db.prepare(
|
|
10687
|
-
`INSERT OR IGNORE INTO
|
|
10806
|
+
`INSERT OR IGNORE INTO ${table} (${columns.join(", ")})
|
|
10688
10807
|
VALUES (${columns.map((c) => `@${c}`).join(", ")})`
|
|
10689
10808
|
);
|
|
10690
10809
|
this.db.transaction((batch) => {
|
|
@@ -10926,14 +11045,14 @@ import { spawn as spawn2 } from "child_process";
|
|
|
10926
11045
|
|
|
10927
11046
|
// src/pty-host/socket.ts
|
|
10928
11047
|
import { createConnection, createServer } from "net";
|
|
10929
|
-
import { homedir as
|
|
10930
|
-
import { join as
|
|
11048
|
+
import { homedir as homedir7 } from "os";
|
|
11049
|
+
import { join as join17 } from "path";
|
|
10931
11050
|
function hostSocketPath(instanceId) {
|
|
10932
11051
|
if (process.platform === "win32") {
|
|
10933
11052
|
return `\\\\.\\pipe\\threadbase-pty-host-${instanceId}`;
|
|
10934
11053
|
}
|
|
10935
|
-
const dir = process.env.THREADBASE_CONFIG_DIR ??
|
|
10936
|
-
return
|
|
11054
|
+
const dir = process.env.THREADBASE_CONFIG_DIR ?? join17(homedir7(), ".threadbase");
|
|
11055
|
+
return join17(dir, "run", `pty-host-${instanceId}.sock`);
|
|
10937
11056
|
}
|
|
10938
11057
|
function socketTransport(socket) {
|
|
10939
11058
|
socket.setEncoding("utf8");
|
|
@@ -11008,8 +11127,8 @@ import {
|
|
|
11008
11127
|
ConversationScanner
|
|
11009
11128
|
} from "@threadbase-sh/scanner";
|
|
11010
11129
|
import { statSync as statSync7 } from "fs";
|
|
11011
|
-
import { homedir as
|
|
11012
|
-
import { join as
|
|
11130
|
+
import { homedir as homedir9 } from "os";
|
|
11131
|
+
import { join as join19 } from "path";
|
|
11013
11132
|
|
|
11014
11133
|
// src/services/cache/cacheMetadata.ts
|
|
11015
11134
|
function getCacheMetadata(repo, key) {
|
|
@@ -11109,9 +11228,9 @@ function refreshConversationCache(deps) {
|
|
|
11109
11228
|
|
|
11110
11229
|
// src/services/conversations/shouldRefreshProjectsFromHdd.ts
|
|
11111
11230
|
import { readdirSync as readdirSync4, statSync as statSync6 } from "fs";
|
|
11112
|
-
import { homedir as
|
|
11113
|
-
import { join as
|
|
11114
|
-
var DEFAULT_PROJECTS_DIR =
|
|
11231
|
+
import { homedir as homedir8 } from "os";
|
|
11232
|
+
import { join as join18 } from "path";
|
|
11233
|
+
var DEFAULT_PROJECTS_DIR = join18(homedir8(), ".claude", "projects");
|
|
11115
11234
|
function maxProjectsTreeMtimeMs(projectsDir) {
|
|
11116
11235
|
let maxMs;
|
|
11117
11236
|
try {
|
|
@@ -11123,7 +11242,7 @@ function maxProjectsTreeMtimeMs(projectsDir) {
|
|
|
11123
11242
|
for (const ent of readdirSync4(projectsDir, { withFileTypes: true })) {
|
|
11124
11243
|
if (!ent.isDirectory()) continue;
|
|
11125
11244
|
try {
|
|
11126
|
-
const childMs = statSync6(
|
|
11245
|
+
const childMs = statSync6(join18(projectsDir, ent.name)).mtimeMs;
|
|
11127
11246
|
if (childMs > maxMs) maxMs = childMs;
|
|
11128
11247
|
} catch {
|
|
11129
11248
|
}
|
|
@@ -11295,9 +11414,9 @@ var ScannerManager = class {
|
|
|
11295
11414
|
projectsDirs() {
|
|
11296
11415
|
const profiles = this.deps.scanProfiles;
|
|
11297
11416
|
if (profiles && profiles.length > 0) {
|
|
11298
|
-
return profiles.filter((p) => p.enabled).map((p) =>
|
|
11417
|
+
return profiles.filter((p) => p.enabled).map((p) => join19(p.configDir, "projects"));
|
|
11299
11418
|
}
|
|
11300
|
-
return [
|
|
11419
|
+
return [join19(homedir9(), ".claude", "projects")];
|
|
11301
11420
|
}
|
|
11302
11421
|
// ─── staleness ────────────────────────────────────────────────────
|
|
11303
11422
|
// Drain the stale set and disarm the flag together. The caller owns the
|
|
@@ -11578,8 +11697,8 @@ import { statSync as statSync9 } from "fs";
|
|
|
11578
11697
|
|
|
11579
11698
|
// src/handlers/handleListProjects.ts
|
|
11580
11699
|
import { closeSync as closeSync4, openSync as openSync4, readdirSync as readdirSync5, readSync as readSync4, statSync as statSync8 } from "fs";
|
|
11581
|
-
import { homedir as
|
|
11582
|
-
import { join as
|
|
11700
|
+
import { homedir as homedir10 } from "os";
|
|
11701
|
+
import { join as join20 } from "path";
|
|
11583
11702
|
var HEAD_BYTES = 64 * 1024;
|
|
11584
11703
|
var MAX_FILES_PROBED = 3;
|
|
11585
11704
|
function readRecordedCwd(dir) {
|
|
@@ -11592,7 +11711,7 @@ function readRecordedCwd(dir) {
|
|
|
11592
11711
|
for (const file of files.slice(0, MAX_FILES_PROBED)) {
|
|
11593
11712
|
let fd;
|
|
11594
11713
|
try {
|
|
11595
|
-
fd = openSync4(
|
|
11714
|
+
fd = openSync4(join20(dir, file), "r");
|
|
11596
11715
|
const buf = Buffer.alloc(HEAD_BYTES);
|
|
11597
11716
|
const bytes = readSync4(fd, buf, 0, HEAD_BYTES, 0);
|
|
11598
11717
|
for (const line of buf.subarray(0, bytes).toString("utf8").split("\n")) {
|
|
@@ -11616,11 +11735,11 @@ function decodeProjectPath(dirName) {
|
|
|
11616
11735
|
function handleListProjects(url, res) {
|
|
11617
11736
|
const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
|
|
11618
11737
|
const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
|
|
11619
|
-
const projectsDir =
|
|
11738
|
+
const projectsDir = join20(homedir10(), ".claude", "projects");
|
|
11620
11739
|
let entries;
|
|
11621
11740
|
try {
|
|
11622
11741
|
entries = readdirSync5(projectsDir).map((dirName) => {
|
|
11623
|
-
const fullPath =
|
|
11742
|
+
const fullPath = join20(projectsDir, dirName);
|
|
11624
11743
|
let mtime = 0;
|
|
11625
11744
|
try {
|
|
11626
11745
|
mtime = statSync8(fullPath).mtimeMs;
|
|
@@ -11635,7 +11754,7 @@ function handleListProjects(url, res) {
|
|
|
11635
11754
|
}
|
|
11636
11755
|
const total = entries.length;
|
|
11637
11756
|
const page = entries.slice(offset, offset + limit).map(({ dirName }) => {
|
|
11638
|
-
const path = readRecordedCwd(
|
|
11757
|
+
const path = readRecordedCwd(join20(projectsDir, dirName)) ?? decodeProjectPath(String(dirName));
|
|
11639
11758
|
const name = path.split(/[\\/]/).filter(Boolean).pop() ?? dirName;
|
|
11640
11759
|
return { name, path, dirName };
|
|
11641
11760
|
});
|
|
@@ -11644,6 +11763,9 @@ function handleListProjects(url, res) {
|
|
|
11644
11763
|
}
|
|
11645
11764
|
|
|
11646
11765
|
// src/server-wiring.ts
|
|
11766
|
+
function wsAllows(principal, required) {
|
|
11767
|
+
return principal === null || hasCapability(principal, required);
|
|
11768
|
+
}
|
|
11647
11769
|
function createConversationWatcherEvents(deps) {
|
|
11648
11770
|
return {
|
|
11649
11771
|
onNewLineSpans: (filePath, spans, readFrom, endOffset) => {
|
|
@@ -11925,7 +12047,15 @@ function createApiDeps(deps) {
|
|
|
11925
12047
|
const alertMsg = deps.cacheMonitor()?.wsMessage();
|
|
11926
12048
|
if (alertMsg) deps.wsHub.unicast(ws, alertMsg);
|
|
11927
12049
|
},
|
|
11928
|
-
handleWsMessage: async (ws, raw) => {
|
|
12050
|
+
handleWsMessage: async (ws, raw, principal) => {
|
|
12051
|
+
const deny = (type, required) => {
|
|
12052
|
+
deps.log().warn(`[ws.capability_denied] ${type} requires ${required}`, {
|
|
12053
|
+
event: "ws.capability_denied",
|
|
12054
|
+
type,
|
|
12055
|
+
required,
|
|
12056
|
+
...principal?.deviceId ? { deviceId: principal.deviceId } : {}
|
|
12057
|
+
});
|
|
12058
|
+
};
|
|
11929
12059
|
try {
|
|
11930
12060
|
const msg = JSON.parse(String(raw));
|
|
11931
12061
|
if (msg.type === "register" && typeof msg.clientId === "string") {
|
|
@@ -11935,6 +12065,10 @@ function createApiDeps(deps) {
|
|
|
11935
12065
|
deps.wsToClientId.set(ws, msg.clientId);
|
|
11936
12066
|
}
|
|
11937
12067
|
if (msg.type === "subscribe_session" && typeof msg.sessionId === "string") {
|
|
12068
|
+
if (!wsAllows(principal, "history:read")) {
|
|
12069
|
+
deny(msg.type, "history:read");
|
|
12070
|
+
return;
|
|
12071
|
+
}
|
|
11938
12072
|
deps.addSessionSubscriber(msg.sessionId, ws);
|
|
11939
12073
|
if (deps.ptyManager.hasSession(msg.sessionId)) {
|
|
11940
12074
|
const lines = await deps.ptyManager.getOutputLines(msg.sessionId, 200);
|
|
@@ -11983,6 +12117,10 @@ function createApiDeps(deps) {
|
|
|
11983
12117
|
}
|
|
11984
12118
|
}
|
|
11985
12119
|
if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
|
|
12120
|
+
if (!wsAllows(principal, "session:control")) {
|
|
12121
|
+
deny(msg.type, "session:control");
|
|
12122
|
+
return;
|
|
12123
|
+
}
|
|
11986
12124
|
deps.startGraceTimer(msg.sessionId, deps.ptyGracePeriodMs);
|
|
11987
12125
|
}
|
|
11988
12126
|
} catch {
|
|
@@ -12010,11 +12148,11 @@ import { existsSync as existsSync12 } from "fs";
|
|
|
12010
12148
|
|
|
12011
12149
|
// src/services/cache-integrity/alertStore.ts
|
|
12012
12150
|
import { mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
|
|
12013
|
-
import { homedir as
|
|
12014
|
-
import { dirname as dirname9, join as
|
|
12151
|
+
import { homedir as homedir11 } from "os";
|
|
12152
|
+
import { dirname as dirname9, join as join21 } from "path";
|
|
12015
12153
|
function alertStatePath() {
|
|
12016
|
-
const dir = process.env.THREADBASE_CONFIG_DIR ??
|
|
12017
|
-
return
|
|
12154
|
+
const dir = process.env.THREADBASE_CONFIG_DIR ?? join21(homedir11(), ".threadbase");
|
|
12155
|
+
return join21(dir, "cache-alert.json");
|
|
12018
12156
|
}
|
|
12019
12157
|
function loadAlertState() {
|
|
12020
12158
|
try {
|
|
@@ -12033,7 +12171,7 @@ function saveAlertState(state) {
|
|
|
12033
12171
|
|
|
12034
12172
|
// src/services/cache-integrity/backup.ts
|
|
12035
12173
|
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readdirSync as readdirSync6, statSync as statSync10, unlinkSync } from "fs";
|
|
12036
|
-
import { join as
|
|
12174
|
+
import { join as join22 } from "path";
|
|
12037
12175
|
var DEFAULT_RETAIN = 3;
|
|
12038
12176
|
function retainCount() {
|
|
12039
12177
|
const parsed = Number.parseInt(process.env.THREADBASE_CACHE_BACKUP_RETAIN ?? "", 10);
|
|
@@ -12044,13 +12182,13 @@ function timestamp(d) {
|
|
|
12044
12182
|
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
12045
12183
|
}
|
|
12046
12184
|
async function backupCacheDb(db, cacheDir) {
|
|
12047
|
-
const backupsDir =
|
|
12185
|
+
const backupsDir = join22(cacheDir, "backups");
|
|
12048
12186
|
mkdirSync5(backupsDir, { recursive: true });
|
|
12049
|
-
const destPath =
|
|
12187
|
+
const destPath = join22(backupsDir, `cache-${timestamp(/* @__PURE__ */ new Date())}.db`);
|
|
12050
12188
|
await db.backup(destPath);
|
|
12051
12189
|
const retain = retainCount();
|
|
12052
12190
|
const backups = readdirSync6(backupsDir).filter((f) => f.startsWith("cache-") && f.endsWith(".db")).map((f) => {
|
|
12053
|
-
const full =
|
|
12191
|
+
const full = join22(backupsDir, f);
|
|
12054
12192
|
return { full, mtime: statSync10(full).mtimeMs };
|
|
12055
12193
|
}).sort((a, b) => b.mtime - a.mtime);
|
|
12056
12194
|
for (const stale of backups.slice(retain)) {
|
|
@@ -13864,9 +14002,15 @@ function getSortValue(s, key) {
|
|
|
13864
14002
|
case "projectName":
|
|
13865
14003
|
return s.projectName;
|
|
13866
14004
|
case "status":
|
|
13867
|
-
return s
|
|
14005
|
+
return statusSortValue(s);
|
|
13868
14006
|
}
|
|
13869
14007
|
}
|
|
14008
|
+
function statusSortValue(s) {
|
|
14009
|
+
const rank = s.status === "idle" ? 1 : 0;
|
|
14010
|
+
const activeAt = Date.parse(s.lastActivityAt ?? s.startedAt);
|
|
14011
|
+
const recency = String(1e15 - (Number.isNaN(activeAt) ? 0 : activeAt)).padStart(16, "0");
|
|
14012
|
+
return `${rank}:${recency}`;
|
|
14013
|
+
}
|
|
13870
14014
|
function compareValues(a, b) {
|
|
13871
14015
|
if (a === void 0 && b === void 0) return 0;
|
|
13872
14016
|
if (a === void 0) return 1;
|
|
@@ -14001,8 +14145,8 @@ function discoveredToResponse(d, conversationId) {
|
|
|
14001
14145
|
|
|
14002
14146
|
// src/session-watchers.ts
|
|
14003
14147
|
import { existsSync as existsSync15, watch as fsWatch, readdirSync as readdirSync7, readFileSync as readFileSync9, statSync as statSync12 } from "fs";
|
|
14004
|
-
import { homedir as
|
|
14005
|
-
import { basename as basename6, join as
|
|
14148
|
+
import { homedir as homedir12 } from "os";
|
|
14149
|
+
import { basename as basename6, join as join23 } from "path";
|
|
14006
14150
|
var SessionWatchers = class {
|
|
14007
14151
|
constructor(deps) {
|
|
14008
14152
|
this.deps = deps;
|
|
@@ -14083,9 +14227,9 @@ var SessionWatchers = class {
|
|
|
14083
14227
|
// was passed to Claude via --session-id so the filename matches from the start.
|
|
14084
14228
|
watchForJsonl(sessionId, projectPath) {
|
|
14085
14229
|
const encoded = projectPath.replace(/[/\\:.]/g, "-");
|
|
14086
|
-
const projectsDir =
|
|
14230
|
+
const projectsDir = join23(homedir12(), ".claude", "projects", encoded);
|
|
14087
14231
|
const expectedFile = `${sessionId}.jsonl`;
|
|
14088
|
-
const filePath =
|
|
14232
|
+
const filePath = join23(projectsDir, expectedFile);
|
|
14089
14233
|
const deadline = Date.now() + 12e4;
|
|
14090
14234
|
let watcher = null;
|
|
14091
14235
|
const cleanup = () => {
|
|
@@ -14107,10 +14251,10 @@ var SessionWatchers = class {
|
|
|
14107
14251
|
if (!resolvedFilePath && existsSync15(projectsDir)) {
|
|
14108
14252
|
try {
|
|
14109
14253
|
const now = Date.now();
|
|
14110
|
-
const match = readdirSync7(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync12(
|
|
14111
|
-
({ f }) => basename6(f, ".jsonl") === sessionId || this.readFirstLineSessionId(
|
|
14254
|
+
const match = readdirSync7(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync12(join23(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).filter(
|
|
14255
|
+
({ f }) => basename6(f, ".jsonl") === sessionId || this.readFirstLineSessionId(join23(projectsDir, f)) === sessionId
|
|
14112
14256
|
).sort((a, b) => b.mtime - a.mtime)[0];
|
|
14113
|
-
if (match) resolvedFilePath =
|
|
14257
|
+
if (match) resolvedFilePath = join23(projectsDir, match.f);
|
|
14114
14258
|
} catch {
|
|
14115
14259
|
}
|
|
14116
14260
|
}
|
|
@@ -14154,7 +14298,7 @@ var SessionWatchers = class {
|
|
|
14154
14298
|
watchForCodexRollout(sessionId, projectPath) {
|
|
14155
14299
|
const deadline = Date.now() + 12e4;
|
|
14156
14300
|
const now = /* @__PURE__ */ new Date();
|
|
14157
|
-
const dateDir =
|
|
14301
|
+
const dateDir = join23(
|
|
14158
14302
|
String(now.getFullYear()),
|
|
14159
14303
|
String(now.getMonth() + 1).padStart(2, "0"),
|
|
14160
14304
|
String(now.getDate()).padStart(2, "0")
|
|
@@ -14195,7 +14339,7 @@ var SessionWatchers = class {
|
|
|
14195
14339
|
this.deps.sessionStore.listManaged().filter((s) => s.id !== sessionId && s.boundConversationId != null).map((s) => s.boundConversationId)
|
|
14196
14340
|
);
|
|
14197
14341
|
for (const root of this.deps.codexRoots) {
|
|
14198
|
-
const sessionsDir =
|
|
14342
|
+
const sessionsDir = join23(root, dateDir);
|
|
14199
14343
|
if (!existsSync15(sessionsDir)) continue;
|
|
14200
14344
|
let candidateFiles;
|
|
14201
14345
|
try {
|
|
@@ -14204,9 +14348,9 @@ var SessionWatchers = class {
|
|
|
14204
14348
|
continue;
|
|
14205
14349
|
}
|
|
14206
14350
|
const nowMs = Date.now();
|
|
14207
|
-
const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync12(
|
|
14351
|
+
const recentCandidates = candidateFiles.map((f) => ({ f, mtime: statSync12(join23(sessionsDir, f)).mtimeMs })).filter(({ mtime }) => nowMs - mtime < 1e4).sort((a, b) => b.mtime - a.mtime);
|
|
14208
14352
|
for (const { f } of recentCandidates) {
|
|
14209
|
-
const candidatePath =
|
|
14353
|
+
const candidatePath = join23(sessionsDir, f);
|
|
14210
14354
|
const match = matchesProjectPath(candidatePath);
|
|
14211
14355
|
if (!match) continue;
|
|
14212
14356
|
if (boundElsewhere.has(match.id)) continue;
|
|
@@ -14610,7 +14754,7 @@ var StreamerServer = class {
|
|
|
14610
14754
|
this.skipStartupWarmup = config.skipStartupWarmup ?? false;
|
|
14611
14755
|
this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
|
|
14612
14756
|
this.scanProfiles = config.scanProfiles;
|
|
14613
|
-
this.codexRoots = config.codexRoots ?? [
|
|
14757
|
+
this.codexRoots = config.codexRoots ?? [join24(homedir13(), ".codex", "sessions")];
|
|
14614
14758
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
14615
14759
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
14616
14760
|
const flagResolution = resolveFeatureFlags({
|
|
@@ -14627,8 +14771,8 @@ var StreamerServer = class {
|
|
|
14627
14771
|
this.claudeFlagsPersistable = config.claudeFlags === void 0;
|
|
14628
14772
|
this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
|
|
14629
14773
|
this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
|
|
14630
|
-
this.cacheDir = config.cacheDir ?? loadCacheDir() ??
|
|
14631
|
-
this.runtimeDbPath = config.runtimeDbPath
|
|
14774
|
+
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join24(homedir13(), ".threadbase", "cache");
|
|
14775
|
+
this.runtimeDbPath = resolveRuntimeDbPath(config.runtimeDbPath);
|
|
14632
14776
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
14633
14777
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
14634
14778
|
this.scannerManager = new ScannerManager({
|
|
@@ -14804,7 +14948,7 @@ var StreamerServer = class {
|
|
|
14804
14948
|
temporalClient,
|
|
14805
14949
|
taskQueue: agentConfig.temporal.taskQueue
|
|
14806
14950
|
});
|
|
14807
|
-
const conversationsBaseDir = agentConfig.conversationsDir ||
|
|
14951
|
+
const conversationsBaseDir = agentConfig.conversationsDir || join24(dirname10(this.cacheDir), "conversations");
|
|
14808
14952
|
conversationWriter = createConversationWriter({
|
|
14809
14953
|
baseDir: conversationsBaseDir
|
|
14810
14954
|
});
|
|
@@ -15277,6 +15421,7 @@ var StreamerServer = class {
|
|
|
15277
15421
|
try {
|
|
15278
15422
|
this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
|
|
15279
15423
|
this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
|
|
15424
|
+
this.devicesRepo = new DevicesRepository(this.runtimeStore.getDatabase());
|
|
15280
15425
|
} catch (err) {
|
|
15281
15426
|
const message = err instanceof Error ? err.message : String(err);
|
|
15282
15427
|
const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
|
|
@@ -15296,7 +15441,7 @@ var StreamerServer = class {
|
|
|
15296
15441
|
}
|
|
15297
15442
|
try {
|
|
15298
15443
|
this.cache = ConversationCache.open(
|
|
15299
|
-
|
|
15444
|
+
join24(this.cacheDir, "cache.db"),
|
|
15300
15445
|
this.tailSize,
|
|
15301
15446
|
void 0,
|
|
15302
15447
|
{
|
|
@@ -15326,18 +15471,39 @@ var StreamerServer = class {
|
|
|
15326
15471
|
if (copied > 0) {
|
|
15327
15472
|
this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
|
|
15328
15473
|
copied,
|
|
15474
|
+
table: "managed_sessions",
|
|
15329
15475
|
event: "runtime.legacy_import"
|
|
15330
15476
|
});
|
|
15331
15477
|
}
|
|
15332
15478
|
} catch (err) {
|
|
15333
15479
|
this.log.warn("[registry] legacy managed_sessions copy failed", {
|
|
15334
15480
|
event: "runtime.legacy_import_failed",
|
|
15481
|
+
table: "managed_sessions",
|
|
15482
|
+
err
|
|
15483
|
+
});
|
|
15484
|
+
}
|
|
15485
|
+
try {
|
|
15486
|
+
const result = this.runtimeStore?.importLegacyDevices(db);
|
|
15487
|
+
if (result && result.copied > 0) {
|
|
15488
|
+
this.log.info(
|
|
15489
|
+
`Moved ${result.copied} device row(s) from cache.db to runtime.db` + (result.purged ? "; removed the cache-side copy" : "; KEPT the cache-side copy (row count did not match after copy)"),
|
|
15490
|
+
{
|
|
15491
|
+
copied: result.copied,
|
|
15492
|
+
purged: result.purged,
|
|
15493
|
+
table: "devices",
|
|
15494
|
+
event: "runtime.legacy_import"
|
|
15495
|
+
}
|
|
15496
|
+
);
|
|
15497
|
+
}
|
|
15498
|
+
} catch (err) {
|
|
15499
|
+
this.log.warn("[registry] legacy devices move failed", {
|
|
15500
|
+
event: "runtime.legacy_import_failed",
|
|
15501
|
+
table: "devices",
|
|
15335
15502
|
err
|
|
15336
15503
|
});
|
|
15337
15504
|
}
|
|
15338
15505
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
15339
15506
|
this.pushRepo = new PushRepository(db);
|
|
15340
|
-
this.devicesRepo = new DevicesRepository(db);
|
|
15341
15507
|
this.initLiveActivityPush(this.pushRepo);
|
|
15342
15508
|
this.initWaitingInputPush(this.pushRepo);
|
|
15343
15509
|
this.cacheMonitor = new CacheIntegrityMonitor(
|