@rubytech/create-maxy-code 0.1.39 → 0.1.41

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 (132) hide show
  1. package/dist/index.js +35 -101
  2. package/dist/uninstall.js +11 -102
  3. package/package.json +1 -1
  4. package/payload/platform/lib/mcp-stderr-tee/package.json +7 -0
  5. package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +142 -52
  6. package/payload/platform/plugins/docs/references/deployment.md +7 -3
  7. package/payload/platform/plugins/docs/references/platform.md +18 -4
  8. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
  9. package/payload/platform/services/claude-session-manager/dist/config.d.ts +17 -0
  10. package/payload/platform/services/claude-session-manager/dist/config.d.ts.map +1 -1
  11. package/payload/platform/services/claude-session-manager/dist/config.js +37 -1
  12. package/payload/platform/services/claude-session-manager/dist/config.js.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +54 -0
  14. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -0
  15. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +516 -0
  16. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -0
  17. package/payload/platform/services/claude-session-manager/dist/hooks-discovery.d.ts +7 -0
  18. package/payload/platform/services/claude-session-manager/dist/hooks-discovery.d.ts.map +1 -0
  19. package/payload/platform/services/claude-session-manager/dist/hooks-discovery.js +90 -0
  20. package/payload/platform/services/claude-session-manager/dist/hooks-discovery.js.map +1 -0
  21. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +3 -1
  22. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/http-server.js +426 -204
  24. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/index.js +60 -13
  26. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +2 -0
  28. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +11 -6
  30. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +40 -45
  32. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +146 -162
  34. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +24 -0
  36. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -0
  37. package/payload/platform/services/claude-session-manager/dist/reaper.js +114 -0
  38. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -0
  39. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +16 -41
  40. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/session-store.js +29 -68
  42. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  43. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts +54 -0
  44. package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
  45. package/payload/platform/services/claude-session-manager/dist/system-prompt.js +111 -0
  46. package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
  47. package/payload/platform/templates/agents/admin/IDENTITY.md +4 -2
  48. package/payload/platform/templates/agents/admin/SOUL.md +8 -14
  49. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts +51 -0
  50. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -0
  51. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js +196 -0
  52. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js.map +1 -0
  53. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/package.json +7 -0
  54. package/payload/server/{chunk-NNHHGQJL.js → chunk-4P2MPERV.js} +6 -163
  55. package/payload/server/{chunk-3G6JMV7N.js → chunk-MEGIQYUD.js} +1 -1
  56. package/payload/server/{chunk-QC5LHYPH.js → chunk-XJVJ2B2Q.js} +1 -1
  57. package/payload/server/{cloudflare-task-tracker-NEB3X6O2.js → cloudflare-task-tracker-R6MMORFU.js} +2 -2
  58. package/payload/server/maxy-edge.js +2 -2
  59. package/payload/server/public/assets/{Checkbox-CmCjmfJy.js → Checkbox-blxv2XPY.js} +1 -1
  60. package/payload/server/public/assets/{admin-CtodW6As.js → admin-C3EvHpLN.js} +1 -1
  61. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-CfOjbx0H.js → architectureDiagram-Q4EWVU46-bETsVzGS.js} +1 -1
  62. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-D0kA2C9i.js → blockDiagram-DXYQGD6D-5y0GBnfQ.js} +1 -1
  63. package/payload/server/public/assets/{brand-6hKfBgOI.css → brand-DQlLKLDH.css} +1 -1
  64. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-CDgXiXgj.js → c4Diagram-AHTNJAMY-C1znpVGu.js} +1 -1
  65. package/payload/server/public/assets/channel-BWqTaXzb.js +1 -0
  66. package/payload/server/public/assets/{chunk-336JU56O-BfBTcT5X.js → chunk-336JU56O-DlCVUGzQ.js} +2 -2
  67. package/payload/server/public/assets/{chunk-426QAEUC-BdSSZ1Kw.js → chunk-426QAEUC-bGYkmA4z.js} +1 -1
  68. package/payload/server/public/assets/{chunk-4TB4RGXK-UvE1im9g.js → chunk-4TB4RGXK-6Sb0Undm.js} +1 -1
  69. package/payload/server/public/assets/{chunk-5FUZZQ4R-Dx4DslXz.js → chunk-5FUZZQ4R-CAT_Pq2T.js} +1 -1
  70. package/payload/server/public/assets/{chunk-5PVQY5BW-CNHawhLJ.js → chunk-5PVQY5BW-j231ESse.js} +1 -1
  71. package/payload/server/public/assets/{chunk-EDXVE4YY-Rmjv-1et.js → chunk-EDXVE4YY-BAlS4QL7.js} +1 -1
  72. package/payload/server/public/assets/{chunk-ENJZ2VHE-CL-LeH3r.js → chunk-ENJZ2VHE-D3Ku_BMP.js} +1 -1
  73. package/payload/server/public/assets/{chunk-ICPOFSXX-B2MFOJDt.js → chunk-ICPOFSXX-AR86A5WB.js} +1 -1
  74. package/payload/server/public/assets/{chunk-OYMX7WX6-BxcIcCt5.js → chunk-OYMX7WX6-DxGhapkW.js} +1 -1
  75. package/payload/server/public/assets/{chunk-U2HBQHQK-COzwOBrY.js → chunk-U2HBQHQK-DAlqsDo_.js} +1 -1
  76. package/payload/server/public/assets/{chunk-X2U36JSP-B0rjT5hL.js → chunk-X2U36JSP-lZdkkdyB.js} +1 -1
  77. package/payload/server/public/assets/{chunk-YZCP3GAM-pwrIw0XH.js → chunk-YZCP3GAM-CCjtXOFD.js} +1 -1
  78. package/payload/server/public/assets/{chunk-ZZ45TVLE-BXpeAm0k.js → chunk-ZZ45TVLE-ComtdKny.js} +1 -1
  79. package/payload/server/public/assets/classDiagram-6PBFFD2Q-B9hC6fOR.js +1 -0
  80. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-afl_ydIG.js +1 -0
  81. package/payload/server/public/assets/clone-Lj83OaZ3.js +1 -0
  82. package/payload/server/public/assets/{dagre-DjOJn05k.js → dagre-DX8QJeVt.js} +1 -1
  83. package/payload/server/public/assets/{dagre-KV5264BT-PczvQiEh.js → dagre-KV5264BT-BcRbiZ1q.js} +1 -1
  84. package/payload/server/public/assets/data-CS_R5VWQ.js +1 -0
  85. package/payload/server/public/assets/{device-url-actions-C55ZS_Zg.js → device-url-actions-Cag9IJ7n.js} +1 -1
  86. package/payload/server/public/assets/{diagram-5BDNPKRD-D477xo5F.js → diagram-5BDNPKRD-Bw47VwHO.js} +1 -1
  87. package/payload/server/public/assets/{diagram-G4DWMVQ6-C-HAtbtT.js → diagram-G4DWMVQ6-B-aVfhiD.js} +1 -1
  88. package/payload/server/public/assets/{diagram-MMDJMWI5-B9wus4H0.js → diagram-MMDJMWI5-DERz2dKo.js} +1 -1
  89. package/payload/server/public/assets/{diagram-TYMM5635-UMZLloFr.js → diagram-TYMM5635-B7eEX9Ta.js} +1 -1
  90. package/payload/server/public/assets/{erDiagram-SMLLAGMA-B8E52Mp4.js → erDiagram-SMLLAGMA-G_JJq3zB.js} +1 -1
  91. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CUel34tq.js → flowDiagram-DWJPFMVM-BeY9QaO0.js} +1 -1
  92. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-BfwE-Xav.js → ganttDiagram-T4ZO3ILL-IXviGYnx.js} +1 -1
  93. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-2d6tXRIM.js → gitGraphDiagram-UUTBAWPF-GlY6pZQ1.js} +1 -1
  94. package/payload/server/public/assets/graph-DtGkQBFr.js +1 -0
  95. package/payload/server/public/assets/graph-labels-Dwu1ON5v.js +1 -0
  96. package/payload/server/public/assets/{graphlib-CCo9-wNJ.js → graphlib-BWgAjZSb.js} +1 -1
  97. package/payload/server/public/assets/{infoDiagram-42DDH7IO-DB1SVWL6.js → infoDiagram-42DDH7IO-D4YZM0gD.js} +1 -1
  98. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CdeMUTYS.js → ishikawaDiagram-UXIWVN3A-fR71GDOp.js} +1 -1
  99. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-D8x8TUoc.js → journeyDiagram-VCZTEJTY-C1TcY_6m.js} +1 -1
  100. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-Cmkhd1mO.js → kanban-definition-6JOO6SKY-IPZKpnnG.js} +1 -1
  101. package/payload/server/public/assets/{line-CoHkpBv3.js → line-B2JtEL1B.js} +1 -1
  102. package/payload/server/public/assets/{mermaid-parser.core-C7R2PfZC.js → mermaid-parser.core-BKCLXqUh.js} +1 -1
  103. package/payload/server/public/assets/{mermaid.core-lQaq-kll.js → mermaid.core-CmNpCLB-.js} +3 -3
  104. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CsPq3pX1.js → mindmap-definition-QFDTVHPH-BS0E2FPD.js} +1 -1
  105. package/payload/server/public/assets/{page-DwEaAdVp.js → page-BdI3GCun.js} +1 -1
  106. package/payload/server/public/assets/{page-CBKzjUHO.js → page-zIjAkU2y.js} +1 -1
  107. package/payload/server/public/assets/{pieDiagram-DEJITSTG-FZH9sTnP.js → pieDiagram-DEJITSTG-J61JDyk8.js} +1 -1
  108. package/payload/server/public/assets/{public-hGLzxhE_.js → public-C5vaQnC_.js} +4 -4
  109. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-9poxfoqh.js → quadrantDiagram-34T5L4WZ-CLI03OtR.js} +1 -1
  110. package/payload/server/public/assets/{requirementDiagram-MS252O5E-Ce7iOdT_.js → requirementDiagram-MS252O5E-C8MCt5NO.js} +1 -1
  111. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-DINuXatI.js → sankeyDiagram-XADWPNL6-DQuugruj.js} +1 -1
  112. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-CSMec3EK.js → sequenceDiagram-FGHM5R23-CoYitwhO.js} +1 -1
  113. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-15DKzypj.js → stateDiagram-FHFEXIEX-G68cHHoI.js} +1 -1
  114. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Dtgpgr_D.js +1 -0
  115. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BxIRc9Pu.js → timeline-definition-GMOUNBTQ-CelLeJVS.js} +1 -1
  116. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-DdgRyU55.js → vennDiagram-DHZGUBPP-BVgFPkJq.js} +1 -1
  117. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-CKvhU1Uj.js → wardleyDiagram-NUSXRM2D-CzD_8hl2.js} +1 -1
  118. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-d0V1V4PD.js → xychartDiagram-5P7HB3ND-BuTxWdX5.js} +1 -1
  119. package/payload/server/public/data.html +5 -5
  120. package/payload/server/public/graph.html +6 -6
  121. package/payload/server/public/index.html +8 -8
  122. package/payload/server/public/public.html +5 -5
  123. package/payload/server/server.js +1058 -846
  124. package/payload/server/public/assets/channel-DA7MGMzQ.js +0 -1
  125. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DqfmcWSr.js +0 -1
  126. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BVOMSbZ_.js +0 -1
  127. package/payload/server/public/assets/clone-Byp7hagU.js +0 -1
  128. package/payload/server/public/assets/data-A7azIkZU.js +0 -1
  129. package/payload/server/public/assets/graph-Dx83EhtP.js +0 -1
  130. package/payload/server/public/assets/graph-labels-Bg2Bzelb.js +0 -1
  131. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-AcaK_QfD.js +0 -1
  132. /package/payload/server/public/assets/{brand-DXOx08r0.js → brand-D6mXeyxV.js} +0 -0
