clay-server 4.0.0-beta.13 → 4.0.0-beta.15

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 (52) hide show
  1. package/lib/capsule-display-floor.js +51 -0
  2. package/lib/capsule-frame-server.js +229 -0
  3. package/lib/capsule-pig-logic.js +268 -0
  4. package/lib/capsule-server-runtimes.js +41 -0
  5. package/lib/capsule-tictactoe-logic.js +260 -0
  6. package/lib/capsules/pig/display.js +190 -0
  7. package/lib/capsules/pig/manifest.json +9 -0
  8. package/lib/capsules/pig/ui.json +46 -0
  9. package/lib/capsules/tictactoe/manifest.json +9 -0
  10. package/lib/capsules/tictactoe/ui.json +203 -0
  11. package/lib/project-capsule-catalog.js +9 -1
  12. package/lib/project-connection.js +1 -1
  13. package/lib/project-log-feedback-delivery.js +76 -0
  14. package/lib/project-logs.js +4 -0
  15. package/lib/project-pair-lifecycle.js +7 -12
  16. package/lib/project-session-pair.js +29 -39
  17. package/lib/project-worker-proposal.js +203 -72
  18. package/lib/project.js +13 -0
  19. package/lib/public/css/capsule-ui.css +18 -0
  20. package/lib/public/css/home-session-actions.css +66 -0
  21. package/lib/public/css/home-sidebar.css +66 -0
  22. package/lib/public/css/mobile-nav.css +80 -0
  23. package/lib/public/css/sidebar.css +92 -0
  24. package/lib/public/css/worker-proposal.css +34 -1
  25. package/lib/public/modules/app-messages.js +14 -1
  26. package/lib/public/modules/home-conversations-sheet.js +102 -48
  27. package/lib/public/modules/home-session-actions.js +6 -0
  28. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  29. package/lib/public/modules/home-tool-frame.js +165 -0
  30. package/lib/public/modules/home-tools.js +129 -1
  31. package/lib/public/modules/session-hierarchy.js +54 -0
  32. package/lib/public/modules/sidebar-mobile.js +35 -6
  33. package/lib/public/modules/sidebar-session-hierarchy.js +206 -0
  34. package/lib/public/modules/sidebar-sessions.js +49 -7
  35. package/lib/public/modules/worker-proposal-state.js +16 -0
  36. package/lib/public/modules/worker-proposal.js +49 -12
  37. package/lib/sdk-bridge.js +16 -8
  38. package/lib/sdk-message-processor.js +34 -4
  39. package/lib/server-home-chat.js +12 -1
  40. package/lib/server-tools.js +97 -9
  41. package/lib/server.js +3 -0
  42. package/lib/session-driver-eligibility.js +20 -165
  43. package/lib/session-pair-factory.js +31 -28
  44. package/lib/session-pair-mcp-server.js +5 -9
  45. package/lib/session-pair-prompts.js +15 -15
  46. package/lib/session-provenance.js +119 -0
  47. package/lib/session-spawn-mcp-server.js +1 -1
  48. package/lib/sessions.js +40 -2
  49. package/lib/tools-registry.js +44 -10
  50. package/lib/ws-schema.js +8 -3
  51. package/lib/yoke/adapters/claude.js +12 -0
  52. package/package.json +1 -1
