@rubytech/create-maxy-code 0.1.92 → 0.1.94

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 +2 -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 +316 -211
  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'];
@@ -131,8 +132,15 @@ function parseSpecialistDomains(raw) {
131
132
  return out.length > 0 ? out : undefined;
132
133
  }
133
134
  // `<id>` may be: intrinsic UUID, bridgeSessionId ("session_<suffix>"),
134
- // bridgeSuffix, or pid (numeric). All resolve to a sessionId via watcher.
135
- const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
135
+ // bridgeSuffix, pid (numeric), or the resume-conflict stash composite
136
+ // id `<uuid>.<unix-ms>` minted at http-server.ts:1006/1013 and surfaced
137
+ // by Task 260's archive enumerator. Dot is admitted; length raised to
138
+ // fit UUID (36) + `.` + 13-digit ms; the negative lookahead rejects
139
+ // all-dot strings so path-traversal shapes (`.`, `..`, `...`) cannot
140
+ // satisfy the gate. No `/` — Hono's route param already excludes it,
141
+ // and admitting it here would broaden the filesystem surface the
142
+ // downstream `jsonlPathForSessionId` composition trusts.
143
+ export const SESSION_ID_PATTERN = /^(?!\.+$)[A-Za-z0-9._-]{1,160}$/;
136
144
  const LIST_TAIL_BYTES = 64 * 1024;
