@rubytech/create-maxy-code 0.1.91 → 0.1.93

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 (126) hide show
  1. package/dist/__tests__/claude-ptys-slice.test.js +25 -0
  2. package/dist/index.js +10 -1
  3. package/dist/port-resolution.js +29 -0
  4. package/package.json +1 -1
  5. package/payload/platform/package-lock.json +18 -4
  6. package/payload/platform/plugins/docs/references/platform.md +4 -2
  7. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +65 -5
  8. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  9. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +468 -223
  10. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  11. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +10 -3
  12. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/http-server.js +486 -193
  14. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  15. package/payload/platform/services/claude-session-manager/dist/index.js +72 -13
  16. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +10 -4
  18. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +27 -8
  20. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +17 -0
  22. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +25 -0
  24. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +165 -33
  26. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +290 -91
  28. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +6 -2
  30. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/reaper.js +29 -25
  32. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +55 -0
  34. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +170 -0
  36. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -0
  37. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +6 -4
  38. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/session-store.js +8 -10
  40. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +114 -0
  42. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -0
  43. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +209 -0
  44. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -0
  45. package/payload/platform/services/claude-session-manager/dist/types.d.ts +0 -4
  46. package/payload/platform/services/claude-session-manager/dist/types.d.ts.map +1 -1
  47. package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts +39 -0
  48. package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts.map +1 -0
  49. package/payload/platform/services/claude-session-manager/dist/user-title-store.js +138 -0
  50. package/payload/platform/services/claude-session-manager/dist/user-title-store.js.map +1 -0
  51. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +3 -0
  52. package/payload/server/public/assets/{ChatInput-dLWJ0Q8I.css → ChatInput-CzQhPt90.css} +1 -1
  53. package/payload/server/public/assets/{Checkbox-Bx8mRHQ3.js → Checkbox-ynZtDWi3.js} +1 -1
  54. package/payload/server/public/assets/admin-ux6TKHS1.js +217 -0
  55. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BEW_bD50.js → architectureDiagram-Q4EWVU46-Ko_izKmY.js} +1 -1
  56. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DFLT98C3.js → blockDiagram-DXYQGD6D-D58VDC0w.js} +1 -1
  57. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BemKtROA.js → c4Diagram-AHTNJAMY-C-eRXyKi.js} +1 -1
  58. package/payload/server/public/assets/channel-CejaITMF.js +1 -0
  59. package/payload/server/public/assets/{chunk-336JU56O-BL8fGJ7_.js → chunk-336JU56O-DZLEfLAb.js} +2 -2
  60. package/payload/server/public/assets/{chunk-426QAEUC-BD6by0MP.js → chunk-426QAEUC-BriTU3L8.js} +1 -1
  61. package/payload/server/public/assets/{chunk-4TB4RGXK-DX-cb6Nd.js → chunk-4TB4RGXK-BdxRL-AY.js} +1 -1
  62. package/payload/server/public/assets/{chunk-5FUZZQ4R-B2QqtIZS.js → chunk-5FUZZQ4R-BgEmy7Y3.js} +1 -1
  63. package/payload/server/public/assets/{chunk-5PVQY5BW-CzJWmsmd.js → chunk-5PVQY5BW-emnbDRLA.js} +1 -1
  64. package/payload/server/public/assets/{chunk-EDXVE4YY-DP9vcvEU.js → chunk-EDXVE4YY-BvCf2Xf2.js} +1 -1
  65. package/payload/server/public/assets/{chunk-ENJZ2VHE-D5xWEjF0.js → chunk-ENJZ2VHE-DTpEYJRb.js} +1 -1
  66. package/payload/server/public/assets/{chunk-ICPOFSXX-AKduf9o4.js → chunk-ICPOFSXX-Ced2j48Y.js} +1 -1
  67. package/payload/server/public/assets/{chunk-OYMX7WX6-D6jsVNyQ.js → chunk-OYMX7WX6-B21_a3Dv.js} +1 -1
  68. package/payload/server/public/assets/{chunk-U2HBQHQK-BNAnTYh8.js → chunk-U2HBQHQK-CPS3yyXK.js} +1 -1
  69. package/payload/server/public/assets/{chunk-X2U36JSP-UdQhYDnh.js → chunk-X2U36JSP-BJ_B5NW7.js} +1 -1
  70. package/payload/server/public/assets/{chunk-YZCP3GAM-DSSmSGmS.js → chunk-YZCP3GAM-CMLy-WXm.js} +1 -1
  71. package/payload/server/public/assets/{chunk-ZZ45TVLE-Czm7IDMC.js → chunk-ZZ45TVLE-BkLnzCHe.js} +1 -1
  72. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BmoqALf4.js +1 -0
  73. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-vMajwCNi.js +1 -0
  74. package/payload/server/public/assets/clone-mvVfHkyg.js +1 -0
  75. package/payload/server/public/assets/{dagre-hSIMHJcO.js → dagre-Dd_gq4yj.js} +1 -1
  76. package/payload/server/public/assets/{dagre-KV5264BT-CBuRCJE7.js → dagre-KV5264BT-RY0E5UAn.js} +1 -1
  77. package/payload/server/public/assets/data-BK4r8Dqy.js +1 -0
  78. package/payload/server/public/assets/{diagram-5BDNPKRD-dbMrH0le.js → diagram-5BDNPKRD-DR7XiPLh.js} +1 -1
  79. package/payload/server/public/assets/{diagram-G4DWMVQ6-DBc6hs9a.js → diagram-G4DWMVQ6-2EbOfKFs.js} +1 -1
  80. package/payload/server/public/assets/{diagram-MMDJMWI5-ubSGybUk.js → diagram-MMDJMWI5-CGcY0Ze7.js} +1 -1
  81. package/payload/server/public/assets/{diagram-TYMM5635-WxL3cv-9.js → diagram-TYMM5635-Dk6MmAso.js} +1 -1
  82. package/payload/server/public/assets/{erDiagram-SMLLAGMA-CrBBOAN8.js → erDiagram-SMLLAGMA-CKvdlUqk.js} +1 -1
  83. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CXUq5Hzt.js → flowDiagram-DWJPFMVM-BqKhvCDz.js} +1 -1
  84. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-vTCBnYVS.js → ganttDiagram-T4ZO3ILL-CeiE-IRb.js} +1 -1
  85. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BHrj_bRa.js → gitGraphDiagram-UUTBAWPF-BbPKn7v2.js} +1 -1
  86. package/payload/server/public/assets/graph-Cb6A8Hhs.js +1 -0
  87. package/payload/server/public/assets/graph-labels-DCsUNtnE.js +1 -0
  88. package/payload/server/public/assets/{graphlib-CDO75l2X.js → graphlib-tG6kR59B.js} +1 -1
  89. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CcQdaOhi.js → infoDiagram-42DDH7IO-6MiRGCI1.js} +1 -1
  90. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-3KvltFgV.js → ishikawaDiagram-UXIWVN3A-BwY4p5mg.js} +1 -1
  91. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DZCXaGFE.js → journeyDiagram-VCZTEJTY-BXAQ_fUi.js} +1 -1
  92. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DAIAKy1-.js → kanban-definition-6JOO6SKY-ChiiO7Yt.js} +1 -1
  93. package/payload/server/public/assets/{lib-CCLURO3E.js → lib-DbPz9cfu.js} +1 -1
  94. package/payload/server/public/assets/{line-C8iCE2Wx.js → line-B5g5OrOD.js} +1 -1
  95. package/payload/server/public/assets/{mermaid-parser.core-ElvUhnV9.js → mermaid-parser.core-BMGzMKJx.js} +1 -1
  96. package/payload/server/public/assets/{mermaid.core-7dSH31Ao.js → mermaid.core-DLMgkjXn.js} +3 -3
  97. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-FZ0rcfac.js → mindmap-definition-QFDTVHPH-BvyzRLAi.js} +1 -1
  98. package/payload/server/public/assets/{page-D3bFmqF4.js → page-BeYR_GI2.js} +1 -1
  99. package/payload/server/public/assets/{page-DNjUtT5q.js → page-DVNp-Dfy.js} +2 -2
  100. package/payload/server/public/assets/{pieDiagram-DEJITSTG-CDuvMZbO.js → pieDiagram-DEJITSTG--EV9pVfs.js} +1 -1
  101. package/payload/server/public/assets/{public-g_2skbnY.js → public-sYW0Dwku.js} +3 -3
  102. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-xDjhkqNc.js → quadrantDiagram-34T5L4WZ-DJcL08ft.js} +1 -1
  103. package/payload/server/public/assets/{requirementDiagram-MS252O5E-BPKpyT0n.js → requirementDiagram-MS252O5E-D4VdmmSu.js} +1 -1
  104. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BvFIrihC.js → sankeyDiagram-XADWPNL6-Dsw3nHRS.js} +1 -1
  105. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-C6G1oAE0.js → sequenceDiagram-FGHM5R23-CAgemUx5.js} +1 -1
  106. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DbOJT_Xh.js → stateDiagram-FHFEXIEX-B7dyL9_X.js} +1 -1
  107. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CImN6Wk6.js +1 -0
  108. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D1DSxHh6.js → timeline-definition-GMOUNBTQ-B7iKswhp.js} +1 -1
  109. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BYoSeMdo.js → vennDiagram-DHZGUBPP-uFS0i1qX.js} +1 -1
  110. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DH3kZdea.js → wardleyDiagram-NUSXRM2D-lDUh4LAD.js} +1 -1
  111. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-C6MROMLG.js → xychartDiagram-5P7HB3ND-0v7MEVwW.js} +1 -1
  112. package/payload/server/public/data.html +5 -5
  113. package/payload/server/public/graph.html +6 -6
  114. package/payload/server/public/index.html +8 -8
  115. package/payload/server/public/public.html +5 -5
  116. package/payload/server/server.js +17 -0
  117. package/payload/server/public/assets/admin-C57bDUgs.js +0 -217
  118. package/payload/server/public/assets/channel-CfpByn-m.js +0 -1
  119. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DNc9R1IX.js +0 -1
  120. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DM1DQh8N.js +0 -1
  121. package/payload/server/public/assets/clone-BSrNdvLW.js +0 -1
  122. package/payload/server/public/assets/data-3n20lir5.js +0 -1
  123. package/payload/server/public/assets/graph-ChwMpxn_.js +0 -1
  124. package/payload/server/public/assets/graph-labels-CUgJgIwl.js +0 -1
  125. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ce4GFk-H.js +0 -1
  126. /package/payload/server/public/assets/{ChatInput-DoS80kaC.js → ChatInput-Cj9JQtYt.js} +0 -0
