very-happy-cli 0.2.124 → 0.2.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{AcpBackend-CT9TOBEM.mjs → AcpBackend-DOQGItcS.mjs} +2 -1
  2. package/dist/{AcpBackend-au0veuIF.cjs → AcpBackend-ssQpTLks.cjs} +30 -29
  3. package/dist/{AcpSessionManager-p1tj7FxF.mjs → AcpSessionManager-D4WkNsRs.mjs} +1 -1
  4. package/dist/{AcpSessionManager-BCWeBKDd.cjs → AcpSessionManager-DHLc-D50.cjs} +9 -9
  5. package/dist/agentGuidance-Cg9ZG1pP.cjs +236 -0
  6. package/dist/agentGuidance-QpYwykb9.mjs +224 -0
  7. package/dist/codex/happyMcpStdioBridge.cjs +29 -6
  8. package/dist/codex/happyMcpStdioBridge.mjs +25 -2
  9. package/dist/{config-GzzqE5Ev.cjs → config-BxHOGtEK.cjs} +11 -11
  10. package/dist/{config-DwMic6AC.mjs → config-CZNIz3W0.mjs} +11 -11
  11. package/dist/{index-DgN9P02i.cjs → index-CBj99SO6.cjs} +53 -19
  12. package/dist/{index-SQKnWyz-.mjs → index-CrSifgJM.mjs} +2046 -1719
  13. package/dist/{index-B3-daYaX.mjs → index-D-BgvQi5.mjs} +8 -8
  14. package/dist/{index-C69uF6Up.cjs → index-D_Rm9dw9.cjs} +8 -8
  15. package/dist/{index-DI_VC3vJ.cjs → index-Do-kjc30.cjs} +2191 -1864
  16. package/dist/{index-Ba6Fu6MJ.mjs → index-Jrk6ud3g.mjs} +49 -15
  17. package/dist/index.cjs +5 -5
  18. package/dist/index.mjs +5 -5
  19. package/dist/{installTerminalHooks-CvMt8cBI.cjs → installTerminalHooks-CPsdeOM4.cjs} +9 -9
  20. package/dist/{installTerminalHooks-Df4hzzdM.mjs → installTerminalHooks-Z1Jb6yjb.mjs} +8 -8
  21. package/dist/lib.cjs +12 -12
  22. package/dist/lib.d.cts +3 -0
  23. package/dist/lib.d.mts +3 -0
  24. package/dist/lib.mjs +9 -9
  25. package/dist/limits-DViGHwSj.cjs +1198 -0
  26. package/dist/limits-FjbPJPsF.mjs +1129 -0
  27. package/dist/{mcp-DnyLwSPq.cjs → mcp-BbQ5FWcQ.cjs} +11 -10
  28. package/dist/{mcp-C0oW5u-b.mjs → mcp-zqPJ94Bk.mjs} +7 -6
  29. package/dist/resources-CVl7NdOB.cjs +83 -0
  30. package/dist/resources-D-mn7ptO.mjs +80 -0
  31. package/dist/{runGemini-_FHrxuHN.cjs → runGemini-BnHGYX-H.cjs} +30 -30
  32. package/dist/{runGemini-mqLjz-M7.mjs → runGemini-Bu49s_bN.mjs} +13 -13
  33. package/dist/{runOpenClaw-C5VZQ6Na.cjs → runOpenClaw-3Ld1eoWV.cjs} +24 -24
  34. package/dist/{runOpenClaw-Bs03yQHe.mjs → runOpenClaw-CznAYPCh.mjs} +9 -9
  35. package/dist/{send-BaLjQPZQ.mjs → send-BevgS1mf.mjs} +10 -10
  36. package/dist/{send-BD2gTVoP.cjs → send-DCzyBxLl.cjs} +10 -10
  37. package/dist/{sessions-ggBePfSY.mjs → sessions-Ba2oRTmw.mjs} +10 -10
  38. package/dist/{sessions-B108j_Xj.cjs → sessions-DSoaBHwA.cjs} +11 -11
  39. package/dist/{spawn-iI8w8sD7.cjs → spawn-CqMLY6dE.cjs} +21 -21
  40. package/dist/{spawn-CORFRb9D.mjs → spawn-j9KBfj4e.mjs} +21 -21
  41. package/dist/teams-C0ggJTJ-.mjs +164 -0
  42. package/dist/teams-CmU3K9vB.cjs +166 -0
  43. package/dist/{types-CZIwVSN2.cjs → types-BGHnQXl5.cjs} +462 -1571
  44. package/dist/{types-gDOmwOKw.mjs → types-DLDkfrTC.mjs} +76 -1148
  45. package/package.json +7 -7
  46. package/dist/agentGuidance-BifkGXdE.cjs +0 -46
  47. package/dist/agentGuidance-C2_pkgRI.mjs +0 -37
  48. package/dist/limits-CwFb5S2b.mjs +0 -22
  49. package/dist/limits-KdWKmwtH.cjs +0 -28
@@ -1,12 +1,13 @@
1
1
  import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import chalk from 'chalk';
2
2
  import os, { homedir, userInfo, tmpdir } from 'node:os';
3
3
  import { randomUUID, randomBytes, createHash as createHash$1, timingSafeEqual } from 'node:crypto';
4
- import { o as readDaemonState, l as logger, c as configuration, q as clearDaemonState, t as getProjectPath, p as projectPath, u as initializeSandbox, v as wrapCommand, g as contentLogMetadata, I as InvalidateSync, R as RawJSONLinesSchema, k as delay, x as AsyncLock, S as STREAM_DELTA_MAX_CHARS, y as ensurePrivateDirectory, M as MAX_CHAT_ATTACHMENT_SOURCE_BYTES, z as hardenPrivateFile, j as packageJson, B as commandOnPath, e as readSettings, C as readCredentials, D as credentialRelayProblem, E as deriveLocalCliUpdateSummary, f as encodeBase64, F as readSettingsOutcome, G as updateSettings, H as encodeBase64Url, J as decodeBase64, K as writeCredentialsLegacy, L as writeCredentialsDataKey, N as readCredentialsForConfiguredRelay, O as encrypt, r as readPersistedSessions, P as isClaudeSessionId, w as writePrivateFileSync, Q as getLocalHappyAgentCredentialPath, T as readLocalHappyAgentCredentials, U as decryptWithDataKey, V as decryptLegacy, W as parseExactVersion, X as compareExactVersions, Y as extractCompleteLines, Z as decideBackfill, _ as parseTerminalHookPayload, $ as tmuxArgs, a0 as scrubTmuxClientEnv, a1 as decideMirrorBinding, a2 as MIRROR_BACKFILL_LINES_DEFAULT, a3 as persistSession, a4 as mirrorLineKey, a5 as mirrorLocalId, a6 as detectCLIAvailability, a7 as detectResumeSupport, a8 as acquireDaemonLock, a9 as summarizeSpawnSessionForLog, aa as deletePersistedSessions, ab as shellescape, ac as decrypt, ad as writeDaemonState, A as ApiClient, ae as writeSettings, af as fetchCliUpdateState, ag as resolveCliUpdateCheckInterval, ah as DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS, ai as releaseDaemonLock, i as isValidSessionId, aj as listVhTerminals, ak as isValidTerminalId, al as readVhTerminal, am as sendToVhTerminal, an as TITLE_PROMPT_PREFIX, ao as BOARD_ANALYZER_PROMPT_PREFIX, d as connectionState, ap as startOfflineReconnection, aq as clearCredentials, ar as clearMachineId, m as ensurePrivateDirectorySync, as as SandboxConfigSchema, b as createEnvelope, at as CodexAppServerClient, au as getLatestDaemonLog } from './types-gDOmwOKw.mjs';
4
+ import { g as getProjectPath, p as projectPath, h as initializeSandbox, w as wrapCommand, b as contentLogMetadata, I as InvalidateSync, R as RawJSONLinesSchema, d as delay, j as AsyncLock, S as STREAM_DELTA_MAX_CHARS, M as MAX_CHAT_ATTACHMENT_SOURCE_BYTES, k as commandOnPath, l as deriveLocalCliUpdateSummary, m as isClaudeSessionId, n as getLocalHappyAgentCredentialPath, r as readLocalHappyAgentCredentials, o as parseExactVersion, q as compareExactVersions, t as extractCompleteLines, u as decideBackfill, v as parseTerminalHookPayload, x as tmuxArgs, y as scrubTmuxClientEnv, z as decideMirrorBinding, B as MIRROR_BACKFILL_LINES_DEFAULT, C as mirrorLineKey, D as mirrorLocalId, E as detectCLIAvailability, F as detectResumeSupport, G as summarizeSpawnSessionForLog, H as shellescape, A as ApiClient, J as fetchCliUpdateState, K as resolveCliUpdateCheckInterval, L as DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS, i as isValidSessionId, N as listVhTerminals, O as isValidTerminalId, P as readVhTerminal, Q as sendToVhTerminal, T as TITLE_PROMPT_PREFIX, U as BOARD_ANALYZER_PROMPT_PREFIX, f as connectionState, V as startOfflineReconnection, c as createEnvelope, W as CodexAppServerClient } from './types-DLDkfrTC.mjs';
5
+ import { z as readDaemonState, l as logger, c as configuration, A as clearDaemonState, B as ensurePrivateDirectory, D as hardenPrivateFile, p as packageJson, f as readSettings, E as readCredentials, s as credentialRelayProblem, g as encodeBase64, F as readSettingsOutcome, G as updateSettings, H as encodeBase64Url, m as decodeBase64, I as writeCredentialsLegacy, J as writeCredentialsDataKey, i as readCredentialsForConfiguredRelay, j as encrypt, r as readPersistedSessions, w as writePrivateFileSync, K as decryptWithDataKey, L as decryptLegacy, M as persistSession, k as decrypt, e as ensurePrivateDirectorySync, N as acquireDaemonLock, O as deletePersistedSessions, P as writeDaemonState, Q as writeSettings, R as releaseDaemonLock, C as CLIPBOARD_TOOL_NAME, a as CLIPBOARD_TOOL_TITLE, b as CLIPBOARD_TOOL_DESCRIPTION, d as CLIPBOARD_MAX_BYTES, S as clearCredentials, T as clearMachineId, U as SandboxConfigSchema, V as getLatestDaemonLog } from './limits-FjbPJPsF.mjs';
5
6
  import spawn$1, { spawn } from 'cross-spawn';
6
7
  import path, { join, resolve, basename, isAbsolute, dirname, sep, delimiter, normalize } from 'node:path';
7
8
  import { createInterface } from 'node:readline';
8
- import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, unlinkSync, renameSync, writeFileSync, accessSync, constants, rmSync as rmSync$1, chmodSync as chmodSync$1 } from 'node:fs';
9
- import { t as trimIdent, A as AGENT_GUIDANCE, P as PREVIEW_TOOL_NAME, R as REPORT_PROGRESS_TOOL_NAME, a as PREVIEW_TOOL_TITLE, b as PREVIEW_TOOL_DESCRIPTION, c as REPORT_PROGRESS_TOOL_TITLE, d as REPORT_PROGRESS_TOOL_DESCRIPTION } from './agentGuidance-C2_pkgRI.mjs';
9
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, unlinkSync, renameSync, writeFileSync, accessSync, constants, openSync, fsyncSync, closeSync, rmSync as rmSync$1, chmodSync as chmodSync$1 } from 'node:fs';
10
+ import { t as trimIdent, A as AGENT_GUIDANCE, P as PREVIEW_TOOL_NAME, R as REPORT_PROGRESS_TOOL_NAME, T as TEAM_TOOL_NAMES, r as registerTeamsTools, a as PREVIEW_TOOL_TITLE, b as PREVIEW_TOOL_DESCRIPTION, d as REPORT_PROGRESS_TOOL_TITLE, e as REPORT_PROGRESS_TOOL_DESCRIPTION } from './agentGuidance-QpYwykb9.mjs';
10
11
  import { readFile, writeFile, rename, unlink, mkdir, symlink, rm, readlink, lstat, stat, open as open$1, appendFile } from 'node:fs/promises';
11
12
  import fs, { watch, access } from 'fs/promises';
12
13
  import { useStdout, useInput, Box, Text, render } from 'ink';
@@ -17,7 +18,7 @@ import 'node:events';
17
18
  import { io } from 'socket.io-client';
18
19
  import tweetnacl from 'tweetnacl';
19
20
  import os$1, { homedir as homedir$1 } from 'os';
20
- import { spawn as spawn$2, execSync as execSync$1, exec } from 'child_process';
21
+ import { spawn as spawn$2, execSync as execSync$1, exec as exec$1 } from 'child_process';
21
22
  import { createId } from '@paralleldrive/cuid2';
22
23
  import { execSync, spawnSync, spawn as spawn$3, execFile, execFileSync } from 'node:child_process';
23
24
  import { existsSync as existsSync$1, statSync as statSync$1, mkdtempSync, rmSync, writeFileSync as writeFileSync$1, chmodSync, unlinkSync as unlinkSync$1 } from 'fs';
@@ -32,12 +33,12 @@ import fastify from 'fastify';
32
33
  import { z } from 'zod';
33
34
  import { validatorCompiler, serializerCompiler } from 'fastify-type-provider-zod';
34
35
  import { createRequire } from 'node:module';
36
+ import { promisify } from 'node:util';
35
37
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
36
38
  import { createServer } from 'node:http';
37
39
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
38
- import { C as CLIPBOARD_TOOL_NAME, a as CLIPBOARD_TOOL_TITLE, b as CLIPBOARD_TOOL_DESCRIPTION, c as CLIPBOARD_MAX_BYTES } from './limits-CwFb5S2b.mjs';
39
40
  import { createServer as createServer$1 } from 'http';
40
- import { promisify } from 'util';
41
+ import { promisify as promisify$1 } from 'util';
41
42
  import inquirer from 'inquirer';
42
43
  import { fileURLToPath } from 'node:url';
43
44
  import { createInterface as createInterface$1 } from 'node:readline/promises';
@@ -5818,7 +5819,7 @@ async function waitForSessionKey(sessionId, timeoutMs) {
5818
5819
  await delay(200);
5819
5820
  }
5820
5821
  }
