flowviant 0.102.2 → 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 (141) 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 +184 -2396
  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 +34 -3
  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/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -7,24 +7,22 @@
7
7
  * terminal session as a tab" — activity, never capacity, and only ever facts
8
8
  * the user could see by looking at their own machine.
9
9
  *
10
+ * This module is the COORDINATOR (split 2026-09-26, SOLID F062): the fence
11
+ * (repo containment, the daemon's own worktrees and conversation ids), the
12
+ * ended-session window and the report cap. The readers are per CLI —
13
+ * claudeSessions.mjs and agySessions.mjs — because Claude's transcript format
14
+ * and agy's SQLite/cwd cache change for different reasons.
15
+ *
10
16
  * The one contract that matters to callers: NOTHING in this file throws. A
11
17
  * presence scan runs inside the poll loop's best-effort tail, and a torn
12
18
  * registry file or a vanished cwd is a session to skip, not an error to raise.
13
19
  */
14
20
 
15
- import {
16
- existsSync,
17
- readdirSync,
18
- readFileSync,
19
- realpathSync,
20
- statSync,
21
- openSync,
22
- readSync,
23
- closeSync,
24
- } from 'node:fs';
25
- import { homedir } from 'node:os';
21
+ import { readdirSync, readFileSync, realpathSync } from 'node:fs';
26
22
  import { join } from 'node:path';
27
23
  import { execFileSync } from 'node:child_process';
24
+ import { scanClaudeSessions } from './claudeSessions.mjs';
25
+ import { scanAgyConversations } from './agySessions.mjs';
28
26
 
29
27
  const REPORT_CAP = 30;
30
28
  // ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
@@ -33,232 +31,14 @@ const REPORT_CAP = 30;
33
31
  // newest per directory, few. (The first ship reported 7 days of everything
34
32
  // and the strip read as session history instead of presence.)
35
33
  const ENDED_WINDOW_MS = 48 * 60 * 60 * 1000;
36
- const ENDED_CAP = 5;
37
-
38
- /**
39
- * The conversation's own title, off the transcript's `ai-title` records
40
- * (the LAST one wins — titles get rewritten as a session evolves), and ONLY
41
- * those records: this wire is presence METADATA, and the first user message
42
- * — the fallback this used to relay — is transcript CONTENT wearing a
43
- * title's clothes. An untitled row is honest presence; a quoted message is
44
- * a leak. Those records sit anywhere in the file (measured: line 81 to line
45
- * 4457), so this reads the WHOLE transcript — behind an mtime cache, because
46
- * the scan runs every minute and a title only changes when the file does:
47
- * steady state is a stat, not a read.
48
- */
49
- const titleCache = new Map(); // file → { mtimeMs, title }
50
- function transcriptTitle(file, mtimeMs) {
51
- const hit = titleCache.get(file);
52
- if (hit && hit.mtimeMs === mtimeMs) return hit.title;
53
- let title = null;
54
- try {
55
- const stat = statSync(file);
56
- // A transcript past this is not worth a read per minute of drift.
57
- if (stat.size <= 64 * 1024 * 1024) {
58
- for (const line of readFileSync(file, 'utf8').split('\n')) {
59
- if (!line.includes('"type":"ai-title"')) continue;
60
- try {
61
- const t = JSON.parse(line)?.aiTitle;
62
- if (typeof t === 'string' && t.trim()) title = t.trim(); // last wins
63
- } catch {
64
- /* torn line */
65
- }
66
- }
67
- if (title) title = title.replace(/\s+/g, ' ').slice(0, 120);
68
- }
69
- } catch {
70
- title = null;
71
- }
72
- if (titleCache.size > 400) titleCache.clear(); // a bound, not an LRU — refills in one scan
73
- titleCache.set(file, { mtimeMs, title });
74
- return title;
75
- }
76
-
77
- /**
78
- * THE TITLE CLAUDE GAVE ITS OWN CONVERSATION, for one session we already know
79
- * the id of.
80
- *
81
- * `scanLocalSessions` finds transcripts the hard way — walk every project
82
- * directory, read each file's own `cwd` — because it is answering "what is in
83
- * this repo" and knows no ids. This asks the same store a narrower question:
84
- * the tab pinned the id the CLI reported at `system.init`, and the directory is
85
- * the place the turn was spawned in, so the file is one munge away (Claude Code
86
- * names the directory after the cwd with `/` and `.` replaced by `-`).
87
- *
88
- * The realpath fallback is not defensive padding: a place under a symlinked
89
- * home munges to a DIFFERENT directory name depending on which form the CLI was
90
- * handed, and the daemon does not control which that was.
91
- *
92
- * Returns null for anything it cannot read. A missing title is honest silence —
93
- * the tab keeps whatever name it has.
94
- */
95
- export function titleForSession(cwd, sessionId) {
96
- if (!cwd || typeof sessionId !== 'string' || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) {
97
- return null;
98
- }
99
- const projects = join(homedir(), '.claude', 'projects');
100
- const dirs = [String(cwd)];
101
- try {
102
- const real = realpathSync(String(cwd));
103
- if (real !== String(cwd)) dirs.push(real);
104
- } catch {
105
- /* the place is gone — nothing to read */
106
- }
107
- for (const dir of dirs) {
108
- const file = join(projects, claudeProjectDir(dir), `${sessionId}.jsonl`);
109
- try {
110
- return transcriptTitle(file, statSync(file).mtimeMs);
111
- } catch {
112
- /* not this munge — try the other */
113
- }
114
- }
115
- return null;
116
- }
117
-
118
- /**
119
- * THE DIRECTORY CLAUDE CODE FILES A CWD'S TRANSCRIPTS UNDER — its own rule,
120
- * copied exactly (2.1.281): every character that is not an ASCII letter or
121
- * digit becomes `-`, and a name past 200 characters is cut there and suffixed
122
- * with a base-36 Java-style string hash of the ORIGINAL path.
123
- *
124
- * This file used `replace(/[/.]/g, '-')`, which agrees with Claude Code only
125
- * while a path holds nothing but letters, digits, `/` and `.` — so a repo at
126
- * `~/code/my_app` or `~/My Projects/x` never offered an ended terminal session
127
- * for adoption and never got a tab auto-title, silently (audit 2026-09-24).
128
- */
129
- const CLAUDE_DIR_MAX = 200;
130
- export function claudeProjectDir(path) {
131
- const p = String(path);
132
- const s = p.replace(/[^a-zA-Z0-9]/g, '-');
133
- if (s.length <= CLAUDE_DIR_MAX) return s;
134
- let h = 0;
135
- for (let i = 0; i < p.length; i++) h = ((h << 5) - h + p.charCodeAt(i)) | 0;
136
- return `${s.slice(0, CLAUDE_DIR_MAX)}-${Math.abs(h).toString(36)}`;
137
- }
138
34
 
139
35
  /** Path-prefix containment on already-realpath'd absolute paths. */
140
36
  const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
141
37
 
142
- /**
143
- * Is this pid the SAME process the registry entry recorded?
144
- *
145
- * Registry entries go stale — Claude exits, the pid is recycled by something
146
- * else, the file stays. `/proc/<pid>` existing only proves A process; the
147
- * starttime (field 22 of /proc/<pid>/stat) proves it is THAT process. The comm
148
- * field (parenthesised, may itself contain spaces and parens) makes naive
149
- * whitespace-splitting wrong, so fields are counted from after the LAST ')':
150
- * the first post-comm field is field 3, which puts starttime at index 19.
151
- */
152
- function pidAlive(pid, procStart) {
153
- if (!Number.isInteger(pid) || pid <= 0) return false;
154
- let stat;
155
- try {
156
- stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
157
- } catch {
158
- // No /proc ENTRY means the process is gone — but only where /proc exists
159
- // at all. On macOS there is no /proc, so this read fails for EVERY pid,
160
- // every session scans as dead, and live sessions become adoptable — the
161
- // exact two-drivers-on-one-conversation outcome liveness exists to
162
- // prevent. Probe by name instead: `ps -o comm=` on the pid, alive only if
163
- // the surviving process still LOOKS like a coding-CLI process. A bare
164
- // signal-0 probe was tried first and reads a RECYCLED pid as alive
165
- // forever — a crashed session whose pid a launchd service inherited would
166
- // report live for weeks, refuse adoption, and suppress its own ended row.
167
- // The name check keeps the conservative direction (a transient race still
168
- // errs live) without the permanent false-live.
169
- if (!existsSync('/proc')) {
170
- try {
171
- const comm = execFileSync('ps', ['-o', 'comm=', '-p', String(pid)], {
172
- encoding: 'utf8',
173
- stdio: ['ignore', 'pipe', 'ignore'],
174
- timeout: 5_000,
175
- })
176
- .trim()
177
- .toLowerCase();
178
- return /\b(claude|node|bun|codex|agy)\b|\/(claude|node|bun|codex|agy)$/.test(comm);
179
- } catch {
180
- return false; // ps errored or the pid is gone — the process is dead
181
- }
182
- }
183
- return false; // /proc is real and has no entry — the process is gone
184
- }
185
- if (procStart == null) return true; // nothing recorded to compare against
186
- const close = stat.lastIndexOf(')');
187
- if (close === -1) return false;
188
- const fields = stat.slice(close + 1).trim().split(/\s+/);
189
- return fields[19] === String(procStart);
190
- }
191
-
192
- /**
193
- * Is a terminal Claude session with this id open on the machine RIGHT NOW?
194
- *
195
- * The adoption path asks this at the moment of adopting: forking a session
196
- * while its terminal is still typing into it would put two Claudes on one
197
- * conversation, which is the exact incoherence the Workbench's own locks
198
- * exist to prevent.
199
- */
200
- export function isTerminalSessionLive(sessionId) {
201
- try {
202
- const dir = join(homedir(), '.claude', 'sessions');
203
- for (const name of readdirSync(dir)) {
204
- if (!name.endsWith('.json')) continue;
205
- let rec;
206
- try {
207
- rec = JSON.parse(readFileSync(join(dir, name), 'utf8'));
208
- } catch {
209
- continue; // torn write / not JSON — not evidence of anything
210
- }
211
- if (rec?.sessionId !== sessionId) continue;
212
- if (pidAlive(rec.pid, rec.procStart)) return true;
213
- }
214
- } catch {
215
- /* registry unreadable — no proof of life is "not live" */
216
- }
217
- return false;
218
- }
219
-
220
- /**
221
- * First transcript record that carries a cwd, from the file's head only.
222
- *
223
- * A transcript can be megabytes; the cwd/gitBranch identity rides on every
224
- * record, so ~16KB from the front is enough to verify WHOSE session this is
225
- * without paying to read the conversation. A file whose first cwd-bearing
226
- * line does not parse (truncated at the window edge) is skipped, not retried
227
- * deeper — this is presence, not forensics.
228
- */
229
- function firstCwdRecord(file) {
230
- let fd;
231
- try {
232
- fd = openSync(file, 'r');
233
- const buf = Buffer.alloc(16384);
234
- const n = readSync(fd, buf, 0, buf.length, 0);
235
- for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
236
- if (!line.includes('"cwd":"')) continue;
237
- try {
238
- const rec = JSON.parse(line);
239
- if (rec && typeof rec.cwd === 'string' && rec.cwd) return rec;
240
- } catch {
241
- /* an incomplete line at the window edge — try the next candidate */
242
- }
243
- }
244
- return null;
245
- } catch {
246
- return null;
247
- } finally {
248
- if (fd !== undefined) {
249
- try {
250
- closeSync(fd);
251
- } catch {
252
- /* best-effort */
253
- }
254
- }
255
- }
256
- }
257
-
258
38
  /**
259
39
  * THE MARKER NAMES THE DAEMON PINS A TAB'S CONVERSATION UNDER.
260
40
  *
261
- * One owner, because two things read them and a drift here is silent: `work.mjs`
41
+ * One owner, because two things read them and a drift here is silent: `workSessionTurns.mjs`
262
42
  * WRITES them (`sessionMetaPath(wt, <name>, sessionId)`) and this file READS
263
43
  * them to know which conversations are its own.
264
44
  */
