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

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 (77) hide show
  1. package/lib/capsule-frame-server.js +26 -0
  2. package/lib/capsule-mate-turn.js +105 -0
  3. package/lib/capsule-pig-logic.js +33 -1
  4. package/lib/capsule-server-runtimes.js +0 -2
  5. package/lib/capsules/pig/display.js +8 -0
  6. package/lib/capsules/pig/manifest.json +1 -1
  7. package/lib/capsules/pig/ui.json +52 -14
  8. package/lib/daemon-projects.js +10 -5
  9. package/lib/daemon.js +69 -10
  10. package/lib/migrate-single-user.js +2 -0
  11. package/lib/project-capsule-turn.js +96 -0
  12. package/lib/project-log-context.js +171 -0
  13. package/lib/project-log-feedback-delivery.js +47 -5
  14. package/lib/project-logs-context-state.js +42 -0
  15. package/lib/project-logs-mcp-server.js +16 -10
  16. package/lib/project-logs-query.js +18 -0
  17. package/lib/project-logs-root.js +8 -0
  18. package/lib/project-logs-service.js +92 -15
  19. package/lib/project-logs-snapshot.js +3 -0
  20. package/lib/project-logs-store.js +29 -32
  21. package/lib/project-logs-versioning.js +6 -2
  22. package/lib/project-logs.js +56 -12
  23. package/lib/project-message-delivery.js +70 -0
  24. package/lib/project-session-pair.js +15 -5
  25. package/lib/project-user-message.js +11 -0
  26. package/lib/project-worker-proposal.js +8 -8
  27. package/lib/project.js +18 -1
  28. package/lib/public/app.js +11 -15
  29. package/lib/public/css/capsule-ui.css +21 -1
  30. package/lib/public/css/home-hub.css +4 -0
  31. package/lib/public/css/input.css +84 -3
  32. package/lib/public/css/project-logs.css +20 -2
  33. package/lib/public/css/session-actions.css +16 -101
  34. package/lib/public/css/sticky-notes.css +3 -7
  35. package/lib/public/index.html +31 -9
  36. package/lib/public/modules/app-connection.js +22 -3
  37. package/lib/public/modules/app-message-cards.js +2 -1
  38. package/lib/public/modules/app-messages.js +39 -3
  39. package/lib/public/modules/app-misc.js +2 -14
  40. package/lib/public/modules/app-panels.js +3 -2
  41. package/lib/public/modules/app-projects.js +6 -3
  42. package/lib/public/modules/app-rendering.js +1 -0
  43. package/lib/public/modules/capsule-preference.js +44 -0
  44. package/lib/public/modules/context-sources.js +0 -3
  45. package/lib/public/modules/home-chat-empty-state.js +39 -0
  46. package/lib/public/modules/home-chat-identity.js +9 -0
  47. package/lib/public/modules/home-mate-chat.js +15 -35
  48. package/lib/public/modules/home-tool-frame.js +17 -0
  49. package/lib/public/modules/home-tools.js +9 -0
  50. package/lib/public/modules/input.js +38 -7
  51. package/lib/public/modules/message-delivery.js +97 -0
  52. package/lib/public/modules/paste-modal.js +84 -0
  53. package/lib/public/modules/project-logs-render.js +48 -2
  54. package/lib/public/modules/project-logs.js +133 -11
  55. package/lib/public/modules/project-removal-target.js +26 -0
  56. package/lib/public/modules/session-actions.js +29 -105
  57. package/lib/public/modules/user-settings.js +4 -0
  58. package/lib/public/modules/worker-pane-lock.js +2 -0
  59. package/lib/sdk-bridge.js +14 -7
  60. package/lib/server-experimental-settings.js +49 -0
  61. package/lib/server-home-chat-events.js +28 -1
  62. package/lib/server-home-chat.js +1 -1
  63. package/lib/server-settings.js +5 -0
  64. package/lib/server-tools.js +30 -0
  65. package/lib/server.js +41 -2
  66. package/lib/session-driver-orchestration.js +31 -0
  67. package/lib/session-pair-prompts.js +24 -5
  68. package/lib/tools-registry.js +46 -1
  69. package/lib/users-experimental-preferences.js +30 -0
  70. package/lib/users.js +6 -0
  71. package/lib/ws-schema.js +8 -2
  72. package/lib/yoke/adapters/codex.js +24 -14
  73. package/lib/yoke/vendor-registry.js +1 -1
  74. package/package.json +2 -2
  75. package/lib/capsule-tictactoe-logic.js +0 -260
  76. package/lib/capsules/tictactoe/manifest.json +0 -9
  77. package/lib/capsules/tictactoe/ui.json +0 -203