@@ -0,0 +1,51 @@
1
+ // The mandatory declarative floor element of a Capsule's Display set.
2
+ //
3
+ // A Capsule is Logic, Skills, and Display. Display carries no packaging duty
4
+ // towards an agent (no Mate ever reads it), but it does carry the human's
5
+ // sovereignty: every Capsule must ship one element the host can always render,
6
+ // so the Capsule stays fully operable by hand with no AI in the path. That
7
+ // element is the validated declarative tree, and it is the floor.
8
+ //
9
+ // Two gates follow from that, and they are gates rather than aspirations:
10
+ // 1. Registration refuses a Capsule whose Display set lacks the floor.
11
+ // 2. Skills go dark when the floor does. A Capsule whose floor is
12
+ // unavailable leaves the catalog and the control surface a Mate sees, at
13
+ // the same moment the human loses it.
14
+ //
15
+ // The only question either gate asks is the one tool-ui-spec answers: does the
16
+ // Capsule have a declarative tree, and does that tree validate? A tree that is
17
+ // merely sparse is still a floor. Nothing here reads a stored verdict, because
18
+ // a stored verdict is a claim rather than the Display itself; the gate always
19
+ // validates the actual tree.
20
+
21
+ var toolUiSpec = require("./tool-ui-spec");
22
+
23
+ var FLOOR_ELEMENT = "declarative";
24
+
25
+ // Fails closed: a scan error, a missing ID, a missing tree, or a tree that
26
+ // does not validate all mean the same thing to a Mate, which is no Capsule.
27
+ function hasUsableFloor(manifest, uiTree) {
28
+ if (!manifest || manifest.error || !manifest.id) return false;
29
+ var tree = uiTree === undefined ? manifest.uiTree : uiTree;
30
+ if (tree === undefined || tree === null) return false;
31
+ try {
32
+ toolUiSpec.validateUiTreeForManifest(tree, manifest);
33
+ return true;
34
+ } catch (error) {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function assertFloor(uiTree, manifest) {
40
+ if (uiTree === undefined || uiTree === null) {
41
+ throw new Error("Capsule Display must include the declarative floor element.");
42
+ }
43
+ toolUiSpec.validateUiTreeForManifest(uiTree, manifest);
44
+ return true;
45
+ }
46
+
47
+ module.exports = {
48
+ FLOOR_ELEMENT: FLOOR_ELEMENT,
49
+ hasUsableFloor: hasUsableFloor,
50
+ assertFloor: assertFloor,
51
+ };
@@ -0,0 +1,229 @@
1
+ // Isolated host for a Capsule's opt-in rich Display element.
2
+ //
3
+ // The rich element is additive Display: arbitrary rendering (canvas, WebGL)
4
+ // with zero authority. It runs in an iframe that is sandboxed with
5
+ // allow-scripts only and served from this dedicated listener, a distinct
6
+ // origin from the app, so nothing of Clay's DOM, cookies, or storage is
7
+ // reachable. Its one outward edge is postMessage to its embedder, and the
8
+ // only thing it can ask for is the same validated act a human click requests.
9
+ //
10
+ // The frame document carries its own CSP, strictly tighter than the app
11
+ // policy: default-src 'none' with a per-response script nonce. There is no
12
+ // connect-src at all, so the frame cannot fetch, beacon, or open sockets; a
13
+ // rich element ships self-contained. The nonce authorizes exactly two
14
+ // scripts: the inline bridge below and the Capsule's own display.js.
15
+ //
16
+ // This origin serves no cookies and knows no sessions. A frame URL is gated
17
+ // by a short-lived token issued over the user's authenticated WebSocket, and
18
+ // each token admits one shell fetch and one display.js fetch. Whatever state
19
+ // the host later pushes into the frame is the entire exfiltration surface,
20
+ // chosen per element, exactly like a snapshot projection is chosen per caller.
21
+
22
+ var crypto = require("crypto");
23
+ var fs = require("fs");
24
+ var http = require("http");
25
+ var https = require("https");
26
+ var path = require("path");
27
+ var toolsRegistry = require("./tools-registry");
28
+
29
+ var TOKEN_TTL_MS = 60000;
30
+ var DISPLAY_FILE = "display.js";
31
+ var MAX_DISPLAY_BYTES = 512 * 1024;
32
+
33
+ function createCapsuleFrameServer(opts) {
34
+ opts = opts || {};
35
+ var tlsOptions = opts.tlsOptions || null;
36
+ var tokens = Object.create(null);
37
+ var server = null;
38
+ var listening = null;
39
+
40
+ function pruneTokens(now) {
41
+ var keys = Object.keys(tokens);
42
+ for (var i = 0; i < keys.length; i++) {
43
+ if (tokens[keys[i]].expiresAt <= now) delete tokens[keys[i]];
44
+ }
45
+ }
46
+
47
+ function displayPathFor(ctx, toolId) {
48
+ toolsRegistry.validateToolId(toolId);
49
+ return path.join(toolsRegistry.resolveToolsRoot(ctx), toolId, DISPLAY_FILE);
50
+ }
51
+
52
+ function hasRichDisplay(ctx, toolId) {
53
+ try {
54
+ return fs.existsSync(displayPathFor(ctx, toolId));
55
+ } catch (error) {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function ensureListening() {
61
+ if (listening) return listening;
62
+ server = tlsOptions ? https.createServer(tlsOptions, handleRequest) : http.createServer(handleRequest);
63
+ listening = new Promise(function (resolve, reject) {
64
+ server.once("error", reject);
65
+ server.listen(opts.port || 0, function () {
66
+ resolve(server.address().port);
67
+ });
68
+ });
69
+ return listening;
70
+ }
71
+
72
+ // Issues the one-time frame URL for a Capsule that ships a rich element.
73
+ // Callable only from the authenticated WebSocket side, which is what binds
74
+ // the anonymous frame origin to a specific user's tools root.
75
+ function issueFrameUrl(ctx, toolId) {
76
+ if (!hasRichDisplay(ctx, toolId)) {
77
+ return Promise.reject(new Error("This Capsule ships no rich Display element."));
78
+ }
79
+ return ensureListening().then(function (port) {
80
+ pruneTokens(Date.now());
81
+ var token = crypto.randomBytes(24).toString("hex");
82
+ tokens[token] = {
83
+ ctx: { userId: ctx.userId, multiUser: ctx.multiUser, linuxUser: ctx.linuxUser },
84
+ toolId: toolId,
85
+ expiresAt: Date.now() + TOKEN_TTL_MS,
86
+ shell: true,
87
+ display: true,
88
+ };
89
+ return { port: port, path: "/capsule/?t=" + token, secure: !!tlsOptions };
90
+ });
91
+ }
92
+
93
+ function consumeToken(token, use) {
94
+ if (typeof token !== "string" || !token) return null;
95
+ var entry = tokens[token];
96
+ if (!entry) return null;
97
+ if (entry.expiresAt <= Date.now()) {
98
+ delete tokens[token];
99
+ return null;
100
+ }
101
+ if (!entry[use]) return null;
102
+ entry[use] = false;
103
+ if (!entry.shell && !entry.display) delete tokens[token];
104
+ return entry;
105
+ }
106
+
107
+ function deny(res, code) {
108
+ res.writeHead(code, { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff" });
109
+ res.end(code === 404 ? "Not found" : "Forbidden");
110
+ }
111
+
112
+ // The in-frame half of the bridge. Its only capabilities are receiving
113
+ // state/event pushes from the embedder and asking the embedder for an act.
114
+ // The embedder routes that request into the same pipeline a floor button
115
+ // uses; nothing here can mutate anything directly.
116
+ function bridgeSource() {
117
+ return [
118
+ "(function () {",
119
+ " \"use strict\";",
120
+ " var api = {",
121
+ " onState: null,",
122
+ " onEvent: null,",
123
+ " act: function (actionId, args) {",
124
+ " window.parent.postMessage({ clayCapsuleFrame: 1, type: \"act\", actionId: String(actionId), args: args || {} }, \"*\");",
125
+ " },",
126
+ " };",
127
+ " window.ClayCapsule = api;",
128
+ " window.addEventListener(\"message\", function (event) {",
129
+ " if (event.source !== window.parent) return;",
130
+ " var data = event.data;",
131
+ " if (!data || data.clayCapsuleFrame !== 1) return;",
132
+ " if (data.type === \"state\" && typeof api.onState === \"function\") api.onState(data.state || {});",
133
+ " else if (data.type === \"event\" && typeof api.onEvent === \"function\") api.onEvent(data.event || null);",
134
+ " });",
135
+ " window.addEventListener(\"load\", function () {",
136
+ " window.parent.postMessage({ clayCapsuleFrame: 1, type: \"ready\" }, \"*\");",
137
+ " });",
138
+ "})();",
139
+ ].join("\n");
140
+ }
141
+
142
+ function shellHtml(nonce, token) {
143
+ return [
144
+ "<!doctype html>",
145
+ "<html>",
146
+ "<head>",
147
+ "<meta charset=\"utf-8\">",
148
+ "<title>Capsule Display</title>",
149
+ "</head>",
150
+ "<body>",
151
+ "<script nonce=\"" + nonce + "\">" + bridgeSource() + "</script>",
152
+ "<script nonce=\"" + nonce + "\" src=\"/capsule/display.js?t=" + token + "\"></script>",
153
+ "</body>",
154
+ "</html>",
155
+ ].join("\n");
156
+ }
157
+
158
+ function handleRequest(req, res) {
159
+ if (req.method !== "GET") return deny(res, 404);
160
+ var parsed;
161
+ try {
162
+ parsed = new URL(req.url, "http://frame.invalid");
163
+ } catch (error) {
164
+ return deny(res, 404);
165
+ }
166
+ var token = parsed.searchParams.get("t");
167
+ if (parsed.pathname === "/capsule/" || parsed.pathname === "/capsule") {
168
+ var shellEntry = consumeToken(token, "shell");
169
+ if (!shellEntry) return deny(res, 403);
170
+ var nonce = crypto.randomBytes(16).toString("base64");
171
+ res.writeHead(200, {
172
+ "Content-Type": "text/html; charset=utf-8",
173
+ // No connect-src, no default fallback: the frame cannot reach the
174
+ // network at all, and only the two nonced scripts may run. This is
175
+ // deliberately tighter than the app policy in every direction.
176
+ "Content-Security-Policy": "default-src 'none'; script-src 'nonce-" + nonce + "'; base-uri 'none'; form-action 'none'",
177
+ "X-Content-Type-Options": "nosniff",
178
+ "Referrer-Policy": "no-referrer",
179
+ "Cache-Control": "no-store",
180
+ });
181
+ res.end(shellHtml(nonce, token));
182
+ return;
183
+ }
184
+ if (parsed.pathname === "/capsule/display.js") {
185
+ var displayEntry = consumeToken(token, "display");
186
+ if (!displayEntry) return deny(res, 403);
187
+ var source;
188
+ try {
189
+ var filePath = displayPathFor(displayEntry.ctx, displayEntry.toolId);
190
+ if (fs.statSync(filePath).size > MAX_DISPLAY_BYTES) return deny(res, 403);
191
+ source = fs.readFileSync(filePath, "utf8");
192
+ } catch (error) {
193
+ return deny(res, 404);
194
+ }
195
+ res.writeHead(200, {
196
+ "Content-Type": "text/javascript; charset=utf-8",
197
+ "X-Content-Type-Options": "nosniff",
198
+ "Referrer-Policy": "no-referrer",
199
+ "Cache-Control": "no-store",
200
+ });
201
+ res.end(source);
202
+ return;
203
+ }
204
+ return deny(res, 404);
205
+ }
206
+
207
+ function close() {
208
+ if (!server) return Promise.resolve();
209
+ var closing = server;
210
+ server = null;
211
+ listening = null;
212
+ return new Promise(function (resolve) {
213
+ closing.close(function () { resolve(); });
214
+ });
215
+ }
216
+
217
+ return {
218
+ issueFrameUrl: issueFrameUrl,
219
+ hasRichDisplay: hasRichDisplay,
220
+ ensureListening: ensureListening,
221
+ close: close,
222
+ };
223
+ }
224
+
225
+ module.exports = {
226
+ DISPLAY_FILE: DISPLAY_FILE,
227
+ TOKEN_TTL_MS: TOKEN_TTL_MS,
228
+ createCapsuleFrameServer: createCapsuleFrameServer,
229
+ };
@@ -0,0 +1,268 @@
1
+ // Server-side Logic for the Pig validation Capsule.
2
+ //
3
+ // Logic is the single source of truth. Every state change in the game happens
4
+ // through one of these functions and nowhere else: the die is rolled here, the
5
+ // turn rules are enforced here, and the game record lives in the Capsule's own
6
+ // datastore. A human button click and a Mate clay_tool_act call arrive at the
7
+ // same act pipeline with the same rules, so neither party can break a rule the
8
+ // other is bound by, and neither sees state the other cannot.
9
+ //
10
+ // The two seats are "user" and "mate". The server resolves its own actor before
11
+ // calling in, and the human actor maps onto the user seat; seat is never read
12
+ // from caller-supplied text.
13
+ //
14
+ // Rules: seats alternate turns. On your turn you may roll as often as you like;
15
+ // each roll adds to the turn total, but rolling a 1 loses the whole turn total
16
+ // and passes play. Holding banks the turn total into your score, and the first
17
+ // seat to bank at least 100 points wins. A finished game is frozen until
18
+ // someone resets it; only the user may reset a game in progress, because the
19
+ // Capsule belongs to the human, not to the Mate.
20
+
21
+ var crypto = require("crypto");
22
+
23
+ var GAME_DOC_ID = "game";
24
+ var TARGET_SCORE = 100;
25
+ var BUST_FACE = 1;
26
+ var USER = "user";
27
+ var MATE = "mate";
28
+ var SEATS = [USER, MATE];
29
+ var SEAT_LABELS = { user: "You", mate: "Your Mate" };
30
+ var MAX_RECENT_ROLLS = 12;
31
+
32
+ // Every read-modify-write for one stored game runs to completion before the
33
+ // next one starts. Two acts that arrive together would otherwise both validate
34
+ // against the same turn, and the second write would silently discard the first.
35
+ var gameLocks = Object.create(null);
36
+
37
+ function runExclusive(key, operation) {
38
+ var previous = gameLocks[key] || Promise.resolve();
39
+ var settled = previous.then(operation, operation);
40
+ gameLocks[key] = settled.then(function () {}, function () {});
41
+ return settled;
42
+ }
43
+
44
+ function secureRollDie() {
45
+ return crypto.randomInt(1, 7);
46
+ }
47
+
48
+ function otherSeat(seat) {
49
+ return seat === USER ? MATE : USER;
50
+ }
51
+
52
+ function newGame() {
53
+ return {
54
+ status: "playing",
55
+ turn: USER,
56
+ scores: { user: 0, mate: 0 },
57
+ turnTotal: 0,
58
+ lastRoll: null,
59
+ lastActor: null,
60
+ winner: null,
61
+ target: TARGET_SCORE,
62
+ sequence: 0,
63
+ eventSeq: 0,
64
+ recentRolls: [],
65
+ };
66
+ }
67
+
68
+ function safeScore(value) {
69
+ return Number.isInteger(value) && value >= 0 && value <= 10000 ? value : 0;
70
+ }
71
+
72
+ // Counters are ordering, not points: they only ever grow, so they carry no
73
+ // upper bound. eventSeq in particular must survive a reset, because a Display
74
+ // that saw event N must treat anything at or below N as already rendered.
75
+ function safeCounter(value) {
76
+ return Number.isInteger(value) && value >= 0 ? value : 0;
77
+ }
78
+
79
+ // A stored game is data, not a promise about shape. Anything unreadable is
80
+ // replaced by a fresh game rather than trusted.
81
+ function normalizeState(stored) {
82
+ if (!stored || typeof stored !== "object" || Array.isArray(stored)) return newGame();
83
+ if (SEATS.indexOf(stored.turn) === -1) return newGame();
84
+ if (stored.status !== "playing" && stored.status !== "complete") return newGame();
85
+ var scores = stored.scores && typeof stored.scores === "object" ? stored.scores : {};
86
+ var rolls = Array.isArray(stored.recentRolls) ? stored.recentRolls.slice(-MAX_RECENT_ROLLS) : [];
87
+ return {
88
+ status: stored.status,
89
+ turn: stored.turn,
90
+ scores: { user: safeScore(scores.user), mate: safeScore(scores.mate) },
91
+ turnTotal: safeScore(stored.turnTotal),
92
+ lastRoll: Number.isInteger(stored.lastRoll) ? stored.lastRoll : null,
93
+ lastActor: SEATS.indexOf(stored.lastActor) !== -1 ? stored.lastActor : null,
94
+ winner: SEATS.indexOf(stored.winner) !== -1 ? stored.winner : null,
95
+ target: TARGET_SCORE,
96
+ sequence: safeCounter(stored.sequence),
97
+ eventSeq: safeCounter(stored.eventSeq),
98
+ recentRolls: rolls.filter(function (entry) {
99
+ return entry && typeof entry.text === "string" && Number.isInteger(entry.face);
100
+ }),
101
+ };
102
+ }
103
+
104
+ // recentRolls holds rolls and only rolls, including a bust. Banking and winning
105
+ // are turn outcomes rather than rolls, so they never appear here.
106
+ function appendRoll(state, seat, face, text) {
107
+ var next = state.sequence + 1;
108
+ state.sequence = next;
109
+ state.recentRolls = state.recentRolls
110
+ .concat([{ id: "roll-" + next, seat: seat, face: face, text: text }])
111
+ .slice(-MAX_RECENT_ROLLS);
112
+ }
113
+
114
+ function requirePlayableTurn(state, seat) {
115
+ if (state.status !== "playing") {
116
+ throw new Error("This game is already over. Reset it to play again.");
117
+ }
118
+ if (state.turn !== seat) {
119
+ throw new Error("This move is out of turn: " + SEAT_LABELS[state.turn] + " to play.");
120
+ }
121
+ }
122
+
123
+ function roll(state, seat, rollDie) {
124
+ requirePlayableTurn(state, seat);
125
+ var face = rollDie();
126
+ if (!Number.isInteger(face) || face < 1 || face > 6) throw new Error("The die produced an invalid face.");
127
+ state.lastRoll = face;
128
+ state.lastActor = seat;
129
+ if (face === BUST_FACE) {
130
+ appendRoll(state, seat, face, SEAT_LABELS[seat] + " rolled a 1 and lost " + state.turnTotal + " point(s).");
131
+ state.turnTotal = 0;
132
+ state.turn = otherSeat(seat);
133
+ return state;
134
+ }
135
+ state.turnTotal += face;
136
+ appendRoll(state, seat, face, SEAT_LABELS[seat] + " rolled a " + face + " for a turn total of " + state.turnTotal + ".");
137
+ return state;
138
+ }
139
+
140
+ function hold(state, seat) {
141
+ requirePlayableTurn(state, seat);
142
+ var banked = state.turnTotal;
143
+ state.scores[seat] += banked;
144
+ state.lastActor = seat;
145
+ state.turnTotal = 0;
146
+ if (state.scores[seat] >= state.target) {
147
+ state.status = "complete";
148
+ state.winner = seat;
149
+ return state;
150
+ }
151
+ state.turn = otherSeat(seat);
152
+ return state;
153
+ }
154
+
155
+ function reset(state, seat) {
156
+ if (state.status === "playing" && seat !== USER) {
157
+ throw new Error("Only the user may reset a game that is still in progress.");
158
+ }
159
+ return newGame();
160
+ }
161
+
162
+ // The projection a Display renders and a Mate reads. It adds no meaning that is
163
+ // absent from state: every derived field is a restatement of the fields above.
164
+ // The score series exist because a progress chart binds to a collection.
165
+ function project(state) {
166
+ var complete = state.status === "complete";
167
+ var userTurn = !complete && state.turn === USER;
168
+ return {
169
+ status: state.status,
170
+ turn: state.turn,
171
+ scores: { user: state.scores.user, mate: state.scores.mate },
172
+ userScoreSeries: [{ seat: SEAT_LABELS.user, value: state.scores.user }],
173
+ mateScoreSeries: [{ seat: SEAT_LABELS.mate, value: state.scores.mate }],
174
+ turnTotal: state.turnTotal,
175
+ turnTotalText: "Turn total " + state.turnTotal + " of " + state.target + ", " + SEAT_LABELS[state.turn] + " to play.",
176
+ lastRoll: state.lastRoll,
177
+ lastActor: state.lastActor,
178
+ winner: state.winner,
179
+ target: state.target,
180
+ recentRolls: state.recentRolls,
181
+ complete: complete,
182
+ userTurn: userTurn,
183
+ eventSeq: state.eventSeq,
184
+ };
185
+ }
186
+
187
+ // Causality for the live Display: {actor, action, previous, next} plus a
188
+ // monotonic seq. Sending only the new state would make a Mate's move teleport
189
+ // onto the human's screen; the event lets the Display replay and attribute it.
190
+ function buildEvent(seat, actionId, previous, next) {
191
+ return {
192
+ seq: next.eventSeq,
193
+ actor: seat,
194
+ action: actionId,
195
+ previous: previous,
196
+ next: next,
197
+ };
198
+ }
199
+
200
+ function seatFor(context) {
201
+ var actor = context && context.actor;
202
+ if (actor === "human" || actor === USER) return USER;
203
+ if (actor === MATE) return MATE;
204
+ throw new Error("The Capsule caller seat could not be determined.");
205
+ }
206
+
207
+ // One act pipeline. The human surface and the Mate MCP surface both land here.
208
+ function createRuntime(options) {
209
+ options = options || {};
210
+ var storage = options.storage;
211
+ var rollDie = typeof options.rollDie === "function" ? options.rollDie : secureRollDie;
212
+ var lockKey = options.lockKey || "pig";
213
+ if (!storage) throw new Error("The Pig Capsule requires its datastore.");
214
+
215
+ async function writeState(state) {
216
+ await storage.put({ _id: GAME_DOC_ID, state: state });
217
+ return state;
218
+ }
219
+
220
+ async function readState() {
221
+ var doc = await storage.get(GAME_DOC_ID);
222
+ if (!doc) return writeState(newGame());
223
+ return normalizeState(doc.state);
224
+ }
225
+
226
+ async function snapshot(context) {
227
+ seatFor(context);
228
+ return runExclusive(lockKey, async function () {
229
+ return project(await readState());
230
+ });
231
+ }
232
+
233
+ // An act returns {state, event}: the new projection for the caller and the
234
+ // causal event for every watching Display. Both are built inside the lock,
235
+ // so seq order on the wire is the order the rules actually ran in.
236
+ async function act(context, actionId, args) {
237
+ var seat = seatFor(context);
238
+ return runExclusive(lockKey, async function () {
239
+ var state = await readState();
240
+ var previous = project(state);
241
+ var next;
242
+ if (actionId === "roll") next = roll(state, seat, rollDie);
243
+ else if (actionId === "hold") next = hold(state, seat);
244
+ else if (actionId === "reset") next = reset(state, seat);
245
+ else throw new Error("Unknown Pig action '" + String(actionId) + "'.");
246
+ next.eventSeq = previous.eventSeq + 1;
247
+ await writeState(next);
248
+ var projected = project(next);
249
+ return { state: projected, event: buildEvent(seat, actionId, previous, projected) };
250
+ });
251
+ }
252
+
253
+ return { snapshot: snapshot, act: act };
254
+ }
255
+
256
+ module.exports = {
257
+ GAME_DOC_ID: GAME_DOC_ID,
258
+ TARGET_SCORE: TARGET_SCORE,
259
+ SEATS: SEATS,
260
+ newGame: newGame,
261
+ normalizeState: normalizeState,
262
+ project: project,
263
+ buildEvent: buildEvent,
264
+ roll: roll,
265
+ hold: hold,
266
+ reset: reset,
267
+ createRuntime: createRuntime,
268
+ };
@@ -0,0 +1,41 @@
1
+ // Trusted server-runtime Logic for shipped Capsules.
2
+ //
3
+ // A server Capsule's Logic runs in this process rather than in a browser
4
+ // worker, so it stays available with no home screen open and owns randomness
5
+ // and hidden state the client must never hold. Only Capsules shipped in
6
+ // lib/capsules/ can claim the server runtime (see tools-registry
7
+ // isTrustedServerRuntime), and only IDs listed here resolve to real Logic.
8
+ //
9
+ // A runtime is created per call. State lives in the Capsule's datastore, never
10
+ // in this module, so nothing here is shared between users.
11
+
12
+ var path = require("path");
13
+ var toolStorage = require("./tool-storage");
14
+ var toolsRegistry = require("./tools-registry");
15
+ var pigLogic = require("./capsule-pig-logic");
16
+ var tictactoeLogic = require("./capsule-tictactoe-logic");
17
+
18
+ var RUNTIME_FACTORIES = {
19
+ pig: pigLogic.createRuntime,
20
+ tictactoe: tictactoeLogic.createRuntime,
21
+ };
22
+
23
+ function hasRuntime(toolId) {
24
+ return Object.prototype.hasOwnProperty.call(RUNTIME_FACTORIES, toolId);
25
+ }
26
+
27
+ function createRuntime(toolId, ctx, options) {
28
+ if (!hasRuntime(toolId)) return null;
29
+ var settings = Object.assign({}, options || {});
30
+ if (!settings.storage) settings.storage = toolStorage.createToolStorage(ctx, toolId);
31
+ // One stored game, one serialization key, no matter how many runtime
32
+ // instances are created for it.
33
+ if (!settings.lockKey) settings.lockKey = path.join(toolsRegistry.resolveToolsRoot(ctx), toolId);
34
+ return RUNTIME_FACTORIES[toolId](settings);
35
+ }
36
+
37
+ module.exports = {
38
+ hasRuntime: hasRuntime,
39
+ createRuntime: createRuntime,
40
+ ids: Object.keys(RUNTIME_FACTORIES),
41
+ };