cozyclay 3.1.4 → 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 (124) hide show
  1. package/CHANGELOG.md +86 -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 +16 -64
  8. package/bin/studio-routes.mjs +69 -0
  9. package/bin/update-check.mjs +53 -5
  10. package/bin/update-install.mjs +136 -0
  11. package/bin/update-routes.mjs +80 -0
  12. package/dist/ai-camera-control/index.html +1 -1
  13. package/dist/app/index.html +5 -6
  14. package/dist/assets/{TaskBoard-U1Yl-4F1.js → TaskBoard-DwXhsJXz.js} +1 -1
  15. package/dist/assets/app-CEP7jzXB.css +1 -0
  16. package/dist/assets/app-CTKcyPM-.js +5144 -0
  17. package/dist/assets/compile-worker-or7djI2v.js +6 -0
  18. package/dist/assets/{motion3-compile-DDG3wklD.js → motion3-compile-Du7UXrWn.js} +1 -1
  19. package/dist/assets/scene-asset-sync-t45pWCyq.js +13 -0
  20. package/dist/assets/task-board-CkLZGPkv.js +15 -0
  21. package/dist/assets/workflow-DJwOUzqJ.js +179 -0
  22. package/dist/cozyclay-package.json +1 -1
  23. package/dist/greybox-to-video/index.html +3 -3
  24. package/dist/index.html +533 -264
  25. package/dist/media/cozyclay-mark.png +0 -0
  26. package/dist/media/cozyclay-truck-demo-poster.jpg +0 -0
  27. package/dist/media/step-move-poster.jpg +0 -0
  28. package/dist/media/step-shoot-poster.jpg +0 -0
  29. package/dist/media/step-stage-poster.jpg +0 -0
  30. package/dist/media/truck-greybox-poster.jpg +0 -0
  31. package/dist/media/truck-seedance-poster.jpg +0 -0
  32. package/dist/previs-software/index.html +4 -4
  33. package/dist/privacy/index.html +46 -19
  34. package/dist/seedance-camera-control/index.html +3 -3
  35. package/dist/sitemap.xml +6 -6
  36. package/dist/workflow/index.html +3 -4
  37. package/package.json +1 -1
  38. package/src/App.jsx +59 -70
  39. package/src/analytics-controls.js +17 -0
  40. package/src/analytics-friction.js +124 -0
  41. package/src/analytics-strict.js +139 -0
  42. package/src/analytics-values.js +49 -0
  43. package/src/analytics.js +37 -133
  44. package/src/asset-pane.jsx +4 -5
  45. package/src/click-friction.js +101 -0
  46. package/src/command-bus-telemetry.js +155 -0
  47. package/src/command-bus.js +10 -8
  48. package/src/commands/motion3.js +7 -2
  49. package/src/controls.jsx +3 -2
  50. package/src/crash-telemetry.js +45 -0
  51. package/src/domains/cast.js +2 -11
  52. package/src/domains/motion.js +49 -38
  53. package/src/domains/objects.js +3 -0
  54. package/src/domains/scene-undo.js +27 -0
  55. package/src/domains/scenes.js +1 -1
  56. package/src/error-boundary.jsx +2 -0
  57. package/src/export-attempt.js +100 -0
  58. package/src/export-workspace.js +12 -1
  59. package/src/first-success-guide.jsx +11 -7
  60. package/src/guide-telemetry.js +60 -0
  61. package/src/hierarchy-panel.jsx +2 -1
  62. package/src/live-control.js +2 -9
  63. package/src/locale-zh/domains.js +1 -0
  64. package/src/main.jsx +5 -0
  65. package/src/motion/timeline.jsx +9 -5
  66. package/src/motion-draft-telemetry.js +62 -0
  67. package/src/motion3/compiler/channels.js +7 -5
  68. package/src/motion3/compiler/compile.js +6 -2
  69. package/src/motion3/compiler/finger-close.js +84 -7
  70. package/src/motion3/compiler/head-lead.js +70 -0
  71. package/src/motion3/compiler/head-steady.js +1 -1
  72. package/src/motion3/compiler/limbs.js +10 -3
  73. package/src/motion3/compiler/pose.js +1 -1
  74. package/src/motion3/compiler/validate.js +5 -3
  75. package/src/motion3/pipeline.js +4 -0
  76. package/src/motion3/qa/explain.js +2 -1
  77. package/src/motion3/qa/finger-checks.js +80 -0
  78. package/src/motion3/qa/gait-observe.js +88 -0
  79. package/src/motion3/qa/grasp-checks.js +3 -0
  80. package/src/motion3/qa/hints-grasp.js +8 -0
  81. package/src/motion3/qa/index.js +2 -1
  82. package/src/motion3/qa/motion-life.js +26 -3
  83. package/src/motion3/schema/topics.js +1 -1
  84. package/src/playback-telemetry.js +107 -0
  85. package/src/post-turn-edits.js +62 -0
  86. package/src/search-friction.js +49 -0
  87. package/src/semantic-edit.js +19 -6
  88. package/src/shell/BoardSlot.jsx +3 -3
  89. package/src/shell/BottomDock.jsx +1 -1
  90. package/src/shell/DetailsSlot.jsx +1 -1
  91. package/src/shell/MenuBar.jsx +13 -13
  92. package/src/shell/StudioShell.jsx +7 -1
  93. package/src/shell/TopBar.jsx +2 -0
  94. package/src/shell/ViewportToolbar.jsx +2 -2
  95. package/src/shortcut-friction.js +122 -0
  96. package/src/stage-canvas-rigs.jsx +3 -4
  97. package/src/studio-telemetry.js +97 -0
  98. package/src/thrash-friction.js +90 -0
  99. package/src/update/UpdateChip.jsx +86 -0
  100. package/src/update/UpdateDialog.jsx +147 -0
  101. package/src/update/update-client.js +188 -0
  102. package/src/update/update-telemetry.js +78 -0
  103. package/src/update/update.css +94 -0
  104. package/src/use-webgl-available.js +35 -0
  105. package/src/workflow/AgentPanel.jsx +29 -30
  106. package/src/workflow/CozySceneNode.jsx +1 -1
  107. package/src/workflow/LoginPanel.jsx +15 -5
  108. package/src/workflow/agent-client.js +10 -91
  109. package/src/workflow/agent-failure-codes.js +47 -0
  110. package/src/workflow/agent-turn-telemetry.js +222 -0
  111. package/src/workflow/connect-telemetry.js +86 -0
  112. package/src/workflow/prompt-telemetry.js +105 -0
  113. package/tools/dev/pages/privacy.html +44 -17
  114. package/tools/loc-baseline.json +10 -10
  115. package/tools/run-tests.mjs +8 -8
  116. package/dist/assets/app-BdRlMf4J.css +0 -1
  117. package/dist/assets/app-KXdgBC-S.js +0 -5144
  118. package/dist/assets/compile-worker-oy_P9HHh.js +0 -6
  119. package/dist/assets/landing-y3EqDgSZ.js +0 -1
  120. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
  121. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +0 -13
  122. package/dist/assets/task-board-DFfNa0xg.js +0 -15
  123. package/dist/assets/workflow-BMJGd_gZ.js +0 -179
  124. package/dist/media/playground-poster.jpg +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,92 @@
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
+
5
91
  ## 3.1.4 - 2026-10-24
6
92
 
7
93
  Starring CozyClay from the terminal takes one key when the GitHub CLI is signed in.
@@ -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";
@@ -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
- || (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))
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
- runUpdate();
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
- let runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
255
- appVersion: version,
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 = takeRuntimeTelemetryConfig(STATE_FILE, {
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 = Boolean(opts.updateCheck && !process.env.CI && !process.env.COZYCLAY_NO_UPDATE_CHECK);
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 (url.pathname === "/__cozyclay/telemetry") {
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 = takeRuntimeTelemetryConfig(STATE_FILE, {
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
+ }
@@ -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
- return JSON.parse(readFileSync(cacheFile, "utf8"));
63
+ disk = JSON.parse(readFileSync(cacheFile, "utf8"));
58
64
  } catch {
59
- return null;
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({ latest, checkedAt: Date.now() }, null, "\t"));
76
+ writeFileSync(cacheFile, JSON.stringify(entry, null, "\t"));
77
+ fetchedInProcess.delete(cacheFile);
67
78
  } catch {
68
- /* a read-only home is not a reason to fail a local dev server */
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);