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.
Files changed (55) 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 +44 -0
  6. package/bin/lib/agentPlan.mjs +6 -2
  7. package/bin/lib/claudePosture.mjs +21 -0
  8. package/bin/lib/claudeStream.mjs +25 -4
  9. package/bin/lib/codexResearch.mjs +33 -5
  10. package/bin/lib/commandSpecs.mjs +8 -5
  11. package/bin/lib/daemonJournal.mjs +102 -0
  12. package/bin/lib/daemonLogging.mjs +22 -18
  13. package/bin/lib/fleet.mjs +16 -6
  14. package/bin/lib/fleetReports.mjs +12 -5
  15. package/bin/lib/hooks/orchestrator.mjs +3 -1
  16. package/bin/lib/logReplay.mjs +133 -0
  17. package/bin/lib/nativeAgentTools.mjs +20 -3
  18. package/bin/lib/observedLines.mjs +63 -0
  19. package/bin/lib/orchestrator.mjs +40 -7
  20. package/bin/lib/orchestratorNative.mjs +133 -3
  21. package/bin/lib/orchestratorPrompt.mjs +50 -4
  22. package/bin/lib/postureTools.mjs +14 -6
  23. package/bin/lib/printable.mjs +1 -1
  24. package/bin/lib/repositoryMerge.mjs +1 -1
  25. package/bin/lib/researchHandoff.mjs +144 -0
  26. package/bin/lib/researchHandoffText.mjs +3 -0
  27. package/bin/lib/researchKnowledge.mjs +60 -0
  28. package/bin/lib/researchRestoration.mjs +38 -0
  29. package/bin/lib/runTurn.mjs +76 -43
  30. package/bin/lib/runtimeClaude.mjs +9 -4
  31. package/bin/lib/runtimeCodex.mjs +51 -22
  32. package/bin/lib/runtimeEvents.mjs +17 -7
  33. package/bin/lib/turnDiagnostics.mjs +2 -1
  34. package/bin/lib/turnOutput.mjs +63 -0
  35. package/bin/lib/turnRecord.mjs +11 -0
  36. package/bin/lib/turnSubagents.mjs +13 -3
  37. package/bin/lib/ui.mjs +2 -2
  38. package/bin/lib/uplinkScrub.mjs +49 -0
  39. package/bin/lib/views.mjs +49 -58
  40. package/bin/lib/wikiRunner.mjs +2 -2
  41. package/bin/lib/work.mjs +2 -2
  42. package/bin/lib/workActivity.mjs +65 -0
  43. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  44. package/bin/lib/workAgentMerges.mjs +57 -52
  45. package/bin/lib/workAgentPlans.mjs +76 -27
  46. package/bin/lib/workAgentPrecheck.mjs +1 -1
  47. package/bin/lib/workAgentTurnExecution.mjs +42 -8
  48. package/bin/lib/workAgentTurnReports.mjs +17 -3
  49. package/bin/lib/workAgentTurnShape.mjs +6 -4
  50. package/bin/lib/workAgentTurns.mjs +8 -6
  51. package/bin/lib/workIntake.mjs +8 -2
  52. package/bin/lib/workMergeBatch.mjs +7 -1
  53. package/bin/lib/workPlanRecovery.mjs +50 -0
  54. package/bin/lib/workSessionTurns.mjs +5 -2
  55. package/package.json +1 -1
package/README.md CHANGED
@@ -67,7 +67,7 @@ The binary and global npm installs update when idle. The binary fetches the rele
67
67
  | `flowviant login` | Connect this repo to a project (approve the link it prints) |
68
68
  | `flowviant stop` | Stop every flowviant daemon on this computer |
69
69
  | `flowviant status` | Each project connected here: repo, running/serving, live agents |
70
- | `flowviant logs [-f]` | This repo's daemon log (`-f` follows it) |
70
+ | `flowviant logs [-f] [-n <lines>] [--turn <id>] [--project <name|id>]` | Replay or follow retained local diagnostics; optionally select one turn |
71
71
  | `flowviant open` | Open this project's board in the browser |
72
72
  | `flowviant projects` | Every stored login and the repo it serves |
73
73
  | `flowviant machines` | Every computer serving your projects; a menu on a terminal |
@@ -76,6 +76,85 @@ The binary and global npm installs update when idle. The binary fetches the rele
76
76
  | `flowviant clean` | Delete kept agent worktrees |
77
77
  | `flowviant uninstall` | Remove every copy (`--purge` for logins too) |
78
78
 
