flowviant 0.103.0 → 0.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +183 -2383
- 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 +2 -2
- 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/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 +17 -17
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3034
- 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 +91 -898
- 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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flowviant machines` — the command's whole body: the scripted verbs
|
|
3
|
+
* (`--forget`, `--remove`), the listing assembled from each stored
|
|
4
|
+
* credential's boxes, and, on a terminal, the ↑/↓ menu over this box's own
|
|
5
|
+
* connections.
|
|
6
|
+
*
|
|
7
|
+
* SPLIT OUT OF cli.mjs (SOLID audit 2026-09-26, F053): the entrypoint changed
|
|
8
|
+
* for terminal dispatch, for this menu, and for the start path's project
|
|
9
|
+
* binding. The menu is a stateful interaction with its own confirm and its own
|
|
10
|
+
* re-ask loop, so it lives here and cli.mjs keeps one line that dispatches to
|
|
11
|
+
* it. Moved verbatim; the imports stay lazy, as they were, so `--forget` still
|
|
12
|
+
* never loads config.mjs or reaches the network.
|
|
13
|
+
*
|
|
14
|
+
* The rules this body is pinned to (machines.test.mjs, tty.test.mjs): the menu
|
|
15
|
+
* is gated on BOTH `canPrompt()` and `menuSupported()`, `--remove` and the
|
|
16
|
+
* menu share one `disconnectHere`, the listing is re-asked after a verb, the
|
|
17
|
+
* keypair is read and never minted, and no prompt carries a time budget.
|
|
18
|
+
*/
|
|
19
|
+
import { terminalCommand } from './launchCommand.mjs';
|
|
20
|
+
|
|
21
|
+
// ── `flowviant machines` — every project connected on THIS box, and every box
|
|
22
|
+
// that has polled each of them; and, on a terminal, a menu over them.
|
|
23
|
+
//
|
|
24
|
+
// A THIRD COMMAND, and the owner's ruling it amends is his own: the terminal
|
|
25
|
+
// surface is `npx flowviant` and `npx flowviant login`, full stop — which is
|
|
26
|
+
// what deleted the old terminal flag for moving a machine — and he asked for
|
|
27
|
+
// this one directly ("the CLI gets a command to list connections"). It shipped
|
|
28
|
+
// VIEW-ONLY, and on 2026-09-23 he asked for the rest: "it seems to be only a
|
|
29
|
+
// read cmd but I want to be able to interact with it and use arrow keys to
|
|
30
|
+
// select the machines or to remove them." So: the listing, then — only where a
|
|
31
|
+
// person can drive one — a menu. Its verbs act on THIS BOX'S OWN CONNECTIONS
|
|
32
|
+
// (stop the daemon here, leave the project's list in the app, forget the
|
|
33
|
+
// credential here) and never on another computer's daemon; machineListing.mjs
|
|
34
|
+
// carries the argument.
|
|
35
|
+
//
|
|
36
|
+
// The question is the owner's, verbatim: "is there a way to view ALL the
|
|
37
|
+
// connected flowviants? because im not sure if i have any duplicate or
|
|
38
|
+
// redundant daemons running". It needs the NETWORK, unlike `projects` next
|
|
39
|
+
// door, because the boxes are the server's answer — this box cannot see a
|
|
40
|
+
// daemon running on somebody else's computer, and guessing would be worse than
|
|
41
|
+
// asking.
|
|
42
|
+
//
|
|
43
|
+
// ONE CALL PER STORED CREDENTIAL, because the CLI has NO PERSON IDENTITY: every
|
|
44
|
+
// request it can make is `Bearer <machine token>`, so "all my machines" has to
|
|
45
|
+
// be assembled from the credentials this box holds. That is also the limit the
|
|
46
|
+
// footer states out loud — the app's Home is where the whole account's answer
|
|
47
|
+
// lives.
|
|
48
|
+
//
|
|
49
|
+
// EXIT 0 whatever it finds, including nothing: the listing is the answer the
|
|
50
|
+
// asker came for, and a non-zero code over "you have no projects connected"
|
|
51
|
+
// would make this command unusable in anything that checks one. `--remove`
|
|
52
|
+
// is the one exit that can be 1, when the daemon it had to stop would not.
|
|
53
|
+
export async function runMachinesCommand() {
|
|
54
|
+
const creds = await import('./credentials.mjs');
|
|
55
|
+
const machineJson = process.argv.includes('--json');
|
|
56
|
+
const forgetAt = process.argv.indexOf('--forget');
|
|
57
|
+
if (forgetAt >= 0) {
|
|
58
|
+
if (machineJson && !process.argv.includes('--yes')) {
|
|
59
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
// THE LOCAL-ONLY WRITE. Named explicitly, matched by full id or a ≥6
|
|
63
|
+
// prefix, and AMBIGUITY REFUSES rather than guessing — this deletes a
|
|
64
|
+
// credential, and two projects that look alike is the case that produced
|
|
65
|
+
// the command.
|
|
66
|
+
const res = creds.forgetStoredProject(process.argv[forgetAt + 1]);
|
|
67
|
+
if (res.error) {
|
|
68
|
+
if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: res.error })}\n`); process.exit(1); }
|
|
69
|
+
console.error(`error: ${res.error}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: true, action: 'forget', projectId: res.entry.projectId })}\n`); process.exit(0); }
|
|
73
|
+
console.log(
|
|
74
|
+
`forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box.\n` +
|
|
75
|
+
` Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
|
|
76
|
+
);
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
const { boxesUrlFrom, leaveUrlFrom, fetchBoxesFor } = await import('./machines.mjs');
|
|
80
|
+
const { renderMachines, MACHINES_FOOTER, MACHINES_FLAGS_FOOTER } = await import('./machineListing.mjs');
|
|
81
|
+
const { disconnectHere, disconnectShortfall, realDisconnectDeps } = await import('./machineDisconnect.mjs');
|
|
82
|
+
const { FLEET_URL } = await import('./config.mjs');
|
|
83
|
+
const removeAt = process.argv.indexOf('--remove');
|
|
84
|
+
if (removeAt >= 0) {
|
|
85
|
+
if (machineJson && !process.argv.includes('--yes')) {
|
|
86
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
// THE SCRIPTED DISCONNECT — the menu's first verb, named by id for a
|
|
90
|
+
// terminal nobody is sitting at. Same matcher and the same refusal of an
|
|
91
|
+
// ambiguous prefix as --forget: a name two projects share is exactly the
|
|
92
|
+
// input this command exists to untangle, and it must not pick one.
|
|
93
|
+
const m = creds.matchStoredProject(process.argv[removeAt + 1]);
|
|
94
|
+
if (m.error) {
|
|
95
|
+
if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: m.error })}\n`); process.exit(1); }
|
|
96
|
+
console.error(`error: ${m.error}. \`${terminalCommand('machines')}\` lists what is stored.`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const res = await disconnectHere(m.entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log: machineJson ? () => {} : undefined }), { log: machineJson ? () => {} : undefined });
|
|
100
|
+
if (machineJson) {
|
|
101
|
+
// A refusal keeps its words for the caller (the tray reads stderr when
|
|
102
|
+
// the exit is nonzero): a daemon finishing a deploy is said, not "failed".
|
|
103
|
+
const why = res.ok ? null : (disconnectShortfall(res)?.reason ?? 'failed');
|
|
104
|
+
process.stdout.write(`${JSON.stringify({ ok: res.ok, action: 'disconnect', projectId: m.entry.projectId, ...(why ? { error: why } : {}) })}\n`);
|
|
105
|
+
if (why) process.stderr.write(`${why}\n`);
|
|
106
|
+
}
|
|
107
|
+
process.exit(res.ok ? 0 : 1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let entries = creds.listStoredProjects();
|
|
111
|
+
if (entries.length === 0) {
|
|
112
|
+
console.log(`no projects connected on this machine yet — run \`${terminalCommand('login')}\` inside a repo.`);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
// OUR OWN BOX ID, so the listing can mark "← this box".
|
|
116
|
+
//
|
|
117
|
+
// READ, NEVER CREATED. This called `ensureKeypair()` until 2026-09-19, which
|
|
118
|
+
// MINTS a keypair when there is no file — a 0600 write establishing this
|
|
119
|
+
// machine's durable identity, performed by a command whose entire job is to
|
|
120
|
+
// print a list. A view-only command is what let a third terminal command
|
|
121
|
+
// exist at all, and enrolling a box as a side effect of looking at one is not
|
|
122
|
+
// that. `readStoredPubB64` reads or answers null.
|
|
123
|
+
//
|
|
124
|
+
// NULL COSTS ONLY THE MARK, and that is the honest outcome: a box that has
|
|
125
|
+
// never run a daemon has never polled, so it is not in this listing to be
|
|
126
|
+
// marked in the first place.
|
|
127
|
+
let me;
|
|
128
|
+
try {
|
|
129
|
+
const env = await import('./boxIdentity.mjs');
|
|
130
|
+
me = env.readStoredPubB64() ?? undefined;
|
|
131
|
+
} catch {
|
|
132
|
+
/* unreadable keypair — nothing is marked, and nothing is claimed */
|
|
133
|
+
}
|
|
134
|
+
const url = boxesUrlFrom(FLEET_URL);
|
|
135
|
+
const listing = async () => {
|
|
136
|
+
const results = {};
|
|
137
|
+
// SEQUENTIAL, not a fan-out: this is a handful of credentials on somebody's
|
|
138
|
+
// laptop, and a parallel burst against the API buys nothing a person
|
|
139
|
+
// waiting two seconds can perceive.
|
|
140
|
+
for (const e of entries) {
|
|
141
|
+
results[e.projectId] = await fetchBoxesFor(e, { url, envpub: me });
|
|
142
|
+
}
|
|
143
|
+
console.log('');
|
|
144
|
+
for (const line of renderMachines(entries, results)) console.log(line);
|
|
145
|
+
console.log('');
|
|
146
|
+
};
|
|
147
|
+
await listing();
|
|
148
|
+
|
|
149
|
+
// THE MENU, only where a person can drive one. `canPrompt()` is the same
|
|
150
|
+
// gate the start path keeps — a backgrounded job or a pipe gets the listing
|
|
151
|
+
// and the flags, never a prompt that stops the process — and `menuSupported`
|
|
152
|
+
// is whether ↑/↓ can be read at all. Without both this is the command it was.
|
|
153
|
+
const { canPrompt, menuSupported, selectMenu, askWithTimeout } = await import('./tty.mjs');
|
|
154
|
+
if (!(canPrompt() && menuSupported())) {
|
|
155
|
+
for (const line of MACHINES_FOOTER) console.log(line);
|
|
156
|
+
for (const line of MACHINES_FLAGS_FOOTER) console.log(line);
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
const deps = await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL) });
|
|
160
|
+
for (;;) {
|
|
161
|
+
// A ROW PER PROJECT connected on this box — the things this box can act on.
|
|
162
|
+
// The other computers under each project are printed above for the
|
|
163
|
+
// duplicate to be visible, and are not rows here: stopping or removing
|
|
164
|
+
// THEM is the app's verb, and a menu row that answers "not from here" is
|
|
165
|
+
// a control wired to a refusal.
|
|
166
|
+
console.log(' projects connected on this box — enter one for what you can do about it:');
|
|
167
|
+
const rowLabel = (e) => creds.projectRowLabel(e, entries) + (e.repoRoot ? ` — ${e.repoRoot}` : ' — not tied to a repo');
|
|
168
|
+
const pick = await selectMenu({ options: [...entries.map(rowLabel), 'done'] });
|
|
169
|
+
if (pick.cancelled || pick.unsupported || pick.index === entries.length) break;
|
|
170
|
+
const picked = entries[pick.index];
|
|
171
|
+
const who = creds.projectRowLabel(picked, entries);
|
|
172
|
+
// TWO VERBS, each row saying its whole consequence — the app's ⋯ menu on a
|
|
173
|
+
// machine row says the same two things, and a menu whose rows are verbs
|
|
174
|
+
// with the outcome in a manual somewhere is one you have to be sure about
|
|
175
|
+
// before you can use it.
|
|
176
|
+
const action = await selectMenu({
|
|
177
|
+
options: [
|
|
178
|
+
`disconnect this box from ${who} — stops its daemon here, removes this box from its machines list in the app, forgets its credential here`,
|
|
179
|
+
`forget ${who} here only — nothing is stopped; the app keeps listing this box until it goes quiet`,
|
|
180
|
+
'back',
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
if (action.cancelled || action.unsupported || action.index === 2) continue;
|
|
184
|
+
// ONE CONFIRM, in words, because both verbs delete a credential and the
|
|
185
|
+
// arrow keys make a slip cheap. A read that failed (stdin gone) is "no".
|
|
186
|
+
const verb = action.index === 0 ? 'Disconnect this box from' : 'Forget';
|
|
187
|
+
const answer = await askWithTimeout(` ${verb} ${who}? [y/N] `);
|
|
188
|
+
if (!/^y(es)?$/i.test(answer ?? '')) {
|
|
189
|
+
console.log(' left as it was.\n');
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (action.index === 0) {
|
|
193
|
+
await disconnectHere(picked, deps, { log: (m) => console.log(` ${m}`) });
|
|
194
|
+
} else {
|
|
195
|
+
const res = creds.forgetStoredProject(picked.projectId);
|
|
196
|
+
console.log(
|
|
197
|
+
res.error
|
|
198
|
+
? ` ${res.error}`
|
|
199
|
+
: ` forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box. Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
entries = creds.listStoredProjects();
|
|
203
|
+
if (entries.length === 0) {
|
|
204
|
+
console.log('\n no projects are connected on this box now.\n');
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
// THE LISTING AGAIN, re-asked: the server's rows are the only true account
|
|
208
|
+
// of what the leave did, and a menu redrawn over a stale listing would be
|
|
209
|
+
// this command asserting the outcome it hoped for.
|
|
210
|
+
await listing();
|
|
211
|
+
}
|
|
212
|
+
for (const line of MACHINES_FOOTER) console.log(line);
|
|
213
|
+
process.exit(0);
|
|
214
|
+
}
|
package/bin/lib/mcp-cli.mjs
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
|
|
21
21
|
import { terminalCommand } from './launchCommand.mjs';
|
|
22
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
22
23
|
|
|
23
|
-
const CLI_TOKEN_URL =
|
|
24
|
+
const CLI_TOKEN_URL = fleetEndpoint('cli-token', FLEET_URL);
|
|
24
25
|
|
|
25
26
|
export async function runMcpCommand(args = []) {
|
|
26
27
|
if (!FLEET_TOKEN) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TAKING A LANDED PATCH BACK — the one live half of what used to be
|
|
3
|
+
* `patch.mjs`.
|
|
4
|
+
*
|
|
5
|
+
* `patch.mjs` held two lifecycles: patch PLACEMENT (cherry-picking a
|
|
6
|
+
* teammate's agent commit into the owner's checkout after a dirty-path
|
|
7
|
+
* collision check, plus a commit-history reader for the retired
|
|
8
|
+
* `report_commits` path) and patch REVERT. Nothing in the daemon has called
|
|
9
|
+
* placement or the history reader since the dispatch lane was deleted — the
|
|
10
|
+
* roster still carries `patchRevertJobs` for a patch that landed before then,
|
|
11
|
+
* and fleetJobs.mjs is the only caller of what is left. So the revert moved here
|
|
12
|
+
* under its own name and the uncalled half was deleted rather than kept as a
|
|
13
|
+
* second, stale commit-record parser nobody tests (SOLID audit 2026-09-26,
|
|
14
|
+
* F167). Git history has the placement code if a new argument ever wants it.
|
|
15
|
+
*
|
|
16
|
+
* One revert at a time per daemon: `withPatchLock` serialises every write this
|
|
17
|
+
* lane makes to the owner's tree, so two jobs cannot race in one checkout.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { git, isValidSha } from './git.mjs';
|
|
21
|
+
|
|
22
|
+
/** Serialises reverts within this process. */
|
|
23
|
+
let patchChain = Promise.resolve();
|
|
24
|
+
|
|
25
|
+
export function withPatchLock(fn) {
|
|
26
|
+
const run = patchChain.then(fn, fn);
|
|
27
|
+
// Keep the chain alive regardless of outcome, but don't swallow the result.
|
|
28
|
+
patchChain = run.then(
|
|
29
|
+
() => {},
|
|
30
|
+
() => {}
|
|
31
|
+
);
|
|
32
|
+
return run;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Undo a landed patch. `git revert` rather than `reset` on purpose: the owner
|
|
37
|
+
* has almost certainly committed or edited on top by now, and rewriting their
|
|
38
|
+
* history to take something back would be far worse than the patch was.
|
|
39
|
+
*/
|
|
40
|
+
export function revertPatch({ repoRoot, shas }) {
|
|
41
|
+
// These arrive over the roster and go straight into git argv. Anything that
|
|
42
|
+
// isn't a bare object id — a revision range, a leading-dash option — is
|
|
43
|
+
// refused here rather than trusted because the server said so.
|
|
44
|
+
const clean = (shas ?? []).filter(isValidSha);
|
|
45
|
+
if (clean.length === 0 || clean.length !== (shas ?? []).length) {
|
|
46
|
+
return { ok: false, error: 'refused: patch revert carried a non-sha value' };
|
|
47
|
+
}
|
|
48
|
+
const ordered = [...clean].reverse(); // newest first
|
|
49
|
+
try {
|
|
50
|
+
for (const sha of ordered) git(['revert', '--no-edit', sha], repoRoot);
|
|
51
|
+
return { ok: true };
|
|
52
|
+
} catch (e) {
|
|
53
|
+
try {
|
|
54
|
+
git(['revert', '--abort'], repoRoot);
|
|
55
|
+
} catch {
|
|
56
|
+
/* nothing in progress */
|
|
57
|
+
}
|
|
58
|
+
return { ok: false, error: e?.message ?? String(e) };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE GITHUB PULL-REQUEST RULES both merge paths share — adopt only an OPEN PR
|
|
3
|
+
* into the project's base, create one with `--fill`, merge with `--merge`, and
|
|
4
|
+
* believe "merged" only once the tip is measured on base.
|
|
5
|
+
*
|
|
6
|
+
* ── WHY ONE FILE (SOLID audit 2026-09-26, F040) ──
|
|
7
|
+
*
|
|
8
|
+
* The session PR lane (`workPullRequests.mjs`) and the agent approve path
|
|
9
|
+
* (`workAgentMerges.mjs`) each spelled these rules, forty lines apart in
|
|
10
|
+
* spirit and two files apart in fact, and they had already drifted once: the
|
|
11
|
+
* session copy adopted a PR into `staging` for months after the agent copy
|
|
12
|
+
* learned to refuse it, and it went without `gh` timeouts until 0.77.1. What
|
|
13
|
+
* is theirs stays theirs — which branch is pushed and how, the lease, the
|
|
14
|
+
* sentences each settles with, the job and the report — and everything that
|
|
15
|
+
* is a fact about GitHub lives here once:
|
|
16
|
+
*
|
|
17
|
+
* - ADOPT ONLY AN OPEN PR. gh's branch finder falls back to the most recent
|
|
18
|
+
* MERGED/CLOSED PR when no open one exists, and adopting a dead PR turns
|
|
19
|
+
* every later delivery on a long-lived branch into a silent black hole.
|
|
20
|
+
* - …AND ONLY ONE THAT TARGETS THE PROJECT'S BASE. A PR somebody opened by
|
|
21
|
+
* hand into another branch would be merged INTO that branch. Refused only
|
|
22
|
+
* on a MEASURED mismatch: an absent or empty `baseRefName` adopts as
|
|
23
|
+
* before. (The agent copy refused an empty string; the session copy
|
|
24
|
+
* adopted it. GitHub never sends one, and "measured" is the rule both
|
|
25
|
+
* copies stated, so the session spelling won.)
|
|
26
|
+
* - `--fill` titles a created PR from the branch's own commits — no model
|
|
27
|
+
* call, nothing invented — and the base is `baseBranchName`, never a
|
|
28
|
+
* remote-tracking name gh 422s on.
|
|
29
|
+
* - `--merge`, never squash and never rebase: the cards' receipts are commit
|
|
30
|
+
* shas, and a squash rewrites them off base, orphaning every receipt and
|
|
31
|
+
* blinding the landed walk's trailer read. "Already merged" is a success.
|
|
32
|
+
* - VERIFY before claiming merged: modern gh exits 0 on an already-MERGED PR
|
|
33
|
+
* and on a merge queue it exits 0 after ENQUEUEING. The tip being an
|
|
34
|
+
* ancestor of base is the fact "merged" claims — measured after a fetch,
|
|
35
|
+
* with one short retry for the fetch racing GitHub's merge commit.
|
|
36
|
+
*
|
|
37
|
+
* EVERY `gh` CALL IS TIMED OUT. `execFileSync` blocks the daemon's whole event
|
|
38
|
+
* loop, and the agent path runs inside the place writer lock — a `gh` that
|
|
39
|
+
* hangs (an expired token prompting, a network black hole, a hung credential
|
|
40
|
+
* helper) would stop every turn on the machine.
|
|
41
|
+
*
|
|
42
|
+
* `gh`, the fetch and the sleep are injectable so the rules are provable
|
|
43
|
+
* without GitHub; nothing in the daemon passes them except the fetch.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { execFileSync } from 'node:child_process';
|
|
47
|
+
import { git } from './git.mjs';
|
|
48
|
+
|
|
49
|
+
/** gh's own first line of complaint, capped — the words a failure relays. */
|
|
50
|
+
export const ghFirstLine = (e) =>
|
|
51
|
+
((e?.stderr?.toString?.() || e?.message || 'failed').split('\n').find((l) => l.trim()) ||
|
|
52
|
+
'failed')
|
|
53
|
+
.slice(0, 400);
|
|
54
|
+
|
|
55
|
+
/** A pull request URL worth storing. Anything else gh printed is not one. */
|
|
56
|
+
export const PR_URL_RE = /^https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+$/;
|
|
57
|
+
|
|
58
|
+
const runGh = (args, opts) => execFileSync('gh', args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts });
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Is `gh` installed and signed in? null when it is; otherwise
|
|
62
|
+
* `{ missing, error }` — `missing` when the binary is absent, so each caller
|
|
63
|
+
* can say "not installed" and "not signed in" in its own words.
|
|
64
|
+
*/
|
|
65
|
+
export function ghReady({ gh = runGh } = {}) {
|
|
66
|
+
try {
|
|
67
|
+
gh(['auth', 'status'], { timeout: 20_000 });
|
|
68
|
+
return null;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { missing: e?.code === 'ENOENT', error: ghFirstLine(e) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The OPEN pull request for `head`, as `{ url, base }` — or null when there
|
|
75
|
+
* is none, or only a merged/closed one. `base` is null when gh did not say. */
|
|
76
|
+
export function findOpenPr(head, { cwd, gh = runGh } = {}) {
|
|
77
|
+
try {
|
|
78
|
+
const j = JSON.parse(gh(['pr', 'view', head, '--json', 'url,state,baseRefName'], { cwd, timeout: 30_000 }).toString());
|
|
79
|
+
if (j?.state !== 'OPEN' || typeof j?.url !== 'string') return null;
|
|
80
|
+
return { url: j.url.trim(), base: typeof j?.baseRefName === 'string' ? j.baseRefName : null };
|
|
81
|
+
} catch {
|
|
82
|
+
return null; // no PR for the branch at all
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The branch an open PR targets when it is MEASURED to be not `base`, else
|
|
87
|
+
* null — the one refusal condition for adopting it. */
|
|
88
|
+
export function prTargetsOtherBase(pr, base) {
|
|
89
|
+
return pr && pr.base && pr.base !== base ? pr.base : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* THE OPEN PR FOR `head` INTO `base`: adopt one already open (a re-delivery,
|
|
94
|
+
* or one somebody opened by hand), or create it. Answers
|
|
95
|
+
* `{ url, adopted }` — url may be null if gh printed nothing usable
|
|
96
|
+
* `{ wrongBase }` — an open PR targets another branch; nothing created
|
|
97
|
+
* `{ error }` — gh's own words from a failed create
|
|
98
|
+
*/
|
|
99
|
+
export function ensureOpenPr(head, base, { cwd, gh = runGh } = {}) {
|
|
100
|
+
const existing = findOpenPr(head, { cwd, gh });
|
|
101
|
+
const wrongBase = prTargetsOtherBase(existing, base);
|
|
102
|
+
if (wrongBase) return { wrongBase };
|
|
103
|
+
if (existing?.url) return { url: existing.url, adopted: true };
|
|
104
|
+
try {
|
|
105
|
+
const out = gh(['pr', 'create', '--head', head, '--base', base, '--fill'], { cwd, timeout: 60_000 })
|
|
106
|
+
.toString()
|
|
107
|
+
.trim();
|
|
108
|
+
return { url: out.split('\n').filter(Boolean).pop() ?? null, adopted: false };
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return { error: ghFirstLine(e) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Is `tip` on `baseRef` yet? Fetch, then ask git; once more after a short
|
|
116
|
+
* pause if not, for the fetch racing GitHub's merge commit. An offline fetch
|
|
117
|
+
* answers from what the box already has. A null tip is never on base.
|
|
118
|
+
*/
|
|
119
|
+
export async function tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), gitImpl = git }) {
|
|
120
|
+
if (!tip) return false;
|
|
121
|
+
const onBase = () => {
|
|
122
|
+
try {
|
|
123
|
+
gitImpl(['merge-base', '--is-ancestor', tip, baseRef()], repoRoot);
|
|
124
|
+
return true;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
130
|
+
if (attempt > 0) await sleep(2000);
|
|
131
|
+
try {
|
|
132
|
+
fetchOrigin();
|
|
133
|
+
} catch {
|
|
134
|
+
/* offline — the check below answers from what we have */
|
|
135
|
+
}
|
|
136
|
+
if (onBase()) return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* MERGE `head`'s PR with a merge commit and VERIFY the tip reached base.
|
|
143
|
+
* Answers `{ ok: true }`, `{ error }` (gh refused, in its own words — an
|
|
144
|
+
* "already merged" is not a refusal), or `{ notOnBase: true }` (gh said yes
|
|
145
|
+
* and base does not have the tip: an older PR merged, or a queue holds it).
|
|
146
|
+
* The push that must precede it is the caller's: GitHub merges the REMOTE tip.
|
|
147
|
+
*/
|
|
148
|
+
export async function mergeAndVerifyTip(head, { cwd, tip, baseRef, repoRoot, fetchOrigin, gh = runGh, sleep, gitImpl }) {
|
|
149
|
+
try {
|
|
150
|
+
gh(['pr', 'merge', head, '--merge'], { cwd, timeout: 120_000 });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
const line = ghFirstLine(e);
|
|
153
|
+
if (!/already merged/i.test(line)) return { error: line };
|
|
154
|
+
}
|
|
155
|
+
const landed = await tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep, gitImpl });
|
|
156
|
+
return landed ? { ok: true } : { notOnBase: true };
|
|
157
|
+
}
|
package/bin/lib/preflight.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { ok, warn, info, c } from './ui.mjs';
|
|
9
9
|
import { addLocalBinToPath, promptYesNo, installClaude, installGh } from './install.mjs';
|
|
10
|
-
import { RUNTIMES,
|
|
10
|
+
import { RUNTIMES, mediatedSafeGap } from './runtimes.mjs';
|
|
11
|
+
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
11
12
|
import { terminalCommand } from './launchCommand.mjs';
|
|
12
13
|
|
|
13
14
|
function present(cmd) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PRESSURE GUARD'S DECISION — pure, and nothing else (2026-09-26, SOLID
|
|
3
|
+
* F050).
|
|
4
|
+
*
|
|
5
|
+
* Split out of resources.mjs, which MEASURES the box. The verdict used to read
|
|
6
|
+
* `FLOWVIANT_NO_PRESSURE_GUARD` and the three numeric overrides out of
|
|
7
|
+
* `process.env` itself while deciding from a measurement it was handed, so one
|
|
8
|
+
* measurement could yield two answers depending on ambient state, and every
|
|
9
|
+
* test of a threshold had to mutate the process's global environment.
|
|
10
|
+
*
|
|
11
|
+
* Now the thresholds are an ARGUMENT. `readPressureThresholds(env)` is the one
|
|
12
|
+
* adapter from an environment to them, and admission.mjs calls it per decision
|
|
13
|
+
* (never once at import), so an operator's override is still true of the
|
|
14
|
+
* environment the daemon is in RIGHT NOW rather than the one it booted in.
|
|
15
|
+
*
|
|
16
|
+
* Why the guard exists, and why it is a runaway bound and never a headroom
|
|
17
|
+
* meter, is argued at the top of resources.mjs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MiB = 1024 * 1024;
|
|
21
|
+
const GiB = 1024 * MiB;
|
|
22
|
+
|
|
23
|
+
/** What a box that nobody tuned is judged against. */
|
|
24
|
+
export const DEFAULT_PRESSURE_THRESHOLDS = Object.freeze({
|
|
25
|
+
off: false,
|
|
26
|
+
criticalFreeBytes: 400 * MiB,
|
|
27
|
+
minFreeBytes: 1024 * MiB,
|
|
28
|
+
maxLoadPerCore: 4,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** An operator's override, or the default. Positive finite numbers only: a
|
|
32
|
+
* typo'd `FLOWVIANT_MIN_FREE_MB=lots` must fall back to the default rather
|
|
33
|
+
* than turning every comparison into NaN, which compares false and would
|
|
34
|
+
* silently disable the guard it was trying to tune. */
|
|
35
|
+
const envNum = (env, name, fallback) => {
|
|
36
|
+
const n = Number(env?.[name]);
|
|
37
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The thresholds an environment states. `FLOWVIANT_NO_PRESSURE_GUARD=1` is the
|
|
42
|
+
* escape hatch for a box whose operator knows better than the thresholds;
|
|
43
|
+
* absence and `'0'` both mean the guard is on. The default parameter is
|
|
44
|
+
* evaluated per CALL, so a caller that passes nothing reads the live
|
|
45
|
+
* `process.env`.
|
|
46
|
+
*/
|
|
47
|
+
export function readPressureThresholds(env = process.env) {
|
|
48
|
+
const d = DEFAULT_PRESSURE_THRESHOLDS;
|
|
49
|
+
return {
|
|
50
|
+
off: env?.FLOWVIANT_NO_PRESSURE_GUARD === '1',
|
|
51
|
+
criticalFreeBytes: envNum(env, 'FLOWVIANT_CRITICAL_FREE_MB', d.criticalFreeBytes / MiB) * MiB,
|
|
52
|
+
minFreeBytes: envNum(env, 'FLOWVIANT_MIN_FREE_MB', d.minFreeBytes / MiB) * MiB,
|
|
53
|
+
maxLoadPerCore: envNum(env, 'FLOWVIANT_MAX_LOAD_PER_CORE', d.maxLoadPerCore),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sizeWord = (n) =>
|
|
58
|
+
n >= GiB ? `${(n / GiB).toFixed(1)} GB` : `${Math.round(n / MiB)} MB`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* IS THIS A MOMENT TO START ANOTHER CLI? — null when there is nothing to say.
|
|
62
|
+
*
|
|
63
|
+
* TWO LEVELS, because the two lanes are not the same promise. `churn` is the
|
|
64
|
+
* unattended work — agent turns, a Deploy press's planner, the wiki
|
|
65
|
+
* cartographer — which nobody is sitting in front of and which the server will
|
|
66
|
+
* cheerfully re-offer next poll, so it yields early and generously. A session
|
|
67
|
+
* turn is `interactive`: somebody is watching a composer they just pressed
|
|
68
|
+
* enter in, and deferring that is a visible stall, so it holds out until the
|
|
69
|
+
* box is genuinely about to fall over.
|
|
70
|
+
*
|
|
71
|
+
* THE REASON IS MEASURED WORDS AND NOTHING ELSE. No adjectives about the
|
|
72
|
+
* machine, no advice, no "try again in a few minutes" — a sentence naming the
|
|
73
|
+
* number that fired, which is the only thing this side actually knows.
|
|
74
|
+
*
|
|
75
|
+
* An unreadable measurement contributes NO verdict: memory that could not be
|
|
76
|
+
* read cannot refuse, and a load average this platform does not publish cannot
|
|
77
|
+
* either. A missing measurement altogether is the same ignorance. Ignorance
|
|
78
|
+
* never withholds.
|
|
79
|
+
*/
|
|
80
|
+
export function pressureVerdict(level, measured, thresholds = DEFAULT_PRESSURE_THRESHOLDS) {
|
|
81
|
+
const t = { ...DEFAULT_PRESSURE_THRESHOLDS, ...(thresholds ?? {}) };
|
|
82
|
+
if (t.off) return null;
|
|
83
|
+
const m = measured ?? null;
|
|
84
|
+
const avail = Number.isFinite(m?.memAvailable) ? m.memAvailable : null;
|
|
85
|
+
if (level === 'interactive') {
|
|
86
|
+
if (avail !== null && avail < t.criticalFreeBytes)
|
|
87
|
+
return { reason: `nearly out of memory — ${sizeWord(avail)} available` };
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const total = Number.isFinite(m?.memTotal) && m.memTotal > 0 ? m.memTotal : 0;
|
|
91
|
+
// A fixed floor AND a proportional one, whichever is larger: 1 GB is the
|
|
92
|
+
// right reserve on a laptop and is nothing on a 256 GB box, where six per
|
|
93
|
+
// cent is the honest "the page cache is already being squeezed" line.
|
|
94
|
+
const floor = Math.max(t.minFreeBytes, Math.round(total * 0.06));
|
|
95
|
+
if (avail !== null && avail < floor)
|
|
96
|
+
return { reason: `low memory — ${sizeWord(avail)} of ${sizeWord(total)} available` };
|
|
97
|
+
const cores = Number.isFinite(m?.cores) && m.cores > 0 ? m.cores : null;
|
|
98
|
+
const load = Number.isFinite(m?.load1) ? m.load1 : null;
|
|
99
|
+
if (load !== null && cores !== null && load > cores * t.maxLoadPerCore)
|
|
100
|
+
return {
|
|
101
|
+
reason: `cpu overloaded — load ${load.toFixed(1)} on ${cores} core${cores === 1 ? '' : 's'}`,
|
|
102
|
+
};
|
|
103
|
+
return null;
|
|
104
|
+
}
|