@rubytech/create-maxy-code 0.1.91 → 0.1.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/__tests__/claude-ptys-slice.test.js +25 -0
  2. package/dist/index.js +10 -1
  3. package/dist/port-resolution.js +29 -0
  4. package/package.json +1 -1
  5. package/payload/platform/package-lock.json +18 -4
  6. package/payload/platform/plugins/docs/references/platform.md +4 -2
  7. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +65 -5
  8. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  9. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +468 -223
  10. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  11. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +10 -3
  12. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  13. package/payload/platform/services/claude-session-manager/dist/http-server.js +486 -193
  14. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  15. package/payload/platform/services/claude-session-manager/dist/index.js +72 -13
  16. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +10 -4
  18. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
  19. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +27 -8
  20. package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
  21. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +17 -0
  22. package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +25 -0
  24. package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +165 -33
  26. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +290 -91
  28. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  29. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +6 -2
  30. package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/reaper.js +29 -25
  32. package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +55 -0
  34. package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -0
  35. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +170 -0
  36. package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -0
  37. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +6 -4
  38. package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
  39. package/payload/platform/services/claude-session-manager/dist/session-store.js +8 -10
  40. package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
  41. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +114 -0
  42. package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -0
  43. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +209 -0
  44. package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -0
  45. package/payload/platform/services/claude-session-manager/dist/types.d.ts +0 -4
  46. package/payload/platform/services/claude-session-manager/dist/types.d.ts.map +1 -1
  47. package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts +39 -0
  48. package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts.map +1 -0
  49. package/payload/platform/services/claude-session-manager/dist/user-title-store.js +138 -0
  50. package/payload/platform/services/claude-session-manager/dist/user-title-store.js.map +1 -0
  51. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +3 -0
  52. package/payload/server/public/assets/{ChatInput-dLWJ0Q8I.css → ChatInput-CzQhPt90.css} +1 -1
  53. package/payload/server/public/assets/{Checkbox-Bx8mRHQ3.js → Checkbox-ynZtDWi3.js} +1 -1
  54. package/payload/server/public/assets/admin-ux6TKHS1.js +217 -0
  55. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BEW_bD50.js → architectureDiagram-Q4EWVU46-Ko_izKmY.js} +1 -1
  56. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DFLT98C3.js → blockDiagram-DXYQGD6D-D58VDC0w.js} +1 -1
  57. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BemKtROA.js → c4Diagram-AHTNJAMY-C-eRXyKi.js} +1 -1
  58. package/payload/server/public/assets/channel-CejaITMF.js +1 -0
  59. package/payload/server/public/assets/{chunk-336JU56O-BL8fGJ7_.js → chunk-336JU56O-DZLEfLAb.js} +2 -2
  60. package/payload/server/public/assets/{chunk-426QAEUC-BD6by0MP.js → chunk-426QAEUC-BriTU3L8.js} +1 -1
  61. package/payload/server/public/assets/{chunk-4TB4RGXK-DX-cb6Nd.js → chunk-4TB4RGXK-BdxRL-AY.js} +1 -1
  62. package/payload/server/public/assets/{chunk-5FUZZQ4R-B2QqtIZS.js → chunk-5FUZZQ4R-BgEmy7Y3.js} +1 -1
  63. package/payload/server/public/assets/{chunk-5PVQY5BW-CzJWmsmd.js → chunk-5PVQY5BW-emnbDRLA.js} +1 -1
  64. package/payload/server/public/assets/{chunk-EDXVE4YY-DP9vcvEU.js → chunk-EDXVE4YY-BvCf2Xf2.js} +1 -1
  65. package/payload/server/public/assets/{chunk-ENJZ2VHE-D5xWEjF0.js → chunk-ENJZ2VHE-DTpEYJRb.js} +1 -1
  66. package/payload/server/public/assets/{chunk-ICPOFSXX-AKduf9o4.js → chunk-ICPOFSXX-Ced2j48Y.js} +1 -1
  67. package/payload/server/public/assets/{chunk-OYMX7WX6-D6jsVNyQ.js → chunk-OYMX7WX6-B21_a3Dv.js} +1 -1
  68. package/payload/server/public/assets/{chunk-U2HBQHQK-BNAnTYh8.js → chunk-U2HBQHQK-CPS3yyXK.js} +1 -1
  69. package/payload/server/public/assets/{chunk-X2U36JSP-UdQhYDnh.js → chunk-X2U36JSP-BJ_B5NW7.js} +1 -1
  70. package/payload/server/public/assets/{chunk-YZCP3GAM-DSSmSGmS.js → chunk-YZCP3GAM-CMLy-WXm.js} +1 -1
  71. package/payload/server/public/assets/{chunk-ZZ45TVLE-Czm7IDMC.js → chunk-ZZ45TVLE-BkLnzCHe.js} +1 -1
  72. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BmoqALf4.js +1 -0
  73. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-vMajwCNi.js +1 -0
  74. package/payload/server/public/assets/clone-mvVfHkyg.js +1 -0
  75. package/payload/server/public/assets/{dagre-hSIMHJcO.js → dagre-Dd_gq4yj.js} +1 -1
  76. package/payload/server/public/assets/{dagre-KV5264BT-CBuRCJE7.js → dagre-KV5264BT-RY0E5UAn.js} +1 -1
  77. package/payload/server/public/assets/data-BK4r8Dqy.js +1 -0
  78. package/payload/server/public/assets/{diagram-5BDNPKRD-dbMrH0le.js → diagram-5BDNPKRD-DR7XiPLh.js} +1 -1
  79. package/payload/server/public/assets/{diagram-G4DWMVQ6-DBc6hs9a.js → diagram-G4DWMVQ6-2EbOfKFs.js} +1 -1
  80. package/payload/server/public/assets/{diagram-MMDJMWI5-ubSGybUk.js → diagram-MMDJMWI5-CGcY0Ze7.js} +1 -1
  81. package/payload/server/public/assets/{diagram-TYMM5635-WxL3cv-9.js → diagram-TYMM5635-Dk6MmAso.js} +1 -1
  82. package/payload/server/public/assets/{erDiagram-SMLLAGMA-CrBBOAN8.js → erDiagram-SMLLAGMA-CKvdlUqk.js} +1 -1
  83. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CXUq5Hzt.js → flowDiagram-DWJPFMVM-BqKhvCDz.js} +1 -1
  84. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-vTCBnYVS.js → ganttDiagram-T4ZO3ILL-CeiE-IRb.js} +1 -1
  85. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BHrj_bRa.js → gitGraphDiagram-UUTBAWPF-BbPKn7v2.js} +1 -1
  86. package/payload/server/public/assets/graph-Cb6A8Hhs.js +1 -0
  87. package/payload/server/public/assets/graph-labels-DCsUNtnE.js +1 -0
  88. package/payload/server/public/assets/{graphlib-CDO75l2X.js → graphlib-tG6kR59B.js} +1 -1
  89. package/payload/server/public/assets/{infoDiagram-42DDH7IO-CcQdaOhi.js → infoDiagram-42DDH7IO-6MiRGCI1.js} +1 -1
  90. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-3KvltFgV.js → ishikawaDiagram-UXIWVN3A-BwY4p5mg.js} +1 -1
  91. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DZCXaGFE.js → journeyDiagram-VCZTEJTY-BXAQ_fUi.js} +1 -1
  92. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DAIAKy1-.js → kanban-definition-6JOO6SKY-ChiiO7Yt.js} +1 -1
  93. package/payload/server/public/assets/{lib-CCLURO3E.js → lib-DbPz9cfu.js} +1 -1
  94. package/payload/server/public/assets/{line-C8iCE2Wx.js → line-B5g5OrOD.js} +1 -1
  95. package/payload/server/public/assets/{mermaid-parser.core-ElvUhnV9.js → mermaid-parser.core-BMGzMKJx.js} +1 -1
  96. package/payload/server/public/assets/{mermaid.core-7dSH31Ao.js → mermaid.core-DLMgkjXn.js} +3 -3
  97. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-FZ0rcfac.js → mindmap-definition-QFDTVHPH-BvyzRLAi.js} +1 -1
  98. package/payload/server/public/assets/{page-D3bFmqF4.js → page-BeYR_GI2.js} +1 -1
  99. package/payload/server/public/assets/{page-DNjUtT5q.js → page-DVNp-Dfy.js} +2 -2
  100. package/payload/server/public/assets/{pieDiagram-DEJITSTG-CDuvMZbO.js → pieDiagram-DEJITSTG--EV9pVfs.js} +1 -1
  101. package/payload/server/public/assets/{public-g_2skbnY.js → public-sYW0Dwku.js} +3 -3
  102. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-xDjhkqNc.js → quadrantDiagram-34T5L4WZ-DJcL08ft.js} +1 -1
  103. package/payload/server/public/assets/{requirementDiagram-MS252O5E-BPKpyT0n.js → requirementDiagram-MS252O5E-D4VdmmSu.js} +1 -1
  104. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BvFIrihC.js → sankeyDiagram-XADWPNL6-Dsw3nHRS.js} +1 -1
  105. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-C6G1oAE0.js → sequenceDiagram-FGHM5R23-CAgemUx5.js} +1 -1
  106. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DbOJT_Xh.js → stateDiagram-FHFEXIEX-B7dyL9_X.js} +1 -1
  107. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CImN6Wk6.js +1 -0
  108. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D1DSxHh6.js → timeline-definition-GMOUNBTQ-B7iKswhp.js} +1 -1
  109. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BYoSeMdo.js → vennDiagram-DHZGUBPP-uFS0i1qX.js} +1 -1
  110. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DH3kZdea.js → wardleyDiagram-NUSXRM2D-lDUh4LAD.js} +1 -1
  111. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-C6MROMLG.js → xychartDiagram-5P7HB3ND-0v7MEVwW.js} +1 -1
  112. package/payload/server/public/data.html +5 -5
  113. package/payload/server/public/graph.html +6 -6
  114. package/payload/server/public/index.html +8 -8
  115. package/payload/server/public/public.html +5 -5
  116. package/payload/server/server.js +17 -0
  117. package/payload/server/public/assets/admin-C57bDUgs.js +0 -217
  118. package/payload/server/public/assets/channel-CfpByn-m.js +0 -1
  119. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DNc9R1IX.js +0 -1
  120. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DM1DQh8N.js +0 -1
  121. package/payload/server/public/assets/clone-BSrNdvLW.js +0 -1
  122. package/payload/server/public/assets/data-3n20lir5.js +0 -1
  123. package/payload/server/public/assets/graph-ChwMpxn_.js +0 -1
  124. package/payload/server/public/assets/graph-labels-CUgJgIwl.js +0 -1
  125. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ce4GFk-H.js +0 -1
  126. /package/payload/server/public/assets/{ChatInput-DoS80kaC.js → ChatInput-Cj9JQtYt.js} +0 -0
