@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.
- package/dist/__tests__/claude-ptys-slice.test.js +25 -0
- package/dist/index.js +10 -1
- package/dist/port-resolution.js +29 -0
- package/package.json +1 -1
- package/payload/platform/package-lock.json +18 -4
- package/payload/platform/plugins/docs/references/platform.md +4 -2
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +65 -5
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +468 -223
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts +10 -3
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +486 -193
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +72 -13
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts +10 -4
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js +27 -8
- package/payload/platform/services/claude-session-manager/dist/jsonl-enumerator.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts +17 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.js +25 -0
- package/payload/platform/services/claude-session-manager/dist/jsonl-path.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +165 -33
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +290 -91
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts +6 -2
- package/payload/platform/services/claude-session-manager/dist/reaper.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/reaper.js +29 -25
- package/payload/platform/services/claude-session-manager/dist/reaper.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts +55 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js +170 -0
- package/payload/platform/services/claude-session-manager/dist/session-sidecar.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts +6 -4
- package/payload/platform/services/claude-session-manager/dist/session-store.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-store.js +8 -10
- package/payload/platform/services/claude-session-manager/dist/session-store.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts +114 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js +209 -0
- package/payload/platform/services/claude-session-manager/dist/systemd-scope.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/types.d.ts +0 -4
- package/payload/platform/services/claude-session-manager/dist/types.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts +39 -0
- package/payload/platform/services/claude-session-manager/dist/user-title-store.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/user-title-store.js +138 -0
- package/payload/platform/services/claude-session-manager/dist/user-title-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +3 -0
- package/payload/server/public/assets/{ChatInput-dLWJ0Q8I.css → ChatInput-CzQhPt90.css} +1 -1
- package/payload/server/public/assets/{Checkbox-Bx8mRHQ3.js → Checkbox-ynZtDWi3.js} +1 -1
- package/payload/server/public/assets/admin-ux6TKHS1.js +217 -0
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BEW_bD50.js → architectureDiagram-Q4EWVU46-Ko_izKmY.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DFLT98C3.js → blockDiagram-DXYQGD6D-D58VDC0w.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BemKtROA.js → c4Diagram-AHTNJAMY-C-eRXyKi.js} +1 -1
- package/payload/server/public/assets/channel-CejaITMF.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-BL8fGJ7_.js → chunk-336JU56O-DZLEfLAb.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-BD6by0MP.js → chunk-426QAEUC-BriTU3L8.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-DX-cb6Nd.js → chunk-4TB4RGXK-BdxRL-AY.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-B2QqtIZS.js → chunk-5FUZZQ4R-BgEmy7Y3.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-CzJWmsmd.js → chunk-5PVQY5BW-emnbDRLA.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-DP9vcvEU.js → chunk-EDXVE4YY-BvCf2Xf2.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-D5xWEjF0.js → chunk-ENJZ2VHE-DTpEYJRb.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-AKduf9o4.js → chunk-ICPOFSXX-Ced2j48Y.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-D6jsVNyQ.js → chunk-OYMX7WX6-B21_a3Dv.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-BNAnTYh8.js → chunk-U2HBQHQK-CPS3yyXK.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-UdQhYDnh.js → chunk-X2U36JSP-BJ_B5NW7.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-DSSmSGmS.js → chunk-YZCP3GAM-CMLy-WXm.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-Czm7IDMC.js → chunk-ZZ45TVLE-BkLnzCHe.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-BmoqALf4.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-vMajwCNi.js +1 -0
- package/payload/server/public/assets/clone-mvVfHkyg.js +1 -0
- package/payload/server/public/assets/{dagre-hSIMHJcO.js → dagre-Dd_gq4yj.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-CBuRCJE7.js → dagre-KV5264BT-RY0E5UAn.js} +1 -1
- package/payload/server/public/assets/data-BK4r8Dqy.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-dbMrH0le.js → diagram-5BDNPKRD-DR7XiPLh.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-DBc6hs9a.js → diagram-G4DWMVQ6-2EbOfKFs.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-ubSGybUk.js → diagram-MMDJMWI5-CGcY0Ze7.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-WxL3cv-9.js → diagram-TYMM5635-Dk6MmAso.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-CrBBOAN8.js → erDiagram-SMLLAGMA-CKvdlUqk.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CXUq5Hzt.js → flowDiagram-DWJPFMVM-BqKhvCDz.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-vTCBnYVS.js → ganttDiagram-T4ZO3ILL-CeiE-IRb.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BHrj_bRa.js → gitGraphDiagram-UUTBAWPF-BbPKn7v2.js} +1 -1
- package/payload/server/public/assets/graph-Cb6A8Hhs.js +1 -0
- package/payload/server/public/assets/graph-labels-DCsUNtnE.js +1 -0
- package/payload/server/public/assets/{graphlib-CDO75l2X.js → graphlib-tG6kR59B.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-CcQdaOhi.js → infoDiagram-42DDH7IO-6MiRGCI1.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-3KvltFgV.js → ishikawaDiagram-UXIWVN3A-BwY4p5mg.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-DZCXaGFE.js → journeyDiagram-VCZTEJTY-BXAQ_fUi.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DAIAKy1-.js → kanban-definition-6JOO6SKY-ChiiO7Yt.js} +1 -1
- package/payload/server/public/assets/{lib-CCLURO3E.js → lib-DbPz9cfu.js} +1 -1
- package/payload/server/public/assets/{line-C8iCE2Wx.js → line-B5g5OrOD.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-ElvUhnV9.js → mermaid-parser.core-BMGzMKJx.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-7dSH31Ao.js → mermaid.core-DLMgkjXn.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-FZ0rcfac.js → mindmap-definition-QFDTVHPH-BvyzRLAi.js} +1 -1
- package/payload/server/public/assets/{page-D3bFmqF4.js → page-BeYR_GI2.js} +1 -1
- package/payload/server/public/assets/{page-DNjUtT5q.js → page-DVNp-Dfy.js} +2 -2
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-CDuvMZbO.js → pieDiagram-DEJITSTG--EV9pVfs.js} +1 -1
- package/payload/server/public/assets/{public-g_2skbnY.js → public-sYW0Dwku.js} +3 -3
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-xDjhkqNc.js → quadrantDiagram-34T5L4WZ-DJcL08ft.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-BPKpyT0n.js → requirementDiagram-MS252O5E-D4VdmmSu.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-BvFIrihC.js → sankeyDiagram-XADWPNL6-Dsw3nHRS.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-C6G1oAE0.js → sequenceDiagram-FGHM5R23-CAgemUx5.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DbOJT_Xh.js → stateDiagram-FHFEXIEX-B7dyL9_X.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CImN6Wk6.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-D1DSxHh6.js → timeline-definition-GMOUNBTQ-B7iKswhp.js} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BYoSeMdo.js → vennDiagram-DHZGUBPP-uFS0i1qX.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-DH3kZdea.js → wardleyDiagram-NUSXRM2D-lDUh4LAD.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-C6MROMLG.js → xychartDiagram-5P7HB3ND-0v7MEVwW.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +17 -0
- package/payload/server/public/assets/admin-C57bDUgs.js +0 -217
- package/payload/server/public/assets/channel-CfpByn-m.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DNc9R1IX.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DM1DQh8N.js +0 -1
- package/payload/server/public/assets/clone-BSrNdvLW.js +0 -1
- package/payload/server/public/assets/data-3n20lir5.js +0 -1
- package/payload/server/public/assets/graph-ChwMpxn_.js +0 -1
- package/payload/server/public/assets/graph-labels-CUgJgIwl.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ce4GFk-H.js +0 -1
- /package/payload/server/public/assets/{ChatInput-DoS80kaC.js → ChatInput-Cj9JQtYt.js} +0 -0
|
@@ -1,23 +1,28 @@
|
|
|
1
|
-
// Filesystem watcher —
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
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
|
-
|
|
140
|
-
//
|
|
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?.
|
|
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
|
|
210
|
-
*
|
|
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
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
//
|
|
229
|
-
//
|
|
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(
|
|
232
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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: '
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
const
|
|
347
|
-
if (
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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(
|
|
411
|
-
if (!entry.isFile() || !entry.name.endsWith('.
|
|
516
|
+
for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
|
|
517
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
412
518
|
continue;
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
if (!row)
|
|
519
|
+
const sessionId = entry.name.slice(0, -'.jsonl'.length);
|
|
520
|
+
if (!sessionId)
|
|
416
521
|
continue;
|
|
417
|
-
const jsonlPath =
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
565
|
+
indexRow(row);
|
|
566
|
+
pidCount += 1;
|
|
464
567
|
}
|
|
465
568
|
}
|
|
466
569
|
catch {
|
|
467
|
-
//
|
|
468
|
-
|
|
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
|
-
|
|
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
|
};
|