flowviant 0.116.0 → 0.124.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/README.md +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +90 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +113 -31
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
14
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, MACHINE_HOST } from './config.mjs';
|
|
15
|
-
import { gitNetAsync
|
|
15
|
+
import { gitNetAsync } from './git.mjs';
|
|
16
16
|
import { measureListeners, listenersSupported } from './listeners.mjs';
|
|
17
17
|
import { processesSupported } from './processes.mjs';
|
|
18
18
|
import { titleForSession } from './claudeSessions.mjs';
|
|
19
|
+
import { RESUME_MARKERS } from './conversationIds.mjs';
|
|
19
20
|
import { worktreeDiff } from './worktreeDiff.mjs';
|
|
20
21
|
import { myPubB64 } from './boxIdentity.mjs';
|
|
21
22
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
22
|
-
import {
|
|
23
|
+
import { isValidPlace } from './workPlaces.mjs';
|
|
23
24
|
|
|
24
25
|
export function createWorkPlaceReports({
|
|
25
26
|
repoRoot,
|
|
@@ -94,7 +95,9 @@ export function createWorkPlaceReports({
|
|
|
94
95
|
// The session's PLACE, not its name: a tab in the checkout is measured in
|
|
95
96
|
// the checkout, and a tab sharing another tab's worktree is measured there.
|
|
96
97
|
const place = placeOf(sessionId);
|
|
97
|
-
|
|
98
|
+
// The one validity rule (`isValidPlace`, workPlaces.mjs, audit 2026-10-10
|
|
99
|
+
// F141) — not a third spelling of it.
|
|
100
|
+
if (!isValidPlace(place)) return null;
|
|
98
101
|
const wt = placeDir(sessionId);
|
|
99
102
|
const d = worktreeDiff(wt, baseRef());
|
|
100
103
|
if (!d) return null;
|
|
@@ -138,7 +141,7 @@ export function createWorkPlaceReports({
|
|
|
138
141
|
// exactly as it found it.
|
|
139
142
|
let title = null;
|
|
140
143
|
try {
|
|
141
|
-
const marker = sessionMetaPath(wt,
|
|
144
|
+
const marker = sessionMetaPath(wt, RESUME_MARKERS.claude, sessionId);
|
|
142
145
|
if (marker) title = titleForSession(wt, readFileSync(marker, 'utf8').trim());
|
|
143
146
|
} catch {
|
|
144
147
|
/* no marker yet — this tab has not spoken, or is not Claude */
|
package/bin/lib/workPlaces.mjs
CHANGED
|
@@ -7,18 +7,62 @@
|
|
|
7
7
|
* one of these four questions, and they have to agree: a tab in the checkout
|
|
8
8
|
* measured against `sessions/<id>` reports nothing, and a marker written
|
|
9
9
|
* unscoped hands one tab another's conversation. One home keeps them one
|
|
10
|
-
* answer. The trust boundary (`
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
10
|
+
* answer. The trust boundary (`isValidPlace`) lives here with the map it
|
|
11
|
+
* guards, and `notePlace` is the map's ONLY writer: the roster's intake and
|
|
12
|
+
* the session lane's turn intake both go through it (audit 2026-10-10, F141),
|
|
13
|
+
* so a lane that learns a place cannot store one unvalidated.
|
|
13
14
|
*/
|
|
14
|
-
import { existsSync } from 'node:fs';
|
|
15
|
+
import { existsSync, statSync } from 'node:fs';
|
|
15
16
|
import { join } from 'node:path';
|
|
16
|
-
import { git, isSafePathSegment, hasCommits, gitFailure } from './git.mjs';
|
|
17
|
+
import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
|
|
17
18
|
|
|
18
19
|
/** 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
|
+
* server's REPO_PLACE — it is a wire value, not a local convention. The
|
|
21
|
+
* release parity gate holds it there (2026-10-10, audit F163). */
|
|
20
22
|
export const REPO_PLACE = 'repo';
|
|
21
23
|
|
|
24
|
+
/** A PERSON's place starts with this — the server's `userPlace(userId)` is
|
|
25
|
+
* `u-<userId>` — which is how the worktree sweep tells a person's manual copy
|
|
26
|
+
* (fast-forwarded) from an agent's or a session's (never touched). A wire
|
|
27
|
+
* value too, held to `userPlace` by the release parity gate (2026-10-10,
|
|
28
|
+
* audit F163). */
|
|
29
|
+
export const PERSON_PLACE_PREFIX = 'u-';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A PLACE THIS MACHINE WILL RESOLVE: the checkout, or a safe path segment.
|
|
33
|
+
*
|
|
34
|
+
* The value becomes the directory a turn is SPAWNED in and a preview port is
|
|
35
|
+
* measured against — the security boundary for the whole preview feature. The
|
|
36
|
+
* server resolves place to an enum (never a path), but a server bug or
|
|
37
|
+
* compromise sending `../../etc` would otherwise point a session's measured
|
|
38
|
+
* directory anywhere on the box. REPO_PLACE resolves to repoRoot, so it is
|
|
39
|
+
* allowed through despite not being a path segment.
|
|
40
|
+
*/
|
|
41
|
+
export function isValidPlace(place) {
|
|
42
|
+
return typeof place === 'string' && (place === REPO_PLACE || isSafePathSegment(place));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A PLACE'S OWN WORKTREE DIRECTORY — `sessions/<place>` under the worktrees
|
|
47
|
+
* root — or null for an id that is not a safe path segment. Pure.
|
|
48
|
+
*
|
|
49
|
+
* No REPO_PLACE arm, on purpose: the agent lanes hold an AGENT's place, which
|
|
50
|
+
* is never the checkout, so a stray `'repo'` there keeps naming a directory
|
|
51
|
+
* that does not exist rather than becoming the operator's checkout.
|
|
52
|
+
*/
|
|
53
|
+
export function worktreePath(baseDir, place) {
|
|
54
|
+
return isSafePathSegment(place) ? join(baseDir, 'sessions', place) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* THE DIRECTORY A PLACE RESOLVES TO: the checkout for REPO_PLACE, its own
|
|
59
|
+
* worktree otherwise, null for an unsafe id. Pure — `placeDir` is this over
|
|
60
|
+
* the session map, and nothing else spells the rule.
|
|
61
|
+
*/
|
|
62
|
+
export function placePath({ repoRoot, baseDir }, place) {
|
|
63
|
+
return place === REPO_PLACE ? repoRoot : worktreePath(baseDir, place);
|
|
64
|
+
}
|
|
65
|
+
|
|
22
66
|
export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged }) {
|
|
23
67
|
/**
|
|
24
68
|
* WHERE EACH SESSION WORKS, learned from the turns we are handed.
|
|
@@ -66,36 +110,59 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
66
110
|
* Absence of the whole map still means "an older server said nothing", which
|
|
67
111
|
* is the only thing absence can safely mean.
|
|
68
112
|
*/
|
|
113
|
+
/**
|
|
114
|
+
* THE MAP'S ONE WRITER — validate, then store; false (and nothing stored)
|
|
115
|
+
* for a place this machine refuses.
|
|
116
|
+
*
|
|
117
|
+
* VALIDATED AT THE TRUST BOUNDARY (`isValidPlace` says why). There were two
|
|
118
|
+
* writers — the roster's intake below and the session lane's turn intake —
|
|
119
|
+
* each spelling the check, with a comment keeping them in step by hand:
|
|
120
|
+
* validating one intake and not the other is one deploy away from
|
|
121
|
+
* validating neither. Now there is one door, and the map is not handed out.
|
|
122
|
+
*/
|
|
123
|
+
const notePlace = (sessionId, place) => {
|
|
124
|
+
if (!isValidPlace(place)) return false;
|
|
125
|
+
sessionPlaces.set(sessionId, place);
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
69
128
|
const learnPlaces = (map) => {
|
|
70
129
|
if (!map || typeof map !== 'object') return;
|
|
71
130
|
for (const [sid, place] of Object.entries(map)) {
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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);
|
|
131
|
+
// `sessionWorktreeReport` and `placeWtFor` already reject an unsafe
|
|
132
|
+
// segment; checking at the intake covers the consumers that do not (the
|
|
133
|
+
// preview-claim `placeDir` did not re-check).
|
|
87
134
|
// null / '' / an unsafe value: the server is telling us this tab is in its
|
|
88
135
|
// OWN worktree (or is malformed). Falling back to the default requires
|
|
89
136
|
// forgetting, not ignoring.
|
|
90
|
-
|
|
137
|
+
if (!notePlace(sid, place)) sessionPlaces.delete(sid);
|
|
91
138
|
}
|
|
92
139
|
};
|
|
140
|
+
/** The directory a PLACE resolves to, on this machine's roots. */
|
|
141
|
+
const dirOfPlace = (place) => placePath({ repoRoot, baseDir }, place);
|
|
93
142
|
/** The DIRECTORY a session works in. Every path that used to build
|
|
94
143
|
* `sessions/<id>` by hand goes through here, or a tab in the checkout gets
|
|
95
|
-
* measured against a directory that does not exist.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
* measured against a directory that does not exist. Null for a session id
|
|
145
|
+
* that is not a safe segment and has learnt no place. */
|
|
146
|
+
const placeDir = (sessionId) => dirOfPlace(placeOf(sessionId));
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* THE BRANCH STANDING IN `wt` — what is actually checked out, not what we
|
|
150
|
+
* named it at birth — as `{ branch }`, or `{ detached: true }` when HEAD
|
|
151
|
+
* names no branch. A directory that is gone (a retired or closed tab) cannot
|
|
152
|
+
* be asked, so `fallback` is the answer: the one case where the recorded
|
|
153
|
+
* name is the only thing there is.
|
|
154
|
+
*
|
|
155
|
+
* Moved verbatim out of ship and the PR lane (audit 2026-10-10, F141), which
|
|
156
|
+
* spelled it twice so that "which branch would ship" and "which branch would
|
|
157
|
+
* open a PR" agreed only by hand.
|
|
158
|
+
*/
|
|
159
|
+
const branchAt = (wt, fallback) => {
|
|
160
|
+
if (!existsSync(wt)) return { branch: fallback };
|
|
161
|
+
try {
|
|
162
|
+
return { branch: git(['symbolic-ref', '--short', 'HEAD'], wt) };
|
|
163
|
+
} catch {
|
|
164
|
+
return { detached: true };
|
|
165
|
+
}
|
|
99
166
|
};
|
|
100
167
|
|
|
101
168
|
/**
|
|
@@ -132,12 +199,11 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
132
199
|
// caller may treat this as a newly-cut branch.
|
|
133
200
|
return { wt: repoRoot, fresh: false };
|
|
134
201
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const wt = join(baseDir, 'sessions', sessionId);
|
|
202
|
+
const wt = worktreePath(baseDir, placeId);
|
|
203
|
+
if (!wt) return null;
|
|
138
204
|
const fresh = !existsSync(wt);
|
|
139
205
|
if (fresh) {
|
|
140
|
-
const branch =
|
|
206
|
+
const branch = sessionBranch(placeId);
|
|
141
207
|
// `baseAt` is the adoption override: a tab born from a terminal session
|
|
142
208
|
// branches from THAT checkout's HEAD, because the conversation being
|
|
143
209
|
// resumed was had against those commits — putting it on the project base
|
|
@@ -224,5 +290,21 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
224
290
|
}
|
|
225
291
|
};
|
|
226
292
|
|
|
227
|
-
|
|
293
|
+
/**
|
|
294
|
+
* WHEN `git worktree add` CUT THIS WORKTREE: its private git dir's
|
|
295
|
+
* `commondir`, written once at creation and left alone by commits, lock,
|
|
296
|
+
* repair and move. A recreated worktree is a new birth. Null for the
|
|
297
|
+
* checkout (no such file) and for anything unreadable — read by the
|
|
298
|
+
* artifact reporter, which then has no evidence a place held older rows.
|
|
299
|
+
*/
|
|
300
|
+
const worktreeBornAt = (wt) => {
|
|
301
|
+
const path = sessionMetaPath(wt, 'commondir');
|
|
302
|
+
try {
|
|
303
|
+
return path ? statSync(path).mtimeMs : null;
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt };
|
|
228
310
|
}
|
package/bin/lib/workPreviews.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** Session preview claims, tunnels, and retirement. */
|
|
2
|
-
import { FLEET_URL
|
|
2
|
+
import { FLEET_URL } from './config.mjs';
|
|
3
3
|
import { isSafePathSegment } from './git.mjs';
|
|
4
4
|
import { originFor as measuredOrigin } from './listeners.mjs';
|
|
5
5
|
import { openTunnel as openLiveTunnel } from './preview.mjs';
|
|
6
6
|
import { note } from './ui.mjs';
|
|
7
7
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
8
|
+
import { claimFleetLease, settleLease } from './fleetPost.mjs';
|
|
8
9
|
|
|
9
10
|
/** `originFor` and `openTunnel` are injectable only so the lane can be driven
|
|
10
11
|
* without a dev server or cloudflared (workPreviews.test.mjs); work.mjs passes
|
|
@@ -19,7 +20,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
19
20
|
// generated password, on a quick tunnel. This daemon never starts an app: the
|
|
20
21
|
// deleted live-preview feature ran a repo-declared command through a shell,
|
|
21
22
|
// and that is the reason it is deleted. Here the human runs their own server,
|
|
22
|
-
// `
|
|
23
|
+
// `measureListeners` notices it, and this only ever wraps a port that measurement
|
|
23
24
|
// already named for that session.
|
|
24
25
|
//
|
|
25
26
|
// CLAIM BEFORE ACTING. Two daemons legitimately share one fleet credential —
|
|
@@ -35,47 +36,26 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
35
36
|
// Returns the parsed JSON (or null on any failure) rather than discarding
|
|
36
37
|
// it — an OPEN settle's caller needs to see `data.settled === false`, the
|
|
37
38
|
// sharper share-id check on the server's side of a re-share racing the
|
|
38
|
-
// open's own round trip (see the openTunnel call site below).
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const res = await fetch(PREVIEW_DONE_URL, {
|
|
42
|
-
method: 'POST',
|
|
43
|
-
headers: {
|
|
44
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
45
|
-
'User-Agent': USER_AGENT,
|
|
46
|
-
'Content-Type': 'application/json',
|
|
47
|
-
},
|
|
48
|
-
signal: AbortSignal.timeout(30_000),
|
|
49
|
-
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
50
|
-
});
|
|
51
|
-
return await res.json().catch(() => null);
|
|
52
|
-
} catch {
|
|
53
|
-
/* the row stops being confirmed and reads as ended — which is true */
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
};
|
|
39
|
+
// open's own round trip (see the openTunnel call site below). On a failure
|
|
40
|
+
// the row stops being confirmed and reads as ended — which is true.
|
|
41
|
+
const postPreview = (body) => settleLease(PREVIEW_DONE_URL, body, { timeoutMs: 30_000 });
|
|
57
42
|
|
|
58
43
|
// `shareId` is the share the daemon was OFFERED, echoed off the job. A
|
|
59
44
|
// re-share inside the claim's round trip rotates the id server-side, so a
|
|
60
45
|
// claim naming the old one matches nothing and this daemon opens no tunnel
|
|
61
46
|
// for a request that no longer exists. Absent-safe: an older server that
|
|
62
47
|
// does not read the field just claims on the place alone, the old rule.
|
|
63
|
-
|
|
48
|
+
// Could not claim → do nothing at all. The other daemon may have.
|
|
49
|
+
const claimPreview = (sessionId, shareId) =>
|
|
50
|
+
claimFleetLease(PREVIEW_CLAIM_URL, { sessionId, ...(shareId ? { shareId } : {}) });
|
|
51
|
+
|
|
52
|
+
/** Stop a tunnel (a live entry or an open's result) without letting its
|
|
53
|
+
* teardown throw into the lane: the stop is best-effort everywhere. */
|
|
54
|
+
const stopQuietly = (live) => {
|
|
64
55
|
try {
|
|
65
|
-
|
|
66
|
-
method: 'POST',
|
|
67
|
-
headers: {
|
|
68
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
69
|
-
'User-Agent': USER_AGENT,
|
|
70
|
-
'Content-Type': 'application/json',
|
|
71
|
-
},
|
|
72
|
-
signal: AbortSignal.timeout(15_000),
|
|
73
|
-
body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE, ...(shareId ? { shareId } : {}) }),
|
|
74
|
-
});
|
|
75
|
-
const j = await res.json().catch(() => null);
|
|
76
|
-
return j?.data?.claimed === true;
|
|
56
|
+
live?.stop();
|
|
77
57
|
} catch {
|
|
78
|
-
|
|
58
|
+
/* best-effort */
|
|
79
59
|
}
|
|
80
60
|
};
|
|
81
61
|
|
|
@@ -85,13 +65,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
85
65
|
const stopPreview = async (sessionId, reason) => {
|
|
86
66
|
const live = livePreviews.get(sessionId);
|
|
87
67
|
livePreviews.delete(sessionId);
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
live.stop();
|
|
91
|
-
} catch {
|
|
92
|
-
/* best-effort */
|
|
93
|
-
}
|
|
94
|
-
}
|
|
68
|
+
stopQuietly(live);
|
|
95
69
|
// Confirm only a teardown we actually PERFORMED. The stop job is a
|
|
96
70
|
// broadcast — every daemon on the credential gets it — and the one holding
|
|
97
71
|
// nothing used to answer instantly, flipping the row to 'ended' so the
|
|
@@ -115,6 +89,15 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
115
89
|
});
|
|
116
90
|
};
|
|
117
91
|
|
|
92
|
+
/** One teardown per session at a time: a session mid-claim (an open or
|
|
93
|
+
* another stop on its way) is left to that claim, and the next tick asks
|
|
94
|
+
* again. The stop job and a closed tab share this guard. */
|
|
95
|
+
const stopOnce = (sessionId, reason) => {
|
|
96
|
+
if (previewClaiming.has(sessionId)) return;
|
|
97
|
+
previewClaiming.add(sessionId);
|
|
98
|
+
void stopPreview(sessionId, reason).finally(() => previewClaiming.delete(sessionId));
|
|
99
|
+
};
|
|
100
|
+
|
|
118
101
|
const processPreviewJobs = (jobs) => {
|
|
119
102
|
if (!Array.isArray(jobs) || jobs.length === 0) return;
|
|
120
103
|
for (const job of jobs.slice(0, 5)) {
|
|
@@ -123,9 +106,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
123
106
|
if (!isSafePathSegment(sessionId)) continue;
|
|
124
107
|
|
|
125
108
|
if (job?.action === 'stop') {
|
|
126
|
-
|
|
127
|
-
previewClaiming.add(sessionId);
|
|
128
|
-
void stopPreview(sessionId, 'stopped').finally(() => previewClaiming.delete(sessionId));
|
|
109
|
+
stopOnce(sessionId, 'stopped');
|
|
129
110
|
continue;
|
|
130
111
|
}
|
|
131
112
|
|
|
@@ -171,6 +152,13 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
171
152
|
if (previewClaiming.has(sessionId)) continue;
|
|
172
153
|
previewClaiming.add(sessionId);
|
|
173
154
|
|
|
155
|
+
// EVERY report about this job's share names the share (F017,
|
|
156
|
+
// 2026-09-26): an un-scoped `ended` or `error` could end a replacement
|
|
157
|
+
// share opened on the same session in the meantime. One spelling, so a
|
|
158
|
+
// new teardown callback cannot forget it (audit 2026-10-10, F162).
|
|
159
|
+
// Absent when the job carried none (an older server), the old rule.
|
|
160
|
+
const settleShare = (body) => postPreview({ sessionId, ...body, ...(shareId ? { shareId } : {}) });
|
|
161
|
+
|
|
174
162
|
void (async () => {
|
|
175
163
|
try {
|
|
176
164
|
if (!(await claimPreview(sessionId, shareId))) return; // somebody else has it
|
|
@@ -188,21 +176,13 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
188
176
|
// machine never measured. A wire string, not a field — no floor.
|
|
189
177
|
const refusal = originFor(wt, port).error;
|
|
190
178
|
if (refusal) {
|
|
191
|
-
await
|
|
192
|
-
sessionId,
|
|
193
|
-
error: refusal,
|
|
194
|
-
...(shareId ? { shareId } : {}),
|
|
195
|
-
});
|
|
179
|
+
await settleShare({ error: refusal });
|
|
196
180
|
return;
|
|
197
181
|
}
|
|
198
182
|
// Replace anything this session already had — one tab, one door.
|
|
199
183
|
const prev = livePreviews.get(sessionId);
|
|
200
184
|
if (prev) {
|
|
201
|
-
|
|
202
|
-
prev.stop();
|
|
203
|
-
} catch {
|
|
204
|
-
/* best-effort */
|
|
205
|
-
}
|
|
185
|
+
stopQuietly(prev);
|
|
206
186
|
livePreviews.delete(sessionId);
|
|
207
187
|
}
|
|
208
188
|
const t = await openTunnel({
|
|
@@ -218,12 +198,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
218
198
|
// without this the app would print "live" over a 502.
|
|
219
199
|
onDead: () => {
|
|
220
200
|
livePreviews.delete(sessionId);
|
|
221
|
-
void
|
|
222
|
-
sessionId,
|
|
223
|
-
ended: true,
|
|
224
|
-
endedReason: 'origin_gone',
|
|
225
|
-
...(shareId ? { shareId } : {}),
|
|
226
|
-
});
|
|
201
|
+
void settleShare({ ended: true, endedReason: 'origin_gone' });
|
|
227
202
|
},
|
|
228
203
|
// ATTRIBUTION rides the probe, not just the open: a freed default
|
|
229
204
|
// port (5173…) rebound by any other process on the box would keep
|
|
@@ -236,38 +211,27 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
236
211
|
// owner can re-share the port without restarting the daemon.
|
|
237
212
|
onAbuse: () => {
|
|
238
213
|
livePreviews.delete(sessionId);
|
|
239
|
-
void
|
|
240
|
-
sessionId,
|
|
241
|
-
ended: true,
|
|
242
|
-
endedReason: 'abuse',
|
|
243
|
-
...(shareId ? { shareId } : {}),
|
|
244
|
-
});
|
|
214
|
+
void settleShare({ ended: true, endedReason: 'abuse' });
|
|
245
215
|
},
|
|
246
216
|
// cloudflared died AFTER publishing (quick tunnels get dropped).
|
|
247
217
|
// Without this the daemon kept heartbeating a hostname that 530s.
|
|
248
218
|
onTunnelGone: () => {
|
|
249
219
|
livePreviews.delete(sessionId);
|
|
250
|
-
void
|
|
251
|
-
sessionId,
|
|
252
|
-
error: 'the tunnel dropped — share it again to reopen.',
|
|
253
|
-
...(shareId ? { shareId } : {}),
|
|
254
|
-
});
|
|
220
|
+
void settleShare({ error: 'the tunnel dropped — share it again to reopen.' });
|
|
255
221
|
},
|
|
256
222
|
});
|
|
257
223
|
if (t.error) {
|
|
258
|
-
await
|
|
224
|
+
await settleShare({ error: t.error });
|
|
259
225
|
return;
|
|
260
226
|
}
|
|
261
227
|
livePreviews.set(sessionId, { port, shareId, url: t.url, stop: t.stop });
|
|
262
228
|
// The gate we ACTUALLY installed, so the app never asserts a door
|
|
263
229
|
// nobody observed. An older server ignores the field.
|
|
264
|
-
const settled = await
|
|
265
|
-
sessionId,
|
|
230
|
+
const settled = await settleShare({
|
|
266
231
|
url: t.url,
|
|
267
232
|
user: t.user,
|
|
268
233
|
password: t.password,
|
|
269
234
|
gate: t.gateMode,
|
|
270
|
-
...(shareId ? { shareId } : {}),
|
|
271
235
|
});
|
|
272
236
|
// A re-share (or a Stop) can land inside this open's own round
|
|
273
237
|
// trip and rotate the share id server-side — the settle is then
|
|
@@ -279,11 +243,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
279
243
|
// settle for an older one).
|
|
280
244
|
if (settled?.data?.settled === false) {
|
|
281
245
|
if (livePreviews.get(sessionId)?.stop === t.stop) livePreviews.delete(sessionId);
|
|
282
|
-
|
|
283
|
-
t.stop();
|
|
284
|
-
} catch {
|
|
285
|
-
/* best-effort */
|
|
286
|
-
}
|
|
246
|
+
stopQuietly(t);
|
|
287
247
|
}
|
|
288
248
|
} finally {
|
|
289
249
|
previewClaiming.delete(sessionId);
|
|
@@ -292,26 +252,6 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
292
252
|
}
|
|
293
253
|
};
|
|
294
254
|
|
|
295
|
-
/** The sessionIds this machine is still serving — sent on the poll so the
|
|
296
|
-
* server can tell a live share from one whose machine went away. Silence
|
|
297
|
-
* must never read as "live". */
|
|
298
|
-
/**
|
|
299
|
-
* The daemon's own shape check on an argv the server parsed.
|
|
300
|
-
*
|
|
301
|
-
* Deliberately a SHAPE check and not a re-parse: the server owns the policy
|
|
302
|
-
* (which argv[0] are allowed, the install refusal, the length caps) and the
|
|
303
|
-
* machine owns the refusal to EXECUTE something malformed. It is duplicated
|
|
304
|
-
* rather than imported because this package ships standalone and cannot
|
|
305
|
-
* depend on the monorepo — the mirror is small, and `devCommand.ts` is where
|
|
306
|
-
* the real rules live.
|
|
307
|
-
*/
|
|
308
|
-
const isPlausibleDevArgv = (argv) =>
|
|
309
|
-
Array.isArray(argv) &&
|
|
310
|
-
argv.length > 0 &&
|
|
311
|
-
argv.length <= 8 &&
|
|
312
|
-
argv.every((a) => typeof a === 'string' && a.length > 0 && a.length <= 200) &&
|
|
313
|
-
!argv.some((a) => /[&|;<>`$(){}*?~\\]/.test(a));
|
|
314
|
-
|
|
315
255
|
// ── DEV RUNS ARE DELETED (2026-08-26) ─────────────────────────────────
|
|
316
256
|
//
|
|
317
257
|
// The machine no longer starts application processes. It never learned to
|
|
@@ -321,7 +261,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
321
261
|
// per ecosystem forever.
|
|
322
262
|
//
|
|
323
263
|
// Nothing downstream is lost, because SHARING NEVER DEPENDED ON US STARTING
|
|
324
|
-
// IT. `
|
|
264
|
+
// IT. `measureListeners` attributes a listening socket to a place by the cwd of
|
|
325
265
|
// the process holding it, so a server the driver's own agent started in the
|
|
326
266
|
// tab is measured exactly like one this file used to spawn. The web renders
|
|
327
267
|
// that measured list and a person picks which port to share.
|
|
@@ -331,6 +271,9 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
331
271
|
// and its adopt-across-re-exec dance. If supervision is ever wanted back it
|
|
332
272
|
// returns as "supervise this process", never as "run dev".
|
|
333
273
|
|
|
274
|
+
/** The sessionIds this machine is still serving — sent on the poll so the
|
|
275
|
+
* server can tell a live share from one whose machine went away. Silence
|
|
276
|
+
* must never read as "live". */
|
|
334
277
|
const livePreviewIds = () => [...livePreviews.keys()];
|
|
335
278
|
|
|
336
279
|
/**
|
|
@@ -349,9 +292,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
349
292
|
const live = new Set(activeIds);
|
|
350
293
|
for (const sessionId of [...livePreviews.keys()]) {
|
|
351
294
|
if (live.has(sessionId)) continue;
|
|
352
|
-
|
|
353
|
-
previewClaiming.add(sessionId);
|
|
354
|
-
void stopPreview(sessionId, 'tab_closed').finally(() => previewClaiming.delete(sessionId));
|
|
295
|
+
stopOnce(sessionId, 'tab_closed');
|
|
355
296
|
}
|
|
356
297
|
};
|
|
357
298
|
|
|
@@ -359,13 +300,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
|
|
|
359
300
|
* them would strand a public hostname until the box rebooted — the exact
|
|
360
301
|
* case `reapOrphanPreviews` exists to clean up after an UNgraceful death. */
|
|
361
302
|
const shutdownPreviews = () => {
|
|
362
|
-
for (const [, live] of livePreviews)
|
|
363
|
-
try {
|
|
364
|
-
live.stop();
|
|
365
|
-
} catch {
|
|
366
|
-
/* best-effort */
|
|
367
|
-
}
|
|
368
|
-
}
|
|
303
|
+
for (const [, live] of livePreviews) stopQuietly(live);
|
|
369
304
|
livePreviews.clear();
|
|
370
305
|
};
|
|
371
306
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** Claims and execution for stopping a measured session process. */
|
|
2
|
-
import { FLEET_URL
|
|
2
|
+
import { FLEET_URL } from './config.mjs';
|
|
3
3
|
import { isSafePathSegment } from './git.mjs';
|
|
4
4
|
import { measureListeners } from './listeners.mjs';
|
|
5
5
|
import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
|
|
6
6
|
import { processAlive } from './procRegistry.mjs';
|
|
7
7
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
8
|
+
import { claimFleetLease, settleLease } from './fleetPost.mjs';
|
|
8
9
|
|
|
9
10
|
export function createWorkProcesses({ placeDir, reportPlaceWorktrees, sessionGroups }) {
|
|
10
11
|
const KILL_DONE_URL = fleetEndpoint('kill-done', FLEET_URL);
|
|
@@ -47,41 +48,13 @@ export function createWorkProcesses({ placeDir, reportPlaceWorktrees, sessionGro
|
|
|
47
48
|
// is the failure this whole comment is about.
|
|
48
49
|
const killing = new Set(); // job ids in flight on this tick
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
method: 'POST',
|
|
54
|
-
headers: {
|
|
55
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
56
|
-
'User-Agent': USER_AGENT,
|
|
57
|
-
'Content-Type': 'application/json',
|
|
58
|
-
},
|
|
59
|
-
signal: AbortSignal.timeout(30_000),
|
|
60
|
-
body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
|
|
61
|
-
});
|
|
62
|
-
} catch {
|
|
63
|
-
/* unsettled, and the server expires it — the asker is told, never spun */
|
|
64
|
-
}
|
|
65
|
-
};
|
|
51
|
+
// Unsettled on any failure, and the server expires it — the asker is told,
|
|
52
|
+
// never spun.
|
|
53
|
+
const postKill = (body) => settleLease(KILL_DONE_URL, body, { timeoutMs: 30_000 });
|
|
66
54
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
method: 'POST',
|
|
71
|
-
headers: {
|
|
72
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
73
|
-
'User-Agent': USER_AGENT,
|
|
74
|
-
'Content-Type': 'application/json',
|
|
75
|
-
},
|
|
76
|
-
signal: AbortSignal.timeout(15_000),
|
|
77
|
-
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
78
|
-
});
|
|
79
|
-
const j = await res.json().catch(() => null);
|
|
80
|
-
return j?.data?.claimed === true;
|
|
81
|
-
} catch {
|
|
82
|
-
return false; // the peer may hold it; doing nothing is the safe answer
|
|
83
|
-
}
|
|
84
|
-
};
|
|
55
|
+
// Could not claim → do nothing: the peer may hold it (fleetPost.mjs's
|
|
56
|
+
// `claimFleetLease`, the one double-run guard).
|
|
57
|
+
const claimKill = (id) => claimFleetLease(KILL_CLAIM_URL, { id });
|
|
85
58
|
|
|
86
59
|
/**
|
|
87
60
|
* Is this pid, RIGHT NOW, one of the things we told the browser about for
|