@@ -1,23 +1,28 @@
1
- // Filesystem watcher — single source of truth for the manager's row index.
1
+ // Filesystem watcher — disk is the source of truth (Task 258).
2
2
  //
3
3
  // Two directories are watched continuously:
4
4
  //
5
5
  // ${CLAUDE_CONFIG_DIR}/sessions/<pid>.json — written by Claude Code at PTY
6
- // launch for entrypoint=cli. Carries sessionId, bridgeSessionId, agent,
7
- // status, cwd, updatedAt directly. Created at process init, refreshed on
8
- // status/agent change, deleted when the process exits cleanly.
6
+ // launch. Carries sessionId, bridgeSessionId, agent, status, cwd,
7
+ // updatedAt directly. Created at process init, refreshed on status/agent
8
+ // change, deleted when the process exits cleanly.
9
9
  //
10
10
  // ${CLAUDE_CONFIG_DIR}/projects/<slug>/<sessionId>.jsonl — written on the
11
11
  // first operator turn; persists after process exit as the audit
12
12
  // transcript.
13
13
  //
14
- // The watcher unifies the two surfaces into one row indexed by the intrinsic
15
- // `sessionId` (the UUID Claude Code mints, identical to the JSONL basename).
16
- // PID-file events drive the live half of the lifecycle; JSONL events drive
17
- // the archived half. A row exists whenever EITHER file exists.
14
+ // Row identity rule:
15
+ // - A top-level JSONL file at `<projectsDir>/<sid>.jsonl` is a row whose
16
+ // `sessionId` IS the basename. Always.
17
+ // - A PID file at `<sessionsDir>/<pid>.json` decorates a row: the row
18
+ // becomes 'live' when the PID file's `sessionId` field matches a
19
+ // top-level JSONL basename, OR when the PID file's `sessionId` field
20
+ // has no matching JSONL yet (fresh /spawn pre-first-turn).
21
+ // - The fs-watcher carries no path-anchored maps and no merge state
22
+ // machine. After Task 258, paths are derived; the watcher reads disk.
18
23
  //
19
24
  // Boot sequence is sync-then-arm: bootSeed() reads both directories
20
- // synchronously and rebuilds the in-memory index before arm() turns on the
25
+ // synchronously and rebuilds the in-memory cache before arm() turns on the
21
26
  // fs.watch handlers. Without this ordering, watch events fire before the
22
27
  // seed completes and either land before any subscriber attaches (silent
23
28
  // drop) or race with seed inserts (double-emit). The deterministic order
@@ -28,18 +33,10 @@
28
33
  // emits when the PID file is rewritten (write-temp-rename is two events
29
34
  // from fs.watch's perspective on macOS, more on Linux), and folds the
30
35
  // CREATE→MODIFY pair an editor-style writer can emit.
