cozyclay 3.1.3 → 3.1.5

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/INSTALL_FOR_AGENTS.md +3 -0
  3. package/README.md +5 -0
  4. package/bin/agent/agent-failure-codes.mjs +31 -0
  5. package/bin/agent/agent-routes.mjs +5 -10
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/cozyclay.mjs +18 -112
  8. package/bin/star-prompt.mjs +123 -0
  9. package/bin/studio-routes.mjs +69 -0
  10. package/bin/update-check.mjs +53 -5
  11. package/bin/update-install.mjs +136 -0
  12. package/bin/update-routes.mjs +80 -0
  13. package/dist/ai-camera-control/index.html +1 -1
  14. package/dist/app/index.html +5 -6
  15. package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
  16. package/dist/assets/app-CEP7jzXB.css +1 -0
  17. package/dist/assets/app-CTKcyPM-.js +5144 -0
  18. package/dist/assets/compile-worker-or7djI2v.js +6 -0
  19. package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
  20. package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
  21. package/dist/assets/task-board-CkLZGPkv.js +15 -0
  22. package/dist/assets/workflow-DJwOUzqJ.js +179 -0
  23. package/dist/cozyclay-package.json +1 -1
  24. package/dist/greybox-to-video/index.html +3 -3
  25. package/dist/index.html +533 -264
  26. package/dist/media/cozyclay-mark.png +0 -0
  27. package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
  28. package/dist/media/step-move-poster.jpg +0 -0
  29. package/dist/media/step-shoot-poster.jpg +0 -0
  30. package/dist/media/step-stage-poster.jpg +0 -0
  31. package/dist/media/truck-greybox-poster.jpg +0 -0
  32. package/dist/media/truck-seedance-poster.jpg +0 -0
  33. package/dist/previs-software/index.html +4 -4
  34. package/dist/privacy/index.html +46 -19
  35. package/dist/seedance-camera-control/index.html +3 -3
  36. package/dist/sitemap.xml +6 -6
  37. package/dist/workflow/index.html +3 -4
  38. package/package.json +1 -1
  39. package/src/App.jsx +59 -70
  40. package/src/analytics-controls.js +17 -0
  41. package/src/analytics-friction.js +124 -0
  42. package/src/analytics-strict.js +139 -0
  43. package/src/analytics-values.js +49 -0
  44. package/src/analytics.js +37 -133
  45. package/src/asset-pane.jsx +4 -5
  46. package/src/click-friction.js +101 -0
  47. package/src/command-bus-telemetry.js +155 -0
  48. package/src/command-bus.js +10 -8
  49. package/src/commands/motion3.js +7 -2
  50. package/src/controls.jsx +3 -2
  51. package/src/crash-telemetry.js +45 -0
  52. package/src/domains/cast.js +2 -11
  53. package/src/domains/motion.js +49 -38
  54. package/src/domains/objects.js +3 -0
  55. package/src/domains/scene-undo.js +27 -0
  56. package/src/domains/scenes.js +1 -1
  57. package/src/error-boundary.jsx +2 -0
  58. package/src/export-attempt.js +100 -0
  59. package/src/export-workspace.js +12 -1
  60. package/src/first-success-guide.jsx +11 -7
  61. package/src/guide-telemetry.js +60 -0
  62. package/src/hierarchy-panel.jsx +2 -1
  63. package/src/live-control.js +2 -9
  64. package/src/locale-zh/domains.js +1 -0
  65. package/src/main.jsx +5 -0
  66. package/src/motion/timeline.jsx +9 -5
  67. package/src/motion-draft-telemetry.js +62 -0
  68. package/src/motion3/compiler/channels.js +7 -5
  69. package/src/motion3/compiler/compile.js +6 -2
  70. package/src/motion3/compiler/finger-close.js +84 -7
  71. package/src/motion3/compiler/head-lead.js +70 -0
  72. package/src/motion3/compiler/head-steady.js +1 -1
  73. package/src/motion3/compiler/limbs.js +10 -3
  74. package/src/motion3/compiler/pose.js +1 -1
  75. package/src/motion3/compiler/validate.js +5 -3
  76. package/src/motion3/pipeline.js +4 -0
  77. package/src/motion3/qa/explain.js +2 -1
  78. package/src/motion3/qa/finger-checks.js +80 -0
  79. package/src/motion3/qa/gait-observe.js +88 -0
  80. package/src/motion3/qa/grasp-checks.js +3 -0
  81. package/src/motion3/qa/hints-grasp.js +8 -0
  82. package/src/motion3/qa/index.js +2 -1
  83. package/src/motion3/qa/motion-life.js +26 -3
  84. package/src/motion3/schema/topics.js +1 -1
  85. package/src/playback-telemetry.js +107 -0
  86. package/src/post-turn-edits.js +62 -0
  87. package/src/search-friction.js +49 -0
  88. package/src/semantic-edit.js +19 -6
  89. package/src/shell/BoardSlot.jsx +3 -3
  90. package/src/shell/BottomDock.jsx +1 -1
  91. package/src/shell/DetailsSlot.jsx +1 -1
  92. package/src/shell/MenuBar.jsx +13 -13
  93. package/src/shell/StudioShell.jsx +7 -1
  94. package/src/shell/TopBar.jsx +2 -0
  95. package/src/shell/ViewportToolbar.jsx +2 -2
  96. package/src/shortcut-friction.js +122 -0
  97. package/src/stage-canvas-rigs.jsx +3 -4
  98. package/src/studio-telemetry.js +97 -0
  99. package/src/thrash-friction.js +90 -0
  100. package/src/update/UpdateChip.jsx +86 -0
  101. package/src/update/UpdateDialog.jsx +147 -0
  102. package/src/update/update-client.js +188 -0
  103. package/src/update/update-telemetry.js +78 -0
  104. package/src/update/update.css +94 -0
  105. package/src/use-webgl-available.js +35 -0
  106. package/src/workflow/AgentPanel.jsx +29 -30
  107. package/src/workflow/CozySceneNode.jsx +1 -1
  108. package/src/workflow/LoginPanel.jsx +15 -5
  109. package/src/workflow/agent-client.js +10 -91
  110. package/src/workflow/agent-failure-codes.js +47 -0
  111. package/src/workflow/agent-turn-telemetry.js +222 -0
  112. package/src/workflow/connect-telemetry.js +86 -0
  113. package/src/workflow/prompt-telemetry.js +105 -0
  114. package/tools/dev/pages/privacy.html +44 -17
  115. package/tools/loc-baseline.json +10 -10
  116. package/tools/run-tests.mjs +8 -8
  117. package/dist/assets/app-BdRlMf4J.css +0 -1
  118. package/dist/assets/app-DwETmoQZ.js +0 -5144
  119. package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
  120. package/dist/assets/landing-y3EqDgSZ.js +0 -1
  121. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
  122. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
  123. package/dist/assets/task-board-DFfNa0xg.js +0 -15
  124. package/dist/assets/workflow-BMJGd_gZ.js +0 -179
  125. package/dist/media/playground-poster.jpg +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,104 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.1.5 - 2026-10-25
