flowviant 0.125.6 → 0.125.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +84 -6
  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 +44 -0
  6. package/bin/lib/agentPlan.mjs +6 -2
  7. package/bin/lib/claudeInput.mjs +61 -1
  8. package/bin/lib/claudePosture.mjs +21 -0
  9. package/bin/lib/claudeStream.mjs +54 -4
  10. package/bin/lib/codexContract.mjs +52 -0
  11. package/bin/lib/codexResearch.mjs +33 -5
  12. package/bin/lib/commandSpecs.mjs +8 -5
  13. package/bin/lib/config.mjs +22 -1
  14. package/bin/lib/daemonJournal.mjs +102 -0
  15. package/bin/lib/daemonLogging.mjs +22 -18
  16. package/bin/lib/fleet.mjs +16 -6
  17. package/bin/lib/fleetReports.mjs +12 -5
  18. package/bin/lib/hooks/orchestrator.mjs +3 -1
  19. package/bin/lib/logReplay.mjs +133 -0
  20. package/bin/lib/mcpInventory.mjs +2 -1
  21. package/bin/lib/mcpReasons.mjs +6 -0
  22. package/bin/lib/nativeAgentTools.mjs +20 -3
  23. package/bin/lib/observedLines.mjs +63 -0
  24. package/bin/lib/orchestrator.mjs +49 -7
  25. package/bin/lib/orchestratorNative.mjs +133 -3
  26. package/bin/lib/orchestratorPrompt.mjs +50 -4
  27. package/bin/lib/postureTools.mjs +14 -6
  28. package/bin/lib/printable.mjs +1 -1
  29. package/bin/lib/processGroup.mjs +41 -0
  30. package/bin/lib/projectTools.mjs +3 -2
  31. package/bin/lib/prompts.mjs +47 -18
  32. package/bin/lib/reportJournal.mjs +61 -0
  33. package/bin/lib/repositoryMerge.mjs +1 -1
  34. package/bin/lib/researchHandoff.mjs +144 -0
  35. package/bin/lib/researchHandoffText.mjs +3 -0
  36. package/bin/lib/researchKnowledge.mjs +60 -0
  37. package/bin/lib/researchRestoration.mjs +38 -0
  38. package/bin/lib/resumeLost.mjs +21 -1
  39. package/bin/lib/runTurn.mjs +98 -44
  40. package/bin/lib/runtimeCapabilities.mjs +7 -2
  41. package/bin/lib/runtimeClaude.mjs +30 -8
  42. package/bin/lib/runtimeCodex.mjs +54 -22
  43. package/bin/lib/runtimeEvents.mjs +17 -7
  44. package/bin/lib/runtimeOfferings.mjs +3 -1
  45. package/bin/lib/startProject.mjs +10 -0
  46. package/bin/lib/turnDiagnostics.mjs +2 -1
  47. package/bin/lib/turnOutput.mjs +63 -0
  48. package/bin/lib/turnRecord.mjs +11 -0
  49. package/bin/lib/turnSubagents.mjs +13 -3
  50. package/bin/lib/ui.mjs +2 -2
  51. package/bin/lib/update.mjs +18 -4
  52. package/bin/lib/uplinkScrub.mjs +49 -0
  53. package/bin/lib/views.mjs +49 -58
  54. package/bin/lib/wikiRunner.mjs +2 -2
  55. package/bin/lib/work.mjs +5 -5
  56. package/bin/lib/workActivity.mjs +65 -0
  57. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  58. package/bin/lib/workAgentMerges.mjs +57 -52
  59. package/bin/lib/workAgentPlans.mjs +76 -27
  60. package/bin/lib/workAgentPrecheck.mjs +1 -1
  61. package/bin/lib/workAgentTurnExecution.mjs +62 -8
  62. package/bin/lib/workAgentTurnReports.mjs +33 -3
  63. package/bin/lib/workAgentTurnShape.mjs +6 -4
  64. package/bin/lib/workAgentTurns.mjs +12 -8
  65. package/bin/lib/workAttachments.mjs +27 -5
  66. package/bin/lib/workIntake.mjs +8 -2
  67. package/bin/lib/workMergeBatch.mjs +7 -1
  68. package/bin/lib/workNarration.mjs +32 -0
  69. package/bin/lib/workPlanRecovery.mjs +50 -0
  70. package/bin/lib/workReportQueue.mjs +18 -1
  71. package/bin/lib/workSessionTurns.mjs +64 -22
  72. package/bin/lib/worktreeDiff.mjs +29 -5
  73. package/package.json +1 -1
