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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FILES THE HUMAN ATTACHED, downloaded into the place a turn runs in.
|
|
3
|
+
*
|
|
4
|
+
* Split out of work.mjs (2026-09-26, SOLID F037). One trust boundary — a
|
|
5
|
+
* server-sent name becoming a path on somebody's machine — with its caps, its
|
|
6
|
+
* collision rule and its git exclude, reached only from the session lane.
|
|
7
|
+
* The name rule itself is safeFileName.mjs's.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
12
|
+
import { excludeInWorktree } from './git.mjs';
|
|
13
|
+
import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
|
|
14
|
+
import { safeFileName } from './safeFileName.mjs';
|
|
15
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
16
|
+
|
|
17
|
+
export function createWorkAttachments() {
|
|
18
|
+
const ATTACHMENT_URL = fleetEndpoint('attachment', FLEET_URL);
|
|
19
|
+
/**
|
|
20
|
+
* FILES THE HUMAN ATTACHED, brought to where a CLI can read them.
|
|
21
|
+
*
|
|
22
|
+
* A screenshot in a chat bubble is useless to an agent; a path is not. So the
|
|
23
|
+
* turn's attachments are downloaded into `.flowviant/uploads/` inside the
|
|
24
|
+
* session's own worktree and the prompt is handed the relative paths.
|
|
25
|
+
*
|
|
26
|
+
* `.flowviant/` rather than the repo proper, and gitignored-or-not it is
|
|
27
|
+
* never committed by us: these are the human's inputs to a conversation, not
|
|
28
|
+
* project files. The name is re-sanitized HERE even though the server already
|
|
29
|
+
* did it — this string becomes a path on someone's machine, and one place
|
|
30
|
+
* doing that check is one deploy away from being zero places.
|
|
31
|
+
*/
|
|
32
|
+
const UPLOAD_DIR = '.flowviant/uploads';
|
|
33
|
+
const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
|
34
|
+
// `safeFileName` (bin/lib/safeFileName.mjs — the server's rule, one daemon
|
|
35
|
+
// home): THE EXTENSION SURVIVES A CUT, and it is idempotent over its own
|
|
36
|
+
// output, so re-applying it to what the server already sanitized is a no-op
|
|
37
|
+
// and the two sides never disagree about the cut. An empty name is
|
|
38
|
+
// `attachment`, the server's own fallback.
|
|
39
|
+
const safeUploadName = (raw) => safeFileName(raw, 'attachment');
|
|
40
|
+
/** @returns relative paths written, in the order the human attached them. */
|
|
41
|
+
const fetchAttachments = async (wt, attachments) => {
|
|
42
|
+
if (!Array.isArray(attachments) || attachments.length === 0) return [];
|
|
43
|
+
const dir = join(wt, UPLOAD_DIR);
|
|
44
|
+
try {
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
// "Never committed by us" has to be true for GIT, not just for this code:
|
|
50
|
+
// an untracked `.flowviant/` makes the whole worktree dirty, which refuses
|
|
51
|
+
// every ship, exempts the tree from closed-tab retirement forever, and
|
|
52
|
+
// shows the human's own uploads in the rail as session changes. Same
|
|
53
|
+
// mechanism the materialized env files used until the vault was deleted —
|
|
54
|
+
// the exclude file git actually reads (git.mjs, where the helper moved when
|
|
55
|
+
// env.mjs shrank), which already skips lines it has written before, so
|
|
56
|
+
// calling it per fetch is idempotent. NARROWED 2026-09-23 to the paths
|
|
57
|
+
// this daemon writes (`FLOWVIANT_OWN_PATHS`, knowledgeLibrary.mjs says why): the
|
|
58
|
+
// whole-directory line also hid a repo's own new `.flowviant/check.json`
|
|
59
|
+
// from its agent's `git add -A`.
|
|
60
|
+
excludeInWorktree(wt, FLOWVIANT_OWN_PATHS);
|
|
61
|
+
const written = [];
|
|
62
|
+
for (const a of attachments.slice(0, 8)) {
|
|
63
|
+
if (!a?.id || typeof a.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(a.id)) continue;
|
|
64
|
+
if (Number(a.size) > ATTACHMENT_MAX_BYTES) continue;
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`${ATTACHMENT_URL}/${a.id}`, {
|
|
67
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
|
|
68
|
+
signal: AbortSignal.timeout(60_000),
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok) continue;
|
|
71
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
72
|
+
if (buf.byteLength === 0 || buf.byteLength > ATTACHMENT_MAX_BYTES) continue;
|
|
73
|
+
// Collisions are real (two screenshots both named Screenshot.png), and
|
|
74
|
+
// silently overwriting one with the other loses a file the human sent.
|
|
75
|
+
let name = safeUploadName(a.name);
|
|
76
|
+
if (existsSync(join(dir, name))) {
|
|
77
|
+
const dot = name.lastIndexOf('.');
|
|
78
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
79
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
80
|
+
name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
|
|
81
|
+
}
|
|
82
|
+
writeFileSync(join(dir, name), buf);
|
|
83
|
+
written.push(`${UPLOAD_DIR}/${name}`);
|
|
84
|
+
} catch {
|
|
85
|
+
/* one file failing must not fail the turn — the prompt lists what
|
|
86
|
+
actually arrived, so the agent never chases a path that isn't there */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return written;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return { fetchAttachments };
|
|
93
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH BRAIN, AT WHICH EFFORT — the one guard every turn's `--model` and
|
|
3
|
+
* `--effort` pass through before they ride argv.
|
|
4
|
+
*
|
|
5
|
+
* Split out of work.mjs (2026-09-26, SOLID F037). Two lanes read it — the
|
|
6
|
+
* Workbench tab (workSessionTurns.mjs) and the agent turn (workAgentTurns.mjs)
|
|
7
|
+
* — each importing it directly — and it is pure over the job and the runtime
|
|
8
|
+
* table, so its home is a module of its own rather than either lane.
|
|
9
|
+
*/
|
|
10
|
+
import { RUNTIMES } from './runtimes.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The shape a per-tab model name must have before it rides argv as
|
|
14
|
+
* `--model <name>`. Conservative for the same reason the codex thread id is
|
|
15
|
+
* (below): it comes off the wire and lands in a child process's arguments —
|
|
16
|
+
* alphanumerics plus dot/dash/underscore, at most 40 characters, and NEVER a
|
|
17
|
+
* leading dash, which is an argv that parses as a flag.
|
|
18
|
+
*/
|
|
19
|
+
const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* WHICH BRAIN, AT WHICH EFFORT — the tab's own pick, off the roster.
|
|
23
|
+
*
|
|
24
|
+
* Absent is the resting state and it must stay genuinely absent: every tab ran
|
|
25
|
+
* with no `--model` and no `--effort` until now, so a job that names neither
|
|
26
|
+
* has to produce the byte-identical argv it produced yesterday — Claude falling
|
|
27
|
+
* back to the machine's MODEL pin, codex and agy to their own defaults. Hence
|
|
28
|
+
* an object with the key MISSING rather than one holding null: a null would
|
|
29
|
+
* reach the builders as a value and Claude's `model || MODEL` is the only one
|
|
30
|
+
* that would survive it.
|
|
31
|
+
*
|
|
32
|
+
* A value that fails its guard is DROPPED, not passed through and not an error.
|
|
33
|
+
* The honest outcome of "the server named a model this machine can't spell" is
|
|
34
|
+
* the machine's own default — a turn that runs — rather than a flag no CLI
|
|
35
|
+
* understands and a tab that fails every message.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* THE EFFORT IS CHECKED AGAINST THE CLI THAT WILL RUN THE TURN (2026-09-26).
|
|
39
|
+
* One global ladder once accepted `max` for every runtime, and Codex has no
|
|
40
|
+
* `max`: a skewed or previously queued Codex job reached argv as a
|
|
41
|
+
* `model_reasoning_effort` its CLI refuses. The per-CLI list lives beside each
|
|
42
|
+
* runtime's argv builder (`RUNTIMES[id].efforts`, runtime{Claude,Codex,Antigravity}.mjs); a runtime this
|
|
43
|
+
* daemon does not define runs nothing, so it spells no effort either.
|
|
44
|
+
*/
|
|
45
|
+
export function brainFor(job, runtimeId) {
|
|
46
|
+
const out = {};
|
|
47
|
+
const model = typeof job?.model === 'string' ? job.model.trim() : '';
|
|
48
|
+
if (model && WORK_MODEL_RE.test(model)) out.model = model;
|
|
49
|
+
const effort = typeof job?.effort === 'string' ? job.effort.trim() : '';
|
|
50
|
+
const efforts = RUNTIMES[runtimeId]?.efforts ?? [];
|
|
51
|
+
if (effort && efforts.includes(effort)) out.effort = effort;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MACHINE'S LIVE CLI CHILDREN — the registry every lane adds its spawn
|
|
3
|
+
* to, the teardown that SIGTERMs them, the count the concurrency ceiling is
|
|
4
|
+
* on, and the admission built over that count.
|
|
5
|
+
*
|
|
6
|
+
* Split out of work.mjs (2026-09-26, SOLID F037). Six lanes write
|
|
7
|
+
* `workChildren` (tab turns, agent turns, plans, the check, the pre-review)
|
|
8
|
+
* and three things read it (teardown, `liveTurnCount`, the machine snapshot's
|
|
9
|
+
* per-task RSS), so the map and everything that reads it as "what is running
|
|
10
|
+
* on this box" belong together — and apart from any one lane.
|
|
11
|
+
*/
|
|
12
|
+
import { createAdmission } from './admission.mjs';
|
|
13
|
+
|
|
14
|
+
export function createWorkChildren({ extraLiveTurns }) {
|
|
15
|
+
/**
|
|
16
|
+
* Live session-turn CLI children. The daemon's teardown SIGTERMs them: an
|
|
17
|
+
* orphaned CLI keeps editing the session worktree and burning quota after
|
|
18
|
+
* the daemon is gone. Each child's pid-lock is deliberately LEFT IN PLACE —
|
|
19
|
+
* a CLI can trap SIGTERM to finish an in-flight request and outlive this
|
|
20
|
+
* loop by seconds, and removing the lock in the same tick handed the
|
|
21
|
+
* restarted daemon a green light to spawn a second CLI into the same held
|
|
22
|
+
* context. turnLockedByLivePid already covers both outcomes: it waits while
|
|
23
|
+
* the pid lives and clears the lock once it is dead.
|
|
24
|
+
*
|
|
25
|
+
* THE VALUE IS THE ID THE CHILD SERVES, and it used to be the pid-lock path —
|
|
26
|
+
* which nothing in this file has ever read. A write-only value is not free:
|
|
27
|
+
* the machine snapshot's per-task RSS (`/fleet/machine`) is the one readout
|
|
28
|
+
* that answers "which task is holding nine gigabytes", and it was being built
|
|
29
|
+
* from the dispatch-era `workers` map, which nothing has `.set()` since that
|
|
30
|
+
* lane was deleted — so the column its own server handler calls the
|
|
31
|
+
* load-bearing half of the report had never been populated once. This map is
|
|
32
|
+
* the only place that knows both the pid and whose work it is, so it carries
|
|
33
|
+
* both. `null` where there is no id to name (a Deploy press is not a task).
|
|
34
|
+
*/
|
|
35
|
+
const workChildren = new Map(); // child process -> sessionId | agentId | null
|
|
36
|
+
/**
|
|
37
|
+
* Children whose whole PROCESS GROUP must go, not just the child.
|
|
38
|
+
*
|
|
39
|
+
* The standing rule is the opposite — teardown SIGTERMs the CLI child and
|
|
40
|
+
* never its group, precisely so an unattended auto-update does not kill the
|
|
41
|
+
* driver's dev server, which a turn started INTO the CLI's group. That rule
|
|
42
|
+
* is about the CLI's group and stays.
|
|
43
|
+
*
|
|
44
|
+
* A project CHECK is a different group entirely: the daemon spawns it itself,
|
|
45
|
+
* `detached` with `shell: true`, so its group holds the check command and
|
|
46
|
+
* nothing else — no dev server of anybody's. And `shell: true` is exactly
|
|
47
|
+
* what makes signalling only the child useless: a compound command like
|
|
48
|
+
* `npm run lint && npm test` leaves `/bin/sh` as the child, so SIGTERM killed
|
|
49
|
+
* the shell and the test runner underneath it carried on holding the
|
|
50
|
+
* worktree — and that place's WRITER lock — through every stop, takeover and
|
|
51
|
+
* auto-update. The check's own ten-minute timer already kills `-child.pid`
|
|
52
|
+
* for this reason; teardown simply did not.
|
|
53
|
+
*/
|
|
54
|
+
const groupKillChildren = new Set();
|
|
55
|
+
const shutdownWork = () => {
|
|
56
|
+
for (const [ch] of workChildren) {
|
|
57
|
+
try {
|
|
58
|
+
if (groupKillChildren.has(ch) && ch.pid) process.kill(-ch.pid, 'SIGTERM');
|
|
59
|
+
else ch.kill('SIGTERM');
|
|
60
|
+
} catch {
|
|
61
|
+
// A group that has already gone, or a pid that is no longer a leader.
|
|
62
|
+
try {
|
|
63
|
+
ch.kill('SIGTERM');
|
|
64
|
+
} catch {
|
|
65
|
+
/* best-effort */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
workChildren.clear();
|
|
70
|
+
groupKillChildren.clear();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* HOW MANY CLIs THIS MACHINE IS RUNNING RIGHT NOW — the number
|
|
75
|
+
* `MAX_CONCURRENT` is a ceiling on, and the thing that had no counter.
|
|
76
|
+
*
|
|
77
|
+
* Every lane, because the bound is on the BOX and not on a lane: session
|
|
78
|
+
* turns, an agent's turn, a Deploy press's planner, a project check — all of
|
|
79
|
+
* them land in `workChildren` — plus whatever the caller reports on top of it
|
|
80
|
+
* (the wiki cartographer, wikiRunner.mjs).
|
|
81
|
+
*
|
|
82
|
+
* THE PROJECT CHECK COUNTS, deliberately. It is not a model turn, but it is a
|
|
83
|
+
* full test or build run in a worktree, which is exactly the kind of process
|
|
84
|
+
* this ceiling exists to stop stacking. Nothing gates a check, so counting it
|
|
85
|
+
* cannot deadlock: it only ever delays the NEXT spawn.
|
|
86
|
+
*/
|
|
87
|
+
const liveTurnCount = () => {
|
|
88
|
+
const extra = Number(extraLiveTurns() ?? 0);
|
|
89
|
+
return workChildren.size + (Number.isFinite(extra) && extra > 0 ? extra : 0);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The live turn children with the id each one serves — what the machine
|
|
93
|
+
* snapshot charges its per-task RSS to. Children with no id (the planner)
|
|
94
|
+
* are still counted above; they just have nothing to be charged TO. */
|
|
95
|
+
const liveTurns = () => {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const [ch, id] of workChildren) if (ch?.pid) out.push({ id: id ?? null, pid: ch.pid });
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* WHETHER TO START ONE MORE. See admission.mjs for the whole argument: a
|
|
103
|
+
* runaway bound on a machine, read at the spawn, surfaced only as the
|
|
104
|
+
* machine's own measured sentence at the thing that is waiting, and never a
|
|
105
|
+
* reason to settle a job — a deferred job is re-offered next poll.
|
|
106
|
+
*/
|
|
107
|
+
const admit = createAdmission({ liveTurnCount });
|
|
108
|
+
|
|
109
|
+
return { workChildren, groupKillChildren, shutdownWork, liveTurnCount, liveTurns, admit };
|
|
110
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE COMMAND AUDIT — the durable record of every `$ …` a tab's CLI ran on the
|
|
3
|
+
* shared machine, batched to `/fleet/session-commands`.
|
|
4
|
+
*
|
|
5
|
+
* Split out of work.mjs's session turn (2026-09-26, SOLID F037). One per turn;
|
|
6
|
+
* the lane feeds it the stream's activity and flushes it at settle. It carries
|
|
7
|
+
* commands only, scrubbed, and a failed post drops the batch rather than
|
|
8
|
+
* blocking the turn.
|
|
9
|
+
*/
|
|
10
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
11
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
12
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
13
|
+
|
|
14
|
+
// THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
|
|
15
|
+
// to the server verbatim so an admin can read what actually ran on
|
|
16
|
+
// this box. Same events the narrator renders and forgets; this is
|
|
17
|
+
// the durable copy, and it carries ONLY commands — no prose, no
|
|
18
|
+
// thinking, no file reads (the session stays private; what executed
|
|
19
|
+
// on the shared machine is the machine's own fact to relay).
|
|
20
|
+
// Flushed mid-turn every 25 so a long turn is not one giant loss on
|
|
21
|
+
// a kill, and again at settle. Best-effort: a failed post drops the
|
|
22
|
+
// batch rather than blocking the turn — the surface says it is the
|
|
23
|
+
// machine's report, not a syscall trace.
|
|
24
|
+
export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
|
|
25
|
+
const SESSION_COMMANDS_URL = fleetEndpoint('session-commands', FLEET_URL);
|
|
26
|
+
const auditBatch = [];
|
|
27
|
+
const flushAudit = () => {
|
|
28
|
+
if (auditBatch.length === 0) return;
|
|
29
|
+
const commands = auditBatch.splice(0, auditBatch.length);
|
|
30
|
+
void fetch(SESSION_COMMANDS_URL, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: {
|
|
33
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
34
|
+
'User-Agent': USER_AGENT,
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
},
|
|
37
|
+
signal: AbortSignal.timeout(30_000),
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
sessionId,
|
|
40
|
+
turnId,
|
|
41
|
+
runtime,
|
|
42
|
+
cwd,
|
|
43
|
+
commands,
|
|
44
|
+
}),
|
|
45
|
+
}).catch(() => {
|
|
46
|
+
/* best-effort — the audit records what reached it */
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
const auditCommand = (a) => {
|
|
50
|
+
if (a?.kind !== 'bash' || !a.command) return;
|
|
51
|
+
// Scrubbed like every other string that leaves this box (the
|
|
52
|
+
// narrator label, the settle answer, commit subjects, ship/merge
|
|
53
|
+
// lines, the per-tab process report). A command line is exactly
|
|
54
|
+
// where a secret leaks — `curl -H "authorization: <token>"`,
|
|
55
|
+
// `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
|
|
56
|
+
// in the admin view, so the one uplink that omitted scrub was the
|
|
57
|
+
// one most likely to carry a plaintext secret.
|
|
58
|
+
auditBatch.push({ command: envScrub(a.command), at: new Date().toISOString() });
|
|
59
|
+
if (auditBatch.length >= 25) flushAudit();
|
|
60
|
+
};
|
|
61
|
+
return { flushAudit, auditCommand };
|
|
62
|
+
}
|
package/bin/lib/workDiffs.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** Commit diff requests and their bounded reports. */
|
|
2
2
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
3
3
|
import { git } from './git.mjs';
|
|
4
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
4
5
|
|
|
5
6
|
export function createWorkDiffs({ repoRoot }) {
|
|
6
|
-
const DIFF_DONE_URL =
|
|
7
|
+
const DIFF_DONE_URL = fleetEndpoint('diff-done', FLEET_URL);
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* COMMIT DIFFS, on request — the one PULL-shaped thing this daemon does.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TAB'S LIVE LINE — the CLI's own stdout relayed while a turn runs, and
|
|
3
|
+
* the machine's one sentence when it defers a turn the tab is waiting on.
|
|
4
|
+
*
|
|
5
|
+
* Split out of work.mjs (2026-09-26, SOLID F037). Both ride
|
|
6
|
+
* `/fleet/session-activity`, both are throttled decoration that must never
|
|
7
|
+
* fail a turn, and both change for the same reason (what the tab shows while
|
|
8
|
+
* nothing has settled). The session lane builds one narrator per turn and
|
|
9
|
+
* clears the deferral clock when a turn actually starts.
|
|
10
|
+
*/
|
|
11
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
12
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
13
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
14
|
+
|
|
15
|
+
export function createWorkNarration() {
|
|
16
|
+
const ACTIVITY_URL = fleetEndpoint('session-activity', FLEET_URL);
|
|
17
|
+
/**
|
|
18
|
+
* THE TAB'S LIVE NARRATION — the terminal's own stdout, relayed.
|
|
19
|
+
*
|
|
20
|
+
* A turn used to be a spinner: the tab said "working…" for minutes and the
|
|
21
|
+
* only thing that ever appeared was the finished reply. The CLI is printing
|
|
22
|
+
* the whole time (thinking, reads, greps, commands), so the honest fix is to
|
|
23
|
+
* FORWARD that, not to invent a progress model on the server. Flowviant
|
|
24
|
+
* relays; it does not narrate on its own behalf.
|
|
25
|
+
*
|
|
26
|
+
* Best-effort by construction: throttled to one POST per window (a turn can
|
|
27
|
+
* emit hundreds of lines), never awaited by the turn, and every failure is
|
|
28
|
+
* swallowed. A spinner must never be able to fail a build. The server clears
|
|
29
|
+
* the line at settle, so a daemon killed mid-turn cannot leave one stuck.
|
|
30
|
+
*/
|
|
31
|
+
const ACTIVITY_MIN_MS = 1_500;
|
|
32
|
+
const ACTIVITY_KEEP = 4; // the last few lines — a tail, not a log
|
|
33
|
+
/** `turnId` scopes the narration to the turn that produced it: a POST
|
|
34
|
+
* already on the wire when the turn settles must not re-stamp a "working…"
|
|
35
|
+
* line over the finished reply — the server drops narration for a turn
|
|
36
|
+
* that is no longer pending. (A session-level pending count can't tell the
|
|
37
|
+
* settled turn's stale line from the queued NEXT turn's fresh one.) */
|
|
38
|
+
const makeNarrator = (sessionId, turnId, getTools) => {
|
|
39
|
+
const recent = [];
|
|
40
|
+
let lastSent = 0;
|
|
41
|
+
let dirty = false;
|
|
42
|
+
let timer = null;
|
|
43
|
+
let sending = false;
|
|
44
|
+
let stopped = false;
|
|
45
|
+
const send = async () => {
|
|
46
|
+
if (sending || stopped) return;
|
|
47
|
+
sending = true;
|
|
48
|
+
dirty = false;
|
|
49
|
+
lastSent = Date.now();
|
|
50
|
+
const lines = recent.slice(-ACTIVITY_KEEP);
|
|
51
|
+
try {
|
|
52
|
+
await fetch(ACTIVITY_URL, {
|
|
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(10_000),
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
sessionId,
|
|
62
|
+
turnId,
|
|
63
|
+
lines,
|
|
64
|
+
// The structured tool log so far, riding the same throttled beat.
|
|
65
|
+
// Same lifecycle as the lines: overwritten as the turn moves,
|
|
66
|
+
// cleared server-side at settle. Absent until something ran.
|
|
67
|
+
...(getTools ? { tools: getTools() } : {}),
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
/* narration is decoration — a dropped line is not an incident */
|
|
72
|
+
}
|
|
73
|
+
sending = false;
|
|
74
|
+
if (dirty && !stopped) schedule();
|
|
75
|
+
};
|
|
76
|
+
const schedule = () => {
|
|
77
|
+
if (timer || stopped) return;
|
|
78
|
+
const wait = Math.max(0, ACTIVITY_MIN_MS - (Date.now() - lastSent));
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
timer = null;
|
|
81
|
+
void send();
|
|
82
|
+
}, wait);
|
|
83
|
+
timer.unref?.(); // never hold the process open for a spinner
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
line(label) {
|
|
87
|
+
// Scrub, like every string that leaves this machine: a narration line
|
|
88
|
+
// is the CLI's own stdout — a command echoing an env var, a read of a
|
|
89
|
+
// config file — and it rides the same uplink the final answer does.
|
|
90
|
+
const s = envScrub(String(label ?? ''))
|
|
91
|
+
.replace(/\s+/g, ' ')
|
|
92
|
+
.trim()
|
|
93
|
+
.slice(0, 200);
|
|
94
|
+
if (!s || stopped) return;
|
|
95
|
+
recent.push(s);
|
|
96
|
+
if (recent.length > ACTIVITY_KEEP * 2) recent.shift();
|
|
97
|
+
dirty = true;
|
|
98
|
+
schedule();
|
|
99
|
+
},
|
|
100
|
+
stop() {
|
|
101
|
+
stopped = true;
|
|
102
|
+
if (timer) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
timer = null;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* TELL THE TAB IT IS WAITING ON THE BOX, not on its Claude.
|
|
112
|
+
*
|
|
113
|
+
* A deferred turn is invisible from a browser: the composer says "working…"
|
|
114
|
+
* and the machine simply does not spawn, which looks exactly like a slow
|
|
115
|
+
* model. So the deferral rides the narration channel the turn would have used
|
|
116
|
+
* anyway — the turn is still pending, so the server accepts the line — and
|
|
117
|
+
* says the measured reason and what happens next. The machine's own voice,
|
|
118
|
+
* for a moment only this side can see; the same shape the planner's "waiting
|
|
119
|
+
* for the checkout" already keeps.
|
|
120
|
+
*
|
|
121
|
+
* ONCE PER SESSION PER WINDOW, because the roster re-offers the same turn on
|
|
122
|
+
* every poll and restating an unchanged sentence every ten seconds is a POST
|
|
123
|
+
* loop, not a readout. The clock is cleared the moment a turn for that session
|
|
124
|
+
* actually starts, so the next stall speaks immediately rather than inheriting
|
|
125
|
+
* a window from an unrelated one.
|
|
126
|
+
*/
|
|
127
|
+
const DEFER_SAY_MS = 30_000;
|
|
128
|
+
const lastDeferSaid = new Map(); // sessionId -> ms
|
|
129
|
+
const sayTurnDeferred = (sessionId, turnId, reason) => {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
if (now - (lastDeferSaid.get(sessionId) ?? 0) < DEFER_SAY_MS) return;
|
|
132
|
+
lastDeferSaid.set(sessionId, now);
|
|
133
|
+
void fetch(ACTIVITY_URL, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
137
|
+
'User-Agent': USER_AGENT,
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
},
|
|
140
|
+
signal: AbortSignal.timeout(10_000),
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
sessionId,
|
|
143
|
+
turnId,
|
|
144
|
+
lines: [`Deferred — ${reason}. The machine retries on its next poll.`],
|
|
145
|
+
}),
|
|
146
|
+
}).catch(() => {
|
|
147
|
+
/* a readout — a dropped line is not an incident */
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return { makeNarrator, sayTurnDeferred, lastDeferSaid };
|
|
152
|
+
}
|