@@ -322,28 +102,29 @@ export function ourConversationIds(repoRoot, gitDirs = []) {
322
102
  }
323
103
 
324
104
  /**
325
- * Every Claude terminal session belonging to this repo: LIVE ones from the
326
- * liveness registry, ENDED ones from the transcript store. Returns
327
- * [{ id, cwd, live, lastActiveAt, branch? }], live first, then newest ended,
328
- * capped at 30, deterministically ordered (so a stringified report only
329
- * changes when the facts do).
105
+ * Every terminal session belonging to this repo — Claude's first (live, then
106
+ * newest ended), then agy's in whatever room the cap leaves — capped at 30.
330
107
  *
331
108
  * `excludeDirs` carves out the daemon's own worktrees: sessions the daemon
332
109
  * itself spawned are tabs already, and offering to adopt one would be the
333
110
  * product offering the user their own reflection.
334
111
  */
335
112
  export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
336
- /** Conversations this daemon started — never offered for adoption. See
337
- * `ourConversationIds`: the operator's tabs share the checkout with real
338
- * terminal sessions, so the fence has to be by id, not by directory. */
339
- const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
340
- const live = [];
341
- const ended = [];
113
+ // The fence setup is inside the catch-all too: presence must never throw
114
+ // into the poll loop, whatever a caller hands in.
342
115
  try {
116
+ /** Conversations this daemon started — never offered for adoption. See
117
+ * `ourConversationIds`: the operator's tabs share the checkout with real
118
+ * terminal sessions, so the fence has to be by id, not by directory. */
119
+ const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
120
+ // Claude falls back to the literal root when it cannot be resolved; agy,
121
+ // whose cwd registry is global, reports nothing for an unresolvable root.
343
122
  let realRoot;
123
+ let resolved = true;
344
124
  try {
345
125
  realRoot = realpathSync(repoRoot);
346
126
  } catch {
127
+ resolved = false;
347
128
  realRoot = String(repoRoot ?? '');
348
129
  }
349
130
  if (!realRoot) return [];
@@ -357,321 +138,15 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
357
138
  }
