flowviant 0.125.6 → 0.125.9
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 +84 -6
- package/bin/cli.mjs +7 -1
- package/bin/lib/activityFeed.mjs +69 -0
- package/bin/lib/agentMergeReports.mjs +46 -0
- package/bin/lib/agentMergeResolution.mjs +44 -0
- package/bin/lib/agentPlan.mjs +6 -2
- package/bin/lib/claudeInput.mjs +61 -1
- package/bin/lib/claudePosture.mjs +21 -0
- package/bin/lib/claudeStream.mjs +54 -4
- package/bin/lib/codexContract.mjs +52 -0
- package/bin/lib/codexResearch.mjs +33 -5
- package/bin/lib/commandSpecs.mjs +8 -5
- package/bin/lib/config.mjs +22 -1
- package/bin/lib/daemonJournal.mjs +102 -0
- package/bin/lib/daemonLogging.mjs +22 -18
- package/bin/lib/fleet.mjs +16 -6
- package/bin/lib/fleetReports.mjs +12 -5
- package/bin/lib/hooks/orchestrator.mjs +3 -1
- package/bin/lib/logReplay.mjs +133 -0
- package/bin/lib/mcpInventory.mjs +2 -1
- package/bin/lib/mcpReasons.mjs +6 -0
- package/bin/lib/nativeAgentTools.mjs +20 -3
- package/bin/lib/observedLines.mjs +63 -0
- package/bin/lib/orchestrator.mjs +49 -7
- package/bin/lib/orchestratorNative.mjs +133 -3
- package/bin/lib/orchestratorPrompt.mjs +50 -4
- package/bin/lib/postureTools.mjs +14 -6
- package/bin/lib/printable.mjs +1 -1
- package/bin/lib/processGroup.mjs +41 -0
- package/bin/lib/projectTools.mjs +3 -2
- package/bin/lib/prompts.mjs +47 -18
- package/bin/lib/reportJournal.mjs +61 -0
- package/bin/lib/repositoryMerge.mjs +1 -1
- package/bin/lib/researchHandoff.mjs +144 -0
- package/bin/lib/researchHandoffText.mjs +3 -0
- package/bin/lib/researchKnowledge.mjs +60 -0
- package/bin/lib/researchRestoration.mjs +38 -0
- package/bin/lib/resumeLost.mjs +21 -1
- package/bin/lib/runTurn.mjs +98 -44
- package/bin/lib/runtimeCapabilities.mjs +7 -2
- package/bin/lib/runtimeClaude.mjs +30 -8
- package/bin/lib/runtimeCodex.mjs +54 -22
- package/bin/lib/runtimeEvents.mjs +17 -7
- package/bin/lib/runtimeOfferings.mjs +3 -1
- package/bin/lib/startProject.mjs +10 -0
- package/bin/lib/turnDiagnostics.mjs +2 -1
- package/bin/lib/turnOutput.mjs +63 -0
- package/bin/lib/turnRecord.mjs +11 -0
- package/bin/lib/turnSubagents.mjs +13 -3
- package/bin/lib/ui.mjs +2 -2
- package/bin/lib/update.mjs +18 -4
- package/bin/lib/uplinkScrub.mjs +49 -0
- package/bin/lib/views.mjs +49 -58
- package/bin/lib/wikiRunner.mjs +2 -2
- package/bin/lib/work.mjs +5 -5
- package/bin/lib/workActivity.mjs +65 -0
- package/bin/lib/workAgentBriefRunner.mjs +2 -2
- package/bin/lib/workAgentMerges.mjs +57 -52
- package/bin/lib/workAgentPlans.mjs +76 -27
- package/bin/lib/workAgentPrecheck.mjs +1 -1
- package/bin/lib/workAgentTurnExecution.mjs +62 -8
- package/bin/lib/workAgentTurnReports.mjs +33 -3
- package/bin/lib/workAgentTurnShape.mjs +6 -4
- package/bin/lib/workAgentTurns.mjs +12 -8
- package/bin/lib/workAttachments.mjs +27 -5
- package/bin/lib/workIntake.mjs +8 -2
- package/bin/lib/workMergeBatch.mjs +7 -1
- package/bin/lib/workNarration.mjs +32 -0
- package/bin/lib/workPlanRecovery.mjs +50 -0
- package/bin/lib/workReportQueue.mjs +18 -1
- package/bin/lib/workSessionTurns.mjs +64 -22
- package/bin/lib/worktreeDiff.mjs +29 -5
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
|
|
|
10
10
|
import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
|
+
import { prepareResearchLaunch } from './researchHandoff.mjs';
|
|
13
14
|
import { versionAtLeast } from './versionOrder.mjs';
|
|
14
15
|
|
|
15
16
|
const MAX_FILES = 20_000;
|
|
@@ -120,7 +121,7 @@ export function researchFence({ cwd, helper, generatedImages }) {
|
|
|
120
121
|
* nothing. Artifact publication stays in the original worktree for the
|
|
121
122
|
* existing uploader.
|
|
122
123
|
*/
|
|
123
|
-
export function publishResearch(source, destination) {
|
|
124
|
+
export function publishResearch(source, destination, inputs = new Map()) {
|
|
124
125
|
if (realDirState(destination) === 'none') mkdirSync(destination, { recursive: true });
|
|
125
126
|
if (realDirState(destination) !== 'dir') throw new Error('The research artifact destination is not a real directory.');
|
|
126
127
|
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
@@ -128,7 +129,7 @@ export function publishResearch(source, destination) {
|
|
|
128
129
|
const to = join(destination, entry.name);
|
|
129
130
|
const stat = lstatSync(from);
|
|
130
131
|
if (stat.isSymbolicLink()) throw new Error('An isolated research output is a symlink; nothing was accepted through it.');
|
|
131
|
-
if (stat.isDirectory()) publishResearch(from, to);
|
|
132
|
+
if (stat.isDirectory()) publishResearch(from, to, inputs);
|
|
132
133
|
else if (stat.isFile() && /\.md$/i.test(entry.name)) {
|
|
133
134
|
if (stat.size > 2 * 1024 * 1024) throw new Error('The research Markdown output exceeds 2 MB.');
|
|
134
135
|
let there = null;
|
|
@@ -139,6 +140,7 @@ export function publishResearch(source, destination) {
|
|
|
139
140
|
}
|
|
140
141
|
if (there && !there.isFile()) throw new Error('The research output would overwrite a non-file.');
|
|
141
142
|
const bytes = readFileSync(from);
|
|
143
|
+
if (inputs.get(from)?.equals(bytes)) continue;
|
|
142
144
|
if (there && there.size === bytes.length && readFileSync(to).equals(bytes)) continue;
|
|
143
145
|
if (there) unlinkSync(to);
|
|
144
146
|
writeFileSync(to, bytes, { flag: 'wx' });
|
|
@@ -146,8 +148,27 @@ export function publishResearch(source, destination) {
|
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
// Retained context is person/source scoped. Do not make another discussion's
|
|
152
|
+
// reports readable merely because they happen to stand in this worktree.
|
|
153
|
+
function retainResearchInputs(dir, names, relative = '') {
|
|
154
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
155
|
+
const name = relative + entry.name, path = join(dir, entry.name);
|
|
156
|
+
if (entry.isDirectory()) retainResearchInputs(path, names, name + '/');
|
|
157
|
+
else if (!names.has(name)) rmSync(path, { force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function rememberResearchInputs(dir, inputs = new Map()) {
|
|
161
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
162
|
+
const path = join(dir, entry.name);
|
|
163
|
+
if (entry.isDirectory()) rememberResearchInputs(path, inputs);
|
|
164
|
+
else if (entry.isFile() && /\.md$/i.test(entry.name)) inputs.set(path, readFileSync(path));
|
|
165
|
+
}
|
|
166
|
+
return inputs;
|
|
167
|
+
}
|
|
168
|
+
|
|
149
169
|
export async function runIsolatedResearch(opts, run, {
|
|
150
170
|
resolveHelper = codexResearchHelper,
|
|
171
|
+
prepareLaunch = prepareResearchLaunch,
|
|
151
172
|
readVersion = (helper) => execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10_000 }),
|
|
152
173
|
} = {}) {
|
|
153
174
|
if (process.platform !== 'linux') throw new Error('Isolated Codex research requires Linux or WSL.');
|
|
@@ -171,11 +192,16 @@ export async function runIsolatedResearch(opts, run, {
|
|
|
171
192
|
try {
|
|
172
193
|
copyResearchView(original, snapshot);
|
|
173
194
|
let knowledge = null;
|
|
174
|
-
if (opts.
|
|
195
|
+
if (Number.isInteger(opts.researchHandoff?.knowledgeRev)) {
|
|
196
|
+
// The launch loader will build this from the held current manifest.
|
|
197
|
+
// Never copy an unmeasured/background-sync mirror into its input view.
|
|
198
|
+
knowledge = join(snapshot, '.flowviant/knowledge');
|
|
199
|
+
} else if (opts.knowledgeDir) {
|
|
175
200
|
knowledge = join(snapshot, '.flowviant/knowledge');
|
|
176
201
|
copyResearchView(realpathSync(opts.knowledgeDir), knowledge);
|
|
177
202
|
}
|
|
178
203
|
for (const dir of ['artifacts', 'tmp']) mkdirSync(join(snapshot, '.flowviant', dir), { recursive: true, mode: 0o700 });
|
|
204
|
+
if (opts.researchHandoff) retainResearchInputs(join(snapshot, '.flowviant/artifacts'), new Set(opts.researchHandoff.artifacts.filter(a => !a.unavailable).map(a => a.name)));
|
|
179
205
|
const rewrite = (text) => typeof text === 'string'
|
|
180
206
|
? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
|
|
181
207
|
const intro = 'This fresh research turn uses an isolated read-only copy. Prior reports are available under .flowviant/artifacts/; previous CLI conversations are not resumed because they may contain host secrets. Git metadata, .env files, credential files, symlinks and personal CLI settings are omitted. Write the Markdown report only under .flowviant/artifacts/. Web search is available; shell networking is disabled. Use exec_command with shell=/bin/bash and login=false; personal shells are outside the fence.\n\n';
|
|
@@ -187,13 +213,15 @@ export async function runIsolatedResearch(opts, run, {
|
|
|
187
213
|
throw new Error('The native generated image directory is not a real directory.');
|
|
188
214
|
generatedImages = join(imageHome, 'generated_images');
|
|
189
215
|
}
|
|
190
|
-
const
|
|
216
|
+
const prepared = await prepareLaunch({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, adoptResumeId: null, cwd: snapshot, knowledgeDir: knowledge,
|
|
191
217
|
prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
|
|
192
218
|
researchIsolation: { cwd: snapshot, helper, generatedImages } });
|
|
219
|
+
const inputs = rememberResearchInputs(join(snapshot, '.flowviant/artifacts'));
|
|
220
|
+
const result = await run(prepared);
|
|
193
221
|
// Made, or found, real at publication too: every component checked again.
|
|
194
222
|
if (!ensureRealSubdir(original, '.flowviant/artifacts'))
|
|
195
223
|
throw new Error('The research artifact destination is not a real directory.');
|
|
196
|
-
publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'));
|
|
224
|
+
publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'), inputs);
|
|
197
225
|
return typeof result === 'string' ? result.replaceAll(snapshot, original) : result;
|
|
198
226
|
} finally { rmSync(isolation, { recursive: true, force: true }); }
|
|
199
227
|
}
|
package/bin/lib/commandSpecs.mjs
CHANGED
|
@@ -30,10 +30,13 @@ export const COMMANDS = [
|
|
|
30
30
|
{
|
|
31
31
|
name: 'start',
|
|
32
32
|
usesCheckout: true,
|
|
33
|
-
usage: 'flowviant [start] [--project <name|id>]',
|
|
33
|
+
usage: 'flowviant [start] [--detail] [--project <name|id>]',
|
|
34
34
|
summary: "Serve this repo's project: run agents and Terminal tabs here. Plain `flowviant` does the same.",
|
|
35
35
|
details: [
|
|
36
36
|
'Run it inside the repository you connected. The first run in a new repo asks you to approve this computer.',
|
|
37
|
+
'Default: compact chronological activity, with fresh routine summaries every 10 seconds and at event boundaries.',
|
|
38
|
+
'--detail print each runtime activity in the foreground; preserved through login and update relaunches',
|
|
39
|
+
'flowviant logs -f --turn @<label> follow retained detail for the short turn label in the feed (full ids also work)',
|
|
37
40
|
'--project <name|id> serve that stored project instead of the one this repo is bound to',
|
|
38
41
|
],
|
|
39
42
|
group: 'run',
|
|
@@ -41,9 +44,9 @@ export const COMMANDS = [
|
|
|
41
44
|
{
|
|
42
45
|
name: 'login',
|
|
43
46
|
usesCheckout: true,
|
|
44
|
-
usage: 'flowviant login [--no-start]',
|
|
47
|
+
usage: 'flowviant login [--no-start] [--detail]',
|
|
45
48
|
summary: 'Connect this repo to a project: approve the code in your browser, then start serving.',
|
|
46
|
-
details: ['--no-start save the connection without starting the daemon'],
|
|
49
|
+
details: ['--no-start save the connection without starting the daemon', '--detail start with the detailed foreground stream'],
|
|
47
50
|
group: 'run',
|
|
48
51
|
},
|
|
49
52
|
{
|
|
@@ -64,9 +67,9 @@ export const COMMANDS = [
|
|
|
64
67
|
{
|
|
65
68
|
name: 'logs',
|
|
66
69
|
usesCheckout: true,
|
|
67
|
-
usage: 'flowviant logs [-f] [--project <name|id>]',
|
|
70
|
+
usage: 'flowviant logs [-f] [-n <lines>] [--turn <id>] [--project <name|id>]',
|
|
68
71
|
summary: "The daemon's log for this repo's project.",
|
|
69
|
-
details: ['-f, --follow keep printing new lines
|
|
72
|
+
details: ['-f, --follow keep printing new retained lines across rotation and restart', '-n <lines> how many matching lines to show first (default 80)', '--turn <id> select an exact full id or the @short label in the feed, including native children', '--project <name|id> select a connected project', 'Retains the current 2 MiB log and one predecessor; older detail expires.', 'Native records keep supplied prose and tool data, at most 64 KiB each.', 'Truncation and detected gaps are marked. This is diagnostic detail, not a full transcript.'],
|
|
70
73
|
group: 'look',
|
|
71
74
|
},
|
|
72
75
|
{
|
package/bin/lib/config.mjs
CHANGED
|
@@ -226,7 +226,28 @@ export const USER_AGENT = `flowviant/${VERSION}`;
|
|
|
226
226
|
* back. Sent on the poll as `di`; its ABSENCE is what an older daemon looks
|
|
227
227
|
* like, and the server hands preview work to nobody who cannot name themselves.
|
|
228
228
|
*/
|
|
229
|
-
export const DAEMON_INSTANCE = randomBytes(12).toString('hex');
|
|
229
|
+
export const DAEMON_INSTANCE = inheritedInstance() ?? randomBytes(12).toString('hex');
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* …EXCEPT ACROSS ITS OWN SELF-UPDATE (2026-10-19). A self-update re-execs
|
|
233
|
+
* only when the machine is idle (update.mjs), so the process it replaces
|
|
234
|
+
* holds nothing in flight — only its tabs' session leases, which no other
|
|
235
|
+
* daemon may take for 45 seconds. A successor with a new id waited out every
|
|
236
|
+
* one of them, so each unattended update left every tab unanswered for most
|
|
237
|
+
* of a minute. The successor named by `FLOWVIANT_REEXEC` takes the id its
|
|
238
|
+
* predecessor handed it; any other start still regenerates it.
|
|
239
|
+
*/
|
|
240
|
+
function inheritedInstance() {
|
|
241
|
+
const handed = process.env.FLOWVIANT_DAEMON_INSTANCE;
|
|
242
|
+
// TAKEN ONCE, THEN GONE FROM THIS PROCESS'S ENV. Every CLI turn spawns with
|
|
243
|
+
// `{ ...process.env }`, so a variable left here reached any `flowviant`
|
|
244
|
+
// started inside one — an agent running the daemon from a worktree — and
|
|
245
|
+
// two live processes would answer to one instance (the trap instance.mjs
|
|
246
|
+
// names for FLOWVIANT_REEXEC_FROM). `reexec` sets it afresh for its own
|
|
247
|
+
// successor, so the next update still hands it on.
|
|
248
|
+
delete process.env.FLOWVIANT_DAEMON_INSTANCE;
|
|
249
|
+
return process.env.FLOWVIANT_REEXEC === '1' && typeof handed === 'string' && /^[0-9a-f]{24}$/.test(handed) ? handed : null;
|
|
250
|
+
}
|
|
230
251
|
|
|
231
252
|
/**
|
|
232
253
|
* WHICH BOX, in words a person recognises — and nothing else.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Bounded local diagnostic journal. Native records share the existing project
|
|
2
|
+
* log with legacy console lines. Each native record occupies one JSON line;
|
|
3
|
+
* replay renders its text, rather than presenting shortened labels as history.
|
|
4
|
+
*/
|
|
5
|
+
import { appendFileSync, closeSync, constants, fchmodSync, fstatSync, mkdirSync, openSync, renameSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
8
|
+
import { printableLine, printableText } from './printable.mjs';
|
|
9
|
+
import { scrub } from './uplinkScrub.mjs';
|
|
10
|
+
|
|
11
|
+
export const MAX_DAEMON_LOG_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
export const MAX_DETAIL_RECORD_BYTES = 64 * 1024;
|
|
13
|
+
export const DETAIL_PREFIX = '@flowviant-detail ';
|
|
14
|
+
export const DETAIL_TRUNCATED = '\n[truncated: local detail record exceeded 64 KiB]';
|
|
15
|
+
export const DETAIL_GAP = '[detail gap: local log writes failed; some detail was not retained]';
|
|
16
|
+
|
|
17
|
+
export const utf8Prefix = (text, bytes) => new StringDecoder('utf8').write((Buffer.isBuffer(text) ? text : Buffer.from(text)).subarray(0, Math.max(0, bytes)));
|
|
18
|
+
const safe = text => printableText(scrub(printableText(scrub(String(text ?? '')))));
|
|
19
|
+
|
|
20
|
+
export function encodeDetailRecord(record) {
|
|
21
|
+
const row = { v: 1, observedAt: record.observedAt, kind: printableLine(safe(record.kind), 120), name: printableLine(safe(record.name), 120),
|
|
22
|
+
workId: safe(record.workId), turnId: safe(record.turnId), source: safe(record.source),
|
|
23
|
+
...(record.agentId ? { agentId: safe(record.agentId) } : {}), text: safe(record.text) };
|
|
24
|
+
const encode = () => DETAIL_PREFIX + JSON.stringify(row) + '\n';
|
|
25
|
+
const encoded = row.text.length <= MAX_DETAIL_RECORD_BYTES ? encode() : null;
|
|
26
|
+
if (encoded && Buffer.byteLength(encoded) <= MAX_DETAIL_RECORD_BYTES) return encoded;
|
|
27
|
+
const full = Buffer.from(row.text);
|
|
28
|
+
row.truncated = true;
|
|
29
|
+
row.text = DETAIL_TRUNCATED;
|
|
30
|
+
if (Buffer.byteLength(encode()) > MAX_DETAIL_RECORD_BYTES) throw new Error('Detail attribution exceeds the record limit');
|
|
31
|
+
let low = 0, high = Math.min(full.length, MAX_DETAIL_RECORD_BYTES);
|
|
32
|
+
while (low < high) {
|
|
33
|
+
const mid = Math.ceil((low + high) / 2);
|
|
34
|
+
row.text = utf8Prefix(full, mid) + DETAIL_TRUNCATED;
|
|
35
|
+
if (Buffer.byteLength(encode()) <= MAX_DETAIL_RECORD_BYTES) low = mid;
|
|
36
|
+
else high = mid - 1;
|
|
37
|
+
}
|
|
38
|
+
row.text = utf8Prefix(full, low) + DETAIL_TRUNCATED;
|
|
39
|
+
return encode();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decodeDetailRecord(line) {
|
|
43
|
+
if (!line.startsWith(DETAIL_PREFIX)) return null;
|
|
44
|
+
if (Buffer.byteLength(line) > MAX_DETAIL_RECORD_BYTES) return false;
|
|
45
|
+
try {
|
|
46
|
+
const row = JSON.parse(line.slice(DETAIL_PREFIX.length));
|
|
47
|
+
if (row?.v !== 1 || !Number.isFinite(row.observedAt) ||
|
|
48
|
+
!['kind', 'name', 'workId', 'turnId', 'source', 'text'].every(k => typeof row[k] === 'string') ||
|
|
49
|
+
row.agentId !== undefined && typeof row.agentId !== 'string') return false;
|
|
50
|
+
// Older/local files are untrusted at replay too. Scrub text before it is
|
|
51
|
+
// expanded into terminal lines; never revive escapes from a JSON string.
|
|
52
|
+
return { ...row, kind: printableLine(safe(row.kind), 120), name: printableLine(safe(row.name), 120),
|
|
53
|
+
workId: printableLine(safe(row.workId), Infinity), turnId: printableLine(safe(row.turnId), Infinity),
|
|
54
|
+
...(row.agentId ? { agentId: printableLine(safe(row.agentId), Infinity) } : {}), text: safe(row.text) };
|
|
55
|
+
} catch { return false; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createDaemonJournal(path, { onFailure = () => {} } = {}) {
|
|
59
|
+
let missed = false;
|
|
60
|
+
const append = (raw) => {
|
|
61
|
+
let fd = null;
|
|
62
|
+
try {
|
|
63
|
+
let chunk = Buffer.from(raw);
|
|
64
|
+
const tail = '\n[truncated]\n';
|
|
65
|
+
if (chunk.length > MAX_DAEMON_LOG_BYTES)
|
|
66
|
+
chunk = Buffer.from(utf8Prefix(raw, MAX_DAEMON_LOG_BYTES - Buffer.byteLength(tail)) + tail);
|
|
67
|
+
const gap = missed ? Buffer.from('\n' + DETAIL_GAP + '\n') : Buffer.alloc(0);
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
69
|
+
const open = () => openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
70
|
+
fd = open();
|
|
71
|
+
fchmodSync(fd, 0o600);
|
|
72
|
+
if (!fstatSync(fd).isFile()) throw new Error('The local log is not a regular file');
|
|
73
|
+
if (fstatSync(fd).size + gap.length + chunk.length > MAX_DAEMON_LOG_BYTES) {
|
|
74
|
+
closeSync(fd); fd = null;
|
|
75
|
+
renameSync(path, path + '.1');
|
|
76
|
+
fd = open();
|
|
77
|
+
fchmodSync(fd, 0o600);
|
|
78
|
+
}
|
|
79
|
+
// A gap is a separate bounded entry, so even a file-sized console line
|
|
80
|
+
// cannot make a recovered log exceed its cap.
|
|
81
|
+
if (gap.length && gap.length + chunk.length <= MAX_DAEMON_LOG_BYTES) appendFileSync(fd, gap);
|
|
82
|
+
else if (gap.length) {
|
|
83
|
+
appendFileSync(fd, gap); closeSync(fd); fd = null;
|
|
84
|
+
renameSync(path, path + '.1'); fd = open(); fchmodSync(fd, 0o600);
|
|
85
|
+
}
|
|
86
|
+
appendFileSync(fd, chunk);
|
|
87
|
+
missed = false;
|
|
88
|
+
return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!missed) try { onFailure(error); } catch { /* diagnostic only */ }
|
|
91
|
+
missed = true;
|
|
92
|
+
return false;
|
|
93
|
+
} finally { if (fd !== null) try { closeSync(fd); } catch { /* a log close never stops work */ } }
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
console: text => append(safe(text) + '\n'),
|
|
97
|
+
record(record) {
|
|
98
|
+
try { return append(encodeDetailRecord(record)); }
|
|
99
|
+
catch (error) { if (!missed) try { onFailure(error); } catch { /* diagnostic only */ } missed = true; return false; }
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -8,11 +8,23 @@
|
|
|
8
8
|
* module. This one owns output only; it is handed the log path and never
|
|
9
9
|
* decides where per-project files live (daemonState.mjs does).
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
12
|
-
import { dirname } from 'node:path';
|
|
11
|
+
import { createDaemonJournal } from './daemonJournal.mjs';
|
|
13
12
|
import { format } from 'node:util';
|
|
13
|
+
import { compactOutput, flushActivityOutput } from './activityFeed.mjs';
|
|
14
14
|
|
|
15
|
-
export
|
|
15
|
+
export { MAX_DAEMON_LOG_BYTES } from './daemonJournal.mjs';
|
|
16
|
+
let journal = null;
|
|
17
|
+
export const hasDaemonJournal = () => journal !== null;
|
|
18
|
+
|
|
19
|
+
/** null means no local daemon journal is configured; false is a measured failure. */
|
|
20
|
+
export const retainTurnRecord = record => journal ? journal.record(record) : null;
|
|
21
|
+
|
|
22
|
+
// Explicit diagnostic source; silence only after its local write succeeded.
|
|
23
|
+
export function diagnostic(line) {
|
|
24
|
+
if (!journal) return console.info(line);
|
|
25
|
+
const retained = journal.console(line);
|
|
26
|
+
if (!compactOutput() || !retained) process.stderr.write(`${line}\n`);
|
|
27
|
+
}
|
|
16
28
|
|
|
17
29
|
let events = false;
|
|
18
30
|
let eventWriter = process.stdout.write.bind(process.stdout);
|
|
@@ -25,29 +37,21 @@ export function emitMachineEvent(value) {
|
|
|
25
37
|
* mode stdout is reserved for JSONL and those same lines go to stderr. Every
|
|
26
38
|
* line is also appended to `logPath`, rotated at MAX_DAEMON_LOG_BYTES. */
|
|
27
39
|
export function installDaemonLogging(logPath, { jsonEvents = false } = {}) {
|
|
28
|
-
if (!logPath) return;
|
|
29
40
|
if (jsonEvents) {
|
|
30
41
|
enableMachineEvents();
|
|
31
42
|
process.stdout.write = (...args) => process.stderr.write(...args);
|
|
32
43
|
}
|
|
44
|
+
if (!logPath) return;
|
|
33
45
|
const original = { log: console.log, info: console.info, warn: console.warn, error: console.error };
|
|
46
|
+
journal = createDaemonJournal(logPath, { onFailure: error => {
|
|
47
|
+
flushActivityOutput();
|
|
48
|
+
original.warn(`Local detail could not be retained (${error?.code || 'log write failed'}). Work continues; the log may have gaps.`);
|
|
49
|
+
} });
|
|
34
50
|
for (const method of Object.keys(original)) {
|
|
35
51
|
console[method] = (...args) => {
|
|
52
|
+
flushActivityOutput();
|
|
36
53
|
const line = format(...args);
|
|
37
|
-
|
|
38
|
-
mkdirSync(dirname(logPath), { recursive: true });
|
|
39
|
-
const encoded = Buffer.from(`${line}\n`);
|
|
40
|
-
const tail = Buffer.from('\n[truncated]\n');
|
|
41
|
-
const chunk = encoded.length > MAX_DAEMON_LOG_BYTES
|
|
42
|
-
? Buffer.concat([encoded.subarray(0, MAX_DAEMON_LOG_BYTES - tail.length), tail])
|
|
43
|
-
: encoded;
|
|
44
|
-
let size = 0;
|
|
45
|
-
try { size = statSync(logPath).size; } catch { /* new file */ }
|
|
46
|
-
if (size + chunk.length > MAX_DAEMON_LOG_BYTES) {
|
|
47
|
-
try { renameSync(logPath, `${logPath}.1`); } catch { /* no old log */ }
|
|
48
|
-
}
|
|
49
|
-
appendFileSync(logPath, chunk, { mode: 0o600 });
|
|
50
|
-
} catch { /* logging must never stop service */ }
|
|
54
|
+
journal.console(line);
|
|
51
55
|
if (jsonEvents) process.stderr.write(`${line}\n`);
|
|
52
56
|
else original[method](...args);
|
|
53
57
|
};
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -35,11 +35,12 @@ import {
|
|
|
35
35
|
import { projectLabel } from './credentials.mjs';
|
|
36
36
|
import { handleVersionSignal } from './update.mjs';
|
|
37
37
|
import { compareVersions } from './versionOrder.mjs';
|
|
38
|
-
import { emitMachineEvent } from './daemonLogging.mjs';
|
|
39
|
-
import {
|
|
38
|
+
import { diagnostic, emitMachineEvent, hasDaemonJournal, installDaemonLogging } from './daemonLogging.mjs';
|
|
39
|
+
import { compactOutput } from './activityFeed.mjs';
|
|
40
|
+
import { daemonLogPath, writeDaemonState } from './daemonState.mjs';
|
|
40
41
|
import { maintenanceTick, readMaintenance } from './maintenance.mjs';
|
|
41
42
|
import { repoRootOrDie, detectBaseRef, excludeInWorktree, baseRefFor } from './git.mjs';
|
|
42
|
-
import { c, info, note, ok, warn, fail } from './ui.mjs';
|
|
43
|
+
import { c, stamp, info, note, ok, warn, fail } from './ui.mjs';
|
|
43
44
|
import { sleep } from './sleep.mjs';
|
|
44
45
|
import { reapOrphanPreviews } from './previewRegistry.mjs';
|
|
45
46
|
import { preflight } from './preflight.mjs';
|
|
@@ -370,9 +371,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
370
371
|
});
|
|
371
372
|
installSignalHandlers(teardown, leave);
|
|
372
373
|
|
|
374
|
+
let connectionFailed = false;
|
|
373
375
|
let connected = false; // log the first successful poll once
|
|
374
376
|
let rosterSig = null; // last roster membership, to log changes only
|
|
375
|
-
let idleBeatAt = 0; // throttle the
|
|
377
|
+
let idleBeatAt = 0; // throttle the legacy detailed idle snapshot
|
|
376
378
|
// WHOSE MACHINE THIS IS, as of the last poll the server arbitrated. 'absent'
|
|
377
379
|
// is the reserved meaning — an older server, or a poll with no envpub — and
|
|
378
380
|
// everything downstream of it must read exactly as it did before 0.84.0.
|
|
@@ -434,6 +436,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
434
436
|
await leave(0);
|
|
435
437
|
return;
|
|
436
438
|
}
|
|
439
|
+
connectionFailed = true;
|
|
437
440
|
warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
|
|
438
441
|
// Work already running keeps running offline; the measurement keeps up.
|
|
439
442
|
if (!await reportMaintenance()) reportBusy();
|
|
@@ -441,6 +444,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
441
444
|
continue;
|
|
442
445
|
}
|
|
443
446
|
if (standingDown()) return leave.drained();
|
|
447
|
+
if (connectionFailed && connected) ok('Connection recovered — connected to Flowviant.');
|
|
448
|
+
connectionFailed = false;
|
|
444
449
|
if (!connected) {
|
|
445
450
|
connected = true;
|
|
446
451
|
ok('Connected to Flowviant — watching your roster.');
|
|
@@ -467,6 +472,9 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
467
472
|
// The server's word on which project this token serves — settles the env
|
|
468
473
|
// report's salt for a token that came from --fleet or the environment.
|
|
469
474
|
learnProjectId(roster.project?.id);
|
|
475
|
+
// Env-token starts learn their project here. No CLI activity is hidden
|
|
476
|
+
// before a journal exists; select it before handing out this roster's work.
|
|
477
|
+
if (!hasDaemonJournal() && roster.project?.id) installDaemonLogging(daemonLogPath(roster.project.id));
|
|
470
478
|
if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
|
|
471
479
|
// THE THREE SIGNALS THAT END THIS DAEMON — a commanded stop, then a
|
|
472
480
|
// displacement, then a removal (holder.mjs, `obeyRosterCommands`). ABOVE
|
|
@@ -678,7 +686,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
678
686
|
if (holderState !== 'standby')
|
|
679
687
|
info('Machine online. Open a tab in Flowviant → Terminal to start working.');
|
|
680
688
|
}
|
|
681
|
-
//
|
|
689
|
+
// The legacy idle snapshot remains in detail only. Compact silence does
|
|
690
|
+
// not manufacture activity or health. Gated on REAL work —
|
|
682
691
|
// it was once gated on `roster.agents`, which the server sends permanently
|
|
683
692
|
// empty, and printed "waiting" once a minute even while a tab's turn was
|
|
684
693
|
// running. `workBusy()` is the honest question: are there session turns,
|
|
@@ -690,7 +699,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
690
699
|
// this box. The internal state is still called `standby` because it names
|
|
691
700
|
// the SERVER'S arbitration arm rather than anything a person reads; what
|
|
692
701
|
// a person reads is this sentence.
|
|
693
|
-
info
|
|
702
|
+
const sayIdle = compactOutput() ? text => diagnostic(`${stamp()} · ${text}`) : info;
|
|
703
|
+
sayIdle(
|
|
694
704
|
holderState === 'standby'
|
|
695
705
|
? 'inactive — another machine is serving this project.'
|
|
696
706
|
: 'machine online — nothing running right now.'
|
package/bin/lib/fleetReports.mjs
CHANGED
|
@@ -383,11 +383,18 @@ export function reportMachine({ worktreeDir, liveTurns }) {
|
|
|
383
383
|
// `liveTurns()` is the live registry: every CLI child this daemon is
|
|
384
384
|
// holding, with the session or agent id it serves. Bounded inside the
|
|
385
385
|
// snapshot, because each row costs a /proc tree walk.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
386
|
+
//
|
|
387
|
+
// WHICH BOX IS SPEAKING (2026-10-19): `pubkey`, the same label the env and
|
|
388
|
+
// tools reports carry. A standby posts this too, and the room kept one
|
|
389
|
+
// snapshot, so on a project with two boxes the vitals alternated between
|
|
390
|
+
// them; the room now keeps each box's apart, and the project settings dial
|
|
391
|
+
// recommends from the smallest. Null before a keypair exists, which the
|
|
392
|
+
// server reads as one unnamed box, as it reads every older daemon.
|
|
393
|
+
void postToFleet(MACHINE_URL, {
|
|
394
|
+
...machineSnapshot({
|
|
389
395
|
worktreeDir,
|
|
390
396
|
tasks: liveTurns().map((t) => ({ intentId: t.id, pid: t.pid })),
|
|
391
|
-
})
|
|
392
|
-
|
|
397
|
+
}),
|
|
398
|
+
pubkey: myPubB64(),
|
|
399
|
+
});
|
|
393
400
|
}
|
|
@@ -29,7 +29,9 @@ export async function main() {
|
|
|
29
29
|
// output) or at a stop (the child keeps going) that refusal is false. An
|
|
30
30
|
// event too large to read is never a spawn: no model writes 24 MiB of input.
|
|
31
31
|
// The names are the controller's own list (nativeAgentTools.mjs).
|
|
32
|
-
|
|
32
|
+
// A research card's children only read (FLOWVIANT_ORCHESTRATOR_CHILDREN,
|
|
33
|
+
// orchestrator.mjs): a child's write is refused here too.
|
|
34
|
+
if (gatedSpawnEvent(event, process.env.FLOWVIANT_ORCHESTRATOR_CHILDREN)) { process.exitCode = 2; return; }
|
|
33
35
|
try { await answer({}); } catch { /* the CLI already stopped reading */ }
|
|
34
36
|
}
|
|
35
37
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** Replay and follow the existing project log plus its one predecessor.
|
|
2
|
+
* Open descriptors carry snapshot offsets into follow: a restart appends at
|
|
3
|
+
* that offset, and replacement is detected by inode, even at equal size.
|
|
4
|
+
*/
|
|
5
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
|
|
6
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
7
|
+
import { decodeDetailRecord, MAX_DAEMON_LOG_BYTES } from './daemonJournal.mjs';
|
|
8
|
+
import { formatTurnRecord } from './turnRecord.mjs';
|
|
9
|
+
import { printableText } from './printable.mjs';
|
|
10
|
+
import { scrub } from './uplinkScrub.mjs';
|
|
11
|
+
import { matchesTurn } from './activityFeed.mjs';
|
|
12
|
+
|
|
13
|
+
const clean = text => printableText(scrub(printableText(scrub(text))));
|
|
14
|
+
const same = (a, b) => a && b && a.dev === b.dev && a.ino === b.ino;
|
|
15
|
+
const closeCursor = cursor => { if (cursor && !cursor.closed) { cursor.closed = true; closeSync(cursor.fd); } };
|
|
16
|
+
function openCursor(path) {
|
|
17
|
+
let fd;
|
|
18
|
+
try {
|
|
19
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
20
|
+
const stat = fstatSync(fd);
|
|
21
|
+
if (!stat.isFile()) throw new Error('The log is not a regular file');
|
|
22
|
+
return { fd, dev: stat.dev, ino: stat.ino, offset: 0, rest: '', decoder: new StringDecoder('utf8'), skip: false };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (fd !== undefined) closeSync(fd);
|
|
25
|
+
if (error.code === 'ENOENT') return null;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createLogReader(path, { turnId = null } = {}) {
|
|
31
|
+
let current = null, previousSeen = null, initialized = false, closed = false;
|
|
32
|
+
const line = (raw, batch) => {
|
|
33
|
+
const row = decodeDetailRecord(raw);
|
|
34
|
+
if (row === false) { batch.lines.push('[detail gap: an incomplete or unreadable retained record was skipped]'); return; }
|
|
35
|
+
if (row) {
|
|
36
|
+
if (turnId !== null && !matchesTurn(row.turnId, turnId)) return;
|
|
37
|
+
batch.matched++;
|
|
38
|
+
for (const text of formatTurnRecord(row, { color: false }).slice(0, -1).split('\n')) batch.lines.push(text);
|
|
39
|
+
} else if (turnId === null || raw.includes('[detail gap:') || raw === '[truncated]') batch.lines.push(clean(raw));
|
|
40
|
+
};
|
|
41
|
+
const read = (cursor, batch) => {
|
|
42
|
+
const size = fstatSync(cursor.fd).size;
|
|
43
|
+
if (size < cursor.offset) {
|
|
44
|
+
batch.lines.push('[detail gap: the log was truncated in place]');
|
|
45
|
+
cursor.offset = 0; cursor.rest = ''; cursor.decoder = new StringDecoder('utf8');
|
|
46
|
+
}
|
|
47
|
+
if (size - cursor.offset > MAX_DAEMON_LOG_BYTES) {
|
|
48
|
+
batch.lines.push('[detail gap: older bytes exceeded the retained read window]');
|
|
49
|
+
cursor.offset = size - MAX_DAEMON_LOG_BYTES; cursor.rest = ''; cursor.decoder = new StringDecoder('utf8'); cursor.skip = true;
|
|
50
|
+
}
|
|
51
|
+
const bytes = Buffer.alloc(32 * 1024);
|
|
52
|
+
while (cursor.offset < size) {
|
|
53
|
+
const n = readSync(cursor.fd, bytes, 0, Math.min(bytes.length, size - cursor.offset), cursor.offset);
|
|
54
|
+
if (!n) break;
|
|
55
|
+
cursor.offset += n;
|
|
56
|
+
cursor.rest += cursor.decoder.write(bytes.subarray(0, n));
|
|
57
|
+
let at;
|
|
58
|
+
while ((at = cursor.rest.indexOf('\n')) >= 0) {
|
|
59
|
+
const raw = cursor.rest.slice(0, at); cursor.rest = cursor.rest.slice(at + 1);
|
|
60
|
+
if (cursor.skip) cursor.skip = false;
|
|
61
|
+
else line(raw, batch);
|
|
62
|
+
}
|
|
63
|
+
if (cursor.rest.length > MAX_DAEMON_LOG_BYTES) {
|
|
64
|
+
cursor.rest = ''; cursor.skip = true;
|
|
65
|
+
batch.lines.push('[detail gap: an oversized incomplete log line was skipped]');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const retire = (cursor, batch) => {
|
|
70
|
+
try {
|
|
71
|
+
read(cursor, batch);
|
|
72
|
+
if (cursor.rest || cursor.decoder.lastNeed) batch.lines.push('[detail gap: an unfinished log record was not replayed]');
|
|
73
|
+
} finally { closeCursor(cursor); }
|
|
74
|
+
};
|
|
75
|
+
const refresh = () => {
|
|
76
|
+
const batch = { lines: [], matched: 0, found: false };
|
|
77
|
+
if (closed) return batch;
|
|
78
|
+
const next = openCursor(path);
|
|
79
|
+
const predecessor = () => {
|
|
80
|
+
try { return openCursor(path + '.1'); }
|
|
81
|
+
catch (error) { if (next) closeCursor(next); throw error; }
|
|
82
|
+
};
|
|
83
|
+
batch.found = Boolean(next || current);
|
|
84
|
+
if (!initialized) {
|
|
85
|
+
initialized = true;
|
|
86
|
+
const previous = predecessor();
|
|
87
|
+
let latest;
|
|
88
|
+
try { latest = openCursor(path); }
|
|
89
|
+
catch (error) { closeCursor(next); closeCursor(previous); throw error; }
|
|
90
|
+
batch.found = Boolean(next || previous || latest);
|
|
91
|
+
// Rotation can happen between opening the two names. Recheck main:
|
|
92
|
+
// if it changed, our held main predates the observed predecessor.
|
|
93
|
+
if (!same(next, latest)) {
|
|
94
|
+
if (next) retire(next, batch);
|
|
95
|
+
if (previous && !same(previous, next)) retire(previous, batch);
|
|
96
|
+
else closeCursor(previous);
|
|
97
|
+
current = latest;
|
|
98
|
+
} else {
|
|
99
|
+
closeCursor(latest);
|
|
100
|
+
if (previous && !same(previous, next)) retire(previous, batch);
|
|
101
|
+
else closeCursor(previous);
|
|
102
|
+
current = next;
|
|
103
|
+
}
|
|
104
|
+
if (previous) previousSeen = previous;
|
|
105
|
+
} else if (!next) {
|
|
106
|
+
if (current) read(current, batch);
|
|
107
|
+
return batch;
|
|
108
|
+
} else if (same(current, next)) {
|
|
109
|
+
closeCursor(next);
|
|
110
|
+
} else {
|
|
111
|
+
const previous = predecessor();
|
|
112
|
+
try {
|
|
113
|
+
if (current) retire(current, batch);
|
|
114
|
+
if (previous && !same(current, previous) && !same(previousSeen, previous)) {
|
|
115
|
+
if (current) batch.lines.push('[detail gap: rotation passed the retained predecessor; some detail may have expired]');
|
|
116
|
+
previousSeen = previous; retire(previous, batch);
|
|
117
|
+
} else if (previous) closeCursor(previous);
|
|
118
|
+
} catch (error) { closeCursor(next); throw error; }
|
|
119
|
+
current = next;
|
|
120
|
+
}
|
|
121
|
+
if (current) read(current, batch);
|
|
122
|
+
return batch;
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
snapshot(count = 80) {
|
|
126
|
+
const batch = refresh();
|
|
127
|
+
const omitted = Math.max(0, batch.lines.length - count);
|
|
128
|
+
return { ...batch, omitted, pending: Boolean(current && (current.rest || current.decoder.lastNeed)), text: batch.lines.slice(-count).join('\n') };
|
|
129
|
+
},
|
|
130
|
+
poll() { const batch = refresh(); return { ...batch, text: batch.lines.length ? batch.lines.join('\n') + '\n' : '' }; },
|
|
131
|
+
close() { if (!closed && current) closeCursor(current); closed = true; current = null; },
|
|
132
|
+
};
|
|
133
|
+
}
|
package/bin/lib/mcpInventory.mjs
CHANGED
|
@@ -57,7 +57,8 @@ function bound(servers) {
|
|
|
57
57
|
const seen = new Set();
|
|
58
58
|
return servers
|
|
59
59
|
.map((s) => ({ ...s, name: s.name.trim().slice(0, MCP_INVENTORY_NAME_MAX).trim() }))
|
|
60
|
-
|
|
60
|
+
// Only the person's own `flowviant` reaches here as theirs (2026-10-19).
|
|
61
|
+
.filter((s) => s.name && (s.name !== 'flowviant' || s.source === 'personal') && !seen.has(s.name) && seen.add(s.name))
|
|
61
62
|
.sort((a, b) => SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
62
63
|
.slice(0, MCP_INVENTORY_MAX_SERVERS)
|
|
63
64
|
.map((s) => {
|
package/bin/lib/mcpReasons.mjs
CHANGED
|
@@ -18,3 +18,9 @@ export const mcpCommandNotFoundReason = (command) => `command ${command} not fou
|
|
|
18
18
|
* installed, so the app has nothing to offer for it (rule 41 checks that). */
|
|
19
19
|
export const mcpLauncherNotExecutableReason = (command) =>
|
|
20
20
|
`command ${command} is not an executable file in the base branch; commit the launcher with executable permission on base`;
|
|
21
|
+
/** A base server that signs in through a `headersHelper` (2026-10-19). The
|
|
22
|
+
* daemon's mount carries no helper — it is a command, and a repository's
|
|
23
|
+
* would need the reviewed-blob check a launcher has — so the server is left
|
|
24
|
+
* off the card and named, never mounted to fail without its headers. No
|
|
25
|
+
* checklist fact either: nothing on the box is missing. */
|
|
26
|
+
export const mcpHeadersHelperReason = 'headersHelper is not carried onto card turns; use headers with ${VAR} values in .mcp.json';
|