cozyclay 3.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +22 -56
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/session-store.mjs +2 -2
  10. package/bin/agent/studio-prompt.mjs +1 -1
  11. package/bin/agent/studio-task-routes.mjs +185 -0
  12. package/bin/agent/studio-tasks.mjs +156 -0
  13. package/bin/agent/studio-tools.mjs +3 -2
  14. package/bin/agent/studio-turn-transaction.mjs +76 -0
  15. package/bin/cozyclay.mjs +5 -7
  16. package/dist/app/index.html +7 -6
  17. package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
  18. package/dist/assets/app-B2BpfCl2.js +5088 -0
  19. package/dist/assets/app-BlwOpest.css +1 -0
  20. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  21. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  22. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  23. package/dist/assets/motion3-compile-B887n77J.js +1 -0
  24. package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
  25. package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
  26. package/dist/assets/task-board-BKaM7d-9.js +14 -0
  27. package/dist/assets/workflow-B8Dhzexj.js +179 -0
  28. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  29. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  30. package/dist/cozyclay-package.json +1 -1
  31. package/dist/greybox-to-video/index.html +2 -3
  32. package/dist/index.html +7 -57
  33. package/dist/llms.txt +2 -1
  34. package/dist/previs-software/index.html +2 -2
  35. package/dist/privacy/index.html +3 -7
  36. package/dist/seedance-camera-control/index.html +2 -2
  37. package/dist/sitemap.xml +5 -5
  38. package/dist/sw.js +5 -1
  39. package/dist/thumbs/blank-stage-dark.webp +0 -0
  40. package/dist/thumbs/blank-stage-light.webp +0 -0
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/mesh-file.mjs +3 -1
  43. package/mcp/server.mjs +3 -0
  44. package/package.json +3 -3
  45. package/src/App.jsx +50 -281
  46. package/src/analytics.js +3 -148
  47. package/src/app-stage.jsx +1 -1
  48. package/src/asset-pane.jsx +5 -10
  49. package/src/auto-flat.js +10 -0
  50. package/src/bus-tasks.js +11 -5
  51. package/src/command-bus.js +1 -1
  52. package/src/commands/motion3-compile.js +51 -27
  53. package/src/commands/motion3.js +3 -3
  54. package/src/commands/project.js +1 -1
  55. package/src/commands/shot.js +16 -4
  56. package/src/controls.jsx +0 -11
  57. package/src/cuts.js +11 -0
  58. package/src/domains/cast.js +3 -2
  59. package/src/domains/motion.js +3 -10
  60. package/src/domains/playback.js +32 -0
  61. package/src/domains/scenes.js +3 -22
  62. package/src/domains/shots.js +5 -5
  63. package/src/dualview.jsx +11 -15
  64. package/src/hierarchy-panel.css +18 -4
  65. package/src/hierarchy-panel.jsx +12 -11
  66. package/src/kits/held.js +3 -2
  67. package/src/light-blockers.js +2 -2
  68. package/src/main.jsx +5 -0
  69. package/src/motion/ground.js +5 -14
  70. package/src/motion/playback-frame-store.js +79 -0
  71. package/src/motion/playback-probe.js +16 -0
  72. package/src/motion/playback-subject.js +7 -0
  73. package/src/motion/timeline.css +43 -5
  74. package/src/motion/timeline.jsx +4 -3
  75. package/src/motion3/body.js +37 -5
  76. package/src/motion3/carried.js +11 -3
  77. package/src/motion3/compile-job.js +1 -1
  78. package/src/motion3/compiler/compile.js +7 -1
  79. package/src/motion3/compiler/finger-close.js +137 -0
  80. package/src/motion3/compiler/limbs.js +18 -3
  81. package/src/motion3/compiler/pose.js +6 -0
  82. package/src/motion3/compiler/validate-objects.js +17 -2
  83. package/src/motion3/compiler/validate-parts.js +2 -1
  84. package/src/motion3/compiler/vocab.js +4 -4
  85. package/src/motion3/describe.js +2 -2
  86. package/src/motion3/fix/index.js +5 -3
  87. package/src/motion3/flight.js +38 -0
  88. package/src/motion3/free-motion.js +10 -7
  89. package/src/motion3/load.js +125 -0
  90. package/src/motion3/mass.js +23 -0
  91. package/src/motion3/object-keys.js +11 -6
  92. package/src/motion3/object-pose.js +11 -3
  93. package/src/motion3/pipeline.js +3 -0
  94. package/src/motion3/qa/explain.js +3 -1
  95. package/src/motion3/qa/grasp-checks.js +123 -0
  96. package/src/motion3/qa/hints-grasp.js +71 -0
  97. package/src/motion3/qa/index.js +3 -1
  98. package/src/motion3/qa/object-checks.js +7 -2
  99. package/src/motion3/qa/patch.js +2 -1
  100. package/src/motion3/qa/spec-checks.js +10 -1
  101. package/src/motion3/schema/fields.js +3 -3
  102. package/src/motion3/schema/topics.js +9 -8
  103. package/src/motion3/schema/types.js +4 -1
  104. package/src/motion3/seat.js +84 -0
  105. package/src/motion3/settle.js +11 -6
  106. package/src/motion3/shapes.js +70 -0
  107. package/src/motion3/skills/catch.js +71 -0
  108. package/src/motion3/skills/index.js +10 -3
  109. package/src/motion3/skills/info.js +13 -0
  110. package/src/motion3/skills/throw.js +72 -0
  111. package/src/object-catalog.jsx +0 -4
  112. package/src/object-gizmo.jsx +2 -1
  113. package/src/object-path.js +1 -1
  114. package/src/panels/ProjectPanel.jsx +42 -21
  115. package/src/panels/details.css +15 -4
  116. package/src/popover.js +37 -0
  117. package/src/posestudio.jsx +15 -10
  118. package/src/project-browser-art.jsx +19 -0
  119. package/src/project-browser.css +74 -121
  120. package/src/project-browser.jsx +36 -60
  121. package/src/props.jsx +4 -7
  122. package/src/retired-kinds.js +37 -0
  123. package/src/scene-objects.js +3 -7
  124. package/src/scenes.js +4 -5
  125. package/src/settings-menu.jsx +1 -20
  126. package/src/shell/BottomDock.jsx +9 -9
  127. package/src/shell/MenuBar.jsx +1 -9
  128. package/src/shell/PreferencesDialog.jsx +3 -4
  129. package/src/shell/ViewportToolbar.jsx +3 -3
  130. package/src/shell/add-camera-shot.js +11 -0
  131. package/src/shell/glass-regions.css +3 -2
  132. package/src/shell/glass.css +0 -5
  133. package/src/shell/lens-scrub-math.js +51 -0
  134. package/src/shell/lens-scrub.css +73 -0
  135. package/src/shell/lens-scrub.jsx +98 -0
  136. package/src/shell/mode.css +6 -5
  137. package/src/shell/preferences.css +1 -32
  138. package/src/shell/shell.css +18 -1
  139. package/src/shell/studio-shell-context.js +5 -0
  140. package/src/shot-frame.js +26 -0
  141. package/src/stage-canvas-rigs.jsx +7 -8
  142. package/src/stage-characters.jsx +26 -0
  143. package/src/stage-gizmo.js +43 -0
  144. package/src/stage-labels-ko.js +0 -6
  145. package/src/stage-startup-defaults.js +3 -9
  146. package/src/starter-scenes.js +5 -11
  147. package/src/studio-agent-commands.js +2 -2
  148. package/src/studio-agent-protocol.js +2 -7
  149. package/src/studio-app-binding.js +1 -1
  150. package/src/studio-elements.js +1 -1
  151. package/src/styles/themes.css +13 -0
  152. package/src/styles.css +6 -674
  153. package/src/text/text-object.jsx +1 -1
  154. package/src/ui.jsx +8 -16
  155. package/src/use-embed-bridge.js +3 -6
  156. package/src/use-render-activity.js +3 -1
  157. package/src/view-ghosts.jsx +5 -3
  158. package/src/viewport-visible-rect.js +37 -0
  159. package/src/workflow/AgentPanel.jsx +75 -42
  160. package/src/workflow/AgentProgressCard.jsx +1 -1
  161. package/src/workflow/TaskBoard.jsx +102 -0
  162. package/src/workflow/TaskInline.jsx +114 -0
  163. package/src/workflow/agent-client.js +11 -25
  164. package/src/workflow/agent-panel.css +14 -1
  165. package/src/workflow/attachment-image.js +10 -3
  166. package/src/workflow/attachment-video.js +91 -0
  167. package/src/workflow/task-board.css +98 -0
  168. package/src/workflow/task-board.js +169 -0
  169. package/tools/check-eslint.mjs +2 -1
  170. package/tools/check-knip.mjs +2 -1
  171. package/tools/dev/pages/greybox-to-video.html +2 -3
  172. package/tools/dev/pages/previs-software.html +2 -2
  173. package/tools/dev/pages/privacy.html +3 -7
  174. package/tools/dev/pages/seedance-camera-control.html +2 -2
  175. package/tools/dev-full.mjs +11 -2
  176. package/tools/eslint-baseline.json +6 -6
  177. package/tools/knip-baseline.json +0 -11
  178. package/tools/loc-baseline.json +30 -31
  179. package/tools/metrics-snapshot.mjs +43 -5
  180. package/tools/render-start-thumbs.mjs +175 -0
  181. package/tools/run-tests.mjs +7 -14
  182. package/tools/sitemap.mjs +1 -1
  183. package/dist/assets/analytics-BibyE5ar.js +0 -6
  184. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  185. package/dist/assets/app-f-fkV5uP.css +0 -1
  186. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  187. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  188. package/dist/assets/landing-DYg87MDY.js +0 -1
  189. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  190. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  191. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  192. package/dist/scenes/city-block.cclayproject +0 -1
  193. package/src/burn-in.js +0 -91
  194. package/src/camera-tutorial.jsx +0 -520
  195. package/src/facing-marks.js +0 -88
  196. package/src/first-shot-handoff.js +0 -44
  197. package/src/panels/motion.css +0 -197
  198. package/src/set-pieces.jsx +0 -183
  199. package/src/tutorial-analytics.js +0 -86
  200. package/src/tutorial-walk.js +0 -17
  201. package/src/use-camera-tutorial.js +0 -50
  202. package/src/use-semantic-state.js +0 -12
  203. package/tools/metrics/archive-traffic.mjs +0 -138
  204. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -0,0 +1,114 @@