6
+
7
+ 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.
8
+
9
+ Agent
10
+
11
+ - Agent turns on Claude models no longer fail with a 400 error about a
12
+ deferred placeholder tool.
13
+ - When a turn is already running, the failure chip reads `busy` instead of
14
+ `upstream`. The message text is unchanged.
15
+
16
+ Motion
17
+
18
+ - The head turns first and leads the body into a turn, by about 5 frames at
19
+ 24 fps, the way a person's does. `flow.headLead` sets the frames (0 to
20
+ 12), and `false` turns it off. A keyed head look still wins.
21
+ - Takes that walk report `observations.gait`: cadence, step length, speed,
22
+ stance and pelvis motion, with warnings outside a person's range. It is a
23
+ reading only and never changes the QA result.
24
+ - Grasp QA reads each finger: `FINGER_ROM` (a joint past its range),
25
+ `FINGER_SELF` (two fingers inside each other) and `THUMB_OFF` (a held
26
+ object without the thumb on it).
27
+ - A carried ball keeps the thumb on it. A finger that misses a held object
28
+ turns toward it at the knuckle, and the ring and little fingers curl
29
+ together. Stacked hand presets no longer over-curl.
30
+
31
+ Studio
32
+
33
+ - An npm studio shows a small **Update** chip in the top bar when a newer
34
+ release is out, once per version. A global install whose folder npm can
35
+ write gets **Update now**, which installs the release and then asks you
36
+ to restart `cclay`. Other installs get the command to copy, and npx gets
37
+ "run `npx cozyclay` again". **Update now** waits while an agent turn,
38
+ an export or a recording runs. A failed install names the cause
39
+ (permissions, network, Node version, npm missing) and keeps the command.
40
+ - An IK edit stays with its own character. Selecting another character
41
+ mid-drag no longer poses it, and undo no longer reverts or deletes the
42
+ other character. Switching characters applies a pending IK edit and shows
43
+ a notice.
44
+ - Ctrl+Z after an IK drag puts the pose back, with or without a take
45
+ loaded.
46
+ - On a browser without WebGL, the unavailable notice appears one frame
47
+ after the Studio first draws instead of during it.
48
+
49
+ CLI
50
+
51
+ - `cclay update --check` prints the current and latest version without
52
+ installing, and exits 0 (up to date), 10 (update available) or 11
53
+ (unknown). `cclay update` now refuses a release your Node.js cannot run.
54
+ - A source checkout reports itself as `clone`, and a Windows global install
55
+ is no longer mistaken for npx.
56
+
57
+ Site
58
+
59
+ - A redesigned cozyclay.org: a black first screen with a greybox-to-video
60
+ reel, how-it-works clips from the tutorial, and Try it opens the Studio.
61
+
62
+ Analytics
63
+
64
+ - The anonymous usage analytics now cover where the Studio gets in the way:
65
+ - the agent loop: refused commands, a summary per agent turn, each motion
66
+ draft and its QA result, and each prompt sent, so retry loops and
67
+ hesitation show
68
+ - model connect: the sign-in steps and where they stop, and model changes
69
+ - exports: retries, the stage a failed or stopped export reached, and
70
+ saved files. Storyboard PNG, render pass and OTIO exports are now
71
+ counted.
72
+ - clicks, shortcuts and search: repeated or dead clicks on the main
73
+ controls, shortcuts that did nothing, searches with no result, and
74
+ switching panels or orbiting the camera back and forth
75
+ - playback, boot and crashes: time to the first frame, playback runs and
76
+ late frames, the first-success guide, render crashes and lost WebGL
77
+ - the update notice: when it shows, which button was pressed, and whether
78
+ the install worked
79
+ - one friction summary per session
80
+ - Every value is a closed bucket or a fixed id. No text, names, paths,
81
+ object ids, click coordinates or npm output are sent. Prompt text never
82
+ leaves the browser; only a length bucket and a similarity grade to the
83
+ last prompt do.
84
+ - First-edit events count only your own edits, not the agent's, and report
85
+ `definition_version` 2.
86
+ - Events that nothing sent any more are removed: the old motion-generation
87
+ events and `sample:played`.
88
+ - The privacy page lists every event and property. `cclay telemetry off`
89
+ still turns it all off.
90
+
91
+ ## 3.1.4 - 2026-10-24
92
+
93
+ Starring CozyClay from the terminal takes one key when the GitHub CLI is signed in.
94
+
95
+ CLI
96
+
97
+ - The one-time star question on exit stars NomaDamas/CozyClay through `gh`
98
+ when `gh` is installed and signed in: answer `y` and it is done, with no
99
+ browser. An account that already starred the repo is not asked. Without
100
+ `gh` the question opens the repo page as before, and `--no-star`,
101
+ `COZYCLAY_NO_STAR` and CI still turn it off.
102
+
5
103
  ## 3.1.3 - 2026-10-24
