flowviant 0.102.2 → 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.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,185 @@
1
+ /**
2
+ * THE MANAGER'S DISK HYGIENE — which session directories this daemon holds,
3
+ * retiring the worktrees of closed tabs (and stopping a stopped agent's CLI
4
+ * there), and fast-forwarding each person's clean manual place.
5
+ *
6
+ * Split out of work.mjs (2026-09-26, SOLID F037). All three read the
7
+ * `sessions/` directory the poll hands back and act only on what the roster
8
+ * says is no longer live or is safely behind; none of them runs a turn or
9
+ * reports a measurement.
10
+ */
11
+ import { existsSync, readdirSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { git, isSafePathSegment } from './git.mjs';
14
+
15
+ export function createWorkRetire({
16
+ repoRoot,
17
+ baseDir,
18
+ baseRef,
19
+ placeOf,
20
+ placeLocks,
21
+ shipping,
22
+ workTokens,
23
+ agentChildren,
24
+ sweepMergedSessionBranch,
25
+ }) {
26
+ /**
27
+ * Retire the worktrees of sessions the server says are CLOSED.
28
+ *
29
+ * `activeWorkSessions` on the roster is the list of this fleet's LIVE
30
+ * sessions; a directory whose id is absent belongs to a tab its owner
31
+ * closed, and the directory — never the branch: committed work survives on
32
+ * `session/<id>`, and ship re-attaches to it — is returned to disk. NEVER
33
+ * by count: the old cap-12 retirement destroyed live sessions on shared
34
+ * machines. When the roster omits the field entirely (older server),
35
+ * absence of signal is not a close — retire nothing.
36
+ */
37
+ /** Every session this daemon currently has a worktree for — what renews our
38
+ * lease on the poll. Read off the directory rather than a map, so it is the
39
+ * same fact retirement acts on. */
40
+ const heldSessionIds = () => {
41
+ const dir = join(baseDir, 'sessions');
42
+ try {
43
+ return readdirSync(dir).filter(isSafePathSegment).slice(0, 50);
44
+ } catch {
45
+ return [];
46
+ }
47
+ };
48
+
49
+ const retireWorkSessions = (activeIds, heldElsewhere) => {
50
+ if (!Array.isArray(activeIds)) return;
51
+ // Sessions ANOTHER daemon on this credential is serving. They are absent
52
+ // from activeWorkSessions for us and present for them, and removing their
53
+ // worktree would pull the directory out from under a running turn. Absence
54
+ // means "the tab closed"; this is the one other thing it can mean.
55
+ const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
56
+ // A peer-held session's CACHED work token is a claim-bypass: the mint is
57
+ // the one place the session lease 409s a non-holder, and a token younger
58
+ // than ~23h skips the mint entirely — so a daemon that lost a lease would
59
+ // run the next turn anyway, editing the worktree while every MCP call
60
+ // 401s (the peer's mint rotated the secret). Dropping the cache forces
61
+ // the next turn through the mint, where the 409 stands it down.
62
+ for (const id of peers) workTokens.delete(id);
63
+ const dir = join(baseDir, 'sessions');
64
+ if (!existsSync(dir)) return;
65
+ let ids;
66
+ try {
67
+ ids = readdirSync(dir);
68
+ } catch {
69
+ return;
70
+ }
71
+ const live = new Set(activeIds);
72
+ let removed = 0;
73
+ for (const id of ids) {
74
+ if (live.has(id)) continue;
75
+ if (peers.has(id)) continue; // another daemon's tab — not ours to retire
76
+ /**
77
+ * A HARD STOP REACHES THE CLI HERE, and this is the whole of it.
78
+ *
79
+ * `stopAgent` marked the agent abandoned in D1 and nothing on the wire
80
+ * told the machine, so the CLI went on working, editing the worktree and
81
+ * spending the operator's quota — while the confirm dialog said the agent
82
+ * was stopped. It also never got cleaned up, because the check below
83
+ * skips any place whose lock is held and a running turn holds it: a
84
+ * stopped agent mid-turn kept its directory forever.
85
+ *
86
+ * No new wire field and no version floor: an abandoned agent simply drops
87
+ * out of `activeWorkSessions` (that list is built from LIVE statuses), so
88
+ * the server ALREADY says everything needed. Absence means "nothing here
89
+ * is live any more", and the honest response to that is to stop what we
90
+ * are running in it and then take the directory.
91
+ *
92
+ * SIGTERM the CHILD, never its group — the rule teardown keeps, so an
93
+ * unattended sweep cannot take the driver's dev server with it. The lock
94
+ * is released when the child exits, so the removal happens on the next
95
+ * pass rather than this one.
96
+ */
97
+ const running = agentChildren.get(id);
98
+ if (running) {
99
+ try {
100
+ running.kill('SIGTERM');
101
+ } catch {
102
+ /* already gone */
103
+ }
104
+ agentChildren.delete(id);
105
+ }
106
+ // Asked of the PLACE, not the session: the lock is keyed by directory,
107
+ // and a session sharing one with a busy peer is not ours to retire
108
+ // either — its worktree is the peer's working directory.
109
+ if (placeLocks.has(placeOf(id)) || shipping.has(id)) continue; // still draining here
110
+ const wt = join(dir, id);
111
+ try {
112
+ // Uncommitted work is the human's — a resource sweep does not outrank
113
+ // it, closed tab or not. (The non-force remove would refuse anyway;
114
+ // the explicit check keeps the intent legible.)
115
+ if (git(['status', '--porcelain'], wt) !== '') continue;
116
+ git(['worktree', 'remove', wt], repoRoot); // non-force
117
+ workTokens.delete(id);
118
+ removed++;
119
+ // NOW the branch can be judged. While this worktree existed the branch
120
+ // was checked out in it, so `git branch -d` refused on every earlier
121
+ // attempt — a tab that shipped and then closed would otherwise leave
122
+ // its merged branch behind forever, which is the common case.
123
+ // Unshipped work still refuses here: `-d` is what decides.
124
+ sweepMergedSessionBranch(id);
125
+ } catch {
126
+ /* not cleanly removable — leave it */
127
+ }
128
+ }
129
+ if (removed) {
130
+ try {
131
+ git(['worktree', 'prune'], repoRoot);
132
+ } catch {
133
+ /* best effort */
134
+ }
135
+ }
136
+ };
137
+
138
+ /**
139
+ * KEEP EACH PERSON'S MANUAL WORKTREE FRESH.
140
+ *
141
+ * Every teammate's Workbench tabs share one directory of their own on a
142
+ * branch of their own — which is not a preference, it is what git allows:
143
+ * two worktrees cannot have the same branch checked out, so "everyone works
144
+ * on main" is only true for the machine's OPERATOR, whose place is the
145
+ * checkout itself. Everyone else needs a branch, and a branch left alone
146
+ * drifts behind main until the first thing they do in a new tab is a merge
147
+ * they did not ask for.
148
+ *
149
+ * FAST-FORWARD ONLY, and that is the whole safety argument. If their branch
150
+ * has no commits of its own it simply catches up, which is the ordinary case
151
+ * and the one worth automating. The moment it HAS diverged, this stops and
152
+ * leaves it exactly as it is: their commits are theirs, a rebase would
153
+ * rewrite them under somebody who is not looking, and a merge would put a
154
+ * commit in their history that they did not make. Their own Claude can fold
155
+ * base in whenever they ask it to.
156
+ *
157
+ * Guarded three ways: a DIRTY tree is left alone (uncommitted work outranks
158
+ * freshness), a place with a live lock is skipped (a turn is standing in it),
159
+ * and the whole thing is silent — nothing here reports, warns or blocks.
160
+ */
161
+ const freshenManualPlaces = () => {
162
+ let dir;
163
+ try {
164
+ dir = readdirSync(join(baseDir, 'sessions'));
165
+ } catch {
166
+ return; // no worktrees yet
167
+ }
168
+ for (const place of dir) {
169
+ // Only a PERSON's manual place. An agent's own worktree (`a-<id>`) is
170
+ // deliberately not touched: its branch is the reviewable unit, and
171
+ // moving it under a review would change what somebody is deciding about.
172
+ if (!place.startsWith('u-') || !isSafePathSegment(place)) continue;
173
+ if (placeLocks.has(place)) continue;
174
+ const wt = join(baseDir, 'sessions', place);
175
+ try {
176
+ if (git(['status', '--porcelain'], wt).trim() !== '') continue;
177
+ git(['merge', '--ff-only', baseRef()], wt);
178
+ } catch {
179
+ /* diverged, or something else is going on in there. Leave it. */
180
+ }
181
+ }
182
+ };
183
+
184
+ return { heldSessionIds, retireWorkSessions, freshenManualPlaces };
185
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * WHICH PROCESS GROUPS EACH TAB HAS STARTED — kept, persisted per repo and
3
+ * boot, and pruned against the kernel on every read.
4
+ *
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). It changes for one reason —
6
+ * how a watcher that outlives its turn stays attributed to its tab — and has
7
+ * three writers (the session lane and the agent lane note a group at spawn,
8
+ * the sweep prunes closed ids) and two readers (the worktree report's
9
+ * `processes`, and the kill lane's check in workProcesses.mjs, which is handed
10
+ * `sessionGroups` itself). groupRegistry.test.mjs drives it through the
11
+ * manager against a real registry file.
12
+ */
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { createHash } from 'node:crypto';
16
+ import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
17
+ import { bootMark, mutateRegistry, readRegistry, sameBoot } from './procRegistry.mjs';
18
+ import { scrub as envScrub } from './uplinkScrub.mjs';
19
+
20
+ export function createWorkSessionGroups({ repoRoot }) {
21
+ /**
22
+ * WHICH PROCESS GROUPS EACH TAB HAS STARTED.
23
+ *
24
+ * A turn's CLI is spawned `detached`, so its pid is a process-group id and
25
+ * everything the agent starts inherits it — through `nohup` and `setsid`,
26
+ * which is precisely where attribution by ppid falls apart. Kept per SESSION
27
+ * and not per turn: the point of the feature is the watcher that outlives the
28
+ * turn that started it.
29
+ *
30
+ * PRUNED ON EVERY READ against the kernel, which is not housekeeping. A pgid
31
+ * is a pid and pids are recycled, so an un-pruned set would eventually
32
+ * attribute a stranger's process to a tab that has been closed for a week.
33
+ */
34
+ const sessionGroups = new Map(); // sessionId -> Set<pgid>
35
+
36
+ /**
37
+ * …AND IT SURVIVES A RESTART, which it did not until 2026-08-27.
38
+ *
39
+ * This map used to live only in this closure. The processes it tracks
40
+ * OUTLIVE the daemon on purpose — `shutdownWork` SIGTERMs the CLI child and
41
+ * never the group, precisely so an unattended auto-update does not kill the
42
+ * driver's dev server — so every daemon restart left a live watcher running
43
+ * with nothing left that knew whose it was. The tab reported `[]`, the web
44
+ * read that as "looked, found none", and the Running section went dark until
45
+ * some later turn happened to open a new group.
46
+ *
47
+ * That is not a small bug: AUTO_UPDATE is on by default, so it fired on every
48
+ * release, on every machine. And it broke the three-state rule this file
49
+ * states in its own header — the honest answer after a restart was "we have
50
+ * forgotten", and `[]` is not that. Persisting is what makes `[]` true again,
51
+ * which is why the fix is a disk write and not a fourth state.
52
+ *
53
+ * `procRegistry` is the right home and was sitting unused: it was built for
54
+ * exactly this ("the daemon spawns things that outlive it… the successor has
55
+ * to find them"), was orphaned when the dev-run system was deleted, and
56
+ * already does the atomic write, the stale-lock recovery, the entry cap and
57
+ * the dead-pid TTL. Its prune is deliberately LOOSE here — it keeps an entry
58
+ * whose leader is gone, because the leader is the CLI and it exits at the end
59
+ * of every turn while the watcher it started keeps running. `liveGroups` is
60
+ * the real prune, on every read, against the kernel.
61
+ */
62
+ /*
63
+ * THREE RULES THE FIRST CUT BROKE (2026-09-24), each a way the file named a
64
+ * group that was not this tab's, or forgot one that was:
65
+ *
66
+ * - ONE FILE PER REPO, not per OS user. Each daemon rewrote the shared file
67
+ * with only its own groups, so two daemons on one box (project A in one
68
+ * checkout, B in another) erased each other's entries and A's watcher
69
+ * was forgotten at A's next restart. The instance lock already makes a
70
+ * repo one daemon, so the repo is the right owner. The pre-2026-09-24
71
+ * shared file is not read: its entries carry no boot, so no rule below
72
+ * could believe them anyway.
73
+ * - AN ENTRY IS BELIEVED ONLY IN THE BOOT THAT WROTE IT. After a reboot the
74
+ * remembered pgid belongs to whatever the kernel handed it to next — the
75
+ * operator's shell, another project's children — and the Running list
76
+ * relayed that group's command lines as this tab's and `killTargetOk`
77
+ * accepted a Stop on them.
78
+ * - ONLY LIVE GROUPS ARE WRITTEN, each with the time it was FIRST seen.
79
+ * Every persist used to restamp every entry with `Date.now()`, so the
80
+ * registry's 7-day TTL never applied to anything, and dead entries (one per
81
+ * agent turn, recorded under a key nothing read) filled the 32-entry cap
82
+ * and pushed a tab's live watcher off the end.
83
+ */
84
+ const GROUPS_DIR = join(homedir(), '.flowviant');
85
+ const GROUPS_KEY = createHash('sha256').update(String(repoRoot)).digest('hex').slice(0, 16);
86
+ const GROUPS_FILE = join(GROUPS_DIR, `session-groups-${GROUPS_KEY}.json`);
87
+ const GROUPS_LOCK = join(GROUPS_DIR, `session-groups-${GROUPS_KEY}.lock`);
88
+ const groupFirstSeen = new Map(); // pgid -> ms first recorded
89
+ const BOOT = bootMark();
90
+
91
+ const persistGroups = () => {
92
+ const flat = [];
93
+ for (const [sid, set] of sessionGroups) {
94
+ for (const pgid of liveGroups(set)) {
95
+ if (!groupFirstSeen.has(pgid)) groupFirstSeen.set(pgid, Date.now());
96
+ flat.push({ sessionId: sid, pid: pgid, startedAt: groupFirstSeen.get(pgid), boot: BOOT });
97
+ }
98
+ }
99
+ const kept = new Set(flat.map((e) => e.pid));
100
+ for (const g of groupFirstSeen.keys()) if (!kept.has(g)) groupFirstSeen.delete(g);
101
+ try {
102
+ mutateRegistry(GROUPS_DIR, GROUPS_FILE, GROUPS_LOCK, () => flat);
103
+ } catch {
104
+ /* best-effort: losing the file costs a restart's visibility, never a turn */
105
+ }
106
+ };
107
+
108
+ try {
109
+ for (const e of readRegistry(GROUPS_FILE)) {
110
+ if (!e?.sessionId || !Number.isInteger(e?.pid)) continue;
111
+ if (!sameBoot(e.boot, BOOT)) continue; // another boot's pgid names a stranger now
112
+ const set = sessionGroups.get(e.sessionId) ?? new Set();
113
+ set.add(e.pid);
114
+ sessionGroups.set(e.sessionId, set);
115
+ if (Number(e.startedAt) > 0) groupFirstSeen.set(e.pid, Number(e.startedAt));
116
+ }
117
+ } catch {
118
+ /* no registry yet — the ordinary first run */
119
+ }
120
+
121
+ /** Forget the groups of every id the roster no longer names — a closed tab,
122
+ * a finished agent. Nothing reports or stops a group for an id that is not
123
+ * live, so holding it only grows the map for the life of the process. */
124
+ const pruneSessionGroups = (activeIds) => {
125
+ const live = new Set(activeIds);
126
+ let changed = false;
127
+ for (const id of [...sessionGroups.keys()]) {
128
+ if (live.has(id)) continue;
129
+ sessionGroups.delete(id);
130
+ changed = true;
131
+ }
132
+ if (changed) persistGroups();
133
+ };
134
+
135
+ const noteSessionGroup = (sessionId, pgid) => {
136
+ if (!sessionId || !pgid) return;
137
+ const set = sessionGroups.get(sessionId) ?? new Set();
138
+ if (set.has(pgid)) return;
139
+ set.add(pgid);
140
+ sessionGroups.set(sessionId, set);
141
+ persistGroups();
142
+ };
143
+
144
+ /** This tab's live processes, or null where the machine cannot look. */
145
+ const sessionProcesses = (sessionId) => {
146
+ if (!processesSupported()) return null;
147
+ const known = sessionGroups.get(sessionId);
148
+ // The SHAPE `measureProcesses` returns — a bare `[]` here has no `.rows`,
149
+ // so the report's `processes` key came out undefined and was dropped from
150
+ // the JSON: "looked and found none" arrived as "never looked".
151
+ if (!known || known.size === 0) return { rows: [], total: 0 };
152
+ const alive = liveGroups(known);
153
+ // Only touch the disk when the set actually MOVED. This runs on every
154
+ // sweep, for every live tab, forever; an unconditional write would be a
155
+ // file rewrite a minute for the life of the daemon to restate what is
156
+ // already there — the same reasoning the auto-name relay uses for its
157
+ // unchanged-title check.
158
+ const changed = alive.size !== known.size;
159
+ if (alive.size === 0) sessionGroups.delete(sessionId);
160
+ else sessionGroups.set(sessionId, alive);
161
+ if (changed) persistGroups();
162
+ return measureProcesses(alive, { scrub: envScrub });
163
+ };
164
+
165
+ return { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses };
166
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * WHICH CLI A TAB SPEAKS, AND WHICH CONVERSATION IT RESUMES — the runtime pin
3
+ * and the shapes a resume id must have before it rides argv.
4
+ *
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). The pin and the id guards
6
+ * change together (a new CLI brings both), and none of it is about running a
7
+ * turn: the session lane asks `sessionRuntime` before it spawns. The two id
8
+ * guards are pure and module-level: both turn lanes import them, so the
9
+ * manager never carries a constant between lanes.
10
+ */
11
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { canRun, RUNTIMES } from './runtimes.mjs';
15
+ import { detectRuntimes } from './runtimeDetection.mjs';
16
+
17
+ /**
18
+ * The shape a codex thread id must have before it is written to disk or —
19
+ * decisive — pushed into argv as `resume <id>`. Conservative on purpose:
20
+ * alphanumeric plus dash/underscore, never a leading dash (an argv that
21
+ * parses as a flag), never whitespace. Anything else is dropped and the
22
+ * session simply runs fresh in its own worktree.
23
+ */
24
+ export const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
25
+
26
+ /** agy conversation ids are plain UUIDs (the db filename IS the identity —
27
+ * measured: a renamed copy fails "trajectory not found"). Guarded the same
28
+ * way as the codex id: it rides in argv as `--conversation <id>`. */
29
+ export const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
30
+
31
+ export function createWorkSessionRuntime({ sessionMetaPath }) {
32
+ /**
33
+ * WHICH CLI drives this session — picked ONCE, on the first turn, and pinned
34
+ * in the worktree's meta dir. The held context belongs to the CLI that made
35
+ * it: `--continue` under a different binary is a different brain wearing the
36
+ * session's half-finished state (the dispatch path pins heldRuntime for the
37
+ * same reason). If the pinned CLI has left the machine, the turn settles
38
+ * honestly instead of substituting. A retired-and-reattached directory has
39
+ * no marker and no held context either, so re-picking there is correct.
40
+ *
41
+ * THE SERVER'S WORD COMES FIRST. A tab is created AS a runtime's tab
42
+ * (`job.runtime`; null/absent = Claude, which is what every tab ran on until
43
+ * now), so on the first turn a named runtime IS the pick — never a
44
+ * preference the machine may override. And a named runtime that DISAGREES
45
+ * with an existing pin is an identity change mid-life: something upstream
46
+ * now calls this tab a different brain's, and the only honest move is to
47
+ * settle the turn and say so ({ mismatch }), because a held context must
48
+ * never be answered by a different brain.
49
+ *
50
+ * Returns { id } | { id: null } (nothing installed) | { missing: label } |
51
+ * { unsupported: label } (a runtime no session can run on) |
52
+ * { mismatch: { pin, runtime } } (labels, for the caller's sentence).
53
+ *
54
+ * SESSION-CAPABLE means rt.mcp is truthy — the session tools ride a real
55
+ * per-invocation MCP config — OR the runtime runs tabs PLAIN (Antigravity):
56
+ * no MCP at all, no cards, no streaming; the final answer is delivered by
57
+ * the daemon's own report and ship-time reconciliation keeps the ledger
58
+ * whole. `pickRuntimeFor('build')` is still the WRONG question here — it
59
+ * says yes to the mediated DISPATCH path without saying how a tab would
60
+ * speak, and a session pinned by it once threw in mcpFor on every turn.
61
+ */
62
+ const sessionCapable = (rid) =>
63
+ (Boolean(RUNTIMES[rid]?.mcp) || rid === 'antigravity') && canRun(RUNTIMES[rid], 'build');
64
+ const sessionRuntime = (wt, jobRuntime, sessionId) => {
65
+ // SCOPED: two tabs standing in one directory may run different CLIs, and an
66
+ // unscoped pin would hand the second one the first one's runtime.
67
+ const marker = sessionMetaPath(wt, 'flowviant-runtime', sessionId);
68
+ let pinned = null;
69
+ if (marker && existsSync(marker)) {
70
+ try {
71
+ pinned = readFileSync(marker, 'utf8').trim() || null;
72
+ } catch {
73
+ /* unreadable marker — re-pin below */
74
+ }
75
+ }
76
+ if (pinned && RUNTIMES[pinned]) {
77
+ if (jobRuntime && jobRuntime !== pinned) {
78
+ return {
79
+ mismatch: {
80
+ pin: RUNTIMES[pinned].label || pinned,
81
+ runtime: RUNTIMES[jobRuntime]?.label || jobRuntime,
82
+ },
83
+ };
84
+ }
85
+ // A pin that names a non-session-capable runtime is settled honestly by
86
+ // the caller, not silently re-picked: re-picking would hand the held
87
+ // context to a different brain, which is the exact substitution the pin
88
+ // exists to prevent.
89
+ if (!sessionCapable(pinned)) return { unsupported: RUNTIMES[pinned].label || pinned };
90
+ const installed = detectRuntimes().find((r) => r.id === pinned)?.installed;
91
+ return installed ? { id: pinned } : { missing: RUNTIMES[pinned].label || pinned };
92
+ }
93
+ // First turn, and the server named the brain: that IS the pick, gated the
94
+ // same two ways as a pin — not session-capable and not installed both
95
+ // settle honestly via the caller's existing paths, never substituted.
96
+ if (jobRuntime) {
97
+ if (!sessionCapable(jobRuntime))
98
+ return { unsupported: RUNTIMES[jobRuntime]?.label || jobRuntime };
99
+ const installed = detectRuntimes().find((r) => r.id === jobRuntime)?.installed;
100
+ if (!installed) return { missing: RUNTIMES[jobRuntime]?.label || jobRuntime };
101
+ if (marker) {
102
+ try {
103
+ writeFileSync(marker, jobRuntime);
104
+ } catch {
105
+ /* best-effort — an unpinnable session just re-picks next turn */
106
+ }
107
+ }
108
+ return { id: jobRuntime };
109
+ }
110
+ // The fresh pick — Claude first when it qualifies, for the reason
111
+ // pickRuntimeFor gives: the prompts were tuned against it. DELIBERATELY
112
+ // NARROWER than sessionCapable: a PLAIN tab (Antigravity — no cards, no
113
+ // streaming) is a degraded mode someone CHOOSES, so it is honored only
114
+ // when the server names it, never handed out as a default.
115
+ const rows = detectRuntimes();
116
+ const okFor = (rid) =>
117
+ Boolean(RUNTIMES[rid]?.mcp) &&
118
+ sessionCapable(rid) &&
119
+ Boolean(rows.find((r) => r.id === rid)?.installed);
120
+ const id = okFor('claude') ? 'claude' : (Object.keys(RUNTIMES).find(okFor) ?? null);
121
+ if (!id) return { id: null };
122
+ if (marker) {
123
+ try {
124
+ writeFileSync(marker, id);
125
+ } catch {
126
+ /* best-effort — an unpinnable session just re-picks next turn */
127
+ }
128
+ }
129
+ return { id };
130
+ };
131
+
132
+ /** agy's own cwd registry — {cwd → the conversation that ran there LAST}.
133
+ * Read once, right after a fresh agy turn, to learn the id the turn just
134
+ * created; from then on the tab's marker is the identity and this registry
135
+ * is never consulted again (a dispatch sharing the machine may overwrite
136
+ * the cwd's entry between turns). */
137
+ const agyRegistryLookup = (cwd) => {
138
+ try {
139
+ const raw = readFileSync(
140
+ join(homedir(), '.gemini', 'antigravity-cli', 'cache', 'last_conversations.json'),
141
+ 'utf8'
142
+ );
143
+ const map = JSON.parse(raw);
144
+ if (!map || typeof map !== 'object') return null;
145
+ // agy keys by the cwd as IT resolved it — try our literal path and its
146
+ // realpath, so a symlinked home doesn't orphan the lookup.
147
+ let keys = [cwd];
148
+ try {
149
+ keys.push(realpathSync(cwd));
150
+ } catch {
151
+ /* the literal alone, then */
152
+ }
153
+ for (const k of keys) {
154
+ const id = map[k];
155
+ if (typeof id === 'string' && AGY_CONV_RE.test(id)) return id;
156
+ }
157
+ return null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ };
162
+
163
+ return { sessionRuntime, agyRegistryLookup };
164
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * THE WORK CREDENTIAL, ONE PER SESSION — minted, cached near its lease, and
3
+ * the mint's three answers (a token, gone, held by another daemon).
4
+ *
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). The session lane mints; the
6
+ * retirement sweep drops a peer-held session's cache so the next turn goes
7
+ * back through the mint (the one place the lease 409s a non-holder). Both hold
8
+ * the same map, handed out by name.
9
+ */
10
+ import {
11
+ FLEET_URL,
12
+ FLEET_TOKEN,
13
+ USER_AGENT,
14
+ REFRESH_BEFORE_SECONDS,
15
+ DAEMON_INSTANCE,
16
+ } from './config.mjs';
17
+ import { fleetEndpoint } from './fleetWire.mjs';
18
+
19
+ export function createWorkSessionTokens({ getLeaseTtl }) {
20
+ const WORK_TOKEN_URL = fleetEndpoint('work-token', FLEET_URL);
21
+ /**
22
+ * The work credential, ONE PER SESSION. The server binds each minted token
23
+ * to the sessionId in the mint body and the MCP layer refuses it for any
24
+ * other session, so a process-wide token would fail every tab but the one
25
+ * that minted it. Cached per session, re-minted near expiry (the endpoint
26
+ * rotates on every mint; per-session chaining means no turn is in flight
27
+ * for the session when its next turn mints). 404 means the server no longer
28
+ * holds that session for this fleet — a fact for the turn to settle with,
29
+ * not a retry.
30
+ */
31
+ const workTokens = new Map(); // sessionId -> { token, mintedAt }
32
+ const mintWorkToken = async (sessionId, force = false) => {
33
+ const cached = workTokens.get(sessionId);
34
+ const freshEnoughS = getLeaseTtl() - REFRESH_BEFORE_SECONDS;
35
+ if (cached && !force && (Date.now() - cached.mintedAt) / 1000 < freshEnoughS)
36
+ return { token: cached.token };
37
+ try {
38
+ const res = await fetch(WORK_TOKEN_URL, {
39
+ method: 'POST',
40
+ headers: {
41
+ Authorization: `Bearer ${FLEET_TOKEN}`,
42
+ 'User-Agent': USER_AGENT,
43
+ 'Content-Type': 'application/json',
44
+ },
45
+ signal: AbortSignal.timeout(30_000),
46
+ // The instance is what CLAIMS the session lease server-side. Two
47
+ // daemons share one fleet credential, so the token cannot say which of
48
+ // us is serving this tab — and the mint is the moment that matters:
49
+ // there is one work-token row per session and minting ROTATES it, so a
50
+ // second mint revokes the first daemon's live secret mid-turn.
51
+ body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
52
+ });
53
+ if (res.status === 404) return { gone: true };
54
+ // 409 — another daemon on this credential holds the session. Not ours to
55
+ // serve and not a retry: stand down and let the holder answer.
56
+ if (res.status === 409) return { heldElsewhere: true };
57
+ if (!res.ok) return null;
58
+ const token = (await res.json().catch(() => null))?.data?.token ?? null;
59
+ if (!token) return null;
60
+ workTokens.set(sessionId, { token, mintedAt: Date.now() });
61
+ return { token };
62
+ } catch {
63
+ return null;
64
+ }
65
+ };
66
+
67
+ return { workTokens, mintWorkToken };
68
+ }