@rubytech/create-maxy-code 0.1.92 → 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 (108) 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/plugins/docs/references/platform.md +4 -2
  6. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +61 -2
  7. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  8. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +337 -32
  9. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  10. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +1 -3
  11. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  12. package/payload/platform/services/claude-session-manager/dist/http-server.js +307 -209
  13. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  14. package/payload/platform/services/claude-session-manager/dist/index.js +62 -13
  15. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  16. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +9 -0
  17. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
  18. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +14 -0
  19. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
  20. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +165 -33
  21. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +283 -92
  23. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  24. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +4 -2
  25. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  26. package/payload/platform/services/claude-session-manager/dist/reaper.js +23 -22
  27. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  28. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +55 -0
  29. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -0
  30. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +170 -0
  31. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -0
  32. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +114 -0
  33. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -0
  34. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +209 -0
  35. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -0
  36. package/payload/server/public/assets/{ChatInput-dLWJ0Q8I.css → ChatInput-CzQhPt90.css} +1 -1
  37. package/payload/server/public/assets/{Checkbox-Bx8mRHQ3.js → Checkbox-ynZtDWi3.js} +1 -1
  38. package/payload/server/public/assets/{admin-BhEvxMrC.js → admin-ux6TKHS1.js} +1 -1
  39. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BEW_bD50.js → architectureDiagram-Q4EWVU46-Ko_izKmY.js} +1 -1
  40. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DFLT98C3.js → blockDiagram-DXYQGD6D-D58VDC0w.js} +1 -1
  41. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BemKtROA.js → c4Diagram-AHTNJAMY-C-eRXyKi.js} +1 -1
  42. package/payload/server/public/assets/channel-CejaITMF.js +1 -0
  43. package/payload/server/public/assets/{chunk-336JU56O-BL8fGJ7_.js → chunk-336JU56O-DZLEfLAb.js} +2 -2
  44. package/payload/server/public/assets/{chunk-426QAEUC-BD6by0MP.js → chunk-426QAEUC-BriTU3L8.js} +1 -1
  45. package/payload/server/public/assets/{chunk-4TB4RGXK-DX-cb6Nd.js → chunk-4TB4RGXK-BdxRL-AY.js} +1 -1
  46. package/payload/server/public/assets/{chunk-5FUZZQ4R-B2QqtIZS.js → chunk-5FUZZQ4R-BgEmy7Y3.js} +1 -1
  47. package/payload/server/public/assets/{chunk-5PVQY5BW-CzJWmsmd.js → chunk-5PVQY5BW-emnbDRLA.js} +1 -1
  48. package/payload/server/public/assets/{chunk-EDXVE4YY-DP9vcvEU.js → chunk-EDXVE4YY-BvCf2Xf2.js} +1 -1
  49. package/payload/server/public/assets/{chunk-ENJZ2VHE-D5xWEjF0.js → chunk-ENJZ2VHE-DTpEYJRb.js} +1 -1
  50. package/payload/server/public/assets/{chunk-ICPOFSXX-AKduf9o4.js → chunk-ICPOFSXX-Ced2j48Y.js} +1 -1
  51. package/payload/server/public/assets/{chunk-OYMX7WX6-D6jsVNyQ.js → chunk-OYMX7WX6-B21_a3Dv.js} +1 -1
  52. package/payload/server/public/assets/{chunk-U2HBQHQK-BNAnTYh8.js → chunk-U2HBQHQK-CPS3yyXK.js} +1 -1
  53. package/payload/server/public/assets/{chunk-X2U36JSP-UdQhYDnh.js → chunk-X2U36JSP-BJ_B5NW7.js} +1 -1
  54. package/payload/server/public/assets/{chunk-YZCP3GAM-DSSmSGmS.js → chunk-YZCP3GAM-CMLy-WXm.js} +1 -1
  55. package/payload/server/public/assets/{chunk-ZZ45TVLE-Czm7IDMC.js → chunk-ZZ45TVLE-BkLnzCHe.js} +1 -1
  56. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BmoqALf4.js +1 -0
  57. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-vMajwCNi.js +1 -0
  58. package/payload/server/public/assets/clone-mvVfHkyg.js +1 -0
  59. package/payload/server/public/assets/{dagre-hSIMHJcO.js → dagre-Dd_gq4yj.js} +1 -1
  60. package/payload/server/public/assets/{dagre-KV5264BT-CBuRCJE7.js → dagre-KV5264BT-RY0E5UAn.js} +1 -1
  61. package/payload/server/public/assets/data-BK4r8Dqy.js +1 -0
  62. package/payload/server/public/assets/{diagram-5BDNPKRD-dbMrH0le.js → diagram-5BDNPKRD-DR7XiPLh.js} +1 -1
  63. package/payload/server/public/assets/{diagram-G4DWMVQ6-DBc6hs9a.js → diagram-G4DWMVQ6-2EbOfKFs.js} +1 -1
  64. package/payload/server/public/assets/{diagram-MMDJMWI5-ubSGybUk.js → diagram-MMDJMWI5-CGcY0Ze7.js} +1 -1
  65. package/payload/server/public/assets/{diagram-TYMM5635-WxL3cv-9.js → diagram-TYMM5635-Dk6MmAso.js} +1 -1
  66. package/payload/server/public/assets/{erDiagram-SMLLAGMA-CrBBOAN8.js → erDiagram-SMLLAGMA-CKvdlUqk.js} +1 -1
  67. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CXUq5Hzt.js → flowDiagram-DWJPFMVM-BqKhvCDz.js} +1 -1
  68. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-vTCBnYVS.js → ganttDiagram-T4ZO3ILL-CeiE-IRb.js} +1 -1
  69. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BHrj_bRa.js → gitGraphDiagram-UUTBAWPF-BbPKn7v2.js} +1 -1
  70. package/payload/server/public/assets/graph-Cb6A8Hhs.js +1 -0
  71. package/payload/server/public/assets/graph-labels-DCsUNtnE.js +1 -0
  72. package/payload/server/public/assets/{graphlib-CDO75l2X.js → graphlib-tG6kR59B.js} +1 -1
  73. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CcQdaOhi.js → infoDiagram-42DDH7IO-6MiRGCI1.js} +1 -1
  74. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-3KvltFgV.js → ishikawaDiagram-UXIWVN3A-BwY4p5mg.js} +1 -1
  75. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DZCXaGFE.js → journeyDiagram-VCZTEJTY-BXAQ_fUi.js} +1 -1
  76. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DAIAKy1-.js → kanban-definition-6JOO6SKY-ChiiO7Yt.js} +1 -1
  77. package/payload/server/public/assets/{lib-CCLURO3E.js → lib-DbPz9cfu.js} +1 -1
  78. package/payload/server/public/assets/{line-C8iCE2Wx.js → line-B5g5OrOD.js} +1 -1
  79. package/payload/server/public/assets/{mermaid-parser.core-ElvUhnV9.js → mermaid-parser.core-BMGzMKJx.js} +1 -1
  80. package/payload/server/public/assets/{mermaid.core-7dSH31Ao.js → mermaid.core-DLMgkjXn.js} +3 -3
  81. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-FZ0rcfac.js → mindmap-definition-QFDTVHPH-BvyzRLAi.js} +1 -1
  82. package/payload/server/public/assets/{page-C1hj89Gf.js → page-BeYR_GI2.js} +1 -1
  83. package/payload/server/public/assets/{page-Bu8kO4jO.js → page-DVNp-Dfy.js} +1 -1
  84. package/payload/server/public/assets/{pieDiagram-DEJITSTG-CDuvMZbO.js → pieDiagram-DEJITSTG--EV9pVfs.js} +1 -1
  85. package/payload/server/public/assets/{public-g_2skbnY.js → public-sYW0Dwku.js} +3 -3
  86. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-xDjhkqNc.js → quadrantDiagram-34T5L4WZ-DJcL08ft.js} +1 -1
  87. package/payload/server/public/assets/{requirementDiagram-MS252O5E-BPKpyT0n.js → requirementDiagram-MS252O5E-D4VdmmSu.js} +1 -1
  88. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BvFIrihC.js → sankeyDiagram-XADWPNL6-Dsw3nHRS.js} +1 -1
  89. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-C6G1oAE0.js → sequenceDiagram-FGHM5R23-CAgemUx5.js} +1 -1
  90. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DbOJT_Xh.js → stateDiagram-FHFEXIEX-B7dyL9_X.js} +1 -1
  91. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CImN6Wk6.js +1 -0
  92. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D1DSxHh6.js → timeline-definition-GMOUNBTQ-B7iKswhp.js} +1 -1
  93. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BYoSeMdo.js → vennDiagram-DHZGUBPP-uFS0i1qX.js} +1 -1
  94. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DH3kZdea.js → wardleyDiagram-NUSXRM2D-lDUh4LAD.js} +1 -1
  95. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-C6MROMLG.js → xychartDiagram-5P7HB3ND-0v7MEVwW.js} +1 -1
  96. package/payload/server/public/data.html +5 -5
  97. package/payload/server/public/graph.html +6 -6
  98. package/payload/server/public/index.html +8 -8
  99. package/payload/server/public/public.html +5 -5
  100. package/payload/server/public/assets/channel-CfpByn-m.js +0 -1
  101. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DNc9R1IX.js +0 -1
  102. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DM1DQh8N.js +0 -1
  103. package/payload/server/public/assets/clone-BSrNdvLW.js +0 -1
  104. package/payload/server/public/assets/data-Cbo0tTS3.js +0 -1
  105. package/payload/server/public/assets/graph-Beucu3_J.js +0 -1
  106. package/payload/server/public/assets/graph-labels-D15-eFQK.js +0 -1
  107. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ce4GFk-H.js +0 -1
  108. /package/payload/server/public/assets/{ChatInput-DoS80kaC.js → ChatInput-Cj9JQtYt.js} +0 -0
