clay-server 2.46.0 → 2.47.0-beta.2

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/bin/cli.js CHANGED
@@ -1359,24 +1359,16 @@ function setup(callback) {
1359
1359
  }
1360
1360
  port = p;
1361
1361
  log(sym.bar);
1362
- askMode();
1362
+ // No "single vs multi" question anymore: every deploy runs in the
1363
+ // (general) multi-user model. Solo users get an auto-provisioned
1364
+ // default admin with no PIN and no login wall. OS-level user
1365
+ // isolation stays an opt-in, offered here on Linux and toggleable
1366
+ // later; everything else is just multi-user.
1367
+ askOsUsers("multi");
1363
1368
  });
1364
1369
  });
1365
1370
  }
1366
1371
 
1367
- function askMode() {
1368
- promptSelect("How will you use Clay?", [
1369
- { label: "Just me (single user)", value: "single" },
1370
- { label: "Multiple users", value: "multi" },
1371
- ], function (mode) {
1372
- if (mode === "single") {
1373
- finishSetup(mode, false);
1374
- } else {
1375
- askOsUsers(mode);
1376
- }
1377
- });
1378
- }
1379
-
1380
1372
  function askOsUsers(mode) {
1381
1373
  // Only offer OS user isolation on Linux
1382
1374
  if (process.platform !== "linux") {
@@ -1554,7 +1546,7 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1554
1546
  keepAwake: keepAwake,
1555
1547
  dangerouslySkipPermissions: dangerouslySkipPermissions,
1556
1548
  osUsers: wantOsUsers || osUsersMode,
1557
- mode: mode || "single",
1549
+ mode: mode || "multi",
1558
1550
  setupCompleted: true,
1559
1551
  projects: allProjects,
1560
1552
  });
@@ -1729,7 +1721,7 @@ async function devMode(mode, keepAwake, existingPinHash, wantOsUsers) {
1729
1721
  debug: true,
1730
1722
  keepAwake: keepAwake || false,
1731
1723
  dangerouslySkipPermissions: dangerouslySkipPermissions,
1732
- mode: mode || "single",
1724
+ mode: mode || "multi",
1733
1725
  setupCompleted: true,
1734
1726
  projects: allProjects,
1735
1727
  osUsers: wantOsUsers || (prevDevConfig ? (prevDevConfig.osUsers || false) : false),
@@ -2667,7 +2659,7 @@ var currentVersion = require("../package.json").version;
2667
2659
  });
2668
2660
  } else {
2669
2661
  // Reuse existing config (repeat run)
2670
- await devMode(devConfig.mode || "single", devConfig.keepAwake || false, devConfig.pinHash || null, devConfig.osUsers || false);
2662
+ await devMode(devConfig.mode || "multi", devConfig.keepAwake || false, devConfig.pinHash || null, devConfig.osUsers || false);
2671
2663
  }
2672
2664
  return;
2673
2665
  }
@@ -2767,7 +2759,7 @@ var currentVersion = require("../package.json").version;
2767
2759
 
