clay-server 4.0.0-beta.8 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/lib/capsule-display-floor.js +51 -0
  2. package/lib/capsule-frame-server.js +255 -0
  3. package/lib/capsule-mate-turn.js +105 -0
  4. package/lib/capsule-pig-logic.js +300 -0
  5. package/lib/capsule-server-runtimes.js +39 -0
  6. package/lib/capsules/pig/display.js +198 -0
  7. package/lib/capsules/pig/manifest.json +9 -0
  8. package/lib/capsules/pig/ui.json +84 -0
  9. package/lib/config.js +25 -1
  10. package/lib/daemon-projects.js +13 -8
  11. package/lib/daemon.js +127 -14
  12. package/lib/git-cli.js +95 -1
  13. package/lib/knowledge-import.js +436 -0
  14. package/lib/knowledge-record-store.js +188 -0
  15. package/lib/knowledge-search.js +240 -0
  16. package/lib/mate-knowledge-mcp-server.js +151 -0
  17. package/lib/mate-knowledge-migration.js +283 -0
  18. package/lib/mate-knowledge-service.js +488 -0
  19. package/lib/mate-knowledge-sync.js +482 -0
  20. package/lib/migrate-single-user.js +2 -0
  21. package/lib/notes-lifecycle.js +149 -0
  22. package/lib/notes.js +60 -3
  23. package/lib/os-users.js +3 -1
  24. package/lib/project-capsule-catalog.js +9 -1
  25. package/lib/project-capsule-turn.js +96 -0
  26. package/lib/project-connection.js +15 -1
  27. package/lib/project-file-history.js +223 -0
  28. package/lib/project-file-watch.js +77 -49
  29. package/lib/project-filesystem.js +31 -220
  30. package/lib/project-http.js +15 -1
  31. package/lib/project-knowledge.js +22 -0
  32. package/lib/project-log-context.js +172 -0
  33. package/lib/project-log-feedback-delivery.js +118 -0
  34. package/lib/project-logs-comments.js +137 -0
  35. package/lib/project-logs-context-state.js +42 -0
  36. package/lib/project-logs-mcp-server.js +328 -0
  37. package/lib/project-logs-query.js +185 -0
  38. package/lib/project-logs-root.js +75 -0
  39. package/lib/project-logs-schema.js +259 -0
  40. package/lib/project-logs-service.js +463 -0
  41. package/lib/project-logs-snapshot.js +205 -0
  42. package/lib/project-logs-store.js +495 -0
  43. package/lib/project-logs-versioning.js +132 -0
  44. package/lib/project-logs.js +400 -0
  45. package/lib/project-mate-interaction.js +14 -0
  46. package/lib/project-mate-knowledge.js +110 -0
  47. package/lib/project-memory.js +22 -0
  48. package/lib/project-message-delivery.js +70 -0
  49. package/lib/project-pair-lifecycle.js +460 -0
  50. package/lib/project-request-access.js +66 -0
  51. package/lib/project-session-handoff.js +1 -1
  52. package/lib/project-session-notes.js +64 -13
  53. package/lib/project-session-pair.js +159 -146
  54. package/lib/project-session-spawn.js +1 -1
  55. package/lib/project-sessions.js +58 -13
  56. package/lib/project-user-message.js +53 -4
  57. package/lib/project-worker-permission.js +430 -0
  58. package/lib/project-worker-proposal.js +208 -62
  59. package/lib/project.js +185 -6
  60. package/lib/public/app.js +63 -35
  61. package/lib/public/css/admin.css +2 -2
  62. package/lib/public/css/capsule-ui.css +38 -0
  63. package/lib/public/css/command-palette.css +19 -9
  64. package/lib/public/css/git-placard.css +154 -0
  65. package/lib/public/css/home-hub.css +4 -0
  66. package/lib/public/css/home-session-actions.css +66 -0
  67. package/lib/public/css/home-sidebar.css +66 -0
  68. package/lib/public/css/input.css +84 -3
  69. package/lib/public/css/messages.css +40 -1
  70. package/lib/public/css/mobile-nav.css +80 -0
  71. package/lib/public/css/notifications-center.css +67 -0
  72. package/lib/public/css/pane.css +97 -10
  73. package/lib/public/css/project-logs.css +198 -0
  74. package/lib/public/css/scheduler.css +1 -1
  75. package/lib/public/css/session-actions.css +16 -101
  76. package/lib/public/css/sidebar.css +103 -0
  77. package/lib/public/css/sticky-notes.css +237 -229
  78. package/lib/public/css/worker-proposal.css +36 -3
  79. package/lib/public/index.html +43 -11
  80. package/lib/public/modules/admin.js +8 -8
  81. package/lib/public/modules/app-connection.js +124 -6
  82. package/lib/public/modules/app-home-hub.js +12 -8
  83. package/lib/public/modules/app-message-cards.js +2 -1
  84. package/lib/public/modules/app-messages.js +92 -11
  85. package/lib/public/modules/app-misc.js +2 -14
  86. package/lib/public/modules/app-notifications.js +11 -7
  87. package/lib/public/modules/app-panels.js +3 -2
  88. package/lib/public/modules/app-projects.js +56 -18
  89. package/lib/public/modules/app-rendering.js +1 -0
  90. package/lib/public/modules/capsule-preference.js +44 -0
  91. package/lib/public/modules/chat-bubble-renderer.js +1 -1
  92. package/lib/public/modules/clay-log-links.js +70 -0
  93. package/lib/public/modules/command-palette.js +52 -15
  94. package/lib/public/modules/context-sources.js +0 -3
  95. package/lib/public/modules/git-agent-sessions.js +85 -0
  96. package/lib/public/modules/git-panel.js +111 -84
  97. package/lib/public/modules/git-placard.js +161 -0
  98. package/lib/public/modules/home-chat-empty-state.js +39 -0
  99. package/lib/public/modules/home-chat-identity.js +9 -0
  100. package/lib/public/modules/home-conversations-sheet.js +102 -48
  101. package/lib/public/modules/home-mate-chat.js +15 -35
  102. package/lib/public/modules/home-session-actions.js +6 -0
  103. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  104. package/lib/public/modules/home-sidebar.js +7 -0
  105. package/lib/public/modules/home-surface-boot.js +7 -3
  106. package/lib/public/modules/home-tool-frame.js +182 -0
  107. package/lib/public/modules/home-tools.js +138 -1
  108. package/lib/public/modules/input.js +38 -7
  109. package/lib/public/modules/markdown.js +2 -0
  110. package/lib/public/modules/mate-sidebar.js +0 -8
  111. package/lib/public/modules/message-delivery.js +97 -0
  112. package/lib/public/modules/paste-modal.js +84 -0
  113. package/lib/public/modules/project-activation.js +45 -0
  114. package/lib/public/modules/project-logs-render.js +405 -0
  115. package/lib/public/modules/project-logs.js +461 -0
  116. package/lib/public/modules/project-removal-target.js +26 -0
  117. package/lib/public/modules/scheduler.js +17 -3
  118. package/lib/public/modules/session-actions.js +29 -105
  119. package/lib/public/modules/session-hierarchy.js +54 -0
  120. package/lib/public/modules/sidebar-mobile.js +40 -15
  121. package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
  122. package/lib/public/modules/sidebar-sessions.js +77 -11
  123. package/lib/public/modules/sidebar.js +17 -5
  124. package/lib/public/modules/split-group-helpers.js +8 -0
  125. package/lib/public/modules/split-pair-ui.js +15 -4
  126. package/lib/public/modules/split-view.js +5 -2
  127. package/lib/public/modules/split-worker-runtime.js +24 -0
  128. package/lib/public/modules/sticky-note-markdown.js +5 -0
  129. package/lib/public/modules/sticky-notes-browser.js +326 -0
  130. package/lib/public/modules/sticky-notes-card.js +360 -0
  131. package/lib/public/modules/sticky-notes-editor.js +291 -0
  132. package/lib/public/modules/sticky-notes-shared.js +90 -0
  133. package/lib/public/modules/sticky-notes.js +170 -946
  134. package/lib/public/modules/thinking-lifecycle.js +175 -0
  135. package/lib/public/modules/thinking-summary.js +28 -0
  136. package/lib/public/modules/thinking-view.js +75 -0
  137. package/lib/public/modules/tool-palette-order.js +143 -0
  138. package/lib/public/modules/tool-palette-overlays.js +159 -0
  139. package/lib/public/modules/tool-palette.js +21 -188
  140. package/lib/public/modules/tools.js +31 -123
  141. package/lib/public/modules/update-snooze.js +169 -0
  142. package/lib/public/modules/user-settings.js +4 -0
  143. package/lib/public/modules/worker-pane-lock.js +176 -0
  144. package/lib/public/modules/worker-proposal-state.js +16 -0
  145. package/lib/public/modules/worker-proposal.js +49 -20
  146. package/lib/public/style.css +3 -1
  147. package/lib/sdk-bridge.js +135 -46
  148. package/lib/sdk-message-processor.js +36 -19
  149. package/lib/server-experimental-settings.js +49 -0
  150. package/lib/server-global-ws.js +11 -0
  151. package/lib/server-home-chat-events.js +31 -1
  152. package/lib/server-home-chat.js +27 -4
  153. package/lib/server-home-clay-entry.js +1 -1
  154. package/lib/server-home-clay-session-links.js +34 -5
  155. package/lib/server-settings.js +5 -0
  156. package/lib/server-tools.js +127 -9
  157. package/lib/server.js +137 -22
  158. package/lib/session-driver-eligibility.js +38 -0
  159. package/lib/session-driver-orchestration.js +31 -0
  160. package/lib/session-hygiene.js +3 -0
  161. package/lib/session-notes-mcp-server.js +25 -5
  162. package/lib/session-pair-factory.js +247 -0
  163. package/lib/session-pair-mcp-server.js +46 -8
  164. package/lib/session-pair-prompts.js +79 -0
  165. package/lib/session-pair-turn-control.js +153 -0
  166. package/lib/session-provenance.js +119 -0
  167. package/lib/session-spawn-mcp-server.js +1 -1
  168. package/lib/session-split-groups.js +4 -1
  169. package/lib/session-title-policy.js +60 -0
  170. package/lib/session-visibility.js +41 -0
  171. package/lib/sessions.js +57 -10
  172. package/lib/tools-registry.js +89 -10
  173. package/lib/update-snooze.js +437 -0
  174. package/lib/users-experimental-preferences.js +30 -0
  175. package/lib/users-permissions.js +6 -6
  176. package/lib/users.js +22 -16
  177. package/lib/workspace-query-access.js +102 -0
  178. package/lib/workspace-query-service.js +24 -17
  179. package/lib/worktree.js +25 -38
  180. package/lib/ws-schema.js +38 -7
  181. package/lib/yoke/adapters/claude-worker.js +16 -6
  182. package/lib/yoke/adapters/claude.js +12 -0
  183. package/lib/yoke/adapters/codex.js +24 -14
  184. package/lib/yoke/vendor-registry.js +1 -1
  185. package/package.json +2 -2