6
104
 
7
105
  More ways to connect a model (Claude Pro/Max, OpenAI-compatible servers, MiniMax M3.1 Flash), a Simplified Chinese UI, and easier project opening.
@@ -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.code;
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: "upstream", message: "busy" }); send({ type: "done" }); res.end(); return true;
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.code;
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) emitFrame({ type: "error", ...errorInfo(error, quota) });
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
- if (tools.length && !marked(tools.at(-1)) && model?.compat?.supportsCacheControlOnTools !== false && count < 4) {
224
- next = { ...next, tools: [...tools.slice(0, -1), { ...tools.at(-1), cache_control: cacheControl }] };
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: "upstream", message: "busy" };
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";
@@ -23,7 +22,9 @@ import { createInterface } from "node:readline";
23
22
  import { fileURLToPath } from "node:url";
24
23
  import { ensureMcpRuntime, runMcp } from "./mcp-runtime.mjs";
25
24
  import { openBrowser } from "./open-browser.mjs";
26
- import { checkForUpdate, readUpdateStatus, runUpdate } from "./update-check.mjs";
25
+ import { maybeAskForStar } from "./star-prompt.mjs";
26
+ import { checkForUpdate, readUpdateStatus, runUpdate, runUpdateCheck, updateChecksOn } from "./update-check.mjs";
27
+ import { createStudioRoutes } from "./studio-routes.mjs";
27
28
  import { handleOAuthRequest } from "./codex-auth.mjs";