358
139
  }
359
140
  const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
360
-
361
- // ── LIVE: the registry, validated pid by pid ─────────────────────────
362
- const nowIso = new Date().toISOString();
363
- const liveIds = new Set();
364
- let regNames = [];
365
- try {
366
- regNames = readdirSync(join(homedir(), '.claude', 'sessions'));
367
- } catch {
368
- /* no registry — no live sessions */
369
- }
370
- for (const name of regNames) {
371
- if (!name.endsWith('.json')) continue; // .key files ride alongside
372
- let rec;
373
- try {
374
- rec = JSON.parse(readFileSync(join(homedir(), '.claude', 'sessions', name), 'utf8'));
375
- } catch {
376
- continue;
377
- }
378
- if (!rec || typeof rec.sessionId !== 'string' || typeof rec.cwd !== 'string') continue;
379
- if (liveIds.has(rec.sessionId)) continue;
380
- if (!pidAlive(rec.pid, rec.procStart)) continue;
381
- let cwd;
382
- try {
383
- cwd = realpathSync(rec.cwd);
384
- } catch {
385
- continue; // the directory is gone — nothing to point a tab at
386
- }
387
- if (!ours(cwd)) continue;
388
- liveIds.add(rec.sessionId);
389
- // A live session's title, off its own transcript (the registry `name`
390
- // is a machine-y fallback like "flowviant-35").
391
- let liveTitle = null;
392
- try {
393
- const liveFile = join(
394
- homedir(),
395
- '.claude',
396
- 'projects',
397
- claudeProjectDir(cwd),
398
- `${rec.sessionId}.jsonl`
399
- );
400
- liveTitle = transcriptTitle(liveFile, statSync(liveFile).mtimeMs);
401
- } catch {
402
- /* no transcript yet */
403
- }
404
- if (!liveTitle && typeof rec.name === 'string' && rec.name.trim()) liveTitle = rec.name.trim();
405
- if (mine.has(rec.sessionId)) continue; // our own tab, not a terminal session
406
- live.push({
407
- id: rec.sessionId,
408
- cwd,
409
- live: true,
410
- lastActiveAt: nowIso,
411
- ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
412
- ...(liveTitle ? { title: liveTitle } : {}),
413
- });
414
- }
415
- live.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
416
-
417
- // ── ENDED: the transcript store, verified file by file ───────────────
418
- //
419
- // The munged directory name is a PREFIX match on purpose: a session run in
420
- // a SUBDIRECTORY of the repo munges to a longer name sharing the root's.
421
- // But so does a sibling repo ('flowviant-two' shares 'flowviant' + '-'),
422
- // which is why every candidate is verified against the cwd its own records
423
- // embed rather than trusted on its directory name.
424
- const munged = claudeProjectDir(realRoot);
425
- // A root past Claude's 200-character cut carries a hash of ITSELF, which a
426
- // subdirectory's name does not share — so the prefix a subdirectory can
427
- // match is the cut, and the cwd verification below does the rest.
428
- const mungedStem = munged.length > CLAUDE_DIR_MAX ? munged.slice(0, CLAUDE_DIR_MAX) : `${munged}-`;
429
- const projectsDir = join(homedir(), '.claude', 'projects');
430
- let projDirs = [];
431
- try {
432
- projDirs = readdirSync(projectsDir);
433
- } catch {
434
- /* no transcript store — live sessions still report */
435
- }
436
141
  const cutoff = Date.now() - ENDED_WINDOW_MS;
