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.cjs CHANGED
@@ -1,19 +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');
8
+ var promises = require('node:fs/promises');
9
+ var node_fs = require('node:fs');
11
10
  var node_events = require('node:events');
12
11
  var socket_ioClient = require('socket.io-client');
13
- var zod = require('zod');
14
- 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
+ var claudeCode = require('@anthropic-ai/claude-code');
15
17
  var node_child_process = require('node:child_process');
18
+ var node_readline = require('node:readline');
19
+ var node_url = require('node:url');
20
+ var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
21
+ var node_http = require('node:http');
22
+ var streamableHttp_js = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
23
+ var qrcode = require('qrcode-terminal');
16
24
 
25
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
17
26
  function _interopNamespaceDefault(e) {
18
27
  var n = Object.create(null);
19
28
  if (e) {
@@ -31,193 +40,322 @@ function _interopNamespaceDefault(e) {
31
40
  return Object.freeze(n);
32
41
  }
33
42
 
34
- var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs);
43
+ var z__namespace = /*#__PURE__*/_interopNamespaceDefault(z);
35
44
 
36
- function encodeBase64(buffer) {
37
- return Buffer.from(buffer).toString("base64");
38
- }
39
- function encodeBase64Url(buffer) {
40
- return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
41
- }
42
- function decodeBase64(base64) {
43
- return new Uint8Array(Buffer.from(base64, "base64"));
44
- }
45
- function getRandomBytes(size) {
46
- return new Uint8Array(node_crypto.randomBytes(size));
47
- }
48
- function encrypt(data, secret) {
49
- const nonce = getRandomBytes(tweetnacl.secretbox.nonceLength);
50
- const encrypted = tweetnacl.secretbox(new TextEncoder().encode(JSON.stringify(data)), nonce, secret);
51
- const result = new Uint8Array(nonce.length + encrypted.length);
52
- result.set(nonce);
53
- result.set(encrypted, nonce.length);
54
- return result;
55
- }
56
- function decrypt(data, secret) {
57
- const nonce = data.slice(0, tweetnacl.secretbox.nonceLength);
58
- const encrypted = data.slice(tweetnacl.secretbox.nonceLength);
59
- const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret);
60
- if (!decrypted) {
61
- return null;
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");
62
62
  }
63
- return JSON.parse(new TextDecoder().decode(decrypted));
64
63
  }
