happy-coder 0.1.1 → 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.
Files changed (3) hide show
  1. package/dist/index.cjs +1173 -245
  2. package/dist/index.mjs +1175 -248
  3. package/package.json +5 -3
package/dist/index.cjs CHANGED
@@ -5,14 +5,18 @@ var fs = require('node:fs');
5
5
  var promises = require('node:fs/promises');
6
6
  var node_crypto = require('node:crypto');
7
7
  var tweetnacl = require('tweetnacl');
8
- var node_os = require('node:os');
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 node_child_process = require('node:child_process');
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(node_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(node_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
- if (process.env.DEBUG) {
109
- this.log("DEBUG" /* DEBUG */, message, ...args);
110
- }
162
+ this.logToFile(`[${(/* @__PURE__ */ new Date()).toISOString()}]`, message, ...args);
111
163
  }
112
- error(message, ...args) {
113
- this.log("ERROR" /* ERROR */, message, ...args);
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.log("INFO" /* INFO */, message, ...args);
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
- log(level, message, ...args) {
125
- const timestamp = this.getTimestamp();
126
- const prefix = `[${timestamp}] [${level}]`;
195
+ logToConsole(level, prefix, message, ...args) {
127
196
  switch (level) {
128
- case "DEBUG" /* DEBUG */: {
197
+ case "debug": {
129
198
  console.log(chalk.gray(prefix), message, ...args);
130
199
  break;
131
200
  }
132
- case "ERROR" /* ERROR */: {
201
+ case "error": {
133
202
  console.error(chalk.red(prefix), message, ...args);
134
203
  break;
135
204
  }
136
- case "INFO" /* INFO */: {
205
+ case "info": {
137
206
  console.log(chalk.blue(prefix), message, ...args);
138
207
  break;
139
208
  }
140
- case "WARN" /* WARN */: {
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
- constructor(token, secret, sessionId) {
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 = 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.warn("Socket disconnected:", reason);
409
+ logger.debug("[API] Socket disconnected:", reason);
236
410
  });
237
411
  this.socket.on("connect_error", (error) => {
238
- logger.error("Socket connection error:", error.message);
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
- const result = UserMessageSchema.safeParse(body);
244
- if (result.success) {
245
- if (!this.receivedMessages.has(data.body.message.id)) {
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(result.data);
426
+ this.pendingMessageCallback(userResult.data);
249
427
  } else {
250
- this.pendingMessages.push(result.data);
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
- let content = {
270
- role: "agent",
271
- content: body
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
  }
@@ -291,11 +581,15 @@ class ApiClient {
291
581
  /**
292
582
  * Create a new session or load existing one with the given tag
293
583
  */
294
- async getOrCreateSession(tag) {
584
+ async getOrCreateSession(opts) {
295
585
  try {
296
586
  const response = await axios.post(
297
587
  `https://handy-api.korshakov.org/v1/sessions`,
298
- { tag },
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}`,
@@ -303,10 +597,21 @@ class ApiClient {
303
597
  }
304
598
  }
305
599
  );
306
- logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${tag})`);
307
- return response.data;
600
+ logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`);
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.error("Failed to get or create session:", error);
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(id) {
319
- return new ApiSessionClient(this.token, this.secret, id);
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.info("=".repeat(50));
326
- logger.info("\u{1F4F1} Scan this QR code with your mobile device:");
327
- logger.info("=".repeat(50));
328
- qrcode.generate(url, { small: true }, (qr) => {
329
- for (let l of qr.split("\n")) {
330
- logger.info(" " + l);
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
- logger.info("=".repeat(50));
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.error("Failed to generate QR code:", error);
336
- logger.info(`\u{1F4CB} Use this URL to connect: ${url}`);
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,237 +705,775 @@ function claudePath() {
345
705
  }
346
706
  }
347
707
 
348
- async function* claude(options) {
349
- const args = buildArgs(options);
350
- const path = claudePath();
351
- logger.info("Spawning Claude CLI with args:", args);
352
- const process = node_child_process.spawn(path, args, {
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
- stdio: ["pipe", "pipe", "pipe"],
355
- shell: false
725
+ env: process.env
356
726
  });
357
- process.stdin?.end();
358
- let outputBuffer = "";
359
- let stderrBuffer = "";
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
- process.on("exit", (code, signal) => {
397
- processExited = true;
398
- outputQueue.push({ type: "exit", code, signal });
399
- if (outputResolve) {
400
- outputResolve();
401
- outputResolve = null;
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
- process.on("error", (error) => {
405
- outputQueue.push({ type: "error", error: error.message });
406
- processExited = true;
407
- if (outputResolve) {
408
- outputResolve();
409
- outputResolve = null;
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
- while (!processExited || outputQueue.length > 0) {
413
- if (outputQueue.length === 0) {
414
- await new Promise((resolve) => {
415
- outputResolve = resolve;
416
- if (outputQueue.length > 0 || processExited) {
417
- resolve();
418
- outputResolve = null;
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
- while (outputQueue.length > 0) {
423
- const output = outputQueue.shift();
424
- yield output;
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
- if (outputBuffer.trim()) {
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 json = JSON.parse(outputBuffer);
430
- yield { type: "json", data: json };
431
- } catch {
432
- yield { type: "text", data: outputBuffer };
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
- if (stderrBuffer.trim()) {
436
- yield { type: "error", error: stderrBuffer };
437
- }
856
+ logger.debug(`Got session file path: ${newSessionFilePath}, now starting file watcher`);
857
+ yield* watchSessionFile(newSessionFilePath, abortController);
438
858
  }
439
- function buildArgs(options) {
440
- const args = [
441
- "--print",
442
- options.command,
443
- "--output-format",
444
- "stream-json",
445
- "--verbose"
446
- ];
447
- if (options.model) {
448
- args.push("--model", options.model);
449
- }
450
- if (options.permissionMode) {
451
- const modeMap = {
452
- "auto": "acceptEdits",
453
- "default": "default",
454
- "plan": "bypassPermissions"
455
- };
456
- args.push("--permission-mode", modeMap[options.permissionMode]);
457
- }
458
- if (options.skipPermissions) {
459
- args.push("--dangerously-skip-permissions");
460
- }
461
- if (options.sessionId) {
462
- args.push("--resume", options.sessionId);
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
- let promise = (async () => {
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 () => {
472
1075
  session.onUserMessage((message) => {
1076
+ logger.debug("Received remote message, adding to queue");
473
1077
  messageQueue.push(message);
1078
+ if (mode === "interactive") {
1079
+ requestSwitchToRemote();
1080
+ }
474
1081
  if (messageResolve) {
1082
+ logger.debug("Waking up message processing loop");
475
1083
  messageResolve();
476
1084
  messageResolve = null;
477
1085
  }
478
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();
479
1126
  while (!exiting) {
480
- if (messageQueue.length > 0) {
1127
+ if (mode === "remote" && messageQueue.length > 0) {
481
1128
  const message = messageQueue.shift();
482
1129
  if (message) {
483
- for await (const output of claude({
484
- command: message.content.text,
485
- workingDirectory: opts.path,
486
- model: opts.model,
487
- permissionMode: opts.permissionMode
488
- })) {
489
- if (output.type === "exit") {
490
- if (output.code !== 0 || output.code === void 0) {
491
- session.sendMessage({
492
- content: {
493
- type: "error",
494
- error: output.error,
495
- code: output.code
496
- },
497
- role: "assistant"
498
- });
499
- }
500
- break;
501
- }
502
- if (output.type === "json") {
503
- session.sendMessage({
504
- data: output.data,
505
- type: "output"
506
- });
507
- }
508
- }
1130
+ await processRemoteMessage(message);
509
1131
  }
1132
+ } else {
1133
+ logger.debug("Waiting for next message or event");
1134
+ await new Promise((resolve) => {
1135
+ messageResolve = resolve;
1136
+ });
510
1137
  }
511
- await new Promise((resolve) => {
512
- messageResolve = resolve;
513
- });
514
1138
  }
515
- })();
516
- return async () => {
1139
+ };
1140
+ const cleanup = () => {
1141
+ logger.debug("[LOOP] cleanup called");
517
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
+ }
518
1153
  if (messageResolve) {
1154
+ logger.debug("[LOOP] Waking up message loop");
519
1155
  messageResolve();
520
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();
521
1165
  await promise;
522
1166
  };
523
1167
  }
524
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
+
525
1342
  async function start(options = {}) {
526
1343
  const workingDirectory = process.cwd();
527
- const projectName = node_path.basename(workingDirectory);
1344
+ node_path.basename(workingDirectory);
528
1345
  const sessionTag = node_crypto.randomUUID();
529
- logger.info(`Starting happy session for project: ${projectName}`);
530
- const secret = await getOrCreateSecretKey();
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
+ }
531
1365
  logger.info("Secret key loaded");
532
1366
  const token = await authGetToken(secret);
533
1367
  logger.info("Authenticated with handy server");
534
1368
  const api = new ApiClient(token, secret);
535
- const response = await api.getOrCreateSession(sessionTag);
536
- logger.info(`Session created: ${response.session.id}`);
537
- const handyUrl = generateAppUrl(secret);
538
- displayQRCode(handyUrl);
539
- const session = api.session(response.session.id);
540
- const loopDestroy = startClaudeLoop({ path: workingDirectory }, session);
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);
541
1424
  const shutdown = async () => {
542
1425
  logger.info("Shutting down...");
1426
+ clearInterval(pingInterval);
543
1427
  await loopDestroy();
1428
+ await permissionServer.stop();
1429
+ session.sendSessionDeath();
1430
+ await session.flush();
544
1431
  await session.close();
545
1432
  process.exit(0);
546
1433
  };
547
1434
  process.on("SIGINT", shutdown);
548
1435
  process.on("SIGTERM", shutdown);
549
- logger.info("Happy CLI is running. Press Ctrl+C to stop.");
1436
+ logger.info("Happy CLI is starting...");
550
1437
  await new Promise(() => {
551
1438
  });
552
1439
  }
553
1440
 
554
1441
  const args = process.argv.slice(2);
555
- const options = {};
556
- let showHelp = false;
557
- let showVersion = false;
558
- for (let i = 0; i < args.length; i++) {
559
- const arg = args[i];
560
- if (arg === "-h" || arg === "--help") {
561
- showHelp = true;
562
- } else if (arg === "-v" || arg === "--version") {
563
- showVersion = true;
564
- } else if (arg === "-m" || arg === "--model") {
565
- options.model = args[++i];
566
- } else if (arg === "-p" || arg === "--permission-mode") {
567
- options.permissionMode = args[++i];
568
- } else {
569
- 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
+ }
570
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
+ }
571
1469
  }
572
- }
573
- if (showHelp) {
574
- console.log(`
1470
+ if (showHelp) {
1471
+ console.log(`
575
1472
  ${chalk.bold("happy")} - Claude Code session sharing
576
1473
 
577
1474
  ${chalk.bold("Usage:")}
578
1475
  happy [options]
1476
+ happy clean Remove happy data directory (requires phone reconnection)
579
1477
 
580
1478
  ${chalk.bold("Options:")}
581
1479
  -h, --help Show this help message
@@ -587,17 +1485,47 @@ ${chalk.bold("Examples:")}
587
1485
  happy Start a session with default settings
588
1486
  happy -m opus Use Claude Opus model
589
1487
  happy -p plan Use plan permission mode
1488
+ happy clean Remove happy data directory and authentication
590
1489
  `);
591
- process.exit(0);
592
- }
593
- if (showVersion) {
594
- console.log("0.1.0");
595
- process.exit(0);
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
+ });
596
1503
  }
597
- start(options).catch((error) => {
598
- console.error(chalk.red("Error:"), error.message);
599
- if (process.env.DEBUG) {
600
- console.error(error);
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;
601
1509
  }
602
- process.exit(1);
603
- });
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
+ }