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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT EACH CLI TAKES OF THE REPO'S TOOLS, AND HOW A TURN IS PREPARED FOR IT
|
|
3
|
+
* (2026-09-26, SOLID F064).
|
|
4
|
+
*
|
|
5
|
+
* One row per runtime: whether it can take MCP servers at all, which MCP
|
|
6
|
+
* configurations it refuses (Codex takes no SSE), whether it takes the base
|
|
7
|
+
* branch's skills, and the runner-specific filesystem work a turn needs
|
|
8
|
+
* (Claude: a reviewed plugin carrying the base skills; Codex: an isolated
|
|
9
|
+
* CODEX_HOME). Split out of projectTools.mjs, where a capability table sat
|
|
10
|
+
* beside `runner === 'codex'` / `runner === 'claude'` branches in
|
|
11
|
+
* prepareAgentTools and a second copy of the SSE rule in toolReadout, so a
|
|
12
|
+
* new runtime or a transport change needed coordinated edits in three places.
|
|
13
|
+
* prepareAgentTools, toolReadout and runtimeOfferings.mjs (the Codex MCP
|
|
14
|
+
* report) all ask this table; the base-branch snapshot reader stays in
|
|
15
|
+
* projectTools.mjs. projectTools.test.mjs pins every transport word to here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
|
|
22
|
+
export const toml = (value) => JSON.stringify(String(value));
|
|
23
|
+
|
|
24
|
+
/** Keep personal Codex files, but do not copy its project trust grants. An
|
|
25
|
+
* untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1. */
|
|
26
|
+
function isolatedCodexHome(dir, env, worktree) {
|
|
27
|
+
const source = resolve(env.CODEX_HOME || join(homedir(), '.codex'));
|
|
28
|
+
const dest = join(dir, 'codex-home');
|
|
29
|
+
mkdirSync(dest, { mode: 0o700 });
|
|
30
|
+
if (existsSync(source)) {
|
|
31
|
+
for (const name of readdirSync(source)) {
|
|
32
|
+
if (name === 'config.toml') continue;
|
|
33
|
+
symlinkSync(join(source, name), join(dest, name));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const configPath = join(source, 'config.toml');
|
|
37
|
+
const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
38
|
+
const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
|
|
39
|
+
const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
|
|
40
|
+
// These sections alone grant a checkout permission to supply project
|
|
41
|
+
// settings. Keep every other personal setting, skill and session store.
|
|
42
|
+
const lines = sourceConfig.split('\n');
|
|
43
|
+
let inProject = false;
|
|
44
|
+
let config = lines.filter((line) => {
|
|
45
|
+
const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
|
|
46
|
+
if (header) {
|
|
47
|
+
const section = header[1] ?? header[2];
|
|
48
|
+
inProject = section === 'projects' || section.startsWith('projects.');
|
|
49
|
+
}
|
|
50
|
+
return !inProject;
|
|
51
|
+
}).join('\n');
|
|
52
|
+
if (worktree) {
|
|
53
|
+
// Codex discovers these even in an untrusted project. Disable exactly the
|
|
54
|
+
// worktree skill paths; the person's ~/.codex/skills and config remain.
|
|
55
|
+
const worktreeSkills = [];
|
|
56
|
+
for (const sourceDir of ['.agents/skills', '.codex/skills', '.claude/skills']) {
|
|
57
|
+
const skillsDir = join(worktree, sourceDir);
|
|
58
|
+
if (!existsSync(skillsDir)) continue;
|
|
59
|
+
for (const name of readdirSync(skillsDir)) {
|
|
60
|
+
const skill = join(skillsDir, name, 'SKILL.md');
|
|
61
|
+
if (existsSync(skill)) worktreeSkills.push(skill);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (worktreeSkills.length && /^\s*skills\.config\s*=/m.test(config))
|
|
65
|
+
throw new Error('Cannot isolate Codex project skills with inline skills.config');
|
|
66
|
+
for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
|
|
67
|
+
}
|
|
68
|
+
writeFileSync(join(dest, 'config.toml'), config, { mode: 0o600 });
|
|
69
|
+
return { path: dest, personalDeveloperInstructions };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Claude takes the base branch's skills as a plugin written for this turn. */
|
|
73
|
+
function reviewedClaudePlugin(dir, snapshot) {
|
|
74
|
+
if (!snapshot.skillFiles.length) return null;
|
|
75
|
+
const pluginDir = join(dir, 'reviewed-plugin');
|
|
76
|
+
mkdirSync(join(pluginDir, '.claude-plugin'), { recursive: true });
|
|
77
|
+
writeFileSync(join(pluginDir, '.claude-plugin/plugin.json'), JSON.stringify({ name: 'flowviant-reviewed', version: '1.0.0', description: 'Base-branch agent skills' }), { mode: 0o600 });
|
|
78
|
+
for (const { path, body } of snapshot.skillFiles) {
|
|
79
|
+
const dest = join(pluginDir, path.replace(/^\.claude\//, ''));
|
|
80
|
+
mkdirSync(join(dest, '..'), { recursive: true });
|
|
81
|
+
writeFileSync(dest, body, { mode: 0o600 });
|
|
82
|
+
}
|
|
83
|
+
return pluginDir;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The table. `refuseMcp(config)` names why this runtime cannot take one MCP
|
|
88
|
+
* configuration it otherwise could; `prepare(dir, snapshot, env, worktree)`
|
|
89
|
+
* returns the runner-specific part of prepareAgentTools' result.
|
|
90
|
+
*/
|
|
91
|
+
const TOOL_RUNTIMES = {
|
|
92
|
+
claude: {
|
|
93
|
+
mcp: true,
|
|
94
|
+
skills: true,
|
|
95
|
+
refuseMcp: () => null,
|
|
96
|
+
prepare: (dir, snapshot) => ({ pluginDir: reviewedClaudePlugin(dir, snapshot) }),
|
|
97
|
+
},
|
|
98
|
+
codex: {
|
|
99
|
+
mcp: true,
|
|
100
|
+
skills: true,
|
|
101
|
+
refuseMcp: (config) => (config.type === 'sse' ? 'Codex cannot take SSE MCP' : null),
|
|
102
|
+
prepare: (dir, _snapshot, env, worktree) => {
|
|
103
|
+
const isolated = isolatedCodexHome(dir, env, worktree);
|
|
104
|
+
return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
antigravity: { mcp: false, skills: false },
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** An unknown runner (or 'none', when no CLI is installed) takes nothing. */
|
|
111
|
+
const takesNothing = { mcp: false, skills: false };
|
|
112
|
+
const rowFor = (runner) => (Object.hasOwn(TOOL_RUNTIMES, runner) ? TOOL_RUNTIMES[runner] : takesNothing);
|
|
113
|
+
|
|
114
|
+
/** Pure mapping used before a future tool-attach gesture is offered. */
|
|
115
|
+
export function runnerToolCapabilities(runner) {
|
|
116
|
+
const { mcp, skills } = rowFor(runner);
|
|
117
|
+
return { mcp, skills };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Why `runner` cannot take this MCP configuration, or null when it can. The
|
|
121
|
+
* one answer both preparation (which drops it) and the readout (which says it)
|
|
122
|
+
* read. */
|
|
123
|
+
export function mcpRefusal(runner, config) {
|
|
124
|
+
const row = rowFor(runner);
|
|
125
|
+
if (!row.mcp) return runner === 'none' ? 'no installed CLI can take MCP' : `${runner} cannot take MCP`;
|
|
126
|
+
return row.refuseMcp?.(config) ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Why `runner` cannot take the base branch's skills, or null when it can. */
|
|
130
|
+
export function skillsRefusal(runner) {
|
|
131
|
+
if (rowFor(runner).skills) return null;
|
|
132
|
+
return runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The runner-specific files a turn needs, written under `dir`. */
|
|
136
|
+
export function prepareForRunner(runner, dir, snapshot, env, worktree) {
|
|
137
|
+
return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree) ?? {}) };
|
|
138
|
+
}
|
package/bin/lib/projectTools.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/** Repo-owned tool configuration, read from the base ref for every agent turn. */
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { accessSync, constants,
|
|
4
|
-
import { delimiter, isAbsolute, join
|
|
5
|
-
import {
|
|
3
|
+
import { accessSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { mcpRefusal, prepareForRunner, skillsRefusal, toml } from './projectToolRuntimes.mjs';
|
|
6
7
|
|
|
7
8
|
const MAX_TOOLS = 40;
|
|
8
9
|
const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
|
|
9
10
|
const MAX_INSTRUCTION_FILES = 80;
|
|
10
11
|
const MAX_INSTRUCTION_BYTES = 512 * 1024;
|
|
11
12
|
const nameOk = (name) => /^[A-Za-z0-9_-]{1,80}$/.test(name) && name !== 'flowviant';
|
|
12
|
-
const toml = (value) => JSON.stringify(String(value));
|
|
13
13
|
const show = (root, ref, path) => {
|
|
14
14
|
try {
|
|
15
15
|
return execFileSync('git', ['show', `${ref}:${path}`], {
|
|
@@ -39,12 +39,6 @@ function referenced(value) {
|
|
|
39
39
|
return [...String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** Pure mapping used before a future tool-attach gesture is offered. */
|
|
43
|
-
export function runnerToolCapabilities(runner) {
|
|
44
|
-
return ({ claude: { mcp: true, skills: true }, codex: { mcp: true, skills: true }, antigravity: { mcp: false, skills: false } })[runner]
|
|
45
|
-
?? { mcp: false, skills: false };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
42
|
export function readBaseTools(root, ref, env = process.env) {
|
|
49
43
|
// Resolve once: a base branch may advance while the turn is being prepared.
|
|
50
44
|
const commit = execFileSync('git', ['rev-parse', '--verify', ref], {
|
|
@@ -105,59 +99,10 @@ export function readBaseTools(root, ref, env = process.env) {
|
|
|
105
99
|
|
|
106
100
|
const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
|
|
107
101
|
|
|
108
|
-
/** Keep personal Codex files, but do not copy its project trust grants. An
|
|
109
|
-
* untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1. */
|
|
110
|
-
function isolatedCodexHome(dir, env, worktree) {
|
|
111
|
-
const source = resolve(env.CODEX_HOME || join(homedir(), '.codex'));
|
|
112
|
-
const dest = join(dir, 'codex-home');
|
|
113
|
-
mkdirSync(dest, { mode: 0o700 });
|
|
114
|
-
if (existsSync(source)) {
|
|
115
|
-
for (const name of readdirSync(source)) {
|
|
116
|
-
if (name === 'config.toml') continue;
|
|
117
|
-
symlinkSync(join(source, name), join(dest, name));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
const configPath = join(source, 'config.toml');
|
|
121
|
-
const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
122
|
-
const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
|
|
123
|
-
const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
|
|
124
|
-
// These sections alone grant a checkout permission to supply project
|
|
125
|
-
// settings. Keep every other personal setting, skill and session store.
|
|
126
|
-
const lines = sourceConfig.split('\n');
|
|
127
|
-
let inProject = false;
|
|
128
|
-
let config = lines.filter((line) => {
|
|
129
|
-
const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
|
|
130
|
-
if (header) {
|
|
131
|
-
const section = header[1] ?? header[2];
|
|
132
|
-
inProject = section === 'projects' || section.startsWith('projects.');
|
|
133
|
-
}
|
|
134
|
-
return !inProject;
|
|
135
|
-
}).join('\n');
|
|
136
|
-
if (worktree) {
|
|
137
|
-
// Codex discovers these even in an untrusted project. Disable exactly the
|
|
138
|
-
// worktree skill paths; the person's ~/.codex/skills and config remain.
|
|
139
|
-
const worktreeSkills = [];
|
|
140
|
-
for (const sourceDir of ['.agents/skills', '.codex/skills', '.claude/skills']) {
|
|
141
|
-
const skillsDir = join(worktree, sourceDir);
|
|
142
|
-
if (!existsSync(skillsDir)) continue;
|
|
143
|
-
for (const name of readdirSync(skillsDir)) {
|
|
144
|
-
const skill = join(skillsDir, name, 'SKILL.md');
|
|
145
|
-
if (existsSync(skill)) worktreeSkills.push(skill);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (worktreeSkills.length && /^\s*skills\.config\s*=/m.test(config))
|
|
149
|
-
throw new Error('Cannot isolate Codex project skills with inline skills.config');
|
|
150
|
-
for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
|
|
151
|
-
}
|
|
152
|
-
writeFileSync(join(dest, 'config.toml'), config, { mode: 0o600 });
|
|
153
|
-
return { path: dest, personalDeveloperInstructions };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
102
|
/** Temporary per-turn files are removed after the last retry. No personal CLI config is written. */
|
|
157
103
|
export function prepareAgentTools(snapshot, runner, env = process.env, worktree = null) {
|
|
158
104
|
const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
|
|
159
|
-
const
|
|
160
|
-
const usable = snapshot.tools.filter((t) => t.state === 'ready' && caps.mcp && !(runner === 'codex' && t.config.type === 'sse'));
|
|
105
|
+
const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
|
|
161
106
|
const mcpServers = {};
|
|
162
107
|
const codexArgs = [];
|
|
163
108
|
for (const { name, config } of usable) {
|
|
@@ -185,7 +130,7 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
|
|
|
185
130
|
const mcpPath = join(dir, 'mcp.json');
|
|
186
131
|
writeFileSync(mcpPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
|
|
187
132
|
const pointers = [];
|
|
188
|
-
for (const { path, body } of [...snapshot.instructions, ...(
|
|
133
|
+
for (const { path, body } of [...snapshot.instructions, ...(skillsRefusal(runner) == null ? snapshot.skillFiles : [])]) {
|
|
189
134
|
const dest = join(dir, path);
|
|
190
135
|
mkdirSync(join(dest, '..'), { recursive: true });
|
|
191
136
|
writeFileSync(dest, body, { mode: 0o600 });
|
|
@@ -195,28 +140,14 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
|
|
|
195
140
|
const instructions = [baseInstructions,
|
|
196
141
|
pointers.length ? `Base-branch instruction and skill copies (read the relevant nested files and skill files before acting):\n${pointers.join('\n')}` : '',
|
|
197
142
|
].filter(Boolean).join('\n\n');
|
|
198
|
-
|
|
199
|
-
if (pluginDir) {
|
|
200
|
-
mkdirSync(join(pluginDir, '.claude-plugin'), { recursive: true });
|
|
201
|
-
writeFileSync(join(pluginDir, '.claude-plugin/plugin.json'), JSON.stringify({ name: 'flowviant-reviewed', version: '1.0.0', description: 'Base-branch agent skills' }), { mode: 0o600 });
|
|
202
|
-
for (const { path, body } of snapshot.skillFiles) {
|
|
203
|
-
const dest = join(pluginDir, path.replace(/^\.claude\//, ''));
|
|
204
|
-
mkdirSync(join(dest, '..'), { recursive: true });
|
|
205
|
-
writeFileSync(dest, body, { mode: 0o600 });
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
let codexHome = null;
|
|
209
|
-
let personalDeveloperInstructions = false;
|
|
143
|
+
let prepared;
|
|
210
144
|
try {
|
|
211
|
-
|
|
212
|
-
const isolated = isolatedCodexHome(dir, env, worktree);
|
|
213
|
-
codexHome = isolated.path;
|
|
214
|
-
personalDeveloperInstructions = isolated.personalDeveloperInstructions;
|
|
215
|
-
}
|
|
145
|
+
prepared = prepareForRunner(runner, dir, snapshot, env, worktree);
|
|
216
146
|
} catch (error) {
|
|
217
147
|
rmSync(dir, { recursive: true, force: true });
|
|
218
148
|
throw error;
|
|
219
149
|
}
|
|
150
|
+
const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
|
|
220
151
|
return {
|
|
221
152
|
mcpPath,
|
|
222
153
|
codexArgs,
|
|
@@ -229,20 +160,14 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
|
|
|
229
160
|
}
|
|
230
161
|
|
|
231
162
|
export function toolReadout(snapshot, runner) {
|
|
232
|
-
const caps = runnerToolCapabilities(runner);
|
|
233
163
|
const short = (reason) => reason == null ? null : String(reason).slice(0, 200);
|
|
234
164
|
const mcp = snapshot.tools.map(({ name, config, state, reason }) => {
|
|
235
|
-
const
|
|
236
|
-
return {
|
|
237
|
-
name, kind: 'mcp',
|
|
238
|
-
state: caps.mcp && !unsupported ? state : 'missing',
|
|
239
|
-
reason: short(!caps.mcp ? runner === 'none' ? 'no installed CLI can take MCP' : `${runner} cannot take MCP`
|
|
240
|
-
: unsupported ? 'Codex cannot take SSE MCP' : reason),
|
|
241
|
-
};
|
|
165
|
+
const refused = mcpRefusal(runner, config);
|
|
166
|
+
return { name, kind: 'mcp', state: refused ? 'missing' : state, reason: short(refused ?? reason) };
|
|
242
167
|
});
|
|
168
|
+
const skillsRefused = skillsRefusal(runner);
|
|
243
169
|
const instructions = snapshot.skills.map(({ path }) => ({
|
|
244
|
-
name: path.split('/')[2], kind: 'instruction', state:
|
|
245
|
-
reason: short(caps.skills ? null : runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`),
|
|
170
|
+
name: path.split('/')[2], kind: 'instruction', state: skillsRefused ? 'missing' : 'ready', reason: short(skillsRefused),
|
|
246
171
|
}));
|
|
247
172
|
return [...mcp, ...instructions].slice(0, MAX_REPORT_TOOLS);
|
|
248
173
|
}
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -1,42 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The operating-contract prompts — every system prompt and kickoff the daemon
|
|
3
|
-
* hands a coding CLI, in one place. Split out of claude.mjs
|
|
4
|
-
* permission sets
|
|
5
|
-
*
|
|
6
|
-
* nothing else: no
|
|
3
|
+
* hands a coding CLI, in one place. Split out of claude.mjs purely for size
|
|
4
|
+
* (the permission sets are claudePosture.mjs's, the turn plumbing runTurn.mjs's);
|
|
5
|
+
* every call site imports them from here. These are strings and
|
|
6
|
+
* nothing else: no environment, no I/O, and one import — the card-kind table
|
|
7
|
+
* (agentTaskKinds.mjs, itself pure data), which selects among them.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* `code` (the default), `design` or `research` — the server sends the key only
|
|
13
|
-
* for the last two, so ABSENT IS CODE, and an unknown string (a newer server, a
|
|
14
|
-
* typo) degrades to code: the one kind that changes nothing about how a card is
|
|
15
|
-
* run. Every reader in this repo — the contract selector, the kickoff, the
|
|
16
|
-
* spec, the posture, the post-turn artifact check — goes through this, so no
|
|
17
|
-
* two of them can disagree about what an odd value means.
|
|
18
|
-
*/
|
|
19
|
-
export const agentTaskKindOf = (v) => (v === 'design' || v === 'research' ? v : 'code');
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* …EXCEPT WHERE A TURN IS ABOUT TO SPAWN (2026-09-23). "Unknown degrades to
|
|
23
|
-
* code" is right for every reader that only PRINTS the kind — the planner's
|
|
24
|
-
* kickoff, the spec, a contract heading — and wrong for the one that picks a
|
|
25
|
-
* POSTURE: code is the build turn, permissions skipped, so a kind a newer
|
|
26
|
-
* server invented for a card that must not touch the code (a fourth kind, a
|
|
27
|
-
* typo, `Design`) would have run as exactly the turn it exists to prevent. The
|
|
28
|
-
* agent lane asks this first and refuses in words, relaying the word it was
|
|
29
|
-
* handed. Absent, null and empty stay code — that is how a code card arrives.
|
|
30
|
-
*
|
|
31
|
-
* @returns {string|null} the unrecognised value, or null when the kind is one
|
|
32
|
-
* this daemon runs.
|
|
33
|
-
*/
|
|
34
|
-
export const unknownAgentTaskKind = (v) =>
|
|
35
|
-
v === undefined || v === null || v === '' || v === 'code' || v === 'design' || v === 'research'
|
|
36
|
-
? null
|
|
37
|
-
: String(v).replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 40);
|
|
38
|
-
|
|
39
|
-
|
|
10
|
+
// A card's kind is read through agentTaskKinds.mjs — the one table of what a
|
|
11
|
+
// kind is. This file keeps only the STRINGS each kind is handed.
|
|
12
|
+
import { agentTaskKindOf } from './agentTaskKinds.mjs';
|
|
40
13
|
|
|
41
14
|
// Wiki-gen turn: the local Claude READS the repo (cwd) and writes/maintains the
|
|
42
15
|
// knowledge VAULT — a plain directory of markdown files with [[wikilinks]]
|
|
@@ -450,7 +423,7 @@ Write plain Markdown for a person reading your reply in a chat tab.`;
|
|
|
450
423
|
* bookkeeping reasons, which is the one thing it never does.
|
|
451
424
|
*
|
|
452
425
|
* SHAPE, NOT MEMBERSHIP. We do not check the name against the machine's skill
|
|
453
|
-
* list: that list is only learned after a turn has run (
|
|
426
|
+
* list: that list is only learned after a turn has run (runtimeCapabilities.mjs), so
|
|
454
427
|
* gating on it would make the first `/foo` of a machine's life behave
|
|
455
428
|
* differently from the second. Instead this matches what a command can LOOK
|
|
456
429
|
* like — one segment, no second slash — which leaves `/home/user/x.ts is
|
|
@@ -939,7 +912,7 @@ not only this card. Past tense, plain sentences, no commit shas and no card ids.
|
|
|
939
912
|
* everything BEFORE the JSON — no commits, no trailer, one artifact.
|
|
940
913
|
*
|
|
941
914
|
* THE PROMPT IS THE QUALITY; THE POSTURE IS THE FENCE. The `design` posture
|
|
942
|
-
* (
|
|
915
|
+
* (claudePosture.mjs) can write ONLY under `.flowviant/artifacts/` — "change no
|
|
943
916
|
* repository file" is enforced, not asked — and the daemon refuses to call the
|
|
944
917
|
* turn delivered if no `.html` was written under it this turn.
|
|
945
918
|
*
|
|
@@ -1071,10 +1044,23 @@ past tense, plain sentences, no card ids.`;
|
|
|
1071
1044
|
* code, and code is `SYSTEM_AGENT` — the same object, so a code turn's system
|
|
1072
1045
|
* prompt is byte-for-byte what it was before kinds existed.
|
|
1073
1046
|
*/
|
|
1074
|
-
export const SYSTEM_AGENT_FOR = (taskKind) =>
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1047
|
+
export const SYSTEM_AGENT_FOR = (taskKind) => AGENT_CONTRACTS[agentTaskKindOf(taskKind)];
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* THE STRINGS EACH KIND IS HANDED, keyed by the kinds in agentTaskKinds.mjs —
|
|
1051
|
+
* its contract, and (for a kind that changes nothing in the repository) the
|
|
1052
|
+
* clause its kickoff says it hands back. A kind added there without a line
|
|
1053
|
+
* here fails `agentTaskKinds.test.mjs`, never a turn.
|
|
1054
|
+
*/
|
|
1055
|
+
export const AGENT_CONTRACTS = Object.freeze({
|
|
1056
|
+
code: SYSTEM_AGENT,
|
|
1057
|
+
design: SYSTEM_AGENT_DESIGN,
|
|
1058
|
+
research: SYSTEM_AGENT_RESEARCH,
|
|
1059
|
+
});
|
|
1060
|
+
export const AGENT_HANDS_BACK = Object.freeze({
|
|
1061
|
+
design: 'a DESIGN card: it hands back a mockup',
|
|
1062
|
+
research: 'a RESEARCH card: it hands back a write-up',
|
|
1063
|
+
});
|
|
1078
1064
|
|
|
1079
1065
|
/**
|
|
1080
1066
|
* The turn itself.
|
|
@@ -1102,7 +1088,7 @@ export const AGENT_TASK_KICKOFF = ({ agentName, task, position, total }) =>
|
|
|
1102
1088
|
// is not said at all. The code branch above is byte-for-byte what every
|
|
1103
1089
|
// turn got before kinds existed.
|
|
1104
1090
|
`You are the agent "${safeName(agentName)}", working card ${position} of ${total} ` +
|
|
1105
|
-
`in this run. This card is ${agentTaskKindOf(task?.taskKind)
|
|
1091
|
+
`in this run. This card is ${AGENT_HANDS_BACK[agentTaskKindOf(task?.taskKind)]}, ` +
|
|
1106
1092
|
`written under .flowviant/artifacts/, and changes nothing in the repository.\n\n` +
|
|
1107
1093
|
`${fence('THE CARD', taskBlock(task))}\n\n` +
|
|
1108
1094
|
`Do it, and end with the JSON object.`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REPO IDENTITY BY PATH — whether two spellings name the same checkout. One
|
|
3
|
+
* home for the rule the instance lock (neighbour takeover, same-repo
|
|
4
|
+
* replacement) and the credential store (collisions, the login refusal, start
|
|
5
|
+
* resolution) both decide on. Split out (SOLID F057) because the two were
|
|
6
|
+
* hand-copied: a change to symlink or missing-path treatment made in one would
|
|
7
|
+
* let a lock and a credential disagree about which directory a daemon serves.
|
|
8
|
+
*
|
|
9
|
+
* Realpath first (symlinks and trailing slashes both resolve); a path that no
|
|
10
|
+
* longer exists falls back to its own spelling minus trailing slashes, so a
|
|
11
|
+
* deleted checkout still matches the lock or entry that names it.
|
|
12
|
+
*/
|
|
13
|
+
import { realpathSync } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
/** Same directory, whatever it is spelled as. A blank side matches nothing. */
|
|
16
|
+
export function samePath(a, b) {
|
|
17
|
+
if (!a || !b) return false;
|
|
18
|
+
const norm = (v) => {
|
|
19
|
+
try {
|
|
20
|
+
return realpathSync(v);
|
|
21
|
+
} catch {
|
|
22
|
+
return String(v).replace(/\/+$/, '');
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
return norm(a) === norm(b);
|
|
26
|
+
}
|
package/bin/lib/resources.mjs
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* ── AND SINCE 2026-09-14 THERE IS A SECOND READER, WHICH IS NOT TELEMETRY ──
|
|
19
19
|
*
|
|
20
20
|
* The paragraph above is about the SNAPSHOT and stands unchanged. What is added
|
|
21
|
-
* below — `memAvailableBytes` and `
|
|
21
|
+
* below — `memAvailableBytes` and `measurePressure`, judged by `pressureVerdict`
|
|
22
|
+
* in pressurePolicy.mjs — is read at the moment
|
|
22
23
|
* this machine is about to spawn one more CLI, and it exists because it froze
|
|
23
24
|
* somebody's computer: `MAX_CONCURRENT` had been enforced nowhere since
|
|
24
25
|
* dispatch was deleted, the session-turn lane had no slice at all, and nothing
|
|
@@ -49,9 +50,6 @@ import { readFileSync, readdirSync, statfsSync } from 'node:fs';
|
|
|
49
50
|
import { cpus, freemem, loadavg, platform } from 'node:os';
|
|
50
51
|
import { MACHINE } from './config.mjs';
|
|
51
52
|
|
|
52
|
-
const MiB = 1024 * 1024;
|
|
53
|
-
const GiB = 1024 * MiB;
|
|
54
|
-
|
|
55
53
|
const readFile = (p) => {
|
|
56
54
|
try {
|
|
57
55
|
return readFileSync(p, 'utf8').trim();
|
|
@@ -162,7 +160,7 @@ export function processTreeRssBytes(pid) {
|
|
|
162
160
|
* pages is the nearest thing it will say out loud.
|
|
163
161
|
*
|
|
164
162
|
* The cgroup is asked SECOND and narrows rather than replaces, for the reason
|
|
165
|
-
* `
|
|
163
|
+
* `deriveMachineLimits` gives in machineLimits.mjs: a 4GB container on a 256GB box reads the
|
|
166
164
|
* host through the os module, and oversubscribing there ends with the OOM
|
|
167
165
|
* killer picking a victim by resident size — frequently not the offender.
|
|
168
166
|
*
|
|
@@ -248,26 +246,6 @@ function darwinAvailableBytes() {
|
|
|
248
246
|
return pages > 0 ? pages * page : null;
|
|
249
247
|
}
|
|
250
248
|
|
|
251
|
-
/** The guard off entirely — an escape hatch for a box whose operator knows
|
|
252
|
-
* better than the thresholds. Read per call, not captured at import, so it is
|
|
253
|
-
* true of the environment the daemon is in RIGHT NOW rather than the one it
|
|
254
|
-
* booted in. `absence` and `'0'` both mean the guard is on. */
|
|
255
|
-
export function pressureGuardOff() {
|
|
256
|
-
return process.env.FLOWVIANT_NO_PRESSURE_GUARD === '1';
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** An operator's override, or the default. Positive finite numbers only: a
|
|
260
|
-
* typo'd `FLOWVIANT_MIN_FREE_MB=lots` must fall back to the default rather
|
|
261
|
-
* than turning every comparison into NaN, which compares false and would
|
|
262
|
-
* silently disable the guard it was trying to tune. */
|
|
263
|
-
const envNum = (name, fallback) => {
|
|
264
|
-
const n = Number(process.env[name]);
|
|
265
|
-
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const sizeWord = (n) =>
|
|
269
|
-
n >= GiB ? `${(n / GiB).toFixed(1)} GB` : `${Math.round(n / MiB)} MB`;
|
|
270
|
-
|
|
271
249
|
/** How long one measurement stands. The reconcile loop asks several times a
|
|
272
250
|
* tick (one per admission point) and every answer would otherwise be a fresh
|
|
273
251
|
* /proc read — or, on macOS, a fresh `vm_stat` spawn, which is a process per
|
|
@@ -308,50 +286,10 @@ export function measurePressure() {
|
|
|
308
286
|
return m;
|
|
309
287
|
}
|
|
310
288
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
* unattended work — agent turns, a Deploy press's planner, the wiki
|
|
316
|
-
* cartographer — which nobody is sitting in front of and which the server will
|
|
317
|
-
* cheerfully re-offer next poll, so it yields early and generously. A session
|
|
318
|
-
* turn is `interactive`: somebody is watching a composer they just pressed
|
|
319
|
-
* enter in, and deferring that is a visible stall, so it holds out until the
|
|
320
|
-
* box is genuinely about to fall over.
|
|
321
|
-
*
|
|
322
|
-
* THE REASON IS MEASURED WORDS AND NOTHING ELSE. No adjectives about the
|
|
323
|
-
* machine, no advice, no "try again in a few minutes" — a sentence naming the
|
|
324
|
-
* number that fired, which is the only thing this side actually knows.
|
|
325
|
-
*
|
|
326
|
-
* An unreadable measurement contributes NO verdict: memory that could not be
|
|
327
|
-
* read cannot refuse, and a load average this platform does not publish cannot
|
|
328
|
-
* either. Ignorance never withholds.
|
|
329
|
-
*/
|
|
330
|
-
export function pressureVerdict(level, measured) {
|
|
331
|
-
if (pressureGuardOff()) return null;
|
|
332
|
-
const m = measured ?? measurePressure();
|
|
333
|
-
const avail = Number.isFinite(m?.memAvailable) ? m.memAvailable : null;
|
|
334
|
-
if (level === 'interactive') {
|
|
335
|
-
const floor = envNum('FLOWVIANT_CRITICAL_FREE_MB', 400) * MiB;
|
|
336
|
-
if (avail !== null && avail < floor)
|
|
337
|
-
return { reason: `nearly out of memory — ${sizeWord(avail)} available` };
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
const total = Number.isFinite(m?.memTotal) && m.memTotal > 0 ? m.memTotal : 0;
|
|
341
|
-
// A fixed floor AND a proportional one, whichever is larger: 1 GB is the
|
|
342
|
-
// right reserve on a laptop and is nothing on a 256 GB box, where six per
|
|
343
|
-
// cent is the honest "the page cache is already being squeezed" line.
|
|
344
|
-
const floor = Math.max(envNum('FLOWVIANT_MIN_FREE_MB', 1024) * MiB, Math.round(total * 0.06));
|
|
345
|
-
if (avail !== null && avail < floor)
|
|
346
|
-
return { reason: `low memory — ${sizeWord(avail)} of ${sizeWord(total)} available` };
|
|
347
|
-
const cores = Number.isFinite(m?.cores) && m.cores > 0 ? m.cores : null;
|
|
348
|
-
const load = Number.isFinite(m?.load1) ? m.load1 : null;
|
|
349
|
-
if (load !== null && cores !== null && load > cores * envNum('FLOWVIANT_MAX_LOAD_PER_CORE', 4))
|
|
350
|
-
return {
|
|
351
|
-
reason: `cpu overloaded — load ${load.toFixed(1)} on ${cores} core${cores === 1 ? '' : 's'}`,
|
|
352
|
-
};
|
|
353
|
-
return null;
|
|
354
|
-
}
|
|
289
|
+
// THE VERDICT ITSELF — "is this a moment to start another CLI?" — lives in
|
|
290
|
+
// pressurePolicy.mjs (2026-09-26), pure, with its thresholds passed in. This
|
|
291
|
+
// module measures; admission.mjs hands the measurement and the live
|
|
292
|
+
// environment's thresholds to it per decision.
|
|
355
293
|
|
|
356
294
|
/** Free bytes on the volume holding the worktrees. */
|
|
357
295
|
export function diskFreeBytes(path) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resume-lost rule — when a resumed CLI turn found no conversation and
|
|
3
|
+
* runs once more fresh. ONE home for both lanes (the Workbench tab and the
|
|
4
|
+
* agent turn), split out of work.mjs because the agent lane called the
|
|
5
|
+
* classifier without the runtime and init evidence it needs (SOLID F001): a
|
|
6
|
+
* successful Claude answer that mentioned "session … not found" re-ran the
|
|
7
|
+
* whole card in a fresh conversation after the first run had already
|
|
8
|
+
* committed. The evidence is now part of the call's shape, not a default.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A RESUME THAT FOUND NO CONVERSATION, in the CLI's own words.
|
|
13
|
+
*
|
|
14
|
+
* "Produced nothing" was the only signal the retry backstop had, and it is not
|
|
15
|
+
* the signal these failures give: Claude Code answers a dead `--resume` id with
|
|
16
|
+
* a result event carrying `errors: ['No conversation found with session ID: …']`
|
|
17
|
+
* AND writes the same line to stderr, so `out` is non-empty, the backstop never
|
|
18
|
+
* fired, and the turn SETTLED SUCCESSFULLY with the error as its answer. Every
|
|
19
|
+
* later message in that tab replied the same way — the marker file still held
|
|
20
|
+
* the dead id, nothing rewrote it (that only happens when an init event is
|
|
21
|
+
* seen, and there is none), and no surface offered a way to clear it. A tab
|
|
22
|
+
* bricked forever by its own CLI pruning its history, which it does on its own
|
|
23
|
+
* schedule.
|
|
24
|
+
*
|
|
25
|
+
* Matched on the CLI's phrasing rather than a code, because neither runtime
|
|
26
|
+
* gives one. Deliberately narrow: it must not swallow a rate limit or a
|
|
27
|
+
* permission refusal, both of which are real answers that should stand.
|
|
28
|
+
*/
|
|
29
|
+
const RESUME_LOST = [
|
|
30
|
+
/no conversation found/i,
|
|
31
|
+
/no session found/i,
|
|
32
|
+
/session .{0,80}not found/i,
|
|
33
|
+
/conversation .{0,80}not found/i,
|
|
34
|
+
/thread .{0,80}not found/i,
|
|
35
|
+
/trajectory not found/i,
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* …BUT ONLY WHEN THE CLI SAID IT, NOT WHEN THE REPLY DID (2026-09-24).
|
|
39
|
+
*
|
|
40
|
+
* Under `answerFromResult` a SUCCESSFUL turn's `out` is Claude's reply, and the
|
|
41
|
+
* patterns above were tested against all of it — so "the session cookie was not
|
|
42
|
+
* found because SameSite dropped it" threw away a real answer and re-ran the
|
|
43
|
+
* same message in a fresh, context-free conversation, whose init id then
|
|
44
|
+
* re-pinned the tab: the first run's edits and commits had already happened,
|
|
45
|
+
* the second repeated or misread them, and the tab's history was gone for good.
|
|
46
|
+
* Web debugging talk says "session … not found" all day.
|
|
47
|
+
*
|
|
48
|
+
* So the text alone is never the evidence. Claude Code emits `system.init` on
|
|
49
|
+
* EVERY turn that reached a conversation, and a dead `--resume` id fails before
|
|
50
|
+
* one is emitted — so for Claude, a lost conversation is the phrase AND no init
|
|
51
|
+
* event. Codex and agy give no such marker to this caller, so for them the
|
|
52
|
+
* phrase must BE the reply: a short line, the shape a CLI error takes, never a
|
|
53
|
+
* paragraph that happens to contain it.
|
|
54
|
+
*/
|
|
55
|
+
export const RESUME_LOST_MAX_CHARS = 400;
|
|
56
|
+
export const resumeConversationLost = (text, { runtime, sawInit } = {}) => {
|
|
57
|
+
const t = String(text || '').trim();
|
|
58
|
+
if (!t || !RESUME_LOST.some((re) => re.test(t))) return false;
|
|
59
|
+
// Claude's evidence is the init event, and it must be SAID: a caller that
|
|
60
|
+
// passes no `sawInit` measured nothing, and an unmeasured init never
|
|
61
|
+
// authorises a second run of a turn that may already have committed.
|
|
62
|
+
if (runtime === 'claude') return sawInit === false;
|
|
63
|
+
// A caller that named no CLI measured nothing either.
|
|
64
|
+
if (typeof runtime !== 'string' || !runtime) return false;
|
|
65
|
+
return t.length <= RESUME_LOST_MAX_CHARS;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* WHETHER A RESUMED TURN RUNS ONCE MORE FRESH — both lanes' whole question.
|
|
70
|
+
*
|
|
71
|
+
* A resume that came back EMPTY, or with only the CLI saying the conversation
|
|
72
|
+
* is gone. `sawInit` is whether THIS spawn's CLI emitted `system.init` (only
|
|
73
|
+
* Claude's does, and only Claude's is read). A fresh turn is never retried:
|
|
74
|
+
* `resume` false answers false whatever the text says.
|
|
75
|
+
*/
|
|
76
|
+
export const resumeRetriesFresh = ({ resume, out, runtime, sawInit }) =>
|
|
77
|
+
Boolean(resume) && (!String(out || '').trim() || resumeConversationLost(out, { runtime, sawInit }));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* ONE SPAWN, AND AT MOST ONE FRESH RETRY — the agent lane's whole sequence,
|
|
81
|
+
* with the init evidence measured HERE rather than by a flag each caller must
|
|
82
|
+
* remember to set and reset (the bug F001 was: the lane asked the rule
|
|
83
|
+
* without it). `onInit` is wrapped, so a caller's own init handler still runs;
|
|
84
|
+
* the retry is a fresh conversation (`resume: false`, no pinned thread) in the
|
|
85
|
+
* same place, and `beforeFresh` lets the caller drop what the first spawn
|
|
86
|
+
* taught it. `runTurn` is the caller's (runTurn.mjs); this module imports
|
|
87
|
+
* nothing.
|
|
88
|
+
*
|
|
89
|
+
* The Workbench tab keeps its own sequence: its evidence is the session id it
|
|
90
|
+
* persists, and a fresh retry must NOT clear it (last write heals the marker).
|
|
91
|
+
*/
|
|
92
|
+
export async function runTurnResumingOnce(runTurn, args, { resume, runtime, beforeFresh } = {}) {
|
|
93
|
+
let sawInit = false;
|
|
94
|
+
const callerInit = args.onInit;
|
|
95
|
+
const measured = {
|
|
96
|
+
...args,
|
|
97
|
+
onInit: (i) => {
|
|
98
|
+
sawInit = true;
|
|
99
|
+
callerInit?.(i);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const out = await runTurn({ ...measured, resume });
|
|
103
|
+
if (!resumeRetriesFresh({ resume, out, runtime, sawInit })) return out;
|
|
104
|
+
beforeFresh?.();
|
|
105
|
+
sawInit = false;
|
|
106
|
+
return runTurn({ ...measured, resume: false, resumeThreadId: undefined });
|
|
107
|
+
}
|