@@ -132,8 +132,34 @@ function createCapsuleFrameServer(opts) {
132
132
  " if (data.type === \"state\" && typeof api.onState === \"function\") api.onState(data.state || {});",
133
133
  " else if (data.type === \"event\" && typeof api.onEvent === \"function\") api.onEvent(data.event || null);",
134
134
  " });",
135
+ " // The host owns the iframe box, so the frame reports its natural",
136
+ " // content height and the host resizes the box to fit. Without this",
137
+ " // the box stays fixed and a widening layout clips the element.",
138
+ " var reportedHeight = 0;",
139
+ " var reportQueued = false;",
140
+ " function reportSize() {",
141
+ " reportQueued = false;",
142
+ " var root = document.documentElement;",
143
+ " var body = document.body;",
144
+ " var height = Math.ceil(Math.max(root ? root.scrollHeight : 0, body ? body.scrollHeight : 0));",
145
+ " if (!height || height === reportedHeight) return;",
146
+ " reportedHeight = height;",
147
+ " window.parent.postMessage({ clayCapsuleFrame: 1, type: \"size\", height: height }, \"*\");",
148
+ " }",
149
+ " function queueReport() {",
150
+ " if (reportQueued) return;",
151
+ " reportQueued = true;",
152
+ " if (typeof requestAnimationFrame === \"function\") requestAnimationFrame(reportSize);",
153
+ " else setTimeout(reportSize, 50);",
154
+ " }",
155
+ " if (typeof ResizeObserver === \"function\") {",
156
+ " new ResizeObserver(queueReport).observe(document.documentElement);",
157
+ " if (document.body) new ResizeObserver(queueReport).observe(document.body);",
158
+ " }",
159
+ " window.addEventListener(\"resize\", queueReport);",
135
160
  " window.addEventListener(\"load\", function () {",
136
161
  " window.parent.postMessage({ clayCapsuleFrame: 1, type: \"ready\" }, \"*\");",
162
+ " queueReport();",
137
163
  " });",
138
164
  "})();",
139
165
  ].join("\n");
@@ -0,0 +1,105 @@
1
+ // Bridges Capsule engagement events to the opponent Mate's session.
2
+ //
3
+ // This module knows no game. A Capsule's Logic declares on its causal event
4
+ // when the Mate should be engaged (`event.engage = {kind}`): "turn" asks the
5
+ // Mate to read the state and act, "start" asks it to acknowledge a fresh
6
+ // game without acting. The bridge only carries that declaration: it finds the
7
+ // opponent Mate's project and delivers a prompt through it, so the Capsule
8
+ // round-trips for every game alike: the human plays on the Display, the Mate
9
+ // wakes up, reads the state over clay_tool_snapshot, and acts through the
10
+ // same clay_tool_act pipeline. The prompt carries words only; it grants no
11
+ // authority the Mate does not already have, and a lost prompt costs a
12
+ // reminder, never the game. Only human-caused events engage, whatever Logic
13
+ // declares, so a Mate can never wake itself.
14
+ //
15
+ // The opponent is the Mate that last acted on this Capsule for this user.
16
+ // Before any Mate has acted, the built-in host Mate (Clay) takes the seat, so
17
+ // a fresh game is playable with zero setup.
18
+
19
+ function attachCapsuleMateTurn(deps) {
20
+ var users = deps.users;
21
+ var findMateProject = deps.findMateProject || null;
22
+ var broadcastToUser = deps.broadcastToUser || null;
23
+ var opponents = Object.create(null);
24
+ var nudgedSeqs = Object.create(null);
25
+
26
+ function keyFor(userId, toolId) {
27
+ return String(userId) + "\u0000" + String(toolId);
28
+ }
29
+
30
+ // Called when a Mate acts on a Capsule: that Mate holds the opponent seat
31
+ // for this user's game from then on.
32
+ function rememberOpponent(userId, toolId, mateId) {
33
+ if (typeof mateId === "string" && mateId) opponents[keyFor(userId, toolId)] = mateId;
34
+ }
35
+
36
+ function turnPrompt(manifest) {
37
+ return "Capsule turn: it is your seat's turn in the \"" + manifest.name + "\" Capsule (toolId \"" + manifest.id + "\"). "
38
+ + "Read the game with clay_tool_snapshot, decide your move, and take your turn with clay_tool_act. "
39
+ + "Keep playing until the turn passes away from your seat, and say your reasoning in one or two short sentences as you play. "
40
+ + "If an act is refused, read the snapshot again instead of retrying blindly.";
41
+ }
42
+
43
+ function startPrompt(manifest) {
44
+ return "Capsule game: the user just started a new game of \"" + manifest.name + "\" (toolId \"" + manifest.id + "\") against you. "
45
+ + "The user moves first. Reply with one short line of table talk, and do not call clay_tool_act now: "
46
+ + "you will be woken in this session whenever it is your turn.";
47
+ }
48
+
49
+ // Fire-and-forget from the act pipeline: delivers a Logic-declared
50
+ // engagement exactly once per event, and only for human-caused events.
51
+ function maybeNudge(userId, manifest, actor, event) {
52
+ if (!findMateProject || !manifest || !event) return Promise.resolve(null);
53
+ if (actor !== "human") return Promise.resolve(null);
54
+ var kind = event.engage && (event.engage.kind === "turn" || event.engage.kind === "start") ? event.engage.kind : null;
55
+ if (!kind) return Promise.resolve(null);
56
+ var key = keyFor(userId, manifest.id);
57
+ if (nudgedSeqs[key] !== undefined && event.seq <= nudgedSeqs[key]) return Promise.resolve(null);
58
+ nudgedSeqs[key] = event.seq;
59
+ return Promise.resolve().then(function () {
60
+ var mateId = opponents[key] || null;
61
+ // The mates registry keys single-user data off a null userId, exactly
62
+ // like the home chat surface does.
63
+ var mateUserId = users.isMultiUser() ? userId : null;
64
+ var found = findMateProject(mateUserId, mateId, true);
65
+ if (!found && mateId) found = findMateProject(mateUserId, null, true);
66
+ if (!found || !found.ctx || typeof found.ctx.deliverCapsuleTurn !== "function") {
67
+ console.error("[capsules] No Mate project could take the '" + manifest.id + "' turn; is a Mate installed?");
68
+ return null;
69
+ }
70
+ var principal = { userId: users.isMultiUser() ? userId : null };
71
+ var mateId = found.mate && found.mate.id ? found.mate.id : null;
72
+ return found.ctx.deliverCapsuleTurn(principal, {
73
+ toolId: manifest.id,
74
+ toolName: manifest.name,
75
+ kind: kind,
76
+ text: kind === "turn" ? turnPrompt(manifest) : startPrompt(manifest),
77
+ }).then(function (delivered) {
78
+ // An explicit game start navigates the user's home board into the
79
+ // Mate's game session, so starting a game visibly opens the table.
80
+ if (broadcastToUser && delivered && delivered.reference && mateId) {
81
+ broadcastToUser(userId, {
82
+ type: "capsule_game_session",
83
+ toolId: manifest.id,
84
+ toolName: manifest.name,
85
+ mateId: mateId,
86
+ sessionId: delivered.reference,
87
+ kind: kind,
88
+ created: !!delivered.created,
89
+ });
90
+ }
91
+ return delivered;
92
+ });
93
+ }).catch(function (error) {
94
+ console.error("[capsules] Could not deliver the Mate's turn:", error && error.message ? error.message : error);
95
+ return null;
96
+ });
97
+ }
98
+
99
+ return {
100
+ rememberOpponent: rememberOpponent,
101
+ maybeNudge: maybeNudge,
102
+ };
103
+ }
104
+
105
+ module.exports = { attachCapsuleMateTurn: attachCapsuleMateTurn };
@@ -159,6 +159,18 @@ function reset(state, seat) {
159
159
  return newGame();
160
160
  }
