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.mjs CHANGED
@@ -1,17 +1,21 @@
1
1
  import axios from 'axios';
2
2
  import * as fs from 'node:fs';
3
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
4
- import { chmod } from 'node:fs/promises';
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
- import { homedir } from 'node:os';
8
- import { join, basename } from 'node:path';
7
+ import os, { homedir } from 'node:os';
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 { spawn } from 'node:child_process';
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
- if (process.env.DEBUG) {
89
- this.log("DEBUG" /* DEBUG */, message, ...args);
90
- }
141
+ this.logToFile(`[${(/* @__PURE__ */ new Date()).toISOString()}]`, message, ...args);
91
142
  }
92
- error(message, ...args) {
93
- this.log("ERROR" /* ERROR */, message, ...args);
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.log("INFO" /* INFO */, message, ...args);
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
- log(level, message, ...args) {
105
- const timestamp = this.getTimestamp();
106
- const prefix = `[${timestamp}] [${level}]`;
174
+ logToConsole(level, prefix, message, ...args) {
107
175
  switch (level) {
108
- case "DEBUG" /* DEBUG */: {
176
+ case "debug": {
109
177
  console.log(chalk.gray(prefix), message, ...args);
110
178
  break;
111
179
  }
112
- case "ERROR" /* ERROR */: {
180
+ case "error": {
113
181
  console.error(chalk.red(prefix), message, ...args);
114
182
  break;
115
183
  }
116
- case "INFO" /* INFO */: {
184
+ case "info": {
117
185
  console.log(chalk.blue(prefix), message, ...args);
118
186
  break;
119
187
  }
120
- case "WARN" /* WARN */: {
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
- constructor(token, secret, sessionId) {
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 = 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.warn("Socket disconnected:", reason);
388
+ logger.debug("[API] Socket disconnected:", reason);
216
389
  });
217
390
  this.socket.on("connect_error", (error) => {
218
- logger.error("Socket connection error:", error.message);
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
- const result = UserMessageSchema.safeParse(body);
224
- if (result.success) {
225
- if (!this.receivedMessages.has(data.body.message.id)) {
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(result.data);
405
+ this.pendingMessageCallback(userResult.data);
229
406
  } else {
230
- this.pendingMessages.push(result.data);
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
- let content = {
250
- role: "agent",
251
- content: body
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
  }
@@ -271,11 +560,15 @@ class ApiClient {
271
560
  /**
272
561
  * Create a new session or load existing one with the given tag
273
562
  */
274
- async getOrCreateSession(tag) {
563
+ async getOrCreateSession(opts) {
275
564
  try {
276
565
  const response = await axios.post(
277
566
  `https://handy-api.korshakov.org/v1/sessions`,
278
- { tag },
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}`,
@@ -283,10 +576,21 @@ class ApiClient {
283
576
  }
284
577
  }
285
578
  );
286
- logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${tag})`);
287
- return response.data;
579
+ logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`);
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.error("Failed to get or create session:", error);
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(id) {
299
- return new ApiSessionClient(this.token, this.secret, id);
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.info("=".repeat(50));
306
- logger.info("\u{1F4F1} Scan this QR code with your mobile device:");
307
- logger.info("=".repeat(50));
308
- qrcode.generate(url, { small: true }, (qr) => {
309
- for (let l of qr.split("\n")) {
310
- logger.info(" " + l);
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
- logger.info("=".repeat(50));
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.error("Failed to generate QR code:", error);
316
- logger.info(`\u{1F4CB} Use this URL to connect: ${url}`);
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,237 +684,775 @@ function claudePath() {
325
684
  }
326
685
  }
327
686
 
328
- async function* claude(options) {
329
- const args = buildArgs(options);
330
- const path = claudePath();
331
- logger.info("Spawning Claude CLI with args:", args);
332
- const process = spawn(path, args, {
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
- stdio: ["pipe", "pipe", "pipe"],
335
- shell: false
704
+ env: process.env
336
705
  });
337
- process.stdin?.end();
338
- let outputBuffer = "";
339
- let stderrBuffer = "";
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
- process.on("exit", (code, signal) => {
377
- processExited = true;
378
- outputQueue.push({ type: "exit", code, signal });
379
- if (outputResolve) {
380
- outputResolve();
381
- outputResolve = null;
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
- process.on("error", (error) => {
385
- outputQueue.push({ type: "error", error: error.message });
386
- processExited = true;
387
- if (outputResolve) {
388
- outputResolve();
389
- outputResolve = null;
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
- while (!processExited || outputQueue.length > 0) {
393
- if (outputQueue.length === 0) {
394
- await new Promise((resolve) => {
395
- outputResolve = resolve;
396
- if (outputQueue.length > 0 || processExited) {
397
- resolve();
398
- outputResolve = null;
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
- while (outputQueue.length > 0) {
403
- const output = outputQueue.shift();
404
- yield output;
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
- if (outputBuffer.trim()) {
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 json = JSON.parse(outputBuffer);
410
- yield { type: "json", data: json };
411
- } catch {
412
- yield { type: "text", data: outputBuffer };
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
- if (stderrBuffer.trim()) {
416
- yield { type: "error", error: stderrBuffer };
417
- }
835
+ logger.debug(`Got session file path: ${newSessionFilePath}, now starting file watcher`);
836
+ yield* watchSessionFile(newSessionFilePath, abortController);
418
837
  }
419
- function buildArgs(options) {
420
- const args = [
421
- "--print",
422
- options.command,
423
- "--output-format",
424
- "stream-json",
425
- "--verbose"
426
- ];
427
- if (options.model) {
428
- args.push("--model", options.model);
429
- }
430
- if (options.permissionMode) {
431
- const modeMap = {
432
- "auto": "acceptEdits",
433
- "default": "default",
434
- "plan": "bypassPermissions"
435
- };
436
- args.push("--permission-mode", modeMap[options.permissionMode]);
437
- }
438
- if (options.skipPermissions) {
439
- args.push("--dangerously-skip-permissions");
440
- }
441
- if (options.sessionId) {
442
- args.push("--resume", options.sessionId);
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
- let promise = (async () => {
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 () => {
452
1054
  session.onUserMessage((message) => {
1055
+ logger.debug("Received remote message, adding to queue");
453
1056
  messageQueue.push(message);
1057
+ if (mode === "interactive") {
1058
+ requestSwitchToRemote();
1059
+ }
454
1060
  if (messageResolve) {
1061
+ logger.debug("Waking up message processing loop");
455
1062
  messageResolve();
456
1063
  messageResolve = null;
457
1064
  }
458
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();
459
1105
  while (!exiting) {
460
- if (messageQueue.length > 0) {
1106
+ if (mode === "remote" && messageQueue.length > 0) {
461
1107
  const message = messageQueue.shift();
462
1108
  if (message) {
463
- for await (const output of claude({
464
- command: message.content.text,
465
- workingDirectory: opts.path,
466
- model: opts.model,
467
- permissionMode: opts.permissionMode
468
- })) {
469
- if (output.type === "exit") {
470
- if (output.code !== 0 || output.code === void 0) {
471
- session.sendMessage({
472
- content: {
473
- type: "error",
474
- error: output.error,
475
- code: output.code
476
- },
477
- role: "assistant"
478
- });
479
- }
480
- break;
481
- }
482
- if (output.type === "json") {
483
- session.sendMessage({
484
- data: output.data,
485
- type: "output"
486
- });
487
- }
488
- }
1109
+ await processRemoteMessage(message);
489
1110
  }
1111
+ } else {
1112
+ logger.debug("Waiting for next message or event");
1113
+ await new Promise((resolve) => {
1114
+ messageResolve = resolve;
1115
+ });
490
1116
  }
491
- await new Promise((resolve) => {
492
- messageResolve = resolve;
493
- });
494
1117
  }
495
- })();
496
- return async () => {
1118
+ };
1119
+ const cleanup = () => {
1120
+ logger.debug("[LOOP] cleanup called");
497
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
+ }
498
1132
  if (messageResolve) {
1133
+ logger.debug("[LOOP] Waking up message loop");
499
1134
  messageResolve();
500
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();
501
1144
  await promise;
502
1145
  };
503
1146
  }
504
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
+
505
1321
  async function start(options = {}) {
506
1322
  const workingDirectory = process.cwd();
507
- const projectName = basename(workingDirectory);
1323
+ basename(workingDirectory);
508
1324
  const sessionTag = randomUUID();
509
- logger.info(`Starting happy session for project: ${projectName}`);
510
- const secret = await getOrCreateSecretKey();
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
+ }
511
1344
  logger.info("Secret key loaded");
512
1345
  const token = await authGetToken(secret);
513
1346
  logger.info("Authenticated with handy server");
514
1347
  const api = new ApiClient(token, secret);
515
- const response = await api.getOrCreateSession(sessionTag);
516
- logger.info(`Session created: ${response.session.id}`);
517
- const handyUrl = generateAppUrl(secret);
518
- displayQRCode(handyUrl);
519
- const session = api.session(response.session.id);
520
- const loopDestroy = startClaudeLoop({ path: workingDirectory }, session);
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);
521
1403
  const shutdown = async () => {
522
1404
  logger.info("Shutting down...");
1405
+ clearInterval(pingInterval);
523
1406
  await loopDestroy();
1407
+ await permissionServer.stop();
1408
+ session.sendSessionDeath();
1409
+ await session.flush();
524
1410
  await session.close();
525
1411
  process.exit(0);
526
1412
  };
527
1413
  process.on("SIGINT", shutdown);
528
1414
  process.on("SIGTERM", shutdown);
529
- logger.info("Happy CLI is running. Press Ctrl+C to stop.");
1415
+ logger.info("Happy CLI is starting...");
530
1416
  await new Promise(() => {
531
1417
  });
532
1418
  }
533
1419
 
534
1420
  const args = process.argv.slice(2);
535
- const options = {};
536
- let showHelp = false;
537
- let showVersion = false;
538
- for (let i = 0; i < args.length; i++) {
539
- const arg = args[i];
540
- if (arg === "-h" || arg === "--help") {
541
- showHelp = true;
542
- } else if (arg === "-v" || arg === "--version") {
543
- showVersion = true;
544
- } else if (arg === "-m" || arg === "--model") {
545
- options.model = args[++i];
546
- } else if (arg === "-p" || arg === "--permission-mode") {
547
- options.permissionMode = args[++i];
548
- } else {
549
- 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
+ }
550
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
+ }
551
1448
  }
552
- }
553
- if (showHelp) {
554
- console.log(`
1449
+ if (showHelp) {
1450
+ console.log(`
555
1451
  ${chalk.bold("happy")} - Claude Code session sharing
556
1452
 
557
1453
  ${chalk.bold("Usage:")}
558
1454
  happy [options]
1455
+ happy clean Remove happy data directory (requires phone reconnection)
559
1456
 
560
1457
  ${chalk.bold("Options:")}
561
1458
  -h, --help Show this help message
@@ -567,17 +1464,47 @@ ${chalk.bold("Examples:")}
567
1464
  happy Start a session with default settings
568
1465
  happy -m opus Use Claude Opus model
569
1466
  happy -p plan Use plan permission mode
1467
+ happy clean Remove happy data directory and authentication
570
1468
  `);
571
- process.exit(0);
572
- }
573
- if (showVersion) {
574
- console.log("0.1.0");
575
- process.exit(0);
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
+ });
576
1482
  }
577
- start(options).catch((error) => {
578
- console.error(chalk.red("Error:"), error.message);
579
- if (process.env.DEBUG) {
580
- console.error(error);
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;
581
1488
  }
582
- process.exit(1);
583
- });
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
+ }