clay-server 3.8.1 → 4.0.0-beta.10

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 (222) hide show
  1. package/lib/ask-user-mcp-server.js +5 -119
  2. package/lib/background-task-timing.js +105 -0
  3. package/lib/builtin-mates.js +6 -6
  4. package/lib/claude-hook-installer.js +15 -0
  5. package/lib/clay-history-mcp-server.js +62 -297
  6. package/lib/daemon.js +23 -1
  7. package/lib/debate-model-selection.js +119 -0
  8. package/lib/home-debate-tool-policy.js +164 -0
  9. package/lib/home-response-notifications.js +26 -0
  10. package/lib/knowledge-import.js +436 -0
  11. package/lib/knowledge-record-store.js +188 -0
  12. package/lib/knowledge-search.js +224 -0
  13. package/lib/mate-creation-mcp-server.js +55 -0
  14. package/lib/mate-knowledge-mcp-server.js +151 -0
  15. package/lib/mate-knowledge-migration.js +283 -0
  16. package/lib/mate-knowledge-service.js +488 -0
  17. package/lib/mate-knowledge-sync.js +482 -0
  18. package/lib/mate-ready-creation.js +95 -0
  19. package/lib/mates.js +2 -0
  20. package/lib/notes.js +2 -1
  21. package/lib/project-ask-user.js +64 -0
  22. package/lib/project-capsule-catalog.js +101 -0
  23. package/lib/project-connection.js +17 -0
  24. package/lib/project-debate-proposal.js +133 -17
  25. package/lib/project-debate.js +214 -110
  26. package/lib/project-delegated-follow-up.js +84 -0
  27. package/lib/project-delegated-session.js +53 -0
  28. package/lib/project-filesystem.js +7 -9
  29. package/lib/project-knowledge.js +23 -0
  30. package/lib/project-logs-comments.js +137 -0
  31. package/lib/project-logs-mcp-server.js +290 -0
  32. package/lib/project-logs-query.js +167 -0
  33. package/lib/project-logs-root.js +67 -0
  34. package/lib/project-logs-schema.js +259 -0
  35. package/lib/project-logs-service.js +357 -0
  36. package/lib/project-logs-snapshot.js +202 -0
  37. package/lib/project-logs-store.js +498 -0
  38. package/lib/project-logs-versioning.js +128 -0
  39. package/lib/project-logs.js +347 -0
  40. package/lib/project-mate-creation-proposal.js +120 -0
  41. package/lib/project-mate-interaction.js +14 -0
  42. package/lib/project-mate-knowledge.js +110 -0
  43. package/lib/project-memory.js +36 -7
  44. package/lib/project-models.js +157 -32
  45. package/lib/project-session-notes.js +2 -2
  46. package/lib/project-session-pair.js +8 -8
  47. package/lib/project-sessions.js +66 -51
  48. package/lib/project-vendor-login.js +414 -0
  49. package/lib/project-worker-proposal.js +25 -25
  50. package/lib/project-workspace-query.js +144 -0
  51. package/lib/project.js +433 -102
  52. package/lib/public/app.js +109 -48
  53. package/lib/public/css/avatar-imprints.css +0 -1
  54. package/lib/public/css/base.css +4 -0
  55. package/lib/public/css/capsule-ui.css +389 -0
  56. package/lib/public/css/command-palette.css +306 -6
  57. package/lib/public/css/home-capsule-library.css +243 -0
  58. package/lib/public/css/home-capsule-source.css +192 -0
  59. package/lib/public/css/home-debate-live.css +146 -0
  60. package/lib/public/css/home-debate-planning.css +125 -0
  61. package/lib/public/css/home-debates-archive.css +281 -0
  62. package/lib/public/css/home-hub.css +896 -405
  63. package/lib/public/css/home-mate-creation.css +34 -0
  64. package/lib/public/css/home-mate-model-picker.css +131 -0
  65. package/lib/public/css/home-mate-settings.css +344 -0
  66. package/lib/public/css/home-session-actions.css +265 -0
  67. package/lib/public/css/home-sidebar.css +667 -0
  68. package/lib/public/css/icon-strip.css +1 -2
  69. package/lib/public/css/input.css +79 -25
  70. package/lib/public/css/mates.css +7 -34
  71. package/lib/public/css/pane.css +1 -1
  72. package/lib/public/css/playbook.css +0 -80
  73. package/lib/public/css/project-logs.css +234 -0
  74. package/lib/public/css/scheduler.css +1 -1
  75. package/lib/public/css/sticky-notes.css +53 -117
  76. package/lib/public/css/tui-attention.css +0 -44
  77. package/lib/public/css/workspace-assignment.css +45 -0
  78. package/lib/public/fonts/source-serif-4/LICENSE.md +93 -0
  79. package/lib/public/fonts/source-serif-4/SourceSerif4Caption-Semibold.ttf.woff2 +0 -0
  80. package/lib/public/index.html +96 -74
  81. package/lib/public/modules/app-connection.js +100 -5
  82. package/lib/public/modules/app-dm.js +43 -160
  83. package/lib/public/modules/app-home-hub.js +328 -585
  84. package/lib/public/modules/app-message-cards.js +242 -0
  85. package/lib/public/modules/app-message-router.js +84 -0
  86. package/lib/public/modules/app-messages.js +124 -38
  87. package/lib/public/modules/app-notifications.js +8 -119
  88. package/lib/public/modules/app-panels.js +43 -2
  89. package/lib/public/modules/app-projects.js +9 -1
  90. package/lib/public/modules/app-rendering.js +21 -602
  91. package/lib/public/modules/avatar-imprint.js +11 -8
  92. package/lib/public/modules/background-tasks-ui.js +139 -16
  93. package/lib/public/modules/chat-bubble-renderer.js +152 -0
  94. package/lib/public/modules/chat-render-runtime.js +198 -0
  95. package/lib/public/modules/command-palette.js +225 -436
  96. package/lib/public/modules/dm-render.js +82 -0
  97. package/lib/public/modules/home-capsule-creation-intent.js +31 -0
  98. package/lib/public/modules/home-capsule-library.js +146 -0
  99. package/lib/public/modules/home-capsule-source.js +383 -0
  100. package/lib/public/modules/home-chat-identity.js +35 -0
  101. package/lib/public/modules/home-chat-scroll.js +77 -0
  102. package/lib/public/modules/home-chat-stream-state.js +34 -0
  103. package/lib/public/modules/home-composer-focus.js +25 -0
  104. package/lib/public/modules/home-conversations-sheet.js +179 -0
  105. package/lib/public/modules/home-debate-activity.js +48 -0
  106. package/lib/public/modules/home-debate-controls.js +295 -0
  107. package/lib/public/modules/home-debate-launch.js +41 -0
  108. package/lib/public/modules/home-debate-live.js +284 -0
  109. package/lib/public/modules/home-debate-models.js +73 -0
  110. package/lib/public/modules/home-debate-planning.js +335 -0
  111. package/lib/public/modules/home-debates-archive.js +279 -0
  112. package/lib/public/modules/home-dock-resize.js +74 -0
  113. package/lib/public/modules/home-dock.js +446 -0
  114. package/lib/public/modules/home-mate-chat.js +496 -0
  115. package/lib/public/modules/home-mate-creation.js +109 -0
  116. package/lib/public/modules/home-mate-model-picker.js +281 -0
  117. package/lib/public/modules/home-mate-selection.js +38 -0
  118. package/lib/public/modules/home-mate-settings-menu.js +131 -0
  119. package/lib/public/modules/home-mate-settings.js +383 -0
  120. package/lib/public/modules/home-session-actions.js +373 -0
  121. package/lib/public/modules/home-session-model-confirmation.js +13 -0
  122. package/lib/public/modules/home-shell.js +9 -0
  123. package/lib/public/modules/home-sidebar-chat-list.js +63 -0
  124. package/lib/public/modules/home-sidebar.js +165 -0
  125. package/lib/public/modules/home-sub-surface.js +19 -0
  126. package/lib/public/modules/home-surface-boot.js +68 -0
  127. package/lib/public/modules/home-surface.js +141 -0
  128. package/lib/public/modules/home-tools.js +271 -0
  129. package/lib/public/modules/markdown.js +50 -2
  130. package/lib/public/modules/mate-management.js +86 -0
  131. package/lib/public/modules/mate-sidebar.js +0 -8
  132. package/lib/public/modules/mate-wizard.js +0 -7
  133. package/lib/public/modules/mention.js +17 -19
  134. package/lib/public/modules/pane-bridge.js +9 -0
  135. package/lib/public/modules/profile.js +4 -5
  136. package/lib/public/modules/project-logs-ambient.js +238 -0
  137. package/lib/public/modules/project-logs-render.js +359 -0
  138. package/lib/public/modules/project-logs.js +328 -0
  139. package/lib/public/modules/project-settings.js +34 -28
  140. package/lib/public/modules/project-switcher.js +11 -5
  141. package/lib/public/modules/project-workspace-assignment.js +23 -0
  142. package/lib/public/modules/scheduler.js +17 -3
  143. package/lib/public/modules/search-clay-chat.js +402 -0
  144. package/lib/public/modules/server-settings.js +14 -12
  145. package/lib/public/modules/session-actions.js +1 -1
  146. package/lib/public/modules/sidebar-mates.js +56 -552
  147. package/lib/public/modules/sidebar-mobile.js +12 -73
  148. package/lib/public/modules/sidebar-presence.js +37 -0
  149. package/lib/public/modules/sidebar-sessions.js +25 -1
  150. package/lib/public/modules/split-pair-ui.js +13 -13
  151. package/lib/public/modules/split-view.js +7 -0
  152. package/lib/public/modules/sticky-notes.js +106 -53
  153. package/lib/public/modules/tool-input-composition.js +48 -0
  154. package/lib/public/modules/tool-llm-status.js +167 -0
  155. package/lib/public/modules/tool-palette.js +76 -4
  156. package/lib/public/modules/tool-renderer-advanced.js +285 -0
  157. package/lib/public/modules/tool-renderer-chart.js +158 -0
  158. package/lib/public/modules/tool-renderer-semantics.js +35 -0
  159. package/lib/public/modules/tool-renderer.js +464 -0
  160. package/lib/public/modules/tool-runtime.js +309 -0
  161. package/lib/public/modules/tool-ui-evaluator.js +97 -0
  162. package/lib/public/modules/tool-ui-tree.js +21 -0
  163. package/lib/public/modules/tui-attention.js +10 -0
  164. package/lib/public/modules/user-settings.js +16 -23
  165. package/lib/public/modules/vendor-login.js +287 -0
  166. package/lib/public/modules/worker-proposal.js +2 -2
  167. package/lib/public/modules/workspace-assignment-card.js +149 -0
  168. package/lib/public/modules/workspace-assignment-routing.js +4 -0
  169. package/lib/public/style.css +14 -2
  170. package/lib/runtime-env.js +65 -0
  171. package/lib/sdk-bridge.js +120 -32
  172. package/lib/sdk-message-processor.js +34 -13
  173. package/lib/server-home-capsule-creation.js +36 -0
  174. package/lib/server-home-chat-events.js +287 -0
  175. package/lib/server-home-chat.js +518 -0
  176. package/lib/server-home-clay-entry.js +65 -0
  177. package/lib/server-home-clay-session-links.js +26 -0
  178. package/lib/server-home-debate-planning.js +214 -0
  179. package/lib/server-home-debates.js +137 -0
  180. package/lib/server-home-mate-creation.js +261 -0
  181. package/lib/server-home-models.js +241 -0
  182. package/lib/server-home-preferences.js +61 -0
  183. package/lib/server-mates.js +37 -0
  184. package/lib/server-tools.js +377 -0
  185. package/lib/server.js +166 -59
  186. package/lib/session-pair-mcp-server.js +6 -6
  187. package/lib/sessions.js +22 -4
  188. package/lib/tool-capsule-source.js +142 -0
  189. package/lib/tool-control-mcp-server.js +126 -0
  190. package/lib/tool-llm.js +48 -0
  191. package/lib/tool-storage.js +77 -0
  192. package/lib/tool-ui-spec-advanced.js +82 -0
  193. package/lib/tool-ui-spec.js +261 -0
  194. package/lib/tools-registry.js +286 -0
  195. package/lib/users-home-dock-preferences.js +77 -0
  196. package/lib/users-home-surface-preferences.js +99 -0
  197. package/lib/users-preferences.js +4 -7
  198. package/lib/users.js +12 -0
  199. package/lib/whats-new-content.js +3 -3
  200. package/lib/workspace-assignment-service.js +420 -0
  201. package/lib/workspace-query-mcp-server.js +154 -0
  202. package/lib/workspace-query-service.js +492 -0
  203. package/lib/ws-schema.js +107 -12
  204. package/lib/yoke/adapters/acp.js +2 -1
  205. package/lib/yoke/adapters/antigravity.js +2 -1
  206. package/lib/yoke/adapters/claude-worker.js +11 -4
  207. package/lib/yoke/adapters/claude.js +141 -46
  208. package/lib/yoke/adapters/codex.js +79 -11
  209. package/lib/yoke/adapters/kiro.js +2 -1
  210. package/lib/yoke/claude-user-input.js +38 -0
  211. package/lib/yoke/codex-app-server.js +20 -2
  212. package/lib/yoke/codex-background-tasks.js +8 -2
  213. package/lib/yoke/codex-user-input.js +64 -0
  214. package/lib/yoke/complete-once.js +164 -0
  215. package/lib/yoke/index.js +5 -1
  216. package/lib/yoke/interface.js +19 -0
  217. package/lib/yoke/mcp-bridge-server.js +2 -6
  218. package/lib/yoke/user-input.js +254 -0
  219. package/package.json +4 -3
  220. package/lib/public/css/home-chat.css +0 -380
  221. package/lib/public/modules/home-chat.js +0 -438
  222. package/lib/server-clay-home.js +0 -245