1
+ // Task mode inside the chat (task-board.js; the headless TaskBoard.jsx feeds taskView). A task is one quiet line
2
+ // under the turn that dispatched it; a status line under the header counts them; a line opens the task's own view
3
+ // (its steps, cost, cancel / undo, and an input that talks to that task only), and the chat is one click back.
4
+ import { useEffect, useState, useSyncExternalStore } from "react";
5
+ import { FiChevronDown, FiChevronLeft } from "react-icons/fi";
6
+ import { ko } from "../locale.js";
7
+ import { ACTIVE_STATUSES, TASK_STATUS_COPY, boardGroups, compileState, costLabel, dispatchedTaskIds, progressLine, scopeLabel, taskLine, taskReason, taskView } from "./task-board.js";
8
+ import "./task-board.css";
9
+ export { readStoredTaskMode, storeTaskMode } from "./task-board.js";
10
+
11
+ const useTaskView = () => useSyncExternalStore(taskView.subscribe, taskView.get, taskView.get);
12
+ const openTask = taskId => taskView.set({ open: taskId });
13
+
14
+ function TaskRow({ task, activity }) {
15
+ const line = task.status === "running" && compileState(activity, task.taskId).waiting > 0 ? ko("Compile waits for a free worker", "컴파일 차례 기다리는 중") : taskLine(task);
16
+ const status = TASK_STATUS_COPY[task.status] ?? task.status;
17
+ return <button type="button" className="task-row" data-status={task.status} data-task-id={task.taskId} onClick={() => openTask(task.taskId)}
18
+ title={`${task.title} — ${line}`} aria-label={`${task.title}, ${status}: ${line}`}>
19
+ <span className="task-dot" aria-hidden="true" />
20
+ <span className="task-row-title">{task.title}</span>
21
+ <span className="task-row-line">{line}</span>
22
+ </button>;
23
+ }
24
+
25
+ /** The tasks a turn dispatched (`steps`), or with `items` every task no turn in the chat dispatched (a restored chat). */
26
+ export function TaskRows({ steps, items }) {
27
+ const { board, activity } = useTaskView();
28
+ const placed = items ? new Set(dispatchedTaskIds(items)) : null;
29
+ const ids = placed ? board.order.filter(id => !placed.has(id)) : [...new Set(dispatchedTaskIds(steps))].filter(id => board.tasks[id]);
30
+ if (!ids.length) return null;
31
+ return <div className="task-rows">{ids.map(id => <TaskRow key={id} task={board.tasks[id]} activity={activity} />)}</div>;
32
+ }
33
+
34
+ const GROUPS = [["failed", ko("need a look", "확인 필요")], ["running", ko("running", "실행 중")], ["waiting", ko("waiting", "대기")], ["done", ko("done", "완료")]];
35
+
36
+ export function TaskStrip() {
37
+ const { board, activity } = useTaskView();
38
+ const [expanded, setExpanded] = useState(false);
39
+ if (!board.order.length) return null;
40
+ const groups = boardGroups(board);
41
+ const shown = GROUPS.filter(([key]) => groups[key].length);
42
+ return <div className="task-strip" data-expanded={expanded ? "true" : "false"}>
43
+ <button type="button" className="task-strip-head" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>
44
+ {shown.map(([key, label]) => <span key={key} className="task-strip-count" data-status={key}><span className="task-dot" aria-hidden="true" />{groups[key].length} {label}</span>)}
45
+ <FiChevronDown className="task-strip-caret" size={12} aria-hidden="true" />
46
+ </button>
47
+ {expanded && <div className="task-rows task-strip-list">{shown.flatMap(([key]) => groups[key]).map(task => <TaskRow key={task.taskId} task={task} activity={activity} />)}</div>}
48
+ </div>;
49
+ }
50
+
51
+ function TaskFrames({ client, turnId, labelOf }) {
52
+ const [lines, setLines] = useState([]);
53
+ useEffect(() => {
54
+ if (!turnId || !client) return undefined;
55
+ const controller = new AbortController();
56
+ let reply = "";
57
+ client.frames(turnId, frame => {
58
+ if (frame.type === "text.delta") { reply += frame.text ?? ""; setLines(list => [...list.filter(line => line.kind !== "reply"), { kind: "reply", text: reply }]); return; }
59
+ const label = frame.type === "tool.done" ? null : progressLine(frame, labelOf);
60
+ if (frame.type === "tool.start") reply = "";
61
+ if (label) setLines(list => [...list.filter(line => line.kind !== "reply"), ...(reply ? [{ kind: "said", text: reply }] : []), { kind: frame.type === "error" ? "error" : "tool", text: label }].slice(-40));
62
+ }, controller.signal).catch(() => {});
63
+ return () => controller.abort();
64
+ }, [client, turnId, labelOf]);
65
+ if (!lines.length) return <p className="task-frames-empty">{ko("No steps yet.", "아직 단계가 없어요.")}</p>;
66
+ return <ol className="task-frames">{lines.map((line, index) => <li key={index} data-kind={line.kind}>{line.text}</li>)}</ol>;
67
+ }
68
+
69
+ export function TaskDetail() {
70
+ const { board, open, client, undo, labelOf } = useTaskView();
71
+ const task = open ? board.tasks[open] : null;
72
+ return task ? <OpenTask key={task.taskId} task={task} client={client} undo={undo} labelOf={labelOf} /> : null;
73
+ }
74
+
75
+ function OpenTask({ task, client, undo, labelOf }) {
76
+ const [notice, setNotice] = useState("");
77
+ const [steering, setSteering] = useState("");
78
+ useEffect(() => {
79
+ const onKey = event => { if (event.key === "Escape") openTask(null); };
80
+ window.addEventListener("keydown", onKey);
81
+ return () => window.removeEventListener("keydown", onKey);
82
+ }, []);
83
+ const active = ACTIVE_STATUSES.has(task.status);
84
+ const cost = costLabel(task.usage);
85
+ const act = async (run, done) => { setNotice(""); try { await run(); setNotice(done); } catch (error) { setNotice(String(error?.message ?? error)); } };
86
+ const steer = event => {
87
+ event.preventDefault();
88
+ const text = steering.trim();
89
+ if (text) act(() => client.steer(task.taskId, text), ko("Sent to this task.", "이 작업에 전달함.")).then(() => setSteering(""));
90
+ };
91
+ return <section className="task-detail" data-status={task.status} data-task-id={task.taskId} aria-label={task.title}>
92
+ <header className="task-detail-head">
93
+ <button type="button" className="agent-ghost-button task-detail-back" onClick={() => openTask(null)} autoFocus><FiChevronLeft size={13} aria-hidden="true" />{ko("Chat", "대화")}</button>
94
+ <span className="task-detail-title">{task.title}</span>
95
+ </header>
96
+ <div className="task-detail-body">
97
+ <div className="task-detail-meta" data-status={task.status}>
98
+ <span className="task-dot" aria-hidden="true" />
99
+ <span>{TASK_STATUS_COPY[task.status] ?? task.status}{cost ? ` · ${cost}` : ""}</span>
100
+ {active && <button type="button" className="agent-ghost-button task-detail-cancel" onClick={() => act(() => client.cancel(task.taskId), ko("Cancelled; its changes go back.", "취소함: 바뀐 것은 되돌아가요."))}>{ko("Cancel", "취소")}</button>}
101
+ {task.status === "done" && !task.undone && <button type="button" className="agent-ghost-button task-detail-undo" onClick={() => act(() => undo(task.taskId), ko("Undone.", "되돌림."))}>{ko("Undo task", "작업 되돌리기")}</button>}
102
+ </div>
103
+ {task.scope?.length > 0 && <p className="task-detail-scope" title={task.scope.join(", ")}>{ko("Touches", "건드리는 것")}: {[...new Set(task.scope.map(scopeLabel))].join(" · ")}</p>}
104
+ {task.reason && task.status !== "done" && <p className="task-detail-reason">{taskReason(task.reason)}</p>}
105
+ {task.notes?.map((text, index) => <p key={index} className="task-detail-note">{text}</p>)}
106
+ {task.summary?.reply && !active && <p className="task-detail-summary">{task.summary.reply}</p>}
107
+ <TaskFrames key={task.turnId} client={client} turnId={task.turnId} labelOf={labelOf} />
108
+ {notice && <p className="task-detail-notice" role="status">{notice}</p>}
109
+ </div>
110
+ {task.status === "running" && <form className="task-detail-steer" onSubmit={steer}>
111
+ <input value={steering} onChange={event => setSteering(event.target.value)} placeholder={ko("Tell this task…", "이 작업에게 지시…")} aria-label={ko("Tell this task", "이 작업에게 지시")} />
112
+ </form>}
113
+ </section>;
114
+ }
@@ -266,27 +266,6 @@ export function storeModel(key, storage = globalThis.localStorage) {
266
266
  }
