flowviant 0.97.0 → 0.100.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/README.md +15 -13
- package/bin/cli.mjs +108 -28
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +36 -9
- package/bin/lib/credentials.mjs +29 -5
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +121 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +129 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +36 -12
- package/bin/lib/machines.mjs +24 -14
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +114 -25
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1151 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -3
package/bin/lib/landed.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { join } from 'node:path';
|
|
|
29
29
|
import { homedir } from 'node:os';
|
|
30
30
|
import { createHash } from 'node:crypto';
|
|
31
31
|
import { git, baseBranchName } from './git.mjs';
|
|
32
|
-
import { stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
32
|
+
import { commitRecord, stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
33
33
|
import { warn } from './ui.mjs';
|
|
34
34
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
35
35
|
|
|
@@ -95,41 +95,39 @@ export function createLandedObserver({ repoRoot, baseRef }) {
|
|
|
95
95
|
* merge commit describes a range rather than doing work, and its
|
|
96
96
|
* constituents are walked as themselves.
|
|
97
97
|
*
|
|
98
|
-
* THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
98
|
+
* THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG, and each
|
|
99
|
+
* commit is then read BY ITS OWN SHA (`commitRecord`, one `git show` per
|
|
100
|
+
* commit) rather than as one delimited multi-record log. A batched log
|
|
101
|
+
* used to print every commit's `%H%x1f%s%x1f%B%x1e` back to back and split
|
|
102
|
+
* the blob on those same delimiter bytes — bytes git preserves verbatim
|
|
103
|
+
* inside a commit BODY (verified empirically), so a crafted message could
|
|
104
|
+
* embed its own fake `%x1e`/`%x1f%1f` run and fabricate an extra "record"
|
|
105
|
+
* naming an arbitrary sha and Flowviant-Task ids that /fleet/base-landed
|
|
106
|
+
* would close cards on. Reading one sha at a time removes the multi-record
|
|
107
|
+
* parse entirely — there is no blob to split, so there is nothing for an
|
|
108
|
+
* embedded delimiter to forge a SECOND record out of. `stripDelims` still
|
|
109
|
+
* scrubs whatever lands in the subject/body of the commit it actually
|
|
110
|
+
* belongs to, the same defence-in-depth `branchCommits` above keeps.
|
|
107
111
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
112
|
+
* ONE UNREADABLE COMMIT IS SKIPPED, NOT AN ABORT OF THE WHOLE BATCH:
|
|
113
|
+
* `commitRecord` runs through `gitRaw`, whose `maxBuffer` is git.mjs's
|
|
114
|
+
* plain default (1MiB) rather than this module's widened `gitWide` — a
|
|
115
|
+
* single commit whose message alone exceeds that returns null and is
|
|
116
|
+
* dropped, while every other commit in the batch still reports. */
|
|
113
117
|
const walk = (from, to) => {
|
|
114
118
|
const shas = gitWide(['rev-list', '--reverse', '--no-merges', `${from}..${to}`])
|
|
115
119
|
.split('\n')
|
|
116
120
|
.filter((s) => SHA_RE.test(s));
|
|
117
121
|
const batch = shas.slice(0, MAX_COMMITS);
|
|
118
122
|
const tipAfter = shas.length > MAX_COMMITS ? batch[batch.length - 1] : null;
|
|
119
|
-
if (batch.length === 0) return { commits: [], tipAfter };
|
|
120
|
-
const real = new Set(batch);
|
|
121
|
-
const raw = gitWide(['log', '--no-walk=unsorted', '--format=%H%x1f%s%x1f%B%x1e', ...batch]);
|
|
122
123
|
const out = [];
|
|
123
|
-
for (const
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
const [sha, subject, ...bodyParts] = line.split('\x1f');
|
|
127
|
-
if (!real.has(sha)) continue;
|
|
128
|
-
real.delete(sha);
|
|
124
|
+
for (const sha of batch) {
|
|
125
|
+
const rec = commitRecord(sha, repoRoot);
|
|
126
|
+
if (!rec) continue;
|
|
129
127
|
out.push({
|
|
130
128
|
sha,
|
|
131
|
-
subject: stripDelims(subject).slice(0, 200),
|
|
132
|
-
taskIds: taskIdsFromMessage(stripDelims(
|
|
129
|
+
subject: stripDelims(rec.subject).slice(0, 200),
|
|
130
|
+
taskIds: taskIdsFromMessage(stripDelims(rec.body)).slice(0, 8),
|
|
133
131
|
});
|
|
134
132
|
}
|
|
135
133
|
return { commits: out, tipAfter };
|
|
@@ -202,13 +200,23 @@ export function createLandedObserver({ repoRoot, baseRef }) {
|
|
|
202
200
|
for (const r of j?.data?.deployRefused ?? []) {
|
|
203
201
|
warn(`deploy-on-merge refused for target "${r?.targetId}": ${r?.reason}`);
|
|
204
202
|
}
|
|
203
|
+
} else if (res.status === 408 || res.status === 429) {
|
|
204
|
+
// A TIMEOUT OR A RATE LIMIT IS "NOT NOW", NOT "NEVER". Both used to
|
|
205
|
+
// land in the drop below, so one 429 — a busy operator IP past the
|
|
206
|
+
// limiter — skipped the range for good: its trailers and receipt shas
|
|
207
|
+
// never closed their cards (in PR mode the observer is the ONLY road
|
|
208
|
+
// to Done) and deploy-on-merge for that tip was never queued. Keep the
|
|
209
|
+
// stored tip, exactly as a 5xx does; the next beat re-walks it.
|
|
205
210
|
} else if (res.status >= 400 && res.status < 500) {
|
|
206
211
|
// A persistent 4xx (deploy skew, a payload this server refuses) would
|
|
207
212
|
// otherwise re-send the same poison range on every beat forever.
|
|
208
|
-
// Drop
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
213
|
+
// Drop THIS BATCH — to `reportedTip`, the last commit it carried, never
|
|
214
|
+
// the full tip: a range past the cap has a remainder this batch never
|
|
215
|
+
// sent, and advancing to the tip discarded that too. The closes it
|
|
216
|
+
// carried re-run at the next REAL tip move only if their cards are
|
|
217
|
+
// still open, which is the idempotent half; the honest cost is stated
|
|
218
|
+
// out loud.
|
|
219
|
+
writeState({ ref, tip: reportedTip });
|
|
212
220
|
warn(`base-landed report refused (${res.status}) — skipped ${commits.length} commit(s)`);
|
|
213
221
|
}
|
|
214
222
|
} catch {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
|
|
3
|
+
/** The updater and terminal advice share one detection of the launch channel. */
|
|
4
|
+
export function runningViaNpx() {
|
|
5
|
+
const ua = process.env.npm_config_user_agent || '';
|
|
6
|
+
const argv1 = process.argv[1] || '';
|
|
7
|
+
let self = '';
|
|
8
|
+
try {
|
|
9
|
+
self = fileURLToPath(import.meta.url);
|
|
10
|
+
} catch {
|
|
11
|
+
/* non-file URL — ignore */
|
|
12
|
+
}
|
|
13
|
+
return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Name the command that launched this process when giving a person a next step. */
|
|
17
|
+
export function launchCommand() {
|
|
18
|
+
return 'flowviant';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function terminalCommand(args = '') {
|
|
22
|
+
return `${launchCommand()}${args ? ` ${args}` : ''}`;
|
|
23
|
+
}
|
package/bin/lib/listeners.mjs
CHANGED
|
@@ -509,3 +509,214 @@ export function isListening(port, timeoutMs = 1500) {
|
|
|
509
509
|
sock.once('error', () => finish(false));
|
|
510
510
|
});
|
|
511
511
|
}
|
|
512
|
+
|
|
513
|
+
// ── the address a share dials ────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* WHICH ADDRESS REACHES THE ATTRIBUTED PROCESS on `port`, or why none does
|
|
517
|
+
* (audit 2026-09-24).
|
|
518
|
+
*
|
|
519
|
+
* Attribution above is keyed on the PORT, and the gate used to dial
|
|
520
|
+
* `127.0.0.1:<port>` whatever socket had been attributed. Linux and macOS let
|
|
521
|
+
* two processes hold one port on the two loopback families side by side, so a
|
|
522
|
+
* dev server in this worktree on `[::1]:5173` and an unrelated process on
|
|
523
|
+
* `127.0.0.1:5173` passed the attribution check while the tunnel published the
|
|
524
|
+
* UNRELATED one — the exact thing attribution exists to prevent. And the
|
|
525
|
+
* ordinary case broke the other way: Vite's default `localhost` bind resolves
|
|
526
|
+
* to `::1` on a Mac, so the share opened and every request answered 502.
|
|
527
|
+
*
|
|
528
|
+
* So the dial address is DERIVED FROM THE ATTRIBUTED SOCKET: a wildcard bind is
|
|
529
|
+
* reached on its own family's loopback, a specific bind on its own address —
|
|
530
|
+
* and a candidate is REFUSED when a process outside the worktree holds that
|
|
531
|
+
* same address (or the same wildcard, via SO_REUSEPORT) on that port, because
|
|
532
|
+
* the kernel may then hand the connection to either. IPv4 is preferred when
|
|
533
|
+
* both reach the attributed process, since that is what every dev server's
|
|
534
|
+
* own printed URL means.
|
|
535
|
+
*
|
|
536
|
+
* `{ host }` on success, `{ error }` with a sentence otherwise. A platform this
|
|
537
|
+
* file cannot measure answers the loopback it always dialled — the same
|
|
538
|
+
* behaviour as before, never a refusal invented from ignorance.
|
|
539
|
+
*/
|
|
540
|
+
export function originFor(worktree, port) {
|
|
541
|
+
if (!worktree || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
542
|
+
return { error: `nothing is listening on port ${port} in this worktree.` };
|
|
543
|
+
}
|
|
544
|
+
let sockets;
|
|
545
|
+
try {
|
|
546
|
+
sockets =
|
|
547
|
+
platform() === 'linux'
|
|
548
|
+
? socketsOnPortLinux(worktree, port)
|
|
549
|
+
: platform() === 'darwin'
|
|
550
|
+
? socketsOnPortDarwin(worktree, port)
|
|
551
|
+
: null;
|
|
552
|
+
} catch {
|
|
553
|
+
sockets = null;
|
|
554
|
+
}
|
|
555
|
+
if (sockets == null) return { host: '127.0.0.1' };
|
|
556
|
+
return pickOrigin(sockets, port);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The rule alone, over `[{ family: 4|6, addr, ours }]` — split out so it is
|
|
561
|
+
* testable without two address families on the test box.
|
|
562
|
+
*/
|
|
563
|
+
export function pickOrigin(sockets, port) {
|
|
564
|
+
const ours = sockets.filter((s) => s.ours);
|
|
565
|
+
if (!ours.length) return { error: `nothing is listening on port ${port} in this worktree.` };
|
|
566
|
+
const foreign = sockets.filter((s) => !s.ours);
|
|
567
|
+
const dialOf = (s) =>
|
|
568
|
+
s.family === 6 ? (s.addr === '::' ? '::1' : s.addr) : s.addr === '0.0.0.0' ? '127.0.0.1' : s.addr;
|
|
569
|
+
const rank = (s) => {
|
|
570
|
+
const d = dialOf(s);
|
|
571
|
+
return d === '127.0.0.1' ? 0 : d === '::1' ? 1 : s.family === 4 ? 2 : 3;
|
|
572
|
+
};
|
|
573
|
+
let contested = false;
|
|
574
|
+
for (const s of [...ours].sort((a, b) => rank(a) - rank(b))) {
|
|
575
|
+
const dial = dialOf(s);
|
|
576
|
+
const shadowed = foreign.some((f) => f.family === s.family && (f.addr === dial || f.addr === s.addr));
|
|
577
|
+
if (shadowed) {
|
|
578
|
+
contested = true;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
return { host: dial };
|
|
582
|
+
}
|
|
583
|
+
return contested
|
|
584
|
+
? {
|
|
585
|
+
error: `another process on this machine, outside this worktree, also listens on port ${port} at the same address, so it was not shared.`,
|
|
586
|
+
}
|
|
587
|
+
: { error: `nothing is listening on port ${port} in this worktree.` };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** /proc/net/tcp{,6} address hex → a printable address. Each 32-bit word is
|
|
591
|
+
* stored little-endian. */
|
|
592
|
+
function hexAddr(hex, family) {
|
|
593
|
+
const words = hex.match(/.{8}/g) || [];
|
|
594
|
+
const bytes = [];
|
|
595
|
+
for (const w of words) for (let i = 6; i >= 0; i -= 2) bytes.push(parseInt(w.slice(i, i + 2), 16));
|
|
596
|
+
if (family === 4) return bytes.join('.');
|
|
597
|
+
if (bytes.slice(0, 10).every((b) => b === 0) && bytes[10] === 0xff && bytes[11] === 0xff) {
|
|
598
|
+
return `::ffff:${bytes.slice(12).join('.')}`;
|
|
599
|
+
}
|
|
600
|
+
const groups = [];
|
|
601
|
+
for (let i = 0; i < 16; i += 2) groups.push(((bytes[i] << 8) | bytes[i + 1]).toString(16));
|
|
602
|
+
// Compress the longest run of zero groups, the way the address is written.
|
|
603
|
+
let best = [-1, 0];
|
|
604
|
+
for (let i = 0; i < 8; ) {
|
|
605
|
+
if (groups[i] !== '0') {
|
|
606
|
+
i++;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
let j = i;
|
|
610
|
+
while (j < 8 && groups[j] === '0') j++;
|
|
611
|
+
if (j - i > best[1]) best = [i, j - i];
|
|
612
|
+
i = j;
|
|
613
|
+
}
|
|
614
|
+
if (best[1] < 2) return groups.join(':');
|
|
615
|
+
const head = groups.slice(0, best[0]).join(':');
|
|
616
|
+
const tail = groups.slice(best[0] + best[1]).join(':');
|
|
617
|
+
return `${head}::${tail}`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function socketsOnPortLinux(worktree, port) {
|
|
621
|
+
const byInode = new Map(); // inode -> { family, addr }
|
|
622
|
+
for (const [f, family] of [
|
|
623
|
+
['/proc/net/tcp', 4],
|
|
624
|
+
['/proc/net/tcp6', 6],
|
|
625
|
+
]) {
|
|
626
|
+
let text;
|
|
627
|
+
try {
|
|
628
|
+
text = readFileSync(f, 'utf8');
|
|
629
|
+
} catch {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
for (const line of text.split('\n').slice(1)) {
|
|
633
|
+
const c = line.trim().split(/\s+/);
|
|
634
|
+
if (c.length < 10 || c[3] !== '0A') continue;
|
|
635
|
+
const [a, p] = String(c[1]).split(':');
|
|
636
|
+
if (parseInt(p, 16) !== port) continue;
|
|
637
|
+
if (c[9] && c[9] !== '0') byInode.set(c[9], { family, addr: hexAddr(a, family) });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (byInode.size === 0) return [];
|
|
641
|
+
let root;
|
|
642
|
+
try {
|
|
643
|
+
root = realpathSync(worktree);
|
|
644
|
+
} catch {
|
|
645
|
+
return [];
|
|
646
|
+
}
|
|
647
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
648
|
+
const ours = new Set();
|
|
649
|
+
let pids;
|
|
650
|
+
try {
|
|
651
|
+
pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
|
|
652
|
+
} catch {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
|
|
656
|
+
for (const pid of pids) {
|
|
657
|
+
let cwd;
|
|
658
|
+
try {
|
|
659
|
+
cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
660
|
+
} catch {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (cwd !== root && !cwd.startsWith(prefix)) continue;
|
|
664
|
+
if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
|
|
665
|
+
let fds;
|
|
666
|
+
try {
|
|
667
|
+
fds = readdirSync(`/proc/${pid}/fd`);
|
|
668
|
+
} catch {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
for (const fd of fds) {
|
|
672
|
+
let link;
|
|
673
|
+
try {
|
|
674
|
+
link = readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
675
|
+
} catch {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const m = /^socket:\[(\d+)\]$/.exec(link);
|
|
679
|
+
if (m && byInode.has(m[1])) ours.add(m[1]);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// A socket whose holder we could not read (another user's process) is
|
|
683
|
+
// FOREIGN — the safe reading for a refusal check.
|
|
684
|
+
return [...byInode.entries()].map(([inode, s]) => ({ ...s, ours: ours.has(inode) }));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function socketsOnPortDarwin(worktree, port) {
|
|
688
|
+
const rows = []; // { pid, family, addr }
|
|
689
|
+
let pid = null;
|
|
690
|
+
let family = null;
|
|
691
|
+
for (const line of lsof(['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-F', 'ptn']).split('\n')) {
|
|
692
|
+
if (line.startsWith('p')) pid = line.slice(1);
|
|
693
|
+
else if (line.startsWith('t')) family = line.slice(1) === 'IPv6' ? 6 : line.slice(1) === 'IPv4' ? 4 : null;
|
|
694
|
+
else if (line.startsWith('n') && pid && family) {
|
|
695
|
+
const m = /^n(.*):(\d+)$/.exec(line);
|
|
696
|
+
if (!m || Number(m[2]) !== port) continue;
|
|
697
|
+
let addr = m[1].replace(/^\[|\]$/g, '');
|
|
698
|
+
if (addr === '*') addr = family === 6 ? '::' : '0.0.0.0';
|
|
699
|
+
rows.push({ pid, family, addr });
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (!rows.length) return [];
|
|
703
|
+
let root;
|
|
704
|
+
try {
|
|
705
|
+
root = realpathSync(worktree);
|
|
706
|
+
} catch {
|
|
707
|
+
return [];
|
|
708
|
+
}
|
|
709
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
710
|
+
const inside = new Set();
|
|
711
|
+
const self = String(process.pid);
|
|
712
|
+
let cur = null;
|
|
713
|
+
const pids = [...new Set(rows.map((r) => r.pid))];
|
|
714
|
+
for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', pids.join(',')]).split('\n')) {
|
|
715
|
+
if (line.startsWith('p')) cur = line.slice(1);
|
|
716
|
+
else if (line.startsWith('n') && cur) {
|
|
717
|
+
const cwd = line.slice(1);
|
|
718
|
+
if ((cwd === root || cwd.startsWith(prefix)) && cur !== self && !infraPids.has(Number(cur))) inside.add(cur);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return rows.map((r) => ({ family: r.family, addr: r.addr, ours: inside.has(r.pid) }));
|
|
722
|
+
}
|
|
@@ -105,7 +105,7 @@ export function titleForSession(cwd, sessionId) {
|
|
|
105
105
|
/* the place is gone — nothing to read */
|
|
106
106
|
}
|
|
107
107
|
for (const dir of dirs) {
|
|
108
|
-
const file = join(projects, dir
|
|
108
|
+
const file = join(projects, claudeProjectDir(dir), `${sessionId}.jsonl`);
|
|
109
109
|
try {
|
|
110
110
|
return transcriptTitle(file, statSync(file).mtimeMs);
|
|
111
111
|
} catch {
|
|
@@ -115,6 +115,27 @@ export function titleForSession(cwd, sessionId) {
|
|
|
115
115
|
return null;
|
|
116
116
|
}
|
|
117
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
|
+
|
|
118
139
|
/** Path-prefix containment on already-realpath'd absolute paths. */
|
|
119
140
|
const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
|
|
120
141
|
|
|
@@ -373,7 +394,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
|
|
|
373
394
|
homedir(),
|
|
374
395
|
'.claude',
|
|
375
396
|
'projects',
|
|
376
|
-
cwd
|
|
397
|
+
claudeProjectDir(cwd),
|
|
377
398
|
`${rec.sessionId}.jsonl`
|
|
378
399
|
);
|
|
379
400
|
liveTitle = transcriptTitle(liveFile, statSync(liveFile).mtimeMs);
|
|
@@ -400,7 +421,11 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
|
|
|
400
421
|
// But so does a sibling repo ('flowviant-two' shares 'flowviant' + '-'),
|
|
401
422
|
// which is why every candidate is verified against the cwd its own records
|
|
402
423
|
// embed rather than trusted on its directory name.
|
|
403
|
-
const munged = realRoot
|
|
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}-`;
|
|
404
429
|
const projectsDir = join(homedir(), '.claude', 'projects');
|
|
405
430
|
let projDirs = [];
|
|
406
431
|
try {
|
|
@@ -411,7 +436,7 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
|
|
|
411
436
|
const cutoff = Date.now() - ENDED_WINDOW_MS;
|
|
412
437
|
const candidates = [];
|
|
413
438
|
for (const dirName of projDirs) {
|
|
414
|
-
if (dirName !== munged && !dirName.startsWith(
|
|
439
|
+
if (dirName !== munged && !dirName.startsWith(mungedStem)) continue;
|
|
415
440
|
let entries = [];
|
|
416
441
|
try {
|
|
417
442
|
entries = readdirSync(join(projectsDir, dirName), { withFileTypes: true });
|
package/bin/lib/login.mjs
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
|
|
10
|
-
import { saveLogin, detectRepoRoot, projectLabel, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
|
|
10
|
+
import { saveLogin, detectRepoRoot, projectLabel, safeName, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
|
|
11
11
|
import { c, info, ok, warn, fail } from './ui.mjs';
|
|
12
12
|
import { sleep } from './claude.mjs';
|
|
13
|
+
import { launchCommand, terminalCommand } from './launchCommand.mjs';
|
|
13
14
|
|
|
14
15
|
const DEVICE_START = FLEET_URL.replace(/\/agents\/?$/, '/device/start');
|
|
15
16
|
const DEVICE_POLL = FLEET_URL.replace(/\/agents\/?$/, '/device/poll');
|
|
@@ -27,24 +28,35 @@ async function post(url, body) {
|
|
|
27
28
|
return j.data ?? j;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export async function runLogin({ thenStart = false } = {}) {
|
|
31
|
-
|
|
31
|
+
export async function runLogin({ thenStart = false, json = false, dir = process.cwd() } = {}) {
|
|
32
|
+
const event = (value) => { if (json) process.stdout.write(`${JSON.stringify(value)}\n`); };
|
|
33
|
+
const selectedRepo = json ? detectRepoRoot(dir) : null;
|
|
34
|
+
if (json && !selectedRepo) { event({ event: 'error', message: `No git repository found in ${dir}.` }); return { saved: false }; }
|
|
35
|
+
if (!json) console.log(`\n ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`login · v${VERSION}`)}\n`);
|
|
32
36
|
let start;
|
|
33
37
|
try {
|
|
34
38
|
start = await post(DEVICE_START, {});
|
|
35
39
|
} catch (e) {
|
|
40
|
+
if (json) { event({ event: 'error', message: `couldn't reach Flowviant (${e.message}).` }); return { saved: false }; }
|
|
36
41
|
fail(`couldn't reach Flowviant (${e.message}).`);
|
|
37
42
|
process.exit(1);
|
|
38
43
|
}
|
|
39
44
|
const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
|
|
45
|
+
if (json && (typeof deviceCode !== 'string' || typeof userCode !== 'string' || userCode.length < 8)) {
|
|
46
|
+
event({ event: 'error', message: 'Flowviant returned an invalid login code.' });
|
|
47
|
+
return { saved: false };
|
|
48
|
+
}
|
|
40
49
|
const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
|
|
41
50
|
// Where the control ACTUALLY is. It was "the Agents panel", a settings
|
|
42
51
|
// section deleted 2026-08-17; connecting a machine is offered on the surface
|
|
43
52
|
// you are on when it matters, and for a new operator that is the Workbench —
|
|
44
53
|
// the project's empty state says so before it can show you any sessions.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
54
|
+
if (json) event({ event: 'open_url', url: APP_URL, code: pretty });
|
|
55
|
+
else {
|
|
56
|
+
console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
|
|
57
|
+
console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
|
|
58
|
+
info('waiting for you to approve…');
|
|
59
|
+
}
|
|
48
60
|
|
|
49
61
|
const deadline = Date.now() + expiresInSeconds * 1000;
|
|
50
62
|
while (Date.now() < deadline) {
|
|
@@ -56,6 +68,10 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
56
68
|
continue; // transient — keep polling
|
|
57
69
|
}
|
|
58
70
|
if (poll.status === 'approved') {
|
|
71
|
+
if (json && (!poll.projectId || !(poll.machineToken ?? poll.fleetToken))) {
|
|
72
|
+
event({ event: 'error', message: 'Flowviant did not return a project credential.' });
|
|
73
|
+
return { saved: false };
|
|
74
|
+
}
|
|
59
75
|
// `machineToken` is the wire's new name; `fleetToken` is the one every
|
|
60
76
|
// published daemon reads. The server dual-sends until DAEMON_MIN clears
|
|
61
77
|
// the release that reads the new one (0.54.2+) — reading both here is
|
|
@@ -65,12 +81,12 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
65
81
|
// know for certain which checkout this project means, and the binding is
|
|
66
82
|
// what lets a multi-project VM resolve `npx flowviant` by DIRECTORY
|
|
67
83
|
// instead of by whichever login happened last.
|
|
68
|
-
const repoRoot = detectRepoRoot();
|
|
84
|
+
const repoRoot = selectedRepo ?? detectRepoRoot(dir);
|
|
69
85
|
const entry = {
|
|
70
86
|
fleetToken: poll.machineToken ?? poll.fleetToken,
|
|
71
87
|
projectId: poll.projectId,
|
|
72
88
|
mcpUrl: poll.mcpUrl,
|
|
73
|
-
name:
|
|
89
|
+
name: safeName(poll.projectName),
|
|
74
90
|
repoRoot,
|
|
75
91
|
};
|
|
76
92
|
// A DIRECTORY SERVES ONE PROJECT (2026-09-23). The owner, verbatim: "a
|
|
@@ -86,12 +102,18 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
86
102
|
// (`directoryTakenRefusal`, shared with the daemon's own start).
|
|
87
103
|
const clash = boundElsewhere(listStoredProjects(), repoRoot, entry.projectId);
|
|
88
104
|
if (clash.length > 0) {
|
|
105
|
+
if (json) { event({ event: 'error', message: directoryTakenRefusal(clash, repoRoot, { incoming: entry }) }); return { saved: false }; }
|
|
89
106
|
console.log('');
|
|
90
107
|
warn(directoryTakenRefusal(clash, repoRoot, { incoming: entry }));
|
|
91
108
|
console.log('');
|
|
92
109
|
return { saved: false };
|
|
93
110
|
}
|
|
94
|
-
saveLogin(entry);
|
|
111
|
+
try { saveLogin(entry); }
|
|
112
|
+
catch (e) {
|
|
113
|
+
if (json) { event({ event: 'error', message: `Could not save this connection: ${e.message}` }); return { saved: false }; }
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
if (json) { event({ event: 'bound', projectId: entry.projectId, name: entry.name, dir: repoRoot }); return { saved: true }; }
|
|
95
117
|
ok(
|
|
96
118
|
`connected to ${c.bold(projectLabel(entry))}` +
|
|
97
119
|
`${repoRoot ? ` for ${c.dim(repoRoot)}` : ''} — saved to ~/.flowviant/credentials.json`
|
|
@@ -102,15 +124,17 @@ export async function runLogin({ thenStart = false } = {}) {
|
|
|
102
124
|
console.log(
|
|
103
125
|
thenStart
|
|
104
126
|
? `\n ${c.dim('starting your agent — leave this running')}\n`
|
|
105
|
-
: `\n Now run: ${c.bold(
|
|
127
|
+
: `\n Now run: ${c.bold(launchCommand())}\n`
|
|
106
128
|
);
|
|
107
129
|
return { saved: true };
|
|
108
130
|
}
|
|
109
131
|
if (poll.status === 'expired') {
|
|
110
|
-
|
|
132
|
+
if (json) { event({ event: 'error', message: 'Login code expired.' }); return { saved: false }; }
|
|
133
|
+
warn(`that code expired — run \`${terminalCommand('login')}\` again.`);
|
|
111
134
|
process.exit(1);
|
|
112
135
|
}
|
|
113
136
|
}
|
|
114
|
-
|
|
137
|
+
if (json) { event({ event: 'error', message: 'Login timed out.' }); return { saved: false }; }
|
|
138
|
+
warn(`login timed out — run \`${terminalCommand('login')}\` again.`);
|
|
115
139
|
process.exit(1);
|
|
116
140
|
}
|
package/bin/lib/machines.mjs
CHANGED
|
@@ -72,7 +72,9 @@
|
|
|
72
72
|
*/
|
|
73
73
|
|
|
74
74
|
import { USER_AGENT } from './config.mjs';
|
|
75
|
-
import { projectLabel, repoCollisions } from './credentials.mjs';
|
|
75
|
+
import { projectLabel, repoCollisions, safeName } from './credentials.mjs';
|
|
76
|
+
import { credentialRejected } from './authReject.mjs';
|
|
77
|
+
import { launchCommand, terminalCommand } from './launchCommand.mjs';
|
|
76
78
|
|
|
77
79
|
/** The boxes read, derived from the roster URL the way the diffstat post is —
|
|
78
80
|
* one place configures the API base and everything else is a suffix swap. */
|
|
@@ -178,7 +180,13 @@ function roleWord(role) {
|
|
|
178
180
|
* does not — and the words beside it are all the server's or the box's own. */
|
|
179
181
|
export function renderBox(box, { me = null, latest = null } = {}, now = Date.now()) {
|
|
180
182
|
const mark = box.fresh ? '●' : '○';
|
|
181
|
-
|
|
183
|
+
// SCRUBBED HERE TOO (2026-09-24, the audit) — a belt against the server's
|
|
184
|
+
// own display scrub, in defence of a stale stored row or a server this
|
|
185
|
+
// fix has not reached: the terminal is where an escape sequence in a
|
|
186
|
+
// box's name or its checkout path actually does harm, so this is the one
|
|
187
|
+
// place it must never be trusted to have already been cleaned. See
|
|
188
|
+
// printable.mjs.
|
|
189
|
+
const name = safeName(box.boxName) ?? 'an unnamed machine';
|
|
182
190
|
// "LAST heard" is the staleness mark, and the row is never hidden for it —
|
|
183
191
|
// the owner's answer to "what about a box that stopped" was exactly this.
|
|
184
192
|
const heard = heardPhrase(box, now);
|
|
@@ -190,7 +198,7 @@ export function renderBox(box, { me = null, latest = null } = {}, now = Date.now
|
|
|
190
198
|
const cells = [
|
|
191
199
|
`${mark} ${name}`,
|
|
192
200
|
roleWord(box.role),
|
|
193
|
-
box.checkoutPath
|
|
201
|
+
safeName(box.checkoutPath) ?? '(checkout not reported)',
|
|
194
202
|
box.daemonVersion ? `v${box.daemonVersion}` : 'version not reported',
|
|
195
203
|
heard,
|
|
196
204
|
].filter(Boolean);
|
|
@@ -223,7 +231,7 @@ export function renderMachines(entries, results, { now = Date.now() } = {}) {
|
|
|
223
231
|
// to notice a suffix to know whether it matters has already been asked to do
|
|
224
232
|
// the work this listing is for.
|
|
225
233
|
for (const e of entries) {
|
|
226
|
-
const label = e
|
|
234
|
+
const label = projectLabel(e);
|
|
227
235
|
const connected = connectedOn(e.savedAt);
|
|
228
236
|
const id = `(id ${e.projectId.slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
|
|
229
237
|
lines.push(
|
|
@@ -239,7 +247,7 @@ export function renderMachines(entries, results, { now = Date.now() } = {}) {
|
|
|
239
247
|
if (r.rejected) {
|
|
240
248
|
lines.push(
|
|
241
249
|
' credential rejected — this project was disconnected or deleted. ' +
|
|
242
|
-
|
|
250
|
+
`\`${terminalCommand(`machines --forget ${e.projectId.slice(0, 8)}`)}\` removes it here.`
|
|
243
251
|
);
|
|
244
252
|
continue;
|
|
245
253
|
}
|
|
@@ -290,7 +298,7 @@ export function renderCollisions(entries) {
|
|
|
290
298
|
const list = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
291
299
|
return (
|
|
292
300
|
` ! ${g.entries.length} projects are connected for ${g.repoRoot}: ${list} — ` +
|
|
293
|
-
|
|
301
|
+
`a directory serves one project, and \`${launchCommand()}\` there refuses to start until it does. ` +
|
|
294
302
|
'Delete the one you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it.'
|
|
295
303
|
);
|
|
296
304
|
});
|
|
@@ -310,7 +318,7 @@ export function renderCollisions(entries) {
|
|
|
310
318
|
*/
|
|
311
319
|
export const MACHINES_FOOTER = [
|
|
312
320
|
' stop a machine from the app: project settings → Machine → Disconnect;',
|
|
313
|
-
|
|
321
|
+
` a new \`${launchCommand()}\` on another box takes the project over.`,
|
|
314
322
|
' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
|
|
315
323
|
];
|
|
316
324
|
|
|
@@ -318,7 +326,7 @@ export const MACHINES_FOOTER = [
|
|
|
318
326
|
* terminal that could draw the menu was offered the same two things by name,
|
|
319
327
|
* and repeating the flags under it would be a manual under a control. */
|
|
320
328
|
export const MACHINES_FLAGS_FOOTER = [
|
|
321
|
-
'
|
|
329
|
+
` \`${terminalCommand('machines --remove <id>')}\` disconnects this box from a project: stops its daemon here,`,
|
|
322
330
|
' removes this box from that project’s machines list in the app, and forgets its credential here.',
|
|
323
331
|
' `--forget <id>` forgets the credential here only.',
|
|
324
332
|
];
|
|
@@ -332,15 +340,16 @@ export const MACHINES_FLAGS_FOOTER = [
|
|
|
332
340
|
* probably forget. Two different next moves; collapsing them is how somebody
|
|
333
341
|
* deletes a working credential.
|
|
334
342
|
*/
|
|
335
|
-
export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch } = {}) {
|
|
343
|
+
export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch, signal = AbortSignal.timeout(15_000) } = {}) {
|
|
336
344
|
const target = new URL(url);
|
|
337
345
|
if (envpub) target.searchParams.set('envpub', envpub);
|
|
338
346
|
try {
|
|
339
347
|
const res = await fetchImpl(target, {
|
|
340
348
|
headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
|
|
341
|
-
signal
|
|
349
|
+
signal,
|
|
342
350
|
});
|
|
343
|
-
if (
|
|
351
|
+
if (await credentialRejected(res)) return { rejected: true };
|
|
352
|
+
if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
|
|
344
353
|
if (res.status === 404) return { unsupported: true };
|
|
345
354
|
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
346
355
|
const body = await res.json();
|
|
@@ -367,7 +376,7 @@ export function otherBoxesLine(boxes, me, now = Date.now()) {
|
|
|
367
376
|
if (others.length === 0) return null;
|
|
368
377
|
const parts = others.slice(0, 6).map((b) => {
|
|
369
378
|
const role = roleWord(b.role);
|
|
370
|
-
return `${b.boxName
|
|
379
|
+
return `${safeName(b.boxName) ?? 'an unnamed machine'} (${role ? `${role} · ` : ''}${heardPhrase(b, now)})`;
|
|
371
380
|
});
|
|
372
381
|
const more = others.length - parts.length;
|
|
373
382
|
return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
|
|
@@ -403,7 +412,8 @@ export async function leaveBoxFor(entry, { url, envpub, fetchImpl = fetch } = {}
|
|
|
403
412
|
body: JSON.stringify({ envpub }),
|
|
404
413
|
signal: AbortSignal.timeout(15_000),
|
|
405
414
|
});
|
|
406
|
-
if (
|
|
415
|
+
if (await credentialRejected(res)) return { rejected: true };
|
|
416
|
+
if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
|
|
407
417
|
if (res.status === 404) return { unsupported: true };
|
|
408
418
|
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
409
419
|
const body = await res.json();
|
|
@@ -478,7 +488,7 @@ export async function disconnectHere(entry, deps, { log = (m) => console.log(m)
|
|
|
478
488
|
log(`could not forget the credential here: ${gone.error}`);
|
|
479
489
|
return { ok: false };
|
|
480
490
|
}
|
|
481
|
-
log(`forgot ${who}’s credential on this box.
|
|
491
|
+
log(`forgot ${who}’s credential on this box. \`${terminalCommand('login')}\` in its repo connects it again.`);
|
|
482
492
|
return { ok: true };
|
|
483
493
|
}
|
|
484
494
|
|