flowviant 0.116.0 → 0.117.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 +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -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 +88 -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 +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- 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/readGuard.mjs +41 -7
- 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/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -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 +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- 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/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- 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 +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +71 -139
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -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 +96 -30
- 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 +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* blocking the turn.
|
|
9
9
|
*/
|
|
10
10
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { scrubCut } from './uplinkScrub.mjs';
|
|
12
12
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
13
13
|
|
|
14
14
|
// THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
|
|
@@ -21,6 +21,10 @@ import { fleetEndpoint } from './fleetWire.mjs';
|
|
|
21
21
|
// a kill, and again at settle. Best-effort: a failed post drops the
|
|
22
22
|
// batch rather than blocking the turn — the surface says it is the
|
|
23
23
|
// machine's report, not a syscall trace.
|
|
24
|
+
/** The server's own cap on one audited command (sessionCommands.ts
|
|
25
|
+
* `MAX_COMMAND_CHARS`), applied here AFTER the scrub. */
|
|
26
|
+
const MAX_COMMAND_CHARS = 2000;
|
|
27
|
+
|
|
24
28
|
export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
|
|
25
29
|
const SESSION_COMMANDS_URL = fleetEndpoint('session-commands', FLEET_URL);
|
|
26
30
|
const auditBatch = [];
|
|
@@ -55,7 +59,12 @@ export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
|
|
|
55
59
|
// `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
|
|
56
60
|
// in the admin view, so the one uplink that omitted scrub was the
|
|
57
61
|
// one most likely to carry a plaintext secret.
|
|
58
|
-
|
|
62
|
+
//
|
|
63
|
+
// SCRUBBED WHOLE, THEN CUT (2026-10-10, the audit's F128): the parsers
|
|
64
|
+
// used to cut the command at 2000 before it reached this scrub, so a
|
|
65
|
+
// secret straddling that character kept its prefix. They now hand a
|
|
66
|
+
// bounded window, and the server's cap is applied here, last.
|
|
67
|
+
auditBatch.push({ command: scrubCut(a.command, MAX_COMMAND_CHARS), at: new Date().toISOString() });
|
|
59
68
|
if (auditBatch.length >= 25) flushAudit();
|
|
60
69
|
};
|
|
61
70
|
return { flushAudit, auditCommand };
|
package/bin/lib/workDiffs.mjs
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
/** Commit diff requests and their bounded reports. */
|
|
2
2
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
3
|
-
import { git } from './git.mjs';
|
|
3
|
+
import { git, GIT_WIDE_BUFFER, OBJECT_ID_RE } from './git.mjs';
|
|
4
4
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
5
|
+
import { scrub, scrubCut } from './uplinkScrub.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A PATCH BIGGER THAN NODE'S 1 MiB DEFAULT BUFFER (2026-10-10, the audit's
|
|
9
|
+
* F150). `git()` (git.mjs) defaults to Node's 1 MiB `maxBuffer`, so any
|
|
10
|
+
* commit whose patch passed 1 MiB failed `ENOBUFS` and the viewer was told it
|
|
11
|
+
* errored, when it should have been shown the first 256 KB marked truncated.
|
|
12
|
+
* The same call with room for GIT_WIDE_BUFFER (8 MiB); past that, what git
|
|
13
|
+
* printed up to the bound is still a patch to truncate, never an error.
|
|
14
|
+
*/
|
|
15
|
+
function gitBig(args, cwd) {
|
|
16
|
+
try {
|
|
17
|
+
return git(args, cwd, { maxBuffer: GIT_WIDE_BUFFER });
|
|
18
|
+
} catch (e) {
|
|
19
|
+
if (e?.code === 'ENOBUFS' && typeof e.stdout === 'string' && e.stdout) return e.stdout;
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
5
23
|
|
|
6
24
|
export function createWorkDiffs({ repoRoot }) {
|
|
7
25
|
const DIFF_DONE_URL = fleetEndpoint('diff-done', FLEET_URL);
|
|
@@ -23,6 +41,18 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
23
41
|
* machine" is a real answer, and a viewer spinning forever is the worst thing
|
|
24
42
|
* this can do. The one thing never sent is a guess — an empty patch would
|
|
25
43
|
* tell a reader the commit changed nothing.
|
|
44
|
+
*
|
|
45
|
+
* ONLY A COMMIT, AND SCRUBBED (2026-10-10, the audit's F150). The sha is a
|
|
46
|
+
* server-named hex string, and `git show <sha>` prints whatever object it
|
|
47
|
+
* names — a blob, a tree, a tag — so it is first resolved as
|
|
48
|
+
* `<sha>^{commit}` and anything else is answered "no such commit on this
|
|
49
|
+
* machine" with no content. (The hex check below already keeps it from
|
|
50
|
+
* reading as an option, so no `--end-of-options`, which git before 2.30
|
|
51
|
+
* does not take in `rev-parse`.) No reachability check: the run page asks
|
|
52
|
+
* for unpushed agent commits on load, and a remote-branch bound would refuse
|
|
53
|
+
* them. And the patch is the one repository uplink that skipped `scrub`; it
|
|
54
|
+
* now passes through it WHOLE, before the line-boundary cut — the
|
|
55
|
+
* scrub-then-cut order every other lane keeps.
|
|
26
56
|
*/
|
|
27
57
|
const MAX_PATCH_BYTES = 256 * 1024;
|
|
28
58
|
const servedDiffs = new Set();
|
|
@@ -48,7 +78,7 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
48
78
|
const sha = String(job?.sha || '').toLowerCase();
|
|
49
79
|
// A sha becomes a `git` argument. The server validates too; one place
|
|
50
80
|
// doing this check is one deploy away from being zero places.
|
|
51
|
-
if (
|
|
81
|
+
if (!OBJECT_ID_RE.test(sha)) continue;
|
|
52
82
|
// In-flight guard, not a cache: the server stops offering a sha the
|
|
53
83
|
// moment it settles, so this only stops the SAME poll's job being
|
|
54
84
|
// started twice while its `git show` is still running.
|
|
@@ -56,6 +86,22 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
56
86
|
servedDiffs.add(sha);
|
|
57
87
|
void (async () => {
|
|
58
88
|
try {
|
|
89
|
+
let commit = '';
|
|
90
|
+
try {
|
|
91
|
+
commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], repoRoot);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// Exit 1 is `--verify --quiet`'s "not a commit here" (absent,
|
|
94
|
+
// ambiguous, or another object type); anything else is git's own
|
|
95
|
+
// failure, relayed in its words as before.
|
|
96
|
+
if (e?.status !== 1) {
|
|
97
|
+
await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!/^[0-9a-f]{40,64}$/.test(commit)) {
|
|
102
|
+
await postDiff({ sha, error: 'no such commit on this machine' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
59
105
|
let patch = '';
|
|
60
106
|
const files = [];
|
|
61
107
|
try {
|
|
@@ -63,13 +109,13 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
63
109
|
// date already reached the card on the worktree sweep, and
|
|
64
110
|
// repeating them inside the patch would put a second copy above
|
|
65
111
|
// every hunk.
|
|
66
|
-
patch =
|
|
112
|
+
patch = scrub(gitBig(['show', '--patch', '--format=', commit], repoRoot));
|
|
67
113
|
} catch (e) {
|
|
68
|
-
await postDiff({ sha, error:
|
|
114
|
+
await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
|
|
69
115
|
return;
|
|
70
116
|
}
|
|
71
117
|
try {
|
|
72
|
-
const raw =
|
|
118
|
+
const raw = gitBig(['show', '--numstat', '--format=', commit], repoRoot);
|
|
73
119
|
for (const line of raw.split('\n')) {
|
|
74
120
|
if (!line.trim()) continue;
|
|
75
121
|
const [a, d, ...rest] = line.split('\t');
|
|
@@ -77,7 +123,7 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
77
123
|
if (!path) continue;
|
|
78
124
|
const binary = a === '-' || d === '-';
|
|
79
125
|
files.push({
|
|
80
|
-
path: path
|
|
126
|
+
path: scrubCut(path, 300),
|
|
81
127
|
added: binary ? 0 : Number(a) || 0,
|
|
82
128
|
deleted: binary ? 0 : Number(d) || 0,
|
|
83
129
|
...(binary ? { binary: true } : {}),
|
package/bin/lib/workIntake.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** One leased incoming ticket, drafted by this machine's read-only CLI. */
|
|
2
|
-
import { FLEET_URL,
|
|
2
|
+
import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
|
|
3
3
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
4
|
+
import { claimFleetLease } from './fleetPost.mjs';
|
|
4
5
|
import { runTurn as defaultRunTurn } from './runTurn.mjs';
|
|
5
6
|
import { pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
|
|
6
|
-
import {
|
|
7
|
+
import { runCappedTurn } from './workCappedTurn.mjs';
|
|
7
8
|
import { REPO_PLACE } from './workPlaces.mjs';
|
|
8
9
|
import { SYSTEM_INTAKE, INTAKE_KICKOFF } from './prompts.mjs';
|
|
9
10
|
import { parseIntakeDraft } from './intakeDraft.mjs';
|
|
@@ -31,25 +32,12 @@ export function createWorkIntake({
|
|
|
31
32
|
const done = fleetEndpoint(INTAKE_DONE_ENDPOINT, FLEET_URL);
|
|
32
33
|
const intake = new Set();
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'User-Agent': USER_AGENT,
|
|
41
|
-
'Content-Type': 'application/json',
|
|
42
|
-
},
|
|
43
|
-
signal: AbortSignal.timeout(15_000),
|
|
44
|
-
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
45
|
-
});
|
|
46
|
-
// A 409 means another holder (or a dismissed/landed ticket). No CLI and
|
|
47
|
-
// no settle: this machine never acquired the right to answer for it.
|
|
48
|
-
return res.status === 200 && (await res.json().catch(() => null))?.ok === true;
|
|
49
|
-
} catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
};
|
|
35
|
+
// The intake route answers `{ ok: true }` on a 200, not `data.claimed`
|
|
36
|
+
// (machineIntake.routes.ts) — the same lease rule with its own winner test.
|
|
37
|
+
// A 409 means another holder (or a dismissed/landed ticket). No CLI and no
|
|
38
|
+
// settle: this machine never acquired the right to answer for it.
|
|
39
|
+
const claim = (id) =>
|
|
40
|
+
claimFleetLease(claiming, { id }, { won: (r) => r.status === 200 && r.json?.ok === true });
|
|
53
41
|
|
|
54
42
|
const report = async (body) => {
|
|
55
43
|
await postBestEffort(done, { ...body, instance: DAEMON_INSTANCE });
|
|
@@ -68,56 +56,43 @@ export function createWorkIntake({
|
|
|
68
56
|
}
|
|
69
57
|
|
|
70
58
|
let out = '';
|
|
71
|
-
let child = null;
|
|
72
|
-
let session = null;
|
|
73
|
-
let timer = null;
|
|
74
59
|
let wedged = false;
|
|
60
|
+
let spawned = false;
|
|
75
61
|
let usage = null;
|
|
76
62
|
// A spawned CLI can fail before reporting counters. Null records that the
|
|
77
63
|
// spend is unmeasured; four zeroes would falsely say it spent nothing.
|
|
78
|
-
const spent = () =>
|
|
64
|
+
const spent = () => spawned ? { usage } : {};
|
|
79
65
|
try {
|
|
80
66
|
await inPlace(REPO_PLACE, false, async () => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
releaseSlot();
|
|
102
|
-
// Start the cap at spawn, never while a checkout writer holds the
|
|
103
|
-
// reader lock; SIGKILL and resolve even if a grandchild holds stdio.
|
|
104
|
-
timer = setTimeout(() => {
|
|
105
|
-
wedged = true;
|
|
106
|
-
try { ch.kill('SIGKILL'); } catch { /* already gone */ }
|
|
107
|
-
stopWaiting('');
|
|
108
|
-
}, 10 * 60_000);
|
|
109
|
-
timer.unref?.();
|
|
67
|
+
// One capped consult turn (workCappedTurn.mjs): the cap starts at
|
|
68
|
+
// spawn, never while a checkout writer holds the reader lock, and
|
|
69
|
+
// SIGKILLs the child and resolves even if a grandchild holds stdio.
|
|
70
|
+
({ out, wedged } = await runCappedTurn({
|
|
71
|
+
runTurn,
|
|
72
|
+
capMs: 10 * 60_000,
|
|
73
|
+
workChildren,
|
|
74
|
+
releaseSlot,
|
|
75
|
+
transcriptCwd: repoRoot,
|
|
76
|
+
turnArgs: {
|
|
77
|
+
prompt: INTAKE_KICKOFF(job),
|
|
78
|
+
system: SYSTEM_INTAKE,
|
|
79
|
+
profile: 'consult',
|
|
80
|
+
cwd: repoRoot,
|
|
81
|
+
runtime,
|
|
82
|
+
answerFromResult: true,
|
|
83
|
+
label: c.cyan('[intake]'),
|
|
84
|
+
// There is no MCP principal for a ticket, and no model or effort pin:
|
|
85
|
+
// this one-shot reader uses the machine's consult defaults.
|
|
86
|
+
onUsage: (u) => { usage = addUsage(usage, { ...u, runtime }); },
|
|
110
87
|
},
|
|
111
|
-
|
|
112
|
-
|
|
88
|
+
// Marked by the lane itself, so a turn that throws after its spawn
|
|
89
|
+
// still reports what it measured.
|
|
90
|
+
onSpawn: () => { spawned = true; },
|
|
91
|
+
}));
|
|
113
92
|
});
|
|
114
93
|
} catch (e) {
|
|
115
94
|
await report({ id, outcome: INTAKE_OUTCOMES.nothing, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
|
|
116
95
|
return;
|
|
117
|
-
} finally {
|
|
118
|
-
if (timer) clearTimeout(timer);
|
|
119
|
-
if (child) workChildren.delete(child);
|
|
120
|
-
if (session) setTimeout(() => removeProbeTranscript(repoRoot, session), 750).unref?.();
|
|
121
96
|
}
|
|
122
97
|
|
|
123
98
|
if (wedged) {
|
|
@@ -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
15
|
import { existsSync } 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,5 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
224
290
|
}
|
|
225
291
|
};
|
|
226
292
|
|
|
227
|
-
return {
|
|
293
|
+
return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath };
|
|
228
294
|
}
|