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