137
145
  function timed(logger, method, path, status, ms) {
138
146
  logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
@@ -166,11 +174,11 @@ function resolveRow(watcher, id) {
166
174
  // auto-archive` lines cover the lifecycle.
167
175
  const END_TURN_SIGNATURE = '"stop_reason":"end_turn"';
168
176
  const AUTO_ARCHIVE_SPECIALIST = 'database-operator';
169
- function attachSpecialistEndTurnAutoArchive(deps, session) {
170
- if (session.specialist !== AUTO_ARCHIVE_SPECIALIST)
177
+ function attachSpecialistEndTurnAutoArchive(deps, sessionId, specialist) {
178
+ if (specialist !== AUTO_ARCHIVE_SPECIALIST)
171
179
  return;
172
180
  let archived = false;
173
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, session.sessionId);
181
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
174
182
  function endTurnSeen() {
175
183
  try {
176
184
  const content = readFileSync(jsonlPath, 'utf8');
@@ -188,35 +196,38 @@ function attachSpecialistEndTurnAutoArchive(deps, session) {
188
196
  if (!endTurnSeen())
189
197
  return;
190
198
  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);
199
+ deps.logger(`auto-archive sessionId=${sessionId} specialist=${AUTO_ARCHIVE_SPECIALIST} reason=end-turn archivedAt=${new Date().toISOString()}`);
200
+ void stopSession({
201
+ killGraceMs: deps.killGraceMs,
202
+ logger: deps.logger,
203
+ runSystemctl: deps.runSystemctl,
204
+ }, sessionId);
193
205
  }
194
206
  tryArchive();
195
207
  const unsubscribe = deps.watcher.subscribe((event) => {
196
208
  if (archived)
197
209
  return;
198
- if (event.sessionId !== session.sessionId)
210
+ if (event.sessionId !== sessionId)
199
211
  return;
200
212
  if (event.source !== 'jsonl')
201
213
  return;
202
214
  tryArchive();
203
215
  });
204
- session.pty.onExit(() => unsubscribe());
216
+ onPtyExit(sessionId, () => unsubscribe());
205
217
  }
206
- function attachPublicAudit(deps, session) {
207
- if (session.role !== 'public')
218
+ function attachPublicAudit(deps, sessionId, role) {
219
+ if (role !== 'public')
208
220
  return;
209
- const claudeSessionId = session.sessionId;
210
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, claudeSessionId);
221
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
211
222
  if (!existsSync(jsonlPath))
212
223
  return;
213
- deps.publicToolAudit.attach(claudeSessionId, jsonlPath);
214
- if (session.exited) {
215
- deps.publicToolAudit.detach(claudeSessionId);
224
+ deps.publicToolAudit.attach(sessionId, jsonlPath);
225
+ if (!isLive(sessionId)) {
226
+ deps.publicToolAudit.detach(sessionId);
216
227
  return;
217
228
  }
218
- session.pty.onExit(() => {
219
- deps.publicToolAudit.detach(claudeSessionId);
229
+ onPtyExit(sessionId, () => {
230
+ deps.publicToolAudit.detach(sessionId);
220
231
  });
221
232
  }
222
233
  export function buildHttpApp(deps) {
@@ -245,8 +256,10 @@ export function buildHttpApp(deps) {
245
256
  }
246
257
  app.__notifyRenameForTests = notifyRename;
247
258
  // 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).
259
+ // session, attach the public-audit watcher. The watcher row carries
260
+ // the role from the sidecar (Task 260disk is the source of truth),
261
+ // so the audit attachment fires deterministically against rows whose
262
+ // PTY is still alive in this manager process.
250
263
  deps.watcher.subscribe((event) => {
251
264
  if (event.source !== 'jsonl')
252
265
  return;
@@ -254,10 +267,9 @@ export function buildHttpApp(deps) {
254
267
  return;
255
268
  if (!event.row)
256
269
  return;
257
- const live = deps.store.get(event.sessionId);
258
- if (!live)
270
+ if (!isLive(event.sessionId))
259
271
  return;
260
- attachPublicAudit(deps, live);
272
+ attachPublicAudit(deps, event.sessionId, event.row.role);
261
273
  });
262
274
  // Task 183 — dispatch jsonl modify/create events into the recorder
263
275
  // tail. The tail filters by registered sessionId (only specialist
@@ -279,75 +291,71 @@ export function buildHttpApp(deps) {
279
291
  tail.onJsonlModify(event.sessionId, path);
280
292
  });
281
293
  }
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 };
294
+ /** Task 260/spawn and /resume need to return a wire payload
295
+ * immediately, but at that moment the watcher row may not yet
296
+ * include the sidecar fields (the JSONL CREATE event hasn't fired
297
+ * in fresh-spawn tests with synthetic watchers). Build the payload
298
+ * from the row if present, falling back to a synthetic shape that
299
+ * reads the sidecar directly. */
300
+ function buildSpawnReturnPayload(sessionId, pid, startedAt, capturedUrl) {
301
+ const row = deps.watcher.getBySessionId(sessionId);
302
+ if (row && row.senderId !== null) {
303
+ return toPayload(row);
295
304
  }
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);
305
+ // Read the sidecar directly — the spawn just wrote it.
306
+ const sidecarPath = sidecarPathForSessionId(deps.spawnCwd, sessionId);
307
+ const { sidecar } = readSidecar(sidecarPath, deps.logger);
308
+ const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
316
309
  const hasJsonl = existsSync(jsonlPath);
317
- const title = liveTitleAndSource(s);
318
310
  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,
311
+ sessionId,
312
+ pid,
313
+ url: capturedUrl ?? sidecar?.url ?? null,
314
+ startedAt: sidecar?.startedAt ?? startedAt,
315
+ senderId: sidecar?.senderId ?? '',
316
+ role: sidecar?.role ?? null,
317
+ channel: sidecar?.channel ?? null,
326
318
  jsonlPath: hasJsonl ? jsonlPath : null,
327
319
  status: 'alive',
328
320
  archived: false,
329
- displayName: title.displayName,
330
- titleSource: title.titleSource,
321
+ displayName: null,
322
+ titleSource: null,
331
323
  lastMessageAt: null,
332
324
  turnCount: 0,
333
325
  capped: false,
334
326
  sizeBytes: hasJsonl ? statSync(jsonlPath).size : null,
335
- hidden: s.hidden,
336
- specialist: s.specialist,
327
+ hidden: sidecar?.hidden ?? false,
328
+ specialist: sidecar?.specialist ?? null,
337
329
  model: null,
338
330
  };
339
331
  }
340
- function toArchivedPayload(row, senderId) {
332
+ /** Task 260 — single payload builder. Both live and archived rows
333
+ * derive every wire field from the watcher row (which itself derived
334
+ * from the sidecar + JSONL + PID file). The split between
335
+ * `toLivePayload`/`toArchivedPayload` is gone; the row's `state`
336
+ * and `pid` decide `status` and the `archived` flag.
337
+ *
338
+ * The `model` field continues to derive from the JSONL tail (last
339
+ * assistant turn) rather than the sidecar's spawn-request model —
340
+ * see [[learnings_writer_reader_charset_drift]] family: the running
341
+ * model is what the wire's `model` has historically meant, and the
342
+ * sidecar's `model` is kept as a self-diagnostic record. */
343
+ function toPayload(row) {
344
+ const isLiveState = row.state === 'live';
345
+ const archivedJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
346
+ const topJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
347
+ const jsonlPath = isLiveState
348
+ ? topJsonlPath
349
+ : existsSync(archivedJsonlPath) ? archivedJsonlPath : topJsonlPath;
350
+ const hasJsonl = existsSync(jsonlPath);
341
351
  let displayName = null;
342
352
  let titleSource = null;
343
353
  let lastMessageAt = null;
344
354
  let turnCount = 0;
345
355
  let capped = false;
346
- let sizeBytes = null;
356
+ let sizeBytes = hasJsonl ? statSync(jsonlPath).size : null;
347
357
  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);
358
+ let enrichedStartedAt = null;
351
359
  if (hasJsonl) {
352
360
  const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
353
361
  if (enriched) {
@@ -357,38 +365,49 @@ export function buildHttpApp(deps) {
357
365
  turnCount = enriched.turnCount;
358
366
  capped = enriched.capped;
359
367
  sizeBytes = enriched.sizeBytes;
360
- startedAt = enriched.startedAt;
368
+ enrichedStartedAt = enriched.startedAt;
361
369
  model = enriched.model;
362
370
  }
363
371
  }
364
372
  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
373
  const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
368
374
  if (userTitle) {
369
375
  displayName = userTitle;
370
376
  titleSource = 'user';
371
377
  }
372
378
  }
379
+ // startedAt resolution order:
380
+ // 1. JSONL enrichment (the most authoritative — claude's own record)
381
+ // 2. Sidecar (/spawn time)
382
+ // 3. Row mtime fallback (legacy archived rows without sidecar)
383
+ const startedAt = enrichedStartedAt
384
+ ?? row.startedAt
385
+ ?? new Date(row.updatedAt || 0).toISOString();
386
+ // Agent label: PID-file `agent` for live rows; cachedAgentSetting for
387
+ // archived rows (JSONL head). Sidecar's `sidecarSpecialist` is the
388
+ // spawn-time request — fall back to it when both above are null so
389
+ // post-Task-260 rows surface their specialist even if the JSONL
390
+ // doesn't include an agent-setting record.
391
+ const specialist = row.agent ?? row.cachedAgentSetting ?? row.sidecarSpecialist;
373
392
  return {
374
393
  sessionId: row.sessionId,
375
- pid: null,
376
- url: null,
394
+ pid: row.pid,
395
+ url: row.url,
377
396
  startedAt,
378
- senderId,
379
- role: null,
380
- channel: null,
397
+ senderId: row.senderId ?? '',
398
+ role: row.role,
399
+ channel: row.channel,
381
400
  jsonlPath: hasJsonl ? jsonlPath : null,
382
- status: 'ended',
383
- archived: isArchivedJsonlPath(row.sessionId),
401
+ status: isLiveState ? 'alive' : 'ended',
402
+ archived: row.state === 'archived',
384
403
  displayName,
385
404
  titleSource,
386
405
  lastMessageAt,
387
406
  turnCount,
388
407
  capped,
389
408
  sizeBytes,
390
- hidden: false,
391
- specialist: row.agent,
409
+ hidden: row.hidden,
410
+ specialist,
392
411
  model,
393
412
  };
394
413
  }
@@ -462,7 +481,16 @@ export function buildHttpApp(deps) {
462
481
  : body.tunnelUrl === null
463
482
  ? null
464
483
  : undefined;
465
- const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => { attachPublicAudit(deps, s); attachSpecialistEndTurnAutoArchive(deps, s); }, onUrlCaptured: notifyUrlCaptured, tunnelUrlOverride }, {
484
+ const result = await spawnClaudeSession({
485
+ ...deps,
486
+ watcher: deps.watcher,
487
+ onSessionReady: (tracker) => {
488
+ attachPublicAudit(deps, tracker.sessionId, role);
489
+ attachSpecialistEndTurnAutoArchive(deps, tracker.sessionId, tracker.specialist);
490
+ },
491
+ onUrlCaptured: notifyUrlCaptured,
492
+ tunnelUrlOverride,
493
+ }, {
466
494
  senderId,
467
495
  userId,
468
496
  role: role,
@@ -491,7 +519,8 @@ export function buildHttpApp(deps) {
491
519
  return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, 500);
492
520
  }
493
521
  timed(deps.logger, 'POST', '/spawn', 200, Date.now() - start);
494
- return c.json(toLivePayload(result.session), 200);
522
+ const payload = buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url);
523
+ return c.json(payload, 200);
495
524
  });
496
525
  // SSE feed of row deltas. One channel per client; manager fans out from
497
526
  // a single watcher subscription. On connect: replay current rows as
@@ -551,10 +580,17 @@ export function buildHttpApp(deps) {
551
580
  firstPrompt,
552
581
  titleSource,
553
582
  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),
583
+ // Task 260 — url is on the row from the sidecar; url-capture
584
+ // mutates the sidecar in place and the watcher re-reads on the
585
+ // next event. Earlier this dipped into deps.store for the live-
586
+ // row case and returned null for everything else.
587
+ url: row.url,
588
+ // Task 260 — archive state is on the row directly (the watcher
589
+ // enumerates both top and archive subdirs and folds a top→archive
590
+ // move into a single state change). Earlier this was an extra
591
+ // existsSync against the archive path because archived rows
592
+ // weren't first-class members of the index.
593
+ archived: row.state === 'archived',
558
594
  };
559
595
  }