437
- const candidates = [];
438
- for (const dirName of projDirs) {
439
- if (dirName !== munged && !dirName.startsWith(mungedStem)) continue;
440
- let entries = [];
441
- try {
442
- entries = readdirSync(join(projectsDir, dirName), { withFileTypes: true });
443
- } catch {
444
- continue;
445
- }
446
- for (const ent of entries) {
447
- if (!ent.isFile() || !ent.name.endsWith('.jsonl')) continue; // top-level only
448
- const id = ent.name.slice(0, -'.jsonl'.length);
449
- if (!id || liveIds.has(id)) continue; // a live session outranks its own transcript
450
- const file = join(projectsDir, dirName, ent.name);
451
- let mtimeMs;
452
- try {
453
- mtimeMs = statSync(file).mtimeMs;
454
- } catch {
455
- continue;
456
- }
457
- if (mtimeMs < cutoff) continue; // an aged session is history, not presence
458
- candidates.push({ id, file, mtimeMs });
459
- }
460
- }
461
- // Newest first, then verify only as many as the cap still has room for —
462
- // the verification read is the expensive step, so it is not spent on
463
- // sessions the report would drop anyway.
464
- candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.id < b.id ? -1 : 1));
465
- // ONE row per DIRECTORY, newest first, few: twenty sessions in the repo
466
- // root are one offer — the newest is the one `--resume`'s picker would
467
- // reach for and the only one worth importing 95% of the time. The rest
468
- // are scrollback, and the product's own law says scrollback doesn't
469
- // matter.
470
- const room = Math.min(ENDED_CAP, Math.max(0, REPORT_CAP - Math.min(live.length, REPORT_CAP)));
471
- const endedIds = new Set();
472
- const seenCwds = new Set(live.map((s) => s.cwd));
473
- for (const cand of candidates) {
474
- if (ended.length >= room) break;
475
- if (endedIds.has(cand.id)) continue; // one row per session, whatever dir names it
476
- endedIds.add(cand.id);
477
- const rec = firstCwdRecord(cand.file);
478
- if (!rec) continue;
479
- let cwd;
480
- try {
481
- cwd = realpathSync(rec.cwd);
482
- } catch {
483
- continue;
484
- }
485
- if (!ours(cwd)) continue;
486
- /**
487
- * OUR OWN TAB IS NOT A CANDIDATE, and it is skipped BEFORE `seenCwds`
488
- * claims the directory — otherwise the daemon's transcript (always the
489
- * freshest thing in the checkout) took the one slot that directory gets
490
- * and a genuine terminal session there could never be offered at all.
491
- */
492
- if (mine.has(cand.id)) continue;
493
- if (seenCwds.has(cwd)) continue; // newest per directory; a live one owns its cwd
494
- seenCwds.add(cwd);
495
- const title = transcriptTitle(cand.file, cand.mtimeMs);
496
- ended.push({
497
- id: cand.id,
498
- cwd,
499
- live: false,
500
- lastActiveAt: new Date(cand.mtimeMs).toISOString(),
501
- ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
502
- ...(title ? { title } : {}),
503
- });
504
- }
505
- } catch {
506
- /* presence must never throw into the poll loop — report what was gathered */
507
- }
508
- const claude = [...live.slice(0, REPORT_CAP), ...ended];
509
- // agy rides in whatever room the cap leaves — Claude sessions first, they
510
- // are the ones adoption serves best (fork, never move).
511
- const agy = scanAgyConversations({ repoRoot, excludeDirs, excludeIds: mine }).slice(
512
- 0,
513
- Math.max(0, REPORT_CAP - claude.length)
514
- );
515
- return [...claude, ...agy];
516
- }
517
-
518
- // ── Antigravity (agy) ──────────────────────────────────────────────────────
519
- //
520
- // agy's store is nothing like Claude's: one SQLite db per conversation at
521
- // ~/.gemini/antigravity-cli/conversations/<uuid>.db (global, not cwd-keyed),
522
- // no per-pid liveness registry that survives contact (the presence/*.lock
523
- // files sit untouched by real runs — measured), and the only cwd mapping is
524
- // cache/last_conversations.json: {cwd → the LAST conversation run there}.
525
- // So the honest agy report is a SUBSET — the last conversation per directory
526
- // inside this repo — and that is exactly the one `agy --continue` would give
527
- // the person at that keyboard, i.e. the one worth offering to adopt.
528
-
529
- const AGY_DIR = () => join(homedir(), '.gemini', 'antigravity-cli');
530
- const AGY_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
531
-
532
- /** Newest write to the conversation's store — the wal carries recent turns,
533
- * so its mtime (not the db's) is the real "last active" (measured: a resume
534
- * touched db+wal, and never the presence lock). 0 = no such conversation. */
535
- function agyLastWriteMs(id) {
536
- if (!AGY_UUID_RE.test(id)) return 0;
537
- let newest = 0;
538
- for (const suffix of ['.db', '.db-wal']) {
539
- try {
540
- const t = statSync(join(AGY_DIR(), 'conversations', `${id}${suffix}`)).mtimeMs;
541
- if (t > newest) newest = t;
542
- } catch {
543
- /* absent half is fine — the db alone still answers */
544
- }
545
- }
546
- return newest;
547
- }
548
-
549
- /** Any agy process on the machine right now? /proc comm scan — cheap at the
550
- * 60s cadence, and the only liveness signal agy leaves (locks are inert).
551
- * Tri-state on purpose: true (an agy process exists), false (scanned /proc
552
- * and found none), null (no /proc to scan — macOS — so the question is
553
- * UNANSWERABLE here, which is a different fact from "no", and the adoption
554
- * path below treats it differently: unknowable refuses, absent permits). */
555
- function agyProcessAlive() {
556
- if (!existsSync('/proc')) {
557
- // No /proc (macOS): ask pgrep the same question. A MEASURED "none" here
558
- // matters — answering null instead made every agy conversation on such a
559
- // machine read live forever, which both invented a state ("live" when the
560
- // truth was "unmeasured") and permanently killed agy adoption there.
561
- try {
562
- execFileSync('pgrep', ['-x', 'agy'], {
563
- stdio: ['ignore', 'ignore', 'ignore'],
564
- timeout: 5_000,
565
- });
566
- return true; // exit 0 — at least one agy process
567
- } catch (e) {
568
- if (e?.status === 1) return false; // pgrep ran and found none
569
- return null; // pgrep itself unavailable/errored — genuinely unknowable
570
- }
571
- }
572
- try {
573
- for (const name of readdirSync('/proc')) {
574
- if (!/^\d+$/.test(name)) continue;
575
- try {
576
- if (readFileSync(`/proc/${name}/comm`, 'utf8').trim() === 'agy') return true;
577
- } catch {
578
- /* raced exit — keep scanning */
579
- }
580
- }
581
- } catch {
582
- return null; // /proc exists but won't read — still unknowable
583
- }
584
- return false;
585
- }
586
-
587
- /**
588
- * Is this agy conversation being driven RIGHT NOW? agy cannot answer
589
- * per-conversation, so this is the conservative composite: an agy process
590
- * exists AND this conversation's store was written in the last 10 minutes.
591
- * Adoption is a MOVE for agy (no fork exists — measured, "trajectory not
592
- * found" on a renamed copy), so refusing a maybe-live conversation for a few
593
- * minutes costs a retry; adopting an actually-live one puts two drivers on
594
- * one store.
595
- */
596
- const AGY_LIVE_WINDOW_MS = 10 * 60 * 1000;
597
- export function isAgyConversationLive(id) {
598
- try {
599
- const up = agyProcessAlive();
600
- // Unknowable is NOT "ended". Without /proc the process half of the
601
- // composite cannot be measured, and calling that "no process" would make
602
- // every agy conversation on such a machine adoptable — the move-adoption
603
- // then puts a second driver on a store someone may still be writing.
604
- // "Live" here means "refuse adoption", and refusing what we cannot verify
605
- // is the same conservatism as the composite itself.
606
- if (up === null) return true;
607
- if (!up) return false;
608
- const t = agyLastWriteMs(id);
609
- return t > 0 && Date.now() - t < AGY_LIVE_WINDOW_MS;
610
- } catch {
611
- return false;
612
- }
613
- }
614
-
615
- /** The repo's agy conversations, via the cwd registry — see the section
616
- * comment for why this is deliberately a subset. */
617
- function scanAgyConversations({ repoRoot, excludeDirs = [], excludeIds }) {
618
- const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
619
- const out = [];
620
- try {
621
- let realRoot;
622
- try {
623
- realRoot = realpathSync(repoRoot);
624
- } catch {
625
- return out;
626
- }
627
- const excludes = [];
628
- for (const d of excludeDirs) {
629
- if (!d) continue;
630
- try {
631
- excludes.push(realpathSync(d));
632
- } catch {
633
- excludes.push(String(d));
634
- }
635
- }
636
- const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
637
- const raw = readFileSync(join(AGY_DIR(), 'cache', 'last_conversations.json'), 'utf8');
638
- const map = JSON.parse(raw);
639
- if (!map || typeof map !== 'object') return out;
640
- const cutoff = Date.now() - ENDED_WINDOW_MS;
641
- const processUp = agyProcessAlive();
642
- for (const [cwd, id] of Object.entries(map)) {
643
- if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
644
- // The operator's own agy TAB, whose conversation is registered against
645
- // the checkout exactly as a terminal one would be.
646
- if (mine.has(id)) continue;
647
- let real;
648
- try {
649
- real = realpathSync(cwd);
650
- } catch {
651
- continue; // the directory is gone — nothing to point a tab at
652
- }
653
- if (!ours(real)) continue;
654
- const lastMs = agyLastWriteMs(id);
655
- if (!lastMs || lastMs < cutoff) continue;
656
- out.push({
657
- id,
658
- cwd: real,
659
- // Mirrors isAgyConversationLive exactly, unknowable (null) included:
660
- // the report must never offer as adoptable a conversation the adopt
661
- // path will then refuse — an offer wired to a refusal is the dead
662
- // control this product keeps deleting.
663
- live: processUp === null ? true : processUp && Date.now() - lastMs < AGY_LIVE_WINDOW_MS,
664
- lastActiveAt: new Date(lastMs).toISOString(),
665
- runtime: 'antigravity',
666
- });
667
- }
668
- out.sort(
669
- (a, b) =>
670
- (b.lastActiveAt < a.lastActiveAt ? -1 : b.lastActiveAt > a.lastActiveAt ? 1 : 0) ||
671
- (a.id < b.id ? -1 : 1)
672
- );
142
+ const claude = scanClaudeSessions({ realRoot, ours, mine, cutoff, cap: REPORT_CAP });
143
+ // agy rides in whatever room the cap leaves — Claude sessions first, they
144
+ // are the ones adoption serves best (fork, never move).
145
+ const agy = resolved
146
+ ? scanAgyConversations({ ours, mine, cutoff }).slice(0, Math.max(0, REPORT_CAP - claude.length))
147
+ : [];
148
+ return [...claude, ...agy];
673
149
  } catch {
674
- /* no agy on this machine, or an unreadable registry — nothing to report */
150
+ return [];
675
151
  }
676
- return out;
677
152
  }
