flowviant 0.116.0 → 0.117.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/README.md +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +71 -139
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
package/bin/lib/repoState.mjs
CHANGED
|
@@ -31,29 +31,26 @@
|
|
|
31
31
|
* repo mid-rebase or an unborn HEAD is a field to omit, not an error to raise.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import { execFileSync } from 'node:child_process';
|
|
35
34
|
import { measureListeners, listenersSupported } from './listeners.mjs';
|
|
35
|
+
import { git, GIT_WIDE_BUFFER, isSessionBranch } from './git.mjs';
|
|
36
36
|
|
|
37
37
|
/** Same cap the session diffstat uses: enough to see the shape, small enough
|
|
38
38
|
* that one machine cannot flood a row. */
|
|
39
39
|
const MAX_BRANCHES = 60;
|
|
40
40
|
const MAX_WORKTREES = 40;
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}).trim();
|
|
50
|
-
}
|
|
42
|
+
/** Every read here is BOUNDED and QUIET: a best-effort report in the poll
|
|
43
|
+
* loop must neither hold the event loop on a wedged git nor print git's
|
|
44
|
+
* complaints about a repo mid-rebase onto the daemon's terminal. git.mjs's
|
|
45
|
+
* own `git()`, with those options (audit 2026-10-10, F107) — this module
|
|
46
|
+
* used to declare a private `function git` of the same name with different
|
|
47
|
+
* semantics, so a reader could not tell which one a call meant. */
|
|
48
|
+
const READ = Object.freeze({ timeout: 10_000, maxBuffer: GIT_WIDE_BUFFER, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
51
49
|
|
|
52
|
-
/** A ref Flowviant itself cut for a tab
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
50
|
+
/** A ref Flowviant itself cut for a tab — the distinction the whole report
|
|
51
|
+
* exists to draw. Defined beside the branch-name writer in git.mjs (audit
|
|
52
|
+
* 2026-10-10, F122) and re-exported here for this report's readers. */
|
|
53
|
+
export { isSessionBranch };
|
|
57
54
|
|
|
58
55
|
/**
|
|
59
56
|
* `git worktree list --porcelain` → rows. The porcelain form is parsed rather
|
|
@@ -63,7 +60,7 @@ export function isSessionBranch(name) {
|
|
|
63
60
|
function readWorktrees(repoRoot) {
|
|
64
61
|
let out;
|
|
65
62
|
try {
|
|
66
|
-
out = git(['worktree', 'list', '--porcelain'], repoRoot);
|
|
63
|
+
out = git(['worktree', 'list', '--porcelain'], repoRoot, READ);
|
|
67
64
|
} catch {
|
|
68
65
|
return null; // not a git repo, or git is unhappy — say nothing
|
|
69
66
|
}
|
|
@@ -103,13 +100,14 @@ function readBranches(repoRoot, baseRef) {
|
|
|
103
100
|
const fmt = '%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:' + baseRef + ')';
|
|
104
101
|
let out;
|
|
105
102
|
try {
|
|
106
|
-
out = git(['for-each-ref', '--sort=-committerdate', `--format=${fmt}`, 'refs/heads'], repoRoot);
|
|
103
|
+
out = git(['for-each-ref', '--sort=-committerdate', `--format=${fmt}`, 'refs/heads'], repoRoot, READ);
|
|
107
104
|
} catch {
|
|
108
105
|
// No ahead-behind on this git. Names and dates still answer most of it.
|
|
109
106
|
try {
|
|
110
107
|
out = git(
|
|
111
108
|
['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)%09%(committerdate:unix)', 'refs/heads'],
|
|
112
|
-
repoRoot
|
|
109
|
+
repoRoot,
|
|
110
|
+
READ
|
|
113
111
|
);
|
|
114
112
|
} catch {
|
|
115
113
|
return null;
|
|
@@ -146,7 +144,7 @@ export function repoState(repoRoot, baseRef) {
|
|
|
146
144
|
/**
|
|
147
145
|
* WHAT IS LISTENING IN THE CHECKOUT ITSELF.
|
|
148
146
|
*
|
|
149
|
-
* `
|
|
147
|
+
* `measureListeners` has always taken any directory, and had only ever been asked
|
|
150
148
|
* about SESSION WORKTREES — so somebody running `npm run dev` in their normal
|
|
151
149
|
* checkout, which is what "just testing or playing around in dev" actually
|
|
152
150
|
* looks like, was invisible to every surface in the product. The measurement
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -10,16 +10,18 @@
|
|
|
10
10
|
* (runtimeEvents.mjs).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { runIsolatedResearch } from './codexResearch.mjs';
|
|
13
14
|
import { spawn } from 'node:child_process';
|
|
14
15
|
import { runtimeById } from './runtimes.mjs';
|
|
15
|
-
import {
|
|
16
|
-
import { openBrowserHome, withoutWindows } from './noWindowEnv.mjs';
|
|
16
|
+
import { openBrowserHome, unattendedEnv } from './noWindowEnv.mjs';
|
|
17
17
|
import { resolveTurnProfile } from './turnProfile.mjs';
|
|
18
18
|
import { claudePermFor } from './claudePosture.mjs';
|
|
19
19
|
import { handleStreamLine } from './claudeStream.mjs';
|
|
20
20
|
import { never, workLanesStopped } from './standDownGate.mjs';
|
|
21
21
|
import { learnCodexLimits, recordClaudeRateLimit } from './runtimeLimits.mjs';
|
|
22
|
+
import { initHarvestFor, recordMcpServers, recordSkills } from './runtimeCapabilities.mjs';
|
|
22
23
|
import { codexTurnModel } from './turnModel.mjs';
|
|
24
|
+
import { printableText } from './printable.mjs';
|
|
23
25
|
|
|
24
26
|
/** How long a Codex turn's answer may wait on its rollout's model line. */
|
|
25
27
|
const CODEX_MODEL_READ_MS = 1_500;
|
|
@@ -37,7 +39,9 @@ const CODEX_MODEL_READ_MS = 1_500;
|
|
|
37
39
|
* needs it: the daemon authenticates to the server itself, and a turn that
|
|
38
40
|
* needs the project's tools gets its own per-turn token through `mcpEnv` or
|
|
39
41
|
* the MCP config file. So those two names, and only those, are removed —
|
|
40
|
-
* by the one rule in machineEnv.mjs,
|
|
42
|
+
* by the one rule in machineEnv.mjs, composed for every unattended CLI child
|
|
43
|
+
* by `unattendedEnv` (noWindowEnv.mjs), which the project check, the skills
|
|
44
|
+
* probe and the Codex offerings reads obey too.
|
|
41
45
|
*/
|
|
42
46
|
/**
|
|
43
47
|
* …AND, UNLESS A PERSON IS AT THE KEYBOARD, NO WAY TO A WINDOW (2026-09-29).
|
|
@@ -49,21 +53,27 @@ const CODEX_MODEL_READ_MS = 1_500;
|
|
|
49
53
|
*/
|
|
50
54
|
export function cliEnv(mcpEnv, { display = false, browserHome = null } = {}) {
|
|
51
55
|
// After the merge, so an mcpEnv can never smuggle one back in.
|
|
52
|
-
|
|
53
|
-
return display === true ? env : withoutWindows(env, browserHome);
|
|
56
|
+
return unattendedEnv({ ...process.env, ...(mcpEnv ?? {}) }, { display, browserHome });
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
// One
|
|
59
|
+
// One CLI turn. Output is captured (for sentinel detection) and streamed
|
|
57
60
|
// through, line-prefixed with the worker label so a fleet stays legible.
|
|
58
61
|
//
|
|
59
|
-
//
|
|
60
|
-
// stream
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// `onActivity` so the caller can forward progress. Build-agent turns leave it
|
|
64
|
-
// off and keep the raw text passthrough + line sentinels.
|
|
62
|
+
// The event stream is parsed, always: only the humanized tool activity reaches
|
|
63
|
+
// the console (a legible stream of `read …`, `grep …`, `+ node …`), assistant
|
|
64
|
+
// text is folded into the returned string for sentinel detection, and each
|
|
65
|
+
// activity is handed to `onActivity` so the caller can forward progress.
|
|
65
66
|
export function runTurn(opts) {
|
|
66
|
-
|
|
67
|
+
// A Codex write-up runs over an isolated copy of its worktree
|
|
68
|
+
// (codexResearch.mjs). After the teardown it copies nothing either: the
|
|
69
|
+
// stand-down gate below is asked before the copy, not only before the spawn.
|
|
70
|
+
if (opts.runtime === 'codex' && opts.profile === 'research')
|
|
71
|
+
return workLanesStopped() ? never() : runIsolatedResearch(opts, runPlainTurn);
|
|
72
|
+
return runPlainTurn(opts);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runPlainTurn(opts) {
|
|
76
|
+
const { prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onRateLimit, onModel, onThreadId, onAnswer, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId, display, browserHome } = opts;
|
|
67
77
|
/**
|
|
68
78
|
* AFTER THE TEARDOWN, NO TURN STARTS AND NO TURN ENDS (standDownGate.mjs).
|
|
69
79
|
* A stand-down that drains a deploy keeps this process alive; without this
|
|
@@ -157,12 +167,12 @@ export function runTurn(opts) {
|
|
|
157
167
|
// to get wrong: the caller named one profile (turnProfile.mjs).
|
|
158
168
|
const args = rt.args({
|
|
159
169
|
personal,
|
|
170
|
+
researchIsolation: opts.researchIsolation,
|
|
160
171
|
prompt,
|
|
161
172
|
system,
|
|
162
173
|
model,
|
|
163
174
|
effort,
|
|
164
175
|
resume,
|
|
165
|
-
streamJson,
|
|
166
176
|
profile: turnProfile.adapterProfile,
|
|
167
177
|
agentTools,
|
|
168
178
|
// Adopting a terminal session (workSessionTurns.mjs): Claude turns it into
|
|
@@ -172,9 +182,9 @@ export function runTurn(opts) {
|
|
|
172
182
|
// so a mis-wired adoption fails as a loud turn error rather than a
|
|
173
183
|
// silent fresh conversation wearing an adopted session's name.
|
|
174
184
|
adoptResumeId,
|
|
175
|
-
// Only the wiki profile uses it, but it is passed unconditionally:
|
|
176
|
-
// runtime that
|
|
177
|
-
// and
|
|
185
|
+
// Only the wiki profile uses it, but it is passed unconditionally: every
|
|
186
|
+
// runtime that runs a cartographer path-scopes its writes to the vault
|
|
187
|
+
// and needs to know WHERE it is — Claude's list names it too (2026-10-10).
|
|
178
188
|
vaultDir,
|
|
179
189
|
// The turn's own directory, for a runtime that fences its writes to a
|
|
180
190
|
// path under it: Codex's image profile makes `<cwd>/.flowviant/artifacts`
|
|
@@ -196,7 +206,7 @@ export function runTurn(opts) {
|
|
|
196
206
|
// PLAN MODE REPLACES THE POSTURE, never joins it: beside
|
|
197
207
|
// `--dangerously-skip-permissions` the bypass wins silently (measured —
|
|
198
208
|
// see PLAN_MODE_PERM).
|
|
199
|
-
perm: claudePermFor(turnProfile.name, knowledgeDir),
|
|
209
|
+
perm: claudePermFor(turnProfile.name, knowledgeDir, vaultDir),
|
|
200
210
|
// Handed to the adapter rather than appended here, because WHERE these go
|
|
201
211
|
// is a property of the CLI: Codex reads its prompt as a trailing
|
|
202
212
|
// positional, so a flag after it is a flag in the wrong place.
|
|
@@ -223,45 +233,73 @@ export function runTurn(opts) {
|
|
|
223
233
|
// Which credential is correct, and whether an account may be shared, is
|
|
224
234
|
// between the operator and the vendor. Flowviant does not detect it and does
|
|
225
235
|
// not enforce it; it runs the CLI the ordinary way and relays what happens.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
236
|
+
/**
|
|
237
|
+
* WHAT THE ADAPTER WROTE FOR THIS ARGV goes when the turn settles — once,
|
|
238
|
+
* on close or on a spawn error: Claude's fenced `--settings` file, which
|
|
239
|
+
* holds the person's login settings (claudePersonal.mjs, 2026-10-10).
|
|
240
|
+
* Everything between the argv and the spawn sits inside the `try` below,
|
|
241
|
+
* so no throw on the way leaves that file behind: the sweep takes only a
|
|
242
|
+
* dead process's leftovers, and this one names the daemon's own pid.
|
|
243
|
+
*/
|
|
244
|
+
let argsReleased = false;
|
|
245
|
+
const releaseArgs = () => {
|
|
246
|
+
if (argsReleased) return;
|
|
247
|
+
argsReleased = true;
|
|
248
|
+
try {
|
|
249
|
+
rt.releaseArgs?.(args);
|
|
250
|
+
} catch {
|
|
251
|
+
/* best effort — a later write sweeps it */
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
let browser = null;
|
|
255
|
+
let child;
|
|
256
|
+
try {
|
|
257
|
+
// A turn nobody is sitting at keeps its browsers in a home of its own —
|
|
258
|
+
// the lane's kept one (an agent's), else this spawn's, removed when the
|
|
259
|
+
// CLI closes. See `cliEnv`.
|
|
260
|
+
browser = display === true ? null : openBrowserHome(browserHome);
|
|
261
|
+
child = spawn(rt.bin, args, {
|
|
262
|
+
cwd,
|
|
263
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
264
|
+
/**
|
|
265
|
+
* ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
|
|
266
|
+
*
|
|
267
|
+
* Everything the CLI spawns inherits this pgid and KEEPS it through
|
|
268
|
+
* `nohup` and `setsid` — which is exactly when attribution by descendancy
|
|
269
|
+
* fails, because reparenting to init breaks the ppid chain the moment a
|
|
270
|
+
* process becomes long-running. `processes.mjs` reads the group; the
|
|
271
|
+
* Workbench renders it.
|
|
272
|
+
*
|
|
273
|
+
* TEARDOWN IS DELIBERATELY UNCHANGED: `shutdownWork` still SIGTERMs this
|
|
274
|
+
* CHILD and never the group. Signalling the group would kill the dev
|
|
275
|
+
* server the driver started every time the daemon restarts — including
|
|
276
|
+
* on an ordinary auto-update, unattended — which is the outcome the
|
|
277
|
+
* deleted dev-run supervisor spent a whole registry avoiding. Flowviant
|
|
278
|
+
* does not manage those processes; it reports them.
|
|
279
|
+
*
|
|
280
|
+
* Not `unref`'d: the daemon must still wait on this turn.
|
|
281
|
+
*/
|
|
282
|
+
detached: true,
|
|
283
|
+
// Only ADDS to the environment (the worker token, for runtimes that read
|
|
284
|
+
// it from there). Never replaces it: the CLI's own credentials live in
|
|
285
|
+
// this environment, and handing it a curated one signs it out. What it
|
|
286
|
+
// REMOVES is this daemon's machine credential and, for a turn nobody is
|
|
287
|
+
// sitting at, every way to a window — see `cliEnv`.
|
|
288
|
+
// The login's literal secrets first, so nothing of the lane's (its MCP
|
|
289
|
+
// token, its CODEX_HOME) can be overwritten by one.
|
|
290
|
+
env: cliEnv(personal?.env ? { ...personal.env, ...(mcpEnv ?? {}) } : mcpEnv, {
|
|
291
|
+
display: display === true,
|
|
292
|
+
browserHome: browser?.dir,
|
|
293
|
+
}),
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
// A spawn that throws (an argument it refuses), or anything on the way
|
|
297
|
+
// to it, still takes what the adapter wrote with it — and this spawn's
|
|
298
|
+
// own browser home.
|
|
299
|
+
releaseArgs();
|
|
300
|
+
browser?.cleanup();
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
265
303
|
if (browser) {
|
|
266
304
|
child.once('close', browser.cleanup);
|
|
267
305
|
child.once('error', browser.cleanup);
|
|
@@ -272,170 +310,190 @@ export function runTurn(opts) {
|
|
|
272
310
|
const spawnedAt = Date.now();
|
|
273
311
|
let out = '';
|
|
274
312
|
const pfx = label ? `${label} ` : '';
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
* THE PLAN'S WINDOWS, LEARNED OFF EVERY TURN THIS DAEMON RUNS (0.109.0).
|
|
289
|
-
*
|
|
290
|
-
* This is the one spawn point — agent turns, Terminal tabs, capture
|
|
291
|
-
* chats, the planner, the pre-review and the wiki all come through here
|
|
292
|
-
* — so it is the one place that sees every CLI's word on its plan.
|
|
293
|
-
* Claude says it on the stream (`rate_limit_event`), recorded as it
|
|
294
|
-
* arrives; a caller's own `onRateLimit` still hears it. Codex writes it
|
|
295
|
-
* into the thread's rollout file instead, so the thread this turn spoke
|
|
296
|
-
* under (announced on `thread.started`, else the one it resumed) is read
|
|
297
|
-
* AFTER the child closes, on the next tick — the turn's answer never
|
|
298
|
-
* waits on a file read. See runtimeLimits.mjs.
|
|
299
|
-
*/
|
|
300
|
-
const onRate = (info) => {
|
|
301
|
-
recordClaudeRateLimit(info);
|
|
302
|
-
onRateLimit?.(info);
|
|
303
|
-
};
|
|
304
|
-
let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
|
|
305
|
-
/**
|
|
306
|
-
* THE MODEL THIS SPAWN RAN ON, ONCE (2026-09-29, turnModel.mjs). Claude
|
|
307
|
-
* names it in its init event and again on every reply; the first valid
|
|
308
|
-
* one is the answer, so the init wins and the first reply is the
|
|
309
|
-
* fallback. Codex names it only in its rollout, read at close below.
|
|
310
|
-
*/
|
|
311
|
-
let ranOn = null;
|
|
312
|
-
const onRanOn = (m) => {
|
|
313
|
-
if (ranOn || !m) return;
|
|
314
|
-
ranOn = m;
|
|
315
|
-
onModel?.(m);
|
|
316
|
-
};
|
|
317
|
-
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
318
|
-
const onLine = (line) => {
|
|
319
|
-
if (!rt.parse)
|
|
320
|
-
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit: onRate, onModel: onRanOn });
|
|
321
|
-
const ev = rt.parse(line, cwd);
|
|
322
|
-
if (!ev) return;
|
|
323
|
-
// The conversation id, when the runtime announces one (codex's
|
|
324
|
-
// thread.started). Purely additive: callers that pass no onThreadId —
|
|
325
|
-
// every dispatch path — see zero behavior change.
|
|
326
|
-
if (ev.threadId) {
|
|
327
|
-
if (rt.id === 'codex') codexThread = ev.threadId;
|
|
328
|
-
onThreadId?.(ev.threadId);
|
|
329
|
-
}
|
|
330
|
-
// The runtime's own token count (codex's `turn.completed`) — the same
|
|
331
|
-
// `onUsage` Claude's result event feeds, so a caller charges a turn
|
|
332
|
-
// identically whichever CLI ran it.
|
|
333
|
-
if (ev.usage) onUsage?.(ev.usage);
|
|
334
|
-
// One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
|
|
335
|
-
// wins at the caller, which is what "the final answer" means.
|
|
336
|
-
if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
|
|
337
|
-
if (ev.text) appendText(ev.text);
|
|
338
|
-
if (ev.activity) {
|
|
339
|
-
emit(`${ev.activity.label}\n`);
|
|
340
|
-
onActivity?.(ev.activity);
|
|
341
|
-
}
|
|
342
|
-
};
|
|
343
|
-
// DECODED AS A STREAM, never chunk by chunk (2026-09-24, the audit): a
|
|
344
|
-
// pipe read can end inside a multi-byte character — the em dash models
|
|
345
|
-
// write constantly — and `d.toString()` per chunk turned each half into
|
|
346
|
-
// U+FFFD, which JSON.parse accepts and the reply then carried verbatim.
|
|
347
|
-
// `setEncoding` holds the partial bytes over to the next chunk.
|
|
348
|
-
child.stdout.setEncoding('utf8');
|
|
349
|
-
child.stderr.setEncoding('utf8');
|
|
350
|
-
child.stdout.on('data', (d) => {
|
|
351
|
-
buf += d;
|
|
352
|
-
let nl;
|
|
353
|
-
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
354
|
-
const line = buf.slice(0, nl);
|
|
355
|
-
buf = buf.slice(nl + 1);
|
|
356
|
-
if (line.trim()) onLine(line);
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
// stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
|
|
360
|
-
child.stderr.on('data', (s) => {
|
|
361
|
-
out += s;
|
|
362
|
-
emit(s);
|
|
363
|
-
});
|
|
364
|
-
child.on('error', (e) => {
|
|
365
|
-
if (e.code === 'ENOENT') {
|
|
366
|
-
// A MISSING CLI FAILS THE TURN, NOT THE DAEMON. This called
|
|
367
|
-
// process.exit(1), which was defensible while `claude` was the only
|
|
368
|
-
// runtime and preflight refused to start without it — the process
|
|
369
|
-
// could not reach here. Both halves of that are gone: preflight is now
|
|
370
|
-
// fatal only when NOTHING is drivable, so a Codex-only machine starts
|
|
371
|
-
// legitimately, and the wiki/plan-check/consult turns still ask for
|
|
372
|
-
// Claude by default. On such a machine the first wiki sweep would have
|
|
373
|
-
// killed the whole daemon, taking every in-flight build with it,
|
|
374
|
-
// because one background job could not find one binary.
|
|
375
|
-
console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
|
|
376
|
-
resolve('');
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
console.error(e);
|
|
380
|
-
resolve(out);
|
|
381
|
-
});
|
|
382
|
-
child.on('close', () => {
|
|
383
|
-
if (buf.trim()) onLine(buf);
|
|
384
|
-
const handBack = () => {
|
|
385
|
-
resolve(out);
|
|
386
|
-
// After the answer is handed back, never before it: see `onRate`.
|
|
387
|
-
if (codexThread) {
|
|
388
|
-
const thread = codexThread;
|
|
389
|
-
setImmediate(() => void learnCodexLimits(thread));
|
|
390
|
-
}
|
|
391
|
-
};
|
|
392
|
-
/**
|
|
393
|
-
* A CODEX TURN'S MODEL IS IN ITS ROLLOUT, so a caller that asked for
|
|
394
|
-
* it waits for one bounded read before the answer goes back — the
|
|
395
|
-
* settle it rides is built from that answer. Only then: a caller that
|
|
396
|
-
* passes no `onModel` hands back exactly as before. A read that fails
|
|
397
|
-
* or overruns reports nothing (never a guess).
|
|
398
|
-
*/
|
|
399
|
-
if (codexThread && onModel && !ranOn) {
|
|
400
|
-
const thread = codexThread;
|
|
401
|
-
let timer = null;
|
|
402
|
-
const cap = new Promise((res) => {
|
|
403
|
-
timer = setTimeout(() => res(null), CODEX_MODEL_READ_MS);
|
|
404
|
-
});
|
|
405
|
-
void Promise.race([codexTurnModel(thread, { since: spawnedAt }), cap])
|
|
406
|
-
.then(onRanOn, () => {})
|
|
407
|
-
.finally(() => {
|
|
408
|
-
clearTimeout(timer);
|
|
409
|
-
handBack();
|
|
410
|
-
});
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
handBack();
|
|
414
|
-
});
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
313
|
+
/**
|
|
314
|
+
* THE ONE SINK FOR WHAT A CLI OR A MODEL WROTE (2026-10-10, the audit's
|
|
315
|
+
* F133): activity labels — a Bash command, a Codex `say`, a file path —
|
|
316
|
+
* and the CLI's raw stderr. A prompt-injected agent can put an OSC 52 in
|
|
317
|
+
* a tool command as easily as in prose, and this wrote it to the
|
|
318
|
+
* operator's terminal as-is. Scrubbed HERE, before the daemon's own
|
|
319
|
+
* coloured `label` is added, so the prefix keeps its colour and nothing
|
|
320
|
+
* the model wrote keeps an escape sequence (printable.mjs, `printableText`).
|
|
321
|
+
*/
|
|
322
|
+
const emit = (s) => {
|
|
323
|
+
const text = printableText(String(s));
|
|
324
|
+
process.stdout.write(pfx ? text.replace(/\n/g, `\n${pfx}`) : text);
|
|
325
|
+
};
|
|
417
326
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
327
|
+
// EVERY TURN IS LINE-PARSED (2026-10-10, the audit). Claude's adapter
|
|
328
|
+
// always asks for `--output-format stream-json` and every other runtime
|
|
329
|
+
// brings its own `parse` — for Codex the JSONL stream is the only output
|
|
330
|
+
// there is. A raw-text path survived for a `streamJson: false` no caller
|
|
331
|
+
// passed any more, with a second copy of the spawn-error rule that had
|
|
332
|
+
// already drifted once (it called process.exit(1)); both are gone. A line
|
|
333
|
+
// that is not JSON is still kept as text (claudeStream.mjs), and stderr
|
|
334
|
+
// still lands in the haystack the sentinels read.
|
|
335
|
+
let buf = '';
|
|
336
|
+
const appendText = (t) => {
|
|
337
|
+
out += t;
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* THE PLAN'S WINDOWS, LEARNED OFF EVERY TURN THIS DAEMON RUNS (0.109.0).
|
|
341
|
+
*
|
|
342
|
+
* This is the one spawn point — agent turns, Terminal tabs, capture
|
|
343
|
+
* chats, the planner, the pre-review and the wiki all come through here
|
|
344
|
+
* — so it is the one place that sees every CLI's word on its plan.
|
|
345
|
+
* Claude says it on the stream (`rate_limit_event`), recorded as it
|
|
346
|
+
* arrives; a caller's own `onRateLimit` still hears it. Codex writes it
|
|
347
|
+
* into the thread's rollout file instead, so the thread this turn spoke
|
|
348
|
+
* under (announced on `thread.started`, else the one it resumed) is read
|
|
349
|
+
* AFTER the child closes, on the next tick — the turn's answer never
|
|
350
|
+
* waits on a file read. See runtimeLimits.mjs.
|
|
351
|
+
*/
|
|
352
|
+
const onRate = (info) => {
|
|
353
|
+
recordClaudeRateLimit(info);
|
|
354
|
+
onRateLimit?.(info);
|
|
421
355
|
};
|
|
422
|
-
|
|
356
|
+
let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
|
|
357
|
+
/**
|
|
358
|
+
* THE MODEL THIS SPAWN RAN ON, ONCE (2026-09-29, turnModel.mjs). Claude
|
|
359
|
+
* names it in its init event and again on every reply; the first valid
|
|
360
|
+
* one is the answer, so the init wins and the first reply is the
|
|
361
|
+
* fallback. Codex names it only in its rollout, read at close below.
|
|
362
|
+
*/
|
|
363
|
+
let ranOn = null;
|
|
364
|
+
const onRanOn = (m) => {
|
|
365
|
+
if (ranOn || !m) return;
|
|
366
|
+
ranOn = m;
|
|
367
|
+
onModel?.(m);
|
|
368
|
+
};
|
|
369
|
+
/**
|
|
370
|
+
* WHAT THE CLI SAID IT HAS — its skills and MCP servers — recorded HERE
|
|
371
|
+
* for every lane (2026-10-10, the audit), beside the plan's windows: four
|
|
372
|
+
* lanes each kept a copy of the harvest, and they drifted until a fenced
|
|
373
|
+
* turn's narrowed list overwrote the machine's report. Only a turn whose
|
|
374
|
+
* argv narrows neither is recorded (`initHarvestFor`,
|
|
375
|
+
* runtimeCapabilities.mjs); a caller's own `onInit` still hears every
|
|
376
|
+
* init.
|
|
377
|
+
*/
|
|
378
|
+
const harvest = initHarvestFor(rt, args);
|
|
379
|
+
const onInitAll = (i) => {
|
|
380
|
+
if (harvest.skills) recordSkills(i?.skills);
|
|
381
|
+
if (harvest.mcp) recordMcpServers(i?.mcpServers);
|
|
382
|
+
onInit?.(i);
|
|
383
|
+
};
|
|
384
|
+
/** One line of the child's stdout, in whichever dialect it speaks. */
|
|
385
|
+
const onLine = (line) => {
|
|
386
|
+
if (opts.onCliFailure) {
|
|
387
|
+
try {
|
|
388
|
+
const event = JSON.parse(line);
|
|
389
|
+
if ((event.type === 'result' && event.is_error === true) ||
|
|
390
|
+
event.type === 'error' || event.type === 'turn.failed')
|
|
391
|
+
opts.onCliFailure();
|
|
392
|
+
} catch { /* ordinary non-JSON CLI output */ }
|
|
393
|
+
}
|
|
394
|
+
if (!rt.parse)
|
|
395
|
+
return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit: onInitAll, onUsage, onRateLimit: onRate, onModel: onRanOn });
|
|
396
|
+
const ev = rt.parse(line, cwd);
|
|
397
|
+
if (!ev) return;
|
|
398
|
+
// The conversation id, when the runtime announces one (codex's
|
|
399
|
+
// thread.started). Purely additive: callers that pass no onThreadId —
|
|
400
|
+
// every dispatch path — see zero behavior change.
|
|
401
|
+
if (ev.threadId) {
|
|
402
|
+
if (rt.id === 'codex') codexThread = ev.threadId;
|
|
403
|
+
onThreadId?.(ev.threadId);
|
|
404
|
+
}
|
|
405
|
+
// The runtime's own token count (codex's `turn.completed`) — the same
|
|
406
|
+
// `onUsage` Claude's result event feeds, so a caller charges a turn
|
|
407
|
+
// identically whichever CLI ran it.
|
|
408
|
+
if (ev.usage) onUsage?.(ev.usage);
|
|
409
|
+
// One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
|
|
410
|
+
// wins at the caller, which is what "the final answer" means.
|
|
411
|
+
if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
|
|
412
|
+
if (ev.text) appendText(ev.text);
|
|
413
|
+
if (ev.activity) {
|
|
414
|
+
emit(`${ev.activity.label}\n`);
|
|
415
|
+
onActivity?.(ev.activity);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
// DECODED AS A STREAM, never chunk by chunk (2026-09-24, the audit): a
|
|
419
|
+
// pipe read can end inside a multi-byte character — the em dash models
|
|
420
|
+
// write constantly — and `d.toString()` per chunk turned each half into
|
|
421
|
+
// U+FFFD, which JSON.parse accepts and the reply then carried verbatim.
|
|
422
|
+
// `setEncoding` holds the partial bytes over to the next chunk.
|
|
423
423
|
child.stdout.setEncoding('utf8');
|
|
424
424
|
child.stderr.setEncoding('utf8');
|
|
425
|
-
child.stdout.on('data',
|
|
426
|
-
|
|
427
|
-
|
|
425
|
+
child.stdout.on('data', (d) => {
|
|
426
|
+
buf += d;
|
|
427
|
+
let nl;
|
|
428
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
429
|
+
const line = buf.slice(0, nl);
|
|
430
|
+
buf = buf.slice(nl + 1);
|
|
431
|
+
if (line.trim()) onLine(line);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
// stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
|
|
435
|
+
child.stderr.on('data', (s) => {
|
|
436
|
+
out += s;
|
|
437
|
+
emit(s);
|
|
438
|
+
});
|
|
439
|
+
/**
|
|
440
|
+
* A SPAWN THAT FAILS FAILS THE TURN, NOT THE DAEMON — the one copy of the
|
|
441
|
+
* rule. A missing CLI resolves '' with the install line; this called
|
|
442
|
+
* process.exit(1), which was defensible while `claude` was the only
|
|
443
|
+
* runtime and preflight refused to start without it — the process could
|
|
444
|
+
* not reach here. Both halves of that are gone: preflight is now fatal
|
|
445
|
+
* only when NOTHING is drivable, so a Codex-only machine starts
|
|
446
|
+
* legitimately, and the wiki/plan-check/consult turns still ask for Claude
|
|
447
|
+
* by default. On such a machine the first wiki sweep would have killed the
|
|
448
|
+
* whole daemon, taking every in-flight build with it, because one
|
|
449
|
+
* background job could not find one binary. Any other spawn error
|
|
450
|
+
* resolves what was captured so far.
|
|
451
|
+
*/
|
|
452
|
+
const onSpawnError = (e) => {
|
|
453
|
+
releaseArgs();
|
|
428
454
|
if (e.code === 'ENOENT') {
|
|
429
|
-
// A MISSING CLI FAILS THE TURN, NOT THE DAEMON — same fix as the
|
|
430
|
-
// line-parsed path above; this raw-output duplicate used to
|
|
431
|
-
// process.exit(1) and take every in-flight worker down with it.
|
|
432
455
|
console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
|
|
433
456
|
resolve('');
|
|
434
457
|
return;
|
|
435
458
|
}
|
|
436
459
|
console.error(e);
|
|
437
460
|
resolve(out);
|
|
461
|
+
};
|
|
462
|
+
child.on('error', onSpawnError);
|
|
463
|
+
child.on('close', (code, signal) => {
|
|
464
|
+
releaseArgs();
|
|
465
|
+
opts.onExit?.(code, signal);
|
|
466
|
+
if (buf.trim()) onLine(buf);
|
|
467
|
+
const handBack = () => {
|
|
468
|
+
resolve(out);
|
|
469
|
+
// After the answer is handed back, never before it: see `onRate`.
|
|
470
|
+
if (codexThread) {
|
|
471
|
+
const thread = codexThread;
|
|
472
|
+
setImmediate(() => void learnCodexLimits(thread));
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* A CODEX TURN'S MODEL IS IN ITS ROLLOUT, so a caller that asked for
|
|
477
|
+
* it waits for one bounded read before the answer goes back — the
|
|
478
|
+
* settle it rides is built from that answer. Only then: a caller that
|
|
479
|
+
* passes no `onModel` hands back exactly as before. A read that fails
|
|
480
|
+
* or overruns reports nothing (never a guess).
|
|
481
|
+
*/
|
|
482
|
+
if (codexThread && onModel && !ranOn) {
|
|
483
|
+
const thread = codexThread;
|
|
484
|
+
let timer = null;
|
|
485
|
+
const cap = new Promise((res) => {
|
|
486
|
+
timer = setTimeout(() => res(null), CODEX_MODEL_READ_MS);
|
|
487
|
+
});
|
|
488
|
+
void Promise.race([codexTurnModel(thread, { since: spawnedAt }), cap])
|
|
489
|
+
.then(onRanOn, () => {})
|
|
490
|
+
.finally(() => {
|
|
491
|
+
clearTimeout(timer);
|
|
492
|
+
handBack();
|
|
493
|
+
});
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
handBack();
|
|
438
497
|
});
|
|
439
|
-
child.on('close', () => resolve(out));
|
|
440
498
|
});
|
|
441
499
|
}
|