31
- //
32
- // Identifiers carried on every row:
33
- // - sessionId (canonical, never null)
34
- // - bridgeSessionId ("session_<suffix>" form, null for entrypoint=cli
35
- // rows that have not yet written URL-bound state)
36
- // - bridgeSuffix ("<suffix>" — derived; bridgeSessionId minus the
37
- // "session_" prefix)
38
- // - pid (null on archived JSONL-only rows)
39
- // The store/HTTP layer resolves an inbound id against all four keys so a
40
- // caller holding any one of them resolves to the same row.
41
36
  import { existsSync, mkdirSync, openSync, closeSync, readSync, readFileSync, readdirSync, statSync, watch } from 'node:fs';
42
- import { join } from 'node:path';
37
+ import { dirname, join } from 'node:path';
38
+ import { readSidecar } from './session-sidecar.js';
39
+ import { defaultSystemctlRunner, getScopeMainPid, listSurvivingScopes, stopScopeUnit, } from './systemd-scope.js';
43
40
  const DEBOUNCE_MS = 200;
44
41
  // First `agent-setting` record sits near the JSONL head (Claude Code writes
45
42
  // it after env+meta, before the first user turn). 32 KiB is enough to cover
@@ -119,49 +116,28 @@ function parsePidFile(path) {
119
116
  : typeof parsed.startedAt === 'number'
120
117
  ? parsed.startedAt
121
118
  : 0;
122
- return {
123
- sessionId,
124
- bridgeSessionId,
125
- bridgeSuffix,
126
- agent,
127
- cachedAgentSetting: null,
128
- cwd,
129
- pid,
130
- jsonlPath: null,
131
- pidFilePath: path,
132
- state: 'live',
133
- status,
134
- updatedAt,
135
- };
119
+ return { sessionId, pid, bridgeSessionId, bridgeSuffix, agent, cwd, status, updatedAt, pidFilePath: path };
136
120
  }