package/bin/lib/login.mjs CHANGED
@@ -11,9 +11,10 @@ import { saveLogin, detectRepoRoot, projectLabel, safeName, listStoredProjects,
11
11
  import { c, info, ok, warn, fail } from './ui.mjs';
12
12
  import { sleep } from './claude.mjs';
13
13
  import { launchCommand, terminalCommand } from './launchCommand.mjs';
14
+ import { fleetEndpoint } from './fleetWire.mjs';
14
15
 
15
- const DEVICE_START = FLEET_URL.replace(/\/agents\/?$/, '/device/start');
16
- const DEVICE_POLL = FLEET_URL.replace(/\/agents\/?$/, '/device/poll');
16
+ const DEVICE_START = fleetEndpoint('device/start', FLEET_URL);
17
+ const DEVICE_POLL = fleetEndpoint('device/poll', FLEET_URL);
17
18
  const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
18
19
 
19
20
  async function post(url, body) {
@@ -21,6 +21,7 @@ import { execFileSync, spawn } from 'node:child_process';
21
21
  import { existsSync, readdirSync } from 'node:fs';
22
22
  import { homedir, platform } from 'node:os';
23
23
  import { join } from 'node:path';
24
+ import { compareVersions } from './versionOrder.mjs';
24
25
 
25
26
  const MARK = '__FLOWVIANT_PATH__=';
26
27
 
@@ -52,11 +53,8 @@ export function userBinDirs({ home = homedir(), exists = existsSync, list = read
52
53
  try {
53
54
  const versions = list(join(home, '.nvm/versions/node'))
54
55
  .filter((v) => /^v\d+\.\d+\.\d+$/.test(v))
55
- .sort((a, b) => {
56
- const pa = a.slice(1).split('.').map(Number);
57
- const pb = b.slice(1).split('.').map(Number);
58
- return pb[0] - pa[0] || pb[1] - pa[1] || pb[2] - pa[2];
59
- });
56
+ // Newest first. Every name passed the filter, so none is unknown.
57
+ .sort((a, b) => compareVersions(b, a));
60
58
  if (versions[0]) dirs.push(join(home, '.nvm/versions/node', versions[0], 'bin'));
61
59
  } catch { /* no nvm */ }
62
60
  return dirs.filter((d) => exists(d));