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