560
596
  timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
@@ -630,12 +666,13 @@ export function buildHttpApp(deps) {
630
666
  deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1`);
631
667
  void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
632
668
  });
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.
669
+ // Task 189 / 260 — when `url-capture` fires for a session, the
670
+ // pty-spawner calls `notifyUrlCaptured(sessionId)` after writing
671
+ // the URL to the sidecar. Re-emit the row so the client's hidden
672
+ // Open-in-new-tab arrow appears in step. The row lookup goes
673
+ // through the watcher (canonical row index); rowToPayload then
674
+ // reads `url` from the row (which the watcher just refreshed
675
+ // from the sidecar).
639
676
  const urlListener = (sessionId) => {
640
677
  if (!active)
641
678
  return;
@@ -698,32 +735,41 @@ export function buildHttpApp(deps) {
698
735
  timed(deps.logger, 'GET', '/list', 400, Date.now() - start);
699
736
  return c.json({ error: 'senderId-required' }, 400);
700
737
  }
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 = [];
738
+ // Task 260 — one source. Filter the watcher's row index by sidecar
739
+ // senderId, then partition into live vs archived for the log line
740
+ // (the merged list returns to the caller in startedAt-desc order).
741
+ // Rows without a sidecar carry `senderId: null` and never match
742
+ // unless includeHidden=1 is passed (the legacy pre-Task-260 archives
743
+ // case). Hidden rows are dropped unless includeHidden=1.
744
+ const payloads = [];
745
+ let liveCount = 0;
746
+ let archivedCount = 0;
747
+ let hiddenSkipped = 0;
711
748
  for (const row of deps.watcher.all()) {
712
- if (row.state !== 'archived')
749
+ if (row.senderId !== senderId && !(includeHidden && row.senderId === null))
713
750
  continue;
714
- if (liveSessionIds.has(row.sessionId))
751
+ if (row.hidden && !includeHidden) {
752
+ hiddenSkipped += 1;
715
753
  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)))
754
+ }
755
+ // Confirm the JSONL is actually on disk; row state can lag behind
756
+ // a delete during boot-seed timing edges. Live rows whose PID
757
+ // file exists pre-first-turn legitimately have no JSONL — keep
758
+ // them in the result.
759
+ const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
760
+ const topJsonl = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
761
+ if (row.state === 'archived' && !existsSync(archivedJsonl) && !existsSync(topJsonl))
719
762
  continue;
720
- dehydrated.push(toArchivedPayload(row, senderId));
763
+ payloads.push(toPayload(row));
764
+ if (row.state === 'live')
765
+ liveCount += 1;
766
+ else
767
+ archivedCount += 1;
721
768
  }
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));
769
+ deps.logger(`[list-source] live=${liveCount} archived=${archivedCount} hidden-skipped=${hiddenSkipped}`);
770
+ payloads.sort((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
725
771
  timed(deps.logger, 'GET', '/list', 200, Date.now() - start);
726
- return c.json(merged, 200);
772
+ return c.json(payloads, 200);
727
773
  });
728
774
  app.post('/resume', async (c) => {
729
775
  const start = Date.now();
@@ -800,6 +846,19 @@ export function buildHttpApp(deps) {
800
846
  if (archivedExists && !canonicalExists) {
801
847
  try {
802
848
  renameSync(archivedJsonl, canonicalJsonl);
849
+ // Task 260 — sidecar travels with the JSONL. Move the archived
850
+ // sidecar to the top-level path so the watcher picks it up on
851
+ // the next reconcile.
852
+ const archivedSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
853
+ const canonicalSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId);
854
+ if (existsSync(archivedSidecar)) {
855
+ try {
856
+ renameSync(archivedSidecar, canonicalSidecar);
857
+ }
858
+ catch (err) {
859
+ deps.logger(`resume-sidecar-restore-failed sessionId=${sessionId.slice(0, 8)} message=${err instanceof Error ? err.message : String(err)}`);
860
+ }
861
+ }
803
862
  deps.logger(`resume-archive-restored sessionId=${sessionId.slice(0, 8)}`);
804
863
  }
805
864
  catch (err) {
@@ -837,7 +896,16 @@ export function buildHttpApp(deps) {
837
896
  return c.json({ error: 'resume-restore-failed', detail: msg }, 500);
838
897
  }
839
898
  }
840
- const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => { attachPublicAudit(deps, s); attachSpecialistEndTurnAutoArchive(deps, s); }, onUrlCaptured: notifyUrlCaptured, tunnelUrlOverride }, {
899
+ const result = await spawnClaudeSession({
900
+ ...deps,
901
+ watcher: deps.watcher,
902
+ onSessionReady: (tracker) => {
903
+ attachPublicAudit(deps, tracker.sessionId, role);
904
+ attachSpecialistEndTurnAutoArchive(deps, tracker.sessionId, tracker.specialist);
905
+ },
906
+ onUrlCaptured: notifyUrlCaptured,
907
+ tunnelUrlOverride,
908
+ }, {
841
909
  senderId,
842
910
  userId,
843
911
  role: role,
@@ -867,11 +935,13 @@ export function buildHttpApp(deps) {
867
935
  }
868
936
  // Task 258 — the archive-restore-before-spawn flow means the operator's
869
937
  // 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.
938
+ // The PtyTracker's sessionId is set to that same prior canonical id
939
+ // (resumeClaudeSessionId), so the wire payload echoes it directly.
873
940
  deps.logger(`resume pid=${result.session.pid} sessionId=${sessionId} resumeFrom=${sessionId} latency-ms=${Date.now() - start}`);
874
- const payload = { ...toLivePayload(result.session), sessionId };
941
+ const payload = {
942
+ ...buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url),
943
+ sessionId,
944
+ };
875
945
  if (idempotencyKey) {
876
946
  resumeDedupCache.set(`${senderId}:${idempotencyKey}`, { payload, createdAt: Date.now() });
877
947
  }
@@ -890,30 +960,27 @@ export function buildHttpApp(deps) {
890
960
  timed(deps.logger, 'GET', `/${id}/meta`, 404, Date.now() - start);
891
961
  return c.json({ error: 'session-not-found' }, 404);
892
962
  }
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
- }
963
+ // Task 260 — single row source. /meta uses the deeper enumerator
964
+ // limit (jsonlReadMaxBytes) for richer turn/sizeBytes data than
965
+ // /list provides; otherwise the payload is identical to /list.
966
+ let payload = toPayload(row);
967
+ const candidatePath = row.state === 'archived'
968
+ ? jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
969
+ : jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
970
+ if (existsSync(candidatePath)) {
971
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, deps.jsonlReadMaxBytes, deps.logger, deps.userTitleStore);
972
+ if (enriched) {
973
+ payload = {
974
+ ...payload,
975
+ lastMessageAt: enriched.lastMessageAt,
976
+ turnCount: enriched.turnCount,
977
+ capped: enriched.capped,
978
+ sizeBytes: enriched.sizeBytes,
979
+ model: enriched.model,
980
+ displayName: enriched.displayName,
981
+ titleSource: enriched.titleSource,
982
+ };
912
983
  }
913
- payload = { ...base, lastMessageAt, turnCount, capped, sizeBytes, model };
914
- }
915
- else {
916
- payload = toArchivedPayload(row, '');
917
984
  }
918
985
  deps.logger(`meta sessionId=${row.sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
919
986
  timed(deps.logger, 'GET', `/${id}/meta`, 200, Date.now() - start);
@@ -948,19 +1015,14 @@ export function buildHttpApp(deps) {
948
1015
  const slugDir = join(claudeStateRoot(), 'projects', slug);
949
1016
  const archiveDir = join(slugDir, 'archive');
950
1017
  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)}`);
1018
+ // Task 260 — refuse archive whenever an alive PTY exists for this
1019
+ // row in the manager's tracker map. Earlier this walked every
1020
+ // store entry to find one whose derived path equaled the candidate
1021
+ // that loop was redundant because the JSONL basename IS the
1022
+ // sessionId, so the only relevant check is "is this sessionId
1023
+ // tracker alive?". `isLive` answers in O(1).
1024
+ if (mode === 'archive' && isLive(row.sessionId)) {
1025
+ deps.logger(`archive-refused sessionId=${row.sessionId.slice(0, 8)} reason=pty-alive`);
964
1026
  timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
965
1027
  return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
966
1028
  }
@@ -981,7 +1043,29 @@ export function buildHttpApp(deps) {
981
1043
  timed(deps.logger, 'POST', `/${id}/archive`, 500, Date.now() - start);
982
1044
  return c.json({ error: 'archive-failed', detail: msg }, 500);
983
1045
  }
984
- deps.logger(`archive sessionId=${row.sessionId.slice(0, 8)} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))}`);
1046
+ // Task 260 the sidecar travels with the JSONL. Best-effort: an
1047
+ // orphan sidecar at the source path is self-healing (the next
1048
+ // bootSeed indexes the row with null fields and the operator
1049
+ // re-archives), but the orphan is itself a regression signal the
1050
+ // log line surfaces.
1051
+ let sidecarMoved = false;
1052
+ try {
1053
+ const fromSidecar = mode === 'archive'
1054
+ ? sidecarPathForSessionId(deps.spawnCwd, row.sessionId)
1055
+ : sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
1056
+ const toSidecar = mode === 'archive'
1057
+ ? sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
1058
+ : sidecarPathForSessionId(deps.spawnCwd, row.sessionId);
1059
+ if (existsSync(fromSidecar)) {
1060
+ renameSync(fromSidecar, toSidecar);
1061
+ sidecarMoved = true;
1062
+ }
1063
+ }
1064
+ catch (err) {
1065
+ const msg = err instanceof Error ? err.message : String(err);
1066
+ deps.logger(`archive-sidecar-move-failed sessionId=${row.sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
1067
+ }
1068
+ 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
1069
  timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
