happy-coder 0.1.2 → 0.1.5

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