@@ -6,35 +6,27 @@
6
6
  // POST /spawn { senderId, role, channel, tunnelUrl? } -> Session
7
7
  // GET /list?senderId=... -> Session[]
8
8
  // POST /resume { senderId, role, channel, claudeSessionId } -> Session
9
- // DELETE /:sessionId -> 204
10
- // DELETE /:sessionId?purge=1 -> 204 (hard-removes JSONL + sidecar); 409 if PTY alive
11
- // POST /:sessionId/input { text }
12
- // GET /:sessionId/log -> { jsonlPath, sizeBytes, exists }
13
- // GET /:sessionId/log?follow=1 -> streamed file content (current + new)
14
- // GET /:sessionId/meta -> SessionMeta payload
15
- // PATCH /:sessionId { displayName } -> 204 (writes sidecar)
9
+ // POST /:id/stop -> 204 (kill PTY; idempotent on already-dead row)
10
+ // 404 if no row matches any of the 4 keys
11
+ // DELETE /:id -> 204 (rmSync JSONL + sidecar + subdir)
12
+ // 409 if PTY still alive
13
+ // 404 if no transcript on disk
14
+ // POST /:id/input { text }
15
+ // GET /:id/log -> { jsonlPath, sizeBytes, exists }
16
+ // GET /:id/log?follow=1 -> streamed file content (current + new)
17
+ // GET /:id/meta -> SessionMeta payload
18
+ // PATCH /:id { displayName } -> 204 (writes sidecar)
19
+ // POST /:id/archive { mode }
16
20
  //
17
- // `/spawn` returns 200 the instant Claude Code emits its `/remote-control`
18
- // URL the operator gets a usable session inside ~2 s even when they never
19
- // type. The wire shape is `{sessionId: <bridge-suffix>, claudeSessionId: null,
20
- // jsonlPath: null, ...LivePayload}`. The jsonl-observer fires on the first
21
- // operator turn (claude does not write the JSONL until then, verified Pi
22
- // 2.1.143 and Mac 2.1.128) and promotes the row to the intrinsic
23
- // claudeSessionId in the background — no second client call needed.
24
- //
25
- // Every GET route resolves either the bridge suffix or the intrinsic id to
26
- // the same row, so a UI that mounted on the bridge suffix at t=0 keeps
27
- // routing correctly after promotion without flipping the row's visible
28
- // position. The intrinsic id is the right field to use for routing once
29
- // known (UI reads `claudeSessionId ?? sessionId`).
30
- //
31
- // sidecar `<id>.meta.json` next to the JSONL holds operator-owned fields
32
- // (displayName). PATCH writes it; /meta reads it via the enumerator helpers.
21
+ // Any of the four ids intrinsic sessionId, bridgeSessionId, bridgeSuffix,
22
+ // or pid (numeric) resolves to the same row via the fs-watcher index.
23
+ // Unknown ids return 404; nothing returns a silent 204 against an id the
24
+ // manager does not know.
33
25
  import { Hono } from 'hono';
34
26
  import { stream } from 'hono/streaming';
35
27
  import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
