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.
- package/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- 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: `
|
|
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
|
|
326
|
-
*
|
|
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
|
-
|
|
337
|
-
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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 =
|
|
16
|
-
const 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) {
|
package/bin/lib/loginPath.mjs
CHANGED
|
@@ -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
|
-
.
|
|
56
|
-
|
|
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));
|