@threadbase-sh/streamer 1.41.1 → 1.42.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/index.js CHANGED
@@ -1148,7 +1148,10 @@ var CodexPtyRunner = class {
1148
1148
  const projectName = options.projectName ?? basename(options.projectPath);
1149
1149
  const proc = nodePty.spawn(
1150
1150
  resolveCodexExe(),
1151
- ["resume", sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1151
+ // `sessionId` stays the runner's map key — only argv carries the
1152
+ // provider-side id, so a resumed Codex session keeps the placeholder id
1153
+ // its client already navigated to.
1154
+ ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1152
1155
  {
1153
1156
  name: "xterm-256color",
1154
1157
  cols: PTY_COLS,
@@ -3073,7 +3076,7 @@ import {
3073
3076
  existsSync as existsSync11,
3074
3077
  watch as fsWatch,
3075
3078
  readdirSync as readdirSync6,
3076
- readFileSync as readFileSync8,
3079
+ readFileSync as readFileSync9,
3077
3080
  statSync as statSync9
3078
3081
  } from "fs";
3079
3082
  import { realpath as realpath2 } from "fs/promises";
@@ -4101,6 +4104,86 @@ function redactValue(value) {
4101
4104
  return value;
4102
4105
  }
4103
4106
 
4107
+ // src/services/sessions/resumeIdentity.ts
4108
+ function resumeIdForRow(row) {
4109
+ if (row.provider !== CODEX_CLI_PROVIDER) return row.session_id;
4110
+ return row.bound_conversation_id;
4111
+ }
4112
+
4113
+ // src/services/sessions/rehydrateSessions.ts
4114
+ var REHYDRATE_MAX = 25;
4115
+ var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
4116
+ var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
4117
+ function rehydrateSkipReason(row, opts) {
4118
+ if (resumeIdForRow(row) == null) return "codex_unbound";
4119
+ if (!opts.projectExists(row.project_path)) return "project_missing";
4120
+ if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return "too_old";
4121
+ if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) {
4122
+ return "agent_exited";
4123
+ }
4124
+ return null;
4125
+ }
4126
+ function rowToStubSession(row) {
4127
+ return {
4128
+ id: row.session_id,
4129
+ provider: row.provider,
4130
+ projectPath: row.project_path,
4131
+ projectName: row.project_name,
4132
+ branch: row.branch,
4133
+ // No PTY exists for a stub, so this is the only truthful status.
4134
+ status: "idle",
4135
+ startedAt: new Date(row.started_at),
4136
+ completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
4137
+ promptCount: row.prompt_count,
4138
+ lastOutput: "",
4139
+ rehydrated: true,
4140
+ ...row.session_name != null && { sessionName: row.session_name },
4141
+ ...row.project_id != null && { projectId: row.project_id },
4142
+ ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
4143
+ ...row.resumed_from_conversation_id != null && {
4144
+ resumedFromConversationId: row.resumed_from_conversation_id
4145
+ },
4146
+ ...row.failure_reason != null && { failureReason: row.failure_reason },
4147
+ ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
4148
+ // Only `shutdown` crosses over. It is the one registry source that is also a
4149
+ // wire StatusSource *and* that genuinely describes the `idle` above — the
4150
+ // streamer stopped this session. A crashed row still says `transition` over
4151
+ // a `running` status, and copying that here would attach observed-confidence
4152
+ // provenance to a status we derived at boot, so leave it unset instead.
4153
+ ...row.status_source === "shutdown" && {
4154
+ statusSource: "shutdown",
4155
+ statusUpdatedAt: new Date(row.status_updated_at),
4156
+ // `status` above had to flatten to `idle`, which erases whether the agent
4157
+ // was mid-answer when we stopped it. Carried separately so a client can
4158
+ // say "interrupted mid-response" without a novel SessionStatus value.
4159
+ // Gated on the same `shutdown` source: a crashed row's `running` is a
4160
+ // frozen value nobody confirmed, not an observation.
4161
+ ...(row.status === "running" || row.status === "waiting_input") && {
4162
+ interruptedStatus: row.status
4163
+ }
4164
+ }
4165
+ };
4166
+ }
4167
+
4168
+ // src/utils/bootToken.ts
4169
+ import { readFileSync as readFileSync5 } from "fs";
4170
+ import os from "os";
4171
+ var cached2;
4172
+ function currentBootToken() {
4173
+ if (cached2 === void 0) cached2 = computeBootToken();
4174
+ return cached2;
4175
+ }
4176
+ function computeBootToken() {
4177
+ if (process.platform === "linux") {
4178
+ try {
4179
+ const bootId = readFileSync5("/proc/sys/kernel/random/boot_id", "utf8").trim();
4180
+ if (bootId) return bootId;
4181
+ } catch {
4182
+ }
4183
+ }
4184
+ return String(Math.round((Date.now() - os.uptime() * 1e3) / 1e4));
4185
+ }
4186
+
4104
4187
  // src/api/routes/diagnostics.routes.ts
4105
4188
  function providerCheck(name, resolve2) {
4106
4189
  try {
@@ -4180,6 +4263,49 @@ var createDiagnosticsRoutes = (deps) => {
4180
4263
  );
4181
4264
  return c.json(redactValue(buildReport(checks)));
4182
4265
  });
4266
+ app.get("/sessions", (c) => {
4267
+ const repo = deps.managedSessionsRepo();
4268
+ if (!repo) {
4269
+ return c.json(
4270
+ { error: "Session registry is unavailable", code: "REGISTRY_UNAVAILABLE" },
4271
+ 503
4272
+ );
4273
+ }
4274
+ const rows = repo.listAll();
4275
+ const verdicts = deps.sessionVerdicts();
4276
+ const bootToken = currentBootToken();
4277
+ const now = Date.now();
4278
+ const sessions = rows.map((row) => {
4279
+ const verdict = verdicts.get(row.session_id);
4280
+ const skip = rehydrateSkipReason(row, { now, projectExists: existsSync5 });
4281
+ return {
4282
+ sessionId: row.session_id,
4283
+ provider: row.provider,
4284
+ status: row.status,
4285
+ statusSource: row.status_source,
4286
+ statusUpdatedAt: new Date(row.status_updated_at).toISOString(),
4287
+ // Whether the recorded pid is probeable at all this boot, not whether
4288
+ // it is alive — a mismatch means the question was never asked.
4289
+ //
4290
+ // NOT named for the boot token it derives from: `redactValue`'s
4291
+ // SECRET_KEY_RE matches any key containing "token", so `bootTokenMatches`
4292
+ // was scrubbed to the string "[redacted]" and the field shipped useless.
4293
+ // The regex is deliberately over-broad for a payload meant to be pasted
4294
+ // into bug reports, so the field moved rather than the guard.
4295
+ recordedThisBoot: row.boot_token != null && row.boot_token === bootToken,
4296
+ // Absent when this boot never classified the row: a clean restart
4297
+ // stamps completed_at on the way out, which takes it out of the probe
4298
+ // set entirely. That absence is itself the answer.
4299
+ lifecycle: verdict?.lifecycle ?? null,
4300
+ lifecycleReason: verdict?.reason ?? null,
4301
+ rehydrated: skip == null,
4302
+ rehydrateSkipReason: skip,
4303
+ projectExists: existsSync5(row.project_path),
4304
+ projectPath: redactPath(row.project_path)
4305
+ };
4306
+ });
4307
+ return c.json(redactValue({ generatedAt: (/* @__PURE__ */ new Date()).toISOString(), sessions }));
4308
+ });
4183
4309
  return app;
4184
4310
  };
4185
4311
 
@@ -4330,7 +4456,7 @@ import { Hono as Hono11 } from "hono";
4330
4456
  import { hostname as hostname2 } from "os";
4331
4457
 
4332
4458
  // src/config/update-config.ts
4333
- import { readFileSync as readFileSync5 } from "fs";
4459
+ import { readFileSync as readFileSync6 } from "fs";
4334
4460
  import { homedir as homedir5 } from "os";
4335
4461
  import { join as join10 } from "path";
4336
4462
  import { parse as parseYaml } from "yaml";
@@ -4353,7 +4479,7 @@ function loadUpdateConfig(opts = {}) {
4353
4479
  const path = opts.path ?? DEFAULT_CONFIG_PATH;
4354
4480
  let raw;
4355
4481
  try {
4356
- raw = readFileSync5(path, "utf-8");
4482
+ raw = readFileSync6(path, "utf-8");
4357
4483
  } catch (err) {
4358
4484
  if (err.code === "ENOENT") return null;
4359
4485
  throw err;
@@ -5185,7 +5311,7 @@ import { dirname as dirname7 } from "path";
5185
5311
  import { setImmediate as yieldToEventLoop } from "timers/promises";
5186
5312
 
5187
5313
  // src/db/sqlite-migrate.ts
5188
- import { readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
5314
+ import { readdirSync as readdirSync2, readFileSync as readFileSync7 } from "fs";
5189
5315
  import { dirname as dirname6, join as join12 } from "path";
5190
5316
  import { fileURLToPath as fileURLToPath2 } from "url";
5191
5317
  function getMigrationsDir2() {
@@ -5217,7 +5343,7 @@ function runSqliteMigrations(db, migrationsDir) {
5217
5343
  skipped.push(file);
5218
5344
  continue;
5219
5345
  }
5220
- const sql = readFileSync6(join12(dir, file), "utf-8");
5346
+ const sql = readFileSync7(join12(dir, file), "utf-8");
5221
5347
  const tx = db.transaction(() => {
5222
5348
  db.exec(sql);
5223
5349
  recordApplied.run(file, (/* @__PURE__ */ new Date()).toISOString());
@@ -5247,8 +5373,8 @@ function isAgentLine(line, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
5247
5373
  function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
5248
5374
  if (entrypoints.size === 0) return false;
5249
5375
  const key = cacheKey(filePath, entrypoints);
5250
- const cached2 = fileDecisionCache.get(key);
5251
- if (cached2 !== void 0) return cached2;
5376
+ const cached3 = fileDecisionCache.get(key);
5377
+ if (cached3 !== void 0) return cached3;
5252
5378
  let fd;
5253
5379
  try {
5254
5380
  fd = openSync2(filePath, "r");
@@ -5904,9 +6030,9 @@ var ConversationCache = class _ConversationCache {
5904
6030
  classifyAgentFile(filePath, mtimeMs, fileSize) {
5905
6031
  if (this.agentEntrypoints.size === 0) return false;
5906
6032
  const entrypointsKey = this.agentEntrypointsKey();
5907
- const cached2 = this.stmts.getFileMetadata.get(filePath);
5908
- if (cached2 && cached2.mtime_ms === mtimeMs && cached2.file_size === fileSize && cached2.agent_entrypoints_key === entrypointsKey) {
5909
- return cached2.is_agent === 1;
6033
+ const cached3 = this.stmts.getFileMetadata.get(filePath);
6034
+ if (cached3 && cached3.mtime_ms === mtimeMs && cached3.file_size === fileSize && cached3.agent_entrypoints_key === entrypointsKey) {
6035
+ return cached3.is_agent === 1;
5910
6036
  }
5911
6037
  const isAgent = isAgentFile(filePath, this.agentEntrypoints);
5912
6038
  this.stmts.upsertFileMetadata.run({
@@ -6611,11 +6737,17 @@ var ConversationsRepository = class {
6611
6737
  };
6612
6738
 
6613
6739
  // src/db/repositories/managed-sessions.repository.ts
6740
+ var PROBE_SET_MAX = 200;
6741
+ var DIAGNOSTICS_MAX = 200;
6742
+ var TERMINAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
6614
6743
  var ManagedSessionsRepository = class {
6615
6744
  upsertStmt;
6616
6745
  updateStatusStmt;
6746
+ bindStmt;
6617
6747
  getStmt;
6618
6748
  listNonTerminalStmt;
6749
+ listAllStmt;
6750
+ pruneTerminalStmt;
6619
6751
  listRecoverableStmt;
6620
6752
  deleteStmt;
6621
6753
  constructor(db) {
@@ -6625,13 +6757,13 @@ var ManagedSessionsRepository = class {
6625
6757
  status, status_source, status_updated_at, started_at, completed_at,
6626
6758
  last_activity_at, prompt_count, session_name, project_id,
6627
6759
  bound_conversation_id, resumed_from_conversation_id, failure_reason,
6628
- streamer_instance_id
6760
+ streamer_instance_id, boot_token
6629
6761
  ) VALUES (
6630
6762
  @session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
6631
6763
  @status, @status_source, @status_updated_at, @started_at, @completed_at,
6632
6764
  @last_activity_at, @prompt_count, @session_name, @project_id,
6633
6765
  @bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
6634
- @streamer_instance_id
6766
+ @streamer_instance_id, @boot_token
6635
6767
  )
6636
6768
  ON CONFLICT(session_id) DO UPDATE SET
6637
6769
  pid = excluded.pid,
@@ -6650,7 +6782,8 @@ var ManagedSessionsRepository = class {
6650
6782
  bound_conversation_id = excluded.bound_conversation_id,
6651
6783
  resumed_from_conversation_id = excluded.resumed_from_conversation_id,
6652
6784
  failure_reason = excluded.failure_reason,
6653
- streamer_instance_id = excluded.streamer_instance_id
6785
+ streamer_instance_id = excluded.streamer_instance_id,
6786
+ boot_token = excluded.boot_token
6654
6787
  `);
6655
6788
  this.updateStatusStmt = db.prepare(`
6656
6789
  UPDATE managed_sessions
@@ -6664,11 +6797,27 @@ var ManagedSessionsRepository = class {
6664
6797
  session_name = COALESCE(@session_name, session_name)
6665
6798
  WHERE session_id = @session_id
6666
6799
  `);
6800
+ this.bindStmt = db.prepare(`
6801
+ UPDATE managed_sessions
6802
+ SET bound_conversation_id = @bound_conversation_id
6803
+ WHERE session_id = @session_id
6804
+ `);
6667
6805
  this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
6668
6806
  this.listNonTerminalStmt = db.prepare(`
6669
6807
  SELECT * FROM managed_sessions
6670
6808
  WHERE completed_at IS NULL
6671
6809
  ORDER BY started_at ASC
6810
+ LIMIT @limit
6811
+ `);
6812
+ this.listAllStmt = db.prepare(`
6813
+ SELECT * FROM managed_sessions
6814
+ ORDER BY status_updated_at DESC
6815
+ LIMIT @limit
6816
+ `);
6817
+ this.pruneTerminalStmt = db.prepare(`
6818
+ DELETE FROM managed_sessions
6819
+ WHERE completed_at IS NOT NULL
6820
+ AND completed_at < @before
6672
6821
  `);
6673
6822
  this.listRecoverableStmt = db.prepare(`
6674
6823
  SELECT * FROM managed_sessions
@@ -6701,7 +6850,10 @@ var ManagedSessionsRepository = class {
6701
6850
  bound_conversation_id: session.boundConversationId ?? null,
6702
6851
  resumed_from_conversation_id: session.resumedFromConversationId ?? null,
6703
6852
  failure_reason: session.failureReason ?? null,
6704
- streamer_instance_id: streamerInstanceId
6853
+ streamer_instance_id: streamerInstanceId,
6854
+ // Recorded, never backfilled: the pid above is only probeable while this
6855
+ // token still matches the running machine.
6856
+ boot_token: currentBootToken()
6705
6857
  });
6706
6858
  }
6707
6859
  /**
@@ -6722,12 +6874,48 @@ var ManagedSessionsRepository = class {
6722
6874
  session_name: fields.sessionName ?? null
6723
6875
  });
6724
6876
  }
6877
+ /**
6878
+ * Persist the Codex rollout id discovered after spawn.
6879
+ *
6880
+ * Its own statement rather than a `recordSpawn` re-run: the binding arrives
6881
+ * while the session is live, and re-upserting would also rewrite `cmdline`
6882
+ * with an id that is *not* in a fresh Codex process's argv, turning the
6883
+ * reconciler's identity check into a false `orphaned`. Without this write the
6884
+ * binding lives only in memory and dies with the streamer — which is the
6885
+ * whole reason a restarted Codex session could not be resumed (G6).
6886
+ */
6887
+ recordBinding(sessionId, boundConversationId) {
6888
+ this.bindStmt.run({
6889
+ session_id: sessionId,
6890
+ bound_conversation_id: boundConversationId
6891
+ });
6892
+ }
6725
6893
  get(sessionId) {
6726
6894
  return this.getStmt.get(sessionId) ?? null;
6727
6895
  }
6728
- /** Rows with no recorded completion — the reconciler's probe set. */
6729
- listNonTerminal() {
6730
- return this.listNonTerminalStmt.all();
6896
+ /**
6897
+ * Rows with no recorded completion — the reconciler's probe set.
6898
+ *
6899
+ * Capped. Callers must compare the result length against the limit and say so
6900
+ * when it clips: a silently truncated probe set reads as "we checked
6901
+ * everything" when it did not.
6902
+ */
6903
+ listNonTerminal(limit = PROBE_SET_MAX) {
6904
+ return this.listNonTerminalStmt.all({ limit });
6905
+ }
6906
+ /** Every row, most recently touched first, for the diagnostics surface. */
6907
+ listAll(limit = DIAGNOSTICS_MAX) {
6908
+ return this.listAllStmt.all({ limit });
6909
+ }
6910
+ /**
6911
+ * Delete terminal rows older than `olderThanMs`, returning how many went.
6912
+ *
6913
+ * Only rows carrying a `completed_at` are eligible, so nothing the reconciler
6914
+ * or rehydrator might still want is reachable from here — a row without one
6915
+ * is by definition unfinished business, however old it looks.
6916
+ */
6917
+ pruneTerminal(olderThanMs = TERMINAL_RETENTION_MS) {
6918
+ return this.pruneTerminalStmt.run({ before: Date.now() - olderThanMs }).changes;
6731
6919
  }
6732
6920
  /**
6733
6921
  * Rows a restart could bring back: still open, or closed by our own shutdown,
@@ -7074,7 +7262,7 @@ import { createHash as createHash3 } from "crypto";
7074
7262
  import { existsSync as existsSync9 } from "fs";
7075
7263
 
7076
7264
  // src/services/cache-integrity/alertStore.ts
7077
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
7265
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
7078
7266
  import { homedir as homedir7 } from "os";
7079
7267
  import { dirname as dirname8, join as join14 } from "path";
7080
7268
  function alertStatePath() {
@@ -7083,7 +7271,7 @@ function alertStatePath() {
7083
7271
  }
7084
7272
  function loadAlertState() {
7085
7273
  try {
7086
- const parsed = JSON.parse(readFileSync7(alertStatePath(), "utf-8"));
7274
+ const parsed = JSON.parse(readFileSync8(alertStatePath(), "utf-8"));
7087
7275
  return parsed && typeof parsed === "object" ? parsed : {};
7088
7276
  } catch {
7089
7277
  return {};
@@ -8696,8 +8884,14 @@ function readIdempotencyKey(body) {
8696
8884
  }
8697
8885
 
8698
8886
  // src/services/sessions/reconcileSessions.ts
8699
- async function classifySession(row, probe, currentInstanceId) {
8887
+ var PRE_BOOT_REASON = "recorded before this machine boot";
8888
+ async function classifySession(row, probe, currentInstanceId, currentBootToken2 = null) {
8700
8889
  const { session_id: sessionId } = row;
8890
+ const resumable = (reason) => resumeIdForRow(row) == null ? {
8891
+ sessionId,
8892
+ lifecycle: "failed",
8893
+ reason: "Codex session ended before its rollout id was known"
8894
+ } : { sessionId, lifecycle: "resumable", reason };
8701
8895
  if (row.completed_at != null) {
8702
8896
  const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
8703
8897
  return {
@@ -8707,18 +8901,19 @@ async function classifySession(row, probe, currentInstanceId) {
8707
8901
  };
8708
8902
  }
8709
8903
  if (row.pid == null) {
8710
- return { sessionId, lifecycle: "resumable", reason: "no pid recorded" };
8904
+ return resumable("no pid recorded");
8905
+ }
8906
+ if (currentBootToken2 != null && row.boot_token !== currentBootToken2) {
8907
+ return resumable(PRE_BOOT_REASON);
8711
8908
  }
8712
8909
  if (!probe.isPidAlive(row.pid)) {
8713
- const clean = probe.endedCleanly?.(row) ?? false;
8714
- if (clean) {
8910
+ if (probe.endedCleanly?.(row)) {
8715
8911
  return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
8716
8912
  }
8717
- return {
8718
- sessionId,
8719
- lifecycle: "resumable",
8720
- reason: "process gone, resumable from provider history"
8721
- };
8913
+ if (row.failure_reason != null) {
8914
+ return { sessionId, lifecycle: "failed", reason: "process gone, failure recorded" };
8915
+ }
8916
+ return resumable("process gone, resumable from provider history");
8722
8917
  }
8723
8918
  const args = await probe.getProcessArgs(row.pid);
8724
8919
  const token = row.cmdline;
@@ -8736,52 +8931,10 @@ async function classifySession(row, probe, currentInstanceId) {
8736
8931
  reason: sameRun ? "owned by this run" : "survived a previous streamer run"
8737
8932
  };
8738
8933
  }
8739
- async function reconcileSessions(rows, probe, currentInstanceId) {
8740
- return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
8741
- }
8742
-
8743
- // src/services/sessions/rehydrateSessions.ts
8744
- var REHYDRATE_MAX = 25;
8745
- var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
8746
- var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
8747
- function shouldRehydrate(row, opts) {
8748
- if (!opts.projectExists(row.project_path)) return false;
8749
- if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
8750
- if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
8751
- return true;
8752
- }
8753
- function rowToStubSession(row) {
8754
- return {
8755
- id: row.session_id,
8756
- provider: row.provider,
8757
- projectPath: row.project_path,
8758
- projectName: row.project_name,
8759
- branch: row.branch,
8760
- // No PTY exists for a stub, so this is the only truthful status.
8761
- status: "idle",
8762
- startedAt: new Date(row.started_at),
8763
- completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
8764
- promptCount: row.prompt_count,
8765
- lastOutput: "",
8766
- rehydrated: true,
8767
- ...row.session_name != null && { sessionName: row.session_name },
8768
- ...row.project_id != null && { projectId: row.project_id },
8769
- ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
8770
- ...row.resumed_from_conversation_id != null && {
8771
- resumedFromConversationId: row.resumed_from_conversation_id
8772
- },
8773
- ...row.failure_reason != null && { failureReason: row.failure_reason },
8774
- ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
8775
- // Only `shutdown` crosses over. It is the one registry source that is also a
8776
- // wire StatusSource *and* that genuinely describes the `idle` above — the
8777
- // streamer stopped this session. A crashed row still says `transition` over
8778
- // a `running` status, and copying that here would attach observed-confidence
8779
- // provenance to a status we derived at boot, so leave it unset instead.
8780
- ...row.status_source === "shutdown" && {
8781
- statusSource: "shutdown",
8782
- statusUpdatedAt: new Date(row.status_updated_at)
8783
- }
8784
- };
8934
+ async function reconcileSessions(rows, probe, currentInstanceId, currentBootToken2 = null) {
8935
+ return Promise.all(
8936
+ rows.map((row) => classifySession(row, probe, currentInstanceId, currentBootToken2))
8937
+ );
8785
8938
  }
8786
8939
 
8787
8940
  // src/types.ts
@@ -8972,7 +9125,8 @@ function managedToResponse(s, ptyAttached) {
8972
9125
  ...s.resumedFromConversationId != null && {
8973
9126
  resumedFromConversationId: s.resumedFromConversationId
8974
9127
  },
8975
- ...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
9128
+ ...s.boundConversationId != null && { boundConversationId: s.boundConversationId },
9129
+ ...s.interruptedStatus != null && { interruptedStatus: s.interruptedStatus }
8976
9130
  };
8977
9131
  }
8978
9132
  function discoveredToResponse(d, conversationId) {
@@ -9517,7 +9671,7 @@ var StreamerServer = class {
9517
9671
  idempotency = new IdempotencyStore();
9518
9672
  // sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
9519
9673
  // this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
9520
- sessionLifecycles = /* @__PURE__ */ new Map();
9674
+ sessionVerdicts = /* @__PURE__ */ new Map();
9521
9675
  // Periodic sweep that releases PTYs no agent is using. Null until listen().
9522
9676
  idleReaperTimer = null;
9523
9677
  // Map of clientId → WS socket (populated by the "register" WS handshake)
@@ -9776,7 +9930,14 @@ var StreamerServer = class {
9776
9930
  this.sessionStore.updateManaged(session.id, {
9777
9931
  status: session.status,
9778
9932
  completedAt: session.completedAt,
9779
- ...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
9933
+ ...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt },
9934
+ // The runner derives this from the first user message, on its own
9935
+ // copy of the session. Without mirroring it here SessionStore never
9936
+ // learns it, so a fresh live session is served with no sessionName
9937
+ // even though the registry has one — the name only appeared after a
9938
+ // restart rebuilt the row as a stub. Guarded so a runner that has not
9939
+ // derived one yet cannot blank a name set by enrichResumedSessionAsync.
9940
+ ...session.sessionName != null && { sessionName: session.sessionName }
9780
9941
  });
9781
9942
  this.managedSessionsRepo?.recordStatus(
9782
9943
  session.id,
@@ -9883,6 +10044,8 @@ var StreamerServer = class {
9883
10044
  sessionsRepo: () => this.sessionsRepo,
9884
10045
  cacheMetadataRepo: () => this.cacheMetadataRepo,
9885
10046
  runtimeStore: () => this.runtimeStore,
10047
+ managedSessionsRepo: () => this.managedSessionsRepo,
10048
+ sessionVerdicts: () => this.sessionVerdicts,
9886
10049
  ptyAttachedIds: () => this.ptyAttachedIds(),
9887
10050
  handleListSessions: (url, res) => this.handleListSessions(url, res),
9888
10051
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -10080,12 +10243,12 @@ var StreamerServer = class {
10080
10243
  * it.
10081
10244
  */
10082
10245
  withReconciledLifecycle(sessions) {
10083
- if (this.sessionLifecycles.size === 0) return sessions;
10246
+ if (this.sessionVerdicts.size === 0) return sessions;
10084
10247
  return sessions.map((s) => {
10085
10248
  if (s.ptyAttached) return s;
10086
- const verdict = this.sessionLifecycles.get(s.id);
10249
+ const verdict = this.sessionVerdicts.get(s.id);
10087
10250
  if (!verdict) return s;
10088
- return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
10251
+ return { ...s, lifecycle: verdict.lifecycle, lifecycleSource: "reconcile" };
10089
10252
  });
10090
10253
  }
10091
10254
  addSessionSubscriber(sessionId, ws) {
@@ -10153,14 +10316,30 @@ var StreamerServer = class {
10153
10316
  let verdicts = [];
10154
10317
  try {
10155
10318
  const rows = this.managedSessionsRepo.listNonTerminal();
10319
+ if (rows.length === PROBE_SET_MAX) {
10320
+ this.log.warn(
10321
+ `[reconcile] probe set hit its cap of ${PROBE_SET_MAX} \u2014 older rows skipped`,
10322
+ {
10323
+ event: "registry.probe_truncated",
10324
+ limit: PROBE_SET_MAX
10325
+ }
10326
+ );
10327
+ }
10156
10328
  if (rows.length === 0) return [];
10157
10329
  verdicts = await reconcileSessions(
10158
10330
  rows,
10159
10331
  { isPidAlive, getProcessArgs },
10160
- this.streamerInstanceId
10332
+ this.streamerInstanceId,
10333
+ currentBootToken()
10161
10334
  );
10162
10335
  for (const v of verdicts) {
10163
- this.sessionLifecycles.set(v.sessionId, v.lifecycle);
10336
+ this.sessionVerdicts.set(v.sessionId, v);
10337
+ if (v.reason === PRE_BOOT_REASON) {
10338
+ this.log.info(`[reconcile] ${v.sessionId} predates this machine boot \u2014 pid not probed`, {
10339
+ event: "sessions.boot_token_mismatch",
10340
+ sessionId: v.sessionId
10341
+ });
10342
+ }
10164
10343
  if (v.lifecycle === "completed" || v.lifecycle === "failed") {
10165
10344
  this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
10166
10345
  completedAt: /* @__PURE__ */ new Date()
@@ -10182,6 +10361,30 @@ var StreamerServer = class {
10182
10361
  }
10183
10362
  return verdicts;
10184
10363
  }
10364
+ /**
10365
+ * Drop finished sessions the registry has held long enough (plan Phase 4).
10366
+ *
10367
+ * The registry is authoritative and never rebuilt from the cache, so nothing
10368
+ * else would ever remove a row: without this it grows for the life of the
10369
+ * install, and every boot pays for rows about sessions from months ago.
10370
+ */
10371
+ pruneTerminalSessions() {
10372
+ if (!this.managedSessionsRepo) return;
10373
+ try {
10374
+ const pruned = this.managedSessionsRepo.pruneTerminal();
10375
+ if (pruned > 0) {
10376
+ this.log.info(`[registry] pruned ${pruned} terminal session row(s)`, {
10377
+ event: "registry.pruned",
10378
+ pruned
10379
+ });
10380
+ }
10381
+ } catch (err) {
10382
+ this.log.warn("[registry] failed to prune terminal sessions", {
10383
+ event: "registry.prune_failed",
10384
+ err
10385
+ });
10386
+ }
10387
+ }
10185
10388
  /**
10186
10389
  * Seed the session list with what previous runs left behind (persistence plan
10187
10390
  * Phase 1, gaps G1/G2/G8).
@@ -10208,15 +10411,29 @@ var StreamerServer = class {
10208
10411
  const truncated = rows.length > REHYDRATE_MAX;
10209
10412
  const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
10210
10413
  if (candidates.length === 0) return;
10211
- const lifecycleByVerdict = new Map(verdicts.map((v) => [v.sessionId, v.lifecycle]));
10414
+ const verdictById = new Map(verdicts.map((v) => [v.sessionId, v]));
10212
10415
  let rehydrated = 0;
10416
+ const skippedBy = {};
10213
10417
  for (const row of candidates) {
10214
10418
  if (this.sessionStore.getManaged(row.session_id)) continue;
10215
- if (!shouldRehydrate(row, { now, projectExists: existsSync11 })) continue;
10419
+ const skip = rehydrateSkipReason(row, { now, projectExists: existsSync11 });
10420
+ if (skip) {
10421
+ skippedBy[skip] = (skippedBy[skip] ?? 0) + 1;
10422
+ this.log.info(`[rehydrate] skipped ${row.session_id}: ${skip}`, {
10423
+ event: "sessions.rehydrate_skipped",
10424
+ sessionId: row.session_id,
10425
+ reason: skip
10426
+ });
10427
+ continue;
10428
+ }
10216
10429
  this.sessionStore.addManaged(rowToStubSession(row));
10217
- this.sessionLifecycles.set(
10430
+ this.sessionVerdicts.set(
10218
10431
  row.session_id,
10219
- lifecycleByVerdict.get(row.session_id) ?? "resumable"
10432
+ verdictById.get(row.session_id) ?? {
10433
+ sessionId: row.session_id,
10434
+ lifecycle: "resumable",
10435
+ reason: "recovered from the registry at boot"
10436
+ }
10220
10437
  );
10221
10438
  if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
10222
10439
  rehydrated++;
@@ -10225,6 +10442,7 @@ var StreamerServer = class {
10225
10442
  event: "sessions.rehydrated",
10226
10443
  rehydrated,
10227
10444
  skipped: candidates.length - rehydrated,
10445
+ skippedBy,
10228
10446
  truncated
10229
10447
  });
10230
10448
  } catch (err) {
@@ -10316,7 +10534,7 @@ var StreamerServer = class {
10316
10534
  const now = /* @__PURE__ */ new Date();
10317
10535
  for (const session of this.ptyManager.listSessions()) {
10318
10536
  try {
10319
- this.managedSessionsRepo.recordStatus(session.id, "idle", "shutdown", {
10537
+ this.managedSessionsRepo.recordStatus(session.id, session.status, "shutdown", {
10320
10538
  completedAt: now,
10321
10539
  lastActivityAt: session.lastActivityAt ?? null,
10322
10540
  promptCount: session.promptCount
@@ -10554,7 +10772,10 @@ var StreamerServer = class {
10554
10772
  );
10555
10773
  this.scannerPersistenceDisabled = true;
10556
10774
  }
10557
- void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
10775
+ void this.reconcilePreviousSessions().then((v) => {
10776
+ this.rehydratePreviousSessions(v);
10777
+ this.pruneTerminalSessions();
10778
+ });
10558
10779
  if (this.skipStartupWarmup) {
10559
10780
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
10560
10781
  event: "cache.warmup_skipped"
@@ -12122,32 +12343,85 @@ var StreamerServer = class {
12122
12343
  json(res, 400, { error: "Missing sessionId" });
12123
12344
  return;
12124
12345
  }
12346
+ const outcome = await this.resumeSession({
12347
+ sessionId,
12348
+ force: body.force === true,
12349
+ projectName: body.projectName,
12350
+ branch: body.branch
12351
+ });
12352
+ if (!outcome.ok) {
12353
+ switch (outcome.reason) {
12354
+ case "history_file_missing":
12355
+ json(res, 404, {
12356
+ error: "Conversation history file is missing; it can no longer be resumed",
12357
+ code: "history_file_missing"
12358
+ });
12359
+ return;
12360
+ case "no_project_path":
12361
+ json(res, 400, { error: "Could not determine project path" });
12362
+ return;
12363
+ case "conversation_busy":
12364
+ json(res, 409, {
12365
+ error: "This conversation looks active in another session",
12366
+ code: "CONVERSATION_BUSY",
12367
+ detectedBy: outcome.detectedBy,
12368
+ lastActivityMs: outcome.lastActivityMs,
12369
+ likelyOwner: outcome.likelyOwner
12370
+ });
12371
+ return;
12372
+ }
12373
+ }
12374
+ if (outcome.alreadyRunning) {
12375
+ json(res, 200, outcome.response);
12376
+ return;
12377
+ }
12378
+ this.broadcastOrUnicastSessionList(req);
12379
+ json(res, 201, outcome.response ?? outcome.session);
12380
+ }
12381
+ /**
12382
+ * Resume a session, from an HTTP request or from the boot path.
12383
+ *
12384
+ * Extracted from `handleResume` so both callers hit the **same collision
12385
+ * probe** (plan Phase 7c). The probe is what stops this streamer attaching to
12386
+ * a conversation an external terminal already owns; a second, hand-adapted
12387
+ * copy of this sequence in the boot path is how two agents end up appending
12388
+ * to one JSONL at 4am with nobody watching.
12389
+ *
12390
+ * Returns a typed reason rather than writing a response, so the HTTP caller
12391
+ * maps it to a status code and the boot caller logs it.
12392
+ */
12393
+ async resumeSession(opts) {
12394
+ const { sessionId } = opts;
12125
12395
  if (this.ptyManager.hasSession(sessionId)) {
12126
- const resp2 = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12127
- if (resp2) {
12128
- json(res, 200, resp2);
12129
- return;
12396
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12397
+ if (resp) {
12398
+ return { ok: true, alreadyRunning: true, session: null, response: resp };
12399
+ }
12400
+ }
12401
+ let jsonlPath = this.findJsonlPath(sessionId);
12402
+ let conv = await this.findConversationByUuid(sessionId);
12403
+ let historyId = sessionId;
12404
+ let registryProvider;
12405
+ if (!jsonlPath && !conv) {
12406
+ const row = this.managedSessionsRepo?.get(sessionId) ?? null;
12407
+ const boundId = row ? resumeIdForRow(row) : null;
12408
+ if (boundId != null && boundId !== sessionId) {
12409
+ historyId = boundId;
12410
+ registryProvider = row?.provider;
12411
+ jsonlPath = this.findJsonlPath(boundId);
12412
+ conv = await this.findConversationByUuid(boundId);
12130
12413
  }
12131
12414
  }
12132
- const jsonlPath = this.findJsonlPath(sessionId);
12133
12415
  const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
12134
- const conv = await this.findConversationByUuid(sessionId);
12135
12416
  const projectPath = jsonlCwd ?? conv?.projectPath;
12136
12417
  if (!projectPath) {
12137
- if (!conv && !jsonlPath) {
12138
- json(res, 404, {
12139
- error: "Conversation history file is missing; it can no longer be resumed",
12140
- code: "history_file_missing"
12141
- });
12142
- return;
12143
- }
12144
- json(res, 400, { error: "Could not determine project path" });
12145
- return;
12418
+ if (!conv && !jsonlPath) return { ok: false, reason: "history_file_missing" };
12419
+ return { ok: false, reason: "no_project_path" };
12146
12420
  }
12147
12421
  let discovered = [];
12148
- const cached2 = this.discoveryCache;
12149
- if (cached2 && Date.now() - cached2.fetchedAt < DISCOVERY_TTL_MS) {
12150
- discovered = cached2.entries;
12422
+ const cached3 = this.discoveryCache;
12423
+ if (cached3 && Date.now() - cached3.fetchedAt < DISCOVERY_TTL_MS) {
12424
+ discovered = cached3.entries;
12151
12425
  } else {
12152
12426
  try {
12153
12427
  discovered = await Promise.race([
@@ -12163,45 +12437,50 @@ var StreamerServer = class {
12163
12437
  }
12164
12438
  }
12165
12439
  const busy = conversationBusy({
12166
- conversationId: sessionId,
12440
+ // The id another owner's argv would actually carry — for a placeholder
12441
+ // that is the bound rollout id, not the one the client asked for.
12442
+ conversationId: historyId,
12167
12443
  projectPath,
12168
12444
  jsonlPath,
12169
12445
  discovered,
12170
12446
  windowMs: resolveResumeBusyWindowMs(),
12171
12447
  selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
12172
12448
  });
12173
- if (busy.busy && body.force !== true) {
12174
- json(res, 409, {
12175
- error: "This conversation looks active in another session",
12176
- code: "CONVERSATION_BUSY",
12449
+ if (busy.busy && opts.force !== true) {
12450
+ return {
12451
+ ok: false,
12452
+ reason: "conversation_busy",
12177
12453
  detectedBy: busy.detectedBy,
12178
12454
  lastActivityMs: busy.lastActivityMs,
12179
12455
  likelyOwner: busy.likelyOwner
12180
- });
12181
- return;
12456
+ };
12182
12457
  }
12183
12458
  if (busy.busy) {
12184
12459
  this.contendedSessions.add(sessionId);
12185
12460
  }
12186
- const cachedConvMeta = this.cache?.getMetaById(sessionId);
12187
- const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
12461
+ const cachedConvMeta = this.cache?.getMetaById(historyId);
12462
+ const provider = coerceProviderForRunner(
12463
+ conv?.provider ?? cachedConvMeta?.provider ?? registryProvider
12464
+ );
12188
12465
  this.discoveryCache = null;
12189
12466
  const session = await this.ptyManager.start(sessionId, {
12190
12467
  provider,
12191
12468
  projectPath,
12192
- projectName: body.projectName,
12193
- branch: body.branch,
12469
+ projectName: opts.projectName,
12470
+ branch: opts.branch,
12471
+ // Omitted on every ordinary resume, so argv is unchanged there.
12472
+ ...historyId !== sessionId && { resumeId: historyId },
12194
12473
  claudeFlags: this.claudeFlags,
12195
12474
  claudeExtraArgs: this.claudeExtraArgs,
12196
12475
  ...this.spawnFlagOverrides()
12197
12476
  });
12477
+ if (historyId !== sessionId) session.boundConversationId = historyId;
12198
12478
  this.sessionStore.addManaged(session);
12199
12479
  this.recordSessionSpawn(session);
12200
- void this.watchConversationFile(sessionId);
12201
- const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
12202
- this.broadcastOrUnicastSessionList(req);
12203
- json(res, 201, resp ?? session);
12480
+ void this.watchConversationFile(sessionId, historyId);
12481
+ const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12204
12482
  this.enrichResumedSessionAsync(sessionId, projectPath, conv);
12483
+ return { ok: true, alreadyRunning: false, session, response };
12205
12484
  }
12206
12485
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
12207
12486
  try {
@@ -12214,18 +12493,18 @@ var StreamerServer = class {
12214
12493
  session.filePath = conv.filePath ?? void 0;
12215
12494
  }
12216
12495
  if (!this.cache || !this.projectsRepo || !this.conversationsRepo) return;
12217
- const cached2 = this.cache.getMetaById(sessionId);
12218
- if (cached2) {
12219
- session.model = cached2.model ?? void 0;
12220
- session.preview = cached2.preview ?? void 0;
12221
- const first = cached2.firstMessage ? JSON.parse(cached2.firstMessage) : null;
12222
- const last = cached2.lastMessage ? JSON.parse(cached2.lastMessage) : null;
12496
+ const cached3 = this.cache.getMetaById(sessionId);
12497
+ if (cached3) {
12498
+ session.model = cached3.model ?? void 0;
12499
+ session.preview = cached3.preview ?? void 0;
12500
+ const first = cached3.firstMessage ? JSON.parse(cached3.firstMessage) : null;
12501
+ const last = cached3.lastMessage ? JSON.parse(cached3.lastMessage) : null;
12223
12502
  session.firstMessageText = first?.text ?? void 0;
12224
12503
  session.firstMessageAt = first?.timestamp ? new Date(first.timestamp).toISOString() : void 0;
12225
12504
  session.lastMessageText = last?.text ?? void 0;
12226
12505
  session.lastMessageAt = last?.timestamp ? new Date(last.timestamp).toISOString() : void 0;
12227
12506
  }
12228
- let resolvedProjectId = cached2?.projectId ?? null;
12507
+ let resolvedProjectId = cached3?.projectId ?? null;
12229
12508
  if (!resolvedProjectId) {
12230
12509
  const project = this.projectsRepo.upsertProjectByPath(projectPath);
12231
12510
  resolvedProjectId = project.id;
@@ -12797,9 +13076,12 @@ var StreamerServer = class {
12797
13076
  }
12798
13077
  }
12799
13078
  // ─── File Watcher Wiring ─────────────────────────────────────────
12800
- async watchConversationFile(sessionId) {
13079
+ // `historyId` is the id the provider filed the history under, which for a
13080
+ // fresh Codex session is its rollout id rather than our placeholder. The map
13081
+ // stays keyed by `sessionId` — that is what broadcasts resolve against.
13082
+ async watchConversationFile(sessionId, historyId = sessionId) {
12801
13083
  try {
12802
- const conversation = await this.findConversationByUuid(sessionId);
13084
+ const conversation = await this.findConversationByUuid(historyId);
12803
13085
  if (conversation?.filePath) {
12804
13086
  this.sessionFileMap.set(sessionId, conversation.filePath);
12805
13087
  this.fileWatcher.watch(conversation.filePath);
@@ -12813,7 +13095,7 @@ var StreamerServer = class {
12813
13095
  // file isn't slurped in full.
12814
13096
  readFirstLineSessionId(filePath) {
12815
13097
  try {
12816
- const content = readFileSync8(filePath, "utf8");
13098
+ const content = readFileSync9(filePath, "utf8");
12817
13099
  const nl = content.indexOf("\n");
12818
13100
  const firstLine = nl === -1 ? content : content.slice(0, nl);
12819
13101
  if (!firstLine.trim()) return null;
@@ -12863,7 +13145,7 @@ var StreamerServer = class {
12863
13145
  cleanup();
12864
13146
  this.sessionFileMap.set(sessionId, resolvedFilePath);
12865
13147
  try {
12866
- const existing = readFileSync8(resolvedFilePath, "utf8").split("\n").filter(Boolean);
13148
+ const existing = readFileSync9(resolvedFilePath, "utf8").split("\n").filter(Boolean);
12867
13149
  if (existing.length > 0) {
12868
13150
  this.broadcastConversationLines(sessionId, existing);
12869
13151
  }
@@ -12916,7 +13198,7 @@ var StreamerServer = class {
12916
13198
  };
12917
13199
  const matchesProjectPath = (candidatePath) => {
12918
13200
  try {
12919
- const firstLine = readFileSync8(candidatePath, "utf8").split("\n", 1)[0];
13201
+ const firstLine = readFileSync9(candidatePath, "utf8").split("\n", 1)[0];
12920
13202
  if (!firstLine) return null;
12921
13203
  const parsed = JSON.parse(firstLine);
12922
13204
  if (parsed?.type !== "session_meta") return null;
@@ -12962,10 +13244,19 @@ var StreamerServer = class {
12962
13244
  const codexSessionId = match.id;
12963
13245
  cleanup();
12964
13246
  this.sessionStore.updateManaged(sessionId, { boundConversationId: codexSessionId });
13247
+ try {
13248
+ this.managedSessionsRepo?.recordBinding(sessionId, codexSessionId);
13249
+ } catch (err) {
13250
+ this.log.warn("[registry] failed to record Codex rollout binding", {
13251
+ event: "registry.binding_write_failed",
13252
+ sessionId,
13253
+ err
13254
+ });
13255
+ }
12965
13256
  this.sessionFileMap.set(sessionId, candidatePath);
12966
13257
  this.fileWatcher.watch(candidatePath);
12967
13258
  try {
12968
- const existing = readFileSync8(candidatePath, "utf8").split("\n").filter(Boolean);
13259
+ const existing = readFileSync9(candidatePath, "utf8").split("\n").filter(Boolean);
12969
13260
  if (existing.length > 0) {
12970
13261
  this.broadcastConversationLines(sessionId, existing);
12971
13262
  }