clay-server 4.8.0-beta.2 → 4.8.0-beta.3

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.
@@ -9,7 +9,8 @@ function escapeHtml(value) {
9
9
  }
10
10
 
11
11
  function safeLinks(session) {
12
- return (session && Array.isArray(session.githubLinks) ? session.githubLinks : []).filter(function (link) {
12
+ return (session && Array.isArray(session.githubLinks) ? session.githubLinks : []).concat(session && Array.isArray(session.linearLinks) ? session.linearLinks : []).filter(function (link) {
13
+ if (link.provider === 'linear') return /^https:\/\/linear\.app\/[a-zA-Z0-9_-]+\/issue\/[A-Z][A-Z0-9_]*-[1-9][0-9]*$/.test(link.url);
13
14
  var target = githubLinkTarget(link.url);
14
15
  return target && (target.kind === 'issue' || target.kind === 'pull');
15
16
  });
@@ -27,7 +28,7 @@ export function orderedGithubWork(session) {
27
28
  return links;
28
29
  }
29
30
  function compactGithubWork(links, sessionRow) {
30
- if (!sessionRow) return links.slice(0, 1);
31
+ if (!sessionRow && !links.some(function (link) { return link.provider === 'linear'; })) return links.slice(0, 1);
31
32
  var latestIssue = links.find(function (link) { return link.kind === 'issue'; });
32
33
  var latestPr = links.find(function (link) { return link.kind === 'pr'; });
33
34
  return [latestIssue, latestPr].filter(Boolean);
@@ -37,6 +38,10 @@ export function githubWorkMarkup(session, detailed, expanded, sessionRow) {
37
38
  if (!links.length) return '';
38
39
  var displayed = detailed || expanded ? links : compactGithubWork(links, sessionRow);
39
40
  return '<span class="session-github-links' + (detailed ? ' github-work-details' : '') + (sessionRow ? ' session-row-github-links' : '') + '">' + displayed.map(function (link) {
41
+ if (link.provider === 'linear') {
42
+ var description = link.identifier + ' · ' + link.title + ' · ' + link.stateName + (link.stale ? ' · Last known status' : '');
43
+ return anchor(link.url, description, iconHtml('circle-dot', 'github-work-status') + '<span class="linear-work-id">' + escapeHtml(link.identifier) + '</span><span class="linear-work-title">' + escapeHtml(link.title) + '</span><span class="linear-work-state">' + escapeHtml(link.stateName) + '</span>', 'session-github-link session-linear-link');
44
+ }
40
45
  var state = ['open', 'closed', 'merged', 'draft'].indexOf(link.state) >= 0 ? link.state : 'unknown';
41
46
  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
47
  var description = link.repository + ' · ' + label(link) + ' · ' + link.title + ' · ' + state + (link.checkedAt ? ' · Checked ' + new Date(link.checkedAt).toLocaleString() : '') + (link.stale ? ' (last known status; refresh unavailable)' : '');
@@ -50,7 +55,7 @@ export function githubWorkMarkup(session, detailed, expanded, sessionRow) {
50
55
  });
51
56
  }
52
57
  return html;
53
- }).join('') + (!detailed && !expanded && links.length > displayed.length ? '<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 - displayed.length) + '</button>' : '') + '</span>';
58
+ }).join('') + (!detailed && !expanded && links.length > displayed.length ? '<button type="button" class="github-work-more" aria-label="Show all ' + links.length + ' linked ' + (links.some(function (item) { return item.provider === 'linear'; }) ? 'work items' : 'GitHub items') + '" aria-haspopup="dialog" aria-expanded="false" data-github-links="' + escapeHtml(JSON.stringify(safeLinks(session))) + '">+' + (links.length - displayed.length) + '</button>' : '') + '</span>';
54
59
  }
55
60
  export function appendGithubWork(row, session, title) {
56
61
  var html = githubWorkMarkup(session, false, false, true);
@@ -109,6 +114,8 @@ export function refreshGithubWork() {
109
114
  if (!ws || ws.readyState !== 1) return;
110
115
  store.set({ githubWorkRefresh: { key: key, at: Date.now() } });
111
116
  ws.send(JSON.stringify({ type: 'session_github_refresh', sessionIds: ids }));
117
+ var linearIds = ids.filter(function (id) { return (store.get('githubWorkSessions') || []).some(function (item) { return item.id === id && (item.linearLinks || []).length; }); });
118
+ if (linearIds.length) ws.send(JSON.stringify({ type: 'session_linear_refresh', sessionIds: linearIds }));
112
119
  }
113
120
 
114
121
  export function resetGithubWork() {
@@ -126,6 +133,8 @@ function bindGithubWorkPopover() {
126
133
  if (store.get('githubWorkPopoverBound')) return;
127
134
  store.set({ githubWorkPopoverBound: true });
128
135
  document.addEventListener('click', function (event) {
136
+ var linear = event.target.closest('.session-linear-link');
137
+ if (linear && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) { event.preventDefault(); event.stopPropagation(); closeGithubWorkPopover(); import('./linear-panel.js').then(function (module) { module.openLinear(linear.href); }); return; }
129
138
  var button = event.target.closest('.github-work-more');
130
139
  if (!button) return;
131
140
  event.preventDefault();
@@ -141,8 +150,9 @@ function bindGithubWorkPopover() {
141
150
  popup.className = 'github-work-popover';
142
151
  popup.setAttribute('popover', 'auto');
143
152
  popup.setAttribute('role', 'dialog');
144
- popup.setAttribute('aria-label', 'Linked GitHub work');
145
- popup.innerHTML = '<strong>Linked GitHub work</strong>' + githubWorkMarkup({ githubLinks: links }, false, true);
153
+ var workTitle = links.some(function (link) { return link.provider === 'linear'; }) ? 'Linked work' : 'Linked GitHub work';
154
+ popup.setAttribute('aria-label', workTitle);
155
+ popup.innerHTML = '<strong>' + workTitle + '</strong>' + githubWorkMarkup({ githubLinks: links }, false, true);
146
156
  document.body.appendChild(popup);
147
157
  button.setAttribute('aria-expanded', 'true');
148
158
  popup.addEventListener('toggle', function (toggle) {
@@ -195,16 +195,33 @@ export function initStickyNotes() {
195
195
  export function showNotes(restoreTemporary) {
196
196
  notesVisible = true;
197
197
  if (restoreTemporary !== false) store.set({ notesTemporarilyHidden: false });
198
- var container = document.getElementById("sticky-notes-container");
199
- if (container) container.classList.remove("hidden");
198
+ applyCanvasVisibility();
200
199
  notes.forEach(function (entry) { if (entry && entry.el) syncFlightState(entry.el); });
201
200
  syncFlightControls();
202
201
  }
203
202
 
203
+ // Project Settings owns the screen while open. Suppression is separate from
204
+ // intent: `notesVisible` keeps tracking show/hide requests and updates, and only
205
+ // the canvas element is held hidden, so release shows the latest intended state.
206
+ var notesSuppressed = false;
207
+ function applyCanvasVisibility() {
208
+ var container = document.getElementById("sticky-notes-container");
209
+ if (container) container.classList.toggle("hidden", !notesVisible || notesSuppressed);
210
+ }
211
+ export function holdNotesForSettings() {
212
+ notesSuppressed = true;
213
+ closeColorPicker();
214
+ closeFormatToolbar();
215
+ applyCanvasVisibility();
216
+ }
217
+ export function releaseNotesForSettings() {
218
+ notesSuppressed = false;
219
+ applyCanvasVisibility();
220
+ }
221
+
204
222
  export function hideNotes() {
205
223
  notesVisible = false;
206
- var container = document.getElementById("sticky-notes-container");
207
- if (container) container.classList.add("hidden");
224
+ applyCanvasVisibility();
208
225
  closeColorPicker();
209
226
  notes.forEach(function (entry) { if (entry && entry.el) syncFlightState(entry.el, true); });
210
227
  syncFlightControls();
@@ -290,7 +307,7 @@ export function handleNotesList(msg) {
290
307
  // explicit view state and must survive reconnects and list updates.
291
308
  if (openCount() > 0 && !notesVisible) {
292
309
  notesVisible = true;
293
- container.classList.remove("hidden");
310
+ applyCanvasVisibility();
294
311
  }
295
312
  if (created) reclampAllNotes();
296
313
  syncFlightControls();
@@ -314,7 +331,7 @@ export function handleNoteCreated(msg) {
314
331
  // Show container if hidden, unless the user explicitly flew the notes out.
315
332
  if (!notesVisible && !store.get("notesTemporarilyHidden")) {
316
333
  notesVisible = true;
317
- container.classList.remove("hidden");
334
+ applyCanvasVisibility();
318
335
  }
319
336
  reclampAllNotes();
320
337
  syncFlightControls();
@@ -1,3 +1,4 @@
1
+ import { initLinearSettings, loadLinearConnection, clearLinearKeyInput } from './linear-settings.js';
1
2
  // user-settings.js — Modal dialog for user settings
2
3
  // Account management and logout
3
4
 
@@ -41,6 +42,7 @@ export function initUserSettings(appCtx) {
41
42
  if (!settingsEl || !openBtn) return;
42
43
 
43
44
  initCapsulePreference();
45
+ initLinearSettings();
44
46
 
45
47
  navItems = settingsEl.querySelectorAll('.us-nav-item');
46
48
  sections = settingsEl.querySelectorAll('.us-section');
@@ -297,6 +299,7 @@ export function initUserSettings(appCtx) {
297
299
  // settings tab (e.g. the disabled-Mates picker button passing 'us-mates').
298
300
  // Falls back to Account so existing callers and the icon click are unchanged.
299
301
  export function openUserSettings(initialSection) {
302
+ loadLinearConnection();
300
303
  settingsEl.classList.remove('hidden');
301
304
  openBtn.classList.add('active');
302
305
  refreshIcons(settingsEl);
@@ -306,6 +309,7 @@ export function openUserSettings(initialSection) {
306
309
  }
307
310
 
308
311
  export function closeUserSettings() {
312
+ clearLinearKeyInput();
309
313
  settingsEl.classList.add('hidden');
310
314
  openBtn.classList.remove('active');
311
315
  }
@@ -1,6 +1,7 @@
1
1
  @import url("css/session-github.css");
2
2
  @import url("css/vendor-login-browser.css");
3
3
  @import url("css/issues.css");
4
+ @import url("css/linear.css");
4
5
  @import url("css/base.css?v=20260828-type1");
5
6
  @import url("css/icon-strip.css?v=20260828-surfaces4");
6
7
  @import url("css/title-bar.css?v=20260828-project-header1");
@@ -45,6 +46,7 @@
45
46
  @import url("css/stt.css");
46
47
  @import url("css/profile.css");
47
48
  @import url("css/user-settings.css?v=20260828-type1");
49
+ @import url("css/settings-ui.css");
48
50
  @import url("css/admin.css");
49
51
  @import url("css/session-search.css");
50
52
  @import url("css/tooltip.css");
package/lib/sdk-bridge.js CHANGED
@@ -553,6 +553,9 @@ function createSDKBridge(opts) {
553
553
  return toolName === name || toolName === "mcp__clay-github__" + name;
554
554
  })) return { behavior: "allow", updatedInput: input };
555
555
 
556
+ if (["link_session_linear", "get_session_linear", "unlink_session_linear", "read_linear_issue", "search_linear_issues"].some(function (name) {
557
+ return toolName === name || toolName === "mcp__clay-linear__" + name;
558
+ })) return { behavior: "allow", updatedInput: input };
556
559
  // Shared-browser handlers enforce session access and live control ownership.
557
560
  // All actions use the same tool; do not duplicate its in-app handoff UI.
558
561
  if (toolName === "shared_browser" || toolName === "mcp__clay-shared-browser__shared_browser") {
@@ -1109,6 +1112,7 @@ function createSDKBridge(opts) {
1109
1112
  if (codexCatalogCandidate.userInputVersion) session.codexUserInputToolCatalogVersion = codexCatalogCandidate.userInputVersion;
1110
1113
  if (codexCatalogCandidate.sharedBrowserVersion) session.codexSharedBrowserToolCatalogVersion = codexCatalogCandidate.sharedBrowserVersion;
1111
1114
  if (codexCatalogCandidate.issuesVersion) session.codexIssuesToolCatalogVersion = codexCatalogCandidate.issuesVersion;
1115
+ if (codexCatalogCandidate.linearVersion) session.codexLinearToolCatalogVersion = codexCatalogCandidate.linearVersion;
1112
1116
  sm.saveSessionFile(session);
1113
1117
  codexCatalogCandidate = null;
1114
1118
  }
@@ -1790,6 +1794,9 @@ function createSDKBridge(opts) {
1790
1794
  // delivered by this provider.
1791
1795
  var sessionToolDefs = getSessionToolDefs(session) || [];
1792
1796
  var queryGeneration = Number(session._sdkQueryGeneration || 0);
1797
+ var hasLinearCatalog = sessionToolDefs.some(function (tool) { return tool.name === "link_session_linear"; });
1798
+ var recoverCodexLinearTools = sessionAdapter.vendor === "codex" && !!session.cliSessionId
1799
+ && hasLinearCatalog && Number(session.codexLinearToolCatalogVersion || 0) < 1 && canUseSessionTools(session) === true;
1793
1800
  var hasIssueCatalog = false;
1794
1801
  for (var issueToolIndex = 0; issueToolIndex < sessionToolDefs.length; issueToolIndex++) {
1795
1802
  if (sessionToolDefs[issueToolIndex].name === "create_issue") {
@@ -1973,7 +1980,7 @@ function createSDKBridge(opts) {
1973
1980
  dynamicTools: sessionToolDefs,
1974
1981
  // Codex persists dynamic tools with a thread. A stale authorized Driver
1975
1982
  // gets this exact-query bridge; its handlers still reauthorize live.
1976
- sessionMcpServer: (recoverCodexIssueTools || recoverCodexQuestionTools || recoverCodexBrowserTools) ? getCodexSessionMcpServer(session, queryGeneration) : undefined,
1983
+ sessionMcpServer: (recoverCodexIssueTools || recoverCodexQuestionTools || recoverCodexBrowserTools || recoverCodexLinearTools) ? getCodexSessionMcpServer(session, queryGeneration) : undefined,
1977
1984
  callDynamicTool: function (toolName, args) {
1978
1985
  var toolDef = findSessionToolDef(toolName);
1979
1986
  if (toolDef && typeof toolDef.handler === "function") {
@@ -2192,6 +2199,7 @@ function createSDKBridge(opts) {
2192
2199
  if (requiredCatalogTools.ask_user_questions) {
2193
2200
  codexCatalogCandidate = Object.assign({}, codexCatalogCandidate || {}, { generation: queryGeneration, userInputVersion: 1 });
2194
2201
  }
2202
+ if (hasLinearCatalog) codexCatalogCandidate = Object.assign({}, codexCatalogCandidate || {}, { generation: queryGeneration, linearVersion: 1 });
2195
2203
  if (hasSharedBrowserCatalog) {
2196
2204
  codexCatalogCandidate = Object.assign({}, codexCatalogCandidate || {}, { generation: queryGeneration, sharedBrowserVersion: 3 });
2197
2205
  }
@@ -0,0 +1,50 @@
1
+ var attachStore = require('./linear-store').attachLinearStore;
2
+ var api = require('./linear-api');
3
+ function attachLinear(ctx) {
4
+ var storage = attachStore(ctx.CONFIG_DIR), pending = new Map();
5
+ function owner(req) {
6
+ if (!ctx.isRequestAuthed(req)) return null;
7
+ if (!ctx.users.isMultiUser()) return 'default';
8
+ var user = ctx.getMultiUserFromReq(req);
9
+ return user && user.id;
10
+ }
11
+ function json(res, status, body) { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); }
12
+ function handleRequest(req, res, url) {
13
+ if (url !== '/api/linear/connection') return false;
14
+ var id = owner(req);
15
+ if (!id) { json(res, 401, { error: 'Sign in to connect Linear.' }); return true; }
16
+ var origin = req.headers.origin;
17
+ try { if (req.headers['sec-fetch-site'] === 'cross-site' || (origin && new URL(origin).host !== req.headers.host)) throw new Error(); }
18
+ catch (error) { json(res, 403, { error: 'Invalid request origin.' }); return true; }
19
+ if (req.method === 'GET') {
20
+ try { json(res, 200, storage.view(id)); } catch (error) { json(res, 500, { error: 'Could not read Linear connection.' }); }
21
+ return true;
22
+ }
23
+ if (req.method !== 'PUT' && req.method !== 'DELETE') { json(res, 405, { error: 'Method not allowed.' }); return true; }
24
+ // A disconnect or newer save invalidates an older validation in flight.
25
+ var operation = {}; pending.set(id, operation);
26
+ var chunks = [], size = 0;
27
+ req.on('data', function (chunk) { size += chunk.length; if (size <= 8192) chunks.push(chunk); });
28
+ req.on('end', async function () {
29
+ try {
30
+ if (size > 8192) throw new Error('Request is too large.');
31
+ var token = null, workspace = null;
32
+ if (req.method === 'PUT') {
33
+ var body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
34
+ if (typeof body.key !== 'string' || !body.key.trim() || body.key.length > 4096 || /[\r\n]/.test(body.key)) throw new Error('Enter a valid Linear API key.');
35
+ token = body.key.trim();
36
+ var data = await (ctx.query || api.query)(token, 'query ClayConnection { organization { id name urlKey } }');
37
+ if (!data.organization || !data.organization.id) throw new Error('Linear workspace is unavailable.');
38
+ workspace = { id: data.organization.id, name: data.organization.name, slug: data.organization.urlKey };
39
+ }
40
+ if (pending.get(id) !== operation || owner(req) !== id || res.destroyed) throw new Error('Connection changed. Please try again.');
41
+ json(res, 200, storage.save(id, token, workspace));
42
+ } catch (error) { if (!res.destroyed) json(res, 400, { error: 'Could not update Linear connection. Check your API key and try again.' }); }
43
+ finally { if (pending.get(id) === operation) pending.delete(id); }
44
+ });
45
+ req.on('error', function () { if (pending.get(id) === operation) pending.delete(id); });
46
+ return true;
47
+ }
48
+ return { handleRequest: handleRequest, storage: storage };
49
+ }
50
+ module.exports = { attachLinear: attachLinear };
package/lib/server.js CHANGED
@@ -443,6 +443,9 @@ function createServer(opts) {
443
443
  getMultiUserFromReq: getMultiUserFromReq,
444
444
  });
445
445
 
446
+ var linear = require('./server-linear').attachLinear({ users: users, getMultiUserFromReq: getMultiUserFromReq,
447
+ isRequestAuthed: isRequestAuthed, CONFIG_DIR: CONFIG_DIR });
448
+
446
449
  var speech = serverSpeech.attachSpeech({ users: users, getMultiUserFromReq: getMultiUserFromReq,
447
450
  isRequestAuthed: isRequestAuthed, CONFIG_DIR: CONFIG_DIR, getAllowedOrigins: getAllowedOrigins });
448
451
 
@@ -798,6 +801,7 @@ function createServer(opts) {
798
801
  return;
799
802
  }
800
803
 
804
+ if (linear.handleRequest(req, res, fullUrl)) return;
801
805
  if (speech.handleRequest(req, res, fullUrl)) return;
802
806
  if (networkSettings.handleRequest(req, res, fullUrl)) return;
803
807
  if (settings.handleRequest(req, res, fullUrl)) return;
@@ -1352,6 +1356,7 @@ function createServer(opts) {
1352
1356
  workspaceQueryService: workspaceQueryService,
1353
1357
  projectLogsService: projectLogsService,
1354
1358
  issuesService: issuesService,
1359
+ linearStorage: linear.storage,
1355
1360
  mateKnowledgeService: mateKnowledgeService,
1356
1361
  durableScheduler: durableScheduler,
1357
1362
  canAccessProjectSlug: function (userId, targetSlug) {
package/lib/sessions.js CHANGED
@@ -177,10 +177,12 @@ function createSessionManager(opts) {
177
177
  createdAt: session.createdAt,
178
178
  };
179
179
  if (session.githubLinks) metaObj.githubLinks = session.githubLinks;
180
+ if (session.linearLinks) metaObj.linearLinks = session.linearLinks;
180
181
  if (session.ownerId) metaObj.ownerId = session.ownerId;
181
182
  if (session.vendor) metaObj.vendor = session.vendor;
182
183
  if (session.codexDynamicToolCatalogVersion) metaObj.codexDynamicToolCatalogVersion = session.codexDynamicToolCatalogVersion;
183
184
  if (session.codexUserInputToolCatalogVersion) metaObj.codexUserInputToolCatalogVersion = session.codexUserInputToolCatalogVersion;
185
+ if (session.codexLinearToolCatalogVersion) metaObj.codexLinearToolCatalogVersion = session.codexLinearToolCatalogVersion;
184
186
  if (session.codexSharedBrowserToolCatalogVersion) metaObj.codexSharedBrowserToolCatalogVersion = session.codexSharedBrowserToolCatalogVersion;
185
187
  if (session.codexIssuesToolCatalogVersion) metaObj.codexIssuesToolCatalogVersion = session.codexIssuesToolCatalogVersion;
186
188
  if (session.model) metaObj.model = session.model;
@@ -347,6 +349,7 @@ function createSessionManager(opts) {
347
349
  titleProvisional: m.titleProvisional === true,
348
350
  titleAutoGenerated: m.titleAutoGenerated === true,
349
351
  githubLinks: Array.isArray(m.githubLinks) ? m.githubLinks.slice(0, 8) : [],
352
+ linearLinks: Array.isArray(m.linearLinks) ? m.linearLinks.slice(0, 8) : [],
350
353
  titleManuallySet: m.titleManuallySet === true || legacyTitleProtected,
351
354
  createdAt: m.createdAt || Date.now(),
352
355
  lastActivity: loaded[i].mtime || m.createdAt || Date.now(),
@@ -357,6 +360,7 @@ function createSessionManager(opts) {
357
360
  if (m.vendor) session.vendor = m.vendor;
358
361
  session.codexDynamicToolCatalogVersion = Number(m.codexDynamicToolCatalogVersion || 0);
359
362
  session.codexUserInputToolCatalogVersion = Number(m.codexUserInputToolCatalogVersion || 0);
363
+ session.codexLinearToolCatalogVersion = Number(m.codexLinearToolCatalogVersion || 0);
360
364
  session.codexSharedBrowserToolCatalogVersion = Number(m.codexSharedBrowserToolCatalogVersion || 0);
361
365
  session.codexIssuesToolCatalogVersion = Number(m.codexIssuesToolCatalogVersion || 0);
362
366
  session.model = m.model || deriveSessionModel(loaded[i].history);
@@ -611,6 +615,7 @@ function createSessionManager(opts) {
611
615
  cliSessionId: s.cliSessionId || null,
612
616
  title: s.title || "New Session",
613
617
  githubLinks: s.githubLinks || [],
618
+ linearLinks: s.linearLinks || [],
614
619
  active: isActive,
615
620
  isProcessing: s.isProcessing,
616
621
  // 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.8.0-beta.2",
3
+ "version": "4.8.0-beta.3",
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"