clay-server 4.0.0-beta.1 → 4.0.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.
- package/lib/background-task-timing.js +72 -0
- package/lib/project-connection.js +4 -0
- package/lib/project-vendor-login.js +397 -0
- package/lib/project.js +20 -0
- package/lib/public/css/input.css +73 -25
- package/lib/public/modules/app-messages.js +24 -17
- package/lib/public/modules/app-notifications.js +4 -117
- package/lib/public/modules/background-tasks-ui.js +109 -15
- package/lib/public/modules/pane-bridge.js +9 -0
- package/lib/public/modules/split-view.js +7 -0
- package/lib/public/modules/tui-attention.js +10 -0
- package/lib/public/modules/vendor-login.js +287 -0
- package/lib/sdk-message-processor.js +10 -2
- package/lib/tools-registry.js +11 -1
- package/lib/ws-schema.js +7 -0
- package/lib/yoke/adapters/claude.js +6 -1
- package/lib/yoke/adapters/codex.js +3 -4
- package/lib/yoke/codex-app-server.js +20 -2
- package/lib/yoke/codex-background-tasks.js +8 -2
- package/package.json +2 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Background task start-time tracking.
|
|
2
|
+
//
|
|
3
|
+
// Vendors report background tasks as a full replacement list on every change
|
|
4
|
+
// and none of them carries a reliable start time today (see
|
|
5
|
+
// normalizeBackgroundTasks in yoke/adapters/claude.js and mapTerminals in
|
|
6
|
+
// yoke/codex-background-tasks.js). The session's previous list is the only
|
|
7
|
+
// place that knows when a task was first seen, so the merge belongs here,
|
|
8
|
+
// where that list is maintained, rather than in an adapter that rebuilds the
|
|
9
|
+
// array from scratch each time.
|
|
10
|
+
//
|
|
11
|
+
// A vendor-supplied timestamp always wins when present; otherwise a task is
|
|
12
|
+
// stamped the first time it appears and keeps that stamp for its whole life.
|
|
13
|
+
|
|
14
|
+
// Accepts epoch milliseconds, epoch seconds, or an ISO date string, since
|
|
15
|
+
// vendors are not consistent. Returns null for anything unusable so the
|
|
16
|
+
// caller falls back to first-seen stamping instead of rendering 1970.
|
|
17
|
+
function normalizeTimestamp(value) {
|
|
18
|
+
if (typeof value === "number" && isFinite(value) && value > 0) {
|
|
19
|
+
// Values this small are epoch seconds, not milliseconds.
|
|
20
|
+
return value < 1e12 ? Math.round(value * 1000) : Math.round(value);
|
|
21
|
+
}
|
|
22
|
+
if (typeof value === "string" && value) {
|
|
23
|
+
var parsed = Date.parse(value);
|
|
24
|
+
if (!isNaN(parsed)) return parsed;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function vendorStartedAt(task) {
|
|
30
|
+
if (!task) return null;
|
|
31
|
+
return normalizeTimestamp(task.started_at)
|
|
32
|
+
|| normalizeTimestamp(task.startedAt)
|
|
33
|
+
|| normalizeTimestamp(task.createdAt)
|
|
34
|
+
|| normalizeTimestamp(task.created_at);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return `nextTasks` with a `started_at` (epoch ms) on every entry.
|
|
39
|
+
* Tasks already present in `previousTasks` keep their original stamp so the
|
|
40
|
+
* elapsed time does not reset every time the list is re-emitted.
|
|
41
|
+
*/
|
|
42
|
+
function mergeStartTimes(previousTasks, nextTasks, now) {
|
|
43
|
+
if (!Array.isArray(nextTasks)) return [];
|
|
44
|
+
var stampedAt = typeof now === "number" ? now : Date.now();
|
|
45
|
+
// Stamps we wrote on a previous pass are already epoch milliseconds, so they
|
|
46
|
+
// are read as-is. Only vendor-supplied values go through normalizeTimestamp,
|
|
47
|
+
// whose seconds-vs-milliseconds heuristic would otherwise be re-applied to
|
|
48
|
+
// our own output.
|
|
49
|
+
var known = {};
|
|
50
|
+
var previous = Array.isArray(previousTasks) ? previousTasks : [];
|
|
51
|
+
for (var p = 0; p < previous.length; p++) {
|
|
52
|
+
var seen = previous[p];
|
|
53
|
+
if (!seen || !seen.task_id) continue;
|
|
54
|
+
if (typeof seen.started_at === "number" && isFinite(seen.started_at) && seen.started_at > 0) {
|
|
55
|
+
known[seen.task_id] = seen.started_at;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var merged = [];
|
|
60
|
+
for (var i = 0; i < nextTasks.length; i++) {
|
|
61
|
+
var task = nextTasks[i];
|
|
62
|
+
if (!task) continue;
|
|
63
|
+
var startedAt = vendorStartedAt(task) || known[task.task_id] || stampedAt;
|
|
64
|
+
merged.push(Object.assign({}, task, { started_at: startedAt }));
|
|
65
|
+
}
|
|
66
|
+
return merged;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
mergeStartTimes: mergeStartTimes,
|
|
71
|
+
normalizeTimestamp: normalizeTimestamp,
|
|
72
|
+
};
|
|
@@ -78,6 +78,7 @@ function attachConnection(ctx) {
|
|
|
78
78
|
var _mcp = ctx._mcp;
|
|
79
79
|
var _notifications = ctx._notifications;
|
|
80
80
|
var _splitGroups = ctx._splitGroups;
|
|
81
|
+
var _vendorLogin = ctx._vendorLogin;
|
|
81
82
|
var hydrateImageRefs = ctx.hydrateImageRefs;
|
|
82
83
|
var resolveSessionForView = ctx.resolveSessionForView;
|
|
83
84
|
var broadcastClientCount = ctx.broadcastClientCount;
|
|
@@ -191,6 +192,9 @@ function attachConnection(ctx) {
|
|
|
191
192
|
sendTo(ws, { type: "last_vendor", vendor: sm.lastVendor || "" });
|
|
192
193
|
sendTo(ws, Object.assign({ type: "codex_config" }, getCodexConfig(sm)));
|
|
193
194
|
sendTo(ws, { type: "term_list", terminals: tm.list() });
|
|
195
|
+
// Any login flow already running in this project, so a reconnecting client
|
|
196
|
+
// re-attaches to the existing login terminal instead of starting another.
|
|
197
|
+
if (_vendorLogin) _vendorLogin.sendStateTo(ws);
|
|
194
198
|
// Context sources sent after session is resolved (per-session storage)
|
|
195
199
|
// Send email accounts list for context sources picker
|
|
196
200
|
var emailUserId = (wsUser && wsUser.id) || "default";
|
|
@@ -0,0 +1,397 @@
|
|
|
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
|
+
|
|
24
|
+
// Login CLIs print a success line and then hand the shell back, so the PTY
|
|
25
|
+
// itself does not exit. Watch the output stream instead. Deliberately narrow:
|
|
26
|
+
// "Successfully logged out" and MCP-server logins must not match.
|
|
27
|
+
var LOGIN_SUCCESS_PATTERNS = [
|
|
28
|
+
/successfully logged in(?![ \t]+to[ \t]+mcp)/i,
|
|
29
|
+
/logged in successfully/i,
|
|
30
|
+
/login successful/i,
|
|
31
|
+
/successfully authenticated/i,
|
|
32
|
+
/authentication successful/i,
|
|
33
|
+
/signed in successfully/i,
|
|
34
|
+
/signed in with your [a-z ]+ account/i,
|
|
35
|
+
/you(?:'re| are) (?:now )?(?:logged|signed) in/i,
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Rolling window kept per flow so a success line split across PTY chunks is
|
|
39
|
+
// still matched, without buffering the whole login transcript.
|
|
40
|
+
var OUTPUT_TAIL_MAX = 4096;
|
|
41
|
+
|
|
42
|
+
// Grace period between detecting success and killing the terminal, so the user
|
|
43
|
+
// actually sees the confirmation line before the modal closes.
|
|
44
|
+
var CLOSE_AFTER_SUCCESS_MS = 2000;
|
|
45
|
+
|
|
46
|
+
var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r/g;
|
|
47
|
+
|
|
48
|
+
function stripAnsi(text) {
|
|
49
|
+
return String(text).replace(ANSI_PATTERN, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function looksLikeLoginSuccess(text) {
|
|
53
|
+
for (var i = 0; i < LOGIN_SUCCESS_PATTERNS.length; i++) {
|
|
54
|
+
if (LOGIN_SUCCESS_PATTERNS[i].test(text)) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ctx fields:
|
|
61
|
+
* slug, osUsers,
|
|
62
|
+
* sm, tm, adapters,
|
|
63
|
+
* send, sendTo,
|
|
64
|
+
* usersModule,
|
|
65
|
+
* getOsUserInfoForWs, getOsUserInfoForLinuxUser, getLinuxUserForSession
|
|
66
|
+
*/
|
|
67
|
+
function attachVendorLogin(ctx) {
|
|
68
|
+
var slug = ctx.slug;
|
|
69
|
+
var osUsers = ctx.osUsers;
|
|
70
|
+
|
|
71
|
+
var sm = ctx.sm;
|
|
72
|
+
var tm = ctx.tm;
|
|
73
|
+
var adapters = ctx.adapters || {};
|
|
74
|
+
|
|
75
|
+
var send = ctx.send;
|
|
76
|
+
var sendTo = ctx.sendTo;
|
|
77
|
+
|
|
78
|
+
var usersModule = ctx.usersModule;
|
|
79
|
+
|
|
80
|
+
var getOsUserInfoForWs = ctx.getOsUserInfoForWs;
|
|
81
|
+
var getOsUserInfoForLinuxUser = ctx.getOsUserInfoForLinuxUser;
|
|
82
|
+
var getLinuxUserForSession = ctx.getLinuxUserForSession;
|
|
83
|
+
|
|
84
|
+
// vendor -> flow record. At most one live login terminal per vendor.
|
|
85
|
+
var _flows = Object.create(null);
|
|
86
|
+
|
|
87
|
+
function loginCommandFor(vendor) {
|
|
88
|
+
var info = vendorRegistry.getVendorInfo(vendor);
|
|
89
|
+
return (info && info.loginCommand) || "claude login";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function vendorDisplayName(vendor) {
|
|
93
|
+
var info = vendorRegistry.getVendorInfo(vendor);
|
|
94
|
+
return (info && info.displayName) || vendor || "Vendor";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function listFlows() {
|
|
98
|
+
var result = [];
|
|
99
|
+
var vendors = Object.keys(_flows);
|
|
100
|
+
for (var i = 0; i < vendors.length; i++) {
|
|
101
|
+
var flow = _flows[vendors[i]];
|
|
102
|
+
result.push({
|
|
103
|
+
vendor: flow.vendor,
|
|
104
|
+
terminalId: flow.terminalId,
|
|
105
|
+
startedAt: flow.startedAt,
|
|
106
|
+
completed: !!flow.completed,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function broadcastState() {
|
|
113
|
+
send({ type: "vendor_login_state", slug: slug, flows: listFlows() });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sendStateTo(ws) {
|
|
117
|
+
sendTo(ws, { type: "vendor_login_state", slug: slug, flows: listFlows() });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hasTerminalPermission(ws) {
|
|
121
|
+
if (!ws || !ws._clayUser) return true;
|
|
122
|
+
if (!usersModule || typeof usersModule.getEffectivePermissions !== "function") return true;
|
|
123
|
+
var perms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
|
|
124
|
+
return !!(perms && perms.terminal);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The login terminal must write credentials to the same HOME the adapter
|
|
128
|
+
// will read them from. With OS-user isolation the adapter runs as the
|
|
129
|
+
// *session owner's* Linux user, which is not necessarily the connected
|
|
130
|
+
// client, so prefer the session identity when the requester is entitled to
|
|
131
|
+
// it. Without isolation both sides run as the daemon user and this resolves
|
|
132
|
+
// to null on both paths.
|
|
133
|
+
function resolveLoginIdentity(ws, msg) {
|
|
134
|
+
if (!osUsers) return { linuxUser: null, osUserInfo: null };
|
|
135
|
+
|
|
136
|
+
var sessionLinuxUser = null;
|
|
137
|
+
var sessionId = msg && msg.sessionId;
|
|
138
|
+
if (sessionId && sm && sm.sessions && typeof getLinuxUserForSession === "function") {
|
|
139
|
+
var session = sm.sessions.get(sessionId);
|
|
140
|
+
if (session) {
|
|
141
|
+
var requesterId = ws && ws._clayUser ? ws._clayUser.id : null;
|
|
142
|
+
var isOwner = !session.ownerId || (requesterId && String(session.ownerId) === String(requesterId));
|
|
143
|
+
var isAdmin = !!(ws && ws._clayUser && ws._clayUser.role === "admin");
|
|
144
|
+
if (isOwner || isAdmin) sessionLinuxUser = getLinuxUserForSession(session);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (sessionLinuxUser && typeof getOsUserInfoForLinuxUser === "function") {
|
|
149
|
+
return { linuxUser: sessionLinuxUser, osUserInfo: getOsUserInfoForLinuxUser(sessionLinuxUser) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
var wsInfo = typeof getOsUserInfoForWs === "function" ? getOsUserInfoForWs(ws) : null;
|
|
153
|
+
return { linuxUser: wsInfo ? wsInfo.user : null, osUserInfo: wsInfo };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isFlowTerminalAlive(flow) {
|
|
157
|
+
return !!(flow && typeof flow.terminalId === "number" && tm.has(flow.terminalId));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function discardFlow(vendor) {
|
|
161
|
+
var flow = _flows[vendor];
|
|
162
|
+
if (!flow) return null;
|
|
163
|
+
delete _flows[vendor];
|
|
164
|
+
if (flow.closeTimer) {
|
|
165
|
+
clearTimeout(flow.closeTimer);
|
|
166
|
+
flow.closeTimer = null;
|
|
167
|
+
}
|
|
168
|
+
return flow;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Kill the PTY and drop it from the sidebar list. `flow.closing` keeps the
|
|
172
|
+
// terminal's own exit hook from re-entering finalization.
|
|
173
|
+
function closeFlowTerminal(flow) {
|
|
174
|
+
if (!flow || typeof flow.terminalId !== "number") return;
|
|
175
|
+
flow.closing = true;
|
|
176
|
+
try { tm.close(flow.terminalId); } catch (e) {}
|
|
177
|
+
send({ type: "term_list", terminals: tm.list() });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Restart the vendor adapter so the next query spawns a process that reads
|
|
181
|
+
// the credentials the login just wrote. Shared adapter instances (Claude) are
|
|
182
|
+
// reused by every project, so tearing one down here would kill unrelated
|
|
183
|
+
// sessions; those runtimes pick up new credentials on their own.
|
|
184
|
+
function refreshVendorAuth(vendor) {
|
|
185
|
+
var adapter = adapters[vendor];
|
|
186
|
+
if (!adapter || typeof adapter.shutdown !== "function") return Promise.resolve(false);
|
|
187
|
+
if (adapter.shared) {
|
|
188
|
+
console.log("[vendor-login] " + vendor + " adapter is shared; skipping restart for " + slug);
|
|
189
|
+
return Promise.resolve(false);
|
|
190
|
+
}
|
|
191
|
+
// adapter.shutdown() already fans out to every per-OS-user runtime it
|
|
192
|
+
// created (shutdownUserRuntimes), so one call covers isolated setups too.
|
|
193
|
+
return Promise.resolve()
|
|
194
|
+
.then(function () { return adapter.shutdown(); })
|
|
195
|
+
.then(function () {
|
|
196
|
+
console.log("[vendor-login] Restarted " + vendor + " adapter for " + slug + " after login");
|
|
197
|
+
return true;
|
|
198
|
+
})
|
|
199
|
+
.catch(function (err) {
|
|
200
|
+
console.error("[vendor-login] " + vendor + " adapter restart failed for " + slug + ":",
|
|
201
|
+
err && err.message ? err.message : err);
|
|
202
|
+
return false;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function finishFlow(vendor, succeeded) {
|
|
207
|
+
var flow = _flows[vendor];
|
|
208
|
+
if (!flow || flow.finishing) return;
|
|
209
|
+
flow.finishing = true;
|
|
210
|
+
flow.completed = !!succeeded;
|
|
211
|
+
|
|
212
|
+
var refresh = succeeded ? refreshVendorAuth(vendor) : Promise.resolve(false);
|
|
213
|
+
refresh.then(function (restarted) {
|
|
214
|
+
discardFlow(vendor);
|
|
215
|
+
closeFlowTerminal(flow);
|
|
216
|
+
if (succeeded) {
|
|
217
|
+
send({ type: "auth_refreshed", slug: slug, vendor: vendor, adapterRestarted: !!restarted });
|
|
218
|
+
}
|
|
219
|
+
broadcastState();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function handleFlowOutput(vendor, chunk) {
|
|
224
|
+
var flow = _flows[vendor];
|
|
225
|
+
if (!flow || flow.finishing || flow.succeeded) return;
|
|
226
|
+
flow.outputTail = (flow.outputTail + stripAnsi(chunk)).slice(-OUTPUT_TAIL_MAX);
|
|
227
|
+
if (!looksLikeLoginSuccess(flow.outputTail)) return;
|
|
228
|
+
|
|
229
|
+
flow.succeeded = true;
|
|
230
|
+
console.log("[vendor-login] Detected successful " + vendor + " login in " + slug);
|
|
231
|
+
// Let the success line land on screen before the terminal disappears.
|
|
232
|
+
flow.closeTimer = setTimeout(function () {
|
|
233
|
+
finishFlow(vendor, true);
|
|
234
|
+
}, CLOSE_AFTER_SUCCESS_MS);
|
|
235
|
+
if (flow.closeTimer && typeof flow.closeTimer.unref === "function") flow.closeTimer.unref();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Fires for both a PTY that exited on its own and one the user closed from
|
|
239
|
+
// the sidebar. Either way the flow is over; refresh auth if the command had
|
|
240
|
+
// already reported success.
|
|
241
|
+
function handleFlowExit(vendor) {
|
|
242
|
+
var flow = _flows[vendor];
|
|
243
|
+
if (!flow || flow.closing || flow.finishing) return;
|
|
244
|
+
finishFlow(vendor, !!flow.succeeded);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function startFlow(ws, vendor, msg) {
|
|
248
|
+
var identity = resolveLoginIdentity(ws, msg);
|
|
249
|
+
var command = loginCommandFor(vendor);
|
|
250
|
+
var flow = {
|
|
251
|
+
vendor: vendor,
|
|
252
|
+
terminalId: null,
|
|
253
|
+
startedAt: Date.now(),
|
|
254
|
+
linuxUser: identity.linuxUser,
|
|
255
|
+
outputTail: "",
|
|
256
|
+
succeeded: false,
|
|
257
|
+
finishing: false,
|
|
258
|
+
closing: false,
|
|
259
|
+
completed: false,
|
|
260
|
+
closeTimer: null,
|
|
261
|
+
};
|
|
262
|
+
// Registered before create() so the PTY's first output chunk (hooks fire
|
|
263
|
+
// synchronously from spawn) already finds the record.
|
|
264
|
+
_flows[vendor] = flow;
|
|
265
|
+
|
|
266
|
+
var terminal = null;
|
|
267
|
+
try {
|
|
268
|
+
terminal = tm.create(100, 30, identity.osUserInfo, ws, {
|
|
269
|
+
initialInput: command + "\n",
|
|
270
|
+
title: vendorDisplayName(vendor) + " login",
|
|
271
|
+
kind: "vendor-login",
|
|
272
|
+
onData: function (data) { handleFlowOutput(vendor, data); },
|
|
273
|
+
onExit: function () { handleFlowExit(vendor); },
|
|
274
|
+
});
|
|
275
|
+
} catch (e) {
|
|
276
|
+
console.error("[vendor-login] Failed to spawn " + vendor + " login terminal in " + slug + ":",
|
|
277
|
+
e && e.message ? e.message : e);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!terminal) {
|
|
281
|
+
delete _flows[vendor];
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
flow.terminalId = terminal.id;
|
|
286
|
+
tm.attach(terminal.id, ws);
|
|
287
|
+
send({ type: "term_list", terminals: tm.list() });
|
|
288
|
+
return flow;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function handleVendorLoginMessage(ws, msg) {
|
|
292
|
+
if (msg.type === "vendor_login_state_request") {
|
|
293
|
+
sendStateTo(ws);
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (msg.type === "vendor_login_cancel") {
|
|
298
|
+
var cancelVendor = String(msg.vendor || "");
|
|
299
|
+
var cancelled = discardFlow(cancelVendor);
|
|
300
|
+
if (cancelled) closeFlowTerminal(cancelled);
|
|
301
|
+
broadcastState();
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (msg.type === "vendor_login_start") {
|
|
306
|
+
var vendor = String(msg.vendor || "claude");
|
|
307
|
+
if (!vendorRegistry.getVendorInfo(vendor)) {
|
|
308
|
+
sendTo(ws, { type: "vendor_login_error", vendor: vendor, error: "Unknown vendor: " + vendor });
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
if (!hasTerminalPermission(ws)) {
|
|
312
|
+
sendTo(ws, { type: "vendor_login_error", vendor: vendor, error: "Terminal access is not permitted" });
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
var existing = _flows[vendor];
|
|
317
|
+
if (existing && !isFlowTerminalAlive(existing)) {
|
|
318
|
+
// Terminal died without its exit hook clearing the record.
|
|
319
|
+
discardFlow(vendor);
|
|
320
|
+
existing = null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (existing) {
|
|
324
|
+
// An auto-triggered request (a fresh auth_required from another
|
|
325
|
+
// session or split pane) must not pop a second prompt; it only learns
|
|
326
|
+
// that a flow is already running. A deliberate request re-attaches.
|
|
327
|
+
if (!msg.auto) {
|
|
328
|
+
tm.attach(existing.terminalId, ws);
|
|
329
|
+
sendTo(ws, {
|
|
330
|
+
type: "vendor_login_ready",
|
|
331
|
+
slug: slug,
|
|
332
|
+
vendor: vendor,
|
|
333
|
+
terminalId: existing.terminalId,
|
|
334
|
+
reused: true,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
sendStateTo(ws);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
var started = startFlow(ws, vendor, msg);
|
|
342
|
+
if (!started) {
|
|
343
|
+
sendTo(ws, {
|
|
344
|
+
type: "vendor_login_error",
|
|
345
|
+
vendor: vendor,
|
|
346
|
+
error: "Cannot create terminal (node-pty not available or limit reached)",
|
|
347
|
+
});
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log("[vendor-login] Started " + vendor + " login terminal " + started.terminalId + " in " + slug);
|
|
352
|
+
sendTo(ws, {
|
|
353
|
+
type: "vendor_login_ready",
|
|
354
|
+
slug: slug,
|
|
355
|
+
vendor: vendor,
|
|
356
|
+
terminalId: started.terminalId,
|
|
357
|
+
reused: false,
|
|
358
|
+
});
|
|
359
|
+
broadcastState();
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The sidebar "close terminal" path goes through tm.close() directly, which
|
|
367
|
+
// fires our exit hook. This is the explicit entry point for callers that
|
|
368
|
+
// remove a terminal without going through the PTY.
|
|
369
|
+
function forgetTerminal(terminalId) {
|
|
370
|
+
var vendors = Object.keys(_flows);
|
|
371
|
+
for (var i = 0; i < vendors.length; i++) {
|
|
372
|
+
if (_flows[vendors[i]].terminalId === terminalId) {
|
|
373
|
+
handleFlowExit(vendors[i]);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isLoginTerminal(terminalId) {
|
|
381
|
+
var vendors = Object.keys(_flows);
|
|
382
|
+
for (var i = 0; i < vendors.length; i++) {
|
|
383
|
+
if (_flows[vendors[i]].terminalId === terminalId) return true;
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
handleVendorLoginMessage: handleVendorLoginMessage,
|
|
390
|
+
sendStateTo: sendStateTo,
|
|
391
|
+
listFlows: listFlows,
|
|
392
|
+
forgetTerminal: forgetTerminal,
|
|
393
|
+
isLoginTerminal: isLoginTerminal,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
module.exports = { attachVendorLogin: attachVendorLogin };
|
package/lib/project.js
CHANGED
|
@@ -34,6 +34,7 @@ var { attachSessions } = require("./project-sessions");
|
|
|
34
34
|
var { attachModels } = require("./project-models");
|
|
35
35
|
var { attachUserMessage } = require("./project-user-message");
|
|
36
36
|
var { attachShellCommand } = require("./project-shell-command");
|
|
37
|
+
var { attachVendorLogin } = require("./project-vendor-login");
|
|
37
38
|
var { attachConnection } = require("./project-connection");
|
|
38
39
|
var { attachMcp } = require("./project-mcp");
|
|
39
40
|
var { createLocalMcp } = require("./mcp-local");
|
|
@@ -1286,6 +1287,9 @@ function createProjectContext(opts) {
|
|
|
1286
1287
|
// --- Shell command context ---
|
|
1287
1288
|
if (_shellCommand.handleShellCommand(ws, msg)) return;
|
|
1288
1289
|
|
|
1290
|
+
// --- Vendor login flow (delegated to project-vendor-login.js) ---
|
|
1291
|
+
if (_vendorLogin.handleVendorLoginMessage(ws, msg)) return;
|
|
1292
|
+
|
|
1289
1293
|
// --- Notes, terminals, context, user message (delegated to project-user-message.js) ---
|
|
1290
1294
|
if (_userMessage.handleUserMessage(ws, msg)) return;
|
|
1291
1295
|
}
|
|
@@ -1593,6 +1597,21 @@ function createProjectContext(opts) {
|
|
|
1593
1597
|
getOsUserInfoForWs: getOsUserInfoForWs,
|
|
1594
1598
|
});
|
|
1595
1599
|
|
|
1600
|
+
// --- Vendor login flow (delegated to project-vendor-login.js) ---
|
|
1601
|
+
var _vendorLogin = attachVendorLogin({
|
|
1602
|
+
slug: slug,
|
|
1603
|
+
osUsers: osUsers,
|
|
1604
|
+
sm: sm,
|
|
1605
|
+
tm: tm,
|
|
1606
|
+
adapters: adapters,
|
|
1607
|
+
send: send,
|
|
1608
|
+
sendTo: sendTo,
|
|
1609
|
+
usersModule: usersModule,
|
|
1610
|
+
getOsUserInfoForWs: getOsUserInfoForWs,
|
|
1611
|
+
getOsUserInfoForLinuxUser: getOsUserInfoForLinuxUser,
|
|
1612
|
+
getLinuxUserForSession: getLinuxUserForSession,
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1596
1615
|
// --- Filesystem handler (delegated to project-filesystem.js) ---
|
|
1597
1616
|
var _filesystem = attachFilesystem({
|
|
1598
1617
|
cwd: cwd,
|
|
@@ -1866,6 +1885,7 @@ function createProjectContext(opts) {
|
|
|
1866
1885
|
_mcp: _mcp,
|
|
1867
1886
|
_notifications: _notifications,
|
|
1868
1887
|
_splitGroups: _splitGroups,
|
|
1888
|
+
_vendorLogin: _vendorLogin,
|
|
1869
1889
|
resolveSessionForView: _sessions.resolveSessionForView,
|
|
1870
1890
|
hydrateImageRefs: hydrateImageRefs,
|
|
1871
1891
|
broadcastClientCount: broadcastClientCount,
|
package/lib/public/css/input.css
CHANGED
|
@@ -667,49 +667,97 @@
|
|
|
667
667
|
position: relative;
|
|
668
668
|
}
|
|
669
669
|
|
|
670
|
+
/* Sidebar session-row badge. Formerly shared its rule with the composer's
|
|
671
|
+
background-tasks chip; it is standalone now that the composer bar no longer
|
|
672
|
+
uses a filled numeric badge. */
|
|
673
|
+
.session-background-task-count {
|
|
674
|
+
display: inline-flex;
|
|
675
|
+
align-items: center;
|
|
676
|
+
justify-content: center;
|
|
677
|
+
min-width: 16px;
|
|
678
|
+
height: 16px;
|
|
679
|
+
padding: 0 4px;
|
|
680
|
+
border-radius: 8px;
|
|
681
|
+
background: var(--accent);
|
|
682
|
+
color: var(--bg);
|
|
683
|
+
font-size: 10px;
|
|
684
|
+
font-weight: 700;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/* --- Background tasks status line (above the composer) ---
|
|
688
|
+
Reads as a calm status line that belongs to the input surface: subtle
|
|
689
|
+
border, no fill, muted text. Progress is conveyed by the animated dots
|
|
690
|
+
rather than by color or size. */
|
|
670
691
|
.background-tasks-bar {
|
|
671
692
|
max-width: var(--content-width);
|
|
672
|
-
margin: 0 auto
|
|
673
|
-
border: 1px solid var(--border);
|
|
674
|
-
border-radius:
|
|
675
|
-
background:
|
|
693
|
+
margin: 0 auto 6px;
|
|
694
|
+
border: 1px solid var(--border-subtle);
|
|
695
|
+
border-radius: 10px;
|
|
696
|
+
background: transparent;
|
|
676
697
|
overflow: hidden;
|
|
698
|
+
transition: border-color .15s ease, background-color .15s ease;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
.background-tasks-bar:hover,
|
|
702
|
+
.background-tasks-bar.expanded {
|
|
703
|
+
border-color: var(--border);
|
|
704
|
+
background: var(--input-bg);
|
|
677
705
|
}
|
|
678
706
|
|
|
679
707
|
.background-tasks-toggle {
|
|
680
708
|
width: 100%;
|
|
681
709
|
display: flex;
|
|
682
710
|
align-items: center;
|
|
683
|
-
gap:
|
|
684
|
-
padding:
|
|
711
|
+
gap: 9px;
|
|
712
|
+
padding: 7px 11px;
|
|
685
713
|
border: 0;
|
|
686
714
|
background: transparent;
|
|
687
|
-
color: var(--text-
|
|
715
|
+
color: var(--text-muted);
|
|
688
716
|
cursor: pointer;
|
|
689
717
|
font: inherit;
|
|
718
|
+
font-size: 12px;
|
|
690
719
|
text-align: left;
|
|
691
720
|
}
|
|
692
721
|
|
|
693
|
-
.background-tasks-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
722
|
+
.background-tasks-toggle:hover { color: var(--text-secondary); }
|
|
723
|
+
.background-tasks-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 10px; }
|
|
724
|
+
|
|
725
|
+
.background-tasks-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
726
|
+
|
|
727
|
+
/* Quiet three-dot activity indicator. Same motion as the Home debate dots
|
|
728
|
+
(home-debate-planning.css) but muted instead of accent-colored, since this
|
|
729
|
+
is ambient status rather than something asking to be read. */
|
|
730
|
+
.background-tasks-activity { display: inline-flex; align-items: center; gap: 3px; color: var(--text-muted); flex: none; }
|
|
731
|
+
.background-tasks-activity i { width: 4px; height: 4px; border-radius: 50%; background: currentColor; animation: background-tasks-pulse 1.4s ease-in-out infinite; }
|
|
732
|
+
.background-tasks-activity i:nth-child(2) { animation-delay: .18s; }
|
|
733
|
+
.background-tasks-activity i:nth-child(3) { animation-delay: .36s; }
|
|
734
|
+
@keyframes background-tasks-pulse { 0%, 70%, 100% { opacity: .28; } 35% { opacity: .95; } }
|
|
735
|
+
|
|
736
|
+
.background-tasks-chevron { display: inline-flex; flex: none; color: var(--text-muted); transition: transform .15s ease; }
|
|
737
|
+
.background-tasks-chevron i { width: 14px; height: 14px; }
|
|
738
|
+
.background-tasks-bar.expanded .background-tasks-chevron { transform: rotate(90deg); }
|
|
739
|
+
|
|
740
|
+
.background-tasks-list { padding: 0 11px 7px; }
|
|
741
|
+
.background-task-row { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-top: 1px solid var(--border-subtle); font-size: 12px; }
|
|
742
|
+
.background-task-icon { display: inline-flex; flex: none; color: var(--text-muted); }
|
|
743
|
+
.background-task-icon i { width: 13px; height: 13px; }
|
|
744
|
+
.background-task-description { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
|
|
745
|
+
.background-task-meta { display: inline-flex; align-items: center; gap: 7px; flex: none; color: var(--text-muted); }
|
|
746
|
+
.background-task-type { font-size: 11px; }
|
|
747
|
+
.background-task-elapsed { font-variant-numeric: tabular-nums; font-size: 11px; min-width: 44px; text-align: right; }
|
|
748
|
+
.background-task-stop { flex: none; border: 1px solid var(--border-subtle); border-radius: 6px; padding: 3px 8px; background: transparent; color: var(--text-muted); cursor: pointer; font: inherit; font-size: 11px; }
|
|
749
|
+
.background-task-stop:hover { color: var(--text); border-color: var(--border); }
|
|
750
|
+
.background-task-stop:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
751
|
+
|
|
752
|
+
@media (prefers-reduced-motion: reduce) {
|
|
753
|
+
.background-tasks-activity i { animation: none; opacity: .7; }
|
|
754
|
+
.background-tasks-bar, .background-tasks-chevron { transition: none; }
|
|
705
755
|
}
|
|
706
756
|
|
|
707
|
-
|
|
708
|
-
.background-task-
|
|
709
|
-
.background-task-
|
|
710
|
-
|
|
711
|
-
.background-task-stop { border: 1px solid var(--border); border-radius: 5px; padding: 3px 7px; background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: 12px; }
|
|
712
|
-
.background-task-stop:hover { color: var(--text); border-color: var(--text-muted); }
|
|
757
|
+
@media (max-width: 600px) {
|
|
758
|
+
.background-task-meta { gap: 5px; }
|
|
759
|
+
.background-task-type { display: none; }
|
|
760
|
+
}
|
|
713
761
|
|
|
714
762
|
#input-row {
|
|
715
763
|
display: flex;
|