cozyclay 3.0.1 → 3.0.3

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 (83) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/THIRD_PARTY_NOTICES.md +10 -0
  3. package/bin/agent/pi-tools.mjs +12 -1
  4. package/bin/agent/studio-context-budget.mjs +8 -2
  5. package/bin/agent/studio-prompt.mjs +1 -1
  6. package/bin/agent/studio-tools.mjs +1 -1
  7. package/dist/app/index.html +4 -4
  8. package/dist/assets/analytics-BibyE5ar.js +6 -0
  9. package/dist/assets/app-DhRJ-PBz.js +5088 -0
  10. package/dist/assets/{compile-worker-BxKXh07s.js → compile-worker-DqiA4AaN.js} +3 -3
  11. package/dist/assets/{first-shot-handoff-DNO8kv_N.js → first-shot-handoff-Qx7SKl38.js} +1 -1
  12. package/dist/assets/helvetiker-bold.typeface-BUlyMz0-.js +13 -0
  13. package/dist/assets/{landing-yfZaJkWc.js → landing-DYg87MDY.js} +1 -1
  14. package/dist/assets/scene-asset-sync-DBYQBR0T.js +20 -0
  15. package/dist/assets/{workflow-D78eiKTa.js → workflow-BSA22Ofr.js} +1 -1
  16. package/dist/cozyclay-package.json +1 -1
  17. package/dist/index.html +3 -3
  18. package/dist/workflow/index.html +3 -3
  19. package/package.json +1 -1
  20. package/src/agent-turn-mark.js +33 -0
  21. package/src/bus-task-scopes.js +69 -0
  22. package/src/bus-task-targets.js +48 -0
  23. package/src/bus-tasks.js +175 -0
  24. package/src/command-bus.js +21 -47
  25. package/src/command-history.js +36 -0
  26. package/src/commands/cast.js +10 -3
  27. package/src/commands/elements/character.js +2 -1
  28. package/src/commands/elements/motion.js +7 -1
  29. package/src/commands/elements/scene.js +3 -0
  30. package/src/commands/elements/stage.js +2 -1
  31. package/src/commands/elements/take-read.js +31 -0
  32. package/src/commands/elements.js +16 -2
  33. package/src/commands/motion.js +2 -1
  34. package/src/commands/motion3.js +13 -5
  35. package/src/commands/objects.js +2 -2
  36. package/src/commands/shared.js +9 -0
  37. package/src/motion3/compiler/anchors.js +89 -5
  38. package/src/motion3/compiler/compile.js +25 -4
  39. package/src/motion3/compiler/grip.js +16 -4
  40. package/src/motion3/compiler/socket-grip.js +131 -0
  41. package/src/motion3/compiler/validate-parts.js +7 -2
  42. package/src/motion3/compiler/vocab.js +6 -0
  43. package/src/motion3/docs.js +1 -1
  44. package/src/motion3/fix/index.js +5 -3
  45. package/src/motion3/free-motion.js +12 -4
  46. package/src/motion3/object-keys.js +36 -5
  47. package/src/motion3/object-pose.js +38 -5
  48. package/src/motion3/pipeline.js +2 -0
  49. package/src/motion3/qa/spec-checks.js +12 -1
  50. package/src/motion3/scene.js +4 -3
  51. package/src/motion3/schema/fields.js +1 -1
  52. package/src/motion3/schema/topics.js +4 -3
  53. package/src/motion3/schema/types.js +3 -2
  54. package/src/motion3/skills/basic.js +68 -14
  55. package/src/motion3/skills/index.js +4 -2
  56. package/src/motion3/skills/info.js +5 -5
  57. package/src/motion3/skills/reach-human.js +139 -0
  58. package/src/motion3/skills/stance.js +92 -0
  59. package/src/motion3/sockets.js +121 -0
  60. package/src/motion3/take-edit.js +1 -1
  61. package/src/motion3/take.js +6 -4
  62. package/src/motion3/views.js +28 -5
  63. package/src/object-catalog.jsx +3 -1
  64. package/src/object-grasp.js +38 -0
  65. package/src/object-look.js +2 -0
  66. package/src/object-travel.js +6 -3
  67. package/src/panels/ObjectLookPanel.jsx +24 -3
  68. package/src/props.jsx +5 -5
  69. package/src/scene-objects.js +19 -11
  70. package/src/shell/details-selection.js +2 -1
  71. package/src/studio-agent-protocol.js +5 -6
  72. package/src/studio-app-binding.js +3 -3
  73. package/src/studio-elements.js +3 -1
  74. package/src/studio-error-codes.js +1 -1
  75. package/src/studio-motion-visual.js +4 -4
  76. package/src/text/fonts/helvetiker-LICENSE.txt +13 -0
  77. package/src/text/fonts/helvetiker-bold.typeface.json +1 -0
  78. package/src/text/text-object.jsx +106 -0
  79. package/src/text/text-record.js +122 -0
  80. package/tools/run-tests.mjs +1 -1
  81. package/dist/assets/analytics-BWCqdgWA.js +0 -1
  82. package/dist/assets/app-DfTCxaKk.js +0 -5087
  83. package/dist/assets/scene-asset-sync-U3kS2rvK.js +0 -20
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.0.1\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"bedf955b7367c9d4e883baa9c0b6fd14a205f6d39a3bf447f628488cd2893564\"}","signature":"+2TEXB+bXFUPN34/ls1YvimKkHfSdSCs0IiIyjIhsHqvz3j+XcKkEkqx1zjXn8MQlybgStGOS290YH40Nw1hBQ=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.0.3\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"b8a07fdd5d546c0314c6099680d3569a89a01e59bd3a442fb2360f1acec56330\"}","signature":"uZinWHO8tjMSZNdv8SjUSnLeHER+UvdGPW6ay++FCJiXOt8eIKjw0bYpN4HJpoNQG6/i9RBBeKMdrPBepn76Ag=="}
package/dist/index.html CHANGED
@@ -301,9 +301,9 @@
301
301
  .btn, a.card, .seg button, .tutorial-steps li { transition: none; }
302
302
  }
303
303
  </style>
304
- <script type="module" crossorigin src="/assets/landing-yfZaJkWc.js"></script>
305
- <link rel="modulepreload" crossorigin href="/assets/analytics-BWCqdgWA.js">
306
- <link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-DNO8kv_N.js">
304
+ <script type="module" crossorigin src="/assets/landing-DYg87MDY.js"></script>
305
+ <link rel="modulepreload" crossorigin href="/assets/analytics-BibyE5ar.js">
306
+ <link rel="modulepreload" crossorigin href="/assets/first-shot-handoff-Qx7SKl38.js">
307
307
  </head>
308
308
  <body>
309
309
  <header class="site">
@@ -8,11 +8,11 @@
8
8
  <meta name="theme-color" content="#101116" />
