flowviant 0.115.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 +99 -145
- 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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* THE READ GUARD (2026-09-23, 0.97.0) — a Claude Code `PreToolUse` hook that
|
|
4
|
-
* refuses a Bash command able to WRITE A FILE OR RUN A PROGRAM,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* own choice.
|
|
4
|
+
* refuses a Bash command able to WRITE A FILE OR RUN A PROGRAM, or (2026-10-10)
|
|
5
|
+
* to READ PAST THE TURN'S FENCE, attached to every curated posture (consult,
|
|
6
|
+
* plan, wiki, design, research) and never to the build posture, whose
|
|
7
|
+
* `--dangerously-skip-permissions` is the operator's own choice.
|
|
8
8
|
*
|
|
9
9
|
* WHY A HOOK AND NOT A NARROWER LIST. `--allowedTools 'Bash(git log:*)'` is a
|
|
10
10
|
* PREFIX match: it says which program may start, never what its arguments may
|
|
@@ -50,6 +50,22 @@
|
|
|
50
50
|
* · an environment assignment — any `GIT_*=` anywhere, and any `NAME=` as the
|
|
51
51
|
* command's first word (`GIT_DIR`, `GIT_EXTERNAL_DIFF`, `PAGER`,
|
|
52
52
|
* `LESSOPEN` all change what a reader runs).
|
|
53
|
+
* · (2026-10-10, the audit) a GIT READER AIMED OFF THE CHECKOUT. The CLI
|
|
54
|
+
* path-validates `cat`, `ls` and `head` (measured, claudePosture.mjs
|
|
55
|
+
* CONSULT_BASH) but nobody measured git, and `git diff` reads any file on
|
|
56
|
+
* the box: `--no-index` compares two arbitrary paths, and git switches to
|
|
57
|
+
* it on its own when a path lies outside the work tree — `git diff
|
|
58
|
+
* ~/.ssh/id_ed25519 /dev/null` prints the key. So `--no-index` is refused
|
|
59
|
+
* anywhere, and for git every argument that starts with `/` or `~` or has a
|
|
60
|
+
* `..` segment (split on `/`, `:` and `=`, so a revision range `a..b` or
|
|
61
|
+
* `main...HEAD` still passes). `cat`/`ls`/`head` keep their absolute
|
|
62
|
+
* paths: the wiki's vault and the knowledge library are named that way.
|
|
63
|
+
* · (2026-10-10) THE CHECKOUT'S SECRET FILES, BY NAME, for every reader:
|
|
64
|
+
* `.env`, `.env.<anything>`, `.dev.vars` and `.dev.vars.<anything>` — the
|
|
65
|
+
* shell's half of the postures' `Read(./.env*)` denies, which say nothing
|
|
66
|
+
* about `cat` — while the templates (`.example`, `.sample`, `.template`)
|
|
67
|
+
* still read. By NAME: a secret someone committed still shows in a whole
|
|
68
|
+
* `git show` or `git log -p`, as it would to anyone holding the repository.
|
|
53
69
|
*
|
|
54
70
|
* FAILS CLOSED: input it cannot parse is refused, because a fence that opens
|
|
55
71
|
* whenever the CLI changes its hook payload is a fence that opens silently.
|
|
@@ -67,8 +83,15 @@
|
|
|
67
83
|
|
|
68
84
|
import { pathToFileURL } from 'node:url';
|
|
69
85
|
|
|
70
|
-
/** Substrings that turn a git reader into a writer or an executor
|
|
71
|
-
|
|
86
|
+
/** Substrings that turn a git reader into a writer or an executor — or, for
|
|
87
|
+
* `--no-index` (2026-10-10), into a reader of any file on the box. */
|
|
88
|
+
const BANNED_FLAGS = ['--output', '--ext-diff', '--textconv', '--exec-path', '--git-dir', '--work-tree', '--config', '--no-index'];
|
|
89
|
+
|
|
90
|
+
/** A checkout secret file, and the templates beside it that still read. */
|
|
91
|
+
const SECRET_FILE = /^(\.env|\.dev\.vars)(\..+)?$/;
|
|
92
|
+
const SECRET_TEMPLATE = /\.(example|sample|template)$/;
|
|
93
|
+
/** The pieces of one word a path could hide in: `a/b`, `HEAD:a/b`, `--x=a/b`. */
|
|
94
|
+
const pathSegments = (w) => w.split(/[/:=]/);
|
|
72
95
|
|
|
73
96
|
/** Shell composition: each is a second command or a redirect. */
|
|
74
97
|
const SHELL = [
|
|
@@ -121,12 +144,23 @@ export function readGuardRefusal(command) {
|
|
|
121
144
|
if (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
|
|
122
145
|
return `an environment assignment (\`${words[0].split('=')[0]}=\`)`;
|
|
123
146
|
}
|
|
147
|
+
for (const w of words) {
|
|
148
|
+
const secret = pathSegments(w).find((seg) => SECRET_FILE.test(seg) && !SECRET_TEMPLATE.test(seg));
|
|
149
|
+
if (secret) return `reading a secret file (\`${secret}\`)`;
|
|
150
|
+
}
|
|
151
|
+
if (words.length && /(^|\/)git$/.test(words[0])) {
|
|
152
|
+
for (const w of words.slice(1)) {
|
|
153
|
+
const value = w.startsWith('-') && w.includes('=') ? w.slice(w.indexOf('=') + 1) : w;
|
|
154
|
+
if (value.startsWith('/') || value.startsWith('~')) return `a path off the checkout (\`${w}\`)`;
|
|
155
|
+
if (pathSegments(w).includes('..')) return `a path that climbs out of the checkout (\`${w}\`)`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
124
158
|
return null;
|
|
125
159
|
}
|
|
126
160
|
|
|
127
161
|
/** The sentence the model reads when a call is refused. */
|
|
128
162
|
export const readGuardSentence = (why) =>
|
|
129
|
-
`flowviant: this turn is read-only, so ${why} is refused here — it can write a file
|
|
163
|
+
`flowviant: this turn is read-only, so ${why} is refused here — it can write a file, run a program or read past this turn's fence. ` +
|
|
130
164
|
'Run one plain read command at a time with no redirects, pipes or expansions, or use the Read, Grep and Glob tools.';
|
|
131
165
|
|
|
132
166
|
function main() {
|
package/bin/lib/install.mjs
CHANGED
|
@@ -5,18 +5,31 @@
|
|
|
5
5
|
* silently and never clobbering an existing install. Interactive login stays
|
|
6
6
|
* yours (`claude` sign-in, `flowviant gh-auth`).
|
|
7
7
|
*
|
|
8
|
-
* gh is dropped into ~/.flowviant/bin (like cloudflared
|
|
9
|
-
* path, so there's nothing to
|
|
10
|
-
* on PATH so the daemon (and
|
|
8
|
+
* gh is dropped into ~/.flowviant/bin (like cloudflared, and pinned and
|
|
9
|
+
* checksummed like it) rather than a system path, so there's nothing to
|
|
10
|
+
* conflict with; addLocalBinToPath() puts that dir on PATH so the daemon (and
|
|
11
|
+
* `flowviant gh-auth`) find it.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
14
16
|
import { writeFileSync, existsSync, mkdirSync, chmodSync, rmSync, cpSync } from 'node:fs';
|
|
15
17
|
import { join } from 'node:path';
|
|
16
18
|
import { homedir, platform, arch } from 'node:os';
|
|
17
19
|
|
|
18
20
|
export const LOCAL_BIN = join(homedir(), '.flowviant', 'bin');
|
|
19
21
|
|
|
22
|
+
/** Is `bin` on PATH and answering `--version`? BOUNDED: a broken shim that
|
|
23
|
+
* hangs here would stall preflight — and the daemon behind it — forever. */
|
|
24
|
+
export function commandPresent(bin, { timeout = 10_000, exec = execFileSync } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
exec(bin, ['--version'], { stdio: 'ignore', timeout });
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
/** Put ~/.flowviant/bin first on PATH so bundled binaries (gh, cloudflared)
|
|
21
34
|
* resolve by bare name for this process and everything it spawns. Idempotent. */
|
|
22
35
|
export function addLocalBinToPath() {
|
|
@@ -61,53 +74,87 @@ export function installClaude(log) {
|
|
|
61
74
|
}
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
/**
|
|
78
|
+
* gh: PINNED AND VERIFIED, LIKE CLOUDFLARED (audit 2026-10-10, F088/F110/F662).
|
|
79
|
+
* This used to ask api.github.com for `releases/latest`, untar whatever came
|
|
80
|
+
* back and chmod it — no digest, no timeout — into the directory
|
|
81
|
+
* addLocalBinToPath puts FIRST on PATH, where it then runs with the operator's
|
|
82
|
+
* GitHub login for every push and merge. preview.mjs's rule is the rule here:
|
|
83
|
+
* we only execute what we verified. Bumping this is a release act: take the
|
|
84
|
+
* digests from the release's own `gh_<version>_checksums.txt` and replace the
|
|
85
|
+
* version and every digest together.
|
|
86
|
+
*/
|
|
87
|
+
export const GH_VERSION = '2.102.0';
|
|
88
|
+
export const GH_SHA256 = {
|
|
89
|
+
'linux-amd64': 'bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386',
|
|
90
|
+
'linux-arm64': '7862c86c72f43df3a2d93ddde6f473285b4e2af61b494849846827e513ef6484',
|
|
91
|
+
'macOS-amd64': 'b245f24eb2bf5f75b426b4c26da3651a107f8d5b6f4fddfbfccc5679041378b3',
|
|
92
|
+
'macOS-arm64': 'da922c20d1792e5b2cbf375593d7a658acf034c12c84e007e71c76ef959c337e',
|
|
93
|
+
'windows-amd64': 'ae64e556ecc240b200f7eba60d550e4bb60d78e860e69dd88c449405b86067f4',
|
|
94
|
+
'windows-arm64': '5dcf12aa8525eabd0c46ec414f323ab6cf65229fc2cd46543cc705001bbaf223',
|
|
95
|
+
};
|
|
74
96
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
97
|
+
const GH_MANUAL = 'Install from https://cli.github.com, then run: gh auth login';
|
|
98
|
+
|
|
99
|
+
/** Fetch the pinned gh release archive, verify it, and put the binary in
|
|
100
|
+
* ~/.flowviant/bin — isolated, no sudo, nothing to conflict with. The digest
|
|
101
|
+
* is checked BEFORE anything is written or extracted (an archive is code
|
|
102
|
+
* too). Returns the binary path, or null after saying why and how to install
|
|
103
|
+
* it by hand. `fetch`, `os`, `cpu`, `dir` and the pin are injectable for the
|
|
104
|
+
* tests; nothing in the daemon passes them. */
|
|
105
|
+
export async function installGh(
|
|
106
|
+
log,
|
|
107
|
+
{
|
|
108
|
+
fetch: fetchImpl = (...a) => fetch(...a),
|
|
109
|
+
os = platform(),
|
|
110
|
+
cpu = arch(),
|
|
111
|
+
dir = LOCAL_BIN,
|
|
112
|
+
version = GH_VERSION,
|
|
113
|
+
sha256 = GH_SHA256,
|
|
114
|
+
timeoutMs = 120_000,
|
|
115
|
+
} = {},
|
|
116
|
+
) {
|
|
117
|
+
const a = cpu === 'arm64' ? 'arm64' : cpu === 'x64' ? 'amd64' : cpu;
|
|
81
118
|
const osName = os === 'darwin' ? 'macOS' : os === 'win32' ? 'windows' : 'linux';
|
|
82
119
|
const ext = os === 'linux' ? 'tar.gz' : 'zip';
|
|
83
120
|
const binName = os === 'win32' ? 'gh.exe' : 'gh';
|
|
84
|
-
const dest = join(
|
|
121
|
+
const dest = join(dir, binName);
|
|
122
|
+
const want = sha256[`${osName}-${a}`];
|
|
123
|
+
if (!want) {
|
|
124
|
+
log?.(`could not install gh automatically: this machine (${os}/${cpu}) has no pinned gh build to fetch. ${GH_MANUAL}`);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
85
127
|
let archive;
|
|
86
128
|
let innerDir;
|
|
87
129
|
try {
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
log?.(`fetching gh ${ver} (${osName}-${a})…`);
|
|
93
|
-
const res = await fetch(url, { redirect: 'follow' });
|
|
130
|
+
const stem = `gh_${version}_${osName}_${a}`;
|
|
131
|
+
const url = `https://github.com/cli/cli/releases/download/v${version}/${stem}.${ext}`;
|
|
132
|
+
log?.(`fetching gh ${version} (${osName}-${a})…`);
|
|
133
|
+
const res = await fetchImpl(url, { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
|
|
94
134
|
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
95
|
-
|
|
96
|
-
|
|
135
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
136
|
+
const got = createHash('sha256').update(buf).digest('hex');
|
|
137
|
+
if (got !== want) {
|
|
138
|
+
log?.(
|
|
139
|
+
`refused to install gh ${version}: the download did not match its pinned checksum (expected ${want.slice(0, 12)}…, got ${got.slice(0, 12)}…). ${GH_MANUAL}`,
|
|
140
|
+
);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
mkdirSync(dir, { recursive: true });
|
|
144
|
+
archive = join(dir, `${stem}.${ext}`);
|
|
145
|
+
writeFileSync(archive, buf);
|
|
97
146
|
// linux ships tar.gz (GNU tar); mac + windows ship zip (bsdtar reads zip).
|
|
98
|
-
execFileSync('tar', [os === 'linux' ? '-xzf' : '-xf', archive, '-C',
|
|
147
|
+
execFileSync('tar', [os === 'linux' ? '-xzf' : '-xf', archive, '-C', dir], {
|
|
99
148
|
stdio: 'ignore',
|
|
100
149
|
});
|
|
101
|
-
innerDir = join(
|
|
150
|
+
innerDir = join(dir, stem);
|
|
102
151
|
const inner = join(innerDir, 'bin', binName);
|
|
103
152
|
if (!existsSync(inner)) throw new Error('gh binary not found in the archive');
|
|
104
153
|
cpSync(inner, dest);
|
|
105
154
|
if (os !== 'win32') chmodSync(dest, 0o755);
|
|
106
155
|
return dest;
|
|
107
156
|
} catch (e) {
|
|
108
|
-
log?.(
|
|
109
|
-
`could not install gh automatically (${e?.message ?? e}). Install from https://cli.github.com, then run: gh auth login`,
|
|
110
|
-
);
|
|
157
|
+
log?.(`could not install gh automatically (${e?.message ?? e}). ${GH_MANUAL}`);
|
|
111
158
|
return null;
|
|
112
159
|
} finally {
|
|
113
160
|
if (archive) rmSync(archive, { force: true });
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -87,7 +87,6 @@ import {
|
|
|
87
87
|
closeSync,
|
|
88
88
|
mkdirSync,
|
|
89
89
|
openSync,
|
|
90
|
-
readdirSync,
|
|
91
90
|
readFileSync,
|
|
92
91
|
renameSync,
|
|
93
92
|
rmSync,
|
|
@@ -96,36 +95,25 @@ import {
|
|
|
96
95
|
} from 'node:fs';
|
|
97
96
|
import { homedir, platform } from 'node:os';
|
|
98
97
|
import { join } from 'node:path';
|
|
99
|
-
import { createHash } from 'node:crypto';
|
|
100
98
|
import { VERSION, PROCESS_STARTED_AT } from './config.mjs';
|
|
101
99
|
import { compareVersions } from './versionOrder.mjs';
|
|
102
100
|
import { samePath } from './repoPath.mjs';
|
|
101
|
+
import { instanceLockPath, listDaemonLocks, readLockFile, readLockHolder } from './daemonLocks.mjs';
|
|
102
|
+
import { processAlive } from './procRegistry.mjs';
|
|
103
|
+
import { procStat } from './procStat.mjs';
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
// The lock file's name, record and scan live in daemonLocks.mjs (a leaf the
|
|
106
|
+
// readers share, 2026-10-10 F090); re-exported so callers keep one import.
|
|
107
|
+
export {
|
|
108
|
+
DAEMON_LOCK_RE,
|
|
109
|
+
instanceLockPath,
|
|
110
|
+
listDaemonLocks,
|
|
111
|
+
parseLockRecord,
|
|
112
|
+
readLockHolder,
|
|
113
|
+
} from './daemonLocks.mjs';
|
|
109
114
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
112
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
113
|
-
try {
|
|
114
|
-
process.kill(pid, 0);
|
|
115
|
-
return true;
|
|
116
|
-
} catch (e) {
|
|
117
|
-
return e.code === 'EPERM';
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function readHolder(path) {
|
|
122
|
-
try {
|
|
123
|
-
const v = JSON.parse(readFileSync(path, 'utf8'));
|
|
124
|
-
return v && Number.isInteger(v.pid) && v.pid > 0 ? v : null;
|
|
125
|
-
} catch {
|
|
126
|
-
return null; // absent, truncated, or half-written — treat as no holder
|
|
127
|
-
}
|
|
128
|
-
}
|
|
115
|
+
/** Absent, truncated, half-written, or no positive pid — no holder. */
|
|
116
|
+
const readHolder = (path) => readLockFile(path);
|
|
129
117
|
|
|
130
118
|
/**
|
|
131
119
|
* WHAT THE DAEMON HOLDING THIS CREDENTIAL'S LOCK IS FINISHING — the `target →
|
|
@@ -136,21 +124,16 @@ function readHolder(path) {
|
|
|
136
124
|
* already measured running.
|
|
137
125
|
*/
|
|
138
126
|
export function drainingFor(fleetToken) {
|
|
139
|
-
const what =
|
|
127
|
+
const what = readLockHolder(fleetToken)?.draining?.what;
|
|
140
128
|
return typeof what === 'string' && what ? what : null;
|
|
141
129
|
}
|
|
142
130
|
|
|
143
131
|
/** Read-only desktop measurement. No lock is unknown because an unguarded or
|
|
144
132
|
* older daemon may still run; a stale lock with a dead pid measures empty. */
|
|
145
133
|
export function daemonRunningFor(fleetToken) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
catch { return null; }
|
|
150
|
-
let holder;
|
|
151
|
-
try { holder = JSON.parse(raw); } catch { return null; }
|
|
152
|
-
if (!Number.isInteger(holder?.pid) || holder.pid <= 0) return null;
|
|
153
|
-
if (!alive(holder.pid)) return false;
|
|
134
|
+
const holder = readLockHolder(fleetToken);
|
|
135
|
+
if (!holder) return null;
|
|
136
|
+
if (!processAlive(holder.pid)) return false;
|
|
154
137
|
return stillTheHolder(holder);
|
|
155
138
|
}
|
|
156
139
|
|
|
@@ -210,10 +193,7 @@ const ANCESTOR_DEPTH = 8;
|
|
|
210
193
|
function parentOf(pid) {
|
|
211
194
|
try {
|
|
212
195
|
if (platform() === 'linux') {
|
|
213
|
-
const
|
|
214
|
-
const close = raw.lastIndexOf(')');
|
|
215
|
-
if (close < 0) return null;
|
|
216
|
-
const ppid = Number.parseInt(raw.slice(close + 1).trim().split(/\s+/)[1], 10);
|
|
196
|
+
const ppid = procStat(pid)?.ppid;
|
|
217
197
|
return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
|
|
218
198
|
}
|
|
219
199
|
if (platform() === 'darwin') {
|
|
@@ -276,29 +256,14 @@ function neighbourLockPath(holder, fallback) {
|
|
|
276
256
|
return NEIGHBOUR_PATHS.get(holder) ?? fallback;
|
|
277
257
|
}
|
|
278
258
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
* between them: it is what separates our locks from anything else living in
|
|
284
|
-
* ~/.flowviant. An unreadable (or absent) directory is not an error here, it is
|
|
285
|
-
* an empty machine. */
|
|
286
|
-
function lockFiles() {
|
|
287
|
-
try {
|
|
288
|
-
return readdirSync(join(homedir(), '.flowviant'))
|
|
289
|
-
.filter((f) => /^daemon-[0-9a-f]{12}\.lock$/.test(f))
|
|
290
|
-
.sort()
|
|
291
|
-
.map((f) => join(homedir(), '.flowviant', f));
|
|
292
|
-
} catch {
|
|
293
|
-
return [];
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
259
|
+
// Every daemon lock file on this machine comes from ONE scan, listDaemonLocks
|
|
260
|
+
// (daemonLocks.mjs): daemonInSameRepo looks for a neighbour to refuse or
|
|
261
|
+
// replace, stopAllDaemons sweeps the lot, and uninstall protects what a live
|
|
262
|
+
// daemon runs from — and the filename pattern must not drift between them.
|
|
297
263
|
export function daemonInSameRepo(repoRoot, ownPath) {
|
|
298
|
-
for (const path of
|
|
264
|
+
for (const { path, holder } of listDaemonLocks()) {
|
|
299
265
|
if (path === ownPath) continue; // our own credential — the lock above owns that question
|
|
300
|
-
|
|
301
|
-
if (!holder || !alive(holder.pid)) continue;
|
|
266
|
+
if (!holder || !processAlive(holder.pid)) continue;
|
|
302
267
|
if (isOurReexecParent(holder.pid)) continue; // ourselves mid self-update re-exec
|
|
303
268
|
if (samePath(holder.repoRoot, repoRoot)) {
|
|
304
269
|
NEIGHBOUR_PATHS.set(holder, path);
|
|
@@ -369,23 +334,11 @@ function processStartedAt(pid) {
|
|
|
369
334
|
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
370
335
|
try {
|
|
371
336
|
if (platform() === 'linux') {
|
|
372
|
-
// FIELD 22 OF /proc/<pid>/stat, and the parse is the whole trap
|
|
373
|
-
// is `comm`, which
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
// the answer is not a parse error but a plausible-looking "started at
|
|
378
|
-
// boot" that sails past any isFinite guard. Measured: a binary named
|
|
379
|
-
// `ev (i l) x` read 28308s too old that way.
|
|
380
|
-
//
|
|
381
|
-
// Every field after `comm` is a number or a single-character state, so no
|
|
382
|
-
// ')' can appear later: the LAST ')' in the line is always the kernel's
|
|
383
|
-
// own closing paren, even when comm itself ends in one.
|
|
384
|
-
const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
385
|
-
const close = raw.lastIndexOf(')');
|
|
386
|
-
if (close < 0) return null;
|
|
387
|
-
const fields = raw.slice(close + 1).trim().split(/\s+/); // fields[0] IS field 3
|
|
388
|
-
const ticks = Number.parseInt(fields[19], 10); // field 22 => 22 - 3
|
|
337
|
+
// FIELD 22 OF /proc/<pid>/stat, and the parse is the whole trap: field 2
|
|
338
|
+
// is `comm`, which may contain BOTH spaces and parens, and a naive split
|
|
339
|
+
// read a binary named `ev (i l) x` 28308s too old. procStat.mjs is the one
|
|
340
|
+
// parser of that line and says why its reading is the right one.
|
|
341
|
+
const ticks = procStat(pid)?.starttime;
|
|
389
342
|
if (!Number.isFinite(ticks) || ticks < 0) return null;
|
|
390
343
|
// `/proc/uptime` rather than `/proc/stat`'s btime, and not for precision
|
|
391
344
|
// alone (measured 6ms out against 162ms). Date.now() and uptime are read
|
|
@@ -653,7 +606,7 @@ function standDown(holder, path, log, { waitOutDrain = false } = {}) {
|
|
|
653
606
|
if (holder.pid === process.pid) return { failed: 'refusing to signal this very process' };
|
|
654
607
|
const standing = () => {
|
|
655
608
|
const now = readHolder(path);
|
|
656
|
-
if (!now || !
|
|
609
|
+
if (!now || !processAlive(now.pid)) return null;
|
|
657
610
|
return now;
|
|
658
611
|
};
|
|
659
612
|
const before = standing();
|
|
@@ -737,7 +690,7 @@ function standDown(holder, path, log, { waitOutDrain = false } = {}) {
|
|
|
737
690
|
* collapsing them once had the daemon telling people a live holder was stale.
|
|
738
691
|
*/
|
|
739
692
|
function takeOverFrom(holder, path, log, { allowDowngrade = false } = {}) {
|
|
740
|
-
if (!holder?.pid || !
|
|
693
|
+
if (!holder?.pid || !processAlive(holder.pid)) return null; // already gone
|
|
741
694
|
const identified = stillTheHolder(holder);
|
|
742
695
|
if (identified === null) {
|
|
743
696
|
// We know nothing about this pid, and said so. The remedy is a human
|
|
@@ -834,7 +787,7 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
834
787
|
} catch (e) {
|
|
835
788
|
if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
|
|
836
789
|
const holder = readHolder(path);
|
|
837
|
-
if (!holder || !
|
|
790
|
+
if (!holder || !processAlive(holder.pid)) {
|
|
838
791
|
// A crashed daemon's leftover. Clear it and take it on the next pass —
|
|
839
792
|
// ownership-verified, because a concurrent start may have cleared and
|
|
840
793
|
// re-created it in the gap since our read.
|
|
@@ -843,7 +796,7 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
|
|
|
843
796
|
// Unreadable content: re-read before clearing, so a half-written
|
|
844
797
|
// record a peer is writing RIGHT NOW is not deleted mid-write.
|
|
845
798
|
const again = readHolder(path);
|
|
846
|
-
if (again &&
|
|
799
|
+
if (again && processAlive(again.pid)) continue; // it finished writing — a real holder now
|
|
847
800
|
try {
|
|
848
801
|
rmSync(path, { force: true });
|
|
849
802
|
} catch {
|
|
@@ -982,7 +935,7 @@ function stopLock(path, log, tally) {
|
|
|
982
935
|
const where = holder.repoRoot ? ` in ${holder.repoRoot}` : '';
|
|
983
936
|
const what = holder.version ? ` ${holder.version}` : '';
|
|
984
937
|
|
|
985
|
-
if (!
|
|
938
|
+
if (!processAlive(holder.pid)) {
|
|
986
939
|
log(`pid ${holder.pid}${where} is already gone — nothing to stop.`);
|
|
987
940
|
return;
|
|
988
941
|
}
|
|
@@ -1117,7 +1070,7 @@ export function stopDaemonFor(fleetToken, { log = (m) => console.log(m) } = {})
|
|
|
1117
1070
|
*/
|
|
1118
1071
|
export function stopAllDaemons({ log = (m) => console.log(m) } = {}) {
|
|
1119
1072
|
const tally = { stopped: 0, unconfirmed: 0, failed: 0, running: 0, draining: 0 };
|
|
1120
|
-
for (const path of
|
|
1073
|
+
for (const { path } of listDaemonLocks()) stopLock(path, log, tally);
|
|
1121
1074
|
const { stopped, unconfirmed, failed, running } = tally;
|
|
1122
1075
|
|
|
1123
1076
|
// WHAT WE ACTUALLY MEASURED IS LOCKS, so that is what this says. The old
|
package/bin/lib/intakeDraft.mjs
CHANGED
|
@@ -20,7 +20,10 @@ export function parseIntakeDraft(text, typeHint = null, scrub = envScrub) {
|
|
|
20
20
|
const taskType = agentTaskTypeOf(typeHint) ?? v.taskType;
|
|
21
21
|
const clean = (value) => String(scrub(value.trim()));
|
|
22
22
|
return {
|
|
23
|
-
|
|
23
|
+
// ONE LINE (2026-10-10): the title is printed in later prompts, and a
|
|
24
|
+
// line break in it forged structure there; parse.ts collapses a ticket's
|
|
25
|
+
// own title the same way.
|
|
26
|
+
title: clean(v.title).replace(/\s+/g, ' ').trim(),
|
|
24
27
|
description: clean(v.description),
|
|
25
28
|
acceptanceCriteria: v.acceptanceCriteria.map(clean),
|
|
26
29
|
taskType,
|
|
@@ -73,6 +73,37 @@ export function writeAtomic(path, data) {
|
|
|
73
73
|
* list. */
|
|
74
74
|
export const isCurrent = (state) => state === 'current' || state === 'wrote';
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* ONE STATE INTO A SYNC'S RESULT (2026-10-10, the audit's F111): `wrote` and
|
|
78
|
+
* `refused` are listed as such, and anything not current (`stale`, `failed`)
|
|
79
|
+
* clears `ok` — the retry contract: the driver backs off and refetches — and
|
|
80
|
+
* is listed as failed. The four fetched kinds restated this by hand. What a
|
|
81
|
+
* refusal means to the caller (a name no longer kept, a path no longer
|
|
82
|
+
* wanted) stays at the call site. Returns `state`, so the caller can branch.
|
|
83
|
+
*/
|
|
84
|
+
export function recordSyncState(result, state, path) {
|
|
85
|
+
if (state === 'refused') result.refused.push(path);
|
|
86
|
+
else if (state === 'wrote') result.wrote.push(path);
|
|
87
|
+
else if (!isCurrent(state)) {
|
|
88
|
+
result.ok = false;
|
|
89
|
+
result.failed.push(path);
|
|
90
|
+
}
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A generated text file (INSTRUCTIONS.md, LIBRARY.md) made to hold `text`:
|
|
95
|
+
* left alone when the REGULAR file there already does, else written
|
|
96
|
+
* atomically. True when it was written. */
|
|
97
|
+
export function writeTextIfChanged(path, text) {
|
|
98
|
+
try {
|
|
99
|
+
if (lstatSync(path).isFile() && readFileSync(path, 'utf8') === text) return false;
|
|
100
|
+
} catch {
|
|
101
|
+
/* absent */
|
|
102
|
+
}
|
|
103
|
+
writeAtomic(path, text);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
76
107
|
/**
|
|
77
108
|
* Make `path` hold the manifest's file.
|
|
78
109
|
*
|