@@ -10,6 +10,7 @@ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
10
10
  import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
11
11
  import { dirname, isAbsolute, join, resolve } from 'node:path';
12
12
  import { createRequire } from 'node:module';
13
+ import { prepareResearchLaunch } from './researchHandoff.mjs';
13
14
  import { versionAtLeast } from './versionOrder.mjs';
14
15
 
15
16
  const MAX_FILES = 20_000;
@@ -120,7 +121,7 @@ export function researchFence({ cwd, helper, generatedImages }) {
120
121
  * nothing. Artifact publication stays in the original worktree for the
121
122
  * existing uploader.
122
123
  */
123
- export function publishResearch(source, destination) {
124
+ export function publishResearch(source, destination, inputs = new Map()) {
124
125
  if (realDirState(destination) === 'none') mkdirSync(destination, { recursive: true });
125
126
  if (realDirState(destination) !== 'dir') throw new Error('The research artifact destination is not a real directory.');
126
127
  for (const entry of readdirSync(source, { withFileTypes: true })) {
@@ -128,7 +129,7 @@ export function publishResearch(source, destination) {
128
129
  const to = join(destination, entry.name);
129
130
  const stat = lstatSync(from);
130
131
  if (stat.isSymbolicLink()) throw new Error('An isolated research output is a symlink; nothing was accepted through it.');
131
- if (stat.isDirectory()) publishResearch(from, to);
132
+ if (stat.isDirectory()) publishResearch(from, to, inputs);
132
133
  else if (stat.isFile() && /\.md$/i.test(entry.name)) {
133
134
  if (stat.size > 2 * 1024 * 1024) throw new Error('The research Markdown output exceeds 2 MB.');
134
135
  let there = null;
@@ -139,6 +140,7 @@ export function publishResearch(source, destination) {
139
140
  }
140
141
  if (there && !there.isFile()) throw new Error('The research output would overwrite a non-file.');
141
142
  const bytes = readFileSync(from);
143
+ if (inputs.get(from)?.equals(bytes)) continue;
142
144
  if (there && there.size === bytes.length && readFileSync(to).equals(bytes)) continue;
143
145
  if (there) unlinkSync(to);
144
146
  writeFileSync(to, bytes, { flag: 'wx' });
@@ -146,8 +148,27 @@ export function publishResearch(source, destination) {
146
148
  }
147
149
  }
148
150
 
151
+ // Retained context is person/source scoped. Do not make another discussion's
152
+ // reports readable merely because they happen to stand in this worktree.
153
+ function retainResearchInputs(dir, names, relative = '') {
154
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
155
+ const name = relative + entry.name, path = join(dir, entry.name);
156
+ if (entry.isDirectory()) retainResearchInputs(path, names, name + '/');
157
+ else if (!names.has(name)) rmSync(path, { force: true });
158
+ }
159
+ }
160
+ function rememberResearchInputs(dir, inputs = new Map()) {
161
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
162
+ const path = join(dir, entry.name);
163
+ if (entry.isDirectory()) rememberResearchInputs(path, inputs);
164
+ else if (entry.isFile() && /\.md$/i.test(entry.name)) inputs.set(path, readFileSync(path));
165
+ }
166
+ return inputs;
167
+ }
168
+
149
169
  export async function runIsolatedResearch(opts, run, {
150
170
  resolveHelper = codexResearchHelper,
171
+ prepareLaunch = prepareResearchLaunch,
151
172
  readVersion = (helper) => execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10_000 }),
152
173
  } = {}) {
153
174
  if (process.platform !== 'linux') throw new Error('Isolated Codex research requires Linux or WSL.');
@@ -171,11 +192,16 @@ export async function runIsolatedResearch(opts, run, {
171
192
  try {
172
193
  copyResearchView(original, snapshot);
173
194
  let knowledge = null;
174
- if (opts.knowledgeDir) {
195
+ if (Number.isInteger(opts.researchHandoff?.knowledgeRev)) {
196
+ // The launch loader will build this from the held current manifest.
197
+ // Never copy an unmeasured/background-sync mirror into its input view.
198
+ knowledge = join(snapshot, '.flowviant/knowledge');
199
+ } else if (opts.knowledgeDir) {
175
200
  knowledge = join(snapshot, '.flowviant/knowledge');
176
201
  copyResearchView(realpathSync(opts.knowledgeDir), knowledge);
177
202
  }
178
203
  for (const dir of ['artifacts', 'tmp']) mkdirSync(join(snapshot, '.flowviant', dir), { recursive: true, mode: 0o700 });
204
+ if (opts.researchHandoff) retainResearchInputs(join(snapshot, '.flowviant/artifacts'), new Set(opts.researchHandoff.artifacts.filter(a => !a.unavailable).map(a => a.name)));
179
205
  const rewrite = (text) => typeof text === 'string'
180
206
  ? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
181
207
  const intro = 'This fresh research turn uses an isolated read-only copy. Prior reports are available under .flowviant/artifacts/; previous CLI conversations are not resumed because they may contain host secrets. Git metadata, .env files, credential files, symlinks and personal CLI settings are omitted. Write the Markdown report only under .flowviant/artifacts/. Web search is available; shell networking is disabled. Use exec_command with shell=/bin/bash and login=false; personal shells are outside the fence.\n\n';
@@ -187,13 +213,15 @@ export async function runIsolatedResearch(opts, run, {
187
213
  throw new Error('The native generated image directory is not a real directory.');
188
214
  generatedImages = join(imageHome, 'generated_images');
189
215
  }
190
- const result = await run({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, cwd: snapshot, knowledgeDir: knowledge,
216
+ const prepared = await prepareLaunch({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, adoptResumeId: null, cwd: snapshot, knowledgeDir: knowledge,
191
217
  prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
192
218
  researchIsolation: { cwd: snapshot, helper, generatedImages } });
219
+ const inputs = rememberResearchInputs(join(snapshot, '.flowviant/artifacts'));
220
+ const result = await run(prepared);
193
221
  // Made, or found, real at publication too: every component checked again.
194
222
  if (!ensureRealSubdir(original, '.flowviant/artifacts'))
195
223
  throw new Error('The research artifact destination is not a real directory.');
196
- publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'));
224
+ publishResearch(join(snapshot, '.flowviant/artifacts'), join(original, '.flowviant/artifacts'), inputs);
197
225
  return typeof result === 'string' ? result.replaceAll(snapshot, original) : result;
198
226
  } finally { rmSync(isolation, { recursive: true, force: true }); }
199
227
  }
@@ -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
  {
@@ -226,7 +226,28 @@ export const USER_AGENT = `flowviant/${VERSION}`;
226
226
  * back. Sent on the poll as `di`; its ABSENCE is what an older daemon looks
227
227
  * like, and the server hands preview work to nobody who cannot name themselves.
228
228
  */
229
- export const DAEMON_INSTANCE = randomBytes(12).toString('hex');
229
+ export const DAEMON_INSTANCE = inheritedInstance() ?? randomBytes(12).toString('hex');
230
+
231
+ /**
232
+ * …EXCEPT ACROSS ITS OWN SELF-UPDATE (2026-10-19). A self-update re-execs
233
+ * only when the machine is idle (update.mjs), so the process it replaces
234
+ * holds nothing in flight — only its tabs' session leases, which no other
235
+ * daemon may take for 45 seconds. A successor with a new id waited out every
236
+ * one of them, so each unattended update left every tab unanswered for most
237
+ * of a minute. The successor named by `FLOWVIANT_REEXEC` takes the id its
238
+ * predecessor handed it; any other start still regenerates it.
239
+ */
240
+ function inheritedInstance() {
241
+ const handed = process.env.FLOWVIANT_DAEMON_INSTANCE;
242
+ // TAKEN ONCE, THEN GONE FROM THIS PROCESS'S ENV. Every CLI turn spawns with
243
+ // `{ ...process.env }`, so a variable left here reached any `flowviant`
244
+ // started inside one — an agent running the daemon from a worktree — and
245
+ // two live processes would answer to one instance (the trap instance.mjs
246
+ // names for FLOWVIANT_REEXEC_FROM). `reexec` sets it afresh for its own
247
+ // successor, so the next update still hands it on.
248
+ delete process.env.FLOWVIANT_DAEMON_INSTANCE;
249
+ return process.env.FLOWVIANT_REEXEC === '1' && typeof handed === 'string' && /^[0-9a-f]{24}$/.test(handed) ? handed : null;
250
+ }
230
251
 
231
252
  /**
232
253
  * WHICH BOX, in words a person recognises — and nothing else.
@@ -0,0 +1,102 @@
1
+ /** Bounded local diagnostic journal. Native records share the existing project
2
+ * log with legacy console lines. Each native record occupies one JSON line;
3
+ * replay renders its text, rather than presenting shortened labels as history.
4
+ */
5
+ import { appendFileSync, closeSync, constants, fchmodSync, fstatSync, mkdirSync, openSync, renameSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
7
+ import { StringDecoder } from 'node:string_decoder';
8
+ import { printableLine, printableText } from './printable.mjs';
9
+ import { scrub } from './uplinkScrub.mjs';
10
+
11
+ export const MAX_DAEMON_LOG_BYTES = 2 * 1024 * 1024;
12
+ export const MAX_DETAIL_RECORD_BYTES = 64 * 1024;
13
+ export const DETAIL_PREFIX = '@flowviant-detail ';
14
+ export const DETAIL_TRUNCATED = '\n[truncated: local detail record exceeded 64 KiB]';
15
+ export const DETAIL_GAP = '[detail gap: local log writes failed; some detail was not retained]';
16
+
17
+ export const utf8Prefix = (text, bytes) => new StringDecoder('utf8').write((Buffer.isBuffer(text) ? text : Buffer.from(text)).subarray(0, Math.max(0, bytes)));
18
+ const safe = text => printableText(scrub(printableText(scrub(String(text ?? '')))));
19
+
20
+ export function encodeDetailRecord(record) {
21
+ const row = { v: 1, observedAt: record.observedAt, kind: printableLine(safe(record.kind), 120), name: printableLine(safe(record.name), 120),
22
+ workId: safe(record.workId), turnId: safe(record.turnId), source: safe(record.source),
23
+ ...(record.agentId ? { agentId: safe(record.agentId) } : {}), text: safe(record.text) };
24
+ const encode = () => DETAIL_PREFIX + JSON.stringify(row) + '\n';
25
+ const encoded = row.text.length <= MAX_DETAIL_RECORD_BYTES ? encode() : null;
26
+ if (encoded && Buffer.byteLength(encoded) <= MAX_DETAIL_RECORD_BYTES) return encoded;
27
+ const full = Buffer.from(row.text);
28
+ row.truncated = true;
29
+ row.text = DETAIL_TRUNCATED;
30
+ if (Buffer.byteLength(encode()) > MAX_DETAIL_RECORD_BYTES) throw new Error('Detail attribution exceeds the record limit');
31
+ let low = 0, high = Math.min(full.length, MAX_DETAIL_RECORD_BYTES);
32
+ while (low < high) {
33
+ const mid = Math.ceil((low + high) / 2);
34
+ row.text = utf8Prefix(full, mid) + DETAIL_TRUNCATED;
35
+ if (Buffer.byteLength(encode()) <= MAX_DETAIL_RECORD_BYTES) low = mid;
36
+ else high = mid - 1;
37
+ }
38
+ row.text = utf8Prefix(full, low) + DETAIL_TRUNCATED;
39
+ return encode();
40
+ }
41
+
42
+ export function decodeDetailRecord(line) {
43
+ if (!line.startsWith(DETAIL_PREFIX)) return null;
44
+ if (Buffer.byteLength(line) > MAX_DETAIL_RECORD_BYTES) return false;
45
+ try {
46
+ const row = JSON.parse(line.slice(DETAIL_PREFIX.length));
47
+ if (row?.v !== 1 || !Number.isFinite(row.observedAt) ||
48
+ !['kind', 'name', 'workId', 'turnId', 'source', 'text'].every(k => typeof row[k] === 'string') ||
49
+ row.agentId !== undefined && typeof row.agentId !== 'string') return false;
50
+ // Older/local files are untrusted at replay too. Scrub text before it is
51
+ // expanded into terminal lines; never revive escapes from a JSON string.
52
+ return { ...row, kind: printableLine(safe(row.kind), 120), name: printableLine(safe(row.name), 120),
53
+ workId: printableLine(safe(row.workId), Infinity), turnId: printableLine(safe(row.turnId), Infinity),
54
+ ...(row.agentId ? { agentId: printableLine(safe(row.agentId), Infinity) } : {}), text: safe(row.text) };
55
+ } catch { return false; }
56
+ }
57
+
58
+ export function createDaemonJournal(path, { onFailure = () => {} } = {}) {
59
+ let missed = false;
60
+ const append = (raw) => {
61
+ let fd = null;
62
+ try {
63
+ let chunk = Buffer.from(raw);
64
+ const tail = '\n[truncated]\n';
65
+ if (chunk.length > MAX_DAEMON_LOG_BYTES)
66
+ chunk = Buffer.from(utf8Prefix(raw, MAX_DAEMON_LOG_BYTES - Buffer.byteLength(tail)) + tail);
67
+ const gap = missed ? Buffer.from('\n' + DETAIL_GAP + '\n') : Buffer.alloc(0);
68
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
69
+ const open = () => openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
70
+ fd = open();
71
+ fchmodSync(fd, 0o600);
72
+ if (!fstatSync(fd).isFile()) throw new Error('The local log is not a regular file');
73
+ if (fstatSync(fd).size + gap.length + chunk.length > MAX_DAEMON_LOG_BYTES) {
74
+ closeSync(fd); fd = null;
75
+ renameSync(path, path + '.1');
76
+ fd = open();
77
+ fchmodSync(fd, 0o600);
78
+ }
79
+ // A gap is a separate bounded entry, so even a file-sized console line
80
+ // cannot make a recovered log exceed its cap.
81
+ if (gap.length && gap.length + chunk.length <= MAX_DAEMON_LOG_BYTES) appendFileSync(fd, gap);
82
+ else if (gap.length) {
83
+ appendFileSync(fd, gap); closeSync(fd); fd = null;
84
+ renameSync(path, path + '.1'); fd = open(); fchmodSync(fd, 0o600);
85
+ }
86
+ appendFileSync(fd, chunk);
87
+ missed = false;
88
+ return true;
89
+ } catch (error) {
90
+ if (!missed) try { onFailure(error); } catch { /* diagnostic only */ }
91
+ missed = true;
92
+ return false;
93
+ } finally { if (fd !== null) try { closeSync(fd); } catch { /* a log close never stops work */ } }
94
+ };
95
+ return {
96
+ console: text => append(safe(text) + '\n'),
97
+ record(record) {
98
+ try { return append(encodeDetailRecord(record)); }
99
+ catch (error) { if (!missed) try { onFailure(error); } catch { /* diagnostic only */ } missed = true; return false; }
100
+ },
101
+ };
102
+ }
@@ -8,11 +8,23 @@
8
8
  * module. This one owns output only; it is handed the log path and never
9
9
  * decides where per-project files live (daemonState.mjs does).
10
10
  */
11
- import { 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
+ }
@@ -57,7 +57,8 @@ function bound(servers) {
57
57
  const seen = new Set();
58
58
  return servers
59
59
  .map((s) => ({ ...s, name: s.name.trim().slice(0, MCP_INVENTORY_NAME_MAX).trim() }))
60
- .filter((s) => s.name && s.name !== 'flowviant' && !seen.has(s.name) && seen.add(s.name))
60
+ // Only the person's own `flowviant` reaches here as theirs (2026-10-19).
61
+ .filter((s) => s.name && (s.name !== 'flowviant' || s.source === 'personal') && !seen.has(s.name) && seen.add(s.name))
61
62
  .sort((a, b) => SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
62
63
  .slice(0, MCP_INVENTORY_MAX_SERVERS)
63
64
  .map((s) => {
@@ -18,3 +18,9 @@ export const mcpCommandNotFoundReason = (command) => `command ${command} not fou
18
18
  * installed, so the app has nothing to offer for it (rule 41 checks that). */
19
19
  export const mcpLauncherNotExecutableReason = (command) =>
20
20
  `command ${command} is not an executable file in the base branch; commit the launcher with executable permission on base`;
21
+ /** A base server that signs in through a `headersHelper` (2026-10-19). The
22
+ * daemon's mount carries no helper — it is a command, and a repository's
23
+ * would need the reviewed-blob check a launcher has — so the server is left
24
+ * off the card and named, never mounted to fail without its headers. No
25
+ * checklist fact either: nothing on the box is missing. */
26
+ export const mcpHeadersHelperReason = 'headersHelper is not carried onto card turns; use headers with ${VAR} values in .mcp.json';