flowviant 0.102.2 → 0.104.0
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/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
package/bin/lib/claude.mjs
CHANGED
|
@@ -1,357 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The Flowviant side of driving a coding CLI that is not a posture, a stream
|
|
3
|
+
* or a process: the sentinel protocol (a line the contract prompts require a
|
|
4
|
+
* turn to end on) and the hand-over of the flowviant MCP server to whichever
|
|
5
|
+
* runtime runs the turn. The hard rule baked into the prompts: there is no
|
|
6
|
+
* interactive user — the only channel to a human is the blocker loop.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* This file used to hold everything about a turn. It was split by reason to
|
|
9
|
+
* change (SOLID 2026-09-26): the contract prompts live in prompts.mjs, the
|
|
10
|
+
* permission lists in claudePosture.mjs, Claude's event-stream reader in
|
|
11
|
+
* claudeStream.mjs, and the spawn-and-supervise of one turn in runTurn.mjs.
|
|
12
|
+
* The argv, the binary and the event shape of each vendor come from each
|
|
13
|
+
* CLI's registry row (runtime{Claude,Codex,Antigravity}.mjs, assembled in
|
|
14
|
+
* runtimes.mjs; the stream's meaning in runtimeEvents.mjs).
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
|
-
import {
|
|
15
|
-
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import { SAFE } from './config.mjs';
|
|
17
|
-
import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
|
|
18
|
-
|
|
19
|
-
// Every prompt/kickoff constant lives in prompts.mjs and is re-exported here:
|
|
20
|
-
// a dozen call sites import them from claude.mjs, and none of them care where
|
|
21
|
-
// the strings live.
|
|
22
|
-
export * from './prompts.mjs';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
|
|
26
|
-
* hooks/readGuard.mjs for what it refuses and the probe that proved it fires.
|
|
27
|
-
*
|
|
28
|
-
* A `Bash(git log:*)` allow is a PREFIX: it names the program and says nothing
|
|
29
|
-
* about the arguments, and `git log --output=<path>` writes any file while
|
|
30
|
-
* `-c core.fsmonitor=…` / `--ext-diff` run a program. A reviewer landed
|
|
31
|
-
* `git log -1 --format='tformat:x' --output=pwned.txt` under RESEARCH_PERM with
|
|
32
|
-
* no denial, and it re-measured the same way here. So every list below that
|
|
33
|
-
* promises "reads, and nothing else" carries a `PreToolUse` hook through
|
|
34
|
-
* `--settings`, measured on Claude Code 2.1.281: it fires under
|
|
35
|
-
* `--allowedTools`, exit 2 blocks the call, and its sentence reaches the model.
|
|
36
|
-
*
|
|
37
|
-
* NEVER ON THE BUILD POSTURE: `--dangerously-skip-permissions` is the
|
|
38
|
-
* operator's own choice for a turn that is meant to write, and a guard there
|
|
39
|
-
* would be refusing the job. Nor on PLAN_MODE_PERM, which is the CLI's own
|
|
40
|
-
* posture on the owner's own tab, not a list this file promises anything about.
|
|
41
|
-
*
|
|
42
|
-
* The hook runs as `<this node> <readGuard.mjs>` — the daemon's own
|
|
43
|
-
* `process.execPath`, never a `node` looked up on a PATH the operator may not
|
|
44
|
-
* have, because a hook that fails to START is a non-blocking error and the
|
|
45
|
-
* command runs anyway.
|
|
46
|
-
*/
|
|
47
|
-
export const READ_GUARD_PATH = fileURLToPath(new URL('./hooks/readGuard.mjs', import.meta.url));
|
|
48
|
-
const hookQuote = (s) =>
|
|
49
|
-
process.platform === 'win32' ? `"${s}"` : `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
50
|
-
export const READ_GUARD_SETTINGS = JSON.stringify({
|
|
51
|
-
hooks: {
|
|
52
|
-
PreToolUse: [
|
|
53
|
-
{
|
|
54
|
-
matcher: 'Bash',
|
|
55
|
-
hooks: [{ type: 'command', command: `${hookQuote(process.execPath)} ${hookQuote(READ_GUARD_PATH)}` }],
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
},
|
|
59
|
-
});
|
|
60
|
-
/** Spread FIRST into a curated list: `--allowedTools` is variadic, so the
|
|
61
|
-
* `--settings` pair must stand before it or be swallowed as a tool name. */
|
|
62
|
-
const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
|
|
63
|
-
|
|
64
|
-
/** A path a permission rule can name: absolute POSIX, and free of the glob and
|
|
65
|
-
* rule-syntax characters that would change what the rule means. Anything else
|
|
66
|
-
* gets no rule — `--add-dir` still admits the directory (measured). */
|
|
67
|
-
const ruleSafeAbsolute = (p) =>
|
|
68
|
-
typeof p === 'string' && p.startsWith('/') && !/[*?[\]{}()\\\n]/.test(p) ? p.replace(/\/+$/, '') : null;
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* THE READ FENCE — the worktree, plus the knowledge library by its absolute
|
|
72
|
-
* path, and nothing else on the box (2026-09-24, the audit).
|
|
73
|
-
*
|
|
74
|
-
* Every curated posture used to allow bare `Read`, `Grep` and `Glob`, and a
|
|
75
|
-
* bare allow is not "read the repo": measured on Claude Code 2.1.281, bare
|
|
76
|
-
* `Read` read a file outside the working directory and bare `Grep` searched
|
|
77
|
-
* one. So a design, consult or capture turn — each steered by words anyone who
|
|
78
|
-
* can file a card can write — could read `~/.flowviant/credentials.json`, which
|
|
79
|
-
* holds the MACHINE CREDENTIAL of every project connected on this box, and put
|
|
80
|
-
* it in an artifact, a plan note or a transcript that leaves the machine. That
|
|
81
|
-
* is a cross-project leak from a posture this file calls read-only.
|
|
82
|
-
*
|
|
83
|
-
* The fence is research's, measured the same day in the design shape:
|
|
84
|
-
* `Read(./**)` and `Glob(./**)` read the worktree; the knowledge library reads
|
|
85
|
-
* through `--add-dir` plus its own `//` rule; a file outside both was refused
|
|
86
|
-
* ("Claude requested permissions to read from …, but you haven't granted it
|
|
87
|
-
* yet"). GREP CARRIES NO ALLOW RULE AT ALL, on purpose: with none, Grep in the
|
|
88
|
-
* working directory (and in the added knowledge directory) ran, and Grep of a
|
|
89
|
-
* path outside them was refused. The Bash readers these postures keep are
|
|
90
|
-
* path-validated by the CLI on its own (see CONSULT_BASH).
|
|
91
|
-
*/
|
|
92
|
-
function fencedReads(knowledgeDir) {
|
|
93
|
-
const kd = ruleSafeAbsolute(knowledgeDir);
|
|
94
|
-
return [
|
|
95
|
-
'Read(./**)',
|
|
96
|
-
...(kd ? [`Read(/${kd}/**)`] : []),
|
|
97
|
-
'Glob(./**)',
|
|
98
|
-
...(kd ? [`Glob(/${kd}/**)`] : []),
|
|
99
|
-
];
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** The shell readers CONSULT and PLAN keep. Each one is PATH-VALIDATED by the
|
|
103
|
-
* CLI itself — measured on 2.1.281 (2026-09-24): with `Bash(cat:*)`,
|
|
104
|
-
* `Bash(head:*)` and `Bash(ls:*)` allowed, `cat`/`ls`/`head` on a file outside
|
|
105
|
-
* the working directories were each refused ("Claude Code may only
|
|
106
|
-
* concatenate files from the allowed working directories") — so it is the
|
|
107
|
-
* Read/Grep/Glob TOOLS, not these, that needed the fence above. */
|
|
108
|
-
const CONSULT_BASH = [
|
|
109
|
-
'Bash(ls:*)',
|
|
110
|
-
'Bash(wc:*)',
|
|
111
|
-
'Bash(head:*)',
|
|
112
|
-
'Bash(cat:*)',
|
|
113
|
-
'Bash(git log:*)',
|
|
114
|
-
'Bash(git show:*)',
|
|
115
|
-
'Bash(git diff:*)',
|
|
116
|
-
'Bash(git rev-parse:*)',
|
|
117
|
-
];
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* PLAN — read the repo, write the plan, never the code.
|
|
121
|
-
*
|
|
122
|
-
* The read half is `consultPermFor`'s verbatim (the read fence and the
|
|
123
|
-
* path-validated shell readers): this turn's prompt is steered by
|
|
124
|
-
* anything a project editor can type, so the same threat applies and the same
|
|
125
|
-
* allowlist answers it. What is added is the control plane and NOTHING else —
|
|
126
|
-
* `mcp__flowviant` is the plan principal's token, whose entire tool set is the
|
|
127
|
-
* five plan tools (the server refuses anything else on it). So even a fully
|
|
128
|
-
* hijacked turn's most destructive reachable act is dropping a slice from the
|
|
129
|
-
* plan it is already in, which a human can see and undo in the thread.
|
|
130
|
-
*
|
|
131
|
-
* Note what is absent versus WIKI_PERM: Write, Edit, mkdir and rm. The
|
|
132
|
-
* cartographer needs those because it authors files; a planner authors records
|
|
133
|
-
* through an API, and there is no file on this machine it has any business
|
|
134
|
-
* touching.
|
|
135
|
-
*/
|
|
136
|
-
export function planPermFor(knowledgeDir) {
|
|
137
|
-
return [
|
|
138
|
-
...READ_GUARD,
|
|
139
|
-
'--allowedTools',
|
|
140
|
-
'mcp__flowviant',
|
|
141
|
-
...fencedReads(knowledgeDir),
|
|
142
|
-
...CONSULT_BASH,
|
|
143
|
-
];
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Unattended (default) skips prompts so the agent never stalls with no terminal;
|
|
147
|
-
// FLOWVIANT_SAFE=1 restricts to a curated toolset instead.
|
|
148
|
-
const PERM = SAFE
|
|
149
|
-
? [
|
|
150
|
-
'--allowedTools',
|
|
151
|
-
'mcp__flowviant',
|
|
152
|
-
'Edit',
|
|
153
|
-
'Write',
|
|
154
|
-
'Read',
|
|
155
|
-
'Grep',
|
|
156
|
-
'Glob',
|
|
157
|
-
'Bash(git:*)',
|
|
158
|
-
'Bash(gh:*)',
|
|
159
|
-
'Bash(npm:*)',
|
|
160
|
-
'Bash(bun:*)',
|
|
161
|
-
]
|
|
162
|
-
: ['--dangerously-skip-permissions'];
|
|
163
|
-
|
|
164
|
-
// Wiki turns are read-the-repo + write-the-vault ONLY — always curated, never
|
|
165
|
-
// --dangerously-skip-permissions: no gh, no push-capable git, no package
|
|
166
|
-
// managers, and nothing that can EXECUTE arbitrary commands — no `find`
|
|
167
|
-
// (-exec/-delete) and no `git grep` (-O<pager> runs a shell; the Grep tool
|
|
168
|
-
// covers search). Command execution is the line: it enables network exfil,
|
|
169
|
-
// which plain file writes never do. `rm` IS allowed: pruning a stale vault
|
|
170
|
-
// page requires a real file deletion (that's how the sync protocol learns of
|
|
171
|
-
// it), and the blast radius is bounded — the daemon resets the repo worktree
|
|
172
|
-
// after every wiki turn, and the vault has its own git history.
|
|
173
|
-
// (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
|
|
174
|
-
const WIKI_PERM = [
|
|
175
|
-
...READ_GUARD,
|
|
176
|
-
'--allowedTools',
|
|
177
|
-
'Read',
|
|
178
|
-
'Grep',
|
|
179
|
-
'Glob',
|
|
180
|
-
'Edit',
|
|
181
|
-
'Write',
|
|
182
|
-
'Bash(ls:*)',
|
|
183
|
-
'Bash(wc:*)',
|
|
184
|
-
'Bash(head:*)',
|
|
185
|
-
'Bash(cat:*)',
|
|
186
|
-
'Bash(mkdir:*)',
|
|
187
|
-
'Bash(rm:*)',
|
|
188
|
-
'Bash(git status:*)',
|
|
189
|
-
'Bash(git log:*)',
|
|
190
|
-
'Bash(git show:*)',
|
|
191
|
-
'Bash(git diff:*)',
|
|
192
|
-
'Bash(git rev-parse:*)',
|
|
193
|
-
];
|
|
194
|
-
|
|
195
|
-
// A CONSULT reads and answers. Nothing else.
|
|
196
|
-
//
|
|
197
|
-
// It used to run on WIKI_PERM, whose comment two blocks up says the quiet part:
|
|
198
|
-
// Write/Edit "can't be path-scoped here; the worktree reset is the backstop".
|
|
199
|
-
// That is a fine trade for the cartographer, which exists to author files and
|
|
200
|
-
// gets reset after every turn. It is the wrong trade for a consult, whose prompt
|
|
201
|
-
// is steered by a question ANY project editor can write and which had no reset
|
|
202
|
-
// behind it — so a sentence in a chat box could reach Write, rm and mkdir on
|
|
203
|
-
// someone else's machine. The permission list is the enforcement; the prompt's
|
|
204
|
-
// "do not change anything" is only an instruction, and instructions are exactly
|
|
205
|
-
// what an injected question competes with.
|
|
206
|
-
export function consultPermFor(knowledgeDir) {
|
|
207
|
-
return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH];
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
|
|
212
|
-
* WRITE, and only under `.flowviant/artifacts/`.
|
|
213
|
-
*
|
|
214
|
-
* The card's words are steered by anyone who can file a card, so "change
|
|
215
|
-
* nothing" as prose is only an instruction, and the permission list is the
|
|
216
|
-
* enforcement. What each carries is the ONE write its contract needs:
|
|
217
|
-
*
|
|
218
|
-
* · `Edit(.flowviant/artifacts/**)` — PROBED on Claude Code 2.1.281 before
|
|
219
|
-
* relying on it (2026-09-23): with `--allowedTools 'Read'
|
|
220
|
-
* 'Edit(.flowviant/artifacts/**)'`, a `-p` turn told to write
|
|
221
|
-
* `.flowviant/artifacts/a.html` AND `b.txt` at the root wrote the first and
|
|
222
|
-
* was DENIED the second. The Edit rule governs every file-writing tool,
|
|
223
|
-
* Write included. Spelled `Write(.flowviant/artifacts/**)` — or `./`- or
|
|
224
|
-
* `//abs`-anchored — the same probe was denied BOTH files, so that spelling
|
|
225
|
-
* is not a scoped write at all, it is no write. Relative to the turn's cwd,
|
|
226
|
-
* which is the agent's own worktree, which is where the artifact scan looks.
|
|
227
|
-
* `**` AND NOT `*`, measured the same day: `Edit(.flowviant/artifacts/*)`
|
|
228
|
-
* let a Write to `.flowviant/artifacts/sub/b.html` through exactly as `**`
|
|
229
|
-
* did, so the single-level spelling is not narrower on this CLI and would
|
|
230
|
-
* only read as if it were. The scan keeps top-level files, so the
|
|
231
|
-
* contracts say "no subfolders" — the prompt is the only thing that can.
|
|
232
|
-
*
|
|
233
|
-
* THEY ARE NOT THE SAME READER, and the difference is the network:
|
|
234
|
-
*
|
|
235
|
-
* · DESIGN draws THIS product, so it reads the repo — the worktree and the
|
|
236
|
-
* knowledge library through the read fence (`fencedReads`, 2026-09-24:
|
|
237
|
-
* it was bare Read/Grep/Glob, which reach the whole box), Grep with no
|
|
238
|
-
* allow rule, `ls`, and `.env*` denied by name — and has no web at all (a mockup that went looking on the
|
|
239
|
-
* web would be a mockup of somebody else's product). No git readers and no
|
|
240
|
-
* cat/head/wc: Read covers every one of them, and each git reader was an
|
|
241
|
-
* `--output` away from a write (see READ_GUARD, which it still carries for
|
|
242
|
-
* the `ls` it keeps).
|
|
243
|
-
* · RESEARCH has the web — WebSearch and WebFetch — and a turn that can both
|
|
244
|
-
* read a secret and fetch a URL can send one. So its reading is FENCED to
|
|
245
|
-
* what the card is about: `Read(./**)` (the worktree) plus the project's
|
|
246
|
-
* knowledge library by its absolute path, `Glob(./**)` for names, NO Grep
|
|
247
|
-
* (an unscoped search of the home directory prints matching lines), NO
|
|
248
|
-
* Bash at all, and `.env*` DENIED by name. PROBED on 2.1.281, the whole
|
|
249
|
-
* list with `--add-dir <knowledge>`: a file in the cwd read; `/etc/hostname`,
|
|
250
|
-
* a sibling directory's file and `~/.flowviant/credentials.json` were each
|
|
251
|
-
* refused ("Claude requested permissions to read from …, but you haven't
|
|
252
|
-
* granted it yet"); `.env` was refused ("File is in a directory that is
|
|
253
|
-
* denied by your permission settings"); the knowledge file read. Two
|
|
254
|
-
* controls: WITHOUT the `--disallowedTools` pair, `Read(./**)` read `.env`
|
|
255
|
-
* — the deny is load-bearing; and a bare `Glob` listed `~/.flowviant`
|
|
256
|
-
* while `Glob(./**)` refused it and still listed the cwd and the added
|
|
257
|
-
* directory. The knowledge `Read(//…)` rule is belt: `--add-dir` alone
|
|
258
|
-
* admitted the file, and the rule keeps the read allowed should the
|
|
259
|
-
* adapter ever stop passing that flag.
|
|
260
|
-
* AND `Bash` IS DENIED BY NAME, because leaving it off the allow list is
|
|
261
|
-
* not "no Bash": measured the same day, with Bash absent from
|
|
262
|
-
* `--allowedTools` the CLI still ran `git log -1 --format=%s` and `ls` on
|
|
263
|
-
* its own read-only classifier (it did refuse `git log … --output=…`,
|
|
264
|
-
* "This command requires approval" — the reviewer's write needed the
|
|
265
|
-
* explicit `Bash(git log:*)` prefix the old list carried). With `Bash` in
|
|
266
|
-
* `--disallowedTools` the tool was not there at all.
|
|
267
|
-
*
|
|
268
|
-
* NO MCP, and no shell that could commit: the agent turn has no MCP anyway
|
|
269
|
-
* (see SYSTEM_AGENT's header), and a posture that could run `git commit` could
|
|
270
|
-
* commit the mockup it was told to keep out of git.
|
|
271
|
-
*
|
|
272
|
-
* STATED, not closed: research may still read any file in the worktree and
|
|
273
|
-
* name it in a WebFetch URL — the repository is what a research card is about,
|
|
274
|
-
* and a turn that could not read it could not answer. Design may read any
|
|
275
|
-
* file in its worktree and put it in an artifact the server stores; text
|
|
276
|
-
* artifacts are scrubbed of the machine's known secret values — and of any
|
|
277
|
-
* Flowviant credential, by shape — on the way out (artifacts.mjs, env.mjs), and
|
|
278
|
-
* binary ones are withheld when they carry one.
|
|
279
|
-
* Design still runs a plain `git log` on the CLI's own read-only classifier
|
|
280
|
-
* though no git reader is allowed (measured) — the guard is what refuses its
|
|
281
|
-
* `--output`. And the Agent tool is offered with no allow rule on this CLI:
|
|
282
|
-
* a research turn with Bash denied launched a subagent to run one, and the
|
|
283
|
-
* subagent had no Bash either (measured) — the rules are inherited. Whether
|
|
284
|
-
* the guard's hook sees a subagent's own calls was not measured here.
|
|
285
|
-
*/
|
|
286
|
-
const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
|
|
287
|
-
/**
|
|
288
|
-
* PLAN MODE (0.97.0) — a Workbench tab's `planMode` switch, and Claude Code's
|
|
289
|
-
* own `--permission-mode plan` INSTEAD OF every list above: the CLI reads,
|
|
290
|
-
* decides and answers with a plan, and refuses each write itself.
|
|
291
|
-
*
|
|
292
|
-
* PROBED on Claude Code 2.1.281 before relying on it (2026-09-23), in a scratch
|
|
293
|
-
* git repo, `-p … --output-format stream-json --verbose`:
|
|
294
|
-
*
|
|
295
|
-
* · ALONE, asked to "add a subtract(a, b) function to app.js": the init event
|
|
296
|
-
* reads `permissionMode: "plan"`, the turn Read the file, ran a read-only
|
|
297
|
-
* `find`, wrote its plan to `~/.claude/plans/<slug>.md` (the CLI's own plan
|
|
298
|
-
* file, outside the repo), tried `ExitPlanMode` and was told "Error: No such
|
|
299
|
-
* tool available: ExitPlanMode. ExitPlanMode is disabled for this session",
|
|
300
|
-
* and ended with the plan as its result text. app.js was untouched.
|
|
301
|
-
* · BESIDE `--dangerously-skip-permissions`, same ask: the init event reads
|
|
302
|
-
* `permissionMode: "bypassPermissions"` — no error, no warning — and the
|
|
303
|
-
* edit LANDED. The bypass silently wins. So this list is a REPLACEMENT for
|
|
304
|
-
* `perm`, never an addition, and a plan turn can never carry both.
|
|
305
|
-
* · WITH `--mcp-config` and `--allowedTools mcp__flowviant`: the server
|
|
306
|
-
* connected and its tools were listed, but a call was refused — "Cannot
|
|
307
|
-
* call mcp__flowviant__log_work while in plan mode." — and without the
|
|
308
|
-
* `--allowedTools` entry, "Claude requested permissions to use
|
|
309
|
-
* mcp__flowviant__log_work, but you haven't granted it yet." Plan mode
|
|
310
|
-
* admitted the call ONLY when the server annotated the tool
|
|
311
|
-
* `readOnlyHint: true`, and none of the session tools (stream_session_turn,
|
|
312
|
-
* log_work, file_card …) are read-only or annotated so. So a plan turn runs
|
|
313
|
-
* PLAIN — no MCP, the plain tab's contract — rather than mounting a control
|
|
314
|
-
* plane whose every call the CLI refuses. See work.mjs.
|
|
315
|
-
*/
|
|
316
|
-
export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
|
|
317
|
-
export function designPermFor(knowledgeDir) {
|
|
318
|
-
return [
|
|
319
|
-
...READ_GUARD,
|
|
320
|
-
'--allowedTools',
|
|
321
|
-
...fencedReads(knowledgeDir),
|
|
322
|
-
'Bash(ls:*)',
|
|
323
|
-
ARTIFACT_WRITE,
|
|
324
|
-
// A mockup is uploaded and runs scripts in a frame that may navigate
|
|
325
|
-
// itself, so the checkout's own secrets are denied by name too — the same
|
|
326
|
-
// pair research carries, measured load-bearing there.
|
|
327
|
-
'--disallowedTools',
|
|
328
|
-
'Read(./.env*)',
|
|
329
|
-
'Read(./**/.env*)',
|
|
330
|
-
];
|
|
331
|
-
}
|
|
332
|
-
export const DESIGN_PERM = designPermFor(null);
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* RESEARCH, built at spawn — the knowledge library is the one directory
|
|
336
|
-
* outside the worktree it may read, and only the spawn knows where that is.
|
|
337
|
-
* `//` is the CLI's own spelling for an absolute path in a rule.
|
|
338
|
-
*/
|
|
339
|
-
export function researchPerm(knowledgeDir) {
|
|
340
|
-
return [
|
|
341
|
-
...READ_GUARD,
|
|
342
|
-
'--allowedTools',
|
|
343
|
-
...fencedReads(knowledgeDir),
|
|
344
|
-
'WebSearch',
|
|
345
|
-
'WebFetch',
|
|
346
|
-
ARTIFACT_WRITE,
|
|
347
|
-
'--disallowedTools',
|
|
348
|
-
'Read(./.env*)',
|
|
349
|
-
'Read(./**/.env*)',
|
|
350
|
-
'Bash',
|
|
351
|
-
];
|
|
352
|
-
}
|
|
353
|
-
/** The list with no library — what a project that keeps none runs under. */
|
|
354
|
-
export const RESEARCH_PERM = researchPerm(null);
|
|
17
|
+
import { runtimeById } from './runtimes.mjs';
|
|
355
18
|
|
|
356
19
|
export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
|
357
20
|
|
|
@@ -393,507 +56,7 @@ export const blockedId = (out) => {
|
|
|
393
56
|
*/
|
|
394
57
|
export function mcpFor(runtimeId, token, mcpUrl) {
|
|
395
58
|
const rt = runtimeById(runtimeId);
|
|
59
|
+
if (!rt) throw new Error(`runtime '${runtimeId}' is not one this daemon knows — update flowviant`);
|
|
396
60
|
if (!rt.mcp) throw new Error(`runtime '${rt.id}' cannot take an MCP server: ${rt.blocked}`);
|
|
397
61
|
return rt.mcp(token, mcpUrl);
|
|
398
62
|
}
|
|
399
|
-
|
|
400
|
-
// Turn one Claude tool_use into a compact activity {kind, label}, or null for
|
|
401
|
-
// tools not worth surfacing. `kind:'read'` is what the file counter counts; a
|
|
402
|
-
// Write/Edit of a vault page is the "writing" signal. Used by wiki turns to
|
|
403
|
-
// stream exactly which files Claude is touching (daemon console + app cover).
|
|
404
|
-
//
|
|
405
|
-
// The body moved to runtimes.mjs, beside Codex's equivalent, because they are
|
|
406
|
-
// the same job for two vendors and keeping them apart is how the two activity
|
|
407
|
-
// vocabularies drift. Re-exported under its original name: a dozen call sites
|
|
408
|
-
// know it, and none of them care where it lives.
|
|
409
|
-
export const humanizeToolUse = humanizeClaudeTool;
|
|
410
|
-
|
|
411
|
-
// Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
|
|
412
|
-
const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* WHAT THIS TURN COST, IN THE CLI'S OWN NUMBERS (2026-09-19).
|
|
416
|
-
*
|
|
417
|
-
* The `result` event carries a `usage` object — `input_tokens`,
|
|
418
|
-
* `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` —
|
|
419
|
-
* and this reads exactly those four and nothing else. A RELAY: nothing here
|
|
420
|
-
* derives, estimates or prices anything.
|
|
421
|
-
*
|
|
422
|
-
* DELIBERATELY NOT `total_cost_usd`, which rides the same event. It is a
|
|
423
|
-
* notional list price that a subscription operator did not pay, so relaying it
|
|
424
|
-
* as "cost" would be the product asserting a figure nobody was charged — the
|
|
425
|
-
* refusal the board's fix pass already made once against the mock's "1.4
|
|
426
|
-
* spent". Tokens are measured; dollars are not.
|
|
427
|
-
*
|
|
428
|
-
* COERCED AND FLOORED, never trusted: a non-number, a NaN, an Infinity or a
|
|
429
|
-
* negative reads as 0, because these are summed into a counter that only ever
|
|
430
|
-
* goes up and one bad field must not poison the other three.
|
|
431
|
-
*
|
|
432
|
-
* Returns null when there is no usage object at all — the three-state rule
|
|
433
|
-
* every readout here keeps: a turn that reported nothing charges nothing, and
|
|
434
|
-
* that is not the same as a turn that reported zeros.
|
|
435
|
-
*/
|
|
436
|
-
export function usageFromResult(ev) {
|
|
437
|
-
const u = ev?.usage;
|
|
438
|
-
if (!u || typeof u !== 'object') return null;
|
|
439
|
-
const n = (v) => {
|
|
440
|
-
const x = Number(v);
|
|
441
|
-
return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0;
|
|
442
|
-
};
|
|
443
|
-
return {
|
|
444
|
-
input: n(u.input_tokens),
|
|
445
|
-
output: n(u.output_tokens),
|
|
446
|
-
cacheCreate: n(u.cache_creation_input_tokens),
|
|
447
|
-
cacheRead: n(u.cache_read_input_tokens),
|
|
448
|
-
};
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Parse ONE line of `--output-format stream-json` NDJSON into feed activities.
|
|
452
|
-
// Surfaces the WHOLE turn — thinking, narration, AND every tool — so neither the
|
|
453
|
-
// daemon console nor the app cover goes dark while Claude reasons (Opus thinks in
|
|
454
|
-
// bursts before/between tools; emitting only tools left long silent gaps).
|
|
455
|
-
// Assistant text is also folded into `out` so the WIKI_DONE/REGROUND_DONE
|
|
456
|
-
// sentinels still match. A non-JSON line (a stray warning) is kept as raw text.
|
|
457
|
-
//
|
|
458
|
-
// `answerFromResult` narrows that last part for callers whose `out` IS the
|
|
459
|
-
// answer rather than a haystack to match sentinels in (a Workbench tab's turn):
|
|
460
|
-
// every intermediate text block still NARRATES, but only the final `result`
|
|
461
|
-
// event contributes text — otherwise the same sentences arrive twice, once as
|
|
462
|
-
// they stream and once in the result, and the tab posts the duplicate.
|
|
463
|
-
//
|
|
464
|
-
// EXPORTED FOR ITS TEST ONLY (2026-09-19), and the reason is worth stating: the
|
|
465
|
-
// `onUsage` threading through this function, `runTurn`'s options and `onLine`
|
|
466
|
-
// was four edits and NOT ONE of them was reachable from either suite — delete
|
|
467
|
-
// any one and both stay green, while the only symptom in production is a
|
|
468
|
-
// container that reports no spend, which is indistinguishable from an older
|
|
469
|
-
// daemon by design. A callback that is silent when it breaks has to be called
|
|
470
|
-
// directly by something.
|
|
471
|
-
export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage }) {
|
|
472
|
-
let ev;
|
|
473
|
-
try {
|
|
474
|
-
ev = JSON.parse(line);
|
|
475
|
-
} catch {
|
|
476
|
-
appendText(line + '\n');
|
|
477
|
-
emit(line + '\n');
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
const push = (a) => {
|
|
481
|
-
if (!a || !a.label) return;
|
|
482
|
-
emit(a.label + '\n');
|
|
483
|
-
onActivity?.(a);
|
|
484
|
-
};
|
|
485
|
-
if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
|
|
486
|
-
for (const b of ev.message.content) {
|
|
487
|
-
if (b.type === 'thinking' || b.type === 'redacted_thinking') {
|
|
488
|
-
/**
|
|
489
|
-
* NO THINKING TEXT ARRIVES TODAY, AND THAT IS MEASURED (2026-09-16).
|
|
490
|
-
*
|
|
491
|
-
* This used to say the text is "usually redacted", which was a guess
|
|
492
|
-
* doing the work of a fact. The fact: across three real transcripts, 93
|
|
493
|
-
* thinking blocks, EVERY ONE of them carried an empty `thinking` — and
|
|
494
|
-
* a live probe with MAX_THINKING_TOKENS set and
|
|
495
|
-
* `--include-partial-messages` on returned an empty `thinking_delta`
|
|
496
|
-
* and a complete block of length zero (signature only). So the CLI
|
|
497
|
-
* emits the FACT that it reasoned and not a word of the reasoning, and
|
|
498
|
-
* "show the full thinking" cannot be conjured from this stream.
|
|
499
|
-
*
|
|
500
|
-
* THE BRANCH BELOW EXISTS ANYWAY, and deliberately. The shape is the
|
|
501
|
-
* whole point: when text is present it rides `full` UNCLIPPED, so the
|
|
502
|
-
* day a CLI release starts emitting it, the trace carries the thought
|
|
503
|
-
* whole with no daemon change and no version floor — the report's own
|
|
504
|
-
* presence is the capability. Until then every block takes the marker
|
|
505
|
-
* arm, and `trace.mjs` collapses a run of identical markers so the
|
|
506
|
-
* absence reads as one quiet step rather than forty.
|
|
507
|
-
*/
|
|
508
|
-
push(
|
|
509
|
-
b.thinking
|
|
510
|
-
? { kind: 'think', label: `thinking: ${oneLine(b.thinking)}`, full: b.thinking }
|
|
511
|
-
: { kind: 'think', label: THINK_MARKER }
|
|
512
|
-
);
|
|
513
|
-
} else if (b.type === 'text' && b.text?.trim()) {
|
|
514
|
-
if (!answerFromResult) appendText(b.text + '\n');
|
|
515
|
-
// `label` for the console and the pulse — one collapsed 160-char line,
|
|
516
|
-
// byte-identical to what it has always printed. `full` for the trace:
|
|
517
|
-
// a `say` is the agent NARRATING, several sentences at a time, and the
|
|
518
|
-
// clip at 160 was landing mid-sentence on the one thing a person opens
|
|
519
|
-
// the page to read.
|
|
520
|
-
push({ kind: 'say', label: oneLine(b.text), full: b.text });
|
|
521
|
-
} else if (b.type === 'tool_use') {
|
|
522
|
-
push(humanizeToolUse(b.name, b.input || {}, cwd));
|
|
523
|
-
// The STRUCTURED form of the same event, for the transcript's tool
|
|
524
|
-
// cards — raw name + input, so the collector can keep what the
|
|
525
|
-
// one-line humanizer drops (an Edit's counts, the plan's items).
|
|
526
|
-
onToolEvent?.(b.name, b.input || {});
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
} else if (ev.type === 'system' && ev.subtype === 'init') {
|
|
530
|
-
// WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
|
|
531
|
-
// CLI's OWN answer — it has already resolved personal skills, this repo's
|
|
532
|
-
// skills, plugins and whatever the project settings enable or disable — so
|
|
533
|
-
// reading it costs nothing and cannot drift the way a `~/.claude/skills`
|
|
534
|
-
// scan of our own would. `skills` (rather than `slash_commands`) is the
|
|
535
|
-
// deliberate narrowing: the 50-odd commands beside it are the CLI's own
|
|
536
|
-
// interactive furniture (/clear, /model, /compact), and offering those in a
|
|
537
|
-
// relayed tab would be an offer wired to nothing.
|
|
538
|
-
//
|
|
539
|
-
// Only ever REPORTED, never enforced. Flowviant does not decide what your
|
|
540
|
-
// Claude can do; it relays what your Claude said it has.
|
|
541
|
-
// `sessionId` rides along for one reason: a `-p` turn WRITES a transcript,
|
|
542
|
-
// and `localSessions.mjs` offers the newest ended session per directory as
|
|
543
|
-
// ADOPTABLE — so any headless turn we run for our own purposes would leave
|
|
544
|
-
// a phantom untitled session in the `+` menu. The caller that needs to
|
|
545
|
-
// clean up after itself cannot do so without this id.
|
|
546
|
-
//
|
|
547
|
-
// `mcpServers` (0.97.0) is the same kind of fact from the same event: the
|
|
548
|
-
// MCP servers and claude.ai connectors the CLI mounted, each with its own
|
|
549
|
-
// status (`needs-auth` above all — a sign-in that has to happen at this
|
|
550
|
-
// box). Relayed raw; `recordMcpServers` (runtimes.mjs) normalises it and
|
|
551
|
-
// drops this daemon's own `flowviant` server.
|
|
552
|
-
if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
|
|
553
|
-
onInit?.({
|
|
554
|
-
skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
|
|
555
|
-
sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
|
|
556
|
-
mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : undefined,
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
} else if (ev.type === 'result') {
|
|
560
|
-
/**
|
|
561
|
-
* WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
|
|
562
|
-
*
|
|
563
|
-
* FIRED ON A FAILED RESULT TOO, and that ordering is the point: a turn that
|
|
564
|
-
* hit a limit, ran out of permission or aborted still sent the requests it
|
|
565
|
-
* sent, and a spend readout that quietly skipped every unhappy turn would
|
|
566
|
-
* under-report exactly the runs somebody is looking at the number to
|
|
567
|
-
* understand. `usageFromResult` returns null when the event carries no
|
|
568
|
-
* usage at all, and a caller that passes no `onUsage` sees no change.
|
|
569
|
-
*/
|
|
570
|
-
const usage = usageFromResult(ev);
|
|
571
|
-
if (usage) onUsage?.(usage);
|
|
572
|
-
// The final assistant text (carries WIKI_DONE / REGROUND_DONE).
|
|
573
|
-
if (typeof ev.result === 'string') appendText(ev.result + '\n');
|
|
574
|
-
else if (ev.is_error || ev.subtype) {
|
|
575
|
-
// A result that carries no text is a FAILED turn (a limit, a refused
|
|
576
|
-
// permission, an aborted run). Under `answerFromResult` this is the only
|
|
577
|
-
// stdout that would have said so, and a caller whose `out` is the answer
|
|
578
|
-
// must not report "no output" for a turn that explained itself.
|
|
579
|
-
// `errors[]` FIRST, because it is where the real sentence is. Claude
|
|
580
|
-
// Code reports a dead `--resume` id as
|
|
581
|
-
// `{subtype:'error_during_execution', errors:['No conversation found
|
|
582
|
-
// with session ID: …']}` — reading only `error`/`subtype` dropped that
|
|
583
|
-
// and appended the literal string `error_during_execution`, which told
|
|
584
|
-
// the driver nothing and hid the one phrase the caller needs to
|
|
585
|
-
// recognise a lost conversation.
|
|
586
|
-
const listed = Array.isArray(ev.errors)
|
|
587
|
-
? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
|
|
588
|
-
: '';
|
|
589
|
-
const msg = listed || ev.error?.message || ev.error || ev.subtype;
|
|
590
|
-
appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* THE ENVIRONMENT A CLI TURN IS SPAWNED WITH — the daemon's own, plus the
|
|
597
|
-
* turn's MCP token, MINUS the machine credential (2026-09-24, the audit).
|
|
598
|
-
*
|
|
599
|
-
* A turn cannot be handed a curated environment the way `childEnv` builds one
|
|
600
|
-
* for a deploy: the CLI's own sign-in lives in this environment, and stripping
|
|
601
|
-
* it signs the CLI out. But on a headless box started as
|
|
602
|
-
* `FLOWVIANT_MACHINE_TOKEN=… npx flowviant` the MACHINE CREDENTIAL lives here
|
|
603
|
-
* too, and every turn — and every dev server and script a turn starts, which
|
|
604
|
-
* inherit it — could read it with one `env`. Nothing a CLI or its children do
|
|
605
|
-
* needs it: the daemon authenticates to the server itself, and a turn that
|
|
606
|
-
* needs the project's tools gets its own per-turn token through `mcpEnv` or
|
|
607
|
-
* the MCP config file. So those two names, and only those, are removed.
|
|
608
|
-
*/
|
|
609
|
-
const MACHINE_CREDENTIAL_ENV = ['FLOWVIANT_MACHINE_TOKEN', 'FLOWVIANT_FLEET'];
|
|
610
|
-
export function cliEnv(mcpEnv) {
|
|
611
|
-
const env = { ...process.env, ...(mcpEnv ?? {}) };
|
|
612
|
-
for (const k of MACHINE_CREDENTIAL_ENV) delete env[k];
|
|
613
|
-
return env;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// One Claude Code turn. Output is captured (for sentinel detection) and streamed
|
|
617
|
-
// through, line-prefixed with the worker label so a fleet stays legible.
|
|
618
|
-
//
|
|
619
|
-
// `streamJson` switches to `--output-format stream-json` and parses the event
|
|
620
|
-
// stream: only the humanized tool activity reaches the console (a legible
|
|
621
|
-
// stream of `read …`, `grep …`, `+ node …`), assistant text is folded into the
|
|
622
|
-
// returned string for sentinel detection, and each activity is handed to
|
|
623
|
-
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
624
|
-
// off and keep the raw text passthrough + line sentinels.
|
|
625
|
-
export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, onAnswer, wikiPerm, readOnly, planPerm, planMode, posture, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
|
|
626
|
-
return new Promise((resolve) => {
|
|
627
|
-
const rt = runtimeById(runtime);
|
|
628
|
-
// PLAN MODE IS CLAUDE'S (0.97.0). The other adapters build their argv
|
|
629
|
-
// from `profile` and never read `perm`, so a codex or agy turn handed
|
|
630
|
-
// `planMode` would run a BUILD turn wearing the word "plan". The caller
|
|
631
|
-
// refuses first, in words; this is the belt — fail the turn, never
|
|
632
|
-
// substitute.
|
|
633
|
-
if (planMode && rt.id !== 'claude') {
|
|
634
|
-
console.error(`\nerror: plan mode runs on Claude Code only — not '${rt.label}'`);
|
|
635
|
-
resolve('');
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
// …AND SO ARE THE TWO NON-CODE POSTURES, for the same reason and in the
|
|
639
|
-
// same shape. The agent lane asks `canRun` first and settles `nothing` in
|
|
640
|
-
// words; this is the layer no caller can skip. A codex or agy adapter
|
|
641
|
-
// handed `posture: 'design'` never reads `perm` — it builds from `profile`
|
|
642
|
-
// and runs whatever that falls back to, which for codex is its build
|
|
643
|
-
// branch, `--sandbox danger-full-access`: a research card's words with the
|
|
644
|
-
// whole machine to act on. Asked against the runtime's own declared profiles,
|
|
645
|
-
// so the day an adapter learns to express one this opens by itself.
|
|
646
|
-
if ((posture === 'design' || posture === 'research') && !(rt.profiles ?? []).includes(posture)) {
|
|
647
|
-
console.error(`\nerror: a ${posture} card runs on Claude Code only — not '${rt.label}'`);
|
|
648
|
-
resolve('');
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
if (!rt.args) {
|
|
652
|
-
// Reached only if a brief names a runtime this daemon declares but cannot
|
|
653
|
-
// drive. Fail as a turn with no sentinel — the loop already treats that as
|
|
654
|
-
// "the protocol did not complete" and retries, rather than inventing a
|
|
655
|
-
// completion for work that never started.
|
|
656
|
-
console.error(`\nerror: cannot run '${rt.label}' — ${rt.blocked}`);
|
|
657
|
-
resolve('');
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
// Pin the model — never inherit the user's global default (which for Claude
|
|
661
|
-
// may be a 1M/long-context tier their subscription can't bill autonomous
|
|
662
|
-
// work on). A per-task override (chosen in the app, validated server-side
|
|
663
|
-
// against a fixed list before it ever reaches this argv) wins over the
|
|
664
|
-
// machine pin; absent, the pin stands. Effort has no machine-level pin at
|
|
665
|
-
// all: unset means the CLI's own default, the honest resting state.
|
|
666
|
-
//
|
|
667
|
-
// readOnly wins over wikiPerm: a consult must never inherit write tools.
|
|
668
|
-
//
|
|
669
|
-
// TWO FORMS OF THE SAME DECISION, and the redundancy is deliberate rather
|
|
670
|
-
// than sloppy. `profile` is the NAME of the posture — a promise about what
|
|
671
|
-
// must be impossible during the turn — and every runtime expresses it in its
|
|
672
|
-
// own vocabulary: Claude as an `--allowedTools` verb list, Codex as a kernel
|
|
673
|
-
// sandbox mode plus feature toggles. `perm` is Claude's expression, still
|
|
674
|
-
// computed here only because those three arrays live in this file; it
|
|
675
|
-
// collapses into the registry the day every runtime expresses every profile.
|
|
676
|
-
// Both derive from the same branch, so they cannot disagree about which
|
|
677
|
-
// posture a turn is running under.
|
|
678
|
-
// `plan` is asked FIRST, above readOnly, because it is the narrower promise
|
|
679
|
-
// of the two and a planning turn that fell through to 'consult' would lose
|
|
680
|
-
// the control plane it exists to use — it would read the repo, decide what
|
|
681
|
-
// the slices are, and have no way to write any of them down.
|
|
682
|
-
// A DESIGN OR RESEARCH CARD'S POSTURE (0.97.0) is asked before all of
|
|
683
|
-
// them, by NAME, because it is the narrowest promise here: write only the
|
|
684
|
-
// artifacts directory. It is set only by the agent lane, from the card's
|
|
685
|
-
// kind; every other caller passes nothing and gets exactly the branch it
|
|
686
|
-
// got before this existed.
|
|
687
|
-
const profile =
|
|
688
|
-
posture === 'design' || posture === 'research'
|
|
689
|
-
? posture
|
|
690
|
-
: planPerm ? 'plan' : readOnly ? 'consult' : wikiPerm ? 'wiki' : 'build';
|
|
691
|
-
const args = rt.args({
|
|
692
|
-
prompt,
|
|
693
|
-
system,
|
|
694
|
-
model,
|
|
695
|
-
effort,
|
|
696
|
-
resume,
|
|
697
|
-
streamJson,
|
|
698
|
-
profile,
|
|
699
|
-
agentTools,
|
|
700
|
-
// Adopting a terminal session (work.mjs): Claude turns it into
|
|
701
|
-
// `--resume <id> --fork-session` (a FORK — the original is untouched);
|
|
702
|
-
// agy turns it into `--conversation <id>` (a MOVE — agy has no fork, the
|
|
703
|
-
// tab continues the terminal conversation itself). Codex THROWS on it,
|
|
704
|
-
// so a mis-wired adoption fails as a loud turn error rather than a
|
|
705
|
-
// silent fresh conversation wearing an adopted session's name.
|
|
706
|
-
adoptResumeId,
|
|
707
|
-
// Only the wiki profile uses it, but it is passed unconditionally: a
|
|
708
|
-
// runtime that can path-scope its writes needs to know WHERE the vault is,
|
|
709
|
-
// and Claude — which cannot — simply ignores it.
|
|
710
|
-
vaultDir,
|
|
711
|
-
// THE PROJECT'S KNOWLEDGE LIBRARY (0.94.0), when this box holds one. It
|
|
712
|
-
// lives in the CHECKOUT and a turn usually runs in a worktree, so the
|
|
713
|
-
// prompt hands an absolute path OUTSIDE the cwd — and a curated Claude
|
|
714
|
-
// profile (the capture chat's read-only list, `FLOWVIANT_SAFE=1`) may
|
|
715
|
-
// refuse a read there. `--add-dir` says the directory is one it may read.
|
|
716
|
-
// Only Claude's adapter uses it; codex's sandboxes read the filesystem
|
|
717
|
-
// already, and agy's `--add-dir` is spent on the wiki vault.
|
|
718
|
-
knowledgeDir,
|
|
719
|
-
// Structured-output flags for the MEDIATED path. Handed to the adapter
|
|
720
|
-
// rather than appended here for the same reason `mcp` is: Codex takes its
|
|
721
|
-
// prompt as a trailing positional, so a flag after it is in the wrong
|
|
722
|
-
// place.
|
|
723
|
-
resultSchemaArgs,
|
|
724
|
-
// PLAN MODE REPLACES THE POSTURE, never joins it: beside
|
|
725
|
-
// `--dangerously-skip-permissions` the bypass wins silently (measured —
|
|
726
|
-
// see PLAN_MODE_PERM).
|
|
727
|
-
perm: planMode
|
|
728
|
-
? PLAN_MODE_PERM
|
|
729
|
-
: profile === 'design'
|
|
730
|
-
? designPermFor(knowledgeDir)
|
|
731
|
-
: profile === 'research'
|
|
732
|
-
? researchPerm(knowledgeDir)
|
|
733
|
-
: planPerm
|
|
734
|
-
? planPermFor(knowledgeDir)
|
|
735
|
-
: readOnly
|
|
736
|
-
? consultPermFor(knowledgeDir)
|
|
737
|
-
: wikiPerm ? WIKI_PERM : PERM,
|
|
738
|
-
// Handed to the adapter rather than appended here, because WHERE these go
|
|
739
|
-
// is a property of the CLI: Codex reads its prompt as a trailing
|
|
740
|
-
// positional, so a flag after it is a flag in the wrong place.
|
|
741
|
-
// Wiki-vault turns are pure file work and pass neither — no MCP at all.
|
|
742
|
-
mcp: planMode ? ['--strict-mcp-config'] : mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
|
|
743
|
-
// Resuming a SPECIFIC held conversation by its own id (work.mjs, codex
|
|
744
|
-
// sessions). Runtimes without a by-id resume ignore it and keep their
|
|
745
|
-
// `resume` behavior unchanged.
|
|
746
|
-
resumeThreadId,
|
|
747
|
-
// agy's by-id resume (work.mjs, antigravity sessions): the conversation
|
|
748
|
-
// id learned from the adopt hint or the cwd registry after a turn.
|
|
749
|
-
resumeConversationId,
|
|
750
|
-
});
|
|
751
|
-
// Whatever this machine is signed in with, we use. We do NOT pick.
|
|
752
|
-
//
|
|
753
|
-
// This used to delete ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN to force
|
|
754
|
-
// the subscription path, which was right when the daemon ran on a
|
|
755
|
-
// developer's laptop: a key left in their shell would silently bill every
|
|
756
|
-
// turn as raw API usage instead of the plan they were already paying for.
|
|
757
|
-
// On a machine the project leaves running, an inherited org key is the
|
|
758
|
-
// POINT — deleting it is the daemon overriding the credential its operator
|
|
759
|
-
// deliberately configured.
|
|
760
|
-
//
|
|
761
|
-
// Which credential is correct, and whether an account may be shared, is
|
|
762
|
-
// between the operator and the vendor. Flowviant does not detect it and does
|
|
763
|
-
// not enforce it; it runs the CLI the ordinary way and relays what happens.
|
|
764
|
-
const child = spawn(rt.bin, args, {
|
|
765
|
-
cwd,
|
|
766
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
767
|
-
/**
|
|
768
|
-
* ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
|
|
769
|
-
*
|
|
770
|
-
* Everything the CLI spawns inherits this pgid and KEEPS it through
|
|
771
|
-
* `nohup` and `setsid` — which is exactly when attribution by descendancy
|
|
772
|
-
* fails, because reparenting to init breaks the ppid chain the moment a
|
|
773
|
-
* process becomes long-running. `processes.mjs` reads the group; the
|
|
774
|
-
* Workbench renders it.
|
|
775
|
-
*
|
|
776
|
-
* TEARDOWN IS DELIBERATELY UNCHANGED: `shutdownWork` still SIGTERMs this
|
|
777
|
-
* CHILD and never the group. Signalling the group would kill the dev
|
|
778
|
-
* server the driver started every time the daemon restarts — including
|
|
779
|
-
* on an ordinary auto-update, unattended — which is the outcome the
|
|
780
|
-
* deleted dev-run supervisor spent a whole registry avoiding. Flowviant
|
|
781
|
-
* does not manage those processes; it reports them.
|
|
782
|
-
*
|
|
783
|
-
* Not `unref`'d: the daemon must still wait on this turn.
|
|
784
|
-
*/
|
|
785
|
-
detached: true,
|
|
786
|
-
// Only ADDS to the environment (the worker token, for runtimes that read
|
|
787
|
-
// it from there). Never replaces it: the CLI's own credentials live in
|
|
788
|
-
// this environment, and handing it a curated one signs it out. The one
|
|
789
|
-
// thing it REMOVES is this daemon's machine credential — see `cliEnv`.
|
|
790
|
-
env: cliEnv(mcpEnv),
|
|
791
|
-
});
|
|
792
|
-
onSpawn?.(child);
|
|
793
|
-
let out = '';
|
|
794
|
-
const pfx = label ? `${label} ` : '';
|
|
795
|
-
const emit = (s) => process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
|
|
796
|
-
|
|
797
|
-
// A runtime with its own parser is ALWAYS line-parsed — for Codex the JSONL
|
|
798
|
-
// stream is the only output there is, so treating it as raw text would print
|
|
799
|
-
// event objects at the operator and, worse, hand the sentinel matcher a
|
|
800
|
-
// string containing every word the model reasoned about.
|
|
801
|
-
const lineParsed = streamJson || Boolean(rt.parse);
|
|
802
|
-
if (lineParsed) {
|
|
803
|
-
let buf = '';
|
|
804
|
-
const appendText = (t) => {
|
|
805
|
-
out += t;
|
|
806
|
-
};
|
|
807
|
-
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
808
|
-
const onLine = (line) => {
|
|
809
|
-
if (!rt.parse)
|
|
810
|
-
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage });
|
|
811
|
-
const ev = rt.parse(line, cwd);
|
|
812
|
-
if (!ev) return;
|
|
813
|
-
// The conversation id, when the runtime announces one (codex's
|
|
814
|
-
// thread.started). Purely additive: callers that pass no onThreadId —
|
|
815
|
-
// every dispatch path — see zero behavior change.
|
|
816
|
-
if (ev.threadId) onThreadId?.(ev.threadId);
|
|
817
|
-
// The runtime's own token count (codex's `turn.completed`) — the same
|
|
818
|
-
// `onUsage` Claude's result event feeds, so a caller charges a turn
|
|
819
|
-
// identically whichever CLI ran it.
|
|
820
|
-
if (ev.usage) onUsage?.(ev.usage);
|
|
821
|
-
// One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
|
|
822
|
-
// wins at the caller, which is what "the final answer" means.
|
|
823
|
-
if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
|
|
824
|
-
if (ev.text) appendText(ev.text);
|
|
825
|
-
if (ev.activity) {
|
|
826
|
-
emit(`${ev.activity.label}\n`);
|
|
827
|
-
onActivity?.(ev.activity);
|
|
828
|
-
}
|
|
829
|
-
};
|
|
830
|
-
// DECODED AS A STREAM, never chunk by chunk (2026-09-24, the audit): a
|
|
831
|
-
// pipe read can end inside a multi-byte character — the em dash models
|
|
832
|
-
// write constantly — and `d.toString()` per chunk turned each half into
|
|
833
|
-
// U+FFFD, which JSON.parse accepts and the reply then carried verbatim.
|
|
834
|
-
// `setEncoding` holds the partial bytes over to the next chunk.
|
|
835
|
-
child.stdout.setEncoding('utf8');
|
|
836
|
-
child.stderr.setEncoding('utf8');
|
|
837
|
-
child.stdout.on('data', (d) => {
|
|
838
|
-
buf += d;
|
|
839
|
-
let nl;
|
|
840
|
-
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
841
|
-
const line = buf.slice(0, nl);
|
|
842
|
-
buf = buf.slice(nl + 1);
|
|
843
|
-
if (line.trim()) onLine(line);
|
|
844
|
-
}
|
|
845
|
-
});
|
|
846
|
-
// stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
|
|
847
|
-
child.stderr.on('data', (s) => {
|
|
848
|
-
out += s;
|
|
849
|
-
emit(s);
|
|
850
|
-
});
|
|
851
|
-
child.on('error', (e) => {
|
|
852
|
-
if (e.code === 'ENOENT') {
|
|
853
|
-
// A MISSING CLI FAILS THE TURN, NOT THE DAEMON. This called
|
|
854
|
-
// process.exit(1), which was defensible while `claude` was the only
|
|
855
|
-
// runtime and preflight refused to start without it — the process
|
|
856
|
-
// could not reach here. Both halves of that are gone: preflight is now
|
|
857
|
-
// fatal only when NOTHING is drivable, so a Codex-only machine starts
|
|
858
|
-
// legitimately, and the wiki/plan-check/consult turns still ask for
|
|
859
|
-
// Claude by default. On such a machine the first wiki sweep would have
|
|
860
|
-
// killed the whole daemon, taking every in-flight build with it,
|
|
861
|
-
// because one background job could not find one binary.
|
|
862
|
-
console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
|
|
863
|
-
resolve('');
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
console.error(e);
|
|
867
|
-
resolve(out);
|
|
868
|
-
});
|
|
869
|
-
child.on('close', () => {
|
|
870
|
-
if (buf.trim()) onLine(buf);
|
|
871
|
-
resolve(out);
|
|
872
|
-
});
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
const onChunk = (s) => {
|
|
877
|
-
out += s;
|
|
878
|
-
emit(s);
|
|
879
|
-
};
|
|
880
|
-
// Stream-decoded for the same reason as the line-parsed path above.
|
|
881
|
-
child.stdout.setEncoding('utf8');
|
|
882
|
-
child.stderr.setEncoding('utf8');
|
|
883
|
-
child.stdout.on('data', onChunk);
|
|
884
|
-
child.stderr.on('data', onChunk);
|
|
885
|
-
child.on('error', (e) => {
|
|
886
|
-
if (e.code === 'ENOENT') {
|
|
887
|
-
// A MISSING CLI FAILS THE TURN, NOT THE DAEMON — same fix as the
|
|
888
|
-
// line-parsed path above; this raw-output duplicate used to
|
|
889
|
-
// process.exit(1) and take every in-flight worker down with it.
|
|
890
|
-
console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
|
|
891
|
-
resolve('');
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
console.error(e);
|
|
895
|
-
resolve(out);
|
|
896
|
-
});
|
|
897
|
-
child.on('close', () => resolve(out));
|
|
898
|
-
});
|
|
899
|
-
}
|