267
267
  }
268
268
 
269
- /** The supervisor switch (#728): off unless the author turned it on. It rides
270
- * on every Studio turn as `supervisor: true|false`; the sidecar reviews only a
271
- * turn sent with `true`, and its own COZYCLAY_SUPERVISOR=off still wins. */
272
- const AGENT_SUPERVISOR_KEY = "cozyclay.agent.supervisor";
273
-
274
- export function readStoredSupervisor(storage = globalThis.localStorage) {
275
- try {
276
- return storage?.getItem(AGENT_SUPERVISOR_KEY) === "on";
277
- } catch {
278
- return false;
279
- }
280
- }
281
-
282
- export function storeSupervisor(enabled, storage = globalThis.localStorage) {
283
- try {
284
- storage?.setItem(AGENT_SUPERVISOR_KEY, enabled ? "on" : "off");
285
- } catch {
286
- // Private-mode storage denial must never break the toggle.
287
- }
288
- }
289
-
290
269
  /** The model to open with: the remembered key when it is still advertised,
291
270
  * otherwise the first model the sidecar listed. */
292
271
  export function preferredModel(models, stored = readStoredModel()) {
@@ -653,6 +632,10 @@ export function keyRefusedMessage(raw) {
653
632
  return `The model provider refused the credentials${status || reason ? ` (${[status, reason].filter(Boolean).join(": ")})` : ""}.`;
654
633
  }
655
634
 
635
+ /** True when the agent server itself did not answer: plain `vite` without the sidecar (503/502/404), a dead
636
+ * port (network error, no status), or an HTML fallback where JSON was expected. Any other status is the server's own answer. */
637
+ export const agentServerUnavailable = (error) => !Number.isFinite(error?.status) || [404, 502, 503].includes(error.status);
638
+
656
639
  export function createHttpTransport({ fetchImpl = globalThis.fetch?.bind(globalThis), surface, capture = track, now = () => performance.now() } = {}) {
657
640
  const activeTurns = new Map();
658
641
  const request = (path, init) => sidecarRequest(fetchImpl, path, init);
@@ -744,7 +727,7 @@ export function createHttpTransport({ fetchImpl = globalThis.fetch?.bind(globalT
744
727
  },
745
728
  /** Streams sidecar events to `onEvent`. Resolves when the turn ends. */
746
729
  async turn(turnRequest, onEvent, signal) {
747
- const { sessionId, text, attachFrame, model, effort, attachments, supervisor } = turnRequest;
730
+ const { sessionId, text, attachFrame, model, effort, attachments, supervisor, tasks } = turnRequest;
748
731
  const attached = Array.isArray(attachments) && attachments.length ? { attachments } : {};
749
732
  const studio = turnRequest.surface === "studio";
750
733
  const turnId = studio ? turnRequest.turnId : null;
@@ -803,9 +786,10 @@ export function createHttpTransport({ fetchImpl = globalThis.fetch?.bind(globalT
803
786
  })
804
787
  : JSON.stringify({ sessionId, text, attachFrame, model, ...(effort ? { effort } : {}), ...attached, ...(telemetry.turnId ? { turn_id: telemetry.turnId } : {}) });
805
788
  try {
806
- const response = await fetchImpl(sidecarUrl("/agent/turn"), {
789
+ // Task mode (the panel's Tasks switch): the turn goes to the main agent, which answers at once and runs tasks.
790
+ const response = await fetchImpl(sidecarUrl(studio && tasks ? "/agent/main" : "/agent/turn"), {
807
791
  method: "POST",
808
- headers: { "content-type": "application/json", accept: "text/event-stream" },
792
+ headers: { "content-type": "application/json", accept: "text/event-stream", ...(studio && tasks ? { "x-cozyclay-tasks": "on" } : {}) },
809
793
  body,
810
794
  signal,
811
795
  });
@@ -1454,6 +1438,8 @@ export function createAgentChatStore({
1454
1438
  return () => listeners.delete(listener);
1455
1439
  },
1456
1440
  setDraft: (draft) => set({ draft: String(draft ?? "") }),
1441
+ /** A reply the main agent made on its own (task mode: a task ended while nobody was talking to it). */
1442
+ note: (text) => { if (String(text ?? "").trim()) set({ items: [...state.items, { kind: "assistant", id: newId(), text: String(text) }] }); },
1457
1443
  /** Pictures pasted or dropped into the composer. Returns what was taken and
1458
1444
  * what had to be refused, so the composer can say so instead of a picture
1459
1445
  * vanishing on its way in. */
@@ -1511,7 +1497,7 @@ export function createAgentChatStore({
1511
1497
  attachFrame: Boolean(options.attachFrame),
1512
1498
  model: options.model,
1513
1499
  effort: options.effort ?? undefined,
1514
- ...(typeof options.supervisor === "boolean" ? { supervisor: options.supervisor } : {}),
1500
+ ...(typeof options.supervisor === "boolean" ? { supervisor: options.supervisor } : {}), ...(options.tasks ? { tasks: true } : {}),
1515
1501
  }, applyEvent, signal);
1516
1502
  } catch (error) {
1517
1503
  if (!signal.aborted) applyEvent({ type: "error", code: "upstream", message: String(error?.message || error) });
@@ -287,7 +287,8 @@
287
287
  cursor: pointer;
288
288
  }
289
289
 
290
- .agent-menu button:hover { background: var(--agent-hover, #2b3040); color: var(--agent-text-strong, #f5f7ff); }
290
+ .agent-menu button[disabled] { opacity: 0.45; cursor: not-allowed; }
291
+ .agent-menu button:not([disabled]):hover { background: var(--agent-hover, #2b3040); color: var(--agent-text-strong, #f5f7ff); }
291
292
 
292
293
  /* --- account strip ----------------------------------------------------- */
293
294
 
@@ -869,6 +870,18 @@
869
870
  /* The second way in on the signed-out card: a key or a server of one's own. */
870
871
  .agent-state-card .agent-use-key { align-self: center; }
871
872
 
873
+ /* No agent server behind the page: a quiet status (icon + words), not an alert. */
874
+ .agent-offline-icon { align-self: center; color: var(--agent-text-muted); }
875
+ .agent-offline-card code {
876
+ padding: 0 var(--agent-space-1);
877
+ border-radius: var(--agent-radius-sm);
878
+ background: var(--agent-bg-sunken);
879
+ color: var(--agent-text);
880
+ font-size: var(--agent-font-sm);
881
+ }
882
+ .agent-offline-retry { align-self: center; display: inline-flex; align-items: center; gap: var(--agent-space-2); }
883
+ .agent-signin-error { align-self: center; max-width: 32ch; }
884
+
872
885
  .agent-primary-button {
873
886
  align-self: center;
874
887
  border: 1px solid var(--agent-accent-edge);
@@ -10,6 +10,7 @@
10
10
  * Anything under it is sent verbatim: no re-encode, no generation loss.
11
11
  */
12
12
  import { fileToDataUrl, imageFileFromTransfer } from "./clipboard-image.js";
13
+ import { isVideoFile, videoContactSheet } from "./attachment-video.js";
13
14
 
14
15
  export const ATTACHMENT_MAX_COUNT = 4;
15
16
  export const ATTACHMENT_MAX_DIMENSION = 1600;
@@ -19,8 +20,9 @@ export const ATTACHMENT_JPEG_QUALITY = 0.85;
19
20
  * rest here rather than letting the sidecar reject a turn the author sent. */
20
21
  const ATTACHMENT_TYPES = Object.freeze(["image/png", "image/jpeg", "image/webp"]);
21
22
  export const ATTACHMENT_LIMIT_NOTICE = "Up to 4 images per message";
22
- export const ATTACHMENT_TYPE_NOTICE = "Attach a PNG, JPEG or WebP image";
23
- export const ATTACHMENT_FAILED_NOTICE = "That image could not be attached";
23
+ export const ATTACHMENT_TYPE_NOTICE = "Attach a PNG, JPEG or WebP image, or a video";
24
+ export const ATTACHMENT_FAILED_NOTICE = "That image or video could not be attached";
25
+ export const ATTACHMENT_ACCEPT = [...ATTACHMENT_TYPES, "video/*"].join(",");
24
26
 
25
27
  const EXTENSION_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
26
28
 
@@ -57,7 +59,8 @@ export function attachmentFilesFromTransfer(transfer) {
57
59
  const images = [];
58
60
  let unsupported = 0;
59
61
  for (const file of candidates) {
60
- if (attachmentType(file)) images.push(file);
62
+ // A video travels as a contact sheet image (attachment-video.js).
63
+ if (attachmentType(file) || isVideoFile(file)) images.push(file);
61
64
  else if (String(file?.type ?? "").startsWith("image/")) unsupported += 1;
62
65
  }
63
66
  return { images, unsupported };
@@ -155,6 +158,10 @@ export function attachmentName(file) {
155
158
  * media type is the resolved one: a file dragged in without a type would
156
159
  * otherwise be labelled PNG whatever its bytes are. */
157
160
  export async function attachmentFromFile(file, options = {}) {
161
+ if (isVideoFile(file)) {
162
+ const sheet = await videoContactSheet(file);
163
+ return { dataUrl: await blobToDataUrl(sheet.blob, "image/jpeg"), name: sheet.name };
164
+ }
158
165
  const { readDataUrl = fileToDataUrl, ...shrink } = options;
159
166
  const type = attachmentType(file) ?? "image/png";
160
167
  const read = await readDataUrl(file);
@@ -0,0 +1,91 @@
1
+ /**
2
+ * A video the author attaches to a turn. The turn envelope carries images
3
+ * only, and the models behind it read pictures, not clips, so the composer
4
+ * turns a video into one contact sheet: evenly spaced frames in a grid, each
5
+ * stamped with its time. It costs one attachment slot and keeps the order of
6
+ * the action legible to the model.
7
+ */
8
+
9
+ const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "ogv", "mkv"]);
10
+ const SHEET_FRAMES = 9;
11
+ const SHEET_COLUMNS = 3;
12
+ const SHEET_CELL_WIDTH = 480;
13
+ const SHEET_JPEG_QUALITY = 0.85;
14
+
15
+ export function isVideoFile(file) {
16
+ const declared = String(file?.type ?? "").toLowerCase();
17
+ if (declared) return declared.startsWith("video/");
18
+ const extension = String(file?.name ?? "").toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
19
+ return VIDEO_EXTENSIONS.has(extension);
20
+ }
21
+
22
+ function sheetTimes(duration, count) {
23
+ return Array.from({ length: count }, (_, index) => (index + 0.5) * duration / count);
24
+ }
25
+
26
+ function formatSheetTime(seconds) {
27
+ const minutes = Math.floor(seconds / 60);
28
+ return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, "0")}`;
29
+ }
30
+
31
+ function nextEvent(target, name) {
32
+ return new Promise((resolve, reject) => {
33
+ const done = () => { target.removeEventListener(name, done); target.removeEventListener("error", failed); resolve(); };
34
+ const failed = () => { target.removeEventListener(name, done); target.removeEventListener("error", failed); reject(new Error("this browser cannot decode that video")); };
35
+ target.addEventListener(name, done);
36
+ target.addEventListener("error", failed);
37
+ });
38
+ }
39
+
40
+ /**
41
+ * One video file as a JPEG contact sheet `{ blob, name }`. Decoding happens in
42
+ * a detached <video>; a codec the browser cannot play rejects instead of
43
+ * producing a black sheet.
44
+ */
45
+ export async function videoContactSheet(file, { doc = globalThis.document, frames = SHEET_FRAMES } = {}) {
46
+ const url = URL.createObjectURL(file);
47
+ const video = doc.createElement("video");
48
+ video.muted = true;
49
+ video.playsInline = true;
50
+ video.preload = "auto";
51
+ try {
52
+ const loaded = nextEvent(video, "loadeddata");
53
+ video.src = url;
54
+ await loaded;
55
+ const { duration, videoWidth, videoHeight } = video;
56
+ if (!Number.isFinite(duration) || duration <= 0 || !videoWidth || !videoHeight) throw new Error("that video has no usable length or size");
57
+ const cellWidth = Math.min(SHEET_CELL_WIDTH, videoWidth);
58
+ const cellHeight = Math.round(cellWidth * videoHeight / videoWidth);
59
+ const canvas = doc.createElement("canvas");
60
+ canvas.width = cellWidth * SHEET_COLUMNS;
61
+ canvas.height = cellHeight * Math.ceil(frames / SHEET_COLUMNS);
62
+ const context = canvas.getContext("2d");
63
+ if (!context) throw new Error("this browser cannot draw video frames — no 2D context");
64
+ context.fillStyle = "#000";
65
+ context.fillRect(0, 0, canvas.width, canvas.height);
66
+ context.font = `600 ${Math.max(12, Math.round(cellHeight / 12))}px system-ui, sans-serif`;
67
+ context.textBaseline = "top";
68
+ for (const [index, time] of sheetTimes(duration, frames).entries()) {
69
+ const seeked = nextEvent(video, "seeked");
70
+ video.currentTime = time;
71
+ await seeked;
72
+ const x = (index % SHEET_COLUMNS) * cellWidth;
73
+ const y = Math.floor(index / SHEET_COLUMNS) * cellHeight;
74
+ context.drawImage(video, x, y, cellWidth, cellHeight);
75
+ const stamp = `${index + 1} · ${formatSheetTime(time)}`;
76
+ const pad = 6;
77
+ const box = context.measureText(stamp).width + pad * 2;
78
+ context.fillStyle = "rgba(0, 0, 0, 0.6)";
79
+ context.fillRect(x + 4, y + 4, box, Math.round(cellHeight / 12) + pad * 2);
80
+ context.fillStyle = "#fff";
81
+ context.fillText(stamp, x + 4 + pad, y + 4 + pad);
82
+ }
83
+ const blob = await new Promise((resolve, reject) => canvas.toBlob((result) => result ? resolve(result) : reject(new Error("the contact sheet could not be encoded")), "image/jpeg", SHEET_JPEG_QUALITY));
84
+ const base = String(file?.name ?? "").trim() || "video";
85
+ return { blob, name: `${base.slice(0, 70)} (${frames} frames over ${duration.toFixed(1)}s)` };
86
+ } finally {
87
+ video.removeAttribute("src");
88
+ video.load();
89
+ URL.revokeObjectURL(url);
90
+ }
91
+ }
@@ -0,0 +1,98 @@
1
+ /* Task mode in the chat (TaskInline.jsx): one quiet line per task, a count line under the header, the open task's
2
+ view over the chat, and the outliner mark on rows inside a running task. Studio tokens first, agent tokens after. */
3
+ .task-rows {
4
+ display: grid;
5
+ margin: 2px 0 2px 2px;
6
+ padding-left: 6px;
7
+ border-left: 2px solid var(--hairline, var(--agent-border, rgba(127, 127, 127, 0.25)));
8
+ min-width: 0;
9
+ }
10
+ .task-row {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 6px;
14
+ min-width: 0;
15
+ height: 24px;
16
+ padding: 0 6px;
17
+ border: 0;
18
+ border-radius: 6px;
19
+ background: none;
20
+ color: inherit;
21
+ font: inherit;
22
+ font-size: 12px;
23
+ text-align: left;
24
+ cursor: pointer;
25
+ }
26
+ .task-row:hover, .task-row:focus-visible { background: var(--chip-fill, rgba(127, 127, 127, 0.1)); outline: none; }
27
+ .task-row-title { flex: 0 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 550; }
28
+ .task-row-line { flex: 1 1 0; min-width: 64px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--text-2, var(--agent-text-muted)); }
29
+ .task-row[data-status="failed"] .task-row-line { color: var(--danger, var(--agent-alert)); }
30
+ .task-row[data-status="cancelled"], .task-row[data-status="done"] .task-row-title { opacity: 0.8; }
31
+
32
+ .task-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--text-3, var(--agent-text-dim)); }
33
+ [data-status="running"] > .task-dot, [data-status="starting"] > .task-dot { background: var(--select, var(--agent-accent)); animation: task-dot-pulse 1.2s ease-in-out infinite; }
34
+ [data-status="waiting"] > .task-dot, [data-status="queued"] > .task-dot { background: none; box-shadow: inset 0 0 0 1.5px var(--text-3, var(--agent-text-dim)); }
35
+ [data-status="done"] > .task-dot { background: var(--ok, var(--agent-ok)); }
36
+ [data-status="failed"] > .task-dot, [data-status="paused"] > .task-dot { background: var(--danger, var(--agent-alert)); }
37
+ @keyframes task-dot-pulse { 50% { opacity: 0.35; } }
38
+ @media (prefers-reduced-motion: reduce) { [data-status] > .task-dot { animation: none; } }
39
+
40
+ .task-strip { flex: none; border-bottom: 1px solid var(--hairline, var(--agent-border, rgba(127, 127, 127, 0.2))); padding: 0 8px; }
41
+ .task-strip-head {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 10px;
45
+ width: 100%;
46
+ height: 26px;
47
+ padding: 0 6px;
48
+ border: 0;
49
+ background: none;
50
+ color: var(--text-2, var(--agent-text-muted));
51
+ font: inherit;
52
+ font-size: 11.5px;
53
+ cursor: pointer;
54
+ }
55
+ .task-strip-count { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
56
+ .task-strip-caret { margin-left: auto; transition: transform 0.15s; }
57
+ .task-strip[data-expanded="true"] .task-strip-caret { transform: rotate(180deg); }
58
+ .task-strip-list { margin: 0 0 6px; max-height: 168px; overflow-y: auto; }
59
+
60
+ .task-detail {
61
+ position: absolute;
62
+ inset: 0;
63
+ z-index: 6;
64
+ display: flex;
65
+ flex-direction: column;
66
+ background: var(--surface-raised, var(--agent-bg));
67
+ border-radius: inherit;
68
+ }
69
+ .task-detail-head { display: flex; align-items: center; gap: 6px; height: 44px; padding: 0 10px; border-bottom: 1px solid var(--hairline, var(--agent-border)); }
70
+ .task-detail-back { display: inline-flex; align-items: center; gap: 2px; flex: none; }
71
+ .task-detail-title { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 600; }
72
+ .task-detail-body { flex: 1; min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 6px; padding: 10px 14px; font-size: 12px; }
73
+ .task-detail-meta { display: flex; align-items: center; gap: 6px; color: var(--text-2, var(--agent-text-muted)); }
74
+ .task-detail-meta .agent-ghost-button { margin-left: auto; }
75
+ .task-detail-scope, .task-detail-reason, .task-detail-note, .task-detail-summary, .task-detail-notice { margin: 0; overflow-wrap: anywhere; }
76
+ .task-detail-scope, .task-detail-reason, .task-detail-note, .task-detail-notice { color: var(--text-2, var(--agent-text-muted)); }
77
+ .task-detail-steer { padding: 8px 10px 10px; border-top: 1px solid var(--hairline, var(--agent-border)); }
78
+ .task-detail-steer input {
79
+ width: 100%;
80
+ box-sizing: border-box;
81
+ padding: 7px 10px;
82
+ border: 1px solid var(--hairline, var(--agent-border));
83
+ border-radius: 10px;
84
+ background: transparent;
85
+ color: inherit;
86
+ font: inherit;
87
+ }
88
+ .task-frames { margin: 4px 0 0; padding-left: 18px; display: grid; gap: 2px; }
89
+ .task-frames li[data-kind="error"] { color: var(--danger, var(--agent-alert)); }
90
+ .task-frames li[data-kind="reply"], .task-frames li[data-kind="said"] { list-style: none; margin-left: -18px; }
91
+ .task-frames-empty { margin: 0; color: var(--text-3, var(--agent-text-dim)); }
92
+
93
+ [data-task-scope]::after {
94
+ content: "●";
95
+ margin-left: 6px;
96
+ color: var(--select, var(--agent-accent));
97
+ font-size: 9px;
98
+ }
@@ -0,0 +1,169 @@
1
+ // The Studio task board in the agent panel (parallel tasks, P3). Task mode is a per-browser switch (the panel's Tasks
2
+ // toggle) or the server's COZYCLAY_STUDIO_TASKS flag: the chat then talks to the main agent (POST /agent/main), which
3
+ // answers at once and dispatches tasks; this board shows them. Pure parts here (the reducer, labels, the switch's
4
+ // storage); the network side is createTaskBoardClient; the view is TaskBoard.jsx.
5
+ // server events (GET /agent/main/<sessionId>/events): task.queued|waiting|started|resumed|paused|done|failed|cancelled
6
+ // carry the task's view { taskId, title, status, scope, reason?, turnId?, usage, summary? }; task.sceneChanged
7
+ // { taskId, keys, midCommand }; main.turn { turnId } (the main agent reporting on its own)
8
+ // editor events (the command bus): task.userEdit { taskId, keys } (the user changed something in its scope)
9
+ // compile activity (motion3-compile.js): { running, waiting } of { taskId, characterId }
10
+ import { ko } from "../locale.js";
11
+ import { sidecarUrl } from "../agent-image-client.js";
12
+ import { parseSseChunk } from "./agent-client.js";
13
+
14
+ const TASKS_KEY = "cozyclay.agent.tasks";
15
+ export function readStoredTaskMode(storage = globalThis.localStorage) {
16
+ try { return storage?.getItem(TASKS_KEY) === "on"; } catch { return false; }
17
+ }
18
+ export function storeTaskMode(enabled, storage = globalThis.localStorage) {
19
+ try { storage?.setItem(TASKS_KEY, enabled ? "on" : "off"); } catch { /* storage may be unavailable */ }
20
+ }
21
+
22
+ export const TASK_STATUS_COPY = {
23
+ queued: ko("Queued", "대기열"),
24
+ waiting: ko("Waiting for scope", "영역 대기"),
25
+ starting: ko("Starting", "시작 중"),
26
+ running: ko("Running", "작업 중"),
27
+ paused: ko("Paused: Studio lost", "일시정지: Studio 연결 끊김"),
28
+ done: ko("Done", "완료"),
29
+ failed: ko("Failed", "실패"),
30
+ cancelled: ko("Cancelled", "취소됨"),
31
+ };
32
+ export const ACTIVE_STATUSES = new Set(["queued", "waiting", "starting", "running", "paused"]);
33
+
34
+ export const emptyBoard = () => ({ order: [], tasks: {}, after: 0 });
35
+
36
+ /** The board after one event (server, editor or compile). Unknown events leave it as it is. */
37
+ export function reduceBoard(board, event) {
38
+ if (!event || typeof event !== "object") return board;
39
+ const after = Number.isFinite(event.eventSeq) ? Math.max(board.after, event.eventSeq) : board.after;
40
+ const patch = (taskId, change) => {
41
+ const known = board.tasks[taskId];
42
+ if (!known && !change.title) return { ...board, after };
43
+ const task = { notes: [], ...known, ...change, taskId };
44
+ return { ...board, after, order: known ? board.order : [...board.order, taskId], tasks: { ...board.tasks, [taskId]: task } };
45
+ };
46
+ const note = (taskId, text) => (board.tasks[taskId] ? patch(taskId, { notes: [...board.tasks[taskId].notes, text].slice(-4) }) : { ...board, after });
47
+ if (event.type === "task.sceneChanged") return note(event.taskId, ko(`The scene changed during a reload: ${event.keys.join(", ")}`, `새로고침 중에 장면이 바뀜: ${event.keys.join(", ")}`));
48
+ if (event.type === "task.userEdit") return note(event.taskId, ko(`You changed ${event.keys.join(", ")}; the task reads it again`, `${event.keys.join(", ")}을(를) 직접 바꿈: 작업이 다시 읽어요`));
49
+ if (event.type === "task.undone") return board.tasks[event.taskId] ? patch(event.taskId, { undone: true }) : board;
50
+ if (typeof event.type === "string" && event.type.startsWith("task.") && event.taskId && event.status) {
51
+ const { type, at, eventSeq, ...view } = event;
52
+ return patch(event.taskId, { ...view, ...(type === "task.started" || type === "task.resumed" ? { progress: "" } : {}) });
53
+ }
54
+ return { ...board, after };
55
+ }
56
+
57
+ /** A board read (GET .../board) merged under what the events already said. */
58
+ export function seedBoard(board, tasks = []) {
59
+ return tasks.reduce((acc, view) => (acc.tasks[view.taskId] ? acc : reduceBoard(acc, { type: "task.seed", ...view })), board);
60
+ }
61
+
62
+ /** The line a running card shows: the last tool it started, or the compile it waits for. */
63
+ export function progressLine(frame, labelOf = call => call.name) {
64
+ if (frame?.type === "tool.start") return labelOf(frame);
65
+ if (frame?.type === "error") return ko(`Error: ${frame.message ?? frame.code}`, `오류: ${frame.message ?? frame.code}`);
66
+ return null;
67
+ }
68
+
69
+ /** A task's wait or failure reason in the author's words: the server names the blocking task by id and title. */
70
+ export function taskReason(reason) {
71
+ // Greedy to the last ")": a title may hold parentheses of its own.
72
+ const after = /(?:waits for|held by) task \S+ \((.+)\)/.exec(String(reason ?? ""))?.[1];
73
+ if (after) return ko(`Starts after "${after}"`, `'${after}' 끝나면 시작`);
74
+ if (/scope is held/.test(String(reason ?? ""))) return ko("Starts when another task frees its part", "다른 작업이 끝나면 시작");
75
+ return reason ?? "";
76
+ }
77
+
78
+ /** The one line a task row shows after its title. */
79
+ export function taskLine(task) {
80
+ if (task.status === "running") return task.progress || ko("Working…", "작업 중…");
81
+ if (task.status === "done") return task.undone ? ko("Undone", "되돌림") : String(task.summary?.reply ?? "").split(/(?<=[.!?。])\s/)[0] || TASK_STATUS_COPY.done;
82
+ if (task.reason) return taskReason(task.reason);
83
+ return TASK_STATUS_COPY[task.status] ?? task.status;
84
+ }
85
+
86
+ const SCOPE_NAMES = { timeline: ko("timeline", "타임라인"), stage: ko("stage", "무대"), scene: ko("scene", "장면"), object: ko("object", "오브젝트"), character: ko("character", "캐릭터"), shot: ko("shot", "샷") };
87
+ /** A scope key (object:<id>, timeline, ...) as a word; the raw key stays in the tooltip. */
88
+ export const scopeLabel = key => SCOPE_NAMES[String(key).split(":")[0]] ?? String(key);
89
+
90
+ /** Rows by state for the status line: what needs a look first, then what runs, waits and ended. */
91
+ export function boardGroups(board) {
92
+ const groups = { failed: [], running: [], waiting: [], done: [] };
93
+ for (const id of board.order) {
94
+ const task = board.tasks[id];
95
+ const group = task.status === "failed" || task.status === "paused" ? "failed" : task.status === "running" || task.status === "starting" ? "running" : ACTIVE_STATUSES.has(task.status) ? "waiting" : "done";
96
+ groups[group].push(task);
97
+ }
98
+ return groups;
99
+ }
100
+
101
+ /** The task ids a turn's dispatch_task steps started, in order (a duplicate points at the task it repeats). */
102
+ export const dispatchedTaskIds = steps => (steps ?? []).filter(step => step.kind === "tool" && step.name === "dispatch_task")
103
+ .map(step => step.result?.taskId ?? step.result?.duplicateOf).filter(Boolean);
104
+
105
+ /** What the task view shares between the headless board (TaskBoard.jsx) and the rows in the chat (TaskInline.jsx). */
106
+ function createTaskViewStore() {
107
+ let state = { board: emptyBoard(), activity: null, client: null, undo: null, open: null };
108
+ const listeners = new Set();
109
+ return {
110
+ get: () => state,
111
+ set: patch => { state = { ...state, ...patch }; for (const listener of listeners) listener(); },
112
+ subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener); },
113
+ };
114
+ }
115
+ // One agent panel per page, so one view.
116
+ export const taskView = createTaskViewStore();
117
+
118
+ /** "$0.12" for a task's cost, or "" when nothing is known yet. */
119
+ export const costLabel = usage => (Number.isFinite(usage?.cost) && usage.cost > 0 ? `$${usage.cost.toFixed(2)}` : "");
120
+
121
+ /** The compile a task waits for: how many of its compiles wait and how many run. */
122
+ export function compileState(activity, taskId) {
123
+ const mine = list => (list ?? []).filter(meta => meta?.taskId === taskId).length;
124
+ return { waiting: mine(activity?.waiting), running: mine(activity?.running) };
125
+ }
126
+
127
+ /** The open scopes the editor's bus reports: taskId -> keys (task.begun / task.scope / task.finished|cancelled). */
128
+ export function reduceScopes(scopes, event) {
129
+ if (event?.type === "task.begun" || event?.type === "task.scope") return { ...scopes, [event.taskId]: event.scope ?? [] };
130
+ if (event?.type === "task.finished" || event?.type === "task.cancelled") { const { [event.taskId]: _gone, ...rest } = scopes; return rest; }
131
+ return scopes;
132
+ }
133
+
134
+ /**
135
+ * The sidecar side of the board. All requests are same-origin with the Studio session's owner cookie.
136
+ * fetchImpl(url, init) -> Response; url(path) -> the sidecar URL.
137
+ */
138
+ export function createTaskBoardClient({ fetchImpl = globalThis.fetch?.bind(globalThis), url = sidecarUrl } = {}) {
139
+ const json = async (path, init) => {
140
+ const response = await fetchImpl(url(path), { credentials: "same-origin", headers: { accept: "application/json", "content-type": "application/json" }, ...init });
141
+ const body = await response.json().catch(() => ({}));
142
+ if (!response.ok) throw Object.assign(new Error(body?.error?.message ?? `${response.status}`), { status: response.status, code: body?.error?.code });
143
+ return body;
144
+ };
145
+ /** Streams an SSE route to onEvent until it ends or signal aborts. */
146
+ async function stream(path, onEvent, signal) {
147
+ const response = await fetchImpl(url(path), { credentials: "same-origin", headers: { accept: "text/event-stream" }, signal });
148
+ if (!response.ok || !response.body) throw Object.assign(new Error(`${response.status}`), { status: response.status });
149
+ const reader = response.body.getReader(), decoder = new TextDecoder();
150
+ let buffer = "";
151
+ try {
152
+ while (true) {
153
+ const { value, done } = await reader.read();
154
+ if (done) break;
155
+ const parsed = parseSseChunk(buffer + decoder.decode(value, { stream: true }));
156
+ buffer = parsed.tail;
157
+ for (const event of parsed.events) onEvent(event);
158
+ }
159
+ } finally { reader.releaseLock(); }
160
+ }
161
+ const id = value => encodeURIComponent(value);
162
+ return {
163
+ board: sessionId => json(`/agent/main/${id(sessionId)}/board`),
164
+ events: (sessionId, after, onEvent, signal) => stream(`/agent/main/${id(sessionId)}/events?after=${after}`, onEvent, signal),
165
+ frames: (turnId, onEvent, signal) => stream(`/agent/turn/${id(turnId)}/events?after=0`, onEvent, signal),
166
+ cancel: taskId => json(`/agent/task/${id(taskId)}/cancel`, { method: "POST", body: "{}" }),
167
+ steer: (taskId, text) => json(`/agent/task/${id(taskId)}/steer`, { method: "POST", body: JSON.stringify({ text }) }),
168
+ };
169
+ }
@@ -10,7 +10,8 @@ import { fileURLToPath } from "node:url";
10
10
  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
11
11
  const baselinePath = join(root, "tools", "eslint-baseline.json");
12
12
 
13
- const run = spawnSync(join(root, "node_modules", ".bin", "eslint"), [".", "--format", "json"], {
13
+ // Run the package's JS entry through node: the .bin shim is a .cmd on Windows, which spawnSync cannot start without a shell.
14
+ const run = spawnSync(process.execPath, [join(root, "node_modules", "eslint", "bin", "eslint.js"), ".", "--format", "json"], {
14
15
  cwd: root,
15
16
  encoding: "utf8",
16
17
  maxBuffer: 512 * 1024 * 1024,
@@ -11,7 +11,8 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
11
11
  const baselinePath = join(root, 'tools', 'knip-baseline.json');
12
12
 
13
13
  // knip exits 1 when it finds issues; only a missing/unparseable report is a failure.
14
- const run = spawnSync(join(root, 'node_modules', '.bin', 'knip'), ['--reporter', 'json', '--no-progress'], {
14
+ // Run the package's JS entry through node: the .bin shim is a .cmd on Windows, which spawnSync cannot start without a shell.
15
+ const run = spawnSync(process.execPath, [join(root, 'node_modules', 'knip', 'bin', 'knip.js'), '--reporter', 'json', '--no-progress'], {
15
16
  cwd: root,
16
17
  encoding: 'utf8',
17
18
  maxBuffer: 256 * 1024 * 1024,