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

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 (41) hide show
  1. package/lib/daemon-projects.js +8 -8
  2. package/lib/daemon.js +42 -10
  3. package/lib/git-cli.js +1 -0
  4. package/lib/os-users.js +3 -1
  5. package/lib/project-log-context.js +24 -23
  6. package/lib/project-logs-mcp-server.js +10 -4
  7. package/lib/project-logs-service.js +29 -0
  8. package/lib/project-logs.js +5 -1
  9. package/lib/project-sessions.js +5 -1
  10. package/lib/project.js +1 -1
  11. package/lib/public/css/command-palette.css +19 -9
  12. package/lib/public/css/sidebar.css +11 -0
  13. package/lib/public/css/sticky-notes.css +7 -0
  14. package/lib/public/index.html +2 -2
  15. package/lib/public/modules/admin.js +8 -8
  16. package/lib/public/modules/app-home-hub.js +19 -7
  17. package/lib/public/modules/app-projects.js +5 -3
  18. package/lib/public/modules/chat-bubble-renderer.js +1 -1
  19. package/lib/public/modules/clay-log-links.js +70 -0
  20. package/lib/public/modules/command-palette.js +52 -15
  21. package/lib/public/modules/markdown.js +2 -0
  22. package/lib/public/modules/project-logs.js +7 -0
  23. package/lib/public/modules/sidebar-session-hierarchy.js +16 -8
  24. package/lib/public/modules/sticky-note-markdown.js +5 -0
  25. package/lib/public/modules/sticky-notes-browser.js +2 -0
  26. package/lib/public/modules/sticky-notes-card.js +2 -0
  27. package/lib/public/modules/sticky-notes-editor.js +3 -0
  28. package/lib/public/modules/sticky-notes.js +2 -0
  29. package/lib/public/style.css +1 -1
  30. package/lib/sdk-bridge.js +20 -10
  31. package/lib/server-global-ws.js +11 -0
  32. package/lib/server-home-chat-events.js +3 -0
  33. package/lib/server-home-chat.js +9 -2
  34. package/lib/server-home-clay-entry.js +1 -1
  35. package/lib/server-home-clay-session-links.js +34 -5
  36. package/lib/server.js +19 -10
  37. package/lib/session-hygiene.js +3 -0
  38. package/lib/users.js +16 -16
  39. package/lib/worktree.js +25 -38
  40. package/lib/ws-schema.js +2 -0
  41. package/package.json +1 -1
@@ -29,9 +29,9 @@ function isWorktreeSlug(slug) {
29
29
  * @param {string} parentIcon - icon of parent project
30
30
  * @param {string} parentOwnerId - owner user ID
31
31
  */
32
- function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, projectKnowledgeId) {
33
- if (isWorktree(parentPath)) return;
34
- var worktrees = scanWorktrees(parentPath);
32
+ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, projectKnowledgeId, osUserInfo) {
33
+ if (isWorktree(parentPath, osUserInfo)) return;
34
+ var worktrees = scanWorktrees(parentPath, osUserInfo);
35
35
  if (worktrees.length === 0) return;
36
36
  if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
37
37
  for (var i = 0; i < worktrees.length; i++) {
@@ -42,14 +42,14 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
42
42
  if (worktreeRegistry[parentSlug][j] === wtSlug) { alreadyRegistered = true; break; }
43
43
  }
44
44
  if (alreadyRegistered) continue;
45
- var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName });
45
+ var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName }, osUserInfo);
46
46
  var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
47
47
  relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
48
48
  worktreeRegistry[parentSlug].push(wtSlug);
49
49
  console.log("[daemon] Registered worktree:", wtSlug, "->", wt.path, wt.external ? "(external)" : "(inside project folder)");
50
50
  }
51
51
  daemonSync.register(getWorktreeSyncKey(parentSlug), function () {
52
- rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, null, projectKnowledgeId);
52
+ rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, null, projectKnowledgeId, osUserInfo);
53
53
  });
54
54
  }
55
55
 
