happy-coder 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,124 +1,53 @@
1
- import axios from 'axios';
2
- import * as fs from 'node:fs';
3
- import { existsSync, rmSync } from 'node:fs';
4
- import { mkdir, readFile, writeFile, watch, readdir, open } from 'node:fs/promises';
5
- import { randomBytes, randomUUID } from 'node:crypto';
6
- import tweetnacl from 'tweetnacl';
7
- import os, { homedir } from 'node:os';
8
- import { join, resolve, basename } from 'node:path';
9
1
  import chalk from 'chalk';
2
+ import axios from 'axios';
10
3
  import { appendFileSync } from 'fs';
4
+ import os, { homedir } from 'node:os';
5
+ import { join, resolve, dirname } from 'node:path';
6
+ import { mkdir, watch as watch$1, readFile, writeFile } from 'node:fs/promises';
7
+ import { existsSync, readFileSync, mkdirSync, watch, rmSync } from 'node:fs';
11
8
  import { EventEmitter } from 'node:events';
12
9
  import { io } from 'socket.io-client';
13
- import { z } from 'zod';
14
- import qrcode from 'qrcode-terminal';
15
- import { query } from '@anthropic-ai/claude-code';
16
- import * as pty from 'node-pty';
10
+ import * as z from 'zod';
11
+ import { z as z$1 } from 'zod';
12
+ import { randomBytes, randomUUID } from 'node:crypto';
13
+ import tweetnacl from 'tweetnacl';
14
+ import { Expo } from 'expo-server-sdk';
15
+ import { query, AbortError } from '@anthropic-ai/claude-code';
16
+ import { spawn } from 'node:child_process';
17
17
  import { createInterface } from 'node:readline';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
20
  import { createServer } from 'node:http';
21
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
22
+ import qrcode from 'qrcode-terminal';
19
23
 
