@rubytech/create-maxy-code 0.1.90 → 0.1.92

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.
Files changed (54) hide show
  1. package/dist/index.js +31 -0
  2. package/dist/port-resolution.js +1 -1
  3. package/package.json +1 -1
  4. package/payload/platform/package-lock.json +18 -4
  5. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +4 -3
  6. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  7. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +159 -219
  8. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  9. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +9 -0
  10. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  11. package/payload/platform/services/claude-session-manager/dist/http-server.js +262 -67
  12. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/index.js +10 -0
  14. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  15. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +10 -4
  16. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +27 -8
  18. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +8 -0
  20. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +11 -0
  22. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  24. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +14 -6
  25. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  26. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +2 -0
  27. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  28. package/payload/platform/services/claude-session-manager/dist/reaper.js +6 -3
  29. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  30. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +6 -4
  31. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  32. package/payload/platform/services/claude-session-manager/dist/session-store.js +8 -10
  33. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  34. package/payload/platform/services/claude-session-manager/dist/types.d.ts +0 -4
  35. package/payload/platform/services/claude-session-manager/dist/types.d.ts.map +1 -1
  36. package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts +39 -0
  37. package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts.map +1 -0
  38. package/payload/platform/services/claude-session-manager/dist/user-title-store.js +138 -0
  39. package/payload/platform/services/claude-session-manager/dist/user-title-store.js.map +1 -0
  40. package/payload/premium-plugins/real-agent/plugins/brochures/skills/make-brochure/SKILL.md +1 -1
  41. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +3 -0
  42. package/payload/server/public/assets/admin-BhEvxMrC.js +217 -0
  43. package/payload/server/public/assets/{data-3n20lir5.js → data-Cbo0tTS3.js} +1 -1
  44. package/payload/server/public/assets/{graph-ChwMpxn_.js → graph-Beucu3_J.js} +1 -1
  45. package/payload/server/public/assets/graph-labels-D15-eFQK.js +1 -0
  46. package/payload/server/public/assets/{page-DNjUtT5q.js → page-Bu8kO4jO.js} +2 -2
  47. package/payload/server/public/assets/{page-D3bFmqF4.js → page-C1hj89Gf.js} +1 -1
  48. package/payload/server/public/data.html +3 -3
  49. package/payload/server/public/graph.html +3 -3
  50. package/payload/server/public/index.html +4 -4
  51. package/payload/server/server-init.cjs +35 -3
  52. package/payload/server/server.js +33 -0
  53. package/payload/server/public/assets/admin-C57bDUgs.js +0 -217
  54. package/payload/server/public/assets/graph-labels-CUgJgIwl.js +0 -1
@@ -26,8 +26,9 @@ import { stream } from 'hono/streaming';
26
26
  import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
27
27
  import { spawnClaudeSession, stopSession, PERMISSION_MODES } from './pty-spawner.js';
28
28
  import { readJsonlSession } from './jsonl-enumerator.js';
29
- import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
29
+ import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId } from './jsonl-path.js';
30
30
  import { basename, join } from 'node:path';
31
+ import { validateUserTitle } from './user-title-store.js';
31
32
  const ROLES = ['admin', 'public'];
32
33
  const CHANNELS = ['browser', 'whatsapp', 'telegram', 'webchat', 'email'];
33
34
  // Task 179 — resume idempotency. Two POSTs 9ms apart with the same
@@ -169,7 +170,8 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
169
170
  if (session.specialist !== AUTO_ARCHIVE_SPECIALIST)
170
171
  return;
171
172
  let archived = false;
172
- function endTurnSeen(jsonlPath) {
173
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, session.sessionId);
174
+ function endTurnSeen() {
173
175
  try {
174
176
  const content = readFileSync(jsonlPath, 'utf8');
175
177
  return content.includes(END_TURN_SIGNATURE);
@@ -178,19 +180,18 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
178
180
  return false;
179
181
  }
180
182
  }