137
121
  export function createFsWatcher(opts) {
138
122
  const { sessionsDir, projectsDir, logger } = opts;
139
- // Canonical store keyed on sessionId. Secondary maps re-derived from the
140
- // canonical row so a removeRow drops every key in one shot.
123
+ const runSystemctl = opts.runSystemctl ?? defaultSystemctlRunner;
124
+ // Transient row cache, keyed on sessionId. Built by bootSeed and kept
125
+ // in sync by fs event reconciliation. Not the source of truth — disk is.
141
126
  const rowsBySessionId = new Map();
127
+ // Reverse indexes derived from row content. Rebuilt on indexRow/unindexRow.
142
128
  const sessionIdByPid = new Map();
143
129
  const sessionIdByBridgeSuffix = new Map();
144
- const sessionIdByPidFilePath = new Map();
145
- // Task 190 — jsonl-path-anchored prior-row lookup. Closes the symmetric
146
- // gap to Task 186's pid-file swap: when `claude --resume` rewrites the
147
- // pid file, the fork sid stays canonical, but Claude continues writing
148
- // turns to `<source>.jsonl`. The admin server archives the source jsonl
149
- // during resume, so the first post-resume turn re-creates the file at
150
- // the projects-dir path. Without this map, that create event lands as
151
- // a stray archived row. The pid-file swap branch pre-seeds this map
152
- // with `jsonlPathFor(parsed.sessionId) → priorSessionIdAtPath` so the
153
- // jsonl event redirects onto the fork row.
154
- const sessionIdByJsonlPath = new Map();
155
130
  const subscribers = new Set();
156
131
  const pidWaiters = new Map();
157
132
  const debounceTimers = new Map();
158
133
  let pidWatcher = null;
159
134
  let jsonlWatcher = null;
135
+ let archiveWatcher = null;
160
136
  let armed = false;
161
137
  let seeded = false;
162
138
  function emit(event) {
163
139
  logger(`[fs-watcher] event=${event.kind} kind=${event.source} sessionId=${event.sessionId} agent=${event.row?.agent ?? 'none'} ` +
164
- `status=${event.row?.status ?? 'gone'} path=${event.row?.[event.source === 'pid' ? 'pidFilePath' : 'jsonlPath'] ?? 'none'}`);
140
+ `status=${event.row?.status ?? 'gone'} path=${event.row?.pidFilePath ?? 'derived'}`);
165
141
  for (const sub of subscribers) {
166
142
  try {
167
143
  sub(event);
@@ -186,10 +162,6 @@ export function createFsWatcher(opts) {
186
162
  sessionIdByPid.set(row.pid, row.sessionId);
187
163
  if (row.bridgeSuffix)
188
164
  sessionIdByBridgeSuffix.set(row.bridgeSuffix, row.sessionId);
189
- if (row.pidFilePath)
190
- sessionIdByPidFilePath.set(row.pidFilePath, row.sessionId);
191
- if (row.jsonlPath)
192
- sessionIdByJsonlPath.set(row.jsonlPath, row.sessionId);
193
165
  }
194
166
  function unindexRow(row) {
195
167
  rowsBySessionId.delete(row.sessionId);
@@ -197,47 +169,188 @@ export function createFsWatcher(opts) {
197
169
  sessionIdByPid.delete(row.pid);
198
170
  if (row.bridgeSuffix)
199
171
  sessionIdByBridgeSuffix.delete(row.bridgeSuffix);
200
- if (row.pidFilePath)
201
- sessionIdByPidFilePath.delete(row.pidFilePath);
202
- if (row.jsonlPath)
203
- sessionIdByJsonlPath.delete(row.jsonlPath);
204
172
  }
173
+ const archiveDir = join(projectsDir, 'archive');
205
174
  function jsonlPathFor(sessionId) {
206
175
  return join(projectsDir, `${sessionId}.jsonl`);
207
176
  }
177
+ function archiveJsonlPathFor(sessionId) {
178
+ return join(archiveDir, `${sessionId}.jsonl`);
179
+ }
180
+ function sidecarPathForLocation(sessionId, location) {
181
+ return location === 'archive'
182
+ ? join(archiveDir, `${sessionId}.meta.json`)
183
+ : join(projectsDir, `${sessionId}.meta.json`);
184
+ }
185
+ /** Read the sidecar that should live next to `<sessionId>.jsonl` for
186
+ * the given location. Missing or malformed sidecars yield a record
187
+ * with seven null fields and `hidden: false` — the row is still
188
+ * indexed; the operator-facing /list filter hides it unless
189
+ * `includeHidden=1`. The watcher logs every read via `readSidecar`'s
190
+ * own log lines. */
191
+ function readSidecarForLocation(sessionId, location) {
192
+ const path = sidecarPathForLocation(sessionId, location);
193
+ const result = readSidecar(path, logger);
194
+ return { sidecar: result.sidecar, present: result.outcome === 'ok' };
195
+ }
196
+ function applySidecarToRow(row, sidecar) {
197
+ if (!sidecar) {
198
+ return {
199
+ ...row,
200
+ senderId: null,
201
+ role: null,
202
+ channel: null,
203
+ url: null,
204
+ startedAt: null,
205
+ permissionMode: null,
206
+ hidden: false,
207
+ sidecarSpecialist: null,
208
+ };
209
+ }
210
+ return {
211
+ ...row,
212
+ senderId: sidecar.senderId,
213
+ role: sidecar.role,
214
+ channel: sidecar.channel,
215
+ url: sidecar.url,
216
+ startedAt: sidecar.startedAt,
217
+ permissionMode: sidecar.permissionMode,
218
+ hidden: sidecar.hidden,
219
+ sidecarSpecialist: sidecar.specialist,
220
+ };
221
+ }
222
+ /** Find the pid file (if any) whose content names `sessionId`. Used at
223
+ * bootSeed to correlate jsonls with their live pid files, and on jsonl
224
+ * events to detect a freshly-created jsonl whose sessionId matches a
225
+ * pre-first-turn live row. */
226
+ function findPidFileForSessionId(sessionId) {
227
+ try {
228
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
229
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
230
+ continue;
231
+ const parsed = parsePidFile(join(sessionsDir, entry.name));
232
+ if (parsed && parsed.sessionId === sessionId)
233
+ return parsed;
234
+ }
235
+ }
236
+ catch {
237
+ // sessionsDir absent — fine.
238
+ }
239
+ return null;
240
+ }
241
+ function buildLiveRow(pidContent) {
242
+ const base = {
243
+ sessionId: pidContent.sessionId,
244
+ bridgeSessionId: pidContent.bridgeSessionId,
245
+ bridgeSuffix: pidContent.bridgeSuffix,
246
+ agent: pidContent.agent,
247
+ cachedAgentSetting: null,
248
+ cwd: pidContent.cwd,
249
+ pid: pidContent.pid,
250
+ pidFilePath: pidContent.pidFilePath,
251
+ state: 'live',
252
+ status: pidContent.status,
253
+ updatedAt: pidContent.updatedAt,
254
+ scopeUnitToken: null,
255
+ senderId: null,
256
+ role: null,
257
+ channel: null,
258
+ url: null,
259
+ startedAt: null,
260
+ permissionMode: null,
261
+ hidden: false,
262
+ sidecarSpecialist: null,
263
+ };
264
+ // Live rows always read the sidecar from the top-level projects dir
265
+ // (the JSONL hasn't been archived yet). A pre-first-turn live spawn
266
+ // without a sidecar should never happen post-Task-260 because the
267
+ // pty-spawner writes the sidecar BEFORE the PID-file event resolves
268
+ // — but if it does, the row indexes with null spawn fields and the
269
+ // /list filter hides it unless includeHidden=1.
270
+ const { sidecar } = readSidecarForLocation(pidContent.sessionId, 'top');
271
+ return applySidecarToRow(base, sidecar);
272
+ }
273
+ function buildArchivedRow(sessionId, jsonlPath) {
274
+ // Locate the sidecar that belongs next to this JSONL. The path the
275
+ // caller passed names the JSONL's directory; the sidecar shares
276
+ // that directory by construction.
277
+ const location = dirname(jsonlPath) === archiveDir ? 'archive' : 'top';
278
+ const base = {
279
+ sessionId,
280
+ bridgeSessionId: null,
281
+ bridgeSuffix: null,
282
+ agent: null,
283
+ cachedAgentSetting: readAgentSettingFromJsonl(jsonlPath),
284
+ cwd: null,
285
+ pid: null,
286
+ pidFilePath: null,
287
+ state: location === 'archive' ? 'archived' : 'archived',
288
+ status: 'gone',
289
+ updatedAt: (() => {
290
+ try {
291
+ return statSync(jsonlPath).mtimeMs;
292
+ }
293
+ catch {
294
+ return 0;
295
+ }
296
+ })(),
297
+ scopeUnitToken: null,
298
+ senderId: null,
299
+ role: null,
300
+ channel: null,
301
+ url: null,
302
+ startedAt: null,
303
+ permissionMode: null,
304
+ hidden: false,
305
+ sidecarSpecialist: null,
306
+ };
307
+ const { sidecar } = readSidecarForLocation(sessionId, location);
308
+ return applySidecarToRow(base, sidecar);
309
+ }
208
310
  /** Apply the current on-disk state for one PID-file path to the index.
209
- * Reads the file if present, merges with any existing row (by sessionId),
210
- * or removes the row when the file is gone. Emits create/modify/delete. */
311
+ * Reads the file if present; on delete, finds the row via pid (from the
312
+ * filename) since we no longer carry a path-keyed index. */
211
313
  function reconcilePidFile(path) {
212
314
  const onDisk = existsSync(path);
213
- const priorSessionId = sessionIdByPidFilePath.get(path);
214
315
  if (!onDisk) {
316
+ // pid is encoded in the filename. Look up by pid → sessionId.
317
+ const fileName = path.slice(path.lastIndexOf('/') + 1);
318
+ const pidStr = fileName.replace(/\.json$/, '');
319
+ const pid = /^\d+$/.test(pidStr) ? Number(pidStr) : null;
320
+ if (pid === null)
321
+ return;
322
+ const priorSessionId = sessionIdByPid.get(pid);
215
323
  if (!priorSessionId)
216
324
  return;
217
325
  const prior = rowsBySessionId.get(priorSessionId);
218
- if (!prior) {
219
- sessionIdByPidFilePath.delete(path);
326
+ if (!prior)
220
327
  return;
221
- }
222
- // PID file gone. If JSONL still exists, demote row to archived;
223
- // else drop it entirely.
224
- const jsonlPath = prior.jsonlPath ?? jsonlPathFor(prior.sessionId);
225
- if (existsSync(jsonlPath)) {
328
+ // Task 260 — the JSONL may have already moved to archive/ if the
329
+ // operator pressed Archive concurrently with the PTY exit. Check
330
+ // both locations; the demotion row stays a single entry, with the
331
+ // sidecar re-read from whichever directory currently holds it.
332
+ const topJsonl = jsonlPathFor(prior.sessionId);
333
+ const archivedJsonl = archiveJsonlPathFor(prior.sessionId);
334
+ const hasTop = existsSync(topJsonl);
335
+ const hasArchived = existsSync(archivedJsonl);
336
+ if (hasTop || hasArchived) {
226
337
  // Cache agent identity from JSONL head before the PID file is
227
- // forgotten. `agent` on a live row comes from the PID file
228
- // (`--agent <name>`); after demote it's null, so without this
229
- // cache the UI would lose the specialist label.
338
+ // forgotten. `agent` on a live row comes from the PID file; after
339
+ // demote it's null, so without this cache the UI would lose the
340
+ // specialist label.
341
+ const presentJsonl = hasTop ? topJsonl : archivedJsonl;
342
+ const sidecarLocation = hasTop ? 'top' : 'archive';
230
343
  const cachedAgentSetting = prior.cachedAgentSetting
231
- ?? readAgentSettingFromJsonl(jsonlPath);
232
- const updated = {
344
+ ?? readAgentSettingFromJsonl(presentJsonl);
345
+ const { sidecar } = readSidecarForLocation(prior.sessionId, sidecarLocation);
346
+ const updated = applySidecarToRow({
233
347
  ...prior,
234
348
  pid: null,
235
349
  pidFilePath: null,
236
350
  state: 'archived',
237
351
  status: 'gone',
238
352
  cachedAgentSetting,
239
- jsonlPath,
240
- };
353
+ }, sidecar);
241
354
  unindexRow(prior);
242
355
  indexRow(updated);
243
356
  emit({ kind: 'delete', source: 'pid', row: updated, sessionId: updated.sessionId });
@@ -251,138 +364,124 @@ export function createFsWatcher(opts) {
251
364
  const parsed = parsePidFile(path);
252
365
  if (!parsed)
253
366
  return;
254
- const jsonlPath = jsonlPathFor(parsed.sessionId);
255
- if (existsSync(jsonlPath))
256
- parsed.jsonlPath = jsonlPath;
257
- // Task 186 — `claude --resume` rewrites the pid file's sessionId field
258
- // to the resumed source id at the same path. Hold the prior sessionId
259
- // as canonical: it matches `SessionStore.add(...)` from `/spawn` (and
260
- // the `/resume` response payload echoed to the client URL). Merge the
261
- // rewritten content's mutable fields (agent, status, updatedAt) into
262
- // the prior row; do NOT index a second row.
263
- const priorSessionIdAtPath = sessionIdByPidFilePath.get(path);
264
- if (priorSessionIdAtPath && priorSessionIdAtPath !== parsed.sessionId) {
265
- const priorAtPath = rowsBySessionId.get(priorSessionIdAtPath);
266
- if (priorAtPath) {
267
- logger(`[fs-watcher] pid-file-sessionId-changed path=${path} prior=${priorSessionIdAtPath} next=${parsed.sessionId}`);
268
- // Task 190 — pre-register the resumed source jsonl path against
269
- // the prior canonical sid so the eventual jsonl create event
270
- // (Claude writes turns to `<parsed.sessionId>.jsonl`) routes back
271
- // to this row instead of indexing a stray archived row.
272
- sessionIdByJsonlPath.set(jsonlPathFor(parsed.sessionId), priorSessionIdAtPath);
273
- const merged = {
274
- ...priorAtPath,
275
- agent: parsed.agent,
276
- status: parsed.status,
277
- updatedAt: parsed.updatedAt,
278
- };
279
- rowsBySessionId.set(priorSessionIdAtPath, merged);
280
- emit({ kind: 'modify', source: 'pid', row: merged, sessionId: priorSessionIdAtPath });
281
- return;
367
+ // If this pid was already mapped to a different sessionId (claude
368
+ // rewrote the file's sessionId mid-life, e.g. Task 186 resume
369
+ // convergence), drop the stale row first. The new content is the
370
+ // authoritative state.
371
+ const stalePriorByPid = sessionIdByPid.get(parsed.pid);
372
+ if (stalePriorByPid && stalePriorByPid !== parsed.sessionId) {
373
+ const stale = rowsBySessionId.get(stalePriorByPid);
374
+ if (stale) {
375
+ unindexRow(stale);
376
+ emit({ kind: 'delete', source: 'pid', row: null, sessionId: stalePriorByPid });
282
377
  }
283
- // Prior row was unindexed (e.g. by the reaper) between events. Drop
284
- // the stale path mapping and fall through to the standard new-row
285
- // path so the rewritten content lands as a fresh row rather than
286
- // permanently leaking the swap log line.
287
- logger(`[fs-watcher] pid-file-sessionId-changed path=${path} prior=${priorSessionIdAtPath} next=${parsed.sessionId} prior-row=gone`);
288
- sessionIdByPidFilePath.delete(path);
289
378
  }
290
379
  const prior = rowsBySessionId.get(parsed.sessionId);
291
- if (!prior) {
292
- indexRow(parsed);
293
- emit({ kind: 'create', source: 'pid', row: parsed, sessionId: parsed.sessionId });
380
+ const merged = buildLiveRow(parsed);
381
+ if (prior) {
382
+ // Preserve cachedAgentSetting from the prior row (read once at
383
+ // archive transition or boot-seed; pid file rewrites don't carry it).
384
+ merged.cachedAgentSetting = prior.cachedAgentSetting ?? merged.cachedAgentSetting;
385
+ unindexRow(prior);
386
+ indexRow(merged);
387
+ emit({ kind: 'modify', source: 'pid', row: merged, sessionId: merged.sessionId });
294
388
  return;
295
389
  }
296
- // Merge: keep jsonlPath from prior if file existed; otherwise from
297
- // parsed (which already checked disk above).
298
- const merged = {
299
- ...prior,
300
- ...parsed,
301
- jsonlPath: parsed.jsonlPath ?? prior.jsonlPath,
302
- };
303
- unindexRow(prior);
304
390
  indexRow(merged);
305
- emit({ kind: 'modify', source: 'pid', row: merged, sessionId: merged.sessionId });
391
+ emit({ kind: 'create', source: 'pid', row: merged, sessionId: merged.sessionId });
306
392
  }
393
+ // Task 260 — reconciliation is now path-aware: the same sessionId can
394
+ // surface at `<projectsDir>/<sid>.jsonl` (live or ended-but-not-archived)
395
+ // or at `<projectsDir>/archive/<sid>.jsonl` (archived). Row identity is
396
+ // the sessionId; the row's `state` reflects which directory holds the
397
+ // JSONL right now.
398
+ //
399
+ // The load-bearing event in this path is the top↔archive RENAME, which
400
+ // fs.watch emits as a (delete on source, create on destination) pair in
401
+ // either order. Coalescing that pair into ONE `row-updated` event keyed
402
+ // on sessionId is the contract Task 260 promises the UI: an archive
403
+ // never produces a `row-removed` followed by a `row-created` — it
404
+ // produces a single `row-updated` with the new state flag.
405
+ //
406
+ // Implementation: on a delete event for one location, check whether the
407
+ // sibling location still holds the JSONL for the same sessionId. If
408
+ // yes, mutate the row's state (and re-read its sidecar from the new
409
+ // location) and emit a single `modify`. If no, the JSONL really left
410
+ // disk — unindex and emit delete. On a create event, if the row
411
+ // already exists with the matching state, no-op (we already emitted
412
+ // the modify above); otherwise treat as a fresh row.
307
413
  function reconcileJsonlFile(path) {
308
- const onDisk = existsSync(path);
309
414
  const fileName = path.slice(path.lastIndexOf('/') + 1);
310
415
  if (!fileName.endsWith('.jsonl'))
311
416
  return;
312
417
  const sessionId = fileName.slice(0, -'.jsonl'.length);
418
+ const location = dirname(path) === archiveDir ? 'archive' : 'top';
419
+ const onDisk = existsSync(path);
313
420
  const prior = rowsBySessionId.get(sessionId);
421
+ const siblingPath = location === 'top' ? archiveJsonlPathFor(sessionId) : jsonlPathFor(sessionId);
422
+ const siblingOnDisk = existsSync(siblingPath);
314
423
  if (!onDisk) {
424
+ // Event is a delete for THIS location.
315
425
  if (!prior)
316
426
  return;
317
- // JSONL gone. If PID file still present, row stays live but loses
318
- // transcript; else drop row entirely.
427
+ if (siblingOnDisk) {
428
+ // top↔archive rename coalesce into ONE modify with the new
429
+ // state. The new location reflects where the JSONL lives now.
430
+ const newLocation = location === 'top' ? 'archive' : 'top';
431
+ const newState = newLocation === 'top' ? 'live' : 'archived';
432
+ const { sidecar } = readSidecarForLocation(sessionId, newLocation);
433
+ const updated = applySidecarToRow({ ...prior, state: newState }, sidecar);
434
+ unindexRow(prior);
435
+ indexRow(updated);
436
+ emit({ kind: 'modify', source: 'jsonl', row: updated, sessionId });
437
+ logger(`[fs-watcher] archive-rename sessionId=${sessionId.slice(0, 8)} direction=${location}->${newLocation}`);
438
+ return;
439
+ }
440
+ // True delete. If PID file still present at the prior location,
441
+ // row stays live (transient — live PTY may write a new jsonl on
442
+ // the next turn); else drop the row entirely.
319
443
  if (prior.pidFilePath && existsSync(prior.pidFilePath)) {
320
- const updated = { ...prior, jsonlPath: null };
321
- rowsBySessionId.set(updated.sessionId, updated);
322
- emit({ kind: 'delete', source: 'jsonl', row: updated, sessionId });
444
+ emit({ kind: 'delete', source: 'jsonl', row: prior, sessionId });
323
445
  return;
324
446
  }
325
447
  unindexRow(prior);
326
448
  emit({ kind: 'delete', source: 'jsonl', row: null, sessionId });
327
449
  return;
328
450
  }
451
+ // Event is a create or modify on THIS location.
452
+ const desiredState = location === 'top' ? 'live' : 'archived';
329
453
  if (prior) {
330
- if (prior.jsonlPath === path) {
331
- emit({ kind: 'modify', source: 'jsonl', row: prior, sessionId });
332
- return;
333
- }
334
- const updated = { ...prior, jsonlPath: path };
454
+ // Row already exists. If the state matches (or the row is live
455
+ // because a PID file is present), this is a content modify or
456
+ // the second event of a rename whose first half already mutated
457
+ // the row to the new state — both cases emit a single modify and
458
+ // refresh the sidecar.
459
+ const { sidecar } = readSidecarForLocation(sessionId, location);
460
+ const nextState = prior.pidFilePath && existsSync(prior.pidFilePath) && location === 'top'
461
+ ? 'live'
462
+ : desiredState;
463
+ const updated = applySidecarToRow({ ...prior, state: nextState }, sidecar);
464
+ // Only unindex+reindex when something changed; otherwise we just
465
+ // re-emit modify so subscribers re-render.
335
466
  unindexRow(prior);
336
467
  indexRow(updated);
337
468
  emit({ kind: 'modify', source: 'jsonl', row: updated, sessionId });
338
469
  return;
339
470
  }
340
- // Task 190 before falling into the archived-row fallback, consult
341
- // the jsonl-path map. A prior canonical sid may have pre-registered
342
- // this path via the Task 186 pid-file swap branch (the resume case)
343
- // or a row indexed earlier may name this path. Either way: redirect
344
- // the event onto the prior canonical sid rather than indexing a
345
- // second row at the basename-derived sid.
346
- const priorCanonicalSid = sessionIdByJsonlPath.get(path);
347
- if (priorCanonicalSid && priorCanonicalSid !== sessionId) {
348
- const priorCanonical = rowsBySessionId.get(priorCanonicalSid);
349
- if (priorCanonical) {
350
- logger(`[fs-watcher] jsonl-path-sessionId-changed path=${path} prior=${priorCanonicalSid} next=${sessionId}`);
351
- const updated = { ...priorCanonical, jsonlPath: path };
352
- unindexRow(priorCanonical);
353
- indexRow(updated);
354
- emit({ kind: 'modify', source: 'jsonl', row: updated, sessionId: updated.sessionId });
355
- return;
356
- }
357
- // Prior canonical row vanished between the swap pre-seed and the
358
- // jsonl event (e.g. pid file deleted, reaper). Drop the stale map
359
- // entry and fall through to the standard archived-row path so the
360
- // file lands as an ordinary archived row at the basename sid.
361
- logger(`[fs-watcher] jsonl-path-sessionId-changed path=${path} prior=${priorCanonicalSid} next=${sessionId} prior-row=gone`);
362
- sessionIdByJsonlPath.delete(path);
363
- }
364
- // JSONL appeared without a corresponding PID file → archived row.
365
- const row = {
366
- sessionId,
367
- bridgeSessionId: null,
368
- bridgeSuffix: null,
369
- agent: null,
370
- cachedAgentSetting: readAgentSettingFromJsonl(path),
371
- cwd: null,
372
- pid: null,
373
- jsonlPath: path,
374
- pidFilePath: null,
375
- state: 'archived',
376
- status: 'gone',
377
- updatedAt: (() => {
378
- try {
379
- return statSync(path).mtimeMs;
380
- }
381
- catch {
382
- return 0;
383
- }
384
- })(),
385
- };
471
+ // Fresh jsonl at this sessionId. If a pid file already exists with
472
+ // this sessionId (rare but possible claude wrote its first turn
473
+ // before the pid-file create event was observed), build a live row.
474
+ // Otherwise the row reflects the location: top live (manager
475
+ // probably just spawned but the PID file hasn't been observed yet,
476
+ // or a manually-restored archive) / archive → archived.
477
+ const matchingPid = findPidFileForSessionId(sessionId);
478
+ if (matchingPid && location === 'top') {
479
+ const row = buildLiveRow(matchingPid);
480
+ indexRow(row);
481
+ emit({ kind: 'create', source: 'jsonl', row, sessionId });
482
+ return;
483
+ }
484
+ const row = buildArchivedRow(sessionId, path);
386
485
  indexRow(row);
387
486
  emit({ kind: 'create', source: 'jsonl', row, sessionId });
388
487
  }
@@ -404,71 +503,165 @@ export function createFsWatcher(opts) {
404
503
  function bootSeed() {
405
504
  if (seeded)
406
505
  return;
506
+ // Task 260 — walk the projects dir AND its archive subdir so archived
507
+ // rows are first-class members of the row index. Then walk the
508
+ // sessions dir; each pid file's sessionId either matches an
509
+ // already-seeded jsonl row (promote to live) or has no jsonl yet (a
510
+ // pre-first-turn live row, no transcript).
511
+ let topJsonlCount = 0;
512
+ let archiveJsonlCount = 0;
513
+ let sidecarCount = 0;
407
514
  let pidCount = 0;
408
- let jsonlCount = 0;
409
515
  try {
410
- for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
411
- if (!entry.isFile() || !entry.name.endsWith('.json'))
516
+ for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
517
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
412
518
  continue;
413
- const path = join(sessionsDir, entry.name);
414
- const row = parsePidFile(path);
415
- if (!row)
519
+ const sessionId = entry.name.slice(0, -'.jsonl'.length);
520
+ if (!sessionId)
416
521
  continue;
417
- const jsonlPath = jsonlPathFor(row.sessionId);
418
- if (existsSync(jsonlPath))
419
- row.jsonlPath = jsonlPath;
420
- indexRow(row);
421
- pidCount += 1;
522
+ const jsonlPath = join(projectsDir, entry.name);
523
+ if (!rowsBySessionId.has(sessionId)) {
524
+ indexRow(buildArchivedRow(sessionId, jsonlPath));
525
+ }
526
+ topJsonlCount += 1;
422
527
  }
423
528
  }
424
529
  catch {
425
- // sessionsDir absent — fine. Watcher creates it via mkdir at arm time.
530
+ // projectsDir absent on first boot Claude Code creates it on first
531
+ // transcript write. Watcher's mkdirSync at arm() avoids fs.watch ENOENT.
426
532
  }
427
533
  try {
428
- for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
534
+ for (const entry of readdirSync(archiveDir, { withFileTypes: true })) {
429
535
  if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
430
536
  continue;
431
537
  const sessionId = entry.name.slice(0, -'.jsonl'.length);
432
- const path = join(projectsDir, entry.name);
433
- const prior = rowsBySessionId.get(sessionId);
434
- if (prior) {
435
- if (prior.jsonlPath !== path) {
436
- rowsBySessionId.set(sessionId, { ...prior, jsonlPath: path });
437
- }
538
+ if (!sessionId)
539
+ continue;
540
+ const jsonlPath = join(archiveDir, entry.name);
541
+ if (!rowsBySessionId.has(sessionId)) {
542
+ indexRow(buildArchivedRow(sessionId, jsonlPath));
438
543
  }
439
- else {
440
- const row = {
441
- sessionId,
442
- bridgeSessionId: null,
443
- bridgeSuffix: null,
444
- agent: null,
445
- cachedAgentSetting: readAgentSettingFromJsonl(path),
446
- cwd: null,
447
- pid: null,
448
- jsonlPath: path,
449
- pidFilePath: null,
450
- state: 'archived',
451
- status: 'gone',
452
- updatedAt: (() => {
453
- try {
454
- return statSync(path).mtimeMs;
455
- }
456
- catch {
457
- return 0;
458
- }
459
- })(),
460
- };
461
- indexRow(row);
544
+ archiveJsonlCount += 1;
545
+ }
546
+ }
547
+ catch {
548
+ // archive subdir absent — fine. Watcher mkdirs it at arm time.
549
+ }
550
+ try {
551
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
552
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
553
+ continue;
554
+ const path = join(sessionsDir, entry.name);
555
+ const parsed = parsePidFile(path);
556
+ if (!parsed)
557
+ continue;
558
+ const prior = rowsBySessionId.get(parsed.sessionId);
559
+ const row = buildLiveRow(parsed);
560
+ if (prior) {
561
+ // Preserve cached agent setting we already read from the jsonl head.
562
+ row.cachedAgentSetting = prior.cachedAgentSetting ?? row.cachedAgentSetting;
563
+ unindexRow(prior);
462
564
  }
463
- jsonlCount += 1;
565
+ indexRow(row);
566
+ pidCount += 1;
464
567
  }
465
568
  }
466
569
  catch {
467
- // projectsDir absent on first boot Claude Code creates it on first
468
- // transcript write. Watcher's mkdirSync at arm() avoids fs.watch ENOENT.
570
+ // sessionsDir absent — fine. Watcher creates it via mkdir at arm time.
571
+ }
572
+ // Count rows whose sidecar populated `senderId` — the load-bearing
573
+ // signal that a row is fully addressable in /list. `sidecarCount <
574
+ // topJsonlCount + archiveJsonlCount` flags pre-Task-260 JSONLs that
575
+ // landed before the sidecar writer existed: they index normally but
576
+ // /list filters them out unless includeHidden=1.
577
+ for (const row of rowsBySessionId.values()) {
578
+ if (row.senderId !== null)
579
+ sidecarCount += 1;
469
580
  }
470
581
  seeded = true;
471
- logger(`[fs-watcher] boot-seed sessions-dir=${pidCount} projects-dir=${jsonlCount} rows=${rowsBySessionId.size}`);
582
+ const totalJsonl = topJsonlCount + archiveJsonlCount;
583
+ logger(`[fs-watcher] boot-seed sessions-dir=${pidCount} projects-dir-top=${topJsonlCount} projects-dir-archive=${archiveJsonlCount} sidecars=${sidecarCount} rows=${rowsBySessionId.size} jsonl-total=${totalJsonl}`);
584
+ // Task 250 — second pass: reconcile surviving `claude-session-*.scope`
585
+ // units with the rows just built. A surviving scope without a row is
586
+ // an orphan; a surviving scope with a row reattaches as `detached`.
587
+ reconcileSurvivingScopes();
588
+ }
589
+ /** Task 250 — boot-seed extension. Lists `claude-session-*.scope` units
590
+ * owned by the user manager; for each, queries MainPID and looks up the
591
+ * matching PID file in sessionsDir. The PID file's `sessionId` field is
592
+ * the row key. Outcomes per scope:
593
+ *
594
+ * - reattached: surviving scope whose MainPID points to a parsed PID
595
+ * file whose sessionId is in `rowsBySessionId`. The row's
596
+ * `scopeUnitToken` is set and `status` flipped to `detached`. The
597
+ * manager has lost the PTY master fd; the row is observation-only
598
+ * until the operator clicks End (handled separately via the row's
599
+ * unit token).
600
+ * - orphaned: surviving scope whose MainPID either (a) doesn't
601
+ * resolve, (b) has no PID file, or (c) the PID file's sessionId is
602
+ * unknown. The manager cannot reconstruct what claude is doing; per
603
+ * the eng-review decision, it issues `systemctl --user stop` on the
604
+ * orphan rather than leaving the cgroup alive.
605
+ *
606
+ * Emits `[boot-seed] survived-scopes=N reattached=N orphaned=N
607
+ * orphaned-stopped=N` even when every count is zero so the operator
608
+ * can confirm the reconciliation ran. */
609
+ function reconcileSurvivingScopes() {
610
+ const { scopes, result } = listSurvivingScopes(runSystemctl);
611
+ if (result.exitCode !== 0) {
612
+ logger(`[boot-seed] systemctl-list-units-failed exit-code=${result.exitCode} stderr=${JSON.stringify(result.stderr.slice(-200))} survived-scopes=0 reattached=0 orphaned=0 orphaned-stopped=0`);
613
+ return;
614
+ }
615
+ let reattached = 0;
616
+ let orphaned = 0;
617
+ let orphanedStopped = 0;
618
+ for (const scope of scopes) {
619
+ const decision = reattachOrOrphan(scope);
620
+ if (decision === 'reattached') {
621
+ reattached += 1;
622
+ }
623
+ else {
624
+ orphaned += 1;
625
+ try {
626
+ const stop = stopScopeUnit(runSystemctl, scope.unitToken);
627
+ if (stop.outcome === 'ok' || stop.outcome === 'already-gone') {
628
+ orphanedStopped += 1;
629
+ }
630
+ logger(`[boot-seed] orphan-stop unit=${scope.unitName} outcome=${stop.outcome} exit-code=${stop.exitCode}`);
631
+ }
632
+ catch (err) {
633
+ const msg = err instanceof Error ? err.message : String(err);
634
+ logger(`[boot-seed] orphan-stop-threw unit=${scope.unitName} err=${JSON.stringify(msg)}`);
635
+ }
636
+ }
637
+ }
638
+ logger(`[boot-seed] survived-scopes=${scopes.length} reattached=${reattached} orphaned=${orphaned} orphaned-stopped=${orphanedStopped}`);
639
+ }
640
+ function reattachOrOrphan(scope) {
641
+ const mainPid = getScopeMainPid(runSystemctl, scope.unitToken);
642
+ if (mainPid === null)
643
+ return 'orphan';
644
+ // Look up PID file at `<sessionsDir>/<mainPid>.json`. Claude Code's
645
+ // own session-init writes this file with `pid` + `sessionId` fields.
646
+ const pidPath = join(sessionsDir, `${mainPid}.json`);
647
+ const parsed = parsePidFile(pidPath);
648
+ if (!parsed)
649
+ return 'orphan';
650
+ if (parsed.pid !== mainPid)
651
+ return 'orphan';
652
+ const row = rowsBySessionId.get(parsed.sessionId);
653
+ if (!row)
654
+ return 'orphan';
655
+ // Found a matching row — mark it detached and stamp the scope token.
656
+ // Use unindexRow/indexRow to keep the reverse indexes consistent (we
657
+ // are not changing the sessionId or pid keys, only mutable fields, so
658
+ // re-indexing is defensive).
659
+ unindexRow(row);
660
+ row.status = 'detached';
661
+ row.scopeUnitToken = scope.unitToken;
662
+ indexRow(row);
663
+ logger(`[boot-seed] reattached sessionId=${parsed.sessionId} unit=${scope.unitName} pid=${mainPid}`);
664
+ return 'reattached';
472
665
  }
473
666
  function arm() {
474
667
  if (armed)
@@ -483,6 +676,13 @@ export function createFsWatcher(opts) {
483
676
  mkdirSync(projectsDir, { recursive: true });
484
677
  }
485
678
  catch { /* best effort */ }
679
+ // Task 260 — archive subdir must exist before fs.watch attaches;
680
+ // otherwise the watch handle fails ENOENT and we miss the first
681
+ // archive event of the session.
682
+ try {
683
+ mkdirSync(archiveDir, { recursive: true });
684
+ }
685
+ catch { /* best effort */ }
486
686
  try {
487
687
  pidWatcher = watch(sessionsDir, { persistent: false }, (_event, filename) => {
488
688
  if (!filename || !filename.endsWith('.json'))
@@ -511,8 +711,27 @@ export function createFsWatcher(opts) {
511
711
  catch (err) {
512
712
  logger(`[fs-watcher] watch-init-failed dir=${projectsDir} message=${err instanceof Error ? err.message : String(err)}`);
513
713
  }
714
+ // Task 260 — second fs.watch on archive/. Without this the archive
715
+ // route's rename emits a delete on top (handled by jsonlWatcher) but
716
+ // the create on archive is silent, breaking the rename-coalesce that
717
+ // turns the pair into one row-updated event. The watcher's
718
+ // reconcileJsonlFile handles archive paths symmetric to top paths.
719
+ try {
720
+ archiveWatcher = watch(archiveDir, { persistent: false }, (_event, filename) => {
721
+ if (!filename || !filename.endsWith('.jsonl'))
722
+ return;
723
+ const path = join(archiveDir, filename);
724
+ schedule(path, reconcileJsonlFile);
725
+ });
726
+ archiveWatcher.on('error', (err) => {
727
+ logger(`[fs-watcher] watch-error dir=${archiveDir} message=${err instanceof Error ? err.message : String(err)}`);
728
+ });
729
+ }
730
+ catch (err) {
731
+ logger(`[fs-watcher] watch-init-failed dir=${archiveDir} message=${err instanceof Error ? err.message : String(err)}`);
732
+ }
514
733
  armed = true;
515
- logger(`[fs-watcher] armed sessions-dir=${sessionsDir} projects-dir=${projectsDir}`);
734
+ logger(`[fs-watcher] armed sessions-dir=${sessionsDir} projects-dir=${projectsDir} archive-dir=${archiveDir}`);
516
735
  }
517
736
  function subscribe(sub) {
518
737
  subscribers.add(sub);
@@ -549,6 +768,27 @@ export function createFsWatcher(opts) {
549
768
  pidWaiters.set(pid, { resolve, reject, timer });
550
769
  });
551
770
  }
771
+ function refreshSidecar(sessionId) {
772
+ const prior = rowsBySessionId.get(sessionId);
773
+ if (!prior)
774
+ return false;
775
+ // Task 260 — find the sidecar wherever it actually lives on disk
776
+ // (top or archive). Row state alone doesn't say: an `archived`
777
+ // state means "no live PTY", but the JSONL/sidecar pair may still
778
+ // be at the top level (e.g. a pre-Task-260 JSONL that has never
779
+ // been archived through the route).
780
+ const topSidecar = sidecarPathForLocation(sessionId, 'top');
781
+ const archiveSidecar = sidecarPathForLocation(sessionId, 'archive');
782
+ const location = existsSync(topSidecar)
783
+ ? 'top'
784
+ : existsSync(archiveSidecar) ? 'archive' : (prior.state === 'archived' ? 'archive' : 'top');
785
+ const { sidecar } = readSidecarForLocation(sessionId, location);
786
+ const updated = applySidecarToRow(prior, sidecar);
787
+ unindexRow(prior);
788
+ indexRow(updated);
789
+ emit({ kind: 'modify', source: 'jsonl', row: updated, sessionId });
790
+ return true;
791
+ }
552
792
  function close() {
553
793
  if (pidWatcher) {
554
794
  pidWatcher.close();
@@ -558,6 +798,10 @@ export function createFsWatcher(opts) {
558
798
  jsonlWatcher.close();
559
799
  jsonlWatcher = null;
560
800
  }
801
+ if (archiveWatcher) {
802
+ archiveWatcher.close();
803
+ archiveWatcher = null;
804
+ }
561
805
  for (const t of debounceTimers.values())
562
806
  clearTimeout(t);
563
807
  debounceTimers.clear();
@@ -578,6 +822,7 @@ export function createFsWatcher(opts) {
578
822
  getByPid,
579
823
  all,
580
824
  waitForPid,
825
+ refreshSidecar,
581
826
  close,
582
827
  __internal: { reconcilePidFile, reconcileJsonlFile },
583
828
  };