flowviant 0.125.22 → 0.125.24
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 +12 -0
- package/bin/cli.mjs +5 -0
- package/bin/lib/agentBrief.mjs +4 -0
- package/bin/lib/agentTaskKinds.mjs +23 -3
- package/bin/lib/agentTaskTypes.mjs +9 -1
- package/bin/lib/artifactContracts.mjs +71 -1
- package/bin/lib/artifactPolicy.mjs +5 -0
- package/bin/lib/artifactPreview.mjs +4 -3
- package/bin/lib/artifacts.mjs +4 -0
- package/bin/lib/codexFencedMount.mjs +3 -0
- package/bin/lib/knowledgeLibrary.mjs +14 -10
- package/bin/lib/pinnedCapture.mjs +58 -7
- package/bin/lib/projectTools.mjs +4 -1
- package/bin/lib/prompts.mjs +11 -4
- package/bin/lib/runTurn.mjs +1 -0
- package/bin/lib/runtimeCodex.mjs +8 -6
- package/bin/lib/turnLock.mjs +14 -1
- package/bin/lib/videoEncode.mjs +406 -0
- package/bin/lib/videoRender.mjs +529 -0
- package/bin/lib/work.mjs +1 -0
- package/bin/lib/workAgentTools.mjs +8 -4
- package/bin/lib/workAgentTurnBegun.mjs +6 -2
- package/bin/lib/workAgentTurnExecution.mjs +27 -1
- package/bin/lib/workPlaces.mjs +13 -2
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/worktreeDiff.mjs +154 -55
- package/bin/lib/worktreeHealth.mjs +23 -0
- package/package.json +1 -1
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE VIDEO CARD'S RENDER TOOL — `card_video.render_video` (2026-10-26, the
|
|
3
|
+
* owner: video gets "own kind" that hands back real files).
|
|
4
|
+
*
|
|
5
|
+
* Neither Claude Code nor Codex makes video. Each WRITES CODE that draws an
|
|
6
|
+
* animation — an HTML page of CSS, canvas, SVG or WebGL — and this tool turns
|
|
7
|
+
* that page into the file the card hands back: a GIF, an MP4 or a WebM, under
|
|
8
|
+
* `.flowviant/artifacts/`. The agent calls it, reads what it made (the answer
|
|
9
|
+
* carries a contact sheet of its first, middle and last frames), and renders
|
|
10
|
+
* again until it is right.
|
|
11
|
+
*
|
|
12
|
+
* WHY A DAEMON TOOL AND NOT A WIDER FENCE. The card runs under the design
|
|
13
|
+
* posture on both CLIs — read the repo, write only the artifacts directory and
|
|
14
|
+
* scratch — and that posture stays exactly what it is. Letting the turn run a
|
|
15
|
+
* browser and an encoder itself would have meant an OS sandbox on Claude
|
|
16
|
+
* (whose default leaves the worktree writable, and which native Windows does
|
|
17
|
+
* not have) and a Chrome inside Codex's bubblewrap. Here the browser runs in
|
|
18
|
+
* the daemon, under the capture that already holds a model-written page to
|
|
19
|
+
* one URL with its network fenced (`pinnedCapture.mjs`), the same one the
|
|
20
|
+
* mockup thumbnail and the 3D model's export use, and the page it renders is
|
|
21
|
+
* the SCRUBBED source (`uplinkScrub.mjs`), as the thumbnail is, so a value
|
|
22
|
+
* from this machine's environment never reaches a pixel.
|
|
23
|
+
*
|
|
24
|
+
* HOW A FRAME IS TAKEN. The page runs in real time but on a clock of its own
|
|
25
|
+
* (`VIDEO_CLOCK`, installed before its scripts): `performance.now()`, `Date`,
|
|
26
|
+
* timers and requestAnimationFrame read and fire on it, and CSS, Web and SVG
|
|
27
|
+
* (SMIL) animations are pinned to it, so each frame is the page exactly one
|
|
28
|
+
* step after the last, whatever the machine's speed, at about 30–60 ms of
|
|
29
|
+
* wall time a frame (Chrome 149, 2026-10-26). Chrome's own virtual time was
|
|
30
|
+
* measured unusable for this (pinnedCapture.mjs): screenshots hung once a
|
|
31
|
+
* finite animation ended, and CSS time ran ahead of the step.
|
|
32
|
+
*
|
|
33
|
+
* HOW A FILE IS MADE (videoEncode.mjs). A GIF is encoded here. An MP4 (H.264)
|
|
34
|
+
* or WebM (VP9, else VP8) is encoded by the browser's own WebCodecs, in a
|
|
35
|
+
* second browser holding a page of the daemon's own, and muxed here. MEASURED
|
|
36
|
+
* the same day: no ffmpeg on the machine, and none needed.
|
|
37
|
+
*
|
|
38
|
+
* WHAT STOPS A RENDER, IN WORDS: no browser on the machine (the turn says so
|
|
39
|
+
* and stops — the contract forbids handing back a still or the page in place
|
|
40
|
+
* of the file), a page that leaves itself, an encoder this browser lacks, and
|
|
41
|
+
* a file over its ceiling (GIF 2 MB, MP4/WebM 32 MB — the artifact policy's).
|
|
42
|
+
*
|
|
43
|
+
* THE HELD-TOOL SHAPE is uiReview.mjs's: the daemon listens on loopback for
|
|
44
|
+
* this one run behind a capability, and `flowviant __video-render-mcp` is the
|
|
45
|
+
* thin stdio MCP the CLI mounts as `card_video` (workAgentTools.mjs).
|
|
46
|
+
*/
|
|
47
|
+
import { createHash } from 'node:crypto';
|
|
48
|
+
import { lstatSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
49
|
+
import { deflateSync } from 'node:zlib';
|
|
50
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
51
|
+
import { ARTIFACT_MAX_BYTES, ARTIFACT_VIDEO_MAX_BYTES } from './artifactPolicy.mjs';
|
|
52
|
+
import { ARTIFACT_DIR, FENCE_TMP_DIR, ensureArtifactDir } from './artifacts.mjs';
|
|
53
|
+
import { capturePinnedPage } from './pinnedCapture.mjs';
|
|
54
|
+
import { previewSubresourceAllowed, serveArtifactPage } from './artifactPreview.mjs';
|
|
55
|
+
import { createGifEncoder, decodePng, muxMp4, muxWebm, sniffVideo, toRgb } from './videoEncode.mjs';
|
|
56
|
+
import { scrub } from './uplinkScrub.mjs';
|
|
57
|
+
import { listenHeldTool, heldToolArgv } from './heldTool.mjs';
|
|
58
|
+
import { serveMcpStdio } from './mcpStdio.mjs';
|
|
59
|
+
|
|
60
|
+
/** The MCP server's name on a video turn, and its one tool. */
|
|
61
|
+
export const VIDEO_SERVER = 'card_video';
|
|
62
|
+
export const VIDEO_TOOL = 'render_video';
|
|
63
|
+
|
|
64
|
+
/** The bounds of one render. A runaway bound, never a budget: inside them the
|
|
65
|
+
* agent chooses. GIF's are tighter — every frame is encoded here, in the
|
|
66
|
+
* daemon, and browsers clamp a GIF delay under 2 cs to 10 cs. */
|
|
67
|
+
export const VIDEO_LIMITS = Object.freeze({
|
|
68
|
+
minSide: 16,
|
|
69
|
+
maxSide: 1920,
|
|
70
|
+
maxGifSide: 1280,
|
|
71
|
+
maxFps: 60,
|
|
72
|
+
maxGifFps: 50,
|
|
73
|
+
maxSeconds: 60,
|
|
74
|
+
maxFrames: 1800,
|
|
75
|
+
maxGifFrames: 400,
|
|
76
|
+
maxPageBytes: 2 * 1024 * 1024,
|
|
77
|
+
});
|
|
78
|
+
/** How long one render may take, and so the tool call's timeout on Codex
|
|
79
|
+
* (whose MCP default is 60 s). */
|
|
80
|
+
export const VIDEO_RENDER_TIMEOUT_MS = 300_000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* THE PAGE'S OWN CLOCK (2026-10-26), installed before the page's scripts —
|
|
84
|
+
* the shape frame-exact capture tools use, because stepping Chrome's virtual
|
|
85
|
+
* time hung and drifted (pinnedCapture.mjs). Time is virtual milliseconds
|
|
86
|
+
* since the page started, and stands still until the render moves it:
|
|
87
|
+
*
|
|
88
|
+
* · `performance.now()`, `Date.now()` and `new Date()` read it;
|
|
89
|
+
* · `setTimeout`/`setInterval` fire on it, in time order, as it passes;
|
|
90
|
+
* · requestAnimationFrame callbacks run once per frame, handed the frame's
|
|
91
|
+
* time;
|
|
92
|
+
* · every CSS animation or transition, Web Animation and SVG (SMIL)
|
|
93
|
+
* animation is paused the first time it is seen and held to the time
|
|
94
|
+
* since — one the page paused itself is left alone.
|
|
95
|
+
*
|
|
96
|
+
* MEASURED on Chrome 149 (2026-10-26) at 40 ms steps: performance.now(), the
|
|
97
|
+
* rAF time, a CSS animation's time and Date.now() each moved exactly 40 ms a
|
|
98
|
+
* frame and a 10 ms interval fired exactly four times; a 1 s CSS animation
|
|
99
|
+
* of 0→100 px drew at 0, 10, 20 … 100 px at 100 ms steps. Its screenshots
|
|
100
|
+
* run on real time, where an occasional one waits ~2.7 s for Chrome's next
|
|
101
|
+
* frame — slower, never stuck.
|
|
102
|
+
*
|
|
103
|
+
* `__flowviantFrame(ms)` moves the clock `ms` and draws one frame; the render
|
|
104
|
+
* screenshots after it. A callback that throws is reported as the page's own
|
|
105
|
+
* error, never as the render's. Loops are bounded per frame.
|
|
106
|
+
*/
|
|
107
|
+
export const VIDEO_CLOCK = `(() => {
|
|
108
|
+
'use strict';
|
|
109
|
+
let now = 0;
|
|
110
|
+
const epoch = Date.now();
|
|
111
|
+
const RealDate = Date;
|
|
112
|
+
const timers = new Map();
|
|
113
|
+
const frames = new Map();
|
|
114
|
+
let seq = 0;
|
|
115
|
+
const report = (e) => queueMicrotask(() => { throw e; });
|
|
116
|
+
const call = (fn, args) => { try { (typeof fn === 'function' ? fn : new Function(String(fn)))(...args); } catch (e) { report(e); } };
|
|
117
|
+
performance.now = () => now;
|
|
118
|
+
function FDate(...a) {
|
|
119
|
+
if (!new.target) return new RealDate(epoch + now).toString();
|
|
120
|
+
return a.length ? new RealDate(...a) : new RealDate(epoch + now);
|
|
121
|
+
}
|
|
122
|
+
FDate.prototype = RealDate.prototype;
|
|
123
|
+
FDate.now = () => epoch + now;
|
|
124
|
+
FDate.parse = RealDate.parse;
|
|
125
|
+
FDate.UTC = RealDate.UTC;
|
|
126
|
+
window.Date = FDate;
|
|
127
|
+
window.setTimeout = (fn, ms, ...args) => { const id = ++seq; timers.set(id, { at: now + Math.max(0, Number(ms) || 0), fn, args, every: 0 }); return id; };
|
|
128
|
+
window.setInterval = (fn, ms, ...args) => { const every = Math.max(1, Number(ms) || 0); const id = ++seq; timers.set(id, { at: now + every, fn, args, every }); return id; };
|
|
129
|
+
window.clearTimeout = window.clearInterval = (id) => { timers.delete(id); };
|
|
130
|
+
window.requestAnimationFrame = (cb) => { const id = ++seq; frames.set(id, cb); return id; };
|
|
131
|
+
window.cancelAnimationFrame = (id) => { frames.delete(id); };
|
|
132
|
+
const held = new WeakMap();
|
|
133
|
+
const svgs = new WeakMap();
|
|
134
|
+
// An animation's time starts when it is first seen: whatever real
|
|
135
|
+
// milliseconds it ran while the page loaded are not the page's time.
|
|
136
|
+
const pin = () => {
|
|
137
|
+
for (const a of document.getAnimations()) {
|
|
138
|
+
if (!held.has(a)) {
|
|
139
|
+
if (a.playState === 'paused') continue;
|
|
140
|
+
held.set(a, now);
|
|
141
|
+
a.pause();
|
|
142
|
+
}
|
|
143
|
+
a.currentTime = now - held.get(a);
|
|
144
|
+
}
|
|
145
|
+
for (const svg of document.querySelectorAll('svg')) {
|
|
146
|
+
if (typeof svg.setCurrentTime !== 'function') continue;
|
|
147
|
+
if (!svgs.has(svg)) { svgs.set(svg, now / 1000); svg.pauseAnimations(); }
|
|
148
|
+
svg.setCurrentTime(now / 1000 - svgs.get(svg));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
Object.defineProperty(window, '__flowviantFrame', { value: (ms) => {
|
|
152
|
+
const until = now + Math.max(0, Number(ms) || 0);
|
|
153
|
+
for (let n = 0; n < 100000; n++) {
|
|
154
|
+
let next = null;
|
|
155
|
+
for (const [id, t] of timers) if (t.at <= until && (!next || t.at < next[1].at || (t.at === next[1].at && id < next[0]))) next = [id, t];
|
|
156
|
+
if (!next) break;
|
|
157
|
+
const [id, t] = next;
|
|
158
|
+
now = Math.max(now, t.at);
|
|
159
|
+
if (t.every) t.at += t.every; else timers.delete(id);
|
|
160
|
+
call(t.fn, t.args);
|
|
161
|
+
// What it started starts now, not at the frame's end.
|
|
162
|
+
pin();
|
|
163
|
+
}
|
|
164
|
+
now = until;
|
|
165
|
+
const due = [...frames.values()];
|
|
166
|
+
frames.clear();
|
|
167
|
+
for (const cb of due) call(cb, [now]);
|
|
168
|
+
pin();
|
|
169
|
+
return now;
|
|
170
|
+
} });
|
|
171
|
+
})();`;
|
|
172
|
+
|
|
173
|
+
const FORMATS = Object.freeze({ gif: 'gif', mp4: 'mp4', webm: 'webm' });
|
|
174
|
+
const SCRATCH_ROOTS = [ARTIFACT_DIR, FENCE_TMP_DIR];
|
|
175
|
+
|
|
176
|
+
/** A relative path inside `wt`, under the artifacts directory or the turn's
|
|
177
|
+
* scratch, of a regular file reached through no symlink — or a refusal. */
|
|
178
|
+
function pagePath(wt, raw) {
|
|
179
|
+
const rel = String(raw ?? '').replace(/^\.\//, '');
|
|
180
|
+
if (!rel || isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) throw new Error(`page must be a relative path under ${ARTIFACT_DIR}/ or ${FENCE_TMP_DIR}/`);
|
|
181
|
+
if (!SCRATCH_ROOTS.some((root) => rel.startsWith(`${root}/`))) throw new Error(`page must be under ${ARTIFACT_DIR}/ or ${FENCE_TMP_DIR}/ — write the animation page there first`);
|
|
182
|
+
if (!/\.html?$/i.test(rel)) throw new Error('page must be an .html file');
|
|
183
|
+
const abs = resolve(wt, rel);
|
|
184
|
+
let stat;
|
|
185
|
+
try { stat = lstatSync(abs); } catch { throw new Error(`no page at ${rel}`); }
|
|
186
|
+
if (stat.isSymbolicLink()) throw new Error(`${rel} is a symlink; write the page itself there`);
|
|
187
|
+
if (!stat.isFile()) throw new Error(`${rel} is not a regular file`);
|
|
188
|
+
if (realpathSync(abs) !== abs) throw new Error(`${rel} is reached through a symlink`);
|
|
189
|
+
if (stat.size > VIDEO_LIMITS.maxPageBytes) throw new Error(`${rel} is over 2 MB`);
|
|
190
|
+
return { rel, abs };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One render request, checked and normalised — or a refusal in words. `out`
|
|
195
|
+
* is a bare file name whose extension is the format; it is written under the
|
|
196
|
+
* artifacts directory.
|
|
197
|
+
*/
|
|
198
|
+
export function validateRenderRequest(input) {
|
|
199
|
+
const req = input && typeof input === 'object' ? input : {};
|
|
200
|
+
const out = String(req.out ?? '');
|
|
201
|
+
const match = /^([A-Za-z0-9][A-Za-z0-9._-]{0,99})\.(gif|mp4|webm)$/i.exec(out);
|
|
202
|
+
if (!match) throw new Error('out must be a file name ending .gif, .mp4 or .webm (letters, digits, dot, dash, underscore; no folders)');
|
|
203
|
+
const format = FORMATS[match[2].toLowerCase()];
|
|
204
|
+
const gif = format === 'gif';
|
|
205
|
+
const int = (name, v, lo, hi) => {
|
|
206
|
+
if (!Number.isInteger(v) || v < lo || v > hi) throw new Error(`${name} must be a whole number from ${lo} to ${hi}`);
|
|
207
|
+
return v;
|
|
208
|
+
};
|
|
209
|
+
const maxSide = gif ? VIDEO_LIMITS.maxGifSide : VIDEO_LIMITS.maxSide;
|
|
210
|
+
const width = int('width', req.width, VIDEO_LIMITS.minSide, maxSide);
|
|
211
|
+
const height = int('height', req.height, VIDEO_LIMITS.minSide, maxSide);
|
|
212
|
+
if (!gif && (width % 2 || height % 2)) throw new Error('width and height must be even for MP4 and WebM');
|
|
213
|
+
const fps = int('fps', req.fps, 1, gif ? VIDEO_LIMITS.maxGifFps : VIDEO_LIMITS.maxFps);
|
|
214
|
+
const seconds = Number(req.seconds);
|
|
215
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > VIDEO_LIMITS.maxSeconds) throw new Error(`seconds must be more than 0 and at most ${VIDEO_LIMITS.maxSeconds}`);
|
|
216
|
+
const start = req.start === undefined ? 0 : Number(req.start);
|
|
217
|
+
if (!Number.isFinite(start) || start < 0 || start > VIDEO_LIMITS.maxSeconds) throw new Error(`start must be from 0 to ${VIDEO_LIMITS.maxSeconds} seconds`);
|
|
218
|
+
const frames = Math.round(seconds * fps);
|
|
219
|
+
const maxFrames = gif ? VIDEO_LIMITS.maxGifFrames : VIDEO_LIMITS.maxFrames;
|
|
220
|
+
if (frames < 2) throw new Error('a render needs at least two frames (seconds × fps ≥ 2) — one frame is a still, not an animation');
|
|
221
|
+
if (frames > maxFrames) throw new Error(`${frames} frames is over the ${maxFrames}-frame bound for ${format.toUpperCase()} — lower fps or seconds`);
|
|
222
|
+
return { page: req.page, out: `${match[1]}.${format}`, format, width, height, fps, seconds, start, frames };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── The contact sheet the answer carries ────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/** Packed RGB → a PNG (filter 0, one IDAT). */
|
|
228
|
+
export function encodePng(rgb, width, height) {
|
|
229
|
+
const raw = Buffer.alloc(height * (width * 3 + 1));
|
|
230
|
+
for (let y = 0; y < height; y++) rgb.copy(raw, y * (width * 3 + 1) + 1, y * width * 3, (y + 1) * width * 3);
|
|
231
|
+
const crcTable = encodePng.crc ??= Array.from({ length: 256 }, (_, n) => {
|
|
232
|
+
let c = n;
|
|
233
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
234
|
+
return c >>> 0;
|
|
235
|
+
});
|
|
236
|
+
const chunk = (type, body) => {
|
|
237
|
+
const head = Buffer.alloc(8); head.writeUInt32BE(body.length); head.write(type, 4, 'latin1');
|
|
238
|
+
let c = 0xffffffff;
|
|
239
|
+
for (const b of Buffer.concat([head.subarray(4), body])) c = crcTable[(c ^ b) & 255] ^ (c >>> 8);
|
|
240
|
+
const tail = Buffer.alloc(4); tail.writeUInt32BE((c ^ 0xffffffff) >>> 0);
|
|
241
|
+
return Buffer.concat([head, body, tail]);
|
|
242
|
+
};
|
|
243
|
+
const ihdr = Buffer.alloc(13);
|
|
244
|
+
ihdr.writeUInt32BE(width); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 2;
|
|
245
|
+
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Frames side by side, each scaled (nearest) to at most 320 wide, on a white
|
|
249
|
+
* strip with a gap — what the agent looks at to judge its own render. */
|
|
250
|
+
export function contactSheet(frames) {
|
|
251
|
+
const scale = Math.min(1, 320 / frames[0].width);
|
|
252
|
+
const w = Math.max(1, Math.round(frames[0].width * scale)), h = Math.max(1, Math.round(frames[0].height * scale));
|
|
253
|
+
const gap = 8, width = frames.length * w + (frames.length - 1) * gap;
|
|
254
|
+
const rgb = Buffer.alloc(width * h * 3, 255);
|
|
255
|
+
frames.forEach((f, n) => {
|
|
256
|
+
for (let y = 0; y < h; y++) {
|
|
257
|
+
const sy = Math.min(f.height - 1, Math.floor(y / scale));
|
|
258
|
+
for (let x = 0; x < w; x++) {
|
|
259
|
+
const sx = Math.min(f.width - 1, Math.floor(x / scale));
|
|
260
|
+
f.rgb.copy(rgb, (y * width + n * (w + gap) + x) * 3, (sy * f.width + sx) * 3, (sy * f.width + sx) * 3 + 3);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
return encodePng(rgb, width, h);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── The encoder page (the daemon's own, real time, no network) ──────────
|
|
268
|
+
|
|
269
|
+
const ENCODER_CSP = "default-src 'none'; script-src 'unsafe-inline'; img-src blob: data:";
|
|
270
|
+
export const ENCODER_PAGE = `<!doctype html><meta charset="utf-8"><title>encoder</title><script>
|
|
271
|
+
'use strict';
|
|
272
|
+
const b64 = (bytes) => { let s = ''; for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode(...bytes.subarray(i, i + 8192)); return btoa(s); };
|
|
273
|
+
const unb64 = (s) => { const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; };
|
|
274
|
+
let enc = null, failed = null, chunks = [], description = null, frameUs = 0, keyEvery = 1;
|
|
275
|
+
window.__enc = {
|
|
276
|
+
async start({ candidates, width, height, fps, bitrate }) {
|
|
277
|
+
for (const codec of candidates) {
|
|
278
|
+
const config = { codec, width, height, bitrate, framerate: fps, latencyMode: 'quality', ...(codec.startsWith('avc1') ? { avc: { format: 'avc' } } : {}) };
|
|
279
|
+
let ok = false;
|
|
280
|
+
try { ok = (await VideoEncoder.isConfigSupported(config)).supported; } catch {}
|
|
281
|
+
if (!ok) continue;
|
|
282
|
+
enc = new VideoEncoder({
|
|
283
|
+
output: (chunk, meta) => {
|
|
284
|
+
const data = new Uint8Array(chunk.byteLength); chunk.copyTo(data);
|
|
285
|
+
chunks.push({ key: chunk.type === 'key', timestamp: chunk.timestamp, duration: chunk.duration ?? frameUs, data: b64(data) });
|
|
286
|
+
if (meta?.decoderConfig?.description) description = b64(new Uint8Array(meta.decoderConfig.description instanceof ArrayBuffer ? meta.decoderConfig.description : meta.decoderConfig.description.buffer));
|
|
287
|
+
},
|
|
288
|
+
error: (e) => { failed = String(e?.message ?? e); },
|
|
289
|
+
});
|
|
290
|
+
enc.configure(config);
|
|
291
|
+
frameUs = Math.round(1e6 / fps); keyEvery = Math.max(1, Math.round(fps * 2));
|
|
292
|
+
return codec;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
},
|
|
296
|
+
async push(png, i) {
|
|
297
|
+
if (failed) throw new Error(failed);
|
|
298
|
+
const bitmap = await createImageBitmap(new Blob([unb64(png)], { type: 'image/png' }));
|
|
299
|
+
const frame = new VideoFrame(bitmap, { timestamp: i * frameUs, duration: frameUs });
|
|
300
|
+
enc.encode(frame, { keyFrame: i % keyEvery === 0 });
|
|
301
|
+
frame.close(); bitmap.close();
|
|
302
|
+
while (enc.encodeQueueSize > 4) await new Promise((r) => enc.addEventListener('dequeue', r, { once: true }));
|
|
303
|
+
const out = chunks; chunks = []; return out;
|
|
304
|
+
},
|
|
305
|
+
async finish() {
|
|
306
|
+
await enc.flush();
|
|
307
|
+
if (failed) throw new Error(failed);
|
|
308
|
+
const out = chunks; chunks = []; enc.close();
|
|
309
|
+
return { chunks: out, description };
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
</script>`;
|
|
313
|
+
|
|
314
|
+
/** H.264 levels by macroblocks a frame and a second: Baseline first (the
|
|
315
|
+
* widest playback), then Main and High at the same level. */
|
|
316
|
+
export function h264Candidates(width, height, fps) {
|
|
317
|
+
const mbs = Math.ceil(width / 16) * Math.ceil(height / 16);
|
|
318
|
+
const levels = [[0x1f, 3600, 108000], [0x20, 5120, 216000], [0x28, 8192, 245760], [0x2a, 8704, 522240], [0x32, 22080, 589824]];
|
|
319
|
+
const level = (levels.find(([, frame, rate]) => mbs <= frame && mbs * fps <= rate) ?? levels.at(-1))[0].toString(16).padStart(2, '0');
|
|
320
|
+
return [`avc1.42E0${level}`, `avc1.4D00${level}`, `avc1.6400${level}`];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const bitrateFor = (width, height, fps) => Math.round(Math.min(12_000_000, Math.max(400_000, width * height * fps * 0.1)));
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* RENDER ONE PAGE TO ONE FILE. Resolves the receipt the agent is answered
|
|
327
|
+
* with, or throws in words. `capture` is injectable for the tests; nothing in
|
|
328
|
+
* the daemon passes it.
|
|
329
|
+
*/
|
|
330
|
+
export async function renderVideo({ wt, request, signal, capture = capturePinnedPage }) {
|
|
331
|
+
const r = validateRenderRequest(request);
|
|
332
|
+
const { rel, abs } = pagePath(wt, r.page);
|
|
333
|
+
if (!ensureArtifactDir(wt)) throw new Error(`${ARTIFACT_DIR}/ is not a real directory in this worktree, so no file can be written`);
|
|
334
|
+
const source = Buffer.from(scrub(readFileSync(abs, 'utf8')), 'utf8');
|
|
335
|
+
const stepMs = 1000 / r.fps;
|
|
336
|
+
const keep = new Set([0, Math.floor((r.frames - 1) / 2), r.frames - 1]);
|
|
337
|
+
const sheetFrames = [];
|
|
338
|
+
const capFor = r.format === 'gif' ? ARTIFACT_MAX_BYTES : ARTIFACT_VIDEO_MAX_BYTES;
|
|
339
|
+
const capWords = `${Math.round(capFor / (1024 * 1024))} MB`;
|
|
340
|
+
|
|
341
|
+
/** Step the agent's page through every frame, handing each PNG to `onFrame`. */
|
|
342
|
+
const stepPage = async (onFrame) => {
|
|
343
|
+
const page = await serveArtifactPage(source);
|
|
344
|
+
try {
|
|
345
|
+
const result = await capture({
|
|
346
|
+
url: page.url, width: r.width, height: r.height, timeoutMs: VIDEO_RENDER_TIMEOUT_MS,
|
|
347
|
+
allowSubresource: previewSubresourceAllowed, signal, initScript: VIDEO_CLOCK,
|
|
348
|
+
work: async (held) => {
|
|
349
|
+
// Its web fonts, so frame 0 is not drawn in a fallback face.
|
|
350
|
+
await held.evaluate('document.fonts ? document.fonts.ready.then(() => true) : true');
|
|
351
|
+
await held.evaluate(`window.__flowviantFrame(${r.start * 1000})`);
|
|
352
|
+
for (let i = 0; i < r.frames; i++) {
|
|
353
|
+
signal?.throwIfAborted();
|
|
354
|
+
if (i > 0) await held.evaluate(`window.__flowviantFrame(${stepMs})`);
|
|
355
|
+
const png = await held.screenshot();
|
|
356
|
+
if (keep.has(i)) sheetFrames.push(toRgb(decodePng(png)));
|
|
357
|
+
await onFrame(png, i);
|
|
358
|
+
if (held.left()) return null;
|
|
359
|
+
}
|
|
360
|
+
return { pageError: held.pageError() };
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
if (!result.ok) {
|
|
364
|
+
if (result.reason === 'no-browser') throw new Error('No Chrome, Chromium or Edge browser was found on this machine, and rendering a video needs one. Nothing was written.');
|
|
365
|
+
throw new Error(`The page could not be rendered: ${result.message ?? result.reason}`);
|
|
366
|
+
}
|
|
367
|
+
return result.value;
|
|
368
|
+
} finally { page.close(); }
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
let bytes;
|
|
372
|
+
let codec = null;
|
|
373
|
+
let pageError = null;
|
|
374
|
+
if (r.format === 'gif') {
|
|
375
|
+
const gif = createGifEncoder(r.width, r.height);
|
|
376
|
+
let shown = 0;
|
|
377
|
+
({ pageError } = await stepPage(async (png, i) => {
|
|
378
|
+
// Cumulative rounding, so the whole GIF lasts as long as was asked.
|
|
379
|
+
const until = Math.round(((i + 1) * 100) / r.fps);
|
|
380
|
+
gif.add(toRgb(decodePng(png)).rgb, Math.max(2, until - shown));
|
|
381
|
+
shown = until;
|
|
382
|
+
if (gif.bytes() > capFor) throw new Error(`The GIF passed ${capWords} at frame ${i + 1} of ${r.frames}. Lower the size, frame rate or length, use fewer colours, or render an MP4 or WebM instead. Nothing was written.`);
|
|
383
|
+
}) ?? {});
|
|
384
|
+
bytes = gif.finish();
|
|
385
|
+
} else {
|
|
386
|
+
const encoderPage = await serveArtifactPage(ENCODER_PAGE, { csp: ENCODER_CSP });
|
|
387
|
+
try {
|
|
388
|
+
const encoded = await capture({
|
|
389
|
+
url: encoderPage.url, width: 64, height: 64, timeoutMs: VIDEO_RENDER_TIMEOUT_MS, signal,
|
|
390
|
+
work: async (encoder) => {
|
|
391
|
+
const candidates = r.format === 'mp4' ? h264Candidates(r.width, r.height, r.fps) : ['vp09.00.10.08', 'vp8'];
|
|
392
|
+
codec = await encoder.evaluate(`window.__enc.start(${JSON.stringify({ candidates, width: r.width, height: r.height, fps: r.fps, bitrate: bitrateFor(r.width, r.height, r.fps) })})`);
|
|
393
|
+
if (!codec) throw new Error(`This machine's browser cannot encode ${r.format === 'mp4' ? 'H.264 for MP4' : 'VP9 or VP8 for WebM'}. Render a ${r.format === 'mp4' ? 'WebM' : 'MP4'} or a GIF instead. Nothing was written.`);
|
|
394
|
+
const chunks = [];
|
|
395
|
+
let size = 0;
|
|
396
|
+
const take = (list) => {
|
|
397
|
+
for (const c of list) {
|
|
398
|
+
const data = Buffer.from(c.data, 'base64');
|
|
399
|
+
size += data.length;
|
|
400
|
+
chunks.push({ ...c, data });
|
|
401
|
+
}
|
|
402
|
+
if (size > capFor) throw new Error(`The ${r.format.toUpperCase()} passed ${capWords}. Lower the size, frame rate or length. Nothing was written.`);
|
|
403
|
+
};
|
|
404
|
+
({ pageError } = await stepPage(async (png, i) => take(await encoder.evaluate(`window.__enc.push(${JSON.stringify(png.toString('base64'))}, ${i})`))) ?? {});
|
|
405
|
+
const done = await encoder.evaluate('window.__enc.finish()');
|
|
406
|
+
take(done.chunks);
|
|
407
|
+
return { chunks, description: done.description ? Buffer.from(done.description, 'base64') : null };
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
if (!encoded.ok) {
|
|
411
|
+
if (encoded.reason === 'no-browser') throw new Error('No Chrome, Chromium or Edge browser was found on this machine, and rendering a video needs one. Nothing was written.');
|
|
412
|
+
throw new Error(encoded.message ?? `The encoder failed (${encoded.reason}).`);
|
|
413
|
+
}
|
|
414
|
+
const { chunks, description } = encoded.value;
|
|
415
|
+
bytes = r.format === 'mp4'
|
|
416
|
+
? muxMp4({ width: r.width, height: r.height, description, chunks })
|
|
417
|
+
: muxWebm({ width: r.width, height: r.height, codec: codec.startsWith('vp8') ? 'V_VP8' : 'V_VP9', chunks });
|
|
418
|
+
} finally { encoderPage.close(); }
|
|
419
|
+
}
|
|
420
|
+
if (sniffVideo(bytes) !== r.format) throw new Error(`The ${r.format.toUpperCase()} came out malformed. Nothing was written.`);
|
|
421
|
+
if (bytes.length > capFor) throw new Error(`The ${r.format.toUpperCase()} is ${bytes.length} bytes, over ${capWords}. Lower the size, frame rate or length. Nothing was written.`);
|
|
422
|
+
|
|
423
|
+
const dest = join(wt, ARTIFACT_DIR, r.out);
|
|
424
|
+
try {
|
|
425
|
+
const st = lstatSync(dest);
|
|
426
|
+
if (!st.isFile()) throw new Error(`${ARTIFACT_DIR}/${r.out} exists and is not a regular file`);
|
|
427
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
428
|
+
const tmp = `${dest}.${process.pid}.partial`;
|
|
429
|
+
writeFileSync(tmp, bytes, { flag: 'wx' });
|
|
430
|
+
try { renameSync(tmp, dest); } catch (e) { rmSync(tmp, { force: true }); throw e; }
|
|
431
|
+
return {
|
|
432
|
+
file: `${ARTIFACT_DIR}/${r.out}`,
|
|
433
|
+
page: rel,
|
|
434
|
+
format: r.format,
|
|
435
|
+
...(codec ? { codec } : {}),
|
|
436
|
+
width: r.width,
|
|
437
|
+
height: r.height,
|
|
438
|
+
fps: r.fps,
|
|
439
|
+
frames: r.frames,
|
|
440
|
+
seconds: r.frames / r.fps,
|
|
441
|
+
bytes: bytes.length,
|
|
442
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
443
|
+
...(pageError ? { pageError } : {}),
|
|
444
|
+
sheet: sheetFrames.length ? contactSheet(sheetFrames).toString('base64') : null,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── The held tool and its MCP ───────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
export const VIDEO_TOOL_SCHEMA = Object.freeze({
|
|
451
|
+
name: VIDEO_TOOL,
|
|
452
|
+
description: `Render an animation page you wrote into a GIF, MP4 or WebM file under ${ARTIFACT_DIR}/, in this machine's own headless browser. The page runs on a clock of its own that moves exactly one frame per step — performance.now(), Date, timers, requestAnimationFrame (handed the frame time), CSS, Web and SVG animations all read it — and is photographed at width × height. Its clock starts at 0 when the page loads. The answer names the file written and shows its first, middle and last frames; look at them before you hand the file back.`,
|
|
453
|
+
inputSchema: {
|
|
454
|
+
type: 'object',
|
|
455
|
+
properties: {
|
|
456
|
+
page: { type: 'string', description: `Relative path of the self-contained .html animation page, under ${ARTIFACT_DIR}/ or ${FENCE_TMP_DIR}/.` },
|
|
457
|
+
out: { type: 'string', description: `Output file name (no folders) ending .gif, .mp4 or .webm; written to ${ARTIFACT_DIR}/<out>, replacing a file of that name.` },
|
|
458
|
+
width: { type: 'integer', description: `Viewport and video width in pixels (${VIDEO_LIMITS.minSide}–${VIDEO_LIMITS.maxSide}; GIF at most ${VIDEO_LIMITS.maxGifSide}; even for MP4/WebM).` },
|
|
459
|
+
height: { type: 'integer', description: `Viewport and video height in pixels (same bounds as width).` },
|
|
460
|
+
fps: { type: 'integer', description: `Frames per second (1–${VIDEO_LIMITS.maxFps}; GIF at most ${VIDEO_LIMITS.maxGifFps}).` },
|
|
461
|
+
seconds: { type: 'number', description: `Length in seconds (at most ${VIDEO_LIMITS.maxSeconds}; at least two frames).` },
|
|
462
|
+
start: { type: 'number', description: 'Seconds of the page’s clock to skip before the first frame (default 0).' },
|
|
463
|
+
},
|
|
464
|
+
required: ['page', 'out', 'width', 'height', 'fps', 'seconds'],
|
|
465
|
+
additionalProperties: false,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
/** The run-scoped tool, daemon side. Returns `{ env, config, close }`; the
|
|
470
|
+
* lane mounts `config` as `card_video` and closes it when the turn ends. */
|
|
471
|
+
export async function createVideoRender({ wt, signal, render = renderVideo }) {
|
|
472
|
+
let closed = false, busy = false;
|
|
473
|
+
const controller = new AbortController();
|
|
474
|
+
const abort = () => controller.abort();
|
|
475
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
476
|
+
const held = await listenHeldTool({
|
|
477
|
+
envPrefix: 'FLOWVIANT_VIDEO_RENDER', verb: '__video-render', requestTimeoutMs: VIDEO_RENDER_TIMEOUT_MS + 10_000,
|
|
478
|
+
isClosed: () => closed || controller.signal.aborted,
|
|
479
|
+
respond: async (req, res) => {
|
|
480
|
+
try {
|
|
481
|
+
let body = '';
|
|
482
|
+
for await (const chunk of req) { body += chunk; if (body.length > 16 * 1024) throw new Error('The render request exceeds 16 KiB.'); }
|
|
483
|
+
if (busy) throw new Error('A render is already running on this turn; wait for it to finish.');
|
|
484
|
+
busy = true;
|
|
485
|
+
try {
|
|
486
|
+
const receipt = await render({ wt, request: JSON.parse(body), signal: controller.signal });
|
|
487
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify(receipt));
|
|
488
|
+
} finally { busy = false; }
|
|
489
|
+
} catch (error) {
|
|
490
|
+
res.writeHead(409, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: scrub(String(error?.message ?? error)).slice(0, 1000) }));
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
return {
|
|
495
|
+
env: held.env,
|
|
496
|
+
// `toolTimeoutSec` is read by the Codex mount (codexFencedMount.mjs) and
|
|
497
|
+
// never written to Claude's mcp.json (projectTools.mjs).
|
|
498
|
+
config: { command: process.execPath, args: heldToolArgv('__video-render-mcp'), env: held.env, toolTimeoutSec: Math.ceil(VIDEO_RENDER_TIMEOUT_MS / 1000) + 30 },
|
|
499
|
+
async close() {
|
|
500
|
+
closed = true; controller.abort(); held.server.close(); held.server.closeAllConnections();
|
|
501
|
+
signal?.removeEventListener('abort', abort);
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** `flowviant __video-render-mcp`: the stdio MCP a video turn's CLI mounts. */
|
|
507
|
+
export async function videoRenderMain() {
|
|
508
|
+
await serveMcpStdio({
|
|
509
|
+
serverInfo: { name: 'card-video', version: '1.0.0' }, rawResult: true, tools: [VIDEO_TOOL_SCHEMA],
|
|
510
|
+
call: async (_name, args) => {
|
|
511
|
+
const { sheet, ...receipt } = await callVideoRender(args);
|
|
512
|
+
return { content: [{ type: 'text', text: JSON.stringify(receipt) }, ...(sheet ? [{ type: 'image', mimeType: 'image/png', data: sheet }] : [])] };
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function callVideoRender(input) {
|
|
518
|
+
const url = new URL(process.env.FLOWVIANT_VIDEO_RENDER_URL ?? '');
|
|
519
|
+
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.pathname !== '/' || url.username || url.password) throw new Error('No video render tool is held for this turn.');
|
|
520
|
+
const response = await fetch(url, {
|
|
521
|
+
method: 'POST',
|
|
522
|
+
headers: { Authorization: `Bearer ${process.env.FLOWVIANT_VIDEO_RENDER_CAPABILITY}`, 'Content-Type': 'application/json' },
|
|
523
|
+
body: JSON.stringify(input),
|
|
524
|
+
signal: AbortSignal.timeout(VIDEO_RENDER_TIMEOUT_MS + 15_000),
|
|
525
|
+
});
|
|
526
|
+
const result = await response.json();
|
|
527
|
+
if (!response.ok) throw new Error(result.error ?? 'The render failed.');
|
|
528
|
+
return result;
|
|
529
|
+
}
|
package/bin/lib/work.mjs
CHANGED
|
@@ -450,6 +450,7 @@ export function createWorkManager({
|
|
|
450
450
|
// Retirement asks the agent lane which CLI to stop.
|
|
451
451
|
const { heldSessionIds, retireWorkSessions, freshenManualPlaces } = createWorkRetire({
|
|
452
452
|
repositoryPlaces, repoRoot,
|
|
453
|
+
sessionMetaPath,
|
|
453
454
|
baseDir,
|
|
454
455
|
baseRef,
|
|
455
456
|
placeOf,
|
|
@@ -26,12 +26,14 @@ import { TURN_PROFILES } from './turnProfile.mjs';
|
|
|
26
26
|
import { postureToolsNote } from './postureTools.mjs';
|
|
27
27
|
import { machineMcpServers } from './claudeMachineMcp.mjs';
|
|
28
28
|
import { ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
|
|
29
|
+
import { VIDEO_SERVER } from './videoRender.mjs';
|
|
29
30
|
|
|
30
31
|
/** The servers the daemon itself mounts on a card turn, by the names the
|
|
31
|
-
* agent calls them: a multi-repository card's add_repository,
|
|
32
|
-
* orchestrated card's routing
|
|
32
|
+
* agent calls them: a multi-repository card's add_repository, an
|
|
33
|
+
* orchestrated card's routing, a build's UI review and a video card's
|
|
34
|
+
* render_video (2026-10-26). Never a project server's, whatever the base
|
|
33
35
|
* `.mcp.json` names. */
|
|
34
|
-
export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review' });
|
|
36
|
+
export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER });
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* The card turn's tools, prepared, and what it is told of them.
|
|
@@ -44,7 +46,7 @@ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositorie
|
|
|
44
46
|
* from here), the note, the mount's part of the CLI's `mcpEnv` and whether it
|
|
45
47
|
* needs one, and the keys the runner takes the mount as.
|
|
46
48
|
*/
|
|
47
|
-
export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
|
|
49
|
+
export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
|
|
48
50
|
// A turn reads the base ref afresh. Changes the agent has made to its
|
|
49
51
|
// own worktree cannot silently change the next turn's tools.
|
|
50
52
|
const source = sources.find((r) => r.wt === wt);
|
|
@@ -57,6 +59,8 @@ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources =
|
|
|
57
59
|
...(repositoryTool ? { [CARD_TURN_SERVERS.repositories]: repositoryTool.config } : {}),
|
|
58
60
|
...(orchestratorConfig ? { [CARD_TURN_SERVERS.orchestrator]: orchestratorConfig } : {}),
|
|
59
61
|
...(posture === 'build' && uiReviewConfig ? { [CARD_TURN_SERVERS.uiReview]: uiReviewConfig } : {}),
|
|
62
|
+
// A video card's render tool rides its fenced posture (videoRender.mjs).
|
|
63
|
+
...(videoConfig ? { [CARD_TURN_SERVERS.video]: videoConfig } : {}),
|
|
60
64
|
} });
|
|
61
65
|
try {
|
|
62
66
|
const mountsProjectMcp = TURN_PROFILES[posture]?.projectMcp === true && runnerToolCapabilities(rt).mcp;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* through the one turn-done wire (workAgentTurnReports.mjs). Null means go on.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
16
17
|
import { git, sessionBranchRef } from './git.mjs';
|
|
17
18
|
import { worktreePath } from './workPlaces.mjs';
|
|
18
19
|
|
|
@@ -49,6 +50,9 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
|
|
|
49
50
|
// null directory AND a null ref below, and the null ref stands the guard
|
|
50
51
|
// down — the same fail-open direction as an unreadable repo.
|
|
51
52
|
const wtDir = worktreePath(baseDir, place);
|
|
53
|
+
// A shell left by an interrupted setup is not the agent's git worktree.
|
|
54
|
+
// The launch path validates ownership and readability before using it.
|
|
55
|
+
const hasWorktree = Boolean(wtDir && existsSync(join(wtDir, '.git')));
|
|
52
56
|
const ref = sessionBranchRef(place);
|
|
53
57
|
let hasBranch = false;
|
|
54
58
|
/**
|
|
@@ -76,7 +80,7 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
|
|
|
76
80
|
else branchMeasured = false;
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
|
-
if (branchMeasured && !
|
|
83
|
+
if (branchMeasured && !hasWorktree && !hasBranch) {
|
|
80
84
|
/**
|
|
81
85
|
* …UNLESS THE WORK IS ON THE REMOTE (0.86.0).
|
|
82
86
|
*
|
|
@@ -110,6 +114,6 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
|
|
|
110
114
|
// opens a worktree on it below — the same path a directory somebody
|
|
111
115
|
// cleaned up already takes, on commits this box now genuinely holds.
|
|
112
116
|
}
|
|
113
|
-
if (hasBranch ||
|
|
117
|
+
if (hasBranch || hasWorktree) onAvailable();
|
|
114
118
|
return null;
|
|
115
119
|
}
|