28
29
  import { createAgentHandler } from "./agent/agent-routes.mjs";
29
30
  import { liveHubPort } from "./live-endpoint.mjs";
@@ -55,8 +56,8 @@ const OFFICIAL_PACKAGE = (
55
56
  && !SOURCE_CHECKOUT
56
57
  && verifyPackageMarker(join(DIST, "cozyclay-package.json"), PKG_ROOT, packageMetadata)
57
58
  );
58
- const INSTALL_KIND = process.env.npm_config_global === "true"
59
- || (PKG_ROOT.includes("/node_modules/") && !PKG_ROOT.includes("/_npx/"))
59
+ const INSTALL_KIND = SOURCE_CHECKOUT ? "clone" : process.env.npm_config_global === "true"
60
+ || (/[\\/]node_modules[\\/]/.test(PKG_ROOT) && !/[\\/]_npx[\\/]/.test(PKG_ROOT))
60
61
  ? "global"
61
62
  : "npx";
62
63
 
@@ -120,12 +121,8 @@ function parseArgs(argv) {
120
121
  return opts;
121
122
  }
122
123
 
123
- const REPO_URL = "https://github.com/NomaDamas/CozyClay";
124
124
  const STATE_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cozyclay");
125
125
  const STATE_FILE = join(STATE_DIR, "state.json");
126
- // Only worth asking someone who actually used the thing. A prompt three
127
- // seconds in is a popup; a prompt after a real session is a question.
128
- const STAR_AFTER_MS = Number(process.env.COZYCLAY_STAR_AFTER_MS ?? 60_000);
129
126
 
130
127
  async function askFirstLaunchSource() {
131
128
  if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
@@ -146,6 +143,7 @@ const HELP = `cozyclay - browser-based 3D staging studio
146
143
  cclay live status drive a running studio from the terminal
147
144
  (cclay live --help lists every verb)
148
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
149
147
  npx cozyclay --port 5200 serve on another port
150
148
  npx cozyclay --no-open do not open a browser
151
149
  npx cozyclay --scene <id> open the studio on a bundled starter scene
@@ -197,49 +195,6 @@ function writeState(patch) {
197
195
  }
198
196
  }
199
197
 
200
- async function starCount() {
201
- try {
202
- const res = await fetch("https://api.github.com/repos/NomaDamas/CozyClay", {
203
- headers: { accept: "application/vnd.github+json" },
204
- signal: AbortSignal.timeout(2500),
205
- });
206
- if (!res.ok) return null;
207
- const body = await res.json();
208
- return Number.isInteger(body?.stargazers_count) ? body.stargazers_count : null;
209
- } catch {
210
- return null;
211
- }
212
- }
213
-
214
- // Asked once, on the way out, and never again on this machine. A CLI that
215
- // nags on every run is worse than one that never asks.
216
- async function maybeAskForStar(startedAt, opts) {
217
- if (!opts.star) return;
218
- if (process.env.CI || process.env.COZYCLAY_NO_STAR) return;
219
- if (!process.stdin.isTTY || !process.stdout.isTTY) return;
220
- if (Date.now() - startedAt < STAR_AFTER_MS) return;
221
- if (readState().starPromptedAt) return;
222
-
223
- writeState({ starPromptedAt: new Date().toISOString() });
224
- const stars = await starCount();
225
- const tally = stars === null ? "" : ` (${stars} so far)`;
226
- const rl = createInterface({ input: process.stdin, output: process.stdout });
227
- const answer = await new Promise((done) => {
228
- const timer = setTimeout(() => done(""), 12_000);
229
- rl.question(`\nWas CozyClay any good? A star helps people find it${tally}. Open GitHub? [Y/n] `, (a) => {
230
- clearTimeout(timer);
231
- done(a);
232
- });
233
- });
234
- rl.close();
235
- if (/^(y|yes|)$/i.test(answer.trim())) {
236
- openBrowser(REPO_URL);
237
- console.log("Thanks. Opening the repo.");
238
- } else {
239
- console.log(`Fair enough. ${REPO_URL} if you change your mind.`);
240
- }
241
- }
242
-
243
198
  // Read on every launch now, so it must not be able to take the launcher down:
244
199
  // the version is cosmetic here, and "0.0.0" just suppresses a bogus notice.
245
200
  function readVersion() {
@@ -257,11 +212,12 @@ if (argv[0] === "mcp") {
257
212
  } else if (argv[0] === "update") {
258
213
  // This branch bypasses parseArgs, so anything trailing would be swallowed
259
214
  // and `cclay update --help` would perform an unrequested global install.
260
- if (argv.length > 1) {
261
- 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(" ")})`);
262
217
  process.exit(1);