161
161
 
162
+ function statusTextFor(state, complete, userTurn) {
163
+ if (complete) {
164
+ return state.winner === USER ? "You win this game!" : "Your Mate wins this game.";
165
+ }
166
+ if (userTurn) {
167
+ return state.turnTotal > 0
168
+ ? "Your turn: roll again or hold to bank " + state.turnTotal + " point(s)."
169
+ : "Your turn: roll the die.";
170
+ }
171
+ return "Your Mate's turn. Moves appear here as they happen.";
172
+ }
173
+
162
174
  // The projection a Display renders and a Mate reads. It adds no meaning that is
163
175
  // absent from state: every derived field is a restatement of the fields above.
164
176
  // The score series exist because a progress chart binds to a collection.
@@ -172,7 +184,12 @@ function project(state) {
172
184
  userScoreSeries: [{ seat: SEAT_LABELS.user, value: state.scores.user }],
173
185
  mateScoreSeries: [{ seat: SEAT_LABELS.mate, value: state.scores.mate }],
174
186
  turnTotal: state.turnTotal,
187
+ turnTotalSeries: [{ label: "Turn total", value: state.turnTotal }],
175
188
  turnTotalText: "Turn total " + state.turnTotal + " of " + state.target + ", " + SEAT_LABELS[state.turn] + " to play.",
189
+ statusText: statusTextFor(state, complete, userTurn),
190
+ lastRollText: state.lastRoll === null
191
+ ? "No rolls yet."
192
+ : "Last roll: " + state.lastRoll + " by " + SEAT_LABELS[state.lastActor || state.turn] + ".",
176
193
  lastRoll: state.lastRoll,
177
194
  lastActor: state.lastActor,
178
195
  winner: state.winner,
@@ -184,17 +201,32 @@ function project(state) {
184
201
  };
185
202
  }
186
203
 
204
+ // When the Mate should be engaged is a game rule, so Logic declares it on the
205
+ // event rather than the host inferring it from Pig's fields. The host bridge
206
+ // delivers any declared engagement generically for every Capsule: "turn"
207
+ // means play your seat now, "start" means the human opened a fresh game and
208
+ // you should acknowledge it without acting.
209
+ function engagementFor(seat, actionId, next) {
210
+ if (seat !== USER) return null;
211
+ if (next.status === "playing" && next.turn === MATE) return { kind: "turn" };
212
+ if (actionId === "reset" && next.status === "playing") return { kind: "start" };
213
+ return null;
214
+ }
215
+
187
216
  // Causality for the live Display: {actor, action, previous, next} plus a
188
217
  // monotonic seq. Sending only the new state would make a Mate's move teleport
189
218
  // onto the human's screen; the event lets the Display replay and attribute it.