20
- function encodeBase64(buffer) {
21
- return Buffer.from(buffer).toString("base64");
22
- }
23
- function encodeBase64Url(buffer) {
24
- return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
25
- }
26
- function decodeBase64(base64) {
27
- return new Uint8Array(Buffer.from(base64, "base64"));
28
- }
29
- function getRandomBytes(size) {
30
- return new Uint8Array(randomBytes(size));
31
- }
32
- function encrypt(data, secret) {
33
- const nonce = getRandomBytes(tweetnacl.secretbox.nonceLength);
34
- const encrypted = tweetnacl.secretbox(new TextEncoder().encode(JSON.stringify(data)), nonce, secret);
35
- const result = new Uint8Array(nonce.length + encrypted.length);
36
- result.set(nonce);
37
- result.set(encrypted, nonce.length);
38
- return result;
39
- }
40
- function decrypt(data, secret) {
41
- const nonce = data.slice(0, tweetnacl.secretbox.nonceLength);
42
- const encrypted = data.slice(tweetnacl.secretbox.nonceLength);
43
- const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret);
44
- if (!decrypted) {
45
- return null;
46
- }
47
- return JSON.parse(new TextDecoder().decode(decrypted));
48
- }
49
- function authChallenge(secret) {
50
- const keypair = tweetnacl.sign.keyPair.fromSeed(secret);
51
- const challenge = getRandomBytes(32);
52
- const signature = tweetnacl.sign.detached(challenge, keypair.secretKey);
53
- return {
54
- challenge,
55
- publicKey: keypair.publicKey,
56
- signature
57
- };
58
- }
59
-
60
- async function authGetToken(secret) {
61
- const { challenge, publicKey, signature } = authChallenge(secret);
62
- const response = await axios.post(`https://handy-api.korshakov.org/v1/auth`, {
63
- challenge: encodeBase64(challenge),
64
- publicKey: encodeBase64(publicKey),
65
- signature: encodeBase64(signature)
66
- });
67
- if (!response.data.success || !response.data.token) {
68
- throw new Error("Authentication failed");
24
+ class Configuration {
25
+ serverUrl;
26
+ // Directories and paths (from persistence)
27
+ happyDir;
28
+ logsDir;
29
+ settingsFile;
30
+ privateKeyFile;
31
+ constructor(location) {
32
+ this.serverUrl = process.env.HANDY_SERVER_URL || "https://handy-api.korshakov.org";
33
+ if (location === "local") {
34
+ this.happyDir = join(process.cwd(), ".happy");
35
+ } else {
36
+ this.happyDir = join(homedir(), ".happy");
37
+ }
38
+ this.logsDir = join(this.happyDir, "logs");
39
+ this.settingsFile = join(this.happyDir, "settings.json");
40
+ this.privateKeyFile = join(this.happyDir, "access.key");
69
41
  }
70
- return response.data.token;
71
42
  }
72
- function generateAppUrl(secret) {
73
- const secretBase64Url = encodeBase64Url(secret);
74
- return `handy://${secretBase64Url}`;
43
+ let configuration = void 0;
44
+ function initializeConfiguration(location) {
45
+ configuration = new Configuration(location);
75
46
  }
76
47
 
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
48
  async function getSessionLogPath() {
120
- if (!existsSync(logsDir)) {
121
- await mkdir(logsDir, { recursive: true });
49
+ if (!existsSync(configuration.logsDir)) {
50
+ await mkdir(configuration.logsDir, { recursive: true });
122
51
  }
123
52
  const now = /* @__PURE__ */ new Date();
124
53
  const timestamp = now.toLocaleString("sv-SE", {
@@ -130,15 +59,26 @@ async function getSessionLogPath() {
130
59
  minute: "2-digit",
131
60
  second: "2-digit"
132
61
  }).replace(/[: ]/g, "-").replace(/,/g, "");
133
- return join(logsDir, `${timestamp}.log`);
62
+ return join(configuration.logsDir, `${timestamp}.log`);
134
63
  }
135
-
136
64
  class Logger {
137
65
  constructor(logFilePathPromise = getSessionLogPath()) {
138
66
  this.logFilePathPromise = logFilePathPromise;
139
67
  }
68
+ // Use local timezone for simplicity of locating the logs,
69
+ // in practice you will not need absolute timestamps
70
+ localTimezoneTimestamp() {
71
+ return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", {
72
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
73
+ hour12: false,
74
+ hour: "2-digit",
75
+ minute: "2-digit",
76
+ second: "2-digit",
77
+ fractionalSecondDigits: 3
78
+ });
79
+ }
140
80
  debug(message, ...args) {
141
- this.logToFile(`[${(/* @__PURE__ */ new Date()).toISOString()}]`, message, ...args);
81
+ this.logToFile(`[${this.localTimezoneTimestamp()}]`, message, ...args);
142
82
  }
143
83
  debugLargeJson(message, object, maxStringLength = 100, maxArrayLength = 10) {
144
84
  if (!process.env.DEBUG) {
@@ -158,6 +98,9 @@ class Logger {
158
98
  if (obj && typeof obj === "object") {
159
99
  const result = {};
160
100
  for (const [key, value] of Object.entries(obj)) {
101
+ if (key === "usage") {
102
+ continue;
103
+ }
161
104
  result[key] = truncateStrings(value);
162
105
  }
163
106
  return result;
@@ -166,10 +109,11 @@ class Logger {
166
109
  };
167
110
  const truncatedObject = truncateStrings(object);
168
111
  const json = JSON.stringify(truncatedObject, null, 2);
169
- this.logToFile(`[${(/* @__PURE__ */ new Date()).toISOString()}]`, message, "\n", json);
112
+ this.logToFile(`[${this.localTimezoneTimestamp()}]`, message, "\n", json);
170
113
  }
171
114
  info(message, ...args) {
172
115
  this.logToConsole("info", "", message, ...args);
116
+ this.debug(message, args);
173
117
  }
174
118
  logToConsole(level, prefix, message, ...args) {
175
119
  switch (level) {
@@ -202,7 +146,14 @@ class Logger {
202
146
  ).join(" ")}
203
147
  `;
204
148
  this.logFilePathPromise.then((logFilePath) => {
205
- appendFileSync(logFilePath, logLine);
149
+ try {
150
+ appendFileSync(logFilePath, logLine);
151
+ } catch (appendError) {
152
+ if (process.env.DEBUG) {
153
+ console.error("Failed to append to log file:", appendError);
154
+ throw appendError;
155
+ }
156
+ }
206
157
  }).catch((error) => {
207
158
  if (process.env.DEBUG) {
208
159
  console.log("This message only visible in DEBUG mode, not in production");
@@ -212,87 +163,123 @@ class Logger {
212
163
  });
213
164
  }
214
165
  }
215
- const logger = new Logger();
166
+ let logger;
167
+ function initLoggerWithGlobalConfiguration() {
168
+ logger = new Logger();
169
+ }
216
170
 
217
- const SessionMessageContentSchema = z.object({
218
- c: z.string(),
171
+ const SessionMessageContentSchema = z$1.object({
172
+ c: z$1.string(),
219
173
  // Base64 encoded encrypted content
220
- t: z.literal("encrypted")
174
+ t: z$1.literal("encrypted")
221
175
  });
222
- const UpdateBodySchema = z.object({
223
- message: z.object({
224
- id: z.string(),
225
- seq: z.number(),
176
+ const UpdateBodySchema = z$1.object({
177
+ message: z$1.object({
178
+ id: z$1.string(),
179
+ seq: z$1.number(),
226
180
  content: SessionMessageContentSchema
227
181
  }),
228
- sid: z.string(),
182
+ sid: z$1.string(),
229
183
  // Session ID
230
- t: z.literal("new-message")
184
+ t: z$1.literal("new-message")
231
185
  });
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()
186
+ const UpdateSessionBodySchema = z$1.object({
187
+ t: z$1.literal("update-session"),
188
+ sid: z$1.string(),
189
+ metadata: z$1.object({
190
+ version: z$1.number(),
191
+ metadata: z$1.string()
238
192
  }).nullish(),
239
- agentState: z.object({
240
- version: z.number(),
241
- agentState: z.string()
193
+ agentState: z$1.object({
194
+ version: z$1.number(),
195
+ agentState: z$1.string()
242
196
  }).nullish()
243
197
  });
244
- z.object({
245
- id: z.string(),
246
- seq: z.number(),
247
- body: z.union([UpdateBodySchema, UpdateSessionBodySchema]),
248
- createdAt: z.number()
198
+ z$1.object({
199
+ id: z$1.string(),
200
+ seq: z$1.number(),
201
+ body: z$1.union([UpdateBodySchema, UpdateSessionBodySchema]),
202
+ createdAt: z$1.number()
249
203
  });
250
- z.object({
251
- createdAt: z.number(),
252
- id: z.string(),
253
- seq: z.number(),
254
- updatedAt: z.number(),
255
- metadata: z.any(),
256
- metadataVersion: z.number(),
257
- agentState: z.any().nullable(),
258
- agentStateVersion: z.number()
204
+ z$1.object({
205
+ createdAt: z$1.number(),
206
+ id: z$1.string(),
207
+ seq: z$1.number(),
208
+ updatedAt: z$1.number(),
209
+ metadata: z$1.any(),
210
+ metadataVersion: z$1.number(),
211
+ agentState: z$1.any().nullable(),
212
+ agentStateVersion: z$1.number()
259
213
  });
260
- z.object({
214
+ z$1.object({
261
215
  content: SessionMessageContentSchema,
262
- createdAt: z.number(),
263
- id: z.string(),
264
- seq: z.number(),
265
- updatedAt: z.number()
216
+ createdAt: z$1.number(),
217
+ id: z$1.string(),
218
+ seq: z$1.number(),
219
+ updatedAt: z$1.number()
266
220
  });
267
- z.object({
268
- session: z.object({
269
- id: z.string(),
270
- tag: z.string(),
271
- seq: z.number(),
272
- createdAt: z.number(),
273
- updatedAt: z.number(),
274
- metadata: z.string(),
275
- metadataVersion: z.number(),
276
- agentState: z.string().nullable(),
277
- agentStateVersion: z.number()
221
+ z$1.object({
222
+ session: z$1.object({
223
+ id: z$1.string(),
224
+ tag: z$1.string(),
225
+ seq: z$1.number(),
226
+ createdAt: z$1.number(),
227
+ updatedAt: z$1.number(),
228
+ metadata: z$1.string(),
229
+ metadataVersion: z$1.number(),
230
+ agentState: z$1.string().nullable(),
231
+ agentStateVersion: z$1.number()
278
232
  })
279
233
  });
280
- const UserMessageSchema = z.object({
281
- role: z.literal("user"),
282
- content: z.object({
283
- type: z.literal("text"),
284
- text: z.string()
234
+ const UserMessageSchema$1 = z$1.object({
235
+ role: z$1.literal("user"),
236
+ content: z$1.object({
237
+ type: z$1.literal("text"),
238
+ text: z$1.string()
285
239
  }),
286
- localKey: z.string().optional(),
240
+ localKey: z$1.string().optional(),
287
241
  // Mobile messages include this
288
- sentFrom: z.enum(["mobile", "cli"]).optional()
242
+ sentFrom: z$1.enum(["mobile", "cli"]).optional()
289
243
  // Source identifier
290
244
  });
291
- const AgentMessageSchema = z.object({
292
- role: z.literal("agent"),
293
- content: z.any()
245
+ const AgentMessageSchema = z$1.object({
246
+ role: z$1.literal("agent"),
247
+ content: z$1.object({
248
+ type: z$1.literal("output"),
249
+ data: z$1.any()
250
+ })
294
251
  });
295
- z.union([UserMessageSchema, AgentMessageSchema]);
252
+ z$1.union([UserMessageSchema$1, AgentMessageSchema]);
253
+
254
+ function encodeBase64(buffer) {
255
+ return Buffer.from(buffer).toString("base64");
256
+ }
257
+ function encodeBase64Url(buffer) {
258
+ return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
259
+ }
260
+ function decodeBase64(base64) {
261
+ return new Uint8Array(Buffer.from(base64, "base64"));
262
+ }
263
+ function getRandomBytes(size) {
264
+ return new Uint8Array(randomBytes(size));
265
+ }
266
+ function encrypt(data, secret) {
267
+ const nonce = getRandomBytes(tweetnacl.secretbox.nonceLength);
268
+ const encrypted = tweetnacl.secretbox(new TextEncoder().encode(JSON.stringify(data)), nonce, secret);
269
+ const result = new Uint8Array(nonce.length + encrypted.length);
270
+ result.set(nonce);
271
+ result.set(encrypted, nonce.length);
272
+ return result;
273
+ }
274
+ function decrypt(data, secret) {
275
+ const nonce = data.slice(0, tweetnacl.secretbox.nonceLength);
276
+ const encrypted = data.slice(tweetnacl.secretbox.nonceLength);
277
+ const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret);
278
+ if (!decrypted) {
279
+ return null;
280
+ }
281
+ return JSON.parse(new TextDecoder().decode(decrypted));
282
+ }
296
283
 
297
284
  async function delay(ms) {
298
285
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -331,8 +318,6 @@ class ApiSessionClient extends EventEmitter {
331
318
  agentState;
332
319
  agentStateVersion;
333
320
  socket;
334
- receivedMessages = /* @__PURE__ */ new Set();
335
- sentLocalKeys = /* @__PURE__ */ new Set();
336
321
  pendingMessages = [];
337
322
  pendingMessageCallback = null;
338
323
  rpcHandlers = /* @__PURE__ */ new Map();
@@ -345,9 +330,11 @@ class ApiSessionClient extends EventEmitter {
345
330
  this.metadataVersion = session.metadataVersion;
346
331
  this.agentState = session.agentState;
347
332
  this.agentStateVersion = session.agentStateVersion;
348
- this.socket = io("https://handy-api.korshakov.org", {
333
+ this.socket = io(configuration.serverUrl, {
349
334
  auth: {
350
- token: this.token
335
+ token: this.token,
336
+ clientType: "session-scoped",
337
+ sessionId: this.sessionId
351
338
  },
352
339
  path: "/v1/updates",
353
340
  reconnection: true,
@@ -359,7 +346,7 @@ class ApiSessionClient extends EventEmitter {
359
346
  autoConnect: false
360
347
  });
361
348
  this.socket.on("connect", () => {
362
- logger.info("Socket connected successfully");
349
+ logger.debug("Socket connected successfully");
363
350
  this.reregisterHandlers();
364
351
  });
365
352
  this.socket.on("rpc-request", async (data, callback) => {
@@ -388,24 +375,18 @@ class ApiSessionClient extends EventEmitter {
388
375
  logger.debug("[API] Socket disconnected:", reason);
389
376
  });
390
377
  this.socket.on("connect_error", (error) => {
391
- logger.debug("[API] Socket connection error:", error.message);
378
+ logger.debug("[API] Socket connection error:", error);
392
379
  });
393
380
  this.socket.on("update", (data) => {
394
381
  if (data.body.t === "new-message" && data.body.message.content.t === "encrypted") {
395
382
  const body = decrypt(decodeBase64(data.body.message.content.c), this.secret);
396
383
  logger.debugLargeJson("[SOCKET] [UPDATE] Received update:", body);
397
- const userResult = UserMessageSchema.safeParse(body);
384
+ const userResult = UserMessageSchema$1.safeParse(body);
398
385
  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)) {
403
- this.receivedMessages.add(data.body.message.id);
404
- if (this.pendingMessageCallback) {
405
- this.pendingMessageCallback(userResult.data);
406
- } else {
407
- this.pendingMessages.push(userResult.data);
408
- }
386
+ if (this.pendingMessageCallback) {
387
+ this.pendingMessageCallback(userResult.data);
388
+ } else {
389
+ this.pendingMessages.push(userResult.data);
409
390
  }
410
391
  } else {
411
392
  this.emit("message", body);
@@ -421,6 +402,9 @@ class ApiSessionClient extends EventEmitter {
421
402
  }
422
403
  }
423
404
  });
405
+ this.socket.on("error", (error) => {
406
+ logger.debug("[API] Socket error:", error);
407
+ });
424
408
  this.socket.connect();
425
409
  }
426
410
  onUserMessage(callback) {
@@ -433,21 +417,27 @@ class ApiSessionClient extends EventEmitter {
433
417
  * Send message to session
434
418
  * @param body - Message body (can be MessageContent or raw content for agent messages)
435
419
  */
436
- sendMessage(body) {
437
- logger.debugLargeJson("[SOCKET] Sending message through socket:", body);
420
+ sendClaudeSessionMessage(body) {
438
421
  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
- }
422
+ if (body.type === "user" && typeof body.message.content === "string") {
423
+ content = {
424
+ role: "user",
425
+ content: {
426
+ type: "text",
427
+ text: body.message.content
428
+ }
429
+ };
445
430
  } else {
446
431
  content = {
447
432
  role: "agent",
448
- content: body
433
+ content: {
434
+ type: "output",
435
+ data: body
436
+ // This wraps the entire Claude message
437
+ }
449
438
  };
450
439
  }
440
+ logger.debugLargeJson("[SOCKET] Sending message through socket:", content);
451
441
  const encrypted = encodeBase64(encrypt(content, this.secret));
452
442
  this.socket.emit("message", {
453
443
  sid: this.sessionId,
@@ -491,27 +481,31 @@ class ApiSessionClient extends EventEmitter {
491
481
  * @param handler - Handler function that returns the updated agent state
492
482
  */
493
483
  updateAgentState(handler) {
484
+ console.log("Updating agent state", this.agentState);
494
485
  backoff(async () => {
495
486
  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 });
487
+ const answer = await this.socket.emitWithAck("update-state", { sid: this.sessionId, expectedVersion: this.agentStateVersion, agentState: updated ? encodeBase64(encrypt(updated, this.secret)) : null });
497
488
  if (answer.result === "success") {
498
489
  this.agentState = answer.agentState ? decrypt(decodeBase64(answer.agentState), this.secret) : null;
499
490
  this.agentStateVersion = answer.version;
491
+ console.log("Agent state updated", this.agentState);
500
492
  } else if (answer.result === "version-mismatch") {
501
493
  if (answer.version > this.agentStateVersion) {
502
494
  this.agentStateVersion = answer.version;
503
495
  this.agentState = answer.agentState ? decrypt(decodeBase64(answer.agentState), this.secret) : null;
504
496
  }
505
497
  throw new Error("Agent state version mismatch");
506
- } else if (answer.result === "error") ;
498
+ } else if (answer.result === "error") {
499
+ console.error("Agent state update error", answer);
500
+ }
507
501
  });
508
502
  }
509
503
  /**
510
- * Add a custom RPC handler for a specific method with encrypted arguments and responses
504
+ * Set a custom RPC handler for a specific method with encrypted arguments and responses
511
505
  * @param method - The method name to handle
512
506
  * @param handler - The handler function to call when the method is invoked
513
507
  */
514
- addHandler(method, handler) {
508
+ setHandler(method, handler) {
515
509
  const prefixedMethod = `${this.sessionId}:${method}`;
516
510
  this.rpcHandlers.set(prefixedMethod, handler);
517
511
  this.socket.emit("rpc-register", { method: prefixedMethod });
@@ -550,12 +544,119 @@ class ApiSessionClient extends EventEmitter {
550
544
  }
551
545
  }
552
546
 
547
+ class PushNotificationClient {
548
+ token;
549
+ baseUrl;
550
+ expo;
551
+ constructor(token, baseUrl = "https://handy-api.korshakov.org") {
552
+ this.token = token;
553
+ this.baseUrl = baseUrl;
554
+ this.expo = new Expo();
555
+ }
556
+ /**
557
+ * Fetch all push tokens for the authenticated user
558
+ */
559
+ async fetchPushTokens() {
560
+ try {
561
+ const response = await axios.get(
562
+ `${this.baseUrl}/v1/push-tokens`,
563
+ {
564
+ headers: {
565
+ "Authorization": `Bearer ${this.token}`,
566
+ "Content-Type": "application/json"
567
+ }
568
+ }
569
+ );
570
+ logger.info(`Fetched ${response.data.tokens.length} push tokens`);
571
+ return response.data.tokens;
572
+ } catch (error) {
573
+ logger.debug("[PUSH] [ERROR] Failed to fetch push tokens:", error);
574
+ throw new Error(`Failed to fetch push tokens: ${error instanceof Error ? error.message : "Unknown error"}`);
575
+ }
576
+ }
577
+ /**
578
+ * Send push notification via Expo Push API with retry
579
+ * @param messages - Array of push messages to send
580
+ */
581
+ async sendPushNotifications(messages) {
582
+ logger.info(`Sending ${messages.length} push notifications`);
583
+ const validMessages = messages.filter((message) => {
584
+ if (Array.isArray(message.to)) {
585
+ return message.to.every((token) => Expo.isExpoPushToken(token));
586
+ }
587
+ return Expo.isExpoPushToken(message.to);
588
+ });
589
+ if (validMessages.length === 0) {
590
+ logger.info("No valid Expo push tokens found");
591
+ return;
592
+ }
593
+ const chunks = this.expo.chunkPushNotifications(validMessages);
594
+ for (const chunk of chunks) {
595
+ const startTime = Date.now();
596
+ const timeout = 3e5;
597
+ let attempt = 0;
598
+ while (true) {
599
+ try {
600
+ const ticketChunk = await this.expo.sendPushNotificationsAsync(chunk);
601
+ const errors = ticketChunk.filter((ticket) => ticket.status === "error");
602
+ if (errors.length > 0) {
603
+ logger.debug("[PUSH] Some notifications failed:", errors);
604
+ }
605
+ if (errors.length === ticketChunk.length) {
606
+ throw new Error("All push notifications in chunk failed");
607
+ }
608
+ break;
609
+ } catch (error) {
610
+ const elapsed = Date.now() - startTime;
611
+ if (elapsed >= timeout) {
612
+ logger.debug("[PUSH] Timeout reached after 5 minutes, giving up on chunk");
613
+ break;
614
+ }
615
+ attempt++;
616
+ const delay = Math.min(1e3 * Math.pow(2, attempt), 3e4);
617
+ const remainingTime = timeout - elapsed;
618
+ const waitTime = Math.min(delay, remainingTime);
619
+ if (waitTime > 0) {
620
+ logger.debug(`[PUSH] Retrying in ${waitTime}ms (attempt ${attempt})`);
621
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
622
+ }
623
+ }
624
+ }
625
+ }
626
+ logger.info(`Push notifications sent successfully`);
627
+ }
628
+ /**
629
+ * Send a push notification to all registered devices for the user
630
+ * @param title - Notification title
631
+ * @param body - Notification body
632
+ * @param data - Additional data to send with the notification
633
+ */
634
+ async sendToAllDevices(title, body, data) {
635
+ const tokens = await this.fetchPushTokens();
636
+ if (tokens.length === 0) {
637
+ logger.info("No push tokens found for user");
638
+ return;
639
+ }
640
+ const messages = tokens.map((token) => ({
641
+ to: token.token,
642
+ title,
643
+ body,
644
+ data,
645
+ sound: "default",
646
+ priority: "high"
647
+ }));
648
+ await this.sendPushNotifications(messages);
649
+ }
650
+ }
651
+
553
652
  class ApiClient {
554
653
  token;
555
654
  secret;
655
+ pushClient;
556
656
  constructor(token, secret) {
557
657
  this.token = token;
558
658
  this.secret = secret;
659
+ this.pushClient = new PushNotificationClient(token);
559
660
  }
560
661
  /**
561
662
  * Create a new session or load existing one with the given tag
@@ -563,7 +664,7 @@ class ApiClient {
563
664
  async getOrCreateSession(opts) {
564
665
  try {
565
666
  const response = await axios.post(
566
- `https://handy-api.korshakov.org/v1/sessions`,
667
+ `${configuration.serverUrl}/v1/sessions`,
567
668
  {
568
669
  tag: opts.tag,
569
670
  metadata: encodeBase64(encrypt(opts.metadata, this.secret)),
@@ -576,7 +677,7 @@ class ApiClient {
576
677
  }
577
678
  }
578
679
  );
579
- logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`);
680
+ logger.debug(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`);
580
681
  let raw = response.data.session;
581
682
  let session = {
582
683
  id: raw.id,
@@ -602,857 +703,1220 @@ class ApiClient {
602
703
  session(session) {
603
704
  return new ApiSessionClient(this.token, this.secret, session);
604
705
  }
706
+ /**
707
+ * Get push notification client
708
+ * @returns Push notification client
709
+ */
710
+ push() {
711
+ return this.pushClient;
712
+ }
605
713
  }
606
714
 
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);
715
+ function formatClaudeMessage(message, onAssistantResult) {
716
+ logger.debugLargeJson("[CLAUDE] Message from non interactive & remote mode:", message);
717
+ switch (message.type) {
718
+ case "system": {
719
+ const sysMsg = message;
720
+ if (sysMsg.subtype === "init") {
721
+ console.log(chalk.gray("\u2500".repeat(60)));
722
+ console.log(chalk.blue.bold("\u{1F680} Session initialized:"), chalk.cyan(sysMsg.session_id));
723
+ console.log(chalk.gray(` Model: ${sysMsg.model}`));
724
+ console.log(chalk.gray(` CWD: ${sysMsg.cwd}`));
725
+ if (sysMsg.tools && sysMsg.tools.length > 0) {
726
+ console.log(chalk.gray(` Tools: ${sysMsg.tools.join(", ")}`));
727
+ }
728
+ console.log(chalk.gray("\u2500".repeat(60)));
729
+ }
730
+ break;
614
731
  }
615
- });
616
- logger.info(`\u{1F4CB} Or use this URL: ${url}`);
617
- }
618
-
619
- async function* claude(options) {
620
- try {
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
636
- });
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 };
732
+ case "user": {
733
+ const userMsg = message;
734
+ if (userMsg.message && typeof userMsg.message === "object" && "content" in userMsg.message) {
735
+ const content = userMsg.message.content;
736
+ if (typeof content === "string") {
737
+ console.log(chalk.magenta.bold("\n\u{1F464} User:"), content);
738
+ } else if (Array.isArray(content)) {
739
+ for (const block of content) {
740
+ if (block.type === "text") {
741
+ console.log(chalk.magenta.bold("\n\u{1F464} User:"), block.text);
742
+ } else if (block.type === "tool_result") {
743
+ console.log(chalk.green.bold("\n\u2705 Tool Result:"), chalk.gray(`(Tool ID: ${block.tool_use_id})`));
744
+ if (block.content) {
745
+ const outputStr = typeof block.content === "string" ? block.content : JSON.stringify(block.content, null, 2);
746
+ const maxLength = 200;
747
+ if (outputStr.length > maxLength) {
748
+ console.log(outputStr.substring(0, maxLength) + chalk.gray("\n... (truncated)"));
749
+ } else {
750
+ console.log(outputStr);
751
+ }
752
+ }
753
+ }
643
754
  }
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 };
755
+ } else {
756
+ console.log(chalk.magenta.bold("\n\u{1F464} User:"), JSON.stringify(content, null, 2));
757
+ }
758
+ }
759
+ break;
760
+ }
761
+ case "assistant": {
762
+ const assistantMsg = message;
763
+ if (assistantMsg.message && assistantMsg.message.content) {
764
+ console.log(chalk.cyan.bold("\n\u{1F916} Assistant:"));
765
+ for (const block of assistantMsg.message.content) {
766
+ if (block.type === "text") {
767
+ console.log(block.text);
768
+ } else if (block.type === "tool_use") {
769
+ console.log(chalk.yellow.bold(`
770
+ \u{1F527} Tool: ${block.name}`));
771
+ if (block.input) {
772
+ const inputStr = JSON.stringify(block.input, null, 2);
773
+ const maxLength = 500;
774
+ if (inputStr.length > maxLength) {
775
+ console.log(chalk.gray("Input:"), inputStr.substring(0, maxLength) + chalk.gray("\n... (truncated)"));
776
+ } else {
777
+ console.log(chalk.gray("Input:"), inputStr);
778
+ }
779
+ }
657
780
  }
658
- break;
659
- default:
660
- yield { type: "json", data: message };
781
+ }
782
+ }
783
+ break;
784
+ }
785
+ case "result": {
786
+ const resultMsg = message;
787
+ if (resultMsg.subtype === "success") {
788
+ if ("result" in resultMsg && resultMsg.result) {
789
+ console.log(chalk.green.bold("\n\u2728 Summary:"));
790
+ console.log(resultMsg.result);
791
+ }
792
+ if (resultMsg.usage) {
793
+ console.log(chalk.gray("\n\u{1F4CA} Session Stats:"));
794
+ console.log(chalk.gray(` \u2022 Turns: ${resultMsg.num_turns}`));
795
+ console.log(chalk.gray(` \u2022 Input tokens: ${resultMsg.usage.input_tokens}`));
796
+ console.log(chalk.gray(` \u2022 Output tokens: ${resultMsg.usage.output_tokens}`));
797
+ if (resultMsg.usage.cache_read_input_tokens) {
798
+ console.log(chalk.gray(` \u2022 Cache read tokens: ${resultMsg.usage.cache_read_input_tokens}`));
799
+ }
800
+ if (resultMsg.usage.cache_creation_input_tokens) {
801
+ console.log(chalk.gray(` \u2022 Cache creation tokens: ${resultMsg.usage.cache_creation_input_tokens}`));
802
+ }
803
+ console.log(chalk.gray(` \u2022 Cost: $${resultMsg.total_cost_usd.toFixed(4)}`));
804
+ console.log(chalk.gray(` \u2022 Duration: ${resultMsg.duration_ms}ms`));
805
+ console.log(chalk.gray("\n\u{1F440} Back already?"));
806
+ console.log(chalk.green("\u{1F449} Press any key to continue your session in `claude`"));
807
+ if (onAssistantResult) {
808
+ Promise.resolve(onAssistantResult(resultMsg)).catch((err) => {
809
+ logger.debug("Error in onAssistantResult callback:", err);
810
+ });
811
+ }
812
+ }
813
+ } else if (resultMsg.subtype === "error_max_turns") {
814
+ console.log(chalk.red.bold("\n\u274C Error: Maximum turns reached"));
815
+ console.log(chalk.gray(`Completed ${resultMsg.num_turns} turns`));
816
+ } else if (resultMsg.subtype === "error_during_execution") {
817
+ console.log(chalk.red.bold("\n\u274C Error during execution"));
818
+ console.log(chalk.gray(`Completed ${resultMsg.num_turns} turns before error`));
819
+ logger.debugLargeJson("[RESULT] Error during execution", resultMsg);
820
+ }
821
+ break;
822
+ }
823
+ default: {
824
+ const exhaustiveCheck = message;
825
+ if (process.env.DEBUG) {
826
+ console.log(chalk.gray(`[Unknown message type]`), exhaustiveCheck);
661
827
  }
662
828
  }
663
- } catch (error) {
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 };
667
829
  }
668
830
  }
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];
831
+ function printDivider() {
832
+ console.log(chalk.gray("\u2550".repeat(60)));
677
833
  }
