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.
Files changed (65) hide show
  1. package/README.md +80 -1
  2. package/bin/cli.mjs +7 -1
  3. package/bin/lib/activityFeed.mjs +69 -0
  4. package/bin/lib/agentMergeReports.mjs +46 -0
  5. package/bin/lib/agentMergeResolution.mjs +48 -0
  6. package/bin/lib/agentPlan.mjs +14 -2
  7. package/bin/lib/agentTaskTypes.mjs +11 -7
  8. package/bin/lib/artifactContracts.mjs +8 -0
  9. package/bin/lib/artifactIteration.mjs +18 -0
  10. package/bin/lib/artifactReporter.mjs +16 -3
  11. package/bin/lib/claudePosture.mjs +21 -0
  12. package/bin/lib/claudeStream.mjs +25 -4
  13. package/bin/lib/codexResearch.mjs +35 -5
  14. package/bin/lib/commandSpecs.mjs +8 -5
  15. package/bin/lib/daemonJournal.mjs +102 -0
  16. package/bin/lib/daemonLogging.mjs +22 -18
  17. package/bin/lib/fleet.mjs +16 -6
  18. package/bin/lib/fleetReports.mjs +12 -5
  19. package/bin/lib/hooks/orchestrator.mjs +3 -1
  20. package/bin/lib/logReplay.mjs +133 -0
  21. package/bin/lib/nativeAgentTools.mjs +20 -3
  22. package/bin/lib/observedLines.mjs +63 -0
  23. package/bin/lib/orchestrator.mjs +40 -7
  24. package/bin/lib/orchestratorNative.mjs +133 -3
  25. package/bin/lib/orchestratorPrompt.mjs +51 -4
  26. package/bin/lib/postureTools.mjs +14 -6
  27. package/bin/lib/printable.mjs +1 -1
  28. package/bin/lib/prompts.mjs +122 -7
  29. package/bin/lib/repositoryDelivery.mjs +1 -1
  30. package/bin/lib/repositoryMerge.mjs +1 -1
  31. package/bin/lib/researchHandoff.mjs +144 -0
  32. package/bin/lib/researchHandoffText.mjs +3 -0
  33. package/bin/lib/researchKnowledge.mjs +60 -0
  34. package/bin/lib/researchRestoration.mjs +38 -0
  35. package/bin/lib/resultInputs.mjs +83 -0
  36. package/bin/lib/runTurn.mjs +76 -43
  37. package/bin/lib/runtimeClaude.mjs +9 -4
  38. package/bin/lib/runtimeCodex.mjs +51 -22
  39. package/bin/lib/runtimeEvents.mjs +17 -7
  40. package/bin/lib/testFindings.mjs +60 -0
  41. package/bin/lib/testFindingsContract.mjs +2 -0
  42. package/bin/lib/turnDiagnostics.mjs +2 -1
  43. package/bin/lib/turnOutput.mjs +63 -0
  44. package/bin/lib/turnRecord.mjs +11 -0
  45. package/bin/lib/turnSubagents.mjs +13 -3
  46. package/bin/lib/ui.mjs +2 -2
  47. package/bin/lib/uplinkScrub.mjs +49 -0
  48. package/bin/lib/views.mjs +49 -58
  49. package/bin/lib/wikiRunner.mjs +2 -2
  50. package/bin/lib/work.mjs +2 -2
  51. package/bin/lib/workActivity.mjs +65 -0
  52. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  53. package/bin/lib/workAgentMerges.mjs +57 -52
  54. package/bin/lib/workAgentPlans.mjs +76 -27
  55. package/bin/lib/workAgentPrecheck.mjs +1 -1
  56. package/bin/lib/workAgentTurnExecution.mjs +55 -9
  57. package/bin/lib/workAgentTurnOutcome.mjs +12 -3
  58. package/bin/lib/workAgentTurnReports.mjs +17 -3
  59. package/bin/lib/workAgentTurnShape.mjs +8 -5
  60. package/bin/lib/workAgentTurns.mjs +8 -6
  61. package/bin/lib/workIntake.mjs +8 -2
  62. package/bin/lib/workMergeBatch.mjs +7 -1
  63. package/bin/lib/workPlanRecovery.mjs +50 -0
  64. package/bin/lib/workSessionTurns.mjs +7 -2
  65. 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.knowledgeDir) {
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 result = await run({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, cwd: snapshot, knowledgeDir: knowledge,
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
  }
@@ -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 as they are written', '-n <lines> how many lines to show first (default 80)'],
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 { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
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 const MAX_DAEMON_LOG_BYTES = 2 * 1024 * 1024;
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
- try {
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 { writeDaemonState } from './daemonState.mjs';
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 "still alive" idle heartbeat
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
- // Heartbeat so a quiet daemon visibly stays alive. Gated on REAL work —
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.'
@@ -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
- void postToFleet(
387
- MACHINE_URL,
388
- machineSnapshot({
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
- if (gatedSpawnEvent(event)) { process.exitCode = 2; return; }
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' && GATED_TOOLS.includes(nativeName(event.tool_name));
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. */