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.
@@ -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,
@@ -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 8px;
673
- border: 1px solid var(--border);
674
- border-radius: 8px;
675
- background: var(--input-bg);
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: 7px;
684
- padding: 8px 10px;
711
+ gap: 9px;
712
+ padding: 7px 11px;
685
713
  border: 0;
686
714
  background: transparent;
687
- color: var(--text-secondary);
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-chip, .session-background-task-count {
694
- display: inline-flex;
695
- align-items: center;
696
- justify-content: center;
697
- min-width: 16px;
698
- height: 16px;
699
- padding: 0 4px;
700
- border-radius: 8px;
701
- background: var(--accent);
702
- color: var(--bg);
703
- font-size: 10px;
704
- font-weight: 700;
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
- .background-tasks-list { padding: 0 10px 8px; }
708
- .background-task-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-top: 1px solid var(--border-subtle); }
709
- .background-task-description { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
710
- .background-task-type { padding: 2px 6px; border-radius: 5px; background: var(--code-bg); color: var(--text-muted); font-size: 11px; }
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;