@kmmao/happy-agent 0.3.11 → 0.4.1
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 +1151 -7
- package/dist/index.d.cts +221 -6
- package/dist/index.d.mts +221 -6
- package/dist/index.mjs +1151 -10
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -8,15 +8,15 @@ import axios, { AxiosError } from 'axios';
|
|
|
8
8
|
import qrcode from 'qrcode-terminal';
|
|
9
9
|
import { EventEmitter } from 'node:events';
|
|
10
10
|
import { io } from 'socket.io-client';
|
|
11
|
-
import { exec, execFile } from 'child_process';
|
|
11
|
+
import { exec, execFile, spawn } from 'child_process';
|
|
12
12
|
import { promisify } from 'util';
|
|
13
13
|
import { readFile, mkdir, writeFile, readdir, stat } from 'fs/promises';
|
|
14
|
-
import { createHash as createHash$1 } from 'crypto';
|
|
14
|
+
import { createHash as createHash$1, randomUUID } from 'crypto';
|
|
15
15
|
import { join as join$1, resolve } from 'path';
|
|
16
16
|
import { realpathSync } from 'fs';
|
|
17
17
|
import { tmpdir } from 'os';
|
|
18
18
|
|
|
19
|
-
var version = "0.
|
|
19
|
+
var version = "0.4.1";
|
|
20
20
|
|
|
21
21
|
function loadConfig() {
|
|
22
22
|
const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
|
|
@@ -787,6 +787,7 @@ function createRpcHandlerManager(config) {
|
|
|
787
787
|
}
|
|
788
788
|
|
|
789
789
|
const execAsync = promisify(exec);
|
|
790
|
+
const execFileAsync$1 = promisify(execFile);
|
|
790
791
|
const UPLOAD_TEMP_DIR = join$1(tmpdir(), "happy", "uploads");
|
|
791
792
|
const MAX_WRITE_SIZE = 10 * 1024 * 1024;
|
|
792
793
|
function validatePath(targetPath, workingDirectory, additionalAllowedDirs) {
|
|
@@ -1046,6 +1047,367 @@ function registerAgentHandlers(rpcHandlerManager, workingDirectory, sessionId) {
|
|
|
1046
1047
|
await mkdir(uploadDir, { recursive: true });
|
|
1047
1048
|
return { success: true, path: uploadDir };
|
|
1048
1049
|
});
|
|
1050
|
+
rpcHandlerManager.registerHandler("getDirectoryTree", async (data) => {
|
|
1051
|
+
logger.debug("Get directory tree request:", data.path, "maxDepth:", data.maxDepth);
|
|
1052
|
+
const validation = validatePath(data.path, workingDirectory);
|
|
1053
|
+
if (!validation.valid) {
|
|
1054
|
+
return { success: false, error: validation.error };
|
|
1055
|
+
}
|
|
1056
|
+
async function buildTree(dirPath, name, currentDepth) {
|
|
1057
|
+
try {
|
|
1058
|
+
const stats = await stat(dirPath);
|
|
1059
|
+
const node = {
|
|
1060
|
+
name,
|
|
1061
|
+
path: dirPath,
|
|
1062
|
+
type: stats.isDirectory() ? "directory" : "file",
|
|
1063
|
+
size: stats.size,
|
|
1064
|
+
modified: stats.mtime.getTime()
|
|
1065
|
+
};
|
|
1066
|
+
if (stats.isDirectory() && currentDepth < data.maxDepth) {
|
|
1067
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
1068
|
+
const children = [];
|
|
1069
|
+
await Promise.all(
|
|
1070
|
+
entries.map(async (entry) => {
|
|
1071
|
+
if (entry.isSymbolicLink()) return;
|
|
1072
|
+
const childPath = join$1(dirPath, entry.name);
|
|
1073
|
+
const childNode = await buildTree(childPath, entry.name, currentDepth + 1);
|
|
1074
|
+
if (childNode) children.push(childNode);
|
|
1075
|
+
})
|
|
1076
|
+
);
|
|
1077
|
+
children.sort((a, b) => {
|
|
1078
|
+
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
1079
|
+
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
1080
|
+
return a.name.localeCompare(b.name);
|
|
1081
|
+
});
|
|
1082
|
+
node.children = children;
|
|
1083
|
+
}
|
|
1084
|
+
return node;
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
logger.debug(`Failed to process ${dirPath}:`, error instanceof Error ? error.message : String(error));
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
try {
|
|
1091
|
+
if (data.maxDepth < 0) {
|
|
1092
|
+
return { success: false, error: "maxDepth must be non-negative" };
|
|
1093
|
+
}
|
|
1094
|
+
const baseName = data.path === "/" ? "/" : data.path.split("/").pop() || data.path;
|
|
1095
|
+
const tree = await buildTree(data.path, baseName, 0);
|
|
1096
|
+
if (!tree) {
|
|
1097
|
+
return { success: false, error: "Failed to access the specified path" };
|
|
1098
|
+
}
|
|
1099
|
+
return { success: true, tree };
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
logger.debug("Failed to get directory tree:", error);
|
|
1102
|
+
return {
|
|
1103
|
+
success: false,
|
|
1104
|
+
error: error instanceof Error ? error.message : "Failed to get directory tree"
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
rpcHandlerManager.registerHandler(
|
|
1109
|
+
"ripgrep",
|
|
1110
|
+
async (data) => {
|
|
1111
|
+
const cwd = data.cwd || workingDirectory;
|
|
1112
|
+
if (data.cwd) {
|
|
1113
|
+
const validation = validatePath(data.cwd, workingDirectory);
|
|
1114
|
+
if (!validation.valid) {
|
|
1115
|
+
return { success: false, error: validation.error };
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
try {
|
|
1119
|
+
const { stdout, stderr } = await execFileAsync$1("rg", data.args, {
|
|
1120
|
+
cwd,
|
|
1121
|
+
timeout: 3e4,
|
|
1122
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1123
|
+
});
|
|
1124
|
+
return { success: true, stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "", exitCode: 0 };
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
const e = error;
|
|
1127
|
+
if (e.code === "ENOENT") {
|
|
1128
|
+
return { success: false, error: "ripgrep (rg) is not installed on this machine" };
|
|
1129
|
+
}
|
|
1130
|
+
if (e.code === "1" || e.status === 1) {
|
|
1131
|
+
return { success: true, stdout: e.stdout?.toString() ?? "", stderr: e.stderr?.toString() ?? "", exitCode: 1 };
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
success: false,
|
|
1135
|
+
stdout: e.stdout?.toString() ?? "",
|
|
1136
|
+
stderr: e.stderr?.toString() ?? "",
|
|
1137
|
+
exitCode: typeof e.code === "number" ? e.code : 1,
|
|
1138
|
+
error: e.message ?? "ripgrep failed"
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
);
|
|
1143
|
+
rpcHandlerManager.registerHandler(
|
|
1144
|
+
"difftastic",
|
|
1145
|
+
async (data) => {
|
|
1146
|
+
const cwd = data.cwd || workingDirectory;
|
|
1147
|
+
if (data.cwd) {
|
|
1148
|
+
const validation = validatePath(data.cwd, workingDirectory);
|
|
1149
|
+
if (!validation.valid) {
|
|
1150
|
+
return { success: false, error: validation.error };
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
try {
|
|
1154
|
+
const { stdout, stderr } = await execFileAsync$1("difft", data.args, {
|
|
1155
|
+
cwd,
|
|
1156
|
+
timeout: 3e4,
|
|
1157
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1158
|
+
});
|
|
1159
|
+
return { success: true, stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "", exitCode: 0 };
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
const e = error;
|
|
1162
|
+
if (e.code === "ENOENT") {
|
|
1163
|
+
return { success: false, error: "difftastic (difft) is not installed on this machine" };
|
|
1164
|
+
}
|
|
1165
|
+
return {
|
|
1166
|
+
success: false,
|
|
1167
|
+
stdout: e.stdout?.toString() ?? "",
|
|
1168
|
+
stderr: e.stderr?.toString() ?? "",
|
|
1169
|
+
exitCode: typeof e.code === "number" ? e.code : 1,
|
|
1170
|
+
error: e.message ?? "difftastic failed"
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
);
|
|
1175
|
+
rpcHandlerManager.registerHandler("listRemoteGitRepos", async (data) => {
|
|
1176
|
+
const page = data.page ?? 1;
|
|
1177
|
+
const perPage = data.perPage ?? 30;
|
|
1178
|
+
const query = data.query?.trim() || "";
|
|
1179
|
+
logger.debug("listRemoteGitRepos request:", { provider: data.provider, host: data.host, page, perPage, query });
|
|
1180
|
+
if (!data.apiToken) return { success: false, error: "API token is required" };
|
|
1181
|
+
if (!data.host?.trim()) return { success: false, error: "Host is required" };
|
|
1182
|
+
try {
|
|
1183
|
+
const baseUrl = buildGitApiBase(data.provider, data.host);
|
|
1184
|
+
const headers = buildGitApiHeaders(data.apiToken);
|
|
1185
|
+
let repos;
|
|
1186
|
+
let hasMore;
|
|
1187
|
+
if (data.provider === "github") {
|
|
1188
|
+
const listUrl = `${baseUrl}/user/repos?per_page=${perPage}&page=${page}&sort=updated&affiliation=owner,collaborator,organization_member`;
|
|
1189
|
+
const response = await fetch(listUrl, { headers });
|
|
1190
|
+
if (!response.ok) {
|
|
1191
|
+
const errorText = await response.text().catch(() => "");
|
|
1192
|
+
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
1193
|
+
}
|
|
1194
|
+
const items = await response.json();
|
|
1195
|
+
repos = normalizeRemoteRepoEntries(items);
|
|
1196
|
+
hasMore = items.length >= perPage;
|
|
1197
|
+
} else {
|
|
1198
|
+
const searchParams = new URLSearchParams({ sort: "updated", order: "desc", limit: String(perPage), page: String(page) });
|
|
1199
|
+
if (query) searchParams.set("q", query);
|
|
1200
|
+
const searchUrl = `${baseUrl}/repos/search?${searchParams.toString()}`;
|
|
1201
|
+
const response = await fetch(searchUrl, { headers });
|
|
1202
|
+
if (!response.ok) {
|
|
1203
|
+
const errorText = await response.text().catch(() => "");
|
|
1204
|
+
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
1205
|
+
}
|
|
1206
|
+
const body = await response.json();
|
|
1207
|
+
const items = Array.isArray(body) ? body : body.data || [];
|
|
1208
|
+
repos = normalizeRemoteRepoEntries(items);
|
|
1209
|
+
hasMore = items.length >= perPage;
|
|
1210
|
+
}
|
|
1211
|
+
return { success: true, repos, hasMore };
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
logger.debug("listRemoteGitRepos failed:", error);
|
|
1214
|
+
return { success: false, error: error instanceof Error ? error.message : "Failed to load remote repositories" };
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
rpcHandlerManager.registerHandler(
|
|
1218
|
+
"cloneGitRepo",
|
|
1219
|
+
async (data) => {
|
|
1220
|
+
logger.debug("cloneGitRepo request:", { repoUrl: data.repoUrl, targetDirectory: data.targetDirectory });
|
|
1221
|
+
if (!data.repoUrl?.trim()) return { success: false, error: "Repository URL is required" };
|
|
1222
|
+
if (!data.targetDirectory?.trim()) return { success: false, error: "Target directory is required" };
|
|
1223
|
+
if (!data.targetDirectory.startsWith("/")) return { success: false, error: "Target directory must be an absolute path" };
|
|
1224
|
+
const coords = parseCloneCoordinates(data.repoUrl);
|
|
1225
|
+
if (!coords) return { success: false, error: "Invalid repository URL" };
|
|
1226
|
+
const repoPath = join$1(resolve(data.targetDirectory), coords.repo);
|
|
1227
|
+
try {
|
|
1228
|
+
await mkdir(resolve(data.targetDirectory), { recursive: true });
|
|
1229
|
+
try {
|
|
1230
|
+
const existing = await stat(repoPath);
|
|
1231
|
+
if (existing.isDirectory()) {
|
|
1232
|
+
return { success: false, error: `Destination already exists: ${repoPath}` };
|
|
1233
|
+
}
|
|
1234
|
+
} catch {
|
|
1235
|
+
}
|
|
1236
|
+
const cloneUrl = resolveCloneUrl(data.repoUrl, data.host);
|
|
1237
|
+
const options = {
|
|
1238
|
+
cwd: resolve(data.targetDirectory),
|
|
1239
|
+
timeout: 3e5,
|
|
1240
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1241
|
+
env: buildCloneAuthEnv(data.provider, data.apiToken)
|
|
1242
|
+
};
|
|
1243
|
+
const { stdout, stderr } = await execFileAsync$1("git", ["clone", cloneUrl, repoPath], options);
|
|
1244
|
+
return { success: true, repoPath, stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "" };
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
const e = error;
|
|
1247
|
+
logger.debug("cloneGitRepo failed:", { message: e.message, stderr: e.stderr });
|
|
1248
|
+
return { success: false, repoPath, stdout: e.stdout || "", stderr: e.stderr || "", error: e.message || "Failed to clone repository" };
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
);
|
|
1252
|
+
rpcHandlerManager.registerHandler("createRemoteWebhook", async (data) => {
|
|
1253
|
+
logger.debug("createRemoteWebhook request:", { provider: data.provider, repoUrl: data.repoUrl });
|
|
1254
|
+
try {
|
|
1255
|
+
const parsed = parseRepoOwnerFromUrl(data.repoUrl);
|
|
1256
|
+
if (!parsed) return { success: false, error: "Invalid repo URL" };
|
|
1257
|
+
const { origin, owner, repo } = parsed;
|
|
1258
|
+
const isGitHubCom = origin === "https://github.com" || origin === "http://github.com";
|
|
1259
|
+
const baseUrl = data.provider === "github" && isGitHubCom ? "https://api.github.com" : data.provider === "github" ? `${origin}/api/v3` : `${origin}/api/v1`;
|
|
1260
|
+
const headers = {
|
|
1261
|
+
Authorization: `token ${data.apiToken}`,
|
|
1262
|
+
"Content-Type": "application/json",
|
|
1263
|
+
Accept: "application/json"
|
|
1264
|
+
};
|
|
1265
|
+
const hooksEndpoint = `${baseUrl}/repos/${owner}/${repo}/hooks`;
|
|
1266
|
+
const giteaEvents = [
|
|
1267
|
+
"issues",
|
|
1268
|
+
"issue_assign",
|
|
1269
|
+
"issue_label",
|
|
1270
|
+
"issue_milestone",
|
|
1271
|
+
"issue_comment",
|
|
1272
|
+
"pull_request",
|
|
1273
|
+
"pull_request_assign",
|
|
1274
|
+
"pull_request_label",
|
|
1275
|
+
"pull_request_milestone",
|
|
1276
|
+
"pull_request_comment",
|
|
1277
|
+
"pull_request_review",
|
|
1278
|
+
"pull_request_sync"
|
|
1279
|
+
];
|
|
1280
|
+
const events = data.provider === "gitea" ? giteaEvents : data.events;
|
|
1281
|
+
const listRes = await fetch(hooksEndpoint, { headers });
|
|
1282
|
+
if (listRes.ok) {
|
|
1283
|
+
const hooks = await listRes.json();
|
|
1284
|
+
const existing = hooks.find((h) => h.config.url === data.webhookUrl);
|
|
1285
|
+
if (existing) {
|
|
1286
|
+
const patchRes = await fetch(`${hooksEndpoint}/${existing.id}`, {
|
|
1287
|
+
method: "PATCH",
|
|
1288
|
+
headers,
|
|
1289
|
+
body: JSON.stringify({ config: { url: data.webhookUrl, content_type: "json", secret: data.webhookSecret }, events, active: true })
|
|
1290
|
+
});
|
|
1291
|
+
if (!patchRes.ok) {
|
|
1292
|
+
const errText = await patchRes.text().catch(() => "");
|
|
1293
|
+
return { success: false, error: `PATCH ${patchRes.status}: ${errText}` };
|
|
1294
|
+
}
|
|
1295
|
+
return { success: true, created: false, webhookId: existing.id };
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
const createBody = {
|
|
1299
|
+
name: "web",
|
|
1300
|
+
config: { url: data.webhookUrl, content_type: "json", secret: data.webhookSecret },
|
|
1301
|
+
events,
|
|
1302
|
+
active: true
|
|
1303
|
+
};
|
|
1304
|
+
if (data.provider === "gitea") createBody.type = "gitea";
|
|
1305
|
+
const createRes = await fetch(hooksEndpoint, { method: "POST", headers, body: JSON.stringify(createBody) });
|
|
1306
|
+
if (createRes.ok || createRes.status === 201) {
|
|
1307
|
+
const body = await createRes.json().catch(() => ({}));
|
|
1308
|
+
return { success: true, created: true, webhookId: body.id };
|
|
1309
|
+
}
|
|
1310
|
+
const errBody = await createRes.text().catch(() => "");
|
|
1311
|
+
return { success: false, error: `HTTP ${createRes.status}: ${errBody}` };
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
logger.debug("createRemoteWebhook failed:", error);
|
|
1314
|
+
return { success: false, error: error instanceof Error ? error.message : "Failed to create webhook" };
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
rpcHandlerManager.registerHandler("deleteRemoteWebhook", async (data) => {
|
|
1318
|
+
logger.debug("deleteRemoteWebhook request:", { provider: data.provider, repoUrl: data.repoUrl });
|
|
1319
|
+
try {
|
|
1320
|
+
const parsed = parseRepoOwnerFromUrl(data.repoUrl);
|
|
1321
|
+
if (!parsed) return { success: false, error: "Invalid repo URL" };
|
|
1322
|
+
const { origin, owner, repo } = parsed;
|
|
1323
|
+
const isGitHubCom = origin === "https://github.com" || origin === "http://github.com";
|
|
1324
|
+
const baseUrl = data.provider === "github" && isGitHubCom ? "https://api.github.com" : data.provider === "github" ? `${origin}/api/v3` : `${origin}/api/v1`;
|
|
1325
|
+
const headers = { Authorization: `token ${data.apiToken}`, Accept: "application/json" };
|
|
1326
|
+
const hooksEndpoint = `${baseUrl}/repos/${owner}/${repo}/hooks`;
|
|
1327
|
+
const listRes = await fetch(hooksEndpoint, { headers });
|
|
1328
|
+
if (!listRes.ok) return { success: false, error: `List hooks failed: HTTP ${listRes.status}` };
|
|
1329
|
+
const hooks = await listRes.json();
|
|
1330
|
+
const existing = hooks.find((h) => h.config.url === data.webhookUrl);
|
|
1331
|
+
if (!existing) return { success: true, deleted: false };
|
|
1332
|
+
const deleteRes = await fetch(`${hooksEndpoint}/${existing.id}`, { method: "DELETE", headers });
|
|
1333
|
+
if (deleteRes.ok || deleteRes.status === 204) return { success: true, deleted: true };
|
|
1334
|
+
const errBody = await deleteRes.text().catch(() => "");
|
|
1335
|
+
return { success: false, error: `DELETE ${deleteRes.status}: ${errBody}` };
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
logger.debug("deleteRemoteWebhook failed:", error);
|
|
1338
|
+
return { success: false, error: error instanceof Error ? error.message : "Failed to delete webhook" };
|
|
1339
|
+
}
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
function parseHostEntry(host) {
|
|
1343
|
+
const match = host.match(/^(https?):\/\/(.+)/);
|
|
1344
|
+
if (match) return { bare: match[2].replace(/\/$/, ""), protocol: match[1] };
|
|
1345
|
+
return { bare: host.replace(/\/$/, ""), protocol: null };
|
|
1346
|
+
}
|
|
1347
|
+
function buildGitApiBase(provider, host) {
|
|
1348
|
+
const { bare, protocol } = parseHostEntry(host);
|
|
1349
|
+
const normalizedProtocol = protocol ?? "https";
|
|
1350
|
+
const origin = `${normalizedProtocol}://${bare}`;
|
|
1351
|
+
const isGitHubCom = bare === "github.com" || bare === "www.github.com";
|
|
1352
|
+
if (provider === "github") {
|
|
1353
|
+
return isGitHubCom ? "https://api.github.com" : `${origin}/api/v3`;
|
|
1354
|
+
}
|
|
1355
|
+
return `${origin}/api/v1`;
|
|
1356
|
+
}
|
|
1357
|
+
function buildGitApiHeaders(apiToken) {
|
|
1358
|
+
return {
|
|
1359
|
+
Authorization: `token ${apiToken}`,
|
|
1360
|
+
Accept: "application/json",
|
|
1361
|
+
"Content-Type": "application/json"
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
function normalizeRemoteRepoEntries(pageRepos) {
|
|
1365
|
+
return pageRepos.filter((repo) => !!repo.name).map((repo) => {
|
|
1366
|
+
const owner = repo.owner?.login || repo.owner?.username || "";
|
|
1367
|
+
const fullName = repo.full_name || (owner ? `${owner}/${repo.name}` : repo.name || "");
|
|
1368
|
+
const htmlUrl = repo.html_url || "";
|
|
1369
|
+
const cloneUrl = repo.clone_url || (htmlUrl ? `${htmlUrl}.git` : "");
|
|
1370
|
+
return { name: repo.name || fullName, fullName, cloneUrl, htmlUrl, private: !!repo.private, updatedAt: repo.updated_at ? Date.parse(repo.updated_at) : null };
|
|
1371
|
+
}).filter((repo) => !!repo.cloneUrl);
|
|
1372
|
+
}
|
|
1373
|
+
function parseCloneCoordinates(repoUrl) {
|
|
1374
|
+
const trimmed = repoUrl.trim();
|
|
1375
|
+
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
1376
|
+
if (sshMatch) return { host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] };
|
|
1377
|
+
const sshUrlMatch = trimmed.match(/^ssh:\/\/[^@]+@([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
1378
|
+
if (sshUrlMatch) return { host: sshUrlMatch[1], owner: sshUrlMatch[2], repo: sshUrlMatch[3] };
|
|
1379
|
+
const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
1380
|
+
if (httpsMatch) return { host: httpsMatch[1], owner: httpsMatch[2], repo: httpsMatch[3] };
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
function resolveCloneUrl(repoUrl, configuredHost) {
|
|
1384
|
+
if (/^https?:\/\//.test(repoUrl)) return repoUrl;
|
|
1385
|
+
const coords = parseCloneCoordinates(repoUrl);
|
|
1386
|
+
if (!coords) return repoUrl;
|
|
1387
|
+
const hostEntry = configuredHost ? parseHostEntry(configuredHost) : null;
|
|
1388
|
+
const protocol = hostEntry?.protocol ?? "https";
|
|
1389
|
+
const bareHost = hostEntry?.bare ?? coords.host;
|
|
1390
|
+
return `${protocol}://${bareHost}/${coords.owner}/${coords.repo}.git`;
|
|
1391
|
+
}
|
|
1392
|
+
function buildCloneAuthEnv(provider, apiToken) {
|
|
1393
|
+
if (!apiToken) return void 0;
|
|
1394
|
+
const username = provider === "github" ? "x-access-token" : "oauth2";
|
|
1395
|
+
const authValue = Buffer.from(`${username}:${apiToken}`).toString("base64");
|
|
1396
|
+
return {
|
|
1397
|
+
...process.env,
|
|
1398
|
+
GIT_CONFIG_COUNT: "1",
|
|
1399
|
+
GIT_CONFIG_KEY_0: "http.extraHeader",
|
|
1400
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${authValue}`
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
function parseRepoOwnerFromUrl(repoUrl) {
|
|
1404
|
+
try {
|
|
1405
|
+
const url = new URL(repoUrl);
|
|
1406
|
+
const parts = url.pathname.replace(/^\//, "").replace(/\.git$/, "").split("/");
|
|
1407
|
+
if (parts.length >= 2) return { origin: url.origin, owner: parts[0], repo: parts[1] };
|
|
1408
|
+
} catch {
|
|
1409
|
+
}
|
|
1410
|
+
return null;
|
|
1049
1411
|
}
|
|
1050
1412
|
|
|
1051
1413
|
class SessionClient extends EventEmitter {
|
|
@@ -1473,6 +1835,364 @@ function isNotFound(err) {
|
|
|
1473
1835
|
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1474
1836
|
}
|
|
1475
1837
|
|
|
1838
|
+
const pidToSession = /* @__PURE__ */ new Map();
|
|
1839
|
+
function trackSession(session) {
|
|
1840
|
+
pidToSession.set(session.pid, session);
|
|
1841
|
+
}
|
|
1842
|
+
function untrackSession(pid) {
|
|
1843
|
+
const session = pidToSession.get(pid);
|
|
1844
|
+
pidToSession.delete(pid);
|
|
1845
|
+
return session;
|
|
1846
|
+
}
|
|
1847
|
+
function getTrackedSession(pid) {
|
|
1848
|
+
return pidToSession.get(pid);
|
|
1849
|
+
}
|
|
1850
|
+
function getAllTrackedSessions() {
|
|
1851
|
+
return [...pidToSession.values()];
|
|
1852
|
+
}
|
|
1853
|
+
function getTrackedSessionCount() {
|
|
1854
|
+
return pidToSession.size;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
var trackedSessions = /*#__PURE__*/Object.freeze({
|
|
1858
|
+
__proto__: null,
|
|
1859
|
+
getAllTrackedSessions: getAllTrackedSessions,
|
|
1860
|
+
getTrackedSession: getTrackedSession,
|
|
1861
|
+
getTrackedSessionCount: getTrackedSessionCount,
|
|
1862
|
+
trackSession: trackSession,
|
|
1863
|
+
untrackSession: untrackSession
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
const execFileAsync = promisify(execFile);
|
|
1867
|
+
const SERVER_INTERNAL_SECRETS = /* @__PURE__ */ new Set([
|
|
1868
|
+
"DATABASE_URL",
|
|
1869
|
+
"REDIS_URL",
|
|
1870
|
+
"JWT_SECRET",
|
|
1871
|
+
"ENCRYPTION_KEY",
|
|
1872
|
+
"GITHUB_CLIENT_SECRET",
|
|
1873
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
1874
|
+
"AWS_ACCESS_KEY_ID",
|
|
1875
|
+
"AWS_SESSION_TOKEN",
|
|
1876
|
+
"STRIPE_SECRET_KEY",
|
|
1877
|
+
"SENDGRID_API_KEY",
|
|
1878
|
+
"S3_ACCESS_KEY",
|
|
1879
|
+
"S3_SECRET_KEY"
|
|
1880
|
+
]);
|
|
1881
|
+
function buildSpawnEnv(extra) {
|
|
1882
|
+
const base = {};
|
|
1883
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
1884
|
+
if (!SERVER_INTERNAL_SECRETS.has(key) && key !== "CLAUDECODE") {
|
|
1885
|
+
base[key] = value;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
if (extra) {
|
|
1889
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
1890
|
+
if (value !== void 0) {
|
|
1891
|
+
base[key] = value;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
return base;
|
|
1896
|
+
}
|
|
1897
|
+
let happyBinaryPath;
|
|
1898
|
+
async function findHappyBinary() {
|
|
1899
|
+
if (happyBinaryPath !== void 0) return happyBinaryPath;
|
|
1900
|
+
try {
|
|
1901
|
+
const { stdout } = await execFileAsync("which", ["happy"]);
|
|
1902
|
+
happyBinaryPath = stdout.trim() || null;
|
|
1903
|
+
} catch {
|
|
1904
|
+
happyBinaryPath = null;
|
|
1905
|
+
}
|
|
1906
|
+
return happyBinaryPath;
|
|
1907
|
+
}
|
|
1908
|
+
async function spawnSession(options) {
|
|
1909
|
+
const {
|
|
1910
|
+
directory,
|
|
1911
|
+
agent = "claude",
|
|
1912
|
+
approvedNewDirectoryCreation = false,
|
|
1913
|
+
happySessionId,
|
|
1914
|
+
automationContext,
|
|
1915
|
+
environmentVariables
|
|
1916
|
+
} = options;
|
|
1917
|
+
const happyPath = await findHappyBinary();
|
|
1918
|
+
if (!happyPath) {
|
|
1919
|
+
return {
|
|
1920
|
+
type: "error",
|
|
1921
|
+
errorMessage: "happy CLI not found. Install with: npm install -g @kmmao/happy-coder"
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
try {
|
|
1925
|
+
const dirStat = await stat(directory);
|
|
1926
|
+
if (!dirStat.isDirectory()) {
|
|
1927
|
+
return { type: "error", errorMessage: `Path exists but is not a directory: ${directory}` };
|
|
1928
|
+
}
|
|
1929
|
+
} catch {
|
|
1930
|
+
if (!approvedNewDirectoryCreation) {
|
|
1931
|
+
return { type: "requestToApproveDirectoryCreation", directory };
|
|
1932
|
+
}
|
|
1933
|
+
try {
|
|
1934
|
+
await mkdir(directory, { recursive: true });
|
|
1935
|
+
logger.debug(`[SPAWN] Created directory: ${directory}`);
|
|
1936
|
+
} catch (mkdirError) {
|
|
1937
|
+
return {
|
|
1938
|
+
type: "error",
|
|
1939
|
+
errorMessage: `Failed to create directory: ${mkdirError instanceof Error ? mkdirError.message : String(mkdirError)}`
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
const args = [
|
|
1944
|
+
agent,
|
|
1945
|
+
"--happy-starting-mode",
|
|
1946
|
+
"remote",
|
|
1947
|
+
"--started-by",
|
|
1948
|
+
"daemon"
|
|
1949
|
+
];
|
|
1950
|
+
if (happySessionId) {
|
|
1951
|
+
args.push("--happy-session-id", happySessionId);
|
|
1952
|
+
}
|
|
1953
|
+
const spawnEnv = buildSpawnEnv(environmentVariables);
|
|
1954
|
+
logger.debug(`[SPAWN] ${happyPath} ${args.join(" ")} in ${directory}`);
|
|
1955
|
+
let happyProcess;
|
|
1956
|
+
try {
|
|
1957
|
+
happyProcess = spawn(happyPath, args, {
|
|
1958
|
+
cwd: directory,
|
|
1959
|
+
detached: true,
|
|
1960
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1961
|
+
env: spawnEnv
|
|
1962
|
+
});
|
|
1963
|
+
} catch (spawnError) {
|
|
1964
|
+
return {
|
|
1965
|
+
type: "error",
|
|
1966
|
+
errorMessage: `Failed to spawn: ${spawnError instanceof Error ? spawnError.message : String(spawnError)}`
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
const pid = happyProcess.pid;
|
|
1970
|
+
if (!pid) {
|
|
1971
|
+
return { type: "error", errorMessage: "Spawned process has no PID" };
|
|
1972
|
+
}
|
|
1973
|
+
const tracked = {
|
|
1974
|
+
pid,
|
|
1975
|
+
directory,
|
|
1976
|
+
startedAt: Date.now(),
|
|
1977
|
+
childProcess: happyProcess,
|
|
1978
|
+
lastActivityAt: Date.now(),
|
|
1979
|
+
automationContext
|
|
1980
|
+
};
|
|
1981
|
+
trackSession(tracked);
|
|
1982
|
+
happyProcess.stdout?.on("data", () => {
|
|
1983
|
+
tracked.lastActivityAt = Date.now();
|
|
1984
|
+
});
|
|
1985
|
+
happyProcess.stderr?.on("data", () => {
|
|
1986
|
+
tracked.lastActivityAt = Date.now();
|
|
1987
|
+
});
|
|
1988
|
+
happyProcess.on("exit", (code, signal) => {
|
|
1989
|
+
logger.debug(`[SPAWN] Process ${pid} exited: code=${code} signal=${signal}`);
|
|
1990
|
+
const session = untrackSession(pid);
|
|
1991
|
+
if (session) {
|
|
1992
|
+
session.terminationReason = signal ? `signal:${signal}` : code !== 0 ? `exit:${code}` : "completed";
|
|
1993
|
+
}
|
|
1994
|
+
});
|
|
1995
|
+
happyProcess.unref();
|
|
1996
|
+
logger.debug(`[SPAWN] Spawned PID ${pid} in ${directory}`);
|
|
1997
|
+
return { type: "success", pid, directory };
|
|
1998
|
+
}
|
|
1999
|
+
function stopSession(pid) {
|
|
2000
|
+
const tracked = untrackSession(pid);
|
|
2001
|
+
if (!tracked) {
|
|
2002
|
+
try {
|
|
2003
|
+
process.kill(pid, "SIGTERM");
|
|
2004
|
+
return { stopped: true };
|
|
2005
|
+
} catch {
|
|
2006
|
+
return { stopped: false, error: `No tracked session with PID ${pid}` };
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
try {
|
|
2010
|
+
if (tracked.childProcess && !tracked.childProcess.killed) {
|
|
2011
|
+
tracked.childProcess.kill("SIGTERM");
|
|
2012
|
+
} else {
|
|
2013
|
+
process.kill(pid, "SIGTERM");
|
|
2014
|
+
}
|
|
2015
|
+
tracked.terminationReason = "user_stop";
|
|
2016
|
+
logger.debug(`[SPAWN] Stopped session PID ${pid}`);
|
|
2017
|
+
return { stopped: true };
|
|
2018
|
+
} catch (error) {
|
|
2019
|
+
return {
|
|
2020
|
+
stopped: false,
|
|
2021
|
+
error: error instanceof Error ? error.message : "Failed to stop session"
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
const PROMPT_DIR = join$1(tmpdir(), "happy", "agent-prompts");
|
|
2027
|
+
async function writePromptFile(prefix, content) {
|
|
2028
|
+
await mkdir(PROMPT_DIR, { recursive: true });
|
|
2029
|
+
const filename = `${prefix}-${Date.now()}.md`;
|
|
2030
|
+
const filepath = join$1(PROMPT_DIR, filename);
|
|
2031
|
+
await writeFile(filepath, content, "utf-8");
|
|
2032
|
+
return filepath;
|
|
2033
|
+
}
|
|
2034
|
+
function buildWebhookPrompt(data) {
|
|
2035
|
+
return [
|
|
2036
|
+
`# Issue #${data.issueNumber}: ${data.issueTitle}`,
|
|
2037
|
+
"",
|
|
2038
|
+
`Author: ${data.issueAuthor}`,
|
|
2039
|
+
`Labels: ${data.issueLabels.join(", ") || "none"}`,
|
|
2040
|
+
`URL: ${data.issueUrl}`,
|
|
2041
|
+
"",
|
|
2042
|
+
data.issueBody
|
|
2043
|
+
].join("\n");
|
|
2044
|
+
}
|
|
2045
|
+
function buildSupervisorPrompt(data) {
|
|
2046
|
+
const parts = [
|
|
2047
|
+
`# Supervisor Run: ${data.runId}`,
|
|
2048
|
+
"",
|
|
2049
|
+
`Trigger: ${data.trigger}`,
|
|
2050
|
+
`Project: ${data.projectId}`
|
|
2051
|
+
];
|
|
2052
|
+
if (data.mode) parts.push(`Mode: ${data.mode}`);
|
|
2053
|
+
if (data.dimensions?.length) parts.push(`Dimensions: ${data.dimensions.join(", ")}`);
|
|
2054
|
+
if (data.changedFiles?.length) {
|
|
2055
|
+
parts.push("", "## Changed Files", ...data.changedFiles.map((f) => `- ${f}`));
|
|
2056
|
+
}
|
|
2057
|
+
if (data.customRules) parts.push("", "## Custom Rules", data.customRules);
|
|
2058
|
+
return parts.join("\n");
|
|
2059
|
+
}
|
|
2060
|
+
function mapTaskPriority(priority) {
|
|
2061
|
+
if (priority === "urgent" || priority === "high") return "urgent";
|
|
2062
|
+
if (priority === "background" || priority === "low") return "background";
|
|
2063
|
+
return "user";
|
|
2064
|
+
}
|
|
2065
|
+
function handleWebhookTrigger(data, client, serverUrl, authToken, scheduler) {
|
|
2066
|
+
logger.debug(`[TRIGGER] Webhook: issue #${data.issueNumber} in ${data.repoPath}`);
|
|
2067
|
+
const { deduped } = scheduler.enqueue({
|
|
2068
|
+
kind: "webhook",
|
|
2069
|
+
dedupeKey: `webhook:${data.webhookEventId}`,
|
|
2070
|
+
priority: "background",
|
|
2071
|
+
run: async (jobId) => {
|
|
2072
|
+
client.emitWebhookStatus({ webhookEventId: data.webhookEventId, status: "dispatched" });
|
|
2073
|
+
const promptFile = await writePromptFile("webhook", buildWebhookPrompt(data));
|
|
2074
|
+
const result = await spawnSession({
|
|
2075
|
+
directory: data.repoPath,
|
|
2076
|
+
approvedNewDirectoryCreation: false,
|
|
2077
|
+
automationContext: { kind: "webhook", trigger: `issue#${data.issueNumber}` },
|
|
2078
|
+
environmentVariables: {
|
|
2079
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2080
|
+
HAPPY_WEBHOOK_EVENT_ID: data.webhookEventId,
|
|
2081
|
+
HAPPY_WEBHOOK_ISSUE_URL: data.issueUrl,
|
|
2082
|
+
HAPPY_SERVER_URL: serverUrl,
|
|
2083
|
+
HAPPY_AUTH_TOKEN: authToken
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
if (result.type !== "success") {
|
|
2087
|
+
const msg = result.type === "error" ? result.errorMessage : "Directory creation not approved";
|
|
2088
|
+
client.emitWebhookStatus({ webhookEventId: data.webhookEventId, status: "failed", errorMessage: msg });
|
|
2089
|
+
throw new Error(msg);
|
|
2090
|
+
}
|
|
2091
|
+
const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
|
|
2092
|
+
if (tracked?.childProcess) {
|
|
2093
|
+
tracked.childProcess.on("exit", (code) => {
|
|
2094
|
+
if (code === 0) scheduler.markCompleted(jobId);
|
|
2095
|
+
else scheduler.markFailed(jobId, `exit code ${code}`);
|
|
2096
|
+
client.emitWebhookStatus({ webhookEventId: data.webhookEventId, status: code === 0 ? "completed" : "failed" });
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
return { pid: result.pid };
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
if (deduped) {
|
|
2103
|
+
logger.debug(`[TRIGGER] Webhook deduped: ${data.webhookEventId}`);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
function handleSupervisorTrigger(data, client, serverUrl, authToken, scheduler) {
|
|
2107
|
+
logger.debug(`[TRIGGER] Supervisor: run ${data.runId} in ${data.repoPath}`);
|
|
2108
|
+
const { deduped } = scheduler.enqueue({
|
|
2109
|
+
kind: "supervisor",
|
|
2110
|
+
dedupeKey: `supervisor:${data.runId}`,
|
|
2111
|
+
priority: "background",
|
|
2112
|
+
run: async (jobId) => {
|
|
2113
|
+
client.emitSupervisorRunStatus({ runId: data.runId, projectId: data.projectId, status: "running" });
|
|
2114
|
+
const promptFile = await writePromptFile("supervisor", buildSupervisorPrompt(data));
|
|
2115
|
+
const result = await spawnSession({
|
|
2116
|
+
directory: data.repoPath,
|
|
2117
|
+
approvedNewDirectoryCreation: false,
|
|
2118
|
+
automationContext: { kind: "supervisor", trigger: data.trigger, projectId: data.projectId, runId: data.runId },
|
|
2119
|
+
environmentVariables: {
|
|
2120
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2121
|
+
HAPPY_SUPERVISOR_RUN_ID: data.runId,
|
|
2122
|
+
HAPPY_SUPERVISOR_PROJECT_ID: data.projectId,
|
|
2123
|
+
HAPPY_SUPERVISOR_SERVER_URL: serverUrl,
|
|
2124
|
+
HAPPY_SUPERVISOR_AUTH_TOKEN: authToken
|
|
2125
|
+
}
|
|
2126
|
+
});
|
|
2127
|
+
if (result.type !== "success") {
|
|
2128
|
+
const msg = result.type === "error" ? result.errorMessage : "Directory creation not approved";
|
|
2129
|
+
client.emitSupervisorRunStatus({ runId: data.runId, projectId: data.projectId, status: "failed", errorMessage: msg });
|
|
2130
|
+
throw new Error(msg);
|
|
2131
|
+
}
|
|
2132
|
+
const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
|
|
2133
|
+
if (tracked?.childProcess) {
|
|
2134
|
+
tracked.childProcess.on("exit", (code) => {
|
|
2135
|
+
const status = code === 0 ? "completed" : "failed";
|
|
2136
|
+
if (code === 0) scheduler.markCompleted(jobId);
|
|
2137
|
+
else scheduler.markFailed(jobId, `exit code ${code}`);
|
|
2138
|
+
client.emitSupervisorRunStatus({ runId: data.runId, projectId: data.projectId, status });
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
return { pid: result.pid };
|
|
2142
|
+
}
|
|
2143
|
+
});
|
|
2144
|
+
if (deduped) {
|
|
2145
|
+
logger.debug(`[TRIGGER] Supervisor deduped: ${data.runId}`);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
function handleTaskTrigger(data, serverUrl, _authToken, scheduler) {
|
|
2149
|
+
logger.debug(`[TRIGGER] Task: ${data.taskId} in ${data.directory}`);
|
|
2150
|
+
const { deduped } = scheduler.enqueue({
|
|
2151
|
+
kind: "task",
|
|
2152
|
+
dedupeKey: `task:${data.taskId}`,
|
|
2153
|
+
priority: mapTaskPriority(data.priority),
|
|
2154
|
+
run: async (jobId) => {
|
|
2155
|
+
const promptFile = await writePromptFile("task", data.prompt);
|
|
2156
|
+
const skillEnv = {};
|
|
2157
|
+
if (data.skillContents?.length) {
|
|
2158
|
+
skillEnv.HAPPY_TASK_SKILL_COUNT = String(data.skillContents.length);
|
|
2159
|
+
for (let i = 0; i < data.skillContents.length; i++) {
|
|
2160
|
+
skillEnv[`HAPPY_TASK_SKILL_${i}_NAME`] = data.skillContents[i].name;
|
|
2161
|
+
skillEnv[`HAPPY_TASK_SKILL_${i}_CONTENT`] = data.skillContents[i].content;
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
const result = await spawnSession({
|
|
2165
|
+
directory: data.directory,
|
|
2166
|
+
approvedNewDirectoryCreation: true,
|
|
2167
|
+
automationContext: { kind: "task", trigger: "task-dispatch", projectId: data.projectId },
|
|
2168
|
+
environmentVariables: {
|
|
2169
|
+
HAPPY_INITIAL_PROMPT_FILE: promptFile,
|
|
2170
|
+
HAPPY_TASK_ID: data.taskId,
|
|
2171
|
+
HAPPY_TASK_PRIORITY: data.priority,
|
|
2172
|
+
HAPPY_TASK_SERVER_URL: serverUrl,
|
|
2173
|
+
HAPPY_TASK_RESULT_TOKEN: data.resultToken ?? "",
|
|
2174
|
+
HAPPY_TASK_REPORT_URL: `${serverUrl}/v1/tasks/${data.taskId}/result`,
|
|
2175
|
+
...skillEnv
|
|
2176
|
+
}
|
|
2177
|
+
});
|
|
2178
|
+
if (result.type !== "success") {
|
|
2179
|
+
throw new Error(result.type === "error" ? result.errorMessage : "Directory creation not approved");
|
|
2180
|
+
}
|
|
2181
|
+
const tracked = (await Promise.resolve().then(function () { return trackedSessions; })).getTrackedSession(result.pid);
|
|
2182
|
+
if (tracked?.childProcess) {
|
|
2183
|
+
tracked.childProcess.on("exit", (code) => {
|
|
2184
|
+
if (code === 0) scheduler.markCompleted(jobId);
|
|
2185
|
+
else scheduler.markFailed(jobId, `exit code ${code}`);
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
return { pid: result.pid };
|
|
2189
|
+
}
|
|
2190
|
+
});
|
|
2191
|
+
if (deduped) {
|
|
2192
|
+
logger.debug(`[TRIGGER] Task deduped: ${data.taskId}`);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
1476
2196
|
const TAILSCALE_REFRESH_MS = 5 * 60 * 1e3;
|
|
1477
2197
|
class MachineClient {
|
|
1478
2198
|
machine;
|
|
@@ -1484,11 +2204,18 @@ class MachineClient {
|
|
|
1484
2204
|
tunnelManager = null;
|
|
1485
2205
|
token;
|
|
1486
2206
|
serverUrl;
|
|
2207
|
+
agentVersion;
|
|
2208
|
+
startTime = Date.now();
|
|
1487
2209
|
onEphemeral;
|
|
2210
|
+
automationEnabled = false;
|
|
2211
|
+
automationServerUrl = "";
|
|
2212
|
+
automationAuthToken = "";
|
|
2213
|
+
scheduler = null;
|
|
1488
2214
|
constructor(opts) {
|
|
1489
2215
|
this.token = opts.token;
|
|
1490
2216
|
this.machine = opts.machine;
|
|
1491
2217
|
this.serverUrl = opts.serverUrl;
|
|
2218
|
+
this.agentVersion = opts.agentVersion ?? "unknown";
|
|
1492
2219
|
this.onEphemeral = opts.onEphemeral;
|
|
1493
2220
|
this.rpcHandlerManager = new RpcHandlerManager({
|
|
1494
2221
|
scopePrefix: opts.machine.id,
|
|
@@ -1498,6 +2225,32 @@ class MachineClient {
|
|
|
1498
2225
|
});
|
|
1499
2226
|
const workDir = opts.workingDirectory ?? process.cwd();
|
|
1500
2227
|
registerAgentHandlers(this.rpcHandlerManager, workDir, opts.machine.id);
|
|
2228
|
+
this.registerMachineHandlers();
|
|
2229
|
+
}
|
|
2230
|
+
// -----------------------------------------------------------------------
|
|
2231
|
+
// Machine-scoped RPC handlers
|
|
2232
|
+
// -----------------------------------------------------------------------
|
|
2233
|
+
registerMachineHandlers() {
|
|
2234
|
+
this.rpcHandlerManager.registerHandler(
|
|
2235
|
+
"spawn-happy-session",
|
|
2236
|
+
async (data) => {
|
|
2237
|
+
logger.debug("[MACHINE] spawn-happy-session request:", data.directory);
|
|
2238
|
+
return spawnSession(data);
|
|
2239
|
+
}
|
|
2240
|
+
);
|
|
2241
|
+
this.rpcHandlerManager.registerHandler("stop-session", async (data) => {
|
|
2242
|
+
logger.debug("[MACHINE] stop-session request:", data.pid);
|
|
2243
|
+
return stopSession(data.pid);
|
|
2244
|
+
});
|
|
2245
|
+
this.rpcHandlerManager.registerHandler("list-tracked-sessions", async () => {
|
|
2246
|
+
const sessions = getAllTrackedSessions().map((s) => ({
|
|
2247
|
+
pid: s.pid,
|
|
2248
|
+
directory: s.directory,
|
|
2249
|
+
startedAt: s.startedAt,
|
|
2250
|
+
happySessionId: s.happySessionId
|
|
2251
|
+
}));
|
|
2252
|
+
return { sessions };
|
|
2253
|
+
});
|
|
1501
2254
|
}
|
|
1502
2255
|
// -----------------------------------------------------------------------
|
|
1503
2256
|
// Connection
|
|
@@ -1524,6 +2277,8 @@ class MachineClient {
|
|
|
1524
2277
|
status: "running",
|
|
1525
2278
|
pid: process.pid,
|
|
1526
2279
|
startedAt: Date.now(),
|
|
2280
|
+
startTime: this.startTime,
|
|
2281
|
+
startedWithCliVersion: this.agentVersion,
|
|
1527
2282
|
tailscale: this.lastTailscaleInfo ?? state?.tailscale
|
|
1528
2283
|
}));
|
|
1529
2284
|
this.startKeepAlive();
|
|
@@ -1563,12 +2318,12 @@ class MachineClient {
|
|
|
1563
2318
|
}
|
|
1564
2319
|
}
|
|
1565
2320
|
});
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
}
|
|
2321
|
+
this.socket.on("ephemeral", (data) => {
|
|
2322
|
+
const event = data;
|
|
2323
|
+
logger.debug("[MACHINE] Received ephemeral event:", event.type);
|
|
2324
|
+
this.onEphemeral?.(event);
|
|
2325
|
+
this.handleAutomationEvent(event);
|
|
2326
|
+
});
|
|
1572
2327
|
this.socket.on("connect_error", (error) => {
|
|
1573
2328
|
logger.debug(`[MACHINE] Connection error: ${error.message}`);
|
|
1574
2329
|
});
|
|
@@ -1645,6 +2400,79 @@ class MachineClient {
|
|
|
1645
2400
|
}, { maxRetries: 3, label: "updateDaemonState" });
|
|
1646
2401
|
}
|
|
1647
2402
|
// -----------------------------------------------------------------------
|
|
2403
|
+
// Socket event emitters
|
|
2404
|
+
// -----------------------------------------------------------------------
|
|
2405
|
+
/** Emit a session lifecycle event. */
|
|
2406
|
+
emitSessionEvent(sessionId, eventType, summary, detail) {
|
|
2407
|
+
this.socket?.emit("session-event", { sessionId, eventType, summary, detail });
|
|
2408
|
+
}
|
|
2409
|
+
/** Report webhook processing status. */
|
|
2410
|
+
emitWebhookStatus(data) {
|
|
2411
|
+
this.socket?.emit("webhook-status", data);
|
|
2412
|
+
}
|
|
2413
|
+
/** Report supervisor run status. */
|
|
2414
|
+
emitSupervisorRunStatus(data) {
|
|
2415
|
+
this.socket?.emit("supervisor-run-status", data);
|
|
2416
|
+
}
|
|
2417
|
+
/** Submit a knowledge entry from a session. */
|
|
2418
|
+
emitSubmitKnowledge(sid, entry) {
|
|
2419
|
+
this.socket?.emit("submit-knowledge", { sid, entry });
|
|
2420
|
+
}
|
|
2421
|
+
/** Fetch knowledge for a session. */
|
|
2422
|
+
emitFetchKnowledge(sid, mode, contextHints, callback) {
|
|
2423
|
+
this.socket?.emit("fetch-knowledge", { sid, mode, contextHints }, callback);
|
|
2424
|
+
}
|
|
2425
|
+
/** Stream task log chunk. */
|
|
2426
|
+
emitTaskLog(sid, taskId, outputFile, chunk, offset) {
|
|
2427
|
+
this.socket?.emit("task-log", { sid, taskId, outputFile, chunk, offset });
|
|
2428
|
+
}
|
|
2429
|
+
// -----------------------------------------------------------------------
|
|
2430
|
+
// Automation triggers
|
|
2431
|
+
// -----------------------------------------------------------------------
|
|
2432
|
+
/**
|
|
2433
|
+
* Enable automation handling — agent will process webhook, supervisor,
|
|
2434
|
+
* and task triggers from the server by spawning Happy CLI sessions.
|
|
2435
|
+
*/
|
|
2436
|
+
enableAutomation(serverUrl, authToken, scheduler) {
|
|
2437
|
+
this.automationEnabled = true;
|
|
2438
|
+
this.automationServerUrl = serverUrl;
|
|
2439
|
+
this.automationAuthToken = authToken;
|
|
2440
|
+
this.scheduler = scheduler;
|
|
2441
|
+
logger.debug("[MACHINE] Automation enabled");
|
|
2442
|
+
}
|
|
2443
|
+
/** Internal dispatch for ephemeral events that need automation handling. */
|
|
2444
|
+
handleAutomationEvent(event) {
|
|
2445
|
+
if (!this.automationEnabled || !this.scheduler) return;
|
|
2446
|
+
switch (event.type) {
|
|
2447
|
+
case "webhook-trigger":
|
|
2448
|
+
handleWebhookTrigger(
|
|
2449
|
+
event,
|
|
2450
|
+
this,
|
|
2451
|
+
this.automationServerUrl,
|
|
2452
|
+
this.automationAuthToken,
|
|
2453
|
+
this.scheduler
|
|
2454
|
+
);
|
|
2455
|
+
break;
|
|
2456
|
+
case "supervisor-trigger":
|
|
2457
|
+
handleSupervisorTrigger(
|
|
2458
|
+
event,
|
|
2459
|
+
this,
|
|
2460
|
+
this.automationServerUrl,
|
|
2461
|
+
this.automationAuthToken,
|
|
2462
|
+
this.scheduler
|
|
2463
|
+
);
|
|
2464
|
+
break;
|
|
2465
|
+
case "task-trigger":
|
|
2466
|
+
handleTaskTrigger(
|
|
2467
|
+
event,
|
|
2468
|
+
this.automationServerUrl,
|
|
2469
|
+
this.automationAuthToken,
|
|
2470
|
+
this.scheduler
|
|
2471
|
+
);
|
|
2472
|
+
break;
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
// -----------------------------------------------------------------------
|
|
1648
2476
|
// Lifecycle
|
|
1649
2477
|
// -----------------------------------------------------------------------
|
|
1650
2478
|
/** Seed initial Tailscale info detected before connect. */
|
|
@@ -1719,6 +2547,306 @@ function tailscaleChanged(prev, next) {
|
|
|
1719
2547
|
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);
|
|
1720
2548
|
}
|
|
1721
2549
|
|
|
2550
|
+
const PRIORITY_ORDER = {
|
|
2551
|
+
urgent: 0,
|
|
2552
|
+
user: 1,
|
|
2553
|
+
background: 2
|
|
2554
|
+
};
|
|
2555
|
+
const ACTIVE_STATUSES = /* @__PURE__ */ new Set(["queued", "dispatching", "running"]);
|
|
2556
|
+
class AutomationScheduler {
|
|
2557
|
+
maxConcurrentJobs;
|
|
2558
|
+
retryDelayMs;
|
|
2559
|
+
defaultMaxAttempts;
|
|
2560
|
+
maxRecentCompletions;
|
|
2561
|
+
/** Active jobs indexed by id. */
|
|
2562
|
+
jobs = /* @__PURE__ */ new Map();
|
|
2563
|
+
/** dedupeKey → jobId for fast dedup lookups. */
|
|
2564
|
+
dedupeIndex = /* @__PURE__ */ new Map();
|
|
2565
|
+
/** Ring buffer for completed/failed jobs. */
|
|
2566
|
+
recentCompletions = [];
|
|
2567
|
+
pumpTimer = null;
|
|
2568
|
+
pumping = false;
|
|
2569
|
+
constructor(options) {
|
|
2570
|
+
this.maxConcurrentJobs = options?.maxConcurrentJobs ?? 2;
|
|
2571
|
+
this.retryDelayMs = options?.retryDelayMs ?? 5e3;
|
|
2572
|
+
this.defaultMaxAttempts = options?.maxAttempts ?? 3;
|
|
2573
|
+
this.maxRecentCompletions = options?.maxRecentCompletions ?? 50;
|
|
2574
|
+
this.pumpTimer = setInterval(() => this.pump(), 1e3);
|
|
2575
|
+
}
|
|
2576
|
+
// -----------------------------------------------------------------------
|
|
2577
|
+
// Public API
|
|
2578
|
+
// -----------------------------------------------------------------------
|
|
2579
|
+
enqueue(opts) {
|
|
2580
|
+
const existingId = this.dedupeIndex.get(opts.dedupeKey);
|
|
2581
|
+
if (existingId) {
|
|
2582
|
+
const existing = this.jobs.get(existingId);
|
|
2583
|
+
if (existing && ACTIVE_STATUSES.has(existing.status)) {
|
|
2584
|
+
logger.debug(`[SCHEDULER] Deduped: ${opts.dedupeKey} (job ${existingId} is ${existing.status})`);
|
|
2585
|
+
return { job: existing, deduped: true };
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
const job = {
|
|
2589
|
+
id: randomUUID(),
|
|
2590
|
+
kind: opts.kind,
|
|
2591
|
+
dedupeKey: opts.dedupeKey,
|
|
2592
|
+
priority: opts.priority,
|
|
2593
|
+
status: "queued",
|
|
2594
|
+
attempt: 0,
|
|
2595
|
+
maxAttempts: this.defaultMaxAttempts,
|
|
2596
|
+
createdAt: Date.now(),
|
|
2597
|
+
updatedAt: Date.now(),
|
|
2598
|
+
nextRunAt: Date.now(),
|
|
2599
|
+
run: opts.run
|
|
2600
|
+
};
|
|
2601
|
+
this.jobs.set(job.id, job);
|
|
2602
|
+
this.dedupeIndex.set(job.dedupeKey, job.id);
|
|
2603
|
+
logger.debug(`[SCHEDULER] Enqueued: ${job.kind} ${job.dedupeKey} (${job.priority}) id=${job.id}`);
|
|
2604
|
+
this.pump();
|
|
2605
|
+
return { job, deduped: false };
|
|
2606
|
+
}
|
|
2607
|
+
markCompleted(jobId) {
|
|
2608
|
+
const job = this.jobs.get(jobId);
|
|
2609
|
+
if (!job || job.status === "completed" || job.status === "failed") return;
|
|
2610
|
+
job.status = "completed";
|
|
2611
|
+
job.updatedAt = Date.now();
|
|
2612
|
+
logger.debug(`[SCHEDULER] Completed: ${job.kind} ${job.dedupeKey} id=${jobId}`);
|
|
2613
|
+
this.finalize(job);
|
|
2614
|
+
}
|
|
2615
|
+
markFailed(jobId, error) {
|
|
2616
|
+
const job = this.jobs.get(jobId);
|
|
2617
|
+
if (!job || job.status === "completed" || job.status === "failed") return;
|
|
2618
|
+
job.errorMessage = error;
|
|
2619
|
+
job.updatedAt = Date.now();
|
|
2620
|
+
if (job.attempt < job.maxAttempts) {
|
|
2621
|
+
job.status = "queued";
|
|
2622
|
+
job.nextRunAt = Date.now() + job.attempt * this.retryDelayMs;
|
|
2623
|
+
logger.debug(`[SCHEDULER] Retry queued: ${job.dedupeKey} attempt=${job.attempt}/${job.maxAttempts} nextRunAt=+${job.attempt * this.retryDelayMs}ms`);
|
|
2624
|
+
this.pump();
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
job.status = "failed";
|
|
2628
|
+
logger.debug(`[SCHEDULER] Failed (exhausted): ${job.kind} ${job.dedupeKey} id=${jobId}: ${error}`);
|
|
2629
|
+
this.finalize(job);
|
|
2630
|
+
}
|
|
2631
|
+
getStatus() {
|
|
2632
|
+
let queueLength = 0;
|
|
2633
|
+
let runningCount = 0;
|
|
2634
|
+
for (const job of this.jobs.values()) {
|
|
2635
|
+
if (job.status === "queued") queueLength++;
|
|
2636
|
+
else if (job.status === "dispatching" || job.status === "running") runningCount++;
|
|
2637
|
+
}
|
|
2638
|
+
return {
|
|
2639
|
+
queueLength,
|
|
2640
|
+
runningCount,
|
|
2641
|
+
recentCompletions: [...this.recentCompletions]
|
|
2642
|
+
};
|
|
2643
|
+
}
|
|
2644
|
+
shutdown() {
|
|
2645
|
+
if (this.pumpTimer) {
|
|
2646
|
+
clearInterval(this.pumpTimer);
|
|
2647
|
+
this.pumpTimer = null;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
// -----------------------------------------------------------------------
|
|
2651
|
+
// Internal
|
|
2652
|
+
// -----------------------------------------------------------------------
|
|
2653
|
+
pump() {
|
|
2654
|
+
if (this.pumping) return;
|
|
2655
|
+
this.pumping = true;
|
|
2656
|
+
try {
|
|
2657
|
+
const now = Date.now();
|
|
2658
|
+
let runningCount = 0;
|
|
2659
|
+
for (const job of this.jobs.values()) {
|
|
2660
|
+
if (job.status === "dispatching" || job.status === "running") runningCount++;
|
|
2661
|
+
}
|
|
2662
|
+
if (runningCount >= this.maxConcurrentJobs) return;
|
|
2663
|
+
const ready = [];
|
|
2664
|
+
for (const job of this.jobs.values()) {
|
|
2665
|
+
if (job.status === "queued" && job.nextRunAt <= now) {
|
|
2666
|
+
ready.push(job);
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
ready.sort((a, b) => {
|
|
2670
|
+
const pDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority];
|
|
2671
|
+
if (pDiff !== 0) return pDiff;
|
|
2672
|
+
return a.createdAt - b.createdAt;
|
|
2673
|
+
});
|
|
2674
|
+
const slotsAvailable = this.maxConcurrentJobs - runningCount;
|
|
2675
|
+
const toDispatch = ready.slice(0, slotsAvailable);
|
|
2676
|
+
for (const job of toDispatch) {
|
|
2677
|
+
this.dispatch(job);
|
|
2678
|
+
}
|
|
2679
|
+
} finally {
|
|
2680
|
+
this.pumping = false;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
dispatch(job) {
|
|
2684
|
+
job.status = "dispatching";
|
|
2685
|
+
job.attempt++;
|
|
2686
|
+
job.updatedAt = Date.now();
|
|
2687
|
+
logger.debug(`[SCHEDULER] Dispatching: ${job.kind} ${job.dedupeKey} attempt=${job.attempt}`);
|
|
2688
|
+
job.run(job.id).then(({ pid }) => {
|
|
2689
|
+
if (job.status === "dispatching") {
|
|
2690
|
+
job.status = "running";
|
|
2691
|
+
job.pid = pid;
|
|
2692
|
+
job.updatedAt = Date.now();
|
|
2693
|
+
logger.debug(`[SCHEDULER] Running: ${job.dedupeKey} pid=${pid}`);
|
|
2694
|
+
}
|
|
2695
|
+
}).catch((error) => {
|
|
2696
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2697
|
+
logger.debug(`[SCHEDULER] Dispatch failed: ${job.dedupeKey}: ${msg}`);
|
|
2698
|
+
if (job.status === "dispatching") {
|
|
2699
|
+
this.markFailed(job.id, msg);
|
|
2700
|
+
}
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
finalize(job) {
|
|
2704
|
+
this.jobs.delete(job.id);
|
|
2705
|
+
if (this.dedupeIndex.get(job.dedupeKey) === job.id) {
|
|
2706
|
+
this.dedupeIndex.delete(job.dedupeKey);
|
|
2707
|
+
}
|
|
2708
|
+
this.recentCompletions.push({
|
|
2709
|
+
id: job.id,
|
|
2710
|
+
kind: job.kind,
|
|
2711
|
+
dedupeKey: job.dedupeKey,
|
|
2712
|
+
status: job.status,
|
|
2713
|
+
completedAt: job.updatedAt,
|
|
2714
|
+
errorMessage: job.errorMessage
|
|
2715
|
+
});
|
|
2716
|
+
while (this.recentCompletions.length > this.maxRecentCompletions) {
|
|
2717
|
+
this.recentCompletions.shift();
|
|
2718
|
+
}
|
|
2719
|
+
this.pump();
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
function pidFilePath(homeDir) {
|
|
2724
|
+
return join(homeDir, "agent-daemon.pid");
|
|
2725
|
+
}
|
|
2726
|
+
function writePidFile(homeDir, pid) {
|
|
2727
|
+
mkdirSync(homeDir, { recursive: true });
|
|
2728
|
+
writeFileSync(pidFilePath(homeDir), String(pid), "utf-8");
|
|
2729
|
+
}
|
|
2730
|
+
function readPidFile(homeDir) {
|
|
2731
|
+
try {
|
|
2732
|
+
const raw = readFileSync(pidFilePath(homeDir), "utf-8").trim();
|
|
2733
|
+
const pid = parseInt(raw, 10);
|
|
2734
|
+
return isNaN(pid) ? null : pid;
|
|
2735
|
+
} catch {
|
|
2736
|
+
return null;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
function removePidFile(homeDir) {
|
|
2740
|
+
try {
|
|
2741
|
+
unlinkSync(pidFilePath(homeDir));
|
|
2742
|
+
} catch {
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
function isProcessRunning(pid) {
|
|
2746
|
+
try {
|
|
2747
|
+
process.kill(pid, 0);
|
|
2748
|
+
return true;
|
|
2749
|
+
} catch {
|
|
2750
|
+
return false;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
async function startDaemon(options) {
|
|
2754
|
+
const config = loadConfig();
|
|
2755
|
+
const creds = requireCredentials(config);
|
|
2756
|
+
const existingPid = readPidFile(config.homeDir);
|
|
2757
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
2758
|
+
console.log(`Daemon already running (PID ${existingPid})`);
|
|
2759
|
+
process.exitCode = 1;
|
|
2760
|
+
return;
|
|
2761
|
+
}
|
|
2762
|
+
if (existingPid) {
|
|
2763
|
+
removePidFile(config.homeDir);
|
|
2764
|
+
}
|
|
2765
|
+
const workDir = options.directory ?? process.cwd();
|
|
2766
|
+
console.log(`Starting agent daemon in ${workDir}...`);
|
|
2767
|
+
const metadata = {
|
|
2768
|
+
host: hostname(),
|
|
2769
|
+
platform: process.platform,
|
|
2770
|
+
happyCliVersion: version,
|
|
2771
|
+
homeDir: config.homeDir,
|
|
2772
|
+
happyHomeDir: config.homeDir,
|
|
2773
|
+
happyLibDir: config.homeDir
|
|
2774
|
+
};
|
|
2775
|
+
const machine = await getOrCreateMachine(config, creds, metadata);
|
|
2776
|
+
console.log(`Machine: ${machine.id} (${machine.metadata.host})`);
|
|
2777
|
+
const tailscaleInfo = await detectTailscale();
|
|
2778
|
+
const serves = tailscaleInfo.status === "connected" ? await detectTailscaleServe() : [];
|
|
2779
|
+
const fullTailscale = { ...tailscaleInfo, serves };
|
|
2780
|
+
if (tailscaleInfo.status === "connected") {
|
|
2781
|
+
console.log(`Tailscale: ${tailscaleInfo.hostname} (${tailscaleInfo.ipv4})`);
|
|
2782
|
+
}
|
|
2783
|
+
const client = new MachineClient({
|
|
2784
|
+
token: creds.token,
|
|
2785
|
+
machine,
|
|
2786
|
+
serverUrl: config.serverUrl,
|
|
2787
|
+
agentVersion: version,
|
|
2788
|
+
workingDirectory: workDir
|
|
2789
|
+
});
|
|
2790
|
+
const scheduler = new AutomationScheduler({ maxConcurrentJobs: 2 });
|
|
2791
|
+
client.setTailscaleInfo(fullTailscale);
|
|
2792
|
+
client.enableAutomation(config.serverUrl, creds.token, scheduler);
|
|
2793
|
+
client.connect();
|
|
2794
|
+
writePidFile(config.homeDir, process.pid);
|
|
2795
|
+
console.log(`Daemon started (PID ${process.pid})`);
|
|
2796
|
+
let shuttingDown = false;
|
|
2797
|
+
const shutdown = (signal) => {
|
|
2798
|
+
if (shuttingDown) return;
|
|
2799
|
+
shuttingDown = true;
|
|
2800
|
+
logger.debug(`[DAEMON] Received ${signal}, shutting down...`);
|
|
2801
|
+
console.log(`
|
|
2802
|
+
Received ${signal}, shutting down...`);
|
|
2803
|
+
scheduler.shutdown();
|
|
2804
|
+
client.shutdown();
|
|
2805
|
+
removePidFile(config.homeDir);
|
|
2806
|
+
process.exit(0);
|
|
2807
|
+
};
|
|
2808
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
2809
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
2810
|
+
await new Promise(() => {
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
function stopDaemon() {
|
|
2814
|
+
const config = loadConfig();
|
|
2815
|
+
const pid = readPidFile(config.homeDir);
|
|
2816
|
+
if (!pid) {
|
|
2817
|
+
console.log("No daemon PID file found.");
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
if (!isProcessRunning(pid)) {
|
|
2821
|
+
console.log(`Daemon PID ${pid} is not running (stale PID file). Cleaning up.`);
|
|
2822
|
+
removePidFile(config.homeDir);
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
try {
|
|
2826
|
+
process.kill(pid, "SIGTERM");
|
|
2827
|
+
console.log(`Sent SIGTERM to daemon (PID ${pid})`);
|
|
2828
|
+
removePidFile(config.homeDir);
|
|
2829
|
+
} catch (error) {
|
|
2830
|
+
console.error(`Failed to stop daemon: ${error instanceof Error ? error.message : String(error)}`);
|
|
2831
|
+
process.exitCode = 1;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
function daemonStatus() {
|
|
2835
|
+
const config = loadConfig();
|
|
2836
|
+
const pid = readPidFile(config.homeDir);
|
|
2837
|
+
if (!pid) {
|
|
2838
|
+
console.log("Daemon is not running (no PID file).");
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
if (isProcessRunning(pid)) {
|
|
2842
|
+
console.log(`Daemon is running (PID ${pid})`);
|
|
2843
|
+
console.log(`PID file: ${pidFilePath(config.homeDir)}`);
|
|
2844
|
+
} else {
|
|
2845
|
+
console.log(`Daemon PID ${pid} is not running (stale PID file).`);
|
|
2846
|
+
console.log(`Run \`happy-agent daemon stop\` to clean up.`);
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
|
|
1722
2850
|
function formatTime(ts) {
|
|
1723
2851
|
if (!ts) return "-";
|
|
1724
2852
|
const date = new Date(ts);
|
|
@@ -2154,9 +3282,22 @@ program.command("machine").description("Manage machine identity").addCommand(
|
|
|
2154
3282
|
}
|
|
2155
3283
|
})
|
|
2156
3284
|
);
|
|
3285
|
+
program.command("daemon").description("Run as a persistent background daemon").addCommand(
|
|
3286
|
+
new 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) => {
|
|
3287
|
+
await startDaemon(opts);
|
|
3288
|
+
})
|
|
3289
|
+
).addCommand(
|
|
3290
|
+
new Command("stop").description("Stop the running daemon").action(() => {
|
|
3291
|
+
stopDaemon();
|
|
3292
|
+
})
|
|
3293
|
+
).addCommand(
|
|
3294
|
+
new Command("status").description("Check daemon status").action(() => {
|
|
3295
|
+
daemonStatus();
|
|
3296
|
+
})
|
|
3297
|
+
);
|
|
2157
3298
|
program.parseAsync(process.argv).catch((err) => {
|
|
2158
3299
|
console.error(err instanceof Error ? err.message : String(err));
|
|
2159
3300
|
process.exitCode = 1;
|
|
2160
3301
|
});
|
|
2161
3302
|
|
|
2162
|
-
export { MachineClient, RpcHandlerManager, SessionClient, authLogin, authLogout, authStatus, createRpcHandlerManager, createSession, deleteSession, fetchMessagesAfterSeq, getOrCreateMachine, getSessionMessages, listActiveSessions, listMachines, listSessions, loadConfig, readCredentials, requireCredentials, resolveSessionEncryption, sendMessagesBatch };
|
|
3303
|
+
export { MachineClient, RpcHandlerManager, SessionClient, authLogin, authLogout, authStatus, createRpcHandlerManager, createSession, daemonStatus, deleteSession, fetchMessagesAfterSeq, getOrCreateMachine, getSessionMessages, listActiveSessions, listMachines, listSessions, loadConfig, readCredentials, requireCredentials, resolveSessionEncryption, sendMessagesBatch, startDaemon, stopDaemon };
|