678
834
 
679
- function claudePath() {
680
- if (fs.existsSync(process.env.HOME + "/.claude/local/claude")) {
681
- return process.env.HOME + "/.claude/local/claude";
682
- } else {
683
- return "claude";
835
+ function claudeCheckSession(sessionId, path) {
836
+ const projectName = resolve(path).replace(/\//g, "-");
837
+ const projectDir = join(homedir(), ".claude", "projects", projectName);
838
+ const sessionFile = join(projectDir, `${sessionId}.jsonl`);
839
+ const sessionExists = existsSync(sessionFile);
840
+ if (!sessionExists) {
841
+ logger.debug(`[claudeCheckSession] Path ${sessionFile} does not exist`);
842
+ return false;
684
843
  }
844
+ const sessionData = readFileSync(sessionFile, "utf-8").split("\n");
845
+ const hasGoodMessage = !!sessionData.find((v) => {
846
+ try {
847
+ return typeof JSON.parse(v).uuid === "string";
848
+ } catch (e) {
849
+ return false;
850
+ }
851
+ });
852
+ return hasGoodMessage;
685
853
  }
686
854
 
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);
855
+ async function claudeRemote(opts) {
856
+ let startFrom = opts.sessionId;
857
+ if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) {
858
+ startFrom = null;
697
859
  }
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,
703
- cwd: options.workingDirectory,
704
- env: process.env
860
+ const abortController = new AbortController();
861
+ const sdkOptions = {
862
+ cwd: opts.path,
863
+ resume: startFrom ?? void 0,
864
+ mcpServers: opts.mcpServers,
865
+ permissionPromptToolName: opts.permissionPromptToolName,
866
+ executable: "node",
867
+ abortController
868
+ };
869
+ let aborted = false;
870
+ let response;
871
+ opts.abort.addEventListener("abort", () => {
872
+ if (!aborted) {
873
+ aborted = true;
874
+ if (response) {
875
+ (async () => {
876
+ try {
877
+ const r = await response.interrupt();
878
+ } catch (e) {
879
+ }
880
+ abortController.abort();
881
+ })();
882
+ } else {
883
+ abortController.abort();
884
+ }
885
+ }
705
886
  });
706
- logger.debug("[PTY] PTY process created, pid:", ptyProcess.pid);
707
- ptyProcess.onData((data) => {
708
- process.stdout.write(data);
887
+ logger.debug(`[claudeRemote] Starting query with messages`);
888
+ response = query({
889
+ prompt: opts.messages,
890
+ abortController,
891
+ options: sdkOptions
709
892
  });
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);
893
+ if (opts.interruptController) {
894
+ opts.interruptController.register(async () => {
895
+ logger.debug("[claudeRemote] Interrupting Claude via SDK");
896
+ await response.interrupt();
721
897
  });
722
- });
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);
736
- }
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
898
  }
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
899
+ printDivider();
900
+ try {
901
+ logger.debug(`[claudeRemote] Starting to iterate over response`);
902
+ for await (const message of response) {
903
+ logger.debug(`[claudeRemote] Received message from SDK: ${message.type}`);
904
+ formatClaudeMessage(message, opts.onAssistantResult);
905
+ if (message.type === "system" && message.subtype === "init") {
906
+ const projectName = resolve(opts.path).replace(/\//g, "-");
907
+ const projectDir = join(homedir(), ".claude", "projects", projectName);
908
+ mkdirSync(projectDir, { recursive: true });
909
+ const watcher = watch(projectDir).on("change", (_, filename) => {
910
+ if (filename === `${message.session_id}.jsonl`) {
911
+ opts.onSessionFound(message.session_id);
912
+ watcher.close();
913
+ }
914
+ });
915
+ }
764
916
  }
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
917
+ logger.debug(`[claudeRemote] Finished iterating over response`);
918
+ } catch (e) {
919
+ if (abortController.signal.aborted) {
920
+ logger.debug(`[claudeRemote] Aborted`);
780
921
  }
781
- };
922
+ if (e instanceof AbortError) {
923
+ logger.debug(`[claudeRemote] Aborted`);
924
+ } else {
925
+ throw e;
926
+ }
927
+ } finally {
928
+ if (opts.interruptController) {
929
+ opts.interruptController.unregister();
930
+ }
931
+ }
932
+ printDivider();
933
+ logger.debug(`[claudeRemote] Function completed`);
782
934
  }
