flowviant 0.125.7 → 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 +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 +44 -0
- package/bin/lib/agentPlan.mjs +6 -2
- package/bin/lib/claudePosture.mjs +21 -0
- package/bin/lib/claudeStream.mjs +25 -4
- package/bin/lib/codexResearch.mjs +33 -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 +50 -4
- package/bin/lib/postureTools.mjs +14 -6
- package/bin/lib/printable.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/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/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 +42 -8
- package/bin/lib/workAgentTurnReports.mjs +17 -3
- package/bin/lib/workAgentTurnShape.mjs +6 -4
- 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 +5 -2
- package/package.json +1 -1
|
@@ -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. */
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Complete pipe lines, with the time their first chunk was observed.
|
|
2
|
+
* Pending text stays below 64 Ki characters in memory. Large protocol/error
|
|
3
|
+
* lines spill to an anonymous private file, so neither a long JSON event nor
|
|
4
|
+
* an unterminated stderr line needs truncation or invented line breaks.
|
|
5
|
+
* The file is unlinked immediately: close, crash and kill leave no log behind.
|
|
6
|
+
*/
|
|
7
|
+
import { closeSync, mkdtempSync, openSync, readFileSync, rmdirSync, unlinkSync, writeSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const PARTIAL_LINE_CHARS = 64 * 1024;
|
|
12
|
+
|
|
13
|
+
export function createObservedLines(onLine, { now = Date.now } = {}) {
|
|
14
|
+
let parts = [], size = 0, observedAt = null, fd = null, offset = 0;
|
|
15
|
+
const clear = () => {
|
|
16
|
+
if (fd !== null) closeSync(fd);
|
|
17
|
+
parts = []; size = 0; observedAt = null; fd = null; offset = 0;
|
|
18
|
+
};
|
|
19
|
+
const write = (text) => {
|
|
20
|
+
const bytes = Buffer.from(text);
|
|
21
|
+
let written = 0;
|
|
22
|
+
while (written < bytes.length)
|
|
23
|
+
written += writeSync(fd, bytes, written, bytes.length - written, offset + written);
|
|
24
|
+
offset += written;
|
|
25
|
+
};
|
|
26
|
+
const append = (text) => {
|
|
27
|
+
if (!text) return;
|
|
28
|
+
if (observedAt === null) observedAt = now();
|
|
29
|
+
if (fd === null && size + text.length > PARTIAL_LINE_CHARS) {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), 'flowviant-line-'));
|
|
31
|
+
const path = join(dir, 'partial');
|
|
32
|
+
try { fd = openSync(path, 'wx+', 0o600); }
|
|
33
|
+
finally {
|
|
34
|
+
try { unlinkSync(path); } finally { rmdirSync(dir); }
|
|
35
|
+
}
|
|
36
|
+
for (const part of parts) write(part);
|
|
37
|
+
parts = [];
|
|
38
|
+
}
|
|
39
|
+
if (fd === null) parts.push(text);
|
|
40
|
+
else write(text);
|
|
41
|
+
size += text.length;
|
|
42
|
+
};
|
|
43
|
+
const flush = () => {
|
|
44
|
+
const at = observedAt ?? now();
|
|
45
|
+
try {
|
|
46
|
+
// Positioned writes leave the descriptor's read position at zero.
|
|
47
|
+
onLine(fd === null ? parts.join('') : readFileSync(fd, 'utf8'), at);
|
|
48
|
+
} finally { clear(); }
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
push(text) {
|
|
52
|
+
let start = 0, nl;
|
|
53
|
+
while ((nl = text.indexOf('\n', start)) >= 0) {
|
|
54
|
+
append(text.slice(start, nl));
|
|
55
|
+
flush();
|
|
56
|
+
start = nl + 1;
|
|
57
|
+
}
|
|
58
|
+
append(text.slice(start));
|
|
59
|
+
},
|
|
60
|
+
end() { if (size) flush(); },
|
|
61
|
+
discard: clear,
|
|
62
|
+
};
|
|
63
|
+
}
|
package/bin/lib/orchestrator.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** A run-scoped loopback capability. Only this daemon holds fleet credentials;
|
|
2
2
|
* native tools and MCP clients receive a random capability for this one run. */
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
4
5
|
import { fleetRequest } from './fleetPost.mjs';
|
|
5
6
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
6
7
|
import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
|
|
@@ -10,11 +11,14 @@ import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs'
|
|
|
10
11
|
import { OrchestratorInputTooLarge, readOrchestratorInput } from './orchestratorInput.mjs';
|
|
11
12
|
import { SAFE_POINT_AFTER_EDIT as AFTER_EDIT } from './orchestratorProtocol.mjs';
|
|
12
13
|
import { MACHINE_MESSAGE_ROUTE_STATES, MESSAGE_ROUTE_FIELD_MAX, OPEN_MESSAGE_ROUTE_STATES, ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
|
|
13
|
-
import { isClaudeStopTool, isDiscoveryTool, isFollowupTool, isPersonQuestionTool, isSpawnTool } from './nativeAgentTools.mjs';
|
|
14
|
-
import { followupTarget, readSpawn, spawnReceipt, withChildContract } from './orchestratorNative.mjs';
|
|
14
|
+
import { isChildWriteTool, isClaudeStopTool, isDiscoveryTool, isFollowupTool, isPersonQuestionTool, isShellTool, isSpawnTool } from './nativeAgentTools.mjs';
|
|
15
|
+
import { childBrain, childChoice, followupTarget, readOnlyShell, readSpawn, spawnReceipt, withChildContract } from './orchestratorNative.mjs';
|
|
15
16
|
import { SETTLED_SUBAGENT_STATUSES, SUBAGENT_CAP, SUBAGENT_FIELD_CAPS } from './subagentBounds.mjs';
|
|
16
17
|
|
|
17
18
|
const WAIT = 'Waiting for a free slot on the machine';
|
|
19
|
+
// A research card's children read inside its fence; the orchestrator alone writes and delivers.
|
|
20
|
+
const READ_ONLY_CHILD = ' This is a research card: you only read. Write no file, edit nothing and commit nothing; return your findings, their sources and what you could not verify.';
|
|
21
|
+
const READ_ONLY_REFUSAL = 'This research card’s children only read: no file writes. Return your findings to the orchestrator, which alone writes and delivers the card.';
|
|
18
22
|
const HELD = 'At the checkpoint for a message';
|
|
19
23
|
const terminal = status => SETTLED_SUBAGENT_STATUSES.includes(status);
|
|
20
24
|
const filled = s => typeof s === 'string' && s.trim().length > 0;
|
|
@@ -22,8 +26,23 @@ const filled = s => typeof s === 'string' && s.trim().length > 0;
|
|
|
22
26
|
const pointOf = s => String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ').replace(/^["'“‘`]+|["'”’`.!?;:,…]+$/g, '');
|
|
23
27
|
const text = (s, n) => scrubNativeText(s, n, scrub);
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* `children` is what this run's children may do and run at (2026-10-19):
|
|
31
|
+
* `access` 'edit' on a code card, 'read' on a research card (the gate refuses
|
|
32
|
+
* a child's write, and the hook does too while this controller is down), and
|
|
33
|
+
* the card's `model` and `effort` as the parent runs them, which a spawn that
|
|
34
|
+
* names neither inherits explicitly (orchestratorNative.mjs `childBrain`);
|
|
35
|
+
* on Codex, only for a model in `catalog`, the measured spawnable models.
|
|
36
|
+
*/
|
|
25
37
|
export async function createOrchestrator({ turnId, runtime, trace, admit, request = fleetRequest, notify = () => false, taken = () => {},
|
|
26
|
-
interval = 500, hold = 60_000, clock = Date.now, scrubText = text } = {}) {
|
|
38
|
+
interval = 500, hold = 60_000, clock = Date.now, scrubText = text, children = {} } = {}) {
|
|
39
|
+
const access = children.access === 'read' ? 'read' : 'edit',
|
|
40
|
+
card = { model: children.model ?? null, effort: children.effort ?? null, catalog: Array.isArray(children.catalog) ? children.catalog : [] };
|
|
41
|
+
// A Codex research child's shell runs in a read-only bubblewrap view
|
|
42
|
+
// (orchestratorNative.mjs `readOnlyShell`); without one it is refused.
|
|
43
|
+
// Claude's research shell is the posture's guarded readers already.
|
|
44
|
+
const bwrap = runtime === 'codex' && access === 'read'
|
|
45
|
+
? (children.bwrap !== undefined ? children.bwrap : ['/usr/bin/bwrap', '/bin/bwrap'].find(path => existsSync(path)) ?? null) : null;
|
|
27
46
|
if (typeof admit !== 'function' || typeof admit.reserve !== 'function') throw new Error('This run has no project admission pool.');
|
|
28
47
|
// Minted before the server exists, so the scrubber knows it from the start.
|
|
29
48
|
const capability = mintHeldCapability();
|
|
@@ -111,6 +130,9 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
111
130
|
const spawn = readSpawn(event);
|
|
112
131
|
if (!spawn) return { deny: 'Give this native spawn a text prompt or message so the run can gate it.' };
|
|
113
132
|
const { callId, brief } = spawn;
|
|
133
|
+
// Above the card's model or effort is refused before any record or slot.
|
|
134
|
+
const chosen = childBrain(event, spawn, card);
|
|
135
|
+
if (chosen.deny) return { deny: chosen.deny };
|
|
114
136
|
const resumed = spawn.resume !== null && records.has(resolve(spawn.resume)) ? resolve(spawn.resume) : null;
|
|
115
137
|
const id = resumed ?? claim(spawn)?.id ?? resolve(callId);
|
|
116
138
|
aliases.set(callId, id); calls.set(callId, id);
|
|
@@ -122,9 +144,13 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
122
144
|
// Claude's SendMessage addresses a child by its Agent `name`, Codex V2 by its task name.
|
|
123
145
|
for (const name of spawn.names) if (filled(name) && name.length <= 160) aliases.set(name, id);
|
|
124
146
|
start(a, a.title);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
147
|
+
// A cheaper model or lower effort than the card's is relayed on the child's
|
|
148
|
+
// stream, and the parent is asked to say why in its own update.
|
|
149
|
+
const choice = childChoice(event, spawn, card);
|
|
150
|
+
if (choice) trace.prose('note', `Runs on ${choice}.`, id);
|
|
151
|
+
const childContract = `\n\nCard orchestrator agent record id: ${id}. FIRST call card_orchestrator.register_agent with agentId=${id} before doing work (tool discovery is allowed). Share this worktree and preserve others’ edits. Ask questions through card_orchestrator.agent_question with agentId=${id}; read its answer before proceeding. Only your orchestrator escalates person-only decisions. When you are done, end with your work or findings as your final message: that is how they reach your orchestrator, which owns the card’s final commit and JSON. agent_question is only for a question you cannot settle yourself, never for handing back results.`;
|
|
152
|
+
return { context: `Agent record id: ${id}. Use it with card_orchestrator for routing and questions.${spawn.relocated ? ' Its isolation and cwd were removed: children share this card’s worktree on this machine.' : ''}${choice ? ` This child runs on ${choice}: name that choice and why in your next update.` : ''}`,
|
|
153
|
+
updatedInput: withChildContract(event, spawn, childContract + (access === 'read' ? READ_ONLY_CHILD : ''), chosen.brain) };
|
|
128
154
|
}
|
|
129
155
|
// Native readings never revive a settled record or start a waiting spawn
|
|
130
156
|
// or follow-up (Codex records a refused call's start anyway); only an
|
|
@@ -245,9 +271,14 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
245
271
|
}
|
|
246
272
|
if (child && !records.has(resolve(child)) && !isDiscoveryTool(ev.tool_name))
|
|
247
273
|
return { deny: 'Before working, call card_orchestrator.register_agent with the agent record id in your prompt. Tool discovery is allowed.' };
|
|
274
|
+
if (child && access === 'read' && isChildWriteTool(ev.tool_name)) return { deny: READ_ONLY_REFUSAL };
|
|
248
275
|
if (isSpawnTool(ev.tool_name)) return preSpawn(ev);
|
|
249
276
|
if (isFollowupTool(ev.tool_name)) { const resumed = preResume(ev); if (resumed) return resumed; }
|
|
250
277
|
const point = checkpoint(ev); if (point.deny) return point;
|
|
278
|
+
if (child && access === 'read' && runtime === 'codex' && isShellTool(ev.tool_name)) {
|
|
279
|
+
const fenced = readOnlyShell(ev, bwrap);
|
|
280
|
+
return fenced ? { updatedInput: fenced } : { deny: `${READ_ONLY_REFUSAL} ${bwrap ? 'Its shell could not be fenced read-only: give the command as text.' : 'This machine has no bubblewrap to keep a child’s shell read-only, so a child here searches the web; the orchestrator reads files.'}` };
|
|
281
|
+
}
|
|
251
282
|
if (isPersonQuestionTool(ev.tool_name))
|
|
252
283
|
return { deny: 'A child asks its orchestrator with agent_question first. The orchestrator escalates only person-only decisions through the existing blocked JSON question card.' };
|
|
253
284
|
}
|
|
@@ -319,8 +350,10 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
319
350
|
error: text(error.message, 300), ...(error instanceof OrchestratorInputTooLarge ? { code: error.code } : {}),
|
|
320
351
|
})); }
|
|
321
352
|
};
|
|
322
|
-
const { server, env, args: argv } = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
|
|
353
|
+
const { server, env: heldEnv, args: argv } = await listenHeldTool({ envPrefix: 'FLOWVIANT_ORCHESTRATOR', verb: '__orchestrator-mcp', requestTimeoutMs: 5_000,
|
|
323
354
|
capability, isClosed: () => closed, respond });
|
|
355
|
+
// The hook reads it to refuse a research child's write while this is down.
|
|
356
|
+
const env = { ...heldEnv, FLOWVIANT_ORCHESTRATOR_CHILDREN: access };
|
|
324
357
|
const timer = setInterval(() => {
|
|
325
358
|
if (closed || polling) return;
|
|
326
359
|
polling = true;
|