263
218
  }
264
- runUpdate();
219
+ if (argv[1] === "--check") process.exit(await runUpdateCheck({ currentVersion: readVersion(), kind: INSTALL_KIND, stateDir: STATE_DIR }));
220
+ else runUpdate();
265
221
  } else if (argv[0] === "telemetry") {
266
222
  const action = argv[1] ?? "status";
267
223
  const internalAction = action === "internal" ? argv[2] : null;
@@ -297,21 +253,14 @@ if (opts.version) {
297
253
  console.log(version);
298
254
  process.exit(0);
299
255
  }
300
- let runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
301
- appVersion: version,
302
- officialPackage: OFFICIAL_PACKAGE,
303
- installKind: INSTALL_KIND,
304
- });
256
+ const takeRuntime = () => takeRuntimeTelemetryConfig(STATE_FILE, { appVersion: version, officialPackage: OFFICIAL_PACKAGE, installKind: INSTALL_KIND });
257
+ let runtimeTelemetry = takeRuntime();
305
258
  if (runtimeTelemetry.firstLaunch && !readTelemetryState(STATE_FILE).firstLaunchHeardFrom) {
306
259
  // A copied `--via` command already says where the install came from.
307
260
  const heardFrom = opts.via || await askFirstLaunchSource();
308
261
  if (heardFrom) {
309
262
  setTelemetryFirstLaunchSource(STATE_FILE, heardFrom);
310
- runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
311
- appVersion: version,
312
- officialPackage: OFFICIAL_PACKAGE,
313
- installKind: INSTALL_KIND,
314
- });
263
+ runtimeTelemetry = takeRuntime();
315
264
  }
316
265
  }
317
266
  const telemetryState = readTelemetryState(STATE_FILE);
@@ -327,7 +276,7 @@ if (
327
276
  }
328
277
  // Fired before anything blocking and never awaited on the launch path: the
329
278
  // notice is worth a line of output, never a second of startup.
330
- const updateChecksEnabled = Boolean(opts.updateCheck && !process.env.CI && !process.env.COZYCLAY_NO_UPDATE_CHECK);
279
+ const updateChecksEnabled = updateChecksOn(opts.updateCheck);
331
280
  const updatePending = updateChecksEnabled ? checkForUpdate(version, STATE_DIR) : null;