65
- function authChallenge(secret) {
66
- const keypair = tweetnacl.sign.keyPair.fromSeed(secret);
67
- const challenge = getRandomBytes(32);
68
- const signature = tweetnacl.sign.detached(challenge, keypair.secretKey);
69
- return {
70
- challenge,
71
- publicKey: keypair.publicKey,
72
- signature
73
- };
64
+ let configuration = void 0;
65
+ function initializeConfiguration(location) {
66
+ configuration = new Configuration(location);
74
67
  }
75
68
 
76
- async function getOrCreateSecretKey() {
77
- const keyPath = node_path.join(os.homedir(), ".handy", "access.key");
78
- if (fs.existsSync(keyPath)) {
79
- const keyBase642 = fs.readFileSync(keyPath, "utf8").trim();
80
- return new Uint8Array(Buffer.from(keyBase642, "base64"));
81
- }
82
- const secret = getRandomBytes(32);
83
- const keyBase64 = encodeBase64(secret);
84
- fs.mkdirSync(node_path.join(os.homedir(), ".handy"), { recursive: true });
85
- fs.writeFileSync(keyPath, keyBase64);
86
- await promises.chmod(keyPath, 384);
87
- return secret;
88
- }
89
- async function authGetToken(secret) {
90
- const { challenge, publicKey, signature } = authChallenge(secret);
91
- const response = await axios.post(`https://handy-api.korshakov.org/v1/auth`, {
92
- challenge: encodeBase64(challenge),
93
- publicKey: encodeBase64(publicKey),
94
- signature: encodeBase64(signature)
95
- });
96
- if (!response.data.success || !response.data.token) {
97
- throw new Error("Authentication failed");
69
+ async function getSessionLogPath() {
70
+ if (!node_fs.existsSync(configuration.logsDir)) {
71
+ await promises.mkdir(configuration.logsDir, { recursive: true });
98
72
  }
99
- return response.data.token;
73
+ const now = /* @__PURE__ */ new Date();
74
+ const timestamp = now.toLocaleString("sv-SE", {
75
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
76
+ year: "numeric",
77
+ month: "2-digit",
78
+ day: "2-digit",
79
+ hour: "2-digit",
80
+ minute: "2-digit",
81
+ second: "2-digit"
82
+ }).replace(/[: ]/g, "-").replace(/,/g, "");
83
+ return node_path.join(configuration.logsDir, `${timestamp}.log`);
100
84
  }
101
- function generateAppUrl(secret) {
102
- const secretBase64Url = encodeBase64Url(secret);
103
- return `handy://${secretBase64Url}`;
104
- }
105
-
106
85
  class Logger {
107
- debug(message, ...args) {
108
- if (process.env.DEBUG) {
109
- this.log("DEBUG" /* DEBUG */, message, ...args);
110
- }
86
+ constructor(logFilePathPromise = getSessionLogPath()) {
87
+ this.logFilePathPromise = logFilePathPromise;
111
88
  }
112
- error(message, ...args) {
113
- this.log("ERROR" /* ERROR */, message, ...args);
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
+ });
114
100
  }
115
- info(message, ...args) {
116
- this.log("INFO" /* INFO */, message, ...args);
101
+ debug(message, ...args) {
102
+ this.logToFile(`[${this.localTimezoneTimestamp()}]`, message, ...args);
117
103
  }
118
- warn(message, ...args) {
119
- this.log("WARN" /* WARN */, message, ...args);
104
+ debugLargeJson(message, object, maxStringLength = 100, maxArrayLength = 10) {
105
+ if (!process.env.DEBUG) {
106
+ this.debug(`In production, skipping message inspection`);
107
+ }
108
+ const truncateStrings = (obj) => {
109
+ if (typeof obj === "string") {
110
+ return obj.length > maxStringLength ? obj.substring(0, maxStringLength) + "... [truncated for logs]" : obj;
111
+ }
112
+ if (Array.isArray(obj)) {
113
+ const truncatedArray = obj.map((item) => truncateStrings(item)).slice(0, maxArrayLength);
114
+ if (obj.length > maxArrayLength) {
115
+ truncatedArray.push(`... [truncated array for logs up to ${maxArrayLength} items]`);
116
+ }
117
+ return truncatedArray;
118
+ }
119
+ if (obj && typeof obj === "object") {
120
+ const result = {};
121
+ for (const [key, value] of Object.entries(obj)) {
122
+ if (key === "usage") {
123
+ continue;
124
+ }
125
+ result[key] = truncateStrings(value);
126
+ }
127
+ return result;
128
+ }
129
+ return obj;
130
+ };
131
+ const truncatedObject = truncateStrings(object);
132
+ const json = JSON.stringify(truncatedObject, null, 2);
133
+ this.logToFile(`[${this.localTimezoneTimestamp()}]`, message, "\n", json);
120
134
  }
121
- getTimestamp() {
122
- return (/* @__PURE__ */ new Date()).toISOString();
135
+ info(message, ...args) {
136
+ this.logToConsole("info", "", message, ...args);
137
+ this.debug(message, args);
123
138
  }
124
- log(level, message, ...args) {
125
- const timestamp = this.getTimestamp();
126
- const prefix = `[${timestamp}] [${level}]`;
139
+ logToConsole(level, prefix, message, ...args) {
127
140
  switch (level) {
128
- case "DEBUG" /* DEBUG */: {
141
+ case "debug": {
129
142
  console.log(chalk.gray(prefix), message, ...args);
130
143
  break;
131
144
  }
132
- case "ERROR" /* ERROR */: {
145
+ case "error": {
133
146
  console.error(chalk.red(prefix), message, ...args);
134
147
  break;
135
148
  }
136
- case "INFO" /* INFO */: {
149
+ case "info": {
137
150
  console.log(chalk.blue(prefix), message, ...args);
138
151
  break;
139
152
  }
140
- case "WARN" /* WARN */: {
153
+ case "warn": {
141
154
  console.log(chalk.yellow(prefix), message, ...args);
142
155
  break;
143
156
  }
157
+ default: {
158
+ this.debug("Unknown log level:", level);
159
+ console.log(chalk.blue(prefix), message, ...args);
160
+ break;
161
+ }
144
162
  }
145
163
  }
164
+ logToFile(prefix, message, ...args) {
165
+ const logLine = `${prefix} ${message} ${args.map(
166
+ (arg) => typeof arg === "string" ? arg : JSON.stringify(arg)
167
+ ).join(" ")}
168
+ `;
169
+ this.logFilePathPromise.then((logFilePath) => {
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
+ }
178
+ }).catch((error) => {
179
+ if (process.env.DEBUG) {
180
+ console.log("This message only visible in DEBUG mode, not in production");
181
+ console.error("Failed to resolve log file path:", error);
182
+ console.log(prefix, message, ...args);
183
+ }
184
+ });
185
+ }
186
+ }
187
+ let logger;
188
+ function initLoggerWithGlobalConfiguration() {
189
+ logger = new Logger();
146
190
  }
147
- const logger = new Logger();
148
191
 
149
- const SessionMessageContentSchema = zod.z.object({
150
- c: zod.z.string(),
192
+ const SessionMessageContentSchema = z.z.object({
193
+ c: z.z.string(),
151
194
  // Base64 encoded encrypted content
152
- t: zod.z.literal("encrypted")
195
+ t: z.z.literal("encrypted")
153
196
  });
154
- const UpdateBodySchema = zod.z.object({
155
- message: zod.z.object({
156
- id: zod.z.string(),
157
- 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(),
158
201
  content: SessionMessageContentSchema
159
202
  }),
160
- sid: zod.z.string(),
203
+ sid: z.z.string(),
161
204
  // Session ID
162
- t: zod.z.literal("new-message")
205
+ t: z.z.literal("new-message")
163
206
  });
164
- zod.z.object({
165
- id: zod.z.string(),
166
- seq: zod.z.number(),
167
- body: UpdateBodySchema,
168
- createdAt: zod.z.number()
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()
213
+ }).nullish(),
214
+ agentState: z.z.object({
215
+ version: z.z.number(),
216
+ agentState: z.z.string()
217
+ }).nullish()
169
218
  });
170
- zod.z.object({
171
- createdAt: zod.z.number(),
172
- id: zod.z.string(),
173
- seq: zod.z.number(),
174
- updatedAt: 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()
175
224
  });
176
- zod.z.object({
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()
234
+ });
235
+ z.z.object({
177
236
  content: SessionMessageContentSchema,
178
- createdAt: zod.z.number(),
179
- id: zod.z.string(),
180
- seq: zod.z.number(),
181
- updatedAt: zod.z.number()
237
+ createdAt: z.z.number(),
238
+ id: z.z.string(),
239
+ seq: z.z.number(),
240
+ updatedAt: z.z.number()
182
241
  });
183
- zod.z.object({
184
- session: zod.z.object({
185
- id: zod.z.string(),
186
- tag: zod.z.string(),
187
- seq: zod.z.number(),
188
- createdAt: zod.z.number(),
189
- updatedAt: 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()
190
253
  })
191
254
  });
192
- const UserMessageSchema = zod.z.object({
193
- role: zod.z.literal("user"),
194
- content: zod.z.object({
195
- type: zod.z.literal("text"),
196
- text: zod.z.string()
197
- })
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()
260
+ }),
261
+ localKey: z.z.string().optional(),
262
+ // Mobile messages include this
263
+ sentFrom: z.z.enum(["mobile", "cli"]).optional()
264
+ // Source identifier
198
265
  });
199
- const AgentMessageSchema = zod.z.object({
200
- role: zod.z.literal("agent"),
201
- 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
+ })
202
272
  });
203
- 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
+ }
304
+
305
+ async function delay(ms) {
306
+ return new Promise((resolve) => setTimeout(resolve, ms));
307
+ }
308
+ function exponentialBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount) {
309
+ let maxDelayRet = minDelay + (maxDelay - minDelay) / maxFailureCount * Math.max(currentFailureCount, maxFailureCount);
310
+ return Math.round(Math.random() * maxDelayRet);
311
+ }
312
+ function createBackoff(opts) {
313
+ return async (callback) => {
314
+ let currentFailureCount = 0;
315
+ const minDelay = 250;
316
+ const maxDelay = 1e3;
317
+ const maxFailureCount = 50;
318
+ while (true) {
319
+ try {
320
+ return await callback();
321
+ } catch (e) {
322
+ if (currentFailureCount < maxFailureCount) {
323
+ currentFailureCount++;
324
+ }
325
+ let waitForRequest = exponentialBackoffDelay(currentFailureCount, minDelay, maxDelay, maxFailureCount);
326
+ await delay(waitForRequest);
327
+ }
328
+ }
329
+ };
330
+ }
331
+ let backoff = createBackoff();
204
332
 
205
333
  class ApiSessionClient extends node_events.EventEmitter {
206
334
  token;
207
335
  secret;
208
336
  sessionId;
337
+ metadata;
338
+ metadataVersion;
339
+ agentState;
340
+ agentStateVersion;
209
341
  socket;
210
- receivedMessages = /* @__PURE__ */ new Set();
211
342
  pendingMessages = [];
212
343
  pendingMessageCallback = null;
213
- constructor(token, secret, sessionId) {
344
+ rpcHandlers = /* @__PURE__ */ new Map();
345
+ constructor(token, secret, session) {
214
346
  super();
215
347
  this.token = token;
216
348
  this.secret = secret;
217
- this.sessionId = sessionId;
218
- this.socket = socket_ioClient.io("https://handy-api.korshakov.org", {
349
+ this.sessionId = session.id;
350
+ this.metadata = session.metadata;
351
+ this.metadataVersion = session.metadataVersion;
352
+ this.agentState = session.agentState;
353
+ this.agentStateVersion = session.agentStateVersion;
354
+ this.socket = socket_ioClient.io(configuration.serverUrl, {
219
355
  auth: {
220
- token: this.token
356
+ token: this.token,
357
+ clientType: "session-scoped",
358
+ sessionId: this.sessionId
221
359
  },
222
360
  path: "/v1/updates",
223
361
  reconnection: true,
@@ -229,30 +367,65 @@ class ApiSessionClient extends node_events.EventEmitter {
229
367
  autoConnect: false
230
368
  });
231
369
  this.socket.on("connect", () => {
232
- logger.info("Socket connected successfully");
370
+ logger.debug("Socket connected successfully");
371
+ this.reregisterHandlers();
372
+ });
373
+ this.socket.on("rpc-request", async (data, callback) => {
374
+ try {
375
+ const method = data.method;
376
+ const handler = this.rpcHandlers.get(method);
377
+ if (!handler) {
378
+ logger.debug("[SOCKET] [RPC] [ERROR] method not found", { method });
379
+ const errorResponse = { error: "Method not found" };
380
+ const encryptedError = encodeBase64(encrypt(errorResponse, this.secret));
381
+ callback(encryptedError);
382
+ return;
383
+ }
384
+ const decryptedParams = decrypt(decodeBase64(data.params), this.secret);
385
+ const result = await handler(decryptedParams);
386
+ const encryptedResponse = encodeBase64(encrypt(result, this.secret));
387
+ callback(encryptedResponse);
388
+ } catch (error) {
389
+ logger.debug("[SOCKET] [RPC] [ERROR] Error handling RPC request", { error });
390
+ const errorResponse = { error: error instanceof Error ? error.message : "Unknown error" };
391
+ const encryptedError = encodeBase64(encrypt(errorResponse, this.secret));
392
+ callback(encryptedError);
393
+ }
233
394
  });
234
395
  this.socket.on("disconnect", (reason) => {
235
- logger.warn("Socket disconnected:", reason);
396
+ logger.debug("[API] Socket disconnected:", reason);
236
397
  });
237
398
  this.socket.on("connect_error", (error) => {
238
- logger.error("Socket connection error:", error.message);
399
+ logger.debug("[API] Socket connection error:", error);
239
400
  });
240
401
  this.socket.on("update", (data) => {
241
402
  if (data.body.t === "new-message" && data.body.message.content.t === "encrypted") {
242
403
  const body = decrypt(decodeBase64(data.body.message.content.c), this.secret);
243
- const result = UserMessageSchema.safeParse(body);
244
- if (result.success) {
245
- if (!this.receivedMessages.has(data.body.message.id)) {
246
- this.receivedMessages.add(data.body.message.id);
247
- if (this.pendingMessageCallback) {
248
- this.pendingMessageCallback(result.data);
249
- } else {
250
- this.pendingMessages.push(result.data);
251
- }
404
+ logger.debugLargeJson("[SOCKET] [UPDATE] Received update:", body);
405
+ const userResult = UserMessageSchema$1.safeParse(body);
406
+ if (userResult.success) {
407
+ if (this.pendingMessageCallback) {
408
+ this.pendingMessageCallback(userResult.data);
409
+ } else {
410
+ this.pendingMessages.push(userResult.data);
252
411
  }
412
+ } else {
413
+ this.emit("message", body);
414
+ }
415
+ } else if (data.body.t === "update-session") {
416
+ if (data.body.metadata && data.body.metadata.version > this.metadataVersion) {
417
+ this.metadata = decrypt(decodeBase64(data.body.metadata.metadata), this.secret);
418
+ this.metadataVersion = data.body.metadata.version;
419
+ }
420
+ if (data.body.agentState && data.body.agentState.version > this.agentStateVersion) {
421
+ this.agentState = data.body.agentState.agentState ? decrypt(decodeBase64(data.body.agentState.agentState), this.secret) : null;
422
+ this.agentStateVersion = data.body.agentState.version;
253
423
  }
254
424
  }
255
425
  });
426
+ this.socket.on("error", (error) => {
427
+ logger.debug("[API] Socket error:", error);
428
+ });
256
429
  this.socket.connect();
257
430
  }
258
431
  onUserMessage(callback) {
@@ -263,30 +436,248 @@ class ApiSessionClient extends node_events.EventEmitter {
263
436
  }
264
437
  /**
265
438
  * Send message to session
266
- * @param body - Message body
439
+ * @param body - Message body (can be MessageContent or raw content for agent messages)
267
440
  */
268
- sendMessage(body) {
269
- let content = {
270
- role: "agent",
271
- content: body
272
- };
441
+ sendClaudeSessionMessage(body) {
442
+ let content;
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
+ };
451
+ } else {
452
+ content = {
453
+ role: "agent",
454
+ content: {
455
+ type: "output",
456
+ data: body
457
+ // This wraps the entire Claude message
458
+ }
459
+ };
460
+ }
461
+ logger.debugLargeJson("[SOCKET] Sending message through socket:", content);
273
462
  const encrypted = encodeBase64(encrypt(content, this.secret));
274
463
  this.socket.emit("message", {
275
464
  sid: this.sessionId,
276
465
  message: encrypted
277
466
  });
278
467
  }
468
+ /**
469
+ * Send a ping message to keep the connection alive
470
+ */
471
+ keepAlive(thinking) {
472
+ this.socket.volatile.emit("session-alive", { sid: this.sessionId, time: Date.now(), thinking });
473
+ }
474
+ /**
475
+ * Send session death message
476
+ */
477
+ sendSessionDeath() {
478
+ this.socket.emit("session-end", { sid: this.sessionId, time: Date.now() });
479
+ }
480
+ /**
481
+ * Update session metadata
482
+ * @param handler - Handler function that returns the updated metadata
483
+ */
484
+ updateMetadata(handler) {
485
+ backoff(async () => {
486
+ let updated = handler(this.metadata);
487
+ const answer = await this.socket.emitWithAck("update-metadata", { sid: this.sessionId, expectedVersion: this.metadataVersion, metadata: encodeBase64(encrypt(updated, this.secret)) });
488
+ if (answer.result === "success") {
489
+ this.metadata = decrypt(decodeBase64(answer.metadata), this.secret);
490
+ this.metadataVersion = answer.version;
491
+ } else if (answer.result === "version-mismatch") {
492
+ if (answer.version > this.metadataVersion) {
493
+ this.metadataVersion = answer.version;
494
+ this.metadata = decrypt(decodeBase64(answer.metadata), this.secret);
495
+ }
496
+ throw new Error("Metadata version mismatch");
497
+ } else if (answer.result === "error") ;
498
+ });
499
+ }
500
+ /**
501
+ * Update session agent state
502
+ * @param handler - Handler function that returns the updated agent state
503
+ */
504
+ updateAgentState(handler) {
505
+ console.log("Updating agent state", this.agentState);
506
+ backoff(async () => {
507
+ let updated = handler(this.agentState || {});
508
+ const answer = await this.socket.emitWithAck("update-state", { sid: this.sessionId, expectedVersion: this.agentStateVersion, agentState: updated ? encodeBase64(encrypt(updated, this.secret)) : null });
509
+ if (answer.result === "success") {
510
+ this.agentState = answer.agentState ? decrypt(decodeBase64(answer.agentState), this.secret) : null;
511
+ this.agentStateVersion = answer.version;
512
+ console.log("Agent state updated", this.agentState);
513
+ } else if (answer.result === "version-mismatch") {
514
+ if (answer.version > this.agentStateVersion) {
515
+ this.agentStateVersion = answer.version;
516
+ this.agentState = answer.agentState ? decrypt(decodeBase64(answer.agentState), this.secret) : null;
517
+ }
518
+ throw new Error("Agent state version mismatch");
519
+ } else if (answer.result === "error") {
520
+ console.error("Agent state update error", answer);
521
+ }
522
+ });
523
+ }
524
+ /**
525
+ * Set a custom RPC handler for a specific method with encrypted arguments and responses
526
+ * @param method - The method name to handle
527
+ * @param handler - The handler function to call when the method is invoked
528
+ */
529
+ setHandler(method, handler) {
530
+ const prefixedMethod = `${this.sessionId}:${method}`;
531
+ this.rpcHandlers.set(prefixedMethod, handler);
532
+ this.socket.emit("rpc-register", { method: prefixedMethod });
533
+ logger.debug("Registered RPC handler", { method, prefixedMethod });
534
+ }
535
+ /**
536
+ * Re-register all RPC handlers after reconnection
537
+ */
538
+ reregisterHandlers() {
539
+ logger.debug("Re-registering RPC handlers after reconnection", {
540
+ totalMethods: this.rpcHandlers.size
541
+ });
542
+ for (const [prefixedMethod] of this.rpcHandlers) {
543
+ this.socket.emit("rpc-register", { method: prefixedMethod });
544
+ logger.debug("Re-registered method", { prefixedMethod });
545
+ }
546
+ }
547
+ /**
548
+ * Wait for socket buffer to flush
549
+ */
550
+ async flush() {
551
+ if (!this.socket.connected) {
552
+ return;
553
+ }
554
+ return new Promise((resolve) => {
555
+ this.socket.emit("ping", () => {
556
+ resolve();
557
+ });
558
+ setTimeout(() => {
559
+ resolve();
560
+ }, 1e4);
561
+ });
562
+ }
279
563
  async close() {
280
564
  this.socket.close();
281
565
  }
282
566
  }
283
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
+
284
673
  class ApiClient {
285
674
  token;
286
675
  secret;
676
+ pushClient;
287
677
  constructor(token, secret) {
288
678
  this.token = token;
289
679
  this.secret = secret;
680
+ this.pushClient = new PushNotificationClient(token);
290
681
  }
291
682
  /**
292
683
  * Create a new session or load existing one with the given tag
@@ -294,8 +685,12 @@ class ApiClient {
294
685
  async getOrCreateSession(opts) {
295
686
  try {
296
687
  const response = await axios.post(
297
- `https://handy-api.korshakov.org/v1/sessions`,
298
- { tag: opts.tag, metadata: encodeBase64(encrypt(opts.metadata, this.secret)) },
688
+ `${configuration.serverUrl}/v1/sessions`,
689
+ {
690
+ tag: opts.tag,
691
+ metadata: encodeBase64(encrypt(opts.metadata, this.secret)),
692
+ agentState: opts.state ? encodeBase64(encrypt(opts.state, this.secret)) : null
693
+ },
299
694
  {
300
695
  headers: {
301
696
  "Authorization": `Bearer ${this.token}`,
@@ -303,10 +698,21 @@ class ApiClient {
303
698
  }
304
699
  }
305
700
  );
306
- logger.info(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`);
307
- return response.data;
701
+ logger.debug(`Session created/loaded: ${response.data.session.id} (tag: ${opts.tag})`);
702
+ let raw = response.data.session;
703
+ let session = {
704
+ id: raw.id,
705
+ createdAt: raw.createdAt,
706
+ updatedAt: raw.updatedAt,
707
+ seq: raw.seq,
708
+ metadata: decrypt(decodeBase64(raw.metadata), this.secret),
709
+ metadataVersion: raw.metadataVersion,
710
+ agentState: raw.agentState ? decrypt(decodeBase64(raw.agentState), this.secret) : null,
711
+ agentStateVersion: raw.agentStateVersion
712
+ };
713
+ return session;
308
714
  } catch (error) {
309
- logger.error("Failed to get or create session:", error);
715
+ logger.debug("[API] [ERROR] Failed to get or create session:", error);
310
716
  throw new Error(`Failed to get or create session: ${error instanceof Error ? error.message : "Unknown error"}`);
311
717
  }
312
718
  }
@@ -315,272 +721,1223 @@ class ApiClient {
315
721
  * @param id - Session ID
316
722
  * @returns Session client
317
723
  */
318
- session(id) {
319
- return new ApiSessionClient(this.token, this.secret, id);
724
+ session(session) {
725
+ return new ApiSessionClient(this.token, this.secret, session);
726
+ }
727
+ /**
728
+ * Get push notification client
729
+ * @returns Push notification client
730
+ */
731
+ push() {
732
+ return this.pushClient;
320
733
  }
321
734
  }
322
735
 
323
- function displayQRCode(url) {
324
- try {
325
- logger.info("=".repeat(50));
326
- logger.info("\u{1F4F1} Scan this QR code with your mobile device:");
327
- logger.info("=".repeat(50));
328
- qrcode.generate(url, { small: true }, (qr) => {
329
- for (let l of qr.split("\n")) {
330
- logger.info(" " + l);
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)));
331
750
  }
332
- });
333
- logger.info("=".repeat(50));
334
- } catch (error) {
335
- logger.error("Failed to generate QR code:", error);
336
- logger.info(`\u{1F4CB} Use this URL to connect: ${url}`);
751
+ break;
752
+ }
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
+ }
775
+ }
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
+ }
801
+ }
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);
848
+ }
849
+ }
337
850
  }
