happy-coder 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1170 -247
- package/dist/index.mjs +1172 -250
- package/package.json +5 -3
package/dist/index.cjs
CHANGED
|
@@ -8,11 +8,15 @@ var tweetnacl = require('tweetnacl');
|
|
|
8
8
|
var os = require('node:os');
|
|
9
9
|
var node_path = require('node:path');
|
|
10
10
|
var chalk = require('chalk');
|
|
11
|
+
var fs$1 = require('fs');
|
|
11
12
|
var node_events = require('node:events');
|
|
12
13
|
var socket_ioClient = require('socket.io-client');
|
|
13
14
|
var zod = require('zod');
|
|
14
15
|
var qrcode = require('qrcode-terminal');
|
|
15
|
-
var
|
|
16
|
+
var claudeCode = require('@anthropic-ai/claude-code');
|
|
17
|
+
var pty = require('node-pty');
|
|
18
|
+
var node_readline = require('node:readline');
|
|
19
|
+
var node_http = require('node:http');
|
|
16
20
|
|
|
17
21
|
function _interopNamespaceDefault(e) {
|
|
18
22
|
var n = Object.create(null);
|
|
@@ -32,6 +36,7 @@ function _interopNamespaceDefault(e) {
|
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
|
|
39
|
+
var pty__namespace = /*#__PURE__*/_interopNamespaceDefault(pty);
|
|
35
40
|
|
|
36
41
|
function encodeBase64(buffer) {
|
|
37
42
|
return Buffer.from(buffer).toString("base64");
|
|
@@ -73,19 +78,6 @@ function authChallenge(secret) {
|
|
|
73
78
|
};
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
async function getOrCreateSecretKey() {
|
|
77
|
-
const keyPath = node_path.join(os.homedir(), ".handy", "access.key");
|
|
78
|
-
if (fs.existsSync(keyPath)) {
|
|
79
|
-
const keyBase642 = fs.readFileSync(keyPath, "utf8").trim();
|
|
80
|
-
return new Uint8Array(Buffer.from(keyBase642, "base64"));
|
|
81
|
-
}
|
|
82
|
-
const secret = getRandomBytes(32);
|
|
83
|
-
const keyBase64 = encodeBase64(secret);
|
|
84
|
-
fs.mkdirSync(node_path.join(os.homedir(), ".handy"), { recursive: true });
|
|
85
|
-
fs.writeFileSync(keyPath, keyBase64);
|
|
86
|
-
await promises.chmod(keyPath, 384);
|
|
87
|
-
return secret;
|
|
88
|
-
}
|
|
89
81
|
async function authGetToken(secret) {
|
|
90
82
|
const { challenge, publicKey, signature } = authChallenge(secret);
|
|
91
83
|
const response = await axios.post(`https://handy-api.korshakov.org/v1/auth`, {
|
|
@@ -103,46 +95,143 @@ function generateAppUrl(secret) {
|
|
|
103
95
|
return `handy://${secretBase64Url}`;
|
|
104
96
|
}
|
|
105
97
|
|
|
98
|
+
const handyDir = node_path.join(os.homedir(), ".handy");
|
|
99
|
+
const logsDir = node_path.join(handyDir, "logs");
|
|
100
|
+
const settingsFile = node_path.join(handyDir, "settings.json");
|
|
101
|
+
const privateKeyFile = node_path.join(handyDir, "access.key");
|
|
102
|
+
const defaultSettings = {
|
|
103
|
+
onboardingCompleted: false
|
|
104
|
+
};
|
|
105
|
+
async function readSettings() {
|
|
106
|
+
if (!fs.existsSync(settingsFile)) {
|
|
107
|
+
return { ...defaultSettings };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const content = await promises.readFile(settingsFile, "utf8");
|
|
111
|
+
return JSON.parse(content);
|
|
112
|
+
} catch {
|
|
113
|
+
return { ...defaultSettings };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function writeSettings(settings) {
|
|
117
|
+
if (!fs.existsSync(handyDir)) {
|
|
118
|
+
await promises.mkdir(handyDir, { recursive: true });
|
|
119
|
+
}
|
|
120
|
+
await promises.writeFile(settingsFile, JSON.stringify(settings, null, 2));
|
|
121
|
+
}
|
|
122
|
+
async function readPrivateKey() {
|
|
123
|
+
if (!fs.existsSync(privateKeyFile)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const keyBase64 = (await promises.readFile(privateKeyFile, "utf8")).trim();
|
|
128
|
+
return new Uint8Array(Buffer.from(keyBase64, "base64"));
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function writePrivateKey(key) {
|
|
134
|
+
if (!fs.existsSync(handyDir)) {
|
|
135
|
+
await promises.mkdir(handyDir, { recursive: true });
|
|
136
|
+
}
|
|
137
|
+
const keyBase64 = Buffer.from(key).toString("base64");
|
|
138
|
+
await promises.writeFile(privateKeyFile, keyBase64, "utf8");
|
|
139
|
+
}
|
|
140
|
+
async function getSessionLogPath() {
|
|
141
|
+
if (!fs.existsSync(logsDir)) {
|
|
142
|
+
await promises.mkdir(logsDir, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
const now = /* @__PURE__ */ new Date();
|
|
145
|
+
const timestamp = now.toLocaleString("sv-SE", {
|
|
146
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
147
|
+
year: "numeric",
|
|
148
|
+
month: "2-digit",
|
|
149
|
+
day: "2-digit",
|
|
150
|
+
hour: "2-digit",
|
|
151
|
+
minute: "2-digit",
|
|
152
|
+
second: "2-digit"
|
|
153
|
+
}).replace(/[: ]/g, "-").replace(/,/g, "");
|
|
154
|
+
return node_path.join(logsDir, `${timestamp}.log`);
|
|
155
|
+
}
|
|
156
|
+
|
|
106
157
|
class Logger {
|
|
158
|
+
constructor(logFilePathPromise = getSessionLogPath()) {
|
|
159
|
+
this.logFilePathPromise = logFilePathPromise;
|
|
160
|
+
}
|
|
107
161
|
debug(message, ...args) {
|
|
108
|
-
|
|
109
|
-
this.log("DEBUG" /* DEBUG */, message, ...args);
|
|
110
|
-
}
|
|
162
|
+
this.logToFile(`[${(/* @__PURE__ */ new Date()).toISOString()}]`, message, ...args);
|
|
111
163
|
}
|
|
112
|
-
|
|
113
|
-
|
|
164
|
+
debugLargeJson(message, object, maxStringLength = 100, maxArrayLength = 10) {
|
|
165
|
+
if (!process.env.DEBUG) {
|
|
166
|
+
this.debug(`In production, skipping message inspection`);
|
|
167
|
+
}
|
|
168
|
+
const truncateStrings = (obj) => {
|
|
169
|
+
if (typeof obj === "string") {
|
|
170
|
+
return obj.length > maxStringLength ? obj.substring(0, maxStringLength) + "... [truncated for logs]" : obj;
|
|
171
|
+
}
|
|
172
|
+
if (Array.isArray(obj)) {
|
|
173
|
+
const truncatedArray = obj.map((item) => truncateStrings(item)).slice(0, maxArrayLength);
|
|
174
|
+
if (obj.length > maxArrayLength) {
|
|
175
|
+
truncatedArray.push(`... [truncated array for logs up to ${maxArrayLength} items]`);
|
|
176
|
+
}
|
|
177
|
+
return truncatedArray;
|
|
178
|
+
}
|
|
179
|
+
if (obj && typeof obj === "object") {
|
|
180
|
+
const result = {};
|
|
181
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
182
|
+
result[key] = truncateStrings(value);
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
return obj;
|
|
187
|
+
};
|
|
188
|
+
const truncatedObject = truncateStrings(object);
|
|
189
|
+
const json = JSON.stringify(truncatedObject, null, 2);
|
|
190
|
+
this.logToFile(`[${(/* @__PURE__ */ new Date()).toISOString()}]`, message, "\n", json);
|
|
114
191
|
}
|
|
115
192
|
info(message, ...args) {
|
|
116
|
-
this.
|
|
117
|
-
}
|
|
118
|
-
warn(message, ...args) {
|
|
119
|
-
this.log("WARN" /* WARN */, message, ...args);
|
|
120
|
-
}
|
|
121
|
-
getTimestamp() {
|
|
122
|
-
return (/* @__PURE__ */ new Date()).toISOString();
|
|
193
|
+
this.logToConsole("info", "", message, ...args);
|
|
123
194
|
}
|
|
124
|
-
|
|
125
|
-
const timestamp = this.getTimestamp();
|
|
126
|
-
const prefix = `[${timestamp}] [${level}]`;
|
|
195
|
+
logToConsole(level, prefix, message, ...args) {
|
|
127
196
|
switch (level) {
|
|
128
|
-
case "
|
|
197
|
+
case "debug": {
|
|
129
198
|
console.log(chalk.gray(prefix), message, ...args);
|
|
130
199
|
break;
|
|
131
200
|
}
|
|
132
|
-
case "
|
|
201
|
+
case "error": {
|
|
133
202
|
console.error(chalk.red(prefix), message, ...args);
|
|
134
203
|
break;
|
|
135
204
|
}
|
|
136
|
-
case "
|
|
205
|
+
case "info": {
|
|
137
206
|
console.log(chalk.blue(prefix), message, ...args);
|
|
138
207
|
break;
|
|
139
208
|
}
|
|
140
|
-
case "
|
|
209
|
+
case "warn": {
|
|
141
210
|
console.log(chalk.yellow(prefix), message, ...args);
|
|
142
211
|
break;
|
|
143
212
|
}
|
|
213
|
+
default: {
|
|
214
|
+
this.debug("Unknown log level:", level);
|
|
215
|
+
console.log(chalk.blue(prefix), message, ...args);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
144
218
|
}
|
|
145
219
|
}
|
|
220
|
+
logToFile(prefix, message, ...args) {
|
|
221
|
+
const logLine = `${prefix} ${message} ${args.map(
|
|
222
|
+
(arg) => typeof arg === "string" ? arg : JSON.stringify(arg)
|
|
223
|
+
).join(" ")}
|
|
224
|
+
`;
|
|
225
|
+
this.logFilePathPromise.then((logFilePath) => {
|
|
226
|
+
fs$1.appendFileSync(logFilePath, logLine);
|
|
227
|
+
}).catch((error) => {
|
|
228
|
+
if (process.env.DEBUG) {
|
|
229
|
+
console.log("This message only visible in DEBUG mode, not in production");
|
|
230
|
+
console.error("Failed to resolve log file path:", error);
|
|
231
|
+
console.log(prefix, message, ...args);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
146
235
|
}
|
|
147
236
|
const logger = new Logger();
|
|
148
237
|
|
|
@@ -161,17 +250,33 @@ const UpdateBodySchema = zod.z.object({
|
|
|
161
250
|
// Session ID
|
|
162
251
|
t: zod.z.literal("new-message")
|
|
163
252
|
});
|
|
253
|
+
const UpdateSessionBodySchema = zod.z.object({
|
|
254
|
+
t: zod.z.literal("update-session"),
|
|
255
|
+
sid: zod.z.string(),
|
|
256
|
+
metadata: zod.z.object({
|
|
257
|
+
version: zod.z.number(),
|
|
258
|
+
metadata: zod.z.string()
|
|
259
|
+
}).nullish(),
|
|
260
|
+
agentState: zod.z.object({
|
|
261
|
+
version: zod.z.number(),
|
|
262
|
+
agentState: zod.z.string()
|
|
263
|
+
}).nullish()
|
|
264
|
+
});
|
|
164
265
|
zod.z.object({
|
|
165
266
|
id: zod.z.string(),
|
|
166
267
|
seq: zod.z.number(),
|
|
167
|
-
body: UpdateBodySchema,
|
|
268
|
+
body: zod.z.union([UpdateBodySchema, UpdateSessionBodySchema]),
|
|
168
269
|
createdAt: zod.z.number()
|
|
169
270
|
});
|
|
170
271
|
zod.z.object({
|
|
171
272
|
createdAt: zod.z.number(),
|
|
172
273
|
id: zod.z.string(),
|
|
173
274
|
seq: zod.z.number(),
|
|
174
|
-
updatedAt: zod.z.number()
|
|
275
|
+
updatedAt: zod.z.number(),
|
|
276
|
+
metadata: zod.z.any(),
|
|
277
|
+
metadataVersion: zod.z.number(),
|
|
278
|
+
agentState: zod.z.any().nullable(),
|
|
279
|
+
agentStateVersion: zod.z.number()
|
|
175
280
|
});
|
|
176
281
|
zod.z.object({
|
|
177
282
|
content: SessionMessageContentSchema,
|
|
@@ -186,7 +291,11 @@ zod.z.object({
|
|
|
186
291
|
tag: zod.z.string(),
|
|
187
292
|
seq: zod.z.number(),
|
|
188
293
|
createdAt: zod.z.number(),
|
|
189
|
-
updatedAt: zod.z.number()
|
|
294
|
+
updatedAt: zod.z.number(),
|
|
295
|
+
metadata: zod.z.string(),
|
|
296
|
+
metadataVersion: zod.z.number(),
|
|
297
|
+
agentState: zod.z.string().nullable(),
|
|
298
|
+
agentStateVersion: zod.z.number()
|
|
190
299
|
})
|
|
191
300
|
});
|
|
192
301
|
const UserMessageSchema = zod.z.object({
|
|
@@ -194,7 +303,11 @@ const UserMessageSchema = zod.z.object({
|
|
|
194
303
|
content: zod.z.object({
|
|
195
304
|
type: zod.z.literal("text"),
|
|
196
305
|
text: zod.z.string()
|
|
197
|
-
})
|
|
306
|
+
}),
|
|
307
|
+
localKey: zod.z.string().optional(),
|
|
308
|
+
// Mobile messages include this
|
|
309
|
+
sentFrom: zod.z.enum(["mobile", "cli"]).optional()
|
|
310
|
+
// Source identifier
|
|
198
311
|
});
|
|
199
312
|
const AgentMessageSchema = zod.z.object({
|
|
200
313
|
role: zod.z.literal("agent"),
|
|
@@ -202,19 +315,57 @@ const AgentMessageSchema = zod.z.object({
|
|
|
202
315
|
});
|
|
203
316
|
zod.z.union([UserMessageSchema, AgentMessageSchema]);
|
|
204
317
|
|
|
318
|
+
async function delay(ms) {
|
|
319
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
320
|
+
}
|
|
321
|
+
function exponentialBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount) {
|
|
322
|
+
let maxDelayRet = minDelay + (maxDelay - minDelay) / maxFailureCount * Math.max(currentFailureCount, maxFailureCount);
|
|
323
|
+
return Math.round(Math.random() * maxDelayRet);
|
|
324
|
+
}
|
|
325
|
+
function createBackoff(opts) {
|
|
326
|
+
return async (callback) => {
|
|
327
|
+
let currentFailureCount = 0;
|
|
328
|
+
const minDelay = 250;
|
|
329
|
+
const maxDelay = 1e3;
|
|
330
|
+
const maxFailureCount = 50;
|
|
331
|
+
while (true) {
|
|
332
|
+
try {
|
|
333
|
+
return await callback();
|
|
334
|
+
} catch (e) {
|
|
335
|
+
if (currentFailureCount < maxFailureCount) {
|
|
336
|
+
currentFailureCount++;
|
|
337
|
+
}
|
|
338
|
+
let waitForRequest = exponentialBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount);
|
|
339
|
+
await delay(waitForRequest);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
let backoff = createBackoff();
|
|
345
|
+
|
|
205
346
|
class ApiSessionClient extends node_events.EventEmitter {
|
|
206
347
|
token;
|
|
207
348
|
secret;
|
|
208
349
|
sessionId;
|
|
350
|
+
metadata;
|
|
351
|
+
metadataVersion;
|
|
352
|
+
agentState;
|
|
353
|
+
agentStateVersion;
|
|
209
354
|
socket;
|
|
210
355
|
receivedMessages = /* @__PURE__ */ new Set();
|
|
356
|
+
sentLocalKeys = /* @__PURE__ */ new Set();
|
|
211
357
|
pendingMessages = [];
|
|
212
358
|
pendingMessageCallback = null;
|
|
213
|
-
|
|
359
|
+
rpcHandlers = /* @__PURE__ */ new Map();
|
|
360
|
+
constructor(token, secret, session) {
|
|
214
361
|
super();
|
|
215
362
|
this.token = token;
|
|
216
363
|
this.secret = secret;
|
|
217
|
-
this.sessionId =
|
|
364
|
+
this.sessionId = session.id;
|
|
365
|
+
this.metadata = session.metadata;
|
|
366
|
+
this.metadataVersion = session.metadataVersion;
|
|
367
|
+
this.agentState = session.agentState;
|
|
368
|
+
this.agentStateVersion = session.agentStateVersion;
|
|
218
369
|
this.socket = socket_ioClient.io("https://handy-api.korshakov.org", {
|
|
219
370
|
auth: {
|
|
220
371
|
token: this.token
|
|
@@ -230,26 +381,64 @@ class ApiSessionClient extends node_events.EventEmitter {
|
|
|
230
381
|
});
|
|
231
382
|
this.socket.on("connect", () => {
|
|
232
383
|
logger.info("Socket connected successfully");
|
|
384
|
+
this.reregisterHandlers();
|
|
385
|
+
});
|
|
386
|
+
this.socket.on("rpc-request", async (data, callback) => {
|
|
387
|
+
try {
|
|
388
|
+
const method = data.method;
|
|
389
|
+
const handler = this.rpcHandlers.get(method);
|
|
390
|
+
if (!handler) {
|
|
391
|
+
logger.debug("[SOCKET] [RPC] [ERROR] method not found", { method });
|
|
392
|
+
const errorResponse = { error: "Method not found" };
|
|
393
|
+
const encryptedError = encodeBase64(encrypt(errorResponse, this.secret));
|
|
394
|
+
callback(encryptedError);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const decryptedParams = decrypt(decodeBase64(data.params), this.secret);
|
|
398
|
+
const result = await handler(decryptedParams);
|
|
399
|
+
const encryptedResponse = encodeBase64(encrypt(result, this.secret));
|
|
400
|
+
callback(encryptedResponse);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
logger.debug("[SOCKET] [RPC] [ERROR] Error handling RPC request", { error });
|
|
403
|
+
const errorResponse = { error: error instanceof Error ? error.message : "Unknown error" };
|
|
404
|
+
const encryptedError = encodeBase64(encrypt(errorResponse, this.secret));
|
|
405
|
+
callback(encryptedError);
|
|
406
|
+
}
|
|
233
407
|
});
|
|
234
408
|
this.socket.on("disconnect", (reason) => {
|
|
235
|
-
logger.
|
|
409
|
+
logger.debug("[API] Socket disconnected:", reason);
|
|
236
410
|
});
|
|
237
411
|
this.socket.on("connect_error", (error) => {
|
|
238
|
-
logger.
|
|
412
|
+
logger.debug("[API] Socket connection error:", error.message);
|
|
239
413
|
});
|
|
240
414
|
this.socket.on("update", (data) => {
|
|
241
415
|
if (data.body.t === "new-message" && data.body.message.content.t === "encrypted") {
|
|
242
416
|
const body = decrypt(decodeBase64(data.body.message.content.c), this.secret);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
417
|
+
logger.debugLargeJson("[SOCKET] [UPDATE] Received update:", body);
|
|
418
|
+
const userResult = UserMessageSchema.safeParse(body);
|
|
419
|
+
if (userResult.success) {
|
|
420
|
+
const localKey = body.localKey;
|
|
421
|
+
if (localKey && this.sentLocalKeys.has(localKey)) {
|
|
422
|
+
logger.debug(`[SOCKET] Ignoring echo of our own message with localKey: ${localKey}`);
|
|
423
|
+
} else if (!this.receivedMessages.has(data.body.message.id)) {
|
|
246
424
|
this.receivedMessages.add(data.body.message.id);
|
|
247
425
|
if (this.pendingMessageCallback) {
|
|
248
|
-
this.pendingMessageCallback(
|
|
426
|
+
this.pendingMessageCallback(userResult.data);
|
|
249
427
|
} else {
|
|
250
|
-
this.pendingMessages.push(
|
|
428
|
+
this.pendingMessages.push(userResult.data);
|
|
251
429
|
}
|
|
252
430
|
}
|
|
431
|
+
} else {
|
|
432
|
+
this.emit("message", body);
|
|
433
|
+
}
|
|
434
|
+
} else if (data.body.t === "update-session") {
|
|
435
|
+
if (data.body.metadata && data.body.metadata.version > this.metadataVersion) {
|
|
436
|
+
this.metadata = decrypt(decodeBase64(data.body.metadata.metadata), this.secret);
|
|
437
|
+
this.metadataVersion = data.body.metadata.version;
|
|
438
|
+
}
|
|
439
|
+
if (data.body.agentState && data.body.agentState.version > this.agentStateVersion) {
|
|
440
|
+
this.agentState = data.body.agentState.agentState ? decrypt(decodeBase64(data.body.agentState.agentState), this.secret) : null;
|
|
441
|
+
this.agentStateVersion = data.body.agentState.version;
|
|
253
442
|
}
|
|
254
443
|
}
|
|
255
444
|
});
|
|
@@ -263,19 +452,120 @@ class ApiSessionClient extends node_events.EventEmitter {
|
|
|
263
452
|
}
|
|
264
453
|
/**
|
|
265
454
|
* Send message to session
|
|
266
|
-
* @param body - Message body
|
|
455
|
+
* @param body - Message body (can be MessageContent or raw content for agent messages)
|
|
267
456
|
*/
|
|
268
457
|
sendMessage(body) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
458
|
+
logger.debugLargeJson("[SOCKET] Sending message through socket:", body);
|
|
459
|
+
let content;
|
|
460
|
+
if (body.role === "user" || body.role === "agent") {
|
|
461
|
+
content = body;
|
|
462
|
+
if (body.role === "user" && body.localKey) {
|
|
463
|
+
this.sentLocalKeys.add(body.localKey);
|
|
464
|
+
logger.debug(`[SOCKET] Tracking sent localKey: ${body.localKey}`);
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
content = {
|
|
468
|
+
role: "agent",
|
|
469
|
+
content: body
|
|
470
|
+
};
|
|
471
|
+
}
|
|
273
472
|
const encrypted = encodeBase64(encrypt(content, this.secret));
|
|
274
473
|
this.socket.emit("message", {
|
|
275
474
|
sid: this.sessionId,
|
|
276
475
|
message: encrypted
|
|
277
476
|
});
|
|
278
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Send a ping message to keep the connection alive
|
|
480
|
+
*/
|
|
481
|
+
keepAlive(thinking) {
|
|
482
|
+
this.socket.volatile.emit("session-alive", { sid: this.sessionId, time: Date.now(), thinking });
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Send session death message
|
|
486
|
+
*/
|
|
487
|
+
sendSessionDeath() {
|
|
488
|
+
this.socket.emit("session-end", { sid: this.sessionId, time: Date.now() });
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Update session metadata
|
|
492
|
+
* @param handler - Handler function that returns the updated metadata
|
|
493
|
+
*/
|
|
494
|
+
updateMetadata(handler) {
|
|
495
|
+
backoff(async () => {
|
|
496
|
+
let updated = handler(this.metadata);
|
|
497
|
+
const answer = await this.socket.emitWithAck("update-metadata", { sid: this.sessionId, expectedVersion: this.metadataVersion, metadata: encodeBase64(encrypt(updated, this.secret)) });
|
|
498
|
+
if (answer.result === "success") {
|
|
499
|
+
this.metadata = decrypt(decodeBase64(answer.metadata), this.secret);
|
|
500
|
+
this.metadataVersion = answer.version;
|
|
501
|
+
} else if (answer.result === "version-mismatch") {
|
|
502
|
+
if (answer.version > this.metadataVersion) {
|
|
503
|
+
this.metadataVersion = answer.version;
|
|
504
|
+
this.metadata = decrypt(decodeBase64(answer.metadata), this.secret);
|
|
505
|
+
}
|
|
506
|
+
throw new Error("Metadata version mismatch");
|
|
507
|
+
} else if (answer.result === "error") ;
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Update session agent state
|
|
512
|
+
* @param handler - Handler function that returns the updated agent state
|
|
513
|
+
*/
|
|
514
|
+
updateAgentState(handler) {
|
|
515
|
+
backoff(async () => {
|
|
516
|
+
let updated = handler(this.agentState || {});
|
|
517
|
+
const answer = await this.socket.emitWithAck("update-agent", { sid: this.sessionId, expectedVersion: this.agentStateVersion, agentState: updated ? encodeBase64(encrypt(updated, this.secret)) : null });
|
|
518
|
+
if (answer.result === "success") {
|
|
519
|
+
this.agentState = answer.agentState ? decrypt(decodeBase64(answer.agentState), this.secret) : null;
|
|
520
|
+
this.agentStateVersion = answer.version;
|
|
521
|
+
} else if (answer.result === "version-mismatch") {
|
|
522
|
+
if (answer.version > this.agentStateVersion) {
|
|
523
|
+
this.agentStateVersion = answer.version;
|
|
524
|
+
this.agentState = answer.agentState ? decrypt(decodeBase64(answer.agentState), this.secret) : null;
|
|
525
|
+
}
|
|
526
|
+
throw new Error("Agent state version mismatch");
|
|
527
|
+
} else if (answer.result === "error") ;
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Add a custom RPC handler for a specific method with encrypted arguments and responses
|
|
532
|
+
* @param method - The method name to handle
|
|
533
|
+
* @param handler - The handler function to call when the method is invoked
|
|
534
|
+
*/
|
|
535
|
+
addHandler(method, handler) {
|
|
536
|
+
const prefixedMethod = `${this.sessionId}:${method}`;
|
|
537
|
+
this.rpcHandlers.set(prefixedMethod, handler);
|
|
538
|
+
this.socket.emit("rpc-register", { method: prefixedMethod });
|
|
539
|
+
logger.debug("Registered RPC handler", { method, prefixedMethod });
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Re-register all RPC handlers after reconnection
|
|
543
|
+
*/
|
|
544
|
+
reregisterHandlers() {
|
|
545
|
+
logger.debug("Re-registering RPC handlers after reconnection", {
|
|
546
|
+
totalMethods: this.rpcHandlers.size
|
|
547
|
+
});
|
|
548
|
+
for (const [prefixedMethod] of this.rpcHandlers) {
|
|
549
|
+
this.socket.emit("rpc-register", { method: prefixedMethod });
|
|
550
|
+
logger.debug("Re-registered method", { prefixedMethod });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Wait for socket buffer to flush
|
|
555
|
+
*/
|
|
556
|
+
async flush() {
|
|
557
|
+
if (!this.socket.connected) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
return new Promise((resolve) => {
|
|
561
|
+
this.socket.emit("ping", () => {
|
|
562
|
+
resolve();
|
|
563
|
+
});
|
|
564
|
+
setTimeout(() => {
|
|
565
|
+
resolve();
|
|
566
|
+
}, 1e4);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
279
569
|
async close() {
|
|
280
570
|
this.socket.close();
|
|
281
571
|
}
|
|
@@ -295,7 +585,11 @@ class ApiClient {
|
|
|
295
585
|
try {
|
|
296
586
|
const response = await axios.post(
|
|
297
587
|
`https://handy-api.korshakov.org/v1/sessions`,
|
|
298
|
-
{
|
|
588
|
+
{
|
|
589
|
+
tag: opts.tag,
|
|
590
|
+
metadata: encodeBase64(encrypt(opts.metadata, this.secret)),
|
|
591
|
+
agentState: opts.state ? encodeBase64(encrypt(opts.state, this.secret)) : null
|
|
592
|
+
},
|
|
299
593
|
{
|
|
300
594
|
headers: {
|
|
301
595
|
"Authorization": `Bearer ${this.token}`,
|
|
@@ -304,9 +598,20 @@ class ApiClient {
|
|
|
304
598
|
}
|
|
305
599
|
);
|
|
306
600
|
logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`);
|
|
307
|
-
|
|
601
|
+
let raw = response.data.session;
|
|
602
|
+
let session = {
|
|
603
|
+
id: raw.id,
|
|
604
|
+
createdAt: raw.createdAt,
|
|
605
|
+
updatedAt: raw.updatedAt,
|
|
606
|
+
seq: raw.seq,
|
|
607
|
+
metadata: decrypt(decodeBase64(raw.metadata), this.secret),
|
|
608
|
+
metadataVersion: raw.metadataVersion,
|
|
609
|
+
agentState: raw.agentState ? decrypt(decodeBase64(raw.agentState), this.secret) : null,
|
|
610
|
+
agentStateVersion: raw.agentStateVersion
|
|
611
|
+
};
|
|
612
|
+
return session;
|
|
308
613
|
} catch (error) {
|
|
309
|
-
logger.
|
|
614
|
+
logger.debug("[API] [ERROR] Failed to get or create session:", error);
|
|
310
615
|
throw new Error(`Failed to get or create session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
311
616
|
}
|
|
312
617
|
}
|
|
@@ -315,27 +620,82 @@ class ApiClient {
|
|
|
315
620
|
* @param id - Session ID
|
|
316
621
|
* @returns Session client
|
|
317
622
|
*/
|
|
318
|
-
session(
|
|
319
|
-
return new ApiSessionClient(this.token, this.secret,
|
|
623
|
+
session(session) {
|
|
624
|
+
return new ApiSessionClient(this.token, this.secret, session);
|
|
320
625
|
}
|
|
321
626
|
}
|
|
322
627
|
|
|
323
628
|
function displayQRCode(url) {
|
|
629
|
+
logger.info("=".repeat(50));
|
|
630
|
+
logger.info("\u{1F4F1} To authenticate, scan this QR code with your mobile device:");
|
|
631
|
+
logger.info("=".repeat(50));
|
|
632
|
+
qrcode.generate(url, { small: true }, (qr) => {
|
|
633
|
+
for (let l of qr.split("\n")) {
|
|
634
|
+
logger.info(" " + l);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
logger.info(`\u{1F4CB} Or use this URL: ${url}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function* claude(options) {
|
|
324
641
|
try {
|
|
325
|
-
logger.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
642
|
+
logger.debug("[CLAUDE SDK] Starting SDK with options:", options);
|
|
643
|
+
const sdkOptions = {
|
|
644
|
+
cwd: options.workingDirectory,
|
|
645
|
+
model: options.model,
|
|
646
|
+
permissionMode: mapPermissionMode(options.permissionMode),
|
|
647
|
+
resume: options.sessionId,
|
|
648
|
+
// Add MCP servers if provided
|
|
649
|
+
mcpServers: options.mcpServers,
|
|
650
|
+
// Add permission prompt tool name if provided
|
|
651
|
+
permissionPromptToolName: options.permissionPromptToolName
|
|
652
|
+
};
|
|
653
|
+
const response = claudeCode.query({
|
|
654
|
+
prompt: options.command,
|
|
655
|
+
abortController: options.abort,
|
|
656
|
+
options: sdkOptions
|
|
332
657
|
});
|
|
333
|
-
|
|
658
|
+
for await (const message of response) {
|
|
659
|
+
logger.debugLargeJson("[CLAUDE SDK] Message:", message);
|
|
660
|
+
switch (message.type) {
|
|
661
|
+
case "system":
|
|
662
|
+
if (message.subtype === "init") {
|
|
663
|
+
yield { type: "json", data: message };
|
|
664
|
+
}
|
|
665
|
+
break;
|
|
666
|
+
case "assistant":
|
|
667
|
+
yield { type: "json", data: message };
|
|
668
|
+
break;
|
|
669
|
+
case "user":
|
|
670
|
+
break;
|
|
671
|
+
case "result":
|
|
672
|
+
if (message.is_error) {
|
|
673
|
+
yield { type: "error", error: `Claude execution error: ${message.subtype}` };
|
|
674
|
+
yield { type: "exit", code: 1, signal: null };
|
|
675
|
+
} else {
|
|
676
|
+
yield { type: "json", data: message };
|
|
677
|
+
yield { type: "exit", code: 0, signal: null };
|
|
678
|
+
}
|
|
679
|
+
break;
|
|
680
|
+
default:
|
|
681
|
+
yield { type: "json", data: message };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
334
684
|
} catch (error) {
|
|
335
|
-
logger.
|
|
336
|
-
|
|
685
|
+
logger.debug("[CLAUDE SDK] [ERROR] SDK error:", error);
|
|
686
|
+
yield { type: "error", error: error instanceof Error ? error.message : String(error) };
|
|
687
|
+
yield { type: "exit", code: 1, signal: null };
|
|
337
688
|
}
|
|
338
689
|
}
|
|
690
|
+
function mapPermissionMode(mode) {
|
|
691
|
+
if (!mode) return void 0;
|
|
692
|
+
const modeMap = {
|
|
693
|
+
"auto": "acceptEdits",
|
|
694
|
+
"default": "default",
|
|
695
|
+
"plan": "bypassPermissions"
|
|
696
|
+
};
|
|
697
|
+
return modeMap[mode];
|
|
698
|
+
}
|
|
339
699
|
|
|
340
700
|
function claudePath() {
|
|
341
701
|
if (fs__namespace.existsSync(process.env.HOME + "/.claude/local/claude")) {
|
|
@@ -345,242 +705,775 @@ function claudePath() {
|
|
|
345
705
|
}
|
|
346
706
|
}
|
|
347
707
|
|
|
348
|
-
|
|
349
|
-
const args =
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
708
|
+
function spawnInteractiveClaude(options) {
|
|
709
|
+
const args = [];
|
|
710
|
+
if (options.sessionId) {
|
|
711
|
+
args.push("--resume", options.sessionId);
|
|
712
|
+
}
|
|
713
|
+
if (options.model) {
|
|
714
|
+
args.push("-m", options.model);
|
|
715
|
+
}
|
|
716
|
+
if (options.permissionMode) {
|
|
717
|
+
args.push("-p", options.permissionMode);
|
|
718
|
+
}
|
|
719
|
+
logger.debug("[PTY] Creating PTY process with args:", args);
|
|
720
|
+
const ptyProcess = pty__namespace.spawn(claudePath(), args, {
|
|
721
|
+
name: "xterm-256color",
|
|
722
|
+
cols: process.stdout.columns,
|
|
723
|
+
rows: process.stdout.rows,
|
|
353
724
|
cwd: options.workingDirectory,
|
|
354
|
-
|
|
355
|
-
shell: false
|
|
725
|
+
env: process.env
|
|
356
726
|
});
|
|
357
|
-
process.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
let processExited = false;
|
|
361
|
-
const outputQueue = [];
|
|
362
|
-
let outputResolve = null;
|
|
363
|
-
process.stdout?.on("data", (data) => {
|
|
364
|
-
outputBuffer += data.toString();
|
|
365
|
-
const lines = outputBuffer.split("\n");
|
|
366
|
-
outputBuffer = lines.pop() || "";
|
|
367
|
-
for (const line of lines) {
|
|
368
|
-
if (line.trim()) {
|
|
369
|
-
try {
|
|
370
|
-
const json = JSON.parse(line);
|
|
371
|
-
outputQueue.push({ type: "json", data: json });
|
|
372
|
-
} catch {
|
|
373
|
-
outputQueue.push({ type: "text", data: line });
|
|
374
|
-
}
|
|
375
|
-
if (outputResolve) {
|
|
376
|
-
outputResolve();
|
|
377
|
-
outputResolve = null;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
});
|
|
382
|
-
process.stderr?.on("data", (data) => {
|
|
383
|
-
stderrBuffer += data.toString();
|
|
384
|
-
const lines = stderrBuffer.split("\n");
|
|
385
|
-
stderrBuffer = lines.pop() || "";
|
|
386
|
-
for (const line of lines) {
|
|
387
|
-
if (line.trim()) {
|
|
388
|
-
outputQueue.push({ type: "error", error: line });
|
|
389
|
-
if (outputResolve) {
|
|
390
|
-
outputResolve();
|
|
391
|
-
outputResolve = null;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
727
|
+
logger.debug("[PTY] PTY process created, pid:", ptyProcess.pid);
|
|
728
|
+
ptyProcess.onData((data) => {
|
|
729
|
+
process.stdout.write(data);
|
|
395
730
|
});
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
731
|
+
const resizeHandler = () => {
|
|
732
|
+
logger.debug("[PTY] SIGWINCH received, resizing to:", { cols: process.stdout.columns, rows: process.stdout.rows });
|
|
733
|
+
ptyProcess.resize(process.stdout.columns, process.stdout.rows);
|
|
734
|
+
};
|
|
735
|
+
process.on("SIGWINCH", resizeHandler);
|
|
736
|
+
const exitPromise = new Promise((resolve) => {
|
|
737
|
+
ptyProcess.onExit((exitCode) => {
|
|
738
|
+
logger.debug("[PTY] PTY process exited with code:", exitCode.exitCode);
|
|
739
|
+
logger.debug("[PTY] Removing SIGWINCH handler");
|
|
740
|
+
process.removeListener("SIGWINCH", resizeHandler);
|
|
741
|
+
resolve(exitCode.exitCode);
|
|
742
|
+
});
|
|
403
743
|
});
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
744
|
+
return {
|
|
745
|
+
write: (data) => {
|
|
746
|
+
ptyProcess.write(data);
|
|
747
|
+
},
|
|
748
|
+
kill: () => {
|
|
749
|
+
logger.debug("[PTY] Kill called");
|
|
750
|
+
process.removeListener("SIGWINCH", resizeHandler);
|
|
751
|
+
ptyProcess.kill();
|
|
752
|
+
},
|
|
753
|
+
waitForExit: () => exitPromise,
|
|
754
|
+
resize: (cols, rows) => {
|
|
755
|
+
logger.debug("[PTY] Manual resize called:", { cols, rows });
|
|
756
|
+
ptyProcess.resize(cols, rows);
|
|
410
757
|
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const PersisstedMessageSchema = zod.z.object({
|
|
762
|
+
sessionId: zod.z.string(),
|
|
763
|
+
type: zod.z.string(),
|
|
764
|
+
subtype: zod.z.string().optional()
|
|
765
|
+
}).loose();
|
|
766
|
+
const SDKMessageSchema = zod.z.object({
|
|
767
|
+
session_id: zod.z.string().optional(),
|
|
768
|
+
type: zod.z.string(),
|
|
769
|
+
subtype: zod.z.string().optional()
|
|
770
|
+
}).loose();
|
|
771
|
+
function parseClaudePersistedMessage(message) {
|
|
772
|
+
const result = PersisstedMessageSchema.safeParse(message);
|
|
773
|
+
if (!result.success) {
|
|
774
|
+
logger.debug("[ERROR] Failed to parse interactive message:", result.error);
|
|
775
|
+
logger.debugLargeJson("[ERROR] Message:", message);
|
|
776
|
+
return void 0;
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
sessionId: result.data.sessionId,
|
|
780
|
+
type: result.data.type,
|
|
781
|
+
rawMessage: {
|
|
782
|
+
...message,
|
|
783
|
+
// Lets patch the message with another type of id just in case
|
|
784
|
+
session_id: result.data.sessionId
|
|
421
785
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
function parseClaudeSdkMessage(message) {
|
|
789
|
+
const result = SDKMessageSchema.safeParse(message);
|
|
790
|
+
if (!result.success) {
|
|
791
|
+
logger.debug("[ERROR] Failed to parse SDK message:", result.error);
|
|
792
|
+
return void 0;
|
|
793
|
+
}
|
|
794
|
+
return {
|
|
795
|
+
sessionId: result.data.session_id,
|
|
796
|
+
type: result.data.type,
|
|
797
|
+
rawMessage: {
|
|
798
|
+
...message,
|
|
799
|
+
// Lets patch the message with another type of id just in case
|
|
800
|
+
session_id: result.data.session_id
|
|
425
801
|
}
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function* watchMostRecentSession(workingDirectory, abortController) {
|
|
806
|
+
const projectName = node_path.resolve(workingDirectory).replace(/\//g, "-");
|
|
807
|
+
const projectDir = node_path.join(os.homedir(), ".claude", "projects", projectName);
|
|
808
|
+
logger.debug(`Starting session watcher for project: ${projectName}`);
|
|
809
|
+
logger.debug(`Watching directory: ${projectDir}`);
|
|
810
|
+
if (!fs.existsSync(projectDir)) {
|
|
811
|
+
logger.debug("Project directory does not exist, creating it");
|
|
812
|
+
await promises.mkdir(projectDir, { recursive: true });
|
|
426
813
|
}
|
|
427
|
-
|
|
814
|
+
const getSessionFiles = async () => {
|
|
815
|
+
const files = await promises.readdir(projectDir);
|
|
816
|
+
return files.filter((f) => f.endsWith(".jsonl")).map((f) => ({
|
|
817
|
+
name: f,
|
|
818
|
+
path: node_path.join(projectDir, f),
|
|
819
|
+
sessionId: f.replace(".jsonl", "")
|
|
820
|
+
}));
|
|
821
|
+
};
|
|
822
|
+
const initialFiles = await getSessionFiles();
|
|
823
|
+
const knownFiles = new Set(initialFiles.map((f) => f.name));
|
|
824
|
+
logger.debug(`Found ${knownFiles.size} existing session files`);
|
|
825
|
+
logger.debug("Starting directory watcher for new session files");
|
|
826
|
+
const dirWatcher = promises.watch(projectDir, { signal: abortController.signal });
|
|
827
|
+
const newSessionFilePath = await (async () => {
|
|
828
|
+
logger.debug("Entering directory watcher loop");
|
|
428
829
|
try {
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
830
|
+
for await (const event of dirWatcher) {
|
|
831
|
+
logger.debug(`Directory watcher event: ${event.eventType} - ${event.filename}`);
|
|
832
|
+
if (event.filename && event.filename.endsWith(".jsonl")) {
|
|
833
|
+
const files = await getSessionFiles();
|
|
834
|
+
for (const file of files) {
|
|
835
|
+
if (!knownFiles.has(file.name)) {
|
|
836
|
+
logger.debug(`New session file detected: ${file.name}`);
|
|
837
|
+
knownFiles.add(file.name);
|
|
838
|
+
logger.debug(`Returning file path: ${file.path}`);
|
|
839
|
+
return file.path;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
} catch (err) {
|
|
845
|
+
if (err.name !== "AbortError") {
|
|
846
|
+
logger.debug("[ERROR] Directory watcher unexpected error:", err);
|
|
847
|
+
}
|
|
848
|
+
logger.debug("Directory watcher aborted");
|
|
433
849
|
}
|
|
850
|
+
return;
|
|
851
|
+
})();
|
|
852
|
+
if (!newSessionFilePath) {
|
|
853
|
+
logger.debug("No new session file path returned, exiting watcher");
|
|
854
|
+
return;
|
|
434
855
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
856
|
+
logger.debug(`Got session file path: ${newSessionFilePath}, now starting file watcher`);
|
|
857
|
+
yield* watchSessionFile(newSessionFilePath, abortController);
|
|
438
858
|
}
|
|
439
|
-
function
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
859
|
+
async function* watchSessionFile(filePath, abortController) {
|
|
860
|
+
logger.debug(`Watching session file: ${filePath}`);
|
|
861
|
+
let position = 0;
|
|
862
|
+
const handle = await promises.open(filePath, "r");
|
|
863
|
+
const stats = await handle.stat();
|
|
864
|
+
position = stats.size;
|
|
865
|
+
await handle.close();
|
|
866
|
+
logger.debug(`Starting file watch from position: ${position}`);
|
|
867
|
+
const fileWatcher = promises.watch(filePath, { signal: abortController.signal });
|
|
868
|
+
try {
|
|
869
|
+
for await (const event of fileWatcher) {
|
|
870
|
+
logger.debug(`File watcher event: ${event.eventType}`);
|
|
871
|
+
if (event.eventType === "change") {
|
|
872
|
+
logger.debug(`Reading new content from position: ${position}`);
|
|
873
|
+
const handle2 = await promises.open(filePath, "r");
|
|
874
|
+
const stream = handle2.createReadStream({ start: position });
|
|
875
|
+
const rl = node_readline.createInterface({ input: stream });
|
|
876
|
+
for await (const line of rl) {
|
|
877
|
+
try {
|
|
878
|
+
const message = parseClaudePersistedMessage(JSON.parse(line));
|
|
879
|
+
if (message) {
|
|
880
|
+
logger.debug(`[WATCHER] New message from watched session file: ${message.type}`);
|
|
881
|
+
logger.debugLargeJson("[WATCHER] Message:", message);
|
|
882
|
+
yield message;
|
|
883
|
+
} else {
|
|
884
|
+
logger.debug("[ERROR] Skipping invalid JSON line");
|
|
885
|
+
}
|
|
886
|
+
} catch {
|
|
887
|
+
logger.debug("Skipping invalid JSON line");
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
rl.close();
|
|
891
|
+
await handle2.close();
|
|
892
|
+
const newHandle = await promises.open(filePath, "r");
|
|
893
|
+
const stats2 = await newHandle.stat();
|
|
894
|
+
const oldPosition = position;
|
|
895
|
+
position = stats2.size;
|
|
896
|
+
logger.debug(`Updated file position: ${oldPosition} -> ${position}`);
|
|
897
|
+
await newHandle.close();
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
} catch (err) {
|
|
901
|
+
if (err.name !== "AbortError") {
|
|
902
|
+
logger.debug("[ERROR] File watcher error:", err);
|
|
903
|
+
throw err;
|
|
904
|
+
}
|
|
905
|
+
logger.debug("File watcher aborted");
|
|
463
906
|
}
|
|
464
|
-
return args;
|
|
465
907
|
}
|
|
466
908
|
|
|
467
909
|
function startClaudeLoop(opts, session) {
|
|
910
|
+
let mode = "interactive";
|
|
468
911
|
let exiting = false;
|
|
912
|
+
let currentClaudeSessionId;
|
|
913
|
+
let interactiveProcess = null;
|
|
914
|
+
let watcherAbortController = null;
|
|
469
915
|
const messageQueue = [];
|
|
470
916
|
let messageResolve = null;
|
|
471
|
-
|
|
472
|
-
|
|
917
|
+
const startInteractive = () => {
|
|
918
|
+
logger.debug("[LOOP] startInteractive called");
|
|
919
|
+
logger.debug("[LOOP] Current mode:", mode);
|
|
920
|
+
logger.debug("[LOOP] Current sessionId:", currentClaudeSessionId);
|
|
921
|
+
logger.debug("[LOOP] Current interactiveProcess:", interactiveProcess ? "exists" : "null");
|
|
922
|
+
mode = "interactive";
|
|
923
|
+
session.updateAgentState((currentState) => ({
|
|
924
|
+
...currentState,
|
|
925
|
+
controlledByUser: false
|
|
926
|
+
// CLI is controlling in interactive mode
|
|
927
|
+
}));
|
|
928
|
+
let startWatcher = async () => {
|
|
929
|
+
watcherAbortController = new AbortController();
|
|
930
|
+
for await (const event of watchMostRecentSession(opts.path, watcherAbortController)) {
|
|
931
|
+
if (event.sessionId) {
|
|
932
|
+
logger.debug(`[LOOP] New session detected from watcher: ${event.sessionId}`);
|
|
933
|
+
currentClaudeSessionId = event.sessionId;
|
|
934
|
+
logger.debug("[LOOP] Updated currentSessionId to:", currentClaudeSessionId);
|
|
935
|
+
}
|
|
936
|
+
if (event.rawMessage) {
|
|
937
|
+
if (event.type === "user" && event.rawMessage.message) {
|
|
938
|
+
const userMessage = {
|
|
939
|
+
role: "user",
|
|
940
|
+
localKey: event.rawMessage.uuid,
|
|
941
|
+
// Use Claude's UUID as localKey
|
|
942
|
+
sentFrom: "cli",
|
|
943
|
+
// Identify this as coming from CLI
|
|
944
|
+
content: {
|
|
945
|
+
type: "text",
|
|
946
|
+
text: event.rawMessage.message.content
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
session.sendMessage(userMessage);
|
|
950
|
+
} else if (event.type === "assistant") {
|
|
951
|
+
session.sendMessage({
|
|
952
|
+
data: event.rawMessage,
|
|
953
|
+
type: "output"
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
void startWatcher();
|
|
960
|
+
logger.info(chalk.bold.blue("\u{1F4F1} Happy CLI - Interactive Mode"));
|
|
961
|
+
if (process.env.DEBUG) {
|
|
962
|
+
logger.logFilePathPromise.then((path) => {
|
|
963
|
+
logger.info(`Debug file for this session: ${path}`);
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
logger.info("Your session is accessible from your mobile app\n");
|
|
967
|
+
logger.debug(`[LOOP] About to spawn interactive Claude process (sessionId: ${currentClaudeSessionId})`);
|
|
968
|
+
interactiveProcess = spawnInteractiveClaude({
|
|
969
|
+
workingDirectory: opts.path,
|
|
970
|
+
sessionId: currentClaudeSessionId,
|
|
971
|
+
model: opts.model,
|
|
972
|
+
permissionMode: opts.permissionMode
|
|
973
|
+
});
|
|
974
|
+
logger.debug("[LOOP] Interactive process spawned");
|
|
975
|
+
setTimeout(() => {
|
|
976
|
+
if (interactiveProcess && process.stdout.columns && process.stdout.rows) {
|
|
977
|
+
const cols = process.stdout.columns;
|
|
978
|
+
const rows = process.stdout.rows;
|
|
979
|
+
logger.debug("[LOOP] Force resize timeout fire.d");
|
|
980
|
+
logger.debug("[LOOP] Terminal size:", { cols, rows });
|
|
981
|
+
logger.debug("[LOOP] Resizing to cols-1, rows-1");
|
|
982
|
+
interactiveProcess.resize(cols - 1, rows - 1);
|
|
983
|
+
setTimeout(() => {
|
|
984
|
+
logger.debug("[LOOP] Second resize timeout fired");
|
|
985
|
+
logger.debug("[LOOP] Resizing back to normal size");
|
|
986
|
+
interactiveProcess?.resize(cols, rows);
|
|
987
|
+
}, 10);
|
|
988
|
+
} else {
|
|
989
|
+
logger.debug("[LOOP] Force resize skipped - no process or invalid terminal size");
|
|
990
|
+
}
|
|
991
|
+
}, 100);
|
|
992
|
+
interactiveProcess.waitForExit().then((code) => {
|
|
993
|
+
logger.debug("[LOOP] Interactive process exit handler fired, code:", code);
|
|
994
|
+
logger.debug("[LOOP] Current mode:", mode);
|
|
995
|
+
logger.debug("[LOOP] Exiting:", exiting);
|
|
996
|
+
if (!exiting && mode === "interactive") {
|
|
997
|
+
logger.info(`
|
|
998
|
+
Claude exited with code ${code}`);
|
|
999
|
+
cleanup();
|
|
1000
|
+
} else {
|
|
1001
|
+
logger.debug("[LOOP] Ignoring exit - was intentional mode switch or already exiting");
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
};
|
|
1005
|
+
const requestSwitchToRemote = () => {
|
|
1006
|
+
logger.debug("[LOOP] requestSwitchToRemote called");
|
|
1007
|
+
logger.debug("[LOOP] Current mode before switch:", mode);
|
|
1008
|
+
logger.debug("[LOOP] interactiveProcess exists:", interactiveProcess ? "yes" : "no");
|
|
1009
|
+
mode = "remote";
|
|
1010
|
+
session.updateAgentState((currentState) => ({
|
|
1011
|
+
...currentState,
|
|
1012
|
+
controlledByUser: true
|
|
1013
|
+
// User is controlling via mobile in remote mode
|
|
1014
|
+
}));
|
|
1015
|
+
if (interactiveProcess) {
|
|
1016
|
+
logger.debug("[LOOP] Killing interactive process");
|
|
1017
|
+
interactiveProcess.kill();
|
|
1018
|
+
logger.debug("[LOOP] Kill called, setting interactiveProcess to null");
|
|
1019
|
+
interactiveProcess = null;
|
|
1020
|
+
} else {
|
|
1021
|
+
logger.debug("[LOOP] No interactive process to kill");
|
|
1022
|
+
}
|
|
1023
|
+
logger.info(chalk.bold.green("\u{1F4F1} Happy CLI - Remote Control Mode"));
|
|
1024
|
+
logger.info(chalk.gray("\u2500".repeat(50)));
|
|
1025
|
+
logger.info("\nYour session is being controlled from the mobile app.");
|
|
1026
|
+
logger.info("\n" + chalk.yellow("Press any key to return to interactive mode..."));
|
|
1027
|
+
process.stdout.write("\n> ");
|
|
1028
|
+
process.stdout.write("\x1B[?25h");
|
|
1029
|
+
logger.debug("[LOOP] Remote UI displayed");
|
|
1030
|
+
};
|
|
1031
|
+
session.addHandler("abort", () => {
|
|
1032
|
+
watcherAbortController?.abort();
|
|
1033
|
+
});
|
|
1034
|
+
const processRemoteMessage = async (message) => {
|
|
1035
|
+
logger.debug("Processing remote message:", message.content.text);
|
|
1036
|
+
opts.onThinking?.(true);
|
|
1037
|
+
watcherAbortController = new AbortController();
|
|
1038
|
+
for await (const output of claude({
|
|
1039
|
+
command: message.content.text,
|
|
1040
|
+
workingDirectory: opts.path,
|
|
1041
|
+
model: opts.model,
|
|
1042
|
+
permissionMode: opts.permissionMode,
|
|
1043
|
+
mcpServers: opts.mcpServers,
|
|
1044
|
+
permissionPromptToolName: opts.permissionPromptToolName,
|
|
1045
|
+
sessionId: currentClaudeSessionId,
|
|
1046
|
+
abort: watcherAbortController
|
|
1047
|
+
})) {
|
|
1048
|
+
if (output.type === "exit") {
|
|
1049
|
+
if (output.code !== 0 || output.code === void 0) {
|
|
1050
|
+
session.sendMessage({
|
|
1051
|
+
type: "error",
|
|
1052
|
+
error: output.error,
|
|
1053
|
+
code: output.code
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
1058
|
+
if (output.type === "json") {
|
|
1059
|
+
logger.debugLargeJson("[LOOP] Sending message through socket:", output.data);
|
|
1060
|
+
session.sendMessage({
|
|
1061
|
+
data: output.data,
|
|
1062
|
+
type: "output"
|
|
1063
|
+
});
|
|
1064
|
+
const claudeSdkMessage = parseClaudeSdkMessage(output.data);
|
|
1065
|
+
if (claudeSdkMessage) {
|
|
1066
|
+
currentClaudeSessionId = claudeSdkMessage.sessionId;
|
|
1067
|
+
logger.debug(`[LOOP] Updated session ID from SDK: ${currentClaudeSessionId}`);
|
|
1068
|
+
logger.debugLargeJson("[LOOP] Full init data:", output.data);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
opts.onThinking?.(false);
|
|
1073
|
+
};
|
|
1074
|
+
const run = async () => {
|
|
473
1075
|
session.onUserMessage((message) => {
|
|
1076
|
+
logger.debug("Received remote message, adding to queue");
|
|
474
1077
|
messageQueue.push(message);
|
|
1078
|
+
if (mode === "interactive") {
|
|
1079
|
+
requestSwitchToRemote();
|
|
1080
|
+
}
|
|
475
1081
|
if (messageResolve) {
|
|
1082
|
+
logger.debug("Waking up message processing loop");
|
|
476
1083
|
messageResolve();
|
|
477
1084
|
messageResolve = null;
|
|
478
1085
|
}
|
|
479
1086
|
});
|
|
1087
|
+
logger.debug("[LOOP] Setting up stdin input handling");
|
|
1088
|
+
process.stdin.setRawMode(true);
|
|
1089
|
+
process.stdin.resume();
|
|
1090
|
+
logger.debug("[LOOP] stdin set to raw mode and resumed");
|
|
1091
|
+
process.stdin.on("error", (err) => {
|
|
1092
|
+
logger.debug("[LOOP] stdin error:", err);
|
|
1093
|
+
if (err.code === "EIO") {
|
|
1094
|
+
cleanup();
|
|
1095
|
+
process.exit(0);
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
process.stdin.on("data", async (data) => {
|
|
1099
|
+
if (data.toString() === "") {
|
|
1100
|
+
logger.debug("[PTY] Ctrl+C detected");
|
|
1101
|
+
cleanup();
|
|
1102
|
+
process.exit(0);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (mode === "interactive" && interactiveProcess) {
|
|
1106
|
+
interactiveProcess.write(data);
|
|
1107
|
+
} else if (mode === "remote") {
|
|
1108
|
+
logger.debug("[LOOP] Key pressed in remote mode, switching back to interactive");
|
|
1109
|
+
startInteractive();
|
|
1110
|
+
} else {
|
|
1111
|
+
logger.debug("[LOOP] [ERROR] Data received but no action taken");
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
process.on("SIGINT", () => {
|
|
1115
|
+
logger.debug("[LOOP] SIGINT received");
|
|
1116
|
+
cleanup();
|
|
1117
|
+
process.exit(0);
|
|
1118
|
+
});
|
|
1119
|
+
process.on("SIGTERM", () => {
|
|
1120
|
+
logger.debug("[LOOP] SIGTERM received");
|
|
1121
|
+
cleanup();
|
|
1122
|
+
process.exit(0);
|
|
1123
|
+
});
|
|
1124
|
+
logger.debug("[LOOP] Initial startup - launching interactive mode");
|
|
1125
|
+
startInteractive();
|
|
480
1126
|
while (!exiting) {
|
|
481
|
-
if (messageQueue.length > 0) {
|
|
1127
|
+
if (mode === "remote" && messageQueue.length > 0) {
|
|
482
1128
|
const message = messageQueue.shift();
|
|
483
1129
|
if (message) {
|
|
484
|
-
|
|
485
|
-
command: message.content.text,
|
|
486
|
-
workingDirectory: opts.path,
|
|
487
|
-
model: opts.model,
|
|
488
|
-
permissionMode: opts.permissionMode,
|
|
489
|
-
sessionId
|
|
490
|
-
})) {
|
|
491
|
-
if (output.type === "exit") {
|
|
492
|
-
if (output.code !== 0 || output.code === void 0) {
|
|
493
|
-
session.sendMessage({
|
|
494
|
-
content: {
|
|
495
|
-
type: "error",
|
|
496
|
-
error: output.error,
|
|
497
|
-
code: output.code
|
|
498
|
-
},
|
|
499
|
-
role: "assistant"
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
break;
|
|
503
|
-
}
|
|
504
|
-
if (output.type === "json") {
|
|
505
|
-
session.sendMessage({
|
|
506
|
-
data: output.data,
|
|
507
|
-
type: "output"
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
if (output.type === "json" && output.data.type === "system" && output.data.subtype === "init") {
|
|
511
|
-
sessionId = output.data.sessionId;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
1130
|
+
await processRemoteMessage(message);
|
|
514
1131
|
}
|
|
1132
|
+
} else {
|
|
1133
|
+
logger.debug("Waiting for next message or event");
|
|
1134
|
+
await new Promise((resolve) => {
|
|
1135
|
+
messageResolve = resolve;
|
|
1136
|
+
});
|
|
515
1137
|
}
|
|
516
|
-
await new Promise((resolve) => {
|
|
517
|
-
messageResolve = resolve;
|
|
518
|
-
});
|
|
519
1138
|
}
|
|
520
|
-
}
|
|
521
|
-
|
|
1139
|
+
};
|
|
1140
|
+
const cleanup = () => {
|
|
1141
|
+
logger.debug("[LOOP] cleanup called");
|
|
522
1142
|
exiting = true;
|
|
1143
|
+
if (interactiveProcess) {
|
|
1144
|
+
logger.debug("[LOOP] Killing interactive process in cleanup");
|
|
1145
|
+
interactiveProcess.kill();
|
|
1146
|
+
} else {
|
|
1147
|
+
logger.debug("[LOOP] No interactive process to kill in cleanup");
|
|
1148
|
+
}
|
|
1149
|
+
if (watcherAbortController) {
|
|
1150
|
+
logger.debug("[LOOP] Aborting watcher");
|
|
1151
|
+
watcherAbortController.abort();
|
|
1152
|
+
}
|
|
523
1153
|
if (messageResolve) {
|
|
1154
|
+
logger.debug("[LOOP] Waking up message loop");
|
|
524
1155
|
messageResolve();
|
|
525
1156
|
}
|
|
1157
|
+
logger.debug("[LOOP] Setting stdin raw mode to false");
|
|
1158
|
+
process.stdin.setRawMode(false);
|
|
1159
|
+
process.stdin.pause();
|
|
1160
|
+
process.stdout.write("\x1B[?25h");
|
|
1161
|
+
};
|
|
1162
|
+
const promise = run();
|
|
1163
|
+
return async () => {
|
|
1164
|
+
cleanup();
|
|
526
1165
|
await promise;
|
|
527
1166
|
};
|
|
528
1167
|
}
|
|
529
1168
|
|
|
1169
|
+
async function startPermissionServer(onPermissionRequest) {
|
|
1170
|
+
const pendingRequests = /* @__PURE__ */ new Map();
|
|
1171
|
+
let lastRequestInput = {};
|
|
1172
|
+
let server;
|
|
1173
|
+
let port = 0;
|
|
1174
|
+
const handleRequest = async (req, res) => {
|
|
1175
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1176
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
1177
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1178
|
+
if (req.method === "OPTIONS") {
|
|
1179
|
+
res.writeHead(200);
|
|
1180
|
+
res.end();
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (req.method !== "POST") {
|
|
1184
|
+
res.writeHead(405);
|
|
1185
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
let body = "";
|
|
1189
|
+
req.on("data", (chunk) => body += chunk);
|
|
1190
|
+
req.on("end", async () => {
|
|
1191
|
+
try {
|
|
1192
|
+
const request = JSON.parse(body);
|
|
1193
|
+
logger.debug("[MCP] Request:", request.method, request.params?.name || "");
|
|
1194
|
+
if (request.method === "tools/list") {
|
|
1195
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1196
|
+
res.end(JSON.stringify({
|
|
1197
|
+
jsonrpc: "2.0",
|
|
1198
|
+
id: request.id,
|
|
1199
|
+
result: {
|
|
1200
|
+
tools: [
|
|
1201
|
+
{
|
|
1202
|
+
name: "request_permission",
|
|
1203
|
+
description: "Request permission to execute a tool",
|
|
1204
|
+
inputSchema: {
|
|
1205
|
+
type: "object",
|
|
1206
|
+
properties: {
|
|
1207
|
+
tool: {
|
|
1208
|
+
type: "string",
|
|
1209
|
+
description: "The tool that needs permission"
|
|
1210
|
+
},
|
|
1211
|
+
arguments: {
|
|
1212
|
+
type: "object",
|
|
1213
|
+
description: "The arguments for the tool"
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
required: ["tool", "arguments"]
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
]
|
|
1220
|
+
}
|
|
1221
|
+
}));
|
|
1222
|
+
} else if (request.method === "tools/call" && request.params?.name === "request_permission") {
|
|
1223
|
+
logger.info(`[MCP] Full request params:`, JSON.stringify(request.params, null, 2));
|
|
1224
|
+
const args = request.params.arguments || {};
|
|
1225
|
+
const { tool_name, input, tool_use_id } = args;
|
|
1226
|
+
lastRequestInput = input || {};
|
|
1227
|
+
const permissionRequest = {
|
|
1228
|
+
id: Math.random().toString(36).substring(7),
|
|
1229
|
+
tool: tool_name || "unknown",
|
|
1230
|
+
arguments: input || {},
|
|
1231
|
+
timestamp: Date.now()
|
|
1232
|
+
};
|
|
1233
|
+
logger.info(`[MCP] Permission request for tool: ${tool_name}`, input);
|
|
1234
|
+
logger.info(`[MCP] Tool use ID: ${tool_use_id}`);
|
|
1235
|
+
onPermissionRequest(permissionRequest);
|
|
1236
|
+
const response = await waitForPermissionResponse(permissionRequest.id);
|
|
1237
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1238
|
+
res.end(JSON.stringify({
|
|
1239
|
+
jsonrpc: "2.0",
|
|
1240
|
+
id: request.id,
|
|
1241
|
+
result: response
|
|
1242
|
+
}));
|
|
1243
|
+
} else if (request.method === "initialize") {
|
|
1244
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1245
|
+
res.end(JSON.stringify({
|
|
1246
|
+
jsonrpc: "2.0",
|
|
1247
|
+
id: request.id,
|
|
1248
|
+
result: {
|
|
1249
|
+
protocolVersion: "2024-11-05",
|
|
1250
|
+
capabilities: {
|
|
1251
|
+
tools: {}
|
|
1252
|
+
},
|
|
1253
|
+
serverInfo: {
|
|
1254
|
+
name: "permission-server",
|
|
1255
|
+
version: "1.0.0"
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}));
|
|
1259
|
+
} else {
|
|
1260
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1261
|
+
res.end(JSON.stringify({
|
|
1262
|
+
jsonrpc: "2.0",
|
|
1263
|
+
id: request.id,
|
|
1264
|
+
error: {
|
|
1265
|
+
code: -32601,
|
|
1266
|
+
message: "Method not found"
|
|
1267
|
+
}
|
|
1268
|
+
}));
|
|
1269
|
+
}
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
logger.debug("[MCP] [ERROR] Request error:", error);
|
|
1272
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1273
|
+
res.end(JSON.stringify({
|
|
1274
|
+
jsonrpc: "2.0",
|
|
1275
|
+
error: {
|
|
1276
|
+
code: -32603,
|
|
1277
|
+
message: "Internal error"
|
|
1278
|
+
}
|
|
1279
|
+
}));
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
};
|
|
1283
|
+
const waitForPermissionResponse = (id) => {
|
|
1284
|
+
return new Promise((resolve, reject) => {
|
|
1285
|
+
pendingRequests.set(id, { resolve, reject });
|
|
1286
|
+
});
|
|
1287
|
+
};
|
|
1288
|
+
logger.info("[MCP] Starting HTTP permission server...");
|
|
1289
|
+
await new Promise((resolve, reject) => {
|
|
1290
|
+
server = node_http.createServer(handleRequest);
|
|
1291
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1292
|
+
const address = server.address();
|
|
1293
|
+
if (address && typeof address !== "string") {
|
|
1294
|
+
port = address.port;
|
|
1295
|
+
logger.info(`[MCP] HTTP server started on port ${port}`);
|
|
1296
|
+
resolve();
|
|
1297
|
+
} else {
|
|
1298
|
+
reject(new Error("Failed to get server port"));
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
server.on("error", (error) => {
|
|
1302
|
+
logger.debug("[MCP] [ERROR] Server error:", error);
|
|
1303
|
+
reject(error);
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1306
|
+
return {
|
|
1307
|
+
port,
|
|
1308
|
+
url: `http://localhost:${port}`,
|
|
1309
|
+
toolName: "mcp__permission-server__request_permission",
|
|
1310
|
+
async stop() {
|
|
1311
|
+
logger.debug("[MCP] Stopping HTTP server...");
|
|
1312
|
+
return new Promise((resolve) => {
|
|
1313
|
+
server.close(() => {
|
|
1314
|
+
logger.debug("[MCP] HTTP server stopped");
|
|
1315
|
+
resolve();
|
|
1316
|
+
});
|
|
1317
|
+
});
|
|
1318
|
+
},
|
|
1319
|
+
respondToPermission(response) {
|
|
1320
|
+
const pending = pendingRequests.get(response.id);
|
|
1321
|
+
if (pending) {
|
|
1322
|
+
pendingRequests.delete(response.id);
|
|
1323
|
+
const result = response.approved ? { behavior: "allow", updatedInput: lastRequestInput || {} } : { behavior: "deny", message: response.reason || "Permission denied by user" };
|
|
1324
|
+
pending.resolve({
|
|
1325
|
+
content: [
|
|
1326
|
+
{
|
|
1327
|
+
type: "text",
|
|
1328
|
+
text: JSON.stringify(result)
|
|
1329
|
+
}
|
|
1330
|
+
],
|
|
1331
|
+
isError: false
|
|
1332
|
+
// Always false - Claude will parse the JSON to determine error state
|
|
1333
|
+
});
|
|
1334
|
+
logger.debug(`[MCP] Permission response for ${response.id}: ${response.approved}`);
|
|
1335
|
+
} else {
|
|
1336
|
+
logger.debug(`[MCP] No pending request found for ${response.id}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
530
1342
|
async function start(options = {}) {
|
|
531
1343
|
const workingDirectory = process.cwd();
|
|
532
|
-
|
|
1344
|
+
node_path.basename(workingDirectory);
|
|
533
1345
|
const sessionTag = node_crypto.randomUUID();
|
|
534
|
-
|
|
535
|
-
const
|
|
1346
|
+
const settings = await readSettings();
|
|
1347
|
+
const needsOnboarding = !settings || !settings.onboardingCompleted;
|
|
1348
|
+
if (needsOnboarding) {
|
|
1349
|
+
logger.info("\n" + chalk.bold.green("\u{1F389} Welcome to Happy CLI!"));
|
|
1350
|
+
logger.info("\nHappy is an open-source, end-to-end encrypted wrapper around Claude Code");
|
|
1351
|
+
logger.info("that allows you to start a regular Claude terminal session with the `happy` command.\n");
|
|
1352
|
+
if (process.platform === "darwin") {
|
|
1353
|
+
logger.info(chalk.yellow("\u{1F4A1} Tip for macOS users:"));
|
|
1354
|
+
logger.info(" Install Amphetamine to prevent your Mac from sleeping during sessions:");
|
|
1355
|
+
logger.info(" https://apps.apple.com/us/app/amphetamine/id937984704?mt=12\n");
|
|
1356
|
+
logger.info(" You can even close your laptop completely while running Amphetamine");
|
|
1357
|
+
logger.info(" and connect through hotspot to your phone for coding on the go!\n");
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
let secret = await readPrivateKey();
|
|
1361
|
+
if (!secret) {
|
|
1362
|
+
secret = new Uint8Array(node_crypto.randomBytes(32));
|
|
1363
|
+
await writePrivateKey(secret);
|
|
1364
|
+
}
|
|
536
1365
|
logger.info("Secret key loaded");
|
|
537
1366
|
const token = await authGetToken(secret);
|
|
538
1367
|
logger.info("Authenticated with handy server");
|
|
539
1368
|
const api = new ApiClient(token, secret);
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1369
|
+
let state = {};
|
|
1370
|
+
let metadata = { path: workingDirectory, host: os.hostname() };
|
|
1371
|
+
const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
|
|
1372
|
+
logger.info(`Session created: ${response.id}`);
|
|
1373
|
+
if (needsOnboarding) {
|
|
1374
|
+
const handyUrl = generateAppUrl(secret);
|
|
1375
|
+
displayQRCode(handyUrl);
|
|
1376
|
+
const secretBase64Url = encodeBase64Url(secret);
|
|
1377
|
+
logger.info(`Or manually enter this code: ${secretBase64Url}`);
|
|
1378
|
+
logger.info("\n" + chalk.bold("Press Enter to continue..."));
|
|
1379
|
+
await new Promise((resolve) => {
|
|
1380
|
+
process.stdin.once("data", () => resolve());
|
|
1381
|
+
});
|
|
1382
|
+
await writeSettings({ onboardingCompleted: true });
|
|
1383
|
+
}
|
|
1384
|
+
const session = api.session(response);
|
|
1385
|
+
const permissionServer = await startPermissionServer((request) => {
|
|
1386
|
+
logger.info("Permission request:", request);
|
|
1387
|
+
session.sendMessage({
|
|
1388
|
+
type: "permission-request",
|
|
1389
|
+
data: request
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
1392
|
+
logger.info(`MCP permission server started on port ${permissionServer.port}`);
|
|
1393
|
+
session.on("message", (message) => {
|
|
1394
|
+
if (message.type === "permission-response") {
|
|
1395
|
+
logger.info("Permission response from client:", message.data);
|
|
1396
|
+
permissionServer.respondToPermission(message.data);
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
const mcpServers = {
|
|
1400
|
+
"permission-server": {
|
|
1401
|
+
type: "http",
|
|
1402
|
+
url: permissionServer.url
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
let thinking = false;
|
|
1406
|
+
const loopDestroy = startClaudeLoop({
|
|
1407
|
+
path: workingDirectory,
|
|
1408
|
+
model: options.model,
|
|
1409
|
+
permissionMode: options.permissionMode,
|
|
1410
|
+
mcpServers,
|
|
1411
|
+
permissionPromptToolName: permissionServer.toolName,
|
|
1412
|
+
onThinking: (t) => {
|
|
1413
|
+
thinking = t;
|
|
1414
|
+
session.keepAlive(t);
|
|
1415
|
+
session.updateAgentState((currentState) => ({
|
|
1416
|
+
...currentState,
|
|
1417
|
+
thinking: t
|
|
1418
|
+
}));
|
|
1419
|
+
}
|
|
1420
|
+
}, session);
|
|
1421
|
+
const pingInterval = setInterval(() => {
|
|
1422
|
+
session.keepAlive(thinking);
|
|
1423
|
+
}, 15e3);
|
|
546
1424
|
const shutdown = async () => {
|
|
547
1425
|
logger.info("Shutting down...");
|
|
1426
|
+
clearInterval(pingInterval);
|
|
548
1427
|
await loopDestroy();
|
|
1428
|
+
await permissionServer.stop();
|
|
1429
|
+
session.sendSessionDeath();
|
|
1430
|
+
await session.flush();
|
|
549
1431
|
await session.close();
|
|
550
1432
|
process.exit(0);
|
|
551
1433
|
};
|
|
552
1434
|
process.on("SIGINT", shutdown);
|
|
553
1435
|
process.on("SIGTERM", shutdown);
|
|
554
|
-
logger.info("Happy CLI is
|
|
1436
|
+
logger.info("Happy CLI is starting...");
|
|
555
1437
|
await new Promise(() => {
|
|
556
1438
|
});
|
|
557
1439
|
}
|
|
558
1440
|
|
|
559
1441
|
const args = process.argv.slice(2);
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
} else if (arg === "-v" || arg === "--version") {
|
|
568
|
-
showVersion = true;
|
|
569
|
-
} else if (arg === "-m" || arg === "--model") {
|
|
570
|
-
options.model = args[++i];
|
|
571
|
-
} else if (arg === "-p" || arg === "--permission-mode") {
|
|
572
|
-
options.permissionMode = args[++i];
|
|
573
|
-
} else {
|
|
574
|
-
console.error(chalk.red(`Unknown argument: ${arg}`));
|
|
1442
|
+
const subcommand = args[0];
|
|
1443
|
+
if (subcommand === "clean") {
|
|
1444
|
+
cleanKey().catch((error) => {
|
|
1445
|
+
console.error(chalk.red("Error:"), error.message);
|
|
1446
|
+
if (process.env.DEBUG) {
|
|
1447
|
+
console.error(error);
|
|
1448
|
+
}
|
|
575
1449
|
process.exit(1);
|
|
1450
|
+
});
|
|
1451
|
+
} else {
|
|
1452
|
+
const options = {};
|
|
1453
|
+
let showHelp = false;
|
|
1454
|
+
let showVersion = false;
|
|
1455
|
+
for (let i = 0; i < args.length; i++) {
|
|
1456
|
+
const arg = args[i];
|
|
1457
|
+
if (arg === "-h" || arg === "--help") {
|
|
1458
|
+
showHelp = true;
|
|
1459
|
+
} else if (arg === "-v" || arg === "--version") {
|
|
1460
|
+
showVersion = true;
|
|
1461
|
+
} else if (arg === "-m" || arg === "--model") {
|
|
1462
|
+
options.model = args[++i];
|
|
1463
|
+
} else if (arg === "-p" || arg === "--permission-mode") {
|
|
1464
|
+
options.permissionMode = args[++i];
|
|
1465
|
+
} else {
|
|
1466
|
+
console.error(chalk.red(`Unknown argument: ${arg}`));
|
|
1467
|
+
process.exit(1);
|
|
1468
|
+
}
|
|
576
1469
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
console.log(`
|
|
1470
|
+
if (showHelp) {
|
|
1471
|
+
console.log(`
|
|
580
1472
|
${chalk.bold("happy")} - Claude Code session sharing
|
|
581
1473
|
|
|
582
1474
|
${chalk.bold("Usage:")}
|
|
583
1475
|
happy [options]
|
|
1476
|
+
happy clean Remove happy data directory (requires phone reconnection)
|
|
584
1477
|
|
|
585
1478
|
${chalk.bold("Options:")}
|
|
586
1479
|
-h, --help Show this help message
|
|
@@ -592,17 +1485,47 @@ ${chalk.bold("Examples:")}
|
|
|
592
1485
|
happy Start a session with default settings
|
|
593
1486
|
happy -m opus Use Claude Opus model
|
|
594
1487
|
happy -p plan Use plan permission mode
|
|
1488
|
+
happy clean Remove happy data directory and authentication
|
|
595
1489
|
`);
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
if (showVersion) {
|
|
599
|
-
|
|
600
|
-
|
|
1490
|
+
process.exit(0);
|
|
1491
|
+
}
|
|
1492
|
+
if (showVersion) {
|
|
1493
|
+
console.log("0.1.0");
|
|
1494
|
+
process.exit(0);
|
|
1495
|
+
}
|
|
1496
|
+
start(options).catch((error) => {
|
|
1497
|
+
console.error(chalk.red("Error:"), error.message);
|
|
1498
|
+
if (process.env.DEBUG) {
|
|
1499
|
+
console.error(error);
|
|
1500
|
+
}
|
|
1501
|
+
process.exit(1);
|
|
1502
|
+
});
|
|
601
1503
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
if (
|
|
605
|
-
console.
|
|
1504
|
+
async function cleanKey() {
|
|
1505
|
+
const handyDir = node_path.join(os.homedir(), ".handy");
|
|
1506
|
+
if (!fs.existsSync(handyDir)) {
|
|
1507
|
+
console.log(chalk.yellow("No happy data directory found at:"), handyDir);
|
|
1508
|
+
return;
|
|
606
1509
|
}
|
|
607
|
-
|
|
608
|
-
|
|
1510
|
+
console.log(chalk.blue("Found happy data directory at:"), handyDir);
|
|
1511
|
+
console.log(chalk.yellow("\u26A0\uFE0F This will remove all authentication data and require reconnecting your phone."));
|
|
1512
|
+
const rl = node_readline.createInterface({
|
|
1513
|
+
input: process.stdin,
|
|
1514
|
+
output: process.stdout
|
|
1515
|
+
});
|
|
1516
|
+
const answer = await new Promise((resolve) => {
|
|
1517
|
+
rl.question(chalk.yellow("Are you sure you want to remove the happy data directory? (y/N): "), resolve);
|
|
1518
|
+
});
|
|
1519
|
+
rl.close();
|
|
1520
|
+
if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
|
|
1521
|
+
try {
|
|
1522
|
+
fs.rmSync(handyDir, { recursive: true, force: true });
|
|
1523
|
+
console.log(chalk.green("\u2713 Happy data directory removed successfully"));
|
|
1524
|
+
console.log(chalk.blue("\u2139\uFE0F You will need to reconnect your phone on the next session"));
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
throw new Error(`Failed to remove data directory: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
1527
|
+
}
|
|
1528
|
+
} else {
|
|
1529
|
+
console.log(chalk.blue("Operation cancelled"));
|
|
1530
|
+
}
|
|
1531
|
+
}
|