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.
- package/bin/lib/claude.mjs +15 -604
- package/bin/lib/fleet.mjs +125 -311
- package/bin/lib/localSessions.mjs +266 -0
- package/bin/lib/prompts.mjs +610 -0
- package/bin/lib/runtimes.mjs +21 -4
- package/bin/lib/work.mjs +1144 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|