338
851
  }
852
+ function printDivider() {
853
+ console.log(chalk.gray("\u2550".repeat(60)));
854
+ }
339
855
 
340
- function claudePath() {
341
- if (fs__namespace.existsSync(process.env.HOME + "/.claude/local/claude")) {
342
- return process.env.HOME + "/.claude/local/claude";
343
- } else {
344
- 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;
345
864
  }
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;
871
+ }
872
+ });
873
+ return hasGoodMessage;
346
874
  }
347
875
 
348
- async function* claude(options) {
349
- const args = buildArgs(options);
350
- const path = claudePath();
351
- logger.info("Spawning Claude CLI with args:", args);
352
- const process = node_child_process.spawn(path, args, {
353
- cwd: options.workingDirectory,
354
- stdio: ["pipe", "pipe", "pipe"],
355
- shell: false
356
- });
357
- process.stdin?.end();
358
- let outputBuffer = "";
359
- let stderrBuffer = "";
360
- let processExited = false;
361
- const outputQueue = [];
362
- let outputResolve = null;
363
- process.stdout?.on("data", (data) => {
364
- outputBuffer += data.toString();
365
- const lines = outputBuffer.split("\n");
366
- outputBuffer = lines.pop() || "";
367
- for (const line of lines) {
368
- if (line.trim()) {
369
- try {
370
- const json = JSON.parse(line);
371
- outputQueue.push({ type: "json", data: json });
372
- } catch {
373
- outputQueue.push({ type: "text", data: line });
374
- }
375
- if (outputResolve) {
376
- outputResolve();
377
- outputResolve = null;
378
- }
876
+ async function claudeRemote(opts) {
877
+ let startFrom = opts.sessionId;
878
+ if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) {
879
+ startFrom = null;
880
+ }
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
889
+ };
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) {
900
+ }
901
+ abortController.abort();
902
+ })();
903
+ } else {
904
+ abortController.abort();
379
905
  }