190
219
  function buildEvent(seat, actionId, previous, next) {
191
- return {
220
+ var event = {
192
221
  seq: next.eventSeq,
193
222
  actor: seat,
194
223
  action: actionId,
195
224
  previous: previous,
196
225
  next: next,
197
226
  };
227
+ var engage = engagementFor(seat, actionId, next);
228
+ if (engage) event.engage = engage;
229
+ return event;
198
230
  }
199
231
 
200
232
  function seatFor(context) {
@@ -13,11 +13,9 @@ var path = require("path");
13
13
  var toolStorage = require("./tool-storage");
14
14
  var toolsRegistry = require("./tools-registry");
15
15
  var pigLogic = require("./capsule-pig-logic");
16
- var tictactoeLogic = require("./capsule-tictactoe-logic");
17
16
 
18
17
  var RUNTIME_FACTORIES = {
19
18
  pig: pigLogic.createRuntime,
20
- tictactoe: tictactoeLogic.createRuntime,
21
19
  };
22
20
 
23
21
  function hasRuntime(toolId) {
@@ -42,6 +42,14 @@
42
42
  controls.style.marginTop = "10px";
43
43
  root.appendChild(controls);
44
44
 
45
+ var rulesEl = document.createElement("p");
46
+ rulesEl.textContent = "Pig is a push-your-luck dice game: roll to build this turn's total, but a 1 loses it. Hold to bank your points; the first player to 100 wins.";
47
+ rulesEl.style.margin = "12px 0 0";
48
+ rulesEl.style.color = "#b8b8b8";
49
+ rulesEl.style.fontSize = "12px";
50
+ rulesEl.style.lineHeight = "1.45";
51
+ root.appendChild(rulesEl);
52
+
45
53
  function makeButton(label, action, accent) {
46
54
  var button = document.createElement("button");
47
55
  button.type = "button";
@@ -5,5 +5,5 @@
5
5
  "lucideIcon": "sparkles",
6
6
  "description": "Two-player push-your-luck dice game shared by the user and one Mate.",
7
7
  "useWhen": "Use when the user wants to play Pig, take a turn, or hear how the current game stands.",
8
- "skills": "# Pig\n\nA two-seat push-your-luck dice game. The seats are `user` and `mate`; the person holds the user seat and you hold the mate seat. All randomness, turn order, and scoring are enforced server-side by the Capsule's Logic; you cannot roll a die yourself, and neither can the user.\n\n## Rules\n\n- Seats alternate turns. On your turn you may roll as often as you like.\n- Each roll adds its face value to the turn total. Rolling a 1 loses the entire turn total and passes play to the other seat.\n- Holding banks the turn total into your score and passes play.\n- The first seat to bank at least 100 points wins. A finished game is frozen until it is reset.\n- Only the user may reset a game that is still in progress. You may reset once the game is complete.\n\n## How to play\n\n1. `clay_tool_snapshot` with toolId `pig` to read the current state: `turn`, `scores.user`, `scores.mate`, `turnTotal`, `status`, `winner`, and `recentRolls`, which lists recent rolls only and never holds or wins.\n2. Act only when `turn` is `mate`. `clay_tool_act` with actionId `roll`, `hold`, or `reset`. Arguments are not used.\n3. An out-of-turn or post-game call is refused by Logic with an explanatory error. Read the snapshot again rather than retrying blindly.\n4. Every act returns the complete new state, so a snapshot immediately after an act is redundant.\n\n## Playing well\n\nThe interesting part of this Capsule is the judgement call, not the mechanics: decide whether to roll again or hold, and say why in your own words before you act. Typical play holds at a turn total near 20, but the score gap matters more than the rule of thumb, so explain the trade-off you are taking. Never claim a roll result before the act returns it."
8
+ "skills": "# Pig\n\nA two-seat push-your-luck dice game. The seats are `user` and `mate`; the person holds the user seat and you hold the mate seat. All randomness, turn order, and scoring are enforced server-side by the Capsule's Logic; you cannot roll a die yourself, and neither can the user.\n\n## Rules\n\n- Seats alternate turns. On your turn you may roll as often as you like.\n- Each roll adds its face value to the turn total. Rolling a 1 loses the entire turn total and passes play to the other seat.\n- Holding banks the turn total into your score and passes play.\n- The first seat to bank at least 100 points wins. A finished game is frozen until it is reset.\n- Only the user may reset a game that is still in progress. You may reset once the game is complete.\n\n## How to play\n\n1. `clay_tool_snapshot` with toolId `pig` to read the current state: `turn`, `scores.user`, `scores.mate`, `turnTotal`, `status`, `winner`, and `recentRolls`, which lists recent rolls only and never holds or wins.\n2. Act only when `turn` is `mate`. `clay_tool_act` with actionId `roll`, `hold`, or `reset`. Arguments are not used.\n3. An out-of-turn or post-game call is refused by Logic with an explanatory error. Read the snapshot again rather than retrying blindly.\n4. Every act returns the complete new state, so a snapshot immediately after an act is redundant.\n\n## Playing well\n\nThe interesting part of this Capsule is the judgement call, not the mechanics: decide whether to roll again or hold, and say why in your own words before you act. Typical play holds at a turn total near 20, but the score gap matters more than the rule of thumb, so explain the trade-off you are taking. Never claim a roll result before the act returns it.\n\n## Turn flow\n\nWhen the user's move hands the turn to your seat, Clay wakes you in a dedicated game session with a \"Capsule turn\" message. Take your whole turn there: roll as often as you judge right, then hold (or bust), and stop once the turn passes away from your seat. The user's Display updates live as you act, so never narrate moves you have not made. If nothing wakes you, you are not on turn."
9
9
  }
@@ -3,16 +3,42 @@
3
3
  "props": { "gap": "md" },
4
4
  "children": [
5
5
  {
6
- "type": "chart",
7
- "bind": "userScoreSeries",
8
- "props": { "label": "Your score", "kind": "progress", "categoryKey": "seat", "valueKey": "value", "max": 100 }
6
+ "type": "callout",
7
+ "when": { "equals": { "path": "$state.complete", "value": true } },
8
+ "props": { "title": "Game over", "text": { "$bind": "$state.statusText", "fallback": "Game over." }, "icon": "circle-check", "variant": "soft" },
9
+ "else": {
10
+ "type": "callout",
11
+ "props": { "title": "Pig", "text": { "$bind": "$state.statusText", "fallback": "Your turn: roll the die." }, "icon": "sparkles", "variant": "soft" }
12
+ }
9
13
  },
10
14
  {
11
- "type": "chart",
12
- "bind": "mateScoreSeries",
13
- "props": { "label": "Your Mate's score", "kind": "progress", "categoryKey": "seat", "valueKey": "value", "max": 100 }
15
+ "type": "row",
16
+ "props": { "gap": "md", "wrap": true },
17
+ "children": [
18
+ {
19
+ "type": "chart",
20
+ "bind": "userScoreSeries",
21
+ "props": { "label": "Your score", "kind": "progress", "categoryKey": "seat", "valueKey": "value", "max": 100 }
22
+ },
23
+ {
24
+ "type": "chart",
25
+ "bind": "mateScoreSeries",
26
+ "props": { "label": "Your Mate's score", "kind": "progress", "categoryKey": "seat", "valueKey": "value", "max": 100 }
27
+ }
28
+ ]
29
+ },
30
+ {
31
+ "type": "row",
32
+ "props": { "gap": "md", "align": "center", "wrap": true },
33
+ "children": [
34
+ {
35
+ "type": "chart",
36
+ "bind": "turnTotalSeries",
37
+ "props": { "label": "Turn total", "kind": "metric", "valueKey": "value" }
38
+ },
39
+ { "type": "text", "props": { "text": { "$bind": "$state.lastRollText", "fallback": "No rolls yet." }, "role": "muted" } }
40
+ ]
14
41
  },
15
- { "type": "text", "props": { "text": { "$bind": "$state.turnTotalText", "fallback": "Turn total 0 of 100." }, "role": "output" } },
16
42
  {
17
43
  "type": "row",
18
44
  "props": { "gap": "sm", "wrap": true },
@@ -20,26 +46,38 @@
20
46
  {
21
47
  "type": "button",
22
48
  "action": "roll",
23
- "when": { "all": [{ "equals": { "path": "$state.status", "value": "playing" } }, { "equals": { "path": "$state.turn", "value": "user" } }] },
49
+ "when": { "equals": { "path": "$state.userTurn", "value": true } },
24
50
  "props": { "label": "Roll", "variant": "primary" },
25
51
  "else": { "type": "button", "action": "roll", "props": { "label": "Roll", "variant": "primary", "disabled": true } }
26
52
  },
27
53
  {
28
54
  "type": "button",
29
55
  "action": "hold",
30
- "when": { "all": [{ "equals": { "path": "$state.status", "value": "playing" } }, { "equals": { "path": "$state.turn", "value": "user" } }] },
56
+ "when": { "equals": { "path": "$state.userTurn", "value": true } },
31
57
  "props": { "label": "Hold", "variant": "secondary" },
32
58
  "else": { "type": "button", "action": "hold", "props": { "label": "Hold", "variant": "secondary", "disabled": true } }
33
59
  },
34
- { "type": "button", "action": "reset", "props": { "label": "Reset", "variant": "ghost" } }
60
+ {
61
+ "type": "button",
62
+ "action": "reset",
63
+ "when": { "equals": { "path": "$state.complete", "value": true } },
64
+ "props": { "label": "New game", "variant": "primary" },
65
+ "else": { "type": "button", "action": "reset", "props": { "label": "Start over", "variant": "ghost" } }
66
+ }
35
67
  ]
36
68
  },
37
69
  {
38
- "type": "list",
39
- "bind": "recentRolls",
40
- "props": { "variant": "divided", "gap": "xs" },
70
+ "type": "section",
71
+ "props": { "label": "Recent rolls", "variant": "inset", "gap": "xs" },
41
72
  "children": [
42
- { "type": "text", "props": { "text": "$item.text", "role": "body" } }
73
+ {
74
+ "type": "list",
75
+ "bind": "recentRolls",
76
+ "props": { "variant": "divided", "gap": "xs" },
77
+ "children": [
78
+ { "type": "text", "props": { "text": "$item.text", "role": "body" } }
79
+ ]
80
+ }
43
81
  ]
44
82
  }
45
83
  ]
@@ -7,6 +7,7 @@
7
7
  var fs = require("fs");
8
8
  var { scanWorktrees, isWorktree } = require("./worktree");
9
9
  var { daemonSync } = require("./daemon-sync");
10
+ var logContext = require("./project-log-context");
10
11
 
11
12
  // --- Worktree tracking state ---
12
13
  var worktreeRegistry = {}; // parentSlug -> [wtSlug, ...]
@@ -28,7 +29,7 @@ function isWorktreeSlug(slug) {
28
29
  * @param {string} parentIcon - icon of parent project
29
30
  * @param {string} parentOwnerId - owner user ID
30
31
  */
31
- function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId) {
32
+ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, projectKnowledgeId) {
32
33
  if (isWorktree(parentPath)) return;
33
34
  var worktrees = scanWorktrees(parentPath);
34
35
  if (worktrees.length === 0) return;
@@ -41,13 +42,14 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
41
42
  if (worktreeRegistry[parentSlug][j] === wtSlug) { alreadyRegistered = true; break; }
42
43
  }
43
44
  if (alreadyRegistered) continue;
44
- var wtMeta = { parentSlug: parentSlug, branch: wt.branch || wt.dirName, external: wt.external };
45
+ var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName });
46
+ var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
45
47
  relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