332
281
  if (!existsSync(join(DIST, "app", "index.html"))) {
333
282
  console.error("cozyclay: this package is missing its build (dist/app/index.html).");
@@ -343,7 +292,7 @@ async function shutdown(exitCode = 0) {
343
292
  shuttingDown = true;
344
293
  if (server?.listening) await new Promise((resolvePromise) => server.close(() => resolvePromise()));
345
294
  try {
346
- await maybeAskForStar(startedAt, opts);
295
+ await maybeAskForStar(startedAt, opts, { readState, writeState });
347
296
  } catch {
348
297
  /* never let the goodbye prompt hold the process hostage */
349
298
  }
@@ -355,6 +304,7 @@ process.on("SIGTERM", () => shutdown(143));
355
304
  // A source checkout has mcp/node_modules; the package loads the Studio's live
356
305
  // tools from the staged MCP runtime that `cclay mcp` also uses (#576).
357
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 } });
358
308
  server = createServer((req, res) => {
359
309
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
360
310
  if (/^\/oauth\/(start|status|logout)$/.test(url.pathname)) {
@@ -374,47 +324,7 @@ server = createServer((req, res) => {
374
324
  });
375
325
  return;
376
326
  }
377
- if (url.pathname === "/__cozyclay/telemetry") {
378
- if (req.method === "POST") {
379
- const origin = req.headers.origin;
380
- const expectedOrigin = `http://127.0.0.1:${opts.port}`;
381
- if (origin !== expectedOrigin) {
382
- res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
383
- res.end(JSON.stringify({ error: "forbidden origin" }));
384
- return;
385
- }
386
- let body = "";
387
- req.setEncoding("utf8");
388
- req.on("data", (chunk) => {
389
- body += chunk;
390
- if (body.length > 1024) req.destroy();
391
- });
392
- req.on("end", () => {
393
- try {
394
- const value = JSON.parse(body);
395
- if (typeof value.enabled !== "boolean") throw new TypeError("enabled must be boolean");
396
- setTelemetryEnabled(STATE_FILE, value.enabled);
397
- runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
398
- appVersion: version,
399
- officialPackage: OFFICIAL_PACKAGE,
400
- installKind: INSTALL_KIND,
401
- });
402
- res.writeHead(200, {
403
- "content-type": "application/json; charset=utf-8",
404
- "cache-control": "no-store",
405
- });
406
- res.end(JSON.stringify(runtimeTelemetry));
407
- } catch {
408
- res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
409
- res.end(JSON.stringify({ error: "invalid telemetry setting" }));
410
- }
411
- });
412
- return;
413
- }
414
- res.writeHead(405, { allow: "POST", "content-type": "application/json; charset=utf-8" });
415
- res.end(JSON.stringify({ error: "method not allowed" }));
416
- return;
417
- }
327
+ if (studioRoutes(req, res, url)) return;
418
328
  let rel;