@@ -0,0 +1,437 @@
1
+ // Update-notification capability and snooze policy.
2
+ //
3
+ // Two concerns that belong together because both decide whether a given
4
+ // connection may be told that a new version exists:
5
+ //
6
+ // 1. Capability. Update version metadata and update actions are
7
+ // admin-grade. In multi-user mode that means an authenticated user whose
8
+ // role is "admin"; a connection with no identity is refused. A
9
+ // single-user install has no user records at all by design, and its sole
10
+ // local operator is the owner, so it is admin-grade. This is the one
11
+ // canonical check; do not re-derive it anywhere else.
12
+ //
13
+ // 2. Snooze. A per-user, per-version deadline stored on the server so it
14
+ // survives reloads and applies on every device. Snoozing 4.1.0 says
15
+ // nothing about 4.2.0, so a newer release notifies immediately.
16
+ //
17
+ // The store is a small JSON file under CONFIG_DIR rather than the users
18
+ // record, because a single-user install has no users record to write to and
19
+ // the same policy has to work in both modes.
20
+
21
+ var crypto = require("crypto");
22
+ var fs = require("fs");
23
+ var path = require("path");
24
+ var config = require("./config");
25
+
26
+ var STORE_VERSION = 1;
27
+ var STORE_FILE = "update-snooze.json";
28
+
29
+ // Requested durations are chosen from this allowlist by key. A duration is
30
+ // never read as a number from the payload, and a deadline is never read from
31
+ // the payload at all: the server computes every instant itself.
32
+ var SNOOZE_OPTIONS = {
33
+ "3h": { label: "3 hours", ms: 3 * 60 * 60 * 1000 },
34
+ "8h": { label: "8 hours", ms: 8 * 60 * 60 * 1000 },
35
+ "tomorrow": { label: "Tomorrow", tomorrow: true },
36
+ };
37
+
38
+ // "Tomorrow" means the next calendar day at 09:00 local time, so at 23:00 it
39
+ // is 10 hours away and at 00:30 it is 32.5 hours away. That upper bound plus
40
+ // headroom is the hard server-side maximum: whatever timezone a client claims,
41
+ // the stored deadline is clamped to it.
42
+ var TOMORROW_HOUR = 9;
43
+ var MAX_SNOOZE_MS = 36 * 60 * 60 * 1000;
44
+
45
+ // A UTC offset outside real-world bounds is refused rather than clamped, so a
46
+ // malformed hint falls back to server local time instead of silently skewing.
47
+ var MIN_TZ_OFFSET_MINUTES = -14 * 60;
48
+ var MAX_TZ_OFFSET_MINUTES = 14 * 60;
49
+
50
+ // The key a single-user install stores its snooze under. It is a server-side
51
+ // constant, never a value a client can supply.
52
+ var LOCAL_USER_KEY = "__local__";
53
+
54
+ function storePath() {
55
+ return path.join(config.CONFIG_DIR, STORE_FILE);
56
+ }
57
+
58
+ function emptyStore() {
59
+ return { version: STORE_VERSION, users: {} };
60
+ }
61
+
62
+ function loadStore() {
63
+ var raw;
64
+ try {
65
+ raw = fs.readFileSync(storePath(), "utf8");
66
+ } catch (e) {
67
+ return emptyStore();
68
+ }
69
+ var parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch (e) {
73
+ // A torn or hand-edited file must not break update notifications; it just
74
+ // means nothing is snoozed.
75
+ return emptyStore();
76
+ }
77
+ if (!parsed || typeof parsed !== "object" || !parsed.users || typeof parsed.users !== "object") {
78
+ return emptyStore();
79
+ }
80
+ return { version: STORE_VERSION, users: parsed.users };
81
+ }
82
+
83
+ // A per-writer temp name. A shared ".tmp" would let two concurrent writers
84
+ // scribble over each other's half-written file and then rename it into place,
85
+ // publishing a mix of both.
86
+ var tmpCounter = 0;
87
+
88
+ function saveStore(store) {
89
+ try {
90
+ config.ensureConfigDir();
91
+ } catch (e) {}
92
+ var target = storePath();
93
+ tmpCounter++;
94
+ var tmp = target + "." + process.pid + "-" + tmpCounter + ".tmp";
95
+ try {
96
+ fs.writeFileSync(tmp, JSON.stringify(store), { mode: 0o600 });
97
+ fs.renameSync(tmp, target);
98
+ return true;
99
+ } catch (e) {
100
+ try { fs.unlinkSync(tmp); } catch (e2) {}
101
+ return false;
102
+ }
103
+ }
104
+
105
+ // --- Write serialization --------------------------------------------------
106
+ //
107
+ // One user's snooze must never erase another's. Node's synchronous I/O already
108
+ // serializes writers inside a process, but two daemons can share a CLAY_HOME,
109
+ // so read-modify-write is done under an exclusive lock file and the store is
110
+ // re-read inside that lock. That makes each mutation a merge into whatever is
111
+ // currently on disk rather than an overwrite with a stale snapshot.
112
+ //
113
+ // Follows the `fs.openSync(path, "wx")` convention already used for the Mate
114
+ // knowledge migration lock, with a short bounded wait because these writes are
115
+ // tiny and rare (a person clicking Snooze).
116
+ //
117
+ // A live holder always wins, however old its lock is: a daemon paused under a
118
+ // debugger is still holding the store, and stealing from it would let two
119
+ // writers merge against the same snapshot. Age alone can only reclaim a lock
120
+ // whose holder is confirmed dead or cannot be identified at all.
121
+
122
+ var LOCK_RETRIES = 25;
123
+ var LOCK_WAIT_MS = 2;
124
+ var LOCK_STALE_MS = 5000;
125
+
126
+ function lockPath() {
127
+ return storePath() + ".lock";
128
+ }
129
+
130
+ function sleepBriefly(ms) {
131
+ try {
132
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
133
+ } catch (e) {
134
+ // Without SharedArrayBuffer the retry loop simply spins; it is bounded.
135
+ }
136
+ }
137
+
138
+ // Delegates to the shared helper, whose contract is exactly what this lock
139
+ // needs: a positive-integer pid, EPERM counted as alive (a second daemon run
140
+ // by another user still holds the store), ESRCH as the only dead, and any
141
+ // unanswerable error as alive. Erring towards "alive" is the safe direction
142
+ // here too: the worst case is refusing to write, never stealing a live lock.
143
+ function isHolderAlive(pid) {
144
+ return config.isPidAlive(pid);
145
+ }
146
+
147
+ function readHolder(target) {
148
+ var raw;
149
+ try {
150
+ raw = fs.readFileSync(target, "utf8");
151
+ } catch (e) {
152
+ return null;
153
+ }
154
+ var parsed;
155
+ try {
156
+ parsed = JSON.parse(raw);
157
+ } catch (e) {
158
+ return { pid: null, nonce: null, startedAt: 0 };
159
+ }
160
+ if (!parsed || typeof parsed !== "object") return { pid: null, nonce: null, startedAt: 0 };
161
+ return {
162
+ pid: typeof parsed.pid === "number" ? parsed.pid : null,
163
+ nonce: typeof parsed.nonce === "string" ? parsed.nonce : null,
164
+ startedAt: typeof parsed.startedAt === "number" ? parsed.startedAt : 0,
165
+ };
166
+ }
167
+
168
+ // The nonce is what makes release safe. A pid alone cannot distinguish our own
169
+ // lock from a successor's lock written by a recycled pid, so every acquisition
170
+ // stamps a fresh random nonce and release only unlinks a file that still
171
+ // carries it.
172
+ function writeLock(target) {
173
+ var nonce = crypto.randomBytes(12).toString("hex");
174
+ var payload = JSON.stringify({ pid: process.pid, nonce: nonce, startedAt: Date.now() }) + "\n";
175
+ var fd = fs.openSync(target, "wx");
176
+ try {
177
+ fs.writeSync(fd, Buffer.from(payload, "utf8"));
178
+ } finally {
179
+ fs.closeSync(fd);
180
+ }
181
+ return { path: target, pid: process.pid, nonce: nonce };
182
+ }
183
+
184
+ function acquireLock() {
185
+ var target = lockPath();
186
+ for (var attempt = 0; attempt < LOCK_RETRIES; attempt++) {
187
+ try {
188
+ return writeLock(target);
189
+ } catch (e) {
190
+ if (!e || e.code !== "EEXIST") return null;
191
+ }
192
+
193
+ var holder = readHolder(target);
194
+ if (!holder) continue; // vanished between open and read: try again
195
+
196
+ if (holder.pid !== null) {
197
+ // An identifiable holder is only ever reclaimed once it is confirmed
198
+ // dead. Age is irrelevant. A recycled pid can therefore only make us
199
+ // wait and ultimately refuse the write, never double-enter.
200
+ if (isHolderAlive(holder.pid)) {
201
+ sleepBriefly(LOCK_WAIT_MS);
202
+ continue;
203
+ }
204
+ } else {
205
+ // No identifiable holder: reclaim only once unambiguously abandoned.
206
+ // The recorded stamp is preferred over mtime, which anything can touch;
207
+ // an unparseable lock has no stamp, so mtime is the only clock left.
208
+ var stamp = holder.startedAt;
209
+ if (!stamp) {
210
+ try { stamp = fs.statSync(target).mtimeMs; } catch (e2) { stamp = 0; }
211
+ }
212
+ var age = Date.now() - stamp;
213
+ // A future stamp (clock skew, or a hostile value) must not wedge the
214
+ // lock forever; it simply starts ageing from now.
215
+ if (age < 0) age = 0;
216
+ if (age < LOCK_STALE_MS) {
217
+ sleepBriefly(LOCK_WAIT_MS);
218
+ continue;
219
+ }
220
+ }
221
+
222
+ // Reclaim, then loop so the exclusive create decides the winner rather
223
+ // than this process assuming it won the race.
224
+ try { fs.unlinkSync(target); } catch (e3) {}
225
+ }
226
+ return null;
227
+ }
228
+
229
+ // Only unlink a lock this call still owns. If the file now carries a different
230
+ // pid or nonce, ownership changed while we held it and the file belongs to a
231
+ // successor, so removing it would release someone else's lock.
232
+ function releaseLock(token) {
233
+ if (!token || !token.path) return;
234
+ var holder = readHolder(token.path);
235
+ if (!holder) return;
236
+ if (holder.pid !== token.pid || holder.nonce !== token.nonce) return;
237
+ try { fs.unlinkSync(token.path); } catch (e) {}
238
+ }
239
+
240
+ // Apply `mutator` to the store under the lock. The mutator receives the store
241
+ // as it exists on disk right now and mutates it in place; returning false means
242
+ // "nothing changed", so no write happens.
243
+ function mutate(mutator) {
244
+ try {
245
+ config.ensureConfigDir();
246
+ } catch (e) {}
247
+ var lock = acquireLock();
248
+ if (!lock) return false;
249
+ try {
250
+ var store = loadStore();
251
+ if (mutator(store) === false) return true;
252
+ return saveStore(store);
253
+ } catch (e) {
254
+ return false;
255
+ } finally {
256
+ releaseLock(lock);
257
+ }
258
+ }
259
+
260
+ // --- Capability -----------------------------------------------------------
261
+
262
+ // The canonical admin-grade check for everything update-related: delivery of
263
+ // version metadata, the snooze action, manual checks, channel changes, and
264
+ // installing.
265
+ //
266
+ // `conn._clayUser` is a snapshot taken when the socket was opened and can be
267
+ // arbitrarily stale: a user demoted or deleted mid-session still carries
268
+ // role "admin" on their live connection. So the snapshot is only used for its
269
+ // id, and the role is re-resolved from the authoritative users store on every
270
+ // delivery and every action. Both must independently say "admin"; a missing
271
+ // record, a throwing store, or any disagreement between the two is a denial.
272
+ function isUpdateAdmin(conn, usersModule) {
273
+ if (!conn) return false;
274
+ var multiUser = false;
275
+ try {
276
+ multiUser = !!(usersModule && typeof usersModule.isMultiUser === "function" && usersModule.isMultiUser());
277
+ } catch (e) {
278
+ // An unreadable users record must not be treated as "no restrictions".
279
+ return false;
280
+ }
281
+ if (!multiUser) return true;
282
+
283
+ var user = conn._clayUser;
284
+ if (!user || !user.id || user.role !== "admin") return false;
285
+ if (!usersModule || typeof usersModule.findUserById !== "function") return false;
286
+
287
+ var live;
288
+ try {
289
+ live = usersModule.findUserById(user.id);
290
+ } catch (e) {
291
+ return false;
292
+ }
293
+ // Deleted between connect and now, or demoted since.
294
+ if (!live || live.role !== "admin") return false;
295
+ return true;
296
+ }
297
+
298
+ // The storage key for a connection. Returns null when multi-user mode cannot
299
+ // attribute the connection, so callers persist nothing rather than guessing.
300
+ // Identity is always read from the connection, never from a message payload.
301
+ function userKeyFor(conn, usersModule) {
302
+ if (!isUpdateAdmin(conn, usersModule)) return null;
303
+ var multiUser = false;
304
+ try {
305
+ multiUser = !!(usersModule && typeof usersModule.isMultiUser === "function" && usersModule.isMultiUser());
306
+ } catch (e) {
307
+ return null;
308
+ }
309
+ if (!multiUser) return LOCAL_USER_KEY;
310
+ var user = conn._clayUser;
311
+ if (!user || !user.id) return null;
312
+ return String(user.id);
313
+ }
314
+
315
+ // --- Deadlines ------------------------------------------------------------
316
+
317
+ function isValidTzOffset(value) {
318
+ return typeof value === "number" && isFinite(value) &&
319
+ Math.floor(value) === value &&
320
+ value >= MIN_TZ_OFFSET_MINUTES && value <= MAX_TZ_OFFSET_MINUTES;
321
+ }
322
+
323
+ // Next calendar day at TOMORROW_HOUR, in the zone described by a UTC offset in
324
+ // minutes east of UTC, expressed as a UTC instant. With no usable offset the
325
+ // server's own local time is used, which is the safe default because it is the
326
+ // only clock the server can vouch for.
327
+ function nextLocalMorning(nowMs, tzOffsetMinutes) {
328
+ if (!isValidTzOffset(tzOffsetMinutes)) {
329
+ var local = new Date(nowMs);
330
+ var serverTarget = new Date(local.getFullYear(), local.getMonth(), local.getDate() + 1, TOMORROW_HOUR, 0, 0, 0);
331
+ return serverTarget.getTime();
332
+ }
333
+ var offsetMs = tzOffsetMinutes * 60 * 1000;
334
+ // Shift into the target zone, take the calendar date there, then shift back.
335
+ var shifted = new Date(nowMs + offsetMs);
336
+ var midnightNextDayUtc = Date.UTC(
337
+ shifted.getUTCFullYear(),
338
+ shifted.getUTCMonth(),
339
+ shifted.getUTCDate() + 1,
340
+ TOMORROW_HOUR, 0, 0, 0
341
+ );
342
+ return midnightNextDayUtc - offsetMs;
343
+ }
344
+
345
+ // Resolve an allowlisted duration key to a UTC deadline, clamped to the
346
+ // server-side maximum. Returns null for anything not on the allowlist.
347
+ function resolveDeadline(durationKey, nowMs, tzOffsetMinutes) {
348
+ if (typeof durationKey !== "string") return null;
349
+ var option = Object.prototype.hasOwnProperty.call(SNOOZE_OPTIONS, durationKey)
350
+ ? SNOOZE_OPTIONS[durationKey]
351
+ : null;
352
+ if (!option) return null;
353
+ var until = option.tomorrow
354
+ ? nextLocalMorning(nowMs, tzOffsetMinutes)
355
+ : nowMs + option.ms;
356
+ if (!isFinite(until)) return null;
357
+ if (until <= nowMs) return null;
358
+ if (until > nowMs + MAX_SNOOZE_MS) until = nowMs + MAX_SNOOZE_MS;
359
+ return until;
360
+ }
361
+
362
+ // --- Store operations ----------------------------------------------------
363
+
364
+ function readRecord(userKey) {
365
+ if (!userKey) return null;
366
+ var store = loadStore();
367
+ var record = store.users[userKey];
368
+ if (!record || typeof record !== "object") return null;
369
+ var until = typeof record.until === "number" && isFinite(record.until) ? record.until : 0;
370
+ var version = typeof record.version === "string" ? record.version : "";
371
+ if (!until || !version) return null;
372
+ return { version: version, until: until };
373
+ }
374
+
375
+ // Touches exactly one user's entry and leaves every other entry as found on
376
+ // disk, so two users snoozing at the same moment both survive.
377
+ function writeRecord(userKey, record) {
378
+ if (!userKey) return false;
379
+ return mutate(function (store) {
380
+ if (record) store.users[userKey] = { version: record.version, until: record.until };
381
+ else if (Object.prototype.hasOwnProperty.call(store.users, userKey)) delete store.users[userKey];
382
+ else return false;
383
+ });
384
+ }
385
+
386
+ // Whether this user is currently snoozing exactly this version. A snooze for a
387
+ // different version never suppresses a newer one, and an expired snooze never
388
+ // suppresses anything.
389
+ function isSnoozed(userKey, version, nowMs) {
390
+ if (!userKey || !version) return false;
391
+ var record = readRecord(userKey);
392
+ if (!record) return false;
393
+ if (record.version !== version) return false;
394
+ return record.until > nowMs;
395
+ }
396
+
397
+ // Record a snooze. `version` is the server's authoritative latest version; a
398
+ // version named in a client payload is never used.
399
+ function snooze(userKey, version, durationKey, nowMs, tzOffsetMinutes) {
400
+ if (!userKey) return { ok: false, error: "no_identity" };
401
+ if (typeof version !== "string" || !version) return { ok: false, error: "no_update" };
402
+ var until = resolveDeadline(durationKey, nowMs, tzOffsetMinutes);
403
+ if (!until) return { ok: false, error: "invalid_duration" };
404
+ if (!writeRecord(userKey, { version: version, until: until })) {
405
+ return { ok: false, error: "not_persisted" };
406
+ }
407
+ return { ok: true, version: version, until: until, duration: durationKey };
408
+ }
409
+
410
+ // Drop any snooze for this user. Used when the user re-engages deliberately:
411
+ // a manual update check, or installing.
412
+ function clearSnooze(userKey) {
413
+ if (!userKey) return false;
414
+ // Delegates straight to the locked mutation rather than checking first and
415
+ // writing after; the check-then-write pair was itself a race.
416
+ return writeRecord(userKey, null);
417
+ }
418
+
419
+ module.exports = {
420
+ LOCAL_USER_KEY: LOCAL_USER_KEY,
421
+ LOCK_STALE_MS: LOCK_STALE_MS,
422
+ acquireLock: acquireLock,
423
+ isHolderAlive: isHolderAlive,
424
+ lockPath: lockPath,
425
+ releaseLock: releaseLock,
426
+ MAX_SNOOZE_MS: MAX_SNOOZE_MS,
427
+ SNOOZE_OPTIONS: SNOOZE_OPTIONS,
428
+ TOMORROW_HOUR: TOMORROW_HOUR,
429
+ clearSnooze: clearSnooze,
430
+ isSnoozed: isSnoozed,
431
+ isUpdateAdmin: isUpdateAdmin,
432
+ readRecord: readRecord,
433
+ resolveDeadline: resolveDeadline,
434
+ snooze: snooze,
435
+ storePath: storePath,
436
+ userKeyFor: userKeyFor,
437
+ };
@@ -0,0 +1,30 @@
1
+ function attachExperimentalPreferences(deps) {
2
+ var loadUsers = deps.loadUsers;
3
+ var saveUsers = deps.saveUsers;
4
+
5
+ function getCapsulesEnabled(userId) {
6
+ var data = loadUsers();
7
+ for (var i = 0; i < data.users.length; i++) {
8
+ if (data.users[i].id === userId) return data.users[i].capsulesEnabled === true;
9
+ }
10
+ return false;
11
+ }
12
+
13
+ function setCapsulesEnabled(userId, enabled) {
14
+ var data = loadUsers();
15
+ for (var i = 0; i < data.users.length; i++) {
16
+ if (data.users[i].id !== userId) continue;
17
+ data.users[i].capsulesEnabled = enabled === true;
18
+ saveUsers(data);
19
+ return { ok: true, capsulesEnabled: data.users[i].capsulesEnabled };
20
+ }
21
+ return { error: "User not found" };
22
+ }
23
+
24
+ return {
25
+ getCapsulesEnabled: getCapsulesEnabled,
26
+ setCapsulesEnabled: setCapsulesEnabled,
27
+ };
28
+ }
29
+
30
+ module.exports = { attachExperimentalPreferences: attachExperimentalPreferences };
@@ -89,15 +89,15 @@ function attachPermissions(deps) {
89
89
  function canAccessSession(userId, session, project) {
90
90
  // Must have project access first
91
91
  if (!canAccessProject(userId, project)) return false;
92
+ // Resolve the stored user record so client-supplied roles cannot elevate access.
93
+ var user = findUserById(userId);
94
+ if (user && user.role === "admin") return true;
92
95
  // Sessions without ownerId are legacy -- only admin can see them
93
- if (!session.ownerId) {
94
- var user = findUserById(userId);
95
- return !!(user && user.role === "admin");
96
- }
96
+ if (!session.ownerId) return false;
97
97
  // Owner can always see their own sessions
98
98
  if (session.ownerId === userId) return true;
99
- // Shared sessions are visible to all project members (default)
100
- if (!session.sessionVisibility || session.sessionVisibility === "shared") return true;
99
+ // Sharing is opt-in; missing or invalid visibility stays private.
100
+ if (session.sessionVisibility === "shared") return true;
101
101
  // Private sessions are only visible to the owner
102
102
  return false;
103
103
  }
package/lib/users.js CHANGED
@@ -8,6 +8,7 @@ var { DEFAULT_PERMISSIONS, ALL_PERMISSIONS, attachPermissions } = require("./use
8
8
  var { attachPreferences } = require("./users-preferences");
9
9
  var { attachHomeDockPreferences } = require("./users-home-dock-preferences");
10
10
  var { attachHomeSurfacePreferences } = require("./users-home-surface-preferences");
11
+ var { attachExperimentalPreferences } = require("./users-experimental-preferences");
11
12
 
12
13
  var USERS_FILE = path.join(CONFIG_DIR, "users.json");
13
14
 
@@ -54,6 +55,16 @@ function generateUserId() {
54
55
  return crypto.randomUUID();
55
56
  }
56
57
 
58
+ function ensureUserBuiltinMates(userId) {
59
+ try {
60
+ var mates = require("./mates");
61
+ var mateCtx = mates.buildMateCtx(userId);
62
+ mates.ensureBuiltinMates(mateCtx);
63
+ } catch (e) {
64
+ console.error("[users] Failed to seed built-in mates for user " + userId + ":", e.message);
65
+ }
66
+ }
67
+
57
68
  function createUser(opts) {
58
69
  var data = loadUsers();
59
70
  // Check username uniqueness
@@ -91,14 +102,9 @@ function createUser(opts) {
91
102
  data.users.push(user);
92
103
  saveUsers(data);
93
104
 
94
- // Seed built-in mates for the new user
95
- try {
96
- var mates = require("./mates");
97
- var mateCtx = mates.buildMateCtx(user.id);
98
- mates.ensureBuiltinMates(mateCtx);
99
- } catch (e) {
100
- console.error("[users] Failed to seed built-in mates for user " + user.id + ":", e.message);
101
- }
105
+ // Seed immediately for ordinary multi-user storage. OS-user provisioning
106
+ // calls this again after the Linux mapping exists, targeting its final home.
107
+ ensureUserBuiltinMates(user.id);
102
108
 
103
109
  return { ok: true, user: user };
104
110
  }
@@ -245,6 +251,7 @@ function updateLinuxUser(userId, linuxUsername) {
245
251
  if (data.users[i].id === userId) {
246
252
  data.users[i].linuxUser = null;
247
253
  saveUsers(data);
254
+ ensureUserBuiltinMates(userId);
248
255
  return { ok: true };
249
256
  }
250
257
  }
@@ -268,6 +275,7 @@ function updateLinuxUser(userId, linuxUsername) {
268
275
  if (data.users[i].id === userId) {
269
276
  data.users[i].linuxUser = linuxUsername;
270
277
  saveUsers(data);
278
+ ensureUserBuiltinMates(userId);
271
279
  return { ok: true };
272
280
  }
273
281
  }
@@ -325,14 +333,7 @@ function createUserWithoutPin(opts) {
325
333
  data.users.push(user);
326
334
  saveUsers(data);
327
335
 
328
- // Seed built-in mates for the new user
329
- try {
330
- var mates = require("./mates");
331
- var mateCtx = mates.buildMateCtx(user.id);
332
- mates.ensureBuiltinMates(mateCtx);
333
- } catch (e) {
334
- console.error("[users] Failed to seed built-in mates for user " + user.id + ":", e.message);
335
- }
336
+ ensureUserBuiltinMates(user.id);
336
337
 
337
338
  return { ok: true, user: user };
338
339
  }
@@ -398,6 +399,7 @@ var permissions = attachPermissions({ loadUsers: loadUsers, saveUsers: saveUsers
398
399
  var preferences = attachPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
399
400
  var homeDockPreferences = attachHomeDockPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
400
401
  var homeSurfacePreferences = attachHomeSurfacePreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
402
+ var experimentalPreferences = attachExperimentalPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
401
403
 
402
404
  // Alias auth functions
403
405
  var isMultiUser = auth.isMultiUser;
@@ -446,6 +448,8 @@ var markWhatsNewSeen = preferences.markWhatsNewSeen;
446
448
  var getTerminalFont = preferences.getTerminalFont;
447
449
  var setTerminalFont = preferences.setTerminalFont;
448
450
  var setMateOnboarded = preferences.setMateOnboarded;
451
+ var getCapsulesEnabled = experimentalPreferences.getCapsulesEnabled;
452
+ var setCapsulesEnabled = experimentalPreferences.setCapsulesEnabled;
449
453
  var getHomeDockPreference = homeDockPreferences.getHomeDockPreference;
450
454
  var setHomeDockPreference = homeDockPreferences.setHomeDockPreference;
451
455
  var getHomeSurfacePreference = homeSurfacePreferences.getHomeSurfacePreference;
@@ -513,6 +517,8 @@ module.exports = {
513
517
  setClaudeUserAllowList: setClaudeUserAllowList,
514
518
  getMatesEnabled: getMatesEnabled,
515
519
  setMatesEnabled: setMatesEnabled,
520
+ getCapsulesEnabled: getCapsulesEnabled,
521
+ setCapsulesEnabled: setCapsulesEnabled,
516
522
  getToolPalettes: getToolPalettes,
517
523
  setToolPalette: setToolPalette,
518
524
  getWhatsNewSeenIds: getWhatsNewSeenIds,
@@ -0,0 +1,102 @@
1
+ // Authoritative project access for workspace queries.
2
+ //
3
+ // getStatus() reports slug, path, title, and owner, but carries no visibility
4
+ // and no allowedUsers. Rebuilding an access record from it therefore defaults
5
+ // every project to public, and users-permissions treats a record with no
6
+ // visibility as public, which authorizes any authenticated user against any
7
+ // project. Access is resolved here from the record onGetProjectAccess()
8
+ // returns, and every failure path denies.
9
+ //
10
+ // The record is re-read on every evaluation rather than captured at bind time,
11
+ // so a project turning private, changing owner, or dropping a user from
12
+ // allowedUsers takes effect on that caller's next query.
13
+
14
+ function attachWorkspaceQueryAccess(deps) {
15
+ var settings = deps || {};
16
+ var isMultiUser = settings.isMultiUser;
17
+ var resolveMate = settings.resolveMate;
18
+ var onGetProjectAccess = settings.onGetProjectAccess;
19
+ var canAccessProject = settings.canAccessProject;
20
+
21
+ // The authoritative record for a project, or null. A missing slug, a missing
22
+ // resolver, a throwing resolver, a non-record return, and an error record all
23
+ // resolve to null, which denies.
24
+ function record(status) {
25
+ if (!status || typeof status.slug !== "string" || !status.slug) return null;
26
+ if (typeof onGetProjectAccess !== "function") return null;
27
+ var access;
28
+ try {
29
+ access = onGetProjectAccess(status.slug);
30
+ } catch (e) {
31
+ return null;
32
+ }
33
+ if (!access || typeof access !== "object" || Array.isArray(access)) return null;
34
+ if (access.error) return null;
35
+ return access;
36
+ }
37
+
38
+ // The permission predicate is only ever consulted with a real record, and
39
+ // anything other than an explicit true denies.
40
+ function permitted(userId, access) {
41
+ if (!userId || !access) return false;
42
+ if (typeof canAccessProject !== "function") return false;
43
+ try {
44
+ return canAccessProject(userId, access) === true;
45
+ } catch (e) {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ // Single-user ownership. There are no visibility rules and no access records
51
+ // to consult in this mode, so the project's own owner field is authoritative.
52
+ // Unchanged from the behaviour this module replaced.
53
+ function ownsSingleUser(principal, status) {
54
+ if (!status.projectOwnerId) return true;
55
+ return !!principal.singleUserOwnerId && status.projectOwnerId === principal.singleUserOwnerId;
56
+ }
57
+
58
+ // A Mate project belongs to whoever the Mate registry says created the Mate.
59
+ //
60
+ // This is not a convenience: Mate projects are registered directly on the
61
+ // running server and are never written to the persisted project list, so
62
+ // onGetProjectAccess() has no record for them at all. The registry is the
63
+ // authority for Mate ownership, and resolveMate is already scoped to the
64
+ // asking user, so this path can only ever grant a Mate its own project.
65
+ function ownsMateProject(principal, status) {
66
+ if (!status || status.isMate !== true || !status.mateId) return false;
67
+ if (!principal.userId || typeof resolveMate !== "function") return false;
68
+ var mate;
69
+ try {
70
+ mate = resolveMate(principal.userId, status.mateId);
71
+ } catch (e) {
72
+ return false;
73
+ }
74
+ if (!mate || mate.id !== status.mateId) return false;
75
+ return !!mate.createdBy && mate.createdBy === principal.userId;
76
+ }
77
+
78
+ // `accessible` decides whether the container is visible at all; `owned`
79
+ // drives the separate rule that an accessible container holding no session of
80
+ // the caller's own is omitted. Worktrees are never surfaced directly.
81
+ function evaluate(principal, status) {
82
+ var denied = { owned: false, accessible: false };
83
+ if (!principal || !status || status.isWorktree) return denied;
84
+ if (typeof isMultiUser !== "function" || !isMultiUser()) {
85
+ var owned = ownsSingleUser(principal, status);
86
+ return { owned: owned, accessible: owned };
87
+ }
88
+ if (!principal.userId) return denied;
89
+ if (ownsMateProject(principal, status)) return { owned: true, accessible: true };
90
+ // status.projectOwnerId is never trusted on its own in multi-user mode: it
91
+ // is a snapshot that can outlive a transfer or a revocation, so treating it
92
+ // as ownership would let a stale status skip the authoritative record.
93
+ var access = record(status);
94
+ if (!access) return denied;
95
+ if (access.ownerId && access.ownerId === principal.userId) return { owned: true, accessible: true };
96
+ return { owned: false, accessible: permitted(principal.userId, access) };
97
+ }
98
+
99
+ return { evaluate: evaluate, getProjectAccess: record };
100
+ }
101
+
102
+ module.exports = { attachWorkspaceQueryAccess: attachWorkspaceQueryAccess };