46
48
  worktreeRegistry[parentSlug].push(wtSlug);
47
49
  console.log("[daemon] Registered worktree:", wtSlug, "->", wt.path, wt.external ? "(external)" : "(inside project folder)");
48
50
  }
49
51
  daemonSync.register(getWorktreeSyncKey(parentSlug), function () {
50
- rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId);
52
+ rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, null, projectKnowledgeId);
51
53
  });
52
54
  }
53
55
 
@@ -60,7 +62,7 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
60
62
  * @param {string} parentOwnerId - owner user ID
61
63
  * @param {object} [config] - daemon config (optional, for broadcasting project count)
62
64
  */
63
- function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, config) {
65
+ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, config, projectKnowledgeId) {
64
66
  if (worktreeScanning[parentSlug]) return;
65
67
  worktreeScanning[parentSlug] = true;
66
68
  try {
@@ -79,7 +81,8 @@ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerI
79
81
  if (existingSlugs[ei] === wtSlug) { found = true; break; }
80
82
  }
81
83
  if (!found) {
82
- var wtMeta = { parentSlug: parentSlug, branch: wt.branch || wt.dirName, external: wt.external };
84
+ var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName });
85
+ var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
83
86
  relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
84
87
  if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
85
88
  worktreeRegistry[parentSlug].push(wtSlug);
