flowviant 0.89.0 → 0.91.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 +114 -5
- package/bin/lib/claude.mjs +60 -3
- package/bin/lib/config.mjs +18 -0
- package/bin/lib/credentials.mjs +78 -0
- package/bin/lib/env.mjs +25 -0
- package/bin/lib/fleet.mjs +195 -7
- package/bin/lib/machines.mjs +270 -0
- package/bin/lib/work.mjs +64 -1
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
* bare `npx flowviant` serves the project BOUND to the repo it is started in.
|
|
41
41
|
* Ambiguity is a picker on a TTY and a worded refusal headless — never a
|
|
42
42
|
* guess. `flowviant projects` lists what is stored; `--project <name|id>`
|
|
43
|
-
* picks without a prompt.
|
|
43
|
+
* picks without a prompt. `flowviant machines` (0.91.0) asks the server which
|
|
44
|
+
* BOXES have polled each of those credentials — the view-only answer to "am I
|
|
45
|
+
* running duplicate or redundant daemons?", which the store alone cannot give
|
|
46
|
+
* because a daemon on another computer is invisible from here.
|
|
44
47
|
*
|
|
45
48
|
* Env:
|
|
46
49
|
* FLOWVIANT_FLEET the machine credential (or use `flowviant login`).
|
|
@@ -195,8 +198,18 @@ if (process.argv[2] === 'projects') {
|
|
|
195
198
|
process.exit(0);
|
|
196
199
|
}
|
|
197
200
|
for (const e of entries) {
|
|
201
|
+
// THE DATE RIDES THE ID, always — this listing exists for the moment
|
|
202
|
+
// somebody is asking "why did it say skadooble?", and "which of these two
|
|
203
|
+
// did I set up last month" is the same question one step on. Absent when
|
|
204
|
+
// nothing recorded one (a credential stored before the field existed);
|
|
205
|
+
// nothing is invented.
|
|
206
|
+
const savedOn = e.savedAt ? new Date(e.savedAt) : null;
|
|
207
|
+
const connected =
|
|
208
|
+
savedOn && !Number.isNaN(savedOn.getTime())
|
|
209
|
+
? `, connected ${savedOn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`
|
|
210
|
+
: '';
|
|
198
211
|
console.log(
|
|
199
|
-
` ${projectLabel(e)} (${e.projectId.slice(0, 8)}
|
|
212
|
+
` ${projectLabel(e)} (${e.projectId.slice(0, 8)}…${connected})` +
|
|
200
213
|
`${e.repoRoot ? `\n repo · ${e.repoRoot}` : '\n repo · not bound yet — first start or login in its repo binds it'}` +
|
|
201
214
|
`${e.active ? '\n what a pre-0.55.0 flowviant on this box would serve (the legacy mirror)' : ''}`
|
|
202
215
|
);
|
|
@@ -208,6 +221,96 @@ if (process.argv[2] === 'projects') {
|
|
|
208
221
|
process.exit(0);
|
|
209
222
|
}
|
|
210
223
|
|
|
224
|
+
// ── `flowviant machines` — every project connected on THIS box, and every box
|
|
225
|
+
// that has polled each of them.
|
|
226
|
+
//
|
|
227
|
+
// A THIRD COMMAND, and the owner's ruling it amends is his own: the terminal
|
|
228
|
+
// surface is `npx flowviant` and `npx flowviant login`, full stop — which is
|
|
229
|
+
// what deleted the old terminal flag for moving a machine — and he asked for
|
|
230
|
+
// this one directly ("the CLI gets a command to list connections"). So it is
|
|
231
|
+
// amended in the narrowest form that ruling allows: VIEW-ONLY. It starts
|
|
232
|
+
// nothing, stops nothing, moves nothing, takes no lock and reaches no daemon.
|
|
233
|
+
// The only state it can change is this box's credential file, and only behind
|
|
234
|
+
// an explicit `--forget`.
|
|
235
|
+
//
|
|
236
|
+
// The question is the owner's, verbatim: "is there a way to view ALL the
|
|
237
|
+
// connected flowviants? because im not sure if i have any duplicate or
|
|
238
|
+
// redundant daemons running". It needs the NETWORK, unlike `projects` next
|
|
239
|
+
// door, because the boxes are the server's answer — this box cannot see a
|
|
240
|
+
// daemon running on somebody else's computer, and guessing would be worse than
|
|
241
|
+
// asking.
|
|
242
|
+
//
|
|
243
|
+
// ONE CALL PER STORED CREDENTIAL, because the CLI has NO PERSON IDENTITY: every
|
|
244
|
+
// request it can make is `Bearer <machine token>`, so "all my machines" has to
|
|
245
|
+
// be assembled from the credentials this box holds. That is also the limit the
|
|
246
|
+
// footer states out loud — the app's Home is where the whole account's answer
|
|
247
|
+
// lives.
|
|
248
|
+
//
|
|
249
|
+
// EXIT 0 whatever it finds, including nothing: the listing is the answer the
|
|
250
|
+
// asker came for, and a non-zero code over "you have no projects connected"
|
|
251
|
+
// would make this command unusable in anything that checks one.
|
|
252
|
+
if (process.argv[2] === 'machines') {
|
|
253
|
+
const creds = await import('./lib/credentials.mjs');
|
|
254
|
+
const forgetAt = process.argv.indexOf('--forget');
|
|
255
|
+
if (forgetAt >= 0) {
|
|
256
|
+
// THE ONE WRITE. Named explicitly, matched by full id or a ≥6 prefix, and
|
|
257
|
+
// AMBIGUITY REFUSES rather than guessing — this deletes a credential, and
|
|
258
|
+
// two projects that look alike is the case that produced the command.
|
|
259
|
+
const res = creds.forgetStoredProject(process.argv[forgetAt + 1]);
|
|
260
|
+
if (res.error) {
|
|
261
|
+
console.error(`error: ${res.error}`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
console.log(
|
|
265
|
+
`forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box.\n` +
|
|
266
|
+
' Nothing was stopped or deleted anywhere else — `flowviant login` connects it again.'
|
|
267
|
+
);
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const entries = creds.listStoredProjects();
|
|
272
|
+
if (entries.length === 0) {
|
|
273
|
+
console.log('no projects connected on this machine yet — run `flowviant login` inside a repo.');
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
const { boxesUrlFrom, fetchBoxesFor, renderMachines, MACHINES_FOOTER } = await import(
|
|
277
|
+
'./lib/machines.mjs'
|
|
278
|
+
);
|
|
279
|
+
const { FLEET_URL } = await import('./lib/config.mjs');
|
|
280
|
+
// OUR OWN BOX ID, so the listing can mark "← this box".
|
|
281
|
+
//
|
|
282
|
+
// READ, NEVER CREATED. This called `ensureKeypair()` until 2026-09-19, which
|
|
283
|
+
// MINTS a keypair when there is no file — a 0600 write establishing this
|
|
284
|
+
// machine's durable identity, performed by a command whose entire job is to
|
|
285
|
+
// print a list. A view-only command is what let a third terminal command
|
|
286
|
+
// exist at all, and enrolling a box as a side effect of looking at one is not
|
|
287
|
+
// that. `readStoredPubB64` reads or answers null.
|
|
288
|
+
//
|
|
289
|
+
// NULL COSTS ONLY THE MARK, and that is the honest outcome: a box that has
|
|
290
|
+
// never run a daemon has never polled, so it is not in this listing to be
|
|
291
|
+
// marked in the first place.
|
|
292
|
+
let me;
|
|
293
|
+
try {
|
|
294
|
+
const env = await import('./lib/env.mjs');
|
|
295
|
+
me = env.readStoredPubB64() ?? undefined;
|
|
296
|
+
} catch {
|
|
297
|
+
/* unreadable keypair — nothing is marked, and nothing is claimed */
|
|
298
|
+
}
|
|
299
|
+
const url = boxesUrlFrom(FLEET_URL);
|
|
300
|
+
const results = {};
|
|
301
|
+
// SEQUENTIAL, not a fan-out: this is a handful of credentials on somebody's
|
|
302
|
+
// laptop, and a parallel burst against the API buys nothing a person waiting
|
|
303
|
+
// two seconds can perceive.
|
|
304
|
+
for (const e of entries) {
|
|
305
|
+
results[e.projectId] = await fetchBoxesFor(e, { url, envpub: me });
|
|
306
|
+
}
|
|
307
|
+
console.log('');
|
|
308
|
+
for (const line of renderMachines(entries, results)) console.log(line);
|
|
309
|
+
console.log('');
|
|
310
|
+
for (const line of MACHINES_FOOTER) console.log(line);
|
|
311
|
+
process.exit(0);
|
|
312
|
+
}
|
|
313
|
+
|
|
211
314
|
// `flowviant env <import|set|show>` — the CLI half of team env sync. Values
|
|
212
315
|
// are sealed to the project pubkey ON THIS MACHINE (same write-only crypto as
|
|
213
316
|
// the browser); `show` decrypts locally — it only works on an ENROLLED machine.
|
|
@@ -268,11 +371,16 @@ async function reexecAfterLogin() {
|
|
|
268
371
|
process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
|
|
269
372
|
}
|
|
270
373
|
|
|
271
|
-
|
|
374
|
+
// TWO ROWS THAT READ ALIKE ARE NOT A CHOICE. `projectRowLabel` adds the id and
|
|
375
|
+
// the connected date to the COLLIDING rows only — production held two projects
|
|
376
|
+
// both called "BRIF AI", both bound to the same checkout, so this picker
|
|
377
|
+
// offered two identical lines and the only way to answer was to guess. See
|
|
378
|
+
// credentials.mjs for why the suffix is not on every row.
|
|
379
|
+
function listLines(entries, { projectRowLabel }) {
|
|
272
380
|
return entries
|
|
273
381
|
.map(
|
|
274
382
|
(e, i) =>
|
|
275
|
-
` ${i + 1}. ${
|
|
383
|
+
` ${i + 1}. ${projectRowLabel(e, entries)}` +
|
|
276
384
|
(e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet')
|
|
277
385
|
)
|
|
278
386
|
.join('\n');
|
|
@@ -321,7 +429,8 @@ if (!FLEET_TOKEN) {
|
|
|
321
429
|
let chosen = null; // 0-based into [...choices, login]
|
|
322
430
|
if (menuSupported()) {
|
|
323
431
|
const rowLabel = (e) =>
|
|
324
|
-
creds.
|
|
432
|
+
creds.projectRowLabel(e, choices) +
|
|
433
|
+
(e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
|
|
325
434
|
const options = [...choices.map(rowLabel), loginLabel];
|
|
326
435
|
// Say WHY the cursor starts where it does — "intuitive" made visible.
|
|
327
436
|
if (likely >= 0) options[likely] += ' ← looks like this repo';
|
package/bin/lib/claude.mjs
CHANGED
|
@@ -168,6 +168,43 @@ export const humanizeToolUse = humanizeClaudeTool;
|
|
|
168
168
|
// Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
|
|
169
169
|
const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* WHAT THIS TURN COST, IN THE CLI'S OWN NUMBERS (2026-09-19).
|
|
173
|
+
*
|
|
174
|
+
* The `result` event carries a `usage` object — `input_tokens`,
|
|
175
|
+
* `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` —
|
|
176
|
+
* and this reads exactly those four and nothing else. A RELAY: nothing here
|
|
177
|
+
* derives, estimates or prices anything.
|
|
178
|
+
*
|
|
179
|
+
* DELIBERATELY NOT `total_cost_usd`, which rides the same event. It is a
|
|
180
|
+
* notional list price that a subscription operator did not pay, so relaying it
|
|
181
|
+
* as "cost" would be the product asserting a figure nobody was charged — the
|
|
182
|
+
* refusal the board's fix pass already made once against the mock's "1.4
|
|
183
|
+
* spent". Tokens are measured; dollars are not.
|
|
184
|
+
*
|
|
185
|
+
* COERCED AND FLOORED, never trusted: a non-number, a NaN, an Infinity or a
|
|
186
|
+
* negative reads as 0, because these are summed into a counter that only ever
|
|
187
|
+
* goes up and one bad field must not poison the other three.
|
|
188
|
+
*
|
|
189
|
+
* Returns null when there is no usage object at all — the three-state rule
|
|
190
|
+
* every readout here keeps: a turn that reported nothing charges nothing, and
|
|
191
|
+
* that is not the same as a turn that reported zeros.
|
|
192
|
+
*/
|
|
193
|
+
export function usageFromResult(ev) {
|
|
194
|
+
const u = ev?.usage;
|
|
195
|
+
if (!u || typeof u !== 'object') return null;
|
|
196
|
+
const n = (v) => {
|
|
197
|
+
const x = Number(v);
|
|
198
|
+
return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0;
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
input: n(u.input_tokens),
|
|
202
|
+
output: n(u.output_tokens),
|
|
203
|
+
cacheCreate: n(u.cache_creation_input_tokens),
|
|
204
|
+
cacheRead: n(u.cache_read_input_tokens),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
171
208
|
// Parse ONE line of `--output-format stream-json` NDJSON into feed activities.
|
|
172
209
|
// Surfaces the WHOLE turn — thinking, narration, AND every tool — so neither the
|
|
173
210
|
// daemon console nor the app cover goes dark while Claude reasons (Opus thinks in
|
|
@@ -180,7 +217,15 @@ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n
|
|
|
180
217
|
// every intermediate text block still NARRATES, but only the final `result`
|
|
181
218
|
// event contributes text — otherwise the same sentences arrive twice, once as
|
|
182
219
|
// they stream and once in the result, and the tab posts the duplicate.
|
|
183
|
-
|
|
220
|
+
//
|
|
221
|
+
// EXPORTED FOR ITS TEST ONLY (2026-09-19), and the reason is worth stating: the
|
|
222
|
+
// `onUsage` threading through this function, `runTurn`'s options and `onLine`
|
|
223
|
+
// was four edits and NOT ONE of them was reachable from either suite — delete
|
|
224
|
+
// any one and both stay green, while the only symptom in production is a
|
|
225
|
+
// container that reports no spend, which is indistinguishable from an older
|
|
226
|
+
// daemon by design. A callback that is silent when it breaks has to be called
|
|
227
|
+
// directly by something.
|
|
228
|
+
export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage }) {
|
|
184
229
|
let ev;
|
|
185
230
|
try {
|
|
186
231
|
ev = JSON.parse(line);
|
|
@@ -262,6 +307,18 @@ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText
|
|
|
262
307
|
});
|
|
263
308
|
}
|
|
264
309
|
} else if (ev.type === 'result') {
|
|
310
|
+
/**
|
|
311
|
+
* WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
|
|
312
|
+
*
|
|
313
|
+
* FIRED ON A FAILED RESULT TOO, and that ordering is the point: a turn that
|
|
314
|
+
* hit a limit, ran out of permission or aborted still sent the requests it
|
|
315
|
+
* sent, and a spend readout that quietly skipped every unhappy turn would
|
|
316
|
+
* under-report exactly the runs somebody is looking at the number to
|
|
317
|
+
* understand. `usageFromResult` returns null when the event carries no
|
|
318
|
+
* usage at all, and a caller that passes no `onUsage` sees no change.
|
|
319
|
+
*/
|
|
320
|
+
const usage = usageFromResult(ev);
|
|
321
|
+
if (usage) onUsage?.(usage);
|
|
265
322
|
// The final assistant text (carries WIKI_DONE / REGROUND_DONE).
|
|
266
323
|
if (typeof ev.result === 'string') appendText(ev.result + '\n');
|
|
267
324
|
else if (ev.is_error || ev.subtype) {
|
|
@@ -294,7 +351,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText
|
|
|
294
351
|
// returned string for sentinel detection, and each activity is handed to
|
|
295
352
|
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
296
353
|
// off and keep the raw text passthrough + line sentinels.
|
|
297
|
-
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
354
|
+
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
298
355
|
return new Promise((resolve) => {
|
|
299
356
|
const rt = runtimeById(runtime);
|
|
300
357
|
if (!rt.args) {
|
|
@@ -425,7 +482,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
|
|
|
425
482
|
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
426
483
|
const onLine = (line) => {
|
|
427
484
|
if (!rt.parse)
|
|
428
|
-
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit });
|
|
485
|
+
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage });
|
|
429
486
|
const ev = rt.parse(line, cwd);
|
|
430
487
|
if (!ev) return;
|
|
431
488
|
// The conversation id, when the runtime announces one (codex's
|
package/bin/lib/config.mjs
CHANGED
|
@@ -264,6 +264,24 @@ export const MACHINE_HOST = (() => {
|
|
|
264
264
|
}
|
|
265
265
|
})();
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* WHEN THIS PROCESS STARTED (0.91.0), measured ONCE at import.
|
|
269
|
+
*
|
|
270
|
+
* It rides the poll as `st` beside `pid`, and together they are what somebody
|
|
271
|
+
* who has lost track of their daemons actually needs: the owner, verbatim, "im
|
|
272
|
+
* not sure if i have any duplicate or redundant daemons running". A pid alone
|
|
273
|
+
* cannot tell one long-lived daemon from a process that has restarted nine
|
|
274
|
+
* times since you last looked; a start time can.
|
|
275
|
+
*
|
|
276
|
+
* Derived from `process.uptime()` rather than stamped at import for its own
|
|
277
|
+
* sake, so it stays true across a lazy import and reads as the process's own
|
|
278
|
+
* age rather than this module's. Computed once, deliberately: a value that
|
|
279
|
+
* drifts by a millisecond every poll would rewrite the column for ever.
|
|
280
|
+
*/
|
|
281
|
+
export const PROCESS_STARTED_AT = new Date(
|
|
282
|
+
Date.now() - Math.max(0, process.uptime() * 1000)
|
|
283
|
+
).toISOString();
|
|
284
|
+
|
|
267
285
|
// The ONE credential. `tokens` (FLOWVIANT_TOKEN / FLOWVIANT_TOKENS / --token /
|
|
268
286
|
// --tokens) stood beside it and carried WORKER tokens into the pre-daemon loop;
|
|
269
287
|
// that principal owns zero tools since dispatch was deleted, and the kind can no
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -136,6 +136,42 @@ export function projectLabel(e) {
|
|
|
136
136
|
return e?.name ?? (e?.projectId ? `project ${e.projectId.slice(0, 8)}…` : 'an unnamed project');
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* THE SAME LABEL, DISAMBIGUATED — for a list where two rows can read alike
|
|
141
|
+
* (2026-09-19).
|
|
142
|
+
*
|
|
143
|
+
* Two projects genuinely named the same thing is not a hypothetical: production
|
|
144
|
+
* held two called "BRIF AI", both bound to the same checkout on one box, so
|
|
145
|
+
* `npx flowviant` there offered two IDENTICAL picker rows and the only way to
|
|
146
|
+
* choose between them was to guess. A picker whose options cannot be told apart
|
|
147
|
+
* is not a picker.
|
|
148
|
+
*
|
|
149
|
+
* THE SUFFIX IS ONLY ON THE COLLIDING ROWS. An id on every row would be noise
|
|
150
|
+
* on the ordinary case — one project per repo, nothing ambiguous — and noise is
|
|
151
|
+
* what makes the one row that matters unfindable. The DATE rides beside the id
|
|
152
|
+
* because it is the fact a person can actually match against their own memory
|
|
153
|
+
* ("I set that one up last month"), where two random-looking hex prefixes are
|
|
154
|
+
* two things to compare character by character.
|
|
155
|
+
*
|
|
156
|
+
* `flowviant machines` deliberately does the OPPOSITE and prints the id on
|
|
157
|
+
* every row: there you are reconciling a whole box's worth of credentials, and
|
|
158
|
+
* a suffix you have to notice before you know it matters has already made you
|
|
159
|
+
* do the work the listing exists to do for you.
|
|
160
|
+
*/
|
|
161
|
+
export function projectRowLabel(e, entries) {
|
|
162
|
+
const label = projectLabel(e);
|
|
163
|
+
const collides =
|
|
164
|
+
Array.isArray(entries) &&
|
|
165
|
+
entries.filter((x) => x !== e && projectLabel(x) === label).length > 0;
|
|
166
|
+
if (!collides) return label;
|
|
167
|
+
const d = e?.savedAt ? new Date(e.savedAt) : null;
|
|
168
|
+
const when =
|
|
169
|
+
d && !Number.isNaN(d.getTime())
|
|
170
|
+
? `, connected ${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`
|
|
171
|
+
: '';
|
|
172
|
+
return `${label} (id ${String(e?.projectId ?? '').slice(0, 8)}…${when})`;
|
|
173
|
+
}
|
|
174
|
+
|
|
139
175
|
/** Collapse a name or slug for loose comparison: "My Project", "my-project"
|
|
140
176
|
* and "myproject" all become "myproject". */
|
|
141
177
|
function normalizeName(s) {
|
|
@@ -213,6 +249,48 @@ export function selectStoredProject(projectId, { bindRepoRoot } = {}) {
|
|
|
213
249
|
});
|
|
214
250
|
}
|
|
215
251
|
|
|
252
|
+
/**
|
|
253
|
+
* FORGET A STORED CREDENTIAL — `flowviant machines --forget <id>` (2026-09-19).
|
|
254
|
+
*
|
|
255
|
+
* The one write this otherwise view-only command can make, and it edits THIS
|
|
256
|
+
* BOX'S credential file and nothing else: no server is called, no daemon is
|
|
257
|
+
* stopped, no project is deleted. It exists because a credential whose project
|
|
258
|
+
* was disconnected or deleted in the app 401s forever from here, and until now
|
|
259
|
+
* the only way to clear one was to hand-edit JSON under `~/.flowviant`.
|
|
260
|
+
*
|
|
261
|
+
* IT CLEARS THE LEGACY MIRROR TOO when the mirror names the entry being
|
|
262
|
+
* removed. Leaving it would resurrect the row on the next read —
|
|
263
|
+
* `listStoredProjects` surfaces the top-level trio as an entry whenever no map
|
|
264
|
+
* row carries its token — so the forget would silently not have happened, which
|
|
265
|
+
* is the worst outcome available here.
|
|
266
|
+
*
|
|
267
|
+
* MATCHES BY FULL ID OR A PREFIX OF AT LEAST SIX, the same rule
|
|
268
|
+
* `matchStoredProject` keeps, and AMBIGUITY IS A REFUSAL rather than a coin
|
|
269
|
+
* flip: this deletes a credential, and the case that produced the whole command
|
|
270
|
+
* is two projects that look alike.
|
|
271
|
+
*/
|
|
272
|
+
export function forgetStoredProject(ref) {
|
|
273
|
+
const q = String(ref ?? '').trim();
|
|
274
|
+
if (!q) return { error: 'empty --forget value' };
|
|
275
|
+
const hits = listStoredProjects().filter(
|
|
276
|
+
(e) => e.projectId === q || (q.length >= 6 && e.projectId.startsWith(q))
|
|
277
|
+
);
|
|
278
|
+
if (hits.length === 0) return { error: `no stored project matches "${q}"` };
|
|
279
|
+
if (hits.length > 1) {
|
|
280
|
+
return { error: `"${q}" matches ${hits.length} stored projects — use the full project id` };
|
|
281
|
+
}
|
|
282
|
+
const gone = hits[0];
|
|
283
|
+
mutate((f) => {
|
|
284
|
+
delete f.projects[gone.projectId];
|
|
285
|
+
if (f.projectId === gone.projectId) {
|
|
286
|
+
delete f.projectId;
|
|
287
|
+
delete f.fleetToken;
|
|
288
|
+
delete f.mcpUrl;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return { entry: gone };
|
|
292
|
+
}
|
|
293
|
+
|
|
216
294
|
export function bindStoredRepo(projectId, repoRoot) {
|
|
217
295
|
if (!repoRoot) return;
|
|
218
296
|
mutate((f) => {
|
package/bin/lib/env.mjs
CHANGED
|
@@ -146,6 +146,31 @@ export function myPubB64() {
|
|
|
146
146
|
return keypair ? sodium.to_base64(keypair.publicKey, B64()) : null;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* THIS BOX'S PUBLIC KEY, READ AND NEVER WRITTEN (2026-09-19, the review).
|
|
151
|
+
*
|
|
152
|
+
* `flowviant machines` is a VIEW-ONLY command — that narrowness is the whole
|
|
153
|
+
* reason a third terminal command was allowed to exist at all — and it was
|
|
154
|
+
* calling `ensureKeypair`, which on a box with no keypair CREATES ONE: a
|
|
155
|
+
* filesystem write, 0600, minting this machine's durable identity, from a
|
|
156
|
+
* command whose entire job is to print a list. Listing must not enrol.
|
|
157
|
+
*
|
|
158
|
+
* It reads the stored file and nothing else. No sodium, no parse of the private
|
|
159
|
+
* half, no creation. NULL for every failure — absent, unreadable, malformed —
|
|
160
|
+
* and null simply means no row gets the "← this box" mark, which is honest: a
|
|
161
|
+
* box that has never run a daemon has never polled, so it is not in the listing
|
|
162
|
+
* to be marked. Claiming a row is you on a guess would be worse than the mark
|
|
163
|
+
* being absent.
|
|
164
|
+
*/
|
|
165
|
+
export function readStoredPubB64() {
|
|
166
|
+
try {
|
|
167
|
+
const stored = JSON.parse(readFileSync(KEYPAIR_PATH, 'utf8'));
|
|
168
|
+
return typeof stored?.pub === 'string' && stored.pub ? stored.pub : null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
149
174
|
/** Query params the roster poll carries: identity, materialized version, and
|
|
150
175
|
* the target files we REFUSED to write.
|
|
151
176
|
*
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
SAFE,
|
|
26
26
|
DAEMON_INSTANCE,
|
|
27
27
|
MACHINE_HOST,
|
|
28
|
+
PROCESS_STARTED_AT,
|
|
28
29
|
POLL_SECONDS,
|
|
29
30
|
MAX_CONCURRENT,
|
|
30
31
|
IDLE_SECONDS,
|
|
@@ -93,6 +94,64 @@ import { claudeAuthContext } from './claudeAuth.mjs';
|
|
|
93
94
|
/** Said once per process — see the catch around `envQueryParams` below. */
|
|
94
95
|
let warnedEnvIdentity = false;
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* HAS THIS PROCESS ALREADY ASKED FOR THE MACHINE? (0.91.0)
|
|
99
|
+
*
|
|
100
|
+
* The owner's ruling on two boxes running one project: "it should kill the
|
|
101
|
+
* first one and take over". So a daemon asks on the FIRST poll of its life and
|
|
102
|
+
* never again, and this pair is the whole of that "never again".
|
|
103
|
+
*
|
|
104
|
+
* ONE ASK PER PROCESS IS THE SAFETY, NOT A POLITENESS. A param that kept asking
|
|
105
|
+
* would be two boxes trading a machine back and forth every ten seconds. Asked
|
|
106
|
+
* once, the story ends at the first exchange, because the box that loses
|
|
107
|
+
* SETTLES ITS TURNS AND EXITS rather than restarting — and a process that has
|
|
108
|
+
* exited sends no more first polls.
|
|
109
|
+
*
|
|
110
|
+
* THAT LAST SENTENCE IS TRUE OF THE PROCESS AND NOT OF THE UNIT, which is why
|
|
111
|
+
* the server stopped relying on it (2026-09-19). Under `Restart=always`, pm2 or
|
|
112
|
+
* docker, exit 0 is a relaunch and the relaunch's first poll is a genuine one.
|
|
113
|
+
* `takeMachineHolderNow` refuses a take by the box it just displaced for the
|
|
114
|
+
* length of the displaced window, so the ping-pong is bounded on the side that
|
|
115
|
+
* can see both boxes. This module still keeps its half of the bargain.
|
|
116
|
+
*
|
|
117
|
+
* SPENT ON A DELIVERED POLL, NOT ON AN ATTEMPTED ONE, which is why this is two
|
|
118
|
+
* functions instead of one consuming read. A daemon that starts while the wire
|
|
119
|
+
* is down retries every ten seconds; spending the ask on the failed attempt
|
|
120
|
+
* would mean the box you walked over to and started never takes the machine,
|
|
121
|
+
* and nothing anywhere would say why.
|
|
122
|
+
*
|
|
123
|
+
* IT IS NOT THE DELETED FLAG COMING BACK. There is still nothing to type: the
|
|
124
|
+
* terminal surface is `npx flowviant` and `npx flowviant login`, and what
|
|
125
|
+
* claims here is starting the daemon, which is already one of those two.
|
|
126
|
+
*
|
|
127
|
+
* Exported so both properties can be proved without a server, a credential or a
|
|
128
|
+
* second box — they are the contract, and nothing else in the file states them.
|
|
129
|
+
*/
|
|
130
|
+
/**
|
|
131
|
+
* AND A RESTART IS NOT A PERSON (2026-09-19, the review).
|
|
132
|
+
*
|
|
133
|
+
* `update.mjs` re-execs the daemon with `FLOWVIANT_REEXEC='1'` after an
|
|
134
|
+
* UNATTENDED auto-update, which is on by default. Born `false`, the new process
|
|
135
|
+
* would then spend a fresh ask and TAKE THE PROJECT'S MACHINE — so a standby
|
|
136
|
+
* sitting quietly on somebody's second computer would seize the machine off the
|
|
137
|
+
* live holder in the middle of the night, settling its running turns as moved,
|
|
138
|
+
* because npm published a patch. Nobody was at either keyboard, and nothing on
|
|
139
|
+
* any surface would say why.
|
|
140
|
+
*
|
|
141
|
+
* The ask belongs to the GESTURE, not to the process: what claims a machine is
|
|
142
|
+
* a person typing `npx flowviant`, and a re-exec is the same start continuing.
|
|
143
|
+
* So a re-executed daemon is born with the ask already spent and behaves
|
|
144
|
+
* exactly as it did before the update — it keeps the machine if it had it (arm
|
|
145
|
+
* (a) stamps it), and stands by if it did not.
|
|
146
|
+
*/
|
|
147
|
+
let machineAskSpent = process.env.FLOWVIANT_REEXEC === '1';
|
|
148
|
+
export function machineAskPending() {
|
|
149
|
+
return !machineAskSpent;
|
|
150
|
+
}
|
|
151
|
+
export function spendMachineAsk() {
|
|
152
|
+
machineAskSpent = true;
|
|
153
|
+
}
|
|
154
|
+
|
|
96
155
|
async function fetchRoster(
|
|
97
156
|
haveIds,
|
|
98
157
|
livePreviewSessionIds = [],
|
|
@@ -101,7 +160,12 @@ async function fetchRoster(
|
|
|
101
160
|
* every unattended lane asks — see the `pr` param below for why it is the
|
|
102
161
|
* admission and not the pressure reading alone. Undefined where the caller
|
|
103
162
|
* has no admission to offer, which reads exactly like an older daemon. */
|
|
104
|
-
churnHold = undefined
|
|
163
|
+
churnHold = undefined,
|
|
164
|
+
/** The checkout this daemon serves, for the `cp` report below. Passed in
|
|
165
|
+
* rather than re-derived: `repoRootOrDie` is resolved once at startup and
|
|
166
|
+
* running git on every poll to re-learn a constant would be a syscall for
|
|
167
|
+
* a readout. */
|
|
168
|
+
repoRoot = null
|
|
105
169
|
) {
|
|
106
170
|
const url = new URL(FLEET_URL);
|
|
107
171
|
if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
|
|
@@ -132,9 +196,51 @@ async function fetchRoster(
|
|
|
132
196
|
// Absent when the host has no readable name — an older daemon looks the same,
|
|
133
197
|
// and both mean "nobody said", which is what the nameless fallback renders.
|
|
134
198
|
if (MACHINE_HOST) url.searchParams.set('mh', MACHINE_HOST);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
199
|
+
/**
|
|
200
|
+
* WHICH CHECKOUT, WHICH PROCESS, AND SINCE WHEN (0.91.0) — the three facts
|
|
201
|
+
* that turn "a box polled" into a row somebody can act on.
|
|
202
|
+
*
|
|
203
|
+
* The owner could not answer a plain question about his own machines: "im not
|
|
204
|
+
* sure if i have any duplicate or redundant daemons running". The hostname
|
|
205
|
+
* alone cannot answer it either, because ONE box legitimately runs several
|
|
206
|
+
* daemons — "i can have 1 daemon in one directory, 1 in another as a
|
|
207
|
+
* differentiator of projects" — so `cp` is what makes two rows on one box
|
|
208
|
+
* legible, and `pid` + `st` are what let somebody standing at that box find
|
|
209
|
+
* the process and tell a long-lived daemon from one that has restarted nine
|
|
210
|
+
* times since they last looked.
|
|
211
|
+
*
|
|
212
|
+
* DAEMON→SERVER REPORTS, so no floor: an older daemon sends none of these and
|
|
213
|
+
* the server stores null, which reads as "it did not say" rather than as a
|
|
214
|
+
* box with no checkout. Bounded here as well as at the boundary — a query
|
|
215
|
+
* string is not a log, and this one is rendered into a terminal.
|
|
216
|
+
*/
|
|
217
|
+
if (repoRoot) url.searchParams.set('cp', String(repoRoot).slice(0, 256));
|
|
218
|
+
url.searchParams.set('pid', String(process.pid));
|
|
219
|
+
url.searchParams.set('st', PROCESS_STARTED_AT);
|
|
220
|
+
/**
|
|
221
|
+
* THE FIRST POLL OF THIS PROCESS ASKS FOR THE MACHINE (`claim`, 0.91.0).
|
|
222
|
+
*
|
|
223
|
+
* The owner, on two boxes running one project: "it should kill the first one
|
|
224
|
+
* and take over." Starting the daemon on the computer in front of you IS the
|
|
225
|
+
* gesture — there is no flag, no env var and no third command, so the ruling
|
|
226
|
+
* that deleted the old machine-moving flag ("i dont intend to run or do
|
|
227
|
+
* anything in the terminal besides npx flowviant or npx flowviant login")
|
|
228
|
+
* stands untouched.
|
|
229
|
+
*
|
|
230
|
+
* SENT ONCE PER PROCESS, and that is the safety rather than a politeness: see
|
|
231
|
+
* `askForMachineOnce`. The server takes holdership there and then, the box it
|
|
232
|
+
* displaces learns through the existing named-and-windowed `displaced` signal
|
|
233
|
+
* and exits 0, and nothing restarts to ask again.
|
|
234
|
+
*
|
|
235
|
+
* AN OLDER SERVER IGNORES IT and the old standby behaviour stands, which is
|
|
236
|
+
* why this needs no floor of its own — the server applies one (it will not
|
|
237
|
+
* take a machine from a box too old to be told it lost it).
|
|
238
|
+
*/
|
|
239
|
+
if (machineAskPending()) url.searchParams.set('claim', '1');
|
|
240
|
+
// A poll reports what this box IS. Moving the project's machine onto it, when
|
|
241
|
+
// this process has already spent its one ask above, is a gesture a person
|
|
242
|
+
// makes in the app — and the daemon learns that outcome on its next poll like
|
|
243
|
+
// every other holder fact.
|
|
138
244
|
// Which shares this machine is still serving. It rides the poll rather than
|
|
139
245
|
// taking an endpoint of its own: one beat, no floor, and the stale window is
|
|
140
246
|
// the reconcile interval instead of minutes — which matters, because a share
|
|
@@ -305,6 +411,12 @@ async function fetchRoster(
|
|
|
305
411
|
throw e;
|
|
306
412
|
}
|
|
307
413
|
if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
|
|
414
|
+
// THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
|
|
415
|
+
// answered. Spending it where the param is SET would lose the claim of a
|
|
416
|
+
// daemon that started while the wire was down: it would retry, silently
|
|
417
|
+
// without `claim`, and the box somebody walked over to and started would
|
|
418
|
+
// never take the machine.
|
|
419
|
+
spendMachineAsk();
|
|
308
420
|
const body = await res.json();
|
|
309
421
|
// Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
|
|
310
422
|
// throws a NORMAL retryable error inside the loop's try/catch, instead of a
|
|
@@ -600,6 +712,34 @@ export function createHolderWatch({ say = () => {} } = {}) {
|
|
|
600
712
|
return 'absent';
|
|
601
713
|
}
|
|
602
714
|
if (holder.mine === true) {
|
|
715
|
+
/**
|
|
716
|
+
* WE TOOK IT, AND THE SERVER SAID SO (0.91.0).
|
|
717
|
+
*
|
|
718
|
+
* Sent on the one poll that moved holdership and never again, so this
|
|
719
|
+
* is news by construction — no key to remember, no window to judge.
|
|
720
|
+
* RELAYED AND NEVER DERIVED: the box we displaced is named because the
|
|
721
|
+
* server named it, and an unnamed one gets the nameless fallback the
|
|
722
|
+
* rest of this file uses rather than a guess. The turns clause DROPS
|
|
723
|
+
* WHOLE when the count is absent — "0 turns were running" is a
|
|
724
|
+
* measurement nobody took, printed at the one moment somebody is
|
|
725
|
+
* deciding whether they have just interrupted their own work.
|
|
726
|
+
*/
|
|
727
|
+
const took = holder.took;
|
|
728
|
+
if (took && typeof took === 'object' && !Array.isArray(took)) {
|
|
729
|
+
standbyKey = null;
|
|
730
|
+
const from =
|
|
731
|
+
typeof took.from === 'string' && took.from.trim()
|
|
732
|
+
? took.from.trim().slice(0, 64)
|
|
733
|
+
: 'another machine';
|
|
734
|
+
const turns = Number.isInteger(took.turns) && took.turns > 0 ? took.turns : null;
|
|
735
|
+
say(
|
|
736
|
+
`took this project's machine from ${from}` +
|
|
737
|
+
(turns
|
|
738
|
+
? ` — ${turns} turn${turns === 1 ? '' : 's'} ${turns === 1 ? 'was' : 'were'} running there and ${turns === 1 ? 'was' : 'were'} settled as moved.`
|
|
739
|
+
: '.')
|
|
740
|
+
);
|
|
741
|
+
return 'mine';
|
|
742
|
+
}
|
|
603
743
|
if (standbyKey !== null) {
|
|
604
744
|
standbyKey = null;
|
|
605
745
|
say('this machine now serves the project.');
|
|
@@ -631,10 +771,28 @@ export function createHolderWatch({ say = () => {} } = {}) {
|
|
|
631
771
|
// rather than a zero — because "heard 0s ago" is a measurement nobody
|
|
632
772
|
// took, printed at the one moment the person is deciding whether to
|
|
633
773
|
// wait.
|
|
774
|
+
//
|
|
775
|
+
// THE PROMISE IS GONE (0.91.0), and its absence is the honest half.
|
|
776
|
+
// This sentence used to end "It moves here automatically once that
|
|
777
|
+
// machine has been quiet 10 minutes", which was true while starting a
|
|
778
|
+
// daemon did nothing but wait. Since 0.91.0 a process START asks for
|
|
779
|
+
// the machine on its first poll — the owner's ruling, "it should kill
|
|
780
|
+
// the first one and take over" — so reaching this branch at all means
|
|
781
|
+
// the ask was REFUSED or never spent. Four ways: an older server that
|
|
782
|
+
// does not honour it; a daemon this server will not hand a machine to;
|
|
783
|
+
// an UNATTENDED RE-EXEC, which is born with the ask spent because a
|
|
784
|
+
// restart is not a person; and — the one worth naming — a box this
|
|
785
|
+
// credential DISPLACED inside the last ten minutes, which the server
|
|
786
|
+
// refuses so that two supervised daemons cannot trade a machine back
|
|
787
|
+
// and forth forever (see `takeMachineHolderNow`). In that last case the
|
|
788
|
+
// right next move is a decision, not a wait, which is exactly what this
|
|
789
|
+
// sentence already points at. Restating the staleness clock instead
|
|
790
|
+
// would promise a handover on the one path where it is least likely to
|
|
791
|
+
// be what happens. So the line relays the fact and points at the door,
|
|
792
|
+
// which is the same door it always pointed at.
|
|
634
793
|
say(
|
|
635
794
|
`This project's machine is ${name ?? 'another machine'}${ago ? ` (heard ${ago} ago)` : ''}. ` +
|
|
636
|
-
|
|
637
|
-
"— or move it now from the app's project settings."
|
|
795
|
+
"This one is standing by — move it here from the app's project settings."
|
|
638
796
|
);
|
|
639
797
|
}
|
|
640
798
|
return 'standby';
|
|
@@ -1926,7 +2084,8 @@ export async function runFleetDaemon() {
|
|
|
1926
2084
|
buildHave(),
|
|
1927
2085
|
livePreviewIds(),
|
|
1928
2086
|
heldSessionIds(),
|
|
1929
|
-
admit('churn')
|
|
2087
|
+
admit('churn'),
|
|
2088
|
+
repoRoot
|
|
1930
2089
|
);
|
|
1931
2090
|
} catch (e) {
|
|
1932
2091
|
if (e.auth) {
|
|
@@ -1960,6 +2119,35 @@ export async function runFleetDaemon() {
|
|
|
1960
2119
|
// for a --fleet/env token (nothing stored to annotate).
|
|
1961
2120
|
setStoredProjectName(roster.project.id, roster.project.name);
|
|
1962
2121
|
}
|
|
2122
|
+
/**
|
|
2123
|
+
* WHAT ELSE IS SERVING THIS PROJECT — said ONCE, right after the first
|
|
2124
|
+
* poll lands, and never again (0.91.0).
|
|
2125
|
+
*
|
|
2126
|
+
* The owner could not answer a plain question about his own setup: "im
|
|
2127
|
+
* not sure if i have any duplicate or redundant daemons running". This is
|
|
2128
|
+
* the half of the answer that belongs in the terminal you just typed
|
|
2129
|
+
* into, because that is where you are standing when the question occurs
|
|
2130
|
+
* to you — the app's Home is where the whole account's answer lives.
|
|
2131
|
+
*
|
|
2132
|
+
* A RELAY, and it withholds nothing on failure: an older server has no
|
|
2133
|
+
* boxes route and 404s, which prints NOTHING rather than a complaint
|
|
2134
|
+
* about a feature nobody asked for. Fire-and-forget and never awaited on
|
|
2135
|
+
* the poll path — a listing must never delay a roster tick.
|
|
2136
|
+
*/
|
|
2137
|
+
void (async () => {
|
|
2138
|
+
try {
|
|
2139
|
+
const { boxesUrlFrom, fetchBoxesFor, otherBoxesLine } = await import('./machines.mjs');
|
|
2140
|
+
const res = await fetchBoxesFor(
|
|
2141
|
+
{ fleetToken: FLEET_TOKEN },
|
|
2142
|
+
{ url: boxesUrlFrom(FLEET_URL), envpub: myPubB64() ?? undefined }
|
|
2143
|
+
);
|
|
2144
|
+
if (!res.boxes) return;
|
|
2145
|
+
const line = otherBoxesLine(res.boxes, res.me);
|
|
2146
|
+
if (line) note(line);
|
|
2147
|
+
} catch {
|
|
2148
|
+
/* a readout — it must never disturb the daemon that produced it */
|
|
2149
|
+
}
|
|
2150
|
+
})();
|
|
1963
2151
|
}
|
|
1964
2152
|
if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
|
|
1965
2153
|
/**
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx flowviant machines` — every project connected on THIS box, and every box
|
|
3
|
+
* that has polled each of them (0.91.0).
|
|
4
|
+
*
|
|
5
|
+
* ── WHY A THIRD COMMAND EXISTS AT ALL ──
|
|
6
|
+
*
|
|
7
|
+
* The terminal surface is `npx flowviant` and `npx flowviant login`, full stop.
|
|
8
|
+
* That is the owner's own ruling and it is what deleted the terminal flag that
|
|
9
|
+
* used to move a project's machine. This amends it BY HIS OWN REQUEST — "the CLI gets a command to list connections" —
|
|
10
|
+
* and it is amended in the narrowest way the ruling can survive: this command
|
|
11
|
+
* is VIEW-ONLY. It starts nothing, stops nothing, moves nothing and takes no
|
|
12
|
+
* lock; the one thing it can change is the local credential file, and only
|
|
13
|
+
* behind an explicit `--forget`. Every decision is still made in the app.
|
|
14
|
+
*
|
|
15
|
+
* The question it answers is the owner's, verbatim: "is there a way to view ALL
|
|
16
|
+
* the connected flowviants? because im not sure if i have any duplicate or
|
|
17
|
+
* redundant daemons running". Measured the day it was asked: four live daemons
|
|
18
|
+
* across two boxes, two credentials last heard three days ago, and two projects
|
|
19
|
+
* both named "BRIF AI" bound to the same checkout — which is why `npx
|
|
20
|
+
* flowviant` there offered two identical picker rows. THE PROJECT ID IS
|
|
21
|
+
* THEREFORE ALWAYS PRINTED: it is the only thing that tells those two apart,
|
|
22
|
+
* and the whole listing is worth less without it.
|
|
23
|
+
*
|
|
24
|
+
* ── IT IS A RELAY ──
|
|
25
|
+
*
|
|
26
|
+
* The role words (`serving`, `standing by`, `idle`) are the SERVER'S, computed
|
|
27
|
+
* in one place so this command, project settings and the account's Home block
|
|
28
|
+
* cannot disagree about what a box is doing. Nothing here re-derives a state, and
|
|
29
|
+
* a project the server could not answer for says so rather than rendering as
|
|
30
|
+
* empty.
|
|
31
|
+
*
|
|
32
|
+
* ── RENDERING IS PURE, FETCHING IS NOT ──
|
|
33
|
+
*
|
|
34
|
+
* `renderMachines` takes the entries and the results and returns lines, so
|
|
35
|
+
* every case that matters — rejected, unreachable, an older server, no boxes,
|
|
36
|
+
* behind, this box — is provable without a network, a credential or a daemon.
|
|
37
|
+
* The wiring above it is the thin part on purpose.
|
|
38
|
+
*
|
|
39
|
+
* It deliberately imports NOTHING from fleet.mjs: that module pulls the whole
|
|
40
|
+
* daemon (the CLIs, the worktree machinery, the preview tunnels) and this
|
|
41
|
+
* command runs before the auth gate, like `stop` and `projects`. The small
|
|
42
|
+
* duration formatter below is a second copy of a ten-line function, knowingly,
|
|
43
|
+
* and it says different words anyway — "heard 12s ago" against "last heard 3d
|
|
44
|
+
* ago", where the word LAST is the staleness mark.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { USER_AGENT } from './config.mjs';
|
|
48
|
+
|
|
49
|
+
/** The boxes read, derived from the roster URL the way the diffstat post is —
|
|
50
|
+
* one place configures the API base and everything else is a suffix swap. */
|
|
51
|
+
export function boxesUrlFrom(fleetUrl) {
|
|
52
|
+
return String(fleetUrl).replace(/\/agents\/?$/, '/boxes');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The one answer `agoFrom` gives that is a PHRASE rather than a duration, so
|
|
56
|
+
* it is the one answer that must not take an " ago" after it. Named because
|
|
57
|
+
* two callers have to agree about it and a bare string literal is how they
|
|
58
|
+
* stopped agreeing. */
|
|
59
|
+
const JUST_NOW = 'just now';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A coarse age. Never "0s": a box heard a moment ago reads as "just now",
|
|
63
|
+
* because a daemon that polled four hundred milliseconds ago has not been
|
|
64
|
+
* silent for no time at all — the same rule `agoLabel` keeps in fleet.mjs and
|
|
65
|
+
* `elapsed` keeps in the web.
|
|
66
|
+
*
|
|
67
|
+
* NULL for anything unmeasurable, so the caller DROPS the clause rather than
|
|
68
|
+
* printing a duration nobody took.
|
|
69
|
+
*/
|
|
70
|
+
export function agoFrom(iso, now = Date.now()) {
|
|
71
|
+
const t = Date.parse(iso ?? '');
|
|
72
|
+
if (Number.isNaN(t)) return null;
|
|
73
|
+
const ms = Math.max(0, now - t);
|
|
74
|
+
const s = Math.round(ms / 1000);
|
|
75
|
+
if (s < 5) return JUST_NOW;
|
|
76
|
+
if (s < 90) return `${s}s`;
|
|
77
|
+
const m = Math.round(s / 60);
|
|
78
|
+
if (m < 90) return `${m}m`;
|
|
79
|
+
const h = Math.round(m / 60);
|
|
80
|
+
if (h < 36) return `${h}h`;
|
|
81
|
+
return `${Math.round(h / 24)}d`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* WHEN THIS BOX WAS LAST HEARD FROM, as a whole phrase — and the phrase is one
|
|
86
|
+
* function because the two callers got it WRONG THE SAME WAY (2026-09-19, the
|
|
87
|
+
* review).
|
|
88
|
+
*
|
|
89
|
+
* `agoFrom` answers with a DURATION for everything except the newest case,
|
|
90
|
+
* where it answers with the words "just now" — and both callers appended
|
|
91
|
+
* " ago", so the most common row in the listing, a daemon that polled four
|
|
92
|
+
* hundred milliseconds ago, read "heard just now ago". On the serving box. On
|
|
93
|
+
* the first line anybody looks at.
|
|
94
|
+
*
|
|
95
|
+
* Stated once here rather than fixed twice: a duration takes the suffix, a
|
|
96
|
+
* phrase does not, and "LAST heard" is the staleness mark the whole listing
|
|
97
|
+
* turns on. An unmeasurable stamp says "never heard" rather than printing a
|
|
98
|
+
* duration nobody took.
|
|
99
|
+
*/
|
|
100
|
+
export function heardPhrase(box, now = Date.now()) {
|
|
101
|
+
const ago = agoFrom(box?.lastHeardAt, now);
|
|
102
|
+
if (!ago) return 'never heard';
|
|
103
|
+
const lead = box?.fresh ? 'heard' : 'last heard';
|
|
104
|
+
return ago === JUST_NOW ? `${lead} ${ago}` : `${lead} ${ago} ago`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `savedAt` as a date somebody can match against their own memory of setting
|
|
108
|
+
* it up. Absent stays absent — a credential stored before the field existed
|
|
109
|
+
* has no date, and inventing one would be the listing asserting a day. */
|
|
110
|
+
export function connectedOn(iso) {
|
|
111
|
+
const t = Date.parse(iso ?? '');
|
|
112
|
+
if (Number.isNaN(t)) return null;
|
|
113
|
+
return new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The server's own role word, spelled for a terminal. Unknown values pass
|
|
117
|
+
* through untouched: this is a relay, and a role this build has never heard of
|
|
118
|
+
* is still the server's answer. */
|
|
119
|
+
function roleWord(role) {
|
|
120
|
+
return role === 'standing-by' ? 'standing by' : String(role ?? 'idle');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One box, as a line under its project. The MARK is the only derived thing on
|
|
124
|
+
* it — a filled dot for a box the server still calls fresh, hollow for one it
|
|
125
|
+
* does not — and the words beside it are all the server's or the box's own. */
|
|
126
|
+
export function renderBox(box, { me = null, latest = null } = {}, now = Date.now()) {
|
|
127
|
+
const mark = box.fresh ? '●' : '○';
|
|
128
|
+
const name = box.boxName || 'an unnamed machine';
|
|
129
|
+
// "LAST heard" is the staleness mark, and the row is never hidden for it —
|
|
130
|
+
// the owner's answer to "what about a box that stopped" was exactly this.
|
|
131
|
+
const heard = heardPhrase(box, now);
|
|
132
|
+
const cells = [
|
|
133
|
+
`${mark} ${name}`,
|
|
134
|
+
roleWord(box.role),
|
|
135
|
+
box.checkoutPath || '(checkout not reported)',
|
|
136
|
+
box.daemonVersion ? `v${box.daemonVersion}` : 'version not reported',
|
|
137
|
+
heard,
|
|
138
|
+
];
|
|
139
|
+
let line = ` ${cells.join(' ')}`;
|
|
140
|
+
if (me && box.boxId === me) line += ' ← this box';
|
|
141
|
+
// Said only when the server said it, and it names the version that is
|
|
142
|
+
// actually published rather than a feature floor — a floor may sit above
|
|
143
|
+
// LATEST on purpose, and an instruction to install something npm does not
|
|
144
|
+
// serve is worse than silence.
|
|
145
|
+
if (box.behind && latest) line += ` · behind, ${latest} is latest`;
|
|
146
|
+
return line;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* THE WHOLE LISTING, as lines. Pure over (entries, results, now).
|
|
151
|
+
*
|
|
152
|
+
* `results` is keyed by projectId and each value is one of:
|
|
153
|
+
* { boxes, latest, me } the server answered
|
|
154
|
+
* { rejected: true } 401/403 — the credential is dead here
|
|
155
|
+
* { unsupported: true } 404 — an older server with no boxes route
|
|
156
|
+
* { error: '<words>' } anything else, relayed rather than summarised
|
|
157
|
+
* A project with no entry in `results` is treated as unreachable, because that
|
|
158
|
+
* is the only thing an absent answer can honestly mean.
|
|
159
|
+
*/
|
|
160
|
+
export function renderMachines(entries, results, { now = Date.now() } = {}) {
|
|
161
|
+
const lines = [];
|
|
162
|
+
// WHICH LABELS COLLIDE. Two projects genuinely called the same thing is the
|
|
163
|
+
// case that produced this command, so the id carries it — and it is printed
|
|
164
|
+
// on EVERY row rather than only the colliding ones, because a reader who has
|
|
165
|
+
// to notice a suffix to know whether it matters has already been asked to do
|
|
166
|
+
// the work this listing is for.
|
|
167
|
+
for (const e of entries) {
|
|
168
|
+
const label = e.name || `project ${e.projectId.slice(0, 8)}…`;
|
|
169
|
+
const connected = connectedOn(e.savedAt);
|
|
170
|
+
const id = `(id ${e.projectId.slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
|
|
171
|
+
lines.push(
|
|
172
|
+
` ${label} · ${e.repoRoot || 'not bound to a repo yet'} ${id}`
|
|
173
|
+
);
|
|
174
|
+
const r = results?.[e.projectId];
|
|
175
|
+
if (!r || r.error) {
|
|
176
|
+
// The failure is RELAYED, not summarised, and the loop carries on: one
|
|
177
|
+
// unreachable project must not cost you the listing for the others.
|
|
178
|
+
lines.push(` could not ask the server — ${r?.error ?? 'no answer'}`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (r.rejected) {
|
|
182
|
+
lines.push(
|
|
183
|
+
' credential rejected — this project was disconnected or deleted. ' +
|
|
184
|
+
`\`flowviant machines --forget ${e.projectId.slice(0, 8)}\` removes it here.`
|
|
185
|
+
);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (r.unsupported) {
|
|
189
|
+
lines.push(' server does not report machines yet');
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const boxes = Array.isArray(r.boxes) ? r.boxes : [];
|
|
193
|
+
if (boxes.length === 0) {
|
|
194
|
+
// NOT "no machines". Nothing has polled this credential, which is a
|
|
195
|
+
// different fact from a project whose machines have all gone quiet — and
|
|
196
|
+
// a quiet one still has rows, wearing its last-heard.
|
|
197
|
+
lines.push(' (no machine has polled this project)');
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
for (const b of boxes) lines.push(renderBox(b, { me: r.me, latest: r.latest }, now));
|
|
201
|
+
}
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* THE FOOTER. Two sentences, both of which exist because this listing is the
|
|
207
|
+
* moment somebody is confused — the same reason `flowviant projects` names its
|
|
208
|
+
* remedies inline rather than in a manual.
|
|
209
|
+
*
|
|
210
|
+
* The first says where the verbs are, because there are none here. The second
|
|
211
|
+
* states this command's own SCOPE, which is the one thing a reader cannot see
|
|
212
|
+
* from the output: it lists what is connected on THIS box, and a daemon running
|
|
213
|
+
* on a computer that has never met this one is invisible to it. The app's Home
|
|
214
|
+
* is where "all of them" lives, and pretending otherwise would be this listing
|
|
215
|
+
* quietly answering a narrower question than the one asked.
|
|
216
|
+
*/
|
|
217
|
+
export const MACHINES_FOOTER = [
|
|
218
|
+
' stop a machine from the app: project settings → Machine → Disconnect;',
|
|
219
|
+
' a new `npx flowviant` on another box takes the project over.',
|
|
220
|
+
' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Ask one credential for its boxes. Never throws: every outcome is a shape the
|
|
225
|
+
* renderer knows, because one dead project must not end the listing.
|
|
226
|
+
*
|
|
227
|
+
* A 404 is an older SERVER rather than a missing project — the route is new —
|
|
228
|
+
* and it is kept apart from a 401, which is a credential this box should
|
|
229
|
+
* probably forget. Two different next moves; collapsing them is how somebody
|
|
230
|
+
* deletes a working credential.
|
|
231
|
+
*/
|
|
232
|
+
export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch } = {}) {
|
|
233
|
+
const target = new URL(url);
|
|
234
|
+
if (envpub) target.searchParams.set('envpub', envpub);
|
|
235
|
+
try {
|
|
236
|
+
const res = await fetchImpl(target, {
|
|
237
|
+
headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
|
|
238
|
+
signal: AbortSignal.timeout(15_000),
|
|
239
|
+
});
|
|
240
|
+
if (res.status === 401 || res.status === 403) return { rejected: true };
|
|
241
|
+
if (res.status === 404) return { unsupported: true };
|
|
242
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
243
|
+
const body = await res.json();
|
|
244
|
+
const data = body?.data;
|
|
245
|
+
if (!data || !Array.isArray(data.boxes)) return { error: 'unexpected answer shape' };
|
|
246
|
+
return { boxes: data.boxes, latest: data.latest ?? null, me: data.me ?? null };
|
|
247
|
+
} catch (e) {
|
|
248
|
+
return { error: String(e?.message ?? e) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* THE OTHER BOXES ON THIS PROJECT, as ONE line for the daemon's startup — or
|
|
254
|
+
* null when there are none, which is the common case and must print nothing.
|
|
255
|
+
*
|
|
256
|
+
* Said ONCE per process, after the first successful poll, and never per poll:
|
|
257
|
+
* the fact only changes when somebody starts a daemon somewhere else, and a
|
|
258
|
+
* true sentence restated every ten seconds is a scrolling console nobody reads.
|
|
259
|
+
* THE CALLING BOX IS EXCLUDED — it is the one box the person at that terminal
|
|
260
|
+
* does not need to be told about.
|
|
261
|
+
*/
|
|
262
|
+
export function otherBoxesLine(boxes, me, now = Date.now()) {
|
|
263
|
+
const others = (Array.isArray(boxes) ? boxes : []).filter((b) => b && b.boxId !== me);
|
|
264
|
+
if (others.length === 0) return null;
|
|
265
|
+
const parts = others.slice(0, 6).map(
|
|
266
|
+
(b) => `${b.boxName || 'an unnamed machine'} (${roleWord(b.role)} · ${heardPhrase(b, now)})`
|
|
267
|
+
);
|
|
268
|
+
const more = others.length - parts.length;
|
|
269
|
+
return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
|
|
270
|
+
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -4885,6 +4885,21 @@ export function createWorkManager({
|
|
|
4885
4885
|
|
|
4886
4886
|
let out = '';
|
|
4887
4887
|
let child = null;
|
|
4888
|
+
/**
|
|
4889
|
+
* WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
|
|
4890
|
+
*
|
|
4891
|
+
* Held across the whole turn so every settle below can carry it — a turn
|
|
4892
|
+
* that hit a limit, produced no parseable outcome or delivered properly
|
|
4893
|
+
* all spent real tokens, and a readout that only charged the happy path
|
|
4894
|
+
* would under-report the runs somebody opens the number to understand.
|
|
4895
|
+
*
|
|
4896
|
+
* The three settles it is spread into are the ones that follow a CLI
|
|
4897
|
+
* actually running. The pre-spawn refusals above (a bad place, a missing
|
|
4898
|
+
* card, the begun-guard) and the teardown sweep below deliberately do NOT
|
|
4899
|
+
* take it: nothing ran there, and `usage` stays null anyway, which is
|
|
4900
|
+
* what makes `...(usage ? … : {})` the whole guard.
|
|
4901
|
+
*/
|
|
4902
|
+
let usage = null;
|
|
4888
4903
|
try {
|
|
4889
4904
|
out = await runTurn({
|
|
4890
4905
|
prompt:
|
|
@@ -4944,6 +4959,13 @@ export function createWorkManager({
|
|
|
4944
4959
|
onToolEvent: (name, input) => {
|
|
4945
4960
|
trace.tool(toolEventOf(name, input, wt, envScrub));
|
|
4946
4961
|
},
|
|
4962
|
+
// The CLI's own per-turn token counts, off the `result` event. One
|
|
4963
|
+
// result per turn, so this is a set and not an accumulate — the
|
|
4964
|
+
// adding-up happens SERVER-side, behind the settle's idempotent win,
|
|
4965
|
+
// because a retried settle must not charge the same turn twice.
|
|
4966
|
+
onUsage: (u) => {
|
|
4967
|
+
usage = u;
|
|
4968
|
+
},
|
|
4947
4969
|
onSpawn: (ch) => {
|
|
4948
4970
|
child = ch;
|
|
4949
4971
|
// The AGENT it serves — what the machine snapshot charges this
|
|
@@ -5015,7 +5037,14 @@ export function createWorkManager({
|
|
|
5015
5037
|
// limit means all of them have. The turn itself is reported as
|
|
5016
5038
|
// `nothing` — it did not deliver and it did not ask.
|
|
5017
5039
|
await postAgentParked(limit);
|
|
5018
|
-
await postAgentTurn({
|
|
5040
|
+
await postAgentTurn({
|
|
5041
|
+
turnId,
|
|
5042
|
+
outcome: 'nothing',
|
|
5043
|
+
answer: limit,
|
|
5044
|
+
...(usage ? { usage } : {}),
|
|
5045
|
+
branch,
|
|
5046
|
+
worktree: wt,
|
|
5047
|
+
});
|
|
5019
5048
|
return;
|
|
5020
5049
|
}
|
|
5021
5050
|
|
|
@@ -5029,6 +5058,7 @@ export function createWorkManager({
|
|
|
5029
5058
|
? envScrub(out).slice(-1500)
|
|
5030
5059
|
: 'the turn produced no output on the machine — its CLI may be signed out',
|
|
5031
5060
|
...(commits.length ? { commits } : {}),
|
|
5061
|
+
...(usage ? { usage } : {}),
|
|
5032
5062
|
branch,
|
|
5033
5063
|
worktree: wt,
|
|
5034
5064
|
});
|
|
@@ -5040,6 +5070,7 @@ export function createWorkManager({
|
|
|
5040
5070
|
answer: envScrub(res.answer ?? '').slice(0, 8000),
|
|
5041
5071
|
...(commits.length ? { commits } : {}),
|
|
5042
5072
|
...(res.raised?.length ? { raised: res.raised } : {}),
|
|
5073
|
+
...(usage ? { usage } : {}),
|
|
5043
5074
|
branch,
|
|
5044
5075
|
worktree: wt,
|
|
5045
5076
|
});
|
|
@@ -5450,6 +5481,17 @@ export function createWorkManager({
|
|
|
5450
5481
|
* and is retried ONCE, because unlike a trace batch this body cost a whole
|
|
5451
5482
|
* model call and losing it to a blip means the operator paid for a label
|
|
5452
5483
|
* nobody ever sees.
|
|
5484
|
+
*
|
|
5485
|
+
* THE RETRY IS NOT A GUARANTEE THAT THE FIRST ATTEMPT FAILED, and since the
|
|
5486
|
+
* body started carrying `usage` (0.90.0) that matters. The `catch` below
|
|
5487
|
+
* takes the 30-second `AbortSignal` with everything else, so a server that
|
|
5488
|
+
* was merely SLOW — and committed — receives this identical body twice. The
|
|
5489
|
+
* store was always idempotent; the token counts riding it were not, and a
|
|
5490
|
+
* timed-out-but-committed post added them twice. The SERVER closes that: its
|
|
5491
|
+
* write refuses a body whose text already sits on the row, so a re-send
|
|
5492
|
+
* stores the same reading and charges nothing for it. Nothing here needs to
|
|
5493
|
+
* change, and nothing here may start assuming a thrown fetch means the
|
|
5494
|
+
* server never saw this.
|
|
5453
5495
|
*/
|
|
5454
5496
|
const postPre = async (body) => {
|
|
5455
5497
|
try {
|
|
@@ -5552,6 +5594,21 @@ export function createWorkManager({
|
|
|
5552
5594
|
let timer = null;
|
|
5553
5595
|
/** The cap fired: the CLI was still running when this machine stopped it. */
|
|
5554
5596
|
let wedged = false;
|
|
5597
|
+
/**
|
|
5598
|
+
* WHAT THE READING SPENT — the agent-turn lane's own relay, on the one
|
|
5599
|
+
* other lane that charges an AGENT (2026-09-19).
|
|
5600
|
+
*
|
|
5601
|
+
* It rides the agent and never a card link: a pre-review belongs to no card
|
|
5602
|
+
* (the same reason `workChildren` gets a null task id below), so the four
|
|
5603
|
+
* counters it adds to are the container's alone. That is also why an
|
|
5604
|
+
* agent's links can never sum to its total, and the schema says so.
|
|
5605
|
+
*
|
|
5606
|
+
* A WEDGED OR UNPARSEABLE READING POSTS NOTHING AT ALL, so it charges
|
|
5607
|
+
* nothing either — which under-reports a turn that really did spend. That
|
|
5608
|
+
* is the honest direction: there is no row to put it on, and inventing a
|
|
5609
|
+
* settle to carry a number would be a post whose only content is a bill.
|
|
5610
|
+
*/
|
|
5611
|
+
let usage = null;
|
|
5555
5612
|
/**
|
|
5556
5613
|
* THE CONVERSATION THIS READING SPEAKS UNDER — held for exactly one reason:
|
|
5557
5614
|
* to DELETE the transcript it leaves behind (review, 2026-09-17).
|
|
@@ -5612,6 +5669,11 @@ export function createWorkManager({
|
|
|
5612
5669
|
if (typeof i.sessionId === 'string' && i.sessionId.trim())
|
|
5613
5670
|
preSession = i.sessionId.trim();
|
|
5614
5671
|
},
|
|
5672
|
+
// The CLI's own count for this reading. Set, never accumulated: one
|
|
5673
|
+
// turn is one `result` event, and the adding-up is the server's.
|
|
5674
|
+
onUsage: (u) => {
|
|
5675
|
+
usage = u;
|
|
5676
|
+
},
|
|
5615
5677
|
onSpawn: (ch) => {
|
|
5616
5678
|
child = ch;
|
|
5617
5679
|
// No task id: a pre-review belongs to no card, and the machine
|
|
@@ -5710,6 +5772,7 @@ export function createWorkManager({
|
|
|
5710
5772
|
const body = {
|
|
5711
5773
|
agentId,
|
|
5712
5774
|
...(headSha ? { headSha } : {}),
|
|
5775
|
+
...(usage ? { usage } : {}),
|
|
5713
5776
|
cards: result.cards.map((cd) => ({
|
|
5714
5777
|
taskId: cd.taskId,
|
|
5715
5778
|
verdict: cd.verdict,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.91.0",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|