From 13f67045d6caa0ad5d9fa3ba3436bcbebccac2f0 Mon Sep 17 00:00:00 2001 From: ChadByte Date: Mon, 20 Jul 2026 15:20:43 +1200 Subject: [PATCH 1/5] Absorb single-user mode into multi-user (phases 1-3) (#380) * feat(auth): solo auto-login safety net for absorbed single-user (phase 1) Groundwork for folding single-user mode into multi-user. When a multi-user instance has exactly one user with no PIN, authenticate them automatically instead of showing a login wall, preserving the frictionless single-user onramp. Central change: getMultiUserFromReq() falls back to users.getSoloNoPinUser() when there's no valid token, so every HTTP and WS auth gate (all route through this) is covered by one path. Disabled the moment a PIN is set or a second user is added. Inert today: only triggers when multiUser is true (single-user deploys are unaffected until the later migration phase flips them). * feat(auth): auto-migrate legacy single-user deploys to multi-user (phase 2) On daemon boot, detect a legacy single-user deploy and fold it into the multi-user model with zero manual steps: - provision one "admin" user that inherits the single-user PIN hash (same PIN still logs in), profile.json, and settings (chatLayout, autoContinue, matesEnabled, terminalFont, deletedBuiltinKeys, mateOnboardingShown) - move legacy flat mates into the per-user mates dir - backfill ownerId onto legacy sessions so they belong to the user - flip multiUser = true Runs before projects/mates/sessions load. Heavily guarded: backs up users.json/daemon.json/profile.json first, is idempotent (config marker), tolerates partial failure per step, and is a no-op on fresh installs and already-multi-user deploys. Combined with the phase-1 solo auto-login, a no-PIN single-user deploy upgrades with no login wall. * feat(auth): default new installs to multi-user, drop the mode prompt (phase 3) There is no separate single-user runtime anymore. Every deploy runs in the (general) multi-user model: - Remove the "Just me (single user) / Multiple users" setup question. Fresh installs go straight to multi-user; OS-user isolation stays an opt-in (offered on Linux at setup, toggleable later). - daemon boot calls ensureMultiUser(): migrate a legacy single-user deploy, or provision a default no-PIN "admin" on a fresh one. With phase-1 solo auto-login, a solo user gets in with no account creation and no login wall. - Flip every mode fallback from "single" to "multi". Solo onramp is unchanged in feel (npx clay-server -> just works); it's now a one-user multi-user deploy under the hood. * docs: handoff for single-user absorption phases 4-6 Capture the plan, what's done (phases 1-3 + verification), and the remaining cleanup (phase 4 settings dual-path, phase 5 !isMultiUser branch removal, phase 6 flag/dead-code removal) with file:line targets and watch-outs. --- bin/cli.js | 28 +-- docs/ongoing/SINGLE-USER-ABSORPTION.md | 75 +++++++ lib/daemon.js | 11 + lib/migrate-single-user.js | 286 +++++++++++++++++++++++++ lib/server-auth.js | 19 +- lib/users.js | 14 ++ 6 files changed, 410 insertions(+), 23 deletions(-) create mode 100644 docs/ongoing/SINGLE-USER-ABSORPTION.md create mode 100644 lib/migrate-single-user.js diff --git a/bin/cli.js b/bin/cli.js index 8e9b1331..7e79004b 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1359,24 +1359,16 @@ function setup(callback) { } port = p; log(sym.bar); - askMode(); + // No "single vs multi" question anymore: every deploy runs in the + // (general) multi-user model. Solo users get an auto-provisioned + // default admin with no PIN and no login wall. OS-level user + // isolation stays an opt-in, offered here on Linux and toggleable + // later; everything else is just multi-user. + askOsUsers("multi"); }); }); } - function askMode() { - promptSelect("How will you use Clay?", [ - { label: "Just me (single user)", value: "single" }, - { label: "Multiple users", value: "multi" }, - ], function (mode) { - if (mode === "single") { - finishSetup(mode, false); - } else { - askOsUsers(mode); - } - }); - } - function askOsUsers(mode) { // Only offer OS user isolation on Linux if (process.platform !== "linux") { @@ -1554,7 +1546,7 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) { keepAwake: keepAwake, dangerouslySkipPermissions: dangerouslySkipPermissions, osUsers: wantOsUsers || osUsersMode, - mode: mode || "single", + mode: mode || "multi", setupCompleted: true, projects: allProjects, }); @@ -1729,7 +1721,7 @@ async function devMode(mode, keepAwake, existingPinHash, wantOsUsers) { debug: true, keepAwake: keepAwake || false, dangerouslySkipPermissions: dangerouslySkipPermissions, - mode: mode || "single", + mode: mode || "multi", setupCompleted: true, projects: allProjects, osUsers: wantOsUsers || (prevDevConfig ? (prevDevConfig.osUsers || false) : false), @@ -2667,7 +2659,7 @@ var currentVersion = require("../package.json").version; }); } else { // Reuse existing config (repeat run) - await devMode(devConfig.mode || "single", devConfig.keepAwake || false, devConfig.pinHash || null, devConfig.osUsers || false); + await devMode(devConfig.mode || "multi", devConfig.keepAwake || false, devConfig.pinHash || null, devConfig.osUsers || false); } return; } @@ -2767,7 +2759,7 @@ var currentVersion = require("../package.json").version; if (isRepeatRun || autoYes) { // Repeat run or --yes: skip wizard, reuse saved config - var savedMode = (savedConfig && savedConfig.mode) || "single"; + var savedMode = (savedConfig && savedConfig.mode) || "multi"; var savedKeepAwake = (savedConfig && savedConfig.keepAwake) || false; var savedOsUsers = (savedConfig && savedConfig.osUsers) || false; diff --git a/docs/ongoing/SINGLE-USER-ABSORPTION.md b/docs/ongoing/SINGLE-USER-ABSORPTION.md new file mode 100644 index 00000000..63b0f74a --- /dev/null +++ b/docs/ongoing/SINGLE-USER-ABSORPTION.md @@ -0,0 +1,75 @@ +# Single-user → multi-user absorption + +Goal: remove the separate **single-user runtime**. Every deploy runs in the +(general) **multi-user** model; a solo user is just a one-user multi-user +deploy with an auto-provisioned default admin and no login wall. **OS-user +isolation (`config.osUsers`) is orthogonal** and untouched by this work. + +Guiding principle: **migrate-then-flip** — safety nets and data migration land +before the default flips, so existing deploys upgrade with zero manual steps. + +--- + +## Done (this branch) + +| Phase | Commit | What | +|-------|--------|------| +| 1 | `3878977` | Solo auto-login safety net. `getMultiUserFromReq()` falls back to `users.getSoloNoPinUser()` (exactly one user, no PIN) so every HTTP/WS auth gate is satisfied with no login wall. Inert until multi-user. | +| 2 | `8fd50b8` | `lib/migrate-single-user.js` — boot migration of a legacy single-user deploy: carry PIN hash (same PIN still logs in), profile.json, settings (chatLayout, autoContinueOnRateLimit, matesEnabled, terminalFont, deletedBuiltinKeys, mateOnboardingShown), move flat mates → `mates//`, backfill ownerId on legacy sessions, flip `multiUser=true`. Backups + idempotent (config marker) + best-effort per step; no-op on fresh/already-multi. | +| 3 | `58bdae0` | Drop the "Just me / Multiple users" setup prompt; every install is multi-user. Boot calls `ensureMultiUser()`: migrate legacy, or provision a default no-PIN admin on fresh installs. All `mode` fallbacks flipped `"single"` → `"multi"`. | + +Net effect: `isMultiUser()` is effectively always `true` at runtime, and there +is always ≥1 user. Single-user code paths are now **dead** (present but never +executed). + +### Verified (isolated `CLAY_HOME` harness — real `~/.clay` untouched) +- Legacy single-user **with PIN** → migrates; the original PIN authenticates, wrong PIN rejected; profile/settings/session-ownerId/mates all carried; backups + marker written; second run idempotent. +- Legacy single-user **no PIN** → migrates; `pinHash=null` → solo auto-login resolves. +- **Fresh** install → `ensureMultiUser()` provisions a default no-PIN admin; idempotent. + +Repro: +``` +CLAY_HOME=/tmp/clay-migtest/home node /tmp/clay-migtest/run.js # (harness, recreate as needed) +``` +Full E2E (still isolated): boot the daemon against a throwaway home/port: +``` +CLAY_HOME=/tmp/clay-e2e CLAY_PORT=2699 node bin/cli.js +``` + +--- + +## Remaining — cleanup (dead-code removal; do incrementally WITH runtime testing) + +These are safe to defer: the branches are already dead. Value is +maintainability, not behavior. **Do not sweep all 109 branches blindly** — +some single-user branches contain details the multi-user branch lacks (see the +avatar caveat). Remove per-file, runtime-testing each area. + +### Phase 4 — collapse the settings dual path (`dc` daemon-config → user record) +Single-user stored settings in `daemon.json`; multi-user stores them per user. +Now that a user always exists, drop the daemon-config path. +- `lib/server-settings.js`: + - GET `/api/profile` (~L16-75): remove the `else` single-user branch (L37-64) and the `if (users.isMultiUser())` wrapper; always use the `mu` (user-record) path. **Caveat:** the single-user branch has custom-avatar-file logic (L65-74) the `mu` branch lacks — preserve it in the unified path. + - PUT `/api/profile` + the per-setting writes (~L392, L417, L442, L467, L492, L517): remove the `onGetDaemonConfig`/`onSetDaemonConfig` (`dc`) write paths; always write the user record. +- `lib/daemon.js`: `onGetDaemonConfig`/`onSetDaemonConfig` handlers (~L702-783) become unused for these keys once the daemon-config settings path is gone — remove the settings keys from the daemon-config surface. +- Migration keeps the old daemon-config keys on disk (harmless); a later step can strip them. + +### Phase 5 — collapse `!isMultiUser` branches (server, then client) +~109 sites across 27 files (recon map). Categories: +- **AUTH** — `server-auth.js` `getAuthPage`/`isRequestAuthed`, `server.js` ws gate (~L924-974). `isRequestAuthed`/`getAuthPage` can drop the single-user (`pinPage`/daemon-token) branch. +- **SESSION/OWNERSHIP** — `sessions.js` (~L411-419 `getVisibleSessions`, L464 ownerId assign, L773 `_isMySession`), `server.js` notification targeting. Legacy `!s.ownerId` handling can stay as a compat read. +- **UI** — client `app.js` (~L820-839 `/api/me`), `sidebar-projects.js`, `sidebar-sessions.js`, `project-settings.js`, `app-cursors.js`, `app-messages.js` — remove `isMultiUserMode` gates (always on). +- **DM/MATES** — `server-dm.js` (userId `"default"` fallback), `mates.js` `buildMateCtx`/`resolveMatesRoot` (flat path branch). +- **ADMIN** — `server-admin.js` endpoints already require multi-user; no change needed. + +### Phase 6 — remove the flag + dead code +- `users-auth.js` `enableMultiUser`/`disableMultiUser`, `data.multiUser` flag, `isMultiUser()` (make it a constant `true` or remove callers), `defaultData().multiUser`. +- CLI: `disableMultiUser` menu item, single-user status labels (`bin/cli.js` ~L2124), any remaining `mode === "single"` handling. +- Strip migrated settings keys from daemon config. + +--- + +## Watch-outs +- **Solo → real multi-user transition:** the default admin has no PIN. If an operator adds a second user (settings), `getSoloNoPinUser()` stops matching and login is required — but the admin has no PIN and would be locked out. The add-user / enable-auth flow must force the admin to set a PIN first. (Follow-up; not handled yet.) +- **OS mode** stays orthogonal — don't fold it into these phases. +- Migration keeps backups (`*.pre-migrate-.bak`) and a config marker (`singleUserMigratedAt`); safe to re-run. diff --git a/lib/daemon.js b/lib/daemon.js index a26d1337..784d30bc 100644 --- a/lib/daemon.js +++ b/lib/daemon.js @@ -52,6 +52,17 @@ console.log("[daemon] Users: " + usersModule.USERS_FILE); if (process.env.SUDO_USER) console.log("[daemon] SUDO_USER: " + process.env.SUDO_USER); console.log("[daemon] UID: " + (typeof process.getuid === "function" ? process.getuid() : "N/A")); +// --- Ensure the multi-user model (there is no single-user runtime anymore): +// migrate a legacy single-user deploy, or provision a default admin on a fresh +// one. One-time, idempotent, best-effort. Runs before projects/mates/sessions +// load so the flip and on-disk backfill are in effect for the rest of boot. --- +try { + require("./migrate-single-user").ensureMultiUser(); + config = loadConfig(); // refresh in-memory config to pick up the migration marker +} catch (e) { + console.error("[daemon] ensureMultiUser threw (continuing):", e && e.message); +} + // --- OS users mode: check required system dependencies --- // Supplementary-group inheritance (issue #367): default on so sessions behave // like a normal login; operators can disable via config / the settings toggle. diff --git a/lib/migrate-single-user.js b/lib/migrate-single-user.js new file mode 100644 index 00000000..7cd29bff --- /dev/null +++ b/lib/migrate-single-user.js @@ -0,0 +1,286 @@ +// One-time migration: fold a legacy single-user deploy into the multi-user +// model by auto-provisioning one "admin" user that inherits the single-user +// PIN, profile, and settings. After this, the app is always internally +// multi-user; a solo deploy is just a one-user multi-user deploy (with the +// solo auto-login safety net so there's no login wall when no PIN was set). +// +// Design goals: +// - Seamless: existing users upgrade and keep working with no manual steps. +// Had a PIN -> same PIN still logs in. Had none -> no login wall. +// - Safe: backs up state first, tolerates partial failure (best-effort per +// step), and is a no-op on fresh installs and already-multi-user deploys. +// - Idempotent: a marker in daemon config prevents re-running. +// +// NOTE: this touches on-disk state (users.json, daemon.json, profile.json, +// mates/). It runs once at daemon boot, before the server starts listening. + +var fs = require("fs"); +var path = require("path"); +var config = require("./config"); +var users = require("./users"); + +var SETTING_KEYS = [ + "chatLayout", + "autoContinueOnRateLimit", + "matesEnabled", + "terminalFont", + "deletedBuiltinKeys", + "mateOnboardingShown", +]; + +function backupFile(filePath, stamp) { + try { + if (!fs.existsSync(filePath)) return; + var bak = filePath + ".pre-migrate-" + stamp + ".bak"; + if (fs.existsSync(bak)) return; // don't clobber an existing backup + fs.copyFileSync(filePath, bak); + } catch (e) { + console.error("[migrate] backup failed for " + filePath + ": " + (e.message || e)); + } +} + +// Move legacy flat mates (CONFIG_DIR/mates/*) under the per-user directory +// (CONFIG_DIR/mates//). Best-effort; never fatal. Mates data is +// preserved on disk regardless. +function migrateMatesToUser(userId) { + try { + var matesRoot = path.join(config.CONFIG_DIR, "mates"); + if (!fs.existsSync(matesRoot)) return; + var userDir = path.join(matesRoot, userId); + fs.mkdirSync(userDir, { recursive: true }); + var entries = fs.readdirSync(matesRoot); + for (var i = 0; i < entries.length; i++) { + var name = entries[i]; + // Skip per-user dirs (userId-shaped) and our target dir. + if (name === userId) continue; + // Only move legacy flat artifacts: mates.json and mate_* directories. + var isLegacy = name === "mates.json" || name.indexOf("mate_") === 0; + if (!isLegacy) continue; + var src = path.join(matesRoot, name); + var dst = path.join(userDir, name); + if (fs.existsSync(dst)) continue; // don't clobber freshly-seeded builtins + try { fs.renameSync(src, dst); } + catch (e) { console.error("[migrate] mates move skipped for " + name + ": " + (e.message || e)); } + } + } catch (e) { + console.error("[migrate] mates migration failed (non-fatal): " + (e.message || e)); + } +} + +// Backfill ownerId onto legacy sessions (created before ownership existed) so +// they belong to the migrated user. Walks CONFIG_DIR/sessions/**/*.jsonl and +// patches the first "meta" line in place. Best-effort per file. +function backfillSessionOwners(userId) { + var count = 0; + try { + var root = path.join(config.CONFIG_DIR, "sessions"); + if (!fs.existsSync(root)) return 0; + var stack = [root]; + while (stack.length > 0) { + var dir = stack.pop(); + var entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { continue; } + for (var i = 0; i < entries.length; i++) { + var ent = entries[i]; + var full = path.join(dir, ent.name); + if (ent.isDirectory()) { stack.push(full); continue; } + if (!ent.name.endsWith(".jsonl")) continue; + try { + var content = fs.readFileSync(full, "utf8"); + var nl = content.indexOf("\n"); + var firstLine = nl === -1 ? content : content.slice(0, nl); + var rest = nl === -1 ? "" : content.slice(nl); // includes leading \n + var meta; + try { meta = JSON.parse(firstLine); } catch (e) { continue; } + if (!meta || meta.type !== "meta") continue; + if (meta.ownerId) continue; // already owned + meta.ownerId = userId; + var tmp = full + ".tmp." + Date.now(); + fs.writeFileSync(tmp, JSON.stringify(meta) + rest); + fs.renameSync(tmp, full); + count++; + } catch (e) { /* skip this file */ } + } + } + } catch (e) { + console.error("[migrate] session ownerId backfill failed (non-fatal): " + (e.message || e)); + } + return count; +} + +function readProfile() { + try { + var p = path.join(config.CONFIG_DIR, "profile.json"); + if (!fs.existsSync(p)) return null; + var raw = fs.readFileSync(p, "utf8"); + var obj = JSON.parse(raw); + return (obj && typeof obj === "object") ? obj : null; + } catch (e) { return null; } +} + +// Returns { migrated: true, userId } on success, or { skipped: }. +function migrateSingleUserToMulti() { + try { + // Guard 1: already multi-user -> nothing to do. + if (users.isMultiUser()) return { skipped: "already-multi-user" }; + + // Guard 2: users already exist (partial prior run) -> don't double-create. + var existing = users.getAllUsers(); + if (existing && existing.length > 0) return { skipped: "users-exist" }; + + var cfg = config.loadConfig() || {}; + + // Guard 3: idempotency marker. + if (cfg.singleUserMigratedAt) return { skipped: "already-migrated" }; + + // Guard 4: only migrate deploys that show prior single-user use. A pristine + // fresh install (no PIN, no settings, no profile) is left for the later + // default-flip phase, so this migration is strictly an upgrade path. + var profile = readProfile(); + var hasSettings = SETTING_KEYS.some(function (k) { return cfg[k] !== undefined; }); + var hasPin = !!cfg.pinHash; + if (!profile && !hasSettings && !hasPin) return { skipped: "fresh-install" }; + + var stamp = String(Date.now()); + console.log("[migrate] Legacy single-user deploy detected; migrating to multi-user (stamp=" + stamp + ")"); + + // --- Backup before touching anything --- + backupFile(config.configPath(), stamp); + // users.json / auth-tokens live in CONFIG_DIR; back them up if present. + backupFile(path.join(config.CONFIG_DIR, "users.json"), stamp); + backupFile(path.join(config.CONFIG_DIR, "profile.json"), stamp); + + // --- Flip to multi-user, then provision the sole admin --- + // Flip first so createUser's built-in mate seeding writes to the per-user + // mates path (CONFIG_DIR/mates//), not the flat legacy path. + users.enableMultiUser(); + + var displayName = (profile && profile.name) ? String(profile.name) : "Admin"; + var created = users.createUserWithoutPin({ + username: "admin", + displayName: displayName, + role: "admin", + profile: profile || undefined, + }); + if (!created || !created.user) { + console.error("[migrate] Failed to create admin user: " + (created && created.error)); + // Roll the flag back so we don't leave a userless multi-user deploy. + try { users.disableMultiUser(); } catch (e) {} + return { skipped: "create-failed" }; + } + var userId = created.user.id; + + // --- Carry over the PIN hash directly (preserve the existing credential; + // we have the hash, not the raw PIN, so we can't go through updateUserPin) --- + if (hasPin) { + try { + var data = users.loadUsers(); + for (var i = 0; i < data.users.length; i++) { + if (data.users[i].id === userId) { data.users[i].pinHash = cfg.pinHash; break; } + } + users.saveUsers(data); + } catch (e) { + console.error("[migrate] PIN carry-over failed (user will have no PIN): " + (e.message || e)); + } + } + + // --- Carry over settings --- + try { + if (cfg.chatLayout !== undefined && users.setChatLayout) users.setChatLayout(userId, cfg.chatLayout); + if (cfg.autoContinueOnRateLimit !== undefined && users.setAutoContinue) users.setAutoContinue(userId, cfg.autoContinueOnRateLimit); + if (cfg.matesEnabled !== undefined && users.setMatesEnabled) users.setMatesEnabled(userId, cfg.matesEnabled); + if (cfg.terminalFont && users.setTerminalFont) { + users.setTerminalFont(userId, cfg.terminalFont.family, cfg.terminalFont.size); + } + // Fields without a dedicated setter: write directly. + if (cfg.deletedBuiltinKeys !== undefined || cfg.mateOnboardingShown !== undefined) { + var d2 = users.loadUsers(); + for (var j = 0; j < d2.users.length; j++) { + if (d2.users[j].id === userId) { + if (cfg.deletedBuiltinKeys !== undefined) d2.users[j].deletedBuiltinKeys = cfg.deletedBuiltinKeys; + if (cfg.mateOnboardingShown !== undefined) d2.users[j].mateOnboardingShown = cfg.mateOnboardingShown; + break; + } + } + users.saveUsers(d2); + } + } catch (e) { + console.error("[migrate] settings carry-over failed (non-fatal): " + (e.message || e)); + } + + // --- Move legacy flat mates under the new user --- + migrateMatesToUser(userId); + + // --- Backfill session ownership so legacy sessions belong to the user --- + var backfilled = backfillSessionOwners(userId); + if (backfilled > 0) console.log("[migrate] Backfilled ownerId on " + backfilled + " legacy session(s)"); + + // --- Mark done (keep the old daemon-config keys for now; a later phase + // removes the dual settings path). --- + try { + var freshCfg = config.loadConfig() || {}; + freshCfg.singleUserMigratedAt = Date.now(); + freshCfg.singleUserMigratedUserId = userId; + config.saveConfig(freshCfg); + } catch (e) { + console.error("[migrate] Failed to write migration marker: " + (e.message || e)); + } + + console.log("[migrate] Single-user -> multi-user migration complete. admin userId=" + userId + (hasPin ? " (PIN preserved)" : " (no PIN; solo auto-login)")); + return { migrated: true, userId: userId }; + } catch (e) { + console.error("[migrate] Single-user migration aborted (non-fatal): " + (e.message || e)); + return { skipped: "error", error: e.message || String(e) }; + } +} + +// Guarantee the app is in the multi-user model with at least one user, so the +// single-user runtime never exists. Called at daemon boot: +// - already multi-user with users -> nothing to do +// - legacy single-user with data -> migrate it (preserves PIN/settings) +// - fresh/empty -> provision one no-PIN "admin" +// (solo auto-login: no login wall) +// OS-user isolation stays an orthogonal, settings/flag-driven toggle. +function ensureMultiUser() { + try { + if (users.isMultiUser()) { + var have = users.getAllUsers(); + if (have && have.length > 0) return { skipped: "ready" }; + } + + // Try the legacy single-user migration first (handles PIN/settings/mates/ + // session carry-over for existing deploys). + var mig = migrateSingleUserToMulti(); + if (mig && mig.migrated) return mig; + + // Fresh or empty deploy: provision a default admin so we're multi-user with + // one user. No PIN -> the phase-1 solo auto-login skips the login wall. + var existing = users.getAllUsers(); + if (!existing || existing.length === 0) { + if (!users.isMultiUser()) users.enableMultiUser(); + var created = users.createUserWithoutPin({ + username: "admin", + displayName: "Admin", + role: "admin", + }); + if (!created || !created.user) { + console.error("[migrate] Failed to provision default admin: " + (created && created.error)); + return { skipped: "provision-failed" }; + } + console.log("[migrate] Provisioned default admin (no PIN; solo auto-login). userId=" + created.user.id); + return { provisioned: true, userId: created.user.id }; + } + + // Users exist but flag wasn't set (shouldn't normally happen) -> flip it. + if (!users.isMultiUser()) users.enableMultiUser(); + return { skipped: "flag-fixed" }; + } catch (e) { + console.error("[migrate] ensureMultiUser failed (non-fatal): " + (e.message || e)); + return { skipped: "error", error: e.message || String(e) }; + } +} + +module.exports = { + migrateSingleUserToMulti: migrateSingleUserToMulti, + ensureMultiUser: ensureMultiUser, +}; diff --git a/lib/server-auth.js b/lib/server-auth.js index df7e84e4..ba70f93f 100644 --- a/lib/server-auth.js +++ b/lib/server-auth.js @@ -102,11 +102,20 @@ function attachAuth(ctx) { function getMultiUserFromReq(req) { var cookies = parseCookies(req); var token = cookies[MULTI_USER_COOKIE]; - if (!token) return null; - var userId = multiUserTokens[token]; - if (!userId) return null; - var user = users.findUserById(userId); - return user || null; + if (token) { + var userId = multiUserTokens[token]; + if (userId) { + var user = users.findUserById(userId); + if (user) return user; + } + } + // Solo auto-login: exactly one user with no PIN => frictionless access, no + // login wall. Mirrors the old single-user onramp once single-user mode is + // absorbed into multi-user. Disabled the moment a PIN is set or a second + // user is added (then the normal token/login flow applies). + var solo = users.getSoloNoPinUser(); + if (solo) return solo; + return null; } function isMultiUserAuthed(req) { diff --git a/lib/users.js b/lib/users.js index 43a0cd71..ef22b1ee 100644 --- a/lib/users.js +++ b/lib/users.js @@ -165,6 +165,19 @@ function getAllUsers() { }); } +// Solo frictionless access: exactly one user with no PIN. Returns the full +// user record (callers read id/role/etc.) or null. Used to skip the login wall +// so an absorbed single-user deploy stays zero-friction; auto-disables the +// moment a PIN is set or a second user is added. +function getSoloNoPinUser() { + if (!isMultiUser()) return null; + var data = loadUsers(); + if (!data.users || data.users.length !== 1) return null; + var u = data.users[0]; + if (u.pinHash) return null; + return u; +} + function getOtherUsers(excludeUserId) { return getAllUsers().filter(function (u) { return u.id !== excludeUserId; @@ -451,6 +464,7 @@ module.exports = { findUserByEmail: findUserByEmail, authenticateUser: authenticateUser, getAllUsers: getAllUsers, + getSoloNoPinUser: getSoloNoPinUser, removeUser: removeUser, updateUserProfile: updateUserProfile, updateUserPin: updateUserPin, From cd564984a467926e80e773bc400f3311172bc105 Mon Sep 17 00:00:00 2001 From: ChadByte Date: Mon, 20 Jul 2026 15:40:01 +1200 Subject: [PATCH 2/5] feat(codex): add GPT-5.6 sol/terra/luna models (#384) Add the GPT-5.6 model family (gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna) to the Codex adapter's model list and make gpt-5.6-terra the default (5.5-class quality at lower cost). Register the 5.6 context window (272000) so the context panel reports usage correctly instead of falling back to 200000. Verified the bundled @openai/codex 0.144.4 binary already recognizes all three model ids. --- lib/public/modules/app-panels.js | 1 + lib/yoke/adapters/codex.js | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/public/modules/app-panels.js b/lib/public/modules/app-panels.js index 450d36db..2e722305 100644 --- a/lib/public/modules/app-panels.js +++ b/lib/public/modules/app-panels.js @@ -108,6 +108,7 @@ var CODEX_WEBSEARCH_OPTIONS = [ var KNOWN_CONTEXT_WINDOWS = { "opus-4-6": 1000000, "claude-sonnet-4": 1000000, + "gpt-5.6": 272000, "gpt-5.5": 1048576, "gpt-5.4": 1048576, "gpt-5.3": 1048576, diff --git a/lib/yoke/adapters/codex.js b/lib/yoke/adapters/codex.js index d46b6f19..40aade0c 100644 --- a/lib/yoke/adapters/codex.js +++ b/lib/yoke/adapters/codex.js @@ -620,7 +620,7 @@ function createCodexQueryHandle(appServer, queryOpts) { done: false, aborted: false, loopStarted: false, - model: queryOpts.model || "gpt-5.5", + model: queryOpts.model || "gpt-5.6-terra", // Track incremental text deltas textBlocks: {}, // itemId -> true (text_start sent) textLengths: {}, // itemId -> last sent length @@ -865,7 +865,7 @@ function createCodexQueryHandle(appServer, queryOpts) { // Start or resume thread var threadParams = { - model: queryOpts.model || "gpt-5.5", + model: queryOpts.model || "gpt-5.6-terra", sandbox: queryOpts.sandboxMode || "workspace-write", approvalPolicy: queryOpts.approvalPolicy || "on-failure", cwd: queryOpts.cwd, @@ -1086,6 +1086,9 @@ function createCodexAdapter(opts) { // slow/failed `initialize` leaves the picker empty and the chip shows the // previous vendor's model. var CODEX_MODELS = [ + "gpt-5.6-terra", + "gpt-5.6-sol", + "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", @@ -1131,7 +1134,7 @@ function createCodexAdapter(opts) { function buildReadyResponse(skillNames) { return { models: _cachedModels, - defaultModel: "gpt-5.5", + defaultModel: "gpt-5.6-terra", skills: skillNames || [], slashCommands: skillNames || [], fastModeState: null, @@ -1428,7 +1431,7 @@ function createCodexAdapter(opts) { throw new Error("[yoke/codex] Adapter not initialized. Call init() first."); } - var model = queryOpts.model || "gpt-5.5"; + var model = queryOpts.model || "gpt-5.6-terra"; var ac = queryOpts.abortController || new AbortController(); var activeEntry = { abort: function() { From b04450d341af2aada27f66f7b5daaf6f11d2ec5a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 20 Jul 2026 03:45:31 +0000 Subject: [PATCH 3/5] Release 2.47.0-beta.1 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d72b55de..32573fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [2.47.0-beta.1](https://github.com/chadbyte/clay/compare/v2.46.0...v2.47.0-beta.1) (2026-07-20) + + +### Features + +* **codex:** add GPT-5.6 sol/terra/luna models ([#384](https://github.com/chadbyte/clay/issues/384)) ([cd56498](https://github.com/chadbyte/clay/commit/cd564984a467926e80e773bc400f3311172bc105)) + # [2.46.0](https://github.com/chadbyte/clay/compare/v2.45.0...v2.46.0) (2026-07-03) diff --git a/package.json b/package.json index edcc9d68..1c7cb48d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clay-server", - "version": "2.46.0", + "version": "2.47.0-beta.1", "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.", "bin": { "clay-server": "./bin/cli.js", From 094eb856817a4ea4bd7dfc6f6506691a3b00492c Mon Sep 17 00:00:00 2001 From: Brandon Date: Tue, 7 Jul 2026 02:36:56 +0000 Subject: [PATCH 4/5] feat(yoke): add Kiro CLI adapter via Agent Client Protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate AWS Kiro CLI as a first-class agent runtime alongside Claude Code and Codex, driven through `kiro-cli acp` (Agent Client Protocol, JSON-RPC 2.0 over stdio) — mirroring the codex app-server transport. New: - lib/yoke/kiro-acp-server.js: child-process JSON-RPC transport - lib/yoke/adapters/kiro.js: YOKE adapter (dynamic model catalog, session lifecycle, session/update event flattening, permission routing, resume, abort) - lib/kiro-defaults.js: single source of truth for Kiro defaults - lib/public/kiro-avatar.svg: branded avatar - docs/guides/KIRO-INTEGRATION*.md: protocol reference + EN/KO summaries Wiring (mirrors every codex touch-point): - yoke/index.js: factory switch, auth (kiro-cli whoami), install detection, createAdapters - sdk-bridge.js: detectInstalledVendors, login command, KIRO adapterOptions, neutral interrupt message - sdk-message-processor.js, project-notifications.js: auth titles - project-sessions.js: GUI-only session mode for kiro - client UI: vendor toggle, new-session buttons (install-gated), effort levels, avatar/name maps Permission requests carry only { toolCallId, title }, so the adapter caches kind/rawInput from the preceding tool_call notification to pass a canonical tool name to canUseTool, keeping the permission whitelist functional. --- docs/guides/KIRO-INTEGRATION-SUMMARY.en.md | 86 ++ docs/guides/KIRO-INTEGRATION-SUMMARY.ko.md | 84 ++ docs/guides/KIRO-INTEGRATION.md | 183 ++++ docs/guides/MODULE_MAP.md | 5 + lib/kiro-defaults.js | 20 + lib/project-notifications.js | 2 +- lib/project-sessions.js | 4 +- lib/public/index.html | 4 + lib/public/kiro-avatar.svg | 14 + lib/public/modules/app-messages.js | 15 +- lib/public/modules/app-panels.js | 5 +- lib/public/modules/app-rendering.js | 2 + lib/public/modules/input.js | 8 +- lib/public/modules/mate-sidebar.js | 6 +- lib/public/modules/sidebar-mates.js | 2 +- lib/public/modules/sidebar-mobile.js | 15 + lib/public/modules/sidebar-sessions.js | 17 + lib/public/modules/tools.js | 2 +- lib/sdk-bridge.js | 21 +- lib/sdk-message-processor.js | 6 +- lib/yoke/adapters/kiro.js | 963 +++++++++++++++++++++ lib/yoke/index.js | 49 +- lib/yoke/kiro-acp-server.js | 257 ++++++ 23 files changed, 1739 insertions(+), 31 deletions(-) create mode 100644 docs/guides/KIRO-INTEGRATION-SUMMARY.en.md create mode 100644 docs/guides/KIRO-INTEGRATION-SUMMARY.ko.md create mode 100644 docs/guides/KIRO-INTEGRATION.md create mode 100644 lib/kiro-defaults.js create mode 100644 lib/public/kiro-avatar.svg create mode 100644 lib/yoke/adapters/kiro.js create mode 100644 lib/yoke/kiro-acp-server.js diff --git a/docs/guides/KIRO-INTEGRATION-SUMMARY.en.md b/docs/guides/KIRO-INTEGRATION-SUMMARY.en.md new file mode 100644 index 00000000..4619e712 --- /dev/null +++ b/docs/guides/KIRO-INTEGRATION-SUMMARY.en.md @@ -0,0 +1,86 @@ +# Kiro CLI Integration — Summary + +> Adds AWS **Kiro CLI** as a first-class agent runtime in Clay, alongside +> Claude Code and Codex, through the YOKE adapter layer. + +## Goal + +Make `kiro-cli` usable inside Clay exactly like Claude Code or Codex: pick the +vendor, create a session, chat, approve tools, and switch models — with no +user-visible difference between runtimes. + +## How it works + +Kiro CLI exposes the **Agent Client Protocol (ACP)** via `kiro-cli acp` — +standard JSON-RPC 2.0 over stdin/stdout, the same editor-agnostic agent +protocol used by Zed. This mirrors the transport strategy Clay already uses for +`codex app-server`. + +``` +Clay session (vendor=kiro) + -> YOKE Kiro Adapter (lib/yoke/adapters/kiro.js) + -> KiroAcpServer (lib/yoke/kiro-acp-server.js) spawn `kiro-cli acp` + -> kiro-cli binary (JSON-RPC 2.0 over stdio) +``` + +### Protocol lifecycle (verified against kiro-cli 2.7.0) + +| Step | Method | Notes | +|------|--------|-------| +| Handshake | `initialize` | negotiates `agentCapabilities` | +| Create | `session/new` | returns `{ sessionId, modes, models }` | +| Resume | `session/load` | replays history | +| Model | `session/set_model` | `{ sessionId, modelId }` | +| Prompt | `session/prompt` | field is **`prompt`** (array of blocks); resolves with `{ stopReason }` | +| Stream | `session/update` | discriminated by `update.sessionUpdate` | +| Approve | `session/request_permission` | server→client request; reply `{ outcome: { outcome: "selected", optionId } }` | +| Abort | `session/cancel` | notification; in-flight prompt resolves `"cancelled"` | + +## Files added + +| File | Purpose | +|------|---------| +| `lib/yoke/kiro-acp-server.js` | Child-process JSON-RPC transport (binary lookup, send/notify/respond, auth-error detection) | +| `lib/yoke/adapters/kiro.js` | YOKE adapter: dynamic model catalog, session lifecycle, `session/update` event flattening, permission routing, resume, abort | +| `lib/kiro-defaults.js` | Single source of truth for Kiro defaults (agent/mode) | +| `lib/public/kiro-avatar.svg` | Branded avatar | +| `docs/guides/KIRO-INTEGRATION.md` | Full protocol reference + gotchas | + +## Files changed (wiring — mirrors every `codex` touch-point) + +- `lib/yoke/index.js` — factory switch, auth (`kiro-cli whoami`), install detection, `createAdapters` +- `lib/sdk-bridge.js` — `detectInstalledVendors`, login command, `KIRO` adapterOptions, neutral interrupt message +- `lib/sdk-message-processor.js`, `lib/project-notifications.js` — auth titles / login command +- `lib/project-sessions.js` — GUI-only session mode for kiro (no TUI adapter) +- Client UI: `index.html` (vendor toggle), `sidebar-sessions.js` / `sidebar-mobile.js` (new-session buttons, install-gated), `app-panels.js` (vendor button + effort levels), `app-rendering.js` / `app-messages.js` / `input.js` / `tools.js` / `mate-sidebar.js` / `sidebar-mates.js` (avatar & name maps) + +## Key design decisions + +- **Dynamic model catalog** (like Claude, unlike Codex's hardcoded list): + `init()` runs `kiro-cli chat --list-models --format json` and filters out + `[Internal]` / `[Deprecated]` entries. Default is the `auto` router model. +- **Permission name recovery**: the `session/request_permission` payload only + carries `{ toolCallId, title }`, so the adapter caches `kind` + `rawInput` + from the preceding `tool_call` notification and passes the canonical tool + name (`Bash`, `Edit`, ...) to `canUseTool`. Without this, Clay's permission + whitelist matching would break. +- **GUI-only**: kiro sessions always run in GUI mode, same as Codex. +- **Per-project adapter**: the ACP process is scoped to a project cwd/slug. + +## Verified end-to-end (against the live binary) + +- Init discovers 15 curated models; default `auto` +- Text streams in real time; `result` emitted with usage +- Model selection works (`session/set_model`) +- Bash tool shows approval with canonical name `Bash` + `{command}`; approve/deny routes through `canUseTool` +- Abort interrupts the turn cleanly +- Registers alongside claude/codex in the daemon adapter path + +## Known gaps + +- Bash `tool_result` content came through empty in the probe (output likely + arrives via a later `tool_call_update` shape); Codex behaves similarly — worth + confirming in the live UI. +- Validated via adapter/daemon-path harnesses, not a live browser session (the + first-run consent wizard requires a TTY). The WebSocket UI paths are wired but + not exercised in a browser. diff --git a/docs/guides/KIRO-INTEGRATION-SUMMARY.ko.md b/docs/guides/KIRO-INTEGRATION-SUMMARY.ko.md new file mode 100644 index 00000000..8d752089 --- /dev/null +++ b/docs/guides/KIRO-INTEGRATION-SUMMARY.ko.md @@ -0,0 +1,84 @@ +# Kiro CLI 통합 — 요약 + +> AWS **Kiro CLI**를 YOKE 어댑터 계층을 통해 Claude Code, Codex와 나란히 +> Clay의 1급 에이전트 런타임으로 추가합니다. + +## 목표 + +`kiro-cli`를 Clay 안에서 Claude Code나 Codex와 똑같이 사용할 수 있게 만듭니다: +벤더 선택, 세션 생성, 대화, 도구 승인, 모델 전환 — 런타임 간 차이를 사용자가 +느끼지 못하도록. + +## 동작 방식 + +Kiro CLI는 `kiro-cli acp`를 통해 **Agent Client Protocol(ACP)**을 노출합니다 — +stdin/stdout 기반의 표준 JSON-RPC 2.0로, Zed 등이 사용하는 에디터 독립적 +에이전트 프로토콜입니다. Clay가 이미 `codex app-server`에 쓰는 트랜스포트 +전략과 동일합니다. + +``` +Clay 세션 (vendor=kiro) + -> YOKE Kiro 어댑터 (lib/yoke/adapters/kiro.js) + -> KiroAcpServer (lib/yoke/kiro-acp-server.js) `kiro-cli acp` 실행 + -> kiro-cli 바이너리 (stdio 기반 JSON-RPC 2.0) +``` + +### 프로토콜 생명주기 (kiro-cli 2.7.0로 검증) + +| 단계 | 메서드 | 비고 | +|------|--------|------| +| 핸드셰이크 | `initialize` | `agentCapabilities` 협상 | +| 생성 | `session/new` | `{ sessionId, modes, models }` 반환 | +| 재개 | `session/load` | 히스토리 재생 | +| 모델 | `session/set_model` | `{ sessionId, modelId }` | +| 프롬프트 | `session/prompt` | 필드명은 **`prompt`**(블록 배열); `{ stopReason }`으로 resolve | +| 스트리밍 | `session/update` | `update.sessionUpdate`로 구분 | +| 승인 | `session/request_permission` | 서버→클라이언트 요청; `{ outcome: { outcome: "selected", optionId } }`로 응답 | +| 중단 | `session/cancel` | 알림(notification); 진행 중 프롬프트는 `"cancelled"`로 resolve | + +## 추가한 파일 + +| 파일 | 목적 | +|------|------| +| `lib/yoke/kiro-acp-server.js` | 자식 프로세스 JSON-RPC 트랜스포트 (바이너리 탐색, send/notify/respond, 인증 오류 감지) | +| `lib/yoke/adapters/kiro.js` | YOKE 어댑터: 동적 모델 목록, 세션 생명주기, `session/update` 이벤트 변환, 권한 라우팅, 재개, 중단 | +| `lib/kiro-defaults.js` | Kiro 기본값(agent/mode) 단일 소스 | +| `lib/public/kiro-avatar.svg` | 브랜드 아바타 | +| `docs/guides/KIRO-INTEGRATION.md` | 전체 프로토콜 참조 + 주의사항 | + +## 수정한 파일 (연결 작업 — 모든 `codex` 접점 미러링) + +- `lib/yoke/index.js` — 팩토리 분기, 인증(`kiro-cli whoami`), 설치 감지, `createAdapters` +- `lib/sdk-bridge.js` — `detectInstalledVendors`, 로그인 명령, `KIRO` adapterOptions, 중립적 중단 메시지 +- `lib/sdk-message-processor.js`, `lib/project-notifications.js` — 인증 타이틀 / 로그인 명령 +- `lib/project-sessions.js` — kiro는 GUI 전용 세션 모드 (TUI 어댑터 없음) +- 클라이언트 UI: `index.html`(벤더 토글), `sidebar-sessions.js` / `sidebar-mobile.js`(새 세션 버튼, 설치 시에만 노출), `app-panels.js`(벤더 버튼 + effort 레벨), `app-rendering.js` / `app-messages.js` / `input.js` / `tools.js` / `mate-sidebar.js` / `sidebar-mates.js`(아바타·이름 맵) + +## 핵심 설계 결정 + +- **동적 모델 목록** (Claude와 동일, Codex의 하드코딩 목록과 다름): + `init()`이 `kiro-cli chat --list-models --format json`을 실행하고 + `[Internal]` / `[Deprecated]` 항목을 필터링합니다. 기본값은 `auto` 라우터 모델. +- **권한 이름 복원**: `session/request_permission` 페이로드에는 + `{ toolCallId, title }`만 담기므로, 어댑터가 직전 `tool_call` 알림의 + `kind` + `rawInput`을 캐시해 정식 도구 이름(`Bash`, `Edit` 등)을 + `canUseTool`에 전달합니다. 이게 없으면 Clay의 권한 화이트리스트 매칭이 깨집니다. +- **GUI 전용**: kiro 세션은 Codex와 마찬가지로 항상 GUI 모드로 동작합니다. +- **프로젝트별 어댑터**: ACP 프로세스는 프로젝트 cwd/slug 단위로 스코프됩니다. + +## 실제 바이너리로 검증 완료 + +- init 시 15개 큐레이션 모델 노출; 기본값 `auto` +- 텍스트 실시간 스트리밍; `result` 이벤트에 사용량 포함 +- 모델 선택 동작 (`session/set_model`) +- Bash 도구가 정식 이름 `Bash` + `{command}`로 승인 UI 표시; 승인/거부가 `canUseTool`로 라우팅 +- 중단(abort)이 턴을 깔끔하게 정리 +- 데몬 어댑터 경로에서 claude/codex와 나란히 등록 + +## 알려진 미비점 + +- 프로브에서 Bash `tool_result` 내용이 비어 있었습니다(출력이 이후 + `tool_call_update` 형태로 오는 것으로 추정); Codex도 유사하게 동작 — 실제 + UI에서 확인 권장. +- 최초 실행 동의 마법사가 TTY를 요구하여, 브라우저 세션 대신 어댑터/데몬 경로 + 하네스로 검증했습니다. WebSocket UI 경로는 연결됐지만 브라우저에서는 미검증. diff --git a/docs/guides/KIRO-INTEGRATION.md b/docs/guides/KIRO-INTEGRATION.md new file mode 100644 index 00000000..f21dcd6d --- /dev/null +++ b/docs/guides/KIRO-INTEGRATION.md @@ -0,0 +1,183 @@ +# Kiro Integration Guide + +How Clay integrates with the AWS **Kiro CLI** via the Agent Client Protocol +(ACP). Read this before changing anything in `lib/yoke/adapters/kiro.js` or +`lib/yoke/kiro-acp-server.js`. + +--- + +## Architecture Overview + +``` +Clay session (vendor=kiro) + | + v +YOKE Kiro Adapter (lib/yoke/adapters/kiro.js) + | + v +KiroAcpServer (lib/yoke/kiro-acp-server.js) + | spawn `kiro-cli acp` + v stdin/stdout JSON-RPC 2.0 (bidirectional) +kiro-cli binary (~/.local/bin/kiro-cli) + | + +--> Kiro's built-in tools (fs, shell, search, ...) + +--> MCP servers (from session/new mcpServers + ~/.kiro/settings/mcp.json) +``` + +Kiro CLI implements **ACP** — the same open, editor-agnostic agent protocol +used by Zed. This is structurally the same transport strategy as the Codex +`app-server` path: line-delimited JSON-RPC where the child both answers our +requests and initiates its own (streaming updates + permission requests). + +--- + +## Why ACP, Not `chat --no-interactive` + +`kiro-cli chat --no-interactive ""` is a one-shot pipe: it takes a +single prompt, streams plain text, and exits. It cannot relay tool-approval +requests back to the client and has no multi-turn session. That is unusable for +Clay's interactive UI. + +`kiro-cli acp` keeps stdin open, supports multi-turn prompts, streams +structured `session/update` events, and sends `session/request_permission` +requests we can route through Clay's `canUseTool`. Always use ACP mode. + +--- + +## Key Files + +| File | Purpose | +|------|---------| +| `lib/yoke/adapters/kiro.js` | YOKE adapter. Init, model catalog, createQuery, event flattening, permission routing, abort. | +| `lib/yoke/kiro-acp-server.js` | Child-process manager. Spawn `kiro-cli acp`, stdin/stdout JSON-RPC, request-id tracking, auth-error detection. | +| `lib/kiro-defaults.js` | **Single source of truth** for Kiro defaults (agent/mode). Do not duplicate elsewhere. | + +--- + +## ACP Protocol (verified against kiro-cli 2.7.0) + +### Handshake +``` +initialize { protocolVersion: 1, clientCapabilities: { fs: {...} } } + -> { protocolVersion, agentCapabilities, agentInfo } +``` + +### Session lifecycle +``` +session/new { cwd, mcpServers: [] } -> { sessionId, modes, models } +session/load { sessionId, cwd, mcpServers } -> replays history, then {} (resume) +session/set_model { sessionId, modelId } -> {} +session/set_mode { sessionId, modeId } -> {} +session/prompt { sessionId, prompt: [{ type:"text", text }] } + ...streams session/update notifications... + -> { stopReason: "end_turn" | "cancelled" | ... } (request resolves at turn end) +session/cancel { sessionId } (notification; in-flight prompt resolves "cancelled") +``` + +> The prompt content field is **`prompt`** (an array of content blocks), not +> `content`. Verified by driving the real binary. + +### Streaming updates (`session/update`, discriminated by `update.sessionUpdate`) +| sessionUpdate | Meaning | yokeType emitted | +|---------------|---------|------------------| +| `agent_message_chunk` | assistant text delta | `text_start` / `text_delta` | +| `agent_thought_chunk` | reasoning delta | `thinking_start` / `thinking_delta` | +| `tool_call` | tool announced (has `kind`, `rawInput`) | `tool_start` + `tool_executing` | +| `tool_call_update` | tool progress/completion | `tool_result` | +| `plan` | plan entries | `plan_updated` | +| `usage_update` | token usage (`used`, `size`) | (tracked for context bar) | + +### Permission requests (server -> client, has an `id`) +``` +session/request_permission { + sessionId, toolCall: { toolCallId, title }, + options: [ {optionId,kind:"allow_once"}, {optionId,kind:"allow_always"}, {optionId,kind:"reject_once"} ] +} +``` +Response: `{ outcome: { outcome: "selected", optionId } }` or +`{ outcome: { outcome: "cancelled" } }`. + +--- + +## Critical Patterns + +### 1. Permission payload lacks kind/rawInput — cache from `tool_call` +The `session/request_permission` payload only carries `{ toolCallId, title }` +(e.g. title `"Running: echo hi"`). Clay's permission whitelist keys on +canonical tool names (`Bash`, `Edit`, ...), so the adapter caches `kind` + +`rawInput` from the preceding `tool_call` notification in `state.toolMeta` and +uses it to build the `canUseTool(toolName, input)` call. Without this, +auto-approval matching breaks. See `state.toolMeta` in `kiro.js`. + +### 2. Permission response is a nested `outcome` object +`{ outcome: { outcome: "selected", optionId } }` — the doubly-nested `outcome` +is intentional and matches the ACP spec. A bare string or single-level object +is treated as a rejection. + +### 3. Turn ends via the `session/prompt` response, not an event +Unlike Codex (`turn/completed` notification), ACP resolves the original +`session/prompt` **request** with `{ stopReason }`. The adapter's query loop +awaits that resolution, then emits the YOKE `result` event. + +### 4. Abort is a notification +`handle.abort()` sends `session/cancel` (a notification) and ends the iterator +immediately. sdk-bridge's post-loop code sends the "interrupted" message + +`done` when `session.taskStopRequested` is set — the adapter does not emit them +itself. This mirrors the Claude/Codex abort pattern. + +### 5. Model catalog is dynamic (like Claude, unlike Codex) +`init()` runs `kiro-cli chat --list-models --format json` and filters out +`[Internal]` / `[Deprecated]` entries for a clean picker. The `auto` router +model is the default. If the CLI call fails, a minimal fallback set is used. + +### 6. No TUI adapter +Kiro sessions are always GUI mode (`project-sessions.js` forces `gui`), same as +Codex. There is no `kiro --session-id` TUI shell path. + +### 7. Auth +`kiro-cli whoami` (exit 0 when logged in) drives install/auth detection in +`yoke/index.js` and `sdk-bridge.detectInstalledVendors`. The login command is +`kiro-cli login`. Auth failures on stderr (401 / expired token) are mapped to +the neutral `auth_required` yokeType. + +--- + +## Capabilities + +```js +{ + thinking: true, // agent_thought_chunk + betas: false, + rewind: false, + sessionResume: true, // session/load + promptSuggestions: false, + elicitation: false, + fileCheckpointing: false, + contextCompacting: true, // /compact exists + toolPolicy: ["ask", "allow-all"], +} +``` + +--- + +## Testing Checklist + +When changing the Kiro adapter: + +- [ ] `init()` returns a filtered model list (no `[Internal]`/`[Deprecated]`) +- [ ] Text response streams in real time (`agent_message_chunk`) +- [ ] Model selection works (`session/set_model`) +- [ ] Bash tool shows approval UI with canonical name `Bash` + `{command}` +- [ ] Approve/deny routes through `canUseTool` correctly +- [ ] Stop button interrupts the turn and clears the typing indicator +- [ ] Switching between Kiro and Claude/Codex sessions shows the right vendor UI +- [ ] `kiro-cli whoami` logged-out state surfaces the auth_required flow + +--- + +## When Things Get Weird + +1. Check server console for `[yoke/kiro]` and `[kiro-acp-server]` logs. +2. Drive the protocol directly: `printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' | kiro-cli acp` (keep stdin open with a trailing sleep). +3. `kiro-cli chat --list-models --format json` to inspect the model catalog. +4. `kiro-cli whoami` to check auth. diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index f70a488d..d4146c69 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -54,6 +54,7 @@ Wires all modules, sets up session manager and SDK bridge, dispatches messages. | `sdk-message-queue.js` | Async iterable message queue for streaming input to SDK | | `sdk-message-processor.js` | SDK stream event processing (message_start, content_block_*), sub-agent message routing | | `codex-defaults.js` | Codex-specific default values (sandbox, approval, web search). **Single source of truth** - do not duplicate elsewhere | +| `kiro-defaults.js` | Kiro-specific default values (agent/mode). **Single source of truth** - do not duplicate elsewhere | | `mates.js` | Mate CRUD, builtin mate management, atomic section enforcement, migration | | `mates-prompts.js` | System section enforcers (team, session memory, sticky notes, project registry, debate), marker constants | | `mates-knowledge.js` | Common knowledge registry (promote/depromote, cross-mate file sharing) | @@ -76,12 +77,16 @@ YOKE is the vendor-agnostic interface layer. Each adapter implements the same co | `yoke/adapters/claude.js` | Claude adapter using `@anthropic-ai/claude-agent-sdk`. In-process + worker (OS user isolation) paths | | `yoke/adapters/codex.js` | Codex adapter using `codex app-server` JSON-RPC protocol. Handles approval events, skill injection, MCP bridge config | | `yoke/codex-app-server.js` | Codex `app-server` child process manager. JSON-RPC 2.0 over stdin/stdout, request ID tracking, event routing | +| `yoke/adapters/kiro.js` | Kiro adapter using `kiro-cli acp` (Agent Client Protocol). Dynamic model catalog, event flattening (session/update), permission routing, session resume | +| `yoke/kiro-acp-server.js` | Kiro `acp` child process manager. JSON-RPC 2.0 over stdin/stdout, request ID tracking, auth-error detection | | `yoke/mcp-bridge-server.js` | Stdio MCP server spawned by Codex. Proxies tool list/call to Clay via HTTP at `/api/mcp-bridge` | **When adding a new vendor**: implement the YOKE interface, register in `yoke/index.js` createAdapter switch. Do not add vendor-specific logic outside the adapter. **For Codex-specific patterns and gotchas**: see [CODEX-INTEGRATION.md](./CODEX-INTEGRATION.md). +**For Kiro-specific patterns and gotchas**: see [KIRO-INTEGRATION.md](./KIRO-INTEGRATION.md). + ### Server Modules (lib/server-*.js) server.js is a thin router. It wires all server modules, sets up HTTP/WS, and dispatches requests. diff --git a/lib/kiro-defaults.js b/lib/kiro-defaults.js new file mode 100644 index 00000000..645586ee --- /dev/null +++ b/lib/kiro-defaults.js @@ -0,0 +1,20 @@ +// Kiro-specific default values. Single source of truth — do not duplicate +// elsewhere. Consumed by the server-side vendor plumbing and sent to clients +// via the config state so the UI renders the right controls. + +var KIRO_DEFAULTS = { + // Default agent/mode used when a Kiro session starts. "kiro_default" is the + // general-purpose Kiro CLI agent. + mode: "kiro_default", +}; + +function getKiroConfig(sm) { + return { + mode: (sm && sm.kiroMode) || KIRO_DEFAULTS.mode, + }; +} + +module.exports = { + KIRO_DEFAULTS: KIRO_DEFAULTS, + getKiroConfig: getKiroConfig, +}; diff --git a/lib/project-notifications.js b/lib/project-notifications.js index 8c4c9375..e3f59277 100644 --- a/lib/project-notifications.js +++ b/lib/project-notifications.js @@ -21,7 +21,7 @@ function generateId() { var formatters = { auth_required: function (data) { var vendor = data.vendor || "claude"; - var title = data.title || ((vendor === "codex" ? "Codex" : "Claude Code") + " is not logged in"); + var title = data.title || ((vendor === "codex" ? "Codex" : (vendor === "kiro" ? "Kiro CLI" : "Claude Code")) + " is not logged in"); return { type: "auth_required", title: title, diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 08dbdcd0..408351db 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -424,13 +424,13 @@ function attachSessions(ctx) { if (ws._clayUser && usersModule.isMultiUser()) sessionOpts.ownerId = ws._clayUser.id; if (msg.sessionVisibility) sessionOpts.sessionVisibility = msg.sessionVisibility; if (msg.vendor) sessionOpts.vendor = msg.vendor; - // Mode resolution: codex sessions are always GUI (no TUI adapter). + // Mode resolution: codex and kiro sessions are always GUI (no TUI adapter). // Claude sessions honor the explicit msg.mode if provided, otherwise // fall back to the user's claudeOpenMode preference. This is what // makes the sidebar's "Claude" icon button create the right kind of // session without the client needing to know the preference. var requestedMode; - if (msg.vendor === "codex") { + if (msg.vendor === "codex" || msg.vendor === "kiro") { requestedMode = "gui"; } else if (msg.mode === "tui" || msg.mode === "gui") { requestedMode = msg.mode; diff --git a/lib/public/index.html b/lib/public/index.html index fc853b02..2a709a67 100644 --- a/lib/public/index.html +++ b/lib/public/index.html @@ -513,6 +513,10 @@

Codex Codex +