flowviant 0.125.7 → 0.125.12
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 +80 -1
- 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 +48 -0
- package/bin/lib/agentPlan.mjs +14 -2
- package/bin/lib/agentTaskTypes.mjs +11 -7
- package/bin/lib/artifactContracts.mjs +8 -0
- package/bin/lib/artifactIteration.mjs +18 -0
- package/bin/lib/artifactReporter.mjs +16 -3
- package/bin/lib/claudePosture.mjs +21 -0
- package/bin/lib/claudeStream.mjs +25 -4
- package/bin/lib/codexResearch.mjs +35 -5
- package/bin/lib/commandSpecs.mjs +8 -5
- 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/nativeAgentTools.mjs +20 -3
- package/bin/lib/observedLines.mjs +63 -0
- package/bin/lib/orchestrator.mjs +40 -7
- package/bin/lib/orchestratorNative.mjs +133 -3
- package/bin/lib/orchestratorPrompt.mjs +51 -4
- package/bin/lib/postureTools.mjs +14 -6
- package/bin/lib/printable.mjs +1 -1
- package/bin/lib/prompts.mjs +122 -7
- package/bin/lib/repositoryDelivery.mjs +1 -1
- 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/resultInputs.mjs +83 -0
- package/bin/lib/runTurn.mjs +76 -43
- package/bin/lib/runtimeClaude.mjs +9 -4
- package/bin/lib/runtimeCodex.mjs +51 -22
- package/bin/lib/runtimeEvents.mjs +17 -7
- package/bin/lib/testFindings.mjs +60 -0
- package/bin/lib/testFindingsContract.mjs +2 -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/uplinkScrub.mjs +49 -0
- package/bin/lib/views.mjs +49 -58
- package/bin/lib/wikiRunner.mjs +2 -2
- package/bin/lib/work.mjs +2 -2
- 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 +55 -9
- package/bin/lib/workAgentTurnOutcome.mjs +12 -3
- package/bin/lib/workAgentTurnReports.mjs +17 -3
- package/bin/lib/workAgentTurnShape.mjs +8 -5
- package/bin/lib/workAgentTurns.mjs +8 -6
- package/bin/lib/workIntake.mjs +8 -2
- package/bin/lib/workMergeBatch.mjs +7 -1
- package/bin/lib/workPlanRecovery.mjs +50 -0
- package/bin/lib/workSessionTurns.mjs +7 -2
- package/package.json +1 -1
|
@@ -10,7 +10,9 @@ 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';
|
|
15
|
+
import { copyResultInputs } from './resultInputs.mjs';
|
|
14
16
|
|
|
15
17
|
const MAX_FILES = 20_000;
|
|
16
18
|
const MAX_BYTES = 128 * 1024 * 1024;
|
|
@@ -120,7 +122,7 @@ export function researchFence({ cwd, helper, generatedImages }) {
|
|
|
120
122
|
* nothing. Artifact publication stays in the original worktree for the
|
|
121
123
|
* existing uploader.
|
|
122
124
|
*/
|
|
123
|
-
export function publishResearch(source, destination) {
|
|
125
|
+
export function publishResearch(source, destination, inputs = new Map()) {
|
|
124
126
|
if (realDirState(destination) === 'none') mkdirSync(destination, { recursive: true });
|
|
125
127
|
if (realDirState(destination) !== 'dir') throw new Error('The research artifact destination is not a real directory.');
|
|
126
128
|
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
@@ -128,7 +130,7 @@ export function publishResearch(source, destination) {
|
|
|
128
130
|
const to = join(destination, entry.name);
|
|
129
131
|
const stat = lstatSync(from);
|
|
130
132
|
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);
|
|
133
|
+
if (stat.isDirectory()) publishResearch(from, to, inputs);
|
|
132
134
|
else if (stat.isFile() && /\.md$/i.test(entry.name)) {
|
|
133
135
|
if (stat.size > 2 * 1024 * 1024) throw new Error('The research Markdown output exceeds 2 MB.');
|
|
134
136
|
let there = null;
|
|
@@ -139,6 +141,7 @@ export function publishResearch(source, destination) {
|
|
|
139
141
|
}
|
|
140
142
|
if (there && !there.isFile()) throw new Error('The research output would overwrite a non-file.');
|
|
141
143
|
const bytes = readFileSync(from);
|
|
144
|
+
if (inputs.get(from)?.equals(bytes)) continue;
|
|
142
145
|
if (there && there.size === bytes.length && readFileSync(to).equals(bytes)) continue;
|
|
143
146
|
if (there) unlinkSync(to);
|
|
144
147
|
writeFileSync(to, bytes, { flag: 'wx' });
|
|
@@ -146,8 +149,27 @@ export function publishResearch(source, destination) {
|
|
|
146
149
|
}
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
// Retained context is person/source scoped. Do not make another discussion's
|
|
153
|
+
// reports readable merely because they happen to stand in this worktree.
|
|
154
|
+
function retainResearchInputs(dir, names, relative = '') {
|
|
155
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
156
|
+
const name = relative + entry.name, path = join(dir, entry.name);
|
|
157
|
+
if (entry.isDirectory()) retainResearchInputs(path, names, name + '/');
|
|
158
|
+
else if (!names.has(name)) rmSync(path, { force: true });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function rememberResearchInputs(dir, inputs = new Map()) {
|
|
162
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
163
|
+
const path = join(dir, entry.name);
|
|
164
|
+
if (entry.isDirectory()) rememberResearchInputs(path, inputs);
|
|
165
|
+
else if (entry.isFile() && /\.md$/i.test(entry.name)) inputs.set(path, readFileSync(path));
|
|
166
|
+
}
|
|
167
|
+
return inputs;
|
|
168
|
+
}
|
|
169
|
+
|
|
149
170
|
export async function runIsolatedResearch(opts, run, {
|
|
150
171
|
resolveHelper = codexResearchHelper,
|
|
172
|
+
prepareLaunch = prepareResearchLaunch,
|
|
151
173
|
readVersion = (helper) => execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10_000 }),
|
|
152
174
|
} = {}) {
|
|
153
175
|
if (process.platform !== 'linux') throw new Error('Isolated Codex research requires Linux or WSL.');
|
|
@@ -170,12 +192,18 @@ export async function runIsolatedResearch(opts, run, {
|
|
|
170
192
|
const snapshot = join(isolation, 'repo');
|
|
171
193
|
try {
|
|
172
194
|
copyResearchView(original, snapshot);
|
|
195
|
+
copyResultInputs(original, snapshot, opts.resultInputs);
|
|
173
196
|
let knowledge = null;
|
|
174
|
-
if (opts.
|
|
197
|
+
if (Number.isInteger(opts.researchHandoff?.knowledgeRev)) {
|
|
198
|
+
// The launch loader will build this from the held current manifest.
|
|
199
|
+
// Never copy an unmeasured/background-sync mirror into its input view.
|
|
200
|
+
knowledge = join(snapshot, '.flowviant/knowledge');
|
|
201
|
+
} else if (opts.knowledgeDir) {
|
|
175
202
|
knowledge = join(snapshot, '.flowviant/knowledge');
|
|
176
203
|
copyResearchView(realpathSync(opts.knowledgeDir), knowledge);
|
|
177
204
|
}
|
|
178
205
|
for (const dir of ['artifacts', 'tmp']) mkdirSync(join(snapshot, '.flowviant', dir), { recursive: true, mode: 0o700 });
|
|
206
|
+
if (opts.researchHandoff) retainResearchInputs(join(snapshot, '.flowviant/artifacts'), new Set(opts.researchHandoff.artifacts.filter(a => !a.unavailable).map(a => a.name)));
|
|
179
207
|
const rewrite = (text) => typeof text === 'string'
|
|
180
208
|
? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
|
|
181
209
|
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 +215,15 @@ export async function runIsolatedResearch(opts, run, {
|
|
|
187
215
|
throw new Error('The native generated image directory is not a real directory.');
|
|
188
216
|
generatedImages = join(imageHome, 'generated_images');
|
|
189
217
|
}
|
|
190
|
-
const
|
|
218
|
+
const prepared = await prepareLaunch({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, adoptResumeId: null, cwd: snapshot, knowledgeDir: knowledge,
|
|
191
219
|
prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
|
|
192
220
|
researchIsolation: { cwd: snapshot, helper, generatedImages } });
|
|
221
|
+
const inputs = rememberResearchInputs(join(snapshot, '.flowviant/artifacts'));
|
|
222
|
+
const result = await run(prepared);
|
|
193
223
|
// Made, or found, real at publication too: every component checked again.
|
|
194
224
|
if (!ensureRealSubdir(original, '.flowviant/artifacts'))
|
|
195
225
|
throw new Error('The research artifact destination is not a real directory.');
|
|
196
|
-
publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'));
|
|
226
|
+
publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'), inputs);
|
|
197
227
|
return typeof result === 'string' ? result.replaceAll(snapshot, original) : result;
|
|
198
228
|
} finally { rmSync(isolation, { recursive: true, force: true }); }
|
|
199
229
|
}
|
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
|
{
|
|
@@ -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
|
+
}
|
|
@@ -29,6 +29,11 @@ export const NATIVE_AGENT_TOOLS = Object.freeze({
|
|
|
29
29
|
stop: Object.freeze(['TaskStop']),
|
|
30
30
|
ask: Object.freeze(['AskUserQuestion']),
|
|
31
31
|
discovery: Object.freeze(['ToolSearch']),
|
|
32
|
+
// A research child's file writes, refused by the gate (2026-10-19).
|
|
33
|
+
// Measured on 2.1.303: a research child inherits the parent's rules, so
|
|
34
|
+
// its Write under .flowviant/artifacts/ landed; its shell is the posture's
|
|
35
|
+
// guarded readers, which refused its `touch`.
|
|
36
|
+
write: Object.freeze(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']),
|
|
32
37
|
}),
|
|
33
38
|
codex: Object.freeze({
|
|
34
39
|
spawn: Object.freeze(['spawn_agent']),
|
|
@@ -38,6 +43,11 @@ export const NATIVE_AGENT_TOOLS = Object.freeze({
|
|
|
38
43
|
stop: Object.freeze(['interrupt_agent', 'close_agent']),
|
|
39
44
|
// Seen in a rollout, never gated.
|
|
40
45
|
message: Object.freeze(['send_message']),
|
|
46
|
+
// A research child's file write (2026-10-19). Measured on 0.163: a child
|
|
47
|
+
// inherits the research fence, whose artifacts directory it may write by
|
|
48
|
+
// apply_patch or by shell; the shell is fenced read-only by the gate
|
|
49
|
+
// instead (orchestratorNative.mjs `readOnlyShell`).
|
|
50
|
+
write: Object.freeze(['apply_patch']),
|
|
41
51
|
ask: Object.freeze(['request_user_input']),
|
|
42
52
|
discovery: Object.freeze(['tool_search', 'search_tool']),
|
|
43
53
|
}),
|
|
@@ -51,21 +61,28 @@ export const nativeName = name => String(name ?? '').split(/[./]/).at(-1).replac
|
|
|
51
61
|
|
|
52
62
|
const every = row => Object.freeze(Object.values(NATIVE_AGENT_TOOLS).flatMap(tools => tools[row] ?? []));
|
|
53
63
|
const SPAWN = every('spawn'), FOLLOWUP = every('followup'), BACKGROUND = every('background');
|
|
54
|
-
const DISCOVERY = every('discovery'), ASK = every('ask');
|
|
64
|
+
const DISCOVERY = every('discovery'), ASK = every('ask'), WRITE = every('write');
|
|
55
65
|
|
|
56
66
|
export const isSpawnTool = name => SPAWN.includes(nativeName(name));
|
|
57
67
|
export const isFollowupTool = name => FOLLOWUP.includes(nativeName(name));
|
|
58
68
|
export const isBackgroundSpawnTool = name => BACKGROUND.includes(nativeName(name));
|
|
59
69
|
export const isDiscoveryTool = name => DISCOVERY.includes(nativeName(name));
|
|
60
70
|
export const isPersonQuestionTool = name => ASK.includes(nativeName(name));
|
|
71
|
+
export const isChildWriteTool = name => WRITE.includes(nativeName(name));
|
|
72
|
+
// Both CLIs' hooks name the shell `Bash` (Codex's exec_command, measured on 0.163).
|
|
73
|
+
export const isShellTool = name => nativeName(name) === 'Bash';
|
|
61
74
|
export const isClaudeFollowupTool = name => NATIVE_AGENT_TOOLS.claude.followup.includes(nativeName(name));
|
|
62
75
|
// The controller reads only Claude's stop: TaskStop names its child as task_id.
|
|
63
76
|
export const isClaudeStopTool = name => NATIVE_AGENT_TOOLS.claude.stop.includes(nativeName(name));
|
|
64
77
|
|
|
65
78
|
/** What the hook refuses while the controller is unreachable: every spawn and
|
|
66
|
-
* follow-up the controller gates
|
|
79
|
+
* follow-up the controller gates, and on a run whose children only read
|
|
80
|
+
* (`children` 'read', a research card) a child's write or shell, which only
|
|
81
|
+
* the controller can fence read-only. */
|
|
67
82
|
export const GATED_TOOLS = Object.freeze([...SPAWN, ...FOLLOWUP]);
|
|
68
|
-
export const gatedSpawnEvent = event => event?.hook_event_name === 'PreToolUse' &&
|
|
83
|
+
export const gatedSpawnEvent = (event, children = 'edit') => event?.hook_event_name === 'PreToolUse' &&
|
|
84
|
+
(GATED_TOOLS.includes(nativeName(event.tool_name)) ||
|
|
85
|
+
(children === 'read' && Boolean(event.agent_id) && (isChildWriteTool(event.tool_name) || isShellTool(event.tool_name))));
|
|
69
86
|
|
|
70
87
|
/** Claude's PostToolUse and PostToolUseFailure matcher (orchestratorHooks.mjs):
|
|
71
88
|
* its spawns and follow-ups. */
|