36
- import { spawnClaudeSession, deleteSession, PERMISSION_MODES } from './pty-spawner.js';
37
- import { listJsonlSessions, readJsonlSession, readSidecar, sidecarPathFor } from './jsonl-enumerator.js';
28
+ import { spawnClaudeSession, stopSession, PERMISSION_MODES } from './pty-spawner.js';
29
+ import { readJsonlSession, readSidecar, sidecarPathFor } from './jsonl-enumerator.js';
38
30
  import { claudeStateRoot, projectSlugForCwd } from './jsonl-path.js';
39
31
  import { basename, join } from 'node:path';
40
32
  const ROLES = ['admin', 'public'];
@@ -43,12 +35,10 @@ function parseAboutOwner(raw) {
43
35
  if (!raw || typeof raw !== 'object')
44
36
  return undefined;
45
37
  const r = raw;
46
- if (r.ok === true && typeof r.body === 'string') {
38
+ if (r.ok === true && typeof r.body === 'string')
47
39
  return { ok: true, body: r.body };
48
- }
49
- if (r.ok === false && typeof r.reason === 'string' && r.reason.length > 0) {
40
+ if (r.ok === false && typeof r.reason === 'string' && r.reason.length > 0)
50
41
  return { ok: false, reason: r.reason };
51
- }
52
42
  return undefined;
53
43
  }
54
44
  function parseDormantPlugins(raw) {
@@ -67,37 +57,94 @@ function parseDormantPlugins(raw) {
67
57
  }
68
58
  return out.length > 0 ? out : undefined;
69
59
  }
70
- // Path-traversal guard. Bridge suffixes and intrinsic JSONL basenames both
71
- // match this character class.
60
+ function parseNameDescList(raw) {
61
+ if (!Array.isArray(raw))
62
+ return [];
63
+ const out = [];
64
+ for (const entry of raw) {
65
+ if (!entry || typeof entry !== 'object')
66
+ continue;
67
+ const e = entry;
68
+ if (typeof e.name !== 'string' || e.name.length === 0)
69
+ continue;
70
+ const item = { name: e.name };
71
+ if (typeof e.description === 'string' && e.description.length > 0)
72
+ item.description = e.description;
73
+ out.push(item);
74
+ }
75
+ return out;
76
+ }
77
+ function parseActivePlugins(raw) {
78
+ if (!Array.isArray(raw))
79
+ return undefined;
80
+ const out = [];
81
+ for (const entry of raw) {
82
+ if (!entry || typeof entry !== 'object')
83
+ continue;
84
+ const e = entry;
85
+ if (typeof e.name !== 'string' || e.name.length === 0)
86
+ continue;
87
+ if (typeof e.description !== 'string')
88
+ continue;
89
+ out.push({
90
+ name: e.name,
91
+ description: e.description,
92
+ tools: parseNameDescList(e.tools),
93
+ skills: parseNameDescList(e.skills),
94
+ });
95
+ }
96
+ return out.length > 0 ? out : undefined;
97
+ }
98
+ function parseSpecialistDomains(raw) {
99
+ if (!Array.isArray(raw))
100
+ return undefined;
101
+ const out = [];
102
+ for (const entry of raw) {
103
+ if (!entry || typeof entry !== 'object')
104
+ continue;
105
+ const e = entry;
106
+ if (typeof e.name !== 'string' || e.name.length === 0)
107
+ continue;
108
+ if (typeof e.description !== 'string')
109
+ continue;
110
+ out.push({ name: e.name, description: e.description, tools: parseNameDescList(e.tools) });
111
+ }
112
+ return out.length > 0 ? out : undefined;
113
+ }
114
+ // `<id>` may be: intrinsic UUID, bridgeSessionId ("session_<suffix>"),
115
+ // bridgeSuffix, or pid (numeric). All resolve to a sessionId via watcher.
72
116
  const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
73
117
  const LIST_TAIL_BYTES = 64 * 1024;
74
118
  const DISPLAY_NAME_MAX = 256;
75
119
  function timed(logger, method, path, status, ms) {
76
120
  logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
77
121
  }
