@threadbase-sh/streamer 1.40.0 → 1.41.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/README.md +1 -1
- package/dist/cli.cjs +308 -56
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +306 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +306 -54
- package/dist/index.js.map +1 -1
- package/dist/{migrations/010_create_managed_sessions.sql → runtime-migrations/001_create_managed_sessions.sql} +6 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ Three layers: **core engine** (`src/*.ts`) → **API layer** (`src/api/` + `src/
|
|
|
112
112
|
- `POST /api/sessions/start` / `resume` spawns `claude` in a PTY; output streams to WebSocket clients as `terminal_output`, with a `terminal_replay` snapshot on subscribe.
|
|
113
113
|
- `SessionStore` tracks both PTY-managed sessions and externally-running `claude` processes discovered on disk.
|
|
114
114
|
- A chokidar-backed watcher tails conversation JSONL files into the SQLite cache, so list/search endpoints don't scan the filesystem.
|
|
115
|
-
-
|
|
115
|
+
- A WebSocket subscriber disconnecting never stops the agent — sessions outlive a sleeping phone or a dropped connection. A PTY is put on hold (history intact, resumable anytime) only on an explicit `hold_session` message, or by the idle reaper after 6 h of agent silence.
|
|
116
116
|
|
|
117
117
|
More detail: [docs/how-it-works.md](docs/how-it-works.md) and [docs/architecture/](docs/architecture/README.md).
|
|
118
118
|
|
package/dist/cli.cjs
CHANGED
|
@@ -6213,6 +6213,12 @@ var init_feature_flags = __esm({
|
|
|
6213
6213
|
description: "Send the built system prompt to fresh Codex sessions. Off by default: Codex has no --system-prompt flag, so the prompt goes in the positional [PROMPT] argument, which Codex treats as the user's opening turn rather than a system-level instruction.",
|
|
6214
6214
|
default: false,
|
|
6215
6215
|
env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
|
|
6216
|
+
},
|
|
6217
|
+
{
|
|
6218
|
+
id: "sessionRehydration",
|
|
6219
|
+
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.",
|
|
6220
|
+
default: true,
|
|
6221
|
+
env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
|
|
6216
6222
|
}
|
|
6217
6223
|
];
|
|
6218
6224
|
}
|
|
@@ -124077,8 +124083,8 @@ function isAbiMismatch(message) {
|
|
|
124077
124083
|
}
|
|
124078
124084
|
function checkSqliteAbi() {
|
|
124079
124085
|
try {
|
|
124080
|
-
const
|
|
124081
|
-
const db = new
|
|
124086
|
+
const Database4 = require("better-sqlite3");
|
|
124087
|
+
const db = new Database4(":memory:");
|
|
124082
124088
|
db.close();
|
|
124083
124089
|
} catch (err) {
|
|
124084
124090
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -139971,6 +139977,9 @@ function getMigrationsDir() {
|
|
|
139971
139977
|
}
|
|
139972
139978
|
return __dirname;
|
|
139973
139979
|
}
|
|
139980
|
+
function resolveMigrationsDir(name = "migrations") {
|
|
139981
|
+
return (0, import_path15.join)(getMigrationsDir(), name);
|
|
139982
|
+
}
|
|
139974
139983
|
var SCHEMA_MIGRATIONS_SQL = `
|
|
139975
139984
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
139976
139985
|
id TEXT PRIMARY KEY,
|
|
@@ -139979,7 +139988,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
|
139979
139988
|
`;
|
|
139980
139989
|
function runSqliteMigrations(db, migrationsDir) {
|
|
139981
139990
|
db.exec(SCHEMA_MIGRATIONS_SQL);
|
|
139982
|
-
const dir = migrationsDir ?? (
|
|
139991
|
+
const dir = migrationsDir ?? resolveMigrationsDir();
|
|
139983
139992
|
const files = (0, import_fs15.readdirSync)(dir).filter((f2) => f2.endsWith(".sql")).sort();
|
|
139984
139993
|
const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
|
|
139985
139994
|
const appliedSet = new Set(appliedRows.map((r) => r.id));
|
|
@@ -141480,6 +141489,7 @@ var ManagedSessionsRepository = class {
|
|
|
141480
141489
|
updateStatusStmt;
|
|
141481
141490
|
getStmt;
|
|
141482
141491
|
listNonTerminalStmt;
|
|
141492
|
+
listRecoverableStmt;
|
|
141483
141493
|
deleteStmt;
|
|
141484
141494
|
constructor(db) {
|
|
141485
141495
|
this.upsertStmt = db.prepare(`
|
|
@@ -141532,6 +141542,13 @@ var ManagedSessionsRepository = class {
|
|
|
141532
141542
|
WHERE completed_at IS NULL
|
|
141533
141543
|
ORDER BY started_at ASC
|
|
141534
141544
|
`);
|
|
141545
|
+
this.listRecoverableStmt = db.prepare(`
|
|
141546
|
+
SELECT * FROM managed_sessions
|
|
141547
|
+
WHERE (completed_at IS NULL OR status_source = 'shutdown')
|
|
141548
|
+
AND status_updated_at >= @since
|
|
141549
|
+
ORDER BY status_updated_at DESC
|
|
141550
|
+
LIMIT @limit
|
|
141551
|
+
`);
|
|
141535
141552
|
this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
|
|
141536
141553
|
}
|
|
141537
141554
|
/** Record a session at spawn, or refresh every field of an existing row. */
|
|
@@ -141583,6 +141600,14 @@ var ManagedSessionsRepository = class {
|
|
|
141583
141600
|
listNonTerminal() {
|
|
141584
141601
|
return this.listNonTerminalStmt.all();
|
|
141585
141602
|
}
|
|
141603
|
+
/**
|
|
141604
|
+
* Rows a restart could bring back: still open, or closed by our own shutdown,
|
|
141605
|
+
* and touched no longer ago than `sinceMs`. Newest first, capped — the caller
|
|
141606
|
+
* decides which of these actually deserve rehydrating (`shouldRehydrate`).
|
|
141607
|
+
*/
|
|
141608
|
+
listRecoverable({ sinceMs, limit }) {
|
|
141609
|
+
return this.listRecoverableStmt.all({ since: sinceMs, limit });
|
|
141610
|
+
}
|
|
141586
141611
|
delete(sessionId) {
|
|
141587
141612
|
this.deleteStmt.run(sessionId);
|
|
141588
141613
|
}
|
|
@@ -141718,6 +141743,54 @@ var SessionsRepository = class {
|
|
|
141718
141743
|
}
|
|
141719
141744
|
};
|
|
141720
141745
|
|
|
141746
|
+
// src/db/runtime-store.ts
|
|
141747
|
+
var import_better_sqlite33 = __toESM(require("better-sqlite3"), 1);
|
|
141748
|
+
var RuntimeStore = class _RuntimeStore {
|
|
141749
|
+
constructor(db) {
|
|
141750
|
+
this.db = db;
|
|
141751
|
+
}
|
|
141752
|
+
db;
|
|
141753
|
+
static open(dbPath, migrationsDir) {
|
|
141754
|
+
const db = new import_better_sqlite33.default(dbPath);
|
|
141755
|
+
db.pragma("journal_mode = WAL");
|
|
141756
|
+
runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
|
|
141757
|
+
return new _RuntimeStore(db);
|
|
141758
|
+
}
|
|
141759
|
+
getDatabase() {
|
|
141760
|
+
return this.db;
|
|
141761
|
+
}
|
|
141762
|
+
/**
|
|
141763
|
+
* One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
|
|
141764
|
+
*
|
|
141765
|
+
* Non-destructive by design: the source table is left in place so an older
|
|
141766
|
+
* streamer rolled back onto the same machine still finds its registry. Runs
|
|
141767
|
+
* only when this file's table is empty, so a second boot is a no-op rather
|
|
141768
|
+
* than a re-copy that would resurrect rows deleted since.
|
|
141769
|
+
*
|
|
141770
|
+
* Returns the number of rows copied.
|
|
141771
|
+
*/
|
|
141772
|
+
importLegacyManagedSessions(source) {
|
|
141773
|
+
const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
|
|
141774
|
+
if (existing.n > 0) return 0;
|
|
141775
|
+
const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
|
|
141776
|
+
if (!hasTable) return 0;
|
|
141777
|
+
const rows = source.prepare("SELECT * FROM managed_sessions").all();
|
|
141778
|
+
if (rows.length === 0) return 0;
|
|
141779
|
+
const columns = Object.keys(rows[0]);
|
|
141780
|
+
const insert = this.db.prepare(
|
|
141781
|
+
`INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
|
|
141782
|
+
VALUES (${columns.map((c) => `@${c}`).join(", ")})`
|
|
141783
|
+
);
|
|
141784
|
+
this.db.transaction((batch) => {
|
|
141785
|
+
for (const row of batch) insert.run(row);
|
|
141786
|
+
})(rows);
|
|
141787
|
+
return rows.length;
|
|
141788
|
+
}
|
|
141789
|
+
close() {
|
|
141790
|
+
this.db.close();
|
|
141791
|
+
}
|
|
141792
|
+
};
|
|
141793
|
+
|
|
141721
141794
|
// src/db/upload-records.ts
|
|
141722
141795
|
async function recordUpload(pool2, instanceId, row) {
|
|
141723
141796
|
if (!pool2) return;
|
|
@@ -142739,6 +142812,12 @@ function detectShellPrompt(lines) {
|
|
|
142739
142812
|
return null;
|
|
142740
142813
|
}
|
|
142741
142814
|
|
|
142815
|
+
// src/utils/deriveSessionName.ts
|
|
142816
|
+
function deriveSessionName(firstMessageText) {
|
|
142817
|
+
const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
|
|
142818
|
+
return firstLine.slice(0, 80);
|
|
142819
|
+
}
|
|
142820
|
+
|
|
142742
142821
|
// src/pty-manager.ts
|
|
142743
142822
|
var OUTPUT_BUFFER_MAX2 = 65536;
|
|
142744
142823
|
var INPUT_HISTORY_MAX2 = 50;
|
|
@@ -143212,6 +143291,10 @@ var PTYManager = class {
|
|
|
143212
143291
|
if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
|
|
143213
143292
|
session.inputHistory.shift();
|
|
143214
143293
|
}
|
|
143294
|
+
if (session.firstMessageText === void 0) {
|
|
143295
|
+
session.firstMessageText = text;
|
|
143296
|
+
session.sessionName = deriveSessionName(text);
|
|
143297
|
+
}
|
|
143215
143298
|
this.onUserMessage?.(session.id, text, ts2);
|
|
143216
143299
|
}
|
|
143217
143300
|
getSession(sessionId) {
|
|
@@ -143477,7 +143560,9 @@ function toPublicSession2(s3) {
|
|
|
143477
143560
|
...s3.lastActivityAt != null && { lastActivityAt: s3.lastActivityAt },
|
|
143478
143561
|
...s3.statusSource != null && { statusSource: s3.statusSource },
|
|
143479
143562
|
...s3.statusUpdatedAt != null && { statusUpdatedAt: s3.statusUpdatedAt },
|
|
143480
|
-
...s3.filePath != null && { filePath: s3.filePath }
|
|
143563
|
+
...s3.filePath != null && { filePath: s3.filePath },
|
|
143564
|
+
...s3.sessionName != null && { sessionName: s3.sessionName },
|
|
143565
|
+
...s3.firstMessageText != null && { firstMessageText: s3.firstMessageText }
|
|
143481
143566
|
};
|
|
143482
143567
|
}
|
|
143483
143568
|
function stripAnsi2(str) {
|
|
@@ -145035,7 +145120,8 @@ function contentStateForSession(args) {
|
|
|
145035
145120
|
status,
|
|
145036
145121
|
startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
|
|
145037
145122
|
lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
|
|
145038
|
-
...args.serverLabel != null && { serverLabel: args.serverLabel }
|
|
145123
|
+
...args.serverLabel != null && { serverLabel: args.serverLabel },
|
|
145124
|
+
...args.session.sessionName != null && { sessionName: args.session.sessionName }
|
|
145039
145125
|
};
|
|
145040
145126
|
}
|
|
145041
145127
|
var LiveActivityNotifier = class {
|
|
@@ -145048,14 +145134,17 @@ var LiveActivityNotifier = class {
|
|
|
145048
145134
|
serverId;
|
|
145049
145135
|
serverLabel;
|
|
145050
145136
|
/**
|
|
145051
|
-
*
|
|
145137
|
+
* Sessions with a currently open (pushed) activity.
|
|
145052
145138
|
*
|
|
145053
|
-
*
|
|
145054
|
-
*
|
|
145055
|
-
*
|
|
145056
|
-
*
|
|
145139
|
+
* An activity opens on a `waiting_input → running` edge (the user sent a
|
|
145140
|
+
* prompt) and closes on the matching `running → waiting_input` edge (the
|
|
145141
|
+
* response, including any sub-agents, finished) — so this set is what makes
|
|
145142
|
+
* the notifier per-turn rather than per-session. A session's very first
|
|
145143
|
+
* `running` (right after spawn, before any user prompt) has no prior
|
|
145144
|
+
* `waiting_input` and therefore no edge, so it never opens an activity —
|
|
145145
|
+
* this is what keeps a fresh/idle session from pushing anything.
|
|
145057
145146
|
*/
|
|
145058
|
-
|
|
145147
|
+
openActivity = /* @__PURE__ */ new Map();
|
|
145059
145148
|
/**
|
|
145060
145149
|
* React to a session status change.
|
|
145061
145150
|
*
|
|
@@ -145063,34 +145152,22 @@ var LiveActivityNotifier = class {
|
|
|
145063
145152
|
* transition, so this returns a promise the caller may ignore and every error
|
|
145064
145153
|
* is logged rather than propagated.
|
|
145065
145154
|
*/
|
|
145066
|
-
async onStatusChange(session) {
|
|
145155
|
+
async onStatusChange(session, previousStatus) {
|
|
145067
145156
|
const status = toLiveActivityStatus(session.status);
|
|
145068
145157
|
try {
|
|
145069
145158
|
if (!status) {
|
|
145070
|
-
await this.endFor(session);
|
|
145159
|
+
if (this.openActivity.has(session.id)) await this.endFor(session);
|
|
145071
145160
|
return;
|
|
145072
145161
|
}
|
|
145073
|
-
if (
|
|
145074
|
-
|
|
145075
|
-
|
|
145076
|
-
|
|
145077
|
-
|
|
145078
|
-
|
|
145079
|
-
|
|
145080
|
-
const outcome = await this.sender.send({
|
|
145081
|
-
sessionId: session.id,
|
|
145082
|
-
event: "update",
|
|
145083
|
-
contentState
|
|
145084
|
-
});
|
|
145085
|
-
this.lastPushed.set(session.id, status);
|
|
145086
|
-
if (outcome.attempted > 0) {
|
|
145087
|
-
log4.info("live_activity.updated", {
|
|
145088
|
-
event: "live_activity.updated",
|
|
145089
|
-
sessionId: session.id,
|
|
145090
|
-
status,
|
|
145091
|
-
...outcome
|
|
145092
|
-
});
|
|
145162
|
+
if (status === "running" && previousStatus === "waiting_input") {
|
|
145163
|
+
await this.startTurn(session);
|
|
145164
|
+
return;
|
|
145165
|
+
}
|
|
145166
|
+
if (status === "waiting_input" && previousStatus === "running") {
|
|
145167
|
+
if (this.openActivity.has(session.id)) await this.endFor(session);
|
|
145168
|
+
return;
|
|
145093
145169
|
}
|
|
145170
|
+
await this.maybeSendName(session);
|
|
145094
145171
|
} catch (err) {
|
|
145095
145172
|
log4.error("live_activity.notify_failed", {
|
|
145096
145173
|
event: "live_activity.notify_failed",
|
|
@@ -145100,14 +145177,57 @@ var LiveActivityNotifier = class {
|
|
|
145100
145177
|
});
|
|
145101
145178
|
}
|
|
145102
145179
|
}
|
|
145180
|
+
async startTurn(session) {
|
|
145181
|
+
const contentState = contentStateForSession({
|
|
145182
|
+
session,
|
|
145183
|
+
serverId: this.serverId,
|
|
145184
|
+
serverLabel: this.serverLabel
|
|
145185
|
+
});
|
|
145186
|
+
if (!contentState) return;
|
|
145187
|
+
const outcome = await this.sender.send({
|
|
145188
|
+
sessionId: session.id,
|
|
145189
|
+
event: "update",
|
|
145190
|
+
contentState
|
|
145191
|
+
});
|
|
145192
|
+
this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
|
|
145193
|
+
if (outcome.attempted > 0) {
|
|
145194
|
+
log4.info("live_activity.updated", {
|
|
145195
|
+
event: "live_activity.updated",
|
|
145196
|
+
sessionId: session.id,
|
|
145197
|
+
status: contentState.status,
|
|
145198
|
+
...outcome
|
|
145199
|
+
});
|
|
145200
|
+
}
|
|
145201
|
+
}
|
|
145202
|
+
async maybeSendName(session) {
|
|
145203
|
+
const open3 = this.openActivity.get(session.id);
|
|
145204
|
+
if (!open3 || open3.sessionNameSent || session.sessionName == null) return;
|
|
145205
|
+
const contentState = contentStateForSession({
|
|
145206
|
+
session,
|
|
145207
|
+
serverId: this.serverId,
|
|
145208
|
+
serverLabel: this.serverLabel
|
|
145209
|
+
});
|
|
145210
|
+
if (!contentState) return;
|
|
145211
|
+
const outcome = await this.sender.send({
|
|
145212
|
+
sessionId: session.id,
|
|
145213
|
+
event: "update",
|
|
145214
|
+
contentState
|
|
145215
|
+
});
|
|
145216
|
+
open3.sessionNameSent = true;
|
|
145217
|
+
if (outcome.attempted > 0) {
|
|
145218
|
+
log4.info("live_activity.updated", {
|
|
145219
|
+
event: "live_activity.updated",
|
|
145220
|
+
sessionId: session.id,
|
|
145221
|
+
status: contentState.status,
|
|
145222
|
+
...outcome
|
|
145223
|
+
});
|
|
145224
|
+
}
|
|
145225
|
+
}
|
|
145103
145226
|
async endFor(session) {
|
|
145104
|
-
|
|
145105
|
-
|
|
145227
|
+
this.openActivity.delete(session.id);
|
|
145228
|
+
const status = toLiveActivityStatus(session.status);
|
|
145106
145229
|
const contentState = contentStateForSession({
|
|
145107
|
-
session: {
|
|
145108
|
-
...session,
|
|
145109
|
-
status: lastStatus === "waiting_input" ? "waiting_input" : "running"
|
|
145110
|
-
},
|
|
145230
|
+
session: { ...session, status: status ?? "waiting_input" },
|
|
145111
145231
|
serverId: this.serverId,
|
|
145112
145232
|
serverLabel: this.serverLabel
|
|
145113
145233
|
});
|
|
@@ -145121,9 +145241,9 @@ var LiveActivityNotifier = class {
|
|
|
145121
145241
|
});
|
|
145122
145242
|
}
|
|
145123
145243
|
}
|
|
145124
|
-
/** Drop cached state for a session, so a resume re-
|
|
145244
|
+
/** Drop cached state for a session, so a resume re-opens on its next turn. */
|
|
145125
145245
|
forget(sessionId) {
|
|
145126
|
-
this.
|
|
145246
|
+
this.openActivity.delete(sessionId);
|
|
145127
145247
|
}
|
|
145128
145248
|
};
|
|
145129
145249
|
|
|
@@ -145358,7 +145478,8 @@ var LiveActivityRenewalScheduler = class {
|
|
|
145358
145478
|
status,
|
|
145359
145479
|
startedAt,
|
|
145360
145480
|
lastOutput: session.lastOutput ?? "",
|
|
145361
|
-
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
145481
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
|
|
145482
|
+
...session.sessionName != null && { sessionName: session.sessionName }
|
|
145362
145483
|
};
|
|
145363
145484
|
try {
|
|
145364
145485
|
await this.deps.sender.send({
|
|
@@ -145418,7 +145539,8 @@ var LiveActivityRenewalScheduler = class {
|
|
|
145418
145539
|
// Carried through unchanged — the whole point of the renewal.
|
|
145419
145540
|
startedAt: args.startedAt,
|
|
145420
145541
|
lastOutput: truncateLastOutput(session.lastOutput ?? ""),
|
|
145421
|
-
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
145542
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
|
|
145543
|
+
...session.sessionName != null && { sessionName: session.sessionName }
|
|
145422
145544
|
},
|
|
145423
145545
|
now: args.now,
|
|
145424
145546
|
staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
|
|
@@ -145805,6 +145927,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
|
|
|
145805
145927
|
return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
|
|
145806
145928
|
}
|
|
145807
145929
|
|
|
145930
|
+
// src/services/sessions/rehydrateSessions.ts
|
|
145931
|
+
var REHYDRATE_MAX = 25;
|
|
145932
|
+
var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
145933
|
+
var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
|
|
145934
|
+
function shouldRehydrate(row, opts) {
|
|
145935
|
+
if (!opts.projectExists(row.project_path)) return false;
|
|
145936
|
+
if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
|
|
145937
|
+
if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
|
|
145938
|
+
return true;
|
|
145939
|
+
}
|
|
145940
|
+
function rowToStubSession(row) {
|
|
145941
|
+
return {
|
|
145942
|
+
id: row.session_id,
|
|
145943
|
+
provider: row.provider,
|
|
145944
|
+
projectPath: row.project_path,
|
|
145945
|
+
projectName: row.project_name,
|
|
145946
|
+
branch: row.branch,
|
|
145947
|
+
// No PTY exists for a stub, so this is the only truthful status.
|
|
145948
|
+
status: "idle",
|
|
145949
|
+
startedAt: new Date(row.started_at),
|
|
145950
|
+
completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
|
|
145951
|
+
promptCount: row.prompt_count,
|
|
145952
|
+
lastOutput: "",
|
|
145953
|
+
rehydrated: true,
|
|
145954
|
+
...row.session_name != null && { sessionName: row.session_name },
|
|
145955
|
+
...row.project_id != null && { projectId: row.project_id },
|
|
145956
|
+
...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
|
|
145957
|
+
...row.resumed_from_conversation_id != null && {
|
|
145958
|
+
resumedFromConversationId: row.resumed_from_conversation_id
|
|
145959
|
+
},
|
|
145960
|
+
...row.failure_reason != null && { failureReason: row.failure_reason },
|
|
145961
|
+
...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
|
|
145962
|
+
// Only `shutdown` crosses over. It is the one registry source that is also a
|
|
145963
|
+
// wire StatusSource *and* that genuinely describes the `idle` above — the
|
|
145964
|
+
// streamer stopped this session. A crashed row still says `transition` over
|
|
145965
|
+
// a `running` status, and copying that here would attach observed-confidence
|
|
145966
|
+
// provenance to a status we derived at boot, so leave it unset instead.
|
|
145967
|
+
...row.status_source === "shutdown" && {
|
|
145968
|
+
statusSource: "shutdown",
|
|
145969
|
+
statusUpdatedAt: new Date(row.status_updated_at)
|
|
145970
|
+
}
|
|
145971
|
+
};
|
|
145972
|
+
}
|
|
145973
|
+
|
|
145808
145974
|
// src/agent/dedupe.ts
|
|
145809
145975
|
function createProgressDedupeLRU(capacity) {
|
|
145810
145976
|
if (!Number.isFinite(capacity) || capacity < 1) {
|
|
@@ -145978,14 +146144,15 @@ function managedToResponse(s3, ptyAttached) {
|
|
|
145978
146144
|
// Lifecycle for a session this run knows about. `attached` while we hold
|
|
145979
146145
|
// its PTY; once the PTY is gone the session is terminal from this run's
|
|
145980
146146
|
// perspective — `failed` when it recorded a reason, else `completed`.
|
|
145981
|
-
//
|
|
145982
|
-
//
|
|
145983
|
-
//
|
|
145984
|
-
|
|
145985
|
-
|
|
146147
|
+
// A `rehydrated` stub is the exception: the boot rehydrator seeded it from
|
|
146148
|
+
// the durable registry, so it is a previous run's session with no process
|
|
146149
|
+
// behind it — `resumable`, and `historical` rather than `managed`
|
|
146150
|
+
// (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
|
|
146151
|
+
lifecycle: ptyAttached ? "attached" : s3.rehydrated ? "resumable" : s3.failureReason != null ? "failed" : "completed",
|
|
146152
|
+
lifecycleSource: ptyAttached ? "spawn" : s3.rehydrated ? "reconcile" : "exit",
|
|
145986
146153
|
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
145987
146154
|
// `activity` is attached for managed sessions.
|
|
145988
|
-
ownership: "managed",
|
|
146155
|
+
ownership: s3.rehydrated ? "historical" : "managed",
|
|
145989
146156
|
projectPath: s3.projectPath,
|
|
145990
146157
|
projectName: s3.projectName,
|
|
145991
146158
|
branch: s3.branch,
|
|
@@ -146575,10 +146742,13 @@ var StreamerServer = class {
|
|
|
146575
146742
|
projectsRepo = null;
|
|
146576
146743
|
conversationsRepo = null;
|
|
146577
146744
|
sessionsRepo = null;
|
|
146578
|
-
// Durable session registry (C1 Phase 2). Null when
|
|
146579
|
-
//
|
|
146580
|
-
// taking the server down with it, so every write goes through `?.`.
|
|
146745
|
+
// Durable session registry (C1 Phase 2). Null when runtime.db failed to open
|
|
146746
|
+
// — persistence degrades to today's in-memory-only behaviour rather than
|
|
146747
|
+
// taking the server down with it, so every write goes through `?.`. Note the
|
|
146748
|
+
// handle is runtime.db, NOT the conversation cache: a cache failure used to
|
|
146749
|
+
// null this repo and silently disable all session persistence.
|
|
146581
146750
|
managedSessionsRepo = null;
|
|
146751
|
+
runtimeStore = null;
|
|
146582
146752
|
// Identifies this streamer run. A registry row carrying a different id is a
|
|
146583
146753
|
// session that outlived the process that started it.
|
|
146584
146754
|
streamerInstanceId = (0, import_crypto13.randomUUID)();
|
|
@@ -146597,6 +146767,7 @@ var StreamerServer = class {
|
|
|
146597
146767
|
liveActivityRenewal = null;
|
|
146598
146768
|
discoveryCache = null;
|
|
146599
146769
|
cacheDir;
|
|
146770
|
+
runtimeDbPath;
|
|
146600
146771
|
tailSize;
|
|
146601
146772
|
directoryDebounceMs;
|
|
146602
146773
|
// Trailing-debounced trigger that flags the scanner stale after a quiet
|
|
@@ -146643,6 +146814,7 @@ var StreamerServer = class {
|
|
|
146643
146814
|
this.claudeFlags = config2.claudeFlags ?? loadClaudeFlags();
|
|
146644
146815
|
this.claudeExtraArgs = config2.claudeExtraArgs ?? loadClaudeExtraArgs();
|
|
146645
146816
|
this.cacheDir = config2.cacheDir ?? loadCacheDir() ?? (0, import_path29.join)((0, import_os13.homedir)(), ".threadbase", "cache");
|
|
146817
|
+
this.runtimeDbPath = config2.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? (0, import_path29.join)(process.env.THREADBASE_CONFIG_DIR ?? (0, import_path29.join)((0, import_os13.homedir)(), ".threadbase"), "runtime.db");
|
|
146646
146818
|
this.tailSize = config2.tailSize ?? loadTailSize() ?? 10;
|
|
146647
146819
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config2.directoryScanDebounceMs ?? 1e3;
|
|
146648
146820
|
this.markScannerStaleDebounced = debounce(() => {
|
|
@@ -146813,6 +146985,7 @@ var StreamerServer = class {
|
|
|
146813
146985
|
if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
|
|
146814
146986
|
},
|
|
146815
146987
|
onStatusChange: (session) => {
|
|
146988
|
+
const previousStatus = this.sessionStore.getManaged(session.id)?.status;
|
|
146816
146989
|
this.sessionStore.updateManaged(session.id, {
|
|
146817
146990
|
status: session.status,
|
|
146818
146991
|
completedAt: session.completedAt,
|
|
@@ -146867,7 +147040,7 @@ var StreamerServer = class {
|
|
|
146867
147040
|
if (resp) {
|
|
146868
147041
|
this.wsHub.broadcast({ type: "session_update", session: resp });
|
|
146869
147042
|
}
|
|
146870
|
-
void this.liveActivityNotifier?.onStatusChange(session);
|
|
147043
|
+
void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
|
|
146871
147044
|
this.sessionStatusBus.emit(`status:${session.id}`, session.status);
|
|
146872
147045
|
}
|
|
146873
147046
|
});
|
|
@@ -146918,6 +147091,7 @@ var StreamerServer = class {
|
|
|
146918
147091
|
conversationsRepo: () => this.conversationsRepo,
|
|
146919
147092
|
sessionsRepo: () => this.sessionsRepo,
|
|
146920
147093
|
cacheMetadataRepo: () => this.cacheMetadataRepo,
|
|
147094
|
+
runtimeStore: () => this.runtimeStore,
|
|
146921
147095
|
ptyAttachedIds: () => this.ptyAttachedIds(),
|
|
146922
147096
|
handleListSessions: (url2, res) => this.handleListSessions(url2, res),
|
|
146923
147097
|
handleSessionsCount: (res) => this.handleSessionsCount(res),
|
|
@@ -147217,6 +147391,58 @@ var StreamerServer = class {
|
|
|
147217
147391
|
}
|
|
147218
147392
|
return verdicts;
|
|
147219
147393
|
}
|
|
147394
|
+
/**
|
|
147395
|
+
* Seed the session list with what previous runs left behind (persistence plan
|
|
147396
|
+
* Phase 1, gaps G1/G2/G8).
|
|
147397
|
+
*
|
|
147398
|
+
* Reconciliation classifies rows and stops there; a verdict is overlaid onto a
|
|
147399
|
+
* SessionResponse that already exists, and after a clean restart none does —
|
|
147400
|
+
* `SessionStore` starts empty. So the user's session did not become
|
|
147401
|
+
* `resumable`, it became *absent*. This is the half that puts it back.
|
|
147402
|
+
*
|
|
147403
|
+
* The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
|
|
147404
|
+
* so `reapIdleSessions` and `startGraceTimer` — both of which iterate
|
|
147405
|
+
* `ptyManager.listSessions()` — cannot observe them. A later resume calls
|
|
147406
|
+
* `sessionStore.addManaged` with the real session, which overwrites the stub
|
|
147407
|
+
* by id rather than duplicating it.
|
|
147408
|
+
*/
|
|
147409
|
+
rehydratePreviousSessions(verdicts) {
|
|
147410
|
+
if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
|
|
147411
|
+
try {
|
|
147412
|
+
const now = Date.now();
|
|
147413
|
+
const rows = this.managedSessionsRepo.listRecoverable({
|
|
147414
|
+
sinceMs: now - REHYDRATE_WINDOW_MS,
|
|
147415
|
+
limit: REHYDRATE_MAX + 1
|
|
147416
|
+
});
|
|
147417
|
+
const truncated = rows.length > REHYDRATE_MAX;
|
|
147418
|
+
const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
|
|
147419
|
+
if (candidates.length === 0) return;
|
|
147420
|
+
const lifecycleByVerdict = new Map(verdicts.map((v2) => [v2.sessionId, v2.lifecycle]));
|
|
147421
|
+
let rehydrated = 0;
|
|
147422
|
+
for (const row of candidates) {
|
|
147423
|
+
if (this.sessionStore.getManaged(row.session_id)) continue;
|
|
147424
|
+
if (!shouldRehydrate(row, { now, projectExists: import_fs30.existsSync })) continue;
|
|
147425
|
+
this.sessionStore.addManaged(rowToStubSession(row));
|
|
147426
|
+
this.sessionLifecycles.set(
|
|
147427
|
+
row.session_id,
|
|
147428
|
+
lifecycleByVerdict.get(row.session_id) ?? "resumable"
|
|
147429
|
+
);
|
|
147430
|
+
if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
|
|
147431
|
+
rehydrated++;
|
|
147432
|
+
}
|
|
147433
|
+
this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
|
|
147434
|
+
event: "sessions.rehydrated",
|
|
147435
|
+
rehydrated,
|
|
147436
|
+
skipped: candidates.length - rehydrated,
|
|
147437
|
+
truncated
|
|
147438
|
+
});
|
|
147439
|
+
} catch (err) {
|
|
147440
|
+
this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
|
|
147441
|
+
event: "sessions.rehydrate_failed",
|
|
147442
|
+
err
|
|
147443
|
+
});
|
|
147444
|
+
}
|
|
147445
|
+
}
|
|
147220
147446
|
/**
|
|
147221
147447
|
* Pick a token guaranteed to appear in the spawned process's argv, for the
|
|
147222
147448
|
* reconciler's pid-reuse guard.
|
|
@@ -147450,6 +147676,17 @@ var StreamerServer = class {
|
|
|
147450
147676
|
port,
|
|
147451
147677
|
event: "server.listening"
|
|
147452
147678
|
});
|
|
147679
|
+
try {
|
|
147680
|
+
this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
|
|
147681
|
+
this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
|
|
147682
|
+
} catch (err) {
|
|
147683
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
147684
|
+
const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
|
|
147685
|
+
this.log.error(
|
|
147686
|
+
`Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
|
|
147687
|
+
{ error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
|
|
147688
|
+
);
|
|
147689
|
+
}
|
|
147453
147690
|
try {
|
|
147454
147691
|
this.cache = ConversationCache.open(
|
|
147455
147692
|
(0, import_path29.join)(this.cacheDir, "cache.db"),
|
|
@@ -147477,8 +147714,20 @@ var StreamerServer = class {
|
|
|
147477
147714
|
this.projectsRepo = new ProjectsRepository(db);
|
|
147478
147715
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
147479
147716
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
147480
|
-
|
|
147481
|
-
|
|
147717
|
+
try {
|
|
147718
|
+
const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
|
|
147719
|
+
if (copied > 0) {
|
|
147720
|
+
this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
|
|
147721
|
+
copied,
|
|
147722
|
+
event: "runtime.legacy_import"
|
|
147723
|
+
});
|
|
147724
|
+
}
|
|
147725
|
+
} catch (err) {
|
|
147726
|
+
this.log.warn("[registry] legacy managed_sessions copy failed", {
|
|
147727
|
+
event: "runtime.legacy_import_failed",
|
|
147728
|
+
err
|
|
147729
|
+
});
|
|
147730
|
+
}
|
|
147482
147731
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
147483
147732
|
this.pushRepo = new PushRepository(db);
|
|
147484
147733
|
this.devicesRepo = new DevicesRepository(db);
|
|
@@ -147514,6 +147763,7 @@ var StreamerServer = class {
|
|
|
147514
147763
|
);
|
|
147515
147764
|
this.scannerPersistenceDisabled = true;
|
|
147516
147765
|
}
|
|
147766
|
+
void this.reconcilePreviousSessions().then((v2) => this.rehydratePreviousSessions(v2));
|
|
147517
147767
|
if (this.skipStartupWarmup) {
|
|
147518
147768
|
this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
|
|
147519
147769
|
event: "cache.warmup_skipped"
|
|
@@ -147721,6 +147971,7 @@ var StreamerServer = class {
|
|
|
147721
147971
|
this.allScanners.clear();
|
|
147722
147972
|
this.scanner = null;
|
|
147723
147973
|
this.cache?.close();
|
|
147974
|
+
this.runtimeStore?.close();
|
|
147724
147975
|
this.ptyManager.dispose();
|
|
147725
147976
|
this.fileWatcher.dispose();
|
|
147726
147977
|
this.externalTails.clear();
|
|
@@ -148168,7 +148419,8 @@ var StreamerServer = class {
|
|
|
148168
148419
|
}
|
|
148169
148420
|
handleSessionsCount(res) {
|
|
148170
148421
|
if (this.rejectIfWarmingUp(res)) return;
|
|
148171
|
-
|
|
148422
|
+
const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s3) => s3.ownership !== "historical").length;
|
|
148423
|
+
json2(res, 200, { total });
|
|
148172
148424
|
}
|
|
148173
148425
|
handleGetRecentSessions(url2, res) {
|
|
148174
148426
|
if (this.rejectIfWarmingUp(res)) return;
|