783
935
 
784
- async function* watchMostRecentSession(workingDirectory, abortController) {
785
- const projectName = resolve(workingDirectory).replace(/\//g, "-");
936
+ const __dirname = dirname(fileURLToPath(import.meta.url));
937
+ async function claudeLocal(opts) {
938
+ const projectName = resolve(opts.path).replace(/\//g, "-");
786
939
  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 });
792
- }
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");
808
- try {
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
- }
940
+ mkdirSync(projectDir, { recursive: true });
941
+ const watcher = watch(projectDir);
942
+ let resolvedSessionId = null;
943
+ const detectedIdsRandomUUID = /* @__PURE__ */ new Set();
944
+ const detectedIdsFileSystem = /* @__PURE__ */ new Set();
945
+ watcher.on("change", (event, filename) => {
946
+ if (typeof filename === "string" && filename.toLowerCase().endsWith(".jsonl")) {
947
+ logger.debug("change", event, filename);
948
+ const sessionId = filename.replace(".jsonl", "");
949
+ if (detectedIdsFileSystem.has(sessionId)) {
950
+ return;
951
+ }
952
+ detectedIdsFileSystem.add(sessionId);
953
+ if (resolvedSessionId) {
954
+ return;
822
955
  }
823
- } catch (err) {
824
- if (err.name !== "AbortError") {
825
- logger.debug("[ERROR] Directory watcher unexpected error:", err);
956
+ if (detectedIdsRandomUUID.has(sessionId)) {
957
+ resolvedSessionId = sessionId;
958
+ opts.onSessionFound(sessionId);
826
959
  }
827
- logger.debug("Directory watcher aborted");
828
960
  }
829
- return;
830
- })();
831
- if (!newSessionFilePath) {
832
- logger.debug("No new session file path returned, exiting watcher");
833
- return;
961
+ });
962
+ let startFrom = opts.sessionId;
963
+ if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) {
964
+ startFrom = null;
834
965
  }
835
- logger.debug(`Got session file path: ${newSessionFilePath}, now starting file watcher`);
836
- yield* watchSessionFile(newSessionFilePath, abortController);
837
- }
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
966
  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");
967
+ process.stdin.pause();
968
+ await new Promise((r, reject) => {
969
+ const args = [];
970
+ if (startFrom) {
971
+ args.push("--resume", startFrom);
972
+ }
973
+ const claudeCliPath = process.env.HAPPY_CLAUDE_CLI_PATH || resolve(join(__dirname, "..", "scripts", "claudeInteractiveLaunch.cjs"));
974
+ const child = spawn("node", [claudeCliPath, ...args], {
975
+ stdio: ["inherit", "inherit", "inherit", "pipe"],
976
+ signal: opts.abort,
977
+ cwd: opts.path
978
+ });
979
+ if (child.stdio[3]) {
980
+ const rl = createInterface({
981
+ input: child.stdio[3],
982
+ crlfDelay: Infinity
983
+ });
984
+ rl.on("line", (line) => {
985
+ const sessionMatch = line.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i);
986
+ if (sessionMatch) {
987
+ detectedIdsRandomUUID.add(sessionMatch[0]);
988
+ if (resolvedSessionId) {
989
+ return;
990
+ }
991
+ if (detectedIdsFileSystem.has(sessionMatch[0])) {
992
+ resolvedSessionId = sessionMatch[0];
993
+ opts.onSessionFound(sessionMatch[0]);
864
994
  }
865
- } catch {
866
- logger.debug("Skipping invalid JSON line");
867
995
  }
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();
996
+ });
997
+ rl.on("error", (err) => {
998
+ console.error("Error reading from fd 3:", err);
999
+ });
877
1000
  }
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");
885
- }
1001
+ child.on("error", (error) => {
1002
+ });
1003
+ child.on("exit", (code, signal) => {
1004
+ if (signal === "SIGTERM" && opts.abort.aborted) {
1005
+ r();
1006
+ } else if (signal) {
1007
+ reject(new Error(`Process terminated with signal: ${signal}`));
1008
+ } else {
1009
+ r();
1010
+ }
1011
+ });
1012
+ });
1013
+ } finally {
1014
+ watcher.close();
1015
+ process.stdin.resume();
1016
+ }
1017
+ return resolvedSessionId;
886
1018
  }
