flowviant 0.115.0 → 0.117.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 +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +99 -145
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -20,7 +20,15 @@ import {
|
|
|
20
20
|
unlinkSync,
|
|
21
21
|
} from 'node:fs';
|
|
22
22
|
import { join } from 'node:path';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
isCurrent,
|
|
25
|
+
localSha,
|
|
26
|
+
recordSyncState,
|
|
27
|
+
syncVerifiedFile,
|
|
28
|
+
writeAtomic,
|
|
29
|
+
writeTextIfChanged,
|
|
30
|
+
} from './knowledgeFileSync.mjs';
|
|
31
|
+
import { ensureOwnDir, flowviantParentState, realDirState } from './ownedDirs.mjs';
|
|
24
32
|
import {
|
|
25
33
|
INSTRUCTIONS_FILE,
|
|
26
34
|
KNOWLEDGE_DIR,
|
|
@@ -50,29 +58,15 @@ const MARKER = '.flowviant/knowledge.rev';
|
|
|
50
58
|
* So the parent is checked, never repaired.
|
|
51
59
|
*/
|
|
52
60
|
function flowviantDirOk(checkoutDir) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return st.isDirectory() && !st.isSymbolicLink();
|
|
56
|
-
} catch {
|
|
57
|
-
return true; // absent — ours to create
|
|
58
|
-
}
|
|
61
|
+
// Absent is ours to create; the three states are ownedDirs.mjs's (2026-10-10).
|
|
62
|
+
return flowviantParentState(checkoutDir) !== 'other';
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
/** The library directory, as a real directory. The PARENT is checked by the
|
|
62
66
|
* caller; the library path itself is OURS, so a symlink or a file planted
|
|
63
|
-
* there is removed (
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
67
|
-
try {
|
|
68
|
-
const st = lstatSync(dir);
|
|
69
|
-
if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(dir);
|
|
70
|
-
} catch {
|
|
71
|
-
/* absent */
|
|
72
|
-
}
|
|
73
|
-
mkdirSync(dir, { recursive: true });
|
|
74
|
-
return dir;
|
|
75
|
-
}
|
|
67
|
+
* there is removed (the link, never its target) rather than followed —
|
|
68
|
+
* `ensureOwnDir`, ownedDirs.mjs. */
|
|
69
|
+
const ensureDir = (checkoutDir) => ensureOwnDir(join(checkoutDir, KNOWLEDGE_DIR));
|
|
76
70
|
|
|
77
71
|
/**
|
|
78
72
|
* THE MARKER SAYS WHICH REV WAS WRITTEN, AND WHETHER THE LIBRARY WAS PART OF
|
|
@@ -109,6 +103,10 @@ export function knowledgeMarkerHasLibrary(checkoutDir) {
|
|
|
109
103
|
}
|
|
110
104
|
|
|
111
105
|
export function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
|
|
106
|
+
// The sync's own parent rule, here too (2026-10-10, the audit's F100): its
|
|
107
|
+
// one caller runs after a sync that refused a linked `.flowviant`, but the
|
|
108
|
+
// marker is a write, and a write checks for itself.
|
|
109
|
+
if (!flowviantDirOk(checkoutDir)) return;
|
|
112
110
|
try {
|
|
113
111
|
mkdirSync(join(checkoutDir, '.flowviant'), { recursive: true });
|
|
114
112
|
writeAtomic(join(checkoutDir, MARKER), `${rev}${lib ? ':lib' : ''}\n`);
|
|
@@ -120,17 +118,7 @@ export function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
|
|
|
120
118
|
|
|
121
119
|
/** A library subdirectory as a real directory — ours, like the library's own
|
|
122
120
|
* path: a symlink or a file planted there is removed, never followed. */
|
|
123
|
-
|
|
124
|
-
const path = join(dir, name);
|
|
125
|
-
try {
|
|
126
|
-
const st = lstatSync(path);
|
|
127
|
-
if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(path);
|
|
128
|
-
} catch {
|
|
129
|
-
/* absent */
|
|
130
|
-
}
|
|
131
|
-
mkdirSync(path, { recursive: true });
|
|
132
|
-
return path;
|
|
133
|
-
}
|
|
121
|
+
const ensureSubdir = (dir, name) => ensureOwnDir(join(dir, name));
|
|
134
122
|
|
|
135
123
|
function ensureLibraryParents(dir, relativePath) {
|
|
136
124
|
const parts = relativePath.split('/');
|
|
@@ -231,34 +219,19 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
231
219
|
maxBytes: knowledgeMaxFor(f.name, maxBytes),
|
|
232
220
|
fetch: () => fetchFile(f.id),
|
|
233
221
|
});
|
|
234
|
-
|
|
222
|
+
// Stale or failed clears `ok` and is retried (a stale file is better than
|
|
223
|
+
// a missing one until the retry lands); a refusal is listed as one.
|
|
224
|
+
if (recordSyncState(result, state, f.local) === 'refused') {
|
|
235
225
|
// Permanent for this rev, and NOT a reason to delete the rest. Any stale
|
|
236
226
|
// local copy under this name goes, because it is not the file the
|
|
237
227
|
// manifest now names.
|
|
238
|
-
result.refused.push(f.local);
|
|
239
228
|
keep.delete(f.local);
|
|
240
|
-
} else if (state === 'wrote') {
|
|
241
|
-
result.wrote.push(f.local);
|
|
242
|
-
} else if (!isCurrent(state)) {
|
|
243
|
-
// A stale file is better than a missing one until the retry lands.
|
|
244
|
-
result.ok = false;
|
|
245
|
-
result.failed.push(f.local);
|
|
246
229
|
}
|
|
247
230
|
}
|
|
248
231
|
|
|
249
232
|
if (instructions) {
|
|
250
|
-
const path = join(dir, INSTRUCTIONS_FILE);
|
|
251
233
|
const body = instructions.endsWith('\n') ? instructions : `${instructions}\n`;
|
|
252
|
-
|
|
253
|
-
try {
|
|
254
|
-
same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === body;
|
|
255
|
-
} catch {
|
|
256
|
-
/* absent */
|
|
257
|
-
}
|
|
258
|
-
if (!same) {
|
|
259
|
-
writeAtomic(path, body);
|
|
260
|
-
result.wrote.push(INSTRUCTIONS_FILE);
|
|
261
|
-
}
|
|
234
|
+
if (writeTextIfChanged(join(dir, INSTRUCTIONS_FILE), body)) result.wrote.push(INSTRUCTIONS_FILE);
|
|
262
235
|
}
|
|
263
236
|
|
|
264
237
|
if (library !== null) {
|
|
@@ -329,16 +302,10 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
|
|
|
329
302
|
fetch: () => fetchFile(it.id, { library: true }),
|
|
330
303
|
beforeWrite: () => ensureLibraryParents(dir, it.path),
|
|
331
304
|
});
|
|
332
|
-
if (entryState === 'refused') {
|
|
333
|
-
result.refused.push(it.path);
|
|
305
|
+
if (recordSyncState(result, entryState, it.path) === 'refused') {
|
|
334
306
|
wanted[sub].delete(file);
|
|
335
307
|
continue;
|
|
336
308
|
}
|
|
337
|
-
if (entryState === 'wrote') result.wrote.push(it.path);
|
|
338
|
-
else if (!isCurrent(entryState)) {
|
|
339
|
-
result.ok = false;
|
|
340
|
-
result.failed.push(it.path);
|
|
341
|
-
}
|
|
342
309
|
let complete = isCurrent(entryState);
|
|
343
310
|
// THE PREVIEW: measured by the server, ours to cap — an oversized one is
|
|
344
311
|
// a failure to retry, never a refusal. Not required for the catalog: the
|
|
@@ -353,11 +320,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
|
|
|
353
320
|
fetch: () => fetchFile(it.id, { library: true, preview: true }),
|
|
354
321
|
beforeWrite: () => ensureLibraryParents(dir, it.previewPath),
|
|
355
322
|
});
|
|
356
|
-
|
|
357
|
-
else if (!isCurrent(previewState)) {
|
|
358
|
-
result.ok = false;
|
|
359
|
-
result.failed.push(it.previewPath);
|
|
360
|
-
}
|
|
323
|
+
recordSyncState(result, previewState, it.previewPath);
|
|
361
324
|
}
|
|
362
325
|
// THE BUNDLE'S SIDECARS are REQUIRED: a design whose model failed to land
|
|
363
326
|
// is not a complete current design, so it is not catalogued as one. The hash
|
|
@@ -375,14 +338,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
|
|
|
375
338
|
fetch: () => fetchFile(it.id, { library: true, fileIndex: f.index }),
|
|
376
339
|
beforeWrite: () => ensureLibraryParents(dir, f.path),
|
|
377
340
|
});
|
|
378
|
-
if (fileState === 'refused')
|
|
379
|
-
result.refused.push(f.path);
|
|
380
|
-
wanted[sub].delete(rel);
|
|
381
|
-
} else if (fileState === 'wrote') result.wrote.push(f.path);
|
|
382
|
-
else if (!isCurrent(fileState)) {
|
|
383
|
-
result.ok = false;
|
|
384
|
-
result.failed.push(f.path);
|
|
385
|
-
}
|
|
341
|
+
if (recordSyncState(result, fileState, f.path) === 'refused') wanted[sub].delete(rel);
|
|
386
342
|
// A REFUSED sidecar is permanent for this rev and said in `refused`, the
|
|
387
343
|
// way `libraryStale` already stops expecting it — blocking the catalog on
|
|
388
344
|
// it would leave LIBRARY.md missing and re-sync the rev every poll.
|
|
@@ -401,17 +357,7 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
|
|
|
401
357
|
const catalog = renderLibraryCatalog(onDisk);
|
|
402
358
|
if (catalog) {
|
|
403
359
|
keep.add(LIBRARY_FILE);
|
|
404
|
-
|
|
405
|
-
let same = false;
|
|
406
|
-
try {
|
|
407
|
-
same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === catalog;
|
|
408
|
-
} catch {
|
|
409
|
-
/* absent */
|
|
410
|
-
}
|
|
411
|
-
if (!same) {
|
|
412
|
-
writeAtomic(path, catalog);
|
|
413
|
-
result.wrote.push(LIBRARY_FILE);
|
|
414
|
-
}
|
|
360
|
+
if (writeTextIfChanged(join(dir, LIBRARY_FILE), catalog)) result.wrote.push(LIBRARY_FILE);
|
|
415
361
|
}
|
|
416
362
|
}
|
|
417
363
|
|
|
@@ -474,8 +420,7 @@ export function libraryStale(checkoutDir, manifest, { synced, refused }) {
|
|
|
474
420
|
let parent = dir;
|
|
475
421
|
for (const part of p.slice(dir.length + 1).split('/').slice(0, -1)) {
|
|
476
422
|
parent = join(parent, part);
|
|
477
|
-
|
|
478
|
-
if (st.isSymbolicLink() || !st.isDirectory()) return false;
|
|
423
|
+
if (realDirState(parent) !== 'dir') return false;
|
|
479
424
|
}
|
|
480
425
|
return lstatSync(p).isFile();
|
|
481
426
|
} catch {
|
package/bin/lib/landed.mjs
CHANGED
|
@@ -23,16 +23,16 @@
|
|
|
23
23
|
* report nothing — ignorance is never turned into a state.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { execFileSync } from 'node:child_process';
|
|
27
26
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
28
27
|
import { join } from 'node:path';
|
|
29
28
|
import { homedir } from 'node:os';
|
|
30
29
|
import { createHash } from 'node:crypto';
|
|
31
|
-
import { git, baseBranchName } from './git.mjs';
|
|
30
|
+
import { git, baseBranchName, GIT_WIDE_BUFFER, OBJECT_ID_RE } from './git.mjs';
|
|
32
31
|
import { commitRecord, stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
33
32
|
import { warn } from './ui.mjs';
|
|
34
|
-
import { FLEET_URL
|
|
35
|
-
import { fleetEndpoint } from './fleetWire.mjs';
|
|
33
|
+
import { FLEET_URL } from './config.mjs';
|
|
34
|
+
import { fleetEndpoint, isConsideredRefusal, isNotNow } from './fleetWire.mjs';
|
|
35
|
+
import { postFleetJson } from './fleetPost.mjs';
|
|
36
36
|
|
|
37
37
|
const LANDED_URL = fleetEndpoint('base-landed', FLEET_URL);
|
|
38
38
|
/** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
|
|
@@ -40,11 +40,15 @@ const LANDED_URL = fleetEndpoint('base-landed', FLEET_URL);
|
|
|
40
40
|
* so the remainder is picked up on the next beat rather than skipped forever
|
|
41
41
|
* — a trailered card in commit 51 of a big catch-up still closes. */
|
|
42
42
|
const MAX_COMMITS = 50;
|
|
43
|
-
|
|
43
|
+
/** The shape a tip or a walked sha must have — git.mjs's named object-id
|
|
44
|
+
* shape (7–64 hex, either case), the one this module always used. */
|
|
45
|
+
const SHA_RE = OBJECT_ID_RE;
|
|
44
46
|
|
|
45
47
|
export function createLandedObserver({ repoRoot, baseRef }) {
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
+
// One state file per checkout, so two repos on one box never share a tip.
|
|
49
|
+
// Its own 8-hex key — NOT the worktree home's `<basename>-<8 hex>`
|
|
50
|
+
// (statePaths.mjs) — and it stays as it is, so a persisted tip is never
|
|
51
|
+
// orphaned by a rename.
|
|
48
52
|
const key = createHash('sha256').update(String(repoRoot)).digest('hex').slice(0, 8);
|
|
49
53
|
const stateFile = join(homedir(), '.flowviant', `landed-${key}.json`);
|
|
50
54
|
let unsupported = false; // 404 once → an older server; quiet until restart
|
|
@@ -79,16 +83,10 @@ export function createLandedObserver({ repoRoot, baseRef }) {
|
|
|
79
83
|
/** git with the buffer the WALK needs. git.mjs's call takes execFileSync's
|
|
80
84
|
* default 1MiB cap, and a catch-up range's `%B` bodies blew through it —
|
|
81
85
|
* the throw landed in the reseed catch below, which skipped the whole range
|
|
82
|
-
* and silently lost every trailer in it. 8MB is
|
|
83
|
-
* same reason; rev-list output at 41 bytes a
|
|
84
|
-
* before it matters. */
|
|
85
|
-
const gitWide = (args) =>
|
|
86
|
-
execFileSync('git', args, {
|
|
87
|
-
cwd: repoRoot,
|
|
88
|
-
encoding: 'utf8',
|
|
89
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
90
|
-
maxBuffer: 8 * 1024 * 1024,
|
|
91
|
-
}).trim();
|
|
86
|
+
* and silently lost every trailer in it. GIT_WIDE_BUFFER (8MB, git.mjs) is
|
|
87
|
+
* the shared number for the same reason; rev-list output at 41 bytes a
|
|
88
|
+
* commit clears ~200k commits before it matters. */
|
|
89
|
+
const gitWide = (args) => git(args, repoRoot, { maxBuffer: GIT_WIDE_BUFFER });
|
|
92
90
|
|
|
93
91
|
/** New non-merge commits in from..to, OLDEST FIRST — the next batch of at
|
|
94
92
|
* most MAX_COMMITS, plus the tip the state should advance to when the range
|
|
@@ -175,16 +173,11 @@ export function createLandedObserver({ repoRoot, baseRef }) {
|
|
|
175
173
|
const reportedTip = walked.tipAfter ?? tip;
|
|
176
174
|
inFlight = true;
|
|
177
175
|
try {
|
|
178
|
-
const res = await
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
'Content-Type': 'application/json',
|
|
184
|
-
},
|
|
185
|
-
signal: AbortSignal.timeout(20_000),
|
|
186
|
-
body: JSON.stringify({ base: baseBranchName(ref), tip: reportedTip, commits }),
|
|
187
|
-
});
|
|
176
|
+
const res = await postFleetJson(
|
|
177
|
+
LANDED_URL,
|
|
178
|
+
{ base: baseBranchName(ref), tip: reportedTip, commits },
|
|
179
|
+
{ timeoutMs: 20_000 }
|
|
180
|
+
);
|
|
188
181
|
if (res.status === 404) {
|
|
189
182
|
unsupported = true;
|
|
190
183
|
return;
|
|
@@ -201,14 +194,14 @@ export function createLandedObserver({ repoRoot, baseRef }) {
|
|
|
201
194
|
for (const r of j?.data?.deployRefused ?? []) {
|
|
202
195
|
warn(`deploy-on-merge refused for target "${r?.targetId}": ${r?.reason}`);
|
|
203
196
|
}
|
|
204
|
-
} else if (res.status
|
|
197
|
+
} else if (isNotNow(res.status)) {
|
|
205
198
|
// A TIMEOUT OR A RATE LIMIT IS "NOT NOW", NOT "NEVER". Both used to
|
|
206
199
|
// land in the drop below, so one 429 — a busy operator IP past the
|
|
207
200
|
// limiter — skipped the range for good: its trailers and receipt shas
|
|
208
201
|
// never closed their cards (in PR mode the observer is the ONLY road
|
|
209
202
|
// to Done) and deploy-on-merge for that tip was never queued. Keep the
|
|
210
203
|
// stored tip, exactly as a 5xx does; the next beat re-walks it.
|
|
211
|
-
} else if (res.status
|
|
204
|
+
} else if (isConsideredRefusal(res.status)) {
|
|
212
205
|
// A persistent 4xx (deploy skew, a payload this server refuses) would
|
|
213
206
|
// otherwise re-send the same poison range on every beat forever.
|
|
214
207
|
// Drop THIS BATCH — to `reportedTip`, the last commit it carried, never
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
1
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { terminalForeground } from './tty.mjs';
|
|
2
4
|
|
|
3
5
|
/** The updater and terminal advice share one detection of the launch channel. */
|
|
4
6
|
export function runningViaNpx() {
|
|
@@ -13,6 +15,17 @@ export function runningViaNpx() {
|
|
|
13
15
|
return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/** Bun marks compiled entrypoints with a virtual /$bunfs/ script path. */
|
|
19
|
+
export function runningCompiledBinary() {
|
|
20
|
+
return Boolean(process.versions.bun && process.argv[1]?.startsWith('/$bunfs/'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How this copy was installed — the tray's `installChannel` and the updater's
|
|
24
|
+
* branch read the same answer. */
|
|
25
|
+
export function installChannel() {
|
|
26
|
+
return runningCompiledBinary() ? 'binary' : runningViaNpx() ? 'npx' : 'npm-g';
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
/** Name the command that launched this process when giving a person a next step. */
|
|
17
30
|
export function launchCommand() {
|
|
18
31
|
return 'flowviant';
|
|
@@ -21,3 +34,81 @@ export function launchCommand() {
|
|
|
21
34
|
export function terminalCommand(args = '') {
|
|
22
35
|
return `${launchCommand()}${args ? ` ${args}` : ''}`;
|
|
23
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The argv that starts THIS flowviant again with `args`: the script path under
|
|
40
|
+
* node (`process.execPath` is node), nothing under the compiled binary (where
|
|
41
|
+
* `process.execPath` IS flowviant and argv[1] is Bun's virtual /$bunfs/ path).
|
|
42
|
+
*/
|
|
43
|
+
export function selfArgv(args = []) {
|
|
44
|
+
return [...(runningCompiledBinary() ? [] : [process.argv[1]]), ...args];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The signals a relaunch proxy passes on to the process it stands in for. */
|
|
48
|
+
export const RELAY_SIGNALS = Object.freeze(['SIGINT', 'SIGTERM', 'SIGHUP']);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* RELAUNCH, AND STAND IN FOR THE CHILD UNTIL IT EXITS (2026-10-10, F068/F124).
|
|
52
|
+
*
|
|
53
|
+
* Four paths restart flowviant as a child and keep this process alive as a
|
|
54
|
+
* thin foreground proxy, so the person's shell (or the tray, or a service
|
|
55
|
+
* manager) keeps watching one process: login then start, the start path's
|
|
56
|
+
* inline login, the Bun PATH adoption (loginPath.mjs) and the self-update
|
|
57
|
+
* (update.mjs). They were four hand copies, and only one of them passed
|
|
58
|
+
* signals on or reported a signal death as a failure.
|
|
59
|
+
*
|
|
60
|
+
* EXIT `status ?? (signal ? 1 : 0)`. A child killed by a signal (the OOM
|
|
61
|
+
* killer, a SIGKILL) is a FAILURE: the daemon's exit codes are argued against
|
|
62
|
+
* `Restart=on-failure` (holder.mjs, fleet.mjs — 0 means asked for, nonzero
|
|
63
|
+
* means relaunch), and a proxy that read a killed successor as 0 left the
|
|
64
|
+
* machine dark after any auto-update.
|
|
65
|
+
*
|
|
66
|
+
* SIGINT, SIGTERM AND SIGHUP REACH THE CHILD. A tray or supervisor that
|
|
67
|
+
* signals only this pid would otherwise leave the child running orphaned. Every
|
|
68
|
+
* listener already on those signals is removed first: on the self-update path
|
|
69
|
+
* the fleet's own handlers are still attached, and they would run a second
|
|
70
|
+
* stand-down and exit here without ever telling the child.
|
|
71
|
+
*
|
|
72
|
+
* …EXCEPT A Ctrl+C THE TERMINAL ALREADY DELIVERED. The child shares this
|
|
73
|
+
* process group, so when this proxy is MEASURED in its terminal's foreground
|
|
74
|
+
* group, a terminal SIGINT reached the child directly; relaying it as well
|
|
75
|
+
* would be a SECOND Ctrl+C, which the daemon reads as "leave now, without the
|
|
76
|
+
* deploy's outcome" (standDownExit.mjs). No terminal, a background job, or an
|
|
77
|
+
* unknown answer relays it. SIGTERM and SIGHUP are always relayed: a repeated
|
|
78
|
+
* SIGTERM changes nothing in the daemon, and SIGHUP ends it on the first.
|
|
79
|
+
*
|
|
80
|
+
* Every seam is injectable for the tests; nothing in the daemon passes them.
|
|
81
|
+
*/
|
|
82
|
+
export function relaunchSelf({
|
|
83
|
+
cmd = process.execPath,
|
|
84
|
+
argv,
|
|
85
|
+
env = process.env,
|
|
86
|
+
cwd,
|
|
87
|
+
forwardSignals = true,
|
|
88
|
+
spawn = nodeSpawn,
|
|
89
|
+
exit = (code) => process.exit(code),
|
|
90
|
+
signals = process,
|
|
91
|
+
terminalDelivers = () => terminalForeground() === true,
|
|
92
|
+
} = {}) {
|
|
93
|
+
const child = spawn(cmd, argv, { stdio: 'inherit', env, ...(cwd ? { cwd } : {}) });
|
|
94
|
+
if (forwardSignals) {
|
|
95
|
+
for (const signal of RELAY_SIGNALS) {
|
|
96
|
+
signals.removeAllListeners(signal);
|
|
97
|
+
signals.on(signal, () => {
|
|
98
|
+
if (signal === 'SIGINT' && terminalDelivers()) return;
|
|
99
|
+
try {
|
|
100
|
+
child.kill(signal);
|
|
101
|
+
} catch {
|
|
102
|
+
/* gone */
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
child.on('exit', (status, signal) => {
|
|
109
|
+
const code = status ?? (signal ? 1 : 0);
|
|
110
|
+
exit(code);
|
|
111
|
+
resolve(code);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
package/bin/lib/listeners.mjs
CHANGED
|
@@ -449,11 +449,6 @@ export function measureListeners(worktree, io) {
|
|
|
449
449
|
};
|
|
450
450
|
}
|
|
451
451
|
|
|
452
|
-
/** The capped rows alone, for callers with nowhere to put a total. */
|
|
453
|
-
export function listenersIn(worktree) {
|
|
454
|
-
return measureListeners(worktree).rows;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
452
|
/** Does this platform measure listeners at all? The web must render no preview
|
|
458
453
|
* affordance where the answer is no, rather than an empty one. */
|
|
459
454
|
export function listenersSupported() {
|
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
|
|
21
21
|
import { readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
22
22
|
import { join } from 'node:path';
|
|
23
|
-
import { execFileSync } from 'node:child_process';
|
|
24
23
|
import { scanClaudeSessions } from './claudeSessions.mjs';
|
|
25
24
|
import { scanAgyConversations } from './agySessions.mjs';
|
|
25
|
+
import { isPathInside } from './repoPath.mjs';
|
|
26
|
+
import { SESSION_MARKERS } from './conversationIds.mjs';
|
|
27
|
+
import { git } from './git.mjs';
|
|
26
28
|
|
|
27
29
|
const REPORT_CAP = 30;
|
|
28
30
|
// ENDED sessions are the adoptable inventory, and the useful ones are FRESH:
|
|
@@ -32,21 +34,47 @@ const REPORT_CAP = 30;
|
|
|
32
34
|
// and the strip read as session history instead of presence.)
|
|
33
35
|
const ENDED_WINDOW_MS = 48 * 60 * 60 * 1000;
|
|
34
36
|
|
|
35
|
-
/** Path-prefix containment on already-realpath'd absolute paths. */
|
|
36
|
-
const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
|
|
37
|
-
|
|
38
37
|
/**
|
|
39
|
-
* THE
|
|
38
|
+
* THE ADOPT FENCE — inside the repo, outside the daemon's own worktrees — on
|
|
39
|
+
* BOTH sides of an adoption: this scan OFFERS what passes it, and the session
|
|
40
|
+
* lane re-checks the cwd the server hands back before it forks anything
|
|
41
|
+
* (workSessionTurns.mjs). The two used to be written separately, each with
|
|
42
|
+
* its own realpath-or-literal resolution and its own containment predicate,
|
|
43
|
+
* agreeing only by hand; a drift would offer sessions the machine then
|
|
44
|
+
* refuses, or accept one it would never have offered (audit 2026-10-10, F106).
|
|
40
45
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
46
|
+
* `repoRoot` and each excluded directory are realpath'd ONCE here, falling
|
|
47
|
+
* back to the literal spelling when they cannot be resolved (`resolved` says
|
|
48
|
+
* which, for the agy reader whose cwd registry is global). `check(p)` takes an
|
|
49
|
+
* already-resolved path and answers:
|
|
50
|
+
* 'outside' — not inside the repo (or the repo has no usable root)
|
|
51
|
+
* 'ours' — inside the repo AND inside one of the daemon's own directories
|
|
52
|
+
* 'ok' — a human's checkout in this repo: adoptable
|
|
44
53
|
*/
|
|
45
|
-
export
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
export function adoptFence({ repoRoot, excludeDirs = [] }) {
|
|
55
|
+
let realRoot;
|
|
56
|
+
let resolved = true;
|
|
57
|
+
try {
|
|
58
|
+
realRoot = realpathSync(repoRoot);
|
|
59
|
+
} catch {
|
|
60
|
+
resolved = false;
|
|
61
|
+
realRoot = String(repoRoot ?? '');
|
|
62
|
+
}
|
|
63
|
+
const excludes = [];
|
|
64
|
+
for (const d of excludeDirs) {
|
|
65
|
+
if (!d) continue;
|
|
66
|
+
try {
|
|
67
|
+
excludes.push(realpathSync(d));
|
|
68
|
+
} catch {
|
|
69
|
+
excludes.push(String(d)); // not on disk yet — keep the literal fence
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const check = (p) => {
|
|
73
|
+
if (!realRoot || !isPathInside(p, realRoot)) return 'outside';
|
|
74
|
+
return excludes.some((e) => isPathInside(p, e)) ? 'ours' : 'ok';
|
|
75
|
+
};
|
|
76
|
+
return { realRoot, resolved, check };
|
|
77
|
+
}
|
|
50
78
|
|
|
51
79
|
/**
|
|
52
80
|
* EVERY CONVERSATION THIS DAEMON STARTED, so the adopt strip never offers you
|
|
@@ -65,19 +93,19 @@ export const SESSION_MARKERS = [
|
|
|
65
93
|
* be offered at all.
|
|
66
94
|
*
|
|
67
95
|
* Excluded by conversation ID rather than by directory, because the directory
|
|
68
|
-
* is shared with exactly the sessions we want to keep offering.
|
|
96
|
+
* is shared with exactly the sessions we want to keep offering. The ids are
|
|
97
|
+
* read off the markers the session lane pins each tab's conversation under —
|
|
98
|
+
* `SESSION_MARKERS`, the one list the writer's names come from too
|
|
99
|
+
* (conversationIds.mjs, audit 2026-10-10 F157), so the two cannot drift.
|
|
69
100
|
*/
|
|
70
101
|
export function ourConversationIds(repoRoot, gitDirs = []) {
|
|
71
102
|
const ids = new Set();
|
|
72
103
|
const dirs = new Set(gitDirs.filter(Boolean));
|
|
73
104
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
timeout: 5_000,
|
|
79
|
-
}).trim()
|
|
80
|
-
);
|
|
105
|
+
// Bounded, and QUIET since 2026-10-10 (F107): this used to set no
|
|
106
|
+
// `stdio`, so git's "not a git repository" for a vanished cwd landed on
|
|
107
|
+
// the daemon's terminal from inside the poll loop's best-effort tail.
|
|
108
|
+
dirs.add(git(['rev-parse', '--absolute-git-dir'], repoRoot, { timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
81
109
|
} catch {
|
|
82
110
|
/* not a repo, or git unavailable — the fence is simply empty */
|
|
83
111
|
}
|
|
@@ -119,25 +147,10 @@ export function scanLocalSessions({ repoRoot, excludeDirs = [], excludeIds }) {
|
|
|
119
147
|
const mine = excludeIds instanceof Set ? excludeIds : new Set(excludeIds ?? []);
|
|
120
148
|
// Claude falls back to the literal root when it cannot be resolved; agy,
|
|
121
149
|
// whose cwd registry is global, reports nothing for an unresolvable root.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
realRoot = realpathSync(repoRoot);
|
|
126
|
-
} catch {
|
|
127
|
-
resolved = false;
|
|
128
|
-
realRoot = String(repoRoot ?? '');
|
|
129
|
-
}
|
|
150
|
+
const fence = adoptFence({ repoRoot, excludeDirs });
|
|
151
|
+
const { realRoot, resolved } = fence;
|
|
130
152
|
if (!realRoot) return [];
|
|
131
|
-
const
|
|
132
|
-
for (const d of excludeDirs) {
|
|
133
|
-
if (!d) continue;
|
|
134
|
-
try {
|
|
135
|
-
excludes.push(realpathSync(d));
|
|
136
|
-
} catch {
|
|
137
|
-
excludes.push(String(d)); // not on disk yet — keep the literal fence
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
|
|
153
|
+
const ours = (p) => fence.check(p) === 'ok';
|
|
141
154
|
const cutoff = Date.now() - ENDED_WINDOW_MS;
|
|
142
155
|
const claude = scanClaudeSessions({ realRoot, ours, mine, cutoff, cap: REPORT_CAP });
|
|
143
156
|
// agy rides in whatever room the cap leaves — Claude sessions first, they
|
package/bin/lib/login.mjs
CHANGED
|
@@ -6,18 +6,17 @@
|
|
|
6
6
|
* plain `flowviant` just runs — no token, no env var.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
|
|
9
|
+
import { APP_URL, FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
|
|
10
10
|
import { saveLogin, detectRepoRoot, projectLabel, safeName, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
|
|
11
11
|
import { c, info, ok, warn, fail } from './ui.mjs';
|
|
12
|
-
import { sleep } from './
|
|
12
|
+
import { sleep } from './sleep.mjs';
|
|
13
13
|
import { launchCommand, terminalCommand } from './launchCommand.mjs';
|
|
14
14
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
15
15
|
|
|
16
16
|
const DEVICE_START = fleetEndpoint('device/start', FLEET_URL);
|
|
17
17
|
const DEVICE_POLL = fleetEndpoint('device/poll', FLEET_URL);
|
|
18
|
-
const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
|
|
19
18
|
|
|
20
|
-
async function
|
|
19
|
+
async function postJson(url, body) {
|
|
21
20
|
const res = await fetch(url, {
|
|
22
21
|
method: 'POST',
|
|
23
22
|
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
|
|
@@ -29,7 +28,9 @@ async function post(url, body) {
|
|
|
29
28
|
return j.data ?? j;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
/** `post` is injectable so the device-code protocol can be driven without a
|
|
32
|
+
* server (login.test.mjs); every caller leaves it at the real one. */
|
|
33
|
+
export async function runLogin({ thenStart = false, json = false, dir = process.cwd(), post = postJson } = {}) {
|
|
33
34
|
const event = (value) => { if (json) process.stdout.write(`${JSON.stringify(value)}\n`); };
|
|
34
35
|
const selectedRepo = json ? detectRepoRoot(dir) : null;
|
|
35
36
|
if (json && !selectedRepo) { event({ event: 'error', message: `No git repository found in ${dir}.` }); return { saved: false }; }
|
|
@@ -42,10 +43,18 @@ export async function runLogin({ thenStart = false, json = false, dir = process.
|
|
|
42
43
|
fail(`couldn't reach Flowviant (${e.message}).`);
|
|
43
44
|
process.exit(1);
|
|
44
45
|
}
|
|
45
|
-
const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
const { deviceCode, userCode, intervalSeconds = 5, expiresInSeconds = 600 } = start ?? {};
|
|
47
|
+
// BOTH PRESENTERS CHECK THE SERVER'S ANSWER (2026-10-10, F109). The shape
|
|
48
|
+
// checks here and on the approval below were written for the tray's JSON
|
|
49
|
+
// path only, so the terminal path sliced an absent code into a TypeError
|
|
50
|
+
// stack and saved an approval with no project — `projects["undefined"]`,
|
|
51
|
+
// with the legacy mirror's working token overwritten by nothing. A
|
|
52
|
+
// malformed answer is a sentence and exit 1 in the terminal, as an
|
|
53
|
+
// unreachable server already was.
|
|
54
|
+
if (typeof deviceCode !== 'string' || typeof userCode !== 'string' || userCode.length < 8) {
|
|
55
|
+
if (json) { event({ event: 'error', message: 'Flowviant returned an invalid login code.' }); return { saved: false }; }
|
|
56
|
+
fail('Flowviant returned an invalid login code.');
|
|
57
|
+
process.exit(1);
|
|
49
58
|
}
|
|
50
59
|
const pretty = `${userCode.slice(0, 4)}-${userCode.slice(4)}`;
|
|
51
60
|
// Where the control ACTUALLY is. It was "the Agents panel", a settings
|
|
@@ -73,9 +82,10 @@ export async function runLogin({ thenStart = false, json = false, dir = process.
|
|
|
73
82
|
continue; // transient — keep polling
|
|
74
83
|
}
|
|
75
84
|
if (poll.status === 'approved') {
|
|
76
|
-
if (
|
|
77
|
-
event({ event: 'error', message: 'Flowviant did not return a project credential.' });
|
|
78
|
-
return
|
|
85
|
+
if (!poll.projectId || !(poll.machineToken ?? poll.fleetToken)) {
|
|
86
|
+
if (json) { event({ event: 'error', message: 'Flowviant did not return a project credential.' }); return { saved: false }; }
|
|
87
|
+
fail('Flowviant did not return a project credential.');
|
|
88
|
+
process.exit(1);
|
|
79
89
|
}
|
|
80
90
|
// `machineToken` is the wire's new name; `fleetToken` is the one every
|
|
81
91
|
// published daemon reads. The server dual-sends until DAEMON_MIN clears
|