clay-server 4.6.0 → 4.6.1-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.
@@ -1,9 +1,43 @@
1
1
  var github = require("./session-github");
2
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.";
3
+ var CONTRACT = "GitHub work links: proactively call link_session_github as soon as conversation context or tool results reasonably establish that an issue or PR is part of this session's work. Do not wait for an explicit linking request, implementation to begin, or work to finish. Investigating an issue, reviewing a PR or its requested changes, addressing review feedback, and checking CI or merge readiness all count as work. When a working PR is connected to an issue and both are relevant to the task, link both. Resolve a known repository and issue/PR number to the exact GitHub URL; if the repository is uncertain, verify it with a read-only lookup instead of silently skipping the link. Incidental mentions, examples, and unrelated reference material alone do not qualify. The current branch alone is insufficient evidence because sessions may share a checkout; combine it with conversation or verified task evidence. This records local metadata only and does not post to GitHub, so no separate linking approval is needed. Use get_session_github to inspect/refresh linked status and discover accessible sessions already working on the same reference before starting duplicate work. When the work shifts to another issue or PR, check that the relevant links are present before reporting progress or completion. Keep Issues as quiet background records, not a mandatory workflow. Unlink an incorrect association with unlink_session_github.";
4
4
  function attachSessionGithub(ctx) {
5
5
  var inflight = new Map();
6
6
  var attempts = new Map();
7
+ var statusCache = new Map();
8
+ var statusQueue = Promise.resolve();
9
+ function readStatus(session, link, stillAllowed) {
10
+ var identity = authorize(session);
11
+ var key = JSON.stringify([session.ownerId || null, identity && identity.uid, identity && identity.home, link.url.toLowerCase()]);
12
+ var cached = statusCache.get(key);
13
+ if (cached && (cached.pending || cached.until > Date.now())) return cached.pending || (cached.error ? Promise.reject(cached.error) : Promise.resolve(cached.value));
14
+ var entry = { failures: cached ? cached.failures : 0 };
15
+ statusCache.set(key, entry);
16
+ entry.pending = statusQueue.then(async function () {
17
+ try {
18
+ var currentIdentity = authorize(session);
19
+ if (JSON.stringify(currentIdentity) !== JSON.stringify(identity) || (stillAllowed && !stillAllowed())) throw new Error("Session access changed.");
20
+ entry.value = await github.readReference(ctx.cwd, link.url, identity, ctx.run, true);
21
+ entry.failures = 0;
22
+ entry.until = Date.now() + (entry.value.state === "closed" || entry.value.state === "merged" ? 300000 : 45000);
23
+ return entry.value;
24
+ } catch (error) {
25
+ entry.error = error;
26
+ entry.failures++;
27
+ entry.until = Date.now() + Math.min(900000, 60000 * Math.pow(2, entry.failures - 1));
28
+ throw error;
29
+ } finally {
30
+ entry.pending = null;
31
+ if (statusCache.size > 1000) {
32
+ for (var pair of statusCache) {
33
+ if (!pair[1].pending && pair[1].until <= Date.now()) statusCache.delete(pair[0]);
34
+ }
35
+ }
36
+ }
37
+ });
38
+ statusQueue = entry.pending.catch(function () {});
39
+ return entry.pending;
40
+ }
7
41
  function authorize(session) {
8
42
  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
43
  var identity = ctx.identity(session);
@@ -15,26 +49,36 @@ function attachSessionGithub(ctx) {
15
49
  if (ctx.sm.saveSessionFile(session) === false) throw new Error("Could not save GitHub links.");
16
50
  ctx.sm.broadcastSessionList();
17
51
  }
18
- async function refresh(session, force) {
52
+ async function refresh(session, force, statusOnly, stillAllowed) {
19
53
  var identity = authorize(session);
20
- var id = session.localId;
54
+ var id = session.localId + (statusOnly ? ":status" : ":full");
21
55
  if (inflight.has(id)) return inflight.get(id);
22
- if (!force && Date.now() - (attempts.get(id) || 0) < 60000) return session.githubLinks || [];
56
+ if (!force && Date.now() - (attempts.get(id) || 0) < (statusOnly ? 30000 : 60000)) return session.githubLinks || [];
23
57
  attempts.set(id, Date.now());
24
58
  var pending = (async function () {
25
59
  var links = (session.githubLinks || []).slice();
26
60
  var updated = [];
27
61
  for (var link of links) {
28
- try { updated.push(await github.readReference(ctx.cwd, link.url, identity, ctx.run)); }
62
+ try {
63
+ if (stillAllowed && !stillAllowed()) throw new Error("Session access changed.");
64
+ updated.push(statusOnly ? Object.assign({}, link, await readStatus(session, link, stillAllowed), { stale: false }) : await github.readReference(ctx.cwd, link.url, identity, ctx.run));
65
+ }
29
66
  catch (error) { updated.push(Object.assign({}, link, { stale: true })); }
30
67
  }
31
68
  authorize(session);
69
+ if (stillAllowed && !stillAllowed()) throw new Error("Session access changed.");
70
+ var changed = false;
32
71
  // A link/unlink during the request wins over the refresh snapshot.
33
72
  session.githubLinks = (session.githubLinks || []).map(function (link) {
34
73
  var index = links.indexOf(link);
35
- return index < 0 ? link : updated[index];
74
+ if (index < 0) return link;
75
+ var next = updated[index];
76
+ var before = Object.assign({}, link, { checkedAt: null, stale: !!link.stale });
77
+ var after = Object.assign({}, next, { checkedAt: null, stale: !!next.stale });
78
+ if (JSON.stringify(before) !== JSON.stringify(after)) changed = true;
79
+ return next;
36
80
  });
37
- if (links.length) persist(session);
81
+ if (changed) persist(session);
38
82
  return session.githubLinks || [];
39
83
  })();
40
84
  inflight.set(id, pending);
@@ -83,9 +127,12 @@ function attachSessionGithub(ctx) {
83
127
  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
128
  handleMessage: function (ws, msg) {
85
129
  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 () {});
130
+ var ids = Array.isArray(msg.sessionIds) ? msg.sessionIds.slice(0, 100) : [ws._clayActiveSession];
131
+ Array.from(new Set(ids)).forEach(function (id) {
132
+ var session = ctx.sm.sessions.get(id);
133
+ if (!session || !ctx.canRefresh(ws, session) || !(session.githubLinks || []).length) return;
134
+ refresh(session, false, true, function () { return ctx.canRefresh(ws, session); }).catch(function () {});
135
+ });
89
136
  return true;
90
137
  },
91
138
  };
@@ -712,10 +712,10 @@ export function processMessage(msg) {
712
712
  break;
713
713
 
714
714
  case "session_list":
715
- syncGithubSessions(msg.sessions || []);
716
715
  markProjectSessionListHydrated();
717
716
  renderMateSessionList(msg.sessions || []);
718
717
  renderSessionList(msg.sessions || []);
718
+ syncGithubSessions(msg.sessions || []);
719
719
  syncPaneTitles();
720
720
  var panePin = resolvePaneSession(store.get('paneMode'), store.get('panePinPending'), store.get('paneSessionId'), msg.sessions);
721
721
  if (panePin.consumed) {
@@ -40,7 +40,7 @@ export function githubWorkMarkup(session, detailed, expanded, sessionRow) {
40
40
  var state = ['open', 'closed', 'merged', 'draft'].indexOf(link.state) >= 0 ? link.state : 'unknown';
41
41
  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');
42
42
  var description = link.repository + ' · ' + label(link) + ' · ' + link.title + ' · ' + state + (link.checkedAt ? ' · Checked ' + new Date(link.checkedAt).toLocaleString() : '') + (link.stale ? ' (last known status; refresh unavailable)' : '');
43
- 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));
43
+ 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-' + state);
44
44
  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>';
45
45
  if (detailed && link.kind === 'pr') {
46
46
  html += anchor(link.url + '/files', 'View PR changes on GitHub', 'Changes', 'github-work-action');
@@ -56,6 +56,7 @@ export function appendGithubWork(row, session, title) {
56
56
  var html = githubWorkMarkup(session, false, false, true);
57
57
  bindGithubWorkPopover();
58
58
  if (!html) return;
59
+ row.dataset.githubSessionId = session.id;
59
60
  var column = document.createElement('span');
60
61
  column.className = 'session-work-column';
61
62
  row.insertBefore(column, title);
@@ -92,15 +93,22 @@ export function currentGithubWorkMarkup() {
92
93
  export function refreshGithubWork() {
93
94
  if (document.hidden || store.get('dmMode') || !store.get('connected')) return;
94
95
  if (store.get('githubWorkProject') !== store.get('currentSlug')) return;
95
- var session = (store.get('githubWorkSessions') || []).find(function (item) { return item.active; });
96
- if (!safeLinks(session).length) return;
97
- var key = store.get('currentSlug') + ':' + session.id;
96
+ var ids = [];
97
+ document.querySelectorAll('[data-github-session-id]').forEach(function (row) {
98
+ var rect = row.getBoundingClientRect();
99
+ if (rect.width && rect.height && rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth) ids.push(Number(row.dataset.githubSessionId));
100
+ });
101
+ var active = (store.get('githubWorkSessions') || []).find(function (item) { return item.active; });
102
+ if (safeLinks(active).length) ids.unshift(active.id);
103
+ ids = Array.from(new Set(ids)).slice(0, 100);
104
+ if (!ids.length) return;
105
+ var key = store.get('currentSlug') + ':' + ids.join(',');
98
106
  var previous = store.get('githubWorkRefresh') || {};
99
107
  if (previous.key === key && Date.now() - previous.at < 60000) return;
100
108
  var ws = getWs();
101
109
  if (!ws || ws.readyState !== 1) return;
102
110
  store.set({ githubWorkRefresh: { key: key, at: Date.now() } });
103
- ws.send(JSON.stringify({ type: 'session_github_refresh' }));
111
+ ws.send(JSON.stringify({ type: 'session_github_refresh', sessionIds: ids }));
104
112
  }
105
113
 
106
114
  export function resetGithubWork() {
@@ -154,6 +162,8 @@ function bindGithubWorkPopover() {
154
162
  refreshIcons();
155
163
  }, true);
156
164
  window.addEventListener('resize', closeGithubWorkPopover);
165
+ window.addEventListener('focus', refreshGithubWork);
166
+ document.addEventListener('visibilitychange', refreshGithubWork);
157
167
  store.subscribe(function (state, previous) {
158
168
  if (state.currentSlug !== previous.currentSlug || state.activeSessionId !== previous.activeSessionId || state.dmMode !== previous.dmMode) closeGithubWorkPopover();
159
169
  });
@@ -31,16 +31,16 @@ function runGh(cwd, args, identity, input) {
31
31
  child.stdin.end(input || '');
32
32
  });
33
33
  }
34
- async function readReference(cwd, url, identity, run) {
34
+ async function readReference(cwd, url, identity, run, statusOnly) {
35
35
  var ref = parseReference(url);
36
- var fields = "title,state,url" + (ref.kind === "pr" ? ",isDraft,headRefName,reviewDecision,statusCheckRollup" : "");
36
+ var fields = "title,state,url" + (ref.kind === "pr" ? (statusOnly ? ",isDraft" : ",isDraft,headRefName,reviewDecision,statusCheckRollup") : "");
37
37
  var data = await (run || runGh)(cwd, [ref.kind === "pr" ? "pr" : "issue", "view", String(ref.number), "--repo", ref.repository, "--json", fields], identity);
38
38
  var verified = parseReference(data.url);
39
39
  if (verified.url.toLowerCase() !== ref.url.toLowerCase()) throw new Error("GitHub returned a different reference.");
40
40
  ref.title = String(data.title || "").slice(0, 200);
41
41
  ref.state = data.state === "MERGED" ? "merged" : data.state === "CLOSED" ? "closed" : data.state === "OPEN" ? (data.isDraft ? "draft" : "open") : "unknown";
42
42
  ref.checkedAt = Date.now();
43
- if (ref.kind === "pr") {
43
+ if (ref.kind === "pr" && !statusOnly) {
44
44
  ref.branch = String(data.headRefName || "").slice(0, 200);
45
45
  ref.review = ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"].indexOf(data.reviewDecision) >= 0 ? data.reviewDecision : "";
46
46
  ref.checks = (data.statusCheckRollup || []).slice(0, 30).map(function (check) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.6.0",
3
+ "version": "4.6.1-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"