flowviant 0.43.0 → 0.45.0

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.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Terminal-session presence — which Claude Code sessions exist in THIS repo,
3
+ * read off Claude's own on-disk state. Nothing here is inference: the liveness
4
+ * registry (~/.claude/sessions/<pid>.json) says what is open right now, and the
5
+ * transcript store (~/.claude/projects/<munged-cwd>/<id>.jsonl) says what was.
6
+ * The daemon RELAYS both to the server so the Workbench can offer "adopt this
7
+ * terminal session as a tab" — activity, never capacity, and only ever facts
8
+ * the user could see by looking at their own machine.
9
+ *
10
+ * The one contract that matters to callers: NOTHING in this file throws. A
11
+ * presence scan runs inside the poll loop's best-effort tail, and a torn
12
+ * registry file or a vanished cwd is a session to skip, not an error to raise.
13
+ */
14
+
15
+ import {
16
+ readdirSync,
17
+ readFileSync,
18
+ realpathSync,
19
+ statSync,
20
+ openSync,
21
+ readSync,
22
+ closeSync,
23
+ } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+
27
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
28
+ const REPORT_CAP = 30;
29
+
30
+ /** Path-prefix containment on already-realpath'd absolute paths. */
31
+ const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
32
+
33
+ /**
34
+ * Is this pid the SAME process the registry entry recorded?
35
+ *
36
+ * Registry entries go stale — Claude exits, the pid is recycled by something
37
+ * else, the file stays. `/proc/<pid>` existing only proves A process; the
38
+ * starttime (field 22 of /proc/<pid>/stat) proves it is THAT process. The comm
39
+ * field (parenthesised, may itself contain spaces and parens) makes naive
40
+ * whitespace-splitting wrong, so fields are counted from after the LAST ')':
41
+ * the first post-comm field is field 3, which puts starttime at index 19.
42
+ */
43
+ function pidAlive(pid, procStart) {
44
+ if (!Number.isInteger(pid) || pid <= 0) return false;
45
+ let stat;
46
+ try {
47
+ stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
48
+ } catch {
49
+ return false; // no /proc entry — the process is gone
50
+ }
51
+ if (procStart == null) return true; // nothing recorded to compare against
52
+ const close = stat.lastIndexOf(')');
53
+ if (close === -1) return false;
54
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
55
+ return fields[19] === String(procStart);
56
+ }
57
+
58
+ /**
59
+ * Is a terminal Claude session with this id open on the machine RIGHT NOW?
60
+ *
61
+ * The adoption path asks this at the moment of adopting: forking a session
62
+ * while its terminal is still typing into it would put two Claudes on one
63
+ * conversation, which is the exact incoherence the Workbench's own locks
64
+ * exist to prevent.
65
+ */
66
+ export function isTerminalSessionLive(sessionId) {
67
+ try {
68
+ const dir = join(homedir(), '.claude', 'sessions');
69
+ for (const name of readdirSync(dir)) {
70
+ if (!name.endsWith('.json')) continue;
71
+ let rec;
72
+ try {
73
+ rec = JSON.parse(readFileSync(join(dir, name), 'utf8'));
74
+ } catch {
75
+ continue; // torn write / not JSON — not evidence of anything
76
+ }
77
+ if (rec?.sessionId !== sessionId) continue;
78
+ if (pidAlive(rec.pid, rec.procStart)) return true;
79
+ }
80
+ } catch {
81
+ /* registry unreadable — no proof of life is "not live" */
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * First transcript record that carries a cwd, from the file's head only.
88
+ *
89
+ * A transcript can be megabytes; the cwd/gitBranch identity rides on every
90
+ * record, so ~16KB from the front is enough to verify WHOSE session this is
91
+ * without paying to read the conversation. A file whose first cwd-bearing
92
+ * line does not parse (truncated at the window edge) is skipped, not retried
93
+ * deeper — this is presence, not forensics.
94
+ */
95
+ function firstCwdRecord(file) {
96
+ let fd;
97
+ try {
98
+ fd = openSync(file, 'r');
99
+ const buf = Buffer.alloc(16384);
100
+ const n = readSync(fd, buf, 0, buf.length, 0);
101
+ for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
102
+ if (!line.includes('"cwd":"')) continue;
103
+ try {
104
+ const rec = JSON.parse(line);
105
+ if (rec && typeof rec.cwd === 'string' && rec.cwd) return rec;
106
+ } catch {
107
+ /* an incomplete line at the window edge — try the next candidate */
108
+ }
109
+ }
110
+ return null;
111
+ } catch {
112
+ return null;
113
+ } finally {
114
+ if (fd !== undefined) {
115
+ try {
116
+ closeSync(fd);
117
+ } catch {
118
+ /* best-effort */
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Every Claude terminal session belonging to this repo: LIVE ones from the
126
+ * liveness registry, ENDED ones from the transcript store. Returns
127
+ * [{ id, cwd, live, lastActiveAt, branch? }], live first, then newest ended,
128
+ * capped at 30, deterministically ordered (so a stringified report only
129
+ * changes when the facts do).
130
+ *
131
+ * `excludeDirs` carves out the daemon's own worktrees: sessions the daemon
132
+ * itself spawned are tabs already, and offering to adopt one would be the
133
+ * product offering the user their own reflection.
134
+ */
135
+ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
136
+ const live = [];
137
+ const ended = [];
138
+ try {
139
+ let realRoot;
140
+ try {
141
+ realRoot = realpathSync(repoRoot);
142
+ } catch {
143
+ realRoot = String(repoRoot ?? '');
144
+ }
145
+ if (!realRoot) return [];
146
+ const excludes = [];
147
+ for (const d of excludeDirs) {
148
+ if (!d) continue;
149
+ try {
150
+ excludes.push(realpathSync(d));
151
+ } catch {
152
+ excludes.push(String(d)); // not on disk yet — keep the literal fence
153
+ }
154
+ }
155
+ const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
156
+
157
+ // ── LIVE: the registry, validated pid by pid ─────────────────────────
158
+ const nowIso = new Date().toISOString();
159
+ const liveIds = new Set();
160
+ let regNames = [];
161
+ try {
162
+ regNames = readdirSync(join(homedir(), '.claude', 'sessions'));
163
+ } catch {
164
+ /* no registry — no live sessions */
165
+ }
166
+ for (const name of regNames) {
167
+ if (!name.endsWith('.json')) continue; // .key files ride alongside
168
+ let rec;
169
+ try {
170
+ rec = JSON.parse(readFileSync(join(homedir(), '.claude', 'sessions', name), 'utf8'));
171
+ } catch {
172
+ continue;
173
+ }
174
+ if (!rec || typeof rec.sessionId !== 'string' || typeof rec.cwd !== 'string') continue;
175
+ if (liveIds.has(rec.sessionId)) continue;
176
+ if (!pidAlive(rec.pid, rec.procStart)) continue;
177
+ let cwd;
178
+ try {
179
+ cwd = realpathSync(rec.cwd);
180
+ } catch {
181
+ continue; // the directory is gone — nothing to point a tab at
182
+ }
183
+ if (!ours(cwd)) continue;
184
+ liveIds.add(rec.sessionId);
185
+ live.push({
186
+ id: rec.sessionId,
187
+ cwd,
188
+ live: true,
189
+ lastActiveAt: nowIso,
190
+ ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
191
+ });
192
+ }
193
+ live.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
194
+
195
+ // ── ENDED: the transcript store, verified file by file ───────────────
196
+ //
197
+ // The munged directory name is a PREFIX match on purpose: a session run in
198
+ // a SUBDIRECTORY of the repo munges to a longer name sharing the root's.
199
+ // But so does a sibling repo ('flowviant-two' shares 'flowviant' + '-'),
200
+ // which is why every candidate is verified against the cwd its own records
201
+ // embed rather than trusted on its directory name.
202
+ const munged = realRoot.replace(/[/.]/g, '-');
203
+ const projectsDir = join(homedir(), '.claude', 'projects');
204
+ let projDirs = [];
205
+ try {
206
+ projDirs = readdirSync(projectsDir);
207
+ } catch {
208
+ /* no transcript store — live sessions still report */
209
+ }
210
+ const cutoff = Date.now() - SEVEN_DAYS_MS;
211
+ const candidates = [];
212
+ for (const dirName of projDirs) {
213
+ if (dirName !== munged && !dirName.startsWith(`${munged}-`)) continue;
214
+ let entries = [];
215
+ try {
216
+ entries = readdirSync(join(projectsDir, dirName), { withFileTypes: true });
217
+ } catch {
218
+ continue;
219
+ }
220
+ for (const ent of entries) {
221
+ if (!ent.isFile() || !ent.name.endsWith('.jsonl')) continue; // top-level only
222
+ const id = ent.name.slice(0, -'.jsonl'.length);
223
+ if (!id || liveIds.has(id)) continue; // a live session outranks its own transcript
224
+ const file = join(projectsDir, dirName, ent.name);
225
+ let mtimeMs;
226
+ try {
227
+ mtimeMs = statSync(file).mtimeMs;
228
+ } catch {
229
+ continue;
230
+ }
231
+ if (mtimeMs < cutoff) continue; // week-old sessions are history, not presence
232
+ candidates.push({ id, file, mtimeMs });
233
+ }
234
+ }
235
+ // Newest first, then verify only as many as the cap still has room for —
236
+ // the verification read is the expensive step, so it is not spent on
237
+ // sessions the report would drop anyway.
238
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.id < b.id ? -1 : 1));
239
+ const room = Math.max(0, REPORT_CAP - Math.min(live.length, REPORT_CAP));
240
+ const endedIds = new Set();
241
+ for (const cand of candidates) {
242
+ if (ended.length >= room) break;
243
+ if (endedIds.has(cand.id)) continue; // one row per session, whatever dir names it
244
+ endedIds.add(cand.id);
245
+ const rec = firstCwdRecord(cand.file);
246
+ if (!rec) continue;
247
+ let cwd;
248
+ try {
249
+ cwd = realpathSync(rec.cwd);
250
+ } catch {
251
+ continue;
252
+ }
253
+ if (!ours(cwd)) continue;
254
+ ended.push({
255
+ id: cand.id,
256
+ cwd,
257
+ live: false,
258
+ lastActiveAt: new Date(cand.mtimeMs).toISOString(),
259
+ ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
260
+ });
261
+ }
262
+ } catch {
263
+ /* presence must never throw into the poll loop — report what was gathered */
264
+ }
265
+ return [...live.slice(0, REPORT_CAP), ...ended];
266
+ }