cozyclay 3.1.6 → 3.2.1

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 (224) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +25 -17
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/openai-compatible-provider.mjs +24 -1
  10. package/bin/agent/studio-prompt.mjs +2 -2
  11. package/bin/agent/supervisor-followup.mjs +34 -0
  12. package/bin/agent/supervisor.mjs +6 -6
  13. package/bin/cozyclay.mjs +23 -60
  14. package/bin/star-prompt.mjs +15 -14
  15. package/bin/studio-routes.mjs +6 -3
  16. package/bin/telemetry-state.mjs +5 -7
  17. package/bin/update-check.mjs +61 -7
  18. package/bin/update-install.mjs +16 -3
  19. package/bin/update-restart.mjs +95 -0
  20. package/bin/update-routes.mjs +25 -3
  21. package/dist/app/index.html +13 -7
  22. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  23. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  24. package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
  25. package/dist/assets/app-DJt06Oyb.js +5144 -0
  26. package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
  27. package/dist/assets/landing-2Fy7DU41.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  30. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  31. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  32. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  33. package/dist/cozyclay-package.json +1 -1
  34. package/dist/index.html +4 -1
  35. package/dist/privacy/index.html +9 -6
  36. package/dist/sitemap.xml +2 -2
  37. package/dist/workflow/index.html +5 -4
  38. package/mcp/README.md +1 -1
  39. package/mcp/live-hub.mjs +4 -4
  40. package/mcp/tool-handlers.mjs +14 -16
  41. package/package.json +3 -1
  42. package/src/App.jsx +42 -75
  43. package/src/analytics-controls.js +25 -3
  44. package/src/analytics-strict.js +16 -4
  45. package/src/analytics.js +32 -26
  46. package/src/app-version.js +13 -0
  47. package/src/asset-drop.js +39 -0
  48. package/src/asset-pane.css +34 -0
  49. package/src/asset-pane.jsx +4 -35
  50. package/src/bus-tasks.js +1 -1
  51. package/src/character-kind.js +8 -6
  52. package/src/click-friction.js +5 -4
  53. package/src/command-bus-telemetry.js +8 -7
  54. package/src/command-bus.js +2 -16
  55. package/src/commands/cast.js +2 -1
  56. package/src/commands/index.js +2 -1
  57. package/src/commands/motion3.js +3 -1
  58. package/src/commands/objects.js +2 -37
  59. package/src/commands/physics.js +21 -0
  60. package/src/commands/project.js +3 -3
  61. package/src/commands/sculpt-compare.js +53 -0
  62. package/src/content-tile.jsx +36 -0
  63. package/src/domains/cast.js +7 -6
  64. package/src/domains/motion.js +13 -14
  65. package/src/domains/scenes.js +41 -31
  66. package/src/execution-telemetry.js +7 -3
  67. package/src/export-menu-telemetry.js +26 -0
  68. package/src/finger-handles.jsx +128 -0
  69. package/src/friction-hint.js +82 -0
  70. package/src/live-control.js +2 -1
  71. package/src/locale-zh/panels.js +20 -0
  72. package/src/locale-zh/shell.js +4 -0
  73. package/src/locale-zh/workspace.js +6 -0
  74. package/src/motion/center-of-mass.js +5 -23
  75. package/src/motion/contact-project.js +143 -0
  76. package/src/motion/find-bone.js +13 -0
  77. package/src/motion/fingers.js +170 -0
  78. package/src/motion/fingertip-pick.js +21 -0
  79. package/src/motion/fix-collisions.js +1 -1
  80. package/src/motion/ik-drag.js +93 -1
  81. package/src/motion/ik-exposure.js +48 -0
  82. package/src/motion/ik.js +4 -11
  83. package/src/motion/timeline.css +12 -0
  84. package/src/motion/timeline.jsx +2 -2
  85. package/src/motion3/compile-job.js +3 -2
  86. package/src/motion3/compiler/compile.js +3 -3
  87. package/src/motion3/compiler/head-lead.js +4 -2
  88. package/src/motion3/compiler/spring.js +95 -0
  89. package/src/motion3/compiler/validate-objects.js +18 -2
  90. package/src/motion3/compiler/validate-parts.js +1 -0
  91. package/src/motion3/compiler/vocab.js +1 -1
  92. package/src/motion3/fix/index.js +8 -4
  93. package/src/motion3/fix/person.js +110 -0
  94. package/src/motion3/free-motion.js +49 -11
  95. package/src/motion3/hybrid-hint.js +19 -0
  96. package/src/motion3/load.js +19 -12
  97. package/src/motion3/object-pose.js +44 -2
  98. package/src/motion3/people.js +16 -1
  99. package/src/motion3/physics-frames.js +72 -0
  100. package/src/motion3/physics-trail.js +45 -0
  101. package/src/motion3/physics-view.js +79 -0
  102. package/src/motion3/pipeline.js +5 -1
  103. package/src/motion3/qa/ballistic.js +67 -0
  104. package/src/motion3/qa/com.js +140 -0
  105. package/src/motion3/qa/ground.js +10 -3
  106. package/src/motion3/qa/hints-contact.js +21 -7
  107. package/src/motion3/qa/hints-motion.js +38 -1
  108. package/src/motion3/qa/key-checks.js +5 -4
  109. package/src/motion3/qa/patch.js +32 -4
  110. package/src/motion3/qa/people-checks.js +1 -1
  111. package/src/motion3/qa/spring-pivot.js +23 -0
  112. package/src/motion3/qa/style.js +13 -30
  113. package/src/motion3/qa/verdict.js +7 -2
  114. package/src/motion3/repair.js +28 -8
  115. package/src/motion3/schema/fields.js +4 -2
  116. package/src/motion3/schema/topics.js +15 -11
  117. package/src/motion3/schema/types.js +13 -9
  118. package/src/motion3/skills/catch.js +2 -1
  119. package/src/panels/FingerPanel.jsx +38 -0
  120. package/src/panels/RigControlPanel.jsx +9 -1
  121. package/src/panels/SculptPanel.jsx +12 -12
  122. package/src/panels/SculptPartTree.jsx +46 -0
  123. package/src/panels/SubjectsPanel.jsx +1 -1
  124. package/src/panels/sculpt.css +11 -0
  125. package/src/physics-overlay-settings.js +41 -0
  126. package/src/physics-overlay.js +152 -0
  127. package/src/physics-overlay.jsx +54 -0
  128. package/src/physics-toggle.jsx +20 -0
  129. package/src/pose-fade.js +7 -3
  130. package/src/posestudio.jsx +15 -28
  131. package/src/project-browser.css +6 -0
  132. package/src/project-browser.jsx +9 -18
  133. package/src/project-start-choices.jsx +45 -0
  134. package/src/project.js +16 -12
  135. package/src/proxy-figure-shape.js +11 -0
  136. package/src/proxy-figure.jsx +25 -18
  137. package/src/scene-assets.js +17 -13
  138. package/src/scenes.js +22 -11
  139. package/src/sculpt-blueprint.js +56 -0
  140. package/src/sculpt-mode.js +5 -0
  141. package/src/sculpt-part-edit.js +115 -0
  142. package/src/sculpt-part-gizmo.jsx +159 -0
  143. package/src/sculpt-part-outline.jsx +72 -0
  144. package/src/sculpt-pick.js +53 -0
  145. package/src/sculpt-run-text.js +51 -0
  146. package/src/sculpt-run.js +10 -46
  147. package/src/sculpt-shelf-file.js +92 -0
  148. package/src/sculpt-shelf-tiles.jsx +69 -0
  149. package/src/sculpt-shelf.js +108 -0
  150. package/src/sculpt-stage.jsx +39 -2
  151. package/src/sculpt-thumb.js +72 -0
  152. package/src/sculpt-views.js +75 -0
  153. package/src/semantic-edit.js +0 -87
  154. package/src/settings-menu.jsx +2 -2
  155. package/src/shell/DetailsSlot.jsx +5 -6
  156. package/src/shell/LibrarySlot.jsx +8 -10
  157. package/src/shell/MenuBar.jsx +6 -6
  158. package/src/shell/PreferencesDialog.jsx +2 -3
  159. package/src/shell/ViewportToolbar.jsx +15 -18
  160. package/src/shell/add-character-entries.js +13 -0
  161. package/src/shell/pose-controls.js +32 -0
  162. package/src/shell/preference-pages.jsx +2 -11
  163. package/src/shell/storyboard-caption.js +21 -0
  164. package/src/shell/studio-shell-context.js +1 -0
  165. package/src/shortcut-friction.js +2 -1
  166. package/src/site/landing-analytics.js +28 -0
  167. package/src/studio-actions.js +3 -2
  168. package/src/studio-agent-protocol.js +4 -4
  169. package/src/studio-app-binding.js +4 -3
  170. package/src/update/UpdateChip.jsx +21 -2
  171. package/src/update/UpdateDialog.jsx +26 -6
  172. package/src/update/update-client.js +123 -17
  173. package/src/update/update-telemetry.js +15 -3
  174. package/src/use-sculpt-mode.js +62 -8
  175. package/src/workflow/agent-failure-codes.js +28 -9
  176. package/src/workflow/agent-turn-telemetry.js +1 -1
  177. package/src/workflow/task-board.js +2 -2
  178. package/tools/dev/page-shell.py +1 -1
  179. package/tools/dev/pages/greybox-to-video.html +21 -2
  180. package/tools/dev/pages/previs-software.html +3 -3
  181. package/tools/dev/pages/privacy.html +9 -6
  182. package/tools/dev/pages/seedance-camera-control.html +2 -2
  183. package/tools/loc-baseline.json +10 -10
  184. package/tools/run-tests.mjs +53 -84
  185. package/tools/select-tests.mjs +119 -0
  186. package/tools/test-durations.json +455 -0
  187. package/tools/test-graph.mjs +181 -0
  188. package/tools/test-shards.mjs +78 -0
  189. package/tools/tutorial/README.md +74 -0
  190. package/tools/tutorial/assemble.mjs +43 -0
  191. package/tools/tutorial/captions.mjs +43 -0
  192. package/tools/tutorial/cdp.mjs +55 -0
  193. package/tools/tutorial/cursor.mjs +159 -0
  194. package/tools/tutorial/episode.mjs +173 -0
  195. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  196. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  197. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  198. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  199. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  200. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  201. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  202. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  203. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  204. package/tools/tutorial/episodes/cast.mjs +12 -0
  205. package/tools/tutorial/episodes/first-video.mjs +121 -0
  206. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  207. package/tools/tutorial/launch.mjs +63 -0
  208. package/tools/tutorial/mux.mjs +61 -0
  209. package/tools/tutorial/overlay.mjs +142 -0
  210. package/tools/tutorial/record.mjs +156 -0
  211. package/tools/tutorial/scenes/terminal.html +63 -0
  212. package/tools/tutorial/steps.mjs +111 -0
  213. package/tools/tutorial/terminal.mjs +43 -0
  214. package/tools/tutorial/voice.mjs +49 -0
  215. package/tools/update-test-durations.mjs +42 -0
  216. package/tools/verify-package-tarball.mjs +18 -12
  217. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  218. package/dist/assets/app-BLS1Zaru.js +0 -5144
  219. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  220. package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
  221. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  222. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  223. package/dist/assets/task-board-CkLZGPkv.js +0 -15
  224. package/dist/assets/workflow-DJwOUzqJ.js +0 -179
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ // Records one tutorial episode: optional voice clips first (voice.mjs), then
3
+ // one continuous capture over CDP (steps.mjs runs each step, the page carries
4
+ // the cursor and teaching overlays), then captions in a band under the frame
5
+ // and the mux (mux.mjs). A chaptered episode is also cut into one file per
6
+ // chapter. Run it through tools/tutorial/launch.mjs (npm run tutorial:record
7
+ // -- <episode>) or inside tools/qa-browser.mjs with QA_URL pointing at a dev
8
+ // server. See tools/tutorial/README.md.
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join, resolve } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import { encode, findFfmpeg } from "./assemble.mjs";
15
+ import { renderCaptions } from "./captions.mjs";
16
+ import { connectPage } from "./cdp.mjs";
17
+ import { createInput } from "./cursor.mjs";
18
+ import { DEFAULT_WPS, actionOf, chapterFileName, normalizeEpisode, stepHoldMs, toSrt, validateEpisode } from "./episode.mjs";
19
+ import { bandHeightFor, chapterCutArgs, muxArgs } from "./mux.mjs";
20
+ import { TEACHING_OVERLAY } from "./overlay.mjs";
21
+ import { localeScript, openStudio, runStep } from "./steps.mjs";
22
+ import { TERMINAL_URL } from "./terminal.mjs";
23
+ import { prepareVoice } from "./voice.mjs";
24
+
25
+ const sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, Math.max(0, ms)));
26
+
27
+ function parseArgs(argv) {
28
+ const options = { episode: null, locale: "en", size: "1600x1000", "wps-ko": DEFAULT_WPS.ko, "wps-en": DEFAULT_WPS.en, voice: "off", fps: 30, out: "dist/tutorial" };
29
+ for (let index = 0; index < argv.length; index += 1) {
30
+ const flag = argv[index];
31
+ if (flag.startsWith("--")) options[flag.slice(2)] = argv[(index += 1)];
32
+ else options.episode = flag;
33
+ }
34
+ const [width, height] = String(options.size).split("x").map(Number);
35
+ if (!options.episode) throw new Error("usage: record.mjs <episode> [--locale en|ko] [--size 1600x1000|1920x1080] [--voice off|say] [--wps-ko 1.6] [--wps-en 2.5] [--fps 30] [--out dist/tutorial]");
36
+ if (!["en", "ko"].includes(options.locale)) throw new Error(`--locale must be en or ko (got ${options.locale})`);
37
+ if (!(width > 0 && height > 0)) throw new Error(`--size must look like 1600x1000 (got ${options.size})`);
38
+ if (!["off", "say"].includes(options.voice)) throw new Error(`--voice must be off or say (got ${options.voice})`);
39
+ return { ...options, width, height, wps: { ko: Number(options["wps-ko"]), en: Number(options["wps-en"]) }, fps: Number(options.fps) };
40
+ }
41
+
42
+ async function loadEpisode(name) {
43
+ const base = existsSync(name) ? resolve(name) : resolve(import.meta.dirname, "episodes", name);
44
+ const file = [base, `${base}.mjs`, `${base}.json`].find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
45
+ if (!file) throw new Error(`no episode ${name} (looked in tools/tutorial/episodes/)`);
46
+ const episode = file.endsWith(".json") ? JSON.parse(readFileSync(file, "utf8")) : (await import(pathToFileURL(file).href)).default;
47
+ const errors = validateEpisode(episode);
48
+ if (errors.length) throw new Error(`episode ${name} is invalid:\n ${errors.join("\n ")}`);
49
+ return normalizeEpisode(episode);
50
+ }
51
+
52
+ const run = (ffmpeg, args, what) => {
53
+ const result = spawnSync(ffmpeg, args, { encoding: "utf8" });
54
+ if (result.status !== 0) throw new Error(`ffmpeg ${what} failed (exit ${result.status}): ${result.stderr.trim()}`);
55
+ };
56
+
57
+ const options = parseArgs(process.argv.slice(2));
58
+ const ffmpeg = findFfmpeg();
59
+ console.log(`ffmpeg: ${ffmpeg.path} (${ffmpeg.version})`);
60
+ const episode = await loadEpisode(options.episode);
61
+ const steps = episode.chapters.flatMap((chapter) => chapter.steps);
62
+ const episodeName = options.episode.replace(/^.*\//, "").replace(/\.(mjs|json)$/, "");
63
+ const outDir = episode.chaptered ? join(options.out, episodeName) : options.out;
64
+ const fullPath = episode.chaptered ? join(outDir, `${episodeName}.${options.locale}.mp4`) : join(outDir, `${options.locale === "en" ? episodeName : `${episodeName}-${options.locale}`}.mp4`);
65
+ const frameDir = mkdtempSync(join(tmpdir(), "cozyclay-tutorial-"));
66
+ // Voice first: each cue's hold comes from its clip, so the clips exist before the capture.
67
+ const clips = options.voice === "say" ? prepareVoice({ steps, language: options.locale, voiceDir: resolve(import.meta.dirname, "voice", episodeName), workDir: frameDir, ffprobe: ffmpeg.path.replace(/ffmpeg$/, "ffprobe") }) : new Map();
68
+ console.log(clips.size ? `voice (${options.locale}): ${clips.size} clips` : `voice (${options.locale}): off, captions only`);
69
+ const url = process.env.QA_URL || "http://127.0.0.1:5594/app/";
70
+ const cdp = await connectPage(Number(process.env.CDP_PORT || 9222));
71
+ try {
72
+ // An export step's download lands in the scratch dir, never in the user's Downloads.
73
+ await cdp.send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: join(frameDir, "downloads") });
74
+ await cdp.send("Emulation.setDeviceMetricsOverride", { width: options.width, height: options.height, deviceScaleFactor: 1, mobile: false });
75
+ await cdp.send("Page.enable");
76
+ const input = createInput(cdp, { mac: (await cdp.evaluate("navigator.platform")).startsWith("Mac") });
77
+ await input.install(TEACHING_OVERLAY, localeScript(new URL(url).origin, options.locale));
78
+ // An episode that opens the Studio itself starts on its first scene; otherwise on the chooser.
79
+ const firstScene = steps.map(actionOf).find((action) => action === "terminal" || action === "studio");
80
+ if (firstScene === "terminal") {
81
+ const loaded = cdp.once("Page.loadEventFired");
82
+ await cdp.send("Page.navigate", { url: TERMINAL_URL });
83
+ await loaded;
84
+ } else if (!firstScene) await openStudio(cdp, url);
85
+
86
+ const frames = [];
87
+ let started = 0;
88
+ cdp.on("Page.screencastFrame", ({ data, sessionId }) => {
89
+ void cdp.send("Page.screencastFrameAck", { sessionId }).catch(() => {});
90
+ const at = Date.now();
91
+ if (!started) started = at;
92
+ const file = `${String(frames.length).padStart(6, "0")}.jpg`;
93
+ writeFileSync(join(frameDir, file), Buffer.from(data, "base64"));
94
+ frames.push({ file, ms: at - started });
95
+ });
96
+ const firstFrame = cdp.once("Page.screencastFrame", 10000);
97
+ await cdp.send("Page.startScreencast", { format: "jpeg", quality: 90, maxWidth: options.width, maxHeight: options.height, everyNthFrame: 1 });
98
+ await firstFrame;
99
+ const clock = () => Date.now() - started;
100
+ await sleep(500);
101
+
102
+ // A narrated step opens a cue; the silent steps after it run inside that cue,
103
+ // and the cue closes once the narration has had its time, or at a chapter end.
104
+ const cues = [];
105
+ const marks = [];
106
+ const timeline = [];
107
+ let open = null;
108
+ const closeCue = async () => {
109
+ if (!open) return;
110
+ await sleep(open.startMs + stepHoldMs(clock() - open.startMs, open.narration, options.wps, clips.get(open.id)?.audioS ?? null, options.locale) - clock());
111
+ cues.push({ ...open, endMs: clock() });
112
+ open = null;
113
+ };
114
+ for (const chapter of episode.chapters) {
115
+ await closeCue();
116
+ marks.push({ chapter, startMs: marks.length ? clock() : 0 });
117
+ for (const step of chapter.steps) {
118
+ // A card is a new scene: the caption before it ends where it starts.
119
+ if (step.narration || step.card) {
120
+ await closeCue();
121
+ if (step.narration) open = { id: step.id, startMs: clock(), narration: step.narration };
122
+ }
123
+ const stepStart = clock();
124
+ await runStep({ cdp, input, wps: options.wps, locale: options.locale, url, episode, chapter }, step);
125
+ timeline.push({ chapter: chapter.id, id: step.id, action: actionOf(step), startMs: stepStart, endMs: clock() });
126
+ }
127
+ console.log(`chapter ${chapter.id}: ${((clock() - marks.at(-1).startMs) / 1000).toFixed(1)}s`);
128
+ }
129
+ await closeCue();
130
+ await sleep(800);
131
+ const endMs = clock();
132
+ await cdp.send("Page.stopScreencast");
133
+ if (frames.length === 0) throw new Error("the screencast produced no frames");
134
+
135
+ mkdirSync(outDir, { recursive: true });
136
+ const base = join(frameDir, "capture.mp4");
137
+ console.log(`ffmpeg command: ${await encode({ ffmpeg: ffmpeg.path, dir: frameDir, frames, endMs, fps: options.fps, output: base })}`);
138
+ const bandHeight = bandHeightFor(options.height);
139
+ const captions = await renderCaptions(cdp, { texts: cues.map((cue) => cue.narration[options.locale]), width: options.width, height: bandHeight, dir: frameDir, language: options.locale });
140
+ run(ffmpeg.path, muxArgs({ base, output: fullPath, cues, clips, captions, endMs, bandHeight }), "mux");
141
+ console.log(`ffmpeg mux: ${clips.size ? "h264 + aac" : "h264, no audio"}, ${captions.length} captions, ${options.width}x${options.height + bandHeight}`);
142
+ const srtBase = fullPath.replace(/\.mp4$/, "");
143
+ for (const language of episode.chaptered ? [options.locale] : ["ko", "en"]) writeFileSync(`${srtBase}${episode.chaptered ? "" : `.${language}`}.srt`, toSrt(cues, language));
144
+ // When each step ran, for QA frames (one per spotlight) and for timing a re-record.
145
+ writeFileSync(`${srtBase}.steps.json`, `${JSON.stringify(timeline, null, "\t")}\n`);
146
+ console.log(`wrote ${fullPath} (${(endMs / 1000).toFixed(1)}s, ${frames.length} frames)`);
147
+ if (episode.chaptered) marks.forEach((mark, index) => {
148
+ const endAt = index + 1 < marks.length ? marks[index + 1].startMs : endMs;
149
+ const output = join(outDir, chapterFileName(episode.firstChapter + index, mark.chapter.id, options.locale));
150
+ run(ffmpeg.path, chapterCutArgs({ input: fullPath, output, startMs: mark.startMs, endMs: endAt, audio: clips.size > 0 }), `cut ${mark.chapter.id}`);
151
+ console.log(`wrote ${output} (${((endAt - mark.startMs) / 1000).toFixed(1)}s)`);
152
+ });
153
+ } finally {
154
+ cdp.close();
155
+ rmSync(frameDir, { recursive: true, force: true });
156
+ }
@@ -0,0 +1,63 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Terminal</title>
6
+ <style>
7
+ html, body { margin: 0; height: 100%; background: #2b2f36; }
8
+ body { display: flex; align-items: center; justify-content: center; }
9
+ .window { width: 1240px; height: 700px; border-radius: 12px; overflow: hidden; background: #16181d; box-shadow: 0 30px 80px rgba(0, 0, 0, .5); display: flex; flex-direction: column; }
10
+ .bar { height: 40px; display: flex; align-items: center; gap: 9px; padding: 0 16px; background: #23262d; color: #9aa3b2; font: 500 15px/1 system-ui, sans-serif; }
11
+ .bar i { width: 13px; height: 13px; border-radius: 50%; background: #4b5160; }
12
+ .bar span { flex: 1; text-align: center; margin-right: 60px; }
13
+ pre { flex: 1; margin: 0; padding: 26px 30px; color: #e5e7eb; font: 23px/1.6 ui-monospace, "DejaVu Sans Mono", "Noto Sans Mono", monospace; white-space: pre-wrap; }
14
+ .line { min-height: 1.6em; }
15
+ .prompt { color: #7dd3a7; }
16
+ .out { color: #b6bfcc; }
17
+ .caret { display: inline-block; width: .6em; height: 1.1em; vertical-align: -.18em; background: #e5e7eb; animation: blink 1s steps(1) infinite; }
18
+ @keyframes blink { 50% { opacity: 0; } }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <div class="window">
23
+ <div class="bar"><i></i><i></i><i></i><span>Terminal</span></div>
24
+ <pre id="screen"><div class="line"><span class="prompt">~ $ </span><span class="text"></span></div></pre>
25
+ </div>
26
+ <script>
27
+ // Played by tools/tutorial/terminal.mjs: events [{ at, kind: "line" | "char" | "enter", text }].
28
+ // A line prints on a new row (or fills the empty row an Enter left), a char
29
+ // types on the last row, an Enter opens an empty row. The caret follows.
30
+ const screen = document.getElementById("screen");
31
+ const caret = document.createElement("span");
32
+ caret.className = "caret";
33
+ const last = () => screen.lastElementChild;
34
+ const textOf = (line) => line.querySelector(".text");
35
+ const newLine = (className = "") => {
36
+ const line = document.createElement("div");
37
+ line.className = "line";
38
+ const text = document.createElement("span");
39
+ text.className = "text " + className;
40
+ line.append(text);
41
+ screen.append(line);
42
+ return line;
43
+ };
44
+ const placeCaret = () => last().append(caret);
45
+ placeCaret();
46
+ window.__terminal = {
47
+ play(events) {
48
+ for (const event of events) setTimeout(() => {
49
+ if (event.kind === "char") textOf(last()).textContent += event.text;
50
+ else if (event.kind === "enter") newLine();
51
+ else {
52
+ const blank = !last().querySelector(".prompt") && !textOf(last()).textContent;
53
+ const line = blank ? last() : newLine();
54
+ textOf(line).classList.add("out");
55
+ textOf(line).textContent = event.text;
56
+ }
57
+ placeCaret();
58
+ }, event.at);
59
+ },
60
+ };
61
+ </script>
62
+ </body>
63
+ </html>
@@ -0,0 +1,111 @@
1
+ // Runs one episode step against the page: input actions through cursor.mjs,
2
+ // teaching overlays through overlay.mjs, the install scene through
3
+ // terminal.mjs, and the Studio itself.
4
+ import { actionOf, joinTexts, localizeTarget, readingMs } from "./episode.mjs";
5
+ import { playTerminal } from "./terminal.mjs";
6
+
7
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
8
+ const CLOSING_TITLE = { ko: "이번 영상에서 본 것", en: "What we covered" };
9
+ const CHAPTER = { ko: (number) => `챕터 ${number}`, en: (number) => `Chapter ${number}` };
10
+
11
+ /** Runs on every new document: the chosen UI language before the Studio's own scripts read it. */
12
+ export const localeScript = (origin, locale) => `if (location.origin === ${JSON.stringify(origin)}) { try { localStorage.setItem("cozyclay.locale", ${JSON.stringify(locale)}); } catch {} }`;
13
+
14
+ /** Empty origin storage (no project session, no saved projects), then the Studio: the chooser a first-time author sees. */
15
+ export async function openStudio(cdp, url) {
16
+ const origin = new URL(url).origin;
17
+ // A Studio still open on the origin could write its session back after the clear.
18
+ if ((await cdp.evaluate("location.origin")) === origin) {
19
+ const blank = cdp.once("Page.loadEventFired");
20
+ await cdp.send("Page.navigate", { url: "about:blank" });
21
+ await blank;
22
+ }
23
+ await cdp.send("Storage.clearDataForOrigin", { origin, storageTypes: "all" });
24
+ const loaded = cdp.once("Page.loadEventFired");
25
+ await cdp.send("Page.navigate", { url });
26
+ await loaded;
27
+ }
28
+
29
+ /** An action value with every target in it resolved for the UI language. */
30
+ function localizeStep(value, local) {
31
+ if (value === null || typeof value !== "object") return value;
32
+ if (Array.isArray(value)) return value.map(local);
33
+ if (Object.hasOwn(value, "from")) return { from: local(value.from), to: Number.isFinite(value.to?.dx) ? value.to : local(value.to) };
34
+ if (Object.hasOwn(value, "open")) return { open: local(value.open), then: local(value.then) };
35
+ if (Object.hasOwn(value, "into")) return { ...value, into: local(value.into) };
36
+ return local(value);
37
+ }
38
+
39
+ /** The words a card shows, in `locale`. */
40
+ function cardContent(step, { episode, chapter, locale }) {
41
+ const titled = episode.chapters.filter((item) => item.title);
42
+ if (step.card === "opening") return { eyebrow: "CozyClay", title: episode.title[locale], subtitle: episode.subtitle?.[locale] };
43
+ // Titles that carry their own number ("2 · Layout tour") are listed as they are.
44
+ const titles = titled.map((item) => item.title[locale]);
45
+ const items = titles.length ? { titles, numbered: !titles.every((title) => /^\d/.test(title)) } : null;
46
+ if (step.card === "closing") return { title: CLOSING_TITLE[locale], items };
47
+ // A title that already carries its number ("2 · Layout tour") gets no "Chapter N" above it.
48
+ const number = episode.chapters.indexOf(chapter) + episode.firstChapter;
49
+ const title = step.card.title[locale];
50
+ if (step.card.chapters) return { title, subtitle: step.card.subtitle?.[locale], items };
51
+ return { eyebrow: chapter.title && !/^\d/.test(title) ? CHAPTER[locale](number) : null, title, subtitle: step.card.subtitle?.[locale] };
52
+ }
53
+
54
+ /** Reading time of a card in the video's language, for its default hold (2 s at least). */
55
+ function cardReadingMs(step, context, wps) {
56
+ const { eyebrow, title, subtitle, items } = cardContent(step, context);
57
+ const words = [eyebrow, title, subtitle, ...(items?.titles ?? [])].filter(Boolean).join(" ");
58
+ return Math.max(2000, readingMs({ [context.locale]: words }, wps, context.locale));
59
+ }
60
+
61
+ /**
62
+ * Run `step`. `context` carries { cdp, input, wps, locale, url, episode, chapter }:
63
+ * the normalized episode and the chapter the step belongs to.
64
+ */
65
+ export async function runStep(context, step) {
66
+ const { cdp, input, wps, url } = context;
67
+ const action = actionOf(step);
68
+ const local = (target) => localizeTarget(target, context.locale);
69
+ const value = ["click", "wait", "spotlight", "drag", "menu", "type"].includes(action) ? localizeStep(step[action], local) : step[action];
70
+ if (action === "click") for (const target of Array.isArray(value) ? value : [value]) await input.click(target);
71
+ else if (action === "type") await input.type(value);
72
+ else if (action === "key") await input.key(value);
73
+ else if (action === "drag") await input.drag(value.from, value.to);
74
+ else if (action === "hold") await sleep(value);
75
+ else if (action === "eval") {
76
+ const result = await cdp.evaluate(`(async () => (${value}))()`);
77
+ if (result?.ok === false) throw new Error(`step ${step.id}: eval returned ${JSON.stringify(result)}`);
78
+ } else if (action === "wait") {
79
+ if (typeof value === "number") await sleep(value);
80
+ else if (!(await input.locate(value, 20000))) throw new Error(`step ${step.id}: ${JSON.stringify(value)} never showed`);
81
+ } else if (action === "spotlight") {
82
+ if (value === null) {
83
+ await cdp.evaluate("window.__tutorialOverlay.clear()");
84
+ await sleep(350);
85
+ } else {
86
+ // The rect is read now, after any menu or scroll the earlier steps opened.
87
+ if (step.glide !== false) await input.hover(value);
88
+ const shown = await cdp.evaluate(`window.__tutorialOverlay.spotlight(${JSON.stringify(value)}, ${JSON.stringify(step.label?.[context.locale] ?? "")}, ${JSON.stringify(step.note?.[context.locale] ?? "")})`);
89
+ if (!shown) throw new Error(`step ${step.id}: ${JSON.stringify(value)} is not on the page`);
90
+ await sleep(step.hold ?? readingMs(joinTexts(step.label, step.note), wps, context.locale));
91
+ }
92
+ } else if (action === "menu") {
93
+ await input.click(value.open);
94
+ await input.click(value.then);
95
+ } else if (action === "card") {
96
+ await cdp.evaluate("window.__tutorialOverlay.clear()");
97
+ await cdp.evaluate(`window.__tutorialOverlay.card(${JSON.stringify(cardContent(step, context))})`);
98
+ await sleep(step.hold ?? cardReadingMs(step, context, wps));
99
+ await cdp.evaluate("window.__tutorialOverlay.hideCard()");
100
+ await sleep(350);
101
+ } else if (action === "terminal") {
102
+ await playTerminal(cdp, value);
103
+ await sleep(step.hold ?? 1500);
104
+ } else if (action === "studio") {
105
+ await openStudio(cdp, url);
106
+ if (!(await cdp.waitFor("document.querySelector('.v2-start-screen')", 20000))) throw new Error(`step ${step.id}: the startup chooser never showed`);
107
+ }
108
+ if (step.until && !(await cdp.waitFor(`!!window.__tutorialFind(${JSON.stringify(local(step.until))})`, 20000))) {
109
+ throw new Error(`step ${step.id}: ${JSON.stringify(step.until)} never showed`);
110
+ }
111
+ }
@@ -0,0 +1,43 @@
1
+ // The install scene: a local terminal page (scenes/terminal.html) prints
2
+ // output lines and types on the last line, captured like any Studio frame.
3
+ // Consecutive terminal steps continue the same page, so a prompt printed by
4
+ // one step can be answered by the next.
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ export const TERMINAL_URL = pathToFileURL(new URL("./scenes/terminal.html", import.meta.url).pathname).href;
8
+
9
+ /**
10
+ * The timed events the terminal page plays for one step: one `line` per
11
+ * printed line, then one `char` per typed character and an `enter`. Pure,
12
+ * so it is tested. About 12 typed characters a second, lines 0.6 s apart.
13
+ */
14
+ export function terminalSequence({ print = [], type = "" }, { startMs = 400, lineMs = 600, charMs = 83, typeAfterMs = 500, enterMs = 450 } = {}) {
15
+ const events = [];
16
+ let at = startMs;
17
+ print.forEach((line, index) => {
18
+ if (index > 0) at += lineMs;
19
+ events.push({ at, kind: "line", text: line });
20
+ });
21
+ if (type) {
22
+ if (print.length) at += typeAfterMs;
23
+ for (const [index, character] of [...type].entries()) {
24
+ if (index > 0) at += charMs;
25
+ events.push({ at, kind: "char", text: character });
26
+ }
27
+ at += enterMs;
28
+ events.push({ at, kind: "enter" });
29
+ }
30
+ return { events, totalMs: at };
31
+ }
32
+
33
+ /** Play one terminal step: on a fresh page when asked or when the page is elsewhere, else continuing. */
34
+ export async function playTerminal(cdp, scene) {
35
+ if (scene.fresh || (await cdp.evaluate("location.href")) !== TERMINAL_URL) {
36
+ const loaded = cdp.once("Page.loadEventFired");
37
+ await cdp.send("Page.navigate", { url: TERMINAL_URL });
38
+ await loaded;
39
+ }
40
+ const { events, totalMs } = terminalSequence(scene);
41
+ await cdp.evaluate(`window.__terminal.play(${JSON.stringify(events)})`);
42
+ await new Promise((resolve) => setTimeout(resolve, totalMs + 200));
43
+ }
@@ -0,0 +1,49 @@
1
+ // Optional voice stage for the tutorial recorder. It runs before recording,
2
+ // because each cue's hold comes from its clip length. Per narrated step: the
3
+ // author's own recording at tools/tutorial/voice/<episode>/<id>.<lang>.<ext>
4
+ // wins; otherwise macOS `say` reads the step; with neither, the step is silent
5
+ // and the video keeps its burned-in caption.
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ const SAY_VOICES = { ko: "Yuna", en: "Samantha" };
11
+ const EXTENSIONS = ["wav", "m4a", "mp3", "aiff"];
12
+
13
+ /** The author's clip for one step and language, or null. */
14
+ export function userClip(voiceDir, id, language) {
15
+ return EXTENSIONS.map((extension) => join(voiceDir, `${id}.${language}.${extension}`)).find(existsSync) ?? null;
16
+ }
17
+
18
+ /** What the voice reads: `speech` overrides the caption text (pronunciation of English names in Korean, say). */
19
+ export const speechText = (step, language) => step.speech?.[language] ?? step.narration[language];
20
+
21
+ /** True when `say` can speak this language with its voice (macOS only). */
22
+ function sayAvailable(language) {
23
+ const list = spawnSync("say", ["-v", "?"], { encoding: "utf8" });
24
+ return list.status === 0 && new RegExp(`^${SAY_VOICES[language]}\\s`, "m").test(list.stdout);
25
+ }
26
+
27
+ /**
28
+ * Clips for every narrated step in `language`: Map id -> { path, audioS, source }.
29
+ * Steps without a user clip and without `say` are left out.
30
+ */
31
+ export function prepareVoice({ steps, language, voiceDir, workDir, ffprobe, useSay = true }) {
32
+ const say = useSay && sayAvailable(language);
33
+ const clips = new Map();
34
+ for (const step of steps.filter((item) => item.narration)) {
35
+ let path = userClip(voiceDir, step.id, language);
36
+ const source = path ? "user" : "say";
37
+ if (!path && !say) continue;
38
+ if (!path) {
39
+ path = join(workDir, `${step.id}.${language}.wav`);
40
+ const spoken = spawnSync("say", ["-v", SAY_VOICES[language], "--data-format=LEI16@48000", "-o", path, speechText(step, language)], { encoding: "utf8" });
41
+ if (spoken.status !== 0) throw new Error(`say failed for step ${step.id}: ${spoken.stderr.trim()}`);
42
+ }
43
+ const probe = spawnSync(ffprobe, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path], { encoding: "utf8" });
44
+ const audioS = Number(probe.stdout.trim());
45
+ if (probe.status !== 0 || !(audioS > 0)) throw new Error(`ffprobe could not read ${path}`);
46
+ clips.set(step.id, { path, audioS, source });
47
+ }
48
+ return clips;
49
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Usage: node tools/update-test-durations.mjs [RUN_ID]
3
+ // Refreshes tools/test-durations.json from the test-durations-N artifacts of
4
+ // the latest successful pages.yml run on main (or of RUN_ID). Only main runs
5
+ // are full runs, so only they feed the file. A person runs this and commits
6
+ // the result in a pull request; no workflow writes to main (#632).
7
+ // GH_REPO overrides the repository (default NomaDamas/CozyClay).
8
+ import { execFileSync } from "node:child_process";
9
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { DURATIONS_PATH, readDurations, writeDurations } from "./test-shards.mjs";
13
+
14
+ const repo = process.env.GH_REPO || "NomaDamas/CozyClay";
15
+ const gh = (...arguments_) => execFileSync("gh", [...arguments_, "--repo", repo], { encoding: "utf8" });
16
+ const latest = () => JSON.parse(gh("run", "list", "--workflow", "pages.yml", "--branch", "main", "--status", "success", "--limit", "1", "--json", "databaseId"))[0]?.databaseId;
17
+ const runId = process.argv[2] ?? latest();
18
+ if (!runId) throw new Error(`no successful pages.yml run on main in ${repo}`);
19
+
20
+ const scratch = mkdtempSync(join(tmpdir(), "cozyclay-test-durations-"));
21
+ try {
22
+ gh("run", "download", String(runId), "--pattern", "test-durations-*", "--dir", scratch);
23
+ const merged = readDurations();
24
+ let measured = 0;
25
+ for (const artifact of readdirSync(scratch)) {
26
+ for (const file of readdirSync(join(scratch, artifact)).filter((name) => name.endsWith(".json"))) {
27
+ for (const [test, seconds] of Object.entries(JSON.parse(readFileSync(join(scratch, artifact, file), "utf8")))) {
28
+ if (!Number.isFinite(seconds)) continue;
29
+ merged[test] = seconds;
30
+ measured += 1;
31
+ }
32
+ }
33
+ }
34
+ if (measured === 0) throw new Error(`run ${runId} has no test-durations-N artifacts with entries`);
35
+ // Files deleted since the last refresh drop out; files the run did not
36
+ // execute keep their old record.
37
+ const kept = Object.fromEntries(Object.entries(merged).filter(([test]) => existsSync(new URL(`../${test}`, import.meta.url))));
38
+ writeDurations(DURATIONS_PATH, kept);
39
+ console.log(`test durations: ${measured} files measured in run ${runId}, ${Object.keys(kept).length} recorded in tools/test-durations.json`);
40
+ } finally {
41
+ rmSync(scratch, { recursive: true, force: true });
42
+ }
@@ -6,6 +6,8 @@ import { dirname, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
 
8
8
  const packageRoot = fileURLToPath(new URL("..", import.meta.url));
9
+ // CI has no signing key: --imports-only stops after the import check.
10
+ const importsOnly = process.argv.includes("--imports-only");
9
11
  const temporaryRoot = mkdtempSync(join(tmpdir(), "cozyclay-package-check-"));
10
12
  const extractionRoot = join(temporaryRoot, "extracted");
11
13
  // Static, side-effect and dynamic imports with a relative specifier.
@@ -34,6 +36,21 @@ function missingImports(packedRoot) {
34
36
  return missing;
35
37
  }
36
38
 
39
+ async function verifySignature(packedRoot) {
40
+ const metadataPath = join(packedRoot, "package.json");
41
+ const markerPath = join(packedRoot, "dist", "cozyclay-package.json");
42
+ const signatureModulePath = join(packedRoot, "bin", "package-signature.mjs");
43
+ if (!existsSync(metadataPath) || !existsSync(markerPath) || !existsSync(signatureModulePath)) {
44
+ throw new Error("npm tarball is missing package metadata or its signature marker");
45
+ }
46
+ const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
47
+ const { verifyPackageMarker } = await import(pathToFileURL(signatureModulePath).href);
48
+ if (!verifyPackageMarker(markerPath, packedRoot, metadata)) {
49
+ throw new Error("npm tarball signature digest does not match the files npm ships");
50
+ }
51
+ console.log(`package tarball signature PASS (${metadata.name}@${metadata.version})`);
52
+ }
53
+
37
54
  try {
38
55
  mkdirSync(extractionRoot);
39
56
  execFileSync("npm", [
@@ -52,18 +69,7 @@ try {
52
69
  const missing = missingImports(packedRoot);
53
70
  if (missing.length > 0) throw new Error(`npm tarball is missing modules its bin/ and mcp/ code imports:\n ${missing.join("\n ")}`);
54
71
  console.log("package tarball imports PASS (every relative import in bin/ and mcp/ resolves inside the tarball)");
55
- const metadataPath = join(packedRoot, "package.json");
56
- const markerPath = join(packedRoot, "dist", "cozyclay-package.json");
57
- const signatureModulePath = join(packedRoot, "bin", "package-signature.mjs");
58
- if (!existsSync(metadataPath) || !existsSync(markerPath) || !existsSync(signatureModulePath)) {
59
- throw new Error("npm tarball is missing package metadata or its signature marker");
60
- }
61
- const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
62
- const { verifyPackageMarker } = await import(pathToFileURL(signatureModulePath).href);
63
- if (!verifyPackageMarker(markerPath, packedRoot, metadata)) {
64
- throw new Error("npm tarball signature digest does not match the files npm ships");
65
- }
66
- console.log(`package tarball signature PASS (${metadata.name}@${metadata.version})`);
72
+ if (!importsOnly) await verifySignature(packedRoot);
67
73
  } finally {
68
74
  rmSync(temporaryRoot, { recursive: true, force: true });
69
75
  }
@@ -1 +0,0 @@
1
- import{r as e}from"./rolldown-runtime-hePW80VL.js";import{Fa as t,Ji as n,c as r,d as i,f as a,l as o,m as s,o as c,v as l}from"./task-board-CkLZGPkv.js";import{o as u,r as d,t as f}from"./motion3-compile-Du7UXrWn.js";var p=e(t(),1),m=2e3,h=(e,t)=>new Promise(n=>{let r=setTimeout(n,e);t.addEventListener(`abort`,()=>{clearTimeout(r),n()},{once:!0})});function g(e,t){(0,p.useEffect)(()=>{let r=new Map;for(let[n,i]of Object.entries(e))for(let e of i)r.set(e,t[n]??n);let i=()=>{for(let e of document.querySelectorAll(`[data-node-id]`)){let t=r.get(e.getAttribute(`data-node-id`));t?(e.setAttribute(`data-task-scope`,t),e.setAttribute(`title`,n(`In task: ${t}`,`작업 중: ${t}`,`任务中:${t}`))):e.hasAttribute(`data-task-scope`)&&(e.removeAttribute(`data-task-scope`),e.removeAttribute(`title`))}};i();let a=new MutationObserver(i);return a.observe(document.body,{childList:!0,subtree:!0}),()=>{a.disconnect(),r.clear(),i()}},[e,t])}function _({sessionId:e,pulse:t,labelOf:n,onMainNote:_}){let v=(0,p.useMemo)(()=>c(),[]),y=u(),[b,x]=(0,p.useReducer)((e,t)=>t.seed?s(e,t.seed):t.reset?r():i(e,t.event),void 0,r),[S,C]=(0,p.useState)({}),[w,T]=(0,p.useState)(f),E=(0,p.useRef)(0),D=(0,p.useRef)(_);(0,p.useEffect)(()=>{E.current=b.after,D.current=_},[b.after,_]),(0,p.useEffect)(()=>{x({reset:!0}),E.current=0},[e]),(0,p.useEffect)(()=>d(T),[]),(0,p.useEffect)(()=>y.subscribe(e=>{typeof e?.type!=`string`||!e.type.startsWith(`task.`)||(C(t=>a(t,e)),(e.type===`task.userEdit`||e.type===`task.undone`)&&x({event:e}))}),[y]),(0,p.useEffect)(()=>{if(!e)return;let t=new AbortController;return(async()=>{for(;!t.signal.aborted;){try{let{tasks:n}=await v.board(e);x({seed:n}),await v.events(e,E.current,e=>{if(e.type===`main.turn`&&e.turnId){let n=``;v.frames(e.turnId,e=>{e.type===`text.delta`&&(n+=e.text??``),e.type===`tool.start`&&(n=``)},t.signal).then(()=>D.current?.(n),()=>{})}x({event:e})},t.signal)}catch{}await h(m,t.signal)}})(),()=>t.abort()},[v,e,t]);let O=b.order.filter(e=>b.tasks[e]?.status===`running`&&b.tasks[e]?.turnId),k=O.map(e=>`${e}:${b.tasks[e].turnId}`).join();return(0,p.useEffect)(()=>{let e=O.map(e=>{let t=new AbortController,{turnId:r}=b.tasks[e];return v.frames(r,t=>{let r=o(t,n);r&&x({event:{type:`task.progress`,taskId:e,status:`running`,progress:r}})},t.signal).catch(()=>{}),t});return()=>e.forEach(e=>e.abort())},[v,k,n]),g(S,(0,p.useMemo)(()=>Object.fromEntries(b.order.map(e=>[e,b.tasks[e].title])),[b])),(0,p.useEffect)(()=>{l.set({client:v,labelOf:n,undo:async e=>{let t=await y.run(`task.undo`,{taskId:e});if(t?.ok===!1)throw Error(t.message??t.code);x({event:{type:`task.undone`,taskId:e}})}})},[y,v,n]),(0,p.useEffect)(()=>{l.set({board:b,activity:w})},[b,w]),(0,p.useEffect)(()=>()=>l.set({board:r(),open:null}),[e]),null}export{_ as default};