@zonease/happy 0.6.38 → 0.6.39
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/{types-BWpRU1VN.mjs → api-CIZKyfSn.mjs} +136 -1725
- package/dist/{types-DvTsFaqR.cjs → api-CwOCQeLH.cjs} +317 -1961
- package/dist/auth-0VnlXT9f.mjs +181 -0
- package/dist/auth-BE8dD20T.mjs +270 -0
- package/dist/auth-BIleVCw0.cjs +274 -0
- package/dist/auth-Cas9DR3b.cjs +183 -0
- package/dist/browser-Cc08m7of.cjs +22 -0
- package/dist/browser-Dll5hxOV.mjs +20 -0
- package/dist/claudeLocal-CPT3zcIU.mjs +344 -0
- package/dist/claudeLocal-WkgLbl1a.cjs +352 -0
- package/dist/codex/happyMcpStdioBridge.cjs +4 -1
- package/dist/codex/happyMcpStdioBridge.mjs +4 -1
- package/dist/common-BdNgghUJ.mjs +75 -0
- package/dist/common-CZvSRU4p.cjs +83 -0
- package/dist/{config-0Xnc8ARM.cjs → config-Draa-5XC.cjs} +21 -34
- package/dist/{config-_GW4YBCP.mjs → config-rTT4qTU5.mjs} +7 -20
- package/dist/connect-BfC1tYP_.mjs +679 -0
- package/dist/connect-CJZ73syK.cjs +681 -0
- package/dist/{constants-B4x1K7by.cjs → constants-E_UWYTo3.cjs} +2 -2
- package/dist/{constants-BO9OAFOT.mjs → constants-pmXZGibX.mjs} +1 -1
- package/dist/controlClient-CM_QcJnB.mjs +159 -0
- package/dist/controlClient-DZMIsKsT.cjs +168 -0
- package/dist/deviceExec-CU1R9CRb.cjs +84 -0
- package/dist/deviceExec-D93AOVV4.mjs +81 -0
- package/dist/diagnose-B3D1a_v0.cjs +60 -0
- package/dist/diagnose-CuRKXxt1.mjs +58 -0
- package/dist/doctor-C08m5Z9f.cjs +78 -0
- package/dist/doctor-Cya9YSA0.cjs +214 -0
- package/dist/doctor-DZYB6IVc.mjs +211 -0
- package/dist/doctor-ON7pN5G5.mjs +74 -0
- package/dist/downloadImage-DfSFLb-h.mjs +887 -0
- package/dist/downloadImage-MQMrUUxS.cjs +897 -0
- package/dist/enroll-BxlbVftP.cjs +115 -0
- package/dist/enroll-C52x8oly.mjs +113 -0
- package/dist/evidence-Ccd9iYcj.mjs +127 -0
- package/dist/evidence-CjWBG5rX.cjs +129 -0
- package/dist/formatImageMessage-B4lSxlk2.mjs +43 -0
- package/dist/formatImageMessage-DbyG8c2j.cjs +46 -0
- package/dist/github-DKcyF1zp.mjs +57 -0
- package/dist/github-h9GhJJ_C.cjs +59 -0
- package/dist/index-CDTNgfUf.cjs +578 -0
- package/dist/index-CPKdvi7J.mjs +554 -0
- package/dist/index.cjs +812 -40
- package/dist/index.mjs +813 -40
- package/dist/install-BokHRgFW.cjs +98 -0
- package/dist/install-C_cp8b2-.cjs +96 -0
- package/dist/install-DAeCn8ae.mjs +94 -0
- package/dist/install-oRAjVLjP.mjs +95 -0
- package/dist/lib.cjs +18 -12
- package/dist/lib.mjs +13 -7
- package/dist/{loadableToolOutput-va3RLplh.mjs → loadableToolOutput-Cuygmd4K.mjs} +4 -2
- package/dist/{loadableToolOutput-5vdQB98M.cjs → loadableToolOutput-DDCJjywt.cjs} +63 -61
- package/dist/mcpToolSchemas-CjeTfrAs.mjs +62 -0
- package/dist/mcpToolSchemas-DK42yoZ_.cjs +64 -0
- package/dist/persistence-BFjvtUad.mjs +944 -0
- package/dist/persistence-JLUn_Nou.cjs +996 -0
- package/dist/projectPath-BTBIMB57.mjs +10 -0
- package/dist/projectPath-ChLhdOyt.cjs +13 -0
- package/dist/prompt-BTWDZZO0.cjs +25 -0
- package/dist/prompt-BsAZsPbo.mjs +22 -0
- package/dist/release-BMEzNONE.mjs +168 -0
- package/dist/release-EhOJeAZe.cjs +171 -0
- package/dist/run-BDn2aE3Q.cjs +2152 -0
- package/dist/run-DBZqN6Kn.mjs +2128 -0
- package/dist/runClaude-DNHvERX0.mjs +3763 -0
- package/dist/runClaude-uaKxPZPm.cjs +3766 -0
- package/dist/{runCodex-skq0j05d.cjs → runCodex-82UrZLme.cjs} +169 -153
- package/dist/{runCodex-DiKzBf9q.mjs → runCodex-C-RTM0wA.mjs} +36 -20
- package/dist/{runGemini-BeqrVq8R.mjs → runGemini-Bm6-4Xm2.mjs} +34 -17
- package/dist/{runGemini-CVp-CEIh.cjs → runGemini-CXMr4Jwb.cjs} +212 -195
- package/dist/{runOneShot-BQNkNW61.cjs → runOneShot-1B6RMYT7.cjs} +31 -55
- package/dist/{runOneShot-ltCpHbBB.mjs → runOneShot-ioARRHrx.mjs} +14 -38
- package/dist/{serve-BECdtivY.cjs → serve-BtdAhkIE.cjs} +35 -58
- package/dist/{serve-BNdoEDVx.mjs → serve-CwibwU6N.mjs} +12 -35
- package/dist/session-2sU4Bu1R.mjs +108 -0
- package/dist/session-D--F87zL.cjs +110 -0
- package/dist/sessionKeyCache-4Tp8pJ3N.cjs +36 -0
- package/dist/sessionKeyCache-D2BwO91C.mjs +33 -0
- package/dist/sessionList-BCSw3qYt.mjs +51 -0
- package/dist/sessionList-ICJsjNtJ.cjs +54 -0
- package/dist/sessionSummary-Bkz52BPh.cjs +393 -0
- package/dist/sessionSummary-lHGBWQSb.mjs +387 -0
- package/dist/spawnHappyCLI-CoORX0YW.mjs +66 -0
- package/dist/spawnHappyCLI-D_V-AAoP.cjs +70 -0
- package/dist/ssh-BTOlhhxi.cjs +333 -0
- package/dist/ssh-DL_AkRAD.mjs +328 -0
- package/dist/systemdScope-BeOtAkhk.mjs +25 -0
- package/dist/systemdScope-DW3-UoKB.cjs +29 -0
- package/dist/task-CQzrHRZe.mjs +165 -0
- package/dist/task-CSpaC9HX.cjs +167 -0
- package/dist/time-Bj2HRkKH.cjs +33 -0
- package/dist/time-DgsBLJA_.mjs +29 -0
- package/dist/trimIdent-4gvk9Wk6.cjs +22 -0
- package/dist/trimIdent-BAjmv_TG.mjs +20 -0
- package/dist/types-BoJh4Qfv.mjs +57 -0
- package/dist/types-DWeB7aqh.cjs +59 -0
- package/dist/uninstall-BiKyRAb1.cjs +77 -0
- package/dist/uninstall-nFMFGUVM.mjs +75 -0
- package/dist/update-BcqRHXf2.mjs +109 -0
- package/dist/update-Dw8pgsAT.cjs +111 -0
- package/dist/workflowCommon-B0d01Y_a.mjs +56 -0
- package/dist/workflowCommon-Bl568-rt.cjs +62 -0
- package/dist/workflowReports-BEWuQesA.cjs +113 -0
- package/dist/workflowReports-Dd8-4-8f.mjs +108 -0
- package/package.json +1 -1
- package/dist/index-BbCDDvoB.cjs +0 -11376
- package/dist/index-vvZXdswy.mjs +0 -11336
- package/dist/mcpToolSchemas-CdPXJQmb.mjs +0 -175
- package/dist/mcpToolSchemas-f-kttjFw.cjs +0 -186
|
@@ -0,0 +1,2152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var fs$1 = require('fs/promises');
|
|
4
|
+
var os = require('os');
|
|
5
|
+
var tmp = require('tmp');
|
|
6
|
+
var api = require('./api-CwOCQeLH.cjs');
|
|
7
|
+
var logger = require('./persistence-JLUn_Nou.cjs');
|
|
8
|
+
var auth = require('./auth-BIleVCw0.cjs');
|
|
9
|
+
var child_process = require('child_process');
|
|
10
|
+
var doctor = require('./doctor-Cya9YSA0.cjs');
|
|
11
|
+
var spawnHappyCLI = require('./spawnHappyCLI-D_V-AAoP.cjs');
|
|
12
|
+
var controlClient = require('./controlClient-DZMIsKsT.cjs');
|
|
13
|
+
var install = require('./install-BokHRgFW.cjs');
|
|
14
|
+
var fastify = require('fastify');
|
|
15
|
+
var fs = require('node:fs');
|
|
16
|
+
var promises = require('node:fs/promises');
|
|
17
|
+
var path = require('node:path');
|
|
18
|
+
var z = require('zod');
|
|
19
|
+
var fastifyTypeProviderZod = require('fastify-type-provider-zod');
|
|
20
|
+
var fs$2 = require('fs');
|
|
21
|
+
var path$1 = require('path');
|
|
22
|
+
var projectPath = require('./projectPath-ChLhdOyt.cjs');
|
|
23
|
+
var common = require('./common-CZvSRU4p.cjs');
|
|
24
|
+
|
|
25
|
+
function _interopNamespaceDefault(e) {
|
|
26
|
+
var n = Object.create(null);
|
|
27
|
+
if (e) {
|
|
28
|
+
Object.keys(e).forEach(function (k) {
|
|
29
|
+
if (k !== 'default') {
|
|
30
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
31
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
32
|
+
enumerable: true,
|
|
33
|
+
get: function () { return e[k]; }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
n.default = e;
|
|
39
|
+
return Object.freeze(n);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
var tmp__namespace = /*#__PURE__*/_interopNamespaceDefault(tmp);
|
|
43
|
+
|
|
44
|
+
let caffeinateProcess = null;
|
|
45
|
+
function startCaffeinate() {
|
|
46
|
+
if (logger.configuration.disableCaffeinate) {
|
|
47
|
+
logger.logger.debug("[caffeinate] Caffeinate disabled via HAPPY_DISABLE_CAFFEINATE environment variable");
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (process.platform !== "darwin") {
|
|
51
|
+
logger.logger.debug("[caffeinate] Not on macOS, skipping caffeinate");
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (caffeinateProcess && !caffeinateProcess.killed) {
|
|
55
|
+
logger.logger.debug("[caffeinate] Caffeinate already running");
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
caffeinateProcess = child_process.spawn("caffeinate", ["-im"], {
|
|
60
|
+
stdio: "ignore",
|
|
61
|
+
detached: false
|
|
62
|
+
});
|
|
63
|
+
caffeinateProcess.on("error", (error) => {
|
|
64
|
+
logger.logger.debug("[caffeinate] Error starting caffeinate:", error);
|
|
65
|
+
caffeinateProcess = null;
|
|
66
|
+
});
|
|
67
|
+
caffeinateProcess.on("exit", (code, signal) => {
|
|
68
|
+
logger.logger.debug(`[caffeinate] Process exited with code ${code}, signal ${signal}`);
|
|
69
|
+
caffeinateProcess = null;
|
|
70
|
+
});
|
|
71
|
+
logger.logger.debug(`[caffeinate] Started with PID ${caffeinateProcess.pid}`);
|
|
72
|
+
setupCleanupHandlers();
|
|
73
|
+
return true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
logger.logger.debug("[caffeinate] Failed to start caffeinate:", error);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let isStopping = false;
|
|
80
|
+
async function stopCaffeinate() {
|
|
81
|
+
if (isStopping) {
|
|
82
|
+
logger.logger.debug("[caffeinate] Already stopping, skipping");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (caffeinateProcess && !caffeinateProcess.killed) {
|
|
86
|
+
isStopping = true;
|
|
87
|
+
logger.logger.debug(`[caffeinate] Stopping caffeinate process PID ${caffeinateProcess.pid}`);
|
|
88
|
+
try {
|
|
89
|
+
caffeinateProcess.kill("SIGTERM");
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
91
|
+
if (caffeinateProcess && !caffeinateProcess.killed) {
|
|
92
|
+
logger.logger.debug("[caffeinate] Force killing caffeinate process");
|
|
93
|
+
caffeinateProcess.kill("SIGKILL");
|
|
94
|
+
}
|
|
95
|
+
caffeinateProcess = null;
|
|
96
|
+
isStopping = false;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
logger.logger.debug("[caffeinate] Error stopping caffeinate:", error);
|
|
99
|
+
isStopping = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let cleanupHandlersSet = false;
|
|
104
|
+
function setupCleanupHandlers() {
|
|
105
|
+
if (cleanupHandlersSet) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
cleanupHandlersSet = true;
|
|
109
|
+
const cleanup = () => {
|
|
110
|
+
stopCaffeinate();
|
|
111
|
+
};
|
|
112
|
+
process.on("exit", cleanup);
|
|
113
|
+
process.on("SIGINT", cleanup);
|
|
114
|
+
process.on("SIGTERM", cleanup);
|
|
115
|
+
process.on("SIGUSR1", cleanup);
|
|
116
|
+
process.on("SIGUSR2", cleanup);
|
|
117
|
+
process.on("uncaughtException", (error) => {
|
|
118
|
+
logger.logger.debug("[caffeinate] Uncaught exception, cleaning up:", error);
|
|
119
|
+
cleanup();
|
|
120
|
+
});
|
|
121
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
122
|
+
logger.logger.debug("[caffeinate] Unhandled rejection, cleaning up:", reason);
|
|
123
|
+
cleanup();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const VIDEO_CONTENT_TYPES = {
|
|
128
|
+
".webm": "video/webm",
|
|
129
|
+
".mp4": "video/mp4",
|
|
130
|
+
".m4v": "video/mp4",
|
|
131
|
+
".mov": "video/quicktime",
|
|
132
|
+
".ogv": "video/ogg"
|
|
133
|
+
};
|
|
134
|
+
function getFileStreamContentType(filePath) {
|
|
135
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
136
|
+
return VIDEO_CONTENT_TYPES[extension] ?? "application/octet-stream";
|
|
137
|
+
}
|
|
138
|
+
function getFileStreamCorsHeaders() {
|
|
139
|
+
return {
|
|
140
|
+
"Access-Control-Allow-Origin": "*",
|
|
141
|
+
"Access-Control-Allow-Headers": "Range, Access-Control-Request-Private-Network",
|
|
142
|
+
"Access-Control-Expose-Headers": "Accept-Ranges, Content-Length, Content-Range, Content-Type",
|
|
143
|
+
"Access-Control-Allow-Private-Network": "true"
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function parseFileStreamRange(rangeHeader, size) {
|
|
147
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
148
|
+
return { ok: false };
|
|
149
|
+
}
|
|
150
|
+
if (!rangeHeader) {
|
|
151
|
+
return { ok: true, partial: false, start: 0, end: size - 1 };
|
|
152
|
+
}
|
|
153
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
|
154
|
+
if (!match) {
|
|
155
|
+
return { ok: false };
|
|
156
|
+
}
|
|
157
|
+
const [, rawStart, rawEnd] = match;
|
|
158
|
+
if (!rawStart && !rawEnd) {
|
|
159
|
+
return { ok: false };
|
|
160
|
+
}
|
|
161
|
+
let start;
|
|
162
|
+
let end;
|
|
163
|
+
if (!rawStart) {
|
|
164
|
+
const suffixLength = Number.parseInt(rawEnd, 10);
|
|
165
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) {
|
|
166
|
+
return { ok: false };
|
|
167
|
+
}
|
|
168
|
+
start = Math.max(size - suffixLength, 0);
|
|
169
|
+
end = size - 1;
|
|
170
|
+
} else {
|
|
171
|
+
start = Number.parseInt(rawStart, 10);
|
|
172
|
+
end = rawEnd ? Number.parseInt(rawEnd, 10) : size - 1;
|
|
173
|
+
}
|
|
174
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
|
|
175
|
+
return { ok: false };
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, partial: true, start, end: Math.min(end, size - 1) };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function startDaemonControlServer({
|
|
181
|
+
getChildren,
|
|
182
|
+
stopSession,
|
|
183
|
+
spawnSession,
|
|
184
|
+
requestShutdown,
|
|
185
|
+
onHappySessionWebhook
|
|
186
|
+
}) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const app = fastify({
|
|
189
|
+
logger: false
|
|
190
|
+
// We use our own logger
|
|
191
|
+
});
|
|
192
|
+
app.setValidatorCompiler(fastifyTypeProviderZod.validatorCompiler);
|
|
193
|
+
app.setSerializerCompiler(fastifyTypeProviderZod.serializerCompiler);
|
|
194
|
+
const typed = app.withTypeProvider();
|
|
195
|
+
const addFileStreamCorsHeaders = (reply) => {
|
|
196
|
+
for (const [key, value] of Object.entries(getFileStreamCorsHeaders())) {
|
|
197
|
+
reply.header(key, value);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
typed.options("/file-stream", async (_request, reply) => {
|
|
201
|
+
addFileStreamCorsHeaders(reply);
|
|
202
|
+
reply.header("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
203
|
+
reply.code(204);
|
|
204
|
+
return "";
|
|
205
|
+
});
|
|
206
|
+
typed.get("/file-stream", {
|
|
207
|
+
schema: {
|
|
208
|
+
querystring: z.z.object({
|
|
209
|
+
path: z.z.string().min(1)
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}, async (request, reply) => {
|
|
213
|
+
addFileStreamCorsHeaders(reply);
|
|
214
|
+
const filePath = request.query.path;
|
|
215
|
+
if (!path.isAbsolute(filePath)) {
|
|
216
|
+
reply.code(400);
|
|
217
|
+
return { error: "path must be absolute" };
|
|
218
|
+
}
|
|
219
|
+
let stats;
|
|
220
|
+
try {
|
|
221
|
+
stats = await promises.stat(filePath);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
logger.logger.debug(`[CONTROL SERVER] File stream stat failed for ${filePath}:`, error);
|
|
224
|
+
reply.code(404);
|
|
225
|
+
return { error: "file not found" };
|
|
226
|
+
}
|
|
227
|
+
if (!stats.isFile()) {
|
|
228
|
+
reply.code(400);
|
|
229
|
+
return { error: "path is not a file" };
|
|
230
|
+
}
|
|
231
|
+
const range = parseFileStreamRange(request.headers.range, stats.size);
|
|
232
|
+
if (!range.ok) {
|
|
233
|
+
reply.code(416);
|
|
234
|
+
reply.header("Content-Range", `bytes */${stats.size}`);
|
|
235
|
+
return { error: "range not satisfiable" };
|
|
236
|
+
}
|
|
237
|
+
const contentLength = range.end - range.start + 1;
|
|
238
|
+
reply.code(range.partial ? 206 : 200);
|
|
239
|
+
reply.header("Content-Type", getFileStreamContentType(filePath));
|
|
240
|
+
reply.header("Accept-Ranges", "bytes");
|
|
241
|
+
reply.header("Content-Length", String(contentLength));
|
|
242
|
+
reply.header("Cache-Control", "no-store");
|
|
243
|
+
reply.header("Content-Disposition", `inline; filename="${path.basename(filePath).replace(/["\\]/g, "_")}"`);
|
|
244
|
+
if (range.partial) {
|
|
245
|
+
reply.header("Content-Range", `bytes ${range.start}-${range.end}/${stats.size}`);
|
|
246
|
+
}
|
|
247
|
+
return reply.send(fs.createReadStream(filePath, { start: range.start, end: range.end }));
|
|
248
|
+
});
|
|
249
|
+
typed.post("/session-started", {
|
|
250
|
+
schema: {
|
|
251
|
+
body: z.z.object({
|
|
252
|
+
sessionId: z.z.string(),
|
|
253
|
+
metadata: z.z.any()
|
|
254
|
+
// Metadata type from API
|
|
255
|
+
}),
|
|
256
|
+
response: {
|
|
257
|
+
200: z.z.object({
|
|
258
|
+
status: z.z.literal("ok")
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}, async (request) => {
|
|
263
|
+
const { sessionId, metadata } = request.body;
|
|
264
|
+
logger.logger.debug(`[CONTROL SERVER] Session started: ${sessionId}`);
|
|
265
|
+
onHappySessionWebhook(sessionId, metadata);
|
|
266
|
+
return { status: "ok" };
|
|
267
|
+
});
|
|
268
|
+
typed.post("/list", {
|
|
269
|
+
schema: {
|
|
270
|
+
response: {
|
|
271
|
+
200: z.z.object({
|
|
272
|
+
children: z.z.array(z.z.object({
|
|
273
|
+
startedBy: z.z.string(),
|
|
274
|
+
happySessionId: z.z.string(),
|
|
275
|
+
pid: z.z.number()
|
|
276
|
+
}))
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}, async () => {
|
|
281
|
+
const children = getChildren();
|
|
282
|
+
logger.logger.debug(`[CONTROL SERVER] Listing ${children.length} sessions`);
|
|
283
|
+
return {
|
|
284
|
+
children: children.filter((child) => child.happySessionId !== void 0).map((child) => ({
|
|
285
|
+
startedBy: child.startedBy,
|
|
286
|
+
happySessionId: child.happySessionId,
|
|
287
|
+
pid: child.pid
|
|
288
|
+
}))
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
typed.post("/stop-session", {
|
|
292
|
+
schema: {
|
|
293
|
+
body: z.z.object({
|
|
294
|
+
sessionId: z.z.string()
|
|
295
|
+
}),
|
|
296
|
+
response: {
|
|
297
|
+
200: z.z.object({
|
|
298
|
+
success: z.z.boolean()
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}, async (request) => {
|
|
303
|
+
const { sessionId } = request.body;
|
|
304
|
+
logger.logger.debug(`[CONTROL SERVER] Stop session request: ${sessionId}`);
|
|
305
|
+
const success = stopSession(sessionId);
|
|
306
|
+
return { success };
|
|
307
|
+
});
|
|
308
|
+
typed.post("/spawn-session", {
|
|
309
|
+
schema: {
|
|
310
|
+
body: z.z.object({
|
|
311
|
+
directory: z.z.string(),
|
|
312
|
+
sessionId: z.z.string().optional()
|
|
313
|
+
}),
|
|
314
|
+
response: {
|
|
315
|
+
200: z.z.object({
|
|
316
|
+
success: z.z.boolean(),
|
|
317
|
+
sessionId: z.z.string().optional(),
|
|
318
|
+
approvedNewDirectoryCreation: z.z.boolean().optional()
|
|
319
|
+
}),
|
|
320
|
+
409: z.z.object({
|
|
321
|
+
success: z.z.boolean(),
|
|
322
|
+
requiresUserApproval: z.z.boolean().optional(),
|
|
323
|
+
actionRequired: z.z.string().optional(),
|
|
324
|
+
directory: z.z.string().optional()
|
|
325
|
+
}),
|
|
326
|
+
500: z.z.object({
|
|
327
|
+
success: z.z.boolean(),
|
|
328
|
+
error: z.z.string().optional()
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}, async (request, reply) => {
|
|
333
|
+
const { directory, sessionId } = request.body;
|
|
334
|
+
logger.logger.debug(`[CONTROL SERVER] Spawn session request: dir=${directory}, sessionId=${sessionId || "new"}`);
|
|
335
|
+
const result = await spawnSession({ directory, sessionId });
|
|
336
|
+
switch (result.type) {
|
|
337
|
+
case "success":
|
|
338
|
+
if (!result.sessionId) {
|
|
339
|
+
reply.code(500);
|
|
340
|
+
return {
|
|
341
|
+
success: false,
|
|
342
|
+
error: "Failed to spawn session: no session ID returned"
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
success: true,
|
|
347
|
+
sessionId: result.sessionId,
|
|
348
|
+
approvedNewDirectoryCreation: true
|
|
349
|
+
};
|
|
350
|
+
case "requestToApproveDirectoryCreation":
|
|
351
|
+
reply.code(409);
|
|
352
|
+
return {
|
|
353
|
+
success: false,
|
|
354
|
+
requiresUserApproval: true,
|
|
355
|
+
actionRequired: "CREATE_DIRECTORY",
|
|
356
|
+
directory: result.directory
|
|
357
|
+
};
|
|
358
|
+
case "error":
|
|
359
|
+
reply.code(500);
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
error: result.errorMessage
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
typed.post("/stop", {
|
|
367
|
+
schema: {
|
|
368
|
+
response: {
|
|
369
|
+
200: z.z.object({
|
|
370
|
+
status: z.z.string()
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}, async () => {
|
|
375
|
+
logger.logger.debug("[CONTROL SERVER] Stop daemon request received");
|
|
376
|
+
setTimeout(() => {
|
|
377
|
+
logger.logger.debug("[CONTROL SERVER] Triggering daemon shutdown");
|
|
378
|
+
requestShutdown();
|
|
379
|
+
}, 50);
|
|
380
|
+
return { status: "stopping" };
|
|
381
|
+
});
|
|
382
|
+
app.listen({ port: 0, host: "127.0.0.1" }, (err, address) => {
|
|
383
|
+
if (err) {
|
|
384
|
+
logger.logger.debug("[CONTROL SERVER] Failed to start:", err);
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
const port = parseInt(address.split(":").pop());
|
|
388
|
+
logger.logger.debug(`[CONTROL SERVER] Started on port ${port}`);
|
|
389
|
+
resolve({
|
|
390
|
+
port,
|
|
391
|
+
stop: async () => {
|
|
392
|
+
logger.logger.debug("[CONTROL SERVER] Stopping server");
|
|
393
|
+
await app.close();
|
|
394
|
+
logger.logger.debug("[CONTROL SERVER] Server stopped");
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
class TmuxSessionIdentifierError extends Error {
|
|
402
|
+
constructor(message) {
|
|
403
|
+
super(message);
|
|
404
|
+
this.name = "TmuxSessionIdentifierError";
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function parseTmuxSessionIdentifier(identifier) {
|
|
408
|
+
if (!identifier || typeof identifier !== "string") {
|
|
409
|
+
throw new TmuxSessionIdentifierError("Session identifier must be a non-empty string");
|
|
410
|
+
}
|
|
411
|
+
const parts = identifier.split(":");
|
|
412
|
+
if (parts.length === 0 || !parts[0]) {
|
|
413
|
+
throw new TmuxSessionIdentifierError("Invalid session identifier: missing session name");
|
|
414
|
+
}
|
|
415
|
+
const result = {
|
|
416
|
+
session: parts[0].trim()
|
|
417
|
+
};
|
|
418
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(result.session)) {
|
|
419
|
+
throw new TmuxSessionIdentifierError(`Invalid session name: "${result.session}". Only alphanumeric characters, dots, hyphens, and underscores are allowed.`);
|
|
420
|
+
}
|
|
421
|
+
if (parts.length > 1) {
|
|
422
|
+
const windowAndPane = parts[1].split(".");
|
|
423
|
+
result.window = windowAndPane[0]?.trim();
|
|
424
|
+
if (result.window && !/^[a-zA-Z0-9._-]+$/.test(result.window)) {
|
|
425
|
+
throw new TmuxSessionIdentifierError(`Invalid window name: "${result.window}". Only alphanumeric characters, dots, hyphens, and underscores are allowed.`);
|
|
426
|
+
}
|
|
427
|
+
if (windowAndPane.length > 1) {
|
|
428
|
+
result.pane = windowAndPane[1]?.trim();
|
|
429
|
+
if (result.pane && !/^[0-9]+$/.test(result.pane)) {
|
|
430
|
+
throw new TmuxSessionIdentifierError(`Invalid pane identifier: "${result.pane}". Only numeric values are allowed.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
function formatTmuxSessionIdentifier(identifier) {
|
|
437
|
+
if (!identifier.session) {
|
|
438
|
+
throw new TmuxSessionIdentifierError("Session identifier must have a session name");
|
|
439
|
+
}
|
|
440
|
+
let result = identifier.session;
|
|
441
|
+
if (identifier.window) {
|
|
442
|
+
result += `:${identifier.window}`;
|
|
443
|
+
if (identifier.pane) {
|
|
444
|
+
result += `.${identifier.pane}`;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
const WIN_OPS = {
|
|
450
|
+
// Navigation and window management
|
|
451
|
+
"new-window": "new-window",
|
|
452
|
+
"new": "new-window",
|
|
453
|
+
"nw": "new-window",
|
|
454
|
+
"select-window": "select-window -t",
|
|
455
|
+
"sw": "select-window -t",
|
|
456
|
+
"window": "select-window -t",
|
|
457
|
+
"w": "select-window -t",
|
|
458
|
+
"next-window": "next-window",
|
|
459
|
+
"n": "next-window",
|
|
460
|
+
"prev-window": "previous-window",
|
|
461
|
+
"p": "previous-window",
|
|
462
|
+
"pw": "previous-window",
|
|
463
|
+
// Pane management
|
|
464
|
+
"split-window": "split-window",
|
|
465
|
+
"split": "split-window",
|
|
466
|
+
"sp": "split-window",
|
|
467
|
+
"vsplit": "split-window -h",
|
|
468
|
+
"vsp": "split-window -h",
|
|
469
|
+
"select-pane": "select-pane -t",
|
|
470
|
+
"pane": "select-pane -t",
|
|
471
|
+
"next-pane": "select-pane -t :.+",
|
|
472
|
+
"np": "select-pane -t :.+",
|
|
473
|
+
"prev-pane": "select-pane -t :.-",
|
|
474
|
+
"pp": "select-pane -t :.-",
|
|
475
|
+
// Session management
|
|
476
|
+
"new-session": "new-session",
|
|
477
|
+
"ns": "new-session",
|
|
478
|
+
"new-sess": "new-session",
|
|
479
|
+
"attach-session": "attach-session -t",
|
|
480
|
+
"attach": "attach-session -t",
|
|
481
|
+
"as": "attach-session -t",
|
|
482
|
+
"detach-client": "detach-client",
|
|
483
|
+
"detach": "detach-client",
|
|
484
|
+
"dc": "detach-client",
|
|
485
|
+
// Layout and display
|
|
486
|
+
"select-layout": "select-layout",
|
|
487
|
+
"layout": "select-layout",
|
|
488
|
+
"sl": "select-layout",
|
|
489
|
+
"clock-mode": "clock-mode",
|
|
490
|
+
"clock": "clock-mode",
|
|
491
|
+
// Copy mode
|
|
492
|
+
"copy-mode": "copy-mode",
|
|
493
|
+
"copy": "copy-mode",
|
|
494
|
+
// Search and navigation in copy mode
|
|
495
|
+
"search-forward": "search-forward",
|
|
496
|
+
"search-backward": "search-backward",
|
|
497
|
+
// Misc operations
|
|
498
|
+
"list-windows": "list-windows",
|
|
499
|
+
"lw": "list-windows",
|
|
500
|
+
"list-sessions": "list-sessions",
|
|
501
|
+
"ls": "list-sessions",
|
|
502
|
+
"list-panes": "list-panes",
|
|
503
|
+
"lp": "list-panes",
|
|
504
|
+
"rename-window": "rename-window",
|
|
505
|
+
"rename": "rename-window",
|
|
506
|
+
"kill-window": "kill-window",
|
|
507
|
+
"kw": "kill-window",
|
|
508
|
+
"kill-pane": "kill-pane",
|
|
509
|
+
"kp": "kill-pane",
|
|
510
|
+
"kill-session": "kill-session",
|
|
511
|
+
"ks": "kill-session",
|
|
512
|
+
// Display and info
|
|
513
|
+
"display-message": "display-message",
|
|
514
|
+
"display": "display-message",
|
|
515
|
+
"dm": "display-message",
|
|
516
|
+
"show-options": "show-options",
|
|
517
|
+
"show": "show-options",
|
|
518
|
+
"so": "show-options",
|
|
519
|
+
// Control and scripting
|
|
520
|
+
"send-keys": "send-keys",
|
|
521
|
+
"send": "send-keys",
|
|
522
|
+
"sk": "send-keys",
|
|
523
|
+
"capture-pane": "capture-pane",
|
|
524
|
+
"capture": "capture-pane",
|
|
525
|
+
"cp": "capture-pane",
|
|
526
|
+
"pipe-pane": "pipe-pane",
|
|
527
|
+
"pipe": "pipe-pane",
|
|
528
|
+
// Buffer operations
|
|
529
|
+
"list-buffers": "list-buffers",
|
|
530
|
+
"lb": "list-buffers",
|
|
531
|
+
"save-buffer": "save-buffer",
|
|
532
|
+
"sb": "save-buffer",
|
|
533
|
+
"delete-buffer": "delete-buffer",
|
|
534
|
+
"db": "delete-buffer",
|
|
535
|
+
// Advanced operations
|
|
536
|
+
"resize-pane": "resize-pane",
|
|
537
|
+
"resize": "resize-pane",
|
|
538
|
+
"rp": "resize-pane",
|
|
539
|
+
"swap-pane": "swap-pane",
|
|
540
|
+
"swap": "swap-pane",
|
|
541
|
+
"join-pane": "join-pane",
|
|
542
|
+
"join": "join-pane",
|
|
543
|
+
"break-pane": "break-pane",
|
|
544
|
+
"break": "break-pane"
|
|
545
|
+
};
|
|
546
|
+
const COMMANDS_SUPPORTING_TARGET = /* @__PURE__ */ new Set([
|
|
547
|
+
"send-keys",
|
|
548
|
+
"capture-pane",
|
|
549
|
+
"new-window",
|
|
550
|
+
"kill-window",
|
|
551
|
+
"select-window",
|
|
552
|
+
"split-window",
|
|
553
|
+
"select-pane",
|
|
554
|
+
"kill-pane",
|
|
555
|
+
"select-layout",
|
|
556
|
+
"display-message",
|
|
557
|
+
"attach-session",
|
|
558
|
+
"detach-client",
|
|
559
|
+
"new-session",
|
|
560
|
+
"kill-session",
|
|
561
|
+
"list-windows",
|
|
562
|
+
"list-panes"
|
|
563
|
+
]);
|
|
564
|
+
const CONTROL_SEQUENCES = /* @__PURE__ */ new Set([
|
|
565
|
+
"C-m",
|
|
566
|
+
"C-c",
|
|
567
|
+
"C-l",
|
|
568
|
+
"C-u",
|
|
569
|
+
"C-w",
|
|
570
|
+
"C-a",
|
|
571
|
+
"C-b",
|
|
572
|
+
"C-d",
|
|
573
|
+
"C-e",
|
|
574
|
+
"C-f",
|
|
575
|
+
"C-g",
|
|
576
|
+
"C-h",
|
|
577
|
+
"C-i",
|
|
578
|
+
"C-j",
|
|
579
|
+
"C-k",
|
|
580
|
+
"C-n",
|
|
581
|
+
"C-o",
|
|
582
|
+
"C-p",
|
|
583
|
+
"C-q",
|
|
584
|
+
"C-r",
|
|
585
|
+
"C-s",
|
|
586
|
+
"C-t",
|
|
587
|
+
"C-v",
|
|
588
|
+
"C-x",
|
|
589
|
+
"C-y",
|
|
590
|
+
"C-z",
|
|
591
|
+
"C-\\",
|
|
592
|
+
"C-]",
|
|
593
|
+
"C-[",
|
|
594
|
+
"C-]"
|
|
595
|
+
]);
|
|
596
|
+
class TmuxUtilities {
|
|
597
|
+
/** Default session name to prevent interference */
|
|
598
|
+
static DEFAULT_SESSION_NAME = "happy";
|
|
599
|
+
controlState = "normal" /* NORMAL */;
|
|
600
|
+
sessionName;
|
|
601
|
+
constructor(sessionName) {
|
|
602
|
+
this.sessionName = sessionName || TmuxUtilities.DEFAULT_SESSION_NAME;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Detect tmux environment from TMUX environment variable
|
|
606
|
+
*/
|
|
607
|
+
detectTmuxEnvironment() {
|
|
608
|
+
const tmuxEnv = process.env.TMUX;
|
|
609
|
+
if (!tmuxEnv) {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
const parts = tmuxEnv.split(",");
|
|
614
|
+
if (parts.length >= 3) {
|
|
615
|
+
const socketPath = parts[0];
|
|
616
|
+
const pathParts = parts[1].split("/");
|
|
617
|
+
const sessionAndWindow = pathParts[pathParts.length - 1] || parts[1];
|
|
618
|
+
const pane = parts[2];
|
|
619
|
+
let session;
|
|
620
|
+
let window;
|
|
621
|
+
if (sessionAndWindow.includes(".")) {
|
|
622
|
+
const parts2 = sessionAndWindow.split(".", 2);
|
|
623
|
+
session = parts2[0];
|
|
624
|
+
window = parts2[1] || "0";
|
|
625
|
+
} else {
|
|
626
|
+
session = sessionAndWindow;
|
|
627
|
+
window = "0";
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
session,
|
|
631
|
+
window,
|
|
632
|
+
pane,
|
|
633
|
+
socket_path: socketPath
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
logger.logger.debug("[TMUX] Failed to parse TMUX environment variable:", error);
|
|
638
|
+
}
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Execute tmux command with proper session targeting and socket handling
|
|
643
|
+
*/
|
|
644
|
+
async executeTmuxCommand(cmd, session, window, pane, socketPath) {
|
|
645
|
+
const targetSession = session || this.sessionName;
|
|
646
|
+
let baseCmd = ["tmux"];
|
|
647
|
+
if (socketPath) {
|
|
648
|
+
baseCmd = ["tmux", "-S", socketPath];
|
|
649
|
+
}
|
|
650
|
+
if (cmd.length > 0 && cmd[0] === "send-keys") {
|
|
651
|
+
const fullCmd = [...baseCmd, cmd[0]];
|
|
652
|
+
let target = targetSession;
|
|
653
|
+
if (window) target += `:${window}`;
|
|
654
|
+
if (pane) target += `.${pane}`;
|
|
655
|
+
fullCmd.push("-t", target);
|
|
656
|
+
fullCmd.push(...cmd.slice(1));
|
|
657
|
+
return this.executeCommand(fullCmd);
|
|
658
|
+
} else {
|
|
659
|
+
const fullCmd = [...baseCmd, ...cmd];
|
|
660
|
+
if (cmd.length > 0 && COMMANDS_SUPPORTING_TARGET.has(cmd[0])) {
|
|
661
|
+
let target = targetSession;
|
|
662
|
+
if (window) target += `:${window}`;
|
|
663
|
+
if (pane) target += `.${pane}`;
|
|
664
|
+
fullCmd.push("-t", target);
|
|
665
|
+
}
|
|
666
|
+
return this.executeCommand(fullCmd);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Execute command with subprocess and return result
|
|
671
|
+
*/
|
|
672
|
+
async executeCommand(cmd) {
|
|
673
|
+
try {
|
|
674
|
+
const result = await this.runCommand(cmd);
|
|
675
|
+
return {
|
|
676
|
+
returncode: result.exitCode,
|
|
677
|
+
stdout: result.stdout || "",
|
|
678
|
+
stderr: result.stderr || "",
|
|
679
|
+
command: cmd
|
|
680
|
+
};
|
|
681
|
+
} catch (error) {
|
|
682
|
+
logger.logger.debug("[TMUX] Command execution failed:", error);
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Run command using Node.js child_process.spawn
|
|
688
|
+
*/
|
|
689
|
+
runCommand(args, options = {}) {
|
|
690
|
+
return new Promise((resolve, reject) => {
|
|
691
|
+
const child = child_process.spawn(args[0], args.slice(1), {
|
|
692
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
693
|
+
timeout: 5e3,
|
|
694
|
+
shell: false,
|
|
695
|
+
...options
|
|
696
|
+
});
|
|
697
|
+
let stdout = "";
|
|
698
|
+
let stderr = "";
|
|
699
|
+
child.stdout?.on("data", (data) => {
|
|
700
|
+
stdout += data.toString();
|
|
701
|
+
});
|
|
702
|
+
child.stderr?.on("data", (data) => {
|
|
703
|
+
stderr += data.toString();
|
|
704
|
+
});
|
|
705
|
+
child.on("close", (code) => {
|
|
706
|
+
resolve({
|
|
707
|
+
exitCode: code || 0,
|
|
708
|
+
stdout,
|
|
709
|
+
stderr
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
child.on("error", (error) => {
|
|
713
|
+
reject(error);
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Parse control sequences in text (^ for escape, ^^ for literal ^)
|
|
719
|
+
*/
|
|
720
|
+
parseControlSequences(text) {
|
|
721
|
+
const result = [];
|
|
722
|
+
let i = 0;
|
|
723
|
+
let localState = this.controlState;
|
|
724
|
+
while (i < text.length) {
|
|
725
|
+
const char = text[i];
|
|
726
|
+
if (localState === "normal" /* NORMAL */) {
|
|
727
|
+
if (char === "^") {
|
|
728
|
+
if (i + 1 < text.length && text[i + 1] === "^") {
|
|
729
|
+
result.push("^");
|
|
730
|
+
i += 2;
|
|
731
|
+
} else {
|
|
732
|
+
localState = "escape" /* ESCAPE */;
|
|
733
|
+
i += 1;
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
result.push(char);
|
|
737
|
+
i += 1;
|
|
738
|
+
}
|
|
739
|
+
} else if (localState === "escape" /* ESCAPE */) {
|
|
740
|
+
result.push(char);
|
|
741
|
+
i += 1;
|
|
742
|
+
localState = "normal" /* NORMAL */;
|
|
743
|
+
} else {
|
|
744
|
+
result.push(char);
|
|
745
|
+
i += 1;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
this.controlState = localState;
|
|
749
|
+
return [result.join(""), localState];
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Execute window operation using WIN_OPS dispatch with type safety
|
|
753
|
+
*/
|
|
754
|
+
async executeWinOp(operation, args = [], session, window, pane) {
|
|
755
|
+
const tmuxCmd = WIN_OPS[operation];
|
|
756
|
+
if (!tmuxCmd) {
|
|
757
|
+
logger.logger.debug(`[TMUX] Unknown operation: ${operation}`);
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
const cmdParts = tmuxCmd.split(" ");
|
|
761
|
+
cmdParts.push(...args);
|
|
762
|
+
const result = await this.executeTmuxCommand(cmdParts, session, window, pane);
|
|
763
|
+
return result !== null && result.returncode === 0;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Ensure session exists, create if needed
|
|
767
|
+
*/
|
|
768
|
+
async ensureSessionExists(sessionName) {
|
|
769
|
+
const targetSession = sessionName || this.sessionName;
|
|
770
|
+
const result = await this.executeTmuxCommand(["has-session", "-t", targetSession]);
|
|
771
|
+
if (result && result.returncode === 0) {
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
const createResult = await this.executeTmuxCommand(["new-session", "-d", "-s", targetSession]);
|
|
775
|
+
return createResult !== null && createResult.returncode === 0;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Capture current input from tmux pane
|
|
779
|
+
*/
|
|
780
|
+
async captureCurrentInput(session, window, pane) {
|
|
781
|
+
const result = await this.executeTmuxCommand(["capture-pane", "-p"], session, window, pane);
|
|
782
|
+
if (result && result.returncode === 0) {
|
|
783
|
+
const lines = result.stdout.trim().split("\n");
|
|
784
|
+
return lines[lines.length - 1] || "";
|
|
785
|
+
}
|
|
786
|
+
return "";
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Check if user is actively typing
|
|
790
|
+
*/
|
|
791
|
+
async isUserTyping(checkInterval = 500, maxChecks = 3, session, window, pane) {
|
|
792
|
+
const initialInput = await this.captureCurrentInput(session, window, pane);
|
|
793
|
+
for (let i = 0; i < maxChecks - 1; i++) {
|
|
794
|
+
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
|
795
|
+
const currentInput = await this.captureCurrentInput(session, window, pane);
|
|
796
|
+
if (currentInput !== initialInput) {
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Send keys to tmux pane with proper control sequence handling and type safety
|
|
804
|
+
*/
|
|
805
|
+
async sendKeys(keys, session, window, pane) {
|
|
806
|
+
if (!keys || typeof keys !== "string") {
|
|
807
|
+
logger.logger.debug("[TMUX] Invalid keys provided to sendKeys");
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
if (CONTROL_SEQUENCES.has(keys)) {
|
|
811
|
+
const result = await this.executeTmuxCommand(["send-keys", keys], session, window, pane);
|
|
812
|
+
return result !== null && result.returncode === 0;
|
|
813
|
+
} else {
|
|
814
|
+
const result = await this.executeTmuxCommand(["send-keys", keys], session, window, pane);
|
|
815
|
+
return result !== null && result.returncode === 0;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Send multiple keys to tmux pane with proper control sequence handling
|
|
820
|
+
*/
|
|
821
|
+
async sendMultipleKeys(keys, session, window, pane) {
|
|
822
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
823
|
+
logger.logger.debug("[TMUX] Invalid keys array provided to sendMultipleKeys");
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
for (const key of keys) {
|
|
827
|
+
const success = await this.sendKeys(key, session, window, pane);
|
|
828
|
+
if (!success) {
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Get comprehensive session information
|
|
836
|
+
*/
|
|
837
|
+
async getSessionInfo(sessionName) {
|
|
838
|
+
const targetSession = sessionName || this.sessionName;
|
|
839
|
+
const envInfo = this.detectTmuxEnvironment();
|
|
840
|
+
const info = {
|
|
841
|
+
target_session: targetSession,
|
|
842
|
+
session: targetSession,
|
|
843
|
+
window: "unknown",
|
|
844
|
+
pane: "unknown",
|
|
845
|
+
socket_path: void 0,
|
|
846
|
+
tmux_active: envInfo !== null,
|
|
847
|
+
current_session: envInfo?.session,
|
|
848
|
+
available_sessions: []
|
|
849
|
+
};
|
|
850
|
+
if (envInfo && envInfo.session === targetSession) {
|
|
851
|
+
info.window = envInfo.window;
|
|
852
|
+
info.pane = envInfo.pane;
|
|
853
|
+
info.socket_path = envInfo.socket_path;
|
|
854
|
+
} else if (envInfo) {
|
|
855
|
+
info.env_session = envInfo.session;
|
|
856
|
+
info.env_window = envInfo.window;
|
|
857
|
+
info.env_pane = envInfo.pane;
|
|
858
|
+
}
|
|
859
|
+
const result = await this.executeTmuxCommand(["list-sessions"]);
|
|
860
|
+
if (result && result.returncode === 0) {
|
|
861
|
+
info.available_sessions = result.stdout.trim().split("\n").filter((line) => line.trim()).map((line) => line.split(":")[0]);
|
|
862
|
+
}
|
|
863
|
+
return info;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Spawn process in tmux session with environment variables.
|
|
867
|
+
*
|
|
868
|
+
* IMPORTANT: Unlike Node.js spawn(), env is a separate parameter.
|
|
869
|
+
* This is intentional because:
|
|
870
|
+
* - Tmux windows inherit environment from the tmux server
|
|
871
|
+
* - Only NEW or DIFFERENT variables need to be set via -e flag
|
|
872
|
+
* - Passing all of process.env would create 50+ unnecessary -e flags
|
|
873
|
+
*
|
|
874
|
+
* @param args - Command and arguments to execute (as array, will be joined)
|
|
875
|
+
* @param options - Spawn options (tmux-specific, excludes env)
|
|
876
|
+
* @param env - Environment variables to set in window (only pass what's different!)
|
|
877
|
+
* @returns Result with success status and session identifier
|
|
878
|
+
*/
|
|
879
|
+
async spawnInTmux(args, options = {}, env) {
|
|
880
|
+
try {
|
|
881
|
+
const tmuxCheck = await this.executeTmuxCommand(["list-sessions"]);
|
|
882
|
+
if (!tmuxCheck) {
|
|
883
|
+
throw new Error("tmux not available");
|
|
884
|
+
}
|
|
885
|
+
let sessionName = options.sessionName !== void 0 && options.sessionName !== "" ? options.sessionName : null;
|
|
886
|
+
if (!sessionName) {
|
|
887
|
+
const listResult = await this.executeTmuxCommand(["list-sessions", "-F", "#{session_name}"]);
|
|
888
|
+
if (listResult && listResult.returncode === 0 && listResult.stdout.trim()) {
|
|
889
|
+
const firstSession = listResult.stdout.trim().split("\n")[0];
|
|
890
|
+
sessionName = firstSession;
|
|
891
|
+
logger.logger.debug(`[TMUX] Using first existing session: ${sessionName}`);
|
|
892
|
+
} else {
|
|
893
|
+
sessionName = "happy";
|
|
894
|
+
logger.logger.debug(`[TMUX] No existing sessions, using default: ${sessionName}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const windowName = options.windowName || `happy-${Date.now()}`;
|
|
898
|
+
await this.ensureSessionExists(sessionName);
|
|
899
|
+
const fullCommand = args.join(" ");
|
|
900
|
+
const createWindowArgs = ["new-window", "-n", windowName];
|
|
901
|
+
if (options.cwd) {
|
|
902
|
+
const cwdPath = typeof options.cwd === "string" ? options.cwd : options.cwd.pathname;
|
|
903
|
+
createWindowArgs.push("-c", cwdPath);
|
|
904
|
+
}
|
|
905
|
+
if (env && Object.keys(env).length > 0) {
|
|
906
|
+
for (const [key, value] of Object.entries(env)) {
|
|
907
|
+
if (value === void 0 || value === null) {
|
|
908
|
+
logger.logger.warn(`[TMUX] Skipping undefined/null environment variable: ${key}`);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) {
|
|
912
|
+
logger.logger.warn(`[TMUX] Skipping invalid environment variable name: ${key}`);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
createWindowArgs.push("-e", `${key}=${value}`);
|
|
916
|
+
}
|
|
917
|
+
logger.logger.debug(`[TMUX] Setting ${Object.keys(env).length} environment variables in tmux window`);
|
|
918
|
+
}
|
|
919
|
+
createWindowArgs.push(fullCommand);
|
|
920
|
+
createWindowArgs.push("-P");
|
|
921
|
+
createWindowArgs.push("-F", "#{pane_pid}");
|
|
922
|
+
const createResult = await this.executeTmuxCommand(createWindowArgs, sessionName);
|
|
923
|
+
if (!createResult || createResult.returncode !== 0) {
|
|
924
|
+
throw new Error(`Failed to create tmux window: ${createResult?.stderr}`);
|
|
925
|
+
}
|
|
926
|
+
const panePid = parseInt(createResult.stdout.trim());
|
|
927
|
+
if (isNaN(panePid)) {
|
|
928
|
+
throw new Error(`Failed to extract PID from tmux output: ${createResult.stdout}`);
|
|
929
|
+
}
|
|
930
|
+
logger.logger.debug(`[TMUX] Spawned command in tmux session ${sessionName}, window ${windowName}, PID ${panePid}`);
|
|
931
|
+
const sessionIdentifier = {
|
|
932
|
+
session: sessionName,
|
|
933
|
+
window: windowName
|
|
934
|
+
};
|
|
935
|
+
return {
|
|
936
|
+
success: true,
|
|
937
|
+
sessionId: formatTmuxSessionIdentifier(sessionIdentifier),
|
|
938
|
+
pid: panePid
|
|
939
|
+
};
|
|
940
|
+
} catch (error) {
|
|
941
|
+
logger.logger.debug("[TMUX] Failed to spawn in tmux:", error);
|
|
942
|
+
return {
|
|
943
|
+
success: false,
|
|
944
|
+
error: error instanceof Error ? error.message : String(error)
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Get session info for a given session identifier string
|
|
950
|
+
*/
|
|
951
|
+
async getSessionInfoFromString(sessionIdentifier) {
|
|
952
|
+
try {
|
|
953
|
+
const parsed = parseTmuxSessionIdentifier(sessionIdentifier);
|
|
954
|
+
const info = await this.getSessionInfo(parsed.session);
|
|
955
|
+
return info;
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if (error instanceof TmuxSessionIdentifierError) {
|
|
958
|
+
logger.logger.debug(`[TMUX] Invalid session identifier: ${error.message}`);
|
|
959
|
+
} else {
|
|
960
|
+
logger.logger.debug("[TMUX] Error getting session info:", error);
|
|
961
|
+
}
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Kill a tmux window safely with proper error handling
|
|
967
|
+
*/
|
|
968
|
+
async killWindow(sessionIdentifier) {
|
|
969
|
+
try {
|
|
970
|
+
const parsed = parseTmuxSessionIdentifier(sessionIdentifier);
|
|
971
|
+
if (!parsed.window) {
|
|
972
|
+
throw new TmuxSessionIdentifierError(`Window identifier required: ${sessionIdentifier}`);
|
|
973
|
+
}
|
|
974
|
+
const result = await this.executeWinOp("kill-window", [parsed.window], parsed.session);
|
|
975
|
+
return result;
|
|
976
|
+
} catch (error) {
|
|
977
|
+
if (error instanceof TmuxSessionIdentifierError) {
|
|
978
|
+
logger.logger.debug(`[TMUX] Invalid window identifier: ${error.message}`);
|
|
979
|
+
} else {
|
|
980
|
+
logger.logger.debug("[TMUX] Error killing window:", error);
|
|
981
|
+
}
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* List windows in a session
|
|
987
|
+
*/
|
|
988
|
+
async listWindows(sessionName) {
|
|
989
|
+
const targetSession = sessionName || this.sessionName;
|
|
990
|
+
const result = await this.executeTmuxCommand(["list-windows", "-t", targetSession]);
|
|
991
|
+
if (!result || result.returncode !== 0) {
|
|
992
|
+
return [];
|
|
993
|
+
}
|
|
994
|
+
const windows = [];
|
|
995
|
+
const lines = result.stdout.trim().split("\n");
|
|
996
|
+
for (const line of lines) {
|
|
997
|
+
const match = line.match(/^\d+:\s+(\w+)/);
|
|
998
|
+
if (match) {
|
|
999
|
+
windows.push(match[1]);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return windows;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
let _tmuxUtils = null;
|
|
1006
|
+
function getTmuxUtilities(sessionName) {
|
|
1007
|
+
if (!_tmuxUtils || sessionName && sessionName !== _tmuxUtils.sessionName) {
|
|
1008
|
+
_tmuxUtils = new TmuxUtilities(sessionName);
|
|
1009
|
+
}
|
|
1010
|
+
return _tmuxUtils;
|
|
1011
|
+
}
|
|
1012
|
+
async function isTmuxAvailable() {
|
|
1013
|
+
try {
|
|
1014
|
+
const utils = new TmuxUtilities();
|
|
1015
|
+
const result = await utils.executeTmuxCommand(["list-sessions"]);
|
|
1016
|
+
return result !== null;
|
|
1017
|
+
} catch {
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function expandEnvironmentVariables(envVars, sourceEnv = process.env) {
|
|
1023
|
+
const expanded = {};
|
|
1024
|
+
const undefinedVars = [];
|
|
1025
|
+
for (const [key, value] of Object.entries(envVars)) {
|
|
1026
|
+
const expandedValue = value.replace(/\$\{([^}]+)\}/g, (match, expr) => {
|
|
1027
|
+
const colonDashIndex = expr.indexOf(":-");
|
|
1028
|
+
let varName;
|
|
1029
|
+
let defaultValue;
|
|
1030
|
+
if (colonDashIndex !== -1) {
|
|
1031
|
+
varName = expr.substring(0, colonDashIndex);
|
|
1032
|
+
defaultValue = expr.substring(colonDashIndex + 2);
|
|
1033
|
+
} else {
|
|
1034
|
+
varName = expr;
|
|
1035
|
+
}
|
|
1036
|
+
const resolvedValue = sourceEnv[varName];
|
|
1037
|
+
if (resolvedValue !== void 0) {
|
|
1038
|
+
const isSensitive = varName.toLowerCase().includes("token") || varName.toLowerCase().includes("key") || varName.toLowerCase().includes("secret");
|
|
1039
|
+
const displayValue = isSensitive ? resolvedValue ? `<${resolvedValue.length} chars>` : "<empty>" : resolvedValue;
|
|
1040
|
+
logger.logger.debug(`[EXPAND ENV] Expanded ${varName} from daemon env: ${displayValue}`);
|
|
1041
|
+
if (resolvedValue === "") {
|
|
1042
|
+
logger.logger.warn(`[EXPAND ENV] WARNING: ${varName} is set but EMPTY in daemon environment`);
|
|
1043
|
+
}
|
|
1044
|
+
return resolvedValue;
|
|
1045
|
+
} else if (defaultValue !== void 0) {
|
|
1046
|
+
logger.logger.debug(`[EXPAND ENV] Using default value for ${varName}: ${defaultValue}`);
|
|
1047
|
+
return defaultValue;
|
|
1048
|
+
} else {
|
|
1049
|
+
undefinedVars.push(varName);
|
|
1050
|
+
return match;
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
expanded[key] = expandedValue;
|
|
1054
|
+
}
|
|
1055
|
+
if (undefinedVars.length > 0) {
|
|
1056
|
+
logger.logger.warn(`[EXPAND ENV] Undefined variables referenced in profile environment: ${undefinedVars.join(", ")}`);
|
|
1057
|
+
logger.logger.warn(`[EXPAND ENV] Session may fail to authenticate. Set these in daemon environment before launching:`);
|
|
1058
|
+
undefinedVars.forEach((varName) => {
|
|
1059
|
+
logger.logger.warn(`[EXPAND ENV] ${varName}=<your-value>`);
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
return expanded;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const GEMINI_TEXT_KEYS = /* @__PURE__ */ new Set([
|
|
1066
|
+
"text",
|
|
1067
|
+
"content",
|
|
1068
|
+
"output",
|
|
1069
|
+
"message",
|
|
1070
|
+
"response",
|
|
1071
|
+
"result",
|
|
1072
|
+
"answer",
|
|
1073
|
+
"final_text"
|
|
1074
|
+
]);
|
|
1075
|
+
const GEMINI_META_KEYS = /* @__PURE__ */ new Set([
|
|
1076
|
+
"session_id",
|
|
1077
|
+
"sessionId",
|
|
1078
|
+
"id",
|
|
1079
|
+
"usage",
|
|
1080
|
+
"metadata",
|
|
1081
|
+
"model",
|
|
1082
|
+
"role",
|
|
1083
|
+
"timestamp"
|
|
1084
|
+
]);
|
|
1085
|
+
const ANSI_ESCAPE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
1086
|
+
function stripAnsi(value) {
|
|
1087
|
+
return value.replace(ANSI_ESCAPE_REGEX, "");
|
|
1088
|
+
}
|
|
1089
|
+
function extractCodexSessionId(output) {
|
|
1090
|
+
const cleaned = stripAnsi(output);
|
|
1091
|
+
const match = cleaned.match(/session id:\s*([^\s]+)/i);
|
|
1092
|
+
if (!match) {
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
const token = match[1].trim().replace(/^['"`(\[]+|[)\]"'`,.;:]+$/g, "");
|
|
1096
|
+
if (!/^[0-9a-zA-Z-]{8,}$/.test(token)) {
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
return token;
|
|
1100
|
+
}
|
|
1101
|
+
function extractGeminiSessionId(stdout) {
|
|
1102
|
+
const trimmed = stdout.trim();
|
|
1103
|
+
if (!trimmed) {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
try {
|
|
1107
|
+
const parsed = JSON.parse(trimmed);
|
|
1108
|
+
if (typeof parsed.session_id === "string") {
|
|
1109
|
+
const sessionId = parsed.session_id.trim();
|
|
1110
|
+
if (sessionId.length > 0) {
|
|
1111
|
+
return sessionId;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
} catch (_error) {
|
|
1115
|
+
}
|
|
1116
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
1117
|
+
const result = extractGeminiSessionIdFromJsonLine(line);
|
|
1118
|
+
if (result) {
|
|
1119
|
+
return result;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
function extractGeminiSessionIdFromJsonLine(line) {
|
|
1125
|
+
const trimmed = line.trim();
|
|
1126
|
+
if (!trimmed) {
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
const parsed = JSON.parse(trimmed);
|
|
1131
|
+
if (typeof parsed.session_id === "string") {
|
|
1132
|
+
const sessionId = parsed.session_id.trim();
|
|
1133
|
+
return sessionId.length > 0 ? sessionId : null;
|
|
1134
|
+
}
|
|
1135
|
+
} catch (_error) {
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
function collectGeminiTextCandidates(value, out) {
|
|
1141
|
+
if (typeof value === "string") {
|
|
1142
|
+
const trimmed = value.trim();
|
|
1143
|
+
if (trimmed.length > 0) {
|
|
1144
|
+
out.push(trimmed);
|
|
1145
|
+
}
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (Array.isArray(value)) {
|
|
1149
|
+
for (const item of value) {
|
|
1150
|
+
collectGeminiTextCandidates(item, out);
|
|
1151
|
+
}
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (!value || typeof value !== "object") {
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1158
|
+
if (GEMINI_META_KEYS.has(key)) {
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
if (GEMINI_TEXT_KEYS.has(key)) {
|
|
1162
|
+
collectGeminiTextCandidates(child, out);
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
if (key === "parts" && Array.isArray(child)) {
|
|
1166
|
+
collectGeminiTextCandidates(child, out);
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
collectGeminiTextCandidates(child, out);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
function normalizeGeminiOutputText(stdout) {
|
|
1173
|
+
const trimmed = stdout.trim();
|
|
1174
|
+
if (!trimmed) {
|
|
1175
|
+
return "";
|
|
1176
|
+
}
|
|
1177
|
+
const candidates = [];
|
|
1178
|
+
let sawJson = false;
|
|
1179
|
+
try {
|
|
1180
|
+
const parsed = JSON.parse(trimmed);
|
|
1181
|
+
sawJson = true;
|
|
1182
|
+
collectGeminiTextCandidates(parsed, candidates);
|
|
1183
|
+
} catch (_error) {
|
|
1184
|
+
}
|
|
1185
|
+
if (!sawJson) {
|
|
1186
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
1187
|
+
const trimmedLine = line.trim();
|
|
1188
|
+
if (!trimmedLine) continue;
|
|
1189
|
+
try {
|
|
1190
|
+
const parsed = JSON.parse(trimmedLine);
|
|
1191
|
+
sawJson = true;
|
|
1192
|
+
collectGeminiTextCandidates(parsed, candidates);
|
|
1193
|
+
} catch (_error) {
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (!sawJson) {
|
|
1198
|
+
return trimmed;
|
|
1199
|
+
}
|
|
1200
|
+
const unique = [...new Set(candidates)];
|
|
1201
|
+
return unique.join("\n").trim();
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const SESSION_SCOPED_HAPPY_ENV_KEYS = [
|
|
1205
|
+
"HAPPY_CLAUDE_RESUME_SESSION_ID",
|
|
1206
|
+
"HAPPY_CLAUDE_BACKFILL",
|
|
1207
|
+
"HAPPY_CLAUDE_BACKFILL_MAX_MESSAGES",
|
|
1208
|
+
"HAPPY_CLAUDE_BACKFILL_MAX_USER_MESSAGES",
|
|
1209
|
+
"HAPPY_CLAUDE_BACKFILL_MAX_BYTES",
|
|
1210
|
+
"HAPPY_CLAUDE_SKIP_FORK_SESSION",
|
|
1211
|
+
"HAPPY_GEMINI_RESUME_SESSION_ID",
|
|
1212
|
+
"HAPPY_GEMINI_BACKFILL",
|
|
1213
|
+
"HAPPY_CODEX_RESUME_FILE",
|
|
1214
|
+
"HAPPY_CODEX_BACKFILL",
|
|
1215
|
+
"HAPPY_SESSION_TITLE",
|
|
1216
|
+
"HAPPY_WORKTREE_BASE_PATH",
|
|
1217
|
+
"HAPPY_WORKTREE_BRANCH_NAME",
|
|
1218
|
+
"HAPPY_WORKSPACE_REPOS",
|
|
1219
|
+
"HAPPY_WORKSPACE_PATH",
|
|
1220
|
+
"HAPPY_EXTRA_MCP_SERVERS"
|
|
1221
|
+
];
|
|
1222
|
+
function stripSessionScopedHappyEnv(env) {
|
|
1223
|
+
const cleaned = { ...env };
|
|
1224
|
+
for (const key of SESSION_SCOPED_HAPPY_ENV_KEYS) {
|
|
1225
|
+
delete cleaned[key];
|
|
1226
|
+
}
|
|
1227
|
+
return cleaned;
|
|
1228
|
+
}
|
|
1229
|
+
const ORCHESTRATOR_WATCHDOG_GRACE_MS = 5e3;
|
|
1230
|
+
const ORCHESTRATOR_OUTPUT_CAPTURE_LIMIT = 64e3;
|
|
1231
|
+
const ORCHESTRATOR_SHUTDOWN_WAIT_MS = 8e3;
|
|
1232
|
+
const initialMachineMetadata = {
|
|
1233
|
+
host: os.hostname(),
|
|
1234
|
+
platform: os.platform(),
|
|
1235
|
+
happyCliVersion: logger.packageJson.version,
|
|
1236
|
+
homeDir: os.homedir(),
|
|
1237
|
+
happyHomeDir: logger.configuration.happyHomeDir,
|
|
1238
|
+
happyLibDir: projectPath.projectPath()
|
|
1239
|
+
};
|
|
1240
|
+
async function buildMachineMetadata() {
|
|
1241
|
+
const settings = await logger.readSettings();
|
|
1242
|
+
return { ...initialMachineMetadata, deviceMode: settings.deviceMode === true };
|
|
1243
|
+
}
|
|
1244
|
+
async function getProfileEnvironmentVariablesForAgent(profileId, agentType) {
|
|
1245
|
+
try {
|
|
1246
|
+
const settings = await logger.readSettings();
|
|
1247
|
+
const profile = settings.profiles.find((p) => p.id === profileId);
|
|
1248
|
+
if (!profile) {
|
|
1249
|
+
logger.logger.debug(`[DAEMON RUN] Profile ${profileId} not found`);
|
|
1250
|
+
return {};
|
|
1251
|
+
}
|
|
1252
|
+
if (!logger.validateProfileForAgent(profile, agentType)) {
|
|
1253
|
+
logger.logger.debug(`[DAEMON RUN] Profile ${profileId} not compatible with agent ${agentType}`);
|
|
1254
|
+
return {};
|
|
1255
|
+
}
|
|
1256
|
+
const envVars = logger.getProfileEnvironmentVariables(profile);
|
|
1257
|
+
logger.logger.debug(`[DAEMON RUN] Loaded ${Object.keys(envVars).length} environment variables from profile ${profileId} for agent ${agentType}`);
|
|
1258
|
+
return envVars;
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
logger.logger.debug("[DAEMON RUN] Failed to get profile environment variables:", error);
|
|
1261
|
+
return {};
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
async function startDaemon() {
|
|
1265
|
+
for (const key of SESSION_SCOPED_HAPPY_ENV_KEYS) {
|
|
1266
|
+
if (process.env[key] !== void 0) {
|
|
1267
|
+
logger.logger.debug(`[DAEMON RUN] Stripping leaked session-scoped env on entry: ${key}`);
|
|
1268
|
+
delete process.env[key];
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
let requestShutdown;
|
|
1272
|
+
let resolvesWhenShutdownRequested = new Promise((resolve) => {
|
|
1273
|
+
requestShutdown = (source, errorMessage) => {
|
|
1274
|
+
logger.logger.debug(`[DAEMON RUN] Requesting shutdown (source: ${source}, errorMessage: ${errorMessage})`);
|
|
1275
|
+
setTimeout(async () => {
|
|
1276
|
+
logger.logger.debug("[DAEMON RUN] Startup malfunctioned, forcing exit with code 1");
|
|
1277
|
+
await new Promise((resolve2) => setTimeout(resolve2, 100));
|
|
1278
|
+
process.exit(1);
|
|
1279
|
+
}, 1e3);
|
|
1280
|
+
resolve({ source, errorMessage });
|
|
1281
|
+
};
|
|
1282
|
+
});
|
|
1283
|
+
process.on("SIGINT", () => {
|
|
1284
|
+
logger.logger.debug("[DAEMON RUN] Received SIGINT");
|
|
1285
|
+
requestShutdown("os-signal");
|
|
1286
|
+
});
|
|
1287
|
+
process.on("SIGTERM", () => {
|
|
1288
|
+
logger.logger.debug("[DAEMON RUN] Received SIGTERM");
|
|
1289
|
+
requestShutdown("os-signal");
|
|
1290
|
+
});
|
|
1291
|
+
process.on("uncaughtException", (error) => {
|
|
1292
|
+
logger.logger.debug("[DAEMON RUN] FATAL: Uncaught exception", error);
|
|
1293
|
+
logger.logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`);
|
|
1294
|
+
requestShutdown("exception", error.message);
|
|
1295
|
+
});
|
|
1296
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
1297
|
+
logger.logger.debug("[DAEMON RUN] FATAL: Unhandled promise rejection", reason);
|
|
1298
|
+
logger.logger.debug(`[DAEMON RUN] Rejected promise:`, promise);
|
|
1299
|
+
const error = reason instanceof Error ? reason : new Error(`Unhandled promise rejection: ${reason}`);
|
|
1300
|
+
logger.logger.debug(`[DAEMON RUN] Stack trace: ${error.stack}`);
|
|
1301
|
+
requestShutdown("exception", error.message);
|
|
1302
|
+
});
|
|
1303
|
+
process.on("exit", (code) => {
|
|
1304
|
+
logger.logger.debug(`[DAEMON RUN] Process exiting with code: ${code}`);
|
|
1305
|
+
});
|
|
1306
|
+
process.on("beforeExit", (code) => {
|
|
1307
|
+
logger.logger.debug(`[DAEMON RUN] Process about to exit with code: ${code}`);
|
|
1308
|
+
});
|
|
1309
|
+
logger.logger.debug("[DAEMON RUN] Starting daemon process...");
|
|
1310
|
+
logger.logger.debugLargeJson("[DAEMON RUN] Environment", doctor.getEnvironmentInfo());
|
|
1311
|
+
const runningDaemonVersionMatches = await controlClient.isDaemonRunningCurrentlyInstalledHappyVersion();
|
|
1312
|
+
if (!runningDaemonVersionMatches) {
|
|
1313
|
+
logger.logger.debug("[DAEMON RUN] Daemon version mismatch detected, restarting daemon with current CLI version");
|
|
1314
|
+
await controlClient.stopDaemon();
|
|
1315
|
+
} else {
|
|
1316
|
+
logger.logger.debug("[DAEMON RUN] Daemon version matches, keeping existing daemon");
|
|
1317
|
+
console.log("Daemon already running with matching version");
|
|
1318
|
+
process.exit(0);
|
|
1319
|
+
}
|
|
1320
|
+
const daemonLockHandle = await logger.acquireDaemonLock(5, 200);
|
|
1321
|
+
if (!daemonLockHandle) {
|
|
1322
|
+
logger.logger.debug("[DAEMON RUN] Daemon lock file already held, another daemon is running");
|
|
1323
|
+
process.exit(0);
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
const caffeinateStarted = startCaffeinate();
|
|
1327
|
+
if (caffeinateStarted) {
|
|
1328
|
+
logger.logger.debug("[DAEMON RUN] Sleep prevention enabled");
|
|
1329
|
+
}
|
|
1330
|
+
const { credentials, machineId } = await auth.authAndSetupMachineIfNeeded();
|
|
1331
|
+
logger.logger.debug("[DAEMON RUN] Auth and machine setup complete");
|
|
1332
|
+
let api$1;
|
|
1333
|
+
const pidToTrackedSession = /* @__PURE__ */ new Map();
|
|
1334
|
+
const pidToAwaiter = /* @__PURE__ */ new Map();
|
|
1335
|
+
const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
|
|
1336
|
+
const executionIdToManagedExecution = /* @__PURE__ */ new Map();
|
|
1337
|
+
const clearExecutionTimers = (execution) => {
|
|
1338
|
+
if (execution.watchdogTimer) {
|
|
1339
|
+
clearTimeout(execution.watchdogTimer);
|
|
1340
|
+
execution.watchdogTimer = null;
|
|
1341
|
+
}
|
|
1342
|
+
if (execution.killTimer) {
|
|
1343
|
+
clearTimeout(execution.killTimer);
|
|
1344
|
+
execution.killTimer = null;
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
const requestExecutionTermination = (execution) => {
|
|
1348
|
+
if (execution.child.killed) {
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
try {
|
|
1352
|
+
execution.child.kill("SIGTERM");
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
logger.logger.debug(`[ORCHESTRATOR] Failed to send SIGTERM for execution ${execution.payload.executionId}`, error);
|
|
1355
|
+
}
|
|
1356
|
+
if (execution.killTimer) {
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
execution.killTimer = setTimeout(() => {
|
|
1360
|
+
execution.killTimer = null;
|
|
1361
|
+
if (execution.child.exitCode !== null || execution.child.signalCode !== null) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
try {
|
|
1365
|
+
execution.child.kill("SIGKILL");
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
logger.logger.debug(`[ORCHESTRATOR] Failed to send SIGKILL for execution ${execution.payload.executionId}`, error);
|
|
1368
|
+
}
|
|
1369
|
+
}, ORCHESTRATOR_WATCHDOG_GRACE_MS);
|
|
1370
|
+
};
|
|
1371
|
+
const reportExecutionFinish = async (execution, opts) => {
|
|
1372
|
+
if (execution.finishReportPromise) {
|
|
1373
|
+
await execution.finishReportPromise;
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
execution.finishReportPromise = (async () => {
|
|
1377
|
+
const normalizedStdout = execution.payload.provider === "gemini" ? normalizeGeminiOutputText(execution.stdout) : execution.stdout.trim();
|
|
1378
|
+
const outputText = [normalizedStdout, execution.stderr.trim()].filter(Boolean).join("\n");
|
|
1379
|
+
const outputSummary = common.buildOutputSummary(normalizedStdout, execution.stderr);
|
|
1380
|
+
const normalizedChildSessionId = execution.detectedChildSessionId?.trim();
|
|
1381
|
+
try {
|
|
1382
|
+
await api$1.reportOrchestratorExecutionFinish({
|
|
1383
|
+
executionId: execution.payload.executionId,
|
|
1384
|
+
dispatchToken: execution.payload.dispatchToken,
|
|
1385
|
+
status: opts.status,
|
|
1386
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1387
|
+
exitCode: opts.exitCode,
|
|
1388
|
+
signal: opts.signal,
|
|
1389
|
+
outputSummary: outputSummary ?? void 0,
|
|
1390
|
+
outputText: outputText || void 0,
|
|
1391
|
+
childSessionId: normalizedChildSessionId ? normalizedChildSessionId : void 0,
|
|
1392
|
+
errorCode: opts.errorCode,
|
|
1393
|
+
errorMessage: opts.errorMessage
|
|
1394
|
+
});
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
logger.logger.debug(`[ORCHESTRATOR] Failed to report finish for execution ${execution.payload.executionId}`, error);
|
|
1397
|
+
} finally {
|
|
1398
|
+
clearExecutionTimers(execution);
|
|
1399
|
+
executionIdToManagedExecution.delete(execution.payload.executionId);
|
|
1400
|
+
}
|
|
1401
|
+
})();
|
|
1402
|
+
await execution.finishReportPromise;
|
|
1403
|
+
};
|
|
1404
|
+
const finalizeExecution = async (executionId, exitCode, signal) => {
|
|
1405
|
+
const execution = executionIdToManagedExecution.get(executionId);
|
|
1406
|
+
if (!execution) {
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
if (!execution.detectedChildSessionId && execution.payload.provider === "gemini" && execution.payload.executionType === "initial") {
|
|
1410
|
+
const parsed = extractGeminiSessionId(execution.stdout);
|
|
1411
|
+
if (parsed) {
|
|
1412
|
+
execution.detectedChildSessionId = parsed;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
const status = common.mapFinishStatus({
|
|
1416
|
+
watchdogTriggered: execution.watchdogTriggered,
|
|
1417
|
+
cancelRequested: execution.cancelRequested,
|
|
1418
|
+
exitCode
|
|
1419
|
+
});
|
|
1420
|
+
await execution.startReportPromise;
|
|
1421
|
+
await reportExecutionFinish(execution, {
|
|
1422
|
+
status,
|
|
1423
|
+
exitCode,
|
|
1424
|
+
signal,
|
|
1425
|
+
errorCode: status === "timeout" ? "WATCHDOG_TIMEOUT" : status === "failed" ? "PROCESS_EXIT_NON_ZERO" : void 0,
|
|
1426
|
+
errorMessage: status === "timeout" ? `Execution exceeded timeout (${execution.payload.timeoutMs}ms)` : status === "failed" ? `Process exited with code ${exitCode ?? "unknown"}${signal ? ` (signal ${signal})` : ""}` : void 0
|
|
1427
|
+
});
|
|
1428
|
+
};
|
|
1429
|
+
const handleOrchestratorDispatch = async (payload) => {
|
|
1430
|
+
const existing = executionIdToManagedExecution.get(payload.executionId);
|
|
1431
|
+
if (existing) {
|
|
1432
|
+
if (existing.payload.dispatchToken !== payload.dispatchToken) {
|
|
1433
|
+
throw new Error(`Execution ${payload.executionId} already running with different dispatchToken`);
|
|
1434
|
+
}
|
|
1435
|
+
return { accepted: true, duplicate: true };
|
|
1436
|
+
}
|
|
1437
|
+
const oneshotEnv = common.buildOrchestratorEnv(payload);
|
|
1438
|
+
const child = spawnHappyCLI.spawnHappyCLI(["orchestrator-oneshot", "--provider", payload.provider], {
|
|
1439
|
+
cwd: os.homedir(),
|
|
1440
|
+
detached: false,
|
|
1441
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1442
|
+
env: {
|
|
1443
|
+
...stripSessionScopedHappyEnv(process.env),
|
|
1444
|
+
...oneshotEnv
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
const execution = {
|
|
1448
|
+
payload,
|
|
1449
|
+
child,
|
|
1450
|
+
startedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1451
|
+
cancelRequested: false,
|
|
1452
|
+
watchdogTriggered: false,
|
|
1453
|
+
detectedChildSessionId: payload.childSessionId ?? null,
|
|
1454
|
+
startReportPromise: Promise.resolve(),
|
|
1455
|
+
finishReportPromise: null,
|
|
1456
|
+
stdout: "",
|
|
1457
|
+
stderr: "",
|
|
1458
|
+
stdoutLineBuffer: "",
|
|
1459
|
+
watchdogTimer: null,
|
|
1460
|
+
killTimer: null
|
|
1461
|
+
};
|
|
1462
|
+
execution.startReportPromise = api$1.reportOrchestratorExecutionStart({
|
|
1463
|
+
executionId: payload.executionId,
|
|
1464
|
+
dispatchToken: payload.dispatchToken,
|
|
1465
|
+
startedAt: execution.startedAtIso,
|
|
1466
|
+
pid: child.pid
|
|
1467
|
+
}).catch((error) => {
|
|
1468
|
+
logger.logger.debug(`[ORCHESTRATOR] Failed to report start for execution ${payload.executionId}`, error);
|
|
1469
|
+
});
|
|
1470
|
+
executionIdToManagedExecution.set(payload.executionId, execution);
|
|
1471
|
+
child.stdout?.on("data", (chunk) => {
|
|
1472
|
+
const text = chunk.toString();
|
|
1473
|
+
execution.stdout = common.appendOutputChunk(execution.stdout, text, ORCHESTRATOR_OUTPUT_CAPTURE_LIMIT);
|
|
1474
|
+
if (!execution.detectedChildSessionId) {
|
|
1475
|
+
if (payload.provider === "codex" && payload.executionType === "initial") {
|
|
1476
|
+
const probe = `${execution.stdoutLineBuffer}${text}`;
|
|
1477
|
+
const parsed = extractCodexSessionId(probe);
|
|
1478
|
+
if (parsed) {
|
|
1479
|
+
execution.detectedChildSessionId = parsed;
|
|
1480
|
+
}
|
|
1481
|
+
execution.stdoutLineBuffer = probe.slice(-256);
|
|
1482
|
+
} else if (payload.provider === "gemini" && payload.executionType === "initial") {
|
|
1483
|
+
execution.stdoutLineBuffer += text;
|
|
1484
|
+
const lines = execution.stdoutLineBuffer.split(/\r?\n/);
|
|
1485
|
+
execution.stdoutLineBuffer = lines.pop() ?? "";
|
|
1486
|
+
for (const line of lines) {
|
|
1487
|
+
const parsed = extractGeminiSessionIdFromJsonLine(line);
|
|
1488
|
+
if (parsed) {
|
|
1489
|
+
execution.detectedChildSessionId = parsed;
|
|
1490
|
+
break;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
child.stderr?.on("data", (chunk) => {
|
|
1497
|
+
const text = chunk.toString();
|
|
1498
|
+
execution.stderr = common.appendOutputChunk(execution.stderr, text, ORCHESTRATOR_OUTPUT_CAPTURE_LIMIT);
|
|
1499
|
+
if (!execution.detectedChildSessionId && payload.provider === "codex" && payload.executionType === "initial") {
|
|
1500
|
+
const probe = `${execution.stdoutLineBuffer}${text}`;
|
|
1501
|
+
const parsed = extractCodexSessionId(probe);
|
|
1502
|
+
if (parsed) {
|
|
1503
|
+
execution.detectedChildSessionId = parsed;
|
|
1504
|
+
}
|
|
1505
|
+
execution.stdoutLineBuffer = probe.slice(-256);
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
child.once("error", async (error) => {
|
|
1509
|
+
execution.stderr = common.appendOutputChunk(execution.stderr, `
|
|
1510
|
+
${error.message}
|
|
1511
|
+
`, ORCHESTRATOR_OUTPUT_CAPTURE_LIMIT);
|
|
1512
|
+
await execution.startReportPromise;
|
|
1513
|
+
await reportExecutionFinish(execution, {
|
|
1514
|
+
status: execution.watchdogTriggered ? "timeout" : execution.cancelRequested ? "cancelled" : "failed",
|
|
1515
|
+
exitCode: execution.child.exitCode,
|
|
1516
|
+
signal: execution.child.signalCode,
|
|
1517
|
+
errorCode: "SPAWN_ERROR",
|
|
1518
|
+
errorMessage: error.message
|
|
1519
|
+
});
|
|
1520
|
+
});
|
|
1521
|
+
child.once("exit", async (code, signal) => {
|
|
1522
|
+
await finalizeExecution(payload.executionId, code, signal);
|
|
1523
|
+
});
|
|
1524
|
+
execution.watchdogTimer = setTimeout(() => {
|
|
1525
|
+
execution.watchdogTimer = null;
|
|
1526
|
+
execution.watchdogTriggered = true;
|
|
1527
|
+
requestExecutionTermination(execution);
|
|
1528
|
+
}, payload.timeoutMs);
|
|
1529
|
+
return { accepted: true };
|
|
1530
|
+
};
|
|
1531
|
+
const handleOrchestratorCancel = async (payload) => {
|
|
1532
|
+
const execution = executionIdToManagedExecution.get(payload.executionId);
|
|
1533
|
+
if (!execution) {
|
|
1534
|
+
return { accepted: true, notFound: true };
|
|
1535
|
+
}
|
|
1536
|
+
if (execution.payload.dispatchToken !== payload.dispatchToken) {
|
|
1537
|
+
throw new Error(`dispatchToken mismatch for execution ${payload.executionId}`);
|
|
1538
|
+
}
|
|
1539
|
+
execution.cancelRequested = true;
|
|
1540
|
+
requestExecutionTermination(execution);
|
|
1541
|
+
return { accepted: true };
|
|
1542
|
+
};
|
|
1543
|
+
const stopAllOrchestratorExecutions = async () => {
|
|
1544
|
+
const executions = Array.from(executionIdToManagedExecution.values());
|
|
1545
|
+
if (executions.length === 0) {
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
logger.logger.debug(`[ORCHESTRATOR] Stopping ${executions.length} running orchestrator execution(s)`);
|
|
1549
|
+
for (const execution of executions) {
|
|
1550
|
+
execution.cancelRequested = true;
|
|
1551
|
+
requestExecutionTermination(execution);
|
|
1552
|
+
}
|
|
1553
|
+
const deadline = Date.now() + ORCHESTRATOR_SHUTDOWN_WAIT_MS;
|
|
1554
|
+
while (executionIdToManagedExecution.size > 0 && Date.now() < deadline) {
|
|
1555
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
const onHappySessionWebhook = (sessionId, sessionMetadata) => {
|
|
1559
|
+
logger.logger.debugLargeJson(`[DAEMON RUN] Session reported`, sessionMetadata);
|
|
1560
|
+
const pid = sessionMetadata.hostPid;
|
|
1561
|
+
if (!pid) {
|
|
1562
|
+
logger.logger.debug(`[DAEMON RUN] Session webhook missing hostPid for sessionId: ${sessionId}`);
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
logger.logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || "unknown"}`);
|
|
1566
|
+
logger.logger.debug(`[DAEMON RUN] Current tracked sessions before webhook: ${Array.from(pidToTrackedSession.keys()).join(", ")}`);
|
|
1567
|
+
const existingSession = pidToTrackedSession.get(pid);
|
|
1568
|
+
if (existingSession && existingSession.startedBy === "daemon") {
|
|
1569
|
+
existingSession.happySessionId = sessionId;
|
|
1570
|
+
existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata;
|
|
1571
|
+
logger.logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`);
|
|
1572
|
+
const awaiter = pidToAwaiter.get(pid);
|
|
1573
|
+
if (awaiter) {
|
|
1574
|
+
pidToAwaiter.delete(pid);
|
|
1575
|
+
awaiter(existingSession);
|
|
1576
|
+
logger.logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`);
|
|
1577
|
+
}
|
|
1578
|
+
} else if (!existingSession) {
|
|
1579
|
+
const trackedSession = {
|
|
1580
|
+
startedBy: "happy directly - likely by user from terminal",
|
|
1581
|
+
happySessionId: sessionId,
|
|
1582
|
+
happySessionMetadataFromLocalWebhook: sessionMetadata,
|
|
1583
|
+
pid
|
|
1584
|
+
};
|
|
1585
|
+
pidToTrackedSession.set(pid, trackedSession);
|
|
1586
|
+
logger.logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`);
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
const spawnSession = async (options) => {
|
|
1590
|
+
logger.logger.debugLargeJson("[DAEMON RUN] Spawning session", options);
|
|
1591
|
+
const { directory, sessionId, resumeSessionId: rawResumeSessionId, sessionTitle, skipForkSession, machineId: machineId2, approvedNewDirectoryCreation = true, intent } = options;
|
|
1592
|
+
const isClaudeAgent = !options.agent || options.agent === "claude";
|
|
1593
|
+
const resumeSessionId = intent === "resume" ? rawResumeSessionId : void 0;
|
|
1594
|
+
if (rawResumeSessionId && intent !== "resume") {
|
|
1595
|
+
logger.logger.debug('[DAEMON RUN] Ignoring resumeSessionId because intent is not "resume"', { rawResumeSessionId, intent });
|
|
1596
|
+
}
|
|
1597
|
+
let directoryCreated = false;
|
|
1598
|
+
try {
|
|
1599
|
+
await fs$1.access(directory);
|
|
1600
|
+
logger.logger.debug(`[DAEMON RUN] Directory exists: ${directory}`);
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
logger.logger.debug(`[DAEMON RUN] Directory doesn't exist, creating: ${directory}`);
|
|
1603
|
+
if (!approvedNewDirectoryCreation) {
|
|
1604
|
+
logger.logger.debug(`[DAEMON RUN] Directory creation not approved for: ${directory}`);
|
|
1605
|
+
return {
|
|
1606
|
+
type: "requestToApproveDirectoryCreation",
|
|
1607
|
+
directory
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
try {
|
|
1611
|
+
await fs$1.mkdir(directory, { recursive: true });
|
|
1612
|
+
logger.logger.debug(`[DAEMON RUN] Successfully created directory: ${directory}`);
|
|
1613
|
+
directoryCreated = true;
|
|
1614
|
+
} catch (mkdirError) {
|
|
1615
|
+
let errorMessage = `Unable to create directory at '${directory}'. `;
|
|
1616
|
+
if (mkdirError.code === "EACCES") {
|
|
1617
|
+
errorMessage += `Permission denied. You don't have write access to create a folder at this location. Try using a different path or check your permissions.`;
|
|
1618
|
+
} else if (mkdirError.code === "ENOTDIR") {
|
|
1619
|
+
errorMessage += `A file already exists at this path or in the parent path. Cannot create a directory here. Please choose a different location.`;
|
|
1620
|
+
} else if (mkdirError.code === "ENOSPC") {
|
|
1621
|
+
errorMessage += `No space left on device. Your disk is full. Please free up some space and try again.`;
|
|
1622
|
+
} else if (mkdirError.code === "EROFS") {
|
|
1623
|
+
errorMessage += `The file system is read-only. Cannot create directories here. Please choose a writable location.`;
|
|
1624
|
+
} else {
|
|
1625
|
+
errorMessage += `System error: ${mkdirError.message || mkdirError}. Please verify the path is valid and you have the necessary permissions.`;
|
|
1626
|
+
}
|
|
1627
|
+
logger.logger.debug(`[DAEMON RUN] Directory creation failed: ${errorMessage}`);
|
|
1628
|
+
return {
|
|
1629
|
+
type: "error",
|
|
1630
|
+
errorMessage
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
try {
|
|
1635
|
+
const authEnv = {};
|
|
1636
|
+
if (options.token) {
|
|
1637
|
+
if (options.agent === "codex") {
|
|
1638
|
+
const codexHomeDir = tmp__namespace.dirSync();
|
|
1639
|
+
fs$1.writeFile(path$1.join(codexHomeDir.name, "auth.json"), options.token);
|
|
1640
|
+
authEnv.CODEX_HOME = codexHomeDir.name;
|
|
1641
|
+
} else {
|
|
1642
|
+
authEnv.CLAUDE_CODE_OAUTH_TOKEN = options.token;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
let profileEnv = {};
|
|
1646
|
+
if (options.environmentVariables && Object.keys(options.environmentVariables).length > 0) {
|
|
1647
|
+
profileEnv = options.environmentVariables;
|
|
1648
|
+
logger.logger.info(`[DAEMON RUN] Using GUI-provided profile environment variables (${Object.keys(profileEnv).length} vars)`);
|
|
1649
|
+
logger.logger.debug(`[DAEMON RUN] GUI profile env var keys: ${Object.keys(profileEnv).join(", ")}`);
|
|
1650
|
+
} else {
|
|
1651
|
+
try {
|
|
1652
|
+
const settings = await logger.readSettings();
|
|
1653
|
+
if (settings.activeProfileId) {
|
|
1654
|
+
logger.logger.debug(`[DAEMON RUN] No GUI profile provided, loading CLI local active profile: ${settings.activeProfileId}`);
|
|
1655
|
+
profileEnv = await getProfileEnvironmentVariablesForAgent(
|
|
1656
|
+
settings.activeProfileId,
|
|
1657
|
+
options.agent || "claude"
|
|
1658
|
+
);
|
|
1659
|
+
logger.logger.debug(`[DAEMON RUN] Loaded ${Object.keys(profileEnv).length} environment variables from CLI local profile for agent ${options.agent || "claude"}`);
|
|
1660
|
+
logger.logger.debug(`[DAEMON RUN] CLI profile env var keys: ${Object.keys(profileEnv).join(", ")}`);
|
|
1661
|
+
} else {
|
|
1662
|
+
logger.logger.debug("[DAEMON RUN] No CLI local active profile set");
|
|
1663
|
+
}
|
|
1664
|
+
} catch (error) {
|
|
1665
|
+
logger.logger.debug("[DAEMON RUN] Failed to load CLI local profile environment variables:", error);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
let extraEnv = { ...profileEnv, ...authEnv };
|
|
1669
|
+
if (resumeSessionId && isClaudeAgent) {
|
|
1670
|
+
extraEnv.HAPPY_CLAUDE_BACKFILL = "1";
|
|
1671
|
+
extraEnv.HAPPY_CLAUDE_BACKFILL_MAX_MESSAGES = "200";
|
|
1672
|
+
extraEnv.HAPPY_CLAUDE_BACKFILL_MAX_USER_MESSAGES = "20";
|
|
1673
|
+
extraEnv.HAPPY_CLAUDE_RESUME_SESSION_ID = resumeSessionId;
|
|
1674
|
+
if (skipForkSession) {
|
|
1675
|
+
extraEnv.HAPPY_CLAUDE_SKIP_FORK_SESSION = "1";
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
if (resumeSessionId && options.agent === "gemini") {
|
|
1679
|
+
extraEnv.HAPPY_GEMINI_RESUME_SESSION_ID = resumeSessionId;
|
|
1680
|
+
extraEnv.HAPPY_GEMINI_BACKFILL = "1";
|
|
1681
|
+
}
|
|
1682
|
+
if (resumeSessionId && options.agent === "codex") {
|
|
1683
|
+
extraEnv.HAPPY_CODEX_RESUME_FILE = resumeSessionId;
|
|
1684
|
+
extraEnv.HAPPY_CODEX_BACKFILL = "1";
|
|
1685
|
+
}
|
|
1686
|
+
if (sessionTitle) {
|
|
1687
|
+
extraEnv.HAPPY_SESSION_TITLE = sessionTitle;
|
|
1688
|
+
}
|
|
1689
|
+
if (options.worktreeBasePath) {
|
|
1690
|
+
extraEnv.HAPPY_WORKTREE_BASE_PATH = options.worktreeBasePath;
|
|
1691
|
+
}
|
|
1692
|
+
if (options.worktreeBranchName) {
|
|
1693
|
+
extraEnv.HAPPY_WORKTREE_BRANCH_NAME = options.worktreeBranchName;
|
|
1694
|
+
}
|
|
1695
|
+
if (options.workspaceRepos && options.workspaceRepos.length > 0) {
|
|
1696
|
+
extraEnv.HAPPY_WORKSPACE_REPOS = JSON.stringify(options.workspaceRepos);
|
|
1697
|
+
}
|
|
1698
|
+
if (options.workspacePath) {
|
|
1699
|
+
extraEnv.HAPPY_WORKSPACE_PATH = options.workspacePath;
|
|
1700
|
+
}
|
|
1701
|
+
if (options.mcpServers && options.mcpServers.length > 0) {
|
|
1702
|
+
extraEnv.HAPPY_EXTRA_MCP_SERVERS = JSON.stringify(options.mcpServers);
|
|
1703
|
+
}
|
|
1704
|
+
logger.logger.debug(`[DAEMON RUN] Final environment variable keys (before expansion) (${Object.keys(extraEnv).length}): ${Object.keys(extraEnv).join(", ")}`);
|
|
1705
|
+
extraEnv = expandEnvironmentVariables(extraEnv, process.env);
|
|
1706
|
+
logger.logger.debug(`[DAEMON RUN] After variable expansion: ${Object.keys(extraEnv).join(", ")}`);
|
|
1707
|
+
const potentialAuthVars = ["ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_HOME", "AZURE_OPENAI_API_KEY", "TOGETHER_API_KEY"];
|
|
1708
|
+
const unexpandedAuthVars = potentialAuthVars.filter((varName) => {
|
|
1709
|
+
const value = extraEnv[varName];
|
|
1710
|
+
return value && typeof value === "string" && value.includes("${");
|
|
1711
|
+
});
|
|
1712
|
+
if (unexpandedAuthVars.length > 0) {
|
|
1713
|
+
const missingVarDetails = unexpandedAuthVars.map((authVar) => {
|
|
1714
|
+
const value = extraEnv[authVar];
|
|
1715
|
+
const unresolvedMatch = value?.match(/\$\{([A-Z_][A-Z0-9_]*)(:-[^}]*)?\}/);
|
|
1716
|
+
const missingVar = unresolvedMatch ? unresolvedMatch[1] : "unknown";
|
|
1717
|
+
return `${authVar} references \${${missingVar}} which is not defined`;
|
|
1718
|
+
});
|
|
1719
|
+
const errorMessage = `Authentication will fail - environment variables not found in daemon: ${missingVarDetails.join("; ")}. Ensure these variables are set in the daemon's environment (not just your shell) before starting sessions.`;
|
|
1720
|
+
logger.logger.warn(`[DAEMON RUN] ${errorMessage}`);
|
|
1721
|
+
return {
|
|
1722
|
+
type: "error",
|
|
1723
|
+
errorMessage
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
if (options.repoScripts && options.repoScripts.length > 0) {
|
|
1727
|
+
const sequentialScripts = options.repoScripts.filter((s) => s.setupScript && !s.parallelSetup);
|
|
1728
|
+
const parallelScripts = options.repoScripts.filter((s) => s.setupScript && s.parallelSetup);
|
|
1729
|
+
for (const script of sequentialScripts) {
|
|
1730
|
+
logger.logger.info(`[DAEMON] Running setup script for ${script.repoDisplayName}...`);
|
|
1731
|
+
try {
|
|
1732
|
+
child_process.execSync(script.setupScript, { cwd: script.worktreePath, stdio: "pipe", timeout: 3e5 });
|
|
1733
|
+
logger.logger.info(`[DAEMON] Setup script completed for ${script.repoDisplayName}`);
|
|
1734
|
+
} catch (err) {
|
|
1735
|
+
logger.logger.warn(`[DAEMON] Setup script failed for ${script.repoDisplayName}: ${err.message}`);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
for (const script of parallelScripts) {
|
|
1739
|
+
logger.logger.info(`[DAEMON] Running parallel setup for ${script.repoDisplayName}...`);
|
|
1740
|
+
const child = child_process.exec(script.setupScript, { cwd: script.worktreePath });
|
|
1741
|
+
child.on("exit", (code) => {
|
|
1742
|
+
if (code === 0) {
|
|
1743
|
+
logger.logger.info(`[DAEMON] Parallel setup completed for ${script.repoDisplayName}`);
|
|
1744
|
+
} else {
|
|
1745
|
+
logger.logger.warn(`[DAEMON] Parallel setup failed for ${script.repoDisplayName} (exit code ${code})`);
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
const tmuxAvailable = await isTmuxAvailable();
|
|
1751
|
+
let useTmux = tmuxAvailable;
|
|
1752
|
+
let tmuxSessionName = extraEnv.TMUX_SESSION_NAME;
|
|
1753
|
+
if (!tmuxAvailable || tmuxSessionName === void 0) {
|
|
1754
|
+
useTmux = false;
|
|
1755
|
+
if (tmuxSessionName !== void 0) {
|
|
1756
|
+
logger.logger.debug(`[DAEMON RUN] tmux session name specified but tmux not available, falling back to regular spawning`);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
if (useTmux && tmuxSessionName !== void 0) {
|
|
1760
|
+
const sessionDesc = tmuxSessionName || "current/most recent session";
|
|
1761
|
+
logger.logger.debug(`[DAEMON RUN] Attempting to spawn session in tmux: ${sessionDesc}`);
|
|
1762
|
+
const tmux = getTmuxUtilities(tmuxSessionName);
|
|
1763
|
+
const cliPath = path$1.join(projectPath.projectPath(), "dist", "index.mjs");
|
|
1764
|
+
const agent = options.agent === "gemini" ? "gemini" : options.agent === "codex" ? "codex" : "claude";
|
|
1765
|
+
const forkFlag = skipForkSession ? "" : " --fork-session";
|
|
1766
|
+
const resumeArgs = resumeSessionId && isClaudeAgent ? ` --resume ${resumeSessionId}${forkFlag}` : "";
|
|
1767
|
+
const fullCommand = `node --no-warnings --no-deprecation ${cliPath} ${agent} --happy-starting-mode remote --started-by daemon${resumeArgs}`;
|
|
1768
|
+
const windowName = `happy-${Date.now()}-${agent}`;
|
|
1769
|
+
const tmuxEnv = {};
|
|
1770
|
+
const sanitizedDaemonEnv = stripSessionScopedHappyEnv(process.env);
|
|
1771
|
+
for (const [key, value] of Object.entries(sanitizedDaemonEnv)) {
|
|
1772
|
+
if (value !== void 0) {
|
|
1773
|
+
tmuxEnv[key] = value;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
Object.assign(tmuxEnv, extraEnv);
|
|
1777
|
+
const tmuxResult = await tmux.spawnInTmux([fullCommand], {
|
|
1778
|
+
sessionName: tmuxSessionName,
|
|
1779
|
+
windowName,
|
|
1780
|
+
cwd: directory
|
|
1781
|
+
}, tmuxEnv);
|
|
1782
|
+
if (tmuxResult.success) {
|
|
1783
|
+
logger.logger.debug(`[DAEMON RUN] Successfully spawned in tmux session: ${tmuxResult.sessionId}, PID: ${tmuxResult.pid}`);
|
|
1784
|
+
if (!tmuxResult.pid) {
|
|
1785
|
+
throw new Error("Tmux window created but no PID returned");
|
|
1786
|
+
}
|
|
1787
|
+
const trackedSession = {
|
|
1788
|
+
startedBy: "daemon",
|
|
1789
|
+
pid: tmuxResult.pid,
|
|
1790
|
+
// Real PID from tmux -P flag
|
|
1791
|
+
tmuxSessionId: tmuxResult.sessionId,
|
|
1792
|
+
directoryCreated,
|
|
1793
|
+
message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.` : `Spawned new session in tmux session '${tmuxSessionName}'. Use 'tmux attach -t ${tmuxSessionName}' to view the session.`,
|
|
1794
|
+
repoScripts: options.repoScripts
|
|
1795
|
+
};
|
|
1796
|
+
pidToTrackedSession.set(tmuxResult.pid, trackedSession);
|
|
1797
|
+
logger.logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${tmuxResult.pid} (tmux)`);
|
|
1798
|
+
return new Promise((resolve) => {
|
|
1799
|
+
const timeout = setTimeout(() => {
|
|
1800
|
+
pidToAwaiter.delete(tmuxResult.pid);
|
|
1801
|
+
logger.logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${tmuxResult.pid} (tmux)`);
|
|
1802
|
+
resolve({
|
|
1803
|
+
type: "error",
|
|
1804
|
+
errorMessage: `Session webhook timeout for PID ${tmuxResult.pid} (tmux)`
|
|
1805
|
+
});
|
|
1806
|
+
}, 15e3);
|
|
1807
|
+
pidToAwaiter.set(tmuxResult.pid, (completedSession) => {
|
|
1808
|
+
clearTimeout(timeout);
|
|
1809
|
+
logger.logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook (tmux)`);
|
|
1810
|
+
resolve({
|
|
1811
|
+
type: "success",
|
|
1812
|
+
sessionId: completedSession.happySessionId
|
|
1813
|
+
});
|
|
1814
|
+
});
|
|
1815
|
+
});
|
|
1816
|
+
} else {
|
|
1817
|
+
logger.logger.debug(`[DAEMON RUN] Failed to spawn in tmux: ${tmuxResult.error}, falling back to regular spawning`);
|
|
1818
|
+
useTmux = false;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (!useTmux) {
|
|
1822
|
+
logger.logger.debug(`[DAEMON RUN] Using regular process spawning`);
|
|
1823
|
+
let agentCommand;
|
|
1824
|
+
switch (options.agent) {
|
|
1825
|
+
case "claude":
|
|
1826
|
+
case void 0:
|
|
1827
|
+
agentCommand = "claude";
|
|
1828
|
+
break;
|
|
1829
|
+
case "codex":
|
|
1830
|
+
agentCommand = "codex";
|
|
1831
|
+
break;
|
|
1832
|
+
case "gemini":
|
|
1833
|
+
agentCommand = "gemini";
|
|
1834
|
+
break;
|
|
1835
|
+
default:
|
|
1836
|
+
return {
|
|
1837
|
+
type: "error",
|
|
1838
|
+
errorMessage: `Unsupported agent type: '${options.agent}'. Please update your CLI to the latest version.`
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
const args = [
|
|
1842
|
+
agentCommand,
|
|
1843
|
+
"--happy-starting-mode",
|
|
1844
|
+
"remote",
|
|
1845
|
+
"--started-by",
|
|
1846
|
+
"daemon"
|
|
1847
|
+
];
|
|
1848
|
+
if (resumeSessionId && isClaudeAgent) {
|
|
1849
|
+
args.push("--resume", resumeSessionId);
|
|
1850
|
+
if (!skipForkSession) {
|
|
1851
|
+
args.push("--fork-session");
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
const happyProcess = spawnHappyCLI.spawnHappyCLI(args, {
|
|
1855
|
+
cwd: directory,
|
|
1856
|
+
detached: true,
|
|
1857
|
+
// Sessions stay alive when daemon stops
|
|
1858
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1859
|
+
// Capture stdout/stderr for debugging
|
|
1860
|
+
env: {
|
|
1861
|
+
// Defense-in-depth: even if startDaemon's entry sanitization is
|
|
1862
|
+
// bypassed somehow, the spawn site re-strips session-scoped
|
|
1863
|
+
// HAPPY_* keys before extraEnv adds them back deterministically.
|
|
1864
|
+
...stripSessionScopedHappyEnv(process.env),
|
|
1865
|
+
...extraEnv
|
|
1866
|
+
}
|
|
1867
|
+
});
|
|
1868
|
+
if (logger.isDebug()) {
|
|
1869
|
+
happyProcess.stdout?.on("data", (data) => {
|
|
1870
|
+
logger.logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`);
|
|
1871
|
+
});
|
|
1872
|
+
happyProcess.stderr?.on("data", (data) => {
|
|
1873
|
+
logger.logger.debug(`[DAEMON RUN] Child stderr: ${data.toString()}`);
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
if (!happyProcess.pid) {
|
|
1877
|
+
logger.logger.debug("[DAEMON RUN] Failed to spawn process - no PID returned");
|
|
1878
|
+
return {
|
|
1879
|
+
type: "error",
|
|
1880
|
+
errorMessage: "Failed to spawn Happy process - no PID returned"
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
logger.logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`);
|
|
1884
|
+
const trackedSession = {
|
|
1885
|
+
startedBy: "daemon",
|
|
1886
|
+
pid: happyProcess.pid,
|
|
1887
|
+
childProcess: happyProcess,
|
|
1888
|
+
directoryCreated,
|
|
1889
|
+
message: directoryCreated ? `The path '${directory}' did not exist. We created a new folder and spawned a new session there.` : void 0,
|
|
1890
|
+
repoScripts: options.repoScripts
|
|
1891
|
+
};
|
|
1892
|
+
pidToTrackedSession.set(happyProcess.pid, trackedSession);
|
|
1893
|
+
happyProcess.on("exit", (code, signal) => {
|
|
1894
|
+
logger.logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`);
|
|
1895
|
+
if (happyProcess.pid) {
|
|
1896
|
+
onChildExited(happyProcess.pid);
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1899
|
+
happyProcess.on("error", (error) => {
|
|
1900
|
+
logger.logger.debug(`[DAEMON RUN] Child process error:`, error);
|
|
1901
|
+
if (happyProcess.pid) {
|
|
1902
|
+
onChildExited(happyProcess.pid);
|
|
1903
|
+
}
|
|
1904
|
+
});
|
|
1905
|
+
logger.logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`);
|
|
1906
|
+
return new Promise((resolve) => {
|
|
1907
|
+
const timeout = setTimeout(() => {
|
|
1908
|
+
pidToAwaiter.delete(happyProcess.pid);
|
|
1909
|
+
logger.logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`);
|
|
1910
|
+
resolve({
|
|
1911
|
+
type: "error",
|
|
1912
|
+
errorMessage: `Session webhook timeout for PID ${happyProcess.pid}`
|
|
1913
|
+
});
|
|
1914
|
+
}, 15e3);
|
|
1915
|
+
pidToAwaiter.set(happyProcess.pid, (completedSession) => {
|
|
1916
|
+
clearTimeout(timeout);
|
|
1917
|
+
logger.logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`);
|
|
1918
|
+
resolve({
|
|
1919
|
+
type: "success",
|
|
1920
|
+
sessionId: completedSession.happySessionId
|
|
1921
|
+
});
|
|
1922
|
+
});
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
return {
|
|
1926
|
+
type: "error",
|
|
1927
|
+
errorMessage: "Unexpected error in session spawning"
|
|
1928
|
+
};
|
|
1929
|
+
} catch (error) {
|
|
1930
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1931
|
+
logger.logger.debug("[DAEMON RUN] Failed to spawn session:", error);
|
|
1932
|
+
return {
|
|
1933
|
+
type: "error",
|
|
1934
|
+
errorMessage: `Failed to spawn session: ${errorMessage}`
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
const stopSession = (sessionId) => {
|
|
1939
|
+
logger.logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`);
|
|
1940
|
+
for (const [pid, session] of pidToTrackedSession.entries()) {
|
|
1941
|
+
if (session.happySessionId === sessionId || sessionId.startsWith("PID-") && pid === parseInt(sessionId.replace("PID-", ""))) {
|
|
1942
|
+
if (session.startedBy === "daemon" && session.childProcess) {
|
|
1943
|
+
try {
|
|
1944
|
+
session.childProcess.kill("SIGTERM");
|
|
1945
|
+
logger.logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`);
|
|
1946
|
+
} catch (error) {
|
|
1947
|
+
logger.logger.debug(`[DAEMON RUN] Failed to kill session ${sessionId}:`, error);
|
|
1948
|
+
}
|
|
1949
|
+
} else {
|
|
1950
|
+
try {
|
|
1951
|
+
process.kill(pid, "SIGTERM");
|
|
1952
|
+
logger.logger.debug(`[DAEMON RUN] Sent SIGTERM to external session PID ${pid}`);
|
|
1953
|
+
} catch (error) {
|
|
1954
|
+
logger.logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
pidToTrackedSession.delete(pid);
|
|
1958
|
+
logger.logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`);
|
|
1959
|
+
return true;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
logger.logger.debug(`[DAEMON RUN] Session ${sessionId} not found`);
|
|
1963
|
+
return false;
|
|
1964
|
+
};
|
|
1965
|
+
const runCleanupScripts = (session) => {
|
|
1966
|
+
if (!session.repoScripts || session.repoScripts.length === 0) return;
|
|
1967
|
+
for (const script of session.repoScripts) {
|
|
1968
|
+
if (!script.cleanupScript) continue;
|
|
1969
|
+
try {
|
|
1970
|
+
const status = child_process.execSync("git status --porcelain", {
|
|
1971
|
+
cwd: script.worktreePath,
|
|
1972
|
+
encoding: "utf-8",
|
|
1973
|
+
timeout: 1e4
|
|
1974
|
+
});
|
|
1975
|
+
if (status.trim()) {
|
|
1976
|
+
logger.logger.info(`[DAEMON] Running cleanup script for ${script.repoDisplayName}...`);
|
|
1977
|
+
child_process.execSync(script.cleanupScript, {
|
|
1978
|
+
cwd: script.worktreePath,
|
|
1979
|
+
stdio: "pipe",
|
|
1980
|
+
timeout: 3e5
|
|
1981
|
+
});
|
|
1982
|
+
logger.logger.info(`[DAEMON] Cleanup script completed for ${script.repoDisplayName}`);
|
|
1983
|
+
} else {
|
|
1984
|
+
logger.logger.debug(`[DAEMON] No changes in ${script.repoDisplayName}, skipping cleanup`);
|
|
1985
|
+
}
|
|
1986
|
+
} catch (err) {
|
|
1987
|
+
logger.logger.warn(`[DAEMON] Cleanup script failed for ${script.repoDisplayName}: ${err.message}`);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
const onChildExited = (pid) => {
|
|
1992
|
+
logger.logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`);
|
|
1993
|
+
const session = pidToTrackedSession.get(pid);
|
|
1994
|
+
if (session) {
|
|
1995
|
+
runCleanupScripts(session);
|
|
1996
|
+
}
|
|
1997
|
+
pidToTrackedSession.delete(pid);
|
|
1998
|
+
};
|
|
1999
|
+
const { port: controlPort, stop: stopControlServer } = await startDaemonControlServer({
|
|
2000
|
+
getChildren: getCurrentChildren,
|
|
2001
|
+
stopSession,
|
|
2002
|
+
spawnSession,
|
|
2003
|
+
requestShutdown: () => requestShutdown("happy-cli"),
|
|
2004
|
+
onHappySessionWebhook
|
|
2005
|
+
});
|
|
2006
|
+
const fileState = {
|
|
2007
|
+
pid: process.pid,
|
|
2008
|
+
httpPort: controlPort,
|
|
2009
|
+
startTime: (/* @__PURE__ */ new Date()).toLocaleString(),
|
|
2010
|
+
startedWithCliVersion: logger.packageJson.version,
|
|
2011
|
+
daemonLogPath: logger.logger.logFilePath
|
|
2012
|
+
};
|
|
2013
|
+
logger.writeDaemonState(fileState);
|
|
2014
|
+
logger.logger.debug("[DAEMON RUN] Daemon state written");
|
|
2015
|
+
const initialDaemonState = {
|
|
2016
|
+
status: "offline",
|
|
2017
|
+
pid: process.pid,
|
|
2018
|
+
httpPort: controlPort,
|
|
2019
|
+
startedAt: Date.now()
|
|
2020
|
+
};
|
|
2021
|
+
api$1 = await api.ApiClient.create(credentials);
|
|
2022
|
+
const machineMetadata = await buildMachineMetadata();
|
|
2023
|
+
const machine = await api$1.getOrCreateMachine({
|
|
2024
|
+
machineId,
|
|
2025
|
+
metadata: machineMetadata,
|
|
2026
|
+
daemonState: initialDaemonState
|
|
2027
|
+
});
|
|
2028
|
+
logger.logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`);
|
|
2029
|
+
const apiMachine = api$1.machineSyncClient(machine, initialDaemonState);
|
|
2030
|
+
apiMachine.setRPCHandlers({
|
|
2031
|
+
spawnSession,
|
|
2032
|
+
stopSession,
|
|
2033
|
+
requestShutdown: () => requestShutdown("happy-app"),
|
|
2034
|
+
orchestratorDispatch: handleOrchestratorDispatch,
|
|
2035
|
+
orchestratorCancel: handleOrchestratorCancel
|
|
2036
|
+
});
|
|
2037
|
+
apiMachine.connect();
|
|
2038
|
+
apiMachine.updateMachineMetadata((current) => ({ ...current, ...machineMetadata })).catch((error) => {
|
|
2039
|
+
logger.logger.debug("[DAEMON RUN] Failed to update machine metadata", error);
|
|
2040
|
+
});
|
|
2041
|
+
const heartbeatIntervalMsRaw = Number(process.env.HAPPY_DAEMON_HEARTBEAT_INTERVAL);
|
|
2042
|
+
const heartbeatIntervalMs = Number.isFinite(heartbeatIntervalMsRaw) && heartbeatIntervalMsRaw > 0 ? heartbeatIntervalMsRaw : 6e4;
|
|
2043
|
+
let heartbeatRunning = false;
|
|
2044
|
+
const restartOnStaleVersionAndHeartbeat = setInterval(async () => {
|
|
2045
|
+
if (heartbeatRunning) {
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
heartbeatRunning = true;
|
|
2049
|
+
if (logger.isDebug()) {
|
|
2050
|
+
logger.logger.debug(`[DAEMON RUN] Health check started at ${(/* @__PURE__ */ new Date()).toLocaleString()}`);
|
|
2051
|
+
}
|
|
2052
|
+
for (const [pid, _] of pidToTrackedSession.entries()) {
|
|
2053
|
+
try {
|
|
2054
|
+
process.kill(pid, 0);
|
|
2055
|
+
} catch (error) {
|
|
2056
|
+
logger.logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`);
|
|
2057
|
+
onChildExited(pid);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
let projectVersion;
|
|
2061
|
+
try {
|
|
2062
|
+
projectVersion = JSON.parse(fs$2.readFileSync(path$1.join(projectPath.projectPath(), "package.json"), "utf-8")).version;
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
logger.logger.debug("[DAEMON RUN] Failed to read package.json for version check, skipping this heartbeat", error);
|
|
2065
|
+
heartbeatRunning = false;
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
if (projectVersion !== logger.configuration.currentCliVersion) {
|
|
2069
|
+
logger.logger.debug("[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval");
|
|
2070
|
+
clearInterval(restartOnStaleVersionAndHeartbeat);
|
|
2071
|
+
const supervised = process.env.HAPPY_DAEMON_SUPERVISED === "1" || process.env.XPC_SERVICE_NAME === install.PLIST_LABEL;
|
|
2072
|
+
if (supervised) {
|
|
2073
|
+
logger.logger.debug("[DAEMON RUN] Supervised daemon is outdated, exiting non-zero so the service manager restarts us with the new version");
|
|
2074
|
+
process.exit(1);
|
|
2075
|
+
}
|
|
2076
|
+
try {
|
|
2077
|
+
spawnHappyCLI.spawnHappyCLI(["daemon", "start"], {
|
|
2078
|
+
detached: true,
|
|
2079
|
+
stdio: "ignore"
|
|
2080
|
+
});
|
|
2081
|
+
} catch (error) {
|
|
2082
|
+
logger.logger.debug("[DAEMON RUN] Failed to spawn new daemon, this is quite likely to happen during integration tests as we are cleaning out dist/ directory", error);
|
|
2083
|
+
}
|
|
2084
|
+
logger.logger.debug("[DAEMON RUN] Hanging for a bit - waiting for CLI to kill us because we are running outdated version of the code");
|
|
2085
|
+
await new Promise((resolve) => setTimeout(resolve, 1e4));
|
|
2086
|
+
process.exit(0);
|
|
2087
|
+
}
|
|
2088
|
+
const daemonState = await logger.readDaemonState();
|
|
2089
|
+
if (daemonState && daemonState.pid !== process.pid) {
|
|
2090
|
+
logger.logger.debug("[DAEMON RUN] Somehow a different daemon was started without killing us. We should kill ourselves.");
|
|
2091
|
+
requestShutdown("exception", "A different daemon was started without killing us. We should kill ourselves.");
|
|
2092
|
+
}
|
|
2093
|
+
try {
|
|
2094
|
+
const updatedState = {
|
|
2095
|
+
pid: process.pid,
|
|
2096
|
+
httpPort: controlPort,
|
|
2097
|
+
startTime: fileState.startTime,
|
|
2098
|
+
startedWithCliVersion: logger.packageJson.version,
|
|
2099
|
+
lastHeartbeat: (/* @__PURE__ */ new Date()).toLocaleString(),
|
|
2100
|
+
daemonLogPath: fileState.daemonLogPath
|
|
2101
|
+
};
|
|
2102
|
+
logger.writeDaemonState(updatedState);
|
|
2103
|
+
if (logger.isDebug()) {
|
|
2104
|
+
logger.logger.debug(`[DAEMON RUN] Health check completed at ${updatedState.lastHeartbeat}`);
|
|
2105
|
+
}
|
|
2106
|
+
} catch (error) {
|
|
2107
|
+
logger.logger.debug("[DAEMON RUN] Failed to write heartbeat", error);
|
|
2108
|
+
}
|
|
2109
|
+
heartbeatRunning = false;
|
|
2110
|
+
}, heartbeatIntervalMs);
|
|
2111
|
+
const cleanupAndShutdown = async (source, errorMessage) => {
|
|
2112
|
+
logger.logger.debug(`[DAEMON RUN] Starting proper cleanup (source: ${source}, errorMessage: ${errorMessage})...`);
|
|
2113
|
+
if (restartOnStaleVersionAndHeartbeat) {
|
|
2114
|
+
clearInterval(restartOnStaleVersionAndHeartbeat);
|
|
2115
|
+
logger.logger.debug("[DAEMON RUN] Health check interval cleared");
|
|
2116
|
+
}
|
|
2117
|
+
await apiMachine.updateDaemonState((state) => ({
|
|
2118
|
+
...state,
|
|
2119
|
+
status: "shutting-down",
|
|
2120
|
+
shutdownRequestedAt: Date.now(),
|
|
2121
|
+
shutdownSource: source
|
|
2122
|
+
}));
|
|
2123
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2124
|
+
await stopAllOrchestratorExecutions();
|
|
2125
|
+
apiMachine.shutdown();
|
|
2126
|
+
await stopControlServer();
|
|
2127
|
+
await controlClient.cleanupDaemonState();
|
|
2128
|
+
await stopCaffeinate();
|
|
2129
|
+
await logger.releaseDaemonLock(daemonLockHandle);
|
|
2130
|
+
logger.logger.debug("[DAEMON RUN] Cleanup completed, exiting process");
|
|
2131
|
+
process.exit(0);
|
|
2132
|
+
};
|
|
2133
|
+
logger.logger.debug("[DAEMON RUN] Daemon started successfully, waiting for shutdown request");
|
|
2134
|
+
const shutdownRequest = await resolvesWhenShutdownRequested;
|
|
2135
|
+
await cleanupAndShutdown(shutdownRequest.source, shutdownRequest.errorMessage);
|
|
2136
|
+
} catch (error) {
|
|
2137
|
+
logger.logger.debug("[DAEMON RUN][FATAL] Failed somewhere unexpectedly - exiting with code 1", error);
|
|
2138
|
+
process.exit(1);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
var run = /*#__PURE__*/Object.freeze({
|
|
2143
|
+
__proto__: null,
|
|
2144
|
+
buildMachineMetadata: buildMachineMetadata,
|
|
2145
|
+
initialMachineMetadata: initialMachineMetadata,
|
|
2146
|
+
startDaemon: startDaemon
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
exports.initialMachineMetadata = initialMachineMetadata;
|
|
2150
|
+
exports.run = run;
|
|
2151
|
+
exports.startCaffeinate = startCaffeinate;
|
|
2152
|
+
exports.stopCaffeinate = stopCaffeinate;
|