9
9
  <meta name="description" content="CozyClay visual workflow builder" />
10
10
  <title>CozyClay Workflow</title>
11
- <script type="module" crossorigin src="/assets/workflow-D78eiKTa.js"></script>
11
+ <script type="module" crossorigin src="/assets/workflow-BSA22Ofr.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
13
- <link rel="modulepreload" crossorigin href="/assets/analytics-BWCqdgWA.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/analytics-BibyE5ar.js">
14
14
  <link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
15
- <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-U3kS2rvK.js">
15
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-DBYQBR0T.js">
16
16
  <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-_S8NQl5B.css">
17
17
  <link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
18
18
  </head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozyclay",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
5
5
  "scripts": {
6
6
  "dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
@@ -23,8 +23,41 @@ export function markAgentTurn(id, turn) {
23
23
  const { mark } = turn;
24
24
  if (!mark) throw new StudioProtocolError('STALE_TARGET', 'The agent turn has no mark to roll back to.');
25
25
  turn.mark = null;
26
+ turn.edits = (turn.edits ?? []).filter(edit => edit.mark !== mark);
26
27
  // No session at the mark: everything the turn authored came after it.
27
28
  turn.session?.rollback(mark.point ?? undefined);
28
29
  turn.affectedIds = mark.affectedIds;
29
30
  return { txId: turn.txId, affectedIds: [...mark.since], toasts: [] };
30
31
  }
32
+
33
+ // The turn's own edits, newest last: each keeps the savepoint before it, so undo_edit on the
34
+ // newest one's receipt rolls back to there and the turn stays open (9240db36 was refused twice).
35
+ /** Where the turn stands before a command runs: the savepoint, the ids so far, the open mark. */
36
+ export const turnEditPoint = turn => turn && { point: turn.session?.savepoint() ?? null, affectedIds: new Set(turn.affectedIds), since: turn.mark && new Set(turn.mark.since), mark: turn.mark };
37
+
38
+ /** Files an authored receipt of the turn with the point before it; answers the receipt. */
39
+ export function recordTurnEdit(turn, at, recorded) {
40
+ if (turn && at && recorded?.ok && recorded.authored) (turn.edits ??= []).push({ ...at, receiptId: recorded.receiptId, ids: recorded.affectedIds });
41
+ return recorded;
42
+ }
43
+
44
+ /**
45
+ * edit.undo from inside the owning turn once it has authored. A receipt of the turn's newest edit
46
+ * rolls back to the point before it and answers the receipt (before: the bus's read at admission;
47
+ * read: a read after); anything else is refused with what the agent can do instead.
48
+ */
49
+ export function undoTurnEdit(turn, args, request, before, read) {
50
+ const edits = turn.edits ?? [], at = edits.findIndex(edit => edit.receiptId === args.receiptId);
51
+ if (at < 0) throw new StudioProtocolError('TARGET_BUSY', `Finish the agent turn before traversing history or opening another transaction. Inside the turn, undo_edit with the receiptId of this message's newest edit reverts it${edits.length ? ` (now ${edits.at(-1).receiptId})` : ''}; an earlier message's edit is undone after this one ends.`);
52
+ const edit = edits[at];
53
+ if (at < edits.length - 1) throw new StudioProtocolError('UNDO_CONFLICT', `Later edits of this message come after it; undo them first, newest first: ${edits.slice(at + 1).reverse().map(later => later.receiptId).join(', ')}.`);
54
+ turn.session.rollback(edit.point ?? undefined);
55
+ edits.pop();
56
+ turn.affectedIds = edit.affectedIds;
57
+ // Nothing came after a mark this edit predates, so the mark moves to now: a script's rollback still finds it.
58
+ if (turn.mark && edit.mark !== turn.mark) turn.mark = { point: turn.session.savepoint(), affectedIds: new Set(turn.affectedIds), since: new Set() };
59
+ else if (turn.mark) turn.mark.since = edit.since;
60
+ return { ok: true, status: 'completed', kind: 'transaction', action: 'edit.undo', txId: turn.txId, commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
61
+ summary: `Undid ${edit.receiptId} inside this message.`, authored: false, revision: { before: before.revision, after: read().revision }, affectedIds: edit.ids, delta: [],
62
+ checks: { coverage: 'agent-turn-savepoint' }, undo: null, warnings: [], output: { undoneReceiptId: edit.receiptId } };
63
+ }
@@ -0,0 +1,69 @@
1
+ // The document targets a command writes, known before it runs, for tasks that edit one scene at once (parallel Studio
2
+ // tasks, P1). A key names one target: object:<id> (a group carries its subtree), character:<id> (its placement, its
3
+ // take, its IK), shot:<id> (its camera, rail and keys), or one of the singletons stage (light, environment, lens),
4
+ // timeline (frame count, shot order and ranges) and scene (documents, the cast library, a replace of a whole kind).
5
+ // timeline and scene are exclusive: no other task runs beside them. A command that only creates (object.add, a kit,
6
+ // character.add) needs no key: what it makes joins its task. A command this table does not know writes scene, so a
7
+ // new command is safe until it is named here.
8
+ const EXCLUSIVE_KEYS = new Set(['timeline', 'scene']);
9
+ const KEY = /^(?:(?:object|character|shot):[A-Za-z0-9][A-Za-z0-9._:-]*|stage|timeline|scene)$/;
10
+ export const isScopeKey = key => typeof key === 'string' && key.length <= 140 && KEY.test(key);
11
+
12
+ const SCENE = ['cast.replace', 'cast.setCustomPoses', 'cast.showExtras', 'cast.removePose', 'objects.replace', 'shot.replace', 'ai.prepareShot'];
13
+ const TIMELINE = ['shot.create', 'shot.createStill', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.reorder', 'shot.setRange', 'shot.setTimeline'];
14
+ const CREATE = ['object.add', 'object.createKit', 'asset.import', 'character.add'];
15
+ const CHARACTER = /^(character\.|characters?\b|cast\.(savePose|setLayer)$|ik\.|motion\.)/;
16
+
17
+ /** object:<id> and every object under it. */
18
+ function objectKeys(ids, state) {
19
+ const rows = state?.objects ?? [], out = new Set();
20
+ const visit = id => { if (!id || out.has(`object:${id}`)) return; out.add(`object:${id}`); for (const row of rows) if (row.parent === id) visit(row.id); };
21
+ for (const id of ids) visit(id);
22
+ return [...out];
23
+ }
24
+ const arrangeKeys = (ops, state) => ops.flatMap(op => op.op === 'create' ? [] : op.op === 'group' ? objectKeys([op.parentId, ...op.childIds], state)
25
+ : op.op === 'ungroup' ? objectKeys(op.childIds, state) : objectKeys([op.id], state));
26
+
27
+ /**
28
+ * The keys command `id` with validated `args` writes against state `state` (the bus's read), or [] when it writes
29
+ * none (a read, a view change, a pure creation). `entry` is its registry entry (kind, readOnly).
30
+ */
31
+ export function commandKeys(id, args = {}, state = {}, entry = {}) {
32
+ if (entry.kind === 'document' || /^(scene|project)\./.test(id) || id === 'load_scenes' || SCENE.includes(id)) return ['scene'];
33
+ if (entry.readOnly || entry.kind === 'transient' || /^(view|timeline)\./.test(id) || ['motion.qa', 'motion.sample', 'motion.describe', 'motion.report', 'sculpt.compare', 'export.shotVideo'].includes(id)) return [];
34
+ if (CREATE.includes(id)) return [];
35
+ if (id === 'objects.batch') return [...new Set(args.ops.flatMap(op => commandKeys(op.name, op.args, state)))];
36
+ if (id === 'objects.arrange') return [...new Set(arrangeKeys(args.ops, state))];
37
+ if (id === 'characters.arrange') return args.ops.flatMap(op => op.op === 'create' ? [] : [`character:${op.characterId}`]);
38
+ if (id.startsWith('stage.') || id === 'shot.setLens') return ['stage'];
39
+ if (TIMELINE.includes(id)) return ['timeline'];
40
+ if (id.startsWith('shot.')) {
41
+ const shot = args.shotId ?? args.id ?? state.selectedShotId;
42
+ return shot ? [`shot:${shot}`] : ['timeline'];
43
+ }
44
+ if (id === 'object.group') return objectKeys([args.parent, ...args.children], state);
45
+ if (id === 'object.ungroup') return objectKeys(args.children, state);
46
+ if (id === 'object.remove') return objectKeys(args.ids, state);
47
+ if (id === 'object.attach' || id === 'motion.keyObject') return [`character:${args.characterId}`, ...objectKeys([args.objectId], state)];
48
+ if (id.startsWith('object.')) return objectKeys([args.id ?? args.objectId], state);
49
+ if (CHARACTER.test(id) && (args.characterId ?? args.id)) return [`character:${args.characterId ?? args.id}`];
50
+ return ['scene'];
51
+ }
52
+
53
+ /** The key an id a receipt names resolves to in state, or null (the scene's own id, a removed target). */
54
+ export function keyOfId(id, state = {}) {
55
+ if ((state.objects ?? []).some(row => row.id === id)) return `object:${id}`;
56
+ if ((state.characters ?? []).some(row => row.id === id)) return `character:${id}`;
57
+ if ((state.shots ?? []).some(row => row.id === id)) return `shot:${id}`;
58
+ return null;
59
+ }
60
+
61
+ /** Why `keys` cannot join task `taskId` beside the other open tasks, or null. */
62
+ export function scopeConflict(taskId, keys, tasks) {
63
+ for (const other of tasks.values()) {
64
+ if (other.taskId === taskId) continue;
65
+ const held = keys.find(key => other.keys.has(key) || EXCLUSIVE_KEYS.has(key) || [...other.keys].some(k => EXCLUSIVE_KEYS.has(k)));
66
+ if (held) return `${held} is held by task ${other.taskId}${other.title ? ` (${other.title})` : ''}`;
67
+ }
68
+ return null;
69
+ }
@@ -0,0 +1,48 @@
1
+ // What a task's scope keys are in the editor's document stores, for the bus's tasks (bus-tasks.js): a snapshot of a
2
+ // key is its rows (an object's row; a character's cast row and motion layer; a shot's row) or, for stage and timeline,
3
+ // the whole slices; restoring puts every row back where it stood (or removes a row the task created) in one composed
4
+ // entry, so undo, history and saves see an ordinary edit. scene is not restorable: documents have no undo.
5
+ import { StudioProtocolError } from './studio-agent-protocol.js';
6
+
7
+ const ROWS = { object: [['objects', null]], character: [['cast', 'characters'], ['motion', null]], shot: [['shot', 'shots']] };
8
+ const WHOLE = { stage: ['stage'], timeline: ['shot', 'motion'] };
9
+ const split = key => { const at = key.indexOf(':'); return at < 0 ? [key] : [key.slice(0, at), key.slice(at + 1)]; };
10
+
11
+ function put(slice, part) {
12
+ if ('whole' in part) return part.whole;
13
+ const rows = (part.path ? slice[part.path] : slice).filter(row => row.id !== part.id);
14
+ if (part.row) rows.splice(Math.min(part.index, rows.length), 0, part.row);
15
+ return part.path ? { ...slice, [part.path]: rows } : rows;
16
+ }
17
+
18
+ /** snapshotTarget(key) and restoreTargets([{ key, value }]) over the app's store domains (ports.storeDomain, ports.beginAction). */
19
+ export function taskTargetPorts(ports) {
20
+ const store = domain => ports.storeDomain?.(domain)?.documentStore;
21
+ const rowParts = (kind, id, read) => ROWS[kind].filter(([domain]) => store(domain)).map(([domain, path]) => {
22
+ const slice = store(domain).read(domain), rows = (path ? slice?.[path] : slice) ?? [];
23
+ const index = read ? rows.findIndex(row => row.id === id) : -1;
24
+ return { domain, path, id, index, row: index < 0 ? undefined : rows[index] };
25
+ });
26
+ return {
27
+ snapshotTarget(key) {
28
+ const [kind, id] = split(key);
29
+ if (WHOLE[kind]) return WHOLE[kind].filter(store).map(domain => ({ domain, whole: store(domain).read(domain) }));
30
+ return ROWS[kind] ? rowParts(kind, id, true) : null;
31
+ },
32
+ restoreTargets(values) {
33
+ const parts = values.flatMap(({ key, value }) => {
34
+ if (value === null) throw new StudioProtocolError('CAPABILITY_MISSING', `${key} cannot be restored: a document change has no undo.`);
35
+ const [kind, id] = split(key);
36
+ return value ?? rowParts(kind, id, false);
37
+ });
38
+ const domains = [...new Set(parts.map(part => part.domain))];
39
+ if (!domains.length) return { historyEntryId: null };
40
+ const session = ports.beginAction(domains[0], null);
41
+ try {
42
+ for (const domain of domains.slice(1)) session.touch(domain);
43
+ session.run(() => { for (const part of parts) store(part.domain).write(part.domain, slice => put(slice, part)); });
44
+ return session.commit();
45
+ } catch (error) { session.cancel(); throw error; }
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,175 @@
1
+ // Tasks: several agents editing one scene at once (parallel Studio tasks, P1; plan in docs). A task holds a scope of
2
+ // keys (bus-task-scopes.js) instead of the whole-scene turn lock: its commands write only inside it, each command
3
+ // commits as its own short entry (the per-command write lock is the document's own composition), and admission reads
4
+ // the scope's target tokens, not the scene revision, so another task's edit elsewhere never makes it stale. Before a
5
+ // key's first write the task keeps its pre-image (ports.snapshotTarget); cancel and undo-task put the pre-images back
6
+ // as one new entry (ports.restoreTargets), which touches no other task's keys. A mark and rollback (a script's start)
7
+ // and undo_edit of the task's newest edit restore the same way. The user's own edits are never refused: the task's
8
+ // next command on a target the user changed answers STALE_TARGET once. Ctrl+Z stops at a running task's entry.
9
+ // A single agent turn (agent.turn.*) is untouched: tasks and a turn never run together.
10
+ import { StudioProtocolError, validateReceipt } from './studio-agent-protocol.js';
11
+ import { commandKeys, keyOfId, scopeConflict, isScopeKey } from './bus-task-scopes.js';
12
+
13
+ const fail = (code, message) => { throw new StudioProtocolError(code, message); };
14
+ const VERBS = ['begin', 'requestScope', 'mark', 'rollback', 'finish', 'cancel', 'undo'];
15
+ const FINISHED_KEPT = 32;
16
+ const listOf = keys => [...keys].slice(0, 6).join(', ') + (keys.length > 6 ? ` and ${keys.length - 6} more` : '');
17
+
18
+ export function createBusTasks({ ports, emit }) {
19
+ const open = new Map(), finished = new Map(), entryTask = new Map();
20
+ const tokenOf = key => {
21
+ const [kind, id] = key.split(/:(.*)/s);
22
+ if (id !== undefined) return ports.readTarget?.(id) ?? 'absent';
23
+ const state = ports.read();
24
+ return kind === 'scene' ? `r${state.revision}` : `${kind}:${state.domainRevisions?.[kind === 'timeline' ? 'shot' : kind] ?? 0}`;
25
+ };
26
+ const snapshot = key => ({ key, value: ports.snapshotTarget ? ports.snapshotTarget(key) : fail('CAPABILITY_MISSING', 'This editor cannot snapshot task targets.') });
27
+ // A scene key has no restorable value (documents have no undo): it is left as it is and said so.
28
+ function restore(task, values, action) {
29
+ task.skipped = values.filter(v => v.value === null).map(v => v.key);
30
+ values = values.filter(v => v.value !== null);
31
+ if (!values.length) return null;
32
+ if (!ports.restoreTargets) fail('CAPABILITY_MISSING', 'This editor cannot restore task targets.');
33
+ const { historyEntryId = null } = ports.restoreTargets(values) ?? {};
34
+ if (historyEntryId) entryTask.set(historyEntryId, task.taskId);
35
+ task.restored = [...new Set(values.map(({ key }) => key.includes(':') ? key.slice(key.indexOf(':') + 1) : ports.read().host.sceneId))];
36
+ for (const { key } of values) task.tokens.set(key, tokenOf(key));
37
+ emit?.({ type: `task.${action}`, taskId: task.taskId, keys: values.map(v => v.key) });
38
+ return historyEntryId;
39
+ }
40
+ function grant(task, keys) {
41
+ const bad = keys.find(key => !isScopeKey(key));
42
+ if (bad !== undefined) fail('INVALID_ARGUMENT', `"${bad}" is not a scope key: object:<id>, character:<id>, shot:<id>, stage, timeline or scene.`);
43
+ const conflict = scopeConflict(task.taskId, keys, open);
44
+ if (conflict) fail('SCOPE_BUSY', `${conflict}; wait for it, or ask for what is free.`);
45
+ for (const key of keys) if (!task.keys.has(key)) { task.keys.add(key); task.tokens.set(key, tokenOf(key)); }
46
+ }
47
+ function receipt(id, task, request, before, { historyEntryId = null, affectedIds = [], output, warnings = [] } = {}) {
48
+ const after = ports.read();
49
+ if (task.skipped?.length) warnings = [...warnings, { code: 'SCOPE_NOT_RESTORED', message: `Left as they are (a document change has no undo): ${listOf(task.skipped)}.` }];
50
+ task.skipped = null;
51
+ return validateReceipt({ ok: true, status: 'completed', kind: 'transaction', commandId: request.commandId, receiptId: crypto.randomUUID(), host: before.host,
52
+ action: id, txId: task.taskId, authored: Boolean(historyEntryId), revision: { before: before.revision, after: after.revision },
53
+ affectedIds: historyEntryId && !affectedIds.length ? task.restored : affectedIds, delta: [],
54
+ checks: { coverage: 'task-scope' }, warnings, undo: historyEntryId ? { historyEntryId, entries: 1, canUndoDirect: false } : null,
55
+ output: { scope: [...task.keys], ...output } });
56
+ }
57
+ const ownerOf = (request, taskId) => { if (request.origin !== 'agent' || request.taskId !== taskId) fail('CAPABILITY_MISSING', 'Only the owning task can control it.'); };
58
+ return {
59
+ get size() { return open.size; },
60
+ controls: (object, identifier) => Object.fromEntries(VERBS.map(verb => [`task.${verb}`,
61
+ verb === 'begin' || verb === 'requestScope'
62
+ ? { ...object({ taskId: identifier, scope: { type: 'array', items: { type: 'string' }, maxItems: 64 } }), properties: { taskId: identifier, scope: { type: 'array', items: { type: 'string' }, maxItems: 64 }, reads: { type: 'array', items: { type: 'string' }, maxItems: 64 }, title: { type: 'string', maxLength: 120 } } }
63
+ : object({ taskId: identifier })])),
64
+ /** The open task a request names (null without one); a request naming a closed task is refused. */
65
+ of(request, id) {
66
+ if (!request.taskId) return null;
67
+ const task = open.get(request.taskId);
68
+ if (!task && !id.startsWith('task.')) fail('STALE_TARGET', `Task ${request.taskId} is not open.`);
69
+ return task ?? null;
70
+ },
71
+ /** task.* controls. `busy` says why no task may begin (an agent turn or a command transaction is open). */
72
+ control(id, args, request, before, busy) {
73
+ if (id === 'task.undo') {
74
+ const task = finished.get(args.taskId) ?? fail('UNDO_EXPIRED', `Task ${args.taskId} is not a finished task this editor still holds.`);
75
+ const moved = [...task.final].filter(([key, token]) => tokenOf(key) !== token).map(([key]) => key);
76
+ if (moved.length) fail('UNDO_CONFLICT', `Changed since the task finished: ${listOf(moved)}; undo those first or edit by hand.`);
77
+ const historyEntryId = restore(task, [...task.pre.values()], 'undone');
78
+ finished.delete(args.taskId);
79
+ return receipt(id, task, request, before, { historyEntryId, affectedIds: task.affectedIds });
80
+ }
81
+ ownerOf(request, args.taskId);
82
+ if (id === 'task.begin') {
83
+ if (busy) fail('TARGET_BUSY', busy);
84
+ if (open.has(args.taskId) || finished.has(args.taskId)) fail('INVALID_ARGUMENT', `Task ${args.taskId} already exists.`);
85
+ const task = { taskId: args.taskId, title: args.title ?? '', keys: new Set(), reads: new Set(args.reads ?? []), tokens: new Map(), pre: new Map(), edits: [], notices: [], affectedIds: [] };
86
+ grant(task, args.scope);
87
+ open.set(task.taskId, task);
88
+ emit?.({ type: 'task.begun', taskId: task.taskId, scope: [...task.keys] });
89
+ return receipt(id, task, request, before);
90
+ }
91
+ const task = open.get(args.taskId) ?? fail('STALE_TARGET', `Task ${args.taskId} is not open.`);
92
+ if (id === 'task.requestScope') { grant(task, args.scope); return receipt(id, task, request, before); }
93
+ if (id === 'task.mark') { task.mark = { values: [...task.keys].map(snapshot), keys: new Set(task.keys) }; return receipt(id, task, request, before); }
94
+ if (id === 'task.rollback') {
95
+ const mark = task.mark ?? fail('STALE_TARGET', 'The task has no mark to roll back to.');
96
+ task.mark = null;
97
+ const after = [...task.keys].filter(key => !mark.keys.has(key)).map(key => task.pre.get(key) ?? snapshot(key));
98
+ const historyEntryId = restore(task, [...mark.values, ...after], 'rolledBack');
99
+ task.edits = task.edits.filter(edit => edit.mark !== mark);
100
+ return receipt(id, task, request, before, { historyEntryId });
101
+ }
102
+ open.delete(task.taskId);
103
+ if (id === 'task.cancel') {
104
+ const historyEntryId = restore(task, [...task.pre.values()], 'cancelled');
105
+ return receipt(id, task, request, before, { historyEntryId, affectedIds: task.affectedIds });
106
+ }
107
+ task.final = new Map([...task.pre.keys()].map(key => [key, tokenOf(key)]));
108
+ finished.set(task.taskId, task);
109
+ while (finished.size > FINISHED_KEPT) finished.delete(finished.keys().next().value);
110
+ emit?.({ type: 'task.finished', taskId: task.taskId });
111
+ return receipt(id, task, request, before, { affectedIds: task.affectedIds, output: { edits: task.edits.length } });
112
+ },
113
+ /**
114
+ * Admits a command: a task writes only inside its scope, on targets nobody else changed since it last looked; a
115
+ * wire caller outside any task may not write a running task's keys; the UI always may. Answers the point a
116
+ * task's edit restores to (undo_edit of it), after keeping each key's first pre-image.
117
+ */
118
+ admit(task, entry, args, request, before) {
119
+ if (!task && (request.origin === 'ui' || !open.size)) return null;
120
+ const keys = entry?.id ? commandKeys(entry.id, args, before, entry) : [];
121
+ if (!task) {
122
+ const held = keys.find(key => scopeConflict(null, [key], open));
123
+ if (held) fail('SCOPE_BUSY', `${scopeConflict(null, [held], open)}; this command waits for that task.`);
124
+ return null;
125
+ }
126
+ const outside = keys.filter(key => !task.keys.has(key));
127
+ if (outside.length) fail('SCOPE_DENIED', `Outside this task's scope: ${listOf(outside)}. Ask for it with task.requestScope, or leave it to the task that holds it.`);
128
+ const moved = keys.filter(key => task.tokens.has(key) && tokenOf(key) !== task.tokens.get(key));
129
+ if (moved.length) {
130
+ for (const key of moved) task.tokens.set(key, tokenOf(key));
131
+ fail('STALE_TARGET', `${listOf(moved)} changed outside this task (the user, or an undo) since it last looked; read it again, then retry.`);
132
+ }
133
+ for (const key of keys) if (!task.pre.has(key)) task.pre.set(key, snapshot(key));
134
+ return { task, keys, values: keys.map(snapshot), mark: task.mark };
135
+ },
136
+ /** Files a task's receipt: ids it created join its scope, it may be undone newest first, readers hear of it. */
137
+ record(point, recorded) {
138
+ if (!point || !recorded?.ok) return recorded;
139
+ const { task } = point, state = ports.read(), warnings = [];
140
+ for (const id of recorded.affectedIds ?? []) {
141
+ const key = keyOfId(id, state);
142
+ if (!key || task.keys.has(key)) continue;
143
+ if (scopeConflict(task.taskId, [key], open)) warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` });
144
+ else { task.keys.add(key); if (!point.keys.includes(key)) { task.pre.set(key, { key, value: undefined }); point.values.push({ key, value: undefined }); } }
145
+ }
146
+ for (const key of task.keys) task.tokens.set(key, tokenOf(key));
147
+ if (recorded.authored) {
148
+ task.edits.push({ receiptId: recorded.receiptId, values: point.values, keys: point.keys, mark: point.mark });
149
+ task.affectedIds = [...new Set([...task.affectedIds, ...recorded.affectedIds])];
150
+ if (recorded.undo?.historyEntryId) entryTask.set(recorded.undo.historyEntryId, task.taskId);
151
+ for (const other of open.values()) for (const key of point.keys) if (other !== task && other.reads.has(key)) {
152
+ other.notices.push({ code: 'DEPENDENCY_CHANGED', message: `${key}, which this task reads, was changed by task ${task.taskId}.` });
153
+ emit?.({ type: 'task.dependencyChanged', taskId: other.taskId, key, by: task.taskId });
154
+ }
155
+ }
156
+ const heard = task.notices.splice(0);
157
+ return heard.length || warnings.length ? { ...recorded, warnings: [...recorded.warnings, ...warnings, ...heard].slice(0, 12) } : recorded;
158
+ },
159
+ /** History traversal: undo_edit of a task's newest edit inside it; the UI's undo stops at a running task's entry. */
160
+ traversal(id, args, request, before, frontier) {
161
+ const task = request.taskId ? open.get(request.taskId) : null;
162
+ if (task && id === 'edit.undo') {
163
+ const at = task.edits.findIndex(edit => edit.receiptId === args.receiptId);
164
+ if (at < 0) fail('TARGET_BUSY', `Inside a task, undo_edit takes the receiptId of the task's own newest edit${task.edits.length ? ` (now ${task.edits.at(-1).receiptId})` : ''}.`);
165
+ if (at < task.edits.length - 1) fail('UNDO_CONFLICT', `Later edits of this task come after it; undo them first, newest first: ${task.edits.slice(at + 1).reverse().map(edit => edit.receiptId).join(', ')}.`);
166
+ const edit = task.edits.pop(), historyEntryId = restore(task, edit.values, 'editUndone');
167
+ return receipt(id, task, request, before, { historyEntryId, output: { undoneReceiptId: edit.receiptId } });
168
+ }
169
+ const owner = open.get(entryTask.get(frontier));
170
+ if (!task && owner) fail('TARGET_BUSY', `The last edit belongs to running task ${owner.taskId}${owner.title ? ` (${owner.title})` : ''}; cancel or undo it from that task.`);
171
+ return null;
172
+ },
173
+ dispose() { open.clear(); finished.clear(); entryTask.clear(); },
174
+ };
175
+ }
@@ -1,40 +1,11 @@
1
1
  // The Studio command runner. The registry owns declarations/implementations;
2
2
  // the editor ports own native state/history, and the existing journal owns
3
3
  // idempotency. Synchronous UI actions stay synchronous (including user gestures).
4
- import { HISTORY_LIMIT } from './history.js';
5
4
  import { StudioProtocolError, StudioSchemas, validateStudioSchema, validateReceipt, validateStudioIdentity, refusalCode } from './studio-agent-protocol.js';
6
- import { agentTurnControls, markAgentTurn } from './agent-turn-mark.js';
5
+ import { agentTurnControls, markAgentTurn, turnEditPoint, recordTurnEdit, undoTurnEdit } from './agent-turn-mark.js';
6
+ import { createBusTasks } from './bus-tasks.js';
7
7
 
8
- // A batching/retention adapter over the existing object history owner. The
9
- // native store still owns every snapshot and transition; this tracks only the
10
- // retained pre-image identities needed by receipts.
11
- export function withCommandHistory(store) {
12
- let past = [], future = [], group = null;
13
- const adapter = Object.create(store);
14
- const pushed = before => { if (before !== store.present()) { past.push(before); past = past.slice(-HISTORY_LIMIT); future = []; } };
15
- adapter.hasHistoryState = state => state === store.present() || past.includes(state) || future.includes(state);
16
- adapter.settle = (...args) => { const before = store.present(); store.settle(...args); pushed(before); };
17
- adapter.applyAtomic = fn => {
18
- if (group) { if (!group.running) fail('TARGET_BUSY', 'A command transaction owns the object history.'); return store.applyIn(group.token, fn); }
19
- adapter.settle(); const before = store.present(); store.applyAtomic(fn); pushed(before);
20
- };
21
- adapter.begin = (...args) => { adapter.settle(); return store.begin(...args); };
22
- adapter.end = (token, options) => { const before = store.present(), ended = store.end(token, options); if (options.commit) pushed(before); return ended; };
23
- adapter.undo = () => { adapter.settle(); const before = store.present(), restored = store.undo(); if (restored !== null) { past.pop(); future.unshift(before); } return restored; };
24
- adapter.redo = () => { const before = store.present(), restored = store.redo(); if (restored !== null) { past.push(before); future.shift(); } return restored; };
25
- adapter.beginCommand = () => {
26
- if (group) fail('TARGET_BUSY', 'An object command is already in progress.');
27
- adapter.settle(); const before = store.present();
28
- const current = { running: 0, token: store.begin('command-bus', () => { current.closed = true; group = null; }) }; group = current;
29
- const check = () => { if (current.closed) fail('STALE_TARGET', 'Native object transaction was retired.'); };
30
- return {
31
- run(fn) { check(); current.running++; try { const result = fn(); if (result?.then) return result.finally(() => { current.running--; }); current.running--; return result; } catch (error) { current.running--; throw error; } },
32
- commit() { check(); current.closed = true; group = null; store.end(current.token, { commit: true }); pushed(before); return before !== store.present(); },
33
- cancel() { if (current.closed) return false; current.closed = true; group = null; return store.end(current.token, { commit: false }); },
34
- };
35
- };
36
- return adapter;
37
- }
8
+ export { withCommandHistory } from './command-history.js';
38
9
 
39
10
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
40
11
  const fail = (code, message) => { throw new StudioProtocolError(code, message); };
@@ -47,6 +18,7 @@ export function createCommandBus({ registry, ports }) {
47
18
  const pending = new Map(), transactions = new Map(), jobs = new Map(), listeners = new Set();
48
19
  const confirmations = new Map(), historyReceipts = new Map();
49
20
  let agentTurn = null;
21
+ const tasks = createBusTasks({ ports, emit: event => emit(event) });
50
22
  function exposure(entry, args, request) {
51
23
  if (request.origin === 'ui') return;
52
24
  if (entry.exposure === 'ui-only') fail('CAPABILITY_MISSING', entry.uiOnlyHint ?? 'This command is available only from the Studio UI.');
@@ -63,7 +35,7 @@ export function createCommandBus({ registry, ports }) {
63
35
  const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
64
36
  const argsSchema = { type: 'object', properties: {}, required: [], additionalProperties: true };
65
37
  const controls = {
66
- ...agentTurnControls(object, identifier),
38
+ ...agentTurnControls(object, identifier), ...tasks.controls(object, identifier),
67
39
  'run.begin': object({ id: identifier, args: argsSchema }),
68
40
  'run.update': object({ txId: identifier, args: argsSchema }),
69
41
  'run.commit': object({ txId: identifier }),
@@ -84,10 +56,11 @@ export function createCommandBus({ registry, ports }) {
84
56
  tx.timer = (ports.setTimeout ?? setTimeout)(() => cancelTransaction(tx, true), ports.transactionIdleMs ?? 30_000);
85
57
  }
86
58
  function control(id, args, request, before) {
59
+ if (id.startsWith('task.')) return tasks.control(id, args, request, before, agentTurn ? 'Finish the agent turn first.' : transactions.size ? 'Finish the open command transaction first.' : null);
87
60
  if (id.startsWith('agent.turn.')) {
88
61
  if (request.origin !== 'agent' || request.turnId !== args.turnId) fail('CAPABILITY_MISSING', 'Only the owning agent turn can control this transaction.');
89
62
  if (id === 'agent.turn.begin') {
90
- if (agentTurn || transactions.size) fail('TARGET_BUSY', 'Finish the open transaction first.');
63
+ if (agentTurn || transactions.size || tasks.size) fail('TARGET_BUSY', 'Finish the open transaction first.');
91
64
  agentTurn = { txId: args.turnId, before, affectedIds: new Set(), toasts: [], session: null };
92
65
  return transactionReceipt(id, agentTurn, request, before);
93
66
  }
@@ -102,12 +75,12 @@ export function createCommandBus({ registry, ports }) {
102
75
  finally { agentTurn = null; }
103
76
  return transactionReceipt(id, turn, request, turn.before);
104
77
  }
105
- // Every Studio turn opens a turn transaction (#715), so "undo the last thing" arrives
106
- // inside one. Until the turn has authored anything (no session yet) history traversal
107
- // is safe: the undo lands as its own entry and a later edit still groups into the turn.
108
- // After the first authored edit the turn owns the frontier and traversal waits (#735).
109
- const traversal = id === 'edit.undo' || id === 'edit.redo';
110
- if (agentTurn && !id.startsWith('job.') && !(traversal && !agentTurn.session)) fail('TARGET_BUSY', 'Finish the agent turn before traversing history or opening another transaction.');
78
+ // Every Studio turn opens a turn transaction (#715), so "undo the last thing" arrives inside one. Until the
79
+ // turn has authored (no session yet) history traversal is safe: the undo lands as its own entry (#735).
80
+ // After that the turn owns the frontier: its own agent may undo its newest edit (agent-turn-mark.js).
81
+ const traversal = id === 'edit.undo' || id === 'edit.redo', scoped = traversal && tasks.traversal(id, args, request, before, id === 'edit.undo' ? ports.history?.(false) : null);
82
+ if (scoped) return scoped;
83
+ if (agentTurn && !id.startsWith('job.') && !(traversal && !agentTurn.session)) { if (id === 'edit.undo' && request.turnId === agentTurn.txId) return validateReceipt(undoTurnEdit(agentTurn, args, request, before, () => ports.read())); fail('TARGET_BUSY', 'Finish the agent turn before traversing history or opening another transaction.'); }
111
84
  if (traversal) {
112
85
  const redo = id === 'edit.redo', historyEntryId = ports.history?.(redo);
113
86
  const previous = args.receiptId ? ports.receipt(args.receiptId) : historyReceipts.get(historyEntryId)
@@ -243,7 +216,7 @@ export function createCommandBus({ registry, ports }) {
243
216
  if (agentTurn && !same(agentTurn.before.host, before.host)) {
244
217
  agentTurn.session?.cancel({ restore: false }); agentTurn = null;
245
218
  }
246
- const turn = request.origin === 'agent' && request.turnId === agentTurn?.txId ? agentTurn : null;
219
+ const turn = request.origin === 'agent' && request.turnId === agentTurn?.txId ? agentTurn : null, task = tasks.of(request, id);
247
220
  const recordAction = (domain, invoke, targetId, nested = false) => {
248
221
  if (!turn) return ports.recordAction(domain, invoke, targetId, nested);
249
222
  // Enrol each owner in the existing composed session. Do not commit (or
@@ -294,13 +267,14 @@ export function createCommandBus({ registry, ports }) {
294
267
  // can advance the revision while their wire request is in transit.
295
268
  // Document identity and the job's own publication fence still apply.
296
269
  // A readOnly action (declared data, e.g. motion.qa) only measures: no revision or gesture fence, the host check above still holds.
297
- if (!entry?.readOnly && !['job.await', 'job.cancel', 'agent.turn.mark', 'agent.turn.rollback', 'agent.turn.finish', 'agent.turn.cancel'].includes(id) && request.expectedRevision !== before.revision) fail('STALE_SCENE', 'Authored state changed; obtain fresh intent.');
270
+ if (!task && !id.startsWith('task.') && !entry?.readOnly && !['job.await', 'job.cancel', 'agent.turn.mark', 'agent.turn.rollback', 'agent.turn.finish', 'agent.turn.cancel'].includes(id) && request.expectedRevision !== before.revision) fail('STALE_SCENE', 'Authored state changed; obtain fresh intent.');
298
271
  if (!entry?.readOnly && before.busy && !turn && !transactions.has(validated.txId) && !id.startsWith('job.')) fail('TARGET_BUSY', 'Finish the current editor gesture first.');
299
272
  }
300
273
  if (controls[id]) return mapResult(control(id, validated, request, before), remember, rejected);
301
274
  if (transactions.size) fail('TARGET_BUSY', 'Finish or cancel the open command transaction first.');
302
275
  if (turn && entry.kind === 'document') fail('TARGET_BUSY', 'Finish the agent turn before changing documents.');
303
276
  exposure(entry, validated, request);
277
+ const scoped = tasks.admit(task, entry, validated, request, before);
304
278
  releaseToasts = ports.captureToasts?.(toast => toasts.push(typeof toast === 'string' ? { message: toast } : toast));
305
279
  const domain = entry.domain ?? entry.undoDomain;
306
280
  const targetId = entry.target?.(validated, before) ?? validated.characterId ?? validated.shotId ?? validated.objectId;
@@ -318,7 +292,7 @@ export function createCommandBus({ registry, ports }) {
318
292
  const current = ports.read();
319
293
  // Name the fence that tripped: the three share one code, and a bare "something changed" leaves the user and the agent guessing.
320
294
  if (!same(current.host, before.host)) fail('STALE_TARGET', 'The open document or scene changed while the job was running.');
321
- if (domain && current.domainRevisions?.[domain] !== domainRevision) fail('STALE_TARGET', `The ${domain} data changed while the job was running (revision ${domainRevision} to ${current.domainRevisions?.[domain]}); an edit landed between the request and its result.`);
295
+ if (domain && !task && current.domainRevisions?.[domain] !== domainRevision) fail('STALE_TARGET', `The ${domain} data changed while the job was running (revision ${domainRevision} to ${current.domainRevisions?.[domain]}); an edit landed between the request and its result.`);
322
296
  if (targetId && ports.readTarget?.(targetId) !== token) fail('STALE_TARGET', `${targetId} changed while the job was running.`);
323
297
  },
324
298
  commit(apply) {
@@ -358,13 +332,13 @@ export function createCommandBus({ registry, ports }) {
358
332
  const deadline = new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true }));
359
333
  return Promise.race([value, deadline]);
360
334
  };
361
- const value = entry.kind === 'mutation' ? recordAction(entry.undoDomain, invoke, validated.characterId ?? null) : { result: invoke(), historyEntryId: null };
335
+ const point = turnEditPoint(turn), value = entry.kind === 'mutation' ? recordAction(entry.undoDomain, invoke, validated.characterId ?? null) : { result: invoke(), historyEntryId: null };
362
336
  const finish = ({ result, historyEntryId }) => mapResult(result, output => {
363
337
  clearTimer();
364
338
  releaseToasts?.(); releaseToasts = null;
365
- const refused = toastRefusal();
339
+ const refused = output?.output === undefined ? toastRefusal() : null; // a job that answered was not refused by a toast that showed meanwhile
366
340
  if (refused) throw refused;
367
- return remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts, turn));
341
+ return tasks.record(scoped, recordTurnEdit(turn, point, remember(receipt(entry, request, before, { ...output, affectedIds: [...new Set([...output.affectedIds, ...nestedIds])] }, historyEntryId ?? committedHistoryId, toasts, turn))));
368
342
  });
369
343
  const finished = value?.then ? value.then(finish) : finish(value);
370
344
  const answer = finished?.then ? finished.catch(rejected) : finished;
@@ -406,5 +380,5 @@ export function createCommandBus({ registry, ports }) {
406
380
  // Trusted UI adapter only: wire callers can consume, never mint a token.
407
381
  confirm(id, args = {}) { const prepared = registry.prepare(id, args), token = crypto.randomUUID(); confirmations.set(token, { id, args: prepared.args, host: ports.read().host, expires: (ports.now ?? Date.now)() + 300_000 }); return token; },
408
382
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
409
- dispose() { agentTurn?.session?.cancel(); agentTurn = null; for (const tx of transactions.values()) cancelTransaction(tx); for (const job of jobs.values()) if (!job.outcome) job.controller.abort(new StudioProtocolError('CANCELLED', 'Studio closed.')); listeners.clear(); } };
383
+ dispose() { agentTurn?.session?.cancel(); agentTurn = null; tasks.dispose(); for (const tx of transactions.values()) cancelTransaction(tx); for (const job of jobs.values()) if (!job.outcome) job.controller.abort(new StudioProtocolError('CANCELLED', 'Studio closed.')); listeners.clear(); } };
410
384
  }
@@ -0,0 +1,36 @@
1
+ // The object history adapter the command bus uses (moved out of command-bus.js unchanged).
2
+ import { HISTORY_LIMIT } from './history.js';
3
+ import { StudioProtocolError } from './studio-agent-protocol.js';
4
+
5
+ const fail = (code, message) => { throw new StudioProtocolError(code, message); };
6
+
7
+ // A batching/retention adapter over the existing object history owner. The
8
+ // native store still owns every snapshot and transition; this tracks only the
9
+ // retained pre-image identities needed by receipts.
10
+ export function withCommandHistory(store) {
11
+ let past = [], future = [], group = null;
12
+ const adapter = Object.create(store);
13
+ const pushed = before => { if (before !== store.present()) { past.push(before); past = past.slice(-HISTORY_LIMIT); future = []; } };
14
+ adapter.hasHistoryState = state => state === store.present() || past.includes(state) || future.includes(state);
15
+ adapter.settle = (...args) => { const before = store.present(); store.settle(...args); pushed(before); };
16
+ adapter.applyAtomic = fn => {
17
+ if (group) { if (!group.running) fail('TARGET_BUSY', 'A command transaction owns the object history.'); return store.applyIn(group.token, fn); }
18
+ adapter.settle(); const before = store.present(); store.applyAtomic(fn); pushed(before);
19
+ };
20
+ adapter.begin = (...args) => { adapter.settle(); return store.begin(...args); };
21
+ adapter.end = (token, options) => { const before = store.present(), ended = store.end(token, options); if (options.commit) pushed(before); return ended; };
22
+ adapter.undo = () => { adapter.settle(); const before = store.present(), restored = store.undo(); if (restored !== null) { past.pop(); future.unshift(before); } return restored; };
23
+ adapter.redo = () => { const before = store.present(), restored = store.redo(); if (restored !== null) { past.push(before); future.shift(); } return restored; };
24
+ adapter.beginCommand = () => {
25
+ if (group) fail('TARGET_BUSY', 'An object command is already in progress.');
26
+ adapter.settle(); const before = store.present();
27
+ const current = { running: 0, token: store.begin('command-bus', () => { current.closed = true; group = null; }) }; group = current;
28
+ const check = () => { if (current.closed) fail('STALE_TARGET', 'Native object transaction was retired.'); };
29
+ return {
30
+ run(fn) { check(); current.running++; try { const result = fn(); if (result?.then) return result.finally(() => { current.running--; }); current.running--; return result; } catch (error) { current.running--; throw error; } },
31
+ commit() { check(); current.closed = true; group = null; store.end(current.token, { commit: true }); pushed(before); return before !== store.present(); },
32
+ cancel() { if (current.closed) return false; current.closed = true; group = null; return store.end(current.token, { commit: false }); },
33
+ };
34
+ };
35
+ return adapter;
36
+ }
@@ -2,7 +2,7 @@
2
2
  // name their character explicitly, so they run the same way whichever
3
3
  // character is active and whatever mode the editor is in.
4
4
  import { studioActionDeclaration } from "../studio-actions.js";
5
- import { characterOf, fail, changedIds } from "./shared.js";
5
+ import { characterOf, fail, changedIds, checkPoseBones } from "./shared.js";
6
6
  import { elementSetSchema, registerElementSet } from './elements.js';
7
7
  import './elements/character.js';
8
8
  import { createCharacterEntry, CHARACTER_KIND_IDS, POSTURES, PROXY_FIGURE_MODEL, isProxyFigure } from '../scenes.js';
@@ -43,7 +43,13 @@ export function register(registry, ports) {
43
43
  const owner = () => ports.storeDomain('cast');
44
44
  const mounted = () => Boolean(ports.storeDomain?.('cast')) || 'The cast document owner is not mounted.';
45
45
  const character = characterId => owner().read().find(row => row.id === characterId) ?? fail('STALE_TARGET', `Character ${characterId} is not in this scene.`);
46
- const pose = value => typeof value === 'string' ? owner().poses().find(row => row.id === value) ?? fail('STALE_TARGET', `Pose ${value} is not in the library.`) : value;
46
+ // Every pose path names only POSE_BONES joints: a pose sent whole, one chosen from the library by id,
47
+ // and every library entry written (#773).
48
+ const pose = value => {
49
+ const resolved = typeof value === 'string' ? owner().poses().find(row => row.id === value) ?? fail('STALE_TARGET', `Pose ${value} is not in the library.`) : value;
50
+ checkPoseBones(resolved?.bones);
51
+ return resolved;
52
+ };
47
53
  const patch = (characterId, update) => { character(characterId); owner().update(characterId, update); };
48
54
  // A character groups under a scene object that stands in the world. An
49
55
  // object a character carries is refused: it rides a bone, not a route.
@@ -69,8 +75,9 @@ export function register(registry, ports) {
69
75
  'character.setPose': ({ characterId, pose: value, clearMotion }) => { character(characterId); owner().applyPose(characterId, pose(value), clearMotion); },
70
76
 
71
77
  'cast.replace': ({ characters }) => owner().write(characters),
72
- 'cast.setCustomPoses': ({ poses }) => owner().writeState(before => ({ ...before, customPoses: poses })),
78
+ 'cast.setCustomPoses': ({ poses }) => { for (const entry of poses) checkPoseBones(entry?.bones); owner().writeState(before => ({ ...before, customPoses: poses })); },
73
79
  'cast.savePose': ({ pose, characterId, clearMotion }) => {
80
+ checkPoseBones(pose?.bones);
74
81
  if (characterId) character(characterId);
75
82
  owner().writeState(before => ({ ...before, customPoses: [...before.customPoses.filter(entry => entry.id !== pose.id), pose] }));
76
83
  if (characterId) owner().applyPose(characterId, pose, clearMotion);