@@ -62,11 +62,11 @@ function scanAndRegisterWorktrees(relay, parentPath, parentSlug, parentIcon, par
62
62
  * @param {string} parentOwnerId - owner user ID
63
63
  * @param {object} [config] - daemon config (optional, for broadcasting project count)
64
64
  */
65
- function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, config, projectKnowledgeId) {
65
+ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerId, config, projectKnowledgeId, osUserInfo) {
66
66
  if (worktreeScanning[parentSlug]) return;
67
67
  worktreeScanning[parentSlug] = true;
68
68
  try {
69
- var discovered = scanWorktrees(parentPath);
69
+ var discovered = scanWorktrees(parentPath, osUserInfo);
70
70
  var changed = false;
71
71
  var existingSlugs = worktreeRegistry[parentSlug] || [];
72
72
  var discoveredNames = {};
@@ -81,7 +81,7 @@ function rescanWorktrees(relay, parentPath, parentSlug, parentIcon, parentOwnerI
81
81
  if (existingSlugs[ei] === wtSlug) { found = true; break; }
82
82
  }
83
83
  if (!found) {
84
- var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName });
84
+ var context = logContext.resolveWorktreeContext(wt.path, projectKnowledgeId, { branch: wt.branch || wt.dirName }, osUserInfo);
85
85
  var wtMeta = Object.assign({ parentSlug: parentSlug, external: wt.external, projectKnowledgeId: projectKnowledgeId }, context);
86
86
  relay.addProject(wt.path, wtSlug, wt.branch || wt.dirName, parentIcon, parentOwnerId, wtMeta);
87
87
  if (!worktreeRegistry[parentSlug]) worktreeRegistry[parentSlug] = [];
package/lib/daemon.js CHANGED
@@ -173,6 +173,25 @@ function projectRuntimeOptions(project) {
173
173
  return { projectKnowledgeId: project.projectKnowledgeId };
174
174
  }
175
175
 
176
+ function worktreeOsUserInfo(project, requestUser) {
177
+ if (!config.osUsers) return null;
178
+ var owner = project && project.ownerId ? usersModule.findUserById(project.ownerId) : null;
179
+ var executionUser = owner && owner.linuxUser ? owner : requestUser;
180
+ if (!executionUser || !executionUser.linuxUser) {
181
+ throw new Error("The project owner does not have a provisioned Linux account");
182
+ }
183
+ return osUsersMod.resolveOsUserInfo(executionUser.linuxUser);
184
+ }
185
+
186
+ function registerProjectWorktrees(project, requestUser) {
187
+ try {
188
+ var osUserInfo = worktreeOsUserInfo(project, requestUser);
189
+ scanAndRegisterWorktrees(relay, project.path, project.slug, project.icon, project.ownerId, project.projectKnowledgeId, osUserInfo);
190
+ } catch (e) {
191
+ console.error("[daemon] Failed to scan worktrees for " + project.slug + ": " + e.message);
192
+ }
193
+ }
194
+
176
195
  var relay = createServer({
177
196
  tlsOptions: tlsOptions,
178
197
  caPath: caRoot,
@@ -219,7 +238,7 @@ var relay = createServer({
219
238
  }
220
239
  }
221
240
  // Discover and register worktrees for the new project
222
- scanAndRegisterWorktrees(relay, absPath, slug, null, wsUser && wsUser.id && wsUser.role !== "admin" ? wsUser.id : null, projectEntry.projectKnowledgeId);
241
+ registerProjectWorktrees(projectEntry, wsUser);
223
242
  // Broadcast updated project list to all clients
224
243
  relay.broadcastAll({
225
244
  type: "projects_updated",
@@ -404,8 +423,15 @@ var relay = createServer({
404
423
  }
405
424
  if (parentProject) {
406
425
  var wtPath = path.join(parentProject.path, wtDirName);
407
- var contextStatus = projectLogContext.isMerged(parentProject.path, wtPath) ? "merged" : "archived";
408
- var rmResult = removeWorktree(parentProject.path, wtDirName);
426
+ var removeUser = userId ? usersModule.findUserById(userId) : null;
427
+ var removeOsUserInfo;
428
+ try {
429
+ removeOsUserInfo = worktreeOsUserInfo(parentProject, removeUser);
430
+ } catch (e) {
431
+ return { ok: false, error: e.message };
432
+ }
433
+ var contextStatus = projectLogContext.isMerged(parentProject.path, wtPath, removeOsUserInfo) ? "merged" : "archived";
434
+ var rmResult = removeWorktree(parentProject.path, wtDirName, removeOsUserInfo);
409
435
  if (!rmResult.ok) {
410
436
  console.log("[daemon] Failed to remove worktree:", slug, rmResult.error);
411
437
  return { ok: false, error: rmResult.error };
@@ -1003,23 +1029,29 @@ var relay = createServer({
1003
1029
  if (!config.osUsers || !linuxUser) return;
1004
1030
  deactivateLinuxUser(linuxUser);
1005
1031
  },
1006
- onCreateWorktree: function (parentSlug, branchName, dirName, baseBranch) {
1032
+ onCreateWorktree: function (parentSlug, branchName, dirName, baseBranch, requestUser) {
1007
1033
  // Find the parent project
1008
1034
  var parent = null;
1009
1035
  for (var j = 0; j < config.projects.length; j++) {
1010
1036
  if (config.projects[j].slug === parentSlug) { parent = config.projects[j]; break; }
1011
1037
  }
1012
1038
  if (!parent) return { ok: false, error: "Parent project not found" };
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");
1015
- var result = createWorktree(parent.path, branchName, dirName, baseBranch);
1039
+ var osUserInfo;
1040
+ try {
1041
+ osUserInfo = worktreeOsUserInfo(parent, requestUser);
1042
+ } catch (e) {
1043
+ return { ok: false, error: e.message };
1044
+ }
1045
+ if (isWorktree(parent.path, osUserInfo)) return { ok: false, error: "Cannot create worktrees from a worktree project" };
1046
+ var baseCommit = projectLogContext.commit(parent.path, baseBranch || "HEAD", osUserInfo);
1047
+ var result = createWorktree(parent.path, branchName, dirName, baseBranch, osUserInfo);
1016
1048
  if (!result.ok) return result;
1017
1049
  // Register the new worktree as ephemeral project
1018
1050
  var wtSlug = parentSlug + "--" + dirName;
1019
1051
  var context = projectLogContext.resolveWorktreeContext(result.path, parent.projectKnowledgeId, {
1020
1052
  branch: branchName,
1021
1053
  baseCommit: baseCommit,
1022
- });
1054
+ }, osUserInfo);
1023
1055
  var wtMeta = Object.assign({ parentSlug: parentSlug, external: false, projectKnowledgeId: parent.projectKnowledgeId }, context);
1024
1056
  relay.addProject(result.path, wtSlug, branchName, parent.icon, parent.ownerId, wtMeta);
1025
1057
  registerWorktreeSlug(parentSlug, wtSlug);
@@ -1114,7 +1146,7 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1114
1146
  try { syncClayrc(config.projects); } catch (e) {}
1115
1147
  console.log("[daemon] Added project:", slug, "→", absPath);
1116
1148
  // Discover and register worktrees for the new project
1117
- scanAndRegisterWorktrees(relay, absPath, slug, null, null, projectEntry.projectKnowledgeId);
1149
+ registerProjectWorktrees(projectEntry, null);
1118
1150
  relay.broadcastAll({
1119
1151
  type: "projects_updated",
1120
1152
  projects: relay.getProjects(),
@@ -1345,7 +1377,7 @@ for (var i = 0; i < projects.length; i++) {
1345
1377
  console.log("[daemon] Adding project:", p.slug, "→", p.path);
1346
1378
  relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId, null, projectRuntimeOptions(p));
1347
1379
  // Discover and register worktrees for this project
1348
- scanAndRegisterWorktrees(relay, p.path, p.slug, p.icon, p.ownerId, p.projectKnowledgeId);
1380
+ registerProjectWorktrees(p, null);
1349
1381
  } else {
1350
1382
  console.log("[daemon] Skipping missing project:", p.path);
1351
1383
  }
package/lib/git-cli.js CHANGED
@@ -458,6 +458,7 @@ module.exports = {
458
458
  parsePorcelainV2: parsePorcelainV2,
459
459
  parseWorktrees: parseWorktrees,
460
460
  readWorkingTreeFile: readWorkingTreeFile,
461
+ runGitSync: runGitSync,
461
462
  runAction: runAction,
462
463
  shortRemoteLabel: shortRemoteLabel,
463
464
  };
package/lib/os-users.js CHANGED
@@ -161,7 +161,9 @@ function fsAsUser(op, args, osUserInfo) {
161
161
  "var fs = require('fs');",
162
162
  "var f = " + JSON.stringify(args.file) + ";",
163
163
  "var content = " + JSON.stringify(args.content || "") + ";",
164
- "fs.writeFileSync(f, content, 'utf8');",
164
+ "var flag = " + JSON.stringify(args.flag === "wx" ? "wx" : "w") + ";",
165
+ "var mode = " + JSON.stringify(typeof args.mode === "number" ? args.mode : 0o666) + ";",
166
+ "fs.writeFileSync(f, content, { encoding: 'utf8', flag: flag, mode: mode });",
165
167
  "process.stdout.write(JSON.stringify({ ok: true }));",
166
168
  ].join(" ");
167
169
  } else if (op === "mkdir") {
@@ -9,7 +9,8 @@
9
9
  var crypto = require("crypto");
10
10
  var fs = require("fs");
11
11
  var path = require("path");
12
- var execFileSync = require("child_process").execFileSync;
12
+ var { runGitSync } = require("./git-cli");
13
+ var { fsAsUser } = require("./os-users");
13
14
  var logsRoot = require("./project-logs-root");
14
15
  var utils = require("./utils");
15
16
 
@@ -47,23 +48,18 @@ function createProjectKnowledgeId() {
47
48
  return newId("pk");
48
49
  }
49
50
 
50
- function git(cwd, args) {
51
- return execFileSync("git", args, {
52
- cwd: cwd,
53
- encoding: "utf8",
54
- timeout: 5000,
55
- stdio: ["pipe", "pipe", "pipe"],
56
- }).trim();
51
+ function git(cwd, args, osUserInfo) {
52
+ return runGitSync(cwd, args, null, osUserInfo).trim();
57
53
  }
58
54
 
59
- function commit(cwd, ref) {
60
- try { return git(cwd, ["rev-parse", ref || "HEAD"]); }
55
+ function commit(cwd, ref, osUserInfo) {
56
+ try { return git(cwd, ["rev-parse", ref || "HEAD"], osUserInfo); }
61
57
  catch (e) { return null; }
62
58
  }
63
59
 
64
- function markerPath(cwd) {
60
+ function markerPath(cwd, osUserInfo) {
65
61
  try {
66
- var gitDir = git(cwd, ["rev-parse", "--git-dir"]);
62
+ var gitDir = git(cwd, ["rev-parse", "--git-dir"], osUserInfo);
67
63
  return path.join(path.resolve(cwd, gitDir), "clay-change-set.json");
68
64
  } catch (e) {
69
65
  return null;
@@ -86,9 +82,18 @@ function deterministicFallback(knowledgeId, cwd) {
86
82
  return "cs_" + digest.substring(0, 22);
87
83
  }
88
84
 
89
- function resolveWorktreeContext(cwd, knowledgeId, details) {
85
+ function writeMarker(filePath, marker, osUserInfo) {
86
+ var content = JSON.stringify(marker, null, 2) + "\n";
87
+ if (osUserInfo) {
88
+ fsAsUser("write", { file: filePath, content: content, flag: "wx", mode: 0o600 }, osUserInfo);
89
+ return;
90
+ }
91
+ fs.writeFileSync(filePath, content, { flag: "wx", mode: 0o600 });
92
+ }
93
+
94
+ function resolveWorktreeContext(cwd, knowledgeId, details, osUserInfo) {
90
95
  var input = details || {};
91
- var filePath = markerPath(cwd);
96
+ var filePath = markerPath(cwd, osUserInfo);
92
97
  var marker = readMarker(filePath, knowledgeId);
93
98
  if (!marker) {
94
99
  marker = {
@@ -99,7 +104,7 @@ function resolveWorktreeContext(cwd, knowledgeId, details) {
99
104
  };
100
105
  if (filePath) {
101
106
  try {
102
- fs.writeFileSync(filePath, JSON.stringify(marker, null, 2) + "\n", { flag: "wx", mode: 0o600 });
107
+ writeMarker(filePath, marker, osUserInfo);
103
108
  } catch (e) {
104
109
  marker = readMarker(filePath, knowledgeId) || marker;
105
110
  }
@@ -113,20 +118,16 @@ function resolveWorktreeContext(cwd, knowledgeId, details) {
113
118
  changeSetId: marker.changeSetId,
114
119
  branch: input.branch || null,
115
120
  baseCommit: marker.baseCommit || input.baseCommit || null,
116
- headCommit: commit(cwd, "HEAD"),
121
+ headCommit: commit(cwd, "HEAD", osUserInfo),
117
122
  status: "active",
118
123
  };
119
124
  }
120
125
 
121
- function isMerged(parentCwd, worktreeCwd) {
122
- var head = commit(worktreeCwd, "HEAD");
126
+ function isMerged(parentCwd, worktreeCwd, osUserInfo) {
127
+ var head = commit(worktreeCwd, "HEAD", osUserInfo);
123
128
  if (!head) return false;
124
129
  try {
125
- execFileSync("git", ["merge-base", "--is-ancestor", head, "HEAD"], {
126
- cwd: parentCwd,
127
- timeout: 5000,
128
- stdio: ["pipe", "pipe", "pipe"],
129
- });
130
+ runGitSync(parentCwd, ["merge-base", "--is-ancestor", head, "HEAD"], null, osUserInfo);
130
131
  return true;
131
132
  } catch (e) {
132
133
  return false;
@@ -22,6 +22,11 @@ var LOGS_CONTRACT =
22
22
  "Do not paste raw conversation transcripts, log command-by-command narration, trivial confirmations, or speculation. Repository history may show the code change but usually does not preserve the user's intent, constraints, verification, or unfinished state, so it is not a substitute for the work log. " +
23
23
  "Every write is attributed and permanently revision-tracked, so keep entries concise, concrete, and true while retaining enough context for a clean Driver handoff.";
24
24
 
25
+ var CLAY_READ_CONTRACT =
26
+ "When the user asks Clay about a project's prior work, decisions, defects, status, rationale, or unfinished work, identify the relevant project and search its Project Logs before answering. " +
27
+ "Use conversation history as additional evidence when useful, but do not substitute it for the ledger's durable project record. Do not search Logs for unrelated general questions. " +
28
+ "When citing a returned log in user-visible text, write [clayos/<ref> — short label] so Clay Studio renders an owner-validated Log control.";
29
+
25
30
  // User learning moments are a durable project asset, so capturing them is a
26
31
  // default rather than an option. This category is about a change in the user's
27
32
  // conceptual model, never knowledge the Driver acquired while doing the work.
@@ -236,7 +241,7 @@ function globalTools(bound) {
236
241
  return [
237
242
  {
238
243
  name: "list_project_logs",
239
- description: LOGS_CONTRACT + " List logs for one project the current user is authorized to see. Read-only, and available only to authoritative builtin Clay.",
244
+ description: LOGS_CONTRACT + " " + CLAY_READ_CONTRACT + " List logs for one project the current user is authorized to see. Read-only, and available only to authoritative builtin Clay.",
240
245
  inputSchema: buildShape({
241
246
  projectSlug: { type: "string", description: "Exact project slug." },
242
247
  kind: { type: "string", description: "Optional category filter, matched exactly against this project's vocabulary. The response lists the categories currently in use." },
@@ -250,7 +255,7 @@ function globalTools(bound) {
250
255
  },
251
256
  {
252
257
  name: "search_project_logs",
253
- description: LOGS_CONTRACT + " Search logs for one project the current user is authorized to see. Read-only, and available only to authoritative builtin Clay.",
258
+ description: LOGS_CONTRACT + " " + CLAY_READ_CONTRACT + " Search logs for one project the current user is authorized to see. Read-only, and available only to authoritative builtin Clay.",
254
259
  inputSchema: buildShape({
255
260
  projectSlug: { type: "string", description: "Exact project slug." },
256
261
  query: { type: "string", description: "Search query." },
@@ -264,7 +269,7 @@ function globalTools(bound) {
264
269
  },
265
270
  {
266
271
  name: "read_project_log_revision",
267
- description: LOGS_CONTRACT + " Read one entry as of a given revision. Read-only, and available only to authoritative builtin Clay.",
272
+ description: LOGS_CONTRACT + " " + CLAY_READ_CONTRACT + " Read one entry as of a given revision. Read-only, and available only to authoritative builtin Clay.",
268
273
  inputSchema: buildShape({
269
274
  projectSlug: { type: "string", description: "Exact project slug." },
270
275
  ref: { type: "string", description: REF_DESCRIPTION },
@@ -274,7 +279,7 @@ function globalTools(bound) {
274
279
  },
275
280
  {
276
281
  name: "read_project_log",
277
- description: LOGS_CONTRACT + " Read one log entry from an authorized project, including its summary and any comments people have added. Read-only, and available only to authoritative builtin Clay.",
282
+ description: LOGS_CONTRACT + " " + CLAY_READ_CONTRACT + " Read one log entry from an authorized project, including its summary and any comments people have added. Read-only, and available only to authoritative builtin Clay.",
278
283
  inputSchema: buildShape({
279
284
  projectSlug: { type: "string", description: "Exact project slug." },
280
285
  ref: { type: "string", description: REF_DESCRIPTION },
@@ -312,6 +317,7 @@ function createMcpServer(adapter, bound, includeGlobal) {
312
317
 
313
318
  module.exports = {
314
319
  LOGS_CONTRACT: LOGS_CONTRACT,
320
+ CLAY_READ_CONTRACT: CLAY_READ_CONTRACT,
315
321
  LEARNING_CONTRACT: LEARNING_CONTRACT,
316
322
  ATTENTION_CONTRACT: ATTENTION_CONTRACT,
317
323
  REVIEW_CONTRACT: REVIEW_CONTRACT,
@@ -21,6 +21,7 @@
21
21
 
22
22
  var builtinMates = require("./builtin-mates");
23
23
  var logsStore = require("./project-logs-store");
24
+ var LOG_REF_PATTERN = /^log:[A-Za-z0-9_-]{24}$/;
24
25
 
25
26
  function readOnly() {
26
27
  throw new Error("Project Logs are read-only for builtin Clay.");
@@ -396,6 +397,33 @@ function attachProjectLogsService(ctx) {
396
397
  return storeForStatus(statusFor(principal, slug));
397
398
  }
398
399
 
400
+ function resolveLogNavigation(args) {
401
+ var principal = revalidate();
402
+ var ref = args && args.ref;
403
+ if (!LOG_REF_PATTERN.test(ref || "")) throw new Error("Invalid log reference.");
404
+ var projects = getProjects();
405
+ var seenScopes = new Set();
406
+ var match = null;
407
+ if (!projects || typeof projects.forEach !== "function") throw new Error("Project Logs are unavailable.");
408
+ projects.forEach(function (project, slug) {
409
+ if (!project || typeof project.getStatus !== "function") return;
410
+ var status = project.getStatus();
411
+ if (!authorizedProject(principal, status)) return;
412
+ var governing = effectiveStatus(status);
413
+ var scope = governing && (governing.projectKnowledgeId || governing.path);
414
+ if (!scope || seenScopes.has(scope)) return;
415
+ seenScopes.add(scope);
416
+ var entry = null;
417
+ try { entry = storeForStatus(status).read(ref, false); } catch (e) { return; }
418
+ if (!entry) return;
419
+ var targetSlug = status.isWorktree && status.parentSlug && projectFor(status.parentSlug) ? status.parentSlug : slug;
420
+ if (match && match.projectSlug !== targetSlug) throw new Error("Log reference is ambiguous.");
421
+ match = { projectSlug: targetSlug, ref: ref };
422
+ });
423
+ if (!match) throw new Error("Log entry not found.");
424
+ return match;
425
+ }
426
+
399
427
  return {
400
428
  isClay: true,
401
429
  canWrite: false,
@@ -413,6 +441,7 @@ function attachProjectLogsService(ctx) {
413
441
  logHistory: function (args) { return target(args).history(args && args.ref, args || {}); },
414
442
  commentLog: readOnly,
415
443
  readLogRevision: function (args) { return target(args).readRevision(args && args.ref, args && args.revision); },
444
+ resolveLogNavigation: resolveLogNavigation,
416
445
  listLogFeedback: readOnly,
417
446
  reviewLogComment: readOnly,
418
447
  revertLog: readOnly,
@@ -366,7 +366,11 @@ function attachProjectLogs(ctx) {
366
366
  }
367
367
 
368
368
  function getSystemPrompt(session) {
369
- if (isMate || !service) return "";
369
+ if (!service) return "";
370
+ if (isMate) {
371
+ if (!includeGlobal() || (session && !sessionBinding(session))) return "";
372
+ return SYSTEM_PROMPT_LABEL + "\n" + logsMcp.CLAY_READ_CONTRACT;
373
+ }
370
374
  if (session && !sessionBinding(session)) return "";
371
375
  return SYSTEM_PROMPT_LABEL + "\n" + logsMcp.LOGS_CONTRACT +
372
376
  "\n" + logsMcp.LEARNING_CONTRACT +
@@ -1777,8 +1777,12 @@ function attachSessions(ctx) {
1777
1777
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid branch name" });
1778
1778
  return true;
1779
1779
  }
1780
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(wtDirName)) {
1781
+ sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid worktree directory name" });
1782
+ return true;
1783
+ }
1780
1784
  if (typeof onCreateWorktree === "function") {
1781
- var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase);
1785
+ var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase, ws._clayUser);
1782
1786
  sendTo(ws, { type: "create_worktree_result", ok: wtResult.ok, slug: wtResult.slug, error: wtResult.error });
1783
1787
  } else {
1784
1788
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Not supported" });
package/lib/project.js CHANGED
@@ -1319,7 +1319,7 @@ function createProjectContext(opts) {
1319
1319
  if (typeof opts.onDmMessage === "function") opts.onDmMessage(ws, msg);
1320
1320
  return;
1321
1321
  }
1322
- if (msg.type === "home_clay_ask" || msg.type === "home_clay_session_resolve") {
1322
+ if (msg.type === "home_clay_ask" || msg.type === "home_clay_session_resolve" || msg.type === "home_clay_log_resolve") {
1323
1323
  if (typeof opts.onDmMessage === "function") opts.onDmMessage(ws, msg, slug);
1324
1324
  return;
1325
1325
  }
@@ -486,7 +486,8 @@ body.mate-dm-active .search-clay-transcript .search-clay-message-user .bubble,
486
486
  .search-clay-activity-item small { color: var(--text-dimmer); font-size: 10px; line-height: 1.35; }
487
487
  @keyframes searchClaySpin { to { transform: rotate(360deg); } }
488
488
 
489
- .clayos-session-link {
489
+ .clayos-session-link,
490
+ .clayos-log-link {
490
491
  display: inline-flex;
491
492
  align-items: center;
492
493
  gap: 6px;
@@ -503,7 +504,8 @@ body.mate-dm-active .search-clay-transcript .search-clay-message-user .bubble,
503
504
  vertical-align: middle;
504
505
  cursor: pointer;
505
506
  }
506
- .clayos-session-link::before {
507
+ .clayos-session-link::before,
508
+ .clayos-log-link::before {
507
509
  content: "\2197";
508
510
  display: grid;
509
511
  place-items: center;
@@ -514,13 +516,21 @@ body.mate-dm-active .search-clay-transcript .search-clay-message-user .bubble,
514
516
  color: var(--accent);
515
517
  font-size: 12px;
516
518
  }
517
- .clayos-session-link > span { font-weight: 620; }
518
- .clayos-session-link > small { color: var(--text-muted); font-size: 10px; }
519
- .clayos-session-link:hover { border-color: color-mix(in srgb, var(--accent) 52%, var(--border)); background: color-mix(in srgb, var(--accent) 11%, var(--bg-alt)); }
520
- .clayos-session-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
521
- .clayos-session-link.is-loading { opacity: 0.62; cursor: progress; }
522
- .clayos-session-link.is-loading::before { animation: searchClaySpin 1.2s linear infinite; }
523
- .clayos-session-link.is-error { border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); }
519
+ .clayos-log-link::before { content: "\2261"; }
520
+ .clayos-session-link > span,
521
+ .clayos-log-link > span { font-weight: 620; }
522
+ .clayos-session-link > small,
523
+ .clayos-log-link > small { color: var(--text-muted); font-size: 10px; }
524
+ .clayos-session-link:hover,
525
+ .clayos-log-link:hover { border-color: color-mix(in srgb, var(--accent) 52%, var(--border)); background: color-mix(in srgb, var(--accent) 11%, var(--bg-alt)); }
526
+ .clayos-session-link:focus-visible,
527
+ .clayos-log-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
528
+ .clayos-session-link.is-loading,
529
+ .clayos-log-link.is-loading { opacity: 0.62; cursor: progress; }
530
+ .clayos-session-link.is-loading::before,
531
+ .clayos-log-link.is-loading::before { animation: searchClaySpin 1.2s linear infinite; }
532
+ .clayos-session-link.is-error,
533
+ .clayos-log-link.is-error { border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); }
524
534
 
525
535
  .search-clay-pending {
526
536
  align-self: flex-start;
@@ -930,6 +930,17 @@
930
930
  position: relative;
931
931
  }
932
932
 
933
+ .session-driver-header {
934
+ display: flex;
935
+ align-items: center;
936
+ }
937
+
938
+ .session-driver-header > .session-item {
939
+ min-width: 0;
940
+ flex: 1;
941
+ padding-left: 0;
942
+ }
943
+
933
944
  .session-driver-toggle {
934
945
  width: 18px;
935
946
  height: 24px;
@@ -85,9 +85,13 @@
85
85
  min-height: 80px;
86
86
  border: 1px solid color-mix(in srgb, var(--note-border) calc(var(--note-opacity, 0.64) * 100%), transparent);
87
87
  background: color-mix(in srgb, var(--note-surface) calc(var(--note-opacity, 0.64) * 100%), transparent);
88
+ -webkit-backdrop-filter: blur(7px) saturate(0.92) contrast(1.02);
89
+ backdrop-filter: blur(7px) saturate(0.92) contrast(1.02);
88
90
  color: var(--note-ink);
89
91
  transition: box-shadow 0.15s;
90
92
  overflow: hidden;
93
+ contain: layout style;
94
+ will-change: transform, backdrop-filter;
91
95
  }
92
96
 
93
97
  .sticky-note:hover {
@@ -798,8 +802,11 @@
798
802
 
799
803
  @media (prefers-reduced-transparency: reduce) {
800
804
  .sticky-note {
805
+ -webkit-backdrop-filter: none;
806
+ backdrop-filter: none;
801
807
  border-color: var(--note-border);
802
808
  background: var(--note-surface);
809
+ will-change: transform;
803
810
  }
804
811
  }
805
812
 
@@ -31,7 +31,7 @@
31
31
  (function(){try{var k="clay-theme-vars",v=localStorage.getItem(k),r=document.documentElement;if(v){var o=JSON.parse(v),p;for(p in o)r.style.setProperty(p,o[p]);var vt=localStorage.getItem(k.replace("-vars","-variant"));if(vt==="light"){r.classList.add("light-theme");r.classList.remove("dark-theme")}else{r.classList.add("dark-theme");r.classList.remove("light-theme")}var m=document.querySelector('meta[name="theme-color"]');if(m&&o["--bg"])m.setAttribute("content",o["--bg"])}else{var sl=window.matchMedia&&window.matchMedia("(prefers-color-scheme: light)").matches;if(sl){r.classList.add("light-theme");r.classList.remove("dark-theme")}}}catch(e){}})();
32
32
  </script>
33
33
  <script>if(window.navigator.standalone||window.matchMedia("(display-mode:standalone)").matches){document.documentElement.classList.add("pwa-standalone")}</script>
34
- <link rel="stylesheet" href="style.css?v=20260901-clay-chat6">
34
+ <link rel="stylesheet" href="style.css?v=20260908-log-links2">
35
35
  <style>
36
36
  @media(max-width:768px){
37
37
  /* User messages: vertical stack, avatar on top, right-aligned */
@@ -2337,7 +2337,7 @@
2337
2337
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0/lib/addon-fit.min.js"></script>
2338
2338
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0/lib/addon-web-links.min.js"></script>
2339
2339
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0/lib/addon-webgl.min.js"></script>
2340
- <script type="module" src="app.js?v=20260902-markdown-boundary1"></script>
2340
+ <script type="module" src="app.js?v=20260908-log-links2"></script>
2341
2341
  <div id="pwa-install-modal" class="pwa-modal hidden">
2342
2342
  <div class="pwa-modal-backdrop"></div>
2343
2343
  <div class="pwa-modal-card">
@@ -2,15 +2,15 @@
2
2
  import { iconHtml, refreshIcons } from './icons.js';
3
3
  import { showToast, copyToClipboard, escapeHtml } from './utils.js';
4
4
 
5
- function showConfirmDialog(message, onConfirm) {
5
+ function showConfirmDialog(message, actionLabel, onConfirm) {
6
6
  var modal = document.createElement("div");
7
7
  modal.className = "admin-modal-overlay";
8
- var html = '<div class="admin-modal">' +
8
+ var html = '<div class="admin-modal" role="dialog" aria-modal="true" aria-label="Confirm action">' +
9
9
  '<div class="admin-modal-body" style="padding:20px 16px 16px">' +
10
10
  '<p class="admin-modal-desc" style="margin:0;font-size:14px;color:var(--text)">' + escapeHtml(message) + '</p>' +
11
11
  '</div>' +
12
12
  '<div class="admin-modal-footer">' +
13
- '<button class="admin-modal-save admin-modal-confirm-danger">Revoke</button>' +
13
+ '<button class="admin-modal-save admin-modal-confirm-danger">' + escapeHtml(actionLabel) + '</button>' +
14
14
  '<button class="admin-modal-cancel">Cancel</button>' +
15
15
  '</div></div>';
16
16
  modal.innerHTML = html;
@@ -198,9 +198,9 @@ function renderUsersTab(body) {
198
198
  var userId = this.dataset.userId;
199
199
  var user = cachedUsers.find(function (u) { return u.id === userId; });
200
200
  var name = user ? (user.displayName || user.username) : "this user";
201
- if (confirm("Remove " + name + "? This cannot be undone.")) {
201
+ showConfirmDialog("Remove " + name + "? This cannot be undone.", "Remove user", function () {
202
202
  removeUser(userId, body);
203
- }
203
+ });
204
204
  });
205
205
  }
206
206
  }
@@ -509,7 +509,7 @@ function renderInvitesTab(body) {
509
509
  revokeBtns[k].addEventListener("click", function () {
510
510
  var code = this.dataset.code;
511
511
  var btn = this;
512
- showConfirmDialog("Revoke this invite? The link will no longer work.", function () {
512
+ showConfirmDialog("Revoke this invite? The link will no longer work.", "Revoke", function () {
513
513
  btn.disabled = true;
514
514
  fetch("/api/admin/invites/" + encodeURIComponent(code), { method: "DELETE" })
515
515
  .then(function (r) { return r.json(); })
@@ -751,12 +751,12 @@ function renderSmtpTab(body, cfg) {
751
751
  var removeBtn = body.querySelector("#smtp-remove");
752
752
  if (removeBtn) {
753
753
  removeBtn.addEventListener("click", function () {
754
- if (confirm("Remove SMTP configuration? Users will need to use PIN login.")) {
754
+ showConfirmDialog("Remove SMTP configuration? Users will need to use PIN login.", "Remove SMTP", function () {
755
755
  apiPost("/api/admin/smtp", { host: "", user: "", pass: "", from: "" }).then(function () {
756
756
  showToast("SMTP configuration removed");
757
757
  loadSmtpTab(body);
758
758
  });
759
- }
759
+ });
760
760
  });
761
761
  }
762
762
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { store } from './store.js';
4
4
  import { getWs } from './ws-ref.js';
5
- import { getCachedProjects } from './app-projects.js';
5
+ import { getCachedProjects, switchProject } from './app-projects.js';
6
6
  import { mateAvatarUrl } from './avatar.js';
7
7
  import { exitDmMode } from './app-dm.js';
8
8
  import { closeHomeDock, initHomeDock, renderDock, requestHomeDockPreference } from './home-dock.js';
@@ -41,7 +41,22 @@ function syncHomePresentation() {
41
41
  function syncHomeCloseControl() {
42
42
  var close = document.getElementById("home-close-control");
43
43
  if (!close) return;
44
- close.classList.toggle("hidden", !store.get('currentSlug'));
44
+ close.classList.toggle("hidden", !getHomeReturnSlug());
45
+ }
46
+
47
+ function getHomeReturnSlug() {
48
+ var projects = getCachedProjects() || [];
49
+ var candidates = [store.get('currentSlug'), store.get('homeSurfaceProjectSlug')];
50
+ for (var ci = 0; ci < candidates.length; ci++) {
51
+ if (!candidates[ci]) continue;
52
+ for (var pi = 0; pi < projects.length; pi++) {
53
+ if (projects[pi] && !projects[pi].isMate && projects[pi].slug === candidates[ci]) return candidates[ci];
54
+ }
55
+ }
56
+ for (var i = 0; i < projects.length; i++) {
57
+ if (projects[i] && !projects[i].isMate && projects[i].slug) return projects[i].slug;
58
+ }
59
+ return null;
45
60
  }
46
61
 
47
62
  function getVisibleMates() {
@@ -390,13 +405,10 @@ export function hideHomeHub() {
390
405
  }
391
406
 
392
407
  export function minimizeHomeHub() {
393
- var slug = store.get('currentSlug');
408
+ var slug = getHomeReturnSlug();
394
409
  if (!slug || !homeHubVisible) return;
395
410
  rememberHomePrimarySurface("project");
396
- hideHomeHub();
397
- var route = "/p/" + slug + "/";
398
- if (document.documentElement.classList.contains("pwa-standalone")) history.replaceState(null, "", route);
399
- else history.pushState(null, "", route);
411
+ switchProject(slug);
400
412
  var projectInput = document.getElementById("input");
401
413
  if (projectInput && !projectInput.disabled) projectInput.focus({ preventScroll: true });
402
414
  }