@@ -75,6 +75,7 @@ function attachSessions(ctx) {
75
75
  var sendToSessionOthers = ctx.sendToSessionOthers;
76
76
  var opts = ctx.opts;
77
77
  var usersModule = ctx.usersModule;
78
+ var getProjectAccess = ctx.getProjectAccess || function () { return { visibility: "public" }; };
78
79
  var userPresence = ctx.userPresence;
79
80
  var pushModule = ctx.pushModule;
80
81
  var getSessionForWs = ctx.getSessionForWs;
@@ -85,6 +86,60 @@ function attachSessions(ctx) {
85
86
  var onProcessingChanged = ctx.onProcessingChanged;
86
87
  var broadcastPresence = ctx.broadcastPresence;
87
88
  var adapter = ctx.adapter;
89
+
90
+ function ownsSearchSession(ws, session) {
91
+ if (!session) return false;
92
+ if (usersModule && typeof usersModule.isMultiUser === "function" && usersModule.isMultiUser()) {
93
+ return !!(ws && ws._clayUser && usersModule.canAccessSession(ws._clayUser.id, session, getProjectAccess()));
94
+ }
95
+ return !session.ownerId;
96
+ }
97
+
98
+ function respondToAskUser(session, msg) {
99
+ if (!session || !msg || !msg.toolId) return false;
100
+ var toolId = msg.toolId;
101
+ var answers = msg.answers || {};
102
+ var pending = session.pendingAskUser && session.pendingAskUser[toolId];
103
+ if (!pending) return false;
104
+ if (pending.mode === "yoke") {
105
+ try { yoke.userInput.normalizeAnswers(pending.request.questions, answers); }
106
+ catch (e) { return false; }
107
+ }
108
+ delete session.pendingAskUser[toolId];
109
+ sm.sendAndRecord(session, { type: "ask_user_answered", toolId: toolId, answers: answers });
110
+ if (pending.mode === "yoke" && typeof pending.respond === "function") {
111
+ pending.respond(answers);
112
+ } else if (pending.mode === "mcp") {
113
+ var answerText = formatAskUserAnswerAsMessage(pending.input, answers);
114
+ var userMsg = { type: "user_message", text: answerText, askUserAnswer: true };
115
+ session.history.push(userMsg);
116
+ sm.appendToSessionFile(session, userMsg);
117
+ sendToSession(session.localId, userMsg);
118
+ if (!session.isProcessing) {
119
+ session.isProcessing = true;
120
+ onProcessingChanged();
121
+ session.sentToolResults = {};
122
+ sendToSession(session.localId, { type: "status", status: "processing" });
123
+ if (!session.queryInstance && !session.worker) sdk.startQuery(session, answerText, undefined, ensureProjectAccessForSession(session));
124
+ else sdk.pushMessage(session, answerText);
125
+ } else {
126
+ sdk.pushMessage(session, answerText);
127
+ }
128
+ } else {
129
+ pending.resolve({ behavior: "deny", message: formatAskUserAnswerAsMessage(pending.input, answers) });
130
+ }
131
+ return true;
132
+ }
133
+
134
+ function cancelPendingAskUser(session, reason) {
135
+ var pendingMap = session && session.pendingAskUser ? session.pendingAskUser : {};
136
+ Object.keys(pendingMap).forEach(function (toolId) {
137
+ var pending = pendingMap[toolId];
138
+ if (pending && pending.respond && typeof pending.respond.cancel === "function") pending.respond.cancel(reason || "Cancelled");
139
+ else if (pending && typeof pending.resolve === "function") pending.resolve({ behavior: "deny", message: reason || "Cancelled" });
140
+ });
141
+ if (session) session.pendingAskUser = {};
142
+ }
88
143
  var getProjectList = ctx.getProjectList;
89
144
  var getProjectCount = ctx.getProjectCount;
90
145
  var getScheduleCount = ctx.getScheduleCount;
@@ -821,14 +876,19 @@ function attachSessions(ctx) {
821
876
  }
822
877
 
823
878
  if (msg.type === "search_sessions") {
824
- var results = sm.searchSessions(msg.query || "");
879
+ var results = sm.searchSessions(msg.query || "", function (session) {
880
+ return ownsSearchSession(ws, session);
881
+ });
825
882
  sendTo(ws, { type: "search_results", query: msg.query || "", results: results });
826
883
  return true;
827
884
  }
828
885
 
829
886
  if (msg.type === "search_session_content") {
830
887
  var targetSession = msg.id ? sm.sessions.get(msg.id) : getSessionForWs(ws);
831
- if (!targetSession) return true;
888
+ if (!ownsSearchSession(ws, targetSession)) {
889
+ sendTo(ws, { type: "search_content_results", query: msg.query || "", sessionId: msg.id || null, hits: [], total: 0 });
890
+ return true;
891
+ }
832
892
  var contentResults = sm.searchSessionContent(targetSession.localId, msg.query || "");
833
893
  var searchResp = { type: "search_content_results", query: msg.query || "", sessionId: targetSession.localId, hits: contentResults.hits, total: contentResults.total };
834
894
  if (msg.source) searchResp.source = msg.source;
@@ -1256,7 +1316,7 @@ function attachSessions(ctx) {
1256
1316
  session.blocks = {};
1257
1317
  session.sentToolResults = {};
1258
1318
  session.pendingPermissions = {};
1259
- session.pendingAskUser = {};
1319
+ cancelPendingAskUser(session, "Conversation rewound");
1260
1320
  session.isProcessing = false;
1261
1321
  onProcessingChanged();
1262
1322
 
@@ -1328,53 +1388,7 @@ function attachSessions(ctx) {
1328
1388
 
1329
1389
  if (msg.type === "ask_user_response") {
1330
1390
  var session = getSessionForWs(ws);
1331
- if (!session) return true;
1332
- var toolId = msg.toolId;
1333
- var answers = msg.answers || {};
1334
- var pending = session.pendingAskUser[toolId];
1335
- if (!pending) return true;
1336
- delete session.pendingAskUser[toolId];
1337
- sm.sendAndRecord(session, { type: "ask_user_answered", toolId: toolId, answers: answers });
1338
-
1339
- if (pending.mode === "mcp") {
1340
- // Stateless MCP path: the tool already returned. Inject the user's
1341
- // answer as a new user message so the conversation continues
1342
- // naturally on the next turn. This matches how the mate would see
1343
- // any other user input.
1344
- var answerText = formatAskUserAnswerAsMessage(pending.input, answers);
1345
- var userMsg = { type: "user_message", text: answerText };
1346
- session.history.push(userMsg);
1347
- sm.appendToSessionFile(session, userMsg);
1348
- sendToSession(session.localId, userMsg);
1349
-
1350
- if (!session.isProcessing) {
1351
- session.isProcessing = true;
1352
- onProcessingChanged();
1353
- session.sentToolResults = {};
1354
- sendToSession(session.localId, { type: "status", status: "processing" });
1355
- if (!session.queryInstance && !session.worker) {
1356
- sdk.startQuery(session, answerText, undefined, ensureProjectAccessForSession(session));
1357
- } else {
1358
- sdk.pushMessage(session, answerText);
1359
- }
1360
- } else {
1361
- // Turn is still running; queue for the next turn.
1362
- sdk.pushMessage(session, answerText);
1363
- }
1364
- } else {
1365
- // Claude native AskUserQuestion path (built-in tool, intercepted via
1366
- // canUseTool). In headless SDK mode the built-in can't render its own
1367
- // answer UI: resolving "allow" lets it return an EMPTY result, so the
1368
- // model continues the same turn with no answer ("I don't see an
1369
- // answer"). Injected user messages also lose the race against that
1370
- // empty result. Instead, deliver the answer as the tool_result by
1371
- // denying with the formatted answer text — the SDK surfaces a deny
1372
- // `message` to the model as the tool result, so the model receives the
1373
- // user's choice in the same turn. (updatedInput.answers is not read by
1374
- // the 0.3.x built-in, which is what caused answers to be dropped.)
1375
- var nativeAnswerText = formatAskUserAnswerAsMessage(pending.input, answers);
1376
- pending.resolve({ behavior: "deny", message: nativeAnswerText });
1377
- }
1391
+ if (session) respondToAskUser(session, msg);
1378
1392
  return true;
1379
1393
  }
1380
1394
 
@@ -1443,7 +1457,7 @@ function attachSessions(ctx) {
1443
1457
  session.isProcessing = false;
1444
1458
  onProcessingChanged();
1445
1459
  session.pendingPermissions = {};
1446
- session.pendingAskUser = {};
1460
+ cancelPendingAskUser(session, "Conversation restarted");
1447
1461
  sm.broadcastSessionList();
1448
1462
  setImmediate(function () {
1449
1463
  if (session.abortController) {
@@ -2011,6 +2025,7 @@ function attachSessions(ctx) {
2011
2025
  return {
2012
2026
  handleSessionsMessage: handleSessionsMessage,
2013
2027
  resolveSessionForView: resolveSessionForView,
2028
+ respondToAskUser: respondToAskUser,
2014
2029
  };
2015
2030
  }
2016
2031
 
@@ -0,0 +1,414 @@
1
+ // Vendor login flow coordinator.
2
+ //
3
+ // Owns the "the vendor CLI is not logged in" recovery path end to end so the
4
+ // client never has to guess. One login terminal per vendor per project is the
5
+ // single source of truth: repeated auth_required events (other sessions, split
6
+ // panes, a burst of 401 retries) re-use that record instead of spawning more
7
+ // terminals.
8
+ //
9
+ // Lifecycle:
10
+ // vendor_login_start -> create (or re-use) a PTY running the vendor login
11
+ // command, reply with vendor_login_ready
12
+ // PTY output/exit -> detect a successful login, restart the vendor's
13
+ // YOKE adapter so it re-reads the credential file,
14
+ // broadcast auth_refreshed, close the terminal
15
+ // vendor_login_cancel -> user dismissed the flow; kill the terminal
16
+ //
17
+ // The adapter restart is the actual bug fix: a Codex app-server is a long-lived
18
+ // child process that reads ~/.codex/auth.json once at spawn, so without a
19
+ // restart every query after a successful login keeps hitting 401 and re-arms
20
+ // auth_required forever.
21
+
22
+ var vendorRegistry = require("./yoke/vendor-registry");
23
+ var yoke = require("./yoke");
24
+
25
+ // Login CLIs print a success line and then hand the shell back, so the PTY
26
+ // itself does not exit. Watch the output stream instead. Deliberately narrow:
27
+ // "Successfully logged out" and MCP-server logins must not match.
28
+ var LOGIN_SUCCESS_PATTERNS = [
29
+ /successfully logged in(?![ \t]+to[ \t]+mcp)/i,
30
+ /logged in successfully/i,
31
+ /login successful/i,
32
+ /successfully authenticated/i,
33
+ /authentication successful/i,
34
+ /signed in successfully/i,
35
+ /signed in with your [a-z ]+ account/i,
36
+ /you(?:'re| are) (?:now )?(?:logged|signed) in/i,
37
+ ];
38
+
39
+ // Rolling window kept per flow so a success line split across PTY chunks is
40
+ // still matched, without buffering the whole login transcript.
41
+ var OUTPUT_TAIL_MAX = 4096;
42
+
43
+ // Grace period between detecting success and killing the terminal, so the user
44
+ // actually sees the confirmation line before the modal closes.
45
+ var CLOSE_AFTER_SUCCESS_MS = 2000;
46
+
47
+ var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r/g;
48
+
49
+ function stripAnsi(text) {
50
+ return String(text).replace(ANSI_PATTERN, "");
51
+ }
52
+
53
+ function looksLikeLoginSuccess(text) {
54
+ for (var i = 0; i < LOGIN_SUCCESS_PATTERNS.length; i++) {
55
+ if (LOGIN_SUCCESS_PATTERNS[i].test(text)) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * ctx fields:
62
+ * slug, osUsers,
63
+ * sm, tm, adapters,
64
+ * send, sendTo,
65
+ * usersModule,
66
+ * getOsUserInfoForWs, getOsUserInfoForLinuxUser, getLinuxUserForSession,
67
+ * refreshVendorAuthEverywhere
68
+ */
69
+ function attachVendorLogin(ctx) {
70
+ var slug = ctx.slug;
71
+ var osUsers = ctx.osUsers;
72
+
73
+ var sm = ctx.sm;
74
+ var tm = ctx.tm;
75
+ var adapters = ctx.adapters || {};
76
+
77
+ var send = ctx.send;
78
+ var sendTo = ctx.sendTo;
79
+
80
+ var usersModule = ctx.usersModule;
81
+
82
+ var getOsUserInfoForWs = ctx.getOsUserInfoForWs;
83
+ var getOsUserInfoForLinuxUser = ctx.getOsUserInfoForLinuxUser;
84
+ var getLinuxUserForSession = ctx.getLinuxUserForSession;
85
+ var refreshVendorAuthEverywhere = ctx.refreshVendorAuthEverywhere;
86
+
87
+ // vendor -> flow record. At most one live login terminal per vendor.
88
+ var _flows = Object.create(null);
89
+
90
+ function loginCommandFor(vendor) {
91
+ var info = vendorRegistry.getVendorInfo(vendor);
92
+ return (info && info.loginCommand) || "claude login";
93
+ }
94
+
95
+ function vendorDisplayName(vendor) {
96
+ var info = vendorRegistry.getVendorInfo(vendor);
97
+ return (info && info.displayName) || vendor || "Vendor";
98
+ }
99
+
100
+ function listFlows() {
101
+ var result = [];
102
+ var vendors = Object.keys(_flows);
103
+ for (var i = 0; i < vendors.length; i++) {
104
+ var flow = _flows[vendors[i]];
105
+ result.push({
106
+ vendor: flow.vendor,
107
+ terminalId: flow.terminalId,
108
+ startedAt: flow.startedAt,
109
+ completed: !!flow.completed,
110
+ });
111
+ }
112
+ return result;
113
+ }
114
+
115
+ function broadcastState() {
116
+ send({ type: "vendor_login_state", slug: slug, flows: listFlows() });
117
+ }
118
+
119
+ function sendStateTo(ws) {
120
+ sendTo(ws, { type: "vendor_login_state", slug: slug, flows: listFlows() });
121
+ }
122
+
123
+ function hasTerminalPermission(ws) {
124
+ if (!ws || !ws._clayUser) return true;
125
+ if (!usersModule || typeof usersModule.getEffectivePermissions !== "function") return true;
126
+ var perms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
127
+ return !!(perms && perms.terminal);
128
+ }
129
+
130
+ // The login terminal must write credentials to the same HOME the adapter
131
+ // will read them from. With OS-user isolation the adapter runs as the
132
+ // *session owner's* Linux user, which is not necessarily the connected
133
+ // client, so prefer the session identity when the requester is entitled to
134
+ // it. Without isolation both sides run as the daemon user and this resolves
135
+ // to null on both paths.
136
+ function resolveLoginIdentity(ws, msg) {
137
+ if (!osUsers) return { linuxUser: null, osUserInfo: null };
138
+
139
+ var sessionLinuxUser = null;
140
+ var sessionId = msg && msg.sessionId;
141
+ if (sessionId && sm && sm.sessions && typeof getLinuxUserForSession === "function") {
142
+ var session = sm.sessions.get(sessionId);
143
+ if (session) {
144
+ var requesterId = ws && ws._clayUser ? ws._clayUser.id : null;
145
+ var isOwner = !session.ownerId || (requesterId && String(session.ownerId) === String(requesterId));
146
+ var isAdmin = !!(ws && ws._clayUser && ws._clayUser.role === "admin");
147
+ if (isOwner || isAdmin) sessionLinuxUser = getLinuxUserForSession(session);
148
+ }
149
+ }
150
+
151
+ if (sessionLinuxUser && typeof getOsUserInfoForLinuxUser === "function") {
152
+ return { linuxUser: sessionLinuxUser, osUserInfo: getOsUserInfoForLinuxUser(sessionLinuxUser) };
153
+ }
154
+
155
+ var wsInfo = typeof getOsUserInfoForWs === "function" ? getOsUserInfoForWs(ws) : null;
156
+ return { linuxUser: wsInfo ? wsInfo.user : null, osUserInfo: wsInfo };
157
+ }
158
+
159
+ function isFlowTerminalAlive(flow) {
160
+ return !!(flow && typeof flow.terminalId === "number" && tm.has(flow.terminalId));
161
+ }
162
+
163
+ function discardFlow(vendor) {
164
+ var flow = _flows[vendor];
165
+ if (!flow) return null;
166
+ delete _flows[vendor];
167
+ if (flow.closeTimer) {
168
+ clearTimeout(flow.closeTimer);
169
+ flow.closeTimer = null;
170
+ }
171
+ return flow;
172
+ }
173
+
174
+ // Kill the PTY and drop it from the sidebar list. `flow.closing` keeps the
175
+ // terminal's own exit hook from re-entering finalization.
176
+ function closeFlowTerminal(flow) {
177
+ if (!flow || typeof flow.terminalId !== "number") return;
178
+ flow.closing = true;
179
+ try { tm.close(flow.terminalId); } catch (e) {}
180
+ send({ type: "term_list", terminals: tm.list() });
181
+ }
182
+
183
+ // Restart the vendor adapter so the next query spawns a process that reads
184
+ // the credentials the login just wrote. Shared adapter instances (Claude) are
185
+ // reused by every project, so tearing one down here would kill unrelated
186
+ // sessions; those runtimes pick up new credentials on their own.
187
+ function refreshVendorAuth(vendor, linuxUser) {
188
+ var adapter = adapters[vendor];
189
+ if (!adapter || typeof adapter.shutdown !== "function") return Promise.resolve(false);
190
+ if (adapter.shared) {
191
+ console.log("[vendor-login] " + vendor + " adapter is shared; skipping restart for " + slug);
192
+ return Promise.resolve(false);
193
+ }
194
+ if (typeof adapter.refreshAuthIdentity === "function") {
195
+ return Promise.resolve(adapter.refreshAuthIdentity(linuxUser || null));
196
+ }
197
+ if (linuxUser) return Promise.resolve(false);
198
+ // adapter.shutdown() already fans out to every per-OS-user runtime it
199
+ // created (shutdownUserRuntimes), so one call covers isolated setups too.
200
+ return Promise.resolve()
201
+ .then(function () { return adapter.shutdown(); })
202
+ .then(function () {
203
+ console.log("[vendor-login] Restarted " + vendor + " adapter for " + slug + " after login");
204
+ return true;
205
+ })
206
+ .catch(function (err) {
207
+ console.error("[vendor-login] " + vendor + " adapter restart failed for " + slug + ":",
208
+ err && err.message ? err.message : err);
209
+ return false;
210
+ });
211
+ }
212
+
213
+ function finishFlow(vendor, succeeded) {
214
+ var flow = _flows[vendor];
215
+ if (!flow || flow.finishing) return;
216
+ flow.finishing = true;
217
+ flow.completed = !!succeeded;
218
+
219
+ // The next session must not reuse the pre-login "not authenticated"
220
+ // result. Re-check before restarting the runtime, which will then read
221
+ // the newly written credential file.
222
+ if (succeeded) yoke.invalidateAuthCache();
223
+ var refresh = Promise.resolve(false);
224
+ if (succeeded) {
225
+ refresh = typeof refreshVendorAuthEverywhere === "function"
226
+ ? refreshVendorAuthEverywhere(vendor, flow.linuxUser || null)
227
+ : refreshVendorAuth(vendor, flow.linuxUser || null);
228
+ }
229
+ refresh.then(function (restarted) {
230
+ discardFlow(vendor);
231
+ closeFlowTerminal(flow);
232
+ if (succeeded) {
233
+ send({ type: "auth_refreshed", slug: slug, vendor: vendor, adapterRestarted: !!restarted });
234
+ }
235
+ broadcastState();
236
+ });
237
+ }
238
+
239
+ function handleFlowOutput(vendor, chunk) {
240
+ var flow = _flows[vendor];
241
+ if (!flow || flow.finishing || flow.succeeded) return;
242
+ flow.outputTail = (flow.outputTail + stripAnsi(chunk)).slice(-OUTPUT_TAIL_MAX);
243
+ if (!looksLikeLoginSuccess(flow.outputTail)) return;
244
+
245
+ flow.succeeded = true;
246
+ console.log("[vendor-login] Detected successful " + vendor + " login in " + slug);
247
+ // Let the success line land on screen before the terminal disappears.
248
+ flow.closeTimer = setTimeout(function () {
249
+ finishFlow(vendor, true);
250
+ }, CLOSE_AFTER_SUCCESS_MS);
251
+ if (flow.closeTimer && typeof flow.closeTimer.unref === "function") flow.closeTimer.unref();
252
+ }
253
+
254
+ // Fires for both a PTY that exited on its own and one the user closed from
255
+ // the sidebar. Either way the flow is over; refresh auth if the command had
256
+ // already reported success.
257
+ function handleFlowExit(vendor) {
258
+ var flow = _flows[vendor];
259
+ if (!flow || flow.closing || flow.finishing) return;
260
+ finishFlow(vendor, !!flow.succeeded);
261
+ }
262
+
263
+ function startFlow(ws, vendor, msg) {
264
+ var identity = resolveLoginIdentity(ws, msg);
265
+ var command = loginCommandFor(vendor);
266
+ var flow = {
267
+ vendor: vendor,
268
+ terminalId: null,
269
+ startedAt: Date.now(),
270
+ linuxUser: identity.linuxUser,
271
+ outputTail: "",
272
+ succeeded: false,
273
+ finishing: false,
274
+ closing: false,
275
+ completed: false,
276
+ closeTimer: null,
277
+ };
278
+ // Registered before create() so the PTY's first output chunk (hooks fire
279
+ // synchronously from spawn) already finds the record.
280
+ _flows[vendor] = flow;
281
+
282
+ var terminal = null;
283
+ try {
284
+ terminal = tm.create(100, 30, identity.osUserInfo, ws, {
285
+ initialInput: command + "\n",
286
+ title: vendorDisplayName(vendor) + " login",
287
+ kind: "vendor-login",
288
+ onData: function (data) { handleFlowOutput(vendor, data); },
289
+ onExit: function () { handleFlowExit(vendor); },
290
+ });
291
+ } catch (e) {
292
+ console.error("[vendor-login] Failed to spawn " + vendor + " login terminal in " + slug + ":",
293
+ e && e.message ? e.message : e);
294
+ }
295
+
296
+ if (!terminal) {
297
+ delete _flows[vendor];
298
+ return null;
299
+ }
300
+
301
+ flow.terminalId = terminal.id;
302
+ tm.attach(terminal.id, ws);
303
+ send({ type: "term_list", terminals: tm.list() });
304
+ return flow;
305
+ }
306
+
307
+ function handleVendorLoginMessage(ws, msg) {
308
+ if (msg.type === "vendor_login_state_request") {
309
+ sendStateTo(ws);
310
+ return true;
311
+ }
312
+
313
+ if (msg.type === "vendor_login_cancel") {
314
+ var cancelVendor = String(msg.vendor || "");
315
+ var cancelled = discardFlow(cancelVendor);
316
+ if (cancelled) closeFlowTerminal(cancelled);
317
+ broadcastState();
318
+ return true;
319
+ }
320
+
321
+ if (msg.type === "vendor_login_start") {
322
+ var vendor = String(msg.vendor || "claude");
323
+ if (!vendorRegistry.getVendorInfo(vendor)) {
324
+ sendTo(ws, { type: "vendor_login_error", vendor: vendor, error: "Unknown vendor: " + vendor });
325
+ return true;
326
+ }
327
+ if (!hasTerminalPermission(ws)) {
328
+ sendTo(ws, { type: "vendor_login_error", vendor: vendor, error: "Terminal access is not permitted" });
329
+ return true;
330
+ }
331
+
332
+ var existing = _flows[vendor];
333
+ if (existing && !isFlowTerminalAlive(existing)) {
334
+ // Terminal died without its exit hook clearing the record.
335
+ discardFlow(vendor);
336
+ existing = null;
337
+ }
338
+
339
+ if (existing) {
340
+ // An auto-triggered request (a fresh auth_required from another
341
+ // session or split pane) must not pop a second prompt; it only learns
342
+ // that a flow is already running. A deliberate request re-attaches.
343
+ if (!msg.auto) {
344
+ tm.attach(existing.terminalId, ws);
345
+ sendTo(ws, {
346
+ type: "vendor_login_ready",
347
+ slug: slug,
348
+ vendor: vendor,
349
+ terminalId: existing.terminalId,
350
+ reused: true,
351
+ });
352
+ }
353
+ sendStateTo(ws);
354
+ return true;
355
+ }
356
+
357
+ var started = startFlow(ws, vendor, msg);
358
+ if (!started) {
359
+ sendTo(ws, {
360
+ type: "vendor_login_error",
361
+ vendor: vendor,
362
+ error: "Cannot create terminal (node-pty not available or limit reached)",
363
+ });
364
+ return true;
365
+ }
366
+
367
+ console.log("[vendor-login] Started " + vendor + " login terminal " + started.terminalId + " in " + slug);
368
+ sendTo(ws, {
369
+ type: "vendor_login_ready",
370
+ slug: slug,
371
+ vendor: vendor,
372
+ terminalId: started.terminalId,
373
+ reused: false,
374
+ });
375
+ broadcastState();
376
+ return true;
377
+ }
378
+
379
+ return false;
380
+ }
381
+
382
+ // The sidebar "close terminal" path goes through tm.close() directly, which
383
+ // fires our exit hook. This is the explicit entry point for callers that
384
+ // remove a terminal without going through the PTY.
385
+ function forgetTerminal(terminalId) {
386
+ var vendors = Object.keys(_flows);
387
+ for (var i = 0; i < vendors.length; i++) {
388
+ if (_flows[vendors[i]].terminalId === terminalId) {
389
+ handleFlowExit(vendors[i]);
390
+ return true;
391
+ }
392
+ }
393
+ return false;
394
+ }
395
+
396
+ function isLoginTerminal(terminalId) {
397
+ var vendors = Object.keys(_flows);
398
+ for (var i = 0; i < vendors.length; i++) {
399
+ if (_flows[vendors[i]].terminalId === terminalId) return true;
400
+ }
401
+ return false;
402
+ }
403
+
404
+ return {
405
+ handleVendorLoginMessage: handleVendorLoginMessage,
406
+ sendStateTo: sendStateTo,
407
+ listFlows: listFlows,
408
+ forgetTerminal: forgetTerminal,
409
+ isLoginTerminal: isLoginTerminal,
410
+ refreshVendorAuth: refreshVendorAuth,
411
+ };
412
+ }
413
+
414
+ module.exports = { attachVendorLogin: attachVendorLogin };