887
1019
 
888
- function startClaudeLoop(opts, session) {
889
- let mode = "interactive";
890
- let exiting = false;
891
- let currentClaudeSessionId;
892
- let interactiveProcess = null;
893
- let watcherAbortController = null;
894
- const messageQueue = [];
895
- let messageResolve = null;
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}`);
1020
+ class MessageQueue {
1021
+ queue = [];
1022
+ waiters = [];
1023
+ closed = false;
1024
+ closePromise;
1025
+ closeResolve;
1026
+ constructor() {
1027
+ this.closePromise = new Promise((resolve) => {
1028
+ this.closeResolve = resolve;
1029
+ });
1030
+ }
1031
+ /**
1032
+ * Push a message to the queue
1033
+ */
1034
+ push(message) {
1035
+ if (this.closed) {
1036
+ throw new Error("Cannot push to closed queue");
1037
+ }
1038
+ logger.debug(`[MessageQueue] push() called. Waiters: ${this.waiters.length}, Queue size before: ${this.queue.length}`);
1039
+ const waiter = this.waiters.shift();
1040
+ if (waiter) {
1041
+ logger.debug(`[MessageQueue] Found waiter! Delivering message directly: "${message}"`);
1042
+ waiter({
1043
+ type: "user",
1044
+ message: {
1045
+ role: "user",
1046
+ content: message
1047
+ },
1048
+ parent_tool_use_id: null,
1049
+ session_id: ""
1050
+ });
1051
+ } else {
1052
+ logger.debug(`[MessageQueue] No waiter found. Adding to queue: "${message}"`);
1053
+ this.queue.push({
1054
+ type: "user",
1055
+ message: {
1056
+ role: "user",
1057
+ content: message
1058
+ },
1059
+ parent_tool_use_id: null,
1060
+ session_id: ""
943
1061
  });
944
1062
  }
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");
1063
+ logger.debug(`[MessageQueue] push() completed. Waiters: ${this.waiters.length}, Queue size after: ${this.queue.length}`);
1064
+ }
1065
+ /**
1066
+ * Close the queue - no more messages can be pushed
1067
+ */
1068
+ close() {
1069
+ logger.debug(`[MessageQueue] close() called. Waiters: ${this.waiters.length}`);
1070
+ this.closed = true;
1071
+ this.closeResolve?.();
1072
+ }
1073
+ /**
1074
+ * Check if the queue is closed
1075
+ */
1076
+ isClosed() {
1077
+ return this.closed;
1078
+ }
1079
+ /**
1080
+ * Get the current queue size
1081
+ */
1082
+ size() {
1083
+ return this.queue.length;
1084
+ }
1085
+ /**
1086
+ * Async iterator implementation
1087
+ */
1088
+ async *[Symbol.asyncIterator]() {
1089
+ logger.debug(`[MessageQueue] Iterator started`);
1090
+ while (true) {
1091
+ const message = this.queue.shift();
1092
+ if (message !== void 0) {
1093
+ logger.debug(`[MessageQueue] Iterator yielding queued message`);
1094
+ yield message;
1095
+ continue;
969
1096
  }
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");
1097
+ if (this.closed) {
1098
+ logger.debug(`[MessageQueue] Iterator ending - queue closed`);
1099
+ return;
1100
+ }
1101
+ logger.debug(`[MessageQueue] Iterator waiting for next message...`);
1102
+ const nextMessage = await this.waitForNext();
1103
+ if (nextMessage === void 0) {
1104
+ logger.debug(`[MessageQueue] Iterator ending - no more messages`);
1105
+ return;
1106
+ }
1107
+ logger.debug(`[MessageQueue] Iterator yielding waited message`);
1108
+ yield nextMessage;
1109
+ }
1110
+ }
1111
+ /**
1112
+ * Wait for the next message or queue closure
1113
+ */
1114
+ waitForNext() {
1115
+ return new Promise((resolve) => {
1116
+ if (this.closed) {
1117
+ logger.debug(`[MessageQueue] waitForNext() called but queue is closed`);
1118
+ resolve(void 0);
1119
+ return;
981
1120
  }
1121
+ const waiter = (value) => resolve(value);
1122
+ this.waiters.push(waiter);
1123
+ logger.debug(`[MessageQueue] waitForNext() adding waiter. Total waiters: ${this.waiters.length}`);
1124
+ this.closePromise?.then(() => {
1125
+ const index = this.waiters.indexOf(waiter);
1126
+ if (index !== -1) {
1127
+ this.waiters.splice(index, 1);
1128
+ logger.debug(`[MessageQueue] waitForNext() waiter removed due to close. Remaining waiters: ${this.waiters.length}`);
1129
+ resolve(void 0);
1130
+ }
1131
+ });
982
1132
  });
1133
+ }
1134
+ }
1135
+
1136
+ class InvalidateSync {
1137
+ _invalidated = false;
1138
+ _invalidatedDouble = false;
1139
+ _stopped = false;
1140
+ _command;
1141
+ _pendings = [];
1142
+ constructor(command) {
1143
+ this._command = command;
1144
+ }
1145
+ invalidate() {
1146
+ if (this._stopped) {
1147
+ return;
1148
+ }
1149
+ if (!this._invalidated) {
1150
+ this._invalidated = true;
1151
+ this._invalidatedDouble = false;
1152
+ this._doSync();
1153
+ } else {
1154
+ if (!this._invalidatedDouble) {
1155
+ this._invalidatedDouble = true;
1156
+ }
1157
+ }
1158
+ }
1159
+ async invalidateAndAwait() {
1160
+ if (this._stopped) {
1161
+ return;
1162
+ }
1163
+ await new Promise((resolve) => {
1164
+ this._pendings.push(resolve);
1165
+ this.invalidate();
1166
+ });
1167
+ }
1168
+ stop() {
1169
+ if (this._stopped) {
1170
+ return;
1171
+ }
1172
+ this._notifyPendings();
1173
+ this._stopped = true;
1174
+ }
1175
+ _notifyPendings = () => {
1176
+ for (let pending of this._pendings) {
1177
+ pending();
1178
+ }
1179
+ this._pendings = [];
983
1180
  };
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;
1181
+ _doSync = async () => {
1182
+ await backoff(async () => {
1183
+ if (this._stopped) {
1184
+ return;
1185
+ }
1186
+ await this._command();
1187
+ });
1188
+ if (this._stopped) {
1189
+ this._notifyPendings();
1190
+ return;
1191
+ }
1192
+ if (this._invalidatedDouble) {
1193
+ this._invalidatedDouble = false;
1194
+ this._doSync();
999
1195
  } else {
1000
- logger.debug("[LOOP] No interactive process to kill");
1196
+ this._invalidated = false;
1197
+ this._notifyPendings();
1001
1198
  }
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
1199
  };
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;
1200
+ }
1201
+
1202
+ const UsageSchema = z$1.object({
1203
+ input_tokens: z$1.number().int().nonnegative(),
1204
+ cache_creation_input_tokens: z$1.number().int().nonnegative().optional(),
1205
+ cache_read_input_tokens: z$1.number().int().nonnegative().optional(),
1206
+ output_tokens: z$1.number().int().nonnegative(),
1207
+ service_tier: z$1.string().optional()
1208
+ });
1209
+ const TextContentSchema = z$1.object({
1210
+ type: z$1.literal("text"),
1211
+ text: z$1.string()
1212
+ });
1213
+ const ThinkingContentSchema = z$1.object({
1214
+ type: z$1.literal("thinking"),
1215
+ thinking: z$1.string(),
1216
+ signature: z$1.string()
1217
+ });
1218
+ const ToolUseContentSchema = z$1.object({
1219
+ type: z$1.literal("tool_use"),
1220
+ id: z$1.string(),
1221
+ name: z$1.string(),
1222
+ input: z$1.unknown()
1223
+ // Tool-specific input parameters
1224
+ });
1225
+ const ToolResultContentSchema = z$1.object({
1226
+ tool_use_id: z$1.string(),
1227
+ type: z$1.literal("tool_result"),
1228
+ content: z$1.union([
1229
+ z$1.string(),
1230
+ // For simple string responses
1231
+ z$1.array(TextContentSchema)
1232
+ // For structured content blocks (typically text)
1233
+ ]),
1234
+ is_error: z$1.boolean().optional()
1235
+ });
1236
+ const ContentSchema = z$1.union([
1237
+ TextContentSchema,
1238
+ ThinkingContentSchema,
1239
+ ToolUseContentSchema,
1240
+ ToolResultContentSchema
1241
+ ]);
1242
+ const UserMessageSchema = z$1.object({
1243
+ role: z$1.literal("user"),
1244
+ content: z$1.union([
1245
+ z$1.string(),
1246
+ // Simple string content
1247
+ z$1.array(z$1.union([ToolResultContentSchema, TextContentSchema]))
1248
+ ])
1249
+ });
1250
+ const AssistantMessageSchema = z$1.object({
1251
+ id: z$1.string(),
1252
+ type: z$1.literal("message"),
1253
+ role: z$1.literal("assistant"),
1254
+ model: z$1.string(),
1255
+ content: z$1.array(ContentSchema),
1256
+ stop_reason: z$1.string().nullable(),
1257
+ stop_sequence: z$1.string().nullable(),
1258
+ usage: UsageSchema
1259
+ });
1260
+ const BaseEntrySchema = z$1.object({
1261
+ cwd: z$1.string(),
1262
+ sessionId: z$1.string(),
1263
+ version: z$1.string(),
1264
+ uuid: z$1.string(),
1265
+ timestamp: z$1.string().datetime(),
1266
+ parent_tool_use_id: z$1.string().nullable().optional()
1267
+ });
1268
+ const SummaryEntrySchema = z$1.object({
1269
+ type: z$1.literal("summary"),
1270
+ summary: z$1.string(),
1271
+ leafUuid: z$1.string()
1272
+ });
1273
+ const UserEntrySchema = BaseEntrySchema.extend({
1274
+ type: z$1.literal("user"),
1275
+ message: UserMessageSchema,
1276
+ isMeta: z$1.boolean().optional(),
1277
+ toolUseResult: z$1.unknown().optional()
1278
+ // Present when user responds to tool use
1279
+ });
1280
+ const AssistantEntrySchema = BaseEntrySchema.extend({
1281
+ type: z$1.literal("assistant"),
1282
+ message: AssistantMessageSchema,
1283
+ requestId: z$1.string().optional()
1284
+ });
1285
+ const SystemEntrySchema = BaseEntrySchema.extend({
1286
+ type: z$1.literal("system"),
1287
+ content: z$1.string(),
1288
+ isMeta: z$1.boolean().optional(),
1289
+ level: z$1.string().optional(),
1290
+ parentUuid: z$1.string().optional(),
1291
+ isSidechain: z$1.boolean().optional(),
1292
+ userType: z$1.string().optional()
1293
+ });
1294
+ const RawJSONLinesSchema = z$1.discriminatedUnion("type", [
1295
+ UserEntrySchema,
1296
+ AssistantEntrySchema,
1297
+ SummaryEntrySchema,
1298
+ SystemEntrySchema
1299
+ ]);
1300
+
1301
+ function createSessionScanner(opts) {
1302
+ const projectName = resolve(opts.workingDirectory).replace(/\//g, "-");
1303
+ const projectDir = join(homedir(), ".claude", "projects", projectName);
1304
+ let finishedSessions = /* @__PURE__ */ new Set();
1305
+ let pendingSessions = /* @__PURE__ */ new Set();
1306
+ let currentSessionId = null;
1307
+ let currentSessionWatcherAbortController = null;
1308
+ let processedMessages = /* @__PURE__ */ new Set();
1309
+ const sync = new InvalidateSync(async () => {
1310
+ let sessions = [];
1311
+ for (let p of pendingSessions) {
1312
+ sessions.push(p);
1313
+ }
1314
+ if (currentSessionId) {
1315
+ sessions.push(currentSessionId);
1316
+ }
1317
+ let processSessionFile = async (sessionId) => {
1318
+ const expectedSessionFile = join(projectDir, `${sessionId}.jsonl`);
1319
+ let file;
1320
+ try {
1321
+ file = await readFile(expectedSessionFile, "utf-8");
1322
+ } catch (error) {
1323
+ return;
1036
1324
  }
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);
1325
+ let lines = file.split("\n");
1326
+ for (let l of lines) {
1327
+ try {
1328
+ let message = JSON.parse(l);
1329
+ let parsed = RawJSONLinesSchema.safeParse(message);
1330
+ if (!parsed.success) {
1331
+ logger.debugLargeJson(`[SESSION_SCANNER] Failed to parse message`, message);
1332
+ continue;
1333
+ }
1334
+ let key = getMessageKey(parsed.data);
1335
+ if (processedMessages.has(key)) {
1336
+ continue;
1337
+ }
1338
+ processedMessages.add(key);
1339
+ logger.debugLargeJson(`[SESSION_SCANNER] Processing message`, parsed.data);
1340
+ logger.debug(`[SESSION_SCANNER] Message key (new): ${key}`);
1341
+ opts.onMessage(parsed.data);
1342
+ } catch (e) {
1343
+ continue;
1048
1344
  }
1049
1345
  }
1346
+ };
1347
+ for (let session of sessions) {
1348
+ await processSessionFile(session);
1050
1349
  }
1051
- opts.onThinking?.(false);
1052
- };
1053
- const run = async () => {
1054
- session.onUserMessage((message) => {
1055
- logger.debug("Received remote message, adding to queue");
1056
- messageQueue.push(message);
1057
- if (mode === "interactive") {
1058
- requestSwitchToRemote();
1350
+ for (let p of sessions) {
1351
+ if (pendingSessions.has(p)) {
1352
+ pendingSessions.delete(p);
1353
+ finishedSessions.add(p);
1059
1354
  }
1060
- if (messageResolve) {
1061
- logger.debug("Waking up message processing loop");
1062
- messageResolve();
1063
- messageResolve = null;
1355
+ }
1356
+ currentSessionWatcherAbortController?.abort();
1357
+ currentSessionWatcherAbortController = new AbortController();
1358
+ void (async () => {
1359
+ if (currentSessionId) {
1360
+ const sessionFile = join(projectDir, `${currentSessionId}.jsonl`);
1361
+ try {
1362
+ for await (const change of watch$1(sessionFile, { persistent: true, signal: currentSessionWatcherAbortController.signal })) {
1363
+ await processSessionFile(currentSessionId);
1364
+ }
1365
+ } catch (error) {
1366
+ if (error.name !== "AbortError") {
1367
+ logger.debug(`[SESSION_SCANNER] Watch error: ${error.message}`);
1368
+ }
1369
+ }
1064
1370
  }
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);
1371
+ })();
1372
+ });
1373
+ const intervalId = setInterval(() => {
1374
+ sync.invalidate();
1375
+ }, 3e3);
1376
+ return {
1377
+ refresh: () => sync.invalidate(),
1378
+ cleanup: () => {
1379
+ clearInterval(intervalId);
1380
+ currentSessionWatcherAbortController?.abort();
1381
+ },
1382
+ onNewSession: (sessionId) => {
1383
+ if (currentSessionId === sessionId) {
1384
+ return;
1075
1385
  }
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);
1386
+ if (finishedSessions.has(sessionId)) {
1082
1387
  return;
1083
1388
  }
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");
1389
+ if (pendingSessions.has(sessionId)) {
1390
+ return;
1091
1391
  }
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();
1105
- while (!exiting) {
1106
- if (mode === "remote" && messageQueue.length > 0) {
1107
- const message = messageQueue.shift();
1108
- if (message) {
1109
- await processRemoteMessage(message);
1392
+ if (currentSessionId) {
1393
+ pendingSessions.add(currentSessionId);
1394
+ }
1395
+ currentSessionId = sessionId;
1396
+ sync.invalidate();
1397
+ }
1398
+ };
1399
+ }
1400
+ function getMessageKey(message) {
1401
+ if (message.type === "user") {
1402
+ return `user:${message.uuid}`;
1403
+ } else if (message.type === "assistant") {
1404
+ const { usage, ...messageWithoutUsage } = message.message;
1405
+ return stableStringify(messageWithoutUsage);
1406
+ } else if (message.type === "summary") {
1407
+ return `summary:${message.leafUuid}`;
1408
+ } else if (message.type === "system") {
1409
+ return `system:${message.uuid}`;
1410
+ }
1411
+ return `unknown:<error, this should be unreachable>`;
1412
+ }
1413
+ function stableStringify(obj) {
1414
+ return JSON.stringify(sortKeys(obj), null, 2);
1415
+ }
1416
+ function sortKeys(value) {
1417
+ if (Array.isArray(value)) {
1418
+ return value.map(sortKeys);
1419
+ } else if (value && typeof value === "object" && value.constructor === Object) {
1420
+ return Object.keys(value).sort().reduce((result, key) => {
1421
+ result[key] = sortKeys(value[key]);
1422
+ return result;
1423
+ }, {});
1424
+ } else {
1425
+ return value;
1426
+ }
1427
+ }
1428
+
1429
+ async function loop(opts) {
1430
+ let mode = "interactive";
1431
+ let currentMessageQueue = new MessageQueue();
1432
+ let sessionId = null;
1433
+ let onMessage = null;
1434
+ let seenRemoteUserMessageCounters = /* @__PURE__ */ new Map();
1435
+ opts.session.onUserMessage((message) => {
1436
+ logger.debugLargeJson("User message pushed to queue:", message);
1437
+ currentMessageQueue.push(message.content.text);
1438
+ seenRemoteUserMessageCounters.set(message.content.text, (seenRemoteUserMessageCounters.get(message.content.text) || 0) + 1);
1439
+ if (onMessage) {
1440
+ onMessage();
1441
+ }
1442
+ });
1443
+ const sessionScanner = createSessionScanner({
1444
+ workingDirectory: opts.path,
1445
+ onMessage: (message) => {
1446
+ if (message.type === "user" && typeof message.message.content === "string") {
1447
+ const currentCounter = seenRemoteUserMessageCounters.get(message.message.content);
1448
+ if (currentCounter && currentCounter > 0) {
1449
+ seenRemoteUserMessageCounters.set(message.message.content, currentCounter - 1);
1450
+ return;
1110
1451
  }
1111
- } else {
1112
- logger.debug("Waiting for next message or event");
1113
- await new Promise((resolve) => {
1114
- messageResolve = resolve;
1115
- });
1116
1452
  }
1453
+ opts.session.sendClaudeSessionMessage(message);
1117
1454
  }
1455
+ });
1456
+ let onSessionFound = (newSessionId) => {
1457
+ sessionId = newSessionId;
1458
+ sessionScanner.onNewSession(newSessionId);
1118
1459
  };
1119
- const cleanup = () => {
1120
- logger.debug("[LOOP] cleanup called");
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");
1460
+ while (true) {
1461
+ if (currentMessageQueue.size() > 0) {
1462
+ mode = "remote";
1463
+ continue;
1127
1464
  }
1128
- if (watcherAbortController) {
1129
- logger.debug("[LOOP] Aborting watcher");
1130
- watcherAbortController.abort();
1465
+ if (mode === "interactive") {
1466
+ let abortedOutside = false;
1467
+ const interactiveAbortController = new AbortController();
1468
+ opts.session.setHandler("switch", () => {
1469
+ if (!interactiveAbortController.signal.aborted) {
1470
+ abortedOutside = true;
1471
+ mode = "remote";
1472
+ interactiveAbortController.abort();
1473
+ }
1474
+ });
1475
+ onMessage = () => {
1476
+ if (!interactiveAbortController.signal.aborted) {
1477
+ abortedOutside = true;
1478
+ mode = "remote";
1479
+ interactiveAbortController.abort();
1480
+ }
1481
+ onMessage = null;
1482
+ };
1483
+ await claudeLocal({
1484
+ path: opts.path,
1485
+ sessionId,
1486
+ onSessionFound,
1487
+ abort: interactiveAbortController.signal
1488
+ });
1489
+ onMessage = null;
1490
+ if (!abortedOutside) {
1491
+ return;
1492
+ }
1493
+ if (mode !== "interactive") {
1494
+ console.log("Switching to remote mode...");
1495
+ }
1131
1496
  }
1132
- if (messageResolve) {
1133
- logger.debug("[LOOP] Waking up message loop");
1134
- messageResolve();
1497
+ if (mode === "remote") {
1498
+ logger.debug("Starting " + sessionId);
1499
+ const remoteAbortController = new AbortController();
1500
+ opts.session.setHandler("abort", () => {
1501
+ if (!remoteAbortController.signal.aborted) {
1502
+ remoteAbortController.abort();
1503
+ }
1504
+ });
1505
+ const abortHandler = () => {
1506
+ if (!remoteAbortController.signal.aborted) {
1507
+ mode = "interactive";
1508
+ remoteAbortController.abort();
1509
+ }
1510
+ process.stdin.setRawMode(false);
1511
+ };
1512
+ process.stdin.resume();
1513
+ process.stdin.setRawMode(true);
1514
+ process.stdin.setEncoding("utf8");
1515
+ process.stdin.on("data", abortHandler);
1516
+ try {
1517
+ logger.debug(`Starting claudeRemote with messages: ${currentMessageQueue.size()}`);
1518
+ await claudeRemote({
1519
+ abort: remoteAbortController.signal,
1520
+ sessionId,
1521
+ path: opts.path,
1522
+ mcpServers: opts.mcpServers,
1523
+ permissionPromptToolName: opts.permissionPromptToolName,
1524
+ onSessionFound,
1525
+ messages: currentMessageQueue,
1526
+ onAssistantResult: opts.onAssistantResult,
1527
+ interruptController: opts.interruptController
1528
+ });
1529
+ } finally {
1530
+ process.stdin.off("data", abortHandler);
1531
+ process.stdin.setRawMode(false);
1532
+ currentMessageQueue.close();
1533
+ currentMessageQueue = new MessageQueue();
1534
+ }
1535
+ if (mode !== "remote") {
1536
+ console.log("Switching back to good old claude...");
1537
+ }
1135
1538
  }
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();
1144
- await promise;
1145
- };
1539
+ }
1146
1540
  }
1147
1541
 
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;
1542
+ async function startPermissionServerV2(handler) {
1543
+ const mcp = new McpServer({
1544
+ name: "Permission Server",
1545
+ version: "1.0.0",
1546
+ description: "A server that allows you to request permissions from the user"
1547
+ });
1548
+ mcp.registerTool("ask_permission", {
1549
+ description: "Request permission to execute a tool",
1550
+ title: "Request Permission",
1551
+ inputSchema: {
1552
+ tool_name: z$1.string().describe("The tool that needs permission"),
1553
+ input: z$1.any().describe("The arguments for the tool")
1166
1554
  }
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
- }));
1555
+ // outputSchema: {
1556
+ // approved: z.boolean().describe('Whether the tool was approved'),
1557
+ // reason: z.string().describe('The reason for the approval or denial'),
1558
+ // },
1559
+ }, async (args) => {
1560
+ const response = await handler({ name: args.tool_name, arguments: args.input });
1561
+ const result = response.approved ? { behavior: "allow", updatedInput: args.input || {} } : { behavior: "deny", message: response.reason || "Permission denied by user" };
1562
+ return {
1563
+ content: [
1564
+ {
1565
+ type: "text",
1566
+ text: JSON.stringify(result)
1248
1567
  }
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
- }));
1568
+ ],
1569
+ isError: false
1570
+ };
1571
+ });
1572
+ const transport = new StreamableHTTPServerTransport({
1573
+ // NOTE: Returning session id here will result in claude
1574
+ // sdk spawn to fail with `Invalid Request: Server already initialized`
1575
+ sessionIdGenerator: void 0
1576
+ });
1577
+ await mcp.connect(transport);
1578
+ const server = createServer(async (req, res) => {
1579
+ try {
1580
+ await transport.handleRequest(req, res);
1581
+ } catch (error) {
1582
+ logger.debug("Error handling request:", error);
1583
+ if (!res.headersSent) {
1584
+ res.writeHead(500).end();
1259
1585
  }
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);
1586
+ }
1587
+ });
1588
+ const baseUrl = await new Promise((resolve) => {
1270
1589
  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);
1590
+ const addr = server.address();
1591
+ resolve(new URL(`http://127.0.0.1:${addr.port}`));
1283
1592
  });
