cozyclay 2.0.0 → 2.1.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/CHANGELOG.md +112 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +219 -38
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +6 -5
- package/bin/cozyclay.mjs +12 -3
- package/bin/live/cli.mjs +2 -3
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +12 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-B41FmPYI.js +1 -0
- package/dist/assets/app-Do-Og5Pu.css +1 -0
- package/dist/assets/app-DwP-tl2x.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
- package/dist/assets/workflow-Br-K7Srs.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/sitemap.xml +5 -5
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +801 -259
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +196 -3
- package/src/ardy/timeline.jsx +117 -29
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +1 -4
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +43 -24
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +16 -3
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +89 -32
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion/generation.js +1 -1
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +52 -3
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +62 -17
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +58 -14
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/TopBar.jsx +17 -3
- package/src/shell/ViewportToolbar.jsx +18 -3
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +186 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +56 -23
- package/src/studio-app-binding.js +52 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +111 -10
- package/src/theme.js +1 -1
- package/src/timeline-fps.js +13 -0
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +110 -20
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +96 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-C1sdVbg4.css +0 -1
- package/dist/assets/app-CA55tcI3.js +0 -4876
- package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
- package/dist/assets/workflow-dNPGY9xz.js +0 -179
|
@@ -10,6 +10,7 @@ import { createAgentTools, SYSTEM_PROMPT, pickWorkspace } from "./agent-tools.mj
|
|
|
10
10
|
import { createVideoAdapters, validateFalVideoRequest } from "./video-adapters.mjs";
|
|
11
11
|
import { createSessionStore, transcriptFromHistory } from "./session-store.mjs";
|
|
12
12
|
import { createAgentRunner } from "./agent-runner.mjs";
|
|
13
|
+
import { STUDIO_SYSTEM_PROMPT_STORYBOARD } from "./studio-prompt.mjs";
|
|
13
14
|
|
|
14
15
|
// Values the codex backend accepts for reasoning.effort (its own 400 lists them).
|
|
15
16
|
export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
|
|
@@ -19,7 +20,7 @@ const json = (res, status, value) => {
|
|
|
19
20
|
res.end(JSON.stringify(value));
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
function allowAgentOrigin(req, port) {
|
|
23
24
|
return [`http://127.0.0.1:${port}`, `http://${"local" + "host"}:${port}`].includes(req.headers.origin)
|
|
24
25
|
|| (req.headers.origin === undefined && req.method === "GET"
|
|
25
26
|
&& [`127.0.0.1:${port}`, `localhost:${port}`].includes(req.headers.host));
|
|
@@ -27,6 +28,8 @@ export function allowAgentOrigin(req, port) {
|
|
|
27
28
|
|
|
28
29
|
// Two 1920x1080 PNG data URLs (frame + reference) fit comfortably in this.
|
|
29
30
|
const IMAGE_BODY_LIMIT = 24 * 1024 * 1024;
|
|
31
|
+
// The sizes gpt-image-2 accepts; omitted means the backend's own choice.
|
|
32
|
+
const IMAGE_SIZES = ["auto", "1024x1024", "1536x1024", "1024x1536"];
|
|
30
33
|
|
|
31
34
|
// Attached scene references (#167): identity sheets and the environment
|
|
32
35
|
// reference. Capped because every one of them is another full image the
|
|
@@ -41,6 +44,10 @@ const ATTACHMENTS_MAX = 4;
|
|
|
41
44
|
const ATTACHMENT_MAX_CHARS = 6_000_000;
|
|
42
45
|
const TURN_BODY_LIMIT = ATTACHMENTS_MAX * ATTACHMENT_MAX_CHARS + 64 * 1024;
|
|
43
46
|
|
|
47
|
+
// How long GET /agent/turn/<id>/supervisor holds a request for a review that
|
|
48
|
+
// is still running before it answers 204 (#728).
|
|
49
|
+
const SUPERVISOR_WAIT_MS = 180_000;
|
|
50
|
+
|
|
44
51
|
/** Reject anything that is not a short list of inline {dataUrl, name?} images.
|
|
45
52
|
* The Studio envelope validates its own copy; this is the legacy body's. */
|
|
46
53
|
function validAttachments(attachments) {
|
|
@@ -60,7 +67,6 @@ const agentToolCategory = (name) => {
|
|
|
60
67
|
// Studio families are authored edits or reads; the analytics vocabulary has
|
|
61
68
|
// no Studio-specific category, so a write says so and everything else does not.
|
|
62
69
|
if (["arrange_objects", "arrange_characters", "patch_elements", "frame_shot", "undo_edit"].includes(name)) return "scene_write";
|
|
63
|
-
if (["inspect_studio", "operate_studio", "verify_result", "generate_motion"].includes(name)) return "other";
|
|
64
70
|
if (name === "run_workflow") return "workflow_run";
|
|
65
71
|
if (name === "describe_workflow" || name === "focus_workflow_node") return "workflow_read";
|
|
66
72
|
if (["add_workflow_node", "update_workflow_node", "remove_workflow_node", "connect_workflow_nodes", "disconnect_workflow_nodes", "set_workflow_node_output"].includes(name)) return "workflow_write";
|
|
@@ -75,6 +81,55 @@ const agentFailureCode = (error, signal, tool = false) => advisory(() => {
|
|
|
75
81
|
if (error?.status === 429) return "rate_limited";
|
|
76
82
|
return tool ? "tool_failed" : "upstream";
|
|
77
83
|
}, tool ? "tool_failed" : "upstream");
|
|
84
|
+
/**
|
|
85
|
+
* One user message is one editor transaction: every Studio turn whose editor
|
|
86
|
+
* reports a previsMode opens agent.turn.begin and closes agent.turn.finish, so
|
|
87
|
+
* all of its mutations land as ONE history entry and one receipt frame. A
|
|
88
|
+
* context without a previsMode comes from an editor that predates turn
|
|
89
|
+
* transactions and runs unwrapped. rollback() cancels the open transaction
|
|
90
|
+
* (restoring the pre-turn document) and reopens it for the rest of the turn.
|
|
91
|
+
*/
|
|
92
|
+
const agentTurn = ({ hub, context, turnId, admission }) => {
|
|
93
|
+
const wrapped = Boolean(context.scene?.previsMode);
|
|
94
|
+
const host = Object.fromEntries(["workspaceId", "documentEpoch", "sceneId", "sceneEpoch"].map(key => [key, context.host[key]]));
|
|
95
|
+
const control = async action => {
|
|
96
|
+
const receipt = await hub.command("run_action", {
|
|
97
|
+
name: "run_action", args: { action, args: { turnId } }, turnId,
|
|
98
|
+
commandId: randomUUID(), host, expectedRevision: admission.revision,
|
|
99
|
+
}, context.host.workspaceHandle);
|
|
100
|
+
if (!receipt.ok) throw Object.assign(new Error(receipt.message), { code: receipt.code });
|
|
101
|
+
return receipt;
|
|
102
|
+
};
|
|
103
|
+
const open = async () => { await control("agent.turn.begin"); admission.turnId = turnId; };
|
|
104
|
+
return {
|
|
105
|
+
async rollback() {
|
|
106
|
+
if (admission.turnId !== turnId) return null;
|
|
107
|
+
delete admission.turnId;
|
|
108
|
+
const receipt = await control("agent.turn.cancel");
|
|
109
|
+
await admission.refresh();
|
|
110
|
+
await open();
|
|
111
|
+
return receipt;
|
|
112
|
+
},
|
|
113
|
+
run: start => async function* (input) {
|
|
114
|
+
if (!wrapped) { yield* start(input); return; }
|
|
115
|
+
await open();
|
|
116
|
+
let terminal;
|
|
117
|
+
try {
|
|
118
|
+
for await (const frame of start(input)) {
|
|
119
|
+
if (frame.type === "done") terminal = frame; else yield frame;
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
// A rollback whose reopen failed has already closed the transaction.
|
|
123
|
+
if (admission.turnId === turnId) {
|
|
124
|
+
delete admission.turnId;
|
|
125
|
+
const receipt = await control("agent.turn.finish");
|
|
126
|
+
if (receipt.authored) yield { type: "receipt", receipt };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (terminal) yield terminal;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
};
|
|
78
133
|
// These existing canvas commands only return a node/edge after publishing a
|
|
79
134
|
// new insertion. Read/focus, generic accepted responses, update no-ops and run
|
|
80
135
|
// outputs (which can echo old values) are deliberately not application proof.
|
|
@@ -134,6 +189,8 @@ function validReferences(references) {
|
|
|
134
189
|
* owns the geometry, each character sheet owns one performer's look and the
|
|
135
190
|
* environment reference owns the location.
|
|
136
191
|
*/
|
|
192
|
+
export const FRAME_COMPOSITION_GUIDANCE = "Use the first image (a clay blocking frame) as the layout: keep its composition and camera angle, and keep the pose but make it look natural. Render it as:";
|
|
193
|
+
|
|
137
194
|
export function referenceGuidance(references = []) {
|
|
138
195
|
const list = Array.isArray(references) ? references : [];
|
|
139
196
|
if (!list.length) return "";
|
|
@@ -143,6 +200,8 @@ export function referenceGuidance(references = []) {
|
|
|
143
200
|
lines.push(`Character ${entry.name || "reference"}: match the identity, face, hair and wardrobe from the attached character sheet.`);
|
|
144
201
|
} else if (entry.role === "environment") {
|
|
145
202
|
lines.push("Environment: take the location look, materials, palette and lighting from the attached environment reference.");
|
|
203
|
+
} else if (entry.role === "reference") {
|
|
204
|
+
lines.push(`Reference ${entry.name || "image"}: use it for the look the prompt asks for (subject, wardrobe, style); keep the exact pose, body position and camera framing of the first image.`);
|
|
146
205
|
}
|
|
147
206
|
}
|
|
148
207
|
return `\n${lines.join("\n")}`;
|
|
@@ -174,7 +233,7 @@ function quotaEvent(codex, headers) {
|
|
|
174
233
|
// one line — without it neither the log nor the panel can say why a turn died.
|
|
175
234
|
const UPSTREAM_DETAIL_MAX = 200;
|
|
176
235
|
export function sanitizeUpstreamDetail(body) {
|
|
177
|
-
let message
|
|
236
|
+
let message;
|
|
178
237
|
try {
|
|
179
238
|
const parsed = JSON.parse(body);
|
|
180
239
|
const candidate = parsed?.error?.message ?? parsed?.error ?? parsed?.message ?? parsed?.detail;
|
|
@@ -249,7 +308,12 @@ function liveToolsRuntime(mcpRuntime) {
|
|
|
249
308
|
return liveToolsModules(mcpRuntime).then(async ([registry, { startLiveHub }]) => {
|
|
250
309
|
const owner = liveHubOwner();
|
|
251
310
|
const token = randomBytes(32).toString("hex");
|
|
252
|
-
const
|
|
311
|
+
const livePort = Number(process.env.COZYCLAY_LIVE_PORT ?? 5184);
|
|
312
|
+
const liveHub = await startLiveHub(livePort, { token, owner });
|
|
313
|
+
// EADDRINUSE comes back as null: another CozyClay (or its dev server) owns
|
|
314
|
+
// the port, and the Studio page is built to connect to that port, so the
|
|
315
|
+
// pane would answer every turn with a misleading capability error.
|
|
316
|
+
if (!liveHub) console.error(`cozyclay: live hub port ${livePort} is already in use (another CozyClay or its dev server?); the Agent pane cannot drive this Studio until that port is free.`);
|
|
253
317
|
registry.setLiveHub(liveHub);
|
|
254
318
|
const handlers = registry.createToolHandlers().map((tool) => ({
|
|
255
319
|
...tool,
|
|
@@ -270,7 +334,7 @@ function liveToolsRuntime(mcpRuntime) {
|
|
|
270
334
|
publishLiveEndpoint({ port: liveHub.port, token, owner });
|
|
271
335
|
liveHub.server?.once("close", () => removeLiveEndpoint(liveHub.port));
|
|
272
336
|
}
|
|
273
|
-
return { liveHub, handlers };
|
|
337
|
+
return { liveHub, handlers, hubPortBusy: !liveHub ? livePort : null };
|
|
274
338
|
}).catch((error) => {
|
|
275
339
|
// The pane answers CAPABILITY_MISSING on use; the reason belongs on the
|
|
276
340
|
// launcher's terminal rather than nowhere.
|
|
@@ -280,7 +344,10 @@ function liveToolsRuntime(mcpRuntime) {
|
|
|
280
344
|
});
|
|
281
345
|
}
|
|
282
346
|
|
|
283
|
-
|
|
347
|
+
// `supervisor` (#728) defaults off only under an injected scripted provider:
|
|
348
|
+
// a test's script is an exact queue of replies, and a review would take one
|
|
349
|
+
// meant for a later turn. The real sidecar never injects one.
|
|
350
|
+
export function createAgentHandler({ auth = defaultAuth, codex, models, codexBaseUrl, cliproxyBaseUrl, env, fauxProvider, handlers, liveHub, mcpRuntime, port, studioRuntime, clock = Date.now, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, sessionStore: injectedSessionStore, supervisor = !fauxProvider } = {}) {
|
|
284
351
|
const requestContext = new AsyncLocalStorage();
|
|
285
352
|
codex ||= defaultClient(auth, requestContext);
|
|
286
353
|
const runtime = handlers !== undefined || liveHub !== undefined ? Promise.resolve({ handlers: handlers ?? [], liveHub }) : liveToolsRuntime(mcpRuntime);
|
|
@@ -342,6 +409,9 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
342
409
|
return owner;
|
|
343
410
|
};
|
|
344
411
|
const emitStudioEvent = (turnId, event) => {
|
|
412
|
+
// The supervisor note lands after the turn ended (#728): it joins the turn's
|
|
413
|
+
// record, terminal or not, but never resurrects one that was retired.
|
|
414
|
+
if (event.type === "supervisor" && !studioEvents.has(turnId)) return;
|
|
345
415
|
const record = studioEvents.get(turnId) || { next: 0, events: [], listeners: new Set(), terminal: false };
|
|
346
416
|
// Execution telemetry is advisory and is validated key-by-key in the
|
|
347
417
|
// browser: it carries no replay cursor, is never retained for a resume,
|
|
@@ -372,10 +442,10 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
372
442
|
});
|
|
373
443
|
|
|
374
444
|
const studioIdentity = host => Object.fromEntries(["workspaceId", "documentEpoch", "sceneId", "sceneEpoch"].map(key => [key, host[key]]));
|
|
375
|
-
const writeStudioStream = (res, record, after = 0
|
|
445
|
+
const writeStudioStream = (res, record, after = 0) => {
|
|
376
446
|
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" }); res.flushHeaders?.();
|
|
377
447
|
let cursor = after; const send = event => {
|
|
378
|
-
if (res.destroyed) return;
|
|
448
|
+
if (res.destroyed || res.writableEnded) return;
|
|
379
449
|
if (event.eventSeq === undefined) { res.write(`data: ${JSON.stringify(event)}\n\n`); return; }
|
|
380
450
|
if (event.eventSeq > cursor) { cursor = event.eventSeq; res.write(`data: ${JSON.stringify(event)}\n\n`); }
|
|
381
451
|
};
|
|
@@ -396,8 +466,8 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
396
466
|
};
|
|
397
467
|
const handleStudioTurn = async (req, res, value, path) => {
|
|
398
468
|
const { StudioProtocolError, validateStudioContextFreshness } = await import("../../src/studio-agent-protocol.js");
|
|
399
|
-
const [{ createStudioTools }, { encodeStudioContext }] = await Promise.all([
|
|
400
|
-
import("./studio-tools.mjs"), import("../../src/studio-agent-context.js"),
|
|
469
|
+
const [{ createStudioTools }, { encodeStudioContext }, { createScriptTool }, { runSupervisor, shouldSupervise }] = await Promise.all([
|
|
470
|
+
import("./studio-tools.mjs"), import("../../src/studio-agent-context.js"), import("./script-tool.mjs"), import("./supervisor.mjs"),
|
|
401
471
|
]);
|
|
402
472
|
const hubDeps = await runtime; const hub = hubDeps.liveHub || liveHub;
|
|
403
473
|
if (path === "/agent/stop") {
|
|
@@ -425,48 +495,71 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
425
495
|
await session.modelSession?.abort?.("studio stop", acknowledged ? { quiet: true } : undefined);
|
|
426
496
|
json(res, 200, { ok: true, status: jobId ? "stopped" : "detached", ...(outcome ? { outcome: { status: outcome.status ?? null, code: outcome.code ?? null, mutated: outcome.mutated ?? null } } : {}) }); return true;
|
|
427
497
|
}
|
|
498
|
+
if (!studioRuntime && !hub && hubDeps.hubPortBusy) throw new StudioProtocolError("CAPABILITY_MISSING", `The Studio live hub could not start: port ${hubDeps.hubPortBusy} is in use by another CozyClay or its dev server. Free that port and relaunch.`);
|
|
428
499
|
if (!studioRuntime && (!hub?.command || !hub?.workspaceId)) throw new StudioProtocolError("CAPABILITY_MISSING", "Studio execution is not installed.");
|
|
429
500
|
if (!value.context.host.workspaceHandle) throw new StudioProtocolError("LIVE_HUB_UNAVAILABLE", "A connected editor handle is required.");
|
|
430
501
|
let session = studioSessions.get(value.sessionId);
|
|
431
502
|
const suppliedOwner = parseCookies(req).studio_owner;
|
|
432
503
|
if (session && suppliedOwner && session.owner !== suppliedOwner) throw new StudioProtocolError("AUTH_REQUIRED", "Studio session owner mismatch.");
|
|
504
|
+
const existing = session?.turns.get(value.turnId);
|
|
505
|
+
if (existing) { session.updatedAt = clock(); writeStudioStream(res, existing, 0); return true; }
|
|
506
|
+
let current;
|
|
507
|
+
try { current = studioRuntime?.readContext ? await studioRuntime.readContext(value.context.host) : await authoritativeStudioContext(value, hub); }
|
|
508
|
+
catch (error) { if (error instanceof StudioProtocolError) throw error; if (error?.code) throw new StudioProtocolError(error.code, error.message); throw new StudioProtocolError("CAPABILITY_MISSING", "The connected editor does not expose authoritative Studio context."); }
|
|
509
|
+
validateStudioContextFreshness(value.context, current);
|
|
510
|
+
// The session, and the owner token only this turn's response can hand
|
|
511
|
+
// the browser, exist only once the turn is admitted. Minting them before
|
|
512
|
+
// a refusal (the editor still reconnecting after a restart) left a
|
|
513
|
+
// session whose owner the browser never received: its cookie from the
|
|
514
|
+
// previous server then failed every retry with "owner mismatch".
|
|
433
515
|
if (!session) {
|
|
434
516
|
studioOwner(req, value.sessionId, true);
|
|
435
517
|
let persisted = null;
|
|
436
|
-
try { persisted = sessionStore.read(value.sessionId); } catch {
|
|
518
|
+
try { persisted = sessionStore.read(value.sessionId); } catch { /* an unreadable history starts the session fresh */ }
|
|
437
519
|
session = { owner: studioOwnerTokens.get(value.sessionId), history: persisted?.history ?? [], persistedItems: persisted?.history?.length ?? 0, meta: persisted?.meta ?? null, turns: new Map(), controller: null, activeJobId: null, activeJobTurnId: null, motionJobIds: new Set(Array.isArray(persisted?.meta?.motionJobIds) ? persisted.meta.motionJobIds.filter(id => typeof id === "string") : []), host: null, updatedAt: clock() };
|
|
438
520
|
studioSessions.set(value.sessionId, session);
|
|
439
521
|
}
|
|
440
522
|
session.updatedAt = clock();
|
|
441
|
-
const existing = session.turns.get(value.turnId);
|
|
442
|
-
if (existing) { writeStudioStream(res, existing, 0, req); return true; }
|
|
443
|
-
let current;
|
|
444
|
-
try { current = studioRuntime?.readContext ? await studioRuntime.readContext(value.context.host) : await authoritativeStudioContext(value, hub); }
|
|
445
|
-
catch (error) { if (error instanceof StudioProtocolError) throw error; if (error?.code) throw new StudioProtocolError(error.code, error.message); throw new StudioProtocolError("CAPABILITY_MISSING", "The connected editor does not expose authoritative Studio context."); }
|
|
446
|
-
validateStudioContextFreshness(value.context, current);
|
|
447
523
|
if (studioRuntime?.handleTurn) { await studioRuntime.handleTurn(value, req, res); return true; }
|
|
448
524
|
const record = { next: 0, events: [], listeners: new Set(), terminal: false }; studioEvents.set(value.turnId, record); session.turns.set(value.turnId, record); session.host = studioIdentity(value.context.host);
|
|
449
525
|
res.setHeader("set-cookie", `studio_owner=${encodeURIComponent(session.owner)}; Path=/agent; HttpOnly; SameSite=Strict`);
|
|
450
|
-
const close = writeStudioStream(res, record, 0
|
|
451
|
-
|
|
526
|
+
const close = writeStudioStream(res, record, 0);
|
|
527
|
+
// What the supervisor reviews once the turn is over: every frame but text
|
|
528
|
+
// and telemetry, and the last stretch of text the model wrote.
|
|
529
|
+
const turnFrames = [];
|
|
530
|
+
let replySegment = "", lastReply = "";
|
|
531
|
+
const send = event => {
|
|
532
|
+
if (event.type === "text.delta") { replySegment += event.text ?? ""; lastReply = replySegment; }
|
|
533
|
+
else if (!["execution_telemetry", "execution_tool_started"].includes(event.type)) {
|
|
534
|
+
if (event.type === "tool.start") replySegment = "";
|
|
535
|
+
turnFrames.push(event);
|
|
536
|
+
}
|
|
537
|
+
emitStudioEvent(value.turnId, event);
|
|
538
|
+
};
|
|
452
539
|
// The Studio turn is measured exactly like the Workflow turn, correlated by
|
|
453
540
|
// the turn id the host already owns (the frozen envelope carries no room
|
|
454
541
|
// for a second one).
|
|
455
542
|
const telemetry = createTurnTelemetry(send, value.turnId);
|
|
456
543
|
let turnOutcome = "succeeded", turnFailureCode = null, toolFailed = false;
|
|
457
|
-
const controller = new AbortController(); session.controller = controller;
|
|
544
|
+
const controller = new AbortController(); session.controller = controller; session.activeTurnId = value.turnId;
|
|
458
545
|
// Admission is the document identity plus the exact scene revision each
|
|
459
546
|
// command expects. Per-entity tokens are deliberately absent: the revision
|
|
460
547
|
// already moves whenever any authored content changes, and a turn's second
|
|
461
548
|
// edit to the same entity would otherwise carry a token the first edit retired.
|
|
462
549
|
const admission = {
|
|
463
550
|
commandId: () => randomUUID(), host: studioIdentity(value.context.host), revision: value.context.revision.scene,
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
551
|
+
// Read at the admitted host: a scene action may have opened another
|
|
552
|
+
// scene of this workspace during the turn (studio-tools adopts it).
|
|
553
|
+
snapshot: async () => {
|
|
467
554
|
const host = { ...value.context.host, ...admission.host };
|
|
468
|
-
|
|
555
|
+
return studioRuntime?.readContext ? await studioRuntime.readContext(host) : await authoritativeStudioContext({ ...value, context: { ...value.context, host } }, hub);
|
|
556
|
+
},
|
|
557
|
+
// Re-admits at the live revision and hands back the context it read, which
|
|
558
|
+
// the tools compare against what the agent last saw (studio-rebase.mjs).
|
|
559
|
+
refresh: async () => {
|
|
560
|
+
const refreshed = await admission.snapshot();
|
|
469
561
|
admission.revision = refreshed.revision.scene;
|
|
562
|
+
return refreshed;
|
|
470
563
|
},
|
|
471
564
|
};
|
|
472
565
|
// One motion generation per user message, whichever path starts it:
|
|
@@ -498,7 +591,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
498
591
|
if (session.activeJobId === jobId) { session.activeJobId = null; session.activeJobTurnId = null; }
|
|
499
592
|
}
|
|
500
593
|
};
|
|
501
|
-
const tools = createStudioTools({ liveHub: hub, workspaceHandle: value.context.host.workspaceHandle, session: { signal: controller.signal, admission, generation, onJob, actionIndex: current?.actionIndex ?? [] }, resolveImage: async (id, correlation) => hub.command("resolve_studio_image", { imageId: id, ...correlation }, value.context.host.workspaceHandle) });
|
|
594
|
+
const tools = createStudioTools({ liveHub: hub, workspaceHandle: value.context.host.workspaceHandle, previsMode: value.context.scene?.previsMode, session: { signal: controller.signal, admission, generation, onJob, actionIndex: current?.actionIndex ?? [], baseline: value.context }, resolveImage: async (id, correlation) => hub.command("resolve_studio_image", { imageId: id, ...correlation }, value.context.host.workspaceHandle) });
|
|
502
595
|
const motion = async args => {
|
|
503
596
|
// Private artifact IDs belonged to the retired sidecar runtime. Reuse
|
|
504
597
|
// retained editor takes through motion.loadVersion, not another installer.
|
|
@@ -519,12 +612,35 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
519
612
|
return { ...result, visualStatus: visual.visualStatus, imageId: visual.imageId, revision: visual.revision, receiptId: visual.receiptId, ...(visual.dataUrl ? { dataUrl: visual.dataUrl } : {}) };
|
|
520
613
|
},
|
|
521
614
|
}));
|
|
615
|
+
const turnTransaction = agentTurn({ hub, context: current, turnId: value.turnId, admission });
|
|
616
|
+
const script = createScriptTool({ tools: modelTools, signal: controller.signal, emit: send });
|
|
617
|
+
const scriptTool = {
|
|
618
|
+
...script,
|
|
619
|
+
// A script that fails after it edited the scene rolls the turn back, so
|
|
620
|
+
// the model retries from the pre-turn document, never from half a script.
|
|
621
|
+
handler: async (args, options) => {
|
|
622
|
+
const result = await script.handler(args, options);
|
|
623
|
+
if (result.ok || !result.calls.some(call => call.ok && Number.isSafeInteger(call.revision?.after) && call.revision.after !== call.revision.before)) return result;
|
|
624
|
+
const rollback = await turnTransaction.rollback();
|
|
625
|
+
return rollback ? { ...result, rollback: { status: "rolled_back", receiptId: rollback.receiptId, revision: rollback.revision, affectedIds: rollback.affectedIds.slice(0, 8) } } : result;
|
|
626
|
+
},
|
|
627
|
+
};
|
|
522
628
|
let frameObservation;
|
|
523
629
|
if (value.attachFrame) {
|
|
524
630
|
const captured = await hub.command("capture_framing_png", {}, value.context.host.workspaceHandle);
|
|
525
631
|
if (!captured?.dataUrl?.startsWith("data:image/")) throw new StudioProtocolError("TARGET_NOT_READY", "The current frame has no image bytes.");
|
|
526
632
|
const match = /^data:([^;]+);base64,(.*)$/.exec(captured.dataUrl);
|
|
527
633
|
frameObservation = { data: match[2], mimeType: match[1], revision: captured.revision, receiptId: captured.receiptId };
|
|
634
|
+
} else if (value.context.scene?.characterCount >= 2 || (value.context.shots?.length ?? 0) >= 1 || /샷|컷|카메라|커버리지|OTS|투샷|프레임|shot|camera|coverage|two-shot|framing/i.test(value.text || "")) {
|
|
635
|
+
// A staging or coverage request also sees the top-view plan; without it the turn just runs.
|
|
636
|
+
try {
|
|
637
|
+
const captured = await hub.command("capture_plan_png", {}, value.context.host.workspaceHandle);
|
|
638
|
+
const match = /^data:([^;]+);base64,(.*)$/.exec(captured?.dataUrl ?? "");
|
|
639
|
+
if (!match) throw new Error("The plan capture has no image bytes.");
|
|
640
|
+
frameObservation = { kind: "plan", data: match[2], mimeType: match[1], revision: captured.revision ?? value.context.revision?.scene ?? null, receiptId: captured.receiptId };
|
|
641
|
+
} catch (error) {
|
|
642
|
+
console.warn(`[agent] plan observation skipped: ${error.message}`);
|
|
643
|
+
}
|
|
528
644
|
}
|
|
529
645
|
const telemetryTools = new Map();
|
|
530
646
|
const emitFrame = frame => {
|
|
@@ -539,6 +655,9 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
539
655
|
toolFailed = true;
|
|
540
656
|
if (typeof frame.error === "string" && frame.error.startsWith("Validation failed for tool ")) frame.error = "INVALID_ARGUMENT: Unexpected field.";
|
|
541
657
|
}
|
|
658
|
+
// A command the sidecar re-sent once (stale revision, busy gesture) is
|
|
659
|
+
// marked on the frame itself; the receipt carries autoRebased and a warning.
|
|
660
|
+
if (frame.ok && frame.result?.autoRebased === true) frame.autoRebased = true;
|
|
542
661
|
send(frame);
|
|
543
662
|
if (record) {
|
|
544
663
|
record.execution.executed(frame.ok ? "succeeded" : (controller.signal.aborted ? "cancelled" : "failed"));
|
|
@@ -554,10 +673,15 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
554
673
|
if (frame.type === "done") {
|
|
555
674
|
if (turnOutcome === "succeeded" && !toolFailed) telemetry.finished("succeeded", null);
|
|
556
675
|
else telemetry.finished(turnOutcome, turnFailureCode || "tool_failed");
|
|
676
|
+
// Marked before the done frame leaves, so a client that asks for the
|
|
677
|
+
// review the moment its stream ends finds it pending, not absent.
|
|
678
|
+
// The author's own switch (value.supervisor === false) skips this turn's review.
|
|
679
|
+
if (supervisor && process.env.COZYCLAY_SUPERVISOR !== "off" && value.supervisor !== false && !controller.signal.aborted && shouldSupervise(turnFrames)) record.supervisor = "pending";
|
|
557
680
|
}
|
|
558
681
|
send(frame);
|
|
559
682
|
};
|
|
560
683
|
const persistenceMeta = { sceneName: value.context?.scene?.name ?? value.context?.sceneName ?? null, firstText: value.text, motionJobIds: [...session.motionJobIds] };
|
|
684
|
+
const modelKey = value.model || (fauxProvider ? `${fauxProvider.provider?.id || fauxProvider.provider || "faux"}/scripted` : "gpt-6-astra");
|
|
561
685
|
let runner = studioRunners.get(value.sessionId);
|
|
562
686
|
if (!runner) {
|
|
563
687
|
runner = createAgentRunner({ models: await ensureWorkflowModels(), fauxProvider, sessionStore, clock, codexBaseUrl, cliproxyBaseUrl, auth, env });
|
|
@@ -565,10 +689,10 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
565
689
|
}
|
|
566
690
|
try {
|
|
567
691
|
if (!session.modelSession) session.modelSession = await runner.openSession(value.sessionId, { surface: "studio" });
|
|
568
|
-
for await (const frame of session.modelSession.start({
|
|
569
|
-
surface: "studio", sessionId: value.sessionId, model:
|
|
692
|
+
for await (const frame of turnTransaction.run(input => session.modelSession.start(input))({
|
|
693
|
+
surface: "studio", sessionId: value.sessionId, model: modelKey, effort: value.effort,
|
|
570
694
|
text: value.text, attachments: value.attachments, contextText: encodeStudioContext(value.context), frameObservation,
|
|
571
|
-
context: value.context, tools: modelTools, signal: controller.signal, emit: send,
|
|
695
|
+
context: value.context, tools: [...modelTools, scriptTool], systemPrompt: value.context.scene?.previsMode === "storyboard" ? STUDIO_SYSTEM_PROMPT_STORYBOARD : undefined, signal: controller.signal, emit: send,
|
|
572
696
|
meta: persistenceMeta,
|
|
573
697
|
quotaEvent: headers => codex?.parseQuotaHeaders ? quotaEvent(codex, headers) : { type: "quota", plan: null, primary: { usedPercent: null, windowMinutes: null, resetAt: null }, credits: { has: null } },
|
|
574
698
|
})) emitFrame(frame);
|
|
@@ -581,7 +705,25 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
581
705
|
}
|
|
582
706
|
if (controller.signal.aborted) { turnOutcome = "cancelled"; turnFailureCode = "aborted"; }
|
|
583
707
|
if (!record.terminal) emitFrame({ type: "done" });
|
|
584
|
-
close(); if (!res.writableEnded) res.end(); session.controller = null;
|
|
708
|
+
close(); if (!res.writableEnded) res.end(); session.controller = null; session.activeTurnId = null;
|
|
709
|
+
// The review starts only now, after the stream has ended, and only ever
|
|
710
|
+
// appends one note to the record: it neither delays nor steers the turn.
|
|
711
|
+
if (record.supervisor === "pending") void (async () => {
|
|
712
|
+
const startedAt = Date.now();
|
|
713
|
+
let frame;
|
|
714
|
+
try {
|
|
715
|
+
frame = await runSupervisor({ registry: await ensureWorkflowModels(), modelKey, effort: process.env.COZYCLAY_SUPERVISOR_EFFORT || "high", turnId: value.turnId, request: value.text, frames: turnFrames, reply: lastReply,
|
|
716
|
+
hub, workspaceHandle: value.context.host.workspaceHandle, admission, baseline: value.context, actions: current?.actionIndex ?? [] });
|
|
717
|
+
} catch (error) {
|
|
718
|
+
frame = { type: "supervisor", turnId: value.turnId, verdict: "unavailable", model: modelKey, effort: null, elapsedMs: Date.now() - startedAt, summary: "", items: [], issues: [], fixes: [], droppedFixes: [], counts: { blocker: 0, concern: 0, note: 0, fixes: 0 }, reason: "The supervisor could not start." };
|
|
719
|
+
}
|
|
720
|
+
// COZYCLAY_SUPERVISOR_AUTOFIX=blocker (#743) only marks a note whose
|
|
721
|
+
// blockers carry fixes; turns stay the panel's to start, never the sidecar's.
|
|
722
|
+
if (process.env.COZYCLAY_SUPERVISOR_AUTOFIX === "blocker" && frame.fixes?.some(fix => fix.severity === "blocker")) frame = { ...frame, autofix: { eligible: true, severity: "blocker" } };
|
|
723
|
+
record.supervisor = "done";
|
|
724
|
+
emitStudioEvent(value.turnId, frame);
|
|
725
|
+
})();
|
|
726
|
+
return true;
|
|
585
727
|
};
|
|
586
728
|
const handle = async (req, res, path = new URL(req.url, "http://127.0.0.1").pathname) => {
|
|
587
729
|
if (!path.startsWith("/agent/")) return false;
|
|
@@ -595,7 +737,34 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
595
737
|
if (!record || !session || parseCookies(req).studio_owner !== session.owner) { json(res, 403, { error: { code: "AUTH_REQUIRED", message: "Studio event stream is not owned by this session." } }); return true; }
|
|
596
738
|
const after = Number(new URL(req.url, "http://127.0.0.1").searchParams.get("after") || 0);
|
|
597
739
|
if (!Number.isSafeInteger(after) || after < 0) { json(res, 400, { error: "invalid cursor" }); return true; }
|
|
598
|
-
writeStudioStream(res, record, after
|
|
740
|
+
writeStudioStream(res, record, after); return true;
|
|
741
|
+
}
|
|
742
|
+
// The supervisor note for one Studio turn (#728): 200 with the note once it
|
|
743
|
+
// exists, held up to SUPERVISOR_WAIT_MS while it is pending, 204 when the
|
|
744
|
+
// turn was never reviewed or the wait ran out.
|
|
745
|
+
if (path.startsWith("/agent/turn/") && path.endsWith("/supervisor") && req.method === "GET") {
|
|
746
|
+
if (!await hasAnyCredential()) { json(res, 401, { error: { code: "AUTH_REQUIRED", message: "Sign in or configure a provider key." } }); return true; }
|
|
747
|
+
const turnId = decodeURIComponent(path.slice("/agent/turn/".length, -"/supervisor".length));
|
|
748
|
+
const session = [...studioSessions.values()].find(candidate => candidate.turns.has(turnId)); const record = studioEvents.get(turnId);
|
|
749
|
+
if (!record || !session) { json(res, 404, { error: "turn not found" }); return true; }
|
|
750
|
+
if (parseCookies(req).studio_owner !== session.owner) { json(res, 403, { error: { code: "AUTH_REQUIRED", message: "Studio supervisor note is not owned by this session." } }); return true; }
|
|
751
|
+
const noContent = () => { res.writeHead(204, { "cache-control": "no-store" }); res.end(); };
|
|
752
|
+
const existing = record.events.findLast(event => event.type === "supervisor");
|
|
753
|
+
if (existing) { json(res, 200, existing); return true; }
|
|
754
|
+
if (record.supervisor !== "pending") { noContent(); return true; }
|
|
755
|
+
await new Promise(resolve => {
|
|
756
|
+
let timer;
|
|
757
|
+
const settle = note => {
|
|
758
|
+
clearTimeout(timer); record.listeners.delete(listener); res.off("close", onClose);
|
|
759
|
+
if (note !== undefined && !res.writableEnded && !res.destroyed) { if (note) json(res, 200, note); else noContent(); }
|
|
760
|
+
resolve();
|
|
761
|
+
};
|
|
762
|
+
const listener = event => { if (event.type === "supervisor") settle(event); };
|
|
763
|
+
const onClose = () => settle(undefined);
|
|
764
|
+
record.listeners.add(listener); res.once("close", onClose);
|
|
765
|
+
timer = setTimeout(() => settle(null), SUPERVISOR_WAIT_MS);
|
|
766
|
+
});
|
|
767
|
+
return true;
|
|
599
768
|
}
|
|
600
769
|
if (path.startsWith("/agent/turn/") && path.endsWith("/steer") && req.method === "POST") {
|
|
601
770
|
const turnId = decodeURIComponent(path.slice("/agent/turn/".length, -"/steer".length));
|
|
@@ -604,12 +773,15 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
604
773
|
value = await readBody(req, TURN_BODY_LIMIT);
|
|
605
774
|
if (!value || typeof value.text !== "string" || !value.text.trim() || !validAttachments(value.attachments)) throw new Error("Invalid request.");
|
|
606
775
|
} catch { json(res, 400, { error: "invalid request" }); return true; }
|
|
607
|
-
// Studio
|
|
608
|
-
//
|
|
609
|
-
|
|
610
|
-
const
|
|
776
|
+
// Studio turns are keyed in studioSessions, never in the Workflow
|
|
777
|
+
// `sessions` map, so that lookup alone tells them apart. Only the cookie
|
|
778
|
+
// owner of the Studio session may steer its running turn.
|
|
779
|
+
const studio = [...studioSessions.values()].find(candidate => candidate.turns.has(turnId));
|
|
780
|
+
if (studio && parseCookies(req).studio_owner !== studio.owner) { json(res, 403, { error: { code: "AUTH_REQUIRED", message: "Studio turn is not owned by this session." } }); return true; }
|
|
781
|
+
const session = studio ?? [...sessions.values()].find(candidate => candidate.turnId === turnId);
|
|
611
782
|
if (!session) { json(res, 404, { error: "turn not found" }); return true; }
|
|
612
|
-
|
|
783
|
+
const active = studio ? studio.activeTurnId === turnId && studio.controller && !studio.controller.signal.aborted : session.running;
|
|
784
|
+
if (!active || !session.modelSession) { json(res, 409, { error: { code: "NO_ACTIVE_TURN", message: "This turn already ended." } }); return true; }
|
|
613
785
|
const images = (Array.isArray(value.attachments) ? value.attachments : []).map(attachment => {
|
|
614
786
|
const match = /^data:([^;]+);base64,(.*)$/.exec(attachment?.dataUrl || "");
|
|
615
787
|
return match ? { type: "image", data: match[2], mimeType: match[1] } : null;
|
|
@@ -672,7 +844,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
672
844
|
let value;
|
|
673
845
|
try {
|
|
674
846
|
value = await readBody(req, IMAGE_BODY_LIMIT);
|
|
675
|
-
if (typeof value.prompt !== "string" || !value.prompt.trim() || typeof value.imageDataUrl !== "string" || !value.imageDataUrl.startsWith("data:image/") || (value.referenceDataUrl !== undefined && (typeof value.referenceDataUrl !== "string" || !value.referenceDataUrl.startsWith("data:image/"))) || !validReferences(value.references) || (value.quality !== undefined && !["auto", "low", "medium", "high"].includes(value.quality))) throw new Error("Invalid request.");
|
|
847
|
+
if (typeof value.prompt !== "string" || !value.prompt.trim() || typeof value.imageDataUrl !== "string" || !value.imageDataUrl.startsWith("data:image/") || (value.referenceDataUrl !== undefined && (typeof value.referenceDataUrl !== "string" || !value.referenceDataUrl.startsWith("data:image/"))) || !validReferences(value.references) || (value.composition !== undefined && !["frame", "sheet"].includes(value.composition)) || (value.quality !== undefined && !["auto", "low", "medium", "high"].includes(value.quality)) || (value.size !== undefined && !IMAGE_SIZES.includes(value.size))) throw new Error("Invalid request.");
|
|
676
848
|
} catch { json(res, 400, { error: "invalid request" }); return true; }
|
|
677
849
|
if (!await auth.getAccessToken()) { json(res, 401, { error: { code: "auth", message: "Sign in with ChatGPT in the Agent panel." } }); return true; }
|
|
678
850
|
try {
|
|
@@ -681,8 +853,17 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
681
853
|
// Scene references (#167) are appended after the frame/reference pair,
|
|
682
854
|
// and the prompt says what each attachment is for.
|
|
683
855
|
const references = Array.isArray(value.references) ? value.references : [];
|
|
684
|
-
|
|
685
|
-
|
|
856
|
+
// composition "frame": the attached frame was framed by hand (Pose mode), so the shot
|
|
857
|
+
// camera's description would contradict it; the frame alone owns camera and pose.
|
|
858
|
+
// composition "sheet" (Sculpt blueprint): a model sheet, not a shot, so no scene or
|
|
859
|
+
// camera guidance is added; the prompt is sent as written.
|
|
860
|
+
const prompt = value.composition === "sheet"
|
|
861
|
+
? value.prompt
|
|
862
|
+
: value.composition === "frame"
|
|
863
|
+
? `${FRAME_COMPOSITION_GUIDANCE}\n${value.prompt}${referenceGuidance(references)}`
|
|
864
|
+
: `${value.prompt}${await renderGuidance(value.prompt)}${referenceGuidance(references)}`;
|
|
865
|
+
const { composition: _composition, ...request } = value;
|
|
866
|
+
const result = await codex.editImage({ ...request, prompt, extraImages: references.map((entry) => entry.dataUrl) });
|
|
686
867
|
json(res, 200, { dataUrl: `data:image/png;base64,${result.pngBase64}`, width: result.width, height: result.height });
|
|
687
868
|
} catch (error) { json(res, error.status === 401 ? 401 : 502, { error: errorInfo(error) }); }
|
|
688
869
|
return true;
|
|
@@ -40,7 +40,7 @@ export function classifyProviderError(message) {
|
|
|
40
40
|
return "upstream";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
function extractStatus(message) {
|
|
44
44
|
const match = /\b(401|403|429|5\d\d)\b/.exec(message);
|
|
45
45
|
return match ? Number(match[1]) : undefined;
|
|
46
46
|
}
|
|
@@ -145,11 +145,90 @@ function studioUserMessage(input) {
|
|
|
145
145
|
};
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
const STUDIO_CONTEXT_PREFIX = "<studio-context>";
|
|
149
|
+
const STUDIO_CONTEXT_BLOCK = /^<studio-context>\n([\s\S]*?)\n<\/studio-context>/;
|
|
150
|
+
const STUDIO_CONTEXT_SUPERSEDED = "<studio-context superseded/>";
|
|
151
|
+
const escapeStudioJson = (value) => JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026");
|
|
152
|
+
const isStudioContextPart = (part) => part?.type === "text" && typeof part.text === "string" && part.text.startsWith(STUDIO_CONTEXT_PREFIX);
|
|
153
|
+
|
|
154
|
+
function withoutInspectContext(part) {
|
|
155
|
+
if (part?.type !== "text" || typeof part.text !== "string") return part;
|
|
156
|
+
let parsed;
|
|
157
|
+
try { parsed = JSON.parse(part.text); } catch { return part; }
|
|
158
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Object.hasOwn(parsed, "context")) return part;
|
|
159
|
+
const { context: _context, ...rest } = parsed;
|
|
160
|
+
return { ...part, text: JSON.stringify(rest) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The Studio per-request context diet (#716), applied to the provider view
|
|
164
|
+
* only (pi's transform_context): every <studio-context> but the latest is
|
|
165
|
+
* replaced by a superseded marker, every inspect_studio result but the most
|
|
166
|
+
* recent tool result drops its re-embedded `context`, and the latest
|
|
167
|
+
* context's static actionIndex moves to the end of the system prompt. The
|
|
168
|
+
* stored lane messages are never touched; edited messages are new objects. */
|
|
169
|
+
function studioContextDiet({ messages, systemPrompt }) {
|
|
170
|
+
let latestContext = -1;
|
|
171
|
+
let latestToolResult = -1;
|
|
172
|
+
messages.forEach((message, index) => {
|
|
173
|
+
if (message?.role === "user" && Array.isArray(message.content) && message.content.some(isStudioContextPart)) latestContext = index;
|
|
174
|
+
if (message?.role === "toolResult") latestToolResult = index;
|
|
175
|
+
});
|
|
176
|
+
let actionIndex;
|
|
177
|
+
const edited = messages.map((message, index) => {
|
|
178
|
+
if (message?.role === "toolResult" && message.toolName === "inspect_studio" && index !== latestToolResult && Array.isArray(message.content)) {
|
|
179
|
+
const content = message.content.map(withoutInspectContext);
|
|
180
|
+
return content.some((part, i) => part !== message.content[i]) ? { ...message, content } : message;
|
|
181
|
+
}
|
|
182
|
+
if (message?.role !== "user" || !Array.isArray(message.content) || !message.content.some(isStudioContextPart)) return message;
|
|
183
|
+
const content = message.content.map((part) => {
|
|
184
|
+
if (!isStudioContextPart(part)) return part;
|
|
185
|
+
const block = STUDIO_CONTEXT_BLOCK.exec(part.text);
|
|
186
|
+
if (index !== latestContext) return { ...part, text: block ? STUDIO_CONTEXT_SUPERSEDED + part.text.slice(block[0].length) : STUDIO_CONTEXT_SUPERSEDED };
|
|
187
|
+
if (!block) return part;
|
|
188
|
+
let context;
|
|
189
|
+
try { context = JSON.parse(block[1]); } catch { return part; }
|
|
190
|
+
if (!context || typeof context !== "object" || !Array.isArray(context.actionIndex)) return part;
|
|
191
|
+
({ actionIndex } = context);
|
|
192
|
+
const { actionIndex: _actionIndex, ...rest } = context;
|
|
193
|
+
return { ...part, text: `${STUDIO_CONTEXT_PREFIX}\n${escapeStudioJson(rest)}\n</studio-context>${part.text.slice(block[0].length)}` };
|
|
194
|
+
});
|
|
195
|
+
return { ...message, content };
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
messages: edited,
|
|
199
|
+
systemPrompt: actionIndex ? `${systemPrompt}\n<actions>\n${JSON.stringify(actionIndex)}\n</actions>` : systemPrompt,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Anthropic prompt-cache breakpoints on the stable prefix (#716): pi-ai's
|
|
204
|
+
* anthropic-messages builder already marks the system blocks, the last tool
|
|
205
|
+
* and the last user block by default; this only fills a missing mark on the
|
|
206
|
+
* last system block and the last tool, never past the API's 4 breakpoints,
|
|
207
|
+
* and never on tools for a model whose compat declares them unsupported. */
|
|
208
|
+
export function withAnthropicCacheBreakpoints(payload, model) {
|
|
209
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
210
|
+
const marked = (block) => Boolean(block?.cache_control);
|
|
211
|
+
const system = Array.isArray(payload.system) ? payload.system : [];
|
|
212
|
+
const tools = Array.isArray(payload.tools) ? payload.tools : [];
|
|
213
|
+
const messageBlocks = (Array.isArray(payload.messages) ? payload.messages : []).flatMap((message) => Array.isArray(message?.content) ? message.content : []);
|
|
214
|
+
let count = [...system, ...tools, ...messageBlocks].filter(marked).length;
|
|
215
|
+
const cacheControl = { type: "ephemeral" };
|
|
216
|
+
let next = payload;
|
|
217
|
+
if (system.length && !marked(system.at(-1)) && count < 4) {
|
|
218
|
+
next = { ...next, system: [...system.slice(0, -1), { ...system.at(-1), cache_control: cacheControl }] };
|
|
219
|
+
count += 1;
|
|
220
|
+
}
|
|
221
|
+
if (tools.length && !marked(tools.at(-1)) && model?.compat?.supportsCacheControlOnTools !== false && count < 4) {
|
|
222
|
+
next = { ...next, tools: [...tools.slice(0, -1), { ...tools.at(-1), cache_control: cacheControl }] };
|
|
223
|
+
}
|
|
224
|
+
return next === payload ? undefined : next;
|
|
225
|
+
}
|
|
226
|
+
|
|
148
227
|
function studioObservationMessage(observation) {
|
|
149
228
|
return {
|
|
150
229
|
role: "user",
|
|
151
230
|
content: [
|
|
152
|
-
{ type: "text", text: `Studio frame observation revision ${JSON.stringify(observation?.revision ?? null)} receipt ${observation?.receiptId ?? "unavailable"}` },
|
|
231
|
+
{ type: "text", text: `Studio ${observation?.kind === "plan" ? "plan observation (top view)" : "frame observation"} revision ${JSON.stringify(observation?.revision ?? null)} receipt ${observation?.receiptId ?? "unavailable"}` },
|
|
153
232
|
{ type: "image", data: observation.data, mimeType: observation.mimeType },
|
|
154
233
|
],
|
|
155
234
|
};
|
|
@@ -311,6 +390,18 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
|
|
|
311
390
|
streamOptions: { maxRetryDelayMs: RETRY_MAX_DELAY_MS, ...(codexBaseUrl ? { transport: "sse" } : {}) },
|
|
312
391
|
}, state.context)).harness;
|
|
313
392
|
state.harness.hooks.on("after_response", (response) => emitQuota(state.active?.queue, state.lastInput, response));
|
|
393
|
+
state.harness.hooks.on("after_response", ({ message }) => {
|
|
394
|
+
const usage = state.active?.usage;
|
|
395
|
+
if (!usage) return;
|
|
396
|
+
usage.requests += 1;
|
|
397
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite"]) if (Number.isFinite(message?.usage?.[key])) usage[key] += message.usage[key];
|
|
398
|
+
});
|
|
399
|
+
state.harness.hooks.on("transform_context", (event) => state.lastInput?.surface === "studio" ? studioContextDiet(event) : undefined);
|
|
400
|
+
state.harness.hooks.on("before_payload", ({ model, payload }) => {
|
|
401
|
+
if (model?.api !== "anthropic-messages") return undefined;
|
|
402
|
+
const next = withAnthropicCacheBreakpoints(payload, model);
|
|
403
|
+
return next ? { payload: next } : undefined;
|
|
404
|
+
});
|
|
314
405
|
state.harness.hooks.on("after_response", async ({ message }) => {
|
|
315
406
|
if (message.stopReason !== "error" || state.provider !== "openai-codex" || state.active.authRetried
|
|
316
407
|
|| classifyError({ message: message.errorMessage }).code !== "unauthorized") return;
|
|
@@ -327,6 +418,9 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
|
|
|
327
418
|
for (const message of restored.history) await state.lane.appendMessage(message, state.context);
|
|
328
419
|
state.persisted = restored.history.length;
|
|
329
420
|
}
|
|
421
|
+
// Studio only: older turns' full contexts are stale and large, so each request
|
|
422
|
+
// carries the newest one whole and a stub for the rest. The stored lane and
|
|
423
|
+
// the persisted history keep every context.
|
|
330
424
|
state.unsubscribers.push(state.harness.events.on("turn_end", persist));
|
|
331
425
|
state.unsubscribers.push(state.harness.events.on("run_end", persist));
|
|
332
426
|
}
|
|
@@ -405,7 +499,7 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
|
|
|
405
499
|
const fallback = code === "aborted" ? "The turn was aborted." : code === "truncated" ? "The model stream ended before a terminal response event." : "The model or live editor could not complete this turn.";
|
|
406
500
|
queue.push({ type: "error", code, message: detail || fallback, ...(status ? { status } : {}) });
|
|
407
501
|
}
|
|
408
|
-
queue.push({ type: "done" });
|
|
502
|
+
queue.push({ type: "done", usage: { ...state.active.usage } });
|
|
409
503
|
queue.close();
|
|
410
504
|
});
|
|
411
505
|
return () => { for (const unsubscribe of eventUnsubscribers) unsubscribe(); };
|
|
@@ -420,7 +514,7 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
|
|
|
420
514
|
const queue = new FrameQueue();
|
|
421
515
|
// Studio never sends a quota frame at all (unchanged, #379): mark it sent
|
|
422
516
|
// up front so `pushFrame`'s gate never holds Studio's first frame.
|
|
423
|
-
state.active = { queue, quotaSent: input.surface === "studio", pendingFrames: [], authRetried: false, abortRequested: false, lastResponseHeaders: null };
|
|
517
|
+
state.active = { queue, quotaSent: input.surface === "studio", pendingFrames: [], authRetried: false, abortRequested: false, lastResponseHeaders: null, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 } };
|
|
424
518
|
state.lastInput = input;
|
|
425
519
|
const controller = input.signal ? null : new AbortController();
|
|
426
520
|
const signal = input.signal || controller.signal;
|