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