986
1070
  return c.json({ ok: true, mode }, 200);
987
1071
  });
@@ -1008,7 +1092,11 @@ export function buildHttpApp(deps) {
1008
1092
  return c.json({ error: 'invalid-json-body' }, 400);
1009
1093
  }
1010
1094
  const row = resolveRow(deps.watcher, id);
1011
- const sessionId = row?.sessionId ?? (deps.store.get(id)?.sessionId);
1095
+ // Task 260 — rename resolves via the watcher row index. The live
1096
+ // tracker is no longer a secondary lookup surface — the watcher's
1097
+ // PID-file event indexes every live session, so a sessionId that
1098
+ // names no row also names no live PTY.
1099
+ const sessionId = row?.sessionId;
1012
1100
  if (!sessionId) {
1013
1101
  timed(deps.logger, 'POST', `/${id}/rename`, 404, Date.now() - start);
1014
1102
  return c.json({ error: 'session-not-found' }, 404);
@@ -1037,12 +1125,28 @@ export function buildHttpApp(deps) {
1037
1125
  return c.json({ error: 'invalid-session-id' }, 400);
1038
1126
  }
1039
1127
  const row = resolveRow(deps.watcher, id);
1040
- if (!row && !deps.store.get(id)) {
1128
+ if (!row && !isLive(id)) {
1041
1129
  timed(deps.logger, 'POST', `/${id}/stop`, 404, Date.now() - start);
1042
1130
  return c.json({ error: 'session-not-found' }, 404);
1043
1131
  }
1044
1132
  const sessionId = row?.sessionId ?? id;
1045
- await stopSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, sessionId);
1133
+ const outcome = await stopSession({
1134
+ killGraceMs: deps.killGraceMs,
1135
+ logger: deps.logger,
1136
+ runSystemctl: deps.runSystemctl,
1137
+ }, sessionId);
1138
+ // Task 250 — surface scope-stop failures as 500 so the operator sees
1139
+ // the End-click fail when systemd cannot stop the unit. ok and
1140
+ // already-gone collapse to 204 (idempotent); no-store-entry is the
1141
+ // archived-row path that auto-archive triggers (no PTY to stop).
1142
+ if (outcome.scopeOutcome === 'error') {
1143
+ timed(deps.logger, 'POST', `/${sessionId}/stop`, 500, Date.now() - start);
1144
+ return c.json({
1145
+ error: 'scope-stop-failed',
1146
+ exitCode: outcome.exitCode,
1147
+ detail: 'systemctl --user stop returned a non-zero exit code for the session\'s scope unit',
1148
+ }, 500);
1149
+ }
1046
1150
  timed(deps.logger, 'POST', `/${sessionId}/stop`, 204, Date.now() - start);
1047
1151
  return c.body(null, 204);
1048
1152
  });
