flowviant 0.103.0 → 0.104.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.
Files changed (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,411 @@
1
+ /**
2
+ * CLAUDE CODE'S SESSION PRESENCE, read off its own on-disk state (split out of
3
+ * localSessions.mjs 2026-09-26, SOLID F062). The liveness registry
4
+ * (~/.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
+ * A Claude transcript-format change lands here; the Antigravity reader is
7
+ * agySessions.mjs and the shared fence and report cap are the coordinator's
8
+ * (localSessions.mjs).
9
+ *
10
+ * NOTHING in this file throws: a torn registry file or a vanished cwd is a
11
+ * session to skip, not an error to raise.
12
+ */
13
+
14
+ import {
15
+ existsSync,
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
+ import { execFileSync } from 'node:child_process';
27
+
28
+ // ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
29
+ // newest per directory, few. (The window itself is the coordinator's.)
30
+ const ENDED_CAP = 5;
31
+
32
+ /**
33
+ * The conversation's own title, off the transcript's `ai-title` records
34
+ * (the LAST one wins — titles get rewritten as a session evolves), and ONLY
35
+ * those records: this wire is presence METADATA, and the first user message
36
+ * — the fallback this used to relay — is transcript CONTENT wearing a
37
+ * title's clothes. An untitled row is honest presence; a quoted message is
38
+ * a leak. Those records sit anywhere in the file (measured: line 81 to line
39
+ * 4457), so this reads the WHOLE transcript — behind an mtime cache, because
40
+ * the scan runs every minute and a title only changes when the file does:
41
+ * steady state is a stat, not a read.
42
+ */
43
+ const titleCache = new Map(); // file → { mtimeMs, title }
44
+ function transcriptTitle(file, mtimeMs) {
45
+ const hit = titleCache.get(file);
46
+ if (hit && hit.mtimeMs === mtimeMs) return hit.title;
47
+ let title = null;
48
+ try {
49
+ const stat = statSync(file);
50
+ // A transcript past this is not worth a read per minute of drift.
51
+ if (stat.size <= 64 * 1024 * 1024) {
52
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
53
+ if (!line.includes('"type":"ai-title"')) continue;
54
+ try {
55
+ const t = JSON.parse(line)?.aiTitle;
56
+ if (typeof t === 'string' && t.trim()) title = t.trim(); // last wins
57
+ } catch {
58
+ /* torn line */
59
+ }
60
+ }
61
+ if (title) title = title.replace(/\s+/g, ' ').slice(0, 120);
62
+ }
63
+ } catch {
64
+ title = null;
65
+ }
66
+ if (titleCache.size > 400) titleCache.clear(); // a bound, not an LRU — refills in one scan
67
+ titleCache.set(file, { mtimeMs, title });
68
+ return title;
69
+ }
70
+
71
+ /**
72
+ * THE TITLE CLAUDE GAVE ITS OWN CONVERSATION, for one session we already know
73
+ * the id of.
74
+ *
75
+ * `scanLocalSessions` finds transcripts the hard way — walk every project
76
+ * directory, read each file's own `cwd` — because it is answering "what is in
77
+ * this repo" and knows no ids. This asks the same store a narrower question:
78
+ * the tab pinned the id the CLI reported at `system.init`, and the directory is
79
+ * the place the turn was spawned in, so the file is one munge away (Claude Code
80
+ * names the directory after the cwd with `/` and `.` replaced by `-`).
81
+ *
82
+ * The realpath fallback is not defensive padding: a place under a symlinked
83
+ * home munges to a DIFFERENT directory name depending on which form the CLI was
84
+ * handed, and the daemon does not control which that was.
85
+ *
86
+ * Returns null for anything it cannot read. A missing title is honest silence —
87
+ * the tab keeps whatever name it has.
88
+ */
89
+ export function titleForSession(cwd, sessionId) {
90
+ if (!cwd || typeof sessionId !== 'string' || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) {
91
+ return null;
92
+ }
93
+ const projects = join(homedir(), '.claude', 'projects');
94
+ const dirs = [String(cwd)];
95
+ try {
96
+ const real = realpathSync(String(cwd));
97
+ if (real !== String(cwd)) dirs.push(real);
98
+ } catch {
99
+ /* the place is gone — nothing to read */
100
+ }
101
+ for (const dir of dirs) {
102
+ const file = join(projects, claudeProjectDir(dir), `${sessionId}.jsonl`);
103
+ try {
104
+ return transcriptTitle(file, statSync(file).mtimeMs);
105
+ } catch {
106
+ /* not this munge — try the other */
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * THE DIRECTORY CLAUDE CODE FILES A CWD'S TRANSCRIPTS UNDER — its own rule,
114
+ * copied exactly (2.1.281): every character that is not an ASCII letter or
115
+ * digit becomes `-`, and a name past 200 characters is cut there and suffixed
116
+ * with a base-36 Java-style string hash of the ORIGINAL path.
117
+ *
118
+ * This file used `replace(/[/.]/g, '-')`, which agrees with Claude Code only
119
+ * while a path holds nothing but letters, digits, `/` and `.` — so a repo at
120
+ * `~/code/my_app` or `~/My Projects/x` never offered an ended terminal session
121
+ * for adoption and never got a tab auto-title, silently (audit 2026-09-24).
122
+ */
123
+ const CLAUDE_DIR_MAX = 200;
124
+ export function claudeProjectDir(path) {
125
+ const p = String(path);
126
+ const s = p.replace(/[^a-zA-Z0-9]/g, '-');
127
+ if (s.length <= CLAUDE_DIR_MAX) return s;
128
+ let h = 0;
129
+ for (let i = 0; i < p.length; i++) h = ((h << 5) - h + p.charCodeAt(i)) | 0;
130
+ return `${s.slice(0, CLAUDE_DIR_MAX)}-${Math.abs(h).toString(36)}`;
131
+ }
132
+
133
+ /**
134
+ * Is this pid the SAME process the registry entry recorded?
135
+ *
136
+ * Registry entries go stale — Claude exits, the pid is recycled by something
137
+ * else, the file stays. `/proc/<pid>` existing only proves A process; the
138
+ * starttime (field 22 of /proc/<pid>/stat) proves it is THAT process. The comm
139
+ * field (parenthesised, may itself contain spaces and parens) makes naive
140
+ * whitespace-splitting wrong, so fields are counted from after the LAST ')':
141
+ * the first post-comm field is field 3, which puts starttime at index 19.
142
+ */
143
+ function pidAlive(pid, procStart) {
144
+ if (!Number.isInteger(pid) || pid <= 0) return false;
145
+ let stat;
146
+ try {
147
+ stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
148
+ } catch {
149
+ // No /proc ENTRY means the process is gone — but only where /proc exists
150
+ // at all. On macOS there is no /proc, so this read fails for EVERY pid,
151
+ // every session scans as dead, and live sessions become adoptable — the
152
+ // exact two-drivers-on-one-conversation outcome liveness exists to
153
+ // prevent. Probe by name instead: `ps -o comm=` on the pid, alive only if
154
+ // the surviving process still LOOKS like a coding-CLI process. A bare
155
+ // signal-0 probe was tried first and reads a RECYCLED pid as alive
156
+ // forever — a crashed session whose pid a launchd service inherited would
157
+ // report live for weeks, refuse adoption, and suppress its own ended row.
158
+ // The name check keeps the conservative direction (a transient race still
159
+ // errs live) without the permanent false-live.
160
+ if (!existsSync('/proc')) {
161
+ try {
162
+ const comm = execFileSync('ps', ['-o', 'comm=', '-p', String(pid)], {
163
+ encoding: 'utf8',
164
+ stdio: ['ignore', 'pipe', 'ignore'],
165
+ timeout: 5_000,
166
+ })
167
+ .trim()
168
+ .toLowerCase();
169
+ return /\b(claude|node|bun|codex|agy)\b|\/(claude|node|bun|codex|agy)$/.test(comm);
170
+ } catch {
171
+ return false; // ps errored or the pid is gone — the process is dead
172
+ }
173
+ }
174
+ return false; // /proc is real and has no entry — the process is gone
175
+ }
176
+ if (procStart == null) return true; // nothing recorded to compare against
177
+ const close = stat.lastIndexOf(')');
178
+ if (close === -1) return false;
179
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
180
+ return fields[19] === String(procStart);
181
+ }
182
+
183
+ /**
184
+ * Is a terminal Claude session with this id open on the machine RIGHT NOW?
185
+ *
186
+ * The adoption path asks this at the moment of adopting: forking a session
187
+ * while its terminal is still typing into it would put two Claudes on one
188
+ * conversation, which is the exact incoherence the Workbench's own locks
189
+ * exist to prevent.
190
+ */
191
+ export function isTerminalSessionLive(sessionId) {
192
+ try {
193
+ const dir = join(homedir(), '.claude', 'sessions');
194
+ for (const name of readdirSync(dir)) {
195
+ if (!name.endsWith('.json')) continue;
196
+ let rec;
197
+ try {
198
+ rec = JSON.parse(readFileSync(join(dir, name), 'utf8'));
199
+ } catch {
200
+ continue; // torn write / not JSON — not evidence of anything
201
+ }
202
+ if (rec?.sessionId !== sessionId) continue;
203
+ if (pidAlive(rec.pid, rec.procStart)) return true;
204
+ }
205
+ } catch {
206
+ /* registry unreadable — no proof of life is "not live" */
207
+ }
208
+ return false;
209
+ }
210
+
211
+ /**
212
+ * First transcript record that carries a cwd, from the file's head only.
213
+ *
214
+ * A transcript can be megabytes; the cwd/gitBranch identity rides on every
215
+ * record, so ~16KB from the front is enough to verify WHOSE session this is
216
+ * without paying to read the conversation. A file whose first cwd-bearing
217
+ * line does not parse (truncated at the window edge) is skipped, not retried
218
+ * deeper — this is presence, not forensics.
219
+ */
220
+ function firstCwdRecord(file) {
221
+ let fd;
222
+ try {
223
+ fd = openSync(file, 'r');
224
+ const buf = Buffer.alloc(16384);
225
+ const n = readSync(fd, buf, 0, buf.length, 0);
226
+ for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
227
+ if (!line.includes('"cwd":"')) continue;
228
+ try {
229
+ const rec = JSON.parse(line);
230
+ if (rec && typeof rec.cwd === 'string' && rec.cwd) return rec;
231
+ } catch {
232
+ /* an incomplete line at the window edge — try the next candidate */
233
+ }
234
+ }
235
+ return null;
236
+ } catch {
237
+ return null;
238
+ } finally {
239
+ if (fd !== undefined) {
240
+ try {
241
+ closeSync(fd);
242
+ } catch {
243
+ /* best-effort */
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Every Claude terminal session belonging to this repo: LIVE ones from the
251
+ * liveness registry, ENDED ones from the transcript store. Returns
252
+ * [{ id, cwd, live, lastActiveAt, branch?, title? }], live first, then newest
253
+ * ended, capped at `cap`, deterministically ordered (so a stringified report
254
+ * only changes when the facts do).
255
+ *
256
+ * The coordinator (localSessions.mjs) hands over the fence: `realRoot`, the
257
+ * `ours(path)` containment test with the daemon's worktrees carved out, the
258
+ * `mine` id set (our own tabs), and the ended-session `cutoff`.
259
+ */
260
+ export function scanClaudeSessions({ realRoot, ours, mine, cutoff, cap }) {
261
+ const live = [];
262
+ const ended = [];
263
+ try {
264
+ // ── LIVE: the registry, validated pid by pid ─────────────────────────
265
+ const nowIso = new Date().toISOString();
266
+ const liveIds = new Set();
267
+ let regNames = [];
268
+ try {
269
+ regNames = readdirSync(join(homedir(), '.claude', 'sessions'));
270
+ } catch {
271
+ /* no registry — no live sessions */
272
+ }
273
+ for (const name of regNames) {
274
+ if (!name.endsWith('.json')) continue; // .key files ride alongside
275
+ let rec;
276
+ try {
277
+ rec = JSON.parse(readFileSync(join(homedir(), '.claude', 'sessions', name), 'utf8'));
278
+ } catch {
279
+ continue;
280
+ }
281
+ if (!rec || typeof rec.sessionId !== 'string' || typeof rec.cwd !== 'string') continue;
282
+ if (liveIds.has(rec.sessionId)) continue;
283
+ if (!pidAlive(rec.pid, rec.procStart)) continue;
284
+ let cwd;
285
+ try {
286
+ cwd = realpathSync(rec.cwd);
287
+ } catch {
288
+ continue; // the directory is gone — nothing to point a tab at
289
+ }
290
+ if (!ours(cwd)) continue;
291
+ liveIds.add(rec.sessionId);
292
+ // A live session's title, off its own transcript (the registry `name`
293
+ // is a machine-y fallback like "flowviant-35").
294
+ let liveTitle = null;
295
+ try {
296
+ const liveFile = join(
297
+ homedir(),
298
+ '.claude',
299
+ 'projects',
300
+ claudeProjectDir(cwd),
301
+ `${rec.sessionId}.jsonl`
302
+ );
303
+ liveTitle = transcriptTitle(liveFile, statSync(liveFile).mtimeMs);
304
+ } catch {
305
+ /* no transcript yet */
306
+ }
307
+ if (!liveTitle && typeof rec.name === 'string' && rec.name.trim()) liveTitle = rec.name.trim();
308
+ if (mine.has(rec.sessionId)) continue; // our own tab, not a terminal session
309
+ live.push({
310
+ id: rec.sessionId,
311
+ cwd,
312
+ live: true,
313
+ lastActiveAt: nowIso,
314
+ ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
315
+ ...(liveTitle ? { title: liveTitle } : {}),
316
+ });
317
+ }
318
+ live.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
319
+
320
+ // ── ENDED: the transcript store, verified file by file ───────────────
321
+ //
322
+ // The munged directory name is a PREFIX match on purpose: a session run in
323
+ // a SUBDIRECTORY of the repo munges to a longer name sharing the root's.
324
+ // But so does a sibling repo ('flowviant-two' shares 'flowviant' + '-'),
325
+ // which is why every candidate is verified against the cwd its own records
326
+ // embed rather than trusted on its directory name.
327
+ const munged = claudeProjectDir(realRoot);
328
+ // A root past Claude's 200-character cut carries a hash of ITSELF, which a
329
+ // subdirectory's name does not share — so the prefix a subdirectory can
330
+ // match is the cut, and the cwd verification below does the rest.
331
+ const mungedStem = munged.length > CLAUDE_DIR_MAX ? munged.slice(0, CLAUDE_DIR_MAX) : `${munged}-`;
332
+ const projectsDir = join(homedir(), '.claude', 'projects');
333
+ let projDirs = [];
334
+ try {
335
+ projDirs = readdirSync(projectsDir);
336
+ } catch {
337
+ /* no transcript store — live sessions still report */
338
+ }
339
+ const candidates = [];
340
+ for (const dirName of projDirs) {
341
+ if (dirName !== munged && !dirName.startsWith(mungedStem)) continue;
342
+ let entries = [];
343
+ try {
344
+ entries = readdirSync(join(projectsDir, dirName), { withFileTypes: true });
345
+ } catch {
346
+ continue;
347
+ }
348
+ for (const ent of entries) {
349
+ if (!ent.isFile() || !ent.name.endsWith('.jsonl')) continue; // top-level only
350
+ const id = ent.name.slice(0, -'.jsonl'.length);
351
+ if (!id || liveIds.has(id)) continue; // a live session outranks its own transcript
352
+ const file = join(projectsDir, dirName, ent.name);
353
+ let mtimeMs;
354
+ try {
355
+ mtimeMs = statSync(file).mtimeMs;
356
+ } catch {
357
+ continue;
358
+ }
359
+ if (mtimeMs < cutoff) continue; // an aged session is history, not presence
360
+ candidates.push({ id, file, mtimeMs });
361
+ }
362
+ }
363
+ // Newest first, then verify only as many as the cap still has room for —
364
+ // the verification read is the expensive step, so it is not spent on
365
+ // sessions the report would drop anyway.
366
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.id < b.id ? -1 : 1));
367
+ // ONE row per DIRECTORY, newest first, few: twenty sessions in the repo
368
+ // root are one offer — the newest is the one `--resume`'s picker would
369
+ // reach for and the only one worth importing 95% of the time. The rest
370
+ // are scrollback, and the product's own law says scrollback doesn't
371
+ // matter.
372
+ const room = Math.min(ENDED_CAP, Math.max(0, cap - Math.min(live.length, cap)));
373
+ const endedIds = new Set();
374
+ const seenCwds = new Set(live.map((s) => s.cwd));
375
+ for (const cand of candidates) {
376
+ if (ended.length >= room) break;
377
+ if (endedIds.has(cand.id)) continue; // one row per session, whatever dir names it
378
+ endedIds.add(cand.id);
379
+ const rec = firstCwdRecord(cand.file);
380
+ if (!rec) continue;
381
+ let cwd;
382
+ try {
383
+ cwd = realpathSync(rec.cwd);
384
+ } catch {
385
+ continue;
386
+ }
387
+ if (!ours(cwd)) continue;
388
+ /**
389
+ * OUR OWN TAB IS NOT A CANDIDATE, and it is skipped BEFORE `seenCwds`
390
+ * claims the directory — otherwise the daemon's transcript (always the
391
+ * freshest thing in the checkout) took the one slot that directory gets
392
+ * and a genuine terminal session there could never be offered at all.
393
+ */
394
+ if (mine.has(cand.id)) continue;
395
+ if (seenCwds.has(cwd)) continue; // newest per directory; a live one owns its cwd
396
+ seenCwds.add(cwd);
397
+ const title = transcriptTitle(cand.file, cand.mtimeMs);
398
+ ended.push({
399
+ id: cand.id,
400
+ cwd,
401
+ live: false,
402
+ lastActiveAt: new Date(cand.mtimeMs).toISOString(),
403
+ ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
404
+ ...(title ? { title } : {}),
405
+ });
406
+ }
407
+ } catch {
408
+ /* presence must never throw into the poll loop — report what was gathered */
409
+ }
410
+ return [...live.slice(0, cap), ...ended];
411
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * CLAUDE CODE'S EVENT STREAM, READ — one line of `--output-format stream-json`
3
+ * into feed activities, the answer text, the init facts and the turn's tokens.
4
+ *
5
+ * Split out of claude.mjs (SOLID 2026-09-26) because it changes when the CLI's
6
+ * event schema changes, which is a different reason from a permission list or
7
+ * a process's lifetime. Codex's and agy's readers, and the Claude tool
8
+ * humanizers this loop calls, live in runtimeEvents.mjs; this is the stream
9
+ * loop Claude's `parse: null` means.
10
+ */
11
+
12
+ import { humanizeClaudeTool, THINK_MARKER } from './runtimeEvents.mjs';
13
+
14
+ // Turn one Claude tool_use into a compact activity {kind, label}, or null for
15
+ // tools not worth surfacing. `kind:'read'` is what the file counter counts; a
16
+ // Write/Edit of a vault page is the "writing" signal. Used by wiki turns to
17
+ // stream exactly which files Claude is touching (daemon console + app cover).
18
+ //
19
+ // The body moved to runtimeEvents.mjs, beside Codex's equivalent, because they are
20
+ // the same job for two vendors and keeping them apart is how the two activity
21
+ // vocabularies drift. Re-exported under its original name: a dozen call sites
22
+ // know it, and none of them care where it lives.
23
+ export const humanizeToolUse = humanizeClaudeTool;
24
+
25
+ // Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
26
+ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
27
+
28
+ /**
29
+ * WHAT THIS TURN COST, IN THE CLI'S OWN NUMBERS (2026-09-19).
30
+ *
31
+ * The `result` event carries a `usage` object — `input_tokens`,
32
+ * `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` —
33
+ * and this reads exactly those four and nothing else. A RELAY: nothing here
34
+ * derives, estimates or prices anything.
35
+ *
36
+ * DELIBERATELY NOT `total_cost_usd`, which rides the same event. It is a
37
+ * notional list price that a subscription operator did not pay, so relaying it
38
+ * as "cost" would be the product asserting a figure nobody was charged — the
39
+ * refusal the board's fix pass already made once against the mock's "1.4
40
+ * spent". Tokens are measured; dollars are not.
41
+ *
42
+ * COERCED AND FLOORED, never trusted: a non-number, a NaN, an Infinity or a
43
+ * negative reads as 0, because these are summed into a counter that only ever
44
+ * goes up and one bad field must not poison the other three.
45
+ *
46
+ * Returns null when there is no usage object at all — the three-state rule
47
+ * every readout here keeps: a turn that reported nothing charges nothing, and
48
+ * that is not the same as a turn that reported zeros.
49
+ */
50
+ export function usageFromResult(ev) {
51
+ const u = ev?.usage;
52
+ if (!u || typeof u !== 'object') return null;
53
+ const n = (v) => {
54
+ const x = Number(v);
55
+ return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0;
56
+ };
57
+ return {
58
+ input: n(u.input_tokens),
59
+ output: n(u.output_tokens),
60
+ cacheCreate: n(u.cache_creation_input_tokens),
61
+ cacheRead: n(u.cache_read_input_tokens),
62
+ };
63
+ }
64
+
65
+ // Parse ONE line of `--output-format stream-json` NDJSON into feed activities.
66
+ // Surfaces the WHOLE turn — thinking, narration, AND every tool — so neither the
67
+ // daemon console nor the app cover goes dark while Claude reasons (Opus thinks in
68
+ // bursts before/between tools; emitting only tools left long silent gaps).
69
+ // Assistant text is also folded into `out` so the WIKI_DONE/REGROUND_DONE
70
+ // sentinels still match. A non-JSON line (a stray warning) is kept as raw text.
71
+ //
72
+ // `answerFromResult` narrows that last part for callers whose `out` IS the
73
+ // answer rather than a haystack to match sentinels in (a Workbench tab's turn):
74
+ // every intermediate text block still NARRATES, but only the final `result`
75
+ // event contributes text — otherwise the same sentences arrive twice, once as
76
+ // they stream and once in the result, and the tab posts the duplicate.
77
+ //
78
+ // EXPORTED FOR ITS TEST ONLY (2026-09-19), and the reason is worth stating: the
79
+ // `onUsage` threading through this function, `runTurn`'s options and `onLine`
80
+ // was four edits and NOT ONE of them was reachable from either suite — delete
81
+ // any one and both stay green, while the only symptom in production is a
82
+ // container that reports no spend, which is indistinguishable from an older
83
+ // daemon by design. A callback that is silent when it breaks has to be called
84
+ // directly by something.
85
+ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage }) {
86
+ let ev;
87
+ try {
88
+ ev = JSON.parse(line);
89
+ } catch {
90
+ appendText(line + '\n');
91
+ emit(line + '\n');
92
+ return;
93
+ }
94
+ const push = (a) => {
95
+ if (!a || !a.label) return;
96
+ emit(a.label + '\n');
97
+ onActivity?.(a);
98
+ };
99
+ if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
100
+ for (const b of ev.message.content) {
101
+ if (b.type === 'thinking' || b.type === 'redacted_thinking') {
102
+ /**
103
+ * NO THINKING TEXT ARRIVES TODAY, AND THAT IS MEASURED (2026-09-16).
104
+ *
105
+ * This used to say the text is "usually redacted", which was a guess
106
+ * doing the work of a fact. The fact: across three real transcripts, 93
107
+ * thinking blocks, EVERY ONE of them carried an empty `thinking` — and
108
+ * a live probe with MAX_THINKING_TOKENS set and
109
+ * `--include-partial-messages` on returned an empty `thinking_delta`
110
+ * and a complete block of length zero (signature only). So the CLI
111
+ * emits the FACT that it reasoned and not a word of the reasoning, and
112
+ * "show the full thinking" cannot be conjured from this stream.
113
+ *
114
+ * THE BRANCH BELOW EXISTS ANYWAY, and deliberately. The shape is the
115
+ * whole point: when text is present it rides `full` UNCLIPPED, so the
116
+ * day a CLI release starts emitting it, the trace carries the thought
117
+ * whole with no daemon change and no version floor — the report's own
118
+ * presence is the capability. Until then every block takes the marker
119
+ * arm, and `trace.mjs` collapses a run of identical markers so the
120
+ * absence reads as one quiet step rather than forty.
121
+ */
122
+ push(
123
+ b.thinking
124
+ ? { kind: 'think', label: `thinking: ${oneLine(b.thinking)}`, full: b.thinking }
125
+ : { kind: 'think', label: THINK_MARKER }
126
+ );
127
+ } else if (b.type === 'text' && b.text?.trim()) {
128
+ if (!answerFromResult) appendText(b.text + '\n');
129
+ // `label` for the console and the pulse — one collapsed 160-char line,
130
+ // byte-identical to what it has always printed. `full` for the trace:
131
+ // a `say` is the agent NARRATING, several sentences at a time, and the
132
+ // clip at 160 was landing mid-sentence on the one thing a person opens
133
+ // the page to read.
134
+ push({ kind: 'say', label: oneLine(b.text), full: b.text });
135
+ } else if (b.type === 'tool_use') {
136
+ push(humanizeToolUse(b.name, b.input || {}, cwd));
137
+ // The STRUCTURED form of the same event, for the transcript's tool
138
+ // cards — raw name + input, so the collector can keep what the
139
+ // one-line humanizer drops (an Edit's counts, the plan's items).
140
+ onToolEvent?.(b.name, b.input || {});
141
+ }
142
+ }
143
+ } else if (ev.type === 'system' && ev.subtype === 'init') {
144
+ // WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
145
+ // CLI's OWN answer — it has already resolved personal skills, this repo's
146
+ // skills, plugins and whatever the project settings enable or disable — so
147
+ // reading it costs nothing and cannot drift the way a `~/.claude/skills`
148
+ // scan of our own would. `skills` (rather than `slash_commands`) is the
149
+ // deliberate narrowing: the 50-odd commands beside it are the CLI's own
150
+ // interactive furniture (/clear, /model, /compact), and offering those in a
151
+ // relayed tab would be an offer wired to nothing.
152
+ //
153
+ // Only ever REPORTED, never enforced. Flowviant does not decide what your
154
+ // Claude can do; it relays what your Claude said it has.
155
+ // `sessionId` rides along for one reason: a `-p` turn WRITES a transcript,
156
+ // and `claudeSessions.mjs` offers the newest ended session per directory as
157
+ // ADOPTABLE — so any headless turn we run for our own purposes would leave
158
+ // a phantom untitled session in the `+` menu. The caller that needs to
159
+ // clean up after itself cannot do so without this id.
160
+ //
161
+ // `mcpServers` (0.97.0) is the same kind of fact from the same event: the
162
+ // MCP servers and claude.ai connectors the CLI mounted, each with its own
163
+ // status (`needs-auth` above all — a sign-in that has to happen at this
164
+ // box). Relayed raw; `recordMcpServers` (runtimeCapabilities.mjs) normalises it and
165
+ // drops this daemon's own `flowviant` server.
166
+ if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
167
+ onInit?.({
168
+ skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
169
+ sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
170
+ mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : undefined,
171
+ });
172
+ }
173
+ } else if (ev.type === 'result') {
174
+ /**
175
+ * WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
176
+ *
177
+ * FIRED ON A FAILED RESULT TOO, and that ordering is the point: a turn that
178
+ * hit a limit, ran out of permission or aborted still sent the requests it
179
+ * sent, and a spend readout that quietly skipped every unhappy turn would
180
+ * under-report exactly the runs somebody is looking at the number to
181
+ * understand. `usageFromResult` returns null when the event carries no
182
+ * usage at all, and a caller that passes no `onUsage` sees no change.
183
+ */
184
+ const usage = usageFromResult(ev);
185
+ if (usage) onUsage?.(usage);
186
+ // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
187
+ if (typeof ev.result === 'string') appendText(ev.result + '\n');
188
+ else if (ev.is_error || ev.subtype) {
189
+ // A result that carries no text is a FAILED turn (a limit, a refused
190
+ // permission, an aborted run). Under `answerFromResult` this is the only
191
+ // stdout that would have said so, and a caller whose `out` is the answer
192
+ // must not report "no output" for a turn that explained itself.
193
+ // `errors[]` FIRST, because it is where the real sentence is. Claude
194
+ // Code reports a dead `--resume` id as
195
+ // `{subtype:'error_during_execution', errors:['No conversation found
196
+ // with session ID: …']}` — reading only `error`/`subtype` dropped that
197
+ // and appended the literal string `error_during_execution`, which told
198
+ // the driver nothing and hid the one phrase the caller needs to
199
+ // recognise a lost conversation.
200
+ const listed = Array.isArray(ev.errors)
201
+ ? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
202
+ : '';
203
+ const msg = listed || ev.error?.message || ev.error || ev.subtype;
204
+ appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
205
+ }
206
+ }
207
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The CLI-limit trigger — whether a CLI's output says the account hit a
3
+ * usage limit, relayed in the CLI's own words. Split out of
4
+ * workAgentReview.mjs (SOLID F039) because two lanes read it (the agent turn,
5
+ * which parks on it, and the pre-review, which only skips) and neither owns
6
+ * it: it is a fact about what the CLIs print.
7
+ */
8
+ import { scrub as envScrub } from './uplinkScrub.mjs';
9
+
10
+ /**
11
+ * DID THE ACCOUNT HIT A LIMIT?
12
+ *
13
+ * Deliberately a LITERAL MATCH on the few sentences the CLIs actually print,
14
+ * and the matched line is relayed VERBATIM. It is a trigger, not a
15
+ * classifier: nothing here decides what an error "means" or writes a sentence
16
+ * of its own, because the product's own rule is that it relays and never
17
+ * infers. The honest limit is that a phrasing nobody listed reads as an
18
+ * ordinary failed turn — which lands the agent in Stuck with the CLI's words
19
+ * attached, and is a perfectly survivable second-best.
20
+ */
21
+ const LIMIT_PHRASES = [
22
+ /usage limit reached/i,
23
+ /rate limit/i,
24
+ /you've reached your .* limit/i,
25
+ /quota exceeded/i,
26
+ /insufficient_quota/i,
27
+ ];
28
+ export const limitLine = (text) => {
29
+ for (const line of String(text ?? '').split('\n')) {
30
+ const t = line.trim();
31
+ if (t && LIMIT_PHRASES.some((re) => re.test(t))) return envScrub(t).slice(0, 300);
32
+ }
33
+ return null;
34
+ };