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,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE WORKTREE READOUT — what each place looks like (diff, listeners,
|
|
3
|
+
* processes, title, box, publication), reported after a settle, in a decaying
|
|
4
|
+
* burst while a dev server binds, and in a throttled rotating sweep over every
|
|
5
|
+
* live session.
|
|
6
|
+
*
|
|
7
|
+
* Split out of work.mjs (2026-09-26, SOLID F037). Every trigger (a settle, a
|
|
8
|
+
* ship, a kill, the poll) asks for a measurement and nothing else; this
|
|
9
|
+
* module owns what a measurement IS and when the sweep runs, so a new field
|
|
10
|
+
* on `/fleet/session-worktrees` is one edit here. A daemon→server report, so
|
|
11
|
+
* none of it needs a version floor.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, MACHINE_HOST } from './config.mjs';
|
|
15
|
+
import { gitNetAsync, isSafePathSegment } from './git.mjs';
|
|
16
|
+
import { measureListeners, listenersSupported } from './listeners.mjs';
|
|
17
|
+
import { processesSupported } from './processes.mjs';
|
|
18
|
+
import { titleForSession } from './claudeSessions.mjs';
|
|
19
|
+
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
20
|
+
import { myPubB64 } from './boxIdentity.mjs';
|
|
21
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
22
|
+
import { REPO_PLACE } from './workPlaces.mjs';
|
|
23
|
+
|
|
24
|
+
export function createWorkPlaceReports({
|
|
25
|
+
repoRoot,
|
|
26
|
+
baseRef,
|
|
27
|
+
placeOf,
|
|
28
|
+
placeDir,
|
|
29
|
+
sessionMetaPath,
|
|
30
|
+
sessionProcesses,
|
|
31
|
+
pruneSessionGroups,
|
|
32
|
+
agentPublished,
|
|
33
|
+
agentRemoteAt,
|
|
34
|
+
publishAgentBranch,
|
|
35
|
+
landed,
|
|
36
|
+
}) {
|
|
37
|
+
const WORKTREES_URL = fleetEndpoint('session-worktrees', FLEET_URL);
|
|
38
|
+
/**
|
|
39
|
+
* WHERE EACH TAB IS STANDING, and what it holds — the readout a human would
|
|
40
|
+
* get by running `git status` in the session's directory, which is the one
|
|
41
|
+
* thing they cannot do from a browser.
|
|
42
|
+
*
|
|
43
|
+
* Two triggers, both cheap: right after a turn settles (the moment the diff
|
|
44
|
+
* changed) and a throttled sweep over every live session (a human editing in
|
|
45
|
+
* the worktree, a build writing files, a ship landing). Best-effort like the
|
|
46
|
+
* narrator: never awaited by a turn, every failure swallowed.
|
|
47
|
+
*/
|
|
48
|
+
const WORKTREE_SWEEP_MS = 60_000;
|
|
49
|
+
/** How often the sweep refreshes `origin/<base>` before measuring. The
|
|
50
|
+
* behind-count is the whole point of the readout — "someone pushed while you
|
|
51
|
+
* were working" — and without a fetch it would only ever count what this
|
|
52
|
+
* machine already happened to have. Rarer than the sweep because a fetch is
|
|
53
|
+
* network, and a teammate's push being visible within three minutes is the
|
|
54
|
+
* same promise the rest of the product makes. */
|
|
55
|
+
const WORKTREE_FETCH_MS = 3 * 60_000;
|
|
56
|
+
let lastWorktreeSweep = 0;
|
|
57
|
+
/** Sessions this process has already tried to measure. See `reportWorktrees`. */
|
|
58
|
+
const worktreeSeen = new Set();
|
|
59
|
+
let lastWorktreeFetch = 0;
|
|
60
|
+
let sweepingWorktrees = false;
|
|
61
|
+
/**
|
|
62
|
+
* WHERE THE NEXT SWEEP STARTS.
|
|
63
|
+
*
|
|
64
|
+
* A safety valve with a rotation, and the rotation is the load-bearing half.
|
|
65
|
+
* The sweep used to take `activeIds.slice(0, 20)` — a silent truncation of a
|
|
66
|
+
* list the server builds tabs-first and agent places LAST, so on a project
|
|
67
|
+
* with twenty live tabs no agent was EVER measured: no branch diff, no head
|
|
68
|
+
* sha, and none of their trailered commits ever reached a card. Permanently,
|
|
69
|
+
* because the same twenty won every pass.
|
|
70
|
+
*
|
|
71
|
+
* Chunking removes the cut for any realistic project (see below). The cursor
|
|
72
|
+
* is what makes the residual cap fair rather than arbitrary: past it, the
|
|
73
|
+
* places that missed one sweep are the ones that lead the next.
|
|
74
|
+
*/
|
|
75
|
+
let worktreeCursor = 0;
|
|
76
|
+
const postWorktrees = async (reports) => {
|
|
77
|
+
if (!reports.length) return;
|
|
78
|
+
try {
|
|
79
|
+
await fetch(WORKTREES_URL, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
83
|
+
'User-Agent': USER_AGENT,
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
},
|
|
86
|
+
signal: AbortSignal.timeout(20_000),
|
|
87
|
+
body: JSON.stringify({ reports }),
|
|
88
|
+
});
|
|
89
|
+
} catch {
|
|
90
|
+
/* a readout — the next sweep carries it */
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const sessionWorktreeReport = (sessionId) => {
|
|
94
|
+
// The session's PLACE, not its name: a tab in the checkout is measured in
|
|
95
|
+
// the checkout, and a tab sharing another tab's worktree is measured there.
|
|
96
|
+
const place = placeOf(sessionId);
|
|
97
|
+
if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
|
|
98
|
+
const wt = placeDir(sessionId);
|
|
99
|
+
const d = worktreeDiff(wt, baseRef());
|
|
100
|
+
if (!d) return null;
|
|
101
|
+
// WHAT IS LISTENING in this worktree, attributed by the CWD of the process
|
|
102
|
+
// holding the socket. It rides the sweep the daemon already makes rather
|
|
103
|
+
// than taking a beat of its own, exactly as the commit trailers do — and
|
|
104
|
+
// like them it needs no version floor, because it is a daemon→server report
|
|
105
|
+
// on an endpoint that already exists. An older server ignores the key.
|
|
106
|
+
//
|
|
107
|
+
// The browser NEVER names a directory and never names a port this did not
|
|
108
|
+
// report: ports are global to a box and a worktree is not, so this
|
|
109
|
+
// measurement is the security boundary for the whole preview feature.
|
|
110
|
+
// `listeningSupported` says whether this machine can measure AT ALL, which
|
|
111
|
+
// is a different fact from finding nothing. Windows reports nothing and a
|
|
112
|
+
// failed scan reports nothing, and both were indistinguishable from an idle
|
|
113
|
+
// worktree — harmless while the only consumer needed a NON-empty array, and
|
|
114
|
+
// a permanent `Starting…` the moment a Run dev offer hangs off an empty one.
|
|
115
|
+
// …AND WHAT IT IS RUNNING, attributed by PROCESS GROUP rather than by cwd.
|
|
116
|
+
// A watcher (`rbxtsc -w`, `tsc --watch`) holds no socket and touches no
|
|
117
|
+
// file for minutes, so it was invisible from a browser in a way it never is
|
|
118
|
+
// in a terminal. Same rules as `listening` beside it: a daemon→server
|
|
119
|
+
// report on an endpoint that already exists, so NO version floor, and
|
|
120
|
+
// `processesSupported` keeps "cannot look" (Windows) apart from "looked and
|
|
121
|
+
// found none", which renders differently.
|
|
122
|
+
const proc = sessionProcesses(sessionId);
|
|
123
|
+
// THE NAME CLAUDE ALREADY GAVE THIS CONVERSATION, relayed.
|
|
124
|
+
//
|
|
125
|
+
// Claude Code titles its own sessions; a Flowviant tab was born "session 3"
|
|
126
|
+
// and stayed that way unless somebody renamed it by hand, so a strip of
|
|
127
|
+
// eight tabs said nothing about any of them. The title is not ours to
|
|
128
|
+
// invent — reading it is the same relay this whole file does, and asking
|
|
129
|
+
// a model for one would be a second brain, which the product forbids.
|
|
130
|
+
//
|
|
131
|
+
// Only CLAUDE tabs have one here: the id is the one the CLI reported at
|
|
132
|
+
// `system.init` and pinned per tab, so a codex or agy tab simply has no
|
|
133
|
+
// marker and reports no title. No runtime check is needed for that — the
|
|
134
|
+
// absent marker IS the check.
|
|
135
|
+
//
|
|
136
|
+
// No version floor: a daemon→server report on an endpoint that already
|
|
137
|
+
// exists, so an older daemon sends no key and the server leaves the name
|
|
138
|
+
// exactly as it found it.
|
|
139
|
+
let title = null;
|
|
140
|
+
try {
|
|
141
|
+
const marker = sessionMetaPath(wt, 'flowviant-claude-session', sessionId);
|
|
142
|
+
if (marker) title = titleForSession(wt, readFileSync(marker, 'utf8').trim());
|
|
143
|
+
} catch {
|
|
144
|
+
/* no marker yet — this tab has not spoken, or is not Claude */
|
|
145
|
+
}
|
|
146
|
+
// The TOTAL rides beside the capped rows, because a list silently cut at
|
|
147
|
+
// twelve answers "what is running in here" with a number that is not true.
|
|
148
|
+
// `wrangler dev` alone opens nine, so the old cap of eight was already
|
|
149
|
+
// dropping a row on an ordinary stack with nothing on the wire to say so.
|
|
150
|
+
const lis = measureListeners(wt);
|
|
151
|
+
/**
|
|
152
|
+
* WHICH BOX MEASURED THIS — on an agent's report only.
|
|
153
|
+
*
|
|
154
|
+
* The server stores it on the agent row so a LATER turn can be checked
|
|
155
|
+
* against the box that actually holds the work: an agent's branch and its
|
|
156
|
+
* conversation exist on one machine's disk until somebody approves it, and
|
|
157
|
+
* two boxes on one credential can both be offered its turns. The daemon
|
|
158
|
+
* reports what it is; the server does the comparing.
|
|
159
|
+
*
|
|
160
|
+
* A daemon→server report on an endpoint that already exists, so no floor —
|
|
161
|
+
* an older daemon sends no key and the agent is left UNATTRIBUTED, which is
|
|
162
|
+
* a third state the server reads as "nobody said" rather than as "not this
|
|
163
|
+
* box". `envpub` is the identity for the same reason the poll uses it: it is
|
|
164
|
+
* durable per box, and the hostname beside it is only a label for a person
|
|
165
|
+
* to read. Absent when the keypair is unreadable — that machine is exempt
|
|
166
|
+
* from arbitration entirely, which is the fail-open direction.
|
|
167
|
+
*
|
|
168
|
+
* TABS GET NOTHING. A tab's place is shared by design and its work is a
|
|
169
|
+
* human's own directory; attributing one would be a fact with no reader.
|
|
170
|
+
*/
|
|
171
|
+
const pub = sessionId.startsWith('a-') && sessionId.length > 2 ? myPubB64() : null;
|
|
172
|
+
/**
|
|
173
|
+
* …AND WHAT THIS MACHINE PUSHED OF IT (0.86.0).
|
|
174
|
+
*
|
|
175
|
+
* The ONE road by which the server learns a push happened: it composes the
|
|
176
|
+
* target name and sends it, and stores nothing until a machine reports
|
|
177
|
+
* back. So an unreported push renders nothing and no surface can name a ref
|
|
178
|
+
* nobody can pull — the same "never assert what you did not observe" rule
|
|
179
|
+
* the box id beside it keeps.
|
|
180
|
+
*
|
|
181
|
+
* NO KEY AT ALL until a target arrives, which is what lets absence keep its
|
|
182
|
+
* one meaning: an older server, or a project with publishing off, leaves
|
|
183
|
+
* the agent reading as never published rather than as failed.
|
|
184
|
+
*
|
|
185
|
+
* Mutually exclusive without a rule of its own, because the state it reads
|
|
186
|
+
* holds a sha or an error and never both.
|
|
187
|
+
*/
|
|
188
|
+
const pushed = sessionId.startsWith('a-') ? agentPublished.get(sessionId) : null;
|
|
189
|
+
return {
|
|
190
|
+
sessionId,
|
|
191
|
+
...d,
|
|
192
|
+
...(pub ? { box: { id: pub, name: MACHINE_HOST } } : {}),
|
|
193
|
+
...(pushed?.sha ? { published: { ref: pushed.ref, sha: pushed.sha } } : {}),
|
|
194
|
+
...(pushed?.error ? { publishError: pushed.error } : {}),
|
|
195
|
+
listening: lis.rows,
|
|
196
|
+
listeningTotal: lis.total,
|
|
197
|
+
listeningSupported: listenersSupported(),
|
|
198
|
+
...(proc === null ? {} : { processes: proc.rows, processesTotal: proc.total }),
|
|
199
|
+
processesSupported: processesSupported(),
|
|
200
|
+
...(title ? { title } : {}),
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
/** One session, now. */
|
|
204
|
+
const reportSessionWorktree = async (sessionId) => {
|
|
205
|
+
const r = sessionWorktreeReport(sessionId);
|
|
206
|
+
if (r) await postWorktrees([r]);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* …AND EVERY OTHER TAB STANDING IN THE SAME DIRECTORY.
|
|
211
|
+
*
|
|
212
|
+
* One tab is not one directory any more. Since tabs moved into the driver's
|
|
213
|
+
* own folder, every tab a person owns resolves to the SAME place — so a turn
|
|
214
|
+
* in tab A changed the directory tab B is also describing, and only tab A was
|
|
215
|
+
* re-measured. Tab B went on rendering its pre-turn `+A −D` for up to a
|
|
216
|
+
* minute, which makes the tab strip visibly disagree with itself about one
|
|
217
|
+
* directory. That is the "why are two tabs showing one dev server" confusion
|
|
218
|
+
* the places readout exists to END, arriving through the diffstat instead.
|
|
219
|
+
*
|
|
220
|
+
* It fires on EVERY turn, every ship and every stop, which is what made this
|
|
221
|
+
* the most-hit instance of the rule and the least visible: nothing is wrong
|
|
222
|
+
* on the tab you are looking at.
|
|
223
|
+
*
|
|
224
|
+
* Bounded by the live set the roster last handed us, and the reports go in
|
|
225
|
+
* ONE post — the endpoint is already batched, and a tab per request would
|
|
226
|
+
* turn a five-tab place into five round trips on every settle.
|
|
227
|
+
*/
|
|
228
|
+
const reportPlaceWorktrees = async (sessionId) => {
|
|
229
|
+
const place = placeOf(sessionId);
|
|
230
|
+
const ids = [sessionId];
|
|
231
|
+
// `worktreeSeen` is the roster's own live set, pruned to `activeWorkSessions`
|
|
232
|
+
// on every sweep — so this can never report a tab that has closed, and it
|
|
233
|
+
// needs no second source of truth about which tabs exist.
|
|
234
|
+
for (const id of worktreeSeen) {
|
|
235
|
+
if (id !== sessionId && placeOf(id) === place) ids.push(id);
|
|
236
|
+
}
|
|
237
|
+
const reports = ids.map(sessionWorktreeReport).filter(Boolean);
|
|
238
|
+
if (reports.length) await postWorktrees(reports);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* THE FIRST MINUTE AFTER A SETTLE — when "run the dev server" actually binds.
|
|
243
|
+
*
|
|
244
|
+
* The settle-time report fires the moment the reply lands, but a dev server
|
|
245
|
+
* the agent just started usually takes a few more seconds to open its socket
|
|
246
|
+
* (vite boots, next compiles). It therefore missed the settle measurement and
|
|
247
|
+
* waited the full 60s sweep — up to a minute of "nothing is running here"
|
|
248
|
+
* over a server that was already up, which is the slowest link in the whole
|
|
249
|
+
* "ask for dev → see the preview" chain. Asked directly: "how do we make it
|
|
250
|
+
* more responsive when the user prompts claude to run dev to waiting for it
|
|
251
|
+
* to appear on the preview?"
|
|
252
|
+
*
|
|
253
|
+
* A DECAYING BURST, and it re-CHECKS before it re-REPORTS: each beat walks
|
|
254
|
+
* /proc for the place's listeners (purely local, no git, no network) and only
|
|
255
|
+
* when the PORT SET actually changed does the full place report run and post.
|
|
256
|
+
* A settle where nothing ever binds costs five /proc walks and zero posts; a
|
|
257
|
+
* dev server that binds at +7s is on the wire at +9 instead of +60. The burst
|
|
258
|
+
* for a place restarts on its next settle, so overlapping turns cannot stack
|
|
259
|
+
* timers, and every timer is unref'd — a readout must never hold the process
|
|
260
|
+
* open.
|
|
261
|
+
*
|
|
262
|
+
* This also serves the OPPOSITE transition for free: a stopped dev server
|
|
263
|
+
* (the panel's Stop, a ctrl-C in a terminal) vanishes from the port set the
|
|
264
|
+
* same way it appeared, so the preview's "origin gone" story starts in
|
|
265
|
+
* seconds too.
|
|
266
|
+
*/
|
|
267
|
+
const LISTEN_BURST_DELAYS_MS = [4_000, 9_000, 16_000, 30_000, 55_000];
|
|
268
|
+
const listenBursts = new Map(); // place -> timers[]
|
|
269
|
+
const listenSignature = (wt) => {
|
|
270
|
+
try {
|
|
271
|
+
const l = measureListeners(wt);
|
|
272
|
+
return l.rows.map((r) => r.port).sort((a, b) => a - b).join(',');
|
|
273
|
+
} catch {
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const burstListeners = (sessionId) => {
|
|
278
|
+
try {
|
|
279
|
+
const place = placeOf(sessionId);
|
|
280
|
+
for (const t of listenBursts.get(place) ?? []) clearTimeout(t);
|
|
281
|
+
const wt = placeDir(sessionId);
|
|
282
|
+
// Captured alongside the settle report, so only a CHANGE after this
|
|
283
|
+
// moment triggers a post — the settle report already said the rest.
|
|
284
|
+
let last = listenSignature(wt);
|
|
285
|
+
const timers = LISTEN_BURST_DELAYS_MS.map((d) =>
|
|
286
|
+
setTimeout(() => {
|
|
287
|
+
try {
|
|
288
|
+
const sig = listenSignature(wt);
|
|
289
|
+
if (sig === last) return;
|
|
290
|
+
last = sig;
|
|
291
|
+
void reportPlaceWorktrees(sessionId).catch(() => {});
|
|
292
|
+
} catch {
|
|
293
|
+
/* a readout — the sweep still carries it */
|
|
294
|
+
}
|
|
295
|
+
}, d)
|
|
296
|
+
);
|
|
297
|
+
for (const t of timers) t.unref?.();
|
|
298
|
+
listenBursts.set(place, timers);
|
|
299
|
+
} catch {
|
|
300
|
+
/* never let the burst break a settle */
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
/** Every live session, throttled — called from the reconcile loop. */
|
|
304
|
+
/**
|
|
305
|
+
* A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
|
|
306
|
+
*
|
|
307
|
+
* The throttle is GLOBAL, not per-session, so a tab opened one second after a
|
|
308
|
+
* sweep waited the remaining fifty-nine for its first measurement — and until
|
|
309
|
+
* it lands there is no branch, no directory and no listeners anywhere in the
|
|
310
|
+
* product, because every one of those readouts is gated on a measurement and
|
|
311
|
+
* renders nothing rather than inventing a state. Asked directly: "how come it
|
|
312
|
+
* takes a while for a new session to show branch and worktree and listeners
|
|
313
|
+
* after i create a new tab."
|
|
314
|
+
*
|
|
315
|
+
* ATTEMPTED, never MEASURED, is what is remembered. A session whose directory
|
|
316
|
+
* cannot be read yet — a pre-places daemon that has not cut one, a worktree
|
|
317
|
+
* mid-creation — would otherwise be "unmeasured" on every poll and force a
|
|
318
|
+
* full sweep each time. Recording the attempt bounds it at exactly one extra
|
|
319
|
+
* sweep per session, ever, after which the normal cadence carries it.
|
|
320
|
+
*/
|
|
321
|
+
const reportWorktrees = (activeIds) => {
|
|
322
|
+
if (!Array.isArray(activeIds) || activeIds.length === 0) return;
|
|
323
|
+
if (sweepingWorktrees) return;
|
|
324
|
+
const firstSight = activeIds.some((id) => !worktreeSeen.has(id));
|
|
325
|
+
if (!firstSight && Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
|
|
326
|
+
// Bounded to LIVE sessions: a long-running daemon must not accumulate a
|
|
327
|
+
// uuid per tab anyone has ever opened. Pruning also means a reopened tab is
|
|
328
|
+
// measured immediately again, which is the same answer for the same reason.
|
|
329
|
+
const live = new Set(activeIds);
|
|
330
|
+
for (const id of worktreeSeen) if (!live.has(id)) worktreeSeen.delete(id);
|
|
331
|
+
for (const id of activeIds) worktreeSeen.add(id);
|
|
332
|
+
// The publish record is bounded the same way and for the same reason: an
|
|
333
|
+
// agent the roster has stopped naming is done, its ref is the server's
|
|
334
|
+
// business now (the merge lane deletes it when the work lands), and a
|
|
335
|
+
// long-running daemon must not accumulate a row per agent that ever ran.
|
|
336
|
+
for (const id of agentPublished.keys()) if (!live.has(id)) agentPublished.delete(id);
|
|
337
|
+
for (const id of agentRemoteAt.keys()) if (!live.has(id)) agentRemoteAt.delete(id);
|
|
338
|
+
pruneSessionGroups(activeIds);
|
|
339
|
+
sweepingWorktrees = true;
|
|
340
|
+
lastWorktreeSweep = Date.now();
|
|
341
|
+
void (async () => {
|
|
342
|
+
try {
|
|
343
|
+
// Refresh the base before measuring, so "3 new on main" means what a
|
|
344
|
+
// person thinks it means. Throttled, best-effort, and never fatal: an
|
|
345
|
+
// offline machine reports the counts it can still compute.
|
|
346
|
+
if (Date.now() - lastWorktreeFetch >= WORKTREE_FETCH_MS) {
|
|
347
|
+
lastWorktreeFetch = Date.now();
|
|
348
|
+
try {
|
|
349
|
+
// ASYNC and TIMED: this runs every three minutes with nobody
|
|
350
|
+
// watching, and a synchronous fetch against a remote that prompts
|
|
351
|
+
// on /dev/tty or a connection gone half-open froze the whole
|
|
352
|
+
// daemon — no polls, no settles, no lease renewals — until it
|
|
353
|
+
// returned. See `gitNetAsync`.
|
|
354
|
+
await gitNetAsync(['fetch', 'origin', '--quiet'], repoRoot);
|
|
355
|
+
} catch {
|
|
356
|
+
/* offline, no remote, or timed out — the numbers just age */
|
|
357
|
+
}
|
|
358
|
+
// The fetch may have moved the base tip — walk and report what
|
|
359
|
+
// landed. Best-effort like everything in this sweep.
|
|
360
|
+
void landed.observe().catch(() => {});
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* MEASURED IN CHUNKS, not truncated to one.
|
|
364
|
+
*
|
|
365
|
+
* The server's endpoint takes twenty entries per request, and this read
|
|
366
|
+
* that bound as "measure twenty places" — so everything past the
|
|
367
|
+
* twentieth was silently never measured, and the server builds that
|
|
368
|
+
* list with agent places at the END. A project with twenty live tabs
|
|
369
|
+
* therefore measured no agent at all: their review pane showed no
|
|
370
|
+
* branch diff, `checkIsStale` had no head to compare against, and their
|
|
371
|
+
* commits never reached the cards they name.
|
|
372
|
+
*
|
|
373
|
+
* The cap on a REQUEST is not a cap on the WORK. Several requests of
|
|
374
|
+
* twenty cost several round trips and each is validated per entry
|
|
375
|
+
* exactly as before, so no contract changes and no floor is needed.
|
|
376
|
+
*
|
|
377
|
+
* `SWEEP_MAX_PLACES` is a bound on the MACHINE — each place costs a
|
|
378
|
+
* `git diff`, a listener scan and a process scan — and the cursor
|
|
379
|
+
* rotates so a project past it still measures everything, just across
|
|
380
|
+
* successive sweeps instead of one.
|
|
381
|
+
*/
|
|
382
|
+
const SWEEP_MAX_PLACES = 60;
|
|
383
|
+
const CHUNK = 20;
|
|
384
|
+
const total = activeIds.length;
|
|
385
|
+
const start = total > SWEEP_MAX_PLACES ? worktreeCursor % total : 0;
|
|
386
|
+
const take = Math.min(total, SWEEP_MAX_PLACES);
|
|
387
|
+
// Rotated slice, so the tail of a long list leads the next sweep rather
|
|
388
|
+
// than never being reached.
|
|
389
|
+
const order = Array.from({ length: take }, (_, i) => activeIds[(start + i) % total]);
|
|
390
|
+
worktreeCursor = total > SWEEP_MAX_PLACES ? (start + take) % total : 0;
|
|
391
|
+
const reports = [];
|
|
392
|
+
for (const id of order) {
|
|
393
|
+
/**
|
|
394
|
+
* KEEP A PUBLISHED BRANCH CURRENT, not merely born.
|
|
395
|
+
*
|
|
396
|
+
* A settle publishes what the turn just wrote, which covers almost
|
|
397
|
+
* everything — but a branch also moves without a turn: the stale
|
|
398
|
+
* path folds base in before a merge, and an operator can commit in
|
|
399
|
+
* the agent's worktree by hand. Without this the remote ref would sit
|
|
400
|
+
* at whatever the last turn left and the durability claim would be
|
|
401
|
+
* quietly false for exactly the branches somebody is working on.
|
|
402
|
+
*
|
|
403
|
+
* ONLY where a target is already known. Nothing here composes a name,
|
|
404
|
+
* so an agent this process has never been told to publish is
|
|
405
|
+
* untouched — and the sha compare inside makes the resting cost of
|
|
406
|
+
* this loop one `rev-parse` per agent.
|
|
407
|
+
*/
|
|
408
|
+
const known = agentPublished.get(id);
|
|
409
|
+
if (known?.ref) await publishAgentBranch(id, known.ref);
|
|
410
|
+
const r = sessionWorktreeReport(id);
|
|
411
|
+
if (r) reports.push(r);
|
|
412
|
+
}
|
|
413
|
+
// One POST per chunk. Awaited in sequence rather than fired together:
|
|
414
|
+
// this runs on the machine's own poll beat and a burst of parallel
|
|
415
|
+
// writes to the same rows buys nothing.
|
|
416
|
+
for (let i = 0; i < reports.length; i += CHUNK) {
|
|
417
|
+
await postWorktrees(reports.slice(i, i + CHUNK));
|
|
418
|
+
}
|
|
419
|
+
} finally {
|
|
420
|
+
sweepingWorktrees = false;
|
|
421
|
+
}
|
|
422
|
+
})();
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
return { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees };
|
|
426
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE A SESSION WORKS — the place map, the directory it resolves to, the
|
|
3
|
+
* worktree cut on first use, and the per-tab marker files beside its git dir.
|
|
4
|
+
*
|
|
5
|
+
* Split out of work.mjs (2026-09-26, SOLID F037). Every other beat — a turn's
|
|
6
|
+
* spawn, the worktree sweep, ship, a preview re-check, the agent lane — asks
|
|
7
|
+
* one of these four questions, and they have to agree: a tab in the checkout
|
|
8
|
+
* measured against `sessions/<id>` reports nothing, and a marker written
|
|
9
|
+
* unscoped hands one tab another's conversation. One home keeps them one
|
|
10
|
+
* answer. The trust boundary (`isSafePathSegment` / `REPO_PLACE`) lives here
|
|
11
|
+
* with the map it guards; the session lane is the only other writer and keeps
|
|
12
|
+
* the same check (its comment says why).
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { git, isSafePathSegment, hasCommits, gitFailure } from './git.mjs';
|
|
17
|
+
|
|
18
|
+
/** The place id meaning "the checkout", not a worktree. Must match the
|
|
19
|
+
* server's REPO_PLACE — it is a wire value, not a local convention. */
|
|
20
|
+
export const REPO_PLACE = 'repo';
|
|
21
|
+
|
|
22
|
+
export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged }) {
|
|
23
|
+
/**
|
|
24
|
+
* WHERE EACH SESSION WORKS, learned from the turns we are handed.
|
|
25
|
+
*
|
|
26
|
+
* Every other beat — the worktree sweep, ship, the preview re-check — has to
|
|
27
|
+
* ask the SAME directory the turn ran in, and only the turn job carries
|
|
28
|
+
* `place`. Caching it here is what keeps them agreeing without a second
|
|
29
|
+
* server→daemon field: a session absent from this map has never run a turn,
|
|
30
|
+
* and its own id is the right answer for that case anyway (it is the default
|
|
31
|
+
* place, and a session with no turn has no worktree either).
|
|
32
|
+
*
|
|
33
|
+
* A tab standing in the CHECKOUT is the case this exists for: its directory
|
|
34
|
+
* is not `sessions/<id>` and never will be, so a sweep that assumed the
|
|
35
|
+
* default would measure a directory that does not exist and report nothing —
|
|
36
|
+
* which is exactly why "I still do not see a preview URL" was true of a tab
|
|
37
|
+
* opened in the checkout.
|
|
38
|
+
*/
|
|
39
|
+
const sessionPlaces = new Map();
|
|
40
|
+
const placeOf = (sessionId) => sessionPlaces.get(sessionId) ?? sessionId;
|
|
41
|
+
/**
|
|
42
|
+
* Places straight off the roster, so a tab is measured the moment it EXISTS
|
|
43
|
+
* rather than after somebody types into it.
|
|
44
|
+
*
|
|
45
|
+
* Learning only from turn jobs meant a tab nobody had spoken to yet was
|
|
46
|
+
* measured at `sessions/<id>` — a directory that does not exist for a tab
|
|
47
|
+
* working in the checkout — so it reported no branch, no diffstat and no
|
|
48
|
+
* ports, and every control reading those had nothing to render.
|
|
49
|
+
*
|
|
50
|
+
* The roster is authoritative and turn jobs still agree with it; a session
|
|
51
|
+
* the server does not name keeps whatever a turn taught, and failing that its
|
|
52
|
+
* own id, which is the pre-places default.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* WHERE EACH TAB WORKS — and it has to be able to UNLEARN.
|
|
56
|
+
*
|
|
57
|
+
* This only ever set. Combined with a server that omitted null places, a tab
|
|
58
|
+
* moved from the checkout back to its own worktree simply stopped being
|
|
59
|
+
* mentioned, and this map kept the old value forever. `placeDir` decides
|
|
60
|
+
* where a turn is SPAWNED and where the worktree is measured, so the browser
|
|
61
|
+
* said "its own worktree" while the CLI went on working in the checkout, and
|
|
62
|
+
* the tab reported the checkout's listeners as its own. That is the exact
|
|
63
|
+
* confusion the whole places feature exists to prevent.
|
|
64
|
+
*
|
|
65
|
+
* An explicit `null` now means "its own worktree" and DELETES the entry.
|
|
66
|
+
* Absence of the whole map still means "an older server said nothing", which
|
|
67
|
+
* is the only thing absence can safely mean.
|
|
68
|
+
*/
|
|
69
|
+
const learnPlaces = (map) => {
|
|
70
|
+
if (!map || typeof map !== 'object') return;
|
|
71
|
+
for (const [sid, place] of Object.entries(map)) {
|
|
72
|
+
// VALIDATE AT THE TRUST BOUNDARY. `placeDir` joins this value straight
|
|
73
|
+
// into `sessions/<place>` and it is the directory a turn is SPAWNED in
|
|
74
|
+
// and a preview port is measured against — the security boundary for the
|
|
75
|
+
// whole preview feature. The server resolves place to an enum (never a
|
|
76
|
+
// path), but a server bug or compromise sending `../../etc` would
|
|
77
|
+
// otherwise point a session's measured directory anywhere on the box and
|
|
78
|
+
// defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
|
|
79
|
+
// already reject an unsafe segment; checking at the intake covers the
|
|
80
|
+
// consumers that do not (the preview-claim `placeDir` did not re-check).
|
|
81
|
+
// NOT the only writer: `processWorkTurns` stores a turn job's place too,
|
|
82
|
+
// and keeps the same check — validating one intake and not the other is
|
|
83
|
+
// one deploy away from validating neither. REPO_PLACE resolves to
|
|
84
|
+
// repoRoot, so it is allowed through despite not being a path segment.
|
|
85
|
+
if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
|
|
86
|
+
sessionPlaces.set(sid, place);
|
|
87
|
+
// null / '' / an unsafe value: the server is telling us this tab is in its
|
|
88
|
+
// OWN worktree (or is malformed). Falling back to the default requires
|
|
89
|
+
// forgetting, not ignoring.
|
|
90
|
+
else sessionPlaces.delete(sid);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
/** The DIRECTORY a session works in. Every path that used to build
|
|
94
|
+
* `sessions/<id>` by hand goes through here, or a tab in the checkout gets
|
|
95
|
+
* measured against a directory that does not exist. */
|
|
96
|
+
const placeDir = (sessionId) => {
|
|
97
|
+
const place = placeOf(sessionId);
|
|
98
|
+
return place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* This tab's worktree — its held context, expressed as a place, ON A BRANCH.
|
|
103
|
+
*
|
|
104
|
+
* Fresh: branch `session/<id>` off the current base. Existing: touched not at
|
|
105
|
+
* all — no fetch-reset-clean like a plan directory, because the dirty state
|
|
106
|
+
* is the point. If the directory was retired but the branch survives, the
|
|
107
|
+
* worktree re-attaches to the branch and the committed work is still there.
|
|
108
|
+
*/
|
|
109
|
+
/**
|
|
110
|
+
* WHERE A SESSION WORKS — its PLACE, which is a directory on a branch.
|
|
111
|
+
*
|
|
112
|
+
* A session used to BE a worktree: one tab, one directory, cut at birth and
|
|
113
|
+
* retired at close. That binding was never an isolation guarantee — a turn
|
|
114
|
+
* runs with permissions skipped, so the worktree is a starting directory and
|
|
115
|
+
* not a fence, and any agent could always `cd` into another one. The product
|
|
116
|
+
* was asserting an invariant it did not have.
|
|
117
|
+
*
|
|
118
|
+
* So a session now REFERENCES a place rather than being one. Many sessions
|
|
119
|
+
* may name the same place; a session may name the repo checkout itself; and
|
|
120
|
+
* `session/<own-id>` is simply the DEFAULT place, cut fresh at first turn,
|
|
121
|
+
* which is why an absent `place` behaves exactly as every existing tab does.
|
|
122
|
+
*
|
|
123
|
+
* `'repo'` IS NOT A DIRECTORY NAME AND MUST NOT BECOME ONE. It resolves to
|
|
124
|
+
* the checkout the daemon already serves — never created, never retired,
|
|
125
|
+
* because it is not ours to remove. The value reaching here is a server-side
|
|
126
|
+
* enum, never a browser-supplied path: `sessions.routes.ts` resolves it the
|
|
127
|
+
* same way adoption resolves a cwd, and for the same reason.
|
|
128
|
+
*/
|
|
129
|
+
const placeWtFor = (placeId, baseAt) => {
|
|
130
|
+
if (placeId === REPO_PLACE) {
|
|
131
|
+
// The checkout. `fresh: false` on purpose — nothing was opened, so no
|
|
132
|
+
// caller may treat this as a newly-cut branch.
|
|
133
|
+
return { wt: repoRoot, fresh: false };
|
|
134
|
+
}
|
|
135
|
+
if (!isSafePathSegment(placeId)) return null;
|
|
136
|
+
const sessionId = placeId;
|
|
137
|
+
const wt = join(baseDir, 'sessions', sessionId);
|
|
138
|
+
const fresh = !existsSync(wt);
|
|
139
|
+
if (fresh) {
|
|
140
|
+
const branch = `session/${sessionId}`;
|
|
141
|
+
// `baseAt` is the adoption override: a tab born from a terminal session
|
|
142
|
+
// branches from THAT checkout's HEAD, because the conversation being
|
|
143
|
+
// resumed was had against those commits — putting it on the project base
|
|
144
|
+
// would hand it a repo state it has never seen. Everything else is
|
|
145
|
+
// unchanged, the attach fallback included: a surviving branch already
|
|
146
|
+
// chose its base, and re-basing it here would move committed work.
|
|
147
|
+
const at = baseAt || baseRef();
|
|
148
|
+
try {
|
|
149
|
+
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
150
|
+
} catch {
|
|
151
|
+
git(['worktree', 'prune'], repoRoot);
|
|
152
|
+
// A directory and a branch just stopped existing. The Repository block
|
|
153
|
+
// would otherwise keep listing both until its own 60s scan came round.
|
|
154
|
+
onRepoChanged();
|
|
155
|
+
try {
|
|
156
|
+
// The branch may already exist (a retired directory's work) — attach.
|
|
157
|
+
git(['worktree', 'add', wt, branch], repoRoot);
|
|
158
|
+
} catch {
|
|
159
|
+
try {
|
|
160
|
+
git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
// Kept for the caller to SAY: an agent turn that cannot get a
|
|
163
|
+
// worktree used to end "nothing" with no words at all.
|
|
164
|
+
placeWtFor.lastError = hasCommits(repoRoot)
|
|
165
|
+
? `git could not create the worktree from ${at}: ${gitFailure(e)}`
|
|
166
|
+
: `the repository ${repoRoot} has no commits yet, so there is nothing to branch from. Make a first commit there, then send the agent any message to try again.`;
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// NOTHING IS MATERIALIZED INTO A FRESH WORKTREE ANY MORE (2026-09-21),
|
|
172
|
+
// and nothing replaced it.
|
|
173
|
+
//
|
|
174
|
+
// Two blocks stood here: one that wrote the decrypted vault bundle into a
|
|
175
|
+
// newly-created worktree, and a second that retried on the next turn for
|
|
176
|
+
// the restart window where the bundle had not warmed yet. The vault is
|
|
177
|
+
// deleted — the owner: "no i dont want it" — so Flowviant holds no secret
|
|
178
|
+
// for this project and has nothing to write.
|
|
179
|
+
//
|
|
180
|
+
// WHAT THE SOURCE OF A WORKTREE'S ENV IS NOW: the repo's own `.env`
|
|
181
|
+
// files, wherever the repo puts them. A worktree branches from the
|
|
182
|
+
// checkout, and a gitignored `.env` is by definition not in a fresh one —
|
|
183
|
+
// which is the ordinary behaviour of `git worktree add`, the same thing a
|
|
184
|
+
// person gets typing it themselves, and the operator's problem to solve
|
|
185
|
+
// the way they already solve it (a symlink, a `direnv`, a copy in a
|
|
186
|
+
// setup script). This product no longer claims otherwise, and the claim
|
|
187
|
+
// is what was worth deleting: a promise that every session tab comes up
|
|
188
|
+
// with the team's secrets is only kept while a vault exists to keep it.
|
|
189
|
+
//
|
|
190
|
+
// The CLI child still inherits the daemon's own environment exactly as it
|
|
191
|
+
// always did (runTurn.mjs spawns with `{...process.env}`), so anything
|
|
192
|
+
// exported in the shell the daemon was started from is present here.
|
|
193
|
+
}
|
|
194
|
+
return { wt, fresh };
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A file in the worktree's PRIVATE git dir (…/.git/worktrees/<name>). It
|
|
199
|
+
* travels with the worktree, dies with `git worktree remove`, and is
|
|
200
|
+
* invisible to `git status` — so nothing stored here can ever make the
|
|
201
|
+
* session look dirty (a dirty tree refuses ships). A marker file in the
|
|
202
|
+
* working tree itself would show up as an untracked path and block every
|
|
203
|
+
* ship of an otherwise-clean session.
|
|
204
|
+
*/
|
|
205
|
+
/**
|
|
206
|
+
* A file beside the worktree's git dir, holding something about ONE TAB.
|
|
207
|
+
*
|
|
208
|
+
* `scope` is the session id and is NOT optional for anything per-tab. These
|
|
209
|
+
* markers were named bare — `flowviant-codex-thread`, `flowviant-agy-
|
|
210
|
+
* conversation` — which was unambiguous while one directory meant one tab.
|
|
211
|
+
* The day tabs moved into their driver's project folder, every tab there
|
|
212
|
+
* started reading and writing ONE marker: tab B would resume tab A's codex
|
|
213
|
+
* thread, and the last turn to finish would overwrite the id for both.
|
|
214
|
+
*
|
|
215
|
+
* The turn LOCK is deliberately still un-scoped — it guards the directory
|
|
216
|
+
* against a second CLI, which is a property of the place and not of a tab.
|
|
217
|
+
*/
|
|
218
|
+
const sessionMetaPath = (wt, name, scope) => {
|
|
219
|
+
try {
|
|
220
|
+
const safe = scope && /^[A-Za-z0-9_-]{1,64}$/.test(String(scope)) ? `-${scope}` : '';
|
|
221
|
+
return join(git(['rev-parse', '--absolute-git-dir'], wt), `${name}${safe}`);
|
|
222
|
+
} catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return { sessionPlaces, placeOf, learnPlaces, placeDir, placeWtFor, sessionMetaPath };
|
|
228
|
+
}
|