@@ -1056,27 +1160,40 @@ export function buildHttpApp(deps) {
1056
1160
  }
1057
1161
  const row = resolveRow(deps.watcher, id);
1058
1162
  const sessionId = row?.sessionId ?? id;
1059
- if (deps.store.get(sessionId)) {
1163
+ if (isLive(sessionId)) {
1060
1164
  deps.logger(`delete-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
1061
1165
  timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
1062
1166
  return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
1063
1167
  }
1064
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1065
- if (!row || !existsSync(jsonlPath)) {
1168
+ // Task 260 purge from BOTH locations: a row may have its JSONL
1169
+ // at the top level (live or just-ended) or under archive/ (archived).
1170
+ const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1171
+ const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
1172
+ const topSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId);
1173
+ const archivedSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
1174
+ const hasTop = existsSync(topJsonl);
1175
+ const hasArchived = existsSync(archivedJsonl);
1176
+ if (!row || (!hasTop && !hasArchived)) {
1066
1177
  timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
1067
1178
  return c.json({ error: 'session-not-found' }, 404);
1068
1179
  }
1069
1180
  const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
1070
1181
  let bytes = 0;
1071
1182
  try {
1072
- bytes = existsSync(jsonlPath) ? statSync(jsonlPath).size : 0;
1183
+ bytes = hasTop ? statSync(topJsonl).size : hasArchived ? statSync(archivedJsonl).size : 0;
1073
1184
  }
1074
1185
  catch {
1075
1186
  // size is informational
1076
1187
  }
1077
1188
  try {
1078
- if (existsSync(jsonlPath))
1079
- rmSync(jsonlPath);
1189
+ if (hasTop)
1190
+ rmSync(topJsonl);
1191
+ if (hasArchived)
1192
+ rmSync(archivedJsonl);
1193
+ if (existsSync(topSidecar))
1194
+ rmSync(topSidecar);
1195
+ if (existsSync(archivedSidecar))
1196
+ rmSync(archivedSidecar);
1080
1197
  if (existsSync(subdir))
1081
1198
  rmSync(subdir, { recursive: true, force: true });
1082
1199
  }
@@ -1100,15 +1217,6 @@ export function buildHttpApp(deps) {
1100
1217
  const id = c.req.param('sessionId');
1101
1218
  const row = resolveRow(deps.watcher, id);
1102
1219
  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
1220
  let body;
1113
1221
  try {
1114
1222
  body = await c.req.json();
@@ -1121,50 +1229,42 @@ export function buildHttpApp(deps) {
1121
1229
  timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
1122
1230
  return c.json({ error: 'text-required' }, 400);
1123
1231
  }
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`);
1232
+ const channel = row?.channel ?? null;
1233
+ const result = writeInputToPty(sessionId, body.text, deps.logger, channel);
1234
+ if (!result.ok) {
1235
+ if (result.error === 'not-found') {
1236
+ timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
1237
+ return c.json({ error: 'session-not-found' }, 404);
1138
1238
  }
1139
- else {
1140
- s.pty.write(body.text + '\r');
1239
+ if (result.error === 'exited') {
1240
+ timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
1241
+ return c.json({ error: 'session-exited' }, 410);
1141
1242
  }
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
1243
  timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
1147
- return c.json({ error: 'pty-write-failed', detail: msg }, 500);
1244
+ return c.json({ error: 'pty-write-failed', detail: result.detail }, 500);
1148
1245
  }
1149
- deps.logger(`input pid=${s.pid} sessionId=${sessionId} bytes=${bytes} channel=${s.channel} multiline=${multiline} submit-mode=${submitMode}`);
1150
1246
  timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
1151
- return c.json({ ok: true, bytes }, 200);
1247
+ return c.json({ ok: true, bytes: result.bytes }, 200);
1152
1248
  });
1153
1249
  app.get('/:sessionId/log', (c) => {
1154
1250
  const start = Date.now();
1155
1251
  const id = c.req.param('sessionId');
1156
1252
  const row = resolveRow(deps.watcher, id);
1157
1253
  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) {
1254
+ const live = isLive(sessionId);
1255
+ // Task 260 — /log reads from whichever location holds the JSONL
1256
+ // right now: top level for live + recently-ended rows, archive
1257
+ // subdir for archived rows. 404 when there's no row AND no live
1258
+ // PTY for this id; 202 pending when a live PTY hasn't flushed
1259
+ // its first turn yet (no JSONL on disk in either location).
1260
+ if (!live && !row) {
1163
1261
  timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
1164
1262
  return c.json({ error: 'session-not-found' }, 404);
1165
1263
  }
1166
- const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1167
- if (s && !existsSync(jsonlPath)) {
1264
+ const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
1265
+ const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
1266
+ const jsonlPath = existsSync(topJsonl) ? topJsonl : archivedJsonl;
1267
+ if (live && !existsSync(topJsonl) && !existsSync(archivedJsonl)) {
1168
1268
  timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
1169
1269
  return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
1170
1270
  }
@@ -1238,7 +1338,12 @@ export function buildHttpApp(deps) {
1238
1338
  }
1239
1339
  position = initialSize;
1240
1340
  }
1241
- deps.logger(`log-follow-open sessionId=${sessionId} initialBytes=${position} pid=${s?.pid ?? 'null'}`);
1341
+ // Task 260 — prefer the row's PID (watcher-derived) but fall back
1342
+ // to the live tracker if the row hasn't been populated yet (the
1343
+ // pre-first-turn window during which the watcher's PID-file event
1344
+ // has not fired against the row builder).
1345
+ const followPid = row?.pid ?? getPtyTrackerForTests(sessionId)?.pid ?? null;
1346
+ deps.logger(`log-follow-open sessionId=${sessionId} initialBytes=${position} pid=${followPid ?? 'null'}`);
1242
1347
  let active = true;
1243
1348
  out.onAbort(() => {
1244
1349
  active = false;
@@ -1270,7 +1375,7 @@ export function buildHttpApp(deps) {
1270
1375
  deps.logger(`log-follow-close sessionId=${sessionId} reason=${closeReason} linesStreamed=${linesStreamed} ms=${Date.now() - followStreamStart}`);
1271
1376
  });
1272
1377
  });
1273
- app.get('/healthz', (c) => c.json({ ok: true, sessions: deps.store.size() }));
1378
+ app.get('/healthz', (c) => c.json({ ok: true, sessions: livePtyCount() }));
1274
1379
  return app;
1275
1380
  }
1276
1381
  //# sourceMappingURL=http-server.js.map