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
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  Run your own coding CLIs as build agents for [Flowviant](https://flowviant.com) — [Claude Code](https://claude.com/claude-code), Codex or Antigravity, on your own credentials. This daemon holds your sessions, keeps a worktree per tab, and ships branches on your word. The daemon does not send your Claude, Codex or GitHub logins to Flowviant; it shells out to CLIs you signed in yourself.
4
4
 
5
5
  ```sh
6
- curl -fsSL https://api.flowviant.com/install.sh | sh
7
- flowviant # approve the code in Flowviant → connected
6
+ curl -fsSL https://api.flowviant.com/install.sh | sh && ~/.flowviant/bin/flowviant
7
+ # approve the code in Flowviant → connected; new terminals just run `flowviant`
8
8
  ```
9
9
 
10
10
  Login keeps going straight into the daemon — there is no second command to run.
@@ -31,11 +31,10 @@ On the machine that runs the daemon:
31
31
  Download the standalone binary, then start it in your checkout:
32
32
 
33
33
  ```sh
34
- curl -fsSL https://api.flowviant.com/install.sh | sh
35
- flowviant
34
+ curl -fsSL https://api.flowviant.com/install.sh | sh && ~/.flowviant/bin/flowviant
36
35
  ```
37
36
 
38
- It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `flowviant` just runs. The installer prints a PATH hint if needed; `FLOWVIANT_INSTALL_DIR` changes its destination. It verifies SHA-256 and never uses sudo.
37
+ The full path is for this first run only: the installer adds its folder to your shell's startup file, which new terminals read. It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `flowviant` just runs. The installer prints a PATH hint if needed; `FLOWVIANT_INSTALL_DIR` changes its destination. It verifies SHA-256 and never uses sudo.
39
38
 
40
39
  Run `flowviant login` from another checkout and choose the same project in the
41
40
  browser to add its repository to that board. Project settings → General lists
@@ -68,7 +67,7 @@ The binary and global npm installs update when idle. The binary fetches the rele
68
67
  | `flowviant login` | Connect this repo to a project (approve the link it prints) |
69
68
  | `flowviant stop` | Stop every flowviant daemon on this computer |
70
69
  | `flowviant status` | Each project connected here: repo, running/serving, live agents |
71
- | `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 |
72
71
  | `flowviant open` | Open this project's board in the browser |
73
72
  | `flowviant projects` | Every stored login and the repo it serves |
74
73
  | `flowviant machines` | Every computer serving your projects; a menu on a terminal |
@@ -77,6 +76,85 @@ The binary and global npm installs update when idle. The binary fetches the rele
77
76
  | `flowviant clean` | Delete kept agent worktrees |
78
77
  | `flowviant uninstall` | Remove every copy (`--purge` for logins too) |
79
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
+
80
158
  ## Uninstall
81
159
 
82
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()) {
@@ -12,7 +12,27 @@
12
12
  * for that wave arrives just then (both measured). Only agents hold it: a
13
13
  * background shell (a dev server) may never end, and the CLI's own wind-down
14
14
  * waits on those. A CLI that never lists tasks closes at its first result. */
15
- export function createClaudeInput(child, { onTaken = () => {} } = {}) {
15
+ /**
16
+ * THE CLI'S OWN CONTEXT MEASURE (2026-10-19). `get_context_usage` with
17
+ * `detail: 'summary'` answers from the last response's usage and local
18
+ * estimates — no model call and no token-count request (measured offline on
19
+ * 2.1.303). It names the window, the tokens in it and the native auto-compact
20
+ * threshold, so `compactAt` is the CLI's, never a guess; an auto-compact that
21
+ * is off has none (null). A CLI that answers an error, or never answers,
22
+ * reports nothing: unknown draws nothing (ContextReadout.tsx).
23
+ */
24
+ export function contextFromUsage(r) {
25
+ const used = r?.totalTokens;
26
+ const window = r?.maxTokens;
27
+ if (!Number.isSafeInteger(used) || used < 0 || !Number.isSafeInteger(window) || window <= 0) return null;
28
+ const threshold = r.isAutoCompactEnabled === true && Number.isFinite(r.autoCompactThreshold) && r.autoCompactThreshold > 0
29
+ ? Math.min(1, r.autoCompactThreshold / window)
30
+ : null;
31
+ return { usedTokens: used, windowTokens: window, compactAt: threshold, compacting: false };
32
+ }
33
+ const CONTEXT_EVERY_MS = 3_000;
34
+
35
+ export function createClaudeInput(child, { onTaken = () => {}, onContext = null } = {}) {
16
36
  const sent = new Set();
17
37
  const pending = new Set();
18
38
  let draining = false; // a result found no live agent: admit nothing, close once pending is read
@@ -22,6 +42,31 @@ export function createClaudeInput(child, { onTaken = () => {} } = {}) {
22
42
  let agents = 0; // live background agents, as the parent stream last listed them
23
43
  child.stdin.on('error', () => { broken = true; }); // EPIPE leaves the message queued
24
44
  const end = () => { if (!ended) { ended = true; child.stdin.end(); } };
45
+ // The context readout's asks: one in flight, at most one per window, and
46
+ // one more owed when something moved while it was out — at once when what
47
+ // moved was a compaction ('now'), else on the window ('later').
48
+ let ctxSeq = 0;
49
+ let ctxAsked = null;
50
+ let ctxAt = 0;
51
+ let ctxOwed = null;
52
+ let compacting = false;
53
+ let lastContext = null;
54
+ const sayContext = (ctx) => {
55
+ lastContext = ctx;
56
+ try { onContext(ctx); } catch { /* a readout never fails a turn */ }
57
+ };
58
+ const askContext = (now = false) => {
59
+ if (!onContext || ended || broken || child.stdin.destroyed || child.stdin.writableEnded) return;
60
+ if (ctxAsked || (!now && Date.now() - ctxAt < CONTEXT_EVERY_MS)) {
61
+ if (ctxOwed !== 'now') ctxOwed = now ? 'now' : 'later';
62
+ return;
63
+ }
64
+ ctxAsked = `fv-context-${++ctxSeq}`;
65
+ ctxAt = Date.now();
66
+ ctxOwed = null;
67
+ const request = { type: 'control_request', request_id: ctxAsked, request: { subtype: 'get_context_usage', detail: 'summary' } };
68
+ try { child.stdin.write(JSON.stringify(request) + '\n'); } catch { ctxAsked = null; }
69
+ };
25
70
  const send = (id, prompt) => {
26
71
  if (draining || broken || ended || sent.has(id) || child.stdin.destroyed || child.stdin.writableEnded) return false;
27
72
  const message = { type: 'user', uuid: id, session_id: '', message: { role: 'user', content: prompt }, parent_tool_use_id: null };
@@ -34,6 +79,21 @@ export function createClaudeInput(child, { onTaken = () => {} } = {}) {
34
79
  send,
35
80
  event(event) {
36
81
  if (event.type === 'user' && event.isReplay === true && pending.delete(event.uuid)) onTaken(event.uuid);
82
+ if (event.type === 'control_response' && ctxAsked && event.response?.request_id === ctxAsked) {
83
+ ctxAsked = null;
84
+ const ctx = event.response.subtype === 'success' ? contextFromUsage(event.response.response) : null;
85
+ if (ctx) sayContext({ ...ctx, compacting });
86
+ if (ctxOwed) askContext(ctxOwed === 'now');
87
+ }
88
+ if (event.type === 'system' && event.subtype === 'init') askContext(true);
89
+ if (event.type === 'assistant') askContext();
90
+ if (event.type === 'system' && event.subtype === 'status') {
91
+ const now = event.status === 'compacting';
92
+ if (now && !compacting && lastContext) sayContext({ ...lastContext, compacting: true });
93
+ if (!now && compacting) askContext(true);
94
+ compacting = now;
95
+ }
96
+ if (event.type === 'system' && event.subtype === 'compact_boundary') { compacting = false; askContext(true); }
37
97
  if (event.type === 'system' && event.subtype === 'background_tasks_changed' && Array.isArray(event.tasks)) {
38
98
  agents = event.tasks.filter(task => task?.task_type === 'local_agent').length;
39
99
  if (agents > 0) draining = false; // a drained message's turn started agents
@@ -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
@@ -79,8 +79,28 @@ export function usageFromResult(ev) {
79
79
  // container that reports no spend, which is indistinguishable from an older
80
80
  // daemon by design. A callback that is silent when it breaks has to be called
81
81
  // directly by something.
82
+ /**
83
+ * One TaskCreate/TaskUpdate result, applied to a turn's task list (`tasks`, a
84
+ * Map of id → `{ content, status }` in creation order). True when the list
85
+ * changed. A task an earlier turn created is not on this turn's list, and an
86
+ * update to it changes nothing here: its subject was never said.
87
+ */
88
+ export function taskPlanChanged(tasks, result) {
89
+ if (!result || typeof result !== 'object') return false;
90
+ const made = result.task;
91
+ if (made && typeof made.id === 'string' && typeof made.subject === 'string' && made.subject.trim()) {
92
+ tasks.set(made.id, { content: made.subject, status: 'pending' });
93
+ return true;
94
+ }
95
+ const to = result.statusChange?.to;
96
+ if (typeof result.taskId !== 'string' || typeof to !== 'string' || !tasks.has(result.taskId)) return false;
97
+ if (to === 'deleted') tasks.delete(result.taskId);
98
+ else tasks.set(result.taskId, { ...tasks.get(result.taskId), status: to });
99
+ return true;
100
+ }
101
+
82
102
  export function handleStreamLine(line, options) {
83
- const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents } = options;
103
+ const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents, tasks, onDetail } = options;
84
104
  let ev;
85
105
  try {
86
106
  ev = JSON.parse(line);
@@ -98,9 +118,11 @@ export function handleStreamLine(line, options) {
98
118
  // A sidechain with no identity must never be attributed to the parent.
99
119
  if (ev.isSidechain && !agentId) return;
100
120
  subagents?.observe(ev);
121
+ if (ev.type === 'result' && !ev.is_error && typeof ev.result === 'string')
122
+ onDetail?.(ev.result, { source: 'answer', ...(agentId ? { agentId } : {}) });
101
123
  const push = (a) => {
102
124
  if (!a || !a.label) return;
103
- emit(a.label + '\n');
125
+ emit(a.kind === 'error' ? a.full ?? a.label : a.label + '\n', { ...a, ...(agentId ? { agentId } : {}) });
104
126
  onActivity?.({ ...a, ...(agentId ? { agentId } : {}) });
105
127
  };
106
128
  if (ev.type === 'stream_event') {
@@ -164,11 +186,21 @@ export function handleStreamLine(line, options) {
164
186
  }
165
187
  }
166
188
  } else if (ev.type === 'user' && Array.isArray(ev.message?.content)) {
189
+ // THE TASK LIST IS THE PLAN NOW (2026-10-19). Claude Code 2.1.303 offers
190
+ // TaskCreate/TaskUpdate and no TodoWrite (measured: the tools a `-p` turn
191
+ // is handed), so the plan card — built from TodoWrite — never drew. Each
192
+ // result says what it did (`tool_use_result`: the created task's id and
193
+ // subject, or a task's status change), so the turn's own list is kept here
194
+ // and handed on in TodoWrite's shape, which the card already reads.
195
+ if (!agentId && tasks && taskPlanChanged(tasks, ev.tool_use_result)) {
196
+ onToolEvent?.('TodoWrite', { todos: [...tasks.values()] }, { id: 'claude-task-list' });
197
+ }
167
198
  for (const b of ev.message.content) {
168
199
  if (b.type === 'tool_result') onToolEvent?.(null, {}, {
169
200
  id: b.tool_use_id, result: true, isError: b.is_error === true,
170
201
  ...(agentId ? { agentId } : {}),
171
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') : '',
172
204
  exitCode: ev.tool_use_result?.exit_code ?? ev.tool_use_result?.exitCode,
173
205
  });
174
206
  }
@@ -208,6 +240,13 @@ export function handleStreamLine(line, options) {
208
240
  tools: Array.isArray(ev.tools) ? ev.tools : undefined,
209
241
  });
210
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 });
211
250
  } else if (ev.type === 'result' && !agentId) {
212
251
  /**
213
252
  * WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
@@ -222,7 +261,10 @@ export function handleStreamLine(line, options) {
222
261
  const usage = usageFromResult(ev);
223
262
  if (usage) onUsage?.(usage);
224
263
  // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
225
- 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
+ }
226
268
  else if (ev.is_error || ev.subtype) {
227
269
  // A result that carries no text is a FAILED turn (a limit, a refused
228
270
  // permission, an aborted run). Under `answerFromResult` this is the only
@@ -239,7 +281,15 @@ export function handleStreamLine(line, options) {
239
281
  ? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
240
282
  : '';
241
283
  const msg = listed || ev.error?.message || ev.error || ev.subtype;
242
- 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 });
243
293
  }
244
294
  } else if (ev.type === 'rate_limit_event') {
245
295
  /**
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A CODEX TAB'S CONTRACT, SENT WHEN IT IS NEWS (2026-10-19).
3
+ *
4
+ * Codex has no system prompt for a resumed `exec`: the tab's contract rode at
5
+ * the head of EVERY turn's message, so each turn added its ~6 KB to the thread
6
+ * again — measured offline on 0.163, the third turn's request carried it three
7
+ * times — and a long tab compacted early, on copies of the same instructions.
8
+ * `developer_instructions` is no way out: a resumed thread keeps the ones it
9
+ * started with and ignores a changed value (measured the same way), and the
10
+ * contract does change (the knowledge and artifact paragraphs).
11
+ *
12
+ * So a resumed thread is sent the contract only when it is news: a thread this
13
+ * marker has not seen, a contract whose text changed, or every
14
+ * `CONTRACT_RESEND_EVERY`th turn — a bound on what a compaction may have
15
+ * summarised away. A fresh thread is always sent it. The marker sits beside
16
+ * the tab's thread marker, scoped by session, owner-only, best-effort: an
17
+ * unreadable one sends the contract, as before.
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { readFileSync, writeFileSync } from 'node:fs';
21
+
22
+ export const CODEX_CONTRACT_MARKER = 'flowviant-codex-contract';
23
+ export const CONTRACT_RESEND_EVERY = 8;
24
+
25
+ const digest = (text) => createHash('sha256').update(String(text ?? '')).digest('hex');
26
+ const read = (path) => {
27
+ try {
28
+ return JSON.parse(readFileSync(path, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
34
+ /** Does the resumed `thread` already hold this `system` text, recently? */
35
+ export function codexContractKnown(path, thread, system) {
36
+ if (!path || !thread) return false;
37
+ const m = read(path);
38
+ return m?.thread === thread && m.hash === digest(system) && Number.isInteger(m.since) && m.since < CONTRACT_RESEND_EVERY;
39
+ }
40
+
41
+ /** After a turn: what `thread` now holds, and how many turns since it was
42
+ * last sent the contract (`sent`: whether this turn carried it). */
43
+ export function recordCodexContract(path, thread, system, sent) {
44
+ if (!path || !thread) return;
45
+ const m = sent ? null : read(path);
46
+ const since = !sent && m?.thread === thread && Number.isInteger(m.since) ? m.since + 1 : 0;
47
+ try {
48
+ writeFileSync(path, JSON.stringify({ thread, hash: digest(system), since }), { mode: 0o600 });
49
+ } catch {
50
+ /* best-effort: the next turn sends the contract */
51
+ }
52
+ }