380
906
  }
381
907
  });
382
- process.stderr?.on("data", (data) => {
383
- stderrBuffer += data.toString();
384
- const lines = stderrBuffer.split("\n");
385
- stderrBuffer = lines.pop() || "";
386
- for (const line of lines) {
387
- if (line.trim()) {
388
- outputQueue.push({ type: "error", error: line });
389
- if (outputResolve) {
390
- outputResolve();
391
- outputResolve = null;
392
- }
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
+ });
919
+ }
920
+ printDivider();
921
+ try {
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();
934
+ }
935
+ });
393
936
  }
394
937
  }
395
- });
396
- process.on("exit", (code, signal) => {
397
- processExited = true;
398
- outputQueue.push({ type: "exit", code, signal });
399
- if (outputResolve) {
400
- outputResolve();
401
- outputResolve = null;
938
+ logger.debug(`[claudeRemote] Finished iterating over response`);
939
+ } catch (e) {
940
+ if (abortController.signal.aborted) {
941
+ logger.debug(`[claudeRemote] Aborted`);
402
942
  }
403
- });
404
- process.on("error", (error) => {
405
- outputQueue.push({ type: "error", error: error.message });
406
- processExited = true;
407
- if (outputResolve) {
408
- outputResolve();
409
- outputResolve = null;
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();
951
+ }
952
+ }
953
+ printDivider();
954
+ logger.debug(`[claudeRemote] Function completed`);
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);
980
+ }
410
981
  }
