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