181
- function tryArchive(jsonlPath) {
183
+ function tryArchive() {
182
184
  if (archived)
183
185
  return;
184
186
  if (!existsSync(jsonlPath))
185
187
  return;
186
- if (!endTurnSeen(jsonlPath))
188
+ if (!endTurnSeen())
187
189
  return;
188
190
  archived = true;
189
191
  deps.logger(`auto-archive sessionId=${session.sessionId} specialist=${AUTO_ARCHIVE_SPECIALIST} reason=end-turn archivedAt=${new Date().toISOString()}`);
190
192
  void stopSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, session.sessionId);
191
193
  }
192
- if (session.jsonlPath)
193
- tryArchive(session.jsonlPath);
194
+ tryArchive();
194
195
  const unsubscribe = deps.watcher.subscribe((event) => {
195
196
  if (archived)
196
197
  return;
@@ -198,20 +199,18 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
198
199
  return;
199
200
  if (event.source !== 'jsonl')
200
201
  return;
201
- const path = event.row?.jsonlPath ?? session.jsonlPath;
202
- if (!path)
203
- return;
204
- tryArchive(path);
202
+ tryArchive();
205
203
  });
206
204
  session.pty.onExit(() => unsubscribe());
207
205
  }
208
206
  function attachPublicAudit(deps, session) {
209
207
  if (session.role !== 'public')
210
208
  return;
211
- if (session.jsonlPath === null)
212
- return;
213
209
  const claudeSessionId = session.sessionId;
214
- deps.publicToolAudit.attach(claudeSessionId, session.jsonlPath);
210
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, claudeSessionId);
211
+ if (!existsSync(jsonlPath))
212
+ return;
213
+ deps.publicToolAudit.attach(claudeSessionId, jsonlPath);
215
214
  if (session.exited) {
216
215
  deps.publicToolAudit.detach(claudeSessionId);
217
216
  return;
@@ -235,22 +234,29 @@ export function buildHttpApp(deps) {
235
234
  listener(sessionId);
236
235
  }
237
236
  app.__notifyUrlCapturedForTests = notifyUrlCaptured;
238
- // Watcher subscriber: when JSONL CREATE fires for a live session,
239
- // patch jsonlPath onto the StoredSession and attach the audit watcher.
237
+ // Task 253 same fan-out shape for rename. The POST /:sessionId/rename
238
+ // route writes the user-title store and then calls notifyRename; each
239
+ // /events handler then re-emits a `row-updated` frame so every browser
240
+ // tab sees the new label without re-fetching /list.
241
+ const renameListeners = new Set();
242
+ function notifyRename(sessionId) {
243
+ for (const listener of renameListeners)
244
+ listener(sessionId);
245
+ }
246
+ app.__notifyRenameForTests = notifyRename;
247
+ // Watcher subscriber: when the JSONL CREATE event fires for a live
248
+ // session, attach the public-audit watcher. The path is derived
249
+ // (Task 258 — StoredSession no longer carries jsonlPath).
240
250
  deps.watcher.subscribe((event) => {
241
251
  if (event.source !== 'jsonl')
242
252
  return;
243
253
  if (event.kind !== 'create' && event.kind !== 'modify')
244
254
  return;
245
- if (!event.row || !event.row.jsonlPath)
255
+ if (!event.row)
246
256
  return;
247
257
  const live = deps.store.get(event.sessionId);
248
258
  if (!live)
249
259
  return;
250
- if (live.jsonlPath !== null)
251
- return;
252
- live.jsonlPath = event.row.jsonlPath;
253
- deps.logger(`[fs-watcher] live-row-jsonl-attached sessionId=${event.sessionId} path=${event.row.jsonlPath}`);
254
260
  attachPublicAudit(deps, live);
255
261
  });
256
262
  // Task 183 — dispatch jsonl modify/create events into the recorder
@@ -258,7 +264,8 @@ export function buildHttpApp(deps) {
258
264
  // database-operator spawns are registered), so operator turns pass
259
265
  // through without producing recorder-* lines. The tail's append-only
260
266
  // cursor ensures repeated modify events for the same JSONL only read
261
- // newly-appended bytes.
267
+ // newly-appended bytes. Path is derived from sessionId + spawnCwd
268
+ // (Task 258).
262
269
  if (deps.jsonlTail) {
263
270
  const tail = deps.jsonlTail;
264
271
  deps.watcher.subscribe((event) => {
@@ -266,22 +273,48 @@ export function buildHttpApp(deps) {
266
273
  return;
267
274
  if (event.kind !== 'create' && event.kind !== 'modify')
268
275
  return;
269
- const path = event.row?.jsonlPath;
270
- if (!path)
276
+ const path = jsonlPathForSessionId(deps.spawnCwd, event.sessionId);
277
+ if (!existsSync(path))
271
278
  return;
272
279
  tail.onJsonlModify(event.sessionId, path);
273
280
  });
274
281
  }
275
- function liveDisplayName(s) {
276
- if (s.jsonlPath && existsSync(s.jsonlPath)) {
277
- const enriched = readJsonlSession(deps.spawnCwd, s.sessionId, LIST_TAIL_BYTES, deps.logger);
278
- if (enriched?.aiTitle?.trim())
279
- return enriched.aiTitle.trim();
282
+ /** Task 253 — resolve (displayName, titleSource) for a live PTY.
283
+ * Three cases:
284
+ * - JSONL exists: read the enumerator's merged result (the enumerator
285
+ * consults the user-title store; one resolution site for both layers).
286
+ * - JSONL missing but user-title-store has an entry: pre-jsonl rename —
287
+ * operator renamed a brand-new row before the first turn flushed.
288
+ * - Neither: null + null. */
289
+ function liveTitleAndSource(s) {
290
+ const path = jsonlPathForSessionId(deps.spawnCwd, s.sessionId);
291
+ if (existsSync(path)) {
292
+ const enriched = readJsonlSession(deps.spawnCwd, s.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
293
+ if (enriched)
294
+ return { displayName: enriched.displayName, titleSource: enriched.titleSource };
280
295
  }
281
- return null;
296
+ const userTitle = deps.userTitleStore.get(s.sessionId)?.title;
297
+ if (userTitle)
298
+ return { displayName: userTitle, titleSource: 'user' };
299
+ return { displayName: null, titleSource: null };
300
+ }
301
+ /** Task 258/259 — derived: a session is "archived" when its canonical
302
+ * top-level jsonl is absent but the archive/ variant exists. After the
303
+ * Task 258 refactor the watcher's row index doesn't surface archive/
304
+ * rows; the field stays on the wire so the Sidebar/SessionMetadataPane
305
+ * Unarchive affordance remains type-stable. A future task that
306
+ * enumerates archive/ jsonls into the row index will populate this
307
+ * honestly for archived rows. */
308
+ function isArchivedJsonlPath(sessionId) {
309
+ const canonical = jsonlPathForSessionId(deps.spawnCwd, sessionId);
310
+ if (existsSync(canonical))
311
+ return false;
312
+ return existsSync(jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true }));
282
313
  }
283
314
  function toLivePayload(s) {
284
- const hasJsonl = s.jsonlPath !== null && existsSync(s.jsonlPath);
315
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, s.sessionId);
316
+ const hasJsonl = existsSync(jsonlPath);
317
+ const title = liveTitleAndSource(s);
285
318
  return {
286
319
  sessionId: s.sessionId,
287
320
  pid: s.pid,
@@ -290,13 +323,15 @@ export function buildHttpApp(deps) {
290
323
  senderId: s.senderId,
291
324
  role: s.role,
292
325
  channel: s.channel,
293
- jsonlPath: s.jsonlPath,
326
+ jsonlPath: hasJsonl ? jsonlPath : null,
294
327
  status: 'alive',
295
- displayName: liveDisplayName(s),
328
+ archived: false,
329
+ displayName: title.displayName,
330
+ titleSource: title.titleSource,
296
331
  lastMessageAt: null,
297
332
  turnCount: 0,
298
333
  capped: false,
299
- sizeBytes: hasJsonl && s.jsonlPath ? statSync(s.jsonlPath).size : null,
334
+ sizeBytes: hasJsonl ? statSync(jsonlPath).size : null,
300
335
  hidden: s.hidden,
301
336
  specialist: s.specialist,
302
337
  model: null,
@@ -304,16 +339,20 @@ export function buildHttpApp(deps) {
304
339
  }
305
340
  function toArchivedPayload(row, senderId) {
306
341
  let displayName = null;
342
+ let titleSource = null;
307
343
  let lastMessageAt = null;
308
344
  let turnCount = 0;
309
345
  let capped = false;
310
346
  let sizeBytes = null;
311
347
  let model = null;
312
348
  let startedAt = new Date(row.updatedAt || 0).toISOString();
313
- if (row.jsonlPath && existsSync(row.jsonlPath)) {
314
- const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger);
349
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
350
+ const hasJsonl = existsSync(jsonlPath);
351
+ if (hasJsonl) {
352
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
315
353
  if (enriched) {
316
354
  displayName = enriched.displayName;
355
+ titleSource = enriched.titleSource;
317
356
  lastMessageAt = enriched.lastMessageAt;
318
357
  turnCount = enriched.turnCount;
319
358
  capped = enriched.capped;
@@ -322,6 +361,15 @@ export function buildHttpApp(deps) {
322
361
  model = enriched.model;
323
362
  }
324
363
  }
364
+ else {
365
+ // JSONL gone but user override may still exist (operator renamed the row
366
+ // before the cascade deleted the file). Surface the user title.
367
+ const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
368
+ if (userTitle) {
369
+ displayName = userTitle;
370
+ titleSource = 'user';
371
+ }
372
+ }
325
373
  return {
326
374
  sessionId: row.sessionId,
327
375
  pid: null,
@@ -330,9 +378,11 @@ export function buildHttpApp(deps) {
330
378
  senderId,
331
379
  role: null,
332
380
  channel: null,
333
- jsonlPath: row.jsonlPath,
381
+ jsonlPath: hasJsonl ? jsonlPath : null,
334
382
  status: 'ended',
383
+ archived: isArchivedJsonlPath(row.sessionId),
335
384
  displayName,
385
+ titleSource,
336
386
  lastMessageAt,
337
387
  turnCount,
338
388
  capped,
@@ -449,7 +499,8 @@ export function buildHttpApp(deps) {
449
499
  // `row-<created|updated|archived|removed>`.
450
500
  //
451
501
  // Payload shape per event (`data: <json>`):
452
- // { sessionId, live, status, agentLabel, cwd, firstPrompt, updatedAt, url }
502
+ // { sessionId, live, status, agentLabel, cwd, firstPrompt, titleSource,
503
+ // updatedAt, url, archived }
453
504
  // Task 141: `sessionId` carries whichever phase is current (bridge
454
505
  // suffix preferred, JSONL basename fallback) — same single-id contract
455
506
  // as `/meta`.
@@ -474,10 +525,22 @@ export function buildHttpApp(deps) {
474
525
  function rowToPayload(row) {
475
526
  const agentLabel = row.agent ?? row.cachedAgentSetting ?? null;
476
527
  let firstPrompt = null;
477
- if (row.jsonlPath && existsSync(row.jsonlPath)) {
478
- const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger);
479
- if (enriched)
528
+ let titleSource = null;
529
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
530
+ if (existsSync(jsonlPath)) {
531
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
532
+ if (enriched) {
480
533
  firstPrompt = enriched.displayName;
534
+ titleSource = enriched.titleSource;
535
+ }
536
+ }
537
+ else {
538
+ // Pre-jsonl / post-purge: only the user-title store can answer.
539
+ const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
540
+ if (userTitle) {
541
+ firstPrompt = userTitle;
542
+ titleSource = 'user';
543
+ }
481
544
  }
482
545
  return {
483
546
  sessionId: row.sessionId,
@@ -486,8 +549,12 @@ export function buildHttpApp(deps) {
486
549
  agentLabel,
487
550
  cwd: row.cwd,
488
551
  firstPrompt,
552
+ titleSource,
489
553
  updatedAt: row.updatedAt,
490
554
  url: deps.store.get(row.sessionId)?.url ?? null,
555
+ // Task 259 — UI uses this to render Unarchive (vs Resume + Archive)
556
+ // on rows whose JSONL has been moved to projects/<slug>/archive/.
557
+ archived: isArchivedJsonlPath(row.sessionId),
491
558
  };
492
559
  }
493
560
  timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
@@ -581,6 +648,24 @@ export function buildHttpApp(deps) {
581
648
  void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
582
649
  };
583
650
  urlUpdateListeners.add(urlListener);
651
+ // Task 253 — rename re-emit. The POST /:sessionId/rename handler
652
+ // mutates the user-title store and then calls notifyRename(); each
653
+ // /events handler re-emits the row so every browser tab sees the new
654
+ // label without re-fetching /list. The row lookup goes through the
655
+ // watcher (canonical row index); rowToPayload then re-reads the user
656
+ // title from `deps.userTitleStore`, picking up the freshly-set value.
657
+ const renameListener = (sessionId) => {
658
+ if (!active)
659
+ return;
660
+ const row = deps.watcher.getBySessionId(sessionId);
661
+ if (!row)
662
+ return;
663
+ const payload = rowToPayload(row);
664
+ subscribersCount += 1;
665
+ deps.logger(`[admin-events] emit event=row-updated sessionId=${sessionId} reason=rename subscribers=1`);
666
+ void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
667
+ };
668
+ renameListeners.add(renameListener);
584
669
  // Keep-alive comment every 25 s. Proxies aggressively close idle
585
670
  // text/event-stream connections at 30–60 s; a comment line is
586
671
  // ignored by EventSource consumers but resets the proxy clock.
@@ -594,6 +679,7 @@ export function buildHttpApp(deps) {
594
679
  clearInterval(keepAlive);
595
680
  unsubscribe();
596
681
  urlUpdateListeners.delete(urlListener);
682
+ renameListeners.delete(renameListener);
597
683
  const durationMs = Date.now() - connectStart;
598
684
  deps.logger(`[admin-events] client-disconnected ip=${clientIp} duration-ms=${durationMs} emits=${subscribersCount}`);
599
685
  });
@@ -627,7 +713,9 @@ export function buildHttpApp(deps) {
627
713
  continue;
628
714
  if (liveSessionIds.has(row.sessionId))
629
715
  continue;
630
- if (!row.jsonlPath)
716
+ // Task 258 — confirm the jsonl is actually on disk; row state can lag
717
+ // behind a delete during boot-seed timing edges.
718
+ if (!existsSync(jsonlPathForSessionId(deps.spawnCwd, row.sessionId)))
631
719
  continue;
632
720
  dehydrated.push(toArchivedPayload(row, senderId));
633
721
  }
@@ -701,6 +789,54 @@ export function buildHttpApp(deps) {
701
789
  : undefined;
702
790
  const sourceRow = resolveRow(deps.watcher, sessionId);
703
791
  const specialist = bodySpecialist ?? sourceRow?.agent ?? undefined;
792
+ // Task 258 — restore the archived jsonl to its canonical top-level path
793
+ // before claude --resume runs. Claude appends to `<projectsDir>/<sid>.jsonl`;
794
+ // if the file lives in `archive/`, claude doesn't find it and starts a fresh
795
+ // empty transcript at the top-level path, silently splitting the conversation.
796
+ const canonicalJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
797
+ const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
798
+ const canonicalExists = existsSync(canonicalJsonl);
799
+ const archivedExists = existsSync(archivedJsonl);
800
+ if (archivedExists && !canonicalExists) {
801
+ try {
802
+ renameSync(archivedJsonl, canonicalJsonl);
803
+ deps.logger(`resume-archive-restored sessionId=${sessionId.slice(0, 8)}`);
804
+ }
805
+ catch (err) {
806
+ const msg = err instanceof Error ? err.message : String(err);
807
+ deps.logger(`resume-archive-restore-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
808
+ timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
809
+ return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
810
+ }
811
+ }
812
+ else if (archivedExists && canonicalExists) {
813
+ // Both files exist — pre-Task-258 bug already produced two parallel
814
+ // jsonls for one sessionId. Keep the larger as canonical (more history);
815
+ // park the smaller at `archive/<sid>.<ts>.jsonl`. No data loss.
816
+ try {
817
+ const canonicalSize = statSync(canonicalJsonl).size;
818
+ const archivedSize = statSync(archivedJsonl).size;
819
+ const keepArchived = archivedSize > canonicalSize;
820
+ const ts = Date.now();
821
+ if (keepArchived) {
822
+ const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
823
+ renameSync(canonicalJsonl, stash);
824
+ renameSync(archivedJsonl, canonicalJsonl);
825
+ deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-archived canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
826
+ }
827
+ else {
828
+ const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
829
+ renameSync(archivedJsonl, stash);
830
+ deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-canonical canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
831
+ }
832
+ }
833
+ catch (err) {
834
+ const msg = err instanceof Error ? err.message : String(err);
835
+ deps.logger(`resume-conflict-resolve-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
836
+ timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
837
+ return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
838
+ }
839
+ }
704
840
  const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => { attachPublicAudit(deps, s); attachSpecialistEndTurnAutoArchive(deps, s); }, onUrlCaptured: notifyUrlCaptured, tunnelUrlOverride }, {
705
841
  senderId,
706
842
  userId,
@@ -729,8 +865,13 @@ export function buildHttpApp(deps) {
729
865
  if (specialist) {
730
866
  deps.logger(`resume-specialist-propagated source=${sessionId} specialist=${specialist}`);
731
867
  }
732
- deps.logger(`resume pid=${result.session.pid} sessionId=${result.session.sessionId} resumeFrom=${sessionId} latency-ms=${Date.now() - start}`);
733
- const payload = toLivePayload(result.session);
868
+ // Task 258 the archive-restore-before-spawn flow means the operator's
869
+ // prior canonical sessionId IS the jsonl basename claude appends to.
870
+ // The pty-spawner's StoredSession.sessionId is set to that same prior
871
+ // canonical id (resumeClaudeSessionId), so the wire payload echoes it
872
+ // directly. No mint divergence to log: there's only one sessionId.
873
+ deps.logger(`resume pid=${result.session.pid} sessionId=${sessionId} resumeFrom=${sessionId} latency-ms=${Date.now() - start}`);
874
+ const payload = { ...toLivePayload(result.session), sessionId };
734
875
  if (idempotencyKey) {
735
876
  resumeDedupCache.set(`${senderId}:${idempotencyKey}`, { payload, createdAt: Date.now() });
736
877
  }
@@ -758,7 +899,8 @@ export function buildHttpApp(deps) {
758
899
  let capped = false;
759
900
  let sizeBytes = base.sizeBytes;
760
901
  let model = null;
761
- if (live.jsonlPath && existsSync(live.jsonlPath)) {
902
+ const liveJsonlPath = jsonlPathForSessionId(deps.spawnCwd, live.sessionId);
903
+ if (existsSync(liveJsonlPath)) {
762
904
  const enriched = readJsonlSession(deps.spawnCwd, live.sessionId, deps.jsonlReadMaxBytes, deps.logger);
763
905
  if (enriched) {
764
906
  lastMessageAt = enriched.lastMessageAt;
@@ -802,15 +944,26 @@ export function buildHttpApp(deps) {
802
944
  timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
803
945
  return c.json({ error: 'session-not-found' }, 404);
804
946
  }
805
- if (deps.store.get(row.sessionId)) {
806
- deps.logger(`archive-rejected sessionId=${row.sessionId.slice(0, 8)} reason=pty-still-alive`);
807
- timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
808
- return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
809
- }
810
947
  const slug = projectSlugForCwd(deps.spawnCwd);
811
948
  const slugDir = join(claudeStateRoot(), 'projects', slug);
812
949
  const archiveDir = join(slugDir, 'archive');
813
950
  const fileName = `${row.sessionId}.jsonl`;
951
+ // Task 258 — refuse archive whenever any alive PTY would append to the
952
+ // canonical jsonl for this row. After the derived-state refactor the
953
+ // path is computed from sessionId + spawnCwd: every StoredSession has
954
+ // exactly one effective path, and the live writer is the StoredSession
955
+ // whose path equals the candidate.
956
+ const candidateJsonl = join(slugDir, fileName);
957
+ const aliveOnPath = deps.store.all().find((s) => {
958
+ if (s.exited)
959
+ return false;
960
+ return jsonlPathForSessionId(deps.spawnCwd, s.sessionId) === candidateJsonl;
961
+ });
962
+ if (aliveOnPath) {
963
+ deps.logger(`archive-refused sessionId=${row.sessionId.slice(0, 8)} reason=pty-alive-jsonl-write store-key=${aliveOnPath.sessionId.slice(0, 8)}`);
964
+ timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
965
+ return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
966
+ }
814
967
  const fromJsonl = mode === 'archive' ? join(slugDir, fileName) : join(archiveDir, fileName);
815
968
  const toJsonl = mode === 'archive' ? join(archiveDir, fileName) : join(slugDir, fileName);
816
969
  if (!existsSync(fromJsonl)) {
@@ -832,6 +985,49 @@ export function buildHttpApp(deps) {
832
985
  timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
833
986
  return c.json({ ok: true, mode }, 200);
834
987
  });
988
+ // Task 253 — POST /:sessionId/rename { title }
989
+ // - 200 + body on accept (carries the persisted title)
990
+ // - 400 on validator reject (empty / too-long / control-chars)
991
+ // - 404 when no row matches any of the four ids
992
+ // After accepting, the user-title store persists and notifyRename()
993
+ // fans a row-updated frame to every connected /events client so the
994
+ // sidebar refreshes without a /list re-fetch.
995
+ app.post('/:sessionId/rename', async (c) => {
996
+ const start = Date.now();
997
+ const id = c.req.param('sessionId');
998
+ if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
999
+ timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
1000
+ return c.json({ error: 'invalid-session-id' }, 400);
1001
+ }
1002
+ let body;
1003
+ try {
1004
+ body = await c.req.json();
1005
+ }
1006
+ catch {
1007
+ timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
1008
+ return c.json({ error: 'invalid-json-body' }, 400);
1009
+ }
1010
+ const row = resolveRow(deps.watcher, id);
1011
+ const sessionId = row?.sessionId ?? (deps.store.get(id)?.sessionId);
1012
+ if (!sessionId) {
1013
+ timed(deps.logger, 'POST', `/${id}/rename`, 404, Date.now() - start);
1014
+ return c.json({ error: 'session-not-found' }, 404);
1015
+ }
1016
+ const result = validateUserTitle(body.title);
1017
+ if (!result.ok) {
1018
+ deps.logger(`rename-rejected sessionId=${sessionId.slice(0, 8)} reason=${result.reason}`);
1019
+ timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
1020
+ return c.json({ error: 'invalid-title', reason: result.reason }, 400);
1021
+ }
1022
+ const entry = deps.userTitleStore.set(sessionId, result.title);
1023
+ // Title content is never logged — operator names may contain customer
1024
+ // data. Byte count is enough for forensic recovery of bulk-rename surges.
1025
+ const titleBytes = Buffer.byteLength(entry.title, 'utf-8');
1026
+ deps.logger(`rename sessionId=${sessionId.slice(0, 8)} title-bytes=${titleBytes} source=manager-route`);
1027
+ notifyRename(sessionId);
1028
+ timed(deps.logger, 'POST', `/${id}/rename`, 200, Date.now() - start);
1029
+ return c.json({ ok: true, sessionId, titleSource: 'user', renamedAt: entry.renamedAt }, 200);
1030
+ });
835
1031
  // SIGTERM-only stop. Idempotent on already-dead PTYs. JSONL untouched.
836
1032
  app.post('/:sessionId/stop', async (c) => {
837
1033
  const start = Date.now();
@@ -865,21 +1061,22 @@ export function buildHttpApp(deps) {
865
1061
  timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
866
1062
  return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
867
1063
  }
868
- if (!row || !row.jsonlPath) {
1064
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1065
+ if (!row || !existsSync(jsonlPath)) {
869
1066
  timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
870
1067
  return c.json({ error: 'session-not-found' }, 404);
871
1068
  }
872
1069
  const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
873
1070
  let bytes = 0;
874
1071
  try {
875
- bytes = existsSync(row.jsonlPath) ? statSync(row.jsonlPath).size : 0;
1072
+ bytes = existsSync(jsonlPath) ? statSync(jsonlPath).size : 0;
876
1073
  }
877
1074
  catch {
878
1075
  // size is informational
879
1076
  }
880
1077
  try {
881
- if (existsSync(row.jsonlPath))
882
- rmSync(row.jsonlPath);
1078
+ if (existsSync(jsonlPath))
1079
+ rmSync(jsonlPath);
883
1080
  if (existsSync(subdir))
884
1081
  rmSync(subdir, { recursive: true, force: true });
885
1082
  }
@@ -889,6 +1086,11 @@ export function buildHttpApp(deps) {
889
1086
  timed(deps.logger, 'DELETE', `/${id}`, 500, Date.now() - start);
890
1087
  return c.json({ error: 'delete-failed', detail: msg }, 500);
891
1088
  }
1089
+ // Task 253 — drop the user-title entry for the purged session so the
1090
+ // session-titles.json doesn't accrete orphans. Best-effort; the store
1091
+ // logs its own failure and reads still serve from the in-memory map
1092
+ // until next boot.
1093
+ deps.userTitleStore.remove(sessionId);
892
1094
  deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
893
1095
  timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
894
1096
  return c.body(null, 204);
@@ -954,26 +1156,19 @@ export function buildHttpApp(deps) {
954
1156
  const row = resolveRow(deps.watcher, id);
955
1157
  const sessionId = row?.sessionId ?? id;
956
1158
  const s = deps.store.get(sessionId);
957
- // Task 204 — /log reads the transcript by filesystem-indexed path, not by
958
- // PTY runtime presence. The store evicts on natural exit, stop, purge, and
959
- // idle, so an ended session whose JSONL still exists on disk and whose
960
- // watcher row still carries `jsonlPath` must remain readable here. Mirrors
961
- // the existing fallback shape at /meta, /stop, and DELETE /:sessionId.
962
- // 404 only when neither the runtime store nor the watcher row can offer a
963
- // path to a transcript.
964
- if (!s && !row?.jsonlPath) {
1159
+ // Task 258 — /log reads the transcript at the derived canonical path.
1160
+ // 404 when there's no row AND no live session at this id; 202 pending
1161
+ // when a live PTY hasn't flushed its first turn yet (no jsonl on disk).
1162
+ if (!s && !row) {
965
1163
  timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
966
1164
  return c.json({ error: 'session-not-found' }, 404);
967
1165
  }
968
- // 202 pending fires only when a live session has not yet flushed its JSONL
969
- // AND the watcher has not seen the file either. A populated watcher row
970
- // proves the file landed, so the read can proceed against disk.
971
- if (s && s.jsonlPath === null && !row?.jsonlPath) {
1166
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1167
+ if (s && !existsSync(jsonlPath)) {
972
1168
  timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
973
1169
  return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
974
1170
  }
975
- const jsonlPath = row?.jsonlPath ?? s?.jsonlPath;
976
- if (!jsonlPath) {
1171
+ if (!existsSync(jsonlPath)) {
977
1172
  timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
978
1173
  return c.json({ error: 'session-not-found' }, 404);
979
1174
  }