411
982
  });
412
- while (!processExited || outputQueue.length > 0) {
413
- if (outputQueue.length === 0) {
414
- await new Promise((resolve) => {
415
- outputResolve = resolve;
416
- if (outputQueue.length > 0 || processExited) {
417
- resolve();
418
- outputResolve = null;
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);
993
+ }
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
+ });
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();
419
1031
  }
420
1032
  });
1033
+ });
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");
421
1058
  }
422
- while (outputQueue.length > 0) {
423
- const output = outputQueue.shift();
424
- yield output;
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
+ });
1072
+ } else {
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
+ });
425
1083
  }
1084
+ logger.debug(`[MessageQueue] push() completed. Waiters: ${this.waiters.length}, Queue size after: ${this.queue.length}`);
426
1085
  }
427
- if (outputBuffer.trim()) {
428
- try {
429
- const json = JSON.parse(outputBuffer);
430
- yield { type: "json", data: json };
431
- } catch {
432
- yield { type: "text", data: outputBuffer };
433
- }
434
- }
435
- if (stderrBuffer.trim()) {
436
- yield { type: "error", error: stderrBuffer };
437
- }
438
- }
439
- function buildArgs(options) {
440
- const args = [
441
- "--print",
442
- options.command,
443
- "--output-format",
444
- "stream-json",
445
- "--verbose"
446
- ];
447
- if (options.model) {
448
- args.push("--model", options.model);
449
- }
450
- if (options.permissionMode) {
451
- const modeMap = {
452
- "auto": "acceptEdits",
453
- "default": "default",
454
- "plan": "bypassPermissions"
455
- };
456
- args.push("--permission-mode", modeMap[options.permissionMode]);
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?.();
457
1093
  }
458
- if (options.skipPermissions) {
459
- args.push("--dangerously-skip-permissions");
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;
1117
+ }
1118
+ if (this.closed) {
1119
+ logger.debug(`[MessageQueue] Iterator ending - queue closed`);
1120
+ return;
1121
+ }
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;
1127
+ }
1128
+ logger.debug(`[MessageQueue] Iterator yielding waited message`);
1129
+ yield nextMessage;
1130
+ }
460
1131
  }