78
- /** Locate the JSONL path for any sessionId. For alive rows, returns null
79
- * when the row has not yet promoted (no JSONL on disk yet). */
80
- function resolveJsonlPath(deps, sessionId) {
81
- const live = deps.store.get(sessionId);
82
- if (live && live.claudeSessionId !== null && live.jsonlPath !== null) {
83
- return { path: live.jsonlPath, claudeSessionId: live.claudeSessionId };
84
- }
85
- if (live && live.claudeSessionId === null) {
86
- // Row is alive but pre-promotion: no JSONL exists yet.
87
- return null;
122
+ /** Resolve any of the 4 keys to the canonical (sessionId, row) pair.
123
+ * Tries: intrinsic sessionId, bridgeSessionId, bridgeSuffix, numeric pid.
124
+ * Returns undefined when nothing matches. */
125
+ function resolveRow(watcher, id) {
126
+ const direct = watcher.getBySessionId(id);
127
+ if (direct)
128
+ return direct;
129
+ const byBridge = watcher.getByBridgeSessionId(id);
130
+ if (byBridge)
131
+ return byBridge;
132
+ const bySuffix = watcher.getByBridgeSuffix(id);
133
+ if (bySuffix)
134
+ return bySuffix;
135
+ if (/^\d+$/.test(id)) {
136
+ const byPid = watcher.getByPid(Number(id));
137
+ if (byPid)
138
+ return byPid;
88
139
  }
89
- const slug = projectSlugForCwd(deps.spawnCwd);
90
- const path = join(claudeStateRoot(), 'projects', slug, `${sessionId}.jsonl`);
91
- if (existsSync(path))
92
- return { path, claudeSessionId: sessionId };
93
- return null;
140
+ return undefined;
94
141
  }
95
142
  function attachPublicAudit(deps, session) {
96
143
  if (session.role !== 'public')
97
144
  return;
98
- if (session.claudeSessionId === null || session.jsonlPath === null)
145
+ if (session.jsonlPath === null)
99
146
  return;
100
- const claudeSessionId = session.claudeSessionId;
147
+ const claudeSessionId = session.sessionId;
101
148
  deps.publicToolAudit.attach(claudeSessionId, session.jsonlPath);
102
149
  if (session.exited) {
103
150
  deps.publicToolAudit.detach(claudeSessionId);
@@ -109,24 +156,41 @@ function attachPublicAudit(deps, session) {
109
156
  }
110
157
  export function buildHttpApp(deps) {
111
158
  const app = new Hono();
159
+ // Watcher subscriber: when JSONL CREATE fires for a live session,
160
+ // patch jsonlPath onto the StoredSession and attach the audit watcher.
161
+ deps.watcher.subscribe((event) => {
162
+ if (event.source !== 'jsonl')
163
+ return;
164
+ if (event.kind !== 'create' && event.kind !== 'modify')
165
+ return;
166
+ if (!event.row || !event.row.jsonlPath)
167
+ return;
168
+ const live = deps.store.get(event.sessionId);
169
+ if (!live)
170
+ return;
171
+ if (live.jsonlPath !== null)
172
+ return;
173
+ live.jsonlPath = event.row.jsonlPath;
174
+ deps.logger(`[fs-watcher] live-row-jsonl-attached sessionId=${event.sessionId} path=${event.row.jsonlPath}`);
175
+ attachPublicAudit(deps, live);
176
+ });
112
177
  function liveDisplayName(s, sidecarName) {
113
178
  if (sidecarName && sidecarName.trim())
114
179
  return sidecarName.trim();
115
- if (s.jsonlPath && s.claudeSessionId && existsSync(s.jsonlPath)) {
116
- const enriched = readJsonlSession(deps.spawnCwd, s.claudeSessionId, LIST_TAIL_BYTES, deps.logger);
180
+ if (s.jsonlPath && existsSync(s.jsonlPath)) {
181
+ const enriched = readJsonlSession(deps.spawnCwd, s.sessionId, LIST_TAIL_BYTES, deps.logger);
117
182
  if (enriched?.aiTitle?.trim())
118
183
  return enriched.aiTitle.trim();
119
184
  }
120
- // pre-promotion 8 chars of the bridge suffix; post-promotion → 8
121
- // chars of the intrinsic id (which the operator may copy into resume).
122
- return (s.claudeSessionId ?? s.bridgeSuffix).slice(0, 8);
185
+ return s.sessionId.slice(0, 8);
123
186
  }
124
187
  function toLivePayload(s) {
125
188
  const sidecar = s.jsonlPath ? readSidecar(s.jsonlPath) : null;
126
189
  const hasJsonl = s.jsonlPath !== null && existsSync(s.jsonlPath);
127
190
  return {
128
- sessionId: s.bridgeSuffix,
129
- claudeSessionId: s.claudeSessionId,
191
+ sessionId: s.sessionId,
192
+ claudeSessionId: s.sessionId,
193
+ bridgeSessionId: s.bridgeSessionId,
130
194
  pid: s.pid,
131
195
  url: s.url,
132
196
  startedAt: s.startedAt,
@@ -144,6 +208,45 @@ export function buildHttpApp(deps) {
144
208
  specialist: s.specialist,
145
209
  };
146
210
  }
211
+ function toArchivedPayload(row, senderId) {
212
+ let displayName = row.sessionId.slice(0, 8);
213
+ let lastMessageAt = null;
214
+ let turnCount = 0;
215
+ let capped = false;
216
+ let sizeBytes = null;
217
+ let startedAt = new Date(row.updatedAt || 0).toISOString();
218
+ if (row.jsonlPath && existsSync(row.jsonlPath)) {
219
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger);
220
+ if (enriched) {
221
+ displayName = enriched.displayName;
222
+ lastMessageAt = enriched.lastMessageAt;
223
+ turnCount = enriched.turnCount;
224
+ capped = enriched.capped;
225
+ sizeBytes = enriched.sizeBytes;
226
+ startedAt = enriched.startedAt;
227
+ }
228
+ }
229
+ return {
230
+ sessionId: row.sessionId,
231
+ claudeSessionId: row.sessionId,
232
+ bridgeSessionId: row.bridgeSessionId,
233
+ pid: null,
234
+ url: null,
235
+ startedAt,
236
+ senderId,
237
+ role: null,
238
+ channel: null,
239
+ jsonlPath: row.jsonlPath,
240
+ status: 'ended',
241
+ displayName,
242
+ lastMessageAt,
243
+ turnCount,
244
+ capped,
245
+ sizeBytes,
246
+ hidden: false,
247
+ specialist: row.agent,
248
+ };
249
+ }
147
250
  app.post('/spawn', async (c) => {
148
251
  const start = Date.now();
149
252
  let body;
@@ -162,7 +265,6 @@ export function buildHttpApp(deps) {
162
265
  const specialist = typeof body.specialist === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.specialist)
163
266
  ? body.specialist
164
267
  : undefined;
165
- const stdinPayload = typeof body.stdinPayload === 'string' ? body.stdinPayload : undefined;
166
268
  const aboutOwnerShape = body.aboutOwner == null ? 'absent' : (typeof body.aboutOwner === 'object' ? 'object' : typeof body.aboutOwner);
167
269
  const dormantPluginsShape = Array.isArray(body.dormantPlugins)
168
270
  ? `array(${body.dormantPlugins.length})`
@@ -194,16 +296,14 @@ export function buildHttpApp(deps) {
194
296
  : undefined;
195
297
  const aboutOwner = parseAboutOwner(body.aboutOwner);
196
298
  const dormantPlugins = parseDormantPlugins(body.dormantPlugins);
197
- // tunnelUrl override resolved upstream by the UI process from the
198
- // brand's tunnel.state on disk (the manager has no Neo4j credentials).
199
- // string → render; null → render `tunnel-url: none`; undefined → fall
200
- // back to deps.host.tunnelUrl (whatever boot resolved).
299
+ const activePlugins = parseActivePlugins(body.activePlugins);
300
+ const specialistDomains = parseSpecialistDomains(body.specialistDomains);
201
301
  const tunnelUrlOverride = typeof body.tunnelUrl === 'string'
202
302
  ? body.tunnelUrl
203
303
  : body.tunnelUrl === null
204
304
  ? null
205
305
  : undefined;
206
- const result = await spawnClaudeSession({ ...deps, store: deps.store, onJsonlObserved: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
306
+ const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
207
307
  senderId,
208
308
  role: role,
209
309
  channel: channel,
@@ -212,21 +312,151 @@ export function buildHttpApp(deps) {
212
312
  attachmentDir,
213
313
  aboutOwner,
214
314
  dormantPlugins,
315
+ activePlugins,
316
+ specialistDomains,
215
317
  hidden,
216
318
  specialist,
217
- stdinPayload,
218
319
  });
219
320
  if (!result.ok) {
220
- // Every remaining spawn-failure surface is 500: which-claude-not-found,
221
- // pty-spawn-failed, url-capture-timeout, bridge-suffix-unparseable,
222
- // host-context-unresolved, identity-unresolved. The observe-timeout 504
223
- // is gone /spawn never blocks on observation.
321
+ if ('rejected' in result) {
322
+ timed(deps.logger, 'POST', '/spawn', 503, Date.now() - start);
323
+ if (result.rejected.kind === 'specialist-cap-reached') {
324
+ return c.json({ error: 'specialist-cap-reached', currentCount: result.rejected.currentCount, cap: result.rejected.cap }, 503);
325
+ }
326
+ return c.json({ error: 'operator-slots-reserved', total: result.rejected.total, reserve: result.rejected.reserve, totalCap: result.rejected.totalCap }, 503);
327
+ }
224
328
  timed(deps.logger, 'POST', '/spawn', 500, Date.now() - start);
225
329
  return c.json({ error: 'spawn-failed', reason: result.reason, stderrTail: result.stderrTail }, 500);
226
330
  }
227
331
  timed(deps.logger, 'POST', '/spawn', 200, Date.now() - start);
228
332
  return c.json(toLivePayload(result.session), 200);
229
333
  });
334
+ // SSE feed of row deltas. One channel per client; manager fans out from
335
+ // a single watcher subscription. On connect: replay current rows as
336
+ // `row-created`, then stream subsequent watcher events as
337
+ // `row-<created|updated|archived|removed>`.
338
+ //
339
+ // Payload shape per event (`data: <json>`):
340
+ // { sessionId, live, status, agentLabel, bridgeSessionId, cwd,
341
+ // firstPrompt, updatedAt }
342
+ //
343
+ // `live` mirrors the watcher's `state === 'live'`. `status` is the
344
+ // watcher's `idle | busy | gone`. `agentLabel` is the resolution
345
+ // `agent ?? cachedAgentSetting`; the UI maps null → "admin",
346
+ // non-null → the specialist name. `firstPrompt` is the resolved
347
+ // displayName (sidecar → ai-title → 8-char slice) so the row title is
348
+ // computed once on the server, not by every browser tab.
349
+ app.get('/events', (c) => {
350
+ const start = Date.now();
351
+ const clientIp = c.req.header('x-forwarded-for') ?? 'loopback';
352
+ const replayRows = deps.watcher.all();
353
+ deps.logger(`[admin-events] client-connected ip=${clientIp} seeded-rows=${replayRows.length}`);
354
+ function rowToPayload(row) {
355
+ const agentLabel = row.agent ?? row.cachedAgentSetting ?? null;
356
+ let firstPrompt = null;
357
+ if (row.jsonlPath && existsSync(row.jsonlPath)) {
358
+ const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger);
359
+ if (enriched)
360
+ firstPrompt = enriched.displayName;
361
+ }
362
+ if (!firstPrompt) {
363
+ const sidecar = row.jsonlPath ? readSidecar(row.jsonlPath) : null;
364
+ firstPrompt = sidecar?.displayName ?? row.sessionId.slice(0, 8);
365
+ }
366
+ return {
367
+ sessionId: row.sessionId,
368
+ live: row.state === 'live',
369
+ status: row.status,
370
+ agentLabel,
371
+ bridgeSessionId: row.bridgeSessionId,
372
+ cwd: row.cwd,
373
+ firstPrompt,
374
+ updatedAt: row.updatedAt,
375
+ };
376
+ }
377
+ timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
378
+ return stream(c, async (out) => {
379
+ // SSE response headers — Hono's stream() helper sets text/event-stream
380
+ // automatically, but we explicitly disable proxy buffering so the
381
+ // first replay batch arrives before any keep-alive.
382
+ c.header('Content-Type', 'text/event-stream');
383
+ c.header('Cache-Control', 'no-cache, no-transform');
384
+ c.header('X-Accel-Buffering', 'no');
385
+ c.header('Connection', 'keep-alive');
386
+ const connectStart = Date.now();
387
+ let active = true;
388
+ let subscribersCount = 0;
389
+ // Replay phase. Emit a `row-created` for every row in the index so
390
+ // a freshly-connected tab catches up to the manager's view before
391
+ // any subsequent delta arrives.
392
+ for (const row of replayRows) {
393
+ if (!active)
394
+ break;
395
+ const payload = rowToPayload(row);
396
+ await out.write(`event: row-created\ndata: ${JSON.stringify(payload)}\n\n`);
397
+ }
398
+ // Delta phase. Subscribe to the watcher and map watcher events to
399
+ // the four SSE event kinds. Mapping:
400
+ // create → row-created
401
+ // modify → row-updated (state may have flipped — see archive)
402
+ // delete → row-removed when the row is fully gone, row-archived
403
+ // when the PID file went away but the JSONL survives.
404
+ const unsubscribe = deps.watcher.subscribe((event) => {
405
+ if (!active)
406
+ return;
407
+ // The watcher emits a synthetic `kind=modify source=jsonl` pair
408
+ // immediately after `kind=delete source=pid` when a row demotes
409
+ // from live → archived. Treat that pair as one `row-archived`
410
+ // event so the UI sees a single transition rather than a delete
411
+ // followed by an unrelated update.
412
+ if (event.kind === 'delete' && event.source === 'pid' && event.row) {
413
+ const payload = rowToPayload(event.row);
414
+ subscribersCount += 1;
415
+ deps.logger(`[admin-events] emit event=row-archived sessionId=${event.sessionId} subscribers=1`);
416
+ void out.write(`event: row-archived\ndata: ${JSON.stringify(payload)}\n\n`);
417
+ return;
418
+ }
419
+ if (event.kind === 'delete' && event.source === 'jsonl' && !event.row) {
420
+ subscribersCount += 1;
421
+ deps.logger(`[admin-events] emit event=row-removed sessionId=${event.sessionId} subscribers=1`);
422
+ void out.write(`event: row-removed\ndata: ${JSON.stringify({ sessionId: event.sessionId })}\n\n`);
423
+ return;
424
+ }
425
+ // Skip the synthetic `modify jsonl` half of the demote pair —
426
+ // it carries the same row state already emitted as row-archived.
427
+ if (event.kind === 'modify' && event.source === 'jsonl' && event.row && event.row.state === 'archived' && event.row.pidFilePath === null) {
428
+ return;
429
+ }
430
+ if (!event.row)
431
+ return;
432
+ const sseEvent = event.kind === 'create' ? 'row-created' : 'row-updated';
433
+ const payload = rowToPayload(event.row);
434
+ subscribersCount += 1;
435
+ deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1`);
436
+ void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
437
+ });
438
+ // Keep-alive comment every 25 s. Proxies aggressively close idle
439
+ // text/event-stream connections at 30–60 s; a comment line is
440
+ // ignored by EventSource consumers but resets the proxy clock.
441
+ const keepAlive = setInterval(() => {
442
+ if (!active)
443
+ return;
444
+ void out.write(`: keep-alive ${Date.now()}\n\n`);
445
+ }, 25_000);
446
+ out.onAbort(() => {
447
+ active = false;
448
+ clearInterval(keepAlive);
449
+ unsubscribe();
450
+ const durationMs = Date.now() - connectStart;
451
+ deps.logger(`[admin-events] client-disconnected ip=${clientIp} duration-ms=${durationMs} emits=${subscribersCount}`);
452
+ });
453
+ // Park the stream open until the client aborts. Without this the
454
+ // handler returns and Hono closes the response.
455
+ while (active) {
456
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
457
+ }
458
+ });
459
+ });
230
460
  app.get('/list', (c) => {
231
461
  const start = Date.now();
232
462
  const senderId = c.req.query('senderId') ?? '';
@@ -237,36 +467,22 @@ export function buildHttpApp(deps) {
237
467
  }
238
468
  const liveStored = deps.store.listBySender(senderId, { includeHidden });
239
469
  const live = liveStored.map(toLivePayload);
240
- // Live rows dedup against dehydrated by intrinsic id when promoted;
241
- // un-promoted rows have no intrinsic id and cannot collide with disk.
242
- const liveIntrinsicIds = new Set();
243
- for (const s of liveStored) {
244
- if (s.claudeSessionId !== null)
245
- liveIntrinsicIds.add(s.claudeSessionId);
246
- }
470
+ const liveSessionIds = new Set();
471
+ for (const s of liveStored)
472
+ liveSessionIds.add(s.sessionId);
473
+ // Archived rows come from the watcher's JSONL index (rows whose
474
+ // state === 'archived'). The watcher's boot-seed already paired
475
+ // PID files with JSONLs on disk, so archived rows here are exactly
476
+ // the sessions whose PTY has exited but transcript persists.
247
477
  const dehydrated = [];
248
- for (const j of listJsonlSessions(deps.spawnCwd, LIST_TAIL_BYTES, deps.logger)) {
249
- if (liveIntrinsicIds.has(j.claudeSessionId))
478
+ for (const row of deps.watcher.all()) {
479
+ if (row.state !== 'archived')
250
480
  continue;
251
- dehydrated.push({
252
- sessionId: j.claudeSessionId,
253
- claudeSessionId: j.claudeSessionId,
254
- pid: null,
255
- url: null,
256
- startedAt: j.startedAt,
257
- senderId,
258
- role: null,
259
- channel: null,
260
- jsonlPath: j.jsonlPath,
261
- status: 'ended',
262
- displayName: j.displayName,
263
- lastMessageAt: j.lastMessageAt,
264
- turnCount: j.turnCount,
265
- capped: j.capped,
266
- sizeBytes: j.sizeBytes,
267
- hidden: false,
268
- specialist: null,
269
- });
481
+ if (liveSessionIds.has(row.sessionId))
482
+ continue;
483
+ if (!row.jsonlPath)
484
+ continue;
485
+ dehydrated.push(toArchivedPayload(row, senderId));
270
486
  }
271
487
  deps.logger(`list-source live=${live.length} jsonl=${dehydrated.length}`);
272
488
  const merged = [...live, ...dehydrated];
@@ -297,12 +513,14 @@ export function buildHttpApp(deps) {
297
513
  : undefined;
298
514
  const aboutOwner = parseAboutOwner(body.aboutOwner);
299
515
  const dormantPlugins = parseDormantPlugins(body.dormantPlugins);
516
+ const activePlugins = parseActivePlugins(body.activePlugins);
517
+ const specialistDomains = parseSpecialistDomains(body.specialistDomains);
300
518
  const tunnelUrlOverride = typeof body.tunnelUrl === 'string'
301
519
  ? body.tunnelUrl
302
520
  : body.tunnelUrl === null
303
521
  ? null
304
522
  : undefined;
305
- const result = await spawnClaudeSession({ ...deps, store: deps.store, onJsonlObserved: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
523
+ const result = await spawnClaudeSession({ ...deps, store: deps.store, watcher: deps.watcher, onSessionReady: (s) => attachPublicAudit(deps, s), tunnelUrlOverride }, {
306
524
  senderId,
307
525
  role: role,
308
526
  channel: channel,
@@ -310,24 +528,38 @@ export function buildHttpApp(deps) {
310
528
  channels,
311
529
  aboutOwner,
312
530
  dormantPlugins,
531
+ activePlugins,
532
+ specialistDomains,
313
533
  });
314
534
  if (!result.ok) {
535
+ if ('rejected' in result) {
536
+ timed(deps.logger, 'POST', '/resume', 503, Date.now() - start);
537
+ if (result.rejected.kind === 'specialist-cap-reached') {
538
+ return c.json({ error: 'specialist-cap-reached', currentCount: result.rejected.currentCount, cap: result.rejected.cap }, 503);
539
+ }
540
+ return c.json({ error: 'operator-slots-reserved', total: result.rejected.total, reserve: result.rejected.reserve, totalCap: result.rejected.totalCap }, 503);
541
+ }
315
542
  deps.logger(`resume-failed reason=${result.reason} resumeFrom=${claudeSessionId}`);
316
543
  timed(deps.logger, 'POST', '/resume', 500, Date.now() - start);
317
544
  return c.json({ error: 'resume-failed', reason: result.reason }, 500);
318
545
  }
319
- deps.logger(`resume pid=${result.session.pid} sessionUrl=${result.session.url} resumeFrom=${claudeSessionId} latency-ms=${Date.now() - start}`);
546
+ deps.logger(`resume pid=${result.session.pid} sessionId=${result.session.sessionId} resumeFrom=${claudeSessionId} latency-ms=${Date.now() - start}`);
320
547
  timed(deps.logger, 'POST', '/resume', 200, Date.now() - start);
321
548
  return c.json(toLivePayload(result.session), 200);
322
549
  });
323
550
  app.get('/:sessionId/meta', (c) => {
324
551
  const start = Date.now();
325
- const sessionId = c.req.param('sessionId');
326
- if (!SESSION_ID_PATTERN.test(sessionId)) {
327
- timed(deps.logger, 'GET', `/${sessionId}/meta`, 400, Date.now() - start);
552
+ const id = c.req.param('sessionId');
553
+ if (!SESSION_ID_PATTERN.test(id)) {
554
+ timed(deps.logger, 'GET', `/${id}/meta`, 400, Date.now() - start);
328
555
  return c.json({ error: 'invalid-session-id' }, 400);
329
556
  }
330
- const live = deps.store.get(sessionId);
557
+ const row = resolveRow(deps.watcher, id);
558
+ if (!row) {
559
+ timed(deps.logger, 'GET', `/${id}/meta`, 404, Date.now() - start);
560
+ return c.json({ error: 'session-not-found' }, 404);
561
+ }
562
+ const live = deps.store.get(row.sessionId);
331
563
  let payload;
332
564
  if (live) {
333
565
  const base = toLivePayload(live);
@@ -335,8 +567,8 @@ export function buildHttpApp(deps) {
335
567
  let turnCount = 0;
336
568
  let capped = false;
337
569
  let sizeBytes = base.sizeBytes;
338
- if (live.claudeSessionId && live.jsonlPath && existsSync(live.jsonlPath)) {
339
- const enriched = readJsonlSession(deps.spawnCwd, live.claudeSessionId, deps.jsonlReadMaxBytes, deps.logger);
570
+ if (live.jsonlPath && existsSync(live.jsonlPath)) {
571
+ const enriched = readJsonlSession(deps.spawnCwd, live.sessionId, deps.jsonlReadMaxBytes, deps.logger);
340
572
  if (enriched) {
341
573
  lastMessageAt = enriched.lastMessageAt;
342
574
  turnCount = enriched.turnCount;
@@ -344,50 +576,20 @@ export function buildHttpApp(deps) {
344
576
  sizeBytes = enriched.sizeBytes;
345
577
  }
346
578
  }
347
- payload = {
348
- ...base,
349
- lastMessageAt,
350
- turnCount,
351
- capped,
352
- sizeBytes,
353
- };
579
+ payload = { ...base, lastMessageAt, turnCount, capped, sizeBytes };
354
580
  }
355
581
  else {
356
- const enriched = readJsonlSession(deps.spawnCwd, sessionId, deps.jsonlReadMaxBytes, deps.logger);
357
- if (!enriched) {
358
- timed(deps.logger, 'GET', `/${sessionId}/meta`, 404, Date.now() - start);
359
- return c.json({ error: 'session-not-found' }, 404);
360
- }
361
- payload = {
362
- sessionId: enriched.claudeSessionId,
363
- claudeSessionId: enriched.claudeSessionId,
364
- pid: null,
365
- url: null,
366
- startedAt: enriched.startedAt,
367
- senderId: '',
368
- role: null,
369
- channel: null,
370
- jsonlPath: enriched.jsonlPath,
371
- status: 'ended',
372
- displayName: enriched.displayName,
373
- lastMessageAt: enriched.lastMessageAt,
374
- turnCount: enriched.turnCount,
375
- capped: enriched.capped,
376
- sizeBytes: enriched.sizeBytes,
377
- hidden: false,
378
- specialist: null,
379
- };
380
- deps.logger(`meta-jsonl-tail-bytes sessionId=${sessionId.slice(0, 8)} read=${Math.min(enriched.sizeBytes, deps.jsonlReadMaxBytes)} capped=${enriched.capped}`);
582
+ payload = toArchivedPayload(row, '');
381
583
  }
382
- deps.logger(`meta sessionId=${sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
383
- timed(deps.logger, 'GET', `/${sessionId}/meta`, 200, Date.now() - start);
584
+ deps.logger(`meta sessionId=${row.sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
585
+ timed(deps.logger, 'GET', `/${id}/meta`, 200, Date.now() - start);
384
586
  return c.json(payload, 200);
385
587
  });
386
588
  app.patch('/:sessionId', async (c) => {
387
589
  const start = Date.now();
388
- const sessionId = c.req.param('sessionId');
389
- if (!SESSION_ID_PATTERN.test(sessionId)) {
390
- timed(deps.logger, 'PATCH', `/${sessionId}`, 400, Date.now() - start);
590
+ const id = c.req.param('sessionId');
591
+ if (!SESSION_ID_PATTERN.test(id)) {
592
+ timed(deps.logger, 'PATCH', `/${id}`, 400, Date.now() - start);
391
593
  return c.json({ error: 'invalid-session-id' }, 400);
392
594
  }
393
595
  let body;
@@ -395,38 +597,38 @@ export function buildHttpApp(deps) {
395
597
  body = await c.req.json();
396
598
  }
397
599
  catch {
398
- timed(deps.logger, 'PATCH', `/${sessionId}`, 400, Date.now() - start);
600
+ timed(deps.logger, 'PATCH', `/${id}`, 400, Date.now() - start);
399
601
  return c.json({ error: 'body-not-json' }, 400);
400
602
  }
401
603
  if (typeof body.displayName !== 'string' || body.displayName.length === 0 || body.displayName.length > DISPLAY_NAME_MAX) {
402
- timed(deps.logger, 'PATCH', `/${sessionId}`, 400, Date.now() - start);
604
+ timed(deps.logger, 'PATCH', `/${id}`, 400, Date.now() - start);
403
605
  return c.json({ error: 'invalid-displayName', detail: { maxLen: DISPLAY_NAME_MAX } }, 400);
404
606
  }
405
- const resolved = resolveJsonlPath(deps, sessionId);
406
- if (!resolved) {
407
- timed(deps.logger, 'PATCH', `/${sessionId}`, 404, Date.now() - start);
607
+ const row = resolveRow(deps.watcher, id);
608
+ if (!row || !row.jsonlPath) {
609
+ timed(deps.logger, 'PATCH', `/${id}`, 404, Date.now() - start);
408
610
  return c.json({ error: 'session-not-found' }, 404);
409
611
  }
410
- const sidecarPath = sidecarPathFor(resolved.path);
411
- const prior = readSidecar(resolved.path)?.displayName ?? null;
612
+ const sidecarPath = sidecarPathFor(row.jsonlPath);
613
+ const prior = readSidecar(row.jsonlPath)?.displayName ?? null;
412
614
  try {
413
615
  writeFileSync(sidecarPath, JSON.stringify({ displayName: body.displayName }, null, 2) + '\n', 'utf8');
414
616
  }
415
617
  catch (err) {
416
618
  const msg = err instanceof Error ? err.message : String(err);
417
- deps.logger(`rename-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
418
- timed(deps.logger, 'PATCH', `/${sessionId}`, 500, Date.now() - start);
619
+ deps.logger(`rename-failed sessionId=${row.sessionId.slice(0, 8)} message=${msg}`);
620
+ timed(deps.logger, 'PATCH', `/${id}`, 500, Date.now() - start);
419
621
  return c.json({ error: 'sidecar-write-failed', detail: msg }, 500);
420
622
  }
421
- deps.logger(`rename sessionId=${sessionId.slice(0, 8)} from=${prior ?? ''} to=${body.displayName}`);
422
- timed(deps.logger, 'PATCH', `/${sessionId}`, 200, Date.now() - start);
623
+ deps.logger(`rename sessionId=${row.sessionId.slice(0, 8)} from=${prior ?? ''} to=${body.displayName}`);
624
+ timed(deps.logger, 'PATCH', `/${id}`, 200, Date.now() - start);
423
625
  return c.json({ ok: true, displayName: body.displayName }, 200);
424
626
  });
425
627
  app.post('/:sessionId/archive', async (c) => {
426
628
  const start = Date.now();
427
- const sessionId = c.req.param('sessionId');
428
- if (!SESSION_ID_PATTERN.test(sessionId)) {
429
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 400, Date.now() - start);
629
+ const id = c.req.param('sessionId');
630
+ if (!SESSION_ID_PATTERN.test(id)) {
631
+ timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
430
632
  return c.json({ error: 'invalid-session-id' }, 400);
431
633
  }
432
634
  let body;
@@ -434,30 +636,35 @@ export function buildHttpApp(deps) {
434
636
  body = await c.req.json();
435
637
  }
436
638
  catch {
437
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 400, Date.now() - start);
639
+ timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
438
640
  return c.json({ error: 'body-not-json' }, 400);
439
641
  }
440
642
  const mode = body.mode;
441
643
  if (mode !== 'archive' && mode !== 'unarchive') {
442
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 400, Date.now() - start);
644
+ timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
443
645
  return c.json({ error: 'invalid-mode', detail: { expected: ['archive', 'unarchive'] } }, 400);
444
646
  }
445
- if (deps.store.get(sessionId)) {
446
- deps.logger(`archive-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
447
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 409, Date.now() - start);
647
+ const row = resolveRow(deps.watcher, id);
648
+ if (!row) {
649
+ timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
650
+ return c.json({ error: 'session-not-found' }, 404);
651
+ }
652
+ if (deps.store.get(row.sessionId)) {
653
+ deps.logger(`archive-rejected sessionId=${row.sessionId.slice(0, 8)} reason=pty-still-alive`);
654
+ timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
448
655
  return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
449
656
  }
450
657
  const slug = projectSlugForCwd(deps.spawnCwd);
451
658
  const slugDir = join(claudeStateRoot(), 'projects', slug);
452
659
  const archiveDir = join(slugDir, 'archive');
453
- const fileName = `${sessionId}.jsonl`;
454
- const sidecarName = `${sessionId}.meta.json`;
660
+ const fileName = `${row.sessionId}.jsonl`;
661
+ const sidecarName = `${row.sessionId}.meta.json`;
455
662
  const fromJsonl = mode === 'archive' ? join(slugDir, fileName) : join(archiveDir, fileName);
456
663
  const toJsonl = mode === 'archive' ? join(archiveDir, fileName) : join(slugDir, fileName);
457
664
  const fromSidecar = mode === 'archive' ? join(slugDir, sidecarName) : join(archiveDir, sidecarName);
458
665
  const toSidecar = mode === 'archive' ? join(archiveDir, sidecarName) : join(slugDir, sidecarName);
459
666
  if (!existsSync(fromJsonl)) {
460
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 404, Date.now() - start);
667
+ timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
461
668
  return c.json({ error: 'session-not-found' }, 404);
462
669
  }
463
670
  try {
@@ -469,49 +676,63 @@ export function buildHttpApp(deps) {
469
676
  }
470
677
  catch (err) {
471
678
  const msg = err instanceof Error ? err.message : String(err);
472
- deps.logger(`archive-failed sessionId=${sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
473
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 500, Date.now() - start);
679
+ deps.logger(`archive-failed sessionId=${row.sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
680
+ timed(deps.logger, 'POST', `/${id}/archive`, 500, Date.now() - start);
474
681
  return c.json({ error: 'archive-failed', detail: msg }, 500);
475
682
  }
476
- deps.logger(`archive sessionId=${sessionId.slice(0, 8)} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))}`);
477
- timed(deps.logger, 'POST', `/${sessionId}/archive`, 200, Date.now() - start);
683
+ deps.logger(`archive sessionId=${row.sessionId.slice(0, 8)} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))}`);
684
+ timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
478
685
  return c.json({ ok: true, mode }, 200);
479
686
  });
480
- app.delete('/:sessionId', async (c) => {
687
+ // SIGTERM-only stop. Idempotent on already-dead PTYs. JSONL untouched.
688
+ app.post('/:sessionId/stop', async (c) => {
481
689
  const start = Date.now();
482
- const sessionId = c.req.param('sessionId');
483
- const purge = c.req.query('purge') === '1';
484
- if (!SESSION_ID_PATTERN.test(sessionId)) {
485
- timed(deps.logger, 'DELETE', `/${sessionId}`, 400, Date.now() - start);
690
+ const id = c.req.param('sessionId');
691
+ if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
692
+ timed(deps.logger, 'POST', `/${id}/stop`, 400, Date.now() - start);
486
693
  return c.json({ error: 'invalid-session-id' }, 400);
487
694
  }
488
- if (!purge) {
489
- await deleteSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, sessionId);
490
- timed(deps.logger, 'DELETE', `/${sessionId}`, 204, Date.now() - start);
491
- return c.body(null, 204);
695
+ const row = resolveRow(deps.watcher, id);
696
+ if (!row && !deps.store.get(id)) {
697
+ timed(deps.logger, 'POST', `/${id}/stop`, 404, Date.now() - start);
698
+ return c.json({ error: 'session-not-found' }, 404);
699
+ }
700
+ const sessionId = row?.sessionId ?? id;
701
+ await stopSession({ store: deps.store, killGraceMs: deps.killGraceMs, logger: deps.logger }, sessionId);
702
+ timed(deps.logger, 'POST', `/${sessionId}/stop`, 204, Date.now() - start);
703
+ return c.body(null, 204);
704
+ });
705
+ // Hard-remove transcript. 409 if PTY is still alive (stop first).
706
+ app.delete('/:sessionId', async (c) => {
707
+ const start = Date.now();
708
+ const id = c.req.param('sessionId');
709
+ if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
710
+ timed(deps.logger, 'DELETE', `/${id}`, 400, Date.now() - start);
711
+ return c.json({ error: 'invalid-session-id' }, 400);
492
712
  }
713
+ const row = resolveRow(deps.watcher, id);
714
+ const sessionId = row?.sessionId ?? id;
493
715
  if (deps.store.get(sessionId)) {
494
- deps.logger(`purge-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
495
- timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 409, Date.now() - start);
496
- return c.json({ error: 'pty-still-alive', detail: 'end the session before purging' }, 409);
716
+ deps.logger(`delete-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
717
+ timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
718
+ return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
497
719
  }
498
- const resolved = resolveJsonlPath(deps, sessionId);
499
- if (!resolved) {
500
- timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 404, Date.now() - start);
720
+ if (!row || !row.jsonlPath) {
721
+ timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
501
722
  return c.json({ error: 'session-not-found' }, 404);
502
723
  }
503
- const sidecarPath = sidecarPathFor(resolved.path);
724
+ const sidecarPath = sidecarPathFor(row.jsonlPath);
504
725
  const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
505
726
  let bytes = 0;
506
727
  try {
507
- bytes = existsSync(resolved.path) ? statSync(resolved.path).size : 0;
728
+ bytes = existsSync(row.jsonlPath) ? statSync(row.jsonlPath).size : 0;
508
729
  }
509
730
  catch {
510
- // ignore — size is informational
731
+ // size is informational
511
732
  }
512
733
  try {
513
- if (existsSync(resolved.path))
514
- rmSync(resolved.path);
734
+ if (existsSync(row.jsonlPath))
735
+ rmSync(row.jsonlPath);
515
736
  if (existsSync(sidecarPath))
516
737
  rmSync(sidecarPath);
517
738
  if (existsSync(subdir))
@@ -519,24 +740,26 @@ export function buildHttpApp(deps) {
519
740
  }
520
741
  catch (err) {
521
742
  const msg = err instanceof Error ? err.message : String(err);
522
- deps.logger(`purge-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
523
- timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 500, Date.now() - start);
524
- return c.json({ error: 'purge-failed', detail: msg }, 500);
743
+ deps.logger(`delete-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
744
+ timed(deps.logger, 'DELETE', `/${id}`, 500, Date.now() - start);
745
+ return c.json({ error: 'delete-failed', detail: msg }, 500);
525
746
  }
526
747
  deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
527
- timed(deps.logger, 'DELETE', `/${sessionId}?purge=1`, 204, Date.now() - start);
748
+ timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
528
749
  return c.body(null, 204);
529
750
  });
530
751
  app.post('/:sessionId/input', async (c) => {
531
752
  const start = Date.now();
532
- const sessionId = c.req.param('sessionId');
753
+ const id = c.req.param('sessionId');
754
+ const row = resolveRow(deps.watcher, id);
755
+ const sessionId = row?.sessionId ?? id;
533
756
  const s = deps.store.get(sessionId);
534
757
  if (!s) {
535
- timed(deps.logger, 'POST', `/${sessionId}/input`, 404, Date.now() - start);
758
+ timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
536
759
  return c.json({ error: 'session-not-found' }, 404);
537
760
  }
538
761
  if (s.exited) {
539
- timed(deps.logger, 'POST', `/${sessionId}/input`, 410, Date.now() - start);
762
+ timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
540
763
  return c.json({ error: 'session-exited' }, 410);
541
764
  }
542
765
  let body;
@@ -544,11 +767,11 @@ export function buildHttpApp(deps) {
544
767
  body = await c.req.json();
545
768
  }
546
769
  catch {
547
- timed(deps.logger, 'POST', `/${sessionId}/input`, 400, Date.now() - start);
770
+ timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
548
771
  return c.json({ error: 'body-not-json' }, 400);
549
772
  }
550
773
  if (typeof body.text !== 'string') {
551
- timed(deps.logger, 'POST', `/${sessionId}/input`, 400, Date.now() - start);
774
+ timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
552
775
  return c.json({ error: 'text-required' }, 400);
553
776
  }
554
777
  const bytes = Buffer.byteLength(body.text, 'utf8');
@@ -558,26 +781,25 @@ export function buildHttpApp(deps) {
558
781
  catch (err) {
559
782
  const msg = err instanceof Error ? err.message : String(err);
560
783
  deps.logger(`input-failed pid=${s.pid} sessionId=${sessionId} message=${msg}`);
561
- timed(deps.logger, 'POST', `/${sessionId}/input`, 500, Date.now() - start);
784
+ timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
562
785
  return c.json({ error: 'pty-write-failed', detail: msg }, 500);
563
786
  }
564
787
  deps.logger(`input pid=${s.pid} sessionId=${sessionId} bytes=${bytes} channel=${s.channel}`);
565
- timed(deps.logger, 'POST', `/${sessionId}/input`, 200, Date.now() - start);
788
+ timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
566
789
  return c.json({ ok: true, bytes }, 200);
567
790
  });
568
791
  app.get('/:sessionId/log', (c) => {
569
792
  const start = Date.now();
570
- const sessionId = c.req.param('sessionId');
793
+ const id = c.req.param('sessionId');
794
+ const row = resolveRow(deps.watcher, id);
795
+ const sessionId = row?.sessionId ?? id;
571
796
  const s = deps.store.get(sessionId);
572
797
  if (!s) {
573
- timed(deps.logger, 'GET', `/${sessionId}/log`, 404, Date.now() - start);
798
+ timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
574
799
  return c.json({ error: 'session-not-found' }, 404);
575
800
  }
576
801
  if (s.jsonlPath === null) {
577
- // alive but pre-promotion: the first operator turn has not landed,
578
- // so claude has not written a JSONL yet. Loud-fail with 202 so the
579
- // UI can re-poll once `claudeSessionId` flips on a subsequent /list.
580
- timed(deps.logger, 'GET', `/${sessionId}/log`, 202, Date.now() - start);
802
+ timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
581
803
  return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
582
804
  }
583
805
  const jsonlPath = s.jsonlPath;
@@ -585,10 +807,10 @@ export function buildHttpApp(deps) {
585
807
  if (!follow) {
586
808
  const exists = existsSync(jsonlPath);
587
809
  const sizeBytes = exists ? statSync(jsonlPath).size : 0;
588
- timed(deps.logger, 'GET', `/${sessionId}/log`, 200, Date.now() - start);
810
+ timed(deps.logger, 'GET', `/${id}/log`, 200, Date.now() - start);
589
811
  return c.json({ jsonlPath, exists, sizeBytes }, 200);
590
812
  }
591
- timed(deps.logger, 'GET', `/${sessionId}/log?follow=1`, 200, Date.now() - start);
813
+ timed(deps.logger, 'GET', `/${id}/log?follow=1`, 200, Date.now() - start);
592
814
  return stream(c, async (out) => {
593
815
  let position = 0;
594
816
  if (existsSync(jsonlPath)) {