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.
- package/README.md +84 -6
- package/bin/cli.mjs +7 -1
- package/bin/lib/activityFeed.mjs +69 -0
- package/bin/lib/agentMergeReports.mjs +46 -0
- package/bin/lib/agentMergeResolution.mjs +44 -0
- package/bin/lib/agentPlan.mjs +6 -2
- package/bin/lib/claudeInput.mjs +61 -1
- package/bin/lib/claudePosture.mjs +21 -0
- package/bin/lib/claudeStream.mjs +54 -4
- package/bin/lib/codexContract.mjs +52 -0
- package/bin/lib/codexResearch.mjs +33 -5
- package/bin/lib/commandSpecs.mjs +8 -5
- package/bin/lib/config.mjs +22 -1
- package/bin/lib/daemonJournal.mjs +102 -0
- package/bin/lib/daemonLogging.mjs +22 -18
- package/bin/lib/fleet.mjs +16 -6
- package/bin/lib/fleetReports.mjs +12 -5
- package/bin/lib/hooks/orchestrator.mjs +3 -1
- package/bin/lib/logReplay.mjs +133 -0
- package/bin/lib/mcpInventory.mjs +2 -1
- package/bin/lib/mcpReasons.mjs +6 -0
- package/bin/lib/nativeAgentTools.mjs +20 -3
- package/bin/lib/observedLines.mjs +63 -0
- package/bin/lib/orchestrator.mjs +49 -7
- package/bin/lib/orchestratorNative.mjs +133 -3
- package/bin/lib/orchestratorPrompt.mjs +50 -4
- package/bin/lib/postureTools.mjs +14 -6
- package/bin/lib/printable.mjs +1 -1
- package/bin/lib/processGroup.mjs +41 -0
- package/bin/lib/projectTools.mjs +3 -2
- package/bin/lib/prompts.mjs +47 -18
- package/bin/lib/reportJournal.mjs +61 -0
- package/bin/lib/repositoryMerge.mjs +1 -1
- package/bin/lib/researchHandoff.mjs +144 -0
- package/bin/lib/researchHandoffText.mjs +3 -0
- package/bin/lib/researchKnowledge.mjs +60 -0
- package/bin/lib/researchRestoration.mjs +38 -0
- package/bin/lib/resumeLost.mjs +21 -1
- package/bin/lib/runTurn.mjs +98 -44
- package/bin/lib/runtimeCapabilities.mjs +7 -2
- package/bin/lib/runtimeClaude.mjs +30 -8
- package/bin/lib/runtimeCodex.mjs +54 -22
- package/bin/lib/runtimeEvents.mjs +17 -7
- package/bin/lib/runtimeOfferings.mjs +3 -1
- package/bin/lib/startProject.mjs +10 -0
- package/bin/lib/turnDiagnostics.mjs +2 -1
- package/bin/lib/turnOutput.mjs +63 -0
- package/bin/lib/turnRecord.mjs +11 -0
- package/bin/lib/turnSubagents.mjs +13 -3
- package/bin/lib/ui.mjs +2 -2
- package/bin/lib/update.mjs +18 -4
- package/bin/lib/uplinkScrub.mjs +49 -0
- package/bin/lib/views.mjs +49 -58
- package/bin/lib/wikiRunner.mjs +2 -2
- package/bin/lib/work.mjs +5 -5
- package/bin/lib/workActivity.mjs +65 -0
- package/bin/lib/workAgentBriefRunner.mjs +2 -2
- package/bin/lib/workAgentMerges.mjs +57 -52
- package/bin/lib/workAgentPlans.mjs +76 -27
- package/bin/lib/workAgentPrecheck.mjs +1 -1
- package/bin/lib/workAgentTurnExecution.mjs +62 -8
- package/bin/lib/workAgentTurnReports.mjs +33 -3
- package/bin/lib/workAgentTurnShape.mjs +6 -4
- package/bin/lib/workAgentTurns.mjs +12 -8
- package/bin/lib/workAttachments.mjs +27 -5
- package/bin/lib/workIntake.mjs +8 -2
- package/bin/lib/workMergeBatch.mjs +7 -1
- package/bin/lib/workNarration.mjs +32 -0
- package/bin/lib/workPlanRecovery.mjs +50 -0
- package/bin/lib/workReportQueue.mjs +18 -1
- package/bin/lib/workSessionTurns.mjs +64 -22
- package/bin/lib/worktreeDiff.mjs +29 -5
- 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
|
-
|
|
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]` |
|
|
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
|
+
}
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -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
|
|
52
|
-
if (!
|
|
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()) {
|
package/bin/lib/claudeInput.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/bin/lib/claudeStream.mjs
CHANGED
|
@@ -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')
|
|
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
|
-
|
|
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
|
+
}
|