79
+ ## Native activity feed
80
+
81
+ `flowviant` (or `flowviant start`) defaults to an append-only chronological
82
+ feed. Each work record carries its observation time, readable work name and
83
+ stable `@` turn label; observed native children keep their parent turn and a
84
+ child label. Long names and routine labels are shortened to fit ordinary
85
+ terminals; errors and questions keep their actual words and line breaks.
86
+
87
+ Starts, failures, interruptions, plan proposals and validated agent deliveries
88
+ appear immediately. A delivery says **Delivered for review**; only the merge
89
+ lane's measured landing says **Merged on the base branch**. Questions retain
90
+ their text and the existing app destination. Answer them in Flowviant.
91
+ Connection failures and recovery, serving changes and stopping remain visible.
92
+
93
+ Routine narration and tool activity are summarized as **Latest activity**
94
+ every ten seconds while fresh activity arrives, and flushed before immediate
95
+ observations or at close. A quiet interval prints nothing. A latest observation
96
+ is not proof that a command is still running. At most 256 pending turn/child
97
+ labels are held, each limited to 120 characters; reaching that bound flushes
98
+ them in observation order. This is a feed, not a live dashboard; `flowviant
99
+ status` remains the on-demand snapshot.
100
+
101
+ Use `flowviant --detail` or `flowviant start --detail` to print each runtime
102
+ activity as it arrives. This selects the existing detailed activity stream;
103
+ fuller supplied narration and tool data are available through logs below.
104
+ The choice survives inline login, PATH adoption and self-update relaunches.
105
+ It is inherited for that launch, not saved as a project preference. A service
106
+ can set `FLOWVIANT_OUTPUT=detail`; `--detail` also works on `flowviant login`.
107
+ Both presentations capture the same diagnostic detail before rendering.
108
+ Legacy idle snapshots stay in retained diagnostics in compact mode.
109
+ Environment-token launches attach the project journal when the roster first
110
+ identifies the project, before starting its CLI work. If
111
+ retention is unavailable or a write fails, the live record stays detailed.
112
+
113
+ ## Local diagnostic detail
114
+
115
+ `flowviant logs` shows the last 80 retained lines for this repo's project,
116
+ including daemon diagnostics and native CLI detail. Use `-n 300` for more lines,
117
+ `-f` or `--follow` for new lines across rotation and daemon restarts, and
118
+ `--project <name|id>` for another connected project. To isolate concurrent work,
119
+ copy the `@` turn label from the compact feed (or the full `turn` id from detail):
120
+
121
+ ```sh
122
+ flowviant logs --turn @<label> -n 300
123
+ flowviant logs --turn <turn-id> --follow --project <name-or-id>
124
+ ```
125
+
126
+ The filter accepts an exact full turn id or the deterministic twelve-hex `@`
127
+ SHA-256 label of that id. It includes observed native children and excludes
128
+ unattributed daemon diagnostics. With no filter those diagnostics remain
129
+ visible. Replay includes the current log and its one predecessor, oldest first.
130
+ Each project retains **two files of at most 2 MiB each**, under
131
+ `~/.flowviant/daemon-<project-hash>.log` and `.log.1`, with mode `0600`.
132
+ Retention is by bytes, not a guaranteed duration; older detail expires as work
133
+ continues. New log directories use mode `0700`.
134
+
135
+ Native entries retain observed timestamps, readable work names, full work/turn
136
+ ids and available native-child ids, with supplied narration, answers, tool
137
+ calls/results and CLI stderr/errors. This is **local diagnostic detail, not a
138
+ complete vendor transcript**: it contains no unavailable reasoning or raw
139
+ vendor event archive, and only tool information the adapters expose. Each
140
+ encoded native record is capped at **64 KiB**, after redaction; a clipped entry
141
+ carries `[truncated: local detail record exceeded 64 KiB]`. Oversized legacy
142
+ console entries carry `[truncated]`. Tail limits, detected rotation/write gaps,
143
+ unreadable records and missing selected-turn detail are explicit. A missing
144
+ turn may have expired or predate this capture; it does not mean the turn did
145
+ not run.
146
+
147
+ Capture is independent of app trace uploads and foreground presentation. Local
148
+ storage and replay use the daemon's existing learned-secret and credential-shape
149
+ scrubber, with its documented exemptions; supplied per-turn MCP/login values
150
+ are masked too. Known multiline secret fragments and streamed PEM bodies are
151
+ masked before storage. Terminal controls are removed at storage and replay.
152
+ Unknown secrets outside those sources/shapes remain outside that guarantee.
153
+ If storage fails, work continues, the daemon reports that detail could not be
154
+ retained, and a successful later write marks the gap. Foreground presentation
155
+ never changes retained detail; desktop `--json-events` still reserves stdout for
156
+ machine events and writes human output to stderr.
157
+
79
158
  ## Uninstall