461
- if (options.sessionId) {
462
- args.push("--resume", options.sessionId);
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;
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
+ });
1153
+ });
463
1154
  }
464
- return args;
465
1155
  }
466
1156
 
467
- function startClaudeLoop(opts, session) {
468
- let exiting = false;
469
- const messageQueue = [];
470
- let messageResolve = null;
471
- let sessionId;
472
- let promise = (async () => {
473
- session.onUserMessage((message) => {
474
- messageQueue.push(message);
475
- if (messageResolve) {
476
- messageResolve();
477
- messageResolve = null;
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;
478
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();
479
1187
  });
480
- while (!exiting) {
481
- if (messageQueue.length > 0) {
482
- const message = messageQueue.shift();
483
- if (message) {
484
- for await (const output of claude({
485
- command: message.content.text,
486
- workingDirectory: opts.path,
487
- model: opts.model,
488
- permissionMode: opts.permissionMode,
489
- sessionId
490
- })) {
491
- if (output.type === "exit") {
492
- if (output.code !== 0 || output.code === void 0) {
493
- session.sendMessage({
494
- content: {
495
- type: "error",
496
- error: output.error,
497
- code: output.code
498
- },
499
- role: "assistant"
500
- });
501
- }
502
- break;
503
- }
504
- if (output.type === "json") {
505
- session.sendMessage({
506
- data: output.data,
507
- type: "output"
508
- });
509
- }
510
- if (output.type === "json" && output.data.type === "system" && output.data.subtype === "init") {
511
- sessionId = output.data.sessionId;
512
- }
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) {
1205
+ return;
1206
+ }
1207
+ await this._command();
1208
+ });
1209
+ if (this._stopped) {
1210
+ this._notifyPendings();
1211
+ return;
1212
+ }
1213
+ if (this._invalidatedDouble) {
1214
+ this._invalidatedDouble = false;
1215
+ this._doSync();
1216
+ } else {
1217
+ this._invalidated = false;
1218
+ this._notifyPendings();
1219
+ }
1220
+ };
1221
+ }
1222
+
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);
1334
+ }
1335
+ if (currentSessionId) {
1336
+ sessions.push(currentSessionId);
1337
+ }
1338
+ let processSessionFile = async (sessionId) => {
1339
+ const expectedSessionFile = node_path.join(projectDir, `${sessionId}.jsonl`);
1340
+ let file;
1341
+ try {
1342
+ file = await promises.readFile(expectedSessionFile, "utf-8");
1343
+ } catch (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;
513
1354
  }
1355
+ let key = getMessageKey(parsed.data);
1356
+ if (processedMessages.has(key)) {
1357
+ continue;
1358
+ }
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
+ }
1366
+ }
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);
1375
+ }
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
+ })();
1393
+ });
1394
+ const intervalId = setInterval(() => {
1395
+ sync.invalidate();
1396
+ }, 3e3);
1397
+ return {
1398
+ refresh: () => sync.invalidate(),
1399
+ cleanup: () => {
1400
+ clearInterval(intervalId);
1401
+ currentSessionWatcherAbortController?.abort();
1402
+ },
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;
514
1472
  }
