lib/daemon.js
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
var saveDaemonSetting = require("./daemon-settings").saveDaemonSetting;
// --- Node version check ---
var nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
if (nodeMajor < 20) {
console.error("\x1b[31m[clay] Node.js 20+ is required (current: " + process.version + ")\x1b[0m");
console.error("[clay] The Claude Agent SDK 0.2.40+ requires Node 20 for Symbol.dispose support.");
console.error("");
console.error(" Upgrade Node: nvm install 22 && nvm use 22");
process.exit(78); // EX_CONFIG — fatal config error, don't auto-restart
}
// Polyfill Symbol.dispose/asyncDispose if missing (Node 20.x may not have it)
if (!Symbol.dispose) Symbol.dispose = Symbol("Symbol.dispose");
if (!Symbol.asyncDispose) Symbol.asyncDispose = Symbol("Symbol.asyncDispose");
// Increase listener limit for projects with many worktrees
process.setMaxListeners(50);
// Remove CLAUDECODE env var so the SDK can spawn Claude Code child processes
// (prevents "cannot be launched inside another Claude Code session" error)
require("./yoke/host-environment").prepare(process.env);
var fs = require("fs");
var path = require("path");
var newSessionDefault = require("./new-session-default");
var { loadConfig, saveConfig, socketPath, generateSlug, syncClayrc, removeFromClayrc, writeCrashInfo, readCrashInfo, clearCrashInfo, isPidAlive, clearStaleConfig, REAL_HOME } = require("./config");
var { createIPCServer } = require("./ipc");
var { createServer, generateAuthToken } = require("./server");
var osUsersMod = require("./os-users");
var { scheduleOsUserDiagnosticsAsync } = require("./os-user-diagnostics");
var { checkAclSupport, grantProjectAccess, revokeProjectAccess, provisionAllUsers, provisionLinuxUser, grantAllUsersAccess, deactivateLinuxUser, ensureProjectsDir } = osUsersMod;
var usersModule = require("./users");
var { createWorktree, isWorktree } = require("./worktree");
var mates = require("./mates");
var mateKnowledgeMigration = require("./mate-knowledge-migration");
var { isWorktreeSlug, scanAndRegisterWorktrees, rescanWorktrees, cleanupWorktreesForParent, getFilteredRemovedProjects, registerWorktreeSlug, unregisterWorktreeSlug } = require("./daemon-projects");
var daemonProjectAccess = require("./daemon-project-access");
var projectLogContext = require("./project-log-context");
var daemonVersion = require("../package.json").version;
var configFile = process.env.CLAY_CONFIG || process.env.CLAUDE_RELAY_CONFIG || require("./config").configPath();
var config;
try {
config = JSON.parse(fs.readFileSync(configFile, "utf8"));
} catch (e) {
console.error("[daemon] Failed to read config:", e.message);
process.exit(1);
}
console.log("[daemon] v" + daemonVersion + " PID " + process.pid + " config " + configFile);
console.log("[daemon] Config: " + configFile);
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);
}
config = require("./project-ownership").repair(config, usersModule.getAllUsers(), saveConfig, osUsersMod.resolveOsUserInfo);
if (projectLogContext.initializeProjectKnowledge(config)) {
saveConfig(config);
console.log("[daemon] Assigned stable knowledge identities to existing projects");
}
// --- OS users mode: check required system dependencies ---
// User-switched processes inherit the account groups, like a normal login.
if (config.osUsers) {
var checkExec = require("child_process").execFileSync;
var missing = [];
try { checkExec("which", ["setfacl"], { stdio: "ignore" }); } catch (e) { missing.push("acl (setfacl)"); }
try { checkExec("which", ["git"], { stdio: "ignore" }); } catch (e) { missing.push("git"); }
try { checkExec("which", ["useradd"], { stdio: "ignore" }); } catch (e) { missing.push("useradd"); }
if (missing.length > 0) {
console.error("[daemon] OS users mode requires missing system packages: " + missing.join(", "));
console.error("[daemon] Install with: sudo apt install " + missing.map(function (m) { return m.split(" ")[0]; }).join(" "));
process.exit(78); // EX_CONFIG
}
console.log("[daemon] Sessions inherit each user's supplementary groups when setpriv is available.");
}
// --- TLS ---
var tlsOptions = null;
if (config.tls) {
// 1. Check builtin cert (shipped with package)
var builtinKeyPath = path.join(__dirname, "certs", "privkey.pem");
var builtinCertPath = path.join(__dirname, "certs", "fullchain.pem");
// 2. User cert (mkcert, etc.)
var os = require("os");
var certDir = path.join(process.env.CLAY_HOME || process.env.CLAUDE_RELAY_HOME || path.join(REAL_HOME, ".clay"), "certs");
var userKeyPath = path.join(certDir, "key.pem");
var userCertPath = path.join(certDir, "cert.pem");
// Cert fetched at runtime from the clay.studio endpoint (always current),
// cached by the CLI before forking us. Prefer it over the baked copy.
var fetched = null;
try { fetched = require("./clay-studio-cert").cachedCertFiles(); } catch (e) {}
var keyPath, certPath;
if (config.builtinCert !== false && fetched) {
keyPath = fetched.key;
certPath = fetched.cert;
config.builtinCert = true;
} else if (config.builtinCert !== false && fs.existsSync(builtinKeyPath) && fs.existsSync(builtinCertPath)) {
keyPath = builtinKeyPath;
certPath = builtinCertPath;
config.builtinCert = true;
} else {
keyPath = userKeyPath;
certPath = userCertPath;
}
try {
tlsOptions = {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath),
};
} catch (e) {
console.error("[daemon] TLS cert not found, falling back to HTTP");
}
}
var caRoot = null;
// --- Resolve LAN IP for share URL ---
var os2 = require("os");
var lanIp = (function () {
var ifaces = os2.networkInterfaces();
for (var addrs of Object.values(ifaces)) {
for (var i = 0; i < addrs.length; i++) {
if (addrs[i].family === "IPv4" && !addrs[i].internal && addrs[i].address.startsWith("100.")) return addrs[i].address;
}
}
for (var addrs of Object.values(ifaces)) {
for (var i = 0; i < addrs.length; i++) {
if (addrs[i].family === "IPv4" && !addrs[i].internal) return addrs[i].address;
}
}
return null;
})();
// getFilteredRemovedProjects extracted to daemon-projects.js
// --- Create multi-project server ---
var listenHost = config.host || "0.0.0.0";
function rememberedProjectKnowledgeId(projectPath) {
var removed = config.removedProjects || [];
for (var i = removed.length - 1; i >= 0; i--) {
if (removed[i].path === projectPath && projectLogContext.validId(removed[i].projectKnowledgeId)) {
return removed[i].projectKnowledgeId;
}
}
return projectLogContext.createProjectKnowledgeId();
}
function projectRuntimeOptions(project) {
return { projectKnowledgeId: project.projectKnowledgeId };
}
function worktreeOsUserInfo(project, requestUser) {
if (!config.osUsers) return null;
var owner = project && project.ownerId ? usersModule.findUserById(project.ownerId) : null;
var executionUser = owner && owner.linuxUser ? owner : requestUser;
if (!executionUser || !executionUser.linuxUser) {
throw new Error("The project owner does not have a provisioned Linux account");
}
return osUsersMod.resolveOsUserInfo(executionUser.linuxUser);
}
function registerProjectWorktrees(project, requestUser) {
try {
var osUserInfo = worktreeOsUserInfo(project, requestUser);
scanAndRegisterWorktrees(relay, project.path, project.slug, project.icon, project.ownerId, project.projectKnowledgeId, osUserInfo, config, function () { return config; }, worktreeOsUserInfo);
} catch (e) {
console.error("[daemon] Failed to scan worktrees for " + project.slug + ": " + e.message);
}
}
var projectLifecycle = require("./daemon-project-lifecycle").attachProjectLifecycle({
getConfig: function () { return config; }, setConfig: function (next) { config = next; }, saveConfig: saveConfig,
getRelay: function () { return relay; }, resolveIdentity: osUsersMod.resolveOsUserInfo,
findUser: usersModule.findUserById, registerWorktrees: registerProjectWorktrees,
});
var lifecycleCallbacks = require("./daemon-project-lifecycle-callbacks").attachLifecycleCallbacks({ service: projectLifecycle });
var worktreeControls = require("./daemon-worktree-controls").attachWorktreeControls({
getConfig: function () { return config; }, setConfig: function (next) { config = next; }, saveConfig: saveConfig,
getRelay: function () { return relay; }, findUser: usersModule.findUserById, users: usersModule,
osIdentity: worktreeOsUserInfo, syncRecent: syncClayrc, storageDir: require("./config").CONFIG_DIR, unregister: unregisterWorktreeSlug,
});
var ownershipRecovery = require('./project-ownership-recovery').createRecoveryService({
getConfig: function () { return config; }, getAccounts: usersModule.getAllUsers,
resolveIdentity: osUsersMod.resolveOsUserInfo, persist: saveConfig,
publish: function (next, slug, ownerId) {
relay.forEachProject(function (project, projectSlug) {
if (projectSlug === slug || daemonProjectAccess.parentSlugFor(projectSlug) === slug) project.setProjectOwner(ownerId);
});
config = next;
relay.refreshProjectAccess();
}
});
var relay = createServer({
ownershipRecovery: ownershipRecovery,
worktreeControls: worktreeControls,
projectLifecycle: projectLifecycle,
tlsOptions: tlsOptions,
caPath: caRoot,
builtinCert: config.builtinCert || false,
pinHash: config.pinHash || null,
port: config.port,
debug: config.debug || false,
dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
osUsers: config.osUsers || false,
lanHost: lanIp ? lanIp + ":" + config.port : null,
getRemovedProjects: function (userId) { return getFilteredRemovedProjects(config, userId); },
onAddProject: lifecycleCallbacks.onAddProject,
onCreateProject: lifecycleCallbacks.onCreateProject,
onCloneProject: function (cloneUrl, wsUser, callback) {
wsUser = wsUser && usersModule.findUserById(wsUser.id);
if (usersModule.isMultiUser() && !wsUser) { callback({ ok: false, error: "A valid account is required." }); return; }
var os = require("os");
var spawn = require("child_process").spawn;
var execFileSync = require("child_process").execFileSync;
var baseDir;
if (config.osUsers) {
baseDir = "/var/clay/projects";
} else {
baseDir = config.projectsDir || path.join(REAL_HOME, "clay-projects");
}
// Derive slug from repo URL
var repoName = cloneUrl.replace(/\.git$/, "").split("/").pop() || "project";
var slugs = config.projects.map(function (p) { return p.slug; });
var slug = generateSlug(path.join(baseDir, repoName), slugs);
var targetDir;
var directoryClaim;
var settled = false;
function finish(result) { if (settled) return; settled = true; callback(result); }
function cleanup() { if (directoryClaim) { try { directoryClaim.cleanup(); } catch (error) {} } }
// Clone as user to use their git credentials
var spawnOpts = { cwd: baseDir };
if (config.osUsers && wsUser && wsUser.linuxUser) {
try {
spawnOpts.uid = parseInt(execFileSync("id", ["-u", wsUser.linuxUser], { encoding: "utf8", stdio: "pipe" }).trim(), 10);
spawnOpts.gid = parseInt(execFileSync("id", ["-g", wsUser.linuxUser], { encoding: "utf8", stdio: "pipe" }).trim(), 10);
spawnOpts.env = Object.assign({}, process.env, {
HOME: "/home/" + wsUser.linuxUser,
USER: wsUser.linuxUser
});
} catch (e) {}
}
try {
directoryClaim = require("./project-directory-claim").claim({ baseDir: baseDir, slug: slug, projects: config.projects, userId: wsUser && wsUser.id });
targetDir = directoryClaim.path;
directoryClaim.assertOwned();
if (config.osUsers && wsUser && wsUser.linuxUser && spawnOpts.uid != null) fs.chownSync(targetDir, spawnOpts.uid, spawnOpts.gid);
directoryClaim.assertOwned();
} catch (error) {
cleanup();
finish({ ok: false, error: "Failed to prepare project directory: " + error.message });
return;
}
var proc;
try {
directoryClaim.assertOwned();
var cloneSpawn = osUsersMod.wrapSpawnAsUser("git", ["clone", cloneUrl, targetDir], spawnOpts);
proc = spawn(cloneSpawn.command, cloneSpawn.args, cloneSpawn.options);
} catch (error) { cleanup(); finish({ ok: false, error: "Failed to start git clone: " + error.message }); return; }
var stderrBuf = "";
proc.stderr.on("data", function (chunk) { stderrBuf += chunk.toString(); });
// 5 minute timeout
var cloneTimeout = setTimeout(function () {
proc.kill("SIGTERM");
}, 5 * 60 * 1000);
proc.on("close", function (code) {
clearTimeout(cloneTimeout);
if (settled) return;
try { directoryClaim.assertOwned(); } catch (error) { finish({ ok: false, error: error.message }); return; }
if (code !== 0) {
cleanup();
var errMsg = stderrBuf.trim().split("\n").pop() || "Clone failed (exit code " + code + ")";
finish({ ok: false, error: errMsg });
return;
}
// chown and restrict permissions if osUsers
if (config.osUsers && wsUser && wsUser.linuxUser) {
try {
directoryClaim.assertOwned();
fs.chmodSync(targetDir, 0o700);
execFileSync("chown", ["-R", wsUser.linuxUser + ":" + wsUser.linuxUser, targetDir]);
} catch (e) {}
}
if (usersModule.isMultiUser() && (!wsUser || !usersModule.findUserById(wsUser.id))) { finish({ ok: false, error: "The creating account is no longer available." }); return; }
// Register project - creator always becomes owner
// Creator always becomes owner, default private
var projectEntry = { path: targetDir, slug: slug, addedAt: Date.now(), visibility: "private" };
projectEntry.projectKnowledgeId = projectLogContext.createProjectKnowledgeId();
if (wsUser && wsUser.id) {
projectEntry.ownerId = wsUser.id;
}
try {
directoryClaim.assertOwned();
config = require("./project-ownership").register(config, projectEntry, usersModule.getAllUsers(), saveConfig, osUsersMod.resolveOsUserInfo);
} catch (error) { finish({ ok: false, error: "Failed to register project: " + error.message }); return; }
relay.addProject(targetDir, slug, null, null, projectEntry.ownerId || null, null, projectRuntimeOptions(projectEntry));
try { syncClayrc(config.projects); } catch (e) {}
console.log("[daemon] Cloned project:", slug, "→", targetDir);
if (config.osUsers && wsUser && wsUser.linuxUser) {
grantProjectAccess(targetDir, wsUser.linuxUser);
}
relay.broadcastAll({
type: "projects_updated",
projects: relay.getProjects(),
projectCount: config.projects.length,
});
finish({ ok: true, slug: slug });
});
proc.on("error", function (err) {
clearTimeout(cloneTimeout);
if (settled) return;
cleanup();
finish({ ok: false, error: "Failed to start git clone: " + err.message });
});
},
onRemoveProject: function (slug, userId) {
if (isWorktreeSlug(slug)) return { ok: false, error: "Use the reviewed Exclude or Delete worktree control." };
return lifecycleCallbacks.onRemoveProject(slug, userId);
},
onReorderProjects: function (slugs) {
// Build a slug->project map from current projects
var projectMap = {};
for (var j = 0; j < config.projects.length; j++) {
projectMap[config.projects[j].slug] = config.projects[j];
}
// Reorder based on the slugs array
var reordered = [];
for (var k = 0; k < slugs.length; k++) {
if (projectMap[slugs[k]]) {
reordered.push(projectMap[slugs[k]]);
delete projectMap[slugs[k]];
}
}
// Append any remaining projects not in slugs (safety)
var remaining = Object.keys(projectMap);
for (var m = 0; m < remaining.length; m++) {
reordered.push(projectMap[remaining[m]]);
}
config.projects = reordered;
saveConfig(config);
try { syncClayrc(config.projects); } catch (e) {}
// Also reorder the in-memory Map so getProjects() returns the new order
relay.reorderProjects(slugs);
relay.broadcastAll({
type: "projects_updated",
projects: relay.getProjects(),
projectCount: config.projects.length,
});
return { ok: true };
},
onSetProjectTitle: function (slug, newTitle) {
if (isWorktreeSlug(slug)) { relay.setProjectTitle(slug, newTitle); return { ok: true }; }
return lifecycleCallbacks.onSetProjectTitle(slug, newTitle);
},
onSetProjectIcon: function (slug, newIcon, userId) {
return worktreeControls.setIcon(slug, newIcon, userId);
},
onSetProjectSessionVisibilityDefault: function (slug, visibility) {
return projectLifecycle.update(slug, "session_visibility", visibility);
},
onProjectOwnerChanged: function (slug, ownerId) {
if (!config.osUsers) return projectLifecycle.update(slug, "owner", ownerId);
if (slug.indexOf("mate-") === 0) return {error:"Mate ownership cannot be transferred."};
if (!ownerId || !usersModule.findUserById(ownerId)) return { error: "A valid owner is required." };
console.log("[daemon] onProjectOwnerChanged:", slug, "→", ownerId);
var previousConfig = JSON.parse(JSON.stringify(config));
var oldOwnerId = null;
var projectIdx = -1;
for (var oi = 0; oi < config.projects.length; oi++) {
if (config.projects[oi].slug === slug) {
oldOwnerId = config.projects[oi].ownerId || null;
projectIdx = oi;
config.projects[oi].ownerId = ownerId;
break;
}
}
try { saveConfig(config); } catch (error) { config = previousConfig; throw error; }
relay.forEachProject(function (project, projectSlug) {
if (projectSlug === slug || daemonProjectAccess.parentSlugFor(projectSlug) === slug) project.setProjectOwner(ownerId);
});
// OS users mode: revoke old owner ACL, grant new owner ACL
if (config.osUsers && projectIdx >= 0) {
var projectPath = config.projects[projectIdx].path;
var allowed = config.projects[projectIdx].allowedUsers || [];
var visibility = config.projects[projectIdx].visibility || "public";
// Revoke old owner (if not in allowedUsers and project is not public)
if (oldOwnerId && oldOwnerId !== ownerId) {
var oldOwner = usersModule.findUserById(oldOwnerId);
if (oldOwner && oldOwner.linuxUser && allowed.indexOf(oldOwnerId) === -1 && visibility !== "public") {
revokeProjectAccess(projectPath, oldOwner.linuxUser);
}
}
// Grant new owner
if (ownerId) {
var newOwner = usersModule.findUserById(ownerId);
console.log("[daemon] Owner grant ACL:", ownerId, "linuxUser:", newOwner && newOwner.linuxUser, "path:", projectPath);
if (newOwner && newOwner.linuxUser) {
grantProjectAccess(projectPath, newOwner.linuxUser);
}
}
}
relay.broadcastAll({
type: "projects_updated",
projects: relay.getProjects(),
projectCount: config.projects.length,
});
if (relay.refreshProjectAccess) relay.refreshProjectAccess();
return { ok: true };
},
onGetProjectEnv: function (slug) {
for (var ei = 0; ei < config.projects.length; ei++) {
if (config.projects[ei].slug === slug) {
return { envrc: config.projects[ei].envrc || "" };
}
}
return { envrc: "" };
},
onSetProjectEnv: function (slug, envrc) {
return lifecycleCallbacks.onSetProjectEnv(slug, envrc);
},
onGetSharedEnv: function () {
return { envrc: config.sharedEnv || "" };
},
onSetSharedEnv: function (envrc) {
saveDaemonSetting(config, "sharedEnv", envrc || undefined, saveConfig);
return { ok: true };
},
onGetServerDefaultEffort: function () {
return { effort: config.defaultEffort || null };
},
onSetServerDefaultEffort: function (effort) {
if (effort) {
config.defaultEffort = effort;
} else {
delete config.defaultEffort;
}
saveConfig(config);
return { ok: true };
},
onGetProjectDefaultEffort: function (slug) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
return { effort: config.projects[i].defaultEffort || null };
}
}
return { effort: null };
},
onSetProjectDefaultEffort: function (slug, effort) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
if (effort) {
config.projects[i].defaultEffort = effort;
} else {
delete config.projects[i].defaultEffort;
}
saveConfig(config);
return { ok: true };
}
}
return { ok: false, error: "Project not found" };
},
onGetServerDefaultModel: function () {
return { model: config.defaultModel || null };
},
onSetServerDefaultModel: function (model) {
if (model) {
config.defaultModel = model;
} else {
delete config.defaultModel;
}
saveConfig(config);
return { ok: true };
},
onGetProjectDefaultModel: function (slug) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
return { model: config.projects[i].defaultModel || null };
}
}
return { model: null };
},
onSetProjectDefaultModel: function (slug, model) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
if (model) {
config.projects[i].defaultModel = model;
} else {
delete config.projects[i].defaultModel;
}
saveConfig(config);
return { ok: true };
}
}
return { ok: false, error: "Project not found" };
},
onGetProjectLastVendor: function (slug) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
return { vendor: config.projects[i].lastVendor || null };
}
}
return { vendor: null };
},
// The provider the project's inline New session row preselects. Stored on
// the project's own config entry only.
onGetProjectNewSessionDefault: function (slug) {
return newSessionDefault.getNewSessionDefault(config, slug);
},
onSetProjectNewSessionDefault: function (slug, preference) {
return newSessionDefault.setNewSessionDefault(config, slug, preference, saveConfig);
},
onSetProjectLastVendor: function (slug, vendor) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
if (config.projects[i].lastVendor === vendor) return { ok: true };
if (vendor) {
config.projects[i].lastVendor = vendor;
} else {
delete config.projects[i].lastVendor;
}
saveConfig(config);
return { ok: true };
}
}
return { ok: false, error: "Project not found" };
},
onGetProjectMcpServers: function (slug) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
return config.projects[i].enabledMcpServers || [];
}
}
return [];
},
onSetProjectMcpServers: function (slug, servers) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
if (servers && servers.length > 0) {
config.projects[i].enabledMcpServers = servers;
} else {
delete config.projects[i].enabledMcpServers;
}
saveConfig(config);
return;
}
}
},
onGetServerDefaultMode: function () {
return { mode: config.defaultMode || null };
},
onSetServerDefaultMode: function (mode) {
if (mode) {
config.defaultMode = mode;
} else {
delete config.defaultMode;
}
saveConfig(config);
return { ok: true };
},
onGetProjectDefaultMode: function (slug) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
return { mode: config.projects[i].defaultMode || null };
}
}
return { mode: null };
},
onSetProjectDefaultMode: function (slug, mode) {
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
if (mode) {
config.projects[i].defaultMode = mode;
} else {
delete config.projects[i].defaultMode;
}
saveConfig(config);
return { ok: true };
}
}
return { ok: false, error: "Project not found" };
},
onGetAllowedOrigins: function () { return config.allowedOrigins || []; },
onSetAllowedOrigins: function (origins) {
require("./network-origins").saveAllowedOrigins(config, origins, saveConfig);
},
onGetDaemonConfig: function () {
return {
port: config.port,
tls: !!tlsOptions,
debug: !!config.debug,
headless: !!config.headless,
keepAwake: !!config.keepAwake,
autoContinueOnRateLimit: !!config.autoContinueOnRateLimit,
osUsers: !!config.osUsers,
chatLayout: config.chatLayout || "channel",
matesEnabled: config.matesEnabled === true,
capsulesEnabled: config.capsulesEnabled === true,
agentBehavior: require("./agent-behavior-schema").normalize(config.agentBehavior),
pinEnabled: !!config.pinHash,
platform: process.platform,
hostname: os2.hostname(),
lanIp: lanIp || null,
updateChannel: config.updateChannel || "stable",
imageRetentionDays: config.imageRetentionDays !== undefined ? config.imageRetentionDays : 7,
};
},
onSetImageRetention: function (days) {
var d = parseInt(days, 10);
if (isNaN(d) || d < 0) d = 7;
saveDaemonSetting(config, "imageRetentionDays", d, saveConfig);
console.log("[daemon] Image retention:", d === 0 ? "forever" : d + " days");
return { ok: true, days: d };
},
onSetUpdateChannel: function (channel) {
saveDaemonSetting(config, "updateChannel", channel === "beta" ? "beta" : "stable", saveConfig);
console.log("[daemon] Update channel:", config.updateChannel, "(web)");
return { ok: true, updateChannel: config.updateChannel };
},
onSetPin: function (pin) {
if (usersModule.isMultiUser()) return { ok: false, error: 'Server PIN is unavailable while account authentication is enabled.' };
saveDaemonSetting(config, "pinHash", pin ? generateAuthToken(pin) : null, saveConfig);
relay.setAuthToken(config.pinHash);
console.log("[daemon] PIN", pin ? "set" : "removed", "(web)");
return { ok: true, pinEnabled: !!config.pinHash };
},
onUpgradePin: function (newHash) {
config.pinHash = newHash;
relay.setAuthToken(newHash);
saveConfig(config);
console.log("[daemon] PIN hash auto-upgraded to scrypt");
},
onSetChatLayout: function (layout) {
var val = (layout === "bubble") ? "bubble" : "channel";
config.chatLayout = val;
saveConfig(config);
console.log("[daemon] Chat layout:", val, "(web)");
return { ok: true, chatLayout: val };
},
onSetTerminalFont: function (family, size) {
var DEFAULT_FAMILY = "'SF Mono', Menlo, Monaco, 'Courier New', monospace";
var current = config.terminalFont || { family: DEFAULT_FAMILY, size: 14 };
var nextFamily = (typeof family === "string" && family.trim()) ? family.trim().slice(0, 200) : current.family;
var nextSize = current.size;
if (typeof size === "number" && size >= 9 && size <= 32) {
nextSize = Math.round(size);
}
config.terminalFont = { family: nextFamily, size: nextSize };
saveConfig(config);
console.log("[daemon] Terminal font:", nextFamily, nextSize);
return config.terminalFont;
},
onSetMateOnboarded: function () {
config.mateOnboardingShown = true;
saveConfig(config);
return { ok: true };
},
onSetAutoContinue: function (value) {
var want = !!value;
config.autoContinueOnRateLimit = want;
saveConfig(config);
console.log("[daemon] Auto-continue on rate limit:", want, "(web)");
return { ok: true, autoContinueOnRateLimit: want };
},
onSetMatesEnabled: function (value) {
var want = !!value;
config.matesEnabled = want;
saveConfig(config);
console.log("[daemon] Project Mate DMs:", want ? "shown" : "hidden", "(web)");
return { ok: true, matesEnabled: want };
},
onSetCapsulesEnabled: function (value) {
var want = value === true;
config.capsulesEnabled = want;
saveConfig(config);
console.log("[daemon] Experimental Capsules:", want ? "enabled" : "disabled", "(web)");
return { ok: true, capsulesEnabled: want };
},
onUpdateAgentBehavior: function (changes) {
var schema = require("./agent-behavior-schema");
var checked = schema.validateChanges(changes);
if (checked.error) return { error: checked.error };
var next = Object.assign({}, schema.explicit(config.agentBehavior), checked.value);
var previous = config.agentBehavior;
config.agentBehavior = next;
try { saveConfig(config); } catch (error) {
config.agentBehavior = previous;
return { error: "Could not save agent behavior" };
}
console.log("[daemon] Agent behavior updated (web)");
return { ok: true, policy: schema.normalize(next) };
},
onGetToolPalettes: function () {
return config.toolPalettes || {};
},
onSetToolPalette: function (paletteName, order, hidden) {
if (paletteName !== "session" && paletteName !== "mate") {
return { error: "Unknown palette" };
}
var safeOrder = Array.isArray(order)
? order.filter(function (s) { return typeof s === "string"; })
: [];
var safeHidden = Array.isArray(hidden)
? hidden.filter(function (s) { return typeof s === "string"; })
: [];
if (!config.toolPalettes) config.toolPalettes = {};
config.toolPalettes[paletteName] = { order: safeOrder, hidden: safeHidden };
saveConfig(config);
return { ok: true, palette: paletteName, order: safeOrder, hidden: safeHidden };
},
onSetKeepAwake: function (value) {
var want = !!value;
saveDaemonSetting(config, "keepAwake", want, saveConfig);
if (want && !caffeinateProc && process.platform === "darwin") {
try {
var { spawn: spawnCaff } = require("child_process");
caffeinateProc = spawnCaff("caffeinate", ["-di"], { stdio: "ignore", detached: false });
caffeinateProc.on("error", function () { caffeinateProc = null; });
} catch (e) {}
} else if (!want && caffeinateProc) {
try { caffeinateProc.kill(); } catch (e) {}
caffeinateProc = null;
}
console.log("[daemon] Keep awake:", want, "(web)");
return { ok: true, keepAwake: want };
},
onShutdown: function () {
console.log("[daemon] Shutdown requested via web UI");
gracefulShutdown();
},
onRestart: function () {
console.log("[daemon] Restart requested via web UI");
spawnAndRestart();
},
onSetProjectVisibility: function (slug, visibility) {
if (!config.osUsers) return projectLifecycle.update(slug, "visibility", visibility);
if (slug.indexOf("mate-") === 0) return {error:"Mates cannot be shared."};
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === slug) {
var prevVisibility = config.projects[i].visibility || "public";
config.projects[i].visibility = visibility;
saveConfig(config);
console.log("[daemon] Set project visibility:", slug, "→", visibility);
if (config.osUsers) {
var projectPath = config.projects[i].path;
var ownerId = config.projects[i].ownerId || null;
// When switching to public: grant ACL to ALL clay users
if (visibility === "public" && prevVisibility !== "public") {
grantAllUsersAccess(projectPath, usersModule);
}
// When switching to private: revoke ACLs for users not in allowedUsers and not the owner
if (visibility === "private" && prevVisibility !== "private") {
var allowed = config.projects[i].allowedUsers || [];
var allUsers = usersModule.getAllUsers();
for (var u = 0; u < allUsers.length; u++) {
var usr = allUsers[u];
if (usr.role === "admin") continue;
if (usr.id === ownerId) continue;
if (usr.linuxUser && allowed.indexOf(usr.id) === -1) {
revokeProjectAccess(projectPath, usr.linuxUser);
}
}
}
}
if (relay.refreshProjectAccess) relay.refreshProjectAccess();
return { ok: true };
}
}
return { error: "Project not found" };
},
onSetProjectAllowedUsers: function (slug, allowedUsers) {
if (!config.osUsers && !daemonProjectAccess.parentSlugFor(slug)) return projectLifecycle.update(slug, "allowed_users", allowedUsers);
var accessBefore = daemonProjectAccess.getProjectAccess(config, slug, config.osUsers);
var result = daemonProjectAccess.setAllowedUsers(config, slug, allowedUsers);
if (result.error) return result;
var parentSlug = daemonProjectAccess.parentSlugFor(slug);
for (var i = 0; i < config.projects.length; i++) {
if (config.projects[i].slug === (parentSlug || slug)) {
var prev = accessBefore && !accessBefore.error
? (parentSlug ? accessBefore.worktreeAllowedUsers : accessBefore.allowedUsers)
: [];
saveConfig(config);
console.log("[daemon] Set project allowed users:", slug, "→", result.allowedUsers.length, "users");
// OS users mode: sync ACLs for added/removed users
// Exact-worktree grants are Clay-only and never change filesystem ACLs.
if (config.osUsers && !parentSlug) {
var projectPath = config.projects[i].path;
// Grant access to newly added users
for (var a = 0; a < allowedUsers.length; a++) {
if (prev.indexOf(allowedUsers[a]) === -1) {
var addedUser = usersModule.findUserById(allowedUsers[a]);
if (addedUser && addedUser.linuxUser) {
grantProjectAccess(projectPath, addedUser.linuxUser);
}
}
}
// Revoke access from removed users
for (var r = 0; r < prev.length; r++) {
if (allowedUsers.indexOf(prev[r]) === -1) {
var removedUser = usersModule.findUserById(prev[r]);
if (removedUser && removedUser.linuxUser) {
revokeProjectAccess(projectPath, removedUser.linuxUser);
}
}
}
}
if (relay.refreshProjectAccess) relay.refreshProjectAccess();
return { ok: true };
}
}
return { error: "Project not found" };
},
onGetProjectAccess: worktreeControls.getAccess,
onUserProvisioned: function (userId, linuxUser) {
// Grant ACL on all public projects to the newly provisioned user
if (!config.osUsers || !linuxUser) return;
for (var i = 0; i < config.projects.length; i++) {
var proj = config.projects[i];
var visibility = proj.visibility || "public";
if (visibility === "public") {
grantProjectAccess(proj.path, linuxUser);
}
}
},
onUserDeleted: function (userId, linuxUser) {
// Deactivate the Linux account when a Clay user is deleted
if (!config.osUsers || !linuxUser) return;
deactivateLinuxUser(linuxUser);
},
onCreateWorktree: function (parentSlug, branchName, dirName, baseBranch, requestUser) {
// Find the parent project
var parent = null;
for (var j = 0; j < config.projects.length; j++) {
if (config.projects[j].slug === parentSlug) { parent = config.projects[j]; break; }
}
if (!parent) return { ok: false, error: "Parent project not found" };
if (worktreeControls.busy(parentSlug) || parent.lifecycleState) return { ok: false, error: "Worktree lifecycle operation is pending." };
var osUserInfo;
try {
osUserInfo = worktreeOsUserInfo(parent, requestUser);
} catch (e) {
return { ok: false, error: e.message };
}
if (isWorktree(parent.path, osUserInfo)) return { ok: false, error: "Cannot create worktrees from a worktree project" };
var baseCommit = projectLogContext.commit(parent.path, baseBranch || "HEAD", osUserInfo);
var result = createWorktree(parent.path, branchName, dirName, baseBranch, osUserInfo);
if (!result.ok) return result;
// Register the new worktree as ephemeral project
var wtSlug = parentSlug + "--" + dirName;
if (daemonProjectAccess.clearWorktreeGrant(config, wtSlug)) saveConfig(config);
var context = projectLogContext.resolveWorktreeContext(result.path, parent.projectKnowledgeId, {
branch: branchName,
baseCommit: baseCommit,
}, osUserInfo);
var wtMeta = Object.assign({ parentSlug: parentSlug, external: false, projectKnowledgeId: parent.projectKnowledgeId }, context);
relay.addProject(result.path, wtSlug, branchName, null, parent.ownerId, wtMeta);
registerWorktreeSlug(parentSlug, wtSlug);
console.log("[daemon] Created worktree:", wtSlug, "->", result.path);
relay.broadcastAll({
type: "projects_updated",
projects: relay.getProjects(),
projectCount: config.projects.length,
});
return { ok: true, slug: wtSlug, path: result.path };
},
});
// Reclaim idle out-of-process agent runtimes. Any adapter that owns a child
// process may implement the optional shutdownIfIdle(ms); adapters without one
// (Claude, which runs in-process or under sdk-worker) are simply skipped.
// The CLAY_CODEX_* names are kept as aliases for backwards compatibility.
var adapterReaperConfig = require("./yoke/host-environment").reaper(process.env);
var IDLE_MS = adapterReaperConfig.idleMs;
var REAP_MS = adapterReaperConfig.reapMs;
var reaperHandle = setInterval(function () {
if (!relay || typeof relay.forEachProject !== "function") return;
relay.forEachProject(function (ctx) {
if (!ctx || !ctx.adapters) return;
Object.keys(ctx.adapters).forEach(function (vendor) {
try {
var adapter = ctx.adapters[vendor];
if (!adapter || typeof adapter.shutdownIfIdle !== "function") return;
var result = adapter.shutdownIfIdle(IDLE_MS);
if (result && typeof result.catch === "function") {
result.catch(function (e) {
console.error("[daemon] " + vendor + " idle reclaim failed:", e && e.message ? e.message : e);
});
}
} catch (e) {}
});
});
}, REAP_MS);
if (reaperHandle && typeof reaperHandle.unref === "function") {
reaperHandle.unref();
}
function stopCodexReaper() {
if (reaperHandle) {
clearInterval(reaperHandle);
reaperHandle = null;
}
}
function shutdownProjects() {
stopCodexReaper();
try {
var result = relay.destroyAll();
if (result && typeof result.then === "function") {
return result;
}
return Promise.resolve(true);
} catch (e) {
return Promise.reject(e);
}
}
// Worktree tracking extracted to daemon-projects.js
// --- IPC server ---
// Clean up stale socket/config left by a previously killed daemon
var existingConfig = loadConfig();
if (existingConfig && existingConfig.pid && existingConfig.pid !== process.pid) {
if (!isPidAlive(existingConfig.pid)) {
console.log("[daemon] Clearing stale config from dead PID " + existingConfig.pid);
clearStaleConfig();
}
}
var ipc = createIPCServer(socketPath(), function (msg) {
console.log("[daemon] IPC:", msg.cmd);
switch (msg.cmd) {
case "add_project": {
if (!msg.path) return { ok: false, error: "missing path" };
var absPath = path.resolve(msg.path);
// Check if already registered
for (var j = 0; j < config.projects.length; j++) {
if (config.projects[j].path === absPath) {
return { ok: true, slug: config.projects[j].slug, existing: true };
}
}
var slugs = config.projects.map(function (p) { return p.slug; });
var slug = generateSlug(absPath, slugs);
var projectEntry = { path: absPath, slug: slug, addedAt: Date.now(), visibility: "private", ownershipRecovery: "required" };
projectEntry.projectKnowledgeId = rememberedProjectKnowledgeId(absPath);
config = require("./project-ownership").register(config, projectEntry, usersModule.getAllUsers(), saveConfig, osUsersMod.resolveOsUserInfo);
var registeredProject = config.projects[config.projects.length - 1];
relay.addProject(absPath, slug, null, null, registeredProject.ownerId || null, null, projectRuntimeOptions(registeredProject));
try { syncClayrc(config.projects); } catch (e) {}
console.log("[daemon] Added project:", slug, "→", absPath);
// Discover and register worktrees for the new project
registerProjectWorktrees(projectEntry, null);
relay.broadcastAll({
type: "projects_updated",
projects: relay.getProjects(),
projectCount: config.projects.length,
});
return { ok: true, slug: slug };
}
case "remove_project": {
if (!msg.path && !msg.slug) return { ok: false, error: "missing path or slug" };
var target = msg.slug;
if (!target) {
var abs = path.resolve(msg.path);
for (var k = 0; k < config.projects.length; k++) {
if (config.projects[k].path === abs) {
target = config.projects[k].slug;
break;
}
}
}
if (!target) return { ok: false, error: "project not found" };
var removedProject = null;
for (var ri = 0; ri < config.projects.length; ri++) {
if (config.projects[ri].slug === target) { removedProject = config.projects[ri]; break; }
}
if (removedProject) {
if (!config.removedProjects) config.removedProjects = [];
config.removedProjects.push({
path: removedProject.path,
title: removedProject.title || null,
icon: removedProject.icon || null,
projectKnowledgeId: removedProject.projectKnowledgeId || null,
removedAt: Date.now(),
});
if (config.removedProjects.length > 20) {
config.removedProjects = config.removedProjects.slice(config.removedProjects.length - 20);
}
}
relay.removeProject(target);
config.projects = config.projects.filter(function (p) { return p.slug !== target; });
saveConfig(config);
try { syncClayrc(config.projects); } catch (e) {}
console.log("[daemon] Removed project:", target);
relay.broadcastAll({
type: "projects_updated",
projects: relay.getProjects(),
projectCount: config.projects.length,
});
return { ok: true };
}
case "get_status":
return {
ok: true,
pid: process.pid,
port: config.port,
tls: !!tlsOptions,
keepAwake: !!config.keepAwake,
osUsers: !!config.osUsers,
projects: relay.getProjects(),
uptime: process.uptime(),
};
case "set_pin": {
if (usersModule.isMultiUser()) return { ok: false, error: 'Server PIN is unavailable while account authentication is enabled.' };
config.pinHash = msg.pinHash || null;
relay.setAuthToken(config.pinHash);
saveConfig(config);
return { ok: true };
}
case "set_project_title": {
if (!msg.slug) return { ok: false, error: "missing slug" };
var newTitle = msg.title || null;
relay.setProjectTitle(msg.slug, newTitle);
for (var ti = 0; ti < config.projects.length; ti++) {
if (config.projects[ti].slug === msg.slug) {
if (newTitle) {
config.projects[ti].title = newTitle;
} else {
delete config.projects[ti].title;
}
break;
}
}
saveConfig(config);
try { syncClayrc(config.projects); } catch (e) {}
console.log("[daemon] Project title:", msg.slug, "→", newTitle || "(default)");
relay.broadcastAll({
type: "projects_updated",
projects: relay.getProjects(),
projectCount: config.projects.length,
});
return { ok: true };
}
case "set_os_users": {
var enableOsUsers = !!msg.value;
if (enableOsUsers) {
// Pre-flight: check if setfacl is available
var aclCheck = checkAclSupport();
if (!aclCheck.available) {
return { error: "acl_not_installed", installCmd: aclCheck.installCmd };
}
}
config.osUsers = enableOsUsers;
saveConfig(config);
console.log("[daemon] OS users:", enableOsUsers);
// Provisioning is handled by CLI (which has terminal access for progress).
// Daemon only saves the flag. On next restart, daemon will pick it up.
return { ok: true };
}
case "set_auto_continue": {
var acWant = !!msg.value;
config.autoContinueOnRateLimit = acWant;
saveConfig(config);
console.log("[daemon] Auto-continue on rate limit:", acWant, "(cli)");
return { ok: true, autoContinueOnRateLimit: acWant };
}
case "set_keep_awake": {
var want = !!msg.value;
config.keepAwake = want;
saveConfig(config);
if (want && !caffeinateProc && process.platform === "darwin") {
try {
var { spawn: spawnCaff } = require("child_process");
caffeinateProc = spawnCaff("caffeinate", ["-di"], { stdio: "ignore", detached: false });
caffeinateProc.on("error", function () { caffeinateProc = null; });
} catch (e) {}
} else if (!want && caffeinateProc) {
try { caffeinateProc.kill(); } catch (e) {}
caffeinateProc = null;
}
console.log("[daemon] Keep awake:", want);
return { ok: true };
}
case "enable_recovery": {
if (!msg.urlPath || !msg.password) return { ok: false, error: "missing urlPath or password" };
relay.setRecovery(msg.urlPath, msg.password);
console.log("[daemon] Admin recovery mode enabled");
return { ok: true };
}
case "disable_recovery": {
relay.clearRecovery();
console.log("[daemon] Admin recovery mode disabled");
return { ok: true };
}
case "shutdown":
console.log("[daemon] Shutdown requested via IPC");
gracefulShutdown();
return { ok: true };
case "restart":
console.log("[daemon] Restart requested via IPC");
spawnAndRestart();
return { ok: true };
case "update": {
if (config.headless) {
console.log("[daemon] Update & restart requested via IPC — blocked (headless mode)");
return { ok: false, error: "Auto-update is disabled in headless mode" };
}
console.log("[daemon] Update & restart requested via IPC");
// Dev mode (config.debug): just exit with code 120, cli.js dev watcher respawns daemon
if (config.debug) {
console.log("[daemon] Dev mode — restarting via dev watcher");
updateHandoff = true;
setTimeout(function () { gracefulShutdown(); }, 100);
return { ok: true };
}
// Production: fetch latest via npx, then spawn updated daemon
var { execSync: execSyncUpd, spawn: spawnUpd } = require("child_process");
var updTag = config.updateChannel === "beta" ? "beta" : "latest";
var updDaemonScript;
try {
// npx downloads the package and puts a bin symlink; `which` prints its path
var binPath = execSyncUpd(
"npx --yes --package=clay-server@" + updTag + " -- which clay-server",
{ stdio: ["ignore", "pipe", "pipe"], timeout: 120000, encoding: "utf8" }
).trim();
// Resolve symlink to get the actual package directory
var realBin = fs.realpathSync(binPath);
updDaemonScript = path.join(path.dirname(realBin), "..", "lib", "daemon.js");
updDaemonScript = path.resolve(updDaemonScript);
console.log("[daemon] Resolved updated daemon:", updDaemonScript);
} catch (updErr) {
console.log("[daemon] npx resolve failed:", updErr.message);
// Fallback: restart with current code
updDaemonScript = path.join(__dirname, "daemon.js");
}
// Spawn new daemon process — it will retry if port is still in use
var { logPath: updLogPath, configPath: updConfigPath } = require("./config");
var updLogFd = fs.openSync(updLogPath(), "a");
var updChild = spawnUpd(process.execPath, [updDaemonScript], {
detached: true,
windowsHide: true,
stdio: ["ignore", updLogFd, updLogFd],
env: Object.assign({}, process.env, {
CLAY_CONFIG: updConfigPath(),
}),
});
updChild.unref();
fs.closeSync(updLogFd);
config.pid = updChild.pid;
saveConfig(config);
console.log("[daemon] Spawned new daemon (PID " + updChild.pid + "), shutting down...");
updateHandoff = true;
setTimeout(function () { gracefulShutdown(); }, 100);
return { ok: true };
}
default:
return { ok: false, error: "unknown command: " + msg.cmd };
}
});
// --- Register projects ---
var projects = config.projects || [];
for (var i = 0; i < projects.length; i++) {
var p = projects[i];
if (!projectLifecycle.canRestore(p)) {
console.log("[daemon] Project removal needs recovery; runtime remains stopped:", p.slug);
continue;
}
if (fs.existsSync(p.path)) {
console.log("[daemon] Adding project:", p.slug, "→", p.path);
relay.addProject(p.path, p.slug, p.title, p.icon, p.ownerId, null, projectRuntimeOptions(p));
// Discover and register worktrees for this project
registerProjectWorktrees(p, null);
} else {
console.log("[daemon] Skipping missing project:", p.path);
}
}
// Migrate legacy mates storage before registration
mates.migrateLegacyMates();
// Import durable Mate knowledge into the Clay-wide Knowledge backend. Runs on
// every start, imports nothing when there is nothing new, and never touches
// the legacy files that remain the live source for existing read paths.
mateKnowledgeMigration.runAtStartup({
extraRoots: (function () {
var roots = [];
try {
if (!usersModule.isMultiUser()) return roots;
var all = usersModule.getAllUsers() || [];
for (var mi = 0; mi < all.length; mi++) {
if (!all[mi] || !all[mi].linuxUser) continue;
roots.push({
userId: all[mi].id,
matesRoot: mates.resolveMatesRoot({ userId: all[mi].id, multiUser: true, linuxUser: all[mi].linuxUser }),
});
}
} catch (e) {}
return roots;
})(),
});
// Register existing mates as projects
if (usersModule.isMultiUser()) {
// Multi-user mode: iterate over all users and load each user's mates
var allUsers = usersModule.getAllUsers();
for (var ui = 0; ui < allUsers.length; ui++) {
var mateCtx = mates.buildMateCtx(allUsers[ui].id);
var userMates = mates.getAllMates(mateCtx);
for (var mi = 0; mi < userMates.length; mi++) {
var m = userMates[mi];
var mateDir = mates.getMateDir(mateCtx, m.id);
var mateSlug = "mate-" + m.id;
var mateName = (m.profile && m.profile.displayName) || m.name || "New Mate";
if (!m.lifecycleDeletion && fs.existsSync(mateDir)) {
var mateDef = m.builtinKey ? require("./builtin-mates").getBuiltinByKey(m.builtinKey) : null;
var isHost = !!(mateDef && mateDef.hostAgent);
console.log("[daemon] Adding mate project:", mateSlug + (isHost ? " (host agent)" : ""));
relay.addProject(mateDir, mateSlug, mateName, null, m.createdBy, null, { isMate: true, mateDisplayName: mateName, isHostAgent: isHost });
}
}
}
} else {
// Single-user mode: use null ctx
var mateCtx = mates.buildMateCtx(null);
var allMates = mates.getAllMates(mateCtx);
for (var mi = 0; mi < allMates.length; mi++) {
var m = allMates[mi];
var mateDir = mates.getMateDir(mateCtx, m.id);
var mateSlug = "mate-" + m.id;
var mateName = (m.profile && m.profile.displayName) || m.name || "New Mate";
if (!m.lifecycleDeletion && fs.existsSync(mateDir)) {
var mateDef2 = m.builtinKey ? require("./builtin-mates").getBuiltinByKey(m.builtinKey) : null;
var isHost2 = !!(mateDef2 && mateDef2.hostAgent);
console.log("[daemon] Adding mate project:", mateSlug + (isHost2 ? " (host agent)" : ""));
relay.addProject(mateDir, mateSlug, mateName, null, m.createdBy, null, { isMate: true, mateDisplayName: mateName, isHostAgent: isHost2 });
}
}
}
// Sync ~/.clayrc on startup
try { syncClayrc(config.projects); } catch (e) {}
// --- Cleanup stale SDK warmup CLI session files ---
//
// On adapter init the SDK is given a dummy "hi" prompt to discover models,
// skills, and capabilities. Even though we abort before any response
// arrives, the SDK creates a real CLI session jsonl on disk. Going forward
// we delete those inline (see lib/yoke/adapters/claude.js and
// claude-worker.js), but installs from before that fix can have a long
// tail of "hi"-only sessions polluting the Resume CLI session dialog.
//
// On every daemon startup we scan REAL_HOME/.claude/projects/*/ and unlink
// files that match a strict warmup signature so legitimate "hi" sessions
// from real users are never touched. All checks must pass:
//
// 1. file size < 64 KB (real sessions grow past this fast;
// threshold is generous because warmup files include the SDK's
// skill_listing / deferred_tools attachments which can run into the
// tens of KB on its own)
// 2. mtime older than 60 seconds (avoid racing an in-flight warmup)
// 3. exactly one real user prompt equal to literal "hi" (the automatic
// interruption record written when the probe is aborted is ignored)
// 4. zero assistant messages (real users virtually always get a reply)
//
// Multi-user OS-isolated installs have per-user ~/.claude trees that the
// daemon process can't traverse; the inline cleanup in claude-worker.js
// covers those.
function cleanupSdkWarmupSessions() { return require("./yoke/maintenance").cleanup(REAL_HOME); }
// --- Start listening (with retry for port-in-use during update handoff) ---
var listenRetries = 0;
var MAX_LISTEN_RETRIES = 15;
function startListening() {
// One-shot cleanup of stale SDK warmup CLI session files from previous
// daemon runs (best-effort, never fatal).
try { cleanupSdkWarmupSessions(); } catch (e) {}
relay.server.listen(config.port, listenHost, function () {
var protocol = tlsOptions ? "https" : "http";
console.log("[daemon] Listening on " + protocol + "://" + listenHost + ":" + config.port);
console.log("[daemon] PID:", process.pid);
console.log("[daemon] Projects:", config.projects.length);
// Update PID in config
config.pid = process.pid;
saveConfig(config);
// Auto-provision Linux accounts on startup if OS users mode is enabled.
// ACLs are NOT re-applied on every startup (too slow with recursive setfacl).
// ACLs are set when: projects are added, users are added, or visibility changes.
if (config.osUsers) {
setTimeout(function () {
try { ensureProjectsDir(); } catch (e) {}
try {
var provResult = provisionAllUsers(usersModule);
if (provResult.provisioned.length > 0) {
console.log("[daemon] Auto-provisioned " + provResult.provisioned.length + " Linux account(s) on startup");
// Only set ACLs for newly provisioned users (not all users on all projects)
for (var pi = 0; pi < config.projects.length; pi++) {
var proj = config.projects[pi];
if ((proj.visibility || "public") === "public") {
for (var ni = 0; ni < provResult.provisioned.length; ni++) {
try {
grantProjectAccess(proj.path, provResult.provisioned[ni].linuxUser);
} catch (e) {}
}
}
}
}
if (provResult.errors.length > 0) {
console.error("[daemon] Failed to provision " + provResult.errors.length + " account(s)");
}
} catch (provErr) {
console.error("[daemon] Startup provisioning error:", provErr.message);
}
console.log("[daemon] Startup OS users check complete.");
// Diagnostics run after provisioning, never affect provisioning or serving.
scheduleOsUserDiagnosticsAsync({
getUsers: function() { return usersModule.getAllUsers(); },
getProjects: function() { return config.projects; },
});
}, 100);
}
// Check for crash info from a previous crash and notify clients
var crashInfo = readCrashInfo();
if (crashInfo) {
console.log("[daemon] Recovered from crash at", new Date(crashInfo.time).toISOString());
console.log("[daemon] Crash reason:", crashInfo.reason);
// Delay notification so clients have time to reconnect
setTimeout(function () {
relay.broadcastAll({
type: "toast",
level: "warn",
message: "Server recovered from a crash and was automatically restarted.",
detail: crashInfo.reason || null,
});
}, 3000);
clearCrashInfo();
}
});
}
relay.server.on("error", function (err) {
if (err.code === "EADDRINUSE" && listenRetries < MAX_LISTEN_RETRIES) {
listenRetries++;
console.log("[daemon] Port " + config.port + " in use, retrying (" + listenRetries + "/" + MAX_LISTEN_RETRIES + ")...");
setTimeout(startListening, 1000);
return;
}
console.error("[daemon] Server error:", err.message);
writeCrashInfo({
reason: "Server error: " + err.message,
pid: process.pid,
time: Date.now(),
});
process.exit(1);
});
startListening();
// --- HTTP onboarding server (only when TLS is active) ---
if (relay.onboardingServer) {
var onboardingPort = config.port + 1;
relay.onboardingServer.on("error", function (err) {
console.error("[daemon] Onboarding HTTP server error:", err.message);
});
relay.onboardingServer.listen(onboardingPort, listenHost, function () {
console.log("[daemon] Onboarding HTTP on http://" + listenHost + ":" + onboardingPort);
});
}
// --- Caffeinate (macOS) ---
var caffeinateProc = null;
if (config.keepAwake && process.platform === "darwin") {
try {
var { spawn } = require("child_process");
caffeinateProc = spawn("caffeinate", ["-di"], { stdio: "ignore", detached: false });
caffeinateProc.on("error", function () { caffeinateProc = null; });
} catch (e) {}
}
// --- Spawn new daemon and graceful restart ---
function spawnAndRestart() {
try {
updateHandoff = true;
ipc.close();
shutdownProjects().then(function () {
if (relay.onboardingServer) relay.onboardingServer.close();
// Close the server first so the port is released before spawning the new daemon
relay.server.close(function () {
try {
var { spawn: spawnRestart } = require("child_process");
var { logPath: restartLogPath, configPath: restartConfigPath } = require("./config");
var daemonScript = path.join(__dirname, "daemon.js");
var logFd = fs.openSync(restartLogPath(), "a");
var child = spawnRestart(process.execPath, [daemonScript], {
detached: true,
windowsHide: true,
stdio: ["ignore", logFd, logFd],
env: Object.assign({}, process.env, {
CLAY_CONFIG: restartConfigPath(),
}),
});
child.unref();
fs.closeSync(logFd);
config.pid = child.pid;
saveConfig(config);
console.log("[daemon] Spawned new daemon (PID " + child.pid + "), exiting.");
process.exit(120);
} catch (e) {
console.error("[daemon] Restart failed:", e.message);
process.exit(1);
}
});
}).catch(function (e) {
console.error("[daemon] Restart shutdown failed:", e && e.message ? e.message : e);
if (relay.onboardingServer) relay.onboardingServer.close();
relay.server.close(function () {
process.exit(1);
});
});
// Force exit after 10 seconds if server.close hangs
setTimeout(function () {
console.error("[daemon] Forced exit after timeout during restart");
process.exit(1);
}, 10000);
} catch (e) {
console.error("[daemon] Restart failed:", e.message);
relay.broadcastAll({ type: "toast", level: "error", message: "Restart failed: " + e.message });
relay.broadcastAll({ type: "restart_server_result", ok: false, error: e.message });
}
}
// --- Graceful shutdown ---
var updateHandoff = false; // true when shutting down for update (new daemon already spawned)
var shutdownStarted = false;
function gracefulShutdown() {
try { console.log("[daemon] Shutting down..."); } catch (e) {}
if (shutdownStarted) return;
shutdownStarted = true;
var exitCode = updateHandoff ? 120 : 0; // 120 = update handoff, don't auto-restart
if (caffeinateProc) {
try { caffeinateProc.kill(); } catch (e) {}
}
ipc.close();
// Remove PID from config (skip if update handoff — new daemon PID is already saved)
if (!updateHandoff) {
try {
var c = loadConfig();
if (c && c.pid === process.pid) {
delete c.pid;
saveConfig(c);
}
} catch (e) {}
}
shutdownProjects().then(function () {
if (relay.onboardingServer) {
relay.onboardingServer.close();
}
relay.server.close(function () {
try { console.log("[daemon] Server closed"); } catch (e) {}
process.exit(exitCode);
});
}).catch(function (e) {
console.error("[daemon] Shutdown cleanup failed:", e && e.message ? e.message : e);
if (relay.onboardingServer) {
relay.onboardingServer.close();
}
relay.server.close(function () {
process.exit(exitCode);
});
});
// Force exit after 10 seconds
setTimeout(function () {
try { console.error("[daemon] Forced exit after timeout"); } catch (e) {}
process.exit(1);
}, 10000);
}
process.on("SIGTERM", gracefulShutdown);
process.on("SIGINT", gracefulShutdown);
// Last-resort cleanup: kill caffeinate if process exits without graceful shutdown
process.on("exit", function () {
if (caffeinateProc) {
try { caffeinateProc.kill(); } catch (e) {}
}
});
// Windows emits SIGHUP when console window closes
if (process.platform === "win32") {
process.on("SIGHUP", gracefulShutdown);
}
process.on("uncaughtException", function (err) {
var errMsg = err ? (err.message || String(err)) : "";
var isAbort = errMsg.indexOf("Operation aborted") !== -1
|| errMsg.indexOf("AbortError") !== -1
|| (err && err.name === "AbortError");
if (isAbort) {
// A single session's SDK write was aborted (e.g. stream closed before
// write completed). This is recoverable, so do NOT tear down the whole
// daemon and kill every other session.
try { console.error("[daemon] Suppressed AbortError (single-session failure):", errMsg); } catch (e) {}
return;
}
// EIO/EPIPE on stdout/stderr when parent process (dev mode CLI) dies.
// Not fatal for the daemon itself.
var isIOError = errMsg.indexOf("EIO") !== -1 || errMsg.indexOf("EPIPE") !== -1;
if (isIOError) {
return;
}
console.error("[daemon] Uncaught exception:", err);
writeCrashInfo({
reason: err ? (err.stack || err.message || String(err)) : "unknown",
pid: process.pid,
time: Date.now(),
});
gracefulShutdown();
});
process.on("unhandledRejection", function (reason) {
var errMsg = reason ? (reason.message || String(reason)) : "";
var isAbort = errMsg.indexOf("Operation aborted") !== -1
|| errMsg.indexOf("AbortError") !== -1
|| (reason && reason.name === "AbortError");
if (isAbort) {
console.error("[daemon] Suppressed unhandled rejection (AbortError):", errMsg);
return;
}
console.error("[daemon] Unhandled rejection:", reason);
});