@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.cjs CHANGED
@@ -1201,7 +1201,10 @@ var CodexPtyRunner = class {
1201
1201
  const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
1202
1202
  const proc = nodePty.spawn(
1203
1203
  resolveCodexExe(),
1204
- ["resume", sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1204
+ // `sessionId` stays the runner's map key — only argv carries the
1205
+ // provider-side id, so a resumed Codex session keeps the placeholder id
1206
+ // its client already navigated to.
1207
+ ["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
1205
1208
  {
1206
1209
  name: "xterm-256color",
1207
1210
  cols: PTY_COLS,
@@ -4140,6 +4143,86 @@ function redactValue(value) {
4140
4143
  return value;
4141
4144
  }
4142
4145
 
4146
+ // src/services/sessions/resumeIdentity.ts
4147
+ function resumeIdForRow(row) {
4148
+ if (row.provider !== CODEX_CLI_PROVIDER) return row.session_id;
4149
+ return row.bound_conversation_id;
4150
+ }
4151
+
4152
+ // src/services/sessions/rehydrateSessions.ts
4153
+ var REHYDRATE_MAX = 25;
4154
+ var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
4155
+ var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
4156
+ function rehydrateSkipReason(row, opts) {
4157
+ if (resumeIdForRow(row) == null) return "codex_unbound";
4158
+ if (!opts.projectExists(row.project_path)) return "project_missing";
4159
+ if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return "too_old";
4160
+ if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) {
4161
+ return "agent_exited";
4162
+ }
4163
+ return null;
4164
+ }
4165
+ function rowToStubSession(row) {
4166
+ return {
4167
+ id: row.session_id,
4168
+ provider: row.provider,
4169
+ projectPath: row.project_path,
4170
+ projectName: row.project_name,
4171
+ branch: row.branch,
4172
+ // No PTY exists for a stub, so this is the only truthful status.
4173
+ status: "idle",
4174
+ startedAt: new Date(row.started_at),
4175
+ completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
4176
+ promptCount: row.prompt_count,
4177
+ lastOutput: "",
4178
+ rehydrated: true,
4179
+ ...row.session_name != null && { sessionName: row.session_name },
4180
+ ...row.project_id != null && { projectId: row.project_id },
4181
+ ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
4182
+ ...row.resumed_from_conversation_id != null && {
4183
+ resumedFromConversationId: row.resumed_from_conversation_id
4184
+ },
4185
+ ...row.failure_reason != null && { failureReason: row.failure_reason },
4186
+ ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
4187
+ // Only `shutdown` crosses over. It is the one registry source that is also a
4188
+ // wire StatusSource *and* that genuinely describes the `idle` above — the
4189
+ // streamer stopped this session. A crashed row still says `transition` over
4190
+ // a `running` status, and copying that here would attach observed-confidence
4191
+ // provenance to a status we derived at boot, so leave it unset instead.
4192
+ ...row.status_source === "shutdown" && {
4193
+ statusSource: "shutdown",
4194
+ statusUpdatedAt: new Date(row.status_updated_at),
4195
+ // `status` above had to flatten to `idle`, which erases whether the agent
4196
+ // was mid-answer when we stopped it. Carried separately so a client can
4197
+ // say "interrupted mid-response" without a novel SessionStatus value.
4198
+ // Gated on the same `shutdown` source: a crashed row's `running` is a
4199
+ // frozen value nobody confirmed, not an observation.
4200
+ ...(row.status === "running" || row.status === "waiting_input") && {
4201
+ interruptedStatus: row.status
4202
+ }
4203
+ }
4204
+ };
4205
+ }
4206
+
4207
+ // src/utils/bootToken.ts
4208
+ var import_node_fs3 = require("fs");
4209
+ var import_node_os = __toESM(require("os"), 1);
4210
+ var cached2;
4211
+ function currentBootToken() {
4212
+ if (cached2 === void 0) cached2 = computeBootToken();
4213
+ return cached2;
4214
+ }
4215
+ function computeBootToken() {
4216
+ if (process.platform === "linux") {
4217
+ try {
4218
+ const bootId = (0, import_node_fs3.readFileSync)("/proc/sys/kernel/random/boot_id", "utf8").trim();
4219
+ if (bootId) return bootId;
4220
+ } catch {
4221
+ }
4222
+ }
4223
+ return String(Math.round((Date.now() - import_node_os.default.uptime() * 1e3) / 1e4));
4224
+ }
4225
+
4143
4226
  // src/api/routes/diagnostics.routes.ts
4144
4227
  function providerCheck(name, resolve2) {
4145
4228
  try {
@@ -4219,6 +4302,49 @@ var createDiagnosticsRoutes = (deps) => {
4219
4302
  );
4220
4303
  return c.json(redactValue(buildReport(checks)));
4221
4304
  });
4305
+ app.get("/sessions", (c) => {
4306
+ const repo = deps.managedSessionsRepo();
4307
+ if (!repo) {
4308
+ return c.json(
4309
+ { error: "Session registry is unavailable", code: "REGISTRY_UNAVAILABLE" },
4310
+ 503
4311
+ );
4312
+ }
4313
+ const rows = repo.listAll();
4314
+ const verdicts = deps.sessionVerdicts();
4315
+ const bootToken = currentBootToken();
4316
+ const now = Date.now();
4317
+ const sessions = rows.map((row) => {
4318
+ const verdict = verdicts.get(row.session_id);
4319
+ const skip = rehydrateSkipReason(row, { now, projectExists: import_fs7.existsSync });
4320
+ return {
4321
+ sessionId: row.session_id,
4322
+ provider: row.provider,
4323
+ status: row.status,
4324
+ statusSource: row.status_source,
4325
+ statusUpdatedAt: new Date(row.status_updated_at).toISOString(),
4326
+ // Whether the recorded pid is probeable at all this boot, not whether
4327
+ // it is alive — a mismatch means the question was never asked.
4328
+ //
4329
+ // NOT named for the boot token it derives from: `redactValue`'s
4330
+ // SECRET_KEY_RE matches any key containing "token", so `bootTokenMatches`
4331
+ // was scrubbed to the string "[redacted]" and the field shipped useless.
4332
+ // The regex is deliberately over-broad for a payload meant to be pasted
4333
+ // into bug reports, so the field moved rather than the guard.
4334
+ recordedThisBoot: row.boot_token != null && row.boot_token === bootToken,
4335
+ // Absent when this boot never classified the row: a clean restart
4336
+ // stamps completed_at on the way out, which takes it out of the probe
4337
+ // set entirely. That absence is itself the answer.
4338
+ lifecycle: verdict?.lifecycle ?? null,
4339
+ lifecycleReason: verdict?.reason ?? null,
4340
+ rehydrated: skip == null,
4341
+ rehydrateSkipReason: skip,
4342
+ projectExists: (0, import_fs7.existsSync)(row.project_path),
4343
+ projectPath: redactPath(row.project_path)
4344
+ };
4345
+ });
4346
+ return c.json(redactValue({ generatedAt: (/* @__PURE__ */ new Date()).toISOString(), sessions }));
4347
+ });
4222
4348
  return app;
4223
4349
  };
4224
4350
 
@@ -4234,16 +4360,16 @@ var createHealthRoutes = (deps) => {
4234
4360
  };
4235
4361
 
4236
4362
  // src/api/routes/logs.routes.ts
4237
- var import_node_fs3 = require("fs");
4363
+ var import_node_fs4 = require("fs");
4238
4364
  var import_node_path5 = require("path");
4239
4365
  var import_hono10 = require("hono");
4240
4366
 
4241
4367
  // src/lifecycle/constants.ts
4242
- var import_node_os = require("os");
4368
+ var import_node_os2 = require("os");
4243
4369
  var import_node_path4 = require("path");
4244
4370
  var TASK_NAME = process.env.THREADBASE_TASK_NAME ?? "Threadbase";
4245
4371
  function installDir() {
4246
- return process.env.THREADBASE_INSTALL_DIR ?? (0, import_node_path4.join)((0, import_node_os.homedir)(), ".threadbase");
4372
+ return process.env.THREADBASE_INSTALL_DIR ?? (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".threadbase");
4247
4373
  }
4248
4374
 
4249
4375
  // src/api/routes/logs.routes.ts
@@ -4254,22 +4380,22 @@ function resolveLogPath(source) {
4254
4380
  function pickDefaultSource() {
4255
4381
  for (const source of ["stdout", "stderr", "dev"]) {
4256
4382
  const p = resolveLogPath(source);
4257
- if ((0, import_node_fs3.existsSync)(p) && (0, import_node_fs3.statSync)(p).size > 0) return source;
4383
+ if ((0, import_node_fs4.existsSync)(p) && (0, import_node_fs4.statSync)(p).size > 0) return source;
4258
4384
  }
4259
4385
  return "stdout";
4260
4386
  }
4261
4387
  function readLogLines(filePath, sinceOffset, limit) {
4262
- if (!(0, import_node_fs3.existsSync)(filePath)) {
4388
+ if (!(0, import_node_fs4.existsSync)(filePath)) {
4263
4389
  return { lines: [], offset: 0, total: 0 };
4264
4390
  }
4265
- const fd = (0, import_node_fs3.openSync)(filePath, "r");
4391
+ const fd = (0, import_node_fs4.openSync)(filePath, "r");
4266
4392
  try {
4267
- const { size } = (0, import_node_fs3.fstatSync)(fd);
4393
+ const { size } = (0, import_node_fs4.fstatSync)(fd);
4268
4394
  if (size === 0) return { lines: [], offset: 0, total: 0 };
4269
4395
  const maxBytes = Math.min(size, 2 * 1024 * 1024);
4270
4396
  const start = size - maxBytes;
4271
4397
  const buf = Buffer.alloc(maxBytes);
4272
- (0, import_node_fs3.readSync)(fd, buf, 0, maxBytes, start);
4398
+ (0, import_node_fs4.readSync)(fd, buf, 0, maxBytes, start);
4273
4399
  let text = buf.toString("utf8");
4274
4400
  if (start > 0) {
4275
4401
  const firstNl = text.indexOf("\n");
@@ -4290,7 +4416,7 @@ function readLogLines(filePath, sinceOffset, limit) {
4290
4416
  }
4291
4417
  return { lines, offset: newOffset, total: allLines.length };
4292
4418
  } finally {
4293
- (0, import_node_fs3.closeSync)(fd);
4419
+ (0, import_node_fs4.closeSync)(fd);
4294
4420
  }
4295
4421
  }
4296
4422
  function createLogsRoutes() {
@@ -4302,7 +4428,7 @@ function createLogsRoutes() {
4302
4428
  const logPath = resolveLogPath(source);
4303
4429
  const sinceOffset = parseInt(c.req.query("since") || "0", 10);
4304
4430
  const limit = Math.min(parseInt(c.req.query("limit") || "100", 10) || 100, 1e3);
4305
- if (!(0, import_node_fs3.existsSync)(logPath)) {
4431
+ if (!(0, import_node_fs4.existsSync)(logPath)) {
4306
4432
  return c.json({
4307
4433
  logs: [],
4308
4434
  message: `No log file found for source=${source}`,
@@ -4312,7 +4438,7 @@ function createLogsRoutes() {
4312
4438
  });
4313
4439
  }
4314
4440
  const { lines, offset, total } = readLogLines(logPath, sinceOffset, limit);
4315
- const stats = (0, import_node_fs3.statSync)(logPath);
4441
+ const stats = (0, import_node_fs4.statSync)(logPath);
4316
4442
  return c.json({
4317
4443
  logs: lines,
4318
4444
  offset,
@@ -4339,10 +4465,10 @@ function createLogsRoutes() {
4339
4465
  try {
4340
4466
  const sources = ["stdout", "stderr", "dev"].map((source) => {
4341
4467
  const logPath = resolveLogPath(source);
4342
- if (!(0, import_node_fs3.existsSync)(logPath)) {
4468
+ if (!(0, import_node_fs4.existsSync)(logPath)) {
4343
4469
  return { source, exists: false, total: 0, fileSize: 0 };
4344
4470
  }
4345
- const stats = (0, import_node_fs3.statSync)(logPath);
4471
+ const stats = (0, import_node_fs4.statSync)(logPath);
4346
4472
  return {
4347
4473
  source,
4348
4474
  exists: true,
@@ -4369,8 +4495,8 @@ var import_hono11 = require("hono");
4369
4495
  var import_os6 = require("os");
4370
4496
 
4371
4497
  // src/config/update-config.ts
4372
- var import_node_fs4 = require("fs");
4373
- var import_node_os2 = require("os");
4498
+ var import_node_fs5 = require("fs");
4499
+ var import_node_os3 = require("os");
4374
4500
  var import_node_path6 = require("path");
4375
4501
  var import_yaml = require("yaml");
4376
4502
 
@@ -4387,12 +4513,12 @@ var UpdateConfigSchema = import_zod3.z.object({
4387
4513
  }).strict();
4388
4514
 
4389
4515
  // src/config/update-config.ts
4390
- var DEFAULT_CONFIG_PATH = (0, import_node_path6.join)((0, import_node_os2.homedir)(), ".threadbase", "update.yaml");
4516
+ var DEFAULT_CONFIG_PATH = (0, import_node_path6.join)((0, import_node_os3.homedir)(), ".threadbase", "update.yaml");
4391
4517
  function loadUpdateConfig(opts = {}) {
4392
4518
  const path = opts.path ?? DEFAULT_CONFIG_PATH;
4393
4519
  let raw;
4394
4520
  try {
4395
- raw = (0, import_node_fs4.readFileSync)(path, "utf-8");
4521
+ raw = (0, import_node_fs5.readFileSync)(path, "utf-8");
4396
4522
  } catch (err) {
4397
4523
  if (err.code === "ENOENT") return null;
4398
4524
  throw err;
@@ -5284,8 +5410,8 @@ function isAgentLine(line, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
5284
5410
  function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
5285
5411
  if (entrypoints.size === 0) return false;
5286
5412
  const key = cacheKey(filePath, entrypoints);
5287
- const cached2 = fileDecisionCache.get(key);
5288
- if (cached2 !== void 0) return cached2;
5413
+ const cached3 = fileDecisionCache.get(key);
5414
+ if (cached3 !== void 0) return cached3;
5289
5415
  let fd;
5290
5416
  try {
5291
5417
  fd = (0, import_fs9.openSync)(filePath, "r");
@@ -5941,9 +6067,9 @@ var ConversationCache = class _ConversationCache {
5941
6067
  classifyAgentFile(filePath, mtimeMs, fileSize) {
5942
6068
  if (this.agentEntrypoints.size === 0) return false;
5943
6069
  const entrypointsKey = this.agentEntrypointsKey();
5944
- const cached2 = this.stmts.getFileMetadata.get(filePath);
5945
- if (cached2 && cached2.mtime_ms === mtimeMs && cached2.file_size === fileSize && cached2.agent_entrypoints_key === entrypointsKey) {
5946
- return cached2.is_agent === 1;
6070
+ const cached3 = this.stmts.getFileMetadata.get(filePath);
6071
+ if (cached3 && cached3.mtime_ms === mtimeMs && cached3.file_size === fileSize && cached3.agent_entrypoints_key === entrypointsKey) {
6072
+ return cached3.is_agent === 1;
5947
6073
  }
5948
6074
  const isAgent = isAgentFile(filePath, this.agentEntrypoints);
5949
6075
  this.stmts.upsertFileMetadata.run({
@@ -6648,11 +6774,17 @@ var ConversationsRepository = class {
6648
6774
  };
6649
6775
 
6650
6776
  // src/db/repositories/managed-sessions.repository.ts
6777
+ var PROBE_SET_MAX = 200;
6778
+ var DIAGNOSTICS_MAX = 200;
6779
+ var TERMINAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
6651
6780
  var ManagedSessionsRepository = class {
6652
6781
  upsertStmt;
6653
6782
  updateStatusStmt;
6783
+ bindStmt;
6654
6784
  getStmt;
6655
6785
  listNonTerminalStmt;
6786
+ listAllStmt;
6787
+ pruneTerminalStmt;
6656
6788
  listRecoverableStmt;
6657
6789
  deleteStmt;
6658
6790
  constructor(db) {
@@ -6662,13 +6794,13 @@ var ManagedSessionsRepository = class {
6662
6794
  status, status_source, status_updated_at, started_at, completed_at,
6663
6795
  last_activity_at, prompt_count, session_name, project_id,
6664
6796
  bound_conversation_id, resumed_from_conversation_id, failure_reason,
6665
- streamer_instance_id
6797
+ streamer_instance_id, boot_token
6666
6798
  ) VALUES (
6667
6799
  @session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
6668
6800
  @status, @status_source, @status_updated_at, @started_at, @completed_at,
6669
6801
  @last_activity_at, @prompt_count, @session_name, @project_id,
6670
6802
  @bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
6671
- @streamer_instance_id
6803
+ @streamer_instance_id, @boot_token
6672
6804
  )
6673
6805
  ON CONFLICT(session_id) DO UPDATE SET
6674
6806
  pid = excluded.pid,
@@ -6687,7 +6819,8 @@ var ManagedSessionsRepository = class {
6687
6819
  bound_conversation_id = excluded.bound_conversation_id,
6688
6820
  resumed_from_conversation_id = excluded.resumed_from_conversation_id,
6689
6821
  failure_reason = excluded.failure_reason,
6690
- streamer_instance_id = excluded.streamer_instance_id
6822
+ streamer_instance_id = excluded.streamer_instance_id,
6823
+ boot_token = excluded.boot_token
6691
6824
  `);
6692
6825
  this.updateStatusStmt = db.prepare(`
6693
6826
  UPDATE managed_sessions
@@ -6701,11 +6834,27 @@ var ManagedSessionsRepository = class {
6701
6834
  session_name = COALESCE(@session_name, session_name)
6702
6835
  WHERE session_id = @session_id
6703
6836
  `);
6837
+ this.bindStmt = db.prepare(`
6838
+ UPDATE managed_sessions
6839
+ SET bound_conversation_id = @bound_conversation_id
6840
+ WHERE session_id = @session_id
6841
+ `);
6704
6842
  this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
6705
6843
  this.listNonTerminalStmt = db.prepare(`
6706
6844
  SELECT * FROM managed_sessions
6707
6845
  WHERE completed_at IS NULL
6708
6846
  ORDER BY started_at ASC
6847
+ LIMIT @limit
6848
+ `);
6849
+ this.listAllStmt = db.prepare(`
6850
+ SELECT * FROM managed_sessions
6851
+ ORDER BY status_updated_at DESC
6852
+ LIMIT @limit
6853
+ `);
6854
+ this.pruneTerminalStmt = db.prepare(`
6855
+ DELETE FROM managed_sessions
6856
+ WHERE completed_at IS NOT NULL
6857
+ AND completed_at < @before
6709
6858
  `);
6710
6859
  this.listRecoverableStmt = db.prepare(`
6711
6860
  SELECT * FROM managed_sessions
@@ -6738,7 +6887,10 @@ var ManagedSessionsRepository = class {
6738
6887
  bound_conversation_id: session.boundConversationId ?? null,
6739
6888
  resumed_from_conversation_id: session.resumedFromConversationId ?? null,
6740
6889
  failure_reason: session.failureReason ?? null,
6741
- streamer_instance_id: streamerInstanceId
6890
+ streamer_instance_id: streamerInstanceId,
6891
+ // Recorded, never backfilled: the pid above is only probeable while this
6892
+ // token still matches the running machine.
6893
+ boot_token: currentBootToken()
6742
6894
  });
6743
6895
  }
6744
6896
  /**
@@ -6759,12 +6911,48 @@ var ManagedSessionsRepository = class {
6759
6911
  session_name: fields.sessionName ?? null
6760
6912
  });
6761
6913
  }
6914
+ /**
6915
+ * Persist the Codex rollout id discovered after spawn.
6916
+ *
6917
+ * Its own statement rather than a `recordSpawn` re-run: the binding arrives
6918
+ * while the session is live, and re-upserting would also rewrite `cmdline`
6919
+ * with an id that is *not* in a fresh Codex process's argv, turning the
6920
+ * reconciler's identity check into a false `orphaned`. Without this write the
6921
+ * binding lives only in memory and dies with the streamer — which is the
6922
+ * whole reason a restarted Codex session could not be resumed (G6).
6923
+ */
6924
+ recordBinding(sessionId, boundConversationId) {
6925
+ this.bindStmt.run({
6926
+ session_id: sessionId,
6927
+ bound_conversation_id: boundConversationId
6928
+ });
6929
+ }
6762
6930
  get(sessionId) {
6763
6931
  return this.getStmt.get(sessionId) ?? null;
6764
6932
  }
6765
- /** Rows with no recorded completion — the reconciler's probe set. */
6766
- listNonTerminal() {
6767
- return this.listNonTerminalStmt.all();
6933
+ /**
6934
+ * Rows with no recorded completion — the reconciler's probe set.
6935
+ *
6936
+ * Capped. Callers must compare the result length against the limit and say so
6937
+ * when it clips: a silently truncated probe set reads as "we checked
6938
+ * everything" when it did not.
6939
+ */
6940
+ listNonTerminal(limit = PROBE_SET_MAX) {
6941
+ return this.listNonTerminalStmt.all({ limit });
6942
+ }
6943
+ /** Every row, most recently touched first, for the diagnostics surface. */
6944
+ listAll(limit = DIAGNOSTICS_MAX) {
6945
+ return this.listAllStmt.all({ limit });
6946
+ }
6947
+ /**
6948
+ * Delete terminal rows older than `olderThanMs`, returning how many went.
6949
+ *
6950
+ * Only rows carrying a `completed_at` are eligible, so nothing the reconciler
6951
+ * or rehydrator might still want is reachable from here — a row without one
6952
+ * is by definition unfinished business, however old it looks.
6953
+ */
6954
+ pruneTerminal(olderThanMs = TERMINAL_RETENTION_MS) {
6955
+ return this.pruneTerminalStmt.run({ before: Date.now() - olderThanMs }).changes;
6768
6956
  }
6769
6957
  /**
6770
6958
  * Rows a restart could bring back: still open, or closed by our own shutdown,
@@ -8733,8 +8921,14 @@ function readIdempotencyKey(body) {
8733
8921
  }
8734
8922
 
8735
8923
  // src/services/sessions/reconcileSessions.ts
8736
- async function classifySession(row, probe, currentInstanceId) {
8924
+ var PRE_BOOT_REASON = "recorded before this machine boot";
8925
+ async function classifySession(row, probe, currentInstanceId, currentBootToken2 = null) {
8737
8926
  const { session_id: sessionId } = row;
8927
+ const resumable = (reason) => resumeIdForRow(row) == null ? {
8928
+ sessionId,
8929
+ lifecycle: "failed",
8930
+ reason: "Codex session ended before its rollout id was known"
8931
+ } : { sessionId, lifecycle: "resumable", reason };
8738
8932
  if (row.completed_at != null) {
8739
8933
  const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
8740
8934
  return {
@@ -8744,18 +8938,19 @@ async function classifySession(row, probe, currentInstanceId) {
8744
8938
  };
8745
8939
  }
8746
8940
  if (row.pid == null) {
8747
- return { sessionId, lifecycle: "resumable", reason: "no pid recorded" };
8941
+ return resumable("no pid recorded");
8942
+ }
8943
+ if (currentBootToken2 != null && row.boot_token !== currentBootToken2) {
8944
+ return resumable(PRE_BOOT_REASON);
8748
8945
  }
8749
8946
  if (!probe.isPidAlive(row.pid)) {
8750
- const clean = probe.endedCleanly?.(row) ?? false;
8751
- if (clean) {
8947
+ if (probe.endedCleanly?.(row)) {
8752
8948
  return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
8753
8949
  }
8754
- return {
8755
- sessionId,
8756
- lifecycle: "resumable",
8757
- reason: "process gone, resumable from provider history"
8758
- };
8950
+ if (row.failure_reason != null) {
8951
+ return { sessionId, lifecycle: "failed", reason: "process gone, failure recorded" };
8952
+ }
8953
+ return resumable("process gone, resumable from provider history");
8759
8954
  }
8760
8955
  const args = await probe.getProcessArgs(row.pid);
8761
8956
  const token = row.cmdline;
@@ -8773,52 +8968,10 @@ async function classifySession(row, probe, currentInstanceId) {
8773
8968
  reason: sameRun ? "owned by this run" : "survived a previous streamer run"
8774
8969
  };
8775
8970
  }
8776
- async function reconcileSessions(rows, probe, currentInstanceId) {
8777
- return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
8778
- }
8779
-
8780
- // src/services/sessions/rehydrateSessions.ts
8781
- var REHYDRATE_MAX = 25;
8782
- var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
8783
- var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
8784
- function shouldRehydrate(row, opts) {
8785
- if (!opts.projectExists(row.project_path)) return false;
8786
- if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
8787
- if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
8788
- return true;
8789
- }
8790
- function rowToStubSession(row) {
8791
- return {
8792
- id: row.session_id,
8793
- provider: row.provider,
8794
- projectPath: row.project_path,
8795
- projectName: row.project_name,
8796
- branch: row.branch,
8797
- // No PTY exists for a stub, so this is the only truthful status.
8798
- status: "idle",
8799
- startedAt: new Date(row.started_at),
8800
- completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
8801
- promptCount: row.prompt_count,
8802
- lastOutput: "",
8803
- rehydrated: true,
8804
- ...row.session_name != null && { sessionName: row.session_name },
8805
- ...row.project_id != null && { projectId: row.project_id },
8806
- ...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
8807
- ...row.resumed_from_conversation_id != null && {
8808
- resumedFromConversationId: row.resumed_from_conversation_id
8809
- },
8810
- ...row.failure_reason != null && { failureReason: row.failure_reason },
8811
- ...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
8812
- // Only `shutdown` crosses over. It is the one registry source that is also a
8813
- // wire StatusSource *and* that genuinely describes the `idle` above — the
8814
- // streamer stopped this session. A crashed row still says `transition` over
8815
- // a `running` status, and copying that here would attach observed-confidence
8816
- // provenance to a status we derived at boot, so leave it unset instead.
8817
- ...row.status_source === "shutdown" && {
8818
- statusSource: "shutdown",
8819
- statusUpdatedAt: new Date(row.status_updated_at)
8820
- }
8821
- };
8971
+ async function reconcileSessions(rows, probe, currentInstanceId, currentBootToken2 = null) {
8972
+ return Promise.all(
8973
+ rows.map((row) => classifySession(row, probe, currentInstanceId, currentBootToken2))
8974
+ );
8822
8975
  }
8823
8976
 
8824
8977
  // src/types.ts
@@ -9009,7 +9162,8 @@ function managedToResponse(s, ptyAttached) {
9009
9162
  ...s.resumedFromConversationId != null && {
9010
9163
  resumedFromConversationId: s.resumedFromConversationId
9011
9164
  },
9012
- ...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
9165
+ ...s.boundConversationId != null && { boundConversationId: s.boundConversationId },
9166
+ ...s.interruptedStatus != null && { interruptedStatus: s.interruptedStatus }
9013
9167
  };
9014
9168
  }
9015
9169
  function discoveredToResponse(d, conversationId) {
@@ -9554,7 +9708,7 @@ var StreamerServer = class {
9554
9708
  idempotency = new IdempotencyStore();
9555
9709
  // sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
9556
9710
  // this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
9557
- sessionLifecycles = /* @__PURE__ */ new Map();
9711
+ sessionVerdicts = /* @__PURE__ */ new Map();
9558
9712
  // Periodic sweep that releases PTYs no agent is using. Null until listen().
9559
9713
  idleReaperTimer = null;
9560
9714
  // Map of clientId → WS socket (populated by the "register" WS handshake)
@@ -9813,7 +9967,14 @@ var StreamerServer = class {
9813
9967
  this.sessionStore.updateManaged(session.id, {
9814
9968
  status: session.status,
9815
9969
  completedAt: session.completedAt,
9816
- ...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
9970
+ ...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt },
9971
+ // The runner derives this from the first user message, on its own
9972
+ // copy of the session. Without mirroring it here SessionStore never
9973
+ // learns it, so a fresh live session is served with no sessionName
9974
+ // even though the registry has one — the name only appeared after a
9975
+ // restart rebuilt the row as a stub. Guarded so a runner that has not
9976
+ // derived one yet cannot blank a name set by enrichResumedSessionAsync.
9977
+ ...session.sessionName != null && { sessionName: session.sessionName }
9817
9978
  });
9818
9979
  this.managedSessionsRepo?.recordStatus(
9819
9980
  session.id,
@@ -9920,6 +10081,8 @@ var StreamerServer = class {
9920
10081
  sessionsRepo: () => this.sessionsRepo,
9921
10082
  cacheMetadataRepo: () => this.cacheMetadataRepo,
9922
10083
  runtimeStore: () => this.runtimeStore,
10084
+ managedSessionsRepo: () => this.managedSessionsRepo,
10085
+ sessionVerdicts: () => this.sessionVerdicts,
9923
10086
  ptyAttachedIds: () => this.ptyAttachedIds(),
9924
10087
  handleListSessions: (url, res) => this.handleListSessions(url, res),
9925
10088
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -10117,12 +10280,12 @@ var StreamerServer = class {
10117
10280
  * it.
10118
10281
  */
10119
10282
  withReconciledLifecycle(sessions) {
10120
- if (this.sessionLifecycles.size === 0) return sessions;
10283
+ if (this.sessionVerdicts.size === 0) return sessions;
10121
10284
  return sessions.map((s) => {
10122
10285
  if (s.ptyAttached) return s;
10123
- const verdict = this.sessionLifecycles.get(s.id);
10286
+ const verdict = this.sessionVerdicts.get(s.id);
10124
10287
  if (!verdict) return s;
10125
- return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
10288
+ return { ...s, lifecycle: verdict.lifecycle, lifecycleSource: "reconcile" };
10126
10289
  });
10127
10290
  }
10128
10291
  addSessionSubscriber(sessionId, ws) {
@@ -10190,14 +10353,30 @@ var StreamerServer = class {
10190
10353
  let verdicts = [];
10191
10354
  try {
10192
10355
  const rows = this.managedSessionsRepo.listNonTerminal();
10356
+ if (rows.length === PROBE_SET_MAX) {
10357
+ this.log.warn(
10358
+ `[reconcile] probe set hit its cap of ${PROBE_SET_MAX} \u2014 older rows skipped`,
10359
+ {
10360
+ event: "registry.probe_truncated",
10361
+ limit: PROBE_SET_MAX
10362
+ }
10363
+ );
10364
+ }
10193
10365
  if (rows.length === 0) return [];
10194
10366
  verdicts = await reconcileSessions(
10195
10367
  rows,
10196
10368
  { isPidAlive, getProcessArgs },
10197
- this.streamerInstanceId
10369
+ this.streamerInstanceId,
10370
+ currentBootToken()
10198
10371
  );
10199
10372
  for (const v of verdicts) {
10200
- this.sessionLifecycles.set(v.sessionId, v.lifecycle);
10373
+ this.sessionVerdicts.set(v.sessionId, v);
10374
+ if (v.reason === PRE_BOOT_REASON) {
10375
+ this.log.info(`[reconcile] ${v.sessionId} predates this machine boot \u2014 pid not probed`, {
10376
+ event: "sessions.boot_token_mismatch",
10377
+ sessionId: v.sessionId
10378
+ });
10379
+ }
10201
10380
  if (v.lifecycle === "completed" || v.lifecycle === "failed") {
10202
10381
  this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
10203
10382
  completedAt: /* @__PURE__ */ new Date()
@@ -10219,6 +10398,30 @@ var StreamerServer = class {
10219
10398
  }
10220
10399
  return verdicts;
10221
10400
  }
10401
+ /**
10402
+ * Drop finished sessions the registry has held long enough (plan Phase 4).
10403
+ *
10404
+ * The registry is authoritative and never rebuilt from the cache, so nothing
10405
+ * else would ever remove a row: without this it grows for the life of the
10406
+ * install, and every boot pays for rows about sessions from months ago.
10407
+ */
10408
+ pruneTerminalSessions() {
10409
+ if (!this.managedSessionsRepo) return;
10410
+ try {
10411
+ const pruned = this.managedSessionsRepo.pruneTerminal();
10412
+ if (pruned > 0) {
10413
+ this.log.info(`[registry] pruned ${pruned} terminal session row(s)`, {
10414
+ event: "registry.pruned",
10415
+ pruned
10416
+ });
10417
+ }
10418
+ } catch (err) {
10419
+ this.log.warn("[registry] failed to prune terminal sessions", {
10420
+ event: "registry.prune_failed",
10421
+ err
10422
+ });
10423
+ }
10424
+ }
10222
10425
  /**
10223
10426
  * Seed the session list with what previous runs left behind (persistence plan
10224
10427
  * Phase 1, gaps G1/G2/G8).
@@ -10245,15 +10448,29 @@ var StreamerServer = class {
10245
10448
  const truncated = rows.length > REHYDRATE_MAX;
10246
10449
  const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
10247
10450
  if (candidates.length === 0) return;
10248
- const lifecycleByVerdict = new Map(verdicts.map((v) => [v.sessionId, v.lifecycle]));
10451
+ const verdictById = new Map(verdicts.map((v) => [v.sessionId, v]));
10249
10452
  let rehydrated = 0;
10453
+ const skippedBy = {};
10250
10454
  for (const row of candidates) {
10251
10455
  if (this.sessionStore.getManaged(row.session_id)) continue;
10252
- if (!shouldRehydrate(row, { now, projectExists: import_fs19.existsSync })) continue;
10456
+ const skip = rehydrateSkipReason(row, { now, projectExists: import_fs19.existsSync });
10457
+ if (skip) {
10458
+ skippedBy[skip] = (skippedBy[skip] ?? 0) + 1;
10459
+ this.log.info(`[rehydrate] skipped ${row.session_id}: ${skip}`, {
10460
+ event: "sessions.rehydrate_skipped",
10461
+ sessionId: row.session_id,
10462
+ reason: skip
10463
+ });
10464
+ continue;
10465
+ }
10253
10466
  this.sessionStore.addManaged(rowToStubSession(row));
10254
- this.sessionLifecycles.set(
10467
+ this.sessionVerdicts.set(
10255
10468
  row.session_id,
10256
- lifecycleByVerdict.get(row.session_id) ?? "resumable"
10469
+ verdictById.get(row.session_id) ?? {
10470
+ sessionId: row.session_id,
10471
+ lifecycle: "resumable",
10472
+ reason: "recovered from the registry at boot"
10473
+ }
10257
10474
  );
10258
10475
  if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
10259
10476
  rehydrated++;
@@ -10262,6 +10479,7 @@ var StreamerServer = class {
10262
10479
  event: "sessions.rehydrated",
10263
10480
  rehydrated,
10264
10481
  skipped: candidates.length - rehydrated,
10482
+ skippedBy,
10265
10483
  truncated
10266
10484
  });
10267
10485
  } catch (err) {
@@ -10353,7 +10571,7 @@ var StreamerServer = class {
10353
10571
  const now = /* @__PURE__ */ new Date();
10354
10572
  for (const session of this.ptyManager.listSessions()) {
10355
10573
  try {
10356
- this.managedSessionsRepo.recordStatus(session.id, "idle", "shutdown", {
10574
+ this.managedSessionsRepo.recordStatus(session.id, session.status, "shutdown", {
10357
10575
  completedAt: now,
10358
10576
  lastActivityAt: session.lastActivityAt ?? null,
10359
10577
  promptCount: session.promptCount
@@ -10591,7 +10809,10 @@ var StreamerServer = class {
10591
10809
  );
10592
10810
  this.scannerPersistenceDisabled = true;
10593
10811
  }
10594
- void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
10812
+ void this.reconcilePreviousSessions().then((v) => {
10813
+ this.rehydratePreviousSessions(v);
10814
+ this.pruneTerminalSessions();
10815
+ });
10595
10816
  if (this.skipStartupWarmup) {
10596
10817
  this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
10597
10818
  event: "cache.warmup_skipped"
@@ -12159,32 +12380,85 @@ var StreamerServer = class {
12159
12380
  json(res, 400, { error: "Missing sessionId" });
12160
12381
  return;
12161
12382
  }
12383
+ const outcome = await this.resumeSession({
12384
+ sessionId,
12385
+ force: body.force === true,
12386
+ projectName: body.projectName,
12387
+ branch: body.branch
12388
+ });
12389
+ if (!outcome.ok) {
12390
+ switch (outcome.reason) {
12391
+ case "history_file_missing":
12392
+ json(res, 404, {
12393
+ error: "Conversation history file is missing; it can no longer be resumed",
12394
+ code: "history_file_missing"
12395
+ });
12396
+ return;
12397
+ case "no_project_path":
12398
+ json(res, 400, { error: "Could not determine project path" });
12399
+ return;
12400
+ case "conversation_busy":
12401
+ json(res, 409, {
12402
+ error: "This conversation looks active in another session",
12403
+ code: "CONVERSATION_BUSY",
12404
+ detectedBy: outcome.detectedBy,
12405
+ lastActivityMs: outcome.lastActivityMs,
12406
+ likelyOwner: outcome.likelyOwner
12407
+ });
12408
+ return;
12409
+ }
12410
+ }
12411
+ if (outcome.alreadyRunning) {
12412
+ json(res, 200, outcome.response);
12413
+ return;
12414
+ }
12415
+ this.broadcastOrUnicastSessionList(req);
12416
+ json(res, 201, outcome.response ?? outcome.session);
12417
+ }
12418
+ /**
12419
+ * Resume a session, from an HTTP request or from the boot path.
12420
+ *
12421
+ * Extracted from `handleResume` so both callers hit the **same collision
12422
+ * probe** (plan Phase 7c). The probe is what stops this streamer attaching to
12423
+ * a conversation an external terminal already owns; a second, hand-adapted
12424
+ * copy of this sequence in the boot path is how two agents end up appending
12425
+ * to one JSONL at 4am with nobody watching.
12426
+ *
12427
+ * Returns a typed reason rather than writing a response, so the HTTP caller
12428
+ * maps it to a status code and the boot caller logs it.
12429
+ */
12430
+ async resumeSession(opts) {
12431
+ const { sessionId } = opts;
12162
12432
  if (this.ptyManager.hasSession(sessionId)) {
12163
- const resp2 = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12164
- if (resp2) {
12165
- json(res, 200, resp2);
12166
- return;
12433
+ const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
12434
+ if (resp) {
12435
+ return { ok: true, alreadyRunning: true, session: null, response: resp };
12436
+ }
12437
+ }
12438
+ let jsonlPath = this.findJsonlPath(sessionId);
12439
+ let conv = await this.findConversationByUuid(sessionId);
12440
+ let historyId = sessionId;
12441
+ let registryProvider;
12442
+ if (!jsonlPath && !conv) {
12443
+ const row = this.managedSessionsRepo?.get(sessionId) ?? null;
12444
+ const boundId = row ? resumeIdForRow(row) : null;
12445
+ if (boundId != null && boundId !== sessionId) {
12446
+ historyId = boundId;
12447
+ registryProvider = row?.provider;
12448
+ jsonlPath = this.findJsonlPath(boundId);
12449
+ conv = await this.findConversationByUuid(boundId);
12167
12450
  }
12168
12451
  }
12169
- const jsonlPath = this.findJsonlPath(sessionId);
12170
12452
  const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
12171
- const conv = await this.findConversationByUuid(sessionId);
12172
12453
  const projectPath = jsonlCwd ?? conv?.projectPath;
12173
12454
  if (!projectPath) {
12174
- if (!conv && !jsonlPath) {
12175
- json(res, 404, {
12176
- error: "Conversation history file is missing; it can no longer be resumed",
12177
- code: "history_file_missing"
12178
- });
12179
- return;
12180
- }
12181
- json(res, 400, { error: "Could not determine project path" });
12182
- return;
12455
+ if (!conv && !jsonlPath) return { ok: false, reason: "history_file_missing" };
12456
+ return { ok: false, reason: "no_project_path" };
12183
12457
  }
12184
12458
  let discovered = [];
12185
- const cached2 = this.discoveryCache;
12186
- if (cached2 && Date.now() - cached2.fetchedAt < DISCOVERY_TTL_MS) {
12187
- discovered = cached2.entries;
12459
+ const cached3 = this.discoveryCache;
12460
+ if (cached3 && Date.now() - cached3.fetchedAt < DISCOVERY_TTL_MS) {
12461
+ discovered = cached3.entries;
12188
12462
  } else {
12189
12463
  try {
12190
12464
  discovered = await Promise.race([
@@ -12200,45 +12474,50 @@ var StreamerServer = class {
12200
12474
  }
12201
12475
  }
12202
12476
  const busy = conversationBusy({
12203
- conversationId: sessionId,
12477
+ // The id another owner's argv would actually carry — for a placeholder
12478
+ // that is the bound rollout id, not the one the client asked for.
12479
+ conversationId: historyId,
12204
12480
  projectPath,
12205
12481
  jsonlPath,
12206
12482
  discovered,
12207
12483
  windowMs: resolveResumeBusyWindowMs(),
12208
12484
  selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
12209
12485
  });
12210
- if (busy.busy && body.force !== true) {
12211
- json(res, 409, {
12212
- error: "This conversation looks active in another session",
12213
- code: "CONVERSATION_BUSY",
12486
+ if (busy.busy && opts.force !== true) {
12487
+ return {
12488
+ ok: false,
12489
+ reason: "conversation_busy",
12214
12490
  detectedBy: busy.detectedBy,
12215
12491
  lastActivityMs: busy.lastActivityMs,
12216
12492
  likelyOwner: busy.likelyOwner
12217
- });
12218
- return;
12493
+ };
12219
12494
  }
12220
12495
  if (busy.busy) {
12221
12496
  this.contendedSessions.add(sessionId);
12222
12497
  }
12223
- const cachedConvMeta = this.cache?.getMetaById(sessionId);
12224
- const provider = coerceProviderForRunner(conv?.provider ?? cachedConvMeta?.provider);
12498
+ const cachedConvMeta = this.cache?.getMetaById(historyId);
12499
+ const provider = coerceProviderForRunner(
12500
+ conv?.provider ?? cachedConvMeta?.provider ?? registryProvider
12501
+ );
12225
12502
  this.discoveryCache = null;
12226
12503
  const session = await this.ptyManager.start(sessionId, {
12227
12504
  provider,
12228
12505
  projectPath,
12229
- projectName: body.projectName,
12230
- branch: body.branch,
12506
+ projectName: opts.projectName,
12507
+ branch: opts.branch,
12508
+ // Omitted on every ordinary resume, so argv is unchanged there.
12509
+ ...historyId !== sessionId && { resumeId: historyId },
12231
12510
  claudeFlags: this.claudeFlags,
12232
12511
  claudeExtraArgs: this.claudeExtraArgs,
12233
12512
  ...this.spawnFlagOverrides()
12234
12513
  });
12514
+ if (historyId !== sessionId) session.boundConversationId = historyId;
12235
12515
  this.sessionStore.addManaged(session);
12236
12516
  this.recordSessionSpawn(session);
12237
- void this.watchConversationFile(sessionId);
12238
- const resp = this.sessionStore.get(session.id, this.ptyAttachedIds());
12239
- this.broadcastOrUnicastSessionList(req);
12240
- json(res, 201, resp ?? session);
12517
+ void this.watchConversationFile(sessionId, historyId);
12518
+ const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
12241
12519
  this.enrichResumedSessionAsync(sessionId, projectPath, conv);
12520
+ return { ok: true, alreadyRunning: false, session, response };
12242
12521
  }
12243
12522
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
12244
12523
  try {
@@ -12251,18 +12530,18 @@ var StreamerServer = class {
12251
12530
  session.filePath = conv.filePath ?? void 0;
12252
12531
  }
12253
12532
  if (!this.cache || !this.projectsRepo || !this.conversationsRepo) return;
12254
- const cached2 = this.cache.getMetaById(sessionId);
12255
- if (cached2) {
12256
- session.model = cached2.model ?? void 0;
12257
- session.preview = cached2.preview ?? void 0;
12258
- const first = cached2.firstMessage ? JSON.parse(cached2.firstMessage) : null;
12259
- const last = cached2.lastMessage ? JSON.parse(cached2.lastMessage) : null;
12533
+ const cached3 = this.cache.getMetaById(sessionId);
12534
+ if (cached3) {
12535
+ session.model = cached3.model ?? void 0;
12536
+ session.preview = cached3.preview ?? void 0;
12537
+ const first = cached3.firstMessage ? JSON.parse(cached3.firstMessage) : null;
12538
+ const last = cached3.lastMessage ? JSON.parse(cached3.lastMessage) : null;
12260
12539
  session.firstMessageText = first?.text ?? void 0;
12261
12540
  session.firstMessageAt = first?.timestamp ? new Date(first.timestamp).toISOString() : void 0;
12262
12541
  session.lastMessageText = last?.text ?? void 0;
12263
12542
  session.lastMessageAt = last?.timestamp ? new Date(last.timestamp).toISOString() : void 0;
12264
12543
  }
12265
- let resolvedProjectId = cached2?.projectId ?? null;
12544
+ let resolvedProjectId = cached3?.projectId ?? null;
12266
12545
  if (!resolvedProjectId) {
12267
12546
  const project = this.projectsRepo.upsertProjectByPath(projectPath);
12268
12547
  resolvedProjectId = project.id;
@@ -12834,9 +13113,12 @@ var StreamerServer = class {
12834
13113
  }
12835
13114
  }
12836
13115
  // ─── File Watcher Wiring ─────────────────────────────────────────
12837
- async watchConversationFile(sessionId) {
13116
+ // `historyId` is the id the provider filed the history under, which for a
13117
+ // fresh Codex session is its rollout id rather than our placeholder. The map
13118
+ // stays keyed by `sessionId` — that is what broadcasts resolve against.
13119
+ async watchConversationFile(sessionId, historyId = sessionId) {
12838
13120
  try {
12839
- const conversation = await this.findConversationByUuid(sessionId);
13121
+ const conversation = await this.findConversationByUuid(historyId);
12840
13122
  if (conversation?.filePath) {
12841
13123
  this.sessionFileMap.set(sessionId, conversation.filePath);
12842
13124
  this.fileWatcher.watch(conversation.filePath);
@@ -12999,6 +13281,15 @@ var StreamerServer = class {
12999
13281
  const codexSessionId = match.id;
13000
13282
  cleanup();
13001
13283
  this.sessionStore.updateManaged(sessionId, { boundConversationId: codexSessionId });
13284
+ try {
13285
+ this.managedSessionsRepo?.recordBinding(sessionId, codexSessionId);
13286
+ } catch (err) {
13287
+ this.log.warn("[registry] failed to record Codex rollout binding", {
13288
+ event: "registry.binding_write_failed",
13289
+ sessionId,
13290
+ err
13291
+ });
13292
+ }
13002
13293
  this.sessionFileMap.set(sessionId, candidatePath);
13003
13294
  this.fileWatcher.watch(candidatePath);
13004
13295
  try {