cozyclay 3.1.4 → 3.1.6
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 +104 -0
- package/INSTALL_FOR_AGENTS.md +3 -0
- package/README.md +5 -0
- package/bin/agent/agent-failure-codes.mjs +31 -0
- package/bin/agent/agent-routes.mjs +5 -10
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/cozyclay.mjs +16 -64
- package/bin/studio-routes.mjs +69 -0
- package/bin/update-check.mjs +53 -5
- package/bin/update-install.mjs +136 -0
- package/bin/update-routes.mjs +80 -0
- package/dist/ai-camera-control/index.html +1 -1
- package/dist/app/index.html +5 -6
- package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
- package/dist/assets/app-BLS1Zaru.js +5144 -0
- package/dist/assets/app-CEP7jzXB.css +1 -0
- package/dist/assets/compile-worker-or7djI2v.js +6 -0
- package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
- package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
- package/dist/assets/task-board-CkLZGPkv.js +15 -0
- package/dist/assets/workflow-DJwOUzqJ.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +3 -3
- package/dist/index.html +533 -264
- package/dist/media/cozyclay-mark.png +0 -0
- package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
- package/dist/media/step-move-poster.jpg +0 -0
- package/dist/media/step-shoot-poster.jpg +0 -0
- package/dist/media/step-stage-poster.jpg +0 -0
- package/dist/media/truck-greybox-poster.jpg +0 -0
- package/dist/media/truck-seedance-poster.jpg +0 -0
- package/dist/previs-software/index.html +4 -4
- package/dist/privacy/index.html +46 -19
- package/dist/seedance-camera-control/index.html +3 -3
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +3 -4
- package/mcp/project-camera.mjs +43 -0
- package/package.json +2 -1
- package/src/App.jsx +59 -70
- package/src/analytics-controls.js +17 -0
- package/src/analytics-friction.js +124 -0
- package/src/analytics-strict.js +139 -0
- package/src/analytics-values.js +49 -0
- package/src/analytics.js +37 -133
- package/src/asset-pane.jsx +4 -5
- package/src/click-friction.js +101 -0
- package/src/command-bus-telemetry.js +155 -0
- package/src/command-bus.js +10 -8
- package/src/commands/motion3.js +7 -2
- package/src/controls.jsx +3 -2
- package/src/crash-telemetry.js +45 -0
- package/src/domains/cast.js +2 -11
- package/src/domains/motion.js +49 -38
- package/src/domains/objects.js +3 -0
- package/src/domains/scene-undo.js +27 -0
- package/src/domains/scenes.js +1 -1
- package/src/error-boundary.jsx +2 -0
- package/src/export-attempt.js +100 -0
- package/src/export-workspace.js +12 -1
- package/src/first-success-guide.jsx +11 -7
- package/src/guide-telemetry.js +60 -0
- package/src/hierarchy-panel.jsx +2 -1
- package/src/live-control.js +2 -9
- package/src/locale-zh/domains.js +1 -0
- package/src/main.jsx +5 -0
- package/src/motion/timeline.jsx +9 -5
- package/src/motion-draft-telemetry.js +62 -0
- package/src/motion3/compiler/channels.js +7 -5
- package/src/motion3/compiler/compile.js +6 -2
- package/src/motion3/compiler/finger-close.js +84 -7
- package/src/motion3/compiler/head-lead.js +70 -0
- package/src/motion3/compiler/head-steady.js +1 -1
- package/src/motion3/compiler/limbs.js +10 -3
- package/src/motion3/compiler/pose.js +1 -1
- package/src/motion3/compiler/validate.js +5 -3
- package/src/motion3/pipeline.js +4 -0
- package/src/motion3/qa/explain.js +2 -1
- package/src/motion3/qa/finger-checks.js +80 -0
- package/src/motion3/qa/gait-observe.js +88 -0
- package/src/motion3/qa/grasp-checks.js +3 -0
- package/src/motion3/qa/hints-grasp.js +8 -0
- package/src/motion3/qa/index.js +2 -1
- package/src/motion3/qa/motion-life.js +26 -3
- package/src/motion3/schema/topics.js +1 -1
- package/src/playback-telemetry.js +107 -0
- package/src/post-turn-edits.js +62 -0
- package/src/search-friction.js +49 -0
- package/src/semantic-edit.js +19 -6
- package/src/shell/BoardSlot.jsx +3 -3
- package/src/shell/BottomDock.jsx +1 -1
- package/src/shell/DetailsSlot.jsx +1 -1
- package/src/shell/MenuBar.jsx +13 -13
- package/src/shell/StudioShell.jsx +7 -1
- package/src/shell/TopBar.jsx +2 -0
- package/src/shell/ViewportToolbar.jsx +2 -2
- package/src/shortcut-friction.js +122 -0
- package/src/stage-canvas-rigs.jsx +3 -4
- package/src/studio-telemetry.js +97 -0
- package/src/thrash-friction.js +90 -0
- package/src/update/UpdateChip.jsx +86 -0
- package/src/update/UpdateDialog.jsx +147 -0
- package/src/update/update-client.js +188 -0
- package/src/update/update-telemetry.js +78 -0
- package/src/update/update.css +94 -0
- package/src/use-webgl-available.js +35 -0
- package/src/workflow/AgentPanel.jsx +29 -30
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/LoginPanel.jsx +15 -5
- package/src/workflow/agent-client.js +10 -91
- package/src/workflow/agent-failure-codes.js +47 -0
- package/src/workflow/agent-turn-telemetry.js +222 -0
- package/src/workflow/connect-telemetry.js +86 -0
- package/src/workflow/prompt-telemetry.js +105 -0
- package/tools/dev/pages/privacy.html +44 -17
- package/tools/loc-baseline.json +10 -10
- package/tools/run-tests.mjs +8 -8
- package/tools/verify-package-tarball.mjs +29 -1
- package/dist/assets/app-BdRlMf4J.css +0 -1
- package/dist/assets/app-KXdgBC-S.js +0 -5144
- package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
- package/dist/assets/landing-y3EqDgSZ.js +0 -1
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
- package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
- package/dist/assets/task-board-DFfNa0xg.js +0 -15
- package/dist/assets/workflow-BMJGd_gZ.js +0 -179
- package/dist/media/playground-poster.jpg +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,110 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 3.1.6 - 2026-10-25
|
|
6
|
+
|
|
7
|
+
The npm package ships the MCP module the live tools need, so the Studio's live tools and `cozyclay mcp` work on a fresh install.
|
|
8
|
+
|
|
9
|
+
Studio
|
|
10
|
+
|
|
11
|
+
- A 3.1.5 global install shows the **Update** chip in the top bar for the
|
|
12
|
+
first time, since this is the first release after the chip shipped.
|
|
13
|
+
**Update now**, or the command it offers to copy, installs 3.1.6; then
|
|
14
|
+
restart `cclay`.
|
|
15
|
+
|
|
16
|
+
MCP
|
|
17
|
+
|
|
18
|
+
- The npm package now ships `mcp/project-camera.mjs`, so the Studio's live
|
|
19
|
+
tools and `cozyclay mcp` start on an npm install instead of failing with
|
|
20
|
+
"Cannot find module" (broken since 3.1.2). The package check now fails a
|
|
21
|
+
release whose `bin/` or `mcp/` code imports a file the package leaves out.
|
|
22
|
+
|
|
23
|
+
## 3.1.5 - 2026-10-25
|
|
24
|
+
|
|
25
|
+
The Studio tells you when a new release is out and installs it in one click, Claude models work in the agent panel again, IK edits stay on their own character, turns and walks look more natural, and anonymous analytics show where the Studio gets in the way.
|
|
26
|
+
|
|
27
|
+
Agent
|
|
28
|
+
|
|
29
|
+
- Agent turns on Claude models no longer fail with a 400 error about a
|
|
30
|
+
deferred placeholder tool.
|
|
31
|
+
- When a turn is already running, the failure chip reads `busy` instead of
|
|
32
|
+
`upstream`. The message text is unchanged.
|
|
33
|
+
|
|
34
|
+
Motion
|
|
35
|
+
|
|
36
|
+
- The head turns first and leads the body into a turn, by about 5 frames at
|
|
37
|
+
24 fps, the way a person's does. `flow.headLead` sets the frames (0 to
|
|
38
|
+
12), and `false` turns it off. A keyed head look still wins.
|
|
39
|
+
- Takes that walk report `observations.gait`: cadence, step length, speed,
|
|
40
|
+
stance and pelvis motion, with warnings outside a person's range. It is a
|
|
41
|
+
reading only and never changes the QA result.
|
|
42
|
+
- Grasp QA reads each finger: `FINGER_ROM` (a joint past its range),
|
|
43
|
+
`FINGER_SELF` (two fingers inside each other) and `THUMB_OFF` (a held
|
|
44
|
+
object without the thumb on it).
|
|
45
|
+
- A carried ball keeps the thumb on it. A finger that misses a held object
|
|
46
|
+
turns toward it at the knuckle, and the ring and little fingers curl
|
|
47
|
+
together. Stacked hand presets no longer over-curl.
|
|
48
|
+
|
|
49
|
+
Studio
|
|
50
|
+
|
|
51
|
+
- An npm studio shows a small **Update** chip in the top bar when a newer
|
|
52
|
+
release is out, once per version. A global install whose folder npm can
|
|
53
|
+
write gets **Update now**, which installs the release and then asks you
|
|
54
|
+
to restart `cclay`. Other installs get the command to copy, and npx gets
|
|
55
|
+
"run `npx cozyclay` again". **Update now** waits while an agent turn,
|
|
56
|
+
an export or a recording runs. A failed install names the cause
|
|
57
|
+
(permissions, network, Node version, npm missing) and keeps the command.
|
|
58
|
+
- An IK edit stays with its own character. Selecting another character
|
|
59
|
+
mid-drag no longer poses it, and undo no longer reverts or deletes the
|
|
60
|
+
other character. Switching characters applies a pending IK edit and shows
|
|
61
|
+
a notice.
|
|
62
|
+
- Ctrl+Z after an IK drag puts the pose back, with or without a take
|
|
63
|
+
loaded.
|
|
64
|
+
- On a browser without WebGL, the unavailable notice appears one frame
|
|
65
|
+
after the Studio first draws instead of during it.
|
|
66
|
+
|
|
67
|
+
CLI
|
|
68
|
+
|
|
69
|
+
- `cclay update --check` prints the current and latest version without
|
|
70
|
+
installing, and exits 0 (up to date), 10 (update available) or 11
|
|
71
|
+
(unknown). `cclay update` now refuses a release your Node.js cannot run.
|
|
72
|
+
- A source checkout reports itself as `clone`, and a Windows global install
|
|
73
|
+
is no longer mistaken for npx.
|
|
74
|
+
|
|
75
|
+
Site
|
|
76
|
+
|
|
77
|
+
- A redesigned cozyclay.org: a black first screen with a greybox-to-video
|
|
78
|
+
reel, how-it-works clips from the tutorial, and Try it opens the Studio.
|
|
79
|
+
|
|
80
|
+
Analytics
|
|
81
|
+
|
|
82
|
+
- The anonymous usage analytics now cover where the Studio gets in the way:
|
|
83
|
+
- the agent loop: refused commands, a summary per agent turn, each motion
|
|
84
|
+
draft and its QA result, and each prompt sent, so retry loops and
|
|
85
|
+
hesitation show
|
|
86
|
+
- model connect: the sign-in steps and where they stop, and model changes
|
|
87
|
+
- exports: retries, the stage a failed or stopped export reached, and
|
|
88
|
+
saved files. Storyboard PNG, render pass and OTIO exports are now
|
|
89
|
+
counted.
|
|
90
|
+
- clicks, shortcuts and search: repeated or dead clicks on the main
|
|
91
|
+
controls, shortcuts that did nothing, searches with no result, and
|
|
92
|
+
switching panels or orbiting the camera back and forth
|
|
93
|
+
- playback, boot and crashes: time to the first frame, playback runs and
|
|
94
|
+
late frames, the first-success guide, render crashes and lost WebGL
|
|
95
|
+
- the update notice: when it shows, which button was pressed, and whether
|
|
96
|
+
the install worked
|
|
97
|
+
- one friction summary per session
|
|
98
|
+
- Every value is a closed bucket or a fixed id. No text, names, paths,
|
|
99
|
+
object ids, click coordinates or npm output are sent. Prompt text never
|
|
100
|
+
leaves the browser; only a length bucket and a similarity grade to the
|
|
101
|
+
last prompt do.
|
|
102
|
+
- First-edit events count only your own edits, not the agent's, and report
|
|
103
|
+
`definition_version` 2.
|
|
104
|
+
- Events that nothing sent any more are removed: the old motion-generation
|
|
105
|
+
events and `sample:played`.
|
|
106
|
+
- The privacy page lists every event and property. `cclay telemetry off`
|
|
107
|
+
still turns it all off.
|
|
108
|
+
|
|
5
109
|
## 3.1.4 - 2026-10-24
|
|
6
110
|
|
|
7
111
|
Starring CozyClay from the terminal takes one key when the GitHub CLI is signed in.
|
package/INSTALL_FOR_AGENTS.md
CHANGED
|
@@ -32,6 +32,9 @@ npx -y cozyclay --no-star
|
|
|
32
32
|
|
|
33
33
|
For a permanent install, run `npm install -g cozyclay`. Then `cclay` and `cozyclay` are the same
|
|
34
34
|
command. `cclay update` installs the latest release.
|
|
35
|
+
`cclay update --check` installs nothing. It prints the current version, the latest version and
|
|
36
|
+
the install kind, then exits `0` when up to date, `10` when an update is available, and `11` when
|
|
37
|
+
it cannot tell (offline, or checks turned off).
|
|
35
38
|
|
|
36
39
|
Tell the user:
|
|
37
40
|
|
package/README.md
CHANGED
|
@@ -58,6 +58,11 @@ npx cozyclay
|
|
|
58
58
|
That is the whole install. You need Node.js 22.19 or newer and a Chromium browser. You do not need
|
|
59
59
|
a GPU, Python or a model download. The studio opens at `http://127.0.0.1:5180/app/`.
|
|
60
60
|
|
|
61
|
+
**Updating.** `npx cozyclay` fetches the newest release each time you run it. After
|
|
62
|
+
`npm install -g cozyclay`, the studio shows a small **Update** chip in the top bar when a newer
|
|
63
|
+
release is out. **Update now** installs it, and then you restart `cclay`. `cclay update` does the
|
|
64
|
+
same from the terminal. `--no-update-check` or `COZYCLAY_NO_UPDATE_CHECK=1` turns the check off.
|
|
65
|
+
|
|
61
66
|
> **Asking an AI agent to set it up?** Give it this repository link. Its instructions are in
|
|
62
67
|
> [`INSTALL_FOR_AGENTS.md`](INSTALL_FOR_AGENTS.md).
|
|
63
68
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The closed `agent:turn_failed.failure_code` vocabulary, sidecar side. Kept
|
|
2
|
+
// self-contained (minimal sidecar installs omit src/); it gives the same answers
|
|
3
|
+
// as src/workflow/agent-failure-codes.js, whose header defines each code, and
|
|
4
|
+
// test/verify-agent-execution.mjs holds the two tables to that. Only the
|
|
5
|
+
// structured code and HTTP status are read, never a message.
|
|
6
|
+
const BY_CODE = {
|
|
7
|
+
unauthorized: "key_refused", auth: "auth", AUTH_REQUIRED: "auth", rate_limit: "rate_limited", RATE_LIMITED: "rate_limited",
|
|
8
|
+
STALE_SCENE: "stale_scene", STALE_TARGET: "stale_scene", STALE_ENVIRONMENT: "stale_scene",
|
|
9
|
+
CAPABILITY_MISSING: "capability_missing", LIVE_HUB_UNAVAILABLE: "capability_missing",
|
|
10
|
+
busy: "busy", TARGET_BUSY: "busy", SCOPE_BUSY: "busy", TARGET_NOT_READY: "busy",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** `{ code, status }` (a thrown error or a runner `error` frame) -> failure code.
|
|
14
|
+
* The runner's `overloaded`/`upstream`/`truncated` and every 5xx read as upstream. */
|
|
15
|
+
export function failureCodeOf(error, fallback = "upstream") {
|
|
16
|
+
try {
|
|
17
|
+
const code = typeof error?.code === "string" ? error.code : "";
|
|
18
|
+
const status = error?.status;
|
|
19
|
+
if (Object.hasOwn(BY_CODE, code)) return BY_CODE[code];
|
|
20
|
+
if (code.startsWith("INVALID_")) return "invalid_request";
|
|
21
|
+
if (status === 401 || status === 403) return "auth";
|
|
22
|
+
if (status === 429) return "rate_limited";
|
|
23
|
+
return Number.isInteger(status) && status >= 400 && status < 500 ? "invalid_request" : fallback;
|
|
24
|
+
} catch { return fallback; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A turn that threw: an abort wins, then the error's own code, then a tool failure. */
|
|
28
|
+
export const agentFailureCode = (error, signal, tool = false) => {
|
|
29
|
+
try { if (signal.aborted || error?.name === "AbortError") return "aborted"; } catch { /* foreign getter */ }
|
|
30
|
+
return failureCodeOf(error, tool ? "tool_failed" : "upstream");
|
|
31
|
+
};
|
|
@@ -13,6 +13,7 @@ import { createAgentRunner } from "./agent-runner.mjs";
|
|
|
13
13
|
import { createProviderRoutes } from "./provider-routes.mjs";
|
|
14
14
|
import { STUDIO_SYSTEM_PROMPT_STORYBOARD } from "./studio-prompt.mjs";
|
|
15
15
|
import { studioTransaction } from "./studio-turn-transaction.mjs";
|
|
16
|
+
import { agentFailureCode, failureCodeOf } from "./agent-failure-codes.mjs";
|
|
16
17
|
|
|
17
18
|
// Values the codex backend accepts for reasoning.effort (its own 400 lists them).
|
|
18
19
|
export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
|
|
@@ -77,12 +78,6 @@ const agentToolCategory = (name) => {
|
|
|
77
78
|
if (name === "place_image_in_scene" || name === "add_reference_node") return "scene_write";
|
|
78
79
|
return "other";
|
|
79
80
|
};
|
|
80
|
-
const agentFailureCode = (error, signal, tool = false) => advisory(() => {
|
|
81
|
-
if (signal.aborted || error?.name === "AbortError") return "aborted";
|
|
82
|
-
if (error?.status === 401 || error?.code === "unauthorized") return "auth";
|
|
83
|
-
if (error?.status === 429) return "rate_limited";
|
|
84
|
-
return tool ? "tool_failed" : "upstream";
|
|
85
|
-
}, tool ? "tool_failed" : "upstream");
|
|
86
81
|
// These existing canvas commands only return a node/edge after publishing a
|
|
87
82
|
// new insertion. Read/focus, generic accepted responses, update no-ops and run
|
|
88
83
|
// outputs (which can echo old values) are deliberately not application proof.
|
|
@@ -579,7 +574,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
579
574
|
}
|
|
580
575
|
if (frame.type === "error") {
|
|
581
576
|
turnOutcome = controller.signal.aborted || frame.code === "aborted" ? "cancelled" : "failed";
|
|
582
|
-
turnFailureCode = controller.signal.aborted ? "aborted" : frame
|
|
577
|
+
turnFailureCode = controller.signal.aborted || frame.code === "aborted" ? "aborted" : failureCodeOf(frame);
|
|
583
578
|
return send(frame);
|
|
584
579
|
}
|
|
585
580
|
if (frame.type === "done") {
|
|
@@ -822,7 +817,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
822
817
|
const send = (event) => { if (!res.writableEnded && !res.destroyed) res.write(`data: ${JSON.stringify(event)}\n\n`); };
|
|
823
818
|
let session = sessions.get(value.sessionId);
|
|
824
819
|
if (session?.running) {
|
|
825
|
-
send({ type: "error", code: "
|
|
820
|
+
send({ type: "error", code: "busy", message: "busy" }); send({ type: "done" }); res.end(); return true;
|
|
826
821
|
}
|
|
827
822
|
session ||= { images: new Map(), history: [] };
|
|
828
823
|
sessions.set(value.sessionId, session);
|
|
@@ -870,7 +865,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
870
865
|
return;
|
|
871
866
|
}
|
|
872
867
|
turnOutcome = signal.aborted || frame.code === "aborted" ? "cancelled" : "failed";
|
|
873
|
-
turnFailureCode = signal.aborted ? "aborted" : frame
|
|
868
|
+
turnFailureCode = signal.aborted || frame.code === "aborted" ? "aborted" : failureCodeOf(frame);
|
|
874
869
|
return send(frame);
|
|
875
870
|
}
|
|
876
871
|
if (frame.type === "done") {
|
|
@@ -903,7 +898,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
903
898
|
catch (error) {
|
|
904
899
|
turnOutcome = signal.aborted ? "cancelled" : "failed";
|
|
905
900
|
turnFailureCode = signal.aborted ? "aborted" : agentFailureCode(error, signal, toolFailed);
|
|
906
|
-
if (!signal.aborted)
|
|
901
|
+
if (!signal.aborted) send({ type: "error", ...errorInfo(error, quota) });
|
|
907
902
|
if (!res.writableEnded) emitFrame({ type: "done" });
|
|
908
903
|
}
|
|
909
904
|
finally {
|
|
@@ -220,8 +220,10 @@ export function withAnthropicCacheBreakpoints(payload, model) {
|
|
|
220
220
|
next = { ...next, system: [...system.slice(0, -1), { ...system.at(-1), cache_control: cacheControl }] };
|
|
221
221
|
count += 1;
|
|
222
222
|
}
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
// pi-ai 1.4 appends a defer_loading placeholder tool; the API rejects cache_control on deferred tools.
|
|
224
|
+
const lastTool = tools.findLastIndex((tool) => !tool?.defer_loading);
|
|
225
|
+
if (lastTool !== -1 && !marked(tools[lastTool]) && model?.compat?.supportsCacheControlOnTools !== false && count < 4) {
|
|
226
|
+
next = { ...next, tools: tools.map((tool, index) => index === lastTool ? { ...tool, cache_control: cacheControl } : tool) };
|
|
225
227
|
}
|
|
226
228
|
return next === payload ? undefined : next;
|
|
227
229
|
}
|
|
@@ -511,7 +513,7 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
|
|
|
511
513
|
|
|
512
514
|
const start = async function* (input = {}) {
|
|
513
515
|
if (state.active) {
|
|
514
|
-
yield { type: "error", code: "
|
|
516
|
+
yield { type: "error", code: "busy", message: "busy" };
|
|
515
517
|
yield { type: "done" };
|
|
516
518
|
return;
|
|
517
519
|
}
|
package/bin/cozyclay.mjs
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* that needs an install step before it can serve a prebuilt app is a
|
|
15
15
|
* launcher that will break.
|
|
16
16
|
*/
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
18
17
|
import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
19
18
|
import { createServer } from "node:http";
|
|
20
19
|
import { homedir } from "node:os";
|
|
@@ -24,7 +23,8 @@ import { fileURLToPath } from "node:url";
|
|
|
24
23
|
import { ensureMcpRuntime, runMcp } from "./mcp-runtime.mjs";
|
|
25
24
|
import { openBrowser } from "./open-browser.mjs";
|
|
26
25
|
import { maybeAskForStar } from "./star-prompt.mjs";
|
|
27
|
-
import { checkForUpdate, readUpdateStatus, runUpdate } from "./update-check.mjs";
|
|
26
|
+
import { checkForUpdate, readUpdateStatus, runUpdate, runUpdateCheck, updateChecksOn } from "./update-check.mjs";
|
|
27
|
+
import { createStudioRoutes } from "./studio-routes.mjs";
|
|
28
28
|
import { handleOAuthRequest } from "./codex-auth.mjs";
|
|
29
29
|
import { createAgentHandler } from "./agent/agent-routes.mjs";
|
|
30
30
|
import { liveHubPort } from "./live-endpoint.mjs";
|
|
@@ -56,8 +56,8 @@ const OFFICIAL_PACKAGE = (
|
|
|
56
56
|
&& !SOURCE_CHECKOUT
|
|
57
57
|
&& verifyPackageMarker(join(DIST, "cozyclay-package.json"), PKG_ROOT, packageMetadata)
|
|
58
58
|
);
|
|
59
|
-
const INSTALL_KIND = process.env.npm_config_global === "true"
|
|
60
|
-
|| (
|
|
59
|
+
const INSTALL_KIND = SOURCE_CHECKOUT ? "clone" : process.env.npm_config_global === "true"
|
|
60
|
+
|| (/[\\/]node_modules[\\/]/.test(PKG_ROOT) && !/[\\/]_npx[\\/]/.test(PKG_ROOT))
|
|
61
61
|
? "global"
|
|
62
62
|
: "npx";
|
|
63
63
|
|
|
@@ -143,6 +143,7 @@ const HELP = `cozyclay - browser-based 3D staging studio
|
|
|
143
143
|
cclay live status drive a running studio from the terminal
|
|
144
144
|
(cclay live --help lists every verb)
|
|
145
145
|
cclay update install the latest cozyclay globally (npm install -g)
|
|
146
|
+
cclay update --check only compare: exit 0 latest, 10 newer exists, 11 unknown
|
|
146
147
|
npx cozyclay --port 5200 serve on another port
|
|
147
148
|
npx cozyclay --no-open do not open a browser
|
|
148
149
|
npx cozyclay --scene <id> open the studio on a bundled starter scene
|
|
@@ -211,11 +212,12 @@ if (argv[0] === "mcp") {
|
|
|
211
212
|
} else if (argv[0] === "update") {
|
|
212
213
|
// This branch bypasses parseArgs, so anything trailing would be swallowed
|
|
213
214
|
// and `cclay update --help` would perform an unrequested global install.
|
|
214
|
-
if (argv.length > 1) {
|
|
215
|
-
console.error(`cozyclay: update takes no arguments (got ${argv.slice(1).join(" ")})`);
|
|
215
|
+
if (argv.length > 1 && !(argv.length === 2 && argv[1] === "--check")) {
|
|
216
|
+
console.error(`cozyclay: update takes no arguments except --check (got ${argv.slice(1).join(" ")})`);
|
|
216
217
|
process.exit(1);
|
|
217
218
|
}
|
|
218
|
-
|
|
219
|
+
if (argv[1] === "--check") process.exit(await runUpdateCheck({ currentVersion: readVersion(), kind: INSTALL_KIND, stateDir: STATE_DIR }));
|
|
220
|
+
else runUpdate();
|
|
219
221
|
} else if (argv[0] === "telemetry") {
|
|
220
222
|
const action = argv[1] ?? "status";
|
|
221
223
|
const internalAction = action === "internal" ? argv[2] : null;
|
|
@@ -251,21 +253,14 @@ if (opts.version) {
|
|
|
251
253
|
console.log(version);
|
|
252
254
|
process.exit(0);
|
|
253
255
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
officialPackage: OFFICIAL_PACKAGE,
|
|
257
|
-
installKind: INSTALL_KIND,
|
|
258
|
-
});
|
|
256
|
+
const takeRuntime = () => takeRuntimeTelemetryConfig(STATE_FILE, { appVersion: version, officialPackage: OFFICIAL_PACKAGE, installKind: INSTALL_KIND });
|
|
257
|
+
let runtimeTelemetry = takeRuntime();
|
|
259
258
|
if (runtimeTelemetry.firstLaunch && !readTelemetryState(STATE_FILE).firstLaunchHeardFrom) {
|
|
260
259
|
// A copied `--via` command already says where the install came from.
|
|
261
260
|
const heardFrom = opts.via || await askFirstLaunchSource();
|
|
262
261
|
if (heardFrom) {
|
|
263
262
|
setTelemetryFirstLaunchSource(STATE_FILE, heardFrom);
|
|
264
|
-
runtimeTelemetry =
|
|
265
|
-
appVersion: version,
|
|
266
|
-
officialPackage: OFFICIAL_PACKAGE,
|
|
267
|
-
installKind: INSTALL_KIND,
|
|
268
|
-
});
|
|
263
|
+
runtimeTelemetry = takeRuntime();
|
|
269
264
|
}
|
|
270
265
|
}
|
|
271
266
|
const telemetryState = readTelemetryState(STATE_FILE);
|
|
@@ -281,7 +276,7 @@ if (
|
|
|
281
276
|
}
|
|
282
277
|
// Fired before anything blocking and never awaited on the launch path: the
|
|
283
278
|
// notice is worth a line of output, never a second of startup.
|
|
284
|
-
const updateChecksEnabled =
|
|
279
|
+
const updateChecksEnabled = updateChecksOn(opts.updateCheck);
|
|
285
280
|
const updatePending = updateChecksEnabled ? checkForUpdate(version, STATE_DIR) : null;
|
|
286
281
|
if (!existsSync(join(DIST, "app", "index.html"))) {
|
|
287
282
|
console.error("cozyclay: this package is missing its build (dist/app/index.html).");
|
|
@@ -309,6 +304,7 @@ process.on("SIGTERM", () => shutdown(143));
|
|
|
309
304
|
// A source checkout has mcp/node_modules; the package loads the Studio's live
|
|
310
305
|
// tools from the staged MCP runtime that `cclay mcp` also uses (#576).
|
|
311
306
|
const agentHandler = createAgentHandler({ port: () => opts.port, ...(SOURCE_CHECKOUT ? {} : { mcpRuntime: { ensure: ensureMcpRuntime } }) });
|
|
307
|
+
const studioRoutes = createStudioRoutes({ port: opts.port, stateFile: STATE_FILE, takeRuntime, update: { currentVersion: version, stateDir: STATE_DIR, kind: INSTALL_KIND, enabled: updateChecksEnabled, pkgRoot: PKG_ROOT } });
|
|
312
308
|
server = createServer((req, res) => {
|
|
313
309
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
314
310
|
if (/^\/oauth\/(start|status|logout)$/.test(url.pathname)) {
|
|
@@ -328,47 +324,7 @@ server = createServer((req, res) => {
|
|
|
328
324
|
});
|
|
329
325
|
return;
|
|
330
326
|
}
|
|
331
|
-
if (
|
|
332
|
-
if (req.method === "POST") {
|
|
333
|
-
const origin = req.headers.origin;
|
|
334
|
-
const expectedOrigin = `http://127.0.0.1:${opts.port}`;
|
|
335
|
-
if (origin !== expectedOrigin) {
|
|
336
|
-
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
337
|
-
res.end(JSON.stringify({ error: "forbidden origin" }));
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
let body = "";
|
|
341
|
-
req.setEncoding("utf8");
|
|
342
|
-
req.on("data", (chunk) => {
|
|
343
|
-
body += chunk;
|
|
344
|
-
if (body.length > 1024) req.destroy();
|
|
345
|
-
});
|
|
346
|
-
req.on("end", () => {
|
|
347
|
-
try {
|
|
348
|
-
const value = JSON.parse(body);
|
|
349
|
-
if (typeof value.enabled !== "boolean") throw new TypeError("enabled must be boolean");
|
|
350
|
-
setTelemetryEnabled(STATE_FILE, value.enabled);
|
|
351
|
-
runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
|
|
352
|
-
appVersion: version,
|
|
353
|
-
officialPackage: OFFICIAL_PACKAGE,
|
|
354
|
-
installKind: INSTALL_KIND,
|
|
355
|
-
});
|
|
356
|
-
res.writeHead(200, {
|
|
357
|
-
"content-type": "application/json; charset=utf-8",
|
|
358
|
-
"cache-control": "no-store",
|
|
359
|
-
});
|
|
360
|
-
res.end(JSON.stringify(runtimeTelemetry));
|
|
361
|
-
} catch {
|
|
362
|
-
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
363
|
-
res.end(JSON.stringify({ error: "invalid telemetry setting" }));
|
|
364
|
-
}
|
|
365
|
-
});
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
res.writeHead(405, { allow: "POST", "content-type": "application/json; charset=utf-8" });
|
|
369
|
-
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
327
|
+
if (studioRoutes(req, res, url)) return;
|
|
372
328
|
let rel;
|
|
373
329
|
try {
|
|
374
330
|
rel = decodeURIComponent(url.pathname);
|
|
@@ -396,11 +352,7 @@ server = createServer((req, res) => {
|
|
|
396
352
|
return;
|
|
397
353
|
}
|
|
398
354
|
if (target === join(DIST, "app", "index.html")) {
|
|
399
|
-
runtimeTelemetry =
|
|
400
|
-
appVersion: version,
|
|
401
|
-
officialPackage: OFFICIAL_PACKAGE,
|
|
402
|
-
installKind: INSTALL_KIND,
|
|
403
|
-
});
|
|
355
|
+
runtimeTelemetry = takeRuntime();
|
|
404
356
|
// Whether this launch is the newest release, from the registry answer the
|
|
405
357
|
// update check already cached (#466). Never a request of its own.
|
|
406
358
|
const runtime = { ...runtimeTelemetry, updateStatus: updateChecksEnabled ? readUpdateStatus(version, STATE_DIR) : "unknown" };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launcher's own /__cozyclay/* routes, kept out of bin/cozyclay.mjs:
|
|
3
|
+
* the telemetry toggle the Studio's privacy setting posts to, and the update
|
|
4
|
+
* routes behind the Studio's update chip.
|
|
5
|
+
*/
|
|
6
|
+
import { setTelemetryEnabled } from "./telemetry-state.mjs";
|
|
7
|
+
import { lookupLatest } from "./update-check.mjs";
|
|
8
|
+
import { canSelfUpdate, createInstaller } from "./update-install.mjs";
|
|
9
|
+
import { createUpdateRoutes } from "./update-routes.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns a handler that answers a /__cozyclay/* request and returns true, or
|
|
13
|
+
* returns false for any other path. `takeRuntime` re-reads the runtime
|
|
14
|
+
* telemetry config after a change; `update` names this launch's version,
|
|
15
|
+
* state dir, install kind, package root and whether update checks are on.
|
|
16
|
+
*/
|
|
17
|
+
export function createStudioRoutes({ port, stateFile, takeRuntime, update: { currentVersion, stateDir, kind, enabled, pkgRoot } }) {
|
|
18
|
+
const updateRoute = createUpdateRoutes({
|
|
19
|
+
currentVersion,
|
|
20
|
+
kind,
|
|
21
|
+
enabled,
|
|
22
|
+
port,
|
|
23
|
+
checkLatest: () => lookupLatest(currentVersion, stateDir),
|
|
24
|
+
canSelfUpdate: () => canSelfUpdate({ pkgRoot }),
|
|
25
|
+
installer: createInstaller(),
|
|
26
|
+
});
|
|
27
|
+
return function handleStudioRoute(req, res, url) {
|
|
28
|
+
if (url.pathname === "/__cozyclay/update") {
|
|
29
|
+
void updateRoute(req, res);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (url.pathname !== "/__cozyclay/telemetry") return false;
|
|
33
|
+
if (req.method === "POST") {
|
|
34
|
+
const origin = req.headers.origin;
|
|
35
|
+
const expectedOrigin = `http://127.0.0.1:${port}`;
|
|
36
|
+
if (origin !== expectedOrigin) {
|
|
37
|
+
res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
|
|
38
|
+
res.end(JSON.stringify({ error: "forbidden origin" }));
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
let body = "";
|
|
42
|
+
req.setEncoding("utf8");
|
|
43
|
+
req.on("data", (chunk) => {
|
|
44
|
+
body += chunk;
|
|
45
|
+
if (body.length > 1024) req.destroy();
|
|
46
|
+
});
|
|
47
|
+
req.on("end", () => {
|
|
48
|
+
try {
|
|
49
|
+
const value = JSON.parse(body);
|
|
50
|
+
if (typeof value.enabled !== "boolean") throw new TypeError("enabled must be boolean");
|
|
51
|
+
setTelemetryEnabled(stateFile, value.enabled);
|
|
52
|
+
const runtimeTelemetry = takeRuntime();
|
|
53
|
+
res.writeHead(200, {
|
|
54
|
+
"content-type": "application/json; charset=utf-8",
|
|
55
|
+
"cache-control": "no-store",
|
|
56
|
+
});
|
|
57
|
+
res.end(JSON.stringify(runtimeTelemetry));
|
|
58
|
+
} catch {
|
|
59
|
+
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
|
60
|
+
res.end(JSON.stringify({ error: "invalid telemetry setting" }));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
res.writeHead(405, { allow: "POST", "content-type": "application/json; charset=utf-8" });
|
|
66
|
+
res.end(JSON.stringify({ error: "method not allowed" }));
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
69
|
+
}
|
package/bin/update-check.mjs
CHANGED
|
@@ -52,20 +52,32 @@ export function compareVersions(a, b) {
|
|
|
52
52
|
return 0;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// A registry answer whose cache write failed (a read-only home), by cache file.
|
|
56
|
+
// The write is best effort, so a good fetch must not depend on it.
|
|
57
|
+
const fetchedInProcess = new Map();
|
|
58
|
+
|
|
59
|
+
// The newer of the cache file and this process's own last fetch.
|
|
55
60
|
function readCache(cacheFile) {
|
|
61
|
+
let disk = null;
|
|
56
62
|
try {
|
|
57
|
-
|
|
63
|
+
disk = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
58
64
|
} catch {
|
|
59
|
-
|
|
65
|
+
/* missing or unreadable: the in-process answer, if any, stands */
|
|
60
66
|
}
|
|
67
|
+
const memory = fetchedInProcess.get(cacheFile) ?? null;
|
|
68
|
+
if (!disk) return memory;
|
|
69
|
+
return memory && !(memory.checkedAt <= disk.checkedAt) ? memory : disk;
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
function writeCache(cacheFile, cacheDir, latest) {
|
|
73
|
+
const entry = { latest, checkedAt: Date.now() };
|
|
64
74
|
try {
|
|
65
75
|
mkdirSync(cacheDir, { recursive: true });
|
|
66
|
-
writeFileSync(cacheFile, JSON.stringify(
|
|
76
|
+
writeFileSync(cacheFile, JSON.stringify(entry, null, "\t"));
|
|
77
|
+
fetchedInProcess.delete(cacheFile);
|
|
67
78
|
} catch {
|
|
68
|
-
|
|
79
|
+
// A read-only home is not a reason to fail a local dev server, nor to forget the answer.
|
|
80
|
+
fetchedInProcess.set(cacheFile, entry);
|
|
69
81
|
}
|
|
70
82
|
}
|
|
71
83
|
|
|
@@ -134,11 +146,47 @@ export function readUpdateStatus(currentVersion, stateDir) {
|
|
|
134
146
|
}
|
|
135
147
|
}
|
|
136
148
|
|
|
149
|
+
// `--no-update-check`, COZYCLAY_NO_UPDATE_CHECK and CI each turn the notice,
|
|
150
|
+
// the Studio chip and the one-click install off.
|
|
151
|
+
export function updateChecksOn(flag, env = process.env) {
|
|
152
|
+
return Boolean(flag && !env.CI && !env.COZYCLAY_NO_UPDATE_CHECK);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The registry's latest x.y.z with this launch's status: the shape behind
|
|
157
|
+
* GET /__cozyclay/update and `cclay update --check`. The cache rules are
|
|
158
|
+
* checkForUpdate's; `fresh` asks the registry even inside the TTL, and a dead
|
|
159
|
+
* registry still falls back to the cached answer.
|
|
160
|
+
*/
|
|
161
|
+
export async function lookupLatest(currentVersion, stateDir, { fresh = false } = {}) {
|
|
162
|
+
const cacheFile = join(stateDir, "update-check.json");
|
|
163
|
+
if (fresh) {
|
|
164
|
+
const fetched = await fetchLatest();
|
|
165
|
+
if (fetched) writeCache(cacheFile, stateDir, fetched);
|
|
166
|
+
} else {
|
|
167
|
+
await checkForUpdate(currentVersion, stateDir);
|
|
168
|
+
}
|
|
169
|
+
const latest = readCache(cacheFile)?.latest;
|
|
170
|
+
const known = typeof latest === "string" && /^\d+\.\d+\.\d+/.test(latest);
|
|
171
|
+
return { latest: known ? latest : null, status: readUpdateStatus(currentVersion, stateDir) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `cclay update --check`: print, install nothing, and exit 0 when this is the
|
|
176
|
+
* newest release, 10 when a newer one exists, 11 when the registry could not
|
|
177
|
+
* answer and nothing is cached. Scripts and agents branch on the code.
|
|
178
|
+
*/
|
|
179
|
+
export async function runUpdateCheck({ currentVersion, kind, stateDir }) {
|
|
180
|
+
const { latest, status } = await lookupLatest(currentVersion, stateDir, { fresh: true });
|
|
181
|
+
console.log(`current: ${currentVersion}\nlatest: ${latest ?? "unknown"}\nkind: ${kind}\nstatus: ${status}`);
|
|
182
|
+
return { latest: 0, outdated: 10 }[status] ?? 11;
|
|
183
|
+
}
|
|
184
|
+
|
|
137
185
|
// npx/bunx runs from a temp dir, so there is no local install to upgrade in
|
|
138
186
|
// place; `npm install -g` is the one command that is right either way.
|
|
139
187
|
export function runUpdate() {
|
|
140
188
|
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
141
|
-
const child = spawn(npm, ["install", "-g", "cozyclay@latest"], { stdio: "inherit", shell: process.platform === "win32" });
|
|
189
|
+
const child = spawn(npm, ["install", "-g", "cozyclay@latest", "--engine-strict"], { stdio: "inherit", shell: process.platform === "win32" });
|
|
142
190
|
child.on("error", (err) => {
|
|
143
191
|
console.error(`cozyclay: could not run npm: ${err.message}`);
|
|
144
192
|
process.exit(1);
|