clay-server 4.4.1-beta.1 → 4.5.0-beta.1

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.
@@ -39,6 +39,17 @@ function attachProjectIssues(ctx) {
39
39
  else if (msg.type === "issue_comment") notify(result, "issue_commented");
40
40
  else if (msg.type === "issue_delete") notify(result, "issue_deleted");
41
41
  }
42
+ if (msg.type === "issue_read" && result) {
43
+ result = Object.assign({}, result, { githubLinks: [] });
44
+ (result.linkedWorkSessions || []).forEach(function (link) {
45
+ try {
46
+ var work = bound.resolveWorkSession({ ref: result.ref, sessionOriginId: link.sessionId });
47
+ (work.githubLinks || []).forEach(function (item) {
48
+ if (!result.githubLinks.some(function (existing) { return existing.url === item.url; })) result.githubLinks.push(item);
49
+ });
50
+ } catch (error) { /* Linked work may no longer be visible. */ }
51
+ });
52
+ }
42
53
  ctx.sendTo(ws, { type: "issues_result", requestId: msg.requestId, action: msg.type, result: result, canDelete: bound.canDelete === true });
43
54
  }).catch(function (error) {
44
55
  var result = error && error.sessionId ? { sessionId: error.sessionId } : undefined;
@@ -0,0 +1,93 @@
1
+ var github = require("./session-github");
2
+ var buildShape = require("./session-spawn-mcp-server").buildShape;
3
+ var CONTRACT = "GitHub work links: when an issue is clearly the task this session is implementing, or you create/work on its PR, call link_session_github with its exact GitHub URL. Mere mentions, examples, and reference material must not be linked. Do not infer a session's PR from the repository's current branch: sessions may share a checkout. This records local metadata only and does not post to GitHub. Use get_session_github to inspect/refresh linked status and discover accessible sessions already working on the same reference before starting duplicate work. Keep Issues as quiet background records, not a mandatory workflow. Unlink an incorrect association with unlink_session_github.";
4
+ function attachSessionGithub(ctx) {
5
+ var inflight = new Map();
6
+ var attempts = new Map();
7
+ function authorize(session) {
8
+ if (!session || ctx.sm.sessions.get(session.localId) !== session || ctx.isMate || !ctx.canUse(session)) throw new Error("GitHub links require an authorized project session.");
9
+ var identity = ctx.identity(session);
10
+ // Account login does not imply OS isolation. Match the project's execution mode.
11
+ if (ctx.osUsers && !identity) throw new Error("GitHub requires a mapped OS user with gh authentication in OS-isolated mode.");
12
+ return identity;
13
+ }
14
+ function persist(session) {
15
+ if (ctx.sm.saveSessionFile(session) === false) throw new Error("Could not save GitHub links.");
16
+ ctx.sm.broadcastSessionList();
17
+ }
18
+ async function refresh(session, force) {
19
+ var identity = authorize(session);
20
+ var id = session.localId;
21
+ if (inflight.has(id)) return inflight.get(id);
22
+ if (!force && Date.now() - (attempts.get(id) || 0) < 60000) return session.githubLinks || [];
23
+ attempts.set(id, Date.now());
24
+ var pending = (async function () {
25
+ var links = (session.githubLinks || []).slice();
26
+ var updated = [];
27
+ for (var link of links) {
28
+ try { updated.push(await github.readReference(ctx.cwd, link.url, identity, ctx.run)); }
29
+ catch (error) { updated.push(Object.assign({}, link, { stale: true })); }
30
+ }
31
+ authorize(session);
32
+ // A link/unlink during the request wins over the refresh snapshot.
33
+ session.githubLinks = (session.githubLinks || []).map(function (link) {
34
+ var index = links.indexOf(link);
35
+ return index < 0 ? link : updated[index];
36
+ });
37
+ if (links.length) persist(session);
38
+ return session.githubLinks || [];
39
+ })();
40
+ inflight.set(id, pending);
41
+ try { return await pending; } finally { inflight.delete(id); }
42
+ }
43
+ async function invoke(session, name, args) {
44
+ var identity = authorize(session);
45
+ if (name === "get_session_github") {
46
+ var links = await refresh(session, false);
47
+ var matches = [];
48
+ if (args.url) {
49
+ var url = github.parseReference(args.url).url.toLowerCase();
50
+ ctx.sm.sessions.forEach(function (other) {
51
+ if (!other.hidden && !other.delegated && !require("./session-provenance").isWorker(other) && ctx.canRead(session, other) && (other.githubLinks || []).some(function (link) { return link.url.toLowerCase() === url; })) matches.push({ sessionId: other.localId, title: other.title });
52
+ });
53
+ }
54
+ return { links: links, matchingSessions: matches.slice(0, 20) };
55
+ }
56
+ var ref = github.parseReference(args.url);
57
+ if (name === "unlink_session_github") {
58
+ session.githubLinks = (session.githubLinks || []).filter(function (link) { return link.url.toLowerCase() !== ref.url.toLowerCase(); });
59
+ } else {
60
+ var verified = await github.readReference(ctx.cwd, ref.url, identity, ctx.run);
61
+ authorize(session);
62
+ var retained = (session.githubLinks || []).filter(function (link) { return link.url.toLowerCase() !== ref.url.toLowerCase(); });
63
+ if (retained.length >= 8) throw new Error("A session can link up to eight GitHub items.");
64
+ session.githubLinks = retained.concat([verified]);
65
+ }
66
+ persist(session);
67
+ return { links: session.githubLinks };
68
+ }
69
+ function defs(session) {
70
+ if (ctx.isMate || (session && !ctx.canUse(session))) return [];
71
+ return ["link_session_github", "get_session_github", "unlink_session_github"].map(function (name) {
72
+ return { name: name, permissionName: "mcp__clay-github__" + name, description: CONTRACT + " " + (name === "get_session_github" ? "Read linked status; optional URL finds visible existing work sessions." : name === "link_session_github" ? "Verify and attach one issue or PR to the current session." : "Remove one local association without modifying GitHub."), inputSchema: buildShape({ url: { type: "string", description: "Exact https://github.com/owner/repo/issues/123 or /pull/123 URL." } }, name === "get_session_github" ? [] : ["url"]), handler: async function (args) {
73
+ try { return { content: [{ type: "text", text: JSON.stringify(await invoke(session, name, args || {})) }] }; }
74
+ catch (error) { return { isError: true, content: [{ type: "text", text: error.message }] }; }
75
+ } };
76
+ });
77
+ }
78
+ return {
79
+ getDynamicToolDefs: defs,
80
+ getSystemPrompt: function () { return ctx.isMate ? "" : CONTRACT; },
81
+ createMcpServer: function (adapter, session) { return ctx.isMate || !adapter.createToolServer ? null : adapter.createToolServer({ name: "clay-github", version: "1.0.0", tools: defs(session) }); },
82
+ getBridgeTools: function (session, normalize) { return defs(session).map(function (tool) { return { server: "clay-github", name: tool.name, description: tool.description, inputSchema: normalize(tool.inputSchema) }; }); },
83
+ callBridgeTool: function (session, name, args) { var tool = defs(session).find(function (item) { return item.name === name; }); return tool ? tool.handler(args) : Promise.reject(new Error("GitHub tool unavailable.")); },
84
+ handleMessage: function (ws, msg) {
85
+ if (msg.type !== "session_github_refresh") return false;
86
+ var session = ctx.sm.sessions.get(ws._clayActiveSession);
87
+ if (!session || !ctx.canRefresh(ws, session) || !(session.githubLinks || []).length) return true;
88
+ refresh(session, false).catch(function () {});
89
+ return true;
90
+ },
91
+ };
92
+ }
93
+ module.exports = { attachSessionGithub: attachSessionGithub };
package/lib/project.js CHANGED
@@ -813,6 +813,24 @@ function createProjectContext(opts) {
813
813
  service: opts.issuesService, projectSlug: slug, isMate: isMate, sm: sm,
814
814
  sendTo: sendTo, getClients: function () { return clients; }, startWork: _issueWork,
815
815
  });
816
+ var _sessionGithub = require("./project-session-github").attachSessionGithub({
817
+ cwd: cwd, sm: sm, isMate: isMate, osUsers: osUsers,
818
+ canUse: function (session) {
819
+ if (session.hidden || session.delegated || session._pairDelegation || session._delegatedBy || require("./session-provenance").isWorker(session)) return false;
820
+ if (!usersModule.isMultiUser()) return true;
821
+ var owner = session.ownerId && usersModule.findUserById(session.ownerId);
822
+ return !!(owner && opts.canAccessProjectSlug && opts.canAccessProjectSlug(owner.id, slug));
823
+ },
824
+ identity: function (session) {
825
+ return getOsUserInfoForLinuxUser(getLinuxUserForSession(session));
826
+ },
827
+ canRead: function (caller, other) {
828
+ return !usersModule.isMultiUser() || usersModule.canAccessSession(caller.ownerId, other, opts.getProjectAccess ? opts.getProjectAccess() : { visibility: "public" });
829
+ },
830
+ canRefresh: function (ws, session) {
831
+ return !usersModule.isMultiUser() || !!(ws._clayUser && ws._clayUser.id === session.ownerId);
832
+ },
833
+ });
816
834
  var _mateKnowledge = attachProjectMateKnowledge({
817
835
  service: opts.mateKnowledgeService || null,
818
836
  sm: sm,
@@ -992,6 +1010,8 @@ function createProjectContext(opts) {
992
1010
  // get the read-only cross-project set only when the Mate registry confirms
993
1011
  // authoritative builtin Clay.
994
1012
  try {
1013
+ var githubMcpConfig = _sessionGithub.createMcpServer(adapter, null);
1014
+ if (githubMcpConfig) servers["clay-github"] = githubMcpConfig;
995
1015
  var issuesMcpConfig = _issues.createMcpServer(adapter, null);
996
1016
  if (issuesMcpConfig) servers["clay-issues"] = issuesMcpConfig;
997
1017
  var projectLogsMcpConfig = _projectLogs.createMcpServer(adapter, null);
@@ -1175,6 +1195,11 @@ function createProjectContext(opts) {
1175
1195
  }
1176
1196
  continue;
1177
1197
  }
1198
+ if (name === "clay-github" && forSession) {
1199
+ var boundGithub = _sessionGithub.createMcpServer(adapter, forSession);
1200
+ if (boundGithub) { filtered[name] = boundGithub; hasAny = true; }
1201
+ continue;
1202
+ }
1178
1203
  if (name === "clay-issues" && forSession) {
1179
1204
  var boundIssues = _issues.createMcpServer(adapter, forSession);
1180
1205
  if (boundIssues) { filtered[name] = boundIssues; hasAny = true; }
@@ -1348,6 +1373,7 @@ function createProjectContext(opts) {
1348
1373
  _capsuleCatalog.getSystemPrompt(session),
1349
1374
  _projectLogs.getSystemPrompt(session),
1350
1375
  _issues.getSystemPrompt(session),
1376
+ _sessionGithub.getSystemPrompt(session),
1351
1377
  _scheduledTasks ? _scheduledTasks.getSystemPrompt(session) : "",
1352
1378
  _mateKnowledge.getSystemPrompt(session),
1353
1379
  _autonomousRun ? _autonomousRun.getSystemPrompt(session) : "",
@@ -1369,6 +1395,7 @@ function createProjectContext(opts) {
1369
1395
  .concat(_workspaceQuery.getHistoryDynamicToolDefs(session))
1370
1396
  .concat(_projectLogs.getDynamicToolDefs(session))
1371
1397
  .concat(_issues.getDynamicToolDefs(session))
1398
+ .concat(_sessionGithub.getDynamicToolDefs(session))
1372
1399
  .concat(_mateKnowledge.getDynamicToolDefs(session))
1373
1400
  .concat(_loopInterview ? _loopInterview.getToolDefs(session) : [])
1374
1401
  .concat(_scheduledTasks ? _scheduledTasks.getToolDefs(session) : [])
@@ -1760,6 +1787,7 @@ function createProjectContext(opts) {
1760
1787
  // --- Project Logs (delegated to project-logs.js) ---
1761
1788
  if (_projectLogs.handleLogsMessage(ws, msg)) return;
1762
1789
  if (_issues.handleMessage(ws, msg)) return;
1790
+ if (_sessionGithub.handleMessage(ws, msg)) return;
1763
1791
 
1764
1792
  // --- Notifications (delegated to project-notifications.js) ---
1765
1793
  if (_notifications.handleNotificationMessage(ws, msg)) return;
@@ -2328,6 +2356,7 @@ function createProjectContext(opts) {
2328
2356
  var historyTools = _workspaceQuery.getHistoryBridgeTools(boundSession, normalizeToolSchema);
2329
2357
  for (var cht = 0; cht < historyTools.length; cht++) tools.push(historyTools[cht]);
2330
2358
  tools = tools.concat(_issues.getBridgeTools(boundSession, normalizeToolSchema));
2359
+ tools = tools.concat(_sessionGithub.getBridgeTools(boundSession, normalizeToolSchema));
2331
2360
  var logsTools = _projectLogs.getBridgeTools(boundSession, normalizeToolSchema);
2332
2361
  for (var plti = 0; plti < logsTools.length; plti++) tools.push(logsTools[plti]);
2333
2362
  var knowledgeTools = _mateKnowledge.getBridgeTools(boundSession, normalizeToolSchema);
@@ -2355,7 +2384,7 @@ function createProjectContext(opts) {
2355
2384
  // Debate tools were added above only when this request had an
2356
2385
  // exact session. Never duplicate them or advertise the static,
2357
2386
  // intentionally unbound proposal server.
2358
- if (inAppNames[i] === "clay-debate" || inAppNames[i] === "clay-mate-creation" || inAppNames[i] === "clay-workspace" || inAppNames[i] === "clay-history" || inAppNames[i] === "clay-issues" || inAppNames[i] === "clay-logs" || inAppNames[i] === "clay-knowledge") continue;
2387
+ if (inAppNames[i] === "clay-debate" || inAppNames[i] === "clay-mate-creation" || inAppNames[i] === "clay-workspace" || inAppNames[i] === "clay-history" || inAppNames[i] === "clay-github" || inAppNames[i] === "clay-issues" || inAppNames[i] === "clay-logs" || inAppNames[i] === "clay-knowledge") continue;
2359
2388
  extractServerTools(inAppNames[i], localMcp[inAppNames[i]]);
2360
2389
  }
2361
2390
  }
@@ -2441,6 +2470,7 @@ function createProjectContext(opts) {
2441
2470
  if (boundSession && serverName === "clay-history") {
2442
2471
  return _workspaceQuery.callHistoryBridgeTool(boundSession, toolName, args);
2443
2472
  }
2473
+ if (boundSession && serverName === "clay-github") return _sessionGithub.callBridgeTool(boundSession, toolName, args);
2444
2474
  if (boundSession && serverName === "clay-issues") return _issues.callBridgeTool(boundSession, toolName, args);
2445
2475
  if (boundSession && serverName === "clay-logs") {
2446
2476
  return _projectLogs.callBridgeTool(boundSession, toolName, args);
@@ -2448,7 +2478,7 @@ function createProjectContext(opts) {
2448
2478
  if (boundSession && serverName === "clay-knowledge") {
2449
2479
  return _mateKnowledge.callBridgeTool(boundSession, toolName, args);
2450
2480
  }
2451
- if (serverName === "clay-sessions" || serverName === "clay-notes" || serverName === "clay-handoff" || serverName === "clay-documents" || serverName === "clay-debate" || serverName === "clay-mate-creation" || serverName === "clay-workspace" || serverName === "clay-history" || serverName === "clay-issues" || serverName === "clay-logs" || serverName === "clay-knowledge") {
2481
+ if (serverName === "clay-sessions" || serverName === "clay-notes" || serverName === "clay-handoff" || serverName === "clay-documents" || serverName === "clay-debate" || serverName === "clay-mate-creation" || serverName === "clay-workspace" || serverName === "clay-history" || serverName === "clay-github" || serverName === "clay-issues" || serverName === "clay-logs" || serverName === "clay-knowledge") {
2452
2482
  return Promise.reject(new Error("Session-bound tool requires a valid Clay session: " + serverName + "/" + toolName));
2453
2483
  }
2454
2484
  if (sessionOnly) return Promise.reject(new Error("Session tool not found: " + serverName + "/" + toolName));
package/lib/public/app.js CHANGED
@@ -286,6 +286,10 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
286
286
  projectName: projectName,
287
287
  cwd: "",
288
288
  currentSlug: currentSlug,
289
+ githubWorkPopoverBound: false,
290
+ githubWorkSessions: [],
291
+ githubWorkProject: null,
292
+ githubWorkRefresh: null,
289
293
  activeProjectSlug: null,
290
294
  sessionActivatedProjectSlug: null,
291
295
  sessionListProjectSlug: null,
@@ -0,0 +1,112 @@
1
+ /* Quiet work references share one visual language across sessions and Git. */
2
+ .session-work-column {
3
+ display: flex;
4
+ flex-direction: column;
5
+ gap: 3px;
6
+ flex: 1 1 auto;
7
+ min-width: 0;
8
+ overflow: hidden;
9
+ }
10
+ .session-work-column > .session-item-text,
11
+ .session-work-column > .mobile-session-title {
12
+ display: block;
13
+ flex: none;
14
+ width: 100%;
15
+ min-width: 0;
16
+ overflow: hidden;
17
+ text-overflow: ellipsis;
18
+ white-space: nowrap;
19
+ }
20
+ .session-github-links {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: 8px;
24
+ min-width: 0;
25
+ overflow-x: auto;
26
+ scrollbar-width: none;
27
+ font-size: 11px;
28
+ line-height: 18px;
29
+ }
30
+ .session-github-link {
31
+ display: inline-flex;
32
+ align-items: center;
33
+ gap: 5px;
34
+ padding: 1px 5px;
35
+ border: 1px solid color-mix(in srgb, var(--text, #888) 12%, transparent);
36
+ background: color-mix(in srgb, var(--text, #888) 3%, transparent);
37
+ flex-shrink: 0;
38
+ color: var(--text-muted, #888);
39
+ text-decoration: none;
40
+ border-radius: 5px;
41
+ }
42
+ .session-github-link svg { width: 13px; height: 13px; }
43
+ .session-github-link:hover { color: var(--text); background: color-mix(in srgb, var(--text, #888) 8%, transparent); border-color: color-mix(in srgb, var(--text, #888) 24%, transparent); }
44
+ .session-github-link:focus-visible, .github-work-action:focus-visible { outline: 2px solid var(--accent, #58a6ff); outline-offset: 2px; }
45
+ .github-state-open .github-work-status svg { color: #3fb950; }
46
+ .github-state-merged .github-work-status svg, .github-state-closed .github-work-status svg { color: #a371f7; }
47
+ .github-state-draft .github-work-status svg, .github-state-unknown .github-work-status svg { color: var(--text-muted, #888); }
48
+ #header-github-links { min-width: 0; max-width: 200px; flex: 0 0 auto; margin-left: 4px; }
49
+ .github-work-details { flex-wrap: wrap; padding: 8px 0; gap: 8px 12px; }
50
+ .github-work-action { color: var(--text-muted, #888); text-decoration: none; font-size: 11px; }
51
+ .github-work-action:hover { text-decoration: underline; }
52
+ @media (max-width: 600px) {
53
+ #header-github-links { max-width: 82px; }
54
+ .mobile-session-item .session-github-link { min-height: 28px; padding-right: 4px; }
55
+ }
56
+
57
+ .session-item:has(.session-work-column) { height: 52px; }
58
+
59
+ .github-kind-pr.github-state-closed .github-work-status svg { color: #f85149; }
60
+ @media (max-width: 600px) {
61
+ #header-github-links { flex: 0 0 auto; }
62
+ #header-github-links .session-github-link { display: none; }
63
+ #header-github-links .session-github-link:first-child { display: inline-flex; }
64
+
65
+ }
66
+
67
+ .github-work-brand, .github-work-status { display: inline-flex; align-items: center; flex: none; }
68
+ .github-work-brand { color: var(--text); opacity: .8; padding-right: 5px; border-right: 1px solid color-mix(in srgb, var(--text, #888) 14%, transparent); }
69
+ #header-github-links .github-work-brand { display: none; }
70
+ @media (max-width: 600px) {
71
+ #header-github-links .github-work-kind { display: none; }
72
+ }
73
+
74
+ .github-work-more {
75
+ flex: none;
76
+ border: 1px solid color-mix(in srgb, var(--text, #888) 12%, transparent);
77
+ background: transparent;
78
+ color: var(--text-muted, #888);
79
+ border-radius: 5px;
80
+ font: inherit;
81
+ line-height: 18px;
82
+ padding: 1px 5px;
83
+ cursor: pointer;
84
+ }
85
+ .github-work-more:hover, .github-work-more[aria-expanded="true"] { background: color-mix(in srgb, var(--text, #888) 8%, transparent); color: var(--text); }
86
+ .github-work-more:focus-visible { outline: 2px solid var(--accent, #58a6ff); outline-offset: 2px; }
87
+ .github-work-popover {
88
+ position: fixed;
89
+ inset: auto;
90
+ margin: 0;
91
+ width: min(320px, calc(100vw - 16px));
92
+ max-height: min(420px, calc(100dvh - 16px));
93
+ overflow-y: auto;
94
+ padding: 12px;
95
+ box-sizing: border-box;
96
+ border: 1px solid var(--border, #444);
97
+ border-radius: 10px;
98
+ background: var(--bg-alt, #222);
99
+ color: var(--text, #eee);
100
+ box-shadow: 0 8px 30px #0003;
101
+ }
102
+ .github-work-popover > strong { display: block; font-size: 12px; margin-bottom: 10px; }
103
+ .github-work-popover .session-github-links { display: flex; flex-direction: column; align-items: stretch; overflow: visible; gap: 0; }
104
+ .github-work-entry { display: grid; grid-template-columns: 1fr; gap: 4px; padding: 8px 0; }
105
+ .github-work-entry + .github-work-entry { border-top: 1px solid var(--border, #444); }
106
+ .github-work-entry .session-github-link { justify-self: start; }
107
+ .github-work-entry-title { font-size: 12px; overflow-wrap: anywhere; }
108
+ .github-work-entry-meta { color: var(--text-muted, #888); font-size: 11px; overflow-wrap: anywhere; }
109
+ @media (max-width: 600px) {
110
+ #header-github-links { max-width: 120px; }
111
+ .mobile-session-item .github-work-more { min-height: 28px; }
112
+ }
@@ -1,3 +1,4 @@
1
+ import { syncGithubSessions } from './session-github.js';
1
2
  import { handleIssuesMessageInContext as handleIssuesMessage } from './issues-context.js';
2
3
  // app-messages.js - WebSocket message router
3
4
  // Extracted from app.js (PR-23)
@@ -711,6 +712,7 @@ export function processMessage(msg) {
711
712
  break;
712
713
 
713
714
  case "session_list":
715
+ syncGithubSessions(msg.sessions || []);
714
716
  markProjectSessionListHydrated();
715
717
  renderMateSessionList(msg.sessions || []);
716
718
  renderSessionList(msg.sessions || []);
@@ -1,3 +1,4 @@
1
+ import { currentGithubWorkMarkup, refreshGithubWork, resetGithubWork } from './session-github.js';
1
2
  import { store } from './store.js';
2
3
  import { refreshIcons, iconHtml } from './icons.js';
3
4
  import { escapeHtml, showToast } from './utils.js';
@@ -205,7 +206,7 @@ function render() {
205
206
  } else if (!currentStatus.isRepository) {
206
207
  body.innerHTML = '<div class="git-panel-empty">' + iconHtml("folder-git-2") + '<span>This project is not a Git repository.</span></div>';
207
208
  } else {
208
- body.innerHTML = renderRepository(currentStatus);
209
+ body.innerHTML = currentGithubWorkMarkup() + renderRepository(currentStatus);
209
210
  }
210
211
  refreshIcons(body);
211
212
  var changesList = body.querySelector(".git-section-changes .git-file-list");
@@ -252,6 +253,7 @@ export function refreshGitSummary() {
252
253
  // Project switch and reconnect both land here, so no stale repository ever
253
254
  // survives into another project and nothing flashes before detection lands.
254
255
  export function resetGitSurface() {
256
+ resetGithubWork();
255
257
  currentStatus = null;
256
258
  currentSummary = null;
257
259
  lastError = "";
@@ -263,6 +265,7 @@ export function resetGitSurface() {
263
265
  }
264
266
 
265
267
  export function refreshGitStatus() {
268
+ refreshGithubWork();
266
269
  if (busy) return Promise.resolve(currentStatus);
267
270
  var basePath = store.get('basePath');
268
271
  if (statusBasePath !== basePath) {
@@ -399,7 +402,7 @@ function initGitLifecycle() {
399
402
  return;
400
403
  }
401
404
  if (state.dmMode !== prev.dmMode) {
402
- if (state.dmMode) hideGitPlacard();
405
+ if (state.dmMode) { hideGitPlacard(); resetGithubWork(); }
403
406
  else refreshGitSummary();
404
407
  return;
405
408
  }
@@ -478,6 +481,7 @@ export function initGitPanel() {
478
481
  if (summaryTicks < SUMMARY_TICKS) return;
479
482
  summaryTicks = 0;
480
483
  if (document.hidden || store.get('dmMode') || !store.get('connected') || busy) return;
484
+ refreshGithubWork();
481
485
  refreshGitSummary();
482
486
  }, POLL_MS);
483
487
  }
@@ -1,3 +1,4 @@
1
+ import { githubWorkMarkup } from './session-github.js';
1
2
  import { escapeHtml } from './utils.js';
2
3
  import { renderMarkdown } from './markdown.js';
3
4
  import { authorLine, relativeTime } from './project-logs-render.js';
@@ -34,6 +35,7 @@ export function renderIssueDetail(entry) {
34
35
  var time = entry.updatedAt || entry.createdAt;
35
36
  var byline = [authorLine(entry), time ? new Date(time).toLocaleString() : '', 'v' + entry.revision].filter(Boolean).join(' · ');
36
37
  return '<article class="issue-detail"><div class="issue-doc-chips"><span class="issue-type">' + esc(entry.type) + '</span>' + priorityChip(entry.priority) + statusChip(entry.status) + '</div>' +
38
+ githubWorkMarkup(entry, true) +
37
39
  '<h2 class="issue-doc-title">' + esc(entry.title) + '</h2>' + prose(entry.summary, 'issue-doc-summary') + '<div class="issue-byline">' + esc(byline) + '</div>' +
38
40
  '<div class="issue-actions">' + action('data-issue-work', 'play', 'Start work', ' class="issue-primary"' + (closed ? ' disabled title="Reopen this issue to start work"' : '')) +
39
41
  action('data-issue-edit', 'pencil', 'Edit') + action('data-issue-history', 'history', 'History') + action('data-issue-copy', 'link', 'Copy reference') +
@@ -0,0 +1,153 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { githubLinkTarget } from './github-links.js';
4
+ import { iconHtml, refreshIcons } from './icons.js';
5
+ import { escapeHtml as escapeText } from './utils.js';
6
+
7
+ function escapeHtml(value) {
8
+ return escapeText(String(value)).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
9
+ }
10
+
11
+ function safeLinks(session) {
12
+ return (session && Array.isArray(session.githubLinks) ? session.githubLinks : []).filter(function (link) {
13
+ var target = githubLinkTarget(link.url);
14
+ return target && (target.kind === 'issue' || target.kind === 'pull');
15
+ });
16
+ }
17
+ function label(link) {
18
+ return (link.kind === 'pr' ? 'PR #' : 'Issue #') + link.number;
19
+ }
20
+ function anchor(url, title, content, className) {
21
+ return '<a class="' + className + '" href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" title="' + escapeHtml(title) + '" aria-label="' + escapeHtml(title) + '">' + content + '</a>';
22
+ }
23
+ export function orderedGithubWork(session) {
24
+ var links = safeLinks(session).slice().reverse();
25
+ var latestIssue = links.find(function (link) { return link.kind === 'issue'; });
26
+ if (latestIssue) links = [latestIssue].concat(links.filter(function (link) { return link !== latestIssue; }));
27
+ return links;
28
+ }
29
+ export function githubWorkMarkup(session, detailed, expanded) {
30
+ var links = orderedGithubWork(session);
31
+ if (!links.length) return '';
32
+ return '<span class="session-github-links' + (detailed ? ' github-work-details' : '') + '">' + (detailed || expanded ? links : links.slice(0, 1)).map(function (link) {
33
+ var state = ['open', 'closed', 'merged', 'draft'].indexOf(link.state) >= 0 ? link.state : 'unknown';
34
+ var icon = link.kind === 'pr' ? (state === 'merged' ? 'git-merge' : state === 'closed' ? 'git-pull-request-closed' : state === 'draft' ? 'git-pull-request-draft' : 'git-pull-request') : (state === 'closed' ? 'circle-check' : 'circle-dot');
35
+ var description = link.repository + ' · ' + label(link) + ' · ' + link.title + ' · ' + state + (link.checkedAt ? ' · Checked ' + new Date(link.checkedAt).toLocaleString() : '') + (link.stale ? ' (last known status; refresh unavailable)' : '');
36
+ var html = anchor(link.url, description, iconHtml('github', 'github-work-brand') + iconHtml(icon, 'github-work-status') + '<span><span class="github-work-kind">' + (link.kind === 'pr' ? 'PR ' : 'Issue ') + '</span>#' + escapeHtml(String(link.number)) + '</span>', 'session-github-link github-kind-' + (link.kind === 'pr' ? 'pr' : 'issue') + ' github-state-' + (link.stale ? 'unknown' : state));
37
+ if (expanded) html = '<span class="github-work-entry">' + html + '<span class="github-work-entry-title">' + escapeHtml(link.title || '') + '</span><span class="github-work-entry-meta">' + escapeHtml(link.repository + ' · ' + state + (link.stale ? ' · Last known status' : '')) + '</span></span>';
38
+ if (detailed && link.kind === 'pr') {
39
+ html += anchor(link.url + '/files', 'View PR changes on GitHub', 'Changes', 'github-work-action');
40
+ html += anchor(link.url + '#pullrequestreview', 'View PR reviews on GitHub', escapeHtml((link.review || 'Reviews').replace(/_/g, ' ').toLowerCase()), 'github-work-action');
41
+ (link.checks || []).forEach(function (check) {
42
+ if (/^https:\/\//i.test(check.url)) html += anchor(check.url, check.name + ': ' + check.state, escapeHtml(check.name + ' · ' + check.state.toLowerCase()), 'github-work-action');
43
+ });
44
+ }
45
+ return html;
46
+ }).join('') + (!detailed && !expanded && links.length > 1 ? '<button type="button" class="github-work-more" aria-label="Show all ' + links.length + ' linked GitHub items" aria-haspopup="dialog" aria-expanded="false" data-github-links="' + escapeHtml(JSON.stringify(safeLinks(session))) + '">+' + (links.length - 1) + '</button>' : '') + '</span>';
47
+ }
48
+ export function appendGithubWork(row, session, title) {
49
+ var html = githubWorkMarkup(session, false);
50
+ bindGithubWorkPopover();
51
+ if (!html) return;
52
+ var column = document.createElement('span');
53
+ column.className = 'session-work-column';
54
+ row.insertBefore(column, title);
55
+ column.appendChild(title);
56
+ var links = document.createElement('span');
57
+ links.innerHTML = html;
58
+ links.addEventListener('click', function (event) { if (event.target.closest('a')) event.stopPropagation(); });
59
+ links.addEventListener('contextmenu', function (event) { event.stopPropagation(); });
60
+ column.appendChild(links);
61
+ }
62
+ export function syncGithubSessions(sessions) {
63
+ bindGithubWorkPopover();
64
+ store.set({ githubWorkSessions: sessions || [], githubWorkProject: store.get('currentSlug') });
65
+ var active = (sessions || []).find(function (session) { return session.active; });
66
+ var title = document.getElementById('header-title');
67
+ var old = document.getElementById('header-github-links');
68
+ var html = title && !store.get('dmMode') && !store.get('splitPanes') && active ? githubWorkMarkup(active, false) : '';
69
+ if (!html) { if (old) old.remove(); }
70
+ else if (!old || old.dataset.markup !== html) {
71
+ var element = old || document.createElement('span');
72
+ element.id = 'header-github-links';
73
+ element.dataset.markup = html;
74
+ element.innerHTML = html;
75
+ if (!old) title.insertAdjacentElement('afterend', element);
76
+ }
77
+ refreshIcons();
78
+ refreshGithubWork();
79
+ }
80
+ export function currentGithubWorkMarkup() {
81
+ if (store.get('githubWorkProject') !== store.get('currentSlug')) return '';
82
+ var session = (store.get('githubWorkSessions') || []).find(function (item) { return item.active; });
83
+ return githubWorkMarkup(session, true);
84
+ }
85
+ export function refreshGithubWork() {
86
+ if (document.hidden || store.get('dmMode') || !store.get('connected')) return;
87
+ if (store.get('githubWorkProject') !== store.get('currentSlug')) return;
88
+ var session = (store.get('githubWorkSessions') || []).find(function (item) { return item.active; });
89
+ if (!safeLinks(session).length) return;
90
+ var key = store.get('currentSlug') + ':' + session.id;
91
+ var previous = store.get('githubWorkRefresh') || {};
92
+ if (previous.key === key && Date.now() - previous.at < 60000) return;
93
+ var ws = getWs();
94
+ if (!ws || ws.readyState !== 1) return;
95
+ store.set({ githubWorkRefresh: { key: key, at: Date.now() } });
96
+ ws.send(JSON.stringify({ type: 'session_github_refresh' }));
97
+ }
98
+
99
+ export function resetGithubWork() {
100
+ closeGithubWorkPopover();
101
+ store.set({ githubWorkSessions: [], githubWorkProject: null, githubWorkRefresh: null });
102
+ var header = document.getElementById('header-github-links');
103
+ if (header) header.remove();
104
+ }
105
+
106
+ function closeGithubWorkPopover() {
107
+ var popup = document.getElementById('github-work-popover');
108
+ if (popup) popup.hidePopover();
109
+ }
110
+ function bindGithubWorkPopover() {
111
+ if (store.get('githubWorkPopoverBound')) return;
112
+ store.set({ githubWorkPopoverBound: true });
113
+ document.addEventListener('click', function (event) {
114
+ var button = event.target.closest('.github-work-more');
115
+ if (!button) return;
116
+ event.preventDefault();
117
+ event.stopPropagation();
118
+ var existing = document.getElementById('github-work-popover');
119
+ var same = existing && button.getAttribute('aria-expanded') === 'true';
120
+ if (existing) { existing.hidePopover(); existing.remove(); }
121
+ if (same) return;
122
+ var links;
123
+ try { links = JSON.parse(button.dataset.githubLinks); } catch (error) { return; }
124
+ var popup = document.createElement('div');
125
+ popup.id = 'github-work-popover';
126
+ popup.className = 'github-work-popover';
127
+ popup.setAttribute('popover', 'auto');
128
+ popup.setAttribute('role', 'dialog');
129
+ popup.setAttribute('aria-label', 'Linked GitHub work');
130
+ popup.innerHTML = '<strong>Linked GitHub work</strong>' + githubWorkMarkup({ githubLinks: links }, false, true);
131
+ document.body.appendChild(popup);
132
+ button.setAttribute('aria-expanded', 'true');
133
+ popup.addEventListener('toggle', function (toggle) {
134
+ if (toggle.newState === 'closed') {
135
+ button.setAttribute('aria-expanded', 'false');
136
+ if (document.activeElement === document.body && button.isConnected) button.focus();
137
+ popup.remove();
138
+ }
139
+ });
140
+ popup.showPopover();
141
+ var rect = button.getBoundingClientRect();
142
+ var box = popup.getBoundingClientRect();
143
+ popup.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - box.width - 8)) + 'px';
144
+ popup.style.top = Math.max(8, rect.bottom + box.height + 8 <= window.innerHeight ? rect.bottom + 6 : rect.top - box.height - 6) + 'px';
145
+ var first = popup.querySelector('a');
146
+ if (first) first.focus({ preventScroll: true });
147
+ refreshIcons();
148
+ }, true);
149
+ window.addEventListener('resize', closeGithubWorkPopover);
150
+ store.subscribe(function (state, previous) {
151
+ if (state.currentSlug !== previous.currentSlug || state.activeSessionId !== previous.activeSessionId || state.dmMode !== previous.dmMode) closeGithubWorkPopover();
152
+ });
153
+ }
@@ -1,3 +1,4 @@
1
+ import { appendGithubWork } from './session-github.js';
1
2
  // sidebar-mobile.js - Mobile sheet overlays, tab bar, and mobile-specific rendering
2
3
  // Extracted from sidebar.js (PR-38)
3
4
 
@@ -384,7 +385,7 @@ function renderSheetSessions(listEl) {
384
385
  // Helper: create a mobile session item element
385
386
  function createMobileSessionItem(s, options) {
386
387
  var itemOptions = options || {};
387
- var el = document.createElement("button");
388
+ var el = document.createElement("div");
388
389
  el.className = "mobile-session-item" + (s.active ? " active" : "");
389
390
  if (itemOptions.worker) el.classList.add("mobile-worker-item");
390
391
 
@@ -404,9 +405,15 @@ function createMobileSessionItem(s, options) {
404
405
  }
405
406
 
406
407
  var titleSpan = document.createElement("span");
408
+ titleSpan.tabIndex = 0;
409
+ titleSpan.setAttribute("role", "button");
410
+ titleSpan.addEventListener("keydown", function (event) {
411
+ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); el.click(); }
412
+ });
407
413
  titleSpan.className = "mobile-session-title";
408
414
  titleSpan.appendChild(document.createTextNode(s.title || "New Session"));
409
415
  el.appendChild(titleSpan);
416
+ appendGithubWork(el, s, titleSpan);
410
417
 
411
418
  if (itemOptions.worker) {
412
419
  var generation = document.createElement("span");
@@ -1,3 +1,4 @@
1
+ import { appendGithubWork } from './session-github.js';
1
2
  // sidebar-sessions.js - Session list, search, presence, countdown, CLI picker
2
3
  // Extracted from sidebar.js (PR-35)
3
4
 
@@ -1367,6 +1368,7 @@ function renderSessionItem(s, options) {
1367
1368
  textHtml += highlightMatch(s.title || "New Session", searchQuery);
1368
1369
  textSpan.innerHTML = textHtml;
1369
1370
  el.appendChild(textSpan);
1371
+ appendGithubWork(el, s, textSpan);
1370
1372
 
1371
1373
  if (itemOptions.worker) {
1372
1374
  var workerLabel = document.createElement("span");
@@ -1,3 +1,4 @@
1
+ @import url("css/session-github.css");
1
2
  @import url("css/issues.css");
2
3
  @import url("css/base.css?v=20260828-type1");
3
4
  @import url("css/icon-strip.css?v=20260828-surfaces4");
package/lib/sdk-bridge.js CHANGED
@@ -567,6 +567,17 @@ function createSDKBridge(opts) {
567
567
  return { behavior: "allow", updatedInput: input };
568
568
  }
569
569
 
570
+ // Session search is read-only; its handler enforces project and session access.
571
+ if (toolName === "search_other_driver_sessions"
572
+ || toolName === "mcp__clay-handoff__search_other_driver_sessions") {
573
+ return { behavior: "allow", updatedInput: input };
574
+ }
575
+
576
+ // Local work associations verify GitHub data but never mutate GitHub.
577
+ if (["link_session_github", "get_session_github", "unlink_session_github"].some(function (name) {
578
+ return toolName === name || toolName === "mcp__clay-github__" + name;
579
+ })) return { behavior: "allow", updatedInput: input };
580
+
570
581
  // Auto-approve safe browser MCP tools.
571
582
  // Only watch/unwatch: user explicitly chose which tab to share.
572
583
  // Everything else (screenshot, read_page, list_tabs, etc.) can expose
@@ -0,0 +1,48 @@
1
+ // Verified GitHub references owned by a work session, never inferred from mentions.
2
+ var execFile = require("child_process").execFile;
3
+ var wrapSpawnAsUser = require("./os-users").wrapSpawnAsUser;
4
+
5
+ function parseReference(value) {
6
+ var match = typeof value === "string" && value.match(/^https:\/\/github\.com\/([a-zA-Z0-9][a-zA-Z0-9-]{0,38})\/([a-zA-Z0-9][a-zA-Z0-9._-]{0,99})\/(issues|pull)\/([1-9][0-9]*)(?:[?#].*)?$/);
7
+ if (!match || !Number.isSafeInteger(Number(match[4]))) throw new Error("Use a GitHub issue or pull request URL.");
8
+ return { repository: match[1] + "/" + match[2], kind: match[3] === "pull" ? "pr" : "issue", number: Number(match[4]), url: "https://github.com/" + match[1] + "/" + match[2] + "/" + match[3] + "/" + match[4] };
9
+ }
10
+ function runGh(cwd, args, identity) {
11
+ return new Promise(function (resolve, reject) {
12
+ var env = Object.assign({}, identity ? require("./build-user-env").buildUserEnv(identity) : process.env, { GH_PROMPT_DISABLED: "1", GH_HOST: "github.com" });
13
+ if (identity) {
14
+ // Never let the daemon's GitHub credentials cross an OS-user boundary.
15
+ delete env.GH_TOKEN; delete env.GITHUB_TOKEN; delete env.GH_ENTERPRISE_TOKEN; delete env.GITHUB_ENTERPRISE_TOKEN;
16
+ delete env.GH_CONFIG_DIR; delete env.XDG_CONFIG_HOME;
17
+ env.HOME = identity.home; env.USER = identity.user; env.LOGNAME = identity.user;
18
+ }
19
+ var options = { cwd: cwd, env: env, timeout: 15000, maxBuffer: 512 * 1024, encoding: "utf8" };
20
+ if (identity) { options.uid = identity.uid; options.gid = identity.gid; }
21
+ var wrapped = wrapSpawnAsUser("gh", args, options);
22
+ execFile(wrapped.command, wrapped.args, wrapped.options, function (error, stdout) {
23
+ if (error) return reject(new Error("GitHub is unavailable. Check gh authentication and repository access."));
24
+ try { resolve(JSON.parse(stdout)); } catch (e) { reject(new Error("GitHub returned an invalid response.")); }
25
+ });
26
+ });
27
+ }
28
+ async function readReference(cwd, url, identity, run) {
29
+ var ref = parseReference(url);
30
+ var fields = "title,state,url" + (ref.kind === "pr" ? ",isDraft,headRefName,reviewDecision,statusCheckRollup" : "");
31
+ var data = await (run || runGh)(cwd, [ref.kind === "pr" ? "pr" : "issue", "view", String(ref.number), "--repo", ref.repository, "--json", fields], identity);
32
+ var verified = parseReference(data.url);
33
+ if (verified.url.toLowerCase() !== ref.url.toLowerCase()) throw new Error("GitHub returned a different reference.");
34
+ ref.title = String(data.title || "").slice(0, 200);
35
+ ref.state = data.state === "MERGED" ? "merged" : data.state === "CLOSED" ? "closed" : data.state === "OPEN" ? (data.isDraft ? "draft" : "open") : "unknown";
36
+ ref.checkedAt = Date.now();
37
+ if (ref.kind === "pr") {
38
+ ref.branch = String(data.headRefName || "").slice(0, 200);
39
+ ref.review = ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"].indexOf(data.reviewDecision) >= 0 ? data.reviewDecision : "";
40
+ ref.checks = (data.statusCheckRollup || []).slice(0, 30).map(function (check) {
41
+ var target = check.detailsUrl || check.targetUrl || "";
42
+ if (!/^https:\/\//i.test(target)) target = "";
43
+ return { name: String(check.name || check.context || "Check").slice(0, 100), state: String(check.conclusion || check.state || check.status || "PENDING").slice(0, 40), url: target };
44
+ });
45
+ }
46
+ return ref;
47
+ }
48
+ module.exports = { parseReference: parseReference, readReference: readReference };
package/lib/sessions.js CHANGED
@@ -176,6 +176,7 @@ function createSessionManager(opts) {
176
176
  titleManuallySet: session.titleManuallySet === true,
177
177
  createdAt: session.createdAt,
178
178
  };
179
+ if (session.githubLinks) metaObj.githubLinks = session.githubLinks;
179
180
  if (session.ownerId) metaObj.ownerId = session.ownerId;
180
181
  if (session.vendor) metaObj.vendor = session.vendor;
181
182
  if (session.codexDynamicToolCatalogVersion) metaObj.codexDynamicToolCatalogVersion = session.codexDynamicToolCatalogVersion;
@@ -343,6 +344,7 @@ function createSessionManager(opts) {
343
344
  title: m.title || "",
344
345
  titleProvisional: m.titleProvisional === true,
345
346
  titleAutoGenerated: m.titleAutoGenerated === true,
347
+ githubLinks: Array.isArray(m.githubLinks) ? m.githubLinks.slice(0, 8) : [],
346
348
  titleManuallySet: m.titleManuallySet === true || legacyTitleProtected,
347
349
  createdAt: m.createdAt || Date.now(),
348
350
  lastActivity: loaded[i].mtime || m.createdAt || Date.now(),
@@ -604,6 +606,7 @@ function createSessionManager(opts) {
604
606
  id: s.localId,
605
607
  cliSessionId: s.cliSessionId || null,
606
608
  title: s.title || "New Session",
609
+ githubLinks: s.githubLinks || [],
607
610
  active: isActive,
608
611
  isProcessing: s.isProcessing,
609
612
  // Sidebar badge: user work only. Ambient housekeeping is surfaced in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.4.1-beta.1",
3
+ "version": "4.5.0-beta.1",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"