2768
2760
  if (isRepeatRun || autoYes) {
2769
2761
  // Repeat run or --yes: skip wizard, reuse saved config
2770
- var savedMode = (savedConfig && savedConfig.mode) || "single";
2762
+ var savedMode = (savedConfig && savedConfig.mode) || "multi";
2771
2763
  var savedKeepAwake = (savedConfig && savedConfig.keepAwake) || false;
2772
2764
  var savedOsUsers = (savedConfig && savedConfig.osUsers) || false;
2773
2765
 
package/lib/daemon.js CHANGED
@@ -52,6 +52,17 @@ console.log("[daemon] Users: " + usersModule.USERS_FILE);
52
52
  if (process.env.SUDO_USER) console.log("[daemon] SUDO_USER: " + process.env.SUDO_USER);
53
53
  console.log("[daemon] UID: " + (typeof process.getuid === "function" ? process.getuid() : "N/A"));
54
54
 
55
+ // --- Ensure the multi-user model (there is no single-user runtime anymore):
56
+ // migrate a legacy single-user deploy, or provision a default admin on a fresh
57
+ // one. One-time, idempotent, best-effort. Runs before projects/mates/sessions
58
+ // load so the flip and on-disk backfill are in effect for the rest of boot. ---
59
+ try {
60
+ require("./migrate-single-user").ensureMultiUser();
61
+ config = loadConfig(); // refresh in-memory config to pick up the migration marker
62
+ } catch (e) {
63
+ console.error("[daemon] ensureMultiUser threw (continuing):", e && e.message);
64
+ }
65
+
55
66
  // --- OS users mode: check required system dependencies ---
56
67
  // Supplementary-group inheritance (issue #367): default on so sessions behave
57
68
  // like a normal login; operators can disable via config / the settings toggle.
@@ -637,6 +648,29 @@ var relay = createServer({
637
648
  }
638
649
  return { ok: false, error: "Project not found" };
639
650
  },
651
+ onGetProjectLastVendor: function (slug) {
652
+ for (var i = 0; i < config.projects.length; i++) {
653
+ if (config.projects[i].slug === slug) {
654
+ return { vendor: config.projects[i].lastVendor || null };
655
+ }
656
+ }
657
+ return { vendor: null };
658
+ },
659
+ onSetProjectLastVendor: function (slug, vendor) {
660
+ for (var i = 0; i < config.projects.length; i++) {
661
+ if (config.projects[i].slug === slug) {
662
+ if (config.projects[i].lastVendor === vendor) return { ok: true };
663
+ if (vendor) {
664
+ config.projects[i].lastVendor = vendor;
665
+ } else {
666
+ delete config.projects[i].lastVendor;
667
+ }
668
+ saveConfig(config);
669
+ return { ok: true };
670
+ }
671
+ }
672
+ return { ok: false, error: "Project not found" };
673
+ },
640
674
  onGetProjectMcpServers: function (slug) {
641
675
  for (var i = 0; i < config.projects.length; i++) {
642
676
  if (config.projects[i].slug === slug) {
@@ -957,21 +991,28 @@ var relay = createServer({
957
991
  },
958
992
  });
959
993
 
960
- var IDLE_MS = Number(process.env.CLAY_CODEX_IDLE_MS) || 5 * 60 * 1000;
961
- var REAP_MS = Number(process.env.CLAY_CODEX_REAPER_MS) || 60 * 1000;
994
+ // Reclaim idle out-of-process agent runtimes. Any adapter that owns a child
995
+ // process may implement the optional shutdownIfIdle(ms); adapters without one
996
+ // (Claude, which runs in-process or under sdk-worker) are simply skipped.
997
+ // The CLAY_CODEX_* names are kept as aliases for backwards compatibility.
998
+ var IDLE_MS = Number(process.env.CLAY_ADAPTER_IDLE_MS) || Number(process.env.CLAY_CODEX_IDLE_MS) || 5 * 60 * 1000;
999
+ var REAP_MS = Number(process.env.CLAY_ADAPTER_REAPER_MS) || Number(process.env.CLAY_CODEX_REAPER_MS) || 60 * 1000;
962
1000
  var reaperHandle = setInterval(function () {
963
1001
  if (!relay || typeof relay.forEachProject !== "function") return;
964
1002
  relay.forEachProject(function (ctx) {
965
- try {
966
- if (ctx && ctx.adapters && ctx.adapters.codex && typeof ctx.adapters.codex.shutdownIfIdle === "function") {
967
- var result = ctx.adapters.codex.shutdownIfIdle(IDLE_MS);
1003
+ if (!ctx || !ctx.adapters) return;
1004
+ Object.keys(ctx.adapters).forEach(function (vendor) {
1005
+ try {
1006
+ var adapter = ctx.adapters[vendor];
1007
+ if (!adapter || typeof adapter.shutdownIfIdle !== "function") return;
1008
+ var result = adapter.shutdownIfIdle(IDLE_MS);
968
1009
  if (result && typeof result.catch === "function") {
969
1010
  result.catch(function (e) {
970
- console.error("[daemon] Codex idle reclaim failed:", e && e.message ? e.message : e);
1011
+ console.error("[daemon] " + vendor + " idle reclaim failed:", e && e.message ? e.message : e);
971
1012
  });
972
1013
  }
973
- }
974
- } catch (e) {}
1014
+ } catch (e) {}
1015
+ });
975
1016
  });
976
1017
  }, REAP_MS);
977
1018
  if (reaperHandle && typeof reaperHandle.unref === "function") {
@@ -0,0 +1,22 @@
1
+ // Kiro-specific default values. Single source of truth — do not duplicate
2
+ // elsewhere. Consumed by the server-side vendor plumbing and sent to clients
3
+ // via the config state so the UI renders the right controls.
4
+
5
+ var KIRO_DEFAULTS = {
6
+ // Kiro CLI 2.18.1 exposes the next-generation agent as the v3 engine. Its
7
+ // general-purpose mode id is "vibe" (displayed as "Default").
8
+ engine: "v3",
9
+ mode: "vibe",
10
+ };
11
+
12
+ function getKiroConfig(sm) {
13
+ return {
14
+ engine: (sm && sm.kiroEngine) || KIRO_DEFAULTS.engine,
15
+ mode: (sm && sm.kiroMode) || KIRO_DEFAULTS.mode,
16
+ };
17
+ }
18
+
19
+ module.exports = {
20
+ KIRO_DEFAULTS: KIRO_DEFAULTS,
21
+ getKiroConfig: getKiroConfig,
22
+ };
@@ -0,0 +1,286 @@
1
+ // One-time migration: fold a legacy single-user deploy into the multi-user
2
+ // model by auto-provisioning one "admin" user that inherits the single-user
3
+ // PIN, profile, and settings. After this, the app is always internally
4
+ // multi-user; a solo deploy is just a one-user multi-user deploy (with the
5
+ // solo auto-login safety net so there's no login wall when no PIN was set).
6
+ //
7
+ // Design goals:
8
+ // - Seamless: existing users upgrade and keep working with no manual steps.
9
+ // Had a PIN -> same PIN still logs in. Had none -> no login wall.
10
+ // - Safe: backs up state first, tolerates partial failure (best-effort per
11
+ // step), and is a no-op on fresh installs and already-multi-user deploys.
12
+ // - Idempotent: a marker in daemon config prevents re-running.
13
+ //
14
+ // NOTE: this touches on-disk state (users.json, daemon.json, profile.json,
15
+ // mates/). It runs once at daemon boot, before the server starts listening.
16
+
17
+ var fs = require("fs");
18
+ var path = require("path");
19
+ var config = require("./config");
20
+ var users = require("./users");
21
+
22
+ var SETTING_KEYS = [
23
+ "chatLayout",
24
+ "autoContinueOnRateLimit",
25
+ "matesEnabled",
26
+ "terminalFont",
27
+ "deletedBuiltinKeys",
28
+ "mateOnboardingShown",
29
+ ];
30
+
31
+ function backupFile(filePath, stamp) {
32
+ try {
33
+ if (!fs.existsSync(filePath)) return;
34
+ var bak = filePath + ".pre-migrate-" + stamp + ".bak";
35
+ if (fs.existsSync(bak)) return; // don't clobber an existing backup
36
+ fs.copyFileSync(filePath, bak);
37
+ } catch (e) {
38
+ console.error("[migrate] backup failed for " + filePath + ": " + (e.message || e));
39
+ }
40
+ }
41
+
42
+ // Move legacy flat mates (CONFIG_DIR/mates/*) under the per-user directory
43
+ // (CONFIG_DIR/mates/<userId>/). Best-effort; never fatal. Mates data is
44
+ // preserved on disk regardless.
45
+ function migrateMatesToUser(userId) {
46
+ try {
47
+ var matesRoot = path.join(config.CONFIG_DIR, "mates");
48
+ if (!fs.existsSync(matesRoot)) return;
49
+ var userDir = path.join(matesRoot, userId);
50
+ fs.mkdirSync(userDir, { recursive: true });
51
+ var entries = fs.readdirSync(matesRoot);
52
+ for (var i = 0; i < entries.length; i++) {
53
+ var name = entries[i];
54
+ // Skip per-user dirs (userId-shaped) and our target dir.
55
+ if (name === userId) continue;
56
+ // Only move legacy flat artifacts: mates.json and mate_* directories.
57
+ var isLegacy = name === "mates.json" || name.indexOf("mate_") === 0;
58
+ if (!isLegacy) continue;
59
+ var src = path.join(matesRoot, name);
60
+ var dst = path.join(userDir, name);
61
+ if (fs.existsSync(dst)) continue; // don't clobber freshly-seeded builtins
62
+ try { fs.renameSync(src, dst); }
63
+ catch (e) { console.error("[migrate] mates move skipped for " + name + ": " + (e.message || e)); }
64
+ }
65
+ } catch (e) {
66
+ console.error("[migrate] mates migration failed (non-fatal): " + (e.message || e));
67
+ }
68
+ }
69
+
70
+ // Backfill ownerId onto legacy sessions (created before ownership existed) so
71
+ // they belong to the migrated user. Walks CONFIG_DIR/sessions/**/*.jsonl and
72
+ // patches the first "meta" line in place. Best-effort per file.
73
+ function backfillSessionOwners(userId) {
74
+ var count = 0;
75
+ try {
76
+ var root = path.join(config.CONFIG_DIR, "sessions");
77
+ if (!fs.existsSync(root)) return 0;
78
+ var stack = [root];
79
+ while (stack.length > 0) {
80
+ var dir = stack.pop();
81
+ var entries;
82
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { continue; }
83
+ for (var i = 0; i < entries.length; i++) {
84
+ var ent = entries[i];
85
+ var full = path.join(dir, ent.name);
86
+ if (ent.isDirectory()) { stack.push(full); continue; }
87
+ if (!ent.name.endsWith(".jsonl")) continue;
88
+ try {
89
+ var content = fs.readFileSync(full, "utf8");
90
+ var nl = content.indexOf("\n");
91
+ var firstLine = nl === -1 ? content : content.slice(0, nl);
92
+ var rest = nl === -1 ? "" : content.slice(nl); // includes leading \n
93
+ var meta;
94
+ try { meta = JSON.parse(firstLine); } catch (e) { continue; }
95
+ if (!meta || meta.type !== "meta") continue;
96
+ if (meta.ownerId) continue; // already owned
97
+ meta.ownerId = userId;
98
+ var tmp = full + ".tmp." + Date.now();
99
+ fs.writeFileSync(tmp, JSON.stringify(meta) + rest);
100
+ fs.renameSync(tmp, full);
101
+ count++;
102
+ } catch (e) { /* skip this file */ }
103
+ }
104
+ }
105
+ } catch (e) {
106
+ console.error("[migrate] session ownerId backfill failed (non-fatal): " + (e.message || e));
107
+ }
108
+ return count;
109
+ }
110
+
111
+ function readProfile() {
112
+ try {
113
+ var p = path.join(config.CONFIG_DIR, "profile.json");
114
+ if (!fs.existsSync(p)) return null;
115
+ var raw = fs.readFileSync(p, "utf8");
116
+ var obj = JSON.parse(raw);
117
+ return (obj && typeof obj === "object") ? obj : null;
118
+ } catch (e) { return null; }
119
+ }
120
+
121
+ // Returns { migrated: true, userId } on success, or { skipped: <reason> }.
122
+ function migrateSingleUserToMulti() {
123
+ try {
124
+ // Guard 1: already multi-user -> nothing to do.
125
+ if (users.isMultiUser()) return { skipped: "already-multi-user" };
126
+
127
+ // Guard 2: users already exist (partial prior run) -> don't double-create.
128
+ var existing = users.getAllUsers();
129
+ if (existing && existing.length > 0) return { skipped: "users-exist" };
130
+
131
+ var cfg = config.loadConfig() || {};
132
+
133
+ // Guard 3: idempotency marker.
134
+ if (cfg.singleUserMigratedAt) return { skipped: "already-migrated" };
135
+
136
+ // Guard 4: only migrate deploys that show prior single-user use. A pristine
137
+ // fresh install (no PIN, no settings, no profile) is left for the later
138
+ // default-flip phase, so this migration is strictly an upgrade path.
139
+ var profile = readProfile();
140
+ var hasSettings = SETTING_KEYS.some(function (k) { return cfg[k] !== undefined; });
141
+ var hasPin = !!cfg.pinHash;
142
+ if (!profile && !hasSettings && !hasPin) return { skipped: "fresh-install" };
143
+
144
+ var stamp = String(Date.now());
145
+ console.log("[migrate] Legacy single-user deploy detected; migrating to multi-user (stamp=" + stamp + ")");
146
+
147
+ // --- Backup before touching anything ---
148
+ backupFile(config.configPath(), stamp);
149
+ // users.json / auth-tokens live in CONFIG_DIR; back them up if present.
150
+ backupFile(path.join(config.CONFIG_DIR, "users.json"), stamp);
151
+ backupFile(path.join(config.CONFIG_DIR, "profile.json"), stamp);
152
+
153
+ // --- Flip to multi-user, then provision the sole admin ---
154
+ // Flip first so createUser's built-in mate seeding writes to the per-user
155
+ // mates path (CONFIG_DIR/mates/<userId>/), not the flat legacy path.
156
+ users.enableMultiUser();
157
+
158
+ var displayName = (profile && profile.name) ? String(profile.name) : "Admin";
159
+ var created = users.createUserWithoutPin({
160
+ username: "admin",
161
+ displayName: displayName,
162
+ role: "admin",
163
+ profile: profile || undefined,
164
+ });
165
+ if (!created || !created.user) {
166
+ console.error("[migrate] Failed to create admin user: " + (created && created.error));
167
+ // Roll the flag back so we don't leave a userless multi-user deploy.
168
+ try { users.disableMultiUser(); } catch (e) {}
169
+ return { skipped: "create-failed" };
170
+ }
171
+ var userId = created.user.id;
172
+
173
+ // --- Carry over the PIN hash directly (preserve the existing credential;
174
+ // we have the hash, not the raw PIN, so we can't go through updateUserPin) ---
175
+ if (hasPin) {
176
+ try {
177
+ var data = users.loadUsers();
178
+ for (var i = 0; i < data.users.length; i++) {
179
+ if (data.users[i].id === userId) { data.users[i].pinHash = cfg.pinHash; break; }
180
+ }
181
+ users.saveUsers(data);
182
+ } catch (e) {
183
+ console.error("[migrate] PIN carry-over failed (user will have no PIN): " + (e.message || e));
184
+ }
185
+ }
186
+
187
+ // --- Carry over settings ---
188
+ try {
189
+ if (cfg.chatLayout !== undefined && users.setChatLayout) users.setChatLayout(userId, cfg.chatLayout);
190
+ if (cfg.autoContinueOnRateLimit !== undefined && users.setAutoContinue) users.setAutoContinue(userId, cfg.autoContinueOnRateLimit);
191
+ if (cfg.matesEnabled !== undefined && users.setMatesEnabled) users.setMatesEnabled(userId, cfg.matesEnabled);
192
+ if (cfg.terminalFont && users.setTerminalFont) {
193
+ users.setTerminalFont(userId, cfg.terminalFont.family, cfg.terminalFont.size);
194
+ }
195
+ // Fields without a dedicated setter: write directly.
196
+ if (cfg.deletedBuiltinKeys !== undefined || cfg.mateOnboardingShown !== undefined) {
197
+ var d2 = users.loadUsers();
198
+ for (var j = 0; j < d2.users.length; j++) {
199
+ if (d2.users[j].id === userId) {
200
+ if (cfg.deletedBuiltinKeys !== undefined) d2.users[j].deletedBuiltinKeys = cfg.deletedBuiltinKeys;
201
+ if (cfg.mateOnboardingShown !== undefined) d2.users[j].mateOnboardingShown = cfg.mateOnboardingShown;
202
+ break;
203
+ }
204
+ }
205
+ users.saveUsers(d2);
206
+ }
207
+ } catch (e) {
208
+ console.error("[migrate] settings carry-over failed (non-fatal): " + (e.message || e));
209
+ }
210
+
211
+ // --- Move legacy flat mates under the new user ---
212
+ migrateMatesToUser(userId);
213
+
214
+ // --- Backfill session ownership so legacy sessions belong to the user ---
215
+ var backfilled = backfillSessionOwners(userId);
216
+ if (backfilled > 0) console.log("[migrate] Backfilled ownerId on " + backfilled + " legacy session(s)");
217
+
218
+ // --- Mark done (keep the old daemon-config keys for now; a later phase
219
+ // removes the dual settings path). ---
220
+ try {
221
+ var freshCfg = config.loadConfig() || {};
222
+ freshCfg.singleUserMigratedAt = Date.now();
223
+ freshCfg.singleUserMigratedUserId = userId;
224
+ config.saveConfig(freshCfg);
225
+ } catch (e) {
226
+ console.error("[migrate] Failed to write migration marker: " + (e.message || e));
227
+ }
228
+
229
+ console.log("[migrate] Single-user -> multi-user migration complete. admin userId=" + userId + (hasPin ? " (PIN preserved)" : " (no PIN; solo auto-login)"));
230
+ return { migrated: true, userId: userId };
231
+ } catch (e) {
232
+ console.error("[migrate] Single-user migration aborted (non-fatal): " + (e.message || e));
233
+ return { skipped: "error", error: e.message || String(e) };
234
+ }
235
+ }
236
+
237
+ // Guarantee the app is in the multi-user model with at least one user, so the
238
+ // single-user runtime never exists. Called at daemon boot:
239
+ // - already multi-user with users -> nothing to do
240
+ // - legacy single-user with data -> migrate it (preserves PIN/settings)
241
+ // - fresh/empty -> provision one no-PIN "admin"
242
+ // (solo auto-login: no login wall)
243
+ // OS-user isolation stays an orthogonal, settings/flag-driven toggle.
244
+ function ensureMultiUser() {
245
+ try {
246
+ if (users.isMultiUser()) {
247
+ var have = users.getAllUsers();
248
+ if (have && have.length > 0) return { skipped: "ready" };
249
+ }
250
+
251
+ // Try the legacy single-user migration first (handles PIN/settings/mates/
252
+ // session carry-over for existing deploys).
253
+ var mig = migrateSingleUserToMulti();
254
+ if (mig && mig.migrated) return mig;
255
+
256
+ // Fresh or empty deploy: provision a default admin so we're multi-user with
257
+ // one user. No PIN -> the phase-1 solo auto-login skips the login wall.
258
+ var existing = users.getAllUsers();
259
+ if (!existing || existing.length === 0) {
260
+ if (!users.isMultiUser()) users.enableMultiUser();
261
+ var created = users.createUserWithoutPin({
262
+ username: "admin",
263
+ displayName: "Admin",
264
+ role: "admin",
265
+ });
266
+ if (!created || !created.user) {
267
+ console.error("[migrate] Failed to provision default admin: " + (created && created.error));
268
+ return { skipped: "provision-failed" };
269
+ }
270
+ console.log("[migrate] Provisioned default admin (no PIN; solo auto-login). userId=" + created.user.id);
271
+ return { provisioned: true, userId: created.user.id };
272
+ }
273
+
274
+ // Users exist but flag wasn't set (shouldn't normally happen) -> flip it.
275
+ if (!users.isMultiUser()) users.enableMultiUser();
276
+ return { skipped: "flag-fixed" };
277
+ } catch (e) {
278
+ console.error("[migrate] ensureMultiUser failed (non-fatal): " + (e.message || e));
279
+ return { skipped: "error", error: e.message || String(e) };
280
+ }
281
+ }
282
+
283
+ module.exports = {
284
+ migrateSingleUserToMulti: migrateSingleUserToMulti,
285
+ ensureMultiUser: ensureMultiUser,
286
+ };
@@ -143,6 +143,7 @@ function attachConnection(ctx) {
143
143
  sendTo(ws, { type: "model_info", model: sm.currentModel, models: initialModels, vendor: initialVendor, availableVendors: sm.availableVendors || [], installedVendors: sm.installedVendors || [] });
144
144
  }
145
145
  sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
146
+ sendTo(ws, { type: "last_vendor", vendor: sm.lastVendor || "" });
146
147
  sendTo(ws, Object.assign({ type: "codex_config" }, getCodexConfig(sm)));
147
148
  sendTo(ws, { type: "term_list", terminals: tm.list() });
148
149
  // Context sources sent after session is resolved (per-session storage)
@@ -21,7 +21,7 @@ function generateId() {
21
21
  var formatters = {
22
22
  auth_required: function (data) {
23
23
  var vendor = data.vendor || "claude";
24
- var title = data.title || ((vendor === "codex" ? "Codex" : "Claude Code") + " is not logged in");
24
+ var title = data.title || ((vendor === "codex" ? "Codex" : (vendor === "kiro" ? "Kiro CLI" : "Claude Code")) + " is not logged in");
25
25
  return {
26
26
  type: "auth_required",
27
27
  title: title,
@@ -424,13 +424,13 @@ function attachSessions(ctx) {
424
424
  if (ws._clayUser && usersModule.isMultiUser()) sessionOpts.ownerId = ws._clayUser.id;
425
425
  if (msg.sessionVisibility) sessionOpts.sessionVisibility = msg.sessionVisibility;
426
426
  if (msg.vendor) sessionOpts.vendor = msg.vendor;
427
- // Mode resolution: codex sessions are always GUI (no TUI adapter).
427
+ // Mode resolution: codex and kiro sessions are always GUI (no TUI adapter).
428
428
  // Claude sessions honor the explicit msg.mode if provided, otherwise
429
429
  // fall back to the user's claudeOpenMode preference. This is what
430
430
  // makes the sidebar's "Claude" icon button create the right kind of
431
431
  // session without the client needing to know the preference.
432
432
  var requestedMode;
433
- if (msg.vendor === "codex") {
433
+ if (msg.vendor === "codex" || msg.vendor === "kiro") {
434
434
  requestedMode = "gui";
435
435
  } else if (msg.mode === "tui" || msg.mode === "gui") {
436
436
  requestedMode = msg.mode;
@@ -495,6 +495,17 @@ function attachSessions(ctx) {
495
495
  newSess = sm.createSession(sessionOpts, ws);
496
496
  }
497
497
  ws._clayActiveSession = newSess.localId;
498
+ // Remember the vendor only when the client asked for one explicitly.
499
+ // new_session without a vendor (mate sidebar, notification banner,
500
+ // debate) falls through to the default adapter and must not clobber
501
+ // the user's remembered pick.
502
+ if (msg.vendor && sm.lastVendor !== msg.vendor) {
503
+ sm.lastVendor = msg.vendor;
504
+ if (typeof opts.onSetProjectLastVendor === "function") {
505
+ opts.onSetProjectLastVendor(slug, msg.vendor);
506
+ }
507
+ send({ type: "last_vendor", vendor: msg.vendor });
508
+ }
498
509
  // Apply project-level email defaults to new session
499
510
  if (typeof ctx._email === "object" && ctx._email.getEmailDefaults) {
500
511
  var emailDefaults = ctx._email.getEmailDefaults();
package/lib/project.js CHANGED
@@ -166,7 +166,7 @@ function createProjectContext(opts) {
166
166
  var sessionTitleMigrationScheduled = false;
167
167
 
168
168
  // --- YOKE adapters (multi-vendor, lazy init) ---
169
- var _yokeState = yoke.createAdapters({ cwd: cwd, slug: slug });
169
+ var _yokeState = yoke.createAdapters({ cwd: cwd, slug: slug, osUsers: osUsers });
170
170
  var adapters = _yokeState.adapters;
171
171
  var defaultVendor = adapters.claude ? "claude" : Object.keys(adapters)[0] || "claude";
172
172
  var adapter = adapters[defaultVendor] || null;
@@ -428,6 +428,12 @@ function createProjectContext(opts) {
428
428
  var _srvEffort = typeof opts.onGetServerDefaultEffort === "function" ? opts.onGetServerDefaultEffort() : null;
429
429
  sm.currentEffort = (_projEffort && _projEffort.effort) || (_srvEffort && _srvEffort.effort) || "medium";
430
430
 
431
+ // Last vendor the user started a session with in this project. Seeds the
432
+ // sidebar's "New session" button so it defaults to whatever they used last
433
+ // instead of always launching Claude.
434
+ var _projLastVendor = typeof opts.onGetProjectLastVendor === "function" ? opts.onGetProjectLastVendor(slug) : null;
435
+ sm.lastVendor = (_projLastVendor && _projLastVendor.vendor) || null;
436
+
431
437
  var _projModel = typeof opts.onGetProjectDefaultModel === "function" ? opts.onGetProjectDefaultModel(slug) : null;
432
438
  var _srvModel = typeof opts.onGetServerDefaultModel === "function" ? opts.onGetServerDefaultModel() : null;
433
439
  sm._savedDefaultModel = (_projModel && _projModel.model) || (_srvModel && _srvModel.model) || null;
@@ -897,10 +903,12 @@ function createProjectContext(opts) {
897
903
  (async function() {
898
904
  if (msg.vendor) {
899
905
  try {
906
+ var modelLinuxUser = getLinuxUserForWs(ws);
900
907
  var vendorAdapter = adapters[msg.vendor] || null;
901
908
  if (!vendorAdapter) {
902
909
  vendorAdapter = await yoke.lazyCreateAdapter(adapters, msg.vendor, {
903
910
  cwd: cwd,
911
+ linuxUser: modelLinuxUser || undefined,
904
912
  clayPort: serverPort,
905
913
  clayTls: serverTls,
906
914
  clayAuthToken: serverAuthToken,
@@ -1528,14 +1536,30 @@ function createProjectContext(opts) {
1528
1536
  fs.rmSync(tmpDir, { recursive: true, force: true });
1529
1537
  } catch (e) {}
1530
1538
 
1531
- var codexShutdown = Promise.resolve(true);
1532
- if (adapters && adapters.codex && typeof adapters.codex.shutdown === "function") {
1533
- codexShutdown = adapters.codex.shutdown().catch(function(err) {
1534
- console.error("[project] Codex shutdown failed for " + slug + ":", err && err.message ? err.message : err);
1535
- return false;
1539
+ // Shut down every adapter that owns a child process. shutdown() is
1540
+ // optional on the YOKE contract, so adapters without one are skipped.
1541
+ var shutdowns = [];
1542
+ if (adapters) {
1543
+ Object.keys(adapters).forEach(function(vendor) {
1544
+ var adapter = adapters[vendor];
1545
+ if (!adapter || typeof adapter.shutdown !== "function") return;
1546
+ // Shared adapter instances (e.g. Claude) are reused across projects,
1547
+ // so tearing one down here would kill other projects' sessions.
1548
+ if (adapter.shared) return;
1549
+ try {
1550
+ shutdowns.push(Promise.resolve(adapter.shutdown()).catch(function(err) {
1551
+ console.error("[project] " + vendor + " shutdown failed for " + slug + ":", err && err.message ? err.message : err);
1552
+ return false;
1553
+ }));
1554
+ } catch (err) {
1555
+ console.error("[project] " + vendor + " shutdown threw for " + slug + ":", err && err.message ? err.message : err);
1556
+ }
1536
1557
  });
1537
1558
  }
1538
- return codexShutdown;
1559
+ if (!shutdowns.length) return Promise.resolve(true);
1560
+ return Promise.all(shutdowns).then(function(results) {
1561
+ return results.every(function(r) { return r !== false; });
1562
+ });
1539
1563
  }
1540
1564
 
1541
1565
  // --- Status info ---
package/lib/public/app.js CHANGED
@@ -310,6 +310,12 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
310
310
  // panels
311
311
  currentModel: "",
312
312
  currentModels: [],
313
+ // Project's last-used vendor; seeds the sidebar's "New session" button.
314
+ lastVendor: "",
315
+ // How Claude sessions open: "gui" (default) or "tui". The server sends
316
+ // claude_open_mode_changed on connect; this seeds it beforehand so the
317
+ // new-session menu doesn't flash the TUI-only entry.
318
+ claudeOpenMode: "gui",
313
319
  currentMode: "default",
314
320
  currentEffort: "medium",
315
321
  currentBetas: [],