419
329
  try {
420
330
  rel = decodeURIComponent(url.pathname);
@@ -442,11 +352,7 @@ server = createServer((req, res) => {
442
352
  return;
443
353
  }
444
354
  if (target === join(DIST, "app", "index.html")) {
445
- runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
446
- appVersion: version,
447
- officialPackage: OFFICIAL_PACKAGE,
448
- installKind: INSTALL_KIND,
449
- });
355
+ runtimeTelemetry = takeRuntime();
450
356
  // Whether this launch is the newest release, from the registry answer the
451
357
  // update check already cached (#466). Never a request of its own.
452
358
  const runtime = { ...runtimeTelemetry, updateStatus: updateChecksEnabled ? readUpdateStatus(version, STATE_DIR) : "unknown" };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The one-time "star the repo" question on the way out of `npx cozyclay` (#900).
3
+ *
4
+ * With the GitHub CLI installed and signed in, a "y" stars the repo through `gh` on
5
+ * the spot: no browser hop, which is where most people who meant to star it got lost.
6
+ * Without gh, a "yes" opens the repo page as before. Asked once per machine, after a
7
+ * real session, never in CI or without a terminal, and never to an account that has
8
+ * already starred it. Every failure here is silent: this runs while the launcher exits.
9
+ */
10
+ import { execFile } from "node:child_process";
11
+ import { createInterface } from "node:readline";
12
+ import { openBrowser } from "./open-browser.mjs";
13
+
14
+ const REPO = "NomaDamas/CozyClay";
15
+ const REPO_URL = `https://github.com/${REPO}`;
16
+ const STARRED_PATH = `/user/starred/${REPO}`;
17
+ const ANSWER_TIMEOUT_MS = 12_000;
18
+ // Only worth asking someone who actually used the thing. A prompt three
19
+ // seconds in is a popup; a prompt after a real session is a question.
20
+ const STAR_AFTER_MS = Number(process.env.COZYCLAY_STAR_AFTER_MS ?? 60_000);
21
+
22
+ /** Runs gh and resolves true on exit 0. No gh on PATH, a timeout or an error status is false. */
23
+ function runGh(args) {
24
+ return new Promise((resolve) => {
25
+ try {
26
+ execFile("gh", args, {
27
+ timeout: 4000,
28
+ windowsHide: true,
29
+ env: { ...process.env, GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" },
30
+ }, (error) => resolve(!error));
31
+ } catch {
32
+ resolve(false);
33
+ }
34
+ });
35
+ }
36
+
37
+ async function starCount() {
38
+ try {
39
+ const res = await fetch(`https://api.github.com/repos/${REPO}`, {
40
+ headers: { accept: "application/vnd.github+json" },
41
+ signal: AbortSignal.timeout(2500),
42
+ });
43
+ if (!res.ok) return null;
44
+ const body = await res.json();
45
+ return Number.isInteger(body?.stargazers_count) ? body.stargazers_count : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function askLine(question) {
52
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
53
+ return new Promise((done) => {
54
+ const timer = setTimeout(() => {
55
+ rl.close();
56
+ done("");
57
+ }, ANSWER_TIMEOUT_MS);
58
+ rl.question(question, (answer) => {
59
+ clearTimeout(timer);
60
+ rl.close();
61
+ done(answer);
62
+ });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Asked once, on the way out, and never again on this machine. A CLI that nags on
68
+ * every run is worse than one that never asks. Everything after `state` is injectable
69
+ * so the test can drive it without a terminal, gh, or the network.
70
+ */
71
+ export async function maybeAskForStar(startedAt, opts, {
72
+ readState,
73
+ writeState,
74
+ env = process.env,
75
+ interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
76
+ now = Date.now,
77
+ minSessionMs = STAR_AFTER_MS,
78
+ gh = runGh,
79
+ ask = askLine,
80
+ stars = starCount,
81
+ open = openBrowser,
82
+ log = console.log,
83
+ }) {
84
+ if (!opts.star) return;
85
+ if (env.CI || env.COZYCLAY_NO_STAR) return;
86
+ if (!interactive) return;
87
+ if (now() - startedAt < minSessionMs) return;
88
+ if (readState().starPromptedAt) return;
89
+
90
+ const promptedAt = new Date(now()).toISOString();
91
+ const viaGh = await gh(["auth", "status"]);
92
+ // GET /user/starred/<repo> answers 204 for a starred repo and 404 otherwise.
93
+ if (viaGh && await gh(["api", STARRED_PATH, "--silent"])) {
94
+ writeState({ starPromptedAt: promptedAt });
95
+ return;
96
+ }
97
+ writeState({ starPromptedAt: promptedAt });
98
+ const count = await stars();
99
+ const tally = count === null ? "" : ` (${count} so far)`;
100
+
101
+ if (viaGh) {
102
+ const answer = await ask(`\nWas CozyClay any good? A star helps people find it${tally}. Star ${REPO} with your gh account? [y/N] `);
103
+ if (!/^y(es)?$/i.test(answer.trim())) {
104
+ log(`No problem. ${REPO_URL} if you change your mind.`);
105
+ return;
106
+ }
107
+ if (await gh(["api", "--method", "PUT", STARRED_PATH, "--silent"])) {
108
+ log(`Starred ${REPO}. Thank you!`);
109
+ return;
110
+ }
111
+ log("gh could not star it, so opening the repo instead.");
112
+ open(REPO_URL);
113
+ return;
114
+ }
115
+
116
+ const answer = await ask(`\nWas CozyClay any good? A star helps people find it${tally}. Open GitHub? [Y/n] `);
117
+ if (/^(y|yes|)$/i.test(answer.trim())) {
118
+ open(REPO_URL);
119
+ log("Thanks. Opening the repo.");
120
+ } else {
121
+ log(`Fair enough. ${REPO_URL} if you change your mind.`);
122
+ }
123
+ }
@@ -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
+ }