@kmmao/happy-agent 0.3.10 → 0.4.0
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/index.cjs +947 -7
- package/dist/index.d.cts +136 -6
- package/dist/index.d.mts +136 -6
- package/dist/index.mjs +946 -9
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -18,7 +18,7 @@ var path = require('path');
|
|
|
18
18
|
var fs = require('fs');
|
|
19
19
|
var os = require('os');
|
|
20
20
|
|
|
21
|
-
var version = "0.
|
|
21
|
+
var version = "0.4.0";
|
|
22
22
|
|
|
23
23
|
function loadConfig() {
|
|
24
24
|
const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
|
|
@@ -789,6 +789,7 @@ function createRpcHandlerManager(config) {
|
|
|
789
789
|
}
|
|
790
790
|
|
|
791
791
|
const execAsync = util.promisify(child_process.exec);
|
|
792
|
+
const execFileAsync$1 = util.promisify(child_process.execFile);
|
|
792
793
|
const UPLOAD_TEMP_DIR = path.join(os.tmpdir(), "happy", "uploads");
|
|
793
794
|
const MAX_WRITE_SIZE = 10 * 1024 * 1024;
|
|
794
795
|
function validatePath(targetPath, workingDirectory, additionalAllowedDirs) {
|
|
@@ -1048,6 +1049,367 @@ function registerAgentHandlers(rpcHandlerManager, workingDirectory, sessionId) {
|
|
|
1048
1049
|
await promises.mkdir(uploadDir, { recursive: true });
|
|
1049
1050
|
return { success: true, path: uploadDir };
|
|
1050
1051
|
});
|
|
1052
|
+
rpcHandlerManager.registerHandler("getDirectoryTree", async (data) => {
|
|
1053
|
+
logger.debug("Get directory tree request:", data.path, "maxDepth:", data.maxDepth);
|
|
1054
|
+
const validation = validatePath(data.path, workingDirectory);
|
|
1055
|
+
if (!validation.valid) {
|
|
1056
|
+
return { success: false, error: validation.error };
|
|
1057
|
+
}
|
|
1058
|
+
async function buildTree(dirPath, name, currentDepth) {
|
|
1059
|
+
try {
|
|
1060
|
+
const stats = await promises.stat(dirPath);
|
|
1061
|
+
const node = {
|
|
1062
|
+
name,
|
|
1063
|
+
path: dirPath,
|
|
1064
|
+
type: stats.isDirectory() ? "directory" : "file",
|
|
1065
|
+
size: stats.size,
|
|
1066
|
+
modified: stats.mtime.getTime()
|
|
1067
|
+
};
|
|
1068
|
+
if (stats.isDirectory() && currentDepth < data.maxDepth) {
|
|
1069
|
+
const entries = await promises.readdir(dirPath, { withFileTypes: true });
|
|
1070
|
+
const children = [];
|
|
1071
|
+
await Promise.all(
|
|
1072
|
+
entries.map(async (entry) => {
|
|
1073
|
+
if (entry.isSymbolicLink()) return;
|
|
1074
|
+
const childPath = path.join(dirPath, entry.name);
|
|
1075
|
+
const childNode = await buildTree(childPath, entry.name, currentDepth + 1);
|
|
1076
|
+
if (childNode) children.push(childNode);
|
|
1077
|
+
})
|
|
1078
|
+
);
|
|
1079
|
+
children.sort((a, b) => {
|
|
1080
|
+
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
1081
|
+
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
1082
|
+
return a.name.localeCompare(b.name);
|
|
1083
|
+
});
|
|
1084
|
+
node.children = children;
|
|
1085
|
+
}
|
|
1086
|
+
return node;
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
logger.debug(`Failed to process ${dirPath}:`, error instanceof Error ? error.message : String(error));
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
if (data.maxDepth < 0) {
|
|
1094
|
+
return { success: false, error: "maxDepth must be non-negative" };
|
|
1095
|
+
}
|
|
1096
|
+
const baseName = data.path === "/" ? "/" : data.path.split("/").pop() || data.path;
|
|
1097
|
+
const tree = await buildTree(data.path, baseName, 0);
|
|
1098
|
+
if (!tree) {
|
|
1099
|
+
return { success: false, error: "Failed to access the specified path" };
|
|
1100
|
+
}
|
|
1101
|
+
return { success: true, tree };
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
logger.debug("Failed to get directory tree:", error);
|
|
1104
|
+
return {
|
|
1105
|
+
success: false,
|
|
1106
|
+
error: error instanceof Error ? error.message : "Failed to get directory tree"
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
rpcHandlerManager.registerHandler(
|
|
1111
|
+
"ripgrep",
|
|
1112
|
+
async (data) => {
|
|
1113
|
+
const cwd = data.cwd || workingDirectory;
|
|
1114
|
+
if (data.cwd) {
|
|
1115
|
+
const validation = validatePath(data.cwd, workingDirectory);
|
|
1116
|
+
if (!validation.valid) {
|
|
1117
|
+
return { success: false, error: validation.error };
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
try {
|
|
1121
|
+
const { stdout, stderr } = await execFileAsync$1("rg", data.args, {
|
|
1122
|
+
cwd,
|
|
1123
|
+
timeout: 3e4,
|
|
1124
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1125
|
+
});
|
|
1126
|
+
return { success: true, stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "", exitCode: 0 };
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
const e = error;
|
|
1129
|
+
if (e.code === "ENOENT") {
|
|
1130
|
+
return { success: false, error: "ripgrep (rg) is not installed on this machine" };
|
|
1131
|
+
}
|
|
1132
|
+
if (e.code === "1" || e.status === 1) {
|
|
1133
|
+
return { success: true, stdout: e.stdout?.toString() ?? "", stderr: e.stderr?.toString() ?? "", exitCode: 1 };
|
|
1134
|
+
}
|
|
1135
|
+
return {
|
|
1136
|
+
success: false,
|
|
1137
|
+
stdout: e.stdout?.toString() ?? "",
|
|
1138
|
+
stderr: e.stderr?.toString() ?? "",
|
|
1139
|
+
exitCode: typeof e.code === "number" ? e.code : 1,
|
|
1140
|
+
error: e.message ?? "ripgrep failed"
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
);
|
|
1145
|
+
rpcHandlerManager.registerHandler(
|
|
1146
|
+
"difftastic",
|
|
1147
|
+
async (data) => {
|
|
1148
|
+
const cwd = data.cwd || workingDirectory;
|
|
1149
|
+
if (data.cwd) {
|
|
1150
|
+
const validation = validatePath(data.cwd, workingDirectory);
|
|
1151
|
+
if (!validation.valid) {
|
|
1152
|
+
return { success: false, error: validation.error };
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const { stdout, stderr } = await execFileAsync$1("difft", data.args, {
|
|
1157
|
+
cwd,
|
|
1158
|
+
timeout: 3e4,
|
|
1159
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1160
|
+
});
|
|
1161
|
+
return { success: true, stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "", exitCode: 0 };
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
const e = error;
|
|
1164
|
+
if (e.code === "ENOENT") {
|
|
1165
|
+
return { success: false, error: "difftastic (difft) is not installed on this machine" };
|
|
1166
|
+
}
|
|
1167
|
+
return {
|
|
1168
|
+
success: false,
|
|
1169
|
+
stdout: e.stdout?.toString() ?? "",
|
|
1170
|
+
stderr: e.stderr?.toString() ?? "",
|
|
1171
|
+
exitCode: typeof e.code === "number" ? e.code : 1,
|
|
1172
|
+
error: e.message ?? "difftastic failed"
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
);
|
|
1177
|
+
rpcHandlerManager.registerHandler("listRemoteGitRepos", async (data) => {
|
|
1178
|
+
const page = data.page ?? 1;
|
|
1179
|
+
const perPage = data.perPage ?? 30;
|
|
1180
|
+
const query = data.query?.trim() || "";
|
|
1181
|
+
logger.debug("listRemoteGitRepos request:", { provider: data.provider, host: data.host, page, perPage, query });
|
|
1182
|
+
if (!data.apiToken) return { success: false, error: "API token is required" };
|
|
1183
|
+
if (!data.host?.trim()) return { success: false, error: "Host is required" };
|
|
1184
|
+
try {
|
|
1185
|
+
const baseUrl = buildGitApiBase(data.provider, data.host);
|
|
1186
|
+
const headers = buildGitApiHeaders(data.apiToken);
|
|
1187
|
+
let repos;
|
|
1188
|
+
let hasMore;
|
|
1189
|
+
if (data.provider === "github") {
|
|
1190
|
+
const listUrl = `${baseUrl}/user/repos?per_page=${perPage}&page=${page}&sort=updated&affiliation=owner,collaborator,organization_member`;
|
|
1191
|
+
const response = await fetch(listUrl, { headers });
|
|
1192
|
+
if (!response.ok) {
|
|
1193
|
+
const errorText = await response.text().catch(() => "");
|
|
1194
|
+
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
1195
|
+
}
|
|
1196
|
+
const items = await response.json();
|
|
1197
|
+
repos = normalizeRemoteRepoEntries(items);
|
|
1198
|
+
hasMore = items.length >= perPage;
|
|
1199
|
+
} else {
|
|
1200
|
+
const searchParams = new URLSearchParams({ sort: "updated", order: "desc", limit: String(perPage), page: String(page) });
|
|
1201
|
+
if (query) searchParams.set("q", query);
|
|
1202
|
+
const searchUrl = `${baseUrl}/repos/search?${searchParams.toString()}`;
|
|
1203
|
+
const response = await fetch(searchUrl, { headers });
|
|
1204
|
+
if (!response.ok) {
|
|
1205
|
+
const errorText = await response.text().catch(() => "");
|
|
1206
|
+
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
1207
|
+
}
|
|
1208
|
+
const body = await response.json();
|
|
1209
|
+
const items = Array.isArray(body) ? body : body.data || [];
|
|
1210
|
+
repos = normalizeRemoteRepoEntries(items);
|
|
1211
|
+
hasMore = items.length >= perPage;
|
|
1212
|
+
}
|
|
1213
|
+
return { success: true, repos, hasMore };
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
logger.debug("listRemoteGitRepos failed:", error);
|
|
1216
|
+
return { success: false, error: error instanceof Error ? error.message : "Failed to load remote repositories" };
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
rpcHandlerManager.registerHandler(
|
|
1220
|
+
"cloneGitRepo",
|
|
1221
|
+
async (data) => {
|
|
1222
|
+
logger.debug("cloneGitRepo request:", { repoUrl: data.repoUrl, targetDirectory: data.targetDirectory });
|
|
1223
|
+
if (!data.repoUrl?.trim()) return { success: false, error: "Repository URL is required" };
|
|
1224
|
+
if (!data.targetDirectory?.trim()) return { success: false, error: "Target directory is required" };
|
|
1225
|
+
if (!data.targetDirectory.startsWith("/")) return { success: false, error: "Target directory must be an absolute path" };
|
|
1226
|
+
const coords = parseCloneCoordinates(data.repoUrl);
|
|
1227
|
+
if (!coords) return { success: false, error: "Invalid repository URL" };
|
|
1228
|
+
const repoPath = path.join(path.resolve(data.targetDirectory), coords.repo);
|
|
1229
|
+
try {
|
|
1230
|
+
await promises.mkdir(path.resolve(data.targetDirectory), { recursive: true });
|
|
1231
|
+
try {
|
|
1232
|
+
const existing = await promises.stat(repoPath);
|
|
1233
|
+
if (existing.isDirectory()) {
|
|
1234
|
+
return { success: false, error: `Destination already exists: ${repoPath}` };
|
|
1235
|
+
}
|
|
1236
|
+
} catch {
|
|
1237
|
+
}
|
|
1238
|
+
const cloneUrl = resolveCloneUrl(data.repoUrl, data.host);
|
|
1239
|
+
const options = {
|
|
1240
|
+
cwd: path.resolve(data.targetDirectory),
|
|
1241
|
+
timeout: 3e5,
|
|
1242
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1243
|
+
env: buildCloneAuthEnv(data.provider, data.apiToken)
|
|
1244
|
+
};
|
|
1245
|
+
const { stdout, stderr } = await execFileAsync$1("git", ["clone", cloneUrl, repoPath], options);
|
|
1246
|
+
return { success: true, repoPath, stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "" };
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
const e = error;
|
|
1249
|
+
logger.debug("cloneGitRepo failed:", { message: e.message, stderr: e.stderr });
|
|
1250
|
+
return { success: false, repoPath, stdout: e.stdout || "", stderr: e.stderr || "", error: e.message || "Failed to clone repository" };
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
);
|
|
1254
|
+
rpcHandlerManager.registerHandler("createRemoteWebhook", async (data) => {
|
|
1255
|
+
logger.debug("createRemoteWebhook request:", { provider: data.provider, repoUrl: data.repoUrl });
|
|
1256
|
+
try {
|
|
1257
|
+
const parsed = parseRepoOwnerFromUrl(data.repoUrl);
|
|
1258
|
+
if (!parsed) return { success: false, error: "Invalid repo URL" };
|
|
1259
|
+
const { origin, owner, repo } = parsed;
|
|
1260
|
+
const isGitHubCom = origin === "https://github.com" || origin === "http://github.com";
|
|
1261
|
+
const baseUrl = data.provider === "github" && isGitHubCom ? "https://api.github.com" : data.provider === "github" ? `${origin}/api/v3` : `${origin}/api/v1`;
|
|
1262
|
+
const headers = {
|
|
1263
|
+
Authorization: `token ${data.apiToken}`,
|
|
1264
|
+
"Content-Type": "application/json",
|
|
1265
|
+
Accept: "application/json"
|
|
1266
|
+
};
|
|
1267
|
+
const hooksEndpoint = `${baseUrl}/repos/${owner}/${repo}/hooks`;
|
|
1268
|
+
const giteaEvents = [
|
|
1269
|
+
"issues",
|
|
1270
|
+
"issue_assign",
|
|
1271
|
+
"issue_label",
|
|
1272
|
+
"issue_milestone",
|
|
1273
|
+
"issue_comment",
|
|
1274
|
+
"pull_request",
|
|
1275
|
+
"pull_request_assign",
|
|
1276
|
+
"pull_request_label",
|
|
1277
|
+
"pull_request_milestone",
|
|
1278
|
+
"pull_request_comment",
|
|
1279
|
+
"pull_request_review",
|
|
1280
|
+
"pull_request_sync"
|
|
1281
|
+
];
|
|
1282
|
+
const events = data.provider === "gitea" ? giteaEvents : data.events;
|
|
1283
|
+
const listRes = await fetch(hooksEndpoint, { headers });
|
|
1284
|
+
if (listRes.ok) {
|
|
1285
|
+
const hooks = await listRes.json();
|
|
1286
|
+
const existing = hooks.find((h) => h.config.url === data.webhookUrl);
|
|
1287
|
+
if (existing) {
|
|
1288
|
+
const patchRes = await fetch(`${hooksEndpoint}/${existing.id}`, {
|
|
1289
|
+
method: "PATCH",
|
|
1290
|
+
headers,
|
|
1291
|
+
body: JSON.stringify({ config: { url: data.webhookUrl, content_type: "json", secret: data.webhookSecret }, events, active: true })
|
|
1292
|
+
});
|
|
1293
|
+
if (!patchRes.ok) {
|
|
1294
|
+
const errText = await patchRes.text().catch(() => "");
|
|
1295
|
+
return { success: false, error: `PATCH ${patchRes.status}: ${errText}` };
|
|
1296
|
+
}
|
|
1297
|
+
return { success: true, created: false, webhookId: existing.id };
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const createBody = {
|
|
1301
|
+
name: "web",
|
|
1302
|
+
config: { url: data.webhookUrl, content_type: "json", secret: data.webhookSecret },
|
|
1303
|
+
events,
|
|
1304
|
+
active: true
|
|
1305
|
+
};
|
|
1306
|
+
if (data.provider === "gitea") createBody.type = "gitea";
|
|
1307
|
+
const createRes = await fetch(hooksEndpoint, { method: "POST", headers, body: JSON.stringify(createBody) });
|
|
1308
|
+
if (createRes.ok || createRes.status === 201) {
|
|
1309
|
+
const body = await createRes.json().catch(() => ({}));
|
|
1310
|
+
return { success: true, created: true, webhookId: body.id };
|
|
1311
|
+
}
|
|
1312
|
+
const errBody = await createRes.text().catch(() => "");
|
|
1313
|
+
return { success: false, error: `HTTP ${createRes.status}: ${errBody}` };
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
logger.debug("createRemoteWebhook failed:", error);
|
|
1316
|
+
return { success: false, error: error instanceof Error ? error.message : "Failed to create webhook" };
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
rpcHandlerManager.registerHandler("deleteRemoteWebhook", async (data) => {
|
|
1320
|
+
logger.debug("deleteRemoteWebhook request:", { provider: data.provider, repoUrl: data.repoUrl });
|
|
1321
|
+
try {
|
|
1322
|
+
const parsed = parseRepoOwnerFromUrl(data.repoUrl);
|
|
1323
|
+
if (!parsed) return { success: false, error: "Invalid repo URL" };
|
|
1324
|
+
const { origin, owner, repo } = parsed;
|
|
1325
|
+
const isGitHubCom = origin === "https://github.com" || origin === "http://github.com";
|
|
1326
|
+
const baseUrl = data.provider === "github" && isGitHubCom ? "https://api.github.com" : data.provider === "github" ? `${origin}/api/v3` : `${origin}/api/v1`;
|
|
1327
|
+
const headers = { Authorization: `token ${data.apiToken}`, Accept: "application/json" };
|
|
1328
|
+
const hooksEndpoint = `${baseUrl}/repos/${owner}/${repo}/hooks`;
|
|
1329
|
+
const listRes = await fetch(hooksEndpoint, { headers });
|
|
1330
|
+
if (!listRes.ok) return { success: false, error: `List hooks failed: HTTP ${listRes.status}` };
|
|
1331
|
+
const hooks = await listRes.json();
|
|
1332
|
+
const existing = hooks.find((h) => h.config.url === data.webhookUrl);
|
|
1333
|
+
if (!existing) return { success: true, deleted: false };
|
|
1334
|
+
const deleteRes = await fetch(`${hooksEndpoint}/${existing.id}`, { method: "DELETE", headers });
|
|
1335
|
+
if (deleteRes.ok || deleteRes.status === 204) return { success: true, deleted: true };
|
|
1336
|
+
const errBody = await deleteRes.text().catch(() => "");
|
|
1337
|
+
return { success: false, error: `DELETE ${deleteRes.status}: ${errBody}` };
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
logger.debug("deleteRemoteWebhook failed:", error);
|
|
1340
|
+
return { success: false, error: error instanceof Error ? error.message : "Failed to delete webhook" };
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
function parseHostEntry(host) {
|
|
1345
|
+
const match = host.match(/^(https?):\/\/(.+)/);
|
|
1346
|
+
if (match) return { bare: match[2].replace(/\/$/, ""), protocol: match[1] };
|
|
1347
|
+
return { bare: host.replace(/\/$/, ""), protocol: null };
|
|
1348
|
+
}
|
|
1349
|
+
function buildGitApiBase(provider, host) {
|
|
1350
|
+
const { bare, protocol } = parseHostEntry(host);
|
|
1351
|
+
const normalizedProtocol = protocol ?? "https";
|
|
1352
|
+
const origin = `${normalizedProtocol}://${bare}`;
|
|
1353
|
+
const isGitHubCom = bare === "github.com" || bare === "www.github.com";
|
|
1354
|
+
if (provider === "github") {
|
|
1355
|
+
return isGitHubCom ? "https://api.github.com" : `${origin}/api/v3`;
|
|
1356
|
+
}
|
|
1357
|
+
return `${origin}/api/v1`;
|
|
1358
|
+
}
|
|
1359
|
+
function buildGitApiHeaders(apiToken) {
|
|
1360
|
+
return {
|
|
1361
|
+
Authorization: `token ${apiToken}`,
|
|
1362
|
+
Accept: "application/json",
|
|
1363
|
+
"Content-Type": "application/json"
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
function normalizeRemoteRepoEntries(pageRepos) {
|
|
1367
|
+
return pageRepos.filter((repo) => !!repo.name).map((repo) => {
|
|
1368
|
+
const owner = repo.owner?.login || repo.owner?.username || "";
|
|
1369
|
+
const fullName = repo.full_name || (owner ? `${owner}/${repo.name}` : repo.name || "");
|
|
1370
|
+
const htmlUrl = repo.html_url || "";
|
|
1371
|
+
const cloneUrl = repo.clone_url || (htmlUrl ? `${htmlUrl}.git` : "");
|
|
1372
|
+
return { name: repo.name || fullName, fullName, cloneUrl, htmlUrl, private: !!repo.private, updatedAt: repo.updated_at ? Date.parse(repo.updated_at) : null };
|
|
1373
|
+
}).filter((repo) => !!repo.cloneUrl);
|
|
1374
|
+
}
|
|
1375
|
+
function parseCloneCoordinates(repoUrl) {
|
|
1376
|
+
const trimmed = repoUrl.trim();
|
|
1377
|
+
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
1378
|
+
if (sshMatch) return { host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] };
|
|
1379
|
+
const sshUrlMatch = trimmed.match(/^ssh:\/\/[^@]+@([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
1380
|
+
if (sshUrlMatch) return { host: sshUrlMatch[1], owner: sshUrlMatch[2], repo: sshUrlMatch[3] };
|
|
1381
|
+
const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
1382
|
+
if (httpsMatch) return { host: httpsMatch[1], owner: httpsMatch[2], repo: httpsMatch[3] };
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
function resolveCloneUrl(repoUrl, configuredHost) {
|
|
1386
|
+
if (/^https?:\/\//.test(repoUrl)) return repoUrl;
|
|
1387
|
+
const coords = parseCloneCoordinates(repoUrl);
|
|
1388
|
+
if (!coords) return repoUrl;
|
|
1389
|
+
const hostEntry = configuredHost ? parseHostEntry(configuredHost) : null;
|
|
1390
|
+
const protocol = hostEntry?.protocol ?? "https";
|
|
1391
|
+
const bareHost = hostEntry?.bare ?? coords.host;
|
|
1392
|
+
return `${protocol}://${bareHost}/${coords.owner}/${coords.repo}.git`;
|
|
1393
|
+
}
|
|
1394
|
+
function buildCloneAuthEnv(provider, apiToken) {
|
|
1395
|
+
if (!apiToken) return void 0;
|
|
1396
|
+
const username = provider === "github" ? "x-access-token" : "oauth2";
|
|
1397
|
+
const authValue = Buffer.from(`${username}:${apiToken}`).toString("base64");
|
|
1398
|
+
return {
|
|
1399
|
+
...process.env,
|
|
1400
|
+
GIT_CONFIG_COUNT: "1",
|
|
1401
|
+
GIT_CONFIG_KEY_0: "http.extraHeader",
|
|
1402
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${authValue}`
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
function parseRepoOwnerFromUrl(repoUrl) {
|
|
1406
|
+
try {
|
|
1407
|
+
const url = new URL(repoUrl);
|
|
1408
|
+
const parts = url.pathname.replace(/^\//, "").replace(/\.git$/, "").split("/");
|
|
1409
|
+
if (parts.length >= 2) return { origin: url.origin, owner: parts[0], repo: parts[1] };
|
|
1410
|
+
} catch {
|
|
1411
|
+
}
|
|
1412
|
+
return null;
|
|
1051
1413
|
}
|
|
1052
1414
|
|
|
1053
1415
|
class SessionClient extends node_events.EventEmitter {
|
|
@@ -1475,6 +1837,340 @@ function isNotFound(err) {
|
|
|
1475
1837
|
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1476
1838
|
}
|
|
1477
1839
|
|
|
1840
|
+
const pidToSession = /* @__PURE__ */ new Map();
|
|
1841
|
+
function trackSession(session) {
|
|
1842
|
+
pidToSession.set(session.pid, session);
|
|
1843
|
+
}
|
|
1844
|
+
function untrackSession(pid) {
|
|
1845
|
+
const session = pidToSession.get(pid);
|
|
1846
|
+
pidToSession.delete(pid);
|
|
1847
|
+
return session;
|
|
1848
|
+
}
|
|
1849
|
+
function getAllTrackedSessions() {
|
|
1850
|
+
return [...pidToSession.values()];
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
const execFileAsync = util.promisify(child_process.execFile);
|
|
1854
|
+
const SERVER_INTERNAL_SECRETS = /* @__PURE__ */ new Set([
|
|
1855
|
+
"DATABASE_URL",
|
|
1856
|
+
"REDIS_URL",
|
|
1857
|
+
"JWT_SECRET",
|
|
1858
|
+
"ENCRYPTION_KEY",
|
|
1859
|
+
"GITHUB_CLIENT_SECRET",
|
|
1860
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
1861
|
+
"AWS_ACCESS_KEY_ID",
|
|
1862
|
+
"AWS_SESSION_TOKEN",
|
|
1863
|
+
"STRIPE_SECRET_KEY",
|
|
1864
|
+
"SENDGRID_API_KEY",
|
|
1865
|
+
"S3_ACCESS_KEY",
|
|
1866
|
+
"S3_SECRET_KEY"
|
|
1867
|
+
]);
|
|
1868
|
+
function buildSpawnEnv(extra) {
|
|
1869
|
+
const base = {};
|
|
1870
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
1871
|
+
if (!SERVER_INTERNAL_SECRETS.has(key) && key !== "CLAUDECODE") {
|
|
1872
|
+
base[key] = value;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
if (extra) {
|
|
1876
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
1877
|
+
if (value !== void 0) {
|
|
1878
|
+
base[key] = value;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
return base;
|
|
1883
|
+
}
|
|
1884
|
+
let happyBinaryPath;
|
|
1885
|
+
async function findHappyBinary() {
|
|
1886
|
+
if (happyBinaryPath !== void 0) return happyBinaryPath;
|
|
1887
|
+
try {
|
|
1888
|
+
const { stdout } = await execFileAsync("which", ["happy"]);
|
|
1889
|
+
happyBinaryPath = stdout.trim() || null;
|
|
1890
|
+
} catch {
|
|
1891
|
+
happyBinaryPath = null;
|
|
1892
|
+
}
|
|
1893
|
+
return happyBinaryPath;
|
|
1894
|
+
}
|
|
1895
|
+
async function spawnSession(options) {
|
|
1896
|
+
const {
|
|
1897
|
+
directory,
|
|
1898
|
+
agent = "claude",
|
|
1899
|
+
approvedNewDirectoryCreation = false,
|
|
1900
|
+
happySessionId,
|
|
1901
|
+
automationContext,
|
|
1902
|
+
environmentVariables
|
|
1903
|
+
} = options;
|
|
1904
|
+
const happyPath = await findHappyBinary();
|
|
1905
|
+
if (!happyPath) {
|
|
1906
|
+
return {
|
|
1907
|
+
type: "error",
|
|
1908
|
+
errorMessage: "happy CLI not found. Install with: npm install -g @kmmao/happy-coder"
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
try {
|
|
1912
|
+
const dirStat = await promises.stat(directory);
|
|
1913
|
+
if (!dirStat.isDirectory()) {
|
|
1914
|
+
return { type: "error", errorMessage: `Path exists but is not a directory: ${directory}` };
|
|
1915
|
+
}
|
|
1916
|
+
} catch {
|
|
1917
|
+
if (!approvedNewDirectoryCreation) {
|
|
1918
|
+
return { type: "requestToApproveDirectoryCreation", directory };
|
|
1919
|
+
}
|
|
1920
|
+
try {
|
|
1921
|
+
await promises.mkdir(directory, { recursive: true });
|
|
1922
|
+
logger.debug(`[SPAWN] Created directory: ${directory}`);
|
|
1923
|
+
} catch (mkdirError) {
|
|
1924
|
+
return {
|
|
1925
|
+
type: "error",
|
|
1926
|
+
errorMessage: `Failed to create directory: ${mkdirError instanceof Error ? mkdirError.message : String(mkdirError)}`
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
const args = [
|
|
1931
|
+
agent,
|
|
1932
|
+
"--happy-starting-mode",
|
|
1933
|
+
"remote",
|
|
1934
|
+
"--started-by",
|
|
1935
|
+
"daemon"
|
|
1936
|
+
];
|
|
1937
|
+
if (happySessionId) {
|
|
1938
|
+
args.push("--happy-session-id", happySessionId);
|
|
1939
|
+
}
|
|
1940
|
+
const spawnEnv = buildSpawnEnv(environmentVariables);
|
|
1941
|
+
logger.debug(`[SPAWN] ${happyPath} ${args.join(" ")} in ${directory}`);
|
|
1942
|
+
let happyProcess;
|
|
1943
|
+
try {
|
|
1944
|
+
happyProcess = child_process.spawn(happyPath, args, {
|
|
1945
|
+
cwd: directory,
|
|
1946
|
+
detached: true,
|
|
1947
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1948
|
+
env: spawnEnv
|
|
1949
|
+
});
|
|
1950
|
+
} catch (spawnError) {
|
|
1951
|
+
return {
|
|
1952
|
+
type: "error",
|
|
1953
|
+
errorMessage: `Failed to spawn: ${spawnError instanceof Error ? spawnError.message : String(spawnError)}`
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
const pid = happyProcess.pid;
|
|
1957
|
+
if (!pid) {
|
|
1958
|
+
return { type: "error", errorMessage: "Spawned process has no PID" };
|
|
1959
|
+
}
|
|
1960
|
+
const tracked = {
|
|
1961
|
+
pid,
|
|
1962
|
+
directory,
|
|
1963
|
+
startedAt: Date.now(),
|
|
1964
|
+
childProcess: happyProcess,
|
|
1965
|
+
lastActivityAt: Date.now(),
|
|
1966
|
+
automationContext
|
|
1967
|
+
};
|
|
1968
|
+
trackSession(tracked);
|
|
1969
|
+
happyProcess.stdout?.on("data", () => {
|
|
1970
|
+
tracked.lastActivityAt = Date.now();
|
|
1971
|
+
});
|
|
1972
|
+
happyProcess.stderr?.on("data", () => {
|
|
1973
|
+
tracked.lastActivityAt = Date.now();
|
|
1974
|
+
});
|
|
1975
|
+
happyProcess.on("exit", (code, signal) => {
|
|
1976
|
+
logger.debug(`[SPAWN] Process ${pid} exited: code=${code} signal=${signal}`);
|
|
1977
|
+
const session = untrackSession(pid);
|
|
1978
|
+
if (session) {
|
|
1979
|
+
session.terminationReason = signal ? `signal:${signal}` : code !== 0 ? `exit:${code}` : "completed";
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
happyProcess.unref();
|
|
1983
|
+
logger.debug(`[SPAWN] Spawned PID ${pid} in ${directory}`);
|
|
1984
|
+
return { type: "success", pid, directory };
|
|
1985
|
+
}
|
|
1986
|
+
function stopSession(pid) {
|
|
1987
|
+
const tracked = untrackSession(pid);
|
|
1988
|
+
if (!tracked) {
|
|
1989
|
+
try {
|
|
1990
|
+
process.kill(pid, "SIGTERM");
|
|
1991
|
+
return { stopped: true };
|
|
1992
|
+
} catch {
|
|
1993
|
+
return { stopped: false, error: `No tracked session with PID ${pid}` };
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
try {
|
|
1997
|
+
if (tracked.childProcess && !tracked.childProcess.killed) {
|
|
1998
|
+
tracked.childProcess.kill("SIGTERM");
|
|
1999
|
+
} else {
|
|
2000
|
+
process.kill(pid, "SIGTERM");
|
|
2001
|
+
}
|
|
2002
|
+
tracked.terminationReason = "user_stop";
|
|
2003
|
+
logger.debug(`[SPAWN] Stopped session PID ${pid}`);
|
|
2004
|
+
return { stopped: true };
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
return {
|
|
2007
|
+
stopped: false,
|
|
2008
|
+
error: error instanceof Error ? error.message : "Failed to stop session"
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
const PROMPT_DIR = path.join(os.tmpdir(), "happy", "agent-prompts");
|
|
2014
|
+
async function writePromptFile(prefix, content) {
|
|
2015
|
+
await promises.mkdir(PROMPT_DIR, { recursive: true });
|
|
2016
|
+
const filename = `${prefix}-${Date.now()}.md`;
|
|
2017
|
+
const filepath = path.join(PROMPT_DIR, filename);
|
|
2018
|
+
await promises.writeFile(filepath, content, "utf-8");
|
|
2019
|
+
return filepath;
|
|
2020
|
+
}
|
|
2021
|
+
function buildWebhookPrompt(data) {
|
|
2022
|
+
return [
|
|
2023
|
+
`# Issue #${data.issueNumber}: ${data.issueTitle}`,
|
|
2024
|
+
"",
|
|
2025
|
+
`Author: ${data.issueAuthor}`,
|
|
2026
|
+
`Labels: ${data.issueLabels.join(", ") || "none"}`,
|
|
2027
|
+
`URL: ${data.issueUrl}`,
|
|
2028
|
+
"",
|
|
2029
|
+
data.issueBody
|
|
2030
|
+
].join("\n");
|
|
2031
|
+
}
|
|
2032
|
+
function buildSupervisorPrompt(data) {
|
|
2033
|
+
const parts = [
|
|
2034
|
+
`# Supervisor Run: ${data.runId}`,
|
|
2035
|
+
"",
|
|
2036
|
+
`Trigger: ${data.trigger}`,
|
|
2037
|
+
`Project: ${data.projectId}`
|
|
2038
|
+
];
|
|
2039
|
+
if (data.mode) parts.push(`Mode: ${data.mode}`);
|
|
2040
|
+
if (data.dimensions?.length) parts.push(`Dimensions: ${data.dimensions.join(", ")}`);
|
|
2041
|
+
if (data.changedFiles?.length) {
|
|
2042
|
+
parts.push("", "## Changed Files", ...data.changedFiles.map((f) => `- ${f}`));
|
|
2043
|
+
}
|
|
2044
|
+
if (data.customRules) parts.push("", "## Custom Rules", data.customRules);
|
|
2045
|
+
return parts.join("\n");
|
|
2046
|
+
}
|
|
2047
|
+
async function handleWebhookTrigger(data, client, serverUrl, authToken) {
|
|
2048
|
+
logger.debug(`[TRIGGER] Webhook: issue #${data.issueNumber} in ${data.repoPath}`);
|
|
2049
|
+
client.emitWebhookStatus({
|
|
2050
|
+
webhookEventId: data.webhookEventId,
|
|
2051
|
+
status: "dispatched"
|
|
2052
|
+
});
|
|
2053
|
+
try {
|
|
2054
|
+
const promptFile = await writePromptFile("webhook", buildWebhookPrompt(data));
|
|
2055
|
+
const result = await spawnSession({
|
|
2056
|
+
directory: data.repoPath,
|
|
2057
|
+
approvedNewDirectoryCreation: false,
|
|
2058
|
+
automationContext: {
|
|
2059
|
+
kind: "webhook",
|
|
2060
|
+
trigger: `issue#${data.issueNumber}`
|
|
2061
|
+
},
|
|
2062
|
+
environmentVariables: {
|
|
2063
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2064
|
+
HAPPY_WEBHOOK_EVENT_ID: data.webhookEventId,
|
|
2065
|
+
HAPPY_WEBHOOK_ISSUE_URL: data.issueUrl,
|
|
2066
|
+
HAPPY_SERVER_URL: serverUrl,
|
|
2067
|
+
HAPPY_AUTH_TOKEN: authToken
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
if (result.type === "success") {
|
|
2071
|
+
logger.debug(`[TRIGGER] Webhook session spawned: PID ${result.pid}`);
|
|
2072
|
+
} else {
|
|
2073
|
+
logger.debug(`[TRIGGER] Webhook spawn failed: ${result.type === "error" ? result.errorMessage : "needs approval"}`);
|
|
2074
|
+
client.emitWebhookStatus({
|
|
2075
|
+
webhookEventId: data.webhookEventId,
|
|
2076
|
+
status: "failed",
|
|
2077
|
+
errorMessage: result.type === "error" ? result.errorMessage : "Directory creation not approved"
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2082
|
+
logger.debug(`[TRIGGER] Webhook error: ${msg}`);
|
|
2083
|
+
client.emitWebhookStatus({
|
|
2084
|
+
webhookEventId: data.webhookEventId,
|
|
2085
|
+
status: "failed",
|
|
2086
|
+
errorMessage: msg
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
async function handleSupervisorTrigger(data, client, serverUrl, authToken) {
|
|
2091
|
+
logger.debug(`[TRIGGER] Supervisor: run ${data.runId} in ${data.repoPath}`);
|
|
2092
|
+
client.emitSupervisorRunStatus({
|
|
2093
|
+
runId: data.runId,
|
|
2094
|
+
projectId: data.projectId,
|
|
2095
|
+
status: "running"
|
|
2096
|
+
});
|
|
2097
|
+
try {
|
|
2098
|
+
const promptFile = await writePromptFile("supervisor", buildSupervisorPrompt(data));
|
|
2099
|
+
const result = await spawnSession({
|
|
2100
|
+
directory: data.repoPath,
|
|
2101
|
+
approvedNewDirectoryCreation: false,
|
|
2102
|
+
automationContext: {
|
|
2103
|
+
kind: "supervisor",
|
|
2104
|
+
trigger: data.trigger,
|
|
2105
|
+
projectId: data.projectId,
|
|
2106
|
+
runId: data.runId
|
|
2107
|
+
},
|
|
2108
|
+
environmentVariables: {
|
|
2109
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2110
|
+
HAPPY_SUPERVISOR_RUN_ID: data.runId,
|
|
2111
|
+
HAPPY_SUPERVISOR_PROJECT_ID: data.projectId,
|
|
2112
|
+
HAPPY_SUPERVISOR_SERVER_URL: serverUrl,
|
|
2113
|
+
HAPPY_SUPERVISOR_AUTH_TOKEN: authToken
|
|
2114
|
+
}
|
|
2115
|
+
});
|
|
2116
|
+
if (result.type !== "success") {
|
|
2117
|
+
client.emitSupervisorRunStatus({
|
|
2118
|
+
runId: data.runId,
|
|
2119
|
+
projectId: data.projectId,
|
|
2120
|
+
status: "failed",
|
|
2121
|
+
errorMessage: result.type === "error" ? result.errorMessage : "Directory creation not approved"
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
} catch (error) {
|
|
2125
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2126
|
+
logger.debug(`[TRIGGER] Supervisor error: ${msg}`);
|
|
2127
|
+
client.emitSupervisorRunStatus({
|
|
2128
|
+
runId: data.runId,
|
|
2129
|
+
projectId: data.projectId,
|
|
2130
|
+
status: "failed",
|
|
2131
|
+
errorMessage: msg
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
async function handleTaskTrigger(data, serverUrl, _authToken) {
|
|
2136
|
+
logger.debug(`[TRIGGER] Task: ${data.taskId} in ${data.directory}`);
|
|
2137
|
+
try {
|
|
2138
|
+
const promptFile = await writePromptFile("task", data.prompt);
|
|
2139
|
+
const skillEnv = {};
|
|
2140
|
+
if (data.skillContents?.length) {
|
|
2141
|
+
skillEnv.HAPPY_TASK_SKILL_COUNT = String(data.skillContents.length);
|
|
2142
|
+
for (let i = 0; i < data.skillContents.length; i++) {
|
|
2143
|
+
skillEnv[`HAPPY_TASK_SKILL_${i}_NAME`] = data.skillContents[i].name;
|
|
2144
|
+
skillEnv[`HAPPY_TASK_SKILL_${i}_CONTENT`] = data.skillContents[i].content;
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
const result = await spawnSession({
|
|
2148
|
+
directory: data.directory,
|
|
2149
|
+
approvedNewDirectoryCreation: true,
|
|
2150
|
+
// Tasks always auto-approve directory
|
|
2151
|
+
automationContext: {
|
|
2152
|
+
kind: "task",
|
|
2153
|
+
trigger: "task-dispatch",
|
|
2154
|
+
projectId: data.projectId
|
|
2155
|
+
},
|
|
2156
|
+
environmentVariables: {
|
|
2157
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2158
|
+
HAPPY_TASK_ID: data.taskId,
|
|
2159
|
+
HAPPY_TASK_PRIORITY: data.priority,
|
|
2160
|
+
HAPPY_TASK_SERVER_URL: serverUrl,
|
|
2161
|
+
HAPPY_TASK_RESULT_TOKEN: data.resultToken ?? "",
|
|
2162
|
+
HAPPY_TASK_REPORT_URL: `${serverUrl}/v1/tasks/${data.taskId}/result`,
|
|
2163
|
+
...skillEnv
|
|
2164
|
+
}
|
|
2165
|
+
});
|
|
2166
|
+
if (result.type !== "success") {
|
|
2167
|
+
logger.debug(`[TRIGGER] Task spawn failed: ${result.type === "error" ? result.errorMessage : "needs approval"}`);
|
|
2168
|
+
}
|
|
2169
|
+
} catch (error) {
|
|
2170
|
+
logger.debug(`[TRIGGER] Task error: ${error instanceof Error ? error.message : String(error)}`);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
1478
2174
|
const TAILSCALE_REFRESH_MS = 5 * 60 * 1e3;
|
|
1479
2175
|
class MachineClient {
|
|
1480
2176
|
machine;
|
|
@@ -1486,11 +2182,17 @@ class MachineClient {
|
|
|
1486
2182
|
tunnelManager = null;
|
|
1487
2183
|
token;
|
|
1488
2184
|
serverUrl;
|
|
2185
|
+
agentVersion;
|
|
2186
|
+
startTime = Date.now();
|
|
1489
2187
|
onEphemeral;
|
|
2188
|
+
automationEnabled = false;
|
|
2189
|
+
automationServerUrl = "";
|
|
2190
|
+
automationAuthToken = "";
|
|
1490
2191
|
constructor(opts) {
|
|
1491
2192
|
this.token = opts.token;
|
|
1492
2193
|
this.machine = opts.machine;
|
|
1493
2194
|
this.serverUrl = opts.serverUrl;
|
|
2195
|
+
this.agentVersion = opts.agentVersion ?? "unknown";
|
|
1494
2196
|
this.onEphemeral = opts.onEphemeral;
|
|
1495
2197
|
this.rpcHandlerManager = new RpcHandlerManager({
|
|
1496
2198
|
scopePrefix: opts.machine.id,
|
|
@@ -1500,6 +2202,32 @@ class MachineClient {
|
|
|
1500
2202
|
});
|
|
1501
2203
|
const workDir = opts.workingDirectory ?? process.cwd();
|
|
1502
2204
|
registerAgentHandlers(this.rpcHandlerManager, workDir, opts.machine.id);
|
|
2205
|
+
this.registerMachineHandlers();
|
|
2206
|
+
}
|
|
2207
|
+
// -----------------------------------------------------------------------
|
|
2208
|
+
// Machine-scoped RPC handlers
|
|
2209
|
+
// -----------------------------------------------------------------------
|
|
2210
|
+
registerMachineHandlers() {
|
|
2211
|
+
this.rpcHandlerManager.registerHandler(
|
|
2212
|
+
"spawn-happy-session",
|
|
2213
|
+
async (data) => {
|
|
2214
|
+
logger.debug("[MACHINE] spawn-happy-session request:", data.directory);
|
|
2215
|
+
return spawnSession(data);
|
|
2216
|
+
}
|
|
2217
|
+
);
|
|
2218
|
+
this.rpcHandlerManager.registerHandler("stop-session", async (data) => {
|
|
2219
|
+
logger.debug("[MACHINE] stop-session request:", data.pid);
|
|
2220
|
+
return stopSession(data.pid);
|
|
2221
|
+
});
|
|
2222
|
+
this.rpcHandlerManager.registerHandler("list-tracked-sessions", async () => {
|
|
2223
|
+
const sessions = getAllTrackedSessions().map((s) => ({
|
|
2224
|
+
pid: s.pid,
|
|
2225
|
+
directory: s.directory,
|
|
2226
|
+
startedAt: s.startedAt,
|
|
2227
|
+
happySessionId: s.happySessionId
|
|
2228
|
+
}));
|
|
2229
|
+
return { sessions };
|
|
2230
|
+
});
|
|
1503
2231
|
}
|
|
1504
2232
|
// -----------------------------------------------------------------------
|
|
1505
2233
|
// Connection
|
|
@@ -1526,6 +2254,8 @@ class MachineClient {
|
|
|
1526
2254
|
status: "running",
|
|
1527
2255
|
pid: process.pid,
|
|
1528
2256
|
startedAt: Date.now(),
|
|
2257
|
+
startTime: this.startTime,
|
|
2258
|
+
startedWithCliVersion: this.agentVersion,
|
|
1529
2259
|
tailscale: this.lastTailscaleInfo ?? state?.tailscale
|
|
1530
2260
|
}));
|
|
1531
2261
|
this.startKeepAlive();
|
|
@@ -1565,12 +2295,12 @@ class MachineClient {
|
|
|
1565
2295
|
}
|
|
1566
2296
|
}
|
|
1567
2297
|
});
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
}
|
|
2298
|
+
this.socket.on("ephemeral", (data) => {
|
|
2299
|
+
const event = data;
|
|
2300
|
+
logger.debug("[MACHINE] Received ephemeral event:", event.type);
|
|
2301
|
+
this.onEphemeral?.(event);
|
|
2302
|
+
this.handleAutomationEvent(event);
|
|
2303
|
+
});
|
|
1574
2304
|
this.socket.on("connect_error", (error) => {
|
|
1575
2305
|
logger.debug(`[MACHINE] Connection error: ${error.message}`);
|
|
1576
2306
|
});
|
|
@@ -1647,6 +2377,75 @@ class MachineClient {
|
|
|
1647
2377
|
}, { maxRetries: 3, label: "updateDaemonState" });
|
|
1648
2378
|
}
|
|
1649
2379
|
// -----------------------------------------------------------------------
|
|
2380
|
+
// Socket event emitters
|
|
2381
|
+
// -----------------------------------------------------------------------
|
|
2382
|
+
/** Emit a session lifecycle event. */
|
|
2383
|
+
emitSessionEvent(sessionId, eventType, summary, detail) {
|
|
2384
|
+
this.socket?.emit("session-event", { sessionId, eventType, summary, detail });
|
|
2385
|
+
}
|
|
2386
|
+
/** Report webhook processing status. */
|
|
2387
|
+
emitWebhookStatus(data) {
|
|
2388
|
+
this.socket?.emit("webhook-status", data);
|
|
2389
|
+
}
|
|
2390
|
+
/** Report supervisor run status. */
|
|
2391
|
+
emitSupervisorRunStatus(data) {
|
|
2392
|
+
this.socket?.emit("supervisor-run-status", data);
|
|
2393
|
+
}
|
|
2394
|
+
/** Submit a knowledge entry from a session. */
|
|
2395
|
+
emitSubmitKnowledge(sid, entry) {
|
|
2396
|
+
this.socket?.emit("submit-knowledge", { sid, entry });
|
|
2397
|
+
}
|
|
2398
|
+
/** Fetch knowledge for a session. */
|
|
2399
|
+
emitFetchKnowledge(sid, mode, contextHints, callback) {
|
|
2400
|
+
this.socket?.emit("fetch-knowledge", { sid, mode, contextHints }, callback);
|
|
2401
|
+
}
|
|
2402
|
+
/** Stream task log chunk. */
|
|
2403
|
+
emitTaskLog(sid, taskId, outputFile, chunk, offset) {
|
|
2404
|
+
this.socket?.emit("task-log", { sid, taskId, outputFile, chunk, offset });
|
|
2405
|
+
}
|
|
2406
|
+
// -----------------------------------------------------------------------
|
|
2407
|
+
// Automation triggers
|
|
2408
|
+
// -----------------------------------------------------------------------
|
|
2409
|
+
/**
|
|
2410
|
+
* Enable automation handling — agent will process webhook, supervisor,
|
|
2411
|
+
* and task triggers from the server by spawning Happy CLI sessions.
|
|
2412
|
+
*/
|
|
2413
|
+
enableAutomation(serverUrl, authToken) {
|
|
2414
|
+
this.automationEnabled = true;
|
|
2415
|
+
this.automationServerUrl = serverUrl;
|
|
2416
|
+
this.automationAuthToken = authToken;
|
|
2417
|
+
logger.debug("[MACHINE] Automation enabled");
|
|
2418
|
+
}
|
|
2419
|
+
/** Internal dispatch for ephemeral events that need automation handling. */
|
|
2420
|
+
handleAutomationEvent(event) {
|
|
2421
|
+
if (!this.automationEnabled) return;
|
|
2422
|
+
switch (event.type) {
|
|
2423
|
+
case "webhook-trigger":
|
|
2424
|
+
void handleWebhookTrigger(
|
|
2425
|
+
event,
|
|
2426
|
+
this,
|
|
2427
|
+
this.automationServerUrl,
|
|
2428
|
+
this.automationAuthToken
|
|
2429
|
+
);
|
|
2430
|
+
break;
|
|
2431
|
+
case "supervisor-trigger":
|
|
2432
|
+
void handleSupervisorTrigger(
|
|
2433
|
+
event,
|
|
2434
|
+
this,
|
|
2435
|
+
this.automationServerUrl,
|
|
2436
|
+
this.automationAuthToken
|
|
2437
|
+
);
|
|
2438
|
+
break;
|
|
2439
|
+
case "task-trigger":
|
|
2440
|
+
void handleTaskTrigger(
|
|
2441
|
+
event,
|
|
2442
|
+
this.automationServerUrl,
|
|
2443
|
+
this.automationAuthToken
|
|
2444
|
+
);
|
|
2445
|
+
break;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
// -----------------------------------------------------------------------
|
|
1650
2449
|
// Lifecycle
|
|
1651
2450
|
// -----------------------------------------------------------------------
|
|
1652
2451
|
/** Seed initial Tailscale info detected before connect. */
|
|
@@ -1721,6 +2520,131 @@ function tailscaleChanged(prev, next) {
|
|
|
1721
2520
|
return prev.status !== next.status || prev.ipv4 !== next.ipv4 || prev.ipv6 !== next.ipv6 || prev.hostname !== next.hostname || JSON.stringify(prev.serves) !== JSON.stringify(next.serves);
|
|
1722
2521
|
}
|
|
1723
2522
|
|
|
2523
|
+
function pidFilePath(homeDir) {
|
|
2524
|
+
return node_path.join(homeDir, "agent-daemon.pid");
|
|
2525
|
+
}
|
|
2526
|
+
function writePidFile(homeDir, pid) {
|
|
2527
|
+
node_fs.mkdirSync(homeDir, { recursive: true });
|
|
2528
|
+
node_fs.writeFileSync(pidFilePath(homeDir), String(pid), "utf-8");
|
|
2529
|
+
}
|
|
2530
|
+
function readPidFile(homeDir) {
|
|
2531
|
+
try {
|
|
2532
|
+
const raw = node_fs.readFileSync(pidFilePath(homeDir), "utf-8").trim();
|
|
2533
|
+
const pid = parseInt(raw, 10);
|
|
2534
|
+
return isNaN(pid) ? null : pid;
|
|
2535
|
+
} catch {
|
|
2536
|
+
return null;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
function removePidFile(homeDir) {
|
|
2540
|
+
try {
|
|
2541
|
+
node_fs.unlinkSync(pidFilePath(homeDir));
|
|
2542
|
+
} catch {
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
function isProcessRunning(pid) {
|
|
2546
|
+
try {
|
|
2547
|
+
process.kill(pid, 0);
|
|
2548
|
+
return true;
|
|
2549
|
+
} catch {
|
|
2550
|
+
return false;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
async function startDaemon(options) {
|
|
2554
|
+
const config = loadConfig();
|
|
2555
|
+
const creds = requireCredentials(config);
|
|
2556
|
+
const existingPid = readPidFile(config.homeDir);
|
|
2557
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
2558
|
+
console.log(`Daemon already running (PID ${existingPid})`);
|
|
2559
|
+
process.exitCode = 1;
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
if (existingPid) {
|
|
2563
|
+
removePidFile(config.homeDir);
|
|
2564
|
+
}
|
|
2565
|
+
const workDir = options.directory ?? process.cwd();
|
|
2566
|
+
console.log(`Starting agent daemon in ${workDir}...`);
|
|
2567
|
+
const metadata = {
|
|
2568
|
+
host: node_os.hostname(),
|
|
2569
|
+
platform: process.platform,
|
|
2570
|
+
happyCliVersion: version,
|
|
2571
|
+
homeDir: config.homeDir,
|
|
2572
|
+
happyHomeDir: config.homeDir,
|
|
2573
|
+
happyLibDir: config.homeDir
|
|
2574
|
+
};
|
|
2575
|
+
const machine = await getOrCreateMachine(config, creds, metadata);
|
|
2576
|
+
console.log(`Machine: ${machine.id} (${machine.metadata.host})`);
|
|
2577
|
+
const tailscaleInfo = await detectTailscale();
|
|
2578
|
+
const serves = tailscaleInfo.status === "connected" ? await detectTailscaleServe() : [];
|
|
2579
|
+
const fullTailscale = { ...tailscaleInfo, serves };
|
|
2580
|
+
if (tailscaleInfo.status === "connected") {
|
|
2581
|
+
console.log(`Tailscale: ${tailscaleInfo.hostname} (${tailscaleInfo.ipv4})`);
|
|
2582
|
+
}
|
|
2583
|
+
const client = new MachineClient({
|
|
2584
|
+
token: creds.token,
|
|
2585
|
+
machine,
|
|
2586
|
+
serverUrl: config.serverUrl,
|
|
2587
|
+
agentVersion: version,
|
|
2588
|
+
workingDirectory: workDir
|
|
2589
|
+
});
|
|
2590
|
+
client.setTailscaleInfo(fullTailscale);
|
|
2591
|
+
client.enableAutomation(config.serverUrl, creds.token);
|
|
2592
|
+
client.connect();
|
|
2593
|
+
writePidFile(config.homeDir, process.pid);
|
|
2594
|
+
console.log(`Daemon started (PID ${process.pid})`);
|
|
2595
|
+
let shuttingDown = false;
|
|
2596
|
+
const shutdown = (signal) => {
|
|
2597
|
+
if (shuttingDown) return;
|
|
2598
|
+
shuttingDown = true;
|
|
2599
|
+
logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
|
|
2600
|
+
console.log(`
|
|
2601
|
+
Received ${signal}, shutting down...`);
|
|
2602
|
+
client.shutdown();
|
|
2603
|
+
removePidFile(config.homeDir);
|
|
2604
|
+
process.exit(0);
|
|
2605
|
+
};
|
|
2606
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
2607
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
2608
|
+
await new Promise(() => {
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
function stopDaemon() {
|
|
2612
|
+
const config = loadConfig();
|
|
2613
|
+
const pid = readPidFile(config.homeDir);
|
|
2614
|
+
if (!pid) {
|
|
2615
|
+
console.log("No daemon PID file found.");
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
if (!isProcessRunning(pid)) {
|
|
2619
|
+
console.log(`Daemon PID ${pid} is not running (stale PID file). Cleaning up.`);
|
|
2620
|
+
removePidFile(config.homeDir);
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
try {
|
|
2624
|
+
process.kill(pid, "SIGTERM");
|
|
2625
|
+
console.log(`Sent SIGTERM to daemon (PID ${pid})`);
|
|
2626
|
+
removePidFile(config.homeDir);
|
|
2627
|
+
} catch (error) {
|
|
2628
|
+
console.error(`Failed to stop daemon: ${error instanceof Error ? error.message : String(error)}`);
|
|
2629
|
+
process.exitCode = 1;
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
function daemonStatus() {
|
|
2633
|
+
const config = loadConfig();
|
|
2634
|
+
const pid = readPidFile(config.homeDir);
|
|
2635
|
+
if (!pid) {
|
|
2636
|
+
console.log("Daemon is not running (no PID file).");
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
if (isProcessRunning(pid)) {
|
|
2640
|
+
console.log(`Daemon is running (PID ${pid})`);
|
|
2641
|
+
console.log(`PID file: ${pidFilePath(config.homeDir)}`);
|
|
2642
|
+
} else {
|
|
2643
|
+
console.log(`Daemon PID ${pid} is not running (stale PID file).`);
|
|
2644
|
+
console.log(`Run \`happy-agent daemon stop\` to clean up.`);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
|
|
1724
2648
|
function formatTime(ts) {
|
|
1725
2649
|
if (!ts) return "-";
|
|
1726
2650
|
const date = new Date(ts);
|
|
@@ -2156,6 +3080,19 @@ program.command("machine").description("Manage machine identity").addCommand(
|
|
|
2156
3080
|
}
|
|
2157
3081
|
})
|
|
2158
3082
|
);
|
|
3083
|
+
program.command("daemon").description("Run as a persistent background daemon").addCommand(
|
|
3084
|
+
new commander.Command("start").description("Start the agent daemon (connects to server, handles triggers)").option("--directory <dir>", "Working directory for spawned sessions").option("--foreground", "Run in foreground (do not detach)").action(async (opts) => {
|
|
3085
|
+
await startDaemon(opts);
|
|
3086
|
+
})
|
|
3087
|
+
).addCommand(
|
|
3088
|
+
new commander.Command("stop").description("Stop the running daemon").action(() => {
|
|
3089
|
+
stopDaemon();
|
|
3090
|
+
})
|
|
3091
|
+
).addCommand(
|
|
3092
|
+
new commander.Command("status").description("Check daemon status").action(() => {
|
|
3093
|
+
daemonStatus();
|
|
3094
|
+
})
|
|
3095
|
+
);
|
|
2159
3096
|
program.parseAsync(process.argv).catch((err) => {
|
|
2160
3097
|
console.error(err instanceof Error ? err.message : String(err));
|
|
2161
3098
|
process.exitCode = 1;
|
|
@@ -2169,6 +3106,7 @@ exports.authLogout = authLogout;
|
|
|
2169
3106
|
exports.authStatus = authStatus;
|
|
2170
3107
|
exports.createRpcHandlerManager = createRpcHandlerManager;
|
|
2171
3108
|
exports.createSession = createSession;
|
|
3109
|
+
exports.daemonStatus = daemonStatus;
|
|
2172
3110
|
exports.deleteSession = deleteSession;
|
|
2173
3111
|
exports.fetchMessagesAfterSeq = fetchMessagesAfterSeq;
|
|
2174
3112
|
exports.getOrCreateMachine = getOrCreateMachine;
|
|
@@ -2181,3 +3119,5 @@ exports.readCredentials = readCredentials;
|
|
|
2181
3119
|
exports.requireCredentials = requireCredentials;
|
|
2182
3120
|
exports.resolveSessionEncryption = resolveSessionEncryption;
|
|
2183
3121
|
exports.sendMessagesBatch = sendMessagesBatch;
|
|
3122
|
+
exports.startDaemon = startDaemon;
|
|
3123
|
+
exports.stopDaemon = stopDaemon;
|