515
1473
  }
516
- await new Promise((resolve) => {
517
- messageResolve = resolve;
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
518
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
1549
+ });
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...");
1558
+ }
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")
519
1575
  }
520
- })();
521
- return async () => {
522
- exiting = true;
523
- if (messageResolve) {
524
- messageResolve();
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
+ }
525
1607
  }
526
- await promise;
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"
527
1618
  };
528
1619
  }
529
1620
 
530
- async function start(options = {}) {
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;
1653
+ }
1654
+ }
1655
+ /**
1656
+ * Check if interrupt is available
1657
+ */
1658
+ canInterrupt() {
1659
+ return !!this.interruptFn && !this.isInterrupting;
1660
+ }
1661
+ }
1662
+
1663
+ async function start(credentials, options = {}) {
531
1664
  const workingDirectory = process.cwd();
532
- const projectName = node_path.basename(workingDirectory);
533
1665
  const sessionTag = node_crypto.randomUUID();
534
- logger.info(`Starting happy session for project: ${projectName}`);
535
- const secret = await getOrCreateSecretKey();
536
- logger.info("Secret key loaded");
537
- const token = await authGetToken(secret);
538
- logger.info("Authenticated with handy server");
539
- const api = new ApiClient(token, secret);
540
- const response = await api.getOrCreateSession({ tag: sessionTag, metadata: { path: workingDirectory, host: os.hostname() } });
541
- logger.info(`Session created: ${response.session.id}`);
542
- const handyUrl = generateAppUrl(secret);
543
- displayQRCode(handyUrl);
544
- const session = api.session(response.session.id);
545
- const loopDestroy = startClaudeLoop({ path: workingDirectory }, session);
546
- const shutdown = async () => {
547
- logger.info("Shutting down...");
548
- await loopDestroy();
549
- await session.close();
550
- process.exit(0);
1666
+ const api = new ApiClient(credentials.token, credentials.secret);
1667
+ let state = {};
1668
+ let metadata = { path: workingDirectory, host: os.hostname() };
1669
+ const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
1670
+ logger.debug(`Session created: ${response.id}`);
1671
+ const session = api.session(response);
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);
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;
1724
+ });
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;
1734
+ }
1735
+ session.updateAgentState((currentState) => {
1736
+ let r = { ...currentState.requests };
1737
+ delete r[id];
1738
+ return {
1739
+ ...currentState,
1740
+ requests: r
1741
+ };
1742
+ });
1743
+ });
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);
1769
+ }
551
1770
  };