@@ -24,9 +24,10 @@
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, jsonlPathForSessionId } from './jsonl-path.js';
30
+ import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId, sidecarPathForSessionId } from './jsonl-path.js';
30
31
  import { basename, join } from 'node:path';
31
32
  import { validateUserTitle } from './user-title-store.js';
32
33
  const ROLES = ['admin', 'public'];
@@ -166,11 +167,11 @@ function resolveRow(watcher, id) {
166
167
  // auto-archive` lines cover the lifecycle.
167
168
  const END_TURN_SIGNATURE = '"stop_reason":"end_turn"';
168
169
  const AUTO_ARCHIVE_SPECIALIST = 'database-operator';
169
- function attachSpecialistEndTurnAutoArchive(deps, session) {
170
- if (session.specialist !== AUTO_ARCHIVE_SPECIALIST)
170
+ function attachSpecialistEndTurnAutoArchive(deps, sessionId, specialist) {
171
+ if (specialist !== AUTO_ARCHIVE_SPECIALIST)
171
172
  return;
172
173
  let archived = false;
173
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, session.sessionId);
174
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
174
175
  function endTurnSeen() {
175
176
  try {
176
177
  const content = readFileSync(jsonlPath, 'utf8');
@@ -188,35 +189,38 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
188
189
  if (!endTurnSeen())
189
190
  return;
190
191
  archived = true;
191
- deps.logger(`auto-archive sessionId=${session.sessionId} specialist=${AUTO_ARCHIVE_SPECIALIST} reason=end-turn archivedAt=${new Date().toISOString()}`);
192
- 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);
193
198
  }
194
199
  tryArchive();
195
200
  const unsubscribe = deps.watcher.subscribe((event) => {
196
201
  if (archived)
197
202
  return;
198
- if (event.sessionId !== session.sessionId)
203
+ if (event.sessionId !== sessionId)
199
204
  return;
200
205
  if (event.source !== 'jsonl')
201
206
  return;
202
207
  tryArchive();
203
208
  });
204
- session.pty.onExit(() => unsubscribe());
209
+ onPtyExit(sessionId, () => unsubscribe());
205
210
  }
206
- function attachPublicAudit(deps, session) {
207
- if (session.role !== 'public')
211
+ function attachPublicAudit(deps, sessionId, role) {
212
+ if (role !== 'public')
208
213
  return;
209
- const claudeSessionId = session.sessionId;
210
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, claudeSessionId);
214
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
211
215
  if (!existsSync(jsonlPath))
212
216
  return;
213
- deps.publicToolAudit.attach(claudeSessionId, jsonlPath);
214
- if (session.exited) {
215
- deps.publicToolAudit.detach(claudeSessionId);
217
+ deps.publicToolAudit.attach(sessionId, jsonlPath);
218
+ if (!isLive(sessionId)) {
219
+ deps.publicToolAudit.detach(sessionId);
216
220
  return;
217
221
  }
218
- session.pty.onExit(() => {
219
- deps.publicToolAudit.detach(claudeSessionId);
222
+ onPtyExit(sessionId, () => {
223
+ deps.publicToolAudit.detach(sessionId);
220
224
  });
221
225
  }
222
226
  export function buildHttpApp(deps) {
@@ -245,8 +249,10 @@ export function buildHttpApp(deps) {
245
249
  }
246
250
  app.__notifyRenameForTests = notifyRename;
247
251
  // Watcher subscriber: when the JSONL CREATE event fires for a live
248
- // session, attach the public-audit watcher. The path is derived
249
- // (Task 258StoredSession no longer carries jsonlPath).
252
+ // session, attach the public-audit watcher. The watcher row carries
253
+ // the role from the sidecar (Task 260disk is the source of truth),
254
+ // so the audit attachment fires deterministically against rows whose
255
+ // PTY is still alive in this manager process.
250
256
  deps.watcher.subscribe((event) => {
251
257
  if (event.source !== 'jsonl')
252
258
  return;
@@ -254,10 +260,9 @@ export function buildHttpApp(deps) {
254
260
  return;
255
261
  if (!event.row)
256
262
  return;
257
- const live = deps.store.get(event.sessionId);
258
- if (!live)
263
+ if (!isLive(event.sessionId))
259
264
  return;
260
- attachPublicAudit(deps, live);
265
+ attachPublicAudit(deps, event.sessionId, event.row.role);
261
266
  });
262
267
  // Task 183 — dispatch jsonl modify/create events into the recorder
263
268
  // tail. The tail filters by registered sessionId (only specialist
@@ -279,75 +284,71 @@ export function buildHttpApp(deps) {
279
284
  tail.onJsonlModify(event.sessionId, path);
280
285
  });
281
286
  }
282
- /** Task 253resolve (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 };
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);
295
297
  }
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 }));
313
- }
314
- function toLivePayload(s) {
315
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, s.sessionId);
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);
316
302
  const hasJsonl = existsSync(jsonlPath);
317
- const title = liveTitleAndSource(s);
318
303
  return {
319
- sessionId: s.sessionId,
320
- pid: s.pid,
321
- url: s.url,
322
- startedAt: s.startedAt,
323
- senderId: s.senderId,
324
- role: s.role,
325
- channel: s.channel,
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,
326
311
  jsonlPath: hasJsonl ? jsonlPath : null,
327
312
  status: 'alive',
328
313
  archived: false,
329
- displayName: title.displayName,
330
- titleSource: title.titleSource,
314
+ displayName: null,
315
+ titleSource: null,
331
316
  lastMessageAt: null,
332
317
  turnCount: 0,
333
318
  capped: false,
334
319
  sizeBytes: hasJsonl ? statSync(jsonlPath).size : null,
335
- hidden: s.hidden,
336
- specialist: s.specialist,
320
+ hidden: sidecar?.hidden ?? false,
321
+ specialist: sidecar?.specialist ?? null,
337
322
  model: null,
338
323
  };
339
324
  }
340
- 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);
341
344
  let displayName = null;
342
345
  let titleSource = null;
343
346
  let lastMessageAt = null;
344
347
  let turnCount = 0;
345
348
  let capped = false;
346
- let sizeBytes = null;
349
+ let sizeBytes = hasJsonl ? statSync(jsonlPath).size : null;
347
350
  let model = null;
348
- let startedAt = new Date(row.updatedAt || 0).toISOString();
349
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
350
- const hasJsonl = existsSync(jsonlPath);
351
+ let enrichedStartedAt = null;
351
352
  if (hasJsonl) {
352
353
  const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
353
354
  if (enriched) {
@@ -357,38 +358,49 @@ export function buildHttpApp(deps) {
357
358
  turnCount = enriched.turnCount;
358
359
  capped = enriched.capped;
359
360
  sizeBytes = enriched.sizeBytes;
360
- startedAt = enriched.startedAt;
361
+ enrichedStartedAt = enriched.startedAt;
361
362
  model = enriched.model;
362
363
  }
363
364
  }
364
365
  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
366
  const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
368
367
  if (userTitle) {
369
368
  displayName = userTitle;
370
369
  titleSource = 'user';
371
370
  }
372
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;
373
385
  return {
374
386
  sessionId: row.sessionId,
375
- pid: null,
376
- url: null,
387
+ pid: row.pid,
388
+ url: row.url,
377
389
  startedAt,
378
- senderId,
379
- role: null,
380
- channel: null,
390
+ senderId: row.senderId ?? '',
391
+ role: row.role,
392
+ channel: row.channel,
381
393
  jsonlPath: hasJsonl ? jsonlPath : null,
382
- status: 'ended',
383
- archived: isArchivedJsonlPath(row.sessionId),
394
+ status: isLiveState ? 'alive' : 'ended',
395
+ archived: row.state === 'archived',
384
396
  displayName,
385
397
  titleSource,
386
398
  lastMessageAt,
387
399
  turnCount,
388
400
  capped,
389
401
  sizeBytes,
390
- hidden: false,
391
- specialist: row.agent,
402
+ hidden: row.hidden,
403
+ specialist,
392
404
  model,
393
405
  };
394
406
  }
@@ -462,7 +474,16 @@ export function buildHttpApp(deps) {
462
474
  : body.tunnelUrl === null
463
475
  ? null
464
476
  : undefined;
465
- 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
+ }, {
466
487
  senderId,
467
488
  userId,
468
489
  role: role,
@@ -491,7 +512,8 @@ export function buildHttpApp(deps) {
491
512
  return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, 500);
492
513
  }
493
514
  timed(deps.logger, 'POST', '/spawn', 200, Date.now() - start);
494
- 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);
495
517
  });
496
518
  // SSE feed of row deltas. One channel per client; manager fans out from
497
519
  // a single watcher subscription. On connect: replay current rows as
@@ -551,10 +573,17 @@ export function buildHttpApp(deps) {
551
573
  firstPrompt,
552
574
  titleSource,
553
575
  updatedAt: row.updatedAt,
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),
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',
558
587
  };
559
588
  }
560
589
  timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
@@ -630,12 +659,13 @@ export function buildHttpApp(deps) {
630
659
  deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1`);
631
660
  void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
632
661
  });
633
- // Task 189 — when `url-capture` fires for a session, the pty-spawner
634
- // calls `notifyUrlCaptured(sessionId)` after mutating
635
- // `StoredSession.url`. Re-emit the row so the client's hidden
636
- // Open-in-new-tab arrow appears in step. The row lookup goes through
637
- // the watcher (canonical row index); rowToPayload then re-reads `url`
638
- // 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).
639
669
  const urlListener = (sessionId) => {
640
670
  if (!active)
641
671
  return;
@@ -698,32 +728,41 @@ export function buildHttpApp(deps) {
698
728
  timed(deps.logger, 'GET', '/list', 400, Date.now() - start);
699
729
  return c.json({ error: 'senderId-required' }, 400);
700
730
  }
701
- const liveStored = deps.store.listBySender(senderId, { includeHidden });
702
- const live = liveStored.map(toLivePayload);
703
- const liveSessionIds = new Set();
704
- for (const s of liveStored)
705
- liveSessionIds.add(s.sessionId);
706
- // Archived rows come from the watcher's JSONL index (rows whose
707
- // state === 'archived'). The watcher's boot-seed already paired
708
- // PID files with JSONLs on disk, so archived rows here are exactly
709
- // the sessions whose PTY has exited but transcript persists.
710
- 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;
711
741
  for (const row of deps.watcher.all()) {
712
- if (row.state !== 'archived')
742
+ if (row.senderId !== senderId && !(includeHidden && row.senderId === null))
713
743
  continue;
714
- if (liveSessionIds.has(row.sessionId))
744
+ if (row.hidden && !includeHidden) {
745
+ hiddenSkipped += 1;
715
746
  continue;
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)))
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))
719
755
  continue;
720
- dehydrated.push(toArchivedPayload(row, senderId));
756
+ payloads.push(toPayload(row));
757
+ if (row.state === 'live')
758
+ liveCount += 1;
759
+ else
760
+ archivedCount += 1;
721
761
  }
722
- deps.logger(`list-source live=${live.length} jsonl=${dehydrated.length}`);
723
- const merged = [...live, ...dehydrated];
724
- 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));
725
764
  timed(deps.logger, 'GET', '/list', 200, Date.now() - start);
726
- return c.json(merged, 200);
765
+ return c.json(payloads, 200);
727
766
  });
728
767
  app.post('/resume', async (c) => {
729
768
  const start = Date.now();
@@ -800,6 +839,19 @@ export function buildHttpApp(deps) {
800
839
  if (archivedExists && !canonicalExists) {
801
840
  try {
802
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
+ }
803
855
  deps.logger(`resume-archive-restored sessionId=${sessionId.slice(0, 8)}`);
804
856
  }
805
857
  catch (err) {
@@ -837,7 +889,16 @@ export function buildHttpApp(deps) {
837
889
  return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
838
890
  }
839
891
  }
840
- const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => { attachPublicAudit(deps, s); attachSpecialistEndTurnAutoArchive(deps, s); }, onUrlCaptured: notifyUrlCaptured, tunnelUrlOverride }, {
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
+ }, {
841
902
  senderId,
842
903
  userId,
843
904
  role: role,
@@ -867,11 +928,13 @@ export function buildHttpApp(deps) {
867
928
  }
868
929
  // Task 258 — the archive-restore-before-spawn flow means the operator's
869
930
  // 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.
931
+ // The PtyTracker's sessionId is set to that same prior canonical id
932
+ // (resumeClaudeSessionId), so the wire payload echoes it directly.
873
933
  deps.logger(`resume pid=${result.session.pid} sessionId=${sessionId} resumeFrom=${sessionId} latency-ms=${Date.now() - start}`);
874
- const payload = { ...toLivePayload(result.session), sessionId };
934
+ const payload = {
935
+ ...buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url),
936
+ sessionId,
937
+ };
875
938
  if (idempotencyKey) {
876
939
  resumeDedupCache.set(`${senderId}:${idempotencyKey}`, { payload, createdAt: Date.now() });
877
940
  }
@@ -890,30 +953,27 @@ export function buildHttpApp(deps) {
890
953
  timed(deps.logger, 'GET', `/${id}/meta`, 404, Date.now() - start);
891
954
  return c.json({ error: 'session-not-found' }, 404);
892
955
  }
893
- const live = deps.store.get(row.sessionId);
894
- let payload;
895
- if (live) {
896
- const base = toLivePayload(live);
897
- let lastMessageAt = null;
898
- let turnCount = 0;
899
- let capped = false;
900
- let sizeBytes = base.sizeBytes;
901
- let model = null;
902
- const liveJsonlPath = jsonlPathForSessionId(deps.spawnCwd, live.sessionId);
903
- if (existsSync(liveJsonlPath)) {
904
- const enriched = readJsonlSession(deps.spawnCwd, live.sessionId, deps.jsonlReadMaxBytes, deps.logger);
905
- if (enriched) {
906
- lastMessageAt = enriched.lastMessageAt;
907
- turnCount = enriched.turnCount;
908
- capped = enriched.capped;
909
- sizeBytes = enriched.sizeBytes;
910
- model = enriched.model;
911
- }
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
+ };
912
976
  }
913
- payload = { ...base, lastMessageAt, turnCount, capped, sizeBytes, model };
914
- }
915
- else {
916
- payload = toArchivedPayload(row, '');
917
977
  }
918
978
  deps.logger(`meta sessionId=${row.sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
919
979
  timed(deps.logger, 'GET', `/${id}/meta`, 200, Date.now() - start);
@@ -948,19 +1008,14 @@ export function buildHttpApp(deps) {
948
1008
  const slugDir = join(claudeStateRoot(), 'projects', slug);
949
1009
  const archiveDir = join(slugDir, 'archive');
950
1010
  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)}`);
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`);
964
1019
  timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
965
1020
  return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
966
1021
  }
@@ -981,7 +1036,29 @@ export function buildHttpApp(deps) {
981
1036
  timed(deps.logger, 'POST', `/${id}/archive`, 500, Date.now() - start);
982
1037
  return c.json({ error: 'archive-failed', detail: msg }, 500);
983
1038
  }
984
- 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}`);
985
1062
  timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
986
1063
  return c.json({ ok: true, mode }, 200);
987
1064
  });
@@ -1008,7 +1085,11 @@ export function buildHttpApp(deps) {
1008
1085
  return c.json({ error: 'invalid-json-body' }, 400);
1009
1086
  }
1010
1087
  const row = resolveRow(deps.watcher, id);
1011
- const sessionId = row?.sessionId ?? (deps.store.get(id)?.sessionId);
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;
1012
1093
  if (!sessionId) {
1013
1094
  timed(deps.logger, 'POST', `/${id}/rename`, 404, Date.now() - start);
1014
1095
  return c.json({ error: 'session-not-found' }, 404);
@@ -1037,12 +1118,28 @@ export function buildHttpApp(deps) {
1037
1118
  return c.json({ error: 'invalid-session-id' }, 400);
1038
1119
  }
1039
1120
  const row = resolveRow(deps.watcher, id);
1040
- if (!row && !deps.store.get(id)) {
1121
+ if (!row && !isLive(id)) {
1041
1122
  timed(deps.logger, 'POST', `/${id}/stop`, 404, Date.now() - start);
1042
1123
  return c.json({ error: 'session-not-found' }, 404);
1043
1124
  }
1044
1125
  const sessionId = row?.sessionId ?? id;
1045
- 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
+ }
1046
1143
  timed(deps.logger, 'POST', `/${sessionId}/stop`, 204, Date.now() - start);
1047
1144
  return c.body(null, 204);
1048
1145
  });
@@ -1056,27 +1153,40 @@ export function buildHttpApp(deps) {
1056
1153
  }
1057
1154
  const row = resolveRow(deps.watcher, id);
1058
1155
  const sessionId = row?.sessionId ?? id;
1059
- if (deps.store.get(sessionId)) {
1156
+ if (isLive(sessionId)) {
1060
1157
  deps.logger(`delete-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
1061
1158
  timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
1062
1159
  return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
1063
1160
  }
1064
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1065
- if (!row || !existsSync(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)) {
1066
1170
  timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
1067
1171
  return c.json({ error: 'session-not-found' }, 404);
1068
1172
  }
1069
1173
  const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
1070
1174
  let bytes = 0;
1071
1175
  try {
1072
- bytes = existsSync(jsonlPath) ? statSync(jsonlPath).size : 0;
1176
+ bytes = hasTop ? statSync(topJsonl).size : hasArchived ? statSync(archivedJsonl).size : 0;
1073
1177
  }
1074
1178
  catch {
1075
1179
  // size is informational
1076
1180
  }
1077
1181
  try {
1078
- if (existsSync(jsonlPath))
1079
- rmSync(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);
1080
1190
  if (existsSync(subdir))
1081
1191
  rmSync(subdir, { recursive: true, force: true });
1082
1192
  }
@@ -1100,15 +1210,6 @@ export function buildHttpApp(deps) {
1100
1210
  const id = c.req.param('sessionId');
1101
1211
  const row = resolveRow(deps.watcher, id);
1102
1212
  const sessionId = row?.sessionId ?? id;
1103
- const s = deps.store.get(sessionId);
1104
- if (!s) {
1105
- timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
1106
- return c.json({ error: 'session-not-found' }, 404);
1107
- }
1108
- if (s.exited) {
1109
- timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
1110
- return c.json({ error: 'session-exited' }, 410);
1111
- }
1112
1213
  let body;
1113
1214
  try {
1114
1215
  body = await c.req.json();
@@ -1121,50 +1222,42 @@ export function buildHttpApp(deps) {
1121
1222
  timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
1122
1223
  return c.json({ error: 'text-required' }, 400);
1123
1224
  }
1124
- const bytes = Buffer.byteLength(body.text, 'utf8');
1125
- // Task 122: multi-line `text` must arrive at the TUI as one submitted
1126
- // turn, not as keystrokes. Claude's TUI treats embedded `\n` typed via
1127
- // `pty.write` as a bracketed-paste boundary that buffers and never
1128
- // submits. Wrapping the bytes in DEC bracketed-paste markers
1129
- // (\e[200~ ... \e[201~) and then sending `\r` makes the TUI treat the
1130
- // payload as a single paste followed by an explicit submit keystroke.
1131
- // Single-line text keeps the previous typed-keystroke path byte-for-byte
1132
- // so the Sidebar timestamp stimulus is unchanged.
1133
- const multiline = body.text.includes('\n');
1134
- const submitMode = multiline ? 'bracketed-paste' : 'typed';
1135
- try {
1136
- if (multiline) {
1137
- 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);
1138
1231
  }
1139
- else {
1140
- 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);
1141
1235
  }
1142
- }
1143
- catch (err) {
1144
- const msg = err instanceof Error ? err.message : String(err);
1145
- deps.logger(`input-failed pid=${s.pid} sessionId=${sessionId} message=${msg}`);
1146
1236
  timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
1147
- return c.json({ error: 'pty-write-failed', detail: msg }, 500);
1237
+ return c.json({ error: 'pty-write-failed', detail: result.detail }, 500);
1148
1238
  }
1149
- deps.logger(`input pid=${s.pid} sessionId=${sessionId} bytes=${bytes} channel=${s.channel} multiline=${multiline} submit-mode=${submitMode}`);
1150
1239
  timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
1151
- return c.json({ ok: true, bytes }, 200);
1240
+ return c.json({ ok: true, bytes: result.bytes }, 200);
1152
1241
  });
1153
1242
  app.get('/:sessionId/log', (c) => {
1154
1243
  const start = Date.now();
1155
1244
  const id = c.req.param('sessionId');
1156
1245
  const row = resolveRow(deps.watcher, id);
1157
1246
  const sessionId = row?.sessionId ?? id;
1158
- const s = deps.store.get(sessionId);
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) {
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) {
1163
1254
  timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
1164
1255
  return c.json({ error: 'session-not-found' }, 404);
1165
1256
  }
1166
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1167
- if (s && !existsSync(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)) {
1168
1261
  timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
1169
1262
  return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
1170
1263
  }
@@ -1238,7 +1331,12 @@ export function buildHttpApp(deps) {
1238
1331
  }
1239
1332
  position = initialSize;
1240
1333
  }
1241
- 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'}`);
1242
1340
  let active = true;
1243
1341
  out.onAbort(() => {
1244
1342
  active = false;
@@ -1270,7 +1368,7 @@ export function buildHttpApp(deps) {
1270
1368
  deps.logger(`log-follow-close sessionId=${sessionId} reason=${closeReason} linesStreamed=${linesStreamed} ms=${Date.now() - followStreamStart}`);
1271
1369
  });
1272
1370
  });
1273
- app.get('/healthz', (c) => c.json({ ok: true, sessions: deps.store.size() }));
1371
+ app.get('/healthz', (c) => c.json({ ok: true, sessions: livePtyCount() }));
1274
1372
  return app;
1275
1373
  }
1276
1374
  //# sourceMappingURL=http-server.js.map