@@ -91,6 +94,7 @@ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerI
91
94
  var sSlug = existingSlugs[si];
92
95
  var dirName = sSlug.split("--").slice(1).join("--");
93
96
  if (!discoveredNames[dirName]) {
97
+ if (relay.setProjectLogContextState) relay.setProjectLogContextState(sSlug, "archived");
94
98
  relay.removeProject(sSlug);
95
99
  existingSlugs.splice(si, 1);
96
100
  console.log("[daemon] Rescan: removed stale worktree:", sSlug);
@@ -112,6 +116,7 @@ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerI
112
116
  function cleanupWorktreesForParent(relay, parentSlug) {
113
117
  var wtSlugs = worktreeRegistry[parentSlug] || [];
114
118
  for (var i = 0; i < wtSlugs.length; i++) {
119
+ if (relay.setProjectLogContextState) relay.setProjectLogContextState(wtSlugs[i], "archived");
115
120
  relay.removeProject(wtSlugs[i]);
116
121
  console.log("[daemon] Cascade removed worktree:", wtSlugs[i]);
117
122
  }
package/lib/daemon.js CHANGED
@@ -36,6 +36,7 @@ var { createWorktree, removeWorktree, isWorktree } = require("./worktree");
36
36
  var mates = require("./mates");
37
37
  var mateKnowledgeMigration = require("./mate-knowledge-migration");
38
38
  var { isWorktreeSlug, scanAndRegisterWorktrees, rescanWorktrees, cleanupWorktreesForParent, getFilteredRemovedProjects, registerWorktreeSlug, unregisterWorktreeSlug } = require("./daemon-projects");
39
+ var projectLogContext = require("./project-log-context");
39
40
 
40
41
  var daemonVersion = require("../package.json").version;
41
42
  var configFile = process.env.CLAY_CONFIG || process.env.CLAUDE_RELAY_CONFIG || require("./config").configPath();
@@ -65,6 +66,11 @@ try {
65
66
  console.error("[daemon] ensureMultiUser threw (continuing):", e && e.message);
66
67
  }
67
68
 
69
+ if (projectLogContext.initializeProjectKnowledge(config)) {
70
+ saveConfig(config);
71
+ console.log("[daemon] Assigned stable knowledge identities to existing projects");
72
+ }
73
+
68
74
  // --- OS users mode: check required system dependencies ---
69
75
  // Supplementary-group inheritance (issue #367): default on so sessions behave
70
76
  // like a normal login; operators can disable via config / the settings toggle.
@@ -153,6 +159,20 @@ var lanIp = (function () {
153
159
  // --- Create multi-project server ---
154
160
  var listenHost = config.host || "0.0.0.0";
155
161
 
162
+ function rememberedProjectKnowledgeId(projectPath) {
163
+ var removed = config.removedProjects || [];
164
+ for (var i = removed.length - 1; i >= 0; i--) {
165
+ if (removed[i].path === projectPath && projectLogContext.validId(removed[i].projectKnowledgeId)) {
166
+ return removed[i].projectKnowledgeId;
167
+ }
168
+ }
169
+ return projectLogContext.createProjectKnowledgeId();
170
+ }
171
+
172
+ function projectRuntimeOptions(project) {
173
+ return { projectKnowledgeId: project.projectKnowledgeId };
174
+ }
175
+
156
176
  var relay = createServer({
157
177
  tlsOptions: tlsOptions,
158
178
  caPath: caRoot,
@@ -173,13 +193,14 @@ var relay = createServer({
173
193
  }
174
194
  var slugs = config.projects.map(function (p) { return p.slug; });
175
195
  var slug = generateSlug(absPath, slugs);
176
- relay.addProject(absPath, slug, null, null, wsUser ? wsUser.id : null);
177
196
  var projectEntry = { path: absPath, slug: slug, addedAt: Date.now(), visibility: "private" };
197
+ projectEntry.projectKnowledgeId = rememberedProjectKnowledgeId(absPath);
178
198
  // The user who adds a project always becomes the owner
179
199
  if (wsUser && wsUser.id) {
180
200
  projectEntry.ownerId = wsUser.id;
181
201
  }
182
202
  config.projects.push(projectEntry);
203
+ relay.addProject(absPath, slug, null, null, wsUser ? wsUser.id : null, null, projectRuntimeOptions(projectEntry));
183
204
  // Remove from removedProjects if present
184
205
  if (config.removedProjects) {
185
206
  config.removedProjects = config.removedProjects.filter(function (rp) { return rp.path !== absPath; });
@@ -198,7 +219,7 @@ var relay = createServer({
198
219
  }
199
220
  }
200
221
  // Discover and register worktrees for the new project
201
- scanAndRegisterWorktrees(relay, absPath, slug, null, wsUser && wsUser.id && wsUser.role !== "admin" ? wsUser.id : null);
222
+ scanAndRegisterWorktrees(relay, absPath, slug, null, wsUser && wsUser.id && wsUser.role !== "admin" ? wsUser.id : null, projectEntry.projectKnowledgeId);
202
223
  // Broadcast updated project list to all clients
203
224
  relay.broadcastAll({
204
225
  type: "projects_updated",
@@ -255,10 +276,11 @@ var relay = createServer({
255
276
  }
256
277
  // Register project - creator always becomes owner, default private
257
278
  var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now(), visibility: "private" };
279
+ projectEntry.projectKnowledgeId = projectLogContext.createProjectKnowledgeId();
258
280
  if (wsUser && wsUser.id) {
259
281
  projectEntry.ownerId = wsUser.id;
260
282
  }
261
- relay.addProject(targetDir, slug, null, null, wsUser ? wsUser.id : null);
283
+ relay.addProject(targetDir, slug, null, null, wsUser ? wsUser.id : null, null, projectRuntimeOptions(projectEntry));
262
284
  config.projects.push(projectEntry);
263
285
  saveConfig(config);
264
286
  try { syncClayrc(config.projects); } catch (e) {}
@@ -345,10 +367,11 @@ var relay = createServer({
345
367
  // Register project - creator always becomes owner
346
368
  // Creator always becomes owner, default private
347
369
  var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now(), visibility: "private" };
370
+ projectEntry.projectKnowledgeId = projectLogContext.createProjectKnowledgeId();
348
371
  if (wsUser && wsUser.id) {
349
372
  projectEntry.ownerId = wsUser.id;
350
373
  }
351
- relay.addProject(targetDir, slug);
374
+ relay.addProject(targetDir, slug, null, null, wsUser ? wsUser.id : null, null, projectRuntimeOptions(projectEntry));
352
375
  config.projects.push(projectEntry);
353
376
  saveConfig(config);
354
377
  try { syncClayrc(config.projects); } catch (e) {}
@@ -380,11 +403,14 @@ var relay = createServer({
380
403
  if (config.projects[pi].slug === wtParent) { parentProject = config.projects[pi]; break; }
381
404
  }
382
405
  if (parentProject) {
406
+ var wtPath = path.join(parentProject.path, wtDirName);
407
+ var contextStatus = projectLogContext.isMerged(parentProject.path, wtPath) ? "merged" : "archived";
383
408
  var rmResult = removeWorktree(parentProject.path, wtDirName);
384
409
  if (!rmResult.ok) {
385
410
  console.log("[daemon] Failed to remove worktree:", slug, rmResult.error);
386
411
  return { ok: false, error: rmResult.error };
387
412
  }
413
+ relay.setProjectLogContextState(slug, contextStatus);
388
414
  }
389
415
  relay.removeProject(slug);
390
416
  unregisterWorktreeSlug(wtParent, slug);
@@ -410,6 +436,7 @@ var relay = createServer({
410
436
  title: found.title || null,
411
437
  icon: found.icon || null,
412
438
  userId: userId || null,
439
+ projectKnowledgeId: found.projectKnowledgeId || null,
413
440
  removedAt: Date.now(),
414
441
  });
415
442
  // Cap at 20 entries (oldest first)
@@ -740,6 +767,7 @@ var relay = createServer({
740
767
  inheritGroups: config.inheritGroups !== false,
741
768
  chatLayout: config.chatLayout || "channel",
742
769
  matesEnabled: config.matesEnabled === true,
770
+ capsulesEnabled: config.capsulesEnabled === true,
743
771
  pinEnabled: !!config.pinHash,
744
772
  platform: process.platform,
745
773
  hostname: os2.hostname(),
@@ -818,6 +846,13 @@ var relay = createServer({
818
846
  console.log("[daemon] Project Mate DMs:", want ? "shown" : "hidden", "(web)");
819
847
  return { ok: true, matesEnabled: want };
820
848
  },
849
+ onSetCapsulesEnabled: function (value) {
850
+ var want = value === true;
851
+ config.capsulesEnabled = want;
852
+ saveConfig(config);
853
+ console.log("[daemon] Experimental Capsules:", want ? "enabled" : "disabled", "(web)");
854
+ return { ok: true, capsulesEnabled: want };
855
+ },
821
856
  onGetToolPalettes: function () {
822
857
  return config.toolPalettes || {};
823
858
  },
@@ -976,11 +1011,16 @@ var relay = createServer({
976
1011
  }
977
1012
  if (!parent) return { ok: false, error: "Parent project not found" };
978
1013
  if (isWorktree(parent.path)) return { ok: false, error: "Cannot create worktrees from a worktree project" };
1014
+ var baseCommit = projectLogContext.commit(parent.path, baseBranch || "HEAD");
979
1015
  var result = createWorktree(parent.path, branchName, dirName, baseBranch);
980
1016
  if (!result.ok) return result;
981
1017
  // Register the new worktree as ephemeral project
982
1018
  var wtSlug = parentSlug + "--" + dirName;
983
- var wtMeta = { parentSlug: parentSlug, branch: branchName, external: false };
1019
+ var context = projectLogContext.resolveWorktreeContext(result.path, parent.projectKnowledgeId, {
1020
+ branch: branchName,
1021
+ baseCommit: baseCommit,
1022
+ });
1023
+ var wtMeta = Object.assign({ parentSlug: parentSlug, external: false, projectKnowledgeId: parent.projectKnowledgeId }, context);
984
1024
  relay.addProject(result.path, wtSlug, branchName, parent.icon, parent.ownerId, wtMeta);
985
1025
  registerWorktreeSlug(parentSlug, wtSlug);
986
1026
  console.log("[daemon] Created worktree:", wtSlug, "->", result.path);
@@ -1066,13 +1106,15 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1066
1106
  }
1067
1107
  var slugs = config.projects.map(function (p) { return p.slug; });
1068
1108
  var slug = generateSlug(absPath, slugs);
1069
- relay.addProject(absPath, slug);
1070
- config.projects.push({ path: absPath, slug: slug, addedAt: Date.now(), visibility: "private" });
1109
+ var projectEntry = { path: absPath, slug: slug, addedAt: Date.now(), visibility: "private" };
1110
+ projectEntry.projectKnowledgeId = rememberedProjectKnowledgeId(absPath);
1111
+ relay.addProject(absPath, slug, null, null, null, null, projectRuntimeOptions(projectEntry));
1112
+ config.projects.push(projectEntry);
1071
1113
  saveConfig(config);
1072
1114
  try { syncClayrc(config.projects); } catch (e) {}
1073
1115
  console.log("[daemon] Added project:", slug, "→", absPath);
1074
1116
  // Discover and register worktrees for the new project
1075
- scanAndRegisterWorktrees(relay, absPath, slug, null, null);
1117
+ scanAndRegisterWorktrees(relay, absPath, slug, null, null, projectEntry.projectKnowledgeId);
1076
1118
  relay.broadcastAll({
1077
1119
  type: "projects_updated",
1078
1120
  projects: relay.getProjects(),
@@ -1094,6 +1136,23 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1094
1136
  }
1095
1137
  }
1096
1138
  if (!target) return { ok: false, error: "project not found" };
1139
+ var removedProject = null;
1140
+ for (var ri = 0; ri < config.projects.length; ri++) {
1141
+ if (config.projects[ri].slug === target) { removedProject = config.projects[ri]; break; }
1142
+ }
1143
+ if (removedProject) {
1144
+ if (!config.removedProjects) config.removedProjects = [];
1145
+ config.removedProjects.push({
1146
+ path: removedProject.path,
1147
+ title: removedProject.title || null,
1148
+ icon: removedProject.icon || null,
1149
+ projectKnowledgeId: removedProject.projectKnowledgeId || null,
1150
+ removedAt: Date.now(),
1151
+ });
1152
+ if (config.removedProjects.length > 20) {
1153
+ config.removedProjects = config.removedProjects.slice(config.removedProjects.length - 20);
1154
+ }
1155
+ }
1097
1156
  relay.removeProject(target);
1098
1157
  config.projects = config.projects.filter(function (p) { return p.slug !== target; });
1099
1158
  saveConfig(config);
@@ -1284,9 +1343,9 @@ for (var i = 0; i < projects.length; i++) {
1284
1343
  var p = projects[i];
1285
1344
  if (fs.existsSync(p.path)) {
1286
1345
  console.log("[daemon] Adding project:", p.slug, "→", p.path);
1287
- relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId);
1346
+ relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId, null, projectRuntimeOptions(p));
1288
1347
  // Discover and register worktrees for this project
1289
- scanAndRegisterWorktrees(relay, p.path, p.slug, p.icon, p.ownerId);
1348
+ scanAndRegisterWorktrees(relay, p.path, p.slug, p.icon, p.ownerId, p.projectKnowledgeId);
1290
1349
  } else {
1291
1350
  console.log("[daemon] Skipping missing project:", p.path);
1292
1351
  }
@@ -23,6 +23,7 @@ var SETTING_KEYS = [
23
23
  "chatLayout",
24
24
  "autoContinueOnRateLimit",
25
25
  "matesEnabled",
26
+ "capsulesEnabled",
26
27
  "terminalFont",
27
28
  "deletedBuiltinKeys",
28
29
  "mateOnboardingShown",
@@ -189,6 +190,7 @@ function migrateSingleUserToMulti() {
189
190
  if (cfg.chatLayout !== undefined && users.setChatLayout) users.setChatLayout(userId, cfg.chatLayout);
190
191
  if (cfg.autoContinueOnRateLimit !== undefined && users.setAutoContinue) users.setAutoContinue(userId, cfg.autoContinueOnRateLimit);
191
192
  if (cfg.matesEnabled !== undefined && users.setMatesEnabled) users.setMatesEnabled(userId, cfg.matesEnabled);
193
+ if (cfg.capsulesEnabled !== undefined && users.setCapsulesEnabled) users.setCapsulesEnabled(userId, cfg.capsulesEnabled);
192
194
  if (cfg.terminalFont && users.setTerminalFont) {
193
195
  users.setTerminalFont(userId, cfg.terminalFont.family, cfg.terminalFont.size);
194
196
  }