flowviant 0.58.0 → 0.59.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/lib/fleet.mjs CHANGED
@@ -86,6 +86,7 @@ import {
86
86
  } from './runtimes.mjs';
87
87
  import { createWorkManager } from './work.mjs';
88
88
  import { scanLocalSessions } from './localSessions.mjs';
89
+ import { repoState } from './repoState.mjs';
89
90
 
90
91
  async function fetchRoster(
91
92
  haveIds,
@@ -298,6 +299,62 @@ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
298
299
  }
299
300
  }
300
301
 
302
+ /**
303
+ * EVERY BRANCH AND WORKTREE ON THIS MACHINE, pushed on the same beat as
304
+ * presence — the answer to "is my Claude leaving a mess in here?".
305
+ *
306
+ * Same three economies as the presence report above and for the same reasons:
307
+ * scanned at most once a minute, not re-sent while identical (repoState orders
308
+ * deterministically so the string only moves when the repo does), and silent
309
+ * for the rest of the process once an older server 404s.
310
+ *
311
+ * ONE DIFFERENCE, deliberate: there is no re-send heartbeat window. Presence
312
+ * expires in the UI because a session that ENDED must stop reading as live;
313
+ * a branch list is not presence — a branch that existed a minute ago still
314
+ * exists — so re-posting an unchanged list would be a write per machine per
315
+ * five minutes to say nothing at all.
316
+ */
317
+ const REPO_STATE_URL = FLEET_URL.replace(/\/agents\/?$/, '/repo-state');
318
+ const REPO_STATE_SCAN_MS = 60_000;
319
+ let repoStateUnsupported = false; // the server 404'd — quiet until restart
320
+ let repoStateScanAt = 0;
321
+ let repoStateSent = null; // last payload the server ACCEPTED, stringified
322
+ async function maybeReportRepoState({ repoRoot, baseRef }) {
323
+ if (repoStateUnsupported) return;
324
+ if (Date.now() - repoStateScanAt < REPO_STATE_SCAN_MS) return;
325
+ repoStateScanAt = Date.now();
326
+ let payload;
327
+ try {
328
+ const state = repoState(repoRoot, baseRef);
329
+ if (!state) return; // not readable — say nothing rather than say "none"
330
+ payload = JSON.stringify(state);
331
+ } catch {
332
+ return; // a readout must never throw into the poll loop
333
+ }
334
+ if (payload === repoStateSent) return;
335
+ try {
336
+ const res = await fetch(REPO_STATE_URL, {
337
+ method: 'POST',
338
+ headers: {
339
+ Authorization: `Bearer ${FLEET_TOKEN}`,
340
+ 'User-Agent': USER_AGENT,
341
+ 'Content-Type': 'application/json',
342
+ },
343
+ signal: AbortSignal.timeout(15_000),
344
+ body: payload,
345
+ });
346
+ if (res.status === 404) {
347
+ repoStateUnsupported = true; // older server — nothing was replaced here
348
+ return;
349
+ }
350
+ // Only an ACCEPTED report counts: anything else forgets it so the next
351
+ // pass retries rather than dedup-suppressing a report nobody received.
352
+ repoStateSent = res.ok ? payload : null;
353
+ } catch {
354
+ repoStateSent = null;
355
+ }
356
+ }
357
+
301
358
  /**
302
359
  * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
303
360
  *
@@ -1544,6 +1601,9 @@ export async function runFleetDaemon() {
1544
1601
  // the daemon's own worktrees are carved out (a session the daemon spawned
1545
1602
  // is already a tab, not something to offer adopting).
1546
1603
  void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
1604
+ // …and the repo itself: every worktree and every branch, ours and not.
1605
+ // Never awaited, throttled inside, and silent on an older server.
1606
+ void maybeReportRepoState({ repoRoot, baseRef });
1547
1607
  // WHAT `/` CAN OFFER, on a machine no turn has taught yet. One-shot and
1548
1608
  // self-cancelling (it returns immediately if a turn has already reported),
1549
1609
  // never awaited, and it lands in the cache that the NEXT poll reads — so
@@ -0,0 +1,183 @@
1
+ /**
2
+ * EVERY WORKTREE AND EVERY BRANCH ON THIS MACHINE — including the ones
3
+ * Flowviant did not make.
4
+ *
5
+ * WHY THIS EXISTS, in the user's words: "can we show all branches or worktrees
6
+ * so we know if claude is polluting the branches or worktrees or not". The
7
+ * Workbench already reports the branch a TAB is standing on, but only for
8
+ * sessions the server knows about — so a branch your Claude cut mid-turn, a
9
+ * worktree left behind by a crash, or anything you made yourself at the
10
+ * keyboard was invisible from the browser. That is the same gap the Changes
11
+ * block was built to close, one level up: a browser has no `git branch` to run,
12
+ * so the machine runs it.
13
+ *
14
+ * IT IS A RELAY, NOT A JUDGEMENT. Nothing here decides what "pollution" is —
15
+ * it reports what git says and marks which refs Flowviant itself created
16
+ * (`session/<id>`), because that is a FACT about who made them and it is the
17
+ * distinction the question is actually asking about. No cleanup, no warnings,
18
+ * no "you have too many branches": the surface counts what is there, and a
19
+ * person decides.
20
+ *
21
+ * BOUNDED AT THE MACHINE, like every other report in this daemon. A repo with
22
+ * eight hundred branches must not put eight hundred rows on the wire every
23
+ * minute; the newest are kept, the rest are counted, and the caller says so
24
+ * rather than letting a short list read as the whole repo.
25
+ *
26
+ * DETERMINISTIC ORDER, for the same reason `recordSkills` sorts: the report is
27
+ * dedupe-compared against the last one that was accepted, and an unstable order
28
+ * would post a "change" every single minute forever.
29
+ *
30
+ * NOTHING HERE THROWS. It runs inside the poll loop's best-effort tail, and a
31
+ * repo mid-rebase or an unborn HEAD is a field to omit, not an error to raise.
32
+ */
33
+
34
+ import { execFileSync } from 'node:child_process';
35
+ import { listenersIn, listenersSupported } from './listeners.mjs';
36
+
37
+ /** Same cap the session diffstat uses: enough to see the shape, small enough
38
+ * that one machine cannot flood a row. */
39
+ const MAX_BRANCHES = 60;
40
+ const MAX_WORKTREES = 40;
41
+
42
+ function git(args, cwd) {
43
+ return execFileSync('git', args, {
44
+ cwd,
45
+ encoding: 'utf8',
46
+ stdio: ['ignore', 'pipe', 'ignore'],
47
+ timeout: 10_000,
48
+ maxBuffer: 8 * 1024 * 1024,
49
+ }).trim();
50
+ }
51
+
52
+ /** A ref Flowviant itself cut for a tab. The ONLY thing that makes a branch
53
+ * "ours", and the distinction the whole report exists to draw. */
54
+ export function isSessionBranch(name) {
55
+ return /^session\//.test(name);
56
+ }
57
+
58
+ /**
59
+ * `git worktree list --porcelain` → rows. The porcelain form is parsed rather
60
+ * than the human one because the human one aligns columns with spaces and a
61
+ * path containing a space silently splits into the wrong fields.
62
+ */
63
+ function readWorktrees(repoRoot) {
64
+ let out;
65
+ try {
66
+ out = git(['worktree', 'list', '--porcelain'], repoRoot);
67
+ } catch {
68
+ return null; // not a git repo, or git is unhappy — say nothing
69
+ }
70
+ const rows = [];
71
+ let cur = null;
72
+ for (const line of out.split('\n')) {
73
+ if (line.startsWith('worktree ')) {
74
+ if (cur) rows.push(cur);
75
+ cur = { path: line.slice(9), branch: null, detached: false, locked: false, prunable: false };
76
+ } else if (!cur) {
77
+ continue;
78
+ } else if (line.startsWith('branch ')) {
79
+ cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
80
+ } else if (line === 'detached') {
81
+ cur.detached = true;
82
+ } else if (line.startsWith('locked')) {
83
+ cur.locked = true;
84
+ } else if (line.startsWith('prunable')) {
85
+ // A directory git still lists but that is gone from disk — exactly the
86
+ // "left behind" case somebody looking for mess wants to see.
87
+ cur.prunable = true;
88
+ }
89
+ }
90
+ if (cur) rows.push(cur);
91
+ return rows;
92
+ }
93
+
94
+ /**
95
+ * Local branches with their distance from base.
96
+ *
97
+ * `for-each-ref` does the whole thing in ONE process — a `rev-list` per branch
98
+ * would be sixty spawns a minute on a busy repo. `%(ahead-behind:<ref>)` needs
99
+ * git 2.41+; when it is missing the counts are simply absent and the surface
100
+ * shows names without numbers, which is still the answer to "what is here".
101
+ */
102
+ function readBranches(repoRoot, baseRef) {
103
+ const fmt = '%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:' + baseRef + ')';
104
+ let out;
105
+ try {
106
+ out = git(['for-each-ref', '--sort=-committerdate', `--format=${fmt}`, 'refs/heads'], repoRoot);
107
+ } catch {
108
+ // No ahead-behind on this git. Names and dates still answer most of it.
109
+ try {
110
+ out = git(
111
+ ['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)%09%(committerdate:unix)', 'refs/heads'],
112
+ repoRoot
113
+ );
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+ const rows = [];
119
+ for (const line of out.split('\n')) {
120
+ if (!line.trim()) continue;
121
+ const [name, when, ab] = line.split('\t');
122
+ if (!name) continue;
123
+ const row = { name, at: Number(when) || 0, session: isSessionBranch(name) };
124
+ // `ahead-behind` prints "N M" — ahead of base, behind base, in that order.
125
+ if (ab) {
126
+ const [a, b] = ab.trim().split(/\s+/).map((n) => parseInt(n, 10));
127
+ if (Number.isFinite(a)) row.ahead = a;
128
+ if (Number.isFinite(b)) row.behind = b;
129
+ }
130
+ rows.push(row);
131
+ }
132
+ return rows;
133
+ }
134
+
135
+ /**
136
+ * The whole picture, or null if this is not a repo we can read.
137
+ *
138
+ * `truncated` is not decoration: a list silently cut at sixty reads as the
139
+ * whole repo, and the one question this report exists to answer is "how much is
140
+ * in here". Same rule the session diffstat's own `truncated` keeps.
141
+ */
142
+ export function repoState(repoRoot, baseRef) {
143
+ const worktrees = readWorktrees(repoRoot);
144
+ const branches = readBranches(repoRoot, baseRef);
145
+ if (!worktrees && !branches) return null;
146
+ /**
147
+ * WHAT IS LISTENING IN THE CHECKOUT ITSELF.
148
+ *
149
+ * `listenersIn` has always taken any directory, and had only ever been asked
150
+ * about SESSION WORKTREES — so somebody running `npm run dev` in their normal
151
+ * checkout, which is what "just testing or playing around in dev" actually
152
+ * looks like, was invisible to every surface in the product. The measurement
153
+ * was there; nobody was pointing it at the repo.
154
+ *
155
+ * THE ATTRIBUTION RULE IS UNCHANGED, and it is the reason this widens to the
156
+ * repo root and no further: a port is attributed by the CWD OF THE PROCESS
157
+ * HOLDING THE SOCKET, so this reports servers running inside THIS PROJECT'S
158
+ * checkout and nothing else. Postgres on 5432 has its own cwd and does not
159
+ * appear here — which is the whole point, and why "just show every port on
160
+ * the box" is not what this does.
161
+ *
162
+ * Reported, not offered: this is a readout of what is up. Sharing one is a
163
+ * separate act with its own gates (see previewJobs).
164
+ */
165
+ const listening = listenersIn(repoRoot);
166
+ const wt = worktrees ?? [];
167
+ const br = branches ?? [];
168
+ return {
169
+ base: baseRef,
170
+ worktrees: wt.slice(0, MAX_WORKTREES),
171
+ worktreesTotal: wt.length,
172
+ // Newest first (for-each-ref already sorted), so a truncated list is the
173
+ // part somebody is actually working in.
174
+ branches: br.slice(0, MAX_BRANCHES),
175
+ branchesTotal: br.length,
176
+ sessionBranches: br.filter((b) => b.session).length,
177
+ listening,
178
+ // "Nothing is listening" and "this machine cannot look" (Windows, a failed
179
+ // scan) are the same empty array without this — and the second must never
180
+ // render as the first. Same field, same reason, as the session report.
181
+ listeningSupported: listenersSupported(),
182
+ };
183
+ }
package/bin/lib/work.mjs CHANGED
@@ -51,6 +51,10 @@ import {
51
51
  } from './prompts.mjs';
52
52
  import { materializeInto, hasMaterialized, excludeInWorktree, scrub as envScrub } from './env.mjs';
53
53
  import { detectRuntimes, canRun, recordSkills, RUNTIMES } from './runtimes.mjs';
54
+
55
+ /** The place id meaning "the checkout", not a worktree. Must match the
56
+ * server's REPO_PLACE — it is a wire value, not a local convention. */
57
+ const REPO_PLACE = 'repo';
54
58
  import { isTerminalSessionLive, isAgyConversationLive } from './localSessions.mjs';
55
59
  import { worktreeDiff } from './worktreeDiff.mjs';
56
60
  import { homedir } from 'node:os';
@@ -119,8 +123,22 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
119
123
  * while that session's turn has a live CLI in it.
120
124
  */
121
125
  const workChains = new Map(); // sessionId -> settled-safe tail promise
122
- const chainFor = (sessionId, fn) => {
123
- const prev = workChains.get(sessionId) ?? Promise.resolve();
126
+ /**
127
+ * Serialize work by PLACE — the directory — not by session.
128
+ *
129
+ * It was keyed by session id, which was the same thing right up until a place
130
+ * could be shared: two sessions pointed at one worktree had independent
131
+ * chains, so their turns would run at the same time in the same directory and
132
+ * edit each other's files mid-edit. Keying on the place is what makes "two
133
+ * tabs in one repo" behave the way two terminal tabs in one repo behave —
134
+ * they take turns.
135
+ *
136
+ * The cross-PROCESS half was already right and needed no change: the turn
137
+ * lock is a file inside the worktree (`flowviant-turn.lock`), so two sessions
138
+ * sharing a place already share the lock by construction.
139
+ */
140
+ const chainFor = (placeId, fn) => {
141
+ const prev = workChains.get(placeId) ?? Promise.resolve();
124
142
  // `.then(fn, fn)`, like withWikiLock: one rejected link must never wedge
125
143
  // every later turn of the tab.
126
144
  const run = prev.then(fn, fn);
@@ -128,11 +146,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
128
146
  () => {},
129
147
  () => {}
130
148
  );
131
- workChains.set(sessionId, stored);
149
+ workChains.set(placeId, stored);
132
150
  // Release the entry when the chain drains, so the map cannot grow for the
133
151
  // process lifetime and `workChains.has()` means "busy right now".
134
152
  stored.then(() => {
135
- if (workChains.get(sessionId) === stored) workChains.delete(sessionId);
153
+ if (workChains.get(placeId) === stored) workChains.delete(placeId);
136
154
  });
137
155
  return run;
138
156
  };
@@ -1159,8 +1177,34 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1159
1177
  * is the point. If the directory was retired but the branch survives, the
1160
1178
  * worktree re-attaches to the branch and the committed work is still there.
1161
1179
  */
1162
- const sessionWtFor = (sessionId, baseAt) => {
1163
- if (!isSafePathSegment(sessionId)) return null;
1180
+ /**
1181
+ * WHERE A SESSION WORKS — its PLACE, which is a directory on a branch.
1182
+ *
1183
+ * A session used to BE a worktree: one tab, one directory, cut at birth and
1184
+ * retired at close. That binding was never an isolation guarantee — a turn
1185
+ * runs with permissions skipped, so the worktree is a starting directory and
1186
+ * not a fence, and any agent could always `cd` into another one. The product
1187
+ * was asserting an invariant it did not have.
1188
+ *
1189
+ * So a session now REFERENCES a place rather than being one. Many sessions
1190
+ * may name the same place; a session may name the repo checkout itself; and
1191
+ * `session/<own-id>` is simply the DEFAULT place, cut fresh at first turn,
1192
+ * which is why an absent `place` behaves exactly as every existing tab does.
1193
+ *
1194
+ * `'repo'` IS NOT A DIRECTORY NAME AND MUST NOT BECOME ONE. It resolves to
1195
+ * the checkout the daemon already serves — never created, never retired,
1196
+ * because it is not ours to remove. The value reaching here is a server-side
1197
+ * enum, never a browser-supplied path: `sessions.routes.ts` resolves it the
1198
+ * same way adoption resolves a cwd, and for the same reason.
1199
+ */
1200
+ const placeWtFor = (placeId, baseAt) => {
1201
+ if (placeId === REPO_PLACE) {
1202
+ // The checkout. `fresh: false` on purpose — nothing was opened, so no
1203
+ // caller may treat this as a newly-cut branch.
1204
+ return { wt: repoRoot, fresh: false };
1205
+ }
1206
+ if (!isSafePathSegment(placeId)) return null;
1207
+ const sessionId = placeId;
1164
1208
  const wt = join(baseDir, 'sessions', sessionId);
1165
1209
  const fresh = !existsSync(wt);
1166
1210
  if (fresh) {
@@ -1538,6 +1582,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1538
1582
  }
1539
1583
  };
1540
1584
 
1585
+ /** The pre-places name, kept so every existing caller reads unchanged: a
1586
+ * session's own id IS its default place. */
1587
+ const sessionWtFor = (sessionId, baseAt) => placeWtFor(sessionId, baseAt);
1588
+
1541
1589
  const retireWorkSessions = (activeIds, heldElsewhere) => {
1542
1590
  if (!Array.isArray(activeIds)) return;
1543
1591
  // Sessions ANOTHER daemon on this credential is serving. They are absent
@@ -1596,7 +1644,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1596
1644
  // run it again while the report is merely undelivered.
1597
1645
  if (pendingWorkReports.has(job.id)) continue;
1598
1646
  workAnswering.add(job.id);
1599
- chainFor(job.sessionId, async () => {
1647
+ // Serialized by PLACE: two tabs sharing a worktree take turns in it
1648
+ // rather than editing the same files at the same time.
1649
+ const place = job.place || job.sessionId;
1650
+ chainFor(place, async () => {
1600
1651
  try {
1601
1652
  const tries = workAttempts.get(job.id) ?? 0;
1602
1653
  if (tries >= MAX_WORK_TRIES) {
@@ -1711,7 +1762,10 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
1711
1762
  }
1712
1763
  // Based at the SOURCE's HEAD when adopting — the resumed
1713
1764
  // conversation was had against those commits, not the project base.
1714
- const dir = sessionWtFor(job.sessionId, adopting ? srcHead : undefined);
1765
+ // The PLACE this tab works in its own worktree unless the server
1766
+ // named another. An older server sends no `place` and the default is
1767
+ // the session's own id, which is what every tab has always done.
1768
+ const dir = placeWtFor(place, adopting ? srcHead : undefined);
1715
1769
  if (!dir) {
1716
1770
  await settleWorkTurn(job.id, {
1717
1771
  ok: false,
@@ -2193,8 +2247,53 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2193
2247
  return;
2194
2248
  }
2195
2249
  note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
2196
- const branch = `session/${job.sessionId}`;
2250
+ /**
2251
+ * WHAT IS ACTUALLY CHECKED OUT — not what we named it at birth.
2252
+ *
2253
+ * Ship used to compute `session/<id>` and then REFUSE if HEAD had
2254
+ * moved: "ask it to return to its session branch, then ship again".
2255
+ * That refusal is the thing this product says it never does — it had
2256
+ * no reason of its own beyond bookkeeping, and in a terminal
2257
+ * `git checkout -b` breaks nothing, which is the whole standard this
2258
+ * surface is held to.
2259
+ *
2260
+ * The bug it was written for was real and is fixed properly here
2261
+ * rather than frozen out: ship once merged the branch NAME while
2262
+ * logging HEAD, so receipts named commits that never landed on main.
2263
+ * That was TWO SOURCES OF TRUTH, not branch switching. There is one
2264
+ * now, and it is the worktree's own HEAD.
2265
+ *
2266
+ * Resolved BEFORE the idempotency check below, and that ordering is
2267
+ * load-bearing: `session/<id>` can still exist, stale and already an
2268
+ * ancestor of base, while the real work sits on the branch that was
2269
+ * checked out afterwards. Asking the old name first would answer
2270
+ * "already merged — nothing new to ship" over unshipped commits.
2271
+ *
2272
+ * A directory that is gone (a retired or closed tab) cannot be asked,
2273
+ * so the recorded name is the fallback — the one case where the name
2274
+ * is the only thing there is.
2275
+ */
2197
2276
  const wt = join(baseDir, 'sessions', job.sessionId);
2277
+ let branch = `session/${job.sessionId}`;
2278
+ let detached = false;
2279
+ if (existsSync(wt)) {
2280
+ try {
2281
+ branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
2282
+ } catch {
2283
+ detached = true;
2284
+ }
2285
+ }
2286
+ // THE ONE REFUSAL LEFT, and it is not policy. A detached HEAD names
2287
+ // no branch, so there is nothing to merge and nothing to record —
2288
+ // that is an ambiguity in git, not a rule of ours.
2289
+ if (detached) {
2290
+ await done({
2291
+ ok: false,
2292
+ error:
2293
+ 'this session is on a detached HEAD — no branch to ship. Ask it to check out a branch, then ship again',
2294
+ });
2295
+ return;
2296
+ }
2198
2297
  let branchExists = true;
2199
2298
  try {
2200
2299
  git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoRoot);
@@ -2388,23 +2487,11 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
2388
2487
  });
2389
2488
  return;
2390
2489
  }
2391
- // What is checked out here must BE the session branch. Sessions may
2392
- // create branches when asked but then "ship" is ambiguous, and
2393
- // folding+logging HEAD while merging the stale branch NAME once
2394
- // shipped receipts for commits that never landed on main.
2395
- let head = null;
2396
- try {
2397
- head = git(['symbolic-ref', '--short', 'HEAD'], dir.wt);
2398
- } catch {
2399
- /* detached */
2400
- }
2401
- if (head !== branch) {
2402
- await done({
2403
- ok: false,
2404
- error: `the session is on ${head ? `branch '${head}'` : 'a detached HEAD'}, not its own '${branch}' — ask it to return to its session branch, then ship again`,
2405
- });
2406
- return;
2407
- }
2490
+ // NO "return to your session branch" GUARD. `branch` was read from
2491
+ // this worktree's HEAD above, so the fold, the tip and the receipts
2492
+ // below all name the same thing by construction — which is what the
2493
+ // old guard was really protecting, and it protected it by refusing
2494
+ // instead of by measuring.
2408
2495
  // Fold main into the branch FIRST: conflicts land here, in the
2409
2496
  // session's own worktree, where the next turn can resolve them.
2410
2497
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — 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": {