very-happy-cli 0.2.123 → 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.
- package/dist/{AcpBackend-hM-qHLN3.mjs → AcpBackend-DOQGItcS.mjs} +2 -1
- package/dist/{AcpBackend-DzIvpTxZ.cjs → AcpBackend-ssQpTLks.cjs} +30 -29
- package/dist/{AcpSessionManager-CRPZPHPa.mjs → AcpSessionManager-D4WkNsRs.mjs} +1 -1
- package/dist/{AcpSessionManager-DyU3Ikgj.cjs → AcpSessionManager-DHLc-D50.cjs} +9 -9
- package/dist/agentGuidance-Cg9ZG1pP.cjs +236 -0
- package/dist/agentGuidance-QpYwykb9.mjs +224 -0
- package/dist/codex/happyMcpStdioBridge.cjs +29 -6
- package/dist/codex/happyMcpStdioBridge.mjs +25 -2
- package/dist/{config-4RaHrVSV.cjs → config-BxHOGtEK.cjs} +11 -11
- package/dist/{config-alG86PDN.mjs → config-CZNIz3W0.mjs} +11 -11
- package/dist/{index-Beh0UZf8.cjs → index-CBj99SO6.cjs} +53 -19
- package/dist/{index-36AkXNF2.mjs → index-CrSifgJM.mjs} +2046 -1719
- package/dist/{index-vyYHKKk6.mjs → index-D-BgvQi5.mjs} +8 -8
- package/dist/{index-DWBKJ5zy.cjs → index-D_Rm9dw9.cjs} +8 -8
- package/dist/{index-5b9YhV-1.cjs → index-Do-kjc30.cjs} +2191 -1864
- package/dist/{index-Cg3Vfc00.mjs → index-Jrk6ud3g.mjs} +49 -15
- package/dist/index.cjs +5 -5
- package/dist/index.mjs +5 -5
- package/dist/{installTerminalHooks-DWCa5j7u.cjs → installTerminalHooks-CPsdeOM4.cjs} +9 -9
- package/dist/{installTerminalHooks-DqUPkh0l.mjs → installTerminalHooks-Z1Jb6yjb.mjs} +8 -8
- package/dist/lib.cjs +12 -12
- package/dist/lib.d.cts +3 -0
- package/dist/lib.d.mts +3 -0
- package/dist/lib.mjs +9 -9
- package/dist/limits-DViGHwSj.cjs +1198 -0
- package/dist/limits-FjbPJPsF.mjs +1129 -0
- package/dist/{mcp-BBya5dsZ.cjs → mcp-BbQ5FWcQ.cjs} +11 -10
- package/dist/{mcp-DZIdcsJp.mjs → mcp-zqPJ94Bk.mjs} +7 -6
- package/dist/resources-CVl7NdOB.cjs +83 -0
- package/dist/resources-D-mn7ptO.mjs +80 -0
- package/dist/{runGemini-DmWxHIwS.cjs → runGemini-BnHGYX-H.cjs} +30 -30
- package/dist/{runGemini-BQa2eF3i.mjs → runGemini-Bu49s_bN.mjs} +13 -13
- package/dist/{runOpenClaw-boyJtPBq.cjs → runOpenClaw-3Ld1eoWV.cjs} +24 -24
- package/dist/{runOpenClaw-362eg04u.mjs → runOpenClaw-CznAYPCh.mjs} +9 -9
- package/dist/{send-CT22-8Tp.mjs → send-BevgS1mf.mjs} +10 -10
- package/dist/{send-BINA__Ke.cjs → send-DCzyBxLl.cjs} +10 -10
- package/dist/{sessions-DemgtCkr.mjs → sessions-Ba2oRTmw.mjs} +10 -10
- package/dist/{sessions-DXYxV6Np.cjs → sessions-DSoaBHwA.cjs} +11 -11
- package/dist/{spawn-BtV9dU1i.cjs → spawn-CqMLY6dE.cjs} +21 -21
- package/dist/{spawn-D2egvPgO.mjs → spawn-j9KBfj4e.mjs} +21 -21
- package/dist/teams-C0ggJTJ-.mjs +164 -0
- package/dist/teams-CmU3K9vB.cjs +166 -0
- package/dist/{types-Cd_tUN3U.cjs → types-BGHnQXl5.cjs} +585 -1577
- package/dist/{types-BqW7nEN_.mjs → types-DLDkfrTC.mjs} +198 -1153
- package/package.json +7 -7
- package/dist/agentGuidance-BifkGXdE.cjs +0 -46
- package/dist/agentGuidance-C2_pkgRI.mjs +0 -37
- package/dist/limits-CwFb5S2b.mjs +0 -22
- 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 {
|
|
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,
|
|
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
|
|
8803
|
-
|
|
8804
|
-
|
|
8805
|
-
const
|
|
8806
|
-
if (
|
|
8807
|
-
return
|
|
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
|
-
|
|
8810
|
-
|
|
8811
|
-
|
|
8812
|
-
|
|
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
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
8837
|
-
|
|
8838
|
-
|
|
8839
|
-
|
|
8840
|
-
|
|
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
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
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
|
-
|
|
8904
|
-
|
|
8905
|
-
|
|
8906
|
-
|
|
8907
|
-
|
|
8908
|
-
|
|
8909
|
-
|
|
8910
|
-
|
|
8911
|
-
|
|
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
|
-
|
|
8914
|
-
|
|
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
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
8919
|
-
|
|
8920
|
-
|
|
8921
|
-
|
|
8922
|
-
|
|
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
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
|
|
8959
|
-
|
|
8960
|
-
|
|
8961
|
-
|
|
8962
|
-
|
|
8963
|
-
|
|
8964
|
-
|
|
8965
|
-
|
|
8966
|
-
|
|
8967
|
-
|
|
8968
|
-
|
|
8969
|
-
|
|
8970
|
-
|
|
8971
|
-
|
|
8972
|
-
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
|
|
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 (
|
|
8983
|
-
|
|
8981
|
+
if (message === "RPC target disconnected") {
|
|
8982
|
+
return { status: "offline", message: "the wrapper disconnected while the permission RPC was in flight" };
|
|
8984
8983
|
}
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
|
|
8988
|
-
|
|
8989
|
-
|
|
8990
|
-
|
|
8991
|
-
|
|
8992
|
-
|
|
8993
|
-
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
|
|
9000
|
-
|
|
9001
|
-
|
|
9002
|
-
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
|
|
9019
|
-
|
|
9020
|
-
|
|
9021
|
-
|
|
9022
|
-
|
|
9023
|
-
|
|
9024
|
-
}
|
|
9025
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
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
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
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
|
-
|
|
9056
|
-
const
|
|
9057
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
|
|
9065
|
-
|
|
9066
|
-
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
9082
|
-
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
9088
|
-
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9093
|
-
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
|
|
9103
|
-
|
|
9104
|
-
|
|
9105
|
-
|
|
9106
|
-
|
|
9107
|
-
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
9125
|
-
|
|
9126
|
-
|
|
9127
|
-
|
|
9128
|
-
|
|
9129
|
-
|
|
9130
|
-
|
|
9131
|
-
|
|
9132
|
-
|
|
9133
|
-
|
|
9134
|
-
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
|
|
9138
|
-
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
|
|
9155
|
-
|
|
9156
|
-
|
|
9157
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
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
|
-
|
|
9179
|
-
|
|
9180
|
-
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
9200
|
-
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
|
|
9210
|
-
|
|
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 (
|
|
9220
|
-
|
|
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
|
-
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
|
|
9226
|
-
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
|
|
9230
|
-
|
|
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
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
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
|
-
|
|
9247
|
-
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
9252
|
-
|
|
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
|
-
|
|
9256
|
-
|
|
9257
|
-
|
|
9258
|
-
|
|
9259
|
-
|
|
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
|
-
|
|
9325
|
-
|
|
9326
|
-
|
|
9327
|
-
|
|
9328
|
-
|
|
9329
|
-
|
|
9330
|
-
|
|
9331
|
-
|
|
9332
|
-
|
|
9333
|
-
|
|
9334
|
-
|
|
9335
|
-
|
|
9336
|
-
|
|
9337
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
9340
|
-
|
|
9341
|
-
|
|
9342
|
-
|
|
9343
|
-
|
|
9344
|
-
|
|
9345
|
-
|
|
9346
|
-
|
|
9347
|
-
|
|
9348
|
-
|
|
9349
|
-
|
|
9350
|
-
|
|
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
|
-
|
|
9380
|
-
|
|
9381
|
-
|
|
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
|
|
9385
|
-
|
|
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
|
-
|
|
9393
|
-
|
|
9394
|
-
|
|
9395
|
-
|
|
9396
|
-
|
|
9397
|
-
|
|
9398
|
-
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
|
|
9402
|
-
|
|
9403
|
-
|
|
9404
|
-
|
|
9405
|
-
|
|
9406
|
-
});
|
|
9407
|
-
|
|
9408
|
-
|
|
9409
|
-
|
|
9410
|
-
|
|
9411
|
-
|
|
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
|
-
|
|
9415
|
-
|
|
9416
|
-
|
|
9417
|
-
|
|
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
|
-
|
|
9432
|
-
|
|
9433
|
-
|
|
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
|
-
|
|
9438
|
-
|
|
9439
|
-
|
|
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
|
-
|
|
9465
|
-
|
|
9466
|
-
|
|
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
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
9471
|
-
|
|
9472
|
-
|
|
9473
|
-
|
|
9474
|
-
|
|
9475
|
-
|
|
9476
|
-
|
|
9477
|
-
|
|
9478
|
-
|
|
9479
|
-
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
9484
|
-
|
|
9485
|
-
|
|
9486
|
-
|
|
9487
|
-
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
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
|
-
|
|
9493
|
-
|
|
9494
|
-
|
|
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
|
-
}
|
|
9497
|
-
|
|
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
|
-
|
|
9500
|
-
|
|
9501
|
-
|
|
9502
|
-
|
|
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
|
|
9515
|
-
const
|
|
9516
|
-
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
|
|
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
|
-
|
|
9522
|
-
|
|
9523
|
-
|
|
9524
|
-
|
|
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
|
-
|
|
9528
|
-
|
|
9529
|
-
|
|
9530
|
-
|
|
9531
|
-
|
|
9532
|
-
|
|
9533
|
-
|
|
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
|
-
|
|
9536
|
-
|
|
9537
|
-
|
|
9538
|
-
|
|
9539
|
-
|
|
9540
|
-
|
|
9541
|
-
|
|
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
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
|
|
9557
|
-
|
|
9558
|
-
|
|
9559
|
-
|
|
9560
|
-
|
|
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
|
-
|
|
9566
|
-
|
|
9567
|
-
|
|
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
|
-
|
|
9601
|
-
|
|
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
|
-
|
|
9624
|
-
|
|
9625
|
-
|
|
9626
|
-
|
|
9627
|
-
|
|
9628
|
-
|
|
9629
|
-
|
|
9630
|
-
|
|
9631
|
-
}
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
|
|
9635
|
-
|
|
9636
|
-
|
|
9637
|
-
|
|
9638
|
-
|
|
9639
|
-
|
|
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
|
-
|
|
9642
|
-
|
|
9643
|
-
|
|
9644
|
-
|
|
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
|
|
9662
|
-
|
|
9663
|
-
|
|
9664
|
-
|
|
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
|
-
|
|
9667
|
-
|
|
9668
|
-
|
|
9669
|
-
|
|
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 (
|
|
9672
|
-
|
|
9983
|
+
if (options.forkedFromMessageId) {
|
|
9984
|
+
extraEnv.HAPPY_FORKED_FROM_MESSAGE_ID = options.forkedFromMessageId;
|
|
9673
9985
|
}
|
|
9674
|
-
if (
|
|
9675
|
-
|
|
9986
|
+
if (options.resumeClaudeSessionId) {
|
|
9987
|
+
extraEnv.HAPPY_FORK_CLAUDE_SESSION_ID = options.resumeClaudeSessionId;
|
|
9676
9988
|
}
|
|
9677
|
-
|
|
9678
|
-
|
|
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
|
|
9687
|
-
|
|
9688
|
-
|
|
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
|
-
|
|
9709
|
-
|
|
9710
|
-
|
|
9711
|
-
if (
|
|
9712
|
-
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
|
|
9718
|
-
|
|
9719
|
-
|
|
9720
|
-
|
|
9721
|
-
|
|
9722
|
-
|
|
9723
|
-
|
|
9724
|
-
|
|
9725
|
-
|
|
9726
|
-
|
|
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 (
|
|
9732
|
-
|
|
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
|
-
|
|
9735
|
-
|
|
9736
|
-
|
|
9737
|
-
|
|
9738
|
-
|
|
9739
|
-
|
|
9740
|
-
|
|
9741
|
-
|
|
9742
|
-
|
|
9743
|
-
|
|
9744
|
-
|
|
9745
|
-
|
|
9746
|
-
|
|
9747
|
-
|
|
9748
|
-
|
|
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
|
-
|
|
9751
|
-
|
|
9752
|
-
|
|
9753
|
-
|
|
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
|
-
|
|
9756
|
-
|
|
9757
|
-
|
|
9758
|
-
|
|
9759
|
-
|
|
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
|
|
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
|
-
|
|
9767
|
-
|
|
9768
|
-
|
|
9769
|
-
|
|
9770
|
-
|
|
9771
|
-
|
|
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
|
|
9799
|
-
|
|
9800
|
-
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
|
|
9806
|
-
|
|
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
|
-
|
|
9814
|
-
|
|
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
|
-
|
|
9820
|
-
|
|
9821
|
-
|
|
9822
|
-
|
|
9823
|
-
|
|
9824
|
-
|
|
9825
|
-
|
|
9826
|
-
|
|
9827
|
-
|
|
9828
|
-
|
|
9829
|
-
|
|
9830
|
-
|
|
9831
|
-
|
|
9832
|
-
|
|
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
|
-
|
|
9977
|
-
|
|
9978
|
-
|
|
9979
|
-
|
|
9980
|
-
|
|
9981
|
-
|
|
9982
|
-
|
|
9983
|
-
|
|
9984
|
-
}
|
|
9985
|
-
|
|
9986
|
-
|
|
9987
|
-
|
|
9988
|
-
|
|
9989
|
-
|
|
9990
|
-
|
|
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
|
-
|
|
9993
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
|
|
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
|
-
|
|
10001
|
-
|
|
10002
|
-
|
|
10003
|
-
|
|
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
|
-
|
|
10006
|
-
|
|
10227
|
+
terminateProcess(happyProcess.pid, () => {
|
|
10228
|
+
});
|
|
10007
10229
|
} catch {
|
|
10008
10230
|
}
|
|
10009
10231
|
}
|
|
10010
|
-
|
|
10011
|
-
|
|
10012
|
-
|
|
10013
|
-
|
|
10014
|
-
|
|
10015
|
-
|
|
10016
|
-
|
|
10017
|
-
|
|
10018
|
-
|
|
10019
|
-
|
|
10020
|
-
|
|
10021
|
-
|
|
10022
|
-
|
|
10023
|
-
|
|
10024
|
-
|
|
10025
|
-
|
|
10026
|
-
|
|
10027
|
-
|
|
10028
|
-
|
|
10029
|
-
|
|
10030
|
-
|
|
10031
|
-
|
|
10032
|
-
|
|
10033
|
-
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
|
|
10037
|
-
|
|
10038
|
-
|
|
10039
|
-
|
|
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
|
-
|
|
10043
|
-
|
|
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
|
-
|
|
10058
|
-
|
|
10059
|
-
|
|
10060
|
-
|
|
10061
|
-
|
|
10062
|
-
|
|
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
|
-
|
|
10088
|
-
const
|
|
10089
|
-
|
|
10090
|
-
|
|
10091
|
-
|
|
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
|
-
|
|
10159
|
-
|
|
10160
|
-
|
|
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
|
-
|
|
10176
|
-
|
|
10177
|
-
|
|
10178
|
-
|
|
10179
|
-
|
|
10180
|
-
|
|
10181
|
-
|
|
10182
|
-
|
|
10183
|
-
|
|
10184
|
-
|
|
10185
|
-
|
|
10186
|
-
|
|
10187
|
-
|
|
10188
|
-
|
|
10189
|
-
|
|
10190
|
-
|
|
10191
|
-
|
|
10192
|
-
|
|
10193
|
-
|
|
10194
|
-
|
|
10195
|
-
|
|
10196
|
-
|
|
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
|
-
|
|
10262
|
-
|
|
10263
|
-
|
|
10264
|
-
|
|
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
|
-
|
|
10350
|
-
|
|
10351
|
-
|
|
10352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10358
|
-
|
|
10359
|
-
|
|
10360
|
-
|
|
10361
|
-
|
|
10362
|
-
|
|
10363
|
-
|
|
10364
|
-
|
|
10365
|
-
|
|
10366
|
-
|
|
10367
|
-
|
|
10368
|
-
|
|
10369
|
-
|
|
10370
|
-
|
|
10371
|
-
|
|
10372
|
-
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
10377
|
-
|
|
10378
|
-
|
|
10379
|
-
|
|
10380
|
-
|
|
10381
|
-
}
|
|
10382
|
-
|
|
10383
|
-
|
|
10384
|
-
|
|
10385
|
-
|
|
10386
|
-
|
|
10387
|
-
|
|
10388
|
-
|
|
10389
|
-
|
|
10390
|
-
|
|
10391
|
-
|
|
10392
|
-
|
|
10393
|
-
|
|
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
|
-
|
|
10396
|
-
|
|
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
|
-
|
|
10551
|
-
|
|
10552
|
-
|
|
10553
|
-
|
|
10554
|
-
|
|
10555
|
-
|
|
10556
|
-
|
|
10557
|
-
|
|
10558
|
-
|
|
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
|
-
|
|
10667
|
+
initialBundleMtimeMs = statSync$1(bundlePath).mtimeMs;
|
|
10561
10668
|
} catch {
|
|
10562
|
-
|
|
10669
|
+
logger.debug(`[DAEMON RUN] Bundle at ${bundlePath} not found; self-restart on upgrade disabled`);
|
|
10563
10670
|
}
|
|
10564
|
-
|
|
10565
|
-
|
|
10566
|
-
|
|
10567
|
-
|
|
10568
|
-
|
|
10569
|
-
|
|
10570
|
-
|
|
10571
|
-
|
|
10572
|
-
|
|
10573
|
-
|
|
10574
|
-
|
|
10575
|
-
|
|
10576
|
-
|
|
10577
|
-
|
|
10578
|
-
|
|
10579
|
-
|
|
10580
|
-
|
|
10581
|
-
|
|
10582
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
16598
|
-
const { readCredentialsForConfiguredRelay: readCredentialsForConfiguredRelay2 } = await import('./
|
|
16599
|
-
const { ApiClient: ApiClient2 } = await import('./types-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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,
|
|
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 };
|