552
- process.on("SIGINT", shutdown);
553
- process.on("SIGTERM", shutdown);
554
- logger.info("Happy CLI is running. Press Ctrl+C to stop.");
555
- await new Promise(() => {
1771
+ await loop({
1772
+ path: workingDirectory,
1773
+ model: options.model,
1774
+ permissionMode: options.permissionMode,
1775
+ mcpServers: {
1776
+ "permission": {
1777
+ type: "http",
1778
+ url: permissionServer.url
1779
+ }
1780
+ },
1781
+ permissionPromptToolName: "mcp__permission__" + permissionServer.toolName,
1782
+ onThinking: (t) => {
1783
+ thinking = t;
1784
+ session.keepAlive(t);
1785
+ },
1786
+ session,
1787
+ onAssistantResult,
1788
+ interruptController
556
1789
  });
1790
+ clearInterval(pingInterval);
1791
+ process.exit(0);
557
1792
  }
558
1793
 
559
- const args = process.argv.slice(2);
560
- const options = {};
561
- let showHelp = false;
562
- let showVersion = false;
563
- for (let i = 0; i < args.length; i++) {
564
- const arg = args[i];
565
- if (arg === "-h" || arg === "--help") {
566
- showHelp = true;
567
- } else if (arg === "-v" || arg === "--version") {
568
- showVersion = true;
569
- } else if (arg === "-m" || arg === "--model") {
570
- options.model = args[++i];
571
- } else if (arg === "-p" || arg === "--permission-mode") {
572
- options.permissionMode = args[++i];
573
- } else {
574
- console.error(chalk.red(`Unknown argument: ${arg}`));
575
- process.exit(1);
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));
1821
+ }
1822
+
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);
1830
+ }
1831
+ });
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;
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;
576
1886
  }
1887
+ return decrypted;
577
1888
  }
578
- if (showHelp) {
579
- console.log(`
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(`
580
1934
  ${chalk.bold("happy")} - Claude Code session sharing
581
1935
 
582
1936
  ${chalk.bold("Usage:")}
583
1937
  happy [options]
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
584
1941
 
585
1942
  ${chalk.bold("Options:")}
586
1943
  -h, --help Show this help message
@@ -588,21 +1945,68 @@ ${chalk.bold("Options:")}
588
1945
  -m, --model <model> Claude model to use (default: sonnet)
589
1946
  -p, --permission-mode Permission mode: auto, default, or plan
590
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
+
591
1954
  ${chalk.bold("Examples:")}
592
1955
  happy Start a session with default settings
593
1956
  happy -m opus Use Claude Opus model
594
1957
  happy -p plan Use plan permission mode
1958
+ happy logout Logs out of your account and removes data directory
595
1959
  `);
596
- process.exit(0);
597
- }
598
- if (showVersion) {
599
- console.log("0.1.0");
600
- process.exit(0);
601
- }
602
- start(options).catch((error) => {
603
- console.error(chalk.red("Error:"), error.message);
604
- if (process.env.DEBUG) {
605
- console.error(error);
1960
+ process.exit(0);
1961
+ }
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
+ }
606
1983
  }
607
- process.exit(1);
608
- });
1984
+ })();
1985
+ async function cleanKey() {
1986
+ const happyDir = configuration.happyDir;
1987
+ if (!node_fs.existsSync(happyDir)) {
1988
+ console.log(chalk.yellow("No happy data directory found at:"), happyDir);
1989
+ return;
1990
+ }
1991
+ console.log(chalk.blue("Found happy data directory at:"), happyDir);
1992
+ console.log(chalk.yellow("\u26A0\uFE0F This will remove all authentication data and require reconnecting your phone."));
1993
+ const rl = node_readline.createInterface({
1994
+ input: process.stdin,
1995
+ output: process.stdout
1996
+ });
1997
+ const answer = await new Promise((resolve) => {
1998
+ rl.question(chalk.yellow("Are you sure you want to remove the happy data directory? (y/N): "), resolve);
1999
+ });
2000
+ rl.close();
2001
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
2002
+ try {
2003
+ node_fs.rmSync(happyDir, { recursive: true, force: true });
2004
+ console.log(chalk.green("\u2713 Happy data directory removed successfully"));
2005
+ console.log(chalk.blue("\u2139\uFE0F You will need to reconnect your phone on the next session"));
2006
+ } catch (error) {
2007
+ throw new Error(`Failed to remove data directory: ${error instanceof Error ? error.message : "Unknown error"}`);
2008
+ }
2009
+ } else {
2010
+ console.log(chalk.blue("Operation cancelled"));
2011
+ }
2012
+ }