@@ -24,10 +24,12 @@
24
24
  import { Hono } from 'hono';
25
25
  import { stream } from 'hono/streaming';
26
26
  import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
27
- import { spawnClaudeSession, stopSession, PERMISSION_MODES } from './pty-spawner.js';
27
+ import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, getPtyTrackerForTests, PERMISSION_MODES, } from './pty-spawner.js';
28
+ import { readSidecar } from './session-sidecar.js';
28
29
  import { readJsonlSession } from './jsonl-enumerator.js';
29
- import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
30
+ import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId, sidecarPathForSessionId } from './jsonl-path.js';
30
31
  import { basename, join } from 'node:path';
32
+ import { validateUserTitle } from './user-title-store.js';
31
33
  const ROLES = ['admin', 'public'];
32
34
  const CHANNELS = ['browser', 'whatsapp', 'telegram', 'webchat', 'email'];
33
35
  // Task 179 — resume idempotency. Two POSTs 9ms apart with the same
@@ -165,11 +167,12 @@ function resolveRow(watcher, id) {
165
167
  // auto-archive` lines cover the lifecycle.
166
168
  const END_TURN_SIGNATURE = '"stop_reason":"end_turn"';
167
169
  const AUTO_ARCHIVE_SPECIALIST = 'database-operator';
168
- function attachSpecialistEndTurnAutoArchive(deps, session) {
169
- if (session.specialist !== AUTO_ARCHIVE_SPECIALIST)
170
+ function attachSpecialistEndTurnAutoArchive(deps, sessionId, specialist) {
171
+ if (specialist !== AUTO_ARCHIVE_SPECIALIST)
170
172
  return;
171
173
  let archived = false;
172
- function endTurnSeen(jsonlPath) {
174
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
175
+ function endTurnSeen() {
173
176
  try {
174
177
  const content = readFileSync(jsonlPath, 'utf8');
175
178
  return content.includes(END_TURN_SIGNATURE);
@@ -178,46 +181,46 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
178
181
  return false;
179
182
  }
180
183
  }
181
- function tryArchive(jsonlPath) {
184
+ function tryArchive() {
182
185
  if (archived)
183
186
  return;
184
187
  if (!existsSync(jsonlPath))
185
188
  return;
186
- if (!endTurnSeen(jsonlPath))
189
+ if (!endTurnSeen())
187
190
  return;
188
191
  archived = true;
189
- deps.logger(`auto-archive sessionId=${session.sessionId} specialist=${AUTO_ARCHIVE_SPECIALIST} reason=end-turn archivedAt=${new Date().toISOString()}`);
190
- void stopSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, session.sessionId);
192
+ deps.logger(`auto-archive sessionId=${sessionId} specialist=${AUTO_ARCHIVE_SPECIALIST} reason=end-turn archivedAt=${new Date().toISOString()}`);
193
+ void stopSession({
194
+ killGraceMs: deps.killGraceMs,
195
+ logger: deps.logger,
196
+ runSystemctl: deps.runSystemctl,
197
+ }, sessionId);
191
198
  }
192
- if (session.jsonlPath)
193
- tryArchive(session.jsonlPath);
199
+ tryArchive();
194
200
  const unsubscribe = deps.watcher.subscribe((event) => {
195
201
  if (archived)
196
202
  return;
197
- if (event.sessionId !== session.sessionId)
203
+ if (event.sessionId !== sessionId)
198
204
  return;
199
205
  if (event.source !== 'jsonl')
200
206
  return;
201
- const path = event.row?.jsonlPath ?? session.jsonlPath;
202
- if (!path)
203
- return;
204
- tryArchive(path);
207
+ tryArchive();
205
208
  });
206
- session.pty.onExit(() => unsubscribe());
209
+ onPtyExit(sessionId, () => unsubscribe());
207
210
  }
208
- function attachPublicAudit(deps, session) {
209
- if (session.role !== 'public')
211
+ function attachPublicAudit(deps, sessionId, role) {
212
+ if (role !== 'public')
210
213
  return;
211
- if (session.jsonlPath === null)
214
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
215
+ if (!existsSync(jsonlPath))
212
216
  return;
213
- const claudeSessionId = session.sessionId;
214
- deps.publicToolAudit.attach(claudeSessionId, session.jsonlPath);
215
- if (session.exited) {
216
- deps.publicToolAudit.detach(claudeSessionId);
217
+ deps.publicToolAudit.attach(sessionId, jsonlPath);
218
+ if (!isLive(sessionId)) {
219
+ deps.publicToolAudit.detach(sessionId);
217
220
  return;
218
221
  }
219
- session.pty.onExit(() => {
220
- deps.publicToolAudit.detach(claudeSessionId);
222
+ onPtyExit(sessionId, () => {
223
+ deps.publicToolAudit.detach(sessionId);
221
224
  });
222
225
  }
223
226
  export function buildHttpApp(deps) {
@@ -235,30 +238,39 @@ export function buildHttpApp(deps) {
235
238
  listener(sessionId);
236
239
  }
237
240
  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.
241
+ // Task 253 same fan-out shape for rename. The POST /:sessionId/rename
242
+ // route writes the user-title store and then calls notifyRename; each
243
+ // /events handler then re-emits a `row-updated` frame so every browser
244
+ // tab sees the new label without re-fetching /list.
245
+ const renameListeners = new Set();
246
+ function notifyRename(sessionId) {
247
+ for (const listener of renameListeners)
248
+ listener(sessionId);
249
+ }
250
+ app.__notifyRenameForTests = notifyRename;
251
+ // Watcher subscriber: when the JSONL CREATE event fires for a live
252
+ // session, attach the public-audit watcher. The watcher row carries
253
+ // the role from the sidecar (Task 260 — disk is the source of truth),
254
+ // so the audit attachment fires deterministically against rows whose
255
+ // PTY is still alive in this manager process.
240
256
  deps.watcher.subscribe((event) => {
241
257
  if (event.source !== 'jsonl')
242
258
  return;
243
259
  if (event.kind !== 'create' && event.kind !== 'modify')
244
260
  return;
245
- if (!event.row || !event.row.jsonlPath)
261
+ if (!event.row)
246
262
  return;
247
- const live = deps.store.get(event.sessionId);
248
- if (!live)
263
+ if (!isLive(event.sessionId))
249
264
  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
- attachPublicAudit(deps, live);
265
+ attachPublicAudit(deps, event.sessionId, event.row.role);
255
266
  });
256
267
  // Task 183 — dispatch jsonl modify/create events into the recorder
257
268
  // tail. The tail filters by registered sessionId (only specialist
258
269
  // database-operator spawns are registered), so operator turns pass
259
270
  // through without producing recorder-* lines. The tail's append-only
260
271
  // cursor ensures repeated modify events for the same JSONL only read
261
- // newly-appended bytes.
272
+ // newly-appended bytes. Path is derived from sessionId + spawnCwd
273
+ // (Task 258).
262
274
  if (deps.jsonlTail) {
263
275
  const tail = deps.jsonlTail;
264
276
  deps.watcher.subscribe((event) => {
@@ -266,79 +278,129 @@ export function buildHttpApp(deps) {
266
278
  return;
267
279
  if (event.kind !== 'create' && event.kind !== 'modify')
268
280
  return;
269
- const path = event.row?.jsonlPath;
270
- if (!path)
281
+ const path = jsonlPathForSessionId(deps.spawnCwd, event.sessionId);
282
+ if (!existsSync(path))
271
283
  return;
272
284
  tail.onJsonlModify(event.sessionId, path);
273
285
  });
274
286
  }
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();
287
+ /** Task 260 — /spawn and /resume need to return a wire payload
288
+ * immediately, but at that moment the watcher row may not yet
289
+ * include the sidecar fields (the JSONL CREATE event hasn't fired
290
+ * in fresh-spawn tests with synthetic watchers). Build the payload
291
+ * from the row if present, falling back to a synthetic shape that
292
+ * reads the sidecar directly. */
293
+ function buildSpawnReturnPayload(sessionId, pid, startedAt, capturedUrl) {
294
+ const row = deps.watcher.getBySessionId(sessionId);
295
+ if (row && row.senderId !== null) {
296
+ return toPayload(row);
280
297
  }
281
- return null;
282
- }
283
- function toLivePayload(s) {
284
- const hasJsonl = s.jsonlPath !== null && existsSync(s.jsonlPath);
298
+ // Read the sidecar directly — the spawn just wrote it.
299
+ const sidecarPath = sidecarPathForSessionId(deps.spawnCwd, sessionId);
300
+ const { sidecar } = readSidecar(sidecarPath, deps.logger);
301
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
302
+ const hasJsonl = existsSync(jsonlPath);
285
303
  return {
286
- sessionId: s.sessionId,
287
- pid: s.pid,
288
- url: s.url,
289
- startedAt: s.startedAt,
290
- senderId: s.senderId,
291
- role: s.role,
292
- channel: s.channel,
293
- jsonlPath: s.jsonlPath,
304
+ sessionId,
305
+ pid,
306
+ url: capturedUrl ?? sidecar?.url ?? null,
307
+ startedAt: sidecar?.startedAt ?? startedAt,
308
+ senderId: sidecar?.senderId ?? '',
309
+ role: sidecar?.role ?? null,
310
+ channel: sidecar?.channel ?? null,
311
+ jsonlPath: hasJsonl ? jsonlPath : null,
294
312
  status: 'alive',
295
- displayName: liveDisplayName(s),
313
+ archived: false,
314
+ displayName: null,
315
+ titleSource: null,
296
316
  lastMessageAt: null,
297
317
  turnCount: 0,
298
318
  capped: false,
299
- sizeBytes: hasJsonl && s.jsonlPath ? statSync(s.jsonlPath).size : null,
300
- hidden: s.hidden,
301
- specialist: s.specialist,
319
+ sizeBytes: hasJsonl ? statSync(jsonlPath).size : null,
320
+ hidden: sidecar?.hidden ?? false,
321
+ specialist: sidecar?.specialist ?? null,
302
322
  model: null,
303
323
  };
304
324
  }
305
- function toArchivedPayload(row, senderId) {
325
+ /** Task 260 — single payload builder. Both live and archived rows
326
+ * derive every wire field from the watcher row (which itself derived
327
+ * from the sidecar + JSONL + PID file). The split between
328
+ * `toLivePayload`/`toArchivedPayload` is gone; the row's `state`
329
+ * and `pid` decide `status` and the `archived` flag.
330
+ *
331
+ * The `model` field continues to derive from the JSONL tail (last
332
+ * assistant turn) rather than the sidecar's spawn-request model —
333
+ * see [[learnings_writer_reader_charset_drift]] family: the running
334
+ * model is what the wire's `model` has historically meant, and the
335
+ * sidecar's `model` is kept as a self-diagnostic record. */
336
+ function toPayload(row) {
337
+ const isLiveState = row.state === 'live';
338
+ const archivedJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
339
+ const topJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
340
+ const jsonlPath = isLiveState
341
+ ? topJsonlPath
342
+ : existsSync(archivedJsonlPath) ? archivedJsonlPath : topJsonlPath;
343
+ const hasJsonl = existsSync(jsonlPath);
306
344
  let displayName = null;
345
+ let titleSource = null;
307
346
  let lastMessageAt = null;
308
347
  let turnCount = 0;
309
348
  let capped = false;
310
- let sizeBytes = null;
349
+ let sizeBytes = hasJsonl ? statSync(jsonlPath).size : null;
311
350
  let model = null;
312
- 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);
351
+ let enrichedStartedAt = null;
352
+ if (hasJsonl) {
353
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
315
354
  if (enriched) {
316
355
  displayName = enriched.displayName;
356
+ titleSource = enriched.titleSource;
317
357
  lastMessageAt = enriched.lastMessageAt;
318
358
  turnCount = enriched.turnCount;
319
359
  capped = enriched.capped;
320
360
  sizeBytes = enriched.sizeBytes;
321
- startedAt = enriched.startedAt;
361
+ enrichedStartedAt = enriched.startedAt;
322
362
  model = enriched.model;
323
363
  }
324
364
  }
365
+ else {
366
+ const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
367
+ if (userTitle) {
368
+ displayName = userTitle;
369
+ titleSource = 'user';
370
+ }
371
+ }
372
+ // startedAt resolution order:
373
+ // 1. JSONL enrichment (the most authoritative — claude's own record)
374
+ // 2. Sidecar (/spawn time)
375
+ // 3. Row mtime fallback (legacy archived rows without sidecar)
376
+ const startedAt = enrichedStartedAt
377
+ ?? row.startedAt
378
+ ?? new Date(row.updatedAt || 0).toISOString();
379
+ // Agent label: PID-file `agent` for live rows; cachedAgentSetting for
380
+ // archived rows (JSONL head). Sidecar's `sidecarSpecialist` is the
381
+ // spawn-time request — fall back to it when both above are null so
382
+ // post-Task-260 rows surface their specialist even if the JSONL
383
+ // doesn't include an agent-setting record.
384
+ const specialist = row.agent ?? row.cachedAgentSetting ?? row.sidecarSpecialist;
325
385
  return {
326
386
  sessionId: row.sessionId,
327
- pid: null,
328
- url: null,
387
+ pid: row.pid,
388
+ url: row.url,
329
389
  startedAt,
330
- senderId,
331
- role: null,
332
- channel: null,
333
- jsonlPath: row.jsonlPath,
334
- status: 'ended',
390
+ senderId: row.senderId ?? '',
391
+ role: row.role,
392
+ channel: row.channel,
393
+ jsonlPath: hasJsonl ? jsonlPath : null,
394
+ status: isLiveState ? 'alive' : 'ended',
395
+ archived: row.state === 'archived',
335
396
  displayName,
397
+ titleSource,
336
398
  lastMessageAt,
337
399
  turnCount,
338
400
  capped,
339
401
  sizeBytes,
340
- hidden: false,
341
- specialist: row.agent,
402
+ hidden: row.hidden,
403
+ specialist,
342
404
  model,
343
405
  };
344
406
  }
@@ -412,7 +474,16 @@ export function buildHttpApp(deps) {
412
474
  : body.tunnelUrl === null
413
475
  ? null
414
476
  : undefined;
415
- const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => { attachPublicAudit(deps, s); attachSpecialistEndTurnAutoArchive(deps, s); }, onUrlCaptured: notifyUrlCaptured, tunnelUrlOverride }, {
477
+ const result = await spawnClaudeSession({
478
+ ...deps,
479
+ watcher: deps.watcher,
480
+ onSessionReady: (tracker) => {
481
+ attachPublicAudit(deps, tracker.sessionId, role);
482
+ attachSpecialistEndTurnAutoArchive(deps, tracker.sessionId, tracker.specialist);
483
+ },
484
+ onUrlCaptured: notifyUrlCaptured,
485
+ tunnelUrlOverride,
486
+ }, {
416
487
  senderId,
417
488
  userId,
418
489
  role: role,
@@ -441,7 +512,8 @@ export function buildHttpApp(deps) {
441
512
  return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, 500);
442
513
  }
443
514
  timed(deps.logger, 'POST', '/spawn', 200, Date.now() - start);
444
- return c.json(toLivePayload(result.session), 200);
515
+ const payload = buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url);
516
+ return c.json(payload, 200);
445
517
  });
446
518
  // SSE feed of row deltas. One channel per client; manager fans out from
447
519
  // a single watcher subscription. On connect: replay current rows as
@@ -449,7 +521,8 @@ export function buildHttpApp(deps) {
449
521
  // `row-<created|updated|archived|removed>`.
450
522
  //
451
523
  // Payload shape per event (`data: <json>`):
452
- // { sessionId, live, status, agentLabel, cwd, firstPrompt, updatedAt, url }
524
+ // { sessionId, live, status, agentLabel, cwd, firstPrompt, titleSource,
525
+ // updatedAt, url, archived }
453
526
  // Task 141: `sessionId` carries whichever phase is current (bridge
454
527
  // suffix preferred, JSONL basename fallback) — same single-id contract
455
528
  // as `/meta`.
@@ -474,10 +547,22 @@ export function buildHttpApp(deps) {
474
547
  function rowToPayload(row) {
475
548
  const agentLabel = row.agent ?? row.cachedAgentSetting ?? null;
476
549
  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)
550
+ let titleSource = null;
551
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
552
+ if (existsSync(jsonlPath)) {
553
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
554
+ if (enriched) {
480
555
  firstPrompt = enriched.displayName;
556
+ titleSource = enriched.titleSource;
557
+ }
558
+ }
559
+ else {
560
+ // Pre-jsonl / post-purge: only the user-title store can answer.
561
+ const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
562
+ if (userTitle) {
563
+ firstPrompt = userTitle;
564
+ titleSource = 'user';
565
+ }
481
566
  }
482
567
  return {
483
568
  sessionId: row.sessionId,
@@ -486,8 +571,19 @@ export function buildHttpApp(deps) {
486
571
  agentLabel,
487
572
  cwd: row.cwd,
488
573
  firstPrompt,
574
+ titleSource,
489
575
  updatedAt: row.updatedAt,
490
- url: deps.store.get(row.sessionId)?.url ?? null,
576
+ // Task 260 — url is on the row from the sidecar; url-capture
577
+ // mutates the sidecar in place and the watcher re-reads on the
578
+ // next event. Earlier this dipped into deps.store for the live-
579
+ // row case and returned null for everything else.
580
+ url: row.url,
581
+ // Task 260 — archive state is on the row directly (the watcher
582
+ // enumerates both top and archive subdirs and folds a top→archive
583
+ // move into a single state change). Earlier this was an extra
584
+ // existsSync against the archive path because archived rows
585
+ // weren't first-class members of the index.
586
+ archived: row.state === 'archived',
491
587
  };
492
588
  }
493
589
  timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
@@ -563,12 +659,13 @@ export function buildHttpApp(deps) {
563
659
  deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1`);
564
660
  void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
565
661
  });
566
- // Task 189 — when `url-capture` fires for a session, the pty-spawner
567
- // calls `notifyUrlCaptured(sessionId)` after mutating
568
- // `StoredSession.url`. Re-emit the row so the client's hidden
569
- // Open-in-new-tab arrow appears in step. The row lookup goes through
570
- // the watcher (canonical row index); rowToPayload then re-reads `url`
571
- // from `deps.store`, picking up the freshly-mutated value.
662
+ // Task 189 / 260 — when `url-capture` fires for a session, the
663
+ // pty-spawner calls `notifyUrlCaptured(sessionId)` after writing
664
+ // the URL to the sidecar. Re-emit the row so the client's hidden
665
+ // Open-in-new-tab arrow appears in step. The row lookup goes
666
+ // through the watcher (canonical row index); rowToPayload then
667
+ // reads `url` from the row (which the watcher just refreshed
668
+ // from the sidecar).
572
669
  const urlListener = (sessionId) => {
573
670
  if (!active)
574
671
  return;
@@ -581,6 +678,24 @@ export function buildHttpApp(deps) {
581
678
  void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
582
679
  };
583
680
  urlUpdateListeners.add(urlListener);
681
+ // Task 253 — rename re-emit. The POST /:sessionId/rename handler
682
+ // mutates the user-title store and then calls notifyRename(); each
683
+ // /events handler re-emits the row so every browser tab sees the new
684
+ // label without re-fetching /list. The row lookup goes through the
685
+ // watcher (canonical row index); rowToPayload then re-reads the user
686
+ // title from `deps.userTitleStore`, picking up the freshly-set value.
687
+ const renameListener = (sessionId) => {
688
+ if (!active)
689
+ return;
690
+ const row = deps.watcher.getBySessionId(sessionId);
691
+ if (!row)
692
+ return;
693
+ const payload = rowToPayload(row);
694
+ subscribersCount += 1;
695
+ deps.logger(`[admin-events] emit event=row-updated sessionId=${sessionId} reason=rename subscribers=1`);
696
+ void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
697
+ };
698
+ renameListeners.add(renameListener);
584
699
  // Keep-alive comment every 25 s. Proxies aggressively close idle
585
700
  // text/event-stream connections at 30–60 s; a comment line is
586
701
  // ignored by EventSource consumers but resets the proxy clock.
@@ -594,6 +709,7 @@ export function buildHttpApp(deps) {
594
709
  clearInterval(keepAlive);
595
710
  unsubscribe();
596
711
  urlUpdateListeners.delete(urlListener);
712
+ renameListeners.delete(renameListener);
597
713
  const durationMs = Date.now() - connectStart;
598
714
  deps.logger(`[admin-events] client-disconnected ip=${clientIp} duration-ms=${durationMs} emits=${subscribersCount}`);
599
715
  });
@@ -612,30 +728,41 @@ export function buildHttpApp(deps) {
612
728
  timed(deps.logger, 'GET', '/list', 400, Date.now() - start);
613
729
  return c.json({ error: 'senderId-required' }, 400);
614
730
  }
615
- const liveStored = deps.store.listBySender(senderId, { includeHidden });
616
- const live = liveStored.map(toLivePayload);
617
- const liveSessionIds = new Set();
618
- for (const s of liveStored)
619
- liveSessionIds.add(s.sessionId);
620
- // Archived rows come from the watcher's JSONL index (rows whose
621
- // state === 'archived'). The watcher's boot-seed already paired
622
- // PID files with JSONLs on disk, so archived rows here are exactly
623
- // the sessions whose PTY has exited but transcript persists.
624
- const dehydrated = [];
731
+ // Task 260 — one source. Filter the watcher's row index by sidecar
732
+ // senderId, then partition into live vs archived for the log line
733
+ // (the merged list returns to the caller in startedAt-desc order).
734
+ // Rows without a sidecar carry `senderId: null` and never match
735
+ // unless includeHidden=1 is passed (the legacy pre-Task-260 archives
736
+ // case). Hidden rows are dropped unless includeHidden=1.
737
+ const payloads = [];
738
+ let liveCount = 0;
739
+ let archivedCount = 0;
740
+ let hiddenSkipped = 0;
625
741
  for (const row of deps.watcher.all()) {
626
- if (row.state !== 'archived')
742
+ if (row.senderId !== senderId && !(includeHidden && row.senderId === null))
627
743
  continue;
628
- if (liveSessionIds.has(row.sessionId))
744
+ if (row.hidden && !includeHidden) {
745
+ hiddenSkipped += 1;
629
746
  continue;
630
- if (!row.jsonlPath)
747
+ }
748
+ // Confirm the JSONL is actually on disk; row state can lag behind
749
+ // a delete during boot-seed timing edges. Live rows whose PID
750
+ // file exists pre-first-turn legitimately have no JSONL — keep
751
+ // them in the result.
752
+ const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
753
+ const topJsonl = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
754
+ if (row.state === 'archived' && !existsSync(archivedJsonl) && !existsSync(topJsonl))
631
755
  continue;
632
- dehydrated.push(toArchivedPayload(row, senderId));
756
+ payloads.push(toPayload(row));
757
+ if (row.state === 'live')
758
+ liveCount += 1;
759
+ else
760
+ archivedCount += 1;
633
761
  }
634
- deps.logger(`list-source live=${live.length} jsonl=${dehydrated.length}`);
635
- const merged = [...live, ...dehydrated];
636
- merged.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
762
+ deps.logger(`[list-source] live=${liveCount} archived=${archivedCount} hidden-skipped=${hiddenSkipped}`);
763
+ payloads.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
637
764
  timed(deps.logger, 'GET', '/list', 200, Date.now() - start);
638
- return c.json(merged, 200);
765
+ return c.json(payloads, 200);
639
766
  });
640
767
  app.post('/resume', async (c) => {
641
768
  const start = Date.now();
@@ -701,7 +828,77 @@ export function buildHttpApp(deps) {
701
828
  : undefined;
702
829
  const sourceRow = resolveRow(deps.watcher, sessionId);
703
830
  const specialist = bodySpecialist ?? sourceRow?.agent ?? undefined;
704
- const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => { attachPublicAudit(deps, s); attachSpecialistEndTurnAutoArchive(deps, s); }, onUrlCaptured: notifyUrlCaptured, tunnelUrlOverride }, {
831
+ // Task 258 restore the archived jsonl to its canonical top-level path
832
+ // before claude --resume runs. Claude appends to `<projectsDir>/<sid>.jsonl`;
833
+ // if the file lives in `archive/`, claude doesn't find it and starts a fresh
834
+ // empty transcript at the top-level path, silently splitting the conversation.
835
+ const canonicalJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
836
+ const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
837
+ const canonicalExists = existsSync(canonicalJsonl);
838
+ const archivedExists = existsSync(archivedJsonl);
839
+ if (archivedExists && !canonicalExists) {
840
+ try {
841
+ renameSync(archivedJsonl, canonicalJsonl);
842
+ // Task 260 — sidecar travels with the JSONL. Move the archived
843
+ // sidecar to the top-level path so the watcher picks it up on
844
+ // the next reconcile.
845
+ const archivedSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
846
+ const canonicalSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId);
847
+ if (existsSync(archivedSidecar)) {
848
+ try {
849
+ renameSync(archivedSidecar, canonicalSidecar);
850
+ }
851
+ catch (err) {
852
+ deps.logger(`resume-sidecar-restore-failed sessionId=${sessionId.slice(0, 8)} message=${err instanceof Error ? err.message : String(err)}`);
853
+ }
854
+ }
855
+ deps.logger(`resume-archive-restored sessionId=${sessionId.slice(0, 8)}`);
856
+ }
857
+ catch (err) {
858
+ const msg = err instanceof Error ? err.message : String(err);
859
+ deps.logger(`resume-archive-restore-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
860
+ timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
861
+ return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
862
+ }
863
+ }
864
+ else if (archivedExists && canonicalExists) {
865
+ // Both files exist — pre-Task-258 bug already produced two parallel
866
+ // jsonls for one sessionId. Keep the larger as canonical (more history);
867
+ // park the smaller at `archive/<sid>.<ts>.jsonl`. No data loss.
868
+ try {
869
+ const canonicalSize = statSync(canonicalJsonl).size;
870
+ const archivedSize = statSync(archivedJsonl).size;
871
+ const keepArchived = archivedSize > canonicalSize;
872
+ const ts = Date.now();
873
+ if (keepArchived) {
874
+ const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
875
+ renameSync(canonicalJsonl, stash);
876
+ renameSync(archivedJsonl, canonicalJsonl);
877
+ deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-archived canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
878
+ }
879
+ else {
880
+ const stash = jsonlPathForSessionId(deps.spawnCwd, `${sessionId}.${ts}`, { archived: true });
881
+ renameSync(archivedJsonl, stash);
882
+ deps.logger(`resume-conflict sessionId=${sessionId.slice(0, 8)} resolution=keep-canonical canonical-size=${canonicalSize} archived-size=${archivedSize} stashed=${stash}`);
883
+ }
884
+ }
885
+ catch (err) {
886
+ const msg = err instanceof Error ? err.message : String(err);
887
+ deps.logger(`resume-conflict-resolve-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
888
+ timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
889
+ return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
890
+ }
891
+ }
892
+ const result = await spawnClaudeSession({
893
+ ...deps,
894
+ watcher: deps.watcher,
895
+ onSessionReady: (tracker) => {
896
+ attachPublicAudit(deps, tracker.sessionId, role);
897
+ attachSpecialistEndTurnAutoArchive(deps, tracker.sessionId, tracker.specialist);
898
+ },
899
+ onUrlCaptured: notifyUrlCaptured,
900
+ tunnelUrlOverride,
901
+ }, {
705
902
  senderId,
706
903
  userId,
707
904
  role: role,
@@ -729,8 +926,15 @@ export function buildHttpApp(deps) {
729
926
  if (specialist) {
730
927
  deps.logger(`resume-specialist-propagated source=${sessionId} specialist=${specialist}`);
731
928
  }
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);
929
+ // Task 258 the archive-restore-before-spawn flow means the operator's
930
+ // prior canonical sessionId IS the jsonl basename claude appends to.
931
+ // The PtyTracker's sessionId is set to that same prior canonical id
932
+ // (resumeClaudeSessionId), so the wire payload echoes it directly.
933
+ deps.logger(`resume pid=${result.session.pid} sessionId=${sessionId} resumeFrom=${sessionId} latency-ms=${Date.now() - start}`);
934
+ const payload = {
935
+ ...buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url),
936
+ sessionId,
937
+ };
734
938
  if (idempotencyKey) {
735
939
  resumeDedupCache.set(`${senderId}:${idempotencyKey}`, { payload, createdAt: Date.now() });
736
940
  }
@@ -749,29 +953,27 @@ export function buildHttpApp(deps) {
749
953
  timed(deps.logger, 'GET', `/${id}/meta`, 404, Date.now() - start);
750
954
  return c.json({ error: 'session-not-found' }, 404);
751
955
  }
752
- const live = deps.store.get(row.sessionId);
753
- let payload;
754
- if (live) {
755
- const base = toLivePayload(live);
756
- let lastMessageAt = null;
757
- let turnCount = 0;
758
- let capped = false;
759
- let sizeBytes = base.sizeBytes;
760
- let model = null;
761
- if (live.jsonlPath && existsSync(live.jsonlPath)) {
762
- const enriched = readJsonlSession(deps.spawnCwd, live.sessionId, deps.jsonlReadMaxBytes, deps.logger);
763
- if (enriched) {
764
- lastMessageAt = enriched.lastMessageAt;
765
- turnCount = enriched.turnCount;
766
- capped = enriched.capped;
767
- sizeBytes = enriched.sizeBytes;
768
- model = enriched.model;
769
- }
956
+ // Task 260 — single row source. /meta uses the deeper enumerator
957
+ // limit (jsonlReadMaxBytes) for richer turn/sizeBytes data than
958
+ // /list provides; otherwise the payload is identical to /list.
959
+ let payload = toPayload(row);
960
+ const candidatePath = row.state === 'archived'
961
+ ? jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
962
+ : jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
963
+ if (existsSync(candidatePath)) {
964
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, deps.jsonlReadMaxBytes, deps.logger, deps.userTitleStore);
965
+ if (enriched) {
966
+ payload = {
967
+ ...payload,
968
+ lastMessageAt: enriched.lastMessageAt,
969
+ turnCount: enriched.turnCount,
970
+ capped: enriched.capped,
971
+ sizeBytes: enriched.sizeBytes,
972
+ model: enriched.model,
973
+ displayName: enriched.displayName,
974
+ titleSource: enriched.titleSource,
975
+ };
770
976
  }
771
- payload = { ...base, lastMessageAt, turnCount, capped, sizeBytes, model };
772
- }
773
- else {
774
- payload = toArchivedPayload(row, '');
775
977
  }
776
978
  deps.logger(`meta sessionId=${row.sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
777
979
  timed(deps.logger, 'GET', `/${id}/meta`, 200, Date.now() - start);
@@ -802,15 +1004,21 @@ export function buildHttpApp(deps) {
802
1004
  timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
803
1005
  return c.json({ error: 'session-not-found' }, 404);
804
1006
  }
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
1007
  const slug = projectSlugForCwd(deps.spawnCwd);
811
1008
  const slugDir = join(claudeStateRoot(), 'projects', slug);
812
1009
  const archiveDir = join(slugDir, 'archive');
813
1010
  const fileName = `${row.sessionId}.jsonl`;
1011
+ // Task 260 — refuse archive whenever an alive PTY exists for this
1012
+ // row in the manager's tracker map. Earlier this walked every
1013
+ // store entry to find one whose derived path equaled the candidate
1014
+ // — that loop was redundant because the JSONL basename IS the
1015
+ // sessionId, so the only relevant check is "is this sessionId
1016
+ // tracker alive?". `isLive` answers in O(1).
1017
+ if (mode === 'archive' && isLive(row.sessionId)) {
1018
+ deps.logger(`archive-refused sessionId=${row.sessionId.slice(0, 8)} reason=pty-alive`);
1019
+ timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
1020
+ return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
1021
+ }
814
1022
  const fromJsonl = mode === 'archive' ? join(slugDir, fileName) : join(archiveDir, fileName);
815
1023
  const toJsonl = mode === 'archive' ? join(archiveDir, fileName) : join(slugDir, fileName);
816
1024
  if (!existsSync(fromJsonl)) {
@@ -828,10 +1036,79 @@ export function buildHttpApp(deps) {
828
1036
  timed(deps.logger, 'POST', `/${id}/archive`, 500, Date.now() - start);
829
1037
  return c.json({ error: 'archive-failed', detail: msg }, 500);
830
1038
  }
831
- deps.logger(`archive sessionId=${row.sessionId.slice(0, 8)} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))}`);
1039
+ // Task 260 the sidecar travels with the JSONL. Best-effort: an
1040
+ // orphan sidecar at the source path is self-healing (the next
1041
+ // bootSeed indexes the row with null fields and the operator
1042
+ // re-archives), but the orphan is itself a regression signal the
1043
+ // log line surfaces.
1044
+ let sidecarMoved = false;
1045
+ try {
1046
+ const fromSidecar = mode === 'archive'
1047
+ ? sidecarPathForSessionId(deps.spawnCwd, row.sessionId)
1048
+ : sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
1049
+ const toSidecar = mode === 'archive'
1050
+ ? sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
1051
+ : sidecarPathForSessionId(deps.spawnCwd, row.sessionId);
1052
+ if (existsSync(fromSidecar)) {
1053
+ renameSync(fromSidecar, toSidecar);
1054
+ sidecarMoved = true;
1055
+ }
1056
+ }
1057
+ catch (err) {
1058
+ const msg = err instanceof Error ? err.message : String(err);
1059
+ deps.logger(`archive-sidecar-move-failed sessionId=${row.sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
1060
+ }
1061
+ deps.logger(`[archive] sessionId=${row.sessionId.slice(0, 8)} mode=${mode} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))} sidecar-moved=${sidecarMoved}`);
832
1062
  timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
833
1063
  return c.json({ ok: true, mode }, 200);
834
1064
  });
1065
+ // Task 253 — POST /:sessionId/rename { title }
1066
+ // - 200 + body on accept (carries the persisted title)
1067
+ // - 400 on validator reject (empty / too-long / control-chars)
1068
+ // - 404 when no row matches any of the four ids
1069
+ // After accepting, the user-title store persists and notifyRename()
1070
+ // fans a row-updated frame to every connected /events client so the
1071
+ // sidebar refreshes without a /list re-fetch.
1072
+ app.post('/:sessionId/rename', async (c) => {
1073
+ const start = Date.now();
1074
+ const id = c.req.param('sessionId');
1075
+ if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
1076
+ timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
1077
+ return c.json({ error: 'invalid-session-id' }, 400);
1078
+ }
1079
+ let body;
1080
+ try {
1081
+ body = await c.req.json();
1082
+ }
1083
+ catch {
1084
+ timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
1085
+ return c.json({ error: 'invalid-json-body' }, 400);
1086
+ }
1087
+ const row = resolveRow(deps.watcher, id);
1088
+ // Task 260 — rename resolves via the watcher row index. The live
1089
+ // tracker is no longer a secondary lookup surface — the watcher's
1090
+ // PID-file event indexes every live session, so a sessionId that
1091
+ // names no row also names no live PTY.
1092
+ const sessionId = row?.sessionId;
1093
+ if (!sessionId) {
1094
+ timed(deps.logger, 'POST', `/${id}/rename`, 404, Date.now() - start);
1095
+ return c.json({ error: 'session-not-found' }, 404);
1096
+ }
1097
+ const result = validateUserTitle(body.title);
1098
+ if (!result.ok) {
1099
+ deps.logger(`rename-rejected sessionId=${sessionId.slice(0, 8)} reason=${result.reason}`);
1100
+ timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
1101
+ return c.json({ error: 'invalid-title', reason: result.reason }, 400);
1102
+ }
1103
+ const entry = deps.userTitleStore.set(sessionId, result.title);
1104
+ // Title content is never logged — operator names may contain customer
1105
+ // data. Byte count is enough for forensic recovery of bulk-rename surges.
1106
+ const titleBytes = Buffer.byteLength(entry.title, 'utf-8');
1107
+ deps.logger(`rename sessionId=${sessionId.slice(0, 8)} title-bytes=${titleBytes} source=manager-route`);
1108
+ notifyRename(sessionId);
1109
+ timed(deps.logger, 'POST', `/${id}/rename`, 200, Date.now() - start);
1110
+ return c.json({ ok: true, sessionId, titleSource: 'user', renamedAt: entry.renamedAt }, 200);
1111
+ });
835
1112
  // SIGTERM-only stop. Idempotent on already-dead PTYs. JSONL untouched.
836
1113
  app.post('/:sessionId/stop', async (c) => {
837
1114
  const start = Date.now();
@@ -841,12 +1118,28 @@ export function buildHttpApp(deps) {
841
1118
  return c.json({ error: 'invalid-session-id' }, 400);
842
1119
  }
843
1120
  const row = resolveRow(deps.watcher, id);
844
- if (!row && !deps.store.get(id)) {
1121
+ if (!row && !isLive(id)) {
845
1122
  timed(deps.logger, 'POST', `/${id}/stop`, 404, Date.now() - start);
846
1123
  return c.json({ error: 'session-not-found' }, 404);
847
1124
  }
848
1125
  const sessionId = row?.sessionId ?? id;
849
- await stopSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, sessionId);
1126
+ const outcome = await stopSession({
1127
+ killGraceMs: deps.killGraceMs,
1128
+ logger: deps.logger,
1129
+ runSystemctl: deps.runSystemctl,
1130
+ }, sessionId);
1131
+ // Task 250 — surface scope-stop failures as 500 so the operator sees
1132
+ // the End-click fail when systemd cannot stop the unit. ok and
1133
+ // already-gone collapse to 204 (idempotent); no-store-entry is the
1134
+ // archived-row path that auto-archive triggers (no PTY to stop).
1135
+ if (outcome.scopeOutcome === 'error') {
1136
+ timed(deps.logger, 'POST', `/${sessionId}/stop`, 500, Date.now() - start);
1137
+ return c.json({
1138
+ error: 'scope-stop-failed',
1139
+ exitCode: outcome.exitCode,
1140
+ detail: 'systemctl --user stop returned a non-zero exit code for the session\'s scope unit',
1141
+ }, 500);
1142
+ }
850
1143
  timed(deps.logger, 'POST', `/${sessionId}/stop`, 204, Date.now() - start);
851
1144
  return c.body(null, 204);
852
1145
  });
@@ -860,26 +1153,40 @@ export function buildHttpApp(deps) {
860
1153
  }
861
1154
  const row = resolveRow(deps.watcher, id);
862
1155
  const sessionId = row?.sessionId ?? id;
863
- if (deps.store.get(sessionId)) {
1156
+ if (isLive(sessionId)) {
864
1157
  deps.logger(`delete-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
865
1158
  timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
866
1159
  return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
867
1160
  }
868
- if (!row || !row.jsonlPath) {
1161
+ // Task 260 — purge from BOTH locations: a row may have its JSONL
1162
+ // at the top level (live or just-ended) or under archive/ (archived).
1163
+ const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1164
+ const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
1165
+ const topSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId);
1166
+ const archivedSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
1167
+ const hasTop = existsSync(topJsonl);
1168
+ const hasArchived = existsSync(archivedJsonl);
1169
+ if (!row || (!hasTop && !hasArchived)) {
869
1170
  timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
870
1171
  return c.json({ error: 'session-not-found' }, 404);
871
1172
  }
872
1173
  const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
873
1174
  let bytes = 0;
874
1175
  try {
875
- bytes = existsSync(row.jsonlPath) ? statSync(row.jsonlPath).size : 0;
1176
+ bytes = hasTop ? statSync(topJsonl).size : hasArchived ? statSync(archivedJsonl).size : 0;
876
1177
  }
877
1178
  catch {
878
1179
  // size is informational
879
1180
  }
880
1181
  try {
881
- if (existsSync(row.jsonlPath))
882
- rmSync(row.jsonlPath);
1182
+ if (hasTop)
1183
+ rmSync(topJsonl);
1184
+ if (hasArchived)
1185
+ rmSync(archivedJsonl);
1186
+ if (existsSync(topSidecar))
1187
+ rmSync(topSidecar);
1188
+ if (existsSync(archivedSidecar))
1189
+ rmSync(archivedSidecar);
883
1190
  if (existsSync(subdir))
884
1191
  rmSync(subdir, { recursive: true, force: true });
885
1192
  }
@@ -889,6 +1196,11 @@ export function buildHttpApp(deps) {
889
1196
  timed(deps.logger, 'DELETE', `/${id}`, 500, Date.now() - start);
890
1197
  return c.json({ error: 'delete-failed', detail: msg }, 500);
891
1198
  }
1199
+ // Task 253 — drop the user-title entry for the purged session so the
1200
+ // session-titles.json doesn't accrete orphans. Best-effort; the store
1201
+ // logs its own failure and reads still serve from the in-memory map
1202
+ // until next boot.
1203
+ deps.userTitleStore.remove(sessionId);
892
1204
  deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
893
1205
  timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
894
1206
  return c.body(null, 204);
@@ -898,15 +1210,6 @@ export function buildHttpApp(deps) {
898
1210
  const id = c.req.param('sessionId');
899
1211
  const row = resolveRow(deps.watcher, id);
900
1212
  const sessionId = row?.sessionId ?? id;
901
- const s = deps.store.get(sessionId);
902
- if (!s) {
903
- timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
904
- return c.json({ error: 'session-not-found' }, 404);
905
- }
906
- if (s.exited) {
907
- timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
908
- return c.json({ error: 'session-exited' }, 410);
909
- }
910
1213
  let body;
911
1214
  try {
912
1215
  body = await c.req.json();
@@ -919,61 +1222,46 @@ export function buildHttpApp(deps) {
919
1222
  timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
920
1223
  return c.json({ error: 'text-required' }, 400);
921
1224
  }
922
- const bytes = Buffer.byteLength(body.text, 'utf8');
923
- // Task 122: multi-line `text` must arrive at the TUI as one submitted
924
- // turn, not as keystrokes. Claude's TUI treats embedded `\n` typed via
925
- // `pty.write` as a bracketed-paste boundary that buffers and never
926
- // submits. Wrapping the bytes in DEC bracketed-paste markers
927
- // (\e[200~ ... \e[201~) and then sending `\r` makes the TUI treat the
928
- // payload as a single paste followed by an explicit submit keystroke.
929
- // Single-line text keeps the previous typed-keystroke path byte-for-byte
930
- // so the Sidebar timestamp stimulus is unchanged.
931
- const multiline = body.text.includes('\n');
932
- const submitMode = multiline ? 'bracketed-paste' : 'typed';
933
- try {
934
- if (multiline) {
935
- s.pty.write(`\x1b[200~${body.text}\x1b[201~\r`);
1225
+ const channel = row?.channel ?? null;
1226
+ const result = writeInputToPty(sessionId, body.text, deps.logger, channel);
1227
+ if (!result.ok) {
1228
+ if (result.error === 'not-found') {
1229
+ timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
1230
+ return c.json({ error: 'session-not-found' }, 404);
936
1231
  }
937
- else {
938
- s.pty.write(body.text + '\r');
1232
+ if (result.error === 'exited') {
1233
+ timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
1234
+ return c.json({ error: 'session-exited' }, 410);
939
1235
  }
940
- }
941
- catch (err) {
942
- const msg = err instanceof Error ? err.message : String(err);
943
- deps.logger(`input-failed pid=${s.pid} sessionId=${sessionId} message=${msg}`);
944
1236
  timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
945
- return c.json({ error: 'pty-write-failed', detail: msg }, 500);
1237
+ return c.json({ error: 'pty-write-failed', detail: result.detail }, 500);
946
1238
  }
947
- deps.logger(`input pid=${s.pid} sessionId=${sessionId} bytes=${bytes} channel=${s.channel} multiline=${multiline} submit-mode=${submitMode}`);
948
1239
  timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
949
- return c.json({ ok: true, bytes }, 200);
1240
+ return c.json({ ok: true, bytes: result.bytes }, 200);
950
1241
  });
951
1242
  app.get('/:sessionId/log', (c) => {
952
1243
  const start = Date.now();
953
1244
  const id = c.req.param('sessionId');
954
1245
  const row = resolveRow(deps.watcher, id);
955
1246
  const sessionId = row?.sessionId ?? id;
956
- 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) {
1247
+ const live = isLive(sessionId);
1248
+ // Task 260 — /log reads from whichever location holds the JSONL
1249
+ // right now: top level for live + recently-ended rows, archive
1250
+ // subdir for archived rows. 404 when there's no row AND no live
1251
+ // PTY for this id; 202 pending when a live PTY hasn't flushed
1252
+ // its first turn yet (no JSONL on disk in either location).
1253
+ if (!live && !row) {
965
1254
  timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
966
1255
  return c.json({ error: 'session-not-found' }, 404);
967
1256
  }
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) {
1257
+ const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1258
+ const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
1259
+ const jsonlPath = existsSync(topJsonl) ? topJsonl : archivedJsonl;
1260
+ if (live && !existsSync(topJsonl) && !existsSync(archivedJsonl)) {
972
1261
  timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
973
1262
  return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
974
1263
  }
975
- const jsonlPath = row?.jsonlPath ?? s?.jsonlPath;
976
- if (!jsonlPath) {
1264
+ if (!existsSync(jsonlPath)) {
977
1265
  timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
978
1266
  return c.json({ error: 'session-not-found' }, 404);
979
1267
  }
@@ -1043,7 +1331,12 @@ export function buildHttpApp(deps) {
1043
1331
  }
1044
1332
  position = initialSize;
1045
1333
  }
1046
- deps.logger(`log-follow-open sessionId=${sessionId} initialBytes=${position} pid=${s?.pid ?? 'null'}`);
1334
+ // Task 260 — prefer the row's PID (watcher-derived) but fall back
1335
+ // to the live tracker if the row hasn't been populated yet (the
1336
+ // pre-first-turn window during which the watcher's PID-file event
1337
+ // has not fired against the row builder).
1338
+ const followPid = row?.pid ?? getPtyTrackerForTests(sessionId)?.pid ?? null;
1339
+ deps.logger(`log-follow-open sessionId=${sessionId} initialBytes=${position} pid=${followPid ?? 'null'}`);
1047
1340
  let active = true;
1048
1341
  out.onAbort(() => {
1049
1342
  active = false;
@@ -1075,7 +1368,7 @@ export function buildHttpApp(deps) {
1075
1368
  deps.logger(`log-follow-close sessionId=${sessionId} reason=${closeReason} linesStreamed=${linesStreamed} ms=${Date.now() - followStreamStart}`);
1076
1369
  });
1077
1370
  });
1078
- app.get('/healthz', (c) => c.json({ ok: true, sessions: deps.store.size() }));
1371
+ app.get('/healthz', (c) => c.json({ ok: true, sessions: livePtyCount() }));
1079
1372
  return app;
1080
1373
  }
1081
1374
  //# sourceMappingURL=http-server.js.map