1284
1593
  });
1285
1594
  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
- }
1595
+ url: baseUrl.toString(),
1596
+ toolName: "ask_permission"
1318
1597
  };
1319
1598
  }
1320
1599
 
1321
- async function start(options = {}) {
1322
- const workingDirectory = process.cwd();
1323
- basename(workingDirectory);
1324
- const sessionTag = randomUUID();
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");
1600
+ class InterruptController {
1601
+ interruptFn;
1602
+ isInterrupting = false;
1603
+ /**
1604
+ * Register an interrupt function from claudeRemote
1605
+ */
1606
+ register(fn) {
1607
+ this.interruptFn = fn;
1608
+ }
1609
+ /**
1610
+ * Unregister the interrupt function (cleanup)
1611
+ */
1612
+ unregister() {
1613
+ this.interruptFn = void 0;
1614
+ this.isInterrupting = false;
1615
+ }
1616
+ /**
1617
+ * Trigger the interrupt - can be called from anywhere
1618
+ */
1619
+ async interrupt() {
1620
+ if (!this.interruptFn || this.isInterrupting) {
1621
+ return false;
1622
+ }
1623
+ this.isInterrupting = true;
1624
+ try {
1625
+ await this.interruptFn();
1626
+ return true;
1627
+ } catch (error) {
1628
+ logger.debug("Failed to interrupt Claude:", error);
1629
+ return false;
1630
+ } finally {
1631
+ this.isInterrupting = false;
1337
1632
  }
1338
1633
  }
1339
- let secret = await readPrivateKey();
1340
- if (!secret) {
1341
- secret = new Uint8Array(randomBytes(32));
1342
- await writePrivateKey(secret);
1634
+ /**
1635
+ * Check if interrupt is available
1636
+ */
1637
+ canInterrupt() {
1638
+ return !!this.interruptFn && !this.isInterrupting;
1343
1639
  }
1344
- logger.info("Secret key loaded");
1345
- const token = await authGetToken(secret);
1346
- logger.info("Authenticated with handy server");
1347
- const api = new ApiClient(token, secret);
1640
+ }
1641
+
1642
+ async function start(credentials, options = {}) {
1643
+ const workingDirectory = process.cwd();
1644
+ const sessionTag = randomUUID();
1645
+ const api = new ApiClient(credentials.token, credentials.secret);
1348
1646
  let state = {};
1349
1647
  let metadata = { path: workingDirectory, host: os.hostname() };
1350
1648
  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
- }
1649
+ logger.debug(`Session created: ${response.id}`);
1363
1650
  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
1651
+ const pushClient = api.push();
1652
+ const interruptController = new InterruptController();
1653
+ let requests = /* @__PURE__ */ new Map();
1654
+ const permissionServer = await startPermissionServerV2(async (request) => {
1655
+ const id = randomUUID();
1656
+ let promise = new Promise((resolve) => {
1657
+ requests.set(id, resolve);
1369
1658
  });
1659
+ let timeout = setTimeout(async () => {
1660
+ logger.info("Permission timeout - attempting to interrupt Claude");
1661
+ const interrupted = await interruptController.interrupt();
1662
+ if (interrupted) {
1663
+ logger.info("Claude interrupted successfully");
1664
+ }
1665
+ requests.delete(id);
1666
+ session.updateAgentState((currentState) => {
1667
+ let r = { ...currentState.requests };
1668
+ delete r[id];
1669
+ return {
1670
+ ...currentState,
1671
+ requests: r
1672
+ };
1673
+ });
1674
+ }, 1e3 * 60 * 4.5);
1675
+ logger.info("Permission request" + id + " " + JSON.stringify(request));
1676
+ try {
1677
+ await pushClient.sendToAllDevices(
1678
+ "Permission Request",
1679
+ `Claude wants to use ${request.name}`,
1680
+ {
1681
+ sessionId: response.id,
1682
+ requestId: id,
1683
+ tool: request.name,
1684
+ type: "permission_request"
1685
+ }
1686
+ );
1687
+ logger.info("Push notification sent for permission request");
1688
+ } catch (error) {
1689
+ logger.debug("Failed to send push notification:", error);
1690
+ }
1691
+ session.updateAgentState((currentState) => ({
1692
+ ...currentState,
1693
+ requests: {
1694
+ ...currentState.requests,
1695
+ [id]: {
1696
+ tool: request.name,
1697
+ arguments: request.arguments
1698
+ }
1699
+ }
1700
+ }));
1701
+ promise.then(() => clearTimeout(timeout)).catch(() => clearTimeout(timeout));
1702
+ return promise;
1370
1703
  });
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);
1704
+ session.setHandler("permission", (message) => {
1705
+ logger.info("Permission response" + JSON.stringify(message));
1706
+ const id = message.id;
1707
+ const resolve = requests.get(id);
1708
+ if (resolve) {
1709
+ resolve({ approved: message.approved, reason: message.reason });
1710
+ } else {
1711
+ logger.info("Permission request stale, likely timed out");
1712
+ return;
1376
1713
  }
1714
+ session.updateAgentState((currentState) => {
1715
+ let r = { ...currentState.requests };
1716
+ delete r[id];
1717
+ return {
1718
+ ...currentState,
1719
+ requests: r
1720
+ };
1721
+ });
1722
+ });
1723
+ session.setHandler("abort", async () => {
1724
+ logger.info("Abort request - interrupting Claude");
1725
+ await interruptController.interrupt();
1377
1726
  });
1378
- const mcpServers = {
1379
- "permission-server": {
1380
- type: "http",
1381
- url: permissionServer.url
1727
+ let thinking = false;
1728
+ const pingInterval = setInterval(() => {
1729
+ session.keepAlive(thinking);
1730
+ }, 15e3);
1731
+ const onAssistantResult = async (result) => {
1732
+ try {
1733
+ const summary = "result" in result && result.result ? result.result.substring(0, 100) + (result.result.length > 100 ? "..." : "") : "";
1734
+ await pushClient.sendToAllDevices(
1735
+ "Your move :D",
1736
+ summary,
1737
+ {
1738
+ sessionId: response.id,
1739
+ type: "assistant_result",
1740
+ turns: result.num_turns,
1741
+ duration_ms: result.duration_ms,
1742
+ cost_usd: result.total_cost_usd
1743
+ }
1744
+ );
1745
+ logger.debug("Push notification sent: Assistant result");
1746
+ } catch (error) {
1747
+ logger.debug("Failed to send assistant result push notification:", error);
1382
1748
  }
1383
1749
  };
1384
- let thinking = false;
1385
- const loopDestroy = startClaudeLoop({
1750
+ await loop({
1386
1751
  path: workingDirectory,
1387
1752
  model: options.model,
1388
1753
  permissionMode: options.permissionMode,
1389
- mcpServers,
1390
- permissionPromptToolName: permissionServer.toolName,
1754
+ mcpServers: {
1755
+ "permission": {
1756
+ type: "http",
1757
+ url: permissionServer.url
1758
+ }
1759
+ },
1760
+ permissionPromptToolName: "mcp__permission__" + permissionServer.toolName,
1391
1761
  onThinking: (t) => {
1392
1762
  thinking = t;
1393
1763
  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);
1403
- const shutdown = async () => {
1404
- logger.info("Shutting down...");
1405
- clearInterval(pingInterval);
1406
- await loopDestroy();
1407
- await permissionServer.stop();
1408
- session.sendSessionDeath();
1409
- await session.flush();
1410
- await session.close();
1411
- process.exit(0);
1412
- };
1413
- process.on("SIGINT", shutdown);
1414
- process.on("SIGTERM", shutdown);
1415
- logger.info("Happy CLI is starting...");
1416
- await new Promise(() => {
1764
+ },
1765
+ session,
1766
+ onAssistantResult,
1767
+ interruptController
1417
1768
  });
1769
+ clearInterval(pingInterval);
1770
+ process.exit(0);
1771
+ }
1772
+
1773
+ const credentialsSchema = z.object({
1774
+ secret: z.string().base64(),
1775
+ token: z.string()
1776
+ });
1777
+ async function readCredentials() {
1778
+ if (!existsSync(configuration.privateKeyFile)) {
1779
+ return null;
1780
+ }
1781
+ try {
1782
+ const keyBase64 = await readFile(configuration.privateKeyFile, "utf8");
1783
+ const credentials = credentialsSchema.parse(JSON.parse(keyBase64));
1784
+ return {
1785
+ secret: new Uint8Array(Buffer.from(credentials.secret, "base64")),
1786
+ token: credentials.token
1787
+ };
1788
+ } catch {
1789
+ return null;
1790
+ }
1791
+ }
1792
+ async function writeCredentials(credentials) {
1793
+ if (!existsSync(configuration.happyDir)) {
1794
+ await mkdir(configuration.happyDir, { recursive: true });
1795
+ }
1796
+ await writeFile(configuration.privateKeyFile, JSON.stringify({
1797
+ secret: encodeBase64(credentials.secret),
1798
+ token: credentials.token
1799
+ }, null, 2));
1418
1800
  }
1419
1801
 
1420
- const args = process.argv.slice(2);
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);
1802
+ function displayQRCode(url) {
1803
+ console.log("=".repeat(80));
1804
+ console.log("\u{1F4F1} To authenticate, scan this QR code with your mobile device:");
1805
+ console.log("=".repeat(80));
1806
+ qrcode.generate(url, { small: true }, (qr) => {
1807
+ for (let l of qr.split("\n")) {
1808
+ console.log(" ".repeat(10) + l);
1427
1809
  }
1428
- process.exit(1);
1429
1810
  });
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);
1811
+ console.log("=".repeat(80));
1812
+ }
1813
+
1814
+ async function doAuth() {
1815
+ console.log("Starting authentication...");
1816
+ const secret = new Uint8Array(randomBytes(32));
1817
+ const keypair = tweetnacl.box.keyPair.fromSecretKey(secret);
1818
+ try {
1819
+ await axios.post(`${configuration.serverUrl}/v1/auth/request`, {
1820
+ publicKey: encodeBase64(keypair.publicKey)
1821
+ });
1822
+ } catch (error) {
1823
+ console.log("Failed to create authentication request, please try again later.");
1824
+ return null;
1825
+ }
1826
+ console.log("Please, authenticate using mobile app");
1827
+ displayQRCode("happy://terminal?" + encodeBase64Url(keypair.publicKey));
1828
+ let credentials = null;
1829
+ while (true) {
1830
+ try {
1831
+ const response = await axios.post(`${configuration.serverUrl}/v1/auth/request`, {
1832
+ publicKey: encodeBase64(keypair.publicKey)
1833
+ });
1834
+ if (response.data.state === "authorized") {
1835
+ let token = response.data.token;
1836
+ let r = decodeBase64(response.data.response);
1837
+ let decrypted = decryptWithEphemeralKey(r, keypair.secretKey);
1838
+ if (decrypted) {
1839
+ credentials = {
1840
+ secret: decrypted,
1841
+ token
1842
+ };
1843
+ await writeCredentials(credentials);
1844
+ return credentials;
1845
+ } else {
1846
+ console.log("Failed to decrypt response, please try again later.");
1847
+ return null;
1848
+ }
1849
+ }
1850
+ } catch (error) {
1851
+ console.log("Failed to create authentication request, please try again later.");
1852
+ return null;
1447
1853
  }
1854
+ await delay(1e3);
1448
1855
  }
1449
- if (showHelp) {
1450
- console.log(`
1856
+ return null;
1857
+ }
1858
+ function decryptWithEphemeralKey(encryptedBundle, recipientSecretKey) {
1859
+ const ephemeralPublicKey = encryptedBundle.slice(0, 32);
1860
+ const nonce = encryptedBundle.slice(32, 32 + tweetnacl.box.nonceLength);
1861
+ const encrypted = encryptedBundle.slice(32 + tweetnacl.box.nonceLength);
1862
+ const decrypted = tweetnacl.box.open(encrypted, nonce, ephemeralPublicKey, recipientSecretKey);
1863
+ if (!decrypted) {
1864
+ return null;
1865
+ }
1866
+ return decrypted;
1867
+ }
1868
+
1869
+ (async () => {
1870
+ const args = process.argv.slice(2);
1871
+ let installationLocation = args.includes("--local") || process.env.HANDY_LOCAL ? "local" : "global";
1872
+ initializeConfiguration(installationLocation);
1873
+ initLoggerWithGlobalConfiguration();
1874
+ logger.debug("Starting happy CLI with args: ", process.argv);
1875
+ const subcommand = args[0];
1876
+ if (subcommand === "logout") {
1877
+ try {
1878
+ await cleanKey();
1879
+ } catch (error) {
1880
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
1881
+ if (process.env.DEBUG) {
1882
+ console.error(error);
1883
+ }
1884
+ process.exit(1);
1885
+ }
1886
+ return;
1887
+ } else if (subcommand === "login" || subcommand === "auth") {
1888
+ await doAuth();
1889
+ return;
1890
+ } else {
1891
+ const options = {};
1892
+ let showHelp = false;
1893
+ let showVersion = false;
1894
+ for (let i = 0; i < args.length; i++) {
1895
+ const arg = args[i];
1896
+ if (arg === "-h" || arg === "--help") {
1897
+ showHelp = true;
1898
+ } else if (arg === "-v" || arg === "--version") {
1899
+ showVersion = true;
1900
+ } else if (arg === "-m" || arg === "--model") {
1901
+ options.model = args[++i];
1902
+ } else if (arg === "-p" || arg === "--permission-mode") {
1903
+ options.permissionMode = args[++i];
1904
+ } else if (arg === "--local") {
1905
+ i++;
1906
+ } else {
1907
+ console.error(chalk.red(`Unknown argument: ${arg}`));
1908
+ process.exit(1);
1909
+ }
1910
+ }
1911
+ if (showHelp) {
1912
+ console.log(`
1451
1913
  ${chalk.bold("happy")} - Claude Code session sharing
1452
1914
 
1453
1915
  ${chalk.bold("Usage:")}
1454
1916
  happy [options]
1455
- happy clean Remove happy data directory (requires phone reconnection)
1917
+ happy logout Logs out of your account and removes data directory
1918
+ happy login Show your secret QR code
1919
+ happy auth Same as login
1456
1920
 
1457
1921
  ${chalk.bold("Options:")}
1458
1922
  -h, --help Show this help message
@@ -1460,33 +1924,50 @@ ${chalk.bold("Options:")}
1460
1924
  -m, --model <model> Claude model to use (default: sonnet)
1461
1925
  -p, --permission-mode Permission mode: auto, default, or plan
1462
1926
 
1927
+ [Advanced]
1928
+ --local < global | local >
1929
+ Will use .happy folder in the current directory for storing your private key and debug logs.
1930
+ You will require re-login each time you run this in a new directory.
1931
+ Use with login to show either global or local QR code.
1932
+
1463
1933
  ${chalk.bold("Examples:")}
1464
1934
  happy Start a session with default settings
1465
1935
  happy -m opus Use Claude Opus model
1466
1936
  happy -p plan Use plan permission mode
1467
- happy clean Remove happy data directory and authentication
1937
+ happy logout Logs out of your account and removes data directory
1468
1938
  `);
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);
1939
+ process.exit(0);
1479
1940
  }
1480
- process.exit(1);
1481
- });
1482
- }
1941
+ if (showVersion) {
1942
+ console.log("0.1.3");
1943
+ process.exit(0);
1944
+ }
1945
+ let credentials = await readCredentials();
1946
+ if (!credentials) {
1947
+ let res = await doAuth();
1948
+ if (!res) {
1949
+ process.exit(1);
1950
+ }
1951
+ credentials = res;
1952
+ }
1953
+ try {
1954
+ await start(credentials, options);
1955
+ } catch (error) {
1956
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
1957
+ if (process.env.DEBUG) {
1958
+ console.error(error);
1959
+ }
1960
+ process.exit(1);
1961
+ }
1962
+ }
1963
+ })();
1483
1964
  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);
1965
+ const happyDir = configuration.happyDir;
1966
+ if (!existsSync(happyDir)) {
1967
+ console.log(chalk.yellow("No happy data directory found at:"), happyDir);
1487
1968
  return;
1488
1969
  }
1489
- console.log(chalk.blue("Found happy data directory at:"), handyDir);
1970
+ console.log(chalk.blue("Found happy data directory at:"), happyDir);
1490
1971
  console.log(chalk.yellow("\u26A0\uFE0F This will remove all authentication data and require reconnecting your phone."));
1491
1972
  const rl = createInterface({
1492
1973
  input: process.stdin,
@@ -1498,7 +1979,7 @@ async function cleanKey() {
1498
1979
  rl.close();
1499
1980
  if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
1500
1981
  try {
1501
- rmSync(handyDir, { recursive: true, force: true });
1982
+ rmSync(happyDir, { recursive: true, force: true });
1502
1983
  console.log(chalk.green("\u2713 Happy data directory removed successfully"));
1503
1984
  console.log(chalk.blue("\u2139\uFE0F You will need to reconnect your phone on the next session"));
1504
1985
  } catch (error) {