80
159
 
81
160
  `flowviant uninstall` stops local daemons and removes installed copies. It keeps logins and local state in `~/.flowviant`, so reinstalling resumes them. Use `flowviant uninstall --purge` to disconnect this box from stored projects and delete that local data too. Add `--yes` to skip confirmation.
package/bin/cli.mjs CHANGED
@@ -115,6 +115,11 @@ if (process.argv[2] === '__hook') {
115
115
  // is that the login ended; wait for stdout to drain before exiting.
116
116
  const flushStdout = () => new Promise((resolve) => process.stdout.write('', resolve));
117
117
 
118
+ // Preserve an explicit foreground choice across login, PATH and update
119
+ // relaunches, including paths that build fresh argv. No setting is saved.
120
+ const { outputMode, configureActivityOutput } = await import('./lib/activityFeed.mjs');
121
+ process.env.FLOWVIANT_OUTPUT = outputMode(process.argv.slice(2), process.env);
122
+
118
123
  // `flowviant start` is the spelled-out form of a bare `flowviant`; everything
119
124
  // below reads the start path from argv, so the word is simply dropped.
120
125
  if (process.argv[2] === 'start' && !process.argv.slice(3).some((arg) => arg === '--help' || arg === '-h')) process.argv.splice(2, 1);
@@ -229,7 +234,7 @@ async function statusCommand() {
229
234
  async function logsCommand() {
230
235
  const { runLogs } = await import('./lib/views.mjs');
231
236
  await runLogs(process.argv);
232
- process.exit(0);
237
+ process.exit(process.exitCode || 0);
233
238
  }
234
239
 
235
240
  // `flowviant open` — this repo's board, in the browser.
@@ -484,4 +489,5 @@ const { installDaemonLogging } = await import('./lib/daemonLogging.mjs');
484
489
  const { daemonLogPath } = await import('./lib/daemonState.mjs');
485
490
  const logProjectId = CREDENTIAL?.entry?.projectId;
486
491
  installDaemonLogging(logProjectId ? daemonLogPath(logProjectId) : null, { jsonEvents: process.argv.includes('--json-events') });
492
+ configureActivityOutput(process.env.FLOWVIANT_OUTPUT);
487
493
  await runFleetDaemon({ afterLock });
@@ -0,0 +1,69 @@
1
+ /** Local foreground presentation. Retention happens before this sink. Unknown
2
+ * or failed retention always falls back to the complete detailed live record.
3
+ * A timer consumes fresh observations; it never infers liveness from silence.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { c, stamp } from './ui.mjs';
7
+ import { printableLine, printableText } from './printable.mjs';
8
+
9
+ export const turnToken = id => '@' + createHash('sha256').update(id).digest('hex').slice(0, 12);
10
+ export const matchesTurn = (id, selection) => selection === id || selection === turnToken(id);
11
+ export const outputMode = (argv, env) => argv.includes('--detail') || env.FLOWVIANT_OUTPUT === 'detail' ? 'detail' : 'compact';
12
+ const routine = new Set(['think', 'say', 'bash', 'glob', 'list', 'plan', 'read', 'search', 'tool', 'write']);
13
+
14
+ export function formatActivityRecord(record) {
15
+ const name = printableLine(printableText(record.name), 24);
16
+ const child = record.agentId ? ` child=${turnToken(record.agentId)}` : '';
17
+ const prefix = `${stamp(record.observedAt)} ${c.cyan(`[${printableLine(record.kind, 16)} ${name} ${turnToken(record.turnId)}${child}]`)} `;
18
+ const body = printableText(record.text).replace(/\n$/, '');
19
+ return body.split('\n').map(line => prefix + line).join('\n') + '\n';
20
+ }
21
+
22
+ export function createActivityFeed({ intervalMs = 10_000, maxPending = 256,
23
+ schedule = setTimeout, cancel = clearTimeout } = {}) {
24
+ const pending = new Map();
25
+ let timer = null;
26
+ const flush = () => {
27
+ if (timer !== null) { cancel(timer); timer = null; }
28
+ const rows = [...pending.values()];
29
+ pending.clear();
30
+ for (const { record, write } of rows) {
31
+ // Only the label is coalesced. Full text was already retained, including
32
+ // tools/results. Observation time belongs to the latest supplied event.
33
+ const latest = { ...record, text: `Latest activity: ${printableLine(record.text, 120)}` };
34
+ write(formatActivityRecord(latest), latest.source, latest, true);
35
+ }
36
+ };
37
+ return {
38
+ present(record, detail, retained, write) {
39
+ if (retained === true && record.source === 'activity' && routine.has(record.activityKind)) {
40
+ const key = JSON.stringify([record.turnId, record.agentId]);
41
+ pending.delete(key); // Map order is the order of the latest observations.
42
+ pending.set(key, { record: { ...record, text: printableLine(record.text, 120) }, write });
43
+ if (pending.size >= maxPending) flush();
44
+ else if (timer === null) { timer = schedule(flush, intervalMs); timer.unref?.(); }
45
+ return;
46
+ }
47
+ // A question/error/outcome is immediate, after older pending activity.
48
+ flush();
49
+ write(retained === true ? formatActivityRecord(record) : detail, record.source, record, retained);
50
+ },
51
+ flush,
52
+ close(turnId) {
53
+ if ([...pending.values()].some(row => row.record.turnId === turnId)) flush();
54
+ },
55
+ };
56
+ }
57
+
58
+ let feed = null;
59
+ export const compactOutput = () => feed !== null;
60
+ export function configureActivityOutput(mode) {
61
+ feed?.flush();
62
+ feed = mode === 'compact' ? createActivityFeed() : null;
63
+ }
64
+ export const flushActivityOutput = () => feed?.flush();
65
+ export const closeActivityTurn = turnId => feed?.close(turnId);
66
+ export function presentTurnRecord(record, detail, retained, write) {
67
+ if (feed) feed.present(record, detail, retained, write);
68
+ else write(detail, record.source, record, retained);
69
+ }
@@ -0,0 +1,46 @@
1
+ /** Persist an outcome before sending it. Uncertain acknowledgement retries the
2
+ * receipt, never the Git operation; the original lease instance survives restart. */
3
+ import { join } from 'node:path';
4
+ import { createReportJournal } from './reportJournal.mjs';
5
+ import { fleetRequest } from './fleetPost.mjs';
6
+ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
7
+ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
8
+
9
+ export function createMergeReports({ baseDir, delivered = async () => {} }) {
10
+ const journal = createReportJournal(join(baseDir, 'agent-merge-reports'));
11
+ const pending = new Map(journal.load().work.filter(([id, held]) =>
12
+ held?.body?.agentId === id && typeof held.body.ok === 'boolean' && typeof held.body.instance === 'string'));
13
+ const sending = new Set();
14
+ const retryAt = new Map();
15
+ const save = () => journal.save(pending, new Map());
16
+ const send = async (id) => {
17
+ if (sending.has(id) || (retryAt.get(id) ?? 0) > Date.now()) return false;
18
+ const held = pending.get(id);
19
+ if (!held) return true;
20
+ sending.add(id);
21
+ try {
22
+ const r = await fleetRequest(fleetEndpoint('agent-merge-done', FLEET_URL), { body: held.body });
23
+ // A parsed receipt includes already-settled/lost-holder results. Auth,
24
+ // HTML, malformed JSON and deploy skew retain the exact body.
25
+ if (r.ok && r.json?.success === true && typeof r.json?.data?.settled === 'boolean') {
26
+ pending.delete(id);
27
+ retryAt.delete(id);
28
+ save();
29
+ await delivered(held, r.json.data.settled);
30
+ return true;
31
+ }
32
+ retryAt.set(id, Date.now() + (r.retryAfterMs ?? (isConsideredRefusal(r.status) ? 60_000 : 1000)));
33
+ return false;
34
+ } finally { sending.delete(id); }
35
+ };
36
+ return {
37
+ pending,
38
+ async report(body, meta = {}) {
39
+ const id = String(body.agentId);
40
+ pending.set(id, { body: { ...body, instance: DAEMON_INSTANCE }, ...meta });
41
+ save();
42
+ return send(id);
43
+ },
44
+ async flush() { for (const id of [...pending.keys()]) await send(id); },
45
+ };
46
+ }
@@ -0,0 +1,44 @@
1
+ /** Evidence belongs to the failed attempt, so an answer after committing can
2
+ * verify the same repair without inventing another commit. */
3
+ import { readFileSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { git } from './git.mjs';
6
+
7
+ const marker = (wt) => resolve(wt, git(['rev-parse', '--git-path', 'flowviant-merge-resolution'], wt));
8
+ const sha = (value) => typeof value === 'string' && /^[a-f0-9]{40,64}$/.test(value);
9
+
10
+ export function gitReportedConflict(error, wt) {
11
+ if (/^CONFLICT \(/m.test(`${error?.stdout ?? ''}\n${error?.stderr ?? ''}`)) return true;
12
+ try {
13
+ return Boolean(git(['diff', '--name-only', '--diff-filter=U'], wt) || git(['rev-parse', '--verify', 'MERGE_HEAD'], wt));
14
+ } catch { return false; }
15
+ }
16
+
17
+ export function clearMergeFailure(wt) {
18
+ try { rmSync(marker(wt), { force: true }); } catch { /* no marker */ }
19
+ }
20
+
21
+ export function recordMergeFailure(wt, base, conflict) {
22
+ try {
23
+ writeFileSync(marker(wt), JSON.stringify({ before: git(['rev-parse', 'HEAD'], wt), base: git(['rev-parse', base], wt), conflict }));
24
+ } catch { clearMergeFailure(wt); }
25
+ }
26
+
27
+ export function measuredMergeResolution(wt, before, base) {
28
+ let start;
29
+ try { start = JSON.parse(readFileSync(marker(wt), 'utf8')); }
30
+ catch (error) {
31
+ if (error?.code !== 'ENOENT') return false;
32
+ start = { before, base: (() => { try { return git(['rev-parse', base], wt); } catch { return null; } })(), conflict: true };
33
+ }
34
+ if (!sha(start?.before) || !sha(start?.base) || typeof start.conflict !== 'boolean') return false;
35
+ try {
36
+ const tip = git(['rev-parse', 'HEAD'], wt);
37
+ if (start.conflict && tip === start.before) return false;
38
+ if (git(['status', '--porcelain', '--untracked-files=no'], wt)) return false;
39
+ if (gitReportedConflict(null, wt)) return false;
40
+ git(['merge-base', '--is-ancestor', start.before, tip], wt);
41
+ git(['merge-base', '--is-ancestor', start.base, tip], wt);
42
+ return true;
43
+ } catch { return false; }
44
+ }
@@ -48,8 +48,12 @@ function extract(raw) {
48
48
 
49
49
  export function parseProposal(text, scrub = envScrub) {
50
50
  const raw = String(text ?? '');
51
- const parsed = extract(raw);
52
- if (!parsed) return null;
51
+ const found = extract(raw);
52
+ if (!found) return null;
53
+ // A model says "none" with null as often as with []: both are an absent key,
54
+ // never a malformed list that throws the whole plan away.
55
+ const parsed = { ...found };
56
+ for (const key of ['dependencies', 'collisions', 'evidence', 'repos']) if (parsed[key] === null) delete parsed[key];
53
57
 
54
58
  const agents = [];
55
59
  for (const [i, g] of parsed.agents.slice(0, MAX_AGENTS).entries()) {
@@ -478,6 +478,27 @@ export function researchPerm(knowledgeDir, projectMcp = []) {
478
478
  /** The list with no library — what a project that keeps none runs under. */
479
479
  export const RESEARCH_PERM = researchPerm(null);
480
480
 
481
+ /**
482
+ * AN ORCHESTRATED RESEARCH CARD'S LIST (2026-10-19): the `Agent` its fence
483
+ * denies is allowed instead, so the gate's children can start. MEASURED on
484
+ * Claude Code 2.1.303 (offline): a research child inherits the parent's rules
485
+ * — a Write to `src/` and a `touch` were refused, `/etc/hostname` and `.env`
486
+ * unreadable, its PreToolUse hooks carried `agent_id` — but its Write under
487
+ * `.flowviant/artifacts/` landed, so the gate refuses a child's file writes
488
+ * (orchestrator.mjs, nativeAgentTools.mjs `write`). Every other entry stays.
489
+ */
490
+ export function withNativeChildren(perm) {
491
+ const out = [];
492
+ let denying = false;
493
+ for (const arg of perm) {
494
+ if (arg.startsWith('--')) denying = arg === '--disallowedTools';
495
+ if (denying && arg === 'Agent') continue;
496
+ out.push(arg);
497
+ if (arg === '--allowedTools') out.push('Agent');
498
+ }
499
+ return out;
500
+ }
501
+
481
502
  /**
482
503
  * CLAUDE'S PERMISSION LIST FOR A TURN PROFILE, by the profile's name — one
483
504
  * entry per profile Claude declares, plus plan-mode (runtimeClaude.mjs
@@ -100,7 +100,7 @@ export function taskPlanChanged(tasks, result) {
100
100
  }
101
101
 
102
102
  export function handleStreamLine(line, options) {
103
- const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents, tasks } = options;
103
+ const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents, tasks, onDetail } = options;
104
104
  let ev;
105
105
  try {
106
106
  ev = JSON.parse(line);
@@ -118,9 +118,11 @@ export function handleStreamLine(line, options) {
118
118
  // A sidechain with no identity must never be attributed to the parent.
119
119
  if (ev.isSidechain && !agentId) return;
120
120
  subagents?.observe(ev);
121
+ if (ev.type === 'result' && !ev.is_error && typeof ev.result === 'string')
122
+ onDetail?.(ev.result, { source: 'answer', ...(agentId ? { agentId } : {}) });
121
123
  const push = (a) => {
122
124
  if (!a || !a.label) return;
123
- emit(a.label + '\n');
125
+ emit(a.kind === 'error' ? a.full ?? a.label : a.label + '\n', { ...a, ...(agentId ? { agentId } : {}) });
124
126
  onActivity?.({ ...a, ...(agentId ? { agentId } : {}) });
125
127
  };
126
128
  if (ev.type === 'stream_event') {
@@ -198,6 +200,7 @@ export function handleStreamLine(line, options) {
198
200
  id: b.tool_use_id, result: true, isError: b.is_error === true,
199
201
  ...(agentId ? { agentId } : {}),
200
202
  output: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.slice(0, 8).filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
203
+ detail: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
201
204
  exitCode: ev.tool_use_result?.exit_code ?? ev.tool_use_result?.exitCode,
202
205
  });
203
206
  }
@@ -237,6 +240,13 @@ export function handleStreamLine(line, options) {
237
240
  tools: Array.isArray(ev.tools) ? ev.tools : undefined,
238
241
  });
239
242
  }
243
+ } else if (ev.type === 'result' && agentId && ev.is_error) {
244
+ // A child's failure belongs in its record, never in the parent's answer
245
+ // or token count. The stream's observed spawn-call id remains its owner.
246
+ const listed = Array.isArray(ev.errors) ? ev.errors.filter(e => typeof e === 'string' && e.trim()).join('\n') : '';
247
+ const msg = listed || ev.result || ev.error?.message || ev.error || ev.subtype;
248
+ const full = typeof msg === 'string' ? msg : JSON.stringify(msg);
249
+ if (full) push({ kind: 'error', label: oneLine(full), full });
240
250
  } else if (ev.type === 'result' && !agentId) {
241
251
  /**
242
252
  * WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
@@ -251,7 +261,10 @@ export function handleStreamLine(line, options) {
251
261
  const usage = usageFromResult(ev);
252
262
  if (usage) onUsage?.(usage);
253
263
  // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
254
- if (typeof ev.result === 'string') appendText(ev.result + '\n');
264
+ if (typeof ev.result === 'string') {
265
+ appendText(ev.result + '\n');
266
+ if (ev.is_error) push({ kind: 'error', label: oneLine(ev.result), full: ev.result });
267
+ }
255
268
  else if (ev.is_error || ev.subtype) {
256
269
  // A result that carries no text is a FAILED turn (a limit, a refused
257
270
  // permission, an aborted run). Under `answerFromResult` this is the only
@@ -268,7 +281,15 @@ export function handleStreamLine(line, options) {
268
281
  ? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
269
282
  : '';
270
283
  const msg = listed || ev.error?.message || ev.error || ev.subtype;
271
- appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
284
+ const text = typeof msg === 'string' ? msg : JSON.stringify(msg);
285
+ appendText(`${text}\n`);
286
+ if (ev.is_error || listed || ev.error || String(ev.subtype).startsWith('error'))
287
+ push({ kind: 'error', label: oneLine(text), full: listed ? ev.errors.filter(e => typeof e === 'string' && e.trim()).join('\n') : text });
288
+ }
289
+ } else if (ev.type === 'error') {
290
+ const text = ev.error?.message ?? ev.message;
291
+ if (typeof text === 'string' && text) {
292
+ push({ kind: 'error', label: oneLine(text), full: text });
272
293
  }
273
294
  } else if (ev.type === 'rate_limit_event') {
274
295
  /**
@@ -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
  {
@@ -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
+ }