5821
- async function sendUserMessage(sessionId, persisted, text, client) {
5822
+ async function sendUserMessage(sessionId, persisted, text, client, options = {}) {
5822
5823
  const credentials = await readCredentialsForConfiguredRelay();
5823
5824
  if (!credentials) {
5824
5825
  throw new Error("Not authenticated. Run `very-happy auth login` first.");
@@ -5830,7 +5831,7 @@ async function sendUserMessage(sessionId, persisted, text, client) {
5830
5831
  text
5831
5832
  },
5832
5833
  meta: {
5833
- sentFrom: "cli"
5834
+ sentFrom: options.sentFrom ?? "cli"
5834
5835
  }
5835
5836
  };
5836
5837
  const encrypted = encodeBase64(encrypt(
@@ -5840,7 +5841,7 @@ async function sendUserMessage(sessionId, persisted, text, client) {
5840
5841
  ));
5841
5842
  await axios.post(
5842
5843
  `${configuration.serverUrl}/v3/sessions/${encodeURIComponent(sessionId)}/messages`,
5843
- { messages: [{ content: encrypted, localId: randomUUID() }] },
5844
+ { messages: [{ content: encrypted, localId: options.localId ?? randomUUID() }] },
5844
5845
  {
5845
5846
  headers: {
5846
5847
  "Authorization": `Bearer ${credentials.token}`,
@@ -8799,1791 +8800,2104 @@ async function claimSessionOrExit(happySessionId, options) {
8799
8800
  process.on("exit", () => releaseSessionLock(happySessionId));
8800
8801
  }
8801
8802
 
8802
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8803
- function sanitizeImportTitle(value) {
8804
- if (typeof value !== "string") return null;
8805
- const collapsed = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
8806
- if (collapsed.length === 0) return null;
8807
- return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
8803
+ const MAX_LINE_CHARS = 500;
8804
+ const MAX_ARGS_CHARS = 160;
8805
+ function truncateText(text, max = MAX_LINE_CHARS) {
8806
+ const chars = Array.from(text);
8807
+ if (chars.length <= max) return text;
8808
+ return chars.slice(0, max).join("") + ` \u2026[+${chars.length - max} chars]`;
8808
8809
  }
8809
- const hostSuffix = process.env.HAPPY_VARIANT === "dev" ? "-dev" : "";
8810
- const startupCliAvailability = detectCLIAvailability();
8811
- const initialMachineMetadata = {
8812
- host: os$1.hostname() + hostSuffix,
8813
- platform: os$1.platform(),
8814
- happyCliVersion: packageJson.version,
8815
- homeDir: os$1.homedir(),
8816
- happyHomeDir: configuration.happyHomeDir,
8817
- happyLibDir: projectPath(),
8818
- cliAvailability: startupCliAvailability,
8819
- resumeSupport: { ...detectResumeSupport(), rpcAvailable: true }
8820
- };
8821
- async function preflightNewBundle(bundlePath) {
8822
- const home = mkdtempSync(join$1(tmpdir(), "vh-preflight-"));
8810
+ function squash(text) {
8811
+ return text.replace(/\s+/g, " ").trim();
8812
+ }
8813
+ function safeJson(value, max) {
8823
8814
  try {
8824
- return await new Promise((resolve) => {
8825
- let stdout = "";
8826
- let settled = false;
8827
- const done = (run) => {
8828
- if (settled) return;
8829
- settled = true;
8830
- resolve(decideHandover(run));
8831
- };
8832
- let child;
8833
- try {
8834
- child = spawn$3(process.execPath, ["--no-warnings", "--no-deprecation", bundlePath, "--version"], {
8835
- env: { ...process.env, HAPPY_HOME_DIR: home },
8836
- stdio: ["ignore", "pipe", "ignore"]
8837
- });
8838
- } catch (error) {
8839
- done({ exitCode: null, stdout: "", timedOut: false, spawnError: String(error) });
8840
- return;
8815
+ return truncateText(JSON.stringify(value) ?? "", max);
8816
+ } catch {
8817
+ return "[unserializable]";
8818
+ }
8819
+ }
8820
+ function formatMessageBody(body) {
8821
+ if (!body || typeof body !== "object") return null;
8822
+ const b = body;
8823
+ if (b.role === "user" && b.content?.type === "text" && typeof b.content.text === "string") {
8824
+ const text = squash(b.content.text);
8825
+ return text.length > 0 ? `user: ${truncateText(text)}` : null;
8826
+ }
8827
+ if (b.role === "session" && b.content && typeof b.content === "object") {
8828
+ const env = b.content;
8829
+ const ev = env.ev;
8830
+ if (!ev || typeof ev.t !== "string") return null;
8831
+ switch (ev.t) {
8832
+ case "text": {
8833
+ if (ev.thinking) return null;
8834
+ const text = squash(String(ev.text ?? ""));
8835
+ if (text.length === 0) return null;
8836
+ const role = env.role === "user" ? "user" : "assistant";
8837
+ return `${role}: ${truncateText(text)}`;
8838
+ }
8839
+ case "tool-call-start": {
8840
+ const name = String(ev.name ?? "tool");
8841
+ const title = squash(String(ev.title ?? ""));
8842
+ const args = ev.args ? safeJson(ev.args, MAX_ARGS_CHARS) : "";
8843
+ const detail = title || args;
8844
+ return `[tool] ${name}${detail ? `: ${truncateText(detail, MAX_ARGS_CHARS)}` : ""}`;
8845
+ }
8846
+ case "service": {
8847
+ const text = squash(String(ev.text ?? ""));
8848
+ return text.length > 0 ? `[service] ${truncateText(text, MAX_ARGS_CHARS)}` : null;
8849
+ }
8850
+ case "turn-end": {
8851
+ if (ev.status && ev.status !== "completed") return `[turn ${ev.status}]`;
8852
+ return null;
8853
+ }
8854
+ default:
8855
+ return null;
8856
+ }
8857
+ }
8858
+ if (b.role === "agent" && b.content?.type === "acp" && b.content.data && typeof b.content.data === "object") {
8859
+ const d = b.content.data;
8860
+ switch (d.type) {
8861
+ case "message": {
8862
+ const text = squash(String(d.message ?? ""));
8863
+ return text.length > 0 ? `assistant: ${truncateText(text)}` : null;
8864
+ }
8865
+ case "tool-call":
8866
+ return `[tool] ${String(d.name ?? "tool")}: ${safeJson(d.input, MAX_ARGS_CHARS)}`;
8867
+ case "permission-request":
8868
+ return `[permission] ${String(d.toolName ?? "")}: ${truncateText(squash(String(d.description ?? "")), MAX_ARGS_CHARS)}`;
8869
+ default:
8870
+ return null;
8871
+ }
8872
+ }
8873
+ if (b.role === "agent" && b.content?.type === "output") {
8874
+ const msg = b.content.data?.message;
8875
+ if (msg && Array.isArray(msg.content)) {
8876
+ const texts = msg.content.filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => squash(c.text)).filter((t) => t.length > 0);
8877
+ if (texts.length > 0) {
8878
+ const role = msg.role === "user" ? "user" : "assistant";
8879
+ return `${role}: ${truncateText(texts.join(" "))}`;
8841
8880
  }
8842
- const timer = setTimeout(() => {
8843
- try {
8844
- child.kill("SIGKILL");
8845
- } catch {
8846
- }
8847
- done({ exitCode: null, stdout, timedOut: true });
8848
- }, HANDOVER_PREFLIGHT_TIMEOUT_MS);
8849
- child.stdout?.on("data", (chunk) => {
8850
- if (stdout.length < 8192) stdout += chunk.toString("utf8");
8851
- });
8852
- child.on("error", (error) => {
8853
- clearTimeout(timer);
8854
- done({ exitCode: null, stdout, timedOut: false, spawnError: String(error) });
8855
- });
8856
- child.on("close", (code) => {
8857
- clearTimeout(timer);
8858
- done({ exitCode: code, stdout, timedOut: false });
8859
- });
8860
- });
8861
- } finally {
8862
- try {
8863
- rmSync(home, { recursive: true, force: true });
8864
- } catch {
8865
8881
  }
8882
+ return null;
8866
8883
  }
8884
+ return null;
8867
8885
  }
8868
- const HANDOVER_PREFLIGHT_TIMEOUT_MS = 3e4;
8869
- async function startDaemon() {
8870
- let claudeAuthServiceRef = null;
8871
- let cliUpdateStateRef = null;
8872
- let lastHandoverHold = null;
8873
- let claudeCredentialStoreSetting = (await readSettings()).claudeCredentialStore === "file" ? "file" : "auto";
8874
- let requestShutdown;
8875
- let resolvesWhenShutdownRequested = new Promise((resolve) => {
8876
- requestShutdown = (source, errorMessage) => {
8877
- logger.debug(`[DAEMON RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`);
8878
- setTimeout(async () => {
8879
- logger.debug("[DAEMON RUN] Startup malfunctioned, forcing exit with code 1");
8880
- await new Promise((resolve2) => setTimeout(resolve2, 100));
8881
- process.exit(1);
8882
- }, 1e3);
8883
- resolve({ source, errorMessage });
8884
- };
8885
- });
8886
- process.on("SIGINT", () => {
8887
- logger.debug("[DAEMON RUN] Received SIGINT");
8888
- requestShutdown("os-signal");
8889
- });
8890
- process.on("SIGTERM", () => {
8891
- logger.debug("[DAEMON RUN] Received SIGTERM");
8892
- requestShutdown("os-signal");
8893
- });
8894
- process.on("uncaughtException", (error) => {
8895
- logger.debug("[DAEMON RUN] FATAL: Uncaught exception", error);
8896
- logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`);
8897
- requestShutdown("exception", error.message);
8898
- });
8899
- const prunedLogs = pruneLogsDir(configuration.logsDir, /* @__PURE__ */ new Set([logger.logFilePath]));
8900
- if (prunedLogs > 0) {
8901
- logger.debug(`[DAEMON RUN] Pruned ${prunedLogs} old log file(s) from ${configuration.logsDir}`);
8886
+ function formatTranscript(bodies) {
8887
+ const lines = [];
8888
+ for (const body of bodies) {
8889
+ const line = formatMessageBody(body);
8890
+ if (line !== null) lines.push(line);
8902
8891
  }
8903
- process.on("unhandledRejection", (reason, promise) => {
8904
- logger.debug("[DAEMON RUN] FATAL: Unhandled promise rejection", reason);
8905
- logger.debug(`[DAEMON RUN] Rejected promise:`, promise);
8906
- const error = reason instanceof Error ? reason : new Error(`Unhandled promise rejection: ${reason}`);
8907
- logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`);
8908
- requestShutdown("exception", error.message);
8909
- });
8910
- process.on("exit", (code) => {
8911
- logger.debug(`[DAEMON RUN] Process exiting with code: ${code}`);
8892
+ return lines.join("\n");
8893
+ }
8894
+
8895
+ const USER_SOCKET_CONNECT_TIMEOUT_MS = 15e3;
8896
+ async function openUserScopedSocket(token, options = {}) {
8897
+ const socket = io(configuration.serverUrl, {
8898
+ auth: {
8899
+ token,
8900
+ clientType: "user-scoped",
8901
+ happyClient: `cli-session-ops/${configuration.currentCliVersion}`
8902
+ },
8903
+ path: "/v1/updates",
8904
+ transports: ["websocket"],
8905
+ reconnection: false,
8906
+ autoConnect: false
8912
8907
  });
8913
- process.on("beforeExit", (code) => {
8914
- logger.debug(`[DAEMON RUN] Process about to exit with code: ${code}`);
8908
+ await new Promise((resolve, reject) => {
8909
+ const timer = setTimeout(() => {
8910
+ socket.close();
8911
+ reject(new Error(`Timed out connecting to ${configuration.serverUrl}`));
8912
+ }, options.connectTimeoutMs ?? USER_SOCKET_CONNECT_TIMEOUT_MS);
8913
+ socket.once("connect", () => {
8914
+ clearTimeout(timer);
8915
+ resolve();
8916
+ });
8917
+ socket.once("connect_error", (error) => {
8918
+ clearTimeout(timer);
8919
+ socket.close();
8920
+ reject(new Error(`Could not connect to ${configuration.serverUrl}: ${error.message}`));
8921
+ });
8922
+ socket.connect();
8915
8923
  });
8916
- logger.debug("[DAEMON RUN] Starting daemon process...");
8917
- logger.debugLargeJson("[DAEMON RUN] Environment", getEnvironmentInfo());
8918
- const runningDaemonVersionMatches = await isDaemonRunningCurrentlyInstalledHappyVersion();
8919
- const runningDaemonState = await readDaemonState();
8920
- const runningDaemonEndpointsMatch = Boolean(runningDaemonState) && daemonEndpointsMatch(
8921
- runningDaemonState?.serverUrl,
8922
- runningDaemonState?.webappUrl,
8923
- configuration.serverUrl,
8924
- configuration.webappUrl
8925
- );
8926
- if (!runningDaemonVersionMatches || !runningDaemonEndpointsMatch) {
8927
- logger.debug("[DAEMON RUN] Daemon version or endpoint mismatch detected, restarting daemon");
8928
- await stopDaemon();
8929
- } else {
8930
- logger.debug("[DAEMON RUN] Daemon version matches, keeping existing daemon");
8931
- console.log("Daemon already running with matching version");
8932
- process.exit(0);
8933
- }
8934
- const daemonLockHandle = await acquireDaemonLock(5, 200);
8935
- if (!daemonLockHandle) {
8936
- logger.debug("[DAEMON RUN] Daemon lock file already held, another daemon is running");
8937
- process.exit(0);
8938
- }
8939
- try {
8940
- const caffeinateStarted = startCaffeinate();
8941
- if (caffeinateStarted) {
8942
- logger.debug("[DAEMON RUN] Sleep prevention enabled");
8924
+ return {
8925
+ async rpcCall(payload, timeoutMs) {
8926
+ const ack = await socket.timeout(timeoutMs).emitWithAck("rpc-call", payload);
8927
+ return ack;
8928
+ },
8929
+ close() {
8930
+ socket.close();
8943
8931
  }
8944
- const { credentials, machineId } = await authAndSetupMachineIfNeeded();
8945
- logger.debug("[DAEMON RUN] Auth and machine setup complete");
8946
- const pidToTrackedSession = /* @__PURE__ */ new Map();
8947
- const sessionIdToFinishedSession = /* @__PURE__ */ new Map();
8948
- const persisted = readPersistedSessions();
8949
- const liveHappyProcesses = await findAllHappyProcesses();
8950
- for (const [id, s] of Object.entries(persisted)) {
8951
- const tracked = {
8952
- startedBy: "persisted",
8953
- happySessionId: id,
8954
- happySessionMetadataFromLocalWebhook: s.metadata,
8955
- encryption: {
8956
- encryptionKey: decodeBase64(s.encryptionKey),
8957
- encryptionVariant: s.encryptionVariant,
8958
- seq: s.seq,
8959
- metadataVersion: s.metadataVersion,
8960
- agentStateVersion: s.agentStateVersion
8961
- },
8962
- pid: 0
8963
- };
8964
- const livePids = findSessionWrapperPids(s.metadata, liveHappyProcesses, { excludePid: process.pid, lockPid: readSessionLock(id)?.pid });
8965
- if (livePids.length > 0) {
8966
- const [livePid, ...duplicates] = livePids;
8967
- tracked.pid = livePid;
8968
- tracked.startedBy = "recovered after daemon restart";
8969
- pidToTrackedSession.set(livePid, tracked);
8970
- if (livePid !== s.metadata?.hostPid) {
8971
- logger.debug(`[DAEMON RUN] Session ${id}: persisted hostPid ${s.metadata?.hostPid} is stale; adopted live wrapper ${livePid} by its --resume command line`);
8972
- }
8973
- for (const dup of duplicates) {
8974
- logger.warn(`[DAEMON RUN] Session ${id} has a duplicate live wrapper ${dup} (kept ${livePid}); restart the session to collapse them`);
8975
- pidToTrackedSession.set(dup, { ...tracked, pid: dup, startedBy: "recovered after daemon restart (duplicate wrapper)" });
8976
- }
8977
- persistSession(id, { ...s, metadata: { ...s.metadata, hostPid: livePid }, savedAt: Date.now() });
8978
- } else {
8979
- sessionIdToFinishedSession.set(id, tracked);
8980
- }
8932
+ };
8933
+ }
8934
+
8935
+ const RPC_RATE_LIMIT_ERROR = "RPC rate limit reached";
8936
+ const RPC_ACCOUNT_RATE_LIMIT_ERROR = "RPC account rate limit reached";
8937
+ const RPC_RATE_RETRY_MIN_DELAY_MS = 2e3;
8938
+ const RPC_RATE_RETRY_MAX_DELAY_MS = 6e4;
8939
+ const RPC_RATE_MAX_RETRIES = 2;
8940
+ function isRpcRateLimitAck(ack) {
8941
+ if (!ack || typeof ack !== "object") return false;
8942
+ const a = ack;
8943
+ if (a.ok !== false) return false;
8944
+ if (a.code === "rpc_rate_limited" || a.code === "rpc_account_rate_limited") return true;
8945
+ return a.error === RPC_RATE_LIMIT_ERROR || a.error === RPC_ACCOUNT_RATE_LIMIT_ERROR;
8946
+ }
8947
+ function rpcRateRetryDelayMs(attempt, retryAfterMs, random = Math.random()) {
8948
+ if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
8949
+ return Math.min(RPC_RATE_RETRY_MAX_DELAY_MS, Math.round(retryAfterMs * (1 + random * 0.5)));
8950
+ }
8951
+ const exponent = Math.max(0, Math.min(attempt, 32) - 1);
8952
+ const ceiling = Math.min(RPC_RATE_RETRY_MIN_DELAY_MS * 2 ** exponent, RPC_RATE_RETRY_MAX_DELAY_MS);
8953
+ return Math.round(RPC_RATE_RETRY_MIN_DELAY_MS + random * (ceiling - RPC_RATE_RETRY_MIN_DELAY_MS));
8954
+ }
8955
+
8956
+ const PERMISSION_OPS_CLIENT = "session-ops";
8957
+ const PERMISSION_RPC_TIMEOUT_MS = 3e4;
8958
+ const SETTLE_TIMEOUT_MS = 5e3;
8959
+ const SETTLE_POLL_MS = 500;
8960
+ function buildPermissionRpcPayload(requestId, verdict) {
8961
+ if (verdict.kind === "approve") {
8962
+ return {
8963
+ id: requestId,
8964
+ approved: true,
8965
+ decision: verdict.forSession ? "approved_for_session" : "approved"
8966
+ };
8967
+ }
8968
+ return {
8969
+ id: requestId,
8970
+ approved: false,
8971
+ decision: "denied",
8972
+ ...verdict.reason ? { reason: verdict.reason } : {}
8973
+ };
8974
+ }
8975
+ function interpretPermissionAck(ack, decryptResult) {
8976
+ if (!ack.ok) {
8977
+ const message = ack.error ?? "RPC call failed";
8978
+ if (message === "RPC method not available") {
8979
+ return { status: "offline", message: "no running wrapper has registered the permission RPC for this session" };
8981
8980
  }
8982
- if (Object.keys(persisted).length > 0) {
8983
- logger.debug(`[DAEMON RUN] Loaded ${Object.keys(persisted).length} persisted sessions from disk`);
8981
+ if (message === "RPC target disconnected") {
8982
+ return { status: "offline", message: "the wrapper disconnected while the permission RPC was in flight" };
8984
8983
  }
8985
- const pidToAwaiter = /* @__PURE__ */ new Map();
8986
- const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
8987
- const onHappySessionWebhook = (sessionId, sessionMetadata, encryption) => {
8988
- logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata);
8989
- const pid = sessionMetadata.hostPid;
8990
- if (!pid) {
8991
- logger.debug(`[DAEMON RUN] Session webhook missing hostPid for sessionId: ${sessionId}`);
8992
- return;
8993
- }
8994
- logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || "unknown"}, hasEncryption: ${!!encryption}`);
8995
- logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(", ")}`);
8996
- if (encryption) {
8997
- persistSession(sessionId, {
8998
- encryptionKey: encodeBase64(encryption.encryptionKey),
8999
- encryptionVariant: encryption.encryptionVariant,
9000
- seq: encryption.seq,
9001
- metadataVersion: encryption.metadataVersion,
9002
- agentStateVersion: encryption.agentStateVersion,
9003
- metadata: sessionMetadata,
9004
- savedAt: Date.now()
9005
- });
9006
- }
9007
- for (const [otherPid, other] of pidToTrackedSession.entries()) {
9008
- if (other.happySessionId === sessionId && otherPid !== pid && isPidAlive(otherPid)) {
9009
- logger.warn(`[DAEMON RUN] Session ${sessionId} now has two live wrappers (${otherPid} and ${pid}); restart the session to collapse them`);
9010
- }
9011
- }
9012
- const existingSession = pidToTrackedSession.get(pid);
9013
- if (existingSession && existingSession.startedBy === "daemon") {
9014
- existingSession.happySessionId = sessionId;
9015
- existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata;
9016
- existingSession.encryption = encryption;
9017
- logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`);
9018
- const awaiter = pidToAwaiter.get(pid);
9019
- if (awaiter) {
9020
- pidToAwaiter.delete(pid);
9021
- awaiter(existingSession);
9022
- logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`);
9023
- }
9024
- } else if (!existingSession) {
9025
- const trackedSession = {
9026
- startedBy: "happy directly - likely by user from terminal",
9027
- happySessionId: sessionId,
9028
- happySessionMetadataFromLocalWebhook: sessionMetadata,
9029
- encryption,
9030
- pid
9031
- };
9032
- pidToTrackedSession.set(pid, trackedSession);
9033
- logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`);
9034
- }
9035
- };
9036
- const assistantSpawnGate = createSpawnGate();
9037
- const spawnSession = (options) => {
9038
- if (assistantSpawnMode(options) !== "claude-singleton") {
9039
- return spawnSessionImpl(options);
9040
- }
9041
- return options.forceNew ? assistantSpawnGate.replace(() => spawnSessionImpl(options)) : assistantSpawnGate.join(() => spawnSessionImpl(options));
9042
- };
9043
- const currentCliAvailability = () => apiMachineRef?.getCLIAvailability() ?? startupCliAvailability;
9044
- const spawnSessionImpl = async (options) => {
9045
- logger.debugLargeJson("[DAEMON RUN] Spawning session", summarizeSpawnSessionForLog(options));
9046
- const spawnPermissionMode = sanitizeSpawnPermissionMode(options.permissionMode);
9047
- if (options.permissionMode !== void 0 && spawnPermissionMode === null) {
9048
- logger.warn("[DAEMON RUN] Ignoring invalid permissionMode in spawn request");
8984
+ if (/timed? ?out/i.test(message)) {
8985
+ return { status: "timeout", message };
8986
+ }
8987
+ if (isRpcRateLimitAck(ack)) {
8988
+ return { status: "rate-limited", message, retryAfterMs: rpcRateRetryDelayMs(1, ack.retryAfterMs, 0) };
8989
+ }
8990
+ return { status: "rejected", message };
8991
+ }
8992
+ let body = null;
8993
+ if (typeof ack.result === "string" && ack.result.length > 0) {
8994
+ try {
8995
+ body = decryptResult(ack.result);
8996
+ } catch {
8997
+ body = null;
8998
+ }
8999
+ }
9000
+ if (body && typeof body === "object" && typeof body.error === "string") {
9001
+ return { status: "handler-error", message: body.error };
9002
+ }
9003
+ return { status: "acknowledged" };
9004
+ }
9005
+ function pendingRequestsOf(agentState, now) {
9006
+ const requests = agentState?.requests;
9007
+ if (!requests || typeof requests !== "object") return [];
9008
+ return Object.entries(requests).map(([id, request]) => ({
9009
+ id,
9010
+ tool: typeof request?.tool === "string" ? request.tool : "unknown",
9011
+ ...request?.kind ? { kind: request.kind } : {},
9012
+ ...typeof request?.createdAt === "number" ? { createdAt: request.createdAt, waitingMs: Math.max(0, now - request.createdAt) } : {}
9013
+ })).sort((a, b) => (a.createdAt ?? Number.MAX_SAFE_INTEGER) - (b.createdAt ?? Number.MAX_SAFE_INTEGER));
9014
+ }
9015
+ const RPC_DEADLINE_GRACE_MS = 2e3;
9016
+ function withDeadline(promise, ms, message) {
9017
+ return new Promise((resolve, reject) => {
9018
+ const timer = setTimeout(() => reject(new Error(`${message} (timed out)`)), ms);
9019
+ promise.then(
9020
+ (value) => {
9021
+ clearTimeout(timer);
9022
+ resolve(value);
9023
+ },
9024
+ (error) => {
9025
+ clearTimeout(timer);
9026
+ reject(error);
9049
9027
  }
9050
- const unavailable = spawnAgentUnavailableError(options.agent, currentCliAvailability());
9051
- if (unavailable) {
9052
- logger.warn(`[DAEMON RUN] Refusing spawn of agent '${options.agent}': ${unavailable.errorMessage}`);
9053
- return unavailable;
9028
+ );
9029
+ });
9030
+ }
9031
+ async function resolvePermissionRequest(sessionId, requestId, verdict, deps = {}) {
9032
+ const now = deps.now ?? Date.now;
9033
+ const persisted = (deps.readPersisted ?? readPersistedSessions)()[sessionId];
9034
+ if (!persisted) {
9035
+ throw new Error(
9036
+ `No local key for session ${sessionId} \u2014 approve/deny needs the session key from ~/.happy/sessions.json (the RPC payload is encrypted with it), so only sessions this machine's daemon spawned within 14 days can be answered from here.`
9037
+ );
9038
+ }
9039
+ const token = await (deps.bearerToken ?? bearerToken$1)();
9040
+ const fetchState = deps.fetchAgentState ?? fetchDecryptedAgentState;
9041
+ const before = pendingRequestsOf(await fetchState(sessionId, persisted, token), now());
9042
+ if (!before.some((request) => request.id === requestId)) {
9043
+ const hint = before.length > 0 ? `Pending request ids: ${before.map((request) => `${request.id} (${request.tool})`).join(", ")}` : "The session has no pending requests.";
9044
+ throw new Error(`Request ${requestId} is not pending on session ${sessionId}. ${hint}`);
9045
+ }
9046
+ const payload = buildPermissionRpcPayload(requestId, verdict);
9047
+ const key = decodeBase64(persisted.encryptionKey);
9048
+ const params = encodeBase64(encrypt(key, persisted.encryptionVariant, payload));
9049
+ const transport = await (deps.openTransport ?? openUserScopedSocket)(token);
9050
+ const rpcTimeoutMs = deps.rpcTimeoutMs ?? PERMISSION_RPC_TIMEOUT_MS;
9051
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
9052
+ let ack;
9053
+ try {
9054
+ for (let attempt = 1; ; attempt++) {
9055
+ try {
9056
+ ack = await withDeadline(
9057
+ transport.rpcCall({ method: `${sessionId}:permission`, params }, rpcTimeoutMs),
9058
+ rpcTimeoutMs + RPC_DEADLINE_GRACE_MS,
9059
+ `permission RPC did not settle within ${rpcTimeoutMs}ms`
9060
+ );
9061
+ } catch (error) {
9062
+ ack = { ok: false, error: error instanceof Error ? error.message : "RPC call failed" };
9054
9063
  }
9055
- const assistantMode = assistantSpawnMode(options);
9056
- const trackedVariant = assistantMode === "claude-singleton" ? options.variant : void 0;
9057
- if (assistantMode === "claude-singleton") {
9058
- options = { ...options, directory: assistantHome() };
9059
- if (options.forceNew) {
9060
- for (const tracked of [...pidToTrackedSession.values()]) {
9061
- if (!isAssistantTracked(tracked)) continue;
9062
- logger.debug(`[DAEMON RUN] forceNew: stopping assistant (session ${tracked.happySessionId ?? "unknown"}, pid ${tracked.pid})`);
9063
- stopSession(tracked.happySessionId ?? `PID-${tracked.pid}`);
9064
- }
9065
- const assistantIds = listPersistedAssistantIds(readPersistedSessions());
9066
- if (assistantIds.length > 0) {
9067
- deletePersistedSessions(assistantIds);
9068
- for (const id of assistantIds) {
9069
- sessionIdToFinishedSession.delete(id);
9070
- }
9071
- logger.debug(`[DAEMON RUN] forceNew: purged persisted assistant session(s): ${assistantIds.join(", ")}`);
9072
- }
9073
- } else {
9074
- const live = findLiveAssistant(pidToTrackedSession.values(), (pid) => {
9075
- try {
9076
- process.kill(pid, 0);
9077
- return true;
9078
- } catch {
9079
- return false;
9080
- }
9081
- });
9082
- if (live?.happySessionId) {
9083
- logger.debug(`[DAEMON RUN] Assistant already running (session ${live.happySessionId}, pid ${live.pid})`);
9084
- return { type: "success", sessionId: live.happySessionId };
9085
- }
9086
- if (live) {
9087
- return { type: "error", errorMessage: `Assistant session is still starting (pid ${live.pid}). Try again in a few seconds.` };
9088
- }
9089
- }
9090
- try {
9091
- const { created } = await bootstrapAssistantHome();
9092
- if (created.length > 0) {
9093
- logger.debug(`[DAEMON RUN] Assistant home bootstrapped: ${created.join(", ")}`);
9094
- }
9095
- } catch (error) {
9096
- const errorMessage = error instanceof Error ? error.message : String(error);
9097
- return { type: "error", errorMessage: `Failed to bootstrap assistant home: ${errorMessage}` };
9098
- }
9099
- const assistantEntry = options.forceNew ? void 0 : pickLatestAssistantEntry(readPersistedSessions());
9100
- if (assistantEntry) {
9101
- const [assistantSessionId, s] = assistantEntry;
9102
- logger.debug(`[DAEMON RUN] Re-attaching assistant session ${assistantSessionId}`);
9103
- const serverMetadata = await fetchServerSessionMetadata(
9104
- assistantSessionId,
9105
- decodeBase64(s.encryptionKey),
9106
- s.encryptionVariant
9107
- );
9108
- const claudeSessionId = resolveAssistantClaudeSessionId(s.metadata, serverMetadata);
9109
- const canResumeClaude = !!claudeSessionId && existsSync$1(join$1(getProjectPath(assistantHome()), `${claudeSessionId}.jsonl`));
9110
- return spawnTrackedHappyProcess({
9111
- args: [
9112
- "claude",
9113
- "--happy-starting-mode",
9114
- "remote",
9115
- "--started-by",
9116
- "daemon",
9117
- // Re-attach honors the CURRENT request's permission mode (the
9118
- // user may have just toggled the assistant permission setting).
9119
- ...spawnPermissionMode ? ["--permission-mode", spawnPermissionMode] : [],
9120
- ...canResumeClaude ? ["--resume", claudeSessionId] : []
9121
- ],
9122
- cwd: assistantHome(),
9123
- env: {
9124
- ...process.env,
9125
- // B-297: see the resume/restart paths — every spawn carries the
9126
- // credentialStore=file shim, not just fresh sessions (B-276 D8).
9127
- ...claudeAuthServiceRef?.claudeProcessEnvOverrides() ?? {},
9128
- HAPPY_SESSION_VARIANT: "assistant",
9129
- HAPPY_RECONNECT_SESSION_ID: assistantSessionId,
9130
- HAPPY_RECONNECT_ENCRYPTION_KEY: s.encryptionKey,
9131
- HAPPY_RECONNECT_ENCRYPTION_VARIANT: s.encryptionVariant,
9132
- HAPPY_RECONNECT_SEQ: String(s.seq),
9133
- HAPPY_RECONNECT_METADATA_VERSION: String(s.metadataVersion),
9134
- HAPPY_RECONNECT_AGENT_STATE_VERSION: String(s.agentStateVersion)
9135
- },
9136
- variant: "assistant"
9137
- });
9138
- }
9139
- }
9140
- const { directory, sessionId, machineId: machineId2, approvedNewDirectoryCreation = true } = options;
9141
- let directoryCreated = false;
9142
- try {
9143
- await fs.access(directory);
9144
- logger.debug(`[DAEMON RUN] Directory exists: ${directory}`);
9145
- } catch (error) {
9146
- logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`);
9147
- if (!approvedNewDirectoryCreation) {
9148
- logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`);
9149
- return {
9150
- type: "requestToApproveDirectoryCreation",
9151
- directory
9152
- };
9153
- }
9154
- try {
9155
- await fs.mkdir(directory, { recursive: true });
9156
- logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`);
9157
- directoryCreated = true;
9158
- } catch (mkdirError) {
9159
- let errorMessage = `Unable to create directory at '${directory}'. `;
9160
- if (mkdirError.code === "EACCES") {
9161
- errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`;
9162
- } else if (mkdirError.code === "ENOTDIR") {
9163
- errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`;
9164
- } else if (mkdirError.code === "ENOSPC") {
9165
- errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`;
9166
- } else if (mkdirError.code === "EROFS") {
9167
- errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`;
9168
- } else {
9169
- errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`;
9170
- }
9171
- logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`);
9172
- return {
9173
- type: "error",
9174
- errorMessage
9175
- };
9064
+ if (!isRpcRateLimitAck(ack) || attempt > RPC_RATE_MAX_RETRIES) break;
9065
+ const waitMs = rpcRateRetryDelayMs(attempt, ack.retryAfterMs, (deps.random ?? Math.random)());
9066
+ deps.onRateLimited?.({ attempt, waitMs, serverError: ack.error ?? "RPC rate limit reached" });
9067
+ await sleep(waitMs);
9068
+ }
9069
+ } finally {
9070
+ transport.close();
9071
+ }
9072
+ const outcome = interpretPermissionAck(ack, (encrypted) => decrypt(key, persisted.encryptionVariant, decodeBase64(encrypted)));
9073
+ if (outcome.status !== "acknowledged") {
9074
+ return { sessionId, requestId, payload, outcome };
9075
+ }
9076
+ const deadline = now() + (deps.settleTimeoutMs ?? SETTLE_TIMEOUT_MS);
9077
+ let settled = false;
9078
+ for (; ; ) {
9079
+ const pending = pendingRequestsOf(await fetchState(sessionId, persisted, token), now());
9080
+ if (!pending.some((request) => request.id === requestId)) {
9081
+ settled = true;
9082
+ break;
9083
+ }
9084
+ if (now() >= deadline) break;
9085
+ await sleep(SETTLE_POLL_MS);
9086
+ }
9087
+ return { sessionId, requestId, payload, outcome, settled };
9088
+ }
9089
+ async function bearerToken$1() {
9090
+ const credentials = await readCredentialsForConfiguredRelay();
9091
+ if (!credentials) throw new Error("CLI is not authenticated (no ~/.happy/access.key)");
9092
+ return credentials.token;
9093
+ }
9094
+ async function fetchDecryptedAgentState(sessionId, persisted, token) {
9095
+ const response = await axios.get(
9096
+ `${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
9097
+ {
9098
+ headers: {
9099
+ "Authorization": `Bearer ${token}`,
9100
+ "X-Happy-Client": `${PERMISSION_OPS_CLIENT}/${configuration.currentCliVersion}`
9101
+ },
9102
+ timeout: 15e3,
9103
+ // 404 is a real answer ("not yours / gone"), not a transport fault.
9104
+ validateStatus: (status) => status >= 200 && status < 300 || status === 404
9105
+ }
9106
+ );
9107
+ if (response.status === 404) {
9108
+ throw new Error(`Session ${sessionId} was not found on this account (it may have been deleted, or the local key belongs to a session on another relay).`);
9109
+ }
9110
+ const raw = response.data?.session?.agentState;
9111
+ if (typeof raw !== "string" || raw.length === 0) return null;
9112
+ return decrypt(decodeBase64(persisted.encryptionKey), persisted.encryptionVariant, decodeBase64(raw));
9113
+ }
9114
+
9115
+ const SESSION_OPS_CLIENT = "session-ops";
9116
+ const DEFAULT_RECENT_LIMIT = 15;
9117
+ const MAX_READ_LIMIT = 100;
9118
+ function mergeSessionSummaries(live, persisted, options = {}) {
9119
+ const recentLimit = Math.max(0, options.recentLimit ?? DEFAULT_RECENT_LIMIT);
9120
+ const seen = /* @__PURE__ */ new Set();
9121
+ const summaries = [];
9122
+ for (const child of live) {
9123
+ const id = typeof child.happySessionId === "string" ? child.happySessionId : void 0;
9124
+ if (!id || seen.has(id)) continue;
9125
+ seen.add(id);
9126
+ summaries.push(toSummary(id, persisted[id], livenessOf(child)));
9127
+ }
9128
+ const rest = Object.entries(persisted).filter(([id]) => !seen.has(id)).filter(([, entry]) => entry.metadata?.flavor !== "terminal-mirror").sort((a, b) => b[1].savedAt - a[1].savedAt).slice(0, recentLimit);
9129
+ for (const [id, entry] of rest) {
9130
+ summaries.push(toSummary(id, entry, { live: false }));
9131
+ }
9132
+ if (!options.tag) return summaries;
9133
+ const wanted = options.tag;
9134
+ return summaries.filter((summary) => summary.tags?.includes(wanted) === true);
9135
+ }
9136
+ function livenessOf(child) {
9137
+ return { live: true, pid: typeof child.pid === "number" ? child.pid : void 0 };
9138
+ }
9139
+ function sessionLiveness(live, sessionId) {
9140
+ const child = live.find((entry) => entry.happySessionId === sessionId);
9141
+ return child ? livenessOf(child) : { live: false };
9142
+ }
9143
+ function toSummary(id, persisted, extra) {
9144
+ const meta = persisted?.metadata;
9145
+ return {
9146
+ id,
9147
+ live: extra.live,
9148
+ ...extra.pid !== void 0 ? { pid: extra.pid } : {},
9149
+ ...meta?.summary?.text ? { title: meta.summary.text } : {},
9150
+ ...meta?.path ? { cwd: meta.path } : {},
9151
+ ...meta?.flavor ? { flavor: meta.flavor } : {},
9152
+ ...meta?.tags?.length ? { tags: [...meta.tags] } : {},
9153
+ ...meta?.variant ? { variant: meta.variant } : {},
9154
+ url: sessionWebUrl(id),
9155
+ ...persisted?.savedAt !== void 0 ? { savedAt: persisted.savedAt } : {}
9156
+ };
9157
+ }
9158
+ async function listSessions(options = {}) {
9159
+ const live = await listDaemonSessions();
9160
+ return mergeSessionSummaries(live, readPersistedSessions(), options);
9161
+ }
9162
+ function summarizeAccountSession(row, persisted, liveIds, now) {
9163
+ const base = {
9164
+ id: row.id,
9165
+ live: liveIds.has(row.id),
9166
+ url: sessionWebUrl(row.id),
9167
+ decryptable: false,
9168
+ active: row.active === true,
9169
+ archived: typeof row.archivedAt === "number",
9170
+ ...typeof row.activeAt === "number" ? { activeAt: row.activeAt } : {},
9171
+ ...typeof row.updatedAt === "number" ? { updatedAt: row.updatedAt } : {},
9172
+ attention: false
9173
+ };
9174
+ if (!persisted) return base;
9175
+ const key = decodeBase64(persisted.encryptionKey);
9176
+ let metadata = null;
9177
+ let agentState = null;
9178
+ try {
9179
+ metadata = row.metadata ? decrypt(key, persisted.encryptionVariant, decodeBase64(row.metadata)) : null;
9180
+ agentState = row.agentState ? decrypt(key, persisted.encryptionVariant, decodeBase64(row.agentState)) : null;
9181
+ } catch {
9182
+ return base;
9183
+ }
9184
+ if (!metadata) return base;
9185
+ const meta = metadata;
9186
+ const pending = pendingRequestsOf(agentState, now);
9187
+ return {
9188
+ ...base,
9189
+ decryptable: true,
9190
+ ...meta.summary?.text ? { title: meta.summary.text } : {},
9191
+ ...meta.path ? { cwd: meta.path } : {},
9192
+ ...meta.flavor ? { flavor: meta.flavor } : {},
9193
+ ...meta.tags?.length ? { tags: [...meta.tags] } : {},
9194
+ ...meta.variant ? { variant: meta.variant } : {},
9195
+ ...meta.machineId ? { machineId: meta.machineId } : {},
9196
+ ...persisted.savedAt !== void 0 ? { savedAt: persisted.savedAt } : {},
9197
+ pending,
9198
+ attention: pending.length > 0
9199
+ };
9200
+ }
9201
+ function orderAccountSessions(summaries, options = {}) {
9202
+ const oldestWait = (summary) => Math.max(0, ...(summary.pending ?? []).map((request) => request.waitingMs ?? 0));
9203
+ const rank = (summary) => summary.attention ? 0 : summary.live ? 1 : 2;
9204
+ let rows = summaries.filter((summary) => summary.flavor !== "terminal-mirror").slice().sort((a, b) => {
9205
+ const byRank = rank(a) - rank(b);
9206
+ if (byRank !== 0) return byRank;
9207
+ if (a.attention && b.attention) return oldestWait(b) - oldestWait(a);
9208
+ return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
9209
+ });
9210
+ if (options.tag) {
9211
+ const wanted = options.tag;
9212
+ rows = rows.filter((summary) => summary.tags?.includes(wanted) === true);
9213
+ }
9214
+ const recentLimit = Math.max(0, options.recentLimit ?? DEFAULT_RECENT_LIMIT);
9215
+ let idleKept = 0;
9216
+ return rows.filter((summary) => rank(summary) < 2 || idleKept++ < recentLimit);
9217
+ }
9218
+ async function listAccountSessions(options = {}) {
9219
+ const token = await bearerToken();
9220
+ const response = await axios.get(`${configuration.serverUrl}/v1/sessions`, {
9221
+ headers: {
9222
+ "Authorization": `Bearer ${token}`,
9223
+ "X-Happy-Client": `${SESSION_OPS_CLIENT}/${configuration.currentCliVersion}`
9224
+ },
9225
+ timeout: 15e3
9226
+ });
9227
+ const rows = Array.isArray(response.data?.sessions) ? response.data.sessions : [];
9228
+ const persisted = readPersistedSessions();
9229
+ const live = await listDaemonSessions();
9230
+ const liveIds = new Set(live.map((child) => child.happySessionId).filter((id) => typeof id === "string"));
9231
+ const now = Date.now();
9232
+ const summaries = rows.filter((row) => typeof row?.id === "string").map((row) => summarizeAccountSession(row, persisted[row.id], liveIds, now)).filter((summary) => options.includeArchived || !summary.archived);
9233
+ return orderAccountSessions(summaries, options);
9234
+ }
9235
+ async function bearerToken() {
9236
+ const credentials = await readCredentialsForConfiguredRelay();
9237
+ if (!credentials) throw new Error("CLI is not authenticated (no ~/.happy/access.key)");
9238
+ return credentials.token;
9239
+ }
9240
+ async function readSessionTranscript(sessionId, limit) {
9241
+ const persisted = readPersistedSessions()[sessionId];
9242
+ if (!persisted) {
9243
+ throw new Error(
9244
+ `No local key for session ${sessionId} \u2014 it was not spawned by this machine's daemon (or is older than 14 days).`
9245
+ );
9246
+ }
9247
+ const bounded = Math.max(1, Math.min(MAX_READ_LIMIT, Math.floor(limit)));
9248
+ const token = await bearerToken();
9249
+ const response = await axios.get(
9250
+ `${configuration.serverUrl}/v3/sessions/${encodeURIComponent(sessionId)}/messages`,
9251
+ {
9252
+ params: { before_seq: 2147483647, limit: bounded },
9253
+ headers: {
9254
+ "Authorization": `Bearer ${token}`,
9255
+ "X-Happy-Client": `${SESSION_OPS_CLIENT}/${configuration.currentCliVersion}`
9256
+ },
9257
+ timeout: 15e3
9258
+ }
9259
+ );
9260
+ const messages = Array.isArray(response.data?.messages) ? response.data.messages : [];
9261
+ messages.reverse();
9262
+ const key = decodeBase64(persisted.encryptionKey);
9263
+ const live = await listDaemonSessions();
9264
+ const bodies = messages.map((message) => {
9265
+ if (message.content?.t !== "encrypted") return null;
9266
+ try {
9267
+ return decrypt(key, persisted.encryptionVariant, decodeBase64(message.content.c));
9268
+ } catch {
9269
+ return null;
9270
+ }
9271
+ });
9272
+ return {
9273
+ summary: toSummary(sessionId, persisted, sessionLiveness(live, sessionId)),
9274
+ messageCount: messages.length,
9275
+ transcript: formatTranscript(bodies)
9276
+ };
9277
+ }
9278
+ async function stopSession(sessionId) {
9279
+ return await stopDaemonSession(sessionId);
9280
+ }
9281
+ async function archiveSession(sessionId) {
9282
+ const token = await bearerToken();
9283
+ await axios.post(
9284
+ `${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}/archive`,
9285
+ {},
9286
+ {
9287
+ headers: {
9288
+ "Authorization": `Bearer ${token}`,
9289
+ "X-Happy-Client": `${SESSION_OPS_CLIENT}/${configuration.currentCliVersion}`
9290
+ },
9291
+ timeout: 1e4
9292
+ }
9293
+ );
9294
+ }
9295
+
9296
+ function receiptPath(home, operationId) {
9297
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(operationId)) throw new Error("Invalid team operation id");
9298
+ return join(home, "teams", "effects", `${operationId}.json`);
9299
+ }
9300
+ function readReceipt(home, id) {
9301
+ try {
9302
+ const value = JSON.parse(readFileSync(receiptPath(home, id), "utf8"));
9303
+ if (value.operationId !== id || typeof value.claimId !== "string" || !["prepared", "spawning", "spawned", "delivered", "completed"].includes(value.phase)) throw new Error("Invalid team effect receipt");
9304
+ return value;
9305
+ } catch (error) {
9306
+ if (error.code === "ENOENT") return null;
9307
+ throw error;
9308
+ }
9309
+ }
9310
+ function writeReceipt(home, receipt) {
9311
+ ensurePrivateDirectorySync(join(home, "teams", "effects"));
9312
+ const target = receiptPath(home, receipt.operationId);
9313
+ const tmp = `${target}.${process.pid}.tmp`;
9314
+ writePrivateFileSync(tmp, JSON.stringify(receipt));
9315
+ const file = openSync(tmp, "r");
9316
+ try {
9317
+ fsyncSync(file);
9318
+ } finally {
9319
+ closeSync(file);
9320
+ }
9321
+ renameSync(tmp, target);
9322
+ if (process.platform !== "win32") {
9323
+ const directory = openSync(join(home, "teams", "effects"), "r");
9324
+ try {
9325
+ fsyncSync(directory);
9326
+ } finally {
9327
+ closeSync(directory);
9328
+ }
9329
+ }
9330
+ }
9331
+
9332
+ const exec = promisify(execFile);
9333
+ const git = async (directory, args) => (await exec("git", ["-C", directory, ...args], { timeout: 3e4, maxBuffer: 2 * 1024 * 1024 })).stdout.trim();
9334
+ async function prepareTeamWorktree(home, repository, id) {
9335
+ if (!isAbsolute(repository) || !/^[a-zA-Z0-9_-]{1,128}$/.test(id)) throw new Error("A team task requires an absolute repository path and valid operation id");
9336
+ await git(repository, ["rev-parse", "--show-toplevel"]);
9337
+ const root = join(home, "teams", "worktrees");
9338
+ ensurePrivateDirectorySync(root);
9339
+ const directory = join(root, id);
9340
+ const branch = `codex/team-${id}`;
9341
+ if (existsSync(directory)) {
9342
+ const actual = await git(directory, ["symbolic-ref", "--short", "HEAD"]);
9343
+ if (actual !== branch) throw new Error("Team worktree path is occupied by another branch");
9344
+ const common = await git(directory, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
9345
+ const expected = await git(repository, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
9346
+ if (resolve(common) !== resolve(expected)) throw new Error("Team worktree belongs to another repository");
9347
+ } else {
9348
+ await git(repository, ["worktree", "add", "-b", branch, directory, "HEAD"]);
9349
+ }
9350
+ return { directory, repository, branch };
9351
+ }
9352
+ async function removeTeamWorktree(resource) {
9353
+ const { directory, repository, branch } = resource;
9354
+ if (!directory || !repository || !branch) return;
9355
+ if (existsSync(directory)) {
9356
+ if (await git(directory, ["symbolic-ref", "--short", "HEAD"]) !== branch) throw new Error("Worktree branch changed; preserve for manual reconciliation");
9357
+ const actualCommon = await git(directory, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
9358
+ const expectedCommon = await git(repository, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
9359
+ if (resolve(actualCommon) !== resolve(expectedCommon)) throw new Error("Worktree repository changed; preserve for manual reconciliation");
9360
+ if (await git(directory, ["status", "--porcelain"])) throw new Error(`Uncommitted work retained at ${directory}`);
9361
+ await git(repository, ["merge-base", "--is-ancestor", branch, "HEAD"]).catch(() => {
9362
+ throw new Error(`Unmerged work retained on ${branch} at ${directory}`);
9363
+ });
9364
+ await git(repository, ["worktree", "remove", directory]);
9365
+ }
9366
+ try {
9367
+ await git(repository, ["show-ref", "--verify", `refs/heads/${branch}`]);
9368
+ } catch {
9369
+ return;
9370
+ }
9371
+ await git(repository, ["branch", "-d", branch]);
9372
+ }
9373
+
9374
+ function createTeamWorker(deps) {
9375
+ const home = deps.home ?? configuration.happyHomeDir;
9376
+ const server = deps.serverUrl ?? configuration.serverUrl;
9377
+ const http = axios.create({ baseURL: server, timeout: 2e4, headers: { Authorization: `Bearer ${deps.token}` } });
9378
+ let running = false;
9379
+ let closed = false;
9380
+ let disabledUntil = 0;
9381
+ let knownSessionIds = /* @__PURE__ */ new Set();
9382
+ const action = async (teamId, requestId, value) => (await http.post(`/v1/teams/${encodeURIComponent(teamId)}/actions`, { requestId, action: value })).data;
9383
+ async function complete(op, receipt) {
9384
+ await action(op.teamId, `complete-${op.id}`, { type: "complete-operation", operationId: op.id, machineId: deps.machineId, claimId: receipt.claimId, ...receipt.sessionId ? { sessionId: receipt.sessionId } : {} });
9385
+ writeReceipt(home, { ...receipt, phase: "completed" });
9386
+ }
9387
+ async function execute(op) {
9388
+ let receipt = readReceipt(home, op.id);
9389
+ let credential;
9390
+ if (!receipt) {
9391
+ if (op.status !== "pending") {
9392
+ if (op.status === "claimed" && op.claimId) {
9393
+ await action(op.teamId, `missing-receipt-${op.id}`, { type: "fail-operation", operationId: op.id, machineId: deps.machineId, claimId: op.claimId, error: "Local execution receipt missing; verify the old process before manual reconciliation", unknown: true });
9176
9394
  }
9395
+ return;
9177
9396
  }
9178
- try {
9179
- const authEnv = {};
9180
- if (options.token) {
9181
- if (options.agent === "codex") {
9182
- const codexHomeDir = tmp.dirSync();
9183
- await fs.writeFile(join$1(codexHomeDir.name, "auth.json"), options.token);
9184
- authEnv.CODEX_HOME = codexHomeDir.name;
9185
- } else {
9186
- authEnv.CLAUDE_CODE_OAUTH_TOKEN = options.token;
9187
- }
9188
- }
9189
- let extraEnv = {
9190
- ...authEnv,
9191
- ...options.environmentVariables ?? {},
9192
- // B-276 D8: credentialStore=file pins the spawned Claude Code (SDK
9193
- // Query, title/board one-shots — all inside this wrapper) to
9194
- // ~/.claude/.credentials.json via the keychain-off `security` shim.
9195
- ...claudeAuthServiceRef?.claudeProcessEnvOverrides() ?? {}
9196
- };
9197
- if (options.parentSessionId) {
9198
- extraEnv.HAPPY_FORKED_FROM_SESSION_ID = options.parentSessionId;
9199
- }
9200
- if (options.forkedFromMessageId) {
9201
- extraEnv.HAPPY_FORKED_FROM_MESSAGE_ID = options.forkedFromMessageId;
9202
- }
9203
- if (options.resumeClaudeSessionId) {
9204
- extraEnv.HAPPY_FORK_CLAUDE_SESSION_ID = options.resumeClaudeSessionId;
9205
- }
9206
- if (options.importedFromClaudeSessionId && UUID_RE.test(options.importedFromClaudeSessionId)) {
9207
- extraEnv.HAPPY_IMPORTED_FROM_CLAUDE_SESSION_ID = options.importedFromClaudeSessionId;
9208
- }
9209
- const importTitle = sanitizeImportTitle(options.importTitle);
9210
- if (importTitle) {
9211
- extraEnv.HAPPY_IMPORT_TITLE = importTitle;
9212
- }
9213
- if (options.resumeCodexThreadId) {
9214
- extraEnv.HAPPY_FORK_CODEX_THREAD_ID = options.resumeCodexThreadId;
9215
- }
9216
- if (options.variant === "assistant") {
9217
- extraEnv.HAPPY_SESSION_VARIANT = "assistant";
9397
+ const claimed = await action(op.teamId, `claim-${op.id}`, { type: "claim-operation", operationId: op.id, machineId: deps.machineId });
9398
+ if (!claimed.operation?.claimId) throw new Error("Operation claim did not return a receipt");
9399
+ op = claimed.operation;
9400
+ credential = claimed.credential;
9401
+ receipt = { operationId: op.id, teamId: op.teamId, claimId: op.claimId, phase: "prepared" };
9402
+ writeReceipt(home, receipt);
9403
+ }
9404
+ if (receipt.phase === "completed") return;
9405
+ try {
9406
+ if (op.type === "spawn") {
9407
+ if (receipt.phase === "prepared") {
9408
+ if (!credential) credential = (await action(op.teamId, `claim-${op.id}`, { type: "claim-operation", operationId: op.id, machineId: deps.machineId })).credential;
9409
+ if (!credential) throw new Error("No scoped credential for worker");
9410
+ const current = (await http.get(`/v1/teams/${encodeURIComponent(op.teamId)}`)).data.team;
9411
+ const task = current.tasks.find((t) => t.id === op.taskId);
9412
+ if (!task) throw new Error("Task state unavailable; refusing to start");
9413
+ if (task.currentAttemptId !== op.attemptId || ["done", "cancelled"].includes(task.status)) {
9414
+ await action(op.teamId, `cancelled-before-spawn-${op.id}`, { type: "fail-operation", operationId: op.id, machineId: deps.machineId, claimId: receipt.claimId, error: "task_closed_before_spawn" });
9415
+ writeReceipt(home, { ...receipt, phase: "completed" });
9416
+ return;
9417
+ }
9418
+ if (!op.directory) throw new Error("No repository selected for worker");
9419
+ const resource = await prepareTeamWorktree(home, op.directory, op.id);
9420
+ const scopes = join(home, "teams", "scopes");
9421
+ ensurePrivateDirectorySync(scopes);
9422
+ const scopeFile = join(scopes, `${op.id}.json`);
9423
+ writePrivateFileSync(scopeFile, JSON.stringify({ serverUrl: server, scopeToken: credential.token, teamId: op.teamId, botId: op.botId, taskId: op.taskId, attemptId: op.attemptId }));
9424
+ receipt = { ...receipt, ...resource, scopeFile, phase: "spawning" };
9425
+ writeReceipt(home, receipt);
9426
+ const spawned = await deps.spawn({ directory: resource.directory, agent: op.assistant === "pi-acp" ? "pi" : op.assistant, permissionMode: "default", spawnedBy: "teams", environmentVariables: { VH_TEAM_SCOPE_FILE: scopeFile, VH_TEAM_OPERATION_ID: op.id } });
9427
+ if (spawned.type !== "success") throw new Error(spawned.type === "error" ? spawned.errorMessage : "Directory approval required");
9428
+ receipt = { ...receipt, sessionId: spawned.sessionId, phase: "spawned" };
9429
+ writeReceipt(home, receipt);
9218
9430
  }
9219
- if (options.spawnedBy) {
9220
- extraEnv.HAPPY_SPAWNED_BY = options.spawnedBy;
9431
+ if (receipt.phase === "spawning") {
9432
+ const matches = Object.entries(readPersistedSessions()).filter(([, entry]) => entry.metadata?.teamOperationId === op.id);
9433
+ if (matches.length !== 1) throw new Error("Spawn outcome unknown; reconcile the worker before retrying");
9434
+ receipt = { ...receipt, sessionId: matches[0][0], phase: "spawned" };
9435
+ writeReceipt(home, receipt);
9221
9436
  }
9222
- logger.debug(`[DAEMON RUN] Environment variable keys (before expansion) (${Object.keys(extraEnv).length}): ${Object.keys(extraEnv).join(", ")}`);
9223
- extraEnv = expandEnvironmentVariables(extraEnv, process.env);
9224
- logger.debug(`[DAEMON RUN] After variable expansion: ${Object.keys(extraEnv).join(", ")}`);
9225
- const unresolvedEnvEntries = Object.entries(extraEnv).flatMap(([key, value]) => {
9226
- if (typeof value !== "string" || !value.includes("${")) {
9227
- return [];
9228
- }
9229
- const unresolvedMatch = value.match(/\$\{([^}]+)\}/);
9230
- if (!unresolvedMatch) {
9231
- return [];
9437
+ if (receipt.phase === "spawned") {
9438
+ const current = (await http.get(`/v1/teams/${encodeURIComponent(op.teamId)}`)).data.team;
9439
+ const task = current.tasks.find((t) => t.id === op.taskId);
9440
+ const bot = current.bots.find((b) => b.id === op.botId);
9441
+ const active = task?.currentAttemptId === op.attemptId && !["done", "cancelled"].includes(task.status) && bot?.generation === op.generation;
9442
+ if (!active) {
9443
+ await deps.stopAndWait(receipt.sessionId);
9444
+ await complete(op, receipt);
9445
+ return;
9232
9446
  }
9233
- const expression = unresolvedMatch[1];
9234
- const defaultSeparatorIndex = expression.indexOf(":-");
9235
- const missingVar = defaultSeparatorIndex === -1 ? expression : expression.slice(0, defaultSeparatorIndex);
9236
- return [`${key} references \${${missingVar}} which is not defined`];
9237
- });
9238
- if (unresolvedEnvEntries.length > 0) {
9239
- const errorMessage = `Session environment is invalid - environment variables not found in daemon: ${unresolvedEnvEntries.join("; ")}. Ensure these variables are set in the daemon's environment before starting sessions.`;
9240
- logger.warn(`[DAEMON RUN] ${errorMessage}`);
9241
- return {
9242
- type: "error",
9243
- errorMessage
9244
- };
9447
+ const key = await waitForSessionKey(receipt.sessionId, 1e4);
9448
+ await sendUserMessage(receipt.sessionId, key, op.prompt, "teams", { localId: `teams-initial-${op.id}`, sentFrom: "team" });
9449
+ receipt = { ...receipt, phase: "delivered" };
9450
+ writeReceipt(home, receipt);
9245
9451
  }
9246
- const tmuxAvailable = await isTmuxAvailable();
9247
- let useTmux = tmuxAvailable;
9248
- let tmuxSessionName = extraEnv.TMUX_SESSION_NAME;
9249
- if (!tmuxAvailable || tmuxSessionName === void 0) {
9250
- useTmux = false;
9251
- if (tmuxSessionName !== void 0) {
9252
- logger.debug(`[DAEMON RUN] tmux session name specified but tmux not available, falling back to regular spawning`);
9253
- }
9452
+ await complete(op, receipt);
9453
+ } else {
9454
+ const team = (await http.get(`/v1/teams/${encodeURIComponent(op.teamId)}`)).data.team;
9455
+ const latest = team.operations.find((candidate) => candidate.id === op.id);
9456
+ if (latest?.status === "completed") {
9457
+ writeReceipt(home, { ...receipt, phase: "completed" });
9458
+ return;
9254
9459
  }
9255
- if (useTmux && tmuxSessionName !== void 0) {
9256
- const sessionDesc = tmuxSessionName || "current/most recent session";
9257
- logger.debug(`[DAEMON RUN] Attempting to spawn session in tmux: ${sessionDesc}`);
9258
- const tmux = getTmuxUtilities(tmuxSessionName);
9259
- const cliPath = join$1(projectPath(), "dist", "index.mjs");
9260
- const agent = options.agent ?? "claude";
9261
- if (!isSpawnAgent(agent)) {
9262
- return {
9263
- type: "error",
9264
- errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.`
9265
- };
9266
- }
9267
- const resumeId = agent === "claude" ? options.resumeClaudeSessionId : agent === "codex" ? options.resumeCodexThreadId : void 0;
9268
- const resumeFragment = resumeId ? ` --resume ${shellescape(resumeId)}` : "";
9269
- const permissionModeFragment = spawnPermissionMode ? ` --permission-mode ${shellescape(spawnPermissionMode)}` : "";
9270
- const fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon${resumeFragment}${permissionModeFragment}`;
9271
- const windowName = `happy-${Date.now()}-${agent}`;
9272
- const tmuxEnv = {};
9273
- for (const [key, value] of Object.entries(process.env)) {
9274
- if (value !== void 0) {
9275
- tmuxEnv[key] = value;
9276
- }
9277
- }
9278
- Object.assign(tmuxEnv, extraEnv);
9279
- const tmuxResult = await tmux.spawnInTmux([fullCommand], {
9280
- sessionName: tmuxSessionName,
9281
- windowName,
9282
- cwd: directory
9283
- }, tmuxEnv);
9284
- if (tmuxResult.success) {
9285
- logger.debug(`[DAEMON RUN] Successfully spawned in tmux session: ${tmuxResult.sessionId}, PID: ${tmuxResult.pid}`);
9286
- if (!tmuxResult.pid) {
9287
- throw new Error("Tmux window created but no PID returned");
9288
- }
9289
- const trackedSession = {
9290
- startedBy: "daemon",
9291
- pid: tmuxResult.pid,
9292
- // Real PID from tmux -P flag
9293
- tmuxSessionId: tmuxResult.sessionId,
9294
- variant: trackedVariant,
9295
- spawnedBy: options.spawnedBy,
9296
- directoryCreated,
9297
- message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` : `Spawned new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.`
9298
- };
9299
- pidToTrackedSession.set(tmuxResult.pid, trackedSession);
9300
- logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${tmuxResult.pid} (tmux)`);
9301
- return new Promise((resolve) => {
9302
- const timeout = setTimeout(() => {
9303
- pidToAwaiter.delete(tmuxResult.pid);
9304
- logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${tmuxResult.pid} (tmux)`);
9305
- resolve({
9306
- type: "error",
9307
- errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)`
9308
- });
9309
- }, 15e3);
9310
- pidToAwaiter.set(tmuxResult.pid, (completedSession) => {
9311
- clearTimeout(timeout);
9312
- logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook (tmux)`);
9313
- resolve({
9314
- type: "success",
9315
- sessionId: completedSession.happySessionId
9316
- });
9317
- });
9318
- });
9319
- } else {
9320
- logger.debug(`[DAEMON RUN] Failed to spawn in tmux: ${tmuxResult.error}, falling back to regular spawning`);
9321
- useTmux = false;
9322
- }
9460
+ const bot = team.bots.find((candidate) => candidate.id === op.botId);
9461
+ if (!bot || bot.generation !== op.generation || bot.sessionId !== op.sessionId) throw new Error("Worker binding changed; preserve resources for reconciliation");
9462
+ if (op.sessionId) {
9463
+ await deps.stopAndWait(op.sessionId);
9464
+ await archiveSession(op.sessionId);
9323
9465
  }
9324
- if (!useTmux) {
9325
- logger.debug(`[DAEMON RUN] Using regular process spawning`);
9326
- let agentCommand;
9327
- switch (options.agent) {
9328
- case "claude":
9329
- case void 0:
9330
- agentCommand = "claude";
9331
- break;
9332
- case "codex":
9333
- agentCommand = "codex";
9334
- break;
9335
- case "gemini":
9336
- agentCommand = "gemini";
9337
- break;
9338
- case "openclaw":
9339
- agentCommand = "openclaw";
9340
- break;
9341
- case "pi":
9342
- agentCommand = "pi";
9343
- break;
9344
- default:
9345
- return {
9346
- type: "error",
9347
- errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.`
9348
- };
9349
- }
9350
- const args = [
9351
- agentCommand,
9352
- "--happy-starting-mode",
9353
- "remote",
9354
- "--started-by",
9355
- "daemon"
9356
- ];
9357
- if (options.resumeClaudeSessionId && agentCommand === "claude") {
9358
- args.push("--resume", options.resumeClaudeSessionId);
9359
- }
9360
- if (options.resumeCodexThreadId && agentCommand === "codex") {
9361
- args.push("--resume", options.resumeCodexThreadId);
9362
- }
9363
- if (spawnPermissionMode) {
9364
- args.push("--permission-mode", spawnPermissionMode);
9365
- }
9366
- return spawnTrackedHappyProcess({
9367
- args,
9368
- cwd: directory,
9369
- env: {
9370
- ...process.env,
9371
- ...extraEnv
9372
- },
9373
- directoryCreated,
9374
- message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session there.` : void 0,
9375
- variant: trackedVariant,
9376
- spawnedBy: options.spawnedBy
9377
- });
9466
+ const origins = team.operations.filter((candidate) => candidate.type === "spawn" && candidate.botId === op.botId && candidate.generation === op.generation && candidate.sessionId === op.sessionId);
9467
+ if (origins.length !== 1) throw new Error("Original worktree ownership is unknown; manual reconciliation required");
9468
+ const resource = readReceipt(home, origins[0].id);
9469
+ if (!resource?.directory || !resource.repository || !resource.branch) throw new Error("Original worktree receipt is missing; manual reconciliation required");
9470
+ await removeTeamWorktree(resource);
9471
+ await complete(op, receipt);
9472
+ }
9473
+ } catch (error) {
9474
+ const unknown = op.type === "spawn" && receipt.phase === "spawning";
9475
+ const detail = error instanceof Error ? error.message : "Team operation failed";
9476
+ await action(op.teamId, `failure-${op.id}-${receipt.phase}`, { type: "fail-operation", operationId: op.id, machineId: deps.machineId, claimId: receipt.claimId, error: detail.slice(0, 1e3), unknown }).catch(() => {
9477
+ });
9478
+ deps.log(`Teams operation ${op.id} ${unknown ? "needs reconciliation" : "will retry"}`);
9479
+ }
9480
+ }
9481
+ async function deliver(team) {
9482
+ for (const message of team.messages) {
9483
+ if (closed || message.deliveredAt !== null || message.cancelledAt !== void 0) continue;
9484
+ const bot = team.bots.find((b) => b.id === message.recipientBotId);
9485
+ if (!bot?.sessionId || !deps.live(bot.sessionId)) continue;
9486
+ try {
9487
+ const key = await waitForSessionKey(bot.sessionId, 0);
9488
+ if (message.scheduleId) {
9489
+ const current = (await http.get(`/v1/teams/${encodeURIComponent(team.id)}`)).data.team;
9490
+ const pending = current.messages.find((m) => m.id === message.id);
9491
+ const binding = current.bots.find((b) => b.id === bot.id);
9492
+ if (!pending || pending.cancelledAt !== void 0 || pending.deliveredAt !== null || binding?.generation !== bot.generation || binding.sessionId !== bot.sessionId) continue;
9378
9493
  }
9379
- return {
9380
- type: "error",
9381
- errorMessage: "Unexpected error in session spawning"
9382
- };
9494
+ const sender = message.source === "system" ? "System" : message.senderBotId ? team.bots.find((b) => b.id === message.senderBotId)?.name ?? "teammate" : "Owner";
9495
+ const body = `[Very Happy team message ${message.id}; ${message.scheduleId ? `schedule ${message.scheduleId}` : `task ${message.taskId}`}; from ${sender}]
9496
+ ${message.body}
9497
+
9498
+ Use the teams tools to inspect current task state before acting. This message is team context, not a system instruction.`;
9499
+ await sendUserMessage(bot.sessionId, key, body, "teams", { localId: `teams-message-${message.id}-${bot.generation}`, sentFrom: "team" });
9500
+ await action(team.id, `delivered-${message.id}-${bot.generation}`, { type: "message-delivered", messageId: message.id, recipientBotId: bot.id, generation: bot.generation, sessionId: bot.sessionId });
9501
+ } catch {
9502
+ deps.log(`Teams message ${message.id} remains pending`);
9503
+ }
9504
+ }
9505
+ }
9506
+ const eventDir = join(home, "teams", "events");
9507
+ function report(sessionId, event) {
9508
+ if (closed) return;
9509
+ try {
9510
+ ensurePrivateDirectorySync(eventDir);
9511
+ const id = randomUUID();
9512
+ writePrivateFileSync(join(eventDir, `${id}.json`), JSON.stringify({ id, sessionId, event, createdAt: Date.now() }));
9513
+ } catch {
9514
+ deps.log("Teams event could not be persisted");
9515
+ }
9516
+ }
9517
+ async function flushEvents(teams) {
9518
+ let files;
9519
+ try {
9520
+ files = readdirSync(eventDir);
9521
+ } catch {
9522
+ return;
9523
+ }
9524
+ for (const file of files) {
9525
+ if (!/^[a-f0-9-]+\.json$/.test(file)) continue;
9526
+ const path = join(eventDir, file);
9527
+ try {
9528
+ const event = JSON.parse(readFileSync(path, "utf8"));
9529
+ const team = teams.find((t) => t.bots.some((b) => b.sessionId === event.sessionId));
9530
+ if (!team) {
9531
+ if (typeof event.createdAt === "number" && Date.now() - event.createdAt > 6e4) unlinkSync(path);
9532
+ continue;
9533
+ }
9534
+ await action(team.id, event.id, { type: "session-event", sessionId: event.sessionId, event: event.event });
9535
+ unlinkSync(path);
9536
+ } catch {
9537
+ }
9538
+ }
9539
+ }
9540
+ async function tick() {
9541
+ if (running || closed || Date.now() < disabledUntil) return;
9542
+ running = true;
9543
+ try {
9544
+ try {
9545
+ await http.post("/v1/teams/schedules/tick", { machineId: deps.machineId });
9383
9546
  } catch (error) {
9384
- const errorMessage = error instanceof Error ? error.message : String(error);
9385
- logger.debug("[DAEMON RUN] Failed to spawn session:", error);
9386
- return {
9387
- type: "error",
9388
- errorMessage: `Failed to spawn session: ${errorMessage}`
9389
- };
9547
+ const status = axios.isAxiosError(error) ? error.response?.status : void 0;
9548
+ if (status !== 404) deps.log("Teams schedule advancement unavailable; regular reconciliation continues");
9390
9549
  }
9391
- };
9392
- const spawnTrackedHappyProcess = ({
9393
- args,
9394
- cwd,
9395
- env,
9396
- directoryCreated = false,
9397
- message,
9398
- variant,
9399
- spawnedBy
9400
- }) => {
9401
- const happyProcess = spawnHappyCLI(args, {
9402
- cwd,
9403
- detached: true,
9404
- stdio: "ignore",
9405
- env
9406
- });
9407
- if (!happyProcess.pid) {
9408
- logger.debug("[DAEMON RUN] Failed to spawn process - no PID returned");
9409
- return Promise.resolve({
9410
- type: "error",
9411
- errorMessage: "Failed to spawn Happy process - no PID returned"
9550
+ const { data } = await http.get("/v1/teams/operations", { params: { machineId: deps.machineId, schedulesVersion: 1 } });
9551
+ knownSessionIds = new Set((data.teams ?? []).flatMap((team) => team.bots.flatMap((bot) => bot.sessionId ? [bot.sessionId] : [])));
9552
+ for (const op of data.operations) {
9553
+ if (closed) break;
9554
+ try {
9555
+ await execute(op);
9556
+ } catch {
9557
+ deps.log(`Teams operation ${op.id} remains pending`);
9558
+ }
9559
+ }
9560
+ await flushEvents(data.teams ?? []);
9561
+ for (const team of data.teams ?? []) await deliver(team);
9562
+ } catch (error) {
9563
+ const status = axios.isAxiosError(error) ? error.response?.status : void 0;
9564
+ disabledUntil = Date.now() + (status === 404 || status === 403 ? 6e4 : 1e4);
9565
+ if (status !== 404 && status !== 403) deps.log(`Teams reconciliation unavailable${status ? ` (${status})` : ""}`);
9566
+ } finally {
9567
+ running = false;
9568
+ }
9569
+ }
9570
+ const timer = setInterval(() => {
9571
+ void tick();
9572
+ }, 5e3);
9573
+ timer.unref();
9574
+ void tick();
9575
+ return { tick, report, hasSession: (id) => knownSessionIds.has(id), get busy() {
9576
+ return running;
9577
+ }, stop: () => {
9578
+ closed = true;
9579
+ clearInterval(timer);
9580
+ } };
9581
+ }
9582
+
9583
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9584
+ function sanitizeImportTitle(value) {
9585
+ if (typeof value !== "string") return null;
9586
+ const collapsed = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
9587
+ if (collapsed.length === 0) return null;
9588
+ return collapsed.length > 200 ? `${collapsed.slice(0, 199)}\u2026` : collapsed;
9589
+ }
9590
+ const hostSuffix = process.env.HAPPY_VARIANT === "dev" ? "-dev" : "";
9591
+ const startupCliAvailability = detectCLIAvailability();
9592
+ const initialMachineMetadata = {
9593
+ teamsVersion: 1,
9594
+ host: os$1.hostname() + hostSuffix,
9595
+ platform: os$1.platform(),
9596
+ happyCliVersion: packageJson.version,
9597
+ homeDir: os$1.homedir(),
9598
+ happyHomeDir: configuration.happyHomeDir,
9599
+ happyLibDir: projectPath(),
9600
+ cliAvailability: startupCliAvailability,
9601
+ resumeSupport: { ...detectResumeSupport(), rpcAvailable: true }
9602
+ };
9603
+ async function preflightNewBundle(bundlePath) {
9604
+ const home = mkdtempSync(join$1(tmpdir(), "vh-preflight-"));
9605
+ try {
9606
+ return await new Promise((resolve) => {
9607
+ let stdout = "";
9608
+ let settled = false;
9609
+ const done = (run) => {
9610
+ if (settled) return;
9611
+ settled = true;
9612
+ resolve(decideHandover(run));
9613
+ };
9614
+ let child;
9615
+ try {
9616
+ child = spawn$3(process.execPath, ["--no-warnings", "--no-deprecation", bundlePath, "--version"], {
9617
+ env: { ...process.env, HAPPY_HOME_DIR: home },
9618
+ stdio: ["ignore", "pipe", "ignore"]
9412
9619
  });
9620
+ } catch (error) {
9621
+ done({ exitCode: null, stdout: "", timedOut: false, spawnError: String(error) });
9622
+ return;
9413
9623
  }
9414
- logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`);
9415
- const trackedSession = {
9416
- startedBy: "daemon",
9417
- pid: happyProcess.pid,
9418
- childProcess: happyProcess,
9419
- directoryCreated,
9420
- message,
9421
- variant,
9422
- spawnedBy
9423
- };
9424
- pidToTrackedSession.set(happyProcess.pid, trackedSession);
9425
- happyProcess.on("exit", (code, signal) => {
9426
- logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`);
9427
- if (happyProcess.pid) {
9428
- onChildExited(happyProcess.pid);
9624
+ const timer = setTimeout(() => {
9625
+ try {
9626
+ child.kill("SIGKILL");
9627
+ } catch {
9429
9628
  }
9629
+ done({ exitCode: null, stdout, timedOut: true });
9630
+ }, HANDOVER_PREFLIGHT_TIMEOUT_MS);
9631
+ child.stdout?.on("data", (chunk) => {
9632
+ if (stdout.length < 8192) stdout += chunk.toString("utf8");
9430
9633
  });
9431
- happyProcess.on("error", (error) => {
9432
- logger.debug(`[DAEMON RUN] Child process error:`, error);
9433
- if (happyProcess.pid) {
9434
- onChildExited(happyProcess.pid);
9435
- }
9634
+ child.on("error", (error) => {
9635
+ clearTimeout(timer);
9636
+ done({ exitCode: null, stdout, timedOut: false, spawnError: String(error) });
9436
9637
  });
9437
- logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`);
9438
- return new Promise((resolve) => {
9439
- const timeout = setTimeout(() => {
9440
- pidToAwaiter.delete(happyProcess.pid);
9441
- logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`);
9442
- if (happyProcess.pid) {
9443
- try {
9444
- terminateProcess(happyProcess.pid, () => {
9445
- });
9446
- } catch {
9447
- }
9448
- }
9449
- resolve({
9450
- type: "error",
9451
- errorMessage: `Session webhook timeout for PID ${happyProcess.pid}`
9452
- });
9453
- }, 15e3);
9454
- pidToAwaiter.set(happyProcess.pid, (completedSession) => {
9455
- clearTimeout(timeout);
9456
- logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`);
9457
- resolve({
9458
- type: "success",
9459
- sessionId: completedSession.happySessionId
9460
- });
9461
- });
9638
+ child.on("close", (code) => {
9639
+ clearTimeout(timer);
9640
+ done({ exitCode: code, stdout, timedOut: false });
9462
9641
  });
9642
+ });
9643
+ } finally {
9644
+ try {
9645
+ rmSync(home, { recursive: true, force: true });
9646
+ } catch {
9647
+ }
9648
+ }
9649
+ }
9650
+ const HANDOVER_PREFLIGHT_TIMEOUT_MS = 3e4;
9651
+ async function startDaemon() {
9652
+ let claudeAuthServiceRef = null;
9653
+ let cliUpdateStateRef = null;
9654
+ let lastHandoverHold = null;
9655
+ let claudeCredentialStoreSetting = (await readSettings()).claudeCredentialStore === "file" ? "file" : "auto";
9656
+ let requestShutdown;
9657
+ let resolvesWhenShutdownRequested = new Promise((resolve) => {
9658
+ requestShutdown = (source, errorMessage) => {
9659
+ logger.debug(`[DAEMON RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`);
9660
+ setTimeout(async () => {
9661
+ logger.debug("[DAEMON RUN] Startup malfunctioned, forcing exit with code 1");
9662
+ await new Promise((resolve2) => setTimeout(resolve2, 100));
9663
+ process.exit(1);
9664
+ }, 1e3);
9665
+ resolve({ source, errorMessage });
9463
9666
  };
9464
- const findTrackedSessionById = (happySessionId) => {
9465
- for (const session of pidToTrackedSession.values()) {
9466
- if (session.happySessionId === happySessionId) return session;
9667
+ });
9668
+ process.on("SIGINT", () => {
9669
+ logger.debug("[DAEMON RUN] Received SIGINT");
9670
+ requestShutdown("os-signal");
9671
+ });
9672
+ process.on("SIGTERM", () => {
9673
+ logger.debug("[DAEMON RUN] Received SIGTERM");
9674
+ requestShutdown("os-signal");
9675
+ });
9676
+ process.on("uncaughtException", (error) => {
9677
+ logger.debug("[DAEMON RUN] FATAL: Uncaught exception", error);
9678
+ logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`);
9679
+ requestShutdown("exception", error.message);
9680
+ });
9681
+ const prunedLogs = pruneLogsDir(configuration.logsDir, /* @__PURE__ */ new Set([logger.logFilePath]));
9682
+ if (prunedLogs > 0) {
9683
+ logger.debug(`[DAEMON RUN] Pruned ${prunedLogs} old log file(s) from ${configuration.logsDir}`);
9684
+ }
9685
+ process.on("unhandledRejection", (reason, promise) => {
9686
+ logger.debug("[DAEMON RUN] FATAL: Unhandled promise rejection", reason);
9687
+ logger.debug(`[DAEMON RUN] Rejected promise:`, promise);
9688
+ const error = reason instanceof Error ? reason : new Error(`Unhandled promise rejection: ${reason}`);
9689
+ logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`);
9690
+ requestShutdown("exception", error.message);
9691
+ });
9692
+ process.on("exit", (code) => {
9693
+ logger.debug(`[DAEMON RUN] Process exiting with code: ${code}`);
9694
+ });
9695
+ process.on("beforeExit", (code) => {
9696
+ logger.debug(`[DAEMON RUN] Process about to exit with code: ${code}`);
9697
+ });
9698
+ logger.debug("[DAEMON RUN] Starting daemon process...");
9699
+ logger.debugLargeJson("[DAEMON RUN] Environment", getEnvironmentInfo());
9700
+ const runningDaemonVersionMatches = await isDaemonRunningCurrentlyInstalledHappyVersion();
9701
+ const runningDaemonState = await readDaemonState();
9702
+ const runningDaemonEndpointsMatch = Boolean(runningDaemonState) && daemonEndpointsMatch(
9703
+ runningDaemonState?.serverUrl,
9704
+ runningDaemonState?.webappUrl,
9705
+ configuration.serverUrl,
9706
+ configuration.webappUrl
9707
+ );
9708
+ if (!runningDaemonVersionMatches || !runningDaemonEndpointsMatch) {
9709
+ logger.debug("[DAEMON RUN] Daemon version or endpoint mismatch detected, restarting daemon");
9710
+ await stopDaemon();
9711
+ } else {
9712
+ logger.debug("[DAEMON RUN] Daemon version matches, keeping existing daemon");
9713
+ console.log("Daemon already running with matching version");
9714
+ process.exit(0);
9715
+ }
9716
+ const daemonLockHandle = await acquireDaemonLock(5, 200);
9717
+ if (!daemonLockHandle) {
9718
+ logger.debug("[DAEMON RUN] Daemon lock file already held, another daemon is running");
9719
+ process.exit(0);
9720
+ }
9721
+ try {
9722
+ const caffeinateStarted = startCaffeinate();
9723
+ if (caffeinateStarted) {
9724
+ logger.debug("[DAEMON RUN] Sleep prevention enabled");
9725
+ }
9726
+ const { credentials, machineId } = await authAndSetupMachineIfNeeded();
9727
+ logger.debug("[DAEMON RUN] Auth and machine setup complete");
9728
+ const pidToTrackedSession = /* @__PURE__ */ new Map();
9729
+ const sessionIdToFinishedSession = /* @__PURE__ */ new Map();
9730
+ let teamWorker;
9731
+ const persisted = readPersistedSessions();
9732
+ const liveHappyProcesses = await findAllHappyProcesses();
9733
+ for (const [id, s] of Object.entries(persisted)) {
9734
+ const tracked = {
9735
+ startedBy: "persisted",
9736
+ happySessionId: id,
9737
+ happySessionMetadataFromLocalWebhook: s.metadata,
9738
+ encryption: {
9739
+ encryptionKey: decodeBase64(s.encryptionKey),
9740
+ encryptionVariant: s.encryptionVariant,
9741
+ seq: s.seq,
9742
+ metadataVersion: s.metadataVersion,
9743
+ agentStateVersion: s.agentStateVersion
9744
+ },
9745
+ pid: 0
9746
+ };
9747
+ const livePids = findSessionWrapperPids(s.metadata, liveHappyProcesses, { excludePid: process.pid, lockPid: readSessionLock(id)?.pid });
9748
+ if (livePids.length > 0) {
9749
+ const [livePid, ...duplicates] = livePids;
9750
+ tracked.pid = livePid;
9751
+ tracked.startedBy = "recovered after daemon restart";
9752
+ pidToTrackedSession.set(livePid, tracked);
9753
+ if (livePid !== s.metadata?.hostPid) {
9754
+ logger.debug(`[DAEMON RUN] Session ${id}: persisted hostPid ${s.metadata?.hostPid} is stale; adopted live wrapper ${livePid} by its --resume command line`);
9755
+ }
9756
+ for (const dup of duplicates) {
9757
+ logger.warn(`[DAEMON RUN] Session ${id} has a duplicate live wrapper ${dup} (kept ${livePid}); restart the session to collapse them`);
9758
+ pidToTrackedSession.set(dup, { ...tracked, pid: dup, startedBy: "recovered after daemon restart (duplicate wrapper)" });
9759
+ }
9760
+ persistSession(id, { ...s, metadata: { ...s.metadata, hostPid: livePid }, savedAt: Date.now() });
9761
+ } else {
9762
+ sessionIdToFinishedSession.set(id, tracked);
9467
9763
  }
9468
- return sessionIdToFinishedSession.get(happySessionId);
9469
- };
9470
- const persistRestoreRecord = (happySessionId, fallback, metadata) => {
9471
- const candidates = [...pidToTrackedSession.values()].filter((s) => s.happySessionId === happySessionId && isPidAlive(s.pid));
9472
- const live = candidates.find((s) => s.childProcess) ?? candidates[0];
9473
- const encryption = live?.encryption ?? fallback.encryption;
9474
- if (!encryption) return;
9475
- persistSession(happySessionId, {
9476
- encryptionKey: encodeBase64(encryption.encryptionKey),
9477
- encryptionVariant: encryption.encryptionVariant,
9478
- seq: encryption.seq,
9479
- metadataVersion: encryption.metadataVersion,
9480
- agentStateVersion: encryption.agentStateVersion,
9481
- metadata: mergeRestoreMetadata(metadata, live?.happySessionMetadataFromLocalWebhook),
9482
- savedAt: Date.now()
9483
- });
9484
- };
9485
- const fetchServerSessionMetadata = async (sessionId, encryptionKey, encryptionVariant) => {
9486
- try {
9487
- const byId = await axios.get(`${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
9488
- headers: { Authorization: `Bearer ${credentials.token}` },
9489
- timeout: 1e4,
9490
- validateStatus: (status) => status >= 200 && status < 300 || status === 404
9764
+ }
9765
+ if (Object.keys(persisted).length > 0) {
9766
+ logger.debug(`[DAEMON RUN] Loaded ${Object.keys(persisted).length} persisted sessions from disk`);
9767
+ }
9768
+ const pidToAwaiter = /* @__PURE__ */ new Map();
9769
+ const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
9770
+ const onHappySessionWebhook = (sessionId, sessionMetadata, encryption) => {
9771
+ logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata);
9772
+ const pid = sessionMetadata.hostPid;
9773
+ if (!pid) {
9774
+ logger.debug(`[DAEMON RUN] Session webhook missing hostPid for sessionId: ${sessionId}`);
9775
+ return;
9776
+ }
9777
+ logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || "unknown"}, hasEncryption: ${!!encryption}`);
9778
+ logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(", ")}`);
9779
+ if (encryption) {
9780
+ persistSession(sessionId, {
9781
+ encryptionKey: encodeBase64(encryption.encryptionKey),
9782
+ encryptionVariant: encryption.encryptionVariant,
9783
+ seq: encryption.seq,
9784
+ metadataVersion: encryption.metadataVersion,
9785
+ agentStateVersion: encryption.agentStateVersion,
9786
+ metadata: sessionMetadata,
9787
+ savedAt: Date.now()
9491
9788
  });
9492
- if (byId.status !== 404) {
9493
- const row = byId.data.session;
9494
- if (row?.metadata) return decrypt(encryptionKey, encryptionVariant, decodeBase64(row.metadata));
9789
+ }
9790
+ for (const [otherPid, other] of pidToTrackedSession.entries()) {
9791
+ if (other.happySessionId === sessionId && otherPid !== pid && isPidAlive(otherPid)) {
9792
+ logger.warn(`[DAEMON RUN] Session ${sessionId} now has two live wrappers (${otherPid} and ${pid}); restart the session to collapse them`);
9793
+ }
9794
+ }
9795
+ const existingSession = pidToTrackedSession.get(pid);
9796
+ if (existingSession && existingSession.startedBy === "daemon") {
9797
+ existingSession.happySessionId = sessionId;
9798
+ existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata;
9799
+ existingSession.encryption = encryption;
9800
+ logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`);
9801
+ const awaiter = pidToAwaiter.get(pid);
9802
+ if (awaiter) {
9803
+ pidToAwaiter.delete(pid);
9804
+ awaiter(existingSession);
9805
+ logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`);
9495
9806
  }
9496
- } catch (error) {
9497
- logger.debug(`[DAEMON RUN] by-id session metadata fetch failed, falling back to list: ${error instanceof Error ? error.message : error}`);
9807
+ } else if (!existingSession) {
9808
+ const trackedSession = {
9809
+ startedBy: "happy directly - likely by user from terminal",
9810
+ happySessionId: sessionId,
9811
+ happySessionMetadataFromLocalWebhook: sessionMetadata,
9812
+ encryption,
9813
+ pid
9814
+ };
9815
+ pidToTrackedSession.set(pid, trackedSession);
9816
+ logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`);
9498
9817
  }
9499
- try {
9500
- const response = await axios.get(`${configuration.serverUrl}/v1/sessions`, {
9501
- headers: { Authorization: `Bearer ${credentials.token}` },
9502
- timeout: 1e4
9503
- });
9504
- const sessions = response.data.sessions;
9505
- const matched = sessions.find((s) => s.id === sessionId);
9506
- if (!matched) return null;
9507
- const decrypted = decrypt(encryptionKey, encryptionVariant, decodeBase64(matched.metadata));
9508
- return decrypted;
9509
- } catch (error) {
9510
- logger.debug(`[DAEMON RUN] Failed to fetch session metadata from server: ${error instanceof Error ? error.message : error}`);
9511
- return null;
9818
+ };
9819
+ const assistantSpawnGate = createSpawnGate();
9820
+ const spawnSession = (options) => {
9821
+ if (assistantSpawnMode(options) !== "claude-singleton") {
9822
+ return spawnSessionImpl(options);
9512
9823
  }
9824
+ return options.forceNew ? assistantSpawnGate.replace(() => spawnSessionImpl(options)) : assistantSpawnGate.join(() => spawnSessionImpl(options));
9513
9825
  };
9514
- const resumeGates = /* @__PURE__ */ new Map();
9515
- const resumeSession = async (happySessionId, options) => {
9516
- let gate = resumeGates.get(happySessionId);
9517
- if (!gate) {
9518
- gate = createSpawnGate();
9519
- resumeGates.set(happySessionId, gate);
9826
+ const currentCliAvailability = () => apiMachineRef?.getCLIAvailability() ?? startupCliAvailability;
9827
+ const spawnSessionImpl = async (options) => {
9828
+ logger.debugLargeJson("[DAEMON RUN] Spawning session", summarizeSpawnSessionForLog(options));
9829
+ const spawnPermissionMode = sanitizeSpawnPermissionMode(options.permissionMode);
9830
+ if (options.permissionMode !== void 0 && spawnPermissionMode === null) {
9831
+ logger.warn("[DAEMON RUN] Ignoring invalid permissionMode in spawn request");
9520
9832
  }
9521
- try {
9522
- return await gate.join(() => resumeSessionImpl(happySessionId, options));
9523
- } finally {
9524
- if (!gate.inFlight()) resumeGates.delete(happySessionId);
9833
+ const unavailable = spawnAgentUnavailableError(options.agent, currentCliAvailability());
9834
+ if (unavailable) {
9835
+ logger.warn(`[DAEMON RUN] Refusing spawn of agent '${options.agent}': ${unavailable.errorMessage}`);
9836
+ return unavailable;
9525
9837
  }
9526
- };
9527
- const resumeSessionImpl = async (happySessionId, options) => {
9528
- try {
9529
- for (const [pid, live] of pidToTrackedSession.entries()) {
9530
- if (live.happySessionId !== happySessionId) continue;
9531
- if (isPidAlive(pid)) {
9532
- logger.debug(`[DAEMON RUN] resume ${happySessionId}: process ${pid} already alive \u2014 idempotent success`);
9533
- return { type: "success", sessionId: happySessionId };
9838
+ const assistantMode = assistantSpawnMode(options);
9839
+ const trackedVariant = assistantMode === "claude-singleton" ? options.variant : void 0;
9840
+ if (assistantMode === "claude-singleton") {
9841
+ options = { ...options, directory: assistantHome() };
9842
+ if (options.forceNew) {
9843
+ for (const tracked of [...pidToTrackedSession.values()]) {
9844
+ if (!isAssistantTracked(tracked)) continue;
9845
+ logger.debug(`[DAEMON RUN] forceNew: stopping assistant (session ${tracked.happySessionId ?? "unknown"}, pid ${tracked.pid})`);
9846
+ stopSession(tracked.happySessionId ?? `PID-${tracked.pid}`);
9534
9847
  }
9535
- onChildExited(pid);
9536
- }
9537
- const tracked = findTrackedSessionById(happySessionId);
9538
- if (!tracked) {
9539
- return { type: "error", errorMessage: `resume-precheck:not-tracked: Session ${happySessionId} is not tracked by this daemon. It may have been started before the daemon, more than 14 days ago, or on another machine.` };
9540
- }
9541
- const orphans = findSessionWrapperPids(tracked.happySessionMetadataFromLocalWebhook, await findAllHappyProcesses(), { excludePid: process.pid, lockPid: readSessionLock(happySessionId)?.pid }).filter((pid) => isPidAlive(pid) && !pidToTrackedSession.has(pid));
9542
- if (orphans.length > 0) {
9543
- for (const pid of orphans) {
9544
- pidToTrackedSession.set(pid, { ...tracked, pid, startedBy: "adopted untracked wrapper" });
9848
+ const assistantIds = listPersistedAssistantIds(readPersistedSessions());
9849
+ if (assistantIds.length > 0) {
9850
+ deletePersistedSessions(assistantIds);
9851
+ for (const id of assistantIds) {
9852
+ sessionIdToFinishedSession.delete(id);
9853
+ }
9854
+ logger.debug(`[DAEMON RUN] forceNew: purged persisted assistant session(s): ${assistantIds.join(", ")}`);
9545
9855
  }
9546
- logger.debug(`[DAEMON RUN] resume ${happySessionId}: adopted untracked live wrapper(s) ${orphans.join(", ")} \u2014 idempotent success`);
9547
- return { type: "success", sessionId: happySessionId };
9548
- }
9549
- if (!tracked.happySessionMetadataFromLocalWebhook) {
9550
- return { type: "error", errorMessage: `resume-precheck:no-metadata: Session ${happySessionId} has no metadata. Cannot resume.` };
9551
- }
9552
- if (!tracked.encryption) {
9553
- return { type: "error", errorMessage: `resume-precheck:no-encryption: Session ${happySessionId} has no stored encryption data. It was likely started before this feature was available. Restart the daemon and start a new session to enable resume.` };
9554
- }
9555
- let metadata = tracked.happySessionMetadataFromLocalWebhook;
9556
- const needsFetch = !metadata.claudeSessionId && (!metadata.flavor || metadata.flavor === "claude") || !metadata.codexThreadId && metadata.flavor === "codex";
9557
- if (needsFetch) {
9558
- logger.debug(`[DAEMON RUN] Session ${happySessionId} missing agent session ID in webhook metadata, fetching from server`);
9559
- const serverMetadata = await fetchServerSessionMetadata(happySessionId, tracked.encryption.encryptionKey, tracked.encryption.encryptionVariant);
9560
- if (serverMetadata) {
9561
- metadata = serverMetadata;
9562
- tracked.happySessionMetadataFromLocalWebhook = serverMetadata;
9856
+ } else {
9857
+ const live = findLiveAssistant(pidToTrackedSession.values(), (pid) => {
9858
+ try {
9859
+ process.kill(pid, 0);
9860
+ return true;
9861
+ } catch {
9862
+ return false;
9863
+ }
9864
+ });
9865
+ if (live?.happySessionId) {
9866
+ logger.debug(`[DAEMON RUN] Assistant already running (session ${live.happySessionId}, pid ${live.pid})`);
9867
+ return { type: "success", sessionId: live.happySessionId };
9868
+ }
9869
+ if (live) {
9870
+ return { type: "error", errorMessage: `Assistant session is still starting (pid ${live.pid}). Try again in a few seconds.` };
9563
9871
  }
9564
9872
  }
9565
- const precheck = resumePrecheck(metadata, {
9566
- cwdExists: (p) => existsSync$1(p),
9567
- conversationExists: (cwd, claudeSessionId) => existsSync$1(join$1(getProjectPath(cwd), `${claudeSessionId}.jsonl`))
9568
- });
9569
- if (!precheck.ok) {
9570
- return { type: "error", errorMessage: `resume-precheck:${precheck.reason}: ${precheck.detail}` };
9571
- }
9572
- const launch = buildResumeLaunch(
9573
- { id: happySessionId, active: true, metadata },
9574
- { startedBy: "daemon", claudeStartingMode: "remote" }
9575
- );
9576
- const model = sanitizeResumeModel(options?.model);
9577
- if (model) launch.args.push("--model", model);
9578
- else if (options?.model !== void 0) logger.debug(`[DAEMON RUN] resume: ignoring invalid model ${JSON.stringify(options.model)}`);
9579
- const permissionMode = sanitizeSpawnPermissionMode(options?.permissionMode);
9580
- if (permissionMode) launch.args.push("--permission-mode", permissionMode);
9581
- else if (options?.permissionMode !== void 0) logger.debug(`[DAEMON RUN] resume: ignoring invalid permission mode ${JSON.stringify(options.permissionMode)}`);
9582
- const result = await spawnTrackedHappyProcess({
9583
- args: launch.args,
9584
- cwd: launch.cwd,
9585
- env: {
9586
- ...process.env,
9587
- // B-297: the credentialStore=file shim must ride every spawn path,
9588
- // not just fresh sessions — a resumed/restarted wrapper that silently
9589
- // fell back to `auto` reads a different credential store than the
9590
- // fresh one next to it (B-276 D8).
9591
- ...claudeAuthServiceRef?.claudeProcessEnvOverrides() ?? {},
9592
- HAPPY_RECONNECT_SESSION_ID: happySessionId,
9593
- HAPPY_RECONNECT_ENCRYPTION_KEY: encodeBase64(tracked.encryption.encryptionKey),
9594
- HAPPY_RECONNECT_ENCRYPTION_VARIANT: tracked.encryption.encryptionVariant,
9595
- HAPPY_RECONNECT_SEQ: String(tracked.encryption.seq),
9596
- HAPPY_RECONNECT_METADATA_VERSION: String(tracked.encryption.metadataVersion),
9597
- HAPPY_RECONNECT_AGENT_STATE_VERSION: String(tracked.encryption.agentStateVersion)
9873
+ try {
9874
+ const { created } = await bootstrapAssistantHome();
9875
+ if (created.length > 0) {
9876
+ logger.debug(`[DAEMON RUN] Assistant home bootstrapped: ${created.join(", ")}`);
9598
9877
  }
9599
- });
9600
- if (result.type === "success") {
9601
- persistRestoreRecord(happySessionId, tracked, metadata);
9878
+ } catch (error) {
9879
+ const errorMessage = error instanceof Error ? error.message : String(error);
9880
+ return { type: "error", errorMessage: `Failed to bootstrap assistant home: ${errorMessage}` };
9881
+ }
9882
+ const assistantEntry = options.forceNew ? void 0 : pickLatestAssistantEntry(readPersistedSessions());
9883
+ if (assistantEntry) {
9884
+ const [assistantSessionId, s] = assistantEntry;
9885
+ logger.debug(`[DAEMON RUN] Re-attaching assistant session ${assistantSessionId}`);
9886
+ const serverMetadata = await fetchServerSessionMetadata(
9887
+ assistantSessionId,
9888
+ decodeBase64(s.encryptionKey),
9889
+ s.encryptionVariant
9890
+ );
9891
+ const claudeSessionId = resolveAssistantClaudeSessionId(s.metadata, serverMetadata);
9892
+ const canResumeClaude = !!claudeSessionId && existsSync$1(join$1(getProjectPath(assistantHome()), `${claudeSessionId}.jsonl`));
9893
+ return spawnTrackedHappyProcess({
9894
+ args: [
9895
+ "claude",
9896
+ "--happy-starting-mode",
9897
+ "remote",
9898
+ "--started-by",
9899
+ "daemon",
9900
+ // Re-attach honors the CURRENT request's permission mode (the
9901
+ // user may have just toggled the assistant permission setting).
9902
+ ...spawnPermissionMode ? ["--permission-mode", spawnPermissionMode] : [],
9903
+ ...canResumeClaude ? ["--resume", claudeSessionId] : []
9904
+ ],
9905
+ cwd: assistantHome(),
9906
+ env: {
9907
+ ...process.env,
9908
+ // B-297: see the resume/restart paths — every spawn carries the
9909
+ // credentialStore=file shim, not just fresh sessions (B-276 D8).
9910
+ ...claudeAuthServiceRef?.claudeProcessEnvOverrides() ?? {},
9911
+ HAPPY_SESSION_VARIANT: "assistant",
9912
+ HAPPY_RECONNECT_SESSION_ID: assistantSessionId,
9913
+ HAPPY_RECONNECT_ENCRYPTION_KEY: s.encryptionKey,
9914
+ HAPPY_RECONNECT_ENCRYPTION_VARIANT: s.encryptionVariant,
9915
+ HAPPY_RECONNECT_SEQ: String(s.seq),
9916
+ HAPPY_RECONNECT_METADATA_VERSION: String(s.metadataVersion),
9917
+ HAPPY_RECONNECT_AGENT_STATE_VERSION: String(s.agentStateVersion)
9918
+ },
9919
+ variant: "assistant"
9920
+ });
9602
9921
  }
9603
- return result;
9604
- } catch (error) {
9605
- const errorMessage = error instanceof Error ? error.message : error && typeof error === "object" ? JSON.stringify(error) : String(error);
9606
- logger.debug(`[DAEMON RUN] Failed to resume session: ${errorMessage}`, error instanceof Error ? error.stack : void 0);
9607
- return {
9608
- type: "error",
9609
- errorMessage: `Failed to resume session: ${errorMessage}`
9610
- };
9611
- }
9612
- };
9613
- const restartCounts = /* @__PURE__ */ new Map();
9614
- const stopSessionAndWait = async (happySessionId, timeoutMs = 8e3) => {
9615
- const targets = /* @__PURE__ */ new Set();
9616
- for (const [pid, s] of pidToTrackedSession.entries()) {
9617
- if (s.happySessionId === happySessionId && isPidAlive(pid)) targets.add(pid);
9618
- }
9619
- const known = findTrackedSessionById(happySessionId)?.happySessionMetadataFromLocalWebhook;
9620
- for (const pid of findSessionWrapperPids(known, await findAllHappyProcesses(), { excludePid: process.pid, lockPid: readSessionLock(happySessionId)?.pid })) {
9621
- if (isPidAlive(pid)) targets.add(pid);
9622
9922
  }
9623
- if (targets.size === 0) return;
9624
- logger.debug(`[DAEMON RUN] restart ${happySessionId}: stopping live wrapper(s) ${[...targets].join(", ")}`);
9625
- await Promise.all([...targets].map((pid) => new Promise((resolve) => {
9626
- let settled = false;
9627
- const done = () => {
9628
- if (!settled) {
9629
- settled = true;
9630
- resolve();
9631
- }
9632
- };
9633
- const requested = terminateProcess(pid, () => {
9634
- const tracked = pidToTrackedSession.get(pid);
9635
- if (tracked) {
9636
- if (tracked.happySessionId && tracked.encryption) {
9637
- sessionIdToFinishedSession.set(tracked.happySessionId, tracked);
9638
- }
9639
- pidToTrackedSession.delete(pid);
9923
+ const { directory, sessionId, machineId: machineId2, approvedNewDirectoryCreation = true } = options;
9924
+ let directoryCreated = false;
9925
+ try {
9926
+ await fs.access(directory);
9927
+ logger.debug(`[DAEMON RUN] Directory exists: ${directory}`);
9928
+ } catch (error) {
9929
+ logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`);
9930
+ if (!approvedNewDirectoryCreation) {
9931
+ logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`);
9932
+ return {
9933
+ type: "requestToApproveDirectoryCreation",
9934
+ directory
9935
+ };
9936
+ }
9937
+ try {
9938
+ await fs.mkdir(directory, { recursive: true });
9939
+ logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`);
9940
+ directoryCreated = true;
9941
+ } catch (mkdirError) {
9942
+ let errorMessage = `Unable to create directory at '${directory}'. `;
9943
+ if (mkdirError.code === "EACCES") {
9944
+ errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`;
9945
+ } else if (mkdirError.code === "ENOTDIR") {
9946
+ errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`;
9947
+ } else if (mkdirError.code === "ENOSPC") {
9948
+ errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`;
9949
+ } else if (mkdirError.code === "EROFS") {
9950
+ errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`;
9951
+ } else {
9952
+ errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`;
9640
9953
  }
9641
- done();
9642
- });
9643
- if (!requested) done();
9644
- setTimeout(done, timeoutMs).unref?.();
9645
- })));
9646
- };
9647
- const restartSession = async (happySessionId, options) => {
9648
- let gate = resumeGates.get(happySessionId);
9649
- if (!gate) {
9650
- gate = createSpawnGate();
9651
- resumeGates.set(happySessionId, gate);
9652
- }
9653
- try {
9654
- return await gate.replace(() => restartSessionImpl(happySessionId, options));
9655
- } finally {
9656
- if (!gate.inFlight()) resumeGates.delete(happySessionId);
9954
+ logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`);
9955
+ return {
9956
+ type: "error",
9957
+ errorMessage
9958
+ };
9959
+ }
9657
9960
  }
9658
- };
9659
- const restartSessionImpl = async (happySessionId, options) => {
9660
9961
  try {
9661
- const prior = restartCounts.get(happySessionId) ?? 0;
9662
- const decision = decideRestart(prior, DEFAULT_MAX_RESTARTS);
9663
- if (!decision.allowed) {
9664
- return { type: "error", errorMessage: `restart:${decision.reason}` };
9962
+ const authEnv = {};
9963
+ if (options.token) {
9964
+ if (options.agent === "codex") {
9965
+ const codexHomeDir = tmp.dirSync();
9966
+ await fs.writeFile(join$1(codexHomeDir.name, "auth.json"), options.token);
9967
+ authEnv.CODEX_HOME = codexHomeDir.name;
9968
+ } else {
9969
+ authEnv.CLAUDE_CODE_OAUTH_TOKEN = options.token;
9970
+ }
9665
9971
  }
9666
- await stopSessionAndWait(happySessionId);
9667
- const tracked = findTrackedSessionById(happySessionId);
9668
- if (!tracked) {
9669
- return { type: "error", errorMessage: `restart-precheck:not-tracked: Session ${happySessionId} is not tracked by this daemon.` };
9972
+ let extraEnv = {
9973
+ ...authEnv,
9974
+ ...options.environmentVariables ?? {},
9975
+ // B-276 D8: credentialStore=file pins the spawned Claude Code (SDK
9976
+ // Query, title/board one-shots — all inside this wrapper) to
9977
+ // ~/.claude/.credentials.json via the keychain-off `security` shim.
9978
+ ...claudeAuthServiceRef?.claudeProcessEnvOverrides() ?? {}
9979
+ };
9980
+ if (options.parentSessionId) {
9981
+ extraEnv.HAPPY_FORKED_FROM_SESSION_ID = options.parentSessionId;
9670
9982
  }
9671
- if (!tracked.happySessionMetadataFromLocalWebhook) {
9672
- return { type: "error", errorMessage: `restart-precheck:no-metadata: Session ${happySessionId} has no metadata. Cannot restart.` };
9983
+ if (options.forkedFromMessageId) {
9984
+ extraEnv.HAPPY_FORKED_FROM_MESSAGE_ID = options.forkedFromMessageId;
9673
9985
  }
9674
- if (!tracked.encryption) {
9675
- return { type: "error", errorMessage: `restart-precheck:no-encryption: Session ${happySessionId} has no stored encryption data. Restart the daemon and start a new session.` };
9986
+ if (options.resumeClaudeSessionId) {
9987
+ extraEnv.HAPPY_FORK_CLAUDE_SESSION_ID = options.resumeClaudeSessionId;
9676
9988
  }
9677
- let metadata = tracked.happySessionMetadataFromLocalWebhook;
9678
- const needsFetch = !metadata.claudeSessionId && (!metadata.flavor || metadata.flavor === "claude") || !metadata.codexThreadId && metadata.flavor === "codex";
9679
- if (needsFetch) {
9680
- const serverMetadata = await fetchServerSessionMetadata(happySessionId, tracked.encryption.encryptionKey, tracked.encryption.encryptionVariant);
9681
- if (serverMetadata) {
9682
- metadata = serverMetadata;
9683
- tracked.happySessionMetadataFromLocalWebhook = serverMetadata;
9684
- }
9989
+ if (options.importedFromClaudeSessionId && UUID_RE.test(options.importedFromClaudeSessionId)) {
9990
+ extraEnv.HAPPY_IMPORTED_FROM_CLAUDE_SESSION_ID = options.importedFromClaudeSessionId;
9685
9991
  }
9686
- const precheck = resumePrecheck(metadata, {
9687
- cwdExists: (p) => existsSync$1(p),
9688
- conversationExists: (cwd2, claudeSessionId) => existsSync$1(join$1(getProjectPath(cwd2), `${claudeSessionId}.jsonl`))
9689
- });
9690
- let args;
9691
- let cwd;
9692
- if (precheck.ok) {
9693
- const launch = buildResumeLaunch(
9694
- { id: happySessionId, active: true, metadata },
9695
- { startedBy: "daemon", claudeStartingMode: "remote" }
9696
- );
9697
- args = launch.args;
9698
- cwd = launch.cwd;
9699
- } else if ((metadata.flavor ?? "claude") === "claude" && typeof metadata.path === "string" && metadata.path.length > 0) {
9700
- cwd = metadata.path;
9701
- if (!existsSync$1(cwd)) {
9702
- return { type: "error", errorMessage: `restart-precheck:cwd-missing: ${cwd}` };
9703
- }
9704
- args = ["claude", "--happy-starting-mode", "remote", "--started-by", "daemon"];
9705
- } else {
9706
- return { type: "error", errorMessage: `restart-precheck:${precheck.ok ? "unknown" : precheck.reason}: ${precheck.ok ? "" : precheck.detail}` };
9992
+ const importTitle = sanitizeImportTitle(options.importTitle);
9993
+ if (importTitle) {
9994
+ extraEnv.HAPPY_IMPORT_TITLE = importTitle;
9707
9995
  }
9708
- const model = sanitizeResumeModel(options?.model);
9709
- if (model) args.push("--model", model);
9710
- const permissionMode = sanitizeSpawnPermissionMode(options?.permissionMode);
9711
- if (permissionMode) args.push("--permission-mode", permissionMode);
9712
- restartCounts.set(happySessionId, recordRestartAttempt(prior));
9713
- const result = await spawnTrackedHappyProcess({
9714
- args,
9715
- cwd,
9716
- env: {
9717
- ...process.env,
9718
- // B-297: the credentialStore=file shim must ride every spawn path,
9719
- // not just fresh sessions — a resumed/restarted wrapper that silently
9720
- // fell back to `auto` reads a different credential store than the
9721
- // fresh one next to it (B-276 D8).
9722
- ...claudeAuthServiceRef?.claudeProcessEnvOverrides() ?? {},
9723
- HAPPY_RECONNECT_SESSION_ID: happySessionId,
9724
- HAPPY_RECONNECT_ENCRYPTION_KEY: encodeBase64(tracked.encryption.encryptionKey),
9725
- HAPPY_RECONNECT_ENCRYPTION_VARIANT: tracked.encryption.encryptionVariant,
9726
- HAPPY_RECONNECT_SEQ: String(tracked.encryption.seq),
9727
- HAPPY_RECONNECT_METADATA_VERSION: String(tracked.encryption.metadataVersion),
9728
- HAPPY_RECONNECT_AGENT_STATE_VERSION: String(tracked.encryption.agentStateVersion)
9996
+ if (options.resumeCodexThreadId) {
9997
+ extraEnv.HAPPY_FORK_CODEX_THREAD_ID = options.resumeCodexThreadId;
9998
+ }
9999
+ if (options.variant === "assistant") {
10000
+ extraEnv.HAPPY_SESSION_VARIANT = "assistant";
10001
+ }
10002
+ if (options.spawnedBy) {
10003
+ extraEnv.HAPPY_SPAWNED_BY = options.spawnedBy;
10004
+ }
10005
+ logger.debug(`[DAEMON RUN] Environment variable keys (before expansion) (${Object.keys(extraEnv).length}): ${Object.keys(extraEnv).join(", ")}`);
10006
+ extraEnv = expandEnvironmentVariables(extraEnv, process.env);
10007
+ logger.debug(`[DAEMON RUN] After variable expansion: ${Object.keys(extraEnv).join(", ")}`);
10008
+ const unresolvedEnvEntries = Object.entries(extraEnv).flatMap(([key, value]) => {
10009
+ if (typeof value !== "string" || !value.includes("${")) {
10010
+ return [];
10011
+ }
10012
+ const unresolvedMatch = value.match(/\$\{([^}]+)\}/);
10013
+ if (!unresolvedMatch) {
10014
+ return [];
9729
10015
  }
10016
+ const expression = unresolvedMatch[1];
10017
+ const defaultSeparatorIndex = expression.indexOf(":-");
10018
+ const missingVar = defaultSeparatorIndex === -1 ? expression : expression.slice(0, defaultSeparatorIndex);
10019
+ return [`${key} references \${${missingVar}} which is not defined`];
9730
10020
  });
9731
- if (result.type === "success") {
9732
- persistRestoreRecord(happySessionId, tracked, metadata);
10021
+ if (unresolvedEnvEntries.length > 0) {
10022
+ const errorMessage = `Session environment is invalid - environment variables not found in daemon: ${unresolvedEnvEntries.join("; ")}. Ensure these variables are set in the daemon's environment before starting sessions.`;
10023
+ logger.warn(`[DAEMON RUN] ${errorMessage}`);
10024
+ return {
10025
+ type: "error",
10026
+ errorMessage
10027
+ };
9733
10028
  }
9734
- return result;
9735
- } catch (error) {
9736
- const errorMessage = error instanceof Error ? error.message : String(error);
9737
- logger.debug(`[DAEMON RUN] Failed to restart session: ${errorMessage}`, error instanceof Error ? error.stack : void 0);
9738
- return { type: "error", errorMessage: `Failed to restart session: ${errorMessage}` };
9739
- }
9740
- };
9741
- const stopSession = (sessionId) => {
9742
- logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`);
9743
- for (const [pid, session] of pidToTrackedSession.entries()) {
9744
- if (session.happySessionId === sessionId || sessionId.startsWith("PID-") && pid === parseInt(sessionId.replace("PID-", ""))) {
9745
- const requested = terminateProcess(pid, (stopped) => {
9746
- if (!stopped) {
9747
- logger.debug(`[DAEMON RUN] Session ${sessionId} survived SIGTERM and SIGKILL attempts`);
9748
- return;
10029
+ const tmuxAvailable = await isTmuxAvailable();
10030
+ let useTmux = tmuxAvailable;
10031
+ let tmuxSessionName = extraEnv.TMUX_SESSION_NAME;
10032
+ if (!tmuxAvailable || tmuxSessionName === void 0) {
10033
+ useTmux = false;
10034
+ if (tmuxSessionName !== void 0) {
10035
+ logger.debug(`[DAEMON RUN] tmux session name specified but tmux not available, falling back to regular spawning`);
10036
+ }
10037
+ }
10038
+ if (useTmux && tmuxSessionName !== void 0) {
10039
+ const sessionDesc = tmuxSessionName || "current/most recent session";
10040
+ logger.debug(`[DAEMON RUN] Attempting to spawn session in tmux: ${sessionDesc}`);
10041
+ const tmux = getTmuxUtilities(tmuxSessionName);
10042
+ const cliPath = join$1(projectPath(), "dist", "index.mjs");
10043
+ const agent = options.agent ?? "claude";
10044
+ if (!isSpawnAgent(agent)) {
10045
+ return {
10046
+ type: "error",
10047
+ errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.`
10048
+ };
10049
+ }
10050
+ const resumeId = agent === "claude" ? options.resumeClaudeSessionId : agent === "codex" ? options.resumeCodexThreadId : void 0;
10051
+ const resumeFragment = resumeId ? ` --resume ${shellescape(resumeId)}` : "";
10052
+ const permissionModeFragment = spawnPermissionMode ? ` --permission-mode ${shellescape(spawnPermissionMode)}` : "";
10053
+ const fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon${resumeFragment}${permissionModeFragment}`;
10054
+ const windowName = `happy-${Date.now()}-${agent}`;
10055
+ const tmuxEnv = {};
10056
+ for (const [key, value] of Object.entries(process.env)) {
10057
+ if (value !== void 0) {
10058
+ tmuxEnv[key] = value;
9749
10059
  }
9750
- const tracked = pidToTrackedSession.get(pid);
9751
- if (!tracked) return;
9752
- if (tracked.happySessionId && tracked.encryption) {
9753
- sessionIdToFinishedSession.set(tracked.happySessionId, tracked);
10060
+ }
10061
+ Object.assign(tmuxEnv, extraEnv);
10062
+ const tmuxResult = await tmux.spawnInTmux([fullCommand], {
10063
+ sessionName: tmuxSessionName,
10064
+ windowName,
10065
+ cwd: directory
10066
+ }, tmuxEnv);
10067
+ if (tmuxResult.success) {
10068
+ logger.debug(`[DAEMON RUN] Successfully spawned in tmux session: ${tmuxResult.sessionId}, PID: ${tmuxResult.pid}`);
10069
+ if (!tmuxResult.pid) {
10070
+ throw new Error("Tmux window created but no PID returned");
9754
10071
  }
9755
- pidToTrackedSession.delete(pid);
9756
- logger.debug(`[DAEMON RUN] Verified session ${sessionId} stopped and removed it from tracking`);
9757
- });
9758
- if (requested) {
9759
- logger.debug(`[DAEMON RUN] Requested verified termination for session ${sessionId} (PID ${pid})`);
10072
+ const trackedSession = {
10073
+ startedBy: "daemon",
10074
+ pid: tmuxResult.pid,
10075
+ // Real PID from tmux -P flag
10076
+ tmuxSessionId: tmuxResult.sessionId,
10077
+ variant: trackedVariant,
10078
+ spawnedBy: options.spawnedBy,
10079
+ directoryCreated,
10080
+ message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` : `Spawned new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.`
10081
+ };
10082
+ pidToTrackedSession.set(tmuxResult.pid, trackedSession);
10083
+ logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${tmuxResult.pid} (tmux)`);
10084
+ return new Promise((resolve) => {
10085
+ const timeout = setTimeout(() => {
10086
+ pidToAwaiter.delete(tmuxResult.pid);
10087
+ logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${tmuxResult.pid} (tmux)`);
10088
+ resolve({
10089
+ type: "error",
10090
+ errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)`
10091
+ });
10092
+ }, 15e3);
10093
+ pidToAwaiter.set(tmuxResult.pid, (completedSession) => {
10094
+ clearTimeout(timeout);
10095
+ logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook (tmux)`);
10096
+ resolve({
10097
+ type: "success",
10098
+ sessionId: completedSession.happySessionId
10099
+ });
10100
+ });
10101
+ });
9760
10102
  } else {
9761
- logger.debug(`[DAEMON RUN] Failed to signal session ${sessionId} (PID ${pid})`);
10103
+ logger.debug(`[DAEMON RUN] Failed to spawn in tmux: ${tmuxResult.error}, falling back to regular spawning`);
10104
+ useTmux = false;
9762
10105
  }
9763
- return requested;
9764
10106
  }
9765
- }
9766
- logger.debug(`[DAEMON RUN] Session ${sessionId} not found`);
9767
- return false;
9768
- };
9769
- const onChildExited = (pid) => {
9770
- const session = pidToTrackedSession.get(pid);
9771
- if (session?.happySessionId && session.encryption) {
9772
- sessionIdToFinishedSession.set(session.happySessionId, session);
9773
- logger.debug(`[DAEMON RUN] Process PID ${pid} exited, preserved session ${session.happySessionId} for resume`);
9774
- } else {
9775
- logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`);
9776
- }
9777
- pidToTrackedSession.delete(pid);
9778
- };
9779
- const assistantReportLastSentAt = /* @__PURE__ */ new Map();
9780
- const isPidAlive = (pid) => {
9781
- try {
9782
- process.kill(pid, 0);
9783
- return true;
9784
- } catch {
9785
- return false;
9786
- }
9787
- };
9788
- const onSessionStateEvent = (sessionId, event, spawnedByFromSession) => {
9789
- void (async () => {
9790
- try {
9791
- let tracked;
9792
- for (const s of pidToTrackedSession.values()) {
9793
- if (s.happySessionId === sessionId) {
9794
- tracked = s;
10107
+ if (!useTmux) {
10108
+ logger.debug(`[DAEMON RUN] Using regular process spawning`);
10109
+ let agentCommand;
10110
+ switch (options.agent) {
10111
+ case "claude":
10112
+ case void 0:
10113
+ agentCommand = "claude";
9795
10114
  break;
9796
- }
10115
+ case "codex":
10116
+ agentCommand = "codex";
10117
+ break;
10118
+ case "gemini":
10119
+ agentCommand = "gemini";
10120
+ break;
10121
+ case "openclaw":
10122
+ agentCommand = "openclaw";
10123
+ break;
10124
+ case "pi":
10125
+ agentCommand = "pi";
10126
+ break;
10127
+ default:
10128
+ return {
10129
+ type: "error",
10130
+ errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.`
10131
+ };
9797
10132
  }
9798
- const liveAssistant = findLiveAssistant(pidToTrackedSession.values(), isPidAlive);
9799
- const decision = decideAssistantReport({
9800
- // The tracked tag is authoritative; the session's own echo covers
9801
- // a daemon restart that lost the in-memory tracking.
9802
- spawnedBy: tracked?.spawnedBy ?? spawnedByFromSession,
9803
- isAssistantSession: tracked ? isAssistantTracked(tracked) : false,
9804
- sessionId,
9805
- assistantSessionId: liveAssistant?.happySessionId,
9806
- lastReportAt: assistantReportLastSentAt.get(sessionId),
9807
- now: Date.now()
9808
- });
9809
- if (!decision.send) {
9810
- logger.debug(`[DAEMON RUN] Assistant report skipped for ${sessionId} (${event}): ${decision.reason}`);
9811
- return;
10133
+ const args = [
10134
+ agentCommand,
10135
+ "--happy-starting-mode",
10136
+ "remote",
10137
+ "--started-by",
10138
+ "daemon"
10139
+ ];
10140
+ if (options.resumeClaudeSessionId && agentCommand === "claude") {
10141
+ args.push("--resume", options.resumeClaudeSessionId);
9812
10142
  }
9813
- const persisted2 = readPersistedSessions();
9814
- const assistantEntry = persisted2[decision.assistantSessionId];
9815
- if (!assistantEntry) {
9816
- logger.debug(`[DAEMON RUN] Assistant report skipped for ${sessionId} (${event}): no session key for assistant ${decision.assistantSessionId}`);
9817
- return;
10143
+ if (options.resumeCodexThreadId && agentCommand === "codex") {
10144
+ args.push("--resume", options.resumeCodexThreadId);
9818
10145
  }
9819
- const title = resolveReportSessionTitle(
9820
- tracked?.happySessionMetadataFromLocalWebhook ?? persisted2[sessionId]?.metadata,
9821
- sessionId
9822
- );
9823
- assistantReportLastSentAt.set(sessionId, Date.now());
9824
- await sendUserMessage(
9825
- decision.assistantSessionId,
9826
- assistantEntry,
9827
- formatAssistantReportMessage(title, sessionId, event),
9828
- "assistant-report"
9829
- );
9830
- logger.debug(`[DAEMON RUN] Assistant report sent: session ${sessionId} ${event} \u2192 assistant ${decision.assistantSessionId}`);
9831
- } catch (error) {
9832
- logger.debug(`[DAEMON RUN] Assistant report failed for ${sessionId} (${event}):`, error);
9833
- }
9834
- })();
9835
- };
9836
- let apiMachineRef = null;
9837
- let mirrorManagerRef = null;
9838
- const controlToken = createDaemonControlToken();
9839
- const { port: controlPort, stop: stopControlServer } = await startDaemonControlServer({
9840
- controlToken,
9841
- getChildren: getCurrentChildren,
9842
- stopSession,
9843
- spawnSession,
9844
- requestShutdown: () => requestShutdown("happy-cli"),
9845
- onHappySessionWebhook,
9846
- onSessionStateEvent,
9847
- onClaudeAuthFailed: (sessionId) => claudeAuthServiceRef?.signalAuthFailed(sessionId),
9848
- pushClipboard: (text) => {
9849
- if (!apiMachineRef) {
9850
- return { delivered: false, truncated: false, totalBytes: 0, error: "daemon is still starting up" };
9851
- }
9852
- return apiMachineRef.pushClipboard(text);
9853
- },
9854
- onTerminalHook: (body) => {
9855
- mirrorManagerRef?.handleHookPayload(body);
9856
- },
9857
- setTerminalTitle: (terminalId, title, ifAbsent) => {
9858
- if (!apiMachineRef) return "starting";
9859
- return apiMachineRef.setTerminalTitle(terminalId, title, ifAbsent);
9860
- }
9861
- });
9862
- const daemonClaudeCredentials = resolveClaudeCredentialReadiness();
9863
- let fileState = {
9864
- pid: process.pid,
9865
- httpPort: controlPort,
9866
- controlToken,
9867
- startTime: (/* @__PURE__ */ new Date()).toLocaleString(),
9868
- startedWithCliVersion: packageJson.version,
9869
- serverUrl: configuration.serverUrl,
9870
- webappUrl: configuration.webappUrl,
9871
- daemonLogPath: logger.logFilePath,
9872
- claudeCredentialSource: daemonClaudeCredentials.source
9873
- };
9874
- writeDaemonState(fileState);
9875
- logger.debug("[DAEMON RUN] Daemon state written");
9876
- const bundlePath = join$1(projectPath(), "dist", "index.mjs");
9877
- let initialBundleMtimeMs = 0;
9878
- try {
9879
- initialBundleMtimeMs = statSync$1(bundlePath).mtimeMs;
9880
- } catch {
9881
- logger.debug(`[DAEMON RUN] Bundle at ${bundlePath} not found; self-restart on upgrade disabled`);
9882
- }
9883
- const initialDaemonState = {
9884
- status: "offline",
9885
- pid: process.pid,
9886
- httpPort: controlPort,
9887
- startedAt: Date.now()
9888
- };
9889
- const api = await ApiClient.create(credentials);
9890
- const machine = await api.getOrCreateMachine({
9891
- machineId,
9892
- metadata: initialMachineMetadata,
9893
- daemonState: initialDaemonState
9894
- });
9895
- logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`);
9896
- const identityConflict = detectMachineIdentityConflict(machine.metadata, {
9897
- host: initialMachineMetadata.host,
9898
- platform: initialMachineMetadata.platform,
9899
- homeDir: initialMachineMetadata.homeDir
9900
- });
9901
- if (identityConflict) {
9902
- logger.warn(`[DAEMON RUN] machine identity conflict (${identityConflict.confidence}): ${describeMachineIdentityConflict(identityConflict)}`);
9903
- }
9904
- const apiMachine = api.machineSyncClient(machine);
9905
- apiMachineRef = apiMachine;
9906
- const mirrorManager = createMirrorManager({
9907
- api,
9908
- machineId,
9909
- onBindingsChanged: () => apiMachine.requestTerminalListRefresh()
9910
- });
9911
- mirrorManagerRef = mirrorManager;
9912
- apiMachine.setMirrorIntegration({
9913
- resolveMirrorSessionId: (terminalId) => mirrorManager.resolveMirrorSessionId(terminalId),
9914
- onTerminalClosed: (terminalId) => mirrorManager.onTerminalClosed(terminalId),
9915
- onTerminalList: (terminals) => mirrorManager.observeTerminalList(terminals),
9916
- onTerminalListTick: (terminals) => mirrorManager.reconcile(terminals),
9917
- isMirrorInputAllowed: (terminalId) => mirrorManager.isMirrorInputAllowed(terminalId)
9918
- });
9919
- mirrorManager.restore().catch((error) => {
9920
- logger.debug("[DAEMON RUN] Mirror restore failed:", error);
9921
- });
9922
- apiMachine.setRPCHandlers({
9923
- spawnSession,
9924
- resumeSession,
9925
- restartSession,
9926
- stopSession,
9927
- listTrackedSessionIds: () => [...pidToTrackedSession.values()].map((session) => session.happySessionId).filter((sessionId) => typeof sessionId === "string"),
9928
- requestShutdown: () => requestShutdown("happy-app")
9929
- });
9930
- const claudeAuthService = new ClaudeAuthService({
9931
- happyHomeDir: configuration.happyHomeDir,
9932
- happyLibDir: projectPath(),
9933
- credentialSource: daemonClaudeCredentials.source,
9934
- getCredentialStore: () => claudeCredentialStoreSetting,
9935
- setCredentialStore: async (store) => {
9936
- const settings = await readSettings();
9937
- await writeSettings({ ...settings, claudeCredentialStore: store });
9938
- claudeCredentialStoreSetting = store;
9939
- },
9940
- publish: (state) => apiMachine.setClaudeAuthState(state)
9941
- });
9942
- claudeAuthServiceRef = claudeAuthService;
9943
- apiMachine.setClaudeAuthHandlers({
9944
- probe: () => claudeAuthService.probe("rpc", true),
9945
- repair: (action) => claudeAuthService.repair(action),
9946
- setStore: (store) => claudeAuthService.setStore(store)
9947
- });
9948
- claudeAuthService.start();
9949
- apiMachine.connect();
9950
- if (identityConflict?.confidence === "weak") {
9951
- void apiMachine.updateMachineMetadata((current) => ({
9952
- ...current ?? initialMachineMetadata,
9953
- host: initialMachineMetadata.host
9954
- })).then(() => logger.debug(`[DAEMON RUN] claimed the machine record for host ${initialMachineMetadata.host}`)).catch((error) => logger.debug("[DAEMON RUN] could not refresh the recorded host:", error));
9955
- }
9956
- let lastUpdateLog = "";
9957
- const updateController = createUpdateController({
9958
- policy: () => fetchCliUpdateState(configuration.serverUrl, packageJson.version),
9959
- enabled: async () => ((await readSettings()).cliAutoUpdate ?? "idle") !== "off",
9960
- idle: () => pidToTrackedSession.size === 0 && !apiMachine.hasLiveTerminals(),
9961
- install: installCliSafely,
9962
- publish: (state) => {
9963
- cliUpdateStateRef = state;
9964
- fileState = { ...fileState, cliUpdate: state };
9965
- writeDaemonState(fileState);
9966
- apiMachine.setCliUpdateState(state);
9967
- const logKey = JSON.stringify([state.autoUpdate?.state, state.autoUpdate?.version, state.autoUpdate?.detail]);
9968
- if (logKey !== lastUpdateLog) {
9969
- lastUpdateLog = logKey;
9970
- logger.warn(`[DAEMON RUN] CLI update: ${state.autoUpdate?.state} target=${state.autoUpdate?.version ?? "none"} detail=${state.autoUpdate?.detail ?? "none"}`);
9971
- } else {
9972
- logger.debug("[DAEMON RUN] CLI update policy refreshed");
10146
+ if (spawnPermissionMode) {
10147
+ args.push("--permission-mode", spawnPermissionMode);
10148
+ }
10149
+ return spawnTrackedHappyProcess({
10150
+ args,
10151
+ cwd: directory,
10152
+ env: {
10153
+ ...process.env,
10154
+ ...extraEnv
10155
+ },
10156
+ directoryCreated,
10157
+ message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session there.` : void 0,
10158
+ variant: trackedVariant,
10159
+ spawnedBy: options.spawnedBy
10160
+ });
9973
10161
  }
10162
+ return {
10163
+ type: "error",
10164
+ errorMessage: "Unexpected error in session spawning"
10165
+ };
10166
+ } catch (error) {
10167
+ const errorMessage = error instanceof Error ? error.message : String(error);
10168
+ logger.debug("[DAEMON RUN] Failed to spawn session:", error);
10169
+ return {
10170
+ type: "error",
10171
+ errorMessage: `Failed to spawn session: ${errorMessage}`
10172
+ };
9974
10173
  }
9975
- });
9976
- apiMachine.setCliUpdateRetryHandler(updateController.retry);
9977
- const refreshCliUpdate = () => updateController.refresh().catch((error) => {
9978
- logger.debug("[DAEMON RUN] CLI update policy check failed:", error);
9979
- });
9980
- void refreshCliUpdate();
9981
- const cliUpdateIntervalMs = resolveCliUpdateCheckInterval(process.env.HAPPY_CLI_UPDATE_CHECK_INTERVAL);
9982
- if (process.env.HAPPY_CLI_UPDATE_CHECK_INTERVAL && cliUpdateIntervalMs === DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS && process.env.HAPPY_CLI_UPDATE_CHECK_INTERVAL.trim() !== String(DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS)) {
9983
- logger.warn(`[DAEMON RUN] Ignoring invalid HAPPY_CLI_UPDATE_CHECK_INTERVAL; using ${DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS}ms`);
9984
- }
9985
- const cliUpdateInterval = setInterval(() => void refreshCliUpdate(), cliUpdateIntervalMs);
9986
- const heartbeatIntervalMs = parseInt(process.env.HAPPY_DAEMON_HEARTBEAT_INTERVAL || "60000");
9987
- const restartOnStaleVersionAndHeartbeat = setInterval(serialTask(async () => {
9988
- try {
9989
- if (process.env.DEBUG) {
9990
- logger.debug(`[DAEMON RUN] Health check started at ${(/* @__PURE__ */ new Date()).toLocaleString()}`);
10174
+ };
10175
+ const spawnTrackedHappyProcess = ({
10176
+ args,
10177
+ cwd,
10178
+ env,
10179
+ directoryCreated = false,
10180
+ message,
10181
+ variant,
10182
+ spawnedBy
10183
+ }) => {
10184
+ const happyProcess = spawnHappyCLI(args, {
10185
+ cwd,
10186
+ detached: true,
10187
+ stdio: "ignore",
10188
+ env
10189
+ });
10190
+ if (!happyProcess.pid) {
10191
+ logger.debug("[DAEMON RUN] Failed to spawn process - no PID returned");
10192
+ return Promise.resolve({
10193
+ type: "error",
10194
+ errorMessage: "Failed to spawn Happy process - no PID returned"
10195
+ });
10196
+ }
10197
+ logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`);
10198
+ const trackedSession = {
10199
+ startedBy: "daemon",
10200
+ pid: happyProcess.pid,
10201
+ childProcess: happyProcess,
10202
+ directoryCreated,
10203
+ message,
10204
+ variant,
10205
+ spawnedBy
10206
+ };
10207
+ pidToTrackedSession.set(happyProcess.pid, trackedSession);
10208
+ happyProcess.on("exit", (code, signal) => {
10209
+ logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`);
10210
+ if (happyProcess.pid) {
10211
+ onChildExited(happyProcess.pid);
9991
10212
  }
9992
- for (const [pid, _] of pidToTrackedSession.entries()) {
9993
- try {
9994
- process.kill(pid, 0);
9995
- } catch (error) {
9996
- logger.debug(`[DAEMON RUN] Finalizing stale session with PID ${pid} (process no longer exists)`);
9997
- onChildExited(pid);
9998
- }
10213
+ });
10214
+ happyProcess.on("error", (error) => {
10215
+ logger.debug(`[DAEMON RUN] Child process error:`, error);
10216
+ if (happyProcess.pid) {
10217
+ onChildExited(happyProcess.pid);
9999
10218
  }
10000
- await updateController.tick();
10001
- await updateController.withHandover(async () => {
10002
- let bundleReplaced = false;
10003
- if (initialBundleMtimeMs > 0) {
10219
+ });
10220
+ logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`);
10221
+ return new Promise((resolve) => {
10222
+ const timeout = setTimeout(() => {
10223
+ pidToAwaiter.delete(happyProcess.pid);
10224
+ logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`);
10225
+ if (happyProcess.pid) {
10004
10226
  try {
10005
- const currentMtimeMs = statSync$1(bundlePath).mtimeMs;
10006
- bundleReplaced = currentMtimeMs !== initialBundleMtimeMs;
10227
+ terminateProcess(happyProcess.pid, () => {
10228
+ });
10007
10229
  } catch {
10008
10230
  }
10009
10231
  }
10010
- if (bundleReplaced && !updateController.isRunning() && pidToTrackedSession.size === 0 && !apiMachine.hasLiveTerminals()) {
10011
- const preflight = await preflightNewBundle(bundlePath);
10012
- if (preflight.action === "hold") {
10013
- if (lastHandoverHold !== preflight.reason) {
10014
- lastHandoverHold = preflight.reason;
10015
- logger.warn(`[DAEMON RUN] holding handover: ${preflight.reason}`);
10016
- }
10017
- updateController.holdHandover(preflight.reason);
10018
- return;
10019
- }
10020
- if (updateController.isRunning() || pidToTrackedSession.size > 0 || apiMachine.hasLiveTerminals()) return;
10021
- lastHandoverHold = null;
10022
- logger.debug("[DAEMON RUN] Daemon bundle replaced on disk and verified, handing off to new daemon");
10023
- clearInterval(restartOnStaleVersionAndHeartbeat);
10024
- clearInterval(cliUpdateInterval);
10025
- claudeAuthService.stop();
10026
- apiMachine.shutdown();
10027
- await stopControlServer();
10028
- await cleanupDaemonState();
10029
- await releaseDaemonLock(daemonLockHandle);
10030
- await stopCaffeinate();
10031
- try {
10032
- spawnHappyCLI(["daemon", "start"], {
10033
- detached: true,
10034
- stdio: "ignore"
10035
- });
10036
- } catch (error) {
10037
- logger.debug("[DAEMON RUN] Failed to spawn new daemon, this is quite likely to happen during integration tests as we are cleaning out dist/ directory", error);
10038
- }
10039
- process.exit(0);
10232
+ resolve({
10233
+ type: "error",
10234
+ errorMessage: `Session webhook timeout for PID ${happyProcess.pid}`
10235
+ });
10236
+ }, 15e3);
10237
+ pidToAwaiter.set(happyProcess.pid, (completedSession) => {
10238
+ clearTimeout(timeout);
10239
+ logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`);
10240
+ resolve({
10241
+ type: "success",
10242
+ sessionId: completedSession.happySessionId
10243
+ });
10244
+ });
10245
+ });
10246
+ };
10247
+ const findTrackedSessionById = (happySessionId) => {
10248
+ for (const session of pidToTrackedSession.values()) {
10249
+ if (session.happySessionId === happySessionId) return session;
10250
+ }
10251
+ return sessionIdToFinishedSession.get(happySessionId);
10252
+ };
10253
+ const persistRestoreRecord = (happySessionId, fallback, metadata) => {
10254
+ const candidates = [...pidToTrackedSession.values()].filter((s) => s.happySessionId === happySessionId && isPidAlive(s.pid));
10255
+ const live = candidates.find((s) => s.childProcess) ?? candidates[0];
10256
+ const encryption = live?.encryption ?? fallback.encryption;
10257
+ if (!encryption) return;
10258
+ persistSession(happySessionId, {
10259
+ encryptionKey: encodeBase64(encryption.encryptionKey),
10260
+ encryptionVariant: encryption.encryptionVariant,
10261
+ seq: encryption.seq,
10262
+ metadataVersion: encryption.metadataVersion,
10263
+ agentStateVersion: encryption.agentStateVersion,
10264
+ metadata: mergeRestoreMetadata(metadata, live?.happySessionMetadataFromLocalWebhook),
10265
+ savedAt: Date.now()
10266
+ });
10267
+ };
10268
+ const fetchServerSessionMetadata = async (sessionId, encryptionKey, encryptionVariant) => {
10269
+ try {
10270
+ const byId = await axios.get(`${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`, {
10271
+ headers: { Authorization: `Bearer ${credentials.token}` },
10272
+ timeout: 1e4,
10273
+ validateStatus: (status) => status >= 200 && status < 300 || status === 404
10274
+ });
10275
+ if (byId.status !== 404) {
10276
+ const row = byId.data.session;
10277
+ if (row?.metadata) return decrypt(encryptionKey, encryptionVariant, decodeBase64(row.metadata));
10278
+ }
10279
+ } catch (error) {
10280
+ logger.debug(`[DAEMON RUN] by-id session metadata fetch failed, falling back to list: ${error instanceof Error ? error.message : error}`);
10281
+ }
10282
+ try {
10283
+ const response = await axios.get(`${configuration.serverUrl}/v1/sessions`, {
10284
+ headers: { Authorization: `Bearer ${credentials.token}` },
10285
+ timeout: 1e4
10286
+ });
10287
+ const sessions = response.data.sessions;
10288
+ const matched = sessions.find((s) => s.id === sessionId);
10289
+ if (!matched) return null;
10290
+ const decrypted = decrypt(encryptionKey, encryptionVariant, decodeBase64(matched.metadata));
10291
+ return decrypted;
10292
+ } catch (error) {
10293
+ logger.debug(`[DAEMON RUN] Failed to fetch session metadata from server: ${error instanceof Error ? error.message : error}`);
10294
+ return null;
10295
+ }
10296
+ };
10297
+ const resumeGates = /* @__PURE__ */ new Map();
10298
+ const resumeSession = async (happySessionId, options) => {
10299
+ let gate = resumeGates.get(happySessionId);
10300
+ if (!gate) {
10301
+ gate = createSpawnGate();
10302
+ resumeGates.set(happySessionId, gate);
10303
+ }
10304
+ try {
10305
+ return await gate.join(() => resumeSessionImpl(happySessionId, options));
10306
+ } finally {
10307
+ if (!gate.inFlight()) resumeGates.delete(happySessionId);
10308
+ }
10309
+ };
10310
+ const resumeSessionImpl = async (happySessionId, options) => {
10311
+ try {
10312
+ for (const [pid, live] of pidToTrackedSession.entries()) {
10313
+ if (live.happySessionId !== happySessionId) continue;
10314
+ if (isPidAlive(pid)) {
10315
+ logger.debug(`[DAEMON RUN] resume ${happySessionId}: process ${pid} already alive \u2014 idempotent success`);
10316
+ return { type: "success", sessionId: happySessionId };
10317
+ }
10318
+ onChildExited(pid);
10319
+ }
10320
+ const tracked = findTrackedSessionById(happySessionId);
10321
+ if (!tracked) {
10322
+ return { type: "error", errorMessage: `resume-precheck:not-tracked: Session ${happySessionId} is not tracked by this daemon. It may have been started before the daemon, more than 14 days ago, or on another machine.` };
10323
+ }
10324
+ const orphans = findSessionWrapperPids(tracked.happySessionMetadataFromLocalWebhook, await findAllHappyProcesses(), { excludePid: process.pid, lockPid: readSessionLock(happySessionId)?.pid }).filter((pid) => isPidAlive(pid) && !pidToTrackedSession.has(pid));
10325
+ if (orphans.length > 0) {
10326
+ for (const pid of orphans) {
10327
+ pidToTrackedSession.set(pid, { ...tracked, pid, startedBy: "adopted untracked wrapper" });
10328
+ }
10329
+ logger.debug(`[DAEMON RUN] resume ${happySessionId}: adopted untracked live wrapper(s) ${orphans.join(", ")} \u2014 idempotent success`);
10330
+ return { type: "success", sessionId: happySessionId };
10331
+ }
10332
+ if (!tracked.happySessionMetadataFromLocalWebhook) {
10333
+ return { type: "error", errorMessage: `resume-precheck:no-metadata: Session ${happySessionId} has no metadata. Cannot resume.` };
10334
+ }
10335
+ if (!tracked.encryption) {
10336
+ return { type: "error", errorMessage: `resume-precheck:no-encryption: Session ${happySessionId} has no stored encryption data. It was likely started before this feature was available. Restart the daemon and start a new session to enable resume.` };
10337
+ }
10338
+ let metadata = tracked.happySessionMetadataFromLocalWebhook;
10339
+ const needsFetch = !metadata.claudeSessionId && (!metadata.flavor || metadata.flavor === "claude") || !metadata.codexThreadId && metadata.flavor === "codex";
10340
+ if (needsFetch) {
10341
+ logger.debug(`[DAEMON RUN] Session ${happySessionId} missing agent session ID in webhook metadata, fetching from server`);
10342
+ const serverMetadata = await fetchServerSessionMetadata(happySessionId, tracked.encryption.encryptionKey, tracked.encryption.encryptionVariant);
10343
+ if (serverMetadata) {
10344
+ metadata = serverMetadata;
10345
+ tracked.happySessionMetadataFromLocalWebhook = serverMetadata;
10346
+ }
10347
+ }
10348
+ const precheck = resumePrecheck(metadata, {
10349
+ cwdExists: (p) => existsSync$1(p),
10350
+ conversationExists: (cwd, claudeSessionId) => existsSync$1(join$1(getProjectPath(cwd), `${claudeSessionId}.jsonl`))
10351
+ });
10352
+ if (!precheck.ok) {
10353
+ return { type: "error", errorMessage: `resume-precheck:${precheck.reason}: ${precheck.detail}` };
10354
+ }
10355
+ const launch = buildResumeLaunch(
10356
+ { id: happySessionId, active: true, metadata },
10357
+ { startedBy: "daemon", claudeStartingMode: "remote" }
10358
+ );
10359
+ const model = sanitizeResumeModel(options?.model);
10360
+ if (model) launch.args.push("--model", model);
10361
+ else if (options?.model !== void 0) logger.debug(`[DAEMON RUN] resume: ignoring invalid model ${JSON.stringify(options.model)}`);
10362
+ const permissionMode = sanitizeSpawnPermissionMode(options?.permissionMode);
10363
+ if (permissionMode) launch.args.push("--permission-mode", permissionMode);
10364
+ else if (options?.permissionMode !== void 0) logger.debug(`[DAEMON RUN] resume: ignoring invalid permission mode ${JSON.stringify(options.permissionMode)}`);
10365
+ const result = await spawnTrackedHappyProcess({
10366
+ args: launch.args,
10367
+ cwd: launch.cwd,
10368
+ env: {
10369
+ ...process.env,
10370
+ // B-297: the credentialStore=file shim must ride every spawn path,
10371
+ // not just fresh sessions — a resumed/restarted wrapper that silently
10372
+ // fell back to `auto` reads a different credential store than the
10373
+ // fresh one next to it (B-276 D8).
10374
+ ...claudeAuthServiceRef?.claudeProcessEnvOverrides() ?? {},
10375
+ HAPPY_RECONNECT_SESSION_ID: happySessionId,
10376
+ HAPPY_RECONNECT_ENCRYPTION_KEY: encodeBase64(tracked.encryption.encryptionKey),
10377
+ HAPPY_RECONNECT_ENCRYPTION_VARIANT: tracked.encryption.encryptionVariant,
10378
+ HAPPY_RECONNECT_SEQ: String(tracked.encryption.seq),
10379
+ HAPPY_RECONNECT_METADATA_VERSION: String(tracked.encryption.metadataVersion),
10380
+ HAPPY_RECONNECT_AGENT_STATE_VERSION: String(tracked.encryption.agentStateVersion)
10040
10381
  }
10041
10382
  });
10042
- const daemonState = await readDaemonState();
10043
- if (daemonState && daemonState.pid !== process.pid) {
10044
- logger.debug("[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves.");
10045
- requestShutdown("exception", "A different daemon was started without killing us. We should kill ourselves.");
10046
- }
10047
- try {
10048
- fileState = withDaemonHeartbeat(fileState, (/* @__PURE__ */ new Date()).toLocaleString());
10049
- writeDaemonState(fileState);
10050
- if (process.env.DEBUG) {
10051
- logger.debug(`[DAEMON RUN] Health check completed at ${fileState.lastHeartbeat}`);
10052
- }
10053
- } catch (error) {
10054
- logger.debug("[DAEMON RUN] Failed to write heartbeat", error);
10383
+ if (result.type === "success") {
10384
+ persistRestoreRecord(happySessionId, tracked, metadata);
10055
10385
  }
10386
+ return result;
10056
10387
  } catch (error) {
10057
- logger.debug("[DAEMON RUN] Heartbeat failed:", error);
10058
- }
10059
- }), heartbeatIntervalMs);
10060
- const cleanupAndShutdown = async (source, errorMessage) => {
10061
- logger.debug(`[DAEMON RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`);
10062
- if (restartOnStaleVersionAndHeartbeat) {
10063
- clearInterval(restartOnStaleVersionAndHeartbeat);
10064
- logger.debug("[DAEMON RUN] Health check interval cleared");
10065
- }
10066
- clearInterval(cliUpdateInterval);
10067
- await apiMachine.updateDaemonState((state) => ({
10068
- ...state,
10069
- status: "shutting-down",
10070
- shutdownRequestedAt: Date.now(),
10071
- shutdownSource: source
10072
- }));
10073
- await new Promise((resolve) => setTimeout(resolve, 100));
10074
- try {
10075
- await mirrorManager.shutdown();
10076
- } catch (error) {
10077
- logger.debug("[DAEMON RUN] Mirror shutdown failed:", error);
10388
+ const errorMessage = error instanceof Error ? error.message : error && typeof error === "object" ? JSON.stringify(error) : String(error);
10389
+ logger.debug(`[DAEMON RUN] Failed to resume session: ${errorMessage}`, error instanceof Error ? error.stack : void 0);
10390
+ return {
10391
+ type: "error",
10392
+ errorMessage: `Failed to resume session: ${errorMessage}`
10393
+ };
10078
10394
  }
10079
- apiMachine.shutdown();
10080
- await stopControlServer();
10081
- await cleanupDaemonState();
10082
- await stopCaffeinate();
10083
- await releaseDaemonLock(daemonLockHandle);
10084
- logger.debug("[DAEMON RUN] Cleanup completed, exiting process");
10085
- process.exit(0);
10086
10395
  };
10087
- logger.debug("[DAEMON RUN] Daemon started successfully, waiting for shutdown request");
10088
- const shutdownRequest = await resolvesWhenShutdownRequested;
10089
- await cleanupAndShutdown(shutdownRequest.source, shutdownRequest.errorMessage);
10090
- } catch (error) {
10091
- logger.debug("[DAEMON RUN][FATAL] Failed somewhere unexpectedly - exiting with code 1", error);
10092
- process.exit(1);
10093
- }
10094
- }
10095
-
10096
- const MAX_LINE_CHARS = 500;
10097
- const MAX_ARGS_CHARS = 160;
10098
- function truncateText(text, max = MAX_LINE_CHARS) {
10099
- const chars = Array.from(text);
10100
- if (chars.length <= max) return text;
10101
- return chars.slice(0, max).join("") + ` \u2026[+${chars.length - max} chars]`;
10102
- }
10103
- function squash(text) {
10104
- return text.replace(/\s+/g, " ").trim();
10105
- }
10106
- function safeJson(value, max) {
10107
- try {
10108
- return truncateText(JSON.stringify(value) ?? "", max);
10109
- } catch {
10110
- return "[unserializable]";
10111
- }
10112
- }
10113
- function formatMessageBody(body) {
10114
- if (!body || typeof body !== "object") return null;
10115
- const b = body;
10116
- if (b.role === "user" && b.content?.type === "text" && typeof b.content.text === "string") {
10117
- const text = squash(b.content.text);
10118
- return text.length > 0 ? `user: ${truncateText(text)}` : null;
10119
- }
10120
- if (b.role === "session" && b.content && typeof b.content === "object") {
10121
- const env = b.content;
10122
- const ev = env.ev;
10123
- if (!ev || typeof ev.t !== "string") return null;
10124
- switch (ev.t) {
10125
- case "text": {
10126
- if (ev.thinking) return null;
10127
- const text = squash(String(ev.text ?? ""));
10128
- if (text.length === 0) return null;
10129
- const role = env.role === "user" ? "user" : "assistant";
10130
- return `${role}: ${truncateText(text)}`;
10131
- }
10132
- case "tool-call-start": {
10133
- const name = String(ev.name ?? "tool");
10134
- const title = squash(String(ev.title ?? ""));
10135
- const args = ev.args ? safeJson(ev.args, MAX_ARGS_CHARS) : "";
10136
- const detail = title || args;
10137
- return `[tool] ${name}${detail ? `: ${truncateText(detail, MAX_ARGS_CHARS)}` : ""}`;
10138
- }
10139
- case "service": {
10140
- const text = squash(String(ev.text ?? ""));
10141
- return text.length > 0 ? `[service] ${truncateText(text, MAX_ARGS_CHARS)}` : null;
10142
- }
10143
- case "turn-end": {
10144
- if (ev.status && ev.status !== "completed") return `[turn ${ev.status}]`;
10145
- return null;
10146
- }
10147
- default:
10148
- return null;
10149
- }
10150
- }
10151
- if (b.role === "agent" && b.content?.type === "acp" && b.content.data && typeof b.content.data === "object") {
10152
- const d = b.content.data;
10153
- switch (d.type) {
10154
- case "message": {
10155
- const text = squash(String(d.message ?? ""));
10156
- return text.length > 0 ? `assistant: ${truncateText(text)}` : null;
10396
+ const restartCounts = /* @__PURE__ */ new Map();
10397
+ const stopSessionAndWait = async (happySessionId, timeoutMs = 8e3) => {
10398
+ const targets = /* @__PURE__ */ new Set();
10399
+ for (const [pid, s] of pidToTrackedSession.entries()) {
10400
+ if (s.happySessionId === happySessionId && isPidAlive(pid)) targets.add(pid);
10157
10401
  }
10158
- case "tool-call":
10159
- return `[tool] ${String(d.name ?? "tool")}: ${safeJson(d.input, MAX_ARGS_CHARS)}`;
10160
- case "permission-request":
10161
- return `[permission] ${String(d.toolName ?? "")}: ${truncateText(squash(String(d.description ?? "")), MAX_ARGS_CHARS)}`;
10162
- default:
10163
- return null;
10164
- }
10165
- }
10166
- if (b.role === "agent" && b.content?.type === "output") {
10167
- const msg = b.content.data?.message;
10168
- if (msg && Array.isArray(msg.content)) {
10169
- const texts = msg.content.filter((c) => c?.type === "text" && typeof c.text === "string").map((c) => squash(c.text)).filter((t) => t.length > 0);
10170
- if (texts.length > 0) {
10171
- const role = msg.role === "user" ? "user" : "assistant";
10172
- return `${role}: ${truncateText(texts.join(" "))}`;
10402
+ const known = findTrackedSessionById(happySessionId)?.happySessionMetadataFromLocalWebhook;
10403
+ for (const pid of findSessionWrapperPids(known, await findAllHappyProcesses(), { excludePid: process.pid, lockPid: readSessionLock(happySessionId)?.pid })) {
10404
+ if (isPidAlive(pid)) targets.add(pid);
10173
10405
  }
10174
- }
10175
- return null;
10176
- }
10177
- return null;
10178
- }
10179
- function formatTranscript(bodies) {
10180
- const lines = [];
10181
- for (const body of bodies) {
10182
- const line = formatMessageBody(body);
10183
- if (line !== null) lines.push(line);
10184
- }
10185
- return lines.join("\n");
10186
- }
10187
-
10188
- const USER_SOCKET_CONNECT_TIMEOUT_MS = 15e3;
10189
- async function openUserScopedSocket(token, options = {}) {
10190
- const socket = io(configuration.serverUrl, {
10191
- auth: {
10192
- token,
10193
- clientType: "user-scoped",
10194
- happyClient: `cli-session-ops/${configuration.currentCliVersion}`
10195
- },
10196
- path: "/v1/updates",
10197
- transports: ["websocket"],
10198
- reconnection: false,
10199
- autoConnect: false
10200
- });
10201
- await new Promise((resolve, reject) => {
10202
- const timer = setTimeout(() => {
10203
- socket.close();
10204
- reject(new Error(`Timed out connecting to ${configuration.serverUrl}`));
10205
- }, options.connectTimeoutMs ?? USER_SOCKET_CONNECT_TIMEOUT_MS);
10206
- socket.once("connect", () => {
10207
- clearTimeout(timer);
10208
- resolve();
10209
- });
10210
- socket.once("connect_error", (error) => {
10211
- clearTimeout(timer);
10212
- socket.close();
10213
- reject(new Error(`Could not connect to ${configuration.serverUrl}: ${error.message}`));
10214
- });
10215
- socket.connect();
10216
- });
10217
- return {
10218
- async rpcCall(payload, timeoutMs) {
10219
- const ack = await socket.timeout(timeoutMs).emitWithAck("rpc-call", payload);
10220
- return ack;
10221
- },
10222
- close() {
10223
- socket.close();
10224
- }
10225
- };
10226
- }
10227
-
10228
- const RPC_RATE_LIMIT_ERROR = "RPC rate limit reached";
10229
- const RPC_ACCOUNT_RATE_LIMIT_ERROR = "RPC account rate limit reached";
10230
- const RPC_RATE_RETRY_MIN_DELAY_MS = 2e3;
10231
- const RPC_RATE_RETRY_MAX_DELAY_MS = 6e4;
10232
- const RPC_RATE_MAX_RETRIES = 2;
10233
- function isRpcRateLimitAck(ack) {
10234
- if (!ack || typeof ack !== "object") return false;
10235
- const a = ack;
10236
- if (a.ok !== false) return false;
10237
- if (a.code === "rpc_rate_limited" || a.code === "rpc_account_rate_limited") return true;
10238
- return a.error === RPC_RATE_LIMIT_ERROR || a.error === RPC_ACCOUNT_RATE_LIMIT_ERROR;
10239
- }
10240
- function rpcRateRetryDelayMs(attempt, retryAfterMs, random = Math.random()) {
10241
- if (typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
10242
- return Math.min(RPC_RATE_RETRY_MAX_DELAY_MS, Math.round(retryAfterMs * (1 + random * 0.5)));
10243
- }
10244
- const exponent = Math.max(0, Math.min(attempt, 32) - 1);
10245
- const ceiling = Math.min(RPC_RATE_RETRY_MIN_DELAY_MS * 2 ** exponent, RPC_RATE_RETRY_MAX_DELAY_MS);
10246
- return Math.round(RPC_RATE_RETRY_MIN_DELAY_MS + random * (ceiling - RPC_RATE_RETRY_MIN_DELAY_MS));
10247
- }
10248
-
10249
- const PERMISSION_OPS_CLIENT = "session-ops";
10250
- const PERMISSION_RPC_TIMEOUT_MS = 3e4;
10251
- const SETTLE_TIMEOUT_MS = 5e3;
10252
- const SETTLE_POLL_MS = 500;
10253
- function buildPermissionRpcPayload(requestId, verdict) {
10254
- if (verdict.kind === "approve") {
10255
- return {
10256
- id: requestId,
10257
- approved: true,
10258
- decision: verdict.forSession ? "approved_for_session" : "approved"
10406
+ if (targets.size === 0) return;
10407
+ logger.debug(`[DAEMON RUN] restart ${happySessionId}: stopping live wrapper(s) ${[...targets].join(", ")}`);
10408
+ await Promise.all([...targets].map((pid) => new Promise((resolve) => {
10409
+ let settled = false;
10410
+ const done = () => {
10411
+ if (!settled) {
10412
+ settled = true;
10413
+ resolve();
10414
+ }
10415
+ };
10416
+ const requested = terminateProcess(pid, () => {
10417
+ const tracked = pidToTrackedSession.get(pid);
10418
+ if (tracked) {
10419
+ if (tracked.happySessionId && tracked.encryption) {
10420
+ sessionIdToFinishedSession.set(tracked.happySessionId, tracked);
10421
+ }
10422
+ pidToTrackedSession.delete(pid);
10423
+ }
10424
+ done();
10425
+ });
10426
+ if (!requested) done();
10427
+ setTimeout(done, timeoutMs).unref?.();
10428
+ })));
10259
10429
  };
10260
- }
10261
- return {
10262
- id: requestId,
10263
- approved: false,
10264
- decision: "denied",
10265
- ...verdict.reason ? { reason: verdict.reason } : {}
10266
- };
10267
- }
10268
- function interpretPermissionAck(ack, decryptResult) {
10269
- if (!ack.ok) {
10270
- const message = ack.error ?? "RPC call failed";
10271
- if (message === "RPC method not available") {
10272
- return { status: "offline", message: "no running wrapper has registered the permission RPC for this session" };
10273
- }
10274
- if (message === "RPC target disconnected") {
10275
- return { status: "offline", message: "the wrapper disconnected while the permission RPC was in flight" };
10276
- }
10277
- if (/timed? ?out/i.test(message)) {
10278
- return { status: "timeout", message };
10279
- }
10280
- if (isRpcRateLimitAck(ack)) {
10281
- return { status: "rate-limited", message, retryAfterMs: rpcRateRetryDelayMs(1, ack.retryAfterMs, 0) };
10282
- }
10283
- return { status: "rejected", message };
10284
- }
10285
- let body = null;
10286
- if (typeof ack.result === "string" && ack.result.length > 0) {
10287
- try {
10288
- body = decryptResult(ack.result);
10289
- } catch {
10290
- body = null;
10291
- }
10292
- }
10293
- if (body && typeof body === "object" && typeof body.error === "string") {
10294
- return { status: "handler-error", message: body.error };
10295
- }
10296
- return { status: "acknowledged" };
10297
- }
10298
- function pendingRequestsOf(agentState, now) {
10299
- const requests = agentState?.requests;
10300
- if (!requests || typeof requests !== "object") return [];
10301
- return Object.entries(requests).map(([id, request]) => ({
10302
- id,
10303
- tool: typeof request?.tool === "string" ? request.tool : "unknown",
10304
- ...request?.kind ? { kind: request.kind } : {},
10305
- ...typeof request?.createdAt === "number" ? { createdAt: request.createdAt, waitingMs: Math.max(0, now - request.createdAt) } : {}
10306
- })).sort((a, b) => (a.createdAt ?? Number.MAX_SAFE_INTEGER) - (b.createdAt ?? Number.MAX_SAFE_INTEGER));
10307
- }
10308
- const RPC_DEADLINE_GRACE_MS = 2e3;
10309
- function withDeadline(promise, ms, message) {
10310
- return new Promise((resolve, reject) => {
10311
- const timer = setTimeout(() => reject(new Error(`${message} (timed out)`)), ms);
10312
- promise.then(
10313
- (value) => {
10314
- clearTimeout(timer);
10315
- resolve(value);
10316
- },
10317
- (error) => {
10318
- clearTimeout(timer);
10319
- reject(error);
10430
+ const restartSession = async (happySessionId, options) => {
10431
+ let gate = resumeGates.get(happySessionId);
10432
+ if (!gate) {
10433
+ gate = createSpawnGate();
10434
+ resumeGates.set(happySessionId, gate);
10320
10435
  }
10321
- );
10322
- });
10323
- }
10324
- async function resolvePermissionRequest(sessionId, requestId, verdict, deps = {}) {
10325
- const now = deps.now ?? Date.now;
10326
- const persisted = (deps.readPersisted ?? readPersistedSessions)()[sessionId];
10327
- if (!persisted) {
10328
- throw new Error(
10329
- `No local key for session ${sessionId} \u2014 approve/deny needs the session key from ~/.happy/sessions.json (the RPC payload is encrypted with it), so only sessions this machine's daemon spawned within 14 days can be answered from here.`
10330
- );
10331
- }
10332
- const token = await (deps.bearerToken ?? bearerToken$1)();
10333
- const fetchState = deps.fetchAgentState ?? fetchDecryptedAgentState;
10334
- const before = pendingRequestsOf(await fetchState(sessionId, persisted, token), now());
10335
- if (!before.some((request) => request.id === requestId)) {
10336
- const hint = before.length > 0 ? `Pending request ids: ${before.map((request) => `${request.id} (${request.tool})`).join(", ")}` : "The session has no pending requests.";
10337
- throw new Error(`Request ${requestId} is not pending on session ${sessionId}. ${hint}`);
10338
- }
10339
- const payload = buildPermissionRpcPayload(requestId, verdict);
10340
- const key = decodeBase64(persisted.encryptionKey);
10341
- const params = encodeBase64(encrypt(key, persisted.encryptionVariant, payload));
10342
- const transport = await (deps.openTransport ?? openUserScopedSocket)(token);
10343
- const rpcTimeoutMs = deps.rpcTimeoutMs ?? PERMISSION_RPC_TIMEOUT_MS;
10344
- const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
10345
- let ack;
10346
- try {
10347
- for (let attempt = 1; ; attempt++) {
10348
10436
  try {
10349
- ack = await withDeadline(
10350
- transport.rpcCall({ method: `${sessionId}:permission`, params }, rpcTimeoutMs),
10351
- rpcTimeoutMs + RPC_DEADLINE_GRACE_MS,
10352
- `permission RPC did not settle within ${rpcTimeoutMs}ms`
10353
- );
10437
+ return await gate.replace(() => restartSessionImpl(happySessionId, options));
10438
+ } finally {
10439
+ if (!gate.inFlight()) resumeGates.delete(happySessionId);
10440
+ }
10441
+ };
10442
+ const restartSessionImpl = async (happySessionId, options) => {
10443
+ try {
10444
+ const prior = restartCounts.get(happySessionId) ?? 0;
10445
+ const decision = decideRestart(prior, DEFAULT_MAX_RESTARTS);
10446
+ if (!decision.allowed) {
10447
+ return { type: "error", errorMessage: `restart:${decision.reason}` };
10448
+ }
10449
+ await stopSessionAndWait(happySessionId);
10450
+ const tracked = findTrackedSessionById(happySessionId);
10451
+ if (!tracked) {
10452
+ return { type: "error", errorMessage: `restart-precheck:not-tracked: Session ${happySessionId} is not tracked by this daemon.` };
10453
+ }
10454
+ if (!tracked.happySessionMetadataFromLocalWebhook) {
10455
+ return { type: "error", errorMessage: `restart-precheck:no-metadata: Session ${happySessionId} has no metadata. Cannot restart.` };
10456
+ }
10457
+ if (!tracked.encryption) {
10458
+ return { type: "error", errorMessage: `restart-precheck:no-encryption: Session ${happySessionId} has no stored encryption data. Restart the daemon and start a new session.` };
10459
+ }
10460
+ let metadata = tracked.happySessionMetadataFromLocalWebhook;
10461
+ const needsFetch = !metadata.claudeSessionId && (!metadata.flavor || metadata.flavor === "claude") || !metadata.codexThreadId && metadata.flavor === "codex";
10462
+ if (needsFetch) {
10463
+ const serverMetadata = await fetchServerSessionMetadata(happySessionId, tracked.encryption.encryptionKey, tracked.encryption.encryptionVariant);
10464
+ if (serverMetadata) {
10465
+ metadata = serverMetadata;
10466
+ tracked.happySessionMetadataFromLocalWebhook = serverMetadata;
10467
+ }
10468
+ }
10469
+ const precheck = resumePrecheck(metadata, {
10470
+ cwdExists: (p) => existsSync$1(p),
10471
+ conversationExists: (cwd2, claudeSessionId) => existsSync$1(join$1(getProjectPath(cwd2), `${claudeSessionId}.jsonl`))
10472
+ });
10473
+ let args;
10474
+ let cwd;
10475
+ if (precheck.ok) {
10476
+ const launch = buildResumeLaunch(
10477
+ { id: happySessionId, active: true, metadata },
10478
+ { startedBy: "daemon", claudeStartingMode: "remote" }
10479
+ );
10480
+ args = launch.args;
10481
+ cwd = launch.cwd;
10482
+ } else if ((metadata.flavor ?? "claude") === "claude" && typeof metadata.path === "string" && metadata.path.length > 0) {
10483
+ cwd = metadata.path;
10484
+ if (!existsSync$1(cwd)) {
10485
+ return { type: "error", errorMessage: `restart-precheck:cwd-missing: ${cwd}` };
10486
+ }
10487
+ args = ["claude", "--happy-starting-mode", "remote", "--started-by", "daemon"];
10488
+ } else {
10489
+ return { type: "error", errorMessage: `restart-precheck:${precheck.ok ? "unknown" : precheck.reason}: ${precheck.ok ? "" : precheck.detail}` };
10490
+ }
10491
+ const model = sanitizeResumeModel(options?.model);
10492
+ if (model) args.push("--model", model);
10493
+ const permissionMode = sanitizeSpawnPermissionMode(options?.permissionMode);
10494
+ if (permissionMode) args.push("--permission-mode", permissionMode);
10495
+ restartCounts.set(happySessionId, recordRestartAttempt(prior));
10496
+ const result = await spawnTrackedHappyProcess({
10497
+ args,
10498
+ cwd,
10499
+ env: {
10500
+ ...process.env,
10501
+ // B-297: the credentialStore=file shim must ride every spawn path,
10502
+ // not just fresh sessions — a resumed/restarted wrapper that silently
10503
+ // fell back to `auto` reads a different credential store than the
10504
+ // fresh one next to it (B-276 D8).
10505
+ ...claudeAuthServiceRef?.claudeProcessEnvOverrides() ?? {},
10506
+ HAPPY_RECONNECT_SESSION_ID: happySessionId,
10507
+ HAPPY_RECONNECT_ENCRYPTION_KEY: encodeBase64(tracked.encryption.encryptionKey),
10508
+ HAPPY_RECONNECT_ENCRYPTION_VARIANT: tracked.encryption.encryptionVariant,
10509
+ HAPPY_RECONNECT_SEQ: String(tracked.encryption.seq),
10510
+ HAPPY_RECONNECT_METADATA_VERSION: String(tracked.encryption.metadataVersion),
10511
+ HAPPY_RECONNECT_AGENT_STATE_VERSION: String(tracked.encryption.agentStateVersion)
10512
+ }
10513
+ });
10514
+ if (result.type === "success") {
10515
+ persistRestoreRecord(happySessionId, tracked, metadata);
10516
+ }
10517
+ return result;
10354
10518
  } catch (error) {
10355
- ack = { ok: false, error: error instanceof Error ? error.message : "RPC call failed" };
10519
+ const errorMessage = error instanceof Error ? error.message : String(error);
10520
+ logger.debug(`[DAEMON RUN] Failed to restart session: ${errorMessage}`, error instanceof Error ? error.stack : void 0);
10521
+ return { type: "error", errorMessage: `Failed to restart session: ${errorMessage}` };
10356
10522
  }
10357
- if (!isRpcRateLimitAck(ack) || attempt > RPC_RATE_MAX_RETRIES) break;
10358
- const waitMs = rpcRateRetryDelayMs(attempt, ack.retryAfterMs, (deps.random ?? Math.random)());
10359
- deps.onRateLimited?.({ attempt, waitMs, serverError: ack.error ?? "RPC rate limit reached" });
10360
- await sleep(waitMs);
10361
- }
10362
- } finally {
10363
- transport.close();
10364
- }
10365
- const outcome = interpretPermissionAck(ack, (encrypted) => decrypt(key, persisted.encryptionVariant, decodeBase64(encrypted)));
10366
- if (outcome.status !== "acknowledged") {
10367
- return { sessionId, requestId, payload, outcome };
10368
- }
10369
- const deadline = now() + (deps.settleTimeoutMs ?? SETTLE_TIMEOUT_MS);
10370
- let settled = false;
10371
- for (; ; ) {
10372
- const pending = pendingRequestsOf(await fetchState(sessionId, persisted, token), now());
10373
- if (!pending.some((request) => request.id === requestId)) {
10374
- settled = true;
10375
- break;
10376
- }
10377
- if (now() >= deadline) break;
10378
- await sleep(SETTLE_POLL_MS);
10379
- }
10380
- return { sessionId, requestId, payload, outcome, settled };
10381
- }
10382
- async function bearerToken$1() {
10383
- const credentials = await readCredentialsForConfiguredRelay();
10384
- if (!credentials) throw new Error("CLI is not authenticated (no ~/.happy/access.key)");
10385
- return credentials.token;
10386
- }
10387
- async function fetchDecryptedAgentState(sessionId, persisted, token) {
10388
- const response = await axios.get(
10389
- `${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
10390
- {
10391
- headers: {
10392
- "Authorization": `Bearer ${token}`,
10393
- "X-Happy-Client": `${PERMISSION_OPS_CLIENT}/${configuration.currentCliVersion}`
10523
+ };
10524
+ const stopSession = (sessionId) => {
10525
+ logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`);
10526
+ for (const [pid, session] of pidToTrackedSession.entries()) {
10527
+ if (session.happySessionId === sessionId || sessionId.startsWith("PID-") && pid === parseInt(sessionId.replace("PID-", ""))) {
10528
+ const requested = terminateProcess(pid, (stopped) => {
10529
+ if (!stopped) {
10530
+ logger.debug(`[DAEMON RUN] Session ${sessionId} survived SIGTERM and SIGKILL attempts`);
10531
+ return;
10532
+ }
10533
+ const tracked = pidToTrackedSession.get(pid);
10534
+ if (!tracked) return;
10535
+ if (tracked.happySessionId && tracked.encryption) {
10536
+ sessionIdToFinishedSession.set(tracked.happySessionId, tracked);
10537
+ }
10538
+ pidToTrackedSession.delete(pid);
10539
+ logger.debug(`[DAEMON RUN] Verified session ${sessionId} stopped and removed it from tracking`);
10540
+ });
10541
+ if (requested) {
10542
+ logger.debug(`[DAEMON RUN] Requested verified termination for session ${sessionId} (PID ${pid})`);
10543
+ } else {
10544
+ logger.debug(`[DAEMON RUN] Failed to signal session ${sessionId} (PID ${pid})`);
10545
+ }
10546
+ return requested;
10547
+ }
10548
+ }
10549
+ logger.debug(`[DAEMON RUN] Session ${sessionId} not found`);
10550
+ return false;
10551
+ };
10552
+ const onChildExited = (pid) => {
10553
+ const session = pidToTrackedSession.get(pid);
10554
+ if (session?.happySessionId && (session.spawnedBy === "teams" || teamWorker?.hasSession(session.happySessionId))) teamWorker?.report(session.happySessionId, "exited");
10555
+ if (session?.happySessionId && session.encryption) {
10556
+ sessionIdToFinishedSession.set(session.happySessionId, session);
10557
+ logger.debug(`[DAEMON RUN] Process PID ${pid} exited, preserved session ${session.happySessionId} for resume`);
10558
+ } else {
10559
+ logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`);
10560
+ }
10561
+ pidToTrackedSession.delete(pid);
10562
+ };
10563
+ const assistantReportLastSentAt = /* @__PURE__ */ new Map();
10564
+ const isPidAlive = (pid) => {
10565
+ try {
10566
+ process.kill(pid, 0);
10567
+ return true;
10568
+ } catch {
10569
+ return false;
10570
+ }
10571
+ };
10572
+ const onSessionStateEvent = (sessionId, event, spawnedByFromSession) => {
10573
+ if (spawnedByFromSession === "teams" || teamWorker?.hasSession(sessionId) || [...pidToTrackedSession.values()].some((s) => s.happySessionId === sessionId && s.spawnedBy === "teams")) {
10574
+ teamWorker?.report(sessionId, event === "completed" ? "idle" : "blocked");
10575
+ return;
10576
+ }
10577
+ void (async () => {
10578
+ try {
10579
+ let tracked;
10580
+ for (const s of pidToTrackedSession.values()) {
10581
+ if (s.happySessionId === sessionId) {
10582
+ tracked = s;
10583
+ break;
10584
+ }
10585
+ }
10586
+ const liveAssistant = findLiveAssistant(pidToTrackedSession.values(), isPidAlive);
10587
+ const decision = decideAssistantReport({
10588
+ // The tracked tag is authoritative; the session's own echo covers
10589
+ // a daemon restart that lost the in-memory tracking.
10590
+ spawnedBy: tracked?.spawnedBy ?? spawnedByFromSession,
10591
+ isAssistantSession: tracked ? isAssistantTracked(tracked) : false,
10592
+ sessionId,
10593
+ assistantSessionId: liveAssistant?.happySessionId,
10594
+ lastReportAt: assistantReportLastSentAt.get(sessionId),
10595
+ now: Date.now()
10596
+ });
10597
+ if (!decision.send) {
10598
+ logger.debug(`[DAEMON RUN] Assistant report skipped for ${sessionId} (${event}): ${decision.reason}`);
10599
+ return;
10600
+ }
10601
+ const persisted2 = readPersistedSessions();
10602
+ const assistantEntry = persisted2[decision.assistantSessionId];
10603
+ if (!assistantEntry) {
10604
+ logger.debug(`[DAEMON RUN] Assistant report skipped for ${sessionId} (${event}): no session key for assistant ${decision.assistantSessionId}`);
10605
+ return;
10606
+ }
10607
+ const title = resolveReportSessionTitle(
10608
+ tracked?.happySessionMetadataFromLocalWebhook ?? persisted2[sessionId]?.metadata,
10609
+ sessionId
10610
+ );
10611
+ assistantReportLastSentAt.set(sessionId, Date.now());
10612
+ await sendUserMessage(
10613
+ decision.assistantSessionId,
10614
+ assistantEntry,
10615
+ formatAssistantReportMessage(title, sessionId, event),
10616
+ "assistant-report"
10617
+ );
10618
+ logger.debug(`[DAEMON RUN] Assistant report sent: session ${sessionId} ${event} \u2192 assistant ${decision.assistantSessionId}`);
10619
+ } catch (error) {
10620
+ logger.debug(`[DAEMON RUN] Assistant report failed for ${sessionId} (${event}):`, error);
10621
+ }
10622
+ })();
10623
+ };
10624
+ let apiMachineRef = null;
10625
+ let mirrorManagerRef = null;
10626
+ const controlToken = createDaemonControlToken();
10627
+ const { port: controlPort, stop: stopControlServer } = await startDaemonControlServer({
10628
+ controlToken,
10629
+ getChildren: getCurrentChildren,
10630
+ stopSession,
10631
+ spawnSession,
10632
+ requestShutdown: () => requestShutdown("happy-cli"),
10633
+ onHappySessionWebhook,
10634
+ onSessionStateEvent,
10635
+ onClaudeAuthFailed: (sessionId) => claudeAuthServiceRef?.signalAuthFailed(sessionId),
10636
+ pushClipboard: (text) => {
10637
+ if (!apiMachineRef) {
10638
+ return { delivered: false, truncated: false, totalBytes: 0, error: "daemon is still starting up" };
10639
+ }
10640
+ return apiMachineRef.pushClipboard(text);
10394
10641
  },
10395
- timeout: 15e3,
10396
- // 404 is a real answer ("not yours / gone"), not a transport fault.
10397
- validateStatus: (status) => status >= 200 && status < 300 || status === 404
10398
- }
10399
- );
10400
- if (response.status === 404) {
10401
- throw new Error(`Session ${sessionId} was not found on this account (it may have been deleted, or the local key belongs to a session on another relay).`);
10402
- }
10403
- const raw = response.data?.session?.agentState;
10404
- if (typeof raw !== "string" || raw.length === 0) return null;
10405
- return decrypt(decodeBase64(persisted.encryptionKey), persisted.encryptionVariant, decodeBase64(raw));
10406
- }
10407
-
10408
- const SESSION_OPS_CLIENT = "session-ops";
10409
- const DEFAULT_RECENT_LIMIT = 15;
10410
- const MAX_READ_LIMIT = 100;
10411
- function mergeSessionSummaries(live, persisted, options = {}) {
10412
- const recentLimit = Math.max(0, options.recentLimit ?? DEFAULT_RECENT_LIMIT);
10413
- const seen = /* @__PURE__ */ new Set();
10414
- const summaries = [];
10415
- for (const child of live) {
10416
- const id = typeof child.happySessionId === "string" ? child.happySessionId : void 0;
10417
- if (!id || seen.has(id)) continue;
10418
- seen.add(id);
10419
- summaries.push(toSummary(id, persisted[id], livenessOf(child)));
10420
- }
10421
- const rest = Object.entries(persisted).filter(([id]) => !seen.has(id)).filter(([, entry]) => entry.metadata?.flavor !== "terminal-mirror").sort((a, b) => b[1].savedAt - a[1].savedAt).slice(0, recentLimit);
10422
- for (const [id, entry] of rest) {
10423
- summaries.push(toSummary(id, entry, { live: false }));
10424
- }
10425
- if (!options.tag) return summaries;
10426
- const wanted = options.tag;
10427
- return summaries.filter((summary) => summary.tags?.includes(wanted) === true);
10428
- }
10429
- function livenessOf(child) {
10430
- return { live: true, pid: typeof child.pid === "number" ? child.pid : void 0 };
10431
- }
10432
- function sessionLiveness(live, sessionId) {
10433
- const child = live.find((entry) => entry.happySessionId === sessionId);
10434
- return child ? livenessOf(child) : { live: false };
10435
- }
10436
- function toSummary(id, persisted, extra) {
10437
- const meta = persisted?.metadata;
10438
- return {
10439
- id,
10440
- live: extra.live,
10441
- ...extra.pid !== void 0 ? { pid: extra.pid } : {},
10442
- ...meta?.summary?.text ? { title: meta.summary.text } : {},
10443
- ...meta?.path ? { cwd: meta.path } : {},
10444
- ...meta?.flavor ? { flavor: meta.flavor } : {},
10445
- ...meta?.tags?.length ? { tags: [...meta.tags] } : {},
10446
- ...meta?.variant ? { variant: meta.variant } : {},
10447
- url: sessionWebUrl(id),
10448
- ...persisted?.savedAt !== void 0 ? { savedAt: persisted.savedAt } : {}
10449
- };
10450
- }
10451
- async function listSessions(options = {}) {
10452
- const live = await listDaemonSessions();
10453
- return mergeSessionSummaries(live, readPersistedSessions(), options);
10454
- }
10455
- function summarizeAccountSession(row, persisted, liveIds, now) {
10456
- const base = {
10457
- id: row.id,
10458
- live: liveIds.has(row.id),
10459
- url: sessionWebUrl(row.id),
10460
- decryptable: false,
10461
- active: row.active === true,
10462
- archived: typeof row.archivedAt === "number",
10463
- ...typeof row.activeAt === "number" ? { activeAt: row.activeAt } : {},
10464
- ...typeof row.updatedAt === "number" ? { updatedAt: row.updatedAt } : {},
10465
- attention: false
10466
- };
10467
- if (!persisted) return base;
10468
- const key = decodeBase64(persisted.encryptionKey);
10469
- let metadata = null;
10470
- let agentState = null;
10471
- try {
10472
- metadata = row.metadata ? decrypt(key, persisted.encryptionVariant, decodeBase64(row.metadata)) : null;
10473
- agentState = row.agentState ? decrypt(key, persisted.encryptionVariant, decodeBase64(row.agentState)) : null;
10474
- } catch {
10475
- return base;
10476
- }
10477
- if (!metadata) return base;
10478
- const meta = metadata;
10479
- const pending = pendingRequestsOf(agentState, now);
10480
- return {
10481
- ...base,
10482
- decryptable: true,
10483
- ...meta.summary?.text ? { title: meta.summary.text } : {},
10484
- ...meta.path ? { cwd: meta.path } : {},
10485
- ...meta.flavor ? { flavor: meta.flavor } : {},
10486
- ...meta.tags?.length ? { tags: [...meta.tags] } : {},
10487
- ...meta.variant ? { variant: meta.variant } : {},
10488
- ...meta.machineId ? { machineId: meta.machineId } : {},
10489
- ...persisted.savedAt !== void 0 ? { savedAt: persisted.savedAt } : {},
10490
- pending,
10491
- attention: pending.length > 0
10492
- };
10493
- }
10494
- function orderAccountSessions(summaries, options = {}) {
10495
- const oldestWait = (summary) => Math.max(0, ...(summary.pending ?? []).map((request) => request.waitingMs ?? 0));
10496
- const rank = (summary) => summary.attention ? 0 : summary.live ? 1 : 2;
10497
- let rows = summaries.filter((summary) => summary.flavor !== "terminal-mirror").slice().sort((a, b) => {
10498
- const byRank = rank(a) - rank(b);
10499
- if (byRank !== 0) return byRank;
10500
- if (a.attention && b.attention) return oldestWait(b) - oldestWait(a);
10501
- return (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
10502
- });
10503
- if (options.tag) {
10504
- const wanted = options.tag;
10505
- rows = rows.filter((summary) => summary.tags?.includes(wanted) === true);
10506
- }
10507
- const recentLimit = Math.max(0, options.recentLimit ?? DEFAULT_RECENT_LIMIT);
10508
- let idleKept = 0;
10509
- return rows.filter((summary) => rank(summary) < 2 || idleKept++ < recentLimit);
10510
- }
10511
- async function listAccountSessions(options = {}) {
10512
- const token = await bearerToken();
10513
- const response = await axios.get(`${configuration.serverUrl}/v1/sessions`, {
10514
- headers: {
10515
- "Authorization": `Bearer ${token}`,
10516
- "X-Happy-Client": `${SESSION_OPS_CLIENT}/${configuration.currentCliVersion}`
10517
- },
10518
- timeout: 15e3
10519
- });
10520
- const rows = Array.isArray(response.data?.sessions) ? response.data.sessions : [];
10521
- const persisted = readPersistedSessions();
10522
- const live = await listDaemonSessions();
10523
- const liveIds = new Set(live.map((child) => child.happySessionId).filter((id) => typeof id === "string"));
10524
- const now = Date.now();
10525
- const summaries = rows.filter((row) => typeof row?.id === "string").map((row) => summarizeAccountSession(row, persisted[row.id], liveIds, now)).filter((summary) => options.includeArchived || !summary.archived);
10526
- return orderAccountSessions(summaries, options);
10527
- }
10528
- async function bearerToken() {
10529
- const credentials = await readCredentialsForConfiguredRelay();
10530
- if (!credentials) throw new Error("CLI is not authenticated (no ~/.happy/access.key)");
10531
- return credentials.token;
10532
- }
10533
- async function readSessionTranscript(sessionId, limit) {
10534
- const persisted = readPersistedSessions()[sessionId];
10535
- if (!persisted) {
10536
- throw new Error(
10537
- `No local key for session ${sessionId} \u2014 it was not spawned by this machine's daemon (or is older than 14 days).`
10538
- );
10539
- }
10540
- const bounded = Math.max(1, Math.min(MAX_READ_LIMIT, Math.floor(limit)));
10541
- const token = await bearerToken();
10542
- const response = await axios.get(
10543
- `${configuration.serverUrl}/v3/sessions/${encodeURIComponent(sessionId)}/messages`,
10544
- {
10545
- params: { before_seq: 2147483647, limit: bounded },
10546
- headers: {
10547
- "Authorization": `Bearer ${token}`,
10548
- "X-Happy-Client": `${SESSION_OPS_CLIENT}/${configuration.currentCliVersion}`
10642
+ onTerminalHook: (body) => {
10643
+ mirrorManagerRef?.handleHookPayload(body);
10549
10644
  },
10550
- timeout: 15e3
10551
- }
10552
- );
10553
- const messages = Array.isArray(response.data?.messages) ? response.data.messages : [];
10554
- messages.reverse();
10555
- const key = decodeBase64(persisted.encryptionKey);
10556
- const live = await listDaemonSessions();
10557
- const bodies = messages.map((message) => {
10558
- if (message.content?.t !== "encrypted") return null;
10645
+ setTerminalTitle: (terminalId, title, ifAbsent) => {
10646
+ if (!apiMachineRef) return "starting";
10647
+ return apiMachineRef.setTerminalTitle(terminalId, title, ifAbsent);
10648
+ }
10649
+ });
10650
+ const daemonClaudeCredentials = resolveClaudeCredentialReadiness();
10651
+ let fileState = {
10652
+ pid: process.pid,
10653
+ httpPort: controlPort,
10654
+ controlToken,
10655
+ startTime: (/* @__PURE__ */ new Date()).toLocaleString(),
10656
+ startedWithCliVersion: packageJson.version,
10657
+ serverUrl: configuration.serverUrl,
10658
+ webappUrl: configuration.webappUrl,
10659
+ daemonLogPath: logger.logFilePath,
10660
+ claudeCredentialSource: daemonClaudeCredentials.source
10661
+ };
10662
+ writeDaemonState(fileState);
10663
+ logger.debug("[DAEMON RUN] Daemon state written");
10664
+ const bundlePath = join$1(projectPath(), "dist", "index.mjs");
10665
+ let initialBundleMtimeMs = 0;
10559
10666
  try {
10560
- return decrypt(key, persisted.encryptionVariant, decodeBase64(message.content.c));
10667
+ initialBundleMtimeMs = statSync$1(bundlePath).mtimeMs;
10561
10668
  } catch {
10562
- return null;
10669
+ logger.debug(`[DAEMON RUN] Bundle at ${bundlePath} not found; self-restart on upgrade disabled`);
10563
10670
  }
10564
- });
10565
- return {
10566
- summary: toSummary(sessionId, persisted, sessionLiveness(live, sessionId)),
10567
- messageCount: messages.length,
10568
- transcript: formatTranscript(bodies)
10569
- };
10570
- }
10571
- async function stopSession(sessionId) {
10572
- return await stopDaemonSession(sessionId);
10573
- }
10574
- async function archiveSession(sessionId) {
10575
- const token = await bearerToken();
10576
- await axios.post(
10577
- `${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}/archive`,
10578
- {},
10579
- {
10580
- headers: {
10581
- "Authorization": `Bearer ${token}`,
10582
- "X-Happy-Client": `${SESSION_OPS_CLIENT}/${configuration.currentCliVersion}`
10671
+ const initialDaemonState = {
10672
+ status: "offline",
10673
+ pid: process.pid,
10674
+ httpPort: controlPort,
10675
+ startedAt: Date.now()
10676
+ };
10677
+ const api = await ApiClient.create(credentials);
10678
+ const machine = await api.getOrCreateMachine({
10679
+ machineId,
10680
+ metadata: initialMachineMetadata,
10681
+ daemonState: initialDaemonState
10682
+ });
10683
+ logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`);
10684
+ const identityConflict = detectMachineIdentityConflict(machine.metadata, {
10685
+ host: initialMachineMetadata.host,
10686
+ platform: initialMachineMetadata.platform,
10687
+ homeDir: initialMachineMetadata.homeDir
10688
+ });
10689
+ if (identityConflict) {
10690
+ logger.warn(`[DAEMON RUN] machine identity conflict (${identityConflict.confidence}): ${describeMachineIdentityConflict(identityConflict)}`);
10691
+ }
10692
+ const apiMachine = api.machineSyncClient(machine);
10693
+ apiMachineRef = apiMachine;
10694
+ const mirrorManager = createMirrorManager({
10695
+ api,
10696
+ machineId,
10697
+ onBindingsChanged: () => apiMachine.requestTerminalListRefresh()
10698
+ });
10699
+ mirrorManagerRef = mirrorManager;
10700
+ apiMachine.setMirrorIntegration({
10701
+ resolveMirrorSessionId: (terminalId) => mirrorManager.resolveMirrorSessionId(terminalId),
10702
+ onTerminalClosed: (terminalId) => mirrorManager.onTerminalClosed(terminalId),
10703
+ onTerminalList: (terminals) => mirrorManager.observeTerminalList(terminals),
10704
+ onTerminalListTick: (terminals) => mirrorManager.reconcile(terminals),
10705
+ isMirrorInputAllowed: (terminalId) => mirrorManager.isMirrorInputAllowed(terminalId)
10706
+ });
10707
+ mirrorManager.restore().catch((error) => {
10708
+ logger.debug("[DAEMON RUN] Mirror restore failed:", error);
10709
+ });
10710
+ apiMachine.setRPCHandlers({
10711
+ spawnSession,
10712
+ resumeSession,
10713
+ restartSession,
10714
+ stopSession,
10715
+ listTrackedSessionIds: () => [...pidToTrackedSession.values()].map((session) => session.happySessionId).filter((sessionId) => typeof sessionId === "string"),
10716
+ requestShutdown: () => requestShutdown("happy-app")
10717
+ });
10718
+ const claudeAuthService = new ClaudeAuthService({
10719
+ happyHomeDir: configuration.happyHomeDir,
10720
+ happyLibDir: projectPath(),
10721
+ credentialSource: daemonClaudeCredentials.source,
10722
+ getCredentialStore: () => claudeCredentialStoreSetting,
10723
+ setCredentialStore: async (store) => {
10724
+ const settings = await readSettings();
10725
+ await writeSettings({ ...settings, claudeCredentialStore: store });
10726
+ claudeCredentialStoreSetting = store;
10583
10727
  },
10584
- timeout: 1e4
10728
+ publish: (state) => apiMachine.setClaudeAuthState(state)
10729
+ });
10730
+ claudeAuthServiceRef = claudeAuthService;
10731
+ apiMachine.setClaudeAuthHandlers({
10732
+ probe: () => claudeAuthService.probe("rpc", true),
10733
+ repair: (action) => claudeAuthService.repair(action),
10734
+ setStore: (store) => claudeAuthService.setStore(store)
10735
+ });
10736
+ claudeAuthService.start();
10737
+ apiMachine.connect();
10738
+ if (identityConflict?.confidence === "weak") {
10739
+ void apiMachine.updateMachineMetadata((current) => ({
10740
+ ...current ?? initialMachineMetadata,
10741
+ host: initialMachineMetadata.host
10742
+ })).then(() => logger.debug(`[DAEMON RUN] claimed the machine record for host ${initialMachineMetadata.host}`)).catch((error) => logger.debug("[DAEMON RUN] could not refresh the recorded host:", error));
10585
10743
  }
10586
- );
10744
+ let lastUpdateLog = "";
10745
+ const updateController = createUpdateController({
10746
+ policy: () => fetchCliUpdateState(configuration.serverUrl, packageJson.version),
10747
+ enabled: async () => ((await readSettings()).cliAutoUpdate ?? "idle") !== "off",
10748
+ idle: () => pidToTrackedSession.size === 0 && !apiMachine.hasLiveTerminals(),
10749
+ install: installCliSafely,
10750
+ publish: (state) => {
10751
+ cliUpdateStateRef = state;
10752
+ fileState = { ...fileState, cliUpdate: state };
10753
+ writeDaemonState(fileState);
10754
+ apiMachine.setCliUpdateState(state);
10755
+ const logKey = JSON.stringify([state.autoUpdate?.state, state.autoUpdate?.version, state.autoUpdate?.detail]);
10756
+ if (logKey !== lastUpdateLog) {
10757
+ lastUpdateLog = logKey;
10758
+ logger.warn(`[DAEMON RUN] CLI update: ${state.autoUpdate?.state} target=${state.autoUpdate?.version ?? "none"} detail=${state.autoUpdate?.detail ?? "none"}`);
10759
+ } else {
10760
+ logger.debug("[DAEMON RUN] CLI update policy refreshed");
10761
+ }
10762
+ }
10763
+ });
10764
+ apiMachine.setCliUpdateRetryHandler(updateController.retry);
10765
+ const refreshCliUpdate = () => updateController.refresh().catch((error) => {
10766
+ logger.debug("[DAEMON RUN] CLI update policy check failed:", error);
10767
+ });
10768
+ void refreshCliUpdate();
10769
+ const cliUpdateIntervalMs = resolveCliUpdateCheckInterval(process.env.HAPPY_CLI_UPDATE_CHECK_INTERVAL);
10770
+ if (process.env.HAPPY_CLI_UPDATE_CHECK_INTERVAL && cliUpdateIntervalMs === DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS && process.env.HAPPY_CLI_UPDATE_CHECK_INTERVAL.trim() !== String(DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS)) {
10771
+ logger.warn(`[DAEMON RUN] Ignoring invalid HAPPY_CLI_UPDATE_CHECK_INTERVAL; using ${DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS}ms`);
10772
+ }
10773
+ const cliUpdateInterval = setInterval(() => void refreshCliUpdate(), cliUpdateIntervalMs);
10774
+ const heartbeatIntervalMs = parseInt(process.env.HAPPY_DAEMON_HEARTBEAT_INTERVAL || "60000");
10775
+ const restartOnStaleVersionAndHeartbeat = setInterval(serialTask(async () => {
10776
+ try {
10777
+ if (process.env.DEBUG) {
10778
+ logger.debug(`[DAEMON RUN] Health check started at ${(/* @__PURE__ */ new Date()).toLocaleString()}`);
10779
+ }
10780
+ for (const [pid, _] of pidToTrackedSession.entries()) {
10781
+ try {
10782
+ process.kill(pid, 0);
10783
+ } catch (error) {
10784
+ logger.debug(`[DAEMON RUN] Finalizing stale session with PID ${pid} (process no longer exists)`);
10785
+ onChildExited(pid);
10786
+ }
10787
+ }
10788
+ await updateController.tick();
10789
+ await updateController.withHandover(async () => {
10790
+ let bundleReplaced = false;
10791
+ if (initialBundleMtimeMs > 0) {
10792
+ try {
10793
+ const currentMtimeMs = statSync$1(bundlePath).mtimeMs;
10794
+ bundleReplaced = currentMtimeMs !== initialBundleMtimeMs;
10795
+ } catch {
10796
+ }
10797
+ }
10798
+ if (bundleReplaced && !teamWorker?.busy && !updateController.isRunning() && pidToTrackedSession.size === 0 && !apiMachine.hasLiveTerminals()) {
10799
+ const preflight = await preflightNewBundle(bundlePath);
10800
+ if (preflight.action === "hold") {
10801
+ if (lastHandoverHold !== preflight.reason) {
10802
+ lastHandoverHold = preflight.reason;
10803
+ logger.warn(`[DAEMON RUN] holding handover: ${preflight.reason}`);
10804
+ }
10805
+ updateController.holdHandover(preflight.reason);
10806
+ return;
10807
+ }
10808
+ if (teamWorker?.busy || updateController.isRunning() || pidToTrackedSession.size > 0 || apiMachine.hasLiveTerminals()) return;
10809
+ lastHandoverHold = null;
10810
+ logger.debug("[DAEMON RUN] Daemon bundle replaced on disk and verified, handing off to new daemon");
10811
+ teamWorker?.stop();
10812
+ clearInterval(restartOnStaleVersionAndHeartbeat);
10813
+ clearInterval(cliUpdateInterval);
10814
+ claudeAuthService.stop();
10815
+ apiMachine.shutdown();
10816
+ await stopControlServer();
10817
+ await cleanupDaemonState();
10818
+ await releaseDaemonLock(daemonLockHandle);
10819
+ await stopCaffeinate();
10820
+ try {
10821
+ spawnHappyCLI(["daemon", "start"], {
10822
+ detached: true,
10823
+ stdio: "ignore"
10824
+ });
10825
+ } catch (error) {
10826
+ logger.debug("[DAEMON RUN] Failed to spawn new daemon, this is quite likely to happen during integration tests as we are cleaning out dist/ directory", error);
10827
+ }
10828
+ process.exit(0);
10829
+ }
10830
+ });
10831
+ const daemonState = await readDaemonState();
10832
+ if (daemonState && daemonState.pid !== process.pid) {
10833
+ logger.debug("[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves.");
10834
+ requestShutdown("exception", "A different daemon was started without killing us. We should kill ourselves.");
10835
+ }
10836
+ try {
10837
+ fileState = withDaemonHeartbeat(fileState, (/* @__PURE__ */ new Date()).toLocaleString());
10838
+ writeDaemonState(fileState);
10839
+ if (process.env.DEBUG) {
10840
+ logger.debug(`[DAEMON RUN] Health check completed at ${fileState.lastHeartbeat}`);
10841
+ }
10842
+ } catch (error) {
10843
+ logger.debug("[DAEMON RUN] Failed to write heartbeat", error);
10844
+ }
10845
+ } catch (error) {
10846
+ logger.debug("[DAEMON RUN] Heartbeat failed:", error);
10847
+ }
10848
+ }), heartbeatIntervalMs);
10849
+ teamWorker = createTeamWorker({
10850
+ machineId,
10851
+ token: credentials.token,
10852
+ spawn: spawnSession,
10853
+ stopAndWait: async (id) => {
10854
+ await stopSessionAndWait(id);
10855
+ const metadata = findTrackedSessionById(id)?.happySessionMetadataFromLocalWebhook ?? readPersistedSessions()[id]?.metadata;
10856
+ const remaining = findSessionWrapperPids(metadata, await findAllHappyProcesses(), { excludePid: process.pid, lockPid: readSessionLock(id)?.pid });
10857
+ if (remaining.some(isPidAlive)) throw new Error("Worker still running; cleanup retained");
10858
+ },
10859
+ live: (id) => [...pidToTrackedSession.values()].some((s) => s.happySessionId === id && isPidAlive(s.pid)),
10860
+ log: (message) => logger.debug(`[DAEMON RUN] ${message}`)
10861
+ });
10862
+ void apiMachine.updateMachineMetadata((current) => ({
10863
+ ...current ?? initialMachineMetadata,
10864
+ teamsVersion: 1
10865
+ })).catch(() => logger.debug("[DAEMON RUN] Teams capability publication will retry on reconnect"));
10866
+ const cleanupAndShutdown = async (source, errorMessage) => {
10867
+ logger.debug(`[DAEMON RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`);
10868
+ teamWorker?.stop();
10869
+ if (restartOnStaleVersionAndHeartbeat) {
10870
+ clearInterval(restartOnStaleVersionAndHeartbeat);
10871
+ logger.debug("[DAEMON RUN] Health check interval cleared");
10872
+ }
10873
+ clearInterval(cliUpdateInterval);
10874
+ await apiMachine.updateDaemonState((state) => ({
10875
+ ...state,
10876
+ status: "shutting-down",
10877
+ shutdownRequestedAt: Date.now(),
10878
+ shutdownSource: source
10879
+ }));
10880
+ await new Promise((resolve) => setTimeout(resolve, 100));
10881
+ try {
10882
+ await mirrorManager.shutdown();
10883
+ } catch (error) {
10884
+ logger.debug("[DAEMON RUN] Mirror shutdown failed:", error);
10885
+ }
10886
+ apiMachine.shutdown();
10887
+ await stopControlServer();
10888
+ await cleanupDaemonState();
10889
+ await stopCaffeinate();
10890
+ await releaseDaemonLock(daemonLockHandle);
10891
+ logger.debug("[DAEMON RUN] Cleanup completed, exiting process");
10892
+ process.exit(0);
10893
+ };
10894
+ logger.debug("[DAEMON RUN] Daemon started successfully, waiting for shutdown request");
10895
+ const shutdownRequest = await resolvesWhenShutdownRequested;
10896
+ await cleanupAndShutdown(shutdownRequest.source, shutdownRequest.errorMessage);
10897
+ } catch (error) {
10898
+ logger.debug("[DAEMON RUN][FATAL] Failed somewhere unexpectedly - exiting with code 1", error);
10899
+ process.exit(1);
10900
+ }
10587
10901
  }
10588
10902
 
10589
10903
  const PERSONAL_MEMORY_SOFT_LIMIT_CHARS = 2e3;
@@ -11019,11 +11333,12 @@ function checkPreviewPath(input, opts) {
11019
11333
  return { resolved, deniedReason: null };
11020
11334
  }
11021
11335
 
11022
- function createMcpServer(handlers, options) {
11336
+ function createMcpServer(handlers, options, sessionId) {
11023
11337
  const mcp = new McpServer({
11024
11338
  name: "Very Happy Tools",
11025
11339
  version: "1.0.0"
11026
11340
  });
11341
+ registerTeamsTools(mcp, sessionId);
11027
11342
  mcp.registerTool("change_title", {
11028
11343
  description: "Change the title of the current chat session",
11029
11344
  title: "Change Chat Title",
@@ -11125,7 +11440,7 @@ function createMcpServer(handlers, options) {
11125
11440
  isError: false
11126
11441
  };
11127
11442
  });
11128
- if (options?.assistant) {
11443
+ if (options?.assistant && !process.env.VH_TEAM_SCOPE_FILE) {
11129
11444
  registerAssistantTools(mcp);
11130
11445
  }
11131
11446
  return mcp;
@@ -11203,7 +11518,7 @@ async function startHappyServer(client, options) {
11203
11518
  }
11204
11519
  };
11205
11520
  const server = createServer(async (req, res) => {
11206
- const mcp = createMcpServer(handlers, options);
11521
+ const mcp = createMcpServer(handlers, options, client.sessionId);
11207
11522
  try {
11208
11523
  const transport = new StreamableHTTPServerTransport({
11209
11524
  sessionIdGenerator: void 0
@@ -11236,7 +11551,8 @@ async function startHappyServer(client, options) {
11236
11551
  CLIPBOARD_TOOL_NAME,
11237
11552
  PREVIEW_TOOL_NAME,
11238
11553
  REPORT_PROGRESS_TOOL_NAME,
11239
- ...options?.assistant ? ASSISTANT_TOOL_NAMES : []
11554
+ ...TEAM_TOOL_NAMES,
11555
+ ...options?.assistant && !process.env.VH_TEAM_SCOPE_FILE ? ASSISTANT_TOOL_NAMES : []
11240
11556
  ],
11241
11557
  stop: () => {
11242
11558
  logger.debug(`[happyMCP] server:stop sessionId=${client.sessionId}`);
@@ -11496,7 +11812,7 @@ function registerSideQuestionHandler(rpc, deps) {
11496
11812
  const retainMs = deps.retainMs ?? SIDE_QUESTION_RETAIN_MS;
11497
11813
  const maxRunMs = deps.maxRunMs ?? SIDE_QUESTION_MAX_RUN_MS;
11498
11814
  const run = deps.run ?? (async (input) => {
11499
- const { query } = await import('./index-B3-daYaX.mjs');
11815
+ const { query } = await import('./index-D-BgvQi5.mjs');
11500
11816
  return runSideQuestion(query, input);
11501
11817
  });
11502
11818
  const slots = /* @__PURE__ */ new Map();
@@ -12054,6 +12370,7 @@ async function runClaude(credentials, options = {}) {
12054
12370
  lifecycleState: "running",
12055
12371
  lifecycleStateSince: Date.now(),
12056
12372
  flavor: "claude",
12373
+ ...process.env.VH_TEAM_OPERATION_ID ? { teamOperationId: process.env.VH_TEAM_OPERATION_ID } : {},
12057
12374
  // Advertise composer support before the first SDK response. Otherwise
12058
12375
  // a brand-new session cannot pick a PDF until after sending once.
12059
12376
  attachmentKinds: [...CLAUDE_ATTACHMENT_KINDS],
@@ -13306,7 +13623,7 @@ async function authenticateClaude() {
13306
13623
  }
13307
13624
  }
13308
13625
 
13309
- const execAsync = promisify(exec);
13626
+ const execAsync = promisify$1(exec$1);
13310
13627
  const CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
13311
13628
  const CLIENT_SECRET = "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";
13312
13629
  const AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth";
@@ -15643,6 +15960,7 @@ async function runCodex(opts) {
15643
15960
  const forkedFromMessageId = process.env.HAPPY_FORKED_FROM_MESSAGE_ID;
15644
15961
  const { state, metadata } = createSessionMetadata({
15645
15962
  flavor: "codex",
15963
+ ...process.env.VH_TEAM_OPERATION_ID ? { teamOperationId: process.env.VH_TEAM_OPERATION_ID } : {},
15646
15964
  machineId,
15647
15965
  startedBy: opts.startedBy,
15648
15966
  sandbox: sandboxConfig,
@@ -16089,7 +16407,7 @@ async function runCodex(opts) {
16089
16407
  }
16090
16408
  });
16091
16409
  const happyServer = await startHappyServer(session);
16092
- const bridgeEntrypoint = join(projectPath(), "bin", "happy-mcp.mjs");
16410
+ const bridgeEntrypoint = join(projectPath(), "bin", "very-happy-mcp.mjs");
16093
16411
  const mcpServers = {
16094
16412
  happy: {
16095
16413
  command: process.execPath,
@@ -16447,7 +16765,7 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16447
16765
  process.exit(0);
16448
16766
  } else if (subcommand === "install-terminal-hooks") {
16449
16767
  try {
16450
- const { installTerminalHooks, parseTerminalHooksArgs, TERMINAL_HOOKS_HELP } = await import('./installTerminalHooks-Df4hzzdM.mjs');
16768
+ const { installTerminalHooks, parseTerminalHooksArgs, TERMINAL_HOOKS_HELP } = await import('./installTerminalHooks-Z1Jb6yjb.mjs');
16451
16769
  const command = parseTerminalHooksArgs(args.slice(1));
16452
16770
  if (command.action === "help") {
16453
16771
  console.log(TERMINAL_HOOKS_HELP);
@@ -16464,7 +16782,7 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16464
16782
  }
16465
16783
  } else if (subcommand === "spawn") {
16466
16784
  try {
16467
- const { handleSpawnCommand } = await import('./spawn-CORFRb9D.mjs');
16785
+ const { handleSpawnCommand } = await import('./spawn-j9KBfj4e.mjs');
16468
16786
  await handleSpawnCommand(args.slice(1));
16469
16787
  } catch (error) {
16470
16788
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
@@ -16476,7 +16794,7 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16476
16794
  return;
16477
16795
  } else if (subcommand === "sessions") {
16478
16796
  try {
16479
- const { handleSessionsCommand } = await import('./sessions-ggBePfSY.mjs');
16797
+ const { handleSessionsCommand } = await import('./sessions-Ba2oRTmw.mjs');
16480
16798
  await handleSessionsCommand(args.slice(1));
16481
16799
  } catch (error) {
16482
16800
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
@@ -16488,7 +16806,7 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16488
16806
  return;
16489
16807
  } else if (subcommand === "send") {
16490
16808
  try {
16491
- const { handleSendCommand } = await import('./send-BaLjQPZQ.mjs');
16809
+ const { handleSendCommand } = await import('./send-BevgS1mf.mjs');
16492
16810
  await handleSendCommand(args.slice(1));
16493
16811
  } catch (error) {
16494
16812
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
@@ -16594,9 +16912,9 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16594
16912
  if (geminiSubcommand === "project" && args[2] === "set" && args[3]) {
16595
16913
  const projectId = args[3];
16596
16914
  try {
16597
- const { saveGoogleCloudProjectToConfig } = await import('./config-DwMic6AC.mjs');
16598
- const { readCredentialsForConfiguredRelay: readCredentialsForConfiguredRelay2 } = await import('./types-gDOmwOKw.mjs').then(function (n) { return n.av; });
16599
- const { ApiClient: ApiClient2 } = await import('./types-gDOmwOKw.mjs').then(function (n) { return n.aw; });
16915
+ const { saveGoogleCloudProjectToConfig } = await import('./config-CZNIz3W0.mjs');
16916
+ const { readCredentialsForConfiguredRelay: readCredentialsForConfiguredRelay2 } = await import('./limits-FjbPJPsF.mjs').then(function (n) { return n.W; });
16917
+ const { ApiClient: ApiClient2 } = await import('./types-DLDkfrTC.mjs').then(function (n) { return n.X; });
16600
16918
  let userEmail = void 0;
16601
16919
  try {
16602
16920
  const credentials = await readCredentialsForConfiguredRelay2();
@@ -16627,7 +16945,7 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16627
16945
  }
16628
16946
  if (geminiSubcommand === "project" && args[2] === "get") {
16629
16947
  try {
16630
- const { readGeminiLocalConfig } = await import('./config-DwMic6AC.mjs');
16948
+ const { readGeminiLocalConfig } = await import('./config-CZNIz3W0.mjs');
16631
16949
  const config = readGeminiLocalConfig();
16632
16950
  if (config.googleCloudProject) {
16633
16951
  console.log(`Current Google Cloud Project: ${config.googleCloudProject}`);
@@ -16667,7 +16985,7 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16667
16985
  process.exit(0);
16668
16986
  }
16669
16987
  try {
16670
- const { runGemini } = await import('./runGemini-mqLjz-M7.mjs');
16988
+ const { runGemini } = await import('./runGemini-Bu49s_bN.mjs');
16671
16989
  let startedBy = void 0;
16672
16990
  for (let i = 1; i < args.length; i++) {
16673
16991
  if (args[i] === "--started-by") {
@@ -16697,7 +17015,7 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16697
17015
  }
16698
17016
  };
16699
17017
  try {
16700
- const { runAcp, resolveAcpAgentConfig } = await import('./index-Ba6Fu6MJ.mjs');
17018
+ const { runAcp, resolveAcpAgentConfig } = await import('./index-Jrk6ud3g.mjs');
16701
17019
  const parsed = parsePiRunnerArgs(args.slice(1));
16702
17020
  const resolved = resolveAcpAgentConfig(["pi", ...parsed.passthrough]);
16703
17021
  const { credentials } = await authAndSetupMachineIfNeeded();
@@ -16724,7 +17042,7 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16724
17042
  return;
16725
17043
  } else if (subcommand === "acp") {
16726
17044
  try {
16727
- const { runAcp, resolveAcpAgentConfig } = await import('./index-Ba6Fu6MJ.mjs');
17045
+ const { runAcp, resolveAcpAgentConfig } = await import('./index-Jrk6ud3g.mjs');
16728
17046
  let startedBy = void 0;
16729
17047
  let verbose = false;
16730
17048
  const acpArgs = [];
@@ -16764,7 +17082,7 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16764
17082
  return;
16765
17083
  } else if (subcommand === "openclaw") {
16766
17084
  try {
16767
- const { runOpenClaw } = await import('./runOpenClaw-Bs03yQHe.mjs');
17085
+ const { runOpenClaw } = await import('./runOpenClaw-CznAYPCh.mjs');
16768
17086
  let startedBy = void 0;
16769
17087
  let verbose = false;
16770
17088
  let gatewayUrl;
@@ -16813,9 +17131,18 @@ Conversation history is preserved on the server, but in-flight tool calls are in
16813
17131
  process.exit(1);
16814
17132
  }
16815
17133
  return;
17134
+ } else if (subcommand === "teams") {
17135
+ try {
17136
+ const { handleTeamsCommand } = await import('./teams-C0ggJTJ-.mjs');
17137
+ await handleTeamsCommand(args.slice(1));
17138
+ } catch (error) {
17139
+ console.error(error instanceof Error ? error.message : "Teams command failed");
17140
+ process.exitCode = 1;
17141
+ }
17142
+ return;
16816
17143
  } else if (subcommand === "mcp" && args.length === 1) {
16817
17144
  try {
16818
- const { handleMcpCommand } = await import('./mcp-C0oW5u-b.mjs');
17145
+ const { handleMcpCommand } = await import('./mcp-zqPJ94Bk.mjs');
16819
17146
  await handleMcpCommand();
16820
17147
  } catch (error) {
16821
17148
  process.stderr.write(`[very-happy mcp] Fatal: ${error instanceof Error ? error.message : String(error)}
@@ -17176,4 +17503,4 @@ ${chalk.bold("Examples:")}
17176
17503
  }
17177
17504
  }
17178
17505
 
17179
- export { ALLOWED_SPAWN_PERMISSION_MODES as A, startHappyServer as B, registerKillSessionHandler as C, DEFAULT_RECENT_LIMIT as D, BasePermissionHandler as E, writeSessionModeFile as F, GEMINI_MODEL_ENV as G, normalizeAcpPermissionMode as H, removeSessionModeFile as I, GEMINI_API_KEY_ENV as J, GOOGLE_API_KEY_ENV as K, BaseReasoningProcessor as L, MAX_READ_LIMIT as M, MessageBuffer as N, CHANGE_TITLE_INSTRUCTION as O, SPAWN_AGENTS as S, TitleGenerator as T, sessionWebUrl as a, sanitizeSpawnPermissionMode as b, checkIfDaemonRunningAndCleanupStaleState as c, sendUserMessage as d, listSessions as e, stopSession as f, resolvePermissionRequest as g, archiveSession as h, isValidSpawnOrigin as i, DEFAULT_GEMINI_MODEL as j, setTerminalTitleViaDaemon as k, listAccountSessions as l, registerAssistantSessionTools as m, initialMachineMetadata as n, createSessionMetadata as o, pushClipboardViaDaemon as p, query as q, readSessionTranscript as r, spawnDaemonSession as s, tmuxSupportsSessionEnv as t, setupOfflineReconnection as u, notifyDaemonSessionStarted as v, waitForSessionKey as w, StreamRelay as x, MessageQueue2 as y, hashObject as z };
17506
+ export { ALLOWED_SPAWN_PERMISSION_MODES as A, hashObject as B, startHappyServer as C, DEFAULT_RECENT_LIMIT as D, BasePermissionHandler as E, writeSessionModeFile as F, GEMINI_MODEL_ENV as G, normalizeAcpPermissionMode as H, removeSessionModeFile as I, GEMINI_API_KEY_ENV as J, GOOGLE_API_KEY_ENV as K, BaseReasoningProcessor as L, MAX_READ_LIMIT as M, MessageBuffer as N, CHANGE_TITLE_INSTRUCTION as O, SPAWN_AGENTS as S, TitleGenerator as T, sessionWebUrl as a, sanitizeSpawnPermissionMode as b, checkIfDaemonRunningAndCleanupStaleState as c, sendUserMessage as d, listSessions as e, stopSession as f, resolvePermissionRequest as g, archiveSession as h, isValidSpawnOrigin as i, DEFAULT_GEMINI_MODEL as j, setTerminalTitleViaDaemon as k, listAccountSessions as l, registerAssistantSessionTools as m, initialMachineMetadata as n, createSessionMetadata as o, pushClipboardViaDaemon as p, query as q, readSessionTranscript as r, spawnDaemonSession as s, tmuxSupportsSessionEnv as t, setupOfflineReconnection as u, notifyDaemonSessionStarted as v, waitForSessionKey as w, MessageQueue2 as x, registerKillSessionHandler as y, StreamRelay as z };