flowviant 0.103.0 → 0.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +183 -2383
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +2 -2
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +17 -17
